dsh-vscode-mode 0.4.2 → 0.4.4

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,167 @@
1
+ /**
2
+ * dsh-vscode-mode host — 文件磁盘新鲜度观察(外部改动检测的 host 半)。
3
+ *
4
+ * 用途:客户端(编辑区)在无法感知外部写盘的前提下会把陈旧缓冲当权威内容,
5
+ * 甚至在防抖自动保存时覆盖外部改动。本模块为客户端提供轻量批查:给一批路径
6
+ * 返回当前磁盘版本令牌(ctx.fs.stat 的 version)+ 类型,客户端与本地基线比对后
7
+ * 决定是否重载/提示/拒绝保存。
8
+ *
9
+ * 附带职责(文件树同步):对每次都变化的路径调用 treeIndex.invalidateIndex,
10
+ * 使「外部新增/删除文件」不再只依赖 60s TTL——已展开目录在下次展开时即为最新。
11
+ *
12
+ * 设计取舍:
13
+ * - 不注册 fs.watch:句柄占用、网络盘/长路径兼容性差且难单测;批 stat 由系统
14
+ * 缓存兜底,1–20 条/轮的代价可忽略,因此观察逻辑全部落在本模块且可确定性测试。
15
+ * - 基准版本表(path → version)只用于「是否变化」判定,不参与客户端语义;
16
+ * 每条记忆带 TTL(MEM_TTL_MS),超过即视为陈旧重新记基准;总量有上限兜底。
17
+ * - 单条失败不影响整批:解析失败/不存在以 type='missing' 返回,响应始终 ok。
18
+ *
19
+ * 作者 ddj 2026-09-15
20
+ */
21
+ import type { FileVersionItem } from './shared/rpc.js'
22
+ import type { Ctx } from './store.js'
23
+ import { cwdOf, sessionOf } from './registry.js'
24
+ import { invalidateIndex } from './treeIndex.js'
25
+ import { log } from './log.js'
26
+
27
+ // --region 常量
28
+
29
+ /** 基准版本记忆上限:超出按写入序逐出最旧(防长时间运行无界增长)。 */
30
+ export const MEM_MAX = 500
31
+ /** 基准版本记忆新鲜期:超过即视为陈旧(重记基准,不报一次假变化)。 */
32
+ export const MEM_TTL_MS = 5 * 60_000
33
+ /** 批量路径上限:超出只处理前 N 条(客户端只发已打开页签,正常远小于此值)。 */
34
+ export const MAX_PATHS = 200
35
+
36
+ // --endregion
37
+
38
+ // --region 类型
39
+
40
+ /** 版本观察器实例。 */
41
+ export interface FileVersions {
42
+ /**
43
+ * 批查路径的磁盘版本。
44
+ * @param args 会话 id 与路径列表
45
+ */
46
+ versions(args: { sessionId?: string; paths: string[] }): Promise<{ ok: true; items: FileVersionItem[] }>
47
+ /** 卸载收尾:清空基准表(会话销毁/插件卸载调用)。 */
48
+ dispose(): void
49
+ }
50
+
51
+ /** 一条基准记忆(版本 + 记录时间)。 */
52
+ interface Memo {
53
+ version: string
54
+ at: number
55
+ }
56
+
57
+ // --endregion
58
+
59
+ // --region 基准表
60
+
61
+ /** 基准表键:工作区根 + 归一化路径。 */
62
+ function memoKey(cwd: string, path: string): string {
63
+ return String(cwd).replace(/\\/g, '/') + '\u0000' + String(path).replace(/\\/g, '/')
64
+ }
65
+
66
+ /** 清理超期/超量记忆(每次写入前调用,摊还 O(1))。 */
67
+ function sweep(memos: Map<string, Memo>, now: number): void {
68
+ if (memos.size === 0) return
69
+ for (const [key, memo] of memos) {
70
+ if (now - memo.at > MEM_TTL_MS) memos.delete(key)
71
+ }
72
+ while (memos.size > MEM_MAX) {
73
+ const oldest = memos.keys().next().value
74
+ if (oldest === undefined) break
75
+ memos.delete(oldest)
76
+ }
77
+ }
78
+
79
+ // --endregion
80
+
81
+ // --region 观察器
82
+
83
+ /**
84
+ * 创建文件版本观察器(host 单例,随插件装配创建、卸载 dispose)。
85
+ * @author ddj 2026年09月15号
86
+ * @param ctx DSH host 上下文
87
+ * @returns 观察器实例
88
+ */
89
+ export function createFileVersions(ctx: Ctx): FileVersions {
90
+ /** 基准表:memoKey → 上次观察到的版本(仅用于变化判定与树失效)。 */
91
+ const memos = new Map<string, Memo>()
92
+
93
+ /**
94
+ * 记录一次观察并按需失效目录树;返回本次是否为「变化」。
95
+ * @author ddj 2026年09月15号
96
+ * @param cwd 工作区根
97
+ * @param path 请求路径
98
+ * @param version 当前版本令牌(空串 = 后端不提供)
99
+ * @param present 磁盘上是否存在
100
+ * @returns 是否检测到变化
101
+ */
102
+ const syncTree = (cwd: string, path: string, version: string, present: boolean): boolean => {
103
+ const now = Date.now()
104
+ const key = memoKey(cwd, path)
105
+ const prev = memos.get(key)
106
+ sweep(memos, now)
107
+ const changed = prev !== undefined && (prev.version !== version || !present)
108
+ memos.set(key, { version, at: now })
109
+ // 每个路径只在「检测到变化」时落一行(正常编辑频率,不会刷屏):外部改动没跟上时先看这条日志
110
+ if (changed) log.debug('检测到磁盘变化(已失效目录树):' + path)
111
+ if (changed) invalidateIndex(ctx, cwd, path)
112
+ return changed
113
+ }
114
+
115
+ /**
116
+ * 单条路径观察:解析 → stat → 组装条目(异常一律降级为 missing,不整批失败)。
117
+ * @author ddj 2026年09月15号
118
+ * @param fs host fs 服务
119
+ * @param cwd 工作区根(为空表示会话无工作区,跳过变化判定)
120
+ * @param path 请求路径
121
+ * @returns 版本条目
122
+ */
123
+ const observe = async (fs: Ctx, cwd: string | null, path: string): Promise<FileVersionItem> => {
124
+ try {
125
+ const target = await fs.resolve(path, cwd ? { cwd } : {})
126
+ const info = await fs.stat(target)
127
+ if (!info || info.type === 'other') {
128
+ if (cwd) syncTree(cwd, path, '', false)
129
+ return { path, version: '', type: 'missing' }
130
+ }
131
+ const version = info.version ? String(info.version) : ''
132
+ if (cwd) syncTree(cwd, path, version, true)
133
+ return { path, version, size: typeof info.size === 'number' ? info.size : undefined, type: info.type }
134
+ } catch (error) {
135
+ if (cwd) syncTree(cwd, path, '', false)
136
+ return { path, version: '', type: 'missing', error: String(error) }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * 批查入口(RPC handler 直接委托):同一会话的路径共享一次会话解析。
142
+ * @author ddj 2026年09月15号
143
+ * @param args 会话 id 与路径列表
144
+ * @returns 始终 ok 的逐条结果
145
+ */
146
+ const versions = async (args: { sessionId?: string; paths: string[] }): Promise<{ ok: true; items: FileVersionItem[] }> => {
147
+ const fs = ctx.get('fs')
148
+ const paths = (Array.isArray(args.paths) ? args.paths : [])
149
+ .filter((p): p is string => typeof p === 'string' && p.length > 0)
150
+ .slice(0, MAX_PATHS)
151
+ if (!fs || !paths.length) return { ok: true, items: [] }
152
+ const session = sessionOf(ctx, args.sessionId)
153
+ const cwd = cwdOf(session)
154
+ const items: FileVersionItem[] = []
155
+ for (const path of paths) items.push(await observe(fs, cwd, path))
156
+ return { ok: true, items }
157
+ }
158
+
159
+ return {
160
+ versions,
161
+ dispose() {
162
+ memos.clear()
163
+ },
164
+ }
165
+ }
166
+
167
+ // --endregion
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ import { createLspManager } from './lsp/manager.js'
25
25
  import { createLspRpc } from './lsp/rpc.js'
26
26
  import { disposeAllServers, hookExitReclaim } from './lsp/transport.js'
27
27
  import { createAiRpc } from './ai/rpc.js'
28
+ import { createFileVersions } from './fileVersions.js'
28
29
  import { installRulesSection } from './rules.js'
29
30
  import { installSkillGroup } from './skills.js'
30
31
  import type { RpcHandlerMap } from './shared/rpc.js'
@@ -65,6 +66,8 @@ export function apply(ctx: Ctx, config?: unknown): void {
65
66
  /** AI 内联补全 RPC(settings 状态桥接 + llm 惰性获取)。 */
66
67
  const aiRpc = createAiRpc({ ctx, settings: openSettings })
67
68
  const aiHandlers: Partial<RpcHandlerMap> = aiRpc.handlers
69
+ /** 文件磁盘新鲜度观察器(客户端轮询 edrv.versions;变化时顺带失效目录树缓存)。 */
70
+ const fileVersions = createFileVersions(ctx)
68
71
 
69
72
  ctx.on('tools/result', (exec: unknown, result: unknown) => {
70
73
  void captureToolResult(ctx, registry, exec, result)
@@ -83,7 +86,7 @@ export function apply(ctx: Ctx, config?: unknown): void {
83
86
  if (typeof sid === 'string') lspRpc.disposeSession(sid)
84
87
  })
85
88
 
86
- registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher, contentSearcher, lspHandlers, aiHandlers), (warning) => warnings.push(warning))
89
+ registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions), (warning) => warnings.push(warning))
87
90
  installIsolation(ctx)
