dsh-vscode-mode 0.18.0 → 0.19.0

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/rpc.ts CHANGED
@@ -56,6 +56,10 @@ import { markActiveSessions, markOfficialFlags, moveOutSessions, planMoveOut, pu
56
56
  import { patchHasPerfConfig, patchInsertPerfConfig, patchRemovePerfConfig, perfConfigBlock } from './perfPatch.js'
57
57
  import type { FileVersions } from './fileVersions.js'
58
58
  import { settingsDocPath } from './settingsDocPath.js'
59
+ import { createTextPages } from './textPages.js'
60
+
61
+ /** Process-local cursor signer; reads must stay on the Host filesystem capability. */
62
+ const readTextPage = createTextPages()
59
63
 
60
64
  /** cwd → 上次 stale 自动清理时间:全量轮询(EditorView/DiffBadge/Dock 各自 5s)节流,避免每轮都读文件算指纹。 */
61
65
  const staleCheckedAt = new Map<string, number>()
@@ -162,7 +166,7 @@ async function readTargetOf(
162
166
  ctx: Ctx,
163
167
  sc: { session: Session; cwd: string },
164
168
  path: string,
165
- ): Promise<ReadTargetOk | { err: string; resolvedPath?: string }> {
169
+ ): Promise<ReadTargetOk | { err: string; resolvedPath?: string; code?: 'file-too-large' }> {
166
170
  const fs = ctx.get('fs')
167
171
  if (!fs) return { err: '缺少 fs' }
168
172
  try {
@@ -173,7 +177,7 @@ async function readTargetOf(
173
177
  // 带上解析后的真实路径:跳转失败时可直接看出是路径解析错还是目标本身不存在
174
178
  return { err: '文件不存在', resolvedPath: fs.processPath(target) }
175
179
  }
176
- if ((info.size ?? 0) > BINARY_READ_CAP) return { err: '文件过大(>32MB),不支持整文件预览' }
180
+ if ((info.size ?? 0) > BINARY_READ_CAP) return { err: '文件过大(>32MB),不支持整文件预览', code: 'file-too-large' }
177
181
  return { target, fs, info }
178
182
  } catch (error) {
179
183
  return { err: '读取失败:' + String(error) }
@@ -186,10 +190,10 @@ async function readTargetOf(
186
190
  * @param prep readTargetOf 的失败分支
187
191
  * @returns { ok:false } 形态载荷
188
192
  */
189
- function targetErrOf(prep: { err: string; resolvedPath?: string }): { ok: false; error: string; resolvedPath?: string } {
190
- return prep.resolvedPath !== undefined
191
- ? { ok: false, error: prep.err, resolvedPath: prep.resolvedPath }
192
- : { ok: false, error: prep.err }
193
+ function targetErrOf(prep: { err: string; resolvedPath?: string; code?: 'file-too-large' }): { ok: false; error: string; resolvedPath?: string; code?: 'file-too-large' } {
194
+ return { ok: false, error: prep.err,
195
+ ...(prep.resolvedPath !== undefined ? { resolvedPath: prep.resolvedPath } : {}),
196
+ ...(prep.code ? { code: prep.code } : {}) }
193
197
  }
194
198
 
195
199
  /**
@@ -699,6 +703,24 @@ export function buildHandlers(
699
703
  const results = await applyDecisions(ctx, sc.session, sc.cwd, bucket, Array.isArray(args.items) ? args.items : [])
700
704
  return { ok: true, results }
701
705
  },
706
+ /** @author ddj 2026年10月22号 @param args Read-only request. @returns Bounded official range read, or unsupported on older providers; never bypasses Host IO. */
707
+ 'edrv.textPage': async (args) => {
708
+ const sc = await requireSession(ctx, args.sessionId)
709
+ if ('err' in sc) return { ok: false, error: sc.err, reason: 'read-failed' }
710
+ try {
711
+ const target = await resolveTarget(ctx, sc.session, args.path)
712
+ const fs = ctx.get('fs')
713
+ if (typeof fs?.readByteRange !== 'function' || typeof fs.stat !== 'function') return readTextPage(undefined, args)
714
+ // Keys are opaque identities, never decoded into physical paths. Both legacy string and official FsTarget are supported.
715
+ const targetKey = typeof target === 'string' ? target : (target as { targetKey?: unknown }).targetKey
716
+ if (typeof targetKey !== 'string') return readTextPage(undefined, args)
717
+ /** @author ddj 2026年10月22号 @returns Provider metadata used for before/after freshness checks. */
718
+ function statPage() { return fs.stat(target) }
719
+ /** @author ddj 2026年10月22号 @param offset Verified cursor position. @param length Bounded byte count. @returns Bytes through the official provider only. */
720
+ function readRange(offset: number, length: number) { return fs.readByteRange(target, { offset, length }) }
721
+ return readTextPage({ identity: JSON.stringify([sc.cwd, targetKey]), stat: statPage, readRange }, args)
722
+ } catch (error) { return { ok: false, error: '读取失败:' + String(error), reason: 'read-failed' } }
723
+ },
702
724
  'edrv.read': async (args) => {
703
725
  const settingsTarget = settingsTargetOf(ctx, args.path)
704
726
  if (settingsTarget && args.encoding !== 'base64') {
@@ -735,7 +757,7 @@ export function buildHandlers(
735
757
  const content = Buffer.from(bytes).toString('base64')
736
758
  return { ok: true, content, size: bytes.byteLength, encoding: 'base64', mime: binaryMimeOf(args.path), version: String(prep.info.version ?? '') }
737
759
  }
738
- if ((prep.info.size ?? 0) > READ_CAP) return { ok: false, error: '文件过大(>8MB),不支持整文件预览' }
760
+ if ((prep.info.size ?? 0) > READ_CAP) return { ok: false, error: '文件过大(>8MB),不支持整文件预览', code: 'file-too-large' }
739
761
  const content = await prep.fs.readText(prep.target)
740
762
  return { ok: true, content, size: content.length, version: String(prep.info.version ?? '') }
741
763
  } catch (error) {
package/src/shared/rpc.ts CHANGED
@@ -372,6 +372,7 @@ export interface RpcRequestMap {
372
372
  'edrv.accept': { sessionId?: string; callId: string; scope?: RpcScope; hunkIndex?: number }
373
373
  'edrv.reject': { sessionId?: string; callId: string; scope?: RpcScope; hunkIndex?: number }
374
374
  'edrv.decideBatch': { sessionId?: string; items: DecideItem[] }
375
+ 'edrv.textPage': import('./textPages.js').TextPageArgs
375
376
  'edrv.read': { sessionId?: string; path: string; encoding?: 'base64' }
376
377
  'edrv.readBinary': { sessionId?: string; path: string }
377
378
  'edrv.versions': { sessionId?: string; paths: string[] }
@@ -540,6 +541,7 @@ export interface RpcOkMap {
540
541
  'edrv.accept': { record: RecordView }
541
542
  'edrv.reject': { record: RecordView }
542
543
  'edrv.decideBatch': { results: DecideResult[] }
544
+ 'edrv.textPage': Extract<import('./textPages.js').TextPageResult, { ok: true }>
543
545
  'edrv.read': { content: string; size: number; encoding?: 'base64'; mime?: string; version?: string }
544
546
  'edrv.readBinary': { binary: BinaryReadPayload }
545
547
  'edrv.versions': { items: FileVersionItem[] }
@@ -698,7 +700,7 @@ export interface RpcOkMap {
698
700
  /** 统一响应:{ok:true, ...payload} 或 {ok:false, error}。 */
699
701
  export type RpcResult<M extends RpcMethod> =
700
702
  | ({ ok: true } & RpcOkMap[M])
701
- | { ok: false; error: string }
703
+ | { ok: false; error: string; code?: 'file-too-large'; reason?: Extract<import('./textPages.js').TextPageResult, { ok: false }>['reason'] }
702
704
 
703
705
  /** 每个方法的 handler 签名(host 分发表与 client 类型检查共用)。 */
704
706
  export type RpcHandler<M extends RpcMethod> = (
@@ -0,0 +1,28 @@
1
+ /** Bounded, read-only text paging protocol. @author ddj 2026年10月22号 */
2
+ export const TEXT_PAGE_BYTES = 256 * 1024
3
+ export const TEXT_VIEW_BYTES = 8 * 1024 * 1024
4
+ export const TEXT_VIEW_LINES = 50_000
5
+
6
+ export interface TextPageArgs {
7
+ sessionId?: string
8
+ path: string
9
+ cursor?: string
10
+ }
11
+
12
+ export type TextPageResult = {
13
+ ok: true
14
+ text: string
15
+ version: string
16
+ /** Absolute byte offset following this page, including the initial BOM. */
17
+ offset: number
18
+ size: number
19
+ eof: boolean
20
+ nextCursor: string | null
21
+ encoding: 'utf-8'
22
+ /** True when this non-final page ends in the middle of a logical line. */
23
+ partialLine: boolean
24
+ } | {
25
+ ok: false
26
+ reason: 'unsupported' | 'invalid-cursor' | 'stale' | 'not-text' | 'read-failed'
27
+ error: string
28
+ }
@@ -0,0 +1,149 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
+ import { TEXT_PAGE_BYTES } from './shared/textPages.js'
3
+ import type { TextPageArgs, TextPageResult } from './shared/textPages.js'
4
+
5
+ /** Only an authorized Host adapter may supply this capability; never use raw fs as fallback. */
6
+ export interface TextPageSource {
7
+ /** Authorized workspace + canonical target identity; re-authorize every request before supplying this source. */
8
+ identity: string
9
+ stat(): Promise<{ version: string; size: number; type: string } | undefined>
10
+ readRange(offset: number, length: number): Promise<Uint8Array>
11
+ }
12
+
13
+ interface PageCursor {
14
+ identity: string
15
+ sessionId: string
16
+ path: string
17
+ version: string
18
+ offset: number
19
+ pendingCR: boolean
20
+ }
21
+
22
+ /**
23
+ * Create a Host-owned pager with process-local authenticated cursors.
24
+ * @public @author ddj 2026年10月22号
25
+ * @returns A bounded reader requiring an independently authorized range capability.
26
+ */
27
+ export function createTextPages() {
28
+ const secret = randomBytes(32)
29
+
30
+ /** @private @author ddj 2026年10月22号 @param payload Authenticated state. @returns Signed cursor. */
31
+ function signCursor(payload: PageCursor): string {
32
+ const body = Buffer.from(JSON.stringify(payload)).toString('base64url')
33
+ return body + '.' + createHmac('sha256', secret).update(body).digest('base64url')
34
+ }
35
+
36
+ /** @private @author ddj 2026年10月22号 @param token Untrusted cursor. @returns Verified state or null. */
37
+ function parseCursor(token: string): PageCursor | null {
38
+ if (typeof token !== 'string' || token.length > 8192) return null
39
+ const parts = token.split('.')
40
+ if (parts.length !== 2 || !/^[A-Za-z0-9_-]+$/.test(parts[0]!)) return null
41
+ const signature = Buffer.from(parts[1]!, 'base64url')
42
+ const expected = createHmac('sha256', secret).update(parts[0]!).digest()
43
+ if (signature.length !== expected.length || !timingSafeEqual(signature, expected)) return null
44
+
45
+ try {
46
+ const payload = JSON.parse(Buffer.from(parts[0]!, 'base64url').toString('utf8')) as PageCursor
47
+ if (typeof payload.identity !== 'string' || typeof payload.version !== 'string'
48
+ || typeof payload.sessionId !== 'string' || typeof payload.path !== 'string'
49
+ || typeof payload.pendingCR !== 'boolean' || !Number.isSafeInteger(payload.offset)
50
+ || payload.offset <= 0) return null
51
+ return payload
52
+ } catch { return null }
53
+ }
54
+
55
+ /**
56
+ * Read at most 256 KiB, validating freshness before and after IO.
57
+ * @public @author ddj 2026年10月22号
58
+ * @param source Authorized range capability; absent means unsupported.
59
+ * @param args Request identity and optional signed cursor.
60
+ * @returns Strict UTF-8 text or an explicit fail-closed result; never partial success on mutation.
61
+ */
62
+ async function readTextPage(source: TextPageSource | undefined, args: TextPageArgs): Promise<TextPageResult> {
63
+ if (!source) return failure('unsupported', 'Host does not expose authorized range reads')
64
+ const cursor = args.cursor === undefined ? undefined : parseCursor(args.cursor)
65
+ if (cursor === null || (cursor && (cursor.identity !== source.identity
66
+ || cursor.sessionId !== (args.sessionId ?? '') || cursor.path !== args.path))) {
67
+ return failure('invalid-cursor', 'Cursor does not belong to this file and session')
68
+ }
69
+
70
+ try {
71
+ const before = await source.stat()
72
+ if (!before || before.type !== 'file' || typeof before.version !== 'string'
73
+ || !Number.isSafeInteger(before.size) || before.size < 0) {
74
+ return failure('unsupported', 'Regular file size and opaque version are required')
75
+ }
76
+ if (cursor && (cursor.version !== before.version || cursor.offset >= before.size)) {
77
+ return failure('stale', 'File changed; restart text preview')
78
+ }
79
+ const offset = cursor?.offset ?? 0
80
+ const length = Math.min(TEXT_PAGE_BYTES, before.size - offset)
81
+ const bytes = await source.readRange(offset, length)
82
+ const after = await source.stat()
83
+ if (!after || after.version !== before.version || after.size !== before.size || after.type !== 'file') {
84
+ return failure('stale', 'File changed during text read')
85
+ }
86
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength !== length) {
87
+ return failure('read-failed', 'Range reader returned an incomplete or oversized page')
88
+ }
89
+ return decodePage(bytes, before, offset, cursor?.pendingCR ?? false, args, source.identity, signCursor)
90
+ } catch (error) {
91
+ return failure('read-failed', String(error))
92
+ }
93
+ }
94
+
95
+ return readTextPage
96
+ }
97
+
98
+ /** @private @author ddj 2026年10月22号 @param reason Failure discriminator. @param error User-visible error. @returns Failure result. */
99
+ function failure(reason: Extract<TextPageResult, { ok: false }>['reason'], error: string): TextPageResult {
100
+ return { ok: false, reason, error }
101
+ }
102
+
103
+ /**
104
+ * Keep an incomplete final UTF-8 sequence for a bounded re-read on the next page.
105
+ * @private @author ddj 2026年10月22号
106
+ * @param bytes Raw page. @returns Complete prefix length; invalid sequences remain for fatal decoding.
107
+ */
108
+ function utf8Prefix(bytes: Uint8Array): number {
109
+ let start = bytes.length - 1
110
+ while (start >= 0 && (bytes[start]! & 0xc0) === 0x80) start--
111
+ if (start < 0) return bytes.length
112
+ const lead = bytes[start]!
113
+ let width = 1
114
+ if (lead >= 0xc2 && lead <= 0xdf) width = 2
115
+ else if (lead >= 0xe0 && lead <= 0xef) width = 3
116
+ else if (lead >= 0xf0 && lead <= 0xf4) width = 4
117
+ return bytes.length - start < width ? start : bytes.length
118
+ }
119
+
120
+ /**
121
+ * Decode one page preserving original line endings without buffering a long line.
122
+ * @private @author ddj 2026年10月22号
123
+ * @param bytes Raw page. @param info File snapshot. @param offset Current offset.
124
+ * @param pendingCR Previous trailing CR. @param args Request identity. @param identity Authorized target identity.
125
+ * @param signCursor Host cursor signer. @returns Page or strict decoding failure.
126
+ */
127
+ function decodePage(
128
+ bytes: Uint8Array, info: { size: number; version: string }, offset: number, pendingCR: boolean,
129
+ args: TextPageArgs, identity: string, signCursor: (cursor: PageCursor) => string,
130
+ ): TextPageResult {
131
+ const finalRange = offset + bytes.length === info.size
132
+ const consumed = finalRange ? bytes.length : utf8Prefix(bytes)
133
+ let text: string
134
+ try {
135
+ text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes.subarray(0, consumed))
136
+ } catch { return failure('not-text', 'File is not valid UTF-8') }
137
+ if (text.includes('\0')) return failure('not-text', 'Binary text preview is unsupported')
138
+ if (offset === 0 && text.startsWith('\uFEFF')) text = text.slice(1)
139
+ if (pendingCR) text = '\r' + text
140
+
141
+ const nextOffset = offset + consumed
142
+ const eof = nextOffset === info.size
143
+ const trailingCR = !eof && text.endsWith('\r')
144
+ if (trailingCR) text = text.slice(0, -1)
145
+ const nextCursor = eof ? null : signCursor({ identity, sessionId: args.sessionId ?? '', path: args.path,
146
+ version: info.version, offset: nextOffset, pendingCR: trailingCR })
147
+ return { ok: true, text, version: info.version, offset: nextOffset, size: info.size, eof, nextCursor,
148
+ encoding: 'utf-8', partialLine: !eof && !/[\r\n]$/.test(text) }
149
+ }
package/src/treeIndex.ts CHANGED
@@ -21,9 +21,9 @@ import { DirListError, listDirCheap } from './treeRead.js'
21
21
  import { LEGACY_TREE_SIDECARS, treeCacheFile } from './paths.js'
22
22
 
23
23
  // --region 常量
24
- /** 命中新鲜度:超过则视为过期,按需重列。 */
24
+ /** 硬 TTL:距最近实列确认达到此时间即过期,命中不续期。 */
25
25
  export const TREE_TTL_MS = 60_000
26
- /** 内存目录上限:超出按 ts 逐出最旧(常驻目录命中保活,不会误逐)。 */
26
+ /** 内存目录上限:超出按独立运行时 LRU 逐出,不改变确认时间。 */
27
27
  export const MEM_MAX_DIRS = 2000
28
28
  /** 落盘目录上限(按 ts 裁旧)。 */
29
29
  export const PERSIST_MAX_DIRS = 400
@@ -65,6 +65,8 @@ interface IndexState {
65
65
  cwd: string
66
66
  data: TreeIndexData
67
67
  stale: Set<string>
68
+ /** 插入顺序即访问顺序,仅运行时使用,不写入 v1 快照。 */
69
+ lru: Map<string, true>
68
70
  dirty: boolean
69
71
  persistTimer: ReturnType<typeof setTimeout> | null
70
72
  healTimer: ReturnType<typeof setTimeout> | null
@@ -75,14 +77,6 @@ interface IndexState {
75
77
 
76
78
  /** cwd → 索引状态(随会话销毁清理)。 */
77
79
  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
80
 
87
81
  // --region 纯函数(可单测)
88
82
  /** 条目原始值 → 合法条目(非法返回 null)。 */
@@ -147,8 +141,9 @@ export function indexParse(text: string | null): TreeIndexData | null {
147
141
  if (!e) continue
148
142
  entries.push({ name: e.name, path: rel ? rel + '/' + e.name : e.name, type: e.type })
149
143
  }
150
- if (!entries.length) continue
151
- dirs[rel] = { ts: typeof dir.ts === 'number' ? dir.ts : 0, full: dir.full === true, entries }
144
+ // 丢弃非法条目后不能再声称快照完整;空目录本身仍是有效快照。
145
+ const full = dir.full === true && entries.length === dir.entries.length
146
+ dirs[rel] = { ts: typeof dir.ts === 'number' ? dir.ts : 0, full, entries }
152
147
  }
153
148
  return { v: 1, root: typeof raw.root === 'string' ? raw.root : null, dirs }
154
149
  } catch (error) {
@@ -162,15 +157,16 @@ export function trimPersist(data: TreeIndexData, maxDirs = PERSIST_MAX_DIRS, max
162
157
  const sorted = Object.entries(data.dirs).sort((a, b) => b[1].ts - a[1].ts)
163
158
  for (const [rel, dir] of sorted) {
164
159
  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) }
160
+ const truncated = dir.entries.length > maxEntries
161
+ const full = dir.full && !truncated
162
+ dirs[rel] = { ts: dir.ts, full, entries: truncated ? dir.entries.slice(0, maxEntries) : dir.entries }
167
163
  }
168
164
  return { v: 1, root: data.root, dirs }
169
165
  }
170
166
  // --endregion
171
167
 
172
168
  // --region 状态机
173
- /** 取(或建)某 cwd 的索引状态,并惰性加载落盘快照(合并:文件 ts 更新才采纳)。 */
169
+ /** 取(或建)某 cwd 的索引状态并惰性加载快照;加载期间真实列取优先,不被旧快照覆盖。 */
174
170
  function ensureState(ctx: Ctx, cwd: string): IndexState {
175
171
  const existing = states.get(cwd)
176
172
  if (existing) return existing
@@ -179,6 +175,7 @@ function ensureState(ctx: Ctx, cwd: string): IndexState {
179
175
  cwd,
180
176
  data: { v: 1, root: null, dirs: {} },
181
177
  stale: new Set(),
178
+ lru: new Map(),
182
179
  dirty: false,
183
180
  persistTimer: null,
184
181
  healTimer: null,
@@ -209,9 +206,12 @@ async function loadIndex(state: IndexState): Promise<void> {
209
206
  try {
210
207
  const parsed = indexParse(await readFile(treeCacheFile(state.cwd), 'utf8'))
211
208
  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
209
+ // 旧快照先进入 LRU;加载期间完成的真实列取优先,不被磁盘覆盖。
210
+ const snapshots = Object.entries(parsed.dirs).sort((a, b) => a[1].ts - b[1].ts)
211
+ for (const [rel, dir] of snapshots) {
212
+ if (state.data.dirs[rel]) continue
213
+ state.data.dirs[rel] = dir
214
+ touchDir(state, rel)
215
215
  }
216
216
  if (parsed.root && !state.data.root) state.data.root = parsed.root
217
217
  } catch (error) { /* 缺失/损坏/失败均按空索引 */ }
@@ -220,34 +220,57 @@ async function loadIndex(state: IndexState): Promise<void> {
220
220
  }
221
221
  }
222
222
 
223
- /** 命中:新鲜且未失效 → 条目(并刷新 ts 保活);否则 null。 */
223
+ /**
224
+ * @private
225
+ * @author ddj 2026年10月22号
226
+ * @description 更新运行时访问顺序,超限时同步清理目录及失效标记。
227
+ * @param state 工作区索引状态
228
+ * @param rel 本次访问的相对目录
229
+ */
230
+ function touchDir(state: IndexState, rel: string): void {
231
+ state.lru.delete(rel)
232
+ state.lru.set(rel, true)
233
+ if (state.lru.size <= MEM_MAX_DIRS) return
234
+
235
+ const oldest = state.lru.keys().next().value
236
+ if (oldest === undefined) return
237
+ state.lru.delete(oldest)
238
+ state.stale.delete(oldest)
239
+ delete state.data.dirs[oldest]
240
+ }
241
+
242
+ /**
243
+ * @public
244
+ * @author ddj 2026年10月22号
245
+ * @description 完整、新鲜且未失效才命中;访问仅更新 LRU,不续期硬 TTL。
246
+ * @param cwd 工作区根
247
+ * @param rel 相对目录
248
+ * @returns 完整条目,或未命中时的 null
249
+ */
224
250
  export function hitIndex(cwd: string, rel: string): TreeEntry[] | null {
225
251
  const state = states.get(cwd)
226
252
  if (!state) return null
227
253
  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
254
+ if (!dir || !dir.full || state.stale.has(rel)) return null
255
+ if (Date.now() - dir.ts >= TREE_TTL_MS) return null
256
+ touchDir(state, rel)
232
257
  return dir.entries
233
258
  }
234
259
 
235
- /** 更新某目录条目:写内存 + 打脏 + 防抖落盘;stale 撤销。 */
260
+ /**
261
+ * @public
262
+ * @author ddj 2026年10月22号
263
+ * @description 保存完整实列结果与确认时间,更新 LRU、撤销 stale 并防抖落盘。
264
+ * @param ctx 工作区上下文
265
+ * @param cwd 工作区根
266
+ * @param rel 相对目录
267
+ * @param entries 完整实列条目
268
+ */
236
269
  export function putIndex(ctx: Ctx, cwd: string, rel: string, entries: TreeEntry[]): void {
237
270
  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 }
271
+ // 实列结果在内存中始终完整;条目上限仅约束持久化副本。
272
+ state.data.dirs[rel] = { ts: Date.now(), full: true, entries }
273
+ touchDir(state, rel)
251
274
  state.stale.delete(rel)
252
275
  state.dirty = true
253
276
  schedulePersist(state)
@@ -319,6 +342,7 @@ export function disposeIndex(cwd: string): void {
319
342
  state.healTimer = null
320
343
  }
321
344
  if (state.dirty) void persistNow(state)
345
+ state.lru.clear()
322
346
  states.delete(cwd)
323
347
  }
324
348
  // --endregion
@@ -379,6 +403,7 @@ export async function listDirCached(
379
403
  if (error instanceof DirListError && error.code === 'not-found') {
380
404
  if (state.data.dirs[rel]) {
381
405
  delete state.data.dirs[rel]
406
+ state.lru.delete(rel)
382
407
  state.dirty = true
383
408
  schedulePersist(state)
384
409
  }