"use client"; import { useRef, useState, type RefObject } from "react"; import { IconArrowUpCircle, IconChevronRight } 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 type { MarketplaceEntry, PluginRecord } from "@/lib/types/plugins"; import { SETTINGS_TYPOGRAPHY } from "@/components/settings/settings-typography"; type PluginRowProps = { plugin: PluginRecord; busy: boolean; /** Set when the marketplace has a newer version than the installed one. */ update?: MarketplaceEntry; /** 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; /** 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, isFinePointer = true, uninstallBusy = false, onEnable, onDisable, onConfirmUninstall, onUpdate, onSetAutoUpdate, }: PluginRowProps) { const { t } = useTranslation(); 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); 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. */}
{plugin.description && (
{plugin.description}
)} {plugin.categories.length > 0 && (
{plugin.categories.map((category) => ( {category} ))}
)}
); } 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 }: { plugin: PluginRecord; needsSetup: boolean }) { const { t } = useTranslation(); return (
{plugin.display_name} {needsSetup && ( {t("plugins:setupRequired")} )} {plugin.signed === false && ( {t("plugins:unsigned")} )}
{plugin.id} · v{plugin.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(); return (
{update && onUpdate && ( )} {canEnable && ( )} {canDisable && ( )} {(isFinePointer || !confirmingUninstall) && ( )}
); }