A local-first note taking app
0

Configure Feed

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

Action & command pallette framework

Ethan Graf (May 30, 2026, 6:18 PM EDT) 4aff66da 97402b88

+1306 -78
+115 -66
src/App.tsx
··· 1 - import { useCallback, useEffect, useMemo, useState } from 'react'; 1 + import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 2 2 import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 3 - import { MemoryRouter, Route, Routes } from 'react-router-dom'; 3 + import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; 4 4 import { UserIdentityProvider } from './auth/UserIdentityContext'; 5 5 import { AppBottomBar } from './components/AppBottomBar'; 6 6 import { AppSidebar } from './components/AppSidebar'; ··· 22 22 type OpenDocRequest, 23 23 type WorkspaceKind, 24 24 } from './workspaces/workspace'; 25 + import { ActionProvider } from './actions/ActionProvider'; 26 + import { 27 + SidebarActionSet, 28 + SettingsActionSet, 29 + ThemeActionSet, 30 + NavigationActionSet, 31 + WorkspaceActionSet, 32 + TilingActionSet, 33 + createActionServices, 34 + type TilingHandle, 35 + } from './actions'; 25 36 26 37 const queryClient = new QueryClient({ 27 38 defaultOptions: { ··· 56 67 } 57 68 } 58 69 59 - export default function App() { 70 + function AppShell() { 60 71 const [theme, setTheme] = useState<'light' | 'dark'>(() => { 61 72 if (typeof document === 'undefined') return 'light'; 62 73 return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'; ··· 68 79 ); 69 80 const [openRequest, setOpenRequest] = useState<OpenDocRequest | null>(null); 70 81 const [openRequestId, setOpenRequestId] = useState(0); 82 + 83 + const isMac = getRendererPlatform() === 'darwin'; 84 + 85 + // Core action-system services — created once at app start and passed down. 86 + const { registry, hotkeyManager } = useMemo( 87 + () => createActionServices(isMac), 88 + [isMac], 89 + ); 90 + 91 + const navigate = useNavigate(); 71 92 72 93 const fileSystemProviders = useMemo<FileSystemProvider[]>(() => { 73 94 const automergeDocuments = new AutomergeDocumentsProvider(); ··· 99 120 fileSystemProviders.find((p) => p.id === providerId), 100 121 [fileSystemProviders], 101 122 ); 102 - // TODO: Remove this guard once non-macOS title bar behavior is implemented. 103 - const isMac = getRendererPlatform() === 'darwin'; 123 + 124 + const tilingHandleRef = useRef<TilingHandle | null>(null); 125 + 126 + // Build ActionSets — each lives close to the code it adapts. 127 + const actionSets = useMemo( 128 + () => [ 129 + new SidebarActionSet(setSidebarOpen), 130 + new SettingsActionSet(setSettingsOpen), 131 + new ThemeActionSet(setTheme), 132 + new NavigationActionSet(navigate), 133 + new WorkspaceActionSet(setWorkspaceKind), 134 + new TilingActionSet(tilingHandleRef), 135 + ], 136 + [setSidebarOpen, setSettingsOpen, setTheme, navigate, setWorkspaceKind], 137 + ); 104 138 105 139 useEffect(() => { 106 140 const root = document.documentElement; ··· 133 167 }, []); 134 168 135 169 return ( 136 - <QueryClientProvider client={queryClient}> 137 - <UserIdentityProvider> 138 - <MemoryRouter initialEntries={['/']}> 139 - {/* TODO: Replace this unsupported screen with full non-macOS UI path. */} 140 - {!isMac ? ( 141 - <main className="bg-background text-foreground flex h-svh w-full items-center justify-center p-6"> 142 - <div className="border-border bg-background max-w-md rounded-md border p-4 text-sm"> 143 - textile currently supports macOS title bar integration only. 144 - </div> 145 - </main> 146 - ) : ( 147 - <div className="bg-background text-foreground flex h-svh w-full flex-col overflow-hidden"> 148 - <AppTitleBar 149 - sidebarOpen={sidebarOpen} 150 - onToggleSidebar={() => setSidebarOpen((o) => !o)} 151 - workspaceKind={workspaceKind} 152 - onWorkspaceKindChange={setWorkspaceKind} 170 + <ActionProvider 171 + actionSets={actionSets} 172 + registry={registry} 173 + hotkeyManager={hotkeyManager} 174 + > 175 + <div className="bg-background text-foreground flex h-svh w-full flex-col overflow-hidden"> 176 + <AppTitleBar 177 + sidebarOpen={sidebarOpen} 178 + onToggleSidebar={() => setSidebarOpen((o) => !o)} 179 + workspaceKind={workspaceKind} 180 + onWorkspaceKindChange={setWorkspaceKind} 181 + /> 182 + <AppBottomBar> 183 + <div 184 + className="flex min-h-0 flex-1 pb-12" 185 + style={{ 186 + // Reserve the window's title bar row; custom chrome is `position: fixed` in that region. 187 + marginTop: 188 + 'calc(env(titlebar-area-y, 0px) + env(titlebar-area-height, 40px))', 189 + }} 190 + > 191 + <AppSidebar 192 + open={sidebarOpen} 193 + providers={fileSystemProviders} 194 + onOpenEntry={handleOpenEntry} 153 195 /> 154 - <AppBottomBar> 155 - <div 156 - className="flex min-h-0 flex-1 pb-12" 157 - style={{ 158 - // Reserve the window’s title bar row; custom chrome is `position: fixed` in that region. 159 - marginTop: 160 - 'calc(env(titlebar-area-y, 0px) + env(titlebar-area-height, 40px))', 161 - }} 162 - > 163 - <AppSidebar 164 - open={sidebarOpen} 165 - providers={fileSystemProviders} 166 - onOpenEntry={handleOpenEntry} 167 - /> 168 - <main className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> 169 - <Routes> 170 - <Route 171 - path="/" 172 - element={ 173 - <WorkspaceHost 174 - kind={workspaceKind} 175 - openRequestId={openRequestId} 176 - openRequest={openRequest} 177 - resolveProvider={resolveProvider} 178 - /> 179 - } 196 + <main className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> 197 + <Routes> 198 + <Route 199 + path="/" 200 + element={ 201 + <WorkspaceHost 202 + kind={workspaceKind} 203 + openRequestId={openRequestId} 204 + openRequest={openRequest} 205 + resolveProvider={resolveProvider} 206 + tilingHandleRef={tilingHandleRef} 180 207 /> 181 - <Route path="/about" element={<AboutPage />} /> 182 - </Routes> 183 - </main> 184 - </div> 185 - </AppBottomBar> 186 - <SettingsModal 187 - open={settingsOpen} 188 - onClose={() => setSettingsOpen(false)} 189 - theme={theme} 190 - onToggleTheme={() => 191 - setTheme((t) => (t === 'light' ? 'dark' : 'light')) 192 - } 193 - workspaceKind={workspaceKind} 194 - onWorkspaceKindChange={setWorkspaceKind} 195 - /> 208 + } 209 + /> 210 + <Route path="/about" element={<AboutPage />} /> 211 + </Routes> 212 + </main> 196 213 </div> 197 - )} 198 - </MemoryRouter> 199 - </UserIdentityProvider> 214 + </AppBottomBar> 215 + <SettingsModal 216 + open={settingsOpen} 217 + onClose={() => setSettingsOpen(false)} 218 + theme={theme} 219 + onToggleTheme={() => 220 + setTheme((t) => (t === 'light' ? 'dark' : 'light')) 221 + } 222 + workspaceKind={workspaceKind} 223 + onWorkspaceKindChange={setWorkspaceKind} 224 + /> 225 + </div> 226 + </ActionProvider> 227 + ); 228 + } 229 + 230 + export default function App() { 231 + const isMac = getRendererPlatform() === 'darwin'; 232 + 233 + return ( 234 + <QueryClientProvider client={queryClient}> 235 + <UserIdentityProvider> 236 + <MemoryRouter initialEntries={['/']}> 237 + {/* TODO: Replace this unsupported screen with full non-macOS UI path. */} 238 + {!isMac ? ( 239 + <main className="bg-background text-foreground flex h-svh w-full items-center justify-center p-6"> 240 + <div className="border-border bg-background max-w-md rounded-md border p-4 text-sm"> 241 + textile currently supports macOS title bar integration only. 242 + </div> 243 + </main> 244 + ) : ( 245 + <AppShell /> 246 + )} 247 + </MemoryRouter> 248 + </UserIdentityProvider> 200 249 </QueryClientProvider> 201 250 ); 202 251 }
+33
src/actions/ActionContext.ts
··· 1 + import { createContext, useContext } from 'react'; 2 + import type { ActionRegistry } from './registry'; 3 + 4 + /** 5 + * React context providing the action registry. 6 + * Used by components that need to register, search, or execute actions. 7 + */ 8 + export interface ActionProviderValue { 9 + /** The action registry for registering and looking up actions */ 10 + registry: ActionRegistry; 11 + } 12 + 13 + export const ActionContext = createContext<ActionProviderValue | null>(null); 14 + 15 + /** 16 + * Hook to access the action registry. 17 + * Must be used within an ActionProvider. 18 + */ 19 + export function useActions(): ActionProviderValue { 20 + const ctx = useContext(ActionContext); 21 + if (!ctx) { 22 + throw new Error('useActions must be used within an ActionProvider'); 23 + } 24 + return ctx; 25 + } 26 + 27 + /** 28 + * Hook to access just the action registry. 29 + * Must be used within an ActionProvider. 30 + */ 31 + export function useActionRegistry(): ActionRegistry { 32 + return useActions().registry; 33 + }
+110
src/actions/ActionProvider.tsx
··· 1 + import { 2 + useCallback, 3 + useEffect, 4 + useMemo, 5 + useState, 6 + type ReactNode, 7 + } from 'react'; 8 + import type { ActionSet, RegisteredAction } from './types'; 9 + import type { ActionRegistry } from './registry'; 10 + import type { HotkeyManager } from './hotkey-manager'; 11 + import { ActionContext } from './ActionContext'; 12 + import { CommandPalette } from './CommandPalette'; 13 + import { getRendererPlatform } from '../lib/platform'; 14 + 15 + interface ActionProviderProps { 16 + children: ReactNode; 17 + /** ActionSets to register — each adapts a component or subsystem. */ 18 + actionSets: ActionSet[]; 19 + /** The action registry (created and owned by AppShell). */ 20 + registry: ActionRegistry; 21 + /** The hotkey manager (created and owned by AppShell). */ 22 + hotkeyManager: HotkeyManager; 23 + } 24 + 25 + /** 26 + * Provides the action registry, hotkey manager, and command palette to the app. 27 + * 28 + * Accepts ActionSets and core services via dependency injection. 29 + * AppShell creates the registry and hotkey manager; ActionProvider only 30 + * registers/unregisters action sets and renders the command palette. 31 + */ 32 + export function ActionProvider({ 33 + children, 34 + actionSets, 35 + registry, 36 + hotkeyManager, 37 + }: ActionProviderProps) { 38 + const isMac = getRendererPlatform() === 'darwin'; 39 + 40 + // Command palette open state 41 + const [paletteOpen, setPaletteOpen] = useState(false); 42 + 43 + // Build the full list of action sets including the internal palette set 44 + const allActionSets = useMemo<ActionSet[]>( 45 + () => [ 46 + ...actionSets, 47 + { 48 + id: 'palette', 49 + getActions: () => [ 50 + { 51 + id: 'open', 52 + name: 'Open command palette', 53 + description: 'Search and execute commands', 54 + hotkeys: [{ modifiers: ['Mod'], key: 'p' }], 55 + execute: () => setPaletteOpen(true), 56 + }, 57 + ], 58 + }, 59 + ], 60 + [actionSets], 61 + ); 62 + 63 + // Register/unregister all actions from all sets 64 + useEffect(() => { 65 + for (const set of allActionSets) { 66 + for (const def of set.getActions()) { 67 + 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 + } 72 + } 73 + } 74 + } 75 + 76 + return () => { 77 + for (const set of allActionSets) { 78 + registry.unregisterAll(set.id); 79 + hotkeyManager.removeSourceHotkeys(set.id); 80 + } 81 + }; 82 + }, [allActionSets, registry, hotkeyManager]); 83 + 84 + const handlePaletteExecute = useCallback( 85 + (action: RegisteredAction) => { 86 + void registry.execute(action.id); 87 + }, 88 + [registry], 89 + ); 90 + 91 + const availableActions = useMemo( 92 + () => registry.getAvailable(), 93 + [registry, paletteOpen], 94 + ); 95 + 96 + const value = useMemo(() => ({ registry }), [registry]); 97 + 98 + return ( 99 + <ActionContext.Provider value={value}> 100 + {children} 101 + <CommandPalette 102 + isOpen={paletteOpen} 103 + onClose={() => setPaletteOpen(false)} 104 + onExecute={handlePaletteExecute} 105 + actions={availableActions} 106 + isMac={isMac} 107 + /> 108 + </ActionContext.Provider> 109 + ); 110 + }
+234
src/actions/CommandPalette.tsx
··· 1 + import { 2 + useCallback, 3 + useEffect, 4 + useMemo, 5 + useRef, 6 + useState, 7 + type KeyboardEvent, 8 + } from 'react'; 9 + import type { RegisteredAction } from './types'; 10 + import { formatHotkeyForDisplay } from './hotkey-utils'; 11 + 12 + interface CommandPaletteProps { 13 + /** Whether the palette is open */ 14 + isOpen: boolean; 15 + /** Callback when the palette should close */ 16 + onClose: () => void; 17 + /** Callback when a command is executed */ 18 + onExecute: (action: RegisteredAction) => void; 19 + /** All available actions */ 20 + actions: RegisteredAction[]; 21 + /** Whether the platform is macOS (affects hotkey display) */ 22 + isMac: boolean; 23 + /** Optional placeholder text */ 24 + placeholder?: string; 25 + } 26 + 27 + /** 28 + * Command palette modal overlay. 29 + * 30 + * Features: 31 + * - Fuzzy search filtering of actions 32 + * - Keyboard navigation (Up/Down arrows, Enter, Escape) 33 + * - Click to execute 34 + * - Shows command hotkey hint if available 35 + */ 36 + export function CommandPalette({ 37 + isOpen, 38 + onClose, 39 + onExecute, 40 + actions, 41 + isMac, 42 + placeholder = 'Type a command...', 43 + }: CommandPaletteProps) { 44 + const [query, setQuery] = useState(''); 45 + const [selectedIndex, setSelectedIndex] = useState(0); 46 + const inputRef = useRef<HTMLInputElement>(null); 47 + const listRef = useRef<HTMLDivElement>(null); 48 + 49 + // Filter actions by query 50 + const filteredActions = useMemo(() => { 51 + if (!query.trim()) return actions; 52 + return defaultFilter(actions, query); 53 + }, [actions, query]); 54 + 55 + // Reset state when opening 56 + useEffect(() => { 57 + if (isOpen) { 58 + setQuery(''); 59 + setSelectedIndex(0); 60 + setTimeout(() => inputRef.current?.focus(), 0); 61 + } 62 + }, [isOpen]); 63 + 64 + // Stable identity for the filtered result set 65 + const filteredKey = useMemo( 66 + () => filteredActions.map((a) => a.id).join('\0'), 67 + [filteredActions], 68 + ); 69 + 70 + // Reset selection when results change 71 + useEffect(() => { 72 + setSelectedIndex(0); 73 + }, [filteredKey]); 74 + 75 + // Scroll selected item into view 76 + useEffect(() => { 77 + if (listRef.current && filteredActions.length > 0) { 78 + const selectedEl = listRef.current.querySelector('[aria-selected="true"]'); 79 + if (selectedEl && typeof selectedEl.scrollIntoView === 'function') { 80 + selectedEl.scrollIntoView({ block: 'nearest' }); 81 + } 82 + } 83 + }, [selectedIndex, filteredActions.length]); 84 + 85 + const handleKeyDown = useCallback( 86 + (e: KeyboardEvent) => { 87 + switch (e.key) { 88 + case 'ArrowDown': 89 + e.preventDefault(); 90 + setSelectedIndex((prev) => 91 + (prev + 1) % Math.max(filteredActions.length, 1), 92 + ); 93 + break; 94 + case 'ArrowUp': 95 + e.preventDefault(); 96 + setSelectedIndex((prev) => 97 + prev <= 0 98 + ? Math.max(filteredActions.length - 1, 0) 99 + : prev - 1, 100 + ); 101 + break; 102 + case 'Enter': 103 + e.preventDefault(); 104 + if (filteredActions[selectedIndex]) { 105 + onExecute(filteredActions[selectedIndex]); 106 + onClose(); 107 + } 108 + break; 109 + case 'Escape': 110 + e.preventDefault(); 111 + onClose(); 112 + break; 113 + } 114 + }, 115 + [filteredActions, selectedIndex, onExecute, onClose], 116 + ); 117 + 118 + const handleActionClick = useCallback( 119 + (action: RegisteredAction) => { 120 + onExecute(action); 121 + onClose(); 122 + }, 123 + [onExecute, onClose], 124 + ); 125 + 126 + if (!isOpen) return null; 127 + 128 + const listId = 'command-palette-list'; 129 + const selectedId = 130 + filteredActions.length > 0 131 + ? `command-palette-item-${selectedIndex}` 132 + : undefined; 133 + 134 + return ( 135 + <div 136 + className="fixed inset-0 z-50 flex items-start justify-center bg-black/35 pt-[20vh] backdrop-blur-sm" 137 + role="dialog" 138 + aria-modal="true" 139 + aria-label="Command palette" 140 + > 141 + <div 142 + className="border-border bg-background text-foreground w-full max-w-lg overflow-hidden rounded-lg border shadow-xl" 143 + onMouseDown={(event) => event.stopPropagation()} 144 + > 145 + {/* Search input */} 146 + <div className="border-border border-b p-3"> 147 + <input 148 + ref={inputRef} 149 + type="text" 150 + className="bg-background text-foreground w-full rounded-md border border-border px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-accent/50" 151 + placeholder={placeholder} 152 + value={query} 153 + onChange={(e) => setQuery(e.target.value)} 154 + onKeyDown={handleKeyDown} 155 + role="combobox" 156 + aria-controls={listId} 157 + aria-activedescendant={selectedId} 158 + aria-autocomplete="list" 159 + aria-expanded={filteredActions.length > 0} 160 + /> 161 + </div> 162 + 163 + {/* Results */} 164 + <div 165 + className="max-h-[50vh] min-h-0 overflow-y-auto p-1" 166 + ref={listRef} 167 + > 168 + {filteredActions.length === 0 ? ( 169 + <div className="text-foreground/50 px-2 py-3 text-center text-sm"> 170 + {query.trim() 171 + ? 'No commands found' 172 + : actions.length === 0 173 + ? 'No commands available' 174 + : 'Type to search commands'} 175 + </div> 176 + ) : ( 177 + <div role="listbox" id={listId}> 178 + {filteredActions.map((action, index) => { 179 + const isSelected = index === selectedIndex; 180 + const hotkeyHint = action.hotkeys?.[0] 181 + ? formatHotkeyForDisplay(action.hotkeys[0], isMac) 182 + : undefined; 183 + 184 + return ( 185 + <div 186 + key={action.id} 187 + id={`command-palette-item-${index}`} 188 + role="option" 189 + aria-selected={isSelected} 190 + className={`flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm select-none transition-none ${ 191 + isSelected 192 + ? 'bg-accent/10 text-foreground' 193 + : 'text-foreground/70 hover:bg-border/35' 194 + }`} 195 + title={action.description} 196 + onClick={() => handleActionClick(action)} 197 + onMouseEnter={() => setSelectedIndex(index)} 198 + > 199 + <span className="flex-1 truncate">{action.name}</span> 200 + {hotkeyHint ? ( 201 + <kbd className="border-border text-foreground/60 shrink-0 rounded border px-1.5 py-0.5 text-xs font-mono"> 202 + {hotkeyHint} 203 + </kbd> 204 + ) : null} 205 + </div> 206 + ); 207 + })} 208 + </div> 209 + )} 210 + </div> 211 + </div> 212 + </div> 213 + ); 214 + } 215 + 216 + /** 217 + * Default filter when no external search is provided. 218 + * All query words must appear in the action name or description (case-insensitive). 219 + */ 220 + function defaultFilter( 221 + actions: RegisteredAction[], 222 + query: string, 223 + ): RegisteredAction[] { 224 + const words = query.toLowerCase().split(/\s+/).filter(Boolean); 225 + 226 + return actions.filter((action) => { 227 + const haystack = ( 228 + action.name + 229 + ' ' + 230 + (action.description ?? '') 231 + ).toLowerCase(); 232 + return words.every((word) => haystack.includes(word)); 233 + }); 234 + }
+234
src/actions/hotkey-manager.ts
··· 1 + import type { Hotkey } from './types'; 2 + import type { ActionRegistry } from './registry'; 3 + import { normalizeHotkey, hotkeyToString, eventToHotkey } from './hotkey-utils'; 4 + 5 + /** 6 + * Information about a hotkey conflict. 7 + * Occurs when an action tries to register a hotkey already bound to another action. 8 + */ 9 + export interface HotkeyConflict { 10 + /** The hotkey string (e.g. "Ctrl+P") */ 11 + hotkeyString: string; 12 + /** The action ID that was rejected (tried to register but lost) */ 13 + rejectedActionId: string; 14 + /** The action ID that currently holds the binding (winner) */ 15 + existingActionId: string; 16 + } 17 + 18 + /** 19 + * Manages global keyboard shortcuts for actions. 20 + * 21 + * Features: 22 + * - Platform-aware Mod key expansion (Cmd on Mac, Ctrl elsewhere) 23 + * - Conflict detection when registering overlapping hotkeys 24 + * - Error isolation — callback errors don't crash the app 25 + * - Automatic cleanup on destroy 26 + */ 27 + export class HotkeyManager { 28 + /** Map of normalized hotkey string -> action ID */ 29 + private bindings = new Map<string, string>(); 30 + /** Map of action ID -> normalized hotkeys */ 31 + private actionToHotkeys = new Map<string, Hotkey[]>(); 32 + /** Tracked conflicts for display in settings UI */ 33 + private conflicts: HotkeyConflict[] = []; 34 + /** Whether we're on Mac (affects Mod expansion) */ 35 + private readonly isMac: boolean; 36 + /** Reference to action registry for callback lookup */ 37 + private readonly registry: ActionRegistry; 38 + /** Bound event handler for cleanup */ 39 + private readonly handleKeyDown: (e: KeyboardEvent) => void; 40 + /** Whether the keydown listener is currently attached */ 41 + private _attached = false; 42 + /** Unsubscribe from registry action-removal notifications */ 43 + private readonly unsubscribeActionRemoved: () => void; 44 + /** Callback invoked when a hotkey matches an action */ 45 + private readonly executeAction: (actionId: string) => void; 46 + 47 + constructor( 48 + registry: ActionRegistry, 49 + isMac: boolean, 50 + executeAction: (actionId: string) => void, 51 + ) { 52 + this.registry = registry; 53 + this.isMac = isMac; 54 + this.executeAction = executeAction; 55 + 56 + this.handleKeyDown = this.onKeyDown.bind(this); 57 + this.unsubscribeActionRemoved = registry.onActionRemoved((actionId) => { 58 + this.removeHotkey(actionId); 59 + }); 60 + window.addEventListener('keydown', this.handleKeyDown); 61 + this._attached = true; 62 + } 63 + 64 + /** 65 + * Register a hotkey for an action. 66 + * 67 + * @returns The action ID of a conflicting binding, or null if no conflict 68 + */ 69 + registerHotkey(actionId: string, hotkey: Hotkey): string | null { 70 + const normalized = normalizeHotkey(hotkey, this.isMac); 71 + const key = hotkeyToString(normalized); 72 + 73 + // Check for conflict 74 + const existing = this.bindings.get(key); 75 + if (existing && existing !== actionId) { 76 + // Track the conflict for display in settings 77 + if ( 78 + !this.conflicts.some( 79 + (conflict) => 80 + conflict.hotkeyString === key && 81 + conflict.rejectedActionId === actionId && 82 + conflict.existingActionId === existing, 83 + ) 84 + ) { 85 + this.conflicts.push({ 86 + hotkeyString: key, 87 + rejectedActionId: actionId, 88 + existingActionId: existing, 89 + }); 90 + } 91 + return existing; 92 + } 93 + 94 + const existingHotkeys = this.actionToHotkeys.get(actionId) ?? []; 95 + if (existingHotkeys.some((h) => hotkeyToString(h) === key)) { 96 + return null; 97 + } 98 + 99 + this.bindings.set(key, actionId); 100 + this.actionToHotkeys.set(actionId, [...existingHotkeys, normalized]); 101 + 102 + return null; 103 + } 104 + 105 + /** 106 + * Remove a hotkey binding for an action. 107 + * Also clears any conflicts where this action was either the winner or the loser. 108 + */ 109 + removeHotkey(actionId: string): void { 110 + const hotkeys = this.actionToHotkeys.get(actionId); 111 + if (hotkeys) { 112 + for (const hotkey of hotkeys) { 113 + const key = hotkeyToString(hotkey); 114 + this.bindings.delete(key); 115 + } 116 + this.actionToHotkeys.delete(actionId); 117 + } 118 + // Always clear conflicts where this action was involved 119 + this.conflicts = this.conflicts.filter( 120 + (c) => c.existingActionId !== actionId && c.rejectedActionId !== actionId, 121 + ); 122 + } 123 + 124 + /** 125 + * Remove all hotkey bindings for a source (e.g. plugin). 126 + */ 127 + removeSourceHotkeys(source: string): void { 128 + const toRemove: string[] = []; 129 + for (const actionId of this.actionToHotkeys.keys()) { 130 + if (actionId.startsWith(source + ':')) { 131 + toRemove.push(actionId); 132 + } 133 + } 134 + for (const actionId of toRemove) { 135 + this.removeHotkey(actionId); 136 + } 137 + } 138 + 139 + /** 140 + * Get the hotkey registered for an action. 141 + */ 142 + getHotkeyForAction(actionId: string): Hotkey | undefined { 143 + return this.actionToHotkeys.get(actionId)?.[0]; 144 + } 145 + 146 + /** 147 + * Get all current hotkey bindings. 148 + */ 149 + getAllBindings(): Map<string, string> { 150 + return new Map(this.bindings); 151 + } 152 + 153 + /** 154 + * Get all tracked hotkey conflicts. 155 + */ 156 + getConflicts(): HotkeyConflict[] { 157 + return [...this.conflicts]; 158 + } 159 + 160 + /** 161 + * Detach the keydown listener. Call reattach() to restore it. 162 + * Does NOT clear bindings — they survive detach/reattach cycles 163 + * (important for React StrictMode unmount/remount). 164 + */ 165 + detach(): void { 166 + if (!this._attached) return; 167 + window.removeEventListener('keydown', this.handleKeyDown); 168 + this._attached = false; 169 + } 170 + 171 + /** 172 + * Re-attach the keydown listener after detach(). 173 + * Idempotent — safe to call if already attached. 174 + */ 175 + reattach(): void { 176 + if (this._attached) return; 177 + window.addEventListener('keydown', this.handleKeyDown); 178 + this._attached = true; 179 + } 180 + 181 + /** 182 + * Permanently clean up. Removes listener and clears all bindings. 183 + * After destroy(), the instance cannot be reattached. 184 + */ 185 + destroy(): void { 186 + window.removeEventListener('keydown', this.handleKeyDown); 187 + this._attached = false; 188 + this.unsubscribeActionRemoved(); 189 + this.bindings.clear(); 190 + this.actionToHotkeys.clear(); 191 + this.conflicts = []; 192 + } 193 + 194 + /** Keys that are modifiers themselves — skip these to avoid spurious lookups */ 195 + private static readonly MODIFIER_KEYS = new Set([ 196 + 'Control', 197 + 'Shift', 198 + 'Alt', 199 + 'Meta', 200 + ]); 201 + 202 + /** 203 + * Handle keydown events and dispatch to matching actions. 204 + */ 205 + private onKeyDown(event: KeyboardEvent): void { 206 + // Skip modifier-only keypresses (e.g. pressing just Shift) 207 + if (HotkeyManager.MODIFIER_KEYS.has(event.key)) return; 208 + 209 + // Ignore events when typing in inputs (unless it's a modifier combo) 210 + const target = event.target as HTMLElement | null; 211 + if (target && target.tagName) { 212 + const tagName = target.tagName.toLowerCase(); 213 + const isInput = 214 + tagName === 'input' || tagName === 'textarea' || target.isContentEditable; 215 + if (isInput && !event.ctrlKey && !event.metaKey && !event.altKey) { 216 + return; 217 + } 218 + } 219 + 220 + const hotkey = eventToHotkey(event); 221 + const key = hotkeyToString(hotkey); 222 + const actionId = this.bindings.get(key); 223 + 224 + if (!actionId) return; 225 + 226 + const action = this.registry.get(actionId); 227 + if (!action) return; 228 + 229 + event.preventDefault(); 230 + event.stopImmediatePropagation(); 231 + 232 + this.executeAction(actionId); 233 + } 234 + }
+105
src/actions/hotkey-utils.ts
··· 1 + /** Modifier sort order for consistent string representation. */ 2 + const MODIFIER_ORDER: Modifier[] = ['Ctrl', 'Meta', 'Alt', 'Shift']; 3 + 4 + /** Known modifier names for validation. */ 5 + const KNOWN_MODIFIERS = new Set<string>(['Ctrl', 'Meta', 'Alt', 'Shift', 'Mod']); 6 + 7 + import type { Modifier, Hotkey } from './types'; 8 + 9 + /** 10 + * Detect if the current platform is macOS. 11 + */ 12 + export function isMacPlatform(): boolean { 13 + if (typeof navigator === 'undefined') return false; 14 + return /Mac|iPod|iPhone|iPad/.test(navigator.platform); 15 + } 16 + 17 + /** 18 + * Normalize a hotkey by expanding 'Mod' to the platform-specific modifier. 19 + * Also lowercases the key for consistent matching. 20 + */ 21 + export function normalizeHotkey(hotkey: Hotkey, isMac: boolean): Hotkey { 22 + const modifiers: Modifier[] = hotkey.modifiers.map((mod) => { 23 + if (mod === 'Mod') { 24 + return isMac ? 'Meta' : 'Ctrl'; 25 + } 26 + return mod; 27 + }); 28 + 29 + return { 30 + modifiers, 31 + key: hotkey.key.toLowerCase(), 32 + }; 33 + } 34 + 35 + /** 36 + * Convert a hotkey to a consistent string representation. 37 + * Modifiers are sorted consistently and key is uppercased. 38 + * Used as a lookup key in the hotkey registry. 39 + */ 40 + export function hotkeyToString(hotkey: Hotkey): string { 41 + const sortedMods = [...hotkey.modifiers].sort( 42 + (a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b), 43 + ); 44 + const parts = [...sortedMods, hotkey.key.toUpperCase()]; 45 + return parts.join('+'); 46 + } 47 + 48 + /** 49 + * Parse a hotkey string back into a Hotkey object. 50 + * E.g., "Ctrl+Shift+P" -> { modifiers: ['Ctrl', 'Shift'], key: 'p' } 51 + * Unknown modifiers are silently dropped. 52 + */ 53 + export function parseHotkeyString(str: string): Hotkey { 54 + const parts = str.split('+'); 55 + const key = parts.pop()!.toLowerCase(); 56 + const modifiers = parts.filter((p) => KNOWN_MODIFIERS.has(p)) as Modifier[]; 57 + return { modifiers, key }; 58 + } 59 + 60 + /** 61 + * Convert a keyboard event to a Hotkey object. 62 + */ 63 + export function eventToHotkey(event: KeyboardEvent): Hotkey { 64 + const modifiers: Modifier[] = []; 65 + if (event.ctrlKey) modifiers.push('Ctrl'); 66 + if (event.metaKey) modifiers.push('Meta'); 67 + if (event.altKey) modifiers.push('Alt'); 68 + if (event.shiftKey) modifiers.push('Shift'); 69 + 70 + return { 71 + modifiers, 72 + key: event.key.toLowerCase(), 73 + }; 74 + } 75 + 76 + /** 77 + * Format a hotkey for display to the user. 78 + * Uses platform-appropriate symbols (e.g., Command on Mac, Ctrl on Windows). 79 + */ 80 + export function formatHotkeyForDisplay(hotkey: Hotkey, isMac: boolean): string { 81 + const symbols: Record<Modifier, string> = isMac 82 + ? { 83 + Ctrl: '^', 84 + Meta: '\u2318', 85 + Alt: '\u2325', 86 + Shift: '\u21E7', 87 + Mod: '\u2318', 88 + } 89 + : { 90 + Ctrl: 'Ctrl', 91 + Meta: 'Win', 92 + Alt: 'Alt', 93 + Shift: 'Shift', 94 + Mod: 'Ctrl', 95 + }; 96 + 97 + const mods = hotkey.modifiers.map((m) => symbols[m] ?? m); 98 + const key = hotkey.key.toUpperCase(); 99 + 100 + if (isMac) { 101 + return mods.join('') + key; 102 + } else { 103 + return [...mods, key].join('+'); 104 + } 105 + }
+28
src/actions/index.ts
··· 1 + export type { 2 + ActionDefinition, 3 + RegisteredAction, 4 + ActionSet, 5 + Hotkey, 6 + Modifier, 7 + } from './types'; 8 + 9 + export { ActionRegistry } from './registry'; 10 + export { HotkeyManager, type HotkeyConflict } from './hotkey-manager'; 11 + export { 12 + normalizeHotkey, 13 + hotkeyToString, 14 + parseHotkeyString, 15 + eventToHotkey, 16 + formatHotkeyForDisplay, 17 + isMacPlatform, 18 + } from './hotkey-utils'; 19 + export { CommandPalette } from './CommandPalette'; 20 + export { ActionProvider } from './ActionProvider'; 21 + export { ActionContext, useActions, useActionRegistry } from './ActionContext'; 22 + export { createActionServices } from './services'; 23 + export { SidebarActionSet } from './sets/sidebarActionSet'; 24 + export { SettingsActionSet } from './sets/settingsActionSet'; 25 + export { ThemeActionSet } from './sets/themeActionSet'; 26 + export { NavigationActionSet } from './sets/navigationActionSet'; 27 + export { WorkspaceActionSet } from './sets/workspaceActionSet'; 28 + export { TilingActionSet, type TilingHandle } from './sets/tilingActionSet';
+174
src/actions/registry.ts
··· 1 + import type { ActionDefinition, RegisteredAction } from './types'; 2 + 3 + /** 4 + * Central registry for all actions across core and plugins. 5 + * 6 + * Actions are stored with full IDs (source:action-id) to avoid collisions. 7 + * Provides search/filter functionality for the command palette. 8 + */ 9 + export class ActionRegistry { 10 + private actions = new Map<string, RegisteredAction>(); 11 + private removalListeners = new Set<(id: string) => void>(); 12 + 13 + /** 14 + * Register an action from a source (e.g. 'core' or a plugin ID). 15 + * The action ID is prefixed with the source. 16 + * 17 + * @returns The registered action with prefixed ID 18 + */ 19 + register(source: string, def: ActionDefinition): RegisteredAction { 20 + const fullId = `${source}:${def.id}`; 21 + const registered: RegisteredAction = { 22 + ...def, 23 + id: fullId, 24 + source, 25 + }; 26 + this.actions.set(fullId, registered); 27 + return registered; 28 + } 29 + 30 + /** 31 + * Remove an action by its full ID. 32 + */ 33 + unregister(id: string): boolean { 34 + const removed = this.actions.delete(id); 35 + if (removed) { 36 + for (const listener of this.removalListeners) { 37 + listener(id); 38 + } 39 + } 40 + return removed; 41 + } 42 + 43 + /** 44 + * Remove all actions from a specific source. 45 + */ 46 + unregisterAll(source: string): void { 47 + const toRemove: string[] = []; 48 + for (const [id, action] of this.actions) { 49 + if (action.source === source) { 50 + toRemove.push(id); 51 + } 52 + } 53 + for (const id of toRemove) { 54 + this.unregister(id); 55 + } 56 + } 57 + 58 + /** 59 + * Get a single action by full ID. 60 + */ 61 + get(id: string): RegisteredAction | undefined { 62 + return this.actions.get(id); 63 + } 64 + 65 + /** 66 + * Get all registered actions. 67 + */ 68 + getAll(): RegisteredAction[] { 69 + return Array.from(this.actions.values()); 70 + } 71 + 72 + /** 73 + * Subscribe to action removal events. 74 + * Returns an unsubscribe function. 75 + */ 76 + onActionRemoved(listener: (id: string) => void): () => void { 77 + this.removalListeners.add(listener); 78 + return () => { 79 + this.removalListeners.delete(listener); 80 + }; 81 + } 82 + 83 + /** 84 + * Check if an action is currently available. 85 + */ 86 + isAvailable(action: RegisteredAction): boolean { 87 + if (!action.checkAvailable) return true; 88 + try { 89 + return action.checkAvailable(); 90 + } catch { 91 + return false; 92 + } 93 + } 94 + 95 + /** 96 + * Get all actions that are currently available. 97 + */ 98 + getAvailable(): RegisteredAction[] { 99 + return this.getAll().filter((action) => this.isAvailable(action)); 100 + } 101 + 102 + /** 103 + * Execute an action by its full ID. 104 + * @returns true if found and executed, false otherwise 105 + */ 106 + async execute(id: string): Promise<boolean> { 107 + const action = this.actions.get(id); 108 + if (!action) return false; 109 + 110 + if (!this.isAvailable(action)) return false; 111 + 112 + try { 113 + const result = action.execute(); 114 + if (result && typeof (result as PromiseLike<unknown>).then === 'function') { 115 + await result; 116 + } 117 + return true; 118 + } catch (error) { 119 + console.error(`Error executing action '${id}':`, error); 120 + return false; 121 + } 122 + } 123 + 124 + /** 125 + * Search actions by name using fuzzy matching. 126 + * Results are filtered by availability and sorted by match quality. 127 + * 128 + * @param query - Search query 129 + */ 130 + search(query: string): RegisteredAction[] { 131 + const available = this.getAvailable(); 132 + 133 + if (!query.trim()) { 134 + return available; 135 + } 136 + 137 + const words = query.toLowerCase().split(/\s+/).filter(Boolean); 138 + const matches: Array<{ action: RegisteredAction; score: number }> = []; 139 + 140 + for (const action of available) { 141 + const name = action.name.toLowerCase(); 142 + const desc = (action.description ?? '').toLowerCase(); 143 + const haystack = name + ' ' + desc; 144 + 145 + // All query words must appear somewhere 146 + const allMatch = words.every((word) => haystack.includes(word)); 147 + if (!allMatch) continue; 148 + 149 + // Scoring: name prefix > name word boundary > name contains > desc contains 150 + let score = 0; 151 + for (const word of words) { 152 + if (name.startsWith(word)) { 153 + score += 4; 154 + } else if (name.includes(' ' + word)) { 155 + score += 3; 156 + } else if (name.includes(word)) { 157 + score += 2; 158 + } else { 159 + score += 1; 160 + } 161 + } 162 + 163 + matches.push({ action, score }); 164 + } 165 + 166 + // Sort by score descending, then by name alphabetically 167 + matches.sort((a, b) => { 168 + if (b.score !== a.score) return b.score - a.score; 169 + return a.action.name.localeCompare(b.action.name); 170 + }); 171 + 172 + return matches.map((m) => m.action); 173 + } 174 + }
+24
src/actions/services.ts
··· 1 + import { ActionRegistry } from './registry'; 2 + import { HotkeyManager } from './hotkey-manager'; 3 + 4 + /** 5 + * Create the core action system services. 6 + * 7 + * Call this once at app startup (e.g. in AppShell via useMemo) and pass 8 + * the returned services into ActionProvider. 9 + * 10 + * @param isMac - Whether the renderer is on macOS (determines Mod expansion) 11 + */ 12 + export function createActionServices(isMac: boolean) { 13 + const registry = new ActionRegistry(); 14 + 15 + const hotkeyManager = new HotkeyManager( 16 + registry, 17 + isMac, 18 + (actionId) => { 19 + void registry.execute(actionId); 20 + }, 21 + ); 22 + 23 + return { registry, hotkeyManager }; 24 + }
+54
src/actions/types.ts
··· 1 + /** Platform-agnostic modifier. 'Mod' expands to Meta (macOS) or Ctrl (others). */ 2 + export type Modifier = 'Mod' | 'Ctrl' | 'Meta' | 'Alt' | 'Shift'; 3 + 4 + export interface Hotkey { 5 + modifiers: Modifier[]; 6 + key: string; 7 + } 8 + 9 + /** 10 + * Definition of an action as provided by core or a plugin. 11 + * 12 + * Actions are self-contained closures over the state they manipulate. 13 + * They do not receive an app-wide context object; instead, each ActionSet 14 + * creates actions that close over the specific state/setters they need. 15 + */ 16 + export interface ActionDefinition { 17 + /** Unique ID within the source namespace (e.g. 'sidebar-toggle'). */ 18 + id: string; 19 + /** Human-readable name for the command palette search. */ 20 + name: string; 21 + /** Short description (shown in palette details / settings). */ 22 + description?: string; 23 + /** Icon identifier for toolbar rendering. */ 24 + icon?: string; 25 + /** Default hotkeys (user-remappable in future settings). */ 26 + hotkeys?: Hotkey[]; 27 + /** Whether holding the hotkey should repeatedly trigger this command. */ 28 + repeatable?: boolean; 29 + /** 30 + * Optional availability check. Return false to hide from palette 31 + * and skip execution. 32 + */ 33 + checkAvailable?(): boolean; 34 + /** Execute the action. */ 35 + execute(): void | Promise<void>; 36 + } 37 + 38 + /** Internal representation after registration (includes source tag). */ 39 + export interface RegisteredAction extends ActionDefinition { 40 + /** 'core' or a plugin ID. */ 41 + source: string; 42 + } 43 + 44 + /** 45 + * A set of actions that adapts a specific component or subsystem to the 46 + * action registry. ActionSets are created close to the code they adapt and 47 + * dependency-injected into ActionProvider. 48 + */ 49 + export interface ActionSet { 50 + /** Unique source ID for this set (used as action source prefix). */ 51 + id: string; 52 + /** Get all action definitions from this set. */ 53 + getActions(): ActionDefinition[]; 54 + }
+10 -1
src/workspaces/workspace.tsx
··· 1 - import type { ComponentType } from 'react'; 1 + import type { ComponentType, MutableRefObject } from 'react'; 2 2 import type { FileSystemProvider } from '../filesystem/types'; 3 3 import { TilingWorkspace } from './tiling/tiling'; 4 4 import { ZenWorkspace } from './zen/zen'; 5 + import type { TilingHandle } from '../actions'; 5 6 6 7 export type WorkspaceKind = 'tiling' | 'zen'; 7 8 ··· 46 47 * open documents via `provider.documents` when present. 47 48 */ 48 49 resolveProvider: (providerId: string) => FileSystemProvider | undefined; 50 + /** 51 + * Mutable ref for TilingWorkspace to expose its split handle. 52 + * Only meaningful for the tiling workspace; other kinds ignore it. 53 + */ 54 + tilingHandleRef?: MutableRefObject<TilingHandle | null>; 49 55 }; 50 56 51 57 export type WorkspaceComponent = ComponentType<WorkspaceProps>; ··· 93 99 openRequestId: number; 94 100 openRequest: OpenDocRequest | null; 95 101 resolveProvider: (providerId: string) => FileSystemProvider | undefined; 102 + tilingHandleRef?: MutableRefObject<TilingHandle | null>; 96 103 }; 97 104 98 105 /** ··· 105 112 openRequestId, 106 113 openRequest, 107 114 resolveProvider, 115 + tilingHandleRef, 108 116 }: WorkspaceHostProps) { 109 117 const { Component } = getWorkspaceDescriptor(kind); 110 118 return ( ··· 113 121 openRequestId={openRequestId} 114 122 openRequest={openRequest} 115 123 resolveProvider={resolveProvider} 124 + tilingHandleRef={tilingHandleRef} 116 125 /> 117 126 ); 118 127 }
+22
src/actions/sets/navigationActionSet.ts
··· 1 + import type { NavigateFunction } from 'react-router-dom'; 2 + import type { ActionSet, ActionDefinition } from '../types'; 3 + 4 + /** 5 + * ActionSet for navigation actions. 6 + */ 7 + export class NavigationActionSet implements ActionSet { 8 + readonly id = 'navigation'; 9 + 10 + constructor(private navigate: NavigateFunction) {} 11 + 12 + getActions(): ActionDefinition[] { 13 + return [ 14 + { 15 + id: 'about', 16 + name: 'Open about page', 17 + description: 'Navigate to the about page', 18 + execute: () => this.navigate('/about'), 19 + }, 20 + ]; 21 + } 22 + }
+22
src/actions/sets/settingsActionSet.ts
··· 1 + import type { ActionSet, ActionDefinition } from '../types'; 2 + 3 + /** 4 + * ActionSet for settings modal actions. 5 + */ 6 + export class SettingsActionSet implements ActionSet { 7 + readonly id = 'settings'; 8 + 9 + constructor(private setSettingsOpen: (open: boolean) => void) {} 10 + 11 + getActions(): ActionDefinition[] { 12 + return [ 13 + { 14 + id: 'open', 15 + name: 'Open settings', 16 + description: 'Show the settings modal', 17 + hotkeys: [{ modifiers: ['Mod'], key: ',' }], 18 + execute: () => this.setSettingsOpen(true), 19 + }, 20 + ]; 21 + } 22 + }
+24
src/actions/sets/sidebarActionSet.ts
··· 1 + import type { ActionSet, ActionDefinition } from '../types'; 2 + 3 + /** 4 + * ActionSet for sidebar chrome actions. 5 + */ 6 + export class SidebarActionSet implements ActionSet { 7 + readonly id = 'sidebar'; 8 + 9 + constructor( 10 + private setSidebarOpen: (open: boolean | ((prev: boolean) => boolean)) => void, 11 + ) {} 12 + 13 + getActions(): ActionDefinition[] { 14 + return [ 15 + { 16 + id: 'toggle', 17 + name: 'Toggle sidebar', 18 + description: 'Show or hide the file sidebar', 19 + hotkeys: [{ modifiers: ['Mod'], key: 'b' }], 20 + execute: () => this.setSidebarOpen((prev) => !prev), 21 + }, 22 + ]; 23 + } 24 + }
+24
src/actions/sets/themeActionSet.ts
··· 1 + import type { ActionSet, ActionDefinition } from '../types'; 2 + 3 + /** 4 + * ActionSet for theme switching actions. 5 + */ 6 + export class ThemeActionSet implements ActionSet { 7 + readonly id = 'theme'; 8 + 9 + constructor( 10 + private setTheme: (theme: 'light' | 'dark' | ((prev: 'light' | 'dark') => 'light' | 'dark')) => void, 11 + ) {} 12 + 13 + getActions(): ActionDefinition[] { 14 + return [ 15 + { 16 + id: 'toggle', 17 + name: 'Toggle theme', 18 + description: 'Switch between light and dark mode', 19 + execute: () => 20 + this.setTheme((prev) => (prev === 'light' ? 'dark' : 'light')), 21 + }, 22 + ]; 23 + } 24 + }
+43
src/actions/sets/tilingActionSet.ts
··· 1 + import type { MutableRefObject } from 'react'; 2 + import type { ActionSet, ActionDefinition } from '../types'; 3 + 4 + /** 5 + * Bridge type that TilingWorkspace writes into and the ActionSet reads from. 6 + */ 7 + export interface TilingHandle { 8 + splitRight(): void; 9 + splitDown(): void; 10 + } 11 + 12 + /** 13 + * ActionSet for tiling workspace actions. 14 + * 15 + * The handle is populated by TilingWorkspace via a ref bridge so the 16 + * ActionSet can trigger splits without owning tiling state. 17 + */ 18 + export class TilingActionSet implements ActionSet { 19 + readonly id = 'tiling'; 20 + 21 + constructor( 22 + private handleRef: MutableRefObject<TilingHandle | null>, 23 + ) {} 24 + 25 + getActions(): ActionDefinition[] { 26 + return [ 27 + { 28 + id: 'split-right', 29 + name: 'Split view to the right', 30 + description: 'Split the active tile vertically', 31 + checkAvailable: () => this.handleRef.current !== null, 32 + execute: () => this.handleRef.current?.splitRight(), 33 + }, 34 + { 35 + id: 'split-down', 36 + name: 'Split view down', 37 + description: 'Split the active tile horizontally', 38 + checkAvailable: () => this.handleRef.current !== null, 39 + execute: () => this.handleRef.current?.splitDown(), 40 + }, 41 + ]; 42 + } 43 + }
+28
src/actions/sets/workspaceActionSet.ts
··· 1 + import type { ActionSet, ActionDefinition } from '../types'; 2 + import type { WorkspaceKind } from '../../workspaces/workspace'; 3 + 4 + /** 5 + * ActionSet for workspace switching actions. 6 + */ 7 + export class WorkspaceActionSet implements ActionSet { 8 + readonly id = 'workspace'; 9 + 10 + constructor(private setWorkspaceKind: (kind: WorkspaceKind) => void) {} 11 + 12 + getActions(): ActionDefinition[] { 13 + return [ 14 + { 15 + id: 'tiling', 16 + name: 'Switch to tiling workspace', 17 + description: 'Arrange documents in split panes', 18 + execute: () => this.setWorkspaceKind('tiling'), 19 + }, 20 + { 21 + id: 'zen', 22 + name: 'Switch to zen workspace', 23 + description: 'Focus on a single document', 24 + execute: () => this.setWorkspaceKind('zen'), 25 + }, 26 + ]; 27 + } 28 + }
+22 -11
src/workspaces/tiling/tiling.tsx
··· 1 - import { ReactNode, useMemo, useRef, useState } from 'react'; 1 + import { ReactNode, useEffect, useRef, useState } from 'react'; 2 2 3 3 import { DocumentSlotView } from '../../editors/DocumentSlotView'; 4 4 import type { DocumentSlotState } from '../../documents/useDocumentSlot'; ··· 16 16 import { WorkspaceBarSlot } from '../barSlot'; 17 17 import type { WorkspaceProps } from '../workspace'; 18 18 import { useTilingDocumentSlots } from './useTilingDocumentSlots'; 19 + import type { TilingHandle } from '../../actions'; 19 20 20 21 type SplitterProps = { 21 22 node: SplitNode; ··· 145 146 openRequestId, 146 147 openRequest, 147 148 resolveProvider, 149 + tilingHandleRef, 148 150 }: WorkspaceProps) { 149 151 const nextIdRef = useRef(2); 150 152 const makeId = (prefix: string) => `${prefix}-${nextIdRef.current++}`; ··· 161 163 activeTileId, 162 164 }); 163 165 164 - const commandIds = useMemo( 165 - () => ({ 166 - splitRight: 'workspace.splitActiveRight', 167 - splitDown: 'workspace.splitActiveDown', 168 - }), 169 - [], 170 - ); 171 - 172 166 const applySplit = (orientation: SplitOrientation) => { 173 167 const splitId = makeId('split'); 174 168 const tileId = makeId('tile'); ··· 181 175 setActiveTileId(result.newTileId); 182 176 ensureTile(result.newTileId); 183 177 }; 178 + 179 + // Keep a stable ref to the latest applySplit so the imperative handle 180 + // never goes stale even as tree / activeTileId change. 181 + const applySplitRef = useRef(applySplit); 182 + applySplitRef.current = applySplit; 183 + 184 + useEffect(() => { 185 + if (!tilingHandleRef) return; 186 + const handle: TilingHandle = { 187 + splitRight: () => applySplitRef.current('row'), 188 + splitDown: () => applySplitRef.current('column'), 189 + }; 190 + tilingHandleRef.current = handle; 191 + return () => { 192 + tilingHandleRef.current = null; 193 + }; 194 + }, [tilingHandleRef]); 184 195 185 196 const renderNode = (node: TilingNode): ReactNode => { 186 197 if (node.kind === 'tile') { ··· 219 230 <button 220 231 type="button" 221 232 onClick={() => applySplit('row')} 222 - data-command-id={commandIds.splitRight} 233 + data-command-id="tiling:split-right" 223 234 className="border-border bg-background text-foreground hover:bg-border/30 focus-visible:ring-accent/50 shrink-0 rounded border px-2 py-1 text-xs outline-none focus-visible:ring-2" 224 235 > 225 236 Split Right ··· 227 238 <button 228 239 type="button" 229 240 onClick={() => applySplit('column')} 230 - data-command-id={commandIds.splitDown} 241 + data-command-id="tiling:split-down" 231 242 className="border-border bg-background text-foreground hover:bg-border/30 focus-visible:ring-accent/50 shrink-0 rounded border px-2 py-1 text-xs outline-none focus-visible:ring-2" 232 243 > 233 244 Split Down