feat(settings): settings import/export and WebView2 clipboard history
This commit is contained in:
1 parent
d44798f70f
commit
7961e688f2
19 files changed
+2316
-216
No files matched your search
@@ -96,6 +96,7 @@ import {
|
||||
type McpConnectionPolicy,
|
||||
type McpGroupPolicy,
|
||||
type ClickTableNavigationTarget,
|
||||
type EditorSettings,
|
||||
type SqlCompletionTriggerMode,
|
||||
SIDEBAR_INDENT_MIN,
|
||||
SIDEBAR_INDENT_MAX,
|
||||
@@ -186,13 +187,25 @@ import { DEFAULT_SQL_SNIPPETS } from "@/lib/sql/sqlCompletion";
|
||||
import AiProviderLogo from "@/components/icons/AiProviderLogo.vue";
|
||||
import AppLogo from "@/components/icons/AppLogo.vue";
|
||||
import ChangelogPanel from "@/components/settings/ChangelogPanel.vue";
|
||||
import SettingsTransferPanel from "@/components/settings/SettingsTransferPanel.vue";
|
||||
import McpResourceScopePicker from "@/components/settings/McpResourceScopePicker.vue";
|
||||
import McpDatabaseScopePicker from "@/components/settings/McpDatabaseScopePicker.vue";
|
||||
import McpAuthorizationStepper from "@/components/settings/McpAuthorizationStepper.vue";
|
||||
import ScheduledDatabaseBackupSettings from "@/components/backup/ScheduledDatabaseBackupSettings.vue";
|
||||
import SqlFormatterSettingsPanel from "./SqlFormatterSettingsPanel.vue";
|
||||
import { APP_CUSTOM_UI_COLOR_DEFS, APP_THEME_PALETTES, type AppCornerStyle, type AppCustomUiColors, type AppThemeAppearance, type AppThemeMode, type AppThemePalette } from "@/lib/app/appTheme";
|
||||
import { editorSettingsDraftChanged, editorSettingsDraftFromSettings, editorSettingsPatchFromDraft, normalizeQueryResultMaxRowsDraft, normalizeTableOpenPageSizeDraft, shouldConfirmEditorSettingsDialogClose, type EditorSettingsDraft } from "@/lib/settings/editorSettingsDraft";
|
||||
import {
|
||||
editorSettingsDraftChanged,
|
||||
editorSettingsDraftFromSettings,
|
||||
editorSettingsDraftPatchFromSettings,
|
||||
editorSettingsPatchFromDraft,
|
||||
normalizeQueryResultMaxRowsDraft,
|
||||
normalizeTableOpenPageSizeDraft,
|
||||
shouldConfirmEditorSettingsDialogClose,
|
||||
type EditorSettingsDraft,
|
||||
type EditorSettingsDraftKey,
|
||||
} from "@/lib/settings/editorSettingsDraft";
|
||||
import { serializeSettingsTransfer, sortTransferCategories, transferCategoryForKey, type SettingsTransferCategoryId } from "@/lib/settings/settingsTransfer";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
|
||||
@@ -1022,6 +1035,15 @@ async function loadSystemFontOptions() {
|
||||
}
|
||||
}
|
||||
|
||||
// An import always leaves the loaded draft pending an explicit Apply, even
|
||||
// when every imported value happens to equal the saved settings — without this
|
||||
// the footer Apply / Apply & Close buttons stay disabled right after "载入设置"
|
||||
// and the "请应用以保存" toast points at a disabled button.
|
||||
// Declared before syncEditorSettingsDraftFromStore because the immediate
|
||||
// settingsVisible watch calls it during setup (a later declaration would be
|
||||
// hit in its temporal dead zone and crash the settings page on open).
|
||||
const hasImportedSettingsPendingApply = ref(false);
|
||||
|
||||
function syncEditorSettingsDraftFromStore() {
|
||||
editFontFamily.value = settingsStore.editorSettings.fontFamily;
|
||||
editFontSize.value = settingsStore.editorSettings.fontSize;
|
||||
@@ -1129,6 +1151,336 @@ function syncEditorSettingsDraftFromStore() {
|
||||
editSqlVariableSyntaxOverrides.value = normalizeSqlVariableSyntaxOverrides(settingsStore.editorSettings.sqlVariableSyntaxOverrides);
|
||||
editClickTableNavigationTarget.value = settingsStore.editorSettings.clickTableNavigationTarget;
|
||||
editEditorSettingsBase.value = editorSettingsDraftFromSettings(settingsStore.editorSettings);
|
||||
hasImportedSettingsPendingApply.value = false;
|
||||
}
|
||||
|
||||
// Mirror of syncEditorSettingsDraftFromStore for loading draft values into
|
||||
// the edit refs. Draft values are already settings-shaped and normalized, so
|
||||
// this only performs the ref-specific representations (joined textarea
|
||||
// strings, grid rows, editable snippet copies). Accepts a key subset so a
|
||||
// partial update (settings import) writes exactly the listed refs and leaves
|
||||
// every other in-progress edit untouched. The base snapshot is intentionally
|
||||
// left untouched: changed values stay unapplied until the user clicks Apply,
|
||||
// exactly like hand-edited draft values.
|
||||
function applyEditorSettingsKeysToRefs(draft: EditorSettingsDraft, keys: readonly EditorSettingsDraftKey[]) {
|
||||
for (const key of keys) {
|
||||
switch (key) {
|
||||
case "fontFamily":
|
||||
editFontFamily.value = draft.fontFamily;
|
||||
break;
|
||||
case "fontSize":
|
||||
editFontSize.value = draft.fontSize;
|
||||
break;
|
||||
case "tableFontFamily":
|
||||
editTableFontFamily.value = draft.tableFontFamily;
|
||||
break;
|
||||
case "uiFontFamily":
|
||||
editUiFontFamily.value = draft.uiFontFamily;
|
||||
break;
|
||||
case "uiScale":
|
||||
editUiScale.value = draft.uiScale;
|
||||
break;
|
||||
case "theme":
|
||||
editTheme.value = draft.theme;
|
||||
break;
|
||||
case "customThemes":
|
||||
editCustomThemes.value = [...draft.customThemes];
|
||||
break;
|
||||
case "activeCustomThemeId":
|
||||
editActiveCustomThemeId.value = draft.activeCustomThemeId;
|
||||
break;
|
||||
case "executeMode":
|
||||
editExecuteMode.value = draft.executeMode;
|
||||
break;
|
||||
case "executeAllOnBlankLine":
|
||||
editExecuteAllOnBlankLine.value = draft.executeAllOnBlankLine;
|
||||
break;
|
||||
case "showExecutionTargetPicker":
|
||||
editShowExecutionTargetPicker.value = draft.showExecutionTargetPicker;
|
||||
break;
|
||||
case "showStatementRunButtons":
|
||||
editShowStatementRunButtons.value = draft.showStatementRunButtons;
|
||||
break;
|
||||
case "showLineNumbers":
|
||||
editShowLineNumbers.value = draft.showLineNumbers;
|
||||
break;
|
||||
case "showCurrentStatementFrame":
|
||||
editShowCurrentStatementFrame.value = draft.showCurrentStatementFrame;
|
||||
break;
|
||||
case "showInsertValueHints":
|
||||
editShowInsertValueHints.value = draft.showInsertValueHints;
|
||||
break;
|
||||
case "autoAliasTables":
|
||||
editAutoAliasTables.value = draft.autoAliasTables;
|
||||
break;
|
||||
case "insertSpaceAfterCompletion":
|
||||
editInsertSpaceAfterCompletion.value = draft.insertSpaceAfterCompletion;
|
||||
break;
|
||||
case "sortCompletionColumnsAlphabetically":
|
||||
editSortCompletionColumnsAlphabetically.value = draft.sortCompletionColumnsAlphabetically;
|
||||
break;
|
||||
case "selectFirstCompletionOnOpen":
|
||||
editSelectFirstCompletionOnOpen.value = draft.selectFirstCompletionOnOpen;
|
||||
break;
|
||||
case "wordWrap":
|
||||
editWordWrap.value = draft.wordWrap;
|
||||
break;
|
||||
case "vimModeEnabled":
|
||||
editVimModeEnabled.value = draft.vimModeEnabled;
|
||||
break;
|
||||
case "autoCloseBrackets":
|
||||
editAutoCloseBrackets.value = draft.autoCloseBrackets;
|
||||
break;
|
||||
case "sqlSemanticDiagnosticsMode":
|
||||
editSqlSemanticDiagnosticsMode.value = draft.sqlSemanticDiagnosticsMode;
|
||||
editSqlSemanticDiagnosticsEnabled.value = draft.sqlSemanticDiagnosticsMode !== "disabled";
|
||||
break;
|
||||
case "confirmDangerousSqlExecution":
|
||||
editConfirmDangerousSqlExecution.value = draft.confirmDangerousSqlExecution;
|
||||
break;
|
||||
case "confirmUnsavedSqlClose":
|
||||
editConfirmUnsavedSqlClose.value = draft.confirmUnsavedSqlClose;
|
||||
break;
|
||||
case "appCloseUnsavedTabsMode":
|
||||
editAppCloseUnsavedTabsMode.value = draft.appCloseUnsavedTabsMode;
|
||||
break;
|
||||
case "savedSqlOpenTargetMode":
|
||||
editSavedSqlOpenTargetMode.value = draft.savedSqlOpenTargetMode;
|
||||
break;
|
||||
case "appLayout":
|
||||
editAppLayout.value = draft.appLayout;
|
||||
break;
|
||||
case "tabLayout":
|
||||
editTabLayout.value = draft.tabLayout;
|
||||
break;
|
||||
case "tabPlacement":
|
||||
editTabPlacement.value = draft.tabPlacement;
|
||||
break;
|
||||
case "tabGroupMode":
|
||||
editTabGroupMode.value = draft.tabGroupMode;
|
||||
break;
|
||||
case "tabSortMode":
|
||||
editTabSortMode.value = draft.tabSortMode;
|
||||
break;
|
||||
case "showColumnCommentsInHeader":
|
||||
editShowColumnCommentsInHeader.value = draft.showColumnCommentsInHeader;
|
||||
break;
|
||||
case "showColumnTypesInHeader":
|
||||
editShowColumnTypesInHeader.value = draft.showColumnTypesInHeader;
|
||||
break;
|
||||
case "dataGridShowTransposeFieldMetadata":
|
||||
editDataGridShowTransposeFieldMetadata.value = draft.dataGridShowTransposeFieldMetadata;
|
||||
break;
|
||||
case "colorizeDataGridCellTypes":
|
||||
editColorizeDataGridCellTypes.value = draft.colorizeDataGridCellTypes;
|
||||
break;
|
||||
case "dataGridTypeColorSchemes":
|
||||
editDataGridTypeColorSchemes.value = cloneDataGridTypeColorSchemes(draft.dataGridTypeColorSchemes);
|
||||
break;
|
||||
case "activeDataGridTypeColorSchemeId":
|
||||
editActiveDataGridTypeColorSchemeId.value = draft.activeDataGridTypeColorSchemeId;
|
||||
break;
|
||||
case "showIndexIndicatorsInHeader":
|
||||
editShowIndexIndicatorsInHeader.value = draft.showIndexIndicatorsInHeader;
|
||||
break;
|
||||
case "compactColumnHeaderActions":
|
||||
editCompactColumnHeaderActions.value = draft.compactColumnHeaderActions;
|
||||
break;
|
||||
case "dataGridQuickEntry":
|
||||
editDataGridQuickEntry.value = draft.dataGridQuickEntry;
|
||||
break;
|
||||
case "dataGridFilterEditorView":
|
||||
editDataGridFilterEditorView.value = draft.dataGridFilterEditorView;
|
||||
break;
|
||||
case "dataGridAutoHideFilterBuilder":
|
||||
editDataGridAutoHideFilterBuilder.value = draft.dataGridAutoHideFilterBuilder;
|
||||
break;
|
||||
case "dataGridTextFilterPanelHeight":
|
||||
editDataGridTextFilterPanelHeight.value = draft.dataGridTextFilterPanelHeight;
|
||||
break;
|
||||
case "multiStatementDefaultView":
|
||||
editMultiStatementDefaultView.value = draft.multiStatementDefaultView;
|
||||
break;
|
||||
case "dataGridAutoTransposeSingleRow":
|
||||
editDataGridAutoTransposeSingleRow.value = draft.dataGridAutoTransposeSingleRow;
|
||||
break;
|
||||
case "dataGridCellDetailButtonVisible":
|
||||
editDataGridCellDetailButtonVisible.value = draft.dataGridCellDetailButtonVisible;
|
||||
break;
|
||||
case "dataGridCrosshairHighlight":
|
||||
editDataGridCrosshairHighlight.value = draft.dataGridCrosshairHighlight;
|
||||
break;
|
||||
case "pageSize":
|
||||
editPageSize.value = draft.pageSize;
|
||||
break;
|
||||
case "tableOpenPageSize":
|
||||
editTableOpenPageSize.value = draft.tableOpenPageSize;
|
||||
break;
|
||||
case "queryResultMaxRowsEnabled":
|
||||
editQueryResultMaxRowsEnabled.value = draft.queryResultMaxRowsEnabled;
|
||||
break;
|
||||
case "queryResultMaxRows":
|
||||
editQueryResultMaxRows.value = draft.queryResultMaxRows;
|
||||
break;
|
||||
case "infiniteScroll":
|
||||
editInfiniteScroll.value = draft.infiniteScroll;
|
||||
break;
|
||||
case "regexMaxMatchCount":
|
||||
editRegexMaxMatchCount.value = draft.regexMaxMatchCount;
|
||||
break;
|
||||
case "autoCalculateTotalRows":
|
||||
editAutoCalculateTotalRows.value = draft.autoCalculateTotalRows;
|
||||
break;
|
||||
case "flatteningMultiLineText":
|
||||
editFlatteningMultiLineText.value = draft.flatteningMultiLineText;
|
||||
break;
|
||||
case "tableColumnTemplateFields":
|
||||
editTableColumnTemplateRows.value = tableColumnTemplateRowsFromSettings(draft.tableColumnTemplateFields);
|
||||
break;
|
||||
case "shortcuts":
|
||||
editShortcuts.value = normalizeShortcutSettings(draft.shortcuts);
|
||||
break;
|
||||
case "sqlFormatter":
|
||||
editSqlFormatter.value = normalizeSqlFormatterSettings(draft.sqlFormatter);
|
||||
sqlFormatterConfigValid.value = true;
|
||||
break;
|
||||
case "sidebarActivation":
|
||||
editSidebarActivation.value = draft.sidebarActivation;
|
||||
break;
|
||||
case "sidebarObjectDisplay":
|
||||
editSidebarObjectDisplay.value = draft.sidebarObjectDisplay;
|
||||
break;
|
||||
case "routineSourceOpenMode":
|
||||
editRoutineSourceOpenMode.value = draft.routineSourceOpenMode;
|
||||
break;
|
||||
case "sidebarTableSearchEnabled":
|
||||
editSidebarTableSearchEnabled.value = draft.sidebarTableSearchEnabled;
|
||||
break;
|
||||
case "autoSelectActiveSidebarNode":
|
||||
editAutoSelectActiveSidebarNode.value = draft.autoSelectActiveSidebarNode;
|
||||
break;
|
||||
case "sidebarBrowseObjectsOnDatabaseActivation":
|
||||
editSidebarBrowseObjectsOnDatabaseActivation.value = draft.sidebarBrowseObjectsOnDatabaseActivation;
|
||||
break;
|
||||
case "openTabsRestoreMode":
|
||||
editOpenTabsRestoreMode.value = draft.openTabsRestoreMode;
|
||||
break;
|
||||
case "disconnectTabHandlingMode":
|
||||
editDisconnectTabHandlingMode.value = draft.disconnectTabHandlingMode;
|
||||
break;
|
||||
case "dataTabReuseMode":
|
||||
editDataTabReuseMode.value = draft.dataTabReuseMode;
|
||||
break;
|
||||
case "openDataTabsNextToActive":
|
||||
editOpenDataTabsNextToActive.value = draft.openDataTabsNextToActive;
|
||||
break;
|
||||
case "prefillNewQueryWithSelect":
|
||||
editPrefillNewQueryWithSelect.value = draft.prefillNewQueryWithSelect;
|
||||
break;
|
||||
case "generateSqlIncludeDatabaseName":
|
||||
editGenerateSqlIncludeDatabaseName.value = draft.generateSqlIncludeDatabaseName;
|
||||
break;
|
||||
case "generateSqlQuoteIdentifiers":
|
||||
editGenerateSqlQuoteIdentifiers.value = draft.generateSqlQuoteIdentifiers;
|
||||
break;
|
||||
case "formatSqlOnSqlFileSave":
|
||||
editFormatSqlOnSqlFileSave.value = draft.formatSqlOnSqlFileSave;
|
||||
break;
|
||||
case "showTableDdlHoverPreview":
|
||||
editShowTableDdlHoverPreview.value = draft.showTableDdlHoverPreview;
|
||||
break;
|
||||
case "updateNotificationsEnabled":
|
||||
editUpdateNotificationsEnabled.value = draft.updateNotificationsEnabled;
|
||||
break;
|
||||
case "sidebarObjectInfoMode":
|
||||
editSidebarObjectInfoMode.value = draft.sidebarObjectInfoMode;
|
||||
break;
|
||||
case "sidebarAllowHorizontalScroll":
|
||||
editSidebarAllowHorizontalScroll.value = draft.sidebarAllowHorizontalScroll;
|
||||
break;
|
||||
case "sidebarShowTooltips":
|
||||
editSidebarShowTooltips.value = draft.sidebarShowTooltips;
|
||||
break;
|
||||
case "sidebarIndent":
|
||||
editSidebarIndent.value = draft.sidebarIndent;
|
||||
break;
|
||||
case "sidebarFontSize":
|
||||
editSidebarFontSize.value = draft.sidebarFontSize;
|
||||
break;
|
||||
case "sidebarHiddenTablePrefixes":
|
||||
editSidebarHiddenTablePrefixes.value = draft.sidebarHiddenTablePrefixes.join("\n");
|
||||
break;
|
||||
case "sidebarCopyTableNameSeparator":
|
||||
editSidebarCopyTableNameSeparator.value = draft.sidebarCopyTableNameSeparator;
|
||||
break;
|
||||
case "sidebarCopyTableNameIncludeSchema":
|
||||
editSidebarCopyTableNameIncludeSchema.value = draft.sidebarCopyTableNameIncludeSchema;
|
||||
break;
|
||||
case "redisKeyTemplates":
|
||||
editRedisKeyTemplates.value = normalizeRedisKeyTemplates(draft.redisKeyTemplates).join("\n");
|
||||
break;
|
||||
case "exportBatchSize":
|
||||
editExportBatchSize.value = draft.exportBatchSize;
|
||||
break;
|
||||
case "csvQuoteMode":
|
||||
editCsvQuoteMode.value = draft.csvQuoteMode;
|
||||
break;
|
||||
case "exportRowLimitEnabled":
|
||||
editExportRowLimitEnabled.value = draft.exportRowLimitEnabled;
|
||||
break;
|
||||
case "exportRowLimit":
|
||||
editExportRowLimit.value = draft.exportRowLimit;
|
||||
break;
|
||||
case "queryExportKeysetOptimizationEnabled":
|
||||
editQueryExportKeysetOptimizationEnabled.value = draft.queryExportKeysetOptimizationEnabled;
|
||||
break;
|
||||
case "globalDateTimeDisplayFormat":
|
||||
editGlobalDateTimeDisplayFormat.value = draft.globalDateTimeDisplayFormat;
|
||||
break;
|
||||
case "globalDateTimeExportFormat":
|
||||
editGlobalDateTimeExportFormat.value = draft.globalDateTimeExportFormat;
|
||||
break;
|
||||
case "globalDateTimeImportFormat":
|
||||
editGlobalDateTimeImportFormat.value = draft.globalDateTimeImportFormat;
|
||||
break;
|
||||
case "updateDownloadSource":
|
||||
editUpdateDownloadSource.value = draft.updateDownloadSource;
|
||||
break;
|
||||
case "toolbarItems":
|
||||
editToolbarItems.value = { ...draft.toolbarItems };
|
||||
break;
|
||||
case "snippets":
|
||||
editSnippets.value = draft.snippets.map(editableSnippet);
|
||||
break;
|
||||
case "sqlShortcuts":
|
||||
editSqlShortcuts.value = draft.sqlShortcuts.map(editableSqlShortcut);
|
||||
break;
|
||||
case "sqlVariableSubstitutionEnabled":
|
||||
editSqlVariableSubstitutionEnabled.value = draft.sqlVariableSubstitutionEnabled;
|
||||
break;
|
||||
case "sqlVariableSyntaxOverrides":
|
||||
editSqlVariableSyntaxOverrides.value = normalizeSqlVariableSyntaxOverrides(draft.sqlVariableSyntaxOverrides);
|
||||
break;
|
||||
case "continueOnErrorOnBatch":
|
||||
editContinueOnErrorOnBatch.value = draft.continueOnErrorOnBatch;
|
||||
break;
|
||||
case "clickTableNavigationTarget":
|
||||
editClickTableNavigationTarget.value = draft.clickTableNavigationTarget;
|
||||
break;
|
||||
case "completionTriggerMode":
|
||||
editCompletionTriggerMode.value = draft.completionTriggerMode;
|
||||
break;
|
||||
case "defaultTransactionMode":
|
||||
editDefaultTransactionMode.value = draft.defaultTransactionMode;
|
||||
break;
|
||||
default: {
|
||||
// Compile-time exhaustiveness over EDITOR_SETTINGS_DRAFT_KEYS: adding a
|
||||
// draft key without a ref assignment fails the build here.
|
||||
const unhandled: never = key;
|
||||
void unhandled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync from store when dialog opens
|
||||
@@ -1217,6 +1569,7 @@ const hasApplyBlocker = computed(() => hasBlockingShortcutConflicts.value || has
|
||||
|
||||
function hasChanges(): boolean {
|
||||
return (
|
||||
hasImportedSettingsPendingApply.value ||
|
||||
hasEditorDraftChanges.value ||
|
||||
editShowTrayIcon.value !== settingsStore.desktopSettings.show_tray_icon ||
|
||||
editQuitOnClose.value !== settingsStore.desktopSettings.quit_on_close ||
|
||||
@@ -1253,6 +1606,7 @@ async function persistSettings() {
|
||||
});
|
||||
editMetadataCacheMaxMemoryMb.value = settingsStore.desktopSettings.metadata_cache_max_memory_mb;
|
||||
desktopCloseBehaviorResetPending.value = false;
|
||||
hasImportedSettingsPendingApply.value = false;
|
||||
if (sidebarObjectDisplayChanged) {
|
||||
await connectionStore.refreshAllTree();
|
||||
} else if (sidebarTablePageSizeChanged) {
|
||||
@@ -2112,6 +2466,54 @@ async function copyAppSupportInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Settings import / export (About page card) ---
|
||||
|
||||
function buildSettingsExportPayload(): string {
|
||||
return serializeSettingsTransfer(settingsStore.editorSettings, {
|
||||
appVersion: props.appVersion || appSupportInfo.value?.appVersion || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// "Apply and export": reuse the regular apply flow so validation, persistence
|
||||
// and error toasts behave exactly like the footer Apply button. Returning null
|
||||
// aborts the export while keeping the draft editable.
|
||||
async function buildAppliedExportPayload(): Promise<string | null> {
|
||||
// persistSettings() silently no-ops while a blocker (shortcut conflicts,
|
||||
// invalid formatter config, row-limit conflict) is active — the footer Apply
|
||||
// button is disabled in that state. Abort the export instead of writing a
|
||||
// file that pretends the draft was applied.
|
||||
if (hasApplyBlocker.value) {
|
||||
toast(t("settings.settingsTransferApplyBlocked"), 5000);
|
||||
return null;
|
||||
}
|
||||
if (!(await applySettingsForResult())) return null;
|
||||
return buildSettingsExportPayload();
|
||||
}
|
||||
|
||||
// Load validated imported values into the draft. Only the keys the file
|
||||
// actually contains are written: in-progress edits the file does not cover —
|
||||
// including state that a draft round-trip would drop, like a half-filled
|
||||
// table-column template row that has no field name yet — keep their exact
|
||||
// draft state. The base snapshot is untouched so everything stays unapplied
|
||||
// until the user clicks Apply (or discards via the close flow).
|
||||
function applyImportedEditorSettings(imported: Partial<EditorSettings>) {
|
||||
const patch = editorSettingsDraftPatchFromSettings(imported);
|
||||
applyEditorSettingsKeysToRefs(patch as EditorSettingsDraft, Object.keys(patch) as EditorSettingsDraftKey[]);
|
||||
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[] {
|
||||
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);
|
||||
}
|
||||
return sortTransferCategories(conflicts);
|
||||
}
|
||||
|
||||
function clearDebugLogs() {
|
||||
clearStoredDebugLogs();
|
||||
debugLogCopied.value = false;
|
||||
@@ -8652,6 +9054,14 @@ LIMIT 100;</pre
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsTransferPanel
|
||||
:has-unapplied-changes="hasChanges"
|
||||
:build-saved-export-payload="buildSettingsExportPayload"
|
||||
:build-applied-export-payload="buildAppliedExportPayload"
|
||||
:apply-imported-settings="applyImportedEditorSettings"
|
||||
:get-draft-conflict-categories="getDraftConflictCategories"
|
||||
/>
|
||||
|
||||
<ChangelogPanel :checking-updates="props.checkingUpdates" @check-updates="emit('check-updates')" />
|
||||
|
||||
<div class="rounded-lg border p-4">
|
||||
|
||||
+2
-1
@@ -13,7 +13,8 @@ const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.
|
||||
// the update itself had reset their settings.
|
||||
describe("EditorSettingsDialog unsaved-changes close guard", () => {
|
||||
it("imports the shared unsaved-changes decision helper", () => {
|
||||
expect(dialogSource).toContain('shouldConfirmEditorSettingsDialogClose, type EditorSettingsDraft } from "@/lib/settings/editorSettingsDraft"');
|
||||
// Keep in sync with the (oxfmt-formatted) multi-line import block in EditorSettingsDialog.vue.
|
||||
expect(dialogSource).toContain('shouldConfirmEditorSettingsDialogClose,\n type EditorSettingsDraft,\n type EditorSettingsDraftKey,\n} from "@/lib/settings/editorSettingsDraft"');
|
||||
});
|
||||
|
||||
it("routes every close path through the same guard instead of emitting update:open directly", () => {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Download, Loader2, Upload } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { currentLocale } from "@/i18n";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import type { EditorSettings } from "@/stores/settingsStore";
|
||||
import {
|
||||
buildSettingsTransferFilename,
|
||||
MAX_SETTINGS_TRANSFER_FILE_BYTES,
|
||||
parseSettingsTransferFile,
|
||||
SETTINGS_TRANSFER_CATEGORY_LABEL_KEYS,
|
||||
SETTINGS_TRANSFER_FORMAT_VERSION,
|
||||
type ParsedSettingsTransfer,
|
||||
type SettingsTransferCategoryId,
|
||||
type SettingsTransferParseError,
|
||||
} from "@/lib/settings/settingsTransfer";
|
||||
|
||||
// The card owns file interactions and the import confirmation; the settings
|
||||
// dialog owns the draft, the unapplied-changes check and the apply flow.
|
||||
const props = defineProps<{
|
||||
hasUnappliedChanges: () => boolean;
|
||||
buildSavedExportPayload: () => string;
|
||||
buildAppliedExportPayload: () => Promise<string | null>;
|
||||
applyImportedSettings: (imported: Partial<EditorSettings>) => void;
|
||||
getDraftConflictCategories: (imported: Partial<EditorSettings>) => SettingsTransferCategoryId[];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
||||
const exporting = ref(false);
|
||||
const importing = ref(false);
|
||||
const showExportChoiceDialog = ref(false);
|
||||
const showImportConfirmDialog = ref(false);
|
||||
const pendingImport = ref<ParsedSettingsTransfer | null>(null);
|
||||
const pendingImportFileName = ref("");
|
||||
const pendingImportConflicts = ref<SettingsTransferCategoryId[]>([]);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const busy = computed(() => exporting.value || importing.value);
|
||||
|
||||
const pendingImportCategoryLabels = computed(() => formatCategoryList(pendingImport.value?.categories ?? []));
|
||||
const pendingImportConflictLabels = computed(() => formatCategoryList(pendingImportConflicts.value));
|
||||
|
||||
function formatCategoryList(categories: readonly SettingsTransferCategoryId[]): string {
|
||||
const labels = categories.map((category) => t(SETTINGS_TRANSFER_CATEGORY_LABEL_KEYS[category]));
|
||||
const formatter = new Intl.ListFormat(currentLocale(), { type: "conjunction" });
|
||||
return formatter.format(labels);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
const mb = bytes / (1024 * 1024);
|
||||
return `${Number.isInteger(mb) ? mb : mb.toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
// --- Export ---
|
||||
|
||||
function onExportClick() {
|
||||
if (busy.value) return;
|
||||
if (props.hasUnappliedChanges()) {
|
||||
showExportChoiceDialog.value = true;
|
||||
return;
|
||||
}
|
||||
void exportSavedSettings();
|
||||
}
|
||||
|
||||
async function exportSavedSettings() {
|
||||
showExportChoiceDialog.value = false;
|
||||
await writeSettingsFile(props.buildSavedExportPayload());
|
||||
}
|
||||
|
||||
async function exportAppliedSettings() {
|
||||
showExportChoiceDialog.value = false;
|
||||
exporting.value = true;
|
||||
try {
|
||||
const payload = await props.buildAppliedExportPayload();
|
||||
// Apply failed: the dialog already surfaced the error toast, and the
|
||||
// draft must stay editable, so the export is silently aborted here.
|
||||
if (payload === null) return;
|
||||
await writeSettingsFile(payload);
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSettingsFile(payload: string) {
|
||||
exporting.value = true;
|
||||
try {
|
||||
const saved = await saveTextFile(buildSettingsTransferFilename(), payload);
|
||||
// Cancelling the native save dialog is not an error and stays silent.
|
||||
if (saved) toast(t("settings.settingsTransferExportSuccess"));
|
||||
} catch (error) {
|
||||
toast(t("settings.settingsTransferExportFailed", { message: errorMessage(error) }), 5000);
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTextFile(filename: string, contents: string): Promise<boolean> {
|
||||
if (isTauriRuntime()) {
|
||||
const [{ save }, { writeTextFile }] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]);
|
||||
const path = await save({
|
||||
defaultPath: filename,
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
if (!path) return false;
|
||||
await writeTextFile(path, contents);
|
||||
return true;
|
||||
}
|
||||
const blob = new Blob([contents], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Import ---
|
||||
|
||||
function onImportClick() {
|
||||
if (busy.value) return;
|
||||
if (isTauriRuntime()) {
|
||||
void importSettingsFromDesktop();
|
||||
return;
|
||||
}
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
async function importSettingsFromDesktop() {
|
||||
importing.value = true;
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: "JSON", extensions: ["json"] }],
|
||||
});
|
||||
if (typeof selected !== "string") return;
|
||||
const { readTextFile, stat } = await import("@tauri-apps/plugin-fs");
|
||||
// Reject oversized files before reading them into memory. If the platform
|
||||
// cannot stat the path, fall through and let the parse-time limit decide.
|
||||
try {
|
||||
const info = await stat(selected);
|
||||
if (info.size > MAX_SETTINGS_TRANSFER_FILE_BYTES) {
|
||||
notifyFileTooLarge();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// stat failures are not fatal; the reader/parse checks still apply.
|
||||
}
|
||||
const text = await readTextFile(selected);
|
||||
handleImportedText(text, fileNameFromPath(selected));
|
||||
} catch (error) {
|
||||
toast(t("settings.settingsTransferImportReadFailed", { message: errorMessage(error) }), 5000);
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileNameFromPath(path: string): string {
|
||||
const normalized = path.replace(/\\/g, "/");
|
||||
const index = normalized.lastIndexOf("/");
|
||||
return index === -1 ? normalized : normalized.slice(index + 1);
|
||||
}
|
||||
|
||||
async function handleFileInputChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
// Reject oversized files before reading them into memory.
|
||||
if (file.size > MAX_SETTINGS_TRANSFER_FILE_BYTES) {
|
||||
notifyFileTooLarge();
|
||||
return;
|
||||
}
|
||||
importing.value = true;
|
||||
try {
|
||||
const text = await file.text();
|
||||
handleImportedText(text, file.name);
|
||||
} catch (error) {
|
||||
toast(t("settings.settingsTransferImportReadFailed", { message: errorMessage(error) }), 5000);
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleImportedText(text: string, fileName: string) {
|
||||
const result = parseSettingsTransferFile(text);
|
||||
if (!result.ok) {
|
||||
toast(translateParseError(result.error), 5000);
|
||||
return;
|
||||
}
|
||||
pendingImport.value = result.value;
|
||||
pendingImportFileName.value = fileName;
|
||||
pendingImportConflicts.value = props.getDraftConflictCategories(result.value.editorSettings);
|
||||
showImportConfirmDialog.value = true;
|
||||
}
|
||||
|
||||
// The pre-read size checks (stat on desktop, File.size in the browser) and
|
||||
// the parser's own limit share this message.
|
||||
function notifyFileTooLarge() {
|
||||
toast(translateParseError({ code: "too-large" }), 5000);
|
||||
}
|
||||
|
||||
function translateParseError(error: SettingsTransferParseError): string {
|
||||
switch (error.code) {
|
||||
case "too-large":
|
||||
return t("settings.settingsTransferImportFileTooLarge", { limit: formatFileSize(MAX_SETTINGS_TRANSFER_FILE_BYTES) });
|
||||
case "invalid-json":
|
||||
return t("settings.settingsTransferImportInvalidJson");
|
||||
case "unsupported-version":
|
||||
return t("settings.settingsTransferImportUnsupportedVersion", { version: error.detail ?? "?", supported: String(SETTINGS_TRANSFER_FORMAT_VERSION) });
|
||||
case "invalid-structure":
|
||||
return t("settings.settingsTransferImportInvalidStructure");
|
||||
case "empty-settings":
|
||||
return t("settings.settingsTransferImportEmpty");
|
||||
case "invalid-fields":
|
||||
return t("settings.settingsTransferImportFieldInvalid", { fields: error.detail ?? "" });
|
||||
}
|
||||
}
|
||||
|
||||
function confirmImport() {
|
||||
const imported = pendingImport.value;
|
||||
if (!imported) return;
|
||||
props.applyImportedSettings(imported.editorSettings);
|
||||
showImportConfirmDialog.value = false;
|
||||
pendingImport.value = null;
|
||||
pendingImportConflicts.value = [];
|
||||
// The draft is not persisted yet; applying is still the user's job.
|
||||
toast(t("settings.settingsTransferImportLoaded"));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border p-4">
|
||||
<div class="settings-about-section-header flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<Label>{{ t("settings.settingsTransferTitle") }}</Label>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t("settings.settingsTransferDescription") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="settings-about-section-actions flex shrink-0 flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" class="shrink-0" :disabled="busy" @click="onExportClick">
|
||||
<Loader2 v-if="exporting" class="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Download v-else class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("settings.settingsTransferExport") }}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" class="shrink-0" :disabled="busy" @click="onImportClick">
|
||||
<Loader2 v-if="importing" class="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
<Upload v-else class="mr-1 h-3.5 w-3.5" />
|
||||
{{ t("settings.settingsTransferImport") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<input ref="fileInputRef" type="file" accept=".json,application/json" class="hidden" @change="handleFileInputChange" />
|
||||
</div>
|
||||
|
||||
<!-- Export choice: shown only when the draft has unapplied changes. -->
|
||||
<Dialog :open="showExportChoiceDialog" @update:open="(value: boolean) => (showExportChoiceDialog = value)">
|
||||
<DialogContent class="sm:max-w-[460px]" @interact-outside.prevent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("settings.settingsTransferExportChoiceTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p class="text-sm text-muted-foreground">{{ t("settings.settingsTransferExportChoiceMessage") }}</p>
|
||||
<DialogFooter class="gap-2">
|
||||
<Button variant="outline" size="sm" @click="showExportChoiceDialog = false">
|
||||
{{ t("common.cancel") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" :disabled="busy" @click="exportSavedSettings">
|
||||
{{ t("settings.settingsTransferExportChoiceSaved") }}
|
||||
</Button>
|
||||
<Button size="sm" :disabled="busy" @click="exportAppliedSettings">
|
||||
{{ t("settings.settingsTransferExportChoiceApplied") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Import confirmation: preview before anything touches the draft.
|
||||
Width is elastic (fit-content) so long category lists stay on one line
|
||||
instead of wrapping; capped for narrow viewports. -->
|
||||
<Dialog :open="showImportConfirmDialog" @update:open="(value: boolean) => (showImportConfirmDialog = value)">
|
||||
<DialogContent class="w-full sm:w-fit sm:min-w-[420px] sm:max-w-[min(90vw,42rem)]" @interact-outside.prevent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ t("settings.settingsTransferImportConfirmTitle") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="flex flex-col gap-2 text-sm">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="shrink-0 text-muted-foreground">{{ t("settings.settingsTransferImportConfirmFile") }}</span>
|
||||
<span class="min-w-0 break-all text-right font-mono text-xs">{{ pendingImportFileName }}</span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="shrink-0 text-muted-foreground">{{ t("settings.settingsTransferImportConfirmSourceVersion") }}</span>
|
||||
<span class="min-w-0 break-all text-right font-mono text-xs">{{ pendingImport?.appVersion || t("settings.supportInfoUnknown") }}</span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<span class="shrink-0 text-muted-foreground">{{ t("settings.settingsTransferImportConfirmIncludes") }}</span>
|
||||
<span class="min-w-0 text-right">{{ pendingImportCategoryLabels }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="pendingImportConflicts.length" class="rounded-md bg-amber-500/10 px-3 py-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
{{ t("settings.settingsTransferImportConfirmDraftWarning", { categories: pendingImportConflictLabels }) }}
|
||||
</p>
|
||||
<DialogFooter class="gap-2">
|
||||
<Button variant="outline" size="sm" @click="showImportConfirmDialog = false">
|
||||
{{ t("common.cancel") }}
|
||||
</Button>
|
||||
<Button size="sm" @click="confirmImport">
|
||||
{{ t("settings.settingsTransferImportConfirmAction") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -7490,6 +7490,32 @@ export default {
|
||||
supportInfoOperatingSystem: "Operating System",
|
||||
supportInfoArchitecture: "Architecture",
|
||||
supportInfoUnknown: "Unknown",
|
||||
settingsTransferTitle: "Import & export settings",
|
||||
settingsTransferDescription: "Export app settings to a file to back up your configuration, or restore settings from a backup file.",
|
||||
settingsTransferExport: "Export",
|
||||
settingsTransferImport: "Import",
|
||||
settingsTransferExportSuccess: "Settings exported",
|
||||
settingsTransferExportFailed: "Failed to export settings: {message}",
|
||||
settingsTransferExportChoiceTitle: "Unapplied changes",
|
||||
settingsTransferExportChoiceMessage: "You have unapplied settings changes. Exporting the saved settings will not include them.",
|
||||
settingsTransferExportChoiceSaved: "Export saved settings",
|
||||
settingsTransferExportChoiceApplied: "Apply and export",
|
||||
settingsTransferApplyBlocked: "Unresolved settings conflicts prevent applying and exporting.",
|
||||
settingsTransferImportReadFailed: "Failed to read the settings file: {message}",
|
||||
settingsTransferImportInvalidJson: "The file is not a valid JSON settings file.",
|
||||
settingsTransferImportUnsupportedVersion: "Unsupported settings file format version: v{version}. Supported version: v{supported}.",
|
||||
settingsTransferImportInvalidStructure: "The settings file structure is invalid.",
|
||||
settingsTransferImportEmpty: "The settings file contains no importable settings.",
|
||||
settingsTransferImportFieldInvalid: "The settings file contains invalid fields. Import cancelled: {fields}",
|
||||
settingsTransferImportFileTooLarge: "The settings file is too large. Maximum size: {limit}.",
|
||||
settingsTransferImportConfirmTitle: "Import settings",
|
||||
settingsTransferImportConfirmFile: "File",
|
||||
settingsTransferImportConfirmSourceVersion: "Source app version",
|
||||
settingsTransferImportConfirmIncludes: "Includes",
|
||||
settingsTransferImportConfirmDraftWarning: "These categories have unapplied changes that will be replaced: {categories}",
|
||||
settingsTransferImportConfirmAction: "Load settings",
|
||||
settingsTransferImportLoaded: "Settings loaded. Click Apply to save them.",
|
||||
settingsTransferCategoryOther: "Other",
|
||||
community: "Community",
|
||||
qqGroup: "QQ Group",
|
||||
wechatGroup: "WeChat Group",
|
||||
|
||||
@@ -7016,6 +7016,32 @@ export default withEnglishFallback({
|
||||
supportInfoOperatingSystem: "Sistema operativo",
|
||||
supportInfoArchitecture: "Arquitectura",
|
||||
supportInfoUnknown: "Desconocido",
|
||||
settingsTransferTitle: "Importar y exportar configuración",
|
||||
settingsTransferDescription: "Exporta la configuración de la aplicación a un archivo para hacer una copia de seguridad, o restaura la configuración desde un archivo de respaldo.",
|
||||
settingsTransferExport: "Exportar",
|
||||
settingsTransferImport: "Importar",
|
||||
settingsTransferExportSuccess: "Configuración exportada",
|
||||
settingsTransferExportFailed: "Error al exportar la configuración: {message}",
|
||||
settingsTransferExportChoiceTitle: "Cambios sin aplicar",
|
||||
settingsTransferExportChoiceMessage: "Tienes cambios de configuración sin aplicar. La exportación de la configuración guardada no los incluirá.",
|
||||
settingsTransferExportChoiceSaved: "Exportar configuración guardada",
|
||||
settingsTransferExportChoiceApplied: "Aplicar y exportar",
|
||||
settingsTransferApplyBlocked: "Hay conflictos de configuración sin resolver; no se puede aplicar y exportar.",
|
||||
settingsTransferImportReadFailed: "Error al leer el archivo de configuración: {message}",
|
||||
settingsTransferImportInvalidJson: "El archivo no es un archivo de configuración JSON válido.",
|
||||
settingsTransferImportUnsupportedVersion: "Versión de formato del archivo de configuración no compatible: v{version}. Versión compatible: v{supported}.",
|
||||
settingsTransferImportInvalidStructure: "La estructura del archivo de configuración no es válida.",
|
||||
settingsTransferImportEmpty: "El archivo de configuración no contiene ajustes importables.",
|
||||
settingsTransferImportFieldInvalid: "El archivo de configuración contiene campos no válidos. Importación cancelada: {fields}",
|
||||
settingsTransferImportFileTooLarge: "El archivo de configuración es demasiado grande. Tamaño máximo: {limit}.",
|
||||
settingsTransferImportConfirmTitle: "Importar configuración",
|
||||
settingsTransferImportConfirmFile: "Archivo",
|
||||
settingsTransferImportConfirmSourceVersion: "Versión de la app de origen",
|
||||
settingsTransferImportConfirmIncludes: "Incluye",
|
||||
settingsTransferImportConfirmDraftWarning: "Estas categorías tienen cambios sin aplicar que serán reemplazados: {categories}",
|
||||
settingsTransferImportConfirmAction: "Cargar configuración",
|
||||
settingsTransferImportLoaded: "Configuración cargada. Haz clic en «Aplicar» para guardarla.",
|
||||
settingsTransferCategoryOther: "Otro",
|
||||
community: "Comunidad",
|
||||
qqGroup: "Grupo QQ",
|
||||
wechatGroup: "Grupo WeChat",
|
||||
|
||||
@@ -7016,6 +7016,32 @@ export default withEnglishFallback({
|
||||
supportInfoOperatingSystem: "Sistema operativo",
|
||||
supportInfoArchitecture: "Architettura",
|
||||
supportInfoUnknown: "Sconosciuto",
|
||||
settingsTransferTitle: "Importa ed esporta impostazioni",
|
||||
settingsTransferDescription: "Esporta le impostazioni dell'app su un file per eseguire il backup della configurazione, oppure ripristina le impostazioni da un file di backup.",
|
||||
settingsTransferExport: "Esporta",
|
||||
settingsTransferImport: "Importa",
|
||||
settingsTransferExportSuccess: "Impostazioni esportate",
|
||||
settingsTransferExportFailed: "Esportazione delle impostazioni non riuscita: {message}",
|
||||
settingsTransferExportChoiceTitle: "Modifiche non applicate",
|
||||
settingsTransferExportChoiceMessage: "Sono presenti modifiche alle impostazioni non applicate. L'esportazione delle impostazioni salvate non le includerà.",
|
||||
settingsTransferExportChoiceSaved: "Esporta impostazioni salvate",
|
||||
settingsTransferExportChoiceApplied: "Applica ed esporta",
|
||||
settingsTransferApplyBlocked: "Ci sono conflitti di impostazioni non risolti; impossibile applicare ed esportare.",
|
||||
settingsTransferImportReadFailed: "Lettura del file delle impostazioni non riuscita: {message}",
|
||||
settingsTransferImportInvalidJson: "Il file non è un file di impostazioni JSON valido.",
|
||||
settingsTransferImportUnsupportedVersion: "Versione del formato del file di impostazioni non supportata: v{version}. Versione supportata: v{supported}.",
|
||||
settingsTransferImportInvalidStructure: "La struttura del file delle impostazioni non è valida.",
|
||||
settingsTransferImportEmpty: "Il file delle impostazioni non contiene impostazioni importabili.",
|
||||
settingsTransferImportFieldInvalid: "Il file delle impostazioni contiene campi non validi. Importazione annullata: {fields}",
|
||||
settingsTransferImportFileTooLarge: "Il file delle impostazioni è troppo grande. Dimensione massima: {limit}.",
|
||||
settingsTransferImportConfirmTitle: "Importa impostazioni",
|
||||
settingsTransferImportConfirmFile: "File",
|
||||
settingsTransferImportConfirmSourceVersion: "Versione dell'app di origine",
|
||||
settingsTransferImportConfirmIncludes: "Include",
|
||||
settingsTransferImportConfirmDraftWarning: "Queste categorie hanno modifiche non applicate che verranno sostituite: {categories}",
|
||||
settingsTransferImportConfirmAction: "Carica impostazioni",
|
||||
settingsTransferImportLoaded: "Impostazioni caricate. Fai clic su «Applica» per salvarle.",
|
||||
settingsTransferCategoryOther: "Altro",
|
||||
community: "Community",
|
||||
qqGroup: "Gruppo QQ",
|
||||
wechatGroup: "Gruppo WeChat",
|
||||
|
||||
@@ -7042,6 +7042,32 @@ export default withEnglishFallback({
|
||||
supportInfoOperatingSystem: "オペレーティングシステム",
|
||||
supportInfoArchitecture: "アーキテクチャ",
|
||||
supportInfoUnknown: "不明",
|
||||
settingsTransferTitle: "設定のインポートとエクスポート",
|
||||
settingsTransferDescription: "アプリ設定をファイルにエクスポートして設定をバックアップしたり、バックアップファイルから設定を復元できます。",
|
||||
settingsTransferExport: "エクスポート",
|
||||
settingsTransferImport: "インポート",
|
||||
settingsTransferExportSuccess: "設定をエクスポートしました",
|
||||
settingsTransferExportFailed: "設定のエクスポートに失敗しました:{message}",
|
||||
settingsTransferExportChoiceTitle: "未適用の変更があります",
|
||||
settingsTransferExportChoiceMessage: "未適用の設定変更があります。保存済み設定のエクスポートにはこれらの変更は含まれません。",
|
||||
settingsTransferExportChoiceSaved: "保存済み設定をエクスポート",
|
||||
settingsTransferExportChoiceApplied: "適用してエクスポート",
|
||||
settingsTransferApplyBlocked: "未解決の設定競合があるため、適用してエクスポートできません。",
|
||||
settingsTransferImportReadFailed: "設定ファイルの読み取りに失敗しました:{message}",
|
||||
settingsTransferImportInvalidJson: "ファイルは有効な JSON 設定ファイルではありません。",
|
||||
settingsTransferImportUnsupportedVersion: "設定ファイルの形式バージョン v{version} はサポートされていません。サポートバージョン:v{supported}。",
|
||||
settingsTransferImportInvalidStructure: "設定ファイルの構造が正しくないためインポートできません。",
|
||||
settingsTransferImportEmpty: "設定ファイルにインポート可能な設定がありません。",
|
||||
settingsTransferImportFieldInvalid: "設定ファイルに無効なフィールドが含まれているため、インポートをキャンセルしました:{fields}",
|
||||
settingsTransferImportFileTooLarge: "設定ファイルが大きすぎます。最大サイズ:{limit}。",
|
||||
settingsTransferImportConfirmTitle: "設定のインポート",
|
||||
settingsTransferImportConfirmFile: "ファイル",
|
||||
settingsTransferImportConfirmSourceVersion: "提供元アプリのバージョン",
|
||||
settingsTransferImportConfirmIncludes: "含まれる設定",
|
||||
settingsTransferImportConfirmDraftWarning: "以下のカテゴリには未適用の変更があり、読み込み後に置き換えられます:{categories}",
|
||||
settingsTransferImportConfirmAction: "設定を読み込む",
|
||||
settingsTransferImportLoaded: "設定を読み込みました。「適用」をクリックして保存してください。",
|
||||
settingsTransferCategoryOther: "その他",
|
||||
community: "コミュニティ",
|
||||
qqGroup: "QQグループ",
|
||||
wechatGroup: "WeChatグループ",
|
||||
|
||||
@@ -6769,6 +6769,32 @@ export default withEnglishFallback({
|
||||
supportInfoOperatingSystem: "운영 체제",
|
||||
supportInfoArchitecture: "아키텍처",
|
||||
supportInfoUnknown: "알 수 없음",
|
||||
settingsTransferTitle: "설정 가져오기 및 내보내기",
|
||||
settingsTransferDescription: "앱 설정을 파일로 내보내 설정을 백업하거나, 백업 파일에서 설정을 복원합니다.",
|
||||
settingsTransferExport: "내보내기",
|
||||
settingsTransferImport: "가져오기",
|
||||
settingsTransferExportSuccess: "설정을 내보냈습니다",
|
||||
settingsTransferExportFailed: "설정 내보내기 실패: {message}",
|
||||
settingsTransferExportChoiceTitle: "적용되지 않은 변경 사항이 있습니다",
|
||||
settingsTransferExportChoiceMessage: "적용되지 않은 설정 변경 사항이 있습니다. 저장된 설정을 내보내면 이러한 변경 사항은 포함되지 않습니다.",
|
||||
settingsTransferExportChoiceSaved: "저장된 설정 내보내기",
|
||||
settingsTransferExportChoiceApplied: "적용 후 내보내기",
|
||||
settingsTransferApplyBlocked: "해결되지 않은 설정 충돌이 있어 적용 후 내보낼 수 없습니다.",
|
||||
settingsTransferImportReadFailed: "설정 파일을 읽지 못했습니다: {message}",
|
||||
settingsTransferImportInvalidJson: "파일이 유효한 JSON 설정 파일이 아닙니다.",
|
||||
settingsTransferImportUnsupportedVersion: "지원되지 않는 설정 파일 형식 버전: v{version}. 지원 버전: v{supported}.",
|
||||
settingsTransferImportInvalidStructure: "설정 파일 구조가 올바르지 않아 가져올 수 없습니다.",
|
||||
settingsTransferImportEmpty: "설정 파일에 가져올 수 있는 설정이 없습니다.",
|
||||
settingsTransferImportFieldInvalid: "설정 파일에 잘못된 필드가 있어 가져오기를 취소했습니다: {fields}",
|
||||
settingsTransferImportFileTooLarge: "설정 파일이 너무 큽니다. 최대 크기: {limit}.",
|
||||
settingsTransferImportConfirmTitle: "설정 가져오기",
|
||||
settingsTransferImportConfirmFile: "파일",
|
||||
settingsTransferImportConfirmSourceVersion: "원본 앱 버전",
|
||||
settingsTransferImportConfirmIncludes: "포함 항목",
|
||||
settingsTransferImportConfirmDraftWarning: "다음 카테고리에는 적용되지 않은 변경 사항이 있으며, 가져오면 대체됩니다: {categories}",
|
||||
settingsTransferImportConfirmAction: "설정 불러오기",
|
||||
settingsTransferImportLoaded: "설정을 불러왔습니다. 저장하려면 '적용'을 클릭하세요.",
|
||||
settingsTransferCategoryOther: "기타",
|
||||
community: "커뮤니티",
|
||||
qqGroup: "QQ 그룹",
|
||||
wechatGroup: "위챗 그룹",
|
||||
|
||||
@@ -7018,6 +7018,32 @@ export default withEnglishFallback({
|
||||
supportInfoOperatingSystem: "Sistema operacional",
|
||||
supportInfoArchitecture: "Arquitetura",
|
||||
supportInfoUnknown: "Desconhecido",
|
||||
settingsTransferTitle: "Importar e exportar configurações",
|
||||
settingsTransferDescription: "Exporte as configurações do aplicativo para um arquivo para fazer backup da sua configuração, ou restaure as configurações de um arquivo de backup.",
|
||||
settingsTransferExport: "Exportar",
|
||||
settingsTransferImport: "Importar",
|
||||
settingsTransferExportSuccess: "Configurações exportadas",
|
||||
settingsTransferExportFailed: "Falha ao exportar as configurações: {message}",
|
||||
settingsTransferExportChoiceTitle: "Alterações não aplicadas",
|
||||
settingsTransferExportChoiceMessage: "Você tem alterações de configuração não aplicadas. A exportação das configurações salvas não as incluirá.",
|
||||
settingsTransferExportChoiceSaved: "Exportar configurações salvas",
|
||||
settingsTransferExportChoiceApplied: "Aplicar e exportar",
|
||||
settingsTransferApplyBlocked: "Há conflitos de configurações não resolvidos; não é possível aplicar e exportar.",
|
||||
settingsTransferImportReadFailed: "Falha ao ler o arquivo de configurações: {message}",
|
||||
settingsTransferImportInvalidJson: "O arquivo não é um arquivo de configurações JSON válido.",
|
||||
settingsTransferImportUnsupportedVersion: "Versão de formato do arquivo de configurações não suportada: v{version}. Versão suportada: v{supported}.",
|
||||
settingsTransferImportInvalidStructure: "A estrutura do arquivo de configurações é inválida.",
|
||||
settingsTransferImportEmpty: "O arquivo de configurações não contém configurações importáveis.",
|
||||
settingsTransferImportFieldInvalid: "O arquivo de configurações contém campos inválidos. Importação cancelada: {fields}",
|
||||
settingsTransferImportFileTooLarge: "O arquivo de configurações é muito grande. Tamanho máximo: {limit}.",
|
||||
settingsTransferImportConfirmTitle: "Importar configurações",
|
||||
settingsTransferImportConfirmFile: "Arquivo",
|
||||
settingsTransferImportConfirmSourceVersion: "Versão do app de origem",
|
||||
settingsTransferImportConfirmIncludes: "Inclui",
|
||||
settingsTransferImportConfirmDraftWarning: "Estas categorias têm alterações não aplicadas que serão substituídas: {categories}",
|
||||
settingsTransferImportConfirmAction: "Carregar configurações",
|
||||
settingsTransferImportLoaded: "Configurações carregadas. Clique em «Aplicar» para salvá-las.",
|
||||
settingsTransferCategoryOther: "Outro",
|
||||
community: "Comunidade",
|
||||
qqGroup: "Grupo do QQ",
|
||||
wechatGroup: "Grupo do WeChat",
|
||||
|
||||
@@ -7475,6 +7475,32 @@ export default withEnglishFallback({
|
||||
supportInfoOperatingSystem: "操作系统",
|
||||
supportInfoArchitecture: "系统架构",
|
||||
supportInfoUnknown: "未知",
|
||||
settingsTransferTitle: "配置导入与导出",
|
||||
settingsTransferDescription: "导出应用设置到文件进行配置备份,或从备份文件恢复配置。",
|
||||
settingsTransferExport: "导出",
|
||||
settingsTransferImport: "导入",
|
||||
settingsTransferExportSuccess: "配置已导出",
|
||||
settingsTransferExportFailed: "导出配置失败:{message}",
|
||||
settingsTransferExportChoiceTitle: "存在未应用的修改",
|
||||
settingsTransferExportChoiceMessage: "当前有未应用的设置修改。导出已保存配置将不包含这些修改。",
|
||||
settingsTransferExportChoiceSaved: "导出已保存配置",
|
||||
settingsTransferExportChoiceApplied: "应用并导出",
|
||||
settingsTransferApplyBlocked: "存在未解决的设置冲突,无法应用并导出。",
|
||||
settingsTransferImportReadFailed: "读取配置文件失败:{message}",
|
||||
settingsTransferImportInvalidJson: "文件不是有效的 JSON 配置文件。",
|
||||
settingsTransferImportUnsupportedVersion: "配置文件格式版本不受支持:v{version},当前支持 v{supported}。",
|
||||
settingsTransferImportInvalidStructure: "配置文件结构不正确,无法导入。",
|
||||
settingsTransferImportEmpty: "配置文件中没有可导入的设置。",
|
||||
settingsTransferImportFieldInvalid: "配置文件包含无效字段,已取消导入:{fields}",
|
||||
settingsTransferImportFileTooLarge: "配置文件过大,最大支持 {limit}。",
|
||||
settingsTransferImportConfirmTitle: "导入配置",
|
||||
settingsTransferImportConfirmFile: "文件",
|
||||
settingsTransferImportConfirmSourceVersion: "来源应用版本",
|
||||
settingsTransferImportConfirmIncludes: "包含",
|
||||
settingsTransferImportConfirmDraftWarning: "以下类别存在未应用的修改,载入后将替换:{categories}",
|
||||
settingsTransferImportConfirmAction: "载入设置",
|
||||
settingsTransferImportLoaded: "已载入设置,请点击“应用”保存",
|
||||
settingsTransferCategoryOther: "其他",
|
||||
community: "社区",
|
||||
qqGroup: "QQ 群",
|
||||
wechatGroup: "微信交流群",
|
||||
|
||||
@@ -6040,6 +6040,32 @@ export default withEnglishFallback({
|
||||
supportInfoOperatingSystem: "作業系統",
|
||||
supportInfoArchitecture: "系統架構",
|
||||
supportInfoUnknown: "未知",
|
||||
settingsTransferTitle: "設定匯入與匯出",
|
||||
settingsTransferDescription: "將應用程式設定匯出至檔案進行設定備份,或從備份檔案還原設定。",
|
||||
settingsTransferExport: "匯出",
|
||||
settingsTransferImport: "匯入",
|
||||
settingsTransferExportSuccess: "設定已匯出",
|
||||
settingsTransferExportFailed: "匯出設定失敗:{message}",
|
||||
settingsTransferExportChoiceTitle: "存在未套用的變更",
|
||||
settingsTransferExportChoiceMessage: "目前有未套用的設定變更。匯出已儲存的設定將不包含這些變更。",
|
||||
settingsTransferExportChoiceSaved: "匯出已儲存設定",
|
||||
settingsTransferExportChoiceApplied: "套用並匯出",
|
||||
settingsTransferApplyBlocked: "存在未解決的設定衝突,無法套用並匯出。",
|
||||
settingsTransferImportReadFailed: "讀取設定檔失敗:{message}",
|
||||
settingsTransferImportInvalidJson: "檔案不是有效的 JSON 設定檔。",
|
||||
settingsTransferImportUnsupportedVersion: "設定檔格式版本不受支援:v{version},目前支援 v{supported}。",
|
||||
settingsTransferImportInvalidStructure: "設定檔結構不正確,無法匯入。",
|
||||
settingsTransferImportEmpty: "設定檔中沒有可匯入的設定。",
|
||||
settingsTransferImportFieldInvalid: "設定檔包含無效欄位,已取消匯入:{fields}",
|
||||
settingsTransferImportFileTooLarge: "設定檔過大,最大支援 {limit}。",
|
||||
settingsTransferImportConfirmTitle: "匯入設定",
|
||||
settingsTransferImportConfirmFile: "檔案",
|
||||
settingsTransferImportConfirmSourceVersion: "來源應用程式版本",
|
||||
settingsTransferImportConfirmIncludes: "包含",
|
||||
settingsTransferImportConfirmDraftWarning: "以下類別存在未套用的變更,載入後將被取代:{categories}",
|
||||
settingsTransferImportConfirmAction: "載入設定",
|
||||
settingsTransferImportLoaded: "已載入設定,請點擊「套用」儲存",
|
||||
settingsTransferCategoryOther: "其他",
|
||||
community: "社群",
|
||||
qqGroup: "QQ 群",
|
||||
wechatGroup: "微信交流群",
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EDITOR_SETTINGS_DRAFT_KEYS, editorSettingsDraftFromSettings, editorSettingsDraftChanged, editorSettingsPatchFromDraft, normalizeQueryResultMaxRowsDraft, normalizeTableOpenPageSizeDraft, shouldConfirmEditorSettingsDialogClose } from "../editorSettingsDraft";
|
||||
import {
|
||||
EDITOR_SETTINGS_DRAFT_KEYS,
|
||||
editorSettingsDraftFromSettings,
|
||||
editorSettingsDraftChanged,
|
||||
editorSettingsDraftPatchFromSettings,
|
||||
editorSettingsPatchFromDraft,
|
||||
normalizeQueryResultMaxRowsDraft,
|
||||
normalizeTableOpenPageSizeDraft,
|
||||
shouldConfirmEditorSettingsDialogClose,
|
||||
} from "../editorSettingsDraft";
|
||||
import type { EditorSettings } from "@/stores/settingsStore";
|
||||
|
||||
const settingsDialogSource = readFileSync(new URL("../../../components/editor/EditorSettingsDialog.vue", import.meta.url), "utf8");
|
||||
@@ -468,3 +477,26 @@ describe("editorSettingsPatchFromDraft - tabLayout", () => {
|
||||
expect(patch.tabLayout).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editorSettingsDraftPatchFromSettings", () => {
|
||||
it("contains exactly the keys present in the input", () => {
|
||||
const patch = editorSettingsDraftPatchFromSettings({ wordWrap: true, pageSize: 200 } as Partial<EditorSettings>);
|
||||
expect(Object.keys(patch).sort()).toEqual(["pageSize", "wordWrap"]);
|
||||
expect(patch.wordWrap).toBe(true);
|
||||
expect(patch.pageSize).toBe(200);
|
||||
});
|
||||
|
||||
it("normalizes imported values per key", () => {
|
||||
const patch = editorSettingsDraftPatchFromSettings({ pageSize: 999999 } as Partial<EditorSettings>);
|
||||
expect(patch.pageSize).toBe(normalizeTableOpenPageSizeDraft(999999));
|
||||
});
|
||||
|
||||
it("is the base for the settings import path in the dialog", () => {
|
||||
// The import must patch only imported keys into the edit refs; rebuilding
|
||||
// the whole draft would drop unsaved state the file does not cover (e.g. a
|
||||
// half-filled table-column template row, which serialization drops).
|
||||
expect(settingsDialogSource).toContain("const patch = editorSettingsDraftPatchFromSettings(imported);");
|
||||
expect(settingsDialogSource).toContain("applyEditorSettingsKeysToRefs(patch as EditorSettingsDraft, Object.keys(patch) as EditorSettingsDraftKey[]);");
|
||||
expect(settingsDialogSource).not.toContain("const merged = editorSettingsDraftFromSettings({");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { 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";
|
||||
|
||||
function fileWith(editor: Record<string, unknown>, overrides: 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: { editor },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("settingsTransfer", () => {
|
||||
it("builds a dated transfer filename", () => {
|
||||
expect(buildSettingsTransferFilename(new Date(2026, 8, 6))).toBe("dbx-settings-2026-09-06.json");
|
||||
});
|
||||
|
||||
it("round-trips the default settings through serialize and parse", () => {
|
||||
const text = serializeSettingsTransfer(DEFAULT_EDITOR_SETTINGS, { appVersion: "1.2.3" });
|
||||
const result = parseSettingsTransferFile(text);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.appVersion).toBe("1.2.3");
|
||||
expect(Object.keys(result.value.editorSettings).sort()).toEqual([...EDITOR_SETTINGS_DRAFT_KEYS].sort());
|
||||
expect(result.value.editorSettings.pageSize).toBe(DEFAULT_EDITOR_SETTINGS.pageSize);
|
||||
expect(result.value.editorSettings.snippets).toEqual(DEFAULT_EDITOR_SETTINGS.snippets);
|
||||
expect(result.value.categories).toContain("snippets");
|
||||
expect(result.value.categories).toContain("shortcuts");
|
||||
});
|
||||
|
||||
it("keeps connection timeout ownership out of the payload", () => {
|
||||
const text = serializeSettingsTransfer(DEFAULT_EDITOR_SETTINGS);
|
||||
expect(text).not.toContain("connectTimeoutInheritConnectionIds");
|
||||
expect(text).not.toContain("queryTimeoutInheritConnectionIds");
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
const result = parseSettingsTransferFile("{ not json");
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("invalid-json");
|
||||
});
|
||||
|
||||
it("rejects unsupported format versions", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({}, { formatVersion: 99 }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("unsupported-version");
|
||||
expect(result.error.detail).toBe("99");
|
||||
});
|
||||
|
||||
it("rejects wrong-typed fields", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ fontSize: "big" }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("invalid-fields");
|
||||
expect(result.error.detail).toContain("fontSize");
|
||||
});
|
||||
|
||||
it("rejects out-of-domain enum values", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ updateDownloadSource: "evil" }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("invalid-fields");
|
||||
expect(result.error.detail).toContain("updateDownloadSource");
|
||||
});
|
||||
|
||||
it("rejects out-of-range numbers that the normalizer clamps", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ sidebarIndent: 99999 }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("invalid-fields");
|
||||
expect(result.error.detail).toContain("sidebarIndent");
|
||||
});
|
||||
|
||||
it("rejects pass-through numeric fields outside the editor font range", () => {
|
||||
const zero = parseSettingsTransferFile(fileWith({ fontSize: 0 }));
|
||||
expect(zero.ok).toBe(false);
|
||||
if (zero.ok) return;
|
||||
expect(zero.error.detail).toContain("fontSize");
|
||||
|
||||
const huge = parseSettingsTransferFile(fileWith({ fontSize: 100 }));
|
||||
expect(huge.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts in-range fractional editor font sizes", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ fontSize: 13.5 }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.editorSettings.fontSize).toBe(13.5);
|
||||
});
|
||||
|
||||
it("rejects pass-through layout enums", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ appLayout: "invalid" }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) return;
|
||||
expect(result.error.code).toBe("invalid-fields");
|
||||
expect(result.error.detail).toContain("appLayout");
|
||||
});
|
||||
|
||||
it("rejects pass-through boolean flags with non-boolean values", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ wordWrap: "yes" }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) return;
|
||||
expect(result.error.detail).toContain("wordWrap");
|
||||
});
|
||||
|
||||
it("rejects toolbar items whose known keys are not booleans", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ toolbarItems: { dataTransfer: "yes" } }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) return;
|
||||
expect(result.error.detail).toContain("toolbarItems");
|
||||
});
|
||||
|
||||
it("rejects toolbar flags that the normalizer would coerce", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ toolbarItems: { exclusiveRightSidebarPanels: "no" } }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) return;
|
||||
expect(result.error.detail).toContain("toolbarItems");
|
||||
});
|
||||
|
||||
it("rejects empty active custom theme ids", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ activeCustomThemeId: " " }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) return;
|
||||
expect(result.error.detail).toContain("activeCustomThemeId");
|
||||
});
|
||||
|
||||
it("imports only the keys present in the file", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ theme: DEFAULT_EDITOR_SETTINGS.theme }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(Object.keys(result.value.editorSettings)).toEqual(["theme"]);
|
||||
expect(result.value.categories).toEqual(["appearance"]);
|
||||
});
|
||||
|
||||
it("ignores unknown fields instead of importing them", () => {
|
||||
const result = parseSettingsTransferFile(
|
||||
fileWith({
|
||||
theme: DEFAULT_EDITOR_SETTINGS.theme,
|
||||
notARealSetting: "injected",
|
||||
connectTimeoutInheritConnectionIds: ["conn-1"],
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.editorSettings).not.toHaveProperty("notARealSetting");
|
||||
expect(result.value.editorSettings).not.toHaveProperty("connectTimeoutInheritConnectionIds");
|
||||
});
|
||||
|
||||
it("sanitizes object fields through the store normalizer", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ shortcuts: {} }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.editorSettings.shortcuts).toEqual(DEFAULT_EDITOR_SETTINGS.shortcuts);
|
||||
});
|
||||
|
||||
it("rejects files without importable settings", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ notARealSetting: true }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("empty-settings");
|
||||
});
|
||||
|
||||
it("rejects oversized files", () => {
|
||||
const result = parseSettingsTransferFile("x".repeat(5 * 1024 * 1024 + 1));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.code).toBe("too-large");
|
||||
});
|
||||
|
||||
it("maps every whitelisted key to a display category", () => {
|
||||
for (const key of EDITOR_SETTINGS_DRAFT_KEYS) {
|
||||
expect(transferCategoryForKey(key), key).toBeDefined();
|
||||
}
|
||||
expect(transferCategoryForKey("notARealSetting")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("collects categories in display order without duplicates", () => {
|
||||
expect(collectTransferCategories(["snippets", "theme", "shortcuts", "fontFamily"])).toEqual(["appearance", "shortcuts", "snippets"]);
|
||||
});
|
||||
|
||||
it("does not mutate the shared defaults while validating", () => {
|
||||
const before = JSON.stringify(DEFAULT_EDITOR_SETTINGS) as string;
|
||||
parseSettingsTransferFile(fileWith({ shortcuts: { hijacked: true }, customThemes: [{ id: "x" }] }));
|
||||
expect(JSON.stringify(DEFAULT_EDITOR_SETTINGS)).toBe(before);
|
||||
});
|
||||
|
||||
it("accepts a serialized draft of arbitrary settings unchanged", () => {
|
||||
const settings = {
|
||||
...DEFAULT_EDITOR_SETTINGS,
|
||||
theme: DEFAULT_EDITOR_SETTINGS.theme,
|
||||
pageSize: 200,
|
||||
snippets: [{ id: "s1", label: "L", prefix: "p", body: "SELECT 1", enabled: true }],
|
||||
} as EditorSettings;
|
||||
const result = parseSettingsTransferFile(serializeSettingsTransfer(settings));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.editorSettings.pageSize).toBe(200);
|
||||
expect(result.value.editorSettings.snippets).toEqual(settings.snippets);
|
||||
});
|
||||
|
||||
it("rejects custom theme items that lack id or name", () => {
|
||||
const validTheme = { ...DEFAULT_EDITOR_SETTINGS.customThemes[0] };
|
||||
const idless = parseSettingsTransferFile(fileWith({ customThemes: [{ ...validTheme, id: "" }] }));
|
||||
expect(idless.ok).toBe(false);
|
||||
if (idless.ok) return;
|
||||
expect(idless.error.detail).toContain("customThemes");
|
||||
|
||||
const unnamed = parseSettingsTransferFile(fileWith({ customThemes: [{ ...validTheme, name: undefined }] }));
|
||||
expect(unnamed.ok).toBe(false);
|
||||
if (unnamed.ok) return;
|
||||
expect(unnamed.error.detail).toContain("customThemes");
|
||||
});
|
||||
|
||||
it("rejects custom theme items with wrong-typed colors", () => {
|
||||
const validTheme = DEFAULT_EDITOR_SETTINGS.customThemes[0];
|
||||
const result = parseSettingsTransferFile(
|
||||
fileWith({
|
||||
customThemes: [{ ...validTheme, id: "t1", name: "T1", colors: { ...validTheme.colors, keyword: 5 } }],
|
||||
}),
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.detail).toContain("customThemes");
|
||||
});
|
||||
|
||||
it("rejects custom theme items with wrong-typed optional background and foreground colors", () => {
|
||||
const validTheme = DEFAULT_EDITOR_SETTINGS.customThemes[0];
|
||||
const background = parseSettingsTransferFile(
|
||||
fileWith({
|
||||
customThemes: [{ ...validTheme, id: "t1", name: "T1", colors: { ...validTheme.colors, background: {} } }],
|
||||
}),
|
||||
);
|
||||
expect(background.ok).toBe(false);
|
||||
if (background.ok) return;
|
||||
expect(background.error.detail).toContain("customThemes");
|
||||
|
||||
const foreground = parseSettingsTransferFile(
|
||||
fileWith({
|
||||
customThemes: [{ ...validTheme, id: "t1", name: "T1", colors: { ...validTheme.colors, foreground: 5 } }],
|
||||
}),
|
||||
);
|
||||
expect(foreground.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts custom theme items with optional background and foreground colors", () => {
|
||||
const base = DEFAULT_EDITOR_SETTINGS.customThemes[0];
|
||||
const validTheme = { ...base, id: "t1", name: "T1", colors: { ...base.colors, background: "#282c34", foreground: "#abb2bf" } };
|
||||
const result = parseSettingsTransferFile(fileWith({ customThemes: [validTheme] }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.editorSettings.customThemes).toEqual([validTheme]);
|
||||
});
|
||||
|
||||
it("rejects null custom theme items instead of crashing", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ customThemes: [null] }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) return;
|
||||
expect(result.error.code).toBe("invalid-fields");
|
||||
expect(result.error.detail).toContain("customThemes");
|
||||
});
|
||||
|
||||
it("accepts complete custom themes and data grid color schemes", () => {
|
||||
const validTheme = { ...DEFAULT_EDITOR_SETTINGS.customThemes[0], id: "t1", name: "T1" };
|
||||
const scheme = {
|
||||
id: "s1",
|
||||
name: "S1",
|
||||
colors: { integer: "#1d4ed8", numeric: "#0e7490", string: "#166534", boolean: "#c2410c", temporal: "#7e22ce", structured: "#be185d", identifier: "#92400e", binary: "#b91c1c", spatial: "#047857" },
|
||||
};
|
||||
const result = parseSettingsTransferFile(fileWith({ customThemes: [validTheme], dataGridTypeColorSchemes: [scheme] }));
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.value.editorSettings.customThemes).toEqual([validTheme]);
|
||||
expect(result.value.editorSettings.dataGridTypeColorSchemes).toEqual([scheme]);
|
||||
});
|
||||
|
||||
it("rejects data grid color schemes claiming the reserved auto id", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ dataGridTypeColorSchemes: [{ id: "auto", name: "S" }] }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.detail).toContain("dataGridTypeColorSchemes");
|
||||
});
|
||||
|
||||
it("rejects malformed snippet items instead of substituting defaults", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ snippets: [{}] }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.detail).toContain("snippets");
|
||||
});
|
||||
|
||||
it("rejects malformed sql shortcut items", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ sqlShortcuts: [{ id: "s1" }] }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.detail).toContain("sqlShortcuts");
|
||||
});
|
||||
|
||||
it("rejects non-string entries in string array settings", () => {
|
||||
const redis = parseSettingsTransferFile(fileWith({ redisKeyTemplates: [5] }));
|
||||
expect(redis.ok).toBe(false);
|
||||
if (redis.ok) return;
|
||||
expect(redis.error.detail).toContain("redisKeyTemplates");
|
||||
|
||||
const prefixes = parseSettingsTransferFile(fileWith({ sidebarHiddenTablePrefixes: [null] }));
|
||||
expect(prefixes.ok).toBe(false);
|
||||
if (prefixes.ok) return;
|
||||
expect(prefixes.error.detail).toContain("sidebarHiddenTablePrefixes");
|
||||
});
|
||||
|
||||
it("rejects formatter settings missing a required option", () => {
|
||||
const partialFormatter: Record<string, unknown> = { ...DEFAULT_EDITOR_SETTINGS.sqlFormatter };
|
||||
delete partialFormatter.keywordCase;
|
||||
const result = parseSettingsTransferFile(fileWith({ sqlFormatter: partialFormatter }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.detail).toContain("sqlFormatter");
|
||||
});
|
||||
|
||||
it("rejects malformed sql variable syntax overrides", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ sqlVariableSyntaxOverrides: { mysql: { positional: "yes" } } }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.detail).toContain("sqlVariableSyntaxOverrides");
|
||||
});
|
||||
|
||||
it("rejects shortcut overrides whose known keys are not strings", () => {
|
||||
const result = parseSettingsTransferFile(fileWith({ shortcuts: { executeSql: 5 } }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.detail).toContain("shortcuts");
|
||||
});
|
||||
|
||||
it("maps csvQuoteMode into the data category", () => {
|
||||
expect(transferCategoryForKey("csvQuoteMode")).toBe("data");
|
||||
expect(collectTransferCategories(["csvQuoteMode", "pageSize"])).toEqual(["data"]);
|
||||
});
|
||||
});
|
||||
@@ -146,6 +146,20 @@ export function editorSettingsDraftFromSettings(settings: EditorSettings): Edito
|
||||
return draft;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draft-shaped, per-key-normalized values for exactly the keys present in
|
||||
* `settings`. Used for partial updates (e.g. settings import) where keys the
|
||||
* input does not contain must leave the target state untouched.
|
||||
*/
|
||||
export function editorSettingsDraftPatchFromSettings(settings: Partial<EditorSettings>): Partial<EditorSettingsDraft> {
|
||||
const patch: Partial<EditorSettingsDraft> = {};
|
||||
for (const key of EDITOR_SETTINGS_DRAFT_KEYS) {
|
||||
if (!(key in settings)) continue;
|
||||
(patch as Record<string, unknown>)[key] = cloneDraftValue(normalizedDraftValue(key, settings[key])) as never;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
export function editorSettingsPatchFromDraft(draft: EditorSettingsDraft, base: EditorSettingsDraft): Partial<EditorSettings> {
|
||||
const patch: Partial<EditorSettings> = {};
|
||||
for (const key of EDITOR_SETTINGS_DRAFT_KEYS) {
|
||||
|
||||
@@ -246,6 +246,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
|
||||
{ id: "security", category: "security", titleKey: "settings.securityTab", targetId: "security", visible: webOnly },
|
||||
{ id: "security-password", category: "security", titleKey: "auth.changePassword", targetId: "security", visible: webOnly },
|
||||
{ id: "about-support", category: "about", titleKey: "settings.supportInfoTitle", descriptionKey: "settings.supportInfoDescription", targetId: "about" },
|
||||
{ id: "about-transfer", category: "about", titleKey: "settings.settingsTransferTitle", descriptionKey: "settings.settingsTransferDescription", targetId: "about" },
|
||||
{ id: "about-update", category: "about", titleKey: "settings.updateDownloadSource", descriptionKey: "settings.updateDownloadSourceDescription", targetId: "about" },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,552 @@
|
||||
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 { 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";
|
||||
import { isCompleteSqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
|
||||
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.
|
||||
*/
|
||||
|
||||
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;
|
||||
|
||||
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";
|
||||
|
||||
export interface SettingsTransferParseError {
|
||||
code: SettingsTransferParseErrorCode;
|
||||
/** Offending format version, or the first invalid field names. */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ParsedSettingsTransfer {
|
||||
formatVersion: number;
|
||||
appVersion?: string;
|
||||
exportedAt?: string;
|
||||
/** Validated, normalized values for exactly the keys present in the file. */
|
||||
editorSettings: Partial<EditorSettings>;
|
||||
categories: SettingsTransferCategoryId[];
|
||||
}
|
||||
|
||||
export interface SettingsTransferExportMeta {
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
export function buildSettingsTransferFilename(date = new Date()): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `dbx-settings-${year}-${month}-${day}.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
export function serializeSettingsTransfer(settings: EditorSettings, meta: SettingsTransferExportMeta = {}): string {
|
||||
const payload = {
|
||||
formatVersion: SETTINGS_TRANSFER_FORMAT_VERSION,
|
||||
app: { name: "dbx", ...(meta.appVersion ? { version: meta.appVersion } : {}) },
|
||||
exportedAt: new Date().toISOString(),
|
||||
settings: {
|
||||
editor: editorSettingsDraftFromSettings(settings),
|
||||
},
|
||||
};
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
const SETTINGS_TRANSFER_CATEGORY_ORDER: readonly SettingsTransferCategoryId[] = ["appearance", "editor", "formatter", "navigation", "data", "shortcuts", "snippets", "other"];
|
||||
|
||||
const SETTINGS_TRANSFER_CATEGORY_KEYS: Record<SettingsTransferCategoryId, readonly EditorSettingsDraftKey[]> = {
|
||||
appearance: ["fontFamily", "fontSize", "tableFontFamily", "uiFontFamily", "uiScale", "theme", "customThemes", "activeCustomThemeId", "toolbarItems", "updateNotificationsEnabled"],
|
||||
editor: [
|
||||
"executeMode",
|
||||
"defaultTransactionMode",
|
||||
"executeAllOnBlankLine",
|
||||
"showExecutionTargetPicker",
|
||||
"showStatementRunButtons",
|
||||
"showLineNumbers",
|
||||
"showCurrentStatementFrame",
|
||||
"showInsertValueHints",
|
||||
"autoAliasTables",
|
||||
"insertSpaceAfterCompletion",
|
||||
"sortCompletionColumnsAlphabetically",
|
||||
"selectFirstCompletionOnOpen",
|
||||
"completionTriggerMode",
|
||||
"wordWrap",
|
||||
"vimModeEnabled",
|
||||
"autoCloseBrackets",
|
||||
"sqlSemanticDiagnosticsMode",
|
||||
"confirmDangerousSqlExecution",
|
||||
"continueOnErrorOnBatch",
|
||||
"confirmUnsavedSqlClose",
|
||||
"appCloseUnsavedTabsMode",
|
||||
"savedSqlOpenTargetMode",
|
||||
"prefillNewQueryWithSelect",
|
||||
"generateSqlIncludeDatabaseName",
|
||||
"generateSqlQuoteIdentifiers",
|
||||
"formatSqlOnSqlFileSave",
|
||||
"showTableDdlHoverPreview",
|
||||
"sqlVariableSubstitutionEnabled",
|
||||
"sqlVariableSyntaxOverrides",
|
||||
],
|
||||
formatter: ["sqlFormatter"],
|
||||
navigation: [
|
||||
"appLayout",
|
||||
"tabLayout",
|
||||
"tabPlacement",
|
||||
"tabGroupMode",
|
||||
"tabSortMode",
|
||||
"sidebarActivation",
|
||||
"sidebarObjectDisplay",
|
||||
"routineSourceOpenMode",
|
||||
"sidebarTableSearchEnabled",
|
||||
"autoSelectActiveSidebarNode",
|
||||
"sidebarBrowseObjectsOnDatabaseActivation",
|
||||
"openTabsRestoreMode",
|
||||
"disconnectTabHandlingMode",
|
||||
"dataTabReuseMode",
|
||||
"openDataTabsNextToActive",
|
||||
"clickTableNavigationTarget",
|
||||
"sidebarObjectInfoMode",
|
||||
"sidebarAllowHorizontalScroll",
|
||||
"sidebarShowTooltips",
|
||||
"sidebarIndent",
|
||||
"sidebarFontSize",
|
||||
"sidebarHiddenTablePrefixes",
|
||||
"sidebarCopyTableNameSeparator",
|
||||
"sidebarCopyTableNameIncludeSchema",
|
||||
],
|
||||
data: [
|
||||
"showColumnCommentsInHeader",
|
||||
"showColumnTypesInHeader",
|
||||
"dataGridShowTransposeFieldMetadata",
|
||||
"colorizeDataGridCellTypes",
|
||||
"dataGridTypeColorSchemes",
|
||||
"activeDataGridTypeColorSchemeId",
|
||||
"showIndexIndicatorsInHeader",
|
||||
"compactColumnHeaderActions",
|
||||
"dataGridQuickEntry",
|
||||
"dataGridFilterEditorView",
|
||||
"dataGridAutoHideFilterBuilder",
|
||||
"dataGridTextFilterPanelHeight",
|
||||
"multiStatementDefaultView",
|
||||
"dataGridAutoTransposeSingleRow",
|
||||
"dataGridCellDetailButtonVisible",
|
||||
"dataGridCrosshairHighlight",
|
||||
"flatteningMultiLineText",
|
||||
"pageSize",
|
||||
"tableOpenPageSize",
|
||||
"queryResultMaxRowsEnabled",
|
||||
"queryResultMaxRows",
|
||||
"infiniteScroll",
|
||||
"regexMaxMatchCount",
|
||||
"autoCalculateTotalRows",
|
||||
"tableColumnTemplateFields",
|
||||
"redisKeyTemplates",
|
||||
"exportBatchSize",
|
||||
"csvQuoteMode",
|
||||
"exportRowLimitEnabled",
|
||||
"exportRowLimit",
|
||||
"queryExportKeysetOptimizationEnabled",
|
||||
"globalDateTimeDisplayFormat",
|
||||
"globalDateTimeExportFormat",
|
||||
"globalDateTimeImportFormat",
|
||||
],
|
||||
shortcuts: ["shortcuts", "sqlShortcuts"],
|
||||
snippets: ["snippets"],
|
||||
other: ["updateDownloadSource"],
|
||||
};
|
||||
|
||||
const KEY_TO_CATEGORY = new Map<string, SettingsTransferCategoryId>();
|
||||
for (const [category, keys] of Object.entries(SETTINGS_TRANSFER_CATEGORY_KEYS) as [SettingsTransferCategoryId, readonly EditorSettingsDraftKey[]][]) {
|
||||
for (const key of keys) KEY_TO_CATEGORY.set(key, category);
|
||||
}
|
||||
|
||||
export function transferCategoryForKey(key: string): SettingsTransferCategoryId | undefined {
|
||||
return KEY_TO_CATEGORY.get(key);
|
||||
}
|
||||
|
||||
export function collectTransferCategories(keys: readonly string[]): SettingsTransferCategoryId[] {
|
||||
const present = new Set<SettingsTransferCategoryId>();
|
||||
for (const key of keys) {
|
||||
const category = KEY_TO_CATEGORY.get(key);
|
||||
if (category) present.add(category);
|
||||
}
|
||||
return sortTransferCategories(present);
|
||||
}
|
||||
|
||||
/** Orders category ids by their fixed display order. */
|
||||
export function sortTransferCategories(categories: Iterable<SettingsTransferCategoryId>): SettingsTransferCategoryId[] {
|
||||
const present = new Set(categories);
|
||||
return SETTINGS_TRANSFER_CATEGORY_ORDER.filter((category) => present.has(category));
|
||||
}
|
||||
|
||||
/** i18n keys used to render the affected categories in the import dialog. */
|
||||
export const SETTINGS_TRANSFER_CATEGORY_LABEL_KEYS: Record<SettingsTransferCategoryId, string> = {
|
||||
appearance: "settings.appearanceTab",
|
||||
editor: "settings.editorTab",
|
||||
formatter: "settings.sqlFormatterTab",
|
||||
navigation: "settings.navigationTab",
|
||||
data: "settings.dataTab",
|
||||
shortcuts: "settings.shortcutsTab",
|
||||
snippets: "settings.snippetsTab",
|
||||
other: "settings.settingsTransferCategoryOther",
|
||||
};
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* UTF-8 needs 1–3 bytes per UTF-16 code unit, so the character count alone
|
||||
* settles every text outside the ambiguous expansion band. The precise byte
|
||||
* count — which allocates an encoded copy of the whole text — is only built
|
||||
* for inputs whose size could still tip over the limit after expansion.
|
||||
*/
|
||||
function exceedsSettingsTransferSizeLimit(text: string): boolean {
|
||||
if (text.length > MAX_SETTINGS_TRANSFER_FILE_BYTES) return true;
|
||||
if (text.length * 3 <= MAX_SETTINGS_TRANSFER_FILE_BYTES) return false;
|
||||
return new TextEncoder().encode(text).byteLength > MAX_SETTINGS_TRANSFER_FILE_BYTES;
|
||||
}
|
||||
|
||||
function deepClone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function jsonKindOf(value: unknown): string {
|
||||
return Array.isArray(value) ? "array" : typeof value;
|
||||
}
|
||||
|
||||
// Expected JSON kind per whitelisted key, derived from the defaults. The store
|
||||
// normalizer is defensive but a few keys pass values through unchanged, so the
|
||||
// kind check is what rejects wrong-typed fields (e.g. a string fontSize).
|
||||
const EXPECTED_JSON_KINDS = new Map<string, string>(EDITOR_SETTINGS_DRAFT_KEYS.map((key) => [key, jsonKindOf((DEFAULT_EDITOR_SETTINGS as unknown as Record<string, unknown>)[key])]));
|
||||
|
||||
/**
|
||||
* Keys whose store normalizer keeps any non-nullish value as-is (only a
|
||||
* nullish fallback). For these, the round-trip equality check cannot catch a
|
||||
* bad value: `fontSize: 0` or `appLayout: "invalid"` would survive untouched
|
||||
* and later be persisted. Each entry restates the domain the settings UI and
|
||||
* the clamping helpers enforce, so importing an out-of-range number, a broken
|
||||
* enum or a non-boolean flag refuses the whole file instead.
|
||||
*/
|
||||
const PASS_THROUGH_BOOLEAN_KEYS = [
|
||||
"wordWrap",
|
||||
"showExecutionTargetPicker",
|
||||
"autoAliasTables",
|
||||
"confirmDangerousSqlExecution",
|
||||
"confirmUnsavedSqlClose",
|
||||
"showColumnCommentsInHeader",
|
||||
"showColumnTypesInHeader",
|
||||
"colorizeDataGridCellTypes",
|
||||
"showIndexIndicatorsInHeader",
|
||||
"compactColumnHeaderActions",
|
||||
"dataGridQuickEntry",
|
||||
"flatteningMultiLineText",
|
||||
"infiniteScroll",
|
||||
"autoCalculateTotalRows",
|
||||
"autoSelectActiveSidebarNode",
|
||||
"sidebarAllowHorizontalScroll",
|
||||
"sidebarShowTooltips",
|
||||
"updateNotificationsEnabled",
|
||||
] as const satisfies readonly EditorSettingsDraftKey[];
|
||||
|
||||
const PASS_THROUGH_FIELD_VALIDATORS: Partial<Record<EditorSettingsDraftKey, (value: unknown) => boolean>> = {
|
||||
// The editor font slider and the Ctrl+wheel zoom both clamp to this range.
|
||||
fontSize: (value) => typeof value === "number" && Number.isFinite(value) && value >= EDITOR_MIN_FONT_SIZE && value <= EDITOR_MAX_FONT_SIZE,
|
||||
appLayout: (value) => value === "separated" || value === "classic",
|
||||
activeCustomThemeId: (value) => typeof value === "string" && value.trim().length > 0,
|
||||
// normalizeToolbarItems keeps unknown/typed values for every known key, so
|
||||
// each one must already be the boolean the UI writes, and no extra key may
|
||||
// appear.
|
||||
toolbarItems: (value) => isPlainObject(value) && Object.keys(DEFAULT_TOOLBAR_ITEMS).every((key) => typeof value[key] === "boolean") && Object.keys(value).every((key) => key in DEFAULT_TOOLBAR_ITEMS),
|
||||
...Object.fromEntries(PASS_THROUGH_BOOLEAN_KEYS.map((key) => [key, (value: unknown) => typeof value === "boolean"])),
|
||||
};
|
||||
|
||||
function isNonEmptyTrimmedString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function isArrayOfShape(value: unknown, isItem: (item: unknown) => boolean): boolean {
|
||||
return Array.isArray(value) && value.every(isItem);
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): boolean {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
}
|
||||
|
||||
function isNonEmptyStringArray(value: unknown): boolean {
|
||||
return isStringArray(value) && (value as string[]).every((item) => item.trim().length > 0);
|
||||
}
|
||||
|
||||
/** Required color keys of a custom editor theme, keyed by expected JSON kind. */
|
||||
function valueKindShape(defaults: Record<string, unknown>): Record<string, string> {
|
||||
return Object.fromEntries(Object.entries(defaults).map(([key, value]) => [key, typeof value]));
|
||||
}
|
||||
|
||||
const CUSTOM_THEME_COLORS_SHAPE = valueKindShape(DEFAULT_CUSTOM_THEME_COLORS as unknown as Record<string, unknown>);
|
||||
const CUSTOM_THEME_DDL_COLORS_SHAPE = valueKindShape(DEFAULT_CUSTOM_THEME_DDL_COLORS as unknown as Record<string, unknown>);
|
||||
|
||||
/**
|
||||
* Optional color keys of a custom editor theme. They are absent from
|
||||
* DEFAULT_CUSTOM_THEME_COLORS, so the defaults-derived shape above cannot
|
||||
* cover them and a wrong-typed value (e.g. `background: {}`) would otherwise
|
||||
* pass validation and reach the persisted settings.
|
||||
*/
|
||||
const CUSTOM_THEME_OPTIONAL_COLOR_KEYS = ["background", "foreground"] as const;
|
||||
|
||||
function hasValidOptionalColorKinds(value: unknown): boolean {
|
||||
if (!isPlainObject(value)) return false;
|
||||
return CUSTOM_THEME_OPTIONAL_COLOR_KEYS.every((key) => value[key] === undefined || typeof value[key] === "string");
|
||||
}
|
||||
|
||||
function hasRequiredValueKinds(value: unknown, shape: Record<string, string>): boolean {
|
||||
if (!isPlainObject(value)) return false;
|
||||
return Object.entries(shape).every(([key, kind]) => typeof value[key] === kind);
|
||||
}
|
||||
|
||||
function isCustomThemeItem(value: unknown): boolean {
|
||||
return isPlainObject(value) && isNonEmptyTrimmedString(value.id) && isNonEmptyTrimmedString(value.name) && hasRequiredValueKinds(value.colors, CUSTOM_THEME_COLORS_SHAPE) && hasValidOptionalColorKinds(value.colors) && hasRequiredValueKinds(value.ddlColors, CUSTOM_THEME_DDL_COLORS_SHAPE);
|
||||
}
|
||||
|
||||
// The store normalizer only writes #rrggbb values, matching normalizeDataGridTypeColors.
|
||||
const DATA_GRID_TYPE_HEX_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
|
||||
|
||||
function isDataGridTypeColorSchemeItem(value: unknown): boolean {
|
||||
if (!isPlainObject(value)) return false;
|
||||
// The auto id is the "follow the built-in palette" sentinel, never a scheme.
|
||||
if (!isNonEmptyTrimmedString(value.id) || value.id === DATA_GRID_TYPE_COLOR_SCHEME_AUTO_ID) return false;
|
||||
if (!isNonEmptyTrimmedString(value.name)) return false;
|
||||
const colors = value.colors;
|
||||
if (!isPlainObject(colors)) return false;
|
||||
return DATA_GRID_TYPE_COLOR_KEYS.every((key) => {
|
||||
const color = colors[key];
|
||||
return typeof color === "string" && DATA_GRID_TYPE_HEX_COLOR_RE.test(color);
|
||||
});
|
||||
}
|
||||
|
||||
function isSqlSnippetItem(value: unknown): boolean {
|
||||
if (!isPlainObject(value)) return false;
|
||||
if (!isNonEmptyTrimmedString(value.id) || !isNonEmptyTrimmedString(value.label) || !isNonEmptyTrimmedString(value.prefix) || typeof value.body !== "string") return false;
|
||||
return value.enabled === undefined || typeof value.enabled === "boolean";
|
||||
}
|
||||
|
||||
function isSqlShortcutActionItem(value: unknown): boolean {
|
||||
if (!isPlainObject(value)) return false;
|
||||
if (!isNonEmptyTrimmedString(value.id) || !isNonEmptyTrimmedString(value.label) || typeof value.shortcut !== "string" || typeof value.sql !== "string") return false;
|
||||
return value.enabled === undefined || typeof value.enabled === "boolean";
|
||||
}
|
||||
|
||||
const SHORTCUT_ACTION_IDS = new Set<string>(SHORTCUT_DEFINITIONS.map((definition) => definition.id));
|
||||
|
||||
/** Normalized shape: exactly the known action ids, each bound to a string. */
|
||||
function isShortcutSettingsShape(value: unknown): boolean {
|
||||
return isPlainObject(value) && Object.entries(value).every(([key, entry]) => SHORTCUT_ACTION_IDS.has(key) && typeof entry === "string");
|
||||
}
|
||||
|
||||
/** Raw shape: overrides for known actions must be strings; unknown keys stay ignored for forward compatibility. */
|
||||
function isRawShortcutSettingsShape(value: unknown): boolean {
|
||||
return isPlainObject(value) && Object.entries(value).every(([key, entry]) => !SHORTCUT_ACTION_IDS.has(key) || typeof entry === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw shape: every known toolbar key must already be a boolean.
|
||||
* normalizeToolbarItems coerces `exclusiveRightSidebarPanels` with `!== false`
|
||||
* (and fills the rest with nullish defaults), so a non-boolean like "no" would
|
||||
* otherwise be silently turned into `true` and pass the normalized round-trip
|
||||
* check. Unknown keys stay ignored for forward compatibility.
|
||||
*/
|
||||
function isRawToolbarItemsShape(value: unknown): boolean {
|
||||
return isPlainObject(value) && Object.entries(value).every(([key, entry]) => !(key in DEFAULT_TOOLBAR_ITEMS) || typeof entry === "boolean");
|
||||
}
|
||||
|
||||
const SQL_VARIABLE_SYNTAX_KEY_SET = new Set<string>(SQL_VARIABLE_SYNTAX_KEYS);
|
||||
|
||||
function isSqlVariableSyntaxOverridesShape(value: unknown, isAllowedToggle: (toggle: unknown) => boolean): boolean {
|
||||
if (!isPlainObject(value)) return false;
|
||||
return Object.values(value).every((entry) => isPlainObject(entry) && Object.entries(entry).every(([key, toggle]) => SQL_VARIABLE_SYNTAX_KEY_SET.has(key) && isAllowedToggle(toggle)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Full nested-schema checks for the structured (array/object) fields, run on
|
||||
* the normalized value. The store normalizer only sanitizes some of them —
|
||||
* notably `normalizeEditorSettings` keeps `customThemes` items that lack
|
||||
* `id` or `name` — so without these checks a file like
|
||||
* `{"customThemes": [{}]}` would import cleanly and later corrupt the
|
||||
* persisted settings.
|
||||
*/
|
||||
const NESTED_FIELD_VALIDATORS: Partial<Record<EditorSettingsDraftKey, (value: unknown) => boolean>> = {
|
||||
customThemes: (value) => isArrayOfShape(value, isCustomThemeItem),
|
||||
dataGridTypeColorSchemes: (value) => isArrayOfShape(value, isDataGridTypeColorSchemeItem),
|
||||
tableColumnTemplateFields: isNonEmptyStringArray,
|
||||
shortcuts: isShortcutSettingsShape,
|
||||
sqlFormatter: isCompleteSqlFormatterSettings,
|
||||
sidebarHiddenTablePrefixes: isNonEmptyStringArray,
|
||||
redisKeyTemplates: isNonEmptyStringArray,
|
||||
snippets: (value) => isArrayOfShape(value, isSqlSnippetItem),
|
||||
sqlShortcuts: (value) => isArrayOfShape(value, isSqlShortcutActionItem),
|
||||
sqlVariableSyntaxOverrides: (value) => isSqlVariableSyntaxOverridesShape(value, (toggle) => toggle === false),
|
||||
};
|
||||
|
||||
/**
|
||||
* The same nested schemas applied to the raw file values before
|
||||
* normalization. This rejects payloads the normalizer would silently repair
|
||||
* by dropping entries or substituting defaults — importing those would
|
||||
* overwrite the user's data (custom schemes, snippets, formatter options, …)
|
||||
* with sanitized defaults instead of surfacing the malformed file. Running
|
||||
* them before the normalizer also keeps `normalizeEditorSettings` from
|
||||
* throwing on malformed containers it does not sanitize (e.g. a null item
|
||||
* inside `customThemes`).
|
||||
*/
|
||||
const RAW_STRUCTURED_FIELD_VALIDATORS: Partial<Record<EditorSettingsDraftKey, (value: unknown) => boolean>> = {
|
||||
customThemes: (value) => isArrayOfShape(value, isCustomThemeItem),
|
||||
dataGridTypeColorSchemes: (value) => isArrayOfShape(value, isDataGridTypeColorSchemeItem),
|
||||
tableColumnTemplateFields: isStringArray,
|
||||
shortcuts: isRawShortcutSettingsShape,
|
||||
toolbarItems: isRawToolbarItemsShape,
|
||||
sqlFormatter: isCompleteSqlFormatterSettings,
|
||||
sidebarHiddenTablePrefixes: isStringArray,
|
||||
redisKeyTemplates: isStringArray,
|
||||
snippets: (value) => isArrayOfShape(value, isSqlSnippetItem),
|
||||
sqlShortcuts: (value) => isArrayOfShape(value, isSqlShortcutActionItem),
|
||||
sqlVariableSyntaxOverrides: (value) => isSqlVariableSyntaxOverridesShape(value, (toggle) => typeof toggle === "boolean"),
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses and validates an imported settings file. Validation is all-or-
|
||||
* nothing: the caller only receives values once every whitelisted field in
|
||||
* the file passed the checks, so a rejected file can never partially modify
|
||||
* the settings draft.
|
||||
*
|
||||
* Raw JSON-kind and raw structured checks (see
|
||||
* RAW_STRUCTURED_FIELD_VALIDATORS) run first, before the normalizer, so a
|
||||
* malformed container is reported as `invalid-fields` instead of throwing
|
||||
* inside a store normalizer. Scalar values (boolean/number/string) must
|
||||
* survive the store's own normalizer unchanged — an out-of-domain enum or a
|
||||
* clamped number comes back different and rejects the import. Pass-through
|
||||
* fields (see PASS_THROUGH_FIELD_VALIDATORS) get an explicit domain check
|
||||
* instead, because the normalizer keeps them as-is. Objects and arrays are
|
||||
* sanitized through the same normalizer and must additionally satisfy a full
|
||||
* 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 } {
|
||||
if (exceedsSettingsTransferSizeLimit(text)) {
|
||||
return { ok: false, error: { code: "too-large" } };
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return { ok: false, error: { code: "invalid-json" } };
|
||||
}
|
||||
if (!isPlainObject(parsed)) return { ok: false, error: { code: "invalid-structure" } };
|
||||
|
||||
const formatVersion = parsed.formatVersion;
|
||||
if (typeof formatVersion !== "number" || !Number.isInteger(formatVersion) || formatVersion !== SETTINGS_TRANSFER_FORMAT_VERSION) {
|
||||
return { ok: false, error: { code: "unsupported-version", detail: typeof formatVersion === "number" ? String(formatVersion) : undefined } };
|
||||
}
|
||||
|
||||
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" } };
|
||||
|
||||
const presentKeys = EDITOR_SETTINGS_DRAFT_KEYS.filter((key) => key in editor);
|
||||
if (presentKeys.length === 0) 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
|
||||
// expose `name`) and throw on malformed input instead of sanitizing it, so
|
||||
// a broken file must be rejected here and surface as `invalid-fields`
|
||||
// rather than crashing the parse.
|
||||
const invalidKeys: string[] = [];
|
||||
for (const key of presentKeys) {
|
||||
const raw = editor[key];
|
||||
if (jsonKindOf(raw) !== EXPECTED_JSON_KINDS.get(key)) {
|
||||
invalidKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
const rawValidator = RAW_STRUCTURED_FIELD_VALIDATORS[key];
|
||||
if (rawValidator && !rawValidator(raw)) {
|
||||
invalidKeys.push(key);
|
||||
}
|
||||
}
|
||||
if (invalidKeys.length > 0) {
|
||||
return { ok: false, error: { code: "invalid-fields", detail: invalidKeys.slice(0, 5).join(", ") } };
|
||||
}
|
||||
|
||||
// Run the imported values through the same normalizer the store applies on
|
||||
// load. Defaults are cloned first so the shared constant can never be
|
||||
// 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];
|
||||
}
|
||||
const normalized = normalizeEditorSettings(probe);
|
||||
|
||||
const imported: Partial<EditorSettings> = {};
|
||||
for (const key of presentKeys) {
|
||||
const raw = editor[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)) {
|
||||
invalidKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
if (typeof raw === "boolean" || typeof raw === "number" || typeof raw === "string") {
|
||||
if (typeof raw !== typeof value || value !== raw) {
|
||||
invalidKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
imported[key as EditorSettingsDraftKey] = deepClone(value) as never;
|
||||
} else if (Array.isArray(raw)) {
|
||||
if (!Array.isArray(value)) {
|
||||
invalidKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
imported[key as EditorSettingsDraftKey] = deepClone(value) as never;
|
||||
} else if (isPlainObject(raw)) {
|
||||
if (!isPlainObject(value)) {
|
||||
invalidKeys.push(key);
|
||||
continue;
|
||||
}
|
||||
imported[key as EditorSettingsDraftKey] = deepClone(value) as never;
|
||||
} else {
|
||||
invalidKeys.push(key);
|
||||
}
|
||||
}
|
||||
if (invalidKeys.length > 0) {
|
||||
return { ok: false, error: { code: "invalid-fields", detail: invalidKeys.slice(0, 5).join(", ") } };
|
||||
}
|
||||
|
||||
const app = isPlainObject(parsed.app) ? parsed.app : {};
|
||||
const rawAppVersion = typeof app.version === "string" ? app.version.trim() : "";
|
||||
const rawExportedAt = typeof parsed.exportedAt === "string" ? parsed.exportedAt : "";
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
formatVersion: SETTINGS_TRANSFER_FORMAT_VERSION,
|
||||
...(rawAppVersion ? { appVersion: rawAppVersion } : {}),
|
||||
...(rawExportedAt ? { exportedAt: rawExportedAt } : {}),
|
||||
editorSettings: imported,
|
||||
categories: collectTransferCategories(Object.keys(imported)),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -208,6 +208,21 @@ export function normalizeSqlFormatterSettings(value: unknown): SqlFormatterSetti
|
||||
return sqlFormatterOptionSettings(optionSource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete nested-schema check for an already-shaped settings object: every
|
||||
* formatter option must be present with a valid value, and no unknown option
|
||||
* may appear. Unlike the normalizer this never fills defaults, so callers can
|
||||
* reject partially malformed payloads instead of silently substituting them.
|
||||
*/
|
||||
export function isCompleteSqlFormatterSettings(value: unknown): value is SqlFormatterSettings {
|
||||
if (!isObject(value)) return false;
|
||||
for (const key of SQL_FORMATTER_OPTION_KEYS) {
|
||||
if (!(key in value)) return false;
|
||||
if (!SQL_FORMATTER_OPTION_VALIDATORS[key]((value as Record<string, unknown>)[key])) return false;
|
||||
}
|
||||
return Object.keys(value).every((key) => SQL_FORMATTER_OPTION_KEYS.has(key as keyof SqlFormatterOptionSettings));
|
||||
}
|
||||
|
||||
export function sqlFormatterConfigFile(settings: unknown): SqlFormatterConfigFile {
|
||||
const normalized = normalizeSqlFormatterSettings(settings);
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user