diff --git a/CHANGELOG.md b/CHANGELOG.md index a08ddfc41..301be4959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index f2bfb749a..3740019d1 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -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. | diff --git a/scripts/bundled-plugins.test.ts b/scripts/bundled-plugins.test.ts index a5fc8de1b..2d9e3969d 100644 --- a/scripts/bundled-plugins.test.ts +++ b/scripts/bundled-plugins.test.ts @@ -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", diff --git a/scripts/lib/bundled-plugins.cjs b/scripts/lib/bundled-plugins.cjs index 196f060a2..fcb2be13d 100644 --- a/scripts/lib/bundled-plugins.cjs +++ b/scripts/lib/bundled-plugins.cjs @@ -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; diff --git a/src/backend/plugins/manage.ts b/src/backend/plugins/manage.ts index 83de771e7..b87a55cc4 100644 --- a/src/backend/plugins/manage.ts +++ b/src/backend/plugins/manage.ts @@ -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; } diff --git a/src/backend/plugins/registry-index.ts b/src/backend/plugins/registry-index.ts index d52d6af18..db9433d9b 100644 --- a/src/backend/plugins/registry-index.ts +++ b/src/backend/plugins/registry-index.ts @@ -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 | 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, +): 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, +): 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 { 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}`, ); } diff --git a/src/backend/tests/plugins/registry-index.test.ts b/src/backend/tests/plugins/registry-index.test.ts index bba89dfde..d196ec898 100644 --- a/src/backend/tests/plugins/registry-index.test.ts +++ b/src/backend/tests/plugins/registry-index.test.ts @@ -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(); @@ -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: [ diff --git a/src/ui/api/plugins-api.ts b/src/ui/api/plugins-api.ts index 3b512032d..2a102ab07 100644 --- a/src/ui/api/plugins-api.ts +++ b/src/ui/api/plugins-api.ts @@ -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; diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index dc07667dc..e68937cf7 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -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": { diff --git a/src/ui/plugins/PluginDetail.tsx b/src/ui/plugins/PluginDetail.tsx index 51f05b92f..0bf16932f 100644 --- a/src/ui/plugins/PluginDetail.tsx +++ b/src/ui/plugins/PluginDetail.tsx @@ -199,6 +199,17 @@ export function PluginDetail({ )} + ) : plugin.unsupported ? ( + + {plugin.needsTermix + ? t("plugins.manager.needsTermix", { + range: plugin.needsTermix, + }) + : t("plugins.manager.unsupported")} + ) : ( + ); + }; + return ( +
+ + {title} + + {row(null, allLabel)} + {options.map((o) => row(o.value, o.label, o.count))} +
+ ); +} + +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 ( +
+ onFacets({ ...facets, kind })} + /> + onFacets({ ...facets, category })} + /> + onFacets({ ...facets, registry })} + /> + {children && ( +
+ {children} +
+ )} +
+ ); +} + +function RailToggle({ + label, + hint, + checked, + onChange, +}: { + label: string; + hint?: string; + checked: boolean; + onChange: (next: boolean) => void; +}) { + return ( + + ); +} + +/** 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 ( +
+ + {t("plugins.manager.filters.results", { count: shown })} + + {chips.map((chip) => ( + + ))} + {chips.length > 1 && ( + + )} +
+ ); +} + +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 ( +
+ + {t("plugins.manager.pager.range", { + from: (page - 1) * per + 1, + to: Math.min(page * per, total), + total, + })} + +
+ + + + {t("plugins.manager.pager.page", { page, pages })} + + +
+
+ ); +} + +/** + * 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; + children: (filtersButton: React.ReactNode) => React.ReactNode; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const button = ( + + ); + return ( +
+ +
+ {open && ( +
{rail}
+ )} + {children(button)} +
+
+ ); +} + function InstalledSection({ plugins, manager, @@ -489,10 +862,9 @@ function InstalledSection({ }) { const { t } = useTranslation(); const [sort, setSort] = useState("name"); - const rows = sortPlugins( - plugins.filter((p) => matchesQuery(p, query)), - sort, - ); + const [facets, setFacets] = useState(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 ( -
- -
- - {layoutControls} - {manager.developerMode && !manager.signedOnly && ( - - )} - -
-
+ {filtersButton} +
+ + {layoutControls} + {manager.developerMode && !manager.signedOnly && ( + + )} + +
+ - {rows.length === 0 ? ( - - ) : ( - <> - {running.length > 0 && ( - - {collection(running)} - + {activeFacets(facets) > 0 && ( + )} - {onServer.length > 0 && ( - - {collection(onServer)} - + + {rows.length === 0 ? ( + + ) : ( + <> + {running.length > 0 && ( + + {collection(running)} + + )} + {onServer.length > 0 && ( + + {collection(onServer)} + + )} + {stopped.length > 0 && ( + + {collection(stopped)} + + )} + )} - {stopped.length > 0 && ( - - {collection(stopped)} - - )} - +
)} - + ); } @@ -953,31 +1341,46 @@ function BrowseSection({ layoutControls: React.ReactNode; }) { const { t } = useTranslation(); - const [category, setCategory] = useState(null); - const [registry, setRegistry] = useState(null); + const [facets, setFacets] = useState(NO_FACETS); const [sort, setSort] = useState("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( + "pluginsTab.pageSize", + PAGE_SIZES, + "24", + ); + const scroller = useRef(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) => , + cell: (p) => , }, { key: "description", @@ -1029,124 +1432,117 @@ function BrowseSection({ { key: "actions", header: "", - width: "120px", + width: "150px", align: "end", cell: (p) => , }, ]; return ( -
- -
- - {registries.length > 1 && ( - )} - - {layoutControls} -
-
+ + } + > + {(filtersButton) => ( +
+ + {filtersButton} +
+ + {layoutControls} +
+
- {categories.length > 0 && ( -
- {[null, ...categories].map((c) => ( - - ))} -
- )} - - {rows.length === 0 ? ( - allInstalledHidden ? ( - - ) : ( - - ) - ) : ( - ( - onOpen(p.id)} + listColumns={listColumns} + onOpen={onOpen} /> )} - listColumns={listColumns} - onOpen={onOpen} - /> - )} -

- {t("plugins.manager.registries.browseHint")}{" "} - - {t("plugins.manager.communitySubmit")} - -

-
+ { + setPageSize(next); + goTo(1); + }} + /> + +

+ {t("plugins.manager.registries.browseHint")}{" "} + + {t("plugins.manager.communitySubmit")} + +

+
+ )} + ); } @@ -1166,6 +1562,7 @@ function BrowseCard({ @@ -1182,12 +1579,14 @@ function BrowseCard({ />
- {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")}
@@ -1215,6 +1614,15 @@ function BrowseAction({ {t("plugins.manager.installedBadge")} + ) : plugin.unsupported ? ( + + {plugin.needsTermix + ? t("plugins.manager.needsTermix", { range: plugin.needsTermix }) + : t("plugins.manager.unsupported")} + ) : (