88
91
  // 系统集成生命周期:启动自动恢复右键菜单注册(marker 存在时);插件卸载/reload 清理注册痕迹
89
92
  ctx.effect(() => shellMenuLifecycle(ctx))
@@ -95,6 +98,8 @@ export function apply(ctx: Ctx, config?: unknown): void {
95
98
  void lspManager.disposeAll().catch(() => {})
96
99
  disposeAllServers()
97
100
  })
101
+ // 卸载时清空文件版本基准表(观察器为模块内单例,不清会跨装配残留陈旧版本)
102
+ ctx.effect(() => () => fileVersions.dispose())
98
103
  // 宿主进程退出回收:ctx.effect 清理不覆盖进程退出,缺此注册会留下跨重启的孤儿服务器
99
104
  hookExitReclaim()
100
105
 
package/src/revert.ts CHANGED
@@ -8,7 +8,8 @@ import type { Ctx, Session } from './store.js'
8
8
  import { policyOf, resolveTarget } from './store.js'
9
9
  import { applyLocations, locateHunks, preciseHunk } from './shared/diff.js'
10
10
 
11
- export type Result = { ok: true } | { ok: false; error: string }
11
+ /** 回滚结果;stale=true 表示该 hunk 的新文本已不在文件中(无可回滚内容,不算失败)。 */
12
+ export type Result = { ok: true } | { ok: false; error: string; stale?: boolean }
12
13
 
