fix(settings): prevent startup from overwriting persisted settings

This commit is contained in:
zipg authored and GitHub committed 2026-08-10 14:38:56 +08:00
1 parent a2f1ca37d8
commit 39399622e5
4 files changed
+196 -34

No files matched your search

@@ -82,11 +82,75 @@ describe("connectionStore timeout recovery", () => {
expect(store.connections[0]?.keepalive_interval_secs).toBe(30);
});
it("loads persisted editor settings before startup timeout migration", async () => {
const callOrder: string[] = [];
const loadEditorSettings = vi.fn(async () => {
callOrder.push("settings");
return {
appLayout: "separated",
uiFontFamily: "persisted-font",
snippets: [{ id: "persisted", label: "Persisted", prefix: "persisted", body: "SELECT 42", enabled: true }],
globalConnectTimeoutSecs: 7,
connectTimeoutInheritConnectionIds: ["persisted"],
globalQueryTimeoutSecs: 12,
queryTimeoutInheritConnectionIds: ["persisted"],
timeoutInheritanceMigrationVersion: 2,
executeModeDefaultVersion: 1,
};
});
const loadConnections = vi.fn(async () => {
callOrder.push("connections");
return [postgresConnection({ id: "persisted", connect_timeout_secs: 7, query_timeout_secs: 12 })];
});
const saveEditorSettings = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadEditorSettings,
loadConnections,
loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]),
loadSidebarLayout: vi.fn().mockResolvedValue(null),
loadTunnelProfiles: vi.fn().mockResolvedValue([]),
saveConnections: vi.fn().mockResolvedValue(undefined),
saveEditorSettings,
saveSidebarLayout: vi.fn().mockResolvedValue(undefined),
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
await useConnectionStore().initFromDisk();
expect(callOrder).toEqual(["settings", "connections"]);
expect(saveEditorSettings).toHaveBeenCalledWith(
expect.objectContaining({
appLayout: "separated",
uiFontFamily: "persisted-font",
snippets: [expect.objectContaining({ id: "persisted", body: "SELECT 42" })],
}),
);
});
it("does not load connections when persisted editor settings cannot be read", async () => {
const loadConnections = vi.fn().mockResolvedValue([postgresConnection()]);
const saveEditorSettings = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
loadEditorSettings: vi.fn().mockRejectedValue(new Error("settings unavailable")),
loadConnections,
saveEditorSettings,
}));
const { useConnectionStore } = await import("@/stores/connectionStore");
await expect(useConnectionStore().initFromDisk()).rejects.toThrow("settings unavailable");
expect(loadConnections).not.toHaveBeenCalled();
expect(saveEditorSettings).not.toHaveBeenCalled();
});
it("migrates legacy timeout defaults to global inheritance and preserves custom overrides", async () => {
const saveConnections = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadEditorSettings: vi.fn().mockResolvedValue(null),
loadConnections: vi
.fn()
.mockResolvedValue([postgresConnection({ id: "default", connect_timeout_secs: 10, query_timeout_secs: 30 }), postgresConnection({ id: "custom", connect_timeout_secs: 45, query_timeout_secs: 300 }), postgresConnection({ id: "inherited", connect_timeout_secs: 60, query_timeout_secs: 60 })]),
@@ -130,6 +194,7 @@ describe("connectionStore timeout recovery", () => {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadEditorSettings: vi.fn().mockResolvedValue(null),
loadConnections: vi.fn().mockResolvedValue([postgresConnection({ id: "local", connect_timeout_secs: 10, query_timeout_secs: 30 })]),
loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]),
loadSidebarLayout: vi.fn().mockResolvedValue(null),
@@ -164,6 +229,7 @@ describe("connectionStore timeout recovery", () => {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadEditorSettings: vi.fn().mockResolvedValue(null),
loadConnections: vi.fn().mockResolvedValue([postgresConnection({ id: "inherited", connect_timeout_secs: 7, query_timeout_secs: 12 })]),
loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]),
loadSidebarLayout: vi.fn().mockResolvedValue(null),
@@ -194,6 +260,7 @@ describe("connectionStore timeout recovery", () => {
vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false }));
vi.doMock("@/lib/backend/api", () => ({
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadEditorSettings: vi.fn().mockResolvedValue(null),
loadConnections: vi.fn().mockResolvedValue([postgresConnection({ id: "inherited", connect_timeout_secs: 20, query_timeout_secs: 45 })]),
loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]),
loadSidebarLayout: vi.fn().mockResolvedValue(null),
@@ -235,6 +302,7 @@ describe("connectionStore timeout recovery", () => {
vi.doMock("@/lib/backend/configCrypto", () => ({ encryptConfig }));
vi.doMock("@/lib/backend/api", () => ({
deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined),
loadEditorSettings: vi.fn().mockResolvedValue(null),
loadConnections: vi.fn().mockResolvedValue([postgresConnection({ id: "inherited", connect_timeout_secs: 99, connect_timeout_inherit: true, query_timeout_secs: 99, query_timeout_inherit: true })]),
loadPinnedTreeNodeIds: vi.fn().mockResolvedValue([]),
loadSidebarLayout: vi.fn().mockResolvedValue(null),
+65 -4
View File
@@ -1,7 +1,7 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { isProxy } from "vue";
import { EXECUTE_MODE_CURRENT_DEFAULT_VERSION, enforceRightSidebarPanelExclusivity, normalizeAiConfig, normalizeDesktopSettings, normalizeEditorSettings, normalizeMcpGlobalPolicy, type RightSidebarPanelState, transitionRightSidebarPanels } from "@/stores/settingsStore";
import { DEFAULT_EDITOR_SETTINGS, EXECUTE_MODE_CURRENT_DEFAULT_VERSION, enforceRightSidebarPanelExclusivity, normalizeAiConfig, normalizeDesktopSettings, normalizeEditorSettings, normalizeMcpGlobalPolicy, type RightSidebarPanelState, transitionRightSidebarPanels } from "@/stores/settingsStore";
import type { AiConfigItem } from "@/types/ai";
describe("normalizeEditorSettings", () => {
@@ -561,6 +561,8 @@ describe("settingsStore persisted settings initialization", () => {
const store = useSettingsStore();
await expect(store.initEditorSettings()).rejects.toThrow("storage temporarily unavailable");
store.updateEditorSettings({ appLayout: "separated" });
expect(saveEditorSettings).not.toHaveBeenCalled();
await store.initEditorSettings();
expect(store.isEditorSettingsLoaded).toBe(true);
@@ -569,8 +571,61 @@ describe("settingsStore persisted settings initialization", () => {
theme: "xcode-dark",
executeMode: "all",
updateNotificationsEnabled: false,
appLayout: "separated",
});
expect(saveEditorSettings).toHaveBeenCalledWith(expect.objectContaining({ fontSize: 17, theme: "xcode-dark", appLayout: "separated" }));
});
it("shares concurrent initialization and applies startup changes after saved settings load", async () => {
let resolveLoad!: (value: unknown) => void;
const loadEditorSettings = vi.fn(
() =>
new Promise((resolve) => {
resolveLoad = resolve;
}),
);
const saveEditorSettings = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/api", () => ({ loadEditorSettings, saveEditorSettings }));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
const firstInitialization = store.initEditorSettings();
const secondInitialization = store.initEditorSettings();
store.updateEditorSettings({
appLayout: "separated",
tabLayout: "wrap",
uiFontFamily: "pre-load default snapshot",
});
expect(saveEditorSettings).not.toHaveBeenCalled();
expect(loadEditorSettings).toHaveBeenCalledOnce();
resolveLoad({
appLayout: "classic",
tabLayout: "scroll",
uiFontFamily: "persisted font",
toolbarItems: { ...DEFAULT_EDITOR_SETTINGS.toolbarItems, history: false },
snippets: [{ id: "persisted", label: "Persisted", prefix: "persisted", body: "SELECT 42", enabled: true }],
executeModeDefaultVersion: EXECUTE_MODE_CURRENT_DEFAULT_VERSION,
});
await Promise.all([firstInitialization, secondInitialization]);
expect(store.editorSettings).toMatchObject({
appLayout: "separated",
tabLayout: "wrap",
uiFontFamily: "pre-load default snapshot",
toolbarItems: expect.objectContaining({ history: false }),
snippets: [expect.objectContaining({ id: "persisted", body: "SELECT 42" })],
});
expect(saveEditorSettings).toHaveBeenCalledWith(
expect.objectContaining({
appLayout: "separated",
tabLayout: "wrap",
uiFontFamily: "pre-load default snapshot",
toolbarItems: expect.objectContaining({ history: false }),
snippets: [expect.objectContaining({ id: "persisted", body: "SELECT 42" })],
}),
);
});
});
@@ -581,11 +636,13 @@ describe("settingsStore sidebar connection sort persistence", () => {
});
it("persists the selected alphabetical sort mode", async () => {
const loadEditorSettings = vi.fn().mockResolvedValue(null);
const saveEditorSettings = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/api", () => ({ saveEditorSettings }));
vi.doMock("@/lib/backend/api", () => ({ loadEditorSettings, saveEditorSettings }));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
await store.initEditorSettings();
store.updateEditorSettings({ sidebarConnectionSortMode: "desc" });
expect(store.editorSettings.sidebarConnectionSortMode).toBe("desc");
@@ -597,11 +654,13 @@ describe("settingsStore sidebar connection sort persistence", () => {
});
it("persists the retained result run display mode", async () => {
const loadEditorSettings = vi.fn().mockResolvedValue(null);
const saveEditorSettings = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/api", () => ({ saveEditorSettings }));
vi.doMock("@/lib/backend/api", () => ({ loadEditorSettings, saveEditorSettings }));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
await store.initEditorSettings();
store.updateEditorSettings({ resultRunDisplayMode: "list" });
expect(store.editorSettings.resultRunDisplayMode).toBe("list");
@@ -617,11 +676,13 @@ describe("settingsStore regular expression match limit persistence", () => {
});
it("normalizes and persists the configured match limit", async () => {
const loadEditorSettings = vi.fn().mockResolvedValue(null);
const saveEditorSettings = vi.fn().mockResolvedValue(undefined);
vi.doMock("@/lib/backend/api", () => ({ saveEditorSettings }));
vi.doMock("@/lib/backend/api", () => ({ loadEditorSettings, saveEditorSettings }));
const { useSettingsStore } = await import("@/stores/settingsStore");
const store = useSettingsStore();
await store.initEditorSettings();
store.updateEditorSettings({ regexMaxMatchCount: 2500.4 });
expect(store.editorSettings.regexMaxMatchCount).toBe(2500);
@@ -7018,6 +7018,9 @@ export const useConnectionStore = defineStore("connection", () => {
}
async function initFromDisk() {
// Connection normalization and timeout migration depend on persisted global
// settings. Startup helpers may initialize connections before App.initApp().
await settingsStore.initEditorSettings();
if (!initFromDiskPromise) {
initFromDiskPromise = (async () => {
const [pinnedOrder, saved] = await Promise.all([loadPinnedTreeNodeOrder(), api.loadConnections(), tunnelProfileStore.init()]);
+60 -30
View File
@@ -1159,6 +1159,10 @@ function editorSettingsSnapshot(settings: EditorSettings): EditorSettings {
return JSON.parse(JSON.stringify(settings)) as EditorSettings;
}
function editorSettingsPatchSnapshot(settings: Partial<EditorSettings>): Partial<EditorSettings> {
return JSON.parse(JSON.stringify(settings)) as Partial<EditorSettings>;
}
function saveEditorSettings(settings: EditorSettings) {
void api.saveEditorSettings(editorSettingsSnapshot(settings)).catch(() => {});
}
@@ -1184,6 +1188,8 @@ export const useSettingsStore = defineStore("settings", () => {
const isDesktopSettingsLoaded = ref(false);
const isMcpGlobalPolicyLoaded = ref(false);
const isEditorSettingsLoaded = ref(false);
let initEditorSettingsPromise: Promise<void> | null = null;
let pendingEditorSettingsPatches: Partial<EditorSettings>[] = [];
let pendingAiChatSelection: AiChatSelectionState | null = null;
let aiChatSelectionSaveRunning = false;
@@ -1201,39 +1207,54 @@ export const useSettingsStore = defineStore("settings", () => {
if (settingsNavigationRequest.value?.id === id) settingsNavigationRequest.value = null;
}
function completeEditorSettingsInitialization() {
const pendingPatches = pendingEditorSettingsPatches;
pendingEditorSettingsPatches = [];
for (const patch of pendingPatches) applyEditorSettingsPatch(patch);
isEditorSettingsLoaded.value = true;
if (pendingPatches.length) saveEditorSettings(editorSettings.value);
}
async function initEditorSettings() {
if (isEditorSettingsLoaded.value) return;
// A read failure is not the same as an empty settings record. Keeping the
// store unloaded prevents startup migrations from persisting defaults over
// settings that are temporarily unavailable.
const saved = await api.loadEditorSettings();
if (saved && typeof saved === "object" && !Array.isArray(saved)) {
const savedSettings = saved as Partial<EditorSettings>;
const normalized = normalizeEditorSettings(savedSettings);
editorSettings.value = normalized;
const needsExecuteModeDefaultMigration = typeof savedSettings.executeModeDefaultVersion !== "number" || savedSettings.executeModeDefaultVersion < EXECUTE_MODE_CURRENT_DEFAULT_VERSION;
const savedUpdateDownloadSource = (saved as { updateDownloadSource?: unknown }).updateDownloadSource;
if (savedUpdateDownloadSource === "atomgit" || needsExecuteModeDefaultMigration) {
// Persist one-time migrations so removed or unsafe defaults cannot reappear.
await api.saveEditorSettings(normalized).catch(() => {});
}
isEditorSettingsLoaded.value = true;
return;
}
if (!initEditorSettingsPromise) {
initEditorSettingsPromise = (async () => {
// A read failure is not the same as an empty settings record. Keeping the
// store unloaded prevents startup migrations from persisting defaults over
// settings that are temporarily unavailable.
const saved = await api.loadEditorSettings();
if (saved && typeof saved === "object" && !Array.isArray(saved)) {
const savedSettings = saved as Partial<EditorSettings>;
const normalized = normalizeEditorSettings(savedSettings);
editorSettings.value = normalized;
const needsExecuteModeDefaultMigration = typeof savedSettings.executeModeDefaultVersion !== "number" || savedSettings.executeModeDefaultVersion < EXECUTE_MODE_CURRENT_DEFAULT_VERSION;
const savedUpdateDownloadSource = (saved as { updateDownloadSource?: unknown }).updateDownloadSource;
if (savedUpdateDownloadSource === "atomgit" || needsExecuteModeDefaultMigration) {
// Persist one-time migrations so removed or unsafe defaults cannot reappear.
await api.saveEditorSettings(normalized).catch(() => {});
}
completeEditorSettingsInitialization();
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 */
}
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 */
}
}
completeEditorSettingsInitialization();
})().finally(() => {
initEditorSettingsPromise = null;
});
}
isEditorSettingsLoaded.value = true;
await initEditorSettingsPromise;
}
async function initDesktopSettings() {
@@ -1477,7 +1498,7 @@ export const useSettingsStore = defineStore("settings", () => {
return !!config.endpoint && !!activeModel.value!.modelId && (!preset.requiresApiKey || !!config.apiKey);
});
function updateEditorSettings(partial: Partial<EditorSettings>) {
function applyEditorSettingsPatch(partial: Partial<EditorSettings>) {
if (partial.fontFamily !== undefined) editorSettings.value.fontFamily = normalizeFontFamily(partial.fontFamily, DEFAULT_EDITOR_SETTINGS.fontFamily);
if (partial.fontSize !== undefined) editorSettings.value.fontSize = partial.fontSize;
if (partial.uiFontFamily !== undefined) editorSettings.value.uiFontFamily = normalizeFontFamily(partial.uiFontFamily, DEFAULT_EDITOR_SETTINGS.uiFontFamily);
@@ -1605,10 +1626,19 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.continueOnErrorOnBatch !== undefined) editorSettings.value.continueOnErrorOnBatch = partial.continueOnErrorOnBatch === true;
if (partial.clickTableNavigationTarget !== undefined) editorSettings.value.clickTableNavigationTarget = normalizeClickTableNavigationTarget(partial.clickTableNavigationTarget);
if (partial.completionTriggerMode !== undefined) editorSettings.value.completionTriggerMode = normalizeCompletionTriggerMode(partial.completionTriggerMode);
}
function updateEditorSettings(partial: Partial<EditorSettings>) {
applyEditorSettingsPatch(partial);
if (!isEditorSettingsLoaded.value) {
pendingEditorSettingsPatches.push(editorSettingsPatchSnapshot(partial));
return;
}
saveEditorSettings(editorSettings.value);
}
async function persistEditorSettings(): Promise<void> {
await initEditorSettings();
await api.saveEditorSettings(editorSettingsSnapshot(editorSettings.value));
}