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

+18 -2
Переглянути файл
@@ -140,8 +140,13 @@ export function actionToggled(action: Action | undefined): boolean | undefined {
}
}
type ActionListener = (id: string, arg: unknown) => void;
/** How the user started an action (for listeners such as plugins' `action-performed`). */
export type ActionSource = "keyboard" | "menu" | "toolbar" | "contextMenu" | "search" | "other";
type ActionListener = (id: string, arg: unknown, source: ActionSource) => void;
const listeners = new Set<ActionListener>();
/** Set by `executeActionFrom` for the duration of the synchronous dispatch. */
let currentSource: ActionSource = "other";
/** Actions running synchronously inside another action's `run`. */
let nesting = 0;
@@ -166,7 +171,7 @@ export async function executeAction(id: string, arg?: unknown): Promise<boolean>
if (nesting === 0)
for (const l of listeners)
try {
l(id, arg);
l(id, arg, currentSource);
} catch (e) {
console.error("action listener failed", e);
}
@@ -184,3 +189,14 @@ export async function executeAction(id: string, arg?: unknown): Promise<boolean>
}
return true;
}
/** `executeAction`, telling listeners how the user started it. */
export function executeActionFrom(source: ActionSource, id: string, arg?: unknown): Promise<boolean> {
const previous = currentSource;
currentSource = source;
try {
return executeAction(id, arg);
} finally {
currentSource = previous;
}
}
+5 -2
Переглянути файл
@@ -1,7 +1,7 @@
/** Visible-file JVM presentation: one bounded IPC batch, shared cache and watcher. */
import { useEffect, useMemo } from "react";
import { create } from "zustand";
import { fileIconName, type IconName } from "@/ui/icons";
import { fileIconName, useFileIconRevision, type IconName } from "@/ui/icons";
import { call, commands, isTauri, onIdeEvent, type JvmPresentation } from "./ipc";
import { basename, relativePath, useProject } from "./project";
import { rootFor, type RootFolder } from "./languageSettings";
@@ -157,6 +157,7 @@ export function useJvmPresentation(path: string, mtime?: number | null) {
}
export function useFilePresentation(path: string, mtime?: number | null) {
useFileIconRevision();
const info = useJvmPresentation(path, mtime);
const roots = useEffectiveRoots();
const root = useProject((s) => s.project?.root ?? null);
@@ -173,6 +174,7 @@ export function useFilePresentationIcons(paths: readonly string[]) {
return () => disposers.forEach((dispose) => dispose());
}, [key]);
const entries = cache((s) => s.entries);
const iconRevision = useFileIconRevision();
const roots = useEffectiveRoots();
const root = useProject((s) => s.project?.root ?? null);
const build = useProjectPresentation();
@@ -184,6 +186,7 @@ export function useFilePresentationIcons(paths: readonly string[]) {
presentationIcon(path, entries.get(path), roots, root, build.spring),
]),
),
[key, entries, roots, root, build.spring],
// eslint-disable-next-line react-hooks/exhaustive-deps -- iconRevision: the file icon theme changed
[key, entries, roots, root, build.spring, iconRevision],
);
}
+7
Переглянути файл
@@ -8,6 +8,7 @@
* at either level (`ide:<key>` / `project:<key>`).
* Mirrors `ide-core::inspection_settings`.
*/
import { create } from "zustand";
import type { Diagnostic, Severity } from "@/bindings";
import { useProject } from "./project";
@@ -472,6 +473,12 @@ function loadSeen(): Map<string, SeenCode> {
return seen;
}
/**
* Names of the producers that aren't language servers, for Settings ›
* Inspections: plugins (`plugin:<id>`) by their names.
*/
export const useProducerNames = create<{ names: ReadonlyMap<string, string> }>(() => ({ names: new Map() }));
/** Records the codes of a producer's diagnostics (persisted, shared with the Settings window). */
export function noteDiagnosticCodes(producer: string, items: readonly Diagnostic[]) {
if (!producer) return;
+55 -9
Переглянути файл
@@ -11,7 +11,7 @@
* optionally with modifiers (`Alt+Button4`); they are single strokes.
*/
import { create } from "zustand";
import { getAction, isActionKeyEnabled, executeAction } from "./actions";
import { executeAction, executeActionFrom, getAction, isActionKeyEnabled } from "./actions";
import { getContext } from "./context";
import { intellijMacos } from "./keymaps/intellij-macos";
import { intellijWindows } from "./keymaps/intellij-windows";
@@ -70,9 +70,11 @@ interface KeymapState {
preset: string;
/** User overrides: action id → bindings (empty array removes defaults). */
overrides: Keymap;
/** Extensions' default bindings (plugins), below the preset. */
extensions: Keymap;
}
export const useKeymap = create<KeymapState>(() => ({ preset: defaultPreset(), overrides: {} }));
export const useKeymap = create<KeymapState>(() => ({ preset: defaultPreset(), overrides: {}, extensions: {} }));
const MOUSE_KEY_RE = /^Button(\d+)$/i;
@@ -189,17 +191,61 @@ export function suspendKeyboardDispatch(): () => void {
}
export function bindingsFor(actionId: string): string[] {
const { preset, overrides } = useKeymap.getState();
return overrides[actionId] ?? PRESETS[preset]?.[actionId] ?? [];
const { preset, overrides, extensions } = useKeymap.getState();
return overrides[actionId] ?? PRESETS[preset]?.[actionId] ?? usableExtensionBindings(extensions)[actionId] ?? [];
}
/** Every (binding, actionId) pair of the effective keymap. */
export function effectiveBindings(): Array<[string, string]> {
const { preset, overrides } = useKeymap.getState();
const merged: Keymap = { ...PRESETS[preset], ...overrides };
const { preset, overrides, extensions } = useKeymap.getState();
const merged: Keymap = { ...usableExtensionBindings(extensions), ...PRESETS[preset], ...overrides };
return Object.entries(merged).flatMap(([id, keys]) => keys.map((k) => [normalizeBinding(k), id] as [string, string]));
}
let extensionCache: { source: Keymap; preset: string; overrides: Keymap; usable: Keymap } | null = null;
/**
* Plugin default bindings that stay: those of actions the preset and the
* user leave unbound, on keystrokes nothing else uses (the IDE's own
* shortcuts always win).
*/
function usableExtensionBindings(extensions: Keymap | undefined = {}): Keymap {
const { preset, overrides } = useKeymap.getState();
if (
extensionCache?.source === extensions &&
extensionCache.preset === preset &&
extensionCache.overrides === overrides
)
return extensionCache.usable;
const builtIn: Keymap = { ...PRESETS[preset], ...overrides };
const taken = new Set(Object.values(builtIn).flatMap((keys) => keys.map(normalizeBinding)));
const usable: Keymap = {};
for (const [id, keys] of Object.entries(extensions)) {
if (id in builtIn) continue;
const free = keys.filter((k) => !taken.has(normalizeBinding(k)));
for (const k of free) taken.add(normalizeBinding(k));
if (free.length) usable[id] = free;
}
extensionCache = { source: extensions, preset, overrides, usable };
return usable;
}
/**
* Default bindings contributed by an extension (a plugin's `keymaps`), below
* the preset and the user's: dropped when the keystroke is already taken.
* Returns a disposer.
*/
export function registerExtensionBindings(actionId: string, bindings: string[]): () => void {
const valid = bindings.filter(isValidBinding);
useKeymap.setState((s) => ({ extensions: { ...(s.extensions ?? {}), [actionId]: valid } }));
return () =>
useKeymap.setState((s) => {
if (s.extensions?.[actionId] !== valid) return s;
const { [actionId]: _, ...rest } = s.extensions ?? {};
return { extensions: rest };
});
}
const MOD_ORDER = MODIFIERS;
/** Canonical form: modifiers in fixed order, so `Shift+Meta+o` == `Meta+Shift+O`. */
@@ -402,7 +448,7 @@ export function isAltGrTyping(
function runIfEnabled(id: string): boolean {
const action = getAction(id);
if (!action || !isActionKeyEnabled(action)) return false;
void executeAction(id);
void executeActionFrom("keyboard", id);
return true;
}
@@ -477,7 +523,7 @@ export function installKeyboardDispatcher(
e.preventDefault();
e.stopPropagation();
tap.keyUsed = true;
void executeAction(id);
void executeActionFrom("keyboard", id);
return true;
}
return false;
@@ -526,7 +572,7 @@ export function installKeyboardDispatcher(
e.preventDefault();
e.stopPropagation();
reset();
void executeAction(id);
void executeActionFrom("keyboard", id);
return;
}
}
+20
Переглянути файл
@@ -0,0 +1,20 @@
/**
* Changes of the backend's language list (plugins add and remove languages),
* for the views that cache it: File Types, Override File Type, Code Style
* pages, Inspections.
*/
import { create } from "zustand";
export const useLanguageListVersion = create<{ version: number }>(() => ({ version: 0 }));
/** The backend's language list changed. */
export function languageListChanged() {
useLanguageListVersion.setState((s) => ({ version: s.version + 1 }));
}
/** Calls `cb` after each change; returns the unsubscribe function. */
export function onLanguageListChanged(cb: () => void): () => void {
return useLanguageListVersion.subscribe((s, prev) => {
if (s.version !== prev.version) cb();
});
}
+72 -4
Переглянути файл
@@ -670,7 +670,6 @@
"Detected from the toolchain": "从工具链检测到",
"Detecting installations": "正在检测安装",
"Detecting…": "正在检测…",
"Dev plugin": "开发插件",
"Diff": "差异",
"Different tool window background": "不同的工具窗口背景",
"Direction": "方向",
@@ -3731,8 +3730,6 @@
"Macro '{name}' stopped": "宏 '{name}' 已停止",
"Id": "ID",
"Enable": "启用",
"{name}: the editor is read-only": "{name}:编辑器为只读",
"{name}: no editor to insert into": "{name}:没有可插入的编辑器",
"The plugin failed to start.": "插件启动失败。",
"Move Directory…": "移动目录…",
"Cannot move {name}": "无法移动 {name}",
@@ -5711,5 +5708,76 @@
"What the session has cost so far, as the agent reports it (API pricing, also shown on subscriptions)": "智能体报告的本会话目前花费(按 API 价格计算,订阅账户也会显示)",
"Build and run using": "构建和运行使用",
"Gradle runs a main class as a JavaExec task on its source set's runtime classpath (Gradle's resource processing included). OpenIDEA launches it on the Java language server's classpath.": "Gradle 以 JavaExec 任务在源集的运行时类路径上运行主类(包括 Gradle 的资源处理)。OpenIDEA 在 Java 语言服务器的类路径上启动它。",
"Set an absolute root path on the Connection tab first": "请先在“连接”页设置绝对根路径"
"Set an absolute root path on the Connection tab first": "请先在“连接”页设置绝对根路径",
"Needs permission": "需要授权",
"linked": "已链接",
"Revoke {id}": "撤销 {id}",
"Allow {id}": "允许 {id}",
"This version asks for new permissions. It stays off until you allow them.": "此版本请求了新的权限。在你允许之前它保持停用。",
"Plugins from a project folder run only after you allow them.": "来自项目文件夹的插件需要你允许后才会运行。",
"Review Permissions…": "查看权限…",
"Statistics": "统计",
"Activated {activations}× (last in {toFixed} ms), {calls} calls, {errors} errors, slowest {toFixed} ms": "已激活 {activations} 次(最近一次 {toFixed} 毫秒),调用 {calls} 次,错误 {errors} 次,最慢 {toFixed} 毫秒",
"Recent calls p95 {toFixed} ms; {crashes} crashes, {slowCalls} editor results too late, reclaimed {reclaims}× when idle": "近期调用 p95 {toFixed} 毫秒;崩溃 {crashes} 次,编辑器结果超时 {slowCalls} 次,空闲回收 {reclaims} 次",
"Linked plugin": "链接的插件",
"Project plugin": "项目插件",
"Signed by {publisher}": "由 {publisher} 签名",
"Unsigned": "未签名",
"Roll Back to {previousVersion}": "回滚到 {previousVersion}",
"Install Plugin from Package File…": "从插件包文件安装…",
"Marketplace index URL": "插件市场索引 URL",
"Refresh Marketplace": "刷新插件市场",
"Marketplace": "插件市场",
"Could not download plugin": "无法下载插件",
"Rolled back {name} to {version}": "已将 {name} 回滚到 {version}",
"Could not roll back": "无法回滚",
"Could not open the package": "无法打开插件包",
"Marketplace index URL (https://…/index.json or a folder path)": "插件市场索引 URL(https://…/index.json 或文件夹路径)",
"Settings of the plugin; projects can override them.": "插件的设置;项目可以覆盖。",
"Menus": "菜单",
"Shortcuts": "快捷键",
"Status Bar Items": "状态栏项",
"Editors": "编辑器",
"Open With": "打开方式",
"The plugin could not show this view": "插件无法显示此视图",
"Loading": "加载中",
"Insert Row Above": "在上方插入行",
"Insert Row Below": "在下方插入行",
"Delete {length} Rows": "删除 {length} 行",
"Delete Row": "删除行",
"No rows": "没有行",
"Cell value": "单元格的值",
"Secrets": "密钥",
"Stored": "已保存",
"Not set": "未设置",
"Replace…": "替换…",
"Value of {label}": "{label} 的值",
"The plugin can only send these with its network requests; it cannot read them.": "插件只能在网络请求中发送这些值,无法读取它们。",
"Editor Providers": "编辑器提供者",
"Linked for development": "为开发而链接",
"Low": "低",
"Medium": "中",
"High": "高",
"Install {name} {version}?": "安装 {name} {version}?",
"Allow {name} to run?": "允许 {name} 运行?",
"Allow": "允许",
"This package is not signed. Only continue if you trust where it came from.": "此软件包未签名。只有在信任其来源时才继续。",
"This plugin needs no special permissions.": "此插件不需要特殊权限。",
"Requested permissions": "请求的权限",
"Required": "必需",
"High-risk permissions are equivalent to running arbitrary code on this machine.": "高风险权限相当于在这台计算机上运行任意代码。",
"{icons} icons": "{icons} 个图标",
"Use Default Icons": "使用默认图标",
"Apply icon theme {label}": "应用图标主题 {label}",
"File Icon Themes": "文件图标主题",
"File Icons": "文件图标",
"File Templates": "文件模板",
"Cannot show {label}": "无法显示 {label}",
"{title} layout": "{title} 布局",
"Editor and {title}": "编辑器和{title}",
"{title} Only": "仅{title}",
"File icon theme": "文件图标主题",
"Plugin not enabled": "插件未启用",
"Problems the {plugin} plugin reports. Severity \"As reported\" keeps the level the plugin assigns; a code below can override it.": "{plugin} 插件报告的问题。严重性“按报告”保留插件分配的级别;下方的代码可以覆盖它。",
"The plugin system is a preview: its API and the plugins may still change.": "插件系统目前是预览版:API 和插件都可能还会变化。"
}
+25
Переглянути файл
@@ -0,0 +1,25 @@
/**
* Watching the active IDE theme: built-in theme, palette theme, plugin theme
* and accent all live as attributes / inline style on the root element.
*/
/** Theme identity: changes whenever the colours the theme tokens resolve to may change. */
export function themeKey(): string {
const root = document.documentElement;
const { theme = "", contrast = "", colorTheme = "", pluginTheme = "" } = root.dataset;
const accent = root.style.getPropertyValue("--accent");
// The UI font (the custom one, or its fallback once loaded) is part of how a theme renders.
const font = getComputedStyle(root).getPropertyValue("--font-ui");
return `${theme}|${contrast}|${colorTheme}|${pluginTheme}|${accent}|${font}`;
}
/** Calls `cb` when the theme attributes (a plugin theme's included) or the accent on the root change. */
export function subscribeTheme(cb: () => void): () => void {
if (typeof MutationObserver === "undefined") return () => {};
const observer = new MutationObserver(cb);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-contrast", "data-color-theme", "data-plugin-theme", "style"],
});
return () => observer.disconnect();
}
+54 -2
Переглянути файл
@@ -1,8 +1,24 @@
import { readFileSync } from "node:fs";
import { afterEach, describe, expect, it } from "vitest";
import { BUILTIN_THEMES, isBuiltinTheme, themeKind } from "./themes";
import {
addContributedTheme,
BUILTIN_THEMES,
isBuiltinTheme,
isThemeAvailable,
parsePluginThemeRef,
pluginThemeRef,
themeChoices,
themeKind,
useContributedThemes,
} from "./themes";
import { _setSchemeSource, effectiveScheme, schemeCss, schemeTokens, themeScheme } from "./colorScheme";
import { applyThemeAttributes, resolveTheme, resolveThemeId } from "@/workbench/theme";
import {
applyThemeAttributes,
resolveTheme,
resolveThemeChoice,
resolveThemeId,
themeSelectOptions,
} from "@/workbench/theme";
import { validateSettings } from "@/features/settings/model/schema";
import { applyPluginTheme } from "@/features/plugins/bridge";
@@ -74,3 +90,39 @@ describe("built-in palettes", () => {
expect(root.dataset.contrast).toBeUndefined();
});
});
describe("plugin themes", () => {
const ref = pluginThemeRef("acme.night", "night");
afterEach(() => {
useContributedThemes.setState({ themes: [] });
localStorage.removeItem("openidea.themeKinds");
});
it("are settings values like the built-in ones, in every picker while their plugin is live", () => {
const a = { theme: ref, preferredLightTheme: "light", preferredDarkTheme: ref };
expect(validateSettings({ appearance: a }).appearance).toMatchObject(a);
expect(resolveThemeChoice(a, false)).toBe(ref);
expect(resolveThemeChoice({ ...a, syncWithOs: true }, false)).toBe(ref);
expect(resolveThemeChoice({ ...a, syncWithOs: true }, true)).toBe("light");
const remove = addContributedTheme({ id: ref, label: "Night", kind: "dark", origin: "Acme" });
expect(isThemeAvailable(ref)).toBe(true);
expect(themeChoices().at(-1)).toMatchObject({ id: ref, label: "Night" });
expect(themeSelectOptions(themeChoices(), ref).at(-1)).toEqual({ value: ref, label: "Night", detail: "Acme" });
// Its tokens apply over the default of its kind.
expect(resolveThemeId(a, false)).toBe("dark");
remove();
expect(isThemeAvailable(ref)).toBe(false);
// Its plugin off: the base of the kind it had shows, and the pickers keep it listed.
expect(resolveThemeId(a, false)).toBe("dark");
expect(themeSelectOptions(themeChoices(), ref).at(-1)).toMatchObject({ value: ref, label: "night" });
});
it("parse only well-formed references", () => {
expect(parsePluginThemeRef(ref)).toEqual({ pluginId: "acme.night", themeId: "night" });
for (const bad of ["plugin:", "plugin:/night", "plugin:acme/", "acme/night", 3])
expect(parsePluginThemeRef(bad)).toBeNull();
expect(validateSettings({ appearance: { theme: "plugin:/x" } }).appearance.theme).toBe("light");
});
});
+99
Переглянути файл
@@ -1,3 +1,5 @@
import { create } from "zustand";
/** Stable built-in ids shared by settings, quick switch and editor schemes. */
export const BUILTIN_THEMES = [
{ id: "light", label: "Islands Light", kind: "light" },
@@ -17,3 +19,100 @@ export function isBuiltinTheme(value: unknown): value is BuiltinThemeId {
export function themeKind(id: BuiltinThemeId): "light" | "dark" {
return BUILTIN_THEMES.find((t) => t.id === id)!.kind;
}
// --- themes contributed at runtime (plugins) ---------------------------------
/** A plugin theme as a theme setting value: `plugin:<pluginId>/<themeId>`. */
export type PluginThemeRef = `plugin:${string}`;
/** Any theme the settings can name: built-in or contributed. */
export type ThemeChoiceId = BuiltinThemeId | PluginThemeRef;
export interface ThemeChoice {
id: ThemeChoiceId;
label: string;
kind: "light" | "dark";
/** Who contributes it (a plugin's name); built-in themes have none. */
origin?: string;
}
export const pluginThemeRef = (pluginId: string, themeId: string): PluginThemeRef => `plugin:${pluginId}/${themeId}`;
export function parsePluginThemeRef(value: unknown): { pluginId: string; themeId: string } | null {
if (typeof value !== "string" || !value.startsWith("plugin:")) return null;
const rest = value.slice("plugin:".length);
const slash = rest.lastIndexOf("/");
if (slash <= 0 || slash === rest.length - 1) return null;
return { pluginId: rest.slice(0, slash), themeId: rest.slice(slash + 1) };
}
export function isThemeChoiceId(value: unknown): value is ThemeChoiceId {
return isBuiltinTheme(value) || parsePluginThemeRef(value) !== null;
}
/** Themes of live plugins, offered next to the built-in ones. */
export const useContributedThemes = create<{ themes: readonly ThemeChoice[] }>(() => ({ themes: [] }));
const KINDS_KEY = "openidea.themeKinds";
function rememberedKinds(): Record<string, "light" | "dark"> {
try {
const v: unknown = JSON.parse(localStorage.getItem(KINDS_KEY) ?? "{}");
return v && typeof v === "object" ? (v as Record<string, "light" | "dark">) : {};
} catch {
return {};
}
}
/** Offers a contributed theme until the returned function is called. */
export function addContributedTheme(theme: ThemeChoice): () => void {
useContributedThemes.setState((s) => ({ themes: [...s.themes.filter((t) => t.id !== theme.id), theme] }));
// Its kind outlives the plugin: a disabled plugin's theme falls back to the built-in of that kind.
try {
const kinds = rememberedKinds();
if (kinds[theme.id] !== theme.kind)
localStorage.setItem(KINDS_KEY, JSON.stringify({ ...kinds, [theme.id]: theme.kind }));
} catch {
/* storage unavailable */
}
return () => useContributedThemes.setState((s) => ({ themes: s.themes.filter((t) => t !== theme) }));
}
/** Every theme that can be selected: the built-in ones, then the contributed ones by name. */
export function themeChoices(
contributed: readonly ThemeChoice[] = useContributedThemes.getState().themes,
): ThemeChoice[] {
return [...BUILTIN_THEMES, ...[...contributed].sort((a, b) => a.label.localeCompare(b.label))];
}
/** Whether `id` can be applied now (a contributed theme's plugin may be off). */
export function isThemeAvailable(id: ThemeChoiceId): boolean {
return isBuiltinTheme(id) || useContributedThemes.getState().themes.some((t) => t.id === id);
}
/**
* The built-in theme under `id`: itself, or for a contributed theme the
* light / dark default of its kind (the base its tokens apply over, and what
* shows while its plugin is off).
*/
export function baseTheme(id: ThemeChoiceId, fallback: BuiltinThemeId = "light"): BuiltinThemeId {
if (isBuiltinTheme(id)) return id;
const kind = useContributedThemes.getState().themes.find((t) => t.id === id)?.kind ?? rememberedKinds()[id];
return kind === "dark" ? "dark" : kind === "light" ? "light" : fallback;
}
// --- file icon themes (plugins) -------------------------------------------------
export interface IconThemeChoice {
/** `<pluginId>/<themeId>`: the `appearance.fileIconTheme` value. */
id: string;
label: string;
origin?: string;
}
/** File icon themes of live plugins, for Settings › Appearance. */
export const useContributedIconThemes = create<{ themes: readonly IconThemeChoice[] }>(() => ({ themes: [] }));
export function addContributedIconTheme(theme: IconThemeChoice): () => void {
useContributedIconThemes.setState((s) => ({ themes: [...s.themes.filter((t) => t.id !== theme.id), theme] }));
return () => useContributedIconThemes.setState((s) => ({ themes: s.themes.filter((t) => t !== theme) }));
}