From d1c301aede8ee880f5d57b9fe6a9b1fe52e132a9 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:03:33 +0545 Subject: [PATCH 01/25] Turn NULL markers on by default With the Empty & NULL Markers extension off, a real NULL and the text 'NULL' drew the same four letters, grey against white, and empty and whitespace-only strings both drew blank. Whether to write IS NULL or = 'NULL' depended on reading that tint. Anyone who switched the extension off keeps it off. --- src/lib/stores/plugins.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/stores/plugins.js b/src/lib/stores/plugins.js index 929fb107..ef68cd83 100644 --- a/src/lib/stores/plugins.js +++ b/src/lib/stores/plugins.js @@ -48,9 +48,10 @@ if (typeof window !== 'undefined') { /** * Extensions that ship enabled. Workflow features (saved views, find & * replace) are core-adjacent - off-by-default would just hide them; the - * Extensions page stays the place to switch them off. + * Extensions page stays the place to switch them off. Nullish markers are on + * because without them a real NULL and the text 'NULL' differ only by colour. */ -const DEFAULT_ON = new Set(['saved-views', 'find-replace']) +const DEFAULT_ON = new Set(['saved-views', 'find-replace', 'nullish-values']) /** * Enabled check against an explicit state snapshot (for reactive `$pluginState` From 078f92c6981f1a8ecd6f7c1757dc9926cd4d545b Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:03:33 +0545 Subject: [PATCH 02/25] Report failed edits in one toast that says where they went Apply raised one "Could not save" per failed edit, then a green "Changes applied". When the same Apply also deleted rows, the failed edits were dropped with nothing said. Now one error names how many edits failed, whether they are still staged or were discarded, and up to three column: error lines in the code block. The success toast reads "Some changes applied" when anything failed. The grid's own "Could not delete" toast is gone, since the delete handler already raises one. --- src/lib/components/DataTable.svelte | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index c8d515ff..0b97ded7 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -2080,6 +2080,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; const entries = [...pendingEdits.values()].filter((e) => !pendingDeletes.has(e.rowIdx)); /** @type {typeof entries} */ const failed = []; + /** @type {string[]} */ + const failReasons = []; let okCount = 0; for (const edit of entries) { try { @@ -2087,7 +2089,7 @@ import FilterX from "@lucide/svelte/icons/filter-x"; okCount++; } catch (err) { failed.push(edit); - toast.error("Could not save", { description: String(err) }); + failReasons.push(`${columns[edit.colIdx]?.name ?? "?"}: ${String(err)}`); } } @@ -2098,8 +2100,8 @@ import FilterX from "@lucide/svelte/icons/filter-x"; await ondelete({ rowIndices: deleteIndices }); deletedCount = deleteIndices.length; pendingDeletes = new Set(); - } catch (err) { - toast.error("Could not delete", { description: String(err) }); + } catch { + // error toast already shown by ondelete } } @@ -2126,11 +2128,20 @@ import FilterX from "@lucide/svelte/icons/filter-x"; futureEdits = []; // Sync the cross-unmount cache to the post-apply state (clears it when empty). savePendingChanges(_persistKey, pendingEdits, pendingDeletes); + // One toast for every failed edit, not one per edit, and it says where the + // edits went. A few distinct reasons are enough to act on. + if (failed.length > 0) { + const n = failed.length; + const title = deletedCount > 0 + ? (n === 1 ? "1 edit wasn't saved and was discarded" : `${n} edits weren't saved and were discarded`) + : `Couldn't save ${n} edit${n === 1 ? "" : "s"}. ${n === 1 ? "It's" : "They're"} still staged`; + toast.error(title, { description: [...new Set(failReasons)].slice(0, 3).join("\n"), code: true, duration: 10000 }); + } if (okCount > 0 || deletedCount > 0) { const parts = []; if (okCount > 0) parts.push(`${okCount} cell${okCount === 1 ? "" : "s"} updated`); if (deletedCount > 0) parts.push(`${deletedCount} row${deletedCount === 1 ? "" : "s"} deleted`); - toast.success("Changes applied", { description: parts.join(", ") }); + toast.success(failed.length > 0 ? "Some changes applied" : "Changes applied", { description: parts.join(", ") }); } } From ea44cea129d617fe8ab163e68a921676f093ae2d Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:03:33 +0545 Subject: [PATCH 03/25] Say "No rows match these filters" when a filter empties the grid An empty result under a search or filter read "No rows in this table", which says the data is gone, and the overlay had no way out. The grid now gets a `filtered` flag and a Clear filters button that drops the search and every filter in one reload. An unfiltered empty table still reads as before. --- src/lib/components/DataTable.svelte | 14 ++++++++++++++ src/lib/components/StudioShell.svelte | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 0b97ded7..dce1fb1a 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -17,6 +17,7 @@ import { toast } from "$lib/components/ui/sonner/toast.svelte.js"; import * as ContextMenu from "$lib/components/ui/context-menu/index.js"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js"; + import { Button } from "$lib/components/ui/button/index.js"; import ArrowUpDown from "@lucide/svelte/icons/arrow-up-down"; import ArrowUp from "@lucide/svelte/icons/arrow-up"; import ArrowDown from "@lucide/svelte/icons/arrow-down"; @@ -376,6 +377,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; * treat as roaming. */ /** True when every row has been loaded in infinite scroll mode (no more pages). */ endOfResults = false, + /** True when a row search or filter is narrowing the result, so an empty + * grid means "nothing matched", not "the table is empty". */ + filtered = false, + /** Clears the row search and every filter (the filtered empty state's button). */ + onclearfilters = /** @type {(() => void) | null} */ (null), /** Active row-search query (toolbar search). Matched substrings are * highlighted in the drawn cell text. */ searchQuery = '', @@ -8667,6 +8673,14 @@ import FilterX from "@lucide/svelte/icons/filter-x";

Loading rows…

{/if} + {:else if filtered} + +

No rows match these filters

+ {#if onclearfilters} + + {/if} {:else}

No rows in this table

diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index f5f39c84..608a27d0 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -5131,6 +5131,20 @@ let rowSearch = $state('') }, SEARCH_DEBOUNCE_MS) } + /** Drop the row search and every filter at once (the grid's "Clear filters"). */ + function clearRowFilters() { + if (searchDebounceTimer) clearTimeout(searchDebounceTimer) + if (filterDebounceTimer) clearTimeout(filterDebounceTimer) + searchDebounceTimer = null + filterDebounceTimer = null + rowSearch = '' + rowFilters = [] + page = 1 + rawOffset = null + _keysetCursor = null + void loadRows() + } + /** @param {TableSort[]} sorts full ordered sort-key list ([] clears; [primary, …secondary]) */ async function handleRowSortChange(sorts) { const list = Array.isArray(sorts) ? sorts.filter((s) => s?.column) : (sorts ? [sorts] : []) @@ -9338,6 +9352,8 @@ let rowSearch = $state('') {loadingMore} {infiniteScroll} endOfResults={infiniteScroll && !windowed && total > 0 && rows.length >= total} + filtered={rowSearch.trim() !== '' || activeFilters(rowFilters).length > 0} + onclearfilters={clearRowFilters} onloadmore={handleLoadMore} saving={savingCell || deletingRows || insertingRow} bind:selected From f62fe0e4473f446b9823dcbff089db0e6d74dd37 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:03:34 +0545 Subject: [PATCH 04/25] Ask before Close All and Close Others discard staged edits Close Tabs to Left/Right asked before throwing away unsaved changes, Close All and Close Others did not. The three now share one confirm, which also resets the visible grid's live edits when its tab is among those closed, as closing a single tab does. --- src/lib/components/StudioShell.svelte | 36 ++++++++++++++++++--------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 608a27d0..f38de673 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -4049,6 +4049,27 @@ let rowSearch = $state('') return true } + /** + * The batch version of confirmDiscardTabChanges: one confirm covers every + * unsaved change in the tabs about to close, and a yes discards them all. + * @param {any[]} closing + */ + async function confirmDiscardManyTabChanges(closing) { + const count = closing.reduce((n, t) => n + tabPendingCount(t), 0) + if (count === 0) return true + const ok = await askConfirm( + `Closing these tabs discards ${count} unsaved change${count === 1 ? '' : 's'}. Close and discard?`, + 'Close & discard', + ) + if (!ok) return false + for (const t of closing) { + if (t.id === activeTabId) resetEdits() + const key = tabTableKey(t) + if (key) clearPendingChanges(key) + } + return true + } + /** @param {string} id */ async function closeTab(id) { const idx = tabs.findIndex((t) => t.id === id) @@ -4124,12 +4145,14 @@ let rowSearch = $state('') async function closeOtherTabs(id) { const keep = tabs.find((t) => t.id === id) if (!keep) return + if (!(await confirmDiscardManyTabChanges(tabs.filter((t) => t.id !== id && !t.pinned)))) return for (const t of tabs) if (t.id !== id && !t.pinned) rememberClosedTab(t) tabs = tabs.filter((t) => t.id === id || t.pinned) await activateTab(keep.id) } async function closeAllTabs() { + if (!(await confirmDiscardManyTabChanges(tabs.filter((t) => !t.pinned)))) return const pinned = tabs.filter((t) => t.pinned) for (const t of tabs) if (!t.pinned) rememberClosedTab(t) if (pinned.length === 0) { @@ -4152,18 +4175,7 @@ let rowSearch = $state('') const idSet = new Set(ids) const toClose = tabs.filter((t) => idSet.has(t.id) && !t.pinned) if (toClose.length === 0) return - const pending = toClose.reduce((n, t) => n + tabPendingCount(t), 0) - if (pending > 0) { - const ok = await askConfirm( - `Closing these tabs discards ${pending} unsaved change${pending === 1 ? '' : 's'}. Close and discard?`, - 'Close & discard', - ) - if (!ok) return - for (const t of toClose) { - const key = tabTableKey(t) - if (key) clearPendingChanges(key) - } - } + if (!(await confirmDiscardManyTabChanges(toClose))) return const closeSet = new Set(toClose.map((t) => t.id)) for (const t of toClose) rememberClosedTab(t) const wasActiveClosed = activeTabId !== null && closeSet.has(activeTabId) From f734ca99c1ca379787e2f3da9b5627dedd942dae Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:03:34 +0545 Subject: [PATCH 05/25] Stop styling Close All Tabs as a destructive action It was red with a trash icon, though every closed tab goes on the reopen stack. Red should mean something can't be undone, so it is now a plain item with the x icon in both the tab menu and the File menu. --- src/lib/components/AppMenuBar.svelte | 2 +- src/lib/components/TabBar.svelte | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/components/AppMenuBar.svelte b/src/lib/components/AppMenuBar.svelte index fe146077..21350988 100644 --- a/src/lib/components/AppMenuBar.svelte +++ b/src/lib/components/AppMenuBar.svelte @@ -80,7 +80,7 @@ {@render item("settings", "Settings…", "openSettings", "Mod+,")} {@render item("x", "Close tab", "closeTab", "Mod+W")} - {@render item("trash-2", "Close all tabs", "closeAllTabs", "Mod+Shift+W")} + {@render item("x", "Close all tabs", "closeAllTabs", "Mod+Shift+W")} diff --git a/src/lib/components/TabBar.svelte b/src/lib/components/TabBar.svelte index 038f361d..1b11baf3 100644 --- a/src/lib/components/TabBar.svelte +++ b/src/lib/components/TabBar.svelte @@ -265,8 +265,8 @@ - - + + Close All Tabs From 93c1a9d462afe93414150560e481cf452f79974b Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:03:34 +0545 Subject: [PATCH 06/25] Measure only the visible head of a long cell when truncating Truncation started with measureText on the whole value, so a 2,000-character Devanagari or markdown chunk was shaped end to end to show its first 60 characters, once per cell scrolled into view. It now measures a head of four times the monospace estimate, which covers narrow fallback glyphs and zero-width marks, and falls back to the whole string only when that head fits. --- src/lib/components/DataTable.svelte | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index dce1fb1a..091ae5dc 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -5524,27 +5524,38 @@ import FilterX from "@lucide/svelte/icons/filter-x"; /** The measuring half of truncText - only reached on a cache miss. */ function _truncMeasure(ctx, text, maxW, len) { + // Only the head of a long value can ever be on screen, so only the head is + // measured. measureText shapes every character it is given, and a long cell + // (a 2,000-character Devanagari or markdown chunk) paid a whole-string + // HarfBuzz pass per cell scrolled into view to show its first 60. Four times + // the monospace estimate covers narrow fallback glyphs and zero-width + // combining marks; a head that still fits falls back to the whole string. + let lim = len + if (_glyphW > 0) { + const cap = Math.ceil(maxW / _glyphW) * 4 + 16 + if (cap < len && ctx.measureText(text.slice(0, cap)).width > maxW) lim = cap + } // For truncation, always measure accurately - the monospace estimate can // over-count for non-ASCII characters (Arabic, CJK, etc.) that fall back // to a narrower font, leaving an apparent gap after the ellipsis. - if (ctx.measureText(text).width <= maxW) return text + if (lim === len && ctx.measureText(text).width <= maxW) return text // Monospace fast path: the slice length is plain arithmetic, verified with a // couple of real measurements (non-ASCII glyphs fall back to narrower fonts, // so a failed verify drops to the exact binary search below). This replaces // ~10 measureText binary-search probes per truncated cell per frame with 2-3. if (_glyphW > 0) { - let k = Math.max(0, Math.min(len - 1, Math.floor(maxW / _glyphW) - 1)) + let k = Math.max(0, Math.min(lim - 1, Math.floor(maxW / _glyphW) - 1)) if (ctx.measureText(text.slice(0, k) + '…').width <= maxW) { // Grow greedily while it still fits (covers narrow fallback glyphs); // bounded so the worst case stays O(1). - for (let step = 0; step < 4 && k < len - 1; step++) { + for (let step = 0; step < 4 && k < lim - 1; step++) { if (ctx.measureText(text.slice(0, k + 1) + '…').width <= maxW) k++ else break } return text.slice(0, k) + '…' } } - let lo = 0, hi = len + let lo = 0, hi = lim while (lo < hi) { const mid = (lo + hi + 1) >> 1 if (ctx.measureText(text.slice(0, mid) + '…').width <= maxW) lo = mid From d09dc1c27a4a374e5736c31f982a975642b6f44a Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:03:34 +0545 Subject: [PATCH 07/25] Hold the previous table under the loading overlay The canvas kept the old table's pixels only for the 220ms grace window, then repainted blank under a spinner that faded in over 300ms. A load just past the window strobed: old table, about 70ms of empty grid, new table. The held frame now stays until the new rows land, dimmed by the overlay, and the fade is gone. A fresh or resized canvas still paints, as before. --- src/lib/components/DataTable.svelte | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/lib/components/DataTable.svelte b/src/lib/components/DataTable.svelte index 091ae5dc..2af1b16c 100644 --- a/src/lib/components/DataTable.svelte +++ b/src/lib/components/DataTable.svelte @@ -6067,11 +6067,13 @@ import FilterX from "@lucide/svelte/icons/filter-x"; }) /** Paint holding. A table switch empties `columns` the moment it starts, and * drawing that frame blanked the grid for the whole round trip - the old - * table, then nothing, then the new one. For the same grace window as the - * spinner the canvas keeps the last table's pixels and the swap lands as one - * frame. Never on a surface that holds nothing yet: a fresh or just-resized - * canvas is blank (or black in WebKit) and has to be painted. */ - const holdPaint = $derived(loading && columns.length === 0 && !loadingVisible) + * table, then nothing, then the new one. The canvas keeps the last table's + * pixels until the new one lands; past the grace window the loading overlay + * dims them. It used to hold only for the grace window, so a load just over + * it strobed: old table, ~70ms of empty grid, new table. Never on a surface + * that holds nothing yet: a fresh or just-resized canvas is blank (or black + * in WebKit) and has to be painted. */ + const holdPaint = $derived(loading && columns.length === 0) let _surfaceHasFrame = false /** Reactivity budget: the pill re-reads at most this often, not per frame. */ const SPAN_PUBLISH_MS = 150 @@ -8656,8 +8658,11 @@ import FilterX from "@lucide/svelte/icons/filter-x"; place of it: swapping the grid out destroyed the canvas on every table open, and a load that landed a frame later flashed a spinner plus a brand-new compositor layer. --> + {#if loadingVisible} -
+
{/if} From 74de85b27f7da0566b2a69286090283cba040bf6 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:04:46 +0545 Subject: [PATCH 08/25] Add a shapes icon and give replace its Hugeicons glyph Objects needs an icon of its own (next commit), so the registry gains 'shapes': Lucide Shapes, Hugeicons Shapes01 and Phosphor Shapes. 'replace' had only a Lucide glyph, so in the default Hugeicons set the Find & replace tab drew in a different stroke and proportion from the five tabs beside it. It now maps to SearchReplace. --- src/lib/icon-registry-phosphor.js | 2 ++ src/lib/icon-registry.js | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/lib/icon-registry-phosphor.js b/src/lib/icon-registry-phosphor.js index d002588b..0c2879af 100644 --- a/src/lib/icon-registry-phosphor.js +++ b/src/lib/icon-registry-phosphor.js @@ -90,6 +90,7 @@ import PhFadersHorizontal from 'phosphor-svelte/lib/FadersHorizontal' import PhSquaresFour from 'phosphor-svelte/lib/SquaresFour' import PhStack from 'phosphor-svelte/lib/Stack' import PhPuzzlePiece from 'phosphor-svelte/lib/PuzzlePiece' +import PhShapes from 'phosphor-svelte/lib/Shapes' import PhWifiHigh from 'phosphor-svelte/lib/WifiHigh' import PhWifiSlash from 'phosphor-svelte/lib/WifiSlash' import PhColumns from 'phosphor-svelte/lib/Columns' @@ -187,6 +188,7 @@ export const PHOSPHOR_MAP = { 'panel-left-open': PhSidebar, 'layers': PhStack, 'blocks': PhPuzzlePiece, + 'shapes': PhShapes, 'globe': PhGlobe, 'wifi': PhWifiHigh, 'wifi-off': PhWifiSlash, diff --git a/src/lib/icon-registry.js b/src/lib/icon-registry.js index 23dcc163..d8d3ed9a 100644 --- a/src/lib/icon-registry.js +++ b/src/lib/icon-registry.js @@ -121,6 +121,7 @@ import Hash from '@lucide/svelte/icons/hash' import Infinity from '@lucide/svelte/icons/infinity' import Keyboard from '@lucide/svelte/icons/keyboard' import Layers from '@lucide/svelte/icons/layers' +import Shapes from '@lucide/svelte/icons/shapes' import LayoutList from '@lucide/svelte/icons/layout-list' import PanelLeftClose from '@lucide/svelte/icons/panel-left-close' import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open' @@ -169,6 +170,7 @@ import { LayoutThreeColumnIcon, SidebarLeftIcon, SidebarLeft01Icon, TextWrapIcon, LeftToRightListNumberIcon, FileDiffIcon, + Shapes01Icon, SearchReplaceIcon, } from '@hugeicons/core-free-icons' /** @type {Record} */ @@ -254,6 +256,8 @@ export const ICON_MAP = { 'share-2': { lucide: Share2, huge: Share08Icon }, 'git-compare': { lucide: GitCompare, huge: GitCompareIcon }, 'blocks': { lucide: Blocks, huge: PuzzleIcon }, + // Database objects (views, routines, types): not 'blocks', which is Extensions. + 'shapes': { lucide: Shapes, huge: Shapes01Icon }, 'globe': { lucide: Globe, huge: GlobalIcon }, 'plug': { lucide: Plug, huge: PlugSocketIcon }, 'notebook-pen': { lucide: NotebookPen, huge: NoteEditIcon }, @@ -266,7 +270,7 @@ export const ICON_MAP = { 'arrow-down-to-line':{ lucide: ArrowDownToLine, huge: Download04Icon }, 'arrow-left-to-line':{ lucide: ArrowLeftToLine, huge: ArrowLeftDoubleIcon }, 'arrow-right-to-line':{ lucide: ArrowRightToLine, huge: ArrowRightDoubleIcon }, - 'replace': { lucide: ReplaceLucide }, + 'replace': { lucide: ReplaceLucide, huge: SearchReplaceIcon }, 'bookmark': { lucide: Bookmark, huge: Bookmark01Icon }, 'box': { lucide: Box, huge: PackageIcon }, 'bug': { lucide: Bug, huge: BugIcon }, From 8b929f4052bf1a56b05067f088a929dcb9aaf5f5 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:04:46 +0545 Subject: [PATCH 09/25] Give the sidebar's Objects tab its own icon Objects used 'blocks', which draws a puzzle piece in Hugeicons and is the Extensions icon everywhere else, so the two read as the same thing. Objects and its empty states now use shapes. --- src/lib/components/Sidebar.svelte | 4 ++-- src/lib/components/SidebarObjects.svelte | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 4bd77153..2b5d6a0d 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -452,7 +452,7 @@ { id: 'databases', label: 'Databases', icon: 'database' }, // Saved queries, in folders: the SQL you keep is what you open next. { id: 'queries', label: 'Queries', icon: 'file-code' }, - { id: 'objects', label: 'Objects', icon: 'blocks' }, + { id: 'objects', label: 'Objects', icon: 'shapes' }, { id: 'recent', label: 'Recent', icon: 'clock' }, // Last, and not a list: find & replace is a tool that works on the table // you already have open, so it belongs where the other panels live rather @@ -540,7 +540,7 @@ */ const TAB_EMPTY = { tables: { icon: 'table-2', title: 'No tables', hint: 'Nothing in this schema yet.' }, - objects: { icon: 'blocks', title: 'No objects', hint: 'Views, functions, triggers and the like show up here.' }, + objects: { icon: 'shapes', title: 'No objects', hint: 'Views, functions, triggers and the like show up here.' }, recent: { icon: 'clock', title: 'No recents', hint: 'Tables you open appear here.' }, databases: { icon: 'database', title: 'No databases', hint: 'Nothing else on this server.' }, } diff --git a/src/lib/components/SidebarObjects.svelte b/src/lib/components/SidebarObjects.svelte index e7ce696f..524e539e 100644 --- a/src/lib/components/SidebarObjects.svelte +++ b/src/lib/components/SidebarObjects.svelte @@ -487,7 +487,7 @@
- +

No views or routines in “{schema}”

{#if family}{@render newMenu('empty')}{/if} From ba4763f6c4a7f1b041a746abdab7bf8713cf37ff Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:04:46 +0545 Subject: [PATCH 10/25] Fix arrow keys on the sidebar tab strip Left and Right never switched tabs. The line after `sidebarTab = SIDEBAR_TABS[next].id` started with `(`, so automatic semicolon insertion joined the two into a call and every press threw "SIDEBAR_TABS[next].id is not a function". The button is read into a variable first now, then the tab is set, then focus moves. --- src/lib/components/Sidebar.svelte | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 2b5d6a0d..69844462 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -1326,10 +1326,11 @@ kbdNav = true const i = SIDEBAR_TABS.findIndex((t) => t.id === sidebarTab) const next = (i + (e.key === "ArrowRight" ? 1 : -1) + SIDEBAR_TABS.length) % SIDEBAR_TABS.length + // Read before assigning: a statement starting with `(` here was + // parsed as a call on the line above, so arrows threw and never moved. + const btn = e.currentTarget.parentElement?.children[next] sidebarTab = SIDEBAR_TABS[next].id - /** @type {HTMLElement | null} */ ( - e.currentTarget.parentElement?.children[next] ?? null - )?.focus() + if (btn instanceof HTMLElement) btn.focus() }} >
- {#if sidebarTab === 'queries'} - {@render queriesPanel?.()} - {:else if sidebarTab === 'objects'} - {#if connectionName} + + {#if panelMounted('queries')} +
+ {@render queriesPanel?.()} +
+ {/if} + {#if connectionName && panelMounted('objects')} +
- {/if} - {:else if sidebarTab === 'search'} +
+ {/if} + {#if activePanel === 'search'} - {:else} -
+ {/if} + + {#snippet listPanel(/** @type {ListTab} */ lt)}
- - {#if showDatabases && canSwitchDb && connectionName && !tabIsEmpty} + {#if lt === 'databases' && canSwitchDb && connectionName && !isTabEmpty(lt)}
Databases {#if dbEntries.length > 0} @@ -1848,7 +1894,7 @@
- {#if databasesOpen} + {#if lt === 'databases'} {#if dbEntriesLoading && dbEntries.length === 0} {@render listLoading('Loading databases')} {:else if dbEntriesError && dbEntries.length === 0} @@ -1946,7 +1992,7 @@ {/if} - {#if showRecent && filteredRecent.length > 0 && connectionName} + {#if lt === 'recent' && filteredRecent.length > 0 && connectionName}
Recent {@render countBadge(Math.min(filteredRecent.length, 5), Math.min(recentTabs.length, 5))} @@ -2025,7 +2071,7 @@ {/if} - {#if showPins && filteredPinnedTables.length > 0 && connectionName} + {#if lt === 'tables' && filteredPinnedTables.length > 0 && connectionName}
@@ -2226,10 +2272,10 @@ {/if} - - {#if showTables && !tabIsEmpty} + {#if lt === 'tables' && !isTabEmpty(lt)} - {#if tabIsEmpty} - {@const empty = TAB_EMPTY[sidebarTab]} + {#if isTabEmpty(lt)} + {@const empty = TAB_EMPTY[lt]}
- +

- {tabEmptyFromFilter ? 'No matches' : empty.title} + {isTabEmptyFromFilter(lt) ? 'No matches' : empty.title}

- {#if tabEmptyFromFilter} + {#if isTabEmptyFromFilter(lt)} Nothing here matches “{debouncedFilter}”. - {:else if sidebarTab === 'tables' && activeSchema} + {:else if lt === 'tables' && activeSchema} Nothing in “{activeSchema}” yet. {:else} {empty.hint} {/if}

- {#if tabEmptyFromFilter} + {#if isTabEmptyFromFilter(lt)} - {:else if sidebarTab === 'databases'} + {:else if lt === 'databases'}
@@ -2513,7 +2559,7 @@ {/if}
- {:else if sidebarTab === 'tables'} + {:else if lt === 'tables'}
-
- {/if} + {/snippet} + {#each LIST_TABS as lt (lt)} + {#if panelMounted(lt)} +
+ {@render listPanel(lt)} +
+ {/if} + {/each}
{:else if navSidebarPanel === "connections"} { const key = placeKey const version = $objectsVersion + if (!panelActive) return untrack(() => void load(key, version)) }) @@ -198,7 +202,9 @@ const measure = () => { const h = probe?.getBoundingClientRect().height if (h && h > 0) rowH = h - if (scroller) viewH = scroller.clientHeight + // Hidden (another tab is up) measures 0: keep the last real height, or + // the window would render a handful of rows and fill in after the switch. + if (scroller && scroller.clientHeight > 0) viewH = scroller.clientHeight } measure() const ro = new ResizeObserver(measure) @@ -216,6 +222,16 @@ } }) + // Shown again after another tab: display:none dropped the scroll offset. + $effect(() => { + if (!panelActive || !scroller) return + const at = untrack(() => scrolled.get(placeKey)) + if (at && scroller.scrollTop !== at) { + scroller.scrollTop = at + scrollTop = scroller.scrollTop + } + }) + // ── Focus, selection, keyboard ────────────────────────────────────────── /** The active row: the one clicked or arrowed to. Kept by key, so it survives a refresh. */ let active = $state('') From c1deac8e7bcfe9265b6efca5b62776fcfdfe2684 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:04:46 +0545 Subject: [PATCH 13/25] Don't say "No databases" while the list is loading The Databases tab counted as empty from the moment it opened until its list arrived, so a first visit read "No databases. Nothing else on this server." for about 200ms, with the loading dots unreachable behind it. A pending or failed load now shows the section's dots or its error instead. --- src/lib/components/Sidebar.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 3b186a85..d5770d92 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -558,11 +558,16 @@ recent: { icon: 'clock', title: 'No recents', hint: 'Tables you open appear here.' }, databases: { icon: 'database', title: 'No databases', hint: 'Nothing else on this server.' }, } + /** No databases listed yet, but a fetch is owed, running or failed: the + * section shows its dots or its error, never "No databases". */ + const dbHeld = $derived( + canSwitchDb && dbEntries.length === 0 && (dbEntriesLoading || !dbEntriesLoaded || !!dbEntriesError), + ) /** @param {SidebarTab} t */ const isTabEmpty = (t) => // Objects always draws its group headers: an empty group still has its +. t !== 'search' && t !== 'queries' && t !== 'objects' && !loadingTables && !!connectionName && - tabCounts[t] === 0 + tabCounts[t] === 0 && !(t === 'databases' && dbHeld) /** True when the tab has rows but the filter hid all of them. @param {SidebarTab} t */ const isTabEmptyFromFilter = (t) => isTabEmpty(t) && From 91a29636a732cf75553058640409853fcf132e97 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:04:46 +0545 Subject: [PATCH 14/25] Keep the sidebar's top bar the same width on every tab Objects dropped the display options button, and Queries and Find & replace showed table actions that did nothing for their panels, so the button group changed width from tab to tab. A spacer holds the slot on Objects, and the table actions keep their space but are hidden on Queries and Find & replace. --- src/lib/components/Sidebar.svelte | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index d5770d92..a9d496ec 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -1400,7 +1400,9 @@
{#if sidebarTab === 'objects'} + needs no header row of its own. The spacer holds the display + options' slot, so the group keeps its width across tabs. --> + {/if} +
{/if}
From 38628281506d07199870993162045709a5e2aa5d Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:04:46 +0545 Subject: [PATCH 15/25] Keep Find & replace's text across sidebar tab switches The panel remounts on every visit and kept what was typed in its own state, so going to Tables and back cleared the find and replace fields. The text and the regex, whole-value and case options are bindable now, held by the sidebar. --- src/lib/components/FindReplacePanel.svelte | 26 +++++++++++----------- src/lib/components/Sidebar.svelte | 13 ++++++++++- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/lib/components/FindReplacePanel.svelte b/src/lib/components/FindReplacePanel.svelte index 30deecea..110af8e2 100644 --- a/src/lib/components/FindReplacePanel.svelte +++ b/src/lib/components/FindReplacePanel.svelte @@ -46,6 +46,13 @@ onreveal = /** @type {(rowIdx: number, colIdx: number) => void} */ (() => {}), /** Assigned here; the shell calls it to focus the Find field. */ focusFind = $bindable(/** @type {() => void} */ (() => {})), + // What was typed and how it matches. Bindable so the sidebar can hold them: + // the panel remounts on every visit, and local state came back empty. + findText = $bindable(''), + replaceText = $bindable(''), + useRegex = $bindable(false), + wholeValue = $bindable(false), + caseSensitive = $bindable(false), } = $props() /** @@ -58,19 +65,12 @@ */ const ANY_COLUMN = -2 let colIdx = $state(-1) - /** - * The three matching options are independent, the way they are in an editor's - * find widget. They were two booleans and a three-way `mode`, so "whole - * value" and "regular expression" were the same field and turning one on - * turned the other off - only ever two of the three lit up, and the pair that - * combines most usefully (a pattern that has to match the whole cell) could - * not be expressed at all. - */ - let useRegex = $state(false) - let wholeValue = $state(false) - let caseSensitive = $state(false) - let findText = $state('') - let replaceText = $state('') + // The three matching options (useRegex, wholeValue, caseSensitive, props + // above) are independent, the way they are in an editor's find widget. They + // were two booleans and a three-way `mode`, so "whole value" and "regular + // expression" were the same field and turning one on turned the other off - + // only ever two of the three lit up, and the pair that combines most usefully + // (a pattern that has to match the whole cell) could not be expressed at all. let applying = $state(false) /** @type {HTMLInputElement | null} */ let findEl = $state(null) diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index a9d496ec..909051c6 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -473,7 +473,7 @@ // Rebuilding one on every switch threw its DOM and scroll away, and the // rebuild was the flash between tabs. Find & replace still mounts per visit: // it previews matches against the open table's rows, which a hidden copy - // would keep recomputing. + // would keep recomputing (its typed text lives here, so it survives). const activePanel = $derived(sidebarTab) let seenPanels = $state(new Set([untrack(() => sidebarTab)])) $effect(() => { @@ -482,6 +482,12 @@ }) /** @param {string} p */ const panelMounted = (p) => activePanel === p || seenPanels.has(p) + // Find & replace's inputs, held here because the panel itself remounts. + let frFind = $state('') + let frReplace = $state('') + let frRegex = $state(false) + let frWhole = $state(false) + let frCase = $state(false) $effect(() => { openFindReplace = () => { @@ -1763,6 +1769,11 @@ {#if activePanel === 'search'} Date: Thu, 29 Oct 2026 13:05:55 +0545 Subject: [PATCH 16/25] Keep the toolbar pager's shape while a table opens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening a table set total to 0, which unmounted the range readout, the page picker and the arrows until the rows landed, then brought them back and moved them again when the count arrived. The page-size select jumped up to 179px each way. Now: - openTableTab seeds total = -1, the "counting" shape, and keeps the last fetch time instead of zeroing it. - The readout sits in a fixed-width box with its text held left, so "…" becoming a number grows into the box. - Outside page size All the page picker is always there, disabled on a single page. Under All it never shows, and while counting the range reads the rows loaded, not "1-1". - The Mod+F hint no longer waits for columns. --- src/lib/components/StudioShell.svelte | 7 +++++-- src/lib/components/TableToolbar.svelte | 27 ++++++++++++++++++++------ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index f38de673..174fae00 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -4504,8 +4504,10 @@ let rowSearch = $state('') primaryKey = [] foreignKeys = [] rows = [] - total = 0 - queryMs = 0 + // -1 is "counting": the toolbar keeps its pager shape from the first frame + // instead of collapsing while the rows load. queryMs keeps the last fetch's + // time until this one lands, so the status bar doesn't drop an item. + total = -1 error = '' selected = new Set() focusedRow = null @@ -9253,6 +9255,7 @@ let rowSearch = $state('') {pageSize} offset={currentOffset} {total} + loaded={rows.length} {columns} {rowSearch} {rowSort} diff --git a/src/lib/components/TableToolbar.svelte b/src/lib/components/TableToolbar.svelte index c781f75f..20611bb6 100644 --- a/src/lib/components/TableToolbar.svelte +++ b/src/lib/components/TableToolbar.svelte @@ -61,6 +61,8 @@ page = 1, pageSize = 50, total = 0, + /** Rows currently loaded, for the range readout under page size All. */ + loaded = 0, loading = false, selectedCount = 0, hasPrimaryKey = false, @@ -215,7 +217,7 @@ */ const searchHintCaps = $derived(keycaps('Mod+F')); const searchHint = $derived( - tableViewMode !== 'structure' && !searchFocused && !localSearch && columns.length > 0, + tableViewMode !== 'structure' && !searchFocused && !localSearch, ); /** * The toggles sit in the field's trailing corner, and only once there is @@ -285,8 +287,15 @@ // row data has already loaded). Show "…" for the unknown total and keep Next // enabled so navigation isn't blocked during the brief counting window. const counting = $derived(total < 0); + const isAll = $derived(pageSize === PAGE_SIZE_ALL); const from = $derived(total === 0 ? 0 : offset + 1); - const to = $derived(counting ? offset + _effectivePageSize : Math.min(offset + _effectivePageSize, total)); + // Under All the page is whatever has loaded; the page size is 1 until the + // count lands, which read as "1-1 of …". + const to = $derived( + counting + ? offset + (isAll ? Math.max(1, loaded) : _effectivePageSize) + : Math.min(offset + _effectivePageSize, total), + ); const pageCount = $derived(Math.max(1, Math.ceil(total / _effectivePageSize) || 1)); const canPrev = $derived(page > 1); const canNext = $derived(keysetMode ? keysetHasMore : (counting || page * _effectivePageSize < total)); @@ -1212,8 +1221,11 @@ {/if} {:else} {#if total > 0 || counting} + {from.toLocaleString("en-US")}-{to.toLocaleString("en-US")} @@ -1273,12 +1285,15 @@ > - {:else if counting || pageCount > 1} + {:else if !isAll} + { if (v) onpagechange(Number(v)); }} - disabled={loading || total === 0} + disabled={loading || total === 0 || (!counting && pageCount <= 1)} > {page} @@ -1291,7 +1306,7 @@ of {counting ? "…" : formatCompactCount(pageCount)} From 019a101f7e4e67eaa27ccd4c4dd09a77a57a2025 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:05:55 +0545 Subject: [PATCH 17/25] Keep the status bar's column jumps and timing in place The go-to-first and go-to-last column buttons were removed whenever the grid fit, and the fetch time only rendered once a time was known, so both left and came back around every table open and shifted what sat beside them. The buttons are disabled instead of removed, and on table tabs the timing has a fixed slot. --- src/lib/components/StatusBar.svelte | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 16d55d87..3b780f1d 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -632,14 +632,14 @@ - {#if canScrollTableHorizontally} - - - {/if} + + + {/if} @@ -669,12 +669,14 @@ {/if} - {#if queryMs > 0} + {#if showTableNav || queryMs > 0} {@render sep()} + {queryMsLabel} + class="min-w-[6ch] shrink-0 px-1 font-mono text-ui-2xs tabular-nums text-muted-foreground" + title={queryMs > 0 ? `Last data fetch took ${queryMs.toLocaleString('en-US')}ms` : undefined} + >{queryMs > 0 ? queryMsLabel : ''} {/if} From 88fd8e53af219055149616f59aac7226872b135d Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:05:56 +0545 Subject: [PATCH 18/25] Show the active filter count as a corner badge The filter button grew from 28 to 42px to fit the count inline, which moved the five buttons after it 14px every time a filter value was typed or cleared. The count is a badge on the corner now, and the button's accessible name says how many filters are active. --- src/lib/components/TableToolbar.svelte | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/components/TableToolbar.svelte b/src/lib/components/TableToolbar.svelte index 20611bb6..64895f62 100644 --- a/src/lib/components/TableToolbar.svelte +++ b/src/lib/components/TableToolbar.svelte @@ -995,11 +995,11 @@ type="button" class={cn( iconBtn, - "shrink-0", - filterCount > 0 ? "gap-1 !w-auto px-2" : "", + "relative shrink-0", (filterCount > 0 || filterBarOpen) && "bg-accent text-foreground", )} title="Filter rows ({KEY.filter})" + aria-label={filterCount > 0 ? `Filter rows, ${filterCount} active` : "Filter rows"} disabled={loading || columns.length === 0} onclick={() => { if (filterBarOpen) filterBarOpen = false; @@ -1007,8 +1007,10 @@ }} > + {#if filterCount > 0} - + {/if} From 13fd31bb488e715ad5e2c0015ec0f660e82eade8 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:05:56 +0545 Subject: [PATCH 19/25] Keep the sidebar list and its counts up during a refresh A refresh swapped the table list for loading dots, which reset its scroll, and the reply's unknown counts blanked counts the sidebar already had until the recount waves came back. loadTables now remembers which connection and schema the list on screen belongs to: re-reading that same list stays quiet and keeps known counts, while tables the reply left unknown are still recounted. A new connection or schema shows the dots as before. --- src/lib/components/StudioShell.svelte | 40 +++++++++++++++++++-------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 174fae00..8dd09acc 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -4998,6 +4998,9 @@ let rowSearch = $state('') /** @param {{ force?: boolean }} [opts] */ /** `quiet` re-lists without the loading state, so the sidebar keeps its rows, * scroll and focus while a refresh after a write lands. */ + /** Catalog key of the list in `tables`, so a refresh can tell it is re-reading the same one. */ + let _tablesKey = '' + async function loadTables({ force = false, quiet = false } = {}) { if (!activeSchema) { tables = [] @@ -5009,16 +5012,24 @@ let rowSearch = $state('') const schemaAtCall = activeSchema const connAtCall = persistConnectionId const key = catalogKey(persistConnectionId, 'tables', schemaAtCall) + // Re-reading the list already on screen (a refresh) keeps it up and keeps the + // counts it knows. Swapping it for loading dots reset the sidebar's scroll + // and blanked every count until the recount waves came back. + const sameList = tables.length > 0 && _tablesKey === key + const prevCounts = sameList ? new Map(tables.map((t) => [t.name, t.rowCount])) : null + /** Tables whose count the reply left unknown: recounted even when a known one is kept. */ + let replyUnknown = /** @type {string[] | null} */ (null) if (force) invalidateCatalog() const cached = force ? undefined : /** @type {any[] | undefined} */ (_catalog.get(key, TABLE_LIST_TTL_MS)) if (cached) { tables = cached + _tablesKey = key loadingTables = false if (activeTable && !tables.find((t) => t.name === activeTable)) { activeTable = tables[0]?.name ?? null } } else { - if (!quiet) loadingTables = true + if (!quiet && !sameList) loadingTables = true error = '' try { const list = await listTables(schemaAtCall) @@ -5027,17 +5038,24 @@ let rowSearch = $state('') // _catalog.set below would file them under whichever connection id is // current - poisoning that connection's cache for the whole TTL. if (connectionMoved(connAtCall)) return + replyUnknown = [] tables = list - .map((t) => ({ - name: t.name ?? t.table_name ?? '', - rowCount: normalizeTableRowCount(t.rowCount ?? t.row_count), - kind: t.kind ?? 'table', - rlsEnabled: t.rlsEnabled ?? null, - // For the sidebar's Created sort (see compareCreated). - createdAt: t.createdAt ?? null, - createOrder: t.createOrder ?? null, - })) + .map((t) => { + const name = t.name ?? t.table_name ?? '' + const fresh = normalizeTableRowCount(t.rowCount ?? t.row_count) + if (fresh === null) replyUnknown?.push(name) + return { + name, + rowCount: fresh ?? prevCounts?.get(name) ?? null, + kind: t.kind ?? 'table', + rlsEnabled: t.rlsEnabled ?? null, + // For the sidebar's Created sort (see compareCreated). + createdAt: t.createdAt ?? null, + createOrder: t.createOrder ?? null, + } + }) .filter((t) => t.name) + _tablesKey = key _catalog.set(key, tables) if (activeTable && !tables.find((t) => t.name === activeTable)) { activeTable = tables[0]?.name ?? null @@ -5055,7 +5073,7 @@ let rowSearch = $state('') // The list arrives with unknown (null) counts so it renders immediately; // resolve them with exact COUNT(*)s in the background and patch them in. // Covers the cached path too - a list cached mid-resolve may still hold nulls. - const unresolved = tables.filter((t) => t.rowCount === null).map((t) => t.name) + const unresolved = replyUnknown ?? tables.filter((t) => t.rowCount === null).map((t) => t.name) if (unresolved.length > 0) void resolveRowCounts(key, schemaAtCall, unresolved) // The schema-level catalog rides the same cache, so returning to a schema // costs nothing. A forced reload has already invalidated it above. From 0e59471dab60a6a25e1ef63a3ed54fbcd015ebe3 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:05:56 +0545 Subject: [PATCH 20/25] Keep the row count on screen while it is recounted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The count taken when a table opens never set _countedView, so the first page change or refresh afterwards dropped total back to -1 and the readout flashed "…" through another COUNT(*). The open path records the counted view now, and a recount of the same table under the same predicate keeps the last number until the new one lands. --- src/lib/components/StudioShell.svelte | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 8dd09acc..7ba3c241 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -5461,6 +5461,9 @@ let rowSearch = $state('') if (tabId === activeTabId) { total = n _totalSig = predicateSig(apiSearch(s.rowSearch), s.rowFilters ?? []) + // Recorded like refreshRowCount does, or the first page change + // after opening counted again and flashed "…" while it did. + _countedView = rowCountKey(s.schema, s.table, _totalSig) } } } catch { /* best-effort */ } @@ -6080,7 +6083,11 @@ let rowSearch = $state('') // would blank the total and force the COUNT(*) the skip below exists to // avoid, and the footer would flicker through "counting" on each page. const incoming = Number(data.total ?? 0) - total = incoming >= 0 ? incoming : (_countedView === viewKey ? total : incoming) + // A recount of the same table under the same predicate (a refresh, or + // coming back to a tab) shows the last number until the new one lands, + // not "…": `total` and `_totalSig` here are this tab's, restored with it. + const sameView = _countedView === viewKey || (total > 0 && _totalSig === ownerSig) + total = incoming >= 0 ? incoming : (sameView ? total : incoming) _totalSig = ownerSig } queryMs = ranMs From 89b3b3190da133122b4bda2c029dc7c73c0b1e16 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:05:56 +0545 Subject: [PATCH 21/25] Keep the open table up while the schema changes Changing schema cleared the active table before the new table list loaded, so the main area showed "Select a table" for a moment, then the old toolbar over an empty grid, and the filter bar remounted and pushed the grid down 82px. The old table now stays until the list lands, then the tab moves to the first table in one step under the grid's held frame. The previous table's search, filters and sort are cleared on the way, the tab title follows, and the filter bar stays mounted through a reload. --- src/lib/components/StudioShell.svelte | 31 +++++++++++++++++--------- src/lib/components/TableToolbar.svelte | 4 +++- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 7ba3c241..977b6f67 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -6963,19 +6963,30 @@ let rowSearch = $state('') if (connectionLost) await reconnectPool() activeSchema = schema setLastSchema(persistConnectionId, schema) - activeTable = null page = 1 tableFilter = '' - columns = [] - rows = [] + // The open table stays on screen while the new schema's list loads, then + // swaps in one step under the grid's held frame. Blanking it first showed + // the "select a table" screen, then the old toolbar over an empty grid. await loadTables() - if (activeTab?.kind === 'table') { - if (tables.length) { - activeTable = tables[0].name - await loadRows() - } else { - saveActiveTabState() - } + if (activeTab?.kind === 'table' && tables.length) { + activeTable = tables[0].name + // The old table's search, filters and sort mean nothing on this one. + rowSearch = '' + rowFilters = [] + rowSort = null + rowSortMore = [] + filterBarOpen = false + columns = [] + rows = [] + total = -1 + saveActiveTabState() + await loadRows() + } else { + activeTable = null + columns = [] + rows = [] + if (activeTab?.kind === 'table') saveActiveTabState() } } diff --git a/src/lib/components/TableToolbar.svelte b/src/lib/components/TableToolbar.svelte index 64895f62..bab33541 100644 --- a/src/lib/components/TableToolbar.svelte +++ b/src/lib/components/TableToolbar.svelte @@ -1638,7 +1638,9 @@ - {#if filterBarOpen && columns.length > 0 && tableViewMode !== "structure"} + + {#if filterBarOpen && (columns.length > 0 || loading) && tableViewMode !== "structure"}
{#each rowFilters as filter, i (filter.id)} {@const colKind = getColKind(filter.column)} From 2989435c246751d1975fb878bc55e33f987d5454 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:05:56 +0545 Subject: [PATCH 22/25] Show the connect screen at once when switching connections A switch unmounts the shell before the new connection exists, and the overlay held its ring and label back 350ms, so a fast switch was about a third of a second of plain black window. The hold-back now applies only to the startup reconnect it was added for. --- src/lib/components/StudioShell.svelte | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 977b6f67..871d987c 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -8318,9 +8318,12 @@ let rowSearch = $state('') + void cancelAutoConnect()} />
From c7784242d9ca2d0495cc3de5bc203990071b294e Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:05:57 +0545 Subject: [PATCH 23/25] Keep columns and the count when a cold tab's rows are evicted Under page size All, a large tab that fell out of the three-tab row cache lost its columns along with its rows, so coming back was a full cold load: the loading overlay, the pager collapsing and the count blinking. Eviction now drops only the rows and marks the tab, which refetches its rows on return with the header and toolbar already in place, and a refetch keeps the tab's known count until the background count lands. --- src/lib/components/StudioShell.svelte | 13 +++++++++++-- src/lib/studio-tabs.js | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/lib/components/StudioShell.svelte b/src/lib/components/StudioShell.svelte index 871d987c..070eeea6 100644 --- a/src/lib/components/StudioShell.svelte +++ b/src/lib/components/StudioShell.svelte @@ -2426,9 +2426,10 @@ let rowSearch = $state('') activeSchema = raw.schema await loadTables() } - if (raw.columns.length === 0) { + if (raw.columns.length === 0 || raw.evicted) { // No cached data - apply lightweight snapshot (no need to clone rows) if (windowed) resetWindowing() + if (raw.evicted) patchTableTab(tab.id, { evicted: false }) applyTableSnapshot(raw, tab.id) // A fetch this tab already has in flight (including a windowed loadRows // it started before you left) keeps running and lands here on its own - @@ -4017,7 +4018,10 @@ let rowSearch = $state('') const st = /** @type {TableTabState} */ (t.state) // windowedHead cleared too: with the array gone the tab has to refetch, and // the restore path must not look for a cache that no longer exists. - return { ...t, state: { ...st, rows: [], columns: [], selected: new Set(), windowedHead: false, windowedLoaded: [], windowCount: 0, loadingRows: false } } + // Only the rows go. Columns and total stay, so coming back draws the header + // and the toolbar at once and refetches the body, instead of a cold load + // that collapsed the pager and covered the grid. + return { ...t, state: { ...st, rows: [], selected: new Set(), windowedHead: false, windowedLoaded: [], windowCount: 0, loadingRows: false, evicted: true } } }) } @@ -5420,6 +5424,11 @@ let rowSearch = $state('') } catch { /* keep the empty result; the grid shows its empty state */ } } + // A tab that already knew its count (evicted while cold, or restored) keeps + // it until the background count lands, rather than flashing "…". The state + // saves total and filters together, so the number is for this predicate. + if (result.total < 0 && s.total > 0) result.total = s.total + // Persist result to tab - one tabs write patchTab(result) diff --git a/src/lib/studio-tabs.js b/src/lib/studio-tabs.js index c7c5c8d4..e3fb0e76 100644 --- a/src/lib/studio-tabs.js +++ b/src/lib/studio-tabs.js @@ -45,6 +45,7 @@ import { loadDefaultPageSize } from '$lib/table-query.js' * @property {number} [windowBase] - absolute row offset the windowed view starts at * @property {number} [windowCount] - rows the windowed view covers * @property {number} [windowBytesPerRow] - measured payload per row + * @property {boolean} [evicted] - rows were dropped while the tab sat cold; columns and total were kept, so it refetches rows only * @property {{ sortColumn: string, sortDirection: string, sorts: Array<{column:string,direction:string}> } | null} [windowOrder] - the one total order every window of this view slices */ From cc0edf6d014689831182cff946211fbb5fb578c8 Mon Sep 17 00:00:00 2001 From: broisnischal Date: Thu, 29 Oct 2026 13:05:57 +0545 Subject: [PATCH 24/25] Add the changeset for the grid and sidebar smoothness work --- .changeset/smooth-grid-and-sidebar.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/smooth-grid-and-sidebar.md diff --git a/.changeset/smooth-grid-and-sidebar.md b/.changeset/smooth-grid-and-sidebar.md new file mode 100644 index 00000000..696f1637 --- /dev/null +++ b/.changeset/smooth-grid-and-sidebar.md @@ -0,0 +1,18 @@ +### Bug Fixes +- Applying edits the database rejects now shows one message saying how many failed and whether they are still staged, instead of one toast per edit followed by "Changes applied" +- A search or filter that matches nothing now says "No rows match these filters" and offers Clear filters, instead of "No rows in this table" +- Close All Tabs and Close Other Tabs now ask before discarding unsaved edits +- Arrow keys on the sidebar tab strip switch tabs again +- The Databases tab no longer says "No databases" while its list is loading +- Find & replace keeps what was typed when switching sidebar tabs + +### Changes +- NULL and empty values are marked in the grid by default, so a NULL no longer looks like the text 'NULL' +- Scrolling through long text columns does less work, because each cell measures only the part that fits +- Opening a table keeps the previous one on screen, dimmed, until the new rows arrive +- The toolbar pager, the status bar and the filter button keep their size and place while a table loads +- Refreshing keeps the sidebar list and its row counts on screen, and the row count no longer blinks back to counting on a refresh or page change +- Changing schema no longer flashes the empty "Select a table" screen +- Switching connections shows the connecting screen straight away instead of a blank window +- Sidebar tabs switch without rebuilding their lists, and each keeps its scroll position +- The Objects tab has its own icon, and Close All Tabs is no longer styled as a destructive action From f0468ac2d88f1209b763f53a7b88a21856496e9b Mon Sep 17 00:00:00 2001 From: broisnischal Date: Fri, 30 Oct 2026 12:14:19 +0545 Subject: [PATCH 25/25] Tighten the changeset for the grid and sidebar release --- .changeset/smooth-grid-and-sidebar.md | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/.changeset/smooth-grid-and-sidebar.md b/.changeset/smooth-grid-and-sidebar.md index 696f1637..2a35d07a 100644 --- a/.changeset/smooth-grid-and-sidebar.md +++ b/.changeset/smooth-grid-and-sidebar.md @@ -1,18 +1,12 @@ ### Bug Fixes -- Applying edits the database rejects now shows one message saying how many failed and whether they are still staged, instead of one toast per edit followed by "Changes applied" -- A search or filter that matches nothing now says "No rows match these filters" and offers Clear filters, instead of "No rows in this table" -- Close All Tabs and Close Other Tabs now ask before discarding unsaved edits -- Arrow keys on the sidebar tab strip switch tabs again -- The Databases tab no longer says "No databases" while its list is loading -- Find & replace keeps what was typed when switching sidebar tabs +- Rejected edits now show one message with the failure count instead of a toast per edit +- Close All Tabs and Close Other Tabs ask before discarding unsaved edits +- Arrow keys switch sidebar tabs again +- Find & replace keeps its text across sidebar tab switches +- A filter that matches nothing says "No rows match these filters" ### Changes -- NULL and empty values are marked in the grid by default, so a NULL no longer looks like the text 'NULL' -- Scrolling through long text columns does less work, because each cell measures only the part that fits -- Opening a table keeps the previous one on screen, dimmed, until the new rows arrive -- The toolbar pager, the status bar and the filter button keep their size and place while a table loads -- Refreshing keeps the sidebar list and its row counts on screen, and the row count no longer blinks back to counting on a refresh or page change -- Changing schema no longer flashes the empty "Select a table" screen -- Switching connections shows the connecting screen straight away instead of a blank window -- Sidebar tabs switch without rebuilding their lists, and each keeps its scroll position -- The Objects tab has its own icon, and Close All Tabs is no longer styled as a destructive action +- Tables, the sidebar and the status bar stay on screen while loading, so there is no flicker or layout shift +- Switching connections shows the connecting screen straight away +- NULL markers are on by default +- Long text columns scroll with less work