13
14
  /**
14
15
  * 删除新建文件(拒绝创建时):subprocess 删除,路径先经 fs.contains 校验工作区边界。
@@ -81,7 +82,8 @@ export async function revertHunk(ctx: Ctx, session: Session, record: DiffRecord,
81
82
  if ((info.size ?? 0) > 8 * 1024 * 1024) return { ok: false, error: '回滚失败:文件过大,无法安全定位' }
82
83
  const content = await fs.readText(target)
83
84
  const location = locateHunks(content, [precise])[0]
84
- if (!location?.matched) return { ok: false, error: '回滚失败:该区域可能已被后续修改影响' }
85
+ // 新文本已不在文件中(被后续修改覆盖/撤销):不采纳的目标已达成,标记 stale 供调用方按"已回滚"记录决策
86
+ if (!location?.matched) return { ok: false, stale: true, error: '回滚失败:该区域在文件中已不存在(可能已被后续修改覆盖)' }
85
87
  const result = applyLocations(content, [location], true)
86
88
  await fs.writeText(target, result.content, void 0, void 0, policyOf(ctx, session))
87
89
  return { ok: true }
package/src/rpc.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  resolveTarget,
21
21
  saveBucket,
22
22
  } from './store.js'
23
- import { mkdir, readFile, rm, writeFile, readdir } from 'node:fs/promises'
23
+ import { mkdir, readFile, rm, stat, writeFile, readdir } from 'node:fs/promises'
24
24
  import { basename, join } from 'node:path'
25
25
  import { archiveEntryFor, markDecision, recordResolved, reconstructOriginal } from './model.js'
26
26
  import type { Registry } from './registry.js'
@@ -48,6 +48,7 @@ import { dshHome } from './paths.js'
48
48
  import { debugRecord } from './debugLog.js'
49
49
  import { markActiveSessions, moveOutSessions, planMoveOut, purgeArchive, restoreSession, scanSessionInventory, sessionsArchiveRoot, sessionSizeOf, sidecarSummaryOf } from './perf.js'
50
50
  import { patchHasPerfConfig, patchInsertPerfConfig, patchRemovePerfConfig, perfConfigBlock } from './perfPatch.js'
51
+ import type { FileVersions } from './fileVersions.js'
51
52
 
52
53
  /** cwd → 上次 stale 自动清理时间:全量轮询(EditorView/DiffBadge/Dock 各自 5s)节流,避免每轮都读文件算指纹。 */
