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

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-15 12:09:04 +08:00
1 parent a9cf5822fa
commit 3f74195835
21 files changed
+558 -100

No files matched your search

@@ -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) {