Файли
openidea/src/features/plugins/bridge.ts
T

186 рядки
6.3 KiB
TypeScript

/**
* 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<string, { key: string; dispose: () => 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<typeof setTimeout> | 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());
};
}