diff --git a/CHANGELOG.md b/CHANGELOG.md index d5dbef5..1537bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- Expanded reader content modes with `raw` and `json pretty` views for long cell values +- `F` in the expanded reader to cycle available content modes + +### Changed +- Expanded reader now auto-detects valid JSON object/array strings and opens them in formatted JSON mode by default + +### Fixed +- Expanded reader now refreshes content mode correctly when moving across rows, including paged row moves +- Expanded reader caches formatted render data so large JSON cells do not re-run formatting and highlighting on every render pass + ## [1.4.0] - 2026-03-24 ### Added diff --git a/README.md b/README.md index 33ecd56..4047a92 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Key features: - predicate filtering - visual indication of missing data in rows and columns, jump to next missing value - missing definition can toggle between NULL+NaN, NULL, NaN -- adaptive column width controls with an expanded reader for long text columns +- adaptive column width controls with an expanded reader for long text columns and structured JSON payloads - resize panels with the mouse - fast mouse scrolling @@ -220,6 +220,13 @@ Predicate notes: Use this overlay for long text cells, JSON payloads, and other values that do not fit comfortably in the table. +The reader supports two content modes: + +- `raw` for the sanitized original cell text +- `json pretty` for valid JSON object/array strings, with formatted indentation and light semantic color + +When a string cell contains valid JSON object/array text, the reader opens in `json pretty` mode by default. Otherwise it opens in `raw`. + | Key | Action | | --- | --- | | `Up`, `k` | Scroll up | @@ -227,6 +234,7 @@ Use this overlay for long text cells, JSON payloads, and other values that do no | `Left`, `h` | Pan left when wrap is off | | `Right`, `l` | Pan right when wrap is off | | `n`, `p` | Move to next/previous row in the same column | +| `F` | Cycle reader format (`raw` / `json pretty` when available) | | `W` | Toggle wrap | | `Space`, `Ctrl+F` | Page down | | `Ctrl+B` | Page up | @@ -236,6 +244,12 @@ Use this overlay for long text cells, JSON payloads, and other values that do no | `G` | Jump to bottom of current cell | | `Esc`, `q`, `w` | Close expanded reader | +Notes: + +- `json pretty` is available only for valid JSON object/array strings; scalar JSON values stay in `raw`. +- Pretty JSON defaults to wrap off so indentation and horizontal panning stay readable. +- If you move rows with `n` or `p`, the reader keeps the current mode when the next row supports it and falls back to `raw` otherwise. + ### File Picker Overlay | Key | Action | diff --git a/internal/ui/model.go b/internal/ui/model.go index bf348ac..945bdbc 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -180,6 +180,13 @@ type Model struct { detailCol string // column shown in detail panel detailTab int // 0=TopValues, 1=Stats, 2=Histogram readerCol string + readerModes []readerMode + readerMode readerMode + readerRender readerRenderData + readerRenderFor string + readerRenderOK bool + readerRenderW int + readerRenderM readerMode readerAbsRow int readerWrap bool readerVertOff int @@ -316,6 +323,13 @@ func (m *Model) resetLoadedDataState() { m.detailCol = "" m.detailTab = 0 m.readerCol = "" + m.readerModes = nil + m.readerMode = readerModeRaw + m.readerRender = readerRenderData{} + m.readerRenderFor = "" + m.readerRenderOK = false + m.readerRenderW = 0 + m.readerRenderM = readerModeRaw m.readerAbsRow = 0 m.readerWrap = false m.readerVertOff = 0 @@ -1000,6 +1014,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.clampTableRowCursor() if m.overlay == OverlayCellReader { m.readerAbsRow = m.tableOffset + m.tableRowCursor + if m.refreshReaderModes() { + m.statusMsg = "Reader format unavailable for this row; using raw" + } m.clampReaderOffsets() } if m.needsMorePreviewRows() { @@ -2418,7 +2435,10 @@ func (m *Model) openCellReader(colName, value string) { m.overlay = OverlayCellReader m.readerCol = colName m.readerAbsRow = m.currentAbsoluteRow() - m.readerWrap = defaultReaderWrap(value) + m.readerModes = readerModesForValue(value) + m.readerMode = defaultReaderMode(m.readerModes) + m.invalidateReaderRenderData() + m.readerWrap = defaultReaderWrapForMode(m.readerMode, value) m.readerVertOff = 0 m.readerHorizOff = 0 m.statusMsg = fmt.Sprintf("Opened expanded reader for %q", colName) @@ -2427,11 +2447,65 @@ func (m *Model) openCellReader(colName, value string) { func (m *Model) closeCellReader() { m.overlay = OverlayNone m.readerCol = "" + m.readerModes = nil + m.readerMode = readerModeRaw + m.invalidateReaderRenderData() m.readerAbsRow = 0 m.readerVertOff = 0 m.readerHorizOff = 0 } +func (m *Model) invalidateReaderRenderData() { + m.readerRender = readerRenderData{} + m.readerRenderFor = "" + m.readerRenderOK = false + m.readerRenderW = 0 + m.readerRenderM = readerModeRaw +} + +func (m *Model) refreshReaderModes() bool { + value, ok := m.readerCurrentValue() + prevMode := m.readerMode + prevModes := m.readerModes + m.readerModes = []readerMode{readerModeRaw} + m.readerMode = readerModeRaw + if !ok { + m.invalidateReaderRenderData() + return false + } + + m.readerModes = readerModesForValue(value) + if slices.Contains(m.readerModes, prevMode) { + m.readerMode = prevMode + if !slices.Equal(prevModes, m.readerModes) { + m.invalidateReaderRenderData() + } + return false + } + + m.readerMode = defaultReaderMode(m.readerModes) + if prevMode != m.readerMode || !slices.Equal(prevModes, m.readerModes) { + m.invalidateReaderRenderData() + } + return prevMode != readerModeRaw && m.readerMode == readerModeRaw +} + +func (m *Model) cycleReaderMode() { + if len(m.readerModes) <= 1 { + m.statusMsg = "No alternate reader format" + return + } + + idx := slices.Index(m.readerModes, m.readerMode) + if idx < 0 { + idx = 0 + } + m.readerMode = m.readerModes[(idx+1)%len(m.readerModes)] + m.invalidateReaderRenderData() + m.clampReaderOffsets() + m.statusMsg = fmt.Sprintf("Reader mode: %s", m.readerMode.label()) +} + // handleActiveColumnWidthAction keeps lowercase `w` adaptive: it toggles fit-width // for ordinary values, but opens the expanded reader when the visible column sample // indicates that widening would be a poor fit for the table layout. @@ -2750,22 +2824,29 @@ func (m Model) readerCurrentValue() (string, bool) { return m.valueForAbsoluteCell(m.readerAbsRow, colName) } -func (m Model) readerRenderedLines(bodyW int) []string { +func (m *Model) ensureReaderRenderData() readerRenderData { value, ok := m.readerCurrentValue() if !ok { - return []string{""} + m.invalidateReaderRenderData() + return readerRenderData{} } - logical := sanitizeMultilineLogicalLines(value) - - // Future: swap in structured renderers here (JSON pretty-format/highlight, - // Markdown, lists, key-value views) once the expanded reader supports them. - if m.readerWrap { - return wrapLogicalLines(logical, bodyW) + if m.readerRenderOK && m.readerRenderFor == value && m.readerRenderM == m.readerMode { + return m.readerRender } - return sliceLogicalLines(logical, m.readerHorizOff, bodyW) + + m.readerRender = newReaderRenderData(value, m.readerMode) + m.readerRenderFor = value + m.readerRenderOK = true + m.readerRenderW = m.readerRender.maxLineWidth() + m.readerRenderM = m.readerMode + return m.readerRender } -func (m Model) maxReaderVerticalOffset() int { +func (m *Model) readerRenderedLines(bodyW int) []string { + return m.ensureReaderRenderData().renderedLines(bodyW, m.readerWrap, m.readerHorizOff) +} + +func (m *Model) maxReaderVerticalOffset() int { innerW, _ := m.readerInnerDimensions() lines := m.readerRenderedLines(innerW) maxOff := len(lines) - m.readerBodyHeight() @@ -2775,22 +2856,15 @@ func (m Model) maxReaderVerticalOffset() int { return maxOff } -func (m Model) maxReaderHorizontalOffset() int { +func (m *Model) maxReaderHorizontalOffset() int { if m.readerWrap { return 0 } innerW, _ := m.readerInnerDimensions() - value, ok := m.readerCurrentValue() - if !ok { + if !m.ensureReaderRenderData().hasContent() { return 0 } - maxLineW := 0 - for _, line := range sanitizeMultilineLogicalLines(value) { - if w := lipgloss.Width(line); w > maxLineW { - maxLineW = w - } - } - maxOff := maxLineW - innerW + maxOff := m.readerRenderW - innerW if maxOff < 0 { return 0 } @@ -2862,9 +2936,9 @@ func (m *Model) toggleReaderWrap() { m.readerWrap = !m.readerWrap m.clampReaderOffsets() if m.readerWrap { - m.statusMsg = "Reader wrap on" + m.statusMsg = fmt.Sprintf("Reader wrap on (%s)", m.readerMode.label()) } else { - m.statusMsg = "Reader wrap off" + m.statusMsg = fmt.Sprintf("Reader wrap off (%s)", m.readerMode.label()) } } @@ -2896,6 +2970,9 @@ func (m Model) moveReaderRow(delta int) (tea.Model, tea.Cmd) { } m.tableOffset++ m.readerAbsRow = m.currentAbsoluteRow() + if m.refreshReaderModes() { + m.statusMsg = "Reader format unavailable for this row; using raw" + } m.clampReaderOffsets() return m, m.nextPreviewCmd() } @@ -2909,12 +2986,18 @@ func (m Model) moveReaderRow(delta int) (tea.Model, tea.Cmd) { } m.tableOffset-- m.readerAbsRow = m.currentAbsoluteRow() + if m.refreshReaderModes() { + m.statusMsg = "Reader format unavailable for this row; using raw" + } m.clampReaderOffsets() return m, m.nextPreviewCmd() } } m.readerAbsRow = m.currentAbsoluteRow() + if m.refreshReaderModes() { + m.statusMsg = "Reader format unavailable for this row; using raw" + } m.clampReaderOffsets() return m, nil } @@ -2933,6 +3016,8 @@ func (m Model) handleReaderKey(key string) (tea.Model, tea.Cmd) { m.scrollReaderHoriz(1) case "W": m.toggleReaderWrap() + case "F": + m.cycleReaderMode() case "n": return m.moveReaderRow(1) case "p": @@ -3397,7 +3482,7 @@ func (m Model) viewBottomBar() string { var hints string switch { case m.overlay == OverlayCellReader: - hints = "w/Esc:close ↑↓/jk:scroll ←→/hl:pan W:wrap n/p:row Space/C-f/C-b:page C-d/u:half g/G:top/bottom" + hints = "w/Esc:close ↑↓/jk:scroll ←→/hl:pan W:wrap F:format/raw n/p:row Space/C-f/C-b:page C-d/u:half g/G:top/bottom" case m.focus == FocusColumns: hints = "Ctrl+O:open jk/↑↓:move Space/C-f/C-b:page C-d/u:half gG/HML:jump m:missing-mode /:search v:sel-list x:toggle a/d/y:sel" default: @@ -3456,13 +3541,15 @@ func (m Model) viewCellReader(w, h int) string { if colType != "" { header += " " + colType } + header = readerHeaderStyle.Render(header) + header = lipgloss.JoinHorizontal(lipgloss.Left, header, " ", readerModeBadge(m.readerMode)) if m.readerWrap { - header += " wrap:on" + header = lipgloss.JoinHorizontal(lipgloss.Left, header, readerHeaderStyle.Render(" wrap:on")) } else { - header += " wrap:off" + header = lipgloss.JoinHorizontal(lipgloss.Left, header, readerHeaderStyle.Render(" wrap:off")) } - header += fmt.Sprintf(" len:%d", utf8.RuneCountInString(value)) - header = clampLineWidth(readerHeaderStyle.Render(header), w) + header = lipgloss.JoinHorizontal(lipgloss.Left, header, readerHeaderStyle.Render(fmt.Sprintf(" len:%d", utf8.RuneCountInString(value)))) + header = clampLineWidth(header, w) topEdge := readerEdgeStyle.Width(w).Render(strings.Repeat("─", max(1, w))) if m.readerVertOff == 0 { @@ -3487,13 +3574,13 @@ func (m Model) viewCellReader(w, h int) string { bodyLines := make([]string, 0, bodyH) for _, line := range lines[start:end] { - bodyLines = append(bodyLines, clampLineWidth(readerBodyStyle.Render(padDisplayRight(line, w)), w)) + bodyLines = append(bodyLines, padReaderLine(line, w, m.readerMode)) } for len(bodyLines) < bodyH { bodyLines = append(bodyLines, readerBodyStyle.Render(strings.Repeat(" ", w))) } - footer := " Scroll: ↑↓/jk Row: n/p Wrap: W Page: Space/C-f/C-b Close: Esc/w" + footer := " Scroll: ↑↓/jk Row: n/p Wrap: W F:format/raw Page: Space/C-f/C-b Close: Esc/w" footer = clampLineWidth(readerFooterStyle.Render(footer), w) return strings.Join(append([]string{header, topEdge}, append(bodyLines, bottomEdge, footer)...), "\n") @@ -4084,6 +4171,7 @@ func (m Model) viewHelp() string { {"↑/↓ or j/k", "Scroll content"}, {"←/→ or h/l", "Pan horizontally (wrap off)"}, {"n / p", "Next / previous row in same column"}, + {"F", "Cycle reader format"}, {"W", "Toggle wrap"}, {"Ctrl+F / Space", "Page down"}, {"Ctrl+B", "Page up"}, diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index a761e9f..960558d 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" _ "github.com/marcboeker/go-duckdb" "github.com/robince/parqview/internal/engine" @@ -1633,13 +1634,16 @@ func TestHandleTableKeyShiftWOpensJSONReaderWithWrapOffByDefault(t *testing.T) { m.height = 12 m.tableCols = []string{"id", "payload"} m.selectedColName = "payload" - m.tableData = [][]string{{"1", strings.Repeat(`{"alpha":1,"beta":2,"gamma":"delta"}`, 4)}} + m.tableData = [][]string{{"1", `{"alpha":"` + strings.Repeat("delta", 16) + `","beta":2,"gamma":{"nested":true}}`}} updated, _ := m.handleTableKey("W") m = updated.(Model) if m.overlay != OverlayCellReader { t.Fatalf("expected expanded reader overlay, got %v", m.overlay) } + if m.readerMode != readerModeJSONPretty { + t.Fatalf("expected JSON-like payload to default to json pretty mode, got %v", m.readerMode) + } if m.readerWrap { t.Fatal("expected JSON-like payload to default to wrap:off") } @@ -1660,6 +1664,124 @@ func TestHandleTableKeyShiftWOpensJSONReaderWithWrapOffByDefault(t *testing.T) { } } +func TestReaderModesForValueOnlyEnablesJSONPrettyForValidObjectOrArray(t *testing.T) { + cases := []struct { + name string + value string + want []readerMode + }{ + {name: "object", value: `{"alpha":1}`, want: []readerMode{readerModeRaw, readerModeJSONPretty}}, + {name: "array", value: `[1,2,3]`, want: []readerMode{readerModeRaw, readerModeJSONPretty}}, + {name: "scalar", value: `123`, want: []readerMode{readerModeRaw}}, + {name: "invalid", value: `{"alpha":1}{"beta":2}`, want: []readerMode{readerModeRaw}}, + {name: "prose", value: `hello`, want: []readerMode{readerModeRaw}}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := readerModesForValue(tc.value); !slices.Equal(got, tc.want) { + t.Fatalf("readerModesForValue(%q)=%v want %v", tc.value, got, tc.want) + } + }) + } +} + +func TestHandleTableKeyShiftWKeepsInvalidJSONInRawMode(t *testing.T) { + m := newTestModel() + m.width = 70 + m.height = 12 + m.tableCols = []string{"id", "payload"} + m.selectedColName = "payload" + m.tableData = [][]string{{"1", `{"alpha":1}{"beta":2}`}} + + updated, _ := m.handleTableKey("W") + m = updated.(Model) + if m.overlay != OverlayCellReader { + t.Fatalf("expected expanded reader overlay, got %v", m.overlay) + } + if m.readerMode != readerModeRaw { + t.Fatalf("expected invalid JSON to stay in raw mode, got %v", m.readerMode) + } + + updated, _ = m.handleReaderKey("F") + m = updated.(Model) + if got := m.statusMsg; got != "No alternate reader format" { + t.Fatalf("expected no-alternate-format status, got %q", got) + } +} + +func TestHandleReaderKeyFTogglesBetweenRawAndPrettyJSON(t *testing.T) { + m := newTestModel() + m.width = 90 + m.height = 12 + m.tableCols = []string{"id", "payload"} + m.selectedColName = "payload" + m.tableData = [][]string{{"1", `{"alpha":1,"beta":"two"}`}} + + updated, _ := m.handleTableKey("W") + m = updated.(Model) + if m.readerMode != readerModeJSONPretty { + t.Fatalf("expected JSON reader mode by default, got %v", m.readerMode) + } + + updated, _ = m.handleReaderKey("F") + m = updated.(Model) + if m.readerMode != readerModeRaw { + t.Fatalf("expected F to switch to raw mode, got %v", m.readerMode) + } + if got := m.statusMsg; got != "Reader mode: raw" { + t.Fatalf("expected raw mode status, got %q", got) + } + + updated, _ = m.handleReaderKey("F") + m = updated.(Model) + if m.readerMode != readerModeJSONPretty { + t.Fatalf("expected second F to switch back to json pretty mode, got %v", m.readerMode) + } + if got := m.statusMsg; got != "Reader mode: json pretty" { + t.Fatalf("expected json pretty status, got %q", got) + } +} + +func TestReaderRenderCacheTracksModeAndValueChanges(t *testing.T) { + m := newTestModel() + m.width = 90 + m.height = 12 + m.tableCols = []string{"id", "payload"} + m.selectedColName = "payload" + m.tableData = [][]string{{"1", `{"alpha":1,"beta":"two"}`}} + + updated, _ := m.handleTableKey("W") + m = updated.(Model) + + prettyLines := m.readerRenderedLines(40) + if !m.readerRenderOK { + t.Fatal("expected reader render cache to be populated") + } + if m.readerRenderFor != `{"alpha":1,"beta":"two"}` { + t.Fatalf("expected cache to track current value, got %q", m.readerRenderFor) + } + if m.readerRenderM != readerModeJSONPretty { + t.Fatalf("expected cache to track json pretty mode, got %v", m.readerRenderM) + } + + updated, _ = m.handleReaderKey("F") + m = updated.(Model) + rawLines := m.readerRenderedLines(40) + if m.readerRenderM != readerModeRaw { + t.Fatalf("expected cache to refresh for raw mode, got %v", m.readerRenderM) + } + if slices.Equal(prettyLines, rawLines) { + t.Fatal("expected raw render lines to differ from pretty render lines") + } + + m.tableData[0][1] = `{"gamma":3}` + _ = m.readerRenderedLines(40) + if m.readerRenderFor != `{"gamma":3}` { + t.Fatalf("expected cache to refresh for changed cell value, got %q", m.readerRenderFor) + } +} + func TestHandleTableKeyWFallsBackToFitWidthWhenCurrentRowHasNoCell(t *testing.T) { m := newTestModel() m.width = 90 @@ -1769,12 +1891,42 @@ func TestReaderKeepsRowNavigationExplicitAndClampsOffsets(t *testing.T) { } } +func TestReaderRowNavigationFallsBackToRawWhenNextRowCannotFormat(t *testing.T) { + m := newTestModel() + m.width = 90 + m.height = 12 + m.tableCols = []string{"id", "payload"} + m.selectedColName = "payload" + m.tableData = [][]string{ + {"1", `{"alpha":1,"beta":2}`}, + {"2", "plain text"}, + } + + updated, _ := m.handleTableKey("W") + m = updated.(Model) + if m.readerMode != readerModeJSONPretty { + t.Fatalf("expected JSON pretty mode on first row, got %v", m.readerMode) + } + + updated, cmd := m.handleReaderKey("n") + if cmd != nil { + t.Fatalf("expected no load command when moving within visible page, got %v", cmd) + } + m = updated.(Model) + if m.readerMode != readerModeRaw { + t.Fatalf("expected reader mode to fall back to raw, got %v", m.readerMode) + } + if got := m.statusMsg; got != "Reader format unavailable for this row; using raw" { + t.Fatalf("expected fallback status, got %q", got) + } +} + func TestReaderUsesLoadedPreviewOffsetWhenPagingPastVisibleWindow(t *testing.T) { m := newCmdTestModel() m.width = 80 m.height = 12 - m.tableCols = []string{"id", "body"} - m.selectedColName = "body" + m.tableCols = []string{"id", "payload"} + m.selectedColName = "payload" visibleRows := m.visibleTableRows() if visibleRows < 1 { @@ -1785,7 +1937,7 @@ func TestReaderUsesLoadedPreviewOffsetWhenPagingPastVisibleWindow(t *testing.T) for i := range rows { body := fmt.Sprintf("row-%d", i+1) if i == visibleRows-1 { - body = strings.Repeat("this is a much longer row ", 8) + body = `{"alpha":"` + strings.Repeat("json", 8) + `","beta":2}` } if i == visibleRows { body = "short" @@ -1795,9 +1947,12 @@ func TestReaderUsesLoadedPreviewOffsetWhenPagingPastVisibleWindow(t *testing.T) m.totalRows = int64(len(rows) + 1) m.tableData = rows m.tableRowCursor = visibleRows - 1 - m.openCellReader("body", rows[m.tableRowCursor][1]) + m.openCellReader("payload", rows[m.tableRowCursor][1]) m.readerWrap = false m.readerHorizOff = 25 + if m.readerMode != readerModeJSONPretty { + t.Fatalf("expected JSON pretty mode before paging, got %v", m.readerMode) + } updated, cmd := m.handleReaderKey("n") if cmd == nil { @@ -1810,6 +1965,12 @@ func TestReaderUsesLoadedPreviewOffsetWhenPagingPastVisibleWindow(t *testing.T) if got, ok := m.readerCurrentValue(); !ok || got != "short" { t.Fatalf("expected reader to show next row from loaded preview, got %q ok=%v", got, ok) } + if m.readerMode != readerModeRaw { + t.Fatalf("expected reader mode to refresh to raw for loaded next row, got %v", m.readerMode) + } + if got := m.statusMsg; got != "Reader format unavailable for this row; using raw" { + t.Fatalf("expected fallback status after paged row move, got %q", got) + } if m.readerHorizOff != 0 { t.Fatalf("expected horizontal offset to clamp before preview reload, got %d", m.readerHorizOff) } @@ -1909,6 +2070,27 @@ func TestViewCellReaderWrapsWithinWidthAndShowsEdgeMarkers(t *testing.T) { } } +func TestViewCellReaderFormatsPrettyJSONAndShowsModeBadge(t *testing.T) { + m := newTestModel() + m.width = 90 + m.height = 14 + m.tableCols = []string{"id", "payload"} + m.selectedColName = "payload" + m.tableData = [][]string{{"1", `{"alpha":1,"beta":{"nested":true},"escaped":"\u001b[31mhello"}`}} + m.openCellReader("payload", m.tableData[0][1]) + + out := ansi.Strip(m.viewCellReader(50, 11)) + if !strings.Contains(out, "JSON") { + t.Fatalf("expected reader header to show JSON mode badge, got %q", out) + } + if !strings.Contains(out, `"alpha": 1`) { + t.Fatalf("expected reader body to pretty-print JSON, got %q", out) + } + if !strings.Contains(out, `"escaped": "\u001b[31mhello"`) { + t.Fatalf("expected escaped JSON control sequence to remain escaped, got %q", out) + } +} + func TestViewCellReaderHeaderUsesReaderAbsoluteRowID(t *testing.T) { m := newTestModel() m.width = 80 diff --git a/internal/ui/reader_render.go b/internal/ui/reader_render.go new file mode 100644 index 0000000..92e0303 --- /dev/null +++ b/internal/ui/reader_render.go @@ -0,0 +1,311 @@ +package ui + +import ( + "bytes" + "encoding/json" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +type readerMode int + +const ( + readerModeRaw readerMode = iota + readerModeJSONPretty +) + +func (m readerMode) label() string { + switch m { + case readerModeJSONPretty: + return "json pretty" + default: + return "raw" + } +} + +func readerModeBadge(mode readerMode) string { + switch mode { + case readerModeJSONPretty: + return readerModeJSONBadgeStyle.Render("JSON") + default: + return readerModeRawBadgeStyle.Render("RAW") + } +} + +func readerModesForValue(value string) []readerMode { + modes := []readerMode{readerModeRaw} + if isJSONLikeValid(value) { + modes = append(modes, readerModeJSONPretty) + } + return modes +} + +func defaultReaderMode(modes []readerMode) readerMode { + for _, mode := range modes { + if mode == readerModeJSONPretty { + return readerModeJSONPretty + } + } + return readerModeRaw +} + +func defaultReaderWrapForMode(mode readerMode, value string) bool { + if mode == readerModeJSONPretty { + return false + } + return defaultReaderWrap(value) +} + +type readerRenderData struct { + logicalLines []string + ansiAware bool +} + +func (d readerRenderData) hasContent() bool { + return d.logicalLines != nil +} + +func newReaderRenderData(value string, mode readerMode) readerRenderData { + if mode == readerModeJSONPretty { + if pretty, ok := prettyJSONReaderValue(value); ok { + return readerRenderData{ + logicalLines: highlightPrettyJSON(pretty), + ansiAware: true, + } + } + } + return readerRenderData{logicalLines: sanitizeMultilineLogicalLines(value)} +} + +func (d readerRenderData) renderedLines(bodyW int, wrap bool, horizOff int) []string { + if d.ansiAware { + if wrap { + return wrapANSILogicalLines(d.logicalLines, bodyW) + } + return sliceANSILogicalLines(d.logicalLines, horizOff, bodyW) + } + if wrap { + return wrapLogicalLines(d.logicalLines, bodyW) + } + return sliceLogicalLines(d.logicalLines, horizOff, bodyW) +} + +func (d readerRenderData) maxLineWidth() int { + maxLineW := 0 + for _, line := range d.logicalLines { + var w int + if d.ansiAware { + w = ansi.StringWidth(line) + } else { + w = lipgloss.Width(line) + } + if w > maxLineW { + maxLineW = w + } + } + return maxLineW +} + +func padReaderLine(line string, targetW int, mode readerMode) string { + if mode == readerModeJSONPretty { + return padANSIRight(line, targetW) + } + return clampLineWidth(readerBodyStyle.Render(padDisplayRight(line, targetW)), targetW) +} + +func isJSONLikeValid(value string) bool { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return false + } + switch trimmed[0] { + case '{', '[': + default: + return false + } + return json.Valid([]byte(trimmed)) +} + +func prettyJSONReaderValue(value string) (string, bool) { + trimmed := strings.TrimSpace(value) + if !isJSONLikeValid(trimmed) { + return "", false + } + + var buf bytes.Buffer + if err := json.Indent(&buf, []byte(trimmed), "", " "); err != nil { + return "", false + } + return buf.String(), true +} + +func highlightPrettyJSON(pretty string) []string { + lines := strings.Split(pretty, "\n") + if len(lines) == 0 { + return []string{""} + } + + styled := make([]string, 0, len(lines)) + for _, line := range lines { + styled = append(styled, highlightPrettyJSONLine(line)) + } + return styled +} + +func highlightPrettyJSONLine(line string) string { + if line == "" { + return "" + } + + var b strings.Builder + for i := 0; i < len(line); { + switch line[i] { + case ' ', '\t': + b.WriteByte(line[i]) + i++ + case '{', '}', '[', ']', ':', ',': + b.WriteString(readerJSONPunctuationStyle.Render(line[i : i+1])) + i++ + case '"': + end := scanJSONStringToken(line, i) + token := line[i:end] + if jsonStringTokenIsKey(line, end) { + b.WriteString(readerJSONKeyStyle.Render(token)) + } else { + b.WriteString(readerJSONStringStyle.Render(token)) + } + i = end + default: + if keyword := jsonKeywordTokenAt(line, i); keyword != "" { + b.WriteString(readerJSONKeywordStyle.Render(keyword)) + i += len(keyword) + continue + } + if number := jsonNumberTokenAt(line, i); number != "" { + b.WriteString(readerJSONNumberStyle.Render(number)) + i += len(number) + continue + } + b.WriteString(readerBodyStyle.Render(line[i : i+1])) + i++ + } + } + return b.String() +} + +func scanJSONStringToken(line string, start int) int { + i := start + 1 + for i < len(line) { + switch line[i] { + case '\\': + i += 2 + case '"': + return i + 1 + default: + i++ + } + } + return len(line) +} + +func jsonStringTokenIsKey(line string, end int) bool { + for i := end; i < len(line); i++ { + switch line[i] { + case ' ', '\t': + continue + case ':': + return true + default: + return false + } + } + return false +} + +func jsonKeywordTokenAt(line string, start int) string { + for _, keyword := range []string{"true", "false", "null"} { + if strings.HasPrefix(line[start:], keyword) && jsonTokenBoundary(line, start+len(keyword)) { + return keyword + } + } + return "" +} + +func jsonNumberTokenAt(line string, start int) string { + if start >= len(line) { + return "" + } + if line[start] != '-' && (line[start] < '0' || line[start] > '9') { + return "" + } + + end := start + for end < len(line) { + ch := line[end] + if (ch >= '0' && ch <= '9') || ch == '-' || ch == '+' || ch == '.' || ch == 'e' || ch == 'E' { + end++ + continue + } + break + } + if end == start || !jsonTokenBoundary(line, end) { + return "" + } + return line[start:end] +} + +func jsonTokenBoundary(line string, pos int) bool { + if pos >= len(line) { + return true + } + switch line[pos] { + case ' ', '\t', '\n', '\r', ',', ':', '}', ']': + return true + default: + return false + } +} + +func wrapANSILogicalLines(lines []string, maxW int) []string { + if maxW <= 0 { + return []string{""} + } + var out []string + for _, line := range lines { + out = append(out, strings.Split(ansi.Hardwrap(line, maxW, true), "\n")...) + } + if len(out) == 0 { + return []string{""} + } + return out +} + +func sliceANSILogicalLines(lines []string, startW, maxW int) []string { + if maxW <= 0 { + return []string{""} + } + if startW < 0 { + startW = 0 + } + out := make([]string, 0, len(lines)) + for _, line := range lines { + out = append(out, ansi.Cut(line, startW, startW+maxW)) + } + if len(out) == 0 { + return []string{""} + } + return out +} + +func padANSIRight(line string, targetW int) string { + if targetW <= 0 { + return "" + } + padding := targetW - ansi.StringWidth(line) + if padding <= 0 { + return line + } + return line + strings.Repeat(" ", padding) +} diff --git a/internal/ui/styles.go b/internal/ui/styles.go index 40f418a..fb736d5 100644 --- a/internal/ui/styles.go +++ b/internal/ui/styles.go @@ -133,6 +133,18 @@ var ( readerBodyStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("252")) + readerModeRawBadgeStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("230")). + Background(lipgloss.Color("240")). + Padding(0, 1) + + readerModeJSONBadgeStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("235")). + Background(lipgloss.Color("80")). + Padding(0, 1) + readerFooterStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("243")) @@ -143,6 +155,21 @@ var ( Foreground(lipgloss.Color("69")). Bold(true) + readerJSONPunctuationStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("242")) + + readerJSONKeyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("117")) + + readerJSONStringStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("114")) + + readerJSONNumberStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("215")) + + readerJSONKeywordStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("173")) + // Search searchPromptStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("62")) diff --git a/testdata/gen_test_data.go b/testdata/gen_test_data.go index 61ee95f..6344ec1 100644 --- a/testdata/gen_test_data.go +++ b/testdata/gen_test_data.go @@ -60,5 +60,47 @@ func main() { os.Exit(1) } - fmt.Println("Generated testdata/sample.parquet and testdata/sample.csv") + _, err = db.Exec(` + COPY ( + SELECT * FROM ( + VALUES + ( + 1, + 'compact_object', + '{"event":"signup","user":{"id":101,"plan":"pro"},"tags":["alpha","beta"],"active":true}', + 'Short plain-text row for raw reader comparison.' + ), + ( + 2, + 'long_object', + '{"event":"page_view","path":"/atlas/studies/session/42","body":"This payload is intentionally long so the expanded reader has something real to format and pan across.","metrics":{"duration_ms":1834,"scroll_depth":0.82,"retry_count":2},"flags":[true,false,null],"notes":["first","second","third"]}', + 'This is an intentionally long plain string column entry to compare wrap behavior against the JSON payload column in the expanded reader.' + ), + ( + 3, + 'json_array', + '[{"step":"extract","ok":true},{"step":"transform","ok":true},{"step":"load","ok":false,"reason":"network timeout"}]', + 'Another regular string row with no JSON semantics.' + ), + ( + 4, + 'invalid_json', + '{"broken": true', + 'Invalid JSON in payload should stay in raw mode.' + ), + ( + 5, + 'plain_text', + 'This payload column is just prose, not JSON. It should open in raw mode and still support row-to-row reader navigation.', + 'Final plain string value.' + ) + ) AS t(id, scenario, payload, description) + ) TO 'testdata/json_reader_sample.parquet' (FORMAT PARQUET); + `) + if err != nil { + fmt.Fprintf(os.Stderr, "generate json reader parquet: %v\n", err) + os.Exit(1) + } + + fmt.Println("Generated testdata/sample.parquet, testdata/sample.csv, and testdata/json_reader_sample.parquet") } diff --git a/testdata/json_reader_sample.parquet b/testdata/json_reader_sample.parquet new file mode 100644 index 0000000..33cb9a7 Binary files /dev/null and b/testdata/json_reader_sample.parquet differ