dsh-code-server-app 0.2.14 → 0.3.6

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.
@@ -0,0 +1,241 @@
1
+ // dshcs-editor-bridge / lib/bridge-client.js —— 纯逻辑:读配置、与 host 同步
2
+ //
3
+ // 这个文件**不 require('vscode')**,所以能在没有 VS Code 的环境里单测
4
+ // (scripts/test-bridge-extension.mjs 就是这么用的)。与编辑器交互的部分在
5
+ // lib/context-model.js(纯数据投影)与 extension.js(glue)里。
6
+ //
7
+ // 三条通道里属于扩展的两条:
8
+ // 1. 读 `<extensionsDir>/.dshcs-bridge/bridge.json` —— host 写,扩展**每次请求前重读**
9
+ // (host 重启会让端口与令牌轮换,而 IDE 进程可能被 adopt 继续活着,env 方案跟不上);
10
+ // 2. `POST /api/code-server/bridge/sync?since=N` —— **一趟来回同时做两件事**:
11
+ // 把编辑器状态(活动文件/脏缓冲区/诊断)推给 host,并取回 host 推来的 agent 改动提示。
12
+ // 为什么合并:扩展宿主里没有 HTTP 服务器,host 反向请求不到它,状态只能由扩展推上来;
13
+ // 而轮询本来就在跑,合并成一个请求就省掉了第二个定时器与一次往返。
14
+ // 带着 x-dshcs-bridge-token 头。
15
+
16
+ 'use strict';
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ /** 与 host 侧 lib/bridge.mjs 的常量保持一致(改动必须两边同步;scripts/test-bridge-extension.mjs
22
+ * 里有一条一致性断言,会把两边的字面量放在一起比)。 */
23
+ const BRIDGE_DIRNAME = '.dshcs-bridge';
24
+ const BRIDGE_FILENAME = 'bridge.json';
25
+ const TOKEN_HEADER = 'x-dshcs-bridge-token';
26
+ const STATE_FILENAME = 'extension-state.json';
27
+ const REQUEST_TIMEOUT_MS = 3000;
28
+ /** 轮询间隔:host 侧事件只是"去看一眼这个文件"的提示,600ms 足够且几乎无开销。 */
29
+ const POLL_INTERVAL_MS = 600;
30
+ /** 宿主配置重读间隔(令牌/端口轮换后最多这么久恢复)。 */
31
+ const CONFIG_REREAD_MS = 5000;
32
+
33
+
34
+ const TOKEN_RE = /^[0-9A-Za-z_-]{16,128}$/;
35
+
36
+ /** 桥配置路径;`extensionsDir` 可由调用方给(测试用),默认从 __dirname 反推。 */
37
+ function bridgeFile(extensionsDir) {
38
+ const dir = extensionsDir ?? path.resolve(__dirname, '..', '..');
39
+ return path.join(dir, BRIDGE_DIRNAME, BRIDGE_FILENAME);
40
+ }
41
+
42
+ /** 扩展自己的小状态文件(since 游标),与桥配置同目录。 */
43
+ function stateFile(extensionsDir) {
44
+ const dir = extensionsDir ?? path.resolve(__dirname, '..', '..');
45
+ return path.join(dir, BRIDGE_DIRNAME, STATE_FILENAME);
46
+ }
47
+
48
+ /**
49
+ * 读桥配置。
50
+ * @returns {{url: string, token: string, pid: number|null}|null} null = 未配置/格式不对 → 休眠
51
+ */
52
+ function readBridgeConfig(extensionsDir) {
53
+ try {
54
+ const raw = JSON.parse(fs.readFileSync(bridgeFile(extensionsDir), 'utf8'));
55
+ if (raw === null || typeof raw !== 'object') return null;
56
+ if (typeof raw.url !== 'string' || !/^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):\d+$/.test(raw.url)) return null;
57
+ if (!TOKEN_RE.test(String(raw.token))) return null;
58
+ return { url: raw.url, token: String(raw.token), pid: Number.isSafeInteger(raw.pid) ? raw.pid : null };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /** 读回上次的轮询游标(实例没换才有意义)。 */
65
+ function readState(extensionsDir) {
66
+ try {
67
+ const raw = JSON.parse(fs.readFileSync(stateFile(extensionsDir), 'utf8'));
68
+ if (raw === null || typeof raw !== 'object') return { since: 0, pid: null };
69
+ return {
70
+ since: Number.isSafeInteger(raw.since) && raw.since > 0 ? raw.since : 0,
71
+ pid: Number.isSafeInteger(raw.pid) ? raw.pid : null,
72
+ };
73
+ } catch {
74
+ return { since: 0, pid: null };
75
+ }
76
+ }
77
+
78
+ /** 写回轮询游标(best-effort:写不进去只影响"重复收到一次提示",不影响正确性)。 */
79
+ function writeState(extensionsDir, state) {
80
+ try {
81
+ fs.mkdirSync(path.dirname(stateFile(extensionsDir)), { recursive: true });
82
+ fs.writeFileSync(stateFile(extensionsDir), JSON.stringify(state), 'utf8');
83
+ return true;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /** host 侧拒绝时的统一错误(带 status,便于区分 401/503/403)。 */
90
+ class BridgeError extends Error {
91
+ constructor(message, status) {
92
+ super(message);
93
+ this.name = 'BridgeError';
94
+ this.status = status;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * 造一个桥客户端。
100
+ *
101
+ * @param {{extensionsDir?: string, fetchImpl?: Function, now?: Function}} [options]
102
+ * `fetchImpl` / `now` 可注入,便于单测(默认用 Node 18+ 的全局 fetch)。
103
+ */
104
+ function createClient(options) {
105
+ const extensionsDir = options && options.extensionsDir !== undefined ? options.extensionsDir : undefined;
106
+ const fetchImpl = (options && options.fetchImpl) || ((...args) => fetch(...args));
107
+ /** 当前配置(null = 休眠)。 */
108
+ let config = null;
109
+ /** 上次读配置的时间(避免每 600ms 都碰磁盘)。 */
110
+ let configReadAt = 0;
111
+ let since = (options && Number.isSafeInteger(options.since)) ? options.since : 0;
112
+
113
+ /** 重读配置(必要时指定强制)。返回当前配置。 */
114
+ function refreshConfig(force) {
115
+ const now = (options && options.now ? options.now() : Date.now());
116
+ if (!force && config !== null && now - configReadAt < CONFIG_REREAD_MS) return config;
117
+ configReadAt = now;
118
+ const next = readBridgeConfig(extensionsDir);
119
+ if (next !== null && config !== null && next.url !== config.url) {
120
+ // 端口/实例变了:游标失去意义(旧实例的事件不该在新实例上重放)。
121
+ since = 0;
122
+ }
123
+ if (next !== null && config !== null && next.pid !== config.pid) since = 0;
124
+ config = next;
125
+ return config;
126
+ }
127
+
128
+ /** 一次带鉴权的请求;非 2xx 抛 BridgeError(带 status)。 */
129
+ async function request(route, init) {
130
+ const current = refreshConfig(true);
131
+ if (current === null) throw new BridgeError('编辑器桥未配置(休眠中)', 0);
132
+ const headers = Object.assign({ [TOKEN_HEADER]: current.token }, (init && init.headers) || {});
133
+ const controller = new AbortController();
134
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
135
+ let response;
136
+ try {
137
+ response = await fetchImpl(current.url + route, Object.assign({}, init, { headers, signal: controller.signal }));
138
+ } catch (error) {
139
+ throw new BridgeError(`无法连接宿主:${error && error.message ? error.message : String(error)}`, 0);
140
+ } finally {
141
+ clearTimeout(timer);
142
+ }
143
+ const text = await response.text();
144
+ let body = null;
145
+ try {
146
+ body = text === '' ? null : JSON.parse(text);
147
+ } catch {
148
+ body = null;
149
+ }
150
+ if (!response.ok) {
151
+ const reason = body !== null && typeof body.error === 'string' ? body.error : `HTTP ${response.status}`;
152
+ throw new BridgeError(reason, response.status);
153
+ }
154
+ return body;
155
+ }
156
+
157
+ return {
158
+ /** 当前配置(null = 休眠)。 */
159
+ get config() {
160
+ return config;
161
+ },
162
+ /** 当前轮询游标。 */
163
+ get cursor() {
164
+ return since;
165
+ },
166
+ /** 是否处于休眠(未配置)。 */
167
+ isDormant() {
168
+ return refreshConfig(false) === null;
169
+ },
170
+ /** 强制重读配置(实例切换、状态栏刷新时用)。 */
171
+ refresh() {
172
+ return refreshConfig(true);
173
+ },
174
+ /** 轻量探活:host 端点是否可达(不碰编辑器)。 */
175
+ async health() {
176
+ return request('/api/code-server/bridge/health');
177
+ },
178
+ /**
179
+ * **一趟来回:上报编辑器状态 + 取回待处理事件。**
180
+ *
181
+ * 上报失败/过期都不影响编辑器:失败只返回 `{ok:false}`,由调用方决定记日志还是静默。
182
+ * 游标只在拿到事件后推进,所以丢一次响应大不了下次重放。
183
+ *
184
+ * @param {{context: object, diagnostics: object[]}} payload 由 context-model 投影出来的状态
185
+ * @returns {Promise<{ok: boolean, events?: object[], error?: string, status?: number}>}
186
+ */
187
+ async sync(payload) {
188
+ if (refreshConfig(false) === null) return { ok: false, error: 'dormant', status: 0 };
189
+ try {
190
+ const body = await request(`/api/code-server/bridge/sync?since=${since}`, {
191
+ method: 'POST',
192
+ headers: { 'content-type': 'application/json' },
193
+ body: JSON.stringify(payload),
194
+ });
195
+ const events = body !== null && Array.isArray(body.events) ? body.events : [];
196
+ for (const event of events) {
197
+ if (Number.isSafeInteger(event.seq) && event.seq > since) since = event.seq;
198
+ }
199
+ return { ok: true, events };
200
+ } catch (error) {
201
+ return { ok: false, error: error.message, status: error.status };
202
+ }
203
+ },
204
+ /** 把"选中内容 + 问题"投给 DSH 的当前会话。 */
205
+ async ask(payload) {
206
+ return request('/api/code-server/bridge/ask', {
207
+ method: 'POST',
208
+ headers: { 'content-type': 'application/json' },
209
+ body: JSON.stringify(payload),
210
+ });
211
+ },
212
+ /** 把游标落盘(实例重启后不重复播报旧事件)。 */
213
+ persist() {
214
+ return writeState(extensionsDir, { since, pid: config === null ? null : config.pid });
215
+ },
216
+ /** 从磁盘恢复游标(实例没换才生效)。 */
217
+ restore() {
218
+ const saved = readState(extensionsDir);
219
+ if (config !== null && saved.pid === config.pid) since = saved.since;
220
+ else since = 0;
221
+ return since;
222
+ },
223
+ };
224
+ }
225
+
226
+ module.exports = {
227
+ BRIDGE_DIRNAME,
228
+ BRIDGE_FILENAME,
229
+ TOKEN_HEADER,
230
+ STATE_FILENAME,
231
+ POLL_INTERVAL_MS,
232
+ CONFIG_REREAD_MS,
233
+ REQUEST_TIMEOUT_MS,
234
+ BridgeError,
235
+ bridgeFile,
236
+ stateFile,
237
+ readBridgeConfig,
238
+ readState,
239
+ writeState,
240
+ createClient,
241
+ };
@@ -0,0 +1,204 @@
1
+ // dshcs-editor-bridge / lib/context-model.js —— 纯逻辑:把编辑器快照投影成桥的响应
2
+ //
3
+ // **不 require('vscode')**:入参是已经拍平的纯数据(见 createProjector 的文档),
4
+ // 这样 scripts/test-bridge-extension.mjs 能在没有 VS Code 的环境里直接验投影规则。
5
+ //
6
+ // 投影规则里每一条都对应一个真实坑:
7
+ // - **未保存缓冲区才是重点**:磁盘内容 ≠ 用户所见,agent 按磁盘文件改就会把人家的编辑冲掉,
8
+ // 所以 dirty 文档必须在 /context 里出现,并且带上"未保存行数"的量级。
9
+ // - **无标题文档没有路径**:用 `untitled:<n>` 占位,且**不接受**它作为 diagnostics 的 file 入参
10
+ // (那条路径在磁盘上不存在,按它去查只会得到空结果,不如直说)。
11
+ // - **诊断必须收敛在工作区内**:workspaceFolder 之外的诊断(比如 node_modules 里的、
12
+ // 或另一个根目录的)对当前任务没有意义,而且会把响应撑爆。
13
+ // - **一切有界**:诊断按严重度排序后截断;选中文本截断。上限在这里,不在 host —— 扩展才是
14
+ // 唯一知道真实规模的一方。
15
+
16
+ 'use strict';
17
+
18
+ /** 选中文本 / 单条诊断 message 的截断上限。 */
19
+ const MAX_SELECTION_CHARS = 8000;
20
+ const MAX_MESSAGE_CHARS = 500;
21
+ /** 默认返回的诊断条数上限(host 侧工具最多要 200)。 */
22
+ const MAX_DIAGNOSTICS = 200;
23
+ /** /context 里按文件聚合的问题条数上限。 */
24
+ const MAX_PROBLEM_SUMMARIES = 30;
25
+
26
+ /** 严重度排序权重(小的在前):error → warning → info → hint。 */
27
+ const SEVERITY_RANK = { error: 0, warning: 1, info: 2, hint: 3 };
28
+
29
+ /** 把 VS Code 的 DiagnosticSeverity 数值化名转成字符串(调用方给数值也行)。 */
30
+ function severityName(severity) {
31
+ if (typeof severity === 'string') {
32
+ const lower = severity.toLowerCase();
33
+ return SEVERITY_RANK[lower] === undefined ? 'info' : lower;
34
+ }
35
+ switch (severity) {
36
+ case 0: return 'error';
37
+ case 1: return 'warning';
38
+ case 2: return 'info';
39
+ case 3: return 'hint';
40
+ default: return 'info';
41
+ }
42
+ }
43
+
44
+ function truncate(text, limit) {
45
+ if (typeof text !== 'string') return '';
46
+ return text.length > limit ? `${text.slice(0, limit)}…(已截断)` : text;
47
+ }
48
+
49
+ /**
50
+ * 拍平一条诊断。
51
+ *
52
+ * @param {{path: string|null, name: string, line: number, column: number, severity: unknown,
53
+ * message: string, source?: string, code?: string|number}} raw 1 基行列
54
+ */
55
+ function normalizeDiagnostic(raw) {
56
+ const diagnostic = {
57
+ path: typeof raw.path === 'string' && raw.path !== '' ? raw.path : (raw.name ?? '(无路径)'),
58
+ line: Number.isSafeInteger(raw.line) && raw.line > 0 ? raw.line : 1,
59
+ column: Number.isSafeInteger(raw.column) && raw.column > 0 ? raw.column : 1,
60
+ severity: severityName(raw.severity),
61
+ message: truncate(typeof raw.message === 'string' ? raw.message : '', MAX_MESSAGE_CHARS),
62
+ };
63
+ if (typeof raw.source === 'string' && raw.source !== '') diagnostic.source = raw.source;
64
+ if (raw.code !== undefined && raw.code !== null && raw.code !== '') diagnostic.code = String(raw.code);
65
+ return diagnostic;
66
+ }
67
+
68
+ /**
69
+ * 排序 + 截断诊断。
70
+ * 排序键:严重度 → 文件 → 行(稳定且与用户看 Problems 面板的顺序接近)。
71
+ */
72
+ function sortDiagnostics(items, limit) {
73
+ const cap = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, MAX_DIAGNOSTICS) : MAX_DIAGNOSTICS;
74
+ const sorted = items.slice().sort((a, b) => {
75
+ const bySeverity = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
76
+ if (bySeverity !== 0) return bySeverity;
77
+ if (a.path !== b.path) return a.path < b.path ? -1 : 1;
78
+ return a.line - b.line;
79
+ });
80
+ return { diagnostics: sorted.slice(0, cap), total: sorted.length, truncated: sorted.length > cap };
81
+ }
82
+
83
+ /**
84
+ * 造一个"快照 → 响应"的投影器。
85
+ *
86
+ * @param {(path: string) => boolean} isInWorkspace 该绝对路径是否落在某个工作区文件夹内
87
+ */
88
+ function createProjector(isInWorkspace) {
89
+ const inWorkspace = typeof isInWorkspace === 'function' ? isInWorkspace : () => true;
90
+
91
+ /**
92
+ * 只保留工作区内的诊断(raw 形态:`{uriPath, items: [{line, column, severity, message, source, code}]}`)。
93
+ */
94
+ function filterDiagnostics(rawDiagnostics) {
95
+ const out = [];
96
+ for (const group of Array.isArray(rawDiagnostics) ? rawDiagnostics : []) {
97
+ if (group === null || typeof group.path !== 'string' || group.path === '') continue;
98
+ if (!inWorkspace(group.path)) continue;
99
+ for (const item of Array.isArray(group.items) ? group.items : []) {
100
+ out.push(normalizeDiagnostic(Object.assign({ path: group.path }, item)));
101
+ }
102
+ }
103
+ return out;
104
+ }
105
+
106
+ /**
107
+ * 组 /diagnostics 的响应。
108
+ * @param {object} input `{diagnostics, file?, severity?, limit?}`
109
+ */
110
+ function diagnostics(input) {
111
+ let items = filterDiagnostics(input.diagnostics);
112
+ if (typeof input.file === 'string' && input.file !== '') {
113
+ if (input.file.startsWith('untitled:')) {
114
+ return { available: true, diagnostics: [], total: 0, truncated: false, note: '未保存的新文件没有磁盘路径,无法按文件查诊断' };
115
+ }
116
+ items = items.filter((item) => item.path === input.file);
117
+ }
118
+ if (typeof input.severity === 'string' && SEVERITY_RANK[input.severity] !== undefined) {
119
+ const floor = SEVERITY_RANK[input.severity];
120
+ items = items.filter((item) => SEVERITY_RANK[item.severity] <= floor);
121
+ }
122
+ const sorted = sortDiagnostics(items, input.limit);
123
+ return {
124
+ available: true,
125
+ diagnostics: sorted.diagnostics,
126
+ total: sorted.total,
127
+ truncated: sorted.truncated,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * 组 /context 的响应。
133
+ * @param {object} input `{active, documents, diagnostics}`
134
+ * - `active`: `null` 或 `{path, name, language, dirty, selection: {startLine, startColumn, endLine, endColumn}|null, selectedText?}`
135
+ * - `documents`: `[{path, name, dirty, unsavedLines, untitled}]`
136
+ * - `diagnostics`: 同 `diagnostics()` 的入参
137
+ */
138
+ function context(input) {
139
+ const notes = [];
140
+ const active = input.active === null || input.active === undefined
141
+ ? null
142
+ : (() => {
143
+ const item = {
144
+ path: input.active.path ?? null,
145
+ name: input.active.name ?? null,
146
+ language: input.active.language ?? null,
147
+ dirty: input.active.dirty === true,
148
+ selection: input.active.selection ?? null,
149
+ };
150
+ if (typeof input.active.selectedText === 'string' && input.active.selectedText !== '') {
151
+ const raw = input.active.selectedText;
152
+ item.selectedText = raw.length > MAX_SELECTION_CHARS ? `${raw.slice(0, MAX_SELECTION_CHARS)}\n…(已截断)` : raw;
153
+ if (raw.length > MAX_SELECTION_CHARS) notes.push('选中文本已截断');
154
+ }
155
+ return item;
156
+ })();
157
+
158
+ const dirtyBuffers = (Array.isArray(input.documents) ? input.documents : [])
159
+ .filter((doc) => doc !== null && doc.dirty === true)
160
+ .map((doc) => ({
161
+ path: doc.path ?? null,
162
+ name: doc.name ?? null,
163
+ unsavedLines: Number.isSafeInteger(doc.unsavedLines) ? doc.unsavedLines : null,
164
+ untitled: doc.untitled === true,
165
+ }));
166
+
167
+ // 问题面板按文件聚合(只算工作区内的):给模型一个"哪里有问题"的量级,细节按需再查。
168
+ const all = filterDiagnostics(input.diagnostics);
169
+ const byFile = new Map();
170
+ for (const item of all) {
171
+ const current = byFile.get(item.path);
172
+ if (current === undefined) byFile.set(item.path, item);
173
+ else if (SEVERITY_RANK[item.severity] < SEVERITY_RANK[current.severity]) byFile.set(item.path, item);
174
+ }
175
+ const problems = [...byFile.values()]
176
+ .sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || (a.path < b.path ? -1 : 1))
177
+ .slice(0, MAX_PROBLEM_SUMMARIES)
178
+ .map((item) => ({ path: item.path, line: item.line, severity: item.severity, message: item.message }));
179
+ if (byFile.size > MAX_PROBLEM_SUMMARIES) notes.push(`问题面板仅列出前 ${MAX_PROBLEM_SUMMARIES} 个文件(共 ${byFile.size} 个)`);
180
+
181
+ return {
182
+ available: true,
183
+ active,
184
+ dirtyBuffers,
185
+ problems,
186
+ diagnosticCount: all.length,
187
+ truncated: notes.join(';'),
188
+ };
189
+ }
190
+
191
+ return { context, diagnostics, filterDiagnostics };
192
+ }
193
+
194
+ module.exports = {
195
+ MAX_SELECTION_CHARS,
196
+ MAX_MESSAGE_CHARS,
197
+ MAX_DIAGNOSTICS,
198
+ MAX_PROBLEM_SUMMARIES,
199
+ SEVERITY_RANK,
200
+ severityName,
201
+ normalizeDiagnostic,
202
+ sortDiagnostics,
203
+ createProjector,
204
+ };
@@ -0,0 +1,123 @@
1
+ // dshcs-editor-bridge / lib/diff-model.js —— 纯逻辑:agent 改动 → 编辑器侧审阅决策
2
+ //
3
+ // **不 require('vscode')**:入参是纯文本,所以能单测。
4
+ //
5
+ // 端到端这条链路是这样走的(每一步都有理由,别随手简化):
6
+ //
7
+ // host: `ctx.on('tools/result')` 看到写类工具(exec.name + exec.arguments)
8
+ // → 推一条 {kind:'agent-edit', path} 进环形缓冲(只播"去看一眼",不播数据)
9
+ // ext : 轮询拿到事件
10
+ // → **立刻**取该文档当前文本作 old 侧:此时 VS Code 的磁盘 watcher 还没把新内容灌进缓冲区,
11
+ // 所以缓冲区里拿到的正是"改动前"的内容(时序敏感,不能先 await 别的)
12
+ // → 再用 workspace.fs 读磁盘作 new 侧
13
+ // → old === new ⇒ 不打扰用户(agent 写的和盘上一样,缓冲区本来就没差别)
14
+ // → 不同 ⇒ 开 diff tab;若该文档 isDirty,弹一条非模态告警(绝不自动覆盖用户的未保存改动)
15
+ //
16
+ // 为什么要缓存"上次见过的内容":文件没在编辑器里打开时缓冲区取不到 old 侧,只能靠上次轮询时
17
+ // 记下的内容。缓存有界(超出丢最旧),因为它的用途只是 diff,不是版本控制。
18
+
19
+ 'use strict';
20
+
21
+ /** 缓存条目上限(每个条目是一份完整文件文本 —— 不能无界)。 */
22
+ const CACHE_MAX = 64;
23
+
24
+ /**
25
+ * 判据:这次改动值不值得开 diff。
26
+ *
27
+ * 只有"文本确实不同"才值得。空文件 ↔ 空文件、或 agent 写回了一模一样的内容,都不该打扰用户。
28
+ *
29
+ * `added` / `removed` 在**只有一侧**时是 `null` 而不是猜出来的数字:没有 old 侧就无从知道
30
+ * "新增了几行"(新文件的所有行都是新增,但那只对新建文件成立;覆盖写不是)——
31
+ * 报个 0 或报个全文行数都会误导用户与模型。null 让调用方只显示"有变化"。
32
+ *
33
+ * @param {string|null} oldText 改动前(缓冲区/缓存)
34
+ * @param {string|null} newText 改动后(磁盘)
35
+ * @returns {{show: boolean, reason: string, added: number|null, removed: number|null}}
36
+ */
37
+ function describeChange(oldText, newText) {
38
+ const oldStr = typeof oldText === 'string' ? oldText : null;
39
+ const newStr = typeof newText === 'string' ? newText : null;
40
+ if (oldStr === null && newStr === null) return { show: false, reason: '两侧都不可读', added: null, removed: null };
41
+ if (oldStr === null) return { show: newStr !== '', reason: '没有改动前的内容', added: null, removed: null };
42
+ if (newStr === null) return { show: oldStr !== '', reason: '文件已被删除', added: null, removed: null };
43
+ if (oldStr === newStr) return { show: false, reason: '内容未变化', added: 0, removed: 0 };
44
+ const stats = lineStats(oldStr, newStr);
45
+ return { show: true, reason: '内容有变化', added: stats.added, removed: stats.removed };
46
+ }
47
+
48
+ /** 行数(空串算 0 行)。 */
49
+ function countLines(text) {
50
+ if (typeof text !== 'string' || text === '') return 0;
51
+ return text.split(/\r\n|\r|\n/).length;
52
+ }
53
+
54
+ /**
55
+ * 极简行级统计(不是完整 diff —— 只用来给用户一句"±N 行"的量级,完整 diff 由 VS Code 渲染)。
56
+ * 用"最长公共前后缀裁剪"来近似:裁剪后剩下的行数就是改动规模。
57
+ */
58
+ function lineStats(oldText, newText) {
59
+ const a = oldText.split(/\r\n|\r|\n/);
60
+ const b = newText.split(/\r\n|\r|\n/);
61
+ let head = 0;
62
+ while (head < a.length && head < b.length && a[head] === b[head]) head += 1;
63
+ let tail = 0;
64
+ while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail += 1;
65
+ return { removed: a.length - head - tail, added: b.length - head - tail };
66
+ }
67
+
68
+ /**
69
+ * 有界 LRU 文本缓存(键 = 绝对路径)。
70
+ */
71
+ function createDiffCache(max) {
72
+ const cap = Number.isSafeInteger(max) && max > 0 ? max : CACHE_MAX;
73
+ /** @type {Map<string, {text: string, at: number}>} */
74
+ const store = new Map();
75
+
76
+ function touch(key) {
77
+ const value = store.get(key);
78
+ if (value === undefined) return undefined;
79
+ store.delete(key);
80
+ store.set(key, value);
81
+ return value;
82
+ }
83
+
84
+ return {
85
+ /** 记下"现在这个文件长这样"。 */
86
+ remember(key, text, at) {
87
+ if (typeof key !== 'string' || key === '' || typeof text !== 'string') return false;
88
+ store.delete(key);
89
+ store.set(key, { text, at: Number.isFinite(at) ? at : 0 });
90
+ while (store.size > cap) {
91
+ const oldest = store.keys().next();
92
+ if (oldest.done === true) break;
93
+ store.delete(oldest.value);
94
+ }
95
+ return true;
96
+ },
97
+ /** 取上次见过的内容(命中会把它挪到最新)。 */
98
+ recall(key) {
99
+ const value = touch(key);
100
+ return value === undefined ? null : value.text;
101
+ },
102
+ has(key) {
103
+ return store.has(key);
104
+ },
105
+ get size() {
106
+ return store.size;
107
+ },
108
+ clear() {
109
+ store.clear();
110
+ },
111
+ keys() {
112
+ return [...store.keys()];
113
+ },
114
+ };
115
+ }
116
+
117
+ module.exports = {
118
+ CACHE_MAX,
119
+ describeChange,
120
+ countLines,
121
+ lineStats,
122
+ createDiffCache,
123
+ };
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "dshcs-editor-bridge",
3
+ "displayName": "DSH Editor Bridge",
4
+ "description": "只读地把编辑器状态(未保存缓冲区、诊断、活动选区)提供给 DSH,并让编辑器里的动作驱动 DSH 会话。由 dsh-code-server-app 插件安装,可禁用。",
5
+ "version": "0.1.0",
6
+ "publisher": "dsh-code-server-app",
7
+ "license": "MIT",
8
+ "private": true,
9
+ "engines": {
10
+ "vscode": "^1.90.0"
11
+ },
12
+ "categories": [
13
+ "Other"
14
+ ],
15
+ "activationEvents": [
16
+ "*"
17
+ ],
18
+ "main": "./extension.js",
19
+ "contributes": {
20
+ "commands": [
21
+ {
22
+ "command": "dsh-code-server.askAboutSelection",
23
+ "title": "DSH: 针对选中内容提问",
24
+ "category": "DSH"
25
+ },
26
+ {
27
+ "command": "dsh-code-server.askAboutFile",
28
+ "title": "DSH: 针对当前文件提问",
29
+ "category": "DSH"
30
+ },
31
+ {
32
+ "command": "dsh-code-server.showBridgeLog",
33
+ "title": "DSH: 显示编辑器桥日志",
34
+ "category": "DSH"
35
+ }
36
+ ],
37
+ "menus": {
38
+ "editor/context": [
39
+ {
40
+ "command": "dsh-code-server.askAboutSelection",
41
+ "when": "editorHasSelection",
42
+ "group": "dsh@1"
43
+ },
44
+ {
45
+ "command": "dsh-code-server.askAboutFile",
46
+ "group": "dsh@2"
47
+ }
48
+ ]
49
+ }
50
+ },
51
+ "capabilities": {
52
+ "untrustedWorkspaces": {
53
+ "supported": true,
54
+ "description": "本扩展只向本机 DSH 读取/上报编辑器状态,不修改工作区文件。"
55
+ },
56
+ "virtualWorkspaces": true
57
+ }
58
+ }
package/cordis.patch.yml CHANGED
@@ -30,7 +30,21 @@
30
30
  locale: ''
31
31
  # 启动就绪探测超时(ms)。
32
32
  readyTimeoutMs: 60000
33
- # 注:后台常驻(keepResident,默认 true)与认领范围(fileOpenScope,默认 session)由
33
+ #
34
+ # ---- 编辑器桥(0.3.0,默认开) ----
35
+ # 树内扩展 dshcs-editor-bridge 与 host 之间的**只读**通道(见 lib/bridge.mjs 顶部的
36
+ # 通道说明与安全不变量):
37
+ # agent 侧:editor_context / editor_diagnostics 两个工具,拿到未保存缓冲区、语言服务器
38
+ # 诊断、活动选区 —— 这些只有编辑器知道;
39
+ # 编辑器侧:「问 DSH」右键命令把选区投进当前会话;agent 改文件后开原生 diff 审阅,
40
+ # 缓冲区有未保存改动时只告警、绝不覆盖。
41
+ # 关掉它 = 不写 bridge.json、不注册工具、扩展休眠。也可在设置文档里改实时的
42
+ # `code-server.editorBridge`(改完即时生效,无需重启)。
43
+ # 已知范围:`serve: dsh`(管道模式,无独立端口)下不支持桥 → 自动禁用,不影响其它功能。
44
+ editorBridge: true
45
+ # 注:「认领类型」(claimExtensions,默认把 markdown/html/图片/PDF 留给 DSH 自带预览)与
46
+ # 「打开即全屏」(fullscreenOnOpen)、「后台常驻」(keepResident)由
34
47
  # "设置 → 插件 → Code Server"卡片控制,持久化于官方 settings 域(命名空间 code-server),
35
- # 卡片修改即时生效。服务方式(serve)没有卡片行:改这里的 config.serve 或设置文档。
48
+ # 卡片修改即时生效。服务方式(serve)与编辑器桥(editorBridge)没有卡片行:
49
+ # 改这里或设置文档(code-server.serve / code-server.editorBridge)。
36
50
  # 另:不再兼容旧版 DSH(无 sidebarRightTabs/sidebarRight 服务),旧版上仅给设置页提示。