53
54
  const staleCheckedAt = new Map<string, number>()
@@ -105,6 +106,32 @@ function snippetTargetOf(path: string): string | null {
105
106
  return isSnippetFilePath(path) ? path.replace(/\\/g, '/') : null
106
107
  }
107
108
 
109
+ /**
110
+ * 本地文件系统版本令牌(mtime+size):与 ctx.fs 的不透明版本串用途相同,
111
+ * 仅供全局片段文件(走 node:fs 直读、不经 ctx.fs)在 edrv.read 时回带。
112
+ * @author ddj 2026年09月15号
113
+ * @param info node fs.Stats(stat 失败传 null)
114
+ * @returns 版本令牌;取不到 → 空串(不启用护栏)
115
+ */
116
+ function fileVersionOf(info: { mtimeMs?: number; size?: number } | null): string {
117
+ if (!info || typeof info.mtimeMs !== 'number' || typeof info.size !== 'number') return ''
118
+ return info.mtimeMs + ':' + info.size
119
+ }
120
+
121
+ /**
122
+ * 判定错误是否为「读取后文件已被外部修改」(版本守卫拒绝写入)。
123
+ * 不依赖 host fs 的 FsError 类(外置模块,instanceof 不可靠),按错误码与文案双重判定。
124
+ * @author ddj 2026年09月15号
125
+ * @param error 捕获到的异常
126
+ * @returns 是否为版本冲突
127
+ */
128
+ function isStaleVersion(error: unknown): boolean {
129
+ const err = error as { code?: unknown; message?: unknown } | null
130
+ if (err && err.code === 'FS_STALE_VERSION') return true
131
+ const message = err && typeof err.message === 'string' ? err.message : String(error)
132
+ return message.includes('changed since it was read')
133
+ }
134
+
108
135
  /**
109
136
  * 手动保存收尾(edrv.save / edrv.saveBinary 共用):目录树失效 + 该路径 pending
110
137
  * 文本差异记录标记 superseded 并归档(旧文本 diff 不得再应用到新内容上)。
@@ -137,8 +164,9 @@ async function afterManualSave(
137
164
 
138
165
  /**
139
166
  * 批量决策核心:一次会话/桶解析 + 逐项处理 + 统一落盘(accept/reject 单条与 decideBatch 共用)。
140
- * rejected 项先做回滚,失败记入该项 error 且不改决策;本批次新增"已解决"记录一次性归档,
141
- * 有任何成功项则整桶只写盘一次,避免逐条读写整个 sidecar。
167
+ * rejected 项先做回滚,失败记入该项 error 且不改决策;hunk 的新文本已不在文件中
168
+ * (被后续修改覆盖/撤销)时不算失败:不写文件,按已回滚记录决策并归档(结果项带 stale)。
169
+ * 本批次新增"已解决"记录一次性归档,有任何成功项则整桶只写盘一次,避免逐条读写整个 sidecar。
142
170
  * @author ddj 2026年08月25号
143
171
  * @param items 决策项(按数组顺序处理,rejected 的先后即回滚顺序)
144
172
  * @returns 逐项结果(ok 项含更新后的记录视图)
@@ -159,19 +187,25 @@ async function applyDecisions(
159
187
  results.push({ callId: item.callId, ok: false, error: '记录不存在' })
160
188
  continue
161
189
  }
190
+ let revertedStale = false
162
191
  if (item.decision === 'rejected') {
163
192
  const scope: RpcScope = item.scope ?? 'call'
164
193
  const outcome = scope === 'hunk' ? await revertHunk(ctx, session, record, item.hunkIndex ?? -1) : await revertCall(ctx, session, record)
165
194
  if (!outcome.ok) {
166
- results.push({ callId: item.callId, ok: false, error: outcome.error })
167
- continue
195
+ if (scope !== 'hunk' || outcome.stale !== true) {
196
+ results.push({ callId: item.callId, ok: false, error: outcome.error })
197
+ continue
198
+ }
199
+ revertedStale = true
168
200
  }
169
201
  }
170
202
  const wasResolved = recordResolved(record)
171
203
  markDecision(record, item.scope ?? 'call', item.hunkIndex, item.decision)
172
204
  changed = true
173
205
  if (!wasResolved && recordResolved(record)) resolved.push(record)
174
- results.push({ callId: item.callId, ok: true, record: recView(record) })
206
+ const result: DecideResult = { callId: item.callId, ok: true, record: recView(record) }
207
+ if (revertedStale) result.stale = true
208
+ results.push(result)
175
209
  }
176
210
  if (resolved.length) {
177
211
  const reason = items.some((item) => item.decision === 'rejected') ? '已处理(回滚)' : '已处理'
@@ -220,6 +254,7 @@ export function buildHandlers(
220
254
  contentSearcher = newContentSearcher(ctx),
221
255
  lspHandlers?: Partial<RpcHandlerMap>,
222
256
  aiHandlers?: Partial<RpcHandlerMap>,
257
+ fileVersions?: FileVersions,
223
258
  ): RpcHandlerMap {
224
259
  return {
225
260
  // edrv.lsp.* 由 createLspRpc 一次性提供(tracker 跨请求保留),这里并入。
@@ -282,7 +317,9 @@ export function buildHandlers(
282
317
  if (snippetTarget && args.encoding !== 'base64') {
283
318
  try {
284
319
  const content = await readFile(snippetTarget, 'utf8')
285
- return { ok: true, content, size: content.length }
320
+ // 版本令牌随读带回:客户端据此判断「读后是否被外部改过」(片段文件同理)
321
+ const info = await stat(snippetTarget).catch(() => null)
322
+ return { ok: true, content, size: content.length, version: fileVersionOf(info) }
286
323
  } catch (error) {
287
324
  return { ok: false, error: '读取片段文件失败:' + String(error), resolvedPath: snippetTarget }
288
325
  }
@@ -303,15 +340,20 @@ export function buildHandlers(
303
340
  // 图片/PDF 等二进制预览:readBytes 无解码、无二进制拒绝;超上限已由上方 stat 拦截
304
341
  const bytes = await fs.readBytes(target, undefined, BINARY_READ_CAP)
305
342
  const content = Buffer.from(bytes).toString('base64')
306
- return { ok: true, content, size: bytes.byteLength, encoding: 'base64', mime: binaryMimeOf(args.path) }
343
+ return { ok: true, content, size: bytes.byteLength, encoding: 'base64', mime: binaryMimeOf(args.path), version: String(info.version ?? '') }
307
344
  }
308
345
  if ((info.size ?? 0) > READ_CAP) return { ok: false, error: '文件过大(>8MB),不支持整文件预览' }
309
346
  const content = await fs.readText(target)
310
- return { ok: true, content, size: content.length }
347
+ return { ok: true, content, size: content.length, version: String(info.version ?? '') }
311
348
  } catch (error) {
312
349
  return { ok: false, error: '读取失败:' + String(error) }
313
350
  }
314
351
  },
352
+ 'edrv.versions': async (args) => {
353
+ // 外部改动检测:批量返回已打开文件的磁盘版本(观察器同时失效变化的目录树)
354
+ if (!fileVersions) return { ok: false, error: '文件版本观察器未装配' }
355
+ return fileVersions.versions(args)
356
+ },
315
357
  'edrv.original': async (args) => {
316
358
  // 重建"本批次修改前"内容:DiffEditor 原始侧。仅反解 pending 块;
317
359
  // 全部反解失败时回退到最早记录的整体 before(若存在)。
@@ -354,10 +396,17 @@ export function buildHandlers(
354
396
  if (!fs) return { ok: false, error: '缺少 fs' }
355
397
  try {
356
398
  const target = await resolveTarget(ctx, sc.session, args.path)
357
- await fs.writeText(target, args.content, void 0, void 0, policyOf(ctx, sc.session))
399
+ // 版本守卫(客户端带回上次读取的版本):文件在读取后被外部改过则拒绝写入,
400
+ // 避免用陈旧缓冲静默覆盖外部改动;客户端未带 rev(旧版/无版本后端)时行为不变。
401
+ const rev = typeof args.rev === 'string' && args.rev ? args.rev : null
402
+ const intent = rev ? { kind: 'replaceIfVersion', version: rev } : undefined
403
+ const outcome = await fs.writeText(target, args.content, intent, void 0, policyOf(ctx, sc.session))
358
404
  await afterManualSave(ctx, registry, sc, args.path)
359
- return { ok: true }
405
+ return { ok: true, rev: outcome?.version ? String(outcome.version) : rev ?? undefined }
360
406
  } catch (error) {
407
+ if (isStaleVersion(error)) {
408
+ return { ok: false, conflict: true, error: '文件已被外部修改,请先重新加载(或用编辑器内容覆盖)' }
409
+ }
361
410
  return { ok: false, error: '保存失败:' + String(error) }
362
411
  }
363
412
  },
@@ -864,8 +913,9 @@ export async function handleRpc<M extends RpcMethod>(
864
913
  contentSearcher = newContentSearcher(ctx),
865
914
  lspHandlers?: Partial<RpcHandlerMap>,
866
915
  aiHandlers?: Partial<RpcHandlerMap>,
916
+ fileVersions?: FileVersions,
867
917
  ): Promise<RpcResult<M>> {
868
- const handlers = buildHandlers(ctx, registry, searcher, contentSearcher, lspHandlers, aiHandlers)
918
+ const handlers = buildHandlers(ctx, registry, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions)
869
919
  const handler = handlers[method]
870
920
  if (!handler) return { ok: false, error: '未知方法: ' + String(method) } as RpcResult<M>
871
921
  return handler(args)
package/src/shared/rpc.ts CHANGED
@@ -137,6 +137,24 @@ export interface SidecarPerfSummary {
137
137
  }
138
138
  // --endregion
139
139
 
140
+ /**
141
+ * 单个路径的磁盘新鲜度(edrv.versions 逐条结果)。
142
+ * version 为 host fs 的不透明新鲜度令牌(本地后端 = dev:ino:size:mtimeNs:ctimeNs):
143
+ * 客户端只做字符串相等比较判「是否变化」,不解析其内部结构。
144
+ */
145
+ export interface FileVersionItem {
146
+ /** 请求时的原始路径(原样回带,客户端按此对齐基线)。 */
147
+ path: string
148
+ /** 当前磁盘版本令牌;空串 = 该后端不提供版本,调用方应停用护栏。 */
149
+ version: string
150
+ /** 文件字节数(后端可报时)。 */
151
+ size?: number
152
+ /** 目标类型;'missing' = 已不存在(外部删除或路径解析失败)。 */
153
+ type?: 'file' | 'directory' | 'other' | 'missing'
154
+ /** 单条失败原因(仅诊断用;解析失败/缺失也可能只体现在 type='missing')。 */
155
+ error?: string
156
+ }
157
+
140
158
  /** 一条批量决策项(decideBatch 的 items 元素,与 accept/reject 单条参数同构)。 */
