dsh-vscode-mode 0.17.0 → 0.18.0

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.17.0",
3
+ "version": "0.18.0",
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": [
@@ -14,8 +14,8 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
14
14
  |---|---|---|
15
15
  | 持久配置 | profile loader 树 | 已注册 workspace 根目录的 `.mcp.json` |
16
16
  | 生效范围 | profile 内所有工作区 | cwd 匹配该 workspace 的 agent |
17
- | 运行方式 | 原有全局 loader entry | `agent.ctx.plugin(officialMcp, config)` |
18
- | 连接实例 | 每个启用的全局 entry 一份 | 每个活动 agent、每个启用服务一份 |
17
+ | 运行方式 | 原有全局 loader entry | 私有连接 scope + 会话授权代理,复用官方 MCP 插件 |
18
+ | 连接实例 | 每个启用的全局 entry 一份 | 默认逐 agent 隔离;显式选择 workspace 后工作区复用 |
19
19
  | 没有活动 agent | 仍可运行 | 仅保存配置,`configured`、`instanceCount: 0` |
20
20
  | 团队共享 | 否 | `.mcp.json` 可以随仓库共享 |
21
21
 
@@ -25,7 +25,9 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
25
25
 
26
26
  设置 → VSCodeMode → MCP 管理,包含「我的 MCP」「项目 MCP」「MCP 市场」(占位)。项目服务在所选项目内管理。
27
27
 
28
- 保存项目服务会写入 `.mcp.json` 并更新匹配的活动 agent;没有活动 agent 时保存为待挂载配置。启用不会凭空创建会话。每个新 agent 的串行 `agent/created` 监听会读取最新配置并等待其官方插件启动;插件装配时也会接入已经存在的 agent。
28
+ 保存项目服务会写入 `.mcp.json` 并更新匹配 agent 的能力目录与惰性资源入口。新建会话只等待配置准备和轻量入口,不等待任何项目 MCP 握手;默认 `on-demand`,未使用不启动。显式 `background` 后台预热也不阻塞创建。同一 Host 最多两个冷启动;同代次初始化 single-flight。配置写操作仍串行保护,但连接准备不占有跨会话全局锁。
29
+
30
+ AI 先调用全局发现入口 `discover_project_mcp {action:"list"}`,仅返回执行 agent 工作区内的安全目录;需要时 `{action:"connect",serverName:"..."}` 连接并返回真实 schema。入口从工具执行上下文取 agent,不接受 sessionId、路径、命令、URL 或凭据。真实工具依然走官方审批、guard 和结果投影;Native 在下一次请求获得工具,PTC 必须结束当前程序后新建程序刷新 SDK,禁止 eval 或通用透传绕过权限。资源工具可通过所属工作区的惰性 provider 按需连接。
29
31
 
30
32
  禁用、删除、配置变更、刷新重连及 agent/插件卸载都会释放相应 fiber 和连接。仅刷新未变化的项目列表不会重复连接。主机代码更新需要重启 DSH 才能加载新实现。
31
33
 
@@ -33,6 +35,14 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
33
35
 
34
36
  表单 stdio 参数采用 JSON `string[]` 或每行一个完整参数,不按空格/引号做 shell 拆分;含空参数或内嵌换行使用 JSON。HTTP 请求头每行 `key=value`,保留值内的等号,拒绝非法/重复(忽略大小写)键。失败保存保持表单,忙时禁止重复动作。
35
37
 
38
+ ### 会话列表状态
39
+
40
+ 官方 `sidebar.session.row.leading` 插槽在会话标题前显示 16px 加载或异常图标;平稳完成不常驻绿色成功标。官方运行中、待审批、新消息状态优先覆盖 leading,归档行不展示 leading;`sidebar.session.row.hover` 悬浮详情仍可查看。
41
+
42
+ 详情只描述 VSCodeMode 管理的**项目 MCP**:待使用(不转圈)、读取配置、等待启动名额、加载插件模块、等待服务插件启动、复用已有连接、释放旧实例、完成或异常,展示完成数、耗时与安全错误摘要。共享显示会话本次等待,不将连接年龄冒充本次等待。超过 10 秒仅提示耗时较长,不判定失败。完成文案为「初始化完成,连接健康未验证」。无活跃 agent 文案为「当前无活跃 MCP 实例」,不是成功;不包含全局或 preset MCP。
43
+
44
+ 所有可见行共用一份 `mcp.sessionSnapshot` 订阅,在文档可见且有消费者时按 2.5 秒间隔查询;请求 10 秒截止、不重叠,隐藏/退订/卸载取消,过期响应丢弃。错误保留上次快照并标过期。查询只读内存,**不读配置、不 reconcile、不激活/保留历史会话、不建立连接**;旧 Host 或缺少插槽时安全降级。错误详情不返回 env、headers、完整配置或凭据 URL,仅返回安全类别提示,原始诊断看 Host 日志。
45
+
36
46
  ## 3. 项目配置
37
47
 
38
48
  `.mcp.json` 示例:
