fix(plugins): retain displayed details during activation refresh

This commit is contained in:
creatixchu committed 2026-10-20 16:23:28 +08:00
1 parent 2bb84a5974
commit f9bacf7b85
7 files changed
+39 -15

No files matched your search

+9 -2
View File
@@ -153,12 +153,16 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
{ method: 'openPermissionSettings', params: { permission: 'accessibility' } },
])
expect(events.every(event => ['permissionStatus', 'requestPermission', 'openPermissionSettings', 'applicationIcons', 'getApp'].includes(event.method))).toBe(true)
await plugins.locator('[data-detail-document] h1').waitFor()
const trace = await page.evaluateHandle(() => {
const errors: string[] = []
const stable = [...document.querySelectorAll('[data-cua-settings] [role="alert"]')].map(node => node.textContent?.trim())
const layouts: { height: number; sections: number[]; groups: number[] }[] = []
const documents: number[] = []
let frame = 0
const measure = () => {
const documentHeight = document.querySelector('[data-plugin-detail] [data-detail-document]')?.getBoundingClientRect().height ?? 0
if (documents.at(-1) !== documentHeight) documents.push(documentHeight)
const settings = document.querySelector('[data-cua-settings]')
if (settings) {
const height = settings.getBoundingClientRect().height
@@ -177,7 +181,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
}
})
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
return { errors, layouts, stop: () => { observer.disconnect(); cancelAnimationFrame(frame) } }
return { errors, layouts, documents, stop: () => { observer.disconnect(); cancelAnimationFrame(frame) } }
})
try {
const toggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: 'Enable Computer Use', exact: true })
@@ -185,7 +189,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
await page.setViewportSize({ width, height: 1000 })
if (width === 390) await page.locator('[data-sidebar-collapsed]').waitFor({ state: 'attached' })
await page.locator('[data-animating]').waitFor({ state: 'hidden' })
await trace.evaluate((value) => { value.layouts.length = 0 })
await trace.evaluate((value) => { value.layouts.length = 0; value.documents.length = 0 })
for (let cycle = 0; cycle < 3; cycle++) {
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
@@ -197,6 +201,9 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr
}
const layouts = await trace.evaluate(value => value.layouts)
expect(layouts.length).toBeGreaterThan(0)
const documentHeights = await trace.evaluate(value => value.documents)
expect(documentHeights.length).toBeGreaterThan(0)
expect(Math.max(...documentHeights) - Math.min(...documentHeights)).toBeLessThanOrEqual(1)
const heights = layouts.map(layout => layout.height)
expect(Math.max(...heights) - Math.min(...heights), JSON.stringify(layouts)).toBeLessThanOrEqual(1)
}
@@ -24,8 +24,8 @@
en: 9312d8b1b381378f
zh: 8d0ae7ad26ada2ac
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle:
en: 4c82092cabed69da
zh: 7e6d0d37e10f5132
en: 58a4ec22fa624531
zh: f838445232446850
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-one-row-of-a-bundle:
en: d3627d4dba5e6cc2
zh: e0d0eada4c20331f
+1 -1
View File
@@ -72,7 +72,7 @@ After the checks, the list toolbar says how many plugins can be updated, that al
### Switching a bundle
A bundle's page shows its full package name under the title. Its **Source** section, after the rows, names the spec that installs it elsewhere, as `pnpm add` accepts it, or **Built in** for an official bundle the installation ships, then its current version. A bundle's switch changes its layer selection. While the Host applies the change, the switch stays disabled and a spinner appears beside it; the action area permanently reserves 22px to the left of the switch. A persistent status region announces the pending change to screen readers. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins.
A bundle's page shows its full package name under the title. Its **Source** section, after the rows, names the spec that installs it elsewhere, as `pnpm add` accepts it, or **Built in** for an official bundle the installation ships, then its current version. A bundle's switch changes its layer selection. A displayed detail document stays visible while a profile change triggers a fresh read; the result replaces it when ready. While the Host applies the change, the switch stays disabled and a spinner appears beside it; the action area permanently reserves 22px to the left of the switch. A persistent status region announces the pending change to screen readers. A profile with HMR recomposes before the operation completes; one without HMR, and a bundle a higher layer overrides, say so in a toast. A bundle the Host cannot read carries a problem tag and its reason on its page and cannot be switched on; one that provides the management components stays locked. The Host answers with error codes, which the page's dictionary words; pnpm's and the Loader's own diagnostics are shown as they are. The page excludes built-in profile bundles from cards even when the profile holds them as dependencies or the Host reports an error. The Host inventory remains complete; the Settings Plugins section's Plugin list tab inspects their plugins.
### Switching one row of a bundle
@@ -72,7 +72,7 @@ Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时
### 切换一个组合包
组合包页面在标题下方显示完整包名。组件列表之后的「来源信息」给出在别处安装它所需的 spec(`pnpm add` 接受的形式),安装随附的官方组合包显示「内置」,随后是当前版本。组合包开关改变其层选择。Host 应用更改期间,开关保持禁用,旁边显示旋转指示器;操作区在开关左侧固定预留 22px。常驻状态区域向屏幕阅读器播报进行中的更改。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。
组合包页面在标题下方显示完整包名。组件列表之后的「来源信息」给出在别处安装它所需的 spec(`pnpm add` 接受的形式),安装随附的官方组合包显示「内置」,随后是当前版本。组合包开关改变其层选择。profile 变更触发重新读取时,已显示的详情文档保持可见,直到新结果返回。Host 应用更改期间,开关保持禁用,旁边显示旋转指示器;操作区在开关左侧固定预留 22px。常驻状态区域向屏幕阅读器播报进行中的更改。启用了 HMR 的 profile 在操作完成前重组;没有 HMR 的 profile,以及被更高层覆盖的组合包,会以 toast 说明。Host 读不了的组合包带异常标签,其页面给出原因,且不能打开;提供管理组件的组合包保持锁定。Host 以错误码作答,由页面字典措辞;pnpm 与 Loader 自己的诊断原样显示。页面从卡片中排除内置 profile 组合包,即使 profile 将它们列为依赖或 Host 报告了异常。Host 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。
### 切换组合包里的一行
@@ -38,9 +38,7 @@ export function BundleDetailSection(props: BundleDetailSectionProps): ReactNode
const { name, version, fromNpm, language, details, t, load, retry, image } = props
const key = bundleDetailKey(name, version, language)
const state = details[key]
// The key names the read completely; a read the controller forgot, after a profile change, starts again.
const missing = state === undefined
useEffect(() => { if (missing) load(name, version, language, fromNpm) }, [key, missing])
useEffect(() => { load(name, version, language, fromNpm) }, [key, details])
const labels = useDetailDocumentLabels({
title: t('detailTitle'), loading: t('detailLoading'), retry: t('retry'), copy: t('copy'), copied: t('copied'),
codeBlock: t('codeBlock.title'), wrap: t('codeBlock.wrap'), unwrap: t('codeBlock.unwrap'), footnotes: t('detailFootnotes'),
@@ -59,7 +59,7 @@ export class BundleDetailController {
* @param fromNpm - whether the bundle's files are unavailable, so the Host reads its offered version from the npm file CDNs.
*/
load(name: string, version: string | undefined, language: string, fromNpm: boolean): void {
if (this.store.getSnapshot()[bundleDetailKey(name, version, language)] === undefined) void this.read(name, version, language, fromNpm)
if (!this.reads.has(bundleDetailKey(name, version, language))) void this.read(name, version, language, fromNpm)
}
/**
@@ -88,10 +88,10 @@ export class BundleDetailController {
return new URL(`${DETAIL_FILE_ROUTE}?${query.toString()}`, this.base()).href
}
/** Forget every read, so each shown bundle reads its document again; bundle files can change at any profile change. */
/** Invalidate reads while retaining settled documents until their replacements arrive. */
reset(): void {
this.reads.clear()
this.store.set({})
this.store.set(Object.fromEntries(Object.entries(this.store.getSnapshot()).filter(([, value]) => value.status === 'ready')))
}
/** Stop publishing late settlements. */
@@ -103,7 +103,7 @@ export class BundleDetailController {
const key = bundleDetailKey(name, version, language)
const read = ++this.sequence
this.reads.set(key, read)
this.publish(key, { status: 'loading' })
if (this.store.getSnapshot()[key]?.status !== 'ready') this.publish(key, { status: 'loading' })
// The remote is captured before any wait: the plugin's context may be disposed while the comparison runs.
const { remote } = this.ctx
const current = (): boolean => !this.disposed && this.reads.get(key) === read
@@ -54,7 +54,7 @@ it('reports failures by reason, reads again on retry, and settles a retried key
expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND })
})
it('forgets every read on reset, drops answers of forgotten reads, and stops publishing after disposal', async () => {
it('invalidates reads on reset, drops superseded answers, and stops publishing after disposal', async () => {
const { controller, manager, state } = setup()
let answer: (value: Answer<BundleDetail>) => void = () => {}
manager.readBundleDetail.mockImplementationOnce(() => new Promise((resolve) => { answer = resolve }))
@@ -80,7 +80,7 @@ it('forgets every read on reset, drops answers of forgotten reads, and stops pub
controller.dispose()
answer({ ok: true, value: FOUND })
await flush()
expect(state('a', '1.0.0', 'en')).toEqual({ status: 'loading' })
expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: { status: 'none' } })
})
it('asks the registry comparison only for bundles read from npm, and reads them and their images from npmmirror first where it won', async () => {
@@ -123,3 +123,22 @@ it('addresses relative images through the Host route and leaves other destinatio
expect(controller.image('x', 'README.md', destination, false)).toBeUndefined()
}
})
it('retains a displayed document during invalidation and replaces it after one fresh read', async () => {
const { controller, manager, state } = setup()
controller.load('a', '1.0.0', 'en', false)
await vi.waitFor(() => { expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND }) })
const response = Promise.withResolvers<Answer<BundleDetail>>()
onTestFinished(() => { response.resolve({ ok: true, value: FOUND }) })
manager.readBundleDetail.mockImplementationOnce(() => response.promise)
controller.reset()
expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND })
controller.load('a', '1.0.0', 'en', false)
controller.load('a', '1.0.0', 'en', false)
expect(manager.readBundleDetail).toHaveBeenCalledTimes(2)
expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: FOUND })
const replacement = { ...FOUND, markdown: '# Updated' }
response.resolve({ ok: true, value: replacement })
await vi.waitFor(() => { expect(state('a', '1.0.0', 'en')).toEqual({ status: 'ready', detail: replacement }) })
})