diff --git a/go.mod b/go.mod index 01eda3d..0c0a76a 100644 --- a/go.mod +++ b/go.mod @@ -17,20 +17,26 @@ require ( ) require ( + github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/aymanbagabas/go-udiff v0.4.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect github.com/charmbracelet/x/ansi v0.11.8 // indirect github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/jsonschema-go v0.4.3 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/openai/openai-go/v3 v3.50.0 // indirect @@ -44,9 +50,13 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + github.com/yuin/goldmark v1.7.8 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index e734d3c..23ea1cf 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,16 @@ charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ= charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q= github.com/RandomCodeSpace/rig v0.2.0 h1:VJNRC2fB3KuVYmVyQ86Au8qF1Sv5PGpUazkoJ1H5Nh4= github.com/RandomCodeSpace/rig v0.2.0/go.mod h1:+YcCXgc5DuA4COoaFiMBU5kNGEIk1tR7jNo9BDFUg7U= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= @@ -18,6 +26,8 @@ github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7 github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f h1:8CnFOYzrMArVN42jYaGvnBo3mxdONgt09fly+9B96GY= github.com/charmbracelet/x/exp/golden v0.0.0-20251109135125-8916d276318f/go.mod h1:V8n/g3qVKNxr2FR37Y+otCsMySvZr601T0C7coEP0bw= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260816001655-68d539dca504 h1:fDsPREsmS3KGgpqhk9e2s1Q1fTdW6n/9E9+jGpmuf7k= github.com/charmbracelet/x/exp/teatest/v2 v2.0.0-20260816001655-68d539dca504/go.mod h1:aRoQwQWmN9LBG2xi3sVByMFt2fdkPCagd0GAJ1qwOfw= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= @@ -30,6 +40,8 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -42,14 +54,20 @@ github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFe github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -80,16 +98,25 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= diff --git a/internal/tui/carddetail/markdown.go b/internal/tui/carddetail/markdown.go new file mode 100644 index 0000000..6a237d9 --- /dev/null +++ b/internal/tui/carddetail/markdown.go @@ -0,0 +1,399 @@ +package carddetail + +import ( + "regexp" + "slices" + "strings" + "unicode" + "unicode/utf8" +) + +type inlineKind uint8 + +const ( + inlineCode inlineKind = iota + inlineBold + inlineItalic + inlineLink + inlineAutoLink +) + +type inlineMatch struct { + start int + end int + priority int + kind inlineKind + text string + href string +} + +var ( + codePattern = regexp.MustCompile("\\x60([^\\x60\\r\\n]+)\\x60") + boldPattern = regexp.MustCompile("\\*\\*([^*\\r\\n]+)\\*\\*") +) + +// parityMarkdown reduces a description to the frozen web renderer's grammar +// before Glamour sees it. Glamour deliberately understands much more +// Markdown; escaping everything outside this allowlist keeps that extra syntax +// literal instead of quietly widening the product contract. +func parityMarkdown(source string) string { + lines := strings.Split(source, "\n") + out := make([]string, 0, len(lines)*2) + for i := 0; i < len(lines); i++ { + raw := lines[i] + if strings.HasPrefix(raw, strings.Repeat(string(rune(0x60)), 3)) { + var code []string + for i++; i < len(lines) && !strings.HasPrefix(lines[i], strings.Repeat(string(rune(0x60)), 3)); i++ { + code = append(code, lines[i]) + } + out = append(out, safeFence(code), strings.Join(code, "\n"), safeFence(code), "") + continue + } + + line, block := parityLine(raw) + out = append(out, line) + if !block { + out = append(out, "") + } + } + return strings.TrimRight(strings.Join(out, "\n"), "\n") +} + +func parityLine(raw string) (line string, listItem bool) { + if _, text, ok := heading(raw); ok { + return "**" + inlineMarkdown(text, true) + "**", false + } + if strings.HasPrefix(raw, "- ") { + return "- " + inlineMarkdown(raw[2:], false), true + } + if markerEnd, textStart := orderedPrefix(raw); markerEnd > 0 { + // Keep the source ordinal literal. Glamour renumbers adjacent ordered + // list items, while the frozen web renderer preserves every marker. + return escapeMarkdown(raw[:markerEnd]) + " " + inlineMarkdown(raw[textStart:], false), false + } + return inlineMarkdown(raw, false), false +} + +func heading(raw string) (int, string, bool) { + level := 0 + for level < len(raw) && level < 4 && raw[level] == '#' { + level++ + } + if level == 0 || level > 3 || level >= len(raw) || !jsWhitespace(firstRune(raw[level:])) { + return 0, "", false + } + textStart := level + for textStart < len(raw) { + r, size := utf8.DecodeRuneInString(raw[textStart:]) + if !jsWhitespace(r) { + break + } + textStart += size + } + if strings.ContainsAny(raw[textStart:], "\r\n\u2028\u2029") { + return 0, "", false + } + return level, raw[textStart:], true +} + +func orderedPrefix(raw string) (markerEnd, textStart int) { + digits := 0 + for digits < len(raw) && digits < 3 && raw[digits] >= '0' && raw[digits] <= '9' { + digits++ + } + if digits == 0 || digits+1 >= len(raw) || raw[digits] != '.' || !jsWhitespace(firstRune(raw[digits+1:])) { + return 0, 0 + } + textStart = digits + 1 + for textStart < len(raw) { + r, size := utf8.DecodeRuneInString(raw[textStart:]) + if !jsWhitespace(r) { + break + } + textStart += size + } + if strings.ContainsAny(raw[textStart:], "\r\n\u2028\u2029") { + return 0, 0 + } + return digits + 1, textStart +} + +func safeFence(lines []string) string { + longest := 2 + for _, line := range lines { + for _, run := range strings.FieldsFunc(line, func(r rune) bool { return r != '~' }) { + longest = max(longest, utf8.RuneCountInString(run)) + } + } + return strings.Repeat("~", longest+1) +} + +func inlineMarkdown(line string, insideHeading bool) string { + matches := inlineMatches(line) + var out strings.Builder + last := 0 + for _, match := range matches { + if match.start < last { + continue + } + out.WriteString(escapeMarkdown(line[last:match.start])) + switch match.kind { + case inlineCode: + out.WriteRune(0x60) + out.WriteString(match.text) + out.WriteRune(0x60) + case inlineBold: + if insideHeading { + out.WriteString(escapeMarkdown(match.text)) + } else { + out.WriteString("**") + out.WriteString(escapeMarkdown(match.text)) + out.WriteString("**") + } + case inlineItalic: + out.WriteByte('*') + out.WriteString(escapeMarkdown(match.text)) + out.WriteByte('*') + case inlineLink: + out.WriteByte('[') + out.WriteString(escapeLinkLabel(match.text)) + out.WriteString("](") + out.WriteString(match.href) + out.WriteByte(')') + case inlineAutoLink: + out.WriteByte('<') + out.WriteString(match.href) + out.WriteByte('>') + } + last = match.end + } + out.WriteString(escapeMarkdown(line[last:])) + return out.String() +} + +func escapeLinkLabel(text string) string { + var out strings.Builder + for _, r := range text { + switch r { + case '\\', '[', ']', '*', '_', '~', '<', '>', 0x60: + out.WriteByte('\\') + } + out.WriteRune(r) + } + return out.String() +} + +func inlineMatches(line string) []inlineMatch { + var found []inlineMatch + found = appendPattern(found, line, codePattern, 0, inlineCode) + found = appendPattern(found, line, boldPattern, 1, inlineBold) + found = append(found, starItalicMatches(line)...) + found = append(found, underscoreMatches(line)...) + found = append(found, linkMatches(line)...) + found = append(found, bareURLMatches(line)...) + slices.SortFunc(found, func(left, right inlineMatch) int { + if left.start != right.start { + return left.start - right.start + } + return left.priority - right.priority + }) + return found +} + +func starItalicMatches(line string) []inlineMatch { + var found []inlineMatch + for cursor := 0; cursor < len(line); { + relative := strings.IndexByte(line[cursor:], '*') + if relative < 0 { + break + } + open := cursor + relative + closeRelative := strings.IndexByte(line[open+1:], '*') + if closeRelative < 0 { + break + } + close := open + 1 + closeRelative + content := line[open+1 : close] + if content != "" && !jsWhitespace(firstRune(content)) { + found = append(found, inlineMatch{ + start: open, end: close + 1, priority: 2, kind: inlineItalic, text: content, + }) + cursor = close + 1 + continue + } + cursor = open + 1 + } + return found +} + +func appendPattern(found []inlineMatch, line string, pattern *regexp.Regexp, priority int, kind inlineKind) []inlineMatch { + for _, index := range pattern.FindAllStringSubmatchIndex(line, -1) { + found = append(found, inlineMatch{ + start: index[0], end: index[1], priority: priority, kind: kind, + text: line[index[2]:index[3]], + }) + } + return found +} + +func underscoreMatches(line string) []inlineMatch { + var found []inlineMatch + for start := 0; start < len(line); { + relative := strings.IndexByte(line[start:], '_') + if relative < 0 { + break + } + open := start + relative + closeRelative := strings.IndexByte(line[open+1:], '_') + if closeRelative < 0 { + break + } + close := open + 1 + closeRelative + content := line[open+1 : close] + if content != "" && !jsWhitespace(firstRune(content)) && !jsWhitespace(lastRune(content)) && + !wordBefore(line, open) && !wordAfter(line, close+1) { + found = append(found, inlineMatch{ + start: open, end: close + 1, priority: 2, kind: inlineItalic, text: content, + }) + start = close + 1 + continue + } + start = open + 1 + } + return found +} + +func linkMatches(line string) []inlineMatch { + var found []inlineMatch + for cursor := 0; cursor < len(line); { + startRelative := strings.IndexByte(line[cursor:], '[') + if startRelative < 0 { + break + } + start := cursor + startRelative + endRelative := strings.IndexByte(line[start+1:], ']') + if endRelative < 0 { + break + } + labelEnd := start + 1 + endRelative + if labelEnd == start+1 || labelEnd+2 >= len(line) || line[labelEnd+1] != '(' { + cursor = labelEnd + 1 + continue + } + hrefStart := labelEnd + 2 + if !strings.HasPrefix(line[hrefStart:], "http://") && !strings.HasPrefix(line[hrefStart:], "https://") { + cursor = labelEnd + 1 + continue + } + hrefEnd := hrefStart + for hrefEnd < len(line) && line[hrefEnd] != ')' { + r, size := utf8.DecodeRuneInString(line[hrefEnd:]) + if jsWhitespace(r) { + break + } + hrefEnd += size + } + if hrefEnd >= len(line) || line[hrefEnd] != ')' { + cursor = hrefEnd + 1 + continue + } + found = append(found, inlineMatch{ + start: start, end: hrefEnd + 1, priority: 3, kind: inlineLink, + text: line[start+1 : labelEnd], href: line[hrefStart:hrefEnd], + }) + cursor = hrefEnd + 1 + } + return found +} + +func bareURLMatches(line string) []inlineMatch { + var found []inlineMatch + for cursor := 0; cursor < len(line); { + start := nextURLStart(line, cursor) + if start < 0 { + break + } + end := start + for end < len(line) { + r, size := utf8.DecodeRuneInString(line[end:]) + if jsWhitespace(r) || strings.ContainsRune("<>()\"'`", r) { + break + } + end += size + } + url := strings.TrimRight(line[start:end], ".,;:!?") + found = append(found, inlineMatch{ + start: start, end: start + len(url), priority: 4, + kind: inlineAutoLink, text: url, href: url, + }) + cursor = max(end, start+1) + } + return found +} + +func nextURLStart(line string, cursor int) int { + http := strings.Index(line[cursor:], "http://") + https := strings.Index(line[cursor:], "https://") + switch { + case http < 0 && https < 0: + return -1 + case http < 0: + return cursor + https + case https < 0: + return cursor + http + default: + return cursor + min(http, https) + } +} + +func escapeMarkdown(text string) string { + var out strings.Builder + for _, r := range text { + if r >= '!' && r <= '~' && (unicode.IsPunct(r) || unicode.IsSymbol(r)) { + out.WriteByte('\\') + } + out.WriteRune(r) + } + return out.String() +} + +func firstRune(text string) rune { + r, _ := utf8.DecodeRuneInString(text) + return r +} + +func lastRune(text string) rune { + r, _ := utf8.DecodeLastRuneInString(text) + return r +} + +func wordBefore(text string, index int) bool { + if index == 0 { + return false + } + r, _ := utf8.DecodeLastRuneInString(text[:index]) + return asciiWord(r) +} + +func wordAfter(text string, index int) bool { + if index >= len(text) { + return false + } + r, _ := utf8.DecodeRuneInString(text[index:]) + return asciiWord(r) +} + +func asciiWord(r rune) bool { + return r == '_' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' +} + +func jsWhitespace(r rune) bool { + switch r { + case '\t', '\n', '\v', '\f', '\r', ' ', '\u00a0', '\u1680', '\u2000', '\u2001', '\u2002', '\u2003', + '\u2004', '\u2005', '\u2006', '\u2007', '\u2008', '\u2009', '\u200a', '\u2028', '\u2029', '\u202f', + '\u205f', '\u3000', '\ufeff': + return true + default: + return false + } +} diff --git a/internal/tui/carddetail/markdown_test.go b/internal/tui/carddetail/markdown_test.go new file mode 100644 index 0000000..cfd4e7f --- /dev/null +++ b/internal/tui/carddetail/markdown_test.go @@ -0,0 +1,203 @@ +package carddetail + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestParityMarkdownKeepsOnlyFrozenScope(t *testing.T) { + tick := string(rune(0x60)) + fence := strings.Repeat(tick, 3) + input := strings.Join([]string{ + "## Plan **now**", + "plain *italic* _also_ snake_case " + tick + "code" + tick + " [docs](https://example.com/x) https://example.com/y.", + "- bullet", + "12. ordered", + "#### not heading", + "> quote", + "~~strike~~", + "| table |", + "html", + fence + "go", + "raw **code**", + "~~~~", + fence, + }, "\n") + + got := parityMarkdown(input) + for _, want := range []string{ + "**Plan now**", + "*italic*", + "*also*", + "snake\\_case", + tick + "code" + tick, + "[docs](https://example.com/x)", + "", + "- bullet", + "12\\. ordered", + "\\#\\#\\#\\# not heading", + "\\> quote", + "\\~\\~strike\\~\\~", + "\\| table \\|", + "\\html\\<\\/b\\>", + "raw **code**\n~~~~\n", + } { + if !strings.Contains(got, want) { + t.Errorf("parity markdown missing %q:\n%s", want, got) + } + } +} + +func TestInlineMarkdownRejectsMalformedAndOverlappingSyntax(t *testing.T) { + tick := string(rune(0x60)) + input := "**bold " + tick + "wins" + tick + "** [bad](javascript:alert(1)) [empty]() [open](https://example.com snake_case _x_ * nope*" + got := inlineMarkdown(input, false) + for _, want := range []string{"**bold \\" + tick + "wins\\" + tick + "**", "\\[bad\\]", "\\[empty\\]", "snake\\_case", "*x*", "\\* nope\\*"} { + if !strings.Contains(got, want) { + t.Errorf("inline markdown missing %q: %s", want, got) + } + } + + if got := inlineMarkdown("**heading**", true); got != "heading" { + t.Fatalf("bold inside flattened heading = %q", got) + } +} + +func TestBlockAndBoundaryHelpers(t *testing.T) { + tests := []struct { + line string + heading int + markerEnd int + textStart int + }{ + {line: "# one", heading: 1}, + {line: "### three", heading: 3}, + {line: "##\t Plan", heading: 2}, + {line: "#### four"}, + {line: "#nospace"}, + {line: "1. one", markerEnd: 2, textStart: 3}, + {line: "123. many", markerEnd: 4, textStart: 5}, + {line: "12.\t many", markerEnd: 3, textStart: 6}, + {line: "1234. too many"}, + {line: ". none"}, + } + for _, tt := range tests { + level, _, _ := heading(tt.line) + if level != tt.heading { + t.Errorf("heading(%q) = %d, want %d", tt.line, level, tt.heading) + } + markerEnd, textStart := orderedPrefix(tt.line) + if markerEnd != tt.markerEnd || textStart != tt.textStart { + t.Errorf("orderedPrefix(%q) = (%d, %d), want (%d, %d)", tt.line, markerEnd, textStart, tt.markerEnd, tt.textStart) + } + } + + if got := safeFence([]string{"~", "~~~~", "plain"}); got != "~~~~~" { + t.Fatalf("safeFence = %q, want five tildes", got) + } + if firstRune("界x") != '界' || lastRune("x界") != '界' { + t.Fatal("rune helpers lost Unicode") + } + if !wordBefore("a_", 1) || wordBefore("_", 0) || !wordAfter("_a", 1) || wordAfter("_", 1) { + t.Fatal("word-boundary helpers returned the wrong result") + } +} + +func TestParityMarkdownAcceptsWebWhitespace(t *testing.T) { + got := parityMarkdown("##\t Plan\n12.\t item") + for _, want := range []string{"**Plan**", "12\\. item"} { + if !strings.Contains(got, want) { + t.Fatalf("parity markdown missing %q:\n%s", want, got) + } + } +} + +func TestRenderMarkdownPreservesSourceOrdinals(t *testing.T) { + got := ansi.Strip(renderMarkdown("0. zero\n3. three\n9. nine\n001. padded", 60)) + for _, want := range []string{"0. zero", "3. three", "9. nine", "001. padded"} { + if !strings.Contains(got, want) { + t.Errorf("rendered markdown missing source marker %q:\n%s", want, got) + } + } + lines := "\n" + strings.TrimSpace(got) + "\n" + if strings.Contains(lines, "\n4. three\n") || strings.Contains(lines, "\n1. padded\n") { + t.Fatalf("rendered markdown renumbered source ordinals:\n%s", got) + } +} + +func TestFrozenJavaScriptBoundaryParity(t *testing.T) { + got := inlineMarkdown("é_x_ _y_界 A_z_ _q_9", false) + want := "é*x* *y*界 A\\_z\\_ \\_q\\_9" + if got != want { + t.Fatalf("ASCII word-boundary parity = %q, want %q", got, want) + } + + nbsp := "\u00a0" + got = parityMarkdown("https://a.test/x" + nbsp + "tail") + if !strings.Contains(got, ""+nbsp+"tail") { + t.Fatalf("bare URL crossed JavaScript whitespace: %q", got) + } + got = inlineMarkdown("[bad](https://x.test/"+nbsp+"tail) [ok](https://ok.test)", false) + if strings.Contains(got, "[bad](") || !strings.Contains(got, "[ok](https://ok.test)") { + t.Fatalf("explicit-link whitespace parity = %q", got) + } + if _, _, ok := heading("#\u0085text"); ok { + t.Fatal("U+0085 incorrectly matched JavaScript whitespace") + } + if marker, _ := orderedPrefix("1.\u0085text"); marker != 0 { + t.Fatal("U+0085 incorrectly started an ordered item") + } +} + +func TestRenderMarkdownPreservesJavaScriptControlWhitespace(t *testing.T) { + got := ansi.Strip(renderMarkdown("*\vx*\n#\vheading\n1.\fitem", 60)) + for _, want := range []string{"* x*", "heading", "1. item"} { + if !strings.Contains(got, want) { + t.Fatalf("rendered control whitespace missing %q:\n%s", want, got) + } + } +} + +func TestNodeDerivedLineSeparatorVectors(t *testing.T) { + for _, tt := range []struct { + line string + headingText string + ordinalText string + }{ + {line: "#\u2028head", headingText: "head"}, + {line: "1.\u2029item", ordinalText: "item"}, + {line: "# head\u2028tail"}, + {line: "# head\u2029tail"}, + {line: "# head\u2028"}, + {line: "1. item\u2028tail"}, + {line: "1. item\u2029tail"}, + {line: "1. item\u2029"}, + } { + _, headingText, headingOK := heading(tt.line) + if headingOK != (tt.headingText != "") || headingText != tt.headingText { + t.Errorf("heading(%q) = (%q, %v), want (%q, %v)", tt.line, headingText, headingOK, tt.headingText, tt.headingText != "") + } + markerEnd, textStart := orderedPrefix(tt.line) + ordinalOK := markerEnd > 0 + ordinalText := "" + if ordinalOK { + ordinalText = tt.line[textStart:] + } + if ordinalOK != (tt.ordinalText != "") || ordinalText != tt.ordinalText { + t.Errorf("orderedPrefix(%q) = (%q, %v), want (%q, %v)", tt.line, ordinalText, ordinalOK, tt.ordinalText, tt.ordinalText != "") + } + } +} + +func TestParityMarkdownUnterminatedFenceAndEmptyInput(t *testing.T) { + fence := strings.Repeat(string(rune(0x60)), 3) + got := parityMarkdown(fence + "\ncode\n~~~") + if !strings.Contains(got, "~~~~\ncode\n~~~\n~~~~") { + t.Fatalf("unterminated fence was not preserved safely:\n%s", got) + } + if got := parityMarkdown(""); got != "" { + t.Fatalf("empty markdown = %q", got) + } +} diff --git a/internal/tui/carddetail/model.go b/internal/tui/carddetail/model.go new file mode 100644 index 0000000..6b3b637 --- /dev/null +++ b/internal/tui/carddetail/model.go @@ -0,0 +1,535 @@ +// Package carddetail renders the read-only full-card overlay for the TUI. +package carddetail + +import ( + "fmt" + "strings" + "time" + "unicode" + + tea "charm.land/bubbletea/v2" + "charm.land/glamour/v2" + "charm.land/glamour/v2/styles" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/store" +) + +const ( + maxPaneWidth = 92 + defaultWidth = 80 + defaultHeight = 24 +) + +// Reader is the store projection needed to enrich a board task for display. +type Reader interface { + Comments(user, taskRef string) ([]store.Comment, error) + TaskLinks(user, taskID string) (store.TaskLinks, error) + Tombstone(user, taskID string) (store.Tombstone, bool, error) +} + +type detailLoadedMsg struct { + taskID string + generation uint64 + comments []store.Comment + links store.TaskLinks + tombstone *store.Tombstone + commentsErr error + linksErr error + tombstoneErr error +} + +type markdownRenderer func(source string, width int) string + +// Model owns the overlay's task snapshot, enriched detail, and scroll state. +type Model struct { + reader Reader + user string + task board.Task + comments []store.Comment + links store.TaskLinks + tombstone *store.Tombstone + open bool + loading bool + reloadPending bool + commentsErr error + linksErr error + tombstoneErr error + scroll int + generation uint64 + width int + height int + renderMarkdown markdownRenderer + bodyLines []string + bodyWidth int +} + +// New creates a closed detail pane. A nil reader still shows board-resident +// task fields; enrichment is simply unavailable to lightweight model tests. +func New(reader Reader, user string) Model { + return Model{ + reader: reader, user: user, width: defaultWidth, height: defaultHeight, + renderMarkdown: renderMarkdown, + } +} + +// IsOpen reports whether the overlay currently owns input and rendering. +func (m Model) IsOpen() bool { return m.open } + +// TaskID returns the displayed task's durable ID, or empty while closed. +func (m Model) TaskID() string { + if !m.open { + return "" + } + return m.task.ID +} + +// Open resets the pane to task and returns the asynchronous enrichment load. +func (m *Model) Open(task board.Task) tea.Cmd { + m.task = task + m.comments = nil + m.links = store.TaskLinks{} + m.tombstone = nil + m.open = true + m.loading = false + m.reloadPending = false + m.commentsErr = nil + m.linksErr = nil + m.tombstoneErr = nil + m.scroll = 0 + if m.reader == nil { + m.rebuildBody() + return nil + } + return m.startLoad() +} + +// Refresh replaces the board-resident snapshot and serializes enrichment IO. +// Repeated refreshes during a load collapse into one successor for the latest +// task snapshot. +func (m *Model) Refresh(task board.Task) tea.Cmd { + if !m.open || task.ID != m.task.ID { + return nil + } + m.task = task + if m.reader == nil { + m.rebuildBody() + return nil + } + if m.loading { + m.reloadPending = true + m.rebuildBody() + return nil + } + return m.startLoad() +} + +// Close dismisses the pane and invalidates any in-flight result by clearing +// the current task identity. +func (m *Model) Close() { + m.generation++ + m.open = false + m.loading = false + m.reloadPending = false + m.task = board.Task{} + m.bodyLines = nil + m.bodyWidth = 0 + m.scroll = 0 +} + +// Resize updates the viewport used to bound persistent scroll state. +func (m *Model) Resize(width, height int) { + m.width = max(width, 1) + m.height = max(height, 1) + innerWidth, _, _ := paneGeometry(m.width, m.height) + if m.open && (m.bodyLines == nil || m.bodyWidth != innerWidth) { + m.rebuildBody() + return + } + m.clampScroll() +} + +// Update handles enrichment results and overlay scrolling. +func (m *Model) Update(message tea.Msg) tea.Cmd { + if !m.open { + return nil + } + switch msg := message.(type) { + case detailLoadedMsg: + if msg.taskID != m.task.ID || msg.generation != m.generation { + return nil + } + if m.reloadPending { + m.reloadPending = false + return m.startLoad() + } + m.loading = false + m.comments = msg.comments + m.links = msg.links + m.tombstone = msg.tombstone + m.commentsErr = msg.commentsErr + m.linksErr = msg.linksErr + m.tombstoneErr = msg.tombstoneErr + m.rebuildBody() + case tea.KeyPressMsg: + switch msg.String() { + case "up", "k", "pgup": + m.scroll = max(0, m.scroll-scrollAmount(msg.String())) + case "down", "j", "pgdown": + m.scroll += scrollAmount(msg.String()) + case "home", "g": + m.scroll = 0 + } + } + m.clampScroll() + return nil +} + +func scrollAmount(key string) int { + if key == "pgup" || key == "pgdown" { + return 8 + } + return 1 +} + +func (m Model) load(taskID string, generation uint64) tea.Cmd { + return func() tea.Msg { + comments, commentsErr := m.reader.Comments(m.user, taskID) + links, linksErr := m.reader.TaskLinks(m.user, taskID) + tombstone, found, tombstoneErr := m.reader.Tombstone(m.user, taskID) + var killed *store.Tombstone + if found { + killed = &tombstone + } + return detailLoadedMsg{ + taskID: taskID, generation: generation, comments: comments, links: links, tombstone: killed, + commentsErr: commentsErr, linksErr: linksErr, tombstoneErr: tombstoneErr, + } + } +} + +func (m *Model) startLoad() tea.Cmd { + m.generation++ + m.loading = true + m.commentsErr = nil + m.linksErr = nil + m.tombstoneErr = nil + m.rebuildBody() + return m.load(m.task.ID, m.generation) +} + +// View renders a centered bordered pane sized for the current terminal. +func (m *Model) View(width, height int) string { + if !m.open { + return "" + } + width = max(width, 1) + height = max(height, 1) + frame, _, _ := m.frame(width, height) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, frame) +} + +// Overlay composes the pane over the board without making carddetail own the +// board renderer. Input routing remains the root model's responsibility. +func (m *Model) Overlay(background string, width, height int) string { + if !m.open { + return background + } + width = max(width, 1) + height = max(height, 1) + frame, paneWidth, paneHeight := m.frame(width, height) + x := max((width-paneWidth)/2, 0) + y := max((height-paneHeight)/2, 0) + return lipgloss.NewCompositor( + lipgloss.NewLayer(background), + lipgloss.NewLayer(frame).X(x).Y(y).Z(1), + ).Render() +} + +func (m *Model) frame(width, height int) (string, int, int) { + width = max(width, 1) + height = max(height, 1) + m.ensureBody(width, height) + innerWidth, innerHeight, paneHeight := paneGeometry(width, height) + + lines := m.bodyLines + maxScroll := max(0, len(lines)-innerHeight) + start := min(m.scroll, maxScroll) + end := min(start+innerHeight, len(lines)) + visibleLines := append([]string(nil), lines[start:end]...) + for i := range visibleLines { + visibleLines[i] = ansi.Truncate(visibleLines[i], innerWidth, "") + } + visible := strings.Join(visibleLines, "\n") + visible = lipgloss.NewStyle().Width(innerWidth).Height(innerHeight).Render(visible) + footer := "esc close ↑/↓ scroll" + if maxScroll > 0 { + footer = fmt.Sprintf("%s %d/%d", footer, start+1, maxScroll+1) + } + content := visible + "\n" + ansi.Truncate(footer, innerWidth, "…") + frame := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + Padding(0, 1). + Width(innerWidth). + Height(paneHeight - 2). + Render(content) + frame = fitTerminal(frame, width, height) + return frame, lipgloss.Width(frame), lipgloss.Height(frame) +} + +func paneGeometry(width, height int) (innerWidth, innerHeight, paneHeight int) { + width = max(width, 1) + height = max(height, 1) + paneWidth := min(max(width-4, 12), maxPaneWidth, width) + paneHeight = min(max(min(height-2, height), 5), height) + return max(paneWidth-4, 1), max(paneHeight-4, 1), paneHeight +} + +func (m *Model) clampScroll() { + if !m.open { + m.scroll = 0 + return + } + m.scroll = min(max(m.scroll, 0), m.maxScroll()) +} + +func (m Model) maxScroll() int { + _, innerHeight, _ := paneGeometry(m.width, m.height) + return max(0, len(m.bodyLines)-innerHeight) +} + +func (m *Model) ensureBody(width, height int) { + m.width = max(width, 1) + m.height = max(height, 1) + innerWidth, _, _ := paneGeometry(m.width, m.height) + if m.bodyLines == nil || m.bodyWidth != innerWidth { + m.rebuildBody() + return + } + m.clampScroll() +} + +func (m *Model) rebuildBody() { + if !m.open { + m.bodyLines = nil + m.bodyWidth = 0 + m.scroll = 0 + return + } + innerWidth, _, _ := paneGeometry(m.width, m.height) + body := m.renderBody(innerWidth) + m.bodyLines = strings.Split(strings.TrimRight(body, "\n"), "\n") + m.bodyWidth = innerWidth + m.clampScroll() +} + +func fitTerminal(rendered string, width, height int) string { + lines := strings.Split(rendered, "\n") + if len(lines) > height { + lines = lines[:height] + } + for i := range lines { + lines[i] = ansi.Truncate(lines[i], width, "") + } + return strings.Join(lines, "\n") +} + +func (m Model) renderBody(width int) string { + title := strings.TrimSpace(safeText(m.task.Title, false)) + if m.task.Emoji != "" { + title = safeText(m.task.Emoji, false) + " " + title + } + if m.task.Seq > 0 { + title += fmt.Sprintf(" #%d", m.task.Seq) + } + sections := []string{lipgloss.NewStyle().Bold(true).Render(title)} + sections = append(sections, m.metadata()) + if tags := regularTags(m.task.Tags); len(tags) > 0 { + sections = append(sections, "labels "+strings.Join(tags, " ")) + } + if links := importLinks(m.task.Tags); len(links) > 0 { + sections = append(sections, "links "+strings.Join(links, " ")) + } + if m.tombstone != nil { + sections = append(sections, killedContext(*m.tombstone)) + } + if m.tombstoneErr != nil { + sections = append(sections, "killed context error: "+safeText(m.tombstoneErr.Error(), false)) + } + if strings.TrimSpace(m.task.Desc) != "" { + sections = append(sections, m.markdown(m.task.Desc, width)) + } + if len(m.task.Checks) > 0 { + sections = append(sections, renderChecklist(m.task.Checks)) + } + if refs := renderTaskLinks(m.links); refs != "" { + sections = append(sections, refs) + } + if m.linksErr != nil { + sections = append(sections, "blocker links error: "+safeText(m.linksErr.Error(), false)) + } + if m.loading { + sections = append(sections, "loading comments and context...") + } else if m.commentsErr != nil { + sections = append(sections, "comments error: "+safeText(m.commentsErr.Error(), false)) + } else { + if len(m.comments) > 0 { + sections = append(sections, renderCommentsWith(m.comments, width, m.markdown)) + } else { + sections = append(sections, "comments none") + } + } + return strings.Join(sections, "\n\n") +} + +func (m Model) markdown(source string, width int) string { + if m.renderMarkdown == nil { + return renderMarkdown(source, width) + } + return m.renderMarkdown(source, width) +} + +func (m Model) metadata() string { + primary := []string{fmt.Sprintf("status %s", m.task.Status), fmt.Sprintf("priority %d", m.task.Prio)} + if m.task.Blocked { + primary = append(primary, "blocked") + } + secondary := make([]string, 0, 2) + if m.task.Due != "" { + secondary = append(secondary, "due "+m.task.Due) + } + if m.task.Effort != "" { + secondary = append(secondary, "effort "+m.task.Effort) + } + if len(secondary) == 0 { + return strings.Join(primary, " ") + } + return strings.Join(primary, " ") + "\n" + strings.Join(secondary, " ") +} + +func regularTags(tags []string) []string { + var out []string + for _, tag := range tags { + if !strings.HasPrefix(tag, "link::") { + out = append(out, "["+safeText(tag, false)+"]") + } + } + return out +} + +func importLinks(tags []string) []string { + seen := make(map[string]bool) + var out []string + for _, tag := range tags { + if !strings.HasPrefix(tag, "link::") { + continue + } + link := strings.TrimSpace(strings.TrimPrefix(tag, "link::")) + if link != "" && !seen[link] { + seen[link] = true + out = append(out, "["+safeText(link, false)+"]") + } + } + return out +} + +func killedContext(tombstone store.Tombstone) string { + date := tombstone.KilledAt + if stamp, err := time.Parse(time.RFC3339Nano, tombstone.KilledAt); err == nil { + date = stamp.UTC().Format("2 Jan 2006") + } + return fmt.Sprintf("killed %s\n%s", safeText(date, false), safeText(tombstone.Reason, false)) +} + +func renderMarkdown(source string, width int) string { + style := styles.DarkStyleConfig + zero := uint(0) + style.Document.Margin = &zero + renderer, err := glamour.NewTermRenderer( + glamour.WithStyles(style), + glamour.WithWordWrap(max(width, 1)), + ) + if err != nil { + return safeText(source, true) + } + rendered, err := renderer.Render(parityMarkdown(safeText(source, true))) + if err != nil { + return safeText(source, true) + } + return strings.Trim(rendered, "\r\n") +} + +func renderChecklist(checks []board.Check) string { + lines := []string{"checklist"} + for _, check := range checks { + mark := "☐" + if check.Done { + mark = "☑" + } + lines = append(lines, fmt.Sprintf(" %s %s", mark, safeText(check.Text, false))) + } + return strings.Join(lines, "\n") +} + +func renderTaskLinks(links store.TaskLinks) string { + var lines []string + if len(links.Blocks) > 0 { + lines = append(lines, "blocks "+taskChips(links.Blocks)) + } + if len(links.BlockedBy) > 0 { + lines = append(lines, "blocked by "+taskChips(links.BlockedBy)) + } + return strings.Join(lines, "\n") +} + +func taskChips(tasks []board.Task) string { + chips := make([]string, 0, len(tasks)) + for _, task := range tasks { + ref := task.ID + if task.Seq > 0 { + ref = fmt.Sprintf("#%d", task.Seq) + } + chips = append(chips, fmt.Sprintf("[%s %s]", ref, task.Status)) + } + return strings.Join(chips, " ") +} + +func renderComments(comments []store.Comment, width int) string { + return renderCommentsWith(comments, width, renderMarkdown) +} + +func renderCommentsWith(comments []store.Comment, width int, renderer markdownRenderer) string { + sections := []string{fmt.Sprintf("comments %d", len(comments))} + for _, comment := range comments { + date := comment.CreatedAt.UTC().Format("2 Jan 2006") + header := fmt.Sprintf("c%d %s %s", comment.ID, safeText(comment.Author, false), date) + sections = append(sections, header+"\n"+renderer(comment.Body, width)) + } + return strings.Join(sections, "\n\n") +} + +func safeText(text string, keepNewlines bool) string { + text = ansi.Strip(text) + var out strings.Builder + for _, r := range text { + if r == '\t' { + out.WriteString(" ") + continue + } + if r == '\v' || r == '\f' || r == '\r' { + out.WriteByte(' ') + continue + } + if keepNewlines && r == '\n' { + out.WriteRune(r) + continue + } + if unicode.IsControl(r) { + continue + } + out.WriteRune(r) + } + return out.String() +} diff --git a/internal/tui/carddetail/model_test.go b/internal/tui/carddetail/model_test.go new file mode 100644 index 0000000..350e1da --- /dev/null +++ b/internal/tui/carddetail/model_test.go @@ -0,0 +1,399 @@ +package carddetail + +import ( + "errors" + "fmt" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" + + "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/store" +) + +type stubReader struct { + comments []store.Comment + links store.TaskLinks + tombstone store.Tombstone + found bool + commentsErr error + linksErr error + tombstoneErr error +} + +func (s stubReader) Comments(string, string) ([]store.Comment, error) { + return s.comments, s.commentsErr +} + +func (s stubReader) TaskLinks(string, string) (store.TaskLinks, error) { + return s.links, s.linksErr +} + +func (s stubReader) Tombstone(string, string) (store.Tombstone, bool, error) { + return s.tombstone, s.found, s.tombstoneErr +} + +func fullTask() board.Task { + return board.Task{ + ID: "task-1", Seq: 7, Emoji: "🧭", Title: "Map it", Desc: "## Plan\n- **first**\nhttps://example.com", + Status: board.StatusCancelled, Blocked: true, Prio: 1, Due: "2026-08-19", Effort: "M", + Tags: []string{"type::feature", "link::github#86", "link::github#86", "link::"}, + Checks: []board.Check{{Text: "done", Done: true}, {Text: "left"}}, + } +} + +func TestModelLoadsAndRendersFullDetail(t *testing.T) { + stamp := time.Date(2026, time.August, 17, 12, 0, 0, 0, time.UTC) + reader := stubReader{ + comments: []store.Comment{{ID: 3, Author: "alice", Body: "looks **good**", CreatedAt: stamp}}, + links: store.TaskLinks{ + Blocks: []board.Task{{Seq: 9, Status: board.StatusTodo}}, + BlockedBy: []board.Task{{ID: "legacy", Status: board.StatusDone}}, + }, + tombstone: store.Tombstone{TaskID: "task-1", Reason: "superseded", KilledAt: stamp.Format(time.RFC3339Nano)}, + found: true, + } + m := New(reader, "alice") + if m.IsOpen() || m.TaskID() != "" || m.View(80, 24) != "" { + t.Fatal("new detail pane was not closed") + } + command := m.Open(fullTask()) + if command == nil || !m.IsOpen() || m.TaskID() != "task-1" || !m.loading { + t.Fatalf("open state = %+v, command nil=%v", m, command == nil) + } + if body := ansi.Strip(m.renderBody(72)); !strings.Contains(body, "loading comments and context") { + t.Fatalf("loading body:\n%s", body) + } + m.Update(command()) + body := ansi.Strip(m.renderBody(72)) + for _, want := range []string{ + "🧭 Map it #7", "status cancelled", "priority 1", "due 2026-08-19", "effort M", "blocked", + "[type::feature]", "[github#86]", "killed 17 Aug 2026", "superseded", "Plan", "first", + "checklist", "☑ done", "☐ left", "blocks [#9 todo]", "blocked by [legacy done]", + "comments 1", "c3 alice 17 Aug 2026", "looks", "good", + } { + if !strings.Contains(body, want) { + t.Errorf("detail body missing %q:\n%s", want, body) + } + } + if strings.Count(body, "[github#86]") != 1 { + t.Errorf("link chips were not deduplicated:\n%s", body) + } + + view := m.View(80, 20) + for _, line := range strings.Split(view, "\n") { + if ansi.StringWidth(line) > 80 { + t.Fatalf("overlay line wider than terminal: %d: %q", ansi.StringWidth(line), line) + } + } +} + +func TestModelHandlesErrorsStaleLoadsScrollAndClose(t *testing.T) { + loadErr := errors.New("comments broke") + m := New(stubReader{commentsErr: loadErr, linksErr: errors.New("links broke")}, "u") + command := m.Open(board.Task{ID: "current", Title: "Current", Status: board.StatusTodo}) + m.Update(detailLoadedMsg{taskID: "stale", comments: []store.Comment{{ID: 1}}}) + if !m.loading { + t.Fatal("stale result changed loading state") + } + m.Update(command()) + if m.loading || !errors.Is(m.commentsErr, loadErr) || m.linksErr == nil { + t.Fatalf("load error state = loading %v, comments %v, links %v", m.loading, m.commentsErr, m.linksErr) + } + if body := ansi.Strip(m.renderBody(40)); !strings.Contains(body, "comments error:") || !strings.Contains(body, "blocker links error:") { + t.Fatalf("error body:\n%s", body) + } + + for _, key := range []tea.KeyPressMsg{ + {Code: tea.KeyDown}, {Code: 'j'}, {Code: tea.KeyPgDown}, + {Code: tea.KeyUp}, {Code: 'k'}, {Code: tea.KeyPgUp}, + } { + m.Update(key) + } + m.scroll = 10 + m.Update(tea.KeyPressMsg{Code: tea.KeyHome}) + if m.scroll != 0 { + t.Fatalf("home scroll = %d", m.scroll) + } + m.scroll = 10 + m.Update(tea.KeyPressMsg{Code: 'g'}) + if m.scroll != 0 || scrollAmount("pgdown") != 8 || scrollAmount("down") != 1 { + t.Fatal("scroll controls returned the wrong amount") + } + + m.Close() + if m.IsOpen() || m.TaskID() != "" || m.Update(command()) != nil { + t.Fatal("closed pane accepted a late load") + } +} + +func TestModelRendersIndependentEnrichmentResults(t *testing.T) { + stamp := time.Date(2026, time.August, 17, 12, 0, 0, 0, time.UTC) + task := board.Task{ID: "task", Title: "Task", Status: board.StatusTodo} + + commentsFailed := New(stubReader{ + comments: []store.Comment{{ID: 8, Author: "partial", Body: "must not render", CreatedAt: stamp}}, + commentsErr: errors.New("comments unavailable"), + links: store.TaskLinks{Blocks: []board.Task{{Seq: 9, Status: board.StatusDoing}}}, + tombstone: store.Tombstone{TaskID: "task", Reason: "replaced", KilledAt: stamp.Format(time.RFC3339Nano)}, + found: true, + }, "u") + commentsFailed.Update(commentsFailed.Open(task)()) + body := ansi.Strip(commentsFailed.renderBody(60)) + for _, want := range []string{"blocks [#9 doing]", "killed 17 Aug 2026", "comments error: comments unavailable"} { + if !strings.Contains(body, want) { + t.Errorf("comments failure hid %q:\n%s", want, body) + } + } + if strings.Contains(body, "comments none") || strings.Contains(body, "must not render") { + t.Fatalf("failed comments read rendered a successful comments state:\n%s", body) + } + + contextFailed := New(stubReader{ + comments: []store.Comment{{ID: 1, Author: "alice", Body: "available", CreatedAt: stamp}}, + linksErr: errors.New("links unavailable"), + tombstoneErr: errors.New("killed unavailable"), + }, "u") + contextFailed.Update(contextFailed.Open(task)()) + body = ansi.Strip(contextFailed.renderBody(60)) + for _, want := range []string{"available", "blocker links error: links unavailable", "killed context error: killed unavailable"} { + if !strings.Contains(body, want) { + t.Errorf("context failure hid %q:\n%s", want, body) + } + } +} + +func TestModelRejectsStaleLoadForReopenedTask(t *testing.T) { + m := New(stubReader{}, "u") + task := board.Task{ID: "same", Title: "Same", Status: board.StatusTodo} + first := m.Open(task) + second := m.Open(task) + + m.Update(first()) + if !m.loading { + t.Fatal("stale same-task result changed loading state") + } + m.Update(second()) + if m.loading { + t.Fatal("current same-task result did not finish loading") + } +} + +type countingDetailReader struct{ loads int } + +func (r *countingDetailReader) Comments(string, string) ([]store.Comment, error) { + r.loads++ + return []store.Comment{{ID: r.loads, Author: "load", Body: fmt.Sprintf("version %d", r.loads)}}, nil +} + +func (*countingDetailReader) TaskLinks(string, string) (store.TaskLinks, error) { + return store.TaskLinks{}, nil +} + +func (*countingDetailReader) Tombstone(string, string) (store.Tombstone, bool, error) { + return store.Tombstone{}, false, nil +} + +func TestRefreshCoalescesEnrichmentLoads(t *testing.T) { + reader := &countingDetailReader{} + m := New(reader, "u") + first := m.Open(board.Task{ID: "same", Title: "first", Status: board.StatusTodo}) + if command := m.Refresh(board.Task{ID: "same", Title: "second", Status: board.StatusDoing}); command != nil { + t.Fatal("refresh overlapped the active enrichment load") + } + if command := m.Refresh(board.Task{ID: "same", Title: "latest", Status: board.StatusDone}); command != nil { + t.Fatal("second refresh overlapped the active enrichment load") + } + + successor := m.Update(first()) + if successor == nil || !m.loading || m.reloadPending || len(m.comments) != 0 { + t.Fatalf("coalesced first result = loading %v pending %v comments %v command %v", m.loading, m.reloadPending, m.comments, successor) + } + m.Update(successor()) + if m.loading || reader.loads != 2 || m.task.Title != "latest" || len(m.comments) != 1 || m.comments[0].Body != "version 2" { + t.Fatalf("coalesced successor = model %+v loads %d", m, reader.loads) + } + + late := m.Refresh(board.Task{ID: "same", Title: "closed", Status: board.StatusDone}) + m.Close() + if command := m.Update(late()); command != nil || m.IsOpen() { + t.Fatal("closed detail restarted a pending refresh") + } +} + +func TestNilReaderAndRenderingHelpers(t *testing.T) { + m := New(nil, "u") + task := board.Task{ID: "id", Title: "Bare", Status: board.StatusTodo, Prio: 3} + if command := m.Open(task); command != nil || m.loading { + t.Fatalf("nil-reader open = command %v loading %v", command, m.loading) + } + body := ansi.Strip(m.renderBody(40)) + if !strings.Contains(body, "comments none") { + t.Fatalf("nil-reader body:\n%s", body) + } + if got := m.View(1, 1); got == "" { + t.Fatal("tiny overlay was empty") + } + + if got := regularTags([]string{"a", "link::x"}); len(got) != 1 || got[0] != "[a]" { + t.Fatalf("regularTags = %v", got) + } + if got := importLinks([]string{"link:: x ", "link::x", "link::"}); len(got) != 1 || got[0] != "[x]" { + t.Fatalf("importLinks = %v", got) + } + if got := killedContext(store.Tombstone{Reason: "why", KilledAt: "bad"}); got != "killed bad\nwhy" { + t.Fatalf("killedContext invalid date = %q", got) + } + if got := renderChecklist(nil); got != "checklist" { + t.Fatalf("empty checklist = %q", got) + } + if got := renderTaskLinks(store.TaskLinks{}); got != "" { + t.Fatalf("empty task links = %q", got) + } + if got := taskChips([]board.Task{{ID: "id", Status: board.StatusDoing}}); got != "[id doing]" { + t.Fatalf("fallback task chip = %q", got) + } + if got := renderComments([]store.Comment{{ID: 1, Author: "a", Body: "body"}}, 0); !strings.Contains(ansi.Strip(got), "1 Jan 0001") { + t.Fatalf("zero-time comment = %q", ansi.Strip(got)) + } + if got := safeText("ok\x1b[31m red\a\nnext", true); got != "ok red\nnext" { + t.Fatalf("safeText = %q", got) + } +} + +func TestViewClampsScrollToContent(t *testing.T) { + m := New(nil, "u") + task := fullTask() + task.Desc = strings.Repeat("line\n", 40) + m.Resize(40, 10) + m.Open(task) + for range 100 { + m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + } + if m.scroll != m.maxScroll() { + t.Fatalf("stored scroll = %d, max = %d", m.scroll, m.maxScroll()) + } + before := m.scroll + m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + if m.scroll != before-1 { + t.Fatalf("up from bottom = %d, want %d", m.scroll, before-1) + } + m.Resize(40, 100) + if m.scroll != m.maxScroll() { + t.Fatalf("resize scroll = %d, max = %d", m.scroll, m.maxScroll()) + } + m.Refresh(board.Task{ID: task.ID, Title: task.Title, Desc: "short", Status: task.Status}) + if m.scroll != 0 { + t.Fatalf("shorter content scroll = %d", m.scroll) + } + view := ansi.Strip(m.View(40, 10)) + if !strings.Contains(view, "esc close") || !strings.Contains(view, "/") { + t.Fatalf("scrolled overlay footer missing:\n%s", view) + } +} + +func TestCardDetailGolden(t *testing.T) { + m := New(nil, "default") + task := fullTask() + task.Status = board.StatusDoing + m.Open(task) + lines := strings.Split(ansi.Strip(m.View(60, 20)), "\n") + for i := range lines { + lines[i] = strings.TrimSpace(lines[i]) + } + golden.RequireEqual(t, strings.Trim(strings.Join(lines, "\n"), "\n")+"\n") +} + +func TestOverlayKeepsBoardAroundPane(t *testing.T) { + m := New(nil, "u") + background := strings.Repeat("b", 30) + "\n" + strings.Repeat("b", 30) + if got := m.Overlay(background, 30, 2); got != background { + t.Fatalf("closed overlay changed background: %q", got) + } + m.Open(board.Task{ID: "id", Title: "detail", Status: board.StatusTodo}) + got := ansi.Strip(m.Overlay(background, 30, 8)) + if !strings.Contains(got, "detail") || !strings.Contains(got, "bbbb") { + t.Fatalf("composed overlay lost pane or board:\n%s", got) + } +} + +func TestViewFitsTinyTerminal(t *testing.T) { + m := New(nil, "u") + m.Open(board.Task{ID: "id", Title: "detail", Status: board.StatusTodo}) + for _, size := range [][2]int{{1, 1}, {2, 3}, {4, 3}} { + view := m.View(size[0], size[1]) + lines := strings.Split(view, "\n") + if len(lines) > size[1] { + t.Fatalf("%dx%d view has %d lines", size[0], size[1], len(lines)) + } + for _, line := range lines { + if got := ansi.StringWidth(line); got > size[0] { + t.Fatalf("%dx%d view line width = %d: %q", size[0], size[1], got, line) + } + } + } +} + +func TestHostileFencedTabsStayInsideBorder(t *testing.T) { + m := New(nil, "u") + m.Open(board.Task{ + ID: "id", Title: "tabs", Status: board.StatusTodo, + Desc: "```\n\t~~~~~~\t界界界界界\n```", + }) + view := ansi.Strip(m.View(20, 20)) + if strings.ContainsRune(view, '\t') { + t.Fatalf("view retained a tab:\n%s", view) + } + if !strings.Contains(view, "~~~~") { + t.Fatalf("hostile fenced code was not visible:\n%s", view) + } + lines := strings.Split(view, "\n") + if len(lines) > 20 { + t.Fatalf("view has %d lines:\n%s", len(lines), view) + } + for _, line := range lines { + if width := ansi.StringWidth(line); width > 20 { + t.Fatalf("line width = %d:\n%s", width, view) + } + if strings.HasPrefix(line, "│") && !strings.HasSuffix(line, "│") { + t.Fatalf("content crossed the right border: %q", line) + } + } +} + +func TestScrollAndViewReuseRenderedMarkdown(t *testing.T) { + renders := 0 + m := New(nil, "u") + m.renderMarkdown = func(source string, _ int) string { + renders++ + return source + } + m.Resize(40, 10) + m.Open(board.Task{ID: "id", Title: "cached", Desc: strings.Repeat("line\n", 40), Status: board.StatusTodo}) + initial := renders + if initial == 0 { + t.Fatal("open did not render the markdown cache") + } + for range 100 { + m.Update(tea.KeyPressMsg{Code: tea.KeyPgDown}) + m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) + } + for range 10 { + _ = m.View(40, 10) + } + if renders != initial { + t.Fatalf("scroll/view rerendered markdown: before %d after %d", initial, renders) + } + m.Resize(40, 20) + if renders != initial { + t.Fatalf("height-only resize rerendered markdown: before %d after %d", initial, renders) + } + m.Resize(30, 10) + if renders != initial+1 { + t.Fatalf("width change renders = %d, want %d", renders, initial+1) + } +} diff --git a/internal/tui/carddetail/testdata/TestCardDetailGolden.golden b/internal/tui/carddetail/testdata/TestCardDetailGolden.golden new file mode 100644 index 0000000..c088c0b --- /dev/null +++ b/internal/tui/carddetail/testdata/TestCardDetailGolden.golden @@ -0,0 +1,17 @@ +╭─────────────────────────────────────────────────────╮ +│ 🧭 Map it #7 │ +│ │ +│ status doing priority 1 blocked │ +│ due 2026-08-19 effort M │ +│ │ +│ labels [type::feature] │ +│ │ +│ links [github#86] │ +│ │ +│ Plan │ +│ │ +│ • first │ +│ https://example.com │ +│ │ +│ esc close ↑/↓ scroll 1/6 │ +╰─────────────────────────────────────────────────────╯ diff --git a/internal/tui/model.go b/internal/tui/model.go index 61ba380..1feca89 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -9,6 +9,7 @@ import ( tea "charm.land/bubbletea/v2" "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/tui/carddetail" ) const ( @@ -53,6 +54,7 @@ type Model struct { user string board board.Board boardView boardViewState + detail carddetail.Model width int height int loading bool @@ -81,11 +83,13 @@ func newModel( user string, ctx context.Context, ) Model { + detailReader, _ := store.(carddetail.Reader) return Model{ store: store, watcher: watcher, user: user, board: board.Board{Title: "Board"}, + detail: carddetail.New(detailReader, user), width: defaultWidth, height: defaultHeight, loading: watcher == nil, @@ -113,6 +117,25 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if m.stopped { return m, nil } + var detailCmd tea.Cmd + if m.detail.IsOpen() { + switch msg := message.(type) { + case tea.KeyPressMsg: + switch msg.String() { + case "esc": + m.detail.Close() + return m, nil + case "q", "ctrl+c": + // Preserve the root quit contract while the overlay is open. + default: + return m, m.detail.Update(message) + } + case boardCardClickedMsg, boardColumnClickedMsg: + return m, nil + default: + detailCmd = m.detail.Update(message) + } + } switch msg := message.(type) { case tea.KeyPressMsg: switch msg.String() { @@ -120,13 +143,24 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { m.stopped = true m.reloadPending = false return m, tea.Quit + case "enter": + if task, ok := m.selectedTask(); ok { + m.detail.Resize(m.width, m.height) + return m, m.detail.Open(task) + } + return m, nil default: if m.boardView.handleKey(msg.String(), m.board) == boardToggledCancelled { return m, m.queueCancelledPreference() } } case boardCardClickedMsg: - m.boardView.focusTask(m.board, msg.taskID) + if m.boardView.focusTask(m.board, msg.taskID) { + if task, ok := m.selectedTask(); ok { + m.detail.Resize(m.width, m.height) + return m, m.detail.Open(task) + } + } case boardColumnClickedMsg: m.boardView.focusColumn(msg.status, m.board) case cancelledPreferenceSavedMsg: @@ -138,16 +172,17 @@ func (m Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { if msg.Height > 0 { m.height = msg.Height } + m.detail.Resize(m.width, m.height) case boardLoadedMsg: next := m.finishBoardLoad(msg) - return m, next + return m, batchCommands(detailCmd, next) case pollTickMsg: return m, m.readDataVersion() case dataVersionMsg: next := m.observeDataVersion(msg) return m, next } - return m, nil + return m, detailCmd } // observeDataVersion advances the watcher baseline and schedules exactly one @@ -186,16 +221,32 @@ func (m *Model) observeDataVersion(msg dataVersionMsg) tea.Cmd { func (m *Model) finishBoardLoad(msg boardLoadedMsg) tea.Cmd { m.loading = false m.loadErr = msg.err + var detailCmd tea.Cmd if msg.err == nil { previous := m.board m.board = msg.board m.boardView.adoptBoard(previous, m.board) + detailCmd = m.reconcileDetail() } if !m.reloadPending { - return nil + return detailCmd } m.reloadPending = false - return m.startBoardLoad() + return batchCommands(detailCmd, m.startBoardLoad()) +} + +func (m *Model) reconcileDetail() tea.Cmd { + if !m.detail.IsOpen() { + return nil + } + taskID := m.detail.TaskID() + for _, task := range m.board.Tasks { + if task.ID == taskID { + return m.detail.Refresh(task) + } + } + m.detail.Close() + return nil } // startBoardLoad starts a fallback or retry only when no load is active. The @@ -225,10 +276,30 @@ func pollAfter(load tea.Cmd) tea.Cmd { return tea.Batch(load, schedulePoll()) } +func batchCommands(commands ...tea.Cmd) tea.Cmd { + filtered := make([]tea.Cmd, 0, len(commands)) + for _, command := range commands { + if command != nil { + filtered = append(filtered, command) + } + } + if len(filtered) == 0 { + return nil + } + if len(filtered) == 1 { + return filtered[0] + } + return tea.Batch(filtered...) +} + // View renders the responsive read-only board and wires view-derived mouse hit // regions back into the update loop. Editing behavior arrives in later slices. func (m Model) View() tea.View { content, hits := m.renderBoard() + if m.detail.IsOpen() { + content = m.detail.Overlay(content, m.width, m.height) + hits = nil + } view := tea.NewView(content) view.AltScreen = true view.MouseMode = tea.MouseModeCellMotion diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 9c50ec3..bcc1236 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -16,6 +16,7 @@ import ( "github.com/charmbracelet/x/exp/teatest/v2" "github.com/RandomCodeSpace/kb/internal/board" + "github.com/RandomCodeSpace/kb/internal/store" ) type stubBoardReader struct { @@ -23,6 +24,40 @@ type stubBoardReader struct { err error } +type stubDetailBoardReader struct{ stubBoardReader } + +func (stubDetailBoardReader) Comments(string, string) ([]store.Comment, error) { + return nil, nil +} + +func (stubDetailBoardReader) TaskLinks(string, string) (store.TaskLinks, error) { + return store.TaskLinks{}, nil +} + +func (stubDetailBoardReader) Tombstone(string, string) (store.Tombstone, bool, error) { + return store.Tombstone{}, false, nil +} + +type mutableDetailReader struct { + board board.Board + commentLoads int +} + +func (r *mutableDetailReader) Board(string) (board.Board, error) { return r.board, nil } + +func (r *mutableDetailReader) Comments(string, string) ([]store.Comment, error) { + r.commentLoads++ + return []store.Comment{{ID: r.commentLoads, Author: "watcher", Body: fmt.Sprintf("enrichment %d", r.commentLoads)}}, nil +} + +func (*mutableDetailReader) TaskLinks(string, string) (store.TaskLinks, error) { + return store.TaskLinks{}, nil +} + +func (*mutableDetailReader) Tombstone(string, string) (store.Tombstone, bool, error) { + return store.Tombstone{}, false, nil +} + func (s stubBoardReader) Board(string) (board.Board, error) { return s.board, s.err } type stubVersionReader struct { @@ -161,6 +196,117 @@ func TestModelLoadsRoutesAndRenders(t *testing.T) { } } +func TestCardDetailOpensFromKeyboardAndClick(t *testing.T) { + tasks := []board.Task{ + {ID: "first", Seq: 1, Title: "First card", Desc: "detail-only description", Status: board.StatusTodo}, + {ID: "second", Seq: 2, Title: "Second card", Status: board.StatusTodo}, + } + m := NewModel(stubDetailBoardReader{stubBoardReader{board: board.Board{Title: "Work", Tasks: tasks}}}, nil, "alice") + completeBoardLoad(t, &m, m.Init()) + + load := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + if !m.detail.IsOpen() || m.detail.TaskID() != "first" || load == nil { + t.Fatalf("enter detail state = open %v task %q command %v", m.detail.IsOpen(), m.detail.TaskID(), load) + } + updateTestModel(t, &m, load()) + view := ansi.Strip(m.View().Content) + if !strings.Contains(view, "detail-only description") || !strings.Contains(view, "esc close") { + t.Fatalf("detail overlay missing content:\n%s", view) + } + columnBefore := m.boardView.column + rowBefore := m.boardView.rows[0] + updateTestModel(t, &m, tea.KeyPressMsg{Code: 'j'}) + updateTestModel(t, &m, boardColumnClickedMsg{status: board.StatusDoing}) + if m.boardView.column != columnBefore || m.boardView.rows[0] != rowBefore { + t.Fatalf("overlay input leaked to board: %+v", m.boardView) + } + updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEsc}) + if m.detail.IsOpen() { + t.Fatal("escape did not close detail") + } + + updateTestModel(t, &m, boardCardClickedMsg{taskID: "second"}) + if !m.detail.IsOpen() || m.detail.TaskID() != "second" || m.boardView.rows[0] != 1 { + t.Fatalf("click detail state = open %v task %q row %d", m.detail.IsOpen(), m.detail.TaskID(), m.boardView.rows[0]) + } + if command := m.View().OnMouse(tea.MouseClickMsg{}); command != nil { + t.Fatal("board mouse handler remained active behind detail") + } + if quit := updateTestModel(t, &m, tea.KeyPressMsg{Code: 'q'}); quit == nil || !m.stopped { + t.Fatal("q did not preserve root quit while detail was open") + } +} + +func TestCardDetailOpenWithoutASelectedTaskIsNoop(t *testing.T) { + m := NewModel(stubBoardReader{board: board.Board{Title: "Empty"}}, nil, "u") + completeBoardLoad(t, &m, m.Init()) + if command := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}); command != nil || m.detail.IsOpen() { + t.Fatalf("empty-board enter = command %v open %v", command, m.detail.IsOpen()) + } + updateTestModel(t, &m, boardCardClickedMsg{taskID: "missing"}) + if m.detail.IsOpen() { + t.Fatal("missing card click opened detail") + } +} + +func TestBoardReloadReconcilesOpenDetailAndCoalescesEnrichment(t *testing.T) { + reader := &mutableDetailReader{board: board.Board{Title: "Work", Tasks: []board.Task{{ + ID: "same", Title: "Old", Desc: "old description", Status: board.StatusTodo, + }}}} + m := NewModel(reader, nil, "u") + completeBoardLoad(t, &m, m.Init()) + firstDetailLoad := updateTestModel(t, &m, tea.KeyPressMsg{Code: tea.KeyEnter}) + + updated := board.Task{ + ID: "same", Title: "Updated", Desc: "updated description", Status: board.StatusDoing, + Checks: []board.Check{{Text: "fresh check", Done: true}}, + } + latest := updated + latest.Title = "Latest" + latest.Desc = "latest description" + for _, snapshot := range []board.Board{ + {Title: "Work", Tasks: []board.Task{updated}}, + {Title: "Work", Tasks: []board.Task{latest}}, + } { + if command := updateTestModel(t, &m, boardLoadedMsg{board: snapshot}); command != nil { + t.Fatal("board refresh overlapped an active detail enrichment load") + } + } + if view := ansi.Strip(m.View().Content); !strings.Contains(view, "Latest") || !strings.Contains(view, "latest description") || !strings.Contains(view, "fresh check") { + t.Fatalf("board refresh did not replace the open task snapshot:\n%s", view) + } + + successor := updateTestModel(t, &m, firstDetailLoad()) + if successor == nil { + t.Fatal("root discarded the coalesced detail successor") + } + updateTestModel(t, &m, successor()) + if reader.commentLoads != 2 || !strings.Contains(ansi.Strip(m.View().Content), "enrichment 2") { + t.Fatalf("coalesced enrichment = loads %d view:\n%s", reader.commentLoads, ansi.Strip(m.View().Content)) + } + + idleUpdate := latest + idleUpdate.Desc = "idle refresh" + refresh := updateTestModel(t, &m, boardLoadedMsg{board: board.Board{Title: "Work", Tasks: []board.Task{idleUpdate}}}) + if refresh == nil { + t.Fatal("idle board refresh did not reload detail enrichment") + } + updateTestModel(t, &m, refresh()) + if reader.commentLoads != 3 || !strings.Contains(ansi.Strip(m.View().Content), "idle refresh") { + t.Fatalf("idle detail refresh = loads %d view:\n%s", reader.commentLoads, ansi.Strip(m.View().Content)) + } + + wantErr := errors.New("external read failed") + updateTestModel(t, &m, boardLoadedMsg{err: wantErr}) + if !m.detail.IsOpen() || !strings.Contains(ansi.Strip(m.View().Content), "idle refresh") { + t.Fatal("failed board reload discarded the last-good detail") + } + updateTestModel(t, &m, boardLoadedMsg{board: board.Board{Title: "Work"}}) + if m.detail.IsOpen() { + t.Fatal("deleted task left a ghost detail snapshot") + } +} + func TestRenderFitsNarrowTerminal(t *testing.T) { m := NewModel(stubBoardReader{}, nil, strings.Repeat("owner", 20)) m.loading = false