Forráskód Böngészése

feat(web): ask before switching a row off that other rows depend on

Switching a row off first asks the Host what other rows inject of what the
row provides — the package-level dependents answer filtered to the services
this row provides — and waits for the same confirmation a pack's disable
uses, naming those rows; a row nothing depends on switches off at once, and
switching on never asks. A row whose fiber is active is running whatever
record the failure registry still holds for it, so a stale wait no longer
reads as waiting once the service it waited for is back.
Yichen Jiang 1 hónapja
szülő
commit
107d07614a

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.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 .agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md
-2026-09-04-plugin-management-in-web-settings.md: 83b4f6fe36b720fb7a57538a53bc3e8c46d09f30
-2026-09-04-plugin-management-in-web-settings.zh.md: 4a1530592e34df647f1ec6a9900f31e3ba0a5853
+2026-09-04-plugin-management-in-web-settings.md: 4d5f500ba1d47c20abe226cb1db58e8ebcde0e1e
+2026-09-04-plugin-management-in-web-settings.zh.md: 0af5baa16ba584ddeb1333a8e6dcfc622c14564c

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.md


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
.agents/notes/implemented/architecture/2026-09-04-plugin-management-in-web-settings.zh.md


+ 2 - 2
packages/client/ui-settings-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-settings-plugin-manager/README.md
-README.md: 4dd556e43edc20211b0fab4f091426a52542316a
-README.zh.md: 20cef6d519c2ce44c7f2d0d72518b98b7e8f5f5c
+README.md: 525eda4f1900a4b3557f5bd0214ebafcf9964e30
+README.zh.md: 4c67a8a1335d130359e99324e5f2224b20e92d71

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

@@ -37,7 +37,7 @@ A pack's switch calls `plugins.enable` or `plugins.disable`. On a live-reload pr
 
 ### Switching one row of a pack
 
-A row's switch on the pack's page calls `plugins.setRowDisabled` against the profile's global user layer: off writes `disabled: true` for the row's id into the profile's `cordis.patch.yml`, on deletes that key again. The tree recomposes at once, so the row's host half unmounts or mounts while the rest of the pack keeps running; a browser half the pack's own client bundle mounts for every component stays until the page reloads. The switch appears only where it acts at once — an external pack that is switched on, on a profile that applies patches while it runs; built-in packs, packs that are off, and profiles that apply patches at their next start list their rows read-only. A row the pack itself keeps off, by a `disabled: true` row or a `!!js` gate, is locked with the reason, because the user layer only ever denies. A failing row can be switched off; a row another layer already owns has nothing mounted to switch. A list longer than ten rows gets a filter over the row ids.
+A row's switch on the pack's page calls `plugins.setRowDisabled` against the profile's global user layer: off writes `disabled: true` for the row's id into the profile's `cordis.patch.yml`, on deletes that key again. The tree recomposes at once, so the row's host half unmounts or mounts while the rest of the pack keeps running; a browser half the pack's own client bundle mounts for every component stays until the page reloads. The switch appears only where it acts at once — an external pack that is switched on, on a profile that applies patches while it runs; built-in packs, packs that are off, and profiles that apply patches at their next start list their rows read-only. A row the pack itself keeps off, by a `disabled: true` row or a `!!js` gate, is locked with the reason, because the user layer only ever denies. A failing row can be switched off; a row another layer already owns has nothing mounted to switch. Switching a row off first asks the Host what other rows inject of what the row provides and waits for a confirmation naming them; a row nothing depends on switches off at once, and switching on never asks. A list longer than ten rows gets a filter over the row ids.
 
 ### Adding a plugin to a preset
 

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

@@ -37,7 +37,7 @@ kind: "package-reference"
 
 ### 切换插件包里的一行
 
