From bd58ab63125315427b42e063732860c1e2dc44f5 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 9 May 2026 22:50:06 +0800 Subject: [PATCH] fix(query): persist unsaved query tabs --- src/lib/openTabsPersistence.ts | 75 ++++++++++++++++++++++++++ src/stores/queryStore.ts | 44 +++------------- tests/openTabsPersistence.test.ts | 88 +++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 38 deletions(-) create mode 100644 src/lib/openTabsPersistence.ts create mode 100644 tests/openTabsPersistence.test.ts diff --git a/src/lib/openTabsPersistence.ts b/src/lib/openTabsPersistence.ts new file mode 100644 index 0000000000..84fa7bd233 --- /dev/null +++ b/src/lib/openTabsPersistence.ts @@ -0,0 +1,75 @@ +import type { QueryTab } from "@/types/database"; + +export interface SavedOpenTab { + id: string; + title: string; + connectionId: string; + database: string; + schema?: string; + sql: string; + pinned?: boolean; + mode?: QueryTab["mode"]; + tableMeta?: QueryTab["tableMeta"]; +} + +export interface RestoredOpenTabs { + tabs: QueryTab[]; + activeTabId: string | null; +} + +export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { + return tabs.map((tab) => ({ + id: tab.id, + title: tab.title, + connectionId: tab.connectionId, + database: tab.database, + schema: tab.schema, + sql: tab.sql, + pinned: tab.pinned, + mode: tab.mode, + tableMeta: tab.tableMeta, + })); +} + +function isSavedOpenTab(value: unknown): value is SavedOpenTab { + if (!value || typeof value !== "object") return false; + const tab = value as Record; + return ( + typeof tab.id === "string" && + typeof tab.title === "string" && + typeof tab.connectionId === "string" && + typeof tab.database === "string" && + typeof tab.sql === "string" + ); +} + +export function restoreOpenTabsState( + rawTabs: string | null, + rawActiveTabId: string | null, + options: { queryOnly?: boolean } = {}, +): RestoredOpenTabs { + if (!rawTabs) return { tabs: [], activeTabId: null }; + + try { + const parsed = JSON.parse(rawTabs); + if (!Array.isArray(parsed)) return { tabs: [], activeTabId: null }; + + const saved = parsed.filter(isSavedOpenTab); + const filtered = options.queryOnly ? saved.filter((tab) => (tab.mode ?? "query") === "query") : saved; + const tabs: QueryTab[] = filtered.map((tab) => ({ + ...tab, + mode: tab.mode ?? "query", + isExecuting: false, + isCancelling: false, + isExplaining: false, + })); + const activeTabId = rawActiveTabId || null; + + return { + tabs, + activeTabId: tabs.some((tab) => tab.id === activeTabId) ? activeTabId : tabs[0]?.id || null, + }; + } catch { + return { tabs: [], activeTabId: null }; + } +} diff --git a/src/stores/queryStore.ts b/src/stores/queryStore.ts index a673cf3d9f..a816b9040b 100644 --- a/src/stores/queryStore.ts +++ b/src/stores/queryStore.ts @@ -7,58 +7,26 @@ import { canCancelQueryExecution } from "@/lib/queryExecutionState"; import { closeAllTabsState, closeOtherTabsState } from "@/lib/tabCloseActions"; import { buildExplainSql, parseExplainResult } from "@/lib/explainPlan"; import { analyzeEditableQuery, allPrimaryKeysPresent } from "@/lib/sqlAnalysis"; +import { restoreOpenTabsState, serializeOpenTabs } from "@/lib/openTabsPersistence"; import * as api from "@/lib/api"; import { useConnectionStore } from "@/stores/connectionStore"; import { isTauriRuntime } from "@/lib/tauriRuntime"; -interface SavedTab { - id: string; - title: string; - connectionId: string; - database: string; - sql: string; - pinned?: boolean; - mode: "data" | "query" | "redis" | "mongo"; - tableMeta?: QueryTab["tableMeta"]; -} - const STORAGE_KEY = "dbx-open-tabs"; const ACTIVE_TAB_KEY = "dbx-active-tab"; function saveTabs(tabs: QueryTab[], activeTabId: string | null) { try { - const saved: SavedTab[] = tabs.map((t) => ({ - id: t.id, - title: t.title, - connectionId: t.connectionId, - database: t.database, - sql: t.sql, - pinned: t.pinned, - mode: t.mode, - tableMeta: t.tableMeta, - })); - localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(serializeOpenTabs(tabs))); localStorage.setItem(ACTIVE_TAB_KEY, activeTabId || ""); } catch {} } function loadSavedTabs(): { tabs: QueryTab[]; activeTabId: string | null } { try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return { tabs: [], activeTabId: null }; - const saved: SavedTab[] = JSON.parse(raw); - const filtered = isTauriRuntime() ? saved.filter((s) => s.mode === "query") : saved; - const tabs: QueryTab[] = filtered.map((s) => ({ - ...s, - isExecuting: false, - isCancelling: false, - isExplaining: false, - })); - const activeTabId = localStorage.getItem(ACTIVE_TAB_KEY) || null; - return { - tabs, - activeTabId: tabs.some((t) => t.id === activeTabId) ? activeTabId : tabs[0]?.id || null, - }; + return restoreOpenTabsState(localStorage.getItem(STORAGE_KEY), localStorage.getItem(ACTIVE_TAB_KEY), { + queryOnly: isTauriRuntime(), + }); } catch { return { tabs: [], activeTabId: null }; } @@ -70,7 +38,7 @@ export const useQueryStore = defineStore("query", () => { const activeTabId = ref(restored.activeTabId); const MAX_CACHED_RESULTS = 10; - watch([tabs, activeTabId], () => saveTabs(tabs.value, activeTabId.value), { deep: true }); + watch([tabs, activeTabId], () => saveTabs(tabs.value, activeTabId.value), { deep: true, flush: "sync" }); function findTabByTitle(connectionId: string, database: string, title: string) { return tabs.value.find((t) => t.connectionId === connectionId && t.database === database && t.title === title); diff --git a/tests/openTabsPersistence.test.ts b/tests/openTabsPersistence.test.ts new file mode 100644 index 0000000000..4f542357b5 --- /dev/null +++ b/tests/openTabsPersistence.test.ts @@ -0,0 +1,88 @@ +import { strict as assert } from "node:assert"; +import test from "node:test"; +import { restoreOpenTabsState, serializeOpenTabs } from "../src/lib/openTabsPersistence.ts"; +import type { QueryTab } from "../src/types/database.ts"; + +function queryTab(overrides: Partial = {}): QueryTab { + return { + id: "tab-1", + title: "Query 1", + connectionId: "conn-1", + database: "app", + schema: "public", + sql: "select * from users", + pinned: true, + isExecuting: false, + isCancelling: false, + isExplaining: false, + mode: "query", + ...overrides, + }; +} + +test("serializes unsaved query tabs with editor context", () => { + const saved = serializeOpenTabs([queryTab()]); + + assert.deepEqual(saved, [ + { + id: "tab-1", + title: "Query 1", + connectionId: "conn-1", + database: "app", + schema: "public", + sql: "select * from users", + pinned: true, + mode: "query", + tableMeta: undefined, + }, + ]); +}); + +test("restores unsaved query tabs and active tab after restart", () => { + const raw = JSON.stringify([ + queryTab({ id: "tab-1", sql: "select 1" }), + queryTab({ id: "tab-2", title: "Query 2", sql: "select 2" }), + ]); + + const restored = restoreOpenTabsState(raw, "tab-2"); + + assert.deepEqual( + restored.tabs.map((tab) => ({ id: tab.id, sql: tab.sql, isExecuting: tab.isExecuting })), + [ + { id: "tab-1", sql: "select 1", isExecuting: false }, + { id: "tab-2", sql: "select 2", isExecuting: false }, + ], + ); + assert.equal(restored.activeTabId, "tab-2"); +}); + +test("desktop restore keeps legacy query tabs without a mode", () => { + const raw = JSON.stringify([ + { + id: "legacy", + title: "Query 1", + connectionId: "conn-1", + database: "app", + sql: "select now()", + }, + queryTab({ id: "data", title: "users", mode: "data" }), + ]); + + const restored = restoreOpenTabsState(raw, "data", { queryOnly: true }); + + assert.deepEqual( + restored.tabs.map((tab) => ({ id: tab.id, mode: tab.mode })), + [{ id: "legacy", mode: "query" }], + ); + assert.equal(restored.activeTabId, "legacy"); +}); + +test("ignores invalid persisted tab payloads", () => { + const restored = restoreOpenTabsState(JSON.stringify([{ id: "missing-fields" }, queryTab()]), "missing-fields"); + + assert.deepEqual( + restored.tabs.map((tab) => tab.id), + ["tab-1"], + ); + assert.equal(restored.activeTabId, "tab-1"); +});