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/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') as Hunk[]
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: typeof r.before === 'string' ? r.before : null,
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: hunks.map((h) => ({ oldText: typeof h.oldText === 'string' ? h.oldText : null, newText: h.newText })),
35
- decisions: r.decisions && typeof r.decisions === 'object'
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
- if (perHunk.length) return perHunk.every((v) => v === 'accepted' || v === 'rejected')
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, Array.isArray(record.hunks) ? record.hunks.length : 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
- if (perHunk.length) {
89
- for (const v of perHunk) {
90
- if (v === 'accepted') accepted++
91
- else if (v === 'rejected') rejected++
92
- else pending++
93
- }
94
- } else if (record.decisions.call === 'accepted') accepted = n
95
- else if (record.decisions.call === 'rejected') rejected = n
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 = perHunk.length ? perHunk[i] : rec.decisions.call
114
- if (st === 'pending') pending.push({ rec, idx: i })
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
- let out = content
119
- const stale: Array<{ callId: string; idx: number }> = []
120
- for (const p of pending) {
121
- const h = p.rec.hunks[p.idx]
122
- const precise = p.rec.toolName === 'edit' && p.rec.hunks.length <= 1 && p.rec.callHunk ? p.rec.callHunk : h
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 hunk = record.hunks[idx]
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.editText(target, { oldString: precise.newText, newString: precise.oldText === null ? '' : precise.oldText }, void 0, void 0, policyOf(ctx, session))
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}。 */
@@ -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 内部完整记录(含持久化专用字段)。 */