@@ -58,6 +68,8 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
58
68
  - stdio:`command` 必填;可填 `args`、`cwd`、`env`。未指定 `cwd` 时,以项目根作为进程工作目录。
59
69
  - HTTP:`url` 必须以 `http://` 或 `https://` 开头,可填 `headers`。
60
70
  - 高级参数:`toolCallTimeoutMs`、`failOnStartupError`、`maxInstructionBytes`、`reconnect`。
71
+ - 项目扩展 `vscodeMode: { startup: "on-demand" | "background", sharing: "agent" | "workspace", startupTimeoutMs: 60000 }` 与官方传输配置分离,不透传;默认 on-demand + agent,旧文件不批量改写。初始化预算接受 1000–300000 整数,不是业务调用超时。全局配置不接受项目扩展。
72
+ - workspace 共享必须显式确认服务适合共享内部状态;不会自动给 CodeGraph 开启。池键含工作区、有效配置和策略,凭据只做内存摘要身份。共享调用默认串行,最后租约释放保温 60 秒;agent 模式立即释放。禁用/删除/刷新/配置变化先失效与撤销再关闭,关闭未确认则阻止替代。超时显示释放阶段,失败冷却 5 秒后允许显式重试,不无限自动重试。
61
73
  - `disabled: true` 保持配置但不创建连接;保存表单会恢复启用。重启和 reconcile 都尊重 `disabled`。
62
74
  - 保存保留文档顶层、其他服务器及当前服务器的未知字段和未编辑高级参数;切换传输方式会移除旧传输专属字段。
63
75
  - `env` / `headers` 在返回值中使用 `••••••` 脱敏。原键的未修改掩码保存时恢复真实值;新键只有掩码而没有原值时会拒绝保存,需填写凭据。
@@ -85,12 +97,14 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
85
97
  |---|---|---|
86
98
  | `mcp.list` | `{}` | `servers` |
87
99
  | `mcp.snapshot` | `{}` | `servers`, `projects`(只读运行快照) |
100
+ | `mcp.sessionSnapshot` | `{}` | `sessions`, `sampledAt`(按 sessionId 区分的活跃初始化状态,只读内存) |
88
101
  | `mcp.save` | `{ config }` | `server` |
89
102
  | `mcp.remove` | `{ id }` | 无 |
90
103
  | `mcp.toggle` | `{ id, enabled }` | `server` |
91
104
  | `mcp.refresh` | `{ id }` | `server` |
92
105
  | `mcp.projects` | `{}` | `projects` |
93
- | `mcp.projectSave` | `{ workspacePath, serverName, config }` | `project` |
106
+ | `mcp.projectSave` | `{ workspacePath, serverName, config, policy? }` | `project` |
107
+ | `mcp.projectConnect` | `{ sessionId, workspacePath, serverName }` | `project`(仅已活跃匹配会话,不激活历史会话) |
94
108
  | `mcp.projectRemove` | `{ workspacePath, serverName }` | `project` |
95
109
  | `mcp.projectToggle` | `{ workspacePath, serverName, enabled }` | `project` |
96
110
  | `mcp.projectRefresh` | `{ workspacePath, serverName }` | `project` |
@@ -109,7 +123,7 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
109
123
 
110
124
  ## 6. 工具、资源和指令隔离
111
125
 
112
- 官方 0.1.7-rc.2 的 MCP Config 没有关闭资源/指令的开关;官方注册随 `scopeOf(ctx)` 归属。因此项目连接必须直接在 `agent.ctx` 挂载,不能全局连接后只隐藏工具。
126
+ 官方 0.1.7-rc.2 的 MCP Config 没有关闭资源/指令的开关;官方注册随 `scopeOf(ctx)` 归属。因此连接在独立 scope 中隔离 tools、mcpResources、systemPrompt,捕获注册后再给合法 agent 提供代理,绝不将原始项目注册暴露全局。创建私有 scope 后先等待 backing fiber 初始激活完成再 provide,防止依赖服务丢失;真实官方模块测试覆盖此边界。
113
127
 
114
128
  子 agent 会继承父 scope;跨 cwd 子会话还有三层处理:
115
129
 
@@ -126,7 +140,7 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
126
140
 
127
141
  | 状态/现象 | 含义与处理 |
128
142
  |---|---|
129
- | `configured` | 中性色;配置已保存,没有已挂载实例,打开匹配工作区会话后挂载 |
143
+ | `configured` | 中性色;配置已保存,尚无连接实例,首次发现/资源访问或显式手动连接才启动(background 策略除外) |
130
144
  | `disabled` | 配置已停用,不建立项目连接 |
131
145
  | `connecting` | 官方插件还在等待启动完成;检查命令、cwd、URL 和握手 |
132
146
  | `unverified` | 中性色;fiber 已激活,但官方没有公开连接状态证据,不能宣称已连接;右侧绿色开关只表示启用 |
@@ -140,7 +154,10 @@ whenToUse: 用户要求新增、修改、启停或排查 VSCodeMode MCP 配置
140
154
 
