fix(tui): sort panes and stabilize details
This commit is contained in:
parent
abea97192e
commit
dc97a95dd6
@ -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`.
|
||||
|
||||
124
tui/tui.go
124
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
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user