From 2ed50c8353c28e6e7946af6002875a77c1afccfe Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 11 Jul 2026 12:10:42 +0545 Subject: [PATCH 1/8] fix(grid): resolve theme colors on fresh nodes; drop global motion overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canvas grid rendered blank (only rgba-literal badges/icons visible) on macOS WKWebView: createColorReader reused one probe and reassigned style.color between synchronous getComputedStyle reads, but WKWebView returns a STALE computed color in that case — every token after the first froze at the first color's value, so text and grid lines painted the same color as the panel and vanished. Resolve each token on a throwaway node instead. Verified via pixel readback in a real WKWebView: foreground/grid/muted now resolve to distinct correct colors. Also remove the global motion additions from the connection-redesign PR: - the @media (prefers-reduced-motion: reduce) block forced animation-duration/transition-duration: 0.12s !important on every element, so spinners/loaders ran ~8x fast (broken) when macOS Reduce Motion is on. - the --ease-out/--ease-in-out/--ease-drawer token overrides silently repunched every ease-* utility app-wide (--ease-drawer was unused). --- src/app.css | 29 ----------------------------- src/lib/canvas-table.js | 22 ++++++++++++++++++++-- 2 files changed, 20 insertions(+), 31 deletions(-) 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..eec02002 100644 --- a/src/lib/canvas-table.js +++ b/src/lib/canvas-table.js @@ -17,11 +17,23 @@ // 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. /** @param {HTMLElement} probe A 0×0 element living inside the table container. */ export function createColorReader(probe) { /** @type {Map} */ const cache = new Map(); + // Resolve on nodes appended to the probe's parent so they inherit the same + // themed context (data-theme lives on , but keeping them adjacent also + // matches font/context). Falls back to if the probe is detached. + const host = probe.parentNode ?? document.body; // 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 +41,15 @@ export function createColorReader(probe) { return (/** @type {string} */ expr) => { const hit = cache.get(expr); if (hit !== undefined) return hit; - probe.style.color = expr; + 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 || "rgb(0,0,0)"; + el.remove(); if (scratch) { try { scratch.fillStyle = "#010203"; // sentinel From c1114f357ddaf1eaea1c4e8ff45c3df8be7f2e80 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 11 Jul 2026 12:35:16 +0545 Subject: [PATCH 2/8] perf(grid): cut per-frame allocations & reactivity churn; roomier ERD layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data grid render/reactivity/memory: - draw(): build the ~20-field per-row draw context ONCE per frame instead of a fresh literal per visible row (was thousands of short-lived objects/sec during scroll → GC jank); use geom.frozenWidth instead of re-summing pinned cols. - computeRowTops returns null when no row is expanded (the common case) and rowIndexAtY/rowAtContentY fall back to O(1) idx*rowHeight math — no 8 MB Float64Array reallocated per infinite-scroll page on 1M-row tables. Verified the null path is numerically identical to the array path. - Split the canvas effects: the resurface effect now tracks only true layout deps; selection/focus/edit/hover/staged-edits moved to the lightweight repaint effect, so arrow-key nav and drag-select no longer re-run resurface or touch the backing store. syncCanvasSurface() skips all DOM writes + setTransform when size/DPR are unchanged. - settings: in-memory cache so the zoom/pinch hot path no longer does localStorage getItem + JSON.parse twice per step. - LRU-cap _tabExpandCache (retained fetched FK sub-view rows) at 12 entries. ERD schema visualizer: - Much larger layout spacing (ranksep 220→340, nodesep 48→84, COL/ROW/RANK gaps 96/52/260 → 150/84/400, roomier orphan grid) so cards don't crowd and edges have room to fan — fixes the cramped/tangled look. - Bigger, more legible cards (NODE_W 230→268, ROW_H 22→28, HDR_H 34→42) with more padding, larger column/type fonts, and roomier PK/FK badges. --- src/lib/canvas-table.js | 28 ++++++-- src/lib/components/DataTable.svelte | 74 +++++++++++++++----- src/lib/components/EntityRelationPage.svelte | 18 ++--- src/lib/components/ErdCanvas.svelte | 20 +++--- src/lib/stores/settings.js | 15 +++- 5 files changed, 112 insertions(+), 43 deletions(-) diff --git a/src/lib/canvas-table.js b/src/lib/canvas-table.js index eec02002..9e0b7a3c 100644 --- a/src/lib/canvas-table.js +++ b/src/lib/canvas-table.js @@ -368,11 +368,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++) { @@ -383,9 +391,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; @@ -400,7 +413,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..679a503c 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() @@ -3506,14 +3524,22 @@ 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). */ + let _lastCssW = -1, _lastCssH = -1, _lastDpr = -1 function syncCanvasSurface() { const canvas = canvasEl const probe = colorProbe if (!canvas || !probe) return false if (!_readColor) _readColor = createColorReader(probe) + if (!_ctx) _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) + // Nothing changed → skip all DOM writes. This effect also fires on selection/ + // focus/edit ticks (they schedule a repaint via the same effect graph); doing + // the style-write + setTransform every time was pure overhead on those. + if (cssW === _lastCssW && cssH === _lastCssH && dpr === _lastDpr) { + return { ok: !!_ctx, resized: false } + } const bw = Math.max(1, Math.round(cssW * dpr)) const bh = Math.max(1, Math.round(cssH * dpr)) canvas.style.width = cssW + 'px' @@ -3526,8 +3552,9 @@ 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 (re)apply it after. if (_ctx) _ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + _lastCssW = cssW; _lastCssH = cssH; _lastDpr = dpr return { ok: !!_ctx, resized } } @@ -3637,18 +3664,23 @@ 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. + // 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() @@ -3661,9 +3693,13 @@ import FilterX from "@lucide/svelte/icons/filter-x"; } }) - // 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 +4165,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..c737e375 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: 340, nodesep: 84, 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,7 @@ // (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 + const COL_GAP = 150, ROW_GAP = 84, RANK_GAP = 400, TARGET_H = 3200 /** @type {Map} */ const ranks = new Map() for (const n of conn) { @@ -137,14 +137,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..098d4966 100644 --- a/src/lib/components/ErdCanvas.svelte +++ b/src/lib/components/ErdCanvas.svelte @@ -268,10 +268,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 +291,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/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) { From 3f7b209a8f9936eabfc18b016692a5ee2454b06b Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 11 Jul 2026 12:45:08 +0545 Subject: [PATCH 3/8] fix(erd): land at a readable zoom instead of a fit-to-sliver; balance layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LR dagre layout of a deep schema is inherently very wide, so fit-to-view scaled the whole bounding box down to an unreadable horizontal sliver (all tables in one thin band). The previous "bump to 0.55 centred" landed the user in the middle of that band, still unreadable. - ensureFit: if the whole graph can't fit at a legible size, land at a fixed READABLE_ZOOM (0.62) anchored at the graph's top-left and let the user pan, instead of fitting everything into a sliver. Small graphs still fit-and-centre. - Moderate the layout gaps (ranksep 340→240, nodesep 84→56, COL/ROW/RANK 110/72/200) so the graph isn't stretched into an even wider sliver, and raise TARGET_H to 2600 so each rank (esp. a hub) stacks into a squarer, narrower block rather than one long line — more vertical breathing room, less horizontal sprawl. --- src/lib/components/EntityRelationPage.svelte | 9 ++++-- src/lib/components/ErdCanvas.svelte | 33 +++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/lib/components/EntityRelationPage.svelte b/src/lib/components/EntityRelationPage.svelte index c737e375..5858997c 100644 --- a/src/lib/components/EntityRelationPage.svelte +++ b/src/lib/components/EntityRelationPage.svelte @@ -89,7 +89,7 @@ if (conn.length) { const g = new dagre.graphlib.Graph() - g.setGraph({ rankdir: 'LR', ranksep: 340, nodesep: 84, marginx: 80, marginy: 80 }) + 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 = 150, ROW_GAP = 84, RANK_GAP = 400, 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) { diff --git a/src/lib/components/ErdCanvas.svelte b/src/lib/components/ErdCanvas.svelte index 098d4966..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])) From 07f4b35eb6d813e4addc8bea1f879739d13c4ef6 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 11 Jul 2026 13:01:00 +0545 Subject: [PATCH 4/8] feat(connection): silent auto-reconnect; drop the "Connection lost" popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 30s health-ping showed a persistent (duration: Infinity) "Connection lost / Reconnect" toast that never auto-dismissed even after recovery, and nothing reconnected on its own — the user had to click it. Now the app heals itself, no popup: - reconnectPool()/silentReconnect(): rebuild the pool for the CURRENT connection config in place — no UI teardown, no full-screen overlay, no toast. Rate-limited (3s) and re-entrancy-guarded so bursts of triggers / an unreachable host can't loop connect_*. - Health monitor: on ping failure, flag connectionLost (subtle StatusBar red dot) and silently reconnect instead of toasting. Pings every 30s when healthy, every 6s while lost, and clears the instant it recovers. - Auto-reconnect triggers: `online` / window focus / visibilitychange (network is back / window refocused), table switch, schema change, manual refresh, and the failing query itself (network-error catch paths). - Widen isNetworkError to also match pool-timeout / connection-closed / "cannot reach" / idle-termination so cloud-Postgres idle drops are treated as network blips (auto-reconnected) rather than surfaced as SQL errors. The StatusBar keeps a subtle red indicator while reconnecting; no modal/toast. --- src/lib/components/StudioShell.svelte | 136 ++++++++++++++++++++------ src/lib/utils.js | 13 +++ 2 files changed, 118 insertions(+), 31 deletions(-) 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/utils.js b/src/lib/utils.js index ce05a88e..e85a5185 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -21,6 +21,19 @@ 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', + 'i/o error', + 'timed out', ] /** From a8c62372e1bf0d5250001b3669b1262a63c2fb62 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 11 Jul 2026 13:08:42 +0545 Subject: [PATCH 5/8] harden(grid): never fill canvas with a black fallback in the color reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createColorReader fell back to opaque rgb(0,0,0) if getComputedStyle ever returned an empty colour, and that value fills the ENTIRE canvas (the --panel background) — the worst-case failure for a full-canvas fill. Empty results are only really possible when the throwaway probe node is measured against a momentarily-detached subtree (e.g. mid tab remount). - Append the throwaway node to a guaranteed-connected host (probe's parent only when the probe itself isConnected, else ); theme tokens live on so inherits them identically. - On an empty computed colour, reuse the last successfully-resolved colour (or a neutral grey, never black) and do NOT cache it, so the next read resolves cleanly once the DOM settles. Re-verified in a real WKWebView: all tokens still resolve to correct distinct colours (panel [11,11,11], foreground [235,235,235], grid [39,39,39], …). --- src/lib/canvas-table.js | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/lib/canvas-table.js b/src/lib/canvas-table.js index 9e0b7a3c..37a9d033 100644 --- a/src/lib/canvas-table.js +++ b/src/lib/canvas-table.js @@ -26,14 +26,19 @@ // 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(); - // Resolve on nodes appended to the probe's parent so they inherit the same - // themed context (data-theme lives on , but keeping them adjacent also - // matches font/context). Falls back to if the probe is detached. - const host = probe.parentNode ?? document.body; + // 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. @@ -41,6 +46,10 @@ export function createColorReader(probe) { return (/** @type {string} */ expr) => { const hit = cache.get(expr); if (hit !== undefined) return hit; + // 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; @@ -48,8 +57,11 @@ export function createColorReader(probe) { el.style.color = expr; host.appendChild(el); // getComputedStyle resolves var()/oklch/hsl to a concrete computed colour. - let resolved = getComputedStyle(el).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 @@ -60,6 +72,7 @@ export function createColorReader(probe) { /* keep the computed string */ } } + lastGood = resolved; cache.set(expr, resolved); return resolved; }; From cccfbe41c1d3f595e513a995c41d4cadbcfd5a2a Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 11 Jul 2026 13:14:10 +0545 Subject: [PATCH 6/8] fix(connection): don't treat statement/lock timeouts as connection loss The earlier isNetworkError widening included bare 'timed out' and 'i/o error', which also match a Postgres statement/lock timeout (a slow query, not a dropped connection). That would flip connectionLost and make every following table tap tear down + rebuild the pool. Drop those two over-broad patterns; keep the connection-specific ones (pool timed out, connection/server closed, terminating connection, cannot reach, os errors). --- src/lib/utils.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/utils.js b/src/lib/utils.js index e85a5185..a9f8b47d 100644 --- a/src/lib/utils.js +++ b/src/lib/utils.js @@ -32,8 +32,9 @@ const NETWORK_ERROR_PATTERNS = [ 'server closed the connection', 'terminating connection', // Postgres idle-timeout / admin close 'connection is closed', - 'i/o error', - 'timed out', + // 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. ] /** From 9b89508c50babb3ba0c31de1566319668e4d9671 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 11 Jul 2026 13:23:33 +0545 Subject: [PATCH 7/8] fix(grid): always re-apply transform + paint on layout sync (guard tab-switch blank) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Belt-and-suspenders for canvas painting across mounts / tab switches: - syncCanvasSurface() no longer short-circuits on unchanged dimensions; it always re-applies setTransform. (After the earlier effect split this is only called from the low-frequency layout effect, not on scroll/selection, so the cost is negligible.) A short-circuit here risked leaving a fresh/remounted canvas untransformed. - The layout effect now calls draw() synchronously whenever the surface is ok (not only on resize), so a fresh mount / tab switch can never composite an unpainted canvas (which would show the dark app background as a black grid). scheduleDraw() still coalesces concurrent updates. (isNetworkError over-match — 'timed out' / 'i/o error' — was already tightened in cccfbe4.) --- src/lib/components/DataTable.svelte | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 679a503c..1bb288bc 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -3523,8 +3523,12 @@ 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). */ - let _lastCssW = -1, _lastCssH = -1, _lastDpr = -1 + * 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 @@ -3534,12 +3538,6 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const cssW = Math.max(1, Math.round(_viewportWidth)) const cssH = Math.max(1, Math.round(_viewportHeight)) const dpr = Math.min(window.devicePixelRatio || 1, 2) - // Nothing changed → skip all DOM writes. This effect also fires on selection/ - // focus/edit ticks (they schedule a repaint via the same effect graph); doing - // the style-write + setTransform every time was pure overhead on those. - if (cssW === _lastCssW && cssH === _lastCssH && dpr === _lastDpr) { - return { ok: !!_ctx, resized: false } - } const bw = Math.max(1, Math.round(cssW * dpr)) const bh = Math.max(1, Math.round(cssH * dpr)) canvas.style.width = cssW + 'px' @@ -3552,9 +3550,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; canvas.height = bh resized = true } - // canvas.width/height assignment resets the transform, so (re)apply it after. + // canvas.width/height assignment resets the transform, so always (re)apply it. if (_ctx) _ctx.setTransform(dpr, 0, 0, dpr, 0, 0) - _lastCssW = cssW; _lastCssH = cssH; _lastDpr = dpr return { ok: !!_ctx, resized } } @@ -3683,12 +3680,13 @@ import FilterX from "@lucide/svelte/icons/filter-x"; 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() } }) From 17c199510fa73c1ec5791c9f7654454fb761f8f6 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Sat, 11 Jul 2026 13:41:46 +0545 Subject: [PATCH 8/8] fix(grid): rebind canvas context to the live element (tab-switch blackout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed via on-device WKWebView instrumentation: after opening/switching tabs the grid went fully black while draw() ran with perfect state (correct size, rows, columns, colours). The context (_ctx) was still bound to a canvas WebKit had recreated on the tab switch (ctx.canvas detached, ctx.canvas !== canvasEl), so every draw painted an off-screen element and the visible canvas stayed black. Root cause was the c1114f3 perf change caching _ctx behind `if (!_ctx)`. - syncCanvasSurface() always re-fetches _ctx for the current canvasEl (getContext is idempotent per element, so it's cheap) — _ctx can never go stale. - New effect re-binds the context + re-applies size/transform + repaints whenever the element identity changes, covering swaps not accompanied by a layout change. Adds a changeset so the release notes cover this PR's fixes (blank grid, tab-switch blackout, spinner/reduce-motion, ERD readability) + silent auto-reconnect. --- .changeset/canvas-render-motion-reconnect.md | 21 ++++++++++++++++++++ src/lib/components/DataTable.svelte | 21 +++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 .changeset/canvas-render-motion-reconnect.md 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/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 1bb288bc..f61d3d4f 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -3534,7 +3534,13 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const probe = colorProbe if (!canvas || !probe) return false if (!_readColor) _readColor = createColorReader(probe) - if (!_ctx) _ctx = canvas.getContext('2d') + // 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) @@ -3661,6 +3667,19 @@ import FilterX from "@lucide/svelte/icons/filter-x"; return () => el.removeEventListener('wheel', onShiftWheel) }) + // 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,