141
155
  ## 8. 维护位置与验证
142
156
 
143
- - `src/mcpRuntime.ts`:agent → fibers 台账、官方插件加载、串行创建、迁移与卸载。
157
+ - `src/mcpRuntime.ts`:会话租约/能力台账、官方模块加载、非阻塞接入、迁移与卸载。
158
+ - `src/mcpPool.ts`:独立连接、single-flight、并发名额、取消/超时/保温和代次围栏。
159
+ - `src/mcpBridge.ts`:私有服务隔离、真实工具代理、动态列表和撤销。
160
+ - `src/mcpDiscover.ts`:统一发现入口与按 scope 输出的能力目录。
144
161
  - `src/mcpProject.ts`:源配置读取、保留字段、命名检查及 reconcile。
145
162
  - `src/mcpIsolation.ts`:继承工具限制、资源执行 guard、指令和资源列表覆盖。
146
163
  - `src/mcp.ts`:全局 loader CRUD、脱敏与诚实状态。
@@ -20,6 +20,8 @@ import { DiffBadge } from './ui/DiffBadge.js'
20
20
  import { PerfSizeBadge } from './ui/PerfSizeBadge.js'
21
21
  import { ConversationDiffDock } from './ui/ConversationDiffDock.js'
22
22
  import { McpSettings } from './ui/McpSettings.js'
23
+ import { mcpSessionState } from './mcpSessionState.js'
24
+ import { McpSessionIcon, McpSessionDetail, type McpRowProps } from './ui/McpSessionStatus.js'
23
25
  import { PluginDetailSection } from './ui/PluginDetailSection.js'
24
26
  import { rpc } from './rpc.js'
25
27
  import { loadMonaco } from './monaco/loader.js'
@@ -634,6 +636,18 @@ export async function apply(ctx: any): Promise<void> {
634
636
  }
635
637
  retrySideService()
636
638
 
639
+ // Root row slots receive only sessionId: no sessions.scope()/activation for historical rows.
640
+ ctx.effect(() => {
641
+ const store = mcpSessionState()
642
+ /** @author ddj 2026年10月20号 @param props Official root row identity. @returns Leading marker with shared polling. */
643
+ const renderIcon = (props: unknown) => React.createElement(McpSessionIcon, { ...(props as McpRowProps), store })
644
+ /** @author ddj 2026年10月20号 @param props Official hover identity. @returns Project-only initialization details. */
645
+ const renderDetail = (props: unknown) => React.createElement(McpSessionDetail, { ...(props as McpRowProps), store })
646
+ const leading = registerSlotSafely(ctx, { name: 'sidebar.session.row.leading', id: 'edrv-mcp-status', order: 20 }, renderIcon)
647
+ const hover = registerSlotSafely(ctx, { name: 'sidebar.session.row.hover', id: 'edrv-mcp-detail', order: 20 }, renderDetail)
648
+ return () => { leading?.(); hover?.(); store.dispose() }
649
+ }, 'vscode-mode: session MCP status')
650
+
637
651
  // 对话输入框上方差异 dock:普通对话显示单文案按钮,文件编辑页由 EditorView 隐藏
