diff --git a/.changeset/stroke-2-0.md b/.changeset/stroke-2-0.md new file mode 100644 index 00000000..dc182d57 --- /dev/null +++ b/.changeset/stroke-2-0.md @@ -0,0 +1,109 @@ +### New Features + +#### Editing + +- **Stage as many new rows as you want.** Add and Duplicate append, so pressing either three times gives three rows stacked under the header, each editable, each with its own tick and its own discard. The first row's tick inserts the batch, as do Apply and ⌘↵, under one confirm - three inserts is one list of three statements, not three dialogs. A row that fails stays staged with everything still typed into it. Right-click a staged cell to generate a UUID or CUID, drop in now/today, copy, paste, clear back to the column's default, or fill that column in every staged row. `Alt+N` stages a row, `Alt+D` copies the focused row into one, `Mod+Escape` discards them all, and the band belongs to the table it was opened on rather than following you between tabs. + +- **Duplicate no longer writes on the click.** It fills the insert draft instead, so nothing reaches the database until it is submitted, and the key and any generated column take their defaults rather than the source row's values. + +- **Import rows from a file.** CSV, TSV and JSON, mapped to the table's columns before anything is written. + +- **Run SQL inside an explicit transaction.** The console can open one, run against it and commit or roll back, instead of every statement standing alone. + +- **Generate migration SQL from a schema diff.** Take a snapshot, change the schema, and read back the statements that would take one to the other. + +- **A cell transform edits the cell.** It used to compute a value and put it on the clipboard, which is one step short of the thing you wanted. + +#### The cell dock + +- **The cell editor lives under the grid.** A docked panel rather than a floating box over the rows you are trying to read, with the other panels matched to it, Escape to close, and Find inside the value. + +- **A JSON cell opens as a tree.** No Monaco, so it costs nothing until you ask for it, and it reads as structure rather than as one long line. + +#### Connections + +- **The connection screen is rebuilt.** One tab stop with arrows walking the list, Enter to open and ⌘↵ to connect, a plain Connect button, database names instead of connection strings, and provider panels that no longer hang while they load. + +- **Every provider gets the same searchable database dropdown.** Neon asks for the scope that can read a connection URI, a paused Supabase project says so and falls back to its direct host, and saving a connection that points at a database already on file asks first. + +- **The paste bar fills itself from the clipboard.** On every window focus, so a URL copied from a dashboard is already waiting when you switch back. + +#### Interface + +- **Four new themes**, a menu bar in the title bar, and a second window from the same session. + +- **Every shortcut draws as keycaps.** One menu recipe across the app, so a chord looks the same wherever it is shown. + +- **Per-tab structure view**, settings for the row-number gutter, the menu bar and grid appearance, and a sidebar that folds Pinned into Tables, keeps its counts in the section headers and stops the list jumping. + +- **Find and replace moved into the sidebar** and gained an Any column option, the editor's find chords (`Alt+C`, `Alt+R`, `Alt+W`) and `Alt+X` to clear. + +- **The app says which version it just updated to** and links that version's changelog. + +### Bug Fixes + +#### The data grid + +- **The staged rows sit on their columns at any scroll offset.** The insert band worked out its own geometry instead of reading the canvas's: it summed the gutters by hand and left the row-number one out, and its horizontal pin was a sticky box whose behaviour depends on how wide it is, so how well it held varied with the table. Dragging a wide table sideways slid the band against the grid under it. Every x and width now comes from the same `geom` the canvas draws from, and the band shares the canvas's sticky anchor, so the two measure the viewport's left edge from one box. Tab inside the band scrolls the column into view, which a pinned band cannot ask the browser to do for it. + +- **A field inside the grid gets the keys a field gets.** The grid's undo/redo/copy listener is bound on `window`, so it ran before every other capture handler in the app - including the one that gives inputs their undo stack and their word deletion. ⌘Z with the caret in a staged row undid a cell edit somewhere else in the table and took the field's own ⌘Z with it. It now stands down for anything you type into, and Alt+Backspace deletes a word on Linux and Windows as well as macOS, which also stops the global "discard staged changes" binding from throwing the row away mid-sentence. + +- **A large value loads into the cell it belongs to.** The in-cell load stopped at 1MB on the grounds that a cell's value is what the canvas formats and the search walks - but the canvas cuts the drawn text to 400 characters and caches it, the highlighter matches that same cut string, and the row search runs in SQL. A 1.2MB resume was being refused for a cost nobody was paying. The ceiling is now the dock's 8MB, and past 1MB a JSON value stays the text it arrived as rather than being parsed and re-serialized to show forty characters of itself. + +- **The search highlight follows how the search matched.** It ran `indexOf` over a lowercased copy of the text, so with match case on it highlighted `Aarav` for a search that returned nothing containing it, and a regex search marked the pattern's literal characters. + +- **`F5` refreshes the table, not the app.** It was bound to nothing, so the webview reloaded the document - tearing down the session, redialling the connection and re-reading the catalog. + +- **The Add button names its shortcut.** Its tooltip read "Insert row (Add)", which is the label, not a chord. It says `Alt+N` now, and `Alt+N` works from inside the staged band too. + +- **A timestamp cell keeps the instant it was edited at**, and Postgres `oid`, `xid`, `xid8` and `pg_lsn` decode instead of showing their bytes. + +- **An expanded row stops swallowing clicks on the header**, the related-rows panel can be selected and copied, and the structure grid no longer draws a field inside every cell. + +- **The database list stops blanking on every switch**, and switching database lands on Tables. + +#### Connections and queries + +- **A dropped pool reads as a dropped pool.** `attempted to acquire a connection on a closed pool` matched none of the connectivity patterns, so it read as a SQL error: no reconnect fired, and the banner sat there. Failed table loads now state themselves where the grid would be, with Reconnect for a dropped pool and Retry for everything else. + +- **Disconnect survives a restart.** Auto-reconnect read the last-connection id as "resume this", so quitting while disconnected came back connected. + +- **A dead pooled connection no longer stalls the first query after an idle spell**, and a Postgres backup no longer writes NULL where the data was. + +- **SQL parameter values are escaped by the target engine's rules**, not by one engine's rules everywhere. + +#### Keyboard and focus + +- **The focus ring was invisible app-wide.** Tailwind v4 compiles every `outline-*` utility to `outline-style: var(--tw-outline-style)`, and `outline-none` sets that variable to `none` on the element - so the `outline-none` + `focus-visible:outline-2` pattern this app uses in about forty places applied a 2px-wide outline whose style was still `none`. + +- Keyboard reaches the rest of the app: the connection rail is one tab stop with arrows walking it, relationship columns join the cell cursor (Enter previews, `⇧↵` opens in a new tab, `⌘↵` in place), `Alt+F` / `Alt+E` filter by or exclude the focused value, and the hand-rolled dialogs trap focus, restore it and inert the page behind them. + +- **Tab cycles correctly on every platform, in both directions**, a chord that wraps keeps its last key instead of losing it, and close-all-tabs has a chord that can actually fire. + +#### Elsewhere + +- Every numeric server setting applies, the JSONPath union selector answers correctly, Instance Insights tables unstick, a Data Diff picker says why it is empty instead of going dead, an AI turn that finishes with no message no longer hangs, and the fixable dependency advisories are patched. + +### Changes + +- **The repo moved to the `stroke-app` organization.** Downloads, the updater manifest and the Scoop bucket live at `github.com/stroke-app/stroke`, and Homebrew is now `brew install --cask stroke-app/tap/stroke`. Builds shipped before this release reach the new location through GitHub's redirect, so nothing installed needs reinstalling. + +- **Apply and Reset are one control**, and staged changes can be read as SQL without running them. + +- **Every dialect runs in Docker against the same schema**, so what the app claims each one supports is what was tested rather than what was assumed. The SQL Server handshake says only what was verified. + +### Performance + +#### Tables with very large values + +- **A table of half-megabyte cells opens in about a second instead of eleven.** A `jsonb` column holding an uploaded file averaged 601KB a row, and `SELECT *` over a page moved roughly 100MB of it out of TOAST, through sqlx, across the IPC bridge and into a webview parse, for a grid that can draw forty characters of it. Wide columns are now fetched as their size: the page asks for `pg_column_size`, which reads the varlena header and touches no TOAST chunk, and the value itself loads per cell on demand. Which columns are wide is measured rather than assumed, and deliberately not taken from `pg_stats.avg_width` - ANALYZE measures the datum in the tuple, so a column whose values all live in TOAST reports the width of the 18-byte pointer; the column above measures 601KB and `pg_stats` says 18. One sampling query settles it in 0.67ms over 37 buffers, cached per table, and skipped entirely unless the table has TOAST worth searching. The size check also runs against the value's text length, not its compressed size: these payloads compress 2x or better, so rows comfortably under the cap on disk were still shipping megabytes each. Settings → Load large values on demand turns the whole thing off and restores the old behaviour. + +- **A capped cell says what it holds and loads on request.** The grid draws `361 KB jsonb` with a download button beside the copy one; it turns into a spinner and fills that cell, and only that cell. The dock's own Load button fetches up to 8MB - a ` -

