A local-first note taking app
0

Configure Feed

Select the types of activity you want to include in your feed.

Hotkeys

Ethan Graf (Jun 1, 2026, 10:07 PM EDT) 6ccb8078 4aff66da

+610 -25
+6
.opencode/skills/textile-design/components.md
··· 31 31 - `aria-label` describing action; `aria-pressed` if toggle 32 32 - SVG decorative: `aria-hidden` 33 33 34 + ## Icons 35 + 36 + **Reference:** `src/icons/index.tsx` 37 + 38 + 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). 39 + 34 40 ## Primary / secondary buttons 35 41 36 42 **Primary CTA:** `bg-primary text-primary-foreground`, adequate padding (`px-3 py-2`), optional `hover:opacity-90`, `disabled:opacity-50`.
+2 -1
src/App.tsx
··· 83 83 const isMac = getRendererPlatform() === 'darwin'; 84 84 85 85 // Core action-system services — created once at app start and passed down. 86 - const { registry, hotkeyManager } = useMemo( 86 + const { registry, hotkeyManager, hotkeyStore } = useMemo( 87 87 () => createActionServices(isMac), 88 88 [isMac], 89 89 ); ··· 171 171 actionSets={actionSets} 172 172 registry={registry} 173 173 hotkeyManager={hotkeyManager} 174 + hotkeyStore={hotkeyStore} 174 175 > 175 176 <div className="bg-background text-foreground flex h-svh w-full flex-col overflow-hidden"> 176 177 <AppTitleBar
+14 -3
src/actions/ActionContext.ts
··· 1 1 import { createContext, useContext } from 'react'; 2 2 import type { ActionRegistry } from './registry'; 3 + import type { HotkeyStore } from './hotkeyStore'; 3 4 4 5 /** 5 - * React context providing the action registry. 6 - * Used by components that need to register, search, or execute actions. 6 + * React context providing the action registry and hotkey store. 7 + * Used by components that need to register, search, execute, or edit actions. 7 8 */ 8 9 export interface ActionProviderValue { 9 10 /** The action registry for registering and looking up actions */ 10 11 registry: ActionRegistry; 12 + /** The hotkey store for reading and editing user overrides */ 13 + hotkeyStore: HotkeyStore; 11 14 } 12 15 13 16 export const ActionContext = createContext<ActionProviderValue | null>(null); 14 17 15 18 /** 16 - * Hook to access the action registry. 19 + * Hook to access the action registry and hotkey store. 17 20 * Must be used within an ActionProvider. 18 21 */ 19 22 export function useActions(): ActionProviderValue { ··· 31 34 export function useActionRegistry(): ActionRegistry { 32 35 return useActions().registry; 33 36 } 37 + 38 + /** 39 + * Hook to access the hotkey store. 40 + * Must be used within an ActionProvider. 41 + */ 42 + export function useHotkeyStore(): HotkeyStore { 43 + return useActions().hotkeyStore; 44 + }
+40 -13
src/actions/ActionProvider.tsx
··· 8 8 import type { ActionSet, RegisteredAction } from './types'; 9 9 import type { ActionRegistry } from './registry'; 10 10 import type { HotkeyManager } from './hotkey-manager'; 11 + import type { HotkeyStore } from './hotkeyStore'; 11 12 import { ActionContext } from './ActionContext'; 12 13 import { CommandPalette } from './CommandPalette'; 13 14 import { getRendererPlatform } from '../lib/platform'; ··· 20 21 registry: ActionRegistry; 21 22 /** The hotkey manager (created and owned by AppShell). */ 22 23 hotkeyManager: HotkeyManager; 24 + /** The hotkey store (created and owned by AppShell). */ 25 + hotkeyStore: HotkeyStore; 23 26 } 24 27 25 28 /** ··· 34 37 actionSets, 35 38 registry, 36 39 hotkeyManager, 40 + hotkeyStore, 37 41 }: ActionProviderProps) { 38 42 const isMac = getRendererPlatform() === 'darwin'; 39 43 ··· 60 64 [actionSets], 61 65 ); 62 66 63 - // Register/unregister all actions from all sets 64 - useEffect(() => { 67 + /** 68 + * Register all actions from all sets and wire their effective hotkeys. 69 + */ 70 + const registerAll = useCallback(() => { 65 71 for (const set of allActionSets) { 66 72 for (const def of set.getActions()) { 67 73 const registered = registry.register(set.id, def); 68 - if (registered.hotkeys) { 69 - for (const hotkey of registered.hotkeys) { 70 - hotkeyManager.registerHotkey(registered.id, hotkey); 71 - } 74 + for (const hotkey of hotkeyStore.getEffectiveHotkeys(registered)) { 75 + hotkeyManager.registerHotkey(registered.id, hotkey); 72 76 } 73 77 } 74 78 } 79 + }, [allActionSets, registry, hotkeyManager, hotkeyStore]); 75 80 76 - return () => { 77 - for (const set of allActionSets) { 78 - registry.unregisterAll(set.id); 79 - hotkeyManager.removeSourceHotkeys(set.id); 80 - } 81 - }; 81 + /** 82 + * Unregister all actions and clear their hotkeys. 83 + */ 84 + const unregisterAll = useCallback(() => { 85 + for (const set of allActionSets) { 86 + registry.unregisterAll(set.id); 87 + hotkeyManager.removeSourceHotkeys(set.id); 88 + } 82 89 }, [allActionSets, registry, hotkeyManager]); 83 90 91 + // Register on mount / when action sets change. 92 + // Re-register hotkeys when the user edits an override. 93 + useEffect(() => { 94 + registerAll(); 95 + return unregisterAll; 96 + }, [registerAll, unregisterAll]); 97 + 98 + // Re-register hotkeys immediately when the user edits an override, 99 + // without waiting for a React state cycle. 100 + useEffect(() => { 101 + return hotkeyStore.onChange(() => { 102 + unregisterAll(); 103 + registerAll(); 104 + }); 105 + }, [hotkeyStore, registerAll, unregisterAll]); 106 + 84 107 const handlePaletteExecute = useCallback( 85 108 (action: RegisteredAction) => { 86 109 void registry.execute(action.id); ··· 93 116 [registry, paletteOpen], 94 117 ); 95 118 96 - const value = useMemo(() => ({ registry }), [registry]); 119 + const value = useMemo( 120 + () => ({ registry, hotkeyStore }), 121 + [registry, hotkeyStore], 122 + ); 97 123 98 124 return ( 99 125 <ActionContext.Provider value={value}> ··· 103 129 onClose={() => setPaletteOpen(false)} 104 130 onExecute={handlePaletteExecute} 105 131 actions={availableActions} 132 + hotkeyStore={hotkeyStore} 106 133 isMac={isMac} 107 134 /> 108 135 </ActionContext.Provider>
+8 -3
src/actions/CommandPalette.tsx
··· 7 7 type KeyboardEvent, 8 8 } from 'react'; 9 9 import type { RegisteredAction } from './types'; 10 + import type { HotkeyStore } from './hotkeyStore'; 10 11 import { formatHotkeyForDisplay } from './hotkey-utils'; 11 12 12 13 interface CommandPaletteProps { ··· 18 19 onExecute: (action: RegisteredAction) => void; 19 20 /** All available actions */ 20 21 actions: RegisteredAction[]; 22 + /** Hotkey store for resolving effective hotkeys */ 23 + hotkeyStore: HotkeyStore; 21 24 /** Whether the platform is macOS (affects hotkey display) */ 22 25 isMac: boolean; 23 26 /** Optional placeholder text */ ··· 38 41 onClose, 39 42 onExecute, 40 43 actions, 44 + hotkeyStore, 41 45 isMac, 42 46 placeholder = 'Type a command...', 43 47 }: CommandPaletteProps) { ··· 177 181 <div role="listbox" id={listId}> 178 182 {filteredActions.map((action, index) => { 179 183 const isSelected = index === selectedIndex; 180 - const hotkeyHint = action.hotkeys?.[0] 181 - ? formatHotkeyForDisplay(action.hotkeys[0], isMac) 184 + const effectiveHotkeys = hotkeyStore.getEffectiveHotkeys(action); 185 + const hotkeyHint = effectiveHotkeys[0] 186 + ? formatHotkeyForDisplay(effectiveHotkeys[0], isMac) 182 187 : undefined; 183 188 184 189 return ( ··· 198 203 > 199 204 <span className="flex-1 truncate">{action.name}</span> 200 205 {hotkeyHint ? ( 201 - <kbd className="border-border text-foreground/60 shrink-0 rounded border px-1.5 py-0.5 text-xs font-mono"> 206 + <kbd className="border-border text-foreground/60 shrink-0 rounded border px-1.5 py-0.5 text-xs"> 202 207 {hotkeyHint} 203 208 </kbd> 204 209 ) : null}
+122
src/actions/hotkeyStore.ts
··· 1 + import type { Hotkey, RegisteredAction } from './types'; 2 + import type { ActionRegistry } from './registry'; 3 + import { normalizeHotkey, hotkeyToString } from './hotkey-utils'; 4 + 5 + const STORAGE_KEY = 'textile.hotkeys.v2'; 6 + 7 + /** 8 + * Persists user hotkey overrides and resolves effective hotkeys. 9 + * 10 + * Each action has default hotkeys from its ActionSet (an array). Users can 11 + * replace the entire array, clear it (unbind), or revert to defaults. 12 + * Overrides are stored in localStorage and survive app restarts. 13 + */ 14 + export class HotkeyStore { 15 + /** Map of action full ID -> override array ([] = explicitly unbound) */ 16 + private overrides = new Map<string, Hotkey[]>(); 17 + private listeners = new Set<() => void>(); 18 + 19 + constructor() { 20 + this.load(); 21 + } 22 + 23 + private load(): void { 24 + if (typeof localStorage === 'undefined') return; 25 + try { 26 + const raw = localStorage.getItem(STORAGE_KEY); 27 + if (!raw) return; 28 + const data = JSON.parse(raw) as Record<string, Hotkey[]>; 29 + for (const [id, hotkeys] of Object.entries(data)) { 30 + if (Array.isArray(hotkeys)) { 31 + this.overrides.set(id, hotkeys); 32 + } 33 + } 34 + } catch { 35 + // Ignore corrupt storage 36 + } 37 + } 38 + 39 + private save(): void { 40 + if (typeof localStorage === 'undefined') return; 41 + const data: Record<string, Hotkey[]> = {}; 42 + for (const [id, hotkeys] of this.overrides) { 43 + data[id] = hotkeys; 44 + } 45 + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); 46 + } 47 + 48 + private notify(): void { 49 + for (const listener of this.listeners) { 50 + listener(); 51 + } 52 + } 53 + 54 + /** 55 + * Get the user override for an action, if any. 56 + * Returns `[]` if the user explicitly unbound it. 57 + * Returns `undefined` if there is no override. 58 + */ 59 + getOverride(actionId: string): Hotkey[] | undefined { 60 + return this.overrides.get(actionId); 61 + } 62 + 63 + /** 64 + * Set the full override array for an action. 65 + * Pass `[]` to explicitly unbind. 66 + */ 67 + setOverride(actionId: string, hotkeys: Hotkey[]): void { 68 + this.overrides.set(actionId, hotkeys); 69 + this.save(); 70 + this.notify(); 71 + } 72 + 73 + /** Remove any override for an action (revert to defaults). */ 74 + removeOverride(actionId: string): void { 75 + this.overrides.delete(actionId); 76 + this.save(); 77 + this.notify(); 78 + } 79 + 80 + /** 81 + * Get all effective hotkeys for an action. 82 + * Checks user override first, then falls back to the action's defaults. 83 + */ 84 + getEffectiveHotkeys(action: RegisteredAction): Hotkey[] { 85 + const override = this.overrides.get(action.id); 86 + if (override !== undefined) return override; 87 + return action.hotkeys ?? []; 88 + } 89 + 90 + /** Subscribe to hotkey override changes. Returns an unsubscribe function. */ 91 + onChange(callback: () => void): () => void { 92 + this.listeners.add(callback); 93 + return () => { 94 + this.listeners.delete(callback); 95 + }; 96 + } 97 + 98 + /** 99 + * Find hotkey conflicts among all registered actions using effective hotkeys. 100 + * Returns a map of normalized hotkey string -> array of conflicting action IDs. 101 + */ 102 + getConflicts(registry: ActionRegistry, isMac: boolean): Map<string, string[]> { 103 + const bindings = new Map<string, string[]>(); 104 + 105 + for (const action of registry.getAll()) { 106 + for (const hotkey of this.getEffectiveHotkeys(action)) { 107 + const key = hotkeyToString(normalizeHotkey(hotkey, isMac)); 108 + const existing = bindings.get(key) ?? []; 109 + existing.push(action.id); 110 + bindings.set(key, existing); 111 + } 112 + } 113 + 114 + const conflicts = new Map<string, string[]>(); 115 + for (const [key, actionIds] of bindings) { 116 + if (actionIds.length > 1) { 117 + conflicts.set(key, actionIds); 118 + } 119 + } 120 + return conflicts; 121 + } 122 + }
+7 -1
src/actions/index.ts
··· 8 8 9 9 export { ActionRegistry } from './registry'; 10 10 export { HotkeyManager, type HotkeyConflict } from './hotkey-manager'; 11 + export { HotkeyStore } from './hotkeyStore'; 11 12 export { 12 13 normalizeHotkey, 13 14 hotkeyToString, ··· 18 19 } from './hotkey-utils'; 19 20 export { CommandPalette } from './CommandPalette'; 20 21 export { ActionProvider } from './ActionProvider'; 21 - export { ActionContext, useActions, useActionRegistry } from './ActionContext'; 22 + export { 23 + ActionContext, 24 + useActions, 25 + useActionRegistry, 26 + useHotkeyStore, 27 + } from './ActionContext'; 22 28 export { createActionServices } from './services'; 23 29 export { SidebarActionSet } from './sets/sidebarActionSet'; 24 30 export { SettingsActionSet } from './sets/settingsActionSet';
+3 -1
src/actions/services.ts
··· 1 1 import { ActionRegistry } from './registry'; 2 2 import { HotkeyManager } from './hotkey-manager'; 3 + import { HotkeyStore } from './hotkeyStore'; 3 4 4 5 /** 5 6 * Create the core action system services. ··· 11 12 */ 12 13 export function createActionServices(isMac: boolean) { 13 14 const registry = new ActionRegistry(); 15 + const hotkeyStore = new HotkeyStore(); 14 16 15 17 const hotkeyManager = new HotkeyManager( 16 18 registry, ··· 20 22 }, 21 23 ); 22 24 23 - return { registry, hotkeyManager }; 25 + return { registry, hotkeyManager, hotkeyStore }; 24 26 }
+323
src/components/HotkeysPane.tsx
··· 1 + import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 2 + import { 3 + useActionRegistry, 4 + useHotkeyStore, 5 + } from '../actions/ActionContext'; 6 + import { 7 + formatHotkeyForDisplay, 8 + eventToHotkey, 9 + normalizeHotkey, 10 + hotkeyToString, 11 + } from '../actions/hotkey-utils'; 12 + import { getRendererPlatform } from '../lib/platform'; 13 + import { PlusIcon, XIcon, SearchIcon } from '../icons'; 14 + import type { RegisteredAction, Hotkey } from '../actions/types'; 15 + 16 + /** Parse editingKey into action ID and slot info. */ 17 + function parseEditingKey( 18 + key: string | null, 19 + ): { actionId: string | null; slot: number | 'new' | null } { 20 + if (!key) return { actionId: null, slot: null }; 21 + const colonIdx = key.lastIndexOf(':'); 22 + if (colonIdx < 0) return { actionId: null, slot: null }; 23 + const actionId = key.slice(0, colonIdx); 24 + const slotRaw = key.slice(colonIdx + 1); 25 + const slot = slotRaw === 'new' ? 'new' : parseInt(slotRaw, 10); 26 + return { actionId, slot }; 27 + } 28 + 29 + export function HotkeysPane() { 30 + const registry = useActionRegistry(); 31 + const hotkeyStore = useHotkeyStore(); 32 + const isMac = getRendererPlatform() === 'darwin'; 33 + 34 + const [editingKey, setEditingKey] = useState<string | null>(null); 35 + const [captured, setCaptured] = useState<Hotkey | null>(null); 36 + const [filter, setFilter] = useState(''); 37 + 38 + // Refs so the persistent keydown listener always sees latest state 39 + const editingKeyRef = useRef(editingKey); 40 + editingKeyRef.current = editingKey; 41 + const capturedRef = useRef(captured); 42 + capturedRef.current = captured; 43 + 44 + // Force re-render when overrides change so badges and conflicts update 45 + const [version, setVersion] = useState(0); 46 + useEffect(() => { 47 + return hotkeyStore.onChange(() => setVersion((v) => v + 1)); 48 + }, [hotkeyStore]); 49 + 50 + // Reset captured combo whenever the user switches to a different editing target 51 + useEffect(() => { 52 + setCaptured(null); 53 + }, [editingKey]); 54 + 55 + const allActions = useMemo(() => { 56 + const actions = registry.getAll(); 57 + // Sort by source then name for stable ordering 58 + actions.sort((a, b) => { 59 + if (a.source !== b.source) return a.source.localeCompare(b.source); 60 + return a.name.localeCompare(b.name); 61 + }); 62 + return actions; 63 + }, [registry]); 64 + 65 + const filteredActions = useMemo(() => { 66 + if (!filter.trim()) return allActions; 67 + const q = filter.toLowerCase(); 68 + return allActions.filter( 69 + (a) => 70 + a.name.toLowerCase().includes(q) || 71 + (a.description ?? '').toLowerCase().includes(q), 72 + ); 73 + }, [allActions, filter]); 74 + 75 + /** Whether the currently captured hotkey conflicts with another action's binding. */ 76 + const isCapturedConflicting = useMemo(() => { 77 + if (!captured || !editingKey) return false; 78 + const { actionId: editingActionId } = parseEditingKey(editingKey); 79 + const capturedStr = hotkeyToString(normalizeHotkey(captured, isMac)); 80 + for (const action of allActions) { 81 + if (action.id === editingActionId) continue; // same-action duplicates are handled separately 82 + for (const hk of hotkeyStore.getEffectiveHotkeys(action)) { 83 + if (hotkeyToString(normalizeHotkey(hk, isMac)) === capturedStr) { 84 + return true; 85 + } 86 + } 87 + } 88 + return false; 89 + }, [captured, editingKey, allActions, hotkeyStore, isMac, version]); 90 + 91 + /** Live conflicts including the currently captured (but not yet confirmed) hotkey. */ 92 + const liveConflicts = useMemo(() => { 93 + const base = hotkeyStore.getConflicts(registry, isMac); 94 + if (!captured || !editingKey) return base; 95 + 96 + const { actionId: editingActionId } = parseEditingKey(editingKey); 97 + const capturedStr = hotkeyToString(normalizeHotkey(captured, isMac)); 98 + const result = new Map(base); 99 + 100 + for (const action of allActions) { 101 + if (action.id === editingActionId) continue; 102 + for (const hk of hotkeyStore.getEffectiveHotkeys(action)) { 103 + const hkStr = hotkeyToString(normalizeHotkey(hk, isMac)); 104 + if (hkStr === capturedStr) { 105 + const existing = result.get(capturedStr) ?? []; 106 + if (!existing.includes(action.id)) { 107 + result.set(capturedStr, [...existing, action.id]); 108 + } 109 + } 110 + } 111 + } 112 + return result; 113 + }, [hotkeyStore, registry, isMac, captured, editingKey, allActions, version]); 114 + 115 + const finishEdit = useCallback( 116 + (actionId: string, nextHotkeys: Hotkey[]) => { 117 + hotkeyStore.setOverride(actionId, nextHotkeys); 118 + setEditingKey(null); 119 + setCaptured(null); 120 + }, 121 + [hotkeyStore], 122 + ); 123 + 124 + // One persistent document-level keydown listener. 125 + // Uses refs to avoid effect re-runs on every state change. 126 + useEffect(() => { 127 + const handleKeyDown = (e: KeyboardEvent) => { 128 + const currentEditing = editingKeyRef.current; 129 + if (!currentEditing) return; 130 + 131 + e.preventDefault(); 132 + e.stopPropagation(); 133 + 134 + const { actionId, slot } = parseEditingKey(currentEditing); 135 + if (!actionId || slot === null) return; 136 + 137 + const currentCaptured = capturedRef.current; 138 + const action = registry.get(actionId); 139 + if (!action) return; 140 + 141 + const base = hotkeyStore.getEffectiveHotkeys(action); 142 + 143 + if (e.key === 'Escape') { 144 + setEditingKey(null); 145 + setCaptured(null); 146 + return; 147 + } 148 + 149 + if (e.key === 'Enter') { 150 + const next = [...base]; 151 + if (currentCaptured) { 152 + if (slot === 'new') { 153 + next.push(currentCaptured); 154 + } else if (typeof slot === 'number') { 155 + next[slot] = currentCaptured; 156 + } 157 + } 158 + // Deduplicate within this action 159 + const seen = new Set<string>(); 160 + const deduped = next.filter((hk) => { 161 + const key = hotkeyToString(normalizeHotkey(hk, isMac)); 162 + if (seen.has(key)) return false; 163 + seen.add(key); 164 + return true; 165 + }); 166 + finishEdit(actionId, deduped); 167 + return; 168 + } 169 + 170 + if (e.key === 'Delete' || e.key === 'Backspace') { 171 + const next = [...base]; 172 + if (typeof slot === 'number') { 173 + next.splice(slot, 1); 174 + } 175 + finishEdit(actionId, next); 176 + return; 177 + } 178 + 179 + // Skip bare modifier keys 180 + if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) return; 181 + 182 + setCaptured(eventToHotkey(e)); 183 + }; 184 + 185 + document.addEventListener('keydown', handleKeyDown, true); 186 + return () => document.removeEventListener('keydown', handleKeyDown, true); 187 + }, [registry, hotkeyStore, finishEdit]); 188 + 189 + const removeAt = (action: RegisteredAction, index: number) => { 190 + const next = [...hotkeyStore.getEffectiveHotkeys(action)]; 191 + next.splice(index, 1); 192 + hotkeyStore.setOverride(action.id, next); 193 + }; 194 + 195 + return ( 196 + <div className="flex flex-1 min-h-0 flex-col -mx-5 px-5 -my-5 py-5"> 197 + {/* Conflict banner */} 198 + {liveConflicts.size > 0 ? ( 199 + <div className="bg-warning-bg border-warning mb-3 rounded-md border p-2.5 text-sm"> 200 + <p className="text-warning font-medium">Conflicting hotkeys</p> 201 + <ul className="text-warning mt-1 list-inside list-disc text-xs"> 202 + {Array.from(liveConflicts.entries()).map(([key, actionIds]) => ( 203 + <li key={key}> 204 + {key}: {actionIds.join(', ')} 205 + </li> 206 + ))} 207 + </ul> 208 + </div> 209 + ) : null} 210 + 211 + {/* Filter bar */} 212 + <div className="relative mb-3"> 213 + <SearchIcon className="text-foreground/40 pointer-events-none absolute left-2 top-1/2 size-4 -translate-y-1/2" /> 214 + <input 215 + type="text" 216 + value={filter} 217 + onChange={(e) => setFilter(e.target.value)} 218 + placeholder="Filter by name or description…" 219 + 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" 220 + /> 221 + </div> 222 + 223 + {/* Table — scrollable body */} 224 + <div className="border-border divide-border min-h-0 flex-1 divide-y overflow-y-auto rounded-md border"> 225 + {filteredActions.map((action: RegisteredAction) => { 226 + const effective = hotkeyStore.getEffectiveHotkeys(action); 227 + const { actionId: editingActionId, slot } = parseEditingKey( 228 + editingKey, 229 + ); 230 + const isEditingAction = editingActionId === action.id; 231 + 232 + return ( 233 + <div 234 + key={action.id} 235 + className="flex items-start overflow-hidden px-3 py-1.5" 236 + > 237 + <div className="min-w-[40%] flex-1 overflow-hidden pr-4"> 238 + <span className="text-foreground text-xs block truncate leading-snug"> 239 + {action.name} 240 + </span> 241 + {action.description ? ( 242 + <p className="text-foreground/50 truncate text-[11px] leading-snug"> 243 + {action.description} 244 + </p> 245 + ) : null} 246 + </div> 247 + <div className="flex min-w-0 flex-1 items-center justify-end overflow-hidden"> 248 + <div className="flex min-w-0 flex-wrap items-center gap-y-1 justify-end"> 249 + {effective.map((hk, index) => { 250 + const isEditing = isEditingAction && slot === index; 251 + return ( 252 + <span 253 + key={`${action.id}-${index}`} 254 + className="inline-flex items-center gap-1 ml-1.5 rounded border border-border bg-foreground/5 px-1 py-0.5" 255 + > 256 + {isEditing ? ( 257 + <span 258 + className={`shrink-0 animate-pulse text-xs ${isCapturedConflicting ? 'text-destructive' : 'text-accent'}`} 259 + > 260 + {captured 261 + ? formatHotkeyForDisplay(captured, isMac) 262 + : 'Press keys…'} 263 + </span> 264 + ) : ( 265 + <> 266 + <button 267 + type="button" 268 + onClick={() => 269 + setEditingKey(`${action.id}:${index}`) 270 + } 271 + className="text-foreground/80 hover:text-foreground shrink-0 text-xs" 272 + > 273 + {formatHotkeyForDisplay(hk, isMac)} 274 + </button> 275 + <button 276 + type="button" 277 + onClick={(e) => { 278 + e.stopPropagation(); 279 + removeAt(action, index); 280 + }} 281 + aria-label="Remove hotkey" 282 + className="text-foreground/50 hover:text-foreground/80 opacity-70 hover:opacity-100" 283 + > 284 + <XIcon className="size-3" /> 285 + </button> 286 + </> 287 + )} 288 + </span> 289 + ); 290 + })} 291 + </div> 292 + <div className="shrink-0 flex items-center"> 293 + {isEditingAction && slot === 'new' ? ( 294 + <span 295 + className={`border-border mr-1 shrink-0 inline-flex items-center animate-pulse rounded border px-1 py-0.5 text-xs ${isCapturedConflicting ? 'bg-destructive/10 text-destructive' : 'bg-accent/10 text-accent'}`} 296 + > 297 + {captured 298 + ? formatHotkeyForDisplay(captured, isMac) 299 + : 'Press keys…'} 300 + </span> 301 + ) : null} 302 + <button 303 + type="button" 304 + onClick={() => setEditingKey(`${action.id}:new`)} 305 + aria-label="Add hotkey" 306 + className="text-foreground/40 hover:text-foreground/80 hover:bg-border/30 rounded p-1" 307 + > 308 + <PlusIcon className="size-3.5" /> 309 + </button> 310 + </div> 311 + </div> 312 + </div> 313 + ); 314 + })} 315 + {filteredActions.length === 0 ? ( 316 + <div className="text-foreground/40 py-8 text-center text-sm"> 317 + No commands match your filter 318 + </div> 319 + ) : null} 320 + </div> 321 + </div> 322 + ); 323 + }
+27 -1
src/components/SettingsModal.tsx
··· 7 7 } from '../workspaces/workspace'; 8 8 import { Modal } from './Modal'; 9 9 import { SettingsPaneLayout, SettingsPaneSection } from './SettingsPane'; 10 + import { HotkeysPane } from './HotkeysPane'; 10 11 11 12 type SettingsModalProps = { 12 13 open: boolean; ··· 17 18 onWorkspaceKindChange: (kind: WorkspaceKind) => void; 18 19 }; 19 20 20 - type SettingsCategory = 'atmosphere' | 'appearance' | 'subtext'; 21 + type SettingsCategory = 'atmosphere' | 'appearance' | 'hotkeys' | 'subtext'; 21 22 22 23 function AtmosphereAccountPane() { 23 24 const { authInfo, login, logout } = useHabitatAuth(); ··· 171 172 172 173 173 174 175 + function KeyboardIcon() { 176 + return ( 177 + <svg 178 + className="size-4" 179 + viewBox="0 0 24 24" 180 + fill="none" 181 + stroke="currentColor" 182 + strokeWidth="2" 183 + strokeLinecap="round" 184 + strokeLinejoin="round" 185 + aria-hidden 186 + > 187 + <rect x="2" y="4" width="20" height="16" rx="2" /> 188 + <path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M8 12h.01M12 12h.01M16 12h.01M7 16h10" /> 189 + </svg> 190 + ); 191 + } 192 + 174 193 function SubtextSettingsPane() { 175 194 const [enabled, setEnabled] = useState(() => { 176 195 if (typeof localStorage === 'undefined') return false; ··· 245 264 icon: <AppearanceIcon />, 246 265 }, 247 266 { 267 + id: 'hotkeys', 268 + label: 'Hotkeys', 269 + icon: <KeyboardIcon />, 270 + }, 271 + { 248 272 id: 'subtext', 249 273 label: 'Subtext', 250 274 icon: <SubtextIcon className="size-4" />, ··· 282 306 > 283 307 {selectedCategory === 'atmosphere' ? ( 284 308 <AtmosphereAccountPane /> 309 + ) : selectedCategory === 'hotkeys' ? ( 310 + <HotkeysPane /> 285 311 ) : selectedCategory === 'subtext' ? ( 286 312 <SubtextSettingsPane /> 287 313 ) : (
+1 -1
src/components/SettingsPane.tsx
··· 19 19 <aside className="border-border bg-background/90 w-60 shrink-0 border-r p-3"> 20 20 {sidebar} 21 21 </aside> 22 - <section className="flex min-w-0 flex-1 flex-col p-5">{children}</section> 22 + <section className="flex min-h-0 min-w-0 flex-1 flex-col p-5">{children}</section> 23 23 </div> 24 24 ); 25 25 }
+55
src/icons/index.tsx
··· 67 67 </svg> 68 68 ); 69 69 } 70 + 71 + /** Plus icon for "add" actions. */ 72 + export function PlusIcon({ className }: IconProps) { 73 + return ( 74 + <svg 75 + className={className} 76 + viewBox="0 0 24 24" 77 + fill="none" 78 + stroke="currentColor" 79 + strokeWidth="2" 80 + strokeLinecap="round" 81 + strokeLinejoin="round" 82 + aria-hidden 83 + > 84 + <path d="M12 5v14M5 12h14" /> 85 + </svg> 86 + ); 87 + } 88 + 89 + /** X icon for "remove" / "close" actions. */ 90 + export function XIcon({ className }: IconProps) { 91 + return ( 92 + <svg 93 + className={className} 94 + viewBox="0 0 24 24" 95 + fill="none" 96 + stroke="currentColor" 97 + strokeWidth="2" 98 + strokeLinecap="round" 99 + strokeLinejoin="round" 100 + aria-hidden 101 + > 102 + <path d="M18 6 6 18M6 6l12 12" /> 103 + </svg> 104 + ); 105 + } 106 + 107 + /** Search / magnifying-glass icon. */ 108 + export function SearchIcon({ className }: IconProps) { 109 + return ( 110 + <svg 111 + className={className} 112 + viewBox="0 0 24 24" 113 + fill="none" 114 + stroke="currentColor" 115 + strokeWidth="2" 116 + strokeLinecap="round" 117 + strokeLinejoin="round" 118 + aria-hidden 119 + > 120 + <circle cx="11" cy="11" r="8" /> 121 + <path d="m21 21-4.3-4.3" /> 122 + </svg> 123 + ); 124 + }
+2 -1
src/main.ts
··· 93 93 submenu: [ 94 94 { 95 95 label: 'Settings…', 96 - accelerator: 'CommandOrControl+,', 96 + // No accelerator here — hotkeys are managed by the renderer's 97 + // ActionRegistry + HotkeyManager so users can customize bindings. 97 98 click: () => { 98 99 const target = 99 100 BrowserWindow.getFocusedWindow() ??