141
159
  export interface DecideItem {
142
160
  callId: string
@@ -151,6 +169,8 @@ export interface DecideResult {
151
169
  ok: boolean
152
170
  error?: string
153
171
  record?: RecordView
172
+ /** 不采纳时该 hunk 的新文本已不在文件中(未改动文件,仅记录决策)。 */
173
+ stale?: boolean
154
174
  }
155
175
 
156
176
  /** 每个方法的请求参数(sessionId 为公共可选字段)。 */
@@ -160,8 +180,9 @@ export interface RpcRequestMap {
160
180
  'edrv.reject': { sessionId?: string; callId: string; scope?: RpcScope; hunkIndex?: number }
161
181
  'edrv.decideBatch': { sessionId?: string; items: DecideItem[] }
162
182
  'edrv.read': { sessionId?: string; path: string; encoding?: 'base64' }
183
+ 'edrv.versions': { sessionId?: string; paths: string[] }
163
184
  'edrv.original': { sessionId?: string; path: string }
164
- 'edrv.save': { sessionId?: string; path: string; content: string }
185
+ 'edrv.save': { sessionId?: string; path: string; content: string; rev?: string }
165
186
  'edrv.saveBinary': { sessionId?: string; path: string; content: string; encoding: 'base64' }
166
187
  'edrv.archiveList': { sessionId?: string }
167
188
  'edrv.archiveRead': { sessionId?: string; path?: string }
@@ -252,9 +273,10 @@ export interface RpcOkMap {
252
273
  'edrv.accept': { record: RecordView }
253
274
  'edrv.reject': { record: RecordView }
254
275
  'edrv.decideBatch': { results: DecideResult[] }
255
- 'edrv.read': { content: string; size: number; encoding?: 'base64'; mime?: string }
276
+ 'edrv.read': { content: string; size: number; encoding?: 'base64'; mime?: string; version?: string }
277
+ 'edrv.versions': { items: FileVersionItem[] }
256
278
  'edrv.original': { content: string; size: number; stale: StaleHunk[]; fallback: boolean }
257
- 'edrv.save': object
279
+ 'edrv.save': { rev?: string; conflict?: boolean }
258
280
  'edrv.saveBinary': object
259
281
  'edrv.archiveList': { entries: ArchiveEntry[] }
260
282
  'edrv.archiveRead': { batches: ArchiveBatch[] }