дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
Initial open-source release
Claude-Session: https://claude.ai/code/session_01B65qLEKnkeYTGdbwhLpjCu
Цей коміт міститься в:
коміт
9a2025b76e
2034 files changed
+243381
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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";
|
||||
Reference in new issue
Заблокувати користувача