fix(settings): cover theme mode and desktop prefs in transfer
This commit is contained in:
1 parent
433cf5e766
commit
9a4d671a6a
4 files changed
+329
-26
No files matched your search
@@ -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<EditorSettings>) {
|
||||
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<TransferableDesktopSettings>) {
|
||||
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<EditorSettings>): SettingsTransferCategoryId[] {
|
||||
function getDraftConflictCategories(imported: Partial<EditorSettings>, extras?: SettingsTransferExtras): SettingsTransferCategoryId[] {
|
||||
const changedKeys = new Set(Object.keys(editorSettingsPatchFromDraft(currentEditorSettingsDraft(), editEditorSettingsBase.value)));
|
||||
const conflicts = new Set<SettingsTransferCategoryId>();
|
||||
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<TransferableDesktopSettings>): 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;</pre>
|
||||
:build-saved-export-payload="buildSettingsExportPayload"
|
||||
:build-applied-export-payload="buildAppliedExportPayload"
|
||||
:apply-imported-settings="applyImportedEditorSettings"
|
||||
:apply-imported-extras="applyImportedExtras"
|
||||
:get-draft-conflict-categories="getDraftConflictCategories"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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<string | null>;
|
||||
applyImportedSettings: (imported: Partial<EditorSettings>) => void;
|
||||
getDraftConflictCategories: (imported: Partial<EditorSettings>) => SettingsTransferCategoryId[];
|
||||
applyImportedExtras: (extras: SettingsTransferExtras) => void;
|
||||
getDraftConflictCategories: (imported: Partial<EditorSettings>, 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"));
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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<string, unknown>, overrides: Record<string, unknown> = {}): string {
|
||||
return JSON.stringify({
|
||||
@@ -13,6 +13,15 @@ function fileWith(editor: Record<string, unknown>, overrides: Record<string, unk
|
||||
});
|
||||
}
|
||||
|
||||
function fileWithSections(sections: Record<string, unknown>): 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");
|
||||
});
|
||||
});
|
||||
@@ -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<DesktopSettings[K], null>;
|
||||
};
|
||||
|
||||
/** 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<TransferableDesktopSettings>;
|
||||
}
|
||||
|
||||
/** Keeps only the whitelisted desktop keys, ignoring everything else. */
|
||||
export function pickTransferableDesktopSettings(settings: Partial<DesktopSettings>): Partial<TransferableDesktopSettings> {
|
||||
const picked: Partial<TransferableDesktopSettings> = {};
|
||||
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<EditorSettings>;
|
||||
/** Validated theme mode from the optional appearance section. */
|
||||
themeMode?: AppThemeMode;
|
||||
/** Validated desktop preferences for exactly the keys present in the file. */
|
||||
desktopSettings: Partial<TransferableDesktopSettings>;
|
||||
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<Record<EditorSettingsDraftKey, (v
|
||||
* nested schema (see NESTED_FIELD_VALIDATORS): malformed nested payloads are
|
||||
* rejected instead of being silently repaired or persisted.
|
||||
*/
|
||||
export function parseSettingsTransferFile(text: string): { ok: true; value: ParsedSettingsTransfer } | { ok: false; error: SettingsTransferParseError } {
|
||||
export function parseSettingsTransferFile(text: string, options: SettingsTransferParseOptions = {}): { ok: true; value: ParsedSettingsTransfer } | { ok: false; error: SettingsTransferParseError } {
|
||||
if (exceedsSettingsTransferSizeLimit(text)) {
|
||||
return { ok: false, error: { code: "too-large" } };
|
||||
}
|
||||
@@ -592,17 +656,62 @@ export function parseSettingsTransferFile(text: string): { ok: true; value: Pars
|
||||
const settings = parsed.settings;
|
||||
if (!isPlainObject(settings)) return { ok: false, error: { code: "invalid-structure" } };
|
||||
const editor = settings.editor;
|
||||
if (!isPlainObject(editor)) return { ok: false, error: { code: "empty-settings" } };
|
||||
if (editor !== undefined && !isPlainObject(editor)) return { ok: false, error: { code: "invalid-structure" } };
|
||||
const appearance = settings.appearance;
|
||||
if (appearance !== undefined && !isPlainObject(appearance)) return { ok: false, error: { code: "invalid-structure" } };
|
||||
const includeDesktop = options.includeDesktopSettings !== false;
|
||||
const desktop = settings.desktop;
|
||||
if (includeDesktop && desktop !== undefined && !isPlainObject(desktop)) return { ok: false, error: { code: "invalid-structure" } };
|
||||
|
||||
// App-wide theme mode from the optional appearance section. Only the three
|
||||
// canonical modes are accepted; legacy soft-* values normalize before they
|
||||
// ever reach a transfer file.
|
||||
let themeMode: AppThemeMode | undefined;
|
||||
if (isPlainObject(appearance) && appearance.themeMode !== undefined) {
|
||||
const value = appearance.themeMode;
|
||||
if (value === "light" || value === "dark" || value === "system") {
|
||||
themeMode = value;
|
||||
} else {
|
||||
return { ok: false, error: { code: "invalid-fields", detail: "appearance.themeMode" } };
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop preferences from the optional desktop section, skipped whole on
|
||||
// platforms where the switches do not exist. Unknown keys are ignored like
|
||||
// in the editor section; the accepted values must survive the store
|
||||
// normalizer unchanged, so a clamped number (duckdb worker processes) or an
|
||||
// out-of-domain enum (icon theme) rejects the whole file instead of being
|
||||
// silently repaired.
|
||||
const desktopKeys = includeDesktop && isPlainObject(desktop) ? TRANSFERABLE_DESKTOP_SETTINGS_KEYS.filter((key) => key in desktop) : [];
|
||||
const importedDesktop: Partial<TransferableDesktopSettings> = {};
|
||||
if (desktopKeys.length > 0 && isPlainObject(desktop)) {
|
||||
const desktopProbe: Record<string, unknown> = { ...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<DesktopSettings>);
|
||||
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<string, unknown>)[key] = editor[key];
|
||||
(probe as unknown as Record<string, unknown>)[key] = editorObject[key];
|
||||
}
|
||||
const normalized = normalizeEditorSettings(probe);
|
||||
|
||||
const imported: Partial<EditorSettings> = {};
|
||||
for (const key of presentKeys) {
|
||||
const raw = editor[key];
|
||||
const raw = editorObject[key];
|
||||
const value = (normalized as unknown as Record<string, unknown>)[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),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user