fix(plugins): restore registry group, own opaque material, hold refresh spinner
The install registry picker used MenuSurface and overrode the theme-owned menu material tokens, which the shared-menu-material gate rejects. Return the picker to a fieldset (its native group role) and paint an opaque bg-layer-2 surface so the form controls stay readable without touching menu tokens. This also restores the group's radios to the accessible tree. Align plugin-install-registry.e2e and its snapshots with the PR's visible text: registry option names drop the parentheses around the host, the custom address field is no longer disabled, and the custom hint spells out http(s) and ~/.npmrc. Hold the manual-refresh spinner to a 400ms minimum so a fast read does not flash it, clearing to idle (or the failure toast) only after the minimum, and dropping a refresh that settles after disposal. Prune the retired no-unknown-casts baseline entry for browser-plugin.client.spec.
This commit is contained in:
1 parent
9fabb67bca
commit
eb9efc6265
9 files changed
+67
-35
No files matched your search
@@ -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 里。
|
||||
@@ -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 里。
|
||||
@@ -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 里。
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<HTMLButtonElement | null>(null)
|
||||
const registryPanelRef = useRef<HTMLDivElement | null>(null)
|
||||
const registryPanelRef = useRef<HTMLFieldSetElement | null>(null)
|
||||
const registryCustomRef = useRef<HTMLInputElement | null>(null)
|
||||
const registryShown = install.registryOpen && phase === 'idle'
|
||||
const specComposition = useInstallComposition(install.open && phase === 'idle')
|
||||
@@ -978,9 +978,8 @@ function InstallDialog({
|
||||
: null}
|
||||
{registryShown
|
||||
? createPortal(
|
||||
<MenuSurface
|
||||
<fieldset
|
||||
ref={registryPanelRef}
|
||||
role="group"
|
||||
id={registryId}
|
||||
className={css.registry}
|
||||
style={registryPosition ?? { visibility: 'hidden', left: 0, top: 0 }}
|
||||
@@ -1038,7 +1037,7 @@ function InstallDialog({
|
||||
: null}
|
||||
<span className={css.registryHint}>{t('registryCustomHint')}</span>
|
||||
</div>
|
||||
</MenuSurface>,
|
||||
</fieldset>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void>((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' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ReturnType<typeof ok<BundleInfo[]>>>()
|
||||
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<void>((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<void>((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<ReturnType<typeof ok<ChangeResult>>>()
|
||||
const { plugins, face, state, controller, started } = bench({
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user