dsh-vscode-mode 0.4.3 → 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/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 不得再应用到新内容上)。
@@ -227,6 +254,7 @@ export function buildHandlers(
227
254
  contentSearcher = newContentSearcher(ctx),
228
255
  lspHandlers?: Partial<RpcHandlerMap>,
229
256
  aiHandlers?: Partial<RpcHandlerMap>,
257
+ fileVersions?: FileVersions,
230
258
  ): RpcHandlerMap {
231
259
  return {
232
260
  // edrv.lsp.* 由 createLspRpc 一次性提供(tracker 跨请求保留),这里并入。
@@ -289,7 +317,9 @@ export function buildHandlers(
289
317
  if (snippetTarget && args.encoding !== 'base64') {
290
318
  try {
291
319
  const content = await readFile(snippetTarget, 'utf8')
292
- 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) }
293
323
  } catch (error) {
294
324
  return { ok: false, error: '读取片段文件失败:' + String(error), resolvedPath: snippetTarget }
295
325
  }
@@ -310,15 +340,20 @@ export function buildHandlers(
310
340
  // 图片/PDF 等二进制预览:readBytes 无解码、无二进制拒绝;超上限已由上方 stat 拦截
311
341
  const bytes = await fs.readBytes(target, undefined, BINARY_READ_CAP)
312
342
  const content = Buffer.from(bytes).toString('base64')
313
- 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 ?? '') }
314
344
  }
315
345
  if ((info.size ?? 0) > READ_CAP) return { ok: false, error: '文件过大(>8MB),不支持整文件预览' }
316
346
  const content = await fs.readText(target)
317
- return { ok: true, content, size: content.length }
347
+ return { ok: true, content, size: content.length, version: String(info.version ?? '') }
318
348
  } catch (error) {
319
349
  return { ok: false, error: '读取失败:' + String(error) }
320
350
  }
321
351
  },
352
+ 'edrv.versions': async (args) => {
353
+ // 外部改动检测:批量返回已打开文件的磁盘版本(观察器同时失效变化的目录树)
354
+ if (!fileVersions) return { ok: false, error: '文件版本观察器未装配' }
355
+ return fileVersions.versions(args)
356
+ },
322
357
  'edrv.original': async (args) => {
323
358
  // 重建"本批次修改前"内容:DiffEditor 原始侧。仅反解 pending 块;
324
359
  // 全部反解失败时回退到最早记录的整体 before(若存在)。
@@ -361,10 +396,17 @@ export function buildHandlers(
361
396
  if (!fs) return { ok: false, error: '缺少 fs' }
362
397
  try {
363
398
  const target = await resolveTarget(ctx, sc.session, args.path)
364
- 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))
365
404
  await afterManualSave(ctx, registry, sc, args.path)
366
- return { ok: true }
405
+ return { ok: true, rev: outcome?.version ? String(outcome.version) : rev ?? undefined }
367
406
  } catch (error) {
407
+ if (isStaleVersion(error)) {
408
+ return { ok: false, conflict: true, error: '文件已被外部修改,请先重新加载(或用编辑器内容覆盖)' }
409
+ }
368
410
  return { ok: false, error: '保存失败:' + String(error) }
369
411
  }
370
412
  },
@@ -871,8 +913,9 @@ export async function handleRpc<M extends RpcMethod>(
871
913
  contentSearcher = newContentSearcher(ctx),
872
914
  lspHandlers?: Partial<RpcHandlerMap>,
873
915
  aiHandlers?: Partial<RpcHandlerMap>,
916
+ fileVersions?: FileVersions,
874
917
  ): Promise<RpcResult<M>> {
875
- const handlers = buildHandlers(ctx, registry, searcher, contentSearcher, lspHandlers, aiHandlers)
918
+ const handlers = buildHandlers(ctx, registry, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions)
876
919
  const handler = handlers[method]
877
920
  if (!handler) return { ok: false, error: '未知方法: ' + String(method) } as RpcResult<M>
878
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
@@ -162,8 +180,9 @@ export interface RpcRequestMap {
162
180
  'edrv.reject': { sessionId?: string; callId: string; scope?: RpcScope; hunkIndex?: number }
163
181
  'edrv.decideBatch': { sessionId?: string; items: DecideItem[] }
164
182
  'edrv.read': { sessionId?: string; path: string; encoding?: 'base64' }
183
+ 'edrv.versions': { sessionId?: string; paths: string[] }
165
184
  'edrv.original': { sessionId?: string; path: string }
166
- 'edrv.save': { sessionId?: string; path: string; content: string }
185
+ 'edrv.save': { sessionId?: string; path: string; content: string; rev?: string }
167
186
  'edrv.saveBinary': { sessionId?: string; path: string; content: string; encoding: 'base64' }
168
187
  'edrv.archiveList': { sessionId?: string }
169
188
  'edrv.archiveRead': { sessionId?: string; path?: string }
@@ -254,9 +273,10 @@ export interface RpcOkMap {
254
273
  'edrv.accept': { record: RecordView }
255
274
  'edrv.reject': { record: RecordView }
256
275
  'edrv.decideBatch': { results: DecideResult[] }
257
- '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[] }
258
278
  'edrv.original': { content: string; size: number; stale: StaleHunk[]; fallback: boolean }
259
- 'edrv.save': object
279
+ 'edrv.save': { rev?: string; conflict?: boolean }
260
280
  'edrv.saveBinary': object
261
281
  'edrv.archiveList': { entries: ArchiveEntry[] }
262
282
  'edrv.archiveRead': { batches: ArchiveBatch[] }