dsh-vscode-mode 0.14.3 → 0.14.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.14.3",
3
+ "version": "0.14.5",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
@@ -48,12 +48,12 @@
48
48
  "@vscode/ripgrep": "^1.18.0"
49
49
  },
50
50
  "peerDependencies": {
51
- "@deepseek-ai/dsh-client-ui-slots": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1",
52
- "@deepseek-ai/dsh-settings": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1",
53
- "@deepseek-ai/dsh-client-ui-settings": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1",
54
- "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1",
55
- "@deepseek-ai/dsh-llm": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1",
56
- "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1",
51
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1 || =0.2.0-rc.2",
52
+ "@deepseek-ai/dsh-settings": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1 || =0.2.0-rc.2",
53
+ "@deepseek-ai/dsh-client-ui-settings": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1 || =0.2.0-rc.2",
54
+ "@deepseek-ai/dsh-client-ui-primitives": ">=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1 || =0.2.0-rc.2",
55
+ "@deepseek-ai/dsh-llm": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1 || =0.2.0-rc.2",
56
+ "@deepseek-ai/dsh-tools": ">=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0 || >=0.1.1-0 <0.2.0-0 || >=0.1.2-0 <0.2.0-0 || >=0.1.3-0 <0.2.0-0 || >=0.1.4-0 <0.2.0-0 || >=0.1.5-0 <0.2.0-0 || >=0.1.6-0 <0.2.0-0 || >=0.1.7-0 <0.2.0-0 || =0.2.0-rc.1 || =0.2.0-rc.2",
57
57
  "cordis": ">=4.0.0-rc <5",
58
58
  "react": "^18.2.0",
59
59
  "react-dom": "^18.2.0",
@@ -29,6 +29,10 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
29
29
 
30
30
  禁用、删除、配置变更、刷新重连及 agent/插件卸载都会释放相应 fiber 和连接。仅刷新未变化的项目列表不会重复连接。主机代码更新需要重启 DSH 才能加载新实现。
31
31
 
32
+ 管理页首次加载配置,之后仅在 MCP 页签可见且文档可见时每 2.5 秒读取 `mcp.snapshot`;单次读取 10 秒截止,不重叠、不在卸载后回写。快照只读运行台账,不读项目文件、不 reconcile、不额外建连。外部文件变更通过显式刷新/重新打开设置或新 agent 加载;快照不是文件监听器。配置写操作串行保护读取—修改—写入与跨作用域命名保留。
33
+
34
+ 表单 stdio 参数采用 JSON `string[]` 或每行一个完整参数,不按空格/引号做 shell 拆分;含空参数或内嵌换行使用 JSON。HTTP 请求头每行 `key=value`,保留值内的等号,拒绝非法/重复(忽略大小写)键。失败保存保持表单,忙时禁止重复动作。
35
+
32
36
  ## 3. 项目配置
33
37
 
34
38
  `.mcp.json` 示例:
@@ -57,7 +61,7 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
57
61
  - `disabled: true` 保持配置但不创建连接;保存表单会恢复启用。重启和 reconcile 都尊重 `disabled`。
58
62
  - 保存保留文档顶层、其他服务器及当前服务器的未知字段和未编辑高级参数;切换传输方式会移除旧传输专属字段。
59
63
  - `env` / `headers` 在返回值中使用 `••••••` 脱敏。原键的未修改掩码保存时恢复真实值;新键只有掩码而没有原值时会拒绝保存,需填写凭据。
60
- - 只有 `ENOENT` 表示配置不存在。权限、IO、JSON 或结构错误会报告 `fileError`,保留最后有效配置与活动连接,不覆盖损坏文件。
64
+ - 配置不存在只认结构化错误码:官方 fs 的 `FS_NOT_FOUND` 与兼容旧后端的 `ENOENT`;缺失的可选配置按空服务处理,不写空文件,不阻断其他项目的重名检查。权限、IO、JSON 或结构错误会报告 `fileError`,保留最后有效配置与活动连接,不覆盖损坏文件。
61
65
 
62
66
  全局配置继续使用官方 loader entry:
63
67
 
@@ -80,6 +84,7 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
80
84
  | 方法 | 入参 | 成功返回字段 |
81
85
  |---|---|---|
82
86
  | `mcp.list` | `{}` | `servers` |
87
+ | `mcp.snapshot` | `{}` | `servers`, `projects`(只读运行快照) |
83
88
  | `mcp.save` | `{ config }` | `server` |
84
89
  | `mcp.remove` | `{ id }` | 无 |
85
90
  | `mcp.toggle` | `{ id, enabled }` | `server` |
@@ -97,6 +102,7 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
97
102
  - `serverName` 使用 `/^[A-Za-z0-9_-]{1,32}$/`;允许下划线,与技能名规则不同。
98
103
  - VSCodeMode 管理的全局和所有项目配置之间名称唯一,包括未启用和没有活动 agent 的配置。官方 MCP 还会拒绝同一个 agent scope 中的重复名称。
99
104
  - 工具名是 `mcp__<serverName>__<原始工具名>`。共享资源工具通过 `arguments.server` 选择服务器。
105
+ - 不同服务名称不得以 `__` 形成前缀交叠,例如 `alpha` 与 `alpha__nested`:原始工具名也允许 `__`,官方编码不能唯一反解,不能靠最长前缀猜归属。保存与 reconcile 双向检查全局/所有项目(含禁用项);历史歧义工具执行时 fail-closed 且不计入任何服务。独立含 `__` 的名称仍合法。
100
106
  - 项目稳定 id 仍是 `vsm-mcp.<workspaceHash>.<serverName>`,用于 UI/RPC 身份;0.13 起不再代表全局 loader entry。
101
107
  - 启动迁移会先删除旧的 `vsm-mcp.*` 和 `vsm-mcp:*` 全局项目条目及其连接,再在 agent scope 挂载。不要手动将项目条目改成其他 id。
102
108
  - 项目写操作只接受已注册 workspace,目标固定为该根目录的 `.mcp.json`。
@@ -120,10 +126,10 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
120
126
 
121
127
  | 状态/现象 | 含义与处理 |
122
128
  |---|---|
123
- | `configured` | 配置已保存,没有已挂载实例;打开匹配工作区会话后挂载 |
129
+ | `configured` | 中性色;配置已保存,没有已挂载实例,打开匹配工作区会话后挂载 |
124
130
  | `disabled` | 配置已停用,不建立项目连接 |
125
131
  | `connecting` | 官方插件还在等待启动完成;检查命令、cwd、URL 和握手 |
126
- | `unverified` | fiber 已激活,但官方没有公开连接状态证据;不能宣称已连接 |
132
+ | `unverified` | 中性色;fiber 已激活,但官方没有公开连接状态证据,不能宣称已连接;右侧绿色开关只表示启用 |
127
133
  | `error` | 插件启动或挂载失败;读 `error` 与 Host 日志 |
128
134
  | `instanceCount: N` | N 个挂载的官方插件 fiber,**不等于 N 个确认存活的网络连接** |
129
135
  | `toolCount: 0` | 可能是合法的纯资源服务器,也可能启动失败或工具同步失败;结合资源调用和日志判断 |
@@ -18,15 +18,20 @@ export interface DiffDockSnapshot {
18
18
  }
19
19
 
20
20
  /**
21
- * 判断编辑器差异栏应显示在指定布局,空态仅在尚有差异文件时显示。
22
- * @author ddj 2026年09月29号
21
+ * 指定布局仅在仍有待处理差异时显示差异栏,冲突差异和其他文件差异也保留入口。
22
+ * @public
23
+ * @author ddj 2026年10月08号
23
24
  * @param snapshot 编辑器发布的差异快照
24
25
  * @param layout 目标布局
25
26
  * @returns 是否显示差异栏
26
27
  */
27
28
  export function dockInLayout(snapshot: DiffDockSnapshot | null, layout: 'side' | 'tab'): boolean {
28
29
  if (!snapshot || (snapshot.layout ?? 'tab') !== layout) return false
29
- return snapshot.mode !== 'editor-empty' || Boolean(snapshot.fileTotal)
30
+ return Number(snapshot.fileTotal) > 0
31
+ || Number(snapshot.allPendingCount) > 0
32
+ || Number(snapshot.diffTotal) > 0
33
+ || (Array.isArray(snapshot.pendingRegions) && snapshot.pendingRegions.length > 0)
34
+ || (Array.isArray(snapshot.staleRegions) && snapshot.staleRegions.length > 0)
30
35
  }
31
36
 
32
37
  type DiffDockListener = () => void
@@ -64,6 +64,7 @@ import { disposeSnippets } from './snippets/provider.js'
64
64
  import { disposeLaunchJson } from './dap/launchSnippetProvider.js'
65
65
  import { disposeAiInline } from './ai/inlineProvider.js'
66
66
  import { readSessionScope, subscribeScope } from './sessionScope.js'
67
+ import { installPlanBridge } from './planBridge.js'
67
68
  import { flushUiState, hydrateUiState, installUiStatePersist, onUiStateReady, reportUiStateEvent, uiStateSettled } from './state/uiStatePersist.js'
68
69
  import { workspaceScopeOf } from './state/scopeStore.js'
69
70
  import { dapStore } from './dap/store.js'
@@ -188,6 +189,7 @@ export async function apply(ctx: any): Promise<void> {
188
189
  const registry: FileOpenerRegistry = createFileOpenerRegistry()
189
190
  const workspaces = ctx.get('workspaces')
190
191
  const sessions = ctx.get('sessions')
192
+ ctx.effect(() => installPlanBridge(ctx, schedule), 'vscode-mode: plan integration')
191
193
 
192
194
  // 外部深链落地:Windows 右键菜单 / Unity 外部编辑器 → 浏览器 URL 参数 → 打开规则路由
193
195
  ctx.effect(() => setupExtOpen(ctx), 'vscode-mode: external open')
@@ -0,0 +1,79 @@
1
+ import type { MpcConfig } from '../shared/mcp.js'
2
+
3
+ export interface McpDraft {
4
+ serverName: string
5
+ transport: 'stdio' | 'streamable-http'
6
+ command: string
7
+ args: string
8
+ cwd: string
9
+ url: string
10
+ headers: string
11
+ }
12
+
13
+ /**
14
+ * @public
15
+ * @author ddj 2026年10月08号
16
+ * @description Parse lossless JSON string[] or one literal argument per nonempty line; no shell splitting.
17
+ * @param text Argument field.
18
+ * @returns Literal argument vector.
19
+ * @throws Invalid JSON or a non-string array element.
20
+ */
21
+ export function parseArgs(text: string): string[] {
22
+ if (text.trimStart().startsWith('[')) {
23
+ let value: unknown
24
+ try { value = JSON.parse(text) } catch { throw new Error('参数 JSON 无效,请输入 string[] 或每行一个参数') }
25
+ if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) {
26
+ throw new Error('参数 JSON 必须是 string[]')
27
+ }
28
+ return value
29
+ }
30
+ return text.split(/\r?\n/).filter((line) => line.length > 0)
31
+ }
32
+
33
+ /**
34
+ * @public
35
+ * @author ddj 2026年10月08号
36
+ * @description Validate HTTP headers; duplicates are case-insensitive and values retain embedded equals.
37
+ * @param text One key=value header per line.
38
+ * @returns Validated header dictionary.
39
+ * @throws Missing separator, invalid/empty key, duplicate key, or illegal control characters.
40
+ */
41
+ export function parsePairs(text: string): Record<string, string> {
42
+ const pairs: Array<[string, string]> = []
43
+ const keys = new Set<string>()
44
+ for (const [index, raw] of text.split(/\r?\n/).entries()) {
45
+ if (!raw.trim()) continue
46
+ const separator = raw.indexOf('=')
47
+ const key = raw.slice(0, separator).trim()
48
+ const value = raw.slice(separator + 1).trim()
49
+ if (separator < 1 || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(key) || /[\x00-\x08\x0a-\x1f\x7f]/.test(value)) {
50
+ throw new Error('请求头第 ' + (index + 1) + ' 行格式错误,应为有效 key=value')
51
+ }
52
+ const normalized = key.toLowerCase()
53
+ if (keys.has(normalized)) throw new Error('请求头重复:' + key)
54
+ keys.add(normalized)
55
+ pairs.push([key, value])
56
+ }
57
+ return Object.fromEntries(pairs)
58
+ }
59
+
60
+ /**
61
+ * @public
62
+ * @author ddj 2026年10月08号
63
+ * @description Convert a form draft without losing argument boundaries or silently accepting malformed headers.
64
+ * @param draft Current editable fields.
65
+ * @returns Host MCP configuration.
66
+ * @throws Argument/header validation errors; callers must keep the form open.
67
+ */
68
+ export function configOf(draft: McpDraft): MpcConfig {
69
+ const config: MpcConfig = { serverName: draft.serverName.trim(), transport: draft.transport }
70
+ if (draft.transport === 'stdio') {
71
+ config.command = draft.command.trim()
72
+ config.args = parseArgs(draft.args)
73
+ if (draft.cwd.trim()) config.cwd = draft.cwd.trim()
74
+ } else {
75
+ config.url = draft.url.trim()
76
+ config.headers = parsePairs(draft.headers)
77
+ }
78
+ return config
79
+ }
@@ -0,0 +1,179 @@
1
+ import type { MpcProject, MpcServer } from '../shared/mcp.js'
2
+ import type { rpc } from './rpc.js'
3
+
4
+ export interface McpView {
5
+ servers: MpcServer[]
6
+ projects: MpcProject[]
7
+ loading: boolean
8
+ busy: string
9
+ error: string
10
+ }
11
+ interface McpTiming { interval?: number; timeout?: number }
12
+ interface ReadToken { controller: AbortController; version: number; timer: ReturnType<typeof setTimeout> }
13
+ type Result = { ok: boolean; error?: string }
14
+
15
+ /** Owns MCP read lifetimes and one synchronous mutation lock for the entire management page. */
16
+ export class McpState {
17
+ readonly view: McpView = { servers: [], projects: [], loading: true, busy: '', error: '' }
18
+ private active = false
19
+ private visible = true
20
+ private disposed = false
21
+ private version = 0
22
+ private reading: ReadToken | null = null
23
+ private timer: ReturnType<typeof setTimeout> | null = null
24
+ private readonly interval: number
25
+ private readonly timeout: number
26
+
27
+ /** @public @author ddj 2026年10月08号 @param request Typed RPC transport. @param changed View notification. @param timing Bounded read and polling timings. */
28
+ constructor(private readonly request: typeof rpc, private readonly changed: (view: McpView) => void, timing: McpTiming = {}) {
29
+ this.interval = Math.max(100, timing.interval ?? 2500)
30
+ this.timeout = Math.max(50, timing.timeout ?? 10000)
31
+ }
32
+
33
+ //region Read lifecycle
34
+ /** @private @author ddj 2026年10月08号 @description Publish a copied view only while mounted. */
35
+ private emit(): void {
36
+ if (!this.disposed) this.changed({ ...this.view })
37
+ }
38
+
39
+ /** @private @author ddj 2026年10月08号 @description Cancel scheduled reads and invalidate every older response. */
40
+ private invalidate(): void {
41
+ this.version++
42
+ if (this.timer !== null) clearTimeout(this.timer)
43
+ this.timer = null
44
+ this.reading?.controller.abort()
45
+ }
46
+
47
+ /** @private @author ddj 2026年10月08号 @description Schedule a single read only after the preceding transport settled. */
48
+ private schedule(): void {
49
+ if (this.disposed || !this.active || !this.visible || this.reading || this.view.loading || this.view.busy || this.timer !== null) return
50
+ this.timer = setTimeout(this.poll, this.interval)
51
+ }
52
+
53
+ /** @private @author ddj 2026年10月08号 @description Timer entry, with failures handled in read. */
54
+ private poll = (): void => {
55
+ this.timer = null
56
+ void this.read(false)
57
+ }
58
+
59
+ /** @private @author ddj 2026年10月08号 @returns Versioned read token with an abort deadline. */
60
+ private beginRead(): ReadToken {
61
+ const controller = new AbortController()
62
+ const version = this.version
63
+ /** @author ddj 2026年10月08号 @description Abort timed-out transport and invalidate its eventual response. */
64
+ const expire = (): void => {
65
+ controller.abort()
66
+ if (this.disposed || version !== this.version) return
67
+ this.version++
68
+ this.view.loading = false
69
+ this.view.error = '读取 MCP 状态超时'
70
+ this.emit()
71
+ }
72
+ const token = { controller, version, timer: setTimeout(expire, this.timeout) }
73
+ this.reading = token
74
+ return token
75
+ }
76
+
77
+ /** @private @author ddj 2026年10月08号 @param initial Initialization versus read-only polling. @param signal Abort deadline. @returns Complete settled data, never an early half-settled initial read. @throws Transport or host failures. */
78
+ private async fetchData(initial: boolean, signal: AbortSignal): Promise<Pick<McpView, 'servers' | 'projects'>> {
79
+ if (!initial) {
80
+ const snapshot = await this.request('mcp.snapshot', {}, signal)
81
+ if (!snapshot.ok) throw new Error(snapshot.error)
82
+ return snapshot
83
+ }
84
+ const [list, groups] = await Promise.allSettled([this.request('mcp.list', {}, signal), this.request('mcp.projects', {}, signal)])
85
+ if (list.status === 'rejected') throw list.reason
86
+ if (groups.status === 'rejected') throw groups.reason
87
+ if (!list.value.ok) throw new Error(list.value.error)
88
+ if (!groups.value.ok) throw new Error(groups.value.error)
89
+ return { servers: list.value.servers, projects: groups.value.projects }
90
+ }
91
+
92
+ /** @private @author ddj 2026年10月08号 @param initial Use legacy initialization RPCs once; later polls are read-only snapshots. @returns Completion after transport settles; aborted results never publish. */
93
+ private async read(initial: boolean): Promise<void> {
94
+ if (this.disposed || this.reading || this.view.busy) return
95
+ if (!initial && (!this.active || !this.visible)) return
96
+ const token = this.beginRead()
97
+ try {
98
+ const signal = token.controller.signal
99
+ const { servers, projects } = await this.fetchData(initial, signal)
100
+ if (!this.disposed && token.version === this.version && !signal.aborted) {
101
+ this.view.servers = servers
102
+ this.view.projects = projects
103
+ this.view.error = ''
104
+ }
105
+ } catch (error) {
106
+ if (!this.disposed && token.version === this.version && !token.controller.signal.aborted) this.view.error = String(error)
107
+ } finally {
108
+ clearTimeout(token.timer)
109
+ this.reading = null
110
+ if (!this.disposed) {
111
+ this.view.loading = false
112
+ this.emit()
113
+ this.schedule()
114
+ }
115
+ }
116
+ }
117
+
118
+ /** @public @author ddj 2026年10月08号 @description Initialize list/projects once per mounted page. @returns Load completion. */
119
+ load(): Promise<void> { return this.read(true) }
120
+
121
+ /** @public @author ddj 2026年10月08号 @param active Whether the top-level MCP tab is selected. */
122
+ setActive(active: boolean): void {
123
+ if (this.active === active) return
124
+ this.active = active
125
+ if (!active) this.invalidate()
126
+ this.schedule()
127
+ }
128
+
129
+ /** @public @author ddj 2026年10月08号 @param visible Whether document visibility permits background requests. */
130
+ setVisible(visible: boolean): void {
131
+ if (this.visible === visible) return
132
+ this.visible = visible
133
+ if (!visible) this.invalidate()
134
+ this.schedule()
135
+ }
136
+
137
+ /** @public @author ddj 2026年10月08号 @description Stop timers and invalidate all responses on unmount. */
138
+ dispose(): void {
139
+ this.disposed = true
140
+ this.invalidate()
141
+ if (this.reading) clearTimeout(this.reading.timer)
142
+ }
143
+ //endregion
144
+
145
+ //region Serialized mutations
146
+ /**
147
+ * @public
148
+ * @author ddj 2026年10月08号
149
+ * @description Acquire a synchronous page-wide lock before validation/RPC. Duplicate actions are rejected; failed saves never call commit.
150
+ * @param label Busy indicator key.
151
+ * @param task Validation and RPC work, including synchronous configuration errors.
152
+ * @param commit Apply successful result; may reset and close a save form.
153
+ * @returns Whether this mutation committed successfully while mounted.
154
+ */
155
+ async mutate<T extends Result>(label: string, task: () => Promise<T>, commit: (result: T) => void): Promise<boolean> {
156
+ if (this.disposed || this.view.busy) return false
157
+ this.invalidate()
158
+ this.view.busy = label
159
+ this.view.error = ''
160
+ this.emit()
161
+ try {
162
+ const result = await task()
163
+ if (!result.ok) throw new Error(result.error)
164
+ if (this.disposed) return false
165
+ commit(result)
166
+ return true
167
+ } catch (error) {
168
+ if (!this.disposed) this.view.error = String(error)
169
+ return false
170
+ } finally {
171
+ if (!this.disposed) {
172
+ this.view.busy = ''
173
+ this.emit()
174
+ this.schedule()
175
+ }
176
+ }
177
+ }
178
+ //endregion
179
+ }