fix(desktop): persist UI state asynchronously

This commit is contained in:
t8y2 2026-07-05 02:22:13 +08:00
parent 693eb0dbb9
commit 6b13564180
14 changed files with 547 additions and 127 deletions

View File

@ -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();

View File

@ -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<string, string>();
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 });
});
});

View File

@ -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 };
}
}

View File

@ -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<void> | 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;
}

View File

@ -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");

View File

@ -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<T>(request: IDBRequest<T>): Promise<T> {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("IndexedDB request failed"));
});
}
let dbPromise: Promise<IDBDatabase | null> | undefined;
function openDb(): Promise<IDBDatabase | null> {
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<T>(mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest<T>): Promise<T | null> {
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<unknown | null> {
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<void> {
const result = await withStore("readwrite", (store) => store.put(value, key));
if (result !== null) return;
safeLocalStorageSet(fallbackKey(key), JSON.stringify(value));
}

View File

@ -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<vo
safeLocalStorageSet(DESKTOP_SETTINGS_STORAGE_KEY, JSON.stringify({ ...DEFAULT_DESKTOP_SETTINGS, ...settings }));
}
export interface OpenTabsStatePayload {
tabs: unknown[];
activeTabId: string | null;
}
export async function loadEditorSettings(): Promise<unknown | null> {
return loadBrowserAppState("editor_settings");
}
export async function saveEditorSettings(settings: unknown): Promise<void> {
await saveBrowserAppState("editor_settings", settings);
}
export async function loadOpenTabsState(): Promise<OpenTabsStatePayload | null> {
const value = await loadBrowserAppState("open_tabs");
if (!value || typeof value !== "object") return null;
const payload = value as Partial<OpenTabsStatePayload>;
return Array.isArray(payload.tabs) ? { tabs: payload.tabs, activeTabId: typeof payload.activeTabId === "string" ? payload.activeTabId : null } : null;
}
export async function saveOpenTabsState(payload: OpenTabsStatePayload): Promise<void> {
await saveBrowserAppState("open_tabs", payload);
}
export async function loadSavedSqlEditorPositions(): Promise<unknown[] | null> {
const value = await loadBrowserAppState("saved_sql_editor_positions");
return Array.isArray(value) ? value : null;
}
export async function saveSavedSqlEditorPositions(positions: unknown[]): Promise<void> {
await saveBrowserAppState("saved_sql_editor_positions", positions);
}
export async function completeAppClose(_action: "quit" | "hide"): Promise<void> {
return undefined;
}

View File

@ -376,6 +376,35 @@ export async function saveDesktopSettings(settings: DesktopSettings): Promise<vo
return invoke("save_desktop_settings", { settings });
}
export interface OpenTabsStatePayload {
tabs: unknown[];
activeTabId: string | null;
}
export async function loadEditorSettings(): Promise<unknown | null> {
return invoke("load_editor_settings");
}
export async function saveEditorSettings(settings: unknown): Promise<void> {
return invoke("save_editor_settings", { settings });
}
export async function loadOpenTabsState(): Promise<OpenTabsStatePayload | null> {
return invoke("load_open_tabs_state");
}
export async function saveOpenTabsState(payload: OpenTabsStatePayload): Promise<void> {
return invoke("save_open_tabs_state", { payload });
}
export async function loadSavedSqlEditorPositions(): Promise<unknown[] | null> {
return invoke("load_saved_sql_editor_positions");
}
export async function saveSavedSqlEditorPositions(positions: unknown[]): Promise<void> {
return invoke("save_saved_sql_editor_positions", { positions });
}
export async function completeAppClose(action: "quit" | "hide"): Promise<void> {
return invoke("complete_app_close", { action });
}

View File

@ -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");

View File

@ -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<string>(["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<void> {
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<QueryTab[]>(restored.tabs);
const activeTabId = ref<string | null>(restored.activeTabId);
const activeTabHistory = ref<string[]>(restored.activeTabId ? [restored.activeTabId] : []);
const tabs = ref<QueryTab[]>([]);
const activeTabId = ref<string | null>(null);
const isOpenTabsLoaded = ref(false);
const activeTabHistory = ref<string[]>([]);
const showCloseConfirm = ref(false);
const pendingCloseTabId = ref<string | null>(null);
const pendingBatchCloseTabIds = ref<string[] | null>(null);
const pendingBatchCloseFinalActiveTabId = ref<string | null | undefined>(undefined);
const isConfirmingAppClose = ref(false);
const closeConfirmContext = ref<CloseConfirmContext>("tab");
for (const tab of restored.tabs) {
if (tab.mode === "data") void deleteTabResultSnapshot(tabResultCacheKey(tab.id));
}
const tableStructureRefreshVersions = ref<Record<string, number>>({});
const savedSqlEditorPositionTimers = new Map<string, ReturnType<typeof setTimeout>>();
@ -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<void> {
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,

View File

@ -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<EditorSettings>, 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<EditorSettings>;
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<Partial<Record<AiProvider, AiConfig>>>({});
const desktopSettings = ref<DesktopSettings>({ ...DEFAULT_DESKTOP_SETTINGS });
const isDesktopSettingsLoaded = ref(false);
const isEditorSettingsLoaded = ref(false);
const editorSettings = ref<EditorSettings>(loadEditorSettings());
const editorSettings = ref<EditorSettings>(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<EditorSettings>);
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,

View File

@ -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<Option<serde_json::Value>, String> {
let key = key.to_string();
let json: Option<String> = 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<Option<serde_json::Value>, 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<Option<serde_json::Value>, 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<Option<serde_json::Value>, 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<String, String> {
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");

View File

@ -75,6 +75,41 @@ pub async fn save_pinned_tree_node_ids(state: State<'_, Arc<AppState>>, ids: Vec
state.storage.save_pinned_tree_node_ids(&ids).await
}
#[tauri::command]
pub async fn load_editor_settings(state: State<'_, Arc<AppState>>) -> Result<Option<serde_json::Value>, String> {
state.storage.load_editor_settings().await
}
#[tauri::command]
pub async fn save_editor_settings(state: State<'_, Arc<AppState>>, 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<AppState>>) -> Result<Option<serde_json::Value>, String> {
state.storage.load_open_tabs_state().await
}
#[tauri::command]
pub async fn save_open_tabs_state(state: State<'_, Arc<AppState>>, 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<AppState>>,
) -> Result<Option<serde_json::Value>, String> {
state.storage.load_saved_sql_editor_positions().await
}
#[tauri::command]
pub async fn save_saved_sql_editor_positions(
state: State<'_, Arc<AppState>>,
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<String, String> {
let log_dir = app.path().app_log_dir().map_err(|e| e.to_string())?;

View File

@ -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,