dsh-vscode-mode 0.1.7 → 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.
@@ -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 内部完整记录(含持久化专用字段)。 */
package/src/store.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * 作者 ddj 2026-08-20
5
5
  */
6
6
  import type { ArchiveBatch, ArchiveData, DiffRecord, SidecarData } from './shared/types.js'
7
+ import type { ReadState } from './shared/diff.js'
8
+ import { fingerprint, isNoopHunk, locateHunks, preciseHunk } from './shared/diff.js'
7
9
  import { archiveEntryFor, groupByBatch, normalizeRecord } from './model.js'
8
10
 
9
11
  export const SIDECAR = '.dsh-edit-review.json'
@@ -105,21 +107,23 @@ export async function saveBucket(ctx: Ctx, cwd: string, recsMap: Map<string, Dif
105
107
  * @author ddj 2026年08月20号
106
108
  * @param cache path → 内容(null=缺失/读取失败,''=过大跳过)
107
109
  */
108
- export async function readForCache(ctx: Ctx, session: Session, cache: Map<string, string | null>, path: string): Promise<string | null> {
109
- if (cache.has(path)) return cache.get(path) ?? null
110
+ export async function readForCache(ctx: Ctx, session: Session, cache: Map<string, ReadState>, path: string): Promise<ReadState> {
111
+ const cached = cache.get(path)
112
+ if (cached) return cached
110
113
  const fs = ctx.get('fs')
111
- if (!fs) { cache.set(path, null); return null }
114
+ if (!fs) { const state: ReadState = { kind: 'unavailable' }; cache.set(path, state); return state }
112
115
  try {
113
116
  const target = await resolveTarget(ctx, session, path)
114
117
  const info = await fs.stat(target)
115
- if (!info || info.type !== 'file') { cache.set(path, null); return null }
116
- if ((info.size ?? 0) > READ_CAP) { cache.set(path, ''); return '' }
117
- const content = await fs.readText(target)
118
- cache.set(path, content)
119
- return content
118
+ if (!info || info.type !== 'file') { const state: ReadState = { kind: 'missing' }; cache.set(path, state); return state }
119
+ if ((info.size ?? 0) > READ_CAP) { const state: ReadState = { kind: 'unavailable' }; cache.set(path, state); return state }
120
+ const state: ReadState = { kind: 'content', content: await fs.readText(target) }
121
+ cache.set(path, state)
122
+ return state
120
123
  } catch (error) {
121
- cache.set(path, null)
122
- return null
124
+ const state: ReadState = { kind: 'unavailable' }
125
+ cache.set(path, state)
126
+ return state
123
127
  }
124
128
  }
125
129
 
@@ -128,28 +132,22 @@ export async function readForCache(ctx: Ctx, session: Session, cache: Map<string
128
132
  * 或新建文件已不存在,或全为空差异。满足则应由 host 自动归档。
129
133
  * @author ddj 2026年08月20号
130
134
  */
131
- export async function recordIsStale(ctx: Ctx, session: Session, cache: Map<string, string | null>, rec: DiffRecord): Promise<boolean> {
135
+ export async function recordIsStale(ctx: Ctx, session: Session, cache: Map<string, ReadState>, rec: DiffRecord): Promise<boolean> {
132
136
  if (rec.superseded === true) return true
133
137
  if (!Array.isArray(rec.hunks) || !rec.hunks.length) return true
134
- const perHunk = Array.isArray(rec.decisions.perHunk) ? rec.decisions.perHunk : []
135
- let hasLocatablePending = false
136
- for (let i = 0; i < rec.hunks.length; i++) {
137
- const st = perHunk.length ? perHunk[i] : rec.decisions.call
138
- if (st === 'accepted' || st === 'rejected') continue
139
- if (rec.create === true) {
140
- const content = await readForCache(ctx, session, cache, rec.path)
141
- if (content !== null) { hasLocatablePending = true; break }
142
- continue
143
- }
144
- const precise = rec.toolName === 'edit' && rec.callHunk ? rec.callHunk : rec.hunks[i]
145
- const oldText = precise?.oldText ?? null
146
- const newText = precise?.newText ?? null
147
- if (oldText !== null && oldText === newText) continue
148
- if (newText === null || newText === undefined) continue
149
- const content = await readForCache(ctx, session, cache, rec.path)
150
- if (content !== null && content.indexOf(newText) >= 0) { hasLocatablePending = true; break }
151
- }
152
- return !hasLocatablePending
138
+ const state = await readForCache(ctx, session, cache, rec.path)
139
+ if (state.kind === 'unavailable') return false
140
+ if (state.kind === 'missing') return true
141
+ if (rec.create === true) return false
142
+ const pending = rec.hunks.map((_, idx) => ({ idx, hunk: preciseHunk(rec, idx), status: rec.decisions.perHunk[idx] ?? rec.decisions.call }))
143
+ .filter((item) => item.status === 'pending' && item.hunk && !isNoopHunk(item.hunk))
144
+ if (!pending.length) return true
145
+ const currentFingerprint = fingerprint(state.content)
146
+ if (rec.afterFingerprint && currentFingerprint === rec.afterFingerprint) { rec.conflict = false; return false }
147
+ const locations = locateHunks(state.content, pending.map((item) => item.hunk!))
148
+ if (locations.some((location) => location.matched)) { rec.conflict = false; return false }
149
+ rec.conflict = true
150
+ return false
153
151
  }
154
152
 
155
153
  /**
@@ -161,7 +159,7 @@ export async function recordIsStale(ctx: Ctx, session: Session, cache: Map<strin
161
159
  export async function autoArchiveStale(ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>): Promise<number> {
162
160
  const fs = ctx.get('fs')
163
161
  if (!fs) return 0
164
- const cache = new Map<string, string | null>()
162
+ const cache = new Map<string, ReadState>()
165
163
  const stale: DiffRecord[] = []
166
164
  for (const rec of bucket.values()) {
167
165
  if (rec.archived || rec.superseded === true) continue