дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
- 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.
319 рядки
11 KiB
TypeScript
319 рядки
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());
|
||
};
|
||
}
|