mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
A plugin system built on WebAssembly components (wasmtime, WASI p2), with a typed WIT API (`openidea:[email protected]`) and a Rust SDK, shipped as a preview. - Extension points declared in `plugin.toml` (commands, languages, themes, icon themes, menus, key bindings, status items, settings, views, custom editors, file icons and templates, run detectors, editor providers), validated before anything takes effect and projected onto the workbench's own registries: plugin themes and file icon themes are chosen in Settings › Appearance and Quick Switch, plugin shortcuts are edited in Settings › Keymap, plugin problems are grouped in Settings › Inspections. - Capabilities with risk levels and consent at install, pinned `process:` programs, secrets in the OS keychain. - Runtime: one thread per active plugin, fuel/time/memory budgets, soft and hard deadlines on the editing path, cancellation, crash backoff, idle reclaim, a precompiled-component cache; remote projects run code plugins on `openidea-server`. - Web views and custom editors with a sandboxed page, a document bridge joined to the text editor's undo history, and split or preview layouts. - Distribution: signed `.oiplugin` packages, a static marketplace index with revocation, versioned installs with rollback, install from a `.zip` or a folder. The release workflow signs and publishes the official plugins and their index, which released builds use as the default marketplace. - Official plugins: String Tools, Key Promoter, Git Toolbox, AI Completion, Checkstyle, MyBatis Navigator, CSV Editor, Solidity, Material Icons, Harbor Night, Excalidraw, Graphviz, Regex Tester, License Header. Closes #732 Co-authored-by: Deng Liu <[email protected]> Co-authored-by: florentvdry <[email protected]>
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
/**
|
||
* Glue between backend plugins and the workbench: UI contributions are
|
||
* projected (`projection.ts` + `points.tsx`), status items, tasks and event
|
||
* subscriptions of running code go through `runtimeUi.tsx`, the selected
|
||
* theme becomes a stylesheet of CSS
|
||
* variables, notifications become balloons, consent requests open the
|
||
* permission dialog, and opened files fire `on-language` / `on-file`.
|
||
*/
|
||
import type { ActiveTheme, PluginEvent } from "@/bindings";
|
||
import { useEditorRegistry } from "@/editor/api";
|
||
import { refreshLanguageInfos } from "@/editor/core/cm/languages";
|
||
import { notifications } from "@/workbench/contributions";
|
||
import { useProducerNames } from "@/platform/inspections";
|
||
import { languageListChanged } from "@/platform/languageList";
|
||
import { isThemeAvailable, parsePluginThemeRef, useContributedThemes } from "@/platform/themes";
|
||
import {
|
||
applyThemeAttributes,
|
||
currentThemeChoice,
|
||
currentThemeId,
|
||
subscribeScheme,
|
||
syncWindowBackground,
|
||
useThemeOverride,
|
||
windowBackgroundHex,
|
||
} from "@/workbench/theme";
|
||
import { useProject } from "@/platform/project";
|
||
import { pluginsBackend } from "./backend";
|
||
import { trackIconTheme } from "./iconThemes";
|
||
import { defineBuiltinUiPoints } from "./points";
|
||
import { clearUiContributions, syncUiContributions } from "./projection";
|
||
import { handleRuntimeEvent, refreshEventKinds, startRuntimeUi } from "./runtimeUi";
|
||
import { grantPlugin, refreshPlugins, selectPluginTheme, usePlugins } from "./store";
|
||
|
||
export { pluginActionId } from "./points";
|
||
|
||
/** Keeps the projected UI contributions in line with the plugin list. */
|
||
export function syncProjection(): () => void {
|
||
const apply = () => {
|
||
const s = usePlugins.getState();
|
||
syncUiContributions(s.ui, s.plugins);
|
||
// Their problems are listed by name in Settings › Inspections.
|
||
useProducerNames.setState({ names: new Map(s.plugins.map((p) => [`plugin:${p.id}`, p.name])) });
|
||
};
|
||
apply();
|
||
const unsubscribe = usePlugins.subscribe((s, prev) => {
|
||
if (s.ui !== prev.ui || s.plugins !== prev.plugins) apply();
|
||
});
|
||
return () => {
|
||
unsubscribe();
|
||
clearUiContributions();
|
||
};
|
||
}
|
||
|
||
// --- theme ------------------------------------------------------------------
|
||
|
||
export const PLUGIN_THEME_STYLE_ID = "openidea-plugin-theme";
|
||
|
||
/** Stylesheet for a plugin theme. Its selector outranks every built-in theme block. */
|
||
export function pluginThemeCss(theme: ActiveTheme["theme"]): string {
|
||
const kind = theme.kind === "light" ? "light" : "dark";
|
||
const decls = [` color-scheme: ${kind};`];
|
||
for (const [name, value] of Object.entries(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;
|
||
decls.push(` ${name}: ${value};`);
|
||
}
|
||
return `:root[data-plugin-theme]:not([data-plugin-theme=""]) {\n${decls.join("\n")}\n}\n`;
|
||
}
|
||
|
||
/**
|
||
* Applies a plugin theme (null restores the built-in one): its tokens as a
|
||
* stylesheet over the built-in light or dark base named by its `kind`. A
|
||
* stylesheet rather than inline styles, so the accent picked in Settings ›
|
||
* Appearance and the editor colour scheme still layer on top.
|
||
*/
|
||
export function applyPluginTheme(theme: ActiveTheme | null, root: HTMLElement = document.documentElement) {
|
||
const doc = root.ownerDocument;
|
||
doc.getElementById(PLUGIN_THEME_STYLE_ID)?.remove();
|
||
const had = root.dataset.pluginTheme !== undefined;
|
||
if (!theme) {
|
||
delete root.dataset.pluginTheme;
|
||
delete root.dataset.pluginThemeKind;
|
||
if (had) applyThemeAttributes(currentThemeId(), root);
|
||
} else {
|
||
const style = doc.createElement("style");
|
||
style.id = PLUGIN_THEME_STYLE_ID;
|
||
style.textContent = pluginThemeCss(theme.theme);
|
||
doc.head.appendChild(style);
|
||
root.dataset.pluginTheme = `${theme.pluginId}/${theme.theme.id}`;
|
||
root.dataset.pluginThemeKind = theme.theme.kind === "light" ? "light" : "dark";
|
||
applyThemeAttributes(currentThemeId(), root);
|
||
}
|
||
if (had || theme) syncWindowBackground();
|
||
}
|
||
|
||
/** localStorage key read by public/boot.js: the selected theme's stylesheet, applied before the app loads. */
|
||
export const PLUGIN_THEME_CACHE_KEY = "openidea.pluginTheme";
|
||
|
||
export interface CachedPluginTheme {
|
||
/** `data-plugin-theme`: `<pluginId>/<themeId>`. */
|
||
id: string;
|
||
kind: "light" | "dark";
|
||
css: string;
|
||
}
|
||
|
||
function cachePluginTheme(theme: ActiveTheme | null) {
|
||
try {
|
||
if (!theme) {
|
||
localStorage.removeItem(PLUGIN_THEME_CACHE_KEY);
|
||
return;
|
||
}
|
||
const cached: CachedPluginTheme = {
|
||
id: `${theme.pluginId}/${theme.theme.id}`,
|
||
kind: theme.theme.kind === "light" ? "light" : "dark",
|
||
css: pluginThemeCss(theme.theme),
|
||
};
|
||
localStorage.setItem(PLUGIN_THEME_CACHE_KEY, JSON.stringify(cached));
|
||
} catch {
|
||
/* storage unavailable */
|
||
}
|
||
}
|
||
|
||
let reportedFrame: string | undefined;
|
||
|
||
/**
|
||
* Tells the backend the theme's resolved frame colour, which new windows open
|
||
* in. Selecting a theme clears it there, so it is sent once per selection.
|
||
*/
|
||
function reportThemeFrame(theme: ActiveTheme | null) {
|
||
const frame = theme ? windowBackgroundHex() : null;
|
||
if (!theme || !frame) {
|
||
reportedFrame = undefined;
|
||
return;
|
||
}
|
||
const key = `${theme.pluginId}/${theme.theme.id}|${frame}`;
|
||
if (key === reportedFrame) return;
|
||
reportedFrame = key;
|
||
void pluginsBackend()
|
||
.setThemeFrame(frame)
|
||
.catch(() => (reportedFrame = undefined));
|
||
}
|
||
|
||
/**
|
||
* Applies the selected plugin theme and keeps it for the next start-up. Until
|
||
* the first plugin list arrives, the theme public/boot.js restored stays.
|
||
*/
|
||
export function trackPluginTheme(): () => void {
|
||
const sync = (s: ReturnType<typeof usePlugins.getState>) => {
|
||
if (!s.activeTheme && (!s.loaded || s.error)) return;
|
||
applyPluginTheme(s.activeTheme);
|
||
cachePluginTheme(s.activeTheme);
|
||
reportThemeFrame(s.activeTheme);
|
||
};
|
||
sync(usePlugins.getState());
|
||
const unsubscribe = usePlugins.subscribe((s, prev) => {
|
||
if (s.activeTheme !== prev.activeTheme || s.loaded !== prev.loaded) sync(s);
|
||
});
|
||
return () => {
|
||
unsubscribe();
|
||
applyPluginTheme(null);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Makes the plugin host's theme the one the Appearance settings select: a
|
||
* plugin theme (`plugin:<id>/<theme>`, from any theme picker, the OS mode with
|
||
* "Sync with OS", or the plugin's details page) is selected there once its
|
||
* plugin is live; a built-in one clears it. While the selected plugin is off,
|
||
* its built-in base shows and the selection is kept for when it is back.
|
||
*/
|
||
export function followThemeSetting(): () => void {
|
||
let requested: string | undefined;
|
||
const reconcile = () => {
|
||
const plugins = usePlugins.getState();
|
||
if (!plugins.loaded || plugins.error || !useProject.getState().settings?.appearance) return;
|
||
const choice = currentThemeChoice();
|
||
const want = parsePluginThemeRef(choice);
|
||
const target = want && isThemeAvailable(choice) ? choice : null;
|
||
const active = plugins.activeTheme;
|
||
const current = active ? `plugin:${active.pluginId}/${active.theme.id}` : null;
|
||
if (target === current || (want && !target && !current)) {
|
||
requested = undefined;
|
||
return;
|
||
}
|
||
// A selected plugin that is off: leave the host's selection for when it is back.
|
||
if (want && !target) {
|
||
if (requested === "") return;
|
||
requested = "";
|
||
void selectPluginTheme(null, null);
|
||
return;
|
||
}
|
||
if (requested === (target ?? "")) return;
|
||
requested = target ?? "";
|
||
void selectPluginTheme(want && target ? want.pluginId : null, want && target ? want.themeId : null);
|
||
};
|
||
reconcile();
|
||
const disposers = [
|
||
usePlugins.subscribe((s, prev) => {
|
||
if (s.loaded !== prev.loaded || s.activeTheme !== prev.activeTheme) reconcile();
|
||
}),
|
||
useProject.subscribe((s, prev) => {
|
||
if (s.settings?.appearance !== prev.settings?.appearance) reconcile();
|
||
}),
|
||
useThemeOverride.subscribe(reconcile),
|
||
useContributedThemes.subscribe(reconcile),
|
||
subscribeScheme(reconcile),
|
||
];
|
||
return () => disposers.forEach((d) => d());
|
||
}
|
||
|
||
/** Reports the focused editor's file to the backend (`editor-focused` events). */
|
||
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 "themeChanged":
|
||
usePlugins.setState({ activeTheme: e.theme });
|
||
break;
|
||
case "consentRequested":
|
||
void refreshPlugins().then(() => grantPlugin(e.pluginId, e.capabilities));
|
||
break;
|
||
case "languagesChanged":
|
||
void refreshLanguageInfos();
|
||
languageListChanged();
|
||
break;
|
||
default:
|
||
handleRuntimeEvent(e);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* For windows that only show plugins (Settings): stays in line with what
|
||
* changes elsewhere — installs, enable / disable, the theme, languages —
|
||
* while notifications and permission prompts stay with the project windows.
|
||
*/
|
||
export function followPluginChanges(): () => void {
|
||
return pluginsBackend().onEvent((e) => {
|
||
if (e.type === "commandsChanged" || e.type === "themeChanged" || e.type === "languagesChanged")
|
||
handlePluginEvent(e);
|
||
});
|
||
}
|
||
|
||
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
function scheduleRefresh() {
|
||
if (refreshTimer) return;
|
||
refreshTimer = setTimeout(() => {
|
||
refreshTimer = null;
|
||
void refreshPlugins();
|
||
refreshEventKinds();
|
||
}, 30);
|
||
}
|
||
|
||
/**
|
||
* Fires `on-language:<id>` and `on-file:<project-relative path>` for files
|
||
* opened in the editor; the backend activates the plugins waiting for them.
|
||
*/
|
||
function trackActivationEvents(): () => void {
|
||
const seenLanguages = new Set<string>();
|
||
const report = (ctx: { path: string | null; language: string } | undefined) => {
|
||
if (!ctx?.path) return;
|
||
if (!seenLanguages.has(ctx.language)) {
|
||
seenLanguages.add(ctx.language);
|
||
void pluginsBackend()
|
||
.activationEvent("language", ctx.language)
|
||
.catch(() => seenLanguages.delete(ctx.language));
|
||
}
|
||
const root = useProject.getState().project?.root;
|
||
if (root && ctx.path.startsWith(`${root}/`)) {
|
||
void pluginsBackend()
|
||
.activationEvent("file", ctx.path.slice(root.length + 1))
|
||
.catch(() => {});
|
||
}
|
||
};
|
||
report(useEditorRegistry.getState().active?.ctx);
|
||
return useEditorRegistry.subscribe((s, prev) => {
|
||
if (s.active?.ctx.path !== prev.active?.ctx.path) report(s.active?.ctx);
|
||
});
|
||
}
|
||
|
||
export function startPluginBridge(): () => void {
|
||
const disposers = [
|
||
defineBuiltinUiPoints(),
|
||
pluginsBackend().onEvent(handlePluginEvent),
|
||
syncProjection(),
|
||
trackPluginTheme(),
|
||
followThemeSetting(),
|
||
trackIconTheme(),
|
||
trackActiveFile(),
|
||
trackActivationEvents(),
|
||
startRuntimeUi(),
|
||
];
|
||
return () => {
|
||
if (refreshTimer) clearTimeout(refreshTimer);
|
||
refreshTimer = null;
|
||
disposers.reverse().forEach((d) => d());
|
||
};
|
||
}
|