-包页面上行的开关以 profile 的全局用户层为目标调用 `plugins.setRowDisabled`:关闭往 profile 的 `cordis.patch.yml` 写入该行 id 的 `disabled: true`,打开再把这个键删掉。树随即重组,该行的宿主半区卸下或挂上,插件包其余部分照常运行;插件包自己的客户端 bundle 为每个组件挂载的浏览器半区要到页面重载才消失。开关只在立即生效的地方出现——已打开的外部插件包,且 profile 在运行中就应用补丁;内置插件包、已关闭的插件包,以及下次启动才应用补丁的 profile,其行只读列出。插件包自己关掉的行,无论是 `disabled: true` 的行还是 `!!js` 门,都带原因锁定,因为用户层只能拒绝。异常的行可以关掉;已被别的层占用 id 的行没有挂载任何东西,无从切换。超过十行的列表带一个按行 id 筛选的输入框。
+包页面上行的开关以 profile 的全局用户层为目标调用 `plugins.setRowDisabled`:关闭往 profile 的 `cordis.patch.yml` 写入该行 id 的 `disabled: true`,打开再把这个键删掉。树随即重组,该行的宿主半区卸下或挂上,插件包其余部分照常运行;插件包自己的客户端 bundle 为每个组件挂载的浏览器半区要到页面重载才消失。开关只在立即生效的地方出现——已打开的外部插件包,且 profile 在运行中就应用补丁;内置插件包、已关闭的插件包,以及下次启动才应用补丁的 profile,其行只读列出。插件包自己关掉的行,无论是 `disabled: true` 的行还是 `!!js` 门,都带原因锁定,因为用户层只能拒绝。异常的行可以关掉;已被别的层占用 id 的行没有挂载任何东西,无从切换。关闭一行前先向宿主询问有哪些行注入它提供的服务,有则弹出确认框点名它们;没有依赖方的行直接关闭,打开从不询问。超过十行的列表带一个按行 id 筛选的输入框。
 
 ### 把插件加入预设
 

+ 24 - 11
packages/client/ui-settings-plugin-manager/src/client/PluginManagerSettingsTab.tsx

