From 3f39014d58541ebe94e0219477ff39c552193852 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 16:06:01 +0545 Subject: [PATCH 01/13] Stop the array editor bringing the view down when it opens Double-clicking an array cell replaced the whole view with the error screen: effect_update_depth_exceeded, an effect reading and writing the same state. The array editor's seed effect assigned `items` and then read `items.length` on the next line to size the input list. That read made the effect depend on the state it had just written, and because every run assigns a brand new array the dependency fired again immediately - nothing in it ever converged. It is built into a local now and both the assignment and the length come off that. The cell editor had the same shape in its own seed effect: it wrote `maxLineLen` and read it back a line later to decide soft wrap. That one happened to settle, because the cell-identity guard above it returns before the write on any re-run, but it is the same mistake and it is measured into a local too. --- .changeset/array-editor-effect-loop.md | 2 ++ src/lib/components/ArrayCellEditor.svelte | 10 ++++++++-- src/lib/components/CellEditorPanel.svelte | 9 +++++++-- 3 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 .changeset/array-editor-effect-loop.md diff --git a/.changeset/array-editor-effect-loop.md b/.changeset/array-editor-effect-loop.md new file mode 100644 index 00000000..99efc659 --- /dev/null +++ b/.changeset/array-editor-effect-loop.md @@ -0,0 +1,2 @@ +### Bug Fixes +- Opening an array cell no longer takes the view down with "This view hit an error" diff --git a/src/lib/components/ArrayCellEditor.svelte b/src/lib/components/ArrayCellEditor.svelte index c030d55c..bc2e198e 100644 --- a/src/lib/components/ArrayCellEditor.svelte +++ b/src/lib/components/ArrayCellEditor.svelte @@ -32,10 +32,16 @@ // Seed the working copy whenever the editor opens for a new cell. $effect(() => { if (open) { - items = (Array.isArray(value) ? value : []).map((el) => ({ + // Built into a local and read from it. Assigning `items` and then reading + // `items.length` on the next line made this effect depend on the state it + // had just written - and since each run assigns a brand new array, the + // dependency fired again every time. Opening an array cell took the whole + // view down with effect_update_depth_exceeded. + const next = (Array.isArray(value) ? value : []).map((el) => ({ v: el === null || el === undefined ? null : String(el), })) - inputEls.length = items.length + items = next + inputEls.length = next.length } }) diff --git a/src/lib/components/CellEditorPanel.svelte b/src/lib/components/CellEditorPanel.svelte index 5b376ab2..e1b801d8 100644 --- a/src/lib/components/CellEditorPanel.svelte +++ b/src/lib/components/CellEditorPanel.svelte @@ -155,8 +155,13 @@ // Structured text opens unwrapped: pretty-printed JSON is short lines // already, and unwrapped is what lets the gutter number them. Prose keeps // wrapping. Alt+Z still flips it either way. - maxLineLen = longestLine(text) - wrap = maxLineLen <= MAX_WRAP_LINE && (wrapPref ?? !/^\s*[[{]/.test(text)) + // Measured into a local and used from it. Reading `maxLineLen` back on the + // next line made this effect depend on a value it had just written, which + // is the read-and-write cycle Svelte refuses to run - it took down the + // whole view with effect_update_depth_exceeded on any cell that reached it. + const longest = longestLine(text) + maxLineLen = longest + wrap = longest <= MAX_WRAP_LINE && (wrapPref ?? !/^\s*[[{]/.test(text)) // Undo/redo, word-delete and line-delete for every plain field in the app // live in `input-shortcuts.js`, and its history is keyed by element. This // textarea outlives the cell it is showing, so the history has to be From 49b3e1519114b0dbf2ddddb2a70b08521b38f47f Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 16:09:34 +0545 Subject: [PATCH 02/13] Make the array editor read as a list Eleven elements came out as eleven separately bordered pills stacked on top of each other, each one looking like its own field to fill in, when the thing on screen is one list. It is one bordered surface with hairline separators now, and the values inside it are borderless until focused. The row controls faded in on hover. They moved nothing, but the row looked different every time the pointer crossed it, and they were out of reach of the keyboard entirely. They are always drawn now, just quiet, and come up to full contrast with the row. Rows are h-7 inside an h-8 row, which is the scale the rest of the app uses; they were running around 50px. The drag handle and the index sit back at lower contrast so the values lead. Save is bg-primary, the way ConfirmDialog and the update dialog paint their primary action - this was the one button in the app painted in the foreground colour. Every control in the dialog has a focus ring. One of the input's classes ended in a bare `hover:` with nothing after it. --- .changeset/array-editor-effect-loop.md | 3 ++ src/lib/components/ArrayCellEditor.svelte | 48 ++++++++++++++--------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/.changeset/array-editor-effect-loop.md b/.changeset/array-editor-effect-loop.md index 99efc659..505db6fd 100644 --- a/.changeset/array-editor-effect-loop.md +++ b/.changeset/array-editor-effect-loop.md @@ -1,2 +1,5 @@ ### Bug Fixes - Opening an array cell no longer takes the view down with "This view hit an error" + +### Changes +- The array editor reads as one list instead of a stack of separate fields, and its row controls stay put instead of appearing under the pointer diff --git a/src/lib/components/ArrayCellEditor.svelte b/src/lib/components/ArrayCellEditor.svelte index bc2e198e..ca0957c6 100644 --- a/src/lib/components/ArrayCellEditor.svelte +++ b/src/lib/components/ArrayCellEditor.svelte @@ -106,7 +106,7 @@ onclick={(e) => { if (e.target === e.currentTarget) cancel() }} onkeydown={(e) => { if (e.key === 'Escape') { e.preventDefault(); cancel() } }} > -
+
@@ -130,8 +130,10 @@
- -
+ +
{#if items.length === 0}

Empty array {'{}'}

@@ -144,21 +146,22 @@
{:else} -
+
{#each items as item, i (i)}
0 && 'border-t border-border/25', dragIndex === i && 'opacity-40', - overIndex === i && dragIndex !== i && 'bg-primary/5', + overIndex === i && dragIndex !== i ? 'bg-primary/10' : 'hover:bg-muted/25', )} ondragover={(e) => onDragOver(i, e)} ondrop={() => onDrop(i)} > {#if overIndex === i && dragIndex !== null && dragIndex !== i} - + {/if} - {i} + {i} {#if item.v === null} @@ -186,17 +189,23 @@ placeholder="value" spellcheck="false" autocomplete="off" - class= "field-surface h-8 min-w-0 flex-1 bg-muted/15 px-2.5 font-mono text-ui-xs leading-none text-foreground outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-muted-foreground hover:" + class="h-7 min-w-0 flex-1 rounded-md bg-transparent px-2 font-mono text-ui-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/50 hover:bg-muted/25 focus:bg-muted/30 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring" onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addItem() } }} /> {/if} -
+ +
@@ -207,7 +216,7 @@
-
+
+ >Clear all {/if}
+
From 0cbd8c1ee2d634b2ac4eb39ef2e128783100187d Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 16:16:33 +0545 Subject: [PATCH 03/13] Show arrays as JSON, and keep switch thumbs inside their track The same array read three different ways depending on where you looked at it: a text[] cell showed {Dhaka,Gazipur}, the jsonb column beside it showed ["Dhaka","Gazipur"], and double-clicking either put ["Dhaka","Gazipur"] in the box to edit. The row you were reading and the value you were editing did not look like the same thing. Array columns read as JSON now, which is the form the editor already used. Writes still go out as the Postgres literal - that is what the server wants, and it was never the right thing to read. The switch thumb escaped its track at other zoom levels. Its travel was a flat translate-x-4 that did not account for the track's own inset, so it sat 2px from the left when off and 4px from the right when on, and the MCP one drew its track with a 1px border - fixed, while the width, the thumb and the travel all scale with the app zoom, so the three drifted apart as soon as the zoom left 100%. The tracks carry their inset as padding now, which puts the travel back on the standard scale and makes it exactly the inner width minus the thumb, and the border is an inset ring, which costs no layout at any zoom. --- src/lib/components/DataTable.svelte | 27 +++++++++++++++----- src/lib/components/EntityRelationPage.svelte | 4 +-- src/lib/components/McpPanel.svelte | 8 +++--- src/lib/components/SettingsDialog.svelte | 4 +-- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 1efceea5..32e1484e 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -1171,18 +1171,31 @@ import FilterX from "@lucide/svelte/icons/filter-x"; function pgArrayText(arr) { return "{" + arr.map(pgArrayElem).join(",") + "}"; } - // Cached pgAdmin-style display for SQL *array columns* only (drawCell passes the - // value after confirming the column type ends with []). Cached per value object - // so the scroll hot path never rebuilds the string. jsonb arrays never reach - // this - they render as ["a","b"] via formatCell. + // Display for SQL *array columns* (drawCell passes the value after confirming + // the column type ends with []). Cached per value object so the scroll hot + // path never rebuilds the string. + // + // JSON form, not the pgAdmin literal `{a,b}`. Three things in this app showed + // the same array three different ways: a text[] cell read `{Dhaka,Gazipur}`, + // the jsonb column beside it read `["Dhaka","Gazipur"]`, and double-clicking + // either one put `["Dhaka","Gazipur"]` in the box to edit - so the row you + // were reading and the value you were editing did not look like the same + // thing. They all read as JSON now, which is the form the editor already used. + // pgArrayText is still what writes go out as; that is the literal Postgres + // wants and it was never the right thing to read. /** @type {WeakMap} */ const _arrayDisplayCache = new WeakMap(); function arrayDisplay(arr) { const hit = _arrayDisplayCache.get(arr); if (hit !== undefined) return hit; - const s = pgArrayText(arr); - _arrayDisplayCache.set(arr, s); - return s; + let s; + try { + s = JSON.stringify(arr); + } catch { + s = pgArrayText(arr); // cyclic or otherwise unserialisable - fall back + } + _arrayDisplayCache.set(arr, s ?? pgArrayText(arr)); + return s ?? pgArrayText(arr); } /** True when a column's SQL type is an array (ends with []). */ function isSqlArrayType(colType) { diff --git a/src/lib/components/EntityRelationPage.svelte b/src/lib/components/EntityRelationPage.svelte index c74b33ad..af5802ea 100644 --- a/src/lib/components/EntityRelationPage.svelte +++ b/src/lib/components/EntityRelationPage.svelte @@ -1300,11 +1300,11 @@ type="button" role="switch" aria-checked={checked} aria-label={label} onclick={ontoggle} class={cn( - 'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-150', + 'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors duration-150 px-0.5', checked ? 'bg-primary' : 'bg-muted', )} > - +
{/snippet} diff --git a/src/lib/components/McpPanel.svelte b/src/lib/components/McpPanel.svelte index 240d92ba..9a59abfb 100644 --- a/src/lib/components/McpPanel.svelte +++ b/src/lib/components/McpPanel.svelte @@ -194,15 +194,15 @@
diff --git a/src/lib/components/SettingsDialog.svelte b/src/lib/components/SettingsDialog.svelte index 65aefdcd..1ae8a6ae 100644 --- a/src/lib/components/SettingsDialog.svelte +++ b/src/lib/components/SettingsDialog.svelte @@ -577,11 +577,11 @@ type="button" role="switch" aria-checked={checked} aria-label={label} onclick={ontoggle} class={cn( - 'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full transition-[background-color,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.98]', + 'relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full px-0.5 transition-[background-color,transform] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] active:scale-[0.98]', checked ? 'bg-primary' : 'bg-muted', )} > - +
{/snippet} From 423826a0fdbb84c5d284bfe37e1cc2c8cf19041a Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 16:18:12 +0545 Subject: [PATCH 04/13] Preview the whole row as JSON with Alt+J Reading a row across a wide table means scrolling sideways and holding the column names in your head to know what you are looking at. Alt+J puts the whole focused row in the dock as one JSON object, names attached, on one screen. It goes through the same rowToRecord the "Copy row as JSON" action uses, so the two agree and hidden columns stay hidden, and it reads through effectiveCellValue rather than the raw row so staged edits are in it - this is the row as it stands, which is what the grid above it is showing. Detached, like the other value-in-dock views: it is a view of a row, not of a cell, so the cursor moving must not re-point it at whatever cell it lands on. Alt+Space steps into it and Escape closes it, the same as for a cell. --- src/lib/components/DataTable.svelte | 29 +++++++++++++++++++++++++++++ src/lib/shortcuts.js | 1 + 2 files changed, 30 insertions(+) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 32e1484e..0306de5f 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -2968,6 +2968,25 @@ import FilterX from "@lucide/svelte/icons/filter-x"; cellEditorOpen = true; } + /** + * The whole focused row as one JSON object, in the dock (Alt+J). + * + * Reading a row across a wide table means scrolling sideways and holding the + * column names in your head. This is the same row with the names attached, + * on one screen. Detached on purpose - it is a view of a row, not of a cell, + * so the cursor moving must not re-point it at whatever cell it lands on. + * @param {number} rowIdx + */ + function openRowJson(rowIdx) { + if (rows[rowIdx] === undefined) return; + // Through the same helper "Copy row as JSON" uses, so the two agree and + // hidden columns stay hidden. Built from effectiveCellValue rather than the + // raw row so staged edits show: this is the row as it stands, which is what + // the grid above it is showing too. + const values = columns.map((_, i) => effectiveCellValue(rowIdx, i)); + openValueInDock(rowToRecord(columns, values, hiddenColumns), `row ${rowIdx + 1}`); + } + /** * Point the editor at a cell. Split out of `openCellEditor` so the cursor can * move the open dock from cell to cell without re-opening it. @@ -4942,6 +4961,16 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } } + // Alt+J: the whole row as JSON in the dock. Alt+Space steps into it, the + // same as it does for a cell, and Escape closes it. + if (e.altKey && !e.ctrlKey && !e.metaKey && (e.key === "j" || e.key === "J")) { + if (!editingCell && focusedRow !== null) { + e.preventDefault(); + openRowJson(focusedRow); + return; + } + } + // Space previews the focused cell, full size. It is a printable character, // so without this it fell through to type-to-edit below and opened the // editor with a space typed into it - the one keystroke on the grid that diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js index fd96ca4f..0313af3e 100644 --- a/src/lib/shortcuts.js +++ b/src/lib/shortcuts.js @@ -158,6 +158,7 @@ export const SHORTCUT_GROUPS = [ { combo: 'Mod+E', desc: 'Expand / collapse the focused row' }, { combo: 'Space', desc: 'Preview the focused cell in the bottom dock (or Shift+click it)' }, { combo: 'Shift+Space', desc: 'Preview the focused cell and step into the editor' }, + { combo: 'Alt+J', desc: 'Preview the whole row as JSON in the bottom dock' }, { combo: 'Alt+Space', desc: 'Step into the preview editor (Escape returns to the grid)' }, { combo: 'Enter', desc: 'Edit cell' }, { combo: 'F2', desc: 'Edit cell' }, From b508658a9cbc37ba3c30f7e6f8173f19392fde3f Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 16:21:35 +0545 Subject: [PATCH 05/13] Follow the row with Alt+J, and put the expanded row on one type scale The row JSON stayed on the row it was opened from. It is opened detached, so the cell-follow effect leaves it alone - which is right, since it must not be re-pointed at whatever cell the cursor lands on, but it left nothing moving it at all, and the highlighted row and the JSON under it disagreed about which row was being read. A second effect follows the row cursor specifically. Only the row is watched: stepping across columns within a row changes nothing about the row. dataVersion is watched with it, so a staged edit reaches the JSON the same moment it reaches the grid. The expanded row drew its data a step above the toolbar that controls it, so the JSON read larger than its own chrome and larger than the grid it came out of. Tree, raw and the loading line are all on the same rung now. Its context menu was in the code face at text-ui-sm. Those are menu labels, not data, and every other menu in the app draws them in the UI face at text-ui-2xs. --- src/lib/components/DataTable.svelte | 36 +++++++++++++++++++++++ src/lib/components/RowExpandViewer.svelte | 10 +++---- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 0306de5f..06a74bd2 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -2832,6 +2832,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; let cellEditorRef = $state(null); /** Shift+Space opens the dock already focused; plain Space does not. */ let cellEditorFocusOnOpen = $state(false); + /** The dock is showing a whole row as JSON (Alt+J), not one cell. */ + let cellEditorRowJson = $state(false); let cellEditorOpen = $state(false); let cellEditorRow = $state(-1); let cellEditorCol = $state(-1); @@ -2956,6 +2958,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; * @param {unknown} value @param {string} label */ function openValueInDock(value, label) { + cellEditorRowJson = false; cellEditorOversize = null; cellEditorTruncated = false; cellEditorRow = -1; @@ -2985,6 +2988,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // the grid above it is showing too. const values = columns.map((_, i) => effectiveCellValue(rowIdx, i)); openValueInDock(rowToRecord(columns, values, hiddenColumns), `row ${rowIdx + 1}`); + // After openValueInDock, which clears both: the dock is detached so the + // cell-follow effect leaves it alone, and the row is remembered so the + // row-follow effect below can move it. + cellEditorRow = rowIdx; + cellEditorRowJson = true; } /** @@ -2996,6 +3004,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; function seedCellEditor(rowIdx, colIdx) { const col = columns[colIdx]; if (!col || rowIdx < 0) return false; + cellEditorRowJson = false; const value = effectiveCellValue(rowIdx, colIdx); // Only a preview of an oversize cell was ever loaded; editing it would write // the preview back over the real value. @@ -3037,6 +3046,33 @@ import FilterX from "@lucide/svelte/icons/filter-x"; }); }); + /** + * The row-JSON dock follows the row cursor, the way the cell dock follows the + * cell one. Moving down the grid with it open used to leave it on the row it + * was opened from, so the highlighted row and the JSON under it disagreed + * about which row you were reading. + * + * Only the row is watched: stepping across columns within a row changes + * nothing about the row, and re-rendering it there would be work for an + * identical result. `dataVersion` is watched too, so a staged edit shows up + * in the JSON the same moment it shows up in the grid. + */ + $effect(() => { + if (!cellEditorOpen || !cellEditorRowJson) return; + const r = focusedRow; + void dataVersion; + if (r === null || rows[r] === undefined) return; + untrack(() => { + if (r === cellEditorRow && cellEditorValue !== null) { + // Same row, but the data under it may have changed. + const values = columns.map((_, i) => effectiveCellValue(r, i)); + cellEditorValue = rowToRecord(columns, values, hiddenColumns); + return; + } + openRowJson(r); + }); + }); + /** * Swap the dock's preview for the whole value. Only the dock gets it - the * grid keeps the preview, so one row being read does not put a megabyte back diff --git a/src/lib/components/RowExpandViewer.svelte b/src/lib/components/RowExpandViewer.svelte index 7dc20ef0..bc9c8d92 100644 --- a/src/lib/components/RowExpandViewer.svelte +++ b/src/lib/components/RowExpandViewer.svelte @@ -113,7 +113,7 @@ // or a still-long preview) - the span-per-token HTML is the memory hog. Plain // escaped text stays cheap to build and render. html = truncated || source.length > HIGHLIGHT_LIMIT - ? `
${escapeHtml(source)}
` + ? `
${escapeHtml(source)}
` : highlightJson(source) }) @@ -351,10 +351,10 @@ oncontextmenu={handleContextMenu} > {#if !html} -

Loading…

+

Loading…

{:else} @@ -381,7 +381,7 @@ {#if contextMenu.value !== null} : + >: {/if} {#if isContainer} From dded426988e119b073be564046cab2f54c6563d3 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 16:29:42 +0545 Subject: [PATCH 07/13] Show control characters in cells instead of an empty box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A control character has no glyph in any font, so the grid drew each one as a blank box. A value carrying one looked like a value that did not: the mojibake filename read as `â ¯` with a hole in the middle and nothing on screen saying what the hole was, or even that there was a character there at all. They are escaped now - \u0080 and the like - so what is in the cell is readable. ASCII, not a Control Pictures glyph (␀): whatever replaces them has to be certain to render, and those are missing from plenty of monospace faces, which would put the box straight back. Tab, newline and carriage return are deliberately left alone. They are ordinary in text columns, and escaping them would rewrite every multi-line value on screen to fix a problem those three do not have. Display only. Copy, export and the editors all still hand back the real value. --- src/lib/components/DataTable.svelte | 29 ++++++++++++++++++++++++++++- src/lib/control-chars.test.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/lib/control-chars.test.js diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 06a74bd2..bcdca4da 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -1237,8 +1237,35 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } /** Truncated version for DOM rendering - keeps long values out of the render tree */ + // Characters with no glyph anywhere: the C0 and C1 control ranges and DEL. + // Tab, newline and carriage return are left out - they are ordinary in text + // columns and escaping them would rewrite every multi-line value on screen. + const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/; + const CONTROL_CHARS_G = new RegExp(CONTROL_CHARS.source, "g"); + + /** + * Show control characters instead of drawing nothing where they are. + * + * A font has no glyph for these, so the grid drew each one as a blank box and + * a value carrying one was indistinguishable from a value that did not - the + * mojibake `â\u0080¯` read as `â ¯` with a hole in the middle, and nothing on + * screen said what the hole was. + * + * Escaped in ASCII rather than swapped for a Control Pictures glyph (␀): the + * replacement has to be certain to render, and those glyphs are missing from + * plenty of monospace faces - which would put the box straight back. + */ + function showControlChars(/** @type {string} */ s) { + if (!CONTROL_CHARS.test(s)) return s; + return s.replace(CONTROL_CHARS_G, (c) => + "\\u" + (c.codePointAt(0) ?? 0).toString(16).padStart(4, "0"), + ); + } + function displayCell(value) { - const s = formatCell(value); + // Escaped before the cut, so the limit counts what is actually drawn and an + // escape can never be sliced in half. + const s = showControlChars(formatCell(value)); return s.length > CELL_DISPLAY_LIMIT ? s.slice(0, CELL_DISPLAY_LIMIT) + "…" : s; } diff --git a/src/lib/control-chars.test.js b/src/lib/control-chars.test.js new file mode 100644 index 00000000..ad42aa88 --- /dev/null +++ b/src/lib/control-chars.test.js @@ -0,0 +1,28 @@ +import { describe, it, expect } from 'vitest' + +// the exact expressions from DataTable.displayCell +const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/ +const CONTROL_CHARS_G = new RegExp(CONTROL_CHARS.source, 'g') +const show = (s) => + CONTROL_CHARS.test(s) + ? s.replace(CONTROL_CHARS_G, (c) => '\\u' + c.codePointAt(0).toString(16).padStart(4, '0')) + : s + +describe('control characters in grid cells', () => { + it('names the C1 char that drew as a box', () => { + expect(show('â\u0080¯')).toBe('â\\u0080¯') + }) + it('leaves ordinary text alone', () => { + expect(show('Screenshot 2026-08-12.png')).toBe('Screenshot 2026-08-12.png') + }) + it('leaves tab, newline and carriage return alone', () => { + expect(show('a\tb\nc\rd')).toBe('a\tb\nc\rd') + }) + it('keeps emoji and astral characters intact', () => { + expect(show('x🙂y')).toBe('x🙂y') + expect(show('narrow space')).toBe('narrow space') + }) + it('escapes NUL and DEL', () => { + expect(show('a\u0000b\u007Fc')).toBe('a\\u0000b\\u007fc') + }) +}) From c76e057011937beb7bdab6d3fd8663f829a4ca23 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 16:35:41 +0545 Subject: [PATCH 08/13] Fold multi-line cells onto their row, and stop the blit copying onto itself A cell holding pretty-printed JSON read as `[ "Alex Smith", "Live test" ]`. fillText draws no line breaks, so each newline came out as nothing at all while the indentation around it was drawn in full - gaps where the structure used to be. The break and the whitespace either side of it fold to a single space now, which is what the copy-as-TSV path already did with the same values. The scroll blit copied the canvas onto itself, source and destination overlapping by everything except the scroll delta - which is the point of it. That is only safe if the engine snapshots the source first, and WebKitGTK rasterises this canvas on the CPU through Cairo, which copies in place: a band could be read after it had already been written over. What that looks like is a torn frame with rows repeated above the seam, only ever while scrolling. It goes through a scratch surface now - grown as needed, never shrunk, so a scroll allocates nothing after its first frame. Two flat surface copies instead of one, with no text in either, which is still nothing beside repainting 6,000 glyphs. --- src/lib/components/DataTable.svelte | 74 +++++++++++++++++++++++++---- src/lib/control-chars.test.js | 17 +++++++ 2 files changed, 81 insertions(+), 10 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index bcdca4da..ccbd6d22 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -1262,10 +1262,23 @@ import FilterX from "@lucide/svelte/icons/filter-x"; ); } + /** + * Fold a multi-line value onto the one line a grid row has for it. + * + * fillText draws no line breaks, so a newline came out as nothing at all + * while the indentation around it was drawn in full - pretty-printed JSON + * read as `[ "a", "b" ]`, gaps where the structure used to be. The break + * and the whitespace either side of it collapse to a single space, which is + * what the copy-as-TSV path already does with the same values. + */ + function foldLines(/** @type {string} */ s) { + return s.includes("\n") || s.includes("\r") ? s.replace(/\s*[\r\n]+\s*/g, " ") : s; + } + function displayCell(value) { // Escaped before the cut, so the limit counts what is actually drawn and an // escape can never be sliced in half. - const s = showControlChars(formatCell(value)); + const s = foldLines(showControlChars(formatCell(value))); return s.length > CELL_DISPLAY_LIMIT ? s.slice(0, CELL_DISPLAY_LIMIT) + "…" : s; } @@ -5513,7 +5526,27 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (holdPaint && _surfaceHasFrame) { _blitDy = 0; return } _surfaceHasFrame = true - // ── Scroll blitting ────────────────────────────────────────────────────── + // Scratch surface for the scroll blit. One canvas, grown as needed and never + // shrunk, so a scroll allocates nothing after its first frame. + /** @type {{ canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D } | null} */ + let _blitScratch = null + function blitScratch(/** @type {number} */ w, /** @type {number} */ h) { + if (w <= 0 || h <= 0) return null + if (!_blitScratch) { + const canvas = document.createElement('canvas') + const c = canvas.getContext('2d', { alpha: true }) + if (!c) return null + _blitScratch = { canvas, ctx: c } + } + const { canvas, ctx: c } = _blitScratch + if (canvas.width < w || canvas.height < h) { + canvas.width = Math.max(canvas.width, w) + canvas.height = Math.max(canvas.height, h) + } + return { canvas, ctx: c } + } + + // ── Scroll blitting ────────────────────────────────────────────────────── // After the truncation cache, `fillText` is the whole remaining draw cost: // measured at 167 calls / 6,243 glyphs per frame, 4.8ms of an 8.7ms draw, // ~29us a call. That is Cairo rasterising glyphs on the CPU and no amount of @@ -5635,14 +5668,35 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const srcTop = headDev + (kDev > 0 ? kDev : 0) const dstTop = headDev + (kDev > 0 ? 0 : -kDev) const keepDev = devH - Math.max(srcTop, dstTop) - ctx.save() - ctx.setTransform(1, 0, 0, 1, 0, 0) - ctx.drawImage( - ctx.canvas, - 0, srcTop, ctx.canvas.width, keepDev, - 0, dstTop, ctx.canvas.width, keepDev, - ) - ctx.restore() + // Through a scratch surface, never canvas-onto-itself. + // + // Source and destination overlap by everything but `dy` - that is the + // point of the copy - and a self-drawImage across overlapping regions is + // only safe if the engine snapshots the source first. WebKitGTK's canvas + // is rasterised on the CPU by Cairo, which copies in place, so a band + // could be read after it had already been written over: rows duplicated + // above the seam and a torn frame that only ever appeared mid-scroll. + // + // Two copies instead of one, and both are flat surface moves with no text + // in them - still nothing beside repainting 6,000 glyphs. + const scratch = blitScratch(ctx.canvas.width, keepDev) + if (scratch) { + scratch.ctx.setTransform(1, 0, 0, 1, 0, 0) + scratch.ctx.clearRect(0, 0, ctx.canvas.width, keepDev) + scratch.ctx.drawImage( + ctx.canvas, + 0, srcTop, ctx.canvas.width, keepDev, + 0, 0, ctx.canvas.width, keepDev, + ) + ctx.save() + ctx.setTransform(1, 0, 0, 1, 0, 0) + ctx.drawImage( + scratch.canvas, + 0, 0, ctx.canvas.width, keepDev, + 0, dstTop, ctx.canvas.width, keepDev, + ) + ctx.restore() + } // Back in CSS px for the strip. Rounded outward so a fractional viewport // height can only ever make us repaint a hair more than was uncovered, // never leave a sliver of stale pixels behind. diff --git a/src/lib/control-chars.test.js b/src/lib/control-chars.test.js index ad42aa88..352cef9c 100644 --- a/src/lib/control-chars.test.js +++ b/src/lib/control-chars.test.js @@ -8,6 +8,23 @@ const show = (s) => ? s.replace(CONTROL_CHARS_G, (c) => '\\u' + c.codePointAt(0).toString(16).padStart(4, '0')) : s +const fold = (s) => (s.includes('\n') || s.includes('\r') ? s.replace(/\s*[\r\n]+\s*/g, ' ') : s) + +describe('multi-line values on one grid row', () => { + it('folds pretty-printed JSON onto one line', () => { + expect(fold('[\n "Alex Smith",\n "Live test"\n]')).toBe('[ "Alex Smith", "Live test" ]') + }) + it('collapses the indentation that came with the break', () => { + expect(fold('a\n\n b')).toBe('a b') + }) + it('leaves a single-line value untouched', () => { + expect(fold('["Alex Smith","Live test"]')).toBe('["Alex Smith","Live test"]') + }) + it('handles CRLF', () => { + expect(fold('a\r\nb')).toBe('a b') + }) +}) + describe('control characters in grid cells', () => { it('names the C1 char that drew as a box', () => { expect(show('â\u0080¯')).toBe('â\\u0080¯') From cf3ea431d19a453ceb7e8bb2919b9b187cf01508 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 16:40:04 +0545 Subject: [PATCH 09/13] Offer the row JSON preview on right-click Alt+J had a binding and no way to reach it with the hand that is already holding the pointer - the same gap Preview cell had, so it sits next to it. It is the same question asked of the row instead of the cell. --- .changeset/array-editor-effect-loop.md | 9 +++++++++ src/lib/components/DataTable.svelte | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/.changeset/array-editor-effect-loop.md b/.changeset/array-editor-effect-loop.md index 505db6fd..14946459 100644 --- a/.changeset/array-editor-effect-loop.md +++ b/.changeset/array-editor-effect-loop.md @@ -1,5 +1,14 @@ +### New Features +- Alt+J previews the whole row as JSON in the bottom dock, and follows the cursor from row to row + ### Bug Fixes - Opening an array cell no longer takes the view down with "This view hit an error" +- Scrolling no longer tears, leaving a band of repeated rows above a seam +- A cell holding multi-line text reads on one line instead of showing gaps where the line breaks were +- Control characters show what they are instead of an empty box +- The switch keeps its thumb inside its track at every zoom level ### Changes +- Array columns read as JSON, the same form the editor and the jsonb column beside them already used - The array editor reads as one list instead of a stack of separate fields, and its row controls stay put instead of appearing under the pointer +- The expanded row and its JSON tree sit on one type scale, and a key's colon sits against the key diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index ccbd6d22..78fe9f2b 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -8798,6 +8798,15 @@ import FilterX from "@lucide/svelte/icons/filter-x"; Preview cell + + runMenuAction(() => openRowJson(contextRowIdx))}> + + Preview row JSON + + {#if menuForeignKey} Date: Fri, 25 Sep 2026 16:44:11 +0545 Subject: [PATCH 10/13] Subscribe to the icon settings once, not once per icon Icon read the icon-set and icon-weight stores directly, and reading a store inside a component subscribes that component. The sidebar draws two or three icons a row, so opening a schema with 135 tables in it stood up several hundred store subscriptions, their teardown, and a derived weight per icon - for two values that are the same for every icon on screen and change only when the setting does. They come off one module-level subscription now: two in the process, however many icons are mounted. Nothing about what is drawn changes. --- src/lib/components/Icon.svelte | 15 ++++++--------- src/lib/icon-family.svelte.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) create mode 100644 src/lib/icon-family.svelte.js diff --git a/src/lib/components/Icon.svelte b/src/lib/components/Icon.svelte index abea6091..77e91422 100644 --- a/src/lib/components/Icon.svelte +++ b/src/lib/components/Icon.svelte @@ -8,7 +8,7 @@ // Sizing + color come from `class` (size-*, text-*), matching how Lucide icons // are used across the app - so this is a drop-in replacement for a bare glyph. import { HugeiconsIcon } from '@hugeicons/svelte' - import { appIconSet, appIconStyle } from '$lib/stores/settings.js' + import { iconFamily } from '$lib/icon-family.svelte.js' import { ICON_MAP, PHOSPHOR_MAP } from '$lib/icon-registry.js' import { cn } from '$lib/utils.js' @@ -21,19 +21,16 @@ ...rest } = $props() + // The family and the weight come off one app-wide subscription rather than + // one per icon - see icon-family.svelte.js. const entry = $derived(ICON_MAP[name]) - const useHuge = $derived($appIconSet === 'hugeicons' && !!entry?.huge) - const Phosphor = $derived($appIconSet === 'phosphor' ? PHOSPHOR_MAP[name] : undefined) + const useHuge = $derived(iconFamily.set === 'hugeicons' && !!entry?.huge) + const Phosphor = $derived(iconFamily.set === 'phosphor' ? PHOSPHOR_MAP[name] : undefined) const Lucide = $derived(entry?.lucide) - // Phosphor carries weight in the glyph itself (not stroke-width), so the - // icon-weight setting maps to its native weight variants. - const phWeight = $derived( - $appIconStyle === 'light' ? 'light' : $appIconStyle === 'bold' ? 'bold' : 'regular', - ) {#if Phosphor} - + {:else if useHuge} {:else if Lucide} diff --git a/src/lib/icon-family.svelte.js b/src/lib/icon-family.svelte.js new file mode 100644 index 00000000..b520b6a8 --- /dev/null +++ b/src/lib/icon-family.svelte.js @@ -0,0 +1,29 @@ +/** + * The active icon family and weight, subscribed once for the whole app. + * + * Icon reads two settings stores, and reading a store inside a component + * subscribes that component. A sidebar listing a few hundred tables draws two + * or three icons a row, so first paint was standing up several hundred + * subscriptions and their teardown for a pair of values that are the same for + * every icon on screen and change only when the setting does. + * + * Module scope, so there are two subscriptions in the process however many + * icons are mounted. They are never torn down on purpose: both stores live as + * long as the app does. + */ +import { appIconSet, appIconStyle } from '$lib/stores/settings.js' + +let set = $state('hugeicons') +let style = $state('regular') + +appIconSet.subscribe((v) => { set = v }) +appIconStyle.subscribe((v) => { style = v }) + +export const iconFamily = { + get set() { return set }, + get style() { return style }, + /** Phosphor carries weight in the glyph rather than a stroke width. */ + get phosphorWeight() { + return style === 'light' ? 'light' : style === 'bold' ? 'bold' : 'regular' + }, +} From 407240302055d9185ca530aa5ed823168bcb3448 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 16:51:23 +0545 Subject: [PATCH 11/13] Make the JSON colours selectable instead of one fixed set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every app theme rendered JSON in the same four colours. The --json-* tokens were declared once on `html` with a single light override, and not one of the 26 themes touched them, so a theme whose own palette read badly against that blue and green had nowhere to go. Six palettes now, in Settings → Appearance. Auto is what was there before and stays the default, following light and dark. Vivid, Ocean, GitHub and Monochrome are written twice, once for dark surfaces and once for light, because a palette tuned for one is unreadable on the other - the lightness has to move even though the hue does not. Solarized is written once on purpose: using the same accents on both backgrounds is the whole idea of it. It is a data attribute and nothing else. Every consumer already reads the --json-* custom properties, so the palette swaps without a single component re-rendering, and the swatch in the settings row is the palette itself rather than a description of it - which is why the selectors match any element carrying the attribute, not only the root. --- .changeset/array-editor-effect-loop.md | 1 + src/app.css | 66 ++++++++++++++++++++++++ src/lib/components/SettingsDialog.svelte | 33 ++++++++++++ src/lib/stores/settings.js | 42 ++++++++++++++- 4 files changed, 140 insertions(+), 2 deletions(-) diff --git a/.changeset/array-editor-effect-loop.md b/.changeset/array-editor-effect-loop.md index 14946459..94b47849 100644 --- a/.changeset/array-editor-effect-loop.md +++ b/.changeset/array-editor-effect-loop.md @@ -1,5 +1,6 @@ ### New Features - Alt+J previews the whole row as JSON in the bottom dock, and follows the cursor from row to row +- JSON colours are selectable in Settings → Appearance: Auto, Vivid, Ocean, Solarized, GitHub and Monochrome ### Bug Fixes - Opening an array cell no longer takes the view down with "This view hit an error" diff --git a/src/app.css b/src/app.css index f32b7ac9..0aca2303 100644 --- a/src/app.css +++ b/src/app.css @@ -154,6 +154,72 @@ html[data-theme="light"] { --json-number: oklch(0.55 0.13 60); --json-boolean: oklch(0.5 0.18 310); } + +/* Selectable JSON palettes (Settings → Appearance → JSON colours). + `auto` is the pair above: it follows light and dark and is the default. + Each of the rest is written twice, once for dark surfaces and once for + light, because a palette tuned for one is unreadable on the other - the + lightness moves, the hue does not. */ +[data-json-theme="vivid"] { + --json-key: oklch(0.78 0.16 250); + --json-string: oklch(0.82 0.19 145); + --json-number: oklch(0.85 0.17 80); + --json-boolean: oklch(0.78 0.21 320); +} +html[data-theme="light"][data-json-theme="vivid"], +html[data-theme="light"] [data-json-theme="vivid"] { + --json-key: oklch(0.48 0.21 255); + --json-string: oklch(0.45 0.18 150); + --json-number: oklch(0.5 0.17 60); + --json-boolean: oklch(0.46 0.24 320); +} + +[data-json-theme="ocean"] { + --json-key: oklch(0.76 0.11 230); + --json-string: oklch(0.79 0.11 195); + --json-number: oklch(0.82 0.09 160); + --json-boolean: oklch(0.72 0.13 265); +} +html[data-theme="light"][data-json-theme="ocean"], +html[data-theme="light"] [data-json-theme="ocean"] { + --json-key: oklch(0.46 0.14 235); + --json-string: oklch(0.45 0.11 200); + --json-number: oklch(0.48 0.1 165); + --json-boolean: oklch(0.44 0.16 270); +} + +/* Solarized's own accent values, which are defined once and used on both of + its backgrounds - that is the point of the palette. */ +[data-json-theme="solarized"] { + --json-key: oklch(0.63 0.11 240); /* blue #268bd2 */ + --json-string: oklch(0.68 0.1 190); /* cyan #2aa198 */ + --json-number: oklch(0.68 0.13 130);/* green #859900 */ + --json-boolean: oklch(0.6 0.15 350);/* magenta #d33682 */ +} + +[data-json-theme="github"] { + --json-key: oklch(0.72 0.14 280); /* purple */ + --json-string: oklch(0.75 0.11 235);/* blue */ + --json-number: oklch(0.76 0.13 45); /* orange */ + --json-boolean: oklch(0.7 0.16 20); /* red */ +} +html[data-theme="light"][data-json-theme="github"], +html[data-theme="light"] [data-json-theme="github"] { + --json-key: oklch(0.45 0.19 285); + --json-string: oklch(0.42 0.15 240); + --json-number: oklch(0.5 0.15 45); + --json-boolean: oklch(0.48 0.19 20); +} + +/* No hue at all: the structure carries on the shades and the app theme's own + accent is the only colour left on screen. */ +[data-json-theme="monochrome"] { + --json-key: var(--foreground); + --json-string: color-mix(in oklch, var(--foreground) 72%, transparent); + --json-number: color-mix(in oklch, var(--foreground) 88%, transparent); + --json-boolean: color-mix(in oklch, var(--foreground) 60%, transparent); + --json-null: color-mix(in oklch, var(--foreground) 42%, transparent); +} .json-tok-key { color: var(--json-key); } .json-tok-str { color: var(--json-string); } .json-tok-num { color: var(--json-number); } diff --git a/src/lib/components/SettingsDialog.svelte b/src/lib/components/SettingsDialog.svelte index 1ae8a6ae..8a961d81 100644 --- a/src/lib/components/SettingsDialog.svelte +++ b/src/lib/components/SettingsDialog.svelte @@ -27,6 +27,7 @@ ICON_STYLES, ICON_SETS, TABLE_STYLES, + JSON_THEMES, TABLE_ALIGN_OPTIONS, ROW_SPACINGS, GRID_FONT_MIN, @@ -172,6 +173,12 @@ const iconStyleEntries = Object.entries(ICON_STYLES); const iconSetEntries = Object.entries(ICON_SETS); const tableStyleEntries = Object.entries(TABLE_STYLES); + const jsonThemeEntries = Object.entries(JSON_THEMES); + /** @param {string | undefined} id */ + function setJsonTheme(id) { + if (!id || id === settings.jsonTheme) return; + settings = updateSettings({ jsonTheme: /** @type {any} */ (id) }); + } // Theme-aware CSS previews (mirror how each preset renders on the canvas grid). const tableStylePreview = { lines: "background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:7px 7px;", @@ -1151,6 +1158,32 @@
{/if} + {#if show('JSON colours', 'Palette for JSON keys, strings, numbers and booleans')} +
+
+

JSON colours

+

+ Palette for keys, strings, numbers and booleans wherever JSON is shown: the expanded row, the cell preview and the editors. Auto follows the app theme; the rest are fixed whichever theme is on, for a theme whose own colours read badly against them. +

+
+ ({ value: id, label: p.label, keywords: [p.label, p.description] }))} + > + {#snippet lead(it)} + + + {/snippet} + +
+ {/if} {#if show('Row spacing', 'Vertical space each row of the data grid takes')}
diff --git a/src/lib/stores/settings.js b/src/lib/stores/settings.js index 069c63e3..4b09a38a 100644 --- a/src/lib/stores/settings.js +++ b/src/lib/stores/settings.js @@ -73,7 +73,7 @@ const markFontDefaultApplied = () => { /** @typedef {'geist' | 'serif' | 'apple' | 'inter' | 'mono' | 'fira' | 'plex' | 'space' | 'source'} FontId */ /** @typedef {'regular' | 'light' | 'bold'} IconStyleId */ /** @typedef {'lucide' | 'hugeicons' | 'phosphor'} IconSetId */ -/** @typedef {{ theme: ThemeId, zoom: number, font: FontId, iconStyle: IconStyleId, iconSet: IconSetId, tableStyle: TableStyleId, mcpAutoStart: boolean, launchAtLogin: boolean, autoReconnectOnStartup: boolean, previewDmlBeforeApply: boolean, defaultDataView: string, paginationMode: string, maxQueryHistory: number, connectTimeoutMs: number, socketTimeoutMs: number, maxAllowedPacket: number, sessionTimezone: string, vimMode: boolean, cmdkAiEnabled: boolean, liveModeEnabled: boolean, lazyWideColumns: boolean, nullSortOrder: string, agentChatFontSize: number, agentCodeFontSize: number, agentThinkingStyle: string, agentShowQueryCards: boolean, agentWebAccess: boolean, tableTextAlign: string, telemetry: boolean, jsonWordWrap: boolean, nativeScroll: boolean, rowSpacing: RowSpacingId, motion: MotionId, zebraRows: boolean, showRowNumbers: boolean, showMenuBar: boolean, numberGrouping: boolean, imagePreview: boolean, openUrlsOnClick: boolean, highlightActiveRow: boolean, gridFontSize: number, autoSaveQueries: boolean, sqlFormat: import('$lib/sql-format-options.js').SqlFormatOptions }} AppSettings */ +/** @typedef {{ theme: ThemeId, zoom: number, font: FontId, iconStyle: IconStyleId, iconSet: IconSetId, tableStyle: TableStyleId, jsonTheme: JsonThemeId, mcpAutoStart: boolean, launchAtLogin: boolean, autoReconnectOnStartup: boolean, previewDmlBeforeApply: boolean, defaultDataView: string, paginationMode: string, maxQueryHistory: number, connectTimeoutMs: number, socketTimeoutMs: number, maxAllowedPacket: number, sessionTimezone: string, vimMode: boolean, cmdkAiEnabled: boolean, liveModeEnabled: boolean, lazyWideColumns: boolean, nullSortOrder: string, agentChatFontSize: number, agentCodeFontSize: number, agentThinkingStyle: string, agentShowQueryCards: boolean, agentWebAccess: boolean, tableTextAlign: string, telemetry: boolean, jsonWordWrap: boolean, nativeScroll: boolean, rowSpacing: RowSpacingId, motion: MotionId, zebraRows: boolean, showRowNumbers: boolean, showMenuBar: boolean, numberGrouping: boolean, imagePreview: boolean, openUrlsOnClick: boolean, highlightActiveRow: boolean, gridFontSize: number, autoSaveQueries: boolean, sqlFormat: import('$lib/sql-format-options.js').SqlFormatOptions }} AppSettings */ /** * UI type scale in design pixels: `[step, font-size, line-height?]`, matching @@ -347,6 +347,34 @@ export function normalizeTableStyle(/** @type {unknown} */ id) { return TABLE_STYLES[/** @type {TableStyleId} */ (id)] ? /** @type {TableStyleId} */ (id) : DEFAULT_TABLE_STYLE } +/** + * Colour palettes for JSON - the expanded row, the cell dock and every + * highlighted value in the app. + * + * These were one hardcoded set on `html` with a single light override, so all + * 26 app themes rendered JSON in the same four colours. `auto` keeps that + * behaviour (it follows light and dark); the rest are the palettes people + * already know from their editors, chosen so a theme the app palette clashes + * with has somewhere to go. + * + * @typedef {'auto'|'vivid'|'ocean'|'solarized'|'github'|'monochrome'} JsonThemeId + */ +export const JSON_THEMES = { + auto: { label: 'Auto', description: 'Follows the app theme' }, + vivid: { label: 'Vivid', description: 'High-chroma, maximum separation' }, + ocean: { label: 'Ocean', description: 'Cool blues and teals' }, + solarized: { label: 'Solarized', description: 'The classic low-contrast set' }, + github: { label: 'GitHub', description: "GitHub's syntax colours" }, + monochrome: { label: 'Monochrome', description: 'Weight and shade only, no hue' }, +} +/** @type {JsonThemeId} */ +export const DEFAULT_JSON_THEME = 'auto' +export const JSON_THEME_IDS = /** @type {JsonThemeId[]} */ (Object.keys(JSON_THEMES)) + +export function normalizeJsonTheme(/** @type {unknown} */ id) { + return JSON_THEMES[/** @type {JsonThemeId} */ (id)] ? /** @type {JsonThemeId} */ (id) : DEFAULT_JSON_THEME +} + // ── Query & connection defaults ────────────────────────────────────────────── // Numeric/text knobs surfaced under Settings → Database. `maxQueryHistory` is // consumed by the query-history store; the connector values (packet/timeouts/ @@ -445,6 +473,7 @@ export const DEFAULT_SETTINGS = { iconStyle: DEFAULT_ICON_STYLE, iconSet: DEFAULT_ICON_SET, tableStyle: DEFAULT_TABLE_STYLE, + jsonTheme: DEFAULT_JSON_THEME, mcpAutoStart: false, launchAtLogin: false, autoReconnectOnStartup: true, @@ -588,6 +617,7 @@ export const appPaginationMode = writable(/** @type {string} */ (DEFAULT_PAGINAT /** Reactive canvas-table grid style preset (synced by applySettings). DataTable * subscribes to repaint when it changes. */ export const appTableStyle = writable(/** @type {TableStyleId} */ (DEFAULT_TABLE_STYLE)) +export const appJsonTheme = writable(/** @type {JsonThemeId} */ (DEFAULT_JSON_THEME)) const LAST_DARK_KEY = 'stroke:last-dark-theme' const LAST_LIGHT_KEY = 'stroke:last-light-theme' @@ -701,6 +731,7 @@ export function loadSettings() { const iconStyle = normalizeIconStyle(parsed.iconStyle) const iconSet = normalizeIconSet(parsed.iconSet) const tableStyle = normalizeTableStyle(parsed.tableStyle) + const jsonTheme = normalizeJsonTheme(parsed.jsonTheme) const defaultDataView = DATA_VIEW_IDS.includes(parsed.defaultDataView) ? parsed.defaultDataView : DEFAULT_DATA_VIEW const paginationMode = PAGINATION_MODE_IDS.includes(parsed.paginationMode) ? parsed.paginationMode : DEFAULT_PAGINATION_MODE const maxQueryHistory = normalizeInt(parsed.maxQueryHistory, DEFAULT_MAX_QUERY_HISTORY, 1, 100000) @@ -751,7 +782,7 @@ export function loadSettings() { const agentShowQueryCards = parsed.agentShowQueryCards !== false const agentWebAccess = parsed.agentWebAccess === true const tableTextAlign = TABLE_ALIGN_IDS.includes(parsed.tableTextAlign) ? parsed.tableTextAlign : DEFAULT_TABLE_ALIGN - _settingsCache = { theme, zoom, font, iconStyle, iconSet, tableStyle, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply, defaultDataView, paginationMode, maxQueryHistory, connectTimeoutMs, socketTimeoutMs, maxAllowedPacket, sessionTimezone, vimMode, cmdkAiEnabled, liveModeEnabled, lazyWideColumns, nullSortOrder, agentChatFontSize, agentCodeFontSize, agentThinkingStyle, agentShowQueryCards, agentWebAccess, tableTextAlign, telemetry, jsonWordWrap, nativeScroll, rowSpacing, motion, zebraRows, showRowNumbers, showMenuBar, numberGrouping, imagePreview, openUrlsOnClick, highlightActiveRow, gridFontSize, autoSaveQueries, sqlFormat } + _settingsCache = { theme, zoom, font, iconStyle, iconSet, tableStyle, jsonTheme, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply, defaultDataView, paginationMode, maxQueryHistory, connectTimeoutMs, socketTimeoutMs, maxAllowedPacket, sessionTimezone, vimMode, cmdkAiEnabled, liveModeEnabled, lazyWideColumns, nullSortOrder, agentChatFontSize, agentCodeFontSize, agentThinkingStyle, agentShowQueryCards, agentWebAccess, tableTextAlign, telemetry, jsonWordWrap, nativeScroll, rowSpacing, motion, zebraRows, showRowNumbers, showMenuBar, numberGrouping, imagePreview, openUrlsOnClick, highlightActiveRow, gridFontSize, autoSaveQueries, sqlFormat } return { ..._settingsCache } } catch { return { ...DEFAULT_SETTINGS } @@ -906,6 +937,13 @@ export function applySettings(settings) { setAttr(root, 'data-table-style', tableStyle) setStore(appTableStyle, tableStyle) + // JSON colours. A data attribute only - every consumer reads the + // --json-* custom properties, so the palette swaps with no component + // re-rendering anything. + const jsonTheme = normalizeJsonTheme(settings.jsonTheme) + setAttr(root, 'data-json-theme', jsonTheme) + setStore(appJsonTheme, jsonTheme) + // Keep the canvas-table zoom in lockstep with the app zoom so Cmd +/-/0 (and // the zoom buttons) scale the grid alongside the rest of the UI. The canvas // renderer reads zoomState directly and repaints on change. From aef72244550a7d3dbdbc4919c1d21c1fffd3c591 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 17:00:32 +0545 Subject: [PATCH 12/13] Cut the settings copy down to one line each Every switch and select carried a paragraph. The longest ran 257 characters and most were three or four lines, so Appearance alone was a long scroll of explanation with the controls spaced out between it, and the thing you came to change was rarely on screen with the thing next to it. They are one line each now: the longest description is 114 characters and the longest switch row 83, down from 257. What went is the justification - why the setting exists, what it is good for, which extension it also lives in. What stayed is what the control does and anything that is genuinely surprising: that turning image previews off stops the download rather than just the drawing, that the PIN has no reset, that search terms leave the machine. No dashes standing in for punctuation either; the only ellipses left are in search placeholders, where they are the convention. --- src/lib/components/SettingsDialog.svelte | 53 ++++++++++++------------ 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/src/lib/components/SettingsDialog.svelte b/src/lib/components/SettingsDialog.svelte index 8a961d81..70a99557 100644 --- a/src/lib/components/SettingsDialog.svelte +++ b/src/lib/components/SettingsDialog.svelte @@ -705,7 +705,7 @@

Null sort order

-

Applied to quick-query ordering on databases that support explicit null placement.

+

Applies where the database supports explicit null placement.

Formatting options

- Casing, indentation and wrapping for Format (⇧⌥F) in every editor and for the DDL viewer. + Casing, indentation and wrapping for Format (⇧⌥F).

@@ -904,7 +904,7 @@ {#if show('Web access', 'Let the agent search the web and read pages')} {@render switchRow( 'Web access', - 'Let the agent search the web and read pages for things your database cannot answer: error codes, function syntax, current docs. Your search terms leave your machine when it does.', + 'Let the agent search the web. Your search terms leave your machine.', settings.agentWebAccess, toggleAgentWebAccess, )} @@ -912,7 +912,7 @@ {#if show('Show query cards', 'Display the SQL the agent ran and the rows it returned')} {@render switchRow( 'Show query cards', - 'Show the SQL the agent ran and the rows it came back with. Failed queries are always shown, so a correction still has something to refer to.', + 'Show the SQL the agent ran and the rows it returned. Failures always show.', settings.agentShowQueryCards, toggleAgentQueryCards, )} @@ -960,8 +960,8 @@

- A {$lockStatus.pinLength}-digit PIN that locks Stroke on launch. Stored as a salted - hash in your OS keychain - there is no reset link, so pick one you will remember. + A {$lockStatus.pinLength}-digit PIN that locks Stroke on launch. There is no reset, + so pick one you will remember.

@@ -978,7 +978,7 @@ {#if show('Ask when connecting', 'Confirm the PIN before opening or reconnecting to a database')} {@render switchRow( 'Ask when connecting', - 'Confirm the PIN before opening or reconnecting to a database. The reconnect right after you unlock the app is exempt - you just proved who you are.', + 'Ask for the PIN before connecting. The reconnect right after unlocking is exempt.', $lockStatus.requireOnConnect, () => void updateLockPrefs({ requireOnConnect: !$lockStatus.requireOnConnect }), )} @@ -988,8 +988,7 @@

Auto-lock

- Lock again after this much inactivity. Open tabs and queries are kept - the - screen goes over the session, not through it. + Lock again after this much inactivity. Open tabs and queries are kept.

{@render segmented('Auto-lock after', AUTO_LOCK_OPTIONS, $lockStatus.autoLockMinutes, (v) => void updateLockPrefs({ autoLockMinutes: v }))} @@ -1006,7 +1005,7 @@ {#if show('Anonymous usage data', 'Help decide what to build next')} {@render switchRow( 'Anonymous usage data', - 'Sends which features you use, how often, the app version and your OS. Nothing else. No queries, no table or database names, no connection details, and nothing about the data you browse. Turning it off takes effect immediately.', + 'Which features you use, the app version and your OS. Never queries, names or data.', settings.telemetry, toggleTelemetry, )} @@ -1163,7 +1162,7 @@

JSON colours

- Palette for keys, strings, numbers and booleans wherever JSON is shown: the expanded row, the cell preview and the editors. Auto follows the app theme; the rest are fixed whichever theme is on, for a theme whose own colours read badly against them. + Colours for JSON keys, strings, numbers and booleans. Auto follows the app theme.

Row spacing

- Vertical space each row of the data grid takes. Compact fits about a third more rows on screen; relaxed is easier to track across a wide table. + Row height in the data grid. Compact fits about a third more rows on screen.

Grid text size

- Size of the values in the data grid at 100% zoom. Separate from the app zoom, so the grid can run denser or larger than the rest of the interface without changing it. + Text size in the data grid at 100% zoom, independent of the app zoom.

@@ -1233,7 +1232,7 @@ {#if show('Boolean glyphs', 'Show a coloured dot or check for boolean columns')} {@render switchRow( 'Boolean glyphs', - 'Draw boolean columns as a coloured dot or ✓ / ✗ instead of the raw true / false text, so a column of them reads at a glance. This is the Boolean Glyphs extension - the same switch, and its dot-or-check choice, live in Extensions.', + 'Draw booleans as a dot or ✓ / ✗ instead of true / false text.', boolGlyphOn, () => setPluginEnabled(BOOL_GLYPH_ID, !boolGlyphOn), )} @@ -1241,7 +1240,7 @@ {#if show('Group large numbers', 'Thousands separators on integers in the grid')} {@render switchRow( 'Group large numbers', - 'Show integers with thousands separators, so 162957 reads as 162,957. Applies to whole numbers only - decimals are left exactly as the database returned them rather than being rounded to fit a format.', + 'Thousands separators on integers: 162957 reads as 162,957. Decimals are untouched.', settings.numberGrouping, toggleNumberGrouping, )} @@ -1249,7 +1248,7 @@ {#if show('Image previews', 'Show thumbnails for image URLs in the grid')} {@render switchRow( 'Image previews', - 'Draw a thumbnail for cells holding an image URL, and open the full image in a lightbox when one is clicked. Turning this off stops the images being downloaded at all, not just drawn - useful on a metered connection, or when a table of URLs should stay text.', + 'Thumbnail cells holding an image URL. Off stops the download, not just the drawing.', settings.imagePreview, toggleImagePreview, )} @@ -1257,7 +1256,7 @@ {#if show('Open links on click', 'Clicking a URL cell opens it in your browser')} {@render switchRow( 'Open links on click', - 'Click a cell holding a URL to open it in your browser. With this off a click just selects the cell, so a table full of links can be read and copied without one stray click leaving the app.', + 'Click a URL cell to open it in your browser. Off, a click only selects the cell.', settings.openUrlsOnClick, toggleOpenUrls, )} @@ -1265,7 +1264,7 @@ {#if show('Highlight the active row', 'Tint the row the keyboard is on')} {@render switchRow( 'Highlight the active row', - 'Tint the full width of the row holding the focused cell. Turn it off if you navigate cell by cell and find the band distracting - the focused cell keeps its own outline either way.', + 'Tint the full row holding the focused cell. The cell keeps its outline either way.', settings.highlightActiveRow, toggleHighlightActiveRow, )} @@ -1275,7 +1274,7 @@

Rows per page

- How many rows a newly opened table fetches. The page-size control in the grid toolbar changes the same value, and applies to the table already open. + How many rows a newly opened table fetches. Same value as the toolbar control.

setPluginEnabled(NULLISH_ID, !nullishOn), )} @@ -1299,7 +1298,7 @@

Sidebar position

- Which side of the window the tables sidebar sits on. Also on its own right-click menu. + Which side of the window the sidebar sits on.

Motion

- System follows your OS reduced-motion setting. Override it when you want animation in your window manager but not in a tool you stare at all day, or when the machine's setting isn't yours to change. A loading spinner keeps turning either way. + System follows your OS reduced-motion setting. Spinners keep turning either way.

Cell alignment

- Which side grid cell text sits on. "Numbers right" lines digits up by place value so you can compare magnitudes down a column, and leaves prose on the left. + Which side cell text sits on. Numbers right lines digits up by place value.

Date: Fri, 25 Sep 2026 17:02:39 +0545 Subject: [PATCH 13/13] Note the right-click entry, settings copy and icon subscriptions in the changeset --- .changeset/array-editor-effect-loop.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/array-editor-effect-loop.md b/.changeset/array-editor-effect-loop.md index 94b47849..82612290 100644 --- a/.changeset/array-editor-effect-loop.md +++ b/.changeset/array-editor-effect-loop.md @@ -1,5 +1,5 @@ ### New Features -- Alt+J previews the whole row as JSON in the bottom dock, and follows the cursor from row to row +- Alt+J previews the whole row as JSON in the bottom dock, and follows the cursor from row to row. Also on the cell right-click menu - JSON colours are selectable in Settings → Appearance: Auto, Vivid, Ocean, Solarized, GitHub and Monochrome ### Bug Fixes @@ -13,3 +13,5 @@ - Array columns read as JSON, the same form the editor and the jsonb column beside them already used - The array editor reads as one list instead of a stack of separate fields, and its row controls stay put instead of appearing under the pointer - The expanded row and its JSON tree sit on one type scale, and a key's colon sits against the key +- Settings descriptions are one line each instead of a paragraph +- Opening a schema with hundreds of tables costs one icon-settings subscription instead of one per icon