mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: plugin filters by type, paged browse, hide plugins needing newer termix
This commit is contained in:
1 parent
556f0406aa
commit
9d93fb0893
14 files changed
+1069
-223
No files matched your search
@@ -34,6 +34,7 @@ import {
|
||||
} from "./paths.js";
|
||||
import {
|
||||
OFFICIAL_REGISTRY_ID,
|
||||
canRunRelease,
|
||||
downloadRelease,
|
||||
fetchRegistryIndex,
|
||||
channelReleases,
|
||||
@@ -42,6 +43,7 @@ import {
|
||||
getRegistryStatus,
|
||||
isApiCompatible,
|
||||
latestOnChannel,
|
||||
neededTermix,
|
||||
officialSource,
|
||||
parsePluginChannel,
|
||||
type PluginChannel,
|
||||
@@ -257,10 +259,13 @@ export interface RegistryListing {
|
||||
videoId?: string;
|
||||
features: string[];
|
||||
docs?: string;
|
||||
contributes: string[];
|
||||
versions: Array<{
|
||||
version: string;
|
||||
prerelease: boolean;
|
||||
compatible: boolean;
|
||||
/** The Termix range this version needs, when this build is too old. */
|
||||
needsTermix: string | null;
|
||||
capabilities: string[];
|
||||
publishedAt?: string;
|
||||
releaseNotesUrl?: string;
|
||||
@@ -396,7 +401,7 @@ export async function listRegistry(
|
||||
const channel = channelOf(record);
|
||||
const latest = latestOnChannel(plugin, channel);
|
||||
const latestBeta =
|
||||
plugin.prereleases.find((entry) => isApiCompatible(entry.api)) ?? null;
|
||||
plugin.prereleases.find((entry) => canRunRelease(entry)) ?? null;
|
||||
const installedVersion =
|
||||
live?.manifest.version ?? record?.version ?? null;
|
||||
const updateAvailable = Boolean(
|
||||
@@ -419,10 +424,12 @@ export async function listRegistry(
|
||||
videoId: plugin.videoId,
|
||||
features: plugin.features,
|
||||
docs: plugin.docs,
|
||||
contributes: plugin.contributes,
|
||||
versions: channelReleases(plugin, "beta").map((entry) => ({
|
||||
version: entry.version,
|
||||
prerelease: Boolean(entry.prerelease),
|
||||
compatible: isApiCompatible(entry.api),
|
||||
compatible: canRunRelease(entry),
|
||||
needsTermix: neededTermix(entry),
|
||||
capabilities: entry.capabilities,
|
||||
publishedAt: entry.publishedAt,
|
||||
releaseNotesUrl: entry.releaseNotesUrl,
|
||||
@@ -902,9 +909,12 @@ async function requireRelease(
|
||||
"NOT_IN_REGISTRY",
|
||||
);
|
||||
}
|
||||
if (!isApiCompatible(found.release.api)) {
|
||||
if (!canRunRelease(found.release)) {
|
||||
const termix = neededTermix(found.release);
|
||||
throw new PluginManageError(
|
||||
`${pluginId} ${found.release.version} does not run on this version of Termix`,
|
||||
termix
|
||||
? `${pluginId} ${found.release.version} needs Termix ${termix}`
|
||||
: `${pluginId} ${found.release.version} does not run on this version of Termix`,
|
||||
409,
|
||||
"INCOMPATIBLE",
|
||||
);
|
||||
@@ -1502,6 +1512,14 @@ function validateUpload(buffer: Buffer): PluginManifest {
|
||||
"INCOMPATIBLE",
|
||||
);
|
||||
}
|
||||
const termix = neededTermix({ engine: manifest.engine });
|
||||
if (termix) {
|
||||
throw new PluginManageError(
|
||||
`${manifest.id} ${manifest.version} needs Termix ${termix}`,
|
||||
409,
|
||||
"INCOMPATIBLE",
|
||||
);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,14 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import semver from "semver";
|
||||
import {
|
||||
isApiCompatible as satisfiesPluginApi,
|
||||
isTermixCompatible,
|
||||
pluginDocsUrl,
|
||||
pluginFeatures,
|
||||
SUPPORTED_PLUGIN_API_VERSION,
|
||||
youtubeVideoId,
|
||||
} from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { getLocalVersion } from "../utils/app-version.js";
|
||||
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import {
|
||||
@@ -59,7 +62,10 @@ const ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
export interface RegistryVersion {
|
||||
version: string;
|
||||
/** The API major, all that builds before engine ranges read. */
|
||||
api: string;
|
||||
/** The manifest's engine ranges, when the index lists them. */
|
||||
engine?: { api: string; termix: string };
|
||||
url: string;
|
||||
sha256: string;
|
||||
signature: string;
|
||||
@@ -92,6 +98,8 @@ export interface RegistryPlugin {
|
||||
features: string[];
|
||||
/** https docs link from the index. */
|
||||
docs?: string;
|
||||
/** The manifest's contributes keys, like layouts, themes or tabs. */
|
||||
contributes: string[];
|
||||
/** Stable releases, newest first. */
|
||||
versions: RegistryVersion[];
|
||||
/**
|
||||
@@ -149,6 +157,9 @@ function parseVersion(raw: unknown): RegistryVersion | null {
|
||||
return null;
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/i.test(sha256)) return null;
|
||||
const engine = entry.engine as Record<string, unknown> | undefined;
|
||||
const engineApi = asString(engine?.api);
|
||||
const engineTermix = asString(engine?.termix);
|
||||
try {
|
||||
if (new URL(url).protocol !== "https:") return null;
|
||||
} catch {
|
||||
@@ -157,6 +168,9 @@ function parseVersion(raw: unknown): RegistryVersion | null {
|
||||
return {
|
||||
version,
|
||||
api: asString(entry.api) ?? "",
|
||||
...(engineApi && engineTermix
|
||||
? { engine: { api: engineApi, termix: engineTermix } }
|
||||
: {}),
|
||||
url,
|
||||
sha256: sha256.toLowerCase(),
|
||||
signature,
|
||||
@@ -191,11 +205,15 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
|
||||
.map(parseVersion)
|
||||
.filter((v): v is RegistryVersion => v !== null)
|
||||
.sort((a, b) => semver.rcompare(a.version, b.version));
|
||||
const versions = parseList(entry.versions).filter(
|
||||
(v) => !semver.prerelease(v.version),
|
||||
);
|
||||
const prereleases = parseList(entry.prereleases)
|
||||
// gated holds releases that need a newer Termix than builds which skip
|
||||
// the engine.termix check, so only builds that check it read the list.
|
||||
const gated = parseList(entry.gated);
|
||||
const versions = [...parseList(entry.versions), ...gated]
|
||||
.filter((v) => !semver.prerelease(v.version))
|
||||
.sort((a, b) => semver.rcompare(a.version, b.version));
|
||||
const prereleases = [...parseList(entry.prereleases), ...gated]
|
||||
.filter((v) => semver.prerelease(v.version))
|
||||
.sort((a, b) => semver.rcompare(a.version, b.version))
|
||||
.map((v) => ({ ...v, prerelease: true }));
|
||||
if (versions.length === 0 && prereleases.length === 0) continue;
|
||||
plugins.push({
|
||||
@@ -209,6 +227,14 @@ export function parseRegistryIndex(raw: unknown): RegistryIndex {
|
||||
videoId: youtubeVideoId(entry.video) ?? undefined,
|
||||
features: pluginFeatures(entry.features),
|
||||
docs: pluginDocsUrl(entry.docs) ?? undefined,
|
||||
contributes: Array.isArray(entry.contributes)
|
||||
? entry.contributes
|
||||
.filter(
|
||||
(kind): kind is string =>
|
||||
typeof kind === "string" && /^[A-Za-z]+$/.test(kind),
|
||||
)
|
||||
.slice(0, 50)
|
||||
: [],
|
||||
versions,
|
||||
prereleases,
|
||||
});
|
||||
@@ -229,6 +255,29 @@ export function isApiCompatible(api: string): boolean {
|
||||
return major !== undefined && String(major) === SUPPORTED_PLUGIN_API_VERSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Termix range a release needs when this build is too old for it, or
|
||||
* null when this build satisfies it.
|
||||
*/
|
||||
export function neededTermix(
|
||||
release: Pick<RegistryVersion, "engine">,
|
||||
): string | null {
|
||||
const range = release.engine?.termix;
|
||||
if (!range) return null;
|
||||
return isTermixCompatible(range, getLocalVersion()) ? null : range;
|
||||
}
|
||||
|
||||
/** Whether this build can run a release: its API and its engine ranges. */
|
||||
export function canRunRelease(
|
||||
release: Pick<RegistryVersion, "api" | "engine">,
|
||||
): boolean {
|
||||
if (!isApiCompatible(release.api)) return false;
|
||||
if (!release.engine) return true;
|
||||
return (
|
||||
satisfiesPluginApi(release.engine.api) && neededTermix(release) === null
|
||||
);
|
||||
}
|
||||
|
||||
/** Follows redirects by hand so each hop still gets the outbound checks. */
|
||||
async function fetchFollowing(url: string): Promise<Response> {
|
||||
let current = url;
|
||||
@@ -427,9 +476,8 @@ export function latestOnChannel(
|
||||
channel: PluginChannel,
|
||||
): RegistryVersion | null {
|
||||
return (
|
||||
channelReleases(plugin, channel).find((entry) =>
|
||||
isApiCompatible(entry.api),
|
||||
) ?? null
|
||||
channelReleases(plugin, channel).find((entry) => canRunRelease(entry)) ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -486,9 +534,12 @@ export async function downloadRelease(
|
||||
release: RegistryVersion,
|
||||
keys: readonly TrustedPluginKey[] = TRUSTED_PLUGIN_KEYS,
|
||||
): Promise<{ file: string; signatureFile: string }> {
|
||||
if (!isApiCompatible(release.api)) {
|
||||
if (!canRunRelease(release)) {
|
||||
const termix = neededTermix(release);
|
||||
throw new Error(
|
||||
`${pluginId} ${release.version} needs plugin API ${release.api}, this build runs ${SUPPORTED_PLUGIN_API_VERSION}`,
|
||||
termix
|
||||
? `${pluginId} ${release.version} needs Termix ${termix}, this is ${getLocalVersion()}`
|
||||
: `${pluginId} ${release.version} needs plugin API ${release.engine?.api ?? release.api}, this build runs ${SUPPORTED_PLUGIN_API_VERSION}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ vi.mock("../../utils/safe-outbound-fetch.js", () => ({
|
||||
safeOutboundFetch: fetchMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/app-version.js", () => ({
|
||||
getLocalVersion: () => "26.10.3",
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/trust.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../plugins/trust.js")>();
|
||||
@@ -44,7 +48,9 @@ import {
|
||||
findRelease,
|
||||
latestOnChannel,
|
||||
getStatsUrl,
|
||||
canRunRelease,
|
||||
isApiCompatible,
|
||||
neededTermix,
|
||||
parseRegistryIndex,
|
||||
parseRegistryStats,
|
||||
resetRegistryCache,
|
||||
@@ -239,6 +245,56 @@ describe("findRelease", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("engine ranges", () => {
|
||||
const engine = (termix: string, api = "^1.0") => ({ api, termix });
|
||||
|
||||
it("reads gated releases into versions and prereleases", () => {
|
||||
const index = parseRegistryIndex({
|
||||
plugins: [
|
||||
{
|
||||
id: "classic-layout",
|
||||
versions: [],
|
||||
gated: [
|
||||
version({ version: "1.1.0-beta.1", engine: engine(">=26.10.3") }),
|
||||
version({ version: "1.0.0", engine: engine(">=26.10.3") }),
|
||||
],
|
||||
contributes: ["layouts", "tabs", "not valid"],
|
||||
},
|
||||
],
|
||||
});
|
||||
const [plugin] = index.plugins;
|
||||
expect(plugin.versions.map((v) => v.version)).toEqual(["1.0.0"]);
|
||||
expect(plugin.prereleases.map((v) => v.version)).toEqual(["1.1.0-beta.1"]);
|
||||
expect(plugin.contributes).toEqual(["layouts", "tabs"]);
|
||||
});
|
||||
|
||||
it("checks engine.termix and the full engine.api", () => {
|
||||
expect(canRunRelease(version({ engine: engine(">=26.10.3") }))).toBe(true);
|
||||
expect(canRunRelease(version({ engine: engine(">=26.11.0") }))).toBe(false);
|
||||
expect(neededTermix(version({ engine: engine(">=26.11.0") }))).toBe(
|
||||
">=26.11.0",
|
||||
);
|
||||
expect(neededTermix(version({ engine: engine(">=26.10.0") }))).toBeNull();
|
||||
expect(
|
||||
canRunRelease(version({ engine: engine(">=26.10.0", "^1.99") })),
|
||||
).toBe(false);
|
||||
expect(canRunRelease(version())).toBe(true);
|
||||
});
|
||||
|
||||
it("skips a release this build is too old for", () => {
|
||||
const index = parseRegistryIndex({
|
||||
plugins: [
|
||||
{
|
||||
id: "docker",
|
||||
versions: [version({ version: "1.0.0" })],
|
||||
gated: [version({ version: "1.1.0", engine: engine(">=26.11.0") })],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(findRelease(index, "docker")?.release.version).toBe("1.0.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prereleases", () => {
|
||||
const index = parseRegistryIndex({
|
||||
plugins: [
|
||||
|
||||
@@ -308,6 +308,8 @@ export interface RegistryPluginVersion {
|
||||
/** A beta, offered only to plugins on the beta channel. */
|
||||
prerelease?: boolean;
|
||||
compatible: boolean;
|
||||
/** The Termix range this version needs, when this build is too old. */
|
||||
needsTermix?: string | null;
|
||||
capabilities: string[];
|
||||
publishedAt?: string;
|
||||
releaseNotesUrl?: string;
|
||||
@@ -332,6 +334,8 @@ export interface RegistryPluginEntry {
|
||||
videoId?: string;
|
||||
features?: string[];
|
||||
docs?: string;
|
||||
/** The manifest's contributes keys, like layouts or themes. */
|
||||
contributes?: string[];
|
||||
versions: RegistryPluginVersion[];
|
||||
/** Newest release on the plugin's channel. */
|
||||
latestVersion: string | null;
|
||||
|
||||
+38
-2
@@ -1490,7 +1490,6 @@
|
||||
"noServerAccess": "No access to your servers",
|
||||
"searchInstalled": "Search installed",
|
||||
"searchPlugins": "Search plugins",
|
||||
"allCategories": "All",
|
||||
"nothingMatches": "Nothing matches that.",
|
||||
"noneInstalled": "No plugins installed yet. Browse to add some.",
|
||||
"nothingHere": "Nothing here.",
|
||||
@@ -1580,7 +1579,44 @@
|
||||
"documentation": "Documentation",
|
||||
"envVars": "Environment variables",
|
||||
"envRequired": "Required",
|
||||
"envDefault": "Default: {{value}}"
|
||||
"envDefault": "Default: {{value}}",
|
||||
"kinds": {
|
||||
"layouts": "Layouts",
|
||||
"themes": "Themes",
|
||||
"tabs": "Tabs",
|
||||
"panels": "Sidebar panels",
|
||||
"dashboardCards": "Dashboard cards",
|
||||
"protocols": "Connection types",
|
||||
"auth": "Sign in methods",
|
||||
"actions": "Actions",
|
||||
"hostCapability": "Host settings"
|
||||
},
|
||||
"filters": {
|
||||
"type": "Type",
|
||||
"allTypes": "All types",
|
||||
"category": "Category",
|
||||
"allCategories": "All categories",
|
||||
"results_one": "{{count}} plugin",
|
||||
"results_other": "{{count}} plugins",
|
||||
"remove": "Remove filter {{name}}",
|
||||
"clear": "Clear filters",
|
||||
"button": "Filters",
|
||||
"buttonCount": "Filters ({{count}})"
|
||||
},
|
||||
"pager": {
|
||||
"range": "{{from}}-{{to}} of {{total}}",
|
||||
"page": "Page {{page}} of {{pages}}",
|
||||
"previous": "Previous page",
|
||||
"next": "Next page",
|
||||
"perPage": "{{count}} per page",
|
||||
"perPageLabel": "Plugins per page"
|
||||
},
|
||||
"showUnsupportedHint": "These are listed but no release runs on this version yet. Update Termix to install them.",
|
||||
"unsupported": "Needs a newer Termix",
|
||||
"unsupportedHint": "No release of this plugin runs on this version of Termix. Update Termix to install it.",
|
||||
"needsTermix": "Needs Termix {{range}}",
|
||||
"showUnsupported": "Show unsupported ({{count}})",
|
||||
"showMore": "Show {{count}} more ({{total}} left)"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
|
||||
@@ -199,6 +199,17 @@ export function PluginDetail({
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : plugin.unsupported ? (
|
||||
<span
|
||||
className="text-xs text-muted-foreground"
|
||||
title={t("plugins.manager.unsupportedHint")}
|
||||
>
|
||||
{plugin.needsTermix
|
||||
? t("plugins.manager.needsTermix", {
|
||||
range: plugin.needsTermix,
|
||||
})
|
||||
: t("plugins.manager.unsupported")}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
+613
-205
@@ -1,12 +1,15 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ArrowDownWideNarrow,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Columns3,
|
||||
Download,
|
||||
Globe,
|
||||
Library,
|
||||
ListFilter,
|
||||
Puzzle,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
@@ -14,6 +17,7 @@ import {
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Card } from "@/components/card";
|
||||
@@ -53,10 +57,13 @@ import {
|
||||
categoriesOf,
|
||||
describeContributions,
|
||||
isSevere,
|
||||
kindsOf,
|
||||
matchesQuery,
|
||||
orderByRisk,
|
||||
pageOf,
|
||||
sortPlugins,
|
||||
type PluginEntry,
|
||||
type PluginKind,
|
||||
type SortKey,
|
||||
} from "./plugin-model";
|
||||
import { usePluginsManager, type PluginsManager } from "./use-plugins-manager";
|
||||
@@ -190,7 +197,7 @@ function PluginsTabBody() {
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{manager.managedByServer && (
|
||||
<Notice>{t("plugins.manager.managedByServer")}</Notice>
|
||||
)}
|
||||
@@ -225,14 +232,18 @@ function PluginsTabBody() {
|
||||
layout={layout}
|
||||
layoutControls={layoutControls}
|
||||
/>
|
||||
) : section === "updates" ? (
|
||||
<UpdatesSection
|
||||
plugins={updates}
|
||||
manager={manager}
|
||||
onOpen={setDetailId}
|
||||
/>
|
||||
) : (
|
||||
<RegistriesSection manager={manager} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
|
||||
{section === "updates" ? (
|
||||
<UpdatesSection
|
||||
plugins={updates}
|
||||
manager={manager}
|
||||
onOpen={setDetailId}
|
||||
/>
|
||||
) : (
|
||||
<RegistriesSection manager={manager} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PanelShell>
|
||||
@@ -313,7 +324,7 @@ function Toolbar({
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-40 max-w-xs flex-1">
|
||||
<div className="relative min-w-40 max-w-md flex-1">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
@@ -470,6 +481,368 @@ function stop(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
interface Facets {
|
||||
kind: PluginKind | null;
|
||||
category: string | null;
|
||||
registry: string | null;
|
||||
}
|
||||
|
||||
const NO_FACETS: Facets = { kind: null, category: null, registry: null };
|
||||
|
||||
/** Rows that pass every facet but `skip`, so a group counts what picking it would show. */
|
||||
function applyFacets(
|
||||
rows: PluginEntry[],
|
||||
facets: Facets,
|
||||
skip?: keyof Facets,
|
||||
): PluginEntry[] {
|
||||
return rows.filter(
|
||||
(p) =>
|
||||
(skip === "kind" || !facets.kind || p.kinds.includes(facets.kind)) &&
|
||||
(skip === "category" ||
|
||||
!facets.category ||
|
||||
p.category === facets.category) &&
|
||||
(skip === "registry" ||
|
||||
!facets.registry ||
|
||||
p.registryId === facets.registry),
|
||||
);
|
||||
}
|
||||
|
||||
function activeFacets(facets: Facets): number {
|
||||
return [facets.kind, facets.category, facets.registry].filter(Boolean).length;
|
||||
}
|
||||
|
||||
const PAGE_SIZES = ["24", "48", "96"] as const;
|
||||
type PageSize = (typeof PAGE_SIZES)[number];
|
||||
|
||||
/** A page number that goes back to 1 whenever what is listed changes. */
|
||||
function usePage(signature: string) {
|
||||
const [state, setState] = useState({ signature, page: 1 });
|
||||
const page = state.signature === signature ? state.page : 1;
|
||||
return [page, (next: number) => setState({ signature, page: next })] as const;
|
||||
}
|
||||
|
||||
function FacetGroup<T extends string>({
|
||||
title,
|
||||
allLabel,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
title: string;
|
||||
allLabel: string;
|
||||
options: Array<{ value: T; label: string; count: number }>;
|
||||
value: T | null;
|
||||
onChange: (next: T | null) => void;
|
||||
}) {
|
||||
if (options.length === 0) return null;
|
||||
const row = (key: T | null, label: string, count?: number) => {
|
||||
const active = value === key;
|
||||
return (
|
||||
<button
|
||||
key={key ?? "__all"}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onChange(key)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 border-l-2 px-2.5 py-1 text-left text-xs transition-colors",
|
||||
active
|
||||
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
|
||||
: "border-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
count === 0 && !active && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{count !== undefined && (
|
||||
<span className="shrink-0 text-[10px] tabular-nums opacity-70">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="px-2.5 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/70">
|
||||
{title}
|
||||
</span>
|
||||
{row(null, allLabel)}
|
||||
{options.map((o) => row(o.value, o.label, o.count))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterRail({
|
||||
rows,
|
||||
facets,
|
||||
onFacets,
|
||||
registries,
|
||||
children,
|
||||
}: {
|
||||
/** What the search and toggles leave, before any facet. */
|
||||
rows: PluginEntry[];
|
||||
facets: Facets;
|
||||
onFacets: (next: Facets) => void;
|
||||
registries?: Array<{ id: string; name: string }>;
|
||||
/** Toggles shown under the groups. */
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const byKind = applyFacets(rows, facets, "kind");
|
||||
const kinds = kindsOf(rows).map(({ kind }) => ({
|
||||
value: kind,
|
||||
label: t(`plugins.manager.kinds.${kind}`),
|
||||
count: byKind.filter((p) => p.kinds.includes(kind)).length,
|
||||
}));
|
||||
const byCategory = applyFacets(rows, facets, "category");
|
||||
const categories = categoriesOf(rows).map((c) => ({
|
||||
value: c,
|
||||
label: c,
|
||||
count: byCategory.filter((p) => p.category === c).length,
|
||||
}));
|
||||
const byRegistry = applyFacets(rows, facets, "registry");
|
||||
const registryOptions =
|
||||
registries && registries.length > 1
|
||||
? registries.map((r) => ({
|
||||
value: r.id,
|
||||
label: r.name,
|
||||
count: byRegistry.filter((p) => p.registryId === r.id).length,
|
||||
}))
|
||||
: [];
|
||||
return (
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<FacetGroup
|
||||
title={t("plugins.manager.filters.type")}
|
||||
allLabel={t("plugins.manager.filters.allTypes")}
|
||||
options={kinds}
|
||||
value={facets.kind}
|
||||
onChange={(kind) => onFacets({ ...facets, kind })}
|
||||
/>
|
||||
<FacetGroup
|
||||
title={t("plugins.manager.filters.category")}
|
||||
allLabel={t("plugins.manager.filters.allCategories")}
|
||||
options={categories}
|
||||
value={facets.category}
|
||||
onChange={(category) => onFacets({ ...facets, category })}
|
||||
/>
|
||||
<FacetGroup
|
||||
title={t("plugins.manager.registries.filter")}
|
||||
allLabel={t("plugins.manager.registries.allRegistries")}
|
||||
options={registryOptions}
|
||||
value={facets.registry}
|
||||
onChange={(registry) => onFacets({ ...facets, registry })}
|
||||
/>
|
||||
{children && (
|
||||
<div className="flex flex-col gap-2.5 border-t border-border px-2.5 pt-3">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RailToggle({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
checked: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center justify-between gap-2" title={hint}>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<FakeSwitch checked={checked} onChange={onChange} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** Chips for the picked facets, so it is clear why the list is short. */
|
||||
function ActiveFilters({
|
||||
facets,
|
||||
onFacets,
|
||||
registries,
|
||||
shown,
|
||||
}: {
|
||||
facets: Facets;
|
||||
onFacets: (next: Facets) => void;
|
||||
registries?: Array<{ id: string; name: string }>;
|
||||
shown: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const chips: Array<{ key: keyof Facets; label: string }> = [];
|
||||
if (facets.kind) {
|
||||
chips.push({
|
||||
key: "kind",
|
||||
label: t(`plugins.manager.kinds.${facets.kind}`),
|
||||
});
|
||||
}
|
||||
if (facets.category) chips.push({ key: "category", label: facets.category });
|
||||
if (facets.registry) {
|
||||
chips.push({
|
||||
key: "registry",
|
||||
label:
|
||||
registries?.find((r) => r.id === facets.registry)?.name ??
|
||||
facets.registry,
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-6 flex-wrap items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.filters.results", { count: shown })}
|
||||
</span>
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={chip.key}
|
||||
type="button"
|
||||
onClick={() => onFacets({ ...facets, [chip.key]: null })}
|
||||
className="flex items-center gap-1 border border-accent-brand/40 bg-accent-brand/10 px-1.5 py-0.5 text-[11px] text-accent-brand hover:bg-accent-brand/20"
|
||||
aria-label={t("plugins.manager.filters.remove", {
|
||||
name: chip.label,
|
||||
})}
|
||||
>
|
||||
{chip.label}
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
))}
|
||||
{chips.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFacets(NO_FACETS)}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t("plugins.manager.filters.clear")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pager({
|
||||
page,
|
||||
pages,
|
||||
total,
|
||||
size,
|
||||
onPage,
|
||||
onSize,
|
||||
}: {
|
||||
page: number;
|
||||
pages: number;
|
||||
total: number;
|
||||
size: PageSize;
|
||||
onPage: (next: number) => void;
|
||||
onSize: (next: PageSize) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const per = Number(size);
|
||||
if (total <= Number(PAGE_SIZES[0])) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-2.5">
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">
|
||||
{t("plugins.manager.pager.range", {
|
||||
from: (page - 1) * per + 1,
|
||||
to: Math.min(page * per, total),
|
||||
total,
|
||||
})}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Select value={size} onValueChange={(v) => onSize(v as PageSize)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-auto text-xs"
|
||||
aria-label={t("plugins.manager.pager.perPageLabel")}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZES.map((n) => (
|
||||
<SelectItem key={n} value={n} className="text-xs">
|
||||
{t("plugins.manager.pager.perPage", { count: Number(n) })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => onPage(page - 1)}
|
||||
title={t("plugins.manager.pager.previous")}
|
||||
aria-label={t("plugins.manager.pager.previous")}
|
||||
>
|
||||
<ChevronLeft className="size-3.5" />
|
||||
</Button>
|
||||
<span className="px-1 text-[11px] tabular-nums text-muted-foreground">
|
||||
{t("plugins.manager.pager.page", { page, pages })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
disabled={page >= pages}
|
||||
onClick={() => onPage(page + 1)}
|
||||
title={t("plugins.manager.pager.next")}
|
||||
aria-label={t("plugins.manager.pager.next")}
|
||||
>
|
||||
<ChevronRight className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters in a rail beside the results. A narrow tab folds the rail behind
|
||||
* a Filters button instead.
|
||||
*/
|
||||
function FilteredLayout({
|
||||
rail,
|
||||
activeCount,
|
||||
scrollRef,
|
||||
children,
|
||||
}: {
|
||||
rail: React.ReactNode;
|
||||
activeCount: number;
|
||||
scrollRef?: React.Ref<HTMLDivElement>;
|
||||
children: (filtersButton: React.ReactNode) => React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const button = (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"gap-1.5 @3xl:hidden",
|
||||
activeCount > 0 && "border-accent-brand/40 text-accent-brand",
|
||||
)}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<ListFilter className="size-3.5" />
|
||||
{activeCount > 0
|
||||
? t("plugins.manager.filters.buttonCount", { count: activeCount })
|
||||
: t("plugins.manager.filters.button")}
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<div className="@container flex min-h-0 flex-1">
|
||||
<aside className="hidden w-52 shrink-0 overflow-y-auto border-r border-border thin-scrollbar @3xl:block">
|
||||
{rail}
|
||||
</aside>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="min-h-0 flex-1 overflow-y-auto thin-scrollbar"
|
||||
>
|
||||
{open && (
|
||||
<div className="border-b border-border @3xl:hidden">{rail}</div>
|
||||
)}
|
||||
{children(button)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledSection({
|
||||
plugins,
|
||||
manager,
|
||||
@@ -489,10 +862,9 @@ function InstalledSection({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [sort, setSort] = useState<SortKey>("name");
|
||||
const rows = sortPlugins(
|
||||
plugins.filter((p) => matchesQuery(p, query)),
|
||||
sort,
|
||||
);
|
||||
const [facets, setFacets] = useState<Facets>(NO_FACETS);
|
||||
const searched = plugins.filter((p) => matchesQuery(p, query));
|
||||
const rows = sortPlugins(applyFacets(searched, facets), sort);
|
||||
const running = rows.filter((p) => p.status === "running");
|
||||
const onServer = rows.filter((p) => p.status === "server");
|
||||
const stopped = rows.filter(
|
||||
@@ -554,72 +926,88 @@ function InstalledSection({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<Toolbar
|
||||
query={query}
|
||||
onQuery={onQuery}
|
||||
placeholder={t("plugins.manager.searchInstalled")}
|
||||
>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SortSelect value={sort} onChange={setSort} />
|
||||
{layoutControls}
|
||||
{manager.developerMode && !manager.signedOnly && (
|
||||
<UploadButton manager={manager} />
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
disabled={
|
||||
pending.length === 0 ||
|
||||
manager.busy.size > 0 ||
|
||||
manager.managedByServer
|
||||
}
|
||||
onClick={() => void manager.updateAll()}
|
||||
<FilteredLayout
|
||||
activeCount={activeFacets(facets)}
|
||||
rail={<FilterRail rows={searched} facets={facets} onFacets={setFacets} />}
|
||||
>
|
||||
{(filtersButton) => (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<Toolbar
|
||||
query={query}
|
||||
onQuery={onQuery}
|
||||
placeholder={t("plugins.manager.searchInstalled")}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
{t("plugins.manager.updateAll")}
|
||||
</Button>
|
||||
</div>
|
||||
</Toolbar>
|
||||
{filtersButton}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SortSelect value={sort} onChange={setSort} />
|
||||
{layoutControls}
|
||||
{manager.developerMode && !manager.signedOnly && (
|
||||
<UploadButton manager={manager} />
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-1.5"
|
||||
disabled={
|
||||
pending.length === 0 ||
|
||||
manager.busy.size > 0 ||
|
||||
manager.managedByServer
|
||||
}
|
||||
onClick={() => void manager.updateAll()}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
{t("plugins.manager.updateAll")}
|
||||
</Button>
|
||||
</div>
|
||||
</Toolbar>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Puzzle}
|
||||
title={
|
||||
plugins.length === 0
|
||||
? t("plugins.manager.noneInstalled")
|
||||
: t("plugins.manager.nothingMatches")
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{running.length > 0 && (
|
||||
<Group
|
||||
title={t("plugins.manager.groups.running")}
|
||||
count={running.length}
|
||||
>
|
||||
{collection(running)}
|
||||
</Group>
|
||||
{activeFacets(facets) > 0 && (
|
||||
<ActiveFilters
|
||||
facets={facets}
|
||||
onFacets={setFacets}
|
||||
shown={rows.length}
|
||||
/>
|
||||
)}
|
||||
{onServer.length > 0 && (
|
||||
<Group
|
||||
title={t("plugins.manager.groups.onServer")}
|
||||
count={onServer.length}
|
||||
>
|
||||
{collection(onServer)}
|
||||
</Group>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Puzzle}
|
||||
title={
|
||||
plugins.length === 0
|
||||
? t("plugins.manager.noneInstalled")
|
||||
: t("plugins.manager.nothingMatches")
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{running.length > 0 && (
|
||||
<Group
|
||||
title={t("plugins.manager.groups.running")}
|
||||
count={running.length}
|
||||
>
|
||||
{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")}
|
||||
count={stopped.length}
|
||||
>
|
||||
{collection(stopped)}
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{stopped.length > 0 && (
|
||||
<Group
|
||||
title={t("plugins.manager.groups.notRunning")}
|
||||
count={stopped.length}
|
||||
>
|
||||
{collection(stopped)}
|
||||
</Group>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FilteredLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -953,31 +1341,46 @@ function BrowseSection({
|
||||
layoutControls: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
const [registry, setRegistry] = useState<string | null>(null);
|
||||
const [facets, setFacets] = useState<Facets>(NO_FACETS);
|
||||
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) &&
|
||||
(registryFilter === null || p.registryId === registryFilter),
|
||||
),
|
||||
sort,
|
||||
const [unsupportedChoice, setUnsupportedChoice] = useStoredChoice(
|
||||
"pluginsTab.browseUnsupported",
|
||||
["hide", "show"],
|
||||
"hide",
|
||||
);
|
||||
const [pageSize, setPageSize] = useStoredChoice<PageSize>(
|
||||
"pluginsTab.pageSize",
|
||||
PAGE_SIZES,
|
||||
"24",
|
||||
);
|
||||
const scroller = useRef<HTMLDivElement>(null);
|
||||
const showInstalled = installedChoice === "show";
|
||||
const showUnsupported = unsupportedChoice === "show";
|
||||
const registries = manager.registries;
|
||||
const unsupportedCount = plugins.filter(
|
||||
(p) => p.unsupported && !p.installed,
|
||||
).length;
|
||||
|
||||
const searched = plugins.filter(
|
||||
(p) =>
|
||||
(showInstalled || !p.installed) &&
|
||||
(showUnsupported || !p.unsupported || p.installed) &&
|
||||
matchesQuery(p, query),
|
||||
);
|
||||
const rows = sortPlugins(applyFacets(searched, facets), sort);
|
||||
const [page, setPage] = usePage(
|
||||
JSON.stringify([query, facets, sort, showInstalled, showUnsupported]),
|
||||
);
|
||||
const paged = pageOf(rows, page, Number(pageSize));
|
||||
const goTo = (next: number) => {
|
||||
setPage(next);
|
||||
if (scroller.current) scroller.current.scrollTop = 0;
|
||||
};
|
||||
|
||||
const allInstalledHidden =
|
||||
!showInstalled && plugins.length > 0 && plugins.every((p) => p.installed);
|
||||
@@ -987,7 +1390,7 @@ function BrowseSection({
|
||||
key: "name",
|
||||
header: t("plugins.manager.table.name"),
|
||||
width: "minmax(0,1.3fr)",
|
||||
cell: (p) => <NameCell plugin={p} />,
|
||||
cell: (p) => <NameCell plugin={p} muted={p.unsupported} />,
|
||||
},
|
||||
{
|
||||
key: "description",
|
||||
@@ -1029,124 +1432,117 @@ function BrowseSection({
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
width: "120px",
|
||||
width: "150px",
|
||||
align: "end",
|
||||
cell: (p) => <BrowseAction plugin={p} manager={manager} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<Toolbar
|
||||
query={query}
|
||||
onQuery={onQuery}
|
||||
placeholder={t("plugins.manager.searchPlugins")}
|
||||
>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<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")}
|
||||
<FilteredLayout
|
||||
activeCount={activeFacets(facets)}
|
||||
scrollRef={scroller}
|
||||
rail={
|
||||
<FilterRail
|
||||
rows={searched}
|
||||
facets={facets}
|
||||
onFacets={setFacets}
|
||||
registries={registries}
|
||||
>
|
||||
<RailToggle
|
||||
label={t("plugins.manager.showInstalled")}
|
||||
checked={showInstalled}
|
||||
onChange={(v) => setInstalledChoice(v ? "show" : "hide")}
|
||||
/>
|
||||
{unsupportedCount > 0 && (
|
||||
<RailToggle
|
||||
label={t("plugins.manager.showUnsupported", {
|
||||
count: unsupportedCount,
|
||||
})}
|
||||
hint={t("plugins.manager.showUnsupportedHint")}
|
||||
checked={showUnsupported}
|
||||
onChange={(v) => setUnsupportedChoice(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>
|
||||
</Toolbar>
|
||||
</FilterRail>
|
||||
}
|
||||
>
|
||||
{(filtersButton) => (
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<Toolbar
|
||||
query={query}
|
||||
onQuery={onQuery}
|
||||
placeholder={t("plugins.manager.searchPlugins")}
|
||||
>
|
||||
{filtersButton}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SortSelect value={sort} onChange={setSort} />
|
||||
{layoutControls}
|
||||
</div>
|
||||
</Toolbar>
|
||||
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{[null, ...categories].map((c) => (
|
||||
<button
|
||||
key={c ?? "__all"}
|
||||
type="button"
|
||||
onClick={() => setCategory(c)}
|
||||
className={cn(
|
||||
"border px-2 py-1 text-[11px] font-medium transition-colors",
|
||||
category === c
|
||||
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
<ActiveFilters
|
||||
facets={facets}
|
||||
onFacets={setFacets}
|
||||
registries={registries}
|
||||
shown={rows.length}
|
||||
/>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
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={paged.items}
|
||||
layout={layout}
|
||||
renderCard={(p) => (
|
||||
<BrowseCard
|
||||
plugin={p}
|
||||
manager={manager}
|
||||
onOpen={() => onOpen(p.id)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{c ?? t("plugins.manager.allCategories")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.length === 0 ? (
|
||||
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}
|
||||
layout={layout}
|
||||
renderCard={(p) => (
|
||||
<BrowseCard
|
||||
plugin={p}
|
||||
manager={manager}
|
||||
onOpen={() => onOpen(p.id)}
|
||||
listColumns={listColumns}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
)}
|
||||
listColumns={listColumns}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.registries.browseHint")}{" "}
|
||||
<DocsLink core="communityRegistry" className="text-[11px]">
|
||||
{t("plugins.manager.communitySubmit")}
|
||||
</DocsLink>
|
||||
</p>
|
||||
</div>
|
||||
<Pager
|
||||
page={paged.page}
|
||||
pages={paged.pages}
|
||||
total={rows.length}
|
||||
size={pageSize}
|
||||
onPage={goTo}
|
||||
onSize={(next) => {
|
||||
setPageSize(next);
|
||||
goTo(1);
|
||||
}}
|
||||
/>
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{t("plugins.manager.registries.browseHint")}{" "}
|
||||
<DocsLink core="communityRegistry" className="text-[11px]">
|
||||
{t("plugins.manager.communitySubmit")}
|
||||
</DocsLink>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</FilteredLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1166,6 +1562,7 @@ function BrowseCard({
|
||||
<Card className="gap-0 overflow-hidden py-0">
|
||||
<CardHead
|
||||
plugin={plugin}
|
||||
muted={plugin.unsupported}
|
||||
onOpen={onOpen}
|
||||
facts={
|
||||
<>
|
||||
@@ -1182,12 +1579,14 @@ function BrowseCard({
|
||||
/>
|
||||
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
|
||||
<span className="min-w-0 flex-1 truncate text-[10px] text-muted-foreground">
|
||||
{severe.length > 0
|
||||
? t(`plugins.capabilities.${severe[0]}.title`, {
|
||||
defaultValue: severe[0],
|
||||
nsSeparator: false,
|
||||
})
|
||||
: t("plugins.manager.noServerAccess")}
|
||||
{plugin.unsupported && !plugin.installed
|
||||
? ""
|
||||
: severe.length > 0
|
||||
? t(`plugins.capabilities.${severe[0]}.title`, {
|
||||
defaultValue: severe[0],
|
||||
nsSeparator: false,
|
||||
})
|
||||
: t("plugins.manager.noServerAccess")}
|
||||
</span>
|
||||
<BrowseAction plugin={plugin} manager={manager} />
|
||||
</div>
|
||||
@@ -1215,6 +1614,15 @@ function BrowseAction({
|
||||
<Check className="size-3" />
|
||||
{t("plugins.manager.installedBadge")}
|
||||
</span>
|
||||
) : plugin.unsupported ? (
|
||||
<span
|
||||
className="shrink-0 text-[11px] text-muted-foreground"
|
||||
title={t("plugins.manager.unsupportedHint")}
|
||||
>
|
||||
{plugin.needsTermix
|
||||
? t("plugins.manager.needsTermix", { range: plugin.needsTermix })
|
||||
: t("plugins.manager.unsupported")}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -73,6 +73,12 @@ export interface PluginEntry {
|
||||
managedByServer: boolean;
|
||||
/** The app is drawn with it by default, so it can be swapped but not uninstalled. */
|
||||
defaultLook: boolean;
|
||||
/** Contribution keys from the manifest or the registry, like layouts. */
|
||||
kinds: string[];
|
||||
/** Listed, but no release runs on this build. */
|
||||
unsupported: boolean;
|
||||
/** The Termix range the newest release needs, when this build is too old. */
|
||||
needsTermix: string | null;
|
||||
}
|
||||
|
||||
export type PluginSource =
|
||||
@@ -171,6 +177,14 @@ export function mergePlugins(
|
||||
registryName: entry?.registryName ?? null,
|
||||
managedByServer: summary.managedByServer ?? false,
|
||||
defaultLook: summary.defaultLook ?? false,
|
||||
kinds: [
|
||||
...new Set([
|
||||
...Object.keys(summary.contributes ?? {}),
|
||||
...(entry?.contributes ?? []),
|
||||
]),
|
||||
],
|
||||
unsupported: false,
|
||||
needsTermix: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -216,6 +230,12 @@ export function mergePlugins(
|
||||
registryName: entry.registryName ?? null,
|
||||
managedByServer: false,
|
||||
defaultLook: false,
|
||||
kinds: entry.contributes ?? [],
|
||||
unsupported: entry.latestVersion === null,
|
||||
needsTermix:
|
||||
entry.latestVersion === null
|
||||
? (entry.versions.find((v) => v.needsTermix)?.needsTermix ?? null)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -258,6 +278,9 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
|
||||
registryName: null,
|
||||
managedByServer: false,
|
||||
defaultLook: false,
|
||||
kinds: [],
|
||||
unsupported: false,
|
||||
needsTermix: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -309,6 +332,46 @@ export function categoriesOf(rows: PluginEntry[]): string[] {
|
||||
return [...new Set(rows.map((row) => row.category).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
/** Contribution keys worth filtering by, in the order the filter lists them. */
|
||||
export const PLUGIN_KINDS = [
|
||||
"layouts",
|
||||
"themes",
|
||||
"tabs",
|
||||
"panels",
|
||||
"dashboardCards",
|
||||
"protocols",
|
||||
"auth",
|
||||
"actions",
|
||||
"hostCapability",
|
||||
] as const;
|
||||
|
||||
export type PluginKind = (typeof PLUGIN_KINDS)[number];
|
||||
|
||||
/** The filterable kinds present in a set of rows, with how many have each. */
|
||||
export function kindsOf(
|
||||
rows: PluginEntry[],
|
||||
): Array<{ kind: PluginKind; count: number }> {
|
||||
return PLUGIN_KINDS.map((kind) => ({
|
||||
kind,
|
||||
count: rows.filter((row) => row.kinds.includes(kind)).length,
|
||||
})).filter((entry) => entry.count > 0);
|
||||
}
|
||||
|
||||
/** One page of rows, with the page clamped to what exists. */
|
||||
export function pageOf<T>(
|
||||
rows: T[],
|
||||
page: number,
|
||||
size: number,
|
||||
): { items: T[]; page: number; pages: number } {
|
||||
const pages = Math.max(1, Math.ceil(rows.length / size));
|
||||
const current = Math.min(Math.max(1, page), pages);
|
||||
return {
|
||||
items: rows.slice((current - 1) * size, current * size),
|
||||
page: current,
|
||||
pages,
|
||||
};
|
||||
}
|
||||
|
||||
export function capabilityRisk(capability: string): CapabilityRisk {
|
||||
return getCapabilityInfo(capability)?.risk ?? "medium";
|
||||
}
|
||||
|
||||
@@ -24,12 +24,31 @@ const plugin = {
|
||||
versions: [],
|
||||
downloads: null,
|
||||
managedByServer: false,
|
||||
kinds: [] as string[],
|
||||
category: "",
|
||||
unsupported: false,
|
||||
needsTermix: null as string | null,
|
||||
};
|
||||
let linked = false;
|
||||
let extra: Array<typeof plugin> = [];
|
||||
|
||||
function listed(
|
||||
id: string,
|
||||
overrides: Partial<typeof plugin> = {},
|
||||
): typeof plugin {
|
||||
return {
|
||||
...plugin,
|
||||
id,
|
||||
name: `Listed ${id}`,
|
||||
installed: false,
|
||||
status: null as never,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("@/plugins/use-plugins-manager", () => ({
|
||||
usePluginsManager: () => ({
|
||||
plugins: [plugin],
|
||||
plugins: [plugin, ...extra],
|
||||
loading: false,
|
||||
refreshing: false,
|
||||
refresh: vi.fn(),
|
||||
@@ -50,6 +69,7 @@ async function renderTab() {
|
||||
await i18n.use(initReactI18next).init({
|
||||
lng: "en",
|
||||
resources: { en: { translation: en } },
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
return render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
@@ -61,6 +81,7 @@ async function renderTab() {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
linked = false;
|
||||
extra = [];
|
||||
plugin.managedByServer = false;
|
||||
});
|
||||
afterEach(cleanup);
|
||||
@@ -92,6 +113,73 @@ describe("PluginsTab", () => {
|
||||
expect(localStorage.getItem("pluginsTab.browseInstalled")).toBe("show");
|
||||
});
|
||||
|
||||
it("filters Browse by what a plugin adds", async () => {
|
||||
extra = [
|
||||
listed("layout", { kinds: ["layouts", "tabs"] }),
|
||||
listed("tabs", { kinds: ["tabs"] }),
|
||||
];
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByText(en.plugins.manager.tabs.browse));
|
||||
expect(screen.getByText("Listed layout")).toBeTruthy();
|
||||
expect(screen.getByText("Listed tabs")).toBeTruthy();
|
||||
|
||||
const [layoutsFilter] = screen.getAllByRole("button", {
|
||||
name: new RegExp(`^${en.plugins.manager.kinds.layouts}`),
|
||||
});
|
||||
fireEvent.click(layoutsFilter);
|
||||
|
||||
expect(screen.getByText("Listed layout")).toBeTruthy();
|
||||
expect(screen.queryByText("Listed tabs")).toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: `Remove filter ${en.plugins.manager.kinds.layouts}`,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByText("Listed tabs")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides plugins this build can't run until shown", async () => {
|
||||
extra = [
|
||||
listed("later", {
|
||||
unsupported: true,
|
||||
needsTermix: ">=26.11.0",
|
||||
latestVersion: null as never,
|
||||
}),
|
||||
];
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByText(en.plugins.manager.tabs.browse));
|
||||
expect(screen.queryByText("Listed later")).toBeNull();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("switch", { name: /Show unsupported \(1\)/ }),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Listed later")).toBeTruthy();
|
||||
expect(screen.getByText("Needs Termix >=26.11.0")).toBeTruthy();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: en.plugins.manager.install }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("pages a long Browse list", async () => {
|
||||
extra = Array.from({ length: 30 }, (_, i) =>
|
||||
listed(`p${String(i).padStart(2, "0")}`),
|
||||
);
|
||||
localStorage.setItem("pluginsTab.view", "list");
|
||||
await renderTab();
|
||||
fireEvent.click(screen.getByText(en.plugins.manager.tabs.browse));
|
||||
|
||||
expect(screen.getAllByText(/^Listed p/)).toHaveLength(24);
|
||||
expect(screen.getByText("1-24 of 30")).toBeTruthy();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: en.plugins.manager.pager.next }),
|
||||
);
|
||||
expect(screen.getAllByText(/^Listed p/)).toHaveLength(6);
|
||||
expect(screen.getByText("Page 2 of 2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches to a table and remembers it", async () => {
|
||||
await renderTab();
|
||||
expect(screen.queryByRole("table")).toBeNull();
|
||||
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
formatBytes,
|
||||
formatCount,
|
||||
groupChanges,
|
||||
kindsOf,
|
||||
matchesQuery,
|
||||
mergeReleaseNotes,
|
||||
mergePlugins,
|
||||
orderByRisk,
|
||||
pageOf,
|
||||
pluginSource,
|
||||
pluginStatus,
|
||||
reportIssueUrl,
|
||||
@@ -501,3 +503,75 @@ describe("betas", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin kinds", () => {
|
||||
it("joins installed contributions with the registry list", () => {
|
||||
const [row] = mergePlugins(
|
||||
[summary({ contributes: { tabs: [{}] } as never })],
|
||||
[entry({ contributes: ["layouts"] })],
|
||||
);
|
||||
expect(row.kinds.sort()).toEqual(["layouts", "tabs"]);
|
||||
});
|
||||
|
||||
it("counts only the kinds worth filtering by", () => {
|
||||
const rows = mergePlugins(
|
||||
[],
|
||||
[
|
||||
entry({ id: "a", installed: false, contributes: ["layouts", "uses"] }),
|
||||
entry({ id: "b", installed: false, contributes: ["layouts", "tabs"] }),
|
||||
],
|
||||
);
|
||||
expect(kindsOf(rows)).toEqual([
|
||||
{ kind: "layouts", count: 2 },
|
||||
{ kind: "tabs", count: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unsupported plugins", () => {
|
||||
it("marks a listed plugin with no release this build runs", () => {
|
||||
const [row] = mergePlugins(
|
||||
[],
|
||||
[
|
||||
entry({
|
||||
installed: false,
|
||||
latestVersion: null,
|
||||
versions: [
|
||||
{
|
||||
version: "1.0.0",
|
||||
compatible: false,
|
||||
needsTermix: ">=26.11.0",
|
||||
capabilities: [],
|
||||
size: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(row.unsupported).toBe(true);
|
||||
expect(row.needsTermix).toBe(">=26.11.0");
|
||||
});
|
||||
|
||||
it("leaves a runnable plugin alone", () => {
|
||||
const [row] = mergePlugins([], [entry({ installed: false })]);
|
||||
expect(row.unsupported).toBe(false);
|
||||
expect(row.needsTermix).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("pageOf", () => {
|
||||
const rows = Array.from({ length: 50 }, (_, i) => i);
|
||||
|
||||
it("slices one page", () => {
|
||||
expect(pageOf(rows, 2, 24)).toEqual({
|
||||
items: rows.slice(24, 48),
|
||||
page: 2,
|
||||
pages: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps a page past the end", () => {
|
||||
expect(pageOf(rows, 9, 24).page).toBe(3);
|
||||
expect(pageOf([], 4, 24)).toEqual({ items: [], page: 1, pages: 1 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user