import { isMacShortcutPlatform, parseShortcutParts, parseShortcutStrokes, shortcutDisplayParts } from "@/lib/editor/shortcutDisplay"; export type ShortcutActionId = | "executeSql" | "executeSqlInNewResultTab" | "explainSql" | "formatSql" | "expandSelectStar" | "toggleLineComment" | "toggleBlockComment" | "saveSql" | "acceptCompletion" | "triggerCompletion" | "indentMore" | "indentLess" | "insertLineBelow" | "joinLines" | "duplicateLine" | "deleteLine" | "moveLineUp" | "moveLineDown" | "copyLineUp" | "copyLineDown" | "undo" | "redo" | "selectAll" | "extendSelection" | "addNextSelectionOccurrence" | "selectAllSelectionOccurrences" | "uppercaseSelection" | "lowercaseSelection" | "convertNamingStyle" | "exPasteSqlInCondition" | "toggleFold" | "editTableStructure" | "copyCurrentRow" | "deleteCurrentRow" | "goToColumn" | "goToFirstPage" | "goToPreviousPage" | "goToNextPage" | "goToLastPage" | "newQuery" | "openSettings" | "closeTab" | "closeOtherTabs" | "focusSearch" | "quickOpen" | "globalSearch" | "toggleAiPanel" | "navigateTabHistoryBack" | "navigateTabHistoryForward" | "tabSwitcher" | "switchToPreviousTab" | "switchToNextTab" | "switchToTab1" | "switchToTab2" | "switchToTab3" | "switchToTab4" | "switchToTab5" | "switchToTab6" | "switchToTab7" | "switchToTab8" | "switchToTab9" | "zoomInUi" | "zoomOutUi" | "resetUiZoom" | "find" | "gotoLine" | "replace" | "refreshData" | "toggleResultsPane" | "toggleTranspose" | "cancelSearch" | "toggleSidebar" | "toggleZenMode" | "copySidebarSelection" | "pasteSidebarSelection" | "editSidebarConnection" | "openDataInNewTab" | "viewTableDdl" | "sendSelectionToAi" | "sqlIntentionActions"; export type ShortcutScope = "global" | "editor" | "grid" | "search" | "sidebar"; export interface ShortcutDefinition { id: ShortcutActionId; labelKey: string; scope: ShortcutScope; defaultShortcut: string; inputKind?: "keyboard" | "modifier-only"; } export type ShortcutSettings = Record; // closeOtherTabs 的平台相关默认键。Windows/Linux 不用 Alt+Mod(= Ctrl+Alt, // 与国际键盘 AltGr 字符输入冲突),也不用 Ctrl+Shift+W(浏览器保留的关窗键, // Web 形态不可拦截);Shift+Alt+W 无浏览器保留冲突(Firefox accesskey // 同为 Alt+Shift+字母,属正常应用快捷键区)。 // 已知取舍:Windows 的 Alt+Shift 布局切换只在单独按下并释放时触发, // Alt+Shift+字母会正常送达应用,多语言用户如遇干扰可自定义改键。 // macOS 的 ⌥⌘W 无上述问题 export function closeOtherTabsDefaultShortcut(platform = globalThis.navigator?.platform || ""): string { return isMacShortcutPlatform(platform) ? "Alt+Mod+W" : "Shift+Alt+W"; } // 同词项选择的平台相关默认键。Windows/Linux 上 Ctrl+Mod+G 经 CodeMirror 的 // Mod→Ctrl 展开后成为不可达的 Ctrl-Ctrl-G,且 Ctrl+G 本身是编辑器内 // find-next(Mod-G 的非 mac 展开),因此非 mac 平台改用 JetBrains 风格的 // Alt+J / Ctrl+Alt+Shift+J;macOS 维持 Ctrl+G / Ctrl+Cmd+G。 export function selectionOccurrenceDefaultShortcut(actionId: "addNextSelectionOccurrence" | "selectAllSelectionOccurrences", platform = globalThis.navigator?.platform || ""): string { if (isMacShortcutPlatform(platform)) { return actionId === "addNextSelectionOccurrence" ? "Ctrl+G" : "Ctrl+Mod+G"; } return actionId === "addNextSelectionOccurrence" ? "Alt+J" : "Ctrl+Alt+Shift+J"; } export function tabNavigationHistoryDefaultShortcut(direction: "back" | "forward", platform = globalThis.navigator?.platform || ""): string { const modifier = isMacShortcutPlatform(platform) ? "Ctrl" : "Mod"; const key = direction === "back" ? "ArrowLeft" : "ArrowRight"; return `${modifier}+Alt+${key}`; } // Match the shortcut used by VS Code to open its chat sidebar on macOS while // keeping a reachable, non-conflicting equivalent on Windows/Linux. export function toggleAiPanelDefaultShortcut(platform = globalThis.navigator?.platform || ""): string { return isMacShortcutPlatform(platform) ? "Ctrl+Mod+I" : "Ctrl+Alt+I"; } // 「跳转到行」的平台默认键。Windows/Linux 上用 Ctrl+G,与 VS Code / SSMS / // DataGrip 的惯例一致(编辑器内查找下一个仍有 F3 与搜索面板回车); // macOS 的 Ctrl+G 已分配给「选中下一个相同词」(JetBrains 风格),因此沿用 // CodeMirror 搜索键位里原本就指向 gotoLine 的 ⌘⌥G。 export function gotoLineDefaultShortcut(platform = globalThis.navigator?.platform || ""): string { return isMacShortcutPlatform(platform) ? "Mod+Alt+G" : "Mod+G"; } const PLATFORM_DEFAULT_SHORTCUTS: Partial>> = { closeOtherTabs: new Set(["Alt+Mod+W", "Shift+Alt+W"]), navigateTabHistoryBack: new Set(["Ctrl+Alt+ArrowLeft", "Mod+Alt+ArrowLeft"]), navigateTabHistoryForward: new Set(["Ctrl+Alt+ArrowRight", "Mod+Alt+ArrowRight"]), addNextSelectionOccurrence: new Set(["Ctrl+G", "Alt+J"]), selectAllSelectionOccurrences: new Set(["Ctrl+Mod+G", "Ctrl+Alt+Shift+J"]), toggleAiPanel: new Set(["Ctrl+Mod+I", "Ctrl+Alt+I"]), gotoLine: new Set(["Mod+G", "Mod+Alt+G"]), }; const LEGACY_CLOSE_TAB_DEFAULT = "Meta+W"; const LEGACY_COPY_CURRENT_ROW_DEFAULT = "Mod+D"; const EDIT_TABLE_STRUCTURE_DEFAULT = "Mod+Shift+D"; const TAB_NAVIGATION_HISTORY_ACTIONS: ShortcutActionId[] = ["navigateTabHistoryBack", "navigateTabHistoryForward"]; export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [ { id: "executeSql", labelKey: "settings.shortcutExecuteSql", scope: "editor", defaultShortcut: "Mod+Enter", }, { id: "executeSqlInNewResultTab", labelKey: "settings.shortcutExecuteSqlInNewResultTab", scope: "editor", defaultShortcut: "Mod+\\", }, { id: "explainSql", labelKey: "toolbar.explainPlan", scope: "editor", defaultShortcut: "Mod+E", }, { id: "formatSql", labelKey: "settings.shortcutFormatSql", scope: "editor", defaultShortcut: "Shift+Mod+F", }, { id: "expandSelectStar", labelKey: "settings.shortcutExpandSelectStar", scope: "editor", defaultShortcut: "Mod+Shift+X", }, { id: "toggleLineComment", labelKey: "settings.shortcutToggleLineComment", scope: "editor", defaultShortcut: "Mod+/", }, { id: "toggleBlockComment", labelKey: "settings.shortcutToggleBlockComment", scope: "editor", defaultShortcut: "Shift+Alt+A", }, { id: "saveSql", labelKey: "settings.shortcutSaveSql", scope: "editor", defaultShortcut: "Mod+S", }, { id: "acceptCompletion", labelKey: "settings.shortcutAcceptCompletion", scope: "editor", defaultShortcut: "Tab", }, { id: "triggerCompletion", labelKey: "settings.shortcutTriggerCompletion", scope: "editor", defaultShortcut: "Alt+/", }, { id: "indentMore", labelKey: "settings.shortcutIndentMore", scope: "editor", defaultShortcut: "", }, { id: "indentLess", labelKey: "settings.shortcutIndentLess", scope: "editor", defaultShortcut: "Shift+Tab", }, { id: "insertLineBelow", labelKey: "settings.shortcutInsertLineBelow", scope: "editor", defaultShortcut: "Shift+Enter", }, { id: "joinLines", labelKey: "settings.shortcutJoinLines", scope: "editor", defaultShortcut: "Mod+J", }, { id: "duplicateLine", labelKey: "settings.shortcutDuplicateLine", scope: "editor", defaultShortcut: "Mod+D", }, { id: "deleteLine", labelKey: "settings.shortcutDeleteLine", scope: "editor", defaultShortcut: "Shift+Mod+K", }, { id: "moveLineUp", labelKey: "settings.shortcutMoveLineUp", scope: "editor", defaultShortcut: "Alt+ArrowUp", }, { id: "moveLineDown", labelKey: "settings.shortcutMoveLineDown", scope: "editor", defaultShortcut: "Alt+ArrowDown", }, { id: "copyLineUp", labelKey: "settings.shortcutCopyLineUp", scope: "editor", defaultShortcut: "Shift+Alt+ArrowUp", }, { id: "copyLineDown", labelKey: "settings.shortcutCopyLineDown", scope: "editor", defaultShortcut: "Shift+Alt+ArrowDown", }, { id: "undo", labelKey: "settings.shortcutUndo", scope: "editor", defaultShortcut: "Mod+Z", }, { id: "redo", labelKey: "settings.shortcutRedo", scope: "editor", defaultShortcut: "Shift+Mod+Z", }, { id: "selectAll", labelKey: "settings.shortcutSelectAll", scope: "editor", defaultShortcut: "Mod+A", }, { id: "extendSelection", labelKey: "settings.shortcutExtendSelection", scope: "editor", defaultShortcut: "Alt+W", }, { id: "addNextSelectionOccurrence", labelKey: "settings.shortcutAddNextSelectionOccurrence", scope: "editor", defaultShortcut: "Ctrl+G", }, { id: "selectAllSelectionOccurrences", labelKey: "settings.shortcutSelectAllSelectionOccurrences", scope: "editor", defaultShortcut: "Ctrl+Mod+G", }, { id: "uppercaseSelection", labelKey: "settings.shortcutUppercaseSelection", scope: "editor", defaultShortcut: "Shift+Alt+U", }, { id: "lowercaseSelection", labelKey: "settings.shortcutLowercaseSelection", scope: "editor", defaultShortcut: "Shift+Alt+L", }, { id: "convertNamingStyle", labelKey: "settings.shortcutConvertNamingStyle", scope: "editor", defaultShortcut: "Shift+Alt+C", }, { id: "exPasteSqlInCondition", labelKey: "settings.shortcutExPasteSqlInCondition", scope: "editor", defaultShortcut: "", }, { id: "toggleFold", labelKey: "settings.shortcutToggleFold", scope: "editor", defaultShortcut: "Mod+.", }, { id: "editTableStructure", labelKey: "settings.shortcutEditTableStructure", scope: "grid", defaultShortcut: EDIT_TABLE_STRUCTURE_DEFAULT, }, { id: "copyCurrentRow", labelKey: "settings.shortcutCopyCurrentRow", scope: "grid", defaultShortcut: LEGACY_COPY_CURRENT_ROW_DEFAULT, }, { id: "deleteCurrentRow", labelKey: "settings.shortcutDeleteCurrentRow", scope: "grid", defaultShortcut: "Delete", }, { id: "goToColumn", labelKey: "settings.shortcutGoToColumn", scope: "grid", defaultShortcut: "", }, { id: "goToFirstPage", labelKey: "settings.shortcutGoToFirstPage", scope: "grid", defaultShortcut: "", }, { id: "goToPreviousPage", labelKey: "settings.shortcutGoToPreviousPage", scope: "grid", defaultShortcut: "", }, { id: "goToNextPage", labelKey: "settings.shortcutGoToNextPage", scope: "grid", defaultShortcut: "", }, { id: "goToLastPage", labelKey: "settings.shortcutGoToLastPage", scope: "grid", defaultShortcut: "", }, { id: "newQuery", labelKey: "settings.shortcutNewQuery", scope: "global", defaultShortcut: "Mod+T", }, { id: "openSettings", labelKey: "settings.shortcutOpenSettings", scope: "global", defaultShortcut: "Mod+,", }, { id: "closeTab", labelKey: "settings.shortcutCloseTab", scope: "global", defaultShortcut: "Mod+W", }, { id: "closeOtherTabs", labelKey: "contextMenu.closeOtherTabs", scope: "global", defaultShortcut: closeOtherTabsDefaultShortcut(), }, { id: "focusSearch", labelKey: "settings.shortcutFocusSearch", scope: "global", defaultShortcut: "Mod+F", }, { id: "quickOpen", labelKey: "settings.shortcutQuickOpen", scope: "global", defaultShortcut: "Mod+P", }, { id: "globalSearch", labelKey: "settings.shortcutGlobalSearch", scope: "global", defaultShortcut: "Mod+Alt+F", }, { id: "toggleAiPanel", labelKey: "settings.shortcutToggleAiPanel", scope: "global", defaultShortcut: toggleAiPanelDefaultShortcut(), }, { id: "navigateTabHistoryBack", labelKey: "settings.shortcutNavigateTabHistoryBack", scope: "global", defaultShortcut: tabNavigationHistoryDefaultShortcut("back"), }, { id: "navigateTabHistoryForward", labelKey: "settings.shortcutNavigateTabHistoryForward", scope: "global", defaultShortcut: tabNavigationHistoryDefaultShortcut("forward"), }, { id: "tabSwitcher", labelKey: "settings.shortcutTabSwitcher", scope: "global", defaultShortcut: "Ctrl+Tab", }, { id: "switchToPreviousTab", labelKey: "settings.shortcutSwitchToPreviousTab", scope: "global", defaultShortcut: "Shift+Mod+[", }, { id: "switchToNextTab", labelKey: "settings.shortcutSwitchToNextTab", scope: "global", defaultShortcut: "Shift+Mod+]", }, { id: "switchToTab1", labelKey: "settings.shortcutSwitchToTab1", scope: "global", defaultShortcut: "Mod+1", }, { id: "switchToTab2", labelKey: "settings.shortcutSwitchToTab2", scope: "global", defaultShortcut: "Mod+2", }, { id: "switchToTab3", labelKey: "settings.shortcutSwitchToTab3", scope: "global", defaultShortcut: "Mod+3", }, { id: "switchToTab4", labelKey: "settings.shortcutSwitchToTab4", scope: "global", defaultShortcut: "Mod+4", }, { id: "switchToTab5", labelKey: "settings.shortcutSwitchToTab5", scope: "global", defaultShortcut: "Mod+5", }, { id: "switchToTab6", labelKey: "settings.shortcutSwitchToTab6", scope: "global", defaultShortcut: "Mod+6", }, { id: "switchToTab7", labelKey: "settings.shortcutSwitchToTab7", scope: "global", defaultShortcut: "Mod+7", }, { id: "switchToTab8", labelKey: "settings.shortcutSwitchToTab8", scope: "global", defaultShortcut: "Mod+8", }, { id: "switchToTab9", labelKey: "settings.shortcutSwitchToTab9", scope: "global", defaultShortcut: "Mod+9", }, { id: "zoomInUi", labelKey: "settings.shortcutZoomInUi", scope: "global", defaultShortcut: "Mod+=", }, { id: "zoomOutUi", labelKey: "settings.shortcutZoomOutUi", scope: "global", defaultShortcut: "Mod+-", }, { id: "resetUiZoom", labelKey: "settings.shortcutResetUiZoom", scope: "global", defaultShortcut: "Mod+0", }, { id: "find", labelKey: "settings.shortcutFind", scope: "editor", defaultShortcut: "Mod+F", }, { id: "gotoLine", labelKey: "settings.shortcutGotoLine", scope: "editor", defaultShortcut: gotoLineDefaultShortcut(), }, { id: "replace", labelKey: "settings.shortcutReplace", scope: "editor", defaultShortcut: "Mod+R", }, { id: "refreshData", labelKey: "settings.shortcutRefreshData", scope: "global", defaultShortcut: "F5", }, { id: "toggleResultsPane", labelKey: "settings.shortcutToggleResultsPane", scope: "global", defaultShortcut: "", }, { id: "toggleTranspose", labelKey: "settings.shortcutToggleTranspose", scope: "grid", defaultShortcut: "Tab", }, { id: "cancelSearch", labelKey: "settings.shortcutCancelSearch", scope: "search", defaultShortcut: "Escape", }, { id: "toggleSidebar", labelKey: "settings.shortcutToggleSidebar", scope: "global", defaultShortcut: "Mod+B", }, { id: "toggleZenMode", labelKey: "settings.shortcutToggleZenMode", scope: "global", defaultShortcut: "Shift+Mod+F12", }, { id: "copySidebarSelection", labelKey: "settings.shortcutCopySidebarSelection", scope: "sidebar", defaultShortcut: "Mod+C", }, { id: "pasteSidebarSelection", labelKey: "settings.shortcutPasteSidebarSelection", scope: "sidebar", defaultShortcut: "Mod+V", }, { id: "editSidebarConnection", labelKey: "settings.shortcutEditSidebarConnection", scope: "sidebar", defaultShortcut: "Mod+E", }, { id: "openDataInNewTab", labelKey: "settings.shortcutOpenDataInNewTab", scope: "sidebar", defaultShortcut: "Alt", inputKind: "modifier-only", }, { id: "viewTableDdl", labelKey: "settings.shortcutViewTableDdl", scope: "sidebar", defaultShortcut: "Shift+Mod+D", }, { id: "sendSelectionToAi", labelKey: "settings.shortcutSendSelectionToAi", scope: "editor", defaultShortcut: "Mod+Shift+A", }, { id: "sqlIntentionActions", labelKey: "settings.shortcutSqlIntentionActions", scope: "editor", defaultShortcut: "Shift+Mod+Enter", }, ]; export const DEFAULT_SHORTCUT_SETTINGS: ShortcutSettings = Object.fromEntries(SHORTCUT_DEFINITIONS.map((definition) => [definition.id, definition.defaultShortcut])) as ShortcutSettings; const modifierOnlyShortcuts = new Set(["Alt", "Shift", "Mod", "Ctrl", "Meta"]); export function normalizeModifierOnlyShortcut(shortcut: string, fallback = ""): string { const normalized = shortcut.trim() === "Control" ? "Ctrl" : shortcut.trim(); if (normalized === "") return ""; return modifierOnlyShortcuts.has(normalized) ? normalized : fallback; } function hasExplicitShortcut(settings: Partial | undefined, actionId: ShortcutActionId): boolean { return !!settings && Object.prototype.hasOwnProperty.call(settings, actionId) && typeof settings[actionId] === "string"; } function shortcutsUseSameKeys(first: string, second: string, platform = globalThis.navigator?.platform || ""): boolean { const comparisonKey = (shortcut: string) => formatShortcut(shortcut, platform) .trim() .split(/\s+/) .filter(Boolean) .map((stroke) => { const parts = stroke.split("+").filter(Boolean); if (parts.length <= 1) return stroke.toLowerCase(); const key = parts.pop()!; return [...parts.sort(), key].join("+").toLowerCase(); }) .join(" "); return !!first && !!second && comparisonKey(first) === comparisonKey(second); } function shortcutDefaultForPlatform(definition: ShortcutDefinition, platform: string): string { if (definition.id === "addNextSelectionOccurrence") return selectionOccurrenceDefaultShortcut("addNextSelectionOccurrence", platform); if (definition.id === "selectAllSelectionOccurrences") return selectionOccurrenceDefaultShortcut("selectAllSelectionOccurrences", platform); if (definition.id === "closeOtherTabs") return closeOtherTabsDefaultShortcut(platform); if (definition.id === "gotoLine") return gotoLineDefaultShortcut(platform); if (definition.id === "navigateTabHistoryBack") return tabNavigationHistoryDefaultShortcut("back", platform); if (definition.id === "navigateTabHistoryForward") return tabNavigationHistoryDefaultShortcut("forward", platform); if (definition.id === "toggleAiPanel") return toggleAiPanelDefaultShortcut(platform); return definition.defaultShortcut; } export function needsTabNavigationHistoryShortcutMigration(settings?: Partial): boolean { return !!settings && TAB_NAVIGATION_HISTORY_ACTIONS.some((actionId) => !hasExplicitShortcut(settings, actionId)); } export function normalizeShortcutSettings(settings?: Partial, platform = globalThis.navigator?.platform || ""): ShortcutSettings { const reservedKeyRepairedActionIds = new Set(); const normalized = Object.fromEntries( SHORTCUT_DEFINITIONS.map((definition) => { const configuredValue = settings?.[definition.id]; const platformDefault = shortcutDefaultForPlatform(definition, platform); let configured = typeof configuredValue === "string" ? configuredValue : platformDefault; // 云同步会把另一平台的默认值当作显式配置带过来。平台默认集合内的值视为 // 未自定义,按本机平台重新解析;用户真正自定义的其他组合原样保留 const platformDefaults = PLATFORM_DEFAULT_SHORTCUTS[definition.id]; if (platformDefaults?.has(configured)) { configured = platformDefault; } // Meta+W was the old macOS-only default. Treat that exact value as a // legacy default so existing Windows/Linux settings adopt Ctrl+W. if (definition.id === "closeTab" && configured === LEGACY_CLOSE_TAB_DEFAULT) { 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; if (settings?.copyCurrentRow === "" && settings?.editTableStructure === LEGACY_COPY_CURRENT_ROW_DEFAULT) { normalized.editTableStructure = EDIT_TABLE_STRUCTURE_DEFAULT; normalized.copyCurrentRow = LEGACY_COPY_CURRENT_ROW_DEFAULT; } // gotoLine 的平台默认键(Win/Linux 为 Ctrl+G)在该动作引入前是其他动作可合法 // 显式绑定的自由键位;无显式配置的默认值与同作用域显式绑定同键时同样让位, // 否则运行时 keymap 先注册的 gotoLine 会遮蔽用户的显式配置。 for (const actionId of [...TAB_NAVIGATION_HISTORY_ACTIONS, "gotoLine"] as ShortcutActionId[]) { if (hasExplicitShortcut(settings, actionId)) continue; const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); if (!definition) continue; const defaultShortcut = normalized[actionId]; const occupiedByExistingAction = SHORTCUT_DEFINITIONS.some((item) => item.id !== actionId && item.scope === definition.scope && hasExplicitShortcut(settings, item.id) && shortcutsUseSameKeys(normalized[item.id], defaultShortcut, platform)); 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; } export interface CapturedShortcutEdit { /** 归一化后的完整键位表:与 store 落盘口径一致,草稿直接采用它就不会与落盘值分叉。 */ shortcuts: ShortcutSettings; /** * 用户按下的组合命中了“另一平台的默认键”规则:落盘时该字面量会被换成本机平台 * 的默认键,所以这个组合在本平台不可能被保存(见上方 PLATFORM_DEFAULT_SHORTCUTS)。 */ rejectedByPlatformDefault: boolean; /** 归一化是否改动了任意一行(跨平台默认键、旧默认键、遗留迁移都会改动)。 */ changed: boolean; } /** * 捕获阶段的归一化:把用户在设置面板按下的组合先过一遍落盘时会走的 * `normalizeShortcutSettings`,让草稿始终是持久化值的**不动点**。 * * 设置面板的“有无未保存改动”是拿草稿和 store 里的值做 JSON 比较 * (`editorSettingsDraftChanged`)得来的:草稿里只要存在一个会被落盘口径改写的 * 值,这个比较就永远为真——「应用」看起来毫无反应,「应用并关闭」每次都弹 * “未保存的更改”,改了多少次都存不进去(#9881:把「关闭其他标签页」改成 ⇧⌥W, * 落盘时被跨平台默认键规则换回 ⌥⌘W)。调用方拿到结果后必须采用 `shortcuts`, * 并在 `rejectedByPlatformDefault` 为真时提示用户换一个组合。 */ export function resolveCapturedShortcutEdit(actionId: ShortcutActionId, shortcut: string, shortcuts: ShortcutSettings, platform = globalThis.navigator?.platform || ""): CapturedShortcutEdit { const next = normalizeShortcutSettings({ ...shortcuts, [actionId]: shortcut }, platform); const rejectedByPlatformDefault = next[actionId] !== shortcut && (PLATFORM_DEFAULT_SHORTCUTS[actionId]?.has(shortcut) === true || (actionId === "closeTab" && shortcut === LEGACY_CLOSE_TAB_DEFAULT)); return { shortcuts: next, rejectedByPlatformDefault, changed: SHORTCUT_DEFINITIONS.some((definition) => next[definition.id] !== shortcuts[definition.id]), }; } export function shortcutToCodeMirrorKey(shortcut: string): string { return parseShortcutStrokes(shortcut) .map((parts) => parts .map((part) => (part.length === 1 ? part.toLowerCase() : part)) .map((part) => (part === "Plus" ? "+" : part)) .join("-"), ) .join(" "); } export function formatShortcut(shortcut: string, platform = globalThis.navigator?.platform || ""): string { const isMac = platform.toLowerCase().includes("mac"); return shortcutDisplayParts(shortcut, platform) .map((part) => { if (part === "Mod") return isMac ? "Cmd" : "Ctrl"; if (part === "Meta") return isMac ? "Cmd" : "Meta"; if (part === "Plus") return "+"; return part; }) .join("+"); } // macOS 上由系统/应用菜单占用的快捷键(DBX 自己的应用子菜单, // src-tauri/src/lib.rs): // Mod+H Hide,⌘H // Alt+Mod+H Hide Others,⌥⌘H // Web 视图中任何绑定只要 preventDefault 都会挡住 AppKit 的菜单 key // equivalent —— 这正是 #9068 里 ⌘H 打开替换面板、应用无法隐藏的机制, // 所以配置层必须把这些键从所有可绑定动作中排除出去。 // 刻意不包含 ⌘M(Minimize)与 ⌘Q(Quit):它们不在报告的问题范围内, // 加入会静默解绑已有用户配置;将来如需覆盖,扩展该集合即可。 export const MACOS_RESERVED_SHORTCUTS: ReadonlySet = new Set(["Mod+H", "Alt+Mod+H"]); // 本模块内的规范比较形式:在 macOS 分支上(isReservedShortcut 已短路掉非 mac // 平台),Mod 永远是 ⌘,因此把 Meta/Cmd 别名写成 Mod 后再解析为 Meta,与 // Ctrl/Control 区分开——不能复用 canonicalShortcutKey,它按 "Win32" 把 Mod // 展开为 Ctrl,会把 ⌃H 误判成 ⌘H。修饰键排序、键名小写,使 // Alt+Mod+H 与 Mod+Alt+H 归一为同一个串。 function macCanonicalShortcut(shortcut: string): string { // 保留键判定必须先 trim:配置层消费时会对值做 trim(createQueryEditorReplaceShortcutHandler // 等),手工编辑设置 JSON 时误留的前后空白不能成为绕过保留键判定的途径。 const parts = parseShortcutParts(shortcut.replace(/\b(?:Meta|Cmd)\b/g, "Mod").trim()); if (parts.length === 0) return ""; const key = parts[parts.length - 1] ?? ""; const modifiers = parts .slice(0, -1) .map((modifier) => (modifier === "Mod" ? "Meta" : modifier === "Control" ? "Ctrl" : modifier)) .sort() .join("+"); return `${modifiers}${modifiers ? "+" : ""}${key}`.toLowerCase(); } export function isReservedShortcut(shortcut: string, platform = globalThis.navigator?.platform || ""): boolean { // 仅 macOS 保留:Mod+H 在 Windows/Linux 上展开为 Ctrl+H(正常的替换键), // 必须原样保留。该短路条件必须先于下面的比较执行。 if (!shortcut || !isMacShortcutPlatform(platform)) return false; const canonical = macCanonicalShortcut(shortcut); for (const reserved of MACOS_RESERVED_SHORTCUTS) { if (macCanonicalShortcut(reserved) === canonical) return true; } return false; } export function findShortcutConflict(actionId: ShortcutActionId, shortcut: string, shortcuts: ShortcutSettings, platform = globalThis.navigator?.platform || ""): ShortcutActionId | null { if (!shortcut) return null; const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId); if (!definition) return null; 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> { const conflicts: Partial> = {}; 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>): number { const pairs = new Set(); 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; }