dsh-vscode-mode 0.1.10 → 0.1.11
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 +12 -0
- package/lib/client.js +536 -53
- package/lib/client.js.map +1 -1
- package/lib/index.js +1003 -94
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/capture.ts +44 -8
- package/src/client/index.ts +11 -2
- package/src/client/state/records.ts +5 -2
- package/src/client/state/regions.ts +26 -20
- package/src/client/styles/mcp.css +87 -0
- package/src/client/ui/DiffBox.ts +1 -0
- package/src/client/ui/McpSettings.ts +233 -0
- package/src/index.ts +4 -2
- package/src/mcp.ts +123 -0
- package/src/mcpIsolation.ts +160 -0
- package/src/mcpProject.ts +258 -0
- package/src/model.ts +66 -34
- package/src/revert.ts +10 -3
- package/src/rpc.ts +44 -0
- package/src/shared/diff.ts +201 -0
- package/src/shared/mcp.ts +53 -0
- package/src/shared/rpc.ts +21 -0
- package/src/shared/types.ts +15 -0
- package/src/store.ts +29 -31
package/src/model.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* 作者 ddj 2026-08-20
|
|
5
5
|
*/
|
|
6
6
|
import type { Decision, DiffRecord, Hunk, RecordSummary } from './shared/types.js'
|
|
7
|
+
import { applyLocations, fingerprint, isNoopHunk, locateHunks, preciseHunk } from './shared/diff.js'
|
|
7
8
|
|
|
8
9
|
/** 工作区记录桶上限(超过后按 at 最旧剔除)。 */
|
|
9
10
|
export const MAX_RECORDS = 200
|
|
@@ -19,22 +20,46 @@ export function normalizeRecord(raw: unknown): DiffRecord | null {
|
|
|
19
20
|
const r = raw as Record<string, unknown>
|
|
20
21
|
if (typeof r.callId !== 'string' || typeof r.path !== 'string') return null
|
|
21
22
|
const hunks: Hunk[] = Array.isArray(r.hunks)
|
|
22
|
-
? (r.hunks as unknown[]).filter((h) => h && typeof (h as { newText?: unknown }).newText === 'string')
|
|
23
|
+
? (r.hunks as unknown[]).filter((h) => h && typeof (h as { newText?: unknown }).newText === 'string').map((h) => {
|
|
24
|
+
const item = h as Record<string, unknown>
|
|
25
|
+
return {
|
|
26
|
+
oldText: typeof item.oldText === 'string' ? item.oldText : null,
|
|
27
|
+
newText: item.newText as string,
|
|
28
|
+
...(Number.isInteger(item.afterStart) ? { afterStart: item.afterStart as number } : {}),
|
|
29
|
+
...(Number.isInteger(item.afterEnd) ? { afterEnd: item.afterEnd as number } : {}),
|
|
30
|
+
...(Number.isInteger(item.beforeStart) ? { beforeStart: item.beforeStart as number } : {}),
|
|
31
|
+
...(Number.isInteger(item.beforeEnd) ? { beforeEnd: item.beforeEnd as number } : {}),
|
|
32
|
+
}
|
|
33
|
+
})
|
|
23
34
|
: []
|
|
24
35
|
const toolName: DiffRecord['toolName'] = r.toolName === 'write' ? 'write' : 'edit'
|
|
36
|
+
const rawDecisions = r.decisions && typeof r.decisions === 'object' ? r.decisions as Record<string, unknown> : {}
|
|
37
|
+
const call = rawDecisions.call === 'accepted' || rawDecisions.call === 'rejected' ? rawDecisions.call : 'pending'
|
|
38
|
+
const rawPerHunk = Array.isArray(rawDecisions.perHunk) ? rawDecisions.perHunk : []
|
|
39
|
+
const perHunk = hunks.map((_, i) => {
|
|
40
|
+
const value = rawPerHunk[i]
|
|
41
|
+
if (value === 'accepted' || value === 'rejected') return value
|
|
42
|
+
return call === 'accepted' || call === 'rejected' ? call : 'pending'
|
|
43
|
+
})
|
|
44
|
+
const before = typeof r.before === 'string' ? r.before : null
|
|
45
|
+
const hasAfter = Object.prototype.hasOwnProperty.call(r, 'after')
|
|
46
|
+
const after = typeof r.after === 'string' ? r.after : null
|
|
25
47
|
return {
|
|
26
48
|
callId: r.callId,
|
|
27
49
|
toolName,
|
|
28
50
|
path: r.path,
|
|
29
|
-
before
|
|
51
|
+
before,
|
|
52
|
+
after,
|
|
53
|
+
baseFingerprint: typeof r.baseFingerprint === 'string' ? r.baseFingerprint : fingerprint(before),
|
|
54
|
+
afterFingerprint: typeof r.afterFingerprint === 'string' ? r.afterFingerprint : fingerprint(after),
|
|
55
|
+
legacy: !hasAfter,
|
|
56
|
+
conflict: r.conflict === true,
|
|
30
57
|
create: r.create === true,
|
|
31
|
-
callHunk: r.callHunk && typeof (r.callHunk as { oldText?: unknown }).oldText === 'string'
|
|
58
|
+
callHunk: r.callHunk && typeof (r.callHunk as { oldText?: unknown }).oldText === 'string' && typeof (r.callHunk as { newText?: unknown }).newText === 'string'
|
|
32
59
|
? { oldText: (r.callHunk as { oldText: string }).oldText, newText: (r.callHunk as { newText: string }).newText }
|
|
33
60
|
: null,
|
|
34
|
-
hunks
|
|
35
|
-
decisions:
|
|
36
|
-
? r.decisions as DiffRecord['decisions']
|
|
37
|
-
: { call: 'pending', perHunk: hunks.map(() => 'pending' as Decision) },
|
|
61
|
+
hunks,
|
|
62
|
+
decisions: { call: call as Decision, perHunk },
|
|
38
63
|
note: typeof r.note === 'string' ? r.note : null,
|
|
39
64
|
superseded: r.superseded === true,
|
|
40
65
|
archived: r.archived === true,
|
|
@@ -68,9 +93,17 @@ export function markDecision(record: DiffRecord, scope: string, idx: number | un
|
|
|
68
93
|
/** 记录是否已无任何待决策差异(superseded 或全部已决策)。 */
|
|
69
94
|
export function recordResolved(record: DiffRecord): boolean {
|
|
70
95
|
if (record.superseded === true) return true
|
|
96
|
+
const hunks = Array.isArray(record.hunks) ? record.hunks : []
|
|
71
97
|
const perHunk = Array.isArray(record.decisions.perHunk) ? record.decisions.perHunk : []
|
|
72
|
-
|
|
73
|
-
return record.decisions.call === 'accepted' || record.decisions.call === 'rejected'
|
|
98
|
+
const count = Math.max(hunks.length, perHunk.length)
|
|
99
|
+
if (!count) return record.decisions.call === 'accepted' || record.decisions.call === 'rejected'
|
|
100
|
+
for (let idx = 0; idx < count; idx++) {
|
|
101
|
+
const precise = preciseHunk(record, idx)
|
|
102
|
+
const value = perHunk[idx] ?? record.decisions.call
|
|
103
|
+
if (isNoopHunk(precise) && value === 'pending') return false
|
|
104
|
+
if (value !== 'accepted' && value !== 'rejected') return false
|
|
105
|
+
}
|
|
106
|
+
return true
|
|
74
107
|
}
|
|
75
108
|
|
|
76
109
|
/**
|
|
@@ -80,20 +113,20 @@ export function recordResolved(record: DiffRecord): boolean {
|
|
|
80
113
|
* @returns 摘要
|
|
81
114
|
*/
|
|
82
115
|
export function recSummary(record: DiffRecord): RecordSummary {
|
|
116
|
+
const hunks = Array.isArray(record.hunks) ? record.hunks : []
|
|
83
117
|
const perHunk = Array.isArray(record.decisions.perHunk) ? record.decisions.perHunk : []
|
|
84
|
-
const n = Math.max(1,
|
|
118
|
+
const n = Math.max(1, hunks.length, perHunk.length)
|
|
85
119
|
let accepted = 0
|
|
86
120
|
let rejected = 0
|
|
87
121
|
let pending = 0
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
else pending = n
|
|
122
|
+
for (let i = 0; i < n; i++) {
|
|
123
|
+
const hunk = preciseHunk(record, i)
|
|
124
|
+
const value = perHunk[i] ?? record.decisions.call
|
|
125
|
+
if (i < hunks.length && isNoopHunk(hunk) && value === 'pending') continue
|
|
126
|
+
if (value === 'accepted') accepted++
|
|
127
|
+
else if (value === 'rejected') rejected++
|
|
128
|
+
else pending++
|
|
129
|
+
}
|
|
97
130
|
return { accepted, rejected, pending, superseded: record.superseded === true }
|
|
98
131
|
}
|
|
99
132
|
|
|
@@ -106,27 +139,21 @@ export function recSummary(record: DiffRecord): RecordSummary {
|
|
|
106
139
|
* @returns 重建内容与 stale 定位
|
|
107
140
|
*/
|
|
108
141
|
export function reconstructOriginal(records: DiffRecord[], content: string): { content: string; stale: Array<{ callId: string; idx: number }> } {
|
|
109
|
-
const pending: Array<{ rec: DiffRecord; idx: number }> = []
|
|
142
|
+
const pending: Array<{ rec: DiffRecord; idx: number; hunk: Hunk }> = []
|
|
110
143
|
for (const rec of records) {
|
|
111
|
-
const perHunk = Array.isArray(rec.decisions.perHunk) ? rec.decisions.perHunk : []
|
|
112
144
|
for (let i = 0; i < rec.hunks.length; i++) {
|
|
113
|
-
const st =
|
|
114
|
-
|
|
145
|
+
const st = rec.decisions.perHunk[i] ?? rec.decisions.call
|
|
146
|
+
const hunk = preciseHunk(rec, i)
|
|
147
|
+
if (st === 'pending' && hunk && !isNoopHunk(hunk)) pending.push({ rec, idx: i, hunk })
|
|
115
148
|
}
|
|
116
149
|
}
|
|
117
150
|
pending.sort((a, b) => (a.rec.at < b.rec.at ? 1 : a.rec.at > b.rec.at ? -1 : 0))
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const newText = precise.newText ?? ''
|
|
124
|
-
const at = out.indexOf(newText)
|
|
125
|
-
if (at < 0) { stale.push({ callId: p.rec.callId, idx: p.idx }); continue }
|
|
126
|
-
const oldText = precise.oldText === null ? '' : precise.oldText
|
|
127
|
-
out = out.slice(0, at) + oldText + out.slice(at + newText.length)
|
|
151
|
+
const locations = locateHunks(content, pending.map((item) => item.hunk))
|
|
152
|
+
const rebuilt = applyLocations(content, locations, true)
|
|
153
|
+
return {
|
|
154
|
+
content: rebuilt.content,
|
|
155
|
+
stale: rebuilt.stale.map((idx) => ({ callId: pending[idx].rec.callId, idx: pending[idx].idx })),
|
|
128
156
|
}
|
|
129
|
-
return { content: out, stale }
|
|
130
157
|
}
|
|
131
158
|
|
|
132
159
|
/** 文件当前最大批次号(无记录为 0)。 */
|
|
@@ -188,6 +215,11 @@ export function archiveEntryFor(recs: DiffRecord[], cwd: string, reason: string)
|
|
|
188
215
|
decisions: r.decisions,
|
|
189
216
|
note: r.note ?? null,
|
|
190
217
|
before: typeof r.before === 'string' ? r.before : null,
|
|
218
|
+
after: typeof r.after === 'string' ? r.after : null,
|
|
219
|
+
baseFingerprint: r.baseFingerprint ?? null,
|
|
220
|
+
afterFingerprint: r.afterFingerprint ?? null,
|
|
221
|
+
conflict: r.conflict === true,
|
|
222
|
+
legacy: r.legacy === true,
|
|
191
223
|
superseded: r.superseded === true,
|
|
192
224
|
batch: Number.isInteger(r.batch) ? r.batch : null,
|
|
193
225
|
at: r.at,
|
package/src/revert.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { DiffRecord } from './shared/types.js'
|
|
7
7
|
import type { Ctx, Session } from './store.js'
|
|
8
8
|
import { policyOf, resolveTarget } from './store.js'
|
|
9
|
+
import { applyLocations, locateHunks, preciseHunk } from './shared/diff.js'
|
|
9
10
|
|
|
10
11
|
export type Result = { ok: true } | { ok: false; error: string }
|
|
11
12
|
|
|
@@ -71,12 +72,18 @@ export async function revertCall(ctx: Ctx, session: Session, record: DiffRecord)
|
|
|
71
72
|
export async function revertHunk(ctx: Ctx, session: Session, record: DiffRecord, idx: number): Promise<Result> {
|
|
72
73
|
const fs = ctx.get('fs')
|
|
73
74
|
if (!fs) return { ok: false, error: '回滚不可用:缺少 fs' }
|
|
74
|
-
const
|
|
75
|
-
const precise = record.toolName === 'edit' && record.hunks.length <= 1 && record.callHunk ? record.callHunk : hunk
|
|
75
|
+
const precise = preciseHunk(record, idx)
|
|
76
76
|
if (!precise) return { ok: false, error: '找不到该差异块' }
|
|
77
77
|
try {
|
|
78
78
|
const target = await resolveTarget(ctx, session, record.path)
|
|
79
|
-
await fs.
|
|
79
|
+
const info = await fs.stat(target)
|
|
80
|
+
if (!info || info.type !== 'file') return { ok: false, error: '回滚失败:文件不存在' }
|
|
81
|
+
if ((info.size ?? 0) > 8 * 1024 * 1024) return { ok: false, error: '回滚失败:文件过大,无法安全定位' }
|
|
82
|
+
const content = await fs.readText(target)
|
|
83
|
+
const location = locateHunks(content, [precise])[0]
|
|
84
|
+
if (!location?.matched) return { ok: false, error: '回滚失败:该区域可能已被后续修改影响' }
|
|
85
|
+
const result = applyLocations(content, [location], true)
|
|
86
|
+
await fs.writeText(target, result.content, void 0, void 0, policyOf(ctx, session))
|
|
80
87
|
return { ok: true }
|
|
81
88
|
} catch (error) {
|
|
82
89
|
return { ok: false, error: '回滚失败:该区域可能已被后续修改影响(' + String(error) + ')' }
|
package/src/rpc.ts
CHANGED
|
@@ -23,6 +23,8 @@ import type { Registry } from './registry.js'
|
|
|
23
23
|
import { bucketOf, cwdOf, sessionOf } from './registry.js'
|
|
24
24
|
import { listWorkspaceFiles } from './workspace.js'
|
|
25
25
|
import { restoreFile, revertCall, revertHunk } from './revert.js'
|
|
26
|
+
import { listMcp, refreshMcp, removeMcp, saveMcp, toggleMcp } from './mcp.js'
|
|
27
|
+
import { listProjects, projectRefresh, projectRemove, projectSave, projectToggle } from './mcpProject.js'
|
|
26
28
|
|
|
27
29
|
/** cwd → Promise 链:串行化 debug 日志追加(fs read+write 非原子,避免并发丢行)。 */
|
|
28
30
|
const debugWriteQueues = new Map<string, Promise<void>>()
|
|
@@ -40,6 +42,11 @@ function recView(record: DiffRecord): RecordView {
|
|
|
40
42
|
decisions: record.decisions,
|
|
41
43
|
note: record.note ?? null,
|
|
42
44
|
superseded: record.superseded === true,
|
|
45
|
+
after: record.after ?? null,
|
|
46
|
+
baseFingerprint: record.baseFingerprint ?? null,
|
|
47
|
+
afterFingerprint: record.afterFingerprint ?? null,
|
|
48
|
+
conflict: record.conflict === true,
|
|
49
|
+
legacy: record.legacy === true,
|
|
43
50
|
at: record.at,
|
|
44
51
|
}
|
|
45
52
|
}
|
|
@@ -275,6 +282,43 @@ export function buildHandlers(ctx: Ctx, registry: Registry): RpcHandlerMap {
|
|
|
275
282
|
}
|
|
276
283
|
return { ok: true, files, truncated: files.length >= 50 }
|
|
277
284
|
},
|
|
285
|
+
'mcp.list': async () => ({ ok: true, ...listMcp(ctx) }),
|
|
286
|
+
'mcp.save': async (args) => {
|
|
287
|
+
try { return { ok: true, server: await saveMcp(ctx, args.config) } }
|
|
288
|
+
catch (error) { return { ok: false, error: String(error) } }
|
|
289
|
+
},
|
|
290
|
+
'mcp.remove': async (args) => {
|
|
291
|
+
try { await removeMcp(ctx, args.id); return { ok: true } }
|
|
292
|
+
catch (error) { return { ok: false, error: String(error) } }
|
|
293
|
+
},
|
|
294
|
+
'mcp.toggle': async (args) => {
|
|
295
|
+
try { return { ok: true, server: await toggleMcp(ctx, args.id, args.enabled) } }
|
|
296
|
+
catch (error) { return { ok: false, error: String(error) } }
|
|
297
|
+
},
|
|
298
|
+
'mcp.refresh': async (args) => {
|
|
299
|
+
try { return { ok: true, server: await refreshMcp(ctx, args.id) } }
|
|
300
|
+
catch (error) { return { ok: false, error: String(error) } }
|
|
301
|
+
},
|
|
302
|
+
'mcp.projects': async () => {
|
|
303
|
+
try { return { ok: true, ...await listProjects(ctx) } }
|
|
304
|
+
catch (error) { return { ok: false, error: String(error) } }
|
|
305
|
+
},
|
|
306
|
+
'mcp.projectSave': async (args) => {
|
|
307
|
+
try { return { ok: true, project: await projectSave(ctx, args.workspacePath, args.serverName, args.config) } }
|
|
308
|
+
catch (error) { return { ok: false, error: String(error) } }
|
|
309
|
+
},
|
|
310
|
+
'mcp.projectRemove': async (args) => {
|
|
311
|
+
try { return { ok: true, project: await projectRemove(ctx, args.workspacePath, args.serverName) } }
|
|
312
|
+
catch (error) { return { ok: false, error: String(error) } }
|
|
313
|
+
},
|
|
314
|
+
'mcp.projectToggle': async (args) => {
|
|
315
|
+
try { return { ok: true, project: await projectToggle(ctx, args.workspacePath, args.serverName, args.enabled) } }
|
|
316
|
+
catch (error) { return { ok: false, error: String(error) } }
|
|
317
|
+
},
|
|
318
|
+
'mcp.projectRefresh': async (args) => {
|
|
319
|
+
try { return { ok: true, project: await projectRefresh(ctx, args.workspacePath, args.serverName) } }
|
|
320
|
+
catch (error) { return { ok: false, error: String(error) } }
|
|
321
|
+
},
|
|
278
322
|
}
|
|
279
323
|
}
|
|
280
324
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-vscode-mode shared diff primitives — 快照指纹、hunk 规范化与安全定位。
|
|
3
|
+
* 不依赖 Node/React,host 与 browser 共用,避免各层用不同 indexOf 语义。
|
|
4
|
+
* 作者 ddj 2026-08-24
|
|
5
|
+
*/
|
|
6
|
+
import type { Hunk, RecordBase } from './types.js'
|
|
7
|
+
|
|
8
|
+
/** 文件读取状态,区分真实空文件与无法读取/过大的文件。 */
|
|
9
|
+
export type ReadState =
|
|
10
|
+
| { kind: 'content'; content: string }
|
|
11
|
+
| { kind: 'missing' }
|
|
12
|
+
| { kind: 'unavailable' }
|
|
13
|
+
|
|
14
|
+
/** 文本中的一个半开区间 [start,end)。 */
|
|
15
|
+
export interface TextRange {
|
|
16
|
+
start: number
|
|
17
|
+
end: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 一条 hunk 在当前内容中的定位结果。 */
|
|
21
|
+
export interface HunkLocation extends TextRange {
|
|
22
|
+
idx: number
|
|
23
|
+
hunk: Hunk
|
|
24
|
+
matched: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 统一取对应 hunk;callHunk 只用于旧记录缺失 hunks 时兼容。 */
|
|
28
|
+
export function preciseHunk(record: Pick<RecordBase, 'hunks' | 'callHunk'>, idx: number): Hunk | null {
|
|
29
|
+
return hunkOf(record, idx)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 轻量、确定性的字符串指纹;不引入 Node crypto。 */
|
|
33
|
+
export function fingerprint(text: string | null | undefined): string | null {
|
|
34
|
+
if (typeof text !== 'string') return null
|
|
35
|
+
let hash = 2166136261
|
|
36
|
+
for (let i = 0; i < text.length; i++) {
|
|
37
|
+
hash ^= text.charCodeAt(i)
|
|
38
|
+
hash = Math.imul(hash, 16777619)
|
|
39
|
+
}
|
|
40
|
+
return 'fnv1a:' + (hash >>> 0).toString(16) + ':' + text.length
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 取记录第 idx 个有效 hunk;只有记录缺 hunk 时才回退 callHunk。 */
|
|
44
|
+
export function hunkOf(record: Pick<RecordBase, 'hunks' | 'callHunk'>, idx: number): Hunk | null {
|
|
45
|
+
const hunk = Array.isArray(record.hunks) ? record.hunks[idx] : undefined
|
|
46
|
+
if (hunk && typeof hunk.newText === 'string') return hunk
|
|
47
|
+
if (idx === 0 && record.callHunk && typeof record.callHunk.newText === 'string') return record.callHunk
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** hunk 是否表示没有实际内容变化。 */
|
|
52
|
+
export function isNoopHunk(hunk: Hunk | null | undefined): boolean {
|
|
53
|
+
return !!hunk && hunk.oldText !== null && hunk.oldText === hunk.newText
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** splitLines 的空文本语义:真实空文件没有一行变更内容。 */
|
|
57
|
+
export function splitLines(text: string): string[] {
|
|
58
|
+
return text.length ? text.split('\n') : []
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 为执行后 hunk 写入快照坐标;空 newText 通过 before/after 公共边界定位删除点。 */
|
|
62
|
+
export function annotateHunk(hunk: Hunk, before: string | null, after: string | null): Hunk {
|
|
63
|
+
return annotateHunks([hunk], before, after)[0]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 按文件顺序为多 hunk 写入不重复的 after 坐标。 */
|
|
67
|
+
export function annotateHunks(hunks: Hunk[], before: string | null, after: string | null): Hunk[] {
|
|
68
|
+
if (typeof after !== 'string') return hunks
|
|
69
|
+
let cursor = 0
|
|
70
|
+
return hunks.map((hunk) => {
|
|
71
|
+
if (hunk.newText.length > 0) {
|
|
72
|
+
const start = after.indexOf(hunk.newText, cursor)
|
|
73
|
+
if (start >= 0) {
|
|
74
|
+
cursor = start + hunk.newText.length
|
|
75
|
+
return { ...hunk, afterStart: start, afterEnd: cursor }
|
|
76
|
+
}
|
|
77
|
+
return hunk
|
|
78
|
+
}
|
|
79
|
+
if (hunks.length !== 1 || typeof before !== 'string' || before === after || hunk.oldText === null) return hunk
|
|
80
|
+
const prefix = commonPrefix(before, after)
|
|
81
|
+
const suffix = commonSuffix(before, after, prefix)
|
|
82
|
+
const removedStart = prefix
|
|
83
|
+
const removedEnd = Math.max(removedStart, before.length - suffix)
|
|
84
|
+
return { ...hunk, afterStart: removedStart, afterEnd: removedStart, beforeStart: removedStart, beforeEnd: removedEnd }
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 计算两个文本的最长公共前缀长度。 */
|
|
89
|
+
function commonPrefix(left: string, right: string): number {
|
|
90
|
+
const limit = Math.min(left.length, right.length)
|
|
91
|
+
let i = 0
|
|
92
|
+
while (i < limit && left.charCodeAt(i) === right.charCodeAt(i)) i++
|
|
93
|
+
return i
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 计算不与 prefix 重叠的最长公共后缀长度。 */
|
|
97
|
+
function commonSuffix(left: string, right: string, prefix: number): number {
|
|
98
|
+
const limit = Math.min(left.length, right.length) - prefix
|
|
99
|
+
let i = 0
|
|
100
|
+
while (i < limit && left.charCodeAt(left.length - 1 - i) === right.charCodeAt(right.length - 1 - i)) i++
|
|
101
|
+
return i
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 计算 index 前的 0 基行数。 */
|
|
105
|
+
export function lineBefore(text: string, index: number): number {
|
|
106
|
+
let n = 0
|
|
107
|
+
const end = Math.max(0, Math.min(index, text.length))
|
|
108
|
+
for (let i = 0; i < end; i++) if (text.charCodeAt(i) === 10) n++
|
|
109
|
+
return n
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 找出文本的全部非重叠出现位置。 */
|
|
113
|
+
function allMatches(content: string, needle: string): TextRange[] {
|
|
114
|
+
if (!needle.length) return []
|
|
115
|
+
const out: TextRange[] = []
|
|
116
|
+
let from = 0
|
|
117
|
+
while (from <= content.length - needle.length) {
|
|
118
|
+
const at = content.indexOf(needle, from)
|
|
119
|
+
if (at < 0) break
|
|
120
|
+
out.push({ start: at, end: at + needle.length })
|
|
121
|
+
from = at + Math.max(1, needle.length)
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 从 after 快照定位所有 hunk,按 hunk 顺序优先选未占用且靠近前一块的候选。
|
|
128
|
+
* 若 hunk 带 afterStart/afterEnd,则优先验证快照坐标,避免重复文本错配。
|
|
129
|
+
* @author ddj 2026年08月24号
|
|
130
|
+
* @param content 当前文件内容
|
|
131
|
+
* @param hunks 待定位 hunk
|
|
132
|
+
* @returns 每个 hunk 一个结果,matched=false 表示冲突/stale
|
|
133
|
+
*/
|
|
134
|
+
export function locateHunks(content: string, hunks: Hunk[]): HunkLocation[] {
|
|
135
|
+
const locations: HunkLocation[] = []
|
|
136
|
+
let cursor = 0
|
|
137
|
+
const occupied: TextRange[] = []
|
|
138
|
+
for (let idx = 0; idx < hunks.length; idx++) {
|
|
139
|
+
const hunk = hunks[idx]
|
|
140
|
+
const newText = hunk.newText
|
|
141
|
+
const snapshot = Number.isInteger(hunk.afterStart) && Number.isInteger(hunk.afterEnd)
|
|
142
|
+
? { start: hunk.afterStart as number, end: hunk.afterEnd as number }
|
|
143
|
+
: null
|
|
144
|
+
const snapshotMatches = snapshot && snapshot.start >= 0 && snapshot.end >= snapshot.start
|
|
145
|
+
&& (newText.length === 0 || content.slice(snapshot.start, snapshot.end) === newText)
|
|
146
|
+
? snapshot
|
|
147
|
+
: null
|
|
148
|
+
const candidates = snapshotMatches ? [snapshotMatches] : allMatches(content, newText)
|
|
149
|
+
const candidate = candidates.find((range) => range.start >= cursor && !occupied.some((used) => overlaps(range, used)))
|
|
150
|
+
?? candidates.find((range) => !occupied.some((used) => overlaps(range, used)))
|
|
151
|
+
if (candidate) {
|
|
152
|
+
locations.push({ idx, hunk, ...candidate, matched: true })
|
|
153
|
+
occupied.push(candidate)
|
|
154
|
+
cursor = Math.max(cursor, candidate.end)
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (newText.length === 0 && hunk.afterStart === undefined && hunk.afterEnd === undefined && content.length === 0) {
|
|
158
|
+
const point = { start: 0, end: 0 }
|
|
159
|
+
locations.push({ idx, hunk, ...point, matched: true })
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
if (newText.length === 0 && hunk.afterStart !== undefined && hunk.afterEnd !== undefined
|
|
163
|
+
&& hunk.afterStart === hunk.afterEnd && hunk.afterStart >= 0 && hunk.afterStart <= content.length) {
|
|
164
|
+
const point = { start: hunk.afterStart, end: hunk.afterEnd }
|
|
165
|
+
if (!occupied.some((used) => overlaps(point, used))) {
|
|
166
|
+
locations.push({ idx, hunk, ...point, matched: true })
|
|
167
|
+
occupied.push(point)
|
|
168
|
+
cursor = Math.max(cursor, point.end)
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
locations.push({ idx, hunk, start: -1, end: -1, matched: false })
|
|
173
|
+
}
|
|
174
|
+
return locations
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 判断两个半开区间是否重叠。零长度区间只与同一位置的零长度区间重叠。 */
|
|
178
|
+
function overlaps(left: TextRange, right: TextRange): boolean {
|
|
179
|
+
if (left.start === left.end || right.start === right.end) return left.start === right.start && left.end === right.end
|
|
180
|
+
return left.start < right.end && right.start < left.end
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 在可定位区间上执行一次 hunk 反向替换。 */
|
|
184
|
+
export function replaceRange(content: string, location: HunkLocation, reverse = false): string | null {
|
|
185
|
+
if (!location.matched || location.start < 0) return null
|
|
186
|
+
const replacement = reverse ? (location.hunk.oldText ?? '') : location.hunk.newText
|
|
187
|
+
return content.slice(0, location.start) + replacement + content.slice(location.end)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 按当前内容中出现的位置从后向前应用 hunk,避免前块长度变化影响后块。 */
|
|
191
|
+
export function applyLocations(content: string, locations: HunkLocation[], reverse = false): { content: string; stale: number[] } {
|
|
192
|
+
const stale = locations.filter((location) => !location.matched).map((location) => location.idx)
|
|
193
|
+
let out = content
|
|
194
|
+
const matched = locations.filter((location) => location.matched).sort((a, b) => b.start - a.start)
|
|
195
|
+
for (const location of matched) {
|
|
196
|
+
const next = replaceRange(out, location, reverse)
|
|
197
|
+
if (next === null) stale.push(location.idx)
|
|
198
|
+
else out = next
|
|
199
|
+
}
|
|
200
|
+
return { content: out, stale: stale.sort((a, b) => a - b) }
|
|
201
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** dsh-vscode-mode MCP 管理共享数据契约。作者 ddj 2026年08月22号 */
|
|
2
|
+
|
|
3
|
+
export type MpcTransport = 'stdio' | 'streamable-http'
|
|
4
|
+
export type MpcStatus = 'connected' | 'connecting' | 'error' | 'disabled'
|
|
5
|
+
|
|
6
|
+
export interface MpcTool {
|
|
7
|
+
name: string
|
|
8
|
+
description?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MpcConfig {
|
|
12
|
+
serverName: string
|
|
13
|
+
transport: MpcTransport
|
|
14
|
+
command?: string
|
|
15
|
+
args?: string[]
|
|
16
|
+
cwd?: string
|
|
17
|
+
env?: Record<string, string>
|
|
18
|
+
url?: string
|
|
19
|
+
headers?: Record<string, string>
|
|
20
|
+
toolCallTimeoutMs?: number
|
|
21
|
+
failOnStartupError?: boolean
|
|
22
|
+
reconnect?: Record<string, unknown>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface MpcServer {
|
|
26
|
+
id: string
|
|
27
|
+
serverName: string
|
|
28
|
+
enabled: boolean
|
|
29
|
+
transport: MpcTransport
|
|
30
|
+
config: MpcConfig
|
|
31
|
+
status: MpcStatus
|
|
32
|
+
toolCount: number
|
|
33
|
+
tools: MpcTool[]
|
|
34
|
+
error?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 项目级 MCP:绑定到一个 workspace 路径,配置存于该项目根 .mcp.json。 */
|
|
38
|
+
export interface MpcProject {
|
|
39
|
+
workspacePath: string
|
|
40
|
+
title: string
|
|
41
|
+
servers: MpcServer[]
|
|
42
|
+
source: 'project'
|
|
43
|
+
missingDir?: boolean
|
|
44
|
+
/** .mcp.json 解析失败时携带错误文案(不覆盖文件)。 */
|
|
45
|
+
fileError?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 项目级 MCP 保存入参:config 为该 serverName 的传输配置(不含归属信息)。 */
|
|
49
|
+
export interface MpcProjectSaveInput {
|
|
50
|
+
workspacePath: string
|
|
51
|
+
serverName: string
|
|
52
|
+
config: MpcConfig
|
|
53
|
+
}
|
package/src/shared/rpc.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
RecordView,
|
|
13
13
|
StaleHunk,
|
|
14
14
|
} from './types.js'
|
|
15
|
+
import type { MpcConfig, MpcProject, MpcProjectSaveInput, MpcServer } from './mcp.js'
|
|
15
16
|
|
|
16
17
|
/** webServer 精确路由。 */
|
|
17
18
|
export const RPC_PATH = '/edrv/rpc'
|
|
@@ -32,6 +33,16 @@ export interface RpcRequestMap {
|
|
|
32
33
|
'edrv.rollback': { sessionId?: string; path: string; batch?: number }
|
|
33
34
|
'edrv.debug': { sessionId?: string; text: string }
|
|
34
35
|
'edrv.searchFiles': { sessionId?: string; query: string }
|
|
36
|
+
'mcp.list': {}
|
|
37
|
+
'mcp.save': { config: MpcConfig }
|
|
38
|
+
'mcp.remove': { id: string }
|
|
39
|
+
'mcp.toggle': { id: string; enabled: boolean }
|
|
40
|
+
'mcp.refresh': { id: string }
|
|
41
|
+
'mcp.projects': {}
|
|
42
|
+
'mcp.projectSave': MpcProjectSaveInput
|
|
43
|
+
'mcp.projectRemove': { workspacePath: string; serverName: string }
|
|
44
|
+
'mcp.projectToggle': { workspacePath: string; serverName: string; enabled: boolean }
|
|
45
|
+
'mcp.projectRefresh': { workspacePath: string; serverName: string }
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
export type RpcMethod = keyof RpcRequestMap
|
|
@@ -49,6 +60,16 @@ export interface RpcOkMap {
|
|
|
49
60
|
'edrv.rollback': { path: string; batch: number | null }
|
|
50
61
|
'edrv.debug': object
|
|
51
62
|
'edrv.searchFiles': { files: string[]; truncated: boolean }
|
|
63
|
+
'mcp.list': { servers: MpcServer[] }
|
|
64
|
+
'mcp.save': { server: MpcServer }
|
|
65
|
+
'mcp.remove': object
|
|
66
|
+
'mcp.toggle': { server: MpcServer }
|
|
67
|
+
'mcp.refresh': { server: MpcServer }
|
|
68
|
+
'mcp.projects': { projects: MpcProject[] }
|
|
69
|
+
'mcp.projectSave': { project: MpcProject }
|
|
70
|
+
'mcp.projectRemove': { project: MpcProject }
|
|
71
|
+
'mcp.projectToggle': { project: MpcProject }
|
|
72
|
+
'mcp.projectRefresh': { project: MpcProject }
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
/** 统一响应:{ok:true, ...payload} 或 {ok:false, error}。 */
|
package/src/shared/types.ts
CHANGED
|
@@ -15,6 +15,12 @@ export type Decision = 'pending' | 'accepted' | 'rejected'
|
|
|
15
15
|
export interface Hunk {
|
|
16
16
|
oldText: string | null
|
|
17
17
|
newText: string
|
|
18
|
+
/** 执行后快照中的精确范围(可选,旧记录没有)。 */
|
|
19
|
+
afterStart?: number
|
|
20
|
+
afterEnd?: number
|
|
21
|
+
/** 执行前快照中的精确范围(可选,旧记录没有)。 */
|
|
22
|
+
beforeStart?: number
|
|
23
|
+
beforeEnd?: number
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
/** 单条 edit 调用的精确 old/new 串(edit 工具参数原样)。 */
|
|
@@ -41,6 +47,15 @@ export interface RecordBase {
|
|
|
41
47
|
note: string | null
|
|
42
48
|
superseded: boolean
|
|
43
49
|
at: string
|
|
50
|
+
/** 完整执行后快照;旧 sidecar 缺失时为 undefined。 */
|
|
51
|
+
after?: string | null
|
|
52
|
+
/** 执行前/后快照指纹,用于保守冲突判断。 */
|
|
53
|
+
baseFingerprint?: string | null
|
|
54
|
+
afterFingerprint?: string | null
|
|
55
|
+
/** 当前磁盘内容无法安全匹配时保留记录并标记冲突。 */
|
|
56
|
+
conflict?: boolean
|
|
57
|
+
/** 记录来自没有快照字段的旧 sidecar。 */
|
|
58
|
+
legacy?: boolean
|
|
44
59
|
}
|
|
45
60
|
|
|
46
61
|
/** host 内部完整记录(含持久化专用字段)。 */
|