From 6ccb80785cb313acde26b2a258dfdf582c0dc32f Mon Sep 17 00:00:00 2001 From: Ethan Graf Date: Mon, 1 Jun 2026 22:07:16 -0400 Subject: [PATCH] Hotkeys --- .opencode/skills/textile-design/components.md | 6 + src/App.tsx | 3 +- src/actions/ActionContext.ts | 17 +- src/actions/ActionProvider.tsx | 53 ++- src/actions/CommandPalette.tsx | 11 +- src/actions/hotkeyStore.ts | 122 +++++++ src/actions/index.ts | 8 +- src/actions/services.ts | 4 +- src/components/HotkeysPane.tsx | 323 ++++++++++++++++++ src/components/SettingsModal.tsx | 28 +- src/components/SettingsPane.tsx | 2 +- src/icons/index.tsx | 55 +++ src/main.ts | 3 +- 13 files changed, 610 insertions(+), 25 deletions(-) create mode 100644 src/actions/hotkeyStore.ts create mode 100644 src/components/HotkeysPane.tsx diff --git a/.opencode/skills/textile-design/components.md b/.opencode/skills/textile-design/components.md index 0278bf1..c6dd3d5 100644 --- a/.opencode/skills/textile-design/components.md +++ b/.opencode/skills/textile-design/components.md @@ -31,6 +31,12 @@ Reference implementations live under `src/components/` unless noted. - `aria-label` describing action; `aria-pressed` if toggle - SVG decorative: `aria-hidden` +## Icons + +**Reference:** `src/icons/index.tsx` + +All shared SVG icons live in `src/icons/index.tsx`. They accept `className` for Tailwind sizing/coloring and use `currentColor` for stroke so they inherit the parent's text color. Import them from `../icons` (or `@/icons` if aliased). + ## Primary / secondary buttons **Primary CTA:** `bg-primary text-primary-foreground`, adequate padding (`px-3 py-2`), optional `hover:opacity-90`, `disabled:opacity-50`. diff --git a/src/App.tsx b/src/App.tsx index 09279aa..3603006 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -83,7 +83,7 @@ function AppShell() { const isMac = getRendererPlatform() === 'darwin'; // Core action-system services — created once at app start and passed down. - const { registry, hotkeyManager } = useMemo( + const { registry, hotkeyManager, hotkeyStore } = useMemo( () => createActionServices(isMac), [isMac], ); @@ -171,6 +171,7 @@ function AppShell() { actionSets={actionSets} registry={registry} hotkeyManager={hotkeyManager} + hotkeyStore={hotkeyStore} >
(null); /** - * Hook to access the action registry. + * Hook to access the action registry and hotkey store. * Must be used within an ActionProvider. */ export function useActions(): ActionProviderValue { @@ -31,3 +34,11 @@ export function useActions(): ActionProviderValue { export function useActionRegistry(): ActionRegistry { return useActions().registry; } + +/** + * Hook to access the hotkey store. + * Must be used within an ActionProvider. + */ +export function useHotkeyStore(): HotkeyStore { + return useActions().hotkeyStore; +} diff --git a/src/actions/ActionProvider.tsx b/src/actions/ActionProvider.tsx index 5576f2f..04a6cdb 100644 --- a/src/actions/ActionProvider.tsx +++ b/src/actions/ActionProvider.tsx @@ -8,6 +8,7 @@ import { import type { ActionSet, RegisteredAction } from './types'; import type { ActionRegistry } from './registry'; import type { HotkeyManager } from './hotkey-manager'; +import type { HotkeyStore } from './hotkeyStore'; import { ActionContext } from './ActionContext'; import { CommandPalette } from './CommandPalette'; import { getRendererPlatform } from '../lib/platform'; @@ -20,6 +21,8 @@ interface ActionProviderProps { registry: ActionRegistry; /** The hotkey manager (created and owned by AppShell). */ hotkeyManager: HotkeyManager; + /** The hotkey store (created and owned by AppShell). */ + hotkeyStore: HotkeyStore; } /** @@ -34,6 +37,7 @@ export function ActionProvider({ actionSets, registry, hotkeyManager, + hotkeyStore, }: ActionProviderProps) { const isMac = getRendererPlatform() === 'darwin'; @@ -60,27 +64,46 @@ export function ActionProvider({ [actionSets], ); - // Register/unregister all actions from all sets - useEffect(() => { + /** + * Register all actions from all sets and wire their effective hotkeys. + */ + const registerAll = useCallback(() => { for (const set of allActionSets) { for (const def of set.getActions()) { const registered = registry.register(set.id, def); - if (registered.hotkeys) { - for (const hotkey of registered.hotkeys) { - hotkeyManager.registerHotkey(registered.id, hotkey); - } + for (const hotkey of hotkeyStore.getEffectiveHotkeys(registered)) { + hotkeyManager.registerHotkey(registered.id, hotkey); } } } + }, [allActionSets, registry, hotkeyManager, hotkeyStore]); - return () => { - for (const set of allActionSets) { - registry.unregisterAll(set.id); - hotkeyManager.removeSourceHotkeys(set.id); - } - }; + /** + * Unregister all actions and clear their hotkeys. + */ + const unregisterAll = useCallback(() => { + for (const set of allActionSets) { + registry.unregisterAll(set.id); + hotkeyManager.removeSourceHotkeys(set.id); + } }, [allActionSets, registry, hotkeyManager]); + // Register on mount / when action sets change. + // Re-register hotkeys when the user edits an override. + useEffect(() => { + registerAll(); + return unregisterAll; + }, [registerAll, unregisterAll]); + + // Re-register hotkeys immediately when the user edits an override, + // without waiting for a React state cycle. + useEffect(() => { + return hotkeyStore.onChange(() => { + unregisterAll(); + registerAll(); + }); + }, [hotkeyStore, registerAll, unregisterAll]); + const handlePaletteExecute = useCallback( (action: RegisteredAction) => { void registry.execute(action.id); @@ -93,7 +116,10 @@ export function ActionProvider({ [registry, paletteOpen], ); - const value = useMemo(() => ({ registry }), [registry]); + const value = useMemo( + () => ({ registry, hotkeyStore }), + [registry, hotkeyStore], + ); return ( @@ -103,6 +129,7 @@ export function ActionProvider({ onClose={() => setPaletteOpen(false)} onExecute={handlePaletteExecute} actions={availableActions} + hotkeyStore={hotkeyStore} isMac={isMac} /> diff --git a/src/actions/CommandPalette.tsx b/src/actions/CommandPalette.tsx index 861ca48..74e345a 100644 --- a/src/actions/CommandPalette.tsx +++ b/src/actions/CommandPalette.tsx @@ -7,6 +7,7 @@ import { type KeyboardEvent, } from 'react'; import type { RegisteredAction } from './types'; +import type { HotkeyStore } from './hotkeyStore'; import { formatHotkeyForDisplay } from './hotkey-utils'; interface CommandPaletteProps { @@ -18,6 +19,8 @@ interface CommandPaletteProps { onExecute: (action: RegisteredAction) => void; /** All available actions */ actions: RegisteredAction[]; + /** Hotkey store for resolving effective hotkeys */ + hotkeyStore: HotkeyStore; /** Whether the platform is macOS (affects hotkey display) */ isMac: boolean; /** Optional placeholder text */ @@ -38,6 +41,7 @@ export function CommandPalette({ onClose, onExecute, actions, + hotkeyStore, isMac, placeholder = 'Type a command...', }: CommandPaletteProps) { @@ -177,8 +181,9 @@ export function CommandPalette({
{filteredActions.map((action, index) => { const isSelected = index === selectedIndex; - const hotkeyHint = action.hotkeys?.[0] - ? formatHotkeyForDisplay(action.hotkeys[0], isMac) + const effectiveHotkeys = hotkeyStore.getEffectiveHotkeys(action); + const hotkeyHint = effectiveHotkeys[0] + ? formatHotkeyForDisplay(effectiveHotkeys[0], isMac) : undefined; return ( @@ -198,7 +203,7 @@ export function CommandPalette({ > {action.name} {hotkeyHint ? ( - + {hotkeyHint} ) : null} diff --git a/src/actions/hotkeyStore.ts b/src/actions/hotkeyStore.ts new file mode 100644 index 0000000..8ec6df8 --- /dev/null +++ b/src/actions/hotkeyStore.ts @@ -0,0 +1,122 @@ +import type { Hotkey, RegisteredAction } from './types'; +import type { ActionRegistry } from './registry'; +import { normalizeHotkey, hotkeyToString } from './hotkey-utils'; + +const STORAGE_KEY = 'textile.hotkeys.v2'; + +/** + * Persists user hotkey overrides and resolves effective hotkeys. + * + * Each action has default hotkeys from its ActionSet (an array). Users can + * replace the entire array, clear it (unbind), or revert to defaults. + * Overrides are stored in localStorage and survive app restarts. + */ +export class HotkeyStore { + /** Map of action full ID -> override array ([] = explicitly unbound) */ + private overrides = new Map(); + private listeners = new Set<() => void>(); + + constructor() { + this.load(); + } + + private load(): void { + if (typeof localStorage === 'undefined') return; + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return; + const data = JSON.parse(raw) as Record; + for (const [id, hotkeys] of Object.entries(data)) { + if (Array.isArray(hotkeys)) { + this.overrides.set(id, hotkeys); + } + } + } catch { + // Ignore corrupt storage + } + } + + private save(): void { + if (typeof localStorage === 'undefined') return; + const data: Record = {}; + for (const [id, hotkeys] of this.overrides) { + data[id] = hotkeys; + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } + + private notify(): void { + for (const listener of this.listeners) { + listener(); + } + } + + /** + * Get the user override for an action, if any. + * Returns `[]` if the user explicitly unbound it. + * Returns `undefined` if there is no override. + */ + getOverride(actionId: string): Hotkey[] | undefined { + return this.overrides.get(actionId); + } + + /** + * Set the full override array for an action. + * Pass `[]` to explicitly unbind. + */ + setOverride(actionId: string, hotkeys: Hotkey[]): void { + this.overrides.set(actionId, hotkeys); + this.save(); + this.notify(); + } + + /** Remove any override for an action (revert to defaults). */ + removeOverride(actionId: string): void { + this.overrides.delete(actionId); + this.save(); + this.notify(); + } + + /** + * Get all effective hotkeys for an action. + * Checks user override first, then falls back to the action's defaults. + */ + getEffectiveHotkeys(action: RegisteredAction): Hotkey[] { + const override = this.overrides.get(action.id); + if (override !== undefined) return override; + return action.hotkeys ?? []; + } + + /** Subscribe to hotkey override changes. Returns an unsubscribe function. */ + onChange(callback: () => void): () => void { + this.listeners.add(callback); + return () => { + this.listeners.delete(callback); + }; + } + + /** + * Find hotkey conflicts among all registered actions using effective hotkeys. + * Returns a map of normalized hotkey string -> array of conflicting action IDs. + */ + getConflicts(registry: ActionRegistry, isMac: boolean): Map { + const bindings = new Map(); + + for (const action of registry.getAll()) { + for (const hotkey of this.getEffectiveHotkeys(action)) { + const key = hotkeyToString(normalizeHotkey(hotkey, isMac)); + const existing = bindings.get(key) ?? []; + existing.push(action.id); + bindings.set(key, existing); + } + } + + const conflicts = new Map(); + for (const [key, actionIds] of bindings) { + if (actionIds.length > 1) { + conflicts.set(key, actionIds); + } + } + return conflicts; + } +} diff --git a/src/actions/index.ts b/src/actions/index.ts index b192977..32ff345 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -8,6 +8,7 @@ export type { export { ActionRegistry } from './registry'; export { HotkeyManager, type HotkeyConflict } from './hotkey-manager'; +export { HotkeyStore } from './hotkeyStore'; export { normalizeHotkey, hotkeyToString, @@ -18,7 +19,12 @@ export { } from './hotkey-utils'; export { CommandPalette } from './CommandPalette'; export { ActionProvider } from './ActionProvider'; -export { ActionContext, useActions, useActionRegistry } from './ActionContext'; +export { + ActionContext, + useActions, + useActionRegistry, + useHotkeyStore, +} from './ActionContext'; export { createActionServices } from './services'; export { SidebarActionSet } from './sets/sidebarActionSet'; export { SettingsActionSet } from './sets/settingsActionSet'; diff --git a/src/actions/services.ts b/src/actions/services.ts index d728189..0ea20a6 100644 --- a/src/actions/services.ts +++ b/src/actions/services.ts @@ -1,5 +1,6 @@ import { ActionRegistry } from './registry'; import { HotkeyManager } from './hotkey-manager'; +import { HotkeyStore } from './hotkeyStore'; /** * Create the core action system services. @@ -11,6 +12,7 @@ import { HotkeyManager } from './hotkey-manager'; */ export function createActionServices(isMac: boolean) { const registry = new ActionRegistry(); + const hotkeyStore = new HotkeyStore(); const hotkeyManager = new HotkeyManager( registry, @@ -20,5 +22,5 @@ export function createActionServices(isMac: boolean) { }, ); - return { registry, hotkeyManager }; + return { registry, hotkeyManager, hotkeyStore }; } diff --git a/src/components/HotkeysPane.tsx b/src/components/HotkeysPane.tsx new file mode 100644 index 0000000..20f72d7 --- /dev/null +++ b/src/components/HotkeysPane.tsx @@ -0,0 +1,323 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useActionRegistry, + useHotkeyStore, +} from '../actions/ActionContext'; +import { + formatHotkeyForDisplay, + eventToHotkey, + normalizeHotkey, + hotkeyToString, +} from '../actions/hotkey-utils'; +import { getRendererPlatform } from '../lib/platform'; +import { PlusIcon, XIcon, SearchIcon } from '../icons'; +import type { RegisteredAction, Hotkey } from '../actions/types'; + +/** Parse editingKey into action ID and slot info. */ +function parseEditingKey( + key: string | null, +): { actionId: string | null; slot: number | 'new' | null } { + if (!key) return { actionId: null, slot: null }; + const colonIdx = key.lastIndexOf(':'); + if (colonIdx < 0) return { actionId: null, slot: null }; + const actionId = key.slice(0, colonIdx); + const slotRaw = key.slice(colonIdx + 1); + const slot = slotRaw === 'new' ? 'new' : parseInt(slotRaw, 10); + return { actionId, slot }; +} + +export function HotkeysPane() { + const registry = useActionRegistry(); + const hotkeyStore = useHotkeyStore(); + const isMac = getRendererPlatform() === 'darwin'; + + const [editingKey, setEditingKey] = useState(null); + const [captured, setCaptured] = useState(null); + const [filter, setFilter] = useState(''); + + // Refs so the persistent keydown listener always sees latest state + const editingKeyRef = useRef(editingKey); + editingKeyRef.current = editingKey; + const capturedRef = useRef(captured); + capturedRef.current = captured; + + // Force re-render when overrides change so badges and conflicts update + const [version, setVersion] = useState(0); + useEffect(() => { + return hotkeyStore.onChange(() => setVersion((v) => v + 1)); + }, [hotkeyStore]); + + // Reset captured combo whenever the user switches to a different editing target + useEffect(() => { + setCaptured(null); + }, [editingKey]); + + const allActions = useMemo(() => { + const actions = registry.getAll(); + // Sort by source then name for stable ordering + actions.sort((a, b) => { + if (a.source !== b.source) return a.source.localeCompare(b.source); + return a.name.localeCompare(b.name); + }); + return actions; + }, [registry]); + + const filteredActions = useMemo(() => { + if (!filter.trim()) return allActions; + const q = filter.toLowerCase(); + return allActions.filter( + (a) => + a.name.toLowerCase().includes(q) || + (a.description ?? '').toLowerCase().includes(q), + ); + }, [allActions, filter]); + + /** Whether the currently captured hotkey conflicts with another action's binding. */ + const isCapturedConflicting = useMemo(() => { + if (!captured || !editingKey) return false; + const { actionId: editingActionId } = parseEditingKey(editingKey); + const capturedStr = hotkeyToString(normalizeHotkey(captured, isMac)); + for (const action of allActions) { + if (action.id === editingActionId) continue; // same-action duplicates are handled separately + for (const hk of hotkeyStore.getEffectiveHotkeys(action)) { + if (hotkeyToString(normalizeHotkey(hk, isMac)) === capturedStr) { + return true; + } + } + } + return false; + }, [captured, editingKey, allActions, hotkeyStore, isMac, version]); + + /** Live conflicts including the currently captured (but not yet confirmed) hotkey. */ + const liveConflicts = useMemo(() => { + const base = hotkeyStore.getConflicts(registry, isMac); + if (!captured || !editingKey) return base; + + const { actionId: editingActionId } = parseEditingKey(editingKey); + const capturedStr = hotkeyToString(normalizeHotkey(captured, isMac)); + const result = new Map(base); + + for (const action of allActions) { + if (action.id === editingActionId) continue; + for (const hk of hotkeyStore.getEffectiveHotkeys(action)) { + const hkStr = hotkeyToString(normalizeHotkey(hk, isMac)); + if (hkStr === capturedStr) { + const existing = result.get(capturedStr) ?? []; + if (!existing.includes(action.id)) { + result.set(capturedStr, [...existing, action.id]); + } + } + } + } + return result; + }, [hotkeyStore, registry, isMac, captured, editingKey, allActions, version]); + + const finishEdit = useCallback( + (actionId: string, nextHotkeys: Hotkey[]) => { + hotkeyStore.setOverride(actionId, nextHotkeys); + setEditingKey(null); + setCaptured(null); + }, + [hotkeyStore], + ); + + // One persistent document-level keydown listener. + // Uses refs to avoid effect re-runs on every state change. + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const currentEditing = editingKeyRef.current; + if (!currentEditing) return; + + e.preventDefault(); + e.stopPropagation(); + + const { actionId, slot } = parseEditingKey(currentEditing); + if (!actionId || slot === null) return; + + const currentCaptured = capturedRef.current; + const action = registry.get(actionId); + if (!action) return; + + const base = hotkeyStore.getEffectiveHotkeys(action); + + if (e.key === 'Escape') { + setEditingKey(null); + setCaptured(null); + return; + } + + if (e.key === 'Enter') { + const next = [...base]; + if (currentCaptured) { + if (slot === 'new') { + next.push(currentCaptured); + } else if (typeof slot === 'number') { + next[slot] = currentCaptured; + } + } + // Deduplicate within this action + const seen = new Set(); + const deduped = next.filter((hk) => { + const key = hotkeyToString(normalizeHotkey(hk, isMac)); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + finishEdit(actionId, deduped); + return; + } + + if (e.key === 'Delete' || e.key === 'Backspace') { + const next = [...base]; + if (typeof slot === 'number') { + next.splice(slot, 1); + } + finishEdit(actionId, next); + return; + } + + // Skip bare modifier keys + if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return; + + setCaptured(eventToHotkey(e)); + }; + + document.addEventListener('keydown', handleKeyDown, true); + return () => document.removeEventListener('keydown', handleKeyDown, true); + }, [registry, hotkeyStore, finishEdit]); + + const removeAt = (action: RegisteredAction, index: number) => { + const next = [...hotkeyStore.getEffectiveHotkeys(action)]; + next.splice(index, 1); + hotkeyStore.setOverride(action.id, next); + }; + + return ( +
+ {/* Conflict banner */} + {liveConflicts.size > 0 ? ( +
+

Conflicting hotkeys

+
    + {Array.from(liveConflicts.entries()).map(([key, actionIds]) => ( +
  • + {key}: {actionIds.join(', ')} +
  • + ))} +
+
+ ) : null} + + {/* Filter bar */} +
+ + setFilter(e.target.value)} + placeholder="Filter by name or description…" + className="border-border bg-foreground/5 text-foreground placeholder:text-foreground/30 w-full rounded-md border py-1.5 pl-8 pr-3 text-sm outline-none focus:border-accent focus:ring-1 focus:ring-accent" + /> +
+ + {/* Table — scrollable body */} +
+ {filteredActions.map((action: RegisteredAction) => { + const effective = hotkeyStore.getEffectiveHotkeys(action); + const { actionId: editingActionId, slot } = parseEditingKey( + editingKey, + ); + const isEditingAction = editingActionId === action.id; + + return ( +
+
+ + {action.name} + + {action.description ? ( +

+ {action.description} +

+ ) : null} +
+
+
+ {effective.map((hk, index) => { + const isEditing = isEditingAction && slot === index; + return ( + + {isEditing ? ( + + {captured + ? formatHotkeyForDisplay(captured, isMac) + : 'Press keys…'} + + ) : ( + <> + + + + )} + + ); + })} +
+
+ {isEditingAction && slot === 'new' ? ( + + {captured + ? formatHotkeyForDisplay(captured, isMac) + : 'Press keys…'} + + ) : null} + +
+
+
+ ); + })} + {filteredActions.length === 0 ? ( +
+ No commands match your filter +
+ ) : null} +
+
+ ); +} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index fe991c3..2109057 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -7,6 +7,7 @@ import { } from '../workspaces/workspace'; import { Modal } from './Modal'; import { SettingsPaneLayout, SettingsPaneSection } from './SettingsPane'; +import { HotkeysPane } from './HotkeysPane'; type SettingsModalProps = { open: boolean; @@ -17,7 +18,7 @@ type SettingsModalProps = { onWorkspaceKindChange: (kind: WorkspaceKind) => void; }; -type SettingsCategory = 'atmosphere' | 'appearance' | 'subtext'; +type SettingsCategory = 'atmosphere' | 'appearance' | 'hotkeys' | 'subtext'; function AtmosphereAccountPane() { const { authInfo, login, logout } = useHabitatAuth(); @@ -171,6 +172,24 @@ function MoonIcon() { +function KeyboardIcon() { + return ( + + + + + ); +} + function SubtextSettingsPane() { const [enabled, setEnabled] = useState(() => { if (typeof localStorage === 'undefined') return false; @@ -244,6 +263,11 @@ export function SettingsModal({ label: 'Appearance', icon: , }, + { + id: 'hotkeys', + label: 'Hotkeys', + icon: , + }, { id: 'subtext', label: 'Subtext', @@ -282,6 +306,8 @@ export function SettingsModal({ > {selectedCategory === 'atmosphere' ? ( + ) : selectedCategory === 'hotkeys' ? ( + ) : selectedCategory === 'subtext' ? ( ) : ( diff --git a/src/components/SettingsPane.tsx b/src/components/SettingsPane.tsx index da1d219..f7aedfe 100644 --- a/src/components/SettingsPane.tsx +++ b/src/components/SettingsPane.tsx @@ -19,7 +19,7 @@ export function SettingsPaneLayout({ -
{children}
+
{children}
); } diff --git a/src/icons/index.tsx b/src/icons/index.tsx index c1f54b1..56a609b 100644 --- a/src/icons/index.tsx +++ b/src/icons/index.tsx @@ -67,3 +67,58 @@ export function SubtextIcon({ className }: IconProps) { ); } + +/** Plus icon for "add" actions. */ +export function PlusIcon({ className }: IconProps) { + return ( + + + + ); +} + +/** X icon for "remove" / "close" actions. */ +export function XIcon({ className }: IconProps) { + return ( + + + + ); +} + +/** Search / magnifying-glass icon. */ +export function SearchIcon({ className }: IconProps) { + return ( + + + + + ); +} diff --git a/src/main.ts b/src/main.ts index c1deb2d..b52e6e5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -93,7 +93,8 @@ function installApplicationMenu() { submenu: [ { label: 'Settings…', - accelerator: 'CommandOrControl+,', + // No accelerator here — hotkeys are managed by the renderer's + // ActionRegistry + HotkeyManager so users can customize bindings. click: () => { const target = BrowserWindow.getFocusedWindow() ?? -- 2.51.2