/** * Settings › Plugins, laid out like IDEA: Available / Installed tabs with a * gear menu, a searchable list on the left (enable checkboxes) and the * selected plugin's details on the right (Overview / What's New). * * There is no network repository: Available lists the plugin packages * available locally (`plugin_catalog`) and installs them from disk. */ import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react"; import clsx from "clsx"; import type { CapabilityInfo, CatalogPlugin, MarketPlugin, PluginIcon as PluginIconData, PluginInfo, PluginStatus, } from "@/bindings"; import { Badge, Button, Checkbox, EmptyState, Icon, IconButton, SearchField, Select, Spinner, Tabs, showContextMenu, type MenuItem, T, } from "@/ui"; import { RISK_LABEL, sortByRisk } from "./consent"; import { pluginIconName } from "./pluginIcon"; import { localizeContributions, usePluginCatalogs, usePluginT } from "./pluginText"; import { itemsOf, uiPoint } from "./projection"; import { compareVersions, isUpdate, grantPlugin, installFromDisk, installFromMarket, refreshMarket, rollBackPlugin, setMarketUrl, refreshCatalog, refreshPlugins, reloadPlugin, setAllInstalledEnabled, setPluginCapability, setPluginEnabled, uninstallPlugin, updateAllPlugins, usePlugins, } from "./store"; import { useProject } from "@/platform/project"; import { openExternalUrl } from "@/platform/openUrl"; import { importIntellijTheme, showInstalledIntellijThemes } from "./intellijImport"; import { promptInput } from "@/workbench/inputDialogs"; import styles from "./PluginsPage.module.css"; import { tx } from "@/platform/i18n"; const STATUS: Record = { active: { label: "Running", tone: "success" }, inactive: { label: "Enabled", tone: "accent" }, static: { label: "Enabled", tone: "accent" }, disabled: { label: "Disabled", tone: "neutral" }, needsConsent: { label: "Needs permission", tone: "warning" }, failed: { label: "Failed", tone: "danger" }, }; /** Human names of the built-in contribution points (others show their id). */ const POINT_TITLES: Record = { commands: "Commands", themes: "Themes", iconThemes: "File Icon Themes", languages: "Languages", providers: "Editor Providers", locales: "Translations", }; const pointTitle = (point: string) => uiPoint(point)?.title ?? POINT_TITLES[point] ?? point; export const NO_REPOSITORY = "There is no online plugin repository"; type Tab = "marketplace" | "installed"; const matches = (texts: readonly (string | null | undefined)[], q: string) => texts.some((s) => !!s && s.toLowerCase().includes(q)); export function filterPlugins(plugins: readonly PluginInfo[], query: string): PluginInfo[] { const q = query.trim().toLowerCase(); if (!q) return [...plugins]; return plugins.filter((p) => matches([p.name, p.id, p.description, ...p.authors, ...p.contributions.map((c) => c.label)], q), ); } export function filterCatalog(catalog: readonly CatalogPlugin[], query: string): CatalogPlugin[] { const q = query.trim().toLowerCase(); if (!q) return [...catalog]; return catalog.filter((p) => matches([p.name, p.id, p.description, ...p.authors], q)); } /** What the Available tab offers for a catalog package given what is installed. */ export function catalogState( entry: CatalogPlugin, installed: readonly PluginInfo[], ): "install" | "update" | "installed" | "dev" { const have = installed.find((p) => p.id === entry.id); if (!have) return "install"; if (have.source !== "installed") return "dev"; return compareVersions(entry.version, have.version) > 0 ? "update" : "installed"; } const vendorOf = (authors: readonly string[]) => authors.join(", "); function PluginIcon({ id, icon, large }: { id: string; icon: PluginIconData | null | undefined; large?: boolean }) { return ( ); } function ListHeader({ title, count }: { title: string; count: number }) { return (
  • {title} {count}
  • ); } function InstalledRow({ plugin, selected, onSelect }: { plugin: PluginInfo; selected: boolean; onSelect: () => void }) { const busy = usePlugins((s) => !!s.busy[plugin.id]); return (
  • void setPluginEnabled(plugin.id, on)} aria-label={plugin.enabled ? tx`Disable ${plugin.name}` : tx`Enable ${plugin.name}`} />
  • ); } function CatalogButton({ entry, compact }: { entry: CatalogPlugin; compact?: boolean }) { const plugins = usePlugins((s) => s.plugins); const installing = usePlugins((s) => s.installing); const state = catalogState(entry, plugins); if (state === "installed" || state === "dev") { return {state === "dev" ? tx("Dev plugin loaded") : tx("Installed")}; } const label = state === "update" ? "Update" : "Install"; return ( ); } function CatalogRow({ entry, selected, onSelect }: { entry: CatalogPlugin; selected: boolean; onSelect: () => void }) { return (
  • ); } function Section({ title, children }: { title: string; children: ReactNode }) { return (

    {title}

    {children}
    ); } function DetailTabs({ notes, overview }: { notes: string | null; overview: ReactNode }) { const [tab, setTab] = useState<"overview" | "whatsNew">("overview"); return ( <> setTab(id as typeof tab)} items={[ { id: "overview", label: "Overview" }, { id: "whatsNew", label: "What's New" }, ]} /> {tab === "overview" ? ( overview ) : notes ? (
    {notes.trim()}
    ) : (

    This version has no release notes.

    )} ); } /** Contribution points of a plugin, in the order they were committed. */ function pointsOf(plugin: PluginInfo): string[] { return [...new Set(plugin.contributions.map((c) => c.point))]; } function PointSection({ plugin, point }: { plugin: PluginInfo; point: string }) { const ui = usePlugins((s) => s.ui); const Details = uiPoint(point)?.Details; const catalogs = usePluginCatalogs((s) => s.catalogs); const t = usePluginT(plugin.id); const items = itemsOf(localizeContributions(ui, catalogs), point, plugin.id); return (
    {Details && items.length > 0 ? (
    ) : (
      {plugin.contributions .filter((c) => c.point === point) .map((c, i) => (
    • {t(c.label)}
    • ))}
    )}
    ); } function CapabilityRow({ cap, children }: { cap: CapabilityInfo; children?: ReactNode }) { return (
  • {children} {tx(cap.description)} {cap.id} {RISK_LABEL[cap.risk].label}
  • ); } function CapabilityList({ plugin, busy }: { plugin: PluginInfo; busy: boolean }) { if (plugin.capabilities.length === 0) { return (
    No special permissions.
    ); } const confirmed = plugin.status !== "needsConsent"; return (
      {sortByRisk(plugin.capabilities).map((c) => ( void setPluginCapability(plugin.id, c.id, on)} aria-label={c.granted ? tx`Revoke ${c.id}` : tx`Allow ${c.id}`} /> ))}
    ); } function Homepage({ value }: { value: string }) { let isWebUrl = false; try { const { protocol } = new URL(value); isWebUrl = protocol === "http:" || protocol === "https:"; } catch { // Malformed manifest values remain readable as plain text. } return (
    {isWebUrl ? ( { event.preventDefault(); void openExternalUrl(value); }} > {value} ) : ( value )}
    ); } function InstalledDetails({ plugin }: { plugin: PluginInfo }) { const busy = usePlugins((s) => !!s.busy[plugin.id]); const status = STATUS[plugin.status]; const [confirmUninstall, setConfirmUninstall] = useState(false); const overview = ( <> {plugin.error && (
    {plugin.error}
    )} {plugin.description &&

    {plugin.description}

    }
    Id
    {plugin.id}
    Location
    {plugin.path}
    Code
    {plugin.hasCode ? tx("WebAssembly component (sandboxed)") : tx("None — contributions only")}
    {plugin.homepage && ( <>
    Homepage
    )}
    {plugin.status === "needsConsent" && (
    {plugin.source === "installed" ? tx("This version asks for new permissions. It stays off until you allow them.") : tx("Plugins from a project folder run only after you allow them.")}
    )} {plugin.warnings.map((w) => (
    {w}
    ))}
    {pointsOf(plugin).map((point) => ( ))} {plugin.hasCode && plugin.stats.activations > 0 && (
    {tx`Activated ${plugin.stats.activations}× (last in ${(plugin.stats.activationMs ?? 0).toFixed(1)} ms), ${plugin.stats.calls} calls, ${plugin.stats.errors} errors, slowest ${(plugin.stats.maxCallMs ?? 0).toFixed(1)} ms`}
    {tx`Recent calls p95 ${(plugin.stats.p95CallMs ?? 0).toFixed(1)} ms; ${plugin.stats.crashes} crashes, ${plugin.stats.slowCalls} editor results too late, reclaimed ${plugin.stats.reclaims}× when idle`}
    )} ); return (

    {plugin.name}

    {plugin.authors.length > 0 &&
    {vendorOf(plugin.authors)}
    }
    {plugin.version} {status.label} {plugin.source !== "installed" && ( {plugin.source === "linked" ? tx("Linked plugin") : tx("Project plugin")} )} {plugin.source === "installed" && (plugin.publisher ? ( {tx`Signed by ${plugin.publisher}`} ) : ( {tx("Unsigned")} ))}
    {busy && } void reloadPlugin(plugin.id)} /> {plugin.previousVersion && ( )} {plugin.source === "installed" && (confirmUninstall ? ( <> ) : ( ))}
    ); } function CatalogDetails({ entry }: { entry: CatalogPlugin }) { const contributes = entry.contributions.map((c) => `${pointTitle(c.point)}: ${c.count}`); const overview = ( <> {entry.problem && (
    {entry.problem}
    )} {entry.description &&

    {entry.description}

    }
    Id
    {entry.id}
    Source
    {entry.path}
    Code
    {entry.hasCode ? tx("WebAssembly component (sandboxed)") : tx("None — contributions only")}
    {entry.homepage && ( <>
    Homepage
    )} {contributes.length > 0 && ( <>
    Contributes
    {contributes.join(", ")}
    )}
    {entry.capabilities.length > 0 && (
      {sortByRisk(entry.capabilities).map((c) => ( ))}
    )} ); return (

    {entry.name}

    {entry.authors.length > 0 &&
    {vendorOf(entry.authors)}
    }
    {entry.version}
    ); } interface GearActions { /** Installing needs a trusted project's window (the host refuses it elsewhere). */ canImport: boolean; install: (packageFile: boolean) => void; importIntellijFile: () => void; importInstalledIntellij: () => void; editRepository: () => void; } /** Asks for the marketplace index URL; an empty one restores the default. */ async function editMarketUrl() { const url = await promptInput({ title: "Plugin Repository", label: "Marketplace index URL:", initial: usePlugins.getState().marketUrl ?? "", placeholder: "https://…/index.json or a folder path", okLabel: "Save", validate: () => null, hint: Leave empty to use the default repository., }); if (url !== null) await setMarketUrl(url); } function gearMenu(plugins: readonly PluginInfo[], updates: number, actions: GearActions): MenuItem[] { const installed = plugins.filter((p) => p.source === "installed"); return [ { label: updates > 0 ? tx`Update All Plugins (${updates})` : "Update All Plugins", disabled: updates === 0, run: () => void updateAllPlugins(), }, { kind: "separator" }, { label: "Install Plugin from Disk…", icon: "package", run: () => actions.install(false) }, { label: "Install Plugin from Package File…", run: () => actions.install(true) }, { label: "Plugin Repository…", run: actions.editRepository }, ...(actions.canImport ? [ { kind: "separator" as const }, { label: "Import Theme from JetBrains IDE…", run: actions.importInstalledIntellij }, { label: "Import IntelliJ Theme from File…", run: actions.importIntellijFile }, ] : []), { kind: "separator" }, { label: "Disable All Downloaded Plugins", disabled: !installed.some((p) => p.enabled), run: () => void setAllInstalledEnabled(false), }, { label: "Enable All Downloaded Plugins", disabled: !installed.some((p) => !p.enabled), run: () => void setAllInstalledEnabled(true), }, { kind: "separator" }, { label: "Rescan Plugins", icon: "refresh", run: () => { void refreshPlugins(); void refreshCatalog(); void refreshMarket(true); }, }, ]; } const CATEGORIES = [ "languages", "themes", "icon-themes", "linters", "vcs", "ai", "frameworks", "tools", "other", ] as const; type Category = (typeof CATEGORIES)[number]; function categoryLabel(c: Category): string { switch (c) { case "languages": return tx("Languages"); case "themes": return tx("Themes"); case "icon-themes": return tx("Icon Themes"); case "linters": return tx("Linters & Formatters"); case "vcs": return tx("Version Control"); case "ai": return tx("AI"); case "frameworks": return tx("Frameworks"); case "tools": return tx("Tools"); case "other": return tx("Other"); } } /** A marketplace plugin's categories this IDE knows; `other` without any. */ function categoriesOf(m: MarketPlugin): Category[] { const known = CATEGORIES.filter((c) => m.categories?.includes(c)); return known.length > 0 ? known : ["other"]; } /** Filters the marketplace by category; lists the categories that have plugins. */ function CategoryFilter({ market, value, onChange, }: { market: readonly MarketPlugin[]; value: Category | "all"; onChange: (value: Category | "all") => void; }) { const counts = new Map(); for (const m of market) for (const c of categoriesOf(m)) counts.set(c, (counts.get(c) ?? 0) + 1); const options = [ { value: "all" as const, label: tx("All Categories"), detail: String(market.length) }, ...CATEGORIES.filter((c) => counts.has(c)).map((c) => ({ value: c, label: categoryLabel(c), detail: String(counts.get(c)), })), ]; return (