From 49e3fb3215cf804f067935fe95e47103659eb9f2 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 11:34:24 +0545 Subject: [PATCH 01/21] Return focus to the grid when the cell dock closes Escape closed the dock and stopped there. Focus was sitting on an element that was about to be removed, so it fell back to , and the grid answered no arrow keys until a cell was clicked again. The same applied to the close button and to staging a change, which are the other two ways out. All three go through one `dismiss()` that closes and tells the owner, and DataTable puts focus back on the grid container the way the undo path already does. --- .changeset/cell-editor-focus-return.md | 2 ++ src/lib/components/CellEditorPanel.svelte | 20 ++++++++++++++++---- src/lib/components/DataTable.svelte | 1 + 3 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 .changeset/cell-editor-focus-return.md diff --git a/.changeset/cell-editor-focus-return.md b/.changeset/cell-editor-focus-return.md new file mode 100644 index 00000000..7fb32128 --- /dev/null +++ b/.changeset/cell-editor-focus-return.md @@ -0,0 +1,2 @@ +### Bug Fixes +- Escaping the cell editor dock puts focus back on the cell it came from. Closing the dock left focus on the element it was about to remove, so it fell through to the page body and the grid stopped answering arrow keys until I clicked a cell again. The close button and staging a change land focus in the same place. diff --git a/src/lib/components/CellEditorPanel.svelte b/src/lib/components/CellEditorPanel.svelte index ba304b8b..7e2aa98d 100644 --- a/src/lib/components/CellEditorPanel.svelte +++ b/src/lib/components/CellEditorPanel.svelte @@ -64,6 +64,8 @@ onloadfull = null, truncatedLoad = false, oncommit = /** @type {(next: string) => void} */ (() => {}), + /** Fired when the dock dismisses itself, so the owner can take focus back. */ + onclose = /** @type {() => void} */ (() => {}), } = $props() /** @@ -352,10 +354,20 @@ const LINE_H = 20 + /** + * Close the dock and tell the owner. Closing alone left focus on a element + * that was about to be removed, so it fell back to and the grid + * stopped answering arrow keys - every dismissal has to hand focus back. + */ + function dismiss() { + open = false + onclose() + } + function apply() { - if (readOnly || !dirty) { open = false; return } + if (readOnly || !dirty) { dismiss(); return } oncommit(draft) - open = false + dismiss() } async function copy() { @@ -387,7 +399,7 @@ if (e.key === 'Escape') { e.preventDefault() e.stopPropagation() - open = false + dismiss() return } // ⌘F / Ctrl+F from anywhere in the dock opens the editor's find panel. @@ -532,7 +544,7 @@ - {/if} {/if} {#if dirty && !readOnly} edited diff --git a/src/lib/components/ConfirmDialog.svelte b/src/lib/components/ConfirmDialog.svelte index b5325058..8fb142e8 100644 --- a/src/lib/components/ConfirmDialog.svelte +++ b/src/lib/components/ConfirmDialog.svelte @@ -57,16 +57,24 @@ let contentEl = $state(null) /** - * Hold focus on the dialog itself instead of letting it land on Cancel, which - * is the first focusable child. With Cancel focused, Enter would click it - - * the exact opposite of the confirm-on-Enter below. Tab still reaches both - * buttons, and Escape still cancels. + * Focus the confirm button, not Cancel and not the dialog box. + * + * Cancel is the first focusable child, so the default would put focus on it + * and Enter would cancel - the opposite of the confirm-on-Enter below. + * Parking focus on the box avoided that but left nothing on screen looking + * focused, so the dialog opened with no visible answer to "where am I". + * + * The confirm button is the default action and already says so with its ↵, so + * it is the honest place for focus: Enter now activates it the ordinary way + * (onkeydown below steps aside for a focused button, so it fires once), and + * Escape still cancels. * @param {Event} e */ function onOpenAutoFocus(e) { if (!confirmOnEnter) return e.preventDefault() - try { contentEl?.focus({ preventScroll: true }) } catch { /* not focusable */ } + const target = contentEl?.querySelector('[data-confirm-action]') ?? contentEl + try { /** @type {HTMLElement|null} */ (target)?.focus({ preventScroll: true }) } catch { /* not focusable */ } } /** @@ -133,10 +141,16 @@ other, and it showed - one carried an icon and a boxed kbd chip, the other an ✕ that said nothing "Cancel" did not already say. --> + diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index e63679d2..e6e78dfb 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -53,8 +53,33 @@ const readOnlyC = new Compartment() const langC = new Compartment() + /** + * Longest logical line, without splitting the string into an array - a + * `split('\n')` on a multi-megabyte value allocates a second copy of it. + */ + function longestLine(/** @type {string} */ text) { + let max = 0 + let at = 0 + for (;;) { + const nl = text.indexOf('\n', at) + if (nl === -1) return Math.max(max, text.length - at) + if (nl - at > max) max = nl - at + at = nl + 1 + } + } + + /** + * Past this, no language and so no parse or highlight. A jsonb column holding + * a file comes through as one line of a few hundred thousand characters, and + * handing that to the JSON parser costs more than the colour is worth. VS + * Code draws the same line at 20,000 characters + * (`editor.maxTokenizationLineLength`), for the same reason. + */ + const MAX_TOKENIZE_LINE = 20_000 + /** JSON by its first character, markup by an early tag; everything else plain. */ function languageFor(/** @type {string} */ text) { + if (longestLine(text) > MAX_TOKENIZE_LINE) return [] if (/^\s*[[{]/.test(text)) return json() if (/<[A-Za-z!/]/.test(text.slice(0, 2000))) return html() return [] diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 8c74ddbb..940ee349 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -2780,6 +2780,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; /** Open the dedicated array editor for a cell (from the context menu). */ // ── Full-size cell editor (Space) ───────────────────────────────────────── + /** @type {{ focusEditor: () => boolean } | null} */ + let cellEditorRef = $state(null); let cellEditorOpen = $state(false); let cellEditorRow = $state(-1); let cellEditorCol = $state(-1); @@ -8864,7 +8866,9 @@ import FilterX from "@lucide/svelte/icons/filter-x"; onpointerdown={(e) => startDockResize(e, 'cell')} > Date: Fri, 25 Sep 2026 12:07:12 +0545 Subject: [PATCH 04/21] Put the table view shortcuts on one modifier Alt+Shift+F/S/C/R for filter, sort, columns and reset are three keys each and nobody remembers which letter goes with which menu. They are now Alt+A, Alt+S, Alt+C and Alt+R, which is the shape the toolbar already used for Alt+N. Filter takes A because Alt+F is spoken for; the other three keep their letter. Alt+Space steps into the cell preview. Space opens the dock and deliberately leaves the cursor on the grid, so arrows keep walking the table and the preview follows along - which left no way in other than the mouse. Escape still brings focus back out. Every one of the five was checked against the whole registry in shortcuts.js and the createHotkey bindings first; none of them collides. --- src/lib/components/DataTable.svelte | 12 +++++++++++- src/lib/components/StudioShell.svelte | 8 ++++---- src/lib/components/TableToolbar.svelte | 8 ++++---- src/lib/search-options.test.js | 2 +- src/lib/shortcuts.js | 9 +++++---- 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 940ee349..9d6f7b9a 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -4858,6 +4858,16 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // the cell it was aimed at. Enter and any other character still start an // edit; Space previews. Shift+Space stays bound to the same thing, which is // what it was before. + // Alt+Space steps into the dock the Space beside it opened. Space leaves the + // cursor on the grid on purpose, so arrows keep walking the table and the + // preview follows; this is the deliberate way in. Escape brings focus back. + if (e.key === " " && e.altKey && !e.ctrlKey && !e.metaKey) { + if (cellEditorOpen && cellEditorRef?.focusEditor()) { + e.preventDefault(); + return; + } + } + if (e.key === " " && !e.ctrlKey && !e.metaKey && !e.altKey) { if (!editingCell && focusedRow !== null && focusedCol !== null) { const ai = visToActualColIdx(focusedCol); @@ -4925,7 +4935,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } // The cell menu's two quick filters, as chords. Alt+F sits beside - // Alt+Shift+F, which opens the filter menu: same family, one step shorter, + // Alt+A, which opens the filter menu: same family, one step shorter, // and Alt+E is the other half of the pair. Handled before the switch so a // plain `f` or `e` still reaches type-to-edit. if (!editingCell && e.altKey && !e.ctrlKey && !e.metaKey && (e.key === "f" || e.key === "F" || e.key === "e" || e.key === "E")) { diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index cbd3548b..c140a5b2 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -2670,19 +2670,19 @@ let rowSearch = $state('') return true } - createHotkey('Alt+Shift+F', (e) => { + createHotkey('Alt+A', (e) => { if (!tableMenuHotkeyGuard(e)) return e.preventDefault() tableToolbar?.openFilterMenu?.() }) - createHotkey('Alt+Shift+S', (e) => { + createHotkey('Alt+S', (e) => { if (!tableMenuHotkeyGuard(e)) return e.preventDefault() tableToolbar?.openSortMenu?.() }) - createHotkey('Alt+Shift+C', (e) => { + createHotkey('Alt+C', (e) => { if (!tableMenuHotkeyGuard(e)) return e.preventDefault() tableToolbar?.openColumnsMenu?.() @@ -2702,7 +2702,7 @@ let rowSearch = $state('') // Reset the active table tab to its unfiltered default (clears search, filters, // sort, hidden columns, custom view, and resets the data view + page). Works in // any table view mode, but not while typing in an input. - createHotkey('Alt+Shift+R', (e) => { + createHotkey('Alt+R', (e) => { if (activeTab?.kind !== 'table' || !activeTable) return if (commandOpen || showConnectionModal || showSettingsModal) return const el = document.activeElement diff --git a/src/lib/components/TableToolbar.svelte b/src/lib/components/TableToolbar.svelte index f2a0cbdf..8eaff940 100644 --- a/src/lib/components/TableToolbar.svelte +++ b/src/lib/components/TableToolbar.svelte @@ -18,10 +18,10 @@ * know a key exists, not when you are already on the button. */ const KEY = { search: IS_MAC ? "⌘F" : "Ctrl+F", - filter: IS_MAC ? "⌥⇧F" : "Alt+Shift+F", - sort: IS_MAC ? "⌥⇧S" : "Alt+Shift+S", - columns: IS_MAC ? "⌥⇧C" : "Alt+Shift+C", - reset: IS_MAC ? "⌥⇧R" : "Alt+Shift+R", + filter: IS_MAC ? "⌥A" : "Alt+A", + sort: IS_MAC ? "⌥S" : "Alt+S", + columns: IS_MAC ? "⌥C" : "Alt+C", + reset: IS_MAC ? "⌥R" : "Alt+R", addRow: IS_MAC ? "⌥N" : "Alt+N", }; import { GAME_WORD, CLEAR_WORD, isMagic } from '$lib/games/easter-eggs.js' diff --git a/src/lib/search-options.test.js b/src/lib/search-options.test.js index 4d0b64a9..b391f7bf 100644 --- a/src/lib/search-options.test.js +++ b/src/lib/search-options.test.js @@ -105,7 +105,7 @@ describe('searchOptionHotkey', () => { it('ignores the same letters without Alt, and Alt with another modifier', () => { expect(searchOptionHotkey(ev({ code: 'KeyC' }))).toBe(null) expect(searchOptionHotkey(ev({ altKey: true, ctrlKey: true, code: 'KeyC' }))).toBe(null) - // Alt+Shift+R is "reset table view" - it must not land here too. + // Alt+R is "reset table view" - it must not land here too. expect(searchOptionHotkey(ev({ altKey: true, shiftKey: true, code: 'KeyR' }))).toBe(null) }) diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js index 155e1d11..44cc2eb8 100644 --- a/src/lib/shortcuts.js +++ b/src/lib/shortcuts.js @@ -145,10 +145,10 @@ export const SHORTCUT_GROUPS = [ // hide the app, so Mod+Alt+F is the one that always arrives. { combo: 'Mod+H', desc: 'Find & replace in column' }, { combo: 'Mod+Alt+F', desc: 'Find & replace in column' }, - { combo: 'Alt+Shift+F', desc: 'Open filter menu' }, - { combo: 'Alt+Shift+S', desc: 'Open sort menu' }, - { combo: 'Alt+Shift+C', desc: 'Open columns menu' }, - { combo: 'Alt+Shift+R', desc: 'Reset table view (clear filters/sort/search)' }, + { combo: 'Alt+A', desc: 'Open filter menu' }, + { combo: 'Alt+S', desc: 'Open sort menu' }, + { combo: 'Alt+C', desc: 'Open columns menu' }, + { combo: 'Alt+R', desc: 'Reset table view (clear filters/sort/search)' }, { combo: 'Alt+F', desc: 'Filter by the focused cell\'s value' }, { combo: 'Alt+E', desc: 'Exclude the focused cell\'s value' }, { combo: 'Alt+X', desc: 'Clear the table search (Escape inside the box does too)' }, @@ -157,6 +157,7 @@ export const SHORTCUT_GROUPS = [ { combo: 'Mod+Escape', desc: 'Discard every staged row' }, { 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: 'Alt+Space', desc: 'Step into the preview editor (Escape returns to the grid)' }, { combo: 'Enter', desc: 'Edit cell' }, { combo: 'F2', desc: 'Edit cell' }, { combo: 'Escape', desc: 'Cancel edit' }, From c2d21cc83726180c1689520d08cd824cff353117 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 12:11:22 +0545 Subject: [PATCH 05/21] Never leave the window stuck on a black screen The page boots at opacity 0 and JavaScript reveals it, but the window is shown from Rust on its own timer. So anything that stopped the bundle before armRevealFailsafe() ran left a painted black rectangle on screen with no way out but killing the app: a throw at module scope, an import that never resolves, one of JavaScriptCore's SIGTRAPs. Two changes, either of which would have been enough on its own. armRevealFailsafe() now runs first in main.js. It used to sit behind applySettings, installZoomShortcuts and resetWebviewZoom, none of which the page needs in order to be visible, and a throw in any of them took the failsafe with it. index.html carries a floor that needs no JavaScript at all: a keyframe that ends at opacity 1 after 8s, well behind the 2.5s JS failsafe so a healthy boot always reveals first and this is never what shows the app. revealApp() sets data-revealed, which stops the animation and beats the inline opacity:0. Checked by deleting the bundle from a copy of the built page: the window still comes up, at opacity 1, with no app JavaScript on the page at all. --- index.html | 16 ++++++++++++++++ src/lib/app-reveal.js | 3 +++ src/main.js | 5 ++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 1823a4b4..11f3f237 100644 --- a/index.html +++ b/index.html @@ -67,6 +67,22 @@ DB Studio +
diff --git a/src/lib/app-reveal.js b/src/lib/app-reveal.js index 99fa5ea4..a73f901f 100644 --- a/src/lib/app-reveal.js +++ b/src/lib/app-reveal.js @@ -36,6 +36,9 @@ export function revealApp() { // caller just set (overlay, modal) is in the DOM before the fade starts. void showWindow().finally(() => { requestAnimationFrame(() => { + // Both: the attribute cancels the CSS boot floor in index.html and beats + // the inline opacity:0, the inline value keeps the 200ms fade. + document.documentElement.dataset.revealed = '' document.documentElement.style.opacity = '1' }) }) diff --git a/src/main.js b/src/main.js index 8c32a2e0..27623815 100644 --- a/src/main.js +++ b/src/main.js @@ -17,10 +17,13 @@ if (import.meta.env.VITE_FRESH_START === '1') { } catch (_) {} } +// First, before anything that can throw. Everything below it is startup work +// that the page does not need in order to be visible, and a throw in any of it +// used to leave the failsafe unarmed and the window black. +armRevealFailsafe() applySettings(loadSettings()) installZoomShortcuts() resetWebviewZoom() -armRevealFailsafe() // ── Zoom watchdog ──────────────────────────────────────────────────────────── // Final safety net against any stray webview zoom that slips past the event From 9bccf0e8ae38259eace7d049a54eb049a11f6f9d Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 12:13:21 +0545 Subject: [PATCH 06/21] Seed Alt+A with the column the cursor is on Alt+A opened the filter bar on whatever column happened to be first, so the filter you actually wanted meant picking your column out of a list that can be eighty long - while the cell you wanted to filter on was already under the cursor. It now seeds that column, and because the operator is chosen from the column's type the row comes up ready to type into rather than ready to configure. The first column is still the fallback when nothing is focused, and a stale name that is no longer in the table falls back the same way. DataTable had no public surface at all, so this adds one function to it rather than lifting the cell cursor into the shell. --- src/lib/components/DataTable.svelte | 11 +++++++++++ src/lib/components/StudioShell.svelte | 7 ++++++- src/lib/components/TableToolbar.svelte | 23 ++++++++++++++++------- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 9d6f7b9a..55a61f9f 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -2779,6 +2779,17 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } /** Open the dedicated array editor for a cell (from the context menu). */ + /** + * The column the cell cursor is sitting on. For callers that want to act on + * the cell you are looking at rather than make you name the column again - + * the filter bar seeds itself with this. + */ + export function focusedColumnName() { + if (focusedCol === null) return ""; + const ai = visToActualColIdx(focusedCol); + return ai >= 0 ? (columns[ai]?.name ?? "") : ""; + } + // ── Full-size cell editor (Space) ───────────────────────────────────────── /** @type {{ focusEditor: () => boolean } | null} */ let cellEditorRef = $state(null); diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index c140a5b2..789e00b1 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -1473,6 +1473,8 @@ let rowSearch = $state('') const virtualExprColsForToolbar = $derived($virtualColumnsStore[_vcolTableKey] ?? []) /** @type {{ focusRowSearch?: () => void, clearRowSearch?: () => void } | null} */ let tableToolbar = $state(null) + /** @type {{ focusedColumnName: () => string } | null} */ + let dataTable = $state(null) /** @type {ReturnType | null} */ let filterDebounceTimer = null /** @type {ReturnType | null} */ @@ -2673,7 +2675,9 @@ let rowSearch = $state('') createHotkey('Alt+A', (e) => { if (!tableMenuHotkeyGuard(e)) return e.preventDefault() - tableToolbar?.openFilterMenu?.() + // Seeded with the column the cell cursor is on: the filter you want is + // nearly always about the cell you are looking at. + tableToolbar?.openFilterMenu?.(dataTable?.focusedColumnName?.() ?? '') }) createHotkey('Alt+S', (e) => { @@ -8297,6 +8301,7 @@ let rowSearch = $state('') edits, selection and scroll position survive mode switches. -->
c.name === preferColumn) ? preferColumn : ""; + const col = wanted || (columns[0]?.name ?? ""); const op = col ? defaultOpForCol(col) : "contains"; onfilterschange([...rowFilters, createFilter(col, op)]); } From 4e3998984c9a53bdefdce7757b540f612275a862 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 12:17:04 +0545 Subject: [PATCH 07/21] Load a cell into the dock instead of talking about it Three fixes to the same idea: Load means show me the value. The in-cell Load button left the dock showing a different row. It re-read the dock only when the dock already happened to be on that cell, which is not where it usually is - the button is in the cell, clicking it does not move the cursor, and the dock follows the cursor. So the value loaded and the panel went on saying "not loaded" over some other row. The dock now goes to the cell that was loaded, because loading a cell is a request to see that cell. A value past the 8MB inline cap answered with a toast explaining that the value was too big and that Space would page through it. Load already was the request; answering it with instructions for a key the reader could press themselves is not an answer. It opens the dock on that cell, which reads it in pages. Soft wrap is remembered. It was decided per cell from the text - structured unwrapped, prose wrapped - so turning it on meant turning it on again at the next cell, and the next. The reader's answer is kept in localStorage and used from then on; the old guess is only the default until they give one. Wrap forced off because a line is too long to lay out is not a preference and is not saved as one. --- src/lib/components/CellEditorPanel.svelte | 30 ++++++++++++++++++++--- src/lib/components/DataTable.svelte | 24 +++++++++++++++--- src/lib/components/StudioShell.svelte | 10 +++++--- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/src/lib/components/CellEditorPanel.svelte b/src/lib/components/CellEditorPanel.svelte index d5128a15..7467da83 100644 --- a/src/lib/components/CellEditorPanel.svelte +++ b/src/lib/components/CellEditorPanel.svelte @@ -149,7 +149,7 @@ // 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 && !/^\s*[[{]/.test(text) + wrap = maxLineLen <= 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 @@ -394,8 +394,32 @@ */ const MAX_WRAP_LINE = 10_000 const canWrap = $derived(maxLineLen <= MAX_WRAP_LINE) + // Forced off because the value cannot afford it, which is not a preference and + // must not be saved as one. $effect(() => { if (!canWrap) wrap = false }) + /** + * Whether to wrap is a reading preference, not a property of the cell, so it + * outlives the cell. Stored as the answer the reader last gave; until they + * give one, structured text opens unwrapped and prose opens wrapped. + */ + const WRAP_PREF_KEY = 'stroke:cell-editor-wrap' + /** @type {boolean | null} */ + let wrapPref = (() => { + try { + const v = localStorage.getItem(WRAP_PREF_KEY) + return v === '1' ? true : v === '0' ? false : null + } catch { return null } + })() + + /** The toggle and Alt+Z. Only an explicit answer is remembered. */ + function toggleWrap() { + if (!canWrap) return + wrap = !wrap + wrapPref = wrap + try { localStorage.setItem(WRAP_PREF_KEY, wrap ? '1' : '0') } catch { /* private window, or storage is full */ } + } + // The editor's type metrics, as whole pixels. The gutter has to sit on the // same baseline grid as the text beside it, and a fractional line-height @@ -468,7 +492,7 @@ // Cmd/Ctrl+Enter applies, matching every other multi-line editor in the app. { key: 'Mod-Enter', run: () => { apply(); return true } }, // Alt+Z toggles wrap, Alt+R reverts - VS Code's keys. - { key: 'Alt-z', run: () => { if (canWrap) wrap = !wrap; return true } }, + { key: 'Alt-z', run: () => { toggleWrap(); return true } }, { key: 'Alt-r', run: () => { revert(); return true } }, ] @@ -535,7 +559,7 @@ 'inline-flex size-7 items-center justify-center rounded-md transition-colors hover:bg-muted/40 hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent', wrap ? 'text-foreground' : 'text-muted-foreground', )} - onclick={() => { if (canWrap) wrap = !wrap }} + onclick={toggleWrap} title={canWrap ? 'Soft wrap (Alt+Z)' : `Soft wrap is off for this value: its longest line is ${maxLineLen.toLocaleString()} characters, and wrapping one line that long lays it out all at once`} diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 55a61f9f..6566aaba 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -2835,9 +2835,14 @@ import FilterX from "@lucide/svelte/icons/filter-x"; try { await onloadcellvalue({ rowIdx, colIdx }) // The dock is a view of a cell, so a cell that just changed under it has - // to be re-read. Without this the panel kept showing "not loaded" over a - // row that already held the value. - if (cellEditorOpen && !cellEditorDetached && cellEditorRow === rowIdx && cellEditorCol === colIdx) { + // to be re-read. It used to re-read only when the dock already happened to + // be on this cell, which is not where it usually is: the Load button is in + // the cell, clicking it does not move the cursor, and the dock follows the + // cursor - so loading a value left the dock showing some other row and + // still saying "not loaded". Loading a cell is a request to see that cell. + if (cellEditorOpen && !cellEditorDetached) { + focusedRow = rowIdx + focusedCol = actualToVisColIdx(colIdx) >= 0 ? actualToVisColIdx(colIdx) : focusedCol seedCellEditor(rowIdx, colIdx) } } catch (e) { @@ -2878,6 +2883,19 @@ import FilterX from "@lucide/svelte/icons/filter-x"; }) }) + /** + * Open the dock on a cell from outside. For a value too big to put in the + * row: the dock reads it in pages, so this is the answer rather than a + * message telling you to press a key yourself. + */ + export function openCellDock(rowIdx, colIdx) { + focusedRow = rowIdx + const vi = actualToVisColIdx(colIdx) + if (vi >= 0) focusedCol = vi + openCellEditor(rowIdx, colIdx) + scrollRowIntoView(rowIdx) + } + function openCellEditor(rowIdx, colIdx) { if (!seedCellEditor(rowIdx, colIdx)) return; cellEditorDetached = false; diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 789e00b1..8f1051a7 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -1473,7 +1473,7 @@ let rowSearch = $state('') const virtualExprColsForToolbar = $derived($virtualColumnsStore[_vcolTableKey] ?? []) /** @type {{ focusRowSearch?: () => void, clearRowSearch?: () => void } | null} */ let tableToolbar = $state(null) - /** @type {{ focusedColumnName: () => string } | null} */ + /** @type {{ focusedColumnName: () => string, openCellDock: (r: number, c: number) => void } | null} */ let dataTable = $state(null) /** @type {ReturnType | null} */ let filterDebounceTimer = null @@ -6944,9 +6944,11 @@ let rowSearch = $state('') // A cut value in a cell is worse than the size it replaces: it reads as // the value and is not one. Past this size nothing loads whole anywhere, // so the dock is the honest answer - it pages through what it has. - toast.info('Too large to load whole', { - description: `${col.name} is ${formatByteSize(res.bytes)}, past the ${formatByteSize(CELL_VALUE_MAX)} this loads in one piece. Open it with Space to read it in pages.`, - }) + // + // It opens it, rather than printing a message telling you to press a key. + // Load is a request to see the value, and the answer to a request you can + // satisfy is not a notification. + dataTable?.openCellDock?.(detail.rowIdx, detail.colIdx) return } // A JSON column renders from a parsed value, the way an under-cap row in the From e851a00ef445b02cc02d8eeabd92c5b40d10f93b Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 12:17:43 +0545 Subject: [PATCH 08/21] Drop the byte formatter that went with the removed toast --- src/lib/components/StudioShell.svelte | 1 - 1 file changed, 1 deletion(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 8f1051a7..057ddacc 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -219,7 +219,6 @@ import { openNotebookFile } from '$lib/api.js' import { formatCompactCount, normalizeTableRowCount } from '$lib/table-list.js' import { humanizeDbError } from '$lib/ai.js' - import { formatByteSize } from '$lib/cell-value.js' import { focusTrap } from '$lib/actions/focus-trap.js' import { MAX_PAGE_SIZE, From 789d7dd578f2fd47019ed88bbd80ecf5d3f7d334 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 12:23:16 +0545 Subject: [PATCH 09/21] Search the database as you type The search bar had a Search button, and nothing happened until it was pressed. Typing is the search now: 400ms after the typing stops, which matters more here than in most boxes because one search is a query per table, ten at a time - on a 135-table schema, firing per keystroke is 135 round trips for a letter about to be followed by another. Two characters is the floor; one matches most of the database and is not a search yet. Changing a match option re-runs it too, since the options change what the query means. Enter still works and means "do not wait for the pause". Escape empties the box and the results with it, and only when there is something to clear, so an empty box lets Escape through to whatever else is listening. The button is a clear affordance instead, shown only when there is text. runSearch no longer refuses while a search is running. A newer search supersedes an older one rather than being dropped by it; the generation check already stops the old workers. The bar was a h-9 field inside py-2 padding, so it stood ~52px against the 36px sidebar header next to it and the two did not line up. It is a h-9 row holding a h-7 control now, which is the sidebar header's height and the same control height as the schema picker and filter box in it. The regex error moved to its own row, since a second child of a fixed-height flex row sits beside the field rather than under it. --- src/lib/components/CellEditorPanel.svelte | 31 +++++++- src/lib/components/DataTable.svelte | 14 +++- src/lib/components/SearchPage.svelte | 90 +++++++++++++++++++---- src/lib/shortcuts.js | 1 + 4 files changed, 117 insertions(+), 19 deletions(-) diff --git a/src/lib/components/CellEditorPanel.svelte b/src/lib/components/CellEditorPanel.svelte index 7467da83..8890ba27 100644 --- a/src/lib/components/CellEditorPanel.svelte +++ b/src/lib/components/CellEditorPanel.svelte @@ -168,17 +168,40 @@ * ran then and did nothing. */ let focusOnReady = $state(false) + /** Where the caret goes once the editor exists; -1 leaves it alone. */ + let pendingCaret = -1 $effect(() => { if (!focusOnReady || !cm || !open) return focusOnReady = false - queueMicrotask(() => cm?.focus()) + const pos = pendingCaret + pendingCaret = -1 + queueMicrotask(() => { + if (pos >= 0) cm?.select(pos, pos) + else cm?.focus() + }) }) - /** Step into the editor from outside, once the dock is already up. */ - export function focusEditor() { + /** + * Past this, the value is something to read from the top rather than a line + * you are about to finish typing. + */ + const SMALL_VALUE_CHARS = 2_000 + + /** + * Step into the editor from outside, once the dock is already up. + * + * `auto` puts the caret where the value says it should go: at the end of a + * short value, which is almost always one you mean to edit, and at the start + * of a long one, which is one you mean to read. + * @param {'auto'|'start'|'end'} [caret] + */ + export function focusEditor(caret = 'auto') { if (!open) return false - if (cm) { cm.focus(); return true } + const at = caret === 'auto' ? (draft.length <= SMALL_VALUE_CHARS ? 'end' : 'start') : caret + const pos = at === 'end' ? draft.length : 0 + if (cm) { cm.select(pos, pos); return true } // Lazy-loaded: if it is not mounted yet, focus it the moment it is. + pendingCaret = pos focusOnReady = true return true } diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 6566aaba..d459f926 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -2791,7 +2791,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } // ── Full-size cell editor (Space) ───────────────────────────────────────── - /** @type {{ focusEditor: () => boolean } | null} */ + /** @type {{ focusEditor: (caret?: 'auto'|'start'|'end') => boolean } | null} */ let cellEditorRef = $state(null); let cellEditorOpen = $state(false); let cellEditorRow = $state(-1); @@ -4897,12 +4897,24 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } } + // 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 + // destroyed the cell it was aimed at. + // + // Plain Space leaves the cursor on the grid, so arrows keep walking the + // table and the dock follows along. Shift+Space is the same preview and + // steps into the editor too: caret at the end of a short value, which is + // one you mean to edit, and at the top of a long one, which is one you mean + // to read. if (e.key === " " && !e.ctrlKey && !e.metaKey && !e.altKey) { if (!editingCell && focusedRow !== null && focusedCol !== null) { const ai = visToActualColIdx(focusedCol); if (ai >= 0) { e.preventDefault(); + const stepIn = e.shiftKey; openCellEditor(focusedRow, ai); + if (stepIn) void tick().then(() => cellEditorRef?.focusEditor('auto')); return; } } diff --git a/src/lib/components/SearchPage.svelte b/src/lib/components/SearchPage.svelte index 7e428406..7046683f 100644 --- a/src/lib/components/SearchPage.svelte +++ b/src/lib/components/SearchPage.svelte @@ -2,10 +2,12 @@ import { getTableRows } from '$lib/api.js' import { buildSearchQuery, searchOptionsSupported, searchOptionHotkey, SEARCH_OPTION_KEYS } from '$lib/search-options.js' import Search from '@lucide/svelte/icons/search' + import X from '@lucide/svelte/icons/x' import Table2 from '@lucide/svelte/icons/table-2' import Eye from '@lucide/svelte/icons/eye' import Loader from '@lucide/svelte/icons/loader' import ArrowRight from '@lucide/svelte/icons/arrow-right' + import { untrack } from 'svelte' import { cn } from '$lib/utils.js' /** @@ -63,9 +65,45 @@ } } + /** + * A search is one query per table, ten at a time, so it is not something to + * fire on a keystroke: on a 135-table schema that is 135 round trips for a + * letter that is about to be followed by another. Waiting out a pause in the + * typing is what makes searching-as-you-type affordable here. + */ + const DEBOUNCE_MS = 400 + /** One character matches most of the database; it is not a search yet. */ + const MIN_QUERY = 2 + /** @type {ReturnType | undefined} */ + let debounceTimer + + /** Drop results and stop anything in flight. */ + function resetResults() { + searchGeneration++ + results = [] + searched = false + searching = false + progress = { done: 0, total: 0 } + } + + $effect(() => { + const q = query.trim() + // Re-run when an option changes too: they change what the query means. + void matchCase; void wholeWord; void useRegex + clearTimeout(debounceTimer) + if (q.length < MIN_QUERY) { + untrack(() => resetResults()) + return + } + debounceTimer = setTimeout(() => void runSearch(), DEBOUNCE_MS) + return () => clearTimeout(debounceTimer) + }) + async function runSearch() { const q = query.trim() - if (!q || searching) return + // No `searching` guard: a newer search supersedes an older one rather than + // being dropped by it, and the generation check below stops the old workers. + if (!q) return if (useRegex && optionsSupported) { const err = validateRegex(q) @@ -128,9 +166,21 @@ } function handleKeydown(/** @type {KeyboardEvent} */ e) { + // Enter is "do not wait for the pause", not a separate way to search. if (e.key === 'Enter') { e.preventDefault() - void runSearch() + clearTimeout(debounceTimer) + if (query.trim()) void runSearch() + return + } + // Escape empties the box, and the effect above clears the results with it. + // Only when there is something to clear, so an empty box lets Escape reach + // whatever else is listening. + if (e.key === 'Escape' && query) { + e.preventDefault() + e.stopPropagation() + query = '' + regexError = '' return } const opt = searchOptionHotkey(e) @@ -165,10 +215,14 @@ -
+ +
- {:else} + {:else if query} {/if}
- {#if regexError} -

{regexError}

- {/if}
+ + {#if regexError} +
+

{regexError}

+
+ {/if} {#if searching} @@ -338,7 +398,9 @@

Search across all {tables.length} tables

-

Type a value and press Enter

+

+ {query.trim().length ? 'Keep typing…' : 'Start typing'} +

{/if}
diff --git a/src/lib/shortcuts.js b/src/lib/shortcuts.js index 44cc2eb8..fd96ca4f 100644 --- a/src/lib/shortcuts.js +++ b/src/lib/shortcuts.js @@ -157,6 +157,7 @@ export const SHORTCUT_GROUPS = [ { combo: 'Mod+Escape', desc: 'Discard every staged row' }, { 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+Space', desc: 'Step into the preview editor (Escape returns to the grid)' }, { combo: 'Enter', desc: 'Edit cell' }, { combo: 'F2', desc: 'Edit cell' }, From b13c5c0625f1076807e0396c85cb8b12c591cd17 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 12:26:31 +0545 Subject: [PATCH 10/21] Make Shift+Space focus reliably, and Ctrl+F reach the database search Shift+Space opened the dock and then chased it with a focus call on the next tick. The editor inside the dock is lazy-loaded, so that call could land before it existed, or before the seed replaced its document and took the selection with it - it worked sometimes, which is worse than never. Focus is a parameter of the open now, set before `cellEditorOpen` so the panel has it the first time it seeds. The panel already knew how to wait for its own editor; that is the path autofocus used before it was turned off for the grid, and the caret rule moved in with it - end of a short value, top of a long one. Ctrl+F on the Find in database page focused nothing. Mod+F already means "search what this page is showing" and the objects page had its own branch; the search page now has the same one, through the same bindable the objects page uses. It selects as well as focuses, so Ctrl+F on a page that already has a query replaces it by typing instead of appending to it. --- src/lib/components/CellEditorPanel.svelte | 7 ++++++- src/lib/components/DataTable.svelte | 21 ++++++++++++++++----- src/lib/components/SearchPage.svelte | 8 ++++++++ src/lib/components/StudioShell.svelte | 3 +++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/lib/components/CellEditorPanel.svelte b/src/lib/components/CellEditorPanel.svelte index 8890ba27..0311c1ff 100644 --- a/src/lib/components/CellEditorPanel.svelte +++ b/src/lib/components/CellEditorPanel.svelte @@ -159,7 +159,12 @@ // the grid cursor with the dock already up re-points it without taking // focus, so arrow keys keep walking the grid. Escape from the editor // closes the dock (`onRootKey`). - if (justOpened && autofocus) focusOnReady = true + if (justOpened && autofocus) { + // Same rule as focusEditor('auto'), decided here because this is where + // the seeded text is known: end of a short value, top of a long one. + pendingCaret = text.length <= SMALL_VALUE_CHARS ? text.length : 0 + focusOnReady = true + } }) /** diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index d459f926..26d2ae28 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -2793,6 +2793,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // ── Full-size cell editor (Space) ───────────────────────────────────────── /** @type {{ focusEditor: (caret?: 'auto'|'start'|'end') => boolean } | null} */ let cellEditorRef = $state(null); + /** Shift+Space opens the dock already focused; plain Space does not. */ + let cellEditorFocusOnOpen = $state(false); let cellEditorOpen = $state(false); let cellEditorRow = $state(-1); let cellEditorCol = $state(-1); @@ -2896,8 +2898,14 @@ import FilterX from "@lucide/svelte/icons/filter-x"; scrollRowIntoView(rowIdx) } - function openCellEditor(rowIdx, colIdx) { + /** + * @param {number} rowIdx @param {number} colIdx + * @param {boolean} [focus] Open with the caret in the editor (Shift+Space). + * Set before `cellEditorOpen`, so the panel has it when it first seeds. + */ + function openCellEditor(rowIdx, colIdx, focus = false) { if (!seedCellEditor(rowIdx, colIdx)) return; + cellEditorFocusOnOpen = focus; cellEditorDetached = false; // One dock at a time. Both live along the bottom edge, and stacking them // leaves the grid a couple of rows tall. @@ -4912,9 +4920,12 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const ai = visToActualColIdx(focusedCol); if (ai >= 0) { e.preventDefault(); - const stepIn = e.shiftKey; - openCellEditor(focusedRow, ai); - if (stepIn) void tick().then(() => cellEditorRef?.focusEditor('auto')); + // Passed into the open, not chased afterwards: the editor inside the + // dock is lazy-loaded, so a focus call made from out here on the tick + // after opening can land before it exists, or before the seed + // replaces its document. The panel already knows how to wait for its + // own editor, so this just tells it to. + openCellEditor(focusedRow, ai, e.shiftKey); return; } } @@ -8919,7 +8930,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; {}, + /** Assigned here; the shell calls it for ⌘F. */ + focusSearch = $bindable(/** @type {() => void} */ (() => {})), } = $props() let query = $state('') @@ -40,6 +42,12 @@ /** @type {HTMLInputElement | null} */ let inputEl = $state(null) + $effect(() => { + // Selects as well as focuses, so ⌘F on a page that already has a query + // replaces it by typing rather than appending to it. + focusSearch = () => { inputEl?.focus(); inputEl?.select() } + }) + $effect(() => { if (active && inputEl) { // Small delay so the tab transition finishes before focusing diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 057ddacc..be0014ab 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -403,6 +403,7 @@ } /** Assigned by ObjectsPage so ⌘F can reach its search box. */ let objectsFocusSearch = $state(/** @type {() => void} */ (() => {})) + let dbSearchFocusInput = $state(/** @type {() => void} */ (() => {})) let showConnectionModal = $state(false) /** Engine chosen on the welcome screen - the modal opens straight into its form. */ let connectionModalEngine = $state('') @@ -2278,6 +2279,7 @@ let rowSearch = $state('') // Find means "search what this page is showing", and on the objects page // that is its own box. It used to mean nothing there at all. if (activeTab?.kind === 'objects') { e.preventDefault(); objectsFocusSearch?.(); return } + if (activeTab?.kind === 'search') { e.preventDefault(); dbSearchFocusInput?.(); return } if (activeTab?.kind !== 'table' || !activeTable) return e.preventDefault() tableToolbar?.focusRowSearch?.() @@ -7903,6 +7905,7 @@ let rowSearch = $state('') {#await import('./SearchPage.svelte')}{:then { default: SearchPage }} Date: Fri, 25 Sep 2026 12:34:03 +0545 Subject: [PATCH 11/21] Cover the whole branch in the changeset The changeset still described only the first commit, so merging with a release label would have written a changelog that mentioned the focus fix and none of the nine changes after it. It now lists all of them, grouped the way CHANGELOG.md groups things. The updater entries are deliberately absent: Retry, the 15s check timeout and the per-frame download progress already shipped in 2.1.2 and are in the changelog under that version. --- .changeset/cell-editor-focus-return.md | 2 -- .changeset/grid-and-cell-dock.md | 30 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) delete mode 100644 .changeset/cell-editor-focus-return.md create mode 100644 .changeset/grid-and-cell-dock.md diff --git a/.changeset/cell-editor-focus-return.md b/.changeset/cell-editor-focus-return.md deleted file mode 100644 index 7fb32128..00000000 --- a/.changeset/cell-editor-focus-return.md +++ /dev/null @@ -1,2 +0,0 @@ -### Bug Fixes -- Escaping the cell editor dock puts focus back on the cell it came from. Closing the dock left focus on the element it was about to remove, so it fell through to the page body and the grid stopped answering arrow keys until I clicked a cell again. The close button and staging a change land focus in the same place. diff --git a/.changeset/grid-and-cell-dock.md b/.changeset/grid-and-cell-dock.md new file mode 100644 index 00000000..161ae3d4 --- /dev/null +++ b/.changeset/grid-and-cell-dock.md @@ -0,0 +1,30 @@ +### Bug Fixes + +#### Canvas Table +- Escaping the cell dock puts focus back on the cell it came from. Closing it left focus on the element it was about to remove, so focus fell through to the page body and the grid stopped answering arrow keys until a cell was clicked again. The close button and staging a change land focus in the same place. +- Space previews the focused cell instead of typing a space into it. Space is a printable character, so it fell through to type-to-edit and opened the editor with a space already in it, which is the one keystroke on the grid that destroyed the cell it was aimed at. +- Soft wrap no longer freezes the app on a long line. A jsonb cell holding a file is one line of half a million characters, and CodeMirror lays a single line out whole however little of it is on screen, so wrapping that one measured every character at once. Past a longest-line cap the toggle is disabled rather than quietly off, and says why. Syntax highlighting stops at the same kind of boundary. +- The in-cell Load button updates the dock. It only re-read the dock when the dock already happened to be on that cell, which is not where it usually is, so the value loaded and the panel went on saying "not loaded" over a different row. +- A value past the 8 MB inline cap opens in the dock, which reads it in pages, instead of showing a message explaining that it was too big and which key to press. + +#### Cell Editor +- Soft wrap is remembered. It was decided per cell from the text, so turning it on meant turning it on again at the next cell, and the next. +- Confirm dialogs show what is focused. They parked focus on the dialog box so Enter would not hit Cancel, which left nothing on screen looking focused. Focus goes to the confirm button, which is the default action and already says so with its ↵. +- The oversize cell notice had two Load buttons for one action. + +#### Startup +- The window can no longer be stuck on a black screen. The page boots hidden and JavaScript reveals it, but the window is shown on a separate timer, so anything that stopped the bundle first left a black rectangle with no way out but killing the app. The reveal failsafe now runs before anything that can throw, and the page carries a second one that needs no JavaScript at all. + +#### Find in Database +- Ctrl+F focuses the search box. It did nothing there before. +- The search bar lines up with the sidebar header beside it. It stood about 52px against the header's 36px. + +### Changes + +#### Canvas Table +- Shift+Space previews the cell and steps into the editor, with the caret at the end of a short value and the top of a long one. Alt+Space steps into a dock that is already open. Plain Space leaves the cursor on the grid so the arrows keep walking the table and the dock follows along. +- Alt+A opens the filter on the column the cursor is already on, with an operator chosen from that column's type, instead of on whichever column happens to be first. +- The table view shortcuts lost a key: filter, sort, columns and reset are Alt+A, Alt+S, Alt+C and Alt+R, matching the Alt+N the toolbar already used. + +#### Find in Database +- Searching happens as you type, 400ms after it stops, so the Search button is gone. One search is a query per table, so a pause is what makes this affordable. Enter skips the wait, Escape clears the box and the results. From e61a1f10cafa24e9d7df3215f2034a128905bdcc Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 12:42:50 +0545 Subject: [PATCH 12/21] Draw the sub-view in the chosen table style, and let the gutter go The FK sub-view drew its own HTML table from three booleans lifted off the grid - row rules, column rules, zebra - so every style that is not plain solid lines came out as plain solid lines in it. Dotted, dashed, hairline, double, bordered, ledger, graph and bands all collapsed to the same table, sitting directly under a grid that was drawing something else. The rest of the style travels with the metrics now, and the panel translates it into what a CSS border can say: the grid's dash array picks dotted or dashed, double and strong are borrowed as-is, and groupEvery gets its heavier rule every Nth row. Dashes are given back some contrast, because so much of the line is missing that they read lighter than a solid rule of the same colour. Corner dots and column ticks do not cross over and are left out rather than approximated into something the grid never draws. The header's own bottom rule stays solid whatever the style: it separates the table from its labels, not one row from the next, and a dashed version of it reads as a missing row. Line numbers in the cell editor are now a toggle (Alt+L), remembered the same way soft wrap is. Wrap already hides them while it is on, since a gutter numbering logical lines against wrapped visual rows either disagrees with the count in the bar or lies, so the button is disabled there and says why. Hiding the numbers drops the fold arrows with them - a fold column with no numbers beside it is a stripe nothing explains. --- src/lib/components/CellEditorPanel.svelte | 35 ++++++++++++++ src/lib/components/CodeEditor.svelte | 12 ++++- src/lib/components/DataTable.svelte | 8 +++ src/lib/components/FkSubviewPanel.svelte | 59 ++++++++++++++++++++--- 4 files changed, 105 insertions(+), 9 deletions(-) diff --git a/src/lib/components/CellEditorPanel.svelte b/src/lib/components/CellEditorPanel.svelte index 0311c1ff..a751e897 100644 --- a/src/lib/components/CellEditorPanel.svelte +++ b/src/lib/components/CellEditorPanel.svelte @@ -45,6 +45,7 @@ import Download from '@lucide/svelte/icons/download' import Undo2 from '@lucide/svelte/icons/undo-2' import WrapText from '@lucide/svelte/icons/wrap-text' + import ListOrdered from '@lucide/svelte/icons/list-ordered' import X from '@lucide/svelte/icons/x' import { cn } from '$lib/utils.js' import { toast } from '$lib/components/ui/sonner/toast.svelte.js' @@ -440,6 +441,22 @@ } catch { return null } })() + /** + * Line numbers, remembered the same way. Wrap already hides them while it is + * on - a gutter numbering logical lines against wrapped visual rows either + * disagrees with the count in the bar or lies - so this is the answer for + * when the value is unwrapped and the numbers are still not wanted. + */ + const GUTTER_PREF_KEY = 'stroke:cell-editor-gutter' + let showGutter = $state((() => { + try { return localStorage.getItem(GUTTER_PREF_KEY) !== '0' } catch { return true } + })()) + + function toggleGutter() { + showGutter = !showGutter + try { localStorage.setItem(GUTTER_PREF_KEY, showGutter ? '1' : '0') } catch { /* private window, or storage is full */ } + } + /** The toggle and Alt+Z. Only an explicit answer is remembered. */ function toggleWrap() { if (!canWrap) return @@ -521,6 +538,7 @@ { key: 'Mod-Enter', run: () => { apply(); return true } }, // Alt+Z toggles wrap, Alt+R reverts - VS Code's keys. { key: 'Alt-z', run: () => { toggleWrap(); return true } }, + { key: 'Alt-l', run: () => { toggleGutter(); return true } }, { key: 'Alt-r', run: () => { revert(); return true } }, ] @@ -594,6 +612,22 @@ > + + @@ -724,6 +758,7 @@ bind:value={draft} {readOnly} {wrap} + gutter={showGutter && !wrap} placeholder={isNull ? 'NULL' : ''} ariaLabel="{colName} value" keys={editorKeys} diff --git a/src/lib/components/CodeEditor.svelte b/src/lib/components/CodeEditor.svelte index e6e78dfb..c4fce8a7 100644 --- a/src/lib/components/CodeEditor.svelte +++ b/src/lib/components/CodeEditor.svelte @@ -42,6 +42,7 @@ placeholder = '', ariaLabel = '', keys = [], + gutter = true, } = $props() /** @type {HTMLDivElement | null} */ @@ -50,6 +51,7 @@ let view = null const wrapC = new Compartment() + const gutterC = new Compartment() const readOnlyC = new Compartment() const langC = new Compartment() @@ -555,9 +557,11 @@ return EditorState.create({ doc, extensions: [ - lineNumbers(), + // Reconfigurable: the panel hides the gutter on request, and hiding it + // means dropping the fold gutter with it - a fold arrow column with no + // numbers beside it is a stripe nothing explains. + gutterC.of(gutter ? [lineNumbers(), foldGutter({ openText: '▾', closedText: '▸' })] : []), codeFolding(), - foldGutter({ openText: '▾', closedText: '▸' }), highlightActiveLine(), highlightActiveLineGutter(), drawSelection(), @@ -611,6 +615,10 @@ if (view && next !== view.state.doc.toString()) view.setState(freshState(next)) }) $effect(() => { const w = wrap; view?.dispatch({ effects: wrapC.reconfigure(w ? EditorView.lineWrapping : []) }) }) + $effect(() => { + const g = gutter + view?.dispatch({ effects: gutterC.reconfigure(g ? [lineNumbers(), foldGutter({ openText: '▾', closedText: '▸' })] : []) }) + }) $effect(() => { const r = readOnly; view?.dispatch({ effects: readOnlyC.reconfigure(EditorState.readOnly.of(r)) }) }) export function focus() { view?.focus() } diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 26d2ae28..a26fe74d 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -5213,6 +5213,14 @@ import FilterX from "@lucide/svelte/icons/filter-x"; rowRules: _tableStyle.rows === true, colRules: _tableStyle.cols === true, zebra: _tableStyle.zebra === true, + // The rest of the chosen style, for surfaces that draw their own table and + // have to land on the same look: dashes, weight, and the heavier rule every + // Nth row. Three booleans were not enough - every dashed, dotted, ledger or + // bordered preset came out as plain solid lines in the FK sub-view. + dash: _tableStyle.dash ?? null, + double: _tableStyle.double === true, + strong: _tableStyle.strong === true, + groupEvery: _tableStyle.groupEvery ?? 0, align: $appTableAlign, rowNumbers: $appRowNumbers === true, }) diff --git a/src/lib/components/FkSubviewPanel.svelte b/src/lib/components/FkSubviewPanel.svelte index 89930cc2..b7ef58a4 100644 --- a/src/lib/components/FkSubviewPanel.svelte +++ b/src/lib/components/FkSubviewPanel.svelte @@ -21,7 +21,7 @@ * Appearance → Grid text size) scaled by the canvas zoom, not a rung of the * UI scale, and the panel has to land on the same number to read as the same * table. The fallbacks are the shipped defaults at 100%. - * @typedef {{ zoom: number, cellPx: number, typePx: number, rowH: number, headerH: number, padX: number, rowRules: boolean, colRules: boolean, zebra: boolean, align: string, rowNumbers: boolean }} GridMetrics + * @typedef {{ zoom: number, cellPx: number, typePx: number, rowH: number, headerH: number, padX: number, rowRules: boolean, colRules: boolean, zebra: boolean, align: string, rowNumbers: boolean, dash?: number[] | null, double?: boolean, strong?: boolean, groupEvery?: number }} GridMetrics */ let { data, @@ -29,6 +29,7 @@ metrics = { zoom: 1, cellPx: 13, typePx: 11, rowH: 28, headerH: 30, padX: 10, rowRules: true, colRules: true, zebra: false, align: 'numbers', rowNumbers: true, + dash: null, double: false, strong: false, groupEvery: 0, }, fkLabel = '', /** Small context hint shown next to the badge (e.g. "row 12"). */ @@ -38,6 +39,39 @@ onfullview = () => {}, } = $props() + /** + * The grid's table style, translated into what a CSS border can say. + * + * The grid draws on canvas and takes a dash array; a border takes a keyword, + * so the array is matched to the nearest one - a 1px-on dash reads as dotted, + * anything longer as dashed. `double` and `strong` are borrowed as-is. What + * cannot cross over (corner dots, column ticks) simply does not, rather than + * being approximated into something the grid never shows. + */ + const rule = $derived.by(() => { + const d = metrics.dash + const style = metrics.double ? 'double' : !d ? 'solid' : d[0] <= 1 ? 'dotted' : 'dashed' + // `double` needs 3px to render as two lines at all; a 1px double border is + // drawn by every engine as a single solid one. + const width = metrics.double ? 3 : 1 + return { + style, + width, + // Dashes and dots read lighter than a solid rule of the same colour + // because so much of the line is missing, so they are given back some + // contrast to sit at the same weight as the grid above. + row: metrics.strong ? 'border-border/60' : style === 'solid' ? 'border-border/15' : 'border-border/30', + col: metrics.strong ? 'border-r-border/60' : style === 'solid' ? 'border-r-border/15' : 'border-r-border/30', + group: metrics.strong ? 'border-border/70' : 'border-border/40', + } + }) + + /** A heavier rule every Nth row (ledger, graph, bands). 1-based like the gutter. */ + function isGroupEdge(/** @type {number} */ i) { + const n = metrics.groupEvery ?? 0 + return n > 0 && (i + 1) % n === 0 + } + /** * What the panel draws. Every lookup replaces `data` with an empty * `{ loading: true, rows: [] }` first, so drawing `data` directly blanked the @@ -305,7 +339,11 @@ {i + 1} {/if} {#each cols as c, j (c.name)} @@ -353,14 +394,18 @@ aria-selected={isSel} class={cn( 'cursor-default overflow-hidden align-middle text-ellipsis whitespace-nowrap outline-none', - metrics.rowRules && i < view.rows.length - 1 && 'border-b border-border/15', - metrics.colRules && 'border-r border-r-border/15', + (metrics.rowRules || isGroupEdge(i)) && i < view.rows.length - 1 && + cn('border-b', isGroupEdge(i) ? rule.group : rule.row), + metrics.colRules && cn('border-r', rule.col), c.alignRight && 'text-right tabular-nums', isNullVal && 'italic text-muted-foreground/70', !isSel && 'group-hover/row:bg-muted/10', isSel && 'bg-primary/15 ring-1 ring-inset ring-primary/40', )} - style="height:{metrics.rowH}px; padding:0 {metrics.padX}px" + style="height:{metrics.rowH}px; padding:0 {metrics.padX}px; + border-bottom-style:{rule.style}; + border-bottom-width:{isGroupEdge(i) ? Math.max(rule.width, 2) : rule.width}px; + border-right-style:{rule.style}; border-right-width:{rule.width}px" title={isNullVal ? '' : text} onclick={() => selectCell(i, j)} onfocus={() => selectCell(i, j)} From 039c8986d662d84ca43605887a0677bd601f3d37 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 12:43:00 +0545 Subject: [PATCH 13/21] Add the sub-view style and gutter toggle to the changeset --- .changeset/grid-and-cell-dock.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.changeset/grid-and-cell-dock.md b/.changeset/grid-and-cell-dock.md index 161ae3d4..c09fa518 100644 --- a/.changeset/grid-and-cell-dock.md +++ b/.changeset/grid-and-cell-dock.md @@ -8,10 +8,14 @@ - A value past the 8 MB inline cap opens in the dock, which reads it in pages, instead of showing a message explaining that it was too big and which key to press. #### Cell Editor +- Line numbers toggle on and off (Alt+L), and the choice is remembered. Soft wrap already hides them while it is on, so the button is disabled there and says why. - Soft wrap is remembered. It was decided per cell from the text, so turning it on meant turning it on again at the next cell, and the next. - Confirm dialogs show what is focused. They parked focus on the dialog box so Enter would not hit Cancel, which left nothing on screen looking focused. Focus goes to the confirm button, which is the default action and already says so with its ↵. - The oversize cell notice had two Load buttons for one action. +#### Related Rows +- The related-rows sub-view draws in the table style that is actually selected. It read three booleans off the grid, so dotted, dashed, hairline, double, bordered, ledger, graph and bands all came out as plain solid lines, directly under a grid drawing something else. + #### Startup - The window can no longer be stuck on a black screen. The page boots hidden and JavaScript reveals it, but the window is shown on a separate timer, so anything that stopped the bundle first left a black rectangle with no way out but killing the app. The reveal failsafe now runs before anything that can throw, and the page carries a second one that needs no JavaScript at all. From 9a05804fbe3b14975cf674a7ce109c43ae8fa728 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 12:49:04 +0545 Subject: [PATCH 14/21] Show a splash while the app boots instead of a black rectangle The window comes up before the app is ready to be seen, so what was on screen for that stretch was the painted background and nothing else - a black rectangle that reads as a hang. The opacity gate moved off and onto #app, which is what lets anything be drawn during the boot at all. The root keeps its painted background, so the anti-flash work is untouched; it is simply no longer invisible. Under the app sits a splash: the brand mark and an indeterminate bar, inline in index.html and depending on nothing the bundle provides, because it has to be able to draw when the bundle is what failed. The mark is picked in CSS off the `dark` class the head script has already set, so the splash needs no JavaScript either. The bar is indeterminate on purpose - startup has no progress to report and a bar that pretends to measure one is a lie told at every launch. The floor that reveals the app if nothing else does is now an inline classic script, which runs even when the module bundle is the thing that failed - the case it exists for. The CSS animation stays as a second layer for when the engine itself stops, but it is no longer the only one: a keyframe animation needs frames to advance, and a failsafe that depends on the thing that may have stopped is not one. Verified by deleting the bundle from the built page: the splash shows, then the app is revealed and the splash retires. --- index.html | 69 +++++++++++++++++++++++++++++++++++++++---- src/lib/app-reveal.js | 6 ++-- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/index.html b/index.html index 11f3f237..8b3c55d1 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + diff --git a/src/lib/app-reveal.js b/src/lib/app-reveal.js index a73f901f..5525cf8c 100644 --- a/src/lib/app-reveal.js +++ b/src/lib/app-reveal.js @@ -36,10 +36,10 @@ export function revealApp() { // caller just set (overlay, modal) is in the DOM before the fade starts. void showWindow().finally(() => { requestAnimationFrame(() => { - // Both: the attribute cancels the CSS boot floor in index.html and beats - // the inline opacity:0, the inline value keeps the 200ms fade. + // The attribute is the whole switch now: index.html gates #app rather than + // , so that the splash can be seen while the app is still booting. + // It cancels the CSS boot floor, fades the app in and retires the splash. document.documentElement.dataset.revealed = '' - document.documentElement.style.opacity = '1' }) }) } From bb761971a77dfd12fab23f87202c81ed53ac0b7b Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 12:54:58 +0545 Subject: [PATCH 15/21] Show the mark while the app boots, and stop timing the reveal The window came up before the app was ready and showed a black rectangle for the gap. It now shows the Stroke mark and name, and nothing that moves: startup has no progress to report, so there is nothing a spinner could say that the mark does not already say by being on screen. The reveal itself was the reason that gap was seconds long. revealApp waited for the show IPC to come back and then for a requestAnimationFrame - a frame a hidden window never produces - so when the show did not land, the page stayed hidden and what eventually uncovered it was a timer. It sets the attribute synchronously now and asks the window to show itself afterwards, which is the right order anyway: the first thing on screen is the finished app rather than a page fading in. Both boot timers are gone with it, and nothing in the startup path waits on one any more. They existed to rescue a window stuck on black, and there is no such state left to rescue: the splash is what shows whenever the app is not ready, so the worst case is the mark staying up rather than an empty rectangle. --- index.html | 72 ++++++------------ src/lib/app-reveal.js | 21 +++--- src/lib/components/FkSubviewPanel.svelte | 95 +++++++++++++++++------- 3 files changed, 101 insertions(+), 87 deletions(-) diff --git a/index.html b/index.html index 8b3c55d1..95d59221 100644 --- a/index.html +++ b/index.html @@ -77,72 +77,46 @@ This is the floor, and it needs no JS to work: after 8s the page shows itself. Longer than the 2.5s JS failsafe so a healthy boot always wins and this is never what reveals the app. */ - /* Real durations, not 0s: a zero-length animation is not reliably given - its forwards fill, which is how the first version of this floor came to - do nothing at all. The hold is in the keyframes instead, so the floor is - a 200ms fade at the 8s mark either way. */ - @keyframes stroke-boot-reveal { 0%, 97.5% { opacity: 0 } 100% { opacity: 1 } } - @keyframes stroke-boot-retire { 0%, 97.5% { opacity: 1 } 100% { opacity: 0; visibility: hidden } } - #app { opacity: 0; transition: opacity 200ms ease; animation: stroke-boot-reveal 8.2s linear forwards; } - #splash { animation: stroke-boot-retire 8.2s linear forwards; } - /* revealApp() sets this. Stops the animations so a later repaint cannot - re-run them, and beats the starting opacity above. */ - html[data-revealed] #app { animation: none; opacity: 1 !important; } - html[data-revealed] #splash { animation: none; opacity: 0; visibility: hidden; } + /* The app is hidden until the first finished screen marks the root. + Nothing times this: revealApp() sets the attribute the moment it has + something worth showing, and until then what is on screen is the mark + below rather than an empty rectangle. Because the splash is what shows + while the app is not ready, there is no failure that ends in a black + window, and so nothing here needs a timer to rescue it. */ + #app { opacity: 0 } + html[data-revealed] #app { opacity: 1 } + html[data-revealed] #splash { display: none } - /* The splash. Inline and asset-light on purpose: it has to be able to - draw when the bundle is the thing that failed, so it depends on nothing - the bundle provides. It sits under the app rather than over it, so the - app fading in covers it and the two never both matter. */ + /* The mark and the name, and nothing that moves. Startup has no progress + to report, so there is nothing a spinner could say that the mark does + not already say by being on screen. Inline and asset-light on purpose: + it has to draw when the bundle is the thing that failed, so it depends + on nothing the bundle provides. */ #splash { position: fixed; inset: 0; z-index: 0; - display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 18px; - transition: opacity 200ms ease, visibility 200ms; + display: flex; align-items: center; justify-content: center; gap: 11px; + color: #080808; + } + html.dark #splash { color: #f7f7f7 } + #splash img { width: 32px; height: 32px; object-fit: contain; user-select: none; -webkit-user-drag: none } + #splash span { + font: 600 19px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + letter-spacing: -0.015em; } - #splash img { width: 88px; height: auto; opacity: 0.9; user-select: none; -webkit-user-drag: none; } /* The mark that stays visible against the surface behind it. The theme is only known to the head script, which has already set `.dark` by now. */ #splash .mark-dark { display: none } html.dark #splash .mark-dark { display: block } html.dark #splash .mark-light { display: none } - /* Matches the two surfaces the head script paints, so the bar sits on the - same scale as the mark above it in either theme. */ - #splash { color: #080808 } - html.dark #splash { color: #f7f7f7 } - #splash .bar { width: 120px; height: 2px; border-radius: 2px; overflow: hidden; background: currentColor; opacity: 0.12; } - #splash .bar i { - display: block; width: 40%; height: 100%; border-radius: 2px; background: currentColor; - animation: stroke-boot-slide 1.1s ease-in-out infinite; - } - /* Indeterminate on purpose: startup has no progress to report, and a bar - that pretends to measure one is a lie told at every launch. */ - @keyframes stroke-boot-slide { - 0% { transform: translateX(-100%) } - 100% { transform: translateX(350%) } - } - @media (prefers-reduced-motion: reduce) { - #splash .bar i { animation: none; width: 100%; opacity: 0.35 } - }
- diff --git a/src/lib/app-reveal.js b/src/lib/app-reveal.js index 5525cf8c..943a73ab 100644 --- a/src/lib/app-reveal.js +++ b/src/lib/app-reveal.js @@ -31,17 +31,16 @@ export function isRevealed() { export function revealApp() { if (revealed || typeof document === 'undefined') return revealed = true - // Show first: a hidden WebKitGTK window never fires requestAnimationFrame, - // so the fade below would wait forever. Then one frame so whatever the - // caller just set (overlay, modal) is in the DOM before the fade starts. - void showWindow().finally(() => { - requestAnimationFrame(() => { - // The attribute is the whole switch now: index.html gates #app rather than - // , so that the splash can be seen while the app is still booting. - // It cancels the CSS boot floor, fades the app in and retires the splash. - document.documentElement.dataset.revealed = '' - }) - }) + // Mark first, and synchronously. This used to wait on the show IPC to come + // back and then on a requestAnimationFrame - a frame a hidden window never + // produces, so when the show did not land the page stayed hidden and what + // finally uncovered it was a timer, seconds later. Nothing here waits now: + // the attribute is set, the CSS swaps the splash for the app, and the window + // is asked to show itself afterwards. Marking before the window appears is + // the right order anyway - the first thing on screen is the finished app + // rather than a page fading in. + document.documentElement.dataset.revealed = '' + void showWindow() } /** Never leave a blank window: reveal regardless if no screen claimed it. */ diff --git a/src/lib/components/FkSubviewPanel.svelte b/src/lib/components/FkSubviewPanel.svelte index b7ef58a4..7bda84f4 100644 --- a/src/lib/components/FkSubviewPanel.svelte +++ b/src/lib/components/FkSubviewPanel.svelte @@ -72,6 +72,28 @@ return n > 0 && (i + 1) % n === 0 } + /** + * The cell classes, built once instead of per cell. + * + * The dock follows the cell cursor, so this table re-renders on every arrow + * key. At 50 rows and a wide table that is a few thousand cells a keystroke, + * and it was doing a seven-argument `cn()`, three `isGroupEdge` calls and a + * four-interpolation style string for each of them. None of that varies by + * cell, so none of it belongs in the loop: what is left per cell is picking + * between strings that already exist. + */ + const cls = $derived.by(() => { + const base = 'cursor-default overflow-hidden align-middle text-ellipsis whitespace-nowrap outline-none' + const col = metrics.colRules ? ` ${rule.col}` : '' + return { + cell: base + col, + cellRight: base + col + ' text-right tabular-nums', + num: 'select-none text-right align-middle tabular-nums text-muted-foreground/60', + rowRule: rule.row, + groupRule: rule.group, + } + }) + /** * What the panel draws. Every lookup replaces `data` with an empty * `{ loading: true, rows: [] }` first, so drawing `data` directly blanked the @@ -186,6 +208,20 @@ sel = { r: i, c: j } } + /** + * One handler for the body. The cell already names itself in `data-fk-cell`, + * so the row and column come off the event target rather than out of a + * closure built per cell. + * @param {Event} e + */ + function onBodyPick(e) { + const el = /** @type {Element | null} */ (e.target)?.closest?.('[data-fk-cell]') + const id = el?.getAttribute('data-fk-cell') + if (!id) return + const [r, c] = id.split(':') + selectCell(Number(r), Number(c)) + } + /** Move the selection, clamped to the result set. @param {number} dr @param {number} dc */ function moveSel(dr, dc) { if (!rowCount || !colNames.length) return @@ -314,7 +350,10 @@ role="grid" data-studio-selectable="text" class="min-w-full border-separate font-mono" - style="border-spacing:0; table-layout:fixed; width:{tableW}px; font-size:{metrics.cellPx}px; line-height:1" + data-colrule={metrics.colRules ? '' : undefined} + style="border-spacing:0; table-layout:fixed; width:{tableW}px; font-size:{metrics.cellPx}px; line-height:1; + --row-h:{metrics.rowH}px; --pad-x:{metrics.padX}px; --num-pad:{Math.round(7 * metrics.zoom)}px; + --rs:{rule.style}; --rw:{rule.width}px; --rwg:{Math.max(rule.width, 2)}px" onkeydown={onGridKey} > @@ -358,9 +397,20 @@ - + + + + {#each view.rows as row, i (i)} + {@const lastRow = i === view.rows.length - 1} + {@const group = isGroupEdge(i)} + {@const ruled = (metrics.rowRules || group) && !lastRow} + {@const rowRule = ruled ? (group ? cls.groupRule : cls.rowRule) : ''} {#if numW} - {i + 1} + {i + 1} {/if} {#each cols as c, j (c.name)} {@const v = cellAt(i, j)} {@const isNullVal = v === null || v === undefined} {@const isSel = sel?.r === i && sel?.c === j} {@const text = fmt(v)} - selectCell(i, j)} - onfocus={() => selectCell(i, j)} - oncontextmenu={() => selectCell(i, j)} >{text} {/each} @@ -463,3 +492,15 @@ {/if} {/if}
+ + From 2d20084284dc1299d839b80ba3be5f09415da56e Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 12:56:53 +0545 Subject: [PATCH 16/21] Follow the cursor sideways in the related-rows dock, and cut its render cost A table whose columns are all foreign keys is exactly where the dock earns its keep, and arrowing along one kept showing the relation the dock was opened on: the cursor said credits_credithistory and the dock said authtoken_token. Only the row was watched, so a sideways move changed nothing at all. It follows the cell now - moving onto a different foreign key is a request to see that one, and the relation, its label and the column all move with the cursor. A reverse relation hangs off the row rather than any one column, so that one still follows rows only. Because the dock follows the cursor it re-renders on every arrow key, and it was doing far too much per cell to afford that: a seven-argument class join, three calls to work out whether the row was a group edge, a four-interpolation inline style, and three event closures - on every cell, fifty rows at a time. None of it varied by cell. The class strings are built once. The geometry and the rule weight come off custom properties on the table, so a cell now carries no inline style at all. The group-edge test happens once per row instead of three times per cell. And the body has one delegated listener rather than three per cell, which the cells were already set up for - they name themselves in data-fk-cell. Also enlarges the boot splash mark and stacks it over the name. --- index.html | 8 ++--- src/lib/components/DataTable.svelte | 46 ++++++++++++++++++++++------- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/index.html b/index.html index 95d59221..40e63c1a 100644 --- a/index.html +++ b/index.html @@ -94,14 +94,14 @@ on nothing the bundle provides. */ #splash { position: fixed; inset: 0; z-index: 0; - display: flex; align-items: center; justify-content: center; gap: 11px; + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; color: #080808; } html.dark #splash { color: #f7f7f7 } - #splash img { width: 32px; height: 32px; object-fit: contain; user-select: none; -webkit-user-drag: none } + #splash img { width: 72px; height: 72px; object-fit: contain; user-select: none; -webkit-user-drag: none } #splash span { - font: 600 19px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; - letter-spacing: -0.015em; + font: 600 15px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + letter-spacing: 0.02em; } /* The mark that stays visible against the surface behind it. The theme is only known to the head script, which has already set `.dark` by now. */ diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index a26fe74d..1efceea5 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -494,14 +494,29 @@ import FilterX from "@lucide/svelte/icons/filter-x"; let _fkFollowTimer = null let _fkFollowSeq = 0 + // Across a row it follows the column too. A table whose columns are all + // foreign keys is exactly where the dock earns its keep, and arrowing along + // one used to keep showing the relation the dock was opened on: the cursor + // said credits_credithistory and the dock said authtoken_token. Only the row + // was watched, so a sideways move changed nothing. $effect(() => { - const target = focusedRow - const anchored = fkSubview?.rowIdx - if (anchored === undefined || target === null || target === anchored) return - if (rows[target] === undefined) return + const targetRow = focusedRow + const targetVis = focusedCol + const sv = fkSubview + if (!sv || targetRow === null || rows[targetRow] === undefined) return + const targetCol = targetVis === null ? -1 : visToActualColIdx(targetVis) + // A reverse relation hangs off the row, not off any one column, so it keeps + // following rows only. A forward one belongs to its column, and moving onto + // a different foreign key is a request to see that one. + const movedToOtherFk = + sv.kind === 'forward' && targetCol >= 0 && targetCol !== sv.colIdx && !!_colCache[targetCol]?.fk + if (targetRow === sv.rowIdx && !movedToOtherFk) return untrack(() => { if (_fkFollowTimer) clearTimeout(_fkFollowTimer) - _fkFollowTimer = setTimeout(() => { _fkFollowTimer = null; void followFkSubview(target) }, FK_FOLLOW_DELAY) + _fkFollowTimer = setTimeout(() => { + _fkFollowTimer = null + void followFkSubview(targetRow, movedToOtherFk ? targetCol : -1) + }, FK_FOLLOW_DELAY) }) }) @@ -510,16 +525,26 @@ import FilterX from "@lucide/svelte/icons/filter-x"; * height. A NULL foreign key resolves to the panel's empty state rather than a * query that can only come back empty. * @param {number} idx + * @param {number} [nextCol] Switch to this column's relation as well as this + * row; -1 or omitted keeps the relation the dock already has. */ - async function followFkSubview(idx) { + async function followFkSubview(idx, nextCol = -1) { const sv = fkSubview - if (!sv || sv.rowIdx === idx || rows[idx] === undefined) return + if (!sv || rows[idx] === undefined) return + if (sv.rowIdx === idx && nextCol < 0) return const row = rows[idx] ?? [] + // Switching column switches the relation, and with it the label the settle + // below checks itself against. + const colIdx = nextCol >= 0 ? nextCol : (sv.colIdx ?? -1) + const label = + nextCol >= 0 && _colCache[nextCol]?.fk + ? foreignKeyTargetLabel(_colCache[nextCol].fk) + : sv.label const seq = ++_fkFollowSeq /** @param {{ columns?: any[], rows?: any[], error?: string | null }} res */ const settle = (res) => { // A newer move (or a close, or a different relation) owns the dock now. - if (seq !== _fkFollowSeq || fkSubview?.rowIdx !== idx || fkSubview?.label !== sv.label) return + if (seq !== _fkFollowSeq || fkSubview?.rowIdx !== idx || fkSubview?.label !== label) return fkSubview = { ...fkSubview, data: { loading: false, columns: res.columns ?? [], rows: res.rows ?? [], error: res.error ?? null } } } @@ -534,15 +559,14 @@ import FilterX from "@lucide/svelte/icons/filter-x"; return } - const colIdx = sv.colIdx ?? -1 const fk = _colCache[colIdx]?.fk ?? null if (!fk) return const value = row[colIdx] if (value === null || value === undefined) { - fkSubview = { ...sv, rowIdx: idx, data: { loading: false, columns: [], rows: [], error: null } } + fkSubview = { ...sv, rowIdx: idx, colIdx, label, data: { loading: false, columns: [], rows: [], error: null } } return } - fkSubview = { ...sv, rowIdx: idx, data: { loading: true, columns: [], rows: [], error: null } } + fkSubview = { ...sv, rowIdx: idx, colIdx, label, data: { loading: true, columns: [], rows: [], error: null } } settle(await onfetchrelatedrows({ kind: 'forward', fk, row })) } From 897f5d95e5d40e4e68ca2ab218f19e622a84107f Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 13:01:41 +0545 Subject: [PATCH 17/21] Hold the preview bar together at any width Everything describing the value in the dock's bar was shrink-0 - the type badge, the row, NULL, read-only, the line and character counts, the size chip. Only the column name could give way, so once there were enough of them the row grew past its own width and pushed Stage and Close off the end of it. They are one shrinking group now: a narrow dock takes room from the description, and the buttons stay where they are. The row number counts up as the cursor moves and was set in proportional digits, so the step from row 9 to row 10 shifted everything after it. Tabular now, like the counts beside it. The column name truncates and had no title. It is the only place the value says what it is, and it is the first thing to be given up when the bar runs short, so the full name (with its type) is now reachable on hover. --- src/lib/components/CellEditorPanel.svelte | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/lib/components/CellEditorPanel.svelte b/src/lib/components/CellEditorPanel.svelte index a751e897..9e442c1c 100644 --- a/src/lib/components/CellEditorPanel.svelte +++ b/src/lib/components/CellEditorPanel.svelte @@ -563,14 +563,27 @@ beside the name, and a 28px row of value did not need 92px of chrome. -->
- {colName} + +
+ + {colName} {#if colType} {colType} {/if} {#if sourceHint} - {sourceHint} + + {sourceHint} {/if} {#if isNull && !dirty} NULL @@ -592,11 +605,12 @@ {#if dirty && !readOnly} edited {/if} +
-
+
- + @@ -778,7 +778,7 @@ bind:value={draft} {readOnly} {wrap} - gutter={showGutter && !wrap} + gutter={showGutter} placeholder={isNull ? 'NULL' : ''} ariaLabel="{colName} value" keys={editorKeys} From 2144dc5ecb4a3aa8794b16a6d9803ffdad6c3cd3 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 25 Sep 2026 14:28:29 +0545 Subject: [PATCH 21/21] Trim the changeset to one line per change --- .changeset/grid-and-cell-dock.md | 52 +++++++++++++------------------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/.changeset/grid-and-cell-dock.md b/.changeset/grid-and-cell-dock.md index c09fa518..ad287c5c 100644 --- a/.changeset/grid-and-cell-dock.md +++ b/.changeset/grid-and-cell-dock.md @@ -1,34 +1,24 @@ -### Bug Fixes - -#### Canvas Table -- Escaping the cell dock puts focus back on the cell it came from. Closing it left focus on the element it was about to remove, so focus fell through to the page body and the grid stopped answering arrow keys until a cell was clicked again. The close button and staging a change land focus in the same place. -- Space previews the focused cell instead of typing a space into it. Space is a printable character, so it fell through to type-to-edit and opened the editor with a space already in it, which is the one keystroke on the grid that destroyed the cell it was aimed at. -- Soft wrap no longer freezes the app on a long line. A jsonb cell holding a file is one line of half a million characters, and CodeMirror lays a single line out whole however little of it is on screen, so wrapping that one measured every character at once. Past a longest-line cap the toggle is disabled rather than quietly off, and says why. Syntax highlighting stops at the same kind of boundary. -- The in-cell Load button updates the dock. It only re-read the dock when the dock already happened to be on that cell, which is not where it usually is, so the value loaded and the panel went on saying "not loaded" over a different row. -- A value past the 8 MB inline cap opens in the dock, which reads it in pages, instead of showing a message explaining that it was too big and which key to press. - -#### Cell Editor -- Line numbers toggle on and off (Alt+L), and the choice is remembered. Soft wrap already hides them while it is on, so the button is disabled there and says why. -- Soft wrap is remembered. It was decided per cell from the text, so turning it on meant turning it on again at the next cell, and the next. -- Confirm dialogs show what is focused. They parked focus on the dialog box so Enter would not hit Cancel, which left nothing on screen looking focused. Focus goes to the confirm button, which is the default action and already says so with its ↵. -- The oversize cell notice had two Load buttons for one action. - -#### Related Rows -- The related-rows sub-view draws in the table style that is actually selected. It read three booleans off the grid, so dotted, dashed, hairline, double, bordered, ledger, graph and bands all came out as plain solid lines, directly under a grid drawing something else. +### New Features +- Space previews the focused cell, Shift+Space opens it with the caret already in the editor +- Line numbers in the cell editor toggle with Alt+L +- Stroke's mark and name show while the app starts -#### Startup -- The window can no longer be stuck on a black screen. The page boots hidden and JavaScript reveals it, but the window is shown on a separate timer, so anything that stopped the bundle first left a black rectangle with no way out but killing the app. The reveal failsafe now runs before anything that can throw, and the page carries a second one that needs no JavaScript at all. - -#### Find in Database -- Ctrl+F focuses the search box. It did nothing there before. -- The search bar lines up with the sidebar header beside it. It stood about 52px against the header's 36px. +### Bug Fixes +- Soft wrap no longer freezes the app on a cell held on one very long line +- Space no longer types a space into the cell it was meant to preview +- Escape from the cell editor puts focus back on the cell it came from +- The window no longer starts on a black screen +- Confirm dialogs show which button is focused +- Related rows follow the cursor sideways across foreign key columns +- Related rows draw in the table style you selected +- The Load button on a cell updates the preview below it +- A value past the size cap opens in the preview instead of a message about it +- Ctrl+F focuses the search box in Find in database +- The cell editor bar keeps its buttons on screen at any width ### Changes - -#### Canvas Table -- Shift+Space previews the cell and steps into the editor, with the caret at the end of a short value and the top of a long one. Alt+Space steps into a dock that is already open. Plain Space leaves the cursor on the grid so the arrows keep walking the table and the dock follows along. -- Alt+A opens the filter on the column the cursor is already on, with an operator chosen from that column's type, instead of on whichever column happens to be first. -- The table view shortcuts lost a key: filter, sort, columns and reset are Alt+A, Alt+S, Alt+C and Alt+R, matching the Alt+N the toolbar already used. - -#### Find in Database -- Searching happens as you type, 400ms after it stops, so the Search button is gone. One search is a query per table, so a pause is what makes this affordable. Enter skips the wait, Escape clears the box and the results. +- Filter, sort, columns and reset move to Alt+A, Alt+S, Alt+C and Alt+R +- Alt+A opens the filter on the column the cursor is already on +- Find in database searches as you type, and Escape clears it +- Soft wrap and line numbers are remembered from one cell to the next +- Opening and scrolling related rows is faster