diff --git a/internal/logging/writer.go b/internal/logging/writer.go index 14ffd753..c8a638df 100644 --- a/internal/logging/writer.go +++ b/internal/logging/writer.go @@ -35,8 +35,9 @@ func (w *writer) Write(p []byte) (int, error) { msg.Message = string(d.Value()) default: msg.Attributes = append(msg.Attributes, Attr{ - Key: string(d.Key()), - Value: string(d.Value()), + Key: string(d.Key()), + Value: string(d.Value()), + Common: resource.New(resource.LogAttr, msg), }) } } diff --git a/internal/pubsub/broker.go b/internal/pubsub/broker.go index 85c483a5..470d4d4b 100644 --- a/internal/pubsub/broker.go +++ b/internal/pubsub/broker.go @@ -17,7 +17,7 @@ type Logger interface { } // Broker allows clients to publish events and subscribe to events -type Broker[T any] struct { +type Broker[T resource.Resource] struct { subs map[chan resource.Event[T]]struct{} // subscriptions mu sync.Mutex // sync access to map done chan struct{} // close when broker is shutting down @@ -25,7 +25,7 @@ type Broker[T any] struct { } // NewBroker constructs a pub/sub broker. -func NewBroker[T any](logger Logger) *Broker[T] { +func NewBroker[T resource.Resource](logger Logger) *Broker[T] { b := &Broker[T]{ subs: make(map[chan resource.Event[T]]struct{}), done: make(chan struct{}), diff --git a/internal/resource/event.go b/internal/resource/event.go index 1017d443..23970374 100644 --- a/internal/resource/event.go +++ b/internal/resource/event.go @@ -11,7 +11,7 @@ type ( EventType string // Event represents an event in the lifecycle of a resource - Event[T any] struct { + Event[T Resource] struct { Type EventType Payload T } @@ -20,7 +20,3 @@ type ( Publish(EventType, T) } ) - -func NewEvent[T any](t EventType, payload T) Event[T] { - return Event[T]{Type: t, Payload: payload} -} diff --git a/internal/tui/logs/model.go b/internal/tui/logs/model.go index a1442e8b..fa111012 100644 --- a/internal/tui/logs/model.go +++ b/internal/tui/logs/model.go @@ -46,28 +46,29 @@ func (mm *Maker) Make(id resource.ID, width, height int) (tea.Model, error) { valueColumn.Key: attr.Value, } } - items := map[resource.ID]logging.Attr{ - resource.NewID(resource.LogAttr): { - Key: timeAttrKey, - Value: msg.Time.Format(timeFormat), - }, - resource.NewID(resource.LogAttr): { - Key: messageAttrKey, - Value: msg.Message, - }, - resource.NewID(resource.LogAttr): { - Key: levelAttrKey, - Value: coloredLogLevel(msg.Level), - }, - } - for _, attr := range msg.Attributes { - items[resource.NewID(resource.LogAttr)] = attr - } table := table.New(columns, renderer, width, height, table.WithSortFunc(byAttribute), table.WithSelectable[logging.Attr](false), ) - table.SetItems(items) + items := []logging.Attr{ + { + Key: timeAttrKey, + Value: msg.Time.Format(timeFormat), + Common: resource.New(resource.LogAttr, resource.GlobalResource), + }, + { + Key: messageAttrKey, + Value: msg.Message, + Common: resource.New(resource.LogAttr, resource.GlobalResource), + }, + { + Key: levelAttrKey, + Value: coloredLogLevel(msg.Level), + Common: resource.New(resource.LogAttr, resource.GlobalResource), + }, + } + items = append(items, msg.Attributes...) + table.SetItems(items...) return model{ msg: msg, diff --git a/internal/tui/module/list.go b/internal/tui/module/list.go index 525aca27..00cd0273 100644 --- a/internal/tui/module/list.go +++ b/internal/tui/module/list.go @@ -121,6 +121,12 @@ func (m list) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ) switch msg := msg.(type) { + case resource.Event[*task.Task]: + // Re-render module whenever a task event is received belonging to the + // module. + if mod := msg.Payload.Module(); mod != nil { + m.table.AddItems(mod.(*module.Module)) + } case tea.KeyMsg: switch { case key.Matches(msg, localKeys.ReloadModules): diff --git a/internal/tui/table/table.go b/internal/tui/table/table.go index 2d5a427c..842711ee 100644 --- a/internal/tui/table/table.go +++ b/internal/tui/table/table.go @@ -36,6 +36,7 @@ type Model[V resource.Resource] struct { rows []Row[V] rowRenderer RowRenderer[V] focus bool + rendered map[resource.ID]RenderedRow border lipgloss.Border borderColor lipgloss.TerminalColor @@ -47,7 +48,7 @@ type Model[V resource.Resource] struct { items map[resource.ID]V sortFunc SortFunc[V] - Selected map[resource.ID]V + selected map[resource.ID]V selectable bool filter textinput.Model @@ -92,13 +93,15 @@ func New[V resource.Resource](cols []Column, fn RowRenderer[V], width, height in filter.Prompt = "Filter: " m := Model[V]{ - rowRenderer: fn, - items: make(map[resource.ID]V), - Selected: make(map[resource.ID]V), - selectable: true, - focus: true, - filter: filter, - border: lipgloss.NormalBorder(), + rowRenderer: fn, + items: make(map[resource.ID]V), + rendered: make(map[resource.ID]RenderedRow), + selected: make(map[resource.ID]V), + selectable: true, + focus: true, + filter: filter, + border: lipgloss.NormalBorder(), + currentRowIndex: -1, } for _, fn := range opts { fn(&m) @@ -221,18 +224,13 @@ func (m Model[V]) Update(msg tea.Msg) (Model[V], tea.Cmd) { m.SelectRange() } case BulkInsertMsg[V]: - for _, ws := range msg { - m.items[ws.GetID()] = ws - } - m.SetItems(m.items) + m.AddItems(msg...) case resource.Event[V]: switch msg.Type { case resource.CreatedEvent, resource.UpdatedEvent: - m.items[msg.Payload.GetID()] = msg.Payload - m.SetItems(m.items) + m.AddItems(msg.Payload) case resource.DeletedEvent: - delete(m.items, msg.Payload.GetID()) - m.SetItems(m.items) + m.removeItem(msg.Payload) } case tea.WindowSizeMsg: m.setDimensions(msg.Width, msg.Height) @@ -250,7 +248,7 @@ func (m Model[V]) Update(msg tea.Msg) (Model[V], tea.Cmd) { m.filter.Blur() m.filter.SetValue("") // Unfilter table items - m.SetItems(m.items) + m.setRows(maps.Values(m.items)...) return m, nil case tui.FilterKeyMsg: // unwrap key and send to filter widget @@ -258,7 +256,7 @@ func (m Model[V]) Update(msg tea.Msg) (Model[V], tea.Cmd) { var cmd tea.Cmd m.filter, cmd = m.filter.Update(kmsg) // Filter table items - m.SetItems(m.items) + m.setRows(maps.Values(m.items)...) return m, cmd default: // Send any other messages to the filter if it is focused. @@ -361,10 +359,10 @@ func (m Model[V]) CurrentRow() (Row[V], bool) { // SelectedOrCurrent returns either the selected rows, or if there are no // selections, the current row func (m Model[V]) SelectedOrCurrent() []Row[V] { - if len(m.Selected) > 0 { - rows := make([]Row[V], len(m.Selected)) + if len(m.selected) > 0 { + rows := make([]Row[V], len(m.selected)) var i int - for k, v := range m.Selected { + for k, v := range m.selected { rows[i] = Row[V]{ID: k, Value: v} i++ } @@ -377,8 +375,8 @@ func (m Model[V]) SelectedOrCurrent() []Row[V] { } func (m Model[V]) SelectedOrCurrentIDs() []resource.ID { - if len(m.Selected) > 0 { - return maps.Keys(m.Selected) + if len(m.selected) > 0 { + return maps.Keys(m.selected) } if row, ok := m.CurrentRow(); ok { return []resource.ID{row.ID} @@ -395,10 +393,10 @@ func (m *Model[V]) ToggleSelection() { if !ok { return } - if _, isSelected := m.Selected[current.ID]; isSelected { - delete(m.Selected, current.ID) + if _, isSelected := m.selected[current.ID]; isSelected { + delete(m.selected, current.ID) } else { - m.Selected[current.ID] = current.Value + m.selected[current.ID] = current.Value } } @@ -412,10 +410,10 @@ func (m *Model[V]) ToggleSelectionByID(id resource.ID) { if !ok { return } - if _, isSelected := m.Selected[id]; isSelected { - delete(m.Selected, id) + if _, isSelected := m.selected[id]; isSelected { + delete(m.selected, id) } else { - m.Selected[id] = v + m.selected[id] = v } } @@ -426,7 +424,7 @@ func (m *Model[V]) SelectAll() { } for _, row := range m.rows { - m.Selected[row.ID] = row.Value + m.selected[row.ID] = row.Value } } @@ -436,7 +434,7 @@ func (m *Model[V]) DeselectAll() { return } - m.Selected = make(map[resource.ID]V) + m.selected = make(map[resource.ID]V) } // SelectRange selects a range of rows. If the current row is *below* a selected @@ -448,7 +446,7 @@ func (m *Model[V]) SelectRange() { if !m.selectable { return } - if len(m.Selected) == 0 { + if len(m.selected) == 0 { return } // Determine the first row to select, and the number of rows to select. @@ -460,7 +458,7 @@ func (m *Model[V]) SelectRange() { n = m.currentRowIndex - first + 1 break } - if _, ok := m.Selected[row.ID]; !ok { + if _, ok := m.selected[row.ID]; !ok { // Ignore unselected rows continue } @@ -475,98 +473,109 @@ func (m *Model[V]) SelectRange() { first = i + 1 } for _, row := range m.rows[first : first+n] { - m.Selected[row.ID] = row.Value + m.selected[row.ID] = row.Value } } -// SetItems sets new items for the table, overwriting existing items. If the -// table has a parent resource, then items that are not a descendent of that -// resource are skipped. -func (m *Model[V]) SetItems(items map[resource.ID]V) { - // Skip non-descendent items - if m.parent != nil { - for k, v := range items { - if !v.HasAncestor(m.parent.GetID()) { - delete(items, k) - } +// SetItems overwrites all existing items in the table with items. +func (m *Model[V]) SetItems(items ...V) { + m.items = make(map[resource.ID]V) + m.rendered = make(map[resource.ID]RenderedRow) + m.AddItems(items...) +} + +// AddItems idempotently adds items to the table, updating any items that exist +// on the table already. +func (m *Model[V]) AddItems(items ...V) { + for _, item := range items { + // Skip item if it's not a descendent of the table parent resource. + if m.parent != nil && !item.HasAncestor(m.parent.GetID()) { + return } + // Add/update item + m.items[item.GetID()] = item + // (Re-)render item's row. + m.rendered[item.GetID()] = m.rowRenderer(item) } + m.setRows(maps.Values(m.items)...) +} - // Overwrite existing items - m.items = items - - // Carry over existing selections. - selections := make(map[resource.ID]V) +func (m *Model[V]) removeItem(item V) { + delete(m.rendered, item.GetID()) + delete(m.items, item.GetID()) + delete(m.selected, item.GetID()) + for i, row := range m.rows { + if row.ID == item.GetID() { + // TODO: this might well produce a memory leak. See note: + // https://go.dev/wiki/SliceTricks#delete-without-preserving-order + m.rows = append(m.rows[:i], m.rows[i+1:]...) + break + } + } + if item.GetID() == m.currentRowID { + // If item being removed is the current row the make the row above it + // the new current row. (MoveUp also calls setStart, see below). + m.MoveUp(1) + } else { + // Removing item may well affect index of first visible row, so + // re-calculate just in case. + m.setStart() + } +} - // Overwrite existing rows +func (m *Model[V]) setRows(items ...V) { + selected := make(map[resource.ID]V) m.rows = make([]Row[V], 0, len(items)) - - // Convert items into rows, and carry across matching selections - for id, it := range items { - if m.filter.Value() != "" { - // Filter rows using row renderer. If the filter value is a - // substring of one of the rendered cells then add row. Otherwise, - // skip row. - // - // NOTE: it is highly inefficient to render every row, every time - // the user edits the filter value, particularly as the row renderer - // can make data lookups on each invocation. But there is no obvious - // alternative at present. - filterMatch := func() bool { - for _, row := range m.rowRenderer(it) { - // Remove ANSI escapes code before filtering - row = internal.StripAnsi(row) - if strings.Contains(row, m.filter.Value()) { - return true - } - } - return false - } - if !filterMatch() { - // Skip item not matching filter - continue - } + for _, item := range items { + if m.filterVisible() && !m.matchFilter(item.GetID()) { + // Skip item that doesn't match filter + continue } - m.rows = append(m.rows, Row[V]{ID: id, Value: it}) + m.rows = append(m.rows, Row[V]{ID: item.GetID(), Value: item}) if m.selectable { - if _, ok := m.Selected[id]; ok { - selections[id] = it + if _, ok := m.selected[item.GetID()]; ok { + selected[item.GetID()] = item } } } - + m.selected = selected // Sort rows in-place if m.sortFunc != nil { slices.SortFunc(m.rows, func(i, j Row[V]) int { return m.sortFunc(i.Value, j.Value) }) } - - // Overwrite existing selections, removing any that no longer have a - // corresponding item. - m.Selected = selections - - // Track item corresponding to the current row. + // Track current row index m.currentRowIndex = -1 for i, row := range m.rows { if row.ID == m.currentRowID { m.currentRowIndex = i + break } } // Check if item corresponding to current row doesn't exist, which occurs - // when items are removed, or the very first time the table is populated. If - // so, set current row to the first row, and reset its offset. - if m.currentRowIndex == -1 { + // the very first time the table is populated. If so, set current row to the + // first row. + if len(m.rows) > 0 && m.currentRowIndex == -1 { m.currentRowIndex = 0 - if len(m.rows) > 0 { - m.currentRowID = m.rows[m.currentRowIndex].ID - } + m.currentRowID = m.rows[m.currentRowIndex].ID } - - // Reset start index m.setStart() } +// matchFilter returns true if the item with the given ID matches the filter +// value. +func (m *Model[V]) matchFilter(id resource.ID) bool { + for _, col := range m.rendered[id] { + // Remove ANSI escapes code before filtering + stripped := internal.StripAnsi(col) + if strings.Contains(stripped, m.filter.Value()) { + return true + } + } + return false +} + // MoveUp moves the current row up by any number of rows. // It can not go above the first row. func (m *Model[V]) MoveUp(n int) { @@ -628,7 +637,7 @@ func (m *Model[V]) renderRow(rowIdx int) string { current bool selected bool ) - if _, ok := m.Selected[row.ID]; ok { + if _, ok := m.selected[row.ID]; ok { selected = true } if rowIdx == m.currentRowIndex { @@ -645,8 +654,8 @@ func (m *Model[V]) renderRow(rowIdx int) string { foreground = tui.SelectedForeground } - renderedCells := make([]string, len(m.cols)) - cells := m.rowRenderer(row.Value) + cells := m.rendered[row.ID] + styledCells := make([]string, len(m.cols)) for i, col := range m.cols { content := cells[col.Key] // Truncate content if it is wider than column @@ -661,11 +670,11 @@ func (m *Model[V]) renderRow(rowIdx int) string { boxed := lipgloss.NewStyle(). Padding(0, 1). Render(inlined) - renderedCells[i] = boxed + styledCells[i] = boxed } // Join cells together to form a row - renderedRow := lipgloss.JoinHorizontal(lipgloss.Left, renderedCells...) + renderedRow := lipgloss.JoinHorizontal(lipgloss.Left, styledCells...) // If current row or seleted rows, strip colors and apply background color if current || selected { @@ -706,10 +715,10 @@ func (m *Model[V]) Prune(fn func(value V) (task.Spec, error)) ([]task.Spec, erro // one or more selections: iterate thru and prune accordingly. var ( ids []task.Spec - before = len(m.Selected) + before = len(m.selected) pruned int ) - for k, v := range m.Selected { + for k, v := range m.selected { spec, err := fn(v) if err != nil { // De-select diff --git a/internal/tui/table/table_test.go b/internal/tui/table/table_test.go index 3939f069..e176cc3f 100644 --- a/internal/tui/table/table_test.go +++ b/internal/tui/table/table_test.go @@ -17,15 +17,6 @@ var ( resource3 = testResource{n: 3, Common: resource.New(resource.Workspace, resource.GlobalResource)} resource4 = testResource{n: 4, Common: resource.New(resource.Workspace, resource.GlobalResource)} resource5 = testResource{n: 5, Common: resource.New(resource.Workspace, resource.GlobalResource)} - - testItems = map[resource.ID]testResource{ - resource0.ID: resource0, - resource1.ID: resource1, - resource2.ID: resource2, - resource3.ID: resource3, - resource4.ID: resource4, - resource5.ID: resource5, - } ) type testResource struct { @@ -47,7 +38,14 @@ func setupTest() Model[testResource] { return 1 }), ) - tbl.SetItems(testItems) + tbl.SetItems( + resource0, + resource1, + resource2, + resource3, + resource4, + resource5, + ) return tbl } @@ -65,8 +63,8 @@ func TestTable_ToggleSelection(t *testing.T) { tbl.ToggleSelection() - assert.Len(t, tbl.Selected, 1) - assert.Equal(t, resource0, tbl.Selected[resource0.ID]) + assert.Len(t, tbl.selected, 1) + assert.Equal(t, resource0, tbl.selected[resource0.ID]) } func TestTable_SelectRange(t *testing.T) { @@ -126,7 +124,7 @@ func TestTable_SelectRange(t *testing.T) { tbl.SelectRange() - got := maps.Keys(tbl.Selected) + got := maps.Keys(tbl.selected) slices.SortFunc(got, sortStrings) slices.SortFunc(tt.want, sortStrings) assert.Equal(t, tt.want, got) diff --git a/internal/tui/workspace/list.go b/internal/tui/workspace/list.go index eca3ecc9..d6d2d528 100644 --- a/internal/tui/workspace/list.go +++ b/internal/tui/workspace/list.go @@ -85,6 +85,20 @@ func (m list) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ) switch msg := msg.(type) { + case resource.Event[*module.Module]: + // Re-render workspaces belonging to updated module (the module's + // current workspace may have changed, which changes the value of the + // workspace's CURRENT column). + workspaces := m.Workspaces.List(workspace.ListOptions{ModuleID: msg.Payload.ID}) + for _, ws := range workspaces { + m.table.AddItems(ws) + } + case resource.Event[*task.Task]: + // Re-render workspace whenever a task event is received belonging to the + // workspace. + if ws := msg.Payload.Workspace(); ws != nil { + m.table.AddItems(ws.(*workspace.Workspace)) + } case tea.KeyMsg: switch { case key.Matches(msg, keys.Common.Delete): diff --git a/internal/tui/workspace/resource_list.go b/internal/tui/workspace/resource_list.go index a56e2d01..d3620148 100644 --- a/internal/tui/workspace/resource_list.go +++ b/internal/tui/workspace/resource_list.go @@ -17,6 +17,7 @@ import ( "github.com/leg100/pug/internal/tui/split" "github.com/leg100/pug/internal/tui/table" "github.com/leg100/pug/internal/workspace" + "golang.org/x/exp/maps" ) var resourceColumn = table.Column{ @@ -191,7 +192,7 @@ func (m resourceList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.state = (*state.State)(msg) - m.Table.SetItems(toTableItems(m.state)) + m.Table.SetItems(maps.Values(m.state.Resources)...) case resource.Event[*state.State]: if msg.Payload.WorkspaceID != m.workspace.GetID() { return m, nil @@ -200,7 +201,7 @@ func (m resourceList) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case resource.CreatedEvent, resource.UpdatedEvent: // Whenever state is created or updated, re-populate table with // resources. - m.Table.SetItems(toTableItems(msg.Payload)) + m.Table.SetItems(maps.Values(msg.Payload.Resources)...) m.state = msg.Payload } case tea.WindowSizeMsg: @@ -276,14 +277,6 @@ func (m resourceList) selectedOrCurrentAddresses() []state.ResourceAddress { return addrs } -func toTableItems(s *state.State) map[resource.ID]*state.Resource { - to := make(map[resource.ID]*state.Resource, len(s.Resources)) - for _, v := range s.Resources { - to[v.ID] = v - } - return to -} - func serialBreadcrumb(serial int64) string { return tui.TitleSerial.Render(fmt.Sprintf("%d", serial)) }