|
|
@@ -1,20 +1,24 @@
|
|
|
/**
|
|
|
- * Global plugin management: cards for the profile's installed bundles and for
|
|
|
- * the official bundles the installation ships switched off, their row
|
|
|
- * switches, the install dialog with its guide and folded pnpm output, the
|
|
|
- * uninstall confirmation, and the toasts an action's outcome becomes. A
|
|
|
- * bundle's page lists the rows it contributes as the Host runs them.
|
|
|
+ * Global plugin management: the Official group's cards for the bundles the
|
|
|
+ * installation ships switched off and for the official plugins that register
|
|
|
+ * their configuration, the Installed group's cards for the profile's bundles,
|
|
|
+ * their row switches, the install dialog with its guide and folded pnpm
|
|
|
+ * output, the uninstall confirmation, and the toasts an action's outcome
|
|
|
+ * becomes. A bundle's page lists the rows it contributes as the Host runs
|
|
|
+ * them; a plugin's configuration renders on its own page through the slots
|
|
|
+ * the page declares.
|
|
|
*/
|
|
|
|
|
|
import { useEffect, useId, useState, type ReactNode } from 'react'
|
|
|
import type { PluginInstallFailureKind } from '@deepseek-ai/dsh-api-remotes/client'
|
|
|
import {
|
|
|
- Button, IconCheckOutline16, IconChevronDownOutline14, IconChevronLeftOutline14, IconCloseOutline16, IconCordisPluginOutline14,
|
|
|
- IconPluginPinwheelOutline16, IconPlusOutline16, IconRefreshOutline16, IconTrashOutline16, IconWarningOutline16,
|
|
|
- Input, Modal, StateDot, Switch, Tag, TerminalBlock, Toast,
|
|
|
+ Button, IconCheckOutline16, IconChevronDownOutline14, IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseOutline16,
|
|
|
+ IconCordisPluginOutline14, IconPluginPinwheelOutline16, IconPlusOutline16, IconRefreshOutline16, IconTrashOutline16,
|
|
|
+ IconWarningOutline16, Input, Modal, StateDot, Switch, Tag, TerminalBlock, Toast,
|
|
|
type StateDotState, type TerminalBlockLabels,
|
|
|
} from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
-import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
+import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
+import { rowConfigKey, type OfficialItem } from './config-ledger.ts'
|
|
|
import type { PluginManagerLocaleKey } from './locales.ts'
|
|
|
import {
|
|
|
isInstallPending, rowKey,
|
|
|
@@ -22,14 +26,26 @@ import {
|
|
|
type PluginManagerFace,
|
|
|
} from './manager-store.ts'
|
|
|
import { managementText, noticeText, packageText, type Translate } from './presentation.ts'
|
|
|
+import type {} from './slot-contract.ts'
|
|
|
import css from './PluginManagerPage.module.css'
|
|
|
|
|
|
/** Full component props assembled by the main slot renderer. */
|
|
|
export type PluginManagerPageProps =
|
|
|
PropsRuntime<'main'>
|
|
|
& PropsLocale<'pluginManager'>
|
|
|
+ & PropsRenderSlots<'plugins.item' | 'plugins.bundle.config' | 'plugins.row.config'>
|
|
|
& InjectFace<PluginManagerFace>
|
|
|
|
|
|
+/** The page's slot renderer, narrowed to the configuration slots. */
|
|
|
+type RenderConfig = PluginManagerPageProps['renderSlot']
|
|
|
+
|
|
|
+/** What the page shows: the cards, a bundle's page, an official plugin's page, or a row's configuration page. */
|
|
|
+type View =
|
|
|
+ | { readonly kind: 'list' }
|
|
|
+ | { readonly kind: 'package'; readonly name: string }
|
|
|
+ | { readonly kind: 'item'; readonly id: string }
|
|
|
+ | { readonly kind: 'row'; readonly name: string; readonly rowId: string }
|
|
|
+
|
|
|
type RowPhase = NonNullable<PackageRow['phase']>
|
|
|
|
|
|
/** How long the list marks a package an install just enabled. */
|
|
|
@@ -76,6 +92,12 @@ interface RowToggles {
|
|
|
readonly onSetEnabled: (row: PackageRow, enabled: boolean) => void
|
|
|
}
|
|
|
|
|
|
+/** Configuration for a pack's rows: which rows registered a page of their own, and opening it. */
|
|
|
+interface RowConfigure {
|
|
|
+ readonly has: (row: PackageRow) => boolean
|
|
|
+ readonly open: (row: PackageRow) => void
|
|
|
+}
|
|
|
+
|
|
|
/** Rows beyond this count get a filter box above the list. */
|
|
|
const ROW_FILTER_THRESHOLD = 10
|
|
|
|
|
|
@@ -112,13 +134,15 @@ function rowDotState(row: PackageRow): StateDotState {
|
|
|
|
|
|
/**
|
|
|
* A pack's rows as a list in the order the pack declares them: a state dot,
|
|
|
- * the row id, one line saying its state, and, when the pack is on, a switch.
|
|
|
- * A pack like base carries close to a hundred rows, so a long list gets a filter.
|
|
|
+ * the row id, one line saying its state, a configure control for a row that
|
|
|
+ * registered a page, and, when the pack is on, a switch. A pack like base
|
|
|
+ * carries close to a hundred rows, so a long list gets a filter.
|
|
|
*/
|
|
|
-function RowsSection({ rows, t, toggle }: {
|
|
|
+function RowsSection({ rows, t, toggle, configure }: {
|
|
|
readonly rows: readonly PackageRow[]
|
|
|
readonly t: Translate
|
|
|
readonly toggle?: RowToggles | undefined
|
|
|
+ readonly configure?: RowConfigure | undefined
|
|
|
}): ReactNode {
|
|
|
const [filter, setFilter] = useState('')
|
|
|
const query = filter.trim().toLowerCase()
|
|
|
@@ -157,7 +181,14 @@ function RowsSection({ rows, t, toggle }: {
|
|
|
<div className={css.rowLine}>
|
|
|
<span className={css.rowIcon} aria-hidden="true"><IconCordisPluginOutline14 /></span>
|
|
|
<div className={css.rowMain}>
|
|
|
- <span className={css.rowId}>{row.rowId}</span>
|
|
|
+ {configure?.has(row) === true
|
|
|
+ ? (
|
|
|
+ <button type="button" className={css.rowOpen} aria-label={t('configureRow', { name: row.rowId })} onClick={() => { configure.open(row) }}>
|
|
|
+ <span className={css.rowId}>{row.rowId}</span>
|
|
|
+ <IconChevronRightOutline14 className={css.rowOpenIcon} aria-hidden="true" />
|
|
|
+ </button>
|
|
|
+ )
|
|
|
+ : <span className={css.rowId}>{row.rowId}</span>}
|
|
|
<span className={css.rowModule}>{row.moduleName}</span>
|
|
|
</div>
|
|
|
<span className={css.rowState}>
|
|
|
@@ -204,6 +235,52 @@ function packageStatus(pkg: PackageView): 'running' | 'disabled' | 'problem' {
|
|
|
return pkg.enabled ? 'running' : 'disabled'
|
|
|
}
|
|
|
|
|
|
+/** The head every card shares: the pinwheel icon, the name that opens the page beside its tags, its one-liner, and what sits at the end. */
|
|
|
+function CardHead({ title, t, onOpen, tags, description, end }: {
|
|
|
+ readonly title: string
|
|
|
+ readonly t: Translate
|
|
|
+ readonly onOpen: () => void
|
|
|
+ readonly tags?: ReactNode
|
|
|
+ readonly description: ReactNode
|
|
|
+ readonly end?: ReactNode
|
|
|
+}): ReactNode {
|
|
|
+ return (
|
|
|
+ <div className={css.cardHead}>
|
|
|
+ <span className={css.cardIcon} aria-hidden="true"><IconPluginPinwheelOutline16 size={20} /></span>
|
|
|
+ <div className={css.cardMain}>
|
|
|
+ <div className={css.titleRow}>
|
|
|
+ <button type="button" className={`${css.cardTitle} ${css.cardOpen}`} aria-label={t('openDetail', { name: title })} onClick={onOpen}>{title}</button>
|
|
|
+ {tags}
|
|
|
+ </div>
|
|
|
+ {description === undefined ? null : <span className={css.cardDesc}>{description}</span>}
|
|
|
+ </div>
|
|
|
+ {end === undefined ? null : <div className={css.cardEnd}>{end}</div>}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+/** The top every page shares: the crumb that leads back, then the icon with the page's actions at its right. */
|
|
|
+function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: {
|
|
|
+ readonly crumbLabel: string
|
|
|
+ readonly crumbText: string
|
|
|
+ readonly onBack: () => void
|
|
|
+ readonly icon?: ReactNode
|
|
|
+ readonly actions?: ReactNode
|
|
|
+}): ReactNode {
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <button type="button" className={css.crumb} aria-label={crumbLabel} onClick={onBack}>
|
|
|
+ <IconChevronDownOutline14 className={css.crumbIcon} aria-hidden="true" />
|
|
|
+ <span>{crumbText}</span>
|
|
|
+ </button>
|
|
|
+ <div className={css.detailHead}>
|
|
|
+ <span className={css.cardIcon} aria-hidden="true">{icon ?? <IconPluginPinwheelOutline16 size={20} />}</span>
|
|
|
+ {actions}
|
|
|
+ </div>
|
|
|
+ </>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
/** One package as a card that opens its page: its name, its one-liner, its tags, and its bundle switch. */
|
|
|
function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
|
|
|
readonly pkg: PackageView
|
|
|
@@ -213,7 +290,7 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
|
|
|
readonly onOpen: () => void
|
|
|
readonly onSetEnabled: (enabled: boolean) => void
|
|
|
}): ReactNode {
|
|
|
- const { title, description } = packageText(pkg, t)
|
|
|
+ const { title, description, beta } = packageText(pkg, t)
|
|
|
const status = packageStatus(pkg)
|
|
|
return (
|
|
|
<li
|
|
|
@@ -222,34 +299,103 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
|
|
|
data-plugin-status={status}
|
|
|
{...highlighted ? { 'data-plugin-highlight': '' } : {}}
|
|
|
>
|
|
|
- <div className={css.cardHead}>
|
|
|
- <span className={css.cardIcon} aria-hidden="true"><IconPluginPinwheelOutline16 size={20} /></span>
|
|
|
- <div className={css.cardMain}>
|
|
|
- <div className={css.titleRow}>
|
|
|
- <button type="button" className={`${css.cardTitle} ${css.cardOpen}`} aria-label={t('openDetail', { name: title })} onClick={onOpen}>{title}</button>
|
|
|
- {pkg.optional ? <Tag className={css.statusTag} tone="info">{t('statusOfficial')}</Tag> : null}
|
|
|
+ <CardHead
|
|
|
+ title={title}
|
|
|
+ t={t}
|
|
|
+ onOpen={onOpen}
|
|
|
+ tags={(
|
|
|
+ <>
|
|
|
+ {beta ? <Tag className={css.statusTag} tone="info">{t('statusBeta')}</Tag> : null}
|
|
|
{status === 'problem' ? <Tag className={css.statusTag} tone="danger">{t('statusProblem')}</Tag> : null}
|
|
|
- </div>
|
|
|
- {description === undefined ? null : <span className={css.cardDesc}>{description}</span>}
|
|
|
+ </>
|
|
|
+ )}
|
|
|
+ description={description}
|
|
|
+ end={<EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />}
|
|
|
+ />
|
|
|
+ </li>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * One official plugin as a card that opens its page: its icon, its title from
|
|
|
+ * the registration, and the one-liner the entry renders in its summary view.
|
|
|
+ */
|
|
|
+function ItemCard({ item, t, onOpen, renderSlot }: {
|
|
|
+ readonly item: OfficialItem
|
|
|
+ readonly t: Translate
|
|
|
+ readonly onOpen: () => void
|
|
|
+ readonly renderSlot: RenderConfig
|
|
|
+}): ReactNode {
|
|
|
+ return (
|
|
|
+ <li className={`${css.card} ${css.cardLink}`} data-plugin-item={item.id}>
|
|
|
+ <CardHead title={item.label} t={t} onOpen={onOpen} description={renderSlot('plugins.item', { view: 'summary' }, { only: item.id })} />
|
|
|
+ </li>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+/** An official plugin's page: the crumb back to the cards, its icon, its title over its one-liner, and the form the entry renders. */
|
|
|
+function ItemDetail({ item, t, onBack, renderSlot }: {
|
|
|
+ readonly item: OfficialItem
|
|
|
+ readonly t: Translate
|
|
|
+ readonly onBack: () => void
|
|
|
+ readonly renderSlot: RenderConfig
|
|
|
+}): ReactNode {
|
|
|
+ return (
|
|
|
+ <div className={css.detail} data-plugin-item-detail={item.id}>
|
|
|
+ <DetailTop crumbLabel={t('backToList')} crumbText={t('crumbRoot')} onBack={onBack} />
|
|
|
+ <div className={css.detailMain}>
|
|
|
+ <div className={css.titleRow}>
|
|
|
+ <h3 className={css.detailTitle}>{item.label}</h3>
|
|
|
</div>
|
|
|
- <div className={css.cardEnd}>
|
|
|
- <EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
|
|
|
+ <p className={css.detailDesc}>{renderSlot('plugins.item', { view: 'summary' }, { only: item.id })}</p>
|
|
|
+ </div>
|
|
|
+ <div className={css.detailSections} data-plugin-config>
|
|
|
+ {renderSlot('plugins.item', { view: 'page' }, { only: item.id })}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * A row's configuration page: the crumb back to its bundle's page, the row id
|
|
|
+ * over the module it names and the entry's one-liner, and the form the entry renders.
|
|
|
+ */
|
|
|
+function RowDetail({ pkg, row, t, onBack, renderSlot }: {
|
|
|
+ readonly pkg: PackageView
|
|
|
+ readonly row: PackageRow
|
|
|
+ readonly t: Translate
|
|
|
+ readonly onBack: () => void
|
|
|
+ readonly renderSlot: RenderConfig
|
|
|
+}): ReactNode {
|
|
|
+ const { title } = packageText(pkg, t)
|
|
|
+ const key = rowConfigKey(pkg.name, row.rowId)
|
|
|
+ return (
|
|
|
+ <div className={css.detail} data-plugin-row-detail={key}>
|
|
|
+ <DetailTop crumbLabel={t('backToPackage', { name: title })} crumbText={title} onBack={onBack} icon={<IconCordisPluginOutline14 size={20} />} />
|
|
|
+ <div className={css.detailMain}>
|
|
|
+ <div className={css.titleRow}>
|
|
|
+ <h3 className={css.detailTitle}>{row.rowId}</h3>
|
|
|
</div>
|
|
|
+ <p className={css.detailName}><code>{row.moduleName}</code></p>
|
|
|
+ <p className={css.detailDesc}>{renderSlot('plugins.row.config', { view: 'summary' }, { entryKey: key })}</p>
|
|
|
</div>
|
|
|
- </li>
|
|
|
+ <div className={css.detailSections} data-plugin-config>
|
|
|
+ {renderSlot('plugins.row.config', { view: 'page' }, { entryKey: key })}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* One package's page: the crumb back to the list; its icon with its switch
|
|
|
* and, for a package the profile installed, uninstall; its title beside its
|
|
|
- * version tag, its official tag, and its problem tag; the
|
|
|
- * package name the title stands for, which is what installs it elsewhere;
|
|
|
- * its one-liner; the Host's problem when it reports one; and its rows with
|
|
|
- * their switches.
|
|
|
+ * version tag, its beta tag, and its problem tag; the package name the title
|
|
|
+ * stands for, which is what installs it elsewhere; its one-liner; the Host's
|
|
|
+ * problem when it reports one; the configuration the bundle registered for
|
|
|
+ * itself; and its rows with their switches and configure controls.
|
|
|
*/
|
|
|
function PackageDetail({
|
|
|
- pkg, t, busy, rowBusy,
|
|
|
+ pkg, t, busy, rowBusy, configured, configure, renderSlot,
|
|
|
onBack, onSetEnabled, onUninstall, onSetRowEnabled,
|
|
|
}: {
|
|
|
readonly pkg: PackageView
|
|
|
@@ -257,45 +403,49 @@ function PackageDetail({
|
|
|
readonly busy: boolean
|
|
|
/** Whether a row has a write in flight. */
|
|
|
readonly rowBusy: (row: PackageRow) => boolean
|
|
|
+ /** Whether the bundle registered a configuration of its own. */
|
|
|
+ readonly configured: boolean
|
|
|
+ readonly configure: RowConfigure
|
|
|
+ readonly renderSlot: RenderConfig
|
|
|
readonly onBack: () => void
|
|
|
readonly onSetEnabled: (enabled: boolean) => void
|
|
|
readonly onUninstall: () => void
|
|
|
readonly onSetRowEnabled: (row: PackageRow, enabled: boolean) => void
|
|
|
}): ReactNode {
|
|
|
- const { title, description } = packageText(pkg, t)
|
|
|
+ const { title, description, beta } = packageText(pkg, t)
|
|
|
const status = packageStatus(pkg)
|
|
|
return (
|
|
|
<div className={css.detail} data-plugin-detail={pkg.name}>
|
|
|
- <button type="button" className={css.crumb} aria-label={t('backToList')} onClick={onBack}>
|
|
|
- <IconChevronDownOutline14 className={css.crumbIcon} aria-hidden="true" />
|
|
|
- <span>{t('crumbRoot')}</span>
|
|
|
- </button>
|
|
|
- <div className={css.detailHead}>
|
|
|
- <span className={css.cardIcon} aria-hidden="true"><IconPluginPinwheelOutline16 size={20} /></span>
|
|
|
- <div className={css.detailActions}>
|
|
|
- {pkg.installed
|
|
|
- ? (
|
|
|
- <Button
|
|
|
- variant="outline"
|
|
|
- size="sm"
|
|
|
- className={css.danger}
|
|
|
- icon={<IconTrashOutline16 size={13} />}
|
|
|
- aria-label={t('uninstallLabel', { name: title })}
|
|
|
- disabled={busy || pkg.readOnlyReason !== undefined}
|
|
|
- onClick={onUninstall}
|
|
|
- >
|
|
|
- {t('uninstall')}
|
|
|
- </Button>
|
|
|
- )
|
|
|
- : null}
|
|
|
- <EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ <DetailTop
|
|
|
+ crumbLabel={t('backToList')}
|
|
|
+ crumbText={t('crumbRoot')}
|
|
|
+ onBack={onBack}
|
|
|
+ actions={(
|
|
|
+ <div className={css.detailActions}>
|
|
|
+ {pkg.installed
|
|
|
+ ? (
|
|
|
+ <Button
|
|
|
+ variant="outline"
|
|
|
+ size="sm"
|
|
|
+ className={css.danger}
|
|
|
+ icon={<IconTrashOutline16 size={13} />}
|
|
|
+ aria-label={t('uninstallLabel', { name: title })}
|
|
|
+ disabled={busy || pkg.readOnlyReason !== undefined}
|
|
|
+ onClick={onUninstall}
|
|
|
+ >
|
|
|
+ {t('uninstall')}
|
|
|
+ </Button>
|
|
|
+ )
|
|
|
+ : null}
|
|
|
+ <EnableSwitch pkg={pkg} title={title} t={t} busy={busy} onSetEnabled={onSetEnabled} />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ />
|
|
|
<div className={css.detailMain}>
|
|
|
<div className={css.titleRow}>
|
|
|
<h3 className={css.detailTitle}>{title}</h3>
|
|
|
- {pkg.version === undefined ? null : <span className={css.versionTag} data-plugin-version>{t('versionTag', { version: pkg.version })}</span>}
|
|
|
- {pkg.optional ? <Tag className={css.statusTag} tone="info">{t('statusOfficial')}</Tag> : null}
|
|
|
+ {pkg.version === undefined ? null : <Tag className={css.versionTag} tone="neutral">{t('versionTag', { version: pkg.version })}</Tag>}
|
|
|
+ {beta ? <Tag className={css.statusTag} tone="info">{t('statusBeta')}</Tag> : null}
|
|
|
{status === 'problem' ? <Tag className={css.statusTag} tone="danger">{t('statusProblem')}</Tag> : null}
|
|
|
</div>
|
|
|
<p className={css.detailName}><code data-plugin-name>{pkg.name}</code></p>
|
|
|
@@ -304,10 +454,18 @@ function PackageDetail({
|
|
|
{pkg.error === undefined ? null : <p className={css.reason} role="status">{t('reasonLabel')}: {managementText(pkg.error, t)}</p>}
|
|
|
{pkg.readOnlyReason === undefined ? null : <p className={css.reason} role="status">{managementText({ code: pkg.readOnlyReason }, t)}</p>}
|
|
|
<div className={css.detailSections}>
|
|
|
+ {configured
|
|
|
+ ? (
|
|
|
+ <section className={css.detailSection} data-plugin-config>
|
|
|
+ {renderSlot('plugins.bundle.config', { view: 'page' }, { entryKey: pkg.name })}
|
|
|
+ </section>
|
|
|
+ )
|
|
|
+ : null}
|
|
|
<RowsSection
|
|
|
rows={pkg.rows}
|
|
|
t={t}
|
|
|
toggle={pkg.enabled ? { busy: row => busy || rowBusy(row), onSetEnabled: onSetRowEnabled } : undefined}
|
|
|
+ configure={configure}
|
|
|
/>
|
|
|
</div>
|
|
|
</div>
|
|
|
@@ -427,13 +585,17 @@ function SubjectCard({ subject, t }: { readonly subject: InstallSubject; readonl
|
|
|
* and failed screens over the same subject card. A failed run that left
|
|
|
* install scripts undecided shows them for approval in place of plain retry.
|
|
|
*/
|
|
|
-function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onToggleDetails, onEnableNow, onApproveBuilds }: {
|
|
|
+function InstallDialog({
|
|
|
+ install, t, onClose, onEditSpec, onRun, onCancel, onCancelAndClose, onToggleDetails, onEnableNow, onApproveBuilds,
|
|
|
+}: {
|
|
|
readonly install: InstallState
|
|
|
readonly t: Translate
|
|
|
readonly onClose: () => void
|
|
|
readonly onEditSpec: (text: string) => void
|
|
|
readonly onRun: () => void
|
|
|
readonly onCancel: () => void
|
|
|
+ /** The close control while the Host runs the install: stop the run, then close. */
|
|
|
+ readonly onCancelAndClose: () => void
|
|
|
readonly onToggleDetails: () => void
|
|
|
readonly onEnableNow: () => void
|
|
|
readonly onApproveBuilds: () => void
|
|
|
@@ -499,7 +661,10 @@ function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onTog
|
|
|
<span className={css.guideIndex} aria-hidden="true">{index + 1}</span>
|
|
|
<div className={css.guideMain}>
|
|
|
<span className={css.guideTitle}>{t(titleKey)}</span>
|
|
|
- <code className={css.guideExample}>{t(exampleKey)}</code>
|
|
|
+ <span className={css.guideExample}>
|
|
|
+ <span className={css.guideExampleLabel}>{t('installGuideExampleLabel')}</span>
|
|
|
+ <code>{t(exampleKey)}</code>
|
|
|
+ </span>
|
|
|
<span className={css.guideHint}>{t(hintKey)}</span>
|
|
|
</div>
|
|
|
<Button
|
|
|
@@ -545,7 +710,13 @@ function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onTog
|
|
|
<span>{t('installEdit')}</span>
|
|
|
</button>
|
|
|
)}
|
|
|
- <button type="button" className={css.wizardClose} aria-label={t('close')} disabled={pending} onClick={onClose}>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ className={css.wizardClose}
|
|
|
+ aria-label={t(phase === 'running' ? 'installCloseCancels' : 'close')}
|
|
|
+ disabled={pending && phase !== 'running'}
|
|
|
+ onClick={phase === 'running' ? onCancelAndClose : onClose}
|
|
|
+ >
|
|
|
<IconCloseOutline16 size={14} />
|
|
|
</button>
|
|
|
</div>
|
|
|
@@ -655,12 +826,13 @@ function ConfirmDialog({ confirm, t, onConfirm, onCancel }: {
|
|
|
)
|
|
|
}
|
|
|
|
|
|
-/** Render the plugin manager: the installed bundles, the install dialog, and the confirmation. */
|
|
|
+/** Render the plugin manager: the official plugins and installed bundles, their pages, the install dialog, and the confirmation. */
|
|
|
export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
|
|
- const { t, ensure } = props
|
|
|
+ const { t, ensure, renderSlot } = props
|
|
|
const state = props.usePluginManager(snapshot => snapshot)
|
|
|
- // The package whose page is open; one that leaves the list (uninstalled) drops back to the cards.
|
|
|
- const [openPackage, setOpenPackage] = useState<string | null>(null)
|
|
|
+ const ledger = props.useConfigLedger(snapshot => snapshot)
|
|
|
+ // What is open; a package that leaves the list (uninstalled) drops back to the cards.
|
|
|
+ const [view, setView] = useState<View>({ kind: 'list' })
|
|
|
useEffect(() => { ensure() }, [ensure])
|
|
|
// A package an install just enabled: scroll it into view and mark it for a moment.
|
|
|
const { highlight, clearHighlight } = { highlight: state.highlight, clearHighlight: props.clearHighlight }
|
|
|
@@ -678,41 +850,54 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
|
|
// Plugins section's Plugin list tab.
|
|
|
const listed = state.packages.filter(pkg => pkg.installed || pkg.optional || pkg.error !== undefined)
|
|
|
const mine = listed.filter(pkg => pkg.installed || !pkg.optional)
|
|
|
- const builtin = listed.filter(pkg => pkg.optional && !pkg.installed)
|
|
|
+ const official = listed.filter(pkg => pkg.optional && !pkg.installed)
|
|
|
const loaded = state.status === 'ready' || state.status === 'error'
|
|
|
- const openPkg = openPackage === null ? undefined : listed.find(pkg => pkg.name === openPackage)
|
|
|
+ const openPkg = view.kind === 'package' || view.kind === 'row' ? listed.find(pkg => pkg.name === view.name) : undefined
|
|
|
+ const openItem = view.kind === 'item' ? ledger.items.find(item => item.id === view.id) : undefined
|
|
|
+ const openRow = view.kind === 'row' && openPkg !== undefined ? openPkg.rows.find(row => row.rowId === view.rowId) : undefined
|
|
|
+ const showsCards = openPkg === undefined && openItem === undefined
|
|
|
const setRowEnabled = (row: PackageRow, enabled: boolean): void => {
|
|
|
/* v8 ignore next -- a row without a live entry has its switch disabled */
|
|
|
if (row.entryId !== undefined) props.setRowEnabled(row.entryId, enabled)
|
|
|
}
|
|
|
- // One group of cards under its heading and count; the built-in group comes first, and a group with nothing in it takes no room.
|
|
|
- const renderGroup = (id: 'bundles' | 'builtin', heading: string, packages: readonly PackageView[]): ReactNode => packages.length === 0
|
|
|
+ const configure = (pkg: PackageView): RowConfigure => ({
|
|
|
+ has: row => ledger.rows.has(rowConfigKey(pkg.name, row.rowId)),
|
|
|
+ open: (row) => { setView({ kind: 'row', name: pkg.name, rowId: row.rowId }) },
|
|
|
+ })
|
|
|
+ const packageCard = (pkg: PackageView): ReactNode => (
|
|
|
+ <PackageCard
|
|
|
+ key={pkg.name}
|
|
|
+ pkg={pkg}
|
|
|
+ t={t}
|
|
|
+ busy={state.busy.includes(pkg.name)}
|
|
|
+ highlighted={state.highlight === pkg.name}
|
|
|
+ onOpen={() => { setView({ kind: 'package', name: pkg.name }) }}
|
|
|
+ onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }}
|
|
|
+ />
|
|
|
+ )
|
|
|
+ // The Official group: the bundles the installation ships, then the plugins that registered their configuration.
|
|
|
+ const officialCards = [
|
|
|
+ ...official.map(packageCard),
|
|
|
+ ...ledger.items.map(item => (
|
|
|
+ <ItemCard key={`item:${item.id}`} item={item} t={t} renderSlot={renderSlot} onOpen={() => { setView({ kind: 'item', id: item.id }) }} />
|
|
|
+ )),
|
|
|
+ ]
|
|
|
+ // One group of cards under its heading and count; the Official group comes first, and a group with nothing in it takes no room.
|
|
|
+ const renderGroup = (id: 'official' | 'bundles', heading: string, cards: readonly ReactNode[]): ReactNode => cards.length === 0
|
|
|
? null
|
|
|
: (
|
|
|
<section className={css.group} data-plugin-scope="global" data-plugin-group={id}>
|
|
|
<div className={css.groupHead}>
|
|
|
<h3 className={css.groupTitle}>{heading}</h3>
|
|
|
- <span className={css.count} data-plugin-count={packages.length}>{packages.length}</span>
|
|
|
+ <span className={css.count} data-plugin-count={cards.length}>{cards.length}</span>
|
|
|
</div>
|
|
|
- <ul className={css.cards}>
|
|
|
- {packages.map(pkg => (
|
|
|
- <PackageCard
|
|
|
- key={pkg.name}
|
|
|
- pkg={pkg}
|
|
|
- t={t}
|
|
|
- busy={state.busy.includes(pkg.name)}
|
|
|
- highlighted={state.highlight === pkg.name}
|
|
|
- onOpen={() => { setOpenPackage(pkg.name) }}
|
|
|
- onSetEnabled={(enabled) => { props.setEnabled(pkg.name, enabled) }}
|
|
|
- />
|
|
|
- ))}
|
|
|
- </ul>
|
|
|
+ <ul className={css.cards}>{cards}</ul>
|
|
|
</section>
|
|
|
)
|
|
|
|
|
|
return (
|
|
|
<section className={css.page} data-plugin-panel aria-busy={state.status === 'loading'}>
|
|
|
- {openPkg === undefined
|
|
|
+ {showsCards
|
|
|
? (
|
|
|
<header className={css.pageHead}>
|
|
|
<div>
|
|
|
@@ -749,27 +934,44 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
|
|
onDone={props.dismissNotice}
|
|
|
/>
|
|
|
)}
|
|
|
- {loaded && openPkg !== undefined
|
|
|
+ {loaded && openPkg !== undefined && openRow !== undefined
|
|
|
+ ? (
|
|
|
+ <RowDetail
|
|
|
+ pkg={openPkg}
|
|
|
+ row={openRow}
|
|
|
+ t={t}
|
|
|
+ renderSlot={renderSlot}
|
|
|
+ onBack={() => { setView({ kind: 'package', name: openPkg.name }) }}
|
|
|
+ />
|
|
|
+ )
|
|
|
+ : null}
|
|
|
+ {loaded && openPkg !== undefined && openRow === undefined
|
|
|
? (
|
|
|
<PackageDetail
|
|
|
pkg={openPkg}
|
|
|
t={t}
|
|
|
busy={state.busy.includes(openPkg.name)}
|
|
|
rowBusy={row => row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))}
|
|
|
- onBack={() => { setOpenPackage(null) }}
|
|
|
+ configured={ledger.bundles.has(openPkg.name)}
|
|
|
+ configure={configure(openPkg)}
|
|
|
+ renderSlot={renderSlot}
|
|
|
+ onBack={() => { setView({ kind: 'list' }) }}
|
|
|
onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }}
|
|
|
onUninstall={() => { props.uninstall(openPkg.name) }}
|
|
|
onSetRowEnabled={setRowEnabled}
|
|
|
/>
|
|
|
)
|
|
|
: null}
|
|
|
- {loaded && openPkg === undefined
|
|
|
- ? listed.length === 0
|
|
|
+ {loaded && openItem !== undefined
|
|
|
+ ? <ItemDetail item={openItem} t={t} renderSlot={renderSlot} onBack={() => { setView({ kind: 'list' }) }} />
|
|
|
+ : null}
|
|
|
+ {loaded && showsCards
|
|
|
+ ? officialCards.length === 0 && mine.length === 0
|
|
|
? <p className={css.empty}>{t('empty')}</p>
|
|
|
: (
|
|
|
<>
|
|
|
- {renderGroup('builtin', t('builtinTitle'), builtin)}
|
|
|
- {renderGroup('bundles', t('bundlesTitle'), mine)}
|
|
|
+ {renderGroup('official', t('officialTitle'), officialCards)}
|
|
|
+ {renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))}
|
|
|
</>
|
|
|
)
|
|
|
: null}
|
|
|
@@ -780,6 +982,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
|
|
onEditSpec={props.editInstallSpec}
|
|
|
onRun={props.runInstall}
|
|
|
onCancel={props.cancelInstall}
|
|
|
+ onCancelAndClose={props.cancelInstallAndClose}
|
|
|
onToggleDetails={props.toggleInstallDetails}
|
|
|
onEnableNow={props.enableInstalled}
|
|
|
onApproveBuilds={props.approveBuildsAndRetry}
|