diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index de45aa25c4..9a99098e58 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -359,7 +359,7 @@ import { type EditorSettingsDraftKey, } from "@/lib/settings/editorSettingsDraft"; import { applyEditorSettingsDraftToRefs, type EditorSettingsDraftRefMap } from "@/lib/settings/applyEditorSettingsDraft"; -import { serializeSettingsTransfer, sortTransferCategories, transferCategoryForKey, type SettingsTransferCategoryId } from "@/lib/settings/settingsTransfer"; +import { pickTransferableDesktopSettings, serializeSettingsTransfer, sortTransferCategories, transferCategoryForKey, type SettingsTransferCategoryId, type SettingsTransferExtras, type TransferableDesktopSettings, type TransferableDesktopSettingsKey } from "@/lib/settings/settingsTransfer"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; @@ -3939,9 +3939,17 @@ async function copyAppSupportInfo() { // --- Settings import / export (About page card) --- function buildSettingsExportPayload(): string { - return serializeSettingsTransfer(settingsStore.editorSettings, { - appVersion: props.appVersion || appSupportInfo.value?.appVersion || undefined, - }); + return serializeSettingsTransfer( + settingsStore.editorSettings, + { appVersion: props.appVersion || appSupportInfo.value?.appVersion || undefined }, + { + themeMode: themeMode.value, + // On web the desktop store only holds defaults (the switches are hidden), + // so exporting them would let a web-made file overwrite a desktop user's + // real tray/close preferences on import. + desktopSettings: isWeb ? undefined : pickTransferableDesktopSettings(settingsStore.desktopSettings), + }, + ); } // "Apply and export": reuse the regular apply flow so validation, persistence @@ -3972,18 +3980,57 @@ function applyImportedEditorSettings(imported: Partial) { hasImportedSettingsPendingApply.value = true; } +// Imported desktop preferences load into the same draft refs the settings +// switches use, so the footer Apply (and its unapplied-changes protection) +// covers them exactly like a manual toggle. The desktop section is skipped on +// web: those switches have no meaning there. +function applyImportedDesktopSettings(desktop: Partial) { + if (isWeb) return; + if (desktop.show_tray_icon !== undefined) editShowTrayIcon.value = desktop.show_tray_icon; + if (desktop.quit_on_close !== undefined) editQuitOnClose.value = desktop.quit_on_close; + if (desktop.icon_theme !== undefined) editIconTheme.value = desktop.icon_theme; + if (desktop.debug_logging_enabled !== undefined) editDebugLoggingEnabled.value = desktop.debug_logging_enabled; + if (desktop.metadata_cache_max_memory_mb !== undefined) editMetadataCacheMaxMemoryMb.value = desktop.metadata_cache_max_memory_mb; + if (desktop.duckdb_worker_process_isolation !== undefined) editDuckDbWorkerProcessIsolation.value = desktop.duckdb_worker_process_isolation; + if (desktop.duckdb_worker_max_processes !== undefined) editDuckDbWorkerMaxProcesses.value = desktop.duckdb_worker_max_processes; + if (desktop.sidebar_table_page_size !== undefined) editSidebarTablePageSize.value = desktop.sidebar_table_page_size; +} + +// The app-wide theme mode has no draft state: it applies immediately through +// the same live path the theme buttons in the appearance tab use. +function applyImportedExtras(extras: SettingsTransferExtras) { + if (extras.themeMode) setThemeMode(extras.themeMode); + const desktopKeys = Object.keys(extras.desktopSettings ?? {}) as TransferableDesktopSettingsKey[]; + if (desktopKeys.length === 0) return; + applyImportedDesktopSettings(extras.desktopSettings ?? {}); + hasImportedSettingsPendingApply.value = true; +} + // Categories whose draft values differ from the saved base AND are covered by // the imported file — these draft changes will be replaced by the import. -function getDraftConflictCategories(imported: Partial): SettingsTransferCategoryId[] { +function getDraftConflictCategories(imported: Partial, extras?: SettingsTransferExtras): SettingsTransferCategoryId[] { const changedKeys = new Set(Object.keys(editorSettingsPatchFromDraft(currentEditorSettingsDraft(), editEditorSettingsBase.value))); const conflicts = new Set(); for (const key of Object.keys(imported)) { const category = transferCategoryForKey(key); if (category && changedKeys.has(key)) conflicts.add(category); } + if (extras?.desktopSettings && !isWeb && importedDesktopKeysTouchDraft(extras.desktopSettings)) conflicts.add("other"); return sortTransferCategories(conflicts); } +function importedDesktopKeysTouchDraft(desktop: Partial): boolean { + if (desktop.show_tray_icon !== undefined && editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon) return true; + if (desktop.quit_on_close !== undefined && editQuitOnClose.value !== settingsStore.desktopSettings.quit_on_close) return true; + if (desktop.icon_theme !== undefined && editIconTheme.value !== settingsStore.desktopSettings.icon_theme) return true; + if (desktop.debug_logging_enabled !== undefined && editDebugLoggingEnabled.value !== settingsStore.desktopSettings.debug_logging_enabled) return true; + if (desktop.metadata_cache_max_memory_mb !== undefined && editMetadataCacheMaxMemoryMb.value !== settingsStore.desktopSettings.metadata_cache_max_memory_mb) return true; + if (desktop.duckdb_worker_process_isolation !== undefined && editDuckDbWorkerProcessIsolation.value !== settingsStore.desktopSettings.duckdb_worker_process_isolation) return true; + if (desktop.duckdb_worker_max_processes !== undefined && editDuckDbWorkerMaxProcesses.value !== settingsStore.desktopSettings.duckdb_worker_max_processes) return true; + if (desktop.sidebar_table_page_size !== undefined && editSidebarTablePageSize.value !== (settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE)) return true; + return false; +} + function clearDebugLogs() { clearStoredDebugLogs(); debugLogCopied.value = false; @@ -13178,6 +13225,7 @@ LIMIT 100; :build-saved-export-payload="buildSettingsExportPayload" :build-applied-export-payload="buildAppliedExportPayload" :apply-imported-settings="applyImportedEditorSettings" + :apply-imported-extras="applyImportedExtras" :get-draft-conflict-categories="getDraftConflictCategories" /> diff --git a/apps/desktop/src/components/settings/SettingsTransferPanel.vue b/apps/desktop/src/components/settings/SettingsTransferPanel.vue index b93ece59ba..6956549ca2 100644 --- a/apps/desktop/src/components/settings/SettingsTransferPanel.vue +++ b/apps/desktop/src/components/settings/SettingsTransferPanel.vue @@ -17,6 +17,7 @@ import { SETTINGS_TRANSFER_FORMAT_VERSION, type ParsedSettingsTransfer, type SettingsTransferCategoryId, + type SettingsTransferExtras, type SettingsTransferParseError, } from "@/lib/settings/settingsTransfer"; @@ -27,7 +28,8 @@ const props = defineProps<{ buildSavedExportPayload: () => string; buildAppliedExportPayload: () => Promise; applyImportedSettings: (imported: Partial) => void; - getDraftConflictCategories: (imported: Partial) => SettingsTransferCategoryId[]; + applyImportedExtras: (extras: SettingsTransferExtras) => void; + getDraftConflictCategories: (imported: Partial, extras?: SettingsTransferExtras) => SettingsTransferCategoryId[]; }>(); const { t } = useI18n(); @@ -195,14 +197,19 @@ async function handleFileInputChange(event: Event) { } function handleImportedText(text: string, fileName: string) { - const result = parseSettingsTransferFile(text); + // The desktop section carries switches that only exist in the Tauri app; + // on web it is skipped so the preview lists only what will be applied. + const result = parseSettingsTransferFile(text, { includeDesktopSettings: isTauriRuntime() }); if (!result.ok) { toast(translateParseError(result.error), 5000); return; } pendingImport.value = result.value; pendingImportFileName.value = fileName; - pendingImportConflicts.value = props.getDraftConflictCategories(result.value.editorSettings); + pendingImportConflicts.value = props.getDraftConflictCategories(result.value.editorSettings, { + themeMode: result.value.themeMode, + desktopSettings: result.value.desktopSettings, + }); showImportConfirmDialog.value = true; } @@ -233,10 +240,13 @@ function confirmImport() { const imported = pendingImport.value; if (!imported) return; props.applyImportedSettings(imported.editorSettings); + props.applyImportedExtras({ themeMode: imported.themeMode, desktopSettings: imported.desktopSettings }); showImportConfirmDialog.value = false; pendingImport.value = null; pendingImportConflicts.value = []; - // The draft is not persisted yet; applying is still the user's job. + // The editor and desktop drafts are not persisted yet; applying is still + // the user's job. The theme mode applies immediately, like the theme + // buttons in the dialog. toast(t("settings.settingsTransferImportLoaded")); } diff --git a/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts b/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts index 1a4ef09fc2..2209060426 100644 --- a/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_EDITOR_SETTINGS, type EditorSettings } from "@/stores/settingsStore"; +import { DEFAULT_DESKTOP_SETTINGS, DEFAULT_EDITOR_SETTINGS, type EditorSettings } from "@/stores/settingsStore"; import { EDITOR_SETTINGS_DRAFT_KEYS } from "../editorSettingsDraft"; -import { buildSettingsTransferFilename, collectTransferCategories, parseSettingsTransferFile, serializeSettingsTransfer, SETTINGS_TRANSFER_FORMAT_VERSION, transferCategoryForKey } from "../settingsTransfer"; +import { buildSettingsTransferFilename, collectTransferCategories, parseSettingsTransferFile, pickTransferableDesktopSettings, serializeSettingsTransfer, SETTINGS_TRANSFER_FORMAT_VERSION, transferCategoryForKey } from "../settingsTransfer"; function fileWith(editor: Record, overrides: Record = {}): string { return JSON.stringify({ @@ -13,6 +13,15 @@ function fileWith(editor: Record, overrides: Record): string { + return JSON.stringify({ + formatVersion: SETTINGS_TRANSFER_FORMAT_VERSION, + app: { name: "dbx", version: "1.2.3" }, + exportedAt: "2026-09-06T00:00:00.000Z", + settings: sections, + }); +} + describe("settingsTransfer", () => { it("round-trips column comment placement as a data preference and rejects non-booleans", () => { const text = serializeSettingsTransfer({ ...DEFAULT_EDITOR_SETTINGS, dataGridColumnCommentsInInfoArea: true, dataGridTransposeMetadataInInfoColumn: true }); @@ -677,4 +686,125 @@ describe("settingsTransfer", () => { if (result.ok) expect(result.value.editorSettings.aiSqlApplyTargetMode).toBe("new"); expect(parseSettingsTransferFile(fileWith({ aiSqlApplyTargetMode: "invalid" })).ok).toBe(false); }); + + it("round-trips the app theme mode and desktop preferences (#13166)", () => { + const desktopSettings = { + show_tray_icon: false, + icon_theme: "black", + quit_on_close: true, + debug_logging_enabled: true, + metadata_cache_max_memory_mb: 128, + duckdb_worker_process_isolation: true, + duckdb_worker_max_processes: 8, + sidebar_table_page_size: 500, + }; + const text = serializeSettingsTransfer(DEFAULT_EDITOR_SETTINGS, {}, { themeMode: "dark", desktopSettings }); + const result = parseSettingsTransferFile(text); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.themeMode).toBe("dark"); + expect(result.value.desktopSettings).toEqual(desktopSettings); + expect(result.value.categories).toContain("appearance"); + expect(result.value.categories).toContain("other"); + }); + + it("imports editor-only exports without theme mode or desktop preferences", () => { + const result = parseSettingsTransferFile(serializeSettingsTransfer(DEFAULT_EDITOR_SETTINGS)); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.themeMode).toBeUndefined(); + expect(result.value.desktopSettings).toEqual({}); + }); + + it("imports a file that only carries the appearance and desktop sections", () => { + const result = parseSettingsTransferFile(fileWithSections({ appearance: { themeMode: "system" }, desktop: { show_tray_icon: false } })); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.editorSettings).toEqual({}); + expect(result.value.themeMode).toBe("system"); + expect(result.value.desktopSettings).toEqual({ show_tray_icon: false }); + expect(result.value.categories).toEqual(["appearance", "other"]); + }); + + it("rejects invalid theme modes", () => { + const invalid = parseSettingsTransferFile(fileWithSections({ appearance: { themeMode: "blue" } })); + expect(invalid.ok).toBe(false); + if (!invalid.ok) { + expect(invalid.error.code).toBe("invalid-fields"); + expect(invalid.error.detail).toContain("appearance.themeMode"); + } + expect(parseSettingsTransferFile(fileWithSections({ appearance: { themeMode: "soft-dark" } })).ok).toBe(false); + }); + + it("rejects wrong-typed desktop values", () => { + const flag = parseSettingsTransferFile(fileWithSections({ desktop: { show_tray_icon: "yes" } })); + expect(flag.ok).toBe(false); + if (!flag.ok) expect(flag.error.detail).toContain("show_tray_icon"); + + const numeric = parseSettingsTransferFile(fileWithSections({ desktop: { metadata_cache_max_memory_mb: "128" } })); + expect(numeric.ok).toBe(false); + }); + + it("rejects desktop values the normalizer would clamp or replace", () => { + const clamped = parseSettingsTransferFile(fileWithSections({ desktop: { duckdb_worker_max_processes: 99 } })); + expect(clamped.ok).toBe(false); + if (!clamped.ok) expect(clamped.error.detail).toContain("duckdb_worker_max_processes"); + + const iconTheme = parseSettingsTransferFile(fileWithSections({ desktop: { icon_theme: "blue" } })); + expect(iconTheme.ok).toBe(false); + + const floored = parseSettingsTransferFile(fileWithSections({ desktop: { sidebar_table_page_size: 0 } })); + expect(floored.ok).toBe(false); + + const belowMinimum = parseSettingsTransferFile(fileWithSections({ desktop: { metadata_cache_max_memory_mb: 5 } })); + expect(belowMinimum.ok).toBe(false); + }); + + it("ignores unknown and excluded keys in the desktop section", () => { + const result = parseSettingsTransferFile(fileWithSections({ desktop: { quit_on_close: true, driver_store_dir: "C:\\evil", close_action_prompted: true, injected: "x" } })); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.desktopSettings).toEqual({ quit_on_close: true }); + }); + + it("keeps device-specific desktop keys out of the serialized payload", () => { + const text = serializeSettingsTransfer( + DEFAULT_EDITOR_SETTINGS, + {}, + { + desktopSettings: pickTransferableDesktopSettings({ ...DEFAULT_DESKTOP_SETTINGS, driver_store_dir: "C:\\drivers", quit_on_close: true }), + }, + ); + expect(text).not.toContain("driver_store_dir"); + expect(text).not.toContain("saved_sql_sync_dir"); + expect(text).not.toContain("close_action_prompted"); + expect(text).toContain("quit_on_close"); + }); + + it("rejects non-object appearance and desktop sections", () => { + expect(parseSettingsTransferFile(fileWithSections({ desktop: [1] })).ok).toBe(false); + expect(parseSettingsTransferFile(fileWithSections({ appearance: "dark" })).ok).toBe(false); + }); + + it("skips the desktop section on web imports but keeps the editor payload", () => { + const result = parseSettingsTransferFile(fileWithSections({ appearance: { themeMode: "dark" }, desktop: { show_tray_icon: false }, editor: { wordWrap: true } }), { includeDesktopSettings: false }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.themeMode).toBe("dark"); + expect(result.value.desktopSettings).toEqual({}); + expect(result.value.editorSettings.wordWrap).toBe(true); + expect(result.value.categories).toContain("appearance"); + expect(result.value.categories).not.toContain("other"); + }); + + it("ignores a malformed desktop section on web imports", () => { + expect(parseSettingsTransferFile(fileWithSections({ desktop: [1], editor: { wordWrap: true } }), { includeDesktopSettings: false }).ok).toBe(true); + expect(parseSettingsTransferFile(fileWithSections({ desktop: { show_tray_icon: "yes" }, editor: { wordWrap: true } }), { includeDesktopSettings: false }).ok).toBe(true); + }); + + it("reports an empty import when a desktop-only file is read on web", () => { + const result = parseSettingsTransferFile(fileWithSections({ desktop: { show_tray_icon: false } }), { includeDesktopSettings: false }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.code).toBe("empty-settings"); + }); }); diff --git a/apps/desktop/src/lib/settings/settingsTransfer.ts b/apps/desktop/src/lib/settings/settingsTransfer.ts index aa1303ee29..145c9d1f6c 100644 --- a/apps/desktop/src/lib/settings/settingsTransfer.ts +++ b/apps/desktop/src/lib/settings/settingsTransfer.ts @@ -1,5 +1,6 @@ import { EDITOR_SETTINGS_DRAFT_KEYS, editorSettingsDraftFromSettings, type EditorSettingsDraftKey } from "./editorSettingsDraft"; -import { DEFAULT_CUSTOM_THEME_COLORS, DEFAULT_CUSTOM_THEME_DDL_COLORS, DEFAULT_EDITOR_SETTINGS, DEFAULT_TOOLBAR_ITEMS, normalizeEditorSettings, type EditorSettings } from "@/stores/settingsStore"; +import { DEFAULT_CUSTOM_THEME_COLORS, DEFAULT_CUSTOM_THEME_DDL_COLORS, DEFAULT_DESKTOP_SETTINGS, DEFAULT_EDITOR_SETTINGS, DEFAULT_TOOLBAR_ITEMS, normalizeDesktopSettings, normalizeEditorSettings, type DesktopSettings, type EditorSettings } from "@/stores/settingsStore"; +import type { AppThemeMode } from "@/lib/app/appTheme"; import { EDITOR_MAX_FONT_SIZE, EDITOR_MIN_FONT_SIZE } from "@/lib/editor/editorZoom"; import { DATA_GRID_TYPE_COLOR_KEYS, DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID } from "@/lib/dataGrid/dataGridTypeColorScheme"; import { SHORTCUT_DEFINITIONS } from "@/lib/editor/shortcutRegistry"; @@ -10,10 +11,12 @@ import { SQL_VARIABLE_SYNTAX_KEYS } from "@/lib/sql/sqlVariableSyntax"; * Local backup / restore of application settings ("配置导入与导出"). * * The transfer file is a versioned JSON document with a strict field - * whitelist: only dialog-managed editor settings are serialized. Connection - * data, saved SQL libraries, AI configuration and device-specific paths are - * never part of the payload, and unknown fields in an imported file are - * ignored instead of being written into the draft. + * whitelist. The `settings.editor` section carries the dialog-managed editor + * settings; the optional `settings.appearance` and `settings.desktop` + * sections carry the app-wide theme mode and the preference-like desktop + * settings. Connection data, saved SQL libraries, AI configuration and + * device-specific paths are never part of the payload, and unknown fields in + * an imported file are ignored instead of being written anywhere. */ export const SETTINGS_TRANSFER_FORMAT_VERSION = 1; @@ -21,6 +24,48 @@ export const SETTINGS_TRANSFER_FORMAT_VERSION = 1; /** Upper bound for an import file; real settings files are a few dozen KB. */ export const MAX_SETTINGS_TRANSFER_FILE_BYTES = 5 * 1024 * 1024; +/** + * Preference-like `DesktopSettings` keys eligible for transfer. Device- + * specific paths (driver/plugin/agent/skill directories, saved-SQL sync dir) + * and the one-shot `close_action_prompted` flag are deliberately excluded: + * they are either meaningless on another machine or not a user preference. + */ +export const TRANSFERABLE_DESKTOP_SETTINGS_KEYS = [ + "show_tray_icon", + "icon_theme", + "quit_on_close", + "debug_logging_enabled", + "metadata_cache_max_memory_mb", + "duckdb_worker_process_isolation", + "duckdb_worker_max_processes", + "sidebar_table_page_size", +] as const satisfies readonly (keyof DesktopSettings)[]; + +export type TransferableDesktopSettingsKey = (typeof TRANSFERABLE_DESKTOP_SETTINGS_KEYS)[number]; + +/** The transferable keys without their `null` "unset" variants. */ +export type TransferableDesktopSettings = { + [K in TransferableDesktopSettingsKey]: Exclude; +}; + +/** Non-editor sections carried alongside the editor draft in the transfer file. */ +export interface SettingsTransferExtras { + /** App-wide light/dark/system mode from `settings.appearance`. */ + themeMode?: AppThemeMode; + /** Preference-like desktop settings from `settings.desktop`. */ + desktopSettings?: Partial; +} + +/** Keeps only the whitelisted desktop keys, ignoring everything else. */ +export function pickTransferableDesktopSettings(settings: Partial): Partial { + const picked: Partial = {}; + for (const key of TRANSFERABLE_DESKTOP_SETTINGS_KEYS) { + const value = settings[key]; + if (value !== undefined && value !== null) picked[key] = value as never; + } + return picked; +} + export type SettingsTransferCategoryId = "appearance" | "editor" | "formatter" | "navigation" | "data" | "shortcuts" | "snippets" | "other"; export type SettingsTransferParseErrorCode = "too-large" | "invalid-json" | "unsupported-version" | "invalid-structure" | "empty-settings" | "invalid-fields"; @@ -37,9 +82,23 @@ export interface ParsedSettingsTransfer { exportedAt?: string; /** Validated, normalized values for exactly the keys present in the file. */ editorSettings: Partial; + /** Validated theme mode from the optional appearance section. */ + themeMode?: AppThemeMode; + /** Validated desktop preferences for exactly the keys present in the file. */ + desktopSettings: Partial; categories: SettingsTransferCategoryId[]; } +export interface SettingsTransferParseOptions { + /** + * Whether the platform-specific `settings.desktop` section applies. Web + * callers pass false: those switches are desktop-only, so the section is + * skipped the same way an older reader skips an unknown section — its + * values are neither validated nor listed among the importable categories. + */ + includeDesktopSettings?: boolean; +} + export interface SettingsTransferExportMeta { appVersion?: string; } @@ -55,14 +114,19 @@ export function buildSettingsTransferFilename(date = new Date()): string { * Serializes the saved editor settings into the transfer format. The draft * helper provides the whitelist, deep cloning and per-key normalization, so * the payload never contains fields outside `EDITOR_SETTINGS_DRAFT_KEYS`. + * The optional extras add the `appearance`/`desktop` sections; files written + * by older versions simply lack them. */ -export function serializeSettingsTransfer(settings: EditorSettings, meta: SettingsTransferExportMeta = {}): string { +export function serializeSettingsTransfer(settings: EditorSettings, meta: SettingsTransferExportMeta = {}, extras: SettingsTransferExtras = {}): string { + const desktopSettings = extras.desktopSettings ? pickTransferableDesktopSettings(extras.desktopSettings) : {}; const payload = { formatVersion: SETTINGS_TRANSFER_FORMAT_VERSION, app: { name: "dbx", ...(meta.appVersion ? { version: meta.appVersion } : {}) }, exportedAt: new Date().toISOString(), settings: { editor: editorSettingsDraftFromSettings(settings), + ...(extras.themeMode ? { appearance: { themeMode: extras.themeMode } } : {}), + ...(Object.keys(desktopSettings).length > 0 ? { desktop: desktopSettings } : {}), }, }; return JSON.stringify(payload, null, 2); @@ -571,7 +635,7 @@ const RAW_STRUCTURED_FIELD_VALIDATORS: Partial key in desktop) : []; + const importedDesktop: Partial = {}; + if (desktopKeys.length > 0 && isPlainObject(desktop)) { + const desktopProbe: Record = { ...DEFAULT_DESKTOP_SETTINGS }; + for (const key of desktopKeys) { + const raw = desktop[key]; + if (typeof raw !== typeof DEFAULT_DESKTOP_SETTINGS[key]) { + return { ok: false, error: { code: "invalid-fields", detail: `desktop.${key}` } }; + } + desktopProbe[key] = raw; + } + const normalizedDesktop = normalizeDesktopSettings(desktopProbe as Partial); + for (const key of desktopKeys) { + if (normalizedDesktop[key] !== desktop[key]) { + return { ok: false, error: { code: "invalid-fields", detail: `desktop.${key}` } }; + } + importedDesktop[key] = normalizedDesktop[key] as never; + } + } // Old exports used the inverse `dataGridAutoHideFilterBuilder` flag. Keep // accepting it, but normalize it into the single current preference before // applying the strict whitelist below. - if (!("dataGridKeepFilterEditorExpanded" in editor) && typeof editor.dataGridAutoHideFilterBuilder === "boolean") { + if (isPlainObject(editor) && !("dataGridKeepFilterEditorExpanded" in editor) && typeof editor.dataGridAutoHideFilterBuilder === "boolean") { editor.dataGridKeepFilterEditorExpanded = !editor.dataGridAutoHideFilterBuilder; } - const presentKeys = EDITOR_SETTINGS_DRAFT_KEYS.filter((key) => key in editor); - if (presentKeys.length === 0) return { ok: false, error: { code: "empty-settings" } }; + const editorObject = isPlainObject(editor) ? editor : {}; + const presentKeys = EDITOR_SETTINGS_DRAFT_KEYS.filter((key) => key in editorObject); + if (presentKeys.length === 0 && desktopKeys.length === 0 && themeMode === undefined) return { ok: false, error: { code: "empty-settings" } }; // Validate the raw file values BEFORE the normalizer runs. Some store // normalizers assume well-formed containers (e.g. customThemes items must @@ -611,7 +720,7 @@ export function parseSettingsTransferFile(text: string): { ok: true; value: Pars // rather than crashing the parse. const invalidKeys: string[] = []; for (const key of presentKeys) { - const raw = editor[key]; + const raw = editorObject[key]; if (jsonKindOf(raw) !== EXPECTED_JSON_KINDS.get(key)) { invalidKeys.push(key); continue; @@ -630,13 +739,13 @@ export function parseSettingsTransferFile(text: string): { ok: true; value: Pars // mutated by a malformed file. const probe = deepClone(DEFAULT_EDITOR_SETTINGS) as EditorSettings; for (const key of presentKeys) { - (probe as unknown as Record)[key] = editor[key]; + (probe as unknown as Record)[key] = editorObject[key]; } const normalized = normalizeEditorSettings(probe); const imported: Partial = {}; for (const key of presentKeys) { - const raw = editor[key]; + const raw = editorObject[key]; const value = (normalized as unknown as Record)[key]; const validator = PASS_THROUGH_FIELD_VALIDATORS[key] ?? NESTED_FIELD_VALIDATORS[key]; if (validator && !validator(value)) { @@ -673,6 +782,10 @@ export function parseSettingsTransferFile(text: string): { ok: true; value: Pars const rawAppVersion = typeof app.version === "string" ? app.version.trim() : ""; const rawExportedAt = typeof parsed.exportedAt === "string" ? parsed.exportedAt : ""; + const categories = new Set(collectTransferCategories(Object.keys(imported))); + if (themeMode !== undefined) categories.add("appearance"); + if (desktopKeys.length > 0) categories.add("other"); + return { ok: true, value: { @@ -680,7 +793,9 @@ export function parseSettingsTransferFile(text: string): { ok: true; value: Pars ...(rawAppVersion ? { appVersion: rawAppVersion } : {}), ...(rawExportedAt ? { exportedAt: rawExportedAt } : {}), editorSettings: imported, - categories: collectTransferCategories(Object.keys(imported)), + ...(themeMode !== undefined ? { themeMode } : {}), + desktopSettings: importedDesktop, + categories: sortTransferCategories(categories), }, }; }