feat(plugins): extension points, capability consent, async runtime and plugin API 1.0

Rework the plugin system per docs/plugin_plan.md (P0–P8):

- Extension-point registry (ide-extension) so new contribution types register
  without touching the kernel; languages, run detectors, providers, views and
  UI contributions go through it.
- Per-plugin capability consent with a risk-graded ledger; process
  capabilities are pinned to resolved binary paths and hashes.
- Async, actor-per-plugin Wasm runtime with lazy activation, cancellation,
  events, long-running tasks and idle reclamation.
- Plugin host moved into ide-host, shared by local and remote projects.
- Distribution: install/package tooling (openidea-plugin bin) and a static
  market index.
- WIT frozen as openidea:[email protected]; Rust SDK with mock host.
- Ten official validation plugins under plugins/official.
Цей коміт міститься в:
l0ng-ai committed 2026-11-04 02:41:46 +08:00
1 джерело 63801ba249
коміт fd1f465aeb
326 files changed
+35587 -2777

No files matched your search

+72 -1
Переглянути файл
@@ -198,11 +198,13 @@
.header {
display: flex;
align-items: flex-start;
/* In a narrow window the actions go below rather than over the name. */
flex-wrap: wrap;
gap: 14px;
}
.headerText {
display: flex;
flex: 1;
flex: 1 1 160px;
flex-direction: column;
gap: 2px;
min-width: 0;
@@ -222,6 +224,11 @@
flex-wrap: wrap;
gap: 8px;
}
/* Badges are counter pills (`min-width: 16px`): in a narrow window they wrap instead of shrinking under their text. */
.titleLine > * {
flex: none;
white-space: nowrap;
}
.title {
margin: 0;
color: var(--fg-strong);
@@ -313,3 +320,67 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* Permission dialog and notices */
.consent {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.consentWarning {
margin: 0;
color: var(--fg-warning, var(--fg));
}
.consentRequired {
width: 16px;
text-align: center;
color: var(--fg-muted);
}
.consentNotice {
display: flex;
align-items: center;
gap: 12px;
justify-content: space-between;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 12px;
}
.settingsPage {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 560px;
}
.settingRow {
display: flex;
flex-direction: column;
gap: 4px;
}
.marketItem {
list-style: none;
padding: 4px 8px;
}
.marketBar {
display: flex;
gap: 4px;
align-items: center;
}
.marketBar > :first-child {
flex: 1;
min-width: 0;
}
.marketOverview {
padding: 12px 16px;
overflow: auto;
}
+317 -132
Переглянути файл
@@ -8,7 +8,7 @@
*/
import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react";
import clsx from "clsx";
import type { CatalogPlugin, Permission, PluginInfo, PluginStatus } from "@/bindings";
import type { CapabilityInfo, CatalogPlugin, MarketPlugin, PluginInfo, PluginStatus } from "@/bindings";
import {
Badge,
Button,
@@ -16,7 +16,7 @@ import {
EmptyState,
Icon,
IconButton,
Kbd,
Input,
SearchField,
Spinner,
Tabs,
@@ -24,16 +24,21 @@ import {
type MenuItem,
T,
} from "@/ui";
import { pluginActionId } from "./bridge";
import { RISK_LABEL, sortByRisk } from "./consent";
import { itemsOf, uiPoint } from "./projection";
import {
compareVersions,
grantPlugin,
installFromDisk,
installFromMarket,
refreshMarket,
rollBackPlugin,
setMarketUrl,
refreshCatalog,
refreshPlugins,
reloadPlugin,
runPluginCommand,
selectPluginTheme,
setAllInstalledEnabled,
setPluginCapability,
setPluginEnabled,
uninstallPlugin,
usePlugins,
@@ -41,19 +46,26 @@ import {
import styles from "./PluginsPage.module.css";
import { tx } from "@/platform/i18n";
const STATUS: Record<PluginStatus, { label: string; tone: "success" | "accent" | "neutral" | "danger" }> = {
const STATUS: Record<PluginStatus, { label: string; tone: "success" | "accent" | "neutral" | "danger" | "warning" }> = {
active: { label: "Running", tone: "success" },
inactive: { label: "Enabled", tone: "accent" },
static: { label: "Enabled", tone: "accent" },
disabled: { label: "Disabled", tone: "neutral" },
needsConsent: { label: "Needs permission", tone: "warning" },
failed: { label: "Failed", tone: "danger" },
};
export const PERMISSION_TEXT: Record<Permission, string> = {
"project-read": "Read files inside the open project",
"editor-read": "See which file is open in the editor",
"editor-write": "Insert text into the active editor",
/** Human names of the built-in contribution points (others show their id). */
const POINT_TITLES: Record<string, string> = {
commands: "Commands",
themes: "Themes",
iconThemes: "File Icon Themes",
languages: "Languages",
providers: "Editor Providers",
};
const pointTitle = (point: string) => uiPoint(point)?.title ?? POINT_TITLES[point] ?? point;
export const NO_REPOSITORY = "There is no online plugin repository";
type Tab = "marketplace" | "installed";
@@ -65,16 +77,14 @@ export function filterPlugins(plugins: readonly PluginInfo[], query: string): Pl
const q = query.trim().toLowerCase();
if (!q) return [...plugins];
return plugins.filter((p) =>
matches([p.name, p.id, p.description, ...p.authors, ...p.commands.map((c) => c.title)], q),
matches([p.name, p.id, p.description, ...p.authors, ...p.contributions.map((c) => c.label)], q),
);
}
export function filterCatalog(catalog: readonly CatalogPlugin[], query: string): CatalogPlugin[] {
const q = query.trim().toLowerCase();
if (!q) return [...catalog];
return catalog.filter((p) =>
matches([p.name, p.id, p.description, ...p.authors, ...p.commands.map((c) => c.title)], q),
);
return catalog.filter((p) => matches([p.name, p.id, p.description, ...p.authors], q));
}
/** What the Available tab offers for a catalog package given what is installed. */
@@ -84,7 +94,7 @@ export function catalogState(
): "install" | "update" | "installed" | "dev" {
const have = installed.find((p) => p.id === entry.id);
if (!have) return "install";
if (have.source === "dev") return "dev";
if (have.source !== "installed") return "dev";
return compareVersions(entry.version, have.version) > 0 ? "update" : "installed";
}
@@ -124,16 +134,19 @@ function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; sele
<span className={styles.rowMeta}>
<span className={styles.rowVendor}>{vendorOf(plugin.authors) || plugin.id}</span>
<span>{plugin.version}</span>
{plugin.source === "dev" && (
<span className={styles.devTag}>
<T>dev</T>
</span>
{plugin.source !== "installed" && (
<span className={styles.devTag}>{plugin.source === "linked" ? tx("linked") : tx("dev")}</span>
)}
{plugin.status === "failed" && (
<Badge dot tone="danger">
<T>Failed</T>
</Badge>
)}
{plugin.status === "needsConsent" && (
<Badge dot tone="warning">
<T>Needs permission</T>
</Badge>
)}
</span>
</span>
</button>
@@ -234,12 +247,73 @@ function DetailTabs({ notes, overview }: { notes: string | null; overview: React
);
}
/** Contribution points of a plugin, in the order they were committed. */
function pointsOf(plugin: PluginInfo): string[] {
return [...new Set(plugin.contributions.map((c) => c.point))];
}
function PointSection({ plugin, point }: { plugin: PluginInfo; point: string }) {
const ui = usePlugins((s) => s.ui);
const Details = uiPoint(point)?.Details;
const items = itemsOf(ui, point, plugin.id);
return (
<Section title={pointTitle(point)}>
{Details && items.length > 0 ? (
<Details plugin={plugin} items={items as never} />
) : (
<ul className={styles.items}>
{plugin.contributions
.filter((c) => c.point === point)
.map((c, i) => (
<li key={`${c.label}-${i}`} className={styles.item}>
<span className={styles.itemMain}>{c.label}</span>
</li>
))}
</ul>
)}
</Section>
);
}
function CapabilityRow({ cap, children }: { cap: CapabilityInfo; children?: ReactNode }) {
return (
<li className={styles.item}>
{children}
<span className={styles.itemMain}>{cap.description}</span>
<span className={styles.mono}>{cap.id}</span>
<Badge tone={RISK_LABEL[cap.risk].tone}>{RISK_LABEL[cap.risk].label}</Badge>
</li>
);
}
function CapabilityList({ plugin, busy }: { plugin: PluginInfo; busy: boolean }) {
if (plugin.capabilities.length === 0) {
return (
<div className={styles.muted}>
<T>No special permissions.</T>
</div>
);
}
const confirmed = plugin.status !== "needsConsent";
return (
<ul className={styles.items} aria-label={tx("Permissions")}>
{sortByRisk(plugin.capabilities).map((c) => (
<CapabilityRow key={c.id} cap={c}>
<Checkbox
checked={c.granted}
disabled={busy || !confirmed}
onChange={(on) => void setPluginCapability(plugin.id, c.id, on)}
aria-label={c.granted ? tx`Revoke ${c.id}` : tx`Allow ${c.id}`}
/>
</CapabilityRow>
))}
</ul>
);
}
function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
const busy = usePlugins((s) => !!s.busy[plugin.id]);
const activeTheme = usePlugins((s) => s.activeTheme);
const status = STATUS[plugin.status];
const running = plugin.status === "active";
const live = running || plugin.status === "static";
const [confirmUninstall, setConfirmUninstall] = useState(false);
const overview = (
@@ -276,95 +350,41 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
)}
</dl>
{plugin.hasCode && (
<Section title="Permissions">
{plugin.permissions.length === 0 ? (
<div className={styles.muted}>
<T>No special permissions.</T>
</div>
) : (
<ul className={styles.items}>
{plugin.permissions.map((p) => (
<li key={p} className={styles.item}>
<span className={styles.itemMain}>{PERMISSION_TEXT[p] ?? p}</span>
<span className={styles.mono}>{p}</span>
</li>
))}
</ul>
)}
</Section>
{plugin.status === "needsConsent" && (
<div className={styles.consentNotice} role="note">
<span>
{plugin.source === "installed"
? tx("This version asks for new permissions. It stays off until you allow them.")
: tx("Plugins from a project folder run only after you allow them.")}
</span>
<Button size="small" variant="primary" disabled={busy} onClick={() => void grantPlugin(plugin.id)}>
<T>Review Permissions…</T>
</Button>
</div>
)}
{plugin.commands.length > 0 && (
<Section title="Commands">
<ul className={styles.items}>
{plugin.commands.map((c) => {
const actionId = pluginActionId(plugin.id, c.id);
return (
<li key={c.id} className={styles.item}>
<span className={styles.itemMain}>{c.title}</span>
<Kbd action={actionId} />
<span className={styles.mono} title={tx("Action id (bind it in Keymap)")}>
{actionId}
</span>
<Button
size="small"
variant="ghost"
icon="run"
disabled={!running}
onClick={() => void runPluginCommand(plugin.id, c.id)}
aria-label={tx`Run ${c.title}`}
>
<T>Run</T>
</Button>
</li>
);
})}
</ul>
</Section>
)}
{plugin.warnings.map((w) => (
<div key={w} className={styles.muted}>
{w}
</div>
))}
{plugin.themes.length > 0 && (
<Section title="Themes">
<ul className={styles.items}>
{plugin.themes.map((t) => {
const isActive = activeTheme?.pluginId === plugin.id && activeTheme.theme.id === t.id;
return (
<li key={t.id} className={styles.item}>
<span className={styles.itemMain}>{t.name}</span>
<span className={styles.muted}>{t.kind === "dark" ? tx("Dark") : tx("Light")}</span>
{isActive ? (
<Button size="small" variant="secondary" onClick={() => void selectPluginTheme(null, null)}>
<T>Use Default Theme</T>
</Button>
) : (
<Button
size="small"
variant="ghost"
disabled={!live}
onClick={() => void selectPluginTheme(plugin.id, t.id)}
aria-label={tx`Apply theme ${t.name}`}
>
<T>Apply</T>
</Button>
)}
</li>
);
})}
</ul>
</Section>
)}
<Section title="Permissions">
<CapabilityList plugin={plugin} busy={busy} />
</Section>
{plugin.languages.length > 0 && (
<Section title="Languages">
<ul className={styles.items}>
{plugin.languages.map((l) => (
<li key={l.id} className={styles.item}>
<span className={styles.itemMain}>{l.name}</span>
<span className={styles.mono}>{l.extensions.map((e) => `.${e}`).join(" ") || l.id}</span>
</li>
))}
</ul>
{pointsOf(plugin).map((point) => (
<PointSection key={point} plugin={plugin} point={point} />
))}
{plugin.hasCode && plugin.stats.activations > 0 && (
<Section title="Statistics">
<div className={styles.muted}>
{tx`Activated ${plugin.stats.activations}× (last in ${(plugin.stats.activationMs ?? 0).toFixed(1)} ms), ${plugin.stats.calls} calls, ${plugin.stats.errors} errors, slowest ${(plugin.stats.maxCallMs ?? 0).toFixed(1)} ms`}
</div>
<div className={styles.muted}>
{tx`Recent calls p95 ${(plugin.stats.p95CallMs ?? 0).toFixed(1)} ms; ${plugin.stats.crashes} crashes, ${plugin.stats.slowCalls} editor results too late, reclaimed ${plugin.stats.reclaims}× when idle`}
</div>
</Section>
)}
</>
@@ -380,11 +400,15 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
<div className={styles.titleLine}>
<span className={styles.version}>{plugin.version}</span>
<Badge tone={status.tone}>{status.label}</Badge>
{plugin.source === "dev" && (
<Badge tone="warning">
<T>Dev plugin</T>
</Badge>
{plugin.source !== "installed" && (
<Badge tone="warning">{plugin.source === "linked" ? tx("Linked plugin") : tx("Project plugin")}</Badge>
)}
{plugin.source === "installed" &&
(plugin.publisher ? (
<Badge tone="success">{tx`Signed by ${plugin.publisher}`}</Badge>
) : (
<Badge tone="neutral">{tx("Unsigned")}</Badge>
))}
</div>
</div>
<div className={styles.headerActions}>
@@ -404,6 +428,11 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
disabled={busy}
onClick={() => void reloadPlugin(plugin.id)}
/>
{plugin.previousVersion && (
<Button size="small" disabled={busy} onClick={() => void rollBackPlugin(plugin.id)}>
{tx`Roll Back to ${plugin.previousVersion}`}
</Button>
)}
{plugin.source === "installed" &&
(confirmUninstall ? (
<>
@@ -433,11 +462,7 @@ function InstalledDetails({ plugin }: { plugin: PluginInfo }) {
}
function CatalogDetails({ entry }: { entry: CatalogPlugin }) {
const contributes = [
entry.commands.length && `${entry.commands.length} ${entry.commands.length === 1 ? "command" : "commands"}`,
entry.themeCount && `${entry.themeCount} ${entry.themeCount === 1 ? "theme" : "themes"}`,
entry.languageCount && `${entry.languageCount} ${entry.languageCount === 1 ? "language" : "languages"}`,
].filter(Boolean);
const contributes = entry.contributions.map((c) => `${pointTitle(c.point)}: ${c.count}`);
const overview = (
<>
{entry.problem && (
@@ -470,14 +495,11 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) {
</>
)}
</dl>
{entry.permissions.length > 0 && (
{entry.capabilities.length > 0 && (
<Section title="Permissions">
<ul className={styles.items}>
{entry.permissions.map((p) => (
<li key={p} className={styles.item}>
<span className={styles.itemMain}>{PERMISSION_TEXT[p] ?? p}</span>
<span className={styles.mono}>{p}</span>
</li>
{sortByRisk(entry.capabilities).map((c) => (
<CapabilityRow key={c.id} cap={c} />
))}
</ul>
</Section>
@@ -504,10 +526,11 @@ function CatalogDetails({ entry }: { entry: CatalogPlugin }) {
);
}
function gearMenu(plugins: readonly PluginInfo[], install: () => void): MenuItem[] {
function gearMenu(plugins: readonly PluginInfo[], install: (packageFile: boolean) => void): MenuItem[] {
const installed = plugins.filter((p) => p.source === "installed");
return [
{ label: "Install Plugin from Disk…", icon: "package", run: install },
{ label: "Install Plugin from Disk…", icon: "package", run: () => install(false) },
{ label: "Install Plugin from Package File…", run: () => install(true) },
{ kind: "separator" },
{
label: "Disable All Downloaded Plugins",
@@ -526,13 +549,127 @@ function gearMenu(plugins: readonly PluginInfo[], install: () => void): MenuItem
run: () => {
void refreshPlugins();
void refreshCatalog();
void refreshMarket(true);
},
},
];
}
/** The marketplace index URL, editable in place. */
function MarketUrlBar() {
const current = usePlugins((s) => s.marketUrl);
const [value, setValue] = useState(current ?? "");
useEffect(() => setValue(current ?? ""), [current]);
return (
<form
className={styles.marketBar}
onSubmit={(e) => {
e.preventDefault();
void setMarketUrl(value);
}}
>
<Input
value={value}
placeholder="Marketplace index URL (https://…/index.json or a folder path)"
aria-label="Marketplace index URL"
onChange={(e) => setValue(e.target.value)}
/>
<IconButton icon="refresh" title="Refresh Marketplace" type="submit" />
</form>
);
}
function MarketRow({ entry, selected, onSelect }: { entry: MarketPlugin; selected: boolean; onSelect: () => void }) {
const update = entry.installed && compareVersions(entry.version, entry.installed) > 0;
return (
<li className={clsx(styles.row, selected && styles.rowSelected)}>
<button
type="button"
className={styles.rowMain}
aria-current={selected || undefined}
onClick={onSelect}
title={entry.description || entry.name}
>
<PluginIcon />
<span className={styles.rowText}>
<span className={styles.rowName}>{entry.name}</span>
<span className={styles.rowMeta}>
<span className={styles.rowVendor}>{entry.publisher}</span>
<span>{entry.version}</span>
</span>
</span>
</button>
{update ? (
<Badge tone="accent">{tx("Update")}</Badge>
) : entry.installed ? (
<Badge tone="neutral">{tx("Installed")}</Badge>
) : null}
</li>
);
}
function MarketDetails({ entry, onInstalled }: { entry: MarketPlugin; onInstalled: (id: string) => void }) {
const installing = usePlugins((s) => s.installing);
const update = entry.installed && compareVersions(entry.version, entry.installed) > 0;
const install = async () => {
const id = await installFromMarket(entry.id);
if (id) onInstalled(id);
};
return (
<div className={styles.details} aria-label={`${entry.name} details`}>
<header className={styles.header}>
<PluginIcon large />
<div className={styles.headerText}>
<h2 className={styles.title}>{entry.name}</h2>
<div className={styles.authors}>{entry.authors?.length ? vendorOf(entry.authors) : entry.publisher}</div>
<div className={styles.titleLine}>
<span className={styles.version}>{entry.version}</span>
<Badge tone="success">{tx`Signed by ${entry.publisher}`}</Badge>
</div>
</div>
<div className={styles.headerActions}>
{(!entry.installed || update) && (
<Button size="small" variant="primary" disabled={installing} onClick={() => void install()}>
{update ? tx`Update to ${entry.version}` : tx("Install")}
</Button>
)}
</div>
</header>
<div className={styles.marketOverview}>
{entry.description && <p className={styles.description}>{entry.description}</p>}
<dl className={styles.facts}>
<dt>
<T>Id</T>
</dt>
<dd className={styles.mono}>{entry.id}</dd>
{entry.installed && (
<>
<dt>
<T>Installed</T>
</dt>
<dd>{entry.installed}</dd>
</>
)}
</dl>
{(entry.capabilities?.length ?? 0) > 0 && (
<Section title="Permissions">
<ul className={styles.items}>
{(entry.capabilities ?? []).map((c) => (
<li key={c} className={clsx(styles.item, styles.mono)}>
{c}
</li>
))}
</ul>
</Section>
)}
</div>
</div>
);
}
export function PluginsPage() {
const { plugins, loaded, loading, error, installing, catalog, catalogError } = usePlugins();
const { plugins, loaded, loading, error, installing, catalog, catalogError, market, marketUrl, marketError } =
usePlugins();
const [tab, setTab] = useState<Tab>("installed");
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -541,18 +678,30 @@ export function PluginsPage() {
useEffect(() => {
void refreshPlugins();
void refreshCatalog();
void refreshMarket(false);
}, []);
const [selectedMarketId, setSelectedMarketId] = useState<string | null>(null);
const marketShown = useMemo(
() =>
(market ?? []).filter((m) => {
const q = query.trim().toLowerCase();
return !q || matches([m.name, m.id, m.description, m.publisher, ...(m.authors ?? [])], q);
}),
[market, query],
);
const selectedMarket = marketShown.find((m) => m.id === selectedMarketId) ?? null;
const shown = useMemo(() => filterPlugins(plugins, query), [plugins, query]);
const downloaded = shown.filter((p) => p.source === "installed");
const dev = shown.filter((p) => p.source === "dev");
const linked = shown.filter((p) => p.source === "linked");
const selected = shown.find((p) => p.id === selectedId) ?? shown[0] ?? null;
const catalogShown = useMemo(() => filterCatalog(catalog ?? [], query), [catalog, query]);
const selectedEntry = catalogShown.find((c) => c.id === selectedCatalogId) ?? catalogShown[0] ?? null;
const install = async () => {
const id = await installFromDisk();
const install = async (packageFile = false) => {
const id = await installFromDisk(undefined, packageFile);
if (id) {
setTab("installed");
setSelectedId(id);
@@ -563,7 +712,7 @@ export function PluginsPage() {
const r = e.currentTarget.getBoundingClientRect();
showContextMenu(
{ x: r.left, y: r.bottom + 2 },
gearMenu(plugins, () => void install()),
gearMenu(plugins, (packageFile) => void install(packageFile)),
);
};
@@ -616,6 +765,15 @@ export function PluginsPage() {
onSelect={() => setSelectedId(p.id)}
/>
))}
{linked.length > 0 && <ListHeader title="Linked for development" count={linked.length} />}
{linked.map((p) => (
<InstalledRow
key={p.id}
plugin={p}
selected={p.id === selected?.id}
onSelect={() => setSelectedId(p.id)}
/>
))}
{dev.length > 0 && <ListHeader title="Project (.openidea/plugins)" count={dev.length} />}
{dev.map((p) => (
<InstalledRow
@@ -633,17 +791,35 @@ export function PluginsPage() {
</ul>
) : (
<ul className={styles.list} aria-label={tx("Available plugins")}>
<li className={styles.notice} role="note">
<Icon name="info" size={14} />
<span>{tx`${NO_REPOSITORY}. Showing plugin packages available on this machine.`}</span>
<li className={styles.marketItem}>
<MarketUrlBar />
</li>
{!marketUrl && (
<li className={styles.notice} role="note">
<Icon name="info" size={14} />
<span>{tx`${NO_REPOSITORY}. Showing plugin packages available on this machine.`}</span>
</li>
)}
{marketError && <li className={clsx(styles.listEmpty, styles.errorText)}>{marketError}</li>}
{marketShown.length > 0 && <ListHeader title="Marketplace" count={marketShown.length} />}
{marketShown.map((m) => (
<MarketRow
key={`market:${m.id}`}
entry={m}
selected={m.id === selectedMarket?.id}
onSelect={() => setSelectedMarketId(m.id)}
/>
))}
{catalogShown.length > 0 && <ListHeader title="Local" count={catalogShown.length} />}
{catalogShown.map((c) => (
<CatalogRow
key={c.id}
entry={c}
selected={c.id === selectedEntry?.id}
onSelect={() => setSelectedCatalogId(c.id)}
onSelect={() => {
setSelectedMarketId(null);
setSelectedCatalogId(c.id);
}}
/>
))}
{catalog && catalogShown.length === 0 && (
@@ -666,6 +842,15 @@ export function PluginsPage() {
<T>inside your project.</T>
</EmptyState>
)
) : selectedMarket ? (
<MarketDetails
key={`market:${selectedMarket.id}`}
entry={selectedMarket}
onInstalled={(id) => {
setTab("installed");
setSelectedId(id);
}}
/>
) : selectedEntry ? (
<CatalogDetails key={selectedEntry.id} entry={selectedEntry} />
) : (
+333 -37
Переглянути файл
@@ -5,28 +5,37 @@ import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import type { PluginInfo } from "@/bindings";
import { _setActiveEditor } from "@/editor/api";
import { executeAction, getAction } from "@/platform/actions";
import { executeAction, executeActionFrom, getAction, registerAction } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
import { applyThemeAttributes, useThemeOverride } from "@/workbench/theme";
import { createMemoryPluginsBackend, sampleCatalog, setPluginsBackend, type PluginsBackend } from "../backend";
import {
applyPluginTheme,
applyTextToActiveEditor,
PLUGIN_THEME_STYLE_ID,
handlePluginEvent,
pluginActionId,
pluginActions,
startPluginBridge,
} from "../bridge";
import { iconResolver } from "../iconThemes";
import { markerIcon, placeMarkers } from "../lineMarkerGutter";
import { SecretsDetails } from "../secrets";
import { usePluginStatusItems } from "../runtimeUi";
import { useTasks } from "@/workbench/tasks";
import { defineBuiltinUiPoints } from "../points";
import { clearUiContributions, defineUiPoint, syncUiContributions } from "../projection";
import { catalogState, filterCatalog, filterPlugins, NO_REPOSITORY, PluginsPage } from "../PluginsPage";
import {
compareVersions,
installFromDisk,
refreshPlugins,
runPluginCommand,
selectPluginTheme,
setAllInstalledEnabled,
setConsentPrompt,
setPluginEnabled,
usePlugins,
type ConsentRequest,
} from "../store";
type Note = Parameters<NotificationService["notify"]>[0];
@@ -48,10 +57,23 @@ function plugin(partial: Partial<PluginInfo> & Pick<PluginInfo, "id">): PluginIn
status: "active",
error: null,
hasCode: true,
permissions: [],
commands: [],
languages: [],
themes: [],
capabilities: [],
contributions: [],
activation: [],
warnings: [],
stats: {
activations: 0,
activationMs: 0,
calls: 0,
errors: 0,
maxCallMs: 0,
p95CallMs: 0,
slowCalls: 0,
crashes: 0,
reclaims: 0,
},
publisher: null,
previousVersion: null,
...partial,
};
}
@@ -63,13 +85,17 @@ function editor(doc: string, anchor: number, head = anchor, readOnly = false) {
return view;
}
let undefinePoints: () => void = () => {};
beforeEach(() => {
undefinePoints = defineBuiltinUiPoints();
notes = [];
provideNotificationService({ notify: (n) => void notes.push(n) });
backend = createMemoryPluginsBackend();
setPluginsBackend(backend);
usePlugins.setState({
plugins: [],
ui: [],
activeTheme: null,
loaded: false,
loading: false,
@@ -82,6 +108,9 @@ beforeEach(() => {
});
afterEach(() => {
clearUiContributions();
undefinePoints();
setConsentPrompt(null);
setPluginsBackend(null);
_setActiveEditor(null);
view?.destroy();
@@ -90,31 +119,124 @@ afterEach(() => {
});
describe("plugin actions", () => {
it("maps commands of running plugins to plugin.<plugin>.<command> actions", () => {
const actions = pluginActions([
plugin({ id: "a", name: "Alpha", commands: [{ id: "go", title: "Alpha: Go", category: null }] }),
plugin({ id: "b", status: "disabled", commands: [{ id: "x", title: "X", category: "Cat" }] }),
plugin({ id: "c", status: "failed", commands: [{ id: "y", title: "Y", category: null }] }),
]);
expect(actions.map((a) => a.id)).toEqual(["plugin.a.go"]);
expect(actions[0]!.category).toBe("Alpha");
it("projects command contributions to plugin.<plugin>.<command> actions", () => {
const cmd = (pluginId: string, id: string, extra: Record<string, unknown> = {}) => ({
pluginId,
point: "commands",
key: id,
decl: { id, title: `T ${id}`, ...extra },
});
syncUiContributions(
[cmd("a", "go"), cmd("a", "when", { when: "editorFocus", category: "Cat" }), cmd("gone", "x")],
[plugin({ id: "a", name: "Alpha" })],
);
expect(getAction("plugin.a.go")).toMatchObject({ title: "T go", category: "Alpha" });
expect(getAction("plugin.a.when")).toMatchObject({ when: "editorFocus", category: "Cat" });
expect(getAction("plugin.gone.x")).toBeUndefined();
expect(pluginActionId("p", "c.d")).toBe("plugin.p.c.d");
// Changed declarations are re-projected; removed ones disposed.
syncUiContributions([cmd("a", "go", { title: "Renamed" })], [plugin({ id: "a", name: "Alpha" })]);
expect(getAction("plugin.a.go")?.title).toBe("Renamed");
expect(getAction("plugin.a.when")).toBeUndefined();
});
it("isolates a failing projection (a defect) from the other items", () => {
const errors: unknown[] = [];
const original = console.error;
console.error = (...args: unknown[]) => void errors.push(args);
const live: string[] = [];
const undefine = defineUiPoint<{ n: number }>("test.items", {
project: ({ decl }) => {
if (decl.n === 2) throw new Error("bug");
live.push(String(decl.n));
return () => live.splice(live.indexOf(String(decl.n)), 1);
},
});
const item = (n: number) => ({ pluginId: "a", point: "test.items", key: String(n), decl: { n } });
syncUiContributions([item(1), item(2), item(3)], [plugin({ id: "a" })]);
expect(live).toEqual(["1", "3"]);
expect(errors).toHaveLength(1);
undefine();
expect(live).toEqual([]);
console.error = original;
});
it("keeps the registry in sync and runs commands through the backend", async () => {
// The bridge defines the points itself (main window).
undefinePoints();
undefinePoints = () => {};
const stop = startPluginBridge();
await refreshPlugins();
expect(getAction("plugin.hello.insert-date")?.title).toBe("Hello: Insert Date");
expect(getAction("plugin.hello.word-count")).toBeDefined();
const v = editor("ab", 1);
// Edits come back as document patches; here the command's result shows it ran.
const ran: unknown[] = [];
const run = backend.runCommand;
backend.runCommand = async (...args) => {
const out = await run(...args);
ran.push(out);
return out;
};
editor("ab", 1);
await act(() => executeAction("plugin.hello.insert-date"));
expect(v.state.doc.toString()).toMatch(/^a\d{4}-\d{2}-\d{2}b$/);
expect(ran).toEqual([expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/)]);
await setPluginEnabled("hello", false);
expect(getAction("plugin.hello.insert-date")).toBeUndefined();
stop();
});
it("reports actions to on-event:action-performed plugins once the project opens and when plugins change", async () => {
// Like `authorize_command`: refused until the window's project is open and trusted.
let kinds: Awaited<ReturnType<PluginsBackend["eventKinds"]>> = [];
const posted: unknown[] = [];
setPluginsBackend({
...backend,
eventKinds: async () => {
if (!useProject.getState().project?.trusted) throw new Error("no project is open in this window");
return kinds;
},
postEvents: async (events) => void posted.push(...events),
});
const offAction = registerAction({ id: "test.columnMode", title: "Column Mode", run: () => {} });
const performed = (n: number) =>
Array.from({ length: n }, () => ({
kind: "actionPerformed",
value: { action: "test.columnMode", title: "Column Mode", source: "menu", shortcut: null },
}));
undefinePoints();
undefinePoints = () => {};
kinds = ["action-performed"];
const stop = startPluginBridge();
try {
// Asked at startup, before the project is open: refused.
await new Promise((r) => setTimeout(r, 20));
await executeActionFrom("menu", "test.columnMode");
await new Promise((r) => setTimeout(r, 600));
expect(posted).toEqual([]);
useProject.setState({ project: { root: "/p", trusted: true } as never });
await new Promise((r) => setTimeout(r, 20));
await executeActionFrom("menu", "test.columnMode");
await waitFor(() => expect(posted).toEqual(performed(1)));
// Declared kinds change with the installed plugins (`commandsChanged`).
kinds = [];
handlePluginEvent({ type: "commandsChanged" });
await new Promise((r) => setTimeout(r, 60));
await executeActionFrom("menu", "test.columnMode");
kinds = ["action-performed"];
handlePluginEvent({ type: "commandsChanged" });
await new Promise((r) => setTimeout(r, 60));
await executeActionFrom("menu", "test.columnMode");
await waitFor(() => expect(posted).toEqual(performed(2)));
} finally {
stop();
offAction();
useProject.setState({ project: null });
}
});
});
describe("store", () => {
@@ -150,9 +272,26 @@ describe("store", () => {
expect(usePlugins.getState().activeTheme).toBeNull();
});
it("filters by name, id, author and command title", () => {
it("asks for consent before installing and passes the answer on", async () => {
const asked: ConsentRequest[] = [];
setConsentPrompt(async (req) => {
asked.push(req);
return { optional: [] };
});
setPluginsBackend(createMemoryPluginsBackend([], sampleCatalog()));
expect(await installFromDisk("plugins/hello")).toBe("hello");
expect(asked[0]).toMatchObject({ pluginId: "hello", kind: "install", unsigned: true });
expect(asked[0]!.capabilities.map((c) => c.id)).toContain("editor.write");
setConsentPrompt(async () => null);
setPluginsBackend(createMemoryPluginsBackend([], sampleCatalog()));
expect(await installFromDisk("plugins/hello")).toBeNull();
expect(usePlugins.getState().plugins).toEqual([]);
});
it("filters by name, id, author and contribution", () => {
const list = [
plugin({ id: "a", name: "Alpha", commands: [{ id: "c", title: "Count Words", category: null }] }),
plugin({ id: "a", name: "Alpha", contributions: [{ point: "commands", label: "Count Words" }] }),
plugin({ id: "b", name: "Beta", authors: ["Zed"] }),
];
expect(filterPlugins(list, "count").map((p) => p.id)).toEqual(["a"]);
@@ -199,22 +338,44 @@ describe("bridge events", () => {
useThemeOverride.setState({ theme: null });
});
it("inserts at the cursor and replaces selections", () => {
const v = editor("hello world", 6, 11);
handlePluginEvent({ type: "replaceSelection", pluginId: "p", text: "there" });
expect(v.state.doc.toString()).toBe("hello there");
expect(v.state.selection.main).toMatchObject({ from: 6, to: 11 });
handlePluginEvent({ type: "insertText", pluginId: "p", text: "!" });
expect(v.state.doc.toString()).toBe("hello !");
expect(v.state.selection.main.head).toBe(7);
it("runs commands with the focused editor's path, language and UTF-16 selections", async () => {
const calls: unknown[] = [];
setPluginsBackend({
...backend,
runCommand: async (...args) => {
calls.push(args);
return null;
},
});
editor("ab\n😀cd", 3, 7);
await runPluginCommand("p", "c");
expect(calls).toEqual([
[
"p",
"c",
null,
{
path: "/p/a.txt",
language: "plaintext",
selections: [{ start: { line: 1, col: 0 }, end: { line: 1, col: 4 } }],
},
],
]);
});
it("warns instead of editing read-only or missing editors", () => {
expect(applyTextToActiveEditor("x", false, "p")).toBe(false);
const v = editor("abc", 0, 0, true);
expect(applyTextToActiveEditor("x", false, "p")).toBe(false);
expect(v.state.doc.toString()).toBe("abc");
expect(notes.map((n) => n.level)).toEqual(["warning", "warning"]);
it("shows status items and tasks of running plugin code", () => {
const item = { text: "main ↑1", tooltip: "Branch", command: "refresh" };
handlePluginEvent({ type: "statusItemChanged", pluginId: "git", id: "branch", item });
expect(usePluginStatusItems.getState().items.get("git/branch")).toEqual({ pluginId: "git", item });
handlePluginEvent({ type: "statusItemChanged", pluginId: "git", id: "branch", item: null });
expect(usePluginStatusItems.getState().items.size).toBe(0);
handlePluginEvent({ type: "taskStarted", pluginId: "git", taskId: "7", title: "Fetching", cancellable: true });
handlePluginEvent({ type: "taskProgress", pluginId: "git", taskId: "7", done: 1, total: 4, message: "origin" });
const task = useTasks.getState().tasks.find((t) => t.id === "plugin:7")!;
expect([task.title, task.fraction, task.message, !!task.cancel]).toEqual(["Fetching", 0.25, "origin", true]);
handlePluginEvent({ type: "taskEnded", pluginId: "git", taskId: "7" });
expect(useTasks.getState().tasks.find((t) => t.id === "plugin:7")?.done).toBe(true);
});
it("shows plugin notifications and tracks theme changes", () => {
@@ -235,11 +396,12 @@ describe("PluginsPage", () => {
// Sorted as returned; first one selected by default.
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Hello");
expect(screen.getByText("plugin.hello.insert-date")).toBeInTheDocument();
expect(screen.getByText("Insert text into the active editor")).toBeInTheDocument();
expect(screen.getByText("Change text in the editor")).toBeInTheDocument();
expect(screen.getByText("Medium")).toBeInTheDocument();
await user.click(screen.getByRole("checkbox", { name: "Disable Hello" }));
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Enable Hello" })).toBeInTheDocument());
expect(screen.getByRole("button", { name: "Run Hello: Insert Date" })).toBeDisabled();
expect(screen.queryByRole("button", { name: "Run Hello: Insert Date" })).not.toBeInTheDocument();
await user.click(within(list).getByText("Harbor Night Theme"));
await user.click(screen.getByRole("button", { name: "Apply theme Harbor Night" }));
@@ -248,7 +410,7 @@ describe("PluginsPage", () => {
await user.type(screen.getByLabelText("Filter plugins"), "toy");
await waitFor(() => expect(within(list).getAllByRole("listitem")).toHaveLength(1));
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Toy Language");
expect(screen.getByText(".toy")).toBeInTheDocument();
expect(screen.getByText("Toy (.toy)")).toBeInTheDocument();
});
it("restarts a failed plugin instead of disabling it", async () => {
@@ -256,7 +418,7 @@ describe("PluginsPage", () => {
const user = userEvent.setup();
render(<PluginsPage />);
await user.click(await screen.findByRole("button", { name: "Restart" }));
await waitFor(() => expect(usePlugins.getState().plugins[0]).toMatchObject({ enabled: true, status: "active" }));
await waitFor(() => expect(usePlugins.getState().plugins[0]).toMatchObject({ enabled: true, status: "inactive" }));
});
it("uninstalls after confirmation", async () => {
@@ -299,6 +461,140 @@ describe("PluginsPage", () => {
});
});
describe("secrets", () => {
it("stores and removes a declared secret without showing its value", async () => {
const user = userEvent.setup();
const items = [{ pluginId: "ai", point: "secrets", key: "api-key", decl: { name: "api-key", label: "API key" } }];
render(<SecretsDetails plugin={plugin({ id: "ai" })} items={items} />);
expect(await screen.findByText("Not set")).toBeInTheDocument();
await user.type(screen.getByLabelText("Value of API key"), "sk-1{Enter}");
await waitFor(() => expect(screen.getByText("Stored")).toBeInTheDocument());
expect(screen.getByLabelText("Value of API key")).toHaveValue("");
expect(await backend.secrets("ai")).toEqual([{ name: "api-key", set: true }]);
await user.click(screen.getByRole("button", { name: "Remove API key" }));
await waitFor(() => expect(screen.getByText("Not set")).toBeInTheDocument());
expect(await backend.secrets("ai")).toEqual([]);
});
});
describe("plugin line markers", () => {
it("places markers on existing lines and maps their icons", () => {
const doc = EditorState.create({ doc: "a\nbb\nccc" }).doc;
const marker = (line: number, icon: string | null) => ({ pluginId: "m", line, icon, tooltip: "t", target: null });
expect(placeMarkers(doc, [marker(1, "run"), marker(7, null)]).map((m) => m.pos)).toEqual([2]);
expect(markerIcon("run")).toBe("gutter-run");
expect(markerIcon("implemented")).toBe("gutter-implemented");
expect(markerIcon(null)).toBe("gutter-implementing");
expect(markerIcon("unknown")).toBe("gutter-implementing");
});
});
describe("marketplace", () => {
it("lists the index's plugins and installs one through the install page", async () => {
const asked: ConsentRequest[] = [];
setConsentPrompt(async (req) => {
asked.push(req);
return { optional: [] };
});
const base = createMemoryPluginsBackend([], sampleCatalog());
const downloads: string[] = [];
setPluginsBackend({
...base,
marketUrl: async () => "https://plugins.example/index.json",
market: async () => [
{
id: "hello",
name: "Hello",
version: "1.0.0",
description: "From the marketplace",
authors: [],
publisher: "acme",
download: "hello.oiplugin",
digest: "x",
capabilities: ["editor.write"],
installed: null,
},
],
// The memory backend installs its sample packages by path.
marketDownload: async (id) => {
downloads.push(id);
return "plugins/hello";
},
});
const user = userEvent.setup();
render(<PluginsPage />);
await user.click(screen.getByRole("tab", { name: /Available/ }));
const list = await screen.findByRole("list", { name: "Available plugins" });
await waitFor(() => expect(within(list).getByText("Marketplace")).toBeInTheDocument());
expect(within(list).queryByText(new RegExp(NO_REPOSITORY))).not.toBeInTheDocument();
await user.click(within(list).getAllByText("Hello")[0]!);
expect(screen.getByText("Signed by acme")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Install" }));
await waitFor(() => expect(usePlugins.getState().plugins.map((p) => p.id)).toEqual(["hello"]));
expect(downloads).toEqual(["hello"]);
expect(asked[0]).toMatchObject({ pluginId: "hello", kind: "install" });
});
it("offers to roll back an update", async () => {
const base = createMemoryPluginsBackend([
plugin({ id: "p", name: "P", version: "2.0.0", previousVersion: "1.0.0" }),
]);
const rolled: string[] = [];
setPluginsBackend({
...base,
rollBack: async (id) => {
rolled.push(id);
return plugin({ id, name: "P", version: "1.0.0" });
},
});
const user = userEvent.setup();
render(<PluginsPage />);
expect(await screen.findByText("Unsigned")).toBeInTheDocument();
await user.click(await screen.findByRole("button", { name: "Roll Back to 1.0.0" }));
await waitFor(() => expect(rolled).toEqual(["p"]));
expect(notes.at(-1)).toMatchObject({ title: "Rolled back P to 1.0.0" });
});
});
describe("icon themes", () => {
it("resolves file names, then the longest extension, then the default icon", () => {
const theme = iconResolver(
{
file: "file",
fileNames: { "cargo.toml": "cargo" },
fileExtensions: { "d.ts": "dts", ts: "ts", toml: "toml" },
},
(icon) => `ext:p/t/${icon}`,
(name) => name !== "ext:p/t/toml",
);
expect(theme.file("Cargo.toml")).toBe("ext:p/t/cargo");
expect(theme.file("a.d.ts")).toBe("ext:p/t/dts");
expect(theme.file("a.ts")).toBe("ext:p/t/ts");
expect(theme.file("x.toml")).toBe("ext:p/t/file");
// No folder icons in this theme: folders keep the built-in ones.
expect(theme.folder("src", false)).toBeNull();
});
it("resolves folders by name, then the default (expanded) folder icon", () => {
const theme = iconResolver(
{
file: null,
folder: "folder",
folderExpanded: "folder-open",
fileNames: {},
fileExtensions: {},
folderNames: { src: "folder-src" },
},
(icon) => `ext:p/t/${icon}`,
() => true,
);
expect(theme.folder("SRC", true)).toBe("ext:p/t/folder-src");
expect(theme.folder("lib", false)).toBe("ext:p/t/folder");
expect(theme.folder("lib", true)).toBe("ext:p/t/folder-open");
expect(theme.file("a.txt")).toBeNull();
});
});
describe("catalog helpers", () => {
it("compares versions numerically", () => {
expect(compareVersions("1.10.0", "1.9.2")).toBe(1);
+194
Переглянути файл
@@ -0,0 +1,194 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { View, ViewEvent } from "@/bindings";
import { registerAction } from "@/platform/actions";
import { bindingsFor, effectiveBindings, registerExtensionBindings, useKeymap } from "@/platform/keymap";
import { createMemoryPluginsBackend, setPluginsBackend, type PluginsBackend } from "../backend";
import { defineBuiltinUiPoints } from "../points";
import { clearUiContributions, syncUiContributions } from "../projection";
import { pluginMenuItems, withPluginSetting } from "../uiPoints";
import { matchesPattern } from "../views/customEditor";
import { FRAME_CSP, frameDocument } from "../views/frame";
import { PluginView, treeNodes, wantedViewport } from "../views/PluginView";
import { bumpView } from "../views/viewStore";
let undefinePoints: () => void = () => {};
beforeEach(() => {
undefinePoints = defineBuiltinUiPoints();
});
afterEach(() => {
clearUiContributions();
undefinePoints();
setPluginsBackend(null);
});
function backendWith(render: (view: string) => View, events: ViewEvent[] = []): PluginsBackend {
const base = createMemoryPluginsBackend([]);
return {
...base,
renderView: vi.fn(async (_p: string, view: string) => render(view)),
viewEvent: vi.fn(async (_p: string, _v: string, e: ViewEvent) => void events.push(e)),
};
}
describe("view helpers", () => {
it("builds the tree from the flat item list", () => {
const item = (key: string, parent: string | null = null) => ({
key,
parent,
label: key,
icon: null,
detail: null,
expandable: false,
expanded: false,
});
const roots = treeNodes([item("a"), item("a/1", "a"), item("b"), item("x", "missing")]);
expect(roots.map((n) => n.item.key)).toEqual(["a", "b", "x"]);
expect(roots[0]!.children.map((n) => n.item.key)).toEqual(["a/1"]);
});
it("asks for the visible table rows plus some around them", () => {
expect(wantedViewport(0, 20, 10)).toEqual({ firstRow: 0, rows: 10 });
expect(wantedViewport(1000, 20, 5000)).toEqual({ firstRow: 950, rows: 120 });
});
it("confines HTML bodies: CSP without network, a message bridge", () => {
const doc = frameDocument("<p>hi</p>", true);
expect(doc).toContain(`content="${FRAME_CSP}"`);
expect(FRAME_CSP).toContain("default-src 'none'");
expect(FRAME_CSP).not.toMatch(/https?:|connect-src/);
expect(doc).toContain("window.openidea = Object.freeze");
expect(doc.indexOf("Content-Security-Policy")).toBeLessThan(doc.indexOf("<p>hi</p>"));
});
it("matches custom editor file name patterns", () => {
expect(matchesPattern("*.csv", "People.CSV")).toBe(true);
expect(matchesPattern("*.{csv,tsv}", "a.tsv")).toBe(true);
expect(matchesPattern("data?.csv", "data1.csv")).toBe(true);
expect(matchesPattern("*.csv", "a.csv.bak")).toBe(false);
});
it("stores plugin settings under the plugin's id, dropping defaults", () => {
const one = withPluginSetting(undefined, "p.x", "watch", false);
expect(one).toEqual({ "p.x": { watch: false } });
expect(withPluginSetting(one, "p.x", "watch", null)).toEqual({});
});
});
describe("plugin keymaps", () => {
afterEach(() => useKeymap.setState({ extensions: {}, overrides: {} }));
it("adds a plugin's shortcut below the IDE's, never taking a used keystroke", () => {
const taken = effectiveBindings()[0]!;
const offFree = registerExtensionBindings("plugin.p.free", ["Ctrl+Alt+Shift+F11"]);
const offTaken = registerExtensionBindings("plugin.p.taken", [taken[0]]);
expect(bindingsFor("plugin.p.free")).toEqual(["Ctrl+Alt+Shift+F11"]);
expect(bindingsFor("plugin.p.taken")).toEqual([]);
useKeymap.setState({ overrides: { "plugin.p.free": [] } });
expect(bindingsFor("plugin.p.free")).toEqual([]);
offFree();
offTaken();
expect(useKeymap.getState().extensions).toEqual({});
});
});
describe("plugin menus", () => {
it("projects context menu entries for their command's action", () => {
const off = registerAction({ id: "plugin.p.check", title: "Check", run: () => {} });
const plugin = {
id: "p",
name: "P",
status: "active",
enabled: true,
} as unknown as Parameters<typeof syncUiContributions>[1][number];
syncUiContributions(
[
{
pluginId: "p",
point: "menus",
key: "projectView.context/check",
decl: { menu: "projectView.context", command: "check" },
},
{
pluginId: "p",
point: "menus",
key: "editor.context/check",
decl: { menu: "editor.context", command: "check", when: "false" },
},
],
[plugin],
);
expect(pluginMenuItems("projectView", ["/p/a.java"])).toEqual([
{ kind: "action", action: "plugin.p.check", arg: "/p/a.java" },
]);
expect(pluginMenuItems("editor", ["/p/a.java"])).toEqual([]);
off();
});
});
describe("PluginView", () => {
it("renders a tree and sends expand / activate", async () => {
const events: ViewEvent[] = [];
const tree: View = {
toolbar: [{ id: "refresh", label: "Refresh", icon: "refresh", enabled: true }],
body: {
kind: "tree",
value: [
{
key: "ns",
parent: null,
label: "default",
icon: null,
detail: "3 pods",
expandable: true,
expanded: false,
},
],
},
status: "1 namespace",
};
setPluginsBackend(backendWith(() => tree, events));
const user = userEvent.setup();
render(<PluginView pluginId="k8s" view="clusters" label="Kubernetes" />);
expect(await screen.findByText("default")).toBeInTheDocument();
expect(screen.getByText("1 namespace")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Refresh" }));
await waitFor(() => expect(events).toContainEqual({ kind: "button", id: "refresh" }));
});
it("edits a table cell and renders again when the plugin refreshes", async () => {
const events: ViewEvent[] = [];
let city = "London";
const table = (): View => ({
toolbar: [],
body: {
kind: "table",
value: { columns: ["name", "city"], totalRows: 1, firstRow: 0, rows: [["Ada", city]], editable: true },
},
status: null,
});
const backend = backendWith(table, events);
setPluginsBackend(backend);
const user = userEvent.setup();
render(<PluginView pluginId="csv" view="table" doc={7} />);
await user.dblClick(await screen.findByText("London"));
const input = screen.getByLabelText("Cell value");
await user.clear(input);
await user.type(input, "Paris{Enter}");
await waitFor(() => expect(events).toContainEqual({ kind: "cell", row: 0, column: 1, value: "Paris" }));
expect(backend.viewEvent).toHaveBeenCalledWith("csv", "table", expect.anything(), 7);
city = "Paris";
bumpView("csv", "table");
expect(await screen.findByText("Paris")).toBeInTheDocument();
});
it("shows a message when the plugin fails", async () => {
const base = createMemoryPluginsBackend([]);
setPluginsBackend({ ...base, renderView: () => Promise.reject(new Error("boom")) });
render(<PluginView pluginId="x" view="v" />);
expect(await screen.findByText("boom")).toBeInTheDocument();
});
});
+253 -51
Переглянути файл
@@ -4,26 +4,84 @@
* development and tests.
*/
import { Channel } from "@tauri-apps/api/core";
import type { ActiveTheme, CatalogPlugin, PluginEvent, PluginInfo, PluginsSnapshot } from "@/bindings";
import { tx } from "@/platform/i18n";
import type {
ActiveTheme,
CapabilityInfo,
CatalogPlugin,
Consent,
EventKind,
InstallPreview,
MarketPlugin,
PluginEditor,
PluginEvent,
PluginInfo,
PluginsSnapshot,
PluginSecretState,
PluginStatusItem,
UiContribution,
View,
ViewEvent,
Viewport,
WorkbenchEvent,
} from "@/bindings";
import { call, commands, IpcError, isTauri } from "@/platform/ipc";
export interface PluginsBackend {
list(): Promise<PluginsSnapshot>;
setEnabled(id: string, enabled: boolean): Promise<void>;
installDir(path: string): Promise<PluginInfo>;
/** Validates a package for the install page; nothing is copied. */
previewInstall(path: string): Promise<InstallPreview>;
installDir(path: string, consent: Consent): Promise<PluginInfo>;
/** Confirms a plugin waiting for consent. */
grant(id: string, consent: Consent): Promise<PluginInfo>;
setCapability(id: string, capability: string, granted: boolean): Promise<PluginInfo>;
/** Validated assets (icon theme mapping and SVGs) whose id starts with `prefix`. */
assets(pluginId: string, prefix: string): Promise<[string, string][]>;
/** `language` / `file` / `view` activation events. */
activationEvent(kind: "language" | "file" | "view", value: string): Promise<void>;
/** Plugin packages available locally (no network marketplace). */
catalog(): Promise<CatalogPlugin[]>;
uninstall(id: string): Promise<void>;
reload(id: string): Promise<PluginInfo>;
runCommand(pluginId: string, commandId: string, arg: string | null): Promise<string | null>;
/** Runs a command with the focused editor (path, language, selections) as its context. */
runCommand(
pluginId: string,
commandId: string,
arg: string | null,
editor: PluginEditor | null,
): Promise<string | null>;
/** Workbench events plugins subscribed to (see `eventKinds`). */
postEvents(events: WorkbenchEvent[]): Promise<void>;
/** Kinds of workbench events plugins want; re-fetch on `subscriptionsChanged`. */
eventKinds(): Promise<EventKind[]>;
/** Plugin status bar items (later changes arrive as `statusItemChanged`). */
statusItems(): Promise<PluginStatusItem[]>;
cancelTask(taskId: string): Promise<void>;
/** Marketplace plugins: the index read again (`refresh`) or the last one read. */
market(refresh: boolean): Promise<MarketPlugin[]>;
marketUrl(): Promise<string | null>;
setMarketUrl(url: string | null): Promise<void>;
/** Downloads and checks a marketplace plugin; the folder to install from. */
marketDownload(id: string): Promise<string>;
/** Unpacks a `.oiplugin` package file; the folder to install from. */
unpackPackage(path: string): Promise<string>;
/** Goes back to the version the last update replaced. */
rollBack(id: string): Promise<PluginInfo>;
/** File picker for a package file; `null` when cancelled. */
pickPackage(): Promise<string | null>;
/** Content of a plugin view (`doc`: the document of a custom editor). */
renderView(pluginId: string, view: string, viewport: Viewport | null, doc: number | null): Promise<View>;
viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise<void>;
/** Which of a plugin's declared secrets are stored (values never come back). */
secrets(pluginId: string): Promise<PluginSecretState[]>;
/** Stores a secret (`null` removes it). */
setSecret(pluginId: string, name: string, value: string | null): Promise<void>;
setActiveFile(path: string | null): Promise<void>;
setTheme(pluginId: string | null, themeId: string | null): Promise<ActiveTheme | null>;
/** The selected theme's resolved frame colour (`#rrggbb[aa]`), which new windows open in. */
setThemeFrame(color: string | null): Promise<void>;
/** Folder picker; `null` when cancelled. */
/** A package to install: a `.zip`, or the `plugin.toml` of a folder. */
pickPackage(): Promise<string | null>;
pickFolder(): Promise<string | null>;
/** Subscribes to backend plugin events (this window only). */
onEvent(handler: (e: PluginEvent) => void): () => void;
}
@@ -33,13 +91,53 @@ export const tauriPluginsBackend: PluginsBackend = {
setEnabled: async (id, enabled) => {
await call(commands.pluginSetEnabled(id, enabled));
},
installDir: (path) => call(commands.pluginInstallDir(path)),
previewInstall: (path) => call(commands.pluginPreviewInstall(path)),
installDir: (path, consent) => call(commands.pluginInstallDir(path, consent)),
grant: (id, consent) => call(commands.pluginGrant(id, consent)),
setCapability: (id, capability, granted) => call(commands.pluginSetCapability(id, capability, granted)),
assets: (pluginId, prefix) => call(commands.pluginAssets(pluginId, prefix)),
activationEvent: async (kind, value) => {
await call(commands.pluginActivationEvent(kind, value));
},
catalog: () => call(commands.pluginCatalog()),
uninstall: async (id) => {
await call(commands.pluginUninstall(id));
},
reload: (id) => call(commands.pluginReload(id)),
runCommand: (pluginId, commandId, arg) => call(commands.pluginRunCommand(pluginId, commandId, arg)),
runCommand: (pluginId, commandId, arg, editor) => call(commands.pluginRunCommand(pluginId, commandId, arg, editor)),
postEvents: async (events) => {
await commands.pluginPostEvents(events);
},
eventKinds: () => commands.pluginEventKinds(),
statusItems: () => commands.pluginStatusItems(),
cancelTask: async (taskId) => {
await commands.pluginCancelTask(taskId);
},
market: (refresh) => call(commands.pluginMarket(refresh)),
marketUrl: () => commands.pluginMarketUrl(),
setMarketUrl: async (url) => {
await commands.pluginSetMarketUrl(url);
},
marketDownload: (id) => call(commands.pluginMarketDownload(id)),
unpackPackage: (path) => call(commands.pluginUnpackPackage(path)),
rollBack: (id) => call(commands.pluginRollBack(id)),
pickPackage: async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
multiple: false,
title: "Install Plugin from Package File",
filters: [{ name: "OpenIDEA plugin", extensions: ["oiplugin"] }],
});
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
renderView: (pluginId, view, viewport, doc) => call(commands.pluginViewRender(pluginId, view, viewport, doc)),
viewEvent: async (pluginId, view, event, doc) => {
await call(commands.pluginViewEvent(pluginId, view, event, doc));
},
secrets: (pluginId) => call(commands.pluginSecrets(pluginId)),
setSecret: async (pluginId, name, value) => {
await call(commands.pluginSetSecret(pluginId, name, value));
},
setActiveFile: async (path) => {
await commands.pluginSetActiveFile(path);
},
@@ -47,13 +145,9 @@ export const tauriPluginsBackend: PluginsBackend = {
setThemeFrame: async (color) => {
await call(commands.pluginSetThemeFrame(color));
},
pickPackage: async () => {
pickFolder: async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({
multiple: false,
title: tx("Install Plugin from Disk"),
filters: [{ name: tx("Plugin package"), extensions: ["zip", "toml"] }],
});
const picked = await open({ directory: true, multiple: false, title: "Install Plugin from Disk" });
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
onEvent: (handler) => {
@@ -66,7 +160,7 @@ export const tauriPluginsBackend: PluginsBackend = {
const tauriListeners = new Set<(e: PluginEvent) => void>();
let subscribed = false;
/** One channel per window; the backend addresses insert-text requests to the invoking window. */
/** One channel per window; the backend addresses notifications and consent requests to the invoking window. */
function ensureSubscribed() {
if (subscribed) return;
subscribed = true;
@@ -101,6 +195,27 @@ const SAMPLE_THEMES: ActiveTheme["theme"][] = [
},
];
const cap = (id: string, risk: CapabilityInfo["risk"], description: string): CapabilityInfo => ({
id,
risk,
description,
optional: false,
granted: true,
});
const HELLO_COMMANDS = [
{ id: "insert-date", title: "Hello: Insert Date", category: "Hello" },
{ id: "word-count", title: "Hello: Word Count of Active File", category: "Hello" },
];
/** UI contributions of the sample plugins (what the backend would project). */
function sampleUi(id: string): UiContribution[] {
if (id === "hello") return HELLO_COMMANDS.map((c) => ({ pluginId: id, point: "commands", key: c.id, decl: c }));
if (id === "theme-harbor-night")
return SAMPLE_THEMES.map((t) => ({ pluginId: id, point: "themes", key: t.id, decl: t }));
return [];
}
function samplePlugins(): PluginInfo[] {
const base = {
authors: ["OpenIDEA contributors"],
@@ -109,10 +224,23 @@ function samplePlugins(): PluginInfo[] {
source: "installed" as const,
enabled: true,
error: null,
languages: [],
themes: [],
commands: [],
permissions: [],
capabilities: [],
contributions: [],
activation: [],
warnings: [],
stats: {
activations: 0,
activationMs: 0,
calls: 0,
errors: 0,
maxCallMs: 0,
p95CallMs: 0,
slowCalls: 0,
crashes: 0,
reclaims: 0,
},
publisher: null,
previousVersion: null,
};
return [
{
@@ -122,13 +250,15 @@ function samplePlugins(): PluginInfo[] {
version: "0.1.0",
description: "Sample plugin: inserts today's date and counts words in the active file.",
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/hello",
status: "active",
status: "inactive",
hasCode: true,
permissions: ["project-read", "editor-read", "editor-write"],
commands: [
{ id: "insert-date", title: "Hello: Insert Date", category: "Hello" },
{ id: "word-count", title: "Hello: Word Count of Active File", category: "Hello" },
activation: ["on-startup-finished"],
capabilities: [
cap("editor.write", "medium", "Change text in the editor"),
cap("project.read", "low", "Read files in the open project"),
cap("editor.read", "low", "Read the focused editor's file and selection"),
],
contributions: HELLO_COMMANDS.map((c) => ({ point: "commands", label: c.title })),
},
{
...base,
@@ -139,7 +269,7 @@ function samplePlugins(): PluginInfo[] {
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/theme-harbor-night",
status: "static",
hasCode: false,
themes: SAMPLE_THEMES.map(({ id, name, kind }) => ({ id, name, kind })),
contributions: SAMPLE_THEMES.map((t) => ({ point: "themes", label: t.name })),
},
{
...base,
@@ -150,31 +280,35 @@ function samplePlugins(): PluginInfo[] {
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/language-toy",
status: "static",
hasCode: false,
languages: [{ id: "toy", name: "Toy", extensions: ["toy"] }],
contributions: [{ point: "languages", label: "Toy (.toy)" }],
},
];
}
/** The sample packages as a local catalog (the desktop app scans `plugins/`). */
export function sampleCatalog(): CatalogPlugin[] {
return samplePlugins().map((p) => ({
id: p.id,
name: p.name,
version: p.version,
description: p.description,
authors: p.authors,
homepage: p.homepage,
changeNotes: `${p.version} — first release.`,
path: `plugins/${p.id}`,
hasCode: p.hasCode,
permissions: p.permissions,
commands: p.commands,
languageCount: p.languages.length,
themeCount: p.themes.length,
problem: null,
}));
return samplePlugins().map((p) => {
const counts = new Map<string, number>();
for (const c of p.contributions) counts.set(c.point, (counts.get(c.point) ?? 0) + 1);
return {
id: p.id,
name: p.name,
version: p.version,
description: p.description,
authors: p.authors,
homepage: p.homepage,
changeNotes: `${p.version} — first release.`,
path: `plugins/${p.id}`,
hasCode: p.hasCode,
capabilities: p.capabilities.map((c) => ({ ...c, granted: false })),
contributions: [...counts].map(([point, count]) => ({ point, count })),
problem: null,
};
});
}
const live = (p: PluginInfo) => p.status === "active" || p.status === "inactive" || p.status === "static";
export function createMemoryPluginsBackend(
initial: PluginInfo[] = samplePlugins(),
catalog: CatalogPlugin[] = sampleCatalog(),
@@ -183,6 +317,8 @@ export function createMemoryPluginsBackend(
const templates = samplePlugins();
let theme: { pluginId: string; themeId: string } | null = null;
let activeFile: string | null = null;
const secrets = new Map<string, string>();
let marketUrl: string | null = null;
const listeners = new Set<(e: PluginEvent) => void>();
const emit = (e: PluginEvent) => listeners.forEach((l) => l(e));
const find = (id: string) => {
@@ -197,16 +333,36 @@ export function createMemoryPluginsBackend(
if (!p?.enabled || !t) return null;
return { pluginId: p.id, theme: t };
};
const ui = () => plugins.filter(live).flatMap((p) => sampleUi(p.id));
const preview = (path: string): InstallPreview => {
const entry = catalog.find((c) => c.path === path);
if (!entry) throw new IpcError({ kind: "unavailable", message: `installing ${path} requires the desktop app` });
return {
id: entry.id,
name: entry.name,
version: entry.version,
description: entry.description,
authors: entry.authors,
hasCode: entry.hasCode,
capabilities: entry.capabilities,
contributions: entry.contributions,
warnings: [],
replaces: plugins.find((p) => p.id === entry.id)?.version ?? null,
unsigned: true,
publisher: null,
};
};
return {
list: async () => ({ plugins: plugins.map((p) => ({ ...p })), activeTheme: activeTheme() }),
list: async () => ({ plugins: plugins.map((p) => ({ ...p })), activeTheme: activeTheme(), ui: ui() }),
setEnabled: async (id, enabled) => {
const p = find(id);
p.enabled = enabled;
p.status = enabled ? (p.hasCode ? "active" : "static") : "disabled";
p.status = enabled ? (p.hasCode ? "inactive" : "static") : "disabled";
p.error = null;
emit({ type: "commandsChanged" });
if (theme?.pluginId === id) emit({ type: "themeChanged", theme: activeTheme() });
},
previewInstall: async (path) => preview(path),
installDir: async (path) => {
// Only catalog packages can be "installed" here; there are no files in the browser.
const entry = catalog.find((c) => c.path === path);
@@ -219,6 +375,22 @@ export function createMemoryPluginsBackend(
emit({ type: "commandsChanged" });
return { ...info };
},
grant: async (id) => {
const p = find(id);
p.status = p.hasCode ? "inactive" : "static";
p.capabilities = p.capabilities.map((c) => ({ ...c, granted: true }));
emit({ type: "commandsChanged" });
return { ...p };
},
setCapability: async (id, capability, granted) => {
const p = find(id);
p.capabilities = p.capabilities.map((c) => (c.id === capability ? { ...c, granted } : c));
if (!granted && p.capabilities.some((c) => c.id === capability && !c.optional)) p.status = "needsConsent";
emit({ type: "commandsChanged" });
return { ...p };
},
assets: async () => [],
activationEvent: async () => {},
catalog: async () => catalog.map((c) => ({ ...c })),
uninstall: async (id) => {
find(id);
@@ -228,13 +400,10 @@ export function createMemoryPluginsBackend(
reload: async (id) => find(id),
runCommand: async (pluginId, commandId) => {
const p = find(pluginId);
if (p.status !== "active")
if (p.status !== "active" && p.status !== "inactive")
throw new IpcError({ kind: "unavailable", message: `plugin "${pluginId}" is not running` });
if (commandId === "insert-date") {
const date = new Date().toISOString().slice(0, 10);
emit({ type: "insertText", pluginId, text: date });
return date;
}
p.status = "active";
if (commandId === "insert-date") return new Date().toISOString().slice(0, 10);
if (commandId === "word-count") {
if (!activeFile) throw new IpcError({ kind: "internal", message: "no file is open in the editor" });
const name = activeFile.split("/").pop() ?? activeFile;
@@ -243,6 +412,39 @@ export function createMemoryPluginsBackend(
}
throw new IpcError({ kind: "internal", message: `unknown command: ${commandId}` });
},
postEvents: async () => {},
eventKinds: async () => [],
statusItems: async () => [],
cancelTask: async () => {},
market: async () => [],
marketUrl: async () => marketUrl,
setMarketUrl: async (url) => {
marketUrl = url;
},
marketDownload: async (id) => {
throw new IpcError({ kind: "unavailable", message: `installing ${id} requires the desktop app` });
},
unpackPackage: async (path) => {
throw new IpcError({ kind: "unavailable", message: `unpacking ${path} requires the desktop app` });
},
rollBack: async (id) => {
throw new IpcError({ kind: "notFound", message: `${id} has no earlier version to roll back to` });
},
pickPackage: async () => null,
renderView: async (pluginId, view) => ({
toolbar: [],
body: { kind: "message", value: `${pluginId} · ${view} (plugin views need the desktop app)` },
status: null,
}),
viewEvent: async () => {},
secrets: async (pluginId) =>
[...secrets.entries()]
.filter(([k]) => k.startsWith(`${pluginId}/`))
.map(([k]) => ({ name: k.slice(pluginId.length + 1), set: true })),
setSecret: async (pluginId, name, value) => {
if (value === null) secrets.delete(`${pluginId}/${name}`);
else secrets.set(`${pluginId}/${name}`, value);
},
setActiveFile: async (path) => {
activeFile = path;
},
@@ -253,7 +455,7 @@ export function createMemoryPluginsBackend(
return t;
},
setThemeFrame: async () => {},
pickPackage: async () => null,
pickFolder: async () => null,
onEvent: (handler) => {
listeners.add(handler);
return () => void listeners.delete(handler);
+66 -87
Переглянути файл
@@ -1,66 +1,39 @@
/**
* Glue between backend plugins and the workbench: plugin commands become
* actions, editor requests are applied to the active editor, the selected
* theme becomes a stylesheet of CSS variables, notifications become balloons.
* 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 { EditorSelection } from "@codemirror/state";
import type { ActiveTheme, PluginEvent, PluginInfo } from "@/bindings";
import { activeEditor, useEditorRegistry } from "@/editor/api";
import { registerAction, type Action } from "@/platform/actions";
import type { ActiveTheme, PluginEvent } from "@/bindings";
import { useEditorRegistry } from "@/editor/api";
import { refreshLanguageInfos } from "@/editor/core/cm/languages";
import { notifications } from "@/workbench/contributions";
import { applyThemeAttributes, currentThemeId, syncWindowBackground, windowBackgroundHex } from "@/workbench/theme";
import { useProject } from "@/platform/project";
import { pluginsBackend } from "./backend";
import { refreshPlugins, runPluginCommand, usePlugins } from "./store";
import { tx } from "@/platform/i18n";
import { trackIconTheme } from "./iconThemes";
import { defineBuiltinUiPoints } from "./points";
import { clearUiContributions, syncUiContributions } from "./projection";
import { handleRuntimeEvent, refreshEventKinds, startRuntimeUi } from "./runtimeUi";
import { grantPlugin, refreshPlugins, usePlugins } from "./store";
export const pluginActionId = (pluginId: string, commandId: string) => `plugin.${pluginId}.${commandId}`;
export { pluginActionId } from "./points";
/** Actions for every command of every running plugin. */
export function pluginActions(plugins: readonly PluginInfo[]): Action[] {
return plugins
.filter((p) => p.status === "active")
.flatMap((p) =>
p.commands.map((c): Action => ({
id: pluginActionId(p.id, c.id),
title: c.title,
category: c.category ?? p.name,
icon: "package",
keywords: ["plugin", p.name, p.id],
description: tx`From plugin ${p.name}`,
run: async ({ arg }) => {
await runPluginCommand(p.id, c.id, typeof arg === "string" ? arg : null);
},
})),
);
}
/** Keeps the action registry in sync with the plugin list; re-registers only changed actions. */
export function syncPluginActions(): () => void {
const registered = new Map<string, { key: string; dispose: () => void }>();
const apply = (plugins: readonly PluginInfo[]) => {
const next = new Map(pluginActions(plugins).map((a) => [a.id, a]));
for (const [id, entry] of registered) {
if (!next.has(id)) {
entry.dispose();
registered.delete(id);
}
}
for (const [id, action] of next) {
const key = `${action.title}\u0000${action.category}`;
const prev = registered.get(id);
if (prev?.key === key) continue;
prev?.dispose();
registered.set(id, { key, dispose: registerAction(action) });
}
/** 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);
};
apply(usePlugins.getState().plugins);
apply();
const unsubscribe = usePlugins.subscribe((s, prev) => {
if (s.plugins !== prev.plugins) apply(s.plugins);
if (s.ui !== prev.ui || s.plugins !== prev.plugins) apply();
});
return () => {
unsubscribe();
registered.forEach((e) => e.dispose());
registered.clear();
clearUiContributions();
};
}
@@ -174,35 +147,7 @@ export function trackPluginTheme(): () => void {
};
}
// --- editor -----------------------------------------------------------------
/** Inserts (`select: false`) or replaces-and-selects (`select: true`) at every selection range. */
export function applyTextToActiveEditor(text: string, select: boolean, pluginId: string): boolean {
const editor = activeEditor();
const name = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.name ?? pluginId;
if (!editor || editor.ctx.readOnly) {
notifications().notify({
group: "plugins",
level: "warning",
title: `${name}: ${editor ? "the editor is read-only" : "no editor to insert into"}`,
});
return false;
}
const { view } = editor;
view.dispatch(
view.state.changeByRange((range) => ({
changes: { from: range.from, to: range.to, insert: text },
range: select
? EditorSelection.range(range.from, range.from + text.length)
: EditorSelection.cursor(range.from + text.length),
})),
{ scrollIntoView: true, userEvent: "input.plugin" },
);
view.focus();
return true;
}
/** Reports the focused editor's file to the backend (`active-file` for plugins). */
/** 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) => {
@@ -228,15 +173,17 @@ export function handlePluginEvent(e: PluginEvent) {
notifications().notify({ group: "plugins", level: e.level, title: e.title || name, body: e.body || undefined });
break;
}
case "insertText":
applyTextToActiveEditor(e.text, false, e.pluginId);
break;
case "replaceSelection":
applyTextToActiveEditor(e.text, true, e.pluginId);
break;
case "themeChanged":
usePlugins.setState({ activeTheme: e.theme });
break;
case "consentRequested":
void refreshPlugins().then(() => grantPlugin(e.pluginId, e.capabilities));
break;
case "languagesChanged":
void refreshLanguageInfos();
break;
default:
handleRuntimeEvent(e);
}
}
@@ -247,15 +194,47 @@ function scheduleRefresh() {
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),
syncPluginActions(),
syncProjection(),
trackPluginTheme(),
trackIconTheme(),
trackActiveFile(),
trackActivationEvents(),
startRuntimeUi(),
];
return () => {
if (refreshTimer) clearTimeout(refreshTimer);
+141
Переглянути файл
@@ -0,0 +1,141 @@
/**
* The install / permission dialog (docs/plugin_plan.md 4.2, 4.6): every
* capability with its risk; optional ones can be refused; high-risk ones are
* called out as running code on this machine.
*/
import { useState } from "react";
import { create } from "zustand";
import type { CapabilityInfo, Consent } from "@/bindings";
import { tx } from "@/platform/i18n";
import { Badge, Checkbox, Dialog, T } from "@/ui";
import { setConsentPrompt, type ConsentRequest } from "./store";
import styles from "./PluginsPage.module.css";
interface Pending {
req: ConsentRequest;
resolve: (c: Consent | null) => void;
}
const usePending = create<{ queue: Pending[] }>(() => ({ queue: [] }));
export const RISK_LABEL: Record<CapabilityInfo["risk"], { label: string; tone: "neutral" | "warning" | "danger" }> = {
low: { label: "Low", tone: "neutral" },
medium: { label: "Medium", tone: "warning" },
high: { label: "High", tone: "danger" },
};
const RISK_ORDER = { high: 0, medium: 1, low: 2 } as const;
export function sortByRisk(caps: readonly CapabilityInfo[]): CapabilityInfo[] {
return [...caps].sort((a, b) => RISK_ORDER[a.risk] - RISK_ORDER[b.risk] || a.id.localeCompare(b.id));
}
function ask(req: ConsentRequest): Promise<Consent | null> {
return new Promise((resolve) => usePending.setState((s) => ({ queue: [...s.queue, { req, resolve }] })));
}
function ConsentDialog({ pending, onDone }: { pending: Pending; onDone: () => void }) {
const { req } = pending;
const caps = sortByRisk(req.capabilities);
const optional = caps.filter((c) => c.optional);
const [accepted, setAccepted] = useState<Set<string>>(
() => new Set(optional.filter((c) => c.granted || c.risk === "low").map((c) => c.id)),
);
const high = caps.some((c) => c.risk === "high" && (!c.optional || accepted.has(c.id)));
const finish = (consent: Consent | null) => {
pending.resolve(consent);
onDone();
};
const title = req.kind === "install" ? tx`Install ${req.name} ${req.version}?` : tx`Allow ${req.name} to run?`;
return (
<Dialog
open
title={title}
icon={high ? "warning" : "question"}
width={520}
onClose={() => finish(null)}
buttons={[
{ label: tx("Cancel"), role: "cancel", onClick: () => finish(null) },
{
label: req.kind === "install" ? tx("Install") : tx("Allow"),
role: "default",
onClick: () => finish({ optional: [...accepted] }),
},
]}
>
<div className={styles.consent}>
{req.unsigned && (
<p className={styles.consentWarning} role="note">
<T>This package is not signed. Only continue if you trust where it came from.</T>
</p>
)}
{caps.length === 0 ? (
<p>
<T>This plugin needs no special permissions.</T>
</p>
) : (
<ul className={styles.items} aria-label={tx("Requested permissions")}>
{caps.map((c) => (
<li key={c.id} className={styles.item}>
{c.optional ? (
<Checkbox
checked={accepted.has(c.id)}
onChange={(on) =>
setAccepted((s) => {
const next = new Set(s);
if (on) next.add(c.id);
else next.delete(c.id);
return next;
})
}
aria-label={tx`Allow ${c.id}`}
/>
) : (
<span className={styles.consentRequired} title={tx("Required")}>
•
</span>
)}
<span className={styles.itemMain}>{c.description}</span>
<span className={styles.mono}>{c.id}</span>
<Badge tone={RISK_LABEL[c.risk].tone}>{RISK_LABEL[c.risk].label}</Badge>
</li>
))}
</ul>
)}
{high && (
<p className={styles.consentWarning} role="alert">
<T>High-risk permissions are equivalent to running arbitrary code on this machine.</T>
</p>
)}
{req.warnings.map((w) => (
<p key={w} className={styles.muted}>
{w}
</p>
))}
</div>
</Dialog>
);
}
/** Overlay rendering the queued consent dialogs one at a time. */
export function ConsentDialogHost() {
const first = usePending((s) => s.queue[0]);
if (!first) return null;
return (
<ConsentDialog
key={first.req.pluginId + first.req.kind}
pending={first}
onDone={() => usePending.setState((s) => ({ queue: s.queue.slice(1) }))}
/>
);
}
/** Routes the store's consent requests to the dialog. */
export function provideConsentDialog(): () => void {
setConsentPrompt(ask);
return () => {
setConsentPrompt(null);
for (const p of usePending.getState().queue) p.resolve(null);
usePending.setState({ queue: [] });
};
}
+170
Переглянути файл
@@ -0,0 +1,170 @@
/**
* File icon themes contributed by plugins (`iconThemes`). The selection is a
* per-user preference kept in this browser profile; the mapping and the
* sanitised SVGs are fetched from the backend only when a theme is selected,
* so installed icon themes cost nothing at start-up.
*/
import { create } from "zustand";
import { registerExternalIcons, setFileIconTheme, type FileIconTheme } from "@/ui";
import { pluginsBackend } from "./backend";
export const ICON_THEME_KEY = "openidea.fileIconTheme";
export interface IconThemeRef {
pluginId: string;
id: string;
label: string;
}
export interface IconThemeMap {
file: string | null;
folder?: string | null;
folderExpanded?: string | null;
fileExtensions: Record<string, string>;
fileNames: Record<string, string>;
folderNames?: Record<string, string>;
}
interface IconThemesState {
/** `<pluginId>/<themeId>` → theme. */
available: ReadonlyMap<string, IconThemeRef>;
/** `<pluginId>/<themeId>` or null for the built-in icons. */
selected: string | null;
/** The theme whose icons are registered. */
applied: string | null;
}
const readSelection = (): string | null => {
try {
return localStorage.getItem(ICON_THEME_KEY);
} catch {
return null;
}
};
export const useIconThemes = create<IconThemesState>(() => ({
available: new Map(),
selected: readSelection(),
applied: null,
}));
export const iconThemeKey = (pluginId: string, id: string) => `${pluginId}/${id}`;
/** Called by the `iconThemes` projection: the theme can be selected while its plugin is live. */
export function addIconTheme(theme: IconThemeRef): () => void {
const key = iconThemeKey(theme.pluginId, theme.id);
useIconThemes.setState((s) => ({ available: new Map(s.available).set(key, theme) }));
return () =>
useIconThemes.setState((s) => {
const available = new Map(s.available);
available.delete(key);
return { available };
});
}
export function selectIconTheme(key: string | null) {
try {
if (key) localStorage.setItem(ICON_THEME_KEY, key);
else localStorage.removeItem(ICON_THEME_KEY);
} catch {
/* storage unavailable: the choice lasts for this window */
}
useIconThemes.setState({ selected: key });
}
/**
* The theme's resolver. Files: exact name, then the longest matching
* extension, then its default file icon. Folders: exact name, then its
* default (expanded) folder icon.
*/
export function iconResolver(
map: IconThemeMap,
iconName: (icon: string) => string,
has: (name: string) => boolean,
): FileIconTheme {
const pick = (icon: string | undefined | null) => (icon && has(iconName(icon)) ? iconName(icon) : null);
return {
file(fileName) {
const lower = fileName.toLowerCase();
const named = pick(map.fileNames[lower]);
if (named) return named;
let rest = lower;
for (let dot = rest.indexOf("."); dot >= 0; dot = rest.indexOf(".")) {
rest = rest.slice(dot + 1);
const byExt = pick(map.fileExtensions[rest]);
if (byExt) return byExt;
}
return pick(map.file);
},
folder(folderName, expanded) {
const named = pick(map.folderNames?.[folderName.toLowerCase()]);
if (named) return named;
return (expanded && pick(map.folderExpanded)) || pick(map.folder);
},
};
}
let undo: (() => void) | null = null;
let loading: string | null = null;
async function apply(key: string | null) {
const theme = key ? useIconThemes.getState().available.get(key) : undefined;
if (!theme) {
undo?.();
undo = null;
useIconThemes.setState({ applied: null });
return;
}
if (useIconThemes.getState().applied === key || loading === key) return;
loading = key;
try {
const prefix = `iconTheme/${theme.id}/`;
const assets = await pluginsBackend().assets(theme.pluginId, prefix);
if (loading !== key) return;
const mapJson = assets.find(([id]) => id === `${prefix}map`)?.[1];
if (!mapJson) throw new Error(`icon theme ${key} has no mapping`);
const map = JSON.parse(mapJson) as IconThemeMap;
const svgPrefix = `${prefix}svg/`;
const iconName = (icon: string) => `ext:${key}/${icon}`;
const icons = assets
.filter(([id]) => id.startsWith(svgPrefix))
.map(([id, svg]) => [iconName(id.slice(svgPrefix.length)), svg] as const);
const names = new Set(icons.map(([n]) => n));
undo?.();
const unregister = registerExternalIcons(icons);
setFileIconTheme(iconResolver(map, iconName, (n) => names.has(n)));
undo = () => {
setFileIconTheme(null);
unregister();
};
useIconThemes.setState({ applied: key });
} catch (e) {
console.warn("could not load the file icon theme", e);
} finally {
if (loading === key) loading = null;
}
}
/** Keeps the registered icons in line with the selection and the available themes. */
export function trackIconTheme(): () => void {
const sync = () => {
const { selected, available } = useIconThemes.getState();
void apply(selected && available.has(selected) ? selected : null);
};
sync();
const unsubscribe = useIconThemes.subscribe((s, prev) => {
if (s.selected !== prev.selected || s.available !== prev.available) sync();
});
// Another window (Settings) changed the selection.
const onStorage = (e: StorageEvent) => {
if (e.key === ICON_THEME_KEY) useIconThemes.setState({ selected: e.newValue });
};
window.addEventListener("storage", onStorage);
return () => {
unsubscribe();
window.removeEventListener("storage", onStorage);
undo?.();
undo = null;
useIconThemes.setState({ applied: null });
};
}
+29 -5
Переглянути файл
@@ -1,18 +1,28 @@
/**
* Plugins: Settings › Plugins (installed list, enable/disable, install from
* disk, uninstall, theme selection) and the runtime bridge to the WASM plugin
* host (`ide-plugin`).
* disk with a permission page, uninstall, theme selection) and the runtime
* bridge to the plugin host (`ide_host::plugins`): UI contributions are
* projected by `projection.ts` / `points.tsx`.
*
* Actions: `plugins.settings`, `plugins.installFromDisk`, `plugins.reloadAll`,
* plus one action per plugin command: `plugin.<pluginId>.<commandId>`
* (searchable in Search Everywhere › Actions and bindable in the keymap editor;
* a string arg is forwarded to the plugin).
*
* Editor contribution `plugins.lineMarkers`: gutter icons from plugin
* `line-markers` providers (`lineMarkerGutter.ts`).
*/
import { registerEditorContribution } from "@/editor/api";
import { lazyComponent } from "@/ui";
import { executeAction, registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { registerSettingsPage } from "@/workbench/contributions";
import { startPluginBridge, trackPluginTheme } from "./bridge";
import { editorService, registerOverlay, registerSettingsPage } from "@/workbench/contributions";
import { startPluginBridge, syncProjection, trackPluginTheme } from "./bridge";
import { ConsentDialogHost, provideConsentDialog } from "./consent";
import { trackIconTheme } from "./iconThemes";
import { pluginLineMarkerGutter } from "./lineMarkerGutter";
import { defineBuiltinUiPoints } from "./points";
import { startViewUi } from "./uiPoints";
import { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, usePlugins } from "./store";
function registerPluginsPage(): () => void {
@@ -28,7 +38,15 @@ function registerPluginsPage(): () => void {
/** Settings › Plugins for the Settings window: the page plus the plugin theme. */
export function activateSettingsPages(): () => void {
const disposers = [registerPluginsPage(), trackPluginTheme()];
const disposers = [
registerPluginsPage(),
registerOverlay({ id: "plugins.consent", component: ConsentDialogHost }),
provideConsentDialog(),
defineBuiltinUiPoints(),
syncProjection(),
trackPluginTheme(),
trackIconTheme(),
];
void refreshPlugins();
return () => disposers.reverse().forEach((d) => d());
}
@@ -36,6 +54,8 @@ export function activateSettingsPages(): () => void {
export function activate(): () => void {
const disposers = [
registerPluginsPage(),
registerOverlay({ id: "plugins.consent", component: ConsentDialogHost }),
provideConsentDialog(),
registerActions([
{
id: "plugins.settings",
@@ -68,6 +88,10 @@ export function activate(): () => void {
},
]),
startPluginBridge(),
// Plugin custom editors (tab kind) and context menu entries.
startViewUi((input) => void editorService().openInput(input)),
// Gutter icons from plugins' `line-markers` providers (next to the LSP ones).
registerEditorContribution({ id: "plugins.lineMarkers", order: 57, extensions: pluginLineMarkerGutter }),
// Dev plugins (`<project>/.openidea/plugins`) depend on the open project.
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root || s.project?.trusted !== prev.project?.trusted) void refreshPlugins();
+149
Переглянути файл
@@ -0,0 +1,149 @@
/**
* Gutter icons from plugins' `line-markers` providers (MyBatis' arrows
* between a mapper interface and its XML). Asked once per document version
* (debounced); markers follow their lines in between; a click opens the
* marker's target. The gutter column exists only while there are markers,
* so editors without plugin providers keep their usual width.
*/
import { Compartment, RangeSet, StateEffect, StateField, type Extension, type Text } from "@codemirror/state";
import { EditorView, GutterMarker, ViewPlugin, gutter, type ViewUpdate } from "@codemirror/view";
import type { EditorDocContext } from "@/editor/api";
import { navigateTo } from "@/editor/lsp/navigation";
import { commands, type LineMarkerInfo } from "@/platform/ipc";
import { gutterIconMarkup } from "@/ui";
import styles from "@/editor/navigation/LineMarkers.module.css";
const DEBOUNCE_MS = 400;
const ICONS: Record<string, string> = {
implemented: "gutter-implemented",
implementing: "gutter-implementing",
overridden: "gutter-overridden",
overriding: "gutter-overriding",
run: "gutter-run",
navigate: "gutter-implementing",
};
/** Icon name for a plugin marker (`navigate` when unknown). */
export function markerIcon(icon: string | null): string {
return ICONS[icon ?? "navigate"] ?? ICONS.navigate!;
}
interface Placed {
marker: LineMarkerInfo;
pos: number;
}
const setMarkers = StateEffect.define<Placed[]>();
const markersField = StateField.define<Placed[]>({
create: () => [],
update(value, tr) {
for (const e of tr.effects) if (e.is(setMarkers)) return e.value;
if (!tr.docChanged || !value.length) return value;
return value.map((m) => ({ ...m, pos: tr.state.doc.lineAt(tr.changes.mapPos(m.pos, -1)).from }));
},
});
/** Markers on existing lines, at their line starts. */
export function placeMarkers(doc: Text, markers: readonly LineMarkerInfo[]): Placed[] {
return markers.filter((m) => m.line < doc.lines).map((marker) => ({ marker, pos: doc.line(marker.line + 1).from }));
}
class PluginMarker extends GutterMarker {
constructor(readonly marker: LineMarkerInfo) {
super();
}
override eq(other: GutterMarker): boolean {
return (
other instanceof PluginMarker &&
other.marker.tooltip === this.marker.tooltip &&
other.marker.icon === this.marker.icon
);
}
override toDOM(): Node {
const el = document.createElement("div");
el.className = styles.marker!;
el.title = this.marker.tooltip;
el.setAttribute("role", "button");
el.setAttribute("aria-label", this.marker.tooltip);
el.innerHTML = gutterIconMarkup(markerIcon(this.marker.icon)) ?? "";
return el;
}
}
class MarkersPlugin {
private timer: ReturnType<typeof setTimeout> | null = null;
private generation = 0;
private shown = false;
constructor(
private view: EditorView,
private doc: number,
private column: Compartment,
) {
this.schedule();
}
update(u: ViewUpdate) {
if (u.docChanged) this.schedule();
}
private schedule() {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => void this.run(), DEBOUNCE_MS);
}
private async run() {
this.timer = null;
const gen = ++this.generation;
const text = this.view.state.doc;
try {
const markers = await commands.pluginLineMarkers(this.doc);
if (gen !== this.generation || this.view.state.doc !== text || !this.view.dom.isConnected) return;
if (markers.status !== "ok") return;
const placed = placeMarkers(text, markers.data);
if (!placed.length && !this.view.state.field(markersField).length) return;
const effects: StateEffect<unknown>[] = [setMarkers.of(placed)];
if (placed.length > 0 !== this.shown) {
this.shown = placed.length > 0;
effects.push(this.column.reconfigure(this.shown ? markerColumn : []));
}
this.view.dispatch({ effects });
} catch (e) {
console.debug("[plugin line markers]", e);
}
}
destroy() {
if (this.timer) clearTimeout(this.timer);
this.generation++;
}
}
const markerColumn: Extension = gutter({
class: `${styles.gutter} cm-openidea-gutter-pluginMarkers`,
markers: (view) =>
RangeSet.of(
view.state.field(markersField).map((m) => new PluginMarker(m.marker).range(m.pos)),
true,
),
domEventHandlers: {
mousedown: (view, block, event) => {
const e = event as MouseEvent;
if (e.button !== 0) return false;
const target = view.state.field(markersField).find((m) => m.pos === block.from && m.marker.target)?.marker.target;
if (!target) return false;
e.preventDefault();
void navigateTo(target);
return true;
},
},
});
export function pluginLineMarkerGutter(ctx: EditorDocContext): Extension {
if (ctx.doc < 0) return [];
const doc = ctx.doc;
const column = new Compartment();
return [markersField, column.of([]), ViewPlugin.define((view) => new MarkersPlugin(view, doc, column))];
}
+158
Переглянути файл
@@ -0,0 +1,158 @@
/**
* Client side of the first-party UI extension points: how each validated
* declaration is projected onto the workbench, and how its items appear on
* the plugin details page. One `defineUiPoint` per contribution type.
*/
import type { PluginInfo, ThemeDef } from "@/bindings";
import { registerAction } from "@/platform/actions";
import { tx } from "@/platform/i18n";
import { Button, Kbd, T } from "@/ui";
import { addIconTheme, iconThemeKey, selectIconTheme, useIconThemes } from "./iconThemes";
import { defineUiPoint, type UiItem } from "./projection";
import { SecretsDetails, type SecretDecl } from "./secrets";
import { defineViewUiPoints } from "./uiPoints";
import { isLive, runPluginCommand, selectPluginTheme, usePlugins } from "./store";
import styles from "./PluginsPage.module.css";
export const pluginActionId = (pluginId: string, commandId: string) => `plugin.${pluginId}.${commandId}`;
interface CommandDecl {
id: string;
title: string;
category?: string | null;
when?: string | null;
}
function CommandsDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<CommandDecl>[] }) {
const runnable = plugin.status === "active" || plugin.status === "inactive";
return (
<ul className={styles.items}>
{items.map(({ decl: c }) => {
const actionId = pluginActionId(plugin.id, c.id);
return (
<li key={c.id} className={styles.item}>
<span className={styles.itemMain}>{c.title}</span>
<Kbd action={actionId} />
<span className={styles.mono} title={tx("Action id (bind it in Keymap)")}>
{actionId}
</span>
<Button
size="small"
variant="ghost"
icon="run"
disabled={!runnable}
onClick={() => void runPluginCommand(plugin.id, c.id)}
aria-label={tx`Run ${c.title}`}
>
<T>Run</T>
</Button>
</li>
);
})}
</ul>
);
}
function ThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<ThemeDef>[] }) {
const activeTheme = usePlugins((s) => s.activeTheme);
const live = isLive(plugin.status);
return (
<ul className={styles.items}>
{items.map(({ decl: t }) => {
const isActive = activeTheme?.pluginId === plugin.id && activeTheme.theme.id === t.id;
return (
<li key={t.id} className={styles.item}>
<span className={styles.itemMain}>{t.name}</span>
<span className={styles.muted}>{t.kind === "dark" ? tx("Dark") : tx("Light")}</span>
{isActive ? (
<Button size="small" variant="secondary" onClick={() => void selectPluginTheme(null, null)}>
<T>Use Default Theme</T>
</Button>
) : (
<Button
size="small"
variant="ghost"
disabled={!live}
onClick={() => void selectPluginTheme(plugin.id, t.id)}
aria-label={tx`Apply theme ${t.name}`}
>
<T>Apply</T>
</Button>
)}
</li>
);
})}
</ul>
);
}
interface IconThemeDecl {
id: string;
label: string;
icons: number;
}
function IconThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<IconThemeDecl>[] }) {
const selected = useIconThemes((s) => s.selected);
return (
<ul className={styles.items}>
{items.map(({ decl: t }) => {
const key = iconThemeKey(plugin.id, t.id);
return (
<li key={t.id} className={styles.item}>
<span className={styles.itemMain}>{t.label}</span>
<span className={styles.muted}>{tx`${t.icons} icons`}</span>
{selected === key ? (
<Button size="small" variant="secondary" onClick={() => selectIconTheme(null)}>
<T>Use Default Icons</T>
</Button>
) : (
<Button
size="small"
variant="ghost"
onClick={() => selectIconTheme(key)}
aria-label={tx`Apply icon theme ${t.label}`}
>
<T>Apply</T>
</Button>
)}
</li>
);
})}
</ul>
);
}
/** Registers the client side of every first-party UI point. */
export function defineBuiltinUiPoints(): () => void {
const disposers = [
defineUiPoint<CommandDecl>("commands", {
title: "Commands",
project: ({ pluginId, decl }, { plugin }) =>
registerAction({
id: pluginActionId(pluginId, decl.id),
title: decl.title,
category: decl.category ?? plugin.name,
icon: "package",
when: decl.when ?? undefined,
keywords: ["plugin", plugin.name, pluginId],
description: tx`From plugin ${plugin.name}`,
run: async ({ arg }) => {
await runPluginCommand(pluginId, decl.id, typeof arg === "string" ? arg : null);
},
}),
Details: CommandsDetails,
}),
// Applied through `activeTheme` (the backend owns the selection); listed here.
defineUiPoint<ThemeDef>("themes", { title: "Themes", Details: ThemesDetails }),
defineUiPoint<IconThemeDecl>("iconThemes", {
title: "File Icon Themes",
project: ({ pluginId, decl }) => addIconTheme({ pluginId, id: decl.id, label: decl.label }),
Details: IconThemesDetails,
}),
// Stored by the backend; listed here with a field per secret.
defineUiPoint<SecretDecl>("secrets", { title: "Secrets", Details: SecretsDetails }),
defineViewUiPoints(),
];
return () => disposers.reverse().forEach((d) => d());
}
+129
Переглянути файл
@@ -0,0 +1,129 @@
/**
* Projection of plugin UI contributions onto the workbench's own `register*`
* functions (docs/plugin_plan.md 4.1).
*
* The backend validated every declaration completely (Rust extension points);
* a projection maps one onto an existing registration and returns its
* disposer. Projections must not fail: an exception is a defect, logged, and
* that one item is skipped — nothing is half-registered.
*
* Adding a UI contribution type = a Rust extension point + one
* `defineUiPoint` here; the kernel (this file, the store, the bridge) is not
* touched.
*/
import type { ComponentType } from "react";
import type { PluginInfo, UiContribution } from "@/bindings";
export interface UiItem<T = Record<string, unknown>> {
pluginId: string;
point: string;
key: string;
decl: T;
}
export interface ProjectionContext {
plugin: PluginInfo;
}
export interface UiPointDefinition<T = Record<string, unknown>> {
/** Makes one item take effect; returns its disposer. */
project?: (item: UiItem<T>, ctx: ProjectionContext) => (() => void) | void;
/** Optional section in Settings › Plugins › details for this point's items. */
Details?: ComponentType<{ plugin: PluginInfo; items: UiItem<T>[] }>;
/** Section title for the details page (defaults to the point id). */
title?: string;
}
const points = new Map<string, UiPointDefinition<never>>();
const live = new Map<string, { sig: string; dispose: () => void }>();
const warned = new Set<string>();
let lastItems: readonly UiContribution[] = [];
let lastPlugins: readonly PluginInfo[] = [];
const itemKey = (c: UiContribution) => `${c.pluginId}\u0000${c.point}\u0000${c.key}`;
/** Registers the client side of an extension point. */
export function defineUiPoint<T>(point: string, def: UiPointDefinition<T>): () => void {
if (points.has(point)) throw new Error(`UI point "${point}" is already defined`);
points.set(point, def as UiPointDefinition<never>);
// Items that arrived before the definition (lazy features) are projected now.
syncUiContributions(lastItems, lastPlugins);
return () => {
for (const [key, entry] of live) {
if (key.split("\u0000")[1] === point) {
safeDispose(entry.dispose);
live.delete(key);
}
}
points.delete(point);
};
}
export function uiPoint(point: string): UiPointDefinition<never> | undefined {
return points.get(point);
}
function safeDispose(dispose: () => void) {
try {
dispose();
} catch (e) {
console.error("plugin contribution disposer failed", e);
}
}
/**
* Brings the registrations in line with `items` (the backend's snapshot):
* removed or changed items are disposed, new or changed ones projected.
*/
export function syncUiContributions(items: readonly UiContribution[], plugins: readonly PluginInfo[]) {
lastItems = items;
lastPlugins = plugins;
const byId = new Map(plugins.map((p) => [p.id, p]));
const next = new Map<string, { item: UiContribution; sig: string }>();
for (const item of items) {
if (!points.get(item.point)?.project) {
if (!points.has(item.point) && !warned.has(item.point)) {
warned.add(item.point);
console.warn(`plugin UI point "${item.point}" has no client projection`);
}
continue;
}
if (!byId.has(item.pluginId)) continue;
next.set(itemKey(item), { item, sig: JSON.stringify(item.decl) });
}
for (const [key, entry] of live) {
if (next.get(key)?.sig !== entry.sig) {
safeDispose(entry.dispose);
live.delete(key);
}
}
for (const [key, { item, sig }] of next) {
if (live.has(key)) continue;
const def = points.get(item.point)!;
const plugin = byId.get(item.pluginId)!;
try {
const dispose = def.project!(item as UiItem<never>, { plugin }) ?? (() => {});
live.set(key, { sig, dispose });
} catch (e) {
// The backend validated the declaration: a failing projection is a defect.
console.error(`projecting ${item.point} "${item.key}" of plugin ${item.pluginId} failed`, e);
}
}
}
/** Disposes every projected registration (shutdown, tests). */
export function clearUiContributions() {
for (const entry of live.values()) safeDispose(entry.dispose);
live.clear();
lastItems = [];
lastPlugins = [];
}
/** Items of one point, optionally of one plugin. */
export function itemsOf<T = Record<string, unknown>>(
items: readonly UiContribution[],
point: string,
pluginId?: string,
): UiItem<T>[] {
return items.filter((c) => c.point === point && (!pluginId || c.pluginId === pluginId)) as UiItem<T>[];
}
+273
Переглянути файл
@@ -0,0 +1,273 @@
/**
* What running plugin code shows outside its declarations: status bar items
* (`ui.set-status-item`), background tasks (`tasks.begin`), and the
* workbench events it subscribed to (`events.subscribe`), reported in
* batches.
*/
import { create } from "zustand";
import type { EventKind, PluginEvent, StatusItem, WorkbenchEvent } from "@/bindings";
import { onEditorEvent } from "@/editor/api";
import { executeAction, getAction, onActionExecuted } from "@/platform/actions";
import { onIdeEvent } from "@/platform/ipc";
import { useProject } from "@/platform/project";
import { shortcutLabel } from "@/platform/keymap";
import { BarButton } from "@/ui/BarButton";
import { registerStatusItem } from "@/workbench/contributions";
import { removeTask, upsertTask, useTasks } from "@/workbench/tasks";
import { pluginsBackend } from "./backend";
import { bumpView, deliverViewMessage } from "./views/viewStore";
import { runPluginCommand } from "./store";
// --- status bar items ---------------------------------------------------------
type ItemKey = `${string}/${string}`;
export const usePluginStatusItems = create<{ items: Map<ItemKey, { pluginId: string; item: StatusItem }> }>(() => ({
items: new Map(),
}));
function setStatusItem(pluginId: string, id: string, item: StatusItem | null) {
usePluginStatusItems.setState((s) => {
const items = new Map(s.items);
if (item) items.set(`${pluginId}/${id}`, { pluginId, item });
else items.delete(`${pluginId}/${id}`);
return { items };
});
}
/** Items declared under `[[contributes.statusBar]]`: shown until (and unless) the code sets its own. */
interface Declared {
item: StatusItem;
align: "left" | "right";
priority: number;
}
export const useDeclaredStatusItems = create<{ items: Map<ItemKey, { pluginId: string } & Declared> }>(() => ({
items: new Map(),
}));
export function declareStatusItem(pluginId: string, id: string, declared: Declared): () => void {
const key: ItemKey = `${pluginId}/${id}`;
const entry = { pluginId, ...declared };
useDeclaredStatusItems.setState((s) => ({ items: new Map(s.items).set(key, entry) }));
return () =>
useDeclaredStatusItems.setState((s) => {
if (s.items.get(key) !== entry) return s;
const items = new Map(s.items);
items.delete(key);
return { items };
});
}
function PluginStatusItemView({ itemKey }: { itemKey: ItemKey }) {
const running = usePluginStatusItems((s) => s.items.get(itemKey));
const declared = useDeclaredStatusItems((s) => s.items.get(itemKey));
const entry = running ?? declared;
if (!entry) return null;
const { pluginId, item } = entry;
const command = item.command;
return (
<BarButton
tooltip={item.tooltip ?? undefined}
aria-label={item.tooltip ?? item.text}
onClick={command ? () => void runPluginCommand(pluginId, command) : undefined}
>
{item.text}
</BarButton>
);
}
/** One status bar contribution per plugin item (declared or set by code), registered while it exists. */
function projectStatusItems(): () => void {
const registered = new Map<ItemKey, { dispose: () => void; sig: string }>();
const sync = () => {
const running = usePluginStatusItems.getState().items;
const declared = useDeclaredStatusItems.getState().items;
const live = new Set<ItemKey>([...running.keys(), ...declared.keys()]);
for (const [key, r] of registered) {
const d = declared.get(key);
const sig = d ? `${d.align}/${d.priority}` : "";
if (!live.has(key) || r.sig !== sig) {
r.dispose();
registered.delete(key);
}
}
for (const key of live)
if (!registered.has(key)) {
const d = declared.get(key);
const View = () => <PluginStatusItemView itemKey={key} />;
const dispose = registerStatusItem({
id: `plugin.${key}`,
align: d?.align ?? "right",
priority: d?.priority ?? 2,
component: View,
});
registered.set(key, { dispose, sig: d ? `${d.align}/${d.priority}` : "" });
}
};
sync();
const unsubscribe = [
usePluginStatusItems.subscribe((s, prev) => s.items !== prev.items && sync()),
useDeclaredStatusItems.subscribe((s, prev) => s.items !== prev.items && sync()),
];
refreshStatusItems();
return () => {
unsubscribe.forEach((u) => u());
for (const r of registered.values()) r.dispose();
registered.clear();
};
}
/**
* Re-reads the status items of running plugin code (later changes arrive as
* `statusItemChanged`). Like the event kinds, the backend answers once this
* window's project is open and trusted, so call it again when that happens.
*/
function refreshStatusItems() {
void pluginsBackend()
.statusItems()
.then((items) => items.forEach((i) => setStatusItem(i.pluginId, i.id, i.item)))
.catch(() => {});
}
// --- tasks --------------------------------------------------------------------
const taskId = (id: string) => `plugin:${id}`;
function onTaskEvent(e: Extract<PluginEvent, { type: "taskStarted" | "taskProgress" | "taskEnded" }>) {
const id = taskId(e.taskId);
const current = useTasks.getState().tasks.find((t) => t.id === id);
switch (e.type) {
case "taskStarted":
upsertTask({
id,
title: e.title,
message: null,
fraction: null,
done: false,
cancel: e.cancellable ? () => void pluginsBackend().cancelTask(e.taskId) : undefined,
});
break;
case "taskProgress":
if (current)
upsertTask({
...current,
message: e.message,
fraction: e.total ? Math.min(1, e.done / e.total) : null,
});
break;
case "taskEnded":
if (current) upsertTask({ ...current, done: true });
else removeTask(id);
break;
}
}
// --- workbench events -----------------------------------------------------------
/** Batches are sent at most this often; the backend batches again per plugin. */
const POST_INTERVAL_MS = 500;
let wanted = new Set<EventKind>();
let queue: WorkbenchEvent[] = [];
let postTimer: ReturnType<typeof setTimeout> | null = null;
/**
* Re-reads the event kinds plugins want. Besides `subscriptionsChanged`
* (run-time subscriptions), declared `on-event:` kinds change whenever plugins
* load, install, toggle or get grants, which the backend reports as `commandsChanged`.
*/
export function refreshEventKinds() {
void pluginsBackend()
.eventKinds()
.then((kinds) => (wanted = new Set(kinds)))
.catch(() => {});
}
function post(event: WorkbenchEvent) {
queue.push(event);
if (postTimer) return;
postTimer = setTimeout(() => {
postTimer = null;
const batch = queue;
queue = [];
void pluginsBackend()
.postEvents(batch)
.catch(() => {});
}, POST_INTERVAL_MS);
}
/** Reports the events plugins subscribed to: files opened and saved, actions run, repositories changed. */
function trackWorkbenchEvents(): () => void {
refreshEventKinds();
const offEditor = onEditorEvent((e) => {
if (e.type === "docOpened" && wanted.has("file-opened")) post({ kind: "fileOpened", value: e.path });
if (e.type === "docSaved" && wanted.has("file-saved")) post({ kind: "fileSaved", value: e.path });
});
const offActions = onActionExecuted((id, _arg, source) => {
if (!wanted.has("action-performed")) return;
const action = getAction(id);
post({
kind: "actionPerformed",
value: { action: id, title: action?.title ?? id, source, shortcut: shortcutLabel(id) ?? null },
});
});
const offVcs = onIdeEvent("vcsChanged", (e) => {
if (wanted.has("vcs-changed")) post({ kind: "vcsChanged", value: e.root });
});
return () => {
offEditor();
offActions();
offVcs();
if (postTimer) clearTimeout(postTimer);
postTimer = null;
queue = [];
};
}
/** Handles the runtime UI events; returns whether `e` was one. */
export function handleRuntimeEvent(e: PluginEvent): boolean {
switch (e.type) {
case "statusItemChanged":
setStatusItem(e.pluginId, e.id, e.item);
return true;
case "taskStarted":
case "taskProgress":
case "taskEnded":
onTaskEvent(e);
return true;
case "subscriptionsChanged":
refreshEventKinds();
return true;
case "viewChanged":
bumpView(e.pluginId, e.view);
return true;
case "viewMessage":
deliverViewMessage(e.pluginId, e.view, e.message);
return true;
case "openTerminal":
// The program was approved (`process:`) and verified by the host.
void executeAction("terminal.runCommand", { program: e.program, args: e.args, name: e.title, cwd: e.cwd });
return true;
default:
return false;
}
}
/**
* The backend refuses the event kinds and status items until this window's
* project is open and trusted (`authorize_command`), but startup asks before
* that: ask again when it changes.
*/
function refreshOnProjectChange(): () => void {
return useProject.subscribe((s, prev) => {
if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return;
refreshEventKinds();
refreshStatusItems();
});
}
export function startRuntimeUi(): () => void {
const disposers = [projectStatusItems(), trackWorkbenchEvents(), refreshOnProjectChange()];
return () => disposers.reverse().forEach((d) => d());
}
+114
Переглянути файл
@@ -0,0 +1,114 @@
/**
* `[[contributes.secrets]]` on the plugin details page: a password field per
* declared secret. Values go to the backend's secret store (the macOS
* Keychain in the desktop app) and never come back to the UI; plugins use
* them only as `{{secret:<name>}}` placeholders in `net` request headers.
*/
import { useCallback, useEffect, useState } from "react";
import type { PluginInfo, PluginSecretState } from "@/bindings";
import { tx } from "@/platform/i18n";
import { Badge, Button, Input, T } from "@/ui";
import { pluginsBackend } from "./backend";
import type { UiItem } from "./projection";
import styles from "./PluginsPage.module.css";
export interface SecretDecl {
name: string;
label: string;
description?: string | null;
}
function SecretRow({
plugin,
decl,
isSet,
onChanged,
}: {
plugin: PluginInfo;
decl: SecretDecl;
isSet: boolean;
onChanged: () => void;
}) {
const [value, setValue] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const save = async (v: string | null) => {
setBusy(true);
setError(null);
try {
await pluginsBackend().setSecret(plugin.id, decl.name, v);
setValue("");
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return (
<li className={styles.item}>
<span className={styles.itemMain} title={decl.description ?? undefined}>
{decl.label}
</span>
{isSet ? <Badge tone="success">{tx("Stored")}</Badge> : <Badge tone="neutral">{tx("Not set")}</Badge>}
<Input
type="password"
value={value}
disabled={busy}
placeholder={isSet ? "Replace…" : "Value"}
aria-label={tx`Value of ${decl.label}`}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && value) void save(value);
}}
/>
<Button size="small" variant="ghost" disabled={busy || !value} onClick={() => void save(value)}>
<T>Save</T>
</Button>
{isSet && (
<Button
size="small"
variant="ghost"
disabled={busy}
onClick={() => void save(null)}
aria-label={tx`Remove ${decl.label}`}
>
<T>Remove</T>
</Button>
)}
{error && (
<span className={styles.error} role="alert">
{error}
</span>
)}
</li>
);
}
export function SecretsDetails({ plugin, items }: { plugin: PluginInfo; items: UiItem<SecretDecl>[] }) {
const [state, setState] = useState<PluginSecretState[]>([]);
const refresh = useCallback(() => {
pluginsBackend()
.secrets(plugin.id)
.then(setState, () => setState([]));
}, [plugin.id]);
useEffect(refresh, [refresh]);
return (
<>
<ul className={styles.items} aria-label={tx("Secrets")}>
{items.map(({ decl }) => (
<SecretRow
key={decl.name}
plugin={plugin}
decl={decl}
isSet={state.some((s) => s.name === decl.name && s.set)}
onChanged={refresh}
/>
))}
</ul>
<div className={styles.muted}>
<T>The plugin can only send these with its network requests; it cannot read them.</T>
</div>
</>
);
}
+189 -15
Переглянути файл
@@ -1,6 +1,17 @@
/** Installed plugins, the selected plugin theme, and the operations on them. */
/** Installed plugins, their UI contributions, the selected plugin theme, and the operations on them. */
import { create } from "zustand";
import type { ActiveTheme, CatalogPlugin, PluginInfo } from "@/bindings";
import type {
ActiveTheme,
CapabilityInfo,
CatalogPlugin,
Consent,
MarketPlugin,
PluginEditor,
PluginInfo,
PluginStatus,
UiContribution,
} from "@/bindings";
import { activeEditor } from "@/editor/api";
import { notifications } from "@/workbench/contributions";
import { pluginsBackend } from "./backend";
@@ -8,6 +19,8 @@ export const SETTINGS_PAGE_ID = "plugins";
interface PluginsState {
plugins: PluginInfo[];
/** UI contributions of live plugins, projected by `projection.ts`. */
ui: UiContribution[];
activeTheme: ActiveTheme | null;
loaded: boolean;
loading: boolean;
@@ -18,10 +31,16 @@ interface PluginsState {
/** Locally available packages (Marketplace tab); `null` until loaded. */
catalog: CatalogPlugin[] | null;
catalogError: string | null;
/** The marketplace index URL; `null` when none is configured. */
marketUrl: string | null;
/** Plugins of the marketplace index; `null` until read. */
market: MarketPlugin[] | null;
marketError: string | null;
}
export const usePlugins = create<PluginsState>(() => ({
plugins: [],
ui: [],
activeTheme: null,
loaded: false,
loading: false,
@@ -30,10 +49,48 @@ export const usePlugins = create<PluginsState>(() => ({
installing: false,
catalog: null,
catalogError: null,
marketUrl: null,
market: null,
marketError: null,
}));
export const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
/** Statuses whose contributions are in effect. */
export const isLive = (status: PluginStatus) => status === "active" || status === "inactive" || status === "static";
// --- consent ------------------------------------------------------------------
/** What a consent prompt shows: an install preview or a plugin waiting for permission. */
export interface ConsentRequest {
pluginId: string;
name: string;
version: string;
/** Install page (new or update) or a permission prompt for an installed/project plugin. */
kind: "install" | "grant";
capabilities: CapabilityInfo[];
/** Unsigned package (from disk or a project folder). */
unsigned: boolean;
warnings: string[];
}
type ConsentPrompt = (req: ConsentRequest) => Promise<Consent | null>;
let consentPrompt: ConsentPrompt | null = null;
/** The dialog that asks (registered by `consent.tsx`; tests substitute their own). */
export function setConsentPrompt(prompt: ConsentPrompt | null) {
consentPrompt = prompt;
}
/** Without a prompt only plugins asking for nothing are accepted. */
function askConsent(req: ConsentRequest): Promise<Consent | null> {
if (consentPrompt) return consentPrompt(req);
return Promise.resolve(req.capabilities.length === 0 ? { optional: [] } : null);
}
// --- loading ------------------------------------------------------------------
let generation = 0;
export async function refreshPlugins(): Promise<void> {
@@ -44,6 +101,7 @@ export async function refreshPlugins(): Promise<void> {
if (gen !== generation) return;
usePlugins.setState({
plugins: snapshot.plugins,
ui: snapshot.ui,
activeTheme: snapshot.activeTheme,
loaded: true,
loading: false,
@@ -93,6 +151,29 @@ export function uninstallPlugin(id: string): Promise<boolean> {
});
}
/** Asks for (and records) the permissions of a plugin waiting for consent. */
export async function grantPlugin(id: string, capabilities?: CapabilityInfo[]): Promise<boolean> {
const plugin = usePlugins.getState().plugins.find((p) => p.id === id);
if (!plugin) return false;
const consent = await askConsent({
pluginId: id,
name: plugin.name,
version: plugin.version,
kind: "grant",
capabilities: capabilities ?? plugin.capabilities,
unsigned: plugin.source !== "installed",
warnings: plugin.warnings,
});
if (!consent) return false;
return withBusy(id, `Could not enable ${plugin.name}`, () => pluginsBackend().grant(id, consent));
}
export function setPluginCapability(id: string, capability: string, granted: boolean): Promise<boolean> {
return withBusy(id, `Could not change ${capability} of ${nameOf(id)}`, () =>
pluginsBackend().setCapability(id, capability, granted),
);
}
export async function refreshCatalog(): Promise<void> {
try {
usePlugins.setState({ catalog: await pluginsBackend().catalog(), catalogError: null });
@@ -101,6 +182,57 @@ export async function refreshCatalog(): Promise<void> {
}
}
/** The marketplace's plugins: the index read again with `refresh`. */
export async function refreshMarket(refresh = true): Promise<void> {
const backend = pluginsBackend();
try {
const marketUrl = await backend.marketUrl();
usePlugins.setState({ marketUrl });
if (!marketUrl) return usePlugins.setState({ market: null, marketError: null });
usePlugins.setState({ market: await backend.market(refresh), marketError: null });
} catch (e) {
usePlugins.setState({ marketError: errorText(e) });
}
}
export async function setMarketUrl(url: string | null): Promise<void> {
await pluginsBackend().setMarketUrl(url?.trim() || null);
await refreshMarket(true);
}
/** Downloads (and checks) a marketplace plugin, then the install page as from disk. */
export async function installFromMarket(id: string): Promise<string | null> {
usePlugins.setState({ installing: true });
let dir: string;
try {
dir = await pluginsBackend().marketDownload(id);
} catch (e) {
usePlugins.setState({ installing: false });
notifications().notify({
group: "plugins",
level: "error",
title: "Could not download plugin",
body: errorText(e),
});
return null;
}
const installed = await installFromDisk(dir);
void refreshMarket(false);
return installed;
}
/** Settings › Plugins › Roll Back: the version before the last update. */
export async function rollBackPlugin(id: string): Promise<void> {
try {
const info = await pluginsBackend().rollBack(id);
notifications().notify({ group: "plugins", level: "info", title: `Rolled back ${info.name} to ${info.version}` });
} catch (e) {
notifications().notify({ group: "plugins", level: "error", title: "Could not roll back", body: errorText(e) });
} finally {
await refreshPlugins();
}
}
/** Enables or disables every installed (non-dev) plugin; returns how many changed. */
export async function setAllInstalledEnabled(enabled: boolean): Promise<number> {
const targets = usePlugins.getState().plugins.filter((p) => p.source === "installed" && p.enabled !== enabled);
@@ -124,25 +256,41 @@ export function compareVersions(a: string, b: string): number {
return 0;
}
/** Asks for a plugin package (a `.zip` or a `plugin.toml`) and installs it. Returns the new plugin id. */
export async function installFromDisk(path?: string): Promise<string | null> {
/**
* Asks for a plugin folder (containing plugin.toml), shows what it may do,
* and installs it with the user's consent. Returns the new plugin id.
*/
export async function installFromDisk(path?: string, packageFile = false): Promise<string | null> {
const backend = pluginsBackend();
let source: string | null = path ?? null;
let dir: string | null = path ?? null;
try {
source ??= await backend.pickPackage();
dir ??= packageFile ? await backend.pickPackage() : await backend.pickFolder();
// A package file (`.oiplugin`) is unpacked first; signed ones are checked on the install page.
if (dir?.endsWith(".oiplugin")) dir = await backend.unpackPackage(dir);
} catch (e) {
notifications().notify({
group: "plugins",
level: "error",
title: "Could not open the file picker",
title: packageFile ? "Could not open the package" : "Could not open the folder picker",
body: errorText(e),
});
return null;
}
if (!source) return null;
if (!dir) return null;
usePlugins.setState({ installing: true });
try {
const info = await backend.installDir(source);
const preview = await backend.previewInstall(dir);
const consent = await askConsent({
pluginId: preview.id,
name: preview.name,
version: preview.version,
kind: "install",
capabilities: preview.capabilities,
unsigned: preview.unsigned,
warnings: preview.warnings,
});
if (!consent) return null;
const info = await backend.installDir(dir, consent);
notifications().notify({
group: "plugins",
level: info.status === "failed" ? "warning" : "info",
@@ -168,17 +316,43 @@ export async function selectPluginTheme(pluginId: string | null, themeId: string
}
}
/** Title of a plugin command from its UI contribution. */
export function commandTitle(pluginId: string, commandId: string): string {
const item = usePlugins
.getState()
.ui.find((c) => c.point === "commands" && c.pluginId === pluginId && c.key === commandId);
const title = (item?.decl as { title?: unknown } | undefined)?.title;
return typeof title === "string" ? title : commandId;
}
/** The focused text editor as a command's context: path, language, selections (line / UTF-16 column). */
function focusedEditor(): PluginEditor | null {
const editor = activeEditor();
if (!editor?.ctx.path) return null;
const { doc, selection } = editor.view.state;
const pos = (offset: number) => {
const line = doc.lineAt(offset);
return { line: line.number - 1, col: offset - line.from };
};
return {
path: editor.ctx.path,
language: editor.ctx.language,
selections: selection.ranges.map((r) => ({ start: pos(r.from), end: pos(r.to) })),
};
}
/** Runs a plugin command from an action; failures become notifications. */
export async function runPluginCommand(pluginId: string, commandId: string, arg: string | null = null) {
const plugin = usePlugins.getState().plugins.find((p) => p.id === pluginId);
const title = plugin?.commands.find((c) => c.id === commandId)?.title ?? commandId;
const title = commandTitle(pluginId, commandId);
try {
return await pluginsBackend().runCommand(pluginId, commandId, arg);
return await pluginsBackend().runCommand(pluginId, commandId, arg, focusedEditor());
} catch (e) {
await refreshPlugins();
// A trap disables the plugin and the backend already announced why.
const failed = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.status === "failed";
if (!failed)
const status = usePlugins.getState().plugins.find((p) => p.id === pluginId)?.status;
// A trap disables the plugin and the backend already announced why; a
// consent request opens its own dialog.
const permission = (e as { kind?: string }).kind === "permissionDenied";
if (status !== "failed" && !permission)
notifications().notify({ group: "plugins", level: "error", title: `${title} failed`, body: errorText(e) });
return null;
}
+318
Переглянути файл
@@ -0,0 +1,318 @@
/**
* Client side of the P7 UI points (docs/plugin_plan.md P7): declarations the
* backend validated, projected onto the workbench's own registries.
*
* - `menus`: editor / project view / editor tab context menus, and Tools in the main menu;
* - `keymaps`: default shortcuts below the IDE's (a taken keystroke is dropped);
* - `statusBar`: items shown before the code runs (its `set-status-item` with the same id updates them);
* - `settings`: a page under Settings › Plugins generated from the schema;
* - `views`: tool windows the plugin renders;
* - `customEditors`: files opening in a plugin-rendered editor sharing the text document.
*/
import { useMemo } from "react";
import type { PluginInfo } from "@/bindings";
import { getAction } from "@/platform/actions";
import { evaluateWhen } from "@/platform/context";
import { tx } from "@/platform/i18n";
import { isMacHost, registerExtensionBindings } from "@/platform/keymap";
import { Checkbox, Input, Select, T, type MenuItem } from "@/ui";
import {
registerEditorKind,
registerFileEditorProvider,
registerMenuGroup,
registerMenuSlotItem,
registerSettingsPage,
registerToolWindow,
useSettingsForm,
type ContextMenuId,
type ToolWindowAnchor,
} from "@/workbench/contributions";
import { pluginActionId } from "./points";
import { defineUiPoint } from "./projection";
import { declareStatusItem } from "./runtimeUi";
import {
CUSTOM_EDITOR_KIND,
CustomEditor,
basename,
customEditorDocument,
customEditorInput,
matchesPattern,
type CustomEditorDecl,
} from "./views/customEditor";
import { PluginView } from "./views/PluginView";
import styles from "./PluginsPage.module.css";
// --- menus ------------------------------------------------------------------------
interface MenuDecl {
menu: "editor.context" | "projectView.context" | "editorTab.context" | "tools";
command: string;
when?: string | null;
}
const CONTEXT_MENUS: Record<string, ContextMenuId> = {
"editor.context": "editor",
"projectView.context": "projectView",
"editorTab.context": "editorTab",
};
const contextItems = new Map<string, { pluginId: string; decl: MenuDecl }>();
let menuSeq = 0;
/** Plugin items of a context menu: their command's action with the menu's file as argument. */
export function pluginMenuItems(menu: ContextMenuId, paths: readonly string[]): MenuItem[] {
const out: MenuItem[] = [];
for (const { pluginId, decl } of contextItems.values()) {
if (CONTEXT_MENUS[decl.menu] !== menu || !evaluateWhen(decl.when ?? undefined)) continue;
const action = pluginActionId(pluginId, decl.command);
if (!getAction(action)) continue;
// The editor menu runs with the focused editor; the others name their file.
out.push({ kind: "action", action, arg: menu === "editor" ? undefined : paths[0] });
}
return out;
}
function projectMenu(pluginId: string, decl: MenuDecl, plugin: PluginInfo): () => void {
if (decl.menu === "tools") {
const action = pluginActionId(pluginId, decl.command);
const title = getAction(action)?.title ?? `${plugin.name}: ${decl.command}`;
return registerMenuSlotItem("pluginTools", { action, label: title });
}
const key = `${++menuSeq}`;
contextItems.set(key, { pluginId, decl });
return () => void contextItems.delete(key);
}
// --- keymaps ----------------------------------------------------------------------
interface KeymapDecl {
command: string;
key: string;
mac?: string | null;
}
// --- status bar -------------------------------------------------------------------
interface StatusDecl {
id: string;
text: string;
tooltip?: string | null;
command?: string | null;
align: "left" | "right";
priority: number;
}
// --- settings ---------------------------------------------------------------------
export interface SettingSchema {
key: string;
type: "boolean" | "string" | "number" | "integer";
title: string;
description?: string | null;
default: unknown;
options?: string[] | null;
multiline?: boolean;
}
interface SettingsDecl {
title: string;
properties: SettingSchema[];
}
type PluginSettings = Record<string, Record<string, unknown>>;
/** `settings` with `key` of `pluginId` set (`null`: back to the default). */
export function withPluginSetting(
settings: PluginSettings | undefined,
pluginId: string,
key: string,
value: unknown,
): PluginSettings {
const own = { ...(settings?.[pluginId] ?? {}) };
if (value === null) delete own[key];
else own[key] = value;
const next = { ...(settings ?? {}) };
if (Object.keys(own).length) next[pluginId] = own;
else delete next[pluginId];
return next;
}
function SettingControl({ pluginId, s }: { pluginId: string; s: SettingSchema }) {
const form = useSettingsForm();
const all = form.useValue("plugins") as PluginSettings | undefined;
const value = all?.[pluginId]?.[s.key] ?? s.default;
const set = (v: unknown) => form.set("plugins", withPluginSetting(all, pluginId, s.key, v === s.default ? null : v));
const id = `plugin-setting-${pluginId}-${s.key}`;
const description = s.description ? <div className={styles.muted}>{s.description}</div> : null;
if (s.type === "boolean")
return (
<div className={styles.settingRow}>
<Checkbox checked={value === true} label={s.title} onChange={set} />
{description}
</div>
);
return (
<div className={styles.settingRow}>
<label htmlFor={id}>{s.title}</label>
{s.options ? (
<Select
aria-label={s.title}
value={String(value)}
options={s.options.map((o) => ({ value: o, label: o }))}
onChange={set}
/>
) : (
<Input
id={id}
type={s.type === "string" ? "text" : "number"}
value={String(value ?? "")}
onChange={(e) => {
const raw = e.target.value;
if (s.type === "string") set(raw);
else if (raw.trim() !== "" && Number.isFinite(Number(raw)))
set(s.type === "integer" ? Math.trunc(Number(raw)) : Number(raw));
}}
/>
)}
{description}
</div>
);
}
function settingsPage(pluginId: string, decl: SettingsDecl) {
return function PluginSettingsPage() {
const properties = useMemo(() => decl.properties, []);
return (
<div className={styles.settingsPage}>
<p className={styles.muted}>
<T>Settings of the plugin; projects can override them.</T>
</p>
{properties.map((s) => (
<SettingControl key={s.key} pluginId={pluginId} s={s} />
))}
</div>
);
};
}
/** Settings page id of a plugin (dots nest pages, so they become dashes). */
export const pluginSettingsPageId = (pluginId: string) => `plugins.${pluginId.replace(/\./g, "-")}`;
// --- views and custom editors -----------------------------------------------------
interface ViewDecl {
id: string;
title: string;
icon?: string | null;
location: ToolWindowAnchor;
}
export const pluginToolWindowId = (pluginId: string, view: string) => `plugin.${pluginId}.${view}`;
/** Registers the client side of the P7 points; returns their disposer. */
export function defineViewUiPoints(): () => void {
const disposers = [
defineUiPoint<MenuDecl>("menus", {
title: "Menus",
project: ({ pluginId, decl }, { plugin }) => projectMenu(pluginId, decl, plugin),
}),
defineUiPoint<KeymapDecl>("keymaps", {
title: "Shortcuts",
project: ({ pluginId, decl }) =>
registerExtensionBindings(pluginActionId(pluginId, decl.command), [
isMacHost() ? (decl.mac ?? decl.key) : decl.key,
]),
}),
defineUiPoint<StatusDecl>("statusBar", {
title: "Status Bar Items",
project: ({ pluginId, decl }) =>
declareStatusItem(pluginId, decl.id, {
item: { text: decl.text, tooltip: decl.tooltip ?? null, command: decl.command ?? null },
align: decl.align,
priority: decl.priority,
}),
}),
defineUiPoint<SettingsDecl>("settings", {
title: "Settings",
project: ({ pluginId, decl }) =>
registerSettingsPage({
id: pluginSettingsPageId(pluginId),
title: decl.title,
projectScope: true,
keywords: ["plugin", pluginId, ...decl.properties.flatMap((p) => [p.key, p.title])],
component: settingsPage(pluginId, decl),
}),
}),
defineUiPoint<ViewDecl>("views", {
title: "Tool Windows",
project: ({ pluginId, decl }) =>
registerToolWindow({
id: pluginToolWindowId(pluginId, decl.id),
title: decl.title,
icon: decl.icon ?? "plugin",
anchor: decl.location,
order: 900,
component: () => <PluginView pluginId={pluginId} view={decl.id} label={decl.title} />,
}),
}),
defineUiPoint<CustomEditorDecl>("customEditors", {
title: "Editors",
project: ({ pluginId, decl }) => {
const matches = (path: string) => decl.patterns.some((p) => matchesPattern(p, basename(path)));
const provider =
decl.priority === "default"
? registerFileEditorProvider({
id: `plugin.${pluginId}.${decl.id}`,
order: 40,
resolve: (path) => (matches(path) ? customEditorInput(pluginId, decl, path) : null),
})
: () => {};
openWith.set(`${pluginId}/${decl.id}`, { pluginId, decl, matches });
return () => {
provider();
openWith.delete(`${pluginId}/${decl.id}`);
};
},
}),
];
return () => disposers.reverse().forEach((d) => d());
}
/** Custom editors by `plugin/id`, for Open With. */
const openWith = new Map<string, { pluginId: string; decl: CustomEditorDecl; matches: (path: string) => boolean }>();
/** One-time registrations behind the points: the editor kind and the context menu group. */
export function startViewUi(openInput: (input: ReturnType<typeof customEditorInput>) => void): () => void {
const disposers = [
registerEditorKind({
kind: CUSTOM_EDITOR_KIND,
component: CustomEditor,
title: (input) => basename(input.path ?? input.key),
icon: () => "db-table",
restorable: true,
document: customEditorDocument,
}),
registerMenuGroup({
id: "plugins",
menus: ["editor", "projectView", "editorTab"],
order: 90,
items: (menu, ctx) => {
const items = pluginMenuItems(menu, ctx.paths);
const path = ctx.paths[0];
if (menu !== "editor" && path && !ctx.dirs?.[0]) {
const editors = [...openWith.values()].filter((e) => e.matches(path));
if (editors.length)
items.push({
label: tx("Open With"),
submenu: editors.map((e) => ({
label: e.decl.title,
run: () => openInput(customEditorInput(e.pluginId, e.decl, path)),
})),
});
}
return items;
},
}),
];
return () => disposers.reverse().forEach((d) => d());
}
+175
Переглянути файл
@@ -0,0 +1,175 @@
.view {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.toolbar {
display: flex;
align-items: center;
gap: 2px;
padding: 2px 6px;
border-bottom: 1px solid var(--border);
}
.textButton {
border: none;
background: none;
color: inherit;
font: inherit;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
}
.textButton:hover:not(:disabled) {
background: var(--bg-toolbar-hover);
}
.textButton:disabled {
opacity: 0.5;
cursor: default;
}
.body {
flex: 1;
min-height: 0;
overflow: auto;
position: relative;
}
.status {
padding: 2px 8px;
border-top: 1px solid var(--border);
color: var(--fg-muted);
font-size: 12px;
}
.error {
padding: 4px 8px;
color: var(--danger);
font-size: 12px;
}
.empty {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.list {
list-style: none;
margin: 0;
padding: 0;
}
.listItem {
display: flex;
align-items: center;
gap: 6px;
height: 24px;
padding: 0 8px;
cursor: default;
}
.listItem:hover,
.listItem:focus-visible {
background: var(--bg-toolbar-hover);
outline: none;
}
.detail {
color: var(--fg-muted);
}
.form {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 12px;
max-width: 520px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.fieldLabel {
color: var(--fg-muted);
}
.textarea {
min-height: 80px;
font: inherit;
font-family: var(--font-mono, monospace);
resize: vertical;
}
.tableScroller {
height: 100%;
overflow: auto;
position: relative;
font-variant-numeric: tabular-nums;
}
.tableCanvas {
position: relative;
min-width: 100%;
width: max-content;
}
.tableRow {
position: absolute;
left: 0;
display: flex;
height: 24px;
min-width: 100%;
border-bottom: 1px solid var(--border);
}
.tableRow[aria-selected="true"] {
background: var(--bg-tree-selected);
}
.tableHeader {
position: sticky;
top: 0;
z-index: 1;
background: var(--bg-island);
font-weight: 600;
}
.rowNumber {
flex: 0 0 48px;
padding: 0 6px;
text-align: right;
color: var(--fg-muted);
line-height: 24px;
border-right: 1px solid var(--border);
}
.cell {
flex: 0 0 160px;
padding: 0 6px;
line-height: 24px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
border-right: 1px solid var(--border);
}
.tableEmpty {
padding: 32px 12px;
color: var(--fg-muted);
}
.frame {
border: none;
width: 100%;
height: 100%;
background: transparent;
}
+480
Переглянути файл
@@ -0,0 +1,480 @@
/**
* Renders a plugin's declarative view (docs/plugin_plan.md 4.2, L2): the
* plugin returns data (`View`), this draws it with the workbench's own
* components and sends the user's actions back (`ViewEvent`). Used by plugin
* tool windows and custom editors. A refresh comes from the plugin
* (`viewChanged`), from `revision` (a custom editor's document changed), or
* from the table scrolling to rows it hasn't got.
*/
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import type { View, ViewEvent, ViewField, ViewTable, ViewTreeItem, Viewport } from "@/bindings";
import { tx } from "@/platform/i18n";
import {
Checkbox,
EmptyState,
Icon,
IconButton,
Input,
Select,
Spinner,
Tree,
showContextMenu,
type MenuItem,
} from "@/ui";
import { pluginsBackend } from "../backend";
import { FRAME_TAG, frameDocument } from "./frame";
import { onViewMessage, useViewRevision } from "./viewStore";
import styles from "./PluginView.module.css";
export interface PluginViewProps {
pluginId: string;
view: string;
/** The document of a custom editor. */
doc?: number | null;
/** Bumped by the owner when the view must render again (document edits). */
revision?: number;
/** Called after an event was handled (custom editors re-render on the document instead). */
label?: string;
}
/** Table rows asked for beyond the visible ones (each side). */
const OVERSCAN = 50;
const ROW_HEIGHT = 24;
export function PluginView({ pluginId, view, doc = null, revision = 0, label }: PluginViewProps) {
const [content, setContent] = useState<View | null>(null);
const [error, setError] = useState<string | null>(null);
const [viewport, setViewport] = useState<Viewport | null>(null);
const pluginRevision = useViewRevision(pluginId, view);
const seq = useRef(0);
useEffect(() => {
const mine = ++seq.current;
pluginsBackend()
.renderView(pluginId, view, viewport, doc)
.then(
(v) => {
if (mine !== seq.current) return;
setContent(v);
setError(null);
},
(e: unknown) => {
if (mine !== seq.current) return;
setError(e instanceof Error ? e.message : String(e));
},
);
}, [pluginId, view, doc, viewport, revision, pluginRevision]);
const send = useCallback(
(event: ViewEvent) => {
pluginsBackend()
.viewEvent(pluginId, view, event, doc)
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)));
},
[pluginId, view, doc],
);
if (!content)
return error ? (
<EmptyState className={styles.empty} title={tx("The plugin could not show this view")}>
{error}
</EmptyState>
) : (
<div className={styles.empty}>
<Spinner label={tx("Loading")} />
</div>
);
return (
<div className={styles.view} aria-label={label}>
{content.toolbar.length > 0 && (
<div className={styles.toolbar} role="toolbar">
{content.toolbar.map((b) =>
b.icon ? (
<IconButton
key={b.id}
icon={b.icon}
title={b.label}
disabled={!b.enabled}
onClick={() => send({ kind: "button", id: b.id })}
/>
) : (
<button
key={b.id}
type="button"
className={styles.textButton}
disabled={!b.enabled}
onClick={() => send({ kind: "button", id: b.id })}
>
{b.label}
</button>
),
)}
</div>
)}
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.body}>
<Body content={content} send={send} pluginId={pluginId} view={view} onViewport={setViewport} />
</div>
{content.status && <div className={styles.status}>{content.status}</div>}
</div>
);
}
interface BodyProps {
content: View;
send: (e: ViewEvent) => void;
pluginId: string;
view: string;
onViewport: (v: Viewport) => void;
}
function Body({ content, send, pluginId, view, onViewport }: BodyProps) {
const body = content.body;
switch (body.kind) {
case "message":
return <EmptyState className={styles.empty} title={body.value} />;
case "tree":
return <TreeBody items={body.value} send={send} />;
case "list":
return (
<ul className={styles.list} role="listbox">
{body.value.map((i) => (
<li
key={i.key}
role="option"
aria-selected={false}
tabIndex={0}
className={styles.listItem}
onClick={() => send({ kind: "select", key: i.key })}
onDoubleClick={() => send({ kind: "activate", key: i.key })}
onKeyDown={(e) => e.key === "Enter" && send({ kind: "activate", key: i.key })}
>
{i.icon && <Icon name={i.icon} size={14} />}
<span>{i.label}</span>
{i.detail && <span className={styles.detail}>{i.detail}</span>}
</li>
))}
</ul>
);
case "form":
return (
<div className={styles.form}>
{body.value.map((f) => (
<FormField key={f.key} field={f} send={send} />
))}
</div>
);
case "table":
return <TableBody table={body.value} send={send} onViewport={onViewport} />;
case "html":
return <HtmlBody html={body.value} send={send} pluginId={pluginId} view={view} />;
}
}
// --- tree -------------------------------------------------------------------------
interface Node {
item: ViewTreeItem;
children: Node[];
}
/** The flat item list as nodes (items naming an unknown parent are roots). */
export function treeNodes(items: readonly ViewTreeItem[]): Node[] {
const byKey = new Map<string, Node>();
const roots: Node[] = [];
for (const item of items) {
const node: Node = { item, children: [] };
if (!byKey.has(item.key)) byKey.set(item.key, node);
const parent = item.parent != null ? byKey.get(item.parent) : undefined;
(parent ? parent.children : roots).push(node);
}
return roots;
}
function TreeBody({ items, send }: { items: ViewTreeItem[]; send: (e: ViewEvent) => void }) {
const roots = useMemo(() => treeNodes(items), [items]);
const expanded = useMemo(() => new Set(items.filter((i) => i.expanded).map((i) => i.key)), [items]);
return (
<Tree<Node>
roots={roots}
getKey={(n) => n.item.key}
getLabel={(n) => n.item.label}
getChildren={(n) => (n.children.length ? n.children : n.item.expandable ? undefined : [])}
isExpandable={(n) => n.item.expandable || n.children.length > 0}
getIcon={(n) => n.item.icon ?? undefined}
getDetail={(n) => n.item.detail ?? undefined}
expanded={expanded}
onExpandedChange={(_, node, isExpanded) =>
send(isExpanded ? { kind: "expand", key: node.item.key } : { kind: "collapse", key: node.item.key })
}
onSelectionChange={(_, lead) => lead && send({ kind: "select", key: lead.item.key })}
onActivate={(n) => send({ kind: "activate", key: n.item.key })}
multiSelect={false}
/>
);
}
// --- form -------------------------------------------------------------------------
function FormField({ field, send }: { field: ViewField; send: (e: ViewEvent) => void }) {
const commit = (value: string) => send({ kind: "field", key: field.key, value });
const v = field.value;
const id = `plugin-field-${field.key}`;
return (
<div className={styles.field}>
{v.kind !== "checkbox" && (
<label className={styles.fieldLabel} htmlFor={id}>
{field.label}
</label>
)}
{v.kind === "text" && <TextField id={id} initial={v.value} onCommit={commit} />}
{v.kind === "multiline" && <TextField id={id} initial={v.value} onCommit={commit} multiline />}
{v.kind === "checkbox" && (
<Checkbox checked={v.value} label={field.label} onChange={(on) => commit(on ? "true" : "false")} />
)}
{v.kind === "choice" && (
<Select
aria-label={field.label}
value={v.value.selected}
options={v.value.options.map((o) => ({ value: o, label: o }))}
onChange={commit}
/>
)}
</div>
);
}
function TextField({
id,
initial,
onCommit,
multiline,
}: {
id: string;
initial: string;
onCommit: (v: string) => void;
multiline?: boolean;
}) {
const [value, setValue] = useState(initial);
useEffect(() => setValue(initial), [initial]);
const commit = () => value !== initial && onCommit(value);
return multiline ? (
<textarea
id={id}
className={styles.textarea}
value={value}
spellCheck={false}
onChange={(e) => setValue(e.target.value)}
onBlur={commit}
/>
) : (
<Input
id={id}
value={value}
onChange={(e) => setValue(e.target.value)}
onBlur={commit}
onKeyDown={(e) => e.key === "Enter" && commit()}
/>
);
}
// --- table ------------------------------------------------------------------------
/** The rows to ask for when `first..first + visible` is shown. */
export function wantedViewport(first: number, visible: number, total: number): Viewport {
const start = Math.max(0, first - OVERSCAN);
const end = Math.min(total, first + visible + OVERSCAN);
return { firstRow: start, rows: Math.max(0, end - start) };
}
function TableBody({
table,
send,
onViewport,
}: {
table: ViewTable;
send: (e: ViewEvent) => void;
onViewport: (v: Viewport) => void;
}) {
const scroller = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [height, setHeight] = useState(400);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [editing, setEditing] = useState<{ row: number; column: number } | null>(null);
useEffect(() => {
const el = scroller.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => setHeight(el.clientHeight || 400));
ro.observe(el);
return () => ro.disconnect();
}, []);
const first = Math.floor(scrollTop / ROW_HEIGHT);
const visible = Math.ceil(height / ROW_HEIGHT) + 1;
const loadedEnd = table.firstRow + table.rows.length;
const shownEnd = Math.min(table.totalRows, first + visible);
// Ask for rows the scroll position shows but the last render didn't include.
useEffect(() => {
if (first < table.firstRow || shownEnd > loadedEnd) onViewport(wantedViewport(first, visible, table.totalRows));
}, [first, visible, shownEnd, loadedEnd, table.firstRow, table.totalRows, onViewport]);
const rowMenu = (row: number, e: React.MouseEvent) => {
e.preventDefault();
const rows = selected.has(row) ? [...selected].sort((a, b) => a - b) : [row];
if (!selected.has(row)) setSelected(new Set([row]));
const items: MenuItem[] = [
{ label: tx("Insert Row Above"), icon: "plus", run: () => send({ kind: "insertRow", before: row }) },
{ label: tx("Insert Row Below"), run: () => send({ kind: "insertRow", before: row + 1 }) },
{ kind: "separator" },
{
label: rows.length > 1 ? tx`Delete ${rows.length} Rows` : tx("Delete Row"),
icon: "trash",
danger: true,
run: () => send({ kind: "deleteRows", rows }),
},
];
if (table.editable) showContextMenu(e, items);
};
const rows: React.ReactNode[] = [];
for (let r = Math.max(first, table.firstRow); r < Math.min(shownEnd, loadedEnd); r++) {
const cells = table.rows[r - table.firstRow] ?? [];
rows.push(
<div
key={r}
role="row"
aria-rowindex={r + 2}
aria-selected={selected.has(r)}
className={styles.tableRow}
style={{ top: (r + 1) * ROW_HEIGHT }}
onClick={(e) =>
setSelected((s) => {
if (!(e.metaKey || e.ctrlKey)) return new Set([r]);
const next = new Set(s);
if (next.has(r)) next.delete(r);
else next.add(r);
return next;
})
}
onContextMenu={(e) => rowMenu(r, e)}
>
<div className={styles.rowNumber} role="rowheader">
{r + 1}
</div>
{table.columns.map((_, c) => (
<div
key={c}
role="gridcell"
className={styles.cell}
title={cells[c]}
onDoubleClick={() => table.editable && setEditing({ row: r, column: c })}
>
{editing?.row === r && editing.column === c ? (
<CellEditor
initial={cells[c] ?? ""}
onDone={(value) => {
setEditing(null);
if (value !== null && value !== (cells[c] ?? "")) send({ kind: "cell", row: r, column: c, value });
}}
/>
) : (
cells[c]
)}
</div>
))}
</div>,
);
}
return (
<div
ref={scroller}
className={styles.tableScroller}
role="grid"
aria-rowcount={table.totalRows + 1}
aria-colcount={table.columns.length}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
<div className={styles.tableCanvas} style={{ height: (table.totalRows + 1) * ROW_HEIGHT }}>
<div className={`${styles.tableRow} ${styles.tableHeader}`} role="row" aria-rowindex={1}>
<div className={styles.rowNumber} />
{table.columns.map((c, i) => (
<div key={i} role="columnheader" className={styles.cell} title={c}>
{c}
</div>
))}
</div>
{rows}
</div>
{table.totalRows === 0 && <div className={styles.tableEmpty}>{tx("No rows")}</div>}
</div>
);
}
function CellEditor({ initial, onDone }: { initial: string; onDone: (value: string | null) => void }) {
const [value, setValue] = useState(initial);
const done = useRef(false);
const finish = (v: string | null) => {
if (done.current) return;
done.current = true;
onDone(v);
};
const onKeyDown = (e: ReactKeyboardEvent) => {
if (e.key === "Enter") finish(value);
else if (e.key === "Escape") finish(null);
};
return (
<Input
autoFocus
variant="bare"
aria-label={tx("Cell value")}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
onBlur={() => finish(value)}
/>
);
}
// --- html -------------------------------------------------------------------------
function HtmlBody({
html,
send,
pluginId,
view,
}: {
html: string;
send: (e: ViewEvent) => void;
pluginId: string;
view: string;
}) {
const frame = useRef<HTMLIFrameElement>(null);
const dark = typeof document !== "undefined" && document.documentElement.dataset.theme === "dark";
const srcDoc = useMemo(() => frameDocument(html, dark), [html, dark]);
useEffect(() => {
const onMessage = (e: MessageEvent) => {
if (e.source !== frame.current?.contentWindow) return;
const data = e.data as { tag?: unknown; text?: unknown } | null;
if (data?.tag === FRAME_TAG && typeof data.text === "string") send({ kind: "message", text: data.text });
};
window.addEventListener("message", onMessage);
const off = onViewMessage(pluginId, view, (text) =>
frame.current?.contentWindow?.postMessage({ tag: FRAME_TAG, text }, "*"),
);
return () => {
window.removeEventListener("message", onMessage);
off();
};
}, [pluginId, view, send]);
// No `allow-same-origin`: an opaque origin without access to the IDE.
return <iframe ref={frame} className={styles.frame} title={view} sandbox="allow-scripts" srcDoc={srcDoc} />;
}
+234
Переглянути файл
@@ -0,0 +1,234 @@
/**
* Plugin custom editors (`[[contributes.customEditors]]`): a file matching
* the declared patterns opens in a tab whose content the plugin renders from
* the file's document. The tab joins the document shared by every editor of
* the file (`attachDocument`), so:
*
* - the plugin edits the document (versioned `apply-edits`), and a text tab
* of the same file shows the change at once — and the other way round;
* - Undo / Redo work on the document's one history: the text tab's when one
* is open, else this tab's own;
* - unsaved changes, Save, and the close prompts are the document's.
*/
import { history } from "@codemirror/commands";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { useEffect, useState } from "react";
import { attachDocument, type DocumentAttachment } from "@/editor/api";
import { tx } from "@/platform/i18n";
import { EmptyState, Spinner } from "@/ui";
import {
editorService,
notifyEditorDocumentChanged,
type EditorDocumentLifecycle,
type EditorInput,
} from "@/workbench/contributions";
import { PluginView } from "./PluginView";
import styles from "./PluginView.module.css";
export const CUSTOM_EDITOR_KIND = "pluginEditor";
export interface CustomEditorData {
pluginId: string;
editorId: string;
title: string;
}
export interface CustomEditorDecl {
id: string;
title: string;
patterns: string[];
priority: "default" | "option";
}
export function customEditorInput(pluginId: string, decl: CustomEditorDecl, path: string): EditorInput {
const data: CustomEditorData = { pluginId, editorId: decl.id, title: decl.title };
return { kind: CUSTOM_EDITOR_KIND, key: `${CUSTOM_EDITOR_KIND}:${pluginId}/${decl.id}:${path}`, path, data };
}
/** File name glob (`*`, `?`, `[abc]`, `{a,b}`), case-insensitive. */
export function matchesPattern(pattern: string, name: string): boolean {
let re = "";
for (let i = 0; i < pattern.length; i++) {
const c = pattern[i]!;
if (c === "*") re += "[^/]*";
else if (c === "?") re += "[^/]";
else if (c === "{") {
const end = pattern.indexOf("}", i);
if (end < 0) re += "\\{";
else {
re += `(?:${pattern
.slice(i + 1, end)
.split(",")
.map((p) => p.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*"))
.join("|")})`;
i = end;
}
} else if (c === "[") {
const end = pattern.indexOf("]", i);
if (end < 0) re += "\\[";
else {
re += pattern.slice(i, end + 1);
i = end;
}
} else re += c.replace(/[.+^$()|\\]/g, "\\$&");
}
return new RegExp(`^${re}$`, "i").test(name);
}
export function basename(path: string): string {
return path.split(/[\\/]/).pop() ?? path;
}
// --- sessions: one per document shown by a custom editor (splits share it) -----------
interface Session {
attachment: DocumentAttachment;
/** Holds this tab's undo history and receives the document's changes. */
view: EditorView;
revision: number;
listeners: Set<(revision: number) => void>;
cancelTimer: () => void;
}
const sessions = new Map<string, Promise<Session>>();
/** Edits settle in the backend before the plugin renders the table again. */
const RENDER_DELAY_MS = 120;
function session(input: EditorInput): Promise<Session> {
let s = sessions.get(input.key);
if (!s) {
s = attachDocument(input.path ?? "").then((attachment) => {
const listeners = new Set<(revision: number) => void>();
let timer: ReturnType<typeof setTimeout> | null = null;
const changed = () => {
notifyEditorDocumentChanged();
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
void attachment.synced().then(() => {
state.revision++;
for (const l of listeners) l(state.revision);
});
}, RENDER_DELAY_MS);
};
const view = new EditorView({
state: EditorState.create({
doc: attachment.text(),
extensions: [attachment.extension, history(), EditorView.updateListener.of((u) => u.docChanged && changed())],
}),
});
const state: Session = {
attachment,
view,
revision: 0,
listeners,
cancelTimer: () => timer && clearTimeout(timer),
};
return state;
});
sessions.set(input.key, s);
s.catch(() => sessions.delete(input.key));
}
return s;
}
async function withSession<T>(input: EditorInput, f: (s: Session) => T | Promise<T>): Promise<T | undefined> {
const s = sessions.get(input.key);
return s ? f(await s) : undefined;
}
const settled = new Map<string, Session>();
/** Lifecycle of the tabs' document (dirty mark, Save, close prompts, Undo). */
export const customEditorDocument: EditorDocumentLifecycle = {
isDirty: (input) => settled.get(input.key)?.attachment.dirty() ?? false,
flush: async (input) => {
await withSession(input, (s) => s.attachment.synced());
},
save: async (input) => {
await withSession(input, (s) => s.attachment.save());
},
prepareClose: async (input) => {
await withSession(input, (s) => s.attachment.synced());
},
cancelClose: () => {},
discard: async () => {
// The document's unsaved changes go when it closes (`close` releases it).
},
close: async (input) => {
const s = sessions.get(input.key);
sessions.delete(input.key);
if (!s) return;
const state = await s;
settled.delete(input.key);
state.cancelTimer();
state.attachment.release();
state.view.destroy();
},
undo: async (input) => {
await withSession(input, (s) => void s.attachment.undo());
},
redo: async (input) => {
await withSession(input, (s) => void s.attachment.redo());
},
};
export function CustomEditor({ input }: { input: EditorInput }) {
const data = input.data as CustomEditorData;
const [state, setState] = useState<{ doc: number; revision: number } | { error: string } | null>(null);
useEffect(() => {
let live = true;
let off = () => {};
session(input).then(
(s) => {
if (!live) return;
settled.set(input.key, s);
const listener = (revision: number) => setState({ doc: s.attachment.doc, revision });
s.listeners.add(listener);
off = () => s.listeners.delete(listener);
setState({ doc: s.attachment.doc, revision: s.revision });
},
(e: unknown) => live && setState({ error: e instanceof Error ? e.message : String(e) }),
);
return () => {
live = false;
off();
};
}, [input]);
if (!state)
return (
<div className={styles.empty}>
<Spinner label={tx("Loading")} />
</div>
);
if ("error" in state)
return (
<EmptyState className={styles.empty} title={tx`Cannot open ${basename(input.path ?? "")}`}>
{state.error}
</EmptyState>
);
return (
<div className={styles.view}>
<PluginView
pluginId={data.pluginId}
view={data.editorId}
doc={state.doc}
revision={state.revision}
label={tx`${data.title}: ${basename(input.path ?? "")}`}
/>
<div className={styles.status}>
<button
type="button"
className={styles.textButton}
onClick={() => input.path && void editorService().openFile(input.path, { asText: true })}
>
{tx("Open as Text")}
</button>
</div>
</div>
);
}
+40
Переглянути файл
@@ -0,0 +1,40 @@
/**
* The document of a view's `html` body. It runs in an `<iframe sandbox=
* "allow-scripts">` (an opaque origin: no cookies, storage, IDE API or parent
* access) under a CSP that allows only its own inline scripts and styles and
* `data:` images — no network. `openidea.postMessage` / `openidea.onMessage`
* are its only way to the plugin.
*/
export const FRAME_CSP =
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; media-src data:";
/** Marks frame ↔ workbench messages. */
export const FRAME_TAG = "openidea-view";
const BRIDGE = `
(() => {
const listeners = [];
window.addEventListener("message", (e) => {
if (e.source !== window.parent || !e.data || e.data.tag !== "${FRAME_TAG}") return;
for (const l of listeners) { try { l(String(e.data.text)); } catch (err) { console.error(err); } }
});
window.openidea = Object.freeze({
postMessage: (text) => window.parent.postMessage({ tag: "${FRAME_TAG}", text: String(text) }, "*"),
onMessage: (fn) => { if (typeof fn === "function") listeners.push(fn); },
});
})();`;
/** `html` wrapped with the CSP, the message bridge and base styles. */
export function frameDocument(html: string, dark: boolean): string {
const scheme = dark ? "dark" : "light";
return [
'<!doctype html><html><head><meta charset="utf-8">',
`<meta http-equiv="Content-Security-Policy" content="${FRAME_CSP}">`,
`<style>:root{color-scheme:${scheme}}body{margin:8px;font:13px system-ui,sans-serif}</style>`,
`<script>${BRIDGE}</script>`,
"</head><body>",
html,
"</body></html>",
].join("");
}
+39
Переглянути файл
@@ -0,0 +1,39 @@
/**
* Client state of plugin views: a revision per (plugin, view) that bumps when
* the plugin asks for a refresh (`viewChanged`), and the messages its code
* sends to the script of an `html` body (`viewMessage`).
*/
import { create } from "zustand";
export const viewKey = (pluginId: string, view: string) => `${pluginId}/${view}`;
export const useViewRevisions = create<{ revisions: Record<string, number> }>(() => ({ revisions: {} }));
/** The plugin's view content changed: rendered views fetch it again. */
export function bumpView(pluginId: string, view: string) {
const key = viewKey(pluginId, view);
useViewRevisions.setState((s) => ({ revisions: { ...s.revisions, [key]: (s.revisions[key] ?? 0) + 1 } }));
}
export function useViewRevision(pluginId: string, view: string): number {
return useViewRevisions((s) => s.revisions[viewKey(pluginId, view)] ?? 0);
}
type MessageListener = (message: string) => void;
const messageListeners = new Map<string, Set<MessageListener>>();
/** Delivers a plugin's message to the frames of its view. */
export function deliverViewMessage(pluginId: string, view: string, message: string) {
for (const l of messageListeners.get(viewKey(pluginId, view)) ?? []) l(message);
}
export function onViewMessage(pluginId: string, view: string, listener: MessageListener): () => void {
const key = viewKey(pluginId, view);
const set = messageListeners.get(key) ?? new Set();
set.add(listener);
messageListeners.set(key, set);
return () => {
set.delete(listener);
if (!set.size) messageListeners.delete(key);
};
}