/** * Glue between backend plugins and the workbench: plugin commands become * actions, editor requests are applied to the active editor, theme overrides * become CSS variables, notifications become balloons. */ import { EditorSelection } from "@codemirror/state"; import type { ActiveTheme, PluginEvent, PluginInfo } from "@/bindings"; import { activeEditor, useEditorRegistry } from "@/editor/api"; import { registerAction, type Action } from "@/platform/actions"; import { notifications } from "@/workbench/contributions"; import { pluginsBackend } from "./backend"; import { refreshPlugins, runPluginCommand, usePlugins } from "./store"; export const pluginActionId = (pluginId: string, commandId: string) => `plugin.${pluginId}.${commandId}`; /** Actions for every command of every running plugin. */ export function pluginActions(plugins: readonly PluginInfo[]): Action[] { return plugins .filter((p) => p.status === "active") .flatMap((p) => p.commands.map((c): Action => ({ id: pluginActionId(p.id, c.id), title: c.title, category: c.category ?? p.name, icon: "package", keywords: ["plugin", p.name, p.id], description: `From plugin ${p.name}`, run: async ({ arg }) => { await runPluginCommand(p.id, c.id, typeof arg === "string" ? arg : null); }, })), ); } /** Keeps the action registry in sync with the plugin list; re-registers only changed actions. */ export function syncPluginActions(): () => void { const registered = new Map void }>(); const apply = (plugins: readonly PluginInfo[]) => { const next = new Map(pluginActions(plugins).map((a) => [a.id, a])); for (const [id, entry] of registered) { if (!next.has(id)) { entry.dispose(); registered.delete(id); } } for (const [id, action] of next) { const key = `${action.title}\u0000${action.category}`; const prev = registered.get(id); if (prev?.key === key) continue; prev?.dispose(); registered.set(id, { key, dispose: registerAction(action) }); } }; apply(usePlugins.getState().plugins); const unsubscribe = usePlugins.subscribe((s, prev) => { if (s.plugins !== prev.plugins) apply(s.plugins); }); return () => { unsubscribe(); registered.forEach((e) => e.dispose()); registered.clear(); }; } // --- theme ------------------------------------------------------------------ let appliedTokens: string[] = []; /** Applies a plugin theme's token overrides as inline CSS variables on :root (null clears). */ export function applyPluginTheme(theme: ActiveTheme | null, root: HTMLElement = document.documentElement) { for (const name of appliedTokens) root.style.removeProperty(name); appliedTokens = []; if (!theme) { delete root.dataset.pluginTheme; return; } for (const [name, value] of Object.entries(theme.theme.tokens)) { // Backend validates; keep the guard so a malformed payload can't inject declarations. if (!/^--[a-z0-9-]+$/.test(name) || value == null || /[;{}<>]/.test(value)) continue; root.style.setProperty(name, value); appliedTokens.push(name); } root.dataset.pluginTheme = `${theme.pluginId}/${theme.theme.id}`; } export function trackPluginTheme(): () => void { applyPluginTheme(usePlugins.getState().activeTheme); const unsubscribe = usePlugins.subscribe((s, prev) => { if (s.activeTheme !== prev.activeTheme) applyPluginTheme(s.activeTheme); }); return () => { unsubscribe(); applyPluginTheme(null); }; } // --- editor ----------------------------------------------------------------- /** Inserts (`select: false`) or replaces-and-selects (`select: true`) at every selection range. */ export function applyTextToActiveEditor(text: string, select: boolean, pluginId: string): boolean { const editor = activeEditor(); const name = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.name ?? pluginId; if (!editor || editor.ctx.readOnly) { notifications().notify({ group: "plugins", level: "warning", title: `${name}: ${editor ? "the editor is read-only" : "no editor to insert into"}`, }); return false; } const { view } = editor; view.dispatch( view.state.changeByRange((range) => ({ changes: { from: range.from, to: range.to, insert: text }, range: select ? EditorSelection.range(range.from, range.from + text.length) : EditorSelection.cursor(range.from + text.length), })), { scrollIntoView: true, userEvent: "input.plugin" }, ); view.focus(); return true; } /** Reports the focused editor's file to the backend (`active-file` for plugins). */ function trackActiveFile(): () => void { let last: string | null | undefined; const report = (path: string | null) => { if (path === last) return; last = path; void pluginsBackend() .setActiveFile(path) .catch(() => {}); }; report(useEditorRegistry.getState().active?.ctx.path ?? null); return useEditorRegistry.subscribe((s) => report(s.active?.ctx.path ?? null)); } // --- events ----------------------------------------------------------------- export function handlePluginEvent(e: PluginEvent) { switch (e.type) { case "commandsChanged": scheduleRefresh(); break; case "notification": { const name = usePlugins.getState().plugins.find((p) => p.id === e.pluginId)?.name ?? e.pluginId; notifications().notify({ group: "plugins", level: e.level, title: e.title || name, body: e.body || undefined }); break; } case "insertText": applyTextToActiveEditor(e.text, false, e.pluginId); break; case "replaceSelection": applyTextToActiveEditor(e.text, true, e.pluginId); break; case "themeChanged": usePlugins.setState({ activeTheme: e.theme }); break; } } let refreshTimer: ReturnType | null = null; function scheduleRefresh() { if (refreshTimer) return; refreshTimer = setTimeout(() => { refreshTimer = null; void refreshPlugins(); }, 30); } export function startPluginBridge(): () => void { const disposers = [ pluginsBackend().onEvent(handlePluginEvent), syncPluginActions(), trackPluginTheme(), trackActiveFile(), ]; return () => { if (refreshTimer) clearTimeout(refreshTimer); refreshTimer = null; disposers.reverse().forEach((d) => d()); }; }