dsh-vscode-mode 0.14.5 → 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 +6 -4
- package/lib/client.js +969 -166
- 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/state/editorFindState.ts +102 -0
- package/src/client/state/editorViewState.ts +56 -0
- package/src/client/styles/editor.css +12 -0
- package/src/client/svnNotice.ts +24 -0
- package/src/client/ui/EditorView.ts +27 -50
- 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
package/src/svn.ts
CHANGED
|
@@ -19,6 +19,12 @@ import { attrOf, numAttrOf, scanXmlTags, textOf, unescapeXml } from './svnXml.js
|
|
|
19
19
|
import { TEXT_OUTPUT_CAP, batchResultOf, tailOfText, updateResultOf } from './svnText.js'
|
|
20
20
|
import { LOG_CAP, LOG_DEFAULT_LIMIT, SVN_LOG_SHOW_ALL_CAP, parseLogXml, parseSvnInfoRevision } from './svnLog.js'
|
|
21
21
|
import { SvnUpdateJobs } from './svnUpdateJob.js'
|
|
22
|
+
import { EXPLORER_CAP, explorerViewOf, scopeEntriesOf, statusXmlValid, viewArgsValid } from './svnExplorer.js'
|
|
23
|
+
import { ExplorerScopes, explorerKey } from './svnExplorerCache.js'
|
|
24
|
+
import type { ExplorerScan } from './svnExplorerCache.js'
|
|
25
|
+
import type { SvnViewRequest } from './shared/svnExplorer.js'
|
|
26
|
+
import { readStatusPipe } from './svnStatusStream.js'
|
|
27
|
+
import type { StatusPipe } from './svnStatusStream.js'
|
|
22
28
|
import type { UpdateProcess } from './svnUpdateJob.js'
|
|
23
29
|
export { batchResultOf, commitResultOf, updateResultOf } from './svnText.js'
|
|
24
30
|
export { mapRepoPath, parseLogXml } from './svnLog.js'
|
|
@@ -245,7 +251,8 @@ export function parseStatusXml(xml: string, cap: number = CHANGES_CAP): { entrie
|
|
|
245
251
|
if (!pendingPath) continue
|
|
246
252
|
const rawItem = attrOf(hit.attrs, 'item')
|
|
247
253
|
const item = (rawItem || 'modified') as SvnItemStatus
|
|
248
|
-
const status: SvnItemStatus =
|
|
254
|
+
const status: SvnItemStatus = attrOf(hit.attrs, 'tree-conflicted') === 'true'
|
|
255
|
+
? 'conflicted' : (KNOWN_ITEM_STATUS.has(item) ? item : 'modified')
|
|
249
256
|
const path = changePathOf(pendingPath)
|
|
250
257
|
if (!path) { pendingPath = null; continue }
|
|
251
258
|
if (entries.length >= cap) { truncated = true }
|
|
@@ -370,13 +377,14 @@ export function parseRemoteStatusXml(
|
|
|
370
377
|
const repos = /<repos-status\b([^>]*?)\/?>/.exec(match[2])
|
|
371
378
|
if (!repos) continue
|
|
372
379
|
const item = attrOf(repos[1], 'item')
|
|
373
|
-
|
|
380
|
+
const props = attrOf(repos[1], 'props')
|
|
381
|
+
if ((!item || item === 'none' || item === 'normal') && props !== 'modified') continue
|
|
374
382
|
// 路径形态双兼容(同 parseSummarizeXml:绝对/相对取决于调用目标形态)
|
|
375
383
|
const rawPath = attrOf(match[1], 'path')
|
|
376
384
|
const rel = relPathOfAbs(rawPath, wcRoot) ?? changePathOf(rawPath)
|
|
377
385
|
if (!rel) continue
|
|
378
386
|
if (outdated.length >= cap) { truncated = true; break }
|
|
379
|
-
outdated.push({ path: rel, item })
|
|
387
|
+
outdated.push({ path: rel, item: item && item !== 'none' && item !== 'normal' ? item : 'modified' })
|
|
380
388
|
}
|
|
381
389
|
return { outdated, againstRev, truncated }
|
|
382
390
|
}
|
|
@@ -500,6 +508,9 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
500
508
|
const updateRootCache = new Map<string, { at: number; root: string }>()
|
|
501
509
|
/** 变更清单缓存(键 = 工作副本根;TTL 短,仅合并同一轮 UI 的多处拉取)。 */
|
|
502
510
|
const changesCache = new Map<string, { at: number; value: { entries: SvnChangeEntry[]; truncated: boolean } }>()
|
|
511
|
+
/** Explorer snapshots are bounded and short-lived; concurrent scans share one subprocess. */
|
|
512
|
+
const explorerCache = new Map<string, { at: number; entries: SvnChangeEntry[]; truncated: boolean }>()
|
|
513
|
+
const explorerFlight = new Map<string, Promise<{ entries: SvnChangeEntry[]; truncated: boolean }>>()
|
|
503
514
|
/** 混合通道方案收件箱(key = 工作副本根;覆盖式入箱 + TTL,见 svn.aiPlanSubmit/Pending)。 */
|
|
504
515
|
const planInbox = new Map<string, { plan: SvnAiPlan; dropped: number; at: number }>()
|
|
505
516
|
/** 工作副本根 relative-url 缓存(日志路径映射用;探测失败也缓存空串避免反复 spawn)。 */
|
|
@@ -936,7 +947,96 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
936
947
|
return { ok, count, summary, output: [...notes, ...failures].join('\n') }
|
|
937
948
|
}
|
|
938
949
|
|
|
950
|
+
/** @private @author ddj 2026年10月10号 @param root Native WC root. @param prefix Session prefix. @param paths Subtree targets. @returns Existing parent roots, merged with segment boundaries; missing session forces full. */
|
|
951
|
+
const scopeRootsOf = async (root: string, prefix: string, paths: readonly string[]): Promise<string[]> => {
|
|
952
|
+
const roots: string[] = []
|
|
953
|
+
for (const raw of paths) {
|
|
954
|
+
let path = raw
|
|
955
|
+
while (path) {
|
|
956
|
+
try { if ((await stat(join(root, prefix, path))).isDirectory()) break } catch { /* Deleted subtree: climb. */ }
|
|
957
|
+
path = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : ''
|
|
958
|
+
}
|
|
959
|
+
if (roots.some((parent) => !parent || path === parent || path.startsWith(parent + '/'))) continue
|
|
960
|
+
for (let i = roots.length - 1; i >= 0; i--) if (!path || roots[i].startsWith(path + '/')) roots.splice(i, 1)
|
|
961
|
+
roots.push(path)
|
|
962
|
+
}
|
|
963
|
+
return roots
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** @private @author ddj 2026年10月10号 @param cwd Session directory. @param root WC root. @param roots Bounded subtrees. @param signal Owner disposal. @returns Confirmed local statuses; malformed/pipe-empty output rejects. */
|
|
967
|
+
const scanExplorer = async (cwd: string, root: string, roots: readonly string[] = [''], signal?: AbortSignal): Promise<ExplorerScan> => {
|
|
968
|
+
const target = svnPathOf(cwd, root, '')
|
|
969
|
+
if (target === null) throw new Error('工作区不在 SVN 根目录内')
|
|
970
|
+
const spawn = subprocess()
|
|
971
|
+
if (!spawn) throw new Error('缺少 subprocess 服务')
|
|
972
|
+
const fs = ctx.get('fs') as { resolve?: (p: string, o: object) => Promise<unknown>; processPath?: (t: unknown) => string } | null
|
|
973
|
+
const realRoot = fs?.resolve && fs?.processPath ? fs.processPath(await fs.resolve(root, {})) : root
|
|
974
|
+
const validRoots = await scopeRootsOf(realRoot, target, roots)
|
|
975
|
+
const targets = validRoots.map((path) => {
|
|
976
|
+
const rel = [target === '.' ? '' : target, path].filter(Boolean).join('/') || '.'
|
|
977
|
+
return rel.includes('@') ? rel + '@' : rel
|
|
978
|
+
})
|
|
979
|
+
const handle = spawn({
|
|
980
|
+
argv: [settings.svn().svnPath.trim() || SVN_CLI_DEFAULT, '--non-interactive', 'status', '--xml', '--verbose', '--no-ignore', '--', ...targets],
|
|
981
|
+
cwd: realRoot, stdio: { stdout: 'pipe', stderr: { maxBytes: OUTPUT_CAP }, stdin: 'ignore' }, graceMs: STATUS_GRACE_MS,
|
|
982
|
+
}) as StatusPipe
|
|
983
|
+
const parsed = await readStatusPipe(handle, parseStatusXml, EXPLORER_CAP, STATUS_GRACE_MS, signal)
|
|
984
|
+
return { entries: scopeEntriesOf(parsed.entries, target), truncated: parsed.truncated, roots: validRoots }
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/** @private @author ddj 2026年10月09号 @param cwd Session directory. @param root WC root. @param force Bypass TTL. @returns Shared bounded scan; old cache entries are evicted. */
|
|
988
|
+
const explorerOf = async (cwd: string, root: string, force: boolean) => {
|
|
989
|
+
const pending = explorerFlight.get(cwd)
|
|
990
|
+
if (pending) return pending
|
|
991
|
+
const hit = explorerCache.get(cwd)
|
|
992
|
+
if (!force && hit && now() - hit.at < CHANGES_TTL_MS) return hit
|
|
993
|
+
const work = scanExplorer(cwd, root)
|
|
994
|
+
explorerFlight.set(cwd, work)
|
|
995
|
+
try {
|
|
996
|
+
const result = await work
|
|
997
|
+
explorerCache.delete(cwd)
|
|
998
|
+
if (explorerCache.size >= 2) explorerCache.delete(explorerCache.keys().next().value!)
|
|
999
|
+
explorerCache.set(cwd, { at: now(), ...result })
|
|
1000
|
+
return result
|
|
1001
|
+
} finally { explorerFlight.delete(cwd) }
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
const explorerScopes = new ExplorerScopes()
|
|
1005
|
+
/** @private @author ddj 2026年10月10号 @param args Validated view projection. @returns Scope-isolated compact evidence; no repository contact. */
|
|
1006
|
+
const viewExplorer = async (args: SvnViewRequest) => {
|
|
1007
|
+
if (!viewArgsValid(args)) return { ok: false as const, error: 'SVN 图标请求无效或超出行数/路径上限' }
|
|
1008
|
+
const sc = await requireSvnSession(ctx, args.sessionId)
|
|
1009
|
+
if ('err' in sc) return { ok: false as const, error: sc.err }
|
|
1010
|
+
const status = await statusOf(sc.cwd)
|
|
1011
|
+
if (!status.managed || !status.wcRoot || !status.svnCli) return { ok: false as const, error: 'SVN 工作副本或命令不可用' }
|
|
1012
|
+
try {
|
|
1013
|
+
const root = status.wcRoot
|
|
1014
|
+
const scopeId = JSON.stringify([explorerKey(root), explorerKey(sc.cwd)])
|
|
1015
|
+
/** @private @author ddj 2026年10月10号 @param roots Scoped subtrees. @param signal Owner disposal. @returns Streamed scan. */
|
|
1016
|
+
const scan = (roots: readonly string[], signal: AbortSignal) => scanExplorer(sc.cwd, root, roots, signal)
|
|
1017
|
+
const queue = explorerScopes.get(scopeId, scan, now)
|
|
1018
|
+
const reason = args.reason ?? 'read'
|
|
1019
|
+
if (reason !== 'read' && (reason !== 'calibrate' || now() - queue.state().confirmedAt > CHANGES_TTL_MS)) {
|
|
1020
|
+
queue.invalidate({ full: reason !== 'paths', paths: args.paths, event: JSON.stringify([args.clientId, args.eventId]) })
|
|
1021
|
+
}
|
|
1022
|
+
await queue.initial()
|
|
1023
|
+
return { ok: true as const, ...explorerViewOf(queue, scopeId, svnPathOf(sc.cwd, root, '') ?? '.', args) }
|
|
1024
|
+
} catch (error) { return { ok: false as const, error: '读取 SVN 图标状态失败:' + String(error) } }
|
|
1025
|
+
}
|
|
1026
|
+
|
|
939
1027
|
const handlers = {
|
|
1028
|
+
'svn.explorerView': viewExplorer,
|
|
1029
|
+
/** @public @author ddj 2026年10月09号 @param args Session and refresh flag. @returns Read-only explorer snapshot; no repository contact. */
|
|
1030
|
+
'svn.explorerStatus': async (args: { sessionId?: string; force?: boolean }) => {
|
|
1031
|
+
const sc = await requireSvnSession(ctx, args.sessionId)
|
|
1032
|
+
if ('err' in sc) return { ok: false as const, error: sc.err }
|
|
1033
|
+
const status = await statusOf(sc.cwd)
|
|
1034
|
+
if (!status.managed || !status.wcRoot || !status.svnCli) return { ok: false as const, error: 'SVN 工作副本或命令不可用' }
|
|
1035
|
+
try {
|
|
1036
|
+
const result = await explorerOf(sc.cwd, status.wcRoot, args.force === true)
|
|
1037
|
+
return { ok: true as const, ...result, prefix: svnPathOf(sc.cwd, status.wcRoot, '') ?? '.' }
|
|
1038
|
+
} catch (error) { return { ok: false as const, error: '读取 SVN 图标状态失败:' + String(error) } }
|
|
1039
|
+
},
|
|
940
1040
|
'svn.status': async (args: { sessionId?: string; force?: boolean }) => {
|
|
941
1041
|
const sc = await requireSvnSession(ctx, args.sessionId)
|
|
942
1042
|
if ('err' in sc) return { ok: false as const, error: sc.err }
|
|
@@ -1174,12 +1274,14 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
1174
1274
|
if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
|
|
1175
1275
|
try {
|
|
1176
1276
|
// W2-3:M3 实测 —— 离线/无凭据会长时间挂起,REMOTE_GRACE_MS 短超时是硬约束
|
|
1177
|
-
const
|
|
1178
|
-
if (
|
|
1277
|
+
const target = svnPathOf(sc.cwd, status.wcRoot, rel)
|
|
1278
|
+
if (target === null) return { ok: false as const, error: '路径不在 SVN 工作副本内' }
|
|
1279
|
+
const argv = ['status', '-u', '--xml', '--', target]
|
|
1179
1280
|
const outcome = await runSvn(status.wcRoot, argv, REMOTE_GRACE_MS, SUM_OUTPUT_CAP)
|
|
1180
1281
|
if (outcome.code !== 0) {
|
|
1181
1282
|
return { ok: false as const, error: '远端检查失败:' + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join('\n')) }
|
|
1182
1283
|
}
|
|
1284
|
+
if (!statusXmlValid(outcome.stdout)) return { ok: false as const, error: '远端状态输出为空或不完整' }
|
|
1183
1285
|
return { ok: true as const, ...parseRemoteStatusXml(outcome.stdout, status.wcRoot) }
|
|
1184
1286
|
} catch (error) {
|
|
1185
1287
|
return { ok: false as const, error: '远端检查失败:' + String(error) }
|
|
@@ -1492,5 +1594,5 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
|
|
|
1492
1594
|
}
|
|
1493
1595
|
},
|
|
1494
1596
|
}
|
|
1495
|
-
return { handlers, dispose: () => { updateJobs.dispose(); updateRootCache.clear() } }
|
|
1597
|
+
return { handlers, dispose: () => { explorerScopes.dispose(); explorerCache.clear(); updateJobs.dispose(); updateRootCache.clear() } }
|
|
1496
1598
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Bounded verbose status reader for confirmed clean icons. @author ddj 2026年10月09号 */
|
|
2
|
+
import type { SvnChangeEntry } from './shared/svn.js'
|
|
3
|
+
import { explorerPathOf } from './shared/svnExplorer.js'
|
|
4
|
+
import type { SvnViewPayload, SvnViewRequest } from './shared/svnExplorer.js'
|
|
5
|
+
import type { ExplorerQueue } from './svnExplorerCache.js'
|
|
6
|
+
|
|
7
|
+
/** Covers measured 125415-entry WC while bounding retained RPC objects, not XML bytes. */
|
|
8
|
+
export const EXPLORER_CAP = 250_000
|
|
9
|
+
export const EXPLORER_ROWS = 1000
|
|
10
|
+
|
|
11
|
+
/** @public @author ddj 2026年10月10号 @param raw Untrusted RPC request. @returns Whether bounded relative row/invalidation paths and tokens are legal. */
|
|
12
|
+
export function viewArgsValid(raw: unknown): raw is SvnViewRequest {
|
|
13
|
+
if (!raw || typeof raw !== 'object') return false
|
|
14
|
+
const args = raw as SvnViewRequest
|
|
15
|
+
if (!Array.isArray(args.rows) || args.rows.length > EXPLORER_ROWS) return false
|
|
16
|
+
if (args.reason && !['read', 'paths', 'full', 'calibrate'].includes(args.reason)) return false
|
|
17
|
+
if (args.paths && (!Array.isArray(args.paths) || args.paths.length > 256 || !args.paths.every(viewPathValid))) return false
|
|
18
|
+
if (!args.rows.every((row) => row && typeof row.isDir === 'boolean' && viewPathValid(row.path))) return false
|
|
19
|
+
if (args.rows.reduce((bytes, row) => bytes + Buffer.byteLength(JSON.stringify(row.path)) + 80, 0) > 240 * 1024) return false
|
|
20
|
+
if ([args.clientId, args.eventId].some((id) => id !== undefined && (typeof id !== 'string' || !id || id.length > 128))) return false
|
|
21
|
+
if (args.reason && args.reason !== 'read' && (!args.clientId || !args.eventId)) return false
|
|
22
|
+
return [args.revision, args.generation].every((n) => n === undefined || (Number.isSafeInteger(n) && n >= 0))
|
|
23
|
+
}
|
|
24
|
+
/** @private @author ddj 2026年10月10号 @param path Untrusted relative path. @returns No absolute/traversal/control characters; max row size bounds JSON. */
|
|
25
|
+
function viewPathValid(path: unknown): path is string {
|
|
26
|
+
if (typeof path !== 'string' || path.length > 1024 || /[\x00-\x1f]/.test(path)) return false
|
|
27
|
+
const normalized = path.replace(/\\/g, '/')
|
|
28
|
+
return !normalized.startsWith('/') && !/^[A-Za-z]:/.test(normalized) && !normalized.split('/').includes('..')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** @public @author ddj 2026年10月10号 @param queue Scope-owned index. @param scopeId Canonical scope. @param prefix WC prefix. @param args Rows and consistency token. @returns Compact rows; page mismatch returns no mixed-version data. */
|
|
32
|
+
export function explorerViewOf(queue: ExplorerQueue, scopeId: string, prefix: string, args: SvnViewRequest): SvnViewPayload {
|
|
33
|
+
const state = queue.state()
|
|
34
|
+
const mismatch = (args.revision !== undefined && args.revision !== state.revision)
|
|
35
|
+
|| (args.generation !== undefined && args.generation !== state.generation)
|
|
36
|
+
const rows = mismatch ? [] : args.rows.map((row) => {
|
|
37
|
+
const { mark, count } = queue.data.mark(row.path, row.isDir)
|
|
38
|
+
return { path: row.path, kind: mark?.kind ?? null, count, stale: queue.stale(row.path, row.isDir) }
|
|
39
|
+
})
|
|
40
|
+
return { scopeId, prefix, ...state, mismatch, rows }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @public @author ddj 2026年10月09号 @param xml Collected CLI output. @returns True only for a complete status document. */
|
|
44
|
+
export function statusXmlValid(xml: string): boolean {
|
|
45
|
+
return /^\s*(?:<\?xml[^>]*>\s*)?<status(?:\s[^>]*)?>/.test(xml) && /<\/status>\s*$/.test(xml)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @public @author ddj 2026年10月09号 @param entries WC-relative statuses. @param prefix Session prefix. @returns Session-relative subset. */
|
|
49
|
+
export function scopeEntriesOf(entries: readonly SvnChangeEntry[], prefix: string): SvnChangeEntry[] {
|
|
50
|
+
const scoped: SvnChangeEntry[] = []
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
const path = explorerPathOf(entry.path, prefix)
|
|
53
|
+
if (path !== null) scoped.push({ ...entry, path })
|
|
54
|
+
}
|
|
55
|
+
return scoped
|
|
56
|
+
}
|
|
@@ -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
|