From 451e50b2fba8819d4da81ca2c8876a7751065022 Mon Sep 17 00:00:00 2001 From: onenewcode Date: Wed, 9 Sep 2026 12:01:49 +0800 Subject: [PATCH] fix(grid): preserve data tab scroll and search across switches --- apps/desktop/src/components/grid/DataGrid.vue | 11 ++ .../sidebar/SidebarTreeRuntimeHost.vue | 4 +- .../__tests__/useDataGridEditor.spec.ts | 136 +++++++++++++++++- .../__tests__/useDataGridSearch.spec.ts | 133 ++++++++++++++++- .../src/composables/useDataGridEditor.ts | 49 ++++++- .../src/composables/useDataGridSearch.ts | 76 +++++++++- .../dataGridSearchStatePersistence.spec.ts | 83 +++++++++++ .../dataGridSearchStatePersistence.ts | 61 ++++++++ apps/desktop/src/stores/queryStore.ts | 4 + .../dataGridSearchStateRestore.test.ts | 50 +++++++ .../dataGridTabSwitchScrollRestore.test.ts | 60 ++++++++ 11 files changed, 652 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/dataGrid/dataGridSearchStatePersistence.spec.ts create mode 100644 apps/desktop/src/lib/dataGrid/dataGridSearchStatePersistence.ts create mode 100644 packages/app-tests/dataGridSearchStateRestore.test.ts create mode 100644 packages/app-tests/dataGridTabSwitchScrollRestore.test.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index fc6f08e686..519e3c8c31 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -306,6 +306,7 @@ import { useDataGridAutoRefresh } from "@/composables/useDataGridAutoRefresh"; import { useDataGridAsyncSurface } from "@/composables/useDataGridAsyncSurface"; import { createDataGridFilterConditionCache, useDataGridFilterBuilder, type DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder"; import { cloneDataGridStructuredFilterRules, loadDataGridStructuredFilterState, saveDataGridStructuredFilterState, type DataGridCachedServerColumnFilter, type DataGridStructuredFilterCacheState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence"; +import { createDataGridSearchScopeKey } from "@/lib/dataGrid/dataGridSearchStatePersistence"; import { useSqlHighlighter } from "@/composables/useSqlHighlighter"; import { useCellDetailEditor, type UseCellDetailEditorReturn } from "@/composables/useCellDetailEditor"; import { useDataGridCellDetailEdit } from "@/composables/useDataGridCellDetailEdit"; @@ -992,6 +993,10 @@ const dataGridSearch = useDataGridSearch({ rows: () => displayItems.value, getCellSearchText: (row, columnIndex) => (row.data[columnIndex] === null ? "" : rowLowerTextCache.get(row.data, columnIndex)), onNavigate: () => nextTick(scrollToCurrentMatch), + // Same key as useDataGridEditor below: table data tabs use the tab id, query + // results use resultGridInstanceKey so a re-execute starts with a clean search. + persistenceKey: () => props.pendingStateKey ?? props.cacheKey, + persistenceScopeKey: () => createDataGridSearchScopeKey(props.result.columns), }); const { searchText, @@ -1006,6 +1011,12 @@ const { currentMatch: currentSearchMatch, } = dataGridSearch; +// Registered ahead of useDataGridEditor's own onMounted so a restored query +// resolves the row set — "filter" search mode shrinks sortedRows — before +// applyScrollPosition measures the content height. Deliberately does not focus the +// search input: the user is returning to the grid, not to the search box. +onMounted(() => dataGridSearch.restorePersistedState()); + const orderByInput = ref(props.initialOrderByInput ?? ""); const whereFilterInput = ref(props.initialWhereInput ?? ""); const conditionInputRevision = ref(0); diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 7943ab671c..bd65413ed8 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -1533,9 +1533,7 @@ function openMongoTreeData(node: TreeNode) { return; } if (node.type !== "mongo-collection") return; - const existing = queryStore.tabs.find( - (tab) => tab.mode === "mongo" && tab.connectionId === node.connectionId && tab.database === node.database && tab.tableMeta?.tableName === node.label, - ); + const existing = queryStore.tabs.find((tab) => tab.mode === "mongo" && tab.connectionId === node.connectionId && tab.database === node.database && tab.tableMeta?.tableName === node.label); if (existing) { queryStore.switchTab(existing.id); return; diff --git a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts index fbf9132d09..c82840fd59 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridEditor.spec.ts @@ -38,7 +38,7 @@ vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({}), })); -function createEditor( +function createEditorWithResult( sourceColumns?: Array, confirmDangerousRowDeletion = true, cacheKey?: string, @@ -125,7 +125,33 @@ function createEditor( }); editor.newRows.value = [[null, null, null]]; - return editor; + return { editor, result }; +} + +function createEditor(...args: Parameters) { + return createEditorWithResult(...args).editor; +} + +function beforeTabSwitchEvent(fromTabId: string, tabId = "next-tab") { + return new CustomEvent("dbx:before-tab-switch", { detail: { tabId, fromTabId } }); +} + +function setupTabSwitchScrollFixture() { + class TestScroller { + scrollTop = 0; + scrollLeft = 0; + scrollTo({ top, left }: ScrollToOptions) { + if (typeof top === "number") this.scrollTop = top; + if (typeof left === "number") this.scrollLeft = left; + } + } + vi.stubGlobal("HTMLElement", TestScroller); + const rows: CellValue[][] = [ + ["a", null, 1], + ["b", null, 2], + ["c", null, 3], + ]; + return { TestScroller, rows }; } describe("useDataGridEditor result snapshots", () => { @@ -200,6 +226,112 @@ describe("useDataGridEditor result snapshots", () => { expect(remountedScroller.scrollLeft).toBe(0); }); + it("adopts a scroll-only snapshot when the previous instance was torn down by a tab switch (#8524)", () => { + const { TestScroller, rows } = setupTabSwitchScrollFixture(); + const key = "table-tab-8524"; + const previous = createEditor(undefined, true, key, undefined, rows); + previous.newRows.value = []; + const previousScroller = new TestScroller(); + previousScroller.scrollTop = 6_400; + previousScroller.scrollLeft = 24; + previous.scrollerRef.value = previousScroller as unknown as NonNullable; + previous.onBeforeTabSwitch(beforeTabSwitchEvent(key)); + // Unmount path runs right after the switch and must not clobber the provenance. + previous.savePendingSnapshot(true, true); + + const remounted = createEditor(undefined, true, key, undefined, rows); + const remountedScroller = new TestScroller(); + remounted.scrollerRef.value = remountedScroller as unknown as NonNullable; + remounted.restorePendingSnapshotFocus(); + expect(remountedScroller.scrollTop).toBe(6_400); + expect(remountedScroller.scrollLeft).toBe(24); + }); + + it("ignores a tab switch that names a different tab", () => { + const { TestScroller, rows } = setupTabSwitchScrollFixture(); + const key = "table-tab-other-group"; + const previous = createEditor(undefined, true, key, undefined, rows); + previous.newRows.value = []; + const previousScroller = new TestScroller(); + previousScroller.scrollTop = 6_400; + previousScroller.scrollLeft = 24; + previous.scrollerRef.value = previousScroller as unknown as NonNullable; + // Split groups keep several grids mounted; this one did not change tabs. + previous.onBeforeTabSwitch(beforeTabSwitchEvent("some-other-tab")); + previous.savePendingSnapshot(true, true); + + const remounted = createEditor(undefined, true, key, undefined, rows); + const remountedScroller = new TestScroller(); + remounted.scrollerRef.value = remountedScroller as unknown as NonNullable; + remounted.restorePendingSnapshotFocus(); + expect(remountedScroller.scrollTop).toBe(0); + expect(remountedScroller.scrollLeft).toBe(0); + }); + + it("ignores a tab switch event without a fromTabId", () => { + const { TestScroller, rows } = setupTabSwitchScrollFixture(); + const key = "table-tab-no-origin"; + const previous = createEditor(undefined, true, key, undefined, rows); + previous.newRows.value = []; + const previousScroller = new TestScroller(); + previousScroller.scrollTop = 6_400; + previousScroller.scrollLeft = 24; + previous.scrollerRef.value = previousScroller as unknown as NonNullable; + previous.onBeforeTabSwitch(new CustomEvent("dbx:before-tab-switch", { detail: { tabId: "next-tab" } })); + previous.savePendingSnapshot(true, true); + + const remounted = createEditor(undefined, true, key, undefined, rows); + const remountedScroller = new TestScroller(); + remounted.scrollerRef.value = remountedScroller as unknown as NonNullable; + remounted.restorePendingSnapshotFocus(); + expect(remountedScroller.scrollTop).toBe(0); + }); + + it("drops the tab-switch scroll once the result identity changes", async () => { + const { TestScroller, rows } = setupTabSwitchScrollFixture(); + const key = "table-tab-reloaded"; + const { editor: previous, result } = createEditorWithResult(undefined, true, key, undefined, rows); + previous.newRows.value = []; + const previousScroller = new TestScroller(); + previousScroller.scrollTop = 6_400; + previousScroller.scrollLeft = 24; + previous.scrollerRef.value = previousScroller as unknown as NonNullable; + previous.onBeforeTabSwitch(beforeTabSwitchEvent(key)); + previous.savePendingSnapshot(true, true); + + // A reload lands a new row array: the grid must start at the first row (#7341). + result.value = { columns: ["first", "hidden", "last"], rows: rows.map((row) => [...row]) }; + await nextTick(); + previous.savePendingSnapshot(true, true); + + const remounted = createEditor(undefined, true, key, undefined, rows); + const remountedScroller = new TestScroller(); + remounted.scrollerRef.value = remountedScroller as unknown as NonNullable; + remounted.restorePendingSnapshotFocus(); + expect(remountedScroller.scrollTop).toBe(0); + }); + + it("clears the tab-switch provenance when the grid is explicitly scrolled to the top", () => { + const { TestScroller, rows } = setupTabSwitchScrollFixture(); + const key = "table-tab-reset-scroll"; + const previous = createEditor(undefined, true, key, undefined, rows); + previous.newRows.value = []; + const previousScroller = new TestScroller(); + previousScroller.scrollTop = 6_400; + previousScroller.scrollLeft = 24; + previous.scrollerRef.value = previousScroller as unknown as NonNullable; + previous.onBeforeTabSwitch(beforeTabSwitchEvent(key)); + // Sort/filter/paginate/refresh all route through here and mean "new viewport". + previous.resetGridVerticalScroll(true); + previous.savePendingSnapshot(true, true); + + const remounted = createEditor(undefined, true, key, undefined, rows); + const remountedScroller = new TestScroller(); + remounted.scrollerRef.value = remountedScroller as unknown as NonNullable; + remounted.restorePendingSnapshotFocus(); + expect(remountedScroller.scrollTop).toBe(0); + }); + it("still adopts the cached scroll when the snapshot carries pending edits", () => { class TestScroller { scrollTop = 0; diff --git a/apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts index 5212a017dd..ab23cd8a93 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridSearch.spec.ts @@ -1,6 +1,7 @@ import { nextTick, ref } from "vue"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useDataGridSearch } from "@/composables/useDataGridSearch"; +import { clearDataGridSearchStates, createDataGridSearchScopeKey, loadDataGridSearchState, saveDataGridSearchState } from "@/lib/dataGrid/dataGridSearchStatePersistence"; afterEach(() => { vi.useRealTimers(); @@ -123,3 +124,133 @@ describe("useDataGridSearch", () => { expect(search.currentMatchIndex.value).toBe(-1); }); }); + +describe("useDataGridSearch persistence (#8524)", () => { + const COLUMNS = ["left", "right"]; + const SCOPE = createDataGridSearchScopeKey(COLUMNS); + const ROWS = [ + ["hit", "hit"], + ["none", "hit"], + ]; + + function createSearch({ onNavigate = vi.fn(), persistenceKey = "tab-1" as string | undefined } = {}) { + const search = useDataGridSearch({ + columns: COLUMNS, + rows: ROWS, + getCellSearchText: (row: string[], column: number) => row[column], + onNavigate, + persistenceKey, + persistenceScopeKey: () => SCOPE, + }); + return { search, onNavigate }; + } + + beforeEach(() => clearDataGridSearchStates()); + + it("persists the query, overlay and match index as the user searches", async () => { + vi.useFakeTimers(); + const { search } = createSearch(); + search.overlayVisible.value = true; + search.searchText.value = "hit"; + await flushSearchDebounce(); + search.navigateMatch(1); + await nextTick(); + + const saved = loadDataGridSearchState("tab-1", SCOPE); + expect(saved).toMatchObject({ searchText: "hit", deferredSearchText: "hit", overlayVisible: true, currentMatchIndex: 1 }); + }); + + it("restores text, overlay and match index without auto-navigating or debouncing", async () => { + const { search, onNavigate } = createSearch(); + saveDataGridSearchState("tab-1", { scopeKey: SCOPE, searchText: "hit", deferredSearchText: "hit", overlayVisible: true, currentMatchIndex: 2 }); + + expect(search.restorePersistedState()).toBe(true); + // No advanceTimersByTime: the restore bypasses the debounce entirely. + await nextTick(); + await nextTick(); + + expect(search.overlayVisible.value).toBe(true); + expect(search.deferredSearchText.value).toBe("hit"); + expect(search.matchCount.value).toBe(3); + expect(search.currentMatchIndex.value).toBe(2); + // The grid's own scroll restore owns the viewport when returning to a tab. + expect(onNavigate).not.toHaveBeenCalled(); + }); + + it("clamps a stale restored match index", async () => { + const { search } = createSearch(); + saveDataGridSearchState("tab-1", { scopeKey: SCOPE, searchText: "none", deferredSearchText: "none", overlayVisible: true, currentMatchIndex: 9 }); + + search.restorePersistedState(); + await nextTick(); + await nextTick(); + + expect(search.matchCount.value).toBe(1); + expect(search.currentMatchIndex.value).toBe(0); + }); + + it("does not reopen the suggestion popup on restore", async () => { + const { search } = createSearch(); + saveDataGridSearchState("tab-1", { scopeKey: SCOPE, searchText: "le", deferredSearchText: "le", overlayVisible: true, currentMatchIndex: 0 }); + + search.restorePersistedState(); + await nextTick(); + + // "le" prefixes the "left" column, so the non-restore path would suggest it. + expect(search.suggestions.value).toEqual([]); + expect(search.suggestionIndex.value).toBe(-1); + }); + + it("does not restore across a column signature change", () => { + const { search } = createSearch(); + saveDataGridSearchState("tab-1", { scopeKey: createDataGridSearchScopeKey(["renamed"]), searchText: "hit", deferredSearchText: "hit", overlayVisible: true, currentMatchIndex: 0 }); + + expect(search.restorePersistedState()).toBe(false); + expect(search.searchText.value).toBe(""); + }); + + it("lets a keystroke during the restore window cancel the token", async () => { + vi.useFakeTimers(); + const { search, onNavigate } = createSearch(); + saveDataGridSearchState("tab-1", { scopeKey: SCOPE, searchText: "hit", deferredSearchText: "hit", overlayVisible: true, currentMatchIndex: 2 }); + + search.restorePersistedState(); + search.searchText.value = "none"; + await flushSearchDebounce(); + + // Normal typing behaviour resumes: debounced, auto-navigated, index reset. + expect(search.deferredSearchText.value).toBe("none"); + expect(search.currentMatchIndex.value).toBe(0); + expect(onNavigate).toHaveBeenCalled(); + }); + + it("clears the persisted entry when the search bar is closed", async () => { + vi.useFakeTimers(); + const { search } = createSearch(); + search.overlayVisible.value = true; + search.searchText.value = "hit"; + await flushSearchDebounce(); + expect(loadDataGridSearchState("tab-1", SCOPE)).toBeDefined(); + + search.close(); + await nextTick(); + expect(loadDataGridSearchState("tab-1", SCOPE)).toBeUndefined(); + }); + + it("opts out entirely when the host passes no persistence key", async () => { + vi.useFakeTimers(); + // Built without the helper: an explicit `undefined` argument would hit its + // default and silently re-enable persistence. + const search = useDataGridSearch({ + columns: COLUMNS, + rows: ROWS, + getCellSearchText: (row: string[], column: number) => row[column], + }); + search.overlayVisible.value = true; + search.searchText.value = "hit"; + await flushSearchDebounce(); + + expect(search.restorePersistedState()).toBe(false); + expect(loadDataGridSearchState("tab-1", SCOPE)).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/composables/useDataGridEditor.ts b/apps/desktop/src/composables/useDataGridEditor.ts index d5364205ae..695ad15f0b 100644 --- a/apps/desktop/src/composables/useDataGridEditor.ts +++ b/apps/desktop/src/composables/useDataGridEditor.ts @@ -141,6 +141,12 @@ interface PendingChangesSnapshot { editValue?: string; transactionActive?: boolean; scroll?: { top: number; left: number }; + // True only when this snapshot was written because the user navigated away from + // this grid's own tab. A scroll-only snapshot may be replayed on remount only + // with that provenance (#8524); every other remount reason — refresh, + // re-execute, sort, paginate, eviction reload — still starts at the first row + // (#7341). + scrollFromTabSwitch?: boolean; columnCount: number; rowCount: number; } @@ -308,6 +314,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { let draftPromotionScheduled = false; const savingNewRows = new WeakSet(); let pendingScrollRestore: PendingChangesSnapshot["scroll"] | undefined; + // Instance-scoped so it survives the second save: a tab switch saves once from + // onBeforeTabSwitch and again from onBeforeUnmount on this same instance. + let scrollSnapshotFromTabSwitch = false; let saveScrollSnapshotTimer = 0; let componentActive = true; @@ -326,13 +335,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { restoredEditingCell = !!cached.editingCell; restoredTransactionActive = cached.transactionActive === true; // A scroll-only snapshot (no pending edits, draft row, or active cell editor) - // must not drag a remounted grid back to the previous viewport: the fresh - // result should start at the first row (#7341). Scroll is only replayed - // alongside edit state so the user lands back on their edited rows. The - // KeepAlive activate path keeps pure scroll restore via the in-instance - // pendingScrollRestore, which this gate does not touch. + // must not drag a remounted grid back to the previous viewport unless we know + // why the previous instance went away. Two reasons justify replaying it: the + // snapshot carries edit state the user must land back on, or the previous + // instance was torn down by a tab switch (#8524) — data tabs render a single + // active pane keyed by tab id, so returning to a tab remounts the grid. A + // fresh or reloaded result still starts at the first row (#7341). const snapshotHasEditState = cached.newRows.length > 0 || cached.dirtyRows.size > 0 || cached.deletedRows.size > 0 || !!cached.editingCell || !!cached.quickEntryDraftRow; - pendingScrollRestore = snapshotHasEditState ? cached.scroll : undefined; + pendingScrollRestore = snapshotHasEditState || cached.scrollFromTabSwitch === true ? cached.scroll : undefined; pendingChangesCache.delete(key); } else { pendingChangesCache.delete(key); @@ -510,8 +520,22 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { if (el) el.scrollTop = 0; } + // Every caller below expresses an explicit "new viewport" intent (sort, filter, + // paginate, refresh, result identity change). Drop the tab-switch provenance so a + // snapshot written earlier cannot drag the next mount back to the old offset. + // The already-written cache entry is fixed up in place because a switch can be + // dispatched without completing, and with split groups the global activeTabId + // watcher can name a tab belonging to another still-mounted grid. + function clearTabSwitchScrollProvenance() { + scrollSnapshotFromTabSwitch = false; + const k = cacheKey?.value; + const cached = k ? pendingChangesCache.get(k) : undefined; + if (cached?.scrollFromTabSwitch) cached.scrollFromTabSwitch = false; + } + function resetGridVerticalScroll(afterResult = false) { if (afterResult) resetScrollAfterResult = true; + clearTabSwitchScrollProvenance(); if (resetScrollFrame) cancelAnimationFrame(resetScrollFrame); scrollGridToTop(); nextTick(() => { @@ -2006,6 +2030,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { } previousResultRows = rows; pendingScrollRestore = undefined; + clearTabSwitchScrollProvenance(); discardChanges(); }, ); @@ -2034,6 +2059,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { editValue: editValue.value, transactionActive: transactionActive.value, scroll, + scrollFromTabSwitch: scroll ? scrollSnapshotFromTabSwitch : false, columnCount: result.value.columns.length, rowCount: result.value.rows.length, }); @@ -2045,8 +2071,14 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { applyScrollPosition(pendingScrollRestore); } - function onBeforeTabSwitch() { + function onBeforeTabSwitch(event?: Event) { if (!componentActive) return; + const detail = (event as CustomEvent<{ tabId?: string; fromTabId?: string }> | undefined)?.detail; + const key = cacheKey?.value; + // Split editor groups keep several grids mounted at once and every one of them + // receives this window event, so only the grid whose own tab is being left may + // claim the scroll restore. A missing fromTabId falls through to "start at top". + if (key && detail?.fromTabId && cacheKeyBelongsToTab(key, detail.fromTabId)) scrollSnapshotFromTabSwitch = true; savePendingSnapshot(true, true); if (editingCell.value) suppressNextBlurCommit = true; } @@ -2184,6 +2216,9 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) { isPreviewLoading, previewChanges, savePendingSnapshot, + // Exposed for tests: the window listener is only registered inside a component + // instance, so specs drive the tab-switch path directly. + onBeforeTabSwitch, restorePendingSnapshotFocus, syncHeaderScroll: (headerRef: Ref) => (e: Event) => { if (headerRef.value) { diff --git a/apps/desktop/src/composables/useDataGridSearch.ts b/apps/desktop/src/composables/useDataGridSearch.ts index ed2b521e34..5960558341 100644 --- a/apps/desktop/src/composables/useDataGridSearch.ts +++ b/apps/desktop/src/composables/useDataGridSearch.ts @@ -1,5 +1,6 @@ import { computed, getCurrentScope, nextTick, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter } from "vue"; import { dataGridSearchMatchKey } from "@/lib/dataGrid/canvasDataGridRenderer"; +import { loadDataGridSearchState, saveDataGridSearchState } from "@/lib/dataGrid/dataGridSearchStatePersistence"; export type DataGridSearchMatch = { kind: "cell" | "column"; @@ -18,6 +19,10 @@ export type UseDataGridSearchOptions = { getCellSearchText: (row: Row, columnIndex: number) => string; debounceMs?: number; onNavigate?: (match: DataGridSearchMatch) => void; + /** 按标签页键控的持久化键;不传则该宿主不参与搜索状态保留。 */ + persistenceKey?: MaybeRefOrGetter; + /** 列签名,用于避免把陈旧搜索恢复到结构不同的结果上。 */ + persistenceScopeKey?: MaybeRefOrGetter; }; const SEARCH_TOKEN_SEPARATOR = /[\s,()><=!&|]+/; @@ -72,12 +77,31 @@ export function useDataGridSearch(options: UseDataGridSearchOptions) { searchTimer = undefined; } + // A single-use token rather than a boolean flag: it is immune to Vue's async + // watcher flush and self-invalidates if the user types during the restore window. + let restoreToken: { searchText: string; matchIndex: number } | null = null; + watch(searchText, (value) => { clearTimer(); const query = value.trim().toLowerCase(); + const restoring = restoreToken !== null && restoreToken.searchText === value; if (!query) deferredSearchText.value = ""; + // Restoring is not typing: resolve the query now so the row set (and, in + // "filter" search mode, the content height) is settled before the grid's + // scroll restore measures it. + else if (restoring) deferredSearchText.value = query; else searchTimer = setTimeout(() => (deferredSearchText.value = query), options.debounceMs ?? 150); + if (restoring) { + // Returning to a tab must not reopen the typeahead popup. + suggestions.value = []; + suggestionIndex.value = -1; + return; + } + // The user typed before the restore resolved: drop the token so the query they + // are writing keeps the normal debounce, index reset and auto-navigate. + restoreToken = null; + const lastToken = value.trim().split(SEARCH_TOKEN_SEPARATOR).pop()?.toLowerCase() ?? ""; suggestions.value = lastToken ? toValue(options.suggestionColumns ?? options.columns) @@ -88,7 +112,20 @@ export function useDataGridSearch(options: UseDataGridSearchOptions) { }); watch(matchKeys, (value) => { - currentMatchIndex.value = value.length ? 0 : -1; + const restored = restoreToken; + restoreToken = null; + if (!value.length) { + currentMatchIndex.value = -1; + return; + } + if (restored) { + // Re-entering the tab must not scroll to the first match: the grid's own + // scroll restore owns the viewport (#8524). Clamp because the match list may + // have shifted while the tab was away. + currentMatchIndex.value = Math.min(Math.max(restored.matchIndex, 0), value.length - 1); + return; + } + currentMatchIndex.value = 0; const firstMatch = matchAt(0); if (firstMatch) nextTick(() => options.onNavigate?.(firstMatch)); }); @@ -126,6 +163,41 @@ export function useDataGridSearch(options: UseDataGridSearchOptions) { suggestions.value = []; } + /** + * Must be called from onMounted, never from the setup body: watch(matchKeys) + * eagerly evaluates its source, and seeding a non-empty query would push + * matchState past its empty-query early return into `options.rows`, which the + * host declares later in its own setup (#8524). + */ + function restorePersistedState(): boolean { + const key = toValue(options.persistenceKey); + if (!key) return false; + const state = loadDataGridSearchState(key, toValue(options.persistenceScopeKey) ?? ""); + if (!state) return false; + overlayVisible.value = state.overlayVisible; + // An empty query never re-triggers the searchText watcher, so the token would + // never be consumed — only arm it when there is text to restore. + if (!state.searchText) return state.overlayVisible; + restoreToken = { searchText: state.searchText, matchIndex: state.currentMatchIndex }; + searchText.value = state.searchText; + return true; + } + + if (options.persistenceKey) { + watch( + [searchText, deferredSearchText, overlayVisible, currentMatchIndex], + () => + saveDataGridSearchState(toValue(options.persistenceKey), { + scopeKey: toValue(options.persistenceScopeKey) ?? "", + searchText: searchText.value, + deferredSearchText: deferredSearchText.value, + overlayVisible: overlayVisible.value, + currentMatchIndex: currentMatchIndex.value, + }), + { flush: "post" }, + ); + } + function onKeydown(event: KeyboardEvent) { const pair = SEARCH_PAIRS[event.key]; const input = event.target as HTMLInputElement; @@ -152,7 +224,7 @@ export function useDataGridSearch(options: UseDataGridSearchOptions) { if (getCurrentScope()) onScopeDispose(clearTimer); - return { searchText, deferredSearchText, overlayVisible, currentMatchIndex, suggestions, suggestionIndex, matches, matchKeys, matchCount, matchAt, matchSet, currentMatch, acceptSuggestion, navigateSuggestion, navigateMatch, close, onKeydown }; + return { searchText, deferredSearchText, overlayVisible, currentMatchIndex, suggestions, suggestionIndex, matches, matchKeys, matchCount, matchAt, matchSet, currentMatch, acceptSuggestion, navigateSuggestion, navigateMatch, close, onKeydown, restorePersistedState }; } function dataGridSearchMatchFromKey(key: number): DataGridSearchMatch { diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridSearchStatePersistence.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridSearchStatePersistence.spec.ts new file mode 100644 index 0000000000..2391b830e7 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridSearchStatePersistence.spec.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { clearDataGridSearchStates, clearDataGridSearchStatesForTab, createDataGridSearchScopeKey, dataGridSearchStateCount, loadDataGridSearchState, saveDataGridSearchState, type DataGridSearchCacheState } from "@/lib/dataGrid/dataGridSearchStatePersistence"; + +const SCOPE = createDataGridSearchScopeKey(["id", "name"]); + +function state(overrides: Partial = {}): DataGridSearchCacheState { + return { + scopeKey: SCOPE, + searchText: "ada", + deferredSearchText: "ada", + overlayVisible: true, + currentMatchIndex: 3, + ...overrides, + }; +} + +describe("dataGridSearchStatePersistence", () => { + beforeEach(() => clearDataGridSearchStates()); + + it("round-trips a saved state as a copy", () => { + saveDataGridSearchState("tab-1", state()); + const loaded = loadDataGridSearchState("tab-1", SCOPE); + expect(loaded).toEqual(state()); + loaded!.searchText = "mutated"; + expect(loadDataGridSearchState("tab-1", SCOPE)?.searchText).toBe("ada"); + }); + + it("ignores a blank cache key", () => { + saveDataGridSearchState(undefined, state()); + saveDataGridSearchState(" ", state()); + expect(dataGridSearchStateCount()).toBe(0); + expect(loadDataGridSearchState(undefined, SCOPE)).toBeUndefined(); + }); + + it("drops the entry when the column signature changed", () => { + saveDataGridSearchState("tab-1", state()); + expect(loadDataGridSearchState("tab-1", createDataGridSearchScopeKey(["id", "renamed"]))).toBeUndefined(); + // The stale entry is evicted, not merely skipped. + expect(loadDataGridSearchState("tab-1", SCOPE)).toBeUndefined(); + expect(dataGridSearchStateCount()).toBe(0); + }); + + it("does not store an empty closed search bar", () => { + saveDataGridSearchState("tab-1", state({ searchText: "", deferredSearchText: "", overlayVisible: false })); + expect(dataGridSearchStateCount()).toBe(0); + }); + + it("keeps an empty query while the bar is still open", () => { + saveDataGridSearchState("tab-1", state({ searchText: "", deferredSearchText: "", overlayVisible: true })); + expect(loadDataGridSearchState("tab-1", SCOPE)?.overlayVisible).toBe(true); + }); + + it("clears the entry when a later save empties the search", () => { + saveDataGridSearchState("tab-1", state()); + saveDataGridSearchState("tab-1", state({ searchText: "", deferredSearchText: "", overlayVisible: false })); + expect(loadDataGridSearchState("tab-1", SCOPE)).toBeUndefined(); + }); + + it("evicts the least recently used entry past the cap", () => { + for (let index = 0; index < 130; index += 1) saveDataGridSearchState(`tab-${index}`, state()); + expect(dataGridSearchStateCount()).toBe(128); + expect(loadDataGridSearchState("tab-0", SCOPE)).toBeUndefined(); + expect(loadDataGridSearchState("tab-129", SCOPE)).toBeDefined(); + }); + + it("treats a load as a recency touch", () => { + saveDataGridSearchState("tab-keep", state()); + for (let index = 0; index < 127; index += 1) saveDataGridSearchState(`tab-${index}`, state()); + loadDataGridSearchState("tab-keep", SCOPE); + saveDataGridSearchState("tab-overflow", state()); + expect(loadDataGridSearchState("tab-keep", SCOPE)).toBeDefined(); + }); + + it("clears every cache key belonging to a closed tab", () => { + saveDataGridSearchState("tab-1", state()); + saveDataGridSearchState("tab-1-run-1-0-rev-2", state()); + saveDataGridSearchState("tab-2", state()); + clearDataGridSearchStatesForTab("tab-1"); + expect(loadDataGridSearchState("tab-1", SCOPE)).toBeUndefined(); + expect(loadDataGridSearchState("tab-1-run-1-0-rev-2", SCOPE)).toBeUndefined(); + expect(loadDataGridSearchState("tab-2", SCOPE)).toBeDefined(); + }); +}); diff --git a/apps/desktop/src/lib/dataGrid/dataGridSearchStatePersistence.ts b/apps/desktop/src/lib/dataGrid/dataGridSearchStatePersistence.ts new file mode 100644 index 0000000000..a907cdbddb --- /dev/null +++ b/apps/desktop/src/lib/dataGrid/dataGridSearchStatePersistence.ts @@ -0,0 +1,61 @@ +export type DataGridSearchCacheState = { + scopeKey: string; + searchText: string; + deferredSearchText: string; + overlayVisible: boolean; + currentMatchIndex: number; +}; + +const SEARCH_STATE_CACHE_MAX_ENTRIES = 128; +const searchStateCache = new Map(); + +/** 列集合定义了匹配空间;行数刻意不参与,翻页/追加行不应丢弃用户的搜索词。 */ +export function createDataGridSearchScopeKey(columns: readonly string[]): string { + return JSON.stringify(columns); +} + +export function loadDataGridSearchState(cacheKey: string | undefined, scopeKey: string): DataGridSearchCacheState | undefined { + const key = cacheKey?.trim(); + if (!key) return undefined; + const cached = searchStateCache.get(key); + if (!cached) return undefined; + if (cached.scopeKey !== scopeKey) { + searchStateCache.delete(key); + return undefined; + } + searchStateCache.delete(key); + searchStateCache.set(key, cached); + return { ...cached }; +} + +export function saveDataGridSearchState(cacheKey: string | undefined, state: DataGridSearchCacheState) { + const key = cacheKey?.trim(); + if (!key) return; + searchStateCache.delete(key); + // An empty, closed search bar is the default state; storing it would only pin a + // dead entry and defeat the reset that close() performs. + if (!state.searchText && !state.overlayVisible) return; + searchStateCache.set(key, { ...state }); + // Query result keys are session-scoped, so bound the in-memory cache instead of + // relying on store cleanup paths. + while (searchStateCache.size > SEARCH_STATE_CACHE_MAX_ENTRIES) { + const oldest = searchStateCache.keys().next().value; + if (oldest === undefined) break; + searchStateCache.delete(oldest); + } +} + +export function clearDataGridSearchStatesForTab(tabId: string) { + searchStateCache.delete(tabId); + for (const cacheKey of searchStateCache.keys()) { + if (cacheKey.startsWith(`${tabId}-`)) searchStateCache.delete(cacheKey); + } +} + +export function clearDataGridSearchStates() { + searchStateCache.clear(); +} + +export function dataGridSearchStateCount(): number { + return searchStateCache.size; +} diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 733911cd78..4984f65d43 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -61,6 +61,7 @@ import { classifySqlRisk } from "@/lib/sql/sqlRisk"; import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen"; import { clearDataGridPendingSnapshot, clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor"; import { clearDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGridFilterBuilderPersistence"; +import { clearDataGridSearchStatesForTab } from "@/lib/dataGrid/dataGridSearchStatePersistence"; import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache"; import { estimateQueryResultsBytes, selectInactiveResultEvictions } from "@/lib/tabs/queryResultSize"; import { queryResultBaseSql, queryResultExecutionSql, resultGridInstanceKey } from "@/lib/tabs/tabPresentation"; @@ -3385,6 +3386,7 @@ export const useQueryStore = defineStore("query", () => { if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id); clearDataGridPendingSnapshotsForTab(id); clearDataGridStructuredFilterStatesForTab(id); + clearDataGridSearchStatesForTab(id); if (tabs.value[idx].txnSessionId) void rollbackTransaction(id); if (tabs.value[idx].isExecuting) void cancelTabExecution(id); if (tabs.value[idx].isExplaining) void cancelTabExplain(id); @@ -3745,6 +3747,7 @@ export const useQueryStore = defineStore("query", () => { if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id); clearDataGridPendingSnapshotsForTab(tab.id); clearDataGridStructuredFilterStatesForTab(tab.id); + clearDataGridSearchStatesForTab(tab.id); if (tab.txnSessionId) void rollbackTransaction(tab.id); if (tab.isExecuting) void cancelTabExecution(tab.id); if (tab.isExplaining) void cancelTabExplain(tab.id); @@ -3961,6 +3964,7 @@ export const useQueryStore = defineStore("query", () => { rollbackTabTransaction(tab, { resetAutoCommit: true }); clearDataGridPendingSnapshotsForTab(tab.id); clearDataGridStructuredFilterStatesForTab(tab.id); + clearDataGridSearchStatesForTab(tab.id); if (tab.isExecuting) void cancelTabExecution(tab.id); if (tab.isExplaining) void cancelTabExplain(tab.id); void closeResultSession(tab); diff --git a/packages/app-tests/dataGridSearchStateRestore.test.ts b/packages/app-tests/dataGridSearchStateRestore.test.ts new file mode 100644 index 0000000000..502af4c432 --- /dev/null +++ b/packages/app-tests/dataGridSearchStateRestore.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { test } from "vitest"; + +/** + * The Ctrl+F overlay was the only grid search/filter surface without tab-keyed + * persistence (structured filters and column widths already had it), so switching + * data tabs dropped the query along with the scroll position (#8524). + */ +function readSource(relativePath: string): string { + return readFileSync(path.resolve(relativePath), "utf8"); +} + +test("DataGrid keys search persistence the same way as its pending-edit snapshot", () => { + const source = readSource("apps/desktop/src/components/grid/DataGrid.vue"); + assert.match(source, /persistenceKey: \(\) => props\.pendingStateKey \?\? props\.cacheKey,/); + assert.match(source, /persistenceScopeKey: \(\) => createDataGridSearchScopeKey\(props\.result\.columns\),/); +}); + +test("search state is restored from onMounted, not the setup body", () => { + const source = readSource("apps/desktop/src/components/grid/DataGrid.vue"); + // watch(matchKeys) evaluates its source eagerly, and a non-empty restored query + // would push matchState past its empty-query early return into `displayItems`, + // which is declared thousands of lines later in this same setup. + assert.match(source, /onMounted\(\(\) => dataGridSearch\.restorePersistedState\(\)\);/); + const restoreIndex = source.indexOf("onMounted(() => dataGridSearch.restorePersistedState());"); + const displayItemsIndex = source.indexOf("const displayItems"); + assert.notEqual(restoreIndex, -1); + assert.notEqual(displayItemsIndex, -1); + assert.ok(restoreIndex < displayItemsIndex, "the restore hook is registered before displayItems is declared, so it must run in onMounted"); +}); + +test("restoring a query does not steal the viewport from the scroll restore", () => { + const source = readSource("apps/desktop/src/composables/useDataGridSearch.ts"); + const start = source.indexOf("watch(matchKeys"); + assert.notEqual(start, -1); + const body = source.slice(start, source.indexOf("\n });", start)); + assert.match(body, /const restored = restoreToken;\s*\n\s*restoreToken = null;/, "the token must be consumed exactly once"); + assert.match(body, /if \(restored\) \{[\s\S]*?Math\.min\(Math\.max\(restored\.matchIndex, 0\), value\.length - 1\)[\s\S]*?return;/, "a restored match index is clamped and must not auto-navigate"); +}); + +test("closed tabs release their persisted search state", () => { + const source = readSource("apps/desktop/src/stores/queryStore.ts"); + const filterCalls = source.match(/clearDataGridStructuredFilterStatesForTab\(/g) ?? []; + const searchCalls = source.match(/clearDataGridSearchStatesForTab\(/g) ?? []; + assert.ok(filterCalls.length > 1, "expected the structured-filter cleanup call sites"); + // One import plus one call per cleanup site, mirroring the filter cache. + assert.equal(searchCalls.length, filterCalls.length, "every structured-filter cleanup site must also clear the search cache"); +}); diff --git a/packages/app-tests/dataGridTabSwitchScrollRestore.test.ts b/packages/app-tests/dataGridTabSwitchScrollRestore.test.ts new file mode 100644 index 0000000000..92259d1fb3 --- /dev/null +++ b/packages/app-tests/dataGridTabSwitchScrollRestore.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { test } from "vitest"; + +/** + * Data tabs render a single active pane (`ContentArea` keyed by `activeTab.id`, + * no KeepAlive), so returning to a tab remounts the grid from scratch. Scroll was + * always snapshotted, but the restore gate threw away scroll-only snapshots — the + * exact read-only-browse case — because it assumed a KeepAlive activate path that + * does not exist. The gate now also honours snapshots written by a tab switch + * (#8524) while a reloaded or re-executed result still starts at row 1 (#7341). + */ +function readSource(relativePath: string): string { + return readFileSync(path.resolve(relativePath), "utf8"); +} + +function editorSource(): string { + return readSource("apps/desktop/src/composables/useDataGridEditor.ts"); +} + +test("the restore gate honours a tab-switch scroll snapshot", () => { + assert.match( + editorSource(), + /pendingScrollRestore = snapshotHasEditState \|\| cached\.scrollFromTabSwitch === true \? cached\.scroll : undefined;/, + "the scroll-only gate must accept snapshots carrying tab-switch provenance", + ); +}); + +test("the tab-switch listener only claims its own tab", () => { + const source = editorSource(); + const start = source.indexOf("function onBeforeTabSwitch"); + assert.notEqual(start, -1, "expected onBeforeTabSwitch in useDataGridEditor.ts"); + const body = source.slice(start, source.indexOf("\n }", start)); + assert.match(body, /detail\?\.fromTabId/, "must read fromTabId off the event detail"); + assert.match(body, /cacheKeyBelongsToTab\(key, detail\.fromTabId\)/, "must validate fromTabId against this grid's cache key"); + assert.match(body, /scrollSnapshotFromTabSwitch = true/); +}); + +test("the snapshot records the tab-switch provenance", () => { + assert.match(editorSource(), /scrollFromTabSwitch: scroll \? scrollSnapshotFromTabSwitch : false,/); +}); + +test("an explicit scroll-to-top intent drops the provenance", () => { + const source = editorSource(); + assert.match(source, /function clearTabSwitchScrollProvenance\(\)/); + const reset = source.slice(source.indexOf("function resetGridVerticalScroll")); + assert.match(reset.slice(0, reset.indexOf("\n }")), /clearTabSwitchScrollProvenance\(\);/, "resetGridVerticalScroll must clear the provenance"); + // The rows-identity watcher already clears pendingScrollRestore; it must clear + // the cached provenance too, or a reload would inherit the old offset. + assert.match(source, /pendingScrollRestore = undefined;\s*\n\s*clearTabSwitchScrollProvenance\(\);/); +}); + +test("data tab panes stay un-cached so the fix cannot be masked by KeepAlive", () => { + // Introducing KeepAlive here would retain every grid instance in memory; the + // snapshot path is the deliberate alternative. Pin that decision. + for (const file of ["apps/desktop/src/components/layout/EditorGroup.vue", "apps/desktop/src/components/layout/ContentArea.vue"]) { + assert.doesNotMatch(readSource(file), /