feat: plugin filters by type, paged browse, hide plugins needing newer termix

This commit is contained in:
LukeGus committed 2026-10-18 20:00:47 -05:00
1 parent 556f0406aa
commit 9d93fb0893
14 files changed
+1069 -223

No files matched your search

+4
View File
@@ -11,6 +11,9 @@
- Restore the bundled copy of a plugin from the repair screen, which also pins the shipped version
- Switching layouts closes the tabs only the old layout could show, like its Settings tab
- A chosen layout whose plugin was uninstalled falls back to the default instead of the repair screen
- Filter plugins by type, like layouts, themes or tabs, from a filter list beside the results
- Browse shows plugins a page at a time
- Plugins that need a newer Termix are hidden in Browse, with a toggle to show them
### Changed
@@ -18,6 +21,7 @@
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own
- A theme that no longer exists shows as System instead of an unstyled page
- Installs and updates from a registry check the Termix version a plugin needs, not only its plugin API
## 26.10.2
+1 -1
View File
@@ -164,7 +164,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
| `icon` | A [Lucide](https://lucide.dev/icons/) icon name, like `Hand`. |
| `features` | Up to 20 short lines shown on the plugin's page. |
| `docs` | An https link to your docs. |
| `engine.termix` | Termix versions you work with, like `>=26.10.0`. |
| `engine.termix` | Termix versions you work with, like `>=26.10.0`. Older Termix builds don't list a release they can't run. |
| `engine.api` | Plugin API range, like `^1.2`. A major bump of the API is breaking. |
| `capabilities` | What your code may do. See the reference at the end. |
| `dependencies`, `optionalDependencies` | Other plugins, `{ "snippets": "^1.0.0" }`. You must keep working without optional ones. |
+27
View File
@@ -276,6 +276,33 @@ describe("pinsFromIndex", () => {
expect(pins[0]).not.toHaveProperty("onboarding");
});
it("pins a release kept in gated", () => {
const pins = pinsFromIndex(
{
plugins: [
{
id: "look",
versions: [],
gated: [
{
version: "1.1.0-beta.1",
url: "https://example.test/look-1.1.0-beta.1.tmxplug",
sha256: "d".repeat(64),
},
{
version: "1.0.0",
url: "https://example.test/look-1.0.0.tmxplug",
sha256: "e".repeat(64),
},
],
},
],
},
null,
);
expect(pins[0].url).toBe("https://example.test/look-1.0.0.tmxplug");
});
it("pins only the ids asked for", () => {
expect(pinsFromIndex(index, ["beta"]).map((pin) => pin.id)).toEqual([
"beta",
+7 -1
View File
@@ -25,6 +25,7 @@
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const semver = require("semver");
const ID = /^[a-z0-9][a-z0-9-]*$/;
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
@@ -251,7 +252,12 @@ function pinsFromIndex(index, ids, current = []) {
const currentById = new Map(current.map((entry) => [entry.id, entry]));
const wanted = ids ?? plugins.map((plugin) => plugin.id);
return [...wanted].sort().map((id) => {
const latest = byId.get(id)?.versions?.[0];
const listed = byId.get(id);
// gated holds releases that need a newer Termix than old builds check for.
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
.filter((entry) => semver.valid(entry.version))
.filter((entry) => !semver.prerelease(entry.version))
.sort((a, b) => semver.rcompare(a.version, b.version))[0];
if (!latest) throw new Error(`${id} is not in the registry index`);
const onboarding = currentById.get(id)?.onboarding;
const defaultLook = currentById.get(id)?.defaultLook === true;
+22 -4
View File
@@ -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;
}
+60 -9
View File
@@ -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: [
+4
View File
@@ -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
View File
@@ -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": {
+11
View File
@@ -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
View File
@@ -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"
+63
View File
@@ -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";
}
+89 -1
View File
@@ -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();
+74
View File
@@ -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 });
});
});