дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
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.
Цей коміт міститься в:
1 джерело
63801ba249
коміт
fd1f465aeb
326 files changed
+35587
-2777
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [] });
|
||||
};
|
||||
}
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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))];
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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>[];
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача