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

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

No files matched your search

@@ -0,0 +1,114 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { QueryResult } from "@/types/database";
function sampleResult(rows: number): QueryResult {
return {
columns: ["id", "name"],
rows: Array.from({ length: rows }, (_, index) => [index, `row-${index}`]),
affected_rows: 0,
execution_time_ms: 1,
};
}
// The first test pays the cold transform of the store module, which can exceed
// the default 10s budget on Windows.
describe("queryStore resultViewGeneration", { timeout: 30_000 }, () => {
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
setActivePinia(createPinia());
});
it("assigns a new generation when an error result replaces the dataset", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "error", "query");
const tab = store.tabs.find((item) => item.id === tabId)!;
tab.result = sampleResult(2);
tab.resultViewGeneration = "gen-old";
store.setErrorResult(tabId, new Error("boom"));
expect(tab.resultViewGeneration).toBeTruthy();
expect(tab.resultViewGeneration).not.toBe("gen-old");
});
it("assigns a new generation for a local sort, because row order changes", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "local-sort", "query");
const tab = store.tabs.find((item) => item.id === tabId)!;
tab.result = {
...sampleResult(3),
rows: [
[3, "c"],
[1, "a"],
[2, "b"],
],
};
tab.resultViewGeneration = "gen-old";
store.sortTabResultLocally(tabId, "id", 0, "asc");
expect(tab.result.rows.map((row) => row[0])).toEqual([1, 2, 3]);
expect(tab.resultViewGeneration).toBeTruthy();
expect(tab.resultViewGeneration).not.toBe("gen-old");
});
it("inherits the generation when a result is restored from disk", async () => {
const cached = sampleResult(2);
vi.doMock("@/lib/tabs/tabResultCache", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/tabs/tabResultCache")>();
return {
...actual,
readTabResultSnapshot: vi.fn(async () => ({ result: cached, resultViewGeneration: "gen-disk" }) as any),
};
});
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "evicted", "query");
const tab = store.tabs.find((item) => item.id === tabId)!;
tab.resultEvicted = true;
tab.resultCacheKey = "tab:cache:key";
tab.resultViewGeneration = "gen-disk";
await store.reloadEvictedTab(tabId);
expect(tab.result).toBeDefined();
expect(tab.resultViewGeneration).toBe("gen-disk");
vi.doUnmock("@/lib/tabs/tabResultCache");
});
it("projects the run's generation onto the tab when switching result runs", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "runs", "query");
const tab = store.tabs.find((item) => item.id === tabId)!;
const first = { id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: sampleResult(1), resultGridRevision: "rev-1", resultViewGeneration: "gen-run-1" };
const second = { id: "run-2", title: "Run 2", sequence: 2, sql: "select 2", createdAt: 2, result: sampleResult(1), resultGridRevision: "rev-2", resultViewGeneration: "gen-run-2" };
tab.resultRuns = [first, second];
tab.activeResultRunId = "run-1";
tab.resultViewGeneration = "gen-run-1";
await store.setActiveResultRun(tabId, "run-2", { evictInactive: false });
expect(tab.resultViewGeneration).toBe("gen-run-2");
});
it("does not let a legacy run without a generation inherit the tab's value", async () => {
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const tabId = store.createTab("pg-1", "app", "legacy-run", "query");
const tab = store.tabs.find((item) => item.id === tabId)!;
const legacy = { id: "run-legacy", title: "Legacy", sequence: 1, sql: "select 1", createdAt: 1, result: sampleResult(1) };
tab.resultRuns = [legacy];
tab.activeResultRunId = "run-legacy";
tab.resultViewGeneration = "gen-stale";
await store.setActiveResultRun(tabId, "run-legacy", { evictInactive: false });
expect(tab.resultViewGeneration).toBeTruthy();
expect(tab.resultViewGeneration).not.toBe("gen-stale");
});
});
@@ -0,0 +1,75 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const queryStoreSource = readFileSync(new URL("../queryStore.ts", import.meta.url), "utf8");
/** Every direct assignment to the token, with its line number. */
function directAssignments(): string[] {
return queryStoreSource
.split("\n")
.map((line, index) => ({ line: line.trim(), number: index + 1 }))
.filter((entry) => /^(current|tab|run)\.resultViewGeneration\s*=/.test(entry.line))
.map((entry) => `${entry.number}: ${entry.line}`);
}
/** The body of executeTabSql, where every result publication must live. */
function executeTabSqlBody(): string {
const start = queryStoreSource.indexOf("async function executeTabSql(");
const end = queryStoreSource.indexOf("\n }\n", start);
expect(start).toBeGreaterThanOrEqual(0);
expect(end).toBeGreaterThan(start);
return queryStoreSource.slice(start, end);
}
describe("queryStore resultViewGeneration writer", () => {
it("assigns the token in exactly the allowed places", () => {
const assignments = directAssignments();
// publishResultGeneration (the writer) plus the two identity-preserving
// readers: disk restore inherits, run projection copies the run's value.
expect(assignments).toHaveLength(3);
expect(assignments.join("\n")).toContain("snapshot.resultViewGeneration");
expect(assignments.join("\n")).toContain("run.resultViewGeneration");
});
it("defaults an unclassified publication to a new generation", () => {
const start = queryStoreSource.indexOf("function publishResultGeneration(");
const end = queryStoreSource.indexOf("function ", start + 10);
const body = queryStoreSource.slice(start, end);
expect(body).toContain('if (origin === "disk-restore") return;');
expect(body).toContain('if (origin === "append") {');
expect(body).toContain("tab.resultViewGeneration = uuid();");
});
it("publishes a generation for every replacement branch of executeTabSql", () => {
const body = executeTabSqlBody();
// Generic branch: append inherits, everything else replaces.
expect(body).toContain('publishResultGeneration(current, shouldAppendResult ? "append" : (options?.publicationOrigin ?? "execute"));');
// Redis, MongoDB, and Elasticsearch branches replace the visible result too;
// Mongo's append variant must keep the current generation.
expect(body).toContain('publishResultGeneration(current, shouldAppendResult ? "append" : "execute");');
const publicationCount = body.match(/publishResultGeneration\(current/g)?.length ?? 0;
expect(publicationCount).toBeGreaterThanOrEqual(5);
});
it("publishes outside executeTabSql where results are also replaced", () => {
expect(queryStoreSource).toContain('publishResultGeneration(tab, "local-sort");');
expect(queryStoreSource).toContain('publishResultGeneration(tab, "execute");');
expect(queryStoreSource).toContain('publishResultGeneration(current, "execute");');
});
it("reserves the closing tombstone for tab closure, not ordinary execution", () => {
// Ordinary execution clears the payload before running; the tombstone there
// would block capturing the fresh result's view for five seconds.
const clearResultPayloadStart = queryStoreSource.indexOf("function clearResultPayload(");
const clearResultPayloadEnd = queryStoreSource.indexOf("\n }\n", clearResultPayloadStart);
const clearResultPayloadBody = queryStoreSource.slice(clearResultPayloadStart, clearResultPayloadEnd);
expect(clearResultPayloadBody).not.toContain("beginClosingDataGridViewSnapshotsForTab");
expect(clearResultPayloadBody).toContain("clearDataGridViewSnapshotsForTab(tab.id);");
// Tab closure keeps the tombstone so the teardown capture cannot resurrect.
expect(queryStoreSource).toContain("beginClosingDataGridViewSnapshotsForTab(id);");
expect(queryStoreSource.match(/beginClosingDataGridViewSnapshotsForTab\(tab\.id\)/g)?.length).toBe(2);
});
});