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); } }