fix(tui): improve archive detail panes
This commit is contained in:
parent
6a8f3e0ff7
commit
cd454a7038
197
tui/tui.go
197
tui/tui.go
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user