fix(tui): render details like gitcrawl

This commit is contained in:
Vincent Koc 2026-05-03 02:00:31 -07:00
parent 20e9cca1c4
commit 696b2015f0
No known key found for this signature in database
3 changed files with 283 additions and 33 deletions

View File

@ -13,4 +13,5 @@
- Fix shared `tui` pane-specific header sorting, scope sorting, and stable detail metadata labels across crawl apps.
- Render shared `tui` parent/member panes with gitcrawl-style table columns, row styling, pane-local header sorting, and a 24-line minimum layout.
- Use a gitcrawl-style viewport for `tui` detail panes so long threads and document previews scroll cleanly inside the focused pane.
- Render `tui` detail content with gitcrawl-style sections, rules, markdown-ish wrapping, and pane-width-aware chat/document previews.
- Rename the public package nouns to `config`, `store`, `snapshot`, `mirror`, `state`, `output`, `tui`, and `cache`.

View File

@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os"
"regexp"
"sort"
"strings"
"time"
@ -21,6 +22,13 @@ import (
var ErrNotTerminal = errors.New("terminal UI requires an interactive terminal")
var (
markdownHeadingRE = regexp.MustCompile(`^(#{1,6})\s+(.+)$`)
markdownLinkRE = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
markdownListRE = regexp.MustCompile(`^(\s*)([-*+]|\d+[.)])\s+(.+)$`)
terminalControlRE = regexp.MustCompile(`\x1b\[[0-9;:]*[A-Za-z]`)
)
const (
wheelScrollDelay = 16 * time.Millisecond
wheelMaxBufferedDelta = 6
@ -1093,7 +1101,7 @@ func (m model) renderDetailPane(rect rect) string {
if !ok {
return pane("Detail", "", []string{"No row selected."}, rect, focusDetail, m.focus, detailPaneAccent)
}
lines := m.detailLines(item)
lines := m.detailLinesForWidth(item, paneContentWidth(rect.w))
return m.renderDetailViewport(rect, lines)
}
@ -1107,7 +1115,8 @@ func (m *model) syncDetailViewport() {
if !ok {
return
}
m.configureDetailViewport(m.layout().detail, m.detailLines(item))
rect := m.layout().detail
m.configureDetailViewport(rect, m.detailLinesForWidth(item, paneContentWidth(rect.w)))
}
func (m *model) configureDetailViewport(rect rect, lines []string) {
@ -2238,7 +2247,7 @@ func (m model) memberTableRows(columns []tableColumn, members []int) []tableRow
case "kind":
row = append(row, rowKind(item))
case "time":
row = append(row, rowWhen(item))
row = append(row, rowTimeForColumn(item, column.Width))
case "age":
row = append(row, rowAge(item))
case "container":
@ -2300,17 +2309,17 @@ func memberColumns(width int, active sortMode) []tableColumn {
whenW := 5
titleW := maxInt(1, width-whenW-1)
return []tableColumn{
{Key: "time", Title: activeTimeLabel("date", active), Width: whenW},
{Key: "time", Title: activeTimeLabel("time", active), Width: whenW},
{Key: "title", Title: activeLabel("title", active == sortTitle), Width: titleW},
}
}
if width < 68 {
if width < 54 {
whenW := 5
ageW := 4
authorW := minInt(maxInt(5, width/6), 9)
titleW := maxInt(1, width-whenW-ageW-authorW-3)
return []tableColumn{
{Key: "time", Title: activeTimeLabel("date", active), Width: whenW},
{Key: "time", Title: activeTimeLabel("time", active), Width: whenW},
{Key: "age", Title: activeTimeLabel("age", active), Width: ageW},
{Key: "author", Title: activeLabel("who", active == sortAuthor), Width: authorW},
{Key: "title", Title: activeLabel("title", active == sortTitle), Width: titleW},
@ -2442,29 +2451,38 @@ func contextLines(item Item, width int) []string {
}
func (m model) detailLines(item Item) []string {
return m.detailLinesForWidth(item, 1000)
}
func (m model) detailLinesForWidth(item Item, width int) []string {
width = maxInt(20, width)
switch m.layoutPreset {
case LayoutChat:
return m.chatDetailLines(item)
return m.chatDetailLines(item, width)
case LayoutDocument:
return documentDetailLines(item)
return documentDetailLinesForWidth(item, width)
}
return genericDetailLines(item)
return genericDetailLinesForWidth(item, width)
}
func genericDetailLines(item Item) []string {
return genericDetailLinesForWidth(item, 1000)
}
func genericDetailLinesForWidth(item Item, width int) []string {
detail := strings.TrimSpace(item.Detail)
var lines []string
context := detailContextLines(item, true)
if len(context) > 0 {
lines = append(lines, "Context")
lines = append(lines, bold("Context"))
lines = append(lines, context...)
}
if detail == "" {
detail = item.Subtitle
}
if detail != "" {
lines = append(lines, "", "Content")
lines = append(lines, wrapLines(detail, 1000)...)
lines = append(lines, "", dim(tuiRule(width)), bold("Content"))
lines = append(lines, markdownLines(detail, width)...)
}
if len(lines) == 0 {
lines = append(lines, "", "No detail for this row.")
@ -2472,27 +2490,27 @@ func genericDetailLines(item Item) []string {
return lines
}
func (m model) chatDetailLines(item Item) []string {
func (m model) chatDetailLines(item Item, width int) []string {
var lines []string
if header := chatHeaderLine(item); header != "" {
lines = append(lines, header)
lines = append(lines, bold(header))
}
if meta := chatMetaLine(item); meta != "" {
lines = append(lines, dim(meta))
}
if thread := m.threadLines(item); len(thread) > 0 {
lines = append(lines, "", "Thread")
if thread := m.threadLines(item, width); len(thread) > 0 {
lines = append(lines, "", dim(tuiRule(width)), bold("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)...)
lines = append(lines, "", dim(tuiRule(width)), bold("Message"))
lines = append(lines, chatBubbleLines(item, message, true, width)...)
}
if properties := chatPropertyLines(item); len(properties) > 0 {
lines = append(lines, "", "Properties")
lines = append(lines, "", dim(tuiRule(width)), bold("Properties"))
lines = append(lines, properties...)
}
if ids := chatIDLines(item); len(ids) > 0 {
lines = append(lines, "", "IDs")
lines = append(lines, "", dim(tuiRule(width)), bold("IDs"))
lines = append(lines, ids...)
}
if len(lines) == 0 {
@ -2502,23 +2520,27 @@ func (m model) chatDetailLines(item Item) []string {
}
func documentDetailLines(item Item) []string {
return documentDetailLinesForWidth(item, 1000)
}
func documentDetailLinesForWidth(item Item, width int) []string {
var lines []string
title := firstNonEmpty(item.Title, item.ID, "Untitled")
lines = append(lines, title)
lines = append(lines, bold(title))
if meta := documentMetaLine(item); meta != "" {
lines = append(lines, dim(meta))
}
if location := documentLocationLines(item); len(location) > 0 {
lines = append(lines, "", "Location")
lines = append(lines, "", dim(tuiRule(width)), bold("Location"))
lines = append(lines, location...)
}
preview := documentPreview(item)
if preview != "" {
lines = append(lines, "", "Preview")
lines = append(lines, wrapLines(preview, 1000)...)
lines = append(lines, "", dim(tuiRule(width)), bold("Preview"))
lines = append(lines, markdownLines(preview, width)...)
}
if metadata := documentPropertyLines(item); len(metadata) > 0 {
lines = append(lines, "", "Properties")
lines = append(lines, "", dim(tuiRule(width)), bold("Properties"))
lines = append(lines, metadata...)
}
if len(lines) == 0 {
@ -2614,6 +2636,20 @@ func indentWrappedLines(value string, indent, width int) []string {
return out
}
func indentMarkdownLines(value string, indent, width int) []string {
prefix := strings.Repeat(" ", maxInt(0, indent))
raw := markdownLines(value, maxInt(8, width-indent))
out := make([]string, 0, len(raw))
for _, line := range raw {
if line == "" {
out = append(out, "")
continue
}
out = append(out, prefix+line)
}
return out
}
func detailContextLines(item Item, includeTitle bool) []string {
var lines []string
fields := []string{
@ -2654,7 +2690,7 @@ func detailContextLines(item Item, includeTitle bool) []string {
return lines
}
func (m model) threadLines(selected Item) []string {
func (m model) threadLines(selected Item, width int) []string {
key := threadKey(selected)
if key == "" {
return nil
@ -2669,7 +2705,7 @@ func (m model) threadLines(selected Item) []string {
continue
}
text := firstNonEmpty(item.Text, item.Detail, item.Title)
lines = append(lines, chatBubbleLines(item, text, item.ID == selected.ID)...)
lines = append(lines, chatBubbleLines(item, text, item.ID == selected.ID, width)...)
}
if len(lines) <= 1 {
return nil
@ -2677,7 +2713,7 @@ func (m model) threadLines(selected Item) []string {
return lines
}
func chatBubbleLines(item Item, text string, selected bool) []string {
func chatBubbleLines(item Item, text string, selected bool, width int) []string {
var lines []string
prefix := " "
if selected {
@ -2687,7 +2723,7 @@ func chatBubbleLines(item Item, text string, selected bool) []string {
if header != "" {
lines = append(lines, prefix+header)
}
body := indentWrappedLines(text, lipgloss.Width(prefix)+2, 1000)
body := indentMarkdownLines(text, lipgloss.Width(prefix)+2, width)
if len(body) == 0 {
body = []string{strings.Repeat(" ", lipgloss.Width(prefix)+2) + "(empty)"}
}
@ -2970,9 +3006,9 @@ 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)
return padCells(truncateCells("TIME", whenW), whenW) + " " + truncateCells("TITLE", titleW)
}
timeLabel := "DATE"
timeLabel := "TIME"
age := "AGE"
author := "WHO"
title := "TITLE"
@ -3068,6 +3104,13 @@ func rowWhen(item Item) string {
return ""
}
func rowTimeForColumn(item Item, width int) string {
if width <= 5 {
return compactDate(item)
}
return rowWhen(item)
}
func rowAge(item Item) string {
if t, ok := itemSortTime(item); ok {
return compactAge(time.Since(t))
@ -3245,6 +3288,104 @@ func dim(value string) string {
return lipgloss.NewStyle().Foreground(lipgloss.Color(archiveMutedFG)).Render(value)
}
func tuiRule(width int) string {
return strings.Repeat("-", minInt(72, maxInt(12, width)))
}
func markdownLines(value string, width int) []string {
if strings.TrimSpace(value) == "" {
return nil
}
width = maxInt(20, width)
var lines []string
inFence := false
blankRun := 0
for _, rawLine := range strings.Split(strings.ReplaceAll(value, "\r\n", "\n"), "\n") {
line := strings.TrimRight(stripTerminalControls(rawLine), " \t")
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
inFence = !inFence
lines = append(lines, dim("--- code ---"))
blankRun = 0
continue
}
if inFence {
lines = append(lines, dim(truncateCells(line, width)))
blankRun = 0
continue
}
if trimmed == "" {
blankRun++
if blankRun <= 1 {
lines = append(lines, "")
}
continue
}
blankRun = 0
if match := markdownHeadingRE.FindStringSubmatch(trimmed); match != nil {
lines = appendWrappedStyled(lines, "", renderInlineMarkdown(match[2]), width, bold)
continue
}
if strings.HasPrefix(trimmed, ">") {
quote := strings.TrimSpace(strings.TrimPrefix(trimmed, ">"))
lines = appendWrappedStyled(lines, "> ", renderInlineMarkdown(quote), width, dim)
continue
}
if match := markdownListRE.FindStringSubmatch(line); match != nil {
indent := match[1]
if lipgloss.Width(indent) > 4 {
indent = strings.Repeat(" ", 4)
}
lines = appendWrappedStyled(lines, indent+"- ", renderInlineMarkdown(match[3]), width, nil)
continue
}
lines = appendWrappedStyled(lines, "", renderInlineMarkdown(line), width, nil)
}
return trimTrailingBlankLines(lines)
}
func appendWrappedStyled(lines []string, prefix, value string, width int, styler func(string) string) []string {
contentWidth := maxInt(8, width-lipgloss.Width(prefix))
wrapped := wrapPlain(value, contentWidth)
if len(wrapped) == 0 {
return lines
}
continuation := strings.Repeat(" ", lipgloss.Width(prefix))
for index, line := range wrapped {
prefixForLine := prefix
if index > 0 {
prefixForLine = continuation
}
if styler != nil {
line = styler(line)
}
lines = append(lines, prefixForLine+line)
}
return lines
}
func renderInlineMarkdown(value string) string {
value = markdownLinkRE.ReplaceAllString(value, "$1 <$2>")
replacer := strings.NewReplacer(
"`", "",
"**", "",
"__", "",
"~~", "",
)
return strings.TrimSpace(replacer.Replace(value))
}
func stripTerminalControls(value string) string {
return terminalControlRE.ReplaceAllString(value, "")
}
func trimTrailingBlankLines(lines []string) []string {
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
return lines
}
func mutedStyle(width int) lipgloss.Style {
return lipgloss.NewStyle().
Foreground(lipgloss.Color(archiveMutedFG)).
@ -3372,6 +3513,40 @@ func wrap(value string, width int) string {
return b.String()
}
func wrapPlain(value string, width int) []string {
width = maxInt(20, width)
words := strings.Fields(value)
if len(words) == 0 {
return []string{""}
}
var lines []string
var line string
for _, word := range words {
if lipgloss.Width(word) > width {
if line != "" {
lines = append(lines, line)
line = ""
}
lines = append(lines, truncateCells(word, width))
continue
}
if lipgloss.Width(line)+1+lipgloss.Width(word) > width && line != "" {
lines = append(lines, line)
line = word
continue
}
if line == "" {
line = word
} else {
line += " " + word
}
}
if line != "" {
lines = append(lines, line)
}
return lines
}
func wrapLines(value string, width int) []string {
width = maxInt(width, 1)
var out []string

View File

@ -167,6 +167,32 @@ func TestViewUsesGitcrawlStylePaneTables(t *testing.T) {
}
}
func TestWideRenderFillsTerminalAndKeepsThreePaneColumns(t *testing.T) {
m := newModel(Options{
Title: "discrawl archive",
Layout: LayoutChat,
Items: []Item{
Row{Kind: "message", ID: "one", Scope: "guild", Container: "general", Author: "Amy", Title: "first update", CreatedAt: "2026-05-02T09:00:00Z"}.ItemForLayout(LayoutChat),
Row{Kind: "message", ID: "two", Scope: "guild", Container: "general", Author: "Zed", Title: "second update", CreatedAt: "2026-05-02T10:00:00Z"}.ItemForLayout(LayoutChat),
},
})
m.width = 220
m.height = 34
view := stripANSI(m.View())
lines := strings.Split(view, "\n")
if len(lines) != 34 {
t.Fatalf("rendered height = %d, want 34:\n%s", len(lines), view)
}
if len(lines[0]) != 220 || len(lines[len(lines)-1]) != 220 {
t.Fatalf("view did not fill terminal width: first=%d last=%d\n%s", len(lines[0]), len(lines[len(lines)-1]), view)
}
for _, want := range []string{"Channels / People", "Messages", "Thread", "type", "count", "latest", "age", "scope", "group", "kind", "time", "where", "author", "title"} {
if !strings.Contains(view, want) {
t.Fatalf("wide render missing %q:\n%s", want, view)
}
}
}
func TestCompactWidthKeepsUsefulColumns(t *testing.T) {
group := itemGroup{Kind: "channel", Count: 18, Latest: "2026-05-02T12:00:00Z", Title: "github-secure-session-4"}
groupHeader := groupListHeader(40, sortDefault)
@ -183,7 +209,7 @@ func TestCompactWidthKeepsUsefulColumns(t *testing.T) {
Author: "Vincent Koc",
CreatedAt: "2026-05-02T12:00:00Z",
}, 42)
for _, want := range []string{"DATE", "AGE", "WHO", "TITLE", "05-02", "Vinc", "Im working"} {
for _, want := range []string{"TIME", "AGE", "WHO", "TITLE", "05-02", "Vinc", "Im working"} {
if !strings.Contains(rowHeader+rowLine, want) {
t.Fatalf("compact row columns missing %q:\n%s\n%s", want, rowHeader, rowLine)
}
@ -206,7 +232,7 @@ func TestVeryNarrowPanesStillShowCompactColumns(t *testing.T) {
Author: "Vincent Koc",
CreatedAt: "2026-05-02T12:00:00Z",
}, 28)
for _, want := range []string{"DATE", "TITLE", "05-02", "Im working"} {
for _, want := range []string{"TIME", "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)
}
@ -273,6 +299,31 @@ func TestChatDetailUsesTranscriptShapeBeforeMetadata(t *testing.T) {
}
}
func TestChatDetailRendersMarkdownTranscriptLikeGitcrawl(t *testing.T) {
m := newModel(Options{
Title: "discrawl archive",
Layout: LayoutChat,
Items: []Item{
Row{Kind: "message", ID: "m1", Container: "general", Author: "alice", Title: "root", Text: "# Plan\n- ship columns\n- polish [preview](https://example.com)", CreatedAt: "2026-05-01T10:00:00Z"}.ItemForLayout(LayoutChat),
Row{Kind: "message", ID: "m2", ParentID: "m1", Container: "general", Author: "bob", Title: "reply", Text: "> agreed\n`done`", CreatedAt: "2026-05-01T10:01:00Z"}.ItemForLayout(LayoutChat),
},
})
m.selectItemIndex(1)
item, ok := m.selectedItem()
if !ok {
t.Fatal("missing selected item")
}
joined := stripANSI(strings.Join(m.detailLinesForWidth(item, 52), "\n"))
for _, want := range []string{"Plan", "- ship columns", "polish preview <https://example.com>", "> agreed", "done", "Properties", "IDs"} {
if !strings.Contains(joined, want) {
t.Fatalf("markdown chat detail missing %q:\n%s", want, joined)
}
}
if strings.Contains(joined, "# Plan") || strings.Contains(joined, "`done`") {
t.Fatalf("chat detail should render markdown-ish text, not raw markdown:\n%s", joined)
}
}
func TestChatMembersDefaultToChronologicalTranscriptOrder(t *testing.T) {
m := newModel(Options{
Title: "slacrawl archive",
@ -807,6 +858,29 @@ func TestDocumentDetailUsesHeaderLocationPreviewProperties(t *testing.T) {
}
}
func TestDocumentDetailRendersMarkdownPreviewLikeGitcrawl(t *testing.T) {
item := Row{
Source: "notion",
Kind: "page",
ID: "page1",
ParentID: "Launch docs",
Scope: "Workspace",
Container: "Roadmap DB",
Title: "Launch plan",
Text: "# Checklist\n- wire panes\n- review [spec](https://example.com/spec)\n> keep it readable",
UpdatedAt: "2026-05-01T12:00:00Z",
}.ItemForLayout(LayoutDocument)
joined := stripANSI(strings.Join(documentDetailLinesForWidth(item, 56), "\n"))
for _, want := range []string{"Launch plan", "Checklist", "- wire panes", "review spec <https://example.com/spec>", "> keep it readable", "Properties"} {
if !strings.Contains(joined, want) {
t.Fatalf("document detail missing %q:\n%s", want, joined)
}
}
if strings.Contains(joined, "# Checklist") {
t.Fatalf("document detail should render markdown-ish headings:\n%s", joined)
}
}
func TestDocumentDetailSeparatesProviderAndSource(t *testing.T) {
item := Row{
Source: "notion",