diff --git a/.changeset/smooth-grid-and-sidebar.md b/.changeset/smooth-grid-and-sidebar.md new file mode 100644 index 00000000..2a35d07a --- /dev/null +++ b/.changeset/smooth-grid-and-sidebar.md @@ -0,0 +1,12 @@ +### Bug Fixes +- 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 +- 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 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/DataTable.svelte b/src/lib/components/DataTable.svelte index c8d515ff..2af1b16c 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 = '', @@ -2080,6 +2086,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 +2095,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 +2106,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 +2134,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(", ") }); } } @@ -5507,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 @@ -6039,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 @@ -8628,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} @@ -8656,6 +8689,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/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 4bd77153..909051c6 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 @@ -469,6 +469,26 @@ } let sidebarTab = $state(loadSidebarTab()) + // Panels mount on first visit and then stay, hidden while another tab is up. + // 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 (its typed text lives here, so it survives). + const activePanel = $derived(sidebarTab) + let seenPanels = $state(new Set([untrack(() => sidebarTab)])) + $effect(() => { + const p = activePanel + if (!untrack(() => seenPanels.has(p))) seenPanels = new Set([...untrack(() => seenPanels), p]) + }) + /** @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 = () => { sidebarTab = 'search' @@ -540,26 +560,31 @@ */ 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.' }, } - const tabIsEmpty = $derived( - // Objects always draws its group headers: an empty group still has its +. - sidebarTab !== 'search' && sidebarTab !== 'queries' && sidebarTab !== 'objects' && !loadingTables && !!connectionName && tabCounts[sidebarTab] === 0, - ) - /** True when the tab has rows but the filter hid all of them. */ - const tabEmptyFromFilter = $derived( - tabIsEmpty && - !!debouncedFilter && - (sidebarTab === 'tables' - ? regularTablesUnpinned.length + visiblePinnedTables.length > 0 - : sidebarTab === 'objects' - ? views.length + matViews.length > 0 - : sidebarTab === 'databases' - ? dbEntries.length > 0 - : recentTables.length > 0), + /** 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 && !(t === 'databases' && dbHeld) + /** True when the tab has rows but the filter hid all of them. @param {SidebarTab} t */ + const isTabEmptyFromFilter = (t) => + isTabEmpty(t) && + !!debouncedFilter && + (t === 'tables' + ? regularTablesUnpinned.length + visiblePinnedTables.length > 0 + : t === 'objects' + ? views.length + matViews.length > 0 + : t === 'databases' + ? dbEntries.length > 0 + : recentTables.length > 0) /** How many rows each tab holds BEFORE the filter. @type {Record} */ const tabTotals = $derived({ @@ -586,18 +611,16 @@ $effect(() => { try { localStorage.setItem(SIDEBAR_TAB_KEY, sidebarTab) } catch {} }) - // The old per-section visibility flags are now just "is this the open tab". - // Keeping the names means the ~900 lines of list markup below did not have to - // be rewritten to ask a different question. - const showTables = $derived(sidebarTab === 'tables') - const showRecent = $derived(sidebarTab === 'recent') + // Each list tab renders its own panel (see `listPanel` in the markup), which + // asks `t === 'tables'` where the shared panel asked "is this the open tab". // Pinned is a section of Tables, not a tab of its own. It was a fifth icon in // the strip that held, for most connections, nothing at all - and it split // "the tables in this schema" across two places you had to switch between to // see. It renders above the Tables header, which is where the pins already // sorted in `selectableOrder`. - const showPins = $derived(sidebarTab === 'tables') - const showDatabases = $derived(sidebarTab === 'databases') + /** @typedef {'tables' | 'databases' | 'recent'} ListTab */ + /** @type {ListTab[]} */ + const LIST_TABS = ['tables', 'databases', 'recent'] // Nothing collapses any more, so every list in the open tab is open. const recentOpen = true, tablesOpen = true // The engine's own schemas (`pg_catalog`, `information_schema`, `sys`…) are @@ -1151,8 +1174,33 @@ sortDir = 'asc'; } // ── Lists (tables, views, materialized views, databases) ───────────────── - /** @type {HTMLElement | null} */ - let scrollContainerEl = $state(null) + /** Each list tab's scroller. @type {Record} */ + let listScrollers = $state({ tables: null, databases: null, recent: null }) + /** The open list tab's scroller, null on the other tabs: every row query + * (listRowButtons, the pin anchor) stays inside what is on screen. */ + const scrollContainerEl = $derived( + /** @type {Record} */ (listScrollers)[activePanel] ?? null, + ) + + // A display:none scroller loses its offset, so each list puts its own back. + // Below the declarations on purpose: $effect.pre runs the moment it is + // created, so above them the reads hit the TDZ and threw on mount. + /** @type {Record} */ + const listScroll = {} + let lastPanel = untrack(() => activePanel) + $effect.pre(() => { + const p = activePanel + untrack(() => { + const el = /** @type {Record} */ (listScrollers)[lastPanel] + if (lastPanel !== p && el) listScroll[lastPanel] = el.scrollTop + lastPanel = p + }) + }) + $effect(() => { + const el = scrollContainerEl + const at = untrack(() => listScroll[activePanel]) + if (el && at && el.scrollTop !== at) el.scrollTop = at + }) // No windowing, and no `content-visibility` either. The sidebar renders every // row, plainly. @@ -1310,7 +1358,9 @@ // row's own bottom edge. It was a 28px pill with the rail pushed // 7px below it, so the rail floated in the gap between the pill and // the border, attached to neither. - "group/tab relative inline-flex h-9 w-8 shrink-0 items-center justify-center transition-colors disabled:pointer-events-none disabled:opacity-40", + // No colour transition: the rail snaps on the switch, so a fading + // pill and icon left two tabs half-lit for 150ms on every change. + "group/tab relative inline-flex h-9 w-8 shrink-0 items-center justify-center disabled:pointer-events-none disabled:opacity-40", // Two cues, not one. Colour alone does not separate four line icons // at this size, so the selected tab also carries a filled surface and // an accent rail - the same "which panel am I in" signal the VS Code @@ -1326,10 +1376,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() }} >