+

Prepended to the system prompt on every turn.

@@ -4560,7 +4572,7 @@ @@ -4570,7 +4582,7 @@

Chat history

-

+

Permanently delete all saved conversations for this connection.

-

+

{contextStats.pct}% used · auto-compresses at 30k tokens

@@ -4798,7 +4810,7 @@ class="font-mono text-ui font-semibold tabular-nums text-foreground" >{stat.value} - {stat.label} @@ -4811,7 +4823,7 @@ > {#each [{ color: "bg-primary/50", text: "Full history re-sent each turn for context." }, { color: "bg-primary/50", text: "Compresses at 30k, keeps last 10 turns." }, { color: "bg-primary/50", text: "Only schema for mentioned tables is injected." }, { color: "bg-warning/60", text: "Failed tool calls blocked after 2 retries." }] as item}
@@ -4967,7 +4980,7 @@ />

Ctrl+scroll to zoom · drag to pan · double-click to reset

@@ -4981,6 +4994,8 @@ aria-modal="true" aria-label="Diagram fullscreen" class="fixed inset-0 z-[500] flex flex-col bg-background" + tabindex="-1" + use:focusTrap >

Drag to pan · Ctrl+scroll to zoom · Double-click to reset

diff --git a/src/lib/components/AiModelPicker.svelte b/src/lib/components/AiModelPicker.svelte index 73a76e90..7bcbc1d4 100644 --- a/src/lib/components/AiModelPicker.svelte +++ b/src/lib/components/AiModelPicker.svelte @@ -29,7 +29,7 @@ {#if activeProfile && hasBrand(activeProfile.provider)} @@ -51,11 +51,11 @@ {#if hasBrand(profile.provider)} {:else} - + {/if}
{profile.name} - {profile.model} + {profile.model}
{/each} diff --git a/src/lib/components/AiSettingsDialog.svelte b/src/lib/components/AiSettingsDialog.svelte index 3c1dee77..277945f0 100644 --- a/src/lib/components/AiSettingsDialog.svelte +++ b/src/lib/components/AiSettingsDialog.svelte @@ -437,7 +437,7 @@ "mb-2 flex items-center gap-1.5 self-start rounded-md border px-2 py-1 text-ui-3xs transition-colors", freeOnly ? "border-primary/35 bg-primary/[0.07] text-foreground" - : "border-border/40 text-muted-foreground/70 hover:border-border/70 hover:text-foreground", + : "border-border/40 text-muted-foreground hover:border-border/70 hover:text-foreground", )} onclick={() => (freeOnly = !freeOnly)} > @@ -449,7 +449,7 @@ {#snippet checkBadge()} - + {/snippet} @@ -468,12 +468,12 @@ > {m.id} {#if formatModelSize(m.bytes)} - {formatModelSize(m.bytes)} + {formatModelSize(m.bytes)} {/if}
{STEPS[step]} - {step + 1}/{STEPS.length} {#if isOmniroute} -

+

Connect a provider in the OmniRoute dashboard, then recheck.

{:else} @@ -816,24 +816,24 @@ Model ID below, so Continue works without retyping it. --> {#if suggestionsLoading} -
+
Loading what Ollama offers…
{:else if ollamaSuggestions.local.length || ollamaSuggestions.cloud.length} {#if ollamaSuggestions.local.length} -

Runs on this machine

+

Runs on this machine

{#each ollamaSuggestions.local.slice(0, 3) as m (m.id)}{@render suggestionRow(m)}{/each}
{/if} {#if ollamaSuggestions.cloud.length} -

+

Ollama Cloud · runs on their hardware

{#each ollamaSuggestions.cloud.slice(0, 5) as m (m.id)}{@render suggestionRow(m)}{/each}
-

+

Cloud models need ollama signin once - no download, and they are far larger than anything local.

@@ -841,7 +841,7 @@ {:else} -

+

Couldn't reach Ollama's model list. Pick one at

@@ -863,7 +863,7 @@
{/if}
-

+

{#if isOllama} Start it with ollama serve, or point the URL elsewhere. {:else} @@ -945,7 +945,7 @@ >

{preset.label}

- {#if preset.tag}

{preset.tag}

{/if} + {#if preset.tag}

{preset.tag}

{/if}
{#if selected}{@render checkBadge()}{/if} @@ -1004,7 +1004,7 @@ /> diff --git a/src/lib/components/AiSidebar.svelte b/src/lib/components/AiSidebar.svelte index 64c0edef..078e579f 100644 --- a/src/lib/components/AiSidebar.svelte +++ b/src/lib/components/AiSidebar.svelte @@ -1,5 +1,6 @@ + + +{#snippet item(/** @type {string} */ icon, /** @type {string} */ label, /** @type {string} */ action, /** @type {string} */ combo = "", /** @type {boolean} */ disabled = false)} + run(action)}> + + {label} + {#if combo}{/if} + +{/snippet} + + +{#snippet divider()} + +{/snippet} + + + + + File + + + {@render item("file-text", "New tab", "newTab", "Mod+Shift+T")} + {@render item("terminal", "New query editor", "newSql", "", !connected)} + {@render item("copy", "New window", "newWindow", "Mod+Shift+N")} + + {@render item("database", "Connect…", "openConnection")} + {@render item("plug", "Disconnect", "disconnect", "Mod+Shift+D", !connected)} + + {@render item("settings", "Settings…", "openSettings", "Mod+,")} + {@render item("x", "Close tab", "closeTab", "Mod+W")} + {@render item("trash-2", "Close all tabs", "closeAllTabs", "Mod+Shift+W")} + + + + + {@render divider()} + + + + Edit + + + {@render item("search", "Search rows", "search", "Mod+F", !connected)} + {@render item("replace", "Find & replace…", "findReplace", "Mod+Alt+F", !connected)} + {@render item("list-filter", "Find in database…", "findInDatabase", "", !connected)} + + {@render item("check", "Apply staged changes", "applyEdits", "Mod+S", !connected)} + {@render item("clipboard-copy", "Copy staged changes as SQL", "copyEditsSql", "Mod+Alt+S", !connected)} + {@render item("undo-2", "Discard staged changes", "resetEdits", "", !connected)} + + + + + {@render divider()} + + + + View + + + {@render item("layout-list", "Toggle sidebar", "toggleSidebar", "Mod+B")} + {@render item("bot", "Toggle chat", "toggleChat", "Mod+I")} + + {@render item("plus", "Zoom in", "zoomIn", "Mod+Plus")} + {@render item("minus", "Zoom out", "zoomOut", "Mod+Minus")} + {@render item("rotate-ccw", "Reset zoom", "zoomReset", "Mod+0")} + + {@render item("maximize-2", "Toggle full screen", "fullscreen", "F11")} + {@render item("command", "Command palette…", "commandPalette", "Mod+K")} + + + + + {@render divider()} + + + + Tools + + + {@render item("box", "Database objects", "objects", "", !connected)} + {@render item("server", "Instance insights", "insights", "", !connected)} + {@render item("git-branch", "Schema explorer", "schema", "", !connected)} + {@render item("network", "ER diagram", "erd", "", !connected)} + + {@render item("git-compare", "Data diff", "dataDiff", "", !connected)} + {@render item("layout-dashboard", "Dashboard", "dashboard", "", !connected)} + {@render item("history", "Activity log", "logs", "Mod+Shift+L", !connected)} + + {@render item("blocks", "Extensions", "extensions", "Mod+Shift+X")} + + + + + {@render divider()} + + + + Help + + + {@render item("command", "Keyboard shortcuts", "shortcuts", "Mod+/")} + {@render item("external-link", "What's new", "changelog")} + + {@render item("alert-triangle", "Report an issue…", "reportIssue")} + {@render item("refresh-cw", "Check for updates", "checkUpdates")} + {@render item("info", "About Stroke", "about")} + + + + diff --git a/src/lib/components/AppearanceMenu.svelte b/src/lib/components/AppearanceMenu.svelte index c682464a..8a4dff1e 100644 --- a/src/lib/components/AppearanceMenu.svelte +++ b/src/lib/components/AppearanceMenu.svelte @@ -1,4 +1,5 @@ @@ -40,8 +38,8 @@ Sidebar - {#if sidebarVisible}{:else}{/if} - {mod}B + {#if sidebarVisible}{:else}{/if} + @@ -49,8 +47,8 @@ Tab Bar - {#if tabBarVisible}{:else}{/if} - {mod}⇧T + {#if tabBarVisible}{:else}{/if} + @@ -58,7 +56,7 @@ Table Toolbar - {#if tableToolbarVisible}{:else}{/if} + {#if tableToolbarVisible}{:else}{/if} @@ -68,8 +66,8 @@ Status Bar - {#if statusBarVisible}{:else}{/if} - {mod}⇧B + {#if statusBarVisible}{:else}{/if} + diff --git a/src/lib/components/ArrayCellEditor.svelte b/src/lib/components/ArrayCellEditor.svelte index 73cb048f..c030d55c 100644 --- a/src/lib/components/ArrayCellEditor.svelte +++ b/src/lib/components/ArrayCellEditor.svelte @@ -108,16 +108,16 @@

{column}

-

+

{elementType || 'array'}[] - · + · {items.length} {items.length === 1 ? 'element' : 'elements'}

- {i} + {i} {#if item.v === null} @@ -180,17 +180,17 @@ placeholder="value" spellcheck="false" autocomplete="off" - class="h-8 min-w-0 flex-1 rounded-lg border-2 border-border bg-muted/15 px-2.5 font-mono text-ui-xs leading-none text-foreground outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-muted-foreground/30 hover:border-border/60 focus:border-ring/55 focus:ring-2 focus:ring-ring/15" + class= "field-surface h-8 min-w-0 flex-1 bg-muted/15 px-2.5 font-mono text-ui-xs leading-none text-foreground outline-none transition-[border-color,box-shadow] duration-150 placeholder:text-muted-foreground hover:" onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addItem() } }} /> {/if}
@@ -204,7 +204,7 @@
{/if}
@@ -398,7 +427,7 @@ {#if exportPhase === "running"} {:else if exportLogs.length > 0} - {exportLogs.length} entries {/if} @@ -408,7 +437,7 @@ class="min-h-0 flex-1 overflow-y-auto bg-muted/10 p-4 font-mono" > {#if exportLogs.length === 0} -

+

Run an export to see progress here.

{:else} @@ -422,7 +451,7 @@ {/each} {#if exportPhase === "running"}
Running…
@@ -445,7 +474,7 @@ {#if importPhase === "running"} {:else if restoreLogs.length > 0} - {restoreLogs.length} entries {/if} @@ -455,7 +484,7 @@ class="min-h-0 flex-1 overflow-y-auto bg-muted/10 p-4 font-mono" > {#if restoreLogs.length === 0} -

+

{importPhase === "idle" ? "Run a restore to see progress here." : "Waiting for log entries…"} @@ -471,7 +500,7 @@ {/each} {#if importPhase === "running"}

Running…
@@ -617,7 +646,7 @@ > Tables {#if tables.length > 0} - {selectedTables.size}/{tables.length} {/if} @@ -640,7 +669,7 @@
{#if tables.length === 0} -

+

No tables found.

{:else} @@ -660,7 +689,7 @@ {:else} {/if} {#if t.rowCount != null && t.rowCount >= 0} {fmtRows(t.rowCount)} {/if} @@ -736,11 +765,14 @@ disabled class="flex flex-1 items-center justify-center gap-2 rounded-md bg-primary/60 px-4 py-2 text-ui-xs font-medium text-primary-foreground" > - Exporting… + {exportStopping + ? "Stopping…" + : "Exporting…"} + {/if} + {/if} + {#if dirty && !readOnly} + edited + {/if} + +
+ + +
+
+ {#if isTreeable} + + + {/if} + + + + {#if !readOnly} + + + {/if} + +
+
+ + + +
+ + {#if !wrap && rawOpen} + + {/if} + + {#if rawOpen && rawRuns} + + {/if} + {#if heavy && rawOpen} + +
+
+ + {formatBytes(rawOffset)}–{formatBytes(rawWindowEnd)} of {formatBytes(draft.length)} + + {#if canEditWindow} + editable + {:else} + read-only + {/if} +
+ + +
+
+ +
+ {/if} + + + {#if parsed} + +
+
+ + {parsed.ok ? 'Tree' : 'Preview'} + + {#if parsed.truncated} + truncated value + {:else if !parsed.ok} + invalid JSON + {/if} +
+
+ {#if parsed.ok && query && !treeSearch?.count} +

+ No match for {query} in this value. +

+ {:else if parsed.ok} + { + const text = typeof v === 'string' ? v : JSON.stringify(v, null, 2) + void navigator.clipboard?.writeText(text) + toast.success('Copied') + }} + /> + {:else} +

{parsed.error}

+ {#if parsed.heavy} + +

+ The raw text is already here, and Find works on it under 2 MB. +

+ {/if} + {#if parsed.truncated && oversize && onloadfull} + +

+ Fetched for this row only - the page stays light. +

+ {/if} + {/if} +
+
+ {/if} +
+ diff --git a/src/lib/components/CellQuickLook.svelte b/src/lib/components/CellQuickLook.svelte deleted file mode 100644 index b75a5fb0..00000000 --- a/src/lib/components/CellQuickLook.svelte +++ /dev/null @@ -1,182 +0,0 @@ - - -{#if cell} - - -
- - - -
- -
-
- - {cell.columnName} - - {#if cell.dataType} - - {cell.dataType} - - {/if} - {#if cell.nullable} - - nullable - - {/if} -
- -
- - -
- {#if isNull} -
- NULL -
- {/if} - -
- - -
- {#if cell.nullable} - - {/if} - - - {charCount.toLocaleString()} chars - - - - - -
-
-
-{/if} diff --git a/src/lib/components/ChartView.svelte b/src/lib/components/ChartView.svelte index 947073ad..cd8d8d5e 100644 --- a/src/lib/components/ChartView.svelte +++ b/src/lib/components/ChartView.svelte @@ -142,12 +142,12 @@ }) const numericCols = $derived(effectiveColumns.filter(c => colType(c) === 'number')) - const allCols = $derived(effectiveColumns.map(c => c.name)) + const allCols = $derived(effectiveColumns.map(c => c.name)) const requiredAxes = $derived(getRequiredAxes(chartType)) - let xCol = $state('') - let yCol = $state('') - let zCol = $state('') + let xCol = $state('') + let yCol = $state('') + let zCol = $state('') let groupCol = $state('') // Seed / repair the axis selections when the available columns change, but @@ -285,11 +285,11 @@ } // ── Save panel ──────────────────────────────────────────────────────────── - let saveOpen = $state(false) - let saveName = $state('') - let saveGroup = $state('Default') - let newGroupMode = $state(false) - let newGroupName = $state('') + let saveOpen = $state(false) + let saveName = $state('') + let saveGroup = $state('Default') + let newGroupMode = $state(false) + let newGroupName = $state('') function openSavePanel() { saveName = ''; saveGroup = $chartGroups[0] ?? 'Default' @@ -316,22 +316,22 @@ // token IS the Select trigger, so the border, hover and focus ring are drawn // once by the primitive instead of a wrapper and a nested control each // painting their own (which is what made the focused state look doubled). - const axisToken = 'group h-7 shrink-0 gap-0 overflow-hidden rounded-md border-border/40 bg-muted/20 p-0 pr-1.5 transition-colors hover:border-border/60 hover:bg-muted/30 [&_svg]:size-3 [&_svg]:text-muted-foreground/40' - const axisLabel = 'flex h-full shrink-0 select-none items-center border-r border-border/30 px-2 text-ui-2xs font-semibold uppercase tracking-widest text-muted-foreground/35' + const axisToken = 'group h-7 shrink-0 gap-0 overflow-hidden rounded-md border-border/40 bg-muted/20 p-0 pr-1.5 transition-colors hover:border-border/60 hover:bg-muted/30 [&_svg]:size-3 [&_svg]:text-muted-foreground' + const axisLabel = 'flex h-full shrink-0 select-none items-center border-r border-border/30 px-2 text-ui-2xs font-semibold uppercase tracking-widest text-muted-foreground' const axisValue = 'truncate px-2 font-mono text-ui-xs text-foreground' const axisContent = 'z-[120] max-h-[20rem] min-w-[11rem] p-1' // No radius here: Select.Item's own rounded-lg is derived from the panel's // radius minus its padding, and overriding it breaks that relationship. const axisItem = 'py-1.5 pl-2 font-mono text-ui-xs' - const iconBtn = 'inline-flex size-7 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground' + const iconBtn = 'inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground'
{#if !chartable}
- -

Need at least one numeric column to chart

+ +

Need at least one numeric column to chart

{:else} @@ -356,7 +356,7 @@ > {currentEntry?.label ?? 'Chart'} - + @@ -370,15 +370,15 @@ >
- + {#if pickerSearch} - {/if} @@ -387,11 +387,11 @@
{#if filteredByGroup.length === 0} -

No charts match "{pickerSearch}"

+

No charts match "{pickerSearch}"

{:else} {#each filteredByGroup as grp (grp.group)}
-

{grp.group}

+

{grp.group}

{#each grp.charts as t (t.id)} @@ -494,12 +494,12 @@ {#if saveOpen}
- Save as + Save as {#if !newGroupMode} @@ -507,7 +507,7 @@ {saveGroup} @@ -515,7 +515,7 @@ {#each $chartGroups as g (g)}{g}{/each} - {:else} @@ -523,9 +523,9 @@ type="text" placeholder="New group name…" bind:value={newGroupName} - class="h-6 w-36 rounded-lg border-2 border-border bg-background/80 px-2 font-mono text-ui-xs text-foreground outline-none placeholder:text-muted-foreground/40 focus:border-ring/55 focus:ring-2 focus:ring-ring/15" + class= "field-surface h-6 w-36 bg-background/80 px-2 font-mono text-ui-xs text-foreground outline-none placeholder:text-muted-foreground" /> - {/if} @@ -554,7 +554,7 @@ {#if rows.length === 0}
-

No data to display

+

No data to display

{:else if chartType === 'meter' && meterSpec} @@ -562,14 +562,14 @@ {/if} {#if sampled} -
+
sampled {MAX_CHART_ROWS.toLocaleString()} of {rows.length.toLocaleString()} rows
{/if} {#snippet failed(error, reset)}

This chart couldn't render

-

{error instanceof Error ? error.message : String(error)}

+

{error instanceof Error ? error.message : String(error)}

{/snippet} diff --git a/src/lib/components/ChartsPage.svelte b/src/lib/components/ChartsPage.svelte index 0c103b46..e953c0bb 100644 --- a/src/lib/components/ChartsPage.svelte +++ b/src/lib/components/ChartsPage.svelte @@ -176,7 +176,7 @@ } const btnIconSm = - "inline-flex size-6 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"; + "inline-flex size-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"; /** * Add chart directly to a specific dashboard (or create one if none exist). @@ -202,7 +202,7 @@ {#if !connection}
- +

Connect to a database to view saved charts

@@ -213,10 +213,10 @@ class="studio-chrome flex shrink-0 items-center gap-2 border-b border-border/50 bg-panel px-4 py-2" data-studio-chrome > - + Saved Charts {totalCharts} @@ -224,7 +224,7 @@ {#if !newGroupOpen} {/if} {/if} @@ -389,7 +389,7 @@
- +
{/if} @@ -404,7 +404,7 @@ title="Open SQL in editor" onclick={() => handleRunSql(chart)} > - + Run
@@ -420,7 +420,7 @@ : chart.id; }} > - + Dashboard {#if dashPickerChartId === chart.id} @@ -437,12 +437,12 @@ }} > {d.name} {#if d.id === $activeDashboardId} active {/if} @@ -451,7 +451,7 @@
{#if moveChartId === chart.id}
g !== chart.group).length === 0}

No other groups

@@ -529,7 +529,7 @@ : "Delete"} onclick={() => handleDeleteChart(chart.id)} > - +
@@ -554,7 +554,7 @@ type="button" class={cn(btnIconSm, "size-4 shrink-0")} onclick={commitRenameChart} - >
@@ -588,12 +588,12 @@ {#if grpData.charts.length === 0}
- +

{grpData.group}

0 {#if grpData.group !== "Default"} @@ -603,12 +603,12 @@ title="Delete empty group" onclick={() => handleDeleteGroup(grpData.group)} > - + {/if}
No charts in this group
diff --git a/src/lib/components/ChoroplethChart.svelte b/src/lib/components/ChoroplethChart.svelte index e710e1ef..8162f9db 100644 --- a/src/lib/components/ChoroplethChart.svelte +++ b/src/lib/components/ChoroplethChart.svelte @@ -133,9 +133,9 @@ } function buildOption() { - const fg = isDark ? '#e5e7eb' : '#111827' - const muted = isDark ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)' - const land = isDark ? '#1e2433' : '#d1d5db' + const fg = isDark ? '#e5e7eb' : '#111827' + const muted = isDark ? 'rgba(255,255,255,0.45)' : 'rgba(0,0,0,0.45)' + const land = isDark ? '#1e2433' : '#d1d5db' const border = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.12)' return { @@ -232,7 +232,7 @@ {#if !spec?.data?.length} -
No data
+
No data
{:else}
@@ -248,7 +248,7 @@ >Retry
{:else if loading} -
+
⟳ Loading map…
{/if} diff --git a/src/lib/components/CloudflareLogin.svelte b/src/lib/components/CloudflareLogin.svelte index 07857f5a..573de0ae 100644 --- a/src/lib/components/CloudflareLogin.svelte +++ b/src/lib/components/CloudflareLogin.svelte @@ -69,6 +69,35 @@ } }) + /** + * Nothing loads forever. + * + * The panel sat on "Loading your Cloudflare accounts…" indefinitely: the + * shared Cloudflare HTTP client had no timeout (reqwest has no default), so a + * stalled request left the Tauri command awaiting and this `await` never + * returned - no error, no dropdown, no way back. The client is bounded now; + * this is the backstop for everything else on that path, the keychain read + * included. + * @template T + * @param {Promise} work + * @param {number} ms + * @param {string} what + * @returns {Promise} + */ + function withTimeout(work, ms, what) { + /** @type {ReturnType} */ + let timer + return Promise.race([ + work, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`Timed out ${what}. Check your connection and try again.`)), + ms, + ) + }), + ]).finally(() => clearTimeout(timer)) + } + /** Turn a raw backend error into a calm title + one-line explanation. */ function friendlyError(msg) { const m = String(msg ?? '') @@ -110,10 +139,16 @@ } async function loadAccounts() { + phase = 'fetching' + errorMsg = '' try { const { cfGetValidToken } = await import('$lib/cloudflare.js') - const token = await cfGetValidToken() - accounts = await cloudflareListAccounts(token) + const token = await withTimeout(cfGetValidToken(), 20_000, 'reading your Cloudflare session') + accounts = await withTimeout( + cloudflareListAccounts(token), + 20_000, + 'listing your Cloudflare accounts', + ) phase = 'selecting' // Auto-select an account so the D1 database list loads immediately; the // user can still switch accounts via the dropdown when there are several. @@ -137,8 +172,12 @@ loadingDbs = true try { const { cfGetValidToken } = await import('$lib/cloudflare.js') - const token = await cfGetValidToken() - databases = await cloudflareListD1Databases(token, id) + const token = await withTimeout(cfGetValidToken(), 20_000, 'reading your Cloudflare session') + databases = await withTimeout( + cloudflareListD1Databases(token, id), + 20_000, + 'listing D1 databases for this account', + ) } catch (e) { // Show the error card - staying in 'selecting' rendered a misleading // "No D1 databases in this account" empty state over a real failure. @@ -221,13 +260,30 @@ {:else if phase === 'error'} - + + {@const signedIn = !!email} + {#snippet mark()}{/snippet} {#snippet action()} - +
+ + {#if signedIn} + + {/if} +
{/snippet}
@@ -243,13 +299,13 @@ Connected

{#if email} -

{email}

+

{email}

{/if}
{/snippet} {#snippet item(it)} - + {it.label} {#if it.value === selectedAccountId}{/if} {/snippet}
{:else if accounts.length === 1} -

Account · {accounts[0].name}

+

Account · {accounts[0].name}

{/if} {#if selectedAccountId}
- D1 Database + D1 Database {#if databases.length > 0} - + {selectedDbName || '- select database -'} @@ -318,7 +374,7 @@ {/snippet} {#snippet item(it)} - + {it.label} {#if it.value === selectedDbUuid}{/if} {/snippet} @@ -326,17 +382,17 @@ {:else if loadingDbs} -
+
Loading databases…
{:else}
- -

No D1 databases in this account.

+ +

No D1 databases in this account.

- / + / {pageLabel[page]}
{/if} @@ -592,7 +623,7 @@ one exchange at a time, so answers and their result tables get clipped. --> {#if page !== 'ask'} - No results. + No results. {/if} @@ -615,7 +646,7 @@
{:else if turn.role === 'tool'}
-
+
{#if turn.status === 'running'} {:else if turn.status === 'error'} {:else}{/if} @@ -628,7 +659,7 @@ {#each turn.result.columns.slice(0, 5) as c} - {c} + {c} {/each} @@ -636,7 +667,7 @@ {#each turn.result.rows as row, ri} {#each row.slice(0, 5) as cell} - {cell === null ? 'NULL' : String(cell)} + {cell === null ? 'NULL' : String(cell)} {/each} {/each} @@ -644,7 +675,7 @@
{#if turn.result.total > turn.result.rows.length} -
showing {turn.result.rows.length} of {turn.result.total} rows
+
showing {turn.result.rows.length} of {turn.result.total} rows
{/if}
{/if} @@ -652,7 +683,7 @@ {:else}
{#if turn.streaming && !turn.text} -
Thinking…
+
Thinking…
{:else} {/if} @@ -682,7 +713,7 @@ {/if}
-
Type below and press ↵ to follow up
+
Type below and press ↵ to follow up
{/if} @@ -692,7 +723,7 @@ startAsk(paletteSearch)}> - Ask AI: "{paletteSearch}" + Ask AI: "{paletteSearch}" @@ -739,7 +770,7 @@ navigate('tables')}> All {tables.length} tables & views… - + {/if} @@ -870,7 +901,7 @@ {#if savedConnections.length > 0} {savedConnections.length} {/if} - + @@ -879,7 +910,7 @@ navigate('docker')}> Docker - + @@ -899,7 +930,7 @@ {/each} {#if tablesPageRegular.total > tablesPageRegular.items.length} -
+
Showing {tablesPageRegular.items.length} of {tablesPageRegular.total}, keep typing to narrow.
{/if} @@ -914,7 +945,7 @@ {/each} {#if tablesPageViews.total > tablesPageViews.items.length} -
+
Showing {tablesPageViews.items.length} of {tablesPageViews.total}, keep typing to narrow.
{/if} @@ -933,7 +964,7 @@ {/each} {#if tablesPageMatViews.total > tablesPageMatViews.items.length} -
+
Showing {tablesPageMatViews.items.length} of {tablesPageMatViews.total}, keep typing to narrow.
{/if} diff --git a/src/lib/components/ConfirmDialog.svelte b/src/lib/components/ConfirmDialog.svelte index 68d4af7a..b5325058 100644 --- a/src/lib/components/ConfirmDialog.svelte +++ b/src/lib/components/ConfirmDialog.svelte @@ -9,6 +9,7 @@ */ import Icon from './Icon.svelte' import * as Dialog from '$lib/components/ui/dialog/index.js' + import { Button } from '$lib/components/ui/button/index.js' import { cn } from '$lib/utils.js' let { @@ -99,24 +100,24 @@ showCloseButton={false} {onkeydown} {onOpenAutoFocus} - class="w-[min(440px,calc(100vw-2rem))] sm:max-w-none gap-0 overflow-hidden p-0" + class="w-[min(32.5rem,calc(100vw-2rem))] sm:max-w-none gap-0 overflow-hidden p-0" >
{title} {#if body} -
{@render body()}
+
{@render body()}
{:else if description} -

{description}

+

{description}

{/if} {#if note} -

{note}

+

{note}

{/if}
@@ -126,32 +127,26 @@ {/if}
- - + +
diff --git a/src/lib/components/ConnectionModal.svelte b/src/lib/components/ConnectionModal.svelte index 20ee5a79..36527229 100644 --- a/src/lib/components/ConnectionModal.svelte +++ b/src/lib/components/ConnectionModal.svelte @@ -1,5 +1,5 @@ diff --git a/src/lib/components/CreateDatabaseDialog.svelte b/src/lib/components/CreateDatabaseDialog.svelte index f6895272..7a3c5796 100644 --- a/src/lib/components/CreateDatabaseDialog.svelte +++ b/src/lib/components/CreateDatabaseDialog.svelte @@ -106,23 +106,23 @@ } } - const inputCls = "h-8 w-full rounded-lg border-2 border-border bg-background px-3 text-ui-xs outline-none placeholder:text-muted-foreground/40 focus:border-ring/55 focus:ring-2 focus:ring-ring/15 disabled:opacity-50" - const selectCls = "h-8 w-full rounded-md border-2 border-border bg-background px-2.5 text-ui-xs outline-none focus:border-ring/55 focus:ring-2 focus:ring-ring/15 disabled:opacity-50" + const inputCls = "field-surface h-8 w-full bg-background px-3 text-ui-xs outline-none placeholder:text-muted-foreground disabled:opacity-50" + const selectCls = "field-surface h-8 w-full bg-background px-2.5 text-ui-xs outline-none disabled:opacity-50" const labelCls = "mb-1.5 block text-ui-xs font-medium text-muted-foreground" - +
-
- Create Database -

- {isMySQL ? 'MySQL' : 'PostgreSQL'} · new database on this server +

+ Create database +

+ {isMySQL ? 'MySQL' : 'PostgreSQL'} · on this server

@@ -133,7 +133,9 @@
-

Letters, digits, underscores · max 63 chars

+

+ Letters, digits, underscores · max 63 characters +

{#if showAdvanced} -
+
{#if !isMySQL} @@ -246,7 +254,7 @@
{ @@ -269,7 +277,7 @@
{/each}
@@ -144,14 +144,14 @@

Cycle

-

Restart from minimum when max is reached

+

Restart from minimum when max is reached

SQL preview

-
{sql}
+
{sql}
@@ -160,7 +160,7 @@
{/each}
@@ -140,12 +140,12 @@
@@ -160,7 +160,7 @@ {/each}
@@ -183,7 +183,7 @@

SQL preview

-
{sql}
+
{sql}
@@ -192,7 +192,7 @@
@@ -309,7 +309,7 @@ { e.stopPropagation(); deleteDashboard(d.id); }} onkeydown={(e) => { if (e.key === "Enter") { e.stopPropagation(); deleteDashboard(d.id); } }} > @@ -326,7 +326,7 @@ type="text" bind:value={nameInput} placeholder="Dashboard name…" - class="h-7 flex-1 rounded-md border-2 border-border bg-background px-2 font-mono text-ui-xs outline-none focus:border-ring/55 focus:ring-2 focus:ring-ring/15" + class= "field-surface h-7 flex-1 bg-background px-2 font-mono text-ui-xs outline-none" onkeydown={(e) => { if (e.key === "Enter") handleCreateDashboard(); if (e.key === "Escape") namingOpen = false; @@ -339,7 +339,7 @@ {:else} @@ -536,7 +536,7 @@
{:else} -
+
No preview
@@ -579,13 +579,13 @@
Add chart to dashboard -
{#if $savedCharts.length === 0} -
+
No saved charts yet. Save a chart from the Query Editor first.
{:else} @@ -604,14 +604,14 @@
{:else} -
+
{/if}

{chart.name}

-

{chart.group}

+

{chart.group}

{/each} diff --git a/src/lib/components/DataDiffPage.svelte b/src/lib/components/DataDiffPage.svelte index c0ff3a69..e87bfd5a 100644 --- a/src/lib/components/DataDiffPage.svelte +++ b/src/lib/components/DataDiffPage.svelte @@ -1,6 +1,7 @@ - +
- +
{isRename ? 'Rename database' : 'Duplicate database'} -

- {isRename ? 'Renames' : 'Copies'} {source} +

+ {isRename ? 'Renames' : 'Copies'} {source} {isRename ? 'on this server' : 'with all of its data'}

- +
@@ -129,12 +129,12 @@ {#if nameError}

{nameError}

{:else if trimmed && trimmed === source} -

That is already its name

+

That is already its name

{/if}
{#if !isRename} -

+

The copy is taken server-side, so a large database takes a while and needs the disk space twice over.

{/if} @@ -143,7 +143,7 @@

SQL preview

-
{sql}
+
{sql}
{#if error} @@ -153,7 +153,7 @@
- + {label} @@ -137,13 +137,13 @@ {/each}
- In the last + In the last
(lastN = Math.max(1, parseInt(e.currentTarget.value.replace(/\D/g, "")) || 1))} diff --git a/src/lib/components/DateFilterPicker.svelte b/src/lib/components/DateFilterPicker.svelte index 2abd578a..2b5e37ce 100644 --- a/src/lib/components/DateFilterPicker.svelte +++ b/src/lib/components/DateFilterPicker.svelte @@ -27,8 +27,8 @@ function pick(d) { if (!d) return; const yyyy = String(d.year).padStart(4, "0"); - const mm = String(d.month).padStart(2, "0"); - const dd = String(d.day).padStart(2, "0"); + const mm = String(d.month).padStart(2, "0"); + const dd = String(d.day).padStart(2, "0"); onchange(`${yyyy}-${mm}-${dd}`); open = false; } @@ -37,12 +37,12 @@ - + {label} diff --git a/src/lib/components/DateRangePicker.svelte b/src/lib/components/DateRangePicker.svelte index caaaf276..9961318a 100644 --- a/src/lib/components/DateRangePicker.svelte +++ b/src/lib/components/DateRangePicker.svelte @@ -41,7 +41,7 @@ function handleRangeChange(range) { if (!range) return; const newFrom = fmt(range.start); - const newTo = fmt(range.end); + const newTo = fmt(range.end); onchange(newFrom, newTo); if (newFrom && newTo) open = false; } @@ -50,11 +50,11 @@ - + {label} diff --git a/src/lib/components/DateTimePicker.svelte b/src/lib/components/DateTimePicker.svelte index fed8eb5c..a7d524ff 100644 --- a/src/lib/components/DateTimePicker.svelte +++ b/src/lib/components/DateTimePicker.svelte @@ -9,21 +9,53 @@ * @property {boolean} [disabled] * @property {string} [colName] * @property {() => void} [onfocus] + * @property {(e: KeyboardEvent) => void} [onkeydown] + * @property {HTMLInputElement | null} [inputRef] + * @property {boolean} [iconTrailing] */ import { Popover, PopoverTrigger, PopoverContent } from "$lib/components/ui/popover/index.js"; import { Calendar } from "$lib/components/ui/calendar/index.js"; import { CalendarDate, parseDate, toCalendarDate } from "@internationalized/date"; + import { untrack } from "svelte"; + import Check from "@lucide/svelte/icons/check"; import CalendarIcon from "@lucide/svelte/icons/calendar"; import { cn } from "$lib/utils.js"; let { value = $bindable(""), + /** A committed value: a calendar pick, a time change, or "now". */ onchange, + /** + * A keystroke in the text field. Split from `onchange` because a caller may + * want to COMMIT on a pick but only stage on typing - the grid's inline cell + * editor does exactly that, and while both went through `onchange` it + * committed after the first character and tore its own editor down, so the + * field accepted exactly one keypress. Defaults to `onchange` so callers + * that genuinely want both are unaffected. + */ + oninput = undefined, + /** + * Keys the picker does not claim itself (Enter, Tab, Ctrl+Backspace) go + * back to the caller, so an inline grid editor keeps its own commit and + * cancel keys while the field still owns ArrowDown and Escape. + */ + onkeydown: onKeyDown = undefined, + /** The text field itself, for a caller that focuses or selects it. */ + inputRef = $bindable(null), + /** + * Cell-editor trim: the calendar button sits after the value rather than + * before it, so the text stays on the x the canvas drew it at. + */ + iconTrailing = false, + class: className = "", + inputClass = "", + inputStyle = "", showTime = true, disabled = false, colName, onfocus, } = $props(); + const onType = $derived(oninput ?? onchange); let open = $state(false); @@ -38,59 +70,158 @@ return "iso"; }); - // Parse the current string value into date parts - const parsed = $derived.by(() => { + /** + * The fields of a timestamp, read straight out of its text. + * + * Deliberately textual rather than via `new Date`. A database timestamp is a + * wall clock plus, sometimes, an offset - and the user is editing those + * digits, not an instant. Going through a local `Date` rendered + * `2026-05-22 11:28:40.501 UTC` in the machine's own zone and dropped the + * offset, so every edit moved the value. It also could not parse that string + * at all: the old code appended `T00:00:00` to anything without a `T`, making + * `…501 UTCT00:00:00`, an Invalid Date - which is why the calendar said + * "Pick a date…" and the hour and minute boxes both read 00 for a row that + * plainly held a time. + * + * `second`, `fraction` and `zone` are carried untouched so a date pick cannot + * spend precision the picker never showed. + */ + const ISO_PARTS_RE = + /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?(\.\d+)?\s*(Z|UTC|GMT|[+-]\d{2}(?::?\d{2})?)?)?$/i; + + const parts = $derived.by(() => { const s = String(value ?? "").trim(); if (!s) return null; - try { - // Epoch numbers are not parseable as `s + "T00:00:00"` - that produced an - // Invalid Date, so the calendar rendered "Pick a date…" for a row that - // plainly held a timestamp, and picking a date overwrote it with ISO text. - if (valueFormat === "epoch-ms" || valueFormat === "epoch-s") { - const n = Number(s); - if (!Number.isFinite(n)) return null; - const d = new Date(valueFormat === "epoch-s" ? n * 1000 : n); - return isNaN(d.getTime()) ? null : d; - } - const d = new Date(s.includes("T") ? s : s + "T00:00:00"); + + // An epoch is a true instant, so it does go through a Date - in local time, + // which is the zone its hour and minute boxes are showing. + if (valueFormat === "epoch-ms" || valueFormat === "epoch-s") { + const n = Number(s); + if (!Number.isFinite(n)) return null; + const d = new Date(valueFormat === "epoch-s" ? n * 1000 : n); if (isNaN(d.getTime())) return null; - return d; - } catch { - return null; + return { + year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate(), + hour: d.getHours(), minute: d.getMinutes(), second: d.getSeconds(), + fraction: "", zone: "", + }; } + + const m = ISO_PARTS_RE.exec(s); + if (!m) return null; + return { + year: Number(m[1]), month: Number(m[2]), day: Number(m[3]), + hour: Number(m[4] ?? 0), minute: Number(m[5] ?? 0), second: Number(m[6] ?? 0), + fraction: m[7] ?? "", + zone: m[8] ?? "", + }; }); const calValue = $derived.by(() => { - if (!parsed) return undefined; + if (!parts) return undefined; try { - return new CalendarDate(parsed.getFullYear(), parsed.getMonth() + 1, parsed.getDate()); + return new CalendarDate(parts.year, parts.month, parts.day); } catch { return undefined; } }); - const timeStr = $derived.by(() => { - if (!parsed) return "00:00"; - const hh = String(parsed.getHours()).padStart(2, "0"); - const mm = String(parsed.getMinutes()).padStart(2, "0"); - return `${hh}:${mm}`; - }); + const timeStr = $derived( + parts + ? `${String(parts.hour).padStart(2, "0")}:${String(parts.minute).padStart(2, "0")}:${String(parts.second ?? 0).padStart(2, "0")}` + : "00:00:00", + ); - const hourVal = $derived(parsed ? parsed.getHours() : 0); - const minVal = $derived(parsed ? parsed.getMinutes() : 0); + const hourVal = $derived(parts ? parts.hour : 0); + const minVal = $derived(parts ? parts.minute : 0); + const secVal = $derived(parts ? (parts.second ?? 0) : 0); - function handleHourChange(e) { - const h = Math.max(0, Math.min(23, parseInt(e.currentTarget.value) || 0)); - const base = calValue ?? new CalendarDate(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()); + /** + * The hour and minute boxes hold their OWN text while the popover is open. + * + * They used to render `value={hourVal}`, a number derived from the staged + * value, so every keystroke round-tripped: typing `1` staged `01:09`, which + * re-derived `hourVal` to 1 and rewrote the field - and typing the `2` of + * `12` fought whatever the derive had just put there. Two digits could not be + * entered. The local strings are the field's truth while you are in it; the + * derived values re-seed them when the popover opens, or when the value + * changes from outside while the boxes are not focused. + * @type {{ h: string, m: string, s: string }} + */ + let timeText = $state({ h: "", m: "", s: "" }); + let timeFocused = $state(false); + + $effect(() => { + const h = String(hourVal).padStart(2, "0"); const m = String(minVal).padStart(2, "0"); - onchange(buildIsoString(base, `${String(h).padStart(2, "0")}:${m}`)); - } + const sec = String(secVal).padStart(2, "0"); + if (!open) { timeText = { h, m, s: sec }; return; } + if (timeFocused) return; + untrack(() => { timeText = { h, m, s: sec }; }); + }); - function handleMinChange(e) { - const m = Math.max(0, Math.min(59, parseInt(e.currentTarget.value) || 0)); + /** The value as the popover currently has it staged. */ + function stagedValue() { const base = calValue ?? new CalendarDate(new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()); - const h = String(hourVal).padStart(2, "0"); - onchange(buildIsoString(base, `${h}:${String(m).padStart(2, "0")}`)); + const h = Math.max(0, Math.min(23, parseInt(timeText.h, 10) || 0)); + const m = Math.max(0, Math.min(59, parseInt(timeText.m, 10) || 0)); + const sec = Math.max(0, Math.min(59, parseInt(timeText.s, 10) || 0)); + const pad = (/** @type {number} */ n) => String(n).padStart(2, "0"); + return buildIsoString(base, `${pad(h)}:${pad(m)}:${pad(sec)}`); + } + + /** Stage what the popover shows, without ending the edit. */ + function stage() { + onType(stagedValue()); + } + + /** Commit it and close - Enter, or the confirm button. */ + function confirm() { + onchange(stagedValue()); + open = false; + } + + /** + * Typing a time stages it and nothing else. + * + * Nothing in this popover commits on a keystroke any more. It did, through + * `onchange`, and for the grid's inline cell editor `onchange` means COMMIT: + * the first digit of an hour wrote the cell, tore the editor down, closed the + * popover and took the focus with it. Enter and the confirm button are the + * only ways out with a value. + * @param {'h' | 'm'} part + */ + function onTimeInput(part, /** @type {Event} */ e) { + const raw = /** @type {HTMLInputElement} */ (e.currentTarget).value.replace(/\D/g, "").slice(0, 2); + timeText = { ...timeText, [part]: raw }; + stage(); + } + + /** Field metadata, so the three boxes are a loop rather than three copies. */ + const TIME_FIELDS = /** @type {const} */ ([ + { part: "h", max: 23, label: "Hour", placeholder: "HH" }, + { part: "m", max: 59, label: "Minute", placeholder: "MM" }, + { part: "s", max: 59, label: "Second", placeholder: "SS" }, + ]); + + /** @param {KeyboardEvent} e */ + function onTimeKeydown(e) { + if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); confirm(); return; } + // Escape closes the popover only. What was typed is already staged in the + // field behind it, and the caller's Escape would cancel the whole edit. + if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); open = false; return; } + // Up/down step the field, as they do in every time input. + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault(); + const part = /** @type {'h' | 'm' | 's'} */ ( + /** @type {HTMLInputElement} */ (e.currentTarget).dataset.part ?? "h" + ); + const max = part === "h" ? 23 : 59; + const cur = parseInt(timeText[part], 10) || 0; + const next = (cur + (e.key === "ArrowUp" ? 1 : -1) + max + 1) % (max + 1); + timeText = { ...timeText, [part]: String(next).padStart(2, "0") }; + stage(); + } } function setNow() { @@ -98,13 +229,16 @@ const d = new CalendarDate(now.getFullYear(), now.getMonth() + 1, now.getDate()); const h = String(now.getHours()).padStart(2, "0"); const m = String(now.getMinutes()).padStart(2, "0"); - onchange(buildIsoString(d, `${h}:${m}`)); + timeText = { h, m }; + // Staged, like every other control in here: "Now" is a value, not a decision + // to stop editing. + onType(buildIsoString(d, `${h}:${m}`)); } const displayLabel = $derived.by(() => { - if (!parsed) return "Pick a date…"; - const date = parsed.toLocaleDateString("en-CA"); // YYYY-MM-DD - return showTime ? `${date} ${timeStr}` : date; + if (!parts) return "Pick a date…"; + const date = `${String(parts.year).padStart(4, "0")}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`; + return showTime ? `${date} ${timeStr}${parts.zone ? ` ${parts.zone}` : ""}` : date; }); /** @@ -117,27 +251,43 @@ const yyyy = String(dateVal.year).padStart(4, "0"); const mm = String(dateVal.month).padStart(2, "0"); const dd = String(dateVal.day).padStart(2, "0"); - const [hh, mi] = (time || "00:00").split(":").map((s) => s.padStart(2, "0")); + const [hh, mi, se] = (time || "00:00:00").split(":").map((s) => s.padStart(2, "0")); + // The picker edits seconds now, so an absent third part means "zero", not + // "keep whatever was stored". + const secs = se ?? String(parts?.second ?? 0).padStart(2, "0"); if (valueFormat === "epoch-ms" || valueFormat === "epoch-s") { - // An epoch always carries a time component, so honour hh:mm even when the - // picker is in date-only mode. Seconds/millis are carried over from the - // stored value rather than zeroed - the picker only edits down to the - // minute, so it must not quietly drop precision it never showed. + // An epoch always carries a time component, so honour the time even when + // the picker is in date-only mode. Fractional seconds are carried over + // from the stored value rather than zeroed: the picker edits down to the + // second, so it must not quietly drop precision it never showed. + const fractionMs = parts?.fraction ? Math.round(Number(parts.fraction) * 1000) : 0; const ms = new Date( Number(yyyy), Number(mm) - 1, Number(dd), Number(hh), Number(mi), - parsed?.getSeconds() ?? 0, parsed?.getMilliseconds() ?? 0, + Number(secs), fractionMs, ).getTime(); return String(valueFormat === "epoch-s" ? Math.floor(ms / 1000) : ms); } if (!showTime) return `${yyyy}-${mm}-${dd}`; - return `${yyyy}-${mm}-${dd}T${hh}:${mi}`; + // Fractional seconds and the offset ride along untouched, for the same + // reason the epoch branch carries them: picking a date must change the date, + // not the column's sub-second precision or its zone. + const ss = secs; + return `${yyyy}-${mm}-${dd}T${hh}:${mi}:${ss}${parts?.fraction ?? ""}${parts?.zone ?? ""}`; } function handleCalendarChange(newDate) { if (!newDate) return; - const iso = buildIsoString(newDate, timeStr); - onchange(iso); - if (!showTime) open = false; + // A date-only column has nothing else to set, so the pick IS the decision: + // commit and close. With a time to set as well, the pick only stages - the + // popover stays open for the hour and minute, and Enter or the tick ends it. + if (!showTime) { + onchange(buildIsoString(newDate, timeStr)); + open = false; + return; + } + const h = timeText.h || String(hourVal).padStart(2, "0"); + const m = timeText.m || String(minVal).padStart(2, "0"); + onType(buildIsoString(newDate, `${h}:${m}`)); } @@ -149,83 +299,120 @@ for "some Tuesday in March" - the field does both, and the value is the column's own text either way, so an epoch column stays editable as digits rather than being hidden behind a formatted label. --> -
+
- + + + { + if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); confirm(); return } + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); open = false } + }} + > {#if showTime} -
-
- Time - -
-
-
+ +
+
+ {#each TIME_FIELDS as f, i (f.part)} + {#if i > 0}:{/if} + onTimeInput(f.part, e)} + onfocusin={() => (timeFocused = true)} + onfocusout={() => (timeFocused = false)} + onkeydown={onTimeKeydown} + class="no-focus-ring h-7 w-9 rounded-md border border-border/50 bg-input/40 px-1 text-center font-mono text-ui-2xs tabular-nums text-foreground outline-none transition-colors hover:border-border focus:border-ring/60" + placeholder={f.placeholder} /> -
- : -
- -
+ {/each}
+ + + + +
{/if} diff --git a/src/lib/components/DbIcon.svelte b/src/lib/components/DbIcon.svelte index 5dda4ac5..809f6a08 100644 --- a/src/lib/components/DbIcon.svelte +++ b/src/lib/components/DbIcon.svelte @@ -14,11 +14,16 @@ } = $props() const resolvedId = $derived(MARK_ALIASES[id] ?? id) - const path = $derived(BRAND_MARKS[resolvedId]?.d ?? FALLBACK_MARK) + const mark = $derived(BRAND_MARKS[resolvedId]) + const path = $derived(mark?.d ?? FALLBACK_MARK) + // Marks are 24-grid unless they say otherwise: a logo taken from its vendor + // (MySQL's dolphin is authored at 128) keeps its own viewBox rather than being + // re-plotted, and `size-*` on the element does the scaling either way. + const viewBox = $derived(mark?.vb ?? '0 0 24 24')
-

SQL preview

+

SQL preview

{#each sqlLines as line, i}
diff --git a/src/lib/components/DiagramsPage.svelte b/src/lib/components/DiagramsPage.svelte index eaf1aedc..b2cace95 100644 --- a/src/lib/components/DiagramsPage.svelte +++ b/src/lib/components/DiagramsPage.svelte @@ -20,6 +20,7 @@ import Check from '@lucide/svelte/icons/check' import Search from '@lucide/svelte/icons/search' import { cn } from '$lib/utils.js' + import { focusTrap } from '$lib/actions/focus-trap.js' import { toast } from '$lib/components/ui/sonner/toast.svelte.js' /** @@ -199,12 +200,12 @@
- +
@@ -213,14 +214,14 @@
{#if filtered.length === 0}
- -

+ +

{searchQ ? 'No results' : 'No diagrams yet'}

{:else} {#each Object.entries(grouped) as [group, items]} -

+

{group}

{#each items as d (d.id)} @@ -246,7 +247,7 @@
-

+

{all.length} diagram{all.length === 1 ? '' : 's'}

@@ -261,13 +262,13 @@ type="text" bind:value={draftName} placeholder="Diagram name…" - class="h-7 min-w-0 flex-1 rounded-lg border-2 border-border bg-background/50 px-2.5 text-ui-sm text-foreground placeholder:text-muted-foreground/40 focus:border-ring/55 focus:ring-2 focus:ring-ring/15 focus:outline-none" + class= "field-surface h-7 min-w-0 flex-1 bg-background/50 px-2.5 text-ui-sm text-foreground placeholder:text-muted-foreground focus:outline-none" />
{/each} -

+

Requires Docker Desktop to be installed and running

diff --git a/src/lib/components/DropDatabaseDialog.svelte b/src/lib/components/DropDatabaseDialog.svelte index 3e6b471e..19e6fafb 100644 --- a/src/lib/components/DropDatabaseDialog.svelte +++ b/src/lib/components/DropDatabaseDialog.svelte @@ -34,7 +34,7 @@ {#snippet extra()}
-