Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -220,13 +220,21 @@ 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 |
| `Down`, `j` | Scroll down |
| `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 |
Expand All @@ -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 |
Expand Down
148 changes: 118 additions & 30 deletions internal/ui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand Down Expand Up @@ -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()
Expand All @@ -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
}
Expand Down Expand Up @@ -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())
}
}

Expand Down Expand Up @@ -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()
}
Expand All @@ -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
}
Expand All @@ -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":
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 {
Expand All @@ -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")
Expand Down Expand Up @@ -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"},
Expand Down
Loading
Loading