diff --git a/src/App.tsx b/src/App.tsx index f3dd477..09279aa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; import { UserIdentityProvider } from './auth/UserIdentityContext'; import { AppBottomBar } from './components/AppBottomBar'; import { AppSidebar } from './components/AppSidebar'; @@ -22,6 +22,17 @@ import { type OpenDocRequest, type WorkspaceKind, } from './workspaces/workspace'; +import { ActionProvider } from './actions/ActionProvider'; +import { + SidebarActionSet, + SettingsActionSet, + ThemeActionSet, + NavigationActionSet, + WorkspaceActionSet, + TilingActionSet, + createActionServices, + type TilingHandle, +} from './actions'; const queryClient = new QueryClient({ defaultOptions: { @@ -56,7 +67,7 @@ function loadInitialWorkspaceKind(): WorkspaceKind { } } -export default function App() { +function AppShell() { const [theme, setTheme] = useState<'light' | 'dark'>(() => { if (typeof document === 'undefined') return 'light'; return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'; @@ -69,6 +80,16 @@ export default function App() { const [openRequest, setOpenRequest] = useState(null); const [openRequestId, setOpenRequestId] = useState(0); + const isMac = getRendererPlatform() === 'darwin'; + + // Core action-system services — created once at app start and passed down. + const { registry, hotkeyManager } = useMemo( + () => createActionServices(isMac), + [isMac], + ); + + const navigate = useNavigate(); + const fileSystemProviders = useMemo(() => { const automergeDocuments = new AutomergeDocumentsProvider(); const habitatDocuments = new HabitatDocumentsProvider(); @@ -99,8 +120,21 @@ export default function App() { fileSystemProviders.find((p) => p.id === providerId), [fileSystemProviders], ); - // TODO: Remove this guard once non-macOS title bar behavior is implemented. - const isMac = getRendererPlatform() === 'darwin'; + + const tilingHandleRef = useRef(null); + + // Build ActionSets — each lives close to the code it adapts. + const actionSets = useMemo( + () => [ + new SidebarActionSet(setSidebarOpen), + new SettingsActionSet(setSettingsOpen), + new ThemeActionSet(setTheme), + new NavigationActionSet(navigate), + new WorkspaceActionSet(setWorkspaceKind), + new TilingActionSet(tilingHandleRef), + ], + [setSidebarOpen, setSettingsOpen, setTheme, navigate, setWorkspaceKind], + ); useEffect(() => { const root = document.documentElement; @@ -133,70 +167,85 @@ export default function App() { }, []); return ( - - - - {/* TODO: Replace this unsupported screen with full non-macOS UI path. */} - {!isMac ? ( -
-
- textile currently supports macOS title bar integration only. -
-
- ) : ( -
- setSidebarOpen((o) => !o)} - workspaceKind={workspaceKind} - onWorkspaceKindChange={setWorkspaceKind} + +
+ setSidebarOpen((o) => !o)} + workspaceKind={workspaceKind} + onWorkspaceKindChange={setWorkspaceKind} + /> + +
+ - -
- -
- - - } +
+ + - } /> - -
-
-
- setSettingsOpen(false)} - theme={theme} - onToggleTheme={() => - setTheme((t) => (t === 'light' ? 'dark' : 'light')) - } - workspaceKind={workspaceKind} - onWorkspaceKindChange={setWorkspaceKind} - /> + } + /> + } /> + +
- )} - - +
+ setSettingsOpen(false)} + theme={theme} + onToggleTheme={() => + setTheme((t) => (t === 'light' ? 'dark' : 'light')) + } + workspaceKind={workspaceKind} + onWorkspaceKindChange={setWorkspaceKind} + /> +
+
+ ); +} + +export default function App() { + const isMac = getRendererPlatform() === 'darwin'; + + return ( + + + + {/* TODO: Replace this unsupported screen with full non-macOS UI path. */} + {!isMac ? ( +
+
+ textile currently supports macOS title bar integration only. +
+
+ ) : ( + + )} +
+
); } diff --git a/src/actions/ActionContext.ts b/src/actions/ActionContext.ts new file mode 100644 index 0000000..07cc233 --- /dev/null +++ b/src/actions/ActionContext.ts @@ -0,0 +1,33 @@ +import { createContext, useContext } from 'react'; +import type { ActionRegistry } from './registry'; + +/** + * React context providing the action registry. + * Used by components that need to register, search, or execute actions. + */ +export interface ActionProviderValue { + /** The action registry for registering and looking up actions */ + registry: ActionRegistry; +} + +export const ActionContext = createContext(null); + +/** + * Hook to access the action registry. + * Must be used within an ActionProvider. + */ +export function useActions(): ActionProviderValue { + const ctx = useContext(ActionContext); + if (!ctx) { + throw new Error('useActions must be used within an ActionProvider'); + } + return ctx; +} + +/** + * Hook to access just the action registry. + * Must be used within an ActionProvider. + */ +export function useActionRegistry(): ActionRegistry { + return useActions().registry; +} diff --git a/src/actions/ActionProvider.tsx b/src/actions/ActionProvider.tsx new file mode 100644 index 0000000..5576f2f --- /dev/null +++ b/src/actions/ActionProvider.tsx @@ -0,0 +1,110 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import type { ActionSet, RegisteredAction } from './types'; +import type { ActionRegistry } from './registry'; +import type { HotkeyManager } from './hotkey-manager'; +import { ActionContext } from './ActionContext'; +import { CommandPalette } from './CommandPalette'; +import { getRendererPlatform } from '../lib/platform'; + +interface ActionProviderProps { + children: ReactNode; + /** ActionSets to register — each adapts a component or subsystem. */ + actionSets: ActionSet[]; + /** The action registry (created and owned by AppShell). */ + registry: ActionRegistry; + /** The hotkey manager (created and owned by AppShell). */ + hotkeyManager: HotkeyManager; +} + +/** + * Provides the action registry, hotkey manager, and command palette to the app. + * + * Accepts ActionSets and core services via dependency injection. + * AppShell creates the registry and hotkey manager; ActionProvider only + * registers/unregisters action sets and renders the command palette. + */ +export function ActionProvider({ + children, + actionSets, + registry, + hotkeyManager, +}: ActionProviderProps) { + const isMac = getRendererPlatform() === 'darwin'; + + // Command palette open state + const [paletteOpen, setPaletteOpen] = useState(false); + + // Build the full list of action sets including the internal palette set + const allActionSets = useMemo( + () => [ + ...actionSets, + { + id: 'palette', + getActions: () => [ + { + id: 'open', + name: 'Open command palette', + description: 'Search and execute commands', + hotkeys: [{ modifiers: ['Mod'], key: 'p' }], + execute: () => setPaletteOpen(true), + }, + ], + }, + ], + [actionSets], + ); + + // Register/unregister all actions from all sets + useEffect(() => { + 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); + } + } + } + } + + return () => { + for (const set of allActionSets) { + registry.unregisterAll(set.id); + hotkeyManager.removeSourceHotkeys(set.id); + } + }; + }, [allActionSets, registry, hotkeyManager]); + + const handlePaletteExecute = useCallback( + (action: RegisteredAction) => { + void registry.execute(action.id); + }, + [registry], + ); + + const availableActions = useMemo( + () => registry.getAvailable(), + [registry, paletteOpen], + ); + + const value = useMemo(() => ({ registry }), [registry]); + + return ( + + {children} + setPaletteOpen(false)} + onExecute={handlePaletteExecute} + actions={availableActions} + isMac={isMac} + /> + + ); +} diff --git a/src/actions/CommandPalette.tsx b/src/actions/CommandPalette.tsx new file mode 100644 index 0000000..861ca48 --- /dev/null +++ b/src/actions/CommandPalette.tsx @@ -0,0 +1,234 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from 'react'; +import type { RegisteredAction } from './types'; +import { formatHotkeyForDisplay } from './hotkey-utils'; + +interface CommandPaletteProps { + /** Whether the palette is open */ + isOpen: boolean; + /** Callback when the palette should close */ + onClose: () => void; + /** Callback when a command is executed */ + onExecute: (action: RegisteredAction) => void; + /** All available actions */ + actions: RegisteredAction[]; + /** Whether the platform is macOS (affects hotkey display) */ + isMac: boolean; + /** Optional placeholder text */ + placeholder?: string; +} + +/** + * Command palette modal overlay. + * + * Features: + * - Fuzzy search filtering of actions + * - Keyboard navigation (Up/Down arrows, Enter, Escape) + * - Click to execute + * - Shows command hotkey hint if available + */ +export function CommandPalette({ + isOpen, + onClose, + onExecute, + actions, + isMac, + placeholder = 'Type a command...', +}: CommandPaletteProps) { + const [query, setQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + // Filter actions by query + const filteredActions = useMemo(() => { + if (!query.trim()) return actions; + return defaultFilter(actions, query); + }, [actions, query]); + + // Reset state when opening + useEffect(() => { + if (isOpen) { + setQuery(''); + setSelectedIndex(0); + setTimeout(() => inputRef.current?.focus(), 0); + } + }, [isOpen]); + + // Stable identity for the filtered result set + const filteredKey = useMemo( + () => filteredActions.map((a) => a.id).join('\0'), + [filteredActions], + ); + + // Reset selection when results change + useEffect(() => { + setSelectedIndex(0); + }, [filteredKey]); + + // Scroll selected item into view + useEffect(() => { + if (listRef.current && filteredActions.length > 0) { + const selectedEl = listRef.current.querySelector('[aria-selected="true"]'); + if (selectedEl && typeof selectedEl.scrollIntoView === 'function') { + selectedEl.scrollIntoView({ block: 'nearest' }); + } + } + }, [selectedIndex, filteredActions.length]); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setSelectedIndex((prev) => + (prev + 1) % Math.max(filteredActions.length, 1), + ); + break; + case 'ArrowUp': + e.preventDefault(); + setSelectedIndex((prev) => + prev <= 0 + ? Math.max(filteredActions.length - 1, 0) + : prev - 1, + ); + break; + case 'Enter': + e.preventDefault(); + if (filteredActions[selectedIndex]) { + onExecute(filteredActions[selectedIndex]); + onClose(); + } + break; + case 'Escape': + e.preventDefault(); + onClose(); + break; + } + }, + [filteredActions, selectedIndex, onExecute, onClose], + ); + + const handleActionClick = useCallback( + (action: RegisteredAction) => { + onExecute(action); + onClose(); + }, + [onExecute, onClose], + ); + + if (!isOpen) return null; + + const listId = 'command-palette-list'; + const selectedId = + filteredActions.length > 0 + ? `command-palette-item-${selectedIndex}` + : undefined; + + return ( +
+
event.stopPropagation()} + > + {/* Search input */} +
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + role="combobox" + aria-controls={listId} + aria-activedescendant={selectedId} + aria-autocomplete="list" + aria-expanded={filteredActions.length > 0} + /> +
+ + {/* Results */} +
+ {filteredActions.length === 0 ? ( +
+ {query.trim() + ? 'No commands found' + : actions.length === 0 + ? 'No commands available' + : 'Type to search commands'} +
+ ) : ( +
+ {filteredActions.map((action, index) => { + const isSelected = index === selectedIndex; + const hotkeyHint = action.hotkeys?.[0] + ? formatHotkeyForDisplay(action.hotkeys[0], isMac) + : undefined; + + return ( +
handleActionClick(action)} + onMouseEnter={() => setSelectedIndex(index)} + > + {action.name} + {hotkeyHint ? ( + + {hotkeyHint} + + ) : null} +
+ ); + })} +
+ )} +
+
+
+ ); +} + +/** + * Default filter when no external search is provided. + * All query words must appear in the action name or description (case-insensitive). + */ +function defaultFilter( + actions: RegisteredAction[], + query: string, +): RegisteredAction[] { + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + + return actions.filter((action) => { + const haystack = ( + action.name + + ' ' + + (action.description ?? '') + ).toLowerCase(); + return words.every((word) => haystack.includes(word)); + }); +} diff --git a/src/actions/hotkey-manager.ts b/src/actions/hotkey-manager.ts new file mode 100644 index 0000000..56275b8 --- /dev/null +++ b/src/actions/hotkey-manager.ts @@ -0,0 +1,234 @@ +import type { Hotkey } from './types'; +import type { ActionRegistry } from './registry'; +import { normalizeHotkey, hotkeyToString, eventToHotkey } from './hotkey-utils'; + +/** + * Information about a hotkey conflict. + * Occurs when an action tries to register a hotkey already bound to another action. + */ +export interface HotkeyConflict { + /** The hotkey string (e.g. "Ctrl+P") */ + hotkeyString: string; + /** The action ID that was rejected (tried to register but lost) */ + rejectedActionId: string; + /** The action ID that currently holds the binding (winner) */ + existingActionId: string; +} + +/** + * Manages global keyboard shortcuts for actions. + * + * Features: + * - Platform-aware Mod key expansion (Cmd on Mac, Ctrl elsewhere) + * - Conflict detection when registering overlapping hotkeys + * - Error isolation — callback errors don't crash the app + * - Automatic cleanup on destroy + */ +export class HotkeyManager { + /** Map of normalized hotkey string -> action ID */ + private bindings = new Map(); + /** Map of action ID -> normalized hotkeys */ + private actionToHotkeys = new Map(); + /** Tracked conflicts for display in settings UI */ + private conflicts: HotkeyConflict[] = []; + /** Whether we're on Mac (affects Mod expansion) */ + private readonly isMac: boolean; + /** Reference to action registry for callback lookup */ + private readonly registry: ActionRegistry; + /** Bound event handler for cleanup */ + private readonly handleKeyDown: (e: KeyboardEvent) => void; + /** Whether the keydown listener is currently attached */ + private _attached = false; + /** Unsubscribe from registry action-removal notifications */ + private readonly unsubscribeActionRemoved: () => void; + /** Callback invoked when a hotkey matches an action */ + private readonly executeAction: (actionId: string) => void; + + constructor( + registry: ActionRegistry, + isMac: boolean, + executeAction: (actionId: string) => void, + ) { + this.registry = registry; + this.isMac = isMac; + this.executeAction = executeAction; + + this.handleKeyDown = this.onKeyDown.bind(this); + this.unsubscribeActionRemoved = registry.onActionRemoved((actionId) => { + this.removeHotkey(actionId); + }); + window.addEventListener('keydown', this.handleKeyDown); + this._attached = true; + } + + /** + * Register a hotkey for an action. + * + * @returns The action ID of a conflicting binding, or null if no conflict + */ + registerHotkey(actionId: string, hotkey: Hotkey): string | null { + const normalized = normalizeHotkey(hotkey, this.isMac); + const key = hotkeyToString(normalized); + + // Check for conflict + const existing = this.bindings.get(key); + if (existing && existing !== actionId) { + // Track the conflict for display in settings + if ( + !this.conflicts.some( + (conflict) => + conflict.hotkeyString === key && + conflict.rejectedActionId === actionId && + conflict.existingActionId === existing, + ) + ) { + this.conflicts.push({ + hotkeyString: key, + rejectedActionId: actionId, + existingActionId: existing, + }); + } + return existing; + } + + const existingHotkeys = this.actionToHotkeys.get(actionId) ?? []; + if (existingHotkeys.some((h) => hotkeyToString(h) === key)) { + return null; + } + + this.bindings.set(key, actionId); + this.actionToHotkeys.set(actionId, [...existingHotkeys, normalized]); + + return null; + } + + /** + * Remove a hotkey binding for an action. + * Also clears any conflicts where this action was either the winner or the loser. + */ + removeHotkey(actionId: string): void { + const hotkeys = this.actionToHotkeys.get(actionId); + if (hotkeys) { + for (const hotkey of hotkeys) { + const key = hotkeyToString(hotkey); + this.bindings.delete(key); + } + this.actionToHotkeys.delete(actionId); + } + // Always clear conflicts where this action was involved + this.conflicts = this.conflicts.filter( + (c) => c.existingActionId !== actionId && c.rejectedActionId !== actionId, + ); + } + + /** + * Remove all hotkey bindings for a source (e.g. plugin). + */ + removeSourceHotkeys(source: string): void { + const toRemove: string[] = []; + for (const actionId of this.actionToHotkeys.keys()) { + if (actionId.startsWith(source + ':')) { + toRemove.push(actionId); + } + } + for (const actionId of toRemove) { + this.removeHotkey(actionId); + } + } + + /** + * Get the hotkey registered for an action. + */ + getHotkeyForAction(actionId: string): Hotkey | undefined { + return this.actionToHotkeys.get(actionId)?.[0]; + } + + /** + * Get all current hotkey bindings. + */ + getAllBindings(): Map { + return new Map(this.bindings); + } + + /** + * Get all tracked hotkey conflicts. + */ + getConflicts(): HotkeyConflict[] { + return [...this.conflicts]; + } + + /** + * Detach the keydown listener. Call reattach() to restore it. + * Does NOT clear bindings — they survive detach/reattach cycles + * (important for React StrictMode unmount/remount). + */ + detach(): void { + if (!this._attached) return; + window.removeEventListener('keydown', this.handleKeyDown); + this._attached = false; + } + + /** + * Re-attach the keydown listener after detach(). + * Idempotent — safe to call if already attached. + */ + reattach(): void { + if (this._attached) return; + window.addEventListener('keydown', this.handleKeyDown); + this._attached = true; + } + + /** + * Permanently clean up. Removes listener and clears all bindings. + * After destroy(), the instance cannot be reattached. + */ + destroy(): void { + window.removeEventListener('keydown', this.handleKeyDown); + this._attached = false; + this.unsubscribeActionRemoved(); + this.bindings.clear(); + this.actionToHotkeys.clear(); + this.conflicts = []; + } + + /** Keys that are modifiers themselves — skip these to avoid spurious lookups */ + private static readonly MODIFIER_KEYS = new Set([ + 'Control', + 'Shift', + 'Alt', + 'Meta', + ]); + + /** + * Handle keydown events and dispatch to matching actions. + */ + private onKeyDown(event: KeyboardEvent): void { + // Skip modifier-only keypresses (e.g. pressing just Shift) + if (HotkeyManager.MODIFIER_KEYS.has(event.key)) return; + + // Ignore events when typing in inputs (unless it's a modifier combo) + const target = event.target as HTMLElement | null; + if (target && target.tagName) { + const tagName = target.tagName.toLowerCase(); + const isInput = + tagName === 'input' || tagName === 'textarea' || target.isContentEditable; + if (isInput && !event.ctrlKey && !event.metaKey && !event.altKey) { + return; + } + } + + const hotkey = eventToHotkey(event); + const key = hotkeyToString(hotkey); + const actionId = this.bindings.get(key); + + if (!actionId) return; + + const action = this.registry.get(actionId); + if (!action) return; + + event.preventDefault(); + event.stopImmediatePropagation(); + + this.executeAction(actionId); + } +} diff --git a/src/actions/hotkey-utils.ts b/src/actions/hotkey-utils.ts new file mode 100644 index 0000000..ca45c36 --- /dev/null +++ b/src/actions/hotkey-utils.ts @@ -0,0 +1,105 @@ +/** Modifier sort order for consistent string representation. */ +const MODIFIER_ORDER: Modifier[] = ['Ctrl', 'Meta', 'Alt', 'Shift']; + +/** Known modifier names for validation. */ +const KNOWN_MODIFIERS = new Set(['Ctrl', 'Meta', 'Alt', 'Shift', 'Mod']); + +import type { Modifier, Hotkey } from './types'; + +/** + * Detect if the current platform is macOS. + */ +export function isMacPlatform(): boolean { + if (typeof navigator === 'undefined') return false; + return /Mac|iPod|iPhone|iPad/.test(navigator.platform); +} + +/** + * Normalize a hotkey by expanding 'Mod' to the platform-specific modifier. + * Also lowercases the key for consistent matching. + */ +export function normalizeHotkey(hotkey: Hotkey, isMac: boolean): Hotkey { + const modifiers: Modifier[] = hotkey.modifiers.map((mod) => { + if (mod === 'Mod') { + return isMac ? 'Meta' : 'Ctrl'; + } + return mod; + }); + + return { + modifiers, + key: hotkey.key.toLowerCase(), + }; +} + +/** + * Convert a hotkey to a consistent string representation. + * Modifiers are sorted consistently and key is uppercased. + * Used as a lookup key in the hotkey registry. + */ +export function hotkeyToString(hotkey: Hotkey): string { + const sortedMods = [...hotkey.modifiers].sort( + (a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b), + ); + const parts = [...sortedMods, hotkey.key.toUpperCase()]; + return parts.join('+'); +} + +/** + * Parse a hotkey string back into a Hotkey object. + * E.g., "Ctrl+Shift+P" -> { modifiers: ['Ctrl', 'Shift'], key: 'p' } + * Unknown modifiers are silently dropped. + */ +export function parseHotkeyString(str: string): Hotkey { + const parts = str.split('+'); + const key = parts.pop()!.toLowerCase(); + const modifiers = parts.filter((p) => KNOWN_MODIFIERS.has(p)) as Modifier[]; + return { modifiers, key }; +} + +/** + * Convert a keyboard event to a Hotkey object. + */ +export function eventToHotkey(event: KeyboardEvent): Hotkey { + const modifiers: Modifier[] = []; + if (event.ctrlKey) modifiers.push('Ctrl'); + if (event.metaKey) modifiers.push('Meta'); + if (event.altKey) modifiers.push('Alt'); + if (event.shiftKey) modifiers.push('Shift'); + + return { + modifiers, + key: event.key.toLowerCase(), + }; +} + +/** + * Format a hotkey for display to the user. + * Uses platform-appropriate symbols (e.g., Command on Mac, Ctrl on Windows). + */ +export function formatHotkeyForDisplay(hotkey: Hotkey, isMac: boolean): string { + const symbols: Record = isMac + ? { + Ctrl: '^', + Meta: '\u2318', + Alt: '\u2325', + Shift: '\u21E7', + Mod: '\u2318', + } + : { + Ctrl: 'Ctrl', + Meta: 'Win', + Alt: 'Alt', + Shift: 'Shift', + Mod: 'Ctrl', + }; + + const mods = hotkey.modifiers.map((m) => symbols[m] ?? m); + const key = hotkey.key.toUpperCase(); + + if (isMac) { + return mods.join('') + key; + } else { + return [...mods, key].join('+'); + } +} diff --git a/src/actions/index.ts b/src/actions/index.ts new file mode 100644 index 0000000..b192977 --- /dev/null +++ b/src/actions/index.ts @@ -0,0 +1,28 @@ +export type { + ActionDefinition, + RegisteredAction, + ActionSet, + Hotkey, + Modifier, +} from './types'; + +export { ActionRegistry } from './registry'; +export { HotkeyManager, type HotkeyConflict } from './hotkey-manager'; +export { + normalizeHotkey, + hotkeyToString, + parseHotkeyString, + eventToHotkey, + formatHotkeyForDisplay, + isMacPlatform, +} from './hotkey-utils'; +export { CommandPalette } from './CommandPalette'; +export { ActionProvider } from './ActionProvider'; +export { ActionContext, useActions, useActionRegistry } from './ActionContext'; +export { createActionServices } from './services'; +export { SidebarActionSet } from './sets/sidebarActionSet'; +export { SettingsActionSet } from './sets/settingsActionSet'; +export { ThemeActionSet } from './sets/themeActionSet'; +export { NavigationActionSet } from './sets/navigationActionSet'; +export { WorkspaceActionSet } from './sets/workspaceActionSet'; +export { TilingActionSet, type TilingHandle } from './sets/tilingActionSet'; diff --git a/src/actions/registry.ts b/src/actions/registry.ts new file mode 100644 index 0000000..4a8de42 --- /dev/null +++ b/src/actions/registry.ts @@ -0,0 +1,174 @@ +import type { ActionDefinition, RegisteredAction } from './types'; + +/** + * Central registry for all actions across core and plugins. + * + * Actions are stored with full IDs (source:action-id) to avoid collisions. + * Provides search/filter functionality for the command palette. + */ +export class ActionRegistry { + private actions = new Map(); + private removalListeners = new Set<(id: string) => void>(); + + /** + * Register an action from a source (e.g. 'core' or a plugin ID). + * The action ID is prefixed with the source. + * + * @returns The registered action with prefixed ID + */ + register(source: string, def: ActionDefinition): RegisteredAction { + const fullId = `${source}:${def.id}`; + const registered: RegisteredAction = { + ...def, + id: fullId, + source, + }; + this.actions.set(fullId, registered); + return registered; + } + + /** + * Remove an action by its full ID. + */ + unregister(id: string): boolean { + const removed = this.actions.delete(id); + if (removed) { + for (const listener of this.removalListeners) { + listener(id); + } + } + return removed; + } + + /** + * Remove all actions from a specific source. + */ + unregisterAll(source: string): void { + const toRemove: string[] = []; + for (const [id, action] of this.actions) { + if (action.source === source) { + toRemove.push(id); + } + } + for (const id of toRemove) { + this.unregister(id); + } + } + + /** + * Get a single action by full ID. + */ + get(id: string): RegisteredAction | undefined { + return this.actions.get(id); + } + + /** + * Get all registered actions. + */ + getAll(): RegisteredAction[] { + return Array.from(this.actions.values()); + } + + /** + * Subscribe to action removal events. + * Returns an unsubscribe function. + */ + onActionRemoved(listener: (id: string) => void): () => void { + this.removalListeners.add(listener); + return () => { + this.removalListeners.delete(listener); + }; + } + + /** + * Check if an action is currently available. + */ + isAvailable(action: RegisteredAction): boolean { + if (!action.checkAvailable) return true; + try { + return action.checkAvailable(); + } catch { + return false; + } + } + + /** + * Get all actions that are currently available. + */ + getAvailable(): RegisteredAction[] { + return this.getAll().filter((action) => this.isAvailable(action)); + } + + /** + * Execute an action by its full ID. + * @returns true if found and executed, false otherwise + */ + async execute(id: string): Promise { + const action = this.actions.get(id); + if (!action) return false; + + if (!this.isAvailable(action)) return false; + + try { + const result = action.execute(); + if (result && typeof (result as PromiseLike).then === 'function') { + await result; + } + return true; + } catch (error) { + console.error(`Error executing action '${id}':`, error); + return false; + } + } + + /** + * Search actions by name using fuzzy matching. + * Results are filtered by availability and sorted by match quality. + * + * @param query - Search query + */ + search(query: string): RegisteredAction[] { + const available = this.getAvailable(); + + if (!query.trim()) { + return available; + } + + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + const matches: Array<{ action: RegisteredAction; score: number }> = []; + + for (const action of available) { + const name = action.name.toLowerCase(); + const desc = (action.description ?? '').toLowerCase(); + const haystack = name + ' ' + desc; + + // All query words must appear somewhere + const allMatch = words.every((word) => haystack.includes(word)); + if (!allMatch) continue; + + // Scoring: name prefix > name word boundary > name contains > desc contains + let score = 0; + for (const word of words) { + if (name.startsWith(word)) { + score += 4; + } else if (name.includes(' ' + word)) { + score += 3; + } else if (name.includes(word)) { + score += 2; + } else { + score += 1; + } + } + + matches.push({ action, score }); + } + + // Sort by score descending, then by name alphabetically + matches.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.action.name.localeCompare(b.action.name); + }); + + return matches.map((m) => m.action); + } +} diff --git a/src/actions/services.ts b/src/actions/services.ts new file mode 100644 index 0000000..d728189 --- /dev/null +++ b/src/actions/services.ts @@ -0,0 +1,24 @@ +import { ActionRegistry } from './registry'; +import { HotkeyManager } from './hotkey-manager'; + +/** + * Create the core action system services. + * + * Call this once at app startup (e.g. in AppShell via useMemo) and pass + * the returned services into ActionProvider. + * + * @param isMac - Whether the renderer is on macOS (determines Mod expansion) + */ +export function createActionServices(isMac: boolean) { + const registry = new ActionRegistry(); + + const hotkeyManager = new HotkeyManager( + registry, + isMac, + (actionId) => { + void registry.execute(actionId); + }, + ); + + return { registry, hotkeyManager }; +} diff --git a/src/actions/sets/navigationActionSet.ts b/src/actions/sets/navigationActionSet.ts new file mode 100644 index 0000000..4d079d4 --- /dev/null +++ b/src/actions/sets/navigationActionSet.ts @@ -0,0 +1,22 @@ +import type { NavigateFunction } from 'react-router-dom'; +import type { ActionSet, ActionDefinition } from '../types'; + +/** + * ActionSet for navigation actions. + */ +export class NavigationActionSet implements ActionSet { + readonly id = 'navigation'; + + constructor(private navigate: NavigateFunction) {} + + getActions(): ActionDefinition[] { + return [ + { + id: 'about', + name: 'Open about page', + description: 'Navigate to the about page', + execute: () => this.navigate('/about'), + }, + ]; + } +} diff --git a/src/actions/sets/settingsActionSet.ts b/src/actions/sets/settingsActionSet.ts new file mode 100644 index 0000000..1b69d15 --- /dev/null +++ b/src/actions/sets/settingsActionSet.ts @@ -0,0 +1,22 @@ +import type { ActionSet, ActionDefinition } from '../types'; + +/** + * ActionSet for settings modal actions. + */ +export class SettingsActionSet implements ActionSet { + readonly id = 'settings'; + + constructor(private setSettingsOpen: (open: boolean) => void) {} + + getActions(): ActionDefinition[] { + return [ + { + id: 'open', + name: 'Open settings', + description: 'Show the settings modal', + hotkeys: [{ modifiers: ['Mod'], key: ',' }], + execute: () => this.setSettingsOpen(true), + }, + ]; + } +} diff --git a/src/actions/sets/sidebarActionSet.ts b/src/actions/sets/sidebarActionSet.ts new file mode 100644 index 0000000..8a251c1 --- /dev/null +++ b/src/actions/sets/sidebarActionSet.ts @@ -0,0 +1,24 @@ +import type { ActionSet, ActionDefinition } from '../types'; + +/** + * ActionSet for sidebar chrome actions. + */ +export class SidebarActionSet implements ActionSet { + readonly id = 'sidebar'; + + constructor( + private setSidebarOpen: (open: boolean | ((prev: boolean) => boolean)) => void, + ) {} + + getActions(): ActionDefinition[] { + return [ + { + id: 'toggle', + name: 'Toggle sidebar', + description: 'Show or hide the file sidebar', + hotkeys: [{ modifiers: ['Mod'], key: 'b' }], + execute: () => this.setSidebarOpen((prev) => !prev), + }, + ]; + } +} diff --git a/src/actions/sets/themeActionSet.ts b/src/actions/sets/themeActionSet.ts new file mode 100644 index 0000000..16455c0 --- /dev/null +++ b/src/actions/sets/themeActionSet.ts @@ -0,0 +1,24 @@ +import type { ActionSet, ActionDefinition } from '../types'; + +/** + * ActionSet for theme switching actions. + */ +export class ThemeActionSet implements ActionSet { + readonly id = 'theme'; + + constructor( + private setTheme: (theme: 'light' | 'dark' | ((prev: 'light' | 'dark') => 'light' | 'dark')) => void, + ) {} + + getActions(): ActionDefinition[] { + return [ + { + id: 'toggle', + name: 'Toggle theme', + description: 'Switch between light and dark mode', + execute: () => + this.setTheme((prev) => (prev === 'light' ? 'dark' : 'light')), + }, + ]; + } +} diff --git a/src/actions/sets/tilingActionSet.ts b/src/actions/sets/tilingActionSet.ts new file mode 100644 index 0000000..2208805 --- /dev/null +++ b/src/actions/sets/tilingActionSet.ts @@ -0,0 +1,43 @@ +import type { MutableRefObject } from 'react'; +import type { ActionSet, ActionDefinition } from '../types'; + +/** + * Bridge type that TilingWorkspace writes into and the ActionSet reads from. + */ +export interface TilingHandle { + splitRight(): void; + splitDown(): void; +} + +/** + * ActionSet for tiling workspace actions. + * + * The handle is populated by TilingWorkspace via a ref bridge so the + * ActionSet can trigger splits without owning tiling state. + */ +export class TilingActionSet implements ActionSet { + readonly id = 'tiling'; + + constructor( + private handleRef: MutableRefObject, + ) {} + + getActions(): ActionDefinition[] { + return [ + { + id: 'split-right', + name: 'Split view to the right', + description: 'Split the active tile vertically', + checkAvailable: () => this.handleRef.current !== null, + execute: () => this.handleRef.current?.splitRight(), + }, + { + id: 'split-down', + name: 'Split view down', + description: 'Split the active tile horizontally', + checkAvailable: () => this.handleRef.current !== null, + execute: () => this.handleRef.current?.splitDown(), + }, + ]; + } +} diff --git a/src/actions/sets/workspaceActionSet.ts b/src/actions/sets/workspaceActionSet.ts new file mode 100644 index 0000000..5920110 --- /dev/null +++ b/src/actions/sets/workspaceActionSet.ts @@ -0,0 +1,28 @@ +import type { ActionSet, ActionDefinition } from '../types'; +import type { WorkspaceKind } from '../../workspaces/workspace'; + +/** + * ActionSet for workspace switching actions. + */ +export class WorkspaceActionSet implements ActionSet { + readonly id = 'workspace'; + + constructor(private setWorkspaceKind: (kind: WorkspaceKind) => void) {} + + getActions(): ActionDefinition[] { + return [ + { + id: 'tiling', + name: 'Switch to tiling workspace', + description: 'Arrange documents in split panes', + execute: () => this.setWorkspaceKind('tiling'), + }, + { + id: 'zen', + name: 'Switch to zen workspace', + description: 'Focus on a single document', + execute: () => this.setWorkspaceKind('zen'), + }, + ]; + } +} diff --git a/src/actions/types.ts b/src/actions/types.ts new file mode 100644 index 0000000..462df65 --- /dev/null +++ b/src/actions/types.ts @@ -0,0 +1,54 @@ +/** Platform-agnostic modifier. 'Mod' expands to Meta (macOS) or Ctrl (others). */ +export type Modifier = 'Mod' | 'Ctrl' | 'Meta' | 'Alt' | 'Shift'; + +export interface Hotkey { + modifiers: Modifier[]; + key: string; +} + +/** + * Definition of an action as provided by core or a plugin. + * + * Actions are self-contained closures over the state they manipulate. + * They do not receive an app-wide context object; instead, each ActionSet + * creates actions that close over the specific state/setters they need. + */ +export interface ActionDefinition { + /** Unique ID within the source namespace (e.g. 'sidebar-toggle'). */ + id: string; + /** Human-readable name for the command palette search. */ + name: string; + /** Short description (shown in palette details / settings). */ + description?: string; + /** Icon identifier for toolbar rendering. */ + icon?: string; + /** Default hotkeys (user-remappable in future settings). */ + hotkeys?: Hotkey[]; + /** Whether holding the hotkey should repeatedly trigger this command. */ + repeatable?: boolean; + /** + * Optional availability check. Return false to hide from palette + * and skip execution. + */ + checkAvailable?(): boolean; + /** Execute the action. */ + execute(): void | Promise; +} + +/** Internal representation after registration (includes source tag). */ +export interface RegisteredAction extends ActionDefinition { + /** 'core' or a plugin ID. */ + source: string; +} + +/** + * A set of actions that adapts a specific component or subsystem to the + * action registry. ActionSets are created close to the code they adapt and + * dependency-injected into ActionProvider. + */ +export interface ActionSet { + /** Unique source ID for this set (used as action source prefix). */ + id: string; + /** Get all action definitions from this set. */ + getActions(): ActionDefinition[]; +} diff --git a/src/workspaces/tiling/tiling.tsx b/src/workspaces/tiling/tiling.tsx index 3d00a98..71f01ce 100644 --- a/src/workspaces/tiling/tiling.tsx +++ b/src/workspaces/tiling/tiling.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useMemo, useRef, useState } from 'react'; +import { ReactNode, useEffect, useRef, useState } from 'react'; import { DocumentSlotView } from '../../editors/DocumentSlotView'; import type { DocumentSlotState } from '../../documents/useDocumentSlot'; @@ -16,6 +16,7 @@ import { useResizeObserver } from '../../tiling/useResizeObserver'; import { WorkspaceBarSlot } from '../barSlot'; import type { WorkspaceProps } from '../workspace'; import { useTilingDocumentSlots } from './useTilingDocumentSlots'; +import type { TilingHandle } from '../../actions'; type SplitterProps = { node: SplitNode; @@ -145,6 +146,7 @@ export function TilingWorkspace({ openRequestId, openRequest, resolveProvider, + tilingHandleRef, }: WorkspaceProps) { const nextIdRef = useRef(2); const makeId = (prefix: string) => `${prefix}-${nextIdRef.current++}`; @@ -161,14 +163,6 @@ export function TilingWorkspace({ activeTileId, }); - const commandIds = useMemo( - () => ({ - splitRight: 'workspace.splitActiveRight', - splitDown: 'workspace.splitActiveDown', - }), - [], - ); - const applySplit = (orientation: SplitOrientation) => { const splitId = makeId('split'); const tileId = makeId('tile'); @@ -182,6 +176,23 @@ export function TilingWorkspace({ ensureTile(result.newTileId); }; + // Keep a stable ref to the latest applySplit so the imperative handle + // never goes stale even as tree / activeTileId change. + const applySplitRef = useRef(applySplit); + applySplitRef.current = applySplit; + + useEffect(() => { + if (!tilingHandleRef) return; + const handle: TilingHandle = { + splitRight: () => applySplitRef.current('row'), + splitDown: () => applySplitRef.current('column'), + }; + tilingHandleRef.current = handle; + return () => { + tilingHandleRef.current = null; + }; + }, [tilingHandleRef]); + const renderNode = (node: TilingNode): ReactNode => { if (node.kind === 'tile') { return ( @@ -219,7 +230,7 @@ export function TilingWorkspace({