diff --git a/.changeset/array-editor-effect-loop.md b/.changeset/array-editor-effect-loop.md new file mode 100644 index 00000000..82612290 --- /dev/null +++ b/.changeset/array-editor-effect-loop.md @@ -0,0 +1,17 @@ +### New Features +- 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 +- 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 +- 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 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/ArrayCellEditor.svelte b/src/lib/components/ArrayCellEditor.svelte index c030d55c..ca0957c6 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 } }) @@ -100,7 +106,7 @@ onclick={(e) => { if (e.target === e.currentTarget) cancel() }} onkeydown={(e) => { if (e.key === 'Escape') { e.preventDefault(); cancel() } }} > -
+
@@ -124,8 +130,10 @@
- -
+ +
{#if items.length === 0}

Empty array {'{}'}

@@ -138,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} @@ -180,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} -
+ +
@@ -201,7 +216,7 @@
-
+
+ >Clear all {/if}
+
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 diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 1efceea5..78fe9f2b 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) { @@ -1224,8 +1237,48 @@ 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"), + ); + } + + /** + * 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) { - 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 = foldLines(showControlChars(formatCell(value))); return s.length > CELL_DISPLAY_LIMIT ? s.slice(0, CELL_DISPLAY_LIMIT) + "…" : s; } @@ -2819,6 +2872,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); @@ -2943,6 +2998,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; @@ -2955,6 +3011,30 @@ 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}`); + // 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; + } + /** * 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. @@ -2964,6 +3044,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. @@ -3005,6 +3086,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 @@ -4929,6 +5037,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 @@ -5408,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 @@ -5530,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. @@ -8639,6 +8798,15 @@ import FilterX from "@lucide/svelte/icons/filter-x"; Preview cell + + runMenuAction(() => openRowJson(contextRowIdx))}> + + Preview row JSON + + {#if menuForeignKey} - +
{/snippet} 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/components/JsonTree.svelte b/src/lib/components/JsonTree.svelte index a33c622f..05068d2f 100644 --- a/src/lib/components/JsonTree.svelte +++ b/src/lib/components/JsonTree.svelte @@ -169,7 +169,7 @@ } -
+
{#if isContainer} @@ -209,7 +209,7 @@ >{#if searching}{#each splitHighlight(label ?? "", query) as run, i (i)}{#if run.hit}{run.t}{:else}{run.t}{/if}{/each}{:else}{label}{/if}: + >: {/if} {#if isContainer} 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/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}
{/snippet} @@ -698,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).

@@ -897,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, )} @@ -905,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, )} @@ -953,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.

@@ -971,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 }), )} @@ -981,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 }))} @@ -999,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, )} @@ -1151,12 +1157,38 @@
{/if} + {#if show('JSON colours', 'Palette for JSON keys, strings, numbers and booleans')} +
+
+

JSON colours

+

+ Colours for JSON keys, strings, numbers and booleans. Auto follows the app theme. +

+
+ ({ 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')}

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.

@@ -1200,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), )} @@ -1208,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, )} @@ -1216,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, )} @@ -1224,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, )} @@ -1232,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, )} @@ -1242,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), )} @@ -1266,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.

+ CONTROL_CHARS.test(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¯') + }) + 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') + }) +}) 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' + }, +} 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' }, 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.