From ba82b28eeb351817cf4c4bf133964bf882cc69d8 Mon Sep 17 00:00:00 2001 From: bendsp Date: Sat, 9 May 2026 16:30:26 +0200 Subject: [PATCH] add: keybinding conflict warnings --- src/renderer/components/SettingsModal.tsx | 89 ++++++++++++++--------- src/renderer/keybindings.ts | 37 +++++++++- tests/index.ts | 1 + tests/keybindings.test.ts | 40 ++++++++++ 4 files changed, 129 insertions(+), 38 deletions(-) create mode 100644 tests/keybindings.test.ts diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 9c8c875..fffe0f9 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -6,6 +6,7 @@ import { } from "../settings"; import { eventToBinding, + findKeyBindingConflicts, formatBinding, keyBindingLabels, isModifierKey, @@ -104,6 +105,10 @@ const SettingsModal = ({ ); const [pendingBinding, setPendingBinding] = useState(null); const [appVersion, setAppVersion] = useState(null); + const keyBindingConflicts = useMemo( + () => findKeyBindingConflicts(settings.keyBindings), + [settings.keyBindings] + ); const originalBindingRef = useRef<{ action: KeyBindingAction | null; binding: string | null; @@ -679,6 +684,7 @@ const SettingsModal = ({ ).map((action, index, arr) => { const isActive = listeningFor === action; const isLast = index === arr.length - 1; + const conflicts = keyBindingConflicts[action] ?? []; return ( {keyBindingLabels[action]} - - {action === "new" - ? "Create a new markdown file." - : action === "open" - ? "Open a markdown file." - : action === "save" - ? "Save the current file." - : action === "saveAs" - ? "Save the current file with a new name." - : action === "openSettings" - ? "Open this settings dialog." - : action === "undo" - ? "Undo the last change." - : action === "redo" - ? "Redo the last undone change." - : action === "find" - ? "Search for text in the current file." - : action === "bold" - ? "Toggle bold markdown (**…**) for the selection or word." - : action === "italic" - ? "Toggle italic markdown (*…*) for the selection or word." - : action === "link" - ? "Insert a markdown link, or wrap the selection." - : action === "inlineCode" - ? "Toggle inline code markdown (`…`) for the selection or word." - : action === "strikethrough" - ? "Toggle strikethrough markdown (~~…~~) for the selection or word." - : action === "unorderedList" - ? "Toggle a bulleted list for the selected lines." - : action === "orderedList" - ? "Toggle a numbered list for the selected lines." - : action === "taskList" - ? "Toggle a task checklist for the selected lines." - : action === "blockquote" - ? "Toggle a blockquote for the selected lines." - : "Wrap the selected lines in a fenced code block."} + + + {action === "new" + ? "Create a new markdown file." + : action === "open" + ? "Open a markdown file." + : action === "save" + ? "Save the current file." + : action === "saveAs" + ? "Save the current file with a new name." + : action === "openSettings" + ? "Open this settings dialog." + : action === "undo" + ? "Undo the last change." + : action === "redo" + ? "Redo the last undone change." + : action === "find" + ? "Search for text in the current file." + : action === "bold" + ? "Toggle bold markdown (**…**) for the selection or word." + : action === "italic" + ? "Toggle italic markdown (*…*) for the selection or word." + : action === "link" + ? "Insert a markdown link, or wrap the selection." + : action === "inlineCode" + ? "Toggle inline code markdown (`…`) for the selection or word." + : action === "strikethrough" + ? "Toggle strikethrough markdown (~~…~~) for the selection or word." + : action === "unorderedList" + ? "Toggle a bulleted list for the selected lines." + : action === "orderedList" + ? "Toggle a numbered list for the selected lines." + : action === "taskList" + ? "Toggle a task checklist for the selected lines." + : action === "blockquote" + ? "Toggle a blockquote for the selected lines." + : "Wrap the selected lines in a fenced code block."} + + {conflicts.length > 0 ? ( + + Conflicts with{" "} + {conflicts + .map((conflict) => keyBindingLabels[conflict]) + .join(", ")} + . + + ) : null} diff --git a/src/renderer/keybindings.ts b/src/renderer/keybindings.ts index 02b9d93..6cd6493 100644 --- a/src/renderer/keybindings.ts +++ b/src/renderer/keybindings.ts @@ -1,6 +1,9 @@ import { KeyBindingAction, KeyBindings } from "./settings"; -const MOD_LABEL = navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"; +const getPlatform = (): string => + typeof navigator === "undefined" ? "" : navigator.platform; + +const MOD_LABEL = getPlatform().includes("Mac") ? "Cmd" : "Ctrl"; const order = ["mod", "ctrl", "alt", "shift"] as const; @@ -108,7 +111,7 @@ export const formatBinding = (binding: string): string => { * - Elsewhere, uses readable labels with "+" (e.g. "Ctrl+Shift+B") */ export const formatBindingShortcut = (binding: string): string => { - const isMac = navigator.platform.includes("Mac"); + const isMac = getPlatform().includes("Mac"); const parts = normalizeBinding(binding).split("+").filter(Boolean); const joiner = isMac ? "" : "+"; @@ -204,3 +207,33 @@ export const clampKeyBindings = (bindings: KeyBindings): KeyBindings => ({ redo: normalizeBinding(bindings.redo), find: normalizeBinding(bindings.find), }); + +export type KeyBindingConflicts = Partial< + Record +>; + +export const findKeyBindingConflicts = ( + bindings: KeyBindings +): KeyBindingConflicts => { + const groups = new Map(); + + for (const action of Object.keys(bindings) as KeyBindingAction[]) { + const binding = normalizeBinding(bindings[action]); + if (!binding) { + continue; + } + groups.set(binding, [...(groups.get(binding) ?? []), action]); + } + + const conflicts: KeyBindingConflicts = {}; + for (const actions of groups.values()) { + if (actions.length <= 1) { + continue; + } + for (const action of actions) { + conflicts[action] = actions.filter((candidate) => candidate !== action); + } + } + + return conflicts; +}; diff --git a/tests/index.ts b/tests/index.ts index c576031..68966bf 100644 --- a/tests/index.ts +++ b/tests/index.ts @@ -1,2 +1,3 @@ import "./controllerShortcuts.test"; +import "./keybindings.test"; import "./themeSettings.test"; diff --git a/tests/keybindings.test.ts b/tests/keybindings.test.ts new file mode 100644 index 0000000..d282d37 --- /dev/null +++ b/tests/keybindings.test.ts @@ -0,0 +1,40 @@ +import { strict as assert } from "assert"; +import { + defaultKeyBindings, + KeyBindings, +} from "../src/renderer/settings"; +import { findKeyBindingConflicts } from "../src/renderer/keybindings"; + +const runTest = (name: string, fn: () => void) => { + try { + fn(); + console.log(`✓ ${name}`); + } catch (error) { + console.error(`✗ ${name}`); + console.error(error); + process.exitCode = 1; + } +}; + +runTest("keybinding conflicts are reported per action", () => { + const bindings: KeyBindings = { + ...defaultKeyBindings, + bold: "mod+b", + italic: "cmd+b", + }; + + const conflicts = findKeyBindingConflicts(bindings); + + assert.deepEqual(conflicts.bold, ["italic"]); + assert.deepEqual(conflicts.italic, ["bold"]); +}); + +runTest("unique keybindings have no conflicts", () => { + const conflicts = findKeyBindingConflicts(defaultKeyBindings); + + assert.deepEqual(conflicts, {}); +}); + +if (process.exitCode && process.exitCode !== 0) { + throw new Error("One or more tests failed."); +}