feat(settings): settings import/export and WebView2 clipboard history

This commit is contained in:
githubasda
2026-09-08 00:43:45 +08:00
committed by GitHub
parent d44798f70f
commit 7961e688f2
19 changed files with 2316 additions and 216 deletions
@@ -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">
@@ -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>
+26
View File
@@ -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",
+26
View File
@@ -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",
+26
View File
@@ -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",
+26
View File
@@ -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グループ",
+26
View File
@@ -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: "위챗 그룹",
+26
View File
@@ -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",
+26
View File
@@ -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: "微信交流群",
+26
View File
@@ -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 {
+293 -203
View File
@@ -1,79 +1,79 @@
{
"repository": "t8y2/dbx",
"generatedAt": "2026-09-01T20:25:01.044Z",
"stars": 17677,
"generatedAt": "2026-09-06T19:53:19.928Z",
"stars": 18128,
"contributors": [
{
"login": "t8y2",
"avatarUrl": "https://avatars.githubusercontent.com/u/77960507?v=4",
"profileUrl": "https://github.com/t8y2",
"commits": 3336,
"mergedPullRequests": 24,
"commits": 3421,
"mergedPullRequests": 26,
"firstContributionAt": "2026-04-30T08:14:51Z",
"latestContributionAt": "2026-09-01T09:38:19Z"
"latestContributionAt": "2026-09-02T10:17:27Z"
},
{
"login": "zipg",
"avatarUrl": "https://avatars.githubusercontent.com/u/4047349?v=4",
"profileUrl": "https://github.com/zipg",
"commits": 668,
"mergedPullRequests": 669,
"commits": 711,
"mergedPullRequests": 712,
"firstContributionAt": "2026-06-22T06:10:59Z",
"latestContributionAt": "2026-09-01T16:27:45Z"
"latestContributionAt": "2026-09-06T16:54:59Z"
},
{
"login": "eryajf",
"avatarUrl": "https://avatars.githubusercontent.com/u/33259379?v=4",
"profileUrl": "https://github.com/eryajf",
"commits": 202,
"mergedPullRequests": 201,
"commits": 221,
"mergedPullRequests": 221,
"firstContributionAt": "2026-06-08T11:45:45Z",
"latestContributionAt": "2026-09-01T15:36:15Z"
"latestContributionAt": "2026-09-06T18:18:29Z"
},
{
"login": "q396921921",
"avatarUrl": "https://avatars.githubusercontent.com/u/37297228?v=4",
"profileUrl": "https://github.com/q396921921",
"commits": 106,
"mergedPullRequests": 106,
"commits": 144,
"mergedPullRequests": 144,
"firstContributionAt": "2026-07-20T13:41:21Z",
"latestContributionAt": "2026-09-01T08:21:40Z"
"latestContributionAt": "2026-09-06T14:14:30Z"
},
{
"login": "Abeautifulsnow",
"avatarUrl": "https://avatars.githubusercontent.com/u/28704977?v=4",
"profileUrl": "https://github.com/Abeautifulsnow",
"commits": 105,
"mergedPullRequests": 93,
"commits": 111,
"mergedPullRequests": 99,
"firstContributionAt": "2026-05-06T03:16:57Z",
"latestContributionAt": "2026-09-01T03:01:00Z"
"latestContributionAt": "2026-09-04T12:38:26Z"
},
{
"login": "CN-Scars",
"avatarUrl": "https://avatars.githubusercontent.com/u/57054419?v=4",
"profileUrl": "https://github.com/CN-Scars",
"commits": 78,
"mergedPullRequests": 78,
"commits": 90,
"mergedPullRequests": 90,
"firstContributionAt": "2026-05-30T10:02:05Z",
"latestContributionAt": "2026-09-01T15:45:33Z"
},
{
"login": "onenewcode",
"avatarUrl": "https://avatars.githubusercontent.com/u/94808747?v=4",
"profileUrl": "https://github.com/onenewcode",
"commits": 70,
"mergedPullRequests": 70,
"firstContributionAt": "2026-06-30T01:53:31Z",
"latestContributionAt": "2026-09-01T05:18:42Z"
"latestContributionAt": "2026-09-04T17:03:31Z"
},
{
"login": "0verme",
"avatarUrl": "https://avatars.githubusercontent.com/u/24986278?v=4",
"profileUrl": "https://github.com/0verme",
"commits": 68,
"mergedPullRequests": 68,
"commits": 87,
"mergedPullRequests": 87,
"firstContributionAt": "2026-08-15T15:33:44Z",
"latestContributionAt": "2026-09-01T08:18:58Z"
"latestContributionAt": "2026-09-06T10:24:45Z"
},
{
"login": "onenewcode",
"avatarUrl": "https://avatars.githubusercontent.com/u/94808747?v=4",
"profileUrl": "https://github.com/onenewcode",
"commits": 72,
"mergedPullRequests": 72,
"firstContributionAt": "2026-06-30T01:53:31Z",
"latestContributionAt": "2026-09-03T03:56:46Z"
},
{
"login": "serenez",
@@ -88,10 +88,19 @@
"login": "onceMisery",
"avatarUrl": "https://avatars.githubusercontent.com/u/37446017?v=4",
"profileUrl": "https://github.com/onceMisery",
"commits": 46,
"mergedPullRequests": 44,
"commits": 47,
"mergedPullRequests": 45,
"firstContributionAt": "2026-06-10T14:19:43Z",
"latestContributionAt": "2026-08-24T10:35:18Z"
"latestContributionAt": "2026-09-05T14:22:55Z"
},
{
"login": "mapan0424",
"avatarUrl": "https://avatars.githubusercontent.com/u/45232878?v=4",
"profileUrl": "https://github.com/mapan0424",
"commits": 44,
"mergedPullRequests": 44,
"firstContributionAt": "2026-07-17T05:42:50Z",
"latestContributionAt": "2026-09-06T02:52:51Z"
},
{
"login": "Illuminated2020",
@@ -102,15 +111,6 @@
"firstContributionAt": "2026-05-12T09:13:51Z",
"latestContributionAt": "2026-05-12T09:22:36Z"
},
{
"login": "mapan0424",
"avatarUrl": "https://avatars.githubusercontent.com/u/45232878?v=4",
"profileUrl": "https://github.com/mapan0424",
"commits": 39,
"mergedPullRequests": 39,
"firstContributionAt": "2026-07-17T05:42:50Z",
"latestContributionAt": "2026-08-31T16:34:12Z"
},
{
"login": "xddcode",
"avatarUrl": "https://avatars.githubusercontent.com/u/48708512?v=4",
@@ -124,10 +124,10 @@
"login": "gggaiitx",
"avatarUrl": "https://avatars.githubusercontent.com/u/62124152?v=4",
"profileUrl": "https://github.com/gggaiitx",
"commits": 28,
"mergedPullRequests": 28,
"commits": 29,
"mergedPullRequests": 29,
"firstContributionAt": "2026-07-06T09:30:53Z",
"latestContributionAt": "2026-08-14T04:56:39Z"
"latestContributionAt": "2026-09-04T12:24:32Z"
},
{
"login": "lexmin0412",
@@ -147,6 +147,15 @@
"firstContributionAt": "2026-04-30T05:56:34Z",
"latestContributionAt": "2026-07-10T10:15:25Z"
},
{
"login": "ptma",
"avatarUrl": "https://avatars.githubusercontent.com/u/1761883?v=4",
"profileUrl": "https://github.com/ptma",
"commits": 23,
"mergedPullRequests": 23,
"firstContributionAt": "2026-06-09T08:33:11Z",
"latestContributionAt": "2026-09-04T05:42:30Z"
},
{
"login": "rarnu",
"avatarUrl": "https://avatars.githubusercontent.com/u/209014?v=4",
@@ -165,15 +174,6 @@
"firstContributionAt": "2026-06-26T03:29:22Z",
"latestContributionAt": "2026-07-16T17:17:49Z"
},
{
"login": "ptma",
"avatarUrl": "https://avatars.githubusercontent.com/u/1761883?v=4",
"profileUrl": "https://github.com/ptma",
"commits": 22,
"mergedPullRequests": 22,
"firstContributionAt": "2026-06-09T08:33:11Z",
"latestContributionAt": "2026-08-13T03:40:23Z"
},
{
"login": "yavon007",
"avatarUrl": "https://avatars.githubusercontent.com/u/27227092?v=4",
@@ -228,6 +228,15 @@
"firstContributionAt": "2026-08-03T09:45:43Z",
"latestContributionAt": "2026-08-20T10:50:22Z"
},
{
"login": "dienaso",
"avatarUrl": "https://avatars.githubusercontent.com/u/4003733?v=4",
"profileUrl": "https://github.com/dienaso",
"commits": 14,
"mergedPullRequests": 14,
"firstContributionAt": "2026-07-24T03:23:39Z",
"latestContributionAt": "2026-09-06T07:00:01Z"
},
{
"login": "juvevood",
"avatarUrl": "https://avatars.githubusercontent.com/u/1421513?v=4",
@@ -265,13 +274,13 @@
"latestContributionAt": "2026-08-26T13:38:10Z"
},
{
"login": "dienaso",
"avatarUrl": "https://avatars.githubusercontent.com/u/4003733?v=4",
"profileUrl": "https://github.com/dienaso",
"login": "DengQingNian",
"avatarUrl": "https://avatars.githubusercontent.com/u/117324885?v=4",
"profileUrl": "https://github.com/DengQingNian",
"commits": 11,
"mergedPullRequests": 11,
"firstContributionAt": "2026-07-24T03:23:39Z",
"latestContributionAt": "2026-08-18T04:43:31Z"
"mergedPullRequests": 14,
"firstContributionAt": "2026-06-02T16:19:01Z",
"latestContributionAt": "2026-09-02T17:45:09Z"
},
{
"login": "guoyongchang",
@@ -300,15 +309,6 @@
"firstContributionAt": "2026-04-30T09:05:18Z",
"latestContributionAt": "2026-05-09T06:42:25Z"
},
{
"login": "DengQingNian",
"avatarUrl": "https://avatars.githubusercontent.com/u/117324885?v=4",
"profileUrl": "https://github.com/DengQingNian",
"commits": 10,
"mergedPullRequests": 13,
"firstContributionAt": "2026-06-02T16:19:01Z",
"latestContributionAt": "2026-07-27T14:47:00Z"
},
{
"login": "chenhbb",
"avatarUrl": "https://avatars.githubusercontent.com/u/34821912?v=4",
@@ -327,6 +327,15 @@
"firstContributionAt": "2026-06-16T09:58:42Z",
"latestContributionAt": "2026-07-02T06:03:20Z"
},
{
"login": "hanxuanyu",
"avatarUrl": "https://avatars.githubusercontent.com/u/21254318?v=4",
"profileUrl": "https://github.com/hanxuanyu",
"commits": 9,
"mergedPullRequests": 9,
"firstContributionAt": "2026-08-08T05:23:28Z",
"latestContributionAt": "2026-09-05T14:17:02Z"
},
{
"login": "aiLi0617",
"avatarUrl": "https://avatars.githubusercontent.com/u/76681595?v=4",
@@ -336,6 +345,15 @@
"firstContributionAt": "2026-06-19T14:50:55Z",
"latestContributionAt": "2026-08-06T18:41:53Z"
},
{
"login": "sxhjlzl",
"avatarUrl": "https://avatars.githubusercontent.com/u/12149913?v=4",
"profileUrl": "https://github.com/sxhjlzl",
"commits": 8,
"mergedPullRequests": 8,
"firstContributionAt": "2026-08-19T03:31:47Z",
"latestContributionAt": "2026-09-02T17:13:06Z"
},
{
"login": "tianyifeng-druid",
"avatarUrl": "https://avatars.githubusercontent.com/u/67090734?v=4",
@@ -372,15 +390,6 @@
"firstContributionAt": "2026-07-31T04:15:42Z",
"latestContributionAt": "2026-08-29T20:17:33Z"
},
{
"login": "hanxuanyu",
"avatarUrl": "https://avatars.githubusercontent.com/u/21254318?v=4",
"profileUrl": "https://github.com/hanxuanyu",
"commits": 7,
"mergedPullRequests": 7,
"firstContributionAt": "2026-08-08T05:23:28Z",
"latestContributionAt": "2026-08-27T04:21:29Z"
},
{
"login": "Jinmoli",
"avatarUrl": "https://avatars.githubusercontent.com/u/49105556?v=4",
@@ -435,6 +444,15 @@
"firstContributionAt": "2026-07-02T15:29:53Z",
"latestContributionAt": "2026-08-12T14:22:34Z"
},
{
"login": "Lan-zk",
"avatarUrl": "https://avatars.githubusercontent.com/u/68137514?v=4",
"profileUrl": "https://github.com/Lan-zk",
"commits": 6,
"mergedPullRequests": 6,
"firstContributionAt": "2026-08-13T05:20:22Z",
"latestContributionAt": "2026-09-04T06:44:04Z"
},
{
"login": "luoianun",
"avatarUrl": "https://avatars.githubusercontent.com/u/20828060?v=4",
@@ -444,6 +462,15 @@
"firstContributionAt": "2026-06-17T05:09:05Z",
"latestContributionAt": "2026-07-01T11:05:45Z"
},
{
"login": "Mo-Moore",
"avatarUrl": "https://avatars.githubusercontent.com/u/33098900?v=4",
"profileUrl": "https://github.com/Mo-Moore",
"commits": 6,
"mergedPullRequests": 6,
"firstContributionAt": "2026-08-27T05:54:45Z",
"latestContributionAt": "2026-09-02T06:15:04Z"
},
{
"login": "possebon",
"avatarUrl": "https://avatars.githubusercontent.com/u/257745?v=4",
@@ -453,15 +480,6 @@
"firstContributionAt": "2026-08-02T17:09:52Z",
"latestContributionAt": "2026-08-08T03:04:17Z"
},
{
"login": "sxhjlzl",
"avatarUrl": "https://avatars.githubusercontent.com/u/12149913?v=4",
"profileUrl": "https://github.com/sxhjlzl",
"commits": 6,
"mergedPullRequests": 6,
"firstContributionAt": "2026-08-19T03:31:47Z",
"latestContributionAt": "2026-08-26T05:52:42Z"
},
{
"login": "xiaou61",
"avatarUrl": "https://avatars.githubusercontent.com/u/113765024?v=4",
@@ -516,15 +534,6 @@
"firstContributionAt": "2026-07-15T03:09:27Z",
"latestContributionAt": "2026-08-18T15:17:24Z"
},
{
"login": "Lan-zk",
"avatarUrl": "https://avatars.githubusercontent.com/u/68137514?v=4",
"profileUrl": "https://github.com/Lan-zk",
"commits": 5,
"mergedPullRequests": 5,
"firstContributionAt": "2026-08-13T05:20:22Z",
"latestContributionAt": "2026-08-29T19:57:03Z"
},
{
"login": "lizzzzz777",
"avatarUrl": "https://avatars.githubusercontent.com/u/72645313?v=4",
@@ -535,13 +544,13 @@
"latestContributionAt": "2026-08-25T06:46:25Z"
},
{
"login": "Mo-Moore",
"avatarUrl": "https://avatars.githubusercontent.com/u/33098900?v=4",
"profileUrl": "https://github.com/Mo-Moore",
"login": "Staroon",
"avatarUrl": "https://avatars.githubusercontent.com/u/26004564?v=4",
"profileUrl": "https://github.com/Staroon",
"commits": 5,
"mergedPullRequests": 5,
"firstContributionAt": "2026-08-27T05:54:45Z",
"latestContributionAt": "2026-09-01T05:11:59Z"
"firstContributionAt": "2026-07-22T04:43:40Z",
"latestContributionAt": "2026-09-03T12:45:27Z"
},
{
"login": "wbean",
@@ -561,6 +570,15 @@
"firstContributionAt": "2026-06-30T16:59:33Z",
"latestContributionAt": "2026-07-08T16:35:34Z"
},
{
"login": "1320209572",
"avatarUrl": "https://avatars.githubusercontent.com/u/92513238?v=4",
"profileUrl": "https://github.com/1320209572",
"commits": 4,
"mergedPullRequests": 4,
"firstContributionAt": "2026-09-01T05:03:42Z",
"latestContributionAt": "2026-09-02T17:53:31Z"
},
{
"login": "azens",
"avatarUrl": "https://avatars.githubusercontent.com/u/14170526?v=4",
@@ -570,6 +588,15 @@
"firstContributionAt": "2026-07-17T06:34:18Z",
"latestContributionAt": "2026-08-17T07:54:14Z"
},
{
"login": "BabyLy233",
"avatarUrl": "https://avatars.githubusercontent.com/u/79321988?v=4",
"profileUrl": "https://github.com/BabyLy233",
"commits": 4,
"mergedPullRequests": 4,
"firstContributionAt": "2026-07-14T07:55:34Z",
"latestContributionAt": "2026-09-03T16:29:30Z"
},
{
"login": "Caisin",
"avatarUrl": "https://avatars.githubusercontent.com/u/25792786?v=4",
@@ -615,6 +642,15 @@
"firstContributionAt": "2026-06-08T17:32:08Z",
"latestContributionAt": "2026-08-06T04:13:57Z"
},
{
"login": "ordinary-s",
"avatarUrl": "https://avatars.githubusercontent.com/u/59008530?v=4",
"profileUrl": "https://github.com/ordinary-s",
"commits": 4,
"mergedPullRequests": 4,
"firstContributionAt": "2026-08-18T11:36:48Z",
"latestContributionAt": "2026-09-05T14:18:04Z"
},
{
"login": "soddygo",
"avatarUrl": "https://avatars.githubusercontent.com/u/13834644?v=4",
@@ -624,15 +660,6 @@
"firstContributionAt": "2026-07-21T12:32:56Z",
"latestContributionAt": "2026-07-27T08:11:54Z"
},
{
"login": "Staroon",
"avatarUrl": "https://avatars.githubusercontent.com/u/26004564?v=4",
"profileUrl": "https://github.com/Staroon",
"commits": 4,
"mergedPullRequests": 4,
"firstContributionAt": "2026-07-22T04:43:40Z",
"latestContributionAt": "2026-07-31T05:54:26Z"
},
{
"login": "wuxiemian",
"avatarUrl": "https://avatars.githubusercontent.com/u/29685283?v=4",
@@ -678,15 +705,6 @@
"firstContributionAt": "2026-05-04T09:11:57Z",
"latestContributionAt": "2026-05-06T04:09:26Z"
},
{
"login": "BabyLy233",
"avatarUrl": "https://avatars.githubusercontent.com/u/79321988?v=4",
"profileUrl": "https://github.com/BabyLy233",
"commits": 3,
"mergedPullRequests": 3,
"firstContributionAt": "2026-07-14T07:55:34Z",
"latestContributionAt": "2026-08-11T08:55:53Z"
},
{
"login": "cheshireez",
"avatarUrl": "https://avatars.githubusercontent.com/u/32764974?v=4",
@@ -696,6 +714,15 @@
"firstContributionAt": "2026-08-06T10:14:50Z",
"latestContributionAt": "2026-08-11T13:32:15Z"
},
{
"login": "Clarence404",
"avatarUrl": "https://avatars.githubusercontent.com/u/36807519?v=4",
"profileUrl": "https://github.com/Clarence404",
"commits": 3,
"mergedPullRequests": 3,
"firstContributionAt": "2026-09-01T05:51:25Z",
"latestContributionAt": "2026-09-02T08:56:13Z"
},
{
"login": "domye",
"avatarUrl": "https://avatars.githubusercontent.com/u/67504754?v=4",
@@ -768,15 +795,6 @@
"firstContributionAt": "2026-07-12T06:57:24Z",
"latestContributionAt": "2026-08-07T05:45:00Z"
},
{
"login": "ordinary-s",
"avatarUrl": "https://avatars.githubusercontent.com/u/59008530?v=4",
"profileUrl": "https://github.com/ordinary-s",
"commits": 3,
"mergedPullRequests": 3,
"firstContributionAt": "2026-08-18T11:36:48Z",
"latestContributionAt": "2026-08-26T14:00:04Z"
},
{
"login": "polemp",
"avatarUrl": "https://avatars.githubusercontent.com/u/48376445?v=4",
@@ -804,6 +822,15 @@
"firstContributionAt": "2026-08-18T10:01:35Z",
"latestContributionAt": "2026-08-20T13:01:23Z"
},
{
"login": "StaravenX",
"avatarUrl": "https://avatars.githubusercontent.com/u/231610333?v=4",
"profileUrl": "https://github.com/StaravenX",
"commits": 3,
"mergedPullRequests": 3,
"firstContributionAt": "2026-08-26T11:48:56Z",
"latestContributionAt": "2026-09-02T01:15:10Z"
},
{
"login": "vergil-lai",
"avatarUrl": "https://avatars.githubusercontent.com/u/2268937?v=4",
@@ -813,6 +840,15 @@
"firstContributionAt": "2026-06-13T11:23:45Z",
"latestContributionAt": "2026-06-24T06:53:47Z"
},
{
"login": "wangfengxiang",
"avatarUrl": "https://avatars.githubusercontent.com/u/31534139?v=4",
"profileUrl": "https://github.com/wangfengxiang",
"commits": 3,
"mergedPullRequests": 3,
"firstContributionAt": "2026-08-31T09:52:03Z",
"latestContributionAt": "2026-09-02T18:09:49Z"
},
{
"login": "wds824",
"avatarUrl": "https://avatars.githubusercontent.com/u/93659702?v=4",
@@ -822,6 +858,15 @@
"firstContributionAt": "2026-04-30T06:28:17Z",
"latestContributionAt": "2026-04-30T09:48:13Z"
},
{
"login": "yanguibao1997",
"avatarUrl": "https://avatars.githubusercontent.com/u/39548181?v=4",
"profileUrl": "https://github.com/yanguibao1997",
"commits": 3,
"mergedPullRequests": 3,
"firstContributionAt": "2026-07-29T06:58:27Z",
"latestContributionAt": "2026-09-04T08:52:47Z"
},
{
"login": "Michaelxwb",
"avatarUrl": "https://avatars.githubusercontent.com/u/34806714?v=4",
@@ -894,6 +939,15 @@
"firstContributionAt": "2026-08-27T10:43:39Z",
"latestContributionAt": "2026-09-01T03:09:45Z"
},
{
"login": "doersa",
"avatarUrl": "https://avatars.githubusercontent.com/u/6913871?v=4",
"profileUrl": "https://github.com/doersa",
"commits": 2,
"mergedPullRequests": 2,
"firstContributionAt": "2026-09-01T03:09:57Z",
"latestContributionAt": "2026-09-02T12:38:17Z"
},
{
"login": "Doracoin",
"avatarUrl": "https://avatars.githubusercontent.com/u/9206272?v=4",
@@ -1002,6 +1056,15 @@
"firstContributionAt": "2026-06-13T09:34:22Z",
"latestContributionAt": "2026-06-29T13:21:46Z"
},
{
"login": "Lem26",
"avatarUrl": "https://avatars.githubusercontent.com/u/38876848?v=4",
"profileUrl": "https://github.com/Lem26",
"commits": 2,
"mergedPullRequests": 2,
"firstContributionAt": "2026-09-04T14:44:45Z",
"latestContributionAt": "2026-09-05T05:02:42Z"
},
{
"login": "libondev",
"avatarUrl": "https://avatars.githubusercontent.com/u/47918504?v=4",
@@ -1057,13 +1120,13 @@
"latestContributionAt": "2026-08-09T15:33:24Z"
},
{
"login": "StaravenX",
"avatarUrl": "https://avatars.githubusercontent.com/u/231610333?v=4",
"profileUrl": "https://github.com/StaravenX",
"login": "sineld",
"avatarUrl": "https://avatars.githubusercontent.com/u/445349?v=4",
"profileUrl": "https://github.com/sineld",
"commits": 2,
"mergedPullRequests": 2,
"firstContributionAt": "2026-08-26T11:48:56Z",
"latestContributionAt": "2026-08-28T07:29:07Z"
"firstContributionAt": "2026-09-03T16:48:39Z",
"latestContributionAt": "2026-09-04T01:51:36Z"
},
{
"login": "TangTangH",
@@ -1074,6 +1137,15 @@
"firstContributionAt": "2026-08-04T04:55:17Z",
"latestContributionAt": "2026-08-06T06:09:21Z"
},
{
"login": "Tara8573",
"avatarUrl": "https://avatars.githubusercontent.com/u/39621793?v=4",
"profileUrl": "https://github.com/Tara8573",
"commits": 2,
"mergedPullRequests": 2,
"firstContributionAt": "2026-08-22T04:03:32Z",
"latestContributionAt": "2026-09-04T06:48:42Z"
},
{
"login": "togls",
"avatarUrl": "https://avatars.githubusercontent.com/u/17137090?v=4",
@@ -1137,6 +1209,15 @@
"firstContributionAt": "2026-07-27T08:55:20Z",
"latestContributionAt": "2026-07-30T07:13:36Z"
},
{
"login": "wnyr",
"avatarUrl": "https://avatars.githubusercontent.com/u/8221975?v=4",
"profileUrl": "https://github.com/wnyr",
"commits": 2,
"mergedPullRequests": 2,
"firstContributionAt": "2026-07-06T03:39:01Z",
"latestContributionAt": "2026-09-04T16:54:00Z"
},
{
"login": "xiaoge19961220",
"avatarUrl": "https://avatars.githubusercontent.com/u/76191330?v=4",
@@ -1146,6 +1227,15 @@
"firstContributionAt": "2026-07-03T06:18:00Z",
"latestContributionAt": "2026-07-03T11:25:02Z"
},
{
"login": "xiaomizhou2",
"avatarUrl": "https://avatars.githubusercontent.com/u/47807926?v=4",
"profileUrl": "https://github.com/xiaomizhou2",
"commits": 2,
"mergedPullRequests": 2,
"firstContributionAt": "2026-07-27T13:49:30Z",
"latestContributionAt": "2026-09-05T05:25:04Z"
},
{
"login": "yangqinlong",
"avatarUrl": "https://avatars.githubusercontent.com/u/7039386?v=4",
@@ -1155,15 +1245,6 @@
"firstContributionAt": "2026-07-22T18:14:03Z",
"latestContributionAt": "2026-07-29T10:42:44Z"
},
{
"login": "yanguibao1997",
"avatarUrl": "https://avatars.githubusercontent.com/u/39548181?v=4",
"profileUrl": "https://github.com/yanguibao1997",
"commits": 2,
"mergedPullRequests": 2,
"firstContributionAt": "2026-07-29T06:58:27Z",
"latestContributionAt": "2026-07-31T08:27:37Z"
},
{
"login": "Mukesh-234234",
"avatarUrl": "https://avatars.githubusercontent.com/u/281438489?v=4",
@@ -1173,15 +1254,6 @@
"firstContributionAt": "2026-05-03T18:36:11Z",
"latestContributionAt": "2026-05-05T09:30:42Z"
},
{
"login": "1320209572",
"avatarUrl": "https://avatars.githubusercontent.com/u/92513238?v=4",
"profileUrl": "https://github.com/1320209572",
"commits": 1,
"mergedPullRequests": 1,
"firstContributionAt": "2026-09-01T05:03:42Z",
"latestContributionAt": "2026-09-01T05:55:38Z"
},
{
"login": "2090230619-syq",
"avatarUrl": "https://avatars.githubusercontent.com/u/268813423?v=4",
@@ -1290,6 +1362,15 @@
"firstContributionAt": "2026-08-06T04:32:47Z",
"latestContributionAt": "2026-08-10T02:54:27Z"
},
{
"login": "cherish-ltt",
"avatarUrl": "https://avatars.githubusercontent.com/u/125760530?v=4",
"profileUrl": "https://github.com/cherish-ltt",
"commits": 1,
"mergedPullRequests": 1,
"firstContributionAt": "2026-09-03T10:55:39Z",
"latestContributionAt": "2026-09-03T13:07:19Z"
},
{
"login": "Cherrs",
"avatarUrl": "https://avatars.githubusercontent.com/u/6745593?v=4",
@@ -1308,15 +1389,6 @@
"firstContributionAt": "2026-06-09T08:33:26Z",
"latestContributionAt": "2026-06-09T08:46:00Z"
},
{
"login": "Clarence404",
"avatarUrl": "https://avatars.githubusercontent.com/u/36807519?v=4",
"profileUrl": "https://github.com/Clarence404",
"commits": 1,
"mergedPullRequests": 1,
"firstContributionAt": "2026-09-01T05:51:25Z",
"latestContributionAt": "2026-09-01T07:28:09Z"
},
{
"login": "clj1566317",
"avatarUrl": "https://avatars.githubusercontent.com/u/295271018?v=4",
@@ -1362,15 +1434,6 @@
"firstContributionAt": "2026-07-24T05:02:37Z",
"latestContributionAt": "2026-07-25T00:45:27Z"
},
{
"login": "doersa",
"avatarUrl": "https://avatars.githubusercontent.com/u/6913871?v=4",
"profileUrl": "https://github.com/doersa",
"commits": 1,
"mergedPullRequests": 1,
"firstContributionAt": "2026-09-01T03:09:57Z",
"latestContributionAt": "2026-09-01T04:34:00Z"
},
{
"login": "Driftlux",
"avatarUrl": "https://avatars.githubusercontent.com/u/96872606?v=4",
@@ -1488,6 +1551,15 @@
"firstContributionAt": "2026-07-03T16:19:58Z",
"latestContributionAt": "2026-07-03T16:58:52Z"
},
{
"login": "githubasda",
"avatarUrl": "https://avatars.githubusercontent.com/u/86922868?v=4",
"profileUrl": "https://github.com/githubasda",
"commits": 1,
"mergedPullRequests": 1,
"firstContributionAt": "2026-09-02T01:23:28Z",
"latestContributionAt": "2026-09-02T07:45:24Z"
},
{
"login": "guoziyangnb",
"avatarUrl": "https://avatars.githubusercontent.com/u/136451743?v=4",
@@ -1560,6 +1632,15 @@
"firstContributionAt": "2026-07-25T03:18:45Z",
"latestContributionAt": "2026-07-26T09:52:33Z"
},
{
"login": "huyphams",
"avatarUrl": "https://avatars.githubusercontent.com/u/3654622?v=4",
"profileUrl": "https://github.com/huyphams",
"commits": 1,
"mergedPullRequests": 1,
"firstContributionAt": "2026-09-04T02:43:39Z",
"latestContributionAt": "2026-09-04T04:08:41Z"
},
{
"login": "idxdy",
"avatarUrl": "https://avatars.githubusercontent.com/u/10395284?v=4",
@@ -1776,6 +1857,15 @@
"firstContributionAt": "2026-06-15T07:10:22Z",
"latestContributionAt": "2026-06-15T09:01:01Z"
},
{
"login": "omerfarukalkilic",
"avatarUrl": "https://avatars.githubusercontent.com/u/178777569?v=4",
"profileUrl": "https://github.com/omerfarukalkilic",
"commits": 1,
"mergedPullRequests": 1,
"firstContributionAt": "2026-09-01T13:13:31Z",
"latestContributionAt": "2026-09-02T03:52:59Z"
},
{
"login": "orwenxiang",
"avatarUrl": "https://avatars.githubusercontent.com/u/1335999?v=4",
@@ -1803,6 +1893,15 @@
"firstContributionAt": "2026-06-07T03:43:59Z",
"latestContributionAt": "2026-06-07T18:07:00Z"
},
{
"login": "Resheba",
"avatarUrl": "https://avatars.githubusercontent.com/u/74815401?v=4",
"profileUrl": "https://github.com/Resheba",
"commits": 1,
"mergedPullRequests": 1,
"firstContributionAt": "2026-09-04T09:14:31Z",
"latestContributionAt": "2026-09-05T06:03:58Z"
},
{
"login": "Rson9",
"avatarUrl": "https://avatars.githubusercontent.com/u/118523711?v=4",
@@ -1875,6 +1974,15 @@
"firstContributionAt": "2026-06-05T08:58:05Z",
"latestContributionAt": "2026-06-05T10:16:18Z"
},
{
"login": "sunzhengbo",
"avatarUrl": "https://avatars.githubusercontent.com/u/29298020?v=4",
"profileUrl": "https://github.com/sunzhengbo",
"commits": 1,
"mergedPullRequests": 1,
"firstContributionAt": "2026-09-03T08:14:23Z",
"latestContributionAt": "2026-09-04T02:25:15Z"
},
{
"login": "sweetwisdom",
"avatarUrl": "https://avatars.githubusercontent.com/u/57869305?v=4",
@@ -1884,15 +1992,6 @@
"firstContributionAt": "2026-06-12T03:18:32Z",
"latestContributionAt": "2026-06-12T03:30:03Z"
},
{
"login": "Tara8573",
"avatarUrl": "https://avatars.githubusercontent.com/u/39621793?v=4",
"profileUrl": "https://github.com/Tara8573",
"commits": 1,
"mergedPullRequests": 1,
"firstContributionAt": "2026-08-22T04:03:32Z",
"latestContributionAt": "2026-08-22T11:30:39Z"
},
{
"login": "tatuke",
"avatarUrl": "https://avatars.githubusercontent.com/u/46305821?v=4",
@@ -1921,13 +2020,22 @@
"latestContributionAt": "2026-06-19T16:13:09Z"
},
{
"login": "wangfengxiang",
"avatarUrl": "https://avatars.githubusercontent.com/u/31534139?v=4",
"profileUrl": "https://github.com/wangfengxiang",
"login": "wang122818",
"avatarUrl": "https://avatars.githubusercontent.com/u/192289271?v=4",
"profileUrl": "https://github.com/wang122818",
"commits": 1,
"mergedPullRequests": 1,
"firstContributionAt": "2026-08-31T09:52:03Z",
"latestContributionAt": "2026-08-31T16:21:10Z"
"firstContributionAt": "2026-09-03T08:35:45Z",
"latestContributionAt": "2026-09-03T13:07:37Z"
},
{
"login": "Weiki886",
"avatarUrl": "https://avatars.githubusercontent.com/u/188135055?v=4",
"profileUrl": "https://github.com/Weiki886",
"commits": 1,
"mergedPullRequests": 1,
"firstContributionAt": "2026-09-03T06:49:26Z",
"latestContributionAt": "2026-09-03T10:10:27Z"
},
{
"login": "wfion",
@@ -1947,15 +2055,6 @@
"firstContributionAt": "2026-08-11T06:09:28Z",
"latestContributionAt": "2026-08-11T13:37:32Z"
},
{
"login": "wnyr",
"avatarUrl": "https://avatars.githubusercontent.com/u/8221975?v=4",
"profileUrl": "https://github.com/wnyr",
"commits": 1,
"mergedPullRequests": 1,
"firstContributionAt": "2026-07-06T03:39:01Z",
"latestContributionAt": "2026-07-06T04:57:27Z"
},
{
"login": "wojiukankan",
"avatarUrl": "https://avatars.githubusercontent.com/u/32760220?v=4",
@@ -2019,15 +2118,6 @@
"firstContributionAt": "2026-07-02T05:54:07Z",
"latestContributionAt": "2026-07-02T06:28:58Z"
},
{
"login": "xiaomizhou2",
"avatarUrl": "https://avatars.githubusercontent.com/u/47807926?v=4",
"profileUrl": "https://github.com/xiaomizhou2",
"commits": 1,
"mergedPullRequests": 1,
"firstContributionAt": "2026-07-27T13:49:30Z",
"latestContributionAt": "2026-07-28T19:18:06Z"
},
{
"login": "xiaoyaoqilan",
"avatarUrl": "https://avatars.githubusercontent.com/u/155608604?v=4",
+121 -10
View File
@@ -74,7 +74,8 @@ const OPEN_ATTEMPTS: usize = 10;
const OPEN_RETRY_DELAY: Duration = Duration::from_millis(20);
/// Rounds of re-owning, covering copies that land while a re-own is already
/// in progress (their update notification is coalesced away).
/// in progress (their update notification is coalesced away). The tail sleep
/// after the last round is covered by a final re-check in the worker instead.
const REOWN_ROUNDS: usize = 3;
/// Standard clipboard format ids (`winuser.h`), widened from the windows
@@ -84,6 +85,13 @@ const CF_METAFILEPICT: u32 = Ole::CF_METAFILEPICT.0 as u32;
const CF_PALETTE: u32 = Ole::CF_PALETTE.0 as u32;
const CF_ENHMETAFILE: u32 = Ole::CF_ENHMETAFILE.0 as u32;
const CF_OWNERDISPLAY: u32 = Ole::CF_OWNERDISPLAY.0 as u32;
/// Text and image formats, which `snapshot_and_reapply` re-sets first (see
/// `replay_priority`).
const CF_TEXT: u32 = Ole::CF_TEXT.0 as u32;
const CF_OEMTEXT: u32 = Ole::CF_OEMTEXT.0 as u32;
const CF_UNICODETEXT: u32 = Ole::CF_UNICODETEXT.0 as u32;
const CF_DIB: u32 = Ole::CF_DIB.0 as u32;
const CF_DIBV5: u32 = Ole::CF_DIBV5.0 as u32;
/// Set while a re-own worker is running so bursts of clipboard updates
/// (WebView2 writes several formats in a row) coalesce into a single worker.
@@ -160,6 +168,15 @@ unsafe fn on_clipboard_update(host: HWND) {
unsafe { reown_from_webview2(host) };
std::thread::sleep(REOWN_DELAY);
}
// A copy landing during the tail sleep of the last round is coalesced
// away by `REOWN_IN_FLIGHT` and its update notification never
// re-arrives, so without this check it would silently miss Clipboard
// History. One final re-check while the flag is still held (no other
// worker can race for it) re-owns the copy; a no-op unless one is
// pending, because `reown_from_webview2` re-verifies the owner first.
if unsafe { clipboard_update_from_webview2(host) } {
unsafe { reown_from_webview2(host) };
}
});
}
@@ -260,6 +277,24 @@ fn payload_kind(format: u32) -> PayloadKind {
}
}
/// Replay order of a payload: lower values are re-set first. After
/// `EmptyClipboard` a failed `SetClipboardData` loses exactly its own
/// format — and under memory pressure several re-sets can fail in a row —
/// so the primary text/image payloads go first and the worst case is
/// bounded to secondary private/registered formats, regardless of where
/// the original writer's enumeration order placed them. Stable, so the
/// writer's own order is kept within each class.
fn replay_priority(format: u32) -> u32 {
match format {
CF_TEXT | CF_OEMTEXT | CF_UNICODETEXT => 0,
CF_BITMAP | CF_DIB | CF_DIBV5 => 1,
// Remaining standard formats (file lists, metafiles, locale, ...).
0x0001..=0x007F => 2,
// Private (0x0200-0x7FFF) and registered (0xC000+) formats.
_ => 3,
}
}
/// A single clipboard format carried across the `EmptyClipboard`.
enum Payload {
Bytes { format: u32, data: Vec<u8> },
@@ -268,6 +303,15 @@ enum Payload {
}
impl Payload {
/// The clipboard format id this payload re-sets.
fn format(&self) -> u32 {
match self {
Payload::Bytes { format, .. } => *format,
Payload::Bitmap(_) => CF_BITMAP,
Payload::EnhancedMetafile(_) => CF_ENHMETAFILE,
}
}
/// Frees duplicated GDI handles when a payload can no longer be applied.
unsafe fn release(self) {
match self {
@@ -284,8 +328,11 @@ impl Payload {
/// With the clipboard already open: snapshots every format, empties the
/// clipboard and re-sets all payloads — which transfers the ownership to the
/// window the clipboard was opened with. Returns `false` when the re-own was
/// deliberately skipped; the original clipboard is then left untouched.
/// window the clipboard was opened with. Standard formats are re-set before
/// private and registered ones, so a failed `SetClipboardData` can at worst
/// cost a secondary format (see `replay_priority`). Returns `false` when the
/// re-own was deliberately skipped; the original clipboard is then left
/// untouched.
unsafe fn snapshot_and_reapply() -> bool {
let mut payloads: Vec<Payload> = Vec::new();
let mut format = EnumClipboardFormats(0);
@@ -317,6 +364,11 @@ unsafe fn snapshot_and_reapply() -> bool {
return false;
}
// Replay primary formats first: a re-set that fails below is gone for
// good, so the valuable payloads must not depend on where the original
// writer's enumeration order happened to place them.
payloads.sort_by_key(|payload| replay_priority(payload.format()));
if EmptyClipboard().is_err() {
for payload in payloads {
payload.release();
@@ -423,9 +475,6 @@ mod tests {
static CLIPBOARD_TEST_LOCK: Mutex<()> = Mutex::new(());
/// Only used by the tests; kept here so the lib build has no dead const.
const CF_UNICODETEXT: u32 = Ole::CF_UNICODETEXT.0 as u32;
unsafe extern "system" fn test_wndproc(
hwnd: HWND,
msg: u32,
@@ -583,7 +632,7 @@ mod tests {
}
#[test]
fn reown_roundtrip_preserves_formats_bytes_order_and_owner() {
fn reown_roundtrip_preserves_formats_bytes_and_owner() {
let _lock = CLIPBOARD_TEST_LOCK.lock().unwrap();
unsafe {
let window = create_test_window();
@@ -602,6 +651,10 @@ mod tests {
assert!(open_clipboard(window), "OpenClipboard failed");
let formats_before = enumerated_formats();
let data_before: Vec<(u32, Vec<u8>)> = formats_before
.iter()
.map(|&format| (format, read_clipboard_bytes(format).unwrap()))
.collect();
assert!(CloseClipboard().is_ok());
// Act: the re-own mechanics with the clipboard opened by `window`.
@@ -617,11 +670,69 @@ mod tests {
let owner = GetClipboardOwner().unwrap();
assert_eq!(owner, window, "the host window must own the clipboard");
// Assert: same formats in the same order with identical bytes.
// Assert: the same set of formats with identical bytes, led by
// CF_UNICODETEXT (see `replay_priority`). Enumeration order is
// deliberately not compared verbatim: Windows synthesizes CF_TEXT/
// CF_OEMTEXT/CF_LOCALE when the original clipboard is closed, and
// the replay puts the text formats first rather than wherever that
// synthesis appended them.
assert!(open_clipboard(window), "OpenClipboard failed");
assert_eq!(enumerated_formats(), formats_before);
let formats_after = enumerated_formats();
assert_eq!(formats_after.first(), Some(&CF_UNICODETEXT));
let mut sorted_before = formats_before;
let mut sorted_after = formats_after;
sorted_before.sort_unstable();
sorted_after.sort_unstable();
assert_eq!(sorted_after, sorted_before, "same set of formats");
for (format, data) in &data_before {
assert_eq!(read_clipboard_bytes(*format).unwrap(), *data);
}
assert!(CloseClipboard().is_ok());
let _ = DestroyWindow(window);
}
}
#[test]
fn reown_replays_standard_formats_before_private_ones() {
let _lock = CLIPBOARD_TEST_LOCK.lock().unwrap();
unsafe {
let window = create_test_window();
let text_bytes = utf16_bytes("standard formats replay first");
let custom_format = RegisterClipboardFormatW(w!("Wry.ClipboardHistory.ReplayOrder"));
assert_ne!(custom_format, 0);
// Arrange: a registered format enumerated BEFORE CF_UNICODETEXT —
// enumeration follows SetClipboardData order, so a blind replay
// would put the private format first.
assert!(open_clipboard(window), "OpenClipboard failed");
assert!(EmptyClipboard().is_ok());
assert!(set_clipboard_bytes(custom_format, &[9u8, 8, 7]));
assert!(set_clipboard_bytes(CF_UNICODETEXT, &text_bytes));
assert!(CloseClipboard().is_ok());
// The registered format is enumerated before the text.
assert!(open_clipboard(window), "OpenClipboard failed");
assert_eq!(enumerated_formats().first(), Some(&custom_format));
assert!(CloseClipboard().is_ok());
assert!(open_clipboard(window), "OpenClipboard failed");
assert!(
snapshot_and_reapply(),
"HGLOBAL-only clipboards must be re-owned"
);
assert!(CloseClipboard().is_ok());
// Assert: text was re-set first — setting CF_UNICODETEXT also makes
// Windows synthesize CF_TEXT/CF_OEMTEXT/CF_LOCALE right behind it —
// so a failed re-set of the trailing registered format can at worst
// drop that format, never the text.
assert!(open_clipboard(window), "OpenClipboard failed");
let formats = enumerated_formats();
assert_eq!(formats.first(), Some(&CF_UNICODETEXT));
assert_eq!(formats.last(), Some(&custom_format));
assert_eq!(read_clipboard_bytes(CF_UNICODETEXT).unwrap(), text_bytes);
assert_eq!(read_clipboard_bytes(custom_format).unwrap(), custom_data);
assert_eq!(read_clipboard_bytes(custom_format).unwrap(), [9u8, 8, 7]);
assert!(CloseClipboard().is_ok());
let _ = DestroyWindow(window);