* 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:
authored and GitHub committed 2026-10-12 11:35:48 -05:00
1 parent 6873bc92bb
commit 62ac931468
174 files changed
+94334 -39995

No files matched your search

+17 -5
View File
@@ -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
View File
@@ -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} />
</>
}
/>
+390
View File
@@ -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>
);
}
+33 -22
View File
@@ -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>
);
}
+28 -14
View File
@@ -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") {
+3
View File
@@ -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,