dsh-vscode-mode 0.14.6 → 0.14.8
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 +10 -7
- package/lib/client.js +818 -123
- package/lib/client.js.map +1 -1
- package/lib/index.js +1002 -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/openFlow.ts +33 -3
- 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/index.ts +9 -1
- package/src/model.ts +112 -1
- package/src/openBridge.ts +13 -0
- package/src/rpc.ts +3 -1
- 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/unity/com.dsh.editor/Editor/DshCodeEditor.cs +58 -4
- package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +13 -2
- package/unity/com.dsh.editor/package.json +2 -2
package/src/shared/rpc.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type { DapAction, DapAdapterInfo, DapBreakpointAck, DapBreakpointInput, D
|
|
|
24
24
|
import { DAP_ACTIONS } from './dap.js'
|
|
25
25
|
import { appUrl } from './appUrl.js'
|
|
26
26
|
import type { LoggerLevel } from './logger.js'
|
|
27
|
+
import type { SvnViewRequest, SvnViewPayload } from './svnExplorer.js'
|
|
27
28
|
|
|
28
29
|
/** webServer 精确路由。 */
|
|
29
30
|
export const RPC_PATH = '/edrv/rpc'
|
|
@@ -464,6 +465,8 @@ export interface RpcRequestMap {
|
|
|
464
465
|
'snippets.entries': { sessionId?: string }
|
|
465
466
|
'svn.status': { sessionId?: string; force?: boolean }
|
|
466
467
|
'svn.changes': { sessionId?: string; force?: boolean }
|
|
468
|
+
'svn.explorerStatus': { sessionId?: string; force?: boolean }
|
|
469
|
+
'svn.explorerView': SvnViewRequest
|
|
467
470
|
'svn.diffBase': { sessionId?: string; path: string }
|
|
468
471
|
'svn.revert': { sessionId?: string; paths: string[] }
|
|
469
472
|
'svn.add': { sessionId?: string; paths: string[] }
|
|
@@ -624,6 +627,8 @@ export interface RpcOkMap {
|
|
|
624
627
|
'snippets.entries': { entries: SnippetEntry[] }
|
|
625
628
|
'svn.status': SvnStatusPayload
|
|
626
629
|
'svn.changes': { wcRoot: string; entries: SvnChangeEntry[]; truncated: boolean }
|
|
630
|
+
'svn.explorerStatus': { entries: SvnChangeEntry[]; truncated: boolean; prefix: string }
|
|
631
|
+
'svn.explorerView': SvnViewPayload
|
|
627
632
|
'svn.diffBase': { base: string | null; working: string; reason?: string; error?: string; binary?: boolean; encodingHint?: boolean }
|
|
628
633
|
'svn.revert': { count: number; summary: string; output: string }
|
|
629
634
|
'svn.add': { count: number; summary: string; output: string }
|
|
@@ -642,7 +647,7 @@ export interface RpcOkMap {
|
|
|
642
647
|
'svn.tortoise': { launched: string }
|
|
643
648
|
'svn.patchText': { text: string; truncated: boolean; binary: boolean }
|
|
644
649
|
'svn.diffSum': { entries: SvnSumEntry[]; truncated: boolean }
|
|
645
|
-
'svn.remoteStatus': { outdated: SvnRemoteOutdatedEntry[]; againstRev: number | null }
|
|
650
|
+
'svn.remoteStatus': { outdated: SvnRemoteOutdatedEntry[]; againstRev: number | null; truncated: boolean }
|
|
646
651
|
'svn.conflictArtifacts': { artifacts: SvnConflictArtifact[] }
|
|
647
652
|
'svn.diffLocalPair': SvnDiffRevResult
|
|
648
653
|
'svn.fileSizes': { sizes: Array<{ path: string; size: number | null }> }
|
package/src/shared/svn.ts
CHANGED
|
@@ -413,6 +413,61 @@ export function svnBadgeTitle(entry: SvnChangeEntry): string {
|
|
|
413
413
|
return letter + ' ' + SVN_STATUS_LABEL[entry.status]
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
/** 文件夹子树聚合状态(资源管理器目录徽标数据源)。 */
|
|
417
|
+
export interface SvnFolderAgg {
|
|
418
|
+
/** 子树内最强子项状态(决胜见 SVN_FOLDER_STATUS_RANK)。 */
|
|
419
|
+
status: SvnItemStatus
|
|
420
|
+
/** 子树内变更条目总数(含更深层目录下的)。 */
|
|
421
|
+
count: number
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* 文件夹聚合决胜优先级(强→弱):冲突/障碍/缺失等 danger 态先于常规修改,
|
|
426
|
+
* 未版本控制最弱;未列出的状态(normal/external/ignored)不参与聚合。
|
|
427
|
+
*/
|
|
428
|
+
export const SVN_FOLDER_STATUS_RANK: Partial<Record<SvnItemStatus, number>> = {
|
|
429
|
+
conflicted: 90,
|
|
430
|
+
obstructed: 80,
|
|
431
|
+
missing: 70,
|
|
432
|
+
incomplete: 60,
|
|
433
|
+
deleted: 50,
|
|
434
|
+
replaced: 40,
|
|
435
|
+
modified: 30,
|
|
436
|
+
added: 20,
|
|
437
|
+
unversioned: 10,
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* 变更清单 → 目录路径 → 子树聚合状态表(P2-15 资源管理器文件夹徽标)。
|
|
442
|
+
* svn status 只报告目录自身状态、子项变更不上卷,受版本控制目录因此缺席清单;
|
|
443
|
+
* 此处对每条变更沿路径拆出全部祖先目录,累计计数并按 SVN_FOLDER_STATUS_RANK
|
|
444
|
+
* 取最强状态。目录自身被报告(A/?/属性M 等)时调用方以直接条目优先,本表只兜底。
|
|
445
|
+
* @author ddj 2026年10月09号
|
|
446
|
+
* @param entries 变更清单(host `svn.changes` 原始条目,路径 `/` 分隔)
|
|
447
|
+
* @returns 目录相对路径 → 聚合状态(根路径 '' 不入表;入参为空返回空表)
|
|
448
|
+
*/
|
|
449
|
+
export function svnFolderStatusOf(entries: readonly SvnChangeEntry[]): Record<string, SvnFolderAgg> {
|
|
450
|
+
const result: Record<string, SvnFolderAgg> = {}
|
|
451
|
+
for (const entry of entries) {
|
|
452
|
+
if (!isSvnChange(entry)) continue
|
|
453
|
+
const parts = String(entry.path ?? '').replace(/\\/g, '/').split('/').filter(Boolean)
|
|
454
|
+
const rank = SVN_FOLDER_STATUS_RANK[entry.status] ?? 0
|
|
455
|
+
let dir = ''
|
|
456
|
+
// 最后一段是条目自身,只向上聚合到祖先目录(目录自身的行走直接条目)
|
|
457
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
458
|
+
dir = dir ? dir + '/' + parts[i] : parts[i]
|
|
459
|
+
const prev = result[dir]
|
|
460
|
+
if (!prev) {
|
|
461
|
+
result[dir] = { status: entry.status, count: 1 }
|
|
462
|
+
continue
|
|
463
|
+
}
|
|
464
|
+
prev.count += 1
|
|
465
|
+
if (rank > (SVN_FOLDER_STATUS_RANK[prev.status] ?? 0)) prev.status = entry.status
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return result
|
|
469
|
+
}
|
|
470
|
+
|
|
416
471
|
// --endregion
|
|
417
472
|
|
|
418
473
|
// --region P2 动作文案与判定
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** Explorer-only SVN presentation; change-list/menu semantics stay untouched. @author ddj 2026年10月09号 */
|
|
2
|
+
import type { SvnChangeEntry, SvnItemStatus, SvnRemoteOutdatedEntry } from './svn.js'
|
|
3
|
+
import { SVN_FOLDER_STATUS_RANK, SVN_STATUS_LABEL } from './svn.js'
|
|
4
|
+
|
|
5
|
+
export type SvnIconKind = 'clean' | 'modified' | 'conflict' | 'added' | 'deleted' | 'unversioned' | 'ignored' | 'external'
|
|
6
|
+
export interface SvnExplorerIndex {
|
|
7
|
+
entries: Map<string, SvnChangeEntry>
|
|
8
|
+
folders: Map<string, { status: SvnItemStatus; count: number }>
|
|
9
|
+
truncated: boolean
|
|
10
|
+
}
|
|
11
|
+
export interface SvnExplorerMark { kind: SvnIconKind; title: string }
|
|
12
|
+
export interface SvnViewRow { path: string; isDir: boolean }
|
|
13
|
+
export type SvnRefreshReason = 'read' | 'paths' | 'full' | 'calibrate'
|
|
14
|
+
export interface SvnViewRequest {
|
|
15
|
+
sessionId?: string; rows: SvnViewRow[]; reason?: SvnRefreshReason; paths?: string[]
|
|
16
|
+
clientId?: string; eventId?: string; revision?: number; generation?: number
|
|
17
|
+
}
|
|
18
|
+
export interface SvnViewMark { path: string; kind: SvnIconKind | null; count: number; stale: boolean }
|
|
19
|
+
export interface SvnViewPayload {
|
|
20
|
+
scopeId: string; prefix: string; revision: number; generation: number; observedGeneration: number
|
|
21
|
+
confirmedAt: number; complete: boolean; refreshing: boolean; scanMs: number; error: string
|
|
22
|
+
mismatch: boolean; rows: SvnViewMark[]
|
|
23
|
+
}
|
|
24
|
+
const ICON_KIND: Record<SvnItemStatus, SvnIconKind> = {
|
|
25
|
+
normal: 'clean', modified: 'modified', replaced: 'modified', added: 'added',
|
|
26
|
+
deleted: 'deleted', missing: 'deleted', incomplete: 'conflict', obstructed: 'conflict',
|
|
27
|
+
conflicted: 'conflict', unversioned: 'unversioned', ignored: 'ignored', external: 'external',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @public @author ddj 2026年10月09号 @param entry Local status. @returns Effective content/property status. */
|
|
31
|
+
export function localStatusOf(entry: SvnChangeEntry): SvnItemStatus {
|
|
32
|
+
if (entry.props === 'conflicted') return 'conflicted'
|
|
33
|
+
if (entry.status === 'normal' && entry.props === 'modified') return 'modified'
|
|
34
|
+
return entry.status
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @private @author ddj 2026年10月09号 @param path Relative path. @returns Ancestors, excluding self/root. */
|
|
38
|
+
function parentsOf(path: string): string[] {
|
|
39
|
+
const result: string[] = []
|
|
40
|
+
let dir = path
|
|
41
|
+
while (dir.includes('/')) {
|
|
42
|
+
dir = dir.slice(0, dir.lastIndexOf('/'))
|
|
43
|
+
result.push(dir)
|
|
44
|
+
}
|
|
45
|
+
return result
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @public @author ddj 2026年10月09号 @param entries Confirmed verbose statuses. @param truncated Incomplete snapshot. @returns Indexed entries and descendant changes. */
|
|
49
|
+
export function explorerIndexOf(entries: readonly SvnChangeEntry[], truncated = false): SvnExplorerIndex {
|
|
50
|
+
const index: SvnExplorerIndex = { entries: new Map(), folders: new Map(), truncated }
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
index.entries.set(entry.path, entry)
|
|
53
|
+
const status = localStatusOf(entry)
|
|
54
|
+
if (status === 'normal' || status === 'ignored' || status === 'external') continue
|
|
55
|
+
for (const dir of parentsOf(entry.path)) {
|
|
56
|
+
const prev = index.folders.get(dir)
|
|
57
|
+
if (!prev) { index.folders.set(dir, { status, count: 1 }); continue }
|
|
58
|
+
prev.count++
|
|
59
|
+
if ((SVN_FOLDER_STATUS_RANK[status] ?? 0) > (SVN_FOLDER_STATUS_RANK[prev.status] ?? 0)) prev.status = status
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return index
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @public @author ddj 2026年10月09号 @param index Confirmed snapshot. @param path Row path. @param isDir Directory row. @returns Overlay or null; absent entries never imply clean. */
|
|
66
|
+
export function explorerMarkOf(index: SvnExplorerIndex | null, path: string, isDir: boolean): SvnExplorerMark | null {
|
|
67
|
+
if (!index) return null
|
|
68
|
+
let entry = index.entries.get(path)
|
|
69
|
+
if (!entry) {
|
|
70
|
+
for (const parent of parentsOf(path)) {
|
|
71
|
+
const ancestor = index.entries.get(parent)
|
|
72
|
+
if (ancestor?.status === 'ignored' || ancestor?.status === 'unversioned') { entry = ancestor; break }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
let status = entry ? localStatusOf(entry) : null
|
|
76
|
+
const agg = isDir ? index.folders.get(path) : null
|
|
77
|
+
if (agg && (!status || (SVN_FOLDER_STATUS_RANK[agg.status] ?? 0) > (SVN_FOLDER_STATUS_RANK[status] ?? 0))) status = agg.status
|
|
78
|
+
if (!status) return null
|
|
79
|
+
if (status === 'normal' && (entry?.versioned !== true || (isDir && index.truncated))) return null
|
|
80
|
+
const title = status === 'normal' ? '本地正常(已受控,未修改;不表示仓库无更新)' : SVN_STATUS_LABEL[status]
|
|
81
|
+
return { kind: ICON_KIND[status], title: title + (agg ? ';子项 ' + agg.count + ' 处变更' : '') }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** @public @author ddj 2026年10月09号 @param entries Remote changes. @returns Exact and ancestor update counts. */
|
|
85
|
+
export function remoteIndexOf(entries: readonly SvnRemoteOutdatedEntry[]): Map<string, number> {
|
|
86
|
+
const result = new Map<string, number>()
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
for (const path of [entry.path, ...parentsOf(entry.path)]) result.set(path, (result.get(path) ?? 0) + 1)
|
|
89
|
+
}
|
|
90
|
+
return result
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @public @author ddj 2026年10月09号 @param path WC-relative path. @param prefix Session path under WC. @returns Session-relative path or null outside session. */
|
|
94
|
+
export function explorerPathOf(path: string, prefix: string): string | null {
|
|
95
|
+
if (!prefix || prefix === '.') return path
|
|
96
|
+
return path.startsWith(prefix + '/') ? path.slice(prefix.length + 1) : null
|
|
97
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { ArchiveBatch, DiffRecord, SidecarData } from './shared/types.js'
|
|
7
7
|
import type { ReadState } from './shared/diff.js'
|
|
8
8
|
import { fingerprint, isNoopHunk, locateHunks, normalizeForCompare, normalizeHunk, preciseHunk } from './shared/diff.js'
|
|
9
|
-
import { archiveEntryFor, normalizeRecord } from './model.js'
|
|
9
|
+
import { archiveEntryFor, findCanceledPairs, findInversePrior, normalizeRecord } from './model.js'
|
|
10
10
|
import { SIDECAR, SIDECAR_ARCHIVE } from './paths.js'
|
|
11
11
|
import { log } from './log.js'
|
|
12
12
|
import { debugRecord } from './debugLog.js'
|
|
@@ -246,6 +246,41 @@ export async function autoArchiveStale(ctx: Ctx, session: Session, cwd: string,
|
|
|
246
246
|
return stale.length
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* @public
|
|
251
|
+
* @author ddj 2026年10月10号
|
|
252
|
+
* 归档整记录精确抵消对;捕获时仅检查新记录,列表维护时检查整个桶。
|
|
253
|
+
* @param ctx DSH 上下文
|
|
254
|
+
* @param session 当前会话
|
|
255
|
+
* @param cwd 工作区
|
|
256
|
+
* @param bucket 活跃记录桶
|
|
257
|
+
* @param record 可选的新捕获记录
|
|
258
|
+
* @returns 本次归档记录数(不是配对数)
|
|
259
|
+
* @throws 归档失败时恢复内存标志并透传错误,已保存的捕获记录不受影响
|
|
260
|
+
*/
|
|
261
|
+
export async function archiveCanceled(
|
|
262
|
+
ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>, record?: DiffRecord,
|
|
263
|
+
): Promise<number> {
|
|
264
|
+
const records = [...bucket.values()]
|
|
265
|
+
const prior = record ? findInversePrior(records, record) : null
|
|
266
|
+
const pairs = record ? (prior ? [[prior, record]] : []) : findCanceledPairs(records)
|
|
267
|
+
const canceled = pairs.flat()
|
|
268
|
+
if (!canceled.length) return 0
|
|
269
|
+
const original = canceled.map((rec) => ({ rec, at: rec.at, superseded: rec.superseded }))
|
|
270
|
+
const at = new Date().toISOString()
|
|
271
|
+
for (const rec of canceled) { rec.superseded = true; rec.at = at }
|
|
272
|
+
try {
|
|
273
|
+
await archiveRecords(ctx, session, cwd, bucket, canceled, '编辑过程自抵消(新增后还原),自动跳过审查')
|
|
274
|
+
} catch (error) {
|
|
275
|
+
for (const item of original) {
|
|
276
|
+
item.rec.superseded = item.superseded
|
|
277
|
+
item.rec.at = item.at
|
|
278
|
+
}
|
|
279
|
+
throw error
|
|
280
|
+
}
|
|
281
|
+
return canceled.length
|
|
282
|
+
}
|
|
283
|
+
|
|
249
284
|
/** 读取归档 sidecar 文本(缺失/失败 → null)。 */
|
|
250
285
|
export async function readArchiveText(ctx: Ctx, cwd: string | null): Promise<string | null> {
|
|
251
286
|
const fs = ctx.get('fs')
|
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
|
+
}
|