@@ -86,11 +86,13 @@ const PHASE_STATES = {
  * same wait. Both read as one state, since the person sees one situation.
  */
 function isWaitingRow(row: RowView): boolean {
+  if (row.phase === 'active') return false
   return row.failure?.stage === 'inject-pending' || (row.failure === undefined && row.phase === 'pending')
 }
 
 /** A row with a recorded startup failure or a failed fiber; a wait for a service is not a failure. */
 function isFailedRow(row: RowView): boolean {
+  if (row.phase === 'active') return false
   return !isWaitingRow(row) && (row.failure !== undefined || row.phase === 'failed')
 }
 
@@ -125,7 +127,7 @@ interface RowToggle {
 /** Switching for a pack's rows: which rows have a write in flight, and the write. */
 interface RowToggles {
   readonly busy: (rowId: string) => boolean
-  readonly onSetDisabled: (rowId: string, disabled: boolean) => void
+  readonly onSetDisabled: (row: RowView, disabled: boolean) => void
 }
 
 /** Rows beyond this count get a filter box above the list. */
@@ -203,7 +205,7 @@ function RowsSection({ rows, t, toggle }: {
             {shown.map((row) => {
               const rowToggle = toggle === undefined || row.failure?.stage === 'conflict'
                 ? undefined
-                : { busy: toggle.busy(row.rowId), onChange: (disabled: boolean) => { toggle.onSetDisabled(row.rowId, disabled) } }
+                : { busy: toggle.busy(row.rowId), onChange: (disabled: boolean) => { toggle.onSetDisabled(row, disabled) } }
               return (
                 <li
                   key={row.entryId}
@@ -223,7 +225,7 @@ function RowsSection({ rows, t, toggle }: {
                     </span>
                     {rowToggle === undefined ? null : <RowSwitch row={row} t={t} toggle={rowToggle} />}
                   </div>
-                  {row.failure === undefined ? null : <p className={css.rowFailure}>{row.failure.message}</p>}
+                  {row.failure === undefined || row.phase === 'active' ? null : <p className={css.rowFailure}>{row.failure.message}</p>}
                 </li>
               )
             })}
@@ -471,7 +473,7 @@ function PackageDetail({
   readonly onRetry: () => void
   readonly onUninstall: () => void
   readonly onAddRow: (declaredName: string, target: PluginRowTarget) => void
-  readonly onSetRowDisabled: (rowId: string, disabled: boolean) => void
+  readonly onSetRowDisabled: (row: RowView, disabled: boolean) => void
 }): ReactNode {
   const title = pkg.title ?? shortName(pkg.name)
   const bundle = pkg.kind === 'bundle'
@@ -651,7 +653,14 @@ function InstallDialog({ install, t, onClose, onEditSpec, onToggleEnable, onRun
   )
 }
 
-/** The confirmation a destructive action waits on, naming what still uses the package. */
+/** The copy of each confirmation: its title, its one-line description, and its action button. */
+const CONFIRM_KEYS = {
+  uninstall: { title: 'confirmUninstallTitle', description: 'confirmUninstallDescription', action: 'confirmUninstall' },
+  disable: { title: 'confirmDisableTitle', description: 'confirmDisableDescription', action: 'confirmDisable' },
+  disableRow: { title: 'confirmDisableRowTitle', description: 'confirmDisableRowDescription', action: 'confirmDisableRow' },
+} as const satisfies Record<ConfirmState['action'], Record<'title' | 'description' | 'action', PluginManagerLocaleKey>>
+
+/** The confirmation a destructive action waits on, naming what still uses the package or the row. */
 function ConfirmDialog({ confirm, t, packages, presets, presetName, onConfirm, onCancel }: {
   readonly confirm: ConfirmState
   readonly t: Translate
@@ -662,7 +671,8 @@ function ConfirmDialog({ confirm, t, packages, presets, presetName, onConfirm, o
   readonly onCancel: () => void
 }): ReactNode {
   const pkg = packages.find(candidate => candidate.name === confirm.packageName)
-  const name = pkg?.title ?? shortName(confirm.packageName)
+  const name = confirm.action === 'disableRow' ? confirm.rowId : pkg?.title ?? shortName(confirm.packageName)
+  const keys = CONFIRM_KEYS[confirm.action]
   const dependents = confirm.dependents
   const lines = dependents === undefined
     ? []
@@ -677,19 +687,18 @@ function ConfirmDialog({ confirm, t, packages, presets, presetName, onConfirm, o
         return t('dependentReferencePreset', { name: preset === undefined ? target : presetName(preset), row })
       }),
     ]
-  const uninstall = confirm.action === 'uninstall'
   return (
     <Modal
       open
       onClose={onCancel}
-      title={t(uninstall ? 'confirmUninstallTitle' : 'confirmDisableTitle', { name })}
+      title={t(keys.title, { name })}
       closeLabel={t('close')}
-      description={t(uninstall ? 'confirmUninstallDescription' : 'confirmDisableDescription')}
+      description={t(keys.description)}
       footer={(
         <>
           <Button variant="outline" onClick={onCancel}>{t('cancel')}</Button>
           <Button variant="primary" className={css.dangerButton} disabled={dependents === undefined} onClick={onConfirm}>
-            {t(uninstall ? 'confirmUninstall' : 'confirmDisable')}
+            {t(keys.action)}
           </Button>
         </>
       )}
@@ -767,7 +776,11 @@ export function PluginManagerSettingsTab(props: PluginManagerSettingsTabProps):
             onRetry={() => { props.retry(openPkg.name) }}
             onUninstall={() => { props.uninstall(openPkg.name) }}
             onAddRow={(declaredName, target) => { props.addRow(openPkg.name, declaredName, target) }}
-            onSetRowDisabled={(rowId, disabled) => { props.setRowDisabled(GLOBAL, rowId, disabled) }}
+            onSetRowDisabled={(row, disabled) => {
+              // Off asks first when other rows inject what the row provides; on has nothing to ask.
+              if (disabled) props.disableRow(openPkg.name, row.entryId, row.rowId)
+              else props.setRowDisabled(GLOBAL, row.rowId, false)
+            }}
           />
         )
         : null}

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

@@ -120,6 +120,9 @@ export const zh = {
   dependentReferencePreset: '预设「{name}」里加入的插件 {row}',
   confirmUninstall: '卸载',
   confirmDisable: '停用',
+  confirmDisableRowTitle: '关闭「{name}」?',
+  confirmDisableRowDescription: '关闭后,依赖它的组件会等待依赖。',
+  confirmDisableRow: '关闭',
   actionFailed: '操作失败:{reason}',
   notEnableable: '无法启用:{reason}',
   enableFailed: '启用失败,已恢复停用:{reason}',
@@ -316,6 +319,9 @@ export const en = {
   dependentReferencePreset: 'the plugin {row} added to preset "{name}"',
   confirmUninstall: 'Uninstall',
   confirmDisable: 'Switch off',
+  confirmDisableRowTitle: 'Switch off "{name}"?',
+  confirmDisableRowDescription: 'Rows that depend on what it provides wait for it while it is off.',
+  confirmDisableRow: 'Switch off',
   actionFailed: 'The action failed: {reason}',
   notEnableable: 'Cannot be enabled: {reason}',
   enableFailed: 'Enabling failed and the plugin pack stays off: {reason}',

+ 43 - 8
packages/client/ui-settings-plugin-manager/src/client/manager-store.ts

@@ -63,13 +63,22 @@ export interface InstallState {
   readonly failure: { readonly code: string; readonly reason: string } | null
 }
 
-/** A destructive action waiting for the user's confirmation. */
-export interface ConfirmState {
-  readonly action: 'uninstall' | 'disable'
-  readonly packageName: string
-  /** What the action would strand; undefined while the Host is asked. */
-  readonly dependents: PluginDependents | undefined
-}
+/** A destructive action waiting for the user's confirmation: a package's uninstall or disable, or one row's switch-off. */
+export type ConfirmState =
+  | {
+    readonly action: 'uninstall' | 'disable'
+    readonly packageName: string
+    /** What the action would strand; undefined while the Host is asked. */
+    readonly dependents: PluginDependents | undefined
+  }
+  | {
+    readonly action: 'disableRow'
+    readonly packageName: string
+    /** The row the switch-off targets. */
+    readonly rowId: string
+    /** The rows injecting what this row provides; undefined while the Host is asked. */
+    readonly dependents: PluginDependents | undefined
+  }
 
 /** What the tab renders. */
 export interface PluginManagerState {
@@ -113,6 +122,8 @@ export interface PluginManagerFace {
   addRow: (packageName: string, declaredName: string, target: PluginRowTarget) => void
   removeRow: (target: PluginRowTarget, rowId: string) => void
   setRowDisabled: (target: PluginRowTarget, rowId: string, disabled: boolean) => void
+  /** Switch one of a package's rows off in the global layer, after asking when other rows inject what it provides. */
+  disableRow: (packageName: string, entryId: string, rowId: string) => void
   dismissNotice: () => void
   /** Display name for one preset, resolved through the agent-preset dictionaries. */
   presetName: (preset: PresetGroup) => string
@@ -231,6 +242,7 @@ export class PluginManagerController {
           this.answer(await this.ctx.remote.plugins.setRowDisabled(target, rowId, disabled))
         })
       },
+      disableRow: (packageName, entryId, rowId) => { void this.askRowConfirm(packageName, entryId, rowId) },
       dismissNotice: () => { this.patch({ notice: null }) },
       presetName: this.presetName,
     }
@@ -310,7 +322,7 @@ export class PluginManagerController {
    * confirmation and runs at once; every other confirmation runs its action
    * through `confirm` once the dependents are listed.
    */
-  private async askConfirm(action: ConfirmState['action'], packageName: string): Promise<void> {
+  private async askConfirm(action: 'uninstall' | 'disable', packageName: string): Promise<void> {
     const perform = action === 'uninstall'
       ? async (): Promise<void> => {
         this.answer(await this.ctx.remote.plugins.uninstall(packageName))
@@ -334,6 +346,29 @@ export class PluginManagerController {
     this.patch({ confirm: { ...confirm, dependents: value } })
   }
 
+  /**
+   * Ask before switching one row off: of the package's dependents, the
+   * services this row provides that other rows inject. A row nothing depends
+   * on switches off at once.
+   */
+  private async askRowConfirm(packageName: string, entryId: string, rowId: string): Promise<void> {
+    const target: PluginRowTarget = { kind: 'global' }
+    this.pendingConfirm = () => this.run(rowKey(target, rowId), { rowId }, async () => {
+      this.answer(await this.ctx.remote.plugins.setRowDisabled(target, rowId, true))
+    })
+    this.patch({ confirm: { action: 'disableRow', packageName, rowId, dependents: undefined } })
+    const dependents = await this.ctx.remote.plugins.dependents(packageName)
+    const confirm = this.getSnapshot().confirm
+    if (this.disposed || confirm?.action !== 'disableRow' || confirm.packageName !== packageName || confirm.rowId !== rowId) return
+    const services = dependents.ok ? dependents.value.services.filter(service => service.providedBy === entryId) : []
+    if (services.length === 0) {
+      this.patch({ confirm: null })
+      await this.confirm()
+      return
+    }
+    this.patch({ confirm: { ...confirm, dependents: { services, references: [] } } })
+  }
+
   private async confirm(): Promise<void> {
     const pending = this.pendingConfirm
     this.pendingConfirm = undefined

+ 34 - 6
packages/client/ui-settings-plugin-manager/tests/components.client.spec.tsx

@@ -1,5 +1,5 @@
 // @vitest-environment jsdom
-import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import type { PluginPackageView } from '@deepseek-ai/dsh-api-remotes/client'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
@@ -73,6 +73,7 @@ function renderTab(state: Partial<PluginManagerState> = {}) {
     addRow: vi.fn(),
     removeRow: vi.fn(),
     setRowDisabled: vi.fn(),
+    disableRow: vi.fn(),
     dismissNotice: vi.fn(),
   }
   const props = {
@@ -195,6 +196,7 @@ describe('PluginManagerSettingsTab', () => {
             { entryId: 'include:idle', rowId: 'idle', moduleName: 'dsh-better-sidebar/idle', enabled: true, phase: null },
             { entryId: 'include:wait', rowId: 'wait', moduleName: 'dsh-better-sidebar/wait', enabled: true, phase: 'pending' },
             { entryId: 'include:recorded', rowId: 'recorded', moduleName: 'dsh-better-sidebar/recorded', enabled: true, phase: null, failure: { stage: 'inject-pending', message: 'pending (waiting for service: authorization)' } },
+            { entryId: 'include:healed', rowId: 'healed', moduleName: 'dsh-better-sidebar/healed', enabled: true, phase: 'active', failure: { stage: 'inject-pending', message: 'stale' } },
           ],
         }),
         pkg({
@@ -219,7 +221,7 @@ describe('PluginManagerSettingsTab', () => {
     expect(screen.getByText('0.16.0')).toBeTruthy()
     expect(screen.getByText(en.sourceExternal)).toBeTruthy()
     // Every row in the pack's order: its id over the module it loads, then its state; a failure adds its message.
-    expect(screen.getByText('8 total · 1 running · 2 waiting · 2 off · 2 failed')).toBeTruthy()
+    expect(screen.getByText('9 total · 2 running · 2 waiting · 2 off · 2 failed')).toBeTruthy()
     const rowText = (id: string): string | undefined => document.querySelector(`[data-plugin-row="include:${id}"]`)?.textContent
     expect(rowText('better-sidebar')).toBe(`better-sidebardsh-better-sidebar${en.rowPhaseActive}`)
     expect(rowText('off')).toBe(`offdsh-better-sidebar/off${en.partDisabledByUser}`)
@@ -232,6 +234,9 @@ describe('PluginManagerSettingsTab', () => {
     expect(rowText('recorded')).toBe(`recordeddsh-better-sidebar/recorded${en.rowPhasePending}pending (waiting for service: authorization)`)
     expect(document.querySelector('[data-plugin-row="include:wait"]')?.getAttribute('data-state')).toBe('waiting')
     expect(document.querySelector('[data-plugin-row="include:recorded"]')?.getAttribute('data-state')).toBe('waiting')
+    // A row that came to life is running, whatever record the registry still holds for it.
+    expect(rowText('healed')).toBe(`healeddsh-better-sidebar/healed${en.rowPhaseActive}`)
+    expect(document.querySelector('[data-plugin-row="include:healed"]')?.getAttribute('data-state')).toBeNull()
     expect(document.querySelector('[data-plugin-row="include:off"]')?.getAttribute('data-state')).toBe('off')
     expect(document.querySelector('[data-plugin-row="include:crash"]')?.getAttribute('data-state')).toBe('failed')
     // A short list has no filter; the built-in rows the pack changes are named.
@@ -302,22 +307,25 @@ describe('PluginManagerSettingsTab', () => {
     })
     const target = { kind: 'global' } as const
     fireEvent.click(screen.getByRole('button', { name: 'View better-sidebar' }))
-    // A row the person switched off switches back on; one the pack itself keeps off is locked and says why.
+    // A row the person switched off switches back on at once; one the pack itself keeps off is locked and says why.
     const off = screen.getByRole('switch', { name: 'Enable component off' })
     expect(off.getAttribute('aria-checked')).toBe('false')
     fireEvent.click(off)
     expect(actions.setRowDisabled).toHaveBeenLastCalledWith(target, 'off', false)
+    expect(actions.disableRow).not.toHaveBeenCalled()
     const gated = screen.getByRole('switch', { name: 'Enable component gated' })
     expect(gated).toHaveProperty('disabled', true)
     expect(gated.getAttribute('title')).toBe(en.partLockedByComposition)
-    // A failing row can be switched off; a row another layer owns has nothing mounted to switch.
+    // Switching a row off goes through the ask, which names the package and the row's tree-wide id;
+    // a failing row can be switched off; a row another layer owns has nothing mounted to switch.
     fireEvent.click(screen.getByRole('switch', { name: 'Enable component crash' }))
-    expect(actions.setRowDisabled).toHaveBeenLastCalledWith(target, 'crash', true)
+    expect(actions.disableRow).toHaveBeenLastCalledWith('dsh-better-sidebar', 'include:crash', 'crash')
     expect(screen.queryByRole('switch', { name: 'Enable component taken' })).toBeNull()
     const sidebar = screen.getByRole('switch', { name: 'Enable component better-sidebar' })
     expect(sidebar.getAttribute('aria-checked')).toBe('true')
     fireEvent.click(sidebar)
-    expect(actions.setRowDisabled).toHaveBeenLastCalledWith(target, 'better-sidebar', true)
+    expect(actions.disableRow).toHaveBeenLastCalledWith('dsh-better-sidebar', 'include:better-sidebar', 'better-sidebar')
+    expect(actions.setRowDisabled).toHaveBeenCalledTimes(1)
     // Only the row with a write in flight goes inert; a busy package takes every row with it.
     set({ busy: [rowKey(target, 'better-sidebar')] })
     expect(screen.getByRole('switch', { name: 'Enable component better-sidebar' })).toHaveProperty('disabled', true)
@@ -506,6 +514,26 @@ describe('PluginManagerSettingsTab', () => {
     expect(screen.getByRole('alert').textContent).toBe(en.installFailed)
   })
 
+  it('asks before switching a row off, naming the rows that inject what it provides', () => {
+    const { actions, set } = renderTab({
+      packages: [pkg()],
+      confirm: { action: 'disableRow', packageName: 'dsh-better-sidebar', rowId: 'seam', dependents: undefined },
+    })
+    expect(screen.getByRole('dialog', { name: en.confirmDisableRowTitle.replace('{name}', 'seam') })).toBeTruthy()
+    expect(screen.getByText(en.confirmDisableRowDescription)).toBeTruthy()
+    expect(screen.getByRole('button', { name: en.confirmDisableRow })).toHaveProperty('disabled', true)
+    set({
+      confirm: {
+        action: 'disableRow', packageName: 'dsh-better-sidebar', rowId: 'seam',
+        dependents: { services: [{ service: 'authorization', providedBy: 'include:seam', injectedBy: ['include:oauth'] }], references: [] },
+      },
+    })
+    expect(screen.getByText(en.confirmDependents)).toBeTruthy()
+    expect(within(screen.getByRole('dialog')).getByRole('listitem').textContent).toContain('oauth')
+    fireEvent.click(screen.getByRole('button', { name: en.confirmDisableRow }))
+    expect(actions.confirm).toHaveBeenCalledTimes(1)
+  })
+
   it('names what still uses a package in the confirmation and runs the action through it', () => {
     const { actions, set } = renderTab({
       packages: [pkg(), pkg({ name: 'dsh-other', title: 'Other' })],

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

@@ -182,6 +182,44 @@ describe('PluginManagerController', () => {
     await vi.waitFor(() => { expect(plugins.disable).toHaveBeenCalledTimes(2) })
   })
 
+  it('asks before switching a row off only when other rows inject what it provides', async () => {
+    const services = [
+      { service: 'authorization', providedBy: 'include:seam', injectedBy: ['include:oauth'] },
+      { service: 'other', providedBy: 'include:elsewhere', injectedBy: ['include:x'] },
+    ]
+    const { plugins, face, state, controller } = bench({ dependents: vi.fn(() => Promise.resolve(ok({ services, references: [] }))) })
+    await controller.load()
+    face.disableRow(BUNDLE.name, 'include:seam', 'seam')
+    expect(state().confirm).toEqual({ action: 'disableRow', packageName: BUNDLE.name, rowId: 'seam', dependents: undefined })
+    // Of the package's dependents, only the services this row provides are the row's.
+    await vi.waitFor(() => { expect(state().confirm?.dependents).toEqual({ services: [services[0]], references: [] }) })
+    expect(plugins.setRowDisabled).not.toHaveBeenCalled()
+    face.confirm()
+    await vi.waitFor(() => { expect(plugins.setRowDisabled).toHaveBeenCalledWith({ kind: 'global' }, 'seam', true) })
+    await vi.waitFor(() => { expect(state().busy).toEqual([]) })
+    // A row nothing depends on switches off without asking; so does one whose dependents the Host refused to name.
+    face.disableRow(BUNDLE.name, 'include:lonely', 'lonely')
+    await vi.waitFor(() => { expect(plugins.setRowDisabled).toHaveBeenCalledWith({ kind: 'global' }, 'lonely', true) })
+    expect(state().confirm).toBeNull()
+    plugins.dependents.mockResolvedValueOnce(refused('gateway/internal', 'boom') as never)
+    face.disableRow(BUNDLE.name, 'include:unknown', 'unknown')
+    await vi.waitFor(() => { expect(plugins.setRowDisabled).toHaveBeenCalledWith({ kind: 'global' }, 'unknown', true) })
+    expect(state().confirm).toBeNull()
+  })
+
+  it('drops a row ask whose answer arrives after the confirmation changed', async () => {
+    const gate = deferred<ReturnType<typeof ok<{ services: never[]; references: never[] }>>>()
+    const { plugins, face, state, controller } = bench({ dependents: vi.fn().mockReturnValueOnce(gate.promise) })
+    await controller.load()
+    face.disableRow(BUNDLE.name, 'include:seam', 'seam')
+    face.cancelConfirm()
+    gate.resolve(ok({ services: [], references: [] }))
+    await Promise.resolve()
+    await Promise.resolve()
+    expect(state().confirm).toBeNull()
+    expect(plugins.setRowDisabled).not.toHaveBeenCalled()
+  })
+
   it('always asks before uninstalling, and cancelling runs nothing', async () => {
     const { plugins, face, state, controller } = bench()
     await controller.load()

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott