/** * The activity entry and its panel: every install, update and uninstall the * user started, with the action each outcome calls for. * * The entry lives in the tab row rather than above the plugin grid, so * paginating, searching or switching tab cannot take a record — or a pending * decision — off screen. It reports the batch as one aggregate ("installing * 3 / 7") instead of one line per plugin. */ import { Button } from '@deepseek-ai/dsh-client-ui-primitives' import { IconCheckOutline16, IconLoadingOutline16, IconWarningOutline16, IconChevronDownOutline14, IconChevronUpOutline14, } from './icons.ts' import { useEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import css from './Market.module.css' import { localizeBilingual, type Translate } from './market-data.ts' import type { ConflictGroup, OperationRecord } from './operations.ts' import { bucketOf, isSettled, needsUser, queuePosition, sortForPanel, summarize } from './operations.ts' /** What the two clash outcomes are, in the order they are offered. */ const CHOICES = [ { id: 'keep' as const, label: 'conflictKeep', note: 'conflictKeepNote' }, { id: 'swap' as const, label: 'conflictSwap', note: 'conflictSwapNote' }, ] /** * How the panel renders a plugin it only knows by package name: the catalog * (or the installed list) supplies the author and avatar a card would show. */ export type DescribePlugin = (name: string) => { title: string author?: string | undefined avatar?: ReactNode } export interface OperationsPanelProps { t: Translate /** Active UI language — used to pick one half of bilingual wire errors. */ lang: 'zh' | 'en' /** Resolves a package name to the identity a card would show for it. */ describe: DescribePlugin records: readonly OperationRecord[] open: boolean /** Owned by the parent: a card's "cannot install" marker raises the panel. */ onOpenChange: (open: boolean) => void /** True while a swap is running, which disables both clash outcomes. */ replacing: boolean /** Blocks the destructive outcome when pnpm is not usable yet. */ envReady: boolean onClearSettled: () => void onCancel: (record: OperationRecord) => void onDismiss: (record: OperationRecord) => void onRefresh: () => void /** Resolve a clash: keep what is installed, or uninstall it and retry. */ onResolveConflict: (record: OperationRecord, choice: 'keep' | 'swap') => void /** Run a queued record now instead of waiting for the queue to drain. */ onRunNow?: ((record: OperationRecord) => void) | undefined /** Retry an operation the host refused for a fixable reason. */ onRetry?: ((record: OperationRecord) => void) | undefined /** Approve the build scripts pnpm blocked, then retry — replaces the plain * retry on a record that failed for that reason, so the fix sits next to * the sentence describing the problem. */ onApproveBuilds?: ((record: OperationRecord) => void) | undefined /** Install the release a hold kept back, over the profile's own * minimumReleaseAge (#635). Offered on the record that says the hold * happened, for the same reason the build approval is: the way out belongs * beside the sentence describing the problem. */ onForceInstall?: ((record: OperationRecord) => void) | undefined } /** * What each plugin ends up as under the selected outcome. * * The consequence is drawn ON the plugins rather than described beside them: * pick "keep what I have" and the installed rows tick while the candidate * crosses out; pick the other and they swap. The candidate is in this list * for the same reason — a decision about which plugin survives has to show * what happens to the one being installed, not only to the others. */ function OutcomePreview(props: { t: Translate record: OperationRecord choice: 'keep' | 'swap' describe: DescribePlugin }) { const { t, record, choice } = props const swap = choice === 'swap' const candidate = props.describe(record.name) const row = (key: string, info: ReturnType, kept: boolean, tag: string) => (
{info.avatar} {info.title} {info.author !== undefined && {info.author}} {kept ? '✓' : '✕'} {tag}
) return (
{row(record.name, candidate, swap, t(swap ? 'conflictOutcomeInstall' : 'conflictOutcomeSkip'))}
{(record.conflicts ?? []).map(group => row(group.owner, props.describe(group.owner), !swap, t(swap ? 'conflictOutcomeRemove' : 'conflictOutcomeKeep')))}
) } /** * The decision attached to a clash. Two outcomes rather than an error plus a * destructive button: the default changes nothing, and selecting the other * one is itself the consent step, so its cost is stated here. */ function ConflictChoice(props: { t: Translate record: OperationRecord replacing: boolean envReady: boolean describe: DescribePlugin onResolve: (choice: 'keep' | 'swap') => void }) { const { t, record, replacing, envReady } = props const [choice, setChoice] = useState<'keep' | 'swap'>('keep') const [whyOpen, setWhyOpen] = useState(false) return (

{t('conflictBody')}

{/* Native radios: the platform already draws the control, groups it by `name`, and gives arrow-key navigation. A hand-rolled ring gets the look wrong and the keyboard behaviour with it. */}
{CHOICES.map(({ id, label, note }) => ( ))}
{/* Both ways out of the decision share one row: the quiet one that reveals evidence, and the one that commits. */}
{whyOpen && (
{(record.conflicts ?? []).map(group => (
{group.owner}: {group.ids.join(', ')}
))}
{t('conflictWhy')}
)}
) } /** Icon for a record's visual bucket — three, not one per state. */ function BucketIcon(props: { record: OperationRecord }) { const bucket = bucketOf(props.record.state) if (bucket === 'busy') { return props.record.state === 'running' ? : ⋯ } if (bucket === 'ok') return return } /** The one-line status under a record's name; the bucket carries the rest. */ function statusLine(t: Translate, lang: 'zh' | 'en', record: OperationRecord, ahead: number | null): string { switch (record.state) { case 'queued': return ahead === null || ahead === 0 ? t('opQueued') : `${t('opQueued')} · ${t('opQueuedAhead')} ${String(ahead)}` case 'running': return record.detail ?? t('opRunning') case 'input': return t('opNeedsChoice') case 'failed': return record.reason !== undefined ? localizeBilingual(record.reason, lang) : t('installFail') case 'warned': return record.reason !== undefined ? localizeBilingual(record.reason, lang) : t('opDone') case 'done': return record.needsRefresh === true ? t('opDoneRefresh') : t('opDone') } } export function OperationsPanel(props: OperationsPanelProps) { const { t, records, open } = props const setOpen = props.onOpenChange const wrapRef = useRef(null) const summary = summarize(records) const busy = summary.running + summary.queued > 0 // Dismissing a popover by pressing the control that opened it is the one // route nobody looks for. Escape and an outside click are; the header also // carries an explicit collapse for anyone who wants a target to aim at. // The listener covers the whole wrapper, button included, so the button's // own toggle is not undone by this closing first. useEffect(() => { if (!open) return undefined const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false) } const onPointer = (event: MouseEvent) => { const wrap = wrapRef.current if (wrap !== null && !wrap.contains(event.target as Node)) setOpen(false) } document.addEventListener('keydown', onKey) document.addEventListener('mousedown', onPointer) return () => { document.removeEventListener('keydown', onKey) document.removeEventListener('mousedown', onPointer) } }, [open, setOpen]) // The entry label is the batch, not a verb with no object: a bare "3" says // nothing about what is happening to the profile. const label = busy ? `${t('opInstalling')} ${String(summary.progressed)}/${String(summary.total)}` : summary.attention > 0 ? `${String(summary.attention)} ${t('opNeedsYou')}` : t('opTitle') // Always render inside .opWrap so the tab-row bottom margin is stable — // the old idle-only {open && (
{t('opTitle')} {summary.settled > 0 && ( )}
{busy && (
{t('opInstalling')} {summary.progressed}/{summary.total}
{/* Long installs are the norm; saying so is what makes leaving the page an option rather than a gamble. */}
{t('opLeaveHint')}
)} {records.length === 0 && (
{t('opEmpty')}
{t('opEmptyHint')}
)} {sortForPanel(records).map((record) => { const ahead = queuePosition(records, record.id) return (
{t(`opKind_${record.kind}`)} {record.name}
{record.state === 'running' && typeof record.percent === 'number' && (
)}
{statusLine(t, props.lang, record, ahead)}
{needsUser(record) && ( props.onResolveConflict(record, choice)} /> )}
{record.state === 'running' && ( )} {record.state === 'queued' && ( <> {props.onRunNow !== undefined && ( )} )} {record.state === 'done' && record.needsRefresh === true && ( )} {record.state === 'failed' && (record.blockedBuilds ?? []).length > 0 && props.onApproveBuilds !== undefined && ( )} {record.state === 'failed' && (record.blockedBuilds ?? []).length === 0 && props.onRetry !== undefined && ( )} {record.state === 'warned' && record.heldRelease !== undefined && props.onForceInstall !== undefined && ( )} {isSettled(record) && ( )}
) })}
)}
) }