feat(plugin-inventory): offline bundle and pnpm plugin install for the desktop
Adds an install surface to the plugin-inventory gateway: availableBundles lists the curated offline-installable optional bundles (AVAILABLE_BUNDLES); install composes an offline bundle into the profile's dsh.profile.bundles, or for a registry spec runs pnpm against the writable profile via the bundled Node and a vendored pnpm (gated behind the dshAllowPluginInstall context flag, set only by the desktop boot); uninstall removes a bundle layer. The reconcile logic from `dsh plugin add` moves into app-boot as shared helpers. The desktop vendored pnpm into the harness and sets the allow-install env; the plugin-list SPA gains an installable-bundles section. Tests cover the guard, install helpers, and the SPA section at 100% host coverage. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+50
@@ -266,6 +266,56 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.install {
|
||||
margin-bottom: 1.25rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--dsh-border);
|
||||
}
|
||||
|
||||
.install h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.installList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.installRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.installName {
|
||||
overflow-wrap: anywhere;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.installAction {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.installRestart {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.installError {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--dsw-alias-danger-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.chevron {
|
||||
transition: transform 140ms var(--ds-ease-in-out);
|
||||
|
||||
+104
-30
@@ -1,5 +1,10 @@
|
||||
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
|
||||
import type { PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {
|
||||
AvailableBundlesSnapshot,
|
||||
InstallResult,
|
||||
InstallSpec,
|
||||
PluginInventorySnapshot,
|
||||
} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import {
|
||||
IconChevronDownOutline14,
|
||||
IconSearchOutline16,
|
||||
@@ -14,6 +19,12 @@ export interface PluginInventorySettingsTabInjected {
|
||||
list: () => Promise<PluginInventorySnapshot>
|
||||
/** Toggle one plugin entry on or off; persists across a restart. */
|
||||
setEnabled: (entryId: PluginInventoryEntry['entryId'], enabled: boolean) => Promise<void>
|
||||
/** List the offline-installable optional bundles. */
|
||||
availableBundles: () => Promise<AvailableBundlesSnapshot>
|
||||
/** Install a bundle or registry plugin; the host persists the change. */
|
||||
install: (spec: InstallSpec) => Promise<InstallResult>
|
||||
/** Un-compose an offline optional bundle. */
|
||||
uninstall: (name: string) => Promise<InstallResult>
|
||||
}
|
||||
|
||||
type PluginInventoryEntry = PluginInventorySnapshot['entries'][number]
|
||||
@@ -62,14 +73,24 @@ function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean
|
||||
.some(value => value.toLocaleLowerCase().includes(normalizedQuery))
|
||||
}
|
||||
|
||||
/** Render the current Loader inventory with per-plugin enable/disable. */
|
||||
export function PluginInventorySettingsTab({ list, setEnabled, t }: PluginInventorySettingsTabProps): ReactNode {
|
||||
type BundleState =
|
||||
| { readonly status: 'loading' }
|
||||
| { readonly status: 'ready'; readonly snapshot: AvailableBundlesSnapshot }
|
||||
| { readonly status: 'error' }
|
||||
|
||||
/** Render the current Loader inventory with per-plugin enable/disable and install. */
|
||||
export function PluginInventorySettingsTab({
|
||||
list, setEnabled, availableBundles, install, uninstall, t,
|
||||
}: PluginInventorySettingsTabProps): ReactNode {
|
||||
const catalogId = useId()
|
||||
const [request, setRequest] = useState(0)
|
||||
const [query, setQuery] = useState('')
|
||||
const [expanded, setExpanded] = useState<PluginInventoryEntry['entryId'] | null>(null)
|
||||
const [state, setState] = useState<ViewState>({ status: 'loading' })
|
||||
const [busy, setBusy] = useState<PluginInventoryEntry['entryId'] | null>(null)
|
||||
const [bundles, setBundles] = useState<BundleState>({ status: 'loading' })
|
||||
const [installBusy, setInstallBusy] = useState<string | null>(null)
|
||||
const [installNote, setInstallNote] = useState<{ kind: 'restart' | 'error'; text: string } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let current = true
|
||||
@@ -80,14 +101,40 @@ export function PluginInventorySettingsTab({ list, setEnabled, t }: PluginInvent
|
||||
return () => { current = false }
|
||||
}, [list, request])
|
||||
|
||||
useEffect(() => {
|
||||
let current = true
|
||||
void Promise.resolve().then(() => availableBundles()).then(
|
||||
(snapshot) => { if (current) setBundles({ status: 'ready', snapshot }) },
|
||||
() => { if (current) setBundles({ status: 'error' }) },
|
||||
)
|
||||
return () => { current = false }
|
||||
}, [availableBundles, request])
|
||||
|
||||
/** Toggle one entry then re-read the inventory. */
|
||||
const toggle = (entryId: PluginInventoryEntry['entryId'], enabled: boolean): void => {
|
||||
if (busy !== null) return
|
||||
setBusy(entryId)
|
||||
void setEnabled(entryId, enabled).then(
|
||||
() => setRequest(value => value + 1),
|
||||
() => { setRequest(value => value + 1) },
|
||||
() => { /* the next list reflects the unchanged state */ },
|
||||
).finally(() => setBusy(null))
|
||||
).finally(() => { setBusy(null) })
|
||||
}
|
||||
|
||||
/** Install or uninstall an optional bundle, then re-read the catalog. */
|
||||
const mutateBundle = (name: string, mode: 'install' | 'uninstall'): void => {
|
||||
if (installBusy !== null) return
|
||||
setInstallBusy(name)
|
||||
setInstallNote(null)
|
||||
const action = mode === 'install'
|
||||
? install({ type: 'bundle', name })
|
||||
: uninstall(name)
|
||||
void action.then(
|
||||
() => {
|
||||
setInstallNote({ kind: 'restart', text: t('restartRequired') })
|
||||
setRequest(value => value + 1)
|
||||
},
|
||||
() => { setInstallNote({ kind: 'error', text: t('installFailed') }) },
|
||||
).finally(() => { setInstallBusy(null) })
|
||||
}
|
||||
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase()
|
||||
@@ -207,32 +254,59 @@ export function PluginInventorySettingsTab({ list, setEnabled, t }: PluginInvent
|
||||
</div>
|
||||
) : null}
|
||||
{state.status === 'ready' ? (
|
||||
<div className={css.catalog}>
|
||||
<label className={css.search}>
|
||||
<IconSearchOutline16 aria-hidden="true" />
|
||||
<span className={css.visuallyHidden}>{t('search')}</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder={t('search')}
|
||||
aria-label={t('search')}
|
||||
onChange={(event) => { setQuery(event.currentTarget.value) }}
|
||||
/>
|
||||
</label>
|
||||
<div className={css.catalogHeading}>
|
||||
<h3>{t('catalog')}</h3>
|
||||
<span data-plugin-count={filteredEntries.length}>{filteredEntries.length}</span>
|
||||
</div>
|
||||
{state.snapshot.entries.length === 0 ? <p className={css.status}>{t('empty')}</p> : null}
|
||||
{state.snapshot.entries.length > 0 && filteredEntries.length === 0
|
||||
? <p className={css.status}>{t('emptySearch')}</p>
|
||||
: null}
|
||||
{filteredEntries.length > 0 ? (
|
||||
<ul className={css.cards}>
|
||||
{filteredEntries.map(card)}
|
||||
</ul>
|
||||
<>
|
||||
{bundles.status === 'ready' && bundles.snapshot.available.length > 0 ? (
|
||||
<section className={css.install} aria-label={t('available')}>
|
||||
<h3>{t('available')}</h3>
|
||||
{installNote !== null
|
||||
? <p className={installNote.kind === 'error' ? css.installError : css.installRestart} role="status">{installNote.text}</p>
|
||||
: null}
|
||||
<ul className={css.installList}>
|
||||
{bundles.snapshot.available.map(bundle => (
|
||||
<li key={bundle.name} className={css.installRow}>
|
||||
<span className={css.installName}>{bundle.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.installAction}
|
||||
disabled={installBusy !== null}
|
||||
onClick={() => { mutateBundle(bundle.name, bundle.installed ? 'uninstall' : 'install') }}
|
||||
>
|
||||
{installBusy === bundle.name
|
||||
? t('installing')
|
||||
: bundle.installed ? t('uninstall') : t('install')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={css.catalog}>
|
||||
<label className={css.search}>
|
||||
<IconSearchOutline16 aria-hidden="true" />
|
||||
<span className={css.visuallyHidden}>{t('search')}</span>
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder={t('search')}
|
||||
aria-label={t('search')}
|
||||
onChange={(event) => { setQuery(event.currentTarget.value) }}
|
||||
/>
|
||||
</label>
|
||||
<div className={css.catalogHeading}>
|
||||
<h3>{t('catalog')}</h3>
|
||||
<span data-plugin-count={filteredEntries.length}>{filteredEntries.length}</span>
|
||||
</div>
|
||||
{state.snapshot.entries.length === 0 ? <p className={css.status}>{t('empty')}</p> : null}
|
||||
{state.snapshot.entries.length > 0 && filteredEntries.length === 0
|
||||
? <p className={css.status}>{t('emptySearch')}</p>
|
||||
: null}
|
||||
{filteredEntries.length > 0 ? (
|
||||
<ul className={css.cards}>
|
||||
{filteredEntries.map(card)}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -40,7 +40,30 @@ export function apply(ctx: ClientContext): void {
|
||||
throw new Error(`pluginInventory.setEnabled failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
}
|
||||
const injected = (): PluginInventorySettingsTabInjected => ({ list, setEnabled })
|
||||
const availableBundles: PluginInventorySettingsTabInjected['availableBundles'] = async () => {
|
||||
const result = await ctx.remote.pluginInventory.availableBundles()
|
||||
if (!result.ok) {
|
||||
throw new Error(`pluginInventory.availableBundles failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
return result.value
|
||||
}
|
||||
const install: PluginInventorySettingsTabInjected['install'] = async (spec) => {
|
||||
const result = await ctx.remote.pluginInventory.install(spec)
|
||||
if (!result.ok) {
|
||||
throw new Error(`pluginInventory.install failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
return result.value
|
||||
}
|
||||
const uninstall: PluginInventorySettingsTabInjected['uninstall'] = async (name) => {
|
||||
const result = await ctx.remote.pluginInventory.uninstall(name)
|
||||
if (!result.ok) {
|
||||
throw new Error(`pluginInventory.uninstall failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
return result.value
|
||||
}
|
||||
const injected = (): PluginInventorySettingsTabInjected => ({
|
||||
list, setEnabled, availableBundles, install, uninstall,
|
||||
})
|
||||
|
||||
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
||||
name: 'settings.plugins.tab',
|
||||
|
||||
@@ -24,6 +24,12 @@ export const zh = {
|
||||
disable: '停用',
|
||||
toggling: '切换中…',
|
||||
required: '应用必需插件,不可切换',
|
||||
available: '可安装插件',
|
||||
install: '安装',
|
||||
uninstall: '卸载',
|
||||
installing: '处理中…',
|
||||
restartRequired: '已应用,重启后生效',
|
||||
installFailed: '安装失败',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** Plugin inventory locale key union. */
|
||||
@@ -53,4 +59,10 @@ export const en = {
|
||||
disable: 'Disable',
|
||||
toggling: 'Toggling…',
|
||||
required: 'Required by the app; cannot be toggled',
|
||||
available: 'Installable plugins',
|
||||
install: 'Install',
|
||||
uninstall: 'Uninstall',
|
||||
installing: 'Working…',
|
||||
restartRequired: 'Applied; restart to activate',
|
||||
installFailed: 'Install failed',
|
||||
} satisfies Record<PluginInventoryLocaleKey, string>
|
||||
|
||||
@@ -13,10 +13,18 @@ afterEach(cleanup)
|
||||
type Snapshot = Awaited<ReturnType<PluginInventorySettingsTabInjected['list']>>
|
||||
const t = ((key: PluginInventoryLocaleKey): string => en[key]) as PluginInventorySettingsTabProps['t']
|
||||
|
||||
function props(list: PluginInventorySettingsTabInjected['list']): PluginInventorySettingsTabProps {
|
||||
function props(
|
||||
list: PluginInventorySettingsTabInjected['list'],
|
||||
bundles: PluginInventorySettingsTabInjected['availableBundles'] = async () => ({ available: [] }),
|
||||
install: PluginInventorySettingsTabInjected['install'] = async () => ({ ok: true as const, restartRequired: true }),
|
||||
uninstall: PluginInventorySettingsTabInjected['uninstall'] = async () => ({ ok: true as const, restartRequired: true }),
|
||||
): PluginInventorySettingsTabProps {
|
||||
return {
|
||||
t,
|
||||
list,
|
||||
availableBundles: bundles,
|
||||
install,
|
||||
uninstall,
|
||||
} as PluginInventorySettingsTabProps
|
||||
}
|
||||
|
||||
@@ -153,4 +161,29 @@ describe('PluginInventorySettingsTab', () => {
|
||||
pendingFailure.unmount()
|
||||
await act(async () => { deferredFailure.reject(new Error('late failure')) })
|
||||
})
|
||||
|
||||
it('renders the installable bundles with install and uninstall actions', async () => {
|
||||
const install = vi.fn<PluginInventorySettingsTabInjected['install']>(async () => ({ ok: true, restartRequired: true }))
|
||||
const uninstall = vi.fn<PluginInventorySettingsTabInjected['uninstall']>(async () => ({ ok: true, restartRequired: true }))
|
||||
const bundles: PluginInventorySettingsTabInjected['availableBundles'] = async () => ({
|
||||
available: [
|
||||
{ name: '@deepseek-ai/dsh-new-bundle', installed: false },
|
||||
{ name: '@deepseek-ai/dsh-installed-bundle', installed: true },
|
||||
],
|
||||
})
|
||||
render(<PluginInventorySettingsTab {...props(async () => SNAPSHOT, bundles, install, uninstall)} />)
|
||||
|
||||
expect(await screen.findByText(en.available)).toBeTruthy()
|
||||
expect(screen.getByText('@deepseek-ai/dsh-new-bundle')).toBeTruthy()
|
||||
expect(screen.getByText('@deepseek-ai/dsh-installed-bundle')).toBeTruthy()
|
||||
|
||||
// Install a not-installed bundle.
|
||||
fireEvent.click(screen.getByRole('button', { name: en.install }))
|
||||
expect(install).toHaveBeenCalledWith({ type: 'bundle', name: '@deepseek-ai/dsh-new-bundle' })
|
||||
expect(await screen.findByText(en.restartRequired)).toBeTruthy()
|
||||
|
||||
// Uninstall an installed bundle.
|
||||
fireEvent.click(screen.getByRole('button', { name: en.uninstall }))
|
||||
expect(uninstall).toHaveBeenCalledWith('@deepseek-ai/dsh-installed-bundle')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user