feat(plugin-inventory): group by enabled state and allow re-enabling disabled plugins

The plugin-list tab now splits by current state: disabled plugins sit in the
main list with an enable button (so a bundle-default-disabled plugin can be
re-enabled), while enabled plugins sit in the collapsible system section —
a user-added enabled plugin keeps a disable toggle, a required one shows
none. setEnabled refuses to disable a required plugin but allows re-enabling
a disabled one, verifying the fiber activates and reverting a
dependency-missing enable.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-14 14:30:13 +08:00
parent d385b7cf05
commit cbec8cd6a9
7 changed files with 46 additions and 24 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-14-plugin-enable-disable-in-page.md
2026-08-14-plugin-enable-disable-in-page.md: b868a8e4f84357e944c184cb80d58ea40858f7b8 2026-08-14-plugin-enable-disable-in-page.md: 9616cc207f8032faf61d17e5211e64ce4253dee9
2026-08-14-plugin-enable-disable-in-page.zh.md: c53187fd067f3e208e62ecc09d361548ba61bda1 2026-08-14-plugin-enable-disable-in-page.zh.md: 1251c0aa7e57fce2364d99119c52d68540e728ae
@@ -38,13 +38,15 @@ The list is grouped: toggleable plugins carry the button in the main list, while
the required system plugins sit in a separate collapsible "system plugins" the required system plugins sit in a separate collapsible "system plugins"
section with no controls. section with no controls.
**Guard:** every entry carries a `protected` flag. The rule is default-protect — **Guard:** every entry carries a `protected` flag. Disabling a plugin that
disabling a plugin that another plugin injects breaks the dependent, and another plugin injects breaks the dependent, and enabling one whose service is
enabling one whose service is unavailable fails the boot (both surfaced as unavailable fails the boot (both surfaced as `dsh-tool-ralph: pending (waiting
`dsh-tool-ralph: pending (waiting for service: workflowEngine)` after a bad for service: workflowEngine)` after a bad toggle). So `setEnabled` refuses to
toggle). So `setEnabled` refuses and the UI hides the toggle for every shipped **disable** a required plugin, and after **enabling** a plugin it verifies the
plugin; only plugins added through an opt-in bundle (`USER_TOGGLEABLE_PLUGINS` fiber became active (reverting a dependency-missing enable). The plugin-list tab
in `src/required.ts`) are toggleable. groups by current state: disabled plugins sit in the main list with an enable
button, while enabled plugins sit in a collapsible system section (a user-added
enabled plugin keeps a disable toggle, a required one shows none).
## Persistence caveat ## Persistence caveat
@@ -29,11 +29,12 @@ Web 插件列表 tab(`ui-settings-plugin-inventory`)在每张展开卡片的
绑定该 Remote,切换后重新拉取列表。列表分组:可切换插件在主列表带按钮,必需的系统插件 绑定该 Remote,切换后重新拉取列表。列表分组:可切换插件在主列表带按钮,必需的系统插件
放在一个可折叠的"系统插件"区,无任何开关。 放在一个可折叠的"系统插件"区,无任何开关。
**门卫:** 每条条目带 `protected` 标记。规则默认保护——停用一个被其他插件注入的插件会破坏 **门卫:** 每条条目带 `protected` 标记。停用一个被其他插件注入的插件会破坏依赖者,
依赖者,启用一个服务不可用的插件会导致启动失败(坏切换后都会表现为 启用一个服务不可用的插件会导致启动失败(坏切换后都会表现为
`dsh-tool-ralph: pending (waiting for service: workflowEngine)`)。所以 `setEnabled` `dsh-tool-ralph: pending (waiting for service: workflowEngine)`)。所以 `setEnabled`
拒绝、UI 隐藏所有随包插件的开关;只有通过 opt-in bundle 添加的插件 拒绝**停用**必需插件;**启用**后校验 fiber 变为 active(依赖缺失的启用会回滚)。
(`src/required.ts` 的 `USER_TOGGLEABLE_PLUGINS`)可切换。 插件列表 tab 按当前状态分组:已停用插件在主列表带"启用"按钮,已启用插件放在可折叠的
"系统插件"区(用户自加的已启用插件保留"停用"按钮,必需插件无任何开关)。
## 持久化注意 ## 持久化注意
@@ -98,10 +98,11 @@ export function PluginInventorySettingsTab({ list, setEnabled, t }: PluginInvent
: [], : [],
[normalizedQuery, state], [normalizedQuery, state],
) )
// User-added (opt-in bundle) plugins are toggleable; the rest are required // Disabled plugins sit in the main list with an enable toggle so they can be
// system plugins shown in a separate collapsible section without toggles. // re-enabled; enabled plugins are grouped into a collapsible system section
const userEntries = filteredEntries.filter(entry => !entry.protected) // (a user-added enabled plugin still carries a disable toggle).
const systemEntries = filteredEntries.filter(entry => entry.protected) const userEntries = filteredEntries.filter(entry => !entry.enabled)
const systemEntries = filteredEntries.filter(entry => entry.enabled)
useEffect(() => { useEffect(() => {
if (expanded !== null && !filteredEntries.some(entry => entry.entryId === expanded)) { if (expanded !== null && !filteredEntries.some(entry => entry.entryId === expanded)) {
@@ -170,7 +171,17 @@ export function PluginInventorySettingsTab({ list, setEnabled, t }: PluginInvent
</div> </div>
) : null} ) : null}
</dl> </dl>
{entry.protected ? ( {!entry.enabled ? (
<button
className={css.toggle}
type="button"
disabled={busy === entry.entryId}
aria-busy={busy === entry.entryId || undefined}
onClick={() => { toggle(entry.entryId, true) }}
>
{busy === entry.entryId ? t('toggling') : t('enable')}
</button>
) : entry.protected ? (
<p className={css.required}>{t('required')}</p> <p className={css.required}>{t('required')}</p>
) : ( ) : (
<button <button
@@ -178,9 +189,9 @@ export function PluginInventorySettingsTab({ list, setEnabled, t }: PluginInvent
type="button" type="button"
disabled={busy === entry.entryId} disabled={busy === entry.entryId}
aria-busy={busy === entry.entryId || undefined} aria-busy={busy === entry.entryId || undefined}
onClick={() => { toggle(entry.entryId, !entry.enabled) }} onClick={() => { toggle(entry.entryId, false) }}
> >
{busy === entry.entryId ? t('toggling') : entry.enabled ? t('disable') : t('enable')} {busy === entry.entryId ? t('toggling') : t('disable')}
</button> </button>
)} )}
</div> </div>
+1 -1
View File
@@ -6,7 +6,7 @@ Host projection of the current Cordis Loader tree with per-plugin enable/disable
The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, or event stream. `setEnabled` toggles one entry live through `ctx.loader.update` and persists an explicit `disabled` override into the profile's user patch layer so the choice survives a restart (a bundle-default disable needs the `disabled: false` override to stick). The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, or event stream. `setEnabled` toggles one entry live through `ctx.loader.update` and persists an explicit `disabled` override into the profile's user patch layer so the choice survives a restart (a bundle-default disable needs the `disabled: false` override to stick).
Every entry carries a `protected` flag. The guard is default-protect: every shipped plugin is required by the application (disabling one that another plugin injects breaks the dependent; enabling one whose service is unavailable fails the boot), so `setEnabled` refuses them and the UI hides the toggle. Only plugins a deployment adds through an opt-in bundle (`USER_TOGGLEABLE_PLUGINS` in `src/required.ts`) are toggleable. The Web plugin-list tab groups the inventory accordingly: toggleable plugins carry an enable/disable button, while the required system plugins sit in a separate collapsible "system plugins" section with no controls. Its public payload types live under `./types`, and Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`. Every entry carries a `protected` flag. The guard is default-protect: every shipped plugin is required by the application (disabling one that another plugin injects breaks the dependent; enabling one whose service is unavailable fails the boot), so `setEnabled` refuses them and the UI hides the toggle. Only plugins a deployment adds through an opt-in bundle (`USER_TOGGLEABLE_PLUGINS` in `src/required.ts`) are toggleable. The Web plugin-list tab groups the inventory by current state: disabled plugins sit in the main list with an enable button (so they can be re-enabled), while enabled plugins sit in a collapsible "system plugins" section — a user-added enabled plugin keeps a disable toggle, a required one shows none. Its public payload types live under `./types`, and Typert generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`.
The service is Remote-only and deliberately declares no same-process Cordis `Context` merge. Client packages consume it through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation. The service is Remote-only and deliberately declares no same-process Cordis `Context` merge. Client packages consume it through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation.
+1 -1
View File
@@ -6,7 +6,7 @@
阶段为 `pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型或事件流。`setEnabled` 通过 `ctx.loader.update` 实时切换单条条目,并把显式 `disabled` 覆盖写进 profile 的用户补丁层,使选择在重启后保留(bundle 默认禁用的行需要 `disabled: false` 覆盖才能保持启用)。 阶段为 `pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型或事件流。`setEnabled` 通过 `ctx.loader.update` 实时切换单条条目,并把显式 `disabled` 覆盖写进 profile 的用户补丁层,使选择在重启后保留(bundle 默认禁用的行需要 `disabled: false` 覆盖才能保持启用)。
每条条目带 `protected` 标记。守卫默认保护:所有随包插件都是应用必需(停用一个被其他插件注入的插件会破坏依赖者;启用一个服务不可用的插件会导致启动失败),所以 `setEnabled` 拒绝它们、UI 隐藏开关。只有部署通过 opt-in bundle 添加的插件(`src/required.ts` 的 `USER_TOGGLEABLE_PLUGINS`)可切换。Web 插件列表 tab 据此分组:可切换插件带启用/停用按钮,必需的系统插件放在一个可折叠的"系统插件"区,无任何开关。公开 payload 类型位于 `./types`,Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。 每条条目带 `protected` 标记。守卫默认保护:所有随包插件都是应用必需(停用一个被其他插件注入的插件会破坏依赖者;启用一个服务不可用的插件会导致启动失败),所以 `setEnabled` 拒绝它们、UI 隐藏开关。只有部署通过 opt-in bundle 添加的插件(`src/required.ts` 的 `USER_TOGGLEABLE_PLUGINS`)可切换。Web 插件列表 tab 按当前状态分组:已停用插件在主列表带"启用"按钮(可重新启用),已启用插件放在可折叠的"系统插件"区——用户自加的已启用插件仍保留"停用"按钮,必需插件则无任何开关。公开 payload 类型位于 `./types`,Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。
该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.md) 组合消费它,而不导入 Host 实现。 该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.md) 组合消费它,而不导入 Host 实现。
+10 -2
View File
@@ -89,11 +89,19 @@ export class PluginInventoryGateway extends TypertRemoteService {
if (entry === undefined) { if (entry === undefined) {
throw new Error(`plugin entry ${String(entryId)} not found`) throw new Error(`plugin entry ${String(entryId)} not found`)
} }
if (isRequiredPlugin(entry.options.name)) { // Disabling a required system plugin tears the process down; refuse it.
throw new Error(`plugin ${String(entryId)} is required by the application and cannot be toggled`) // Re-enabling a disabled plugin is what this surface is for.
if (!enabled && isRequiredPlugin(entry.options.name)) {
throw new Error(`plugin ${String(entryId)} is required by the application and cannot be disabled`)
} }
const rowId = entry.options.id const rowId = entry.options.id
await this.ctx.loader.update(entryId, { disabled: !enabled }) await this.ctx.loader.update(entryId, { disabled: !enabled })
// An enable whose injected services are unavailable would fail the next
// boot (the dependent never becomes active). Revert and refuse loudly.
if (enabled && entry.fiber !== undefined && entry.fiber.state !== FIBER_STATE.ACTIVE) {
await this.ctx.loader.update(entryId, { disabled: true })
throw new Error(`plugin ${String(entryId)} could not start; its dependencies are unavailable`)
}
if (this.ctx.baseUrl !== undefined) { if (this.ctx.baseUrl !== undefined) {
persistPluginDisabled(fileURLToPath(this.ctx.baseUrl), rowId, !enabled) persistPluginDisabled(fileURLToPath(this.ctx.baseUrl), rowId, !enabled)
} }