From 2f138978307bb81652c48df2a82c8b94dc54e5e0 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 01:12:54 -0700 Subject: [PATCH] feat(tui): section archive context panes --- tui/tui.go | 167 ++++++++++++++++++++++++++++++++++++++++++------ tui/tui_test.go | 47 +++++++++++--- 2 files changed, 184 insertions(+), 30 deletions(-) diff --git a/tui/tui.go b/tui/tui.go index 76262aa..405caca 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -683,7 +683,7 @@ func newModel(opts Options) model { sourceLocation: strings.TrimSpace(opts.SourceLocation), layoutPreset: layout, sortMode: initialGroupSortMode(layout), - memberSortMode: sortNewest, + memberSortMode: initialMemberSortMode(layout), compactDetail: initialCompactDetail(layout), detailView: viewport.New(1, 1), } @@ -717,6 +717,17 @@ func initialCompactDetail(layout LayoutPreset) bool { } } +func initialMemberSortMode(layout LayoutPreset) sortMode { + switch layout { + case LayoutChat: + return sortDefault + case LayoutDocument: + return sortKind + default: + return sortDefault + } +} + func (m *model) applyInitialGroupMode() { if m.layoutPreset != LayoutChat || m.groupMode != groupByDefault || len(m.groups) > 1 { return @@ -752,6 +763,12 @@ type tableColumn struct { type tableRow []string +type contextRow struct { + ItemIndex int + Label string + Selectable bool +} + type archiveLayout struct { rows rect context rect @@ -1023,12 +1040,18 @@ func (m *model) selectMemberAt(rect rect, x, y int) (int, bool) { m.clearLastClick() return 0, false } - members := m.currentGroupMembers() + contextRows := m.currentContextRows() memberOffset := m.contextOffset + row - if row < 0 || row >= rowsViewportHeight(rect.h) || memberOffset < 0 || memberOffset >= len(members) { + if row < 0 || row >= rowsViewportHeight(rect.h) || memberOffset < 0 || memberOffset >= len(contextRows) { return 0, false } - itemIndex := members[memberOffset] + contextRow := contextRows[memberOffset] + if !contextRow.Selectable { + m.status = contextRow.Label + m.clearLastClick() + return memberOffset, false + } + itemIndex := contextRow.ItemIndex m.selectItemIndex(itemIndex) m.detailView.GotoTop() m.ensureVisible() @@ -1903,18 +1926,25 @@ func (m model) renderContextPane(rect rect) string { if !ok { return pane(m.memberPaneTitle(), "", []string{"No group selected."}, rect, focusContext, m.focus, contextPaneAccent) } - members := m.currentGroupMembers() + contextRows := m.currentContextRows() columns := m.memberColumns(width) - rows := m.memberTableRows(columns, members) - if len(members) == 0 { + rows := m.memberTableRows(columns, contextRows) + if len(contextRows) == 0 { rows = []tableRow{messageTableRow(columns, "no rows in group")} } selectedItem := m.currentItemIndex() tableView := renderStyledTable(columns, rows, m.contextOffset, height, width, contextPaneAccent, func(index int) lipgloss.Style { - if index < 0 || index >= len(members) { + if index < 0 || index >= len(contextRows) { return lipgloss.NewStyle().Foreground(lipgloss.Color(archiveTextFG)) } - return rowStyle(width, members[index] == selectedItem, m.focus == focusContext, itemInactive(m.items[members[index]])) + row := contextRows[index] + if !row.Selectable { + return sectionRowStyle(width) + } + if row.ItemIndex < 0 || row.ItemIndex >= len(m.items) { + return lipgloss.NewStyle().Foreground(lipgloss.Color(archiveTextFG)) + } + return rowStyle(width, row.ItemIndex == selectedItem, m.focus == focusContext, itemInactive(m.items[row.ItemIndex])) }) content := lipgloss.JoinVertical(lipgloss.Left, paneTitleWithLabelForWidth(m.memberPaneTitle(), focusContext, m.focus, m.memberPositionLabel()+" "+group.Title, width), tableView) return paneStyle(focusContext, m.focus, rect.w, rect.h, contextPaneAccent).Render(content) @@ -2365,7 +2395,7 @@ func (m model) focusedPageSize() int { } func (m model) maxContextOffset() int { - return maxInt(0, len(m.currentGroupMembers())-rowsViewportHeight(m.layout().context.h)) + return maxInt(0, len(m.currentContextRows())-rowsViewportHeight(m.layout().context.h)) } func (m *model) applyFilter() { @@ -2700,12 +2730,12 @@ func (m *model) ensureVisible() { } m.offset = clampInt(m.offset, 0, maxInt(len(m.groups)-1, 0)) memberPage := rowsViewportHeight(m.layout().context.h) - memberIndex := m.currentMemberOffset() - if memberIndex < m.contextOffset { - m.contextOffset = memberIndex + contextRowIndex := m.currentContextRowOffset() + if contextRowIndex < m.contextOffset { + m.contextOffset = contextRowIndex } - if memberIndex >= m.contextOffset+memberPage { - m.contextOffset = memberIndex - memberPage + 1 + if contextRowIndex >= m.contextOffset+memberPage { + m.contextOffset = contextRowIndex - memberPage + 1 } m.contextOffset = clampInt(m.contextOffset, 0, m.maxContextOffset()) } @@ -2851,6 +2881,78 @@ func (m model) currentGroupMembers() []int { return group.Members } +func (m model) currentContextRows() []contextRow { + return m.contextRowsForMembers(m.currentGroupMembers()) +} + +func (m model) contextRowsForMembers(members []int) []contextRow { + if len(members) == 0 { + return nil + } + switch m.layoutPreset { + case LayoutChat: + if m.memberSortMode == sortDefault || m.memberSortMode == sortNewest || m.memberSortMode == sortOldest { + return m.contextRowsWithSections(members, chatDateSectionLabel) + } + case LayoutDocument: + if m.memberSortMode == sortDefault || m.memberSortMode == sortKind { + return m.contextRowsWithSections(members, documentKindSectionLabel) + } + } + rows := make([]contextRow, 0, len(members)) + for _, itemIndex := range members { + rows = append(rows, contextRow{ItemIndex: itemIndex, Selectable: true}) + } + return rows +} + +func (m model) contextRowsWithSections(members []int, labelFor func(Item) string) []contextRow { + labels := make([]string, len(members)) + counts := map[string]int{} + for index, itemIndex := range members { + if itemIndex < 0 || itemIndex >= len(m.items) { + continue + } + label := labelFor(m.items[itemIndex]) + if strings.TrimSpace(label) == "" { + label = "OTHER" + } + labels[index] = label + counts[label]++ + } + rows := make([]contextRow, 0, len(members)+len(counts)) + previous := "" + for index, itemIndex := range members { + label := labels[index] + if label != "" && label != previous { + rows = append(rows, contextRow{Label: fmt.Sprintf("%s (%d)", label, counts[label])}) + previous = label + } + rows = append(rows, contextRow{ItemIndex: itemIndex, Selectable: true}) + } + return rows +} + +func chatDateSectionLabel(item Item) string { + if t, ok := itemSortTime(item); ok { + return strings.ToUpper(t.UTC().Format("2006-01-02 Mon")) + } + return "UNDATED" +} + +func documentKindSectionLabel(item Item) string { + switch strings.ToLower(strings.TrimSpace(itemKind(item))) { + case "database", "collection": + return "DATABASES" + case "page": + return "PAGES" + case "block": + return "BLOCKS" + default: + return strings.ToUpper(firstNonEmpty(itemKind(item), "items")) + } +} + func (m model) currentMemberOffset() int { itemIndex := m.currentItemIndex() members := m.currentGroupMembers() @@ -2862,6 +2964,20 @@ func (m model) currentMemberOffset() int { return 0 } +func (m model) currentContextRowOffset() int { + itemIndex := m.currentItemIndex() + if itemIndex < 0 { + return 0 + } + rows := m.currentContextRows() + for index, row := range rows { + if row.Selectable && row.ItemIndex == itemIndex { + return index + } + } + return 0 +} + func (m *model) selectItemIndex(itemIndex int) { for index, filteredIndex := range m.filtered { if filteredIndex == itemIndex { @@ -3350,13 +3466,17 @@ func (m model) groupTableRows(columns []tableColumn) []tableRow { return rows } -func (m model) memberTableRows(columns []tableColumn, members []int) []tableRow { - rows := make([]tableRow, 0, len(members)) - for _, itemIndex := range members { - if itemIndex < 0 || itemIndex >= len(m.items) { +func (m model) memberTableRows(columns []tableColumn, contextRows []contextRow) []tableRow { + rows := make([]tableRow, 0, len(contextRows)) + for _, contextRow := range contextRows { + if !contextRow.Selectable { + rows = append(rows, messageTableRow(columns, contextRow.Label)) continue } - item := m.items[itemIndex] + if contextRow.ItemIndex < 0 || contextRow.ItemIndex >= len(m.items) { + continue + } + item := m.items[contextRow.ItemIndex] title := displayRowTitle(item.Title) if item.Depth > 0 { title = strings.Repeat(" ", minInt(item.Depth, 6)) + "-> " + title @@ -5052,6 +5172,13 @@ func rowStyle(width int, selected bool, focused bool, inactive bool) lipgloss.St Background(lipgloss.Color(archiveActiveRowBG)) } +func sectionRowStyle(width int) lipgloss.Style { + return lipgloss.NewStyle(). + Width(width). + Foreground(lipgloss.Color(archiveSubtleAccentFG)). + Bold(true) +} + func itemInactive(item Item) bool { for _, value := range []string{ fieldValue(item, "status"), diff --git a/tui/tui_test.go b/tui/tui_test.go index ff95572..5d30f60 100644 --- a/tui/tui_test.go +++ b/tui/tui_test.go @@ -744,7 +744,7 @@ func TestFooterControlsPrioritizeGitcrawlMuscleMemory(t *testing.T) { } } -func TestChatMembersDefaultToNewestFirstLikeGitcrawl(t *testing.T) { +func TestChatMembersDefaultToChronologicalSectionsLikeGitcrawl(t *testing.T) { m := newModel(Options{ Title: "slacrawl archive", Layout: LayoutChat, @@ -757,13 +757,17 @@ func TestChatMembersDefaultToNewestFirstLikeGitcrawl(t *testing.T) { if len(members) != 2 { t.Fatalf("members = %#v", members) } - if got := m.items[members[0]].ID; got != "new" { - t.Fatalf("first member = %q, want newest message first", got) - } - m.setMemberSortMode(sortOldest) - members = m.currentGroupMembers() if got := m.items[members[0]].ID; got != "old" { - t.Fatalf("oldest sort first member = %q, want oldest message first", got) + t.Fatalf("first member = %q, want chronological message first", got) + } + contextRows := m.currentContextRows() + if len(contextRows) != 3 || contextRows[0].Selectable || !strings.Contains(contextRows[0].Label, "2026-05-01") { + t.Fatalf("chat context rows should start with a date section: %#v", contextRows) + } + m.setMemberSortMode(sortNewest) + members = m.currentGroupMembers() + if got := m.items[members[0]].ID; got != "new" { + t.Fatalf("newest sort first member = %q, want newest message first", got) } } @@ -781,7 +785,8 @@ func TestChatMemberColumnsExposeThreadState(t *testing.T) { if !strings.Contains(header, "rel") { t.Fatalf("chat member header should expose relation column:\n%s", header) } - rows := m.memberTableRows(columns, m.currentGroupMembers()) + m.setMemberSortMode(sortTitle) + rows := m.memberTableRows(columns, m.currentContextRows()) rendered := stripANSI(strings.Join([]string{ renderTableRow(columns, rows[0], 52, lipgloss.NewStyle()), renderTableRow(columns, rows[1], 52, lipgloss.NewStyle()), @@ -1429,7 +1434,7 @@ func TestGitcrawlKeymapCyclesGroupAndMemberSort(t *testing.T) { } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) m = updated.(model) - if m.menuOpen || m.memberSortMode != sortOldest || !strings.Contains(m.status, "Member sort: oldest") { + if m.menuOpen || m.memberSortMode != sortNewest || !strings.Contains(m.status, "Member sort: newest") { t.Fatalf("m should cycle member sort, menu=%v member=%v status=%q", m.menuOpen, m.memberSortMode, m.status) } } @@ -1580,7 +1585,7 @@ func TestChatExplorerGroupsChannelsAndListsMessages(t *testing.T) { } m.focus = focusContext item, ok := m.selectedItem() - if !ok || item.Title != "second" { + if !ok || item.Title != "first" { t.Fatalf("selected member = %#v ok=%v", item, ok) } } @@ -1681,6 +1686,28 @@ func TestDocumentContextColumnsAvoidEmptyChatAuthorSlot(t *testing.T) { } } +func TestDocumentContextStartsWithKindSections(t *testing.T) { + m := newModel(Options{ + Title: "notcrawl archive", + Layout: LayoutDocument, + Items: []Item{ + Row{Kind: "page", ParentID: "Marketing", Title: "Gideon's SF Events", UpdatedAt: "2026-05-01T17:52:33Z"}.ItemForLayout(LayoutDocument), + Row{Kind: "database", ParentID: "Marketing", Title: "Launch database", UpdatedAt: "2026-05-01T16:00:00Z"}.ItemForLayout(LayoutDocument), + }, + }) + if m.memberSortMode != sortKind { + t.Fatalf("document member sort = %v, want kind", m.memberSortMode) + } + rows := m.currentContextRows() + if len(rows) != 4 || rows[0].Selectable || rows[0].Label != "DATABASES (1)" || rows[2].Selectable || rows[2].Label != "PAGES (1)" { + t.Fatalf("document context rows should section databases and pages: %#v", rows) + } + view := stripANSI(m.View()) + if !strings.Contains(view, "DATABASES") || !strings.Contains(view, "PAGES") { + t.Fatalf("document view missing section rows:\n%s", view) + } +} + func TestDocumentExplorerCyclesGroupViews(t *testing.T) { m := newModel(Options{ Title: "notcrawl archive",