From 6b135641804aabfe5e176ef4fc23164c066fddd6 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sun, 5 Jul 2026 02:22:13 +0800 Subject: [PATCH] fix(desktop): persist UI state asynchronously --- apps/desktop/src/App.vue | 59 +++++----- .../__tests__/savedSqlEditorPosition.spec.ts | 15 ++- .../src/lib/app/openTabsPersistence.ts | 22 +++- .../src/lib/app/savedSqlEditorPosition.ts | 101 +++++++++++----- apps/desktop/src/lib/backend/api.ts | 6 + .../src/lib/backend/browserAppStateStorage.ts | 70 +++++++++++ apps/desktop/src/lib/backend/http.ts | 34 ++++++ apps/desktop/src/lib/backend/tauri.ts | 29 +++++ .../queryStore.database-open.spec.ts | 10 +- apps/desktop/src/stores/queryStore.ts | 110 +++++++++++++----- apps/desktop/src/stores/settingsStore.ts | 76 +++++++----- crates/dbx-core/src/storage.rs | 101 ++++++++++++++++ src-tauri/src/commands/app_settings.rs | 35 ++++++ src-tauri/src/lib.rs | 6 + 14 files changed, 547 insertions(+), 127 deletions(-) create mode 100644 apps/desktop/src/lib/backend/browserAppStateStorage.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index f3dc10aaf..189476fe7 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -76,6 +76,7 @@ import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStor import { apiUrl, webPath } from "@/lib/common/webPath"; import { APP_FONT_SANS_CSS_VAR, DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts"; import { rankSavedSqlHistory } from "@/lib/savedSql/savedSqlHistory"; +import { initSavedSqlEditorPositions } from "@/lib/app/savedSqlEditorPosition"; import { isSchemaAware, isSingleDatabase, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport"; import { codeMirrorSqlDialect, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { detectDatabaseFileType } from "@/lib/database/databaseFileDetection"; @@ -562,8 +563,10 @@ function finishPendingAppClose(action: AppCloseAction) { } pendingAppCloseAction.value = null; pendingSaveShouldCloseTab.value = true; - queryStore.flushPendingPersist(); - void performCloseAction(action); + void queryStore + .flushPendingPersist() + .catch(() => {}) + .finally(() => performCloseAction(action)); } function continuePendingAppCloseAfterSave() { @@ -1529,35 +1532,35 @@ function onLoginSuccess() { setupRequired.value = false; needsAuth.value = true; window.history.replaceState(null, "", webPath("/")); - initApp(); + void initApp(); } -function initApp() { +async function initApp() { const t0 = performance.now(); console.log("[STARTUP] initApp begin"); - settingsStore - .initDesktopSettings() - .catch(() => {}) - .then(() => { - void savedSqlStore - .initFromStorage() - .then(() => { - console.log(`[STARTUP] savedSqlStore.initFromStorage: ${(performance.now() - t0).toFixed(0)}ms`); - void queryStore.hydrateSavedSqlTabs(); - }) - .catch((e: any) => { - toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000); - }); - return connectionStore.initFromDisk(); - }) - .then(() => { - console.log(`[STARTUP] connectionStore.initFromDisk: ${(performance.now() - t0).toFixed(0)}ms`); - restoreActiveConnectionContext(); - }) - .catch((e: any) => { - toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000); - }); settingsStore.initAiConfig(); + try { + await settingsStore.initEditorSettings(); + console.log(`[STARTUP] settingsStore.initEditorSettings: ${(performance.now() - t0).toFixed(0)}ms`); + await queryStore.initOpenTabs(); + console.log(`[STARTUP] queryStore.initOpenTabs: ${(performance.now() - t0).toFixed(0)}ms`); + await settingsStore.initDesktopSettings().catch(() => {}); + + void Promise.all([initSavedSqlEditorPositions(), savedSqlStore.initFromStorage()]) + .then(() => { + console.log(`[STARTUP] savedSqlStore.initFromStorage: ${(performance.now() - t0).toFixed(0)}ms`); + void queryStore.hydrateSavedSqlTabs(); + }) + .catch((e: any) => { + toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000); + }); + + await connectionStore.initFromDisk(); + console.log(`[STARTUP] connectionStore.initFromDisk: ${(performance.now() - t0).toFixed(0)}ms`); + restoreActiveConnectionContext(); + } catch (e: any) { + toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000); + } } function restoreActiveConnectionContext() { @@ -1662,7 +1665,7 @@ onMounted(async () => { if (needsAuth.value && !authenticated.value) { history.replaceState(null, "", webPath("/login")); } - if (!setupRequired.value && (!needsAuth.value || authenticated.value)) initApp(); + if (!setupRequired.value && (!needsAuth.value || authenticated.value)) void initApp(); api .getAppVersion() .then((v) => { @@ -1671,7 +1674,7 @@ onMounted(async () => { .catch(() => {}); return; } - initApp(); + void initApp(); setupFileDrop().catch(() => {}); setTimeout(() => { runUpdateNotificationChecks(); diff --git a/apps/desktop/src/lib/__tests__/savedSqlEditorPosition.spec.ts b/apps/desktop/src/lib/__tests__/savedSqlEditorPosition.spec.ts index 0f9734e25..e64fc2d06 100644 --- a/apps/desktop/src/lib/__tests__/savedSqlEditorPosition.spec.ts +++ b/apps/desktop/src/lib/__tests__/savedSqlEditorPosition.spec.ts @@ -1,10 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createSavedSqlEditorPosition, forgetSavedSqlEditorPosition, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition, SAVED_SQL_EDITOR_POSITIONS_STORAGE_KEY } from "../app/savedSqlEditorPosition"; +import { __resetSavedSqlEditorPositionsForTests, createSavedSqlEditorPosition, forgetSavedSqlEditorPosition, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "../app/savedSqlEditorPosition"; + +vi.mock("@/lib/backend/api", () => ({ + loadSavedSqlEditorPositions: vi.fn().mockResolvedValue(null), + saveSavedSqlEditorPositions: vi.fn().mockResolvedValue(undefined), +})); const storage = new Map(); beforeEach(() => { storage.clear(); + __resetSavedSqlEditorPositionsForTests(); vi.stubGlobal("localStorage", { getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value), @@ -100,10 +106,7 @@ describe("savedSqlEditorPosition", () => { ); } - const stored = JSON.parse(storage.get(SAVED_SQL_EDITOR_POSITIONS_STORAGE_KEY) ?? "[]") as Array<{ savedSqlId: string }>; - - expect(stored).toHaveLength(200); - expect(stored.some((item) => item.savedSqlId === "file-0")).toBe(false); - expect(stored[0]?.savedSqlId).toBe("file-204"); + expect(restoreSavedSqlEditorPosition("file-0", "select 1;")).toEqual({}); + expect(restoreSavedSqlEditorPosition("file-204", "select 1;").selection).toEqual({ anchor: 0, head: 0 }); }); }); diff --git a/apps/desktop/src/lib/app/openTabsPersistence.ts b/apps/desktop/src/lib/app/openTabsPersistence.ts index d87ebdb37..ae089d451 100644 --- a/apps/desktop/src/lib/app/openTabsPersistence.ts +++ b/apps/desktop/src/lib/app/openTabsPersistence.ts @@ -132,13 +132,10 @@ function isSavedOpenTab(value: unknown): value is SavedOpenTab { return typeof tab.id === "string" && typeof tab.title === "string" && typeof tab.connectionId === "string" && typeof tab.database === "string" && (typeof tab.sql === "string" || typeof tab.savedSqlId === "string"); } -export function restoreOpenTabsState(rawTabs: string | null, rawActiveTabId: string | null, options: { queryOnly?: boolean; filter?: OpenTabsRestoreFilter } = {}): RestoredOpenTabs { - if (!rawTabs) return { tabs: [], activeTabId: null }; +function restoreOpenTabsArray(parsed: unknown, rawActiveTabId: string | null, options: { queryOnly?: boolean; filter?: OpenTabsRestoreFilter } = {}): RestoredOpenTabs { + if (!Array.isArray(parsed)) 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 = saved.filter((tab) => { if (options.queryOnly && (tab.mode ?? "query") !== "query") return false; @@ -185,3 +182,18 @@ export function restoreOpenTabsState(rawTabs: string | null, rawActiveTabId: str return { tabs: [], activeTabId: null }; } } + +export function restoreOpenTabsPayload(payload: { tabs?: unknown; activeTabId?: unknown } | null | undefined, options: { queryOnly?: boolean; filter?: OpenTabsRestoreFilter } = {}): RestoredOpenTabs { + if (!payload) return { tabs: [], activeTabId: null }; + return restoreOpenTabsArray(payload.tabs, typeof payload.activeTabId === "string" ? payload.activeTabId : null, options); +} + +export function restoreOpenTabsState(rawTabs: string | null, rawActiveTabId: string | null, options: { queryOnly?: boolean; filter?: OpenTabsRestoreFilter } = {}): RestoredOpenTabs { + if (!rawTabs) return { tabs: [], activeTabId: null }; + + try { + return restoreOpenTabsArray(JSON.parse(rawTabs), rawActiveTabId, options); + } catch { + return { tabs: [], activeTabId: null }; + } +} diff --git a/apps/desktop/src/lib/app/savedSqlEditorPosition.ts b/apps/desktop/src/lib/app/savedSqlEditorPosition.ts index 0f9020fe7..f69c002d0 100644 --- a/apps/desktop/src/lib/app/savedSqlEditorPosition.ts +++ b/apps/desktop/src/lib/app/savedSqlEditorPosition.ts @@ -1,3 +1,6 @@ +import * as api from "@/lib/backend/api"; +import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage"; + export const SAVED_SQL_EDITOR_POSITIONS_STORAGE_KEY = "dbx-saved-sql-editor-positions"; const MAX_SAVED_SQL_EDITOR_POSITIONS = 200; @@ -83,44 +86,82 @@ function restoredHeadFromAnchor(position: SavedSqlEditorPosition, sql: string): return targetHead; } -function parseSavedPositions(raw: string | null): SavedSqlEditorPosition[] { +function normalizeSavedPositions(value: unknown): SavedSqlEditorPosition[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is SavedSqlEditorPosition => { + return ( + !!item && + typeof item === "object" && + typeof item.savedSqlId === "string" && + !!item.selection && + typeof item.selection.anchor === "number" && + typeof item.selection.head === "number" && + !!item.anchor && + typeof item.anchor.before === "string" && + typeof item.anchor.after === "string" && + typeof item.anchor.head === "number" && + typeof item.anchor.docLength === "number" && + typeof item.updatedAt === "number" + ); + }); +} + +function parseLegacySavedPositions(): SavedSqlEditorPosition[] { + const raw = safeLocalStorageGet(SAVED_SQL_EDITOR_POSITIONS_STORAGE_KEY); if (!raw) return []; try { - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed.filter((item): item is SavedSqlEditorPosition => { - return ( - !!item && - typeof item === "object" && - typeof item.savedSqlId === "string" && - !!item.selection && - typeof item.selection.anchor === "number" && - typeof item.selection.head === "number" && - !!item.anchor && - typeof item.anchor.before === "string" && - typeof item.anchor.after === "string" && - typeof item.anchor.head === "number" && - typeof item.anchor.docLength === "number" && - typeof item.updatedAt === "number" - ); - }); + return normalizeSavedPositions(JSON.parse(raw)); } catch { return []; } } +let savedPositions: SavedSqlEditorPosition[] = []; +let savedPositionsLoaded = false; +let savedPositionsInitPromise: Promise | undefined; + +export async function initSavedSqlEditorPositions() { + if (savedPositionsLoaded) return; + if (savedPositionsInitPromise) return savedPositionsInitPromise; + + savedPositionsInitPromise = (async () => { + const storedValue = await api.loadSavedSqlEditorPositions().catch(() => null); + if (Array.isArray(storedValue)) { + savedPositions = normalizeSavedPositions(storedValue); + return; + } + + const legacy = parseLegacySavedPositions(); + if (legacy.length > 0) { + savedPositions = legacy; + try { + await api.saveSavedSqlEditorPositions(trimSavedPositions(legacy)); + // Remove the synchronous startup payload only after the async store + // accepts the migrated positions. + safeLocalStorageRemove(SAVED_SQL_EDITOR_POSITIONS_STORAGE_KEY); + } catch { + /* keep legacy values for a later migration attempt */ + } + } + })().finally(() => { + savedPositionsLoaded = true; + savedPositionsInitPromise = undefined; + }); + + return savedPositionsInitPromise; +} + function readSavedPositions(): SavedSqlEditorPosition[] { - try { - return parseSavedPositions(localStorage.getItem(SAVED_SQL_EDITOR_POSITIONS_STORAGE_KEY)); - } catch { - return []; - } + return savedPositions; +} + +function trimSavedPositions(positions: SavedSqlEditorPosition[]) { + return [...positions].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_SAVED_SQL_EDITOR_POSITIONS); } function writeSavedPositions(positions: SavedSqlEditorPosition[]) { - try { - localStorage.setItem(SAVED_SQL_EDITOR_POSITIONS_STORAGE_KEY, JSON.stringify([...positions].sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_SAVED_SQL_EDITOR_POSITIONS))); - } catch {} + savedPositions = trimSavedPositions(positions); + void api.saveSavedSqlEditorPositions(savedPositions).catch(() => {}); } export function createSavedSqlEditorPosition(input: { savedSqlId: string; sql: string; selection?: SavedSqlEditorSelection; viewport?: SavedSqlEditorViewport; now?: number }): SavedSqlEditorPosition { @@ -166,3 +207,9 @@ export function restoreSavedSqlEditorPosition(savedSqlId: string, sql: string): export function forgetSavedSqlEditorPosition(savedSqlId: string) { writeSavedPositions(readSavedPositions().filter((item) => item.savedSqlId !== savedSqlId)); } + +export function __resetSavedSqlEditorPositionsForTests(positions: SavedSqlEditorPosition[] = []) { + savedPositions = trimSavedPositions(positions); + savedPositionsLoaded = true; + savedPositionsInitPromise = undefined; +} diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 697165a29..6d0f04228 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -219,6 +219,12 @@ export const setAgentStoreDir = forward("setAgentStoreDir"); export const getDriverStorePath = forward("getDriverStorePath"); export const loadPinnedTreeNodeIds = forward("loadPinnedTreeNodeIds"); export const savePinnedTreeNodeIds = forward("savePinnedTreeNodeIds"); +export const loadEditorSettings = forward("loadEditorSettings"); +export const saveEditorSettings = forward("saveEditorSettings"); +export const loadOpenTabsState = forward("loadOpenTabsState"); +export const saveOpenTabsState = forward("saveOpenTabsState"); +export const loadSavedSqlEditorPositions = forward("loadSavedSqlEditorPositions"); +export const saveSavedSqlEditorPositions = forward("saveSavedSqlEditorPositions"); export const webdavSyncTest = forward("webdavSyncTest"); export const webdavPasswordStatus = forward("webdavPasswordStatus"); export const saveWebdavSavedPassword = forward("saveWebdavSavedPassword"); diff --git a/apps/desktop/src/lib/backend/browserAppStateStorage.ts b/apps/desktop/src/lib/backend/browserAppStateStorage.ts new file mode 100644 index 000000000..91e66d340 --- /dev/null +++ b/apps/desktop/src/lib/backend/browserAppStateStorage.ts @@ -0,0 +1,70 @@ +import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; + +const DB_NAME = "dbx-app-state"; +const DB_VERSION = 1; +const STORE_NAME = "state"; +const LOCAL_STORAGE_PREFIX = "dbx-app-state:"; + +function indexedDb(): IDBFactory | undefined { + return typeof globalThis.indexedDB === "undefined" ? undefined : globalThis.indexedDB; +} + +function requestToPromise(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed")); + }); +} + +let dbPromise: Promise | undefined; + +function openDb(): Promise { + if (dbPromise) return dbPromise; + const idb = indexedDb(); + if (!idb) return Promise.resolve(null); + + dbPromise = new Promise((resolve) => { + const request = idb.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME); + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => resolve(null); + request.onblocked = () => resolve(null); + }); + return dbPromise; +} + +function fallbackKey(key: string) { + return `${LOCAL_STORAGE_PREFIX}${key}`; +} + +async function withStore(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest): Promise { + const db = await openDb(); + if (!db) return null; + try { + return await requestToPromise(run(db.transaction(STORE_NAME, mode).objectStore(STORE_NAME))); + } catch { + return null; + } +} + +export async function loadBrowserAppState(key: string): Promise { + const value = await withStore("readonly", (store) => store.get(key)); + if (value !== null && value !== undefined) return value; + + const fallback = safeLocalStorageGet(fallbackKey(key)); + if (!fallback) return null; + try { + return JSON.parse(fallback); + } catch { + return null; + } +} + +export async function saveBrowserAppState(key: string, value: unknown): Promise { + const result = await withStore("readwrite", (store) => store.put(value, key)); + if (result !== null) return; + safeLocalStorageSet(fallbackKey(key), JSON.stringify(value)); +} diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 2b087c77b..c3bf8c24c 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -113,6 +113,7 @@ import type { BuildRenameObjectSqlOptions } from "@/lib/table/objectRenameSql"; import type { CreateDatabaseSqlOptions } from "@/lib/database/createDatabaseSql"; import type { DatabaseNameSqlOptions, DropTableChildObjectSqlOptions, DropObjectSqlOptions, DuplicateTableStructureSqlOptions, CopyTableDataSqlOptions, SchemaNameSqlOptions, TableAdminSqlOptions } from "@/lib/database/dbAdminSql"; import type { BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions } from "@/lib/export/databaseExport"; +import { loadBrowserAppState, saveBrowserAppState } from "@/lib/backend/browserAppStateStorage"; import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataGrid/dataCompare"; import { apiUrl, apiWebSocketUrl } from "@/lib/common/webPath"; import type { DataGridSavePreparation } from "@/lib/backend/tauri"; @@ -1026,6 +1027,39 @@ export async function saveDesktopSettings(settings: DesktopSettings): Promise { + return loadBrowserAppState("editor_settings"); +} + +export async function saveEditorSettings(settings: unknown): Promise { + await saveBrowserAppState("editor_settings", settings); +} + +export async function loadOpenTabsState(): Promise { + const value = await loadBrowserAppState("open_tabs"); + if (!value || typeof value !== "object") return null; + const payload = value as Partial; + return Array.isArray(payload.tabs) ? { tabs: payload.tabs, activeTabId: typeof payload.activeTabId === "string" ? payload.activeTabId : null } : null; +} + +export async function saveOpenTabsState(payload: OpenTabsStatePayload): Promise { + await saveBrowserAppState("open_tabs", payload); +} + +export async function loadSavedSqlEditorPositions(): Promise { + const value = await loadBrowserAppState("saved_sql_editor_positions"); + return Array.isArray(value) ? value : null; +} + +export async function saveSavedSqlEditorPositions(positions: unknown[]): Promise { + await saveBrowserAppState("saved_sql_editor_positions", positions); +} + export async function completeAppClose(_action: "quit" | "hide"): Promise { return undefined; } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 5ddbd8ea5..7cecf5286 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -376,6 +376,35 @@ export async function saveDesktopSettings(settings: DesktopSettings): Promise { + return invoke("load_editor_settings"); +} + +export async function saveEditorSettings(settings: unknown): Promise { + return invoke("save_editor_settings", { settings }); +} + +export async function loadOpenTabsState(): Promise { + return invoke("load_open_tabs_state"); +} + +export async function saveOpenTabsState(payload: OpenTabsStatePayload): Promise { + return invoke("save_open_tabs_state", { payload }); +} + +export async function loadSavedSqlEditorPositions(): Promise { + return invoke("load_saved_sql_editor_positions"); +} + +export async function saveSavedSqlEditorPositions(positions: unknown[]): Promise { + return invoke("save_saved_sql_editor_positions", { positions }); +} + export async function completeAppClose(action: "quit" | "hide"): Promise { return invoke("complete_app_close", { action }); } diff --git a/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts index fe4b5676e..d0665ba45 100644 --- a/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts +++ b/apps/desktop/src/stores/__tests__/queryStore.database-open.spec.ts @@ -160,13 +160,16 @@ describe("queryStore database open state", () => { storage.set(ACTIVE_TAB_STORAGE_KEY, "tab-1"); setActivePinia(createPinia()); + const { useSettingsStore } = await import("@/stores/settingsStore"); const { useQueryStore } = await import("@/stores/queryStore"); + await useSettingsStore().initEditorSettings(); const store = useQueryStore(); + await store.initOpenTabs(); expect(store.tabs).toEqual([]); expect(store.activeTabId).toBeNull(); - expect(storage.get(OPEN_TABS_STORAGE_KEY)).toBe(persistedTabs); - expect(storage.get(ACTIVE_TAB_STORAGE_KEY)).toBe("tab-1"); + expect(storage.get(OPEN_TABS_STORAGE_KEY)).toBeUndefined(); + expect(storage.get(ACTIVE_TAB_STORAGE_KEY)).toBeUndefined(); }); it("restores only pinned tabs when launch restore mode is pinned", async () => { @@ -196,8 +199,11 @@ describe("queryStore database open state", () => { storage.set(ACTIVE_TAB_STORAGE_KEY, "tab-2"); setActivePinia(createPinia()); + const { useSettingsStore } = await import("@/stores/settingsStore"); const { useQueryStore } = await import("@/stores/queryStore"); + await useSettingsStore().initEditorSettings(); const store = useQueryStore(); + await store.initOpenTabs(); expect(store.tabs.map((tab) => tab.id)).toEqual(["tab-1"]); expect(store.activeTabId).toBe("tab-1"); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index d9d2ac6ce..02525b884 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -7,7 +7,7 @@ import { orderPinnedFirst } from "@/lib/app/pinnedItems"; import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState"; import { buildExplainSql, parseExplainResult, parseDamengExplainText } from "@/lib/diagram/explainPlan"; import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQuery, sourceColumnsForResult, type EditableQueryInfo } from "@/lib/sql/sqlAnalysis"; -import { ACTIVE_TAB_STORAGE_KEY, OPEN_TABS_STORAGE_KEY, restoreOpenTabsState, serializeOpenTabs } from "@/lib/app/openTabsPersistence"; +import { ACTIVE_TAB_STORAGE_KEY, OPEN_TABS_STORAGE_KEY, restoreOpenTabsPayload, restoreOpenTabsState, serializeOpenTabs } from "@/lib/app/openTabsPersistence"; import { evaluateMongoAggregateSafety, evaluateMongoWriteSafety, @@ -44,7 +44,8 @@ import * as api from "@/lib/backend/api"; import { useConnectionStore } from "@/stores/connectionStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; -import { createSavedSqlEditorPosition, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition"; +import { createSavedSqlEditorPosition, initSavedSqlEditorPositions, restoreSavedSqlEditorPosition, saveSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition"; +import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage"; import type { SavedSqlFile } from "@/types/database"; const ORACLE_LIKE_METADATA_TYPES = new Set(["oracle", "dameng", "oceanbase-oracle"]); @@ -179,25 +180,41 @@ function normalizeOracleLikeQueryAnalysis(dbType: string, analysis: EditableQuer }; } -function saveTabs(tabs: QueryTab[], activeTabId: string | null) { - try { - localStorage.setItem(OPEN_TABS_STORAGE_KEY, JSON.stringify(serializeOpenTabs(tabs))); - localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, activeTabId || ""); - } catch {} +let saveTabsQueue = Promise.resolve(); + +function saveTabs(tabs: QueryTab[], activeTabId: string | null): Promise { + const payload = { tabs: serializeOpenTabs(tabs), activeTabId }; + saveTabsQueue = saveTabsQueue.catch(() => undefined).then(() => api.saveOpenTabsState(payload)); + return saveTabsQueue; } -function loadSavedTabs(): { tabs: QueryTab[]; activeTabId: string | null } { - try { - const restoreMode = useSettingsStore().editorSettings.openTabsRestoreMode; - if (restoreMode === "none") { - return { tabs: [], activeTabId: null }; - } - return restoreOpenTabsState(localStorage.getItem(OPEN_TABS_STORAGE_KEY), localStorage.getItem(ACTIVE_TAB_STORAGE_KEY), { - filter: restoreMode === "pinned" ? "pinned" : "all", - }); - } catch { - return { tabs: [], activeTabId: null }; - } +function loadLegacySavedTabs(): { rawTabs: string | null; rawActiveTabId: string | null } { + return { + rawTabs: safeLocalStorageGet(OPEN_TABS_STORAGE_KEY), + rawActiveTabId: safeLocalStorageGet(ACTIVE_TAB_STORAGE_KEY), + }; +} + +function clearLegacySavedTabs() { + safeLocalStorageRemove(OPEN_TABS_STORAGE_KEY); + safeLocalStorageRemove(ACTIVE_TAB_STORAGE_KEY); +} + +function restoreSavedTabsFromPayload(payload: { tabs?: unknown; activeTabId?: unknown } | null | undefined): { tabs: QueryTab[]; activeTabId: string | null } { + const restoreMode = useSettingsStore().editorSettings.openTabsRestoreMode; + if (restoreMode === "none") return { tabs: [], activeTabId: null }; + return restoreOpenTabsPayload(payload, { + filter: restoreMode === "pinned" ? "pinned" : "all", + }); +} + +function restoreLegacySavedTabs(): { tabs: QueryTab[]; activeTabId: string | null } { + const restoreMode = useSettingsStore().editorSettings.openTabsRestoreMode; + if (restoreMode === "none") return { tabs: [], activeTabId: null }; + const legacy = loadLegacySavedTabs(); + return restoreOpenTabsState(legacy.rawTabs, legacy.rawActiveTabId, { + filter: restoreMode === "pinned" ? "pinned" : "all", + }); } function getI18nT() { @@ -210,19 +227,16 @@ function getI18nT() { export const useQueryStore = defineStore("query", () => { const t = getI18nT(); - const restored = loadSavedTabs(); - const tabs = ref(restored.tabs); - const activeTabId = ref(restored.activeTabId); - const activeTabHistory = ref(restored.activeTabId ? [restored.activeTabId] : []); + const tabs = ref([]); + const activeTabId = ref(null); + const isOpenTabsLoaded = ref(false); + const activeTabHistory = ref([]); const showCloseConfirm = ref(false); const pendingCloseTabId = ref(null); const pendingBatchCloseTabIds = ref(null); const pendingBatchCloseFinalActiveTabId = ref(undefined); const isConfirmingAppClose = ref(false); const closeConfirmContext = ref("tab"); - for (const tab of restored.tabs) { - if (tab.mode === "data") void deleteTabResultSnapshot(tabResultCacheKey(tab.id)); - } const tableStructureRefreshVersions = ref>({}); const savedSqlEditorPositionTimers = new Map>(); @@ -569,6 +583,41 @@ export const useQueryStore = defineStore("query", () => { clearResultPayload(tab, { evicted: true }); } + function applyRestoredOpenTabs(restored: { tabs: QueryTab[]; activeTabId: string | null }) { + tabs.value = restored.tabs; + activeTabId.value = restored.activeTabId; + activeTabHistory.value = restored.activeTabId ? [restored.activeTabId] : []; + for (const tab of restored.tabs) { + if (tab.mode === "data") void deleteTabResultSnapshot(tabResultCacheKey(tab.id)); + } + } + + async function initOpenTabs() { + if (isOpenTabsLoaded.value) return; + const saved = await api.loadOpenTabsState().catch(() => null); + if (saved?.tabs && Array.isArray(saved.tabs)) { + const restored = restoreSavedTabsFromPayload(saved); + applyRestoredOpenTabs(restored); + isOpenTabsLoaded.value = true; + return; + } + + const legacy = loadLegacySavedTabs(); + if (legacy.rawTabs || legacy.rawActiveTabId) { + const restored = restoreLegacySavedTabs(); + applyRestoredOpenTabs(restored); + try { + await saveTabs(tabs.value, activeTabId.value); + // Keep old desktop installs readable until the async store has the + // migrated state; only then remove the synchronous startup payload. + clearLegacySavedTabs(); + } catch { + /* keep legacy values for a later migration attempt */ + } + } + isOpenTabsLoaded.value = true; + } + const _persistSnapshot = computed(() => tabs.value.map((t) => ({ id: t.id, @@ -609,7 +658,7 @@ export const useQueryStore = defineStore("query", () => { () => { if (_persistTimer) clearTimeout(_persistTimer); _persistTimer = setTimeout(() => { - saveTabs(tabs.value, activeTabId.value); + void saveTabs(tabs.value, activeTabId.value).catch(() => {}); _persistTimer = null; }, 300); }, @@ -620,12 +669,12 @@ export const useQueryStore = defineStore("query", () => { // reflects the latest in-memory tabs without waiting for the 300ms debounce. // Lets callers (e.g. tests that reload the store) read back persisted state // deterministically instead of racing the debounce timer. - function flushPendingPersist() { + function flushPendingPersist(): Promise { if (_persistTimer) { clearTimeout(_persistTimer); _persistTimer = null; } - saveTabs(tabs.value, activeTabId.value); + return saveTabs(tabs.value, activeTabId.value); } function findTabByIdentity(connectionId: string, database: string, title: string, mode: QueryTab["mode"], schema?: string) { @@ -1473,6 +1522,7 @@ export const useQueryStore = defineStore("query", () => { } async function hydrateSavedSqlTabs() { + await initSavedSqlEditorPositions(); const savedSqlStore = useSavedSqlStore(); const linkedTabs = tabs.value.filter((tab) => tab.savedSqlId && tab.sql === ""); for (const tab of linkedTabs) { @@ -2921,6 +2971,8 @@ export const useQueryStore = defineStore("query", () => { return { tabs, activeTabId, + isOpenTabsLoaded, + initOpenTabs, showCloseConfirm, pendingCloseTabId, closeConfirmContext, diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index fbd76386b..ce8d8fbcf 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -12,6 +12,7 @@ import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion"; import { setDebugLoggingEnabled } from "@/lib/backend/debugLog"; import { DEFAULT_TABLE_COLUMN_TEMPLATE_FIELDS, normalizeTableColumnTemplateFields } from "@/lib/table/tableColumnTemplates"; import { DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts"; +import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage"; export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "codex-cli" | "custom"; export type AiApiStyle = "completions" | "responses" | "anthropic-messages"; @@ -751,46 +752,34 @@ export function normalizeEditorSettings(settings: Partial, exist }; } -function loadEditorSettings(): EditorSettings { - // Try new format first - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) { +function loadLegacyEditorSettings(): EditorSettings | null { + const raw = safeLocalStorageGet(STORAGE_KEY); + if (raw) { + try { const parsed = JSON.parse(raw) as Partial; - if (parsed.exportBatchSize === LEGACY_DEFAULT_EXPORT_BATCH_SIZE && localStorage.getItem(EXPORT_BATCH_SIZE_DEFAULT_MIGRATION_KEY) !== "1") { + if (parsed.exportBatchSize === LEGACY_DEFAULT_EXPORT_BATCH_SIZE && safeLocalStorageGet(EXPORT_BATCH_SIZE_DEFAULT_MIGRATION_KEY) !== "1") { parsed.exportBatchSize = DEFAULT_EDITOR_SETTINGS.exportBatchSize; - localStorage.setItem(EXPORT_BATCH_SIZE_DEFAULT_MIGRATION_KEY, "1"); - const migrated = normalizeEditorSettings(parsed); - saveEditorSettings(migrated); - return migrated; } return normalizeEditorSettings(parsed); + } catch { + return null; } - } catch { - /* ignore */ } - // Migrate old font-size key if new settings don't exist - try { - const oldSize = localStorage.getItem(OLD_FONT_SIZE_KEY); - if (oldSize) { - const parsed = parseInt(oldSize, 10); - if (!isNaN(parsed)) { - const migrated = normalizeEditorSettings({ fontSize: parsed }); - saveEditorSettings(migrated); - localStorage.removeItem(OLD_FONT_SIZE_KEY); - return migrated; - } - } - } catch { - /* ignore */ - } + const oldSize = safeLocalStorageGet(OLD_FONT_SIZE_KEY); + if (!oldSize) return null; + const parsed = parseInt(oldSize, 10); + return Number.isNaN(parsed) ? null : normalizeEditorSettings({ fontSize: parsed }); +} - return normalizeEditorSettings({}); +function clearLegacyEditorSettings() { + safeLocalStorageRemove(STORAGE_KEY); + safeLocalStorageRemove(OLD_FONT_SIZE_KEY); + safeLocalStorageRemove(EXPORT_BATCH_SIZE_DEFAULT_MIGRATION_KEY); } function saveEditorSettings(settings: EditorSettings) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + void api.saveEditorSettings(settings).catch(() => {}); } export const useSettingsStore = defineStore("settings", () => { @@ -799,8 +788,33 @@ export const useSettingsStore = defineStore("settings", () => { const aiProviderConfigs = ref>>({}); const desktopSettings = ref({ ...DEFAULT_DESKTOP_SETTINGS }); const isDesktopSettingsLoaded = ref(false); + const isEditorSettingsLoaded = ref(false); - const editorSettings = ref(loadEditorSettings()); + const editorSettings = ref(normalizeEditorSettings({})); + + async function initEditorSettings() { + if (isEditorSettingsLoaded.value) return; + const saved = await api.loadEditorSettings().catch(() => null); + if (saved && typeof saved === "object" && !Array.isArray(saved)) { + editorSettings.value = normalizeEditorSettings(saved as Partial); + isEditorSettingsLoaded.value = true; + return; + } + + const legacy = loadLegacyEditorSettings(); + if (legacy) { + editorSettings.value = legacy; + try { + await api.saveEditorSettings(legacy); + // Existing desktop users keep settings in localStorage; remove them only + // after the async store has accepted the migrated value. + clearLegacyEditorSettings(); + } catch { + /* keep legacy values for a later migration attempt */ + } + } + isEditorSettingsLoaded.value = true; + } async function initDesktopSettings() { if (isDesktopSettingsLoaded.value) return; @@ -1016,8 +1030,10 @@ export const useSettingsStore = defineStore("settings", () => { updateAiConfig, isConfigured, isAiProviderConfigured, + isEditorSettingsLoaded, editorSettings, desktopSettings, + initEditorSettings, updateEditorSettings, initDesktopSettings, updateDesktopSettings, diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index a6a1f76f4..fba637795 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -20,6 +20,9 @@ use crate::saved_sql::{SavedSqlFile, SavedSqlFolder, SavedSqlLibrary}; const SSH_TUNNEL_SECRET_PREFIX: &str = "ssh_tunnels."; const TRANSPORT_LAYER_SECRET_PREFIX: &str = "transport_layers."; const STORAGE_DB_FILE_NAME: &str = "dbx.db"; +const APP_STATE_EDITOR_SETTINGS_KEY: &str = "editor_settings"; +const APP_STATE_OPEN_TABS_KEY: &str = "open_tabs"; +const APP_STATE_SAVED_SQL_EDITOR_POSITIONS_KEY: &str = "saved_sql_editor_positions"; const USER_DATA_TABLES: &[&str] = &[ "connections", "connection_secrets", @@ -223,6 +226,10 @@ const SCHEMA_STATEMENTS: &[&str] = &[ id INTEGER PRIMARY KEY CHECK (id = 1), settings_json TEXT NOT NULL )", + "CREATE TABLE IF NOT EXISTS app_state ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL + )", "CREATE TABLE IF NOT EXISTS schema_cache ( cache_key TEXT PRIMARY KEY, payload_json TEXT NOT NULL, @@ -918,6 +925,53 @@ impl Storage { Ok(array.iter().filter_map(|item| item.as_str().map(|value| value.to_string())).collect()) } + async fn save_app_state_value(&self, key: &str, value: &serde_json::Value) -> Result<(), String> { + let key = key.to_string(); + let value_json = serde_json::to_string(value).map_err(|e| e.to_string())?; + self.with_conn(move |conn| { + conn.execute("INSERT OR REPLACE INTO app_state (key, value_json) VALUES (?1, ?2)", params![key, value_json]) + .map(|_| ()) + .map_err(|e| e.to_string()) + }) + .await + } + + async fn load_app_state_value(&self, key: &str) -> Result, String> { + let key = key.to_string(); + let json: Option = self + .with_conn(move |conn| { + conn.query_row("SELECT value_json FROM app_state WHERE key = ?1", [key], |row| row.get(0)) + .optional() + .map_err(|e| e.to_string()) + }) + .await?; + json.map(|value| serde_json::from_str(&value).map_err(|e| e.to_string())).transpose() + } + + pub async fn save_editor_settings(&self, settings: &serde_json::Value) -> Result<(), String> { + self.save_app_state_value(APP_STATE_EDITOR_SETTINGS_KEY, settings).await + } + + pub async fn load_editor_settings(&self) -> Result, String> { + self.load_app_state_value(APP_STATE_EDITOR_SETTINGS_KEY).await + } + + pub async fn save_open_tabs_state(&self, state: &serde_json::Value) -> Result<(), String> { + self.save_app_state_value(APP_STATE_OPEN_TABS_KEY, state).await + } + + pub async fn load_open_tabs_state(&self) -> Result, String> { + self.load_app_state_value(APP_STATE_OPEN_TABS_KEY).await + } + + pub async fn save_saved_sql_editor_positions(&self, positions: &serde_json::Value) -> Result<(), String> { + self.save_app_state_value(APP_STATE_SAVED_SQL_EDITOR_POSITIONS_KEY, positions).await + } + + pub async fn load_saved_sql_editor_positions(&self) -> Result, String> { + self.load_app_state_value(APP_STATE_SAVED_SQL_EDITOR_POSITIONS_KEY).await + } + pub async fn load_or_create_local_device_secret(&self) -> Result { let mut settings = self.load_app_settings_json().await?; if let Some(secret) = settings.get("local_device_secret").and_then(|value| value.as_str()) { @@ -2867,6 +2921,53 @@ mod tests { assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-3".to_string())); } + #[tokio::test] + async fn app_state_roundtrips_without_polluting_app_settings() { + let path = temp_db_path("app-state-roundtrip"); + let storage = Storage::open(&path).await.unwrap(); + + storage.save_password_hash("hash-4").await.unwrap(); + storage + .save_desktop_settings(&DesktopSettings { + icon_theme: DesktopIconTheme::Black, + ..DesktopSettings::default() + }) + .await + .unwrap(); + + storage.save_editor_settings(&serde_json::json!({ "openTabsRestoreMode": "pinned" })).await.unwrap(); + storage + .save_open_tabs_state(&serde_json::json!({ + "tabs": [{ "id": "tab-1", "title": "Pinned", "connectionId": "pg", "database": "app", "sql": "select 1", "pinned": true }], + "activeTabId": "tab-1" + })) + .await + .unwrap(); + storage + .save_saved_sql_editor_positions(&serde_json::json!([{ "savedSqlId": "file-1", "updatedAt": 1 }])) + .await + .unwrap(); + + assert_eq!( + storage.load_editor_settings().await.unwrap(), + Some(serde_json::json!({ "openTabsRestoreMode": "pinned" })) + ); + assert_eq!( + storage.load_open_tabs_state().await.unwrap().and_then(|value| value.get("activeTabId").cloned()), + Some(serde_json::json!("tab-1")) + ); + assert_eq!( + storage.load_saved_sql_editor_positions().await.unwrap(), + Some(serde_json::json!([{ "savedSqlId": "file-1", "updatedAt": 1 }])) + ); + assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-4".to_string())); + assert_eq!( + storage.load_desktop_settings().await.unwrap(), + DesktopSettings { icon_theme: DesktopIconTheme::Black, ..DesktopSettings::default() } + ); + assert_eq!(storage.load_app_settings_json().await.unwrap().get("open_tabs"), None); + } + #[tokio::test] async fn tab_runtime_cache_roundtrips_binary_payloads() { let path = temp_db_path("tab-runtime-cache"); diff --git a/src-tauri/src/commands/app_settings.rs b/src-tauri/src/commands/app_settings.rs index de54bfc08..b93557897 100644 --- a/src-tauri/src/commands/app_settings.rs +++ b/src-tauri/src/commands/app_settings.rs @@ -75,6 +75,41 @@ pub async fn save_pinned_tree_node_ids(state: State<'_, Arc>, ids: Vec state.storage.save_pinned_tree_node_ids(&ids).await } +#[tauri::command] +pub async fn load_editor_settings(state: State<'_, Arc>) -> Result, String> { + state.storage.load_editor_settings().await +} + +#[tauri::command] +pub async fn save_editor_settings(state: State<'_, Arc>, settings: serde_json::Value) -> Result<(), String> { + state.storage.save_editor_settings(&settings).await +} + +#[tauri::command] +pub async fn load_open_tabs_state(state: State<'_, Arc>) -> Result, String> { + state.storage.load_open_tabs_state().await +} + +#[tauri::command] +pub async fn save_open_tabs_state(state: State<'_, Arc>, payload: serde_json::Value) -> Result<(), String> { + state.storage.save_open_tabs_state(&payload).await +} + +#[tauri::command] +pub async fn load_saved_sql_editor_positions( + state: State<'_, Arc>, +) -> Result, String> { + state.storage.load_saved_sql_editor_positions().await +} + +#[tauri::command] +pub async fn save_saved_sql_editor_positions( + state: State<'_, Arc>, + positions: serde_json::Value, +) -> Result<(), String> { + state.storage.save_saved_sql_editor_positions(&positions).await +} + #[tauri::command] pub async fn load_native_debug_logs(app: AppHandle) -> Result { let log_dir = app.path().app_log_dir().map_err(|e| e.to_string())?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ce41001ad..062d38809 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -736,6 +736,12 @@ pub fn run() { commands::app_settings::get_driver_store_path, commands::app_settings::load_pinned_tree_node_ids, commands::app_settings::save_pinned_tree_node_ids, + commands::app_settings::load_editor_settings, + commands::app_settings::save_editor_settings, + commands::app_settings::load_open_tabs_state, + commands::app_settings::save_open_tabs_state, + commands::app_settings::load_saved_sql_editor_positions, + commands::app_settings::save_saved_sql_editor_positions, commands::app_settings::load_native_debug_logs, commands::cloud_sync::webdav_sync_test, commands::cloud_sync::webdav_password_status,