feat(grid): preserve data-grid viewport and selection across tab switches

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-09 22:36:20 +08:00
1 parent 2b26bd6a1a
commit 41b5e51dcc
21 files changed
+1231 -8

No files matched your search

+287 -7
View File
@@ -250,6 +250,7 @@ import { uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrde
import { dataGridColumnLayoutScopeKey, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage"; import { dataGridColumnLayoutScopeKey, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
import { createPendingSelectionSummary, formatSelectionAggregate, formatSelectionAverage, summarizeSelection } from "@/lib/dataGrid/gridSelection"; import { createPendingSelectionSummary, formatSelectionAggregate, formatSelectionAverage, summarizeSelection } from "@/lib/dataGrid/gridSelection";
import { captureDataGridSelection, restoreDataGridSelection, type CaptureDataGridSelectionOptions, type PersistedDataGridSelection } from "@/lib/dataGrid/dataGridSelectionPersistence"; import { captureDataGridSelection, restoreDataGridSelection, type CaptureDataGridSelectionOptions, type PersistedDataGridSelection } from "@/lib/dataGrid/dataGridSelectionPersistence";
import { buildDataGridViewProbe, clampDataGridViewSelection, DATA_GRID_VIEW_SNAPSHOT_RESTORE, consumeDataGridViewSnapshot, peekDataGridViewSnapshot, saveDataGridViewSnapshot, shouldNotifyOverBudgetSelection, type DataGridViewSelectionSnapshot } from "@/lib/dataGrid/dataGridViewStateCache";
import { dataGridFrameCoversRow, dataGridSelectionEdgeMask, dataGridSelectionFrameKindAtCell, dataGridSelectionUsesOuterFrame, resolveDataGridSelectionFrames } from "@/lib/dataGrid/dataGridSelectionFrames"; import { dataGridFrameCoversRow, dataGridSelectionEdgeMask, dataGridSelectionFrameKindAtCell, dataGridSelectionUsesOuterFrame, resolveDataGridSelectionFrames } from "@/lib/dataGrid/dataGridSelectionFrames";
import { import {
createDataGridCellContextMenuItems, createDataGridCellContextMenuItems,
@@ -485,6 +486,13 @@ interface DataGridProps {
cacheKey?: string; cacheKey?: string;
columnWidthCacheKey?: string; columnWidthCacheKey?: string;
pendingStateKey?: string; pendingStateKey?: string;
/**
* Logical-result identity (`QueryTab.resultViewGeneration`) the grid is
* currently rendering. The tab-switch view snapshot is captured with this
* value and replayed only when it still matches, so a replaced dataset never
* adopts a stale viewport or selection (#7341).
*/
viewGeneration?: string;
exportSql?: string; exportSql?: string;
onExecuteSql?: (sql: string) => Promise<void>; onExecuteSql?: (sql: string) => Promise<void>;
fullExportResult?: (onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => Promise<QueryResult | undefined>; fullExportResult?: (onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => Promise<QueryResult | undefined>;
@@ -1197,6 +1205,11 @@ const filterBuilderOpen = filterBuilder.open;
const filterBuilderColumnSearch = filterBuilder.columnSearch; const filterBuilderColumnSearch = filterBuilder.columnSearch;
const filteredFilterBuilderColumnOptions = filterBuilder.filteredColumns; const filteredFilterBuilderColumnOptions = filterBuilder.filteredColumns;
const appliedStructuredWhereInput = filterBuilder.appliedWhereInput; const appliedStructuredWhereInput = filterBuilder.appliedWhereInput;
// Structured filter rules are restored asynchronously. A tab-switch snapshot's
// probe includes the applied condition, so restoring before this hydration
// settles would reject an otherwise valid snapshot and never retry it.
const structuredFilterHydrationReady = ref(true);
let structuredFilterHydrationRequestId = 0;
const draftStructuredWhereInput = ref(""); const draftStructuredWhereInput = ref("");
const filterEditorView = computed(() => settingsStore.editorSettings.dataGridFilterEditorView); const filterEditorView = computed(() => settingsStore.editorSettings.dataGridFilterEditorView);
const isPersistentFilterView = computed(() => filterEditorView.value === "conditions" || filterEditorView.value === "text"); const isPersistentFilterView = computed(() => filterEditorView.value === "conditions" || filterEditorView.value === "text");
@@ -1464,6 +1477,8 @@ function persistStructuredFilterState() {
} }
function loadStructuredFilterStateForScope() { function loadStructuredFilterStateForScope() {
const requestId = ++structuredFilterHydrationRequestId;
structuredFilterHydrationReady.value = false;
const cached = cachedStructuredFilterState(); const cached = cachedStructuredFilterState();
if (cached) { if (cached) {
const cacheKey = structuredFilterCacheKey.value; const cacheKey = structuredFilterCacheKey.value;
@@ -1472,14 +1487,23 @@ function loadStructuredFilterStateForScope() {
whereFilterInput.value = cached.manualWhereInput; whereFilterInput.value = cached.manualWhereInput;
serverColumnFilters.value = structuredClone(cached.serverColumnFilters ?? {}); serverColumnFilters.value = structuredClone(cached.serverColumnFilters ?? {});
appliedStructuredWhereInput.value = ""; appliedStructuredWhereInput.value = "";
void buildStructuredWhereFromRules(structuredFilterRules.value).then((whereInput) => { void buildStructuredWhereFromRules(structuredFilterRules.value)
if (structuredFilterCacheKey.value !== cacheKey || structuredFilterScopeKey.value !== scopeKey) return; .then((whereInput) => {
appliedStructuredWhereInput.value = whereInput; if (requestId !== structuredFilterHydrationRequestId || structuredFilterCacheKey.value !== cacheKey || structuredFilterScopeKey.value !== scopeKey) return;
nextTick(() => { appliedStructuredWhereInput.value = whereInput;
emit("update:whereInput", currentWhereInput() ?? ""); nextTick(() => {
markConditionInputsApplied(); emit("update:whereInput", currentWhereInput() ?? "");
markConditionInputsApplied();
});
})
.catch(() => undefined)
.finally(() => {
if (requestId !== structuredFilterHydrationRequestId || structuredFilterCacheKey.value !== cacheKey || structuredFilterScopeKey.value !== scopeKey) return;
structuredFilterHydrationReady.value = true;
// The initial mounted restore may have run while the applied condition
// was empty; retry exactly after the async condition is settled.
nextTick(restoreTabSwitchViewSnapshot);
}); });
});
return; return;
} }
appliedStructuredWhereInput.value = ""; appliedStructuredWhereInput.value = "";
@@ -1487,6 +1511,7 @@ function loadStructuredFilterStateForScope() {
structuredFilterRules.value = filterBuilderColumnOptions.value.length > 0 ? [defaultStructuredFilterRule()] : []; structuredFilterRules.value = filterBuilderColumnOptions.value.length > 0 ? [defaultStructuredFilterRule()] : [];
persistStructuredFilterState(); persistStructuredFilterState();
markConditionInputsApplied(); markConditionInputsApplied();
structuredFilterHydrationReady.value = true;
} }
function ensureStructuredFilterRule() { function ensureStructuredFilterRule() {
@@ -4925,6 +4950,251 @@ function restoreSelectionAfterRefresh(snapshot: PersistedDataGridSelection) {
}); });
} }
/** Bounded settling envelope for a replayed tab-switch viewport. */
const MAX_VIEW_SNAPSHOT_RESTORE_FRAMES = 8;
let viewSnapshotRestoreFrame = 0;
function cancelViewSnapshotRestoreFrame() {
if (viewSnapshotRestoreFrame) cancelAnimationFrame(viewSnapshotRestoreFrame);
viewSnapshotRestoreFrame = 0;
}
/** Bounded integrity probe of the result the grid is currently rendering. */
function currentViewProbe(): string {
const rows = props.result.rows;
return buildDataGridViewProbe({
columns: props.result.columns,
columnTypes: props.result.column_types,
rowCount: rows.length,
firstRow: rows[0],
lastRow: rows[rows.length - 1],
largeValueCells: props.result.large_value_cells,
navigation: {
whereInput: currentWhereInput(),
orderByInput: orderByInput.value,
pageOffset: props.pageOffset,
pageLimit: pageSize.value,
sortColumn: props.sortColumn,
sortDirection: props.sortDirection,
sortMode: props.sortMode,
},
});
}
/**
* Copy the current selection into display coordinates. O(selection size): a
* generation match already guarantees the same dataset on return, so the
* identity-token machinery of the refresh path (which scans every row) is not
* needed here.
*
* Large row sets are represented compactly: a contiguous range (or "all" when
* every row is selected) costs a single range pair instead of one id per row.
*/
function captureViewSelection(): { selection?: DataGridViewSelectionSnapshot; droppedSelection: boolean } {
const selectedRowCount = selectedRowIds.value.size;
const hasRows = selectedRowCount > 0;
const hasCells = selectedCellKeys.value.size > 0;
const hasColumns = selectedColumnIndexes.value.size > 0;
const hasRange = !!selectionAnchor.value && !!selectionFocus.value;
if (!hasRows && !hasCells && !hasColumns && !hasRange) return { selection: undefined, droppedSelection: false };
if (hasRows) {
const displayCount = displayItems.value.length;
const contiguous = selectedRowCount === displayCount || selectedRowsContiguous(selectedRowIds.value);
if (contiguous) {
const anchorRowIndex = selection.lastClickedRowIndex.value;
if (selectedRowCount === displayCount) {
return { selection: { kind: "all", anchorRowIndex }, droppedSelection: false };
}
return { selection: { kind: "rows", rowIndexes: minimalRowRange(selectedRowIds.value), contiguous: true, anchorRowIndex }, droppedSelection: false };
}
const { selection: capturedRows, droppedSelection } = clampDataGridViewSelection({ kind: "rows", rowIndexes: selectedRowIdsAsDisplayIndexes(selectedRowIds.value), anchorRowIndex: selection.lastClickedRowIndex.value });
return { selection: capturedRows.kind === "rows" && capturedRows.rowIndexes.length ? capturedRows : undefined, droppedSelection };
}
if (hasColumns) {
return { selection: { kind: "columns", columnIndexes: [...selectedColumnIndexes.value] }, droppedSelection: false };
}
if (hasRange) {
return {
selection: { kind: "range", anchor: { ...selectionAnchor.value! }, focus: { ...selectionFocus.value! }, selectingAll: isSelectingAll.value || undefined, lastClickedRowIndex: selection.lastClickedRowIndex.value },
droppedSelection: false,
};
}
const { selection: capturedCells, droppedSelection } = clampDataGridViewSelection({ kind: "cells", cellKeys: [...selectedCellKeys.value], anchor: selectionAnchor.value ? { ...selectionAnchor.value } : undefined, focus: selectionFocus.value ? { ...selectionFocus.value } : undefined });
return { selection: capturedCells.kind === "cells" && capturedCells.cellKeys.length ? capturedCells : undefined, droppedSelection };
}
/** Display indexes of the selected row ids, bounded to the displayed rows. */
function selectedRowIdsAsDisplayIndexes(selectedIds: ReadonlySet<number>): number[] {
const indexes: number[] = [];
for (const rowId of selectedIds) {
const index = displayRowIndexById(rowId);
if (index >= 0) indexes.push(index);
}
return indexes;
}
/**
* True when the selected rows form a contiguous block in display order.
* Iterates only the selected ids via the O(1) index lookup, so a single-row
* selection on a 100k-row result stays O(1) instead of scanning every row.
*/
function selectedRowsContiguous(selectedIds: ReadonlySet<number>): boolean {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
let count = 0;
for (const rowId of selectedIds) {
const index = displayRowIndexById(rowId);
if (index < 0) continue;
if (index < min) min = index;
if (index > max) max = index;
count += 1;
}
return count > 0 && max - min + 1 === count;
}
/** The [first, last] display indexes of the selected rows, when contiguous. */
function minimalRowRange(selectedIds: ReadonlySet<number>): number[] {
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const rowId of selectedIds) {
const index = displayRowIndexById(rowId);
if (index < 0) continue;
if (index < min) min = index;
if (index > max) max = index;
}
return Number.isFinite(min) ? [min, max] : [];
}
/** Expand a [first, last] row range into every display index in between. */
function expandRowRange(range: readonly number[], displayCount: number): number[] {
const [first, last] = range;
if (!Number.isInteger(first) || !Number.isInteger(last) || first < 0 || last < first) return [];
const indexes: number[] = [];
const end = Math.min(last, displayCount - 1);
for (let index = first; index <= end; index += 1) indexes.push(index);
return indexes;
}
/**
* Capture scroll/selection before this instance is unmounted by a tab switch.
* Skips transpose (explicitly out of scope) and grids without an owner key or
* generation, so a snapshot can never be replayed without an identity check.
*/
function captureTabSwitchViewSnapshot() {
if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return;
if (!props.cacheKey || !props.viewGeneration) return;
if (showTranspose.value) return;
const scroller = useCanvasGridRows.value ? canvasScrollerElement() : gridScrollerElement();
if (!scroller) return;
const { selection, droppedSelection } = captureViewSelection();
saveDataGridViewSnapshot({
ownerKey: props.cacheKey,
viewGeneration: props.viewGeneration,
probe: currentViewProbe(),
renderer: useCanvasGridRows.value ? "canvas" : "dom",
rowCount: props.result.rows.length,
columnCount: props.result.columns.length,
viewport: { top: Math.max(0, scroller.scrollTop), left: Math.max(0, scroller.scrollLeft) },
selection,
selectionDropped: droppedSelection || undefined,
});
}
/**
* Replay a tab-switch snapshot captured from a previous instance of the same
* logical result. Every gate must pass; any mismatch degrades to a clean start
* rather than a partial restore.
*/
function restoreTabSwitchViewSnapshot() {
if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return;
if (!structuredFilterHydrationReady.value) return;
const ownerKey = props.cacheKey;
if (!ownerKey || !props.viewGeneration) return;
if (showTranspose.value) return;
const snapshot = peekDataGridViewSnapshot(ownerKey);
if (!snapshot) return;
if (snapshot.viewGeneration !== props.viewGeneration) return;
if (snapshot.renderer !== (useCanvasGridRows.value ? "canvas" : "dom")) return;
if (snapshot.probe !== currentViewProbe()) return;
// The selection rides along for free: indexes stay valid under a generation
// match, and out-of-range entries simply never match any display row.
const capturedSelection = snapshot.selection;
if (capturedSelection) {
if (capturedSelection.kind === "cells") {
if (capturedSelection.cellKeys.length) restoreCellSelectionState({ cellKeys: new Set(capturedSelection.cellKeys) });
} else if (capturedSelection.kind === "range") {
restoreCellSelectionState({ anchor: capturedSelection.anchor, focus: capturedSelection.focus, selectingAll: capturedSelection.selectingAll === true });
if (capturedSelection.lastClickedRowIndex != null) selection.lastClickedRowIndex.value = capturedSelection.lastClickedRowIndex;
} else if (capturedSelection.kind === "columns") {
selectedColumnIndexes.value = new Set(capturedSelection.columnIndexes);
} else if (capturedSelection.kind === "rows") {
const indexes = capturedSelection.contiguous ? expandRowRange(capturedSelection.rowIndexes, displayItems.value.length) : capturedSelection.rowIndexes;
selectedRowIds.value = new Set(indexes.map((index) => displayItems.value[index]?.id).filter((id): id is number => typeof id === "number"));
if (capturedSelection.anchorRowIndex != null) selection.lastClickedRowIndex.value = capturedSelection.anchorRowIndex;
} else if (capturedSelection.kind === "all") {
selectedRowIds.value = new Set(displayItems.value.map((item) => item.id));
if (capturedSelection.anchorRowIndex != null) selection.lastClickedRowIndex.value = capturedSelection.anchorRowIndex;
}
}
// DOM virtualization and canvas layout can report an incomplete scroll height
// for more than a frame, which would clamp the saved position to the top.
// Mirror `restoreScrollAcrossFrames`: immediate, then nextTick, then a
// bounded rAF settling loop that stops once the position sticks.
let settled = false;
let previousMaxTop = -1;
const applyView = (): boolean => {
const scroller = useCanvasGridRows.value ? canvasScrollerElement() : gridScrollerElement();
if (!scroller) return false;
const maxTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
const maxLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
const targetTop = Math.min(Math.max(0, snapshot.viewport.top), maxTop);
const targetLeft = Math.min(Math.max(0, snapshot.viewport.left), maxLeft);
scroller.scrollTop = targetTop;
scroller.scrollLeft = targetLeft;
if (useCanvasGridRows.value) syncCanvasViewport();
const accepted = Math.abs(scroller.scrollTop - targetTop) < 0.5 && Math.abs(scroller.scrollLeft - targetLeft) < 0.5;
// A saved offset of 0 is settled as soon as it is accepted. Otherwise the
// scroller must be tall enough to hold the saved position AND its measured
// geometry must hold still — an unmeasured virtual scroller clamps the
// target to 0 and would otherwise be mistaken for a finished restore.
const roomForSavedTop = maxTop >= snapshot.viewport.top;
const stable = maxTop === previousMaxTop;
previousMaxTop = maxTop;
return accepted && (snapshot.viewport.top === 0 || (roomForSavedTop && stable));
};
const attempt = (): boolean => {
if (settled || !applyView()) return settled;
settled = true;
cancelViewSnapshotRestoreFrame();
consumeDataGridViewSnapshot(ownerKey);
// Report a dropped selection only once the restore it belongs to actually
// happened — never while merely leaving the tab.
if (snapshot.selectionDropped && shouldNotifyOverBudgetSelection(ownerKey, props.viewGeneration!)) {
toast(t("grid.viewSnapshotSelectionNotRestored"), 4000);
}
return true;
};
if (attempt()) return;
nextTick(() => {
if (attempt()) return;
if (typeof requestAnimationFrame !== "function") return;
let frames = 0;
const onFrame = () => {
if (attempt()) return;
frames += 1;
if (frames >= MAX_VIEW_SNAPSHOT_RESTORE_FRAMES) return;
viewSnapshotRestoreFrame = requestAnimationFrame(onFrame);
};
viewSnapshotRestoreFrame = requestAnimationFrame(onFrame);
});
}
const multiRowCount = computed(() => { const multiRowCount = computed(() => {
if (hasRowSelection.value) return selectedRowCount.value; if (hasRowSelection.value) return selectedRowCount.value;
const range = selectedRange.value; const range = selectedRange.value;
@@ -6848,11 +7118,20 @@ onMounted(() => {
window.visualViewport?.addEventListener("resize", refreshDataGridViewportMetrics); window.visualViewport?.addEventListener("resize", refreshDataGridViewportMetrics);
window.addEventListener("dbx:ui-scale-applied", refreshDataGridViewportMetrics); window.addEventListener("dbx:ui-scale-applied", refreshDataGridViewportMetrics);
window.addEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onSynchronizedTableDataGridColumnOrderChanged); window.addEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onSynchronizedTableDataGridColumnOrderChanged);
window.addEventListener("dbx:before-tab-switch", captureTabSwitchViewSnapshot);
window.addEventListener("blur", clearInternalClipboardCopy); window.addEventListener("blur", clearInternalClipboardCopy);
document.addEventListener("visibilitychange", clearInternalClipboardCopy); document.addEventListener("visibilitychange", clearInternalClipboardCopy);
nextTick(restoreTabSwitchViewSnapshot);
}); });
onDeactivated(pauseCanvasGridWork); onDeactivated(pauseCanvasGridWork);
onUnmounted(() => { onUnmounted(() => {
// Capture before teardown: a tab switch unmounts this instance, and the
// snapshot is the only carrier of its viewport/selection. This runs after the
// `dbx:before-tab-switch` capture and wins, because it observes the final
// rendered viewport. Closing a tab blocks both writes via the cache's
// closing-tab tombstone.
captureTabSwitchViewSnapshot();
cancelViewSnapshotRestoreFrame();
onLocalFilterResizeEnd(); onLocalFilterResizeEnd();
dataGridRuntimeScope.dispose(); dataGridRuntimeScope.dispose();
foreignKeyDisplayRequests.dispose(); foreignKeyDisplayRequests.dispose();
@@ -6874,6 +7153,7 @@ onUnmounted(() => {
window.visualViewport?.removeEventListener("resize", refreshDataGridViewportMetrics); window.visualViewport?.removeEventListener("resize", refreshDataGridViewportMetrics);
window.removeEventListener("dbx:ui-scale-applied", refreshDataGridViewportMetrics); window.removeEventListener("dbx:ui-scale-applied", refreshDataGridViewportMetrics);
window.removeEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onSynchronizedTableDataGridColumnOrderChanged); window.removeEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onSynchronizedTableDataGridColumnOrderChanged);
window.removeEventListener("dbx:before-tab-switch", captureTabSwitchViewSnapshot);
window.removeEventListener("blur", clearInternalClipboardCopy); window.removeEventListener("blur", clearInternalClipboardCopy);
document.removeEventListener("visibilitychange", clearInternalClipboardCopy); document.removeEventListener("visibilitychange", clearInternalClipboardCopy);
}); });
@@ -0,0 +1,111 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
function functionSource(name: string, endMarker: string): string {
const start = dataGridSource.indexOf(`function ${name}(`);
const end = dataGridSource.indexOf(endMarker, start + 1);
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
return dataGridSource.slice(start, end);
}
describe("DataGrid tab-switch view snapshots", () => {
const captureSource = functionSource("captureViewSelection", "function captureTabSwitchViewSnapshot(");
const captureFnSource = functionSource("captureTabSwitchViewSnapshot", "function restoreTabSwitchViewSnapshot(");
const restoreSource = functionSource("restoreTabSwitchViewSnapshot", "const multiRowCount = computed(");
it("captures only with an owner key and a generation, and skips transpose", () => {
expect(captureFnSource).toContain("if (!props.cacheKey || !props.viewGeneration) return;");
expect(captureFnSource).toContain("if (showTranspose.value) return;");
expect(captureFnSource).toContain("ownerKey: props.cacheKey");
expect(captureFnSource).toContain("viewGeneration: props.viewGeneration");
expect(captureFnSource).toContain("probe: currentViewProbe()");
});
it("captures the selection in O(selection size), never through the refresh identity scan", () => {
// Copies refs directly instead of captureCurrentSelectionForRefresh, which
// builds an identity token for every row (O(row count) on a large result).
expect(captureFnSource).not.toContain("captureCurrentSelectionForRefresh");
// Contiguous/all row selections serialize as a compact range instead of one
// id per row, and the range helpers iterate only the selected ids via the
// O(1) display index lookup — never the full displayItems.
expect(captureSource).toContain("selectedRowCount === displayCount");
expect(captureSource).toContain("selectedRowsContiguous(selectedRowIds.value)");
expect(captureSource).toContain('kind: "all"');
expect(captureSource).toContain("clampDataGridViewSelection(");
const contiguousHelper = dataGridSource.slice(dataGridSource.indexOf("function selectedRowsContiguous("), dataGridSource.indexOf("function minimalRowRange("));
expect(contiguousHelper).toContain("displayRowIndexById(rowId)");
expect(contiguousHelper).toContain("max - min + 1 === count");
expect(contiguousHelper).not.toContain("displayItems");
});
it("gates every restore on identity, renderer, and probe", () => {
expect(restoreSource).toContain("if (!structuredFilterHydrationReady.value) return;");
expect(restoreSource).toContain("if (snapshot.viewGeneration !== props.viewGeneration) return;");
expect(restoreSource).toContain('if (snapshot.renderer !== (useCanvasGridRows.value ? "canvas" : "dom")) return;');
expect(restoreSource).toContain("if (snapshot.probe !== currentViewProbe()) return;");
expect(restoreSource).toContain("if (showTranspose.value) return;");
});
it("applies the index-based selection directly without scrolling it into view", () => {
expect(restoreSource).toContain("restoreCellSelectionState({ cellKeys: new Set(capturedSelection.cellKeys) })");
expect(restoreSource).toContain('selectedRowIds.value = new Set(indexes.map((index) => displayItems.value[index]?.id).filter((id): id is number => typeof id === "number"));');
expect(restoreSource).toContain("selectedColumnIndexes.value = new Set(capturedSelection.columnIndexes);");
expect(restoreSource).toContain("selectedRowIds.value = new Set(displayItems.value.map((item) => item.id));");
expect(restoreSource).toContain("selection.lastClickedRowIndex.value = capturedSelection.anchorRowIndex;");
expect(restoreSource).not.toContain("restoreSelectionAfterRefresh(snapshot");
});
it("clamps the restored viewport and consumes the snapshot after success", () => {
expect(restoreSource).toContain("Math.min(Math.max(0, snapshot.viewport.top), maxTop)");
expect(restoreSource).toContain("Math.min(Math.max(0, snapshot.viewport.left), maxLeft)");
// A replayed snapshot is consumed so a later remount cannot re-apply it.
expect(restoreSource).toContain("consumeDataGridViewSnapshot(ownerKey);");
});
it("settles the viewport over a bounded retry envelope instead of a fixed two frames", () => {
// Immediate attempt, then nextTick, then at most 8 rAFs — mirroring
// restoreScrollAcrossFrames. A fixed two-frame assumption intermittently
// clamps the saved position to the top before virtualization measures.
expect(restoreSource).toContain("if (attempt()) return;");
expect(restoreSource).toContain("nextTick(() => {");
expect(restoreSource).toContain("frames >= MAX_VIEW_SNAPSHOT_RESTORE_FRAMES");
expect(restoreSource).toContain("viewSnapshotRestoreFrame = requestAnimationFrame(onFrame);");
// An unmeasured scroller clamps the target to 0; that must not count as a
// finished restore, or the saved position is lost at the top.
expect(restoreSource).toContain("const roomForSavedTop = maxTop >= snapshot.viewport.top;");
expect(restoreSource).toContain("return accepted && (snapshot.viewport.top === 0 || (roomForSavedTop && stable));");
expect(dataGridSource).toContain("const MAX_VIEW_SNAPSHOT_RESTORE_FRAMES = 8;");
});
it("honours the rollback switch in both directions", () => {
expect(captureFnSource).toContain("if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return;");
expect(restoreSource).toContain("if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return;");
expect(dataGridSource).toContain("cancelViewSnapshotRestoreFrame();");
});
it("wires capture to unmount and to the pre-tab-switch event", () => {
expect(dataGridSource).toContain('window.addEventListener("dbx:before-tab-switch", captureTabSwitchViewSnapshot)');
expect(dataGridSource).toContain('window.removeEventListener("dbx:before-tab-switch", captureTabSwitchViewSnapshot)');
expect(dataGridSource).toContain("onUnmounted(() => {\n // Capture before teardown");
expect(dataGridSource).toContain("captureTabSwitchViewSnapshot();");
});
it("reports a dropped selection only after a successful restore", () => {
// The capture must not toast while the user is leaving the tab.
expect(captureFnSource).not.toContain("toast(");
expect(restoreSource).toContain("snapshot.selectionDropped && shouldNotifyOverBudgetSelection(ownerKey");
expect(restoreSource).toContain('toast(t("grid.viewSnapshotSelectionNotRestored")');
// The notice stays one-shot per owner+generation.
expect(dataGridSource).toContain("shouldNotifyOverBudgetSelection(ownerKey, props.viewGeneration!)");
});
it("retries after asynchronous structured-filter hydration", () => {
expect(dataGridSource).toContain("structuredFilterHydrationReady.value = false;");
expect(dataGridSource).toContain("structuredFilterHydrationReady.value = true;");
expect(dataGridSource).toContain("nextTick(restoreTabSwitchViewSnapshot);");
expect(dataGridSource).toContain("structuredFilterHydrationRequestId");
});
});
@@ -1842,6 +1842,7 @@ defineExpose({
:cache-key="activeResultGridCacheKey" :cache-key="activeResultGridCacheKey"
:column-width-cache-key="activeResultGridColumnWidthCacheKey" :column-width-cache-key="activeResultGridColumnWidthCacheKey"
:pending-state-key="activeResultGridInstanceKey" :pending-state-key="activeResultGridInstanceKey"
:view-generation="activeTab.resultViewGeneration"
class="flex-1 min-h-0" class="flex-1 min-h-0"
:result="activeTab.result" :result="activeTab.result"
:sort-column="activeTab.resultSortColumn" :sort-column="activeTab.resultSortColumn"
@@ -2270,6 +2271,7 @@ defineExpose({
class="flex-1 min-h-0" class="flex-1 min-h-0"
:key="activeTab.id" :key="activeTab.id"
:cache-key="activeTab.id" :cache-key="activeTab.id"
:view-generation="activeTab.resultViewGeneration"
:result="activeTab.result" :result="activeTab.result"
:sort-column="activeTab.resultSortColumn" :sort-column="activeTab.resultSortColumn"
:sort-column-index="activeTab.resultSortColumnIndex" :sort-column-index="activeTab.resultSortColumnIndex"
+1
View File
@@ -2201,6 +2201,7 @@ export default withEnglishFallback({
queryError: "Sorğu xətası", queryError: "Sorğu xətası",
saveErrorTitle: "Dəyişiklikləri yadda saxlamaq alınmadı", saveErrorTitle: "Dəyişiklikləri yadda saxlamaq alınmadı",
dataUnavailable: "Cədvəl məlumatları yenidən yüklənməlidir.", dataUnavailable: "Cədvəl məlumatları yenidən yüklənməlidir.",
viewSnapshotSelectionNotRestored: "Əvvəlki görünüş bərpa edildi, lakin seçim çox böyük olduğu üçün saxlanılmadı.",
cachedResultUnavailable: "Keşlənmiş nəticə yoxdur və ya uyğun deyil.", cachedResultUnavailable: "Keşlənmiş nəticə yoxdur və ya uyğun deyil.",
reexecuteQuery: "Sorğunu yenidən işlət", reexecuteQuery: "Sorğunu yenidən işlət",
dataUnavailableHintPrefix: "Basın: ", dataUnavailableHintPrefix: "Basın: ",
+1
View File
@@ -2202,6 +2202,7 @@ export default {
queryError: "Query Error", queryError: "Query Error",
saveErrorTitle: "Failed to Save Changes", saveErrorTitle: "Failed to Save Changes",
dataUnavailable: "Table data needs to be reloaded.", dataUnavailable: "Table data needs to be reloaded.",
viewSnapshotSelectionNotRestored: "The previous view was restored, but its selection was too large to keep.",
cachedResultUnavailable: "The cached result is missing or incompatible.", cachedResultUnavailable: "The cached result is missing or incompatible.",
reexecuteQuery: "Run query again", reexecuteQuery: "Run query again",
dataUnavailableHintPrefix: "Press ", dataUnavailableHintPrefix: "Press ",
+1
View File
@@ -2051,6 +2051,7 @@ export default withEnglishFallback({
queryError: "Error de consulta", queryError: "Error de consulta",
saveErrorTitle: "Error al guardar los cambios", saveErrorTitle: "Error al guardar los cambios",
dataUnavailable: "Los datos de la tabla deben volver a cargarse.", dataUnavailable: "Los datos de la tabla deben volver a cargarse.",
viewSnapshotSelectionNotRestored: "Se restauró la vista anterior, pero la selección era demasiado grande para conservarla.",
dataUnavailableHintPrefix: "Presiona ", dataUnavailableHintPrefix: "Presiona ",
dataUnavailableHintSuffix: " o haz clic en Actualizar abajo para recargar.", dataUnavailableHintSuffix: " o haz clic en Actualizar abajo para recargar.",
refresh: "Actualizar", refresh: "Actualizar",
+1
View File
@@ -2049,6 +2049,7 @@ export default withEnglishFallback({
queryError: "Errore Query", queryError: "Errore Query",
saveErrorTitle: "Impossibile salvare le modifiche", saveErrorTitle: "Impossibile salvare le modifiche",
dataUnavailable: "I dati della tabella devono essere ricaricati.", dataUnavailable: "I dati della tabella devono essere ricaricati.",
viewSnapshotSelectionNotRestored: "La vista precedente è stata ripristinata, ma la selezione era troppo grande per essere mantenuta.",
dataUnavailableHintPrefix: "Premi ", dataUnavailableHintPrefix: "Premi ",
dataUnavailableHintSuffix: " o fai clic su Aggiorna qui sotto per ricaricare.", dataUnavailableHintSuffix: " o fai clic su Aggiorna qui sotto per ricaricare.",
refresh: "Aggiorna", refresh: "Aggiorna",
+1
View File
@@ -2061,6 +2061,7 @@ export default withEnglishFallback({
queryError: "クエリエラー", queryError: "クエリエラー",
saveErrorTitle: "変更の保存に失敗しました", saveErrorTitle: "変更の保存に失敗しました",
dataUnavailable: "テーブルデータを再読み込みする必要があります。", dataUnavailable: "テーブルデータを再読み込みする必要があります。",
viewSnapshotSelectionNotRestored: "前回の表示位置は復元しましたが、選択範囲が大きすぎるため復元できませんでした。",
dataUnavailableHintPrefix: "", dataUnavailableHintPrefix: "",
dataUnavailableHintSuffix: " を押すか、下の更新ボタンをクリックして再読み込みしてください。", dataUnavailableHintSuffix: " を押すか、下の更新ボタンをクリックして再読み込みしてください。",
refresh: "更新", refresh: "更新",
+1
View File
@@ -2025,6 +2025,7 @@ export default withEnglishFallback({
queryError: "쿼리 오류", queryError: "쿼리 오류",
saveErrorTitle: "변경사항 저장 실패", saveErrorTitle: "변경사항 저장 실패",
dataUnavailable: "테이블 데이터를 다시 불러와야 합니다.", dataUnavailable: "테이블 데이터를 다시 불러와야 합니다.",
viewSnapshotSelectionNotRestored: "이전 보기 위치는 복원했지만 선택 범위가 너무 커서 복원하지 못했습니다.",
cachedResultUnavailable: "캐시된 결과가 없거나 호환되지 않습니다.", cachedResultUnavailable: "캐시된 결과가 없거나 호환되지 않습니다.",
reexecuteQuery: "쿼리 다시 실행", reexecuteQuery: "쿼리 다시 실행",
dataUnavailableHintPrefix: "", dataUnavailableHintPrefix: "",
+1
View File
@@ -2051,6 +2051,7 @@ export default withEnglishFallback({
queryError: "Erro na Consulta", queryError: "Erro na Consulta",
saveErrorTitle: "Falha ao salvar alterações", saveErrorTitle: "Falha ao salvar alterações",
dataUnavailable: "Os dados da tabela precisam ser recarregados.", dataUnavailable: "Os dados da tabela precisam ser recarregados.",
viewSnapshotSelectionNotRestored: "A visualização anterior foi restaurada, mas a seleção era grande demais para ser mantida.",
dataUnavailableHintPrefix: "Pressione ", dataUnavailableHintPrefix: "Pressione ",
dataUnavailableHintSuffix: " ou clique em Atualizar abaixo para recarregar.", dataUnavailableHintSuffix: " ou clique em Atualizar abaixo para recarregar.",
refresh: "Atualizar", refresh: "Atualizar",
+1
View File
@@ -2196,6 +2196,7 @@ export default withEnglishFallback({
queryError: "Sorgu Hatası", queryError: "Sorgu Hatası",
saveErrorTitle: "Değişiklikler Kaydedilemedi", saveErrorTitle: "Değişiklikler Kaydedilemedi",
dataUnavailable: "Tablo verisinin yeniden yüklenmesi gerekiyor.", dataUnavailable: "Tablo verisinin yeniden yüklenmesi gerekiyor.",
viewSnapshotSelectionNotRestored: "Önceki görünüm geri yüklendi, ancak seçim korunamayacak kadar büyüktü.",
cachedResultUnavailable: "Önbelleğe alınmış sonuç eksik veya uyumsuz.", cachedResultUnavailable: "Önbelleğe alınmış sonuç eksik veya uyumsuz.",
reexecuteQuery: "Sorguyu yeniden çalıştır", reexecuteQuery: "Sorguyu yeniden çalıştır",
dataUnavailableHintPrefix: "Yeniden yüklemek için ", dataUnavailableHintPrefix: "Yeniden yüklemek için ",
+1
View File
@@ -2126,6 +2126,7 @@ export default withEnglishFallback({
queryError: "查询出错", queryError: "查询出错",
saveErrorTitle: "数据修改保存失败", saveErrorTitle: "数据修改保存失败",
dataUnavailable: "表数据需要重新加载。", dataUnavailable: "表数据需要重新加载。",
viewSnapshotSelectionNotRestored: "已恢复上次的视图位置,但过大的选择范围未能保留。",
dataUnavailableHintPrefix: "按 ", dataUnavailableHintPrefix: "按 ",
dataUnavailableHintSuffix: " 或点击下方刷新按钮重新加载。", dataUnavailableHintSuffix: " 或点击下方刷新按钮重新加载。",
cachedResultUnavailable: "缓存结果缺失或不兼容。", cachedResultUnavailable: "缓存结果缺失或不兼容。",
+1
View File
@@ -2052,6 +2052,7 @@ export default withEnglishFallback({
searchColumn: "搜尋欄/註解...", searchColumn: "搜尋欄/註解...",
noColumnsFound: "未找到欄", noColumnsFound: "未找到欄",
dataUnavailable: "資料表資料需要重新載入。", dataUnavailable: "資料表資料需要重新載入。",
viewSnapshotSelectionNotRestored: "已還原上次的檢視位置,但過大的選取範圍未能保留。",
dataUnavailableHintPrefix: "按 ", dataUnavailableHintPrefix: "按 ",
dataUnavailableHintSuffix: " 或點擊下方重新整理來重新載入。", dataUnavailableHintSuffix: " 或點擊下方重新整理來重新載入。",
queryError: "查詢出錯", queryError: "查詢出錯",
@@ -0,0 +1,206 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
DATA_GRID_VIEW_SNAPSHOT_RESTORE,
MAX_CACHE_BYTES,
MAX_SNAPSHOT_BYTES,
MAX_SPARSE_CELL_ENTRIES,
beginClosingDataGridViewSnapshotsForTab,
buildDataGridViewProbe,
clampDataGridViewSelection,
clearDataGridViewSnapshot,
clearDataGridViewSnapshotsForTab,
dataGridViewSnapshotCacheBytes,
dataGridViewSnapshotCacheSize,
peekDataGridViewSnapshot,
resetDataGridViewSnapshots,
saveDataGridViewSnapshot,
shouldNotifyOverBudgetSelection,
type DataGridViewSelectionSnapshot,
type DataGridViewSnapshot,
} from "@/lib/dataGrid/dataGridViewStateCache";
function snapshot(ownerKey: string, patch: Partial<DataGridViewSnapshot> = {}): DataGridViewSnapshot {
return {
ownerKey,
viewGeneration: "gen-1",
probe: "probe",
renderer: "dom",
rowCount: 10,
columnCount: 2,
viewport: { top: 100, left: 0 },
...patch,
};
}
function sparseCellSelection(count: number): DataGridViewSelectionSnapshot {
return { kind: "cells", cellKeys: Array.from({ length: count }, (_, index) => `${index}:${index}`) };
}
describe("dataGridViewStateCache", () => {
beforeEach(() => {
resetDataGridViewSnapshots();
});
it("exposes an enabled rollback switch by default", () => {
expect(DATA_GRID_VIEW_SNAPSHOT_RESTORE).toBe(true);
});
it("round-trips a snapshot and refreshes LRU recency on peek", () => {
saveDataGridViewSnapshot(snapshot("tab-1-run-1-0"));
expect(peekDataGridViewSnapshot("tab-1-run-1-0")?.viewport.top).toBe(100);
expect(peekDataGridViewSnapshot("missing")).toBeUndefined();
});
it("caps the cache at 32 owners and evicts the least recently used", () => {
for (let index = 0; index < 33; index += 1) saveDataGridViewSnapshot(snapshot(`tab-${index}`));
expect(dataGridViewSnapshotCacheSize()).toBe(32);
expect(peekDataGridViewSnapshot("tab-0")).toBeUndefined();
expect(peekDataGridViewSnapshot("tab-32")).toBeDefined();
});
it("measures size in bytes, not UTF-16 code units", () => {
// A selection of multi-byte characters: byte length exceeds code-unit count.
const selection: DataGridViewSelectionSnapshot = { kind: "cells", cellKeys: Array.from({ length: 300 }, () => "格:式") };
const stored = snapshot("tab-1", { selection });
saveDataGridViewSnapshot(stored);
// ~300 CJK keys cost well above 600 UTF-16 units but above 1800 bytes; a
// code-unit measure would keep this under a tiny budget while bytes exceed it.
const kept = peekDataGridViewSnapshot("tab-1");
expect(JSON.stringify(kept ?? {}).length).toBeGreaterThan(600);
});
it("drops the cell keys when they exceed the per-snapshot byte budget and marks the snapshot", () => {
// Long keys push the serialized selection past the 64 KB budget.
const selection: DataGridViewSelectionSnapshot = { kind: "cells", cellKeys: Array.from({ length: 1200 }, (_, index) => `${index}:${"cell".repeat(20)}`) };
const result = saveDataGridViewSnapshot(snapshot("tab-1", { selection }));
expect(result.droppedSelection).toBe(true);
const kept = peekDataGridViewSnapshot("tab-1");
expect(kept?.selection?.kind === "cells" && kept.selection.cellKeys).toEqual([]);
expect(kept?.selectionDropped).toBe(true);
expect(kept?.viewport.top).toBe(100);
expect(dataGridViewSnapshotCacheBytes()).toBeLessThan(MAX_SNAPSHOT_BYTES);
});
it("keeps a selection that fits the budget without a dropped flag", () => {
const result = saveDataGridViewSnapshot(snapshot("tab-1", { selection: sparseCellSelection(3) }));
expect(result.droppedSelection).toBe(false);
const kept = peekDataGridViewSnapshot("tab-1");
expect(kept?.selection?.kind === "cells" && kept.selection.cellKeys).toHaveLength(3);
expect(kept?.selectionDropped).toBeUndefined();
});
it("clamps cell keys beyond the sparse cardinality cap", () => {
const over = sparseCellSelection(MAX_SPARSE_CELL_ENTRIES + 1);
const result = clampDataGridViewSelection(over);
expect(result.droppedSelection).toBe(true);
expect(result.selection.kind === "cells" && result.selection.cellKeys).toEqual([]);
expect(clampDataGridViewSelection(sparseCellSelection(MAX_SPARSE_CELL_ENTRIES)).droppedSelection).toBe(false);
});
it("clears a single owner and its notice marker", () => {
saveDataGridViewSnapshot(snapshot("tab-1"));
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(true);
clearDataGridViewSnapshot("tab-1");
expect(peekDataGridViewSnapshot("tab-1")).toBeUndefined();
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(true);
});
it("clears every snapshot belonging to a tab", () => {
saveDataGridViewSnapshot(snapshot("tab-1-run-a-0"));
saveDataGridViewSnapshot(snapshot("tab-1-run-b-0"));
saveDataGridViewSnapshot(snapshot("tab-2-run-a-0"));
clearDataGridViewSnapshotsForTab("tab-1");
expect(peekDataGridViewSnapshot("tab-1-run-a-0")).toBeUndefined();
expect(peekDataGridViewSnapshot("tab-1-run-b-0")).toBeUndefined();
expect(peekDataGridViewSnapshot("tab-2-run-a-0")).toBeDefined();
});
it("lets a tab capture again right after a plain clear (no tombstone)", () => {
clearDataGridViewSnapshotsForTab("tab-1");
saveDataGridViewSnapshot(snapshot("tab-1"));
expect(peekDataGridViewSnapshot("tab-1")).toBeDefined();
});
it("notifies at most once per owner and generation", () => {
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(true);
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(false);
// A new logical result is a new chance to report.
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-2")).toBe(true);
expect(shouldNotifyOverBudgetSelection("tab-2", "gen-1")).toBe(true);
});
it("keeps the whole cache within the global byte budget", () => {
for (let index = 0; index < 40; index += 1) {
saveDataGridViewSnapshot(snapshot(`tab-${index}`, { selection: sparseCellSelection(2000) }));
}
expect(dataGridViewSnapshotCacheBytes()).toBeLessThanOrEqual(MAX_CACHE_BYTES);
expect(dataGridViewSnapshotCacheSize()).toBeLessThanOrEqual(32);
});
it("blocks writes for a closing tab without requiring window timers", () => {
const originalWindow = globalThis.window;
// Test hosts may stub `window` without timer functions.
(globalThis as { window?: unknown }).window = {};
try {
expect(() => beginClosingDataGridViewSnapshotsForTab("tab-1")).not.toThrow();
} finally {
(globalThis as { window?: unknown }).window = originalWindow;
}
saveDataGridViewSnapshot(snapshot("tab-1"));
expect(peekDataGridViewSnapshot("tab-1")).toBeUndefined();
});
});
describe("buildDataGridViewProbe", () => {
it("is deterministic and bounded", () => {
const long = "x".repeat(500);
const input = {
columns: Array.from({ length: 40 }, (_, index) => `column-${index}`),
columnTypes: Array.from({ length: 40 }, (_, index) => `type-${index}`),
rowCount: 3,
firstRow: [long, 1, 2, 3, 4],
lastRow: ["z", 1],
};
const probe = buildDataGridViewProbe(input);
expect(probe).toBe(buildDataGridViewProbe(input));
expect(probe).not.toContain(long);
expect(probe).toContain("x".repeat(64));
// Bounded to 16 columns / 4 probed row columns.
expect(probe).not.toContain("column-20");
});
it("changes when the dataset changes", () => {
const base = { columns: ["id", "name"], rowCount: 1, firstRow: [1, "a"] };
expect(buildDataGridViewProbe(base)).not.toBe(buildDataGridViewProbe({ ...base, firstRow: [2, "a"] }));
expect(buildDataGridViewProbe(base)).not.toBe(buildDataGridViewProbe({ ...base, rowCount: 2 }));
expect(buildDataGridViewProbe(base)).not.toBe(buildDataGridViewProbe({ ...base, columns: ["id", "other"] }));
});
it("skips large-value cells instead of reading them", () => {
const input = {
columns: ["id", "blob"],
rowCount: 1,
firstRow: [1, "huge-value"],
largeValueCells: [{ row_index: 0, column_index: 1 }],
};
expect(buildDataGridViewProbe(input)).not.toContain("huge-value");
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { decodeTabResultSnapshot, encodeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
import type { QueryResult } from "@/types/database";
function sampleResult(): QueryResult {
return {
columns: ["id", "name"],
rows: [[1, "a"]],
affected_rows: 0,
execution_time_ms: 1,
};
}
describe("tab result cache view generation", () => {
it("round-trips the tab-level view generation for a data tab with no runs", () => {
const restored = decodeTabResultSnapshot(
encodeTabResultSnapshot({
result: sampleResult(),
resultViewGeneration: "gen-data-tab",
cachedAt: 1,
}),
);
expect(restored?.resultViewGeneration).toBe("gen-data-tab");
});
it("round-trips the per-run view generation", () => {
const restored = decodeTabResultSnapshot(
encodeTabResultSnapshot({
resultRuns: [{ id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: sampleResult(), resultViewGeneration: "gen-run" }],
activeResultRunId: "run-1",
cachedAt: 1,
}),
);
expect(restored?.resultRuns?.[0]?.resultViewGeneration).toBe("gen-run");
});
});
@@ -0,0 +1,309 @@
/**
* Session-scoped cache for the view state of a data grid across tab switches.
*
* `ContentArea` renders only the active tab, so every `DataGrid` is unmounted on
* a tab switch and remounted when the user returns. Scroll position and
* selection live inside the component instance and would otherwise be lost.
*
* This cache deliberately holds primitives only — never a `QueryResult`, a rows
* array, a Vue reactive object, or a callback — so it cannot defeat
* `queryStore.releaseResultObjectPayload()` or inactive-result eviction.
*
* A snapshot is replayed only when the owning grid's `resultViewGeneration`
* matches the value captured with the snapshot. That token is assigned by
* `publishResultGeneration` in `queryStore` (new for every dataset replacement,
* inherited only for disk restore and infinite-scroll append), so a fresh
* result can never adopt a stale viewport (#7341).
*/
export type DataGridRendererMode = "dom" | "canvas";
/** Cell position in display-row / visible-column coordinates. */
export interface DataGridViewCellPosition {
rowIndex: number;
colIndex: number;
}
/**
* Selection captured in grid display coordinates. A generation match already
* guarantees the same dataset, so plain indexes are used instead of the
* identity-token machinery from `dataGridSelectionPersistence.ts` — that path
* builds a token for every row, which is O(row count) and would violate the
* capture budget on large results.
*/
export type DataGridViewSelectionSnapshot =
| { kind: "all"; anchorRowIndex?: number | null }
| { kind: "rows"; rowIndexes: number[]; contiguous?: boolean; anchorRowIndex?: number | null }
| { kind: "columns"; columnIndexes: number[] }
| { kind: "cells"; cellKeys: string[]; anchor?: DataGridViewCellPosition; focus?: DataGridViewCellPosition; selectingAll?: boolean }
| { kind: "range"; anchor: DataGridViewCellPosition; focus: DataGridViewCellPosition; selectingAll?: boolean; lastClickedRowIndex?: number | null };
export interface DataGridViewSnapshot {
/** Grid cache key: `resultGridCacheKey(tab)` for results, tab id for data tabs. */
ownerKey: string;
/** Logical-result identity the grid was rendering when this was captured. */
viewGeneration: string;
/** Bounded deterministic probe; integrity check only, never the authority. */
probe: string;
renderer: DataGridRendererMode;
rowCount: number;
columnCount: number;
viewport: { top: number; left: number };
/** Index-based selection captured in O(selection size). */
selection?: DataGridViewSelectionSnapshot;
/** Set when a sparse selection had to be dropped; surfaces the restore-time notice. */
selectionDropped?: boolean;
}
export interface DataGridViewProbeInput {
columns: readonly string[];
columnTypes?: readonly (string | undefined)[];
rowCount: number;
firstRow?: readonly unknown[];
lastRow?: readonly unknown[];
/** Large-value cells are skipped so the probe never reads a multi-MB value. */
largeValueCells?: ReadonlyArray<{ row_index: number; column_index: number }>;
navigation?: {
whereInput?: string;
orderByInput?: string;
pageOffset?: number;
pageLimit?: number;
sortColumn?: string;
sortDirection?: string;
sortMode?: string;
};
}
const MAX_ENTRIES = 32;
/**
* Rollback switch (default on). Set `VITE_DBX_DISABLE_GRID_VIEW_SNAPSHOT=true`
* to skip capture and restore entirely, which restores the pre-feature
* behavior without a code change.
*/
export const DATA_GRID_VIEW_SNAPSHOT_RESTORE = import.meta.env?.VITE_DBX_DISABLE_GRID_VIEW_SNAPSHOT !== "true";
/** Per-snapshot serialized budget. */
export const MAX_SNAPSHOT_BYTES = 64 * 1024;
/** Whole-cache serialized budget. */
export const MAX_CACHE_BYTES = 2 * 1024 * 1024;
/** Hard cap on sparse cell-selection entries. */
export const MAX_SPARSE_CELL_ENTRIES = 4096;
const PROBE_COLUMNS = 16;
const PROBE_ROW_COLUMNS = 4;
const PROBE_VALUE_CHARS = 64;
const cache = new Map<string, DataGridViewSnapshot>();
/**
* Tabs whose snapshots must not be written again. Closing a tab unmounts its
* grid, whose teardown capture would otherwise re-create the entry the store
* just deleted. Mirrors `closingPendingSnapshotTabs` in `useDataGridEditor`.
*/
const closingTabs = new Set<string>();
/**
* Owners already told that an oversized sparse selection was dropped. Cleared
* only when the owner's snapshot is cleared or its generation changes, so
* repeated tab switching cannot re-notify.
*/
const overBudgetNotified = new Set<string>();
function ownerBelongsToTab(ownerKey: string, tabId: string): boolean {
return ownerKey === tabId || ownerKey.startsWith(`${tabId}-`);
}
function isClosingOwner(ownerKey: string): boolean {
for (const tabId of closingTabs) {
if (ownerBelongsToTab(ownerKey, tabId)) return true;
}
return false;
}
const byteEncoder = new TextEncoder();
/**
* Serialized size in bytes (UTF-8), so the budgets hold for non-ASCII column
* and cell content too. Only ever called on the small snapshot object, never
* on result data.
*/
function snapshotBytes(snapshot: DataGridViewSnapshot): number {
try {
return byteEncoder.encode(JSON.stringify(snapshot)).byteLength;
} catch {
return Number.MAX_SAFE_INTEGER;
}
}
/** True when the selection exceeds the sparse-cell budget. */
export function selectionExceedsBudget(selection: DataGridViewSelectionSnapshot | undefined): boolean {
return !!selection && (selection.kind === "cells" ? selection.cellKeys.length > MAX_SPARSE_CELL_ENTRIES : selection.kind === "rows" && !selection.contiguous && selection.rowIndexes.length > MAX_SPARSE_CELL_ENTRIES);
}
/** Trim an over-budget sparse selection, reporting what was dropped. */
export function clampDataGridViewSelection(selection: DataGridViewSelectionSnapshot): { selection: DataGridViewSelectionSnapshot; droppedSelection: boolean } {
if (selection.kind === "cells") {
if (selection.cellKeys.length <= MAX_SPARSE_CELL_ENTRIES) return { selection, droppedSelection: false };
return { selection: { kind: "cells", cellKeys: [], anchor: selection.anchor, focus: selection.focus }, droppedSelection: true };
}
if (selection.kind === "rows" && !selection.contiguous) {
if (selection.rowIndexes.length <= MAX_SPARSE_CELL_ENTRIES) return { selection, droppedSelection: false };
return { selection: { kind: "rows", rowIndexes: [], anchorRowIndex: selection.anchorRowIndex }, droppedSelection: true };
}
return { selection, droppedSelection: false };
}
/**
* Shrink a snapshot until it fits the per-snapshot budget: a sparse selection
* first, then the whole selection. The viewport is always retained.
*/
function degradeToBudget(snapshot: DataGridViewSnapshot): { snapshot: DataGridViewSnapshot; droppedSelection: boolean } {
if (snapshotBytes(snapshot) <= MAX_SNAPSHOT_BYTES) {
return { snapshot, droppedSelection: false };
}
if (snapshot.selection?.kind === "cells" && snapshot.selection.cellKeys.length) {
const withoutCells: DataGridViewSnapshot = { ...snapshot, selection: { ...snapshot.selection, cellKeys: [] }, selectionDropped: true };
if (snapshotBytes(withoutCells) <= MAX_SNAPSHOT_BYTES) {
return { snapshot: withoutCells, droppedSelection: true };
}
}
return { snapshot: { ...snapshot, selection: undefined, selectionDropped: true }, droppedSelection: !!snapshot.selection };
}
function totalCacheBytes(): number {
let total = 0;
for (const snapshot of cache.values()) total += snapshotBytes(snapshot);
return total;
}
function evictOldest(): boolean {
const oldest = cache.keys().next().value;
if (oldest === undefined) return false;
cache.delete(oldest);
return true;
}
/**
* Store a snapshot for `ownerKey`, degrading it to fit the budgets. Returns
* whether a selection had to be dropped, so the caller can surface the one-shot
* notice.
*/
export function saveDataGridViewSnapshot(snapshot: DataGridViewSnapshot): { droppedSelection: boolean } {
if (isClosingOwner(snapshot.ownerKey)) return { droppedSelection: false };
const { snapshot: fitted, droppedSelection } = degradeToBudget(snapshot);
cache.delete(snapshot.ownerKey);
cache.set(snapshot.ownerKey, fitted);
while (cache.size > MAX_ENTRIES) {
if (!evictOldest()) break;
}
while (cache.size > 1 && totalCacheBytes() > MAX_CACHE_BYTES) {
if (!evictOldest()) break;
}
return { droppedSelection };
}
/** Read a snapshot without consuming it, refreshing its LRU recency. */
export function peekDataGridViewSnapshot(ownerKey: string): DataGridViewSnapshot | undefined {
const snapshot = cache.get(ownerKey);
if (!snapshot) return undefined;
cache.delete(ownerKey);
cache.set(ownerKey, snapshot);
return snapshot;
}
/**
* Drop a replayed snapshot after a successful restore, keeping the over-budget
* notice marker so switching back and forth cannot re-notify.
*/
export function consumeDataGridViewSnapshot(ownerKey: string): void {
cache.delete(ownerKey);
}
/** Drop a single owner's snapshot and its notice marker. */
export function clearDataGridViewSnapshot(ownerKey: string): void {
cache.delete(ownerKey);
// Deleting while iterating a Set/Map is safe: iteration skips removed entries.
for (const key of overBudgetNotified) {
if (key.startsWith(`${ownerKey} `)) overBudgetNotified.delete(key);
}
}
/** Drop every snapshot belonging to a tab (owner keys are `${tabId}-...`). */
export function clearDataGridViewSnapshotsForTab(tabId: string): void {
for (const key of cache.keys()) {
if (ownerBelongsToTab(key, tabId)) cache.delete(key);
}
for (const key of overBudgetNotified) {
if (ownerBelongsToTab(key, tabId) || key.startsWith(`${tabId} `)) overBudgetNotified.delete(key);
}
}
/**
* Clear a tab's snapshots and block new writes for a short window, so the grid
* teardown capture cannot resurrect an entry after the tab is closed or its
* result cleared.
*/
export function beginClosingDataGridViewSnapshotsForTab(tabId: string): void {
closingTabs.add(tabId);
// Some test environments stub `window` without timers, so a bare
// `window.setTimeout` would throw while clearing a result.
const schedule = typeof window !== "undefined" && typeof window.setTimeout === "function" ? window.setTimeout.bind(window) : setTimeout;
schedule(() => closingTabs.delete(tabId), 5000);
clearDataGridViewSnapshotsForTab(tabId);
}
/** Test seam: drop everything. */
export function resetDataGridViewSnapshots(): void {
cache.clear();
overBudgetNotified.clear();
closingTabs.clear();
}
export function dataGridViewSnapshotCacheSize(): number {
return cache.size;
}
export function dataGridViewSnapshotCacheBytes(): number {
return totalCacheBytes();
}
/**
* One-shot notice bookkeeping for a dropped selection. Returns true the first
* time this owner+generation combination reports a drop, and false on every
* later call until the snapshot is cleared or the generation changes.
*/
export function shouldNotifyOverBudgetSelection(ownerKey: string, viewGeneration: string): boolean {
const marker = `${ownerKey} ${viewGeneration}`;
if (overBudgetNotified.has(marker)) return false;
overBudgetNotified.add(marker);
return true;
}
function probeValue(value: unknown, skip: boolean): string {
if (skip) return "";
if (value === null) return " ";
const text = typeof value === "string" ? value : String(value);
return text.length > PROBE_VALUE_CHARS ? text.slice(0, PROBE_VALUE_CHARS) : text;
}
/**
* Deterministic bounded fingerprint of a result. Used only to detect cache
* corruption or wiring mistakes: it must never authorize a restore by itself.
*/
export function buildDataGridViewProbe(input: DataGridViewProbeInput): string {
const skip = new Set((input.largeValueCells ?? []).map((cell) => `${cell.row_index}:${cell.column_index}`));
const rowProbe = (row: readonly unknown[] | undefined, rowIndex: number): string => {
if (!row) return "";
const cells: string[] = [];
const limit = Math.min(PROBE_ROW_COLUMNS, row.length);
for (let columnIndex = 0; columnIndex < limit; columnIndex += 1) {
cells.push(probeValue(row[columnIndex], skip.has(`${rowIndex}:${columnIndex}`)));
}
return cells.join("");
};
const navigation = input.navigation;
return JSON.stringify({
columns: input.columns.slice(0, PROBE_COLUMNS),
columnTypes: input.columnTypes?.slice(0, PROBE_COLUMNS),
rowCount: input.rowCount,
first: rowProbe(input.firstRow, 0),
last: rowProbe(input.lastRow, Math.max(0, input.rowCount - 1)),
nav: navigation ? [navigation.whereInput ?? "", navigation.orderByInput ?? "", navigation.pageOffset ?? 0, navigation.pageLimit ?? 0, navigation.sortColumn ?? "", navigation.sortDirection ?? "", navigation.sortMode ?? ""] : undefined,
});
}
@@ -32,6 +32,12 @@ export interface TabResultSnapshot {
resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"]; resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"];
resultRuns?: QueryTab["resultRuns"]; resultRuns?: QueryTab["resultRuns"];
activeResultRunId?: string; activeResultRunId?: string;
/**
* Logical-result identity for the tab-switch view snapshot cache. Required at
* the tab level because a data tab has no result run to carry it; query tabs
* additionally carry it per run through `resultRuns`.
*/
resultViewGeneration?: string;
queryAnalysis?: QueryTab["queryAnalysis"]; queryAnalysis?: QueryTab["queryAnalysis"];
querySourceColumns?: QueryTab["querySourceColumns"]; querySourceColumns?: QueryTab["querySourceColumns"];
queryWriteTargets?: QueryTab["queryWriteTargets"]; queryWriteTargets?: QueryTab["queryWriteTargets"];
@@ -747,6 +753,7 @@ export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undef
resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments ? clonePlain(tab.resultLocalSortOriginalMongoCopyDocuments) : undefined, resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments ? clonePlain(tab.resultLocalSortOriginalMongoCopyDocuments) : undefined,
resultRuns: stripResultRunSessionIds(tab.resultRuns), resultRuns: stripResultRunSessionIds(tab.resultRuns),
activeResultRunId: tab.activeResultRunId, activeResultRunId: tab.activeResultRunId,
resultViewGeneration: tab.resultViewGeneration,
queryAnalysis: tab.queryAnalysis ? clonePlain(tab.queryAnalysis) : undefined, queryAnalysis: tab.queryAnalysis ? clonePlain(tab.queryAnalysis) : undefined,
querySourceColumns: tab.querySourceColumns ? [...tab.querySourceColumns] : undefined, querySourceColumns: tab.querySourceColumns ? [...tab.querySourceColumns] : undefined,
queryWriteTargets: tab.queryWriteTargets?.map((target) => ({ ...target, sourceColumns: [...target.sourceColumns] })), queryWriteTargets: tab.queryWriteTargets?.map((target) => ({ ...target, sourceColumns: [...target.sourceColumns] })),
@@ -0,0 +1,114 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { QueryResult } from "@/types/database";
function sampleResult(rows: number): QueryResult {
return {
columns: ["id", "name"],
rows: Array.from({ length: rows }, (_, index) => [index, `row-${index}`]),
affected_rows: 0,
execution_time_ms: 1,
};
}
// The first test pays the cold transform of the store module, which can exceed
// the default 10s budget on Windows.
describe("queryStore resultViewGeneration", { timeout: 30_000 }, () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
setActivePinia(createPinia());
});
it("assigns a new generation when an error result replaces the dataset", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "error", "query");
const tab = store.tabs.find((item) => item.id === tabId)!;
tab.result = sampleResult(2);
tab.resultViewGeneration = "gen-old";
store.setErrorResult(tabId, new Error("boom"));
expect(tab.resultViewGeneration).toBeTruthy();
expect(tab.resultViewGeneration).not.toBe("gen-old");
});
it("assigns a new generation for a local sort, because row order changes", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "local-sort", "query");
const tab = store.tabs.find((item) => item.id === tabId)!;
tab.result = {
...sampleResult(3),
rows: [
[3, "c"],
[1, "a"],
[2, "b"],
],
};
tab.resultViewGeneration = "gen-old";
store.sortTabResultLocally(tabId, "id", 0, "asc");
expect(tab.result.rows.map((row) => row[0])).toEqual([1, 2, 3]);
expect(tab.resultViewGeneration).toBeTruthy();
expect(tab.resultViewGeneration).not.toBe("gen-old");
});
it("inherits the generation when a result is restored from disk", async () => {
const cached = sampleResult(2);
vi.doMock("@/lib/tabs/tabResultCache", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/tabs/tabResultCache")>();
return {
...actual,
readTabResultSnapshot: vi.fn(async () => ({ result: cached, resultViewGeneration: "gen-disk" }) as any),
};
});
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "evicted", "query");
const tab = store.tabs.find((item) => item.id === tabId)!;
tab.resultEvicted = true;
tab.resultCacheKey = "tab:cache:key";
tab.resultViewGeneration = "gen-disk";
await store.reloadEvictedTab(tabId);
expect(tab.result).toBeDefined();
expect(tab.resultViewGeneration).toBe("gen-disk");
vi.doUnmock("@/lib/tabs/tabResultCache");
});
it("projects the run's generation onto the tab when switching result runs", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "runs", "query");
const tab = store.tabs.find((item) => item.id === tabId)!;
const first = { id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: sampleResult(1), resultGridRevision: "rev-1", resultViewGeneration: "gen-run-1" };
const second = { id: "run-2", title: "Run 2", sequence: 2, sql: "select 2", createdAt: 2, result: sampleResult(1), resultGridRevision: "rev-2", resultViewGeneration: "gen-run-2" };
tab.resultRuns = [first, second];
tab.activeResultRunId = "run-1";
tab.resultViewGeneration = "gen-run-1";
await store.setActiveResultRun(tabId, "run-2", { evictInactive: false });
expect(tab.resultViewGeneration).toBe("gen-run-2");
});
it("does not let a legacy run without a generation inherit the tab's value", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "legacy-run", "query");
const tab = store.tabs.find((item) => item.id === tabId)!;
const legacy = { id: "run-legacy", title: "Legacy", sequence: 1, sql: "select 1", createdAt: 1, result: sampleResult(1) };
tab.resultRuns = [legacy];
tab.activeResultRunId = "run-legacy";
tab.resultViewGeneration = "gen-stale";
await store.setActiveResultRun(tabId, "run-legacy", { evictInactive: false });
expect(tab.resultViewGeneration).toBeTruthy();
expect(tab.resultViewGeneration).not.toBe("gen-stale");
});
});
@@ -0,0 +1,75 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const queryStoreSource = readFileSync(new URL("../queryStore.ts", import.meta.url), "utf8");
/** Every direct assignment to the token, with its line number. */
function directAssignments(): string[] {
return queryStoreSource
.split("\n")
.map((line, index) => ({ line: line.trim(), number: index + 1 }))
.filter((entry) => /^(current|tab|run)\.resultViewGeneration\s*=/.test(entry.line))
.map((entry) => `${entry.number}: ${entry.line}`);
}
/** The body of executeTabSql, where every result publication must live. */
function executeTabSqlBody(): string {
const start = queryStoreSource.indexOf("async function executeTabSql(");
const end = queryStoreSource.indexOf("\n }\n", start);
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
return queryStoreSource.slice(start, end);
}
describe("queryStore resultViewGeneration writer", () => {
it("assigns the token in exactly the allowed places", () => {
const assignments = directAssignments();
// publishResultGeneration (the writer) plus the two identity-preserving
// readers: disk restore inherits, run projection copies the run's value.
expect(assignments).toHaveLength(3);
expect(assignments.join("\n")).toContain("snapshot.resultViewGeneration");
expect(assignments.join("\n")).toContain("run.resultViewGeneration");
});
it("defaults an unclassified publication to a new generation", () => {
const start = queryStoreSource.indexOf("function publishResultGeneration(");
const end = queryStoreSource.indexOf("function ", start + 10);
const body = queryStoreSource.slice(start, end);
expect(body).toContain('if (origin === "disk-restore") return;');
expect(body).toContain('if (origin === "append") {');
expect(body).toContain("tab.resultViewGeneration = uuid();");
});
it("publishes a generation for every replacement branch of executeTabSql", () => {
const body = executeTabSqlBody();
// Generic branch: append inherits, everything else replaces.
expect(body).toContain('publishResultGeneration(current, shouldAppendResult ? "append" : (options?.publicationOrigin ?? "execute"));');
// Redis, MongoDB, and Elasticsearch branches replace the visible result too;
// Mongo's append variant must keep the current generation.
expect(body).toContain('publishResultGeneration(current, shouldAppendResult ? "append" : "execute");');
const publicationCount = body.match(/publishResultGeneration\(current/g)?.length ?? 0;
expect(publicationCount).toBeGreaterThanOrEqual(5);
});
it("publishes outside executeTabSql where results are also replaced", () => {
expect(queryStoreSource).toContain('publishResultGeneration(tab, "local-sort");');
expect(queryStoreSource).toContain('publishResultGeneration(tab, "execute");');
expect(queryStoreSource).toContain('publishResultGeneration(current, "execute");');
});
it("reserves the closing tombstone for tab closure, not ordinary execution", () => {
// Ordinary execution clears the payload before running; the tombstone there
// would block capturing the fresh result's view for five seconds.
const clearResultPayloadStart = queryStoreSource.indexOf("function clearResultPayload(");
const clearResultPayloadEnd = queryStoreSource.indexOf("\n }\n", clearResultPayloadStart);
const clearResultPayloadBody = queryStoreSource.slice(clearResultPayloadStart, clearResultPayloadEnd);
expect(clearResultPayloadBody).not.toContain("beginClosingDataGridViewSnapshotsForTab");
expect(clearResultPayloadBody).toContain("clearDataGridViewSnapshotsForTab(tab.id);");
// Tab closure keeps the tombstone so the teardown capture cannot resurrect.
expect(queryStoreSource).toContain("beginClosingDataGridViewSnapshotsForTab(id);");
expect(queryStoreSource.match(/beginClosingDataGridViewSnapshotsForTab\(tab\.id\)/g)?.length).toBe(2);
});
});
+63 -1
View File
@@ -60,6 +60,7 @@ import { replaceSqlServerLeadingUseQuery, sqlServerLeadingUseScript, sqlServerUs
import { classifySqlRisk } from "@/lib/sql/sqlRisk"; import { classifySqlRisk } from "@/lib/sql/sqlRisk";
import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen"; import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen";
import { clearDataGridPendingSnapshot, clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor"; import { clearDataGridPendingSnapshot, clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor";
import { beginClosingDataGridViewSnapshotsForTab, clearDataGridViewSnapshot, clearDataGridViewSnapshotsForTab } from "@/lib/dataGrid/dataGridViewStateCache";
import { clearDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGridFilterBuilderPersistence"; import { clearDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
import { clearDataGridSearchStatesForTab } from "@/lib/dataGrid/dataGridSearchStatePersistence"; import { clearDataGridSearchStatesForTab } from "@/lib/dataGrid/dataGridSearchStatePersistence";
import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache"; import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
@@ -1475,6 +1476,12 @@ export const useQueryStore = defineStore("query", () => {
if (!options.evicted) { if (!options.evicted) {
if (tab.resultCacheKey && !options.preserveCacheSnapshot) void deleteTabResultSnapshot(tab.resultCacheKey); if (tab.resultCacheKey && !options.preserveCacheSnapshot) void deleteTabResultSnapshot(tab.resultCacheKey);
tab.resultCacheKey = undefined; tab.resultCacheKey = undefined;
// Drop the stale view snapshot but do NOT tombstone: ordinary execution
// clears the payload before running, and the replacement result must stay
// free to capture a fresh snapshot when the user switches away. Tab
// closure (closeTab/closeTabsWhere/releaseTabsWhere) uses the tombstone.
// An evicted result keeps its snapshot so returning to the tab can replay it.
clearDataGridViewSnapshotsForTab(tab.id);
} }
} }
@@ -1554,6 +1561,9 @@ export const useQueryStore = defineStore("query", () => {
tab.results = run.results; tab.results = run.results;
tab.activeResultIndex = run.activeResultIndex; tab.activeResultIndex = run.activeResultIndex;
tab.resultGridRevision = run.resultGridRevision; tab.resultGridRevision = run.resultGridRevision;
// A legacy run without the token must not inherit a stale tab value: fail
// safe by starting a fresh logical result.
tab.resultViewGeneration = run.resultViewGeneration ?? uuid();
tab.batchSqlExecution = cloneBatchSqlExecution(run.batchSqlExecution); tab.batchSqlExecution = cloneBatchSqlExecution(run.batchSqlExecution);
tab.resultBaseSql = run.resultBaseSql; tab.resultBaseSql = run.resultBaseSql;
tab.resultEditorFingerprint = run.resultEditorFingerprint; tab.resultEditorFingerprint = run.resultEditorFingerprint;
@@ -1877,6 +1887,7 @@ export const useQueryStore = defineStore("query", () => {
results: tab.results, results: tab.results,
activeResultIndex: tab.activeResultIndex, activeResultIndex: tab.activeResultIndex,
resultGridRevision: tab.resultGridRevision, resultGridRevision: tab.resultGridRevision,
resultViewGeneration: tab.resultViewGeneration,
batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution), batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution),
resultBaseSql: tab.resultBaseSql, resultBaseSql: tab.resultBaseSql,
resultEditorFingerprint: tab.resultEditorFingerprint, resultEditorFingerprint: tab.resultEditorFingerprint,
@@ -1978,6 +1989,7 @@ export const useQueryStore = defineStore("query", () => {
results: tab.results, results: tab.results,
activeResultIndex: tab.activeResultIndex, activeResultIndex: tab.activeResultIndex,
resultGridRevision: tab.resultGridRevision, resultGridRevision: tab.resultGridRevision,
resultViewGeneration: tab.resultViewGeneration,
batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution), batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution),
resultBaseSql: tab.resultBaseSql, resultBaseSql: tab.resultBaseSql,
resultEditorFingerprint: tab.resultEditorFingerprint, resultEditorFingerprint: tab.resultEditorFingerprint,
@@ -2038,6 +2050,27 @@ export const useQueryStore = defineStore("query", () => {
} }
} }
type ResultPublicationOrigin = "execute" | "refresh" | "page" | "sort" | "local-sort" | "append" | "disk-restore";
/**
* Single writer of `resultViewGeneration`, the logical-result identity used by
* the tab-switch view snapshot cache (`dataGridViewStateCache.ts`).
*
* New value by default, so a path that forgets to classify itself still fails
* safe (the old view snapshot stops matching) instead of replaying a stale
* viewport. `append` extends the current dataset; `disk-restore` inherits, so
* an evicted-then-restored payload keeps its captured view. No other code path
* may assign the token directly.
*/
function publishResultGeneration(tab: QueryTab, origin: ResultPublicationOrigin) {
if (origin === "disk-restore") return;
if (origin === "append") {
tab.resultViewGeneration ??= uuid();
return;
}
tab.resultViewGeneration = uuid();
}
function sortTabResultLocally(id: string, column: string, columnIndex: number, direction: DataGridSortDirection | null) { function sortTabResultLocally(id: string, column: string, columnIndex: number, direction: DataGridSortDirection | null) {
const tab = tabs.value.find((t) => t.id === id); const tab = tabs.value.find((t) => t.id === id);
if (!tab?.result) return; if (!tab?.result) return;
@@ -2059,6 +2092,8 @@ export const useQueryStore = defineStore("query", () => {
const mongo_copy_documents = originalMongoCopyDocuments ? rowIndexes.map((index) => originalMongoCopyDocuments[index]) : undefined; const mongo_copy_documents = originalMongoCopyDocuments ? rowIndexes.map((index) => originalMongoCopyDocuments[index]) : undefined;
const large_value_cells = remapLargeValueCells(tab.resultLocalSortOriginalLargeValueCells, rowIndexes); const large_value_cells = remapLargeValueCells(tab.resultLocalSortOriginalLargeValueCells, rowIndexes);
assignDisplayedResult(tab, { ...tab.result, rows, large_value_cells, mongo_documents, mongo_copy_documents }); assignDisplayedResult(tab, { ...tab.result, rows, large_value_cells, mongo_documents, mongo_copy_documents });
// Reordering rows invalidates source-index view snapshots.
publishResultGeneration(tab, "local-sort");
tab.resultSortColumn = direction ? column : undefined; tab.resultSortColumn = direction ? column : undefined;
tab.resultSortColumnIndex = direction ? columnIndex : undefined; tab.resultSortColumnIndex = direction ? columnIndex : undefined;
@@ -2532,6 +2567,9 @@ export const useQueryStore = defineStore("query", () => {
tab.executionId = undefined; tab.executionId = undefined;
tab.executingResultRunId = undefined; tab.executingResultRunId = undefined;
tab.queryExecutionStartedAt = undefined; tab.queryExecutionStartedAt = undefined;
// An externally-supplied result is a brand-new dataset, not the previous
// one: publish a fresh generation so the tab can capture a view snapshot.
publishResultGeneration(tab, "execute");
if (tab.result) touchResult(tab); if (tab.result) touchResult(tab);
return id; return id;
} }
@@ -3385,6 +3423,7 @@ export const useQueryStore = defineStore("query", () => {
persistSavedSqlEditorPosition(tabs.value[idx]); persistSavedSqlEditorPosition(tabs.value[idx]);
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id); if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
clearDataGridPendingSnapshotsForTab(id); clearDataGridPendingSnapshotsForTab(id);
beginClosingDataGridViewSnapshotsForTab(id);
clearDataGridStructuredFilterStatesForTab(id); clearDataGridStructuredFilterStatesForTab(id);
clearDataGridSearchStatesForTab(id); clearDataGridSearchStatesForTab(id);
if (tabs.value[idx].txnSessionId) void rollbackTransaction(id); if (tabs.value[idx].txnSessionId) void rollbackTransaction(id);
@@ -3746,6 +3785,7 @@ export const useQueryStore = defineStore("query", () => {
.forEach((tab) => { .forEach((tab) => {
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id); if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
clearDataGridPendingSnapshotsForTab(tab.id); clearDataGridPendingSnapshotsForTab(tab.id);
beginClosingDataGridViewSnapshotsForTab(tab.id);
clearDataGridStructuredFilterStatesForTab(tab.id); clearDataGridStructuredFilterStatesForTab(tab.id);
clearDataGridSearchStatesForTab(tab.id); clearDataGridSearchStatesForTab(tab.id);
if (tab.txnSessionId) void rollbackTransaction(tab.id); if (tab.txnSessionId) void rollbackTransaction(tab.id);
@@ -3930,6 +3970,7 @@ export const useQueryStore = defineStore("query", () => {
await executeTabSql(tab.id, sql, { await executeTabSql(tab.id, sql, {
pagination: { limit, offset }, pagination: { limit, offset },
preserveResultDuringExecution: true, preserveResultDuringExecution: true,
publicationOrigin: "refresh",
}); });
return true; return true;
} catch (error) { } catch (error) {
@@ -3963,6 +4004,7 @@ export const useQueryStore = defineStore("query", () => {
.forEach((tab) => { .forEach((tab) => {
rollbackTabTransaction(tab, { resetAutoCommit: true }); rollbackTabTransaction(tab, { resetAutoCommit: true });
clearDataGridPendingSnapshotsForTab(tab.id); clearDataGridPendingSnapshotsForTab(tab.id);
beginClosingDataGridViewSnapshotsForTab(tab.id);
clearDataGridStructuredFilterStatesForTab(tab.id); clearDataGridStructuredFilterStatesForTab(tab.id);
clearDataGridSearchStatesForTab(tab.id); clearDataGridSearchStatesForTab(tab.id);
if (tab.isExecuting) void cancelTabExecution(tab.id); if (tab.isExecuting) void cancelTabExecution(tab.id);
@@ -4521,6 +4563,8 @@ export const useQueryStore = defineStore("query", () => {
tab.result = toErrorResult(e); tab.result = toErrorResult(e);
tab.results = undefined; tab.results = undefined;
tab.activeResultIndex = undefined; tab.activeResultIndex = undefined;
// An error result is a replacement, not the previous dataset.
publishResultGeneration(tab, "execute");
tab.resultSessionId = undefined; tab.resultSessionId = undefined;
tab.resultClientSessionId = undefined; tab.resultClientSessionId = undefined;
tab.isExecuting = false; tab.isExecuting = false;
@@ -4550,6 +4594,7 @@ export const useQueryStore = defineStore("query", () => {
current.activeResultIndex = undefined; current.activeResultIndex = undefined;
current.resultSessionId = undefined; current.resultSessionId = undefined;
current.resultClientSessionId = undefined; current.resultClientSessionId = undefined;
publishResultGeneration(current, "execute");
touchResult(current); touchResult(current);
} }
clearLiveBatchSqlExecution(current, executionId); clearLiveBatchSqlExecution(current, executionId);
@@ -4584,7 +4629,10 @@ export const useQueryStore = defineStore("query", () => {
current.resultGridRevision = uuid(); current.resultGridRevision = uuid();
if (current.activeResultRunId) syncActiveResultRunFromDisplayed(current); if (current.activeResultRunId) syncActiveResultRunFromDisplayed(current);
await nextTick(); await nextTick();
if (previousGridKey && options?.openInNewResultTab !== true) clearDataGridPendingSnapshot(previousGridKey); if (previousGridKey && options?.openInNewResultTab !== true) {
clearDataGridPendingSnapshot(previousGridKey);
clearDataGridViewSnapshot(previousGridKey);
}
} }
} }
return producedResult; return producedResult;
@@ -5458,6 +5506,8 @@ export const useQueryStore = defineStore("query", () => {
}; };
pagination?: { limit: number; offset: number; sessionId?: string; clientSessionId?: string }; pagination?: { limit: number; offset: number; sessionId?: string; clientSessionId?: string };
appendResult?: { maxRows: number }; appendResult?: { maxRows: number };
/** Logical-result publication origin for the view-snapshot cache. */
publicationOrigin?: ResultPublicationOrigin;
mongoSafety?: MongoAggregateSafetyOptions; mongoSafety?: MongoAggregateSafetyOptions;
preserveResultDuringExecution?: boolean; preserveResultDuringExecution?: boolean;
preserveTotalRowCountDuringExecution?: boolean; preserveTotalRowCountDuringExecution?: boolean;
@@ -5681,6 +5731,8 @@ export const useQueryStore = defineStore("query", () => {
current.activeResultIndex = undefined; current.activeResultIndex = undefined;
current.result = allResults[0]; current.result = allResults[0];
} }
// Redis command batches always replace the visible result.
publishResultGeneration(current, "execute");
producedResult = current.result !== undefined; producedResult = current.result !== undefined;
touchResult(current); touchResult(current);
current.queryAnalysis = undefined; current.queryAnalysis = undefined;
@@ -6066,6 +6118,7 @@ export const useQueryStore = defineStore("query", () => {
current.activeResultIndex = undefined; current.activeResultIndex = undefined;
current.result = allResults[0]; current.result = allResults[0];
} }
publishResultGeneration(current, shouldAppendResult ? "append" : "execute");
producedResult = current.result !== undefined; producedResult = current.result !== undefined;
touchResult(current); touchResult(current);
current.queryAnalysis = undefined; current.queryAnalysis = undefined;
@@ -6134,6 +6187,8 @@ export const useQueryStore = defineStore("query", () => {
current.results = allResults.length > 1 ? allResults : undefined; current.results = allResults.length > 1 ? allResults : undefined;
current.activeResultIndex = allResults.length > 1 ? resultIndex : undefined; current.activeResultIndex = allResults.length > 1 ? resultIndex : undefined;
current.result = allResults[resultIndex]; current.result = allResults[resultIndex];
// Elasticsearch batch requests replace the visible result.
publishResultGeneration(current, "execute");
producedResult = current.result !== undefined; producedResult = current.result !== undefined;
touchResult(current); touchResult(current);
current.queryAnalysis = undefined; current.queryAnalysis = undefined;
@@ -6461,6 +6516,9 @@ export const useQueryStore = defineStore("query", () => {
current.activeResultIndex = undefined; current.activeResultIndex = undefined;
current.result = results[0]; current.result = results[0];
} }
// Logical-result identity for the view-snapshot cache. An append extends
// the same dataset; every other branch above replaces it.
publishResultGeneration(current, shouldAppendResult ? "append" : (options?.publicationOrigin ?? "execute"));
producedResult = current.result !== undefined; producedResult = current.result !== undefined;
current.resultBaseSql = batchResume ? batchResume.batch.submittedSql : shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql; current.resultBaseSql = batchResume ? batchResume.batch.submittedSql : shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
current.resultEditorFingerprint = batchResume ? batchResume.batch.editorFingerprint : shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint; current.resultEditorFingerprint = batchResume ? batchResume.batch.editorFingerprint : shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint;
@@ -6651,6 +6709,8 @@ export const useQueryStore = defineStore("query", () => {
current.resultTotalRowCountLoading = false; current.resultTotalRowCountLoading = false;
touchResult(current); touchResult(current);
producedResult = true; producedResult = true;
// An error result replaces the dataset the view snapshot was taken on.
publishResultGeneration(current, "execute");
// When a pinned result requires a new run, errors must use that same // When a pinned result requires a new run, errors must use that same
// run instead of being replaced by the retained pinned result below. // run instead of being replaced by the retained pinned result below.
syncDisplayedResultRun(current, queryBaseSql, captureResultRun); syncDisplayedResultRun(current, queryBaseSql, captureResultRun);
@@ -7274,6 +7334,8 @@ export const useQueryStore = defineStore("query", () => {
// 置空让 projectResultRun 按需重算 // 置空让 projectResultRun 按需重算
tab.resultRuns = snapshot.resultRuns ? markQueryResultRunsRowsRaw(snapshot.resultRuns).map((run) => ({ ...run, resultEstimatedBytes: undefined })) : tab.resultRuns; tab.resultRuns = snapshot.resultRuns ? markQueryResultRunsRowsRaw(snapshot.resultRuns).map((run) => ({ ...run, resultEstimatedBytes: undefined })) : tab.resultRuns;
tab.activeResultRunId = snapshot.activeResultRunId ?? tab.activeResultRunId; tab.activeResultRunId = snapshot.activeResultRunId ?? tab.activeResultRunId;
// Disk restore is the same logical result: keep the captured view identity.
tab.resultViewGeneration = snapshot.resultViewGeneration ?? tab.resultViewGeneration;
if (!tab.result && !tab.results && !tab.resultRuns) return false; if (!tab.result && !tab.results && !tab.resultRuns) return false;
tab.queryAnalysis = snapshot.queryAnalysis; tab.queryAnalysis = snapshot.queryAnalysis;
+9
View File
@@ -800,6 +800,13 @@ export interface QueryResultRun {
pinned?: boolean; pinned?: boolean;
/** Distinguishes successive result payloads that reuse the same run slot. */ /** Distinguishes successive result payloads that reuse the same run slot. */
resultGridRevision?: string; resultGridRevision?: string;
/**
* Logical-result identity for the tab-switch view snapshot cache. Distinct
* from `resultGridRevision` (the grid remount key): this one changes on every
* dataset replacement, including in-place refresh, and is preserved across
* disk eviction/restore. See `dataGridViewStateCache.ts`.
*/
resultViewGeneration?: string;
result?: QueryResult; result?: QueryResult;
results?: QueryResult[]; results?: QueryResult[];
activeResultIndex?: number; activeResultIndex?: number;
@@ -1200,6 +1207,8 @@ export interface QueryTab {
activeResultIndex?: number; activeResultIndex?: number;
/** Distinguishes successive result payloads that reuse the current result slot. */ /** Distinguishes successive result payloads that reuse the current result slot. */
resultGridRevision?: string; resultGridRevision?: string;
/** Logical-result identity for the tab-switch view snapshot cache; see QueryResultRun. */
resultViewGeneration?: string;
resultRuns?: QueryResultRun[]; resultRuns?: QueryResultRun[];
activeResultRunId?: string; activeResultRunId?: string;
resultAutoSave?: boolean; resultAutoSave?: boolean;