dsh-vscode-mode 0.1.30 → 0.1.32

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/shared/rpc.ts CHANGED
@@ -38,6 +38,54 @@ export interface SearchContentMatch {
38
38
  text: string
39
39
  }
40
40
 
41
+ // --region 会话性能管理(edrv.perf.*)
42
+ /** 一个已持久化会话的体积/活跃信息(edrv.perf.inventory 每行)。 */
43
+ export interface PerfSession {
44
+ sessionId: string
45
+ workspaceKey: string
46
+ bytes: number
47
+ mtime: number
48
+ active: boolean
49
+ }
50
+
51
+ /** 一个工作区的会话聚合(edrv.perf.inventory)。 */
52
+ export interface PerfWorkspace {
53
+ workspaceKey: string
54
+ sessionCount: number
55
+ totalBytes: number
56
+ minMtime: number
57
+ maxMtime: number
58
+ }
59
+
60
+ /** 会话盘点汇总。 */
61
+ export interface PerfTotals {
62
+ workspaces: number
63
+ sessions: number
64
+ totalBytes: number
65
+ }
66
+
67
+ /** 一次移出/恢复操作中的单个会话条目。 */
68
+ export interface PerfMoveItem {
69
+ workspaceKey: string
70
+ sessionId: string
71
+ bytes: number
72
+ }
73
+
74
+ /** 一次移出操作的失败项。 */
75
+ export interface PerfMoveFailure {
76
+ workspaceKey: string
77
+ sessionId: string
78
+ error: string
79
+ }
80
+
81
+ /** 侧车摘要(edrv.perf.sidecarSummary):紧凑关键字段,不整份读 sidecar 进对话。 */
82
+ export interface SidecarPerfSummary {
83
+ active: number
84
+ pendingByFile: { path: string; pending: number }[]
85
+ archiveBytes: number
86
+ }
87
+ // --endregion
88
+
41
89
  /** 一条批量决策项(decideBatch 的 items 元素,与 accept/reject 单条参数同构)。 */
