···11+import { createContext, useContext } from 'react';
22+import type { ActionRegistry } from './registry';
33+44+/**
55+ * React context providing the action registry.
66+ * Used by components that need to register, search, or execute actions.
77+ */
88+export interface ActionProviderValue {
99+ /** The action registry for registering and looking up actions */
1010+ registry: ActionRegistry;
1111+}
1212+1313+export const ActionContext = createContext<ActionProviderValue | null>(null);
1414+1515+/**
1616+ * Hook to access the action registry.
1717+ * Must be used within an ActionProvider.
1818+ */
1919+export function useActions(): ActionProviderValue {
2020+ const ctx = useContext(ActionContext);
2121+ if (!ctx) {
2222+ throw new Error('useActions must be used within an ActionProvider');
2323+ }
2424+ return ctx;
2525+}
2626+2727+/**
2828+ * Hook to access just the action registry.
2929+ * Must be used within an ActionProvider.
3030+ */
3131+export function useActionRegistry(): ActionRegistry {
3232+ return useActions().registry;
3333+}
+110
src/actions/ActionProvider.tsx
···11+import {
22+ useCallback,
33+ useEffect,
44+ useMemo,
55+ useState,
66+ type ReactNode,
77+} from 'react';
88+import type { ActionSet, RegisteredAction } from './types';
99+import type { ActionRegistry } from './registry';
1010+import type { HotkeyManager } from './hotkey-manager';
1111+import { ActionContext } from './ActionContext';
1212+import { CommandPalette } from './CommandPalette';
1313+import { getRendererPlatform } from '../lib/platform';
1414+1515+interface ActionProviderProps {
1616+ children: ReactNode;
1717+ /** ActionSets to register — each adapts a component or subsystem. */
1818+ actionSets: ActionSet[];
1919+ /** The action registry (created and owned by AppShell). */
2020+ registry: ActionRegistry;
2121+ /** The hotkey manager (created and owned by AppShell). */
2222+ hotkeyManager: HotkeyManager;
2323+}
2424+2525+/**
2626+ * Provides the action registry, hotkey manager, and command palette to the app.
2727+ *
2828+ * Accepts ActionSets and core services via dependency injection.
2929+ * AppShell creates the registry and hotkey manager; ActionProvider only
3030+ * registers/unregisters action sets and renders the command palette.
3131+ */
3232+export function ActionProvider({
3333+ children,
3434+ actionSets,
3535+ registry,
3636+ hotkeyManager,
3737+}: ActionProviderProps) {
3838+ const isMac = getRendererPlatform() === 'darwin';
3939+4040+ // Command palette open state
4141+ const [paletteOpen, setPaletteOpen] = useState(false);
4242+4343+ // Build the full list of action sets including the internal palette set
4444+ const allActionSets = useMemo<ActionSet[]>(
4545+ () => [
4646+ ...actionSets,
4747+ {
4848+ id: 'palette',
4949+ getActions: () => [
5050+ {
5151+ id: 'open',
5252+ name: 'Open command palette',
5353+ description: 'Search and execute commands',
5454+ hotkeys: [{ modifiers: ['Mod'], key: 'p' }],
5555+ execute: () => setPaletteOpen(true),
5656+ },
5757+ ],
5858+ },
5959+ ],
6060+ [actionSets],
6161+ );
6262+6363+ // Register/unregister all actions from all sets
6464+ useEffect(() => {
6565+ for (const set of allActionSets) {
6666+ for (const def of set.getActions()) {
6767+ const registered = registry.register(set.id, def);
6868+ if (registered.hotkeys) {
6969+ for (const hotkey of registered.hotkeys) {
7070+ hotkeyManager.registerHotkey(registered.id, hotkey);
7171+ }
7272+ }
7373+ }
7474+ }
7575+7676+ return () => {
7777+ for (const set of allActionSets) {
7878+ registry.unregisterAll(set.id);
7979+ hotkeyManager.removeSourceHotkeys(set.id);
8080+ }
8181+ };
8282+ }, [allActionSets, registry, hotkeyManager]);
8383+8484+ const handlePaletteExecute = useCallback(
8585+ (action: RegisteredAction) => {
8686+ void registry.execute(action.id);
8787+ },
8888+ [registry],
8989+ );
9090+9191+ const availableActions = useMemo(
9292+ () => registry.getAvailable(),
9393+ [registry, paletteOpen],
9494+ );
9595+9696+ const value = useMemo(() => ({ registry }), [registry]);
9797+9898+ return (
9999+ <ActionContext.Provider value={value}>
100100+ {children}
101101+ <CommandPalette
102102+ isOpen={paletteOpen}
103103+ onClose={() => setPaletteOpen(false)}
104104+ onExecute={handlePaletteExecute}
105105+ actions={availableActions}
106106+ isMac={isMac}
107107+ />
108108+ </ActionContext.Provider>
109109+ );
110110+}
···11+import type { Hotkey } from './types';
22+import type { ActionRegistry } from './registry';
33+import { normalizeHotkey, hotkeyToString, eventToHotkey } from './hotkey-utils';
44+55+/**
66+ * Information about a hotkey conflict.
77+ * Occurs when an action tries to register a hotkey already bound to another action.
88+ */
99+export interface HotkeyConflict {
1010+ /** The hotkey string (e.g. "Ctrl+P") */
1111+ hotkeyString: string;
1212+ /** The action ID that was rejected (tried to register but lost) */
1313+ rejectedActionId: string;
1414+ /** The action ID that currently holds the binding (winner) */
1515+ existingActionId: string;
1616+}
1717+1818+/**
1919+ * Manages global keyboard shortcuts for actions.
2020+ *
2121+ * Features:
2222+ * - Platform-aware Mod key expansion (Cmd on Mac, Ctrl elsewhere)
2323+ * - Conflict detection when registering overlapping hotkeys
2424+ * - Error isolation — callback errors don't crash the app
2525+ * - Automatic cleanup on destroy
2626+ */
2727+export class HotkeyManager {
2828+ /** Map of normalized hotkey string -> action ID */
2929+ private bindings = new Map<string, string>();
3030+ /** Map of action ID -> normalized hotkeys */
3131+ private actionToHotkeys = new Map<string, Hotkey[]>();
3232+ /** Tracked conflicts for display in settings UI */
3333+ private conflicts: HotkeyConflict[] = [];
3434+ /** Whether we're on Mac (affects Mod expansion) */
3535+ private readonly isMac: boolean;
3636+ /** Reference to action registry for callback lookup */
3737+ private readonly registry: ActionRegistry;
3838+ /** Bound event handler for cleanup */
3939+ private readonly handleKeyDown: (e: KeyboardEvent) => void;
4040+ /** Whether the keydown listener is currently attached */
4141+ private _attached = false;
4242+ /** Unsubscribe from registry action-removal notifications */
4343+ private readonly unsubscribeActionRemoved: () => void;
4444+ /** Callback invoked when a hotkey matches an action */
4545+ private readonly executeAction: (actionId: string) => void;
4646+4747+ constructor(
4848+ registry: ActionRegistry,
4949+ isMac: boolean,
5050+ executeAction: (actionId: string) => void,
5151+ ) {
5252+ this.registry = registry;
5353+ this.isMac = isMac;
5454+ this.executeAction = executeAction;
5555+5656+ this.handleKeyDown = this.onKeyDown.bind(this);
5757+ this.unsubscribeActionRemoved = registry.onActionRemoved((actionId) => {
5858+ this.removeHotkey(actionId);
5959+ });
6060+ window.addEventListener('keydown', this.handleKeyDown);
6161+ this._attached = true;
6262+ }
6363+6464+ /**
6565+ * Register a hotkey for an action.
6666+ *
6767+ * @returns The action ID of a conflicting binding, or null if no conflict
6868+ */
6969+ registerHotkey(actionId: string, hotkey: Hotkey): string | null {
7070+ const normalized = normalizeHotkey(hotkey, this.isMac);
7171+ const key = hotkeyToString(normalized);
7272+7373+ // Check for conflict
7474+ const existing = this.bindings.get(key);
7575+ if (existing && existing !== actionId) {
7676+ // Track the conflict for display in settings
7777+ if (
7878+ !this.conflicts.some(
7979+ (conflict) =>
8080+ conflict.hotkeyString === key &&
8181+ conflict.rejectedActionId === actionId &&
8282+ conflict.existingActionId === existing,
8383+ )
8484+ ) {
8585+ this.conflicts.push({
8686+ hotkeyString: key,
8787+ rejectedActionId: actionId,
8888+ existingActionId: existing,
8989+ });
9090+ }
9191+ return existing;
9292+ }
9393+9494+ const existingHotkeys = this.actionToHotkeys.get(actionId) ?? [];
9595+ if (existingHotkeys.some((h) => hotkeyToString(h) === key)) {
9696+ return null;
9797+ }
9898+9999+ this.bindings.set(key, actionId);
100100+ this.actionToHotkeys.set(actionId, [...existingHotkeys, normalized]);
101101+102102+ return null;
103103+ }
104104+105105+ /**
106106+ * Remove a hotkey binding for an action.
107107+ * Also clears any conflicts where this action was either the winner or the loser.
108108+ */
109109+ removeHotkey(actionId: string): void {
110110+ const hotkeys = this.actionToHotkeys.get(actionId);
111111+ if (hotkeys) {
112112+ for (const hotkey of hotkeys) {
113113+ const key = hotkeyToString(hotkey);
114114+ this.bindings.delete(key);
115115+ }
116116+ this.actionToHotkeys.delete(actionId);
117117+ }
118118+ // Always clear conflicts where this action was involved
119119+ this.conflicts = this.conflicts.filter(
120120+ (c) => c.existingActionId !== actionId && c.rejectedActionId !== actionId,
121121+ );
122122+ }
123123+124124+ /**
125125+ * Remove all hotkey bindings for a source (e.g. plugin).
126126+ */
127127+ removeSourceHotkeys(source: string): void {
128128+ const toRemove: string[] = [];
129129+ for (const actionId of this.actionToHotkeys.keys()) {
130130+ if (actionId.startsWith(source + ':')) {
131131+ toRemove.push(actionId);
132132+ }
133133+ }
134134+ for (const actionId of toRemove) {
135135+ this.removeHotkey(actionId);
136136+ }
137137+ }
138138+139139+ /**
140140+ * Get the hotkey registered for an action.
141141+ */
142142+ getHotkeyForAction(actionId: string): Hotkey | undefined {
143143+ return this.actionToHotkeys.get(actionId)?.[0];
144144+ }
145145+146146+ /**
147147+ * Get all current hotkey bindings.
148148+ */
149149+ getAllBindings(): Map<string, string> {
150150+ return new Map(this.bindings);
151151+ }
152152+153153+ /**
154154+ * Get all tracked hotkey conflicts.
155155+ */
156156+ getConflicts(): HotkeyConflict[] {
157157+ return [...this.conflicts];
158158+ }
159159+160160+ /**
161161+ * Detach the keydown listener. Call reattach() to restore it.
162162+ * Does NOT clear bindings — they survive detach/reattach cycles
163163+ * (important for React StrictMode unmount/remount).
164164+ */
165165+ detach(): void {
166166+ if (!this._attached) return;
167167+ window.removeEventListener('keydown', this.handleKeyDown);
168168+ this._attached = false;
169169+ }
170170+171171+ /**
172172+ * Re-attach the keydown listener after detach().
173173+ * Idempotent — safe to call if already attached.
174174+ */
175175+ reattach(): void {
176176+ if (this._attached) return;
177177+ window.addEventListener('keydown', this.handleKeyDown);
178178+ this._attached = true;
179179+ }
180180+181181+ /**
182182+ * Permanently clean up. Removes listener and clears all bindings.
183183+ * After destroy(), the instance cannot be reattached.
184184+ */
185185+ destroy(): void {
186186+ window.removeEventListener('keydown', this.handleKeyDown);
187187+ this._attached = false;
188188+ this.unsubscribeActionRemoved();
189189+ this.bindings.clear();
190190+ this.actionToHotkeys.clear();
191191+ this.conflicts = [];
192192+ }
193193+194194+ /** Keys that are modifiers themselves — skip these to avoid spurious lookups */
195195+ private static readonly MODIFIER_KEYS = new Set([
196196+ 'Control',
197197+ 'Shift',
198198+ 'Alt',
199199+ 'Meta',
200200+ ]);
201201+202202+ /**
203203+ * Handle keydown events and dispatch to matching actions.
204204+ */
205205+ private onKeyDown(event: KeyboardEvent): void {
206206+ // Skip modifier-only keypresses (e.g. pressing just Shift)
207207+ if (HotkeyManager.MODIFIER_KEYS.has(event.key)) return;
208208+209209+ // Ignore events when typing in inputs (unless it's a modifier combo)
210210+ const target = event.target as HTMLElement | null;
211211+ if (target && target.tagName) {
212212+ const tagName = target.tagName.toLowerCase();
213213+ const isInput =
214214+ tagName === 'input' || tagName === 'textarea' || target.isContentEditable;
215215+ if (isInput && !event.ctrlKey && !event.metaKey && !event.altKey) {
216216+ return;
217217+ }
218218+ }
219219+220220+ const hotkey = eventToHotkey(event);
221221+ const key = hotkeyToString(hotkey);
222222+ const actionId = this.bindings.get(key);
223223+224224+ if (!actionId) return;
225225+226226+ const action = this.registry.get(actionId);
227227+ if (!action) return;
228228+229229+ event.preventDefault();
230230+ event.stopImmediatePropagation();
231231+232232+ this.executeAction(actionId);
233233+ }
234234+}
+105
src/actions/hotkey-utils.ts
···11+/** Modifier sort order for consistent string representation. */
22+const MODIFIER_ORDER: Modifier[] = ['Ctrl', 'Meta', 'Alt', 'Shift'];
33+44+/** Known modifier names for validation. */
55+const KNOWN_MODIFIERS = new Set<string>(['Ctrl', 'Meta', 'Alt', 'Shift', 'Mod']);
66+77+import type { Modifier, Hotkey } from './types';
88+99+/**
1010+ * Detect if the current platform is macOS.
1111+ */
1212+export function isMacPlatform(): boolean {
1313+ if (typeof navigator === 'undefined') return false;
1414+ return /Mac|iPod|iPhone|iPad/.test(navigator.platform);
1515+}
1616+1717+/**
1818+ * Normalize a hotkey by expanding 'Mod' to the platform-specific modifier.
1919+ * Also lowercases the key for consistent matching.
2020+ */
2121+export function normalizeHotkey(hotkey: Hotkey, isMac: boolean): Hotkey {
2222+ const modifiers: Modifier[] = hotkey.modifiers.map((mod) => {
2323+ if (mod === 'Mod') {
2424+ return isMac ? 'Meta' : 'Ctrl';
2525+ }
2626+ return mod;
2727+ });
2828+2929+ return {
3030+ modifiers,
3131+ key: hotkey.key.toLowerCase(),
3232+ };
3333+}
3434+3535+/**
3636+ * Convert a hotkey to a consistent string representation.
3737+ * Modifiers are sorted consistently and key is uppercased.
3838+ * Used as a lookup key in the hotkey registry.
3939+ */
4040+export function hotkeyToString(hotkey: Hotkey): string {
4141+ const sortedMods = [...hotkey.modifiers].sort(
4242+ (a, b) => MODIFIER_ORDER.indexOf(a) - MODIFIER_ORDER.indexOf(b),
4343+ );
4444+ const parts = [...sortedMods, hotkey.key.toUpperCase()];
4545+ return parts.join('+');
4646+}
4747+4848+/**
4949+ * Parse a hotkey string back into a Hotkey object.
5050+ * E.g., "Ctrl+Shift+P" -> { modifiers: ['Ctrl', 'Shift'], key: 'p' }
5151+ * Unknown modifiers are silently dropped.
5252+ */
5353+export function parseHotkeyString(str: string): Hotkey {
5454+ const parts = str.split('+');
5555+ const key = parts.pop()!.toLowerCase();
5656+ const modifiers = parts.filter((p) => KNOWN_MODIFIERS.has(p)) as Modifier[];
5757+ return { modifiers, key };
5858+}
5959+6060+/**
6161+ * Convert a keyboard event to a Hotkey object.
6262+ */
6363+export function eventToHotkey(event: KeyboardEvent): Hotkey {
6464+ const modifiers: Modifier[] = [];
6565+ if (event.ctrlKey) modifiers.push('Ctrl');
6666+ if (event.metaKey) modifiers.push('Meta');
6767+ if (event.altKey) modifiers.push('Alt');
6868+ if (event.shiftKey) modifiers.push('Shift');
6969+7070+ return {
7171+ modifiers,
7272+ key: event.key.toLowerCase(),
7373+ };
7474+}
7575+7676+/**
7777+ * Format a hotkey for display to the user.
7878+ * Uses platform-appropriate symbols (e.g., Command on Mac, Ctrl on Windows).
7979+ */
8080+export function formatHotkeyForDisplay(hotkey: Hotkey, isMac: boolean): string {
8181+ const symbols: Record<Modifier, string> = isMac
8282+ ? {
8383+ Ctrl: '^',
8484+ Meta: '\u2318',
8585+ Alt: '\u2325',
8686+ Shift: '\u21E7',
8787+ Mod: '\u2318',
8888+ }
8989+ : {
9090+ Ctrl: 'Ctrl',
9191+ Meta: 'Win',
9292+ Alt: 'Alt',
9393+ Shift: 'Shift',
9494+ Mod: 'Ctrl',
9595+ };
9696+9797+ const mods = hotkey.modifiers.map((m) => symbols[m] ?? m);
9898+ const key = hotkey.key.toUpperCase();
9999+100100+ if (isMac) {
101101+ return mods.join('') + key;
102102+ } else {
103103+ return [...mods, key].join('+');
104104+ }
105105+}
+28
src/actions/index.ts
···11+export type {
22+ ActionDefinition,
33+ RegisteredAction,
44+ ActionSet,
55+ Hotkey,
66+ Modifier,
77+} from './types';
88+99+export { ActionRegistry } from './registry';
1010+export { HotkeyManager, type HotkeyConflict } from './hotkey-manager';
1111+export {
1212+ normalizeHotkey,
1313+ hotkeyToString,
1414+ parseHotkeyString,
1515+ eventToHotkey,
1616+ formatHotkeyForDisplay,
1717+ isMacPlatform,
1818+} from './hotkey-utils';
1919+export { CommandPalette } from './CommandPalette';
2020+export { ActionProvider } from './ActionProvider';
2121+export { ActionContext, useActions, useActionRegistry } from './ActionContext';
2222+export { createActionServices } from './services';
2323+export { SidebarActionSet } from './sets/sidebarActionSet';
2424+export { SettingsActionSet } from './sets/settingsActionSet';
2525+export { ThemeActionSet } from './sets/themeActionSet';
2626+export { NavigationActionSet } from './sets/navigationActionSet';
2727+export { WorkspaceActionSet } from './sets/workspaceActionSet';
2828+export { TilingActionSet, type TilingHandle } from './sets/tilingActionSet';
+174
src/actions/registry.ts
···11+import type { ActionDefinition, RegisteredAction } from './types';
22+33+/**
44+ * Central registry for all actions across core and plugins.
55+ *
66+ * Actions are stored with full IDs (source:action-id) to avoid collisions.
77+ * Provides search/filter functionality for the command palette.
88+ */
99+export class ActionRegistry {
1010+ private actions = new Map<string, RegisteredAction>();
1111+ private removalListeners = new Set<(id: string) => void>();
1212+1313+ /**
1414+ * Register an action from a source (e.g. 'core' or a plugin ID).
1515+ * The action ID is prefixed with the source.
1616+ *
1717+ * @returns The registered action with prefixed ID
1818+ */
1919+ register(source: string, def: ActionDefinition): RegisteredAction {
2020+ const fullId = `${source}:${def.id}`;
2121+ const registered: RegisteredAction = {
2222+ ...def,
2323+ id: fullId,
2424+ source,
2525+ };
2626+ this.actions.set(fullId, registered);
2727+ return registered;
2828+ }
2929+3030+ /**
3131+ * Remove an action by its full ID.
3232+ */
3333+ unregister(id: string): boolean {
3434+ const removed = this.actions.delete(id);
3535+ if (removed) {
3636+ for (const listener of this.removalListeners) {
3737+ listener(id);
3838+ }
3939+ }
4040+ return removed;
4141+ }
4242+4343+ /**
4444+ * Remove all actions from a specific source.
4545+ */
4646+ unregisterAll(source: string): void {
4747+ const toRemove: string[] = [];
4848+ for (const [id, action] of this.actions) {
4949+ if (action.source === source) {
5050+ toRemove.push(id);
5151+ }
5252+ }
5353+ for (const id of toRemove) {
5454+ this.unregister(id);
5555+ }
5656+ }
5757+5858+ /**
5959+ * Get a single action by full ID.
6060+ */
6161+ get(id: string): RegisteredAction | undefined {
6262+ return this.actions.get(id);
6363+ }
6464+6565+ /**
6666+ * Get all registered actions.
6767+ */
6868+ getAll(): RegisteredAction[] {
6969+ return Array.from(this.actions.values());
7070+ }
7171+7272+ /**
7373+ * Subscribe to action removal events.
7474+ * Returns an unsubscribe function.
7575+ */
7676+ onActionRemoved(listener: (id: string) => void): () => void {
7777+ this.removalListeners.add(listener);
7878+ return () => {
7979+ this.removalListeners.delete(listener);
8080+ };
8181+ }
8282+8383+ /**
8484+ * Check if an action is currently available.
8585+ */
8686+ isAvailable(action: RegisteredAction): boolean {
8787+ if (!action.checkAvailable) return true;
8888+ try {
8989+ return action.checkAvailable();
9090+ } catch {
9191+ return false;
9292+ }
9393+ }
9494+9595+ /**
9696+ * Get all actions that are currently available.
9797+ */
9898+ getAvailable(): RegisteredAction[] {
9999+ return this.getAll().filter((action) => this.isAvailable(action));
100100+ }
101101+102102+ /**
103103+ * Execute an action by its full ID.
104104+ * @returns true if found and executed, false otherwise
105105+ */
106106+ async execute(id: string): Promise<boolean> {
107107+ const action = this.actions.get(id);
108108+ if (!action) return false;
109109+110110+ if (!this.isAvailable(action)) return false;
111111+112112+ try {
113113+ const result = action.execute();
114114+ if (result && typeof (result as PromiseLike<unknown>).then === 'function') {
115115+ await result;
116116+ }
117117+ return true;
118118+ } catch (error) {
119119+ console.error(`Error executing action '${id}':`, error);
120120+ return false;
121121+ }
122122+ }
123123+124124+ /**
125125+ * Search actions by name using fuzzy matching.
126126+ * Results are filtered by availability and sorted by match quality.
127127+ *
128128+ * @param query - Search query
129129+ */
130130+ search(query: string): RegisteredAction[] {
131131+ const available = this.getAvailable();
132132+133133+ if (!query.trim()) {
134134+ return available;
135135+ }
136136+137137+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
138138+ const matches: Array<{ action: RegisteredAction; score: number }> = [];
139139+140140+ for (const action of available) {
141141+ const name = action.name.toLowerCase();
142142+ const desc = (action.description ?? '').toLowerCase();
143143+ const haystack = name + ' ' + desc;
144144+145145+ // All query words must appear somewhere
146146+ const allMatch = words.every((word) => haystack.includes(word));
147147+ if (!allMatch) continue;
148148+149149+ // Scoring: name prefix > name word boundary > name contains > desc contains
150150+ let score = 0;
151151+ for (const word of words) {
152152+ if (name.startsWith(word)) {
153153+ score += 4;
154154+ } else if (name.includes(' ' + word)) {
155155+ score += 3;
156156+ } else if (name.includes(word)) {
157157+ score += 2;
158158+ } else {
159159+ score += 1;
160160+ }
161161+ }
162162+163163+ matches.push({ action, score });
164164+ }
165165+166166+ // Sort by score descending, then by name alphabetically
167167+ matches.sort((a, b) => {
168168+ if (b.score !== a.score) return b.score - a.score;
169169+ return a.action.name.localeCompare(b.action.name);
170170+ });
171171+172172+ return matches.map((m) => m.action);
173173+ }
174174+}
+24
src/actions/services.ts
···11+import { ActionRegistry } from './registry';
22+import { HotkeyManager } from './hotkey-manager';
33+44+/**
55+ * Create the core action system services.
66+ *
77+ * Call this once at app startup (e.g. in AppShell via useMemo) and pass
88+ * the returned services into ActionProvider.
99+ *
1010+ * @param isMac - Whether the renderer is on macOS (determines Mod expansion)
1111+ */
1212+export function createActionServices(isMac: boolean) {
1313+ const registry = new ActionRegistry();
1414+1515+ const hotkeyManager = new HotkeyManager(
1616+ registry,
1717+ isMac,
1818+ (actionId) => {
1919+ void registry.execute(actionId);
2020+ },
2121+ );
2222+2323+ return { registry, hotkeyManager };
2424+}
+54
src/actions/types.ts
···11+/** Platform-agnostic modifier. 'Mod' expands to Meta (macOS) or Ctrl (others). */
22+export type Modifier = 'Mod' | 'Ctrl' | 'Meta' | 'Alt' | 'Shift';
33+44+export interface Hotkey {
55+ modifiers: Modifier[];
66+ key: string;
77+}
88+99+/**
1010+ * Definition of an action as provided by core or a plugin.
1111+ *
1212+ * Actions are self-contained closures over the state they manipulate.
1313+ * They do not receive an app-wide context object; instead, each ActionSet
1414+ * creates actions that close over the specific state/setters they need.
1515+ */
1616+export interface ActionDefinition {
1717+ /** Unique ID within the source namespace (e.g. 'sidebar-toggle'). */
1818+ id: string;
1919+ /** Human-readable name for the command palette search. */
2020+ name: string;
2121+ /** Short description (shown in palette details / settings). */
2222+ description?: string;
2323+ /** Icon identifier for toolbar rendering. */
2424+ icon?: string;
2525+ /** Default hotkeys (user-remappable in future settings). */
2626+ hotkeys?: Hotkey[];
2727+ /** Whether holding the hotkey should repeatedly trigger this command. */
2828+ repeatable?: boolean;
2929+ /**
3030+ * Optional availability check. Return false to hide from palette
3131+ * and skip execution.
3232+ */
3333+ checkAvailable?(): boolean;
3434+ /** Execute the action. */
3535+ execute(): void | Promise<void>;
3636+}
3737+3838+/** Internal representation after registration (includes source tag). */
3939+export interface RegisteredAction extends ActionDefinition {
4040+ /** 'core' or a plugin ID. */
4141+ source: string;
4242+}
4343+4444+/**
4545+ * A set of actions that adapts a specific component or subsystem to the
4646+ * action registry. ActionSets are created close to the code they adapt and
4747+ * dependency-injected into ActionProvider.
4848+ */
4949+export interface ActionSet {
5050+ /** Unique source ID for this set (used as action source prefix). */
5151+ id: string;
5252+ /** Get all action definitions from this set. */
5353+ getActions(): ActionDefinition[];
5454+}
+10-1
src/workspaces/workspace.tsx
···11-import type { ComponentType } from 'react';
11+import type { ComponentType, MutableRefObject } from 'react';
22import type { FileSystemProvider } from '../filesystem/types';
33import { TilingWorkspace } from './tiling/tiling';
44import { ZenWorkspace } from './zen/zen';
55+import type { TilingHandle } from '../actions';
5667export type WorkspaceKind = 'tiling' | 'zen';
78···4647 * open documents via `provider.documents` when present.
4748 */
4849 resolveProvider: (providerId: string) => FileSystemProvider | undefined;
5050+ /**
5151+ * Mutable ref for TilingWorkspace to expose its split handle.
5252+ * Only meaningful for the tiling workspace; other kinds ignore it.
5353+ */
5454+ tilingHandleRef?: MutableRefObject<TilingHandle | null>;
4955};
50565157export type WorkspaceComponent = ComponentType<WorkspaceProps>;
···9399 openRequestId: number;
94100 openRequest: OpenDocRequest | null;
95101 resolveProvider: (providerId: string) => FileSystemProvider | undefined;
102102+ tilingHandleRef?: MutableRefObject<TilingHandle | null>;
96103};
9710498105/**
···105112 openRequestId,
106113 openRequest,
107114 resolveProvider,
115115+ tilingHandleRef,
108116}: WorkspaceHostProps) {
109117 const { Component } = getWorkspaceDescriptor(kind);
110118 return (
···113121 openRequestId={openRequestId}
114122 openRequest={openRequest}
115123 resolveProvider={resolveProvider}
124124+ tilingHandleRef={tilingHandleRef}
116125 />
117126 );
118127}
+22
src/actions/sets/navigationActionSet.ts
···11+import type { NavigateFunction } from 'react-router-dom';
22+import type { ActionSet, ActionDefinition } from '../types';
33+44+/**
55+ * ActionSet for navigation actions.
66+ */
77+export class NavigationActionSet implements ActionSet {
88+ readonly id = 'navigation';
99+1010+ constructor(private navigate: NavigateFunction) {}
1111+1212+ getActions(): ActionDefinition[] {
1313+ return [
1414+ {
1515+ id: 'about',
1616+ name: 'Open about page',
1717+ description: 'Navigate to the about page',
1818+ execute: () => this.navigate('/about'),
1919+ },
2020+ ];
2121+ }
2222+}