"use client"; import { useEffect, useRef, useState, type RefObject } from "react"; import { IconArrowUpCircle, IconChevronRight, IconLoader2, IconSettings, } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Switch } from "@kandev/ui/switch"; import Link from "@/components/routing/app-link"; import { PluginRepoLink } from "./plugin-repo-link"; import { PluginStatusBadge } from "./plugin-status-badge"; import { PluginErrorDiagnostic } from "./plugin-error-diagnostic"; import { PluginUninstallConfirmation } from "./uninstall-plugin-dialog"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import type { MarketplaceEntry, PluginRecord } from "@/lib/types/plugins"; import { SETTINGS_TYPOGRAPHY } from "@/components/settings/settings-typography"; import { controlSizingClassName } from "@kandev/ui/control-sizing"; /** * The row's view of its marketplace-update status, computed by * plugins-settings.tsx from usePluginUpdates + usePluginUpdateAction. * `checked` mirrors the hook's global flag (true once the first successful * catalog check has completed) so the row can distinguish "haven't checked * yet" from "checked and this plugin isn't in any catalog" — never flashing * a misleading "not in marketplace" before the first response arrives. */ export type PluginRowUpdateState = { /** This plugin's catalog entry, present once checked and found in any enabled source. */ latest?: MarketplaceEntry; /** True when `latest` is strictly newer than the installed version. */ hasUpdate: boolean; /** True once a successful catalog check has completed at least once. */ checked: boolean; /** * True when the last check reached some sources but not all of them. A * plugin absent from a partial catalog is unknown, not delisted, so the * not-in-marketplace hint is withheld. */ sourcesDegraded?: boolean; /** True while a manual update for this plugin is in flight. */ busy: boolean; /** Set when the last manual update attempt for this plugin failed. */ error?: string; }; type PluginRowProps = { plugin: PluginRecord; busy: boolean; /** Marketplace version/update-check state for this plugin; absent when the parent has no update data at all. */ update?: PluginRowUpdateState; /** The instance-wide auto-update default, used when the plugin has no override. */ autoUpdateDefault: boolean; /** True while this row's auto-update override request is in flight. */ autoUpdateBusy: boolean; /** True when the plugin declares required settings the operator has not filled in. */ needsSetup?: boolean; /** Whether the current user may mutate instance-global plugin state. */ canManage?: boolean; /** Fine pointers use an anchored popover; coarse pointers use inline row actions. */ isFinePointer?: boolean; /** True while this plugin's uninstall request is in flight. */ uninstallBusy?: boolean; onEnable: (plugin: PluginRecord) => void; onDisable: (plugin: PluginRecord) => void; onConfirmUninstall?: (plugin: PluginRecord) => void | Promise; onUpdate?: (entry: MarketplaceEntry) => void; onSetAutoUpdate: (plugin: PluginRecord, value: boolean | null) => void; }; /** * One plugin's row. Div-based (not a ``) so it wraps/stacks naturally * on narrow viewports and inside the mobile settings sheet — no separate * mobile layout needed. * * The whole card opens the plugin's settings page, via an overlay link and a * trailing chevron (the pattern the workspaces list already uses). The name * alone used to carry the link, which was indistinguishable from a heading * until hovered — and therefore invisible on touch, where nothing hovers. * Every control in the card sits above the overlay at z-10 so it keeps its * own click. */ export function PluginRow({ plugin, busy, update, autoUpdateDefault, autoUpdateBusy, needsSetup = false, canManage = true, isFinePointer = true, uninstallBusy = false, onEnable, onDisable, onConfirmUninstall, onUpdate, onSetAutoUpdate, }: PluginRowProps) { const canEnable = plugin.status === "disabled" || plugin.status === "registered" || plugin.status === "error"; const canDisable = plugin.status === "active" || plugin.status === "error"; const mutationBusy = busy || autoUpdateBusy || uninstallBusy; const { confirmingUninstall, setConfirmingUninstall, uninstallAnchorRef, requestUninstall, cancelUninstall, confirmUninstall, } = usePluginUninstallConfirmation(plugin, onConfirmUninstall); useEffect(() => { if (!canManage) setConfirmingUninstall(false); }, [canManage, setConfirmingUninstall]); return (
{canManage && (
)}
); } type PluginRowContentProps = { plugin: PluginRecord; update?: PluginRowUpdateState; autoUpdateDefault: boolean; needsSetup: boolean; canManage: boolean; isFinePointer: boolean; mutationBusy: boolean; canEnable: boolean; canDisable: boolean; confirmingUninstall: boolean; uninstallAnchorRef: RefObject; onEnable: (plugin: PluginRecord) => void; onDisable: (plugin: PluginRecord) => void; onUninstall: (plugin: PluginRecord) => void; onUpdate?: (entry: MarketplaceEntry) => void; onSetAutoUpdate: (plugin: PluginRecord, value: boolean | null) => void; }; function PluginRowContent({ plugin, update, autoUpdateDefault, needsSetup, canManage, isFinePointer, mutationBusy, canEnable, canDisable, confirmingUninstall, uninstallAnchorRef, onEnable, onDisable, onUninstall, onUpdate, onSetAutoUpdate, }: PluginRowContentProps) { const { t } = useTranslation(); return ( <> {/* The spacing lives on this inner wrapper, not on the card. `space-y-*` compiles to a margin on every child bar the last, and with the overlay link as a card child that margin shortened it by one gap, leaving the bottom strip of the card unclickable. */}
{canManage && ( )}
{plugin.description && (
{plugin.description}
)} {update?.error && (
{update.error}
)} {plugin.categories.length > 0 && (
{plugin.categories.map((category) => ( {category} ))}
)} {canManage && ( )}
); } function usePluginUninstallConfirmation( plugin: PluginRecord, onConfirmUninstall?: (plugin: PluginRecord) => void | Promise, ) { const [confirmingUninstall, setConfirmingUninstall] = useState(false); const uninstallAnchorRef = useRef(null); const requestUninstall = () => { setConfirmingUninstall(true); }; const cancelUninstall = () => { setConfirmingUninstall(false); }; const confirmUninstall = () => { setConfirmingUninstall(false); return onConfirmUninstall?.(plugin); }; return { confirmingUninstall, setConfirmingUninstall, uninstallAnchorRef, requestUninstall, cancelUninstall, confirmUninstall, }; } /** * The row's name, state badges and identifiers. Everything here is inert and * sits under the card's overlay link, except the repo link, which raises * itself to z-10 to keep its own click. */ function PluginRowIdentity({ plugin, needsSetup, update, }: { plugin: PluginRecord; needsSetup: boolean; update?: PluginRowUpdateState; }) { const { t } = useTranslation(); return (
{plugin.display_name} {needsSetup && ( {t("plugins:setupRequired")} )} {plugin.signed === false && ( {t("plugins:unsigned")} )}
{plugin.id} · v{plugin.version}
); } /** * The latest-known marketplace version for this plugin, once a catalog check * has completed at least once: "Latest v" or a not-in-marketplace hint * when no source carries this plugin id at all. The update action itself is * the update-available affordance, so the row does not repeat it as a badge. * Renders nothing before the first successful check — a stale "not in * marketplace" flash would be actively misleading while the marketplace * hasn't been queried yet — and likewise nothing when the check only reached * some sources, since a plugin carried solely by the source that failed is * unknown, not delisted. */ function PluginUpdateInfo({ pluginId, update, }: { pluginId: string; update?: PluginRowUpdateState; }) { const { t } = useTranslation(); if (!update?.checked) return null; if (!update.latest) { if (update.sourcesDegraded) return null; return ( {t("plugins:notInMarketplace")} ); } return ( {t("plugins:latestVersion", { version: update.latest.version })} ); } /** * The per-plugin auto-update control. The switch reflects the effective state * (the plugin's own override, or the instance-wide default when it has none); * toggling it sets an explicit override. Once overridden, a "Reset" affordance * clears the override so the plugin follows the global default again. */ function PluginAutoUpdateRow({ plugin, autoUpdateDefault, busy, onSetAutoUpdate, }: { plugin: PluginRecord; autoUpdateDefault: boolean; busy: boolean; onSetAutoUpdate: (plugin: PluginRecord, value: boolean | null) => void; }) { const { t } = useTranslation(); const isOverridden = plugin.auto_update !== null && plugin.auto_update !== undefined; const effective = isOverridden ? (plugin.auto_update as boolean) : autoUpdateDefault; return (
{t("plugins:autoUpdate")} {isOverridden && ( {t("plugins:override")} )}
{isOverridden && ( )} onSetAutoUpdate(plugin, value)} className="cursor-pointer" />
); } type PluginRowActionsProps = Omit< PluginRowProps, | "onEnable" | "onDisable" | "onUninstall" | "autoUpdateDefault" | "autoUpdateBusy" | "needsSetup" | "onSetAutoUpdate" | "onConfirmUninstall" | "uninstallBusy" > & { canEnable: boolean; canDisable: boolean; isFinePointer: boolean; confirmingUninstall: boolean; uninstallAnchorRef: RefObject; onEnable: (plugin: PluginRecord) => void; onDisable: (plugin: PluginRecord) => void; onUninstall: (plugin: PluginRecord) => void; }; function PluginRowActions({ plugin, busy, update, canEnable, canDisable, isFinePointer, confirmingUninstall, uninstallAnchorRef, onEnable, onDisable, onUninstall, onUpdate, }: PluginRowActionsProps) { const { t } = useTranslation(); const { isMobile } = useResponsiveBreakpoint(); const updateEntry = update?.hasUpdate ? update.latest : undefined; return (
{updateEntry && onUpdate && ( )} {canEnable && ( )} {canDisable && ( )} {(isMobile || isFinePointer || !confirmingUninstall) && ( )} {t("plugins:settings")}
); } function PluginUpdateButton({ pluginId, entry, updating, disabled, onUpdate, }: { pluginId: string; entry: MarketplaceEntry; updating: boolean; disabled: boolean; onUpdate: (entry: MarketplaceEntry) => void; }) { const { t } = useTranslation(); return ( ); }