Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178import 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<string, RegisteredAction>(); 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<boolean> { 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<unknown>).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); }}