mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 06:00:39 +00:00
fix(editor): keep Cmd+H for macOS hide instead of replace
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+93
-69
@@ -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) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -7095,6 +7095,7 @@ export default withEnglishFallback({
|
||||
shortcutSearchPlaceholder: "ショートカットを検索",
|
||||
shortcutSearchNoResults: "一致するショートカットはありません。",
|
||||
shortcutConflict: "このショートカットは同じスコープ内の別のアクションと競合しています。",
|
||||
shortcutReserved: "このショートカットはシステムで予約されています。",
|
||||
shortcutClear: "ショートカットをクリア",
|
||||
preview: "ライブプレビュー",
|
||||
previewSyntaxErrorHint: "プレビューは読み取り専用です。デモ SQL 内の FOM は意図的な構文エラーで、エラーのハイライトを示しています。",
|
||||
|
||||
@@ -6860,6 +6860,7 @@ export default withEnglishFallback({
|
||||
shortcutSearchPlaceholder: "단축키 검색",
|
||||
shortcutSearchNoResults: "검색과 일치하는 단축키가 없습니다.",
|
||||
shortcutConflict: "이 단축키는 같은 범위의 다른 작업과 충돌합니다.",
|
||||
shortcutReserved: "이 단축키는 시스템에서 예약되었습니다.",
|
||||
shortcutClear: "단축키 지우기",
|
||||
preview: "실시간 미리보기",
|
||||
previewSyntaxErrorHint: "미리보기는 읽기 전용입니다. 데모 SQL의 FOM 오타는 의도적인 구문 오류이며 오류 강조 표시를 보여줍니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -7552,6 +7552,7 @@ export default withEnglishFallback({
|
||||
shortcutSearchPlaceholder: "搜索快捷键",
|
||||
shortcutSearchNoResults: "没有匹配的快捷键。",
|
||||
shortcutConflict: "这个快捷键与同一作用域内的其他操作冲突。",
|
||||
shortcutReserved: "该快捷键由系统保留,请换一个组合。",
|
||||
shortcutClear: "清除快捷键",
|
||||
preview: "实时预览",
|
||||
previewSyntaxErrorHint: "预览为只读。示例 SQL 中的 FOM 拼写是故意设置的语法错误,用于演示错误高亮效果。",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user