Файли
openidea/src/features/plugins/bridge.ts
T
l0ng-ai 9cc83c77b8 feat(plugins): plugin contributions in the IDE's own settings and menus; official plugins published with releases
- Themes: a plugin theme is an `appearance.theme` value
  (`plugin:<id>/<theme>`), offered by Settings › Appearance (and the
  preferred themes of Sync with OS), Quick Switch, the toolbar gear menu and
  Welcome › Customize. The project window selects it in the plugin host;
  picking a built-in theme clears it. While its plugin is off, the built-in
  theme of its kind shows and the choice is kept.
- File icon themes: chosen in Settings › Appearance › File icon theme and
  kept in `appearance.fileIconTheme`, so every window follows.
- Keymap: plugins' default shortcuts show in Settings › Keymap as the
  defaults of their actions: removable, changeable, resettable and part of
  conflict checks.
- Problems: a plugin's problems are withdrawn when it stops, fails, is
  disabled or uninstalled. Settings › Inspections lists them under Plugins by
  plugin name; stored overrides of `plugin:` producers parse correctly.
- Status bar items of plugins are named in the status bar menu and can be
  hidden.
- Languages added or removed by plugins refresh Override File Type, the
  Code Style pages and Inspections without a restart.
- Uninstalling a plugin deletes the secrets it declared.
- The Settings window follows plugin changes made elsewhere.
- The plugin system is marked Preview (Settings › Plugins, README); Harbor
  Night is versioned 0.1.0 like the other official plugins.
- Release: the official plugins are packed, signed (secret
  PLUGIN_SIGNING_KEY) and indexed into the release as
  `plugins-index.json`; released builds use it as the default marketplace.
  Without the key the step is skipped.
2026-11-04 02:41:46 +08:00

319 рядки
11 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
/**
* 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());
};
}