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}
+
{/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}
>
@@ -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 @@
-
{/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