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/README.md +6 -4
- package/lib/client.debug.js +934 -0
- package/lib/client.debug.js.map +1 -0
- package/lib/client.js +848 -1676
- package/lib/client.js.map +1 -1
- package/lib/client.rules.js +793 -0
- package/lib/client.rules.js.map +1 -0
- package/lib/index.js +236 -42
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +9 -0
- package/src/client/lazyPanel.ts +68 -0
- package/src/client/sidebar/PanelBoundary.ts +57 -0
- package/src/client/sidebar/SidebarView.ts +58 -21
- package/src/client/sidebar/panels/FileExplorer.ts +80 -27
- package/src/client/sidebar/panels/SvnPanel.ts +24 -8
- package/src/client/sidebar/panels/index.ts +3 -4
- package/src/client/sidebar/panels/treeWindow.ts +106 -0
- package/src/client/sidebar/panels/useExplorerReveal.ts +5 -2
- package/src/client/sidebar/registry.ts +4 -1
- package/src/client/styles/editor.css +8 -0
- package/src/client/textPages.ts +135 -0
- package/src/client/ui/EditorView.ts +71 -11
- package/src/client/ui/LargeTextView.ts +94 -0
- package/src/rpc.ts +29 -7
- package/src/shared/rpc.ts +3 -1
- package/src/shared/textPages.ts +28 -0
- package/src/textPages.ts +149 -0
- package/src/treeIndex.ts +62 -37
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.
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
}
|
package/src/textPages.ts
ADDED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
151
|
-
|
|
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
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
230
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
}
|