mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 05:20:02 +00:00
fix(grid): preserve data tab scroll and search across switches
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -38,7 +38,7 @@ vi.mock("@/stores/productionSafetyStore", () => ({
|
||||
useProductionSafetyStore: () => ({}),
|
||||
}));
|
||||
|
||||
function createEditor(
|
||||
function createEditorWithResult(
|
||||
sourceColumns?: Array<string | undefined>,
|
||||
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<typeof createEditorWithResult>) {
|
||||
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<typeof previous.scrollerRef.value>;
|
||||
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<typeof remounted.scrollerRef.value>;
|
||||
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<typeof previous.scrollerRef.value>;
|
||||
// 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<typeof remounted.scrollerRef.value>;
|
||||
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<typeof previous.scrollerRef.value>;
|
||||
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<typeof remounted.scrollerRef.value>;
|
||||
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<typeof previous.scrollerRef.value>;
|
||||
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<typeof remounted.scrollerRef.value>;
|
||||
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<typeof previous.scrollerRef.value>;
|
||||
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<typeof remounted.scrollerRef.value>;
|
||||
remounted.restorePendingSnapshotFocus();
|
||||
expect(remountedScroller.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("still adopts the cached scroll when the snapshot carries pending edits", () => {
|
||||
class TestScroller {
|
||||
scrollTop = 0;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<CellValue[]>();
|
||||
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<HTMLDivElement | undefined>) => (e: Event) => {
|
||||
if (headerRef.value) {
|
||||
|
||||
@@ -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<Row> = {
|
||||
getCellSearchText: (row: Row, columnIndex: number) => string;
|
||||
debounceMs?: number;
|
||||
onNavigate?: (match: DataGridSearchMatch) => void;
|
||||
/** 按标签页键控的持久化键;不传则该宿主不参与搜索状态保留。 */
|
||||
persistenceKey?: MaybeRefOrGetter<string | undefined>;
|
||||
/** 列签名,用于避免把陈旧搜索恢复到结构不同的结果上。 */
|
||||
persistenceScopeKey?: MaybeRefOrGetter<string>;
|
||||
};
|
||||
|
||||
const SEARCH_TOKEN_SEPARATOR = /[\s,()><=!&|]+/;
|
||||
@@ -72,12 +77,31 @@ export function useDataGridSearch<Row>(options: UseDataGridSearchOptions<Row>) {
|
||||
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<Row>(options: UseDataGridSearchOptions<Row>) {
|
||||
});
|
||||
|
||||
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<Row>(options: UseDataGridSearchOptions<Row>) {
|
||||
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<Row>(options: UseDataGridSearchOptions<Row>) {
|
||||
|
||||
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 {
|
||||
|
||||
+83
@@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, DataGridSearchCacheState>();
|
||||
|
||||
/** 列集合定义了匹配空间;行数刻意不参与,翻页/追加行不应丢弃用户的搜索词。 */
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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), /<KeepAlive|<keep-alive/, `${file} must not wrap tab panes in KeepAlive`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user