feat(tui): section archive context panes

This commit is contained in:
Vincent Koc 2026-05-04 01:12:54 -07:00
parent a2fe832435
commit 2f13897830
No known key found for this signature in database
2 changed files with 184 additions and 30 deletions

View File

@ -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"),

View File

@ -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",