feat(plugins): plugin system with extension points, consent, Wasm runtime and plugin API 1.0 (#971)

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]>
Цей коміт міститься в:
authored and GitHub committed 2026-11-04 02:54:31 +08:00
1 джерело cfdbd3872a
коміт 1c5a92f952
450 files changed
+47018 -3058

No files matched your search

+2 -2
Переглянути файл
@@ -13,7 +13,7 @@ import {
} from "react";
import clsx from "clsx";
import { create } from "zustand";
import { getAction, isActionEnabled, executeAction, actionToggled } from "@/platform/actions";
import { getAction, isActionEnabled, executeActionFrom, actionToggled } from "@/platform/actions";
import { useContextKeys } from "@/platform/context";
import { shortcutLabel } from "@/platform/keymap";
import { Icon, type IconName } from "./icons";
@@ -84,7 +84,7 @@ export function resolveMenuItems(items: MenuItem[]): Resolved[] {
shortcut: shortcutLabel(item.action),
disabled: !!item.disabled || !isActionEnabled(a),
checked: actionToggled(a),
run: () => void executeAction(item.action, item.arg),
run: () => void executeActionFrom("menu", item.action, item.arg),
});
return;
}
+75
Переглянути файл
@@ -21,6 +21,7 @@
*/
import { useId, useMemo, type CSSProperties } from "react";
import clsx from "clsx";
import { create } from "zustand";
import styles from "./icons.module.css";
const RAW = import.meta.glob<string>(["./expui/**/*.svg", "./openidea/**/*.svg"], {
@@ -537,10 +538,64 @@ export type IconName = BaseName | `symbol-${SymbolKind}`;
export const ICON_NAMES = Object.keys(ICONS) as BaseName[];
function iconPath(name: string): string | undefined {
if (name.startsWith(EXTERNAL_PREFIX)) return EXTERNAL.has(name) ? name : undefined;
if (name.startsWith("symbol-")) return SYMBOL_ICONS[name.slice(7) as SymbolKind];
return (ICONS as Record<string, string>)[name];
}
// ---------------------------------------------------------------------------
// Icons supplied at runtime (plugin icon themes). The markup was sanitised by
// the backend (`ide_host::plugins::sanitize_svg`); names are `ext:<source>/<icon>`.
const EXTERNAL_PREFIX = "ext:";
const EXTERNAL = new Map<string, string>();
/** Registers icons by name (`ext:…`); the returned function removes them. */
export function registerExternalIcons(icons: ReadonlyArray<readonly [name: string, svg: string]>): () => void {
const names: string[] = [];
for (const [name, svg] of icons) {
if (!name.startsWith(EXTERNAL_PREFIX)) continue;
EXTERNAL.set(name, svg);
prepared.delete(`${name}|16`);
prepared.delete(`${name}|20`);
names.push(name);
}
return () => {
for (const name of names) {
EXTERNAL.delete(name);
prepared.delete(`${name}|16`);
prepared.delete(`${name}|20`);
}
};
}
/** A file icon theme (plugin `iconThemes`). Each method returns an icon name, or null for the built-in one. */
export interface FileIconTheme {
file(fileName: string): string | null;
/** Plain folders only: folders with a role (source root, excluded, …) keep their built-in icons. */
folder(folderName: string, expanded: boolean): string | null;
}
let fileIconTheme: FileIconTheme | null = null;
/** Bumped when the file icon theme changes; file lists subscribe to redraw their icons. */
export const useFileIconRevision = create<number>(() => 0);
/**
* Sets the file icon theme: consulted after the user's own file type
* associations and before the built-in icons.
*/
export function setFileIconTheme(theme: FileIconTheme | null) {
if (fileIconTheme === theme) return;
fileIconTheme = theme;
useFileIconRevision.setState((n) => n + 1, true);
}
/** The icon theme's icon for a plain folder, or null to keep the built-in one. */
export function themedFolderIconName(folderName: string, expanded = false): IconName | null {
return (fileIconTheme?.folder(folderName, expanded) ?? null) as IconName | null;
}
// ---------------------------------------------------------------------------
// SVG processing
@@ -562,6 +617,7 @@ interface Prepared {
}
function raw(path: string): string | undefined {
if (path.startsWith(EXTERNAL_PREFIX)) return EXTERNAL.get(path);
return RAW[path.startsWith("openidea/") ? `./${path}.svg` : `./expui/${path}.svg`];
}
@@ -930,10 +986,29 @@ export function setFileIconOverride(resolve: ((fileName: string) => IconName | n
iconOverride = resolve;
}
/** Icons for particular files (plugin `fileIcons`), newest first: after the user's, before the icon theme's. */
let fileIconProviders: ReadonlyArray<(fileName: string) => string | null> = [];
/** Adds a file icon provider (an `ext:` icon name or null); the returned function removes it. */
export function addFileIconProvider(resolve: (fileName: string) => string | null): () => void {
fileIconProviders = [resolve, ...fileIconProviders];
useFileIconRevision.setState((n) => n + 1, true);
return () => {
fileIconProviders = fileIconProviders.filter((p) => p !== resolve);
useFileIconRevision.setState((n) => n + 1, true);
};
}
/** Picks a file-type icon from a file name. */
export function fileIconName(fileName: string): IconName {
const custom = iconOverride?.(fileName);
if (custom) return custom;
for (const provide of fileIconProviders) {
const icon = provide(fileName);
if (icon && iconPath(icon)) return icon as IconName;
}
const themed = fileIconTheme?.file(fileName);
if (themed) return themed as IconName;
const lower = fileName.toLowerCase();
const named = FILE_NAMES[lower] ?? NAME_PATTERNS.find(([re]) => re.test(lower))?.[1];
if (named) return named;