diff --git a/apps/web/tests/cua-permissions.e2e.ts b/apps/web/tests/cua-permissions.e2e.ts index d64821f905..05931096ca 100644 --- a/apps/web/tests/cua-permissions.e2e.ts +++ b/apps/web/tests/cua-permissions.e2e.ts @@ -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) } diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 520dc95ed7..0492c0621d 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 176e278c96..c2896e1379 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -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 diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 9a770b0dcc..b7fd7e0f5d 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -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 清单仍保留完整数据;设置中「插件」分区的「插件列表」标签页负责查看它们的插件。 ### 切换组合包里的一行 diff --git a/packages/client/ui-plugin-manager/src/client/BundleDetailSection.tsx b/packages/client/ui-plugin-manager/src/client/BundleDetailSection.tsx index 90a2f07045..207d3783ad 100644 --- a/packages/client/ui-plugin-manager/src/client/BundleDetailSection.tsx +++ b/packages/client/ui-plugin-manager/src/client/BundleDetailSection.tsx @@ -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'), diff --git a/packages/client/ui-plugin-manager/src/client/detail-store.ts b/packages/client/ui-plugin-manager/src/client/detail-store.ts index 531ef3d29d..a50e7e8b74 100644 --- a/packages/client/ui-plugin-manager/src/client/detail-store.ts +++ b/packages/client/ui-plugin-manager/src/client/detail-store.ts @@ -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 diff --git a/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts index b57cee89f6..ea53f6b804 100644 --- a/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/detail-store.client.spec.ts @@ -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) => 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>() + 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 }) }) +})