import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArrowDownWideNarrow, Check, Columns3, Download, Globe, Library, Puzzle, RefreshCw, RotateCcw, Search, Trash2, TriangleAlert, Upload, } from "lucide-react"; import { Button } from "@/components/button"; import { Card } from "@/components/card"; import { Input } from "@/components/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/select"; import { EmptyState } from "@/components/empty-state"; import { TabStrip } from "@/components/tab-strip"; import { FakeSwitch } from "@/components/section-card"; import { Facts, GroupHeading, PANEL, PanelShell, ViewToggle, type PanelViewMode, } from "@/components/panel-layout"; import { DataView, type DataColumn } from "@/components/data-view"; import { InlineView, SurfaceScope } from "@/components/surface/surface-scope"; import { cn } from "@/lib/utils"; import { BetaBadge, DownloadCount, PluginIconBox, SourceBadge, UnverifiedBadge, } from "./plugin-bits"; import { RegistriesSection } from "./RegistriesSection"; import { PluginConsentPrompt } from "./PluginConsentPrompt"; import { PluginDetail } from "./PluginDetail"; import { categoriesOf, describeContributions, isSevere, matchesQuery, orderByRisk, sortPlugins, type PluginEntry, type SortKey, } from "./plugin-model"; import { usePluginsManager, type PluginsManager } from "./use-plugins-manager"; import { docsUrl } from "@/lib/docs"; import { DocsLink } from "@/components/docs-link"; type Section = "installed" | "browse" | "updates" | "registries"; const GRID_SIZES = ["auto", "2", "3", "4", "5", "6"] as const; type GridSize = (typeof GRID_SIZES)[number]; interface Layout { view: PanelViewMode; columns: GridSize; } function useStoredChoice( key: string, allowed: readonly T[], fallback: T, ) { const [value, setValue] = useState(() => { try { const saved = localStorage.getItem(key); return allowed.includes(saved as T) ? (saved as T) : fallback; } catch { return fallback; } }); useEffect(() => { try { localStorage.setItem(key, value); } catch { // blocked storage just means the choice won't stick } }, [key, value]); return [value, setValue] as const; } export function PluginsTab() { return ( ); } function PluginsTabBody() { const { t } = useTranslation(); const manager = usePluginsManager(); const [section, setSection] = useState
("installed"); const [query, setQuery] = useState(""); const [detailId, setDetailId] = useState(null); const [view, setView] = useStoredChoice( "pluginsTab.view", ["grid", "list"], "grid", ); const [columns, setColumns] = useStoredChoice( "pluginsTab.columns", GRID_SIZES, "auto", ); const layout: Layout = { view, columns }; const layoutControls = ( ); const installed = manager.plugins.filter((p) => p.installed); const available = manager.plugins.filter((p) => p.inRegistry); const updates = installed.filter((p) => p.updateAvailable); const running = installed.filter((p) => p.status === "running").length; const detail = manager.plugins.find((p) => p.id === detailId) ?? null; const tabs = [ { id: "installed", label: t("plugins.manager.tabs.installed"), icon: , count: installed.length, }, { id: "browse", label: t("plugins.manager.tabs.browse"), icon: , }, { id: "updates", label: t("plugins.manager.tabs.updates"), icon: , count: updates.filter((p) => !p.pinnedVersion).length || undefined, }, { id: "registries", label: t("plugins.manager.tabs.registries"), icon: , }, ]; return ( <> } title={t("nav.plugins")} docs={docsUrl("plugins")} status={t("plugins.manager.status.summary", { installed: installed.length, running, })} scroll={false} actions={ } tabs={ setSection(id as Section)} /> } >
{manager.managedByServer && ( {t("plugins.manager.managedByServer")} )} {manager.registryError && ( {t("plugins.manager.registryUnreachable", { error: manager.registryError, })} )} {manager.loading ? (
) : section === "installed" ? ( ) : section === "browse" ? ( ) : section === "updates" ? ( ) : ( )}
!open && setDetailId(null)} title={detail?.name ?? ""} icon={} width="wide" bare > {detail && ( setDetailId(null)} /> )} manager.setConsent(null)} onConfirm={() => void manager.confirmConsent()} /> ); } function Notice({ children }: { children: React.ReactNode }) { return (
{children}
); } function SortSelect({ value, onChange, }: { value: SortKey; onChange: (next: SortKey) => void; }) { const { t } = useTranslation(); const keys: SortKey[] = ["popular", "name", "updated", "category"]; return ( ); } function Toolbar({ query, onQuery, placeholder, children, }: { query: string; onQuery: (v: string) => void; placeholder: string; children?: React.ReactNode; }) { return (
onQuery(e.target.value)} placeholder={placeholder} className="h-8 pl-8 text-xs" />
{children}
); } function Group({ title, count, children, }: { title: string; count: number; children: React.ReactNode; }) { return (
{children}
); } function LayoutControls({ layout, onView, onColumns, }: { layout: Layout; onView: (next: PanelViewMode) => void; onColumns: (next: GridSize) => void; }) { const { t } = useTranslation(); return ( <> {layout.view === "grid" && ( )} ); } // Sized off the tab's own width, not the window. Cards keep a min width, // so narrow panes drop columns on their own. function gridStyle(columns: GridSize): React.CSSProperties { if (columns === "auto") { return { gridTemplateColumns: "repeat(auto-fill, minmax(min(100%, 400px), 1fr))", }; } const n = Number(columns); const gaps = (n - 1) * 12; return { gridTemplateColumns: `repeat(auto-fill, minmax(max(220px, calc((100% - ${gaps}px) / ${n} - 0.5px)), 1fr))`, }; } function PluginCollection({ plugins, layout, renderCard, listColumns, onOpen, }: { plugins: PluginEntry[]; layout: Layout; renderCard: (plugin: PluginEntry) => React.ReactNode; listColumns: DataColumn[]; onOpen: (id: string) => void; }) { if (layout.view === "list") { return ( p.id} renderCard={renderCard} columns={{}} listColumns={listColumns} onRowClick={(p) => onOpen(p.id)} empty={null} /> ); } return (
{plugins.map((p) => (
{renderCard(p)}
))}
); } function NameCell({ plugin, muted }: { plugin: PluginEntry; muted?: boolean }) { return (
{plugin.name} {plugin.unverified && } {plugin.isBeta && }
); } function DescriptionCell({ plugin }: { plugin: PluginEntry }) { return ( {plugin.description} ); } function stop(e: React.MouseEvent) { e.stopPropagation(); } function InstalledSection({ plugins, manager, query, onQuery, onOpen, layout, layoutControls, }: { plugins: PluginEntry[]; manager: PluginsManager; query: string; onQuery: (v: string) => void; onOpen: (id: string) => void; layout: Layout; layoutControls: React.ReactNode; }) { const { t } = useTranslation(); const [sort, setSort] = useState("name"); const rows = sortPlugins( plugins.filter((p) => matchesQuery(p, query)), sort, ); const running = rows.filter((p) => p.status === "running"); const onServer = rows.filter((p) => p.status === "server"); const stopped = rows.filter( (p) => p.status !== "running" && p.status !== "server", ); const pending = plugins.filter((p) => p.updateAvailable && !p.pinnedVersion); const listColumns: DataColumn[] = [ { key: "name", header: t("plugins.manager.table.name"), width: "minmax(0,1.3fr)", cell: (p) => , }, { key: "description", header: t("plugins.manager.table.description"), width: "minmax(0,2fr)", hideBelow: "md", cell: (p) => , }, { key: "version", header: t("plugins.manager.table.version"), width: "80px", cell: (p) => ( v{p.version} ), }, { key: "status", header: t("plugins.manager.table.status"), width: "140px", hideBelow: "lg", cell: (p) => , }, { key: "actions", header: "", width: "150px", align: "end", cell: (p) => , }, ]; const collection = (items: PluginEntry[]) => ( ( onOpen(p.id)} /> )} listColumns={listColumns} onOpen={onOpen} /> ); return (
{layoutControls} {manager.developerMode && !manager.signedOnly && ( )}
{rows.length === 0 ? ( ) : ( <> {running.length > 0 && ( {collection(running)} )} {onServer.length > 0 && ( {collection(onServer)} )} {stopped.length > 0 && ( {collection(stopped)} )} )}
); } function UploadButton({ manager }: { manager: PluginsManager }) { const { t } = useTranslation(); const input = useRef(null); return ( <> { const file = e.target.files?.[0]; e.target.value = ""; if (file) void manager.requestUpload(file); }} /> ); } function CardHead({ plugin, muted, facts, onOpen, }: { plugin: PluginEntry; muted?: boolean; facts: React.ReactNode; onOpen: () => void; }) { return ( ); } function InstalledCard({ plugin, manager, onOpen, }: { plugin: PluginEntry; manager: PluginsManager; onOpen: () => void; }) { const { t } = useTranslation(); const running = plugin.status === "running"; const failed = plugin.status === "failed" || plugin.status === "blocked"; const busy = manager.busy.has(plugin.id) || manager.busy.has("*"); const locked = manager.managedByServer; const adds = describeContributions(plugin.contributes) .slice(0, 3) .map((c) => t(`plugins.manager.contributes.${c.kind}`, { count: c.count })); return ( {plugin.unverified && } {plugin.isBeta && } {plugin.author && {plugin.author}} v{plugin.version} {adds.length > 0 && {adds.join(", ")}} } /> {failed ? (
{plugin.status === "blocked" ? t("plugins.manager.status.blocked") : t("plugins.manager.status.failed")}
) : !running ? (
{plugin.status === "server" ? t("plugins.manager.status.server") : t("plugins.manager.status.disabled")}
) : (
{plugin.pinnedVersion ? t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion, }) : t("plugins.manager.status.running")}
)}
{plugin.updateAvailable && !plugin.pinnedVersion && !failed && ( )} {failed ? ( ) : ( )} {!plugin.defaultLook && ( )}
); } function StatusCell({ plugin }: { plugin: PluginEntry }) { const { t } = useTranslation(); const running = plugin.status === "running"; const failed = plugin.status === "failed" || plugin.status === "blocked"; const label = plugin.status === "blocked" ? t("plugins.manager.status.blocked") : failed ? t("plugins.manager.status.failed") : plugin.status === "server" ? t("plugins.manager.status.server") : !running ? t("plugins.manager.status.disabled") : plugin.pinnedVersion ? t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion }) : t("plugins.manager.status.running"); return ( {failed ? ( ) : ( )} {label} ); } function InstalledRowActions({ plugin, manager, }: { plugin: PluginEntry; manager: PluginsManager; }) { const { t } = useTranslation(); const failed = plugin.status === "failed" || plugin.status === "blocked"; const busy = manager.busy.has(plugin.id) || manager.busy.has("*"); const locked = manager.managedByServer; const updateLabel = plugin.addedCapabilities.length > 0 ? t("plugins.manager.reviewUpdate") : t("plugins.manager.updateTo", { version: plugin.latestVersion }); return (
{plugin.updateAvailable && !plugin.pinnedVersion && !failed && ( )} {failed ? ( ) : ( )} {!plugin.defaultLook && ( )}
); } function BrowseSection({ plugins, manager, query, onQuery, onOpen, layout, layoutControls, }: { plugins: PluginEntry[]; manager: PluginsManager; query: string; onQuery: (v: string) => void; onOpen: (id: string) => void; layout: Layout; layoutControls: React.ReactNode; }) { const { t } = useTranslation(); const [category, setCategory] = useState(null); const [registry, setRegistry] = useState(null); 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 allInstalledHidden = !showInstalled && plugins.length > 0 && plugins.every((p) => p.installed); const listColumns: DataColumn[] = [ { key: "name", header: t("plugins.manager.table.name"), width: "minmax(0,1.3fr)", cell: (p) => , }, { key: "description", header: t("plugins.manager.table.description"), width: "minmax(0,2fr)", hideBelow: "md", cell: (p) => , }, { key: "category", header: t("plugins.manager.table.category"), width: "120px", hideBelow: "lg", cell: (p) => ( {p.category ?? ""} ), }, { key: "downloads", header: t("plugins.manager.table.downloads"), width: "110px", hideBelow: "lg", cell: (p) => ( ), }, { key: "version", header: t("plugins.manager.table.version"), width: "80px", cell: (p) => ( {p.latestVersion ? `v${p.latestVersion}` : ""} ), }, { key: "actions", header: "", width: "120px", align: "end", cell: (p) => , }, ]; return (
{registries.length > 1 && ( )} {layoutControls}
{categories.length > 0 && (
{[null, ...categories].map((c) => ( ))}
)} {rows.length === 0 ? ( allInstalledHidden ? ( ) : ( ) ) : ( ( onOpen(p.id)} /> )} listColumns={listColumns} onOpen={onOpen} /> )}

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

); } function BrowseCard({ plugin, manager, onOpen, }: { plugin: PluginEntry; manager: PluginsManager; onOpen: () => void; }) { const { t } = useTranslation(); const severe = orderByRisk(plugin.capabilities).filter(isSevere); return ( {plugin.author && {plugin.author}} {plugin.latestVersion && v{plugin.latestVersion}} {plugin.category && {plugin.category}} } />
{severe.length > 0 ? t(`plugins.capabilities.${severe[0]}.title`, { defaultValue: severe[0], nsSeparator: false, }) : t("plugins.manager.noServerAccess")}
); } function BrowseAction({ plugin, manager, }: { plugin: PluginEntry; manager: PluginsManager; }) { const { t } = useTranslation(); const busy = manager.busy.has(plugin.id) || manager.busy.has("*"); return (
{plugin.installed ? ( {t("plugins.manager.installedBadge")} ) : ( )}
); } function UpdatesSection({ plugins, manager, onOpen, }: { plugins: PluginEntry[]; manager: PluginsManager; onOpen: (id: string) => void; }) { const { t } = useTranslation(); const pinned = plugins.filter((p) => p.pinnedVersion); const open = plugins.filter((p) => !p.pinnedVersion); const gated = open.filter((p) => p.addedCapabilities.length > 0); const plain = open.filter((p) => p.addedCapabilities.length === 0); if (plugins.length === 0) { return (
); } return (
{plain.length > 0 && ( {t("plugins.manager.updatesReady", { count: plain.length })} {gated.length > 0 && ` ${t("plugins.manager.updatesNeedReview", { count: gated.length })}`} )}
{[...gated, ...plain, ...pinned].map((p) => ( onOpen(p.id)} /> ))}
); } function UpdateRow({ plugin, manager, onOpen, }: { plugin: PluginEntry; manager: PluginsManager; onOpen: () => void; }) { const { t } = useTranslation(); const gated = plugin.addedCapabilities.length > 0; const busy = manager.busy.has(plugin.id) || manager.busy.has("*"); const notes = plugin.versions.find((v) => v.version === plugin.latestVersion); return (
{t("plugins.manager.versionChange", { from: plugin.version, to: plugin.latestVersion, })}
{plugin.pinnedVersion ? ( {t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion })} ) : gated ? ( {t("plugins.manager.updateAsksMore")} ) : null} {notes?.releaseNotesUrl && ( {t("plugins.manager.releaseNotes")} )}
{!plugin.pinnedVersion && ( )}
); }