diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 2685ddd8fc..2f04441d6d 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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 { createPendingSelectionSummary, formatSelectionAggregate, formatSelectionAverage, summarizeSelection } from "@/lib/dataGrid/gridSelection"; 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 { createDataGridCellContextMenuItems, @@ -485,6 +486,13 @@ interface DataGridProps { cacheKey?: string; columnWidthCacheKey?: 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; onExecuteSql?: (sql: string) => Promise; fullExportResult?: (onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => Promise; @@ -1197,6 +1205,11 @@ const filterBuilderOpen = filterBuilder.open; const filterBuilderColumnSearch = filterBuilder.columnSearch; const filteredFilterBuilderColumnOptions = filterBuilder.filteredColumns; 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 filterEditorView = computed(() => settingsStore.editorSettings.dataGridFilterEditorView); const isPersistentFilterView = computed(() => filterEditorView.value === "conditions" || filterEditorView.value === "text"); @@ -1464,6 +1477,8 @@ function persistStructuredFilterState() { } function loadStructuredFilterStateForScope() { + const requestId = ++structuredFilterHydrationRequestId; + structuredFilterHydrationReady.value = false; const cached = cachedStructuredFilterState(); if (cached) { const cacheKey = structuredFilterCacheKey.value; @@ -1472,14 +1487,23 @@ function loadStructuredFilterStateForScope() { whereFilterInput.value = cached.manualWhereInput; serverColumnFilters.value = structuredClone(cached.serverColumnFilters ?? {}); appliedStructuredWhereInput.value = ""; - void buildStructuredWhereFromRules(structuredFilterRules.value).then((whereInput) => { - if (structuredFilterCacheKey.value !== cacheKey || structuredFilterScopeKey.value !== scopeKey) return; - appliedStructuredWhereInput.value = whereInput; - nextTick(() => { - emit("update:whereInput", currentWhereInput() ?? ""); - markConditionInputsApplied(); + void buildStructuredWhereFromRules(structuredFilterRules.value) + .then((whereInput) => { + if (requestId !== structuredFilterHydrationRequestId || structuredFilterCacheKey.value !== cacheKey || structuredFilterScopeKey.value !== scopeKey) return; + appliedStructuredWhereInput.value = whereInput; + nextTick(() => { + 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; } appliedStructuredWhereInput.value = ""; @@ -1487,6 +1511,7 @@ function loadStructuredFilterStateForScope() { structuredFilterRules.value = filterBuilderColumnOptions.value.length > 0 ? [defaultStructuredFilterRule()] : []; persistStructuredFilterState(); markConditionInputsApplied(); + structuredFilterHydrationReady.value = true; } 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[] { + 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): 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[] { + 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(() => { if (hasRowSelection.value) return selectedRowCount.value; const range = selectedRange.value; @@ -6848,11 +7118,20 @@ onMounted(() => { window.visualViewport?.addEventListener("resize", refreshDataGridViewportMetrics); window.addEventListener("dbx:ui-scale-applied", refreshDataGridViewportMetrics); window.addEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onSynchronizedTableDataGridColumnOrderChanged); + window.addEventListener("dbx:before-tab-switch", captureTabSwitchViewSnapshot); window.addEventListener("blur", clearInternalClipboardCopy); document.addEventListener("visibilitychange", clearInternalClipboardCopy); + nextTick(restoreTabSwitchViewSnapshot); }); onDeactivated(pauseCanvasGridWork); 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(); dataGridRuntimeScope.dispose(); foreignKeyDisplayRequests.dispose(); @@ -6874,6 +7153,7 @@ onUnmounted(() => { window.visualViewport?.removeEventListener("resize", refreshDataGridViewportMetrics); window.removeEventListener("dbx:ui-scale-applied", refreshDataGridViewportMetrics); window.removeEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onSynchronizedTableDataGridColumnOrderChanged); + window.removeEventListener("dbx:before-tab-switch", captureTabSwitchViewSnapshot); window.removeEventListener("blur", clearInternalClipboardCopy); document.removeEventListener("visibilitychange", clearInternalClipboardCopy); }); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridViewSnapshotRestore.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridViewSnapshotRestore.spec.ts new file mode 100644 index 0000000000..0160b6882b --- /dev/null +++ b/apps/desktop/src/components/grid/__tests__/DataGridViewSnapshotRestore.spec.ts @@ -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"); + }); +}); diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 4eff8a21aa..87acac1b74 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -1842,6 +1842,7 @@ defineExpose({ :cache-key="activeResultGridCacheKey" :column-width-cache-key="activeResultGridColumnWidthCacheKey" :pending-state-key="activeResultGridInstanceKey" + :view-generation="activeTab.resultViewGeneration" class="flex-1 min-h-0" :result="activeTab.result" :sort-column="activeTab.resultSortColumn" @@ -2270,6 +2271,7 @@ defineExpose({ class="flex-1 min-h-0" :key="activeTab.id" :cache-key="activeTab.id" + :view-generation="activeTab.resultViewGeneration" :result="activeTab.result" :sort-column="activeTab.resultSortColumn" :sort-column-index="activeTab.resultSortColumnIndex" diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 543c2d13b6..679eb573af 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -2201,6 +2201,7 @@ export default withEnglishFallback({ queryError: "Sorğu xətası", saveErrorTitle: "Dəyişiklikləri yadda saxlamaq alınmadı", 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.", reexecuteQuery: "Sorğunu yenidən işlət", dataUnavailableHintPrefix: "Basın: ", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 3b5d8eeb09..424d2ba0f4 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -2202,6 +2202,7 @@ export default { queryError: "Query Error", saveErrorTitle: "Failed to Save Changes", 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.", reexecuteQuery: "Run query again", dataUnavailableHintPrefix: "Press ", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 207fb7749d..ec9278faa2 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -2051,6 +2051,7 @@ export default withEnglishFallback({ queryError: "Error de consulta", saveErrorTitle: "Error al guardar los cambios", 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 ", dataUnavailableHintSuffix: " o haz clic en Actualizar abajo para recargar.", refresh: "Actualizar", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 3d6ba14847..b81c87db2d 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -2049,6 +2049,7 @@ export default withEnglishFallback({ queryError: "Errore Query", saveErrorTitle: "Impossibile salvare le modifiche", 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 ", dataUnavailableHintSuffix: " o fai clic su Aggiorna qui sotto per ricaricare.", refresh: "Aggiorna", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 16bf056be6..7335d2a405 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -2061,6 +2061,7 @@ export default withEnglishFallback({ queryError: "クエリエラー", saveErrorTitle: "変更の保存に失敗しました", dataUnavailable: "テーブルデータを再読み込みする必要があります。", + viewSnapshotSelectionNotRestored: "前回の表示位置は復元しましたが、選択範囲が大きすぎるため復元できませんでした。", dataUnavailableHintPrefix: "", dataUnavailableHintSuffix: " を押すか、下の更新ボタンをクリックして再読み込みしてください。", refresh: "更新", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index ac7038f40d..deeb4a3adb 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -2025,6 +2025,7 @@ export default withEnglishFallback({ queryError: "쿼리 오류", saveErrorTitle: "변경사항 저장 실패", dataUnavailable: "테이블 데이터를 다시 불러와야 합니다.", + viewSnapshotSelectionNotRestored: "이전 보기 위치는 복원했지만 선택 범위가 너무 커서 복원하지 못했습니다.", cachedResultUnavailable: "캐시된 결과가 없거나 호환되지 않습니다.", reexecuteQuery: "쿼리 다시 실행", dataUnavailableHintPrefix: "", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 93317aac95..8f71dd6db3 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -2051,6 +2051,7 @@ export default withEnglishFallback({ queryError: "Erro na Consulta", saveErrorTitle: "Falha ao salvar alterações", 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 ", dataUnavailableHintSuffix: " ou clique em Atualizar abaixo para recarregar.", refresh: "Atualizar", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 265ee907e4..bbc9d8c583 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -2196,6 +2196,7 @@ export default withEnglishFallback({ queryError: "Sorgu Hatası", saveErrorTitle: "Değişiklikler Kaydedilemedi", 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.", reexecuteQuery: "Sorguyu yeniden çalıştır", dataUnavailableHintPrefix: "Yeniden yüklemek için ", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index de500b01a6..5c3e3df73c 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -2126,6 +2126,7 @@ export default withEnglishFallback({ queryError: "查询出错", saveErrorTitle: "数据修改保存失败", dataUnavailable: "表数据需要重新加载。", + viewSnapshotSelectionNotRestored: "已恢复上次的视图位置,但过大的选择范围未能保留。", dataUnavailableHintPrefix: "按 ", dataUnavailableHintSuffix: " 或点击下方刷新按钮重新加载。", cachedResultUnavailable: "缓存结果缺失或不兼容。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 1165a718de..21aae73880 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -2052,6 +2052,7 @@ export default withEnglishFallback({ searchColumn: "搜尋欄/註解...", noColumnsFound: "未找到欄", dataUnavailable: "資料表資料需要重新載入。", + viewSnapshotSelectionNotRestored: "已還原上次的檢視位置,但過大的選取範圍未能保留。", dataUnavailableHintPrefix: "按 ", dataUnavailableHintSuffix: " 或點擊下方重新整理來重新載入。", queryError: "查詢出錯", diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridViewStateCache.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridViewStateCache.spec.ts new file mode 100644 index 0000000000..d2bbfd661f --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridViewStateCache.spec.ts @@ -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 { + 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"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/tabs/tabResultCache.viewGeneration.spec.ts b/apps/desktop/src/lib/__tests__/tabs/tabResultCache.viewGeneration.spec.ts new file mode 100644 index 0000000000..dfc07824ce --- /dev/null +++ b/apps/desktop/src/lib/__tests__/tabs/tabResultCache.viewGeneration.spec.ts @@ -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"); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridViewStateCache.ts b/apps/desktop/src/lib/dataGrid/dataGridViewStateCache.ts new file mode 100644 index 0000000000..ee027ef5cf --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridViewStateCache.ts @@ -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(); +/** + * 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(); +/** + * 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(); + +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, + }); +} diff --git a/apps/desktop/src/lib/tabs/tabResultCache.ts b/apps/desktop/src/lib/tabs/tabResultCache.ts index bde99a867d..4e4afea4da 100644 --- a/apps/desktop/src/lib/tabs/tabResultCache.ts +++ b/apps/desktop/src/lib/tabs/tabResultCache.ts @@ -32,6 +32,12 @@ export interface TabResultSnapshot { resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"]; resultRuns?: QueryTab["resultRuns"]; 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"]; querySourceColumns?: QueryTab["querySourceColumns"]; queryWriteTargets?: QueryTab["queryWriteTargets"]; @@ -747,6 +753,7 @@ export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undef resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments ? clonePlain(tab.resultLocalSortOriginalMongoCopyDocuments) : undefined, resultRuns: stripResultRunSessionIds(tab.resultRuns), activeResultRunId: tab.activeResultRunId, + resultViewGeneration: tab.resultViewGeneration, queryAnalysis: tab.queryAnalysis ? clonePlain(tab.queryAnalysis) : undefined, querySourceColumns: tab.querySourceColumns ? [...tab.querySourceColumns] : undefined, queryWriteTargets: tab.queryWriteTargets?.map((target) => ({ ...target, sourceColumns: [...target.sourceColumns] })), diff --git a/apps/desktop/src/stores/__tests__/queryStore.resultViewGeneration.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.resultViewGeneration.spec.ts new file mode 100644 index 0000000000..e2a552ce19 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.resultViewGeneration.spec.ts @@ -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(); + 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"); + }); +}); diff --git a/apps/desktop/src/stores/__tests__/queryStore.resultViewGenerationSource.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.resultViewGenerationSource.spec.ts new file mode 100644 index 0000000000..842900cff6 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.resultViewGenerationSource.spec.ts @@ -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); + }); +}); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 8f25d53f6e..be982ac41f 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -60,6 +60,7 @@ import { replaceSqlServerLeadingUseQuery, sqlServerLeadingUseScript, sqlServerUs import { classifySqlRisk } from "@/lib/sql/sqlRisk"; import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen"; import { clearDataGridPendingSnapshot, clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor"; +import { beginClosingDataGridViewSnapshotsForTab, clearDataGridViewSnapshot, clearDataGridViewSnapshotsForTab } from "@/lib/dataGrid/dataGridViewStateCache"; import { clearDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGridFilterBuilderPersistence"; import { clearDataGridSearchStatesForTab } from "@/lib/dataGrid/dataGridSearchStatePersistence"; import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache"; @@ -1475,6 +1476,12 @@ export const useQueryStore = defineStore("query", () => { if (!options.evicted) { if (tab.resultCacheKey && !options.preserveCacheSnapshot) void deleteTabResultSnapshot(tab.resultCacheKey); 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.activeResultIndex = run.activeResultIndex; 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.resultBaseSql = run.resultBaseSql; tab.resultEditorFingerprint = run.resultEditorFingerprint; @@ -1877,6 +1887,7 @@ export const useQueryStore = defineStore("query", () => { results: tab.results, activeResultIndex: tab.activeResultIndex, resultGridRevision: tab.resultGridRevision, + resultViewGeneration: tab.resultViewGeneration, batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution), resultBaseSql: tab.resultBaseSql, resultEditorFingerprint: tab.resultEditorFingerprint, @@ -1978,6 +1989,7 @@ export const useQueryStore = defineStore("query", () => { results: tab.results, activeResultIndex: tab.activeResultIndex, resultGridRevision: tab.resultGridRevision, + resultViewGeneration: tab.resultViewGeneration, batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution), resultBaseSql: tab.resultBaseSql, 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) { const tab = tabs.value.find((t) => t.id === id); 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 large_value_cells = remapLargeValueCells(tab.resultLocalSortOriginalLargeValueCells, rowIndexes); 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.resultSortColumnIndex = direction ? columnIndex : undefined; @@ -2532,6 +2567,9 @@ export const useQueryStore = defineStore("query", () => { tab.executionId = undefined; tab.executingResultRunId = 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); return id; } @@ -3385,6 +3423,7 @@ export const useQueryStore = defineStore("query", () => { persistSavedSqlEditorPosition(tabs.value[idx]); if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id); clearDataGridPendingSnapshotsForTab(id); + beginClosingDataGridViewSnapshotsForTab(id); clearDataGridStructuredFilterStatesForTab(id); clearDataGridSearchStatesForTab(id); if (tabs.value[idx].txnSessionId) void rollbackTransaction(id); @@ -3746,6 +3785,7 @@ export const useQueryStore = defineStore("query", () => { .forEach((tab) => { if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id); clearDataGridPendingSnapshotsForTab(tab.id); + beginClosingDataGridViewSnapshotsForTab(tab.id); clearDataGridStructuredFilterStatesForTab(tab.id); clearDataGridSearchStatesForTab(tab.id); if (tab.txnSessionId) void rollbackTransaction(tab.id); @@ -3930,6 +3970,7 @@ export const useQueryStore = defineStore("query", () => { await executeTabSql(tab.id, sql, { pagination: { limit, offset }, preserveResultDuringExecution: true, + publicationOrigin: "refresh", }); return true; } catch (error) { @@ -3963,6 +4004,7 @@ export const useQueryStore = defineStore("query", () => { .forEach((tab) => { rollbackTabTransaction(tab, { resetAutoCommit: true }); clearDataGridPendingSnapshotsForTab(tab.id); + beginClosingDataGridViewSnapshotsForTab(tab.id); clearDataGridStructuredFilterStatesForTab(tab.id); clearDataGridSearchStatesForTab(tab.id); if (tab.isExecuting) void cancelTabExecution(tab.id); @@ -4521,6 +4563,8 @@ export const useQueryStore = defineStore("query", () => { tab.result = toErrorResult(e); tab.results = undefined; tab.activeResultIndex = undefined; + // An error result is a replacement, not the previous dataset. + publishResultGeneration(tab, "execute"); tab.resultSessionId = undefined; tab.resultClientSessionId = undefined; tab.isExecuting = false; @@ -4550,6 +4594,7 @@ export const useQueryStore = defineStore("query", () => { current.activeResultIndex = undefined; current.resultSessionId = undefined; current.resultClientSessionId = undefined; + publishResultGeneration(current, "execute"); touchResult(current); } clearLiveBatchSqlExecution(current, executionId); @@ -4584,7 +4629,10 @@ export const useQueryStore = defineStore("query", () => { current.resultGridRevision = uuid(); if (current.activeResultRunId) syncActiveResultRunFromDisplayed(current); await nextTick(); - if (previousGridKey && options?.openInNewResultTab !== true) clearDataGridPendingSnapshot(previousGridKey); + if (previousGridKey && options?.openInNewResultTab !== true) { + clearDataGridPendingSnapshot(previousGridKey); + clearDataGridViewSnapshot(previousGridKey); + } } } return producedResult; @@ -5458,6 +5506,8 @@ export const useQueryStore = defineStore("query", () => { }; pagination?: { limit: number; offset: number; sessionId?: string; clientSessionId?: string }; appendResult?: { maxRows: number }; + /** Logical-result publication origin for the view-snapshot cache. */ + publicationOrigin?: ResultPublicationOrigin; mongoSafety?: MongoAggregateSafetyOptions; preserveResultDuringExecution?: boolean; preserveTotalRowCountDuringExecution?: boolean; @@ -5681,6 +5731,8 @@ export const useQueryStore = defineStore("query", () => { current.activeResultIndex = undefined; current.result = allResults[0]; } + // Redis command batches always replace the visible result. + publishResultGeneration(current, "execute"); producedResult = current.result !== undefined; touchResult(current); current.queryAnalysis = undefined; @@ -6066,6 +6118,7 @@ export const useQueryStore = defineStore("query", () => { current.activeResultIndex = undefined; current.result = allResults[0]; } + publishResultGeneration(current, shouldAppendResult ? "append" : "execute"); producedResult = current.result !== undefined; touchResult(current); current.queryAnalysis = undefined; @@ -6134,6 +6187,8 @@ export const useQueryStore = defineStore("query", () => { current.results = allResults.length > 1 ? allResults : undefined; current.activeResultIndex = allResults.length > 1 ? resultIndex : undefined; current.result = allResults[resultIndex]; + // Elasticsearch batch requests replace the visible result. + publishResultGeneration(current, "execute"); producedResult = current.result !== undefined; touchResult(current); current.queryAnalysis = undefined; @@ -6461,6 +6516,9 @@ export const useQueryStore = defineStore("query", () => { current.activeResultIndex = undefined; 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; current.resultBaseSql = batchResume ? batchResume.batch.submittedSql : shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql; current.resultEditorFingerprint = batchResume ? batchResume.batch.editorFingerprint : shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint; @@ -6651,6 +6709,8 @@ export const useQueryStore = defineStore("query", () => { current.resultTotalRowCountLoading = false; touchResult(current); 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 // run instead of being replaced by the retained pinned result below. syncDisplayedResultRun(current, queryBaseSql, captureResultRun); @@ -7274,6 +7334,8 @@ export const useQueryStore = defineStore("query", () => { // 置空让 projectResultRun 按需重算 tab.resultRuns = snapshot.resultRuns ? markQueryResultRunsRowsRaw(snapshot.resultRuns).map((run) => ({ ...run, resultEstimatedBytes: undefined })) : tab.resultRuns; 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; tab.queryAnalysis = snapshot.queryAnalysis; diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 7a129f4b15..195a2185e4 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -800,6 +800,13 @@ export interface QueryResultRun { pinned?: boolean; /** Distinguishes successive result payloads that reuse the same run slot. */ 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; results?: QueryResult[]; activeResultIndex?: number; @@ -1200,6 +1207,8 @@ export interface QueryTab { activeResultIndex?: number; /** Distinguishes successive result payloads that reuse the current result slot. */ resultGridRevision?: string; + /** Logical-result identity for the tab-switch view snapshot cache; see QueryResultRun. */ + resultViewGeneration?: string; resultRuns?: QueryResultRun[]; activeResultRunId?: string; resultAutoSave?: boolean;