diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 26ec94aeba..4e3ff64dcb 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -174,7 +174,7 @@ import { type WebDavConfig, } from "@/lib/backend/api"; import { eventToModifierOnlyShortcut, eventToShortcut } from "@/lib/editor/keyboardShortcuts"; -import { SHORTCUT_DEFINITIONS, countShortcutConflictPairs, findCrossScopeShortcutConflicts, findShortcutConflict, isReservedShortcut, normalizeShortcutSettings, type ShortcutActionId, type ShortcutDefinition, type ShortcutScope } from "@/lib/editor/shortcutRegistry"; +import { SHORTCUT_DEFINITIONS, countShortcutConflictPairs, findCrossScopeShortcutConflicts, findShortcutConflict, isReservedShortcut, normalizeShortcutSettings, resolveCapturedShortcutEdit, type ShortcutActionId, type ShortcutDefinition, type ShortcutScope } from "@/lib/editor/shortcutRegistry"; import LightTooltip from "@/components/ui/LightTooltip.vue"; import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay"; import { COLUMN_NAME_COPY_SEPARATOR_LABELS, COLUMN_NAME_COPY_SEPARATOR_OPTIONS, isColumnNameCopySeparator, type ColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy"; @@ -2065,6 +2065,9 @@ async function persistSettings() { // result back into the input so an out-of-range draft doesn't keep // reporting unsaved changes after a successful apply. editRedisDatabaseDisplayLimit.value = settingsStore.editorSettings.redisDatabaseDisplayLimit; + // 同理:落盘口径可能改写键位(跨平台默认键、保留键回退),草稿要跟着回到 + // 落盘值,避免面板卡在“未保存”无法应用(#9881)。 + editShortcuts.value = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts); } if (pendingBackgroundImageCleanup) { const cleanupPath = pendingBackgroundImageCleanup; @@ -2688,7 +2691,19 @@ function onShortcutKeydown(actionId: ShortcutActionId, event: KeyboardEvent) { editingShortcutId.value = null; return; } - onShortcutChange(actionId, shortcut); + // 草稿必须正好等于落盘值:否则“未保存”状态永远消不掉,#9881 里 + // 「应用」点了没反应、「应用并关闭」每次都弹未保存确认。 + const captured = resolveCapturedShortcutEdit(actionId, shortcut, editShortcuts.value); + if (captured.rejectedByPlatformDefault) { + toast(t("settings.shortcutPlatformDefault"), 3000); + editingShortcutId.value = null; + return; + } + if (captured.changed) { + editShortcuts.value = captured.shortcuts; + } else { + onShortcutChange(actionId, shortcut); + } editingShortcutId.value = null; } @@ -2729,10 +2744,14 @@ function cancelShortcutEdit() { function resetShortcut(actionId: ShortcutActionId) { const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); if (!definition) return; - editShortcuts.value = { + // The static definition default is the macOS literal for a few actions + // (selection-occurrence), so the reset must go through the same persisted + // normalization as capture — otherwise Windows drafts a key that can never + // save and silently flips to the platform default on apply. + editShortcuts.value = normalizeShortcutSettings({ ...editShortcuts.value, [actionId]: definition.defaultShortcut, - }; + }); } function clearShortcut(actionId: ShortcutActionId) { diff --git a/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.shortcutCapture.spec.ts b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.shortcutCapture.spec.ts new file mode 100644 index 0000000000..25e8d5df94 --- /dev/null +++ b/apps/desktop/src/components/editor/__tests__/EditorSettingsDialog.shortcutCapture.spec.ts @@ -0,0 +1,46 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8"); + +// Regression for https://github.com/t8y2/dbx/issues/9881: the shortcut draft was +// written straight from the captured key event, while persistence re-normalizes it +// and swaps "another platform's default" literals for the local platform default. +// The draft then never equaled the stored value again, so `hasChanges()` stayed true +// forever: "Apply" looked like it did nothing and "Apply and Close" always asked +// about unsaved changes — the shortcut (and every other pending edit) could never be +// saved. The capture path must therefore write the same value the store will keep. +describe("EditorSettingsDialog shortcut capture normalization", () => { + it("round-trips a captured combination through the persistence normalizer", () => { + expect(dialogSource).toContain("resolveCapturedShortcutEdit,"); + expect(dialogSource).toContain("const captured = resolveCapturedShortcutEdit(actionId, shortcut, editShortcuts.value);"); + }); + + it("refuses combinations the store would rewrite, keeping the previous shortcut", () => { + expect(dialogSource).toContain('toast(t("settings.shortcutPlatformDefault"), 3000);'); + + const guardStart = dialogSource.indexOf("if (captured.rejectedByPlatformDefault) {"); + const guardEnd = dialogSource.indexOf("if (captured.changed) {", guardStart); + + expect(guardStart).toBeGreaterThan(-1); + expect(guardEnd).toBeGreaterThan(guardStart); + // 拒绝分支必须先结束编辑、且不得写入草稿(保留原值 → 草稿仍是落盘值的不动点) + const guardBody = dialogSource.slice(guardStart, guardEnd); + expect(guardBody).toContain("editingShortcutId.value = null;"); + expect(guardBody).toContain("return;"); + expect(guardBody).not.toContain("editShortcuts.value ="); + }); + + it("adopts the normalized result so the draft can never diverge from the stored value", () => { + expect(dialogSource).toContain("editShortcuts.value = captured.shortcuts;"); + }); + + it("re-syncs the shortcut draft from the store after a successful apply", () => { + const bodyStart = dialogSource.indexOf("async function persistSettings() {"); + const bodyEnd = dialogSource.indexOf("function applySettingsErrorToast(", bodyStart); + + expect(bodyStart).toBeGreaterThan(-1); + expect(bodyEnd).toBeGreaterThan(bodyStart); + expect(dialogSource.slice(bodyStart, bodyEnd)).toContain("editShortcuts.value = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);"); + }); +}); diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index b6e9f9a929..462c631bb4 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -7686,6 +7686,7 @@ export default withEnglishFallback({ shortcutSearchNoResults: "Axtarışınıza uyğun qısayol yoxdur.", shortcutConflict: "Bu qısayol eyni əhatə dairəsində başqa əməliyyatla ziddiyyət təşkil edir.", shortcutReserved: "Bu qısayol sistem tərəfindən qorunur.", + shortcutPlatformDefault: "Bu kombinasiya digər platformanın standart qısayoludur və burada təyin edilə bilməz. Başqa kombinasiya seçin.", shortcutClear: "Qısayolu təmizlə", preview: "Canlı önbaxış", previewSyntaxErrorHint: "Yalnız oxumaq üçün önbaxış. Nümunə SQL-dəki FOM yazı xətası qəsdəndir və sintaksis xətalarının vurğulanmasını göstərir.", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 294f3c660c..34355d32f6 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -8184,6 +8184,7 @@ export default { shortcutSearchNoResults: "No shortcuts match your search.", shortcutConflict: "This shortcut conflicts with another action in the same scope.", shortcutReserved: "This shortcut is reserved by the system.", + shortcutPlatformDefault: "This combination is another platform's default shortcut and cannot be bound here. Please use a different one.", shortcutClear: "Clear shortcut", preview: "Live Preview", previewSyntaxErrorHint: "Read-only preview. The FOM typo in the demo SQL is intentional and demonstrates syntax-error highlighting.", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index f3bcf9800c..ce782c62bc 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -7528,6 +7528,7 @@ export default withEnglishFallback({ shortcutSearchNoResults: "Ningún atajo coincide con la búsqueda.", shortcutConflict: "Este atajo entra en conflicto con otra acción del mismo ámbito.", shortcutReserved: "Este atajo está reservado por el sistema.", + shortcutPlatformDefault: "Esta combinación es el atajo predeterminado de otra plataforma y no se puede asignar aquí. Elige otra.", shortcutClear: "Borrar atajo", preview: "Vista previa en tiempo real", previewSyntaxErrorHint: "Vista previa de solo lectura. El error FOM en el SQL de ejemplo es intencionado y demuestra el resaltado de errores de sintaxis.", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 3a45613782..7d5817cccd 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -7527,6 +7527,7 @@ export default withEnglishFallback({ shortcutSearchNoResults: "Nessuna scorciatoia corrisponde alla ricerca.", shortcutConflict: "Questa scorciatoia è in conflitto con un'altra azione nello stesso ambito.", shortcutReserved: "Questa scorciatoia è riservata dal sistema.", + shortcutPlatformDefault: "Questa combinazione è la scorciatoia predefinita di un'altra piattaforma e non può essere assegnata qui. Scegline un'altra.", shortcutClear: "Cancella scorciatoia", preview: "Anteprima in tempo reale", previewSyntaxErrorHint: "Anteprima di sola lettura. L'errore FOM nell'SQL di esempio è intenzionale e mostra l'evidenziazione degli errori di sintassi.", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index dfb3b2451d..acc34682e0 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -7532,6 +7532,7 @@ export default withEnglishFallback({ shortcutSearchNoResults: "一致するショートカットはありません。", shortcutConflict: "このショートカットは同じスコープ内の別のアクションと競合しています。", shortcutReserved: "このショートカットはシステムで予約されています。", + shortcutPlatformDefault: "この組み合わせは他プラットフォームの既定キーです。ここでは割り当てできないため、別の組み合わせを選んでください。", shortcutClear: "ショートカットをクリア", preview: "ライブプレビュー", previewSyntaxErrorHint: "プレビューは読み取り専用です。デモ SQL 内の FOM は意図的な構文エラーで、エラーのハイライトを示しています。", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 1e15bf7a05..15b4970a15 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -7308,6 +7308,7 @@ export default withEnglishFallback({ shortcutSearchNoResults: "검색과 일치하는 단축키가 없습니다.", shortcutConflict: "이 단축키는 같은 범위의 다른 작업과 충돌합니다.", shortcutReserved: "이 단축키는 시스템에서 예약되었습니다.", + shortcutPlatformDefault: "이 조합은 다른 플랫폼의 기본 단축키라 여기에서는 지정할 수 없습니다. 다른 조합을 사용하세요.", shortcutClear: "단축키 지우기", preview: "실시간 미리보기", previewSyntaxErrorHint: "미리보기는 읽기 전용입니다. 데모 SQL의 FOM 오타는 의도적인 구문 오류이며 오류 강조 표시를 보여줍니다.", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 624661440d..0669618049 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -7529,6 +7529,7 @@ export default withEnglishFallback({ shortcutSearchNoResults: "Nenhum atalho corresponde à pesquisa.", shortcutConflict: "Este atalho conflita com outra ação no mesmo escopo.", shortcutReserved: "Este atalho é reservado pelo sistema.", + shortcutPlatformDefault: "Esta combinação é o atalho padrão de outra plataforma e não pode ser atribuída aqui. Escolha outra.", shortcutClear: "Limpar atalho", preview: "Pré-visualização ao vivo", previewSyntaxErrorHint: "Pré-visualização somente leitura. O erro FOM no SQL de exemplo é intencional e demonstra o destaque de erros de sintaxe.", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index e502af9afa..af44d7deed 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -7585,6 +7585,7 @@ export default withEnglishFallback({ shortcutSearchNoResults: "Aramanızla eşleşen kısayol yok.", shortcutConflict: "Bu kısayol aynı kapsamdaki başka bir eylemle çakışıyor.", shortcutReserved: "Bu kısayol sistem tarafından ayrılmıştır.", + shortcutPlatformDefault: "Bu kombinasyon diğer platformun varsayılan kısayoludur ve burada atanamaz. Başka bir kombinasyon seçin.", shortcutClear: "Kısayolu temizle", preview: "Canlı Önizleme", previewSyntaxErrorHint: "Salt okunur önizleme. Örnek SQL'deki FOM yazım hatası kasıtlıdır ve sözdizimi hatası vurgulamasını gösterir.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 9a33737e8d..78d0f0f808 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -8158,6 +8158,7 @@ export default withEnglishFallback({ shortcutSearchNoResults: "没有匹配的快捷键。", shortcutConflict: "这个快捷键与同一作用域内的其他操作冲突。", shortcutReserved: "该快捷键由系统保留,请换一个组合。", + shortcutPlatformDefault: "该组合是其它平台的默认键,本平台无法绑定,请换一个组合。", shortcutClear: "清除快捷键", preview: "实时预览", previewSyntaxErrorHint: "预览为只读。示例 SQL 中的 FOM 拼写是故意设置的语法错误,用于演示错误高亮效果。", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 974982a7fb..657f2192fd 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -6829,6 +6829,7 @@ export default withEnglishFallback({ shortcutSearchNoResults: "沒有符合的快速鍵。", shortcutConflict: "這個快速鍵與同一作用域內的其他操作衝突。", shortcutReserved: "此快速鍵由系統保留,請改用其他組合。", + shortcutPlatformDefault: "該組合是其它平台的預設鍵,本平台無法綁定,請換一個組合。", shortcutClear: "清除快速鍵", preview: "即時預覽", previewSyntaxErrorHint: "預覽為唯讀。範例 SQL 中的 FOM 拼寫是刻意設置的語法錯誤,用於示範錯誤高亮效果。", diff --git a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts index bb1b3262f2..87ea1d60ad 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts @@ -11,6 +11,7 @@ import { MACOS_RESERVED_SHORTCUTS, normalizeModifierOnlyShortcut, normalizeShortcutSettings, + resolveCapturedShortcutEdit, selectionOccurrenceDefaultShortcut, SHORTCUT_DEFINITIONS, shortcutToCodeMirrorKey, @@ -526,6 +527,67 @@ describe("shortcutRegistry editor actions", () => { }); }); + describe("resolveCapturedShortcutEdit", () => { + // #9881:设置面板「应用」点了毫无反应、「应用并关闭」每次都弹未保存确认, + // 根因就是捕获到的组合被落盘口径改写,草稿和 store 值再也对不上。 + const macDefaults = normalizeShortcutSettings(undefined, "MacIntel"); + const crossPlatformCases: [ShortcutActionId, string][] = [ + ["closeOtherTabs", "Shift+Alt+W"], + ["navigateTabHistoryBack", "Mod+Alt+ArrowLeft"], + ["navigateTabHistoryForward", "Mod+Alt+ArrowRight"], + ["addNextSelectionOccurrence", "Alt+J"], + ["selectAllSelectionOccurrences", "Ctrl+Alt+Shift+J"], + ["toggleAiPanel", "Ctrl+Alt+I"], + ["gotoLine", "Mod+G"], + ]; + + it("flags combinations that are another platform's default for the same action", () => { + for (const [actionId, shortcut] of crossPlatformCases) { + const edit = resolveCapturedShortcutEdit(actionId, shortcut, macDefaults, "MacIntel"); + + expect(edit.rejectedByPlatformDefault).toBe(true); + // 落盘值是该动作本机平台的默认键,绝不能用它当草稿值写入 + expect(edit.shortcuts[actionId]).not.toBe(shortcut); + } + }); + + it("accepts a plain rebind and keeps the draft a fixed point of the persistence normalizer", () => { + const edit = resolveCapturedShortcutEdit("executeSql", "Shift+Ctrl+U", macDefaults, "MacIntel"); + + expect(edit.rejectedByPlatformDefault).toBe(false); + expect(edit.changed).toBe(true); + expect(edit.shortcuts.executeSql).toBe("Shift+Ctrl+U"); + // 关键不变式:草稿再走一遍落盘口径不会被改写,否则“未保存”永远消不掉 + expect(normalizeShortcutSettings(edit.shortcuts, "MacIntel")).toEqual(edit.shortcuts); + }); + + it("adopts the copyCurrentRow/editTableStructure legacy migration into the draft", () => { + const draft = normalizeShortcutSettings({ ...macDefaults, copyCurrentRow: "" }, "MacIntel"); + const edit = resolveCapturedShortcutEdit("editTableStructure", "Mod+D", draft, "MacIntel"); + + expect(edit.rejectedByPlatformDefault).toBe(false); + expect(edit.changed).toBe(true); + expect(edit.shortcuts).toMatchObject({ editTableStructure: "Mod+Shift+D", copyCurrentRow: "Mod+D" }); + expect(normalizeShortcutSettings(edit.shortcuts, "MacIntel")).toEqual(edit.shortcuts); + }); + + it("reports no changes when the user re-presses the current combination", () => { + const edit = resolveCapturedShortcutEdit("closeOtherTabs", macDefaults.closeOtherTabs, macDefaults, "MacIntel"); + + expect(edit.rejectedByPlatformDefault).toBe(false); + expect(edit.changed).toBe(false); + expect(edit.shortcuts).toEqual(macDefaults); + }); + + it("still applies the cross-platform rule on Windows for the macOS default literals", () => { + const windowsDefaults = normalizeShortcutSettings(undefined, "Win32"); + const edit = resolveCapturedShortcutEdit("closeOtherTabs", "Alt+Mod+W", windowsDefaults, "Win32"); + + expect(edit.rejectedByPlatformDefault).toBe(true); + expect(edit.shortcuts.closeOtherTabs).toBe(closeOtherTabsDefaultShortcut("Win32")); + }); + }); + describe("countShortcutConflictPairs", () => { it("counts an unordered pair once even though the map is bidirectional", () => { expect(countShortcutConflictPairs({ find: "focusSearch", focusSearch: "find" })).toBe(1); diff --git a/apps/desktop/src/lib/editor/shortcutRegistry.ts b/apps/desktop/src/lib/editor/shortcutRegistry.ts index 4538654d8a..cee8ea50a2 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -745,6 +745,39 @@ export function normalizeShortcutSettings(settings?: Partial, return normalized; } +export interface CapturedShortcutEdit { + /** 归一化后的完整键位表:与 store 落盘口径一致,草稿直接采用它就不会与落盘值分叉。 */ + shortcuts: ShortcutSettings; + /** + * 用户按下的组合命中了“另一平台的默认键”规则:落盘时该字面量会被换成本机平台 + * 的默认键,所以这个组合在本平台不可能被保存(见上方 PLATFORM_DEFAULT_SHORTCUTS)。 + */ + rejectedByPlatformDefault: boolean; + /** 归一化是否改动了任意一行(跨平台默认键、旧默认键、遗留迁移都会改动)。 */ + changed: boolean; +} + +/** + * 捕获阶段的归一化:把用户在设置面板按下的组合先过一遍落盘时会走的 + * `normalizeShortcutSettings`,让草稿始终是持久化值的**不动点**。 + * + * 设置面板的“有无未保存改动”是拿草稿和 store 里的值做 JSON 比较 + * (`editorSettingsDraftChanged`)得来的:草稿里只要存在一个会被落盘口径改写的 + * 值,这个比较就永远为真——「应用」看起来毫无反应,「应用并关闭」每次都弹 + * “未保存的更改”,改了多少次都存不进去(#9881:把「关闭其他标签页」改成 ⇧⌥W, + * 落盘时被跨平台默认键规则换回 ⌥⌘W)。调用方拿到结果后必须采用 `shortcuts`, + * 并在 `rejectedByPlatformDefault` 为真时提示用户换一个组合。 + */ +export function resolveCapturedShortcutEdit(actionId: ShortcutActionId, shortcut: string, shortcuts: ShortcutSettings, platform = globalThis.navigator?.platform || ""): CapturedShortcutEdit { + const next = normalizeShortcutSettings({ ...shortcuts, [actionId]: shortcut }, platform); + const rejectedByPlatformDefault = next[actionId] !== shortcut && (PLATFORM_DEFAULT_SHORTCUTS[actionId]?.has(shortcut) === true || (actionId === "closeTab" && shortcut === LEGACY_CLOSE_TAB_DEFAULT)); + return { + shortcuts: next, + rejectedByPlatformDefault, + changed: SHORTCUT_DEFINITIONS.some((definition) => next[definition.id] !== shortcuts[definition.id]), + }; +} + export function shortcutToCodeMirrorKey(shortcut: string): string { return parseShortcutStrokes(shortcut) .map((parts) =>