42
90
  export interface DecideItem {
43
91
  callId: string
@@ -69,7 +117,7 @@ export interface RpcRequestMap {
69
117
  'edrv.debug': { sessionId?: string; text: string }
70
118
  'edrv.searchFiles': { sessionId?: string; query: string }
71
119
  'edrv.searchContent': { sessionId?: string; query: string; matchCase?: boolean; wholeWord?: boolean; regex?: boolean; maxResults?: number; include?: string[]; exclude?: string[] }
72
- 'edrv.listDir': { sessionId?: string; path: string }
120
+ 'edrv.listDir': { sessionId?: string; path: string; force?: boolean }
73
121
  'edrv.revealInExplorer': { sessionId?: string; path: string }
74
122
  'mcp.list': {}
75
123
  'mcp.save': { config: MpcConfig }
@@ -86,6 +134,16 @@ export interface RpcRequestMap {
86
134
  'vscode.devFormGet': {}
87
135
  'vscode.devFormSet': { enabled: boolean; path?: string }
88
136
  'compat': {}
137
+ 'edrv.perf.inventory': { sessionId?: string }
138
+ 'edrv.perf.sessionSize': { sessionId?: string; cwd: string }
139
+ 'edrv.perf.movePlan': { workspaceKey?: string; sessionIds?: string[]; minBytes?: number; olderThanDays?: number }
140
+ 'edrv.perf.moveOut': { workspaceKey: string; sessionIds: string[]; dryRun?: boolean }
141
+ 'edrv.perf.restore': { workspaceKey: string; sessionId: string }
142
+ 'edrv.perf.purgeArchive': { olderThanDays: number }
143
+ 'edrv.perf.sidecarSummary': { sessionId?: string }
144
+ 'edrv.perf.configGet': {}
145
+ 'edrv.perf.configApply': {}
146
+ 'edrv.perf.configUndo': {}
89
147
  }
90
148
 
91
149
  export type RpcMethod = keyof RpcRequestMap
@@ -104,7 +162,7 @@ export interface RpcOkMap {
104
162
  'edrv.rollback': { path: string; batch: number | null }
105
163
  'edrv.debug': object
106
164
  'edrv.searchFiles': { files: string[]; truncated: boolean }
107
- 'edrv.searchContent': { matches: SearchContentMatch[]; truncated: boolean }
165
+ 'edrv.searchContent': { matches: SearchContentMatch[]; truncated: boolean; warning?: string }
108
166
  'edrv.listDir': { root: string; path: string; entries: TreeEntry[] }
109
167
  'edrv.revealInExplorer': { revealed: string }
110
168
  'mcp.list': { servers: MpcServer[] }
@@ -122,6 +180,16 @@ export interface RpcOkMap {
122
180
  'vscode.devFormGet': { devForm: DevFormInfo }
123
181
  'vscode.devFormSet': { devForm: DevFormInfo; restart: boolean }
124
182
  'compat': { report: CompatReport }
183
+ 'edrv.perf.inventory': { workspaces: PerfWorkspace[]; sessions: PerfSession[]; totals: PerfTotals; activeIds: string[] }
184
+ 'edrv.perf.sessionSize': { bytes: number; exists: boolean }
185
+ 'edrv.perf.movePlan': { items: PerfMoveItem[]; reclaimedBytes: number }
186
+ 'edrv.perf.moveOut': { moved: PerfMoveItem[]; failures: PerfMoveFailure[]; reclaimedBytes: number }
187
+ 'edrv.perf.restore': { restored: boolean; error?: string }
188
+ 'edrv.perf.purgeArchive': { removed: number; reclaimedBytes: number }
189
+ 'edrv.perf.sidecarSummary': SidecarPerfSummary
190
+ 'edrv.perf.configGet': { profileDir?: string; patchPath?: string; applied: boolean; block: string; backup?: string }
191
+ 'edrv.perf.configApply': { applied: boolean; backup: string; restart: boolean }
192
+ 'edrv.perf.configUndo': { restored: boolean; error?: string; backup?: string }
125
193
  }
126
194
 
127
195
  /** 统一响应:{ok:true, ...payload} 或 {ok:false, error}。 */
package/src/store.ts CHANGED
@@ -7,9 +7,9 @@ import type { ArchiveBatch, ArchiveData, DiffRecord, SidecarData } from './share
7
7
  import type { ReadState } from './shared/diff.js'
8
8
  import { fingerprint, isNoopHunk, locateHunks, preciseHunk } from './shared/diff.js'
9
9
  import { archiveEntryFor, groupByBatch, normalizeRecord } from './model.js'
10
+ import { SIDECAR, SIDECAR_ARCHIVE } from './paths.js'
10
11
 
11
- export const SIDECAR = '.dsh-edit-review.json'
12
- export const SIDECAR_ARCHIVE = '.dsh-edit-review-archive.json'
12
+ export { SIDECAR, SIDECAR_ARCHIVE }
13
13
  export const READ_CAP = 8 * 1024 * 1024
14
14
 
15
15
  /** DSH 上下文与会话类型较宽松:本地无 dsh 类型声明,显式 any + 文档约束。 */
@@ -0,0 +1,397 @@
1
+ /**
2
+ * dsh-vscode-mode host — 工作区目录树索引(.sln 式结构缓存,层 2)。
3
+ * 内存 Map<cwd, 索引> + 落盘缓存文件(工作区级:
4
+ * ~/.dsh/dsh-vscode-mode/cache/workspace/<cwdHash>/tree.v<schema>.json,路径统一走
5
+ * paths.ts PathConst):命中(ts 新鲜且未失效)内存直出 0 IO;列取结果 putIndex 更新
6
+ * 并防抖落盘;agent 写入/手动保存 invalidateIndex 父目录+祖先 → 3s 防抖后台 re-heal
7
+ * (并行、单轮有界、合并并发轮);TTL 兜底外部改动。缓存写读走原生 node:fs(与引擎
8
+ * settings-file 一致,不占用工作区;沙箱只约束 ctx fs)。文件只存 {name,type}(path 由
9
+ * 目录 rel 还原),落盘上限 400 目录/1500 条目/8MB 硬跳过——只是暖启动快照,正确性
10
+ * 不依赖它。首次使用某 cwd 时清理旧工作区树 sidecar
11
+ * (.dsh-edit-review-tree.json/.dsh-vscode-mode-tree.json,缓存可重建,删除无损)。
12
+ * 纯函数部分导出可单测。
13
+ * 作者 ddj 2026-08-31 / 2026-09-01
14
+ */
15
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
16
+ import { dirname, join } from 'node:path'
17
+ import type { TreeEntry } from './shared/rpc.js'
18
+ import type { Ctx } from './store.js'
19
+ import { toTreeEntries } from './tree.js'
20
+ import { DirListError, listDirCheap } from './treeRead.js'
21
+ import { LEGACY_TREE_SIDECARS, treeCacheFile } from './paths.js'
22
+
23
+ // --region 常量
24
+ /** 命中新鲜度:超过则视为过期,按需重列。 */
25
+ export const TREE_TTL_MS = 60_000
26
+ /** 内存目录上限:超出按 ts 逐出最旧(常驻目录命中保活,不会误逐)。 */
27
+ export const MEM_MAX_DIRS = 2000
28
+ /** 落盘目录上限(按 ts 裁旧)。 */
29
+ export const PERSIST_MAX_DIRS = 400
30
+ /** 落盘单目录条目上限:超出截断并标 full=false(截断仍可即时渲染,客户端后台刷新补全)。 */
31
+ export const PERSIST_DIR_ENTRIES = 1500
32
+ /** 落盘序列化硬上限:超出跳过落盘(内存索引照常工作)。 */
33
+ export const PERSIST_MAX_BYTES = 8 * 1024 * 1024
34
+ /** 落盘防抖:变更后静默 10s 再写一次。 */
35
+ export const PERSIST_DEBOUNCE_MS = 10_000
36
+ /** re-heal 防抖:写入密集期合并为 3s 后一轮。 */
37
+ export const HEAL_DEBOUNCE_MS = 3000
38
+ /** re-heal 单轮目录数上限(并行,防 IO 风暴)。 */
39
+ export const HEAL_ROUND_MAX = 20
40
+ // --endregion
41
+
42
+ /** 索引条目(文件瘦身格式:不含 path,加载时由目录 rel 还原)。 */
43
+ export interface TreeIndexEntry {
44
+ name: string
45
+ type: 'file' | 'directory' | 'other'
46
+ }
47
+
48
+ /** 单个目录的索引(ts=最近确认时间;full=false 表示条目被落盘截断过)。 */
49
+ export interface TreeIndexDir {
50
+ ts: number
51
+ full: boolean
52
+ entries: TreeEntry[]
53
+ }
54
+
55
+ /** 索引数据(内存全量;落盘前经 trimPersist 裁剪)。 */
56
+ export interface TreeIndexData {
57
+ v: 1
58
+ root: string | null
59
+ dirs: Record<string, TreeIndexDir>
60
+ }
61
+
62
+ /** 每 cwd 的索引运行时状态。 */
63
+ interface IndexState {
64
+ ctx: Ctx
65
+ cwd: string
66
+ data: TreeIndexData
67
+ stale: Set<string>
68
+ dirty: boolean
69
+ persistTimer: ReturnType<typeof setTimeout> | null
70
+ healTimer: ReturnType<typeof setTimeout> | null
71
+ pending: Map<string, Promise<{ entries: TreeEntry[] } | { error: string }>>
72
+ loading: boolean
73
+ loadPromise: Promise<void> | null
74
+ }
75
+
76
+ /** cwd → 索引状态(随会话销毁清理)。 */
77
+ const states = new Map<string, IndexState>()
78
+ /** 单调时钟:连续 put 的 ts 严格递增(同毫秒不并列,逐出/裁剪顺序可预期)。 */
79
+ let clock = 0
80
+
81
+ /** 取下一时间戳(不小于真实时间且严格递增)。 */
82
+ function nextTs(): number {
83
+ clock = Math.max(Date.now(), clock + 1)
84
+ return clock
85
+ }
86
+
87
+ // --region 纯函数(可单测)
88
+ /** 条目原始值 → 合法条目(非法返回 null)。 */
89
+ export function entryOf(raw: unknown): TreeIndexEntry | null {
90
+ if (!raw || typeof raw !== 'object') return null
91
+ const e = raw as { name?: unknown; type?: unknown }
92
+ if (typeof e.name !== 'string' || !e.name) return null
93
+ const type = e.type === 'directory' ? 'directory' : e.type === 'file' ? 'file' : 'other'
94
+ return { name: e.name, type }
95
+ }
96
+
97
+ /** 写入路径 → 相对工作区根的 rel(绝对路径按 cwd 前缀裁剪;越界/非法 → null)。 */
98
+ export function relOf(cwd: string, path: string): string | null {
99
+ let rel = String(path ?? '').trim().replace(/\\/g, '/')
100
+ const c = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
101
+ const cl = c.toLowerCase()
102
+ const pl = rel.toLowerCase()
103
+ if (pl.startsWith(cl + '/')) rel = rel.slice(cl.length + 1)
104
+ else if (rel.startsWith('/') || /^[a-z]:\//i.test(rel)) return null
105
+ rel = rel.replace(/^\/+|\/+$/g, '')
106
+ if (!rel) return null
107
+ const segs = rel.split('/').filter((s) => s !== '' && s !== '.')
108
+ if (!segs.length) return null
109
+ for (const s of segs) {
110
+ if (s === '..') return null
111
+ }
112
+ return segs.join('/')
113
+ }
114
+
115
+ /** rel 的父目录 + 全部祖先('a/b/c' → ['a/b','a',''];'a' → [''])。 */
116
+ export function ancestorsOf(rel: string): string[] {
117
+ const segs = rel.split('/')
118
+ const out: string[] = []
119
+ for (let i = segs.length - 1; i >= 1; i--) out.push(segs.slice(0, i).join('/'))
120
+ out.push('')
121
+ return out
122
+ }
123
+
124
+ /** 序列化索引数据。 */
125
+ export function indexSerialize(data: TreeIndexData): string {
126
+ const lean: { v: 1; root: string | null; dirs: Record<string, { ts: number; full: boolean; entries: TreeIndexEntry[] }> } = { v: 1, root: data.root, dirs: {} }
127
+ for (const [rel, dir] of Object.entries(data.dirs)) {
128
+ lean.dirs[rel] = { ts: dir.ts, full: dir.full, entries: dir.entries.map((e) => ({ name: e.name, type: e.type })) }
129
+ }
130
+ return JSON.stringify(lean)
131
+ }
132
+
133
+ /** 解析索引文本;损坏/格式不符 → null。 */
134
+ export function indexParse(text: string | null): TreeIndexData | null {
135
+ if (!text) return null
136
+ try {
137
+ const raw = JSON.parse(text) as { v?: unknown; root?: unknown; dirs?: unknown }
138
+ if (!raw || raw.v !== 1 || !raw.dirs || typeof raw.dirs !== 'object') return null
139
+ const dirs: Record<string, TreeIndexDir> = {}
140
+ for (const [rel, d] of Object.entries(raw.dirs)) {
141
+ if (!d || typeof d !== 'object') continue
142
+ const dir = d as { ts?: unknown; full?: unknown; entries?: unknown }
143
+ if (!Array.isArray(dir.entries)) continue
144
+ const entries: TreeEntry[] = []
145
+ for (const rawEntry of dir.entries) {
146
+ const e = entryOf(rawEntry)
147
+ if (!e) continue
148
+ entries.push({ name: e.name, path: rel ? rel + '/' + e.name : e.name, type: e.type })
149
+ }
150
+ if (!entries.length) continue
151
+ dirs[rel] = { ts: typeof dir.ts === 'number' ? dir.ts : 0, full: dir.full === true, entries }
152
+ }
153
+ return { v: 1, root: typeof raw.root === 'string' ? raw.root : null, dirs }
154
+ } catch (error) {
155
+ return null
156
+ }
157
+ }
158
+
159
+ /** 落盘裁剪:目录按 ts 取新裁旧、单目录超限截断标 full=false(内存不受影响)。 */
160
+ export function trimPersist(data: TreeIndexData, maxDirs = PERSIST_MAX_DIRS, maxEntries = PERSIST_DIR_ENTRIES): TreeIndexData {
161
+ const dirs: Record<string, TreeIndexDir> = {}
162
+ const sorted = Object.entries(data.dirs).sort((a, b) => b[1].ts - a[1].ts)
163
+ for (const [rel, dir] of sorted) {
164
+ if (Object.keys(dirs).length >= maxDirs) break
165
+ const full = dir.entries.length <= maxEntries
166
+ dirs[rel] = { ts: dir.ts, full, entries: full ? dir.entries : dir.entries.slice(0, maxEntries) }
167
+ }
168
+ return { v: 1, root: data.root, dirs }
169
+ }
170
+ // --endregion
171
+
172
+ // --region 状态机
173
+ /** 取(或建)某 cwd 的索引状态,并惰性加载落盘快照(合并:文件 ts 更新才采纳)。 */
174
+ function ensureState(ctx: Ctx, cwd: string): IndexState {
175
+ const existing = states.get(cwd)
176
+ if (existing) return existing
177
+ const state: IndexState = {
178
+ ctx,
179
+ cwd,
180
+ data: { v: 1, root: null, dirs: {} },
181
+ stale: new Set(),
182
+ dirty: false,
183
+ persistTimer: null,
184
+ healTimer: null,
185
+ pending: new Map(),
186
+ loading: false,
187
+ loadPromise: null,
188
+ }
189
+ states.set(cwd, state)
190
+ state.loadPromise = loadIndex(state)
191
+ // 首次使用该工作区:清理旧版本在工作区根留下的树缓存 sidecar(缓存可重建,删除无损)
192
+ void removeLegacyTreeSidecars(state.cwd)
193
+ return state
194
+ }
195
+
196
+ /** 清理旧工作区树 sidecar(.dsh-edit-review-tree.json/.dsh-vscode-mode-tree.json)。 */
197
+ export async function removeLegacyTreeSidecars(cwd: string): Promise<void> {
198
+ await Promise.all(LEGACY_TREE_SIDECARS.map(async (name) => {
199
+ try {
200
+ await rm(join(cwd, name), { force: true })
201
+ } catch (error) { /* 删除失败静默 */ }
202
+ }))
203
+ }
204
+
205
+ /** 惰性加载落盘快照一次(缺失/损坏 → 空索引;失败静默)。 */
206
+ async function loadIndex(state: IndexState): Promise<void> {
207
+ if (state.loading) return
208
+ state.loading = true
209
+ try {
210
+ const parsed = indexParse(await readFile(treeCacheFile(state.cwd), 'utf8'))
211
+ if (!parsed) return
212
+ for (const [rel, dir] of Object.entries(parsed.dirs)) {
213
+ const cur = state.data.dirs[rel]
214
+ if (!cur || dir.ts > cur.ts) state.data.dirs[rel] = dir
215
+ }
216
+ if (parsed.root && !state.data.root) state.data.root = parsed.root
217
+ } catch (error) { /* 缺失/损坏/失败均按空索引 */ }
218
+ finally {
219
+ state.loading = false
220
+ }
221
+ }
222
+
223
+ /** 命中:新鲜且未失效 → 条目(并刷新 ts 保活);否则 null。 */
224
+ export function hitIndex(cwd: string, rel: string): TreeEntry[] | null {
225
+ const state = states.get(cwd)
226
+ if (!state) return null
227
+ const dir = state.data.dirs[rel]
228
+ if (!dir || state.stale.has(rel)) return null
229
+ const now = Date.now()
230
+ if (now - dir.ts > TREE_TTL_MS) return null
231
+ dir.ts = now
232
+ return dir.entries
233
+ }
234
+
235
+ /** 更新某目录条目:写内存 + 打脏 + 防抖落盘;stale 撤销。 */
236
+ export function putIndex(ctx: Ctx, cwd: string, rel: string, entries: TreeEntry[]): void {
237
+ const state = ensureState(ctx, cwd)
238
+ const dirs = state.data.dirs
239
+ if (!dirs[rel] && Object.keys(dirs).length >= MEM_MAX_DIRS) {
240
+ let oldest = rel
241
+ let oldestTs = Infinity
242
+ for (const [k, v] of Object.entries(dirs)) {
243
+ if (v.ts < oldestTs) {
244
+ oldestTs = v.ts
245
+ oldest = k
246
+ }
247
+ }
248
+ delete dirs[oldest]
249
+ }
250
+ dirs[rel] = { ts: nextTs(), full: entries.length <= PERSIST_DIR_ENTRIES, entries }
251
+ state.stale.delete(rel)
252
+ state.dirty = true
253
+ schedulePersist(state)
254
+ }
255
+
256
+ /** 写入路径失效:父目录+祖先进 stale,并调度后台 re-heal。 */
257
+ export function invalidateIndex(ctx: Ctx, cwd: string, path: string): void {
258
+ const rel = relOf(cwd, path)
259
+ if (rel === null) return
260
+ const state = ensureState(ctx, cwd)
261
+ for (const anc of ancestorsOf(rel)) state.stale.add(anc)
262
+ scheduleHeal(state)
263
+ }
264
+
265
+ /** 调度 re-heal(防抖合并;已有定时器则复用)。 */
266
+ function scheduleHeal(state: IndexState): void {
267
+ if (state.healTimer) return
268
+ state.healTimer = setTimeout(() => {
269
+ state.healTimer = null
270
+ void healRound(state)
271
+ }, HEAL_DEBOUNCE_MS)
272
+ }
273
+
274
+ /** 后台自愈一轮:并行重列 ≤HEAL_ROUND_MAX 个 stale 目录;失败移出(交给 TTL/展开路径自愈)。 */
275
+ async function healRound(state: IndexState): Promise<void> {
276
+ if (!state.stale.size) return
277
+ const targets = [...state.stale].slice(0, HEAL_ROUND_MAX)
278
+ await Promise.all(targets.map(async (rel) => {
279
+ const res = await listDirCached(state.ctx, state.cwd, rel, true)
280
+ if ('error' in res) state.stale.delete(rel)
281
+ }))
282
+ if (state.stale.size) scheduleHeal(state)
283
+ }
284
+
285
+ /** 调度落盘(防抖;已有定时器则重置)。 */
286
+ function schedulePersist(state: IndexState): void {
287
+ if (state.persistTimer) clearTimeout(state.persistTimer)
288
+ state.persistTimer = setTimeout(() => {
289
+ state.persistTimer = null
290
+ void persistNow(state)
291
+ }, PERSIST_DEBOUNCE_MS)
292
+ }
293
+
294
+ /** 落盘快照(裁剪 + 序列化 + 超限跳过;raw fs 写 ~/.dsh 缓存根,失败静默)。 */
295
+ async function persistNow(state: IndexState): Promise<void> {
296
+ if (!state.dirty) return
297
+ try {
298
+ const json = indexSerialize(trimPersist(state.data))
299
+ if (json.length > PERSIST_MAX_BYTES) {
300
+ state.dirty = false
301
+ return
302
+ }
303
+ await mkdir(dirname(treeCacheFile(state.cwd)), { recursive: true })
304
+ await writeFile(treeCacheFile(state.cwd), json, 'utf8')
305
+ state.dirty = false
306
+ } catch (error) { /* 写缓存失败静默:内存索引不受影响 */ }
307
+ }
308
+
309
+ /** 清理某 cwd 索引(会话销毁时):清定时器,dirty 则尽力补写一次。 */
310
+ export function disposeIndex(cwd: string): void {
311
+ const state = states.get(cwd)
312
+ if (!state) return
313
+ if (state.persistTimer) {
314
+ clearTimeout(state.persistTimer)
315
+ state.persistTimer = null
316
+ }
317
+ if (state.healTimer) {
318
+ clearTimeout(state.healTimer)
319
+ state.healTimer = null
320
+ }
321
+ if (state.dirty) void persistNow(state)
322
+ states.delete(cwd)
323
+ }
324
+ // --endregion
325
+
326
+ /** 错误 → 用户文案(DirListError 用自身 message,其余带上下文)。 */
327
+ function errorText(error: unknown): string {
328
+ if (error instanceof DirListError) return error.message
329
+ return '读取目录失败:' + String(error)
330
+ }
331
+
332
+ /**
333
+ * 目录列取统一入口(RPC handler 用):命中索引直出;失效/force 走快路径
334
+ * (resolve + listDirCheap + putIndex);同 (cwd,rel) 在途请求复用同一 Promise。
335
+ * @author ddj 2026年08月31号 / 2026年09月01号
336
+ * @param ctx DSH 上下文(仅用于工作区 IO)
337
+ * @param cwd 工作区根
338
+ * @param rel 归一化相对路径('' = 根)
339
+ * @param force 跳过索引命中,强制实列
340
+ * @returns 目录条目与工作区根,或错误文案
341
+ */
342
+ export async function listDirCached(
343
+ ctx: Ctx,
344
+ cwd: string,
345
+ rel: string,
346
+ force: boolean,
347
+ ): Promise<{ entries: TreeEntry[]; root: string } | { error: string }> {
348
+ const state = ensureState(ctx, cwd)
349
+ // 首次请求等待落盘快照加载完成(此后命中快照;加载失败则照常实列)
350
+ if (state.loadPromise) await state.loadPromise
351
+ const fs = ctx.get('fs')
352
+ if (!fs) return { error: '缺少 fs' }
353
+ if (!state.data.root) {
354
+ try {
355
+ state.data.root = String(fs.processPath(await fs.resolve('.', { cwd })))
356
+ } catch (error) {
357
+ return { error: errorText(error) }
358
+ }
359
+ }
360
+ const root = state.data.root as string
361
+ if (!force) {
362
+ const hit = hitIndex(cwd, rel)
363
+ if (hit) return { entries: hit, root }
364
+ }
365
+ const inflight = state.pending.get(rel)
366
+ if (inflight) {
367
+ const res = await inflight
368
+ return 'entries' in res ? { entries: res.entries, root } : res
369
+ }
370
+ const task = (async (): Promise<{ entries: TreeEntry[] } | { error: string }> => {
371
+ try {
372
+ const target = await fs.resolve(rel || '.', { cwd })
373
+ const children = await listDirCheap(target.targetKey)
374
+ const entries = toTreeEntries(rel, children)
375
+ putIndex(ctx, cwd, rel, entries)
376
+ return { entries }
377
+ } catch (error) {
378
+ // 目录已被外部删除:从索引移除,避免陈旧条目长期滞留
379
+ if (error instanceof DirListError && error.code === 'not-found') {
380
+ if (state.data.dirs[rel]) {
381
+ delete state.data.dirs[rel]
382
+ state.dirty = true
383
+ schedulePersist(state)
384
+ }
385
+ state.stale.delete(rel)
386
+ }
387
+ return { error: errorText(error) }
388
+ }
389
+ })()
390
+ state.pending.set(rel, task)
391
+ try {
392
+ const res = await task
393
+ return 'entries' in res ? { entries: res.entries, root } : res
394
+ } finally {
395
+ if (state.pending.get(rel) === task) state.pending.delete(rel)
396
+ }
397
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * dsh-vscode-mode host — 目录快速列取(edrv.listDir 数据来源,树索引 miss 时走这里)。
3
+ * dsh-fs.listDir 对每个子项顺序 realpath+stat(2 次 open/项),在本部署被中介的
4
+ * open(0.3–3s/次)下大目录呈分钟级;这里用原生 readdir(withFileTypes) 一次取型
5
+ * (O(1) open),仅对 symlink/junction(Windows junction 在 dirent 中报 symlink,
6
+ * 已实测)并行 follow-stat 判型,延迟按轮摊平而非按条目累加。
7
+ * 结果形状与 dsh-fs 一致(name/type),由调用方 toTreeEntries 整形排序。
8
+ * 作者 ddj 2026-08-31
9
+ */
10
+ import { readdir, stat } from 'node:fs/promises'
11
+ import { join } from 'node:path'
12
+ import type { DirChildLike } from './tree.js'
13
+
14
+ // --region 常量
15
+ /** symlink 并行 stat 分块并发数(并行摊平单次 open 延迟)。 */
16
+ export const SYMLINK_CONCURRENCY = 32
17
+ /** symlink 并行 stat 总数上限:超出部分按 other(防御病态 symlink 目录)。 */
18
+ export const MAX_SYMLINK_STATS = 256
19
+ // --endregion
20
+
21
+ /** 列取失败(带稳定语义 code,调用方转文案)。 */
22
+ export class DirListError extends Error {
23
+ constructor(
24
+ readonly code: 'not-found' | 'not-dir' | 'permission' | 'io',
25
+ message: string,
26
+ ) {
27
+ super(message)
28
+ }
29
+ }
30
+
31
+ /** node fs 错误码 → 稳定语义错误。 */
32
+ function codeOf(err: unknown): DirListError {
33
+ const code = (err as NodeJS.ErrnoException | undefined)?.code
34
+ if (code === 'ENOENT') return new DirListError('not-found', '目录不存在')
35
+ if (code === 'ENOTDIR') return new DirListError('not-dir', '不是目录')
36
+ if (code === 'EACCES' || code === 'EPERM' || code === 'EBUSY') return new DirListError('permission', '权限不足')
37
+ return new DirListError('io', String((err as Error | undefined)?.message ?? err))
38
+ }
39
+
40
+ /** 并行 stat 一批绝对路径 → 目录/文件/other(follow symlink,失败按 other)。 */
41
+ async function statTypes(paths: string[], out: Map<string, 'directory' | 'file' | 'other'>): Promise<void> {
42
+ const cap = Math.min(paths.length, MAX_SYMLINK_STATS)
43
+ for (let i = 0; i < cap; i += SYMLINK_CONCURRENCY) {
44
+ const chunk = paths.slice(i, i + SYMLINK_CONCURRENCY)
45
+ await Promise.all(chunk.map(async (p) => {
46
+ try {
47
+ const info = await stat(p)
48
+ out.set(p, info.isDirectory() ? 'directory' : info.isFile() ? 'file' : 'other')
49
+ } catch (error) {
50
+ out.set(p, 'other')
51
+ }
52
+ }))
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 快速列取目录:一次 readdir(withFileTypes) 判型;symlink 并行 follow-stat。
58
+ * @author ddj 2026年08月31号
59
+ * @param absDir 绝对目录路径(fs.resolve 后的 targetKey)
60
+ * @returns 子项列表(name/type;size 缺省,树界面不展示大小)
61
+ * @throws DirListError 目录不存在/不是目录/权限不足/IO 失败
62
+ */
63
+ export async function listDirCheap(absDir: string): Promise<DirChildLike[]> {
64
+ let dirents
65
+ try {
66
+ dirents = await readdir(absDir, { withFileTypes: true, encoding: 'utf8' })
67
+ } catch (error) {
68
+ throw codeOf(error)
69
+ }
70
+ const out: DirChildLike[] = []
71
+ const syms: string[] = []
72
+ for (const d of dirents) {
73
+ if (d.isDirectory()) out.push({ name: d.name, type: 'directory' })
74
+ else if (d.isFile()) out.push({ name: d.name, type: 'file' })
75
+ else if (d.isSymbolicLink()) syms.push(d.name)
76
+ else out.push({ name: d.name, type: 'other' })
77
+ }
78
+ if (syms.length) {
79
+ const symAbs = syms.map((name) => join(absDir, name))
80
+ const types = new Map<string, 'directory' | 'file' | 'other'>()
81
+ await statTypes(symAbs, types)
82
+ for (let i = 0; i < syms.length; i++) {
83
+ out.push({ name: syms[i], type: types.get(symAbs[i]) ?? 'other' })
84
+ }
85
+ }
86
+ return out
87
+ }