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>
|
||||
Reference in new issue
Block a user