From 3f74195835ecc636f0494eb98252d65395ec435e Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Tue, 15 Sep 2026 12:09:04 +0800 Subject: [PATCH] fix(editor): keep Cmd+H for macOS hide instead of replace --- .../editor/EditorSettingsDialog.vue | 17 +- .../__tests__/EditorSearchPanel.spec.ts | 57 +++++- .../__tests__/QueryEditorSearchKeymap.spec.ts | 162 ++++++++++-------- .../components/nacos/NacosAdminConsole.vue | 3 +- apps/desktop/src/i18n/locales/az.ts | 1 + apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/ja.ts | 1 + apps/desktop/src/i18n/locales/ko.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/tr.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + .../editor/codemirrorSearchKeymap.spec.ts | 53 ++++-- .../__tests__/editor/shortcutRegistry.spec.ts | 71 +++++++- .../__tests__/sql/sqlShortcutActions.spec.ts | 28 ++- .../queryEditorSearchKeymapMac.spec.ts | 161 +++++++++++++++++ .../src/lib/editor/queryEditorSearchKeymap.ts | 20 ++- .../src/lib/editor/shortcutRegistry.ts | 67 +++++++- apps/desktop/src/stores/settingsStore.ts | 9 +- 21 files changed, 558 insertions(+), 100 deletions(-) create mode 100644 apps/desktop/src/lib/editor/__tests__/queryEditorSearchKeymapMac.spec.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 3988e3d79d..5b44e5529d 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -167,7 +167,7 @@ import { type WebDavConfig, } from "@/lib/backend/api"; import { eventToModifierOnlyShortcut, eventToShortcut } from "@/lib/editor/keyboardShortcuts"; -import { SHORTCUT_DEFINITIONS, findShortcutConflict, normalizeShortcutSettings, type ShortcutActionId } from "@/lib/editor/shortcutRegistry"; +import { SHORTCUT_DEFINITIONS, findShortcutConflict, isReservedShortcut, normalizeShortcutSettings, type ShortcutActionId } from "@/lib/editor/shortcutRegistry"; import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay"; import { COLUMN_NAME_COPY_SEPARATOR_LABELS, COLUMN_NAME_COPY_SEPARATOR_OPTIONS, isColumnNameCopySeparator, type ColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy"; import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay"; @@ -1131,6 +1131,13 @@ function onSqlShortcutBindingKeydown(id: string, event: KeyboardEvent) { } const shortcut = eventToShortcut(event); if (!shortcut) return; + // macOS 上 ⌘H/⌥⌘H 属于系统 Hide 快捷键(见 MACOS_RESERVED_SHORTCUTS), + // 若允许记录并持久化,配置层仍会 preventDefault 并重新劫持它们,因此输入时直接拒绝。 + if (isReservedShortcut(shortcut)) { + toast(t("settings.shortcutReserved"), 3000); + editingSqlShortcutInputId.value = null; + return; + } sqlShortcutForm.value = { ...sqlShortcutForm.value, shortcut }; editingSqlShortcutInputId.value = null; } @@ -2155,6 +2162,14 @@ function onShortcutKeydown(actionId: ShortcutActionId, event: KeyboardEvent) { const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); const shortcut = definition?.inputKind === "modifier-only" ? eventToModifierOnlyShortcut(event) : eventToShortcut(event); if (!shortcut) return; + // macOS 上 ⌘H/⌥⌘H 由系统保留(Hide / Hide Others),配置层一旦绑定并在 + // CodeMirror 中 preventDefault,AppKit 菜单的 key equivalent 就无法触发 + // (#9068 的配置层复现),故此处输入时直接拒绝。 + if (isReservedShortcut(shortcut)) { + toast(t("settings.shortcutReserved"), 3000); + editingShortcutId.value = null; + return; + } onShortcutChange(actionId, shortcut); editingShortcutId.value = null; } diff --git a/apps/desktop/src/components/editor/__tests__/EditorSearchPanel.spec.ts b/apps/desktop/src/components/editor/__tests__/EditorSearchPanel.spec.ts index bbe94de92e..08ed8287fc 100644 --- a/apps/desktop/src/components/editor/__tests__/EditorSearchPanel.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/EditorSearchPanel.spec.ts @@ -1,10 +1,11 @@ import { readFileSync } from "node:fs"; import { describe, expect, it, vi } from "vitest"; -import { createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap"; +import { createQueryEditorSearchKeymap, replaceFallbackKey } from "@/lib/editor/queryEditorSearchKeymap"; const editorSearchPanelSource = readFileSync(new URL("../EditorSearchPanel.vue", import.meta.url), "utf8"); const queryEditorSource = readFileSync(new URL("../QueryEditor.vue", import.meta.url), "utf8"); const contentAreaSource = readFileSync(new URL("../../layout/ContentArea.vue", import.meta.url), "utf8"); +const nacosConsoleSource = readFileSync(new URL("../../nacos/NacosAdminConsole.vue", import.meta.url), "utf8"); describe("EditorSearchPanel corner style", () => { it("uses the configurable five-pixel radius token for editor inputs", () => { @@ -16,7 +17,7 @@ describe("QueryEditor search shortcuts", () => { it("opens search and replace in editable editors", () => { const openSearch = vi.fn(() => true); const openReplace = vi.fn(() => true); - const bindings = createQueryEditorSearchKeymap({ openSearch, openReplace, isReadOnly: () => false }); + const bindings = createQueryEditorSearchKeymap({ openSearch, openReplace, isReadOnly: () => false }, "Win32"); expect(bindings.map(({ key, preventDefault }) => ({ key, preventDefault }))).toEqual([ { key: "Mod-f", preventDefault: true }, @@ -28,10 +29,46 @@ describe("QueryEditor search shortcuts", () => { expect(openReplace).toHaveBeenCalledOnce(); }); + it("leaves Cmd+H to the system on macOS and binds the platform replace key instead", () => { + const openSearch = vi.fn(() => true); + const openReplace = vi.fn(() => true); + const bindings = createQueryEditorSearchKeymap({ openSearch, openReplace, isReadOnly: () => false }, "MacIntel"); + + // ⌘H must stay unbound: the webview's preventDefault would keep AppKit's + // Hide key equivalent from ever firing (#9068). + expect(bindings.map(({ key }) => key)).not.toContain("Mod-h"); + expect(bindings.map(({ key, preventDefault }) => ({ key, preventDefault }))).toEqual([ + { key: "Mod-f", preventDefault: true }, + { key: "Alt-Mod-f", preventDefault: true }, + ]); + expect(bindings[1]?.run?.({} as never)).toBe(true); + expect(openReplace).toHaveBeenCalledOnce(); + }); + + it("keeps the macOS replace fallback while consuming it without mutation controls in read-only editors", () => { + const openReplace = vi.fn(() => true); + const bindings = createQueryEditorSearchKeymap({ openSearch: vi.fn(() => true), openReplace, isReadOnly: () => true }, "MacIntel"); + + expect(bindings[1]?.key).toBe("Alt-Mod-f"); + expect(bindings[1]?.run?.({} as never)).toBe(true); + expect(openReplace).not.toHaveBeenCalled(); + }); + + it("maps the built-in replace fallback per platform", () => { + expect(replaceFallbackKey("MacIntel")).toBe("Alt-Mod-f"); + expect(replaceFallbackKey("Win32")).toBe("Mod-h"); + expect(replaceFallbackKey("Linux x86_64")).toBe("Mod-h"); + }); + + it("routes the Nacos console replace fallback through the same platform helper", () => { + expect(nacosConsoleSource).toContain("key: replaceFallbackKey()"); + expect(nacosConsoleSource).not.toContain('key: "Mod-h"'); + }); + it("allows search but consumes replace without opening it in read-only editors", () => { const openSearch = vi.fn(() => true); const openReplace = vi.fn(() => true); - const bindings = createQueryEditorSearchKeymap({ openSearch, openReplace, isReadOnly: () => true }); + const bindings = createQueryEditorSearchKeymap({ openSearch, openReplace, isReadOnly: () => true }, "Win32"); expect(bindings[0]?.run?.({} as never)).toBe(true); expect(bindings[1]?.run?.({} as never)).toBe(true); @@ -50,4 +87,18 @@ describe("QueryEditor search shortcuts", () => { expect(editorSearchPanelSource).toContain('e.key === "Enter" && !e.shiftKey'); expect(editorSearchPanelSource).toContain('e.key === "Enter" && e.shiftKey'); }); + + it("registers the find binding before the formatSql binding so an occupied default would shadow formatSql", () => { + // The runtime keymap is first-match-wins: the find binding (shortcuts.find, + // Mod+F) is spread into QueryEditor.vue's Prec.high keymap before the + // formatSql binding (shortcuts.formatSql). A reserved-key repair that reset + // find to its default Mod+F while the user had explicitly bound formatSql to + // Mod+F would make formatSql unreachable — this ordering is why + // normalizeShortcutSettings clears such a repair instead of applying it. + const findIndex = queryEditorSource.indexOf("...binding(shortcuts.find, openSearch),"); + const formatIndex = queryEditorSource.indexOf("...binding(shortcuts.formatSql,"); + expect(findIndex).toBeGreaterThan(-1); + expect(formatIndex).toBeGreaterThan(-1); + expect(findIndex).toBeLessThan(formatIndex); + }); }); diff --git a/apps/desktop/src/components/editor/__tests__/QueryEditorSearchKeymap.spec.ts b/apps/desktop/src/components/editor/__tests__/QueryEditorSearchKeymap.spec.ts index b6981a3f58..1e2732cadd 100644 --- a/apps/desktop/src/components/editor/__tests__/QueryEditorSearchKeymap.spec.ts +++ b/apps/desktop/src/components/editor/__tests__/QueryEditorSearchKeymap.spec.ts @@ -3,7 +3,7 @@ // pi-lens-ignore: typescript:2307 import { EditorState, Prec } from "@codemirror/state"; // pi-lens-ignore: typescript:2307 -import { EditorView, keymap, runScopeHandlers } from "@codemirror/view"; +import { EditorView, keymap, runScopeHandlers, type KeyBinding } from "@codemirror/view"; // pi-lens-ignore: typescript:2307 import { describe, expect, it, vi } from "vitest"; // pi-lens-ignore: typescript:2307 @@ -11,83 +11,107 @@ import { createQueryEditorExecutionShortcutBindings } from "@/lib/editor/queryEd // pi-lens-ignore: typescript:2307 import { createQueryEditorReplaceShortcutBindings, createQueryEditorReplaceShortcutHandler, createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap"; +// CodeMirror resolves `Mod` from the *host's* `navigator.platform` once at +// module load, so a synthetic key event cannot influence it. Spelling the +// modifier out in the binding keys (Meta/Ctrl) and in the dispatched event +// makes each case deterministic on every host. `Mod` is the only +// platform-dependent part of normalizeKeyName: `Meta-r` and `Ctrl-r` normalize +// identically everywhere. (Same idiom as codemirrorDefaultKeymap.spec.ts.) +const platforms = [ + { name: "macOS", platform: "MacIntel", modifier: "Meta", event: { metaKey: true } }, + { name: "Windows", platform: "Win32", modifier: "Ctrl", event: { ctrlKey: true } }, +] as const; + +const withModifier = (bindings: readonly KeyBinding[], modifier: "Meta" | "Ctrl"): KeyBinding[] => bindings.map((binding) => ({ ...binding, key: binding.key?.replace("Mod", modifier) })); + describe("QueryEditor search keymap precedence", () => { - it("runs configured editor actions before the built-in search fallback", () => { - const formatSql = vi.fn(() => true); - const openSearch = vi.fn(() => true); - const view = new EditorView({ - parent: document.createElement("div"), - state: EditorState.create({ - extensions: [Prec.high(keymap.of([{ key: "Mod-f", run: formatSql }, ...createQueryEditorSearchKeymap({ openSearch, openReplace: () => true, isReadOnly: () => false })]))], - }), + for (const { name, modifier, event: modifierEvent } of platforms) { + it(`${name}: runs configured editor actions before the built-in search fallback`, () => { + const formatSql = vi.fn(() => true); + const openSearch = vi.fn(() => true); + const view = new EditorView({ + parent: document.createElement("div"), + state: EditorState.create({ + extensions: [Prec.high(keymap.of([{ key: `${modifier}-f`, run: formatSql }, ...withModifier(createQueryEditorSearchKeymap({ openSearch, openReplace: () => true, isReadOnly: () => false }), modifier)]))], + }), + }); + + expect(runScopeHandlers(view, new KeyboardEvent("keydown", { key: "f", ...modifierEvent }), "editor")).toBe(true); + expect(formatSql).toHaveBeenCalledOnce(); + expect(openSearch).not.toHaveBeenCalled(); + view.destroy(); }); - expect(runScopeHandlers(view, new KeyboardEvent("keydown", { key: "f", ctrlKey: true }), "editor")).toBe(true); - expect(formatSql).toHaveBeenCalledOnce(); - expect(openSearch).not.toHaveBeenCalled(); - view.destroy(); - }); + it(`${name}: executes a custom Shift+Mod+R shortcut instead of the Mod+R replace binding`, () => { + const openReplace = vi.fn(() => true); + const executeSql = vi.fn(() => true); + const view = new EditorView({ + parent: document.createElement("div"), + state: EditorState.create({ + extensions: [ + Prec.high( + EditorView.domEventHandlers({ + keydown: createQueryEditorReplaceShortcutHandler({ shortcut: "Mod+R", openReplace, isReadOnly: () => false }), + }), + ), + Prec.high( + keymap.of([ + ...withModifier(createQueryEditorReplaceShortcutBindings("Mod+R", openReplace), modifier), + ...withModifier( + createQueryEditorExecutionShortcutBindings("Shift+Mod+R", executeSql, () => false), + modifier, + ), + ]), + ), + ], + }), + }); - it("executes a custom Shift+Mod+R shortcut instead of the Mod+R replace binding", () => { - const openReplace = vi.fn(() => true); - const executeSql = vi.fn(() => true); - const view = new EditorView({ - parent: document.createElement("div"), - state: EditorState.create({ - extensions: [ - Prec.high( - EditorView.domEventHandlers({ - keydown: createQueryEditorReplaceShortcutHandler({ shortcut: "Mod+R", openReplace, isReadOnly: () => false }), - }), - ), - Prec.high(keymap.of([...createQueryEditorReplaceShortcutBindings("Mod+R", openReplace), ...createQueryEditorExecutionShortcutBindings("Shift+Mod+R", executeSql, () => false)])), - ], - }), + const event = new KeyboardEvent("keydown", { key: "R", keyCode: 82, code: "KeyR", ...modifierEvent, shiftKey: true, bubbles: true, cancelable: true }); + Object.defineProperty(event, "keyCode", { get: () => 82 }); + view.contentDOM.dispatchEvent(event); + expect(executeSql).toHaveBeenCalledOnce(); + expect(openReplace).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(true); + view.destroy(); }); - const event = new KeyboardEvent("keydown", { key: "r", ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true }); - view.contentDOM.dispatchEvent(event); - expect(executeSql).toHaveBeenCalledOnce(); - expect(openReplace).not.toHaveBeenCalled(); - expect(event.defaultPrevented).toBe(true); - view.destroy(); - }); + it(`${name}: keeps the default Mod+R replace action working`, () => { + const openReplace = vi.fn(() => true); + const view = new EditorView({ + parent: document.createElement("div"), + state: EditorState.create({ + extensions: [ + Prec.high( + EditorView.domEventHandlers({ + keydown: createQueryEditorReplaceShortcutHandler({ shortcut: "Mod+R", openReplace, isReadOnly: () => false }), + }), + ), + ], + }), + }); - it("keeps the default Mod+R replace action working", () => { - const openReplace = vi.fn(() => true); - const view = new EditorView({ - parent: document.createElement("div"), - state: EditorState.create({ - extensions: [ - Prec.high( - EditorView.domEventHandlers({ - keydown: createQueryEditorReplaceShortcutHandler({ shortcut: "Mod+R", openReplace, isReadOnly: () => false }), - }), - ), - ], - }), + const event = new KeyboardEvent("keydown", { key: "r", ...modifierEvent, bubbles: true, cancelable: true }); + view.contentDOM.dispatchEvent(event); + expect(openReplace).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(true); + view.destroy(); }); - const event = new KeyboardEvent("keydown", { key: "r", ctrlKey: true, bubbles: true, cancelable: true }); - view.contentDOM.dispatchEvent(event); - expect(openReplace).toHaveBeenCalledOnce(); - expect(event.defaultPrevented).toBe(true); - view.destroy(); - }); + it(`${name}: keeps the search fallback above lower-priority CodeMirror bindings`, () => { + const openSearch = vi.fn(() => true); + const lowerPrioritySearch = vi.fn(() => true); + const view = new EditorView({ + parent: document.createElement("div"), + state: EditorState.create({ + extensions: [keymap.of([{ key: `${modifier}-f`, run: lowerPrioritySearch }]), Prec.high(keymap.of(withModifier(createQueryEditorSearchKeymap({ openSearch, openReplace: () => true, isReadOnly: () => false }), modifier)))], + }), + }); - it("keeps the search fallback above lower-priority CodeMirror bindings", () => { - const openSearch = vi.fn(() => true); - const lowerPrioritySearch = vi.fn(() => true); - const view = new EditorView({ - parent: document.createElement("div"), - state: EditorState.create({ - extensions: [keymap.of([{ key: "Mod-f", run: lowerPrioritySearch }]), Prec.high(keymap.of(createQueryEditorSearchKeymap({ openSearch, openReplace: () => true, isReadOnly: () => false })))], - }), + expect(runScopeHandlers(view, new KeyboardEvent("keydown", { key: "f", ...modifierEvent }), "editor")).toBe(true); + expect(openSearch).toHaveBeenCalledOnce(); + expect(lowerPrioritySearch).not.toHaveBeenCalled(); + view.destroy(); }); - - expect(runScopeHandlers(view, new KeyboardEvent("keydown", { key: "f", ctrlKey: true }), "editor")).toBe(true); - expect(openSearch).toHaveBeenCalledOnce(); - expect(lowerPrioritySearch).not.toHaveBeenCalled(); - view.destroy(); - }); + } }); diff --git a/apps/desktop/src/components/nacos/NacosAdminConsole.vue b/apps/desktop/src/components/nacos/NacosAdminConsole.vue index aa0bcd6078..977e67405d 100644 --- a/apps/desktop/src/components/nacos/NacosAdminConsole.vue +++ b/apps/desktop/src/components/nacos/NacosAdminConsole.vue @@ -53,6 +53,7 @@ import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLa import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; import { editorFontTheme, loadEditorTheme } from "@/lib/editor/editorThemes"; import { clampEditorFontSize, createEditorWheelZoomGestureGuard, createEditorZoomCommitScheduler, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom"; +import { replaceFallbackKey } from "@/lib/editor/queryEditorSearchKeymap"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { useSettingsStore } from "@/stores/settingsStore"; import { useTheme } from "@/composables/useTheme"; @@ -613,7 +614,7 @@ async function mountConfigEditor() { basicSetup, EditorState.allowMultipleSelections.of(true), trimmedSelectionLayer(), - Prec.highest(keymap.of([{ key: "Mod-f", run: () => configSearchPanelRef.value?.openSearch() ?? false, preventDefault: true }, { key: "Mod-h", run: () => configSearchPanelRef.value?.openReplace() ?? false, preventDefault: true }, indentWithTab])), + Prec.highest(keymap.of([{ key: "Mod-f", run: () => configSearchPanelRef.value?.openSearch() ?? false, preventDefault: true }, { key: replaceFallbackKey(), run: () => configSearchPanelRef.value?.openReplace() ?? false, preventDefault: true }, indentWithTab])), keymap.of([...defaultKeymap, ...historyKeymap]), EditorView.domEventHandlers({ wheel(event, eventView) { diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 277aee4de6..01efb92b46 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -7392,6 +7392,7 @@ export default withEnglishFallback({ shortcutSearchPlaceholder: "Qısayolları axtar", 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.", 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 0f7118db59..d12dc6d06e 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -7576,6 +7576,7 @@ export default { shortcutSearchPlaceholder: "Search shortcuts", 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.", 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 0ee0f136f3..6bc0467672 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -7089,6 +7089,7 @@ export default withEnglishFallback({ shortcutSearchPlaceholder: "Buscar atajos", 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.", 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 583b78ec0e..9f232d200a 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -7089,6 +7089,7 @@ export default withEnglishFallback({ shortcutSearchPlaceholder: "Cerca scorciatoie", shortcutSearchNoResults: "Nessuna scorciatoia corrisponde alla ricerca.", shortcutConflict: "Questa scorciatoia è in conflitto con un'altra azione nello stesso ambito.", + shortcutReserved: "Questa scorciatoia è riservata dal sistema.", 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 87164db762..0e0c709edc 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -7095,6 +7095,7 @@ export default withEnglishFallback({ shortcutSearchPlaceholder: "ショートカットを検索", shortcutSearchNoResults: "一致するショートカットはありません。", shortcutConflict: "このショートカットは同じスコープ内の別のアクションと競合しています。", + shortcutReserved: "このショートカットはシステムで予約されています。", shortcutClear: "ショートカットをクリア", preview: "ライブプレビュー", previewSyntaxErrorHint: "プレビューは読み取り専用です。デモ SQL 内の FOM は意図的な構文エラーで、エラーのハイライトを示しています。", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 2e221a330a..7800de0634 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -6860,6 +6860,7 @@ export default withEnglishFallback({ shortcutSearchPlaceholder: "단축키 검색", shortcutSearchNoResults: "검색과 일치하는 단축키가 없습니다.", shortcutConflict: "이 단축키는 같은 범위의 다른 작업과 충돌합니다.", + shortcutReserved: "이 단축키는 시스템에서 예약되었습니다.", 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 ec4bd510c1..35a6fe9b13 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -7091,6 +7091,7 @@ export default withEnglishFallback({ shortcutSearchPlaceholder: "Pesquisar atalhos", shortcutSearchNoResults: "Nenhum atalho corresponde à pesquisa.", shortcutConflict: "Este atalho conflita com outra ação no mesmo escopo.", + shortcutReserved: "Este atalho é reservado pelo sistema.", 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 bfe385e46f..9c3ccb8b35 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -7273,6 +7273,7 @@ export default withEnglishFallback({ shortcutSearchPlaceholder: "Kısayollarda ara", 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.", 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 5e5432726b..e596ff3043 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -7552,6 +7552,7 @@ export default withEnglishFallback({ shortcutSearchPlaceholder: "搜索快捷键", shortcutSearchNoResults: "没有匹配的快捷键。", shortcutConflict: "这个快捷键与同一作用域内的其他操作冲突。", + shortcutReserved: "该快捷键由系统保留,请换一个组合。", 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 f376e72421..d7ded4a219 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -6387,6 +6387,7 @@ export default withEnglishFallback({ shortcutSearchPlaceholder: "搜尋快速鍵", shortcutSearchNoResults: "沒有符合的快速鍵。", shortcutConflict: "這個快速鍵與同一作用域內的其他操作衝突。", + shortcutReserved: "此快速鍵由系統保留,請改用其他組合。", shortcutClear: "清除快速鍵", preview: "即時預覽", previewSyntaxErrorHint: "預覽為唯讀。範例 SQL 中的 FOM 拼寫是刻意設置的語法錯誤,用於示範錯誤高亮效果。", diff --git a/apps/desktop/src/lib/__tests__/editor/codemirrorSearchKeymap.spec.ts b/apps/desktop/src/lib/__tests__/editor/codemirrorSearchKeymap.spec.ts index a3557d04cb..09d42af348 100644 --- a/apps/desktop/src/lib/__tests__/editor/codemirrorSearchKeymap.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/codemirrorSearchKeymap.spec.ts @@ -6,6 +6,19 @@ import { searchKeymap } from "@codemirror/search"; import { describe, expect, it } from "vitest"; import { searchKeymapWithoutModD } from "@/lib/editor/codemirrorSearchKeymap"; +// CodeMirror resolves `Mod` from the *host's* `navigator.platform` once at +// module load, so a synthetic key event cannot influence it. Spelling the +// modifier out in the binding keys (Meta/Ctrl) and in the dispatched event +// makes each case deterministic on every host. `Mod` is the only +// platform-dependent part of normalizeKeyName: `Meta-d` and `Ctrl-d` normalize +// identically everywhere. (Same idiom as codemirrorDefaultKeymap.spec.ts.) +const platforms = [ + { name: "macOS", modifier: "Meta", event: { metaKey: true } }, + { name: "Windows", modifier: "Ctrl", event: { ctrlKey: true } }, +] as const; + +const withModifier = (bindings: readonly { key: string }[], modifier: "Meta" | "Ctrl") => bindings.map((binding) => ({ ...binding, key: binding.key.replace("Mod", modifier) })); + describe("searchKeymapWithoutModD", () => { it("drops the built-in Mod-d binding from the search keymap", () => { const filtered = searchKeymapWithoutModD(searchKeymap); @@ -22,27 +35,29 @@ describe("searchKeymapWithoutModD", () => { expect(keys).toContain("F3"); }); - it("baseline: the stock searchKeymap consumes Ctrl+D (the bug being fixed)", () => { - const view = new EditorView({ - parent: document.createElement("div"), - state: EditorState.create({ - extensions: [keymap.of([...searchKeymap])], - }), + for (const { name, modifier, event } of platforms) { + it(`${name}: baseline — the stock searchKeymap consumes Mod+D (the bug being fixed)`, () => { + const view = new EditorView({ + parent: document.createElement("div"), + state: EditorState.create({ + extensions: [keymap.of(withModifier(searchKeymap as { key: string }[], modifier))], + }), + }); + + expect(runScopeHandlers(view, new KeyboardEvent("keydown", { key: "d", ...event }), "editor")).toBe(true); + view.destroy(); }); - expect(runScopeHandlers(view, new KeyboardEvent("keydown", { key: "d", ctrlKey: true }), "editor")).toBe(true); - view.destroy(); - }); + it(`${name}: no longer consumes Mod+D, letting the event bubble to the global shortcut handler`, () => { + const view = new EditorView({ + parent: document.createElement("div"), + state: EditorState.create({ + extensions: [keymap.of(withModifier(searchKeymapWithoutModD(searchKeymap) as { key: string }[], modifier))], + }), + }); - it("no longer consumes Ctrl+D, letting the event bubble to the global shortcut handler", () => { - const view = new EditorView({ - parent: document.createElement("div"), - state: EditorState.create({ - extensions: [keymap.of([...searchKeymapWithoutModD(searchKeymap)])], - }), + expect(runScopeHandlers(view, new KeyboardEvent("keydown", { key: "d", ...event }), "editor")).toBe(false); + view.destroy(); }); - - expect(runScopeHandlers(view, new KeyboardEvent("keydown", { key: "d", ctrlKey: true }), "editor")).toBe(false); - view.destroy(); - }); + } }); diff --git a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts index 25731575a6..36b8d8eb78 100644 --- a/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/shortcutRegistry.spec.ts @@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest"; import { closeOtherTabsDefaultShortcut, DEFAULT_SHORTCUT_SETTINGS, - SHORTCUT_DEFINITIONS, findShortcutConflict, formatShortcut, + isReservedShortcut, + MACOS_RESERVED_SHORTCUTS, normalizeModifierOnlyShortcut, normalizeShortcutSettings, selectionOccurrenceDefaultShortcut, + SHORTCUT_DEFINITIONS, shortcutToCodeMirrorKey, toggleAiPanelDefaultShortcut, type ShortcutActionId, @@ -362,4 +364,71 @@ describe("shortcutRegistry editor actions", () => { it("converts multi-stroke shortcuts for CodeMirror keymaps", () => { expect(shortcutToCodeMirrorKey("Ctrl+K Ctrl+C")).toBe("Ctrl-k Ctrl-c"); }); + + it("reserves only the macOS Hide combinations that the app menu owns", () => { + expect(MACOS_RESERVED_SHORTCUTS).toEqual(new Set(["Mod+H", "Alt+Mod+H"])); + // Meta+H and Mod+H are the same key on macOS; Shift+Mod+H is NOT reserved + // because no menu key equivalent uses the shifted form. + expect(isReservedShortcut("Mod+H", "MacIntel")).toBe(true); + expect(isReservedShortcut("Meta+H", "MacIntel")).toBe(true); + expect(isReservedShortcut("Cmd+H", "MacIntel")).toBe(true); + expect(isReservedShortcut("Alt+Mod+H", "MacIntel")).toBe(true); + // eventToShortcut 记录的 ⌥⌘H 顺序是 Mod+Alt+H(修饰键顺序不同),比较必须不敏感于顺序。 + expect(isReservedShortcut("Mod+Alt+H", "MacIntel")).toBe(true); + expect(isReservedShortcut("Meta+Alt+H", "MacIntel")).toBe(true); + // 反例:Ctrl+H / Control+H 是同一物理组合(CodeMirror 的 deleteCharBackward), + // 不是 DBX 应用菜单的 accelerator,绝不能误判为保留键。 + expect(isReservedShortcut("Ctrl+H", "MacIntel")).toBe(false); + expect(isReservedShortcut("Control+H", "MacIntel")).toBe(false); + expect(isReservedShortcut("Shift+Mod+H", "MacIntel")).toBe(false); + expect(isReservedShortcut("Mod+R", "MacIntel")).toBe(false); + // On Windows/Linux Mod expands to Ctrl, so Ctrl+H stays a legitimate replace alias. + expect(isReservedShortcut("Mod+H", "Win32")).toBe(false); + expect(isReservedShortcut("Mod+H", "Linux x86_64")).toBe(false); + }); + + it("repairs a mac-synced reserved replace/find shortcut to the platform default", () => { + // 云同步把 Windows 上的 Ctrl+H 当作 replace 显式配置带到 macOS,此处应修复 + // 为该动作在 macOS 上的平台默认值 Mod+R(而不是清空),确保 ⌘H 不被劫持。 + expect(normalizeShortcutSettings({ replace: "Mod+H" }, "MacIntel").replace).toBe("Mod+R"); + expect(normalizeShortcutSettings({ replace: "Meta+H" }, "MacIntel").replace).toBe("Mod+R"); + expect(normalizeShortcutSettings({ replace: "Alt+Mod+H" }, "MacIntel").replace).toBe("Mod+R"); + // eventToShortcut 记录的 ⌥⌘H 是 Mod+Alt+H,两个顺序都必须修复到平台默认值。 + expect(normalizeShortcutSettings({ replace: "Mod+Alt+H" }, "MacIntel").replace).toBe("Mod+R"); + expect(normalizeShortcutSettings({ find: "Mod+H" }, "MacIntel").find).toBe("Mod+F"); + // ⌃H 不是保留键:手工编辑或同步进来的 ⌃H 绑定原样保留,绝不修复。 + expect(normalizeShortcutSettings({ replace: "Ctrl+H" }, "MacIntel").replace).toBe("Ctrl+H"); + // 未保留的 Shift+Mod+H 原样保留,其他动作的默认值不受影响。 + expect(normalizeShortcutSettings({ replace: "Shift+Mod+H" }, "MacIntel").replace).toBe("Shift+Mod+H"); + expect(normalizeShortcutSettings({ replace: "Mod+R" }, "MacIntel").replace).toBe("Mod+R"); + expect(normalizeShortcutSettings({ replace: "Mod+H" }, "MacIntel").find).toBe("Mod+F"); + // Windows/Linux 上 Mod+H = Ctrl+H 必须原样保留(正常的替换键)。 + expect(normalizeShortcutSettings({ replace: "Mod+H" }, "Win32").replace).toBe("Mod+H"); + expect(normalizeShortcutSettings({ replace: "Mod+H" }, "Linux x86_64").replace).toBe("Mod+H"); + }); + + it("clears a reserved-key repair when the platform default is already occupied by an explicit config", () => { + // find 的平台默认值是 Mod+F。若用户把 formatSql 显式配置为 Mod+F、而 find 又被 + // 云同步/旧配置带入 macOS 保留键 ⌘H,修复会把 find 还原成 Mod+F,恰好抢占 + // formatSql——QueryEditor.vue 的 keymap 里 find 绑定注册在 formatSql 之前, + // 先匹配先执行,formatSql 就永远不可达了。用户显式配置必须赢,因此被占用的 + // 修复动作只能清空("" = 未绑定),而不是把默认值强加回去。 + const findOccupied = normalizeShortcutSettings({ find: "Mod+H", formatSql: "Mod+F" }, "MacIntel"); + expect(findOccupied.find).toBe(""); + expect(findOccupied.formatSql).toBe("Mod+F"); + // 同理由 replace 的平台默认值 Mod+R 与显式配置的 formatSql 冲突时清空 replace。 + const replaceOccupied = normalizeShortcutSettings({ replace: "Mod+H", formatSql: "Mod+R" }, "MacIntel"); + expect(replaceOccupied.replace).toBe(""); + expect(replaceOccupied.formatSql).toBe("Mod+R"); + // 无占用时仍按原逻辑修复到平台默认值。 + expect(normalizeShortcutSettings({ replace: "Mod+H" }, "MacIntel").replace).toBe("Mod+R"); + expect(normalizeShortcutSettings({ find: "Mod+H" }, "MacIntel").find).toBe("Mod+F"); + // 占用者仅来自默认(未显式配置)时不清空;⌃H 不是保留键,也原样保留。 + expect(normalizeShortcutSettings({ find: "Mod+H", replace: "Ctrl+H" }, "MacIntel").find).toBe("Mod+F"); + expect(normalizeShortcutSettings({ replace: "Ctrl+H" }, "MacIntel").replace).toBe("Ctrl+H"); + // 非 mac 平台 Mod+H = Ctrl+H 不是保留键,原样保留且不触发清空。 + const windows = normalizeShortcutSettings({ find: "Mod+H", formatSql: "Mod+F" }, "Win32"); + expect(windows.find).toBe("Mod+H"); + expect(windows.formatSql).toBe("Mod+F"); + }); }); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlShortcutActions.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlShortcutActions.spec.ts index 1d4a83bdc3..17b1ef58c7 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlShortcutActions.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlShortcutActions.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_SHORTCUT_SETTINGS } from "@/lib/editor/shortcutRegistry"; import { enabledSqlShortcutActions, findSqlShortcutConflicts, hasSqlShortcutConflicts, resolveSqlShortcutTemplate } from "@/lib/sql/sqlShortcutActions"; import type { SqlShortcutAction } from "@/types/database"; @@ -61,6 +61,11 @@ describe("findSqlShortcutConflicts", () => { }); describe("normalizeSqlShortcuts", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + it("preserves a single shortcut through normalizeEditorSettings", async () => { const { normalizeEditorSettings } = await import("@/stores/settingsStore"); const source = [action("count", "Mod+Shift+C", { label: "Count rows", sql: "SELECT COUNT(*) FROM ${table}" })]; @@ -85,6 +90,27 @@ describe("normalizeSqlShortcuts", () => { expect(normalized.map((item) => item.id)).toEqual(["a", "b"]); }); + it("drops a macOS-reserved shortcut so legacy/synced SQL shortcuts cannot hijack ⌘H", async () => { + vi.stubGlobal("navigator", { platform: "MacIntel" }); + const { normalizeEditorSettings } = await import("@/stores/settingsStore"); + // normalizeSqlShortcuts has no notion of a per-action "default", so clearing + // the binding is the repair: an unbound action simply never fires. + const normalized = normalizeEditorSettings({ + sqlShortcuts: [action("h", "Mod+H"), action("ok", "Mod+Shift+K")], + }).sqlShortcuts; + expect(normalized.find((item) => item.id === "h")?.shortcut).toBe(""); + expect(normalized.find((item) => item.id === "ok")?.shortcut).toBe("Mod+Shift+K"); + expect(normalized).toHaveLength(2); + }); + + it("keeps Ctrl+H on non-mac platforms where it is a legitimate SQL shortcut", async () => { + const { normalizeEditorSettings } = await import("@/stores/settingsStore"); + const normalized = normalizeEditorSettings({ + sqlShortcuts: [action("h", "Mod+H")], + }).sqlShortcuts; + expect(normalized.find((item) => item.id === "h")?.shortcut).toBe("Mod+H"); + }); + it("blocks saving when canonical duplicates conflict at validation time", () => { const actions = [action("a", "Mod+Shift+1"), action("b", "Shift+Mod+1")]; expect(hasSqlShortcutConflicts(actions, DEFAULT_SHORTCUT_SETTINGS)).toBe(true); diff --git a/apps/desktop/src/lib/editor/__tests__/queryEditorSearchKeymapMac.spec.ts b/apps/desktop/src/lib/editor/__tests__/queryEditorSearchKeymapMac.spec.ts new file mode 100644 index 0000000000..bcefb1ddf7 --- /dev/null +++ b/apps/desktop/src/lib/editor/__tests__/queryEditorSearchKeymapMac.spec.ts @@ -0,0 +1,161 @@ +// @vitest-environment happy-dom + +// pi-lens-ignore: typescript:2307 +import { afterEach, describe, expect, it, vi } from "vitest"; + +// CodeMirror reads `navigator.platform` once at module load to decide how `Mod` +// expands (`browser.mac ? "mac" : …` in @codemirror/view), so the harness stubs +// the navigator and re-imports the packages — same approach as +// queryEditorExecutionShortcutWebKit.spec.ts. +const macNavigator = { + maxTouchPoints: 0, + platform: "MacIntel", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15", + vendor: "Apple Computer, Inc.", +}; +const winNavigator = { + maxTouchPoints: 0, + platform: "Win32", + userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/140.0.0.0 Safari/537.36", + vendor: "", +}; + +interface MacKeyInit { + key: string; + code: string; + keyCode: number; + altKey?: boolean; + metaKey?: boolean; + ctrlKey?: boolean; + shiftKey?: boolean; +} + +function keyEvent(init: MacKeyInit): KeyboardEvent { + const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }); + // KeyboardEvent's init dict ignores keyCode, but CodeMirror's macOS + // Option-letter fallback matches on `base[keyCode]` (⌥F arrives as key "ƒ"), + // which a real webview always provides. + Object.defineProperty(event, "keyCode", { get: () => init.keyCode }); + return event; +} + +async function createMacSearchHarness() { + vi.stubGlobal("navigator", macNavigator); + vi.resetModules(); + + const [{ EditorState }, { EditorView, keymap }, { createQueryEditorSearchKeymap }] = await Promise.all([import("@codemirror/state"), import("@codemirror/view"), import("../../editor/queryEditorSearchKeymap")]); + const openSearch = vi.fn(() => true); + const openReplace = vi.fn(() => true); + const root = document.createElement("div"); + document.body.append(root); + const view = new EditorView({ + parent: root, + state: EditorState.create({ + // No explicit platform: this exercises the production wiring, where the + // editor surface passes options only and the keymap reads the navigator. + extensions: [keymap.of(createQueryEditorSearchKeymap({ openSearch, openReplace, isReadOnly: () => false }))], + }), + }); + return { view, openSearch, openReplace }; +} + +// Composed regression for the *configurable* replace path (#9068 config layer): +// normalizeShortcutSettings must repair a synced `replace: "Mod+H"` to the mac +// default `Mod+R`, so the replace bindings+handler built from the normalized +// value can no longer consume ⌘H — while the same normalized "Mod+H" on Win32 +// (where it means Ctrl+H) must still consume Ctrl+H. Runs under a stubbed mac +// navigator, so CodeMirror resolves Mod→Meta for the keymap and matchesShortcut +// resolves Mod→Meta for the DOM handler, exactly like production on a mac. +async function createReservedKeyHarness(platform: "mac" | "win") { + vi.stubGlobal("navigator", platform === "mac" ? macNavigator : winNavigator); + vi.resetModules(); + + const [{ EditorState, Prec }, { EditorView, keymap }, { normalizeShortcutSettings }, { createQueryEditorReplaceShortcutBindings, createQueryEditorReplaceShortcutHandler }] = await Promise.all([ + import("@codemirror/state"), + import("@codemirror/view"), + import("../../editor/shortcutRegistry"), + import("../../editor/queryEditorSearchKeymap"), + ]); + const shortcut = normalizeShortcutSettings({ replace: "Mod+H" }, platform === "mac" ? "MacIntel" : "Win32").replace; + const openReplace = vi.fn(() => true); + const view = new EditorView({ + parent: document.createElement("div"), + state: EditorState.create({ + extensions: [ + Prec.high( + EditorView.domEventHandlers({ + keydown: createQueryEditorReplaceShortcutHandler({ shortcut, openReplace, isReadOnly: () => false }), + }), + ), + Prec.high(keymap.of(createQueryEditorReplaceShortcutBindings(shortcut, openReplace))), + ], + }), + }); + return { shortcut, view, openReplace }; +} + +afterEach(() => { + document.body.innerHTML = ""; + vi.unstubAllGlobals(); +}); + +describe("QueryEditor search keymap on macOS", () => { + it("opens replace with the platform shortcut (⌥⌘F) instead of ⌘H", async () => { + const { view, openReplace } = await createMacSearchHarness(); + + const event = keyEvent({ key: "ƒ", code: "KeyF", keyCode: 70, altKey: true, metaKey: true }); + view.contentDOM.dispatchEvent(event); + + expect(openReplace).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(true); + view.destroy(); + }); + + it("leaves ⌘H to the system so macOS can hide the app", async () => { + const { view, openReplace } = await createMacSearchHarness(); + + const event = keyEvent({ key: "h", code: "KeyH", keyCode: 72, metaKey: true }); + view.contentDOM.dispatchEvent(event); + + expect(openReplace).not.toHaveBeenCalled(); + // Not consumed: the key event must reach AppKit's Hide menu equivalent. + expect(event.defaultPrevented).toBe(false); + view.destroy(); + }); + + it("still opens search with ⌘F", async () => { + const { view, openSearch } = await createMacSearchHarness(); + + const event = keyEvent({ key: "f", code: "KeyF", keyCode: 70, metaKey: true }); + view.contentDOM.dispatchEvent(event); + + expect(openSearch).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(true); + view.destroy(); + }); + + it("a configurable replace that was synced as ⌘H is repaired and no longer consumes ⌘H", async () => { + const { shortcut, view, openReplace } = await createReservedKeyHarness("mac"); + + // normalizeShortcutSettings({ replace: "Mod+H" }, "MacIntel") must repair to Mod+R. + expect(shortcut).toBe("Mod+R"); + const event = keyEvent({ key: "h", code: "KeyH", keyCode: 72, metaKey: true }); + view.contentDOM.dispatchEvent(event); + + expect(openReplace).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + view.destroy(); + }); + + it("on Win32 the same synced Mod+H (Ctrl+H) stays a live replace shortcut", async () => { + const { shortcut, view, openReplace } = await createReservedKeyHarness("win"); + + expect(shortcut).toBe("Mod+H"); + const event = keyEvent({ key: "h", code: "KeyH", keyCode: 72, ctrlKey: true }); + view.contentDOM.dispatchEvent(event); + + expect(openReplace).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(true); + view.destroy(); + }); +}); diff --git a/apps/desktop/src/lib/editor/queryEditorSearchKeymap.ts b/apps/desktop/src/lib/editor/queryEditorSearchKeymap.ts index 7207ffa1dc..7a820fe5f6 100644 --- a/apps/desktop/src/lib/editor/queryEditorSearchKeymap.ts +++ b/apps/desktop/src/lib/editor/queryEditorSearchKeymap.ts @@ -1,4 +1,5 @@ import { matchesShortcut } from "@/lib/editor/keyboardShortcuts"; +import { isMacShortcutPlatform } from "@/lib/editor/shortcutDisplay"; import { shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry"; // pi-lens-ignore: typescript:2307 import type { KeyBinding } from "@codemirror/view"; @@ -9,7 +10,22 @@ interface QueryEditorSearchKeymapOptions { isReadOnly: () => boolean; } -export function createQueryEditorSearchKeymap(options: QueryEditorSearchKeymapOptions): KeyBinding[] { +/** + * CodeMirror key for the built-in replace fallback (the non-configurable alias + * that keeps replace reachable when the user clears the `replace` shortcut). + * + * macOS must not bind `Mod-h`: ⌘H is the system-wide Hide-App shortcut, and + * because this binding calls `preventDefault()` the event never reaches AppKit, + * so the menu's Hide key equivalent cannot fire and DBX becomes impossible to + * hide with the standard shortcut (#9068). macOS therefore uses the platform's + * conventional find-and-replace key — ⌥⌘F, as in VS Code and TextEdit — + * while Windows/Linux keep Ctrl+H. + */ +export function replaceFallbackKey(platform = globalThis.navigator?.platform || ""): string { + return isMacShortcutPlatform(platform) ? "Alt-Mod-f" : "Mod-h"; +} + +export function createQueryEditorSearchKeymap(options: QueryEditorSearchKeymapOptions, platform = globalThis.navigator?.platform || ""): KeyBinding[] { return [ { key: "Mod-f", @@ -17,7 +33,7 @@ export function createQueryEditorSearchKeymap(options: QueryEditorSearchKeymapOp run: options.openSearch, }, { - key: "Mod-h", + key: replaceFallbackKey(platform), preventDefault: true, // Consume the shortcut in previews without exposing mutation controls. run: () => options.isReadOnly() || options.openReplace(), diff --git a/apps/desktop/src/lib/editor/shortcutRegistry.ts b/apps/desktop/src/lib/editor/shortcutRegistry.ts index c4fe2eb4cf..8667e584dd 100644 --- a/apps/desktop/src/lib/editor/shortcutRegistry.ts +++ b/apps/desktop/src/lib/editor/shortcutRegistry.ts @@ -1,4 +1,4 @@ -import { isMacShortcutPlatform, parseShortcutStrokes, shortcutDisplayParts } from "@/lib/editor/shortcutDisplay"; +import { isMacShortcutPlatform, parseShortcutParts, parseShortcutStrokes, shortcutDisplayParts } from "@/lib/editor/shortcutDisplay"; export type ShortcutActionId = | "executeSql" @@ -644,6 +644,7 @@ export function needsTabNavigationHistoryShortcutMigration(settings?: Partial, platform = globalThis.navigator?.platform || ""): ShortcutSettings { + const reservedKeyRepairedActionIds = new Set(); const normalized = Object.fromEntries( SHORTCUT_DEFINITIONS.map((definition) => { const configuredValue = settings?.[definition.id]; @@ -661,6 +662,14 @@ export function normalizeShortcutSettings(settings?: Partial, configured = definition.defaultShortcut; } const normalized = definition.inputKind === "modifier-only" ? normalizeModifierOnlyShortcut(configured, definition.defaultShortcut) : configured; + // 用户显式配置(或云同步带入)的 macOS 保留键会重新劫持 ⌘H(#9068 的 + // 配置层复现:CodeMirror 对匹配的绑定 preventDefault,AppKit 菜单的 Hide + // key equivalent 永远收不到),因此解析后如果命中保留键,则回退到该动作 + // 的平台默认值——而非清空——与上面云同步跨平台默认值的修复行为保持一致。 + if (isReservedShortcut(normalized, platform)) { + reservedKeyRepairedActionIds.add(definition.id); + return [definition.id, platformDefault]; + } return [definition.id, normalized]; }), ) as ShortcutSettings; @@ -679,6 +688,21 @@ export function normalizeShortcutSettings(settings?: Partial, if (occupiedByExistingAction) normalized[actionId] = ""; } + // 修复后的平台默认值如果被同一作用域内另一动作的显式配置占用,则清空而非套用: + // 默认值不是用户的选择,强加回去会让用户刻意绑定的动作变得不可达——运行时的 + // keymap 先匹配先执行(QueryEditor.vue 中 find 绑定注册在 formatSql 之前, + // 二者默认都是 Mod+F 时 formatSql 永远轮不到)。用户显式配置必须赢, + // 占用者只统计显式配置(hasExplicitShortcut),默认值之间的“占位”不算。 + // 与上面 TAB_NAVIGATION_HISTORY_ACTIONS 的占用判定同构;清空后由上方 + // copyCurrentRow/editTableStructure 迁移与 tab 导航占位判定依赖的值均不受影响。 + for (const actionId of reservedKeyRepairedActionIds) { + const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); + if (!definition) continue; + const repairedValue = normalized[actionId]; + const occupiedByExistingAction = SHORTCUT_DEFINITIONS.some((item) => item.id !== actionId && item.scope === definition.scope && hasExplicitShortcut(settings, item.id) && shortcutsUseSameKeys(normalized[item.id], repairedValue, platform)); + if (occupiedByExistingAction) normalized[actionId] = ""; + } + return normalized; } @@ -705,6 +729,47 @@ export function formatShortcut(shortcut: string, platform = globalThis.navigator .join("+"); } +// macOS 上由系统/应用菜单占用的快捷键(DBX 自己的应用子菜单, +// src-tauri/src/lib.rs): +// Mod+H Hide,⌘H +// Alt+Mod+H Hide Others,⌥⌘H +// Web 视图中任何绑定只要 preventDefault 都会挡住 AppKit 的菜单 key +// equivalent —— 这正是 #9068 里 ⌘H 打开替换面板、应用无法隐藏的机制, +// 所以配置层必须把这些键从所有可绑定动作中排除出去。 +// 刻意不包含 ⌘M(Minimize)与 ⌘Q(Quit):它们不在报告的问题范围内, +// 加入会静默解绑已有用户配置;将来如需覆盖,扩展该集合即可。 +export const MACOS_RESERVED_SHORTCUTS: ReadonlySet = new Set(["Mod+H", "Alt+Mod+H"]); + +// 本模块内的规范比较形式:在 macOS 分支上(isReservedShortcut 已短路掉非 mac +// 平台),Mod 永远是 ⌘,因此把 Meta/Cmd 别名写成 Mod 后再解析为 Meta,与 +// Ctrl/Control 区分开——不能复用 canonicalShortcutKey,它按 "Win32" 把 Mod +// 展开为 Ctrl,会把 ⌃H 误判成 ⌘H。修饰键排序、键名小写,使 +// Alt+Mod+H 与 Mod+Alt+H 归一为同一个串。 +function macCanonicalShortcut(shortcut: string): string { + // 保留键判定必须先 trim:配置层消费时会对值做 trim(createQueryEditorReplaceShortcutHandler + // 等),手工编辑设置 JSON 时误留的前后空白不能成为绕过保留键判定的途径。 + const parts = parseShortcutParts(shortcut.replace(/\b(?:Meta|Cmd)\b/g, "Mod").trim()); + if (parts.length === 0) return ""; + const key = parts[parts.length - 1] ?? ""; + const modifiers = parts + .slice(0, -1) + .map((modifier) => (modifier === "Mod" ? "Meta" : modifier === "Control" ? "Ctrl" : modifier)) + .sort() + .join("+"); + return `${modifiers}${modifiers ? "+" : ""}${key}`.toLowerCase(); +} + +export function isReservedShortcut(shortcut: string, platform = globalThis.navigator?.platform || ""): boolean { + // 仅 macOS 保留:Mod+H 在 Windows/Linux 上展开为 Ctrl+H(正常的替换键), + // 必须原样保留。该短路条件必须先于下面的比较执行。 + if (!shortcut || !isMacShortcutPlatform(platform)) return false; + const canonical = macCanonicalShortcut(shortcut); + for (const reserved of MACOS_RESERVED_SHORTCUTS) { + if (macCanonicalShortcut(reserved) === canonical) return true; + } + return false; +} + export function findShortcutConflict(actionId: ShortcutActionId, shortcut: string, shortcuts: ShortcutSettings, platform = globalThis.navigator?.platform || ""): ShortcutActionId | null { if (!shortcut) return null; const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index 1ac55150f3..910f2cd206 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -14,7 +14,7 @@ import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize"; import { DEFAULT_QUERY_RESULT_MAX_ROWS, normalizeQueryResultMaxRows } from "@/lib/dataGrid/queryResultRowLimit"; import { normalizeExternalSqlEditorMaxMb } from "@/lib/sql/sqlFileOpen"; import { DEFAULT_QUERY_TIMEOUT_SECS, normalizeConnectTimeoutSecs, normalizeQueryTimeoutSecs } from "@/lib/connection/timeoutLimits"; -import { needsTabNavigationHistoryShortcutMigration, normalizeShortcutSettings, type ShortcutSettings } from "@/lib/editor/shortcutRegistry"; +import { needsTabNavigationHistoryShortcutMigration, normalizeShortcutSettings, isReservedShortcut, type ShortcutSettings } from "@/lib/editor/shortcutRegistry"; import type { SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget"; import type { ConnectionListSortMode } from "@/lib/sidebar/connectionListSort"; import { type ColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy"; @@ -1357,10 +1357,15 @@ function normalizeSqlShortcuts(value: unknown, existing?: SqlShortcutAction[]): if (!item || typeof item !== "object" || typeof item.id !== "string" || !item.id || typeof item.label !== "string" || !item.label || typeof item.shortcut !== "string" || typeof item.sql !== "string") { continue; } + const shortcut = item.shortcut.trim(); + // SQL 快捷键走 createQueryEditorSqlShortcutDomHandler:匹配后 preventDefault, + // 与普通动作一样会重新劫持 macOS 的 ⌘H。此处直接丢弃保留组合——SQL 快捷键 + // 没有“平台默认值”这一概念(它是用户自定义模板的专属触发键),清空即视为未绑定。 + const normalizedShortcut = isReservedShortcut(shortcut) ? "" : shortcut; valid.push({ id: item.id, label: item.label, - shortcut: item.shortcut.trim(), + shortcut: normalizedShortcut, sql: item.sql, enabled: item.enabled !== false, });