feat(editor): group shortcuts by scope with conflict tiers

Closes #9075
This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-16 21:36:55 +08:00
1 parent b2d56625e0
commit ea1de14517
17 files changed
+970 -80

No files matched your search

@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
closeOtherTabsDefaultShortcut,
countShortcutConflictPairs,
DEFAULT_SHORTCUT_SETTINGS,
findCrossScopeShortcutConflicts,
findShortcutConflict,
formatShortcut,
isReservedShortcut,
@@ -431,4 +433,69 @@ describe("shortcutRegistry editor actions", () => {
expect(windows.find).toBe("Mod+H");
expect(windows.formatSql).toBe("Mod+F");
});
describe("findCrossScopeShortcutConflicts", () => {
it("reports the intentional cross-scope overlaps in the defaults", () => {
// 默认配置里确实存在跨作用域同键,这是设计使然;设置界面把它作为“提示”
// 展示,而 normalizeShortcutSettings 的占用判定刻意不管它们。
const conflicts = findCrossScopeShortcutConflicts(DEFAULT_SHORTCUT_SETTINGS);
expect(conflicts.find).toContain("focusSearch");
expect(conflicts.focusSearch).toContain("find");
expect(conflicts.acceptCompletion).toContain("toggleTranspose");
expect(conflicts.explainSql).toContain("editSidebarConnection");
// Shift+Mod+D 与 Mod+Shift+D 经 formatShortcut 规范化后同键。
expect(conflicts.viewTableDdl).toContain("editTableStructure");
});
it("never reports same-scope duplicates (those are blocking conflicts)", () => {
// uppercaseSelection 改成 Mod+A 只与同作用域的 selectAll 重复,
// 跨作用域提示不应把它列出来——那属于 findShortcutConflict 的职责。
const shortcuts = normalizeShortcutSettings({ uppercaseSelection: "Mod+A" });
expect(findShortcutConflict("uppercaseSelection", shortcuts.uppercaseSelection, shortcuts)).toBe("selectAll");
expect(findCrossScopeShortcutConflicts(shortcuts).uppercaseSelection).toBeUndefined();
});
it("ignores unbound shortcuts", () => {
const shortcuts = normalizeShortcutSettings({ find: "", focusSearch: "" });
expect(shortcuts.find).toBe("");
expect(shortcuts.focusSearch).toBe("");
const conflicts = findCrossScopeShortcutConflicts(shortcuts);
expect(conflicts.find).toBeUndefined();
expect(conflicts.focusSearch).toBeUndefined();
// goToColumn 默认未绑定,也不参与比较。
expect(conflicts.goToColumn).toBeUndefined();
});
it("resolves platform-dependent keys before comparing", () => {
// Win32 上 Mod+F 展开为 Ctrl+F,两边仍然是同键,提示照旧成立;
// 未自定义的组合也不会被误判。
const windows = findCrossScopeShortcutConflicts(normalizeShortcutSettings(undefined, "Win32"), "Win32");
expect(windows.find).toContain("focusSearch");
const mac = findCrossScopeShortcutConflicts(normalizeShortcutSettings(undefined, "MacIntel"), "MacIntel");
expect(mac.find).toContain("focusSearch");
});
it("does not mutate the settings it inspects", () => {
const shortcuts = normalizeShortcutSettings({ duplicateLine: "Mod+Alt+K" });
const before = { ...shortcuts };
findCrossScopeShortcutConflicts(shortcuts);
expect(shortcuts).toEqual(before);
});
});
describe("countShortcutConflictPairs", () => {
it("counts an unordered pair once even though the map is bidirectional", () => {
expect(countShortcutConflictPairs({ find: "focusSearch", focusSearch: "find" })).toBe(1);
});
it("flattens multi-partner entries", () => {
expect(countShortcutConflictPairs({ find: ["focusSearch", "formatSql"], formatSql: ["find"] })).toBe(2);
});
it("ignores empty entries", () => {
expect(countShortcutConflictPairs({})).toBe(0);
expect(countShortcutConflictPairs({ find: "" })).toBe(0);
expect(countShortcutConflictPairs({ find: [] })).toBe(0);
});
});
});
@@ -778,3 +778,49 @@ export function findShortcutConflict(actionId: ShortcutActionId, shortcut: strin
const conflict = SHORTCUT_DEFINITIONS.find((item) => item.id !== actionId && item.scope === definition.scope && shortcutsUseSameKeys(shortcuts[item.id], shortcut, platform));
return conflict?.id ?? null;
}
/**
* 跨作用域同键(**仅用于提示,不参与任何修复**)。
*
* 不同作用域共用同一个组合是**有意的设计**:`find`(editor)与
* `focusSearch`(global)默认都是 `Mod+F`,运行时由焦点/目标路由区分
* (`App.vue` 在 `defaultPrevented` 时让位,`ContentArea.focusSearch()`
* 按聚焦面路由)。因此本函数只**只读地报告**这类重叠,供设置界面以
* “提示”级别展示;绝不能拿它的结果去改写配置——`normalizeShortcutSettings`
* 的占用判定刻意只统计**同作用域**(见该函数内的两张占用轮),跨作用域
* 重叠不在其范围内。
*
* 同作用域重复**不**在此返回(那是必须阻断的冲突,见 `findShortcutConflict`)。
* 未绑定(空串)不参与比较。比较口径与 `findShortcutConflict` 一致:
* `formatShortcut` 展开 `Mod`、修饰键排序、大小写不敏感,因此
* `Mod+Shift+D` 与 `Shift+Mod+D` 视为同键。
*/
export function findCrossScopeShortcutConflicts(shortcuts: ShortcutSettings, platform = globalThis.navigator?.platform || ""): Partial<Record<ShortcutActionId, ShortcutActionId[]>> {
const conflicts: Partial<Record<ShortcutActionId, ShortcutActionId[]>> = {};
for (const definition of SHORTCUT_DEFINITIONS) {
const shortcut = shortcuts[definition.id];
if (!shortcut) continue;
const others = SHORTCUT_DEFINITIONS.filter((item) => item.id !== definition.id && item.scope !== definition.scope && shortcutsUseSameKeys(shortcuts[item.id], shortcut, platform)).map((item) => item.id);
if (others.length > 0) conflicts[definition.id] = others;
}
return conflicts;
}
/**
* 去重后的重复对数。行→对方 的映射会把同一对重复算两次(双向各一次),
* 而摘要文案要说的是“有多少组重复”,故统一按无序对折算。
*
* 同时接受 L1(每行单个对方)与 L2(每行多个对方)两种形状,
* 让同作用域与跨作用域的计数口径保持一致。
*/
export function countShortcutConflictPairs(conflicts: Partial<Record<ShortcutActionId, ShortcutActionId | ShortcutActionId[]>>): number {
const pairs = new Set<string>();
for (const [actionId, value] of Object.entries(conflicts)) {
if (!value) continue;
for (const other of Array.isArray(value) ? value : [value]) {
if (!other) continue;
pairs.add([actionId, other].sort().join("|"));
}
}
return pairs.size;
}