feat(plugin-host): add combined lifecycle controls (#2476)
## Summary - Add combined `Enable and Load` and `Disable and Unload` controls to Plugin Host Debug. - Keep enablement persistence and runtime lifecycle changes in one sequential store action. - Add tests for ordering, busy state, and failure handling. - Localize the new controls and fallback error messages in English and Simplified Chinese. ## Verification - `pnpm exec vitest run --config packages/stage-ui/vitest.config.ts --project node src/stores/devtools/plugin-host-debug.test.ts` - `pnpm -F @proj-airi/i18n test` - `pnpm -F @proj-airi/i18n build` - `pnpm run build:packages` - `pnpm typecheck` - `pnpm lint` - Manually tested the bundled `devtools-sample-plugin` in the Electron Plugin Host Debug page. Both combined actions updated the persisted registry and runtime session state without restarting the app. ## Visual changes | Before | After | |---|---| |  |  | | Plugin Host Debug | Plugin Host Debug with combined lifecycle controls |
This commit is contained in:
@@ -1641,6 +1641,13 @@ pages:
|
|||||||
markdown-stress:
|
markdown-stress:
|
||||||
title: Markdown Stress
|
title: Markdown Stress
|
||||||
description: Stress markdown parsing and rendering with heavy chat payloads
|
description: Stress markdown parsing and rendering with heavy chat payloads
|
||||||
|
plugin-host:
|
||||||
|
actions:
|
||||||
|
enable-and-load: Enable and Load
|
||||||
|
disable-and-unload: Disable and Unload
|
||||||
|
errors:
|
||||||
|
enable-and-load: 'Failed to enable and load plugin {extensionId}.'
|
||||||
|
disable-and-unload: 'Failed to disable and unload plugin {extensionId}.'
|
||||||
use-magic-keys:
|
use-magic-keys:
|
||||||
title: useMagicKeys Tool
|
title: useMagicKeys Tool
|
||||||
description: Test shortcuts
|
description: Test shortcuts
|
||||||
|
|||||||
@@ -1555,6 +1555,13 @@ pages:
|
|||||||
markdown-stress:
|
markdown-stress:
|
||||||
title: Markdown Stress
|
title: Markdown Stress
|
||||||
description: 用大量聊天负载来测试 Markdown 的解析和渲染能力
|
description: 用大量聊天负载来测试 Markdown 的解析和渲染能力
|
||||||
|
plugin-host:
|
||||||
|
actions:
|
||||||
|
enable-and-load: 启用并加载
|
||||||
|
disable-and-unload: 停用并卸载
|
||||||
|
errors:
|
||||||
|
enable-and-load: '启用并加载插件 {extensionId} 失败。'
|
||||||
|
disable-and-unload: '停用并卸载插件 {extensionId} 失败。'
|
||||||
use-magic-keys:
|
use-magic-keys:
|
||||||
title: useMagicKeys 工具
|
title: useMagicKeys 工具
|
||||||
description: 测试快捷键
|
description: 测试快捷键
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import { Section } from '@proj-airi/stage-ui/components'
|
|||||||
import { usePluginHostInspectorStore } from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug'
|
import { usePluginHostInspectorStore } from '@proj-airi/stage-ui/stores/devtools/plugin-host-debug'
|
||||||
import { Button, Callout, GhostButton, Input } from '@proj-airi/ui'
|
import { Button, Callout, GhostButton, Input } from '@proj-airi/ui'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
const store = usePluginHostInspectorStore()
|
const store = usePluginHostInspectorStore()
|
||||||
|
const { t } = useI18n()
|
||||||
const filter = ref('')
|
const filter = ref('')
|
||||||
const selectedExtensionId = ref('')
|
const selectedExtensionId = ref('')
|
||||||
|
|
||||||
@@ -150,6 +152,24 @@ async function unloadPlugin(plugin: PluginManifestSummary) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enableAndLoadPlugin(plugin: PluginManifestSummary) {
|
||||||
|
try {
|
||||||
|
await store.enableAndLoad({ extensionId: plugin.extensionId, path: plugin.path })
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
toast.error(errorMessageFrom(error) ?? t('settings.pages.system.sections.section.developer.sections.section.plugin-host.errors.enable-and-load', { extensionId: plugin.extensionId }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableAndUnloadPlugin(plugin: PluginManifestSummary) {
|
||||||
|
try {
|
||||||
|
await store.disableAndUnload({ extensionId: plugin.extensionId, path: plugin.path })
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
toast.error(errorMessageFrom(error) ?? t('settings.pages.system.sections.section.developer.sections.section.plugin-host.errors.disable-and-unload', { extensionId: plugin.extensionId }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadSelectedPlugin() {
|
async function loadSelectedPlugin() {
|
||||||
const extensionId = selectedExtensionId.value.trim()
|
const extensionId = selectedExtensionId.value.trim()
|
||||||
if (!extensionId) {
|
if (!extensionId) {
|
||||||
@@ -304,6 +324,22 @@ onMounted(async () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
|
<div :class="['flex', 'flex-wrap', 'items-center', 'gap-2']">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:label="t('settings.pages.system.sections.section.developer.sections.section.plugin-host.actions.enable-and-load')"
|
||||||
|
icon="i-solar:play-bold-duotone"
|
||||||
|
:disabled="store.loading || (plugin.enabled && plugin.loaded)"
|
||||||
|
:loading="store.loading"
|
||||||
|
@click="enableAndLoadPlugin(plugin)"
|
||||||
|
/>
|
||||||
|
<GhostButton
|
||||||
|
size="sm"
|
||||||
|
:label="t('settings.pages.system.sections.section.developer.sections.section.plugin-host.actions.disable-and-unload')"
|
||||||
|
icon="i-solar:stop-bold-duotone"
|
||||||
|
:disabled="store.loading || (!plugin.enabled && !plugin.loaded)"
|
||||||
|
:loading="store.loading"
|
||||||
|
@click="disableAndUnloadPlugin(plugin)"
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { PluginHostDebugSnapshot, PluginRegistrySnapshot } from './plugin-host-debug'
|
||||||
|
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { usePluginHostInspectorStore } from './plugin-host-debug'
|
||||||
|
|
||||||
|
function createRegistry(enabled: boolean, loaded: boolean): PluginRegistrySnapshot {
|
||||||
|
return {
|
||||||
|
root: 'extensions/v1',
|
||||||
|
plugins: [{
|
||||||
|
extensionId: 'sample-plugin',
|
||||||
|
entrypoints: { electron: './index.mjs' },
|
||||||
|
path: 'sample-plugin/extension.airi.json',
|
||||||
|
enabled,
|
||||||
|
loaded,
|
||||||
|
autoReload: false,
|
||||||
|
isNew: false,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBridge(registry: PluginRegistrySnapshot) {
|
||||||
|
const snapshot: PluginHostDebugSnapshot = {
|
||||||
|
registry,
|
||||||
|
sessions: registry.plugins.filter(plugin => plugin.loaded).map(plugin => ({
|
||||||
|
id: 'sample-session',
|
||||||
|
extensionId: plugin.extensionId,
|
||||||
|
phase: 'ready',
|
||||||
|
runtime: 'electron',
|
||||||
|
moduleId: plugin.extensionId,
|
||||||
|
})),
|
||||||
|
kits: [],
|
||||||
|
modules: [],
|
||||||
|
capabilities: [],
|
||||||
|
refreshedAt: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
list: vi.fn(async () => registry),
|
||||||
|
setEnabled: vi.fn(async () => registry),
|
||||||
|
setAutoReload: vi.fn(async () => registry),
|
||||||
|
loadEnabled: vi.fn(async () => registry),
|
||||||
|
load: vi.fn(async () => registry),
|
||||||
|
unload: vi.fn(async () => registry),
|
||||||
|
inspect: vi.fn(async () => snapshot),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.each([
|
||||||
|
{ action: 'enableAndLoad' as const, runtimeAction: 'load' as const, enabled: true },
|
||||||
|
{ action: 'disableAndUnload' as const, runtimeAction: 'unload' as const, enabled: false },
|
||||||
|
])('$action', ({ action, runtimeAction, enabled }) => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves enablement before changing runtime state and stays busy through inspection', async () => {
|
||||||
|
const finalRegistry = createRegistry(enabled, enabled)
|
||||||
|
const bridge = createBridge(finalRegistry)
|
||||||
|
const save = Promise.withResolvers<PluginRegistrySnapshot>()
|
||||||
|
const runtime = Promise.withResolvers<PluginRegistrySnapshot>()
|
||||||
|
const inspection = Promise.withResolvers<void>()
|
||||||
|
const inspect = bridge.inspect.getMockImplementation()!
|
||||||
|
bridge.setEnabled.mockReturnValueOnce(save.promise)
|
||||||
|
bridge[runtimeAction].mockReturnValueOnce(runtime.promise)
|
||||||
|
bridge.inspect.mockImplementationOnce(async () => {
|
||||||
|
await inspection.promise
|
||||||
|
return inspect()
|
||||||
|
})
|
||||||
|
|
||||||
|
const store = usePluginHostInspectorStore()
|
||||||
|
store.setBridge(bridge)
|
||||||
|
const operation = store[action]({
|
||||||
|
extensionId: 'sample-plugin',
|
||||||
|
path: 'sample-plugin/extension.airi.json',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.loading).toBe(true)
|
||||||
|
expect(bridge.setEnabled).toHaveBeenCalledExactlyOnceWith({
|
||||||
|
extensionId: 'sample-plugin',
|
||||||
|
path: 'sample-plugin/extension.airi.json',
|
||||||
|
enabled,
|
||||||
|
})
|
||||||
|
expect(bridge[runtimeAction]).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
const savedRegistry = createRegistry(enabled, !enabled)
|
||||||
|
save.resolve(savedRegistry)
|
||||||
|
await vi.waitFor(() => expect(bridge[runtimeAction]).toHaveBeenCalledExactlyOnceWith({ extensionId: 'sample-plugin' }))
|
||||||
|
expect(store.registry).toEqual(savedRegistry)
|
||||||
|
expect(store.loading).toBe(true)
|
||||||
|
expect(bridge.inspect).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
runtime.resolve(finalRegistry)
|
||||||
|
await vi.waitFor(() => expect(bridge.inspect).toHaveBeenCalledOnce())
|
||||||
|
expect(store.registry).toEqual(finalRegistry)
|
||||||
|
expect(store.loading).toBe(true)
|
||||||
|
|
||||||
|
inspection.resolve()
|
||||||
|
await expect(operation).resolves.toEqual(finalRegistry)
|
||||||
|
expect(store.sessions).toHaveLength(enabled ? 1 : 0)
|
||||||
|
expect(store.refreshedAt).toBe(1)
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
expect(store.error).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not change runtime state when saving enablement fails', async () => {
|
||||||
|
const bridge = createBridge(createRegistry(!enabled, !enabled))
|
||||||
|
bridge.setEnabled.mockRejectedValueOnce(new Error('Could not save enablement.'))
|
||||||
|
const store = usePluginHostInspectorStore()
|
||||||
|
store.setBridge(bridge)
|
||||||
|
|
||||||
|
await expect(store[action]({ extensionId: 'sample-plugin' })).rejects.toThrow('Could not save enablement.')
|
||||||
|
|
||||||
|
expect(bridge[runtimeAction]).not.toHaveBeenCalled()
|
||||||
|
expect(bridge.inspect).not.toHaveBeenCalled()
|
||||||
|
expect(store.error).toBe('Could not save enablement.')
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the saved enablement visible when the runtime operation fails', async () => {
|
||||||
|
const savedRegistry = createRegistry(enabled, !enabled)
|
||||||
|
const bridge = createBridge(savedRegistry)
|
||||||
|
bridge[runtimeAction].mockRejectedValueOnce(new Error('Runtime operation failed.'))
|
||||||
|
const store = usePluginHostInspectorStore()
|
||||||
|
store.setBridge(bridge)
|
||||||
|
|
||||||
|
await expect(store[action]({ extensionId: 'sample-plugin' })).rejects.toThrow('Runtime operation failed.')
|
||||||
|
|
||||||
|
expect(bridge.setEnabled).toHaveBeenCalledOnce()
|
||||||
|
expect(store.registry).toEqual(savedRegistry)
|
||||||
|
expect(store.error).toBe('Runtime operation failed.')
|
||||||
|
expect(store.loading).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -206,6 +206,28 @@ export const usePluginHostInspectorStore = defineStore('devtools:plugin-host-deb
|
|||||||
return nextRegistry
|
return nextRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Keeps enablement visible if loading fails, so the user can retry loading. */
|
||||||
|
async function enableAndLoad(payload: { extensionId: string, path?: string }) {
|
||||||
|
return withBridge(async (activeBridge) => {
|
||||||
|
assignRegistry(await activeBridge.setEnabled({ ...payload, enabled: true }))
|
||||||
|
const nextRegistry = await activeBridge.load({ extensionId: payload.extensionId })
|
||||||
|
assignRegistry(nextRegistry)
|
||||||
|
assignInspection(await activeBridge.inspect())
|
||||||
|
return nextRegistry
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps the plugin disabled for future startup even if stopping its current session fails. */
|
||||||
|
async function disableAndUnload(payload: { extensionId: string, path?: string }) {
|
||||||
|
return withBridge(async (activeBridge) => {
|
||||||
|
assignRegistry(await activeBridge.setEnabled({ ...payload, enabled: false }))
|
||||||
|
const nextRegistry = await activeBridge.unload({ extensionId: payload.extensionId })
|
||||||
|
assignRegistry(nextRegistry)
|
||||||
|
assignInspection(await activeBridge.inspect())
|
||||||
|
return nextRegistry
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
registry,
|
registry,
|
||||||
sessions,
|
sessions,
|
||||||
@@ -229,5 +251,7 @@ export const usePluginHostInspectorStore = defineStore('devtools:plugin-host-deb
|
|||||||
loadEnabled,
|
loadEnabled,
|
||||||
load,
|
load,
|
||||||
unload,
|
unload,
|
||||||
|
enableAndLoad,
|
||||||
|
disableAndUnload,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user