feat: preview window json syntax highlighting

This commit is contained in:
2026-03-14 23:37:32 +02:00
parent 561c98ae02
commit 1bd37227c4
5 changed files with 339 additions and 15 deletions

2
go.mod
View File

@@ -12,11 +12,13 @@ require (
)
require (
github.com/alecthomas/chroma/v2 v2.23.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect

4
go.sum
View File

@@ -1,3 +1,5 @@
github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY=
github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
@@ -14,6 +16,8 @@ github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQ
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=

97
internal/ui/highlight.go Normal file
View File

@@ -0,0 +1,97 @@
package ui
import (
"bytes"
"encoding/json"
"regexp"
"strings"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
)
// ansiEscPattern matches ANSI escape sequences (CSI and simple ESC sequences).
var ansiEscPattern = regexp.MustCompile(`\x1b\[[0-9;?]*[a-zA-Z]|\x1b[^\[]`)
// stripANSI removes all ANSI escape sequences from a string.
func stripANSI(s string) string {
return ansiEscPattern.ReplaceAllString(s, "")
}
// extractJSON finds the first JSON object or array in a string,
// skipping any leading non-JSON content (e.g. ANSI codes, log prefixes).
// Returns the JSON substring and any prefix before it.
func extractJSON(s string) (prefix, jsonStr string, ok bool) {
// Find first { or [
idx := strings.IndexAny(s, "{[")
if idx < 0 {
return "", "", false
}
return s[:idx], s[idx:], true
}
// highlightJSON attempts to detect JSON content, pretty-print it, and apply
// syntax highlighting for terminal output. Returns the original string
// unchanged if the content is not valid JSON.
func highlightJSON(s string) string {
trimmed := strings.TrimSpace(s)
if len(trimmed) == 0 {
return s
}
// Strip ANSI codes for JSON detection and parsing
clean := stripANSI(trimmed)
// Extract JSON from the string (may have a non-JSON prefix)
prefix, jsonStr, ok := extractJSON(clean)
if !ok {
return s
}
// Try to pretty-print the JSON portion
var buf bytes.Buffer
if err := json.Indent(&buf, []byte(jsonStr), "", " "); err != nil {
return s
}
pretty := buf.String()
// Highlight with chroma (only the JSON portion)
lexer := lexers.Get("json")
if lexer == nil {
return pretty
}
lexer = chroma.Coalesce(lexer)
style := styles.Get("monokai")
if style == nil {
style = styles.Fallback
}
formatter := formatters.Get("terminal256")
if formatter == nil {
formatter = formatters.Fallback
}
iterator, err := lexer.Tokenise(nil, pretty)
if err != nil {
return pretty
}
var out bytes.Buffer
if err := formatter.Format(&out, style, iterator); err != nil {
return pretty
}
// Re-attach any non-JSON prefix (stripped of ANSI)
result := out.String()
if prefix != "" {
prefix = strings.TrimSpace(prefix)
if prefix != "" {
result = prefix + "\n" + result
}
}
return result
}

View File

@@ -0,0 +1,164 @@
package ui
import (
"strings"
"testing"
)
func TestWrapTextANSI(t *testing.T) {
t.Run("ANSI sequences are not split", func(t *testing.T) {
// Red "ab" then reset: \033[31mab\033[0m
input := "\033[31mabcdef\033[0m"
lines := wrapText(input, 3)
// Should wrap into "abc" and "def", each with proper ANSI
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d: %q", len(lines), lines)
}
// First line should have the color and a reset
if !strings.Contains(lines[0], "\033[31m") {
t.Errorf("first line missing color code: %q", lines[0])
}
if !strings.Contains(lines[0], "abc") {
t.Errorf("first line missing 'abc': %q", lines[0])
}
// Second line should re-apply the color
if !strings.Contains(lines[1], "\033[31m") {
t.Errorf("second line missing re-applied color code: %q", lines[1])
}
if !strings.Contains(lines[1], "def") {
t.Errorf("second line missing 'def': %q", lines[1])
}
})
t.Run("no ANSI still works", func(t *testing.T) {
lines := wrapText("abcdef", 3)
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d", len(lines))
}
if lines[0] != "abc" {
t.Errorf("expected 'abc', got %q", lines[0])
}
if lines[1] != "def" {
t.Errorf("expected 'def', got %q", lines[1])
}
})
t.Run("ANSI reset clears active state", func(t *testing.T) {
// Color "ab", reset, then "cd"
input := "\033[31mab\033[0mcd"
lines := wrapText(input, 2)
// "ab" on first line (with color), "cd" on second (no color re-applied)
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d: %q", len(lines), lines)
}
// Second line should NOT have the red color re-applied
if strings.Contains(lines[1], "\033[31m") {
t.Errorf("second line should not have color after reset: %q", lines[1])
}
})
}
func TestWrapPreviewContentANSI(t *testing.T) {
// Multi-line input with ANSI codes should survive split + wrap
input := "\033[31mhello\033[0m\n\033[32mworld\033[0m"
lines := wrapPreviewContent(input, 80)
if len(lines) != 2 {
t.Fatalf("expected 2 lines, got %d", len(lines))
}
if !strings.Contains(lines[0], "\033[31m") || !strings.Contains(lines[0], "hello") {
t.Errorf("first line missing color or content: %q", lines[0])
}
if !strings.Contains(lines[1], "\033[32m") || !strings.Contains(lines[1], "world") {
t.Errorf("second line missing color or content: %q", lines[1])
}
}
func TestHighlightJSON(t *testing.T) {
t.Run("valid JSON object is pretty-printed and highlighted", func(t *testing.T) {
input := `{"name":"test","count":42}`
result := highlightJSON(input)
// Should be pretty-printed (multi-line)
if !strings.Contains(result, "\n") {
t.Error("expected multi-line pretty-printed output")
}
// Should contain the key and value
if !strings.Contains(result, "name") {
t.Error("expected output to contain 'name'")
}
if !strings.Contains(result, "test") {
t.Error("expected output to contain 'test'")
}
if !strings.Contains(result, "42") {
t.Error("expected output to contain '42'")
}
// Should contain ANSI escape codes (syntax highlighting)
if !strings.Contains(result, "\033[") {
t.Error("expected ANSI color codes in output")
}
})
t.Run("valid JSON array", func(t *testing.T) {
input := `[1, 2, 3]`
result := highlightJSON(input)
if !strings.Contains(result, "\033[") {
t.Error("expected ANSI color codes for JSON array")
}
})
t.Run("non-JSON returns unchanged", func(t *testing.T) {
input := "hello world"
result := highlightJSON(input)
if result != input {
t.Errorf("expected unchanged output for non-JSON, got %q", result)
}
})
t.Run("invalid JSON returns unchanged", func(t *testing.T) {
input := `{"broken": }`
result := highlightJSON(input)
if result != input {
t.Errorf("expected unchanged output for invalid JSON, got %q", result)
}
})
t.Run("empty string returns unchanged", func(t *testing.T) {
result := highlightJSON("")
if result != "" {
t.Errorf("expected empty string, got %q", result)
}
})
t.Run("whitespace-only returns unchanged", func(t *testing.T) {
input := " "
result := highlightJSON(input)
if result != input {
t.Errorf("expected unchanged output, got %q", result)
}
})
t.Run("JSON with leading ANSI escape sequences", func(t *testing.T) {
input := "\x1b[34h\x1b[?25h{\"key\":\"value\"}"
result := highlightJSON(input)
if !strings.Contains(result, "key") {
t.Error("expected output to contain 'key'")
}
if !strings.Contains(result, "value") {
t.Error("expected output to contain 'value'")
}
if !strings.Contains(result, "\033[") {
t.Error("expected ANSI color codes in output")
}
})
t.Run("JSON with non-JSON prefix text", func(t *testing.T) {
input := `some prefix {"key":"value"}`
result := highlightJSON(input)
if !strings.Contains(result, "key") {
t.Error("expected output to contain 'key'")
}
if !strings.Contains(result, "some prefix") {
t.Error("expected output to preserve prefix")
}
})
}

