fix(client): float the registry options over the install dialog and let its content scroll

With the guide open, unfolding the registry options pushed the dialog
past the viewport, and the card did not scroll. The options now float
from their toggle in a portal, right-aligned to it, folded by Escape
(claimed before the dialog's own Escape) or a pointer outside them,
and by the store when a run starts; useAnchoredPosition gains end
alignment for this. The dialog's content region scrolls under its
footer. The mirror reads as the mainland China mirror without a tag.
This commit is contained in:
Yichen Jiang committed 2026-09-18 17:20:10 +08:00
1 parent cb2750019a
commit 2b168ce1ae
16 files changed
+136 -81

No files matched your search

@@ -5,7 +5,7 @@
- paragraph: 版本 2.0.0
- button "收起安装详情" [expanded]
- paragraph: 安装位置:{{cwd}}/.dsh-home/profiles/scaffold
- paragraph: 第 1 次 · npmmirror
- paragraph: 第 1 次 · 中国大陆镜像源
- text: 失败 $ pnpm add mirrored-package --registry=https://registry.npmmirror.com/ 退出码 1
- button "复制"
- text: "ERR_PNPM_META_FETCH_FAIL GET https://registry.npmmirror.com/mirrored-package: socket hang up"
@@ -1,23 +1,11 @@
- dialog "添加插件":
- heading "添加插件" [level=2]
- button "关闭"
- paragraph: 输入插件的包名、GitHub 仓库地址或本地目录路径。
- text: 包名或地址
- textbox "包名或地址":
- /placeholder: 例如 @deepseek-ai/dsh-experimental-auto-review
- text: mirrored-package
- button "不知道该填什么?"
- button "安装源 npmmirror" [expanded]
- group "从哪个 npm 源下载插件":
- text: 从哪个 npm 源下载插件
- radio "官方源(默认) registry.npmjs.org,遵循你本机的 pnpm 与 .npmrc 配置"
- text: 官方源(默认) registry.npmjs.org,遵循你本机的 pnpm 与 .npmrc 配置
- radio "npmmirror 中国大陆网络推荐 registry.npmmirror.com,官方源的完整镜像" [checked]
- text: npmmirror 中国大陆网络推荐 registry.npmmirror.com,官方源的完整镜像
- radio "自定义地址"
- text: 自定义地址
- textbox "自定义地址" [disabled]:
- /placeholder: https://npm.example.com/
- text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里
- paragraph: 选中的源连接失败时,会依次改用:官方源。GitHub 地址和 .tgz 直链本身不经过安装源,但它们的依赖仍从这里下载。
- button "安装"
- group "从哪个 npm 源下载插件":
- radio "官方源(默认) registry.npmjs.org,遵循你本机的 pnpm 与 .npmrc 配置"
- text: 官方源(默认) registry.npmjs.org,遵循你本机的 pnpm 与 .npmrc 配置
- radio "中国大陆镜像源 registry.npmmirror.com,官方源的完整镜像" [checked]
- text: 中国大陆镜像源 registry.npmmirror.com,官方源的完整镜像
- radio "自定义地址"
- text: 自定义地址
- textbox "自定义地址" [disabled]:
- /placeholder: https://npm.example.com/
- text: 公司内网或私有 npm 源;需要登录的源,把凭据放在本机的 ~/.npmrc 里
- paragraph: 选中的源连接失败时,会依次改用:官方源。GitHub 地址和 .tgz 直链本身不经过安装源,但它们的依赖仍从这里下载。
+15 -8
View File
@@ -61,19 +61,26 @@ it('offers the registries, remembers the one picked, and moves an install on to
const panel = page.locator('[data-plugin-panel]')
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
const dialog = page.getByRole('dialog')
// Folded, the registry control names pnpm's own registry; unfolded, it offers the configured mirror and a typed address.
// Folded, the registry control names pnpm's own registry; unfolded, its options float from it and offer the configured
// mirror and a typed address.
const registryToggle = dialog.getByRole('button', { name: '安装源 官方源(默认)', exact: true })
await registryToggle.waitFor()
expect(await dialog.getByRole('radio').count()).toBe(0)
expect(await page.getByRole('radio').count()).toBe(0)
await registryToggle.click()
await dialog.getByRole('radio', { name: /npmmirror/ }).click()
expect(await dialog.getByRole('radio').count()).toBe(3)
await dialog.getByRole('button', { name: '安装源 npmmirror', exact: true }).waitFor()
const options = page.getByRole('group', { name: '从哪个 npm 源下载插件', exact: true })
await options.getByRole('radio', { name: /npmmirror/ }).click()
expect(await options.getByRole('radio').count()).toBe(3)
await dialog.getByRole('button', { name: '安装源 中国大陆镜像源', exact: true }).waitFor()
await dialog.getByRole('textbox', { name: '包名或地址' }).fill('mirrored-package')
const picker = (await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
const picker = (await captureStableAria(page, '[data-install-registry]', scaffold.workspaceCwd))
.split(process.execPath).join('{{node}}')
.split(scaffold.harnessHome).join('{{harnessHome}}')
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-registry/picker.expected.md', import.meta.url)), picker, webSnapshotMode())
// The options float over the dialog rather than inside it: the card keeps its height with them open, and Escape folds them.
expect(await dialog.locator('[data-install-registry]').count()).toBe(0)
await page.keyboard.press('Escape')
await options.waitFor({ state: 'detached' })
await dialog.getByRole('button', { name: '安装源 中国大陆镜像源', exact: true }).waitFor()
await dialog.getByRole('button', { name: '安装', exact: true }).click()
// The check asked the mirror first, which answered; the run that lost the mirror sent the install on to
// the registry after it, which finished it. Each run shows behind the details with the registry it asked.
@@ -81,7 +88,7 @@ it('offers the registries, remembers the one picked, and moves an install on to
await dialog.getByText('版本 2.0.0', { exact: true }).waitFor()
await dialog.getByRole('button', { name: '查看安装详情', exact: true }).click()
await dialog.getByText('Installed from the registry pnpm names', { exact: true }).waitFor()
await dialog.getByText('第 1 次 · npmmirror', { exact: true }).waitFor()
await dialog.getByText('第 1 次 · 中国大陆镜像源', { exact: true }).waitFor()
await dialog.getByText('第 2 次 · 官方源', { exact: true }).waitFor()
expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toMatchObject({ dependencies: { 'mirrored-package': '2.0.0' } })
const installed = (await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd))
@@ -91,7 +98,7 @@ it('offers the registries, remembers the one picked, and moves an install on to
// The dialog opened again starts from the registry picked for the last install.
await dialog.getByRole('button', { name: '立即启用', exact: true }).click()
await panel.getByRole('button', { name: '添加插件', exact: true }).click()
await page.getByRole('dialog').getByRole('button', { name: '安装源 npmmirror', exact: true }).waitFor()
await page.getByRole('dialog').getByRole('button', { name: '安装源 中国大陆镜像源', exact: true }).waitFor()
expect(tripwire.pageErrors).toEqual([])
} finally { await browser.close() }
} finally {
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md
README.md: e586490bcd7fd9f731eb8fc1f5d4a6e43d15f3a8
README.zh.md: 21c58f7df2866562e74d66311759262763262c22
README.md: c2e0b4ac5af95e664bb50fb743b99edc920f37bb
README.zh.md: 0cc93f1ee7888bc1810fa32983e4f3ad9086e9c4
+1 -1
View File
@@ -31,7 +31,7 @@ The Agent Teams and Auto Authorization Review packages have localized names and
### Installing a bundle
**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Not sure what to enter?** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, read as the official one, or by its host when the machine's pnpm configuration names another; each mirror the Host configured (`pluginManager.registries`), npmmirror by name with its recommendation for mainland China; and a typed http(s) address. The registry the Host asks first carries the default tag and is the choice until one is remembered; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one. A note under the options names the registries the Host's own plan asks in turn after the chosen one — none for a typed address outside the configured set, or for a pnpm registry that names a private one — and says a GitHub address or a tarball link is not fetched through the registry. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate.
**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Not sure what to enter?** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, read as the official one, or by its host when the machine's pnpm configuration names another; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The registry the Host asks first carries the default tag and is the choice until one is remembered; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one. A note under the options names the registries the Host's own plan asks in turn after the chosen one — none for a typed address outside the configured set, or for a pnpm registry that names a private one — and says a GitHub address or a tarball link is not fetched through the registry. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate.
During installation, **Cancel install** asks the Host to stop the run and shows **Stopping installation…** until the Host confirms. Loading the bundle cannot be cancelled. Once confirmed, the dialog returns to the spec, ready to install again, and a toast says the installation was cancelled; the manifest and lockfile are back as they were, while downloaded files can remain. Closing the dialog while the run is in progress asks the Host to stop it the same way, and the dialog closes once the Host confirms; while the Host prepares, stops, or loads, the dialog cannot be closed. A connection error does not confirm cancellation: the running screen says so and cancelling can be tried again.
@@ -31,7 +31,7 @@ Agent Teams 和 Auto Authorization Review 两个包使用随界面语言切换
### 安装一个组合包
**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**不知道该填什么?**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,指向官方源时显示为官方源,本机 pnpm 配置指向别处时按其主机名显示;Host 配置的每个镜像(`pluginManager.registries`),npmmirror 以名字显示并标注中国大陆推荐;以及手动输入的 http(s) 地址。Host 首先询问的注册表带「默认」标记,在记住选择之前就是当前选择;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址。选项下方一行按 Host 自己的计划说明所选源之后会依次改用哪些源——配置集合之外的手动地址、以及指向私有源的 pnpm 注册表都没有备选——并说明 GitHub 地址和压缩包链接不经过安装源。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。
**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**不知道该填什么?**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,指向官方源时显示为官方源,本机 pnpm 配置指向别处时按其主机名显示;Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。Host 首先询问的注册表带「默认」标记,在记住选择之前就是当前选择;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址。选项下方一行按 Host 自己的计划说明所选源之后会依次改用哪些源——配置集合之外的手动地址、以及指向私有源的 pnpm 注册表都没有备选——并说明 GitHub 地址和压缩包链接不经过安装源。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。
安装期间可点击**取消安装**,对话框显示**正在停止安装…**,直到 Host 确认。加载组合包的阶段不可取消。确认后对话框回到 spec 输入界面,可再次安装,并用 toast 说明安装已取消;manifest 与 lockfile 已恢复原样,已下载文件可能保留。安装进行中关闭对话框,同样会请求 Host 停止安装,Host 确认后对话框才关闭;Host 正在准备、停止或加载时不能关闭对话框。连接错误不代表取消成功:安装中界面会如此说明,可以再次尝试取消。
@@ -58,6 +58,7 @@
"@deepseek-ai/dsh-plugin-manager": "workspace:^",
"@testing-library/react": "^16.1.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"clsx": "^2.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -388,6 +388,12 @@
max-height: calc(100vh - 48px);
}
/* The spec screen's content scrolls inside the card when the guide is open on a short viewport; the footer stays put. */
.installContent {
min-height: 0;
overflow-y: auto;
}
.installBody {
display: flex;
flex-direction: column;
@@ -605,26 +611,25 @@
color: var(--dsw-alias-label-primary);
}
/* The registry options: pnpm's own, the configured mirrors, and a typed address, with the order asked under them. */
/* The registry options float from their toggle over the dialog: pnpm's own, the configured mirrors, and a typed
address, with the order asked under them. Portaled and fixed, so the dialog keeps its height. */
.registry {
position: fixed;
z-index: 1100;
display: flex;
flex-direction: column;
gap: 4px;
box-sizing: border-box;
width: min(720px, calc(100vw - 24px));
margin: 0;
padding: 8px 10px 12px;
border: 0.5px solid var(--dsw-alias-border-l4);
border: 0.5px solid var(--dsw-alias-border-l3);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-1);
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-elevation-prominent);
min-width: 0;
}
.registryLegend {
padding: 0 4px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
}
.registryOption {
display: flex;
flex-direction: column;
@@ -672,15 +677,6 @@ label.registryOption {
color: var(--dsw-alias-label-primary);
}
.registryBadge {
padding: 0 6px;
border-radius: 6px;
background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent);
font-size: 11px;
line-height: 18px;
color: var(--dsw-alias-brand-primary);
}
.registryHint {
font-size: 12px;
line-height: 18px;
@@ -9,14 +9,15 @@
* the page declares.
*/
import { useEffect, useId, useState, type ReactNode } from 'react'
import { useEffect, useId, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import type { PluginInstallFailureKind, PluginRegistries, Registry } from '@deepseek-ai/dsh-api-remotes/client'
import { normalizeRegistry, registryPlan } from '@deepseek-ai/dsh-plugin-manager/registry'
import {
Button, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular,
IconChevronRightOutlineRegular, IconCloseOutlineRegular, IconCordisPluginOutlineRegular,
IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular,
IconWarningOutlineRegular, Input, Modal, StateDot, Switch, Tag, TerminalBlock, Toast,
IconWarningOutlineRegular, Input, Modal, StateDot, Switch, Tag, TerminalBlock, Toast, useAnchoredPosition, useDismissOnOutsidePointer,
type StateDotState, type TerminalBlockLabels,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
@@ -650,6 +651,27 @@ function InstallDialog({
const registryErrorId = useId()
const [guideOpen, setGuideOpen] = useState(false)
const { phase } = install
// 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<HTMLFieldSetElement | null>(null)
const registryShown = install.registryOpen && phase === 'idle'
const registryPosition = useAnchoredPosition({
open: registryShown, anchorRef: registryToggleRef, panelRef: registryPanelRef, align: 'end', gap: 6, margin: 12,
})
// The hook only ever asks to close.
useDismissOnOutsidePointer(registryToggleRef, registryShown, onToggleRegistry, registryPanelRef)
useEffect(() => {
if (!registryShown) return
// Escape folds the options and goes no further: the dialog under them listens for the same key.
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') return
event.stopPropagation()
onToggleRegistry()
}
document.addEventListener('keydown', onKeyDown, true)
return () => { document.removeEventListener('keydown', onKeyDown, true) }
}, [registryShown, onToggleRegistry])
if (phase === 'idle' || phase === 'checking') {
const checking = phase === 'checking'
const empty = install.spec.trim() === ''
@@ -677,6 +699,7 @@ function InstallDialog({
closeLabel={t('close')}
description={t('installDescription')}
className={css.installDialog as string}
contentClassName={css.installContent as string}
footer={(
<Button variant="primary" className={css.wide} disabled={checking || empty} aria-busy={checking} onClick={onRun}>
{checking ? <StateDot state="ongoing" /> : null}
@@ -713,10 +736,12 @@ function InstallDialog({
<span>{t(guideOpen ? 'installGuideHide' : 'installGuideToggle')}</span>
</button>
<button
ref={registryToggleRef}
type="button"
className={css.registryToggle}
aria-expanded={install.registryOpen}
aria-controls={registryId}
aria-haspopup="dialog"
disabled={checking}
onClick={onToggleRegistry}
>
@@ -762,10 +787,16 @@ function InstallDialog({
</div>
)
: null}
{install.registryOpen
? (
<fieldset id={registryId} className={css.registry} data-install-registry disabled={checking}>
<legend className={css.registryLegend}>{t('registryLegend')}</legend>
{registryShown
? createPortal(
<fieldset
ref={registryPanelRef}
id={registryId}
className={css.registry}
style={registryPosition ?? { visibility: 'hidden', left: 0, top: 0 }}
data-install-registry
aria-label={t('registryLegend')}
>
{offeredRegistries(install.registries).map((registry) => {
const text = registryText(registry, t, resolved)
const checked = choice.kind === 'offered' && choice.registry === registry
@@ -775,7 +806,6 @@ function InstallDialog({
<span className={css.registryMain}>
<span className={css.registryTitle}>
<span>{titleOf(registry)}</span>
{text.badge === undefined ? null : <span className={css.registryBadge}>{text.badge}</span>}
</span>
<span className={css.registryHint}>{text.hint}</span>
</span>
@@ -812,7 +842,8 @@ function InstallDialog({
<p className={css.registryNote}>
{after.length === 0 ? t('registryNoFallbackNote') : t('registryFallbackNote', { order: registryList(after, t, resolved) })}
</p>
</fieldset>
</fieldset>,
document.body,
)
: null}
</div>
@@ -76,8 +76,7 @@ export const zh = {
registryOfficialHint: 'registry.npmjs.org,遵循你本机的 pnpm 与 .npmrc 配置',
registryOwnHint: '本机 pnpm 配置指定的源:{url}',
registryDefaultTag: '(默认)',
registryNpmmirror: 'npmmirror',
registryNpmmirrorBadge: '中国大陆网络推荐',
registryNpmmirror: '中国大陆镜像源',
registryNpmmirrorHint: 'registry.npmmirror.com,官方源的完整镜像',
registryCustom: '自定义地址',
registryCustomPlaceholder: 'https://npm.example.com/',
@@ -258,8 +257,7 @@ export const en = {
registryOfficialHint: 'registry.npmjs.org, following the pnpm and .npmrc configuration on this machine',
registryOwnHint: 'The registry the pnpm configuration on this machine names: {url}',
registryDefaultTag: ' (default)',
registryNpmmirror: 'npmmirror',
registryNpmmirrorBadge: 'Recommended in mainland China',
registryNpmmirror: 'Mainland China mirror',
registryNpmmirrorHint: 'registry.npmmirror.com, a complete mirror of the official registry',
registryCustom: 'Custom address',
registryCustomPlaceholder: 'https://npm.example.com/',
@@ -628,7 +628,7 @@ export class PluginManagerController {
const controller = new AbortController()
this.inspectAbort = controller
this.patchInstall({
phase: 'checking', inputError: null, subject: null, runs: [], detailsOpen: false, attempts: null,
phase: 'checking', inputError: null, subject: null, runs: [], detailsOpen: false, attempts: null, registryOpen: false,
installed: null, restartRequired: false, failure: null, approvedBuilds: [],
})
const inspected = await this.ctx.remote.pluginManager.inspect(spec, { registry }, controller.signal)
@@ -18,9 +18,9 @@ const BUILTIN_COPY = new Map<string, { title: PluginManagerLocaleKey; descriptio
}],
])
/** The registries with copy of their own, by host: a name, the line under it, and the tag that says whom it serves. */
const REGISTRY_COPY = new Map<string, { title: PluginManagerLocaleKey; hint: PluginManagerLocaleKey; badge: PluginManagerLocaleKey }>([
['registry.npmmirror.com', { title: 'registryNpmmirror', hint: 'registryNpmmirrorHint', badge: 'registryNpmmirrorBadge' }],
/** The registries with copy of their own, by host: a name and the line under it. */
const REGISTRY_COPY = new Map<string, { title: PluginManagerLocaleKey; hint: PluginManagerLocaleKey }>([
['registry.npmmirror.com', { title: 'registryNpmmirror', hint: 'registryNpmmirrorHint' }],
])
/** npm's own registry, which pnpm names without any configuration. */
@@ -32,9 +32,9 @@ const OFFICIAL_NPM_HOST = 'registry.npmjs.org'
* @param registry - the registry, null for the one pnpm's own configuration names.
* @param t - the manager's translate seat.
* @param resolved - the URL pnpm's own configuration names, null while unknown.
* @returns the title, the line under it, and the tag beside it when it has one.
* @returns the title and the line under it.
*/
export function registryText(registry: Registry, t: Translate, resolved: string | null): { title: string; hint: string; badge?: string } {
export function registryText(registry: Registry, t: Translate, resolved: string | null): { title: string; hint: string } {
if (registry === null) {
// Unknown, or npm's own: the official registry. Anything else pnpm names reads by its host.
if (resolved === null || registryHost(resolved) === OFFICIAL_NPM_HOST) return { title: t('registryOfficial'), hint: t('registryOfficialHint') }
@@ -43,7 +43,7 @@ export function registryText(registry: Registry, t: Translate, resolved: string
const host = registryHost(registry)
const keys = REGISTRY_COPY.get(host)
if (keys === undefined) return { title: host, hint: registry }
return { title: t(keys.title), hint: t(keys.hint), badge: t(keys.badge) }
return { title: t(keys.title), hint: t(keys.hint) }
}
/** The host of a registry URL; the URL as written when it does not parse. */
@@ -686,7 +686,17 @@ describe('PluginManagerPage', () => {
expect(radios).toHaveLength(3)
expect(radios[0]).toHaveProperty('checked', true)
expect(screen.getByText(en.registryOfficialHint)).toBeTruthy()
expect(screen.getByText(en.registryNpmmirrorBadge)).toBeTruthy()
expect(screen.getByText(en.registryNpmmirrorHint)).toBeTruthy()
// The options float from the toggle, so the dialog card itself does not grow.
expect(screen.getByRole('dialog').contains(screen.getByRole('group', { name: en.registryLegend }))).toBe(false)
// Escape folds the options without reaching the dialog; a pointer outside them folds them too.
fireEvent.keyDown(document.body, { key: 'Escape' })
expect(actions.toggleRegistryOptions).toHaveBeenCalledTimes(2)
expect(actions.closeInstall).not.toHaveBeenCalled()
fireEvent.pointerDown(document.body)
expect(actions.toggleRegistryOptions).toHaveBeenCalledTimes(3)
fireEvent.pointerDown(screen.getByRole('group', { name: en.registryLegend }))
expect(actions.toggleRegistryOptions).toHaveBeenCalledTimes(3)
// The note names the registries asked after the chosen one, in order.
expect(screen.getByText(en.registryFallbackNote.replace('{order}', en.registryNpmmirror))).toBeTruthy()
fireEvent.click(screen.getByRole('radio', { name: new RegExp(en.registryNpmmirror) }))
@@ -22,6 +22,8 @@ export interface AnchoredPositionOptions {
panelRef: RefObject<HTMLElement | null>
/** Which anchor edge the panel hangs from: below it (`bottom`, the default) or above it (`top`). */
side?: 'top' | 'bottom'
/** Which anchor edge the panel lines up with: its left edge (`start`, the default) or its right edge (`end`). */
align?: 'start' | 'end'
/** Distance kept between the anchor edge named by `side` and the panel. */
gap: number
/** Distance kept between the panel and each viewport edge. */
@@ -30,11 +32,11 @@ export interface AnchoredPositionOptions {
/**
* Track an anchor and return the panel's fixed coordinates.
* @param options - the open state, the two refs, the placement side, and the gap/margin distances.
* @param options - the open state, the two refs, the placement side and alignment, and the gap/margin distances.
* @returns `left`/`top` for the panel, or `null` before the first measurement.
*/
export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProperties | null {
const { open, anchorRef, panelRef, side = 'bottom', gap, margin } = options
const { open, anchorRef, panelRef, side = 'bottom', align = 'start', gap, margin } = options
const [position, setPosition] = useState<CSSProperties | null>(null)
useLayoutEffect(() => {
if (!open) {
@@ -50,7 +52,7 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper
const panel = panelRef.current
const width = panel?.offsetWidth ?? 0
const height = panel?.offsetHeight ?? 0
let left = rect.left
let left = align === 'end' ? rect.right - width : rect.left
let top = side === 'top' ? rect.top - gap - height : rect.bottom + gap
if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin)
if (height > 0) top = Math.min(Math.max(top, margin), window.innerHeight - height - margin)
@@ -78,6 +80,6 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
}, [open, anchorRef, panelRef, side, gap, margin])
}, [open, anchorRef, panelRef, side, align, gap, margin])
return position
}
@@ -51,10 +51,10 @@ function stubResizeObserver(): Recorded[] {
* @param props - whether the panel is open.
* @returns the anchor and, while open, the panel carrying the position.
*/
function Host({ open }: { open: boolean }) {
function Host({ open, align }: { open: boolean; align?: 'start' | 'end' }) {
const anchorRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const position = useAnchoredPosition({ open, anchorRef, panelRef, gap: 4, margin: 12 })
const position = useAnchoredPosition({ open, anchorRef, panelRef, gap: 4, margin: 12, ...align === undefined ? {} : { align } })
return (
<>
<button ref={anchorRef} type="button">anchor</button>
@@ -64,6 +64,25 @@ function Host({ open }: { open: boolean }) {
}
describe('useAnchoredPosition', () => {
it('lines the panel up with the anchor\'s right edge when aligned to the end', () => {
// jsdom lays nothing out: the anchor's rect and the panel's width are stated, and the placement read back.
const rect = { left: 100, right: 300, top: 10, bottom: 30, width: 200, height: 20, x: 100, y: 10, toJSON: () => ({}) } as DOMRect
const anchorRect = vi.spyOn(HTMLButtonElement.prototype, 'getBoundingClientRect').mockReturnValue(rect)
const width = vi.spyOn(HTMLDivElement.prototype, 'offsetWidth', 'get').mockReturnValue(120)
vi.stubGlobal('innerWidth', 1000)
vi.stubGlobal('innerHeight', 800)
try {
const { getByTestId, rerender } = render(<Host open align="end" />)
expect(getByTestId('panel').style.left).toBe('180px')
expect(getByTestId('panel').style.top).toBe('34px')
rerender(<Host open />)
expect(getByTestId('panel').style.left).toBe('100px')
} finally {
anchorRect.mockRestore()
width.mockRestore()
}
})
it('observes the panel while open and disconnects when it closes', () => {
const made = stubResizeObserver()
const ui = render(<Host open />)
+3
View File
@@ -3767,6 +3767,9 @@ importers:
'@types/react':
specifier: ~18.3.1
version: 18.3.31
'@types/react-dom':
specifier: ~18.3.0
version: 18.3.7(@types/[email protected])
clsx:
specifier: ^2.1.1
version: 2.1.1