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()
}}
>
{#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. -->
+
@@ -1212,8 +1223,11 @@
{/if}
{:else}
{#if total > 0 || counting}
+
{from.toLocaleString("en-US")}-{to.toLocaleString("en-US")}
@@ -1273,12 +1287,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 +1308,7 @@
of {counting ? "…" : formatCompactCount(pageCount)}
@@ -1621,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)}
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 },
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`
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
*/