feat: implement plugin management UI

This commit is contained in:
LukeGus committed 2026-10-06 10:35:56 -05:00
1 parent 88d7bc5525
commit 3ed5775ed5
52 files changed
+20072 -41

No files matched your search

+9
View File
@@ -238,6 +238,7 @@ import {
setShellLayoutProvider,
} from "@/plugin-host/shell-bridge";
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
export { buildHostTree } from "@/sidebar/build-host-tree";
export { tabIcon, renderTabContent } from "@/shell/tabUtils";
@@ -1723,6 +1724,7 @@ export function AppShell({
const singletonLabels: Partial<Record<TabType, string>> = {
"host-manager": t("nav.manage"),
settings: t("nav.settings"),
plugins: t("nav.plugins"),
};
// A plugin tab names itself; promoted rail panels reuse the rail's own
// label so the two stay in sync.
@@ -1776,6 +1778,12 @@ export function AppShell({
const openSingletonTabRef = useRef(openSingletonTab);
openSingletonTabRef.current = openSingletonTab;
useEffect(() => {
const handler = () => openSingletonTabRef.current("plugins");
window.addEventListener(OPEN_PLUGINS_EVENT, handler);
return () => window.removeEventListener(OPEN_PLUGINS_EVENT, handler);
}, []);
const getTabCloseLabel = useCallback((tab: Tab) => {
return tab.customLabel || tab.label || tab.host?.name || String(tab.id);
}, []);
@@ -3161,6 +3169,7 @@ export function AppShell({
"host-manager",
"user-profile",
"admin-settings",
"plugins",
].includes(type)
) {
openSingletonTab(type, pendingEvent);
+200
View File
@@ -237,9 +237,209 @@ export interface PluginSummary {
lastError?: string | null;
/** Paths a running plugin serves without login. Admins only. */
publicRoutes?: { http: string[]; ws: string[] };
registryId?: string | null;
autoUpdate?: boolean;
pinnedVersion?: string | null;
description?: string;
author?: string;
repository?: string;
signedBy?: string | null;
}
export async function getPlugins(): Promise<PluginSummary[]> {
const response = await rbacApi.get("/plugins");
return Array.isArray(response.data) ? response.data : [];
}
/** Fired after any change to installed plugins; the loader resyncs on it. */
export const PLUGINS_CHANGED_EVENT = "termix:plugins-changed";
function announcePluginsChanged(): void {
window.dispatchEvent(new CustomEvent(PLUGINS_CHANGED_EVENT));
}
export interface RegistryPluginVersion {
version: string;
compatible: boolean;
capabilities: string[];
publishedAt?: string;
releaseNotesUrl?: string;
size: number;
}
export interface RegistryPluginEntry {
id: string;
name: string;
description: string;
author: string;
category: string;
repository?: string;
icon?: string;
versions: RegistryPluginVersion[];
latestVersion: string | null;
installed: boolean;
installedVersion: string | null;
updateAvailable: boolean;
addedCapabilities: string[];
pinnedVersion: string | null;
autoUpdate: boolean;
bundled: boolean;
}
export interface RegistryListing {
registry: {
id: string;
url: string;
lastCheckedAt: string | null;
error: string | null;
};
managedByServer: boolean;
plugins: RegistryPluginEntry[];
}
export interface PluginStateChange {
id: string;
enabled: boolean;
enable: string[];
disable: string[];
missing: string[];
state?: string;
}
export interface PluginDataSummary {
id: string;
tables: Array<{ name: string; rows: number | null }>;
kvKeys: number;
settings: { admin: number; user: number; host: number; secret: number };
migrations: Array<{ id: string; appliedAt: string | null }>;
grants: Array<{
capability: string;
source: string;
grantedAt: string | null;
}>;
filesBytes: number;
}
export interface UpdateAllResult {
updated: Array<{ id: string; version: string }>;
needsReview: Array<{ id: string; capabilities: string[] }>;
failed: Array<{ id: string; error: string }>;
}
const pluginPath = (pluginId: string) =>
`/plugins/${encodeURIComponent(pluginId)}`;
export async function getPluginRegistry(
refresh = false,
): Promise<RegistryListing> {
const response = await rbacApi.get("/plugins/registry", {
params: refresh ? { refresh: 1 } : undefined,
});
return response.data;
}
export async function installPlugin(
pluginId: string,
version?: string,
): Promise<{ id: string; version: string; state: string }> {
try {
const response = await rbacApi.post(`${pluginPath(pluginId)}/install`, {
version,
});
return response.data;
} finally {
announcePluginsChanged();
}
}
export async function updatePlugin(
pluginId: string,
options: { version?: string; acceptCapabilities?: boolean } = {},
): Promise<{ id: string; version: string; state: string }> {
try {
const response = await rbacApi.post(
`${pluginPath(pluginId)}/update`,
options,
);
return response.data;
} finally {
announcePluginsChanged();
}
}
export async function updateAllPlugins(): Promise<UpdateAllResult> {
try {
const response = await rbacApi.post("/plugins/update-all");
return response.data;
} finally {
announcePluginsChanged();
}
}
export async function uninstallPlugin(pluginId: string): Promise<void> {
try {
await rbacApi.delete(pluginPath(pluginId));
} finally {
announcePluginsChanged();
}
}
export async function previewPluginState(
pluginId: string,
enabled: boolean,
): Promise<PluginStateChange> {
const response = await rbacApi.patch(
`${pluginPath(pluginId)}/state`,
{ enabled },
{ params: { dryRun: 1 } },
);
return response.data;
}
export async function setPluginState(
pluginId: string,
enabled: boolean,
): Promise<PluginStateChange> {
try {
const response = await rbacApi.patch(`${pluginPath(pluginId)}/state`, {
enabled,
});
return response.data;
} finally {
announcePluginsChanged();
}
}
export async function retryPlugin(pluginId: string): Promise<void> {
try {
await rbacApi.post(`${pluginPath(pluginId)}/retry`);
} finally {
announcePluginsChanged();
}
}
export async function setPluginOptions(
pluginId: string,
options: { autoUpdate?: boolean; pinned?: boolean },
): Promise<{ autoUpdate: boolean; pinnedVersion: string | null }> {
const response = await rbacApi.patch(
`${pluginPath(pluginId)}/options`,
options,
);
return response.data;
}
export async function getPluginData(
pluginId: string,
): Promise<PluginDataSummary> {
const response = await rbacApi.get(`${pluginPath(pluginId)}/data`);
return response.data;
}
export async function deletePluginData(pluginId: string): Promise<void> {
try {
await rbacApi.delete(`${pluginPath(pluginId)}/data`);
} finally {
announcePluginsChanged();
}
}
+168 -1
View File
@@ -331,7 +331,8 @@
"openSettings": "Open settings",
"settings": "Settings",
"manage": "Manage",
"mobileNavigation": "Navigation"
"mobileNavigation": "Navigation",
"plugins": "Plugins"
},
"hosts": {
"hosts": "Hosts",
@@ -1952,6 +1953,172 @@
"loading": "Loading {{name}}",
"crashed": "Something went wrong in {{name}}",
"retry": "Try again"
},
"manager": {
"tabs": {
"installed": "Installed",
"browse": "Browse",
"updates": "Updates"
},
"status": {
"summary": "{{installed}} installed, {{running}} running",
"running": "Running",
"disabled": "Disabled",
"failed": "Stopped unexpectedly",
"blocked": "Waiting on another plugin"
},
"groups": {
"running": "Running",
"notRunning": "Not running"
},
"sort": {
"name": "Name",
"updated": "Recently updated",
"category": "Category"
},
"risk": {
"critical": "Critical",
"high": "High risk",
"medium": "Medium risk",
"low": "Low risk"
},
"contributes": {
"tabs_one": "{{count}} tab",
"tabs_other": "{{count}} tabs",
"panels_one": "{{count}} panel",
"panels_other": "{{count}} panels",
"dashboardCards_one": "{{count}} dashboard card",
"dashboardCards_other": "{{count}} dashboard cards",
"actions_one": "{{count}} action",
"actions_other": "{{count}} actions",
"protocols_one": "{{count}} connection type",
"protocols_other": "{{count}} connection types",
"auth_one": "{{count}} sign in method",
"auth_other": "{{count}} sign in methods",
"settings_one": "Settings",
"settings_other": "Settings",
"hostCapability_one": "Host settings",
"hostCapability_other": "Host settings",
"permissions_one": "{{count}} role permission",
"permissions_other": "{{count}} role permissions",
"keybindingActions_one": "{{count}} shortcut",
"keybindingActions_other": "{{count}} shortcuts",
"syncEntities_one": "Desktop sync",
"syncEntities_other": "Desktop sync"
},
"consent": {
"installTitle": "Install {{name}}",
"updateTitle": "Update {{name}}",
"agreeUpdate": "Agree and update",
"updateExplain": "This version asks for something the one you have does not, so it will not install on its own.",
"newInVersion": "New in this version",
"minor": "It also adds its own screens and stores its own data, kept separate from other plugins.",
"none": "It asks for no special access."
},
"confirm": {
"uninstallTitle": "Uninstall {{name}}?",
"uninstallBody": "This stops it and deletes everything it stores, including its settings and secrets. You can install it again later, but the data will not come back.",
"deleteDataTitle": "Delete all data for {{name}}?",
"deleteDataBody": "Its tables, settings, secrets and files are deleted and it is turned off. This cannot be undone.",
"enableAlsoTitle": "Turn on {{name}}?",
"enableAlsoBody": "It needs these to run, so they will be turned on too: {{list}}.",
"disableAlsoTitle": "Turn off {{name}}?",
"disableAlsoBody": "These need it to run, so they will be turned off too: {{list}}.",
"downgradeTitle": "Go back to {{name}} {{version}}?",
"downgradeBody": "Data written by the newer version stays as it is, and an older version may not understand it. The plugin will stay on this version until you unpin it.",
"downgradeConfirm": "Switch version"
},
"toast": {
"installed": "{{name}} installed",
"installedNotRunning": "{{name}} installed but could not start. Open it to see why.",
"updated": "{{name}} updated to {{version}}",
"updatedMany_one": "{{count}} plugin updated",
"updatedMany_other": "{{count}} plugins updated",
"uninstalled": "{{name}} uninstalled",
"dataDeleted": "Data for {{name}} deleted",
"failedToStart": "{{name}} could not start. Open it to see why."
},
"errors": {
"generic": "Something went wrong",
"install": "Could not install {{name}}: {{error}}",
"update": "Could not update {{name}}: {{error}}",
"updateAll": "Could not update plugins: {{error}}",
"uninstall": "Could not uninstall {{name}}: {{error}}",
"state": "Could not change {{name}}: {{error}}",
"retry": "Could not restart {{name}}: {{error}}",
"options": "Could not save options for {{name}}: {{error}}",
"deleteData": "Could not delete data for {{name}}: {{error}}",
"managedByServer": "This desktop is linked to a server, which decides its plugins.",
"hasDependents": "{{name}} is needed by {{list}}. Turn those off or uninstall them first.",
"missingDependencies": "{{name}} needs {{list}}, which is not installed."
},
"data": {
"title": "Stored data",
"delete": "Delete data",
"loadFailed": "Could not read what this plugin stores.",
"tables": "Tables",
"kv": "Keys",
"settings": "Settings",
"files": "Files",
"rows_one": "{{count}} row",
"rows_other": "{{count}} rows",
"secrets_one": "{{count}} secret",
"secrets_other": "{{count}} secrets",
"migrations_one": "{{count}} migration applied",
"migrations_other": "{{count}} migrations applied",
"grants_one": "{{count}} capability granted",
"grants_other": "{{count}} capabilities granted"
},
"install": "Install",
"uninstall": "Uninstall",
"enable": "Enable",
"disable": "Disable",
"restart": "Restart",
"update": "Update",
"updateAll": "Update all",
"updateTo": "Update to {{version}}",
"review": "Review",
"reviewUpdate": "Review update",
"updates": "Updates",
"auto": "Auto",
"autoUpdate": "Update on its own",
"autoUpdateHint": "Never when the update asks for something new.",
"pin": "Stay on this version",
"pinHint": "Keeps {{version}} and skips every update until you turn this off.",
"pinnedTo": "Pinned to {{version}}",
"version": "Version",
"versionPickHint": "Install an older release instead of the newest. It stays on that version until you unpin it.",
"versionPickHintInstalled": "Switch to another release. Picking one that is not the newest pins it.",
"switchVersion": "Switch",
"latestVersion": "{{version}} (latest)",
"versionChange": "{{from}} to {{to}}",
"versionHistory": "Version history",
"releaseNotes": "Release notes",
"installedBadge": "Installed",
"incompatible": "Needs a newer Termix",
"whatItCanDo": "What it can do",
"addsToTermix": "Adds to Termix",
"addsToTermixHint": "Uninstall and every one of these goes away.",
"noServerAccess": "No access to your servers",
"searchInstalled": "Search installed",
"searchPlugins": "Search plugins",
"allCategories": "All",
"nothingMatches": "Nothing matches that.",
"noneInstalled": "No plugins installed yet. Browse to add some.",
"nothingHere": "Nothing here.",
"nothingHereHint": "Try another category or search.",
"upToDate": "Everything is up to date.",
"lastChecked": "Last checked {{time}}",
"updatesReady_one": "{{count}} update ready.",
"updatesReady_other": "{{count}} updates ready.",
"updatesNeedReview_one": "{{count}} more needs your review first.",
"updatesNeedReview_other": "{{count}} more need your review first.",
"updateAsksMore": "This update asks for more access, so it waits for you.",
"checkRegistry": "Check for plugins and updates",
"registryUnreachable": "Could not reach the plugin registry ({{error}}). Installed plugins still work.",
"managedByServer": "This desktop is linked to a server, which decides its plugins. Changes are made on the server.",
"browsePlugins": "Browse plugins",
"loading": "Loading"
}
},
"settings": {
@@ -1,5 +1,8 @@
import { CircleSlash } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { usePermissions } from "@/hooks/use-permissions";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
import { usePluginStore } from "./plugin-store";
import { unregisteredViewStatus, type ViewKind } from "./view-ownership";
@@ -22,6 +25,7 @@ export function PluginViewPlaceholder({
const { t } = useTranslation();
// Subscribing re-renders this when plugin state changes.
usePluginStore();
const { has } = usePermissions();
const { status, owner } = unregisteredViewStatus(kind, viewId);
const name = owner?.summary.name ?? viewId;
@@ -60,6 +64,18 @@ export function PluginViewPlaceholder({
{title}
</span>
<span className="text-xs text-muted-foreground/70">{hint}</span>
{!compact && status !== "failed" && has("admin.plugins.manage") && (
<Button
variant="outline"
size="sm"
className="mt-1"
onClick={() =>
window.dispatchEvent(new CustomEvent(OPEN_PLUGINS_EVENT))
}
>
{t("plugins.manager.browsePlugins")}
</Button>
)}
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Facts } from "@/components/panel-layout";
import { PanePrompt } from "@/components/surface/surface-scope";
import { CapabilityRow, PluginIconBox } from "./plugin-bits";
import { capabilityRisk, orderByRisk, type PluginEntry } from "./plugin-model";
export interface ConsentRequest {
plugin: PluginEntry;
mode: "install" | "update";
version: string;
/** What the prompt lists: all of them to install, only the new ones to update. */
capabilities: string[];
}
/**
* Install and update consent. Capabilities read as consequences, worst first;
* the low risk ones fold into one line so the list stays short enough to read.
*/
export function PluginConsentPrompt({
request,
busy,
onCancel,
onConfirm,
}: {
request: ConsentRequest | null;
busy?: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
const { t } = useTranslation();
if (!request) return null;
const { plugin, mode, version } = request;
const ordered = orderByRisk(request.capabilities);
const notable = ordered.filter((c) => capabilityRisk(c) !== "low");
const minor = ordered.filter((c) => capabilityRisk(c) === "low");
return (
<PanePrompt
open
className="max-w-md"
onCancel={busy ? undefined : onCancel}
icon={<PluginIconBox name={plugin.icon} size="sm" />}
title={
mode === "update"
? t("plugins.manager.consent.updateTitle", { name: plugin.name })
: t("plugins.manager.consent.installTitle", { name: plugin.name })
}
description={
<Facts>
{plugin.author && <span className="truncate">{plugin.author}</span>}
<span className="shrink-0">
{mode === "update" && plugin.version
? t("plugins.manager.versionChange", {
from: plugin.version,
to: version,
})
: version}
</span>
</Facts>
}
actions={
<>
<Button variant="ghost" onClick={onCancel} disabled={busy}>
{t("common.cancel")}
</Button>
<Button onClick={onConfirm} disabled={busy}>
{mode === "update"
? t("plugins.manager.consent.agreeUpdate")
: t("plugins.manager.install")}
</Button>
</>
}
>
<div className="flex flex-col gap-3">
{mode === "update" && (
<p className="text-xs leading-relaxed text-foreground">
{t("plugins.manager.consent.updateExplain")}
</p>
)}
<div className="flex flex-col">
<span className="pb-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{mode === "update"
? t("plugins.manager.consent.newInVersion")
: t("plugins.manager.whatItCanDo")}
</span>
<div className="divide-y divide-border border-y border-border">
{notable.map((capability) => (
<CapabilityRow
key={capability}
capability={capability}
className="py-2.5"
/>
))}
{minor.length > 0 && (
<div className="py-2.5 text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.consent.minor")}
</div>
)}
{ordered.length === 0 && (
<div className="py-2.5 text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.consent.none")}
</div>
)}
</div>
</div>
</div>
</PanePrompt>
);
}
+527
View File
@@ -0,0 +1,527 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Database,
Download,
ExternalLink,
Pin,
RotateCcw,
Trash2,
TriangleAlert,
} from "lucide-react";
import { Button } from "@/components/button";
import { Card } from "@/components/card";
import { Facts, PANEL } from "@/components/panel-layout";
import { FakeSwitch, SectionCard } from "@/components/section-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import { getPluginData, type PluginDataSummary } from "@/api/plugins-api";
import { CapabilityRow, PluginIconBox } from "./plugin-bits";
import {
describeContributions,
formatBytes,
orderByRisk,
type PluginEntry,
} from "./plugin-model";
import type { PluginsManager } from "./use-plugins-manager";
function formatDate(value: string | null | undefined): string {
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString();
}
export function PluginDetail({
plugin,
manager,
onUninstalled,
}: {
plugin: PluginEntry;
manager: PluginsManager;
onUninstalled: () => void;
}) {
const { t } = useTranslation();
const busy = manager.busy.has(plugin.id) || manager.busy.has("*");
const locked = manager.managedByServer;
const running = plugin.status === "running";
const failed = plugin.status === "failed" || plugin.status === "blocked";
const compatible = plugin.versions.filter((v) => v.compatible);
const [picked, setPicked] = useState<string>(
plugin.version ?? plugin.latestVersion ?? "",
);
useEffect(() => {
setPicked(plugin.version ?? plugin.latestVersion ?? "");
}, [plugin.version, plugin.latestVersion]);
const contributions = describeContributions(plugin.contributes);
return (
<div
className={`mx-auto flex w-full max-w-5xl flex-col ${PANEL.gap} ${PANEL.body}`}
>
<Card className="flex-row flex-wrap items-start gap-4 px-4 py-4">
<PluginIconBox
name={plugin.icon}
size="lg"
muted={plugin.installed && !running}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<h1 className="text-lg font-bold tracking-tight">{plugin.name}</h1>
<span className="text-xs leading-relaxed text-muted-foreground">
{plugin.description}
</span>
<Facts className="text-[11px] text-muted-foreground/70">
{plugin.author && <span>{plugin.author}</span>}
{plugin.version ? (
<span>v{plugin.version}</span>
) : (
plugin.latestVersion && <span>v{plugin.latestVersion}</span>
)}
{plugin.category && <span>{plugin.category}</span>}
{plugin.pinnedVersion && (
<span>
{t("plugins.manager.pinnedTo", {
version: plugin.pinnedVersion,
})}
</span>
)}
</Facts>
</div>
<div className="flex shrink-0 flex-col gap-1.5">
{plugin.installed &&
plugin.updateAvailable &&
!plugin.pinnedVersion && (
<Button
size="sm"
className="gap-1.5"
disabled={busy || locked}
onClick={() => void manager.requestUpdate(plugin)}
>
<Download className="size-3.5" />
{t("plugins.manager.updateTo", {
version: plugin.latestVersion,
})}
</Button>
)}
{plugin.installed ? (
<>
{failed ? (
<Button
variant="outline"
size="sm"
className="gap-1.5"
disabled={busy || locked}
onClick={() => void manager.retry(plugin)}
>
<RotateCcw className="size-3.5" />
{t("plugins.manager.restart")}
</Button>
) : null}
<Button
variant="outline"
size="sm"
disabled={busy || locked}
onClick={() => void manager.toggle(plugin)}
>
{plugin.enabled
? t("plugins.manager.disable")
: t("plugins.manager.enable")}
</Button>
<Button
variant="destructive"
size="sm"
className="gap-1.5"
disabled={busy || locked}
onClick={async () => {
if (await manager.uninstall(plugin)) onUninstalled();
}}
>
<Trash2 className="size-3.5" />
{t("plugins.manager.uninstall")}
</Button>
</>
) : (
<Button
size="sm"
className="gap-1.5"
disabled={busy || locked || !plugin.latestVersion}
onClick={() => manager.requestInstall(plugin)}
>
<Download className="size-3.5" />
{t("plugins.manager.install")}
</Button>
)}
</div>
</Card>
{plugin.installed && failed && plugin.lastError && (
<div className="flex items-start gap-2 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<TriangleAlert className="mt-px size-3.5 shrink-0 text-destructive" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs font-medium">
{plugin.status === "blocked"
? t("plugins.manager.status.blocked")
: t("plugins.manager.status.failed")}
</span>
<span className="break-words font-mono text-[11px] leading-snug text-muted-foreground">
{plugin.lastError}
</span>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
<div className="flex flex-col gap-2 lg:col-span-2">
<SectionCard title={t("plugins.manager.whatItCanDo")} icon={null}>
<div className="divide-y divide-border">
{orderByRisk(plugin.capabilities).map((capability) => (
<CapabilityRow
key={capability}
capability={capability}
showId
className="px-4 py-2.5"
/>
))}
{plugin.capabilities.length === 0 && (
<div className="px-4 py-2.5 text-[11px] text-muted-foreground">
{t("plugins.manager.consent.none")}
</div>
)}
</div>
</SectionCard>
{plugin.versions.length > 0 && (
<SectionCard
title={t("plugins.manager.versionHistory")}
icon={null}
>
<div className="divide-y divide-border">
{plugin.versions.map((v) => (
<div
key={v.version}
className="flex flex-col gap-0.5 px-4 py-2.5"
>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold">{v.version}</span>
{v.version === plugin.version && (
<span className="border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-accent-brand">
{t("plugins.manager.installedBadge")}
</span>
)}
{!v.compatible && (
<span className="border border-warning/40 bg-warning/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">
{t("plugins.manager.incompatible")}
</span>
)}
<span className="ml-auto text-[10px] text-muted-foreground">
{formatDate(v.publishedAt)}
</span>
</div>
{v.releaseNotesUrl && (
<a
href={v.releaseNotesUrl}
target="_blank"
rel="noreferrer"
className="w-fit text-[11px] text-muted-foreground hover:text-accent-brand"
>
{t("plugins.manager.releaseNotes")}
</a>
)}
</div>
))}
</div>
</SectionCard>
)}
{plugin.installed && (
<DataCard
plugin={plugin}
disabled={busy || locked}
onDelete={() => manager.removeData(plugin)}
/>
)}
</div>
<div className="flex flex-col gap-2">
{contributions.length > 0 && (
<SectionCard title={t("plugins.manager.addsToTermix")} icon={null}>
<div className="flex flex-col gap-2 px-4 py-3">
<div className="flex flex-wrap gap-1">
{contributions.map((c) => (
<span
key={c.kind}
className="border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
{t(`plugins.manager.contributes.${c.kind}`, {
count: c.count,
})}
</span>
))}
</div>
<span className="text-[11px] leading-snug text-muted-foreground/70">
{t("plugins.manager.addsToTermixHint")}
</span>
</div>
</SectionCard>
)}
{plugin.inRegistry && compatible.length > 0 && (
<SectionCard title={t("plugins.manager.version")} icon={null}>
<div className="flex flex-col gap-2 px-4 py-3">
<span className="text-[11px] leading-snug text-muted-foreground">
{plugin.installed
? t("plugins.manager.versionPickHintInstalled")
: t("plugins.manager.versionPickHint")}
</span>
<div className="flex items-center gap-2">
<Select value={picked} onValueChange={setPicked}>
<SelectTrigger size="sm" className="flex-1 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{compatible.map((v) => (
<SelectItem
key={v.version}
value={v.version}
className="text-xs"
>
{v.version === plugin.latestVersion
? t("plugins.manager.latestVersion", {
version: v.version,
})
: v.version}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
disabled={
busy || locked || !picked || picked === plugin.version
}
onClick={() =>
plugin.installed
? void manager.requestUpdate(plugin, picked)
: manager.requestInstall(plugin, picked)
}
>
{plugin.installed
? t("plugins.manager.switchVersion")
: t("plugins.manager.install")}
</Button>
</div>
</div>
</SectionCard>
)}
{plugin.installed && (
<SectionCard title={t("plugins.manager.updates")} icon={null}>
<div className="divide-y divide-border">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs font-medium">
{t("plugins.manager.autoUpdate")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.autoUpdateHint")}
</span>
</div>
<FakeSwitch
checked={plugin.autoUpdate}
disabled={busy || locked || !!plugin.pinnedVersion}
onChange={(v) =>
void manager.setOptions(plugin, { autoUpdate: v })
}
/>
</div>
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs font-medium">
<Pin className="size-3 text-muted-foreground" />
{t("plugins.manager.pin")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.pinHint", {
version: plugin.version,
})}
</span>
</div>
<FakeSwitch
checked={!!plugin.pinnedVersion}
disabled={busy || locked}
onChange={(v) =>
void manager.setOptions(plugin, { pinned: v })
}
/>
</div>
</div>
</SectionCard>
)}
{plugin.repository && (
<a
href={plugin.repository}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 border border-border bg-card px-3 py-2.5 text-[11px] text-muted-foreground transition-colors hover:border-accent-brand/40 hover:text-foreground"
>
<ExternalLink className="size-3.5 shrink-0" />
<span className="truncate">
{plugin.repository.replace(/^https?:\/\//, "")}
</span>
</a>
)}
</div>
</div>
</div>
);
}
function DataCard({
plugin,
disabled,
onDelete,
}: {
plugin: PluginEntry;
disabled: boolean;
onDelete: () => Promise<boolean>;
}) {
const { t } = useTranslation();
const [data, setData] = useState<PluginDataSummary | null>(null);
const [error, setError] = useState(false);
const [version, setVersion] = useState(0);
useEffect(() => {
let cancelled = false;
setError(false);
getPluginData(plugin.id)
.then((summary) => {
if (!cancelled) setData(summary);
})
.catch(() => {
if (!cancelled) setError(true);
});
return () => {
cancelled = true;
};
}, [plugin.id, plugin.version, version]);
const settings = data
? data.settings.admin + data.settings.user + data.settings.host
: 0;
const rows = data?.tables.reduce((sum, table) => sum + (table.rows ?? 0), 0);
return (
<SectionCard
title={t("plugins.manager.data.title")}
icon={<Database className="size-3.5" />}
action={
<Button
variant="ghost"
size="xs"
className="gap-1 text-muted-foreground hover:text-destructive"
disabled={disabled}
onClick={async () => {
if (await onDelete()) setVersion((v) => v + 1);
}}
>
<Trash2 className="size-3" />
{t("plugins.manager.data.delete")}
</Button>
}
>
{error ? (
<div className="px-4 py-3 text-[11px] text-muted-foreground">
{t("plugins.manager.data.loadFailed")}
</div>
) : !data ? (
<div className="px-4 py-3 text-[11px] text-muted-foreground">
{t("plugins.manager.loading")}
</div>
) : (
<div className="flex flex-col">
<div className="grid grid-cols-2 gap-px border-b border-border bg-border sm:grid-cols-4">
<Stat
label={t("plugins.manager.data.tables")}
value={`${data.tables.length}`}
hint={t("plugins.manager.data.rows", { count: rows ?? 0 })}
/>
<Stat
label={t("plugins.manager.data.kv")}
value={`${data.kvKeys}`}
/>
<Stat
label={t("plugins.manager.data.settings")}
value={`${settings}`}
hint={t("plugins.manager.data.secrets", {
count: data.settings.secret,
})}
/>
<Stat
label={t("plugins.manager.data.files")}
value={formatBytes(data.filesBytes)}
/>
</div>
{data.tables.length > 0 && (
<div className="divide-y divide-border">
{data.tables.map((table) => (
<div
key={table.name}
className="flex items-center gap-2 px-4 py-1.5"
>
<span className="min-w-0 flex-1 truncate font-mono text-[11px]">
{table.name}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground">
{table.rows === null
? "-"
: t("plugins.manager.data.rows", { count: table.rows })}
</span>
</div>
))}
</div>
)}
<div className="flex flex-wrap gap-x-4 gap-y-1 border-t border-border px-4 py-2 text-[11px] text-muted-foreground">
<span>
{t("plugins.manager.data.migrations", {
count: data.migrations.length,
})}
</span>
<span>
{t("plugins.manager.data.grants", { count: data.grants.length })}
</span>
</div>
</div>
)}
</SectionCard>
);
}
function Stat({
label,
value,
hint,
}: {
label: string;
value: string;
hint?: string;
}) {
return (
<div className="flex flex-col gap-0.5 bg-card px-4 py-2.5">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{label}
</span>
<span className="text-sm font-semibold">{value}</span>
{hint && (
<span className="text-[10px] text-muted-foreground">{hint}</span>
)}
</div>
);
}
+818
View File
@@ -0,0 +1,818 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowDownWideNarrow,
Check,
Download,
Globe,
Puzzle,
RefreshCw,
RotateCcw,
Search,
Trash2,
TriangleAlert,
} from "lucide-react";
import { Button } from "@/components/button";
import { Card } from "@/components/card";
import { Input } from "@/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import { EmptyState } from "@/components/empty-state";
import { TabStrip } from "@/components/tab-strip";
import { FakeSwitch } from "@/components/section-card";
import {
Facts,
GroupHeading,
PANEL,
PanelShell,
} from "@/components/panel-layout";
import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
import { cn } from "@/lib/utils";
import { PluginIconBox } from "./plugin-bits";
import { PluginConsentPrompt } from "./PluginConsentPrompt";
import { PluginDetail } from "./PluginDetail";
import {
categoriesOf,
describeContributions,
isSevere,
matchesQuery,
orderByRisk,
sortPlugins,
type PluginEntry,
type SortKey,
} from "./plugin-model";
import { usePluginsManager, type PluginsManager } from "./use-plugins-manager";
type Section = "installed" | "browse" | "updates";
export function PluginsTab() {
return (
<SurfaceScope kind="tab" className="h-full min-h-0">
<PluginsTabBody />
</SurfaceScope>
);
}
function PluginsTabBody() {
const { t } = useTranslation();
const manager = usePluginsManager();
const [section, setSection] = useState<Section>("installed");
const [query, setQuery] = useState("");
const [detailId, setDetailId] = useState<string | null>(null);
const installed = manager.plugins.filter((p) => p.installed);
const available = manager.plugins.filter((p) => p.inRegistry);
const updates = installed.filter((p) => p.updateAvailable);
const running = installed.filter((p) => p.status === "running").length;
const detail = manager.plugins.find((p) => p.id === detailId) ?? null;
const tabs = [
{
id: "installed",
label: t("plugins.manager.tabs.installed"),
icon: <Puzzle className="size-3" />,
count: installed.length,
},
{
id: "browse",
label: t("plugins.manager.tabs.browse"),
icon: <Globe className="size-3" />,
},
{
id: "updates",
label: t("plugins.manager.tabs.updates"),
icon: <Download className="size-3" />,
count: updates.filter((p) => !p.pinnedVersion).length || undefined,
},
];
return (
<>
<PanelShell
icon={<Puzzle className="size-4" />}
title={t("nav.plugins")}
status={t("plugins.manager.status.summary", {
installed: installed.length,
running,
})}
scroll={false}
actions={
<Button
variant="ghost"
size="icon-sm"
title={t("plugins.manager.checkRegistry")}
aria-label={t("plugins.manager.checkRegistry")}
disabled={manager.refreshing}
onClick={() => void manager.refresh()}
className="text-muted-foreground"
>
<RefreshCw
className={cn("size-3.5", manager.refreshing && "animate-spin")}
/>
</Button>
}
tabs={
<TabStrip
tabs={tabs}
activeTab={section}
onTabChange={(id) => setSection(id as Section)}
/>
}
>
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
{manager.managedByServer && (
<Notice>{t("plugins.manager.managedByServer")}</Notice>
)}
{manager.registryError && (
<Notice>
{t("plugins.manager.registryUnreachable", {
error: manager.registryError,
})}
</Notice>
)}
{manager.loading ? (
<div className={PANEL.body}>
<EmptyState icon={Puzzle} title={t("plugins.manager.loading")} />
</div>
) : section === "installed" ? (
<InstalledSection
plugins={installed}
manager={manager}
query={query}
onQuery={setQuery}
onOpen={setDetailId}
/>
) : section === "browse" ? (
<BrowseSection
plugins={available}
manager={manager}
query={query}
onQuery={setQuery}
onOpen={setDetailId}
/>
) : (
<UpdatesSection
plugins={updates}
manager={manager}
onOpen={setDetailId}
/>
)}
</div>
</PanelShell>
<InlineView
open={!!detail}
onOpenChange={(open) => !open && setDetailId(null)}
title={detail?.name ?? ""}
icon={<Puzzle className="size-4" />}
width="wide"
bare
>
{detail && (
<PluginDetail
plugin={detail}
manager={manager}
onUninstalled={() => setDetailId(null)}
/>
)}
</InlineView>
<PluginConsentPrompt
request={manager.consent}
onCancel={() => manager.setConsent(null)}
onConfirm={() => void manager.confirmConsent()}
/>
</>
);
}
function Notice({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-start gap-2 border-b border-warning/30 bg-warning/5 px-3 py-2">
<TriangleAlert className="mt-px size-3.5 shrink-0 text-warning" />
<span className="text-[11px] leading-snug text-muted-foreground">
{children}
</span>
</div>
);
}
function SortSelect({
value,
onChange,
}: {
value: SortKey;
onChange: (next: SortKey) => void;
}) {
const { t } = useTranslation();
const keys: SortKey[] = ["name", "updated", "category"];
return (
<Select value={value} onValueChange={(v) => onChange(v as SortKey)}>
<SelectTrigger size="sm" className="w-auto gap-1.5 text-xs">
<ArrowDownWideNarrow className="size-3.5 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{keys.map((key) => (
<SelectItem key={key} value={key} className="text-xs">
{t(`plugins.manager.sort.${key}`)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function Toolbar({
query,
onQuery,
placeholder,
children,
}: {
query: string;
onQuery: (v: string) => void;
placeholder: string;
children?: React.ReactNode;
}) {
return (
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-40 max-w-xs flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => onQuery(e.target.value)}
placeholder={placeholder}
className="h-8 pl-8 text-xs"
/>
</div>
{children}
</div>
);
}
function Group({
title,
count,
children,
}: {
title: string;
count: number;
children: React.ReactNode;
}) {
return (
<section className="flex flex-col gap-2.5">
<GroupHeading title={title} count={count} />
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{children}
</div>
</section>
);
}
function InstalledSection({
plugins,
manager,
query,
onQuery,
onOpen,
}: {
plugins: PluginEntry[];
manager: PluginsManager;
query: string;
onQuery: (v: string) => void;
onOpen: (id: string) => void;
}) {
const { t } = useTranslation();
const [sort, setSort] = useState<SortKey>("name");
const rows = sortPlugins(
plugins.filter((p) => matchesQuery(p, query)),
sort,
);
const running = rows.filter((p) => p.status === "running");
const stopped = rows.filter((p) => p.status !== "running");
const pending = plugins.filter((p) => p.updateAvailable && !p.pinnedVersion);
return (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
<Toolbar
query={query}
onQuery={onQuery}
placeholder={t("plugins.manager.searchInstalled")}
>
<div className="ml-auto flex items-center gap-2">
<SortSelect value={sort} onChange={setSort} />
<Button
variant="outline"
className="gap-1.5"
disabled={
pending.length === 0 ||
manager.busy.size > 0 ||
manager.managedByServer
}
onClick={() => void manager.updateAll()}
>
<Download className="size-3.5" />
{t("plugins.manager.updateAll")}
</Button>
</div>
</Toolbar>
{rows.length === 0 ? (
<EmptyState
icon={Puzzle}
title={
plugins.length === 0
? t("plugins.manager.noneInstalled")
: t("plugins.manager.nothingMatches")
}
/>
) : (
<>
{running.length > 0 && (
<Group
title={t("plugins.manager.groups.running")}
count={running.length}
>
{running.map((p) => (
<InstalledCard
key={p.id}
plugin={p}
manager={manager}
onOpen={() => onOpen(p.id)}
/>
))}
</Group>
)}
{stopped.length > 0 && (
<Group
title={t("plugins.manager.groups.notRunning")}
count={stopped.length}
>
{stopped.map((p) => (
<InstalledCard
key={p.id}
plugin={p}
manager={manager}
onOpen={() => onOpen(p.id)}
/>
))}
</Group>
)}
</>
)}
</div>
);
}
function CardHead({
plugin,
muted,
facts,
onOpen,
}: {
plugin: PluginEntry;
muted?: boolean;
facts: React.ReactNode;
onOpen: () => void;
}) {
return (
<button
type="button"
onClick={onOpen}
className="group/open flex flex-1 items-start gap-3 p-3 text-left"
>
<PluginIconBox name={plugin.icon} muted={muted} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span
className={cn(
"truncate text-sm font-semibold transition-colors group-hover/open:text-accent-brand",
muted && "text-muted-foreground",
)}
>
{plugin.name}
</span>
<span className="line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{plugin.description}
</span>
<Facts className="text-[10px] text-muted-foreground/70">{facts}</Facts>
</div>
</button>
);
}
function InstalledCard({
plugin,
manager,
onOpen,
}: {
plugin: PluginEntry;
manager: PluginsManager;
onOpen: () => void;
}) {
const { t } = useTranslation();
const running = plugin.status === "running";
const failed = plugin.status === "failed" || plugin.status === "blocked";
const busy = manager.busy.has(plugin.id) || manager.busy.has("*");
const locked = manager.managedByServer;
const adds = describeContributions(plugin.contributes)
.slice(0, 3)
.map((c) => t(`plugins.manager.contributes.${c.kind}`, { count: c.count }));
return (
<Card className="gap-0 overflow-hidden py-0">
<CardHead
plugin={plugin}
muted={!running}
onOpen={onOpen}
facts={
<>
{plugin.author && <span>{plugin.author}</span>}
<span>v{plugin.version}</span>
{adds.length > 0 && <span>{adds.join(", ")}</span>}
</>
}
/>
{failed ? (
<div className="flex items-center gap-2 border-t border-border bg-destructive/5 px-3 py-2">
<TriangleAlert className="size-3.5 shrink-0 text-destructive" />
<span
className="flex-1 truncate text-[11px] text-muted-foreground"
title={plugin.lastError ?? undefined}
>
{plugin.status === "blocked"
? t("plugins.manager.status.blocked")
: t("plugins.manager.status.failed")}
</span>
</div>
) : !running ? (
<div className="border-t border-border bg-muted/20 px-3 py-2">
<span className="text-[11px] text-muted-foreground">
{t("plugins.manager.status.disabled")}
</span>
</div>
) : (
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
<span className="size-1.5 shrink-0 bg-green-500" />
<span className="text-[11px] text-muted-foreground">
{plugin.pinnedVersion
? t("plugins.manager.pinnedTo", {
version: plugin.pinnedVersion,
})
: t("plugins.manager.status.running")}
</span>
</div>
)}
<div className="flex items-center gap-1.5 border-t border-border px-3 py-2">
{plugin.updateAvailable && !plugin.pinnedVersion && !failed && (
<Button
size="xs"
className="gap-1"
disabled={busy || locked}
onClick={() => void manager.requestUpdate(plugin)}
>
<Download className="size-3" />
{plugin.addedCapabilities.length > 0
? t("plugins.manager.reviewUpdate")
: t("plugins.manager.updateTo", {
version: plugin.latestVersion,
})}
</Button>
)}
{failed ? (
<Button
variant="outline"
size="xs"
className="gap-1"
disabled={busy || locked}
onClick={() => void manager.retry(plugin)}
>
<RotateCcw className="size-3" />
{t("plugins.manager.restart")}
</Button>
) : (
<Button
variant="outline"
size="xs"
disabled={busy || locked}
onClick={() => void manager.toggle(plugin)}
>
{plugin.enabled
? t("plugins.manager.disable")
: t("plugins.manager.enable")}
</Button>
)}
<Button
variant="ghost"
size="icon-xs"
disabled={busy || locked}
title={t("plugins.manager.uninstall")}
aria-label={t("plugins.manager.uninstall")}
className="ml-auto text-muted-foreground hover:text-destructive"
onClick={() => void manager.uninstall(plugin)}
>
<Trash2 className="size-3" />
</Button>
</div>
</Card>
);
}
function BrowseSection({
plugins,
manager,
query,
onQuery,
onOpen,
}: {
plugins: PluginEntry[];
manager: PluginsManager;
query: string;
onQuery: (v: string) => void;
onOpen: (id: string) => void;
}) {
const { t } = useTranslation();
const [category, setCategory] = useState<string | null>(null);
const [sort, setSort] = useState<SortKey>("name");
const categories = useMemo(() => categoriesOf(plugins), [plugins]);
const rows = sortPlugins(
plugins.filter(
(p) =>
matchesQuery(p, query) &&
(category === null || p.category === category),
),
sort,
);
return (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
<Toolbar
query={query}
onQuery={onQuery}
placeholder={t("plugins.manager.searchPlugins")}
>
<div className="ml-auto flex items-center gap-2">
<SortSelect value={sort} onChange={setSort} />
</div>
</Toolbar>
{categories.length > 0 && (
<div className="flex flex-wrap items-center gap-1">
{[null, ...categories].map((c) => (
<button
key={c ?? "__all"}
type="button"
onClick={() => setCategory(c)}
className={cn(
"border px-2 py-1 text-[11px] font-medium transition-colors",
category === c
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground",
)}
>
{c ?? t("plugins.manager.allCategories")}
</button>
))}
</div>
)}
{rows.length === 0 ? (
<EmptyState
icon={Puzzle}
title={t("plugins.manager.nothingHere")}
hint={
plugins.length === 0
? undefined
: t("plugins.manager.nothingHereHint")
}
/>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{rows.map((p) => (
<BrowseCard
key={p.id}
plugin={p}
manager={manager}
onOpen={() => onOpen(p.id)}
/>
))}
</div>
)}
</div>
);
}
function BrowseCard({
plugin,
manager,
onOpen,
}: {
plugin: PluginEntry;
manager: PluginsManager;
onOpen: () => void;
}) {
const { t } = useTranslation();
const severe = orderByRisk(plugin.capabilities).filter(isSevere);
const busy = manager.busy.has(plugin.id) || manager.busy.has("*");
return (
<Card className="gap-0 overflow-hidden py-0">
<CardHead
plugin={plugin}
onOpen={onOpen}
facts={
<>
{plugin.author && <span>{plugin.author}</span>}
{plugin.latestVersion && <span>v{plugin.latestVersion}</span>}
{plugin.category && <span>{plugin.category}</span>}
</>
}
/>
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
<span className="min-w-0 flex-1 truncate text-[10px] text-muted-foreground">
{severe.length > 0
? t(`plugins.capabilities.${severe[0]}.title`, {
defaultValue: severe[0],
nsSeparator: false,
})
: t("plugins.manager.noServerAccess")}
</span>
{plugin.installed ? (
<span className="flex shrink-0 items-center gap-1 text-[11px] font-medium text-accent-brand">
<Check className="size-3" />
{t("plugins.manager.installedBadge")}
</span>
) : (
<Button
size="xs"
disabled={busy || manager.managedByServer || !plugin.latestVersion}
onClick={() => manager.requestInstall(plugin)}
>
{t("plugins.manager.install")}
</Button>
)}
</div>
</Card>
);
}
function UpdatesSection({
plugins,
manager,
onOpen,
}: {
plugins: PluginEntry[];
manager: PluginsManager;
onOpen: (id: string) => void;
}) {
const { t } = useTranslation();
const pinned = plugins.filter((p) => p.pinnedVersion);
const open = plugins.filter((p) => !p.pinnedVersion);
const gated = open.filter((p) => p.addedCapabilities.length > 0);
const plain = open.filter((p) => p.addedCapabilities.length === 0);
if (plugins.length === 0) {
return (
<div className={PANEL.body}>
<EmptyState
icon={Check}
title={t("plugins.manager.upToDate")}
hint={
manager.lastCheckedAt
? t("plugins.manager.lastChecked", {
time: new Date(manager.lastCheckedAt).toLocaleString(),
})
: undefined
}
/>
</div>
);
}
return (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
{plain.length > 0 && (
<Card className="flex-row items-center gap-3 px-3 py-2.5">
<span className="flex-1 text-xs text-muted-foreground">
{t("plugins.manager.updatesReady", { count: plain.length })}
{gated.length > 0 &&
` ${t("plugins.manager.updatesNeedReview", { count: gated.length })}`}
</span>
<Button
size="sm"
className="gap-1.5"
disabled={manager.busy.size > 0 || manager.managedByServer}
onClick={() => void manager.updateAll()}
>
<Download className="size-3.5" />
{t("plugins.manager.updateAll")}
</Button>
</Card>
)}
<div className="flex flex-col gap-3">
{[...gated, ...plain, ...pinned].map((p) => (
<UpdateRow
key={p.id}
plugin={p}
manager={manager}
onOpen={() => onOpen(p.id)}
/>
))}
</div>
</div>
);
}
function UpdateRow({
plugin,
manager,
onOpen,
}: {
plugin: PluginEntry;
manager: PluginsManager;
onOpen: () => void;
}) {
const { t } = useTranslation();
const gated = plugin.addedCapabilities.length > 0;
const busy = manager.busy.has(plugin.id) || manager.busy.has("*");
const notes = plugin.versions.find((v) => v.version === plugin.latestVersion);
return (
<Card className="gap-0 overflow-hidden py-0">
<div className="flex flex-wrap items-start gap-3 p-3">
<PluginIconBox name={plugin.icon} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
onClick={onOpen}
className="truncate text-sm font-semibold hover:text-accent-brand"
>
{plugin.name}
</button>
<span className="shrink-0 text-[11px] text-muted-foreground">
{t("plugins.manager.versionChange", {
from: plugin.version,
to: plugin.latestVersion,
})}
</span>
</div>
{plugin.pinnedVersion ? (
<span className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion })}
</span>
) : gated ? (
<span className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.updateAsksMore")}
</span>
) : null}
{notes?.releaseNotesUrl && (
<a
href={notes.releaseNotesUrl}
target="_blank"
rel="noreferrer"
className="w-fit text-[11px] text-muted-foreground hover:text-accent-brand"
>
{t("plugins.manager.releaseNotes")}
</a>
)}
</div>
<div className="flex shrink-0 items-center gap-3">
<label className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground">
{t("plugins.manager.auto")}
</span>
<FakeSwitch
checked={plugin.autoUpdate}
disabled={
busy || manager.managedByServer || !!plugin.pinnedVersion
}
onChange={(v) =>
void manager.setOptions(plugin, { autoUpdate: v })
}
/>
</label>
{!plugin.pinnedVersion && (
<Button
size="xs"
disabled={busy || manager.managedByServer}
onClick={() => void manager.requestUpdate(plugin)}
>
{gated
? t("plugins.manager.review")
: t("plugins.manager.update")}
</Button>
)}
</div>
</div>
</Card>
);
}
+2
View File
@@ -0,0 +1,2 @@
/** Asks the shell to open the Plugins tab. */
export const OPEN_PLUGINS_EVENT = "termix:open-plugins";
+76
View File
@@ -0,0 +1,76 @@
import { useTranslation } from "react-i18next";
import { PluginIcon } from "@/lib/plugin-icon";
import { cn } from "@/lib/utils";
import { capabilityRisk, isSevere } from "./plugin-model";
export function PluginIconBox({
name,
size = "md",
muted,
}: {
name?: string;
size?: "sm" | "md" | "lg";
muted?: boolean;
}) {
const box = size === "lg" ? "size-10" : size === "sm" ? "size-7" : "size-9";
const glyph =
size === "lg" ? "size-5" : size === "sm" ? "size-3.5" : "size-4";
return (
<div
className={cn(
box,
"flex shrink-0 items-center justify-center border border-border bg-muted",
muted && "opacity-40",
)}
>
<PluginIcon name={name} className={cn(glyph, "text-accent-brand")} />
</div>
);
}
/** One capability as its consequence, with a mark only on the severe ones. */
export function CapabilityRow({
capability,
showId,
className,
}: {
capability: string;
showId?: boolean;
className?: string;
}) {
const { t } = useTranslation();
const severe = isSevere(capability);
return (
<div className={cn("flex items-start gap-2.5", className)}>
<span className="mt-0.5 flex size-4 shrink-0 items-center justify-center">
{severe && (
<span
className="flex size-4 items-center justify-center border border-destructive/50 bg-destructive/10 text-[10px] font-bold text-destructive"
title={t(`plugins.manager.risk.${capabilityRisk(capability)}`)}
>
!
</span>
)}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-xs font-medium leading-snug">
{t(`plugins.capabilities.${capability}.title`, {
defaultValue: capability,
nsSeparator: false,
})}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{t(`plugins.capabilities.${capability}.consequence`, {
defaultValue: "",
nsSeparator: false,
})}
</span>
</div>
{showId && (
<span className="shrink-0 text-[10px] text-muted-foreground/50">
{capability}
</span>
)}
</div>
);
}
+232
View File
@@ -0,0 +1,232 @@
import {
CAPABILITY_RISK_ORDER,
getCapabilityInfo,
type CapabilityRisk,
} from "@termix-ssh/plugin-sdk/capabilities";
import type {
PluginSummary,
RegistryPluginEntry,
RegistryPluginVersion,
} from "@/api/plugins-api";
export type PluginStatus = "running" | "disabled" | "failed" | "blocked";
/** One row of the Plugins tab: what is installed merged with the registry. */
export interface PluginEntry {
id: string;
name: string;
description: string;
author: string;
category: string;
icon?: string;
repository?: string;
installed: boolean;
version: string | null;
latestVersion: string | null;
updateAvailable: boolean;
addedCapabilities: string[];
versions: RegistryPluginVersion[];
capabilities: string[];
status: PluginStatus | null;
enabled: boolean;
lastError: string | null;
autoUpdate: boolean;
pinnedVersion: string | null;
bundled: boolean;
inRegistry: boolean;
contributes: Record<string, unknown> | null;
publishedAt: string | null;
}
export function pluginStatus(summary: PluginSummary): PluginStatus {
if (summary.state === "failed") return "failed";
if (summary.state === "blocked") return "blocked";
if (!summary.enabled) return "disabled";
return "running";
}
export function mergePlugins(
installed: PluginSummary[],
registry: RegistryPluginEntry[] | null,
): PluginEntry[] {
const byId = new Map((registry ?? []).map((entry) => [entry.id, entry]));
const rows: PluginEntry[] = [];
for (const summary of installed) {
const entry = byId.get(summary.id);
byId.delete(summary.id);
rows.push({
id: summary.id,
name: summary.name,
description: summary.description ?? entry?.description ?? "",
author: summary.author ?? entry?.author ?? "",
category: entry?.category ?? "",
icon: summary.icon ?? entry?.icon,
repository: summary.repository ?? entry?.repository,
installed: true,
version: summary.version,
latestVersion: entry?.latestVersion ?? null,
updateAvailable: entry?.updateAvailable ?? false,
addedCapabilities: entry?.addedCapabilities ?? [],
versions: entry?.versions ?? [],
capabilities: summary.capabilities ?? [],
status: pluginStatus(summary),
enabled: summary.enabled,
lastError: summary.lastError ?? null,
autoUpdate: summary.autoUpdate ?? entry?.autoUpdate ?? false,
pinnedVersion: summary.pinnedVersion ?? entry?.pinnedVersion ?? null,
bundled: entry?.bundled ?? summary.tier === "bundled",
inRegistry: Boolean(entry),
contributes: (summary.contributes as Record<string, unknown>) ?? null,
publishedAt: latestPublished(entry?.versions ?? []),
});
}
for (const entry of byId.values()) {
const latest =
entry.versions.find((v) => v.version === entry.latestVersion) ??
entry.versions[0];
rows.push({
id: entry.id,
name: entry.name,
description: entry.description,
author: entry.author,
category: entry.category,
icon: entry.icon,
repository: entry.repository,
installed: false,
version: null,
latestVersion: entry.latestVersion,
updateAvailable: false,
addedCapabilities: [],
versions: entry.versions,
capabilities: latest?.capabilities ?? [],
status: null,
enabled: false,
lastError: null,
autoUpdate: false,
pinnedVersion: null,
bundled: entry.bundled,
inRegistry: true,
contributes: null,
publishedAt: latestPublished(entry.versions),
});
}
return rows;
}
function latestPublished(versions: RegistryPluginVersion[]): string | null {
const dates = versions
.map((v) => v.publishedAt)
.filter((d): d is string => Boolean(d))
.sort();
return dates.at(-1) ?? null;
}
export type SortKey = "name" | "updated" | "category";
export function sortPlugins(rows: PluginEntry[], key: SortKey): PluginEntry[] {
const sorted = [...rows];
if (key === "updated") {
sorted.sort((a, b) =>
(b.publishedAt ?? "").localeCompare(a.publishedAt ?? ""),
);
} else if (key === "category") {
sorted.sort(
(a, b) =>
a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
);
} else {
sorted.sort((a, b) => a.name.localeCompare(b.name));
}
return sorted;
}
export function matchesQuery(row: PluginEntry, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
return (
row.name.toLowerCase().includes(q) ||
row.description.toLowerCase().includes(q) ||
row.id.includes(q)
);
}
export function categoriesOf(rows: PluginEntry[]): string[] {
return [...new Set(rows.map((row) => row.category).filter(Boolean))].sort();
}
export function capabilityRisk(capability: string): CapabilityRisk {
return getCapabilityInfo(capability)?.risk ?? "medium";
}
/** Worst first, the order the consent copy is read in. */
export function orderByRisk(capabilities: string[]): string[] {
return [...capabilities].sort(
(a, b) =>
CAPABILITY_RISK_ORDER.indexOf(capabilityRisk(a)) -
CAPABILITY_RISK_ORDER.indexOf(capabilityRisk(b)),
);
}
export function isSevere(capability: string): boolean {
const risk = capabilityRisk(capability);
return risk === "critical" || risk === "high";
}
/** Contribution kinds worth naming in "Adds to Termix", with a count each. */
const CONTRIBUTION_KINDS = [
"tabs",
"panels",
"dashboardCards",
"actions",
"protocols",
"auth",
"settings",
"hostCapability",
"permissions",
"keybindingActions",
"syncEntities",
] as const;
export type ContributionKind = (typeof CONTRIBUTION_KINDS)[number];
export function describeContributions(
contributes: Record<string, unknown> | null,
): Array<{ kind: ContributionKind; count: number }> {
if (!contributes) return [];
const result: Array<{ kind: ContributionKind; count: number }> = [];
for (const kind of CONTRIBUTION_KINDS) {
const value = contributes[kind];
if (value === undefined || value === null || value === false) continue;
if (Array.isArray(value)) {
if (value.length > 0) result.push({ kind, count: value.length });
continue;
}
if (kind === "auth" && typeof value === "object") {
const auth = value as {
loginMethods?: unknown[];
secondFactors?: unknown[];
};
const count =
(auth.loginMethods?.length ?? 0) + (auth.secondFactors?.length ?? 0);
if (count > 0) result.push({ kind, count });
continue;
}
result.push({ kind, count: 1 });
}
return result;
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let value = bytes / 1024;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unit]}`;
}
+455
View File
@@ -0,0 +1,455 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import semver from "semver";
import {
deletePluginData,
getPluginRegistry,
getPlugins,
installPlugin,
previewPluginState,
retryPlugin,
setPluginOptions,
setPluginState,
uninstallPlugin,
updateAllPlugins,
updatePlugin,
type PluginSummary,
type RegistryListing,
} from "@/api/plugins-api";
import { useConfirm } from "@/components/surface/surface-scope";
import { mergePlugins, type PluginEntry } from "./plugin-model";
import type { ConsentRequest } from "./PluginConsentPrompt";
interface ApiError {
message: string;
code?: string;
data: Record<string, unknown>;
}
function readError(error: unknown, fallback: string): ApiError {
const data = (error as { response?: { data?: Record<string, unknown> } })
?.response?.data;
if (data && typeof data === "object") {
return {
message: typeof data.error === "string" ? data.error : fallback,
code: typeof data.code === "string" ? data.code : undefined,
data,
};
}
return {
message: error instanceof Error ? error.message : fallback,
data: {},
};
}
export function usePluginsManager() {
const { t } = useTranslation();
const confirm = useConfirm();
const [installed, setInstalled] = useState<PluginSummary[]>([]);
const [registry, setRegistry] = useState<RegistryListing | null>(null);
const [registryError, setRegistryError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [busy, setBusy] = useState<Set<string>>(new Set());
const [consent, setConsent] = useState<ConsentRequest | null>(null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const load = useCallback(async (refresh = false) => {
if (refresh) setRefreshing(true);
const [plugins, listing] = await Promise.allSettled([
getPlugins(),
getPluginRegistry(refresh),
]);
if (!mounted.current) return;
if (plugins.status === "fulfilled") setInstalled(plugins.value);
if (listing.status === "fulfilled") {
setRegistry(listing.value);
setRegistryError(listing.value.registry.error);
} else {
setRegistryError(readError(listing.reason, "").message || "unreachable");
}
setLoading(false);
setRefreshing(false);
}, []);
useEffect(() => {
void load();
}, [load]);
const plugins = useMemo(
() => mergePlugins(installed, registry?.plugins ?? null),
[installed, registry],
);
const run = useCallback(
async (id: string, action: () => Promise<void>) => {
setBusy((current) => new Set(current).add(id));
try {
await action();
} finally {
if (mounted.current) {
setBusy((current) => {
const next = new Set(current);
next.delete(id);
return next;
});
}
await load();
}
},
[load],
);
const failToast = useCallback(
(error: unknown, key: string, name: string) => {
const info = readError(error, t("plugins.manager.errors.generic"));
if (info.code === "MANAGED_BY_SERVER") {
toast.error(t("plugins.manager.errors.managedByServer"));
} else if (info.code === "HAS_DEPENDENTS") {
toast.error(
t("plugins.manager.errors.hasDependents", {
name,
list: ((info.data.dependents as string[]) ?? []).join(", "),
}),
);
} else if (info.code === "MISSING_DEPENDENCIES") {
toast.error(
t("plugins.manager.errors.missingDependencies", {
name,
list: ((info.data.missing as string[]) ?? []).join(", "),
}),
);
} else {
toast.error(t(key, { name, error: info.message }));
}
},
[t],
);
const versionCapabilities = (plugin: PluginEntry, version: string) =>
plugin.versions.find((v) => v.version === version)?.capabilities ?? [];
const requestInstall = useCallback(
(plugin: PluginEntry, version?: string) => {
const target = version ?? plugin.latestVersion;
if (!target) return;
setConsent({
plugin,
mode: "install",
version: target,
capabilities: versionCapabilities(plugin, target),
});
},
[],
);
const doInstall = useCallback(
(plugin: PluginEntry, version: string) =>
run(plugin.id, async () => {
try {
const result = await installPlugin(
plugin.id,
version === plugin.latestVersion ? undefined : version,
);
if (result.state === "failed" || result.state === "blocked") {
toast.error(
t("plugins.manager.toast.installedNotRunning", {
name: plugin.name,
}),
);
} else {
toast.success(
t("plugins.manager.toast.installed", { name: plugin.name }),
);
}
} catch (error) {
failToast(error, "plugins.manager.errors.install", plugin.name);
}
}),
[run, t, failToast],
);
const doUpdate = useCallback(
(plugin: PluginEntry, version: string, acceptCapabilities: boolean) =>
run(plugin.id, async () => {
try {
await updatePlugin(plugin.id, {
version: version === plugin.latestVersion ? undefined : version,
acceptCapabilities,
});
toast.success(
t("plugins.manager.toast.updated", {
name: plugin.name,
version,
}),
);
} catch (error) {
const info = readError(error, "");
if (info.code === "CAPABILITIES_ADDED") {
setConsent({
plugin,
mode: "update",
version,
capabilities: (info.data.capabilities as string[]) ?? [],
});
return;
}
failToast(error, "plugins.manager.errors.update", plugin.name);
}
}),
[run, t, failToast],
);
/** Updates, or changes to another version. New capabilities ask first. */
const requestUpdate = useCallback(
async (plugin: PluginEntry, version?: string) => {
const target = version ?? plugin.latestVersion;
if (!target || !plugin.version) return;
if (
semver.valid(target) &&
semver.valid(plugin.version) &&
semver.lt(target, plugin.version)
) {
const ok = await confirm({
title: t("plugins.manager.confirm.downgradeTitle", {
name: plugin.name,
version: target,
}),
description: t("plugins.manager.confirm.downgradeBody"),
confirmLabel: t("plugins.manager.confirm.downgradeConfirm"),
destructive: false,
});
if (!ok) return;
}
const have = new Set(plugin.capabilities);
const added = versionCapabilities(plugin, target).filter(
(c) => !have.has(c),
);
if (added.length > 0) {
setConsent({
plugin,
mode: "update",
version: target,
capabilities: added,
});
return;
}
await doUpdate(plugin, target, false);
},
[confirm, t, doUpdate],
);
const confirmConsent = useCallback(async () => {
if (!consent) return;
const request = consent;
setConsent(null);
if (request.mode === "install") {
await doInstall(request.plugin, request.version);
} else {
await doUpdate(request.plugin, request.version, true);
}
}, [consent, doInstall, doUpdate]);
const nameOf = useCallback(
(id: string) => plugins.find((p) => p.id === id)?.name ?? id,
[plugins],
);
const toggle = useCallback(
async (plugin: PluginEntry) => {
const enable = !plugin.enabled;
try {
const preview = await previewPluginState(plugin.id, enable);
if (enable && preview.missing.length > 0) {
toast.error(
t("plugins.manager.errors.missingDependencies", {
name: plugin.name,
list: preview.missing.join(", "),
}),
);
return;
}
const others = enable ? preview.enable : preview.disable;
if (others.length > 0) {
const ok = await confirm({
title: enable
? t("plugins.manager.confirm.enableAlsoTitle", {
name: plugin.name,
})
: t("plugins.manager.confirm.disableAlsoTitle", {
name: plugin.name,
}),
description: enable
? t("plugins.manager.confirm.enableAlsoBody", {
list: others.map(nameOf).join(", "),
})
: t("plugins.manager.confirm.disableAlsoBody", {
list: others.map(nameOf).join(", "),
}),
confirmLabel: enable
? t("plugins.manager.enable")
: t("plugins.manager.disable"),
destructive: !enable,
});
if (!ok) return;
}
} catch (error) {
failToast(error, "plugins.manager.errors.state", plugin.name);
return;
}
await run(plugin.id, async () => {
try {
const result = await setPluginState(plugin.id, enable);
if (result.state === "failed" || result.state === "blocked") {
toast.error(
t("plugins.manager.toast.failedToStart", { name: plugin.name }),
);
}
} catch (error) {
failToast(error, "plugins.manager.errors.state", plugin.name);
}
});
},
[confirm, t, run, failToast, nameOf],
);
const retry = useCallback(
(plugin: PluginEntry) =>
run(plugin.id, async () => {
try {
await retryPlugin(plugin.id);
} catch (error) {
failToast(error, "plugins.manager.errors.retry", plugin.name);
}
}),
[run, failToast],
);
const uninstall = useCallback(
async (plugin: PluginEntry): Promise<boolean> => {
const ok = await confirm({
title: t("plugins.manager.confirm.uninstallTitle", {
name: plugin.name,
}),
description: t("plugins.manager.confirm.uninstallBody"),
confirmLabel: t("plugins.manager.uninstall"),
});
if (!ok) return false;
let done = false;
await run(plugin.id, async () => {
try {
await uninstallPlugin(plugin.id);
done = true;
toast.success(
t("plugins.manager.toast.uninstalled", { name: plugin.name }),
);
} catch (error) {
failToast(error, "plugins.manager.errors.uninstall", plugin.name);
}
});
return done;
},
[confirm, t, run, failToast],
);
const removeData = useCallback(
async (plugin: PluginEntry): Promise<boolean> => {
const ok = await confirm({
title: t("plugins.manager.confirm.deleteDataTitle", {
name: plugin.name,
}),
description: t("plugins.manager.confirm.deleteDataBody"),
confirmLabel: t("plugins.manager.data.delete"),
});
if (!ok) return false;
let done = false;
await run(plugin.id, async () => {
try {
await deletePluginData(plugin.id);
done = true;
toast.success(
t("plugins.manager.toast.dataDeleted", { name: plugin.name }),
);
} catch (error) {
failToast(error, "plugins.manager.errors.deleteData", plugin.name);
}
});
return done;
},
[confirm, t, run, failToast],
);
const setOptions = useCallback(
(
plugin: PluginEntry,
options: { autoUpdate?: boolean; pinned?: boolean },
) =>
run(plugin.id, async () => {
try {
await setPluginOptions(plugin.id, options);
} catch (error) {
failToast(error, "plugins.manager.errors.options", plugin.name);
}
}),
[run, failToast],
);
const updateAll = useCallback(
() =>
run("*", async () => {
try {
const result = await updateAllPlugins();
if (result.updated.length > 0) {
toast.success(
t("plugins.manager.toast.updatedMany", {
count: result.updated.length,
}),
);
}
for (const failed of result.failed) {
toast.error(
t("plugins.manager.errors.update", {
name: nameOf(failed.id),
error: failed.error,
}),
);
}
} catch (error) {
failToast(error, "plugins.manager.errors.updateAll", "");
}
}),
[run, t, failToast, nameOf],
);
return {
plugins,
loading,
refreshing,
registryError,
managedByServer: registry?.managedByServer ?? false,
lastCheckedAt: registry?.registry.lastCheckedAt ?? null,
busy,
consent,
setConsent,
refresh: () => load(true),
requestInstall,
requestUpdate,
confirmConsent,
toggle,
retry,
uninstall,
removeData,
setOptions,
updateAll,
};
}
export type PluginsManager = ReturnType<typeof usePluginsManager>;
+1
View File
@@ -26,6 +26,7 @@ const CORE_UNSAVED = new Set([
"user-profile",
"admin-settings",
"split-screen",
"plugins",
]);
/**
+9
View File
@@ -2,6 +2,7 @@
import {
LayoutDashboard,
LayoutPanelLeft,
Puzzle,
LibraryBig,
Server,
Settings,
@@ -30,6 +31,9 @@ const ManageTab = lazy(() =>
const SettingsTab = lazy(() =>
import("@/settings/SettingsTab").then((m) => ({ default: m.SettingsTab })),
);
const PluginsTab = lazy(() =>
import("@/plugins/PluginsTab").then((m) => ({ default: m.PluginsTab })),
);
const DashboardTab = lazy(() =>
import("@/dashboard/DashboardTab").then((m) => ({
default: m.DashboardTab,
@@ -105,6 +109,8 @@ export function tabIcon(type: TabType) {
return <Settings className="size-3.5" />;
case "split-screen":
return <LayoutPanelLeft className="size-3.5" />;
case "plugins":
return <Puzzle className="size-3.5" />;
default: {
const Icon = getTabType(type)?.icon;
return Icon ? <Icon className="size-3.5" /> : null;
@@ -236,6 +242,9 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
case "host-manager":
return withTabSuspense(<ManageTab />);
case "plugins":
return withTabSuspense(<PluginsTab />);
case "settings":
return context.settings
? withTabSuspense(
+10
View File
@@ -4,6 +4,7 @@ import {
Plug,
Server,
LibraryBig,
Puzzle,
Settings,
Zap,
type LucideIcon,
@@ -118,6 +119,15 @@ export const RAIL_ITEMS: RailItemDef[] = [
group: "system",
useBadge: useSyncAttentionCount,
},
{
id: "plugins",
icon: Puzzle,
labelKey: "nav.plugins",
kind: "tab",
placement: "footer",
group: "system",
permission: "admin.plugins.manage",
},
];
/** Main rail items split into their bands, in band order, empty bands left out. */
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { PluginConsentPrompt } from "@/plugins/PluginConsentPrompt";
import { mergePlugins } from "@/plugins/plugin-model";
afterEach(cleanup);
const [plugin] = mergePlugins(
[],
[
{
id: "docker",
name: "Docker",
description: "",
author: "Termix",
category: "",
versions: [],
latestVersion: "1.0.0",
installed: false,
installedVersion: null,
updateAvailable: false,
addedCapabilities: [],
pinnedVersion: null,
autoUpdate: false,
bundled: false,
},
],
);
describe("PluginConsentPrompt", () => {
it("lists notable capabilities and folds the low risk ones", () => {
render(
<PluginConsentPrompt
request={{
plugin,
mode: "install",
version: "1.0.0",
capabilities: ["kv:own", "ssh:connect", "ui:surface"],
}}
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
expect(
screen.getByText("plugins.capabilities.ssh:connect.title"),
).toBeTruthy();
expect(screen.queryByText("plugins.capabilities.kv:own.title")).toBeNull();
expect(screen.getByText("plugins.manager.consent.minor")).toBeTruthy();
});
it("confirms and cancels", () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<PluginConsentPrompt
request={{
plugin,
mode: "update",
version: "1.1.0",
capabilities: ["notify:send"],
}}
onCancel={onCancel}
onConfirm={onConfirm}
/>,
);
expect(
screen.getByText("plugins.manager.consent.updateExplain"),
).toBeTruthy();
fireEvent.click(screen.getByText("plugins.manager.consent.agreeUpdate"));
fireEvent.click(screen.getByText("common.cancel"));
expect(onConfirm).toHaveBeenCalledOnce();
expect(onCancel).toHaveBeenCalledOnce();
});
it("renders nothing without a request", () => {
const { container } = render(
<PluginConsentPrompt
request={null}
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
expect(container.innerHTML).toBe("");
});
});
+39
View File
@@ -0,0 +1,39 @@
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import i18next from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import { CapabilityRow } from "@/plugins/plugin-bits";
afterEach(cleanup);
describe("CapabilityRow", () => {
it("resolves capability ids that contain a colon", async () => {
const i18n = i18next.createInstance();
await i18n.use(initReactI18next).init({
lng: "en",
resources: {
en: {
translation: {
plugins: {
capabilities: {
"users:impersonate": {
title: "Act as other users",
consequence: "It can do anything a user can.",
},
},
},
},
},
},
});
render(
<I18nextProvider i18n={i18n}>
<CapabilityRow capability="users:impersonate" />
</I18nextProvider>,
);
expect(screen.getByText("Act as other users")).toBeTruthy();
expect(screen.getByText("It can do anything a user can.")).toBeTruthy();
});
});
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, it } from "vitest";
import type { PluginSummary, RegistryPluginEntry } from "@/api/plugins-api";
import {
categoriesOf,
describeContributions,
formatBytes,
matchesQuery,
mergePlugins,
orderByRisk,
pluginStatus,
sortPlugins,
} from "@/plugins/plugin-model";
function summary(overrides: Partial<PluginSummary> = {}): PluginSummary {
return {
id: "docker",
name: "Docker",
version: "1.0.0",
enabled: true,
state: "active",
contributes: null,
capabilities: ["kv:own"],
...overrides,
};
}
function entry(
overrides: Partial<RegistryPluginEntry> = {},
): RegistryPluginEntry {
return {
id: "docker",
name: "Docker",
description: "Containers",
author: "Termix",
category: "Infrastructure",
versions: [
{
version: "1.1.0",
compatible: true,
capabilities: ["kv:own", "ssh:connect"],
publishedAt: "2026-10-02",
size: 1,
},
],
latestVersion: "1.1.0",
installed: true,
installedVersion: "1.0.0",
updateAvailable: true,
addedCapabilities: ["ssh:connect"],
pinnedVersion: null,
autoUpdate: false,
bundled: true,
...overrides,
};
}
describe("mergePlugins", () => {
it("joins an installed plugin with its registry entry", () => {
const [row] = mergePlugins([summary()], [entry()]);
expect(row).toMatchObject({
installed: true,
version: "1.0.0",
latestVersion: "1.1.0",
updateAvailable: true,
addedCapabilities: ["ssh:connect"],
capabilities: ["kv:own"],
category: "Infrastructure",
inRegistry: true,
status: "running",
});
});
it("lists registry plugins that are not installed with the latest capabilities", () => {
const rows = mergePlugins(
[],
[entry({ installed: false, installedVersion: null })],
);
expect(rows[0]).toMatchObject({
installed: false,
version: null,
status: null,
capabilities: ["kv:own", "ssh:connect"],
});
});
it("keeps installed plugins when the registry is unreachable", () => {
const rows = mergePlugins([summary()], null);
expect(rows).toHaveLength(1);
expect(rows[0].inRegistry).toBe(false);
});
});
describe("pluginStatus", () => {
it("maps loader and stored states", () => {
expect(pluginStatus(summary({ state: "failed" }))).toBe("failed");
expect(pluginStatus(summary({ state: "blocked" }))).toBe("blocked");
expect(pluginStatus(summary({ enabled: false, state: "disabled" }))).toBe(
"disabled",
);
expect(pluginStatus(summary())).toBe("running");
});
});
describe("sorting and filtering", () => {
const rows = mergePlugins(
[],
[
entry({ id: "b", name: "Beta", category: "Monitoring" }),
entry({
id: "a",
name: "Alpha",
category: "Access",
description: "Sign in",
versions: [
{
version: "1.0.0",
compatible: true,
capabilities: [],
publishedAt: "2026-10-05",
size: 1,
},
],
}),
],
);
it("sorts by name, date and category", () => {
expect(sortPlugins(rows, "name").map((r) => r.id)).toEqual(["a", "b"]);
expect(sortPlugins(rows, "updated").map((r) => r.id)).toEqual(["a", "b"]);
expect(sortPlugins(rows, "category").map((r) => r.id)).toEqual(["a", "b"]);
});
it("searches name, description and id", () => {
expect(
rows.filter((r) => matchesQuery(r, "sign")).map((r) => r.id),
).toEqual(["a"]);
expect(rows.filter((r) => matchesQuery(r, " ")).length).toBe(2);
});
it("collects categories", () => {
expect(categoriesOf(rows)).toEqual(["Access", "Monitoring"]);
});
});
describe("helpers", () => {
it("orders capabilities worst first", () => {
expect(orderByRisk(["kv:own", "hosts:write", "credentials:read"])).toEqual([
"credentials:read",
"hosts:write",
"kv:own",
]);
});
it("counts contributions", () => {
expect(
describeContributions({
tabs: [{}, {}],
settings: { admin: [] },
auth: { loginMethods: [{}], secondFactors: [] },
panels: [],
}),
).toEqual([
{ kind: "tabs", count: 2 },
{ kind: "auth", count: 1 },
{ kind: "settings", count: 1 },
]);
});
it("formats sizes", () => {
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(2048)).toBe("2.0 KB");
expect(formatBytes(50 * 1024 * 1024)).toBe("50 MB");
});
});
+10
View File
@@ -63,9 +63,19 @@ describe("RAIL_ITEMS", () => {
"connections",
"quick-connect",
"sync",
"plugins",
]);
});
it("opens the Plugins tab only for plugin managers", () => {
const plugins = RAIL_ITEMS.find((item) => item.id === "plugins");
expect(plugins).toMatchObject({
kind: "tab",
placement: "footer",
permission: "admin.plugins.manage",
});
});
it("exposes every visible rail item as hideable", () => {
expect(hideableRailIds()).toEqual(
visibleRailItems().map((item) => item.id),