feat(plugin-inventory): live plugin activation, mirror install, and toggle lists

Installing a plugin now activates it without a restart: the CLI boot provides a
dshReloadProfile handle that re-runs the profile composition and applies it to
the running root Include, and the install/uninstall Remotes recompose live when
the handle is present (restartRequired: false). Registry installs try the
ordered INSTALL_REGISTRIES mirrors with the official npm registry as the final
fallback, erroring only when every source is unreachable. The enable/disable
guard splits into a REQUIRED_PLUGINS blacklist and a USER_TOGGLEABLE_PLUGINS
whitelist (default toggleable) generated from the running plugin list, and the
offline optional-bundle catalog is emptied (default bundles are not
installable/uninstallable). The plugin-list tab becomes a registry install form
and shows immediate-activation instead of a restart notice.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-14 20:45:00 +08:00
parent a0efe02c04
commit e18cfbfb93
16 changed files with 486 additions and 196 deletions
@@ -278,26 +278,15 @@
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;
gap: 0.5rem;
}
.installName {
overflow-wrap: anywhere;
font-family: var(--ds-font-family-code);
font-size: 12px;
.installInput {
flex: 1;
min-width: 0;
}
.installAction {
@@ -1,10 +1,5 @@
import { useEffect, useId, useMemo, useState, type ReactNode } from 'react'
import type {
AvailableBundlesSnapshot,
InstallResult,
InstallSpec,
PluginInventorySnapshot,
} from '@deepseek-ai/dsh-api-remotes/client'
import type { InstallResult, InstallSpec, PluginInventorySnapshot } from '@deepseek-ai/dsh-api-remotes/client'
import {
IconChevronDownOutline14,
IconSearchOutline16,
@@ -19,12 +14,8 @@ 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 a registry plugin by package name; the host persists the change. */
installPlugin: (spec: InstallSpec) => Promise<InstallResult>
/** Un-compose an offline optional bundle. */
uninstall: (name: string) => Promise<InstallResult>
}
type PluginInventoryEntry = PluginInventorySnapshot['entries'][number]
@@ -73,14 +64,9 @@ function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean
.some(value => value.toLocaleLowerCase().includes(normalizedQuery))
}
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, installPlugin, uninstall, t,
list, setEnabled, installPlugin, t,
}: PluginInventorySettingsTabProps): ReactNode {
const catalogId = useId()
const [request, setRequest] = useState(0)
@@ -88,8 +74,8 @@ export function PluginInventorySettingsTab({
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 [spec, setSpec] = useState('')
const [installBusy, setInstallBusy] = useState(false)
const [installNote, setInstallNote] = useState<{ kind: 'restart' | 'error'; text: string } | null>(null)
useEffect(() => {
@@ -101,15 +87,6 @@ export function PluginInventorySettingsTab({
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
@@ -120,21 +97,25 @@ export function PluginInventorySettingsTab({
).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)
/** Install a plugin by package name via the registry, then re-read. */
const installRegistry = (): void => {
const trimmed = spec.trim()
if (trimmed.length === 0 || installBusy) return
setInstallBusy(true)
setInstallNote(null)
const action = mode === 'install'
? installPlugin({ type: 'bundle', name })
: uninstall(name)
void action.then(
() => {
setInstallNote({ kind: 'restart', text: t('restartRequired') })
void installPlugin({ type: 'registry', spec: trimmed }).then(
(result) => {
// A live recompose activates the plugin immediately; only fall back to a
// restart notice when no reload handle exists.
setInstallNote({ kind: 'restart', text: t(result.restartRequired ? 'restartRequired' : 'installed') })
setSpec('')
setRequest(value => value + 1)
},
() => { setInstallNote({ kind: 'error', text: t('installFailed') }) },
).finally(() => { setInstallBusy(null) })
(error: unknown) => {
const detail = error instanceof Error ? error.message : String(error)
setInstallNote({ kind: 'error', text: `${t('installFailed')}: ${detail}` })
},
).finally(() => { setInstallBusy(false) })
}
const normalizedQuery = query.trim().toLocaleLowerCase()
@@ -255,31 +236,31 @@ export function PluginInventorySettingsTab({
) : null}
{state.status === 'ready' ? (
<>
{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}
<section className={css.install} aria-label={t('installPlugin')}>
<h3>{t('installPlugin')}</h3>
<div className={css.installRow}>
<input
type="text"
className={css.installInput}
value={spec}
placeholder={t('installSpec')}
aria-label={t('installSpec')}
onChange={(event) => { setSpec(event.currentTarget.value) }}
onKeyDown={(event) => { if (event.key === 'Enter') installRegistry() }}
/>
<button
type="button"
className={css.installAction}
disabled={installBusy || spec.trim().length === 0}
onClick={installRegistry}
>
{installBusy ? t('installing') : t('install')}
</button>
</div>
{installNote !== null
? <p className={installNote.kind === 'error' ? css.installError : css.installRestart} role="status">{installNote.text}</p>
: null}
</section>
<div className={css.catalog}>
<label className={css.search}>
<IconSearchOutline16 aria-hidden="true" />
@@ -40,13 +40,6 @@ export function apply(ctx: ClientContext): void {
throw new Error(`pluginInventory.setEnabled failed: ${result.error.code}: ${result.error.message}`)
}
}
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 installPlugin: PluginInventorySettingsTabInjected['installPlugin'] = async (spec) => {
const result = await ctx.remote.pluginInventory.installPlugin(spec)
if (!result.ok) {
@@ -54,15 +47,8 @@ export function apply(ctx: ClientContext): void {
}
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, installPlugin, uninstall,
list, setEnabled, installPlugin,
})
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
@@ -24,11 +24,12 @@ export const zh = {
disable: '停用',
toggling: '切换中…',
required: '应用必需插件,不可切换',
available: '安装插件',
installPlugin: '安装插件',
installSpec: '输入插件包名,如 @scope/plugin',
install: '安装',
uninstall: '卸载',
installing: '处理中…',
restartRequired: '已应用,重启后生效',
installing: '安装中…',
restartRequired: '已安装,重启后生效',
installed: '已安装并生效',
installFailed: '安装失败',
} satisfies Record<string, string>
@@ -59,10 +60,11 @@ export const en = {
disable: 'Disable',
toggling: 'Toggling…',
required: 'Required by the app; cannot be toggled',
available: 'Installable plugins',
installPlugin: 'Install plugin',
installSpec: 'Package name, e.g. @scope/plugin',
install: 'Install',
uninstall: 'Uninstall',
installing: 'Working…',
restartRequired: 'Applied; restart to activate',
installing: 'Installing…',
restartRequired: 'Installed; restart to activate',
installed: 'Installed and active',
installFailed: 'Install failed',
} satisfies Record<PluginInventoryLocaleKey, string>
@@ -15,16 +15,13 @@ const t = ((key: PluginInventoryLocaleKey): string => en[key]) as PluginInventor
function props(
list: PluginInventorySettingsTabInjected['list'],
bundles: PluginInventorySettingsTabInjected['availableBundles'] = async () => ({ available: [] }),
installPlugin: PluginInventorySettingsTabInjected['installPlugin'] = async () => ({ ok: true as const, restartRequired: true }),
uninstall: PluginInventorySettingsTabInjected['uninstall'] = async () => ({ ok: true as const, restartRequired: true }),
): PluginInventorySettingsTabProps {
return {
t,
list,
availableBundles: bundles,
setEnabled: vi.fn(async () => {}),
installPlugin,
uninstall,
} as PluginInventorySettingsTabProps
}
@@ -162,28 +159,25 @@ describe('PluginInventorySettingsTab', () => {
await act(async () => { deferredFailure.reject(new Error('late failure')) })
})
it('renders the installable bundles with install and uninstall actions', async () => {
it('installs a registry plugin by package name', async () => {
const installPlugin = vi.fn<PluginInventorySettingsTabInjected['installPlugin']>(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, installPlugin, uninstall)} />)
render(<PluginInventorySettingsTab {...props(async () => SNAPSHOT, installPlugin)} />)
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.
const input = await screen.findByRole('textbox', { name: en.installSpec })
fireEvent.change(input, { target: { value: '@scope/plugin' } })
fireEvent.click(screen.getByRole('button', { name: en.install }))
expect(installPlugin).toHaveBeenCalledWith({ type: 'bundle', name: '@deepseek-ai/dsh-new-bundle' })
expect(installPlugin).toHaveBeenCalledWith({ type: 'registry', spec: '@scope/plugin' })
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')
it('shows an immediate-activation note when a live reload activates the plugin', async () => {
const installPlugin = vi.fn<PluginInventorySettingsTabInjected['installPlugin']>(async () => ({ ok: true, restartRequired: false }))
render(<PluginInventorySettingsTab {...props(async () => SNAPSHOT, installPlugin)} />)
const input = await screen.findByRole('textbox', { name: en.installSpec })
fireEvent.change(input, { target: { value: '@scope/plugin' } })
fireEvent.click(screen.getByRole('button', { name: en.install }))
expect(await screen.findByText(en.installed)).toBeTruthy()
expect(screen.queryByText(en.restartRequired)).toBeNull()
})
})