Initial open-source release

Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
l0ng-ai committed 2026-10-22 18:55:10 +08:00
коміт 9a2025b76e
2034 files changed
+243381

No files matched your search

+315
Переглянути файл
@@ -0,0 +1,315 @@
.page {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
height: 100%;
min-height: 420px;
}
.topBar {
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid var(--border);
}
.tabs {
flex: none;
border-bottom: 0;
}
.tabCount {
margin-left: 6px;
color: var(--fg-muted);
font-size: var(--fs-small);
font-variant-numeric: tabular-nums;
}
.side {
display: flex;
flex: none;
flex-direction: column;
width: 280px;
min-height: 0;
border-right: 1px solid var(--border);
}
.search {
margin: 8px;
}
.spacer {
flex: 1;
}
.error {
color: var(--danger);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.body {
display: flex;
flex: 1;
min-height: 0;
border-radius: var(--radius-small);
box-shadow: inset 0 0 0 1px var(--border);
overflow: hidden;
}
/* list */
.list {
flex: 1;
min-height: 0;
margin: 0;
padding: 0 0 4px;
list-style: none;
overflow: auto;
}
.groupHeader {
display: flex;
align-items: center;
gap: 6px;
height: var(--h-row);
padding: 0 12px;
color: var(--fg-muted);
font-size: var(--fs-small);
}
.groupCount {
font-variant-numeric: tabular-nums;
}
.notice {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 0 8px 4px;
padding: 6px 8px;
border-radius: var(--radius-small);
background: var(--bg-control);
color: var(--fg-secondary);
font-size: var(--fs-small);
}
.notice > :first-child {
flex: none;
margin-top: 1px;
}
.icon {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-small);
background: var(--bg-control);
color: var(--fg-secondary);
}
.iconLarge {
width: 56px;
height: 56px;
border-radius: var(--radius-popup);
}
.rowText {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.rowVendor {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowDisabled .rowName {
color: var(--fg-muted);
}
.rowCheck {
flex: none;
}
.installedTag {
flex: none;
color: var(--fg-muted);
font-size: var(--fs-small);
}
.errorText {
color: var(--danger);
}
.row {
display: flex;
align-items: center;
gap: 4px;
padding-right: 8px;
}
.row:hover {
background: var(--bg-hover);
}
.rowSelected,
.rowSelected:hover {
background: var(--bg-selected);
}
.rowMain {
display: flex;
flex: 1;
align-items: center;
gap: 10px;
min-width: 0;
min-height: 48px;
padding: 6px 8px 6px 12px;
border: 0;
background: transparent;
color: var(--fg);
font: inherit;
text-align: left;
}
.rowMain:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.rowName {
overflow: hidden;
color: var(--fg-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.rowMeta {
display: flex;
align-items: center;
gap: 6px;
color: var(--fg-muted);
font-size: var(--fs-small);
font-variant-numeric: tabular-nums;
}
.devTag {
padding: 0 4px;
border-radius: var(--radius-small);
background: var(--bg-control);
color: var(--warning);
font-size: var(--fs-tiny);
line-height: 14px;
}
.listEmpty {
padding: 8px 12px;
color: var(--fg-muted);
}
/* details */
.details {
flex: 1;
min-width: 0;
padding: 12px 16px 16px;
overflow: auto;
}
.detailsEmpty {
flex: 1;
}
.header {
display: flex;
align-items: flex-start;
gap: 14px;
}
.headerText {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.detailTabs {
margin-top: 12px;
}
.notes {
margin: 10px 0 0;
color: var(--fg);
font: inherit;
white-space: pre-wrap;
}
.titleLine {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.title {
margin: 0;
color: var(--fg-strong);
font-size: var(--fs-input-large);
font-weight: 600;
}
.version {
color: var(--fg-muted);
font-variant-numeric: tabular-nums;
}
.authors {
color: var(--fg-secondary);
}
.headerActions {
display: flex;
flex: none;
align-items: center;
gap: 6px;
}
.description {
margin: 10px 0 0;
max-width: 640px;
color: var(--fg);
}
.details .error {
margin-top: 10px;
padding: 6px 8px;
border-radius: var(--radius-small);
background: var(--bg-control);
font-family: var(--font-mono);
font-size: var(--fs-small);
}
.facts {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 2px 16px;
margin: 12px 0 0;
}
.facts dt {
color: var(--fg-muted);
}
.facts dd {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mono {
color: var(--fg-secondary);
font-family: var(--font-mono);
font-size: var(--fs-small);
}
.muted {
color: var(--fg-muted);
}
.section {
margin-top: 16px;
}
.sectionTitle {
height: var(--h-row);
margin: 0;
color: var(--fg-emphasis);
font-size: var(--fs-ui);
font-weight: 500;
line-height: var(--h-row);
}
.items {
margin: 0;
padding: 0;
list-style: none;
border-radius: var(--radius-small);
box-shadow: inset 0 0 0 1px var(--border);
}
.item {
display: flex;
align-items: center;
gap: 10px;
min-height: 28px;
padding: 0 4px 0 10px;
}
.item + .item {
border-top: 1px solid var(--border);
}
.itemMain {
flex: 1;
min-width: 0;
overflow: hidden;
color: var(--fg);
text-overflow: ellipsis;
white-space: nowrap;
}
+650
Переглянути файл
@@ -0,0 +1,650 @@
/**
* Settings › Plugins, laid out like IDEA: Available / Installed tabs with a
* gear menu, a searchable list on the left (enable checkboxes) and the
* selected plugin's details on the right (Overview / What's New).
*
* There is no network repository: Available lists the plugin packages
* available locally (`plugin_catalog`) and installs them from disk.
*/
import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react";
import clsx from "clsx";
import type { CatalogPlugin, Permission, PluginInfo, PluginStatus } from "@/bindings";
import {
Badge,
Button,
Checkbox,
EmptyState,
Icon,
IconButton,
Kbd,
SearchField,
Spinner,
Tabs,
showContextMenu,
type MenuItem,
} from "@/ui";
import { pluginActionId } from "./bridge";
import {
compareVersions,
installFromDisk,
refreshCatalog,
refreshPlugins,
reloadPlugin,
runPluginCommand,
selectPluginTheme,
setAllInstalledEnabled,
setPluginEnabled,
uninstallPlugin,
usePlugins,
} from "./store";
import styles from "./PluginsPage.module.css";
const STATUS: Record<PluginStatus, { label: string; tone: "success" | "accent" | "neutral" | "danger" }> = {
active: { label: "Running", tone: "success" },
static: { label: "Enabled", tone: "accent" },
disabled: { label: "Disabled", tone: "neutral" },
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",
};
export const NO_REPOSITORY = "There is no online plugin repository";
type Tab = "marketplace" | "installed";
const matches = (texts: readonly (string | null | undefined)[], q: string) =>
texts.some((s) => !!s && s.toLowerCase().includes(q));
export function filterPlugins(plugins: readonly PluginInfo[], query: string): PluginInfo[] {
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),
);
}
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),
);
}
/** What the Available tab offers for a catalog package given what is installed. */
export function catalogState(
entry: CatalogPlugin,
installed: readonly PluginInfo[],
): "install" | "update" | "installed" | "dev" {
const have = installed.find((p) => p.id === entry.id);
if (!have) return "install";
if (have.source === "dev") return "dev";
return compareVersions(entry.version, have.version) > 0 ? "update" : "installed";
}
const vendorOf = (authors: readonly string[]) => authors.join(", ");
function PluginIcon({ large }: { large?: boolean }) {
return (
<span className={clsx(styles.icon, large && styles.iconLarge)} aria-hidden>
<Icon name="plugin" size={large ? 32 : 20} />
</span>
);
}
function ListHeader({ title, count }: { title: string; count: number }) {
return (
<li className={styles.groupHeader} aria-hidden>
<span>{title}</span>
<span className={styles.groupCount}>{count}</span>
</li>
);
}
function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; selected: boolean; onSelect: () => void }) {
const busy = usePlugins((s) => !!s.busy[plugin.id]);
return (
<li className={clsx(styles.row, selected && styles.rowSelected, !plugin.enabled && styles.rowDisabled)}>
<button
type="button"
className={styles.rowMain}
aria-current={selected || undefined}
onClick={onSelect}
title={plugin.description || plugin.name}
>
<PluginIcon />
<span className={styles.rowText}>
<span className={styles.rowName}>{plugin.name}</span>
<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}>dev</span>}
{plugin.status === "failed" && (
<Badge dot tone="danger">
Failed
</Badge>
)}
</span>
</span>
</button>
<Checkbox
className={styles.rowCheck}
checked={plugin.enabled}
disabled={busy}
onChange={(on) => void setPluginEnabled(plugin.id, on)}
aria-label={`${plugin.enabled ? "Disable" : "Enable"} ${plugin.name}`}
/>
</li>
);
}
function CatalogButton({ entry, compact }: { entry: CatalogPlugin; compact?: boolean }) {
const plugins = usePlugins((s) => s.plugins);
const installing = usePlugins((s) => s.installing);
const state = catalogState(entry, plugins);
if (state === "installed" || state === "dev") {
return <span className={styles.installedTag}>{state === "dev" ? "Dev plugin loaded" : "Installed"}</span>;
}
const label = state === "update" ? "Update" : "Install";
return (
<Button
size="small"
variant={compact ? "secondary" : "primary"}
disabled={installing || !!entry.problem}
title={entry.problem ?? undefined}
aria-label={`${label} ${entry.name}`}
onClick={(e) => {
e.stopPropagation();
void installFromDisk(entry.path);
}}
>
{label}
</Button>
);
}
function CatalogRow({ entry, selected, onSelect }: { entry: CatalogPlugin; selected: boolean; onSelect: () => void }) {
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}>{vendorOf(entry.authors) || entry.id}</span>
<span>{entry.version}</span>
</span>
</span>
</button>
<CatalogButton entry={entry} compact />
</li>
);
}
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section className={styles.section} aria-label={title}>
<h3 className={styles.sectionTitle}>{title}</h3>
{children}
</section>
);
}
function DetailTabs({ notes, overview }: { notes: string | null; overview: ReactNode }) {
const [tab, setTab] = useState<"overview" | "whatsNew">("overview");
return (
<>
<Tabs
variant="compact"
className={styles.detailTabs}
aria-label="Plugin details"
activeId={tab}
onSelect={(id) => setTab(id as typeof tab)}
items={[
{ id: "overview", label: "Overview" },
{ id: "whatsNew", label: "What's New" },
]}
/>
{tab === "overview" ? (
overview
) : notes ? (
<pre className={styles.notes}>{notes.trim()}</pre>
) : (
<p className={styles.muted}>This version has no release notes.</p>
)}
</>
);
}
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 = (
<>
{plugin.error && (
<div className={styles.error} role="alert">
{plugin.error}
</div>
)}
{plugin.description && <p className={styles.description}>{plugin.description}</p>}
<dl className={styles.facts}>
<dt>Id</dt>
<dd className={styles.mono}>{plugin.id}</dd>
<dt>Location</dt>
<dd className={styles.mono} title={plugin.path}>
{plugin.path}
</dd>
<dt>Code</dt>
<dd>{plugin.hasCode ? "WebAssembly component (sandboxed)" : "None — contributions only"}</dd>
{plugin.homepage && (
<>
<dt>Homepage</dt>
<dd className={styles.mono}>{plugin.homepage}</dd>
</>
)}
</dl>
{plugin.hasCode && (
<Section title="Permissions">
{plugin.permissions.length === 0 ? (
<div className={styles.muted}>No special permissions.</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.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="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={`Run ${c.title}`}
>
Run
</Button>
</li>
);
})}
</ul>
</Section>
)}
{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" ? "Dark" : "Light"}</span>
{isActive ? (
<Button size="small" variant="secondary" onClick={() => void selectPluginTheme(null, null)}>
Use Default Theme
</Button>
) : (
<Button
size="small"
variant="ghost"
disabled={!live}
onClick={() => void selectPluginTheme(plugin.id, t.id)}
aria-label={`Apply theme ${t.name}`}
>
Apply
</Button>
)}
</li>
);
})}
</ul>
</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>
</Section>
)}
</>
);
return (
<div className={styles.details} aria-label={`${plugin.name} details`}>
<header className={styles.header}>
<PluginIcon large />
<div className={styles.headerText}>
<h2 className={styles.title}>{plugin.name}</h2>
{plugin.authors.length > 0 && <div className={styles.authors}>{vendorOf(plugin.authors)}</div>}
<div className={styles.titleLine}>
<span className={styles.version}>{plugin.version}</span>
<Badge tone={status.tone}>{status.label}</Badge>
{plugin.source === "dev" && <Badge tone="warning">Dev plugin</Badge>}
</div>
</div>
<div className={styles.headerActions}>
{busy && <Spinner size={14} label="Working" />}
<Button
size="small"
variant={plugin.enabled ? "secondary" : "primary"}
disabled={busy}
// Enabling an enabled plugin stops and starts it again: that is "Restart".
onClick={() => void setPluginEnabled(plugin.id, !plugin.enabled || plugin.status === "failed")}
>
{plugin.enabled ? (plugin.status === "failed" ? "Restart" : "Disable") : "Enable"}
</Button>
<IconButton
icon="refresh"
title="Reload from disk"
disabled={busy}
onClick={() => void reloadPlugin(plugin.id)}
/>
{plugin.source === "installed" &&
(confirmUninstall ? (
<>
<Button
size="small"
variant="primary"
disabled={busy}
onClick={() => void uninstallPlugin(plugin.id)}
aria-label={`Confirm uninstall ${plugin.name}`}
>
Uninstall
</Button>
<Button size="small" variant="ghost" onClick={() => setConfirmUninstall(false)}>
Cancel
</Button>
</>
) : (
<Button size="small" disabled={busy} onClick={() => setConfirmUninstall(true)}>
Uninstall…
</Button>
))}
</div>
</header>
<DetailTabs notes={plugin.changeNotes} overview={overview} />
</div>
);
}
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 overview = (
<>
{entry.problem && (
<div className={styles.error} role="alert">
{entry.problem}
</div>
)}
{entry.description && <p className={styles.description}>{entry.description}</p>}
<dl className={styles.facts}>
<dt>Id</dt>
<dd className={styles.mono}>{entry.id}</dd>
<dt>Source</dt>
<dd className={styles.mono} title={entry.path}>
{entry.path}
</dd>
<dt>Code</dt>
<dd>{entry.hasCode ? "WebAssembly component (sandboxed)" : "None — contributions only"}</dd>
{contributes.length > 0 && (
<>
<dt>Contributes</dt>
<dd>{contributes.join(", ")}</dd>
</>
)}
</dl>
{entry.permissions.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>
))}
</ul>
</Section>
)}
</>
);
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>
{entry.authors.length > 0 && <div className={styles.authors}>{vendorOf(entry.authors)}</div>}
<div className={styles.titleLine}>
<span className={styles.version}>{entry.version}</span>
</div>
</div>
<div className={styles.headerActions}>
<CatalogButton entry={entry} />
</div>
</header>
<DetailTabs notes={entry.changeNotes} overview={overview} />
</div>
);
}
function gearMenu(plugins: readonly PluginInfo[], install: () => void): MenuItem[] {
const installed = plugins.filter((p) => p.source === "installed");
return [
{ label: "Install Plugin from Disk…", icon: "package", run: install },
{ kind: "separator" },
{
label: "Disable All Downloaded Plugins",
disabled: !installed.some((p) => p.enabled),
run: () => void setAllInstalledEnabled(false),
},
{
label: "Enable All Downloaded Plugins",
disabled: !installed.some((p) => !p.enabled),
run: () => void setAllInstalledEnabled(true),
},
{ kind: "separator" },
{
label: "Rescan Plugins",
icon: "refresh",
run: () => {
void refreshPlugins();
void refreshCatalog();
},
},
];
}
export function PluginsPage() {
const { plugins, loaded, loading, error, installing, catalog, catalogError } = usePlugins();
const [tab, setTab] = useState<Tab>("installed");
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [selectedCatalogId, setSelectedCatalogId] = useState<string | null>(null);
useEffect(() => {
void refreshPlugins();
void refreshCatalog();
}, []);
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 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();
if (id) {
setTab("installed");
setSelectedId(id);
}
};
const openGear = (e: MouseEvent<HTMLButtonElement>) => {
const r = e.currentTarget.getBoundingClientRect();
showContextMenu(
{ x: r.left, y: r.bottom + 2 },
gearMenu(plugins, () => void install()),
);
};
return (
<div className={styles.page}>
<div className={styles.topBar}>
<Tabs
variant="compact"
className={styles.tabs}
aria-label="Plugin sources"
activeId={tab}
onSelect={(id) => setTab(id as Tab)}
items={[
{ id: "marketplace", label: "Available" },
{
id: "installed",
label: "Installed",
trailing: <span className={styles.tabCount}>{plugins.length}</span>,
},
]}
/>
<span className={styles.spacer} />
{(loading || installing) && <Spinner size={14} label={installing ? "Installing plugin" : "Loading plugins"} />}
<IconButton icon="settings" title="Manage Plugins" aria-haspopup="menu" onClick={openGear} />
</div>
{error && (
<div className={styles.error} role="alert">
{error}
</div>
)}
<div className={styles.body}>
<div className={styles.side}>
<SearchField
className={styles.search}
value={query}
onChange={setQuery}
placeholder={tab === "installed" ? "Search installed plugins" : "Search local plugins"}
aria-label="Filter plugins"
/>
{tab === "installed" ? (
<ul className={styles.list} aria-label="Installed plugins">
{downloaded.length > 0 && <ListHeader title="Downloaded" count={downloaded.length} />}
{downloaded.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
key={p.id}
plugin={p}
selected={p.id === selected?.id}
onSelect={() => setSelectedId(p.id)}
/>
))}
{loaded && shown.length === 0 && (
<li className={styles.listEmpty}>
{plugins.length === 0 ? "No plugins installed." : "No plugins match."}
</li>
)}
</ul>
) : (
<ul className={styles.list} aria-label="Available plugins">
<li className={styles.notice} role="note">
<Icon name="info" size={14} />
<span>{NO_REPOSITORY}. Showing plugin packages available on this machine.</span>
</li>
{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)}
/>
))}
{catalog && catalogShown.length === 0 && (
<li className={styles.listEmpty}>
{catalog.length === 0 ? "No local plugin packages." : "No plugins match."}
</li>
)}
{catalogError && <li className={clsx(styles.listEmpty, styles.errorText)}>{catalogError}</li>}
</ul>
)}
</div>
{tab === "installed" ? (
selected ? (
<InstalledDetails key={selected.id} plugin={selected} />
) : (
<EmptyState className={styles.detailsEmpty} title={loaded ? "No plugin selected" : "Loading…"}>
Plugins are folders with a <code>plugin.toml</code>. Use the gear menu to install one from disk, or put
dev plugins in <code>.openidea/plugins/</code> inside your project.
</EmptyState>
)
) : selectedEntry ? (
<CatalogDetails key={selectedEntry.id} entry={selectedEntry} />
) : (
<EmptyState className={styles.detailsEmpty} title={NO_REPOSITORY}>
Install a plugin folder with <code>plugin.toml</code> via the gear menu › Install Plugin from Disk….
</EmptyState>
)}
</div>
</div>
);
}
export default PluginsPage;
+304
Переглянути файл
@@ -0,0 +1,304 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
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 { provideNotificationService, type NotificationService } from "@/workbench/contributions";
import { createMemoryPluginsBackend, sampleCatalog, setPluginsBackend, type PluginsBackend } from "../backend";
import {
applyPluginTheme,
applyTextToActiveEditor,
handlePluginEvent,
pluginActionId,
pluginActions,
startPluginBridge,
} from "../bridge";
import { catalogState, filterCatalog, filterPlugins, NO_REPOSITORY, PluginsPage } from "../PluginsPage";
import {
compareVersions,
refreshPlugins,
runPluginCommand,
selectPluginTheme,
setAllInstalledEnabled,
setPluginEnabled,
usePlugins,
} from "../store";
type Note = Parameters<NotificationService["notify"]>[0];
let notes: Note[];
let backend: PluginsBackend;
let view: EditorView | null = null;
function plugin(partial: Partial<PluginInfo> & Pick<PluginInfo, "id">): PluginInfo {
return {
name: partial.id,
version: "1.0.0",
description: "",
authors: [],
homepage: null,
changeNotes: null,
source: "installed",
path: `/plugins/${partial.id}`,
enabled: true,
status: "active",
error: null,
hasCode: true,
permissions: [],
commands: [],
languages: [],
themes: [],
...partial,
};
}
function editor(doc: string, anchor: number, head = anchor, readOnly = false) {
const state = EditorState.create({ doc, selection: EditorSelection.single(anchor, head) });
view = new EditorView({ state, parent: document.body });
_setActiveEditor({ view, ctx: { doc: 1, path: "/p/a.txt", language: "plaintext", uiMode: null, readOnly } });
return view;
}
beforeEach(() => {
notes = [];
provideNotificationService({ notify: (n) => void notes.push(n) });
backend = createMemoryPluginsBackend();
setPluginsBackend(backend);
usePlugins.setState({
plugins: [],
activeTheme: null,
loaded: false,
loading: false,
error: null,
busy: {},
installing: false,
catalog: null,
catalogError: null,
});
});
afterEach(() => {
setPluginsBackend(null);
_setActiveEditor(null);
view?.destroy();
view = null;
applyPluginTheme(null);
});
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");
expect(pluginActionId("p", "c.d")).toBe("plugin.p.c.d");
});
it("keeps the registry in sync and runs commands through the backend", async () => {
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);
await act(() => executeAction("plugin.hello.insert-date"));
expect(v.state.doc.toString()).toMatch(/^a\d{4}-\d{2}-\d{2}b$/);
await setPluginEnabled("hello", false);
expect(getAction("plugin.hello.insert-date")).toBeUndefined();
stop();
});
});
describe("store", () => {
it("reports command failures, but not twice for plugins that trapped", async () => {
await refreshPlugins();
await runPluginCommand("hello", "word-count");
expect(notes.at(-1)).toMatchObject({ level: "error", title: "Hello: Word Count of Active File failed" });
notes = [];
const failing: PluginsBackend = {
...backend,
runCommand: async () => {
throw new Error("trapped");
},
list: async () => {
const snap = await backend.list();
return {
...snap,
plugins: snap.plugins.map((p) => (p.id === "hello" ? { ...p, status: "failed" as const } : p)),
};
},
};
setPluginsBackend(failing);
await runPluginCommand("hello", "insert-date");
expect(notes).toEqual([]);
});
it("selects and clears a plugin theme", async () => {
await refreshPlugins();
await selectPluginTheme("theme-solarized-dark", "solarized-dark");
expect(usePlugins.getState().activeTheme?.theme.id).toBe("solarized-dark");
await selectPluginTheme(null, null);
expect(usePlugins.getState().activeTheme).toBeNull();
});
it("filters by name, id, author and command title", () => {
const list = [
plugin({ id: "a", name: "Alpha", commands: [{ id: "c", title: "Count Words", category: null }] }),
plugin({ id: "b", name: "Beta", authors: ["Zed"] }),
];
expect(filterPlugins(list, "count").map((p) => p.id)).toEqual(["a"]);
expect(filterPlugins(list, "zed").map((p) => p.id)).toEqual(["b"]);
expect(filterPlugins(list, " ")).toHaveLength(2);
});
});
describe("bridge events", () => {
it("applies theme tokens as CSS variables and removes them again", () => {
const root = document.documentElement;
applyPluginTheme({
pluginId: "p",
theme: { id: "t", name: "T", kind: "dark", tokens: { "--bg-app": "#002b36", "--evil": "red; x: y", bad: "1" } },
});
expect(root.style.getPropertyValue("--bg-app")).toBe("#002b36");
expect(root.style.getPropertyValue("--evil")).toBe("");
expect(root.dataset.pluginTheme).toBe("p/t");
applyPluginTheme(null);
expect(root.style.getPropertyValue("--bg-app")).toBe("");
expect(root.dataset.pluginTheme).toBeUndefined();
});
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("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 plugin notifications and tracks theme changes", () => {
handlePluginEvent({ type: "notification", pluginId: "p", level: "info", title: "Done", body: "3 words" });
expect(notes).toEqual([{ group: "plugins", level: "info", title: "Done", body: "3 words" }]);
handlePluginEvent({ type: "themeChanged", theme: null });
expect(usePlugins.getState().activeTheme).toBeNull();
});
});
describe("PluginsPage", () => {
it("lists plugins, shows details, toggles and applies themes", async () => {
const user = userEvent.setup();
render(<PluginsPage />);
const list = await screen.findByRole("list", { name: "Installed plugins" });
await waitFor(() => expect(within(list).getAllByRole("listitem")).toHaveLength(3));
// 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();
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();
await user.click(within(list).getByText("Solarized Dark Theme"));
await user.click(screen.getByRole("button", { name: "Apply theme Solarized Dark" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Use Default Theme" })).toBeInTheDocument());
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();
});
it("restarts a failed plugin instead of disabling it", async () => {
setPluginsBackend(createMemoryPluginsBackend([plugin({ id: "broken", status: "failed", error: "trap" })]));
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" }));
});
it("uninstalls after confirmation", async () => {
const user = userEvent.setup();
render(<PluginsPage />);
await screen.findByRole("heading", { level: 2 });
await user.click(screen.getByRole("button", { name: "Uninstall…" }));
await user.click(screen.getByRole("button", { name: "Confirm uninstall Hello" }));
await waitFor(() => expect(screen.getByRole("heading", { level: 2 })).not.toHaveTextContent("Hello"));
expect(notes.at(-1)).toMatchObject({ title: "Uninstalled Hello" });
});
it("shows What's New from the change notes", async () => {
const user = userEvent.setup();
setPluginsBackend(
createMemoryPluginsBackend([plugin({ id: "hello", name: "Hello", changeNotes: "Added word count." })]),
);
render(<PluginsPage />);
await screen.findByRole("heading", { level: 2 });
expect(screen.queryByText("Added word count.")).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "What's New" }));
expect(screen.getByText("Added word count.")).toBeInTheDocument();
});
it("installs from the local catalog on the Available tab", async () => {
const user = userEvent.setup();
const catalog = sampleCatalog();
setPluginsBackend(createMemoryPluginsBackend([], catalog));
render(<PluginsPage />);
await user.click(screen.getByRole("tab", { name: /Available/ }));
const list = await screen.findByRole("list", { name: "Available plugins" });
expect(within(list).getByText(new RegExp(NO_REPOSITORY))).toBeInTheDocument();
await waitFor(() => expect(within(list).getByText("Toy Language")).toBeInTheDocument());
await user.click(within(list).getByText("Toy Language"));
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Toy Language");
await user.click(screen.getAllByRole("button", { name: "Install Toy Language" })[0]!);
await waitFor(() => expect(usePlugins.getState().plugins.map((p) => p.id)).toEqual(["language-toy"]));
expect(within(list).getAllByText("Installed").length).toBeGreaterThan(0);
expect(notes.at(-1)).toMatchObject({ title: "Installed Toy Language 0.1.0" });
});
});
describe("catalog helpers", () => {
it("compares versions numerically", () => {
expect(compareVersions("1.10.0", "1.9.2")).toBe(1);
expect(compareVersions("1.0", "1.0.0")).toBe(0);
expect(compareVersions("0.1.0-beta", "0.2.0")).toBe(-1);
});
it("derives install / update / installed / dev states", () => {
const entry = sampleCatalog().find((c) => c.id === "hello")!;
expect(catalogState(entry, [])).toBe("install");
expect(catalogState(entry, [plugin({ id: "hello", version: "0.0.9" })])).toBe("update");
expect(catalogState(entry, [plugin({ id: "hello", version: "0.1.0" })])).toBe("installed");
expect(catalogState(entry, [plugin({ id: "hello", source: "dev" })])).toBe("dev");
expect(filterCatalog(sampleCatalog(), "solarized").map((c) => c.id)).toEqual(["theme-solarized-dark"]);
});
it("disables and re-enables all downloaded plugins, leaving dev plugins alone", async () => {
setPluginsBackend(
createMemoryPluginsBackend([plugin({ id: "a" }), plugin({ id: "b" }), plugin({ id: "d", source: "dev" })]),
);
await refreshPlugins();
expect(await setAllInstalledEnabled(false)).toBe(2);
const enabled = () => Object.fromEntries(usePlugins.getState().plugins.map((p) => [p.id, p.enabled]));
expect(enabled()).toEqual({ a: false, b: false, d: true });
expect(await setAllInstalledEnabled(true)).toBe(2);
expect(enabled()).toEqual({ a: true, b: true, d: true });
});
});
+257
Переглянути файл
@@ -0,0 +1,257 @@
/**
* Plugin host backend: `plugin_*` IPC + a `plugin_subscribe` event channel in the desktop
* app; an in-memory simulation (the bundled samples) in plain-browser
* development and tests.
*/
import { Channel } from "@tauri-apps/api/core";
import type { ActiveTheme, CatalogPlugin, PluginEvent, PluginInfo, PluginsSnapshot } 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>;
/** 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>;
setActiveFile(path: string | null): Promise<void>;
setTheme(pluginId: string | null, themeId: string | null): Promise<ActiveTheme | null>;
/** Folder picker; `null` when cancelled. */
pickFolder(): Promise<string | null>;
/** Subscribes to backend plugin events (this window only). */
onEvent(handler: (e: PluginEvent) => void): () => void;
}
export const tauriPluginsBackend: PluginsBackend = {
list: () => call(commands.pluginList()),
setEnabled: async (id, enabled) => {
await call(commands.pluginSetEnabled(id, enabled));
},
installDir: (path) => call(commands.pluginInstallDir(path)),
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)),
setActiveFile: async (path) => {
await commands.pluginSetActiveFile(path);
},
setTheme: (pluginId, themeId) => call(commands.pluginSetTheme(pluginId, themeId)),
pickFolder: async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const picked = await open({ directory: true, multiple: false, title: "Install Plugin from Disk" });
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
onEvent: (handler) => {
tauriListeners.add(handler);
ensureSubscribed();
return () => void tauriListeners.delete(handler);
},
};
const tauriListeners = new Set<(e: PluginEvent) => void>();
let subscribed = false;
/** One channel per window; the backend addresses insert-text requests to the invoking window. */
function ensureSubscribed() {
if (subscribed) return;
subscribed = true;
const channel = new Channel<PluginEvent>();
channel.onmessage = (e) => tauriListeners.forEach((l) => l(e));
commands
.pluginSubscribe(channel)
// Anything that changed before the channel existed is picked up by a refresh.
.then(() => tauriListeners.forEach((l) => l({ type: "commandsChanged" })))
.catch(() => (subscribed = false));
}
// ---------------------------------------------------------------------------
// Browser simulation: the bundled sample plugins, with `hello` implemented in JS.
const SOLARIZED: ActiveTheme["theme"] = {
id: "solarized-dark",
name: "Solarized Dark",
kind: "dark",
tokens: {
"--bg-app": "#002b36",
"--bg-elev": "#03313c",
"--bg-popup": "#073642",
"--fg": "#93a1a1",
"--fg-strong": "#eee8d5",
"--accent": "#268bd2",
"--syn-keyword": "#859900",
"--syn-string": "#2aa198",
},
};
function samplePlugins(): PluginInfo[] {
const base = {
authors: ["OpenIDEA contributors"],
homepage: null,
changeNotes: null,
source: "installed" as const,
enabled: true,
error: null,
languages: [],
themes: [],
commands: [],
permissions: [],
};
return [
{
...base,
id: "hello",
name: "Hello",
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",
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" },
],
},
{
...base,
id: "theme-solarized-dark",
name: "Solarized Dark Theme",
version: "1.0.0",
description: "Ethan Schoonover's Solarized palette, dark variant.",
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/theme-solarized-dark",
status: "static",
hasCode: false,
themes: [{ id: "solarized-dark", name: "Solarized Dark", kind: "dark" }],
},
{
...base,
id: "language-toy",
name: "Toy Language",
version: "0.1.0",
description: "Language support for .toy files: detection, comments and indentation.",
path: "~/Library/Application Support/ai.l0ng.openidea/plugins/language-toy",
status: "static",
hasCode: false,
languages: [{ id: "toy", name: "Toy", extensions: ["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,
}));
}
export function createMemoryPluginsBackend(
initial: PluginInfo[] = samplePlugins(),
catalog: CatalogPlugin[] = sampleCatalog(),
): PluginsBackend {
let plugins = initial.map((p) => ({ ...p }));
const templates = samplePlugins();
let theme: { pluginId: string; themeId: string } | null = null;
let activeFile: string | null = null;
const listeners = new Set<(e: PluginEvent) => void>();
const emit = (e: PluginEvent) => listeners.forEach((l) => l(e));
const find = (id: string) => {
const p = plugins.find((x) => x.id === id);
if (!p) throw new IpcError({ kind: "notFound", message: `no plugin "${id}"` });
return p;
};
const activeTheme = (): ActiveTheme | null => {
if (!theme) return null;
const p = plugins.find((x) => x.id === theme?.pluginId);
if (!p?.enabled || theme.themeId !== SOLARIZED.id) return null;
return { pluginId: p.id, theme: SOLARIZED };
};
return {
list: async () => ({ plugins: plugins.map((p) => ({ ...p })), activeTheme: activeTheme() }),
setEnabled: async (id, enabled) => {
const p = find(id);
p.enabled = enabled;
p.status = enabled ? (p.hasCode ? "active" : "static") : "disabled";
p.error = null;
emit({ type: "commandsChanged" });
if (theme?.pluginId === id) emit({ type: "themeChanged", theme: activeTheme() });
},
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);
const template = entry && templates.find((t) => t.id === entry.id);
if (!entry || !template) {
throw new IpcError({ kind: "unavailable", message: `installing ${path} requires the desktop app` });
}
const info: PluginInfo = { ...template, version: entry.version, changeNotes: entry.changeNotes };
plugins = [...plugins.filter((p) => p.id !== entry.id), info];
emit({ type: "commandsChanged" });
return { ...info };
},
catalog: async () => catalog.map((c) => ({ ...c })),
uninstall: async (id) => {
find(id);
plugins = plugins.filter((p) => p.id !== id);
emit({ type: "commandsChanged" });
},
reload: async (id) => find(id),
runCommand: async (pluginId, commandId) => {
const p = find(pluginId);
if (p.status !== "active")
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;
}
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;
emit({ type: "notification", pluginId, level: "info", title: `Word count: ${name}`, body: "(simulated)" });
return null;
}
throw new IpcError({ kind: "internal", message: `unknown command: ${commandId}` });
},
setActiveFile: async (path) => {
activeFile = path;
},
setTheme: async (pluginId, themeId) => {
theme = pluginId && themeId ? { pluginId, themeId } : null;
const t = activeTheme();
emit({ type: "themeChanged", theme: t });
return t;
},
pickFolder: async () => null,
onEvent: (handler) => {
listeners.add(handler);
return () => void listeners.delete(handler);
},
};
}
let current: PluginsBackend | null = null;
export function pluginsBackend(): PluginsBackend {
return (current ??= isTauri() ? tauriPluginsBackend : createMemoryPluginsBackend());
}
/** Test hook. */
export function setPluginsBackend(b: PluginsBackend | null) {
current = b;
}
+185
Переглянути файл
@@ -0,0 +1,185 @@
/**
* Glue between backend plugins and the workbench: plugin commands become
* actions, editor requests are applied to the active editor, theme overrides
* become CSS variables, notifications become balloons.
*/
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 { notifications } from "@/workbench/contributions";
import { pluginsBackend } from "./backend";
import { refreshPlugins, runPluginCommand, usePlugins } from "./store";
export const pluginActionId = (pluginId: string, commandId: string) => `plugin.${pluginId}.${commandId}`;
/** 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: `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) });
}
};
apply(usePlugins.getState().plugins);
const unsubscribe = usePlugins.subscribe((s, prev) => {
if (s.plugins !== prev.plugins) apply(s.plugins);
});
return () => {
unsubscribe();
registered.forEach((e) => e.dispose());
registered.clear();
};
}
// --- theme ------------------------------------------------------------------
let appliedTokens: string[] = [];
/** Applies a plugin theme's token overrides as inline CSS variables on :root (null clears). */
export function applyPluginTheme(theme: ActiveTheme | null, root: HTMLElement = document.documentElement) {
for (const name of appliedTokens) root.style.removeProperty(name);
appliedTokens = [];
if (!theme) {
delete root.dataset.pluginTheme;
return;
}
for (const [name, value] of Object.entries(theme.theme.tokens)) {
// Backend validates; keep the guard so a malformed payload can't inject declarations.
if (!/^--[a-z0-9-]+$/.test(name) || value == null || /[;{}<>]/.test(value)) continue;
root.style.setProperty(name, value);
appliedTokens.push(name);
}
root.dataset.pluginTheme = `${theme.pluginId}/${theme.theme.id}`;
}
export function trackPluginTheme(): () => void {
applyPluginTheme(usePlugins.getState().activeTheme);
const unsubscribe = usePlugins.subscribe((s, prev) => {
if (s.activeTheme !== prev.activeTheme) applyPluginTheme(s.activeTheme);
});
return () => {
unsubscribe();
applyPluginTheme(null);
};
}
// --- 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). */
function trackActiveFile(): () => void {
let last: string | null | undefined;
const report = (path: string | null) => {
if (path === last) return;
last = path;
void pluginsBackend()
.setActiveFile(path)
.catch(() => {});
};
report(useEditorRegistry.getState().active?.ctx.path ?? null);
return useEditorRegistry.subscribe((s) => report(s.active?.ctx.path ?? null));
}
// --- events -----------------------------------------------------------------
export function handlePluginEvent(e: PluginEvent) {
switch (e.type) {
case "commandsChanged":
scheduleRefresh();
break;
case "notification": {
const name = usePlugins.getState().plugins.find((p) => p.id === e.pluginId)?.name ?? e.pluginId;
notifications().notify({ group: "plugins", level: e.level, title: e.title || name, body: e.body || undefined });
break;
}
case "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;
}
}
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleRefresh() {
if (refreshTimer) return;
refreshTimer = setTimeout(() => {
refreshTimer = null;
void refreshPlugins();
}, 30);
}
export function startPluginBridge(): () => void {
const disposers = [
pluginsBackend().onEvent(handlePluginEvent),
syncPluginActions(),
trackPluginTheme(),
trackActiveFile(),
];
return () => {
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = null;
disposers.reverse().forEach((d) => d());
};
}
+78
Переглянути файл
@@ -0,0 +1,78 @@
/**
* Plugins: Settings › Plugins (installed list, enable/disable, install from
* disk, uninstall, theme selection) and the runtime bridge to the WASM plugin
* host (`ide-plugin`).
*
* 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).
*/
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 { installFromDisk, refreshPlugins, reloadPlugin, SETTINGS_PAGE_ID, usePlugins } from "./store";
function registerPluginsPage(): () => void {
return registerSettingsPage({
id: SETTINGS_PAGE_ID,
title: "Plugins",
// After Editor, as in IntelliJ IDEA.
order: 250,
keywords: ["plugin", "extension", "wasm", "install", "theme", "marketplace"],
component: lazyComponent(() => import("./PluginsPage").then((m) => m.PluginsPage)),
});
}
/** Settings › Plugins for the Settings window: the page plus the plugin theme. */
export function activateSettingsPages(): () => void {
const disposers = [registerPluginsPage(), trackPluginTheme()];
void refreshPlugins();
return () => disposers.reverse().forEach((d) => d());
}
export function activate(): () => void {
const disposers = [
registerPluginsPage(),
registerActions([
{
id: "plugins.settings",
title: "Plugins…",
category: "Plugins",
icon: "plugin",
keywords: ["extensions", "manage plugins"],
run: () => void executeAction("file.settings", SETTINGS_PAGE_ID),
},
{
id: "plugins.installFromDisk",
title: "Install Plugin from Disk…",
category: "Plugins",
icon: "plugin",
keywords: ["extension", "wasm", "plugin.toml"],
run: async ({ arg }) => {
await installFromDisk(typeof arg === "string" ? arg : undefined);
},
},
{
id: "plugins.reloadAll",
title: "Reload Plugins",
category: "Plugins",
icon: "refresh",
keywords: ["dev plugin", "rebuild"],
run: async () => {
const ids = usePlugins.getState().plugins.map((p) => p.id);
for (const id of ids) await reloadPlugin(id);
},
},
]),
startPluginBridge(),
// Dev plugins (`<project>/.openidea/plugins`) depend on the open project.
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) void refreshPlugins();
}),
];
void refreshPlugins();
return () => disposers.reverse().forEach((d) => d());
}
+188
Переглянути файл
@@ -0,0 +1,188 @@
/** Installed plugins, the selected plugin theme, and the operations on them. */
import { create } from "zustand";
import type { ActiveTheme, CatalogPlugin, PluginInfo } from "@/bindings";
import { notifications } from "@/workbench/contributions";
import { pluginsBackend } from "./backend";
export const SETTINGS_PAGE_ID = "plugins";
interface PluginsState {
plugins: PluginInfo[];
activeTheme: ActiveTheme | null;
loaded: boolean;
loading: boolean;
error: string | null;
/** Plugin ids with an operation in flight. */
busy: Readonly<Record<string, boolean>>;
installing: boolean;
/** Locally available packages (Marketplace tab); `null` until loaded. */
catalog: CatalogPlugin[] | null;
catalogError: string | null;
}
export const usePlugins = create<PluginsState>(() => ({
plugins: [],
activeTheme: null,
loaded: false,
loading: false,
error: null,
busy: {},
installing: false,
catalog: null,
catalogError: null,
}));
export const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
let generation = 0;
export async function refreshPlugins(): Promise<void> {
const gen = ++generation;
usePlugins.setState({ loading: true });
try {
const snapshot = await pluginsBackend().list();
if (gen !== generation) return;
usePlugins.setState({
plugins: snapshot.plugins,
activeTheme: snapshot.activeTheme,
loaded: true,
loading: false,
error: null,
});
} catch (e) {
if (gen !== generation) return;
usePlugins.setState({ loading: false, loaded: true, error: errorText(e) });
}
}
async function withBusy(id: string, title: string, op: () => Promise<unknown>): Promise<boolean> {
usePlugins.setState((s) => ({ busy: { ...s.busy, [id]: true } }));
try {
await op();
return true;
} catch (e) {
notifications().notify({ group: "plugins", level: "error", title, body: errorText(e) });
return false;
} finally {
usePlugins.setState((s) => {
const busy = { ...s.busy };
delete busy[id];
return { busy };
});
await refreshPlugins();
}
}
const nameOf = (id: string) => usePlugins.getState().plugins.find((p) => p.id === id)?.name ?? id;
export function setPluginEnabled(id: string, enabled: boolean): Promise<boolean> {
return withBusy(id, `Could not ${enabled ? "enable" : "disable"} ${nameOf(id)}`, () =>
pluginsBackend().setEnabled(id, enabled),
);
}
export function reloadPlugin(id: string): Promise<boolean> {
return withBusy(id, `Could not reload ${nameOf(id)}`, () => pluginsBackend().reload(id));
}
export function uninstallPlugin(id: string): Promise<boolean> {
const name = nameOf(id);
return withBusy(id, `Could not uninstall ${name}`, async () => {
await pluginsBackend().uninstall(id);
notifications().notify({ group: "plugins", level: "info", title: `Uninstalled ${name}` });
});
}
export async function refreshCatalog(): Promise<void> {
try {
usePlugins.setState({ catalog: await pluginsBackend().catalog(), catalogError: null });
} catch (e) {
usePlugins.setState({ catalog: [], catalogError: errorText(e) });
}
}
/** 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);
let changed = 0;
for (const p of targets) if (await setPluginEnabled(p.id, enabled)) changed++;
return changed;
}
/** Numeric dotted-version comparison (`1.10.0` > `1.9.2`); pre-release suffixes are ignored. */
export function compareVersions(a: string, b: string): number {
const parts = (v: string) =>
v
.split(/[-+]/)[0]!
.split(".")
.map((x) => parseInt(x, 10) || 0);
const [x, y] = [parts(a), parts(b)];
for (let i = 0; i < Math.max(x.length, y.length); i++) {
const d = (x[i] ?? 0) - (y[i] ?? 0);
if (d) return Math.sign(d);
}
return 0;
}
/** Asks for a plugin folder (containing plugin.toml) and installs it. Returns the new plugin id. */
export async function installFromDisk(path?: string): Promise<string | null> {
const backend = pluginsBackend();
let dir: string | null = path ?? null;
try {
dir ??= await backend.pickFolder();
} catch (e) {
notifications().notify({
group: "plugins",
level: "error",
title: "Could not open the folder picker",
body: errorText(e),
});
return null;
}
if (!dir) return null;
usePlugins.setState({ installing: true });
try {
const info = await backend.installDir(dir);
notifications().notify({
group: "plugins",
level: info.status === "failed" ? "warning" : "info",
title: `Installed ${info.name} ${info.version}`,
body: info.status === "failed" ? (info.error ?? "The plugin failed to start.") : undefined,
});
return info.id;
} catch (e) {
notifications().notify({ group: "plugins", level: "error", title: "Could not install plugin", body: errorText(e) });
return null;
} finally {
usePlugins.setState({ installing: false });
await refreshPlugins();
}
}
export async function selectPluginTheme(pluginId: string | null, themeId: string | null): Promise<void> {
try {
const activeTheme = await pluginsBackend().setTheme(pluginId, themeId);
usePlugins.setState({ activeTheme });
} catch (e) {
notifications().notify({ group: "plugins", level: "error", title: "Could not apply theme", body: errorText(e) });
}
}
/** 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;
try {
return await pluginsBackend().runCommand(pluginId, commandId, arg);
} 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)
notifications().notify({ group: "plugins", level: "error", title: `${title} failed`, body: errorText(e) });
return null;
}
}
/** Plugins whose contributions are live (enabled and running, or contribution-only). */
export const isLive = (p: PluginInfo) => p.status === "active" || p.status === "static";