diff --git a/.changeset/canvas-render-motion-reconnect.md b/.changeset/canvas-render-motion-reconnect.md new file mode 100644 index 00000000..9bd724d3 --- /dev/null +++ b/.changeset/canvas-render-motion-reconnect.md @@ -0,0 +1,21 @@ +### Bug Fixes + +#### Data Table +- **Grid no longer renders blank** — on macOS WebKit the canvas colour reader returned a stale computed colour, collapsing every theme token to one value so cell text and grid lines vanished (only badges/icons showed). Each colour is now resolved on its own node. +- **Grid no longer blacks out when switching tabs** — the cached 2D context could keep pointing at a canvas WebKit had recreated, so draws landed on an off-screen element. The context now always tracks the live canvas and repaints on mount/tab switch. + +#### Schema Visualizer +- **Opens readable instead of a tiny sliver** — a deep schema no longer shrinks to an unreadable horizontal band; it lands at a legible zoom at the top-left of the graph (pan to explore), with roomier, larger table cards and more spacing. + +#### Interface +- **Spinners and loaders animate at normal speed** — a global reduced-motion rule was forcing every animation to 0.12s, so spinners/pulses ran frantically fast when macOS "Reduce Motion" was on. Removed the global motion overrides. + +### New Features + +#### Connection +- **Silent auto-reconnect** — a dropped database connection (network blip, sleep/wake, idle timeout) now reconnects automatically when the network returns, the window refocuses, or you switch tables / refresh, showing only a subtle status-bar indicator instead of a "Connection lost" popup. + +### Changes + +#### Performance +- **Smoother scrolling and lower memory on large tables** — removed per-frame allocations in the grid render loop, decoupled selection/hover repaints from canvas resizing, and avoid allocating a per-page row-offset array on million-row tables. diff --git a/src/app.css b/src/app.css index 9553809c..73e4afea 100644 --- a/src/app.css +++ b/src/app.css @@ -21,13 +21,6 @@ "editor font" and everything else. JetBrains Mono is a true code typeface: heavier, even rhythm, designed ligatures. */ --editor-font-family: "JetBrains Mono Variable", "Geist Mono Variable", ui-monospace, monospace; - /* Motion tokens — stronger-than-default curves (Emil Kowalski). These override - Tailwind's built-in --ease-out / --ease-in-out so the `ease-out` / `ease-in-out` - utilities (and tw-animate-css enter/exit keyframes, which read --tw-ease) all - inherit the punchier curves. Only transform/opacity should ride these. */ - --ease-out: cubic-bezier(0.23, 1, 0.32, 1); - --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); - --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); } /* JSON colorizer tokens (highlightJson) — dark-friendly defaults, light override @@ -107,28 +100,6 @@ html[data-os="linux"] { --window-radius: 8px; } @apply border-border outline-ring/50; } - /* Reduced motion — reduced, not zero (Emil / WCAG). Keep opacity + colour - fades that aid comprehension; strip movement. Neutralising tw-animate-css's - enter/exit transform vars makes overlays fade in place instead of - sliding/zooming, and clamping durations keeps any remaining transition brief. */ - @media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - --tw-enter-translate-x: 0 !important; - --tw-enter-translate-y: 0 !important; - --tw-exit-translate-x: 0 !important; - --tw-exit-translate-y: 0 !important; - --tw-enter-scale: 1 !important; - --tw-exit-scale: 1 !important; - --tw-enter-rotate: 0 !important; - --tw-exit-rotate: 0 !important; - animation-duration: 0.12s !important; - transition-duration: 0.12s !important; - scroll-behavior: auto !important; - } - } - /* Disable OS word suggestions, autocorrect, and spellcheck on all inputs. */ input:not([type="file"]), textarea { diff --git a/src/lib/canvas-table.js b/src/lib/canvas-table.js index 8089848d..37a9d033 100644 --- a/src/lib/canvas-table.js +++ b/src/lib/canvas-table.js @@ -17,11 +17,28 @@ // once per draw via a hidden probe element. Values are cached by expression for // the lifetime of the reader (call `createColorReader` again after a theme flip // — the component recreates it on a theme-change tick). +// +// IMPORTANT: each colour is resolved on a FRESH element rather than by mutating +// one shared probe's `style.color`. macOS WKWebView (Tauri's engine) returns a +// STALE `getComputedStyle(el).color` when `el.style.color` is reassigned to a +// different `var(--…)` between synchronous reads — every lookup after the first +// froze at the first colour's value, so the grid painted every token the same +// colour (text/grid lines vanished, only rgba-literal badges survived). Reading +// each token off a throwaway node sidesteps the stale-recompute bug entirely. + +// Neutral mid-grey fallback. Deliberately NOT black: this value can fill the +// entire canvas (the --panel background), so on the rare frame where a token +// fails to resolve, a grey flash is recoverable whereas opaque black is not. +const COLOR_FALLBACK = "rgb(128, 128, 128)"; /** @param {HTMLElement} probe A 0×0 element living inside the table container. */ export function createColorReader(probe) { /** @type {Map} */ const cache = new Map(); + // Last successfully-resolved colour, reused if a later read comes back empty + // (e.g. a getComputedStyle against a momentarily-detached subtree) so we never + // paint an unresolved fallback when we already know a good value. + let lastGood = ""; // A scratch 2D context normalises whatever the engine serialises (oklch / // oklab / rgb / hex) into a canvas-native string (#rrggbb or rgba(...)), so // downstream alpha math is always reliable. @@ -29,9 +46,22 @@ export function createColorReader(probe) { return (/** @type {string} */ expr) => { const hit = cache.get(expr); if (hit !== undefined) return hit; - probe.style.color = expr; + // Append the throwaway node to a GUARANTEED-connected host — a detached node + // yields an empty computed colour in WebKit, which would poison the fill. + // Theme tokens live on , so inherits them just as the probe does. + const host = probe.isConnected ? (probe.parentNode ?? document.body) : document.body; + const el = document.createElement("span"); + el.setAttribute("aria-hidden", "true"); + el.className = probe.className; + el.style.cssText = "position:absolute;top:0;left:0;width:0;height:0;overflow:hidden;pointer-events:none"; + el.style.color = expr; + host.appendChild(el); // getComputedStyle resolves var()/oklch/hsl to a concrete computed colour. - let resolved = getComputedStyle(probe).color || "rgb(0,0,0)"; + let resolved = getComputedStyle(el).color; + el.remove(); + // Unresolved (empty) → reuse the last good colour, else a neutral grey. Do + // NOT cache this: a later read (once the DOM settles) should resolve properly. + if (!resolved) return lastGood || COLOR_FALLBACK; if (scratch) { try { scratch.fillStyle = "#010203"; // sentinel @@ -42,6 +72,7 @@ export function createColorReader(probe) { /* keep the computed string */ } } + lastGood = resolved; cache.set(expr, resolved); return resolved; }; @@ -350,11 +381,19 @@ export function resizeColAtX(x, geom, scrollLeft, slop = 5, frozenLeft = geom.gu * Cumulative top offset of every row. Fixed `rowHeight` rows, plus expand height * for each expanded row. Per-row measured heights are used when available; * `defaultExpandHeight` is the fallback before measurement. + * + * Returns `null` in the common case where NO row is expanded — every row top is + * then exactly `idx * rowHeight`, so the O(1) linear path in the consumers + * (rowDocTop / rowIndexAtY / rowAtContentY) applies and we skip allocating a + * Float64Array over all rows. On a 1M-row result set that array is ~8 MB and was + * being reallocated on every infinite-scroll page append — this avoids it + * entirely unless the user actually expands a row. * @param {Set} expandedSet * @param {Map | null} expandHeights Per-row measured heights (optional). - * @returns {Float64Array} length rowCount+1; [rowCount] is the total height. + * @returns {Float64Array | null} length rowCount+1 ([rowCount] = total height), or null when unexpanded. */ export function computeRowTops(rowCount, expandedSet, defaultExpandHeight, rowHeight, expandHeights = null) { + if (expandedSet.size === 0) return null; const tops = new Float64Array(rowCount + 1); let y = 0; for (let i = 0; i < rowCount; i++) { @@ -365,9 +404,14 @@ export function computeRowTops(rowCount, expandedSet, defaultExpandHeight, rowHe return tops; } -/** Largest row index with top ≤ y (binary search). */ -export function rowIndexAtY(tops, rowCount, y) { +/** Largest row index with top ≤ y (binary search; O(1) linear when `tops` is null). */ +export function rowIndexAtY(tops, rowCount, y, rowHeight = 0) { if (rowCount === 0 || y < 0) return 0; + if (tops === null) { + // Uniform rows: top(idx) = idx * rowHeight. + if (rowHeight <= 0) return 0; + return Math.min(rowCount - 1, Math.max(0, Math.floor(y / rowHeight))); + } let lo = 0, hi = rowCount - 1, idx = 0; while (lo <= hi) { const mid = (lo + hi) >> 1; @@ -382,7 +426,14 @@ export function rowIndexAtY(tops, rowCount, y) { * @returns {{ idx: number, inRowBody: boolean } | null} */ export function rowAtContentY(tops, rowCount, rowHeight, y) { - if (rowCount === 0 || y < 0 || y >= tops[rowCount]) return null; + if (rowCount === 0 || y < 0) return null; + if (tops === null) { + // Uniform rows: total height = rowCount * rowHeight, top(idx) = idx*rowHeight. + if (y >= rowCount * rowHeight) return null; + const idx = Math.min(rowCount - 1, Math.floor(y / rowHeight)); + return { idx, inRowBody: y < idx * rowHeight + rowHeight }; + } + if (y >= tops[rowCount]) return null; const idx = rowIndexAtY(tops, rowCount, y); return { idx, inRowBody: y < tops[idx] + rowHeight }; } diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 29356758..f61d3d4f 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -1948,9 +1948,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // FK sub-view is a zero-cost overlay — it does NOT push rows down and is NOT // included in rowTops. This eliminates the fkSubviewHeight→_mergedHeights→rowTops // reactive chain that caused lag every time the panel opened or changed height. + // null when no row is expanded (the common case) → rowDocTop/rowIndexAtY use + // O(1) `idx * ROW_HEIGHT` math and no per-page Float64Array is allocated. const rowTops = $derived(computeRowTops(rows.length, expandedRows, 280, ROW_HEIGHT, expandedRowHeights)) /** Total scrollable content height incl. header + insert slot + body + 2-row bottom margin. */ - const contentHeight = $derived(HEADER_H + insertRowOffset + (rowTops[rows.length] ?? 0) + ROW_HEIGHT * 2) + const contentHeight = $derived(HEADER_H + insertRowOffset + (rowTops ? rowTops[rows.length] : rows.length * ROW_HEIGHT) + ROW_HEIGHT * 2) /** Viewport-visible column resize handles (DOM overlay — not on the canvas). */ const resizeHandles = $derived.by(() => { @@ -1981,7 +1983,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; /** Document-space y of a body row's top (0 = top of the sizer). */ function rowDocTop(/** @type {number} */ idx) { - return HEADER_H + insertRowOffset + (rowTops[idx] ?? idx * ROW_HEIGHT) + return HEADER_H + insertRowOffset + (rowTops ? (rowTops[idx] ?? idx * ROW_HEIGHT) : idx * ROW_HEIGHT) } /** Viewport y of a body row's top. */ function rowViewportY(/** @type {number} */ idx) { @@ -2273,6 +2275,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; // restores exactly what the user had open in each table. /** @type {Map, fkSubview: typeof fkSubview }>} */ const _tabExpandCache = new Map() + // Cap the per-tab cache: each entry can retain a whole FK sub-view's fetched + // rows, so an unbounded map would accumulate row data for every table visited + // in a session. LRU-evict the oldest once over the cap (Map keeps insertion + // order; re-inserting on save moves an entry to the most-recent position). + const TAB_EXPAND_CACHE_MAX = 12 let _lastTabKey = $state(untrack(() => columnWidthsKey ?? '')) $effect(() => { @@ -2281,10 +2288,17 @@ import FilterX from "@lucide/svelte/icons/filter-x"; untrack(() => { // Save state for the tab we're leaving if (_lastTabKey !== undefined && _lastTabKey !== '') { + _tabExpandCache.delete(_lastTabKey) // re-insert at MRU position _tabExpandCache.set(_lastTabKey, { expandedRows: new Set(expandedRows), fkSubview: fkSubview, }) + // Evict least-recently-used entries (oldest insertion order) over the cap. + while (_tabExpandCache.size > TAB_EXPAND_CACHE_MAX) { + const oldest = _tabExpandCache.keys().next().value + if (oldest === undefined) break + _tabExpandCache.delete(oldest) + } // Preserve unsaved edits/deletes for the table we're leaving so they're // restored when the user returns instead of being silently discarded. savePendingChanges(_lastTabKey, pendingEdits, pendingDeletes) @@ -2791,9 +2805,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const usedW = Math.max(0, Math.min(W, geom.totalWidth - _scrollLeft)) const navName = focusedCol !== null ? navigableColumns[focusedCol]?.name : null - // Frozen left region. Pinned cols only (gutters scroll with content). - let frozenW = 0 - for (const col of geom.cols) if (col.pinned) frozenW += col.w + // Frozen left region (pinned cols only) — already summed by geometry. + const frozenW = geom.frozenWidth // ── Body ───────────────────────────────────────────────────────────── ctx.save() @@ -2819,18 +2832,23 @@ import FilterX from "@lucide/svelte/icons/filter-x"; rangeR0 = range.r0; rangeR1 = range.r1 } + // Frame-constant draw context — built ONCE per frame and shared by every + // visible row, instead of a fresh ~20-field literal per row (that churned + // thousands of short-lived objects/sec during scroll → GC jank). + const bodyC = { + cFg, cText, cMuted, cGrid, cMutedBg, cRing, cAccent, cPanel, usedW, navName, + AMBER, BLUE_FG, RED, cPrimary, frozenW, + rangeColNames, rangeFirstCol, rangeLastCol, rangeR0, rangeR1, + } + const bodyTopY = Math.max(0, _scrollTop - HEADER_H - insertRowOffset) if (visibleColumns.length > 0) { - let i = rowIndexAtY(rowTops, n, bodyTopY) + let i = rowIndexAtY(rowTops, n, bodyTopY, ROW_HEIGHT) for (; i < n; i++) { const ry = rowViewportY(i) if (ry >= H) break if (ry + ROW_HEIGHT <= HEADER_H) continue - drawBodyRow(ctx, i, ry, { - cFg, cText, cMuted, cGrid, cMutedBg, cRing, cAccent, cPanel, usedW, navName, - AMBER, BLUE_FG, RED, cPrimary, frozenW, - rangeColNames, rangeFirstCol, rangeLastCol, rangeR0, rangeR1, - }) + drawBodyRow(ctx, i, ry, bodyC) } } ctx.restore() @@ -3505,12 +3523,24 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } /** Size the canvas backing store (DPR-aware). Always sync CSS px size so macOS - * WebKit can't display a stale CSS box over a mismatched bitmap (blurry zoom). */ + * WebKit can't display a stale CSS box over a mismatched bitmap (blurry zoom). + * Called only from the (low-frequency) layout effect — interaction repaints go + * through the separate repaint effect — so it always re-applies the transform + * rather than short-circuiting on unchanged dimensions. That guarantees the + * visible canvas is transformed + painted on every mount / tab switch (a + * short-circuit here risked leaving a fresh canvas untransformed → blank). */ function syncCanvasSurface() { const canvas = canvasEl const probe = colorProbe if (!canvas || !probe) return false if (!_readColor) _readColor = createColorReader(probe) + // ALWAYS (re)fetch the context for the CURRENT canvas element. getContext is + // idempotent per element (returns the same object), so this is cheap — but if + // Svelte recreated the (it does across tab switches while this + // instance persists), a cached _ctx would keep painting the OLD, detached + // canvas and the visible one would stay black. Binding to canvasEl every call + // keeps _ctx and the on-screen canvas in lockstep. + _ctx = canvas.getContext('2d') const cssW = Math.max(1, Math.round(_viewportWidth)) const cssH = Math.max(1, Math.round(_viewportHeight)) const dpr = Math.min(window.devicePixelRatio || 1, 2) @@ -3526,7 +3556,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; canvas.height = bh resized = true } - _ctx = canvas.getContext('2d') + // canvas.width/height assignment resets the transform, so always (re)apply it. if (_ctx) _ctx.setTransform(dpr, 0, 0, dpr, 0, 0) return { ok: !!_ctx, resized } } @@ -3637,33 +3667,56 @@ import FilterX from "@lucide/svelte/icons/filter-x"; return () => el.removeEventListener('wheel', onShiftWheel) }) - // Layout / sizing effect — resize the backing store when geometry or viewport changes. + // Canvas element binding — Svelte can recreate the across tab switches + // while this component instance (and its cached _ctx) persist. When the element + // identity changes, immediately re-bind the context, re-apply size + transform, + // and repaint the NEW element — otherwise draws keep hitting the old, detached + // canvas and the visible grid stays black. Tracks canvasEl only. + $effect(() => { + const el = canvasEl + if (!el) { _ctx = null; return } + if (_ctx?.canvas === el) return // already bound to this element + const { ok } = syncCanvasSurface() + if (ok) draw() + }) + + // Layout / sizing effect — resize the backing store when geometry or viewport + // changes. Tracks ONLY dependencies that can change the canvas dimensions, + // geometry, or the full set of drawn data. Interaction state (selection, focus, + // edit, hover, staged edits) is intentionally NOT tracked here — it can never + // change canvas dimensions, so it lives in the lightweight repaint effect below. + // Keeping it out means arrow-key nav and drag-select don't re-run the resurface + // dependency graph or touch the backing store on every tick. $effect(() => { void rows; void columns; void columnWidths - void pinnedColumns; void hiddenColumns; void selected; void focusedRow - void focusedCol; void editingCell; void pendingEdits; void pendingDeletes; void expandedRows; void selAnchor + void pinnedColumns; void hiddenColumns void rowSort; void _viewportWidth // NOTE: _scrollTop / _scrollLeft are intentionally NOT tracked here — scrolling // repaints via scheduleDraw() inside onContainerScroll, so this effect (which // also re-syncs the canvas backing store) doesn't run on every scroll frame. - void _viewportHeight; void newRowDrafts; void insertSaving; void colMeta + void _viewportHeight; void newRowDrafts; void colMeta void geom; void rowTops; void _redrawToken; void foreignKeys; void indexes - void _colCache; void expandedRowHeights; void fkSubview; void virtualRelCols; void VIRTUAL_COL_W + void _colCache; void expandedRows; void expandedRowHeights; void virtualRelCols; void VIRTUAL_COL_W void vexprTotalW; void _vcolFns void zoomState.value; void canvasZoom - const { ok, resized } = syncCanvasSurface() + const { ok } = syncCanvasSurface() if (ok) { - // When the backing store was cleared by a dimension change, draw immediately - // (synchronously in this microtask) so the browser never composites a black - // canvas. scheduleDraw() still queues a RAF for any concurrent state updates. - if (resized) draw() + // Always paint synchronously in this microtask (not only when the backing + // store was resized) so a fresh mount / tab switch never composites an + // untransformed, unpainted canvas (which would show the dark app background + // as a "black" grid). scheduleDraw() still coalesces concurrent updates. + draw() scheduleDraw() } }) - // Hover / affordance repaint — pointer moves must not re-touch canvas dimensions. + // Repaint effect — interaction/visual state that changes what's drawn but never + // the canvas dimensions. A plain scheduleDraw() (rAF-coalesced) with no backing- + // store work, so hover/selection/focus/edit stay cheap even during drag-select. $effect(() => { void hoveredRow; void hoveredColName; void _resizeHoverCol; void resizingColName + void selected; void focusedRow; void focusedCol; void selAnchor; void editingCell + void pendingEdits; void pendingDeletes; void insertSaving; void fkSubview if (_ctx) scheduleDraw() }) @@ -4129,7 +4182,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; if (!editingCell) return null const col = geom.cols.find((c) => _nameToActualIdx.get(c.name) === editingCell.colIdx) if (!col) return null - const top = HEADER_H + insertRowOffset + (rowTops[editingCell.rowIdx] ?? 0) + const top = HEADER_H + insertRowOffset + (rowTops ? (rowTops[editingCell.rowIdx] ?? 0) : editingCell.rowIdx * ROW_HEIGHT) // Pinned columns rest at their frozen x (content-space = scrollLeft + fixed). const left = col.pinned ? Math.max(col.contentX, _scrollLeft + (geom.pinnedFixedX.get(col.name) ?? 0)) diff --git a/src/lib/components/EntityRelationPage.svelte b/src/lib/components/EntityRelationPage.svelte index 06fffcb5..5858997c 100644 --- a/src/lib/components/EntityRelationPage.svelte +++ b/src/lib/components/EntityRelationPage.svelte @@ -31,10 +31,10 @@ */ // ── Config ──────────────────────────────────────────────────────────────── - const NODE_W = 230 - const ROW_H = 22 - const HDR_H = 34 - const PAD_B = 4 + const NODE_W = 268 + const ROW_H = 28 + const HDR_H = 42 + const PAD_B = 10 const BATCH = 16 const WARN_MANY = 60 @@ -89,7 +89,7 @@ if (conn.length) { const g = new dagre.graphlib.Graph() - g.setGraph({ rankdir: 'LR', ranksep: 220, nodesep: 48, marginx: 60, marginy: 60 }) + g.setGraph({ rankdir: 'LR', ranksep: 240, nodesep: 56, marginx: 80, marginy: 80 }) g.setDefaultEdgeLabel(() => ({})) for (const n of conn) { g.setNode(n.id, { width: NODE_W, height: n.data ? nodeH(n.data) : HDR_H }) @@ -102,7 +102,12 @@ // (nodes share an x in LR mode) into multiple sub-columns capped at a target // height, so a big fan spreads into a readable grid. Generous gaps keep the // cards from crowding and give the edges room to fan without overlapping. - const COL_GAP = 96, ROW_GAP = 52, RANK_GAP = 260, TARGET_H = 3200 + // Vertical gaps stay generous (breathing room within a readable view); + // horizontal RANK_GAP is kept modest so a deep LR graph doesn't stretch into + // an unreadable 8:1 sliver that fit-to-view then shrinks to nothing. A higher + // TARGET_H stacks each rank (esp. a hub referenced by many tables) into more + // vertical rows and fewer sub-columns → a squarer, narrower overall block. + const COL_GAP = 110, ROW_GAP = 72, RANK_GAP = 200, TARGET_H = 2600 /** @type {Map} */ const ranks = new Map() for (const n of conn) { @@ -137,14 +142,14 @@ } // Orphans in a responsive grid below the connected graph - const orphanY = laidConn.length ? bottomY + 80 : 0 + const orphanY = laidConn.length ? bottomY + 120 : 0 const maxH = orphans.reduce((m, n) => Math.max(m, n.data ? nodeH(n.data) : HDR_H), HDR_H) const GCOLS = Math.max(3, Math.min(6, Math.ceil(Math.sqrt(orphans.length * 1.8)))) const laidOrphans = orphans.map((n, i) => ({ ...n, position: { - x: (i % GCOLS) * (NODE_W + 32), - y: orphanY + Math.floor(i / GCOLS) * (maxH + 48), + x: (i % GCOLS) * (NODE_W + 56), + y: orphanY + Math.floor(i / GCOLS) * (maxH + 72), }, })) diff --git a/src/lib/components/ErdCanvas.svelte b/src/lib/components/ErdCanvas.svelte index eb53c76b..3f54eb5b 100644 --- a/src/lib/components/ErdCanvas.svelte +++ b/src/lib/components/ErdCanvas.svelte @@ -118,13 +118,38 @@ // Auto-fit the first time nodes populate (they load async, after mount), then // never again until the graph empties — so search/rebuild doesn't reset the view. let _fitted = false + // Zoom below which cards stop being legible — a big/deep schema whose whole + // bounding box would fit only under this is NOT fit-to-view (that produces an + // unreadable sliver). Instead we land at READABLE_ZOOM anchored at the graph's + // top-left so real cards are visible and the user pans to explore. + const READABLE_ZOOM = 0.62 function ensureFit() { if (_fitted || !cssW || !nodes.length) return _fitted = true - fit() - // A huge/star schema fits down to an unreadable sliver — instead land the - // user at a readable zoom centred on the graph so they can pan around. - if (cam.zoom < 0.4) zoomBy(0.55 / cam.zoom, cssW / 2, cssH / 2) + // Graph bounding box (world coords). + let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity + for (const n of nodes) { + const p = posOf(n), h = nodeH(n.data) + x0 = Math.min(x0, p.x); y0 = Math.min(y0, p.y) + x1 = Math.max(x1, p.x + NODE_W); y1 = Math.max(y1, p.y + h) + } + const bw = x1 - x0 || 1, bh = y1 - y0 || 1 + const zFit = Math.min(cssW / bw, cssH / bh) * (1 - 0.12) + if (zFit >= READABLE_ZOOM) { + // Small enough to show entirely at a legible size — fit-and-centre. + fit() + return + } + // Too large to fit legibly: land readable at the top-left of the graph so the + // first ranks are visible; the user pans right/down through the rest. + const M = 56 // screen-px margin from the viewport's top-left + cam.zoom = READABLE_ZOOM + cam.panX = M - x0 * READABLE_ZOOM + // Centre vertically when the graph is shorter than the viewport, else top-anchor. + cam.panY = bh * READABLE_ZOOM < cssH - M + ? (cssH - bh * READABLE_ZOOM) / 2 - y0 * READABLE_ZOOM + : M - y0 * READABLE_ZOOM + markDirty() } $effect(() => { byId = new Map(nodes.map((n) => [n.id, n])) @@ -268,10 +293,10 @@ ctx.textBaseline = 'middle' if (zoom >= LOD_NAME) { - ctx.font = `600 13px ${FONT_SANS}` + ctx.font = `600 13.5px ${FONT_SANS}` ctx.fillStyle = c('fg', 1) ctx.textAlign = 'left' - fillClipped(d.name ?? '', p.x + 12, p.y + HDR_H / 2, NODE_W - 24, 13) + fillClipped(d.name ?? '', p.x + 16, p.y + HDR_H / 2, NODE_W - 32, 13.5) } if (zoom < LOD_ROWS) { ctx.globalAlpha = 1; return } @@ -291,27 +316,27 @@ } const midY = cy + ROW_H / 2 const badge = isPk ? 'pk' : isFk ? 'fk' : '' - const typeRight = p.x + NODE_W - (badge ? 42 : 12) + const typeRight = p.x + NODE_W - (badge ? 52 : 16) // Column name. - ctx.font = `${isPk ? '600 ' : ''}11px ${FONT_SANS}` + ctx.font = `${isPk ? '600 ' : ''}12px ${FONT_SANS}` ctx.fillStyle = isPk ? `rgba(${PK},0.95)` : isFk ? `rgba(${FK},0.95)` : c('fg', 0.82) ctx.textAlign = 'left' - fillClipped(col.name ?? '', p.x + 12, midY, typeRight - (p.x + 16), 11) + fillClipped(col.name ?? '', p.x + 16, midY, typeRight - (p.x + 20), 12) // Data type. - ctx.font = `10px ${FONT_MONO}` + ctx.font = `11px ${FONT_MONO}` ctx.fillStyle = c('mfg', 0.6) ctx.textAlign = 'right' - fillClipped(String(col.dataType ?? ''), typeRight, midY, 64, 10, true) + fillClipped(String(col.dataType ?? ''), typeRight, midY, 72, 11, true) // PK / FK badge. if (badge) { - const bx = p.x + NODE_W - 34, bw = 22 - roundRect(bx, cy + 5, bw, ROW_H - 10, 3) + const bw = 26, bx = p.x + NODE_W - bw - 12 + roundRect(bx, cy + 6, bw, ROW_H - 12, 3) ctx.fillStyle = isPk ? `rgba(${PK},0.16)` : `rgba(${FK},0.14)` ctx.fill() - ctx.font = `700 8px ${FONT_MONO}` + ctx.font = `700 9px ${FONT_MONO}` ctx.fillStyle = isPk ? `rgba(${PK},1)` : `rgba(${FK},1)` ctx.textAlign = 'center' ctx.fillText(badge, bx + bw / 2, midY + 0.5) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 9687c614..f08b8487 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -816,37 +816,57 @@ let rowSearch = $state('') }) // ── Connection health monitor ───────────────────────────────────────────────── - // Pings the active DB every 30 seconds. On failure, shows a persistent toast - // with a Reconnect button. Clears automatically when the connection recovers. + // Pings the active DB periodically. On failure it flags the connection as lost + // (a subtle red dot in the StatusBar) and silently reconnects in place — NO + // popup. Pings every 30s when healthy; every 6s while lost so recovery is + // detected fast. Clears itself the moment the connection is back. $effect(() => { if (!connection) { connectionLost = false return } - const conn = connection - const id = setInterval(async () => { + let stopped = false + /** @type {ReturnType | undefined} */ + let timer + const schedule = () => { + if (stopped) return + timer = setTimeout(run, connectionLost ? 6_000 : 30_000) + } + const run = async () => { + if (stopped) return // Skip the round-trip while the window is hidden (backgrounded/minimized): // there's no UI to update, and idle pings only risk waking a sleeping // remote connection. The next tick after the window is shown will ping. - if (typeof document !== 'undefined' && document.hidden) return + if (typeof document !== 'undefined' && document.hidden) { schedule(); return } try { await pingConnection() - if (connectionLost) connectionLost = false + if (connectionLost) { connectionLost = false; error = '' } } catch { - if (!connectionLost) { - connectionLost = true - toast.error('Connection lost', { - description: 'The database connection was interrupted.', - duration: Infinity, - action: { - label: 'Reconnect', - onClick: () => handleSwitchDatabase(conn), - }, - }) - } + if (!connectionLost) connectionLost = true + // Heal in place instead of showing a Reconnect popup. + void silentReconnect() } - }, 30_000) - return () => clearInterval(id) + schedule() + } + schedule() + return () => { stopped = true; if (timer) clearTimeout(timer) } + }) + + // Reconnect the instant the OS reports the network is back, or when the user + // returns to a backgrounded window — the two moments a dropped remote DB most + // likely became reachable again. Both are no-ops unless the connection is lost. + $effect(() => { + if (typeof window === 'undefined') return + const kick = () => { if (connection && connectionLost) void silentReconnect() } + const onVis = () => { if (typeof document !== 'undefined' && !document.hidden) kick() } + window.addEventListener('online', kick) + window.addEventListener('focus', kick) + document.addEventListener('visibilitychange', onVis) + return () => { + window.removeEventListener('online', kick) + window.removeEventListener('focus', kick) + document.removeEventListener('visibilitychange', onVis) + } }) const sqlSchemaHints = $derived.by(() => { @@ -2844,7 +2864,7 @@ let rowSearch = $state('') } } catch (e) { const errStr = String(e) - if (isNetworkError(errStr)) connectionLost = true + if (isNetworkError(errStr)) { connectionLost = true; void silentReconnect() } patchTab({ loadingRows: false, error: errStr, columns: [], rows: [], total: 0 }) if (tabId === activeTabId) { loadingRows = false @@ -2925,7 +2945,7 @@ let rowSearch = $state('') } catch (e) { if (seq !== _loadSeq) return const errStr = String(e) - if (isNetworkError(errStr)) connectionLost = true + if (isNetworkError(errStr)) { connectionLost = true; void silentReconnect() } error = errStr columns = [] primaryKey = [] @@ -3069,7 +3089,7 @@ let rowSearch = $state('') } catch (e) { sqlError = String(e) sqlMultiResults = [] - if (isNetworkError(sqlError)) connectionLost = true + if (isNetworkError(sqlError)) { connectionLost = true; void silentReconnect() } } finally { sqlLoading = false recordActivity({ type: 'sql_exec', title: sqlRan.trim().slice(0, 80) + (sqlRan.trim().length > 80 ? '…' : ''), detail: sqlRan, durationMs: sqlQueryMs, rowCount: sqlRows.length || undefined, success: !sqlError, error: sqlError || undefined }) @@ -3236,6 +3256,7 @@ let rowSearch = $state('') async function handleSchemaChange(schema) { if (!schema || schema === activeSchema) return + if (connectionLost) await reconnectPool() activeSchema = schema activeTable = null page = 1 @@ -3254,6 +3275,8 @@ let rowSearch = $state('') } async function handleTableSelect(name) { + // Switching tables should transparently heal a dropped connection. + if (connectionLost) await reconnectPool() recordActivity({ type: 'table_open', title: `Opened ${name}`, schema: activeSchema, table: name, success: true }) await openTableTab(activeSchema, name) } @@ -3357,6 +3380,23 @@ let rowSearch = $state('') } } + /** + * Re-establish the backend pool for a saved connection, dispatched by engine. + * Shared by the explicit switch flow and the silent auto-reconnect path. + * @param {import('$lib/stores/connections.js').SavedConnection} conn + */ + async function connectByType(conn) { + const { connectPostgres, connectSqlite, connectD1, connectLibSql, connectMysql, connectClickhouse, connectDuckdb, connectMssql } = await import('$lib/api.js') + if (conn.type === 'sqlite') await connectSqlite(conn) + else if (conn.type === 'd1') await connectD1(conn) + else if (conn.type === 'libsql') await connectLibSql(conn) + else if (conn.type === 'mysql' || conn.type === 'mariadb') await connectMysql(conn) + else if (conn.type === 'clickhouse') await connectClickhouse(conn) + else if (conn.type === 'duckdb') await connectDuckdb(conn) + else if (conn.type === 'mssql') await connectMssql(conn) + else await connectPostgres(conn) + } + /** @param {import('$lib/stores/connections.js').SavedConnection} conn */ async function handleSwitchDatabase(conn) { // Disconnect current before connecting to the new one to avoid the race @@ -3367,15 +3407,7 @@ let rowSearch = $state('') // Connect to the chosen saved connection autoConnecting = true try { - const { connectPostgres, connectSqlite, connectD1, connectLibSql, connectMysql, connectClickhouse, connectDuckdb, connectMssql } = await import('$lib/api.js') - if (conn.type === 'sqlite') await connectSqlite(conn) - else if (conn.type === 'd1') await connectD1(conn) - else if (conn.type === 'libsql') await connectLibSql(conn) - else if (conn.type === 'mysql' || conn.type === 'mariadb') await connectMysql(conn) - else if (conn.type === 'clickhouse') await connectClickhouse(conn) - else if (conn.type === 'duckdb') await connectDuckdb(conn) - else if (conn.type === 'mssql') await connectMssql(conn) - else await connectPostgres(conn) + await connectByType(conn) await onConnected(conn, conn.id) } catch (e) { error = String(e) @@ -3385,7 +3417,49 @@ let rowSearch = $state('') } } + // ── Silent auto-reconnect ────────────────────────────────────────────────── + // Heals a dropped connection IN PLACE — rebuilds the backend pool for the + // CURRENT connection config, with no UI teardown, no full-screen overlay, and + // no toast. The health monitor, an `online` event, a table switch, a refresh, + // or a failed query can all trigger this, so the app recovers by itself instead + // of nagging the user with a "Connection lost / Reconnect" popup. + let _reconnecting = false + let _lastReconnectAt = 0 + /** Rebuild the pool for the active connection. Returns true on success. */ + async function reconnectPool() { + const conn = connection + if (!conn) return false + try { + await connectByType(conn) + connectionLost = false + return true + } catch { + return false + } + } + /** Rate-limited silent reconnect + quiet refetch, for background triggers. */ + async function silentReconnect() { + if (_reconnecting || !connection) return + // Coalesce bursts of triggers (online + visibilitychange + ping fire together) + // and stop a still-unreachable host from hammering connect_* in a tight loop. + const now = Date.now() + if (now - _lastReconnectAt < 3000) return + _lastReconnectAt = now + _reconnecting = true + try { + if (await reconnectPool()) { + error = '' + if (activeTab?.kind === 'table' && activeTable) await loadRows().catch(() => {}) + else await loadTables().catch(() => {}) + } + } finally { + _reconnecting = false + } + } + async function handleRefresh() { + // A manual refresh should also recover a dropped connection. + if (connectionLost) await reconnectPool() await loadSchemas() await loadTables({ force: true }) if (activeTab?.kind === 'table' && activeTable) { diff --git a/src/lib/stores/settings.js b/src/lib/stores/settings.js index c1cb4794..054f3b0d 100644 --- a/src/lib/stores/settings.js +++ b/src/lib/stores/settings.js @@ -154,11 +154,20 @@ function recordThemeBeforeChange(theme) { if (themeHistoryStack.length > 32) themeHistoryStack.shift() } +// In-memory cache of the normalized settings. All reads go through loadSettings +// and all writes through saveSettings, so this stays authoritative for the app's +// (single) window and lets the zoom/pinch hot path skip a localStorage getItem + +// JSON.parse (× the two loadSettings calls each updateSettings made) per step. +/** @type {AppSettings | null} */ +let _settingsCache = null + /** @returns {AppSettings} */ export function loadSettings() { + if (_settingsCache) return { ..._settingsCache } try { const raw = localStorage.getItem(STORAGE_KEY) if (!raw) { + _settingsCache = { ...DEFAULT_SETTINGS } return { ...DEFAULT_SETTINGS } } const parsed = JSON.parse(raw) @@ -183,7 +192,8 @@ export function loadSettings() { const font = normalizeFont(parsed.font) const iconStyle = normalizeIconStyle(parsed.iconStyle) const iconSet = normalizeIconSet(parsed.iconSet) - return { theme, zoom, font, iconStyle, iconSet, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply } + _settingsCache = { theme, zoom, font, iconStyle, iconSet, mcpAutoStart, launchAtLogin, autoReconnectOnStartup, previewDmlBeforeApply } + return { ..._settingsCache } } catch { return { ...DEFAULT_SETTINGS } } @@ -191,6 +201,9 @@ export function loadSettings() { /** @param {AppSettings} settings */ export function saveSettings(settings) { + // Keep the in-memory cache authoritative even if the localStorage write throws + // (quota/private-mode) — the running app should still reflect the new settings. + _settingsCache = { ...settings } try { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)) } catch (err) { diff --git a/src/lib/utils.js b/src/lib/utils.js index ce05a88e..a9f8b47d 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -21,6 +21,20 @@ const NETWORK_ERROR_PATTERNS = [ 'os error 111', // ECONNREFUSED Linux 'os error 110', // ETIMEDOUT 'os error 113', // EHOSTUNREACH + 'os error 51', // ENETUNREACH macOS (Wi‑Fi dropped) + 'os error 65', // EHOSTUNREACH macOS + 'cannot reach', // our own preflight message + 'pool timed out', // sqlx pool acquire timeout (idle/dead peer) + 'pool has been closed', + 'poolclosed', + 'connection closed', + 'connection was closed', + 'server closed the connection', + 'terminating connection', // Postgres idle-timeout / admin close + 'connection is closed', + // NB: deliberately NOT matching bare 'timed out' / 'i/o error' — those also fire + // on a statement/lock timeout (a slow query, not a dropped connection), which + // would spuriously flag connectionLost and churn the pool on every subsequent tap. ] /**