Просмотр исходного кода

feat(web): let the install dialog's close control stop a running install

While the Host runs an install, the dialog's close control was disabled. It now asks the Host to stop the run, the same request as Cancel install, and closes the dialog once the Host confirms the stop; when the Host answers too late or cannot confirm, the dialog stays with that word. Escape and the mask keep waiting while the Host owns the operation. The control is labelled 取消安装并关闭 / Cancel install and close while it does this.
Yichen Jiang 1 день назад
Родитель
Сommit
57a705fa7e

+ 2 - 2
packages/client/ui-plugin-manager/README.i18n.yaml

@@ -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: 5592a5c566ca058535f05096a89e75d2bf4b4e48
-README.zh.md: 2f7344c754ab6445f172c07b375c57dee372283e
+README.md: 2cfeaad06d2b0247057f5824213411019a8a3cef
+README.zh.md: e61a79b1b58d66fbec543966d12b7a8c15e402c9

+ 1 - 1
packages/client/ui-plugin-manager/README.md

@@ -33,7 +33,7 @@ The Agent Teams, Agent Teams Web UI, and Auto Authorization Review packages have
 
 **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. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`): a name the list already shows, a name the registry does not have, 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. 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. A failed install says what went wrong in one line — the registry or network could not be reached, 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; 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 is blocked while the Host owns the operation. A connection error does not confirm cancellation: the running screen says so and cancelling can be tried again.
+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.
 
 ### Switching a bundle
 

+ 1 - 1
packages/client/ui-plugin-manager/README.zh.md

@@ -33,7 +33,7 @@ Agent Teams、Agent Teams Web UI 和 Auto Authorization Review 三个包使用
 
 **添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**不知道该填什么?**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。**安装**先让 Host 读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。
 
-安装期间可点击**取消安装**,对话框显示**正在停止安装…**,直到 Host 确认。加载组合包的阶段不可取消。确认后对话框回到 spec 输入界面,可再次安装,并用 toast 说明安装已取消;manifest 与 lockfile 已恢复原样,已下载文件可能保留。Host 仍在处理操作时不能关闭对话框。连接错误不代表取消成功:安装中界面会如此说明,可以再次尝试取消。
+安装期间可点击**取消安装**,对话框显示**正在停止安装…**,直到 Host 确认。加载组合包的阶段不可取消。确认后对话框回到 spec 输入界面,可再次安装,并用 toast 说明安装已取消;manifest 与 lockfile 已恢复原样,已下载文件可能保留。安装进行中关闭对话框,同样会请求 Host 停止安装,Host 确认后对话框才关闭;Host 正在准备、停止或加载时不能关闭对话框。连接错误不代表取消成功:安装中界面会如此说明,可以再次尝试取消。
 
 ### 切换一个组合包
 

+ 13 - 2
packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx

@@ -585,13 +585,17 @@ function SubjectCard({ subject, t }: { readonly subject: InstallSubject; readonl
  * and failed screens over the same subject card. A failed run that left
  * install scripts undecided shows them for approval in place of plain retry.
  */
-function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onToggleDetails, onEnableNow, onApproveBuilds }: {
+function InstallDialog({
+  install, t, onClose, onEditSpec, onRun, onCancel, onCancelAndClose, onToggleDetails, onEnableNow, onApproveBuilds,
+}: {
   readonly install: InstallState
   readonly t: Translate
   readonly onClose: () => void
   readonly onEditSpec: (text: string) => void
   readonly onRun: () => void
   readonly onCancel: () => void
+  /** The close control while the Host runs the install: stop the run, then close. */
+  readonly onCancelAndClose: () => void
   readonly onToggleDetails: () => void
   readonly onEnableNow: () => void
   readonly onApproveBuilds: () => void
@@ -706,7 +710,13 @@ function InstallDialog({ install, t, onClose, onEditSpec, onRun, onCancel, onTog
                 <span>{t('installEdit')}</span>
               </button>
             )}
-          <button type="button" className={css.wizardClose} aria-label={t('close')} disabled={pending} onClick={onClose}>
+          <button
+            type="button"
+            className={css.wizardClose}
+            aria-label={t(phase === 'running' ? 'installCloseCancels' : 'close')}
+            disabled={pending && phase !== 'running'}
+            onClick={phase === 'running' ? onCancelAndClose : onClose}
+          >
             <IconCloseOutline16 size={14} />
           </button>
         </div>
@@ -972,6 +982,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
         onEditSpec={props.editInstallSpec}
         onRun={props.runInstall}
         onCancel={props.cancelInstall}
+        onCancelAndClose={props.cancelInstallAndClose}
         onToggleDetails={props.toggleInstallDetails}
         onEnableNow={props.enableInstalled}
         onApproveBuilds={props.approveBuildsAndRetry}

+ 2 - 0
packages/client/ui-plugin-manager/src/client/locales.ts

@@ -87,6 +87,7 @@ export const zh = {
   installEdit: '编辑',
   installEditAria: '返回编辑',
   installCancel: '取消安装',
+  installCloseCancels: '取消安装并关闭',
   installStarting: '正在准备安装…',
   installCancelling: '正在停止安装…',
   installApplying: '正在应用配置,请稍候…',
@@ -248,6 +249,7 @@ export const en = {
   installEdit: 'Edit',
   installEditAria: 'Back to editing',
   installCancel: 'Cancel install',
+  installCloseCancels: 'Cancel install and close',
   installStarting: 'Preparing installation…',
   installCancelling: 'Stopping installation…',
   installApplying: 'Applying configuration, please wait…',

+ 12 - 3
packages/client/ui-plugin-manager/src/client/manager-store.ts

@@ -202,6 +202,11 @@ export interface PluginManagerFace {
   approveBuildsAndRetry: () => void
   /** Leave the check or the failed screen for the spec, or ask the Host to stop the run and wait for its cleanup. */
   cancelInstall: () => void
+  /**
+   * While the Host runs the install, ask it to stop and close the dialog once
+   * it confirms; the dialog stays when it cannot. Otherwise nothing.
+   */
+  cancelInstallAndClose: () => void
   toggleInstallDetails: () => void
   /** Enable the bundle the finished install added, then close the dialog and mark it in the list. */
   enableInstalled: () => void
@@ -374,6 +379,7 @@ export class PluginManagerController {
       runInstall: () => { void this.runInstall() },
       approveBuildsAndRetry: () => { void this.approveBuildsAndRetry() },
       cancelInstall: () => { void this.cancelInstall() },
+      cancelInstallAndClose: () => { void this.cancelInstall(true) },
       toggleInstallDetails: () => { this.patchInstall({ detailsOpen: !this.getSnapshot().install.detailsOpen }) },
       enableInstalled: () => { void this.enableInstalled() },
       clearHighlight: () => { if (this.getSnapshot().highlight !== null) this.patch({ highlight: null }) },
@@ -595,10 +601,11 @@ export class PluginManagerController {
    * Leave the check or the failed screen for the spec at once; a Host-owned
    * run is asked to stop and the dialog waits for the Host's word, since
    * neither a dropped RPC nor a closed connection means pnpm has stopped.
+   * @param closeAfter - stop only a running install, and close the dialog once the Host confirms the stop.
    */
-  private async cancelInstall(): Promise<void> {
+  private async cancelInstall(closeAfter = false): Promise<void> {
     const install = this.getSnapshot().install
-    if (install.phase === 'checking' || install.phase === 'failed') {
+    if (!closeAfter && (install.phase === 'checking' || install.phase === 'failed')) {
       this.abortInspect()
       this.offerSpecAgain()
       return
@@ -614,7 +621,9 @@ export class PluginManagerController {
       return
     }
     if (result.value.status === 'cancelled') {
-      this.offerSpecAgain({ kind: 'cancelled', seq: ++this.noticeSeq })
+      const notice: ManagerNotice = { kind: 'cancelled', seq: ++this.noticeSeq }
+      if (closeAfter) this.patch({ install: IDLE_INSTALL, notice })
+      else this.offerSpecAgain(notice)
       void this.load()
     } else if (result.value.status === 'too-late') {
       this.patchInstall({ phase: 'applying' })

+ 5 - 2
packages/client/ui-plugin-manager/tests/components.client.spec.tsx

@@ -68,6 +68,7 @@ function renderTab(state: Partial<PluginManagerState> = {}, config: Partial<Conf
     editInstallSpec: vi.fn(),
     runInstall: vi.fn(),
     cancelInstall: vi.fn(),
+    cancelInstallAndClose: vi.fn(),
     toggleInstallDetails: vi.fn(),
     approveBuildsAndRetry: vi.fn(),
     enableInstalled: vi.fn(),
@@ -460,11 +461,13 @@ describe('PluginManagerPage', () => {
     // Before the first chunk there is no location to name.
     set({ install: { ...IDLE_INSTALL, open: true, spec: 'dsh-x', phase: 'running', subject, detailsOpen: true } })
     expect(screen.getByText(en.terminalNoOutput)).toBeTruthy()
-    // Cancel and the back control each ask the Host to stop the run; close waits for the Host's word.
+    // Cancel and the back control each ask the Host to stop the run; the close control asks too, and closes once the Host confirms.
     fireEvent.click(screen.getByRole('button', { name: en.installCancel }))
     fireEvent.click(screen.getByRole('button', { name: en.installEditAria }))
     expect(actions.cancelInstall).toHaveBeenCalledTimes(2)
-    expect(screen.getByRole('button', { name: en.close })).toHaveProperty('disabled', true)
+    expect(screen.queryByRole('button', { name: en.close })).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: en.installCloseCancels }))
+    expect(actions.cancelInstallAndClose).toHaveBeenCalledOnce()
     expect(actions.closeInstall).not.toHaveBeenCalled()
   })
 

+ 27 - 0
packages/client/ui-plugin-manager/tests/manager-store.client.spec.ts

@@ -467,6 +467,33 @@ describe('PluginManagerController', () => {
     expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 })
   })
 
+  it.each(['cancelled', 'too-late'] as const)('closes the dialog once the Host confirms the stop its close control asked for, and stays on %s', async (status) => {
+    const pending = deferred<ReturnType<typeof ok<ChangeResult>>>()
+    const { plugins, face, state, controller, started } = bench({
+      installBundle: vi.fn().mockReturnValue(pending.promise),
+      cancelInstall: vi.fn().mockResolvedValue(ok({ status })),
+    })
+    // Before a run exists there is nothing to stop, and the dialog stays as it is.
+    face.openInstall()
+    face.cancelInstallAndClose()
+    expect(state().install).toMatchObject({ open: true, phase: 'idle' })
+    face.editInstallSpec('slow')
+    face.runInstall()
+    const requestId = await started()
+    controller.installProgress({ requestId, phase: 'installing' })
+    face.cancelInstallAndClose()
+    expect(state().install.phase).toBe('cancelling')
+    if (status === 'cancelled') {
+      await vi.waitFor(() => { expect(state().install).toMatchObject({ open: false, phase: 'idle', spec: '' }) })
+      expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 })
+    } else {
+      await vi.waitFor(() => { expect(state().install.phase).toBe('applying') })
+      expect(state().install.open).toBe(true)
+    }
+    expect(plugins.cancelInstall).toHaveBeenCalledExactlyOnceWith(requestId)
+    pending.resolve(ok({ ...failed(), application: 'cancelled' }))
+  })
+
   it.each([false, true])('drops a stop the Host confirms once the run settled, or after disposal (%s)', async (dispose) => {
     const answer = deferred<ReturnType<typeof ok<ChangeResult>>>()
     const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()