mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: community and custom plugin registries
This commit is contained in:
1 parent
1aa1bde5f7
commit
6a7afa94a0
31 files changed
+2903
-134
No files matched your search
@@ -291,8 +291,14 @@ export interface RegistryPluginVersion {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type RegistryTier = "official" | "community" | "custom";
|
||||
|
||||
export interface RegistryPluginEntry {
|
||||
id: string;
|
||||
/** Which registry this entry comes from. */
|
||||
registryId?: string;
|
||||
registryName?: string;
|
||||
tier?: RegistryTier;
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
@@ -320,6 +326,16 @@ export interface RegistryPluginEntry {
|
||||
installCountSource: string | null;
|
||||
}
|
||||
|
||||
export interface RegistryListingSource {
|
||||
id: string;
|
||||
name: string;
|
||||
tier: RegistryTier;
|
||||
url: string;
|
||||
lastCheckedAt: string | null;
|
||||
error: string | null;
|
||||
plugins: number;
|
||||
}
|
||||
|
||||
export interface RegistryListing {
|
||||
registry: {
|
||||
id: string;
|
||||
@@ -327,6 +343,7 @@ export interface RegistryListing {
|
||||
lastCheckedAt: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
registries?: RegistryListingSource[];
|
||||
managedByServer: boolean;
|
||||
plugins: RegistryPluginEntry[];
|
||||
}
|
||||
@@ -375,7 +392,7 @@ export async function getPluginRegistry(
|
||||
/** `capabilities` is exactly what the consent prompt showed. */
|
||||
export async function installPlugin(
|
||||
pluginId: string,
|
||||
options: { version?: string; capabilities: string[] },
|
||||
options: { version?: string; capabilities: string[]; registryId?: string },
|
||||
): Promise<{ id: string; version: string; state: string }> {
|
||||
try {
|
||||
const response = await rbacApi.post(
|
||||
@@ -407,6 +424,61 @@ export async function updatePlugin(
|
||||
}
|
||||
}
|
||||
|
||||
export interface CustomRegistryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
keyId: string;
|
||||
enabled: boolean;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface RegistriesState {
|
||||
managedByServer: boolean;
|
||||
official: { id: string; url: string };
|
||||
community: { id: string; url: string; enabled: boolean };
|
||||
custom: CustomRegistryInfo[];
|
||||
}
|
||||
|
||||
export interface RegistryPreview {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
keyId: string;
|
||||
plugins: number;
|
||||
}
|
||||
|
||||
export async function getRegistries(): Promise<RegistriesState> {
|
||||
const response = await rbacApi.get("/plugins/registries");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function previewRegistry(url: string): Promise<RegistryPreview> {
|
||||
const response = await rbacApi.post("/plugins/registries/preview", { url });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function addRegistry(
|
||||
url: string,
|
||||
keyId: string,
|
||||
): Promise<CustomRegistryInfo> {
|
||||
const response = await rbacApi.post("/plugins/registries", { url, keyId });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function setRegistryEnabled(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
await rbacApi.patch(`/plugins/registries/${encodeURIComponent(id)}`, {
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeRegistry(id: string): Promise<void> {
|
||||
await rbacApi.delete(`/plugins/registries/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function updateAllPlugins(): Promise<UpdateAllResult> {
|
||||
try {
|
||||
const response = await rbacApi.post("/plugins/update-all");
|
||||
|
||||
@@ -38,5 +38,7 @@
|
||||
"pluginCatalog": "plugins",
|
||||
"cli": "cli",
|
||||
"develop": "develop",
|
||||
"communityRegistry": "develop/community-registry"
|
||||
"communityRegistry": "develop/community-registry",
|
||||
"pluginRegistries": "guide/plugins#registries",
|
||||
"hostRegistry": "develop/registries"
|
||||
}
|
||||
+39
-2
@@ -1899,7 +1899,8 @@
|
||||
"tabs": {
|
||||
"installed": "Installed",
|
||||
"browse": "Browse",
|
||||
"updates": "Updates"
|
||||
"updates": "Updates",
|
||||
"registries": "Registries"
|
||||
},
|
||||
"status": {
|
||||
"summary": "{{installed}} installed, {{running}} running",
|
||||
@@ -2084,8 +2085,44 @@
|
||||
"noneInstalled": "No plugins installed yet. Browse to add some.",
|
||||
"nothingHere": "Nothing here.",
|
||||
"nothingHereHint": "Try another category or search.",
|
||||
"communitySoon": "Community plugins are coming in a later update. Plugin authors can already submit theirs to the community registry.",
|
||||
"communitySubmit": "How to submit",
|
||||
"registries": {
|
||||
"intro": "Registries are lists of plugins you can install. The official one is always on. You can turn on the community registry or add your own.",
|
||||
"hostYourOwn": "Host your own registry",
|
||||
"add": "Add registry",
|
||||
"addTitle": "Add a registry",
|
||||
"addHint": "Paste the link to the registry's index.json. Termix reads it and shows the key it signs plugins with. Only add registries from people you trust.",
|
||||
"urlLabel": "Index URL",
|
||||
"check": "Check",
|
||||
"addConfirm": "Trust and add",
|
||||
"added": "Added {{name}}",
|
||||
"customWarning": "Plugins from this registry are not reviewed by the Termix team. Only the owner of the registry decides what is in it. Every download must be signed by the key above.",
|
||||
"keyFingerprint": "Key {{keyId}}",
|
||||
"pluginCount_one": "{{count}} plugin",
|
||||
"pluginCount_other": "{{count}} plugins",
|
||||
"officialName": "Official",
|
||||
"communityName": "Community",
|
||||
"alwaysOn": "Always on",
|
||||
"communityHint": "Plugins made by other people. Each version is checked by a person and by automated checks before it is listed.",
|
||||
"communityConfirmTitle": "Turn on community plugins?",
|
||||
"communityConfirmBody": "Community plugins are made by other people, not the Termix team. Every version is checked by a person and by automated checks before it is listed, but mistakes can still get through. Only install plugins you trust and check what each one asks for.",
|
||||
"communityConfirm": "Turn on",
|
||||
"communityBadge": "Community",
|
||||
"communityBadgeHint": "From the community registry. Made by other people and reviewed before it was listed.",
|
||||
"customBadge": "Custom",
|
||||
"customBadgeHint": "From {{name}}, a registry an admin added. Not reviewed by the Termix team.",
|
||||
"remove": "Remove",
|
||||
"removeTitle": "Remove {{name}}?",
|
||||
"removeBody": "Its plugins will no longer show in Browse. You can add it again later.",
|
||||
"removed": "Removed {{name}}",
|
||||
"inUse": "Uninstall the plugins from {{name}} first: {{list}}",
|
||||
"changeFailed": "Could not change the registry: {{error}}",
|
||||
"loadFailed": "Could not load registries: {{error}}",
|
||||
"unreachable": "Could not read it: {{error}}",
|
||||
"filter": "Registry",
|
||||
"allRegistries": "All registries",
|
||||
"browseHint": "Turn on community plugins or add your own registry in the Registries tab."
|
||||
},
|
||||
"upToDate": "Everything is up to date.",
|
||||
"lastChecked": "Last checked {{time}}",
|
||||
"updatesReady_one": "{{count}} update ready.",
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
CapabilityRow,
|
||||
InstallCountFact,
|
||||
PluginIconBox,
|
||||
SourceBadge,
|
||||
UnverifiedBadge,
|
||||
} from "./plugin-bits";
|
||||
import {
|
||||
@@ -108,6 +109,10 @@ export function PluginDetail({
|
||||
</span>
|
||||
<Facts className="text-[11px] text-muted-foreground/70">
|
||||
{plugin.unverified && <UnverifiedBadge />}
|
||||
<SourceBadge
|
||||
source={plugin.source}
|
||||
registryName={plugin.registryName}
|
||||
/>
|
||||
{plugin.isBeta && <BetaBadge />}
|
||||
{plugin.author && <span>{plugin.author}</span>}
|
||||
{plugin.version ? (
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Columns3,
|
||||
Download,
|
||||
Globe,
|
||||
Library,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
@@ -42,8 +43,10 @@ import {
|
||||
BetaBadge,
|
||||
InstallCountFact,
|
||||
PluginIconBox,
|
||||
SourceBadge,
|
||||
UnverifiedBadge,
|
||||
} from "./plugin-bits";
|
||||
import { RegistriesSection } from "./RegistriesSection";
|
||||
import { PluginConsentPrompt } from "./PluginConsentPrompt";
|
||||
import { PluginDetail } from "./PluginDetail";
|
||||
import {
|
||||
@@ -60,7 +63,7 @@ import { usePluginsManager, type PluginsManager } from "./use-plugins-manager";
|
||||
import { docsUrl } from "@/lib/docs";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
|
||||
type Section = "installed" | "browse" | "updates";
|
||||
type Section = "installed" | "browse" | "updates" | "registries";
|
||||
|
||||
const GRID_SIZES = ["auto", "2", "3", "4", "5", "6"] as const;
|
||||
type GridSize = (typeof GRID_SIZES)[number];
|
||||
@@ -146,6 +149,11 @@ function PluginsTabBody() {
|
||||
icon: <Download className="size-3" />,
|
||||
count: updates.filter((p) => !p.pinnedVersion).length || undefined,
|
||||
},
|
||||
{
|
||||
id: "registries",
|
||||
label: t("plugins.manager.tabs.registries"),
|
||||
icon: <Library className="size-3" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -217,12 +225,14 @@ function PluginsTabBody() {
|
||||
layout={layout}
|
||||
layoutControls={layoutControls}
|
||||
/>
|
||||
) : (
|
||||
) : section === "updates" ? (
|
||||
<UpdatesSection
|
||||
plugins={updates}
|
||||
manager={manager}
|
||||
onOpen={setDetailId}
|
||||
/>
|
||||
) : (
|
||||
<RegistriesSection manager={manager} />
|
||||
)}
|
||||
</div>
|
||||
</PanelShell>
|
||||
@@ -442,6 +452,7 @@ function NameCell({ plugin, muted }: { plugin: PluginEntry; muted?: boolean }) {
|
||||
{plugin.name}
|
||||
</span>
|
||||
{plugin.unverified && <UnverifiedBadge />}
|
||||
<SourceBadge source={plugin.source} registryName={plugin.registryName} />
|
||||
{plugin.isBeta && <BetaBadge />}
|
||||
</div>
|
||||
);
|
||||
@@ -704,6 +715,10 @@ function InstalledCard({
|
||||
facts={
|
||||
<>
|
||||
{plugin.unverified && <UnverifiedBadge />}
|
||||
<SourceBadge
|
||||
source={plugin.source}
|
||||
registryName={plugin.registryName}
|
||||
/>
|
||||
{plugin.isBeta && <BetaBadge />}
|
||||
{plugin.author && <span>{plugin.author}</span>}
|
||||
<span>v{plugin.version}</span>
|
||||
@@ -935,14 +950,20 @@ function BrowseSection({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
const [registry, setRegistry] = useState<string | null>(null);
|
||||
const [sort, setSort] = useState<SortKey>("popular");
|
||||
const categories = useMemo(() => categoriesOf(plugins), [plugins]);
|
||||
const registries = manager.registries;
|
||||
const registryFilter = registries.some((entry) => entry.id === registry)
|
||||
? registry
|
||||
: null;
|
||||
|
||||
const rows = sortPlugins(
|
||||
plugins.filter(
|
||||
(p) =>
|
||||
matchesQuery(p, query) &&
|
||||
(category === null || p.category === category),
|
||||
(category === null || p.category === category) &&
|
||||
(registryFilter === null || p.registryId === registryFilter),
|
||||
),
|
||||
sort,
|
||||
);
|
||||
@@ -1011,6 +1032,35 @@ function BrowseSection({
|
||||
placeholder={t("plugins.manager.searchPlugins")}
|
||||
>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{registries.length > 1 && (
|
||||
<Select
|
||||
value={registryFilter ?? "__all"}
|
||||
onValueChange={(v) => setRegistry(v === "__all" ? null : v)}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-auto gap-1.5 text-xs"
|
||||
aria-label={t("plugins.manager.registries.filter")}
|
||||
>
|
||||
<Library className="size-3.5 text-muted-foreground" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all" className="text-xs">
|
||||
{t("plugins.manager.registries.allRegistries")}
|
||||
</SelectItem>
|
||||
{registries.map((entry) => (
|
||||
<SelectItem
|
||||
key={entry.id}
|
||||
value={entry.id}
|
||||
className="text-xs"
|
||||
>
|
||||
{entry.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<SortSelect value={sort} onChange={setSort} />
|
||||
{layoutControls}
|
||||
</div>
|
||||
@@ -1063,7 +1113,7 @@ function BrowseSection({
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.communitySoon")}{" "}
|
||||
{t("plugins.manager.registries.browseHint")}{" "}
|
||||
<DocsLink core="communityRegistry" className="text-[11px]">
|
||||
{t("plugins.manager.communitySubmit")}
|
||||
</DocsLink>
|
||||
@@ -1091,6 +1141,10 @@ function BrowseCard({
|
||||
onOpen={onOpen}
|
||||
facts={
|
||||
<>
|
||||
<SourceBadge
|
||||
source={plugin.source}
|
||||
registryName={plugin.registryName}
|
||||
/>
|
||||
{plugin.author && <span>{plugin.author}</span>}
|
||||
{plugin.latestVersion && <span>v{plugin.latestVersion}</span>}
|
||||
{plugin.category && <span>{plugin.category}</span>}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Library, Trash2 } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { FakeSwitch } from "@/components/section-card";
|
||||
import { PANEL } from "@/components/panel-layout";
|
||||
import {
|
||||
AddButton,
|
||||
FormFooter,
|
||||
ListBadge,
|
||||
ListRow,
|
||||
ListRowAction,
|
||||
} from "@/components/list-kit";
|
||||
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
import {
|
||||
addRegistry,
|
||||
getRegistries,
|
||||
previewRegistry,
|
||||
removeRegistry,
|
||||
setRegistryEnabled,
|
||||
type RegistriesState,
|
||||
type RegistryListingSource,
|
||||
type RegistryPreview,
|
||||
} from "@/api/plugins-api";
|
||||
import type { PluginsManager } from "./use-plugins-manager";
|
||||
|
||||
function errorOf(error: unknown): {
|
||||
message: string;
|
||||
code?: string;
|
||||
data: Record<string, unknown>;
|
||||
} {
|
||||
const data = (error as { response?: { data?: Record<string, unknown> } })
|
||||
?.response?.data;
|
||||
return {
|
||||
message:
|
||||
typeof data?.error === "string"
|
||||
? data.error
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error),
|
||||
code: typeof data?.code === "string" ? data.code : undefined,
|
||||
data: data ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
export function RegistriesSection({ manager }: { manager: PluginsManager }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [state, setState] = useState<RegistriesState | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setState(await getRegistries());
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("plugins.manager.registries.loadFailed", {
|
||||
error: errorOf(error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const statusOf = (id: string): RegistryListingSource | undefined =>
|
||||
manager.registries.find((entry) => entry.id === id);
|
||||
|
||||
const toggle = async (id: string, enabled: boolean) => {
|
||||
if (enabled && id === "community") {
|
||||
const ok = await confirm({
|
||||
title: t("plugins.manager.registries.communityConfirmTitle"),
|
||||
description: t("plugins.manager.registries.communityConfirmBody"),
|
||||
confirmLabel: t("plugins.manager.registries.communityConfirm"),
|
||||
destructive: false,
|
||||
});
|
||||
if (!ok) return;
|
||||
}
|
||||
setBusy(id);
|
||||
try {
|
||||
await setRegistryEnabled(id, enabled);
|
||||
await load();
|
||||
await manager.refresh();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
t("plugins.manager.registries.changeFailed", {
|
||||
error: errorOf(error).message,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: string, name: string) => {
|
||||
const ok = await confirm({
|
||||
title: t("plugins.manager.registries.removeTitle", { name }),
|
||||
description: t("plugins.manager.registries.removeBody"),
|
||||
confirmLabel: t("plugins.manager.registries.remove"),
|
||||
});
|
||||
if (!ok) return;
|
||||
setBusy(id);
|
||||
try {
|
||||
await removeRegistry(id);
|
||||
toast.success(t("plugins.manager.registries.removed", { name }));
|
||||
await load();
|
||||
await manager.refresh();
|
||||
} catch (error) {
|
||||
const info = errorOf(error);
|
||||
toast.error(
|
||||
info.code === "REGISTRY_IN_USE"
|
||||
? t("plugins.manager.registries.inUse", {
|
||||
name,
|
||||
list: ((info.data.plugins as string[]) ?? []).join(", "),
|
||||
})
|
||||
: t("plugins.manager.registries.changeFailed", {
|
||||
error: info.message,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const readOnly = manager.managedByServer || state?.managedByServer;
|
||||
|
||||
const meta = (id: string, url: string, extra?: string) => {
|
||||
const status = statusOf(id);
|
||||
const parts = [url];
|
||||
if (status && !status.error) {
|
||||
parts.push(
|
||||
t("plugins.manager.registries.pluginCount", { count: status.plugins }),
|
||||
);
|
||||
}
|
||||
if (extra) parts.push(extra);
|
||||
return parts.join(" | ");
|
||||
};
|
||||
|
||||
const errorLine = (id: string) => {
|
||||
const error = statusOf(id)?.error;
|
||||
return error ? (
|
||||
<span className="text-[11px] leading-snug text-destructive">
|
||||
{t("plugins.manager.registries.unreachable", { error })}
|
||||
</span>
|
||||
) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="min-w-0 flex-1 text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.registries.intro")}{" "}
|
||||
<DocsLink core="hostRegistry" className="text-[11px]">
|
||||
{t("plugins.manager.registries.hostYourOwn")}
|
||||
</DocsLink>
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<AddButton
|
||||
label={t("plugins.manager.registries.add")}
|
||||
onClick={() => setAdding(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{state && (
|
||||
<div className="flex flex-col border-t border-border/40">
|
||||
<ListRow
|
||||
stripe={0}
|
||||
icon={<Library />}
|
||||
title={t("plugins.manager.registries.officialName")}
|
||||
badges={
|
||||
<ListBadge tone="brand">
|
||||
{t("plugins.manager.registries.alwaysOn")}
|
||||
</ListBadge>
|
||||
}
|
||||
meta={meta("official", state.official.url)}
|
||||
>
|
||||
{errorLine("official")}
|
||||
</ListRow>
|
||||
|
||||
<ListRow
|
||||
stripe={1}
|
||||
icon={<Library />}
|
||||
tone={state.community.enabled ? "brand" : "muted"}
|
||||
dimmed={!state.community.enabled}
|
||||
title={t("plugins.manager.registries.communityName")}
|
||||
meta={meta("community", state.community.url)}
|
||||
trailing={
|
||||
<FakeSwitch
|
||||
checked={state.community.enabled}
|
||||
disabled={readOnly || busy === "community"}
|
||||
onChange={(next) => void toggle("community", next)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.registries.communityHint")}{" "}
|
||||
<DocsLink core="communityRegistry" className="text-[11px]">
|
||||
{t("plugins.manager.communitySubmit")}
|
||||
</DocsLink>
|
||||
</span>
|
||||
{state.community.enabled && errorLine("community")}
|
||||
</ListRow>
|
||||
|
||||
{state.custom.map((entry, index) => (
|
||||
<ListRow
|
||||
key={entry.id}
|
||||
stripe={index}
|
||||
icon={<Library />}
|
||||
tone={entry.enabled ? "warning" : "muted"}
|
||||
dimmed={!entry.enabled}
|
||||
title={entry.name}
|
||||
badges={
|
||||
<ListBadge tone="warning">
|
||||
{t("plugins.manager.registries.customBadge")}
|
||||
</ListBadge>
|
||||
}
|
||||
meta={meta(
|
||||
entry.id,
|
||||
entry.url,
|
||||
t("plugins.manager.registries.keyFingerprint", {
|
||||
keyId: entry.keyId,
|
||||
}),
|
||||
)}
|
||||
trailing={
|
||||
<FakeSwitch
|
||||
checked={entry.enabled}
|
||||
disabled={readOnly || busy === entry.id}
|
||||
onChange={(next) => void toggle(entry.id, next)}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
readOnly ? undefined : (
|
||||
<ListRowAction
|
||||
label={t("plugins.manager.registries.remove")}
|
||||
tone="destructive"
|
||||
disabled={busy === entry.id}
|
||||
onClick={() => void remove(entry.id, entry.name)}
|
||||
>
|
||||
<Trash2 />
|
||||
</ListRowAction>
|
||||
)
|
||||
}
|
||||
>
|
||||
{entry.enabled && errorLine(entry.id)}
|
||||
</ListRow>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddRegistryView
|
||||
open={adding}
|
||||
onOpenChange={setAdding}
|
||||
onAdded={async () => {
|
||||
setAdding(false);
|
||||
await load();
|
||||
await manager.refresh();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddRegistryView({
|
||||
open,
|
||||
onOpenChange,
|
||||
onAdded,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdded: () => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [url, setUrl] = useState("");
|
||||
const [preview, setPreview] = useState<RegistryPreview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setUrl("");
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const check = async () => {
|
||||
setWorking(true);
|
||||
setError(null);
|
||||
setPreview(null);
|
||||
try {
|
||||
setPreview(await previewRegistry(url.trim()));
|
||||
} catch (failure) {
|
||||
setError(errorOf(failure).message);
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const add = async () => {
|
||||
if (!preview) return;
|
||||
setWorking(true);
|
||||
setError(null);
|
||||
try {
|
||||
const added = await addRegistry(preview.url, preview.keyId);
|
||||
toast.success(
|
||||
t("plugins.manager.registries.added", { name: added.name }),
|
||||
);
|
||||
await onAdded();
|
||||
} catch (failure) {
|
||||
const info = errorOf(failure);
|
||||
if (info.code === "KEY_MISMATCH") setPreview(null);
|
||||
setError(info.message);
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<InlineView
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t("plugins.manager.registries.addTitle")}
|
||||
icon={<Library className="size-4" />}
|
||||
footer={
|
||||
<FormFooter
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSave={() => void (preview ? add() : check())}
|
||||
saveLabel={
|
||||
preview
|
||||
? t("plugins.manager.registries.addConfirm")
|
||||
: t("plugins.manager.registries.check")
|
||||
}
|
||||
saving={working}
|
||||
disabled={!url.trim()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<p className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.registries.addHint")}
|
||||
</p>
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && url.trim() && !working) {
|
||||
e.preventDefault();
|
||||
void (preview ? add() : check());
|
||||
}
|
||||
}}
|
||||
placeholder="https://example.com/termix/index.json"
|
||||
className="h-8 text-xs"
|
||||
aria-label={t("plugins.manager.registries.urlLabel")}
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-[11px] leading-snug text-destructive">{error}</p>
|
||||
)}
|
||||
{preview && (
|
||||
<div className="flex flex-col gap-1.5 border border-warning/40 bg-warning/5 p-3">
|
||||
<span className="text-sm font-semibold">{preview.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.registries.pluginCount", {
|
||||
count: preview.plugins,
|
||||
})}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.registries.keyFingerprint", {
|
||||
keyId: preview.keyId,
|
||||
})}
|
||||
</span>
|
||||
<p className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.registries.customWarning")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</InlineView>
|
||||
);
|
||||
}
|
||||
@@ -88,6 +88,32 @@ export function UnverifiedBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Marks a plugin from the community registry or a custom one. */
|
||||
export function SourceBadge({
|
||||
source,
|
||||
registryName,
|
||||
}: {
|
||||
source: string;
|
||||
registryName?: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (source !== "community" && source !== "custom") return null;
|
||||
const label =
|
||||
source === "community"
|
||||
? t("plugins.manager.registries.communityBadge")
|
||||
: (registryName ?? t("plugins.manager.registries.customBadge"));
|
||||
return (
|
||||
<span
|
||||
className="max-w-32 shrink-0 truncate border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"
|
||||
title={t(`plugins.manager.registries.${source}BadgeHint`, {
|
||||
name: label,
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function BetaBadge() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
|
||||
@@ -67,9 +67,13 @@ export interface PluginEntry {
|
||||
source: PluginSource;
|
||||
/** Installed from a file or folder with no registry signature behind it. */
|
||||
unverified: boolean;
|
||||
/** The registry it is listed in or was installed from. */
|
||||
registryId: string | null;
|
||||
registryName: string | null;
|
||||
}
|
||||
|
||||
export type PluginSource = "bundled" | "official" | "unverified";
|
||||
export type PluginSource =
|
||||
"bundled" | "official" | "community" | "custom" | "unverified";
|
||||
|
||||
export function isPrerelease(version: string | null | undefined): boolean {
|
||||
return Boolean(
|
||||
@@ -97,7 +101,10 @@ export function pluginSource(summary: PluginSummary): PluginSource {
|
||||
if (summary.source === "bundled" || summary.tier === "bundled") {
|
||||
return "bundled";
|
||||
}
|
||||
return summary.signedBy ? "official" : "unverified";
|
||||
if (!summary.signedBy) return "unverified";
|
||||
return summary.tier === "community" || summary.tier === "custom"
|
||||
? summary.tier
|
||||
: "official";
|
||||
}
|
||||
|
||||
export function pluginStatus(summary: PluginSummary): PluginStatus {
|
||||
@@ -158,6 +165,8 @@ export function mergePlugins(
|
||||
installCountSource: entry?.installCountSource ?? null,
|
||||
source: pluginSource(summary),
|
||||
unverified: pluginSource(summary) === "unverified",
|
||||
registryId: entry?.registryId ?? summary.registryId ?? null,
|
||||
registryName: entry?.registryName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -198,8 +207,10 @@ export function mergePlugins(
|
||||
publishedAt: latestPublished(entry.versions),
|
||||
installCount: entry.installCount ?? null,
|
||||
installCountSource: entry.installCountSource ?? null,
|
||||
source: "official",
|
||||
source: entry.tier ?? "official",
|
||||
unverified: false,
|
||||
registryId: entry.registryId ?? "official",
|
||||
registryName: entry.registryName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -239,6 +250,8 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
|
||||
installCountSource: null,
|
||||
source: "unverified",
|
||||
unverified: true,
|
||||
registryId: null,
|
||||
registryName: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ export function usePluginsManager() {
|
||||
const result = await installPlugin(plugin.id, {
|
||||
version: version === plugin.latestVersion ? undefined : version,
|
||||
capabilities,
|
||||
registryId: plugin.registryId ?? undefined,
|
||||
});
|
||||
if (result.state === "failed" || result.state === "blocked") {
|
||||
toast.error(
|
||||
@@ -585,6 +586,8 @@ export function usePluginsManager() {
|
||||
registryError,
|
||||
managedByServer: registry?.managedByServer ?? false,
|
||||
lastCheckedAt: registry?.registry.lastCheckedAt ?? null,
|
||||
registries: registry?.registries ?? [],
|
||||
reload: () => load(),
|
||||
busy,
|
||||
consent,
|
||||
setConsent,
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock("@/plugins/use-plugins-manager", () => ({
|
||||
confirmConsent: vi.fn(),
|
||||
developerMode: false,
|
||||
signedOnly: false,
|
||||
registries: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -60,12 +61,14 @@ beforeEach(() => localStorage.clear());
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("PluginsTab", () => {
|
||||
it("tells people community plugins are coming and links the guide", async () => {
|
||||
it("points to the Registries tab and links the submit guide", async () => {
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByText(en.plugins.manager.tabs.browse));
|
||||
|
||||
expect(
|
||||
screen.getByText(en.plugins.manager.communitySoon, { exact: false }),
|
||||
screen.getByText(en.plugins.manager.registries.browseHint, {
|
||||
exact: false,
|
||||
}),
|
||||
).toBeTruthy();
|
||||
const link = screen.getByText(en.plugins.manager.communitySubmit);
|
||||
expect(link.getAttribute("href")).toBe(docsUrl("communityRegistry"));
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider, initReactI18next } from "react-i18next";
|
||||
import en from "@/locales/en.json";
|
||||
import { RegistriesSection } from "@/plugins/RegistriesSection";
|
||||
import type { PluginsManager } from "@/plugins/use-plugins-manager";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getRegistries: vi.fn(),
|
||||
previewRegistry: vi.fn(),
|
||||
addRegistry: vi.fn(),
|
||||
setRegistryEnabled: vi.fn(),
|
||||
removeRegistry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/plugins-api", () => api);
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const manager = {
|
||||
registries: [],
|
||||
managedByServer: false,
|
||||
refresh: vi.fn(async () => {}),
|
||||
} as unknown as PluginsManager;
|
||||
|
||||
function state(communityEnabled = false) {
|
||||
return {
|
||||
managedByServer: false,
|
||||
official: { id: "official", url: "https://example.com/official.json" },
|
||||
community: {
|
||||
id: "community",
|
||||
url: "https://example.com/community.json",
|
||||
enabled: communityEnabled,
|
||||
},
|
||||
custom: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function renderSection() {
|
||||
const i18n = i18next.createInstance();
|
||||
await i18n.use(initReactI18next).init({
|
||||
lng: "en",
|
||||
resources: { en: { translation: en } },
|
||||
});
|
||||
return render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<RegistriesSection manager={manager} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(api)) fn.mockReset();
|
||||
api.getRegistries.mockResolvedValue(state());
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("RegistriesSection", () => {
|
||||
it("asks before turning community plugins on", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
await renderSection();
|
||||
const toggle = await screen.findByRole("switch");
|
||||
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() => expect(confirm).toHaveBeenCalled());
|
||||
expect(confirm.mock.calls[0][0]).toContain(
|
||||
en.plugins.manager.registries.communityConfirmBody,
|
||||
);
|
||||
expect(api.setRegistryEnabled).not.toHaveBeenCalled();
|
||||
|
||||
confirm.mockReturnValue(true);
|
||||
api.getRegistries.mockResolvedValue(state(true));
|
||||
fireEvent.click(toggle);
|
||||
await waitFor(() =>
|
||||
expect(api.setRegistryEnabled).toHaveBeenCalledWith("community", true),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not ask when turning community plugins off", async () => {
|
||||
const confirm = vi.spyOn(window, "confirm");
|
||||
api.getRegistries.mockResolvedValue(state(true));
|
||||
await renderSection();
|
||||
fireEvent.click(await screen.findByRole("switch"));
|
||||
await waitFor(() =>
|
||||
expect(api.setRegistryEnabled).toHaveBeenCalledWith("community", false),
|
||||
);
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks a registry before adding it with the key it showed", async () => {
|
||||
api.previewRegistry.mockResolvedValue({
|
||||
id: "acme",
|
||||
name: "Acme Plugins",
|
||||
url: "https://acme.example/index.json",
|
||||
keyId: "abcd1234abcd1234",
|
||||
plugins: 3,
|
||||
});
|
||||
api.addRegistry.mockResolvedValue({
|
||||
id: "acme",
|
||||
name: "Acme Plugins",
|
||||
url: "https://acme.example/index.json",
|
||||
keyId: "abcd1234abcd1234",
|
||||
enabled: true,
|
||||
addedAt: "",
|
||||
});
|
||||
await renderSection();
|
||||
fireEvent.click(await screen.findByText(en.plugins.manager.registries.add));
|
||||
fireEvent.change(
|
||||
screen.getByLabelText(en.plugins.manager.registries.urlLabel),
|
||||
{ target: { value: "https://acme.example/index.json" } },
|
||||
);
|
||||
fireEvent.click(screen.getByText(en.plugins.manager.registries.check));
|
||||
|
||||
expect(await screen.findByText("Acme Plugins")).toBeTruthy();
|
||||
expect(screen.getByText("Key abcd1234abcd1234")).toBeTruthy();
|
||||
expect(api.addRegistry).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByText(en.plugins.manager.registries.addConfirm));
|
||||
await waitFor(() =>
|
||||
expect(api.addRegistry).toHaveBeenCalledWith(
|
||||
"https://acme.example/index.json",
|
||||
"abcd1234abcd1234",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user