dsh-vscode-mode 0.3.1 → 0.3.3
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/README.md +78 -3
- package/lib/client.js +1152 -209
- package/lib/client.js.map +1 -1
- package/lib/index.js +467 -3
- package/lib/index.js.map +1 -1
- package/package.json +2 -1
- package/skills/dsh-vscodemode-mcp/SKILL.md +181 -0
- package/src/client/addToConversation.ts +192 -20
- package/src/client/editorModelState.ts +18 -0
- package/src/client/openFlow.ts +2 -1
- package/src/client/paths.ts +4 -2
- package/src/client/saveDebounce.ts +69 -0
- package/src/client/sidebar/panels/FileExplorer.ts +83 -2
- package/src/client/state/scopeStore.ts +2 -1
- package/src/client/styles/editor.css +17 -2
- package/src/client/tabActions.ts +326 -0
- package/src/client/tabMenu.ts +107 -0
- package/src/client/ui/ContextMenu.ts +52 -8
- package/src/client/ui/EditorView.ts +221 -57
- package/src/client/ui/QuickOpen.ts +61 -13
- package/src/client/ui/commandCatalog.ts +17 -1
- package/src/client/ui/searchNav.ts +24 -0
- package/src/compat.ts +4 -0
- package/src/index.ts +5 -1
- package/src/paths.ts +11 -0
- package/src/shared/keybindings.ts +3 -0
- package/src/skills.ts +534 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"files": [
|
|
36
36
|
"lib",
|
|
37
37
|
"assets",
|
|
38
|
+
"skills",
|
|
38
39
|
"src",
|
|
39
40
|
"unity",
|
|
40
41
|
"cordis.patch.yml"
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dsh-vscodemode-mcp
|
|
3
|
+
description: 配置、新增、启用或排查 dsh-vscode-mode 插件(VSCodeMode 设置页)的 MCP 服务——全局「我的 MCP」与项目级 `.mcp.json` 两种作用域、stdio / streamable-http 传输写法、`mcp.*` RPC 方法、serverName 命名与冲突规则、项目 MCP 的工作区隔离边界,以及连接失败/工具数为 0 等故障的定位路径。当用户提到「加个 MCP」「配置 MCP」「MCP 连不上」「项目 MCP」「.mcp.json」「VSCodeMode 的 MCP 管理」时加载本技能。
|
|
4
|
+
whenToUse: 用户要求新增/修改/启停/排查 dsh-vscode-mode 的 MCP 服务配置时;或需要判断某个 MCP 工具为何在对话中不可见/被拒绝时。
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# dsh-vscodemode-mcp — dsh-vscode-mode 的 MCP 配置与使用
|
|
8
|
+
|
|
9
|
+
本技能覆盖 **dsh-vscode-mode 插件**(DSH 上的类 VSCode 编码体验)提供的 MCP 可视化管理能力。
|
|
10
|
+
它管理的是 `@deepseek-ai/dsh-mcp-client` 的 loader 条目,连接由 DSH Host 维护,**不在浏览器侧连接 MCP**。
|
|
11
|
+
|
|
12
|
+
## 1. 两种作用域(先判断用户要哪一种)
|
|
13
|
+
|
|
14
|
+
| | 全局「我的 MCP」 | 项目 MCP |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| 配置位置 | profile 的 loader 树(`~/.dsh/profiles/<profile>/cordis.yml`) | 项目根 `.mcp.json` 的 `mcpServers` |
|
|
17
|
+
| 生效范围 | 该 profile 下所有工作区 | 仅该 workspace(+ 其子路径可匹配到的工作区) |
|
|
18
|
+
| 随仓库共享 | 否 | 是(`.mcp.json` 入库即团队共享) |
|
|
19
|
+
| 持久真相 | loader 条目 | `.mcp.json` 文件(重启后由插件 reconcile 恢复激活) |
|
|
20
|
+
| 受工作区隔离约束 | 否 | **是**(见 §5) |
|
|
21
|
+
|
|
22
|
+
判断规则:
|
|
23
|
+
- 用户说「全局都能用」「所有项目都要」→ 全局。
|
|
24
|
+
- 用户说「这个项目需要」「跟仓库一起提交」→ 项目级。
|
|
25
|
+
- 不确定且该 MCP 只在某个仓库有意义 → 默认项目级。
|
|
26
|
+
|
|
27
|
+
## 2. UI 路径
|
|
28
|
+
|
|
29
|
+
设置 → **VSCodeMode** → **「MCP 管理」** Tab,其下三个子页签:
|
|
30
|
+
|
|
31
|
+
- **我的 MCP** — 全局服务列表(可查看状态/工具、添加、刷新 ⟳、启用/禁用、删除 ⌫)。
|
|
32
|
+
- **项目 MCP** — 先选项目(带路径与 MCP 计数),再管理该项目的服务。
|
|
33
|
+
- **MCP 市场** — 占位,暂未接入。
|
|
34
|
+
|
|
35
|
+
「+ 添加全局 MCP」按钮在子页签栏右侧;项目 MCP 的新增入口在所选项目的分组内。
|
|
36
|
+
表单字段:名称 / 传输方式 / (stdio)命令·参数·工作目录 / (streamable-http)URL·请求头。
|
|
37
|
+
按钮文案为「保存并连接」——**保存后会立刻尝试连接**。
|
|
38
|
+
|
|
39
|
+
## 3. 配置写法
|
|
40
|
+
|
|
41
|
+
### 3.1 项目级 `.mcp.json`
|
|
42
|
+
|
|
43
|
+
对齐 Claude Code / Cursor 格式,存于项目根:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"mcpServers": {
|
|
48
|
+
"codegraph": {
|
|
49
|
+
"command": "codegraph",
|
|
50
|
+
"args": ["serve", "--mcp", "--path", "D:/Work/MyProject"]
|
|
51
|
+
},
|
|
52
|
+
"remote-tools": {
|
|
53
|
+
"url": "http://localhost:3000/mcp",
|
|
54
|
+
"headers": { "Authorization": "Bearer <token>" },
|
|
55
|
+
"toolCallTimeoutMs": 60000
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
规则(`src/mcpProject.ts` 的 `configFromDef`):
|
|
62
|
+
- **有 `url` → `streamable-http`;无 `url` → `stdio`**。传输方式由字段形态自动判定,不写 `transport`。
|
|
63
|
+
- stdio 字段:`command`(必填)、`args`(数组)、`cwd`、`env`(键值对象)。
|
|
64
|
+
- http 字段:`url`(必填,须 `http(s)://`)、`headers`(键值对象)。
|
|
65
|
+
- 可选:`toolCallTimeoutMs`(数字)。
|
|
66
|
+
- `disabled: true` = 停用(由启停开关维护;**保存时不会写这个键,保存恒为启用**)。
|
|
67
|
+
- 顶层可有其他字段,写回时保留;`mcpServers` 之外的未知字段不丢。
|
|
68
|
+
|
|
69
|
+
### 3.2 全局(等价配置形)
|
|
70
|
+
|
|
71
|
+
全局条目等价于 profile loader 里的一行 `@deepseek-ai/dsh-mcp-client`:
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
- id: <条目 id>
|
|
75
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
76
|
+
config:
|
|
77
|
+
serverName: codegraph
|
|
78
|
+
transport: stdio
|
|
79
|
+
command: codegraph
|
|
80
|
+
args: ['serve', '--mcp']
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
但 **优先用设置页 UI 或 `mcp.save` RPC**,不要手改 profile 的 `cordis.yml`:UI/RPC 会同步触发 loader 热更新与冲突校验,手改容易产生重复条目。
|
|
84
|
+
|
|
85
|
+
### 3.3 两个最小可用示例
|
|
86
|
+
|
|
87
|
+
stdio(本地命令行 MCP,如 CodeGraph):
|
|
88
|
+
```
|
|
89
|
+
名称: codegraph
|
|
90
|
+
传输方式: stdio
|
|
91
|
+
命令: codegraph
|
|
92
|
+
参数: serve --mcp --path D:/Work/MyProject
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
streamable-http(远端/已启动的 HTTP MCP):
|
|
96
|
+
```
|
|
97
|
+
名称: remote-tools
|
|
98
|
+
传输方式: streamable-http
|
|
99
|
+
URL: http://localhost:3000/mcp
|
|
100
|
+
请求头: Authorization=Bearer <token>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## 4. RPC 方法表(UI 之外的程序化入口)
|
|
104
|
+
|
|
105
|
+
路由:`POST /edrv/rpc`,报文体 `{ "method": "...", "args": { ... } }`,返回 `{ ok: true, ... }` 或 `{ ok: false, error }`。
|
|
106
|
+
|
|
107
|
+
| 方法 | 入参 | 返回 |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `mcp.list` | `{}` | `{ servers: MpcServer[] }` |
|
|
110
|
+
| `mcp.save` | `{ config: MpcConfig }` | `{ server: MpcServer }` |
|
|
111
|
+
| `mcp.remove` | `{ id }` | `{}` |
|
|
112
|
+
| `mcp.toggle` | `{ id, enabled }` | `{ server: MpcServer }` |
|
|
113
|
+
| `mcp.refresh` | `{ id }` | `{ server: MpcServer }` |
|
|
114
|
+
| `mcp.projects` | `{}` | `{ projects: MpcProject[] }` |
|
|
115
|
+
| `mcp.projectSave` | `{ workspacePath, serverName, config }` | `{ project: MpcProject }` |
|
|
116
|
+
| `mcp.projectRemove` | `{ workspacePath, serverName }` | `{ project: MpcProject }` |
|
|
117
|
+
| `mcp.projectToggle` | `{ workspacePath, serverName, enabled }` | `{ project: MpcProject }` |
|
|
118
|
+
| `mcp.projectRefresh` | `{ workspacePath, serverName }` | `{ project: MpcProject }` |
|
|
119
|
+
|
|
120
|
+
`MpcServer` 关键字段:`id`、`serverName`、`enabled`、`transport`、`config`、`status`、`toolCount`、`tools[]`。
|
|
121
|
+
`MpcProject` 关键字段:`workspacePath`、`title`、`servers[]`、`source: 'project'`,异常时带 `missingDir` 或 `fileError`。
|
|
122
|
+
|
|
123
|
+
## 5. 命名规则与冲突(最容易踩的坑)
|
|
124
|
+
|
|
125
|
+
- **`serverName` 规则**:`/^[A-Za-z0-9_-]{1,32}$/` —— 字母、数字、**下划线**、连字符,最多 32 位。
|
|
126
|
+
⚠️ 注意与技能名的区别:**MCP 的 `serverName` 允许下划线**,而 DSH 技能名不允许(技能名须 kebab-case)。
|
|
127
|
+
- **唯一性**:同一个 `serverName` 在**全局与所有项目之间**全局唯一。重复时保存直接失败:
|
|
128
|
+
`serverName "xxx" 已被另一个 MCP 使用(全局或其他项目),请换一个名称`。
|
|
129
|
+
- **模型侧工具名**:`mcp__<serverName>__<原始工具名>`。这段前缀是隔离与可见性机制的判定依据,不要手工改 `serverName` 来"重命名工具"——那会生成一整套新工具名。
|
|
130
|
+
- **项目条目 id**:`vsm-mcp.<workspaceHash>.<serverName>`(旧版残留为 `vsm-mcp:<hash>:<name>`,插件识别但不显示为全局 MCP)。
|
|
131
|
+
- 项目 MCP 只接受**已注册为 DSH workspace** 的路径,否则报 `项目未注册为 DSH workspace,不能管理项目 MCP`。
|
|
132
|
+
|
|
133
|
+
## 6. 作用域隔离(项目 MCP 的边界)
|
|
134
|
+
|
|
135
|
+
项目 MCP 的连接由 Host 维护,但**每个 agent 只继承当前工作区的项目工具**:
|
|
136
|
+
|
|
137
|
+
- 机制:`tools.restrict({ deny })` 控制模型可见性 + `tools.guard()` 在执行层兜底拒绝。
|
|
138
|
+
- 当前对话工作区 ≠ 条目所属工作区时,调用被拒,返回:
|
|
139
|
+
`已拒绝:当前对话工作区不能使用其他项目的 MCP`
|
|
140
|
+
- 会话没有落在任何已注册工作区时:
|
|
141
|
+
`项目 MCP 需要在已注册工作区的对话中使用`
|
|
142
|
+
- **全局 MCP 不受此限制**,在任何会话都可用。
|
|
143
|
+
- 工作区匹配按**最长父路径**:在子目录会话里也能用到父级工作区的项目 MCP。
|
|
144
|
+
|
|
145
|
+
排查「工具有但没有出现在模型面前」时,先看当前会话 cwd 是否落在目标 workspace 内。
|
|
146
|
+
|
|
147
|
+
## 7. 故障排查
|
|
148
|
+
|
|
149
|
+
| 症状 | 原因与处理 |
|
|
150
|
+
|---|---|
|
|
151
|
+
| 卡片状态 `错误`,提示 `MCP 插件未正常运行` | loader 条目 fiber 未进入 ACTIVE。看 Host 启动日志中该条目的报错;多半是 `command` 不存在或启动即退出 |
|
|
152
|
+
| 状态 `连接中` 一直不变 | 服务端未响应 MCP 握手。核对 stdio 的 `command`/`args`/`cwd`,或 HTTP 的 URL 可达性 |
|
|
153
|
+
| 已连接但 `toolCount` 为 0 | 服务端 `tools/list` 返回空,或工具同步失败。先用该 MCP 的 CLI 自行验证能列出工具 |
|
|
154
|
+
| `stdio MCP 必须填写 command` | 表单「命令」为空 |
|
|
155
|
+
| `HTTP MCP 必须填写 http(s) URL` | 「URL」为空或不是 `http(s)://` 开头 |
|
|
156
|
+
| `serverName 只能包含字母、数字、下划线和连字符(最多 32 位)` | 名称含空格/点/中文或超长 |
|
|
157
|
+
| `serverName "x" 已被另一个 MCP 使用…` | 见 §5 唯一性 |
|
|
158
|
+
| 设置页里 `env` / `headers` 的值显示成 `••••••` | **这是脱敏掩码**(`publicConfig`)。读回的是掩码而非真实值,**不要把它原样回写保存**,否则会用掩码覆盖真实凭据 |
|
|
159
|
+
| `.mcp.json 解析失败:…` | 文件不是合法 JSON;插件只报错、**不覆盖**该文件,修好 JSON 后刷新 |
|
|
160
|
+
| `.mcp.json 的 mcpServers 必须是对象` | `mcpServers` 写成了数组/标量 |
|
|
161
|
+
| 项目显示「目录缺失」 | workspace 路径已不存在(项目被移动/删除) |
|
|
162
|
+
|
|
163
|
+
## 8. 边界与注意事项
|
|
164
|
+
|
|
165
|
+
- **删除/启停走设置页或 RPC**,不要手删 profile 树里的条目——插件按条目 id(`vsm-mcp.*`)识别项目 MCP,手工改名会让它被当成全局条目。
|
|
166
|
+
- **项目 MCP 的持久真相是 `.mcp.json`**:重启后插件按文件内容 reconcile 该项目的激活状态(文件里有的激活、文件里没有的停用)。
|
|
167
|
+
- 项目 MCP 的写操作固定写入 `<workspacePath>/.mcp.json`,不会写到别处。
|
|
168
|
+
- 全局 MCP 由 loader 落盘到 profile 树,重启自动恢复。
|
|
169
|
+
- MCP 条目本身是 loader 条目,**HMR/热更新会重建连接**;调试连接问题时可先 `mcp.refresh`(等价于用当前 config 重新 `loader.update`)。
|
|
170
|
+
- 本技能只覆盖 dsh-vscode-mode 的 MCP 管理面。若问题出在 MCP 服务端自身(协议实现、鉴权、工具 schema),那不是本插件的问题,应转向该服务端的调试手段。
|
|
171
|
+
|
|
172
|
+
## 9. 关键源码位置
|
|
173
|
+
|
|
174
|
+
需要在插件仓库里核对行为时(按需读取,不要一次全开):
|
|
175
|
+
|
|
176
|
+
- `src/mcp.ts` — 全局 MCP 的 list/save/remove/toggle/refresh,`validateConfig`,`publicConfig` 脱敏,`toolsOf` 工具前缀。
|
|
177
|
+
- `src/mcpProject.ts` — 项目 `.mcp.json` 读写、`configFromDef`、reconcile、条目 id 生成。
|
|
178
|
+
- `src/mcpIsolation.ts` — 工作区隔离(restrict + guard)与两条拒绝文案。
|
|
179
|
+
- `src/shared/mcp.ts` — 共享契约(`MpcConfig` / `MpcServer` / `MpcProject`)。
|
|
180
|
+
- `src/shared/rpc.ts` — `mcp.*` 方法的入参与返回形状。
|
|
181
|
+
- `src/client/ui/McpSettings.ts` — 设置页 UI(三个子页签与表单字段映射)。
|
|
@@ -12,27 +12,155 @@ export interface RefRange {
|
|
|
12
12
|
endLine: number
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
export type AddOutcome = 'ok' | 'busy' | 'unavailable'
|
|
15
|
+
/** 引用动作的返回状态(failed = 两条通道都没写成,内容未被改动)。 */
|
|
16
|
+
export type AddOutcome = 'ok' | 'busy' | 'unavailable' | 'failed'
|
|
17
17
|
|
|
18
18
|
/** 引用外观类型:文件/文件夹(DSH reference source 均支持)。 */
|
|
19
19
|
export type RefAppearance = 'file' | 'folder'
|
|
20
20
|
|
|
21
21
|
/** 把追加引用结果映射为可读文案(ok 用 okText;busy 提示已降级纯文本;unavailable 提示不可用)。
|
|
22
|
-
* @author ddj 2026年09月03号
|
|
22
|
+
* @author ddj 2026年09月03号 / 2026年09月11号
|
|
23
23
|
* @param outcome 追加结果状态
|
|
24
24
|
* @param okText 成功文案(如「已添加文件引用」/「已添加文件夹引用」)
|
|
25
25
|
* @returns 状态栏/通知用文案
|
|
26
26
|
*/
|
|
27
27
|
export function statusOfAdd(outcome: AddOutcome, okText: string): string {
|
|
28
28
|
if (outcome === 'ok') return okText
|
|
29
|
-
if (outcome === 'busy') return okText + '
|
|
29
|
+
if (outcome === 'busy') return okText + '(已降级纯文本)'
|
|
30
|
+
if (outcome === 'failed') return '添加引用失败(输入框忙或未就绪,请重试)'
|
|
30
31
|
return '无法添加到对话(无会话或输入框不可用)'
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/** DSH reference source 名(dsh-client-ui-reference 注册的 @file/@session 统一源)。 */
|
|
34
35
|
const REF_SOURCE = 'reference'
|
|
35
36
|
|
|
37
|
+
/** 输入快照的引用出现项(clipboard 投影中的位置与长度,用于坐标换算)。 */
|
|
38
|
+
export interface OccurrenceLike {
|
|
39
|
+
/** clipboard 投影中的起始偏移(chip 左边界)。 */
|
|
40
|
+
offset?: number
|
|
41
|
+
/** clipboard 投影中的长度(chip 展开成 `@path` 全文的长度)。 */
|
|
42
|
+
length?: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 输入快照(draft 为 clipboard 投影;occurrences 为 chip 列表)。 */
|
|
46
|
+
export interface CursorSnapshot {
|
|
47
|
+
draft: string
|
|
48
|
+
draftRev: number
|
|
49
|
+
occurrences?: readonly OccurrenceLike[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 把 clipboard 投影的末尾折算成 **detect 投影**的末尾。
|
|
54
|
+
*
|
|
55
|
+
* ⚠️ 两个投影对 chip 的计长不同,混用会直接毁掉已插入的引用(真实缺陷,勿改回 `draft.length`):
|
|
56
|
+
* DSH 的 `$composerLayout()` 中 chip 在 detect 投影里只贡献 **1 个 U+FFFC**,
|
|
57
|
+
* 在 clipboard 投影里贡献 `clipboardText` 全文
|
|
58
|
+
* (`pushLeaf('chip', kid, '', kid.getTextContent())`)。
|
|
59
|
+
* 而 `insertReference(ref, span)` 的 span 走 detect 坐标并经 `selectSpan` 校验:
|
|
60
|
+
* `span.end > layout.detectLength` 时返回 null → 插入失败。
|
|
61
|
+
*
|
|
62
|
+
* 旧实现直接用 `draft.length`(clipboard 长度)当 detect 起点:草稿里一旦有 chip,
|
|
63
|
+
* 该值就超出 detect 长度 → 插入被拒 → 落入 `setDraft` 兜底,
|
|
64
|
+
* 而 `setDraft` 会整篇重建并剔除 U+FFFC(`text.replace(REFERENCE_PLACEHOLDER_RE, '')`),
|
|
65
|
+
* 于是**既有 chip 被销毁、引用退化为重复的纯文本**(即「连续添加多个引用显示异常」)。
|
|
66
|
+
*
|
|
67
|
+
* 换算:每个 chip 在 clipboard 中多算了 `length - 1`,逐个扣回即得 detect 末尾。
|
|
68
|
+
* 无 occurrences(旧版 DSH)时退化为 `draft.length`,与旧行为一致。
|
|
69
|
+
* @author ddj 2026年09月11号
|
|
70
|
+
* @param snapshot 输入快照
|
|
71
|
+
* @returns detect 投影中的文档末尾偏移
|
|
72
|
+
*/
|
|
73
|
+
export function detectEndOf(snapshot: CursorSnapshot): number {
|
|
74
|
+
const total = String(snapshot?.draft ?? '').length
|
|
75
|
+
const occurrences = snapshot?.occurrences
|
|
76
|
+
if (!Array.isArray(occurrences) || !occurrences.length) return total
|
|
77
|
+
let clipped = 0
|
|
78
|
+
for (const occurrence of occurrences) {
|
|
79
|
+
const length = Number(occurrence?.length ?? 0)
|
|
80
|
+
if (!Number.isFinite(length) || length <= 1) continue
|
|
81
|
+
clipped += length - 1
|
|
82
|
+
}
|
|
83
|
+
return Math.max(0, total - clipped)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** chip 列表按 clipboard 偏移升序(官方已排序,此处防御性归一,丢弃非法项)。 */
|
|
87
|
+
function chipsOf(snapshot: CursorSnapshot): Array<{ offset: number; length: number }> {
|
|
88
|
+
const occurrences = snapshot?.occurrences
|
|
89
|
+
if (!Array.isArray(occurrences) || !occurrences.length) return []
|
|
90
|
+
const out: Array<{ offset: number; length: number }> = []
|
|
91
|
+
for (const occurrence of occurrences) {
|
|
92
|
+
const offset = Number(occurrence?.offset ?? Number.NaN)
|
|
93
|
+
const length = Number(occurrence?.length ?? Number.NaN)
|
|
94
|
+
if (!Number.isFinite(offset) || !Number.isFinite(length) || length <= 1) continue
|
|
95
|
+
out.push({ offset: Math.max(0, offset), length })
|
|
96
|
+
}
|
|
97
|
+
return out.sort((a, b) => a.offset - b.offset)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* detect 偏移 → clipboard 偏移(paddingAt 用:draft 是 clipboard 投影,不能直接按下标取字符)。
|
|
102
|
+
* 每个位于该偏移之前的 chip 在 clipboard 中多占 `length − 1`,累计补上即可。
|
|
103
|
+
* @author ddj 2026年09月11号
|
|
104
|
+
* @param snapshot 输入快照
|
|
105
|
+
* @param detectOffset detect 投影偏移
|
|
106
|
+
* @returns clipboard 投影偏移(已夹到 draft 范围内)
|
|
107
|
+
*/
|
|
108
|
+
export function detectToClipboardOf(snapshot: CursorSnapshot, detectOffset: number): number {
|
|
109
|
+
const total = String(snapshot?.draft ?? '').length
|
|
110
|
+
const target = Math.max(0, Math.min(Number(detectOffset) || 0, detectEndOf(snapshot)))
|
|
111
|
+
let shift = 0
|
|
112
|
+
for (const chip of chipsOf(snapshot)) {
|
|
113
|
+
const chipDetect = chip.offset - shift
|
|
114
|
+
if (chipDetect >= target) break
|
|
115
|
+
shift += chip.length - 1
|
|
116
|
+
}
|
|
117
|
+
return Math.max(0, Math.min(target + shift, total))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 插入点两侧是否需要补空格(仅纯文本通道需要;chip 由 facade 自行补尾随空格)。
|
|
122
|
+
*
|
|
123
|
+
* 在 detect 坐标的任意位置求值,故**末尾追加的行为与旧实现逐字符一致**
|
|
124
|
+
* (末尾前为非空白 → 前后各补一个空格;末尾前已是空白 → 只补尾随空格),
|
|
125
|
+
* 同时正确支持「插在文本中间」不再贴字。
|
|
126
|
+
* @author ddj 2026年09月11号
|
|
127
|
+
* @param snapshot 输入快照
|
|
128
|
+
* @param detectOffset detect 投影插入点
|
|
129
|
+
* @returns 是否需要前导/尾随空格
|
|
130
|
+
*/
|
|
131
|
+
export function paddingAt(snapshot: CursorSnapshot, detectOffset: number): { lead: boolean; tail: boolean } {
|
|
132
|
+
const draft = String(snapshot?.draft ?? '')
|
|
133
|
+
const at = detectToClipboardOf(snapshot, detectOffset)
|
|
134
|
+
const before = at > 0 ? draft.slice(at - 1, at) : ''
|
|
135
|
+
const after = at < draft.length ? draft.slice(at, at + 1) : ''
|
|
136
|
+
return {
|
|
137
|
+
lead: before !== '' && !/\s/.test(before),
|
|
138
|
+
// 末尾(after 为空)也补尾随空格:与旧「追加到末尾」行为一致
|
|
139
|
+
tail: after === '' || !/\s/.test(after),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 解析插入点(detect 坐标):优先用输入框**当前光标/选区**(`caretSpan()`,
|
|
145
|
+
* 官方契约即 detect 坐标,无选区时回落文档末尾),并夹到 `[0, detectLength]`。
|
|
146
|
+
*
|
|
147
|
+
* 选区非塌缩时取 `end`(右边界)—— 插入而非替换,绝不删除用户已选中的内容。
|
|
148
|
+
* caret 缺失/非法(旧版 facade 无该方法、抛异常、NaN 等)一律回落到文档末尾。
|
|
149
|
+
* @author ddj 2026年09月11号
|
|
150
|
+
* @param caret caretSpan() 的返回值(可为 null/非法)
|
|
151
|
+
* @param detectLength detect 投影文档长度
|
|
152
|
+
* @returns detect 投影插入偏移
|
|
153
|
+
*/
|
|
154
|
+
export function insertOffsetOf(caret: unknown, detectLength: number): number {
|
|
155
|
+
const max = Math.max(0, Number(detectLength) || 0)
|
|
156
|
+
if (!caret || typeof caret !== 'object') return max
|
|
157
|
+
const span = caret as { start?: unknown; end?: unknown }
|
|
158
|
+
const raw = Number(span.end)
|
|
159
|
+
const value = Number.isFinite(raw) ? raw : Number(span.start)
|
|
160
|
+
if (!Number.isFinite(value)) return max
|
|
161
|
+
return Math.max(0, Math.min(value, max))
|
|
162
|
+
}
|
|
163
|
+
|
|
36
164
|
/**
|
|
37
165
|
* 生成 DSH @file 语法引用串:cwd 相对化、\ → /、含空白时按 @"path" 语法加引号。
|
|
38
166
|
* cwd 外/无法相对化的路径回退原路径。
|
|
@@ -103,8 +231,15 @@ export function buildFileRef(
|
|
|
103
231
|
|
|
104
232
|
/** 输入门面最小形状(运行时来自 ctx.conversation.input,避免引入新类型依赖)。 */
|
|
105
233
|
export interface InputLike {
|
|
106
|
-
state: { getSnapshot: () =>
|
|
234
|
+
state: { getSnapshot: () => CursorSnapshot }
|
|
235
|
+
/**
|
|
236
|
+
* 当前光标/选区(**detect 坐标**;无选区时回落为文档末尾的塌缩 span)。
|
|
237
|
+
* 官方 facade 契约:`caretSpan(): { start, end }`,与 insertReference 同一坐标系。
|
|
238
|
+
*/
|
|
239
|
+
caretSpan?: () => { start?: number; end?: number } | null
|
|
107
240
|
insertReference: (ref: ReferenceInsertLike, span: TokenSpanLike) => boolean
|
|
241
|
+
/** 纯文本插入(官方 plain-text reference path;按 detect 坐标替换,不重建文档)。 */
|
|
242
|
+
insertText?: (text: string, span: TokenSpanLike, keepCompleting?: boolean) => boolean
|
|
108
243
|
setDraft: (text: string) => void
|
|
109
244
|
}
|
|
110
245
|
|
|
@@ -154,19 +289,24 @@ export function inputFor(ctx: CtxLike, sessionId: string | undefined): InputLike
|
|
|
154
289
|
const conversation = ctx.get('conversation') as { input?: { for: (actx: unknown) => unknown } } | undefined
|
|
155
290
|
const actx = sessions?.scope?.(sessionId)
|
|
156
291
|
const shell = actx && conversation?.input?.for?.(actx)
|
|
157
|
-
|
|
292
|
+
// 探针须是我们的**实际依赖**(insertReference),而不是会被整篇重建的 setDraft
|
|
293
|
+
return (shell && typeof (shell as InputLike).insertReference === 'function') ? shell as InputLike : undefined
|
|
158
294
|
} catch {
|
|
159
295
|
return undefined
|
|
160
296
|
}
|
|
161
297
|
}
|
|
162
298
|
|
|
163
|
-
/**
|
|
164
|
-
* @author ddj 2026年08月25号 */
|
|
165
|
-
function draftCursor(input: InputLike | undefined):
|
|
299
|
+
/** 取当前草稿快照(长度/版本/chip 列表);无输入门面时返回 null。
|
|
300
|
+
* @author ddj 2026年08月25号 / 2026年09月11号 */
|
|
301
|
+
function draftCursor(input: InputLike | undefined): CursorSnapshot | null {
|
|
166
302
|
try {
|
|
167
303
|
const s = input?.state?.getSnapshot?.()
|
|
168
304
|
if (!s) return null
|
|
169
|
-
return {
|
|
305
|
+
return {
|
|
306
|
+
draft: String(s.draft ?? ''),
|
|
307
|
+
draftRev: Number(s.draftRev ?? 0),
|
|
308
|
+
occurrences: Array.isArray(s.occurrences) ? s.occurrences : undefined,
|
|
309
|
+
}
|
|
170
310
|
} catch {
|
|
171
311
|
return null
|
|
172
312
|
}
|
|
@@ -196,9 +336,40 @@ function safeInsert(input: InputLike, reference: ReferenceInsertLike, span: Toke
|
|
|
196
336
|
}
|
|
197
337
|
}
|
|
198
338
|
|
|
339
|
+
/** 输入门面插入纯文本(官方 plain-text 通道;不存在或异常视作未应用)。
|
|
340
|
+
* @author ddj 2026年09月11号 */
|
|
341
|
+
function safeInsertText(input: InputLike, text: string, span: TokenSpanLike): boolean {
|
|
342
|
+
if (typeof input.insertText !== 'function') return false
|
|
343
|
+
try {
|
|
344
|
+
return input.insertText(text, span) === true
|
|
345
|
+
} catch {
|
|
346
|
+
return false
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 读输入框当前光标(detect 坐标);门面缺失/异常返回 null(由调用方回落到文档末尾)。
|
|
352
|
+
* @author ddj 2026年09月11号
|
|
353
|
+
* @param input 输入门面
|
|
354
|
+
* @returns caretSpan 结果或 null
|
|
355
|
+
*/
|
|
356
|
+
function caretOf(input: InputLike): { start?: number; end?: number } | null {
|
|
357
|
+
if (typeof input.caretSpan !== 'function') return null
|
|
358
|
+
try {
|
|
359
|
+
return input.caretSpan() ?? null
|
|
360
|
+
} catch {
|
|
361
|
+
return null
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
199
365
|
/**
|
|
200
366
|
* 创建「添加到对话」动作集(apply 阶段构建一次,随 props 传给 EditorView)。
|
|
201
|
-
*
|
|
367
|
+
*
|
|
368
|
+
* 插入位置 = 输入框**当前光标**(`caretSpan()`,detect 坐标;无光标提示时回落文档末尾),
|
|
369
|
+
* 不再固定追加到最末尾。坐标一律用 **detect 投影**(见 detectEndOf 的缺陷说明);
|
|
370
|
+
* 两条写入通道都失败时**不写任何内容**并返回 'failed' —— 旧实现在此处退回 `setDraft`
|
|
371
|
+
* 整篇重建,会把既有 chip 全部销毁(连续添加多个引用即触发)。
|
|
372
|
+
* @author ddj 2026年08月25号 / 2026年09月11号
|
|
202
373
|
* @param ctx 客户端服务上下文(sessions + conversation)
|
|
203
374
|
* @returns 动作集
|
|
204
375
|
*/
|
|
@@ -209,15 +380,16 @@ export function createAddToConversation(ctx: CtxLike): AddToConversation {
|
|
|
209
380
|
const cur = draftCursor(input)
|
|
210
381
|
if (!cur) return 'unavailable'
|
|
211
382
|
const { reference, mention } = buildFileRef(path, cwdOf(ctx, sessionId), range, appearance)
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
383
|
+
// 光标位置(detect 坐标);caretSpan 不可用时 = 文档末尾(旧行为)
|
|
384
|
+
const at = insertOffsetOf(caretOf(input), detectEndOf(cur))
|
|
385
|
+
const span: TokenSpanLike = { start: at, end: at, draftRev: cur.draftRev }
|
|
386
|
+
if (safeInsert(input, reference, span)) return 'ok'
|
|
387
|
+
// chip 通道被拒(忙态/坐标 CAS 过期):改用纯文本通道就地替换,**保留既有 chip**。
|
|
388
|
+
const pad = paddingAt(cur, at)
|
|
389
|
+
const text = (pad.lead ? ' ' : '') + mention + (pad.tail ? ' ' : '')
|
|
390
|
+
if (safeInsertText(input, text, span)) return 'busy'
|
|
391
|
+
// 两条通道都不可用:宁可不写,也不整篇重建(那会销毁用户已插入的引用)
|
|
392
|
+
return 'failed'
|
|
221
393
|
}
|
|
222
394
|
|
|
223
395
|
return { appendReference }
|
|
@@ -19,6 +19,12 @@ export const EDITOR_ROOT_SELECTOR = '.edrv-editor-row'
|
|
|
19
19
|
/** Monaco 编辑器实例选择器(限定在插件编辑器行内,排除官方预览等其它 Monaco)。 */
|
|
20
20
|
export const EDITOR_MODEL_SELECTOR = EDITOR_ROOT_SELECTOR + ' .monaco-editor'
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* 已打开文件页签选择器(页签栏在编辑器行内;空态时无此节点)。
|
|
24
|
+
* 判据同样不依赖 Monaco:图片/PDF 页签没有 Monaco 实例,但仍是「有页签可关」。
|
|
25
|
+
*/
|
|
26
|
+
export const TAB_SELECTOR = EDITOR_ROOT_SELECTOR + ' .edrv-tabs .edrv-tab'
|
|
27
|
+
|
|
22
28
|
/**
|
|
23
29
|
* 当前文档是否挂载了编辑器视图(无 document 的运行环境返回 false)。
|
|
24
30
|
* @author ddj 2026年09月10号
|
|
@@ -40,3 +46,15 @@ export function hasEditorModel(): boolean {
|
|
|
40
46
|
if (typeof document === 'undefined') return false
|
|
41
47
|
return document.querySelector(EDITOR_MODEL_SELECTOR) !== null
|
|
42
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 当前是否已打开文件页签(「关闭当前页签」等页签级命令的可用性判据)。
|
|
52
|
+
* 用页签 DOM 而非 Monaco model 判定:图片/PDF 页签没有 Monaco 实例但可以关闭;
|
|
53
|
+
* 无页签时返回 false,让 Ctrl+F4 之类按键放行给浏览器(与 addSelectionRef「不吞键」同款约定)。
|
|
54
|
+
* @author ddj 2026年09月11号
|
|
55
|
+
* @returns 是否存在已打开的文件页签
|
|
56
|
+
*/
|
|
57
|
+
export function hasOpenTabs(): boolean {
|
|
58
|
+
if (typeof document === 'undefined') return false
|
|
59
|
+
return document.querySelector(TAB_SELECTOR) !== null
|
|
60
|
+
}
|
package/src/client/openFlow.ts
CHANGED
|
@@ -312,7 +312,8 @@ function realDeps(ctx: unknown, overrides: Partial<OpenDeps> = {}): OpenDeps {
|
|
|
312
312
|
openSession: (id) => service('sessions').open(id),
|
|
313
313
|
reference: async (sessionId, path, appearance) => {
|
|
314
314
|
const outcome: AddOutcome = await add.appendReference(sessionId, path, undefined, appearance)
|
|
315
|
-
|
|
315
|
+
// ok/busy(纯文本降级)都算已落点;unavailable/failed 交给 settleRef 继续重试
|
|
316
|
+
return outcome === 'ok' || outcome === 'busy'
|
|
316
317
|
},
|
|
317
318
|
choose: (title, folder) => chooseWorkspace(title, folder),
|
|
318
319
|
openEditor: (path, line, column) => {
|
package/src/client/paths.ts
CHANGED
|
@@ -14,8 +14,10 @@ export const CACHE_KEY = {
|
|
|
14
14
|
entries: 'edrv.cache.entries.v2.',
|
|
15
15
|
/** 编辑区视图状态(viewStateCache v1)。 */
|
|
16
16
|
viewstate: 'edrv.cache.viewstate.v1.',
|
|
17
|
-
/** 编辑器页签/活动文件(EditorView
|
|
18
|
-
editor: 'edrv.editor.
|
|
17
|
+
/** 编辑器页签/活动文件(EditorView v3:页签带 pinned 固定标记)。 */
|
|
18
|
+
editor: 'edrv.editor.v3.',
|
|
19
|
+
/** 旧编辑器页签键(v2:页签为纯路径数组)——仅用于一次性回读迁移,不再写入。 */
|
|
20
|
+
editorLegacy: 'edrv.editor.v2.',
|
|
19
21
|
/** 侧边栏状态(EditorView;按布局 side/central 追加段)。 */
|
|
20
22
|
sidebar: 'edrv.sidebar.',
|
|
21
23
|
/** 搜索面板条件(SearchPanel v1)。 */
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode client — 防抖保存槽(纯逻辑,可单测)。
|
|
3
|
+
*
|
|
4
|
+
* 存在意义(真实缺陷,勿把「取消」与「保存」再合并回一个句柄):
|
|
5
|
+
* 自动保存由 `schedule(fn, 700)` 承载,而 `schedule` = `ctx.timeout(fn, ms)` 的返回值是
|
|
6
|
+
* **cordis disposer**(只 `clearTimeout`,不执行 fn)。旧实现把定时器句柄直接存下,并在
|
|
7
|
+
* 「切页签 / 关闭文件 / 卸载」时调用它来「立即保存」—— 实际只取消了这一轮保存,
|
|
8
|
+
* 于是 700ms 防抖窗口内的编辑**既不落盘也不再重试**,静默丢改动
|
|
9
|
+
* (实测:改文件后 150ms 内关闭页签,磁盘仍是旧内容)。
|
|
10
|
+
*
|
|
11
|
+
* 故把「取消」与「立即提交」拆成两个明确入口:
|
|
12
|
+
* - `arm()` 重新计时(先取消上一轮,到点自动执行)
|
|
13
|
+
* - `flush()` 立即执行待提交的保存(先取消定时器防重复,再执行回调体)
|
|
14
|
+
* - `cancel()` 纯取消(确实要放弃时才用)
|
|
15
|
+
*
|
|
16
|
+
* 作者 ddj 2026年09月11号
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 定时器调度器(EditorView 传入 ctx.timeout 包装;返回值为 canceller)。 */
|
|
20
|
+
export type Scheduler = (fn: () => void, ms: number) => (() => void) | undefined
|
|
21
|
+
|
|
22
|
+
/** 防抖保存槽句柄。 */
|
|
23
|
+
export interface SaveTimer {
|
|
24
|
+
/** 重新计时:取消上一轮并挂新定时器(到点自动执行并清槽)。 */
|
|
25
|
+
arm(schedule: Scheduler, delay: number, run: () => void): void
|
|
26
|
+
/** 立即提交待执行的保存(无待提交时为空操作);幂等。 */
|
|
27
|
+
flush(): void
|
|
28
|
+
/** 纯取消(不保存)。 */
|
|
29
|
+
cancel(): void
|
|
30
|
+
/** 当前是否有待提交的保存。 */
|
|
31
|
+
isPending(): boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 创建防抖保存槽。
|
|
36
|
+
* @author ddj 2026年09月11号
|
|
37
|
+
* @returns 保存槽句柄
|
|
38
|
+
*/
|
|
39
|
+
export function createSaveTimer(): SaveTimer {
|
|
40
|
+
let pending: { cancel?: () => void; run: () => void } | null = null
|
|
41
|
+
return {
|
|
42
|
+
arm(schedule, delay, run) {
|
|
43
|
+
if (pending) { pending.cancel?.(); pending = null }
|
|
44
|
+
const cancel = schedule(() => {
|
|
45
|
+
// 到点:先清槽再执行,避免回调内 flush 重入同一份保存
|
|
46
|
+
pending = null
|
|
47
|
+
run()
|
|
48
|
+
}, delay)
|
|
49
|
+
pending = { cancel: typeof cancel === 'function' ? cancel : undefined, run }
|
|
50
|
+
},
|
|
51
|
+
flush() {
|
|
52
|
+
const slot = pending
|
|
53
|
+
if (!slot) return
|
|
54
|
+
pending = null
|
|
55
|
+
// 先取消定时器再执行:否则到点后会重复保存一次
|
|
56
|
+
slot.cancel?.()
|
|
57
|
+
slot.run()
|
|
58
|
+
},
|
|
59
|
+
cancel() {
|
|
60
|
+
const slot = pending
|
|
61
|
+
if (!slot) return
|
|
62
|
+
pending = null
|
|
63
|
+
slot.cancel?.()
|
|
64
|
+
},
|
|
65
|
+
isPending() {
|
|
66
|
+
return pending !== null
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
}
|