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