fix(tui): sort panes and stabilize details

This commit is contained in:
Vincent Koc 2026-05-03 01:03:39 -07:00
parent abea97192e
commit dc97a95dd6
No known key found for this signature in database
3 changed files with 174 additions and 26 deletions

View File

@ -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`.

View File

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

View File

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