mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
v26.10.1 (#1599)
* feat: AI translations at release, 7 new languages and regional fallback * fix: proper plural forms for count strings and full translations * fix: plural forms for selected, hidden and active counts * fix: synced plugin updates failing on desktop and server-only plugins shown as stopped * fix: restore rounded corners on app icons and favicon * feat: desktop app auto updates with automatic, ask first or never modes * feat: community and custom plugin registries * feat: plugin guide for AI tools in the SDK with a lint check * fix: keep the terminal WebSocket open past nginx's 60s default (#1588) * chore: update changelog * feat: show plugin download counts only and stop CI builds inflating them * feat: hide installed plugins in the browse tab by default * chore: pin bundled plugins to latest releases * chore: update translations * fix: focusing a saved password field and saving no longer erases it * fix: tell desktop users when their 2.8 data needs 2.9 first * fix: point windows update info at the x64 installer * fix: keep desktop client tunnel sockets alive behind proxies * chore: pin bundled plugins to latest releases * fix: cover client tunnel keepalive in the c2s runtime tests * fix: typing after opening a host no longer goes to the host search * fix: accept tab and = separators in SSH config import (#1598) * chore: update changelog * chore: drop plugin versions from core changelog * chore: update translations * chore: lint, format, and bump version to 26.10.1 --------- Co-authored-by: Darnel Hunter <68697450+innotelinc@users.noreply.github.com> Co-authored-by: Iain <Iain1970@protonmail.com>
This commit is contained in:
174 files changed
+94334
-39995
No files matched your search
@@ -11,6 +11,7 @@ import {
|
||||
Lightbulb,
|
||||
Pin,
|
||||
RotateCcw,
|
||||
Server,
|
||||
Settings,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
@@ -30,8 +31,9 @@ import { getPluginData, type PluginDataSummary } from "@/api/plugins-api";
|
||||
import {
|
||||
BetaBadge,
|
||||
CapabilityRow,
|
||||
InstallCountFact,
|
||||
DownloadCount,
|
||||
PluginIconBox,
|
||||
SourceBadge,
|
||||
UnverifiedBadge,
|
||||
} from "./plugin-bits";
|
||||
import {
|
||||
@@ -107,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 ? (
|
||||
@@ -122,10 +128,7 @@ export function PluginDetail({
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<InstallCountFact
|
||||
count={plugin.installCount}
|
||||
source={plugin.installCountSource}
|
||||
/>
|
||||
<DownloadCount count={plugin.downloads} />
|
||||
</Facts>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col gap-1.5">
|
||||
@@ -225,6 +228,15 @@ export function PluginDetail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{plugin.status === "server" && (
|
||||
<div className="flex items-start gap-2 border border-border bg-muted/30 px-3 py-2.5">
|
||||
<Server className="mt-px size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-[11px] leading-snug text-muted-foreground">
|
||||
{t("plugins.manager.runsOnServerHint")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
|
||||
<div className="flex flex-col gap-2 lg:col-span-2">
|
||||
<PluginVideo videoId={plugin.videoId} name={plugin.name} />
|
||||
|
||||
+121
-31
@@ -6,6 +6,7 @@ import {
|
||||
Columns3,
|
||||
Download,
|
||||
Globe,
|
||||
Library,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
@@ -40,10 +41,12 @@ import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
BetaBadge,
|
||||
InstallCountFact,
|
||||
DownloadCount,
|
||||
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>
|
||||
);
|
||||
@@ -483,7 +494,10 @@ function InstalledSection({
|
||||
sort,
|
||||
);
|
||||
const running = rows.filter((p) => p.status === "running");
|
||||
const stopped = rows.filter((p) => p.status !== "running");
|
||||
const onServer = rows.filter((p) => p.status === "server");
|
||||
const stopped = rows.filter(
|
||||
(p) => p.status !== "running" && p.status !== "server",
|
||||
);
|
||||
const pending = plugins.filter((p) => p.updateAvailable && !p.pinnedVersion);
|
||||
|
||||
const listColumns: DataColumn<PluginEntry>[] = [
|
||||
@@ -587,6 +601,14 @@ function InstalledSection({
|
||||
{collection(running)}
|
||||
</Group>
|
||||
)}
|
||||
{onServer.length > 0 && (
|
||||
<Group
|
||||
title={t("plugins.manager.groups.onServer")}
|
||||
count={onServer.length}
|
||||
>
|
||||
{collection(onServer)}
|
||||
</Group>
|
||||
)}
|
||||
{stopped.length > 0 && (
|
||||
<Group
|
||||
title={t("plugins.manager.groups.notRunning")}
|
||||
@@ -693,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>
|
||||
@@ -717,7 +743,9 @@ function InstalledCard({
|
||||
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
|
||||
<span className="size-1.5 shrink-0 bg-muted-foreground/40" />
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.status.disabled")}
|
||||
{plugin.status === "server"
|
||||
? t("plugins.manager.status.server")
|
||||
: t("plugins.manager.status.disabled")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
@@ -806,11 +834,13 @@ function StatusCell({ plugin }: { plugin: PluginEntry }) {
|
||||
? t("plugins.manager.status.blocked")
|
||||
: failed
|
||||
? t("plugins.manager.status.failed")
|
||||
: !running
|
||||
? t("plugins.manager.status.disabled")
|
||||
: plugin.pinnedVersion
|
||||
? t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion })
|
||||
: t("plugins.manager.status.running");
|
||||
: plugin.status === "server"
|
||||
? t("plugins.manager.status.server")
|
||||
: !running
|
||||
? t("plugins.manager.status.disabled")
|
||||
: plugin.pinnedVersion
|
||||
? t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion })
|
||||
: t("plugins.manager.status.running");
|
||||
return (
|
||||
<span
|
||||
className="flex min-w-0 items-center gap-1.5 text-muted-foreground"
|
||||
@@ -920,18 +950,34 @@ 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 [installedChoice, setInstalledChoice] = useStoredChoice(
|
||||
"pluginsTab.browseInstalled",
|
||||
["hide", "show"],
|
||||
"hide",
|
||||
);
|
||||
const showInstalled = installedChoice === "show";
|
||||
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) =>
|
||||
(showInstalled || !p.installed) &&
|
||||
matchesQuery(p, query) &&
|
||||
(category === null || p.category === category),
|
||||
(category === null || p.category === category) &&
|
||||
(registryFilter === null || p.registryId === registryFilter),
|
||||
),
|
||||
sort,
|
||||
);
|
||||
|
||||
const allInstalledHidden =
|
||||
!showInstalled && plugins.length > 0 && plugins.every((p) => p.installed);
|
||||
|
||||
const listColumns: DataColumn<PluginEntry>[] = [
|
||||
{
|
||||
key: "name",
|
||||
@@ -956,16 +1002,13 @@ function BrowseSection({
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "installs",
|
||||
header: t("plugins.manager.table.installs"),
|
||||
key: "downloads",
|
||||
header: t("plugins.manager.table.downloads"),
|
||||
width: "110px",
|
||||
hideBelow: "lg",
|
||||
cell: (p) => (
|
||||
<span className="text-muted-foreground">
|
||||
<InstallCountFact
|
||||
count={p.installCount}
|
||||
source={p.installCountSource}
|
||||
/>
|
||||
<DownloadCount count={p.downloads} />
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -996,6 +1039,44 @@ function BrowseSection({
|
||||
placeholder={t("plugins.manager.searchPlugins")}
|
||||
>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("plugins.manager.showInstalled")}
|
||||
</span>
|
||||
<FakeSwitch
|
||||
checked={showInstalled}
|
||||
onChange={(v) => setInstalledChoice(v ? "show" : "hide")}
|
||||
/>
|
||||
</label>
|
||||
{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>
|
||||
@@ -1022,15 +1103,23 @@ function BrowseSection({
|
||||
)}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Puzzle}
|
||||
title={t("plugins.manager.nothingHere")}
|
||||
hint={
|
||||
plugins.length === 0
|
||||
? undefined
|
||||
: t("plugins.manager.nothingHereHint")
|
||||
}
|
||||
/>
|
||||
allInstalledHidden ? (
|
||||
<EmptyState
|
||||
icon={Puzzle}
|
||||
title={t("plugins.manager.allInstalled")}
|
||||
hint={t("plugins.manager.allInstalledHint")}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Puzzle}
|
||||
title={t("plugins.manager.nothingHere")}
|
||||
hint={
|
||||
plugins.length === 0
|
||||
? undefined
|
||||
: t("plugins.manager.nothingHereHint")
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<PluginCollection
|
||||
plugins={rows}
|
||||
@@ -1048,7 +1137,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>
|
||||
@@ -1076,13 +1165,14 @@ 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>}
|
||||
<InstallCountFact
|
||||
count={plugin.installCount}
|
||||
source={plugin.installCountSource}
|
||||
/>
|
||||
<DownloadCount count={plugin.downloads} />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
@@ -100,31 +126,16 @@ export function BetaBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Active installs when the registry has them, release downloads otherwise. */
|
||||
export function InstallCountFact({
|
||||
count,
|
||||
source,
|
||||
}: {
|
||||
count: number | null;
|
||||
source: string | null;
|
||||
}) {
|
||||
/** How often the plugin's release files were downloaded. */
|
||||
export function DownloadCount({ count }: { count: number | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (count === null || count <= 0) return null;
|
||||
const active = source === "aggregate-telemetry";
|
||||
return (
|
||||
<span
|
||||
title={
|
||||
active
|
||||
? t("plugins.manager.count.activeHint")
|
||||
: t("plugins.manager.count.downloadsHint")
|
||||
}
|
||||
>
|
||||
{t(
|
||||
active
|
||||
? "plugins.manager.count.active"
|
||||
: "plugins.manager.count.downloads",
|
||||
{ count, formatted: formatCount(count) },
|
||||
)}
|
||||
<span title={t("plugins.manager.count.downloadsHint")}>
|
||||
{t("plugins.manager.count.downloads", {
|
||||
count,
|
||||
formatted: formatCount(count),
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
normalizeGitHubRepo,
|
||||
} from "@/lib/issue-url";
|
||||
|
||||
export type PluginStatus = "running" | "disabled" | "failed" | "blocked";
|
||||
export type PluginStatus =
|
||||
"running" | "disabled" | "failed" | "blocked" | "server";
|
||||
|
||||
/** One row of the Plugins tab: what is installed merged with the registry. */
|
||||
export interface PluginEntry {
|
||||
@@ -61,14 +62,17 @@ export interface PluginEntry {
|
||||
inRegistry: boolean;
|
||||
contributes: Record<string, unknown> | null;
|
||||
publishedAt: string | null;
|
||||
installCount: number | null;
|
||||
installCountSource: string | null;
|
||||
downloads: number | null;
|
||||
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(
|
||||
@@ -96,10 +100,14 @@ 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 {
|
||||
if (summary.runsOnServer) return "server";
|
||||
if (summary.state === "failed") return "failed";
|
||||
if (summary.state === "blocked") return "blocked";
|
||||
if (!summary.enabled) return "disabled";
|
||||
@@ -136,7 +144,10 @@ export function mergePlugins(
|
||||
latestBeta: entry?.latestBeta ?? null,
|
||||
channel: summary.channel ?? entry?.channel ?? "stable",
|
||||
isBeta: isPrerelease(summary.version),
|
||||
updateAvailable: entry?.updateAvailable ?? false,
|
||||
// The server keeps its own copy up to date.
|
||||
updateAvailable: summary.runsOnServer
|
||||
? false
|
||||
: (entry?.updateAvailable ?? false),
|
||||
addedCapabilities: entry?.addedCapabilities ?? [],
|
||||
versions: entry?.versions ?? [],
|
||||
capabilities: summary.capabilities ?? [],
|
||||
@@ -149,10 +160,11 @@ export function mergePlugins(
|
||||
inRegistry: Boolean(entry),
|
||||
contributes: (summary.contributes as Record<string, unknown>) ?? null,
|
||||
publishedAt: latestPublished(entry?.versions ?? []),
|
||||
installCount: entry?.installCount ?? null,
|
||||
installCountSource: entry?.installCountSource ?? null,
|
||||
downloads: entry?.downloads ?? null,
|
||||
source: pluginSource(summary),
|
||||
unverified: pluginSource(summary) === "unverified",
|
||||
registryId: entry?.registryId ?? summary.registryId ?? null,
|
||||
registryName: entry?.registryName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -191,10 +203,11 @@ export function mergePlugins(
|
||||
inRegistry: true,
|
||||
contributes: null,
|
||||
publishedAt: latestPublished(entry.versions),
|
||||
installCount: entry.installCount ?? null,
|
||||
installCountSource: entry.installCountSource ?? null,
|
||||
source: "official",
|
||||
downloads: entry.downloads ?? null,
|
||||
source: entry.tier ?? "official",
|
||||
unverified: false,
|
||||
registryId: entry.registryId ?? "official",
|
||||
registryName: entry.registryName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,10 +243,11 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
|
||||
inRegistry: false,
|
||||
contributes: null,
|
||||
publishedAt: null,
|
||||
installCount: null,
|
||||
installCountSource: null,
|
||||
downloads: null,
|
||||
source: "unverified",
|
||||
unverified: true,
|
||||
registryId: null,
|
||||
registryName: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,7 +266,7 @@ export function sortPlugins(rows: PluginEntry[], key: SortKey): PluginEntry[] {
|
||||
if (key === "popular") {
|
||||
sorted.sort(
|
||||
(a, b) =>
|
||||
(b.installCount ?? -1) - (a.installCount ?? -1) ||
|
||||
(b.downloads ?? -1) - (a.downloads ?? -1) ||
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
} else if (key === "updated") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user