diff --git a/.changeset/grid-and-cell-dock.md b/.changeset/grid-and-cell-dock.md new file mode 100644 index 00000000..ad287c5c --- /dev/null +++ b/.changeset/grid-and-cell-dock.md @@ -0,0 +1,24 @@ +### 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 + +### 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 +- 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 diff --git a/index.html b/index.html index 1823a4b4..02489bec 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 99fa5ea4..943a73ab 100644 --- a/src/lib/app-reveal.js +++ b/src/lib/app-reveal.js @@ -31,14 +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(() => { - document.documentElement.style.opacity = '1' - }) - }) + // 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/CellEditorPanel.svelte b/src/lib/components/CellEditorPanel.svelte index ba304b8b..5b376ab2 100644 --- a/src/lib/components/CellEditorPanel.svelte +++ b/src/lib/components/CellEditorPanel.svelte @@ -5,7 +5,7 @@ * A grid row is 28px tall, which is the wrong surface for a paragraph of * markdown, a stack trace, a 40-line JSON payload or a SQL snippet stored in a * text column - the inline editor shows one line of it and scrolls the rest - * sideways. Shift+Space opens the same value here: the whole thing, wrapped, + * sideways. Space opens the same value here: the whole thing, wrapped, * editable, with the raw text and a read-only preview side by side when the * value is structured. * @@ -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' @@ -64,6 +65,14 @@ 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} */ (() => {}), + /** + * Put the caret in the editor when the dock opens. Off for the grid, which + * opens this as a preview: the cursor stays on the cell so arrows keep + * walking the table, and the reader steps in deliberately (`focusEditor`). + */ + autofocus = true, } = $props() /** @@ -123,9 +132,15 @@ // what this cell holds without moving the cursor, and the draft has to // follow it. const cell = `${colName}\u0000${sourceHint}\u0000${detached ? 'd' : ''}\u0000${oversize ? 'preview' : 'full'}` - const text = toText(value) + // Read, not converted. `toText` used to run up here, above both guards, so + // every re-run stringified the value and threw the result away - twice for + // anything it pretty-prints, and on every arrow key, because the dock + // follows the cursor. It even did it with the dock closed. The read is what + // registers the dependency; the work belongs after the guards. + const raw = value if (!open) { wasOpen = false; seededCell = ''; return } if (wasOpen && cell === seededCell) return + const text = toText(raw) const justOpened = !wasOpen // An unstaged draft is about to be replaced by the cell the cursor moved to. // Said out loud, because losing typing silently is worse than a toast. @@ -140,7 +155,8 @@ // 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. - wrap = !/^\s*[[{]/.test(text) + maxLineLen = longestLine(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 @@ -150,7 +166,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) 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 + } }) /** @@ -159,12 +180,44 @@ * 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() + }) }) + /** + * 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 + 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 + } + const dirty = $derived(draft !== original) const isNull = $derived(value === null || value === undefined) @@ -254,8 +307,8 @@ ok: false, truncated: true, error: loaded - ? `Showing the first ${formatBytes(loaded)} of ${formatBytes(oversize.bytes)}. The page fetched a preview of this column instead of the value - that is what keeps a table of half-megabyte cells openable at all.` - : `This cell holds ${formatBytes(oversize.bytes)}. The page fetched its size, not its contents: reading a column like this for every row on screen is what makes a table take ten seconds to open. It is one click away.`, + ? `Showing the first ${formatBytes(loaded)} of ${formatBytes(oversize.bytes)}. The grid reads a preview of a column this wide, not the whole value.` + : `The grid reads this column's size, not its contents, so a table full of cells this wide still opens fast.`, } } // Loaded, but the server stopped at the ceiling: the text really is cut, so @@ -344,6 +397,82 @@ // toggle still works if you want it. $effect(() => { if (heavy) wrap = false }) + /** + * Longest line in the value, measured once per cell rather than per keystroke + * and without a `split('\n')`, which would allocate a second copy of a value + * already big enough to be the problem. + */ + let maxLineLen = $state(0) + 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 + } + } + + /** + * The line length past which soft wrap is refused, not merely defaulted off. + * + * CodeMirror virtualises by line: rows outside the viewport cost nothing, but + * a single line always lays out whole. A jsonb column holding a file arrives + * as one line of half a million characters, and wrapping that means measuring + * every one of them into a few thousand visual rows in one frame. The editor + * stops answering, which is what it did here before this line existed. + * + * Unwrapped there is no such cost: the line is one row and the view draws the + * slice that is on screen. So past the cap the toggle is disabled rather than + * merely off, because turning it on is the hang. + */ + 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 } + })() + + /** + * Line numbers, remembered the same way soft wrap is. + * + * It holds while wrapped too. The gutter numbers logical lines, so a wrapped + * line carries its number on its first visual row and nothing on the rest, + * which is what every editor that wraps does and is the reading everyone + * already has. Tying this to wrap only took the choice away. + */ + 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 + 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 @@ -352,10 +481,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 +526,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. @@ -406,7 +545,8 @@ // 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: () => { wrap = !wrap; return true } }, + { key: 'Alt-z', run: () => { toggleWrap(); return true } }, + { key: 'Alt-l', run: () => { toggleGutter(); return true } }, { key: 'Alt-r', run: () => { revert(); return true } }, ] @@ -431,14 +571,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 @@ -456,45 +609,45 @@ class="shrink-0 rounded-[3px] border border-warning/30 bg-warning/10 px-1.5 py-px font-mono text-ui-3xs text-warning" title="A wide column reports its size per row instead of its contents - the value is not loaded and cannot be edited until it is" >{formatBytes(oversize.bytes)} · not loaded - {#if onloadfull} - - {/if} {/if} {#if dirty && !readOnly} edited {/if} +
-
+
+ + @@ -532,7 +685,7 @@ +
= 0 ? loadFullCellValue : null} oncommit={commitCellEditor} + onclose={() => tick().then(() => tableContainer?.focus({ preventScroll: true }))} />
{/if} diff --git a/src/lib/components/FkSubviewPanel.svelte b/src/lib/components/FkSubviewPanel.svelte index 89930cc2..7bda84f4 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,61 @@ 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 + } + + /** + * 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 @@ -152,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 @@ -280,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} > @@ -305,7 +378,11 @@ - + + + + {#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} @@ -418,3 +492,15 @@ {/if} {/if}
+ + diff --git a/src/lib/components/SearchPage.svelte b/src/lib/components/SearchPage.svelte index 7e428406..f556d4ae 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' /** @@ -21,6 +23,8 @@ active = false, /** @param {string} tableName @param {string} searchTerm */ onopentable = (tableName, searchTerm) => {}, + /** Assigned here; the shell calls it for ⌘F. */ + focusSearch = $bindable(/** @type {() => void} */ (() => {})), } = $props() let query = $state('') @@ -38,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 @@ -63,9 +73,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 +174,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 +223,14 @@ -
+ +
- {:else} + {:else if query} {/if}
- {#if regexError} -

{regexError}

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

{regexError}

+
+ {/if} {#if searching} @@ -338,7 +406,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/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 423df387..be0014ab 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, @@ -404,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('') @@ -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, openCellDock: (r: number, c: number) => void } | null} */ + let dataTable = $state(null) /** @type {ReturnType | null} */ let filterDebounceTimer = null /** @type {ReturnType | null} */ @@ -2277,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?.() @@ -2670,19 +2673,21 @@ let rowSearch = $state('') return true } - createHotkey('Alt+Shift+F', (e) => { + 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+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 +2707,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 @@ -6940,9 +6945,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 Shift+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 @@ -7898,6 +7905,7 @@ let rowSearch = $state('') {#await import('./SearchPage.svelte')}{:then { default: SearchPage }}
c.name === preferColumn) ? preferColumn : ""; + const col = wanted || (columns[0]?.name ?? ""); const op = col ? defaultOpForCol(col) : "contains"; onfilterschange([...rowFilters, createFilter(col, op)]); } @@ -1173,7 +1182,7 @@ .map((c) => `${c.name} (~${fmtBytes(c.avgBytes)}/row)`) .join(', ')}.\n\nA page of these would move ${fmtBytes( previewColumns.reduce((n, c) => n + c.avgBytes, 0) * Math.max(1, to - from + 1), - )}. Open a cell (Shift+Space) and press Load to read one in full.`} + )}. Open a cell (Space) and press Load to read one in full.`} > {previewColumns.length === 1 ? previewColumns[0].name : `${previewColumns.length} columns`} previewed 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 12febe06..fd96ca4f 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)' }, @@ -156,7 +156,9 @@ export const SHORTCUT_GROUPS = [ { combo: 'Alt+D', desc: 'Copy the focused row into the insert draft' }, { combo: 'Mod+Escape', desc: 'Discard every staged row' }, { combo: 'Mod+E', desc: 'Expand / collapse the focused row' }, - { combo: 'Shift+Space', desc: 'Preview the focused cell in the bottom dock (or Shift+click it)' }, + { 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' }, { combo: 'Escape', desc: 'Cancel edit' }, 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