View File

@@ -598,6 +598,9 @@ func truncateToWidth(s string, maxWidth int) string {
}
// wrapText wraps text to fit within the given width, returning multiple lines.
// It is ANSI-aware: escape sequences are preserved intact and don't count
// toward the visible width. When a line wraps, any active ANSI state is
// carried over so colours continue on the next line.
func wrapText(s string, width int) []string {
if width <= 0 {
return nil
@@ -607,29 +610,84 @@ func wrapText(s string, width int) []string {
}
var lines []string
currentLine := ""
var currentLine strings.Builder
currentWidth := 0
// Track the last seen ANSI escape so we can re-apply it after a wrap
var activeANSI string
for _, r := range s {
i := 0
runes := []rune(s)
for i < len(runes) {
// Check for ANSI escape sequence: ESC [ ... final_byte
if runes[i] == '\033' && i+1 < len(runes) && runes[i+1] == '[' {
// Consume entire escape sequence
var seq strings.Builder
seq.WriteRune(runes[i]) // ESC
i++
seq.WriteRune(runes[i]) // [
i++
for i < len(runes) {
seq.WriteRune(runes[i])
// Final byte of CSI sequence is in range 0x40-0x7E
if runes[i] >= 0x40 && runes[i] <= 0x7E {
i++
break
}
i++
}
seqStr := seq.String()
currentLine.WriteString(seqStr)
// Track reset vs color sequences
if seqStr == "\033[0m" || seqStr == "\033[m" {
activeANSI = ""
} else {
activeANSI = seqStr
}
continue
}
r := runes[i]
runeWidth := lipgloss.Width(string(r))
if currentWidth+runeWidth > width {
// Start new line
lines = append(lines, currentLine)
currentLine = string(r)
currentWidth = runeWidth
} else {
currentLine += string(r)
currentWidth += runeWidth
// Close any active ANSI on this line before wrapping
if activeANSI != "" {
currentLine.WriteString("\033[0m")
}
lines = append(lines, currentLine.String())
currentLine.Reset()
currentWidth = 0
// Re-apply active ANSI on the new line
if activeANSI != "" {
currentLine.WriteString(activeANSI)
}
}
currentLine.WriteRune(r)
currentWidth += runeWidth
i++
}
// Don't forget the last line
if currentLine != "" {
lines = append(lines, currentLine)
if currentLine.Len() > 0 {
lines = append(lines, currentLine.String())
}
return lines
}
// wrapPreviewContent splits multi-line content (e.g. pretty-printed JSON) by
// newlines first, then wraps each line to fit within the given width.
func wrapPreviewContent(s string, width int) []string {
var result []string
for line := range strings.SplitSeq(s, "\n") {
if line == "" {
result = append(result, "")
continue
}
wrapped := wrapText(line, width)
result = append(result, wrapped...)
}
return result
}
func (m *model) updateFiltered() {
m.filtered = []int{}
m.filterRegexErr = nil
@@ -1178,7 +1236,7 @@ func (m model) renderMainView() string {
if m.showPreview && len(m.filtered) > 0 && m.cursor >= 0 && m.cursor < len(m.filtered) {
idx := m.filtered[m.cursor]
if idx < len(m.lines) {
previewContent = m.lines[idx].Content
previewContent = highlightJSON(m.lines[idx].Content)
}
}
@@ -1231,7 +1289,7 @@ func (m model) renderMainView() string {
var previewLines []string
// Wrap preview content to fit width
if previewContent != "" {
previewLines = wrapText(previewContent, innerWidth)
previewLines = wrapPreviewContent(previewContent, innerWidth)
}
// Pad preview to height
for len(previewLines) < previewH {
@@ -1285,12 +1343,11 @@ func (m model) renderMainView() string {
// Prepare preview lines (wrap text instead of truncating)
var previewLines []string
if previewContent != "" {
// Determine preview width for wrapping
previewW := leftW
if m.config.PreviewPosition == PreviewRight {
previewW = rightW
}
previewLines = wrapText(previewContent, previewW)
previewLines = wrapPreviewContent(previewContent, previewW)
}
for len(previewLines) < listHeight {
previewLines = append(previewLines, "")