fix(editor): keep Cmd+H for macOS hide instead of replace

This commit is contained in:
Abeautifulsnow
2026-09-15 12:09:04 +08:00
committed by GitHub
parent a9cf5822fa
commit 3f74195835
21 changed files with 558 additions and 100 deletions
@@ -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;
}
@@ -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);
});
});
@@ -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();
});
}
});
@@ -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) {
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -7095,6 +7095,7 @@ export default withEnglishFallback({
shortcutSearchPlaceholder: "ショートカットを検索",
shortcutSearchNoResults: "一致するショートカットはありません。",
shortcutConflict: "このショートカットは同じスコープ内の別のアクションと競合しています。",
shortcutReserved: "このショートカットはシステムで予約されています。",
shortcutClear: "ショートカットをクリア",
preview: "ライブプレビュー",
previewSyntaxErrorHint: "プレビューは読み取り専用です。デモ SQL 内の FOM は意図的な構文エラーで、エラーのハイライトを示しています。",
+1
View File
@@ -6860,6 +6860,7 @@ export default withEnglishFallback({
shortcutSearchPlaceholder: "단축키 검색",
shortcutSearchNoResults: "검색과 일치하는 단축키가 없습니다.",
shortcutConflict: "이 단축키는 같은 범위의 다른 작업과 충돌합니다.",
shortcutReserved: "이 단축키는 시스템에서 예약되었습니다.",
shortcutClear: "단축키 지우기",
preview: "실시간 미리보기",
previewSyntaxErrorHint: "미리보기는 읽기 전용입니다. 데모 SQL의 FOM 오타는 의도적인 구문 오류이며 오류 강조 표시를 보여줍니다.",
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+1
View File
@@ -7552,6 +7552,7 @@ export default withEnglishFallback({
shortcutSearchPlaceholder: "搜索快捷键",
shortcutSearchNoResults: "没有匹配的快捷键。",
shortcutConflict: "这个快捷键与同一作用域内的其他操作冲突。",
shortcutReserved: "该快捷键由系统保留,请换一个组合。",
shortcutClear: "清除快捷键",
preview: "实时预览",
previewSyntaxErrorHint: "预览为只读。示例 SQL 中的 FOM 拼写是故意设置的语法错误,用于演示错误高亮效果。",
+1
View File
@@ -6387,6 +6387,7 @@ export default withEnglishFallback({
shortcutSearchPlaceholder: "搜尋快速鍵",
shortcutSearchNoResults: "沒有符合的快速鍵。",
shortcutConflict: "這個快速鍵與同一作用域內的其他操作衝突。",
shortcutReserved: "此快速鍵由系統保留,請改用其他組合。",
shortcutClear: "清除快速鍵",
preview: "即時預覽",
previewSyntaxErrorHint: "預覽為唯讀。範例 SQL 中的 FOM 拼寫是刻意設置的語法錯誤,用於示範錯誤高亮效果。",
@@ -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();
});
}
});
@@ -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");
});
});
@@ -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);
@@ -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();
});
});
@@ -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(),
@@ -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<Sh
}
export function normalizeShortcutSettings(settings?: Partial<ShortcutSettings>, platform = globalThis.navigator?.platform || ""): ShortcutSettings {
const reservedKeyRepairedActionIds = new Set<ShortcutActionId>();
const normalized = Object.fromEntries(
SHORTCUT_DEFINITIONS.map((definition) => {
const configuredValue = settings?.[definition.id];
@@ -661,6 +662,14 @@ export function normalizeShortcutSettings(settings?: Partial<ShortcutSettings>,
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<ShortcutSettings>,
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<string> = 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);
+7 -2
View File
@@ -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,
});