diff --git a/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md b/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md index ff8c008df0..91c3574956 100644 --- a/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md +++ b/apps/web/tests/expected/plugin-install-registry/fastest-default.expected.md @@ -1,10 +1,10 @@ - group "从哪个 npm 源下载插件": - - radio "npm 官方源(registry.npmjs.org)" - - text: npm 官方源(registry.npmjs.org) - - radio "中国大陆镜像源(registry.npmmirror.com)" [checked] - - text: 中国大陆镜像源(registry.npmmirror.com) + - radio "npm 官方源 registry.npmjs.org" + - text: npm 官方源 registry.npmjs.org + - radio "中国大陆镜像源 registry.npmmirror.com" [checked] + - text: 中国大陆镜像源 registry.npmmirror.com - radio "自定义地址" - text: 自定义地址 - - textbox "自定义地址" [disabled]: + - textbox "自定义地址": - /placeholder: https://npm.example.com/ - - text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里 + - text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。 diff --git a/apps/web/tests/expected/plugin-install-registry/official-default.expected.md b/apps/web/tests/expected/plugin-install-registry/official-default.expected.md index 9c219b83e0..944fe05716 100644 --- a/apps/web/tests/expected/plugin-install-registry/official-default.expected.md +++ b/apps/web/tests/expected/plugin-install-registry/official-default.expected.md @@ -1,10 +1,10 @@ - group "从哪个 npm 源下载插件": - - radio "npm 官方源(registry.npmjs.org)" [checked] - - text: npm 官方源(registry.npmjs.org) - - radio "中国大陆镜像源(registry.npmmirror.com)" - - text: 中国大陆镜像源(registry.npmmirror.com) + - radio "npm 官方源 registry.npmjs.org" [checked] + - text: npm 官方源 registry.npmjs.org + - radio "中国大陆镜像源 registry.npmmirror.com" + - text: 中国大陆镜像源 registry.npmmirror.com - radio "自定义地址" - text: 自定义地址 - - textbox "自定义地址" [disabled]: + - textbox "自定义地址": - /placeholder: https://npm.example.com/ - - text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里 + - text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。 diff --git a/apps/web/tests/expected/plugin-install-registry/picker.expected.md b/apps/web/tests/expected/plugin-install-registry/picker.expected.md index ff8c008df0..91c3574956 100644 --- a/apps/web/tests/expected/plugin-install-registry/picker.expected.md +++ b/apps/web/tests/expected/plugin-install-registry/picker.expected.md @@ -1,10 +1,10 @@ - group "从哪个 npm 源下载插件": - - radio "npm 官方源(registry.npmjs.org)" - - text: npm 官方源(registry.npmjs.org) - - radio "中国大陆镜像源(registry.npmmirror.com)" [checked] - - text: 中国大陆镜像源(registry.npmmirror.com) + - radio "npm 官方源 registry.npmjs.org" + - text: npm 官方源 registry.npmjs.org + - radio "中国大陆镜像源 registry.npmmirror.com" [checked] + - text: 中国大陆镜像源 registry.npmmirror.com - radio "自定义地址" - text: 自定义地址 - - textbox "自定义地址" [disabled]: + - textbox "自定义地址": - /placeholder: https://npm.example.com/ - - text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里 + - text: 填写内网或私有 npm 源地址,以 http:// 或 https:// 开头。若为需要登录的源,请把凭据放在本机的 ~/.npmrc 里。 diff --git a/apps/web/tests/plugin-install-registry.e2e.ts b/apps/web/tests/plugin-install-registry.e2e.ts index 043dd70764..54a1dbe3a1 100644 --- a/apps/web/tests/plugin-install-registry.e2e.ts +++ b/apps/web/tests/plugin-install-registry.e2e.ts @@ -75,7 +75,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding expect(await page.getByRole('radio').count()).toBe(0) await registryToggle.click() const options = page.getByRole('group', { name: '从哪个 npm 源下载插件', exact: true }) - const mirror = options.getByRole('radio', { name: '中国大陆镜像源(registry.npmmirror.com)', exact: true }) + const mirror = options.getByRole('radio', { name: '中国大陆镜像源 registry.npmmirror.com', exact: true }) await expect.poll(async () => (await readFile(requestLog, 'utf8')).trim().split('\n').filter(Boolean)).toHaveLength(2) const pingUrls = (await readFile(requestLog, 'utf8')).trim().split('\n').sort() expect(pingUrls).toEqual(['https://registry.npmjs.org/-/ping', 'https://registry.npmmirror.com/-/ping'].map(url => JSON.stringify(url))) @@ -84,7 +84,7 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/fastest-default.expected.md', import.meta.url)), await captureStableAria(page, '[data-install-registry]', scaffold.workspaceCwd), webSnapshotMode()) } else { - expect(await options.getByRole('radio', { name: 'npm 官方源(registry.npmjs.org)', exact: true }).isChecked()).toBe(true) + expect(await options.getByRole('radio', { name: 'npm 官方源 registry.npmjs.org', exact: true }).isChecked()).toBe(true) await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/official-default.expected.md', import.meta.url)), await captureStableAria(page, '[data-install-registry]', scaffold.workspaceCwd), webSnapshotMode()) await mirror.click() diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 37ff54cc9f..505ae1e096 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -700,9 +700,9 @@ margin: 0; padding: 8px; border: 0; - /* Keep an opaque surface behind the registry form controls. */ - --dsw-menu-surface-fill: var(--dsw-alias-bg-layer-2); - --dsw-menu-backdrop-filter: none; + /* An opaque layer keeps the registry form controls readable over the dialog. */ + background: var(--dsw-alias-bg-layer-2); + border-radius: var(--dsw-radius-lg); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); min-width: 0; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index df9a064490..2dfbb1afee 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -16,7 +16,7 @@ import { Button, IconCheckCircleFillRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineMedium, IconChevronRightOutlineRegular, IconCloseOutlineMedium, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular, - IconWarningOutlineRegular, Input, MenuSurface, Modal, + IconWarningOutlineRegular, Input, Modal, PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTerminal, StateDot, Switch, Tag, TerminalBlock, Toast, Tooltip, useAnchoredPosition, useDismissOnOutsidePointer, type IconProps, type StateDotState, type TerminalBlockLabels, @@ -808,7 +808,7 @@ function InstallDialog({ // The registry options float over the dialog from their toggle, so unfolding them never adds to its height; // the store folds them when a run starts, so they show at the spec only. const registryToggleRef = useRef(null) - const registryPanelRef = useRef(null) + const registryPanelRef = useRef(null) const registryCustomRef = useRef(null) const registryShown = install.registryOpen && phase === 'idle' const specComposition = useInstallComposition(install.open && phase === 'idle') @@ -978,9 +978,8 @@ function InstallDialog({ : null} {registryShown ? createPortal( - {t('registryCustomHint')} - , + , document.body, ) : null} diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index 35bcc433f9..80018300db 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -107,6 +107,9 @@ export type RegistryChoice = /** The choice shown until the Host has said which registry it asks first: the one pnpm's own configuration names. */ const OFFICIAL_REGISTRY: RegistryChoice = { kind: 'offered', registry: null } +/** Hold the manual-refresh spinner at least this long so a fast read does not flash it. */ +const REFRESH_SPINNER_MIN_MS = 400 + /** * The registry a choice asks, as the Host's install plan compares registries: pnpm's own configuration stands for * the URL it names, once the Host has read it. @@ -727,6 +730,7 @@ export class PluginManagerController { /** Keep manual refresh feedback until its coalesced reads settle, without clearing cached cards. */ private async refresh(): Promise { if (this.disposed || this.getSnapshot().refreshStatus === 'refreshing') return + const startedAt = Date.now() this.patch({ refreshStatus: 'refreshing', ...this.getSnapshot().notice?.kind === 'refresh-failed' ? { notice: null } : {}, @@ -737,10 +741,15 @@ export class PluginManagerController { // A rejected transport request leaves the cached cards available for retry. this.patch({ status: 'error' }) } finally { - const failed = this.getSnapshot().status === 'error' - this.patch(failed && this.hasCachedInventory - ? { status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: ++this.noticeSeq } } - : { refreshStatus: failed ? 'failed' : 'idle' }) + // Hold the spinner to its minimum so a fast read does not flash it, then settle. + const remaining = REFRESH_SPINNER_MIN_MS - (Date.now() - startedAt) + if (remaining > 0) await new Promise((resolve) => { setTimeout(resolve, remaining) }) + if (!this.disposed) { + const failed = this.getSnapshot().status === 'error' + this.patch(failed && this.hasCachedInventory + ? { status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: ++this.noticeSeq } } + : { refreshStatus: failed ? 'failed' : 'idle' }) + } } } diff --git a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts index b0e82bb12c..08a0f1e0f8 100644 --- a/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts +++ b/packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts @@ -1232,6 +1232,33 @@ describe('PluginManagerController', () => { await loading expect(state()).toBe(before) }) + + it('drops a manual refresh that settles after disposal', async () => { + const gate = deferred>>() + const { plugins, face, state, controller } = bench() + await controller.load() + plugins.listBundles.mockReturnValueOnce(gate.promise) + face.refresh() + await vi.waitFor(() => { expect(state().refreshStatus).toBe('refreshing') }) + controller.dispose() + const before = state() + gate.resolve(ok([{ ...BUNDLE, version: '9.9.9' }])) + await new Promise((resolve) => { setTimeout(resolve, 10) }) + expect(state()).toBe(before) + }) + + it('settles a manual refresh whose read outlasts the spinner minimum without an extra hold', async () => { + const { plugins, face, state, controller } = bench() + await controller.load() + plugins.listBundles.mockImplementationOnce(async () => { + await new Promise((resolve) => { setTimeout(resolve, 420) }) + return ok([{ ...BUNDLE, version: '0.42.0' }]) + }) + face.refresh() + await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') }, { timeout: 2_000 }) + expect(state().packages[0]?.version).toBe('0.42.0') + expect(state().status).toBe('ready') + }) it('offers the registries the Host configured, starts from its first, and asks the check and the run to start where the person chose', async () => { const gate = deferred>>() const { plugins, face, state, controller, started } = bench({ diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 761358a17e..a6232e59af 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -619,9 +619,6 @@ "packages/client/ui-plan/tests/plan-resource.client.spec.ts": { "fa53d5cd55d87b522c83a98968e3a2bf933b1cc1b84d2f009e79d6c702099786": 1 }, - "packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx": { - "04984a2ce1486250236e638e0197da0a1a840855ed3975c59130a85358c7e5fe": 1 - }, "packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx": { "6061de0a1e01547eb1abf9d4e1b5fa79fda112fa35dda980775491123413dcce": 1 },