/** * What running plugin code shows outside its declarations: status bar items * (`ui.set-status-item`), background tasks (`tasks.begin`), and the * workbench events it subscribed to (`events.subscribe`), reported in * batches. */ import { create } from "zustand"; import type { EventKind, PluginEvent, StatusItem, WorkbenchEvent } from "@/bindings"; import { onEditorEvent } from "@/editor/api"; import { executeAction, getAction, onActionExecuted } from "@/platform/actions"; import { onIdeEvent } from "@/platform/ipc"; import { openExternalUrl } from "@/platform/openUrl"; import { useProject } from "@/platform/project"; import { shortcutLabel } from "@/platform/keymap"; import { BarButton } from "@/ui/BarButton"; import { maybeEditorService, registerStatusItem } from "@/workbench/contributions"; import { removeTask, upsertTask, useTasks } from "@/workbench/tasks"; import { pluginsBackend } from "./backend"; import { bumpView, deliverViewMessage } from "./views/viewStore"; import { runPluginCommand, usePlugins } from "./store"; // --- status bar items --------------------------------------------------------- type ItemKey = `${string}/${string}`; export const usePluginStatusItems = create<{ items: Map }>(() => ({ items: new Map(), })); function setStatusItem(pluginId: string, id: string, item: StatusItem | null) { usePluginStatusItems.setState((s) => { const items = new Map(s.items); if (item) items.set(`${pluginId}/${id}`, { pluginId, item }); else items.delete(`${pluginId}/${id}`); return { items }; }); } /** Items declared under `[[contributes.statusBar]]`: shown until (and unless) the code sets its own. */ interface Declared { item: StatusItem; align: "left" | "right"; priority: number; } export const useDeclaredStatusItems = create<{ items: Map }>(() => ({ items: new Map(), })); export function declareStatusItem(pluginId: string, id: string, declared: Declared): () => void { const key: ItemKey = `${pluginId}/${id}`; const entry = { pluginId, ...declared }; useDeclaredStatusItems.setState((s) => ({ items: new Map(s.items).set(key, entry) })); return () => useDeclaredStatusItems.setState((s) => { if (s.items.get(key) !== entry) return s; const items = new Map(s.items); items.delete(key); return { items }; }); } function PluginStatusItemView({ itemKey }: { itemKey: ItemKey }) { const running = usePluginStatusItems((s) => s.items.get(itemKey)); const declared = useDeclaredStatusItems((s) => s.items.get(itemKey)); const entry = running ?? declared; if (!entry) return null; const { pluginId, item } = entry; const command = item.command; return ( void runPluginCommand(pluginId, command) : undefined} > {item.text} ); } /** The item's name in the status bar menu: its plugin's, plus what it declares (tooltip or text). */ function statusItemTitle(key: ItemKey, declared: Declared | undefined): string { const pluginId = key.slice(0, key.indexOf("/")); const plugin = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.name ?? pluginId; const what = declared?.item.tooltip || declared?.item.text; return what ? `${plugin}: ${what}` : plugin; } /** One status bar contribution per plugin item (declared or set by code), registered while it exists. */ function projectStatusItems(): () => void { const registered = new Map void; sig: string }>(); const sync = () => { const running = usePluginStatusItems.getState().items; const declared = useDeclaredStatusItems.getState().items; const live = new Set([...running.keys(), ...declared.keys()]); for (const [key, r] of registered) { const d = declared.get(key); const sig = d ? `${d.align}/${d.priority}` : ""; if (!live.has(key) || r.sig !== sig) { r.dispose(); registered.delete(key); } } for (const key of live) if (!registered.has(key)) { const d = declared.get(key); const View = () => ; const dispose = registerStatusItem({ id: `plugin.${key}`, // Named in the status bar's context menu, so it can be hidden like the built-in widgets. title: statusItemTitle(key, d), align: d?.align ?? "right", priority: d?.priority ?? 2, component: View, }); registered.set(key, { dispose, sig: d ? `${d.align}/${d.priority}` : "" }); } }; sync(); const unsubscribe = [ usePluginStatusItems.subscribe((s, prev) => s.items !== prev.items && sync()), useDeclaredStatusItems.subscribe((s, prev) => s.items !== prev.items && sync()), ]; refreshStatusItems(); return () => { unsubscribe.forEach((u) => u()); for (const r of registered.values()) r.dispose(); registered.clear(); }; } /** * Re-reads the status items of running plugin code (later changes arrive as * `statusItemChanged`). Like the event kinds, the backend answers once this * window's project is open and trusted, so call it again when that happens. */ function refreshStatusItems() { void pluginsBackend() .statusItems() .then((items) => items.forEach((i) => setStatusItem(i.pluginId, i.id, i.item))) .catch(() => {}); } // --- tasks -------------------------------------------------------------------- const taskId = (id: string) => `plugin:${id}`; function onTaskEvent(e: Extract) { const id = taskId(e.taskId); const current = useTasks.getState().tasks.find((t) => t.id === id); switch (e.type) { case "taskStarted": upsertTask({ id, title: e.title, message: null, fraction: null, done: false, cancel: e.cancellable ? () => void pluginsBackend().cancelTask(e.taskId) : undefined, }); break; case "taskProgress": if (current) upsertTask({ ...current, message: e.message, fraction: e.total ? Math.min(1, e.done / e.total) : null, }); break; case "taskEnded": if (current) upsertTask({ ...current, done: true }); else removeTask(id); break; } } // --- workbench events ----------------------------------------------------------- /** Batches are sent at most this often; the backend batches again per plugin. */ const POST_INTERVAL_MS = 500; let wanted = new Set(); let queue: WorkbenchEvent[] = []; let postTimer: ReturnType | null = null; /** * Re-reads the event kinds plugins want. Besides `subscriptionsChanged` * (run-time subscriptions), declared `on-event:` kinds change whenever plugins * load, install, toggle or get grants, which the backend reports as `commandsChanged`. */ export function refreshEventKinds() { void pluginsBackend() .eventKinds() .then((kinds) => (wanted = new Set(kinds))) .catch(() => {}); } function post(event: WorkbenchEvent) { queue.push(event); if (postTimer) return; postTimer = setTimeout(() => { postTimer = null; const batch = queue; queue = []; void pluginsBackend() .postEvents(batch) .catch(() => {}); }, POST_INTERVAL_MS); } /** Reports the events plugins subscribed to: files opened and saved, actions run, repositories changed. */ function trackWorkbenchEvents(): () => void { refreshEventKinds(); const offEditor = onEditorEvent((e) => { if (e.type === "docOpened" && wanted.has("file-opened")) post({ kind: "fileOpened", value: e.path }); if (e.type === "docSaved" && wanted.has("file-saved")) post({ kind: "fileSaved", value: e.path }); }); const offActions = onActionExecuted((id, _arg, source) => { if (!wanted.has("action-performed")) return; const action = getAction(id); post({ kind: "actionPerformed", value: { action: id, title: action?.title ?? id, source, shortcut: shortcutLabel(id) ?? null }, }); }); const offVcs = onIdeEvent("vcsChanged", (e) => { if (wanted.has("vcs-changed")) post({ kind: "vcsChanged", value: e.root }); }); return () => { offEditor(); offActions(); offVcs(); if (postTimer) clearTimeout(postTimer); postTimer = null; queue = []; }; } /** Handles the runtime UI events; returns whether `e` was one. */ export function handleRuntimeEvent(e: PluginEvent): boolean { switch (e.type) { case "statusItemChanged": setStatusItem(e.pluginId, e.id, e.item); return true; case "taskStarted": case "taskProgress": case "taskEnded": onTaskEvent(e); return true; case "subscriptionsChanged": refreshEventKinds(); return true; case "viewChanged": bumpView(e.pluginId, e.view); return true; case "viewMessage": deliverViewMessage(e.pluginId, e.view, e.message); return true; case "openTerminal": // The program was approved (`process:`) and verified by the host. void executeAction("terminal.runCommand", { program: e.program, args: e.args, name: e.title, cwd: e.cwd }); return true; case "openLocation": void maybeEditorService()?.openFile(e.path, { selection: { line: e.line, col: e.column }, focus: true }); return true; case "copyToClipboard": void navigator.clipboard?.writeText(e.text); return true; case "openUrl": void openExternalUrl(e.url); return true; default: return false; } } /** * The backend refuses the event kinds and status items until this window's * project is open and trusted (`authorize_command`), but startup asks before * that: ask again when it changes. */ function refreshOnProjectChange(): () => void { return useProject.subscribe((s, prev) => { if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return; refreshEventKinds(); refreshStatusItems(); }); } export function startRuntimeUi(): () => void { const disposers = [projectStatusItems(), trackWorkbenchEvents(), refreshOnProjectChange()]; return () => disposers.reverse().forEach((d) => d()); }