638
652
  registerSlotSafely(ctx, {
639
653
  name: 'conversation.input.dock',
@@ -659,6 +673,7 @@ export async function apply(ctx: any): Promise<void> {
659
673
  label: () => t('slot.settings'),
660
674
  }, () => React.createElement(SettingsContext.Provider, { value: bridge.scope() }, React.createElement(McpSettings, {
661
675
  openerRegistry: registry,
676
+ getMcpSession: () => readSessionScope(ctx).sessionId,
662
677
  compatSummary,
663
678
  // 快捷键统一走 DSH 官方配置:通用页只提供「打开官方快捷键配置」入口。
664
679
  // 服务运行时探测(自愈):旧版 DSH 无该服务时按钮给出手动打开方式。
@@ -0,0 +1,108 @@
1
+ /** Shared, read-only sidebar MCP polling. @author ddj 2026年10月20号 */
2
+ import { rpc } from './rpc.js'
3
+ import type { McpSessionStatus } from '../shared/mcp.js'
4
+
5
+ export interface SessionMcpView { status?: McpSessionStatus; stale: boolean }
6
+ type Snapshot = { sessions: McpSessionStatus[]; sampledAt: number }
7
+ type FetchStatus = (signal: AbortSignal) => Promise<Snapshot>
8
+ const unavailable: SessionMcpView = { stale: true }
9
+ const inactive: SessionMcpView = { stale: false }
10
+
11
+ /** One poller for all mounted row/hover consumers; never retains or activates sessions. */
12
+ export class McpSessionState {
13
+ private views = new Map<string, SessionMcpView>()
14
+ private listeners = new Map<string, Set<() => void>>()
15
+ private timer?: ReturnType<typeof setTimeout>
16
+ private request?: AbortController
17
+ private epoch = 0
18
+ private stale = true
19
+ private disposed = false
20
+
21
+ /** @public @author ddj 2026年10月20号 @param fetchStatus Authenticated read-only query. @param page Visibility boundary, omitted for server tests. */
22
+ constructor(private readonly fetchStatus: FetchStatus, private readonly page?: Document) {}
23
+
24
+ /** @public @author ddj 2026年10月20号 @param sessionId Row identity. @returns Stable view until the next observation. */
25
+ get(sessionId: string): SessionMcpView { return this.views.get(sessionId) ?? (this.stale ? unavailable : inactive) }
26
+
27
+ /** @public @author ddj 2026年10月20号 @param sessionId Row identity. @param listener React invalidation. @returns Idempotent unsubscribe; the last consumer stops polling. */
28
+ subscribe(sessionId: string, listener: () => void): () => void {
29
+ if (this.disposed) return () => undefined
30
+ const first = this.listeners.size === 0
31
+ const listeners = this.listeners.get(sessionId) ?? new Set()
32
+ listeners.add(listener)
33
+ this.listeners.set(sessionId, listeners)
34
+ if (first) {
35
+ this.page?.addEventListener('visibilitychange', this.visibility)
36
+ void this.poll()
37
+ }
38
+ return () => {
39
+ listeners.delete(listener)
40
+ if (listeners.size === 0) this.listeners.delete(sessionId)
41
+ if (this.listeners.size === 0) this.pause()
42
+ }
43
+ }
44
+
45
+ /** @private @author ddj 2026年10月20号 Stops pending work when hidden; resuming never accepts an old response. */
46
+ private visibility = (): void => {
47
+ if (this.page?.hidden) this.invalidate()
48
+ else void this.poll()
49
+ }
50
+
51
+ /** @private @author ddj 2026年10月20号 Invalidates responses without allowing overlapping transport requests. */
52
+ private invalidate(): void {
53
+ this.epoch += 1
54
+ clearTimeout(this.timer)
55
+ this.timer = undefined
56
+ this.request?.abort()
57
+ this.publish(undefined)
58
+ }
59
+
60
+ /** @private @author ddj 2026年10月20号 Cleans listener lifetime as well as timers. */
61
+ private pause(): void {
62
+ this.page?.removeEventListener('visibilitychange', this.visibility)
63
+ this.invalidate()
64
+ }
65
+
66
+ /** @public @author ddj 2026年10月20号 Permanently disposes HMR-owned polling. */
67
+ dispose(): void { this.disposed = true; this.pause(); this.listeners.clear(); this.views.clear() }
68
+
69
+ /** @private @author ddj 2026年10月20号 @param snapshot Fresh ledger, or undefined to mark all previous observations stale. */
70
+ private publish(snapshot?: Snapshot): void {
71
+ this.stale = snapshot === undefined
72
+ if (snapshot) this.views = new Map(snapshot.sessions.map((status) => [status.sessionId, { status, stale: false }]))
73
+ else for (const [id, view] of this.views) this.views.set(id, { ...view, stale: true })
74
+ for (const listeners of this.listeners.values()) for (const listener of listeners) listener()
75
+ }
76
+
77
+ /** @private @author ddj 2026年10月20号 Queries once, with a 10-second deadline and a 2.5-second gap after settlement. */
78
+ private async poll(): Promise<void> {
79
+ if (this.disposed || this.request || !this.listeners.size || this.page?.hidden) return
80
+ const request = new AbortController()
81
+ const epoch = this.epoch
82
+ this.request = request
83
+ /** @author ddj 2026年10月20号 Marks overdue responses unusable even if a transport ignores cancellation. */
84
+ const expire = (): void => { request.abort(); if (epoch === this.epoch) this.publish(undefined) }
85
+ const deadline = setTimeout(expire, 10000)
86
+ try {
87
+ const snapshot = await this.fetchStatus(request.signal)
88
+ if (epoch === this.epoch && !request.signal.aborted) this.publish(snapshot)
89
+ } catch {
90
+ if (epoch === this.epoch) this.publish(undefined)
91
+ } finally {
92
+ clearTimeout(deadline)
93
+ this.request = undefined
94
+ if (!this.disposed && this.listeners.size && !this.page?.hidden) this.timer = setTimeout(() => { void this.poll() }, 2500)
95
+ }
96
+ }
97
+ }
98
+
99
+ /** @public @author ddj 2026年10月20号 @returns Browser-owned singleton per plugin apply; legacy Hosts fail safely as stale. */
100
+ export function mcpSessionState(): McpSessionState {
101
+ /** @author ddj 2026年10月20号 @param signal Fetch lifetime. @returns Current in-memory host ledger. @throws RPC errors. */
102
+ const fetchStatus = async (signal: AbortSignal): Promise<Snapshot> => {
103
+ const result = await rpc('mcp.sessionSnapshot', {}, signal)
104
+ if (!result.ok) throw new Error('MCP 状态暂不可用')
105
+ return result
106
+ }
107
+ return new McpSessionState(fetchStatus, document)
108
+ }
@@ -5,7 +5,8 @@
5
5
  * 交互层:edrv.goToDefinition(F12)/ edrv.findReferences(Shift+F12 / 右键)均走 Monaco 原生命令;
6
6
  * Ctrl+点击使用处交给原生;Lua 表枚举字段声明处改为打开引用 Peek,
7
7
  * 数据经 host 的 LSP RPC 提供(registerEditorOpener 接管 edrv:// 点击跳转)。
8
- * 作者 ddj 2026-08-27 / 2026-09-11
8
+ * 定义结果返回前预热目标 model,避免 hover 定义链接预览抛 "Model not found"。
9
+ * 作者 ddj 2026-08-27 / 2026-09-11 / 2026-10-20
9
10
  */
10
11
  import {
11
12
  pathOfModel, syncDoc, closeDoc, findDefinition, findReferences,
@@ -19,6 +20,7 @@ import {
19
20
  LSP_COMPLETION_KIND_NAMES, LSP_INSERT_TEXT_FORMAT_SNIPPET,
20
21
  } from '../../../shared/lsp.js'
21
22
  import { luaKeywordsAt, luaTypesAt, mergeKeywords } from './keywords.js'
23
+ import { warmModelFor } from '../../state/modelWarmer.js'
22
24
 
23
25
  const LSP_LANGS = ['lua', 'csharp']
24
26
 
@@ -79,6 +81,31 @@ export function navTargetOf(selectionOrPosition) {
79
81
  return endColumn === undefined ? { line, column, endLine } : { line, column, endLine, endColumn }
80
82
  }
81
83
 
84
+ /**
85
+ * 预热定义目标文件的 Monaco model(供 Ctrl+hover 定义链接预览使用)。
86
+ *
87
+ * 为什么需要:Monaco 的 GotoDefinitionAtPositionEditorContribution 在渲染定义链接预览时,
88
+ * 对目标 uri 调 `createModelReference` 后**只挂 then、没有 catch**;本插件的定义目标是
89
+ * 自造的 `edrv://` uri,目标文件若从未打开过就没有已注册 model,`createModelReference`
90
+ * 会 reject("Model not found") 变成未捕获的 promise 异常(弹到 Console)。
91
+ * 本 vendored Monaco 也没有 `registerTextModelContentProvider` 可惰性喂内容,故只能提前建模型。
92
+ *
93
+ * 只预热单一定义:多定义分支(`locations.length > 1`)Monaco 走列表、不调
94
+ * `createModelReference`,没必要读盘。预热纯属增强,任何异常都在此吞掉,
95
+ * 不得让跳转查询失败(F12 与 Ctrl+点击不依赖模型是否已建)。
96
+ * @author ddj 2026年10月20号
97
+ * @param locations findDefinition 的客户端结果(含 edrv uri)
98
+ */
99
+ async function prewarmDefinitionTarget(locations) {
100
+ if (!Array.isArray(locations) || locations.length !== 1) return
101
+ try {
102
+ const target = lspUriToAbs(locations[0]?.uri)
103
+ if (target) await warmModelFor(target)
104
+ } catch (error) {
105
+ /* 预热失败静默降级:等价于本次 hover 不显示预览 */
106
+ }
107
+ }
108
+
82
109
  /** 注册全部 Monaco LSP provider 与文档跟踪(幂等)。 */
83
110
  export function registerLspProviders(monaco) {
84
111
  if (registered) return
@@ -151,10 +178,12 @@ export function registerLspProviders(monaco) {
151
178
  },
152
179
  }),
153
180
  monaco.languages.registerDefinitionProvider(LSP_LANGS, {
154
- provideDefinition: (model, position, token) => {
181
+ provideDefinition: async (model, position, token) => {
155
182
  const path = pathOfModel(model)
156
183
  if (!path) return []
157
- return findDefinition(path, model.getValue(), position)
184
+ const locations = await findDefinition(path, model.getValue(), position)
185
+ await prewarmDefinitionTarget(locations)
186
+ return locations
158
187
  },
159
188
  }),
160
189
  monaco.languages.registerReferenceProvider(LSP_LANGS, {
@@ -0,0 +1,198 @@
1
+ /**
2
+ * dsh-vscode-mode client — Monaco model 预热调度层。
3
+ *
4
+ * 背景:Monaco 的 GotoDefinitionAtPositionEditorContribution 在 Ctrl+hover 渲染定义链接
5
+ * 预览时,对目标的 edrv:// uri 调 textModelResolverService.createModelReference 后
6
+ * **只挂 then、没有 catch**;目标文件若从未在本会话打开过,该 uri 没有已注册 model,
7
+ * createModelReference 直接 reject("Model not found"),成为未捕获的 promise 异常。
8
+ * 本构建的 vendored Monaco 也没有 registerTextModelContentProvider 可供惰性喂内容,
9
+ * 因此修法是在 provideDefinition 返回前把目标 model 建出来(预热)。
10
+ *
11
+ * 本模块只负责「让某路径的 model 存在」这件事的调度:安装/卸载实现、并发单飞、
12
+ * 失败冷却与等待上限。真正建 model 的实现由 EditorView 注入(它才持有 monaco 与文档缓存)。
13
+ * 纯逻辑,不依赖 monaco,可单测。
14
+ * 作者 ddj 2026年10月20号
15
+ */
16
+
17
+ // --region 常量与状态
18
+
19
+ /** 单次预热调用的等待上限:超过即放行 hover(预热仍在后台继续)。 */
20
+ export const WARM_TIMEOUT_MS = 400
21
+
22
+ /** 预热失败后该路径的冷却窗口,避免每次 hover 都重试同一失败目标。 */
23
+ export const WARM_COOLDOWN_MS = 10_000
24
+
25
+ /** 冷却表条目上限(超出按插入序丢最旧),防长会话里无限增长。 */
26
+ export const WARM_COOLDOWN_CAP = 64
27
+
28
+ /** 预热实现:给定工作区相对路径,成功让 model 存在则返回 true。 */
29
+ export type WarmImpl = (path: string) => Promise<boolean>
30
+
31
+ /** 当前安装的预热实现(EditorView 挂载时注入,未挂载则为 null)。 */
32
+ let impl: WarmImpl | null = null
33
+ /** path → 进行中的预热(单飞:连续 hover 同一目标只读一次盘)。 */
34
+ const inFlight = new Map<string, Promise<boolean>>()
35
+ /** path → 冷却截止时间戳(毫秒)。 */
36
+ const cooled = new Map<string, number>()
37
+ // --endregion
38
+
39
+ // --region 状态维护
40
+
41
+ /**
42
+ * 清空进行中/冷却状态(安装或卸载实现时复位,避免跨实现串味)。
43
+ * @author ddj 2026年10月20号
44
+ */
45
+ function resetWarmerState(): void {
46
+ inFlight.clear()
47
+ cooled.clear()
48
+ }
49
+
50
+ /**
51
+ * 该路径是否处于失败冷却窗口内(过期的顺手清掉)。
52
+ * @author ddj 2026年10月20号
53
+ * @param path 工作区相对路径
54
+ * @returns 仍在冷却中返回 true
55
+ */
56
+ function isCooling(path: string): boolean {
57
+ const until = cooled.get(path)
58
+ if (until === undefined) return false
59
+ if (until > Date.now()) return true
60
+
61
+ cooled.delete(path)
62
+ return false
63
+ }
64
+
65
+ /**
66
+ * 记录一次失败冷却,并把冷却表裁剪到上限以内。
67
+ * @author ddj 2026年10月20号
68
+ * @param path 工作区相对路径
69
+ */
70
+ function coolDown(path: string): void {
71
+ cooled.delete(path)
72
+ cooled.set(path, Date.now() + WARM_COOLDOWN_MS)
73
+
74
+ while (cooled.size > WARM_COOLDOWN_CAP) {
75
+ const oldest = cooled.keys().next()
76
+ if (oldest.done) break
77
+
78
+ cooled.delete(oldest.value)
79
+ }
80
+ }
81
+
82
+ /**
83
+ * 结算一次预热:清 inFlight(仅当仍是自己那条)、按结果维护冷却。
84
+ * @author ddj 2026年10月20号
85
+ * @param path 工作区相对路径
86
+ * @param run 该次预热的 promise(用于识别是否已被更新的一次替换)
87
+ * @param ok 实现返回的成功与否
88
+ */
89
+ function settleWarm(path: string, run: Promise<boolean>, ok: boolean): void {
90
+ if (inFlight.get(path) === run) inFlight.delete(path)
91
+ if (ok) {
92
+ cooled.delete(path)
93
+ return
94
+ }
95
+
96
+ coolDown(path)
97
+ }
98
+ // --endregion
99
+
100
+ // --region 底层预热
101
+
102
+ /**
103
+ * 启动一次底层预热:登记 inFlight、把实现异常折叠成 false(绝不向调用方抛)。
104
+ * @author ddj 2026年10月20号
105
+ * @param path 工作区相对路径
106
+ * @param fn 预热实现
107
+ * @returns 永不 reject 的布尔 promise(已登记到 inFlight)
108
+ */
109
+ function startWarm(path: string, fn: WarmImpl): Promise<boolean> {
110
+ const run = Promise.resolve()
111
+ .then(() => fn(path))
112
+ .then(
113
+ (ok) => ok === true,
114
+ () => false,
115
+ )
116
+
117
+ inFlight.set(path, run)
118
+ run.then((ok) => settleWarm(path, run, ok))
119
+ return run
120
+ }
121
+
122
+ /**
123
+ * 给底层预热挂一个等待上限:超时只是本次调用放行,底层继续跑(仍可能建成 model
124
+ * 惠及下一次 hover),因此超时既不取消 promise,也不提前摘掉 inFlight。
125
+ * @author ddj 2026年10月20号
126
+ * @param path 工作区相对路径
127
+ * @param run 底层预热 promise(永不 reject)
128
+ * @returns 最多 WARM_TIMEOUT_MS 内结算的布尔结果
129
+ */
130
+ function withTimeout(path: string, run: Promise<boolean>): Promise<boolean> {
131
+ return new Promise<boolean>((resolve) => {
132
+ let settled = false
133
+ let timer: ReturnType<typeof setTimeout> | null = null
134
+
135
+ const finish = (ok: boolean): void => {
136
+ if (settled) return
137
+
138
+ settled = true
139
+ if (timer) clearTimeout(timer)
140
+ resolve(ok)
141
+ }
142
+
143
+ timer = setTimeout(() => {
144
+ coolDown(path)
145
+ finish(false)
146
+ }, WARM_TIMEOUT_MS)
147
+
148
+ run.then(finish, () => finish(false))
149
+ })
150
+ }
151
+ // --endregion
152
+
153
+ // --region 安装与卸载
154
+
155
+ /**
156
+ * 安装预热实现(EditorView 挂载时调用;传 null 视同卸载)。
157
+ * @author ddj 2026年10月20号
158
+ * @param fn 预热实现,或 null 表示卸载
159
+ */
160
+ export function setModelWarmer(fn: WarmImpl | null): void {
161
+ impl = typeof fn === 'function' ? fn : null
162
+ if (!impl) resetWarmerState()
163
+ }
164
+
165
+ /**
166
+ * 卸载预热实现:仅当传入的仍是当前实现才清(防卸载竞态误清新挂载的实现)。
167
+ * @author ddj 2026年10月20号
168
+ * @param fn 当初安装的实现
169
+ */
170
+ export function clearModelWarmer(fn: WarmImpl): void {
171
+ if (impl !== fn) return
172
+
173
+ impl = null
174
+ resetWarmerState()
175
+ }
176
+ // --endregion
177
+
178
+ // --region 预热调度
179
+
180
+ /**
181
+ * 确保目标路径的 Monaco model 存在(供 hover 定义预览的 createModelReference 命中)。
182
+ *
183
+ * 降级语义:未安装实现(编辑器页签未挂载)、路径为空、处于失败冷却、实现失败或超时,
184
+ * 一律返回 false 且不抛异常——此时行为等价于修复前(预览不显示,但不再产生未捕获异常)。
185
+ * @author ddj 2026年10月20号
186
+ * @param path 工作区相对路径(或绝对路径,由实现决定如何解析)
187
+ * @returns 预热成功返回 true;被跳过/失败/超时返回 false
188
+ */
189
+ export function warmModelFor(path: string): Promise<boolean> {
190
+ if (!path || !impl) return Promise.resolve(false)
191
+
192
+ const running = inFlight.get(path)
193
+ if (running) return withTimeout(path, running)
194
+ if (isCooling(path)) return Promise.resolve(false)
195
+
196
+ return withTimeout(path, startWarm(path, impl))
197
+ }
198
+ // --endregion
@@ -16,6 +16,11 @@
16
16
  /* 固定页签:📌 标记 + 免关闭按钮(固定页签不渲染 ×,仅可经右键菜单取消固定) */
17
17
  [data-edrv-view] .edrv-tab .edrv-tab-pin { flex-shrink: 0; font-size: 10px; line-height: 1; opacity: .85; }
18
18
  [data-edrv-view] .edrv-tab.edrv-tab-pinned { background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,.04)); }
19
+ /* 页签行 = 可横滚的页签区 + 行尾固定工具列(计划预览页签与磁盘页签同处这一行的滚动区)@author ddj 2026年10月20号 */
20
+ [data-edrv-view] .edrv-tabs-row { display: flex; align-items: center; min-width: 0; flex: 0 0 auto; background: var(--dsw-alias-bg-layer-1, transparent); }
21
+ /* 特异性需盖过下面 .edrv-group .edrv-tabs 的 flex: 0 0 auto,页签区才能吃掉行走宽、把工具列压到行尾 */
22
+ [data-edrv-view] .edrv-group .edrv-tabs-row > .edrv-tabs { flex: 1 1 auto; min-width: 0; background: transparent; }
23
+ [data-edrv-view] .edrv-tabs-tools { display: flex; align-items: center; gap: 2px; flex: 0 0 auto; padding: 0 6px 0 2px; }
19
24
  [data-edrv-view] .edrv-tab-add { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; margin-left: 2px; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary, #aaa); font-size: 14px; cursor: pointer; flex-shrink: 0; }
20
25
  [data-edrv-view] .edrv-tab-add:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,.06)); }
21
26
  [data-edrv-view] .edrv-pathbar { display: flex; align-items: center; gap: 8px; height: 26px; padding: 0 10px; background: var(--dsw-alias-bg-layer-1, #ffffff); border-bottom: 1px solid var(--dsw-alias-border-l1, #e0e6e8); font-size: 12px; flex-shrink: 0; overflow: hidden; }
@@ -988,7 +993,6 @@
988
993
  [data-edrv-view] .edrv-group { border: 0; }
989
994
  [data-edrv-view] .edrv-group .edrv-tabs { flex: 0 0 auto; touch-action: none; }
990
995
  [data-edrv-view] .edrv-group-tab-name { overflow: hidden; text-overflow: ellipsis; max-width: 180px; }
991
- [data-edrv-view] .edrv-workbench-tools { flex: 0 0 auto; }
992
996
  [data-edrv-view] .edrv-split { display: flex; flex: 1 1 auto; min-width: 0; min-height: 0; overflow: hidden; }
993
997
  [data-edrv-view] .edrv-split-vertical { flex-direction: column; }
994
998
  [data-edrv-view] .edrv-split-child { display: flex; min-width: 0; min-height: 0; overflow: hidden; }
@@ -14,14 +14,24 @@ export interface PaneProps {
14
14
  onDrag(path: string, event: React.PointerEvent): void
15
15
  consumeClick(): boolean
16
16
  children?: React.ReactNode
17
+ /** @public @author ddj 2026年10月20号 Virtual preview tabs of this group's own strip: plans and files share one row. */
17
18
  extras?: React.ReactNode
19
+ /** @public @author ddj 2026年10月20号 Row controls pinned after the strip of the same row (never inside the scroller). */
20
+ tools?: React.ReactNode
21
+ /** @public @author ddj 2026年10月20号 Strip ref owned by the workbench (scroll-into-view and horizontal wheel). */
22
+ tabsRef?: React.Ref<HTMLDivElement>
23
+ /** @public @author ddj 2026年10月20号 Read-only plan preview owns this pane body, so no disk tab paints as active. */
24
+ suppressActive?: boolean
18
25
  }
19
26
 
20
27
  /** @public @author ddj 2026年10月14号 @param props Group callbacks and file view. @returns Accessible tab strip and drop target. */
21
28
  export function EditorPane(props: PaneProps): React.ReactElement {
22
29
  const { group } = props
23
- /** @private @author ddj 2026年10月14号 @param tab File record. @returns Draggable tab with isolated close control. */
30
+ // 计划预览接管本组正文时,磁盘页签只保留可点击性,不再画选中态,避免同一可见区域出现两条下划线。
31
+ const suppressActive = props.suppressActive === true
32
+ /** @private @author ddj 2026年10月20号 @param tab File record. @returns Draggable tab with isolated close control. */
24
33
  function renderTab(tab: TabLike): React.ReactElement {
34
+ const selected = !suppressActive && tab.path === group.active
25
35
  /** @private @author ddj 2026年10月14号 @param event Primary press. Start trusted file drag. */
26
36
  function press(event: React.PointerEvent): void { props.onDrag(tab.path, event) }
27
37
  /** @private @author ddj 2026年10月14号 Select unless release completed a drag. */
@@ -35,8 +45,8 @@ export function EditorPane(props: PaneProps): React.ReactElement {
35
45
  /** @private @author ddj 2026年10月14号 @param event Keyboard activation. */
36
46
  function key(event: React.KeyboardEvent): void { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); props.onSelect(group.id, tab.path) } }
37
47
  return React.createElement('div', {
38
- key: tab.path, 'data-edrv-tab-path': tab.path, role: 'tab', tabIndex: 0, 'aria-selected': tab.path === group.active,
39
- className: 'edrv-tab' + (tab.path === group.active ? ' edrv-tab-active' : '') + (tab.pinned ? ' edrv-tab-pinned' : ''),
48
+ key: tab.path, 'data-edrv-tab-path': tab.path, role: 'tab', tabIndex: 0, 'aria-selected': selected,
49
+ className: 'edrv-tab' + (selected ? ' edrv-tab-active' : '') + (tab.pinned ? ' edrv-tab-pinned' : ''),
40
50
  title: tab.path + (tab.pinned ? '(已固定)' : ''), onPointerDown: press, onClick: select, onContextMenu: menu, onKeyDown: key,
41
51
  }, tab.pinned ? React.createElement('span', { className: 'edrv-tab-pin' }, '📌') : null,
42
52
  React.createElement('span', { className: 'edrv-group-tab-name' }, tab.path.split(/[\\/]/).pop()),
@@ -48,7 +58,9 @@ export function EditorPane(props: PaneProps): React.ReactElement {
48
58
  if (!group.active && !props.focused && event.button === 0) props.onSelect(group.id)
49
59
  }
50
60
  return React.createElement('section', { className: 'edrv-group' + (props.focused ? ' edrv-group-focused' : ''), 'data-edrv-group': group.id, onPointerDown: focusEmpty },
51
- React.createElement('div', { className: 'edrv-tabs', role: 'tablist', 'data-edrv-group-tabs': group.id }, group.tabs.map(renderTab), props.extras),
61
+ React.createElement('div', { className: 'edrv-tabs-row' },
62
+ React.createElement('div', { className: 'edrv-tabs', role: 'tablist', 'data-edrv-group-tabs': group.id, ref: props.tabsRef }, group.tabs.map(renderTab), props.extras),
63
+ props.tools),
52
64
  React.createElement('div', { className: 'edrv-group-body', role: 'tabpanel' }, props.children))
53
65
  }
54
66