From e0e2b22e4187abe9487abf4f2628f4fba16193f1 Mon Sep 17 00:00:00 2001 From: Mamdouh Date: Tue, 28 Jul 2026 20:48:08 +0300 Subject: [PATCH 1/7] Prototype shortcuts settings layouts Co-Authored-By: Oz --- apps/desktop/src/App.tsx | 68 +- .../desktop/src/components/SettingsDialog.tsx | 4 +- .../components/ShortcutsSettingsPrototype.tsx | 992 ++++++++++++++++++ 3 files changed, 1038 insertions(+), 26 deletions(-) create mode 100644 apps/desktop/src/components/ShortcutsSettingsPrototype.tsx diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 61a7a8ff..0aeb7450 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -29,6 +29,10 @@ import { import { buildAppCommands } from "./commands/useAppCommands"; import { HtmlAppEmptyState } from "./components/HtmlAppEmptyState"; import { SettingsDialog, SettingsSection } from "./components/SettingsDialog"; +import { + hasShortcutsPrototypeQuery, + ShortcutsSettingsPrototype, +} from "./components/ShortcutsSettingsPrototype"; import { type DesktopSidebarFocus, Sidebar } from "./components/Sidebar"; import { SpellcheckSettingsSection } from "./components/SpellcheckSection"; import { @@ -214,7 +218,7 @@ function App() { useHistoryNav(); const [scrollContainerEl, setScrollContainerEl] = useState(null); - const [settingsOpen, setSettingsOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(hasShortcutsPrototypeQuery); const [copyAsMarkdownRequest, setCopyAsMarkdownRequest] = useState(0); const [updateState, setUpdateState] = useState( null, @@ -699,6 +703,34 @@ function App() { }; }, []); + const settingsSections = ( + <> + {updateState ? ( + void triggerPrimaryUpdateAction()} + onViewChangelog={openWhatsNew} + /> + ) : null} + + {spellcheck ? ( + + ) : null} + + + {telemetryConsent ? ( + void chooseTelemetry(choice)} + /> + ) : null} + + ); + return (
- - {updateState ? ( - void triggerPrimaryUpdateAction()} - onViewChangelog={openWhatsNew} - /> - ) : null} - - {spellcheck ? ( - - ) : null} - - - {telemetryConsent ? ( - void chooseTelemetry(choice)} - /> - ) : null} + + {import.meta.env.DEV ? ( + + ) : ( + settingsSections + )}
); diff --git a/apps/desktop/src/components/SettingsDialog.tsx b/apps/desktop/src/components/SettingsDialog.tsx index ad26ce56..0e723628 100644 --- a/apps/desktop/src/components/SettingsDialog.tsx +++ b/apps/desktop/src/components/SettingsDialog.tsx @@ -4,10 +4,12 @@ import type { ReactNode } from "react"; export function SettingsDialog({ open, onOpenChange, + className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; + className?: string; children: ReactNode; }) { return ( @@ -15,7 +17,7 @@ export function SettingsDialog({ open={open} onOpenChange={onOpenChange} title="Settings" - className="max-w-xl" + className={className ?? "max-w-xl"} >
{children}
diff --git a/apps/desktop/src/components/ShortcutsSettingsPrototype.tsx b/apps/desktop/src/components/ShortcutsSettingsPrototype.tsx new file mode 100644 index 00000000..da239787 --- /dev/null +++ b/apps/desktop/src/components/ShortcutsSettingsPrototype.tsx @@ -0,0 +1,992 @@ +import { Button, formatShortcut, Input } from "@hubble.md/ui"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; + +// PROTOTYPE — three variants of the Shortcuts settings page, switchable via +// `?variant=`, inside the existing Settings dialog. Throw away after #194 +// chooses a direction. + +const variants = [ + { id: "sidebar", label: "Sidebar navigator" }, + { id: "tabs", label: "Grouped cards" }, + { id: "table", label: "Command table" }, +] as const; + +type Variant = (typeof variants)[number]["id"]; +type Area = "App" | "Editor"; + +type Command = { + id: string; + label: string; + description: string; + area: Area; + defaultBinding: string; +}; + +type Bindings = Record; +type Errors = Record; + +const commands: Command[] = [ + { + id: "app.new-file", + label: "New File", + description: "Create a Markdown File in the open folder.", + area: "App", + defaultBinding: "CmdOrCtrl+N", + }, + { + id: "app.add-folder", + label: "Add Folder", + description: "Create and open a new folder.", + area: "App", + defaultBinding: "CmdOrCtrl+Shift+N", + }, + { + id: "app.open-file", + label: "Open", + description: "Choose a file from the filesystem.", + area: "App", + defaultBinding: "CmdOrCtrl+O", + }, + { + id: "app.open-folder", + label: "Open Folder", + description: "Switch to another recent open folder.", + area: "App", + defaultBinding: "CmdOrCtrl+Shift+O", + }, + { + id: "app.go-to-file", + label: "Go to File", + description: "Search files in the current open folder.", + area: "App", + defaultBinding: "CmdOrCtrl+P", + }, + { + id: "app.settings", + label: "Settings", + description: "Open Hubble settings.", + area: "App", + defaultBinding: "CmdOrCtrl+,", + }, + { + id: "app.go-back", + label: "Go Back", + description: "Move backward through file history.", + area: "App", + defaultBinding: "CmdOrCtrl+[", + }, + { + id: "app.go-forward", + label: "Go Forward", + description: "Move forward through file history.", + area: "App", + defaultBinding: "CmdOrCtrl+]", + }, + { + id: "app.toggle-terminal", + label: "Toggle Terminal", + description: "Show or hide the terminal panel.", + area: "App", + defaultBinding: "CmdOrCtrl+J", + }, + { + id: "app.toggle-source-mode", + label: "Toggle Source Mode", + description: "Switch between rich and source editing.", + area: "App", + defaultBinding: "Alt+CmdOrCtrl+U", + }, + { + id: "app.copy-as-markdown", + label: "Copy as Markdown", + description: "Copy the current selection as Markdown.", + area: "App", + defaultBinding: "Alt+CmdOrCtrl+C", + }, + { + id: "app.copy-path", + label: "Copy File Path", + description: "Copy the selected file path.", + area: "App", + defaultBinding: "CmdOrCtrl+Shift+C", + }, + { + id: "app.reveal", + label: "Reveal in File Manager", + description: "Reveal the selected item in Finder or Explorer.", + area: "App", + defaultBinding: "CmdOrCtrl+Alt+R", + }, + { + id: "app.chat-about-note", + label: "Chat About Note", + description: "Open the configured agent command for this note.", + area: "App", + defaultBinding: "CmdOrCtrl+Shift+J", + }, + { + id: "app.toggle-sidebar", + label: "Toggle Sidebar", + description: "Show or hide the file sidebar.", + area: "App", + defaultBinding: "CmdOrCtrl+Shift+E", + }, + { + id: "app.delete", + label: "Delete", + description: "Delete the selected file or folder.", + area: "App", + defaultBinding: "CmdOrCtrl+Backspace", + }, + { + id: "app.find", + label: "Find", + description: "Find text in the current file.", + area: "App", + defaultBinding: "CmdOrCtrl+F", + }, + { + id: "app.format-menu", + label: "Format", + description: "Open the editor formatting menu.", + area: "App", + defaultBinding: "CmdOrCtrl+/", + }, + { + id: "editor.link", + label: "Link", + description: "Add or edit a link.", + area: "Editor", + defaultBinding: "CmdOrCtrl+K", + }, + { + id: "editor.strike", + label: "Strikethrough", + description: "Toggle strikethrough formatting.", + area: "Editor", + defaultBinding: "CmdOrCtrl+Shift+X", + }, + { + id: "editor.ordered-list", + label: "Numbered List", + description: "Toggle a numbered list.", + area: "Editor", + defaultBinding: "CmdOrCtrl+Shift+7", + }, + { + id: "editor.bullet-list", + label: "Bulleted List", + description: "Toggle a bulleted list.", + area: "Editor", + defaultBinding: "CmdOrCtrl+Shift+8", + }, + { + id: "editor.task-list", + label: "To-do List", + description: "Toggle a to-do list.", + area: "Editor", + defaultBinding: "CmdOrCtrl+Shift+9", + }, + { + id: "editor.bold", + label: "Bold", + description: "Toggle bold formatting.", + area: "Editor", + defaultBinding: "CmdOrCtrl+B", + }, + { + id: "editor.italic", + label: "Italic", + description: "Toggle italic formatting.", + area: "Editor", + defaultBinding: "CmdOrCtrl+I", + }, + { + id: "editor.code", + label: "Inline Code", + description: "Toggle inline code formatting.", + area: "Editor", + defaultBinding: "CmdOrCtrl+E", + }, + ...Array.from({ length: 6 }, (_, index): Command => { + const level = index + 1; + return { + id: `editor.heading-${level}`, + label: `Heading ${level}`, + description: `Convert the current block to heading ${level}.`, + area: "Editor", + defaultBinding: `CmdOrCtrl+Alt+${level}`, + }; + }), + { + id: "editor.blockquote", + label: "Quote", + description: "Toggle block quote formatting.", + area: "Editor", + defaultBinding: "CmdOrCtrl+Shift+B", + }, +]; + +const reservedBindings = new Set([ + "CmdOrCtrl+C", + "CmdOrCtrl+Q", + "CmdOrCtrl+V", + "CmdOrCtrl+X", + "CmdOrCtrl+Z", +]); + +function variantFromUrl(): Variant | null { + const requested = new URLSearchParams(window.location.search).get("variant"); + return variants.some(({ id }) => id === requested) + ? (requested as Variant) + : null; +} + +export function hasShortcutsPrototypeQuery() { + return import.meta.env.DEV && variantFromUrl() !== null; +} + +function initialBindings(): Bindings { + return Object.fromEntries( + commands.map((command) => [command.id, command.defaultBinding]), + ); +} + +function eventBinding(event: KeyboardEvent): string | null { + const keyAliases: Record = { + " ": "Space", + ArrowDown: "Down", + ArrowLeft: "Left", + ArrowRight: "Right", + ArrowUp: "Up", + }; + const key = keyAliases[event.key] ?? event.key; + if (["Alt", "Control", "Meta", "Shift"].includes(key)) return null; + + const parts: string[] = []; + if (event.metaKey || event.ctrlKey) parts.push("CmdOrCtrl"); + if (event.altKey) parts.push("Alt"); + if (event.shiftKey) parts.push("Shift"); + parts.push(key.length === 1 ? key.toUpperCase() : key); + return parts.join("+"); +} + +function isTypingTarget(target: EventTarget | null) { + if (!(target instanceof HTMLElement)) return false; + return ( + target.matches("input, textarea, select") || + target.closest("[contenteditable='true']") !== null + ); +} + +function usePrototypeState() { + const [bindings, setBindings] = useState(() => { + const initial = initialBindings(); + initial["app.new-file"] = "CmdOrCtrl+Alt+N"; + initial["app.chat-about-note"] = null; + return initial; + }); + const [errors, setErrors] = useState({}); + const [recordingId, setRecordingId] = useState(null); + const [query, setQuery] = useState(""); + + useEffect(() => { + if (!recordingId) return; + + const record = (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (event.key === "Escape") { + setRecordingId(null); + setErrors((current) => ({ ...current, [recordingId]: undefined })); + return; + } + if ( + (event.key === "Backspace" || event.key === "Delete") && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey + ) { + setBindings((current) => ({ ...current, [recordingId]: null })); + setRecordingId(null); + setErrors((current) => ({ ...current, [recordingId]: undefined })); + return; + } + + const binding = eventBinding(event); + if (!binding) return; + if (reservedBindings.has(binding)) { + setErrors((current) => ({ + ...current, + [recordingId]: `${formatShortcut(binding)} is reserved by the system.`, + })); + return; + } + const duplicate = commands.find( + (command) => + command.id !== recordingId && bindings[command.id] === binding, + ); + if (duplicate) { + setErrors((current) => ({ + ...current, + [recordingId]: `Already assigned to ${duplicate.label}.`, + })); + return; + } + + setBindings((current) => ({ ...current, [recordingId]: binding })); + setErrors((current) => ({ ...current, [recordingId]: undefined })); + setRecordingId(null); + }; + + window.addEventListener("keydown", record, true); + return () => window.removeEventListener("keydown", record, true); + }, [bindings, recordingId]); + + const reset = (command: Command) => { + setBindings((current) => ({ + ...current, + [command.id]: command.defaultBinding, + })); + setErrors((current) => ({ ...current, [command.id]: undefined })); + if (recordingId === command.id) setRecordingId(null); + }; + + const clear = (command: Command) => { + setBindings((current) => ({ ...current, [command.id]: null })); + setErrors((current) => ({ ...current, [command.id]: undefined })); + if (recordingId === command.id) setRecordingId(null); + }; + + const resetAll = () => { + setBindings(initialBindings()); + setErrors({}); + setRecordingId(null); + }; + + return { + bindings, + clear, + errors, + query, + recordingId, + reset, + resetAll, + setQuery, + startRecording: (id: string) => { + setErrors((current) => ({ ...current, [id]: undefined })); + setRecordingId(id); + }, + }; +} + +type PrototypeState = ReturnType; + +type PrototypeProps = { + general: ReactNode; + page: "general" | "shortcuts"; + setPage: (page: "general" | "shortcuts") => void; + state: PrototypeState; +}; + +export function ShortcutsSettingsPrototype({ + general, +}: { + general: ReactNode; +}) { + const [variant, setVariant] = useState( + () => variantFromUrl() ?? "sidebar", + ); + const [page, setPage] = useState<"general" | "shortcuts">("shortcuts"); + const state = usePrototypeState(); + + const chooseVariant = useCallback((next: Variant) => { + const url = new URL(window.location.href); + url.searchParams.set("variant", next); + window.history.replaceState(null, "", url); + setVariant(next); + }, []); + + useEffect(() => { + const cycle = (direction: -1 | 1) => { + const currentIndex = variants.findIndex(({ id }) => id === variant); + const nextIndex = + (currentIndex + direction + variants.length) % variants.length; + chooseVariant(variants[nextIndex].id); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (isTypingTarget(event.target) || state.recordingId) return; + if (event.key === "ArrowLeft") cycle(-1); + if (event.key === "ArrowRight") cycle(1); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [chooseVariant, state.recordingId, variant]); + + const props = { general, page, setPage, state }; + + return ( + <> + {variant === "sidebar" ? ( + + ) : variant === "tabs" ? ( + + ) : ( + + )} + + + ); +} + +function SidebarVariant({ general, page, setPage, state }: PrototypeProps) { + return ( +
+ +
+ {page === "general" ? ( + {general} + ) : ( + + )} +
+
+ ); +} + +function TabsVariant({ general, page, setPage, state }: PrototypeProps) { + const filtered = filteredCommands(state.query); + return ( +
+
+
+ setPage("general")} + > + General + + setPage("shortcuts")} + > + Shortcuts + +
+
+ {page === "general" ? ( + {general} + ) : ( +
+ +
+ {(["App", "Editor"] as const).map((area) => ( +
+
+

{area}

+ + {filtered.filter((command) => command.area === area).length} + +
+
+ {filtered + .filter((command) => command.area === area) + .map((command) => ( + + ))} +
+
+ ))} +
+
+ )} +
+ ); +} + +function TableVariant({ general, page, setPage, state }: PrototypeProps) { + const filtered = filteredCommands(state.query); + return ( +
+
+
+ setPage("general")} + > + General + + setPage("shortcuts")} + > + Shortcuts + +
+ {page === "shortcuts" ? ( + <> + state.setQuery(event.currentTarget.value)} + /> + + + ) : null} +
+ {page === "general" ? ( + {general} + ) : ( +
+
+ Area + Command + Binding + Action +
+
+ {filtered.map((command) => ( + + ))} +
+ {filtered.length === 0 ? : null} +
+ )} +
+ ); +} + +function ShortcutList({ state }: { state: PrototypeState }) { + const filtered = filteredCommands(state.query); + return ( +
+ +
+ {(["App", "Editor"] as const).map((area) => ( +
+
+

{area}

+ + {filtered.filter((command) => command.area === area).length}{" "} + commands + +
+
+ {filtered + .filter((command) => command.area === area) + .map((command) => ( + + ))} +
+
+ ))} + {filtered.length === 0 ? : null} +
+
+ ); +} + +function ShortcutHeader({ state }: { state: PrototypeState }) { + return ( +
+
+

Keyboard shortcuts

+

+ Select a binding, then press a new key combination. +

+
+
+ state.setQuery(event.currentTarget.value)} + /> + +
+
+ ); +} + +function ListCommandRow({ + command, + state, +}: { + command: Command; + state: PrototypeState; +}) { + const binding = state.bindings[command.id]; + const customized = binding !== command.defaultBinding; + return ( +
+
+

{command.label}

+

+ {command.description} +

+ +
+
+ + {customized ? ( + + ) : null} + +
+
+ ); +} + +function CardCommandRow({ + command, + state, +}: { + command: Command; + state: PrototypeState; +}) { + const binding = state.bindings[command.id]; + return ( +
+
+
+

{command.label}

+

+ {command.description} +

+
+ +
+
+ + {state.errors[command.id] ? null : ( +

+ {binding === null + ? "Disabled" + : binding === command.defaultBinding + ? "Default" + : "Customized"} +

+ )} +
+ {binding !== command.defaultBinding ? ( + + ) : null} + +
+
+
+ ); +} + +function TableCommandRow({ + command, + state, +}: { + command: Command; + state: PrototypeState; +}) { + const binding = state.bindings[command.id]; + return ( +
+
+ + {command.area} + +
+

{command.label}

+

+ {command.id} +

+
+ +
+ {binding !== command.defaultBinding ? ( + + ) : null} + +
+
+ +
+ ); +} + +function BindingButton({ + command, + state, +}: { + command: Command; + state: PrototypeState; +}) { + const binding = state.bindings[command.id]; + const recording = state.recordingId === command.id; + return ( + + ); +} + +function BindingError({ + error, + className = "", +}: { + error?: string; + className?: string; +}) { + return error ? ( +

{error}

+ ) : null; +} + +function GeneralPane({ children }: { children: ReactNode }) { + return ( +
{children}
+ ); +} + +function NavButton({ + active, + children, + count, + onClick, +}: { + active: boolean; + children: ReactNode; + count?: number; + onClick: () => void; +}) { + return ( + + ); +} + +function TabButton({ + active, + children, + onClick, +}: { + active: boolean; + children: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +function SegmentButton({ + active, + children, + onClick, +}: { + active: boolean; + children: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +function EmptySearch() { + return ( +

+ No shortcuts match this search. +

+ ); +} + +function filteredCommands(query: string) { + const needle = query.trim().toLocaleLowerCase(); + if (!needle) return commands; + return commands.filter((command) => + `${command.label} ${command.description} ${command.id} ${command.area}` + .toLocaleLowerCase() + .includes(needle), + ); +} + +function PrototypeSwitcher({ + current, + onChange, + recording, +}: { + current: Variant; + onChange: (variant: Variant) => void; + recording: boolean; +}) { + const currentIndex = variants.findIndex(({ id }) => id === current); + const cycle = (direction: -1 | 1) => { + const nextIndex = + (currentIndex + direction + variants.length) % variants.length; + onChange(variants[nextIndex].id); + }; + const currentVariant = variants[currentIndex]; + + return ( +
+ +
+ {currentVariant.id} + — {currentVariant.label} +
+ +
+ ); +} From 436404794c7c924a2844c8a99f9e43b26e16ebcc Mon Sep 17 00:00:00 2001 From: Mamdouh Date: Tue, 28 Jul 2026 21:26:33 +0300 Subject: [PATCH 2/7] Address shortcuts prototype review Co-Authored-By: Oz --- apps/desktop/src/App.tsx | 11 +- .../components/ShortcutsSettingsPrototype.tsx | 229 +++++++++++++----- 2 files changed, 179 insertions(+), 61 deletions(-) diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 0aeb7450..16b9398d 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -29,10 +29,7 @@ import { import { buildAppCommands } from "./commands/useAppCommands"; import { HtmlAppEmptyState } from "./components/HtmlAppEmptyState"; import { SettingsDialog, SettingsSection } from "./components/SettingsDialog"; -import { - hasShortcutsPrototypeQuery, - ShortcutsSettingsPrototype, -} from "./components/ShortcutsSettingsPrototype"; +import { ShortcutsSettingsPrototype } from "./components/ShortcutsSettingsPrototype"; import { type DesktopSidebarFocus, Sidebar } from "./components/Sidebar"; import { SpellcheckSettingsSection } from "./components/SpellcheckSection"; import { @@ -207,6 +204,12 @@ async function searchFileContents(query: string) { return { results, truncated }; } +function hasShortcutsPrototypeQuery() { + if (!import.meta.env.DEV) return false; + const variant = new URLSearchParams(window.location.search).get("variant"); + return variant === "sidebar" || variant === "tabs" || variant === "table"; +} + function App() { const state = useStoreValue(viewerStore); const compact = useCompactWindow(); diff --git a/apps/desktop/src/components/ShortcutsSettingsPrototype.tsx b/apps/desktop/src/components/ShortcutsSettingsPrototype.tsx index da239787..37be6da0 100644 --- a/apps/desktop/src/components/ShortcutsSettingsPrototype.tsx +++ b/apps/desktop/src/components/ShortcutsSettingsPrototype.tsx @@ -1,4 +1,5 @@ import { Button, formatShortcut, Input } from "@hubble.md/ui"; +import { isMac } from "keymatch"; import { type ReactNode, useCallback, useEffect, useState } from "react"; // PROTOTYPE — three variants of the Shortcuts settings page, switchable via @@ -36,7 +37,7 @@ const commands: Command[] = [ { id: "app.add-folder", label: "Add Folder", - description: "Create and open a new folder.", + description: "Choose a folder to add as a workspace.", area: "App", defaultBinding: "CmdOrCtrl+Shift+N", }, @@ -208,16 +209,48 @@ const commands: Command[] = [ area: "Editor", defaultBinding: "CmdOrCtrl+E", }, - ...Array.from({ length: 6 }, (_, index): Command => { - const level = index + 1; - return { - id: `editor.heading-${level}`, - label: `Heading ${level}`, - description: `Convert the current block to heading ${level}.`, - area: "Editor", - defaultBinding: `CmdOrCtrl+Alt+${level}`, - }; - }), + { + id: "editor.heading-1", + label: "Heading 1", + description: "Convert the current block to heading 1.", + area: "Editor", + defaultBinding: "CmdOrCtrl+Alt+1", + }, + { + id: "editor.heading-2", + label: "Heading 2", + description: "Convert the current block to heading 2.", + area: "Editor", + defaultBinding: "CmdOrCtrl+Alt+2", + }, + { + id: "editor.heading-3", + label: "Heading 3", + description: "Convert the current block to heading 3.", + area: "Editor", + defaultBinding: "CmdOrCtrl+Alt+3", + }, + { + id: "editor.heading-4", + label: "Heading 4", + description: "Convert the current block to heading 4.", + area: "Editor", + defaultBinding: "CmdOrCtrl+Alt+4", + }, + { + id: "editor.heading-5", + label: "Heading 5", + description: "Convert the current block to heading 5.", + area: "Editor", + defaultBinding: "CmdOrCtrl+Alt+5", + }, + { + id: "editor.heading-6", + label: "Heading 6", + description: "Convert the current block to heading 6.", + area: "Editor", + defaultBinding: "CmdOrCtrl+Alt+6", + }, { id: "editor.blockquote", label: "Quote", @@ -227,13 +260,74 @@ const commands: Command[] = [ }, ]; -const reservedBindings = new Set([ - "CmdOrCtrl+C", - "CmdOrCtrl+Q", - "CmdOrCtrl+V", - "CmdOrCtrl+X", - "CmdOrCtrl+Z", -]); +const modifierOrder = ["CmdOrCtrl", "Ctrl", "Alt", "Shift", "Super"] as const; +const modifierSet = new Set(modifierOrder); + +function normalizeBinding(binding: string) { + const parts = binding.split("+"); + const modifiers = modifierOrder.filter((modifier) => + parts.includes(modifier), + ); + const keys = parts.filter((part) => !modifierSet.has(part)); + return [...modifiers, ...keys].join("+"); +} + +function defaultBinding(command: Command) { + return normalizeBinding(command.defaultBinding); +} + +const fixedBindings = new Set( + [ + "CmdOrCtrl+A", + "CmdOrCtrl+C", + "CmdOrCtrl+=", + "CmdOrCtrl+-", + "CmdOrCtrl+0", + "CmdOrCtrl+Q", + "CmdOrCtrl+V", + "CmdOrCtrl+X", + "CmdOrCtrl+Y", + "CmdOrCtrl+Z", + "CmdOrCtrl+Shift+Z", + ].map(normalizeBinding), +); + +const unavailableBindings = new Set( + (isMac() ? ["CmdOrCtrl+Space", "CmdOrCtrl+Tab"] : ["Alt+F4", "Alt+Tab"]).map( + normalizeBinding, + ), +); + +function validateBinding( + commandId: string, + binding: string, + bindings: Bindings, +) { + const parts = binding.split("+"); + if (parts.some((part) => part.length === 0)) { + return "That key cannot be used in a Hubble shortcut."; + } + if (parts.includes("Super")) { + return "The system key is not available for app shortcuts."; + } + if ( + !parts.some( + (part) => part === "CmdOrCtrl" || part === "Ctrl" || part === "Alt", + ) + ) { + return "Add Command, Control, or Alt to create a shortcut."; + } + if (unavailableBindings.has(binding)) { + return `${formatShortcut(binding)} is unavailable on this operating system.`; + } + if (fixedBindings.has(binding)) { + return `${formatShortcut(binding)} stays fixed in Hubble.`; + } + const duplicate = commands.find( + (command) => command.id !== commandId && bindings[command.id] === binding, + ); + return duplicate ? `Already assigned to ${duplicate.label}.` : undefined; +} function variantFromUrl(): Variant | null { const requested = new URLSearchParams(window.location.search).get("variant"); @@ -242,13 +336,9 @@ function variantFromUrl(): Variant | null { : null; } -export function hasShortcutsPrototypeQuery() { - return import.meta.env.DEV && variantFromUrl() !== null; -} - function initialBindings(): Bindings { return Object.fromEntries( - commands.map((command) => [command.id, command.defaultBinding]), + commands.map((command) => [command.id, defaultBinding(command)]), ); } @@ -260,15 +350,23 @@ function eventBinding(event: KeyboardEvent): string | null { ArrowRight: "Right", ArrowUp: "Up", }; - const key = keyAliases[event.key] ?? event.key; + const key = + // Match keymatch's physical semantics for letters and digits, including + // when Alt or Shift composition reports punctuation or a dead key. + /^Key[A-Z]$/.test(event.code) + ? event.code.slice(3) + : /^Digit[0-9]$/.test(event.code) + ? event.code.slice(5) + : (keyAliases[event.key] ?? event.key); if (["Alt", "Control", "Meta", "Shift"].includes(key)) return null; const parts: string[] = []; - if (event.metaKey || event.ctrlKey) parts.push("CmdOrCtrl"); + if (event.ctrlKey) parts.push(isMac() ? "Ctrl" : "CmdOrCtrl"); + if (event.metaKey) parts.push(isMac() ? "CmdOrCtrl" : "Super"); if (event.altKey) parts.push("Alt"); if (event.shiftKey) parts.push("Shift"); parts.push(key.length === 1 ? key.toUpperCase() : key); - return parts.join("+"); + return normalizeBinding(parts.join("+")); } function isTypingTarget(target: EventTarget | null) { @@ -317,21 +415,11 @@ function usePrototypeState() { const binding = eventBinding(event); if (!binding) return; - if (reservedBindings.has(binding)) { - setErrors((current) => ({ - ...current, - [recordingId]: `${formatShortcut(binding)} is reserved by the system.`, - })); - return; - } - const duplicate = commands.find( - (command) => - command.id !== recordingId && bindings[command.id] === binding, - ); - if (duplicate) { + const error = validateBinding(recordingId, binding, bindings); + if (error) { setErrors((current) => ({ ...current, - [recordingId]: `Already assigned to ${duplicate.label}.`, + [recordingId]: error, })); return; } @@ -342,13 +430,31 @@ function usePrototypeState() { }; window.addEventListener("keydown", record, true); - return () => window.removeEventListener("keydown", record, true); + const onWindowBlur = () => { + setErrors((current) => ({ + ...current, + [recordingId]: + "The operating system may have intercepted that shortcut. Press another combination or Escape.", + })); + }; + window.addEventListener("blur", onWindowBlur); + return () => { + window.removeEventListener("keydown", record, true); + window.removeEventListener("blur", onWindowBlur); + }; }, [bindings, recordingId]); const reset = (command: Command) => { + const binding = defaultBinding(command); + const error = validateBinding(command.id, binding, bindings); + if (error) { + setErrors((current) => ({ ...current, [command.id]: error })); + if (recordingId === command.id) setRecordingId(null); + return; + } setBindings((current) => ({ ...current, - [command.id]: command.defaultBinding, + [command.id]: binding, })); setErrors((current) => ({ ...current, [command.id]: undefined })); if (recordingId === command.id) setRecordingId(null); @@ -447,7 +553,7 @@ export function ShortcutsSettingsPrototype({ function SidebarVariant({ general, page, setPage, state }: PrototypeProps) { return ( -
+
-
+
{page === "general" ? ( {general} ) : ( @@ -485,7 +591,7 @@ function SidebarVariant({ general, page, setPage, state }: PrototypeProps) { function TabsVariant({ general, page, setPage, state }: PrototypeProps) { const filtered = filteredCommands(state.query); return ( -
+
{page === "general" ? ( - {general} +
+ {general} +
) : ( -
+
{(["App", "Editor"] as const).map((area) => ( @@ -542,7 +650,7 @@ function TabsVariant({ general, page, setPage, state }: PrototypeProps) { function TableVariant({ general, page, setPage, state }: PrototypeProps) { const filtered = filteredCommands(state.query); return ( -
+
{page === "general" ? ( - {general} +
+ {general} +
) : ( -
+
Area Command @@ -664,7 +774,7 @@ function ListCommandRow({ state: PrototypeState; }) { const binding = state.bindings[command.id]; - const customized = binding !== command.defaultBinding; + const customized = binding !== defaultBinding(command); return (
@@ -726,13 +836,13 @@ function CardCommandRow({

{binding === null ? "Disabled" - : binding === command.defaultBinding + : binding === defaultBinding(command) ? "Default" : "Customized"}

)}
- {binding !== command.defaultBinding ? ( + {binding !== defaultBinding(command) ? (
- {binding !== command.defaultBinding ? ( + {binding !== defaultBinding(command) ? ( -
- {currentVariant.id} - — {currentVariant.label} +
+
+ {currentVariant.id} + — {currentVariant.label} +
+
+ Mock data · resets on reload +
+ {filtered.length === 0 ? : null}
)}
From d3c12fa6d8d52ff3412f10128996ecf31790ad39 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Wed, 19 Aug 2026 10:32:26 -0400 Subject: [PATCH 4/7] Wire customizable shortcuts through the app --- apps/desktop/electron/main.ts | 22 +++- apps/desktop/electron/preload.ts | 2 + apps/desktop/src/commands/useAppCommands.ts | 3 +- apps/desktop/src/components/Toolbar.tsx | 12 ++- .../src/components/WorkspaceSwitcher.tsx | 31 ++++-- apps/desktop/src/desktopApi/types.ts | 2 + apps/desktop/src/store/actions.test.ts | 75 +++++++++++++ apps/desktop/src/store/actions.ts | 70 +++++++++++- apps/desktop/src/store/persistence.ts | 5 + apps/desktop/src/store/state.ts | 13 ++- packages/editor/src/commandRegistry.test.ts | 62 ++++++++++- packages/editor/src/commandRegistry.ts | 93 +++++++++++++++- packages/editor/src/index.ts | 12 +++ packages/ui/src/components/Sidebar.tsx | 32 ++++-- packages/ui/src/components/Toolbar.test.tsx | 20 ++++ packages/ui/src/components/Toolbar.tsx | 11 +- .../src/editor/EditorCommandShortcuts.test.ts | 42 +++++++- .../ui/src/editor/EditorCommandShortcuts.ts | 100 ++++++++++++------ packages/ui/src/editor/FindBar.tsx | 5 +- packages/ui/src/editor/FormatCommandMenu.tsx | 37 +++++-- packages/ui/src/editor/SlashCommandMenu.tsx | 12 ++- packages/ui/src/index.ts | 8 +- packages/ui/src/lib/shortcut.ts | 37 ++++++- 23 files changed, 618 insertions(+), 88 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 0dc980ee..e1625352 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -3,7 +3,13 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { type AppCommandId, getCommand } from "@hubble.md/editor/commands"; +import { + type AppCommandId, + type CommandBindings, + getCommand, + getCommandBinding, + setCommandBindings, +} from "@hubble.md/editor/commands"; import hubbleRuntime from "@hubble.md/runtime/global.js?raw"; import htmlAppTheme from "@hubble.md/runtime/html-app-theme.css?raw"; import tailwindRuntime from "@tailwindcss/browser?raw"; @@ -785,7 +791,6 @@ type TextContextMenuItem = | { id: "copy-as-markdown"; label: string; - accelerator?: string; flag: keyof Electron.EditFlags; click: (webContents: Electron.WebContents) => void; }; @@ -796,7 +801,6 @@ const textContextMenuItems: TextContextMenuItem[] = [ { id: "copy-as-markdown", label: getCommand("app.copy-as-markdown").label, - accelerator: getCommand("app.copy-as-markdown").defaultBinding, flag: "canCopy", click: (webContents) => { webContents.send("desktop:menu-copy-as-markdown"); @@ -849,7 +853,7 @@ function buildTextContextMenu( : { id: item.id, label: item.label, - accelerator: item.accelerator, + accelerator: getCommandBinding("app.copy-as-markdown") ?? undefined, enabled: params.editFlags[item.flag], click: () => item.click(webContents), }, @@ -877,7 +881,7 @@ function commandMenuItem( return { id, label: command.label, - accelerator: command.defaultBinding, + accelerator: getCommandBinding(id) ?? undefined, enabled: command.isEnabled(menuState), click, }; @@ -1951,6 +1955,14 @@ function registerIpc() { }; buildMenu(); }); + + ipcMain.handle( + "desktop:set-shortcut-bindings", + (_event, bindings: CommandBindings) => { + setCommandBindings(bindings); + buildMenu(); + }, + ); } protocol.registerSchemesAsPrivileged([ diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index f2dbe046..1eabfff3 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -115,6 +115,8 @@ const desktopApi = { setSpellcheckLanguages: (languages) => ipcRenderer.invoke("desktop:set-spellcheck-languages", { languages }), setMenuState: (state) => ipcRenderer.invoke("desktop:set-menu-state", state), + setShortcutBindings: (bindings) => + ipcRenderer.invoke("desktop:set-shortcut-bindings", bindings), getUpdateState: () => ipcRenderer.invoke("desktop:get-update-state"), getTelemetryConsent: () => ipcRenderer.invoke("desktop:get-telemetry-consent"), diff --git a/apps/desktop/src/commands/useAppCommands.ts b/apps/desktop/src/commands/useAppCommands.ts index 21f578c5..6867f4a6 100644 --- a/apps/desktop/src/commands/useAppCommands.ts +++ b/apps/desktop/src/commands/useAppCommands.ts @@ -1,6 +1,7 @@ import { type CommandId, getCommand, + getCommandBinding, type CommandContext as RegistryContext, } from "@hubble.md/editor"; import { @@ -111,7 +112,7 @@ function defineCommands( label: options?.label ?? command.label, group, keywords, - binding: command.defaultBinding, + binding: getCommandBinding(id) ?? undefined, globalShortcut: options?.globalShortcut ?? true, isEnabled: options?.isEnabled ?? (() => command.isEnabled(registry)), run, diff --git a/apps/desktop/src/components/Toolbar.tsx b/apps/desktop/src/components/Toolbar.tsx index eb8ee467..9a06eab3 100644 --- a/apps/desktop/src/components/Toolbar.tsx +++ b/apps/desktop/src/components/Toolbar.tsx @@ -3,10 +3,11 @@ import type { AppCommandId } from "@hubble.md/editor"; import { Button, commandReviewThread, - formatCommandShortcut, ReviewCommentSummary, type ReviewCommentSummaryProps, Toolbar as SharedToolbar, + useCommandShortcut, + useCommandShortcutLabel, } from "@hubble.md/ui"; import { useStoreValue } from "@simplestack/store/react"; import { type CSSProperties, useEffect, useState } from "react"; @@ -161,8 +162,8 @@ export function Toolbar({ function NavigationControls() { const { canGoBack, canGoForward } = useHistoryNav(); - const backLabel = `Go Back (${formatCommandShortcut("app.go-back")})`; - const forwardLabel = `Go Forward (${formatCommandShortcut("app.go-forward")})`; + const backLabel = useCommandShortcutLabel("Go Back", "app.go-back"); + const forwardLabel = useCommandShortcutLabel("Go Forward", "app.go-forward"); return (
diff --git a/packages/ui/src/editor/EditorCommandShortcuts.test.ts b/packages/ui/src/editor/EditorCommandShortcuts.test.ts index 07f2bdfd..b3aadb4a 100644 --- a/packages/ui/src/editor/EditorCommandShortcuts.test.ts +++ b/packages/ui/src/editor/EditorCommandShortcuts.test.ts @@ -1,6 +1,10 @@ // @vitest-environment happy-dom -import { InlineCodeExtension, listExtensions } from "@hubble.md/editor"; +import { + InlineCodeExtension, + listExtensions, + setCommandBindings, +} from "@hubble.md/editor"; import { Editor } from "@tiptap/core"; import { TaskItem } from "@tiptap/extension-list"; import { afterEach, describe, expect, it } from "vitest"; @@ -14,6 +18,7 @@ const editors: Editor[] = []; afterEach(() => { for (const editor of editors) editor.destroy(); editors.length = 0; + setCommandBindings({}); }); describe("EditorCommandShortcuts", () => { @@ -64,6 +69,41 @@ describe("EditorCommandShortcuts", () => { expect(editor.commands.keyboardShortcut("Mod-Shift-9")).toBe(true); expect(editor.isActive("bulletList")).toBe(true); }); + + it("applies a remap to an editor that is already open", () => { + const editor = createEditor(); + editor.commands.selectAll(); + setCommandBindings({ "editor.bold": "CmdOrCtrl+Alt+B" }); + + editor.commands.keyboardShortcut("Mod-b"); + expect(editor.isActive("bold")).toBe(false); + + editor.commands.keyboardShortcut("Mod-Alt-b"); + expect(editor.isActive("bold")).toBe(true); + }); + + it("does not run a disabled shortcut", () => { + const editor = createEditor(); + editor.commands.selectAll(); + setCommandBindings({ "editor.italic": null }); + + editor.commands.keyboardShortcut("Mod-i"); + expect(editor.isActive("italic")).toBe(false); + }); + + it("runs only the first command when shortcuts conflict", () => { + const editor = createEditor(); + editor.commands.selectAll(); + setCommandBindings({ + "editor.bold": "CmdOrCtrl+Alt+M", + "editor.italic": "CmdOrCtrl+Alt+M", + }); + + editor.commands.keyboardShortcut("Mod-Alt-m"); + + expect(editor.isActive("bold")).toBe(true); + expect(editor.isActive("italic")).toBe(false); + }); }); // Mirrors the EditorView setup: registry-owned StarterKit shortcuts plus diff --git a/packages/ui/src/editor/EditorCommandShortcuts.ts b/packages/ui/src/editor/EditorCommandShortcuts.ts index 9d9dadff..ca22ee9e 100644 --- a/packages/ui/src/editor/EditorCommandShortcuts.ts +++ b/packages/ui/src/editor/EditorCommandShortcuts.ts @@ -1,10 +1,12 @@ -import { tiptapBinding } from "@hubble.md/editor"; +import { type EditorCommandId, getCommandBinding } from "@hubble.md/editor"; import { Extension } from "@tiptap/core"; import { Blockquote } from "@tiptap/extension-blockquote"; import { Bold } from "@tiptap/extension-bold"; import { Heading } from "@tiptap/extension-heading"; import { Italic } from "@tiptap/extension-italic"; +import { Plugin } from "@tiptap/pm/state"; import StarterKit from "@tiptap/starter-kit"; +import { keymatch } from "keymatch"; const withoutShortcuts = { addKeyboardShortcuts: () => ({}) }; @@ -43,36 +45,72 @@ export const EditorCommandShortcuts = Extension.create({ name: "editorCommandShortcuts", priority: 2000, - addKeyboardShortcuts() { - return { - [tiptapBinding("editor.link")]: () => - this.editor.commands.toggleLinkAtSelection(), - [tiptapBinding("editor.strike")]: () => - this.editor.commands.toggleMark("strike"), - [tiptapBinding("editor.ordered-list")]: () => - this.editor.commands.toggleParentOrderedList(), - [tiptapBinding("editor.bullet-list")]: () => - this.editor.commands.toggleParentBulletList(), - [tiptapBinding("editor.task-list")]: () => - this.editor.commands.toggleParentTaskList(), - [tiptapBinding("editor.bold")]: () => this.editor.commands.toggleBold(), - [tiptapBinding("editor.italic")]: () => - this.editor.commands.toggleItalic(), - [tiptapBinding("editor.code")]: () => this.editor.commands.toggleCode(), - [tiptapBinding("editor.heading-1")]: () => - this.editor.commands.toggleHeading({ level: 1 }), - [tiptapBinding("editor.heading-2")]: () => - this.editor.commands.toggleHeading({ level: 2 }), - [tiptapBinding("editor.heading-3")]: () => - this.editor.commands.toggleHeading({ level: 3 }), - [tiptapBinding("editor.heading-4")]: () => - this.editor.commands.toggleHeading({ level: 4 }), - [tiptapBinding("editor.heading-5")]: () => - this.editor.commands.toggleHeading({ level: 5 }), - [tiptapBinding("editor.heading-6")]: () => - this.editor.commands.toggleHeading({ level: 6 }), - [tiptapBinding("editor.blockquote")]: () => - this.editor.commands.toggleBlockquote(), + addProseMirrorPlugins() { + const run = (id: EditorCommandId) => { + switch (id) { + case "editor.link": + return this.editor.commands.toggleLinkAtSelection(); + case "editor.strike": + return this.editor.commands.toggleMark("strike"); + case "editor.ordered-list": + return this.editor.commands.toggleParentOrderedList(); + case "editor.bullet-list": + return this.editor.commands.toggleParentBulletList(); + case "editor.task-list": + return this.editor.commands.toggleParentTaskList(); + case "editor.bold": + return this.editor.commands.toggleBold(); + case "editor.italic": + return this.editor.commands.toggleItalic(); + case "editor.code": + return this.editor.commands.toggleCode(); + case "editor.heading-1": + return this.editor.commands.toggleHeading({ level: 1 }); + case "editor.heading-2": + return this.editor.commands.toggleHeading({ level: 2 }); + case "editor.heading-3": + return this.editor.commands.toggleHeading({ level: 3 }); + case "editor.heading-4": + return this.editor.commands.toggleHeading({ level: 4 }); + case "editor.heading-5": + return this.editor.commands.toggleHeading({ level: 5 }); + case "editor.heading-6": + return this.editor.commands.toggleHeading({ level: 6 }); + case "editor.blockquote": + return this.editor.commands.toggleBlockquote(); + } }; + + return [ + new Plugin({ + props: { + handleKeyDown: (_view, event) => { + for (const id of editorCommandIds) { + const binding = getCommandBinding(id); + if (binding && keymatch(event, binding)) return run(id); + } + return false; + }, + }, + }), + ]; }, }); + +const editorCommandIds: EditorCommandId[] = [ + "editor.link", + "editor.strike", + "editor.ordered-list", + "editor.bullet-list", + "editor.task-list", + "editor.bold", + "editor.italic", + "editor.code", + "editor.heading-1", + "editor.heading-2", + "editor.heading-3", + "editor.heading-4", + "editor.heading-5", + "editor.heading-6", + "editor.blockquote", +]; diff --git a/packages/ui/src/editor/FindBar.tsx b/packages/ui/src/editor/FindBar.tsx index e6829b24..e089b6e3 100644 --- a/packages/ui/src/editor/FindBar.tsx +++ b/packages/ui/src/editor/FindBar.tsx @@ -1,6 +1,6 @@ import { type FindState, - getCommand, + getCommandBinding, getFindState, selectFindMatch, } from "@hubble.md/editor"; @@ -108,7 +108,8 @@ export function FindBar({ editor }: { editor: Editor | null }) { } return; } - if (!keymatch(event, getCommand("app.find").defaultBinding)) return; + const binding = getCommandBinding("app.find"); + if (!binding || !keymatch(event, binding)) return; if (!editor?.isFocused && !open) return; event.preventDefault(); openFind(); diff --git a/packages/ui/src/editor/FormatCommandMenu.tsx b/packages/ui/src/editor/FormatCommandMenu.tsx index 7863ce9f..7910c2af 100644 --- a/packages/ui/src/editor/FormatCommandMenu.tsx +++ b/packages/ui/src/editor/FormatCommandMenu.tsx @@ -1,4 +1,8 @@ -import { type CommandId, getCommand } from "@hubble.md/editor"; +import { + type CommandBindings, + type CommandId, + getCommandBinding, +} from "@hubble.md/editor"; import type { Editor } from "@tiptap/core"; import { Command } from "cmdk"; import { keymatch } from "keymatch"; @@ -23,7 +27,7 @@ import MingcuteListOrderedLine from "~icons/mingcute/list-ordered-line"; import MingcuteQuoteLeftLine from "~icons/mingcute/quote-left-line"; import MingcuteStrikethroughLine from "~icons/mingcute/strikethrough-line"; import MingcuteTextLine from "~icons/mingcute/text-line"; -import { formatCommandShortcut } from "../lib/shortcut"; +import { formatCommandShortcut, useCommandBindings } from "../lib/shortcut"; import { cn } from "../lib/utils"; import { useCommandMenuPosition } from "./commandMenuPosition"; import { @@ -172,6 +176,7 @@ export function FormatCommandMenu({ editor: Editor | null; viewportRef: RefObject; }) { + const commandBindings = useCommandBindings(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [position, setPosition] = useState(null); @@ -224,8 +229,8 @@ export function FormatCommandMenu({ editor.commands.focus(undefined, { scrollIntoView: false }); return; } - if (!keymatch(event, getCommand("app.format-menu").defaultBinding)) - return; + const binding = getCommandBinding("app.format-menu"); + if (!binding || !keymatch(event, binding)) return; if (!editor.isFocused && !open) return; if (!open && editor.state.selection.empty) return; event.preventDefault(); @@ -321,8 +326,20 @@ export function FormatCommandMenu({
) : ( <> - {renderGroup("Block", visibleCommands, runCommand, editor)} - {renderGroup("Inline", visibleCommands, runCommand, editor)} + {renderGroup( + "Block", + visibleCommands, + runCommand, + editor, + commandBindings, + )} + {renderGroup( + "Inline", + visibleCommands, + runCommand, + editor, + commandBindings, + )} )} @@ -336,6 +353,7 @@ function renderGroup( commands: FormatCommand[], runCommand: (kind: FormatCommandKind) => void, editor: Editor, + commandBindings: CommandBindings, ) { const groupCommands = commands.filter((command) => command.group === group); if (groupCommands.length === 0) return null; @@ -349,6 +367,9 @@ function renderGroup( {groupCommands.map((command) => { const Icon = command.icon; const isApplied = isFormatActive(editor, command.kind); + const shortcut = command.shortcut + ? formatCommandShortcut(command.shortcut, commandBindings) + : null; return ( {isApplied ? ( - ) : command.shortcut ? ( + ) : shortcut ? ( ) : null} diff --git a/packages/ui/src/editor/SlashCommandMenu.tsx b/packages/ui/src/editor/SlashCommandMenu.tsx index a67ee7ab..ddb633f6 100644 --- a/packages/ui/src/editor/SlashCommandMenu.tsx +++ b/packages/ui/src/editor/SlashCommandMenu.tsx @@ -19,7 +19,7 @@ import MingcuteQuoteLeftLine from "~icons/mingcute/quote-left-line"; import MingcuteStrikethroughLine from "~icons/mingcute/strikethrough-line"; import MingcuteTable2Line from "~icons/mingcute/table-2-line"; import MingcuteTextLine from "~icons/mingcute/text-line"; -import { formatCommandShortcut } from "../lib/shortcut"; +import { formatCommandShortcut, useCommandBindings } from "../lib/shortcut"; import { cn } from "../lib/utils"; import { useCommandMenuPosition } from "./commandMenuPosition"; import { @@ -138,6 +138,7 @@ export function SlashCommandMenu({ editor: Editor | null; viewportRef: RefObject; }) { + const commandBindings = useCommandBindings(); const [token, setToken] = useState(null); const [position, setPosition] = useState(null); const [selectedKind, setSelectedKind] = @@ -297,6 +298,9 @@ export function SlashCommandMenu({ {visibleCommands.map((command) => { const Icon = command.icon; + const shortcut = command.shortcut + ? formatCommandShortcut(command.shortcut, commandBindings) + : null; return ( {command.title} - {command.shortcut && ( + {shortcut ? ( - )} + ) : null} ); })} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index f1077666..6e8c6c5f 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -73,7 +73,13 @@ export { scoreCommand, stripCommandPrefix, } from "./lib/paletteCommand"; -export { formatCommandShortcut, formatShortcut } from "./lib/shortcut"; +export { + formatCommandShortcut, + formatShortcut, + useCommandBindings, + useCommandShortcut, + useCommandShortcutLabel, +} from "./lib/shortcut"; export { Button, buttonVariants } from "./primitives/button"; export { Input } from "./primitives/input"; export { Modal } from "./primitives/modal"; diff --git a/packages/ui/src/lib/shortcut.ts b/packages/ui/src/lib/shortcut.ts index 43f1b556..ae2cfc9c 100644 --- a/packages/ui/src/lib/shortcut.ts +++ b/packages/ui/src/lib/shortcut.ts @@ -1,5 +1,12 @@ -import { type CommandId, getCommand } from "@hubble.md/editor"; +import { + type CommandId, + getCommandBinding, + getCommandBindings, + resolveCommandBinding, + subscribeCommandBindings, +} from "@hubble.md/editor"; import { isMac } from "keymatch"; +import { useSyncExternalStore } from "react"; // macOS renders modifiers as adjacent glyphs (⌘⌥R); Windows/Linux use // "+"-joined words (Ctrl+Alt+R). Keys are matched against the same @@ -57,6 +64,30 @@ export function formatShortcut(spec: string): string { return mac ? rendered.join("") : rendered.join("+"); } -export function formatCommandShortcut(id: CommandId): string { - return formatShortcut(getCommand(id).defaultBinding); +export function formatCommandShortcut( + id: CommandId, + bindings?: ReturnType, +): string | null { + const binding = bindings + ? resolveCommandBinding(id, bindings) + : getCommandBinding(id); + return binding ? formatShortcut(binding) : null; +} + +export function useCommandBindings() { + return useSyncExternalStore( + subscribeCommandBindings, + getCommandBindings, + getCommandBindings, + ); +} + +export function useCommandShortcut(id: CommandId) { + const bindings = useCommandBindings(); + return formatCommandShortcut(id, bindings); +} + +export function useCommandShortcutLabel(label: string, id: CommandId) { + const shortcut = useCommandShortcut(id); + return shortcut ? `${label} (${shortcut})` : label; } From 4f505d5ad9db344f1ecafec105146a75bf99a297 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Wed, 19 Aug 2026 10:32:29 -0400 Subject: [PATCH 5/7] Finish shortcut settings experience --- apps/desktop/src/App.tsx | 218 +--- apps/desktop/src/components/Settings.tsx | 556 +++++++++ .../desktop/src/components/SettingsDialog.tsx | 30 +- .../components/ShortcutsSettingsPrototype.tsx | 1084 ----------------- .../components/shortcutSettingsModel.test.ts | 63 + .../src/components/shortcutSettingsModel.ts | 191 +++ packages/ui/src/primitives/modal.tsx | 20 +- 7 files changed, 882 insertions(+), 1280 deletions(-) create mode 100644 apps/desktop/src/components/Settings.tsx delete mode 100644 apps/desktop/src/components/ShortcutsSettingsPrototype.tsx create mode 100644 apps/desktop/src/components/shortcutSettingsModel.test.ts create mode 100644 apps/desktop/src/components/shortcutSettingsModel.ts diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 16b9398d..e920fea8 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,6 +1,7 @@ import { type AppCommandId, getCommand, + getCommandBinding, wikiDisplayNameForTarget, } from "@hubble.md/editor"; import { @@ -9,7 +10,6 @@ import { EditorView, GlobalSearchPalette, getActiveEditor, - Input, MarkdownSourceEditor, OPEN_COMMAND_PALETTE_EVENT, type PaletteFile, @@ -21,32 +21,21 @@ import { useStoreValue } from "@simplestack/store/react"; import { keymatch } from "keymatch"; import { useEffect, useState, useSyncExternalStore } from "react"; import { toast } from "sonner"; -import MingcutePencilLine from "~icons/mingcute/pencil-line"; import { recentCommandIdsStore, recordRecentCommand, } from "./commands/recentCommands"; import { buildAppCommands } from "./commands/useAppCommands"; import { HtmlAppEmptyState } from "./components/HtmlAppEmptyState"; -import { SettingsDialog, SettingsSection } from "./components/SettingsDialog"; -import { ShortcutsSettingsPrototype } from "./components/ShortcutsSettingsPrototype"; +import { Settings } from "./components/Settings"; import { type DesktopSidebarFocus, Sidebar } from "./components/Sidebar"; -import { SpellcheckSettingsSection } from "./components/SpellcheckSection"; -import { - TelemetryConsentCallout, - TelemetrySettingsSection, -} from "./components/TelemetrySection"; +import { TelemetryConsentCallout } from "./components/TelemetrySection"; import { TerminalPanel } from "./components/TerminalPanel"; import { Toolbar } from "./components/Toolbar"; -import { SidebarCallout, UpdatesSection } from "./components/UpdatesSection"; +import { SidebarCallout } from "./components/UpdatesSection"; import { WelcomeScreen } from "./components/WelcomeScreen"; import { desktopApi } from "./desktopApi"; -import type { - DesktopUpdateState, - SpellcheckState, - TelemetryChoice, - TelemetryConsent, -} from "./desktopApi/types"; +import type { DesktopUpdateState } from "./desktopApi/types"; import { createEmbedExtension } from "./editor/EmbedExtension"; import { handleImageDrop, handleImagePaste } from "./editor/handleImagePaste"; import { IframeView, toAssetUrl } from "./editor/IframeView"; @@ -81,6 +70,7 @@ import { goForward, handleExternalFileChange, loadPath, + loadSettingsState, openChangelog, openWorkspace, openWorkspaceWithSidebar, @@ -91,12 +81,10 @@ import { reloadFromDiskConflict, requestChatAboutNote, savePathContent, - setChatCommand, - setCodeFileOpenMode, setLastSeenVersion, setReviewThreads, setSidebarOpen, - setThemePreference, + setTelemetryConsent, setViewerMode, setWorkspaceSwitcherOpen, toggleTerminal, @@ -106,12 +94,12 @@ import { import { canGoBack, canGoForward } from "./store/history"; import { useHistoryNav } from "./store/hooks"; import { - chatCommandStore, - codeFileOpenModeStore, lastSeenVersionStore, + shortcutBindingsStore, sidebarOpenStore, + spellcheckStore, + telemetryConsentStore, terminalPositionStore, - themePreferenceStore, uiStore, type ViewMode, viewerStore, @@ -204,31 +192,25 @@ async function searchFileContents(query: string) { return { results, truncated }; } -function hasShortcutsPrototypeQuery() { - if (!import.meta.env.DEV) return false; - const variant = new URLSearchParams(window.location.search).get("variant"); - return variant === "sidebar" || variant === "tabs" || variant === "table"; -} - function App() { const state = useStoreValue(viewerStore); const compact = useCompactWindow(); const workspacePath = useStoreValue(workspacePathStore); const sidebarOpen = useStoreValue(sidebarOpenStore); const terminalPosition = useStoreValue(terminalPositionStore); + const shortcutBindings = useStoreValue(shortcutBindingsStore); const hasWorkspace = workspacePath !== null; const { canGoBack: menuCanGoBack, canGoForward: menuCanGoForward } = useHistoryNav(); const [scrollContainerEl, setScrollContainerEl] = useState(null); - const [settingsOpen, setSettingsOpen] = useState(hasShortcutsPrototypeQuery); + const [settingsOpen, setSettingsOpen] = useState(false); const [copyAsMarkdownRequest, setCopyAsMarkdownRequest] = useState(0); const [updateState, setUpdateState] = useState( null, ); - const [telemetryConsent, setTelemetryConsent] = - useState(null); - const [spellcheck, setSpellcheck] = useState(null); + const telemetryConsent = useStoreValue(telemetryConsentStore); + const spellcheck = useStoreValue(spellcheckStore); const [focusedSidebarItem, setFocusedSidebarItem] = useState(null); const updateFocusedSidebarItem = (next: DesktopSidebarFocus) => { @@ -318,30 +300,7 @@ function App() { if (currentVersion) setLastSeenVersion(currentVersion); }; - useEffect(() => { - void desktopApi.getTelemetryConsent().then(setTelemetryConsent); - }, []); - - useEffect(() => { - void desktopApi.getSpellcheckState().then(setSpellcheck); - }, []); - - const updateSpellcheck = async (request: Promise) => { - try { - await request; - setSpellcheck(await desktopApi.getSpellcheckState()); - } catch { - toast.error("Failed to update spellcheck"); - } - }; - - const changeSpellcheckEnabled = (enabled: boolean) => { - void updateSpellcheck(desktopApi.setSpellcheckEnabled(enabled)); - }; - - const changeSpellcheckLanguages = (languages: string[]) => { - void updateSpellcheck(desktopApi.setSpellcheckLanguages(languages)); - }; + useEffect(loadSettingsState, []); const spellcheckStatus: SpellcheckStatus | null = spellcheck?.enabled && @@ -354,20 +313,6 @@ function App() { } : null; - const chooseTelemetry = async (choice: TelemetryChoice) => { - setTelemetryConsent(await desktopApi.setTelemetryConsent(choice)); - if (choice !== "enabled") return; - // Declining wiped today's record, so re-enabling must record the current - // session again; an open HTML file counts as HTML App use. - const viewer = viewerStore.get(); - void desktopApi.recordTelemetryActivity({ - usedHtmlApp: - viewer.status === "ready" && - !!viewer.currentPath && - hasHtmlExtension(viewer.currentPath), - }); - }; - useEffect(() => { // First install has no update to announce; just record the version. if (currentVersion && lastSeenVersion === null) { @@ -463,6 +408,10 @@ function App() { state.viewMode, ]); + useEffect(() => { + void desktopApi.setShortcutBindings(shortcutBindings); + }, [shortcutBindings]); + useEffect(() => { if (!sidebarOpen) setFocusedSidebarItem(null); }, [sidebarOpen]); @@ -470,7 +419,8 @@ function App() { useEffect(() => { const onKeyDown = async (event: KeyboardEvent) => { if (event.defaultPrevented) return; - if (keymatch(event, getCommand("app.format-menu").defaultBinding)) { + const formatBinding = getCommandBinding("app.format-menu"); + if (formatBinding && keymatch(event, formatBinding)) { const editor = getActiveEditor(); if (editor?.isFocused && !editor.state.selection.empty) return; event.preventDefault(); @@ -517,10 +467,8 @@ function App() { () => void | Promise, ][]) { const command = getCommand(id); - if ( - keymatch(event, command.defaultBinding) && - command.isEnabled(context) - ) { + const binding = getCommandBinding(id); + if (binding && keymatch(event, binding) && command.isEnabled(context)) { event.preventDefault(); await handler(); return; @@ -706,34 +654,6 @@ function App() { }; }, []); - const settingsSections = ( - <> - {updateState ? ( - void triggerPrimaryUpdateAction()} - onViewChangelog={openWhatsNew} - /> - ) : null} - - {spellcheck ? ( - - ) : null} - - - {telemetryConsent ? ( - void chooseTelemetry(choice)} - /> - ) : null} - - ); - return (
) : telemetryConsent === "unset" ? ( void chooseTelemetry(choice)} + onChoose={(choice) => void setTelemetryConsent(choice)} /> ) : undefined } @@ -881,97 +801,17 @@ function App() { recentCommandIds={recentCommandIds} onRunCommand={recordRecentCommand} /> - - {import.meta.env.DEV ? ( - - ) : ( - settingsSections - )} - + updateState={updateState} + onUpdateAction={() => void triggerPrimaryUpdateAction()} + onViewChangelog={openWhatsNew} + />
); } -function GeneralSettingsSection() { - const theme = useStoreValue(themePreferenceStore); - return ( - -
- {(["light", "dark", "system"] as const).map((preference) => ( - - ))} -
-
- ); -} - -function CodeFilesSettingsSection() { - const mode = useStoreValue(codeFileOpenModeStore); - return ( - -
- - -
-
- ); -} - -function ChatAboutNoteSettingsSection() { - const [draft, setDraft] = useState(() => chatCommandStore.get()); - - return ( - -
- { - setDraft(event.currentTarget.value); - setChatCommand(event.currentTarget.value); - }} - /> - -
-
- ); -} - function DocumentViewer({ path, content, diff --git a/apps/desktop/src/components/Settings.tsx b/apps/desktop/src/components/Settings.tsx new file mode 100644 index 00000000..804b94cb --- /dev/null +++ b/apps/desktop/src/components/Settings.tsx @@ -0,0 +1,556 @@ +import { + type CommandId, + findCommandBindingConflicts, + getCommand, + resolveCommandBinding, +} from "@hubble.md/editor"; +import { Button, formatShortcut, Input } from "@hubble.md/ui"; +import { useStoreValue } from "@simplestack/store/react"; +import { type ReactNode, type Ref, useEffect, useRef, useState } from "react"; +import MingcuteCloseLine from "~icons/mingcute/close-line"; +import MingcutePencilLine from "~icons/mingcute/pencil-line"; +import MingcuteRefresh2Line from "~icons/mingcute/refresh-2-line"; +import type { DesktopUpdateState } from "../desktopApi/types"; +import { + resetShortcutBindings, + setChatCommand, + setCodeFileOpenMode, + setShortcutBinding, + setSpellcheckEnabled, + setSpellcheckLanguages, + setTelemetryConsent, + setThemePreference, +} from "../store/actions"; +import { + chatCommandStore, + codeFileOpenModeStore, + shortcutBindingsStore, + spellcheckStore, + telemetryConsentStore, + themePreferenceStore, +} from "../store/state"; +import { SettingsDialog, SettingsSection } from "./SettingsDialog"; +import { SpellcheckSettingsSection } from "./SpellcheckSection"; +import { + filterShortcutGroups, + isShortcutCustomized, + type ShortcutCommand, + shortcutBindingFromEvent, + validateShortcutBinding, +} from "./shortcutSettingsModel"; +import { TelemetrySettingsSection } from "./TelemetrySection"; +import { UpdatesSection } from "./UpdatesSection"; + +type SettingsPage = "general" | "chat" | "shortcuts"; +type Errors = Partial>; + +export function Settings({ + open, + onOpenChange, + updateState, + onUpdateAction, + onViewChangelog, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + updateState: DesktopUpdateState | null; + onUpdateAction: () => void; + onViewChangelog: () => void; +}) { + const [page, setPage] = useState("general"); + const activeTabRef = useRef(null); + useEffect(() => { + if (!open) return; + const timeout = window.setTimeout(() => activeTabRef.current?.focus()); + return () => window.clearTimeout(timeout); + }, [open]); + + return ( + +
+ +
+ {page === "general" ? ( + + ) : page === "chat" ? ( + + ) : ( + + )} +
+
+
+ ); +} + +const pageTitles: Record = { + general: "General", + chat: "Chat", + shortcuts: "Keyboard shortcuts", +}; + +function GeneralSettings({ + updateState, + onUpdateAction, + onViewChangelog, +}: { + updateState: DesktopUpdateState | null; + onUpdateAction: () => void; + onViewChangelog: () => void; +}) { + const spellcheck = useStoreValue(spellcheckStore); + const telemetryConsent = useStoreValue(telemetryConsentStore); + + return ( +
+ {updateState ? ( + + ) : null} + + {spellcheck ? ( + void setSpellcheckEnabled(enabled)} + onLanguagesChange={(languages) => + void setSpellcheckLanguages(languages) + } + /> + ) : null} + + {telemetryConsent ? ( + void setTelemetryConsent(choice)} + /> + ) : null} +
+ ); +} + +function AppearanceSettings() { + const theme = useStoreValue(themePreferenceStore); + return ( + +
+ {(["light", "dark", "system"] as const).map((preference) => ( + + ))} +
+
+ ); +} + +function CodeFilesSettings() { + const mode = useStoreValue(codeFileOpenModeStore); + return ( + +
+ + +
+
+ ); +} + +function ChatSettings() { + const [draft, setDraft] = useState(() => chatCommandStore.get()); + + return ( +
+ +
+ { + setDraft(event.currentTarget.value); + setChatCommand(event.currentTarget.value); + }} + /> + +
+
+
+ ); +} + +function ShortcutSettings() { + const state = useShortcutState(); + const groups = filterShortcutGroups(state.query); + + return ( +
+
+ state.setQuery(event.currentTarget.value)} + /> + +
+
+ {groups.map(({ area, commands: groupCommands }) => ( +
+

{area}

+
+ {groupCommands.map((command) => ( + + ))} +
+
+ ))} + {groups.length === 0 ? ( +

+ No shortcuts match this search. +

+ ) : null} +
+
+ ); +} + +function CommandRow({ + command, + state, +}: { + command: ShortcutCommand; + state: ReturnType; +}) { + const binding = resolveCommandBinding(command.id, state.bindings); + const conflicts = findCommandBindingConflicts(command.id, state.bindings); + const customized = isShortcutCustomized(command.id, state.bindings); + const recording = state.recordingId === command.id; + + return ( +
+
+

{command.label}

+

+ {command.description} +

+ {state.errors[command.id] ? ( +

+ {state.errors[command.id]} +

+ ) : null} + {conflicts.length > 0 ? ( +

+ Also assigned to{" "} + {conflicts.map((id, index) => ( + + {conflictSeparator(index, conflicts.length)} + + + ))} +

+ ) : null} +
+
+
+ {customized ? ( + + ) : null} +
+
+ + {binding && !recording ? ( + + ) : null} +
+
+
+ ); +} + +function useShortcutState() { + const bindings = useStoreValue(shortcutBindingsStore); + const [errors, setErrors] = useState({}); + const [recordingId, setRecordingId] = useState(null); + const [query, setQuery] = useState(""); + // Conflict links change this ID to scroll to and pulse the matching row. + const [revealId, setRevealId] = useState(null); + + useEffect(() => { + if (!revealId) return; + const frame = requestAnimationFrame(() => { + const row = document.getElementById(shortcutRowId(revealId)); + if (!row) return; + row.scrollIntoView({ + behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? "auto" + : "smooth", + block: "center", + }); + row.focus({ preventScroll: true }); + pulseShortcutRow(row); + setRevealId(null); + }); + return () => cancelAnimationFrame(frame); + }, [revealId]); + + useEffect(() => { + if (!recordingId) return; + + const record = (event: KeyboardEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (event.key === "Escape") { + setRecordingId(null); + setErrors((current) => ({ ...current, [recordingId]: undefined })); + return; + } + if ( + (event.key === "Backspace" || event.key === "Delete") && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey + ) { + setShortcutBinding(recordingId, null); + setRecordingId(null); + setErrors((current) => ({ ...current, [recordingId]: undefined })); + return; + } + + const binding = shortcutBindingFromEvent(event); + if (!binding) return; + const error = validateShortcutBinding(binding); + if (error) { + setErrors((current) => ({ ...current, [recordingId]: error })); + return; + } + + setShortcutBinding(recordingId, binding); + setErrors((current) => ({ ...current, [recordingId]: undefined })); + setRecordingId(null); + }; + + const onBlur = () => { + setErrors((current) => ({ + ...current, + [recordingId]: + "The operating system may have intercepted that shortcut. Press another combination or Escape.", + })); + }; + window.addEventListener("keydown", record, true); + window.addEventListener("blur", onBlur); + return () => { + window.removeEventListener("keydown", record, true); + window.removeEventListener("blur", onBlur); + }; + }, [recordingId]); + + const clearError = (id: CommandId) => + setErrors((current) => ({ ...current, [id]: undefined })); + + return { + bindings, + clear: (id: CommandId) => { + setShortcutBinding(id, null); + clearError(id); + if (recordingId === id) setRecordingId(null); + }, + errors, + query, + recordingId, + reveal: (id: CommandId) => { + setQuery(""); + setRevealId(id); + }, + reset: (id: CommandId) => { + setShortcutBinding(id, getCommand(id).defaultBinding); + clearError(id); + if (recordingId === id) setRecordingId(null); + }, + resetAll: () => { + resetShortcutBindings(); + setErrors({}); + setRecordingId(null); + }, + setQuery, + startRecording: (id: CommandId) => { + clearError(id); + setRecordingId(id); + }, + }; +} + +function shortcutRowId(id: CommandId) { + return `shortcut-${id}`; +} + +function conflictSeparator(index: number, count: number) { + if (index === 0) return ""; + if (index === count - 1) return count === 2 ? " and " : ", and "; + return ", "; +} + +function pulseShortcutRow(row: HTMLElement) { + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + row.animate( + [ + { backgroundColor: "transparent" }, + { + backgroundColor: + "color-mix(in oklab, var(--brand-accent) 24%, transparent)", + }, + { backgroundColor: "transparent" }, + ], + { duration: 700, easing: "ease-out" }, + ); +} + +function NavButton({ + active, + buttonRef, + children, + onClick, +}: { + active: boolean; + buttonRef?: Ref; + children: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/desktop/src/components/SettingsDialog.tsx b/apps/desktop/src/components/SettingsDialog.tsx index 0e723628..b7805b94 100644 --- a/apps/desktop/src/components/SettingsDialog.tsx +++ b/apps/desktop/src/components/SettingsDialog.tsx @@ -1,25 +1,47 @@ import { Modal } from "@hubble.md/ui"; -import type { ReactNode } from "react"; +import { type ReactNode, useState } from "react"; export function SettingsDialog({ open, onOpenChange, + title, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; + title: string; className?: string; children: ReactNode; }) { + const [scrolled, setScrolled] = useState(false); + return ( + Settings + {title} + + } + className={`overflow-hidden p-0 ${className ?? "max-w-xl"}`} + headerClassName={`relative z-10 mb-0 h-12 items-center pr-2.5 [&>div:first-child]:flex-1 ${ + scrolled + ? "after:absolute after:right-0 after:bottom-0 after:left-40 after:border-b after:border-dashed after:border-border after:content-['']" + : "" + }`} + contentClassName="mr-0 overflow-visible pr-0" > -
{children}
+
+ setScrolled((event.target as HTMLElement).scrollTop > 0) + } + > + {children} +
); } diff --git a/apps/desktop/src/components/ShortcutsSettingsPrototype.tsx b/apps/desktop/src/components/ShortcutsSettingsPrototype.tsx deleted file mode 100644 index b1d64b0b..00000000 --- a/apps/desktop/src/components/ShortcutsSettingsPrototype.tsx +++ /dev/null @@ -1,1084 +0,0 @@ -import { Button, formatShortcut, Input } from "@hubble.md/ui"; -import { isMac } from "keymatch"; -import { type ReactNode, useEffect, useState } from "react"; - -// PROTOTYPE — three variants of the Shortcuts settings page, switchable via -// `?variant=`, inside the existing Settings dialog. Throw away after #194 -// chooses a direction. - -const variants = [ - { id: "sidebar", label: "Sidebar navigator" }, - { id: "tabs", label: "Grouped cards" }, - { id: "table", label: "Command table" }, -] as const; - -type Variant = (typeof variants)[number]["id"]; -type Area = "App" | "Editor"; - -type Command = { - id: string; - label: string; - description: string; - area: Area; - defaultBinding: string; -}; - -type Bindings = Record; -type Errors = Record; - -const commands: Command[] = [ - { - id: "app.new-file", - label: "New File", - description: "Create a Markdown File in the open folder.", - area: "App", - defaultBinding: "CmdOrCtrl+N", - }, - { - id: "app.add-folder", - label: "Add Folder", - description: "Choose a folder to add as a workspace.", - area: "App", - defaultBinding: "CmdOrCtrl+Shift+N", - }, - { - id: "app.open-file", - label: "Open", - description: "Choose a file from the filesystem.", - area: "App", - defaultBinding: "CmdOrCtrl+O", - }, - { - id: "app.open-folder", - label: "Open Folder", - description: "Switch to another recent open folder.", - area: "App", - defaultBinding: "CmdOrCtrl+Shift+O", - }, - { - id: "app.go-to-file", - label: "Go to File", - description: "Search files in the current open folder.", - area: "App", - defaultBinding: "CmdOrCtrl+P", - }, - { - id: "app.settings", - label: "Settings", - description: "Open Hubble settings.", - area: "App", - defaultBinding: "CmdOrCtrl+,", - }, - { - id: "app.go-back", - label: "Go Back", - description: "Move backward through file history.", - area: "App", - defaultBinding: "CmdOrCtrl+[", - }, - { - id: "app.go-forward", - label: "Go Forward", - description: "Move forward through file history.", - area: "App", - defaultBinding: "CmdOrCtrl+]", - }, - { - id: "app.toggle-terminal", - label: "Toggle Terminal", - description: "Show or hide the terminal panel.", - area: "App", - defaultBinding: "CmdOrCtrl+J", - }, - { - id: "app.toggle-source-mode", - label: "Toggle Source Mode", - description: "Switch between rich and source editing.", - area: "App", - defaultBinding: "Alt+CmdOrCtrl+U", - }, - { - id: "app.copy-as-markdown", - label: "Copy as Markdown", - description: "Copy the current selection as Markdown.", - area: "App", - defaultBinding: "Alt+CmdOrCtrl+C", - }, - { - id: "app.copy-path", - label: "Copy File Path", - description: "Copy the selected file path.", - area: "App", - defaultBinding: "CmdOrCtrl+Shift+C", - }, - { - id: "app.reveal", - label: "Reveal in File Manager", - description: "Reveal the selected item in Finder or Explorer.", - area: "App", - defaultBinding: "CmdOrCtrl+Alt+R", - }, - { - id: "app.chat-about-note", - label: "Chat About Note", - description: "Open the configured agent command for this note.", - area: "App", - defaultBinding: "CmdOrCtrl+Shift+J", - }, - { - id: "app.toggle-sidebar", - label: "Toggle Sidebar", - description: "Show or hide the file sidebar.", - area: "App", - defaultBinding: "CmdOrCtrl+Shift+E", - }, - { - id: "app.delete", - label: "Delete", - description: "Delete the selected file or folder.", - area: "App", - defaultBinding: "CmdOrCtrl+Backspace", - }, - { - id: "app.find", - label: "Find", - description: "Find text in the current file.", - area: "App", - defaultBinding: "CmdOrCtrl+F", - }, - { - id: "app.format-menu", - label: "Format", - description: "Open the editor formatting menu.", - area: "App", - defaultBinding: "CmdOrCtrl+/", - }, - { - id: "editor.link", - label: "Link", - description: "Add or edit a link.", - area: "Editor", - defaultBinding: "CmdOrCtrl+K", - }, - { - id: "editor.strike", - label: "Strikethrough", - description: "Toggle strikethrough formatting.", - area: "Editor", - defaultBinding: "CmdOrCtrl+Shift+X", - }, - { - id: "editor.ordered-list", - label: "Numbered List", - description: "Toggle a numbered list.", - area: "Editor", - defaultBinding: "CmdOrCtrl+Shift+7", - }, - { - id: "editor.bullet-list", - label: "Bulleted List", - description: "Toggle a bulleted list.", - area: "Editor", - defaultBinding: "CmdOrCtrl+Shift+8", - }, - { - id: "editor.task-list", - label: "To-do List", - description: "Toggle a to-do list.", - area: "Editor", - defaultBinding: "CmdOrCtrl+Shift+9", - }, - { - id: "editor.bold", - label: "Bold", - description: "Toggle bold formatting.", - area: "Editor", - defaultBinding: "CmdOrCtrl+B", - }, - { - id: "editor.italic", - label: "Italic", - description: "Toggle italic formatting.", - area: "Editor", - defaultBinding: "CmdOrCtrl+I", - }, - { - id: "editor.code", - label: "Inline Code", - description: "Toggle inline code formatting.", - area: "Editor", - defaultBinding: "CmdOrCtrl+E", - }, - { - id: "editor.heading-1", - label: "Heading 1", - description: "Convert the current block to heading 1.", - area: "Editor", - defaultBinding: "CmdOrCtrl+Alt+1", - }, - { - id: "editor.heading-2", - label: "Heading 2", - description: "Convert the current block to heading 2.", - area: "Editor", - defaultBinding: "CmdOrCtrl+Alt+2", - }, - { - id: "editor.heading-3", - label: "Heading 3", - description: "Convert the current block to heading 3.", - area: "Editor", - defaultBinding: "CmdOrCtrl+Alt+3", - }, - { - id: "editor.heading-4", - label: "Heading 4", - description: "Convert the current block to heading 4.", - area: "Editor", - defaultBinding: "CmdOrCtrl+Alt+4", - }, - { - id: "editor.heading-5", - label: "Heading 5", - description: "Convert the current block to heading 5.", - area: "Editor", - defaultBinding: "CmdOrCtrl+Alt+5", - }, - { - id: "editor.heading-6", - label: "Heading 6", - description: "Convert the current block to heading 6.", - area: "Editor", - defaultBinding: "CmdOrCtrl+Alt+6", - }, - { - id: "editor.blockquote", - label: "Quote", - description: "Toggle block quote formatting.", - area: "Editor", - defaultBinding: "CmdOrCtrl+Shift+B", - }, -]; - -const modifierOrder = ["CmdOrCtrl", "Ctrl", "Alt", "Shift", "Super"] as const; -const modifierSet = new Set(modifierOrder); - -function normalizeBinding(binding: string) { - const parts = binding.split("+"); - const modifiers = modifierOrder.filter((modifier) => - parts.includes(modifier), - ); - const keys = parts.filter((part) => !modifierSet.has(part)); - return [...modifiers, ...keys].join("+"); -} - -function defaultBinding(command: Command) { - return normalizeBinding(command.defaultBinding); -} - -const fixedBindings = new Set( - [ - "CmdOrCtrl+A", - "CmdOrCtrl+C", - "CmdOrCtrl+=", - "CmdOrCtrl+-", - "CmdOrCtrl+0", - "CmdOrCtrl+Q", - "CmdOrCtrl+V", - "CmdOrCtrl+X", - "CmdOrCtrl+Y", - "CmdOrCtrl+Z", - "CmdOrCtrl+Shift+Z", - ].map(normalizeBinding), -); - -const unavailableBindings = new Set( - (isMac() ? ["CmdOrCtrl+Space", "CmdOrCtrl+Tab"] : ["Alt+F4", "Alt+Tab"]).map( - normalizeBinding, - ), -); - -function validateBinding( - commandId: string, - binding: string, - bindings: Bindings, -) { - const parts = binding.split("+"); - if (parts.some((part) => part.length === 0)) { - return "That key cannot be used in a Hubble shortcut."; - } - if (parts.includes("Super")) { - return "The system key is not available for app shortcuts."; - } - if ( - !parts.some( - (part) => part === "CmdOrCtrl" || part === "Ctrl" || part === "Alt", - ) - ) { - return "Add Command, Control, or Alt to create a shortcut."; - } - if (unavailableBindings.has(binding)) { - return `${formatShortcut(binding)} is unavailable on this operating system.`; - } - if (fixedBindings.has(binding)) { - return `${formatShortcut(binding)} stays fixed in Hubble.`; - } - const duplicate = commands.find( - (command) => command.id !== commandId && bindings[command.id] === binding, - ); - return duplicate ? `Already assigned to ${duplicate.label}.` : undefined; -} - -function variantFromUrl(): Variant | null { - const requested = new URLSearchParams(window.location.search).get("variant"); - return variants.some(({ id }) => id === requested) - ? (requested as Variant) - : null; -} - -function initialBindings(): Bindings { - return Object.fromEntries( - commands.map((command) => [command.id, defaultBinding(command)]), - ); -} - -function eventBinding(event: KeyboardEvent): string | null { - const keyAliases: Record = { - " ": "Space", - ArrowDown: "Down", - ArrowLeft: "Left", - ArrowRight: "Right", - ArrowUp: "Up", - }; - const key = - // Match keymatch's physical semantics for letters and digits, including - // when Alt or Shift composition reports punctuation or a dead key. - /^Key[A-Z]$/.test(event.code) - ? event.code.slice(3) - : /^Digit[0-9]$/.test(event.code) - ? event.code.slice(5) - : (keyAliases[event.key] ?? event.key); - if (["Alt", "Control", "Meta", "Shift"].includes(key)) return null; - - const parts: string[] = []; - if (event.ctrlKey) parts.push(isMac() ? "Ctrl" : "CmdOrCtrl"); - if (event.metaKey) parts.push(isMac() ? "CmdOrCtrl" : "Super"); - if (event.altKey) parts.push("Alt"); - if (event.shiftKey) parts.push("Shift"); - parts.push(key.length === 1 ? key.toUpperCase() : key); - return normalizeBinding(parts.join("+")); -} - -function usePrototypeState() { - const [bindings, setBindings] = useState(() => { - const initial = initialBindings(); - initial["app.new-file"] = "CmdOrCtrl+Alt+N"; - initial["app.chat-about-note"] = null; - return initial; - }); - const [errors, setErrors] = useState({}); - const [recordingId, setRecordingId] = useState(null); - const [query, setQuery] = useState(""); - - useEffect(() => { - if (!recordingId) return; - - const record = (event: KeyboardEvent) => { - event.preventDefault(); - event.stopPropagation(); - - if (event.key === "Escape") { - setRecordingId(null); - setErrors((current) => ({ ...current, [recordingId]: undefined })); - return; - } - if ( - (event.key === "Backspace" || event.key === "Delete") && - !event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey - ) { - setBindings((current) => ({ ...current, [recordingId]: null })); - setRecordingId(null); - setErrors((current) => ({ ...current, [recordingId]: undefined })); - return; - } - - const binding = eventBinding(event); - if (!binding) return; - const error = validateBinding(recordingId, binding, bindings); - if (error) { - setErrors((current) => ({ - ...current, - [recordingId]: error, - })); - return; - } - - setBindings((current) => ({ ...current, [recordingId]: binding })); - setErrors((current) => ({ ...current, [recordingId]: undefined })); - setRecordingId(null); - }; - - window.addEventListener("keydown", record, true); - const onWindowBlur = () => { - setErrors((current) => ({ - ...current, - [recordingId]: - "The operating system may have intercepted that shortcut. Press another combination or Escape.", - })); - }; - window.addEventListener("blur", onWindowBlur); - return () => { - window.removeEventListener("keydown", record, true); - window.removeEventListener("blur", onWindowBlur); - }; - }, [bindings, recordingId]); - - const reset = (command: Command) => { - const binding = defaultBinding(command); - const error = validateBinding(command.id, binding, bindings); - if (error) { - setErrors((current) => ({ ...current, [command.id]: error })); - if (recordingId === command.id) setRecordingId(null); - return; - } - setBindings((current) => ({ - ...current, - [command.id]: binding, - })); - setErrors((current) => ({ ...current, [command.id]: undefined })); - if (recordingId === command.id) setRecordingId(null); - }; - - const clear = (command: Command) => { - setBindings((current) => ({ ...current, [command.id]: null })); - setErrors((current) => ({ ...current, [command.id]: undefined })); - if (recordingId === command.id) setRecordingId(null); - }; - - const resetAll = () => { - setBindings(initialBindings()); - setErrors({}); - setRecordingId(null); - }; - - return { - bindings, - clear, - errors, - query, - recordingId, - reset, - resetAll, - setQuery, - startRecording: (id: string) => { - setErrors((current) => ({ ...current, [id]: undefined })); - setRecordingId(id); - }, - }; -} - -type PrototypeState = ReturnType; - -type PrototypeProps = { - general: ReactNode; - page: "general" | "shortcuts"; - setPage: (page: "general" | "shortcuts") => void; - state: PrototypeState; -}; - -export function ShortcutsSettingsPrototype({ - general, -}: { - general: ReactNode; -}) { - const [variant, setVariant] = useState( - () => variantFromUrl() ?? "sidebar", - ); - const [page, setPage] = useState<"general" | "shortcuts">("shortcuts"); - const state = usePrototypeState(); - - const chooseVariant = (next: Variant) => { - const url = new URL(window.location.href); - url.searchParams.set("variant", next); - window.history.replaceState(null, "", url); - setVariant(next); - }; - - const props = { general, page, setPage, state }; - - return ( - <> - {variant === "sidebar" ? ( - - ) : variant === "tabs" ? ( - - ) : ( - - )} - - - ); -} - -function SidebarVariant({ general, page, setPage, state }: PrototypeProps) { - return ( -
- -
- {page === "general" ? ( - {general} - ) : ( - - )} -
-
- ); -} - -function TabsVariant({ general, page, setPage, state }: PrototypeProps) { - const filtered = filteredCommands(state.query); - return ( -
-
-
- setPage("general")} - > - General - - setPage("shortcuts")} - > - Shortcuts - -
-
- {page === "general" ? ( -
- {general} -
- ) : ( -
- -
- {(["App", "Editor"] as const).map((area) => ( -
-
-

{area}

- - {filtered.filter((command) => command.area === area).length} - -
-
- {filtered - .filter((command) => command.area === area) - .map((command) => ( - - ))} -
-
- ))} -
- {filtered.length === 0 ? : null} -
- )} -
- ); -} - -function TableVariant({ general, page, setPage, state }: PrototypeProps) { - const filtered = filteredCommands(state.query); - return ( -
-
-
- setPage("general")} - > - General - - setPage("shortcuts")} - > - Shortcuts - -
- {page === "shortcuts" ? ( - <> - state.setQuery(event.currentTarget.value)} - /> - - - ) : null} -
- {page === "general" ? ( -
- {general} -
- ) : ( -
-
- Area - Command - Binding - Action -
-
- {filtered.map((command) => ( - - ))} -
- {filtered.length === 0 ? : null} -
- )} -
- ); -} - -function ShortcutList({ state }: { state: PrototypeState }) { - const filtered = filteredCommands(state.query); - return ( -
- -
- {(["App", "Editor"] as const).map((area) => ( -
-
-

{area}

- - {filtered.filter((command) => command.area === area).length}{" "} - commands - -
-
- {filtered - .filter((command) => command.area === area) - .map((command) => ( - - ))} -
-
- ))} - {filtered.length === 0 ? : null} -
-
- ); -} - -function ShortcutHeader({ state }: { state: PrototypeState }) { - return ( -
-
-

Keyboard shortcuts

-

- Select a binding, then press a new key combination. -

-
-
- state.setQuery(event.currentTarget.value)} - /> - -
-
- ); -} - -function ListCommandRow({ - command, - state, -}: { - command: Command; - state: PrototypeState; -}) { - const binding = state.bindings[command.id]; - const customized = binding !== defaultBinding(command); - return ( -
-
-

{command.label}

-

- {command.description} -

- -
-
- - {customized ? ( - - ) : null} - -
-
- ); -} - -function CardCommandRow({ - command, - state, -}: { - command: Command; - state: PrototypeState; -}) { - const binding = state.bindings[command.id]; - return ( -
-
-
-

{command.label}

-

- {command.description} -

-
- -
-
- - {state.errors[command.id] ? null : ( -

- {binding === null - ? "Disabled" - : binding === defaultBinding(command) - ? "Default" - : "Customized"} -

- )} -
- {binding !== defaultBinding(command) ? ( - - ) : null} - -
-
-
- ); -} - -function TableCommandRow({ - command, - state, -}: { - command: Command; - state: PrototypeState; -}) { - const binding = state.bindings[command.id]; - return ( -
-
- - {command.area} - -
-

{command.label}

-

- {command.description} -

-
- -
- {binding !== defaultBinding(command) ? ( - - ) : null} - -
-
- -
- ); -} - -function BindingButton({ - command, - state, -}: { - command: Command; - state: PrototypeState; -}) { - const binding = state.bindings[command.id]; - const recording = state.recordingId === command.id; - return ( - - ); -} - -function BindingError({ - error, - className = "", -}: { - error?: string; - className?: string; -}) { - return error ? ( -

{error}

- ) : null; -} - -function GeneralPane({ children }: { children: ReactNode }) { - return ( -
{children}
- ); -} - -function NavButton({ - active, - children, - count, - onClick, -}: { - active: boolean; - children: ReactNode; - count?: number; - onClick: () => void; -}) { - return ( - - ); -} - -function TabButton({ - active, - children, - onClick, -}: { - active: boolean; - children: ReactNode; - onClick: () => void; -}) { - return ( - - ); -} - -function SegmentButton({ - active, - children, - onClick, -}: { - active: boolean; - children: ReactNode; - onClick: () => void; -}) { - return ( - - ); -} - -function EmptySearch() { - return ( -

- No shortcuts match this search. -

- ); -} - -function filteredCommands(query: string) { - const needle = query.trim().toLocaleLowerCase(); - if (!needle) return commands; - return commands.filter((command) => - `${command.label} ${command.description} ${command.id} ${command.area}` - .toLocaleLowerCase() - .includes(needle), - ); -} - -function PrototypeSwitcher({ - current, - onChange, - recording, -}: { - current: Variant; - onChange: (variant: Variant) => void; - recording: boolean; -}) { - const currentIndex = variants.findIndex(({ id }) => id === current); - const cycle = (direction: -1 | 1) => { - const nextIndex = - (currentIndex + direction + variants.length) % variants.length; - onChange(variants[nextIndex].id); - }; - const currentVariant = variants[currentIndex]; - - return ( -
- -
-
- {currentVariant.id} - — {currentVariant.label} -
-
- Mock data · resets on reload -
-
- -
- ); -} diff --git a/apps/desktop/src/components/shortcutSettingsModel.test.ts b/apps/desktop/src/components/shortcutSettingsModel.test.ts new file mode 100644 index 00000000..f701a442 --- /dev/null +++ b/apps/desktop/src/components/shortcutSettingsModel.test.ts @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom + +import { describe, expect, it } from "vitest"; + +Object.defineProperty(window, "desktopApi", { value: {} }); + +const { filterShortcutGroups, isShortcutCustomized, validateShortcutBinding } = + await import("./shortcutSettingsModel"); + +describe("filterShortcutGroups", () => { + it("omits groups with no search matches", () => { + const groups = filterShortcutGroups("workspace"); + + expect(groups.map((group) => group.area)).toEqual(["App"]); + expect(groups[0]?.commands.length).toBeGreaterThan(0); + }); + + it("returns no empty group headers", () => { + expect(filterShortcutGroups("no such shortcut")).toEqual([]); + }); + + it("does not mark reordered registry modifiers as custom", () => { + expect(isShortcutCustomized("app.toggle-source-mode", {})).toBe(false); + expect(isShortcutCustomized("app.copy-as-markdown", {})).toBe(false); + }); +}); + +describe("validateShortcutBinding", () => { + it("keeps application and terminal bindings fixed", () => { + expect(validateShortcutBinding("CmdOrCtrl+Q", true)).toContain( + "stays fixed", + ); + expect(validateShortcutBinding("Ctrl+`", true)).toContain("stays fixed"); + expect(validateShortcutBinding("CmdOrCtrl+Q", false)).toBeUndefined(); + }); + + it("rejects operating system shortcuts by platform", () => { + expect(validateShortcutBinding("CmdOrCtrl+`", true)).toContain( + "operating system", + ); + expect(validateShortcutBinding("CmdOrCtrl+Shift+4", true)).toContain( + "operating system", + ); + expect(validateShortcutBinding("CmdOrCtrl+Ctrl+Q", true)).toContain( + "operating system", + ); + expect(validateShortcutBinding("Alt+Tab", false)).toContain( + "operating system", + ); + expect(validateShortcutBinding("Ctrl+Alt+Delete", false)).toContain( + "operating system", + ); + }); + + it("uses platform-specific modifier names", () => { + expect(validateShortcutBinding("K", true)).toBe( + "Add a modifier (⌘, ⌥, ⌃) to create a shortcut.", + ); + expect(validateShortcutBinding("K", false)).toBe( + "Add a modifier (Ctrl, Alt) to create a shortcut.", + ); + }); +}); diff --git a/apps/desktop/src/components/shortcutSettingsModel.ts b/apps/desktop/src/components/shortcutSettingsModel.ts new file mode 100644 index 00000000..6f89648e --- /dev/null +++ b/apps/desktop/src/components/shortcutSettingsModel.ts @@ -0,0 +1,191 @@ +import { + type CommandBindings, + type CommandId, + commandRegistry, + getCommand, + resolveCommandBinding, + sortCommandBinding, +} from "@hubble.md/editor"; +import { formatShortcut } from "@hubble.md/ui"; +import { isMac } from "keymatch"; + +export type ShortcutCommand = { + id: CommandId; + label: string; + description: string; + area: "App" | "Editor"; +}; + +const descriptions: Record = { + "app.new-file": "Create a Markdown file in the open folder.", + "app.open-recent": "Switch to another recently opened folder.", + "app.open-file": "Choose a file from the filesystem.", + "app.open-folder": "Choose a folder to open as a workspace.", + "app.go-to-file": "Search files in the current open folder.", + "app.settings": "Open Hubble settings.", + "app.go-back": "Move backward through file history.", + "app.go-forward": "Move forward through file history.", + "app.toggle-terminal": "Show or hide the terminal panel.", + "app.toggle-source-mode": "Switch between rich and source editing.", + "app.copy-as-markdown": "Copy the current selection as Markdown.", + "app.copy-path": "Copy the selected file path.", + "app.reveal": "Reveal the selected item in Finder or Explorer.", + "app.chat-about-note": "Open the configured agent command for this note.", + "app.toggle-sidebar": "Show or hide the file sidebar.", + "app.delete": "Delete the selected file or folder.", + "app.find": "Find text in the current file.", + "app.format-menu": "Open the editor formatting menu.", + "editor.link": "Add or edit a link.", + "editor.strike": "Toggle strikethrough formatting.", + "editor.ordered-list": "Toggle a numbered list.", + "editor.bullet-list": "Toggle a bulleted list.", + "editor.task-list": "Toggle a to-do list.", + "editor.bold": "Toggle bold formatting.", + "editor.italic": "Toggle italic formatting.", + "editor.code": "Toggle inline code formatting.", + "editor.heading-1": "Convert the current block to heading 1.", + "editor.heading-2": "Convert the current block to heading 2.", + "editor.heading-3": "Convert the current block to heading 3.", + "editor.heading-4": "Convert the current block to heading 4.", + "editor.heading-5": "Convert the current block to heading 5.", + "editor.heading-6": "Convert the current block to heading 6.", + "editor.blockquote": "Toggle block quote formatting.", +}; + +export const shortcutCommands = ( + Object.keys(commandRegistry) as CommandId[] +).map((id): ShortcutCommand => { + const command = getCommand(id); + return { + id, + label: command.label, + description: descriptions[id], + area: id.startsWith("app.") ? "App" : "Editor", + }; +}); + +export function filterShortcutGroups(query: string) { + const needle = query.trim().toLocaleLowerCase(); + const filtered = needle + ? shortcutCommands.filter((command) => + `${command.label} ${command.description} ${command.id} ${command.area}` + .toLocaleLowerCase() + .includes(needle), + ) + : shortcutCommands; + + return (["App", "Editor"] as const).flatMap((area) => { + const commands = filtered.filter((command) => command.area === area); + return commands.length > 0 ? [{ area, commands }] : []; + }); +} + +export function isShortcutCustomized(id: CommandId, bindings: CommandBindings) { + return resolveCommandBinding(id, bindings) !== getCommand(id).defaultBinding; +} + +const commonFixedBindings = bindingSet([ + "CmdOrCtrl+A", + "CmdOrCtrl+C", + "CmdOrCtrl+=", + "CmdOrCtrl+-", + "CmdOrCtrl+0", + "CmdOrCtrl+V", + "CmdOrCtrl+X", + "CmdOrCtrl+Y", + "CmdOrCtrl+Z", + "CmdOrCtrl+Shift+Z", + "Ctrl+`", +]); +const macFixedBindings = bindingSet([ + "CmdOrCtrl+Q", + "CmdOrCtrl+W", + "CmdOrCtrl+H", + "CmdOrCtrl+Alt+H", + "CmdOrCtrl+M", +]); +const macUnavailableBindings = bindingSet([ + "CmdOrCtrl+Space", + "CmdOrCtrl+Tab", + "CmdOrCtrl+`", + "CmdOrCtrl+Alt+Escape", + "CmdOrCtrl+Shift+3", + "CmdOrCtrl+Shift+4", + "CmdOrCtrl+Shift+5", + "CmdOrCtrl+Ctrl+Space", + "CmdOrCtrl+Ctrl+Q", + "CmdOrCtrl+Shift+Q", + "CmdOrCtrl+Alt+Shift+Q", +]); +const otherUnavailableBindings = bindingSet([ + "Alt+F4", + "Alt+Tab", + "Alt+Escape", + "Ctrl+Alt+Delete", + "Ctrl+Shift+Escape", +]); + +export function validateShortcutBinding(binding: string, mac = isMac()) { + const sortedBinding = sortCommandBinding(binding); + const parts = sortedBinding.split("+"); + if (parts.some((part) => part.length === 0)) { + return "That key cannot be used in a Hubble shortcut."; + } + if (parts.includes("Super")) { + return "The system key is not available for app shortcuts."; + } + if ( + !parts.some( + (part) => part === "CmdOrCtrl" || part === "Ctrl" || part === "Alt", + ) + ) { + return mac + ? "Add a modifier (⌘, ⌥, ⌃) to create a shortcut." + : "Add a modifier (Ctrl, Alt) to create a shortcut."; + } + + if ( + commonFixedBindings.has(sortedBinding) || + (mac && macFixedBindings.has(sortedBinding)) + ) { + return `${formatShortcut(binding)} stays fixed in Hubble.`; + } + + const unavailableBindings = mac + ? macUnavailableBindings + : otherUnavailableBindings; + if (unavailableBindings.has(sortedBinding)) { + return `${formatShortcut(binding)} is unavailable on this operating system.`; + } +} + +export function shortcutBindingFromEvent( + event: KeyboardEvent, + mac = isMac(), +): string | null { + const keyAliases: Record = { + " ": "Space", + ArrowDown: "Down", + ArrowLeft: "Left", + ArrowRight: "Right", + ArrowUp: "Up", + }; + const key = /^Key[A-Z]$/.test(event.code) + ? event.code.slice(3) + : /^Digit[0-9]$/.test(event.code) + ? event.code.slice(5) + : (keyAliases[event.key] ?? event.key); + if (["Alt", "Control", "Meta", "Shift"].includes(key)) return null; + + const parts: string[] = []; + if (event.ctrlKey) parts.push(mac ? "Ctrl" : "CmdOrCtrl"); + if (event.metaKey) parts.push(mac ? "CmdOrCtrl" : "Super"); + if (event.altKey) parts.push("Alt"); + if (event.shiftKey) parts.push("Shift"); + parts.push(key.length === 1 ? key.toUpperCase() : key); + return sortCommandBinding(parts.join("+")); +} + +function bindingSet(bindings: string[]) { + return new Set(bindings.map(sortCommandBinding)); +} diff --git a/packages/ui/src/primitives/modal.tsx b/packages/ui/src/primitives/modal.tsx index 66ef7935..005d64c3 100644 --- a/packages/ui/src/primitives/modal.tsx +++ b/packages/ui/src/primitives/modal.tsx @@ -7,9 +7,11 @@ import { Button } from "./button"; type Props = { open?: boolean; onOpenChange?: (open: boolean) => void; - title: string; + title: ReactNode; description?: string; className?: string; + headerClassName?: string; + contentClassName?: string; children: ReactNode; }; @@ -19,6 +21,8 @@ function Modal({ title, description, className, + headerClassName, + contentClassName, children, }: Props) { return ( @@ -31,7 +35,12 @@ function Modal({ className, )} > -
+
{title} @@ -55,7 +64,12 @@ function Modal({ } />
-
+
{children}
From c08c1c796bc3d36e785f54d64744c7efc58ec7b6 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Wed, 19 Aug 2026 10:43:00 -0400 Subject: [PATCH 6/7] Address shortcut settings review --- apps/desktop/src/components/Settings.tsx | 2 +- .../desktop/src/components/SettingsDialog.tsx | 8 ++++-- .../components/shortcutSettingsModel.test.ts | 25 +++++++++++++++++-- .../src/components/shortcutSettingsModel.ts | 17 ++++++++----- apps/desktop/src/store/actions.test.ts | 3 +++ apps/desktop/src/store/actions.ts | 4 +-- packages/editor/src/commandRegistry.test.ts | 5 ++++ packages/editor/src/commandRegistry.ts | 10 +++++++- packages/editor/src/index.ts | 1 + 9 files changed, 61 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/components/Settings.tsx b/apps/desktop/src/components/Settings.tsx index 804b94cb..2087dfa9 100644 --- a/apps/desktop/src/components/Settings.tsx +++ b/apps/desktop/src/components/Settings.tsx @@ -249,7 +249,7 @@ function ChatSettings() { function ShortcutSettings() { const state = useShortcutState(); - const groups = filterShortcutGroups(state.query); + const groups = filterShortcutGroups(state.query, state.bindings); return (
diff --git a/apps/desktop/src/components/SettingsDialog.tsx b/apps/desktop/src/components/SettingsDialog.tsx index b7805b94..991119bb 100644 --- a/apps/desktop/src/components/SettingsDialog.tsx +++ b/apps/desktop/src/components/SettingsDialog.tsx @@ -14,7 +14,8 @@ export function SettingsDialog({ className?: string; children: ReactNode; }) { - const [scrolled, setScrolled] = useState(false); + const [scrollState, setScrollState] = useState({ title, scrolled: false }); + const scrolled = scrollState.title === title && scrollState.scrolled; return ( - setScrolled((event.target as HTMLElement).scrollTop > 0) + setScrollState({ + title, + scrolled: (event.target as HTMLElement).scrollTop > 0, + }) } > {children} diff --git a/apps/desktop/src/components/shortcutSettingsModel.test.ts b/apps/desktop/src/components/shortcutSettingsModel.test.ts index f701a442..431b825b 100644 --- a/apps/desktop/src/components/shortcutSettingsModel.test.ts +++ b/apps/desktop/src/components/shortcutSettingsModel.test.ts @@ -1,5 +1,6 @@ // @vitest-environment happy-dom +import { formatShortcut } from "@hubble.md/ui"; import { describe, expect, it } from "vitest"; Object.defineProperty(window, "desktopApi", { value: {} }); @@ -19,9 +20,29 @@ describe("filterShortcutGroups", () => { expect(filterShortcutGroups("no such shortcut")).toEqual([]); }); + it("matches current bindings in raw and display form", () => { + const bindings = { "app.new-file": "CmdOrCtrl+Alt+N" } as const; + + expect( + filterShortcutGroups("CmdOrCtrl+Alt+N", bindings)[0]?.commands[0]?.id, + ).toBe("app.new-file"); + expect( + filterShortcutGroups(formatShortcut("CmdOrCtrl+Alt+N"), bindings)[0] + ?.commands[0]?.id, + ).toBe("app.new-file"); + }); + it("does not mark reordered registry modifiers as custom", () => { - expect(isShortcutCustomized("app.toggle-source-mode", {})).toBe(false); - expect(isShortcutCustomized("app.copy-as-markdown", {})).toBe(false); + expect( + isShortcutCustomized("app.toggle-source-mode", { + "app.toggle-source-mode": "CmdOrCtrl+Alt+U", + }), + ).toBe(false); + expect( + isShortcutCustomized("app.toggle-source-mode", { + "app.toggle-source-mode": null, + }), + ).toBe(true); }); }); diff --git a/apps/desktop/src/components/shortcutSettingsModel.ts b/apps/desktop/src/components/shortcutSettingsModel.ts index 6f89648e..78528d7d 100644 --- a/apps/desktop/src/components/shortcutSettingsModel.ts +++ b/apps/desktop/src/components/shortcutSettingsModel.ts @@ -3,6 +3,7 @@ import { type CommandId, commandRegistry, getCommand, + isDefaultCommandBinding, resolveCommandBinding, sortCommandBinding, } from "@hubble.md/editor"; @@ -64,14 +65,18 @@ export const shortcutCommands = ( }; }); -export function filterShortcutGroups(query: string) { +export function filterShortcutGroups( + query: string, + bindings: CommandBindings = {}, +) { const needle = query.trim().toLocaleLowerCase(); const filtered = needle - ? shortcutCommands.filter((command) => - `${command.label} ${command.description} ${command.id} ${command.area}` + ? shortcutCommands.filter((command) => { + const binding = resolveCommandBinding(command.id, bindings); + return `${command.label} ${command.description} ${command.id} ${command.area} ${binding ?? ""} ${binding ? formatShortcut(binding) : ""}` .toLocaleLowerCase() - .includes(needle), - ) + .includes(needle); + }) : shortcutCommands; return (["App", "Editor"] as const).flatMap((area) => { @@ -81,7 +86,7 @@ export function filterShortcutGroups(query: string) { } export function isShortcutCustomized(id: CommandId, bindings: CommandBindings) { - return resolveCommandBinding(id, bindings) !== getCommand(id).defaultBinding; + return !isDefaultCommandBinding(id, resolveCommandBinding(id, bindings)); } const commonFixedBindings = bindingSet([ diff --git a/apps/desktop/src/store/actions.test.ts b/apps/desktop/src/store/actions.test.ts index 3bec1223..ca9fa2c1 100644 --- a/apps/desktop/src/store/actions.test.ts +++ b/apps/desktop/src/store/actions.test.ts @@ -170,6 +170,9 @@ describe("desktop savePathContent", () => { resetShortcutBindings(); expect(shortcutBindingsStore.get()).toEqual({}); expect(getCommandBinding("app.new-file")).toBe("CmdOrCtrl+N"); + + setShortcutBinding("app.toggle-source-mode", "CmdOrCtrl+Alt+U"); + expect(shortcutBindingsStore.get()).toEqual({}); }); it("shares desktop settings state across consumers", async () => { diff --git a/apps/desktop/src/store/actions.ts b/apps/desktop/src/store/actions.ts index c681218f..99423db8 100644 --- a/apps/desktop/src/store/actions.ts +++ b/apps/desktop/src/store/actions.ts @@ -1,7 +1,7 @@ import { type CommandBindings, type CommandId, - getCommand, + isDefaultCommandBinding, setCommandBindings, } from "@hubble.md/editor"; import type { ReviewThread } from "@hubble.md/ui"; @@ -613,7 +613,7 @@ export async function setTelemetryConsent(choice: TelemetryChoice) { export function setShortcutBinding(id: CommandId, binding: string | null) { const next = { ...shortcutBindingsStore.get() }; - if (binding === getCommand(id).defaultBinding) { + if (isDefaultCommandBinding(id, binding)) { delete next[id]; } else { next[id] = binding; diff --git a/packages/editor/src/commandRegistry.test.ts b/packages/editor/src/commandRegistry.test.ts index 14e49ed5..14c5b982 100644 --- a/packages/editor/src/commandRegistry.test.ts +++ b/packages/editor/src/commandRegistry.test.ts @@ -6,6 +6,7 @@ import { getCommand, getCommandBinding, getCommandBindings, + isDefaultCommandBinding, resolveCommandBinding, setCommandBindings, subscribeCommandBindings, @@ -75,11 +76,15 @@ describe("commandRegistry", () => { expect( cleanCommandBindings({ "app.new-file": "CmdOrCtrl+N", + "app.toggle-source-mode": "CmdOrCtrl+Alt+U", "app.settings": null, "editor.bold": 42, "missing.command": "CmdOrCtrl+M", }), ).toEqual({ "app.settings": null }); + expect( + isDefaultCommandBinding("app.toggle-source-mode", "CmdOrCtrl+Alt+U"), + ).toBe(true); expect( resolveCommandBinding("app.settings", { "app.settings": null }), ).toBeNull(); diff --git a/packages/editor/src/commandRegistry.ts b/packages/editor/src/commandRegistry.ts index ec73b2b9..5ffcdf65 100644 --- a/packages/editor/src/commandRegistry.ts +++ b/packages/editor/src/commandRegistry.ts @@ -256,6 +256,14 @@ export function sortCommandBinding(binding: string) { return [...modifiers, ...keys].join("+"); } +export function isDefaultCommandBinding(id: CommandId, binding: string | null) { + return ( + binding !== null && + sortCommandBinding(binding) === + sortCommandBinding(getCommand(id).defaultBinding) + ); +} + export function setCommandBindings(bindings: CommandBindings) { commandBindings = cleanCommandBindings(bindings); for (const listener of bindingListeners) listener(); @@ -280,7 +288,7 @@ export function cleanCommandBindings(value: unknown): CommandBindings { if (!(id in commandRegistry)) continue; if (binding !== null && (typeof binding !== "string" || !binding)) continue; const commandId = id as CommandId; - if (binding !== getCommand(commandId).defaultBinding) { + if (!isDefaultCommandBinding(commandId, binding)) { bindings[commandId] = binding; } } diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 69f488ad..55c7d07d 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -11,6 +11,7 @@ export { getCommand, getCommandBinding, getCommandBindings, + isDefaultCommandBinding, resolveCommandBinding, setCommandBindings, sortCommandBinding, From 2eb6b5b63d7643f7be731182b6a52514e482afd0 Mon Sep 17 00:00:00 2001 From: Ben Holmes Date: Wed, 19 Aug 2026 10:52:39 -0400 Subject: [PATCH 7/7] Add shortcut settings changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6b491f..450b1bc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com). ### Added +- Customize keyboard shortcuts from Settings. Thanks [@Mamdouh66](https://github.com/Mamdouh66)! [#204](https://github.com/bholmesdev/hubble.md/pull/204) + ### Changed ### Fixed