fix(tui): improve archive detail panes

This commit is contained in:
Vincent Koc 2026-05-03 00:28:47 -07:00
parent 6a8f3e0ff7
commit cd454a7038
No known key found for this signature in database
2 changed files with 246 additions and 29 deletions

View File

@ -1557,9 +1557,60 @@ func (m *model) buildGroups() {
}
return strings.ToLower(groups[i].Title) < strings.ToLower(groups[j].Title)
})
for index := range groups {
m.sortGroupMembers(groups[index].Members)
}
m.groups = groups
}
func (m model) sortGroupMembers(members []int) {
if len(members) < 2 {
return
}
sort.SliceStable(members, func(i, j int) bool {
left := m.items[members[i]]
right := m.items[members[j]]
switch m.sortMode {
case sortNewest:
if less, ok := compareItemTime(left, right, true); ok {
return less
}
case sortOldest:
if less, ok := compareItemTime(left, right, false); ok {
return less
}
case sortTitle:
if less, ok := compareStrings(left.Title, right.Title); ok {
return less
}
case sortKind:
if less, ok := compareStrings(itemKind(left), itemKind(right)); ok {
return less
}
case sortScope, sortContainer:
if less, ok := compareStrings(itemContainer(left), itemContainer(right)); ok {
return less
}
case sortAuthor:
if less, ok := compareStrings(itemAuthor(left), itemAuthor(right)); ok {
return less
}
default:
if m.layoutPreset == LayoutChat {
if less, ok := compareItemTime(left, right, false); ok {
return less
}
}
if m.layoutPreset == LayoutDocument {
if less, ok := compareItemTime(left, right, true); ok {
return less
}
}
}
return members[i] < members[j]
})
}
func (m model) groupFields(item Item) (key, title, kind, scope string) {
switch m.layoutPreset {
case LayoutChat:
@ -2167,18 +2218,20 @@ func (m model) chatDetailLines(item Item) []string {
if meta := chatMetaLine(item); meta != "" {
lines = append(lines, dim(meta))
}
message := strings.TrimSpace(firstNonEmpty(item.Text, item.Detail, item.Title))
if message != "" {
lines = append(lines, "", "Message")
lines = append(lines, indentWrappedLines(message, 2, 1000)...)
}
if thread := m.threadLines(item); len(thread) > 0 {
lines = append(lines, "", "Thread")
lines = append(lines, thread...)
} else if message := strings.TrimSpace(firstNonEmpty(item.Text, item.Detail, item.Title)); message != "" {
lines = append(lines, "", "Message")
lines = append(lines, chatBubbleLines(item, message, true)...)
}
if metadata := detailContextLines(item, false); len(metadata) > 0 {
lines = append(lines, "", "Metadata")
lines = append(lines, metadata...)
if properties := chatPropertyLines(item); len(properties) > 0 {
lines = append(lines, "", "Properties")
lines = append(lines, properties...)
}
if ids := chatIDLines(item); len(ids) > 0 {
lines = append(lines, "", "IDs")
lines = append(lines, ids...)
}
if len(lines) == 0 {
return []string{"No detail for this message."}
@ -2193,16 +2246,17 @@ func documentDetailLines(item Item) []string {
if meta := documentMetaLine(item); meta != "" {
lines = append(lines, dim(meta))
}
if url := strings.TrimSpace(item.URL); url != "" {
lines = append(lines, "url: "+url)
if location := documentLocationLines(item); len(location) > 0 {
lines = append(lines, "", "Location")
lines = append(lines, location...)
}
preview := documentPreview(item)
if preview != "" {
lines = append(lines, "", "Preview")
lines = append(lines, wrapLines(preview, 1000)...)
}
if metadata := detailContextLines(item, false); len(metadata) > 0 {
lines = append(lines, "", "Metadata")
if metadata := documentPropertyLines(item); len(metadata) > 0 {
lines = append(lines, "", "Properties")
lines = append(lines, metadata...)
}
if len(lines) == 0 {
@ -2253,6 +2307,27 @@ func documentPreview(item Item) string {
return detail
}
func documentLocationLines(item Item) []string {
return compactNonEmpty([]string{
fieldLine("parent", item.ParentID),
fieldLine("container", item.Container),
fieldLine("workspace", item.Scope),
fieldLine("url", item.URL),
})
}
func documentPropertyLines(item Item) []string {
lines := compactNonEmpty([]string{
fieldLine("kind", itemKind(item)),
fieldLine("source", item.Source),
fieldLine("created", shortTimestamp(item.CreatedAt)),
fieldLine("updated", shortTimestamp(item.UpdatedAt)),
fieldLine("id", item.ID),
})
lines = append(lines, compactFieldLines(item.Fields, "source", "space_id", "collection_id", "parent_table")...)
return lines
}
func looksLikeFieldDump(value string) bool {
lines := compactNonEmpty(strings.Split(value, "\n"))
if len(lines) == 0 {
@ -2331,17 +2406,8 @@ func (m model) threadLines(selected Item) []string {
if threadKey(item) != key {
continue
}
prefix := shortTimestamp(firstNonEmpty(item.CreatedAt, item.UpdatedAt))
if author := itemAuthor(item); author != "" {
prefix = strings.TrimSpace(prefix + " " + author)
}
text := firstNonEmpty(item.Text, item.Detail, item.Title)
if prefix != "" {
lines = append(lines, prefix)
lines = append(lines, indentWrappedLines(text, 2, 1000)...)
continue
}
lines = append(lines, text)
lines = append(lines, chatBubbleLines(item, text, item.ID == selected.ID)...)
}
if len(lines) <= 1 {
return nil
@ -2349,6 +2415,74 @@ func (m model) threadLines(selected Item) []string {
return lines
}
func chatBubbleLines(item Item, text string, selected bool) []string {
var lines []string
prefix := " "
if selected {
prefix = "> "
}
header := joinNonEmpty([]string{itemAuthor(item), shortTimestamp(firstNonEmpty(item.CreatedAt, item.UpdatedAt))}, " ")
if header != "" {
lines = append(lines, prefix+header)
}
body := indentWrappedLines(text, lipgloss.Width(prefix)+2, 1000)
if len(body) == 0 {
body = []string{strings.Repeat(" ", lipgloss.Width(prefix)+2) + "(empty)"}
}
lines = append(lines, body...)
return lines
}
func chatPropertyLines(item Item) []string {
return compactNonEmpty([]string{
fieldLine("channel", item.Container),
fieldLine("scope", item.Scope),
fieldLine("author", itemAuthor(item)),
fieldLine("kind", itemKind(item)),
fieldLine("source", item.Source),
fieldLine("created", shortTimestamp(item.CreatedAt)),
fieldLine("updated", shortTimestamp(item.UpdatedAt)),
fieldLine("attachments", fieldValue(item, "attachments")),
fieldLine("pinned", fieldValue(item, "pinned")),
fieldLine("subtype", fieldValue(item, "subtype")),
})
}
func chatIDLines(item Item) []string {
lines := compactNonEmpty([]string{
fieldLine("id", item.ID),
fieldLine("thread", threadKey(item)),
fieldLine("parent", item.ParentID),
})
lines = append(lines, compactFieldLines(item.Fields, "guild_id", "channel_id", "author_id", "user_id", "ts", "reply_to")...)
return lines
}
func compactFieldLines(fields map[string]string, keys ...string) []string {
if len(fields) == 0 {
return nil
}
lines := make([]string, 0, len(keys))
seen := make(map[string]struct{}, len(keys))
for _, key := range keys {
value := fieldValue(Item{Fields: fields}, key)
if line := fieldLine(key, value); line != "" {
lines = append(lines, line)
}
seen[strings.ToLower(strings.TrimSpace(key))] = struct{}{}
}
for key, value := range fields {
normalized := strings.ToLower(strings.TrimSpace(key))
if _, ok := seen[normalized]; ok {
continue
}
if line := fieldLine(key, value); line != "" {
lines = append(lines, line)
}
}
return lines
}
func threadKey(item Item) string {
for _, value := range []string{
fieldValue(item, "thread"),
@ -2370,7 +2504,7 @@ func rowListLine(item Item, width int) string {
if item.Depth > 0 {
title = strings.Repeat(" ", minInt(item.Depth, 6)) + "-> " + title
}
if width >= 34 && width < 68 {
if width >= 24 && width < 68 {
return compactRowListLine(item, title, width)
}
if width < 68 {
@ -2396,6 +2530,12 @@ func rowListLine(item Item, width int) string {
}
func compactRowListLine(item Item, title string, width int) string {
if width < 34 {
whenW := 5
titleW := maxInt(1, width-whenW-1)
return padCells(truncateCells(compactDate(item), whenW), whenW) + " " +
truncateCells(title, titleW)
}
whenW := 5
ageW := 4
authorW := minInt(maxInt(5, width/6), 9)
@ -2408,7 +2548,7 @@ func compactRowListLine(item Item, title string, width int) string {
func groupListLine(group itemGroup, width int) string {
width = maxInt(width, 1)
if width >= 32 && width < 68 {
if width >= 24 && width < 68 {
return compactGroupListLine(group, width)
}
if width < 68 {
@ -2447,7 +2587,7 @@ func compactGroupListLine(group itemGroup, width int) string {
func groupListHeader(width int, active sortMode) string {
width = maxInt(width, 1)
if width >= 32 && width < 68 {
if width >= 24 && width < 68 {
return tagStyle(width).Bold(true).Render(compactGroupListHeader(width, active))
}
if width < 68 {
@ -2516,7 +2656,7 @@ func compactGroupListHeader(width int, active sortMode) string {
func rowListHeader(width int, active sortMode) string {
width = maxInt(width, 1)
if width >= 34 && width < 68 {
if width >= 24 && width < 68 {
return tagStyle(width).Bold(true).Render(compactRowListHeader(width, active))
}
if width < 68 {
@ -2558,6 +2698,11 @@ func rowListHeader(width int, active sortMode) string {
}
func compactRowListHeader(width int, active sortMode) string {
if width < 34 {
whenW := 5
titleW := maxInt(1, width-whenW-1)
return padCells(truncateCells("DATE", whenW), whenW) + " " + truncateCells("TITLE", titleW)
}
timeLabel := "DATE"
age := "AGE"
author := "WHO"

View File

@ -159,6 +159,29 @@ func TestCompactWidthKeepsUsefulColumns(t *testing.T) {
}
}
func TestVeryNarrowPanesStillShowCompactColumns(t *testing.T) {
group := itemGroup{Kind: "channel", Count: 18, Latest: "2026-05-02T12:00:00Z", Title: "github-secure-session-4"}
groupHeader := groupListHeader(28, sortDefault)
groupLine := groupListLine(group, 28)
for _, want := range []string{"N", "AGE", "GROUP", "18", "github-secure"} {
if !strings.Contains(groupHeader+groupLine, want) {
t.Fatalf("narrow group columns missing %q:\n%s\n%s", want, groupHeader, groupLine)
}
}
rowHeader := rowListHeader(28, sortDefault)
rowLine := rowListLine(Item{
Title: "Im working on adding",
Author: "Vincent Koc",
CreatedAt: "2026-05-02T12:00:00Z",
}, 28)
for _, want := range []string{"DATE", "TITLE", "05-02", "Im working"} {
if !strings.Contains(rowHeader+rowLine, want) {
t.Fatalf("narrow row columns missing %q:\n%s\n%s", want, rowHeader, rowLine)
}
}
}
func TestQQuitsFromMenuAndFilterModes(t *testing.T) {
m := newModel(Options{Title: "archive", Items: []Item{{Title: "alpha"}}})
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}})
@ -209,13 +232,36 @@ func TestChatDetailUsesTranscriptShapeBeforeMetadata(t *testing.T) {
}
lines := m.detailLines(item)
joined := strings.Join(lines, "\n")
for _, want := range []string{"general bob", "Message", "reply message", "Thread", "alice", "root message", "Metadata", "parent=m1"} {
for _, want := range []string{"general bob", "Thread", "alice", "root message", "> bob", "reply message", "Properties", "IDs", "parent=m1"} {
if !strings.Contains(joined, want) {
t.Fatalf("chat detail missing %q:\n%s", want, joined)
}
}
if strings.Index(joined, "Message") > strings.Index(joined, "Metadata") {
t.Fatalf("chat detail should put readable content before metadata:\n%s", joined)
if strings.Index(joined, "Thread") > strings.Index(joined, "Properties") {
t.Fatalf("chat detail should put readable content before properties:\n%s", joined)
}
}
func TestChatMembersDefaultToChronologicalTranscriptOrder(t *testing.T) {
m := newModel(Options{
Title: "slacrawl archive",
Layout: LayoutChat,
Items: []Item{
Row{Kind: "message", ID: "new", Container: "general", Author: "bob", Title: "new", CreatedAt: "2026-05-01T10:02:00Z"}.ItemForLayout(LayoutChat),
Row{Kind: "message", ID: "old", Container: "general", Author: "alice", Title: "old", CreatedAt: "2026-05-01T10:00:00Z"}.ItemForLayout(LayoutChat),
},
})
members := m.currentGroupMembers()
if len(members) != 2 {
t.Fatalf("members = %#v", members)
}
if got := m.items[members[0]].ID; got != "old" {
t.Fatalf("first member = %q, want oldest message first", got)
}
m.setSortMode(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)
}
}
@ -654,6 +700,32 @@ func TestDocumentLayoutPrioritizesURLDetail(t *testing.T) {
}
}
func TestDocumentDetailUsesHeaderLocationPreviewProperties(t *testing.T) {
item := Row{
Source: "notion",
Kind: "page",
ID: "page1",
ParentID: "Launch docs",
Scope: "Workspace",
Container: "Roadmap DB",
Title: "Launch plan",
Text: "Ship the terminal UI cleanup.",
URL: "https://example.com/launch",
UpdatedAt: "2026-05-01T12:00:00Z",
Fields: map[string]string{"space_id": "space1", "parent_table": "collection"},
}.ItemForLayout(LayoutDocument)
lines := documentDetailLines(item)
joined := strings.Join(lines, "\n")
for _, want := range []string{"Launch plan", "Location", "parent=Launch docs", "container=Roadmap DB", "Preview", "Ship the terminal UI cleanup.", "Properties", "updated=2026-05-01 12:00"} {
if !strings.Contains(joined, want) {
t.Fatalf("document detail missing %q:\n%s", want, joined)
}
}
if strings.Index(joined, "Preview") > strings.Index(joined, "Properties") {
t.Fatalf("document preview should come before properties:\n%s", joined)
}
}
func TestModelFilterAndRender(t *testing.T) {
m := newModel(Options{
Title: "notcrawl",