dsh-vscode-mode 0.4.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +32 -2
  2. package/lib/client.js +8854 -4023
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +1842 -65
  5. package/lib/index.js.map +1 -1
  6. package/package.json +2 -2
  7. package/src/client/index.ts +3 -0
  8. package/src/client/monaco/lsp/providers.ts +28 -6
  9. package/src/client/navHighlight.ts +96 -0
  10. package/src/client/paths.ts +2 -0
  11. package/src/client/rpc.ts +32 -12
  12. package/src/client/sidebar/SidebarView.ts +6 -2
  13. package/src/client/sidebar/menuItems.ts +93 -2
  14. package/src/client/sidebar/panels/FileExplorer.ts +18 -1
  15. package/src/client/sidebar/panels/SvnPanel.ts +303 -0
  16. package/src/client/sidebar/panels/index.ts +44 -3
  17. package/src/client/sidebar/types.ts +20 -0
  18. package/src/client/styles/editor.css +177 -0
  19. package/src/client/svnActions.ts +153 -0
  20. package/src/client/svnLog.ts +356 -0
  21. package/src/client/svnStatus.ts +363 -0
  22. package/src/client/svnStore.ts +150 -0
  23. package/src/client/tabMenu.ts +51 -0
  24. package/src/client/ui/EditorView.ts +583 -24
  25. package/src/client/ui/LogDialog.ts +333 -0
  26. package/src/client/ui/McpSettings.ts +98 -0
  27. package/src/client/ui/ModalShell.ts +79 -0
  28. package/src/client/ui/SideBySideDiff.tsx +139 -0
  29. package/src/client/ui/SnippetsPicker.ts +70 -58
  30. package/src/client/ui/SvnDiffPanel.ts +50 -0
  31. package/src/client/ui/SvnLogDialog.ts +1141 -0
  32. package/src/client/ui/SvnLogStats.ts +243 -0
  33. package/src/client/ui/commandCatalog.ts +72 -0
  34. package/src/debugLog.ts +81 -12
  35. package/src/fileOpenSettings.ts +40 -3
  36. package/src/index.ts +5 -1
  37. package/src/rpc.ts +41 -4
  38. package/src/shared/rpc.ts +53 -1
  39. package/src/shared/svn.ts +377 -0
  40. package/src/shared/svnActions.ts +296 -0
  41. package/src/svn.ts +870 -0
  42. package/src/svnLog.ts +315 -0
  43. package/src/svnText.ts +175 -0
  44. package/src/svnXml.ts +110 -0
