diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cad317..112552a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,4 +10,5 @@ - Align `tui` pane chrome with `gitcrawl`: wide three-column layout, split/stacked resize modes, focused pane titles, compact row headers, click-to-sort headers, and floating right-click menus. - Make the shared `tui` explorer group-aware: left pane now shows channels/people or document parents, middle pane shows group members, and right pane shows detail/thread content. - Polish shared `tui` detail panes with chat-style transcript rendering, document location/preview sections, chronological chat member ordering, and compact columns in narrower tmux panes. +- Fix shared `tui` pane-specific header sorting, scope sorting, and stable detail metadata labels across crawl apps. - Rename the public package nouns to `config`, `store`, `snapshot`, `mirror`, `state`, `output`, `tui`, and `cache`. diff --git a/tui/tui.go b/tui/tui.go index 8e6753b..3b0d141 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -703,7 +703,7 @@ func (m *model) handleRightClick(x, y int) { func (m *model) selectGroupAt(rect rect, x, y int) { row := y - rect.y - 3 if row == -1 { - m.sortRowsFromHeader(x - rect.x - 2) + m.sortGroupsFromHeader(x-rect.x-2, paneContentWidth(rect.w)) return } if row < 0 || row >= rowsViewportHeight(rect.h) { @@ -719,7 +719,7 @@ func (m *model) selectGroupAt(rect rect, x, y int) { func (m *model) selectMemberAt(rect rect, x, y int) { row := y - rect.y - 3 if row == -1 { - m.sortRowsFromHeader(x - rect.x - 2) + m.sortMembersFromHeader(x-rect.x-2, paneContentWidth(rect.w)) return } members := m.currentGroupMembers() @@ -1587,7 +1587,11 @@ func (m model) sortGroupMembers(members []int) { if less, ok := compareStrings(itemKind(left), itemKind(right)); ok { return less } - case sortScope, sortContainer: + case sortScope: + if less, ok := compareStrings(itemScope(left), itemScope(right)); ok { + return less + } + case sortContainer: if less, ok := compareStrings(itemContainer(left), itemContainer(right)); ok { return less } @@ -2319,7 +2323,7 @@ func documentLocationLines(item Item) []string { func documentPropertyLines(item Item) []string { lines := compactNonEmpty([]string{ fieldLine("kind", itemKind(item)), - fieldLine("source", item.Source), + fieldLine("provider", item.Source), fieldLine("created", shortTimestamp(item.CreatedAt)), fieldLine("updated", shortTimestamp(item.UpdatedAt)), fieldLine("id", item.ID), @@ -2471,12 +2475,19 @@ func compactFieldLines(fields map[string]string, keys ...string) []string { } seen[strings.ToLower(strings.TrimSpace(key))] = struct{}{} } - for key, value := range fields { + remaining := make([]string, 0, len(fields)) + for key := range fields { normalized := strings.ToLower(strings.TrimSpace(key)) if _, ok := seen[normalized]; ok { continue } - if line := fieldLine(key, value); line != "" { + remaining = append(remaining, key) + } + sort.SliceStable(remaining, func(i, j int) bool { + return strings.ToLower(strings.TrimSpace(remaining[i])) < strings.ToLower(strings.TrimSpace(remaining[j])) + }) + for _, key := range remaining { + if line := fieldLine(key, fields[key]); line != "" { lines = append(lines, line) } } @@ -2725,10 +2736,39 @@ func compactRowListHeader(width int, active sortMode) string { truncateCells(title, titleW) } -func (m *model) sortRowsFromHeader(x int) { - width := paneContentWidth(m.layout().rows.w) - if width >= 34 && width < 68 { - m.sortCompactHeader(x, width) +func (m *model) sortGroupsFromHeader(x, width int) { + if width >= 24 && width < 68 { + m.sortCompactGroupHeader(x, width) + return + } + if width < 68 { + m.setSortMode(sortTitle) + return + } + kindW := minInt(maxInt(6, width/8), 10) + countW := minInt(maxInt(4, width/12), 7) + timeW := minInt(maxInt(12, width/5), 18) + ageW := minInt(maxInt(4, width/16), 7) + scopeW := minInt(maxInt(8, width/7), 16) + switch { + case x < kindW: + m.setSortMode(sortKind) + case x < kindW+1+countW: + return + case x < kindW+1+countW+1+timeW: + m.toggleTimeSort() + case x < kindW+1+countW+1+timeW+1+ageW: + m.toggleTimeSort() + case x < kindW+1+countW+1+timeW+1+ageW+1+scopeW: + m.setSortMode(sortScope) + default: + m.setSortMode(sortTitle) + } +} + +func (m *model) sortMembersFromHeader(x, width int) { + if width >= 24 && width < 68 { + m.sortCompactMemberHeader(x, width) return } if width < 68 { @@ -2744,17 +2784,9 @@ func (m *model) sortRowsFromHeader(x int) { case x < kindW: m.setSortMode(sortKind) case x < kindW+1+whenW: - if m.sortMode == sortNewest { - m.setSortMode(sortOldest) - } else { - m.setSortMode(sortNewest) - } + m.toggleTimeSort() case x < kindW+1+whenW+1+ageW: - if m.sortMode == sortNewest { - m.setSortMode(sortOldest) - } else { - m.setSortMode(sortNewest) - } + m.toggleTimeSort() case x < kindW+1+whenW+1+ageW+1+whereW: m.setSortMode(sortContainer) case x < kindW+1+whenW+1+ageW+1+whereW+1+authorW: @@ -2764,17 +2796,49 @@ func (m *model) sortRowsFromHeader(x int) { } } -func (m *model) sortCompactHeader(x int, width int) { +func (m *model) sortCompactGroupHeader(x, width int) { + countW := 3 + ageW := 4 + if width >= 44 { + kindW := 8 + switch { + case x < kindW: + m.setSortMode(sortKind) + case x < kindW+1+countW: + return + case x < kindW+1+countW+1+ageW: + m.toggleTimeSort() + default: + m.setSortMode(sortTitle) + } + return + } + switch { + case x < countW: + return + case x < countW+1+ageW: + m.toggleTimeSort() + default: + m.setSortMode(sortTitle) + } +} + +func (m *model) sortCompactMemberHeader(x, width int) { + if width < 34 { + whenW := 5 + if x < whenW { + m.toggleTimeSort() + return + } + m.setSortMode(sortTitle) + return + } whenW := 5 ageW := 4 authorW := minInt(maxInt(5, width/6), 9) switch { case x < whenW+1+ageW: - if m.sortMode == sortNewest { - m.setSortMode(sortOldest) - } else { - m.setSortMode(sortNewest) - } + m.toggleTimeSort() case x < whenW+1+ageW+1+authorW: m.setSortMode(sortAuthor) default: @@ -2782,6 +2846,14 @@ func (m *model) sortCompactHeader(x int, width int) { } } +func (m *model) toggleTimeSort() { + if m.sortMode == sortNewest { + m.setSortMode(sortOldest) + return + } + m.setSortMode(sortNewest) +} + func rowKind(item Item) string { if kind := itemKind(item); kind != "" { return kind diff --git a/tui/tui_test.go b/tui/tui_test.go index 0a921c4..5c68b96 100644 --- a/tui/tui_test.go +++ b/tui/tui_test.go @@ -265,6 +265,25 @@ func TestChatMembersDefaultToChronologicalTranscriptOrder(t *testing.T) { } } +func TestChatMembersScopeSortUsesScopeNotContainer(t *testing.T) { + m := newModel(Options{ + Title: "slacrawl archive", + Layout: LayoutChat, + Items: []Item{ + Row{Kind: "message", ID: "one", Scope: "z-workspace", Container: "general", Title: "one"}.ItemForLayout(LayoutChat), + Row{Kind: "message", ID: "two", Scope: "a-workspace", Container: "general", Title: "two"}.ItemForLayout(LayoutChat), + }, + }) + m.setSortMode(sortScope) + members := m.currentGroupMembers() + if len(members) != 2 { + t.Fatalf("members = %#v", members) + } + if got := m.items[members[0]].ID; got != "two" { + t.Fatalf("scope-sorted first member = %q, want a-workspace row", got) + } +} + func TestFocusedDetailPaneScrollsIndependently(t *testing.T) { m := newModel(Options{ Title: "discrawl archive", @@ -666,6 +685,40 @@ func TestClickingRowsHeaderSorts(t *testing.T) { } } +func TestClickingContextHeaderUsesContextPaneColumns(t *testing.T) { + m := newModel(Options{ + Title: "slacrawl archive", + Layout: LayoutChat, + Items: []Item{ + Row{Kind: "message", ID: "z", Container: "general", Author: "Zed", Title: "later", CreatedAt: "2026-05-02T10:00:00Z"}.ItemForLayout(LayoutChat), + Row{Kind: "message", ID: "a", Container: "general", Author: "Amy", Title: "earlier", CreatedAt: "2026-05-02T09:00:00Z"}.ItemForLayout(LayoutChat), + }, + }) + m.width = 300 + m.height = 24 + layout := m.layout() + contextWidth := paneContentWidth(layout.context.w) + kindW := minInt(maxInt(5, contextWidth/10), 10) + whenW := minInt(maxInt(10, contextWidth/6), 16) + ageW := minInt(maxInt(4, contextWidth/16), 7) + whereW := minInt(maxInt(10, contextWidth/5), 22) + authorX := kindW + 1 + whenW + 1 + ageW + 1 + whereW + 1 + updated, _ := m.Update(tea.MouseMsg{ + X: layout.context.x + 2 + authorX, + Y: layout.context.y + 2, + Button: tea.MouseButtonLeft, + Action: tea.MouseActionPress, + }) + m = updated.(model) + if m.sortMode != sortAuthor { + t.Fatalf("sort mode = %v, want author", m.sortMode) + } + members := m.currentGroupMembers() + if got := m.items[members[0]].Author; got != "Amy" { + t.Fatalf("first author = %q, want Amy", got) + } +} + func TestRowStyleUsesSubtleSelectedPalette(t *testing.T) { selected := rowStyle(80, true, true) if fmt.Sprint(selected.GetForeground()) != archiveSelectedFG { @@ -726,6 +779,28 @@ func TestDocumentDetailUsesHeaderLocationPreviewProperties(t *testing.T) { } } +func TestDocumentDetailSeparatesProviderAndSource(t *testing.T) { + item := Row{ + Source: "notion", + Kind: "page", + ID: "page1", + Title: "Launch plan", + Fields: map[string]string{"source": "desktop", "zeta": "last", "alpha": "first"}, + }.ItemForLayout(LayoutDocument) + joined := strings.Join(documentDetailLines(item), "\n") + for _, want := range []string{"provider=notion", "source=desktop"} { + if !strings.Contains(joined, want) { + t.Fatalf("document detail missing %q:\n%s", want, joined) + } + } + if strings.Contains(joined, "source=notion") { + t.Fatalf("document detail should not duplicate provider as source:\n%s", joined) + } + if strings.Index(joined, "alpha=first") > strings.Index(joined, "zeta=last") { + t.Fatalf("field tail should be stable and sorted:\n%s", joined) + } +} + func TestModelFilterAndRender(t *testing.T) { m := newModel(Options{ Title: "notcrawl",