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:
+4
-15
@@ -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 {
|
||||
|
||||
+46
-65
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user