package/src/svn.ts ADDED
@@ -0,0 +1,870 @@
1
+ /**
2
+ * dsh-vscode-mode host — SVN 能力:检测(CLI/Tortoise/工作副本根)、状态缓存与命令执行。
3
+ * 底座 svn CLI:全部调用带 --non-interactive + stdin ignore + graceMs 超时防交互挂起;
4
+ * 只需退出码的调用 stdio 用 inherit(受管环境管道 spawn EPERM,见 dsh-vscode-mode 技能),
5
+ * 需要输出的调用先管道、spawn 级失败回退 inherit(输出降级为空)。
6
+ * TortoiseProc 为长驻 GUI 进程:发射后不 await done(只挂 no-op catch 防未处理 rejection)。
7
+ * 作者 ddj 2026年09月16号
8
+ */
9
+ import { stat } from 'node:fs/promises'
10
+ import { dirname, join } from 'node:path'
11
+ import { tmpdir } from 'node:os'
12
+ import type { Ctx, Session } from './store.js'
13
+ import { cwdOf, sessionOf } from './registry.js'
14
+ import type { SvnAction, SvnChangeEntry, SvnFeature, SvnItemStatus, SvnLogEntry, SvnStatusPayload } from './shared/svn.js'
15
+ import { SVN_CLI_DEFAULT, TORTOISE_EXE, tortoiseLaunchArgv } from './shared/svn.js'
16
+ import { attrOf, numAttrOf, scanXmlTags, textOf } from './svnXml.js'
17
+ import { TEXT_OUTPUT_CAP, batchResultOf, tailOfText, updateResultOf } from './svnText.js'
18
+ import { LOG_CAP, LOG_DEFAULT_LIMIT, SVN_LOG_SHOW_ALL_CAP, parseLogXml, parseSvnInfoRevision } from './svnLog.js'
19
+ export { batchResultOf, commitResultOf, updateResultOf } from './svnText.js'
20
+ export { mapRepoPath, parseLogXml } from './svnLog.js'
21
+
22
+ /** 状态载荷 TTL(毫秒):右键/命令只读缓存,过期后台重算。 */
23
+ export const STATUS_TTL_MS = 60_000
24
+ /** svn CLI 探测 TTL(毫秒):PATH 很少变,长缓存避免频繁 spawn。 */
25
+ export const CLI_TTL_MS = 5 * 60_000
26
+ /** CLI 探测超时。 */
27
+ const CLI_GRACE_MS = 6_000
28
+ /** svn update 超时(大工作副本留足预算)。 */
29
+ const UPDATE_GRACE_MS = 180_000
30
+ /** TortoiseProc 发射超时预算(不 await done,仅约束服务侧资源)。 */
31
+ const TORTOISE_GRACE_MS = 600_000
32
+ /** 前台置顶助手超时预算(轮询 ≤8s + PowerShell 启动余量)。 */
33
+ const HELPER_GRACE_MS = 20_000
34
+ /** 输出载荷上限(1MB,对齐 runSvn stdio 限额;文本解析上限见 svnText.TEXT_OUTPUT_CAP)。 */
35
+ const OUTPUT_CAP = TEXT_OUTPUT_CAP
36
+ /**
37
+ * 日志拉取的 stdout 上限:默认窗口 LOG_CAP=500 条、Show All 上限 SVN_LOG_SHOW_ALL_CAP=5000 条。
38
+ *
39
+ * 实测(2026-09-18,IslandSplash_BugFix `Assets`,svn 1.14.5):`-l 500` → 2.52MB / 2.0s,
40
+ * `-l 5000` → 18.86MB / 30.1s(约 3.8~5KB/条,含中文路径与提交信息)。故取 64MB 留约 3× 余量,
41
+ * `-g`(merged)单条更大也吃这份余量。
42
+ * 实测教训(勿复踩):默认 1MB 上限时收集器保留**尾部**,Assets 全量 100 条(1.19MB)
43
+ * 恰好把最新的 52 条挤出(用户报「显示的数据不对」)。
44
+ * @author ddj 2026年09月17号 / 2026年09月18号
45
+ */
46
+ const LOG_OUTPUT_CAP = 64 * 1024 * 1024
47
+ /** 变更条目上限(防超大工作副本撑爆载荷)。 */
48
+ export const CHANGES_CAP = 4000
49
+ /** 批量动作路径数上限(revert/add 单次操作目标数)。 */
50
+ export const BATCH_PATHS_CAP = 64
51
+ /** `svn status --xml` 超时(大工作副本留足预算)。 */
52
+ const STATUS_GRACE_MS = 120_000
53
+ /** 其他只读子命令(cat/info)超时。 */
54
+ const READ_GRACE_MS = 30_000
55
+ /** revert/add 超时。 */
56
+ const MUTATE_GRACE_MS = 120_000
57
+ /** 变更 TTL(毫秒):与状态同源,避免频繁全量 status。 */
58
+ export const CHANGES_TTL_MS = 5_000
59
+ /** 文本读取上限(对齐 rpc.ts READ_CAP:>8MB 拒绝整文件读取/差异)。 */
60
+ const READ_CAP = 8 * 1024 * 1024
61
+
62
+ /** 文件存在性探测(可注入替身)。 */
63
+ export type ExistsFn = (path: string) => Promise<boolean>
64
+
65
+ /** 工作副本根向上查找(≤10 层,命中含 .svn 的最近祖先)。 */
66
+ const SVN_ROOT_MAX_DEPTH = 10
67
+
68
+ /** 默认存在性探测(node fs;异常一律按不存在)。 */
69
+ const defaultExists: ExistsFn = async (path) => {
70
+ try {
71
+ await stat(path)
72
+ return true
73
+ } catch {
74
+ return false
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 从 cwd 向上查找 SVN 工作副本根(.svn 目录/文件均算,SvnHelper 同口径)。
80
+ * `.svn` 判定与平台无关(Linux/macOS 亦为目录),无需平台参数。
81
+ * @author ddj 2026年09月16号
82
+ * @param cwd 起始绝对路径
83
+ * @param exists 存在性探测(测试注入)
84
+ * @returns 工作副本根绝对路径;未命中返回 null
85
+ */
86
+ export async function findSvnRoot(
87
+ cwd: string,
88
+ exists: ExistsFn = defaultExists,
89
+ ): Promise<string | null> {
90
+ let dir = cwd
91
+ for (let depth = 0; dir && depth < SVN_ROOT_MAX_DEPTH; depth += 1) {
92
+ if (await exists(join(dir, '.svn'))) return dir
93
+ const parent = dirname(dir)
94
+ if (parent === dir) return null
95
+ dir = parent
96
+ }
97
+ return null
98
+ }
99
+
100
+ /**
101
+ * 解析 TortoiseProc.exe(候选序:设置目录 → bin 子目录 → 默认安装目录 → PATH 逐项)。
102
+ * @author ddj 2026年09月16号
103
+ * @param dirSetting 设置里的 TortoiseSVN 目录(空 = 默认)
104
+ * @param exists 存在性探测(测试注入)
105
+ * @param platform 目标平台(非 win32 恒 null)
106
+ * @param pathEnv PATH 环境变量(测试注入)
107
+ * @returns TortoiseProc.exe 绝对路径;不可用返回 null
108
+ */
109
+ export async function findTortoiseProc(
110
+ dirSetting: string,
111
+ exists: ExistsFn = defaultExists,
112
+ platform: NodeJS.Platform = process.platform,
113
+ pathEnv: string | undefined = process.env?.PATH,
114
+ ): Promise<string | null> {
115
+ if (platform !== 'win32') return null
116
+ const dir = dirSetting && dirSetting.trim() ? dirSetting.trim() : ''
117
+ const candidates = [
118
+ dir && join(dir, 'bin', TORTOISE_EXE),
119
+ dir && join(dir, TORTOISE_EXE),
120
+ ].filter((item): item is string => Boolean(item))
121
+ for (const entry of (pathEnv ?? '').split(';')) {
122
+ const trim = entry.trim()
123
+ if (trim) candidates.push(join(trim, TORTOISE_EXE))
124
+ }
125
+ for (const candidate of candidates) {
126
+ if (await exists(candidate)) return candidate
127
+ }
128
+ return null
129
+ }
130
+
131
+ /** 设置里 SVN 路径的最小读取面(setupOpenSettings 提供)。 */
132
+ export interface SvnSettingsLike {
133
+ svn: () => { svnPath: string; tortoisePath: string }
134
+ }
135
+
136
+ /**
137
+ * 本插件已自研的 SVN 能力(「自研替换时间线」的推进点)。
138
+ *
139
+ * 每完成一个阶段就把对应能力加进来:client 会据此隐藏被覆盖的 TortoiseProc 项,
140
+ * 避免自研项与官方同名项并列、用户误点到官方弹窗。
141
+ * 当前:差异(P2「与基线比较」)、还原(P2 `revert -R`)、日志(P3 日志弹窗)。
142
+ * 待补:提交(P4 完成后加入 'commit');追溯无自研计划(P5 另议),故不含 'blame'。
143
+ */
144
+ export const SVN_FEATURES: readonly SvnFeature[] = ['diff', 'revert', 'log']
145
+
146
+ // --region P2 工作副本状态解析
147
+
148
+ /** `svn status --xml` 的 `<wc-status item="...">` 合法取值(未知值一律按 modified 兜底)。 */
149
+ const KNOWN_ITEM_STATUS: ReadonlySet<string> = new Set([
150
+ 'normal', 'added', 'deleted', 'replaced', 'conflicted',
151
+ 'missing', 'unversioned', 'obstructed', 'ignored', 'modified',
152
+ 'external', 'incomplete',
153
+ ])
154
+
155
+ /** 受版本控制判据:unversioned/ignored 之外均为版本化条目。 */
156
+ const UNVERSIONED_STATUS: ReadonlySet<string> = new Set(['unversioned', 'ignored'])
157
+
158
+ /**
159
+ * 归一化 XML 中的条目路径为工作区相对路径(`/` 分隔、无前导 `./`)。
160
+ * XML 路径相对「执行 status 时的 cwd」(实测 Windows 下用 `\`),故 host 必须以
161
+ * 工作副本根为 cwd 执行,这里只做分隔符与形态归一;`''`/`.` 表示根自身。
162
+ * @author ddj 2026年09月16号
163
+ * @param raw XML path 属性原文(已反转义)
164
+ * @returns 归一化路径;根自身返回 ''
165
+ */
166
+ export function changePathOf(raw: string): string {
167
+ const slashed = String(raw ?? '').replace(/\\/g, '/').trim()
168
+ const stripped = slashed.replace(/^(?:\.\/)+/, '').replace(/\/+$/, '')
169
+ return stripped === '.' ? '' : stripped
170
+ }
171
+
172
+ /**
173
+ * 解析 `svn status --xml` 输出为变更清单(纯函数,无第三方 XML 依赖)。
174
+ *
175
+ * 结构要点(实测 svn 1.14.5):
176
+ * - `<target path="..."><entry path="..."><wc-status item="..." props="..." revision="..."/></entry>...`
177
+ * - 登记过 changelist 的条目被抽到 `<changelist name="...">` 块内(与 target 平级),
178
+ * 故解析必须按块回填 changelist 名,不能只看 `<entry>`。
179
+ * - 根自身条目(`path="."`)在属性被改时出现,`item="normal"` 不代表变更;路径为空的
180
+ * 条目一律跳过,避免把根当成变更项展示。
181
+ *
182
+ * @author ddj 2026年09月16号
183
+ * @param xml status --xml 的 stdout
184
+ * @param cap 条目上限(超出标记 truncated)
185
+ * @returns 变更清单与是否截断
186
+ */
187
+ export function parseStatusXml(xml: string, cap: number = CHANGES_CAP): { entries: SvnChangeEntry[]; truncated: boolean } {
188
+ const entries: SvnChangeEntry[] = []
189
+ let truncated = false
190
+ if (typeof xml !== 'string' || !xml) return { entries, truncated }
191
+ let changelist = ''
192
+ let pendingPath: string | null = null
193
+ for (const hit of scanXmlTags(xml, ['changelist', 'entry', 'wc-status'])) {
194
+ if (hit.name === 'changelist') {
195
+ changelist = hit.closing ? '' : attrOf(hit.attrs, 'name')
196
+ continue
197
+ }
198
+ if (hit.name === 'entry') {
199
+ pendingPath = hit.closing ? null : attrOf(hit.attrs, 'path')
200
+ continue
201
+ }
202
+ // 闭合标签 `</wc-status>` 只是结构收尾(无属性),不得当成新条目——旧实现会据此产出
203
+ // 一条 `item` 缺失的兜底 'modified' 条目,同一路径出现两条(实测由本模块单测抓出)
204
+ if (hit.closing) { pendingPath = null; continue }
205
+ if (!pendingPath) continue
206
+ const rawItem = attrOf(hit.attrs, 'item')
207
+ const item = (rawItem || 'modified') as SvnItemStatus
208
+ const status: SvnItemStatus = KNOWN_ITEM_STATUS.has(item) ? item : 'modified'
209
+ const path = changePathOf(pendingPath)
210
+ if (!path) { pendingPath = null; continue }
211
+ if (entries.length >= cap) { truncated = true }
212
+ else {
213
+ const props = attrOf(hit.attrs, 'props')
214
+ const revision = numAttrOf(hit.attrs, 'revision')
215
+ entries.push({
216
+ path,
217
+ status,
218
+ props: props === 'modified' || props === 'conflicted' ? props : 'none',
219
+ versioned: !UNVERSIONED_STATUS.has(status),
220
+ ...(changelist ? { changelist } : {}),
221
+ ...(revision !== undefined && revision >= 0 ? { revision } : {}),
222
+ })
223
+ }
224
+ // 自闭合形态(`<wc-status .../>`)到此结束;长形态由 `</entry>` 收尾
225
+ if (hit.selfClosing) pendingPath = null
226
+ }
227
+ return { entries, truncated }
228
+ }
229
+
230
+ // --endregion
231
+
232
+ /** 一次 svn CLI 运行结果(输出可能因 stdio 回退而为空)。 */
233
+ interface SvnRunOutcome { code: number | null; stdout: string; stderr: string }
234
+
235
+ /** subprocess 产句柄最小结构面(done 载荷对齐 revert.ts 的 exitCode/code 双读)。 */
236
+ interface SpawnedHandle {
237
+ done: Promise<{ exitCode?: number; code?: number } | undefined>
238
+ collected?: {
239
+ stdout?: { readFrom: (n: number) => { text: string } }
240
+ stderr?: { readFrom: (n: number) => { text: string } }
241
+ }
242
+ }
243
+
244
+ /** subprocess 服务 spawn 方法最小结构面。 */
245
+ type SpawnFn = (spec: Record<string, unknown>) => SpawnedHandle
246
+
247
+ /** 进程名(去路径去 .exe;前台助手用)。 */
248
+ export function processNameOf(exe: string): string {
249
+ const base = exe.split(/[\\/]/).pop() ?? exe
250
+ return base.replace(/\.exe$/i, '')
251
+ }
252
+
253
+ /**
254
+ * 前台置顶助手 argv(纯函数)。
255
+ * 背景:Windows 前台锁——后台进程(DSH host)启动的窗口默认抢不到前台(任务栏闪烁),
256
+ * 前台进程的子进程才继承置顶权(SvnHelper 依赖 VSCode 在前台故无此问题)。
257
+ * 方案:独立 PowerShell 助手轮询新进程主窗口,SwitchToThisWindow 强制切换
258
+ * (任务栏同源 API,无前台权限也可切窗);脚本内不含任何用户数据(按进程名发现),无注入面。
259
+ * @author ddj 2026年09月16号
260
+ * @param processName 目标进程名(不带 .exe)
261
+ * @returns powershell 助手 argv
262
+ */
263
+ export function foregroundHelperArgv(processName: string): string[] {
264
+ const script = [
265
+ '$cut = (Get-Date).AddSeconds(-2)',
266
+ '$deadline = $cut.AddSeconds(8)',
267
+ '$h = [IntPtr]::Zero',
268
+ 'while ((Get-Date) -lt $deadline) {',
269
+ ' Start-Sleep -Milliseconds 150',
270
+ ' $p = Get-Process -Name "' + processName + '" -ErrorAction SilentlyContinue | Where-Object { $_.StartTime -ge $cut -and $_.MainWindowHandle -ne 0 } | Sort-Object StartTime -Descending | Select-Object -First 1',
271
+ ' if ($p) { $h = $p.MainWindowHandle; break }',
272
+ '}',
273
+ 'if ($h -ne [IntPtr]::Zero) {',
274
+ " Add-Type -Namespace W -Name N -MemberDefinition '[DllImport(\"user32.dll\")] public static extern void SwitchToThisWindow(IntPtr h, bool a);'",
275
+ ' [W.N]::SwitchToThisWindow($h, $true)',
276
+ '}',
277
+ ].join('\n')
278
+ return ['powershell', '-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', script]
279
+ }
280
+
281
+ /** createSvnRpc 依赖(时钟/查找器/平台可注入便于单测)。 */
282
+ export interface SvnRpcDeps {
283
+ ctx: Ctx
284
+ settings: SvnSettingsLike
285
+ now?: () => number
286
+ /** 工作副本根查找(测试注入;缺省 findSvnRoot)。 */
287
+ findRoot?: (cwd: string) => Promise<string | null>
288
+ /** TortoiseProc 查找(测试注入;缺省 findTortoiseProc)。 */
289
+ findTortoise?: (dir: string) => Promise<string | null>
290
+ /** 目标平台(测试注入;缺省当前进程平台,前台助手仅 win32 发射)。 */
291
+ platform?: NodeJS.Platform
292
+ }
293
+
294
+ /** host 会话/工作区前置(对齐 rpc.ts requireSession 的错误文案)。 */
295
+ async function requireSvnSession(
296
+ ctx: Ctx,
297
+ sessionId?: string,
298
+ ): Promise<{ cwd: string } | { err: string }> {
299
+ const session: Session | undefined = sessionOf(ctx, sessionId)
300
+ if (!session) return { err: '会话不存在' }
301
+ const cwd = cwdOf(session)
302
+ if (!cwd) return { err: '会话无工作区' }
303
+ return { cwd }
304
+ }
305
+
306
+ /** 工作区相对路径归一('' 合法 = 工作区根;非法含越界/绝对路径返回 null)。 */
307
+ function safeRel(raw: unknown): string | null {
308
+ if (raw === undefined || raw === null || raw === '') return ''
309
+ if (typeof raw !== 'string') return null
310
+ const rel = raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
311
+ if (rel === '') return ''
312
+ if (rel === '..' || rel.startsWith('../') || rel.includes('/../')) return null
313
+ if (/^[A-Za-z]:/.test(rel) || rel.startsWith('/')) return null
314
+ return rel
315
+ }
316
+
317
+ /**
318
+ * 创建 svn.* RPC handlers(缓存随装配生命周期;lsp/ai 同模式并入 buildHandlers)。
319
+ * @author ddj 2026年09月16号
320
+ * @param deps ctx + 设置读取面 + 可注入时钟
321
+ * @returns handlers(Partial<RpcHandlerMap> 形状)
322
+ */
323
+ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unknown> } {
324
+ const { ctx, settings } = deps
325
+ const now = deps.now ?? (() => Date.now())
326
+ const findRoot = deps.findRoot ?? ((cwd: string) => findSvnRoot(cwd))
327
+ const findTortoise = deps.findTortoise ?? ((dir: string) => findTortoiseProc(dir))
328
+ const statusCache = new Map<string, { at: number; payload: SvnStatusPayload }>()
329
+ /** 变更清单缓存(键 = 工作副本根;TTL 短,仅合并同一轮 UI 的多处拉取)。 */
330
+ const changesCache = new Map<string, { at: number; value: { entries: SvnChangeEntry[]; truncated: boolean } }>()
331
+ /** 工作副本根 relative-url 缓存(日志路径映射用;探测失败也缓存空串避免反复 spawn)。 */
332
+ const urlCache = new Map<string, { at: number; value: string }>()
333
+ let cliProbe: { at: number; exe: string; ok: boolean } | null = null
334
+
335
+ const subprocess = (): SpawnFn | null => {
336
+ const sub = ctx.get('subprocess') as { spawn?: unknown } | null | undefined
337
+ if (!sub || typeof sub.spawn !== 'function') return null
338
+ return (sub.spawn as SpawnFn).bind(sub)
339
+ }
340
+
341
+ /** svn CLI 探测(inherit stdio 只取退出码;TTL 缓存按 exe 键)。 */
342
+ const probeCli = async (exe: string): Promise<boolean> => {
343
+ const at = now()
344
+ if (cliProbe && cliProbe.exe === exe && at - cliProbe.at < CLI_TTL_MS) return cliProbe.ok
345
+ let ok = false
346
+ const spawn = subprocess()
347
+ if (spawn) {
348
+ try {
349
+ const handle = spawn({
350
+ argv: [exe, '--version', '--quiet'],
351
+ // subprocess 服务要求 spec.cwd 必须为字符串(validateNoNullByte 对 cwd 做 .includes,
352
+ // 缺省即抛 TypeError——已实测),无工作目录语义的探测统一落在系统临时目录。
353
+ cwd: tmpdir(),
354
+ stdio: { stdout: 'inherit', stderr: 'inherit', stdin: 'ignore' },
355
+ graceMs: CLI_GRACE_MS,
356
+ })
357
+ const outcome = await handle.done
358
+ ok = (outcome?.exitCode ?? outcome?.code) === 0
359
+ } catch {
360
+ ok = false
361
+ }
362
+ }
363
+ cliProbe = { at, exe, ok }
364
+ return ok
365
+ }
366
+
367
+ /** 合成状态(managed/svnCli/tortoise),带 TTL 缓存。 */
368
+ const statusOf = async (cwd: string, force = false): Promise<SvnStatusPayload> => {
369
+ const at = now()
370
+ const hit = statusCache.get(cwd)
371
+ if (!force && hit && at - hit.at < STATUS_TTL_MS) return hit.payload
372
+ const configured = settings.svn()
373
+ const cliExe = configured.svnPath.trim() || SVN_CLI_DEFAULT
374
+ const wcRoot = await findRoot(cwd)
375
+ const svnCli = await probeCli(cliExe)
376
+ const tortoiseExe = await findTortoise(configured.tortoisePath)
377
+ const payload: SvnStatusPayload = {
378
+ managed: wcRoot !== null,
379
+ wcRoot,
380
+ svnCli,
381
+ tortoise: tortoiseExe !== null,
382
+ // 自研替换时间线:声明本插件已自研的能力,client 据此隐藏对应 Tortoise 过渡项
383
+ // (差异/还原 = P2,日志 = P3;提交待 P4,追溯未自研,均不在此列)
384
+ svnFeatures: SVN_FEATURES,
385
+ svnPath: cliExe,
386
+ tortoiseExe: tortoiseExe ?? '',
387
+ }
388
+ statusCache.set(cwd, { at, payload })
389
+ return payload
390
+ }
391
+
392
+ /**
393
+ * 运行 svn CLI(管道收集;spawn 级失败回退 inherit,输出降级为空)。
394
+ * @author ddj 2026年08月26号 / 2026年09月17号
395
+ * @param cwd 运行目录
396
+ * @param argv 参数(不含可执行文件名;自动加 --non-interactive)
397
+ * @param graceMs 进程宽限
398
+ * @param stdoutCap stdout 收集上限(缺省 1MB;大载荷场景如日志拉取需显式放大)
399
+ * @returns 退出码与输出
400
+ */
401
+ const runSvn = async (cwd: string, argv: string[], graceMs: number, stdoutCap: number = OUTPUT_CAP): Promise<SvnRunOutcome> => {
402
+ const spawn = subprocess()
403
+ if (!spawn) throw new Error('缺少 subprocess 服务')
404
+ const fs = ctx.get('fs') as { resolve?: (p: string, o: object) => Promise<unknown>; processPath?: (t: unknown) => string } | null | undefined
405
+ let realCwd = cwd
406
+ if (fs?.resolve && fs?.processPath) realCwd = fs.processPath(await fs.resolve(cwd, {}))
407
+ const exe = settings.svn().svnPath.trim() || SVN_CLI_DEFAULT
408
+ const attempt = (mode: 'pipe' | 'inherit') => spawn({
409
+ argv: [exe, '--non-interactive', ...argv],
410
+ cwd: realCwd,
411
+ stdio: mode === 'pipe'
412
+ ? { stdout: { maxBytes: stdoutCap }, stderr: { maxBytes: OUTPUT_CAP }, stdin: 'ignore' }
413
+ : { stdout: 'inherit', stderr: 'inherit', stdin: 'ignore' },
414
+ graceMs,
415
+ })
416
+ try {
417
+ const handle = attempt('pipe')
418
+ const outcome = await handle.done
419
+ const stdout = handle.collected?.stdout?.readFrom(0).text ?? ''
420
+ const stderr = handle.collected?.stderr?.readFrom(0).text ?? ''
421
+ return { code: outcome?.exitCode ?? outcome?.code ?? null, stdout, stderr }
422
+ } catch {
423
+ // 受管环境管道 EPERM:回退 inherit(只拿退出码,输出为空)
424
+ const handle = attempt('inherit')
425
+ const outcome = await handle.done
426
+ return { code: outcome?.exitCode ?? outcome?.code ?? null, stdout: '', stderr: '' }
427
+ }
428
+ }
429
+
430
+ /** 发射 TortoiseProc(GUI 长驻:不 await done,no-op catch 防未处理 rejection)。 */
431
+ const launchTortoise = (exe: string, action: SvnAction, absPath: string): void => {
432
+ const spawn = subprocess()
433
+ if (!spawn) throw new Error('缺少 subprocess 服务')
434
+ const handle = spawn({
435
+ argv: tortoiseLaunchArgv(exe, action, absPath),
436
+ // spec.cwd 必须为字符串(subprocess 契约,缺省即抛 TypeError);GUI 进程不依赖 cwd,落目标父目录
437
+ cwd: dirname(absPath),
438
+ stdio: { stdout: 'inherit', stderr: 'inherit', stdin: 'ignore' },
439
+ graceMs: TORTOISE_GRACE_MS,
440
+ })
441
+ void handle.done.catch(() => {})
442
+ // 前台置顶助手:后台进程启动的窗口抢不到前台(任务栏闪烁),SwitchToThisWindow 兜底;
443
+ // best-effort——助手失败仅表现为窗口不上浮,不影响对话框本身
444
+ if ((deps.platform ?? process.platform) !== 'win32') return
445
+ try {
446
+ const helper = spawn({
447
+ argv: foregroundHelperArgv(processNameOf(exe)),
448
+ cwd: tmpdir(),
449
+ stdio: { stdout: 'inherit', stderr: 'inherit', stdin: 'ignore' },
450
+ graceMs: HELPER_GRACE_MS,
451
+ })
452
+ void helper.done.catch(() => {})
453
+ } catch {
454
+ // 置顶失败不影响对话框启动
455
+ }
456
+ }
457
+
458
+ /**
459
+ * 读取并缓存工作副本变更(`svn status --xml --no-ignore`,以工作副本根为 cwd 执行)。
460
+ *
461
+ * 为什么固定以 wcRoot 为 cwd:XML 里的 path 是相对「执行时的 cwd」的(实测 Windows 用
462
+ * `\`),固定根目录后路径即为「工作副本根相对」,与 client 的 scope/树路径口径一致;
463
+ * 恒带 `--no-ignore` 取全集(默认不返回 ignored),显示开关交给客户端,避免为开关重跑。
464
+ * @author ddj 2026年09月16号
465
+ * @param wcRoot 工作副本根绝对路径
466
+ * @param force 跳过 TTL 强制重查
467
+ * @returns 变更清单与截断标记
468
+ */
469
+ const changesOf = async (wcRoot: string, force = false): Promise<{ entries: SvnChangeEntry[]; truncated: boolean }> => {
470
+ const at = now()
471
+ const hit = changesCache.get(wcRoot)
472
+ if (!force && hit && at - hit.at < CHANGES_TTL_MS) return hit.value
473
+ const outcome = await runSvn(wcRoot, ['status', '--xml', '--no-ignore'], STATUS_GRACE_MS)
474
+ if (outcome.code !== 0) {
475
+ throw new Error(tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join('\n')))
476
+ }
477
+ const value = parseStatusXml(outcome.stdout)
478
+ changesCache.set(wcRoot, { at, value })
479
+ return value
480
+ }
481
+
482
+ /**
483
+ * 批量路径动作公共流程(revert/add 共用):会话 → 路径白名单与上限 → 受管理校验 → 执行。
484
+ * 路径一律以工作副本根为 cwd 传入(`--` 之后),避免以 cwd 为基准产生歧义。
485
+ * @author ddj 2026年09月16号
486
+ * @param verb 动作动词(摘要用)
487
+ * @param verbArgs 子命令与选项(如 ['revert','-R'])
488
+ * @param sessionId 会话 id
489
+ * @param paths 工作区相对路径列表
490
+ * @returns 动作结果或错误
491
+ */
492
+ const mutate = async (
493
+ verb: string,
494
+ verbArgs: string[],
495
+ sessionId: string | undefined,
496
+ paths: unknown,
497
+ ): Promise<
498
+ | { ok: false; error: string }
499
+ | { ok: true; count: number; summary: string; output: string }
500
+ > => {
501
+ const sc = await requireSvnSession(ctx, sessionId)
502
+ if ('err' in sc) return { ok: false as const, error: sc.err }
503
+ if (!Array.isArray(paths) || !paths.length) return { ok: false as const, error: '未选择任何路径' }
504
+ if (paths.length > BATCH_PATHS_CAP) {
505
+ return { ok: false as const, error: '一次最多处理 ' + BATCH_PATHS_CAP + ' 个路径' }
506
+ }
507
+ const rels: string[] = []
508
+ for (const item of paths) {
509
+ const rel = safeRel(item)
510
+ if (rel === null || rel === '') return { ok: false as const, error: '路径不合法' }
511
+ rels.push(rel)
512
+ }
513
+ const status = await statusOf(sc.cwd)
514
+ if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
515
+ if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
516
+ try {
517
+ const outcome = await runSvn(status.wcRoot, [...verbArgs, '--', ...rels], MUTATE_GRACE_MS)
518
+ const result = batchResultOf(outcome.stdout, outcome.stderr, outcome.code, verb)
519
+ // 变更清单已过期(revert/add 改工作副本状态):主动作废缓存,下次拉取即为最新
520
+ changesCache.delete(status.wcRoot)
521
+ if (outcome.code !== 0) return { ok: false as const, error: verb + '失败:' + (result.output ? tailOfText(result.output) : '未知错误') }
522
+ return { ok: true as const, count: result.count, summary: result.summary, output: result.output }
523
+ } catch (error) {
524
+ return { ok: false as const, error: verb + '失败:' + String(error) }
525
+ }
526
+ }
527
+
528
+ /**
529
+ * 读取并缓存工作副本根的 `relative-url`(`^/trunk`),供日志路径映射用。
530
+ * 与状态探测同 TTL 量级:仓库切换(switch/relocate)是低频操作,缓存 60s 足够且省一次 spawn。
531
+ * @author ddj 2026年09月16号
532
+ * @param wcRoot 工作副本根
533
+ * @returns relative-url(探测失败返回 '')
534
+ */
535
+ const relativeUrlOf = async (wcRoot: string): Promise<string> => {
536
+ const at = now()
537
+ const hit = urlCache.get(wcRoot)
538
+ if (hit && at - hit.at < STATUS_TTL_MS) return hit.value
539
+ let value = ''
540
+ try {
541
+ const outcome = await runSvn(wcRoot, ['info', '--xml'], READ_GRACE_MS)
542
+ if (outcome.code === 0) value = textOf(outcome.stdout, 'relative-url')
543
+ } catch {
544
+ value = ''
545
+ }
546
+ urlCache.set(wcRoot, { at, value })
547
+ return value
548
+ }
549
+
550
+ /**
551
+ * 拉取提交日志(`svn log --xml -v`)。
552
+ * 必须显式 `-r HEAD:1`:实测默认范围是 `BASE:1`,工作副本落后时几乎拿不到日志。
553
+ * @author ddj 2026年09月16号 / 2026年09月18号
554
+ * @param wcRoot 工作副本根
555
+ * @param target 目标相对路径('' = 根)
556
+ * @param limit 条数上限
557
+ * @param opts stopOnCopy(P1-4)/ range(P1-6)/ showMerged(P1-5,argv `-g`)
558
+ * @param cap 条数硬上限(P1-7 Show All 传 SVN_LOG_SHOW_ALL_CAP,默认 LOG_CAP)
559
+ * @returns 日志条目与截断标记
560
+ */
561
+ const logOf = async (
562
+ wcRoot: string,
563
+ target: string,
564
+ limit: number,
565
+ opts: { stopOnCopy?: boolean; range?: { start: number; end: number } | null; showMerged?: boolean } = {},
566
+ cap: number = LOG_CAP,
567
+ ): Promise<{ entries: SvnLogEntry[]; truncated: boolean; limit: number }> => {
568
+ const capped = Math.max(1, Math.min(cap, Math.floor(limit) || LOG_DEFAULT_LIMIT))
569
+ const relativeUrl = await relativeUrlOf(wcRoot)
570
+ // 选项必须全部在 `--` 之前(G3);默认窗口 HEAD:1(G1),Show Range 用 -r START:END(P1-6)
571
+ const revArg = opts.range ? String(opts.range.start) + ':' + String(opts.range.end) : 'HEAD:1'
572
+ const argv = ['log', '-r', revArg, '-l', String(capped), '--xml', '-v']
573
+ // P1-4 Stop on copy(§6 M2 已实测:仅截断条目集合,解析零改动)
574
+ if (opts.stopOnCopy) argv.push('--stop-on-copy')
575
+ // P1-5 Include merged revisions(§6 M1 已实测:嵌套 logentry + reverse-merge)
576
+ if (opts.showMerged) argv.push('-g')
577
+ if (target) argv.push('--', target)
578
+ const outcome = await runSvn(wcRoot, argv, READ_GRACE_MS, LOG_OUTPUT_CAP)
579
+ if (outcome.code !== 0) {
580
+ throw new Error(tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join('\n')))
581
+ }
582
+ const parsed = parseLogXml(outcome.stdout, relativeUrl)
583
+ // 请求数已达上限说明可能还有更多(用于「加载更多」按钮);limit 回传供 client 记录 loadedLimit(P1-8)
584
+ return { entries: parsed.entries, truncated: parsed.entries.length >= capped, limit: capped }
585
+ }
586
+
587
+ /**
588
+ * 读取单文件某版本的左右两侧内容(`cat -r REV` vs `cat -r REV-1`)。
589
+ *
590
+ * 左侧在「该路径在该版本不存在」时(实测新增文件报 E195012)返回 null 而非报错:
591
+ * 这正是「新增」的正常语义,UI 应把左侧渲染为空而不是弹错误。
592
+ * @author ddj 2026年09月16号
593
+ * @param wcRoot 工作副本根
594
+ * @param rel 工作区相对路径
595
+ * @param revision 目标版本
596
+ * @returns 双侧内容
597
+ */
598
+ const diffRevSides = async (wcRoot: string, rel: string, revision: number): Promise<{ left: string | null; right: string | null; reason?: string; error?: string }> => {
599
+ const rightOutcome = await runSvn(wcRoot, ['cat', '-r', String(revision), '--', rel], READ_GRACE_MS)
600
+ if (rightOutcome.code !== 0) {
601
+ return { left: null, right: null, reason: 'not-exist', error: tailOfText(rightOutcome.stderr || rightOutcome.stdout) }
602
+ }
603
+ const prev = revision - 1
604
+ if (prev < 1) return { left: null, right: rightOutcome.stdout, reason: 'not-exist' }
605
+ const leftOutcome = await runSvn(wcRoot, ['cat', '-r', String(prev), '--', rel], READ_GRACE_MS)
606
+ if (leftOutcome.code !== 0) {
607
+ // 该版本尚无此文件(新增/复制):左侧为空即可,不是错误
608
+ return { left: null, right: rightOutcome.stdout, reason: 'not-exist' }
609
+ }
610
+ return { left: leftOutcome.stdout, right: rightOutcome.stdout }
611
+ }
612
+
613
+ /**
614
+ * 读取单文件两个指定版本的左右两侧内容(P1-2 比较两个修订;与 diffRevSides 同口径)。
615
+ *
616
+ * 任一侧 cat 失败返回 null + reason(新增/删除侧为正常业务分支),UI 渲染为空而非报错。
617
+ * @author ddj 2026年09月17号
618
+ * @param wcRoot 工作副本根
619
+ * @param rel 工作区相对路径
620
+ * @param revA 左侧版本(选中顺序在前)
621
+ * @param revB 右侧版本(选中顺序在后)
622
+ * @returns 双侧内容
623
+ */
624
+ const pairSides = async (wcRoot: string, rel: string, revA: number, revB: number): Promise<{ left: string | null; right: string | null; reason?: string; error?: string }> => {
625
+ const sides: Array<{ rev: number; text: string | null; reason?: string; error?: string }> = []
626
+ for (const rev of [revA, revB]) {
627
+ const outcome = await runSvn(wcRoot, ['cat', '-r', String(rev), '--', rel], READ_GRACE_MS)
628
+ if (outcome.code !== 0) {
629
+ sides.push({ rev, text: null, reason: 'not-exist', error: tailOfText(outcome.stderr || outcome.stdout) })
630
+ } else {
631
+ sides.push({ rev, text: outcome.stdout })
632
+ }
633
+ }
634
+ return {
635
+ left: sides[0].text,
636
+ right: sides[1].text,
637
+ reason: sides[0].reason ?? sides[1].reason,
638
+ error: sides[0].error ?? sides[1].error,
639
+ }
640
+ }
641
+
642
+ const handlers = {
643
+ 'svn.status': async (args: { sessionId?: string; force?: boolean }) => {
644
+ const sc = await requireSvnSession(ctx, args.sessionId)
645
+ if ('err' in sc) return { ok: false as const, error: sc.err }
646
+ const payload = await statusOf(sc.cwd, args.force === true)
647
+ return { ok: true as const, ...payload }
648
+ },
649
+ 'svn.changes': async (args: { sessionId?: string; force?: boolean }) => {
650
+ const sc = await requireSvnSession(ctx, args.sessionId)
651
+ if ('err' in sc) return { ok: false as const, error: sc.err }
652
+ const status = await statusOf(sc.cwd)
653
+ if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
654
+ if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
655
+ try {
656
+ const result = await changesOf(status.wcRoot, args.force === true)
657
+ return { ok: true as const, wcRoot: status.wcRoot, ...result }
658
+ } catch (error) {
659
+ return { ok: false as const, error: '读取变更失败:' + String(error) }
660
+ }
661
+ },
662
+ 'svn.diffBase': async (args: { sessionId?: string; path?: string }) => {
663
+ const sc = await requireSvnSession(ctx, args.sessionId)
664
+ if ('err' in sc) return { ok: false as const, error: sc.err }
665
+ const rel = safeRel(args.path)
666
+ if (rel === null || rel === '') return { ok: false as const, error: '路径不合法' }
667
+ const status = await statusOf(sc.cwd)
668
+ if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
669
+ if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
670
+ const fs = ctx.get('fs') as {
671
+ resolve?: (p: string, o: object) => Promise<unknown>
672
+ stat?: (t: unknown) => Promise<{ type?: string; size?: number } | null>
673
+ readText?: (t: unknown) => Promise<string>
674
+ } | null | undefined
675
+ if (!fs?.resolve || !fs?.stat || !fs?.readText) return { ok: false as const, error: '缺少 fs' }
676
+ try {
677
+ const target = await fs.resolve(rel, { cwd: sc.cwd })
678
+ const info = await fs.stat(target)
679
+ if (!info || info.type !== 'file') return { ok: false as const, error: '文件不存在' }
680
+ if ((info.size ?? 0) > READ_CAP) return { ok: false as const, error: '文件过大(>8MB),不支持基线差异' }
681
+ const working = await fs.readText(target)
682
+ // BASE 侧:以工作副本根为 cwd 执行(与 svn.changes 同源,保证相对路径语义一致)
683
+ const outcome = await runSvn(status.wcRoot, ['cat', '-r', 'BASE', '--', rel], READ_GRACE_MS)
684
+ if (outcome.code !== 0) {
685
+ // added/未提交条目没有 pristine:非致命,按原因返回(客户端提示而非报错面板)
686
+ const text = (outcome.stderr || outcome.stdout).trim()
687
+ const reason = /pristine|E200009/i.test(text) ? 'no-pristine' : 'cat-failed'
688
+ return { ok: true as const, base: null, working, reason, error: tailOfText(text) }
689
+ }
690
+ return { ok: true as const, base: outcome.stdout, working }
691
+ } catch (error) {
692
+ return { ok: false as const, error: '读取基线失败:' + String(error) }
693
+ }
694
+ },
695
+ 'svn.revert': async (args: { sessionId?: string; paths?: string[] }) => {
696
+ return mutate('还原', ['revert', '-R'], args.sessionId, args.paths)
697
+ },
698
+ 'svn.add': async (args: { sessionId?: string; paths?: string[] }) => {
699
+ return mutate('加入版本控制', ['add'], args.sessionId, args.paths)
700
+ },
701
+ 'svn.log': async (args: { sessionId?: string; path?: string; limit?: number; stopOnCopy?: boolean; startRev?: number; endRev?: number; showMerged?: boolean }) => {
702
+ const sc = await requireSvnSession(ctx, args.sessionId)
703
+ if ('err' in sc) return { ok: false as const, error: sc.err }
704
+ const rel = safeRel(args.path)
705
+ if (rel === null) return { ok: false as const, error: '路径不合法' }
706
+ const status = await statusOf(sc.cwd)
707
+ if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
708
+ if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
709
+ try {
710
+ const result = await logOf(status.wcRoot, rel, args.limit ?? LOG_DEFAULT_LIMIT, {
711
+ stopOnCopy: args.stopOnCopy === true,
712
+ showMerged: args.showMerged === true,
713
+ range: args.startRev && args.endRev
714
+ ? { start: Math.floor(args.startRev), end: Math.floor(args.endRev) }
715
+ : null,
716
+ }, SVN_LOG_SHOW_ALL_CAP)
717
+ return { ok: true as const, ...result, target: rel }
718
+ } catch (error) {
719
+ return { ok: false as const, error: '读取日志失败:' + String(error) }
720
+ }
721
+ },
722
+ 'svn.diffRev': async (args: { sessionId?: string; path?: string; revision?: number }) => {
723
+ const sc = await requireSvnSession(ctx, args.sessionId)
724
+ if ('err' in sc) return { ok: false as const, error: sc.err }
725
+ const rel = safeRel(args.path)
726
+ if (rel === null || rel === '') return { ok: false as const, error: '路径不合法' }
727
+ const revision = Number(args.revision)
728
+ if (!Number.isFinite(revision) || revision < 1) return { ok: false as const, error: '版本号不合法' }
729
+ const status = await statusOf(sc.cwd)
730
+ if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
731
+ if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
732
+ try {
733
+ const result = await diffRevSides(status.wcRoot, rel, Math.floor(revision))
734
+ return { ok: true as const, ...result }
735
+ } catch (error) {
736
+ return { ok: false as const, error: '读取版本差异失败:' + String(error) }
737
+ }
738
+ },
739
+ 'svn.diffPair': async (args: { sessionId?: string; path?: string; revA?: number; revB?: number }) => {
740
+ const sc = await requireSvnSession(ctx, args.sessionId)
741
+ if ('err' in sc) return { ok: false as const, error: sc.err }
742
+ const rel = safeRel(args.path)
743
+ if (rel === null || rel === '') return { ok: false as const, error: '路径不合法' }
744
+ const revA = Number(args.revA)
745
+ const revB = Number(args.revB)
746
+ if (!Number.isFinite(revA) || revA < 1 || !Number.isFinite(revB) || revB < 1) {
747
+ return { ok: false as const, error: '版本号不合法' }
748
+ }
749
+ const status = await statusOf(sc.cwd)
750
+ if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
751
+ if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
752
+ try {
753
+ const result = await pairSides(status.wcRoot, rel, Math.floor(revA), Math.floor(revB))
754
+ return { ok: true as const, ...result }
755
+ } catch (error) {
756
+ return { ok: false as const, error: '读取版本差异失败:' + String(error) }
757
+ }
758
+ },
759
+ 'svn.diffWorking': async (args: { sessionId?: string; path?: string; revision?: number }) => {
760
+ const sc = await requireSvnSession(ctx, args.sessionId)
761
+ if ('err' in sc) return { ok: false as const, error: sc.err }
762
+ const rel = safeRel(args.path)
763
+ if (rel === null || rel === '') return { ok: false as const, error: '路径不合法' }
764
+ const revision = Number(args.revision)
765
+ if (!Number.isFinite(revision) || revision < 1) return { ok: false as const, error: '版本号不合法' }
766
+ const status = await statusOf(sc.cwd)
767
+ if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
768
+ if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
769
+ const fs = ctx.get('fs') as {
770
+ resolve?: (p: string, o: object) => Promise<unknown>
771
+ stat?: (t: unknown) => Promise<{ type?: string; size?: number } | null>
772
+ readText?: (t: unknown) => Promise<string>
773
+ } | null | undefined
774
+ if (!fs?.resolve || !fs?.stat || !fs?.readText) return { ok: false as const, error: '缺少 fs' }
775
+ try {
776
+ const resolved = await fs.resolve(rel, { cwd: sc.cwd })
777
+ const info = await fs.stat(resolved)
778
+ if (!info || info.type !== 'file') return { ok: false as const, error: '文件不存在' }
779
+ if ((info.size ?? 0) > READ_CAP) return { ok: false as const, error: '文件过大(>8MB),不支持与工作副本比较' }
780
+ const working = await fs.readText(resolved)
781
+ // 左侧 = 该版本内容;cat 失败按「该版本尚无此文件」处理(新增语义,非错误)
782
+ const leftOutcome = await runSvn(status.wcRoot, ['cat', '-r', String(Math.floor(revision)), '--', rel], READ_GRACE_MS)
783
+ if (leftOutcome.code !== 0) {
784
+ const text = (leftOutcome.stderr || leftOutcome.stdout).trim()
785
+ return { ok: true as const, left: null, right: working, reason: 'not-exist', error: tailOfText(text) }
786
+ }
787
+ return { ok: true as const, left: leftOutcome.stdout, right: working }
788
+ } catch (error) {
789
+ return { ok: false as const, error: '读取工作副本差异失败:' + String(error) }
790
+ }
791
+ },
792
+ 'svn.wcRev': async (args: { sessionId?: string; path?: string }) => {
793
+ const sc = await requireSvnSession(ctx, args.sessionId)
794
+ if ('err' in sc) return { ok: false as const, error: sc.err }
795
+ // P1-9 口径:仅文件目标做版号加粗;根/目录返回 null,避免引入 crawl 成本(官方在独立线程做)
796
+ const rel = safeRel(args.path)
797
+ if (rel === null || rel === '') return { ok: true as const, revision: null }
798
+ const status = await statusOf(sc.cwd)
799
+ if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
800
+ if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
801
+ const outcome = await runSvn(status.wcRoot, ['info', '--xml', '--', rel], READ_GRACE_MS)
802
+ if (outcome.code !== 0) return { ok: true as const, revision: null }
803
+ return { ok: true as const, revision: parseSvnInfoRevision(outcome.stdout) }
804
+ },
805
+ 'svn.cleanup': async (args: { sessionId?: string; removeUnversioned?: boolean; removeIgnored?: boolean }) => {
806
+ const sc = await requireSvnSession(ctx, args.sessionId)
807
+ if ('err' in sc) return { ok: false as const, error: sc.err }
808
+ const status = await statusOf(sc.cwd)
809
+ if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
810
+ if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
811
+ const argv = ['cleanup']
812
+ // 破坏性选项由 client 强制 confirm 后才传入(默认清锁,不删文件)
813
+ if (args.removeUnversioned === true) argv.push('--remove-unversioned')
814
+ if (args.removeIgnored === true) argv.push('--remove-ignored')
815
+ try {
816
+ const outcome = await runSvn(status.wcRoot, argv, MUTATE_GRACE_MS)
817
+ const result = batchResultOf(outcome.stdout, outcome.stderr, outcome.code, '清理')
818
+ if (outcome.code !== 0) return { ok: false as const, error: '清理失败:' + tailOfText(result.output) }
819
+ // 清理可能改动工作副本(删未版本控制文件):作废变更缓存
820
+ changesCache.delete(status.wcRoot)
821
+ return { ok: true as const, ...result }
822
+ } catch (error) {
823
+ return { ok: false as const, error: '清理失败:' + String(error) }
824
+ }
825
+ },
826
+ 'svn.update': async (args: { sessionId?: string; path?: string }) => {
827
+ const sc = await requireSvnSession(ctx, args.sessionId)
828
+ if ('err' in sc) return { ok: false as const, error: sc.err }
829
+ const rel = safeRel(args.path)
830
+ if (rel === null) return { ok: false as const, error: '路径不合法' }
831
+ const status = await statusOf(sc.cwd)
832
+ if (!status.managed) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
833
+ if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
834
+ try {
835
+ const outcome = await runSvn(sc.cwd, ['update', rel || '.'], UPDATE_GRACE_MS)
836
+ const result = updateResultOf(outcome.stdout, outcome.stderr, outcome.code)
837
+ if (outcome.code !== 0) return { ok: false as const, error: '更新失败:' + tailOfText(result.output) }
838
+ return { ok: true as const, ...result }
839
+ } catch (error) {
840
+ return { ok: false as const, error: '更新失败:' + String(error) }
841
+ }
842
+ },
843
+ 'svn.tortoise': async (args: { sessionId?: string; action?: SvnAction; path?: string }) => {
844
+ const sc = await requireSvnSession(ctx, args.sessionId)
845
+ if ('err' in sc) return { ok: false as const, error: sc.err }
846
+ const action = args.action
847
+ if (action !== 'update' && action !== 'commit' && action !== 'log' && action !== 'diff' && action !== 'blame' && action !== 'revert') {
848
+ return { ok: false as const, error: '未知动作: ' + String(action) }
849
+ }
850
+ const rel = safeRel(args.path)
851
+ if (rel === null) return { ok: false as const, error: '路径不合法' }
852
+ const status = await statusOf(sc.cwd)
853
+ if (!status.managed) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
854
+ if (!status.tortoise) return { ok: false as const, error: 'TortoiseProc.exe 不可用(仅 Windows;可在设置页配置目录)' }
855
+ const fs = ctx.get('fs') as { resolve?: (p: string, o: object) => Promise<unknown>; stat?: (t: unknown) => Promise<{ type?: string } | null>; processPath?: (t: unknown) => string } | null | undefined
856
+ if (!fs?.resolve || !fs?.stat || !fs?.processPath) return { ok: false as const, error: '缺少 fs' }
857
+ try {
858
+ const target = await fs.resolve(rel || '.', { cwd: sc.cwd })
859
+ const info = await fs.stat(target)
860
+ if (!info) return { ok: false as const, error: '路径不存在' }
861
+ const abs = fs.processPath(target)
862
+ launchTortoise(status.tortoiseExe, action, abs)
863
+ return { ok: true as const, launched: abs }
864
+ } catch (error) {
865
+ return { ok: false as const, error: '启动 TortoiseSVN 失败:' + String(error) }
866
+ }
867
+ },
868
+ }
869
+ return { handlers }
870
+ }