dsh-vscode-mode 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/svn.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * 作者 ddj 2026年09月16号
8
8
  */
9
9
  import { readdir, stat } from 'node:fs/promises'
10
- import { basename, dirname, join } from 'node:path'
10
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
11
11
  import { tmpdir } from 'node:os'
12
12
  import type { Ctx, Session } from './store.js'
13
13
  import { cwdOf, sessionOf } from './registry.js'
@@ -18,6 +18,8 @@ import { buildAiPrompt, svnAiPlanOf } from './ai/svnTriage.js'
18
18
  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
+ import { SvnUpdateJobs } from './svnUpdateJob.js'
22
+ import type { UpdateProcess } from './svnUpdateJob.js'
21
23
  export { batchResultOf, commitResultOf, updateResultOf } from './svnText.js'
22
24
  export { mapRepoPath, parseLogXml } from './svnLog.js'
23
25
 
@@ -387,6 +389,7 @@ interface SvnRunOutcome { code: number | null; stdout: string; stderr: string }
387
389
  /** subprocess 产句柄最小结构面(done 载荷对齐 revert.ts 的 exitCode/code 双读)。 */
388
390
  interface SpawnedHandle {
389
391
  done: Promise<{ exitCode?: number; code?: number } | undefined>
392
+ terminate?: () => void
390
393
  collected?: {
391
394
  stdout?: { readFrom: (n: number) => { text: string } }
392
395
  stderr?: { readFrom: (n: number) => { text: string } }
@@ -466,18 +469,35 @@ function safeRel(raw: unknown): string | null {
466
469
  return rel
467
470
  }
468
471
 
472
+ /**
473
+ * 会话根可在 SVN 工作副本的子目录;CLI 在 wcRoot 执行时必须换算目标坐标。
474
+ * @author ddj 2026年09月24号
475
+ * @param cwd 当前会话目录
476
+ * @param wcRoot SVN 工作副本根
477
+ * @param rel 会话相对目标
478
+ * @returns SVN 根相对目标;越界返回 null
479
+ */
480
+ export function svnPathOf(cwd: string, wcRoot: string, rel: string): string | null {
481
+ if (safeRel(rel) === null) return null
482
+ const wcRel = relative(wcRoot, resolve(cwd, rel || '.'))
483
+ if (isAbsolute(wcRel) || wcRel === '..' || wcRel.startsWith('../') || wcRel.startsWith('..\\')) return null
484
+ return wcRel.replace(/\\/g, '/') || '.'
485
+ }
486
+
469
487
  /**
470
488
  * 创建 svn.* RPC handlers(缓存随装配生命周期;lsp/ai 同模式并入 buildHandlers)。
471
489
  * @author ddj 2026年09月16号
472
490
  * @param deps ctx + 设置读取面 + 可注入时钟
473
491
  * @returns handlers(Partial<RpcHandlerMap> 形状)
474
492
  */
475
- export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unknown> } {
493
+ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unknown>; dispose: () => void } {
476
494
  const { ctx, settings } = deps
477
495
  const now = deps.now ?? (() => Date.now())
478
496
  const findRoot = deps.findRoot ?? ((cwd: string) => findSvnRoot(cwd))
479
497
  const findTortoise = deps.findTortoise ?? ((dir: string) => findTortoiseProc(dir))
480
498
  const statusCache = new Map<string, { at: number; payload: SvnStatusPayload }>()
499
+ /** 运行中更新按会话目录复用工作副本根;TTL 到期才重新扫描 .svn。 */
500
+ const updateRootCache = new Map<string, { at: number; root: string }>()
481
501
  /** 变更清单缓存(键 = 工作副本根;TTL 短,仅合并同一轮 UI 的多处拉取)。 */
482
502
  const changesCache = new Map<string, { at: number; value: { entries: SvnChangeEntry[]; truncated: boolean } }>()
483
503
  /** 混合通道方案收件箱(key = 工作副本根;覆盖式入箱 + TTL,见 svn.aiPlanSubmit/Pending)。 */
@@ -485,6 +505,11 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
485
505
  /** 工作副本根 relative-url 缓存(日志路径映射用;探测失败也缓存空串避免反复 spawn)。 */
486
506
  const urlCache = new Map<string, { at: number; value: string }>()
487
507
  let cliProbe: { at: number; exe: string; ok: boolean } | null = null
508
+ const updateJobs = new SvnUpdateJobs(
509
+ () => ctx.get('subprocess') as UpdateProcess | null,
510
+ deps.platform ?? process.platform,
511
+ now,
512
+ )
488
513
 
489
514
  const subprocess = (): SpawnFn | null => {
490
515
  const sub = ctx.get('subprocess') as { spawn?: unknown } | null | undefined
@@ -499,6 +524,9 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
499
524
  let ok = false
500
525
  const spawn = subprocess()
501
526
  if (spawn) {
527
+ const controller = new AbortController()
528
+ const deadline = setTimeout(() => controller.abort(new Error('SVN CLI 探测超时')), CLI_GRACE_MS)
529
+ deadline.unref?.()
502
530
  try {
503
531
  const handle = spawn({
504
532
  argv: [exe, '--version', '--quiet'],
@@ -507,11 +535,20 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
507
535
  cwd: tmpdir(),
508
536
  stdio: { stdout: 'inherit', stderr: 'inherit', stdin: 'ignore' },
509
537
  graceMs: CLI_GRACE_MS,
538
+ signal: controller.signal,
539
+ })
540
+ const expired = new Promise<never>((_, reject) => {
541
+ controller.signal.addEventListener('abort', () => {
542
+ try { handle.terminate?.() } catch { /* 返回探测超时即可 */ }
543
+ reject(controller.signal.reason)
544
+ }, { once: true })
510
545
  })
511
- const outcome = await handle.done
546
+ const outcome = await Promise.race([handle.done, expired])
512
547
  ok = (outcome?.exitCode ?? outcome?.code) === 0
513
548
  } catch {
514
549
  ok = false
550
+ } finally {
551
+ clearTimeout(deadline)
515
552
  }
516
553
  }
517
554
  cliProbe = { at, exe, ok }
@@ -543,6 +580,28 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
543
580
  return payload
544
581
  }
545
582
 
583
+ /**
584
+ * 验证更新请求属于当前会话的工作副本;取消/轮询不依赖 CLI 仍可用。
585
+ * @author ddj 2026年09月24号
586
+ * @param sessionId 当前会话 id
587
+ * @param needCli 仅真正启动更新时才探测 SVN 客户端
588
+ * @returns 已校验的目录和状态,或错误
589
+ */
590
+ const updateScope = async (sessionId?: string, needCli = false) => {
591
+ const sc = await requireSvnSession(ctx, sessionId)
592
+ if ('err' in sc) return { err: sc.err }
593
+ if (!needCli) {
594
+ const cached = updateRootCache.get(sc.cwd)
595
+ const root = cached && now() - cached.at < STATUS_TTL_MS ? cached.root : await findRoot(sc.cwd)
596
+ if (root && (!cached || now() - cached.at >= STATUS_TTL_MS)) updateRootCache.set(sc.cwd, { at: now(), root })
597
+ return root ? { cwd: sc.cwd, root, exe: '', cli: false } : { err: '当前工作区不受 SVN 管理' }
598
+ }
599
+ const status = await statusOf(sc.cwd)
600
+ if (!status.managed || !status.wcRoot) return { err: '当前工作区不受 SVN 管理' }
601
+ updateRootCache.set(sc.cwd, { at: now(), root: status.wcRoot })
602
+ return { cwd: sc.cwd, root: status.wcRoot, exe: status.svnPath, cli: status.svnCli }
603
+ }
604
+
546
605
  /**
547
606
  * 运行 svn CLI(管道收集;spawn 级失败回退 inherit,输出降级为空)。
548
607
  * @author ddj 2026年08月26号 / 2026年09月17号
@@ -966,8 +1025,10 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
966
1025
  const status = await statusOf(sc.cwd)
967
1026
  if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
968
1027
  if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
1028
+ const svnTarget = svnPathOf(sc.cwd, status.wcRoot, rel)
1029
+ if (svnTarget === null) return { ok: false as const, error: '日志路径不在 SVN 工作副本内' }
969
1030
  try {
970
- const result = await logOf(status.wcRoot, rel, args.limit ?? LOG_DEFAULT_LIMIT, {
1031
+ const result = await logOf(status.wcRoot, svnTarget === '.' ? '' : svnTarget, args.limit ?? LOG_DEFAULT_LIMIT, {
971
1032
  stopOnCopy: args.stopOnCopy === true,
972
1033
  showMerged: args.showMerged === true,
973
1034
  range: args.startRev && args.endRev
@@ -1026,6 +1087,8 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
1026
1087
  const status = await statusOf(sc.cwd)
1027
1088
  if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
1028
1089
  if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
1090
+ const svnRel = svnPathOf(sc.cwd, status.wcRoot, rel)
1091
+ if (!svnRel) return { ok: false as const, error: '比较路径不在 SVN 工作副本内' }
1029
1092
  const fs = ctx.get('fs') as {
1030
1093
  resolve?: (p: string, o: object) => Promise<unknown>
1031
1094
  stat?: (t: unknown) => Promise<{ type?: string; size?: number } | null>
@@ -1039,7 +1102,7 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
1039
1102
  if ((info.size ?? 0) > READ_CAP) return { ok: false as const, error: '文件过大(>8MB),不支持与工作副本比较' }
1040
1103
  const working = await fs.readText(resolved)
1041
1104
  // 左侧 = 该版本内容;cat 失败按「该版本尚无此文件」处理(新增语义,非错误)
1042
- const leftOutcome = await runSvn(status.wcRoot, ['cat', '-r', String(Math.floor(revision)), '--', rel], READ_GRACE_MS)
1105
+ const leftOutcome = await runSvn(status.wcRoot, ['cat', '-r', String(Math.floor(revision)), '--', svnRel], READ_GRACE_MS)
1043
1106
  if (leftOutcome.code !== 0) {
1044
1107
  const text = (leftOutcome.stderr || leftOutcome.stdout).trim()
1045
1108
  return { ok: true as const, left: null, right: working, reason: 'not-exist', error: tailOfText(text), ...guardSides(null, working) }
@@ -1363,7 +1426,7 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
1363
1426
  if (!status.managed) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
1364
1427
  if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
1365
1428
  try {
1366
- const outcome = await runSvn(sc.cwd, ['update', rel || '.'], UPDATE_GRACE_MS)
1429
+ const outcome = await runSvn(sc.cwd, ['update', '--', rel || '.'], UPDATE_GRACE_MS)
1367
1430
  const result = updateResultOf(outcome.stdout, outcome.stderr, outcome.code)
1368
1431
  if (outcome.code !== 0) return { ok: false as const, error: '更新失败:' + tailOfText(result.output) }
1369
1432
  return { ok: true as const, ...result }
@@ -1371,6 +1434,38 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
1371
1434
  return { ok: false as const, error: '更新失败:' + String(error) }
1372
1435
  }
1373
1436
  },
1437
+ 'svn.updateStart': async (args: { sessionId?: string; path?: string }) => {
1438
+ const scope = await updateScope(args.sessionId, true)
1439
+ if ('err' in scope) return { ok: false as const, error: scope.err }
1440
+ if (!scope.cli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
1441
+ const rel = safeRel(args.path)
1442
+ if (rel === null) return { ok: false as const, error: '路径不合法' }
1443
+ try {
1444
+ const fs = ctx.get('fs') as { resolve?: (p: string, o: object) => Promise<unknown>; processPath?: (t: unknown) => string } | undefined
1445
+ const cwd = fs?.resolve && fs?.processPath
1446
+ ? fs.processPath(await fs.resolve(scope.cwd, {})) : scope.cwd
1447
+ return { ok: true as const, ...updateJobs.start({ cwd, root: scope.root, path: rel, exe: scope.exe }) }
1448
+ } catch (error) {
1449
+ return { ok: false as const, error: '启动 SVN 更新失败:' + String(error) }
1450
+ }
1451
+ },
1452
+ 'svn.updatePoll': async (args: { sessionId?: string; jobId: string; since: number }) => {
1453
+ const scope = await updateScope(args.sessionId)
1454
+ if ('err' in scope) return { ok: false as const, error: scope.err }
1455
+ try { return { ok: true as const, ...updateJobs.poll(scope.root, args.jobId, args.since) } }
1456
+ catch (error) { return { ok: false as const, error: String(error) } }
1457
+ },
1458
+ 'svn.updateCancel': async (args: { sessionId?: string; jobId: string }) => {
1459
+ const scope = await updateScope(args.sessionId)
1460
+ if ('err' in scope) return { ok: false as const, error: scope.err }
1461
+ try { return { ok: true as const, ...updateJobs.cancel(scope.root, args.jobId) } }
1462
+ catch (error) { return { ok: false as const, error: String(error) } }
1463
+ },
1464
+ 'svn.updateActive': async (args: { sessionId?: string }) => {
1465
+ const scope = await updateScope(args.sessionId)
1466
+ if ('err' in scope) return { ok: false as const, error: scope.err }
1467
+ return { ok: true as const, job: updateJobs.active(scope.root) }
1468
+ },
1374
1469
  'svn.tortoise': async (args: { sessionId?: string; action?: SvnAction; path?: string }) => {
1375
1470
  const sc = await requireSvnSession(ctx, args.sessionId)
1376
1471
  if ('err' in sc) return { ok: false as const, error: sc.err }
@@ -1397,5 +1492,5 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
1397
1492
  }
1398
1493
  },
1399
1494
  }
1400
- return { handlers }
1495
+ return { handlers, dispose: () => { updateJobs.dispose(); updateRootCache.clear() } }
1401
1496
  }
package/src/svnText.ts CHANGED
@@ -70,7 +70,24 @@ const UPDATE_ACTION_LABEL: Record<string, string> = {
70
70
  }
71
71
 
72
72
  /** update 结果行:行首若干空白 + 单字母 + 空白 + 路径(路径可含空格,取整段)。 */
73
- const UPDATE_LINE_RE = /^([ADGCEUR]|\s{3})\s{2,}(\S.*)$/
73
+ const UPDATE_LINE_RE = /^([A-Z_ ]{1,4})\s{2,}(\S.*)$/
74
+
75
+ /**
76
+ * 解析 update 的单行状态(正文/摘要不匹配)。属性列 U/C 也按该条目状态处理。
77
+ * @author ddj 2026年09月24号
78
+ * @param line 原始输出行
79
+ * @returns 可识别条目或 null
80
+ */
81
+ export function updateLineOf(line: string): SvnUpdateEntry | null {
82
+ const hit = UPDATE_LINE_RE.exec(line)
83
+ if (!hit) return null
84
+ const status = hit[1].replace(/[ _]/g, '')
85
+ if (!status || /[^ADGCEUR]/.test(status)) return null
86
+ const action = status.includes('C') ? 'C' : status[0]
87
+ const path = hit[2].trim()
88
+ if (!path) return null
89
+ return { action: action as SvnUpdateEntry['action'], path, label: UPDATE_ACTION_LABEL[action] ?? action }
90
+ }
74
91
 
75
92
  /**
76
93
  * 解析 `svn update` 文本输出为**条目级**结构化结果(附冲突清单与一行摘要)。
@@ -95,18 +112,10 @@ export function updateResultOf(stdout: string, stderr: string, code: number | nu
95
112
  const conflicts: string[] = []
96
113
  for (const line of output.split(/\r?\n/)) {
97
114
  // 条目行(`U a.txt` / `C conf.txt` / `A new.txt`)
98
- const hit = UPDATE_LINE_RE.exec(line)
99
- if (hit) {
100
- const action = hit[1].trim()
101
- const path = hit[2].trim()
102
- if (path && entries.length < TEXT_ENTRY_CAP) {
103
- entries.push({
104
- action: action as SvnUpdateEntry['action'],
105
- path,
106
- label: UPDATE_ACTION_LABEL[action] ?? action,
107
- })
108
- }
109
- if (action === 'C' && conflicts.length < TEXT_CONFLICT_CAP) conflicts.push(path)
115
+ const entry = updateLineOf(line)
116
+ if (entry) {
117
+ if (entries.length < TEXT_ENTRY_CAP) entries.push(entry)
118
+ if (entry.action === 'C' && conflicts.length < TEXT_CONFLICT_CAP) conflicts.push(entry.path)
110
119
  continue
111
120
  }
112
121
  // 兼容 P1 的宽松口径:属性列冲突(` M C f` 等)也算冲突