dsh-vscode-mode 0.14.6 → 0.14.7
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 +5 -4
- package/lib/client.js +799 -121
- package/lib/client.js.map +1 -1
- package/lib/index.js +980 -19
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/capture.ts +4 -1
- package/src/client/events.ts +11 -5
- package/src/client/rpc.ts +10 -0
- package/src/client/sidebar/menuItems.ts +7 -6
- package/src/client/sidebar/panels/FileExplorer.ts +89 -72
- package/src/client/sidebar/panels/explorerView.ts +165 -0
- package/src/client/sidebar/panels/useExplorerSvn.ts +141 -0
- package/src/client/styles/editor.css +12 -0
- package/src/client/svnNotice.ts +24 -0
- package/src/client/ui/EditorView.ts +11 -10
- package/src/client/ui/SvnOverlay.ts +24 -0
- package/src/dap/manager.ts +65 -17
- package/src/model.ts +112 -1
- package/src/rpc.ts +2 -0
- package/src/shared/rpc.ts +6 -1
- package/src/shared/svn.ts +55 -0
- package/src/shared/svnExplorer.ts +97 -0
- package/src/store.ts +36 -1
- package/src/svn.ts +108 -6
- package/src/svnExplorer.ts +56 -0
- package/src/svnExplorerCache.ts +304 -0
- package/src/svnStatusStream.ts +135 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/** Host-only scoped SVN index and single-flight invalidation queue. @author ddj 2026年10月10号 */
|
|
2
|
+
import type { SvnChangeEntry, SvnItemStatus } from './shared/svn.js'
|
|
3
|
+
import { SVN_FOLDER_STATUS_RANK } from './shared/svn.js'
|
|
4
|
+
import { explorerMarkOf, localStatusOf } from './shared/svnExplorer.js'
|
|
5
|
+
import type { SvnExplorerIndex } from './shared/svnExplorer.js'
|
|
6
|
+
import { EXPLORER_CAP } from './svnExplorer.js'
|
|
7
|
+
|
|
8
|
+
export interface ExplorerScan { entries: SvnChangeEntry[]; truncated: boolean; roots?: readonly string[] }
|
|
9
|
+
export type ExplorerScanner = (paths: readonly string[], signal: AbortSignal) => Promise<ExplorerScan>
|
|
10
|
+
export interface ExplorerChange { paths?: readonly string[]; full?: boolean; event?: string }
|
|
11
|
+
export interface ExplorerState {
|
|
12
|
+
revision: number; generation: number; observedGeneration: number; confirmedAt: number; complete: boolean
|
|
13
|
+
refreshing: boolean; scanMs: number; error: string
|
|
14
|
+
}
|
|
15
|
+
type StatusCounts = Map<SvnItemStatus, number>
|
|
16
|
+
const QUIET_MS = 300
|
|
17
|
+
const MAX_WAIT_MS = 1000
|
|
18
|
+
const EVENT_CAP = 512
|
|
19
|
+
|
|
20
|
+
// --region Path sets and normalization
|
|
21
|
+
/** @public @author ddj 2026年10月10号 @param path Validated relative path. @param windows Case-insensitive platform. @returns Canonical lookup key, preserving row spelling separately. */
|
|
22
|
+
export function explorerKey(path: string, windows = process.platform === 'win32'): string {
|
|
23
|
+
const normalized = path.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
|
|
24
|
+
return windows ? normalized.toLowerCase() : normalized
|
|
25
|
+
}
|
|
26
|
+
/** @private @author ddj 2026年10月10号 @param path Canonical descendant. @param root Canonical subtree. @returns Segment-bound membership including root. */
|
|
27
|
+
function within(path: string, root: string): boolean { return !root || path === root || path.startsWith(root + '/') }
|
|
28
|
+
/** @private @author ddj 2026年10月10号 @param path Canonical entry. @returns Ancestors excluding virtual scope root. */
|
|
29
|
+
function ancestors(path: string): string[] {
|
|
30
|
+
const parents: string[] = []
|
|
31
|
+
let parent = path
|
|
32
|
+
while (parent.includes('/')) { parent = parent.slice(0, parent.lastIndexOf('/')); parents.push(parent) }
|
|
33
|
+
return parents
|
|
34
|
+
}
|
|
35
|
+
/** @public @author ddj 2026年10月10号 @param paths Changed paths. @param windows Platform matching. @returns Minimal parent subtree set; empty root means full scan. */
|
|
36
|
+
export function scanRootsOf(paths: readonly string[], windows = process.platform === 'win32'): string[] {
|
|
37
|
+
const roots: string[] = []
|
|
38
|
+
for (const raw of paths) {
|
|
39
|
+
const path = explorerKey(raw, windows)
|
|
40
|
+
const parent = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''
|
|
41
|
+
if (roots.some((root) => within(parent, root))) continue
|
|
42
|
+
for (let i = roots.length - 1; i >= 0; i--) if (within(roots[i], parent)) roots.splice(i, 1)
|
|
43
|
+
roots.push(parent)
|
|
44
|
+
}
|
|
45
|
+
return roots
|
|
46
|
+
}
|
|
47
|
+
// --endregion
|
|
48
|
+
|
|
49
|
+
// --region Incremental subtree index
|
|
50
|
+
/** @public @author ddj 2026年10月10号 Retains authoritative entries and decrementable ancestor counts; no client-wide payload. */
|
|
51
|
+
export class ExplorerIndex {
|
|
52
|
+
readonly index: SvnExplorerIndex = { entries: new Map(), folders: new Map(), truncated: false }
|
|
53
|
+
private readonly counts = new Map<string, StatusCounts>()
|
|
54
|
+
private readonly children = new Map<string, Set<string>>()
|
|
55
|
+
/** @public @author ddj 2026年10月10号 @param windows Case-insensitive lookup mode. */
|
|
56
|
+
constructor(private readonly windows = process.platform === 'win32') {}
|
|
57
|
+
|
|
58
|
+
/** @private @author ddj 2026年10月10号 @param entry Entry contribution. @param delta Add or remove. Updates strongest status after downgrade. */
|
|
59
|
+
private contribute(entry: SvnChangeEntry, delta: number): void {
|
|
60
|
+
const status = localStatusOf(entry)
|
|
61
|
+
if (status === 'normal' || status === 'ignored' || status === 'external') return
|
|
62
|
+
for (const parent of ancestors(entry.path)) {
|
|
63
|
+
const counts = this.counts.get(parent) ?? new Map<SvnItemStatus, number>()
|
|
64
|
+
const next = (counts.get(status) ?? 0) + delta
|
|
65
|
+
if (next > 0) counts.set(status, next)
|
|
66
|
+
else counts.delete(status)
|
|
67
|
+
if (!counts.size) { this.counts.delete(parent); this.index.folders.delete(parent); continue }
|
|
68
|
+
this.counts.set(parent, counts)
|
|
69
|
+
let strongest: SvnItemStatus = 'normal'
|
|
70
|
+
let count = 0
|
|
71
|
+
for (const [item, n] of counts) {
|
|
72
|
+
count += n
|
|
73
|
+
if ((SVN_FOLDER_STATUS_RANK[item] ?? 0) > (SVN_FOLDER_STATUS_RANK[strongest] ?? 0)) strongest = item
|
|
74
|
+
}
|
|
75
|
+
this.index.folders.set(parent, { status: strongest, count })
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @private @author ddj 2026年10月10号 @param path Canonical entry. Build direct child membership, including implicit ancestors. */
|
|
80
|
+
private link(path: string): void {
|
|
81
|
+
let child = path
|
|
82
|
+
while (child) {
|
|
83
|
+
const parent = child.includes('/') ? child.slice(0, child.lastIndexOf('/')) : ''
|
|
84
|
+
const siblings = this.children.get(parent) ?? new Set<string>()
|
|
85
|
+
siblings.add(child); this.children.set(parent, siblings)
|
|
86
|
+
child = parent
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** @private @author ddj 2026年10月10号 @param path Removed entry. Prune only empty implicit ancestors; retained siblings remain indexed. */
|
|
90
|
+
private unlink(path: string): void {
|
|
91
|
+
let child = path
|
|
92
|
+
while (child && !this.index.entries.has(child) && !this.children.get(child)?.size) {
|
|
93
|
+
const parent = child.includes('/') ? child.slice(0, child.lastIndexOf('/')) : ''
|
|
94
|
+
this.children.delete(child)
|
|
95
|
+
const siblings = this.children.get(parent)
|
|
96
|
+
siblings?.delete(child)
|
|
97
|
+
if (!siblings?.size) this.children.delete(parent)
|
|
98
|
+
child = parent
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** @private @author ddj 2026年10月10号 @param roots Subtree membership roots. @returns Retained affected entries only; no whole-index iteration for local replacement. */
|
|
102
|
+
private members(roots: readonly string[]): string[] {
|
|
103
|
+
const result = new Set<string>(), pending = [...roots], visited = new Set<string>()
|
|
104
|
+
while (pending.length) {
|
|
105
|
+
const path = pending.pop()!
|
|
106
|
+
if (visited.has(path)) continue
|
|
107
|
+
visited.add(path)
|
|
108
|
+
if (this.index.entries.has(path)) result.add(path)
|
|
109
|
+
for (const child of this.children.get(path) ?? []) pending.push(child)
|
|
110
|
+
}
|
|
111
|
+
return [...result]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** @public @author ddj 2026年10月10号 @param roots Replaced session-relative subtrees. @param result Complete or bounded scan. @throws Capacity mismatch on partial replacement; caller must full-calibrate. */
|
|
115
|
+
replace(roots: readonly string[], result: ExplorerScan): void {
|
|
116
|
+
const keys = roots.map((root) => explorerKey(root, this.windows))
|
|
117
|
+
const full = keys.includes('')
|
|
118
|
+
const incoming = new Map<string, SvnChangeEntry>()
|
|
119
|
+
for (const entry of result.entries) {
|
|
120
|
+
const path = explorerKey(entry.path, this.windows)
|
|
121
|
+
if (keys.some((root) => within(path, root))) incoming.set(path, { ...entry, path })
|
|
122
|
+
}
|
|
123
|
+
const removed = full ? [] : this.members(keys)
|
|
124
|
+
if (!full && (result.truncated || this.index.entries.size - removed.length + incoming.size > EXPLORER_CAP)) {
|
|
125
|
+
throw new Error('局部 SVN 状态不完整,需要全量校准')
|
|
126
|
+
}
|
|
127
|
+
if (full) this.clear()
|
|
128
|
+
else for (const path of removed) {
|
|
129
|
+
this.contribute(this.index.entries.get(path)!, -1)
|
|
130
|
+
this.index.entries.delete(path)
|
|
131
|
+
this.unlink(path)
|
|
132
|
+
}
|
|
133
|
+
for (const [path, entry] of incoming) { this.index.entries.set(path, entry); this.link(path); this.contribute(entry, 1) }
|
|
134
|
+
if (full) this.index.truncated = result.truncated
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @public @author ddj 2026年10月10号 Release retained entries and aggregation counters. */
|
|
138
|
+
clear(): void { this.index.entries.clear(); this.index.folders.clear(); this.counts.clear(); this.children.clear() }
|
|
139
|
+
|
|
140
|
+
/** @public @author ddj 2026年10月10号 @param path Display row spelling. @param isDir Directory row. @returns Exact/ancestor mark and dirty descendant count. */
|
|
141
|
+
mark(path: string, isDir: boolean) {
|
|
142
|
+
const key = explorerKey(path, this.windows)
|
|
143
|
+
return { mark: explorerMarkOf(this.index, key, isDir), count: isDir ? this.index.folders.get(key)?.count ?? 0 : 0 }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// --endregion
|
|
147
|
+
|
|
148
|
+
// --region Scoped ownership
|
|
149
|
+
/** @public @author ddj 2026年10月10号 Two retained scope indexes; active work is never evicted to satisfy a new reader. */
|
|
150
|
+
export class ExplorerScopes {
|
|
151
|
+
private readonly scopes = new Map<string, ExplorerQueue>()
|
|
152
|
+
/** @public @author ddj 2026年10月10号 @param key Canonical root/session key. @param scan Scanner bound to this scope. @param now Clock. @param windows Matching. @returns Shared owner; refuses new scopes if all retained owners are busy. */
|
|
153
|
+
get(key: string, scan: ExplorerScanner, now = Date.now, windows = process.platform === 'win32'): ExplorerQueue {
|
|
154
|
+
const existing = this.scopes.get(key)
|
|
155
|
+
if (existing) { this.scopes.delete(key); this.scopes.set(key, existing); return existing }
|
|
156
|
+
if (this.scopes.size >= 2) {
|
|
157
|
+
const idle = [...this.scopes].find(([, queue]) => queue.idle())
|
|
158
|
+
if (!idle) throw new Error('SVN 状态扫描繁忙,请稍后重试')
|
|
159
|
+
idle[1].dispose()
|
|
160
|
+
this.scopes.delete(idle[0])
|
|
161
|
+
}
|
|
162
|
+
const queue = new ExplorerQueue(scan, now, windows)
|
|
163
|
+
this.scopes.set(key, queue)
|
|
164
|
+
return queue
|
|
165
|
+
}
|
|
166
|
+
/** @public @author ddj 2026年10月10号 Release all scoped work and retained state. */
|
|
167
|
+
dispose(): void { for (const queue of this.scopes.values()) queue.dispose(); this.scopes.clear() }
|
|
168
|
+
}
|
|
169
|
+
// --endregion
|
|
170
|
+
|
|
171
|
+
// --region Single-flight dirty queue
|
|
172
|
+
/** @public @author ddj 2026年10月10号 Coalesces generations without swallowing changes arriving during a scan. */
|
|
173
|
+
export class ExplorerQueue {
|
|
174
|
+
readonly data: ExplorerIndex
|
|
175
|
+
private generation = 0
|
|
176
|
+
private observed = 0
|
|
177
|
+
private revision = 0
|
|
178
|
+
private confirmedAt = 0
|
|
179
|
+
private scanMs = 0
|
|
180
|
+
private error = ''
|
|
181
|
+
private fullDirty = false
|
|
182
|
+
private readonly dirty = new Set<string>()
|
|
183
|
+
private active: readonly string[] | null = null
|
|
184
|
+
private timer: ReturnType<typeof setTimeout> | null = null
|
|
185
|
+
private pendingSince: number | null = null
|
|
186
|
+
private lastEventAt = 0
|
|
187
|
+
private controller: AbortController | null = null
|
|
188
|
+
private flight: Promise<void> | null = null
|
|
189
|
+
private stopped = false
|
|
190
|
+
private readonly events = new Set<string>()
|
|
191
|
+
private staleRoots: readonly string[] | null = null
|
|
192
|
+
|
|
193
|
+
/** @public @author ddj 2026年10月10号 @param scan Managed scoped scan. @param now Injectable clock. @param windows Canonical path matching. */
|
|
194
|
+
constructor(private readonly scan: ExplorerScanner, private readonly now = Date.now, private readonly windows = process.platform === 'win32') {
|
|
195
|
+
this.data = new ExplorerIndex(windows)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** @public @author ddj 2026年10月10号 @param change Path/full invalidation and idempotency token. @returns Whether a new change was registered. */
|
|
199
|
+
invalidate(change: ExplorerChange = { full: true }): boolean {
|
|
200
|
+
if (this.stopped || (change.event && this.events.has(change.event))) return false
|
|
201
|
+
if (change.event) {
|
|
202
|
+
this.events.add(change.event)
|
|
203
|
+
if (this.events.size > EVENT_CAP) this.events.delete(this.events.values().next().value!)
|
|
204
|
+
}
|
|
205
|
+
this.generation++
|
|
206
|
+
this.staleRoots = null
|
|
207
|
+
this.lastEventAt = this.now()
|
|
208
|
+
if (this.pendingSince === null) this.pendingSince = this.lastEventAt
|
|
209
|
+
if (change.full || !change.paths?.length || !this.confirmedAt || this.data.index.truncated) this.fullDirty = true
|
|
210
|
+
for (const path of change.paths ?? []) this.dirty.add(explorerKey(path, this.windows))
|
|
211
|
+
if (this.dirty.size > 256 || scanRootsOf([...this.dirty], this.windows).length > 32) this.fullDirty = true
|
|
212
|
+
if (this.fullDirty) this.dirty.clear()
|
|
213
|
+
this.schedule()
|
|
214
|
+
return true
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** @private @author ddj 2026年10月10号 Tail debounce has a hard max wait; active scans retain one merged follow-up batch. */
|
|
218
|
+
private schedule(): void {
|
|
219
|
+
if (this.stopped || this.active || this.pendingSince === null) return
|
|
220
|
+
if (this.timer) clearTimeout(this.timer)
|
|
221
|
+
const delay = Math.max(0, Math.min(this.lastEventAt + QUIET_MS, this.pendingSince + MAX_WAIT_MS) - this.now())
|
|
222
|
+
this.timer = setTimeout(() => { this.timer = null; this.launch() }, delay)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** @private @author ddj 2026年10月10号 Starts one scan, snapshots generation and dirtiness before yielding. */
|
|
226
|
+
private launch(): void {
|
|
227
|
+
if (this.stopped || this.active || this.pendingSince === null) return
|
|
228
|
+
const roots = this.fullDirty ? [''] : scanRootsOf([...this.dirty], this.windows)
|
|
229
|
+
const generation = this.generation
|
|
230
|
+
this.fullDirty = false
|
|
231
|
+
this.dirty.clear()
|
|
232
|
+
this.pendingSince = null
|
|
233
|
+
this.active = roots
|
|
234
|
+
this.staleRoots = null
|
|
235
|
+
this.controller = new AbortController()
|
|
236
|
+
this.flight = this.run(roots, generation, this.controller.signal)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** @private @author ddj 2026年10月10号 @param roots Scan subtrees. @param generation Captured invalidation generation. @param signal Owner disposal. Applies known evidence but never confirms newer dirty paths. */
|
|
240
|
+
private async run(roots: readonly string[], generation: number, signal: AbortSignal): Promise<void> {
|
|
241
|
+
const started = this.now()
|
|
242
|
+
try {
|
|
243
|
+
const result = await this.scan(roots, signal)
|
|
244
|
+
if (this.stopped) return
|
|
245
|
+
this.data.replace(result.roots ?? roots, result)
|
|
246
|
+
this.observed = generation
|
|
247
|
+
this.revision++
|
|
248
|
+
this.error = ''
|
|
249
|
+
if (generation === this.generation && !result.truncated) this.confirmedAt = this.now()
|
|
250
|
+
} catch (failure) {
|
|
251
|
+
if (this.stopped) return
|
|
252
|
+
this.error = String(failure)
|
|
253
|
+
// A failed subtree must not keep green evidence. Retry only on a later read/event, not a spin loop.
|
|
254
|
+
this.fullDirty = true
|
|
255
|
+
} finally {
|
|
256
|
+
this.scanMs = Math.max(0, this.now() - started)
|
|
257
|
+
this.active = null
|
|
258
|
+
this.staleRoots = null
|
|
259
|
+
this.controller = null
|
|
260
|
+
this.flight = null
|
|
261
|
+
this.schedule()
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** @public @author ddj 2026年10月10号 @param path Display row. @param isDir Directory membership. @returns Whether evidence intersects active/queued changes or incomplete state. */
|
|
266
|
+
stale(path: string, isDir: boolean): boolean {
|
|
267
|
+
if (!this.confirmedAt || this.fullDirty || this.data.index.truncated) return true
|
|
268
|
+
const key = explorerKey(path, this.windows)
|
|
269
|
+
const roots = this.staleRoots ??= [...(this.active ?? []), ...scanRootsOf([...this.dirty], this.windows)]
|
|
270
|
+
return roots.some((root) => within(key, root) || (isDir && within(root, key)))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** @public @author ddj 2026年10月10号 @returns Lightweight scope metadata; freshness is independent of cache TTL. */
|
|
274
|
+
state(): ExplorerState {
|
|
275
|
+
return { revision: this.revision, generation: this.generation, observedGeneration: this.observed, confirmedAt: this.confirmedAt,
|
|
276
|
+
complete: this.confirmedAt > 0 && !this.data.index.truncated && !this.fullDirty && !this.active && !this.dirty.size,
|
|
277
|
+
refreshing: this.active !== null || this.timer !== null, scanMs: this.scanMs, error: this.error }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** @public @author ddj 2026年10月10号 @returns First-load confirmation; warm callers project immediately with stale flags. */
|
|
281
|
+
async initial(): Promise<void> {
|
|
282
|
+
if (this.stopped) throw new Error('SVN 状态调度已释放')
|
|
283
|
+
if (this.confirmedAt || this.revision || this.error) return
|
|
284
|
+
if (this.pendingSince === null && !this.active) this.invalidate({ full: true })
|
|
285
|
+
if (this.timer) { clearTimeout(this.timer); this.timer = null }
|
|
286
|
+
this.launch()
|
|
287
|
+
await this.flight
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** @public @author ddj 2026年10月10号 @returns Whether an owner may evict this scope without killing shared work. */
|
|
291
|
+
idle(): boolean { return !this.active && !this.timer }
|
|
292
|
+
|
|
293
|
+
/** @public @author ddj 2026年10月10号 Abort owned scans and timers; late completions cannot restore state. */
|
|
294
|
+
dispose(): void {
|
|
295
|
+
this.stopped = true
|
|
296
|
+
if (this.timer) clearTimeout(this.timer)
|
|
297
|
+
this.timer = null
|
|
298
|
+
this.controller?.abort(new Error('SVN 状态调度已释放'))
|
|
299
|
+
this.dirty.clear()
|
|
300
|
+
this.events.clear()
|
|
301
|
+
this.data.clear()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// --endregion
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** Bounded SVN XML framing; retain statuses, never the entire verbose document. @author ddj 2026年10月09号 */
|
|
2
|
+
import { StringDecoder } from 'node:string_decoder'
|
|
3
|
+
import type { Readable } from 'node:stream'
|
|
4
|
+
import { attrOf } from './svnXml.js'
|
|
5
|
+
import type { SvnChangeEntry } from './shared/svn.js'
|
|
6
|
+
|
|
7
|
+
export interface StatusPipe {
|
|
8
|
+
stdout?: Readable | null
|
|
9
|
+
done: Promise<{ exitCode?: number | null; code?: number | null }>
|
|
10
|
+
terminate?: () => unknown
|
|
11
|
+
collected?: { stderr?: { readFrom: (offset: number) => { text: string } } }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** @public @author ddj 2026年10月09号 @param handle Managed pipe process. @param parse Status semantics. @param cap Entry bound. @param timeout Deadline. @returns EOF-validated statuses. @throws CLI, timeout or framing failure; never retries with inherit. */
|
|
15
|
+
export async function readStatusPipe(handle: StatusPipe, parse: StatusParser, cap: number, timeout: number, signal?: AbortSignal) {
|
|
16
|
+
const reader = new SvnStatusReader(parse, cap)
|
|
17
|
+
/** @private @author ddj 2026年10月09号 Consume raw chunks through stream EOF, not just direct process exit. */
|
|
18
|
+
async function consume(): Promise<void> {
|
|
19
|
+
if (!handle.stdout) throw new Error('SVN 状态管道不可用,未确认正常状态')
|
|
20
|
+
for await (const chunk of handle.stdout) reader.push(chunk)
|
|
21
|
+
}
|
|
22
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
23
|
+
const expired = new Promise<never>((_, reject) => {
|
|
24
|
+
timer = setTimeout(() => reject(new Error('读取 SVN 图标状态超时')), timeout)
|
|
25
|
+
timer.unref?.()
|
|
26
|
+
})
|
|
27
|
+
let abort: (() => void) | undefined
|
|
28
|
+
const cancelled = new Promise<never>((_, reject) => {
|
|
29
|
+
abort = () => reject(signal?.reason ?? new Error('SVN 状态扫描已取消'))
|
|
30
|
+
if (signal?.aborted) abort()
|
|
31
|
+
else signal?.addEventListener('abort', abort, { once: true })
|
|
32
|
+
})
|
|
33
|
+
try {
|
|
34
|
+
const [outcome] = await Promise.race([Promise.all([handle.done, consume()]), expired, cancelled])
|
|
35
|
+
if ((outcome.exitCode ?? outcome.code) !== 0) throw new Error(handle.collected?.stderr?.readFrom(0).text || 'SVN 状态命令失败')
|
|
36
|
+
return reader.finish()
|
|
37
|
+
} catch (error) {
|
|
38
|
+
// Keep managed teardown observable but do not mask the original failure.
|
|
39
|
+
try { await handle.terminate?.() } catch { /* Preserve CLI/stream failure. */ }
|
|
40
|
+
throw error
|
|
41
|
+
} finally {
|
|
42
|
+
if (timer) clearTimeout(timer)
|
|
43
|
+
if (abort) signal?.removeEventListener('abort', abort)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type StatusParser = (xml: string, cap: number) => { entries: SvnChangeEntry[]; truncated: boolean }
|
|
48
|
+
const TAG_CAP = 64 * 1024
|
|
49
|
+
const STREAM_CAP = 512 * 1024 * 1024
|
|
50
|
+
|
|
51
|
+
/** @public @author ddj 2026年10月09号 Validates framing through EOF, including entries beyond the retained cap. */
|
|
52
|
+
export class SvnStatusReader {
|
|
53
|
+
private readonly decoder = new StringDecoder('utf8')
|
|
54
|
+
private buffer = ''
|
|
55
|
+
private bytes = 0
|
|
56
|
+
private readonly stack: string[] = []
|
|
57
|
+
private opened = false
|
|
58
|
+
private closed = false
|
|
59
|
+
private entry = ''
|
|
60
|
+
private changelist = ''
|
|
61
|
+
private readonly entries: SvnChangeEntry[] = []
|
|
62
|
+
private truncated = false
|
|
63
|
+
|
|
64
|
+
/** @public @author ddj 2026年10月09号 @param parse Existing status semantics. @param cap Retained entry bound. */
|
|
65
|
+
constructor(private readonly parse: StatusParser, private readonly cap: number) {}
|
|
66
|
+
|
|
67
|
+
/** @public @author ddj 2026年10月09号 @param chunk Raw pipe chunk. @throws Byte/tag limits or invalid framing. */
|
|
68
|
+
push(chunk: Buffer | string): void {
|
|
69
|
+
this.bytes += Buffer.byteLength(chunk)
|
|
70
|
+
if (this.bytes > STREAM_CAP) throw new Error('SVN 状态流超过 512 MiB 安全上限')
|
|
71
|
+
this.feed(typeof chunk === 'string' ? chunk : this.decoder.write(chunk))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** @private @author ddj 2026年10月09号 @param text Decoded chunk. Process each complete tag once; partial tags stay bounded. */
|
|
75
|
+
private feed(text: string): void {
|
|
76
|
+
this.buffer += text
|
|
77
|
+
let cursor = 0
|
|
78
|
+
while (cursor < this.buffer.length) {
|
|
79
|
+
const start = this.buffer.indexOf('<', cursor)
|
|
80
|
+
const end = start < 0 ? -1 : this.buffer.indexOf('>', start)
|
|
81
|
+
const tail = start < 0 ? this.buffer.length : start
|
|
82
|
+
if (!this.stack.length && this.buffer.slice(cursor, tail).trim()) this.invalid()
|
|
83
|
+
if (end < 0) { cursor = tail; break }
|
|
84
|
+
if (end - start > TAG_CAP) this.invalid()
|
|
85
|
+
this.tag(this.buffer.slice(start, end + 1))
|
|
86
|
+
cursor = end + 1
|
|
87
|
+
}
|
|
88
|
+
this.buffer = this.buffer.slice(cursor)
|
|
89
|
+
if (this.buffer.length > TAG_CAP) this.invalid()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @private @author ddj 2026年10月09号 @param token Complete XML tag. Validate nesting and capture only entry/wc-status attributes. */
|
|
93
|
+
private tag(token: string): void {
|
|
94
|
+
if (token.startsWith('<?xml ') && !this.opened && !this.stack.length) return
|
|
95
|
+
const match = /^<(\/?)([\w-]+)\b([^>]*?)(\/?)>$/.exec(token)
|
|
96
|
+
if (!match || this.closed) this.invalid()
|
|
97
|
+
const [, closing, name, attrs, self] = match!
|
|
98
|
+
if (closing) {
|
|
99
|
+
if (this.stack.pop() !== name) this.invalid()
|
|
100
|
+
if (name === 'entry') this.saveEntry()
|
|
101
|
+
if (name === 'changelist') this.changelist = ''
|
|
102
|
+
if (name === 'status') this.closed = true
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (!this.stack.length) {
|
|
106
|
+
if (name !== 'status' || this.opened || self) this.invalid()
|
|
107
|
+
this.opened = true
|
|
108
|
+
}
|
|
109
|
+
if (name === 'changelist') this.changelist = attrOf(attrs, 'name')
|
|
110
|
+
if (name === 'entry') this.entry = token.replace(/\/>$/, '>')
|
|
111
|
+
if (name === 'wc-status' && this.entry) this.entry += '<wc-status' + attrs + '/>'
|
|
112
|
+
if (!self) this.stack.push(name)
|
|
113
|
+
else if (name === 'entry') this.saveEntry()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** @private @author ddj 2026年10月09号 Preserve existing property/tree-conflict/path semantics and bounded statuses. */
|
|
117
|
+
private saveEntry(): void {
|
|
118
|
+
const result = this.parse(this.entry + '</entry>', 1)
|
|
119
|
+
this.entry = ''
|
|
120
|
+
for (const entry of result.entries) {
|
|
121
|
+
if (this.entries.length >= this.cap) { this.truncated = true; continue }
|
|
122
|
+
this.entries.push(this.changelist ? { ...entry, changelist: this.changelist } : entry)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** @private @author ddj 2026年10月09号 @throws Invalid/missing/truncated XML; never produces a clean snapshot. */
|
|
127
|
+
private invalid(): never { throw new Error('SVN 状态输出为空或不完整,未确认正常状态') }
|
|
128
|
+
|
|
129
|
+
/** @public @author ddj 2026年10月09号 @returns Confirmed snapshot only after complete stream EOF. @throws Invalid framing. */
|
|
130
|
+
finish(): { entries: SvnChangeEntry[]; truncated: boolean } {
|
|
131
|
+
this.feed(this.decoder.end())
|
|
132
|
+
if (!this.opened || !this.closed || this.stack.length || this.buffer.trim()) this.invalid()
|
|
133
|
+
return { entries: this.entries, truncated: this.truncated }
|
|
134
|
+
}
|
|
135
|
+
}
|