dsh-vscode-mode 0.5.2 → 0.6.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 (78) hide show
  1. package/README.md +78 -5
  2. package/lib/client.js +5579 -236
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +2358 -126
  5. package/lib/index.js.map +1 -1
  6. package/package.json +1 -1
  7. package/src/client/compat.ts +1 -1
  8. package/src/client/dap/BpWidget.ts +262 -0
  9. package/src/client/dap/bpMenu.ts +91 -0
  10. package/src/client/dap/bpRowMenu.ts +53 -0
  11. package/src/client/dap/breakpoints.ts +222 -0
  12. package/src/client/dap/decorate.ts +128 -0
  13. package/src/client/dap/hintLine.ts +29 -0
  14. package/src/client/dap/hover.ts +166 -0
  15. package/src/client/dap/hoverMode.ts +159 -0
  16. package/src/client/dap/hoverTree.ts +722 -0
  17. package/src/client/dap/launchInsert.ts +229 -0
  18. package/src/client/dap/launchSnippetProvider.ts +206 -0
  19. package/src/client/dap/modelPath.ts +23 -0
  20. package/src/client/dap/panelSplit.ts +105 -0
  21. package/src/client/dap/pidPick.ts +24 -0
  22. package/src/client/dap/store.ts +571 -0
  23. package/src/client/dap/toolbarDrag.ts +51 -0
  24. package/src/client/dap/trace.ts +22 -0
  25. package/src/client/dap/variableTree.ts +62 -0
  26. package/src/client/editorLimit.ts +31 -0
  27. package/src/client/index.ts +13 -0
  28. package/src/client/markdownPreview.ts +24 -0
  29. package/src/client/md/componentType.ts +28 -0
  30. package/src/client/md/mdPanel.ts +98 -0
  31. package/src/client/monaco/lsp/index.ts +15 -0
  32. package/src/client/monaco/lsp/providers.ts +2 -0
  33. package/src/client/monaco/theme.ts +15 -0
  34. package/src/client/searchSeed.ts +64 -0
  35. package/src/client/sidebar/panels/DebugPanel.ts +613 -0
  36. package/src/client/sidebar/panels/SearchPanel.ts +70 -15
  37. package/src/client/sidebar/panels/SvnPanel.ts +167 -23
  38. package/src/client/sidebar/panels/index.ts +19 -0
  39. package/src/client/sidebar/types.ts +2 -0
  40. package/src/client/styles/editor.css +186 -1
  41. package/src/client/svnActions.ts +18 -1
  42. package/src/client/svnLog.ts +3 -3
  43. package/src/client/svnStatus.ts +140 -2
  44. package/src/client/tabActions.ts +54 -0
  45. package/src/client/ui/EditorView.ts +711 -29
  46. package/src/client/ui/McpSettings.ts +29 -0
  47. package/src/client/ui/SideBySideDiff.tsx +214 -29
  48. package/src/client/ui/SvnDiffPanel.ts +21 -8
  49. package/src/client/ui/SvnLogDialog.ts +44 -2
  50. package/src/client/ui/SvnPatchDialog.ts +63 -0
  51. package/src/client/ui/SvnSumDialog.ts +77 -0
  52. package/src/client/ui/commandCatalog.ts +54 -0
  53. package/src/client/ui/horizontalWheel.ts +85 -0
  54. package/src/client/ui/svnExport.ts +71 -0
  55. package/src/client-primitives.d.ts +34 -0
  56. package/src/dap/configSnippets.ts +269 -0
  57. package/src/dap/discovery.ts +198 -0
  58. package/src/dap/launchConfig.ts +127 -0
  59. package/src/dap/manager.ts +588 -0
  60. package/src/dap/processList.ts +76 -0
  61. package/src/dap/protocol.ts +80 -0
  62. package/src/dap/provider.ts +49 -0
  63. package/src/dap/rpc.ts +192 -0
  64. package/src/dap/sourcePath.ts +82 -0
  65. package/src/fileOpenSettings.ts +3 -0
  66. package/src/index.ts +7 -3
  67. package/src/lsp/providers.ts +3 -2
  68. package/src/reveal.ts +31 -20
  69. package/src/rpc.ts +11 -3
  70. package/src/search/ripgrep.ts +127 -11
  71. package/src/shared/dap.ts +262 -0
  72. package/src/shared/editorLimit.ts +46 -0
  73. package/src/shared/keybindings.ts +10 -0
  74. package/src/shared/rpc.ts +40 -2
  75. package/src/shared/svn.ts +147 -0
  76. package/src/shared/svnActions.ts +20 -0
  77. package/src/svn.ts +320 -11
  78. package/src/workspace.ts +0 -75
package/src/shared/svn.ts CHANGED
@@ -189,6 +189,10 @@ export interface SvnDiffRevResult {
189
189
  reason?: string
190
190
  /** 失败提示(诊断用)。 */
191
191
  error?: string
192
+ /** W2-6 护栏:内容含 NUL 字节(二进制嫌疑),客户端不渲染编辑器。 */
193
+ binary?: boolean
194
+ /** W2-6 护栏:嗅探窗含 U+FFFD(可能非 UTF-8),客户端提示乱码风险。 */
195
+ encodingHint?: boolean
192
196
  }
193
197
 
194
198
  // --endregion
@@ -281,6 +285,31 @@ export interface SvnChangeFilter {
281
285
  unversioned?: boolean
282
286
  /** 是否显示已被忽略的条目(默认 false)。 */
283
287
  ignored?: boolean
288
+ /**
289
+ * 名称过滤串(W1-2,纯前端):`*`/`?` 通配或子串包含,忽略大小写;空/空白 = 不过滤。
290
+ * 只影响列表展示;「全部加入/全部还原」等批量动作仍按未过滤集合计算(防误伤)。
291
+ */
292
+ name?: string
293
+ }
294
+
295
+ /**
296
+ * 路径名称过滤匹配(W1-2):`*` 任意串、`?` 单字符(忽略大小写);
297
+ * 无通配符时回落子串包含。非法正则元字符已转义,永不抛错。
298
+ * @author ddj 2026年09月20号
299
+ * @param path 条目路径(`/` 分隔)
300
+ * @param pattern 过滤串(空/空白 = 不过滤)
301
+ * @returns 是否命中
302
+ */
303
+ export function changeNameMatch(path: string, pattern: string | undefined): boolean {
304
+ const query = String(pattern ?? '').trim().toLowerCase()
305
+ if (!query) return true
306
+ const target = String(path ?? '').toLowerCase()
307
+ if (!query.includes('*') && !query.includes('?')) return target.includes(query)
308
+ const source = query
309
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
310
+ .replaceAll('*', '.*')
311
+ .replaceAll('?', '.')
312
+ return new RegExp('^' + source + '$').test(target)
284
313
  }
285
314
 
286
315
  /**
@@ -297,6 +326,7 @@ export function svnVisibleChanges(
297
326
  const showUnversioned = filter.unversioned !== false
298
327
  const showIgnored = filter.ignored === true
299
328
  return entries.filter((entry) => {
329
+ if (!changeNameMatch(entry.path, filter.name)) return false
300
330
  if (entry.status === 'ignored') return showIgnored
301
331
  if (entry.status === 'unversioned') return showUnversioned
302
332
  return isSvnChange(entry)
@@ -375,3 +405,120 @@ export const SVN_TORTOISE_LABELS: Record<SvnAction, string> = {
375
405
  blame: 'TortoiseSVN 追溯',
376
406
  revert: 'TortoiseSVN 还原',
377
407
  }
408
+
409
+ // --region W2 差异管理补全(护栏/目录对比/远端检查/冲突/配对)
410
+
411
+ /** W2-6 内容护栏嗅探窗(字符数;NUL/U+FFFD 只看前 8K,全量扫描大文本得不偿失)。 */
412
+ export const GUARD_SNIFF_CHARS = 8 * 1024
413
+
414
+ /** W2-6 内容级护栏标记(host 嗅探 → client 提示)。 */
415
+ export interface SvnContentGuard {
416
+ /** 嗅探窗含 NUL 字节(二进制嫌疑;M4 实测 svn cat 对二进制原样透传字节)。 */
417
+ binary?: boolean
418
+ /** 嗅探窗含 U+FFFD(UTF-8 解码失败替换符;可能非 UTF-8 编码)。 */
419
+ encodingHint?: boolean
420
+ }
421
+
422
+ /**
423
+ * 文本级内容护栏(W2-6,纯函数):对喂给差异视图/补丁的文本做低成本嗅探。
424
+ * 二进制锚点取 NUL 字节(比 svn:mime-type 更兜底——mime 缺省的「伪文本」二进制也拦得住);
425
+ * 编码提示取 U+FFFD(host fs.readText 按 UTF-8 解码,无效序列会变成替换符)。
426
+ * @author ddj 2026年09月20号
427
+ * @param text 待嗅探文本(null/undefined 按空处理)
428
+ * @returns 护栏标记(干净内容返回空对象)
429
+ */
430
+ export function contentGuardOf(text: string | null | undefined): SvnContentGuard {
431
+ if (typeof text !== 'string' || !text) return {}
432
+ const window = text.slice(0, GUARD_SNIFF_CHARS)
433
+ if (window.includes('\u0000')) return { binary: true }
434
+ if (window.includes('\uFFFD')) return { encodingHint: true }
435
+ return {}
436
+ }
437
+
438
+ /** W2-5 一对「疑似改名」配对(missing `!` ↔ unversioned `?`)。 */
439
+ export interface SvnRenamePair {
440
+ /** 原路径(missing 条目)。 */
441
+ missingPath: string
442
+ /** 新路径(unversioned 条目)。 */
443
+ unversionedPath: string
444
+ }
445
+
446
+ /** 取路径 basename(`/`/`\` 通用;纯函数)。 */
447
+ function baseNameOf(path: string): string {
448
+ return String(path ?? '').split(/[\\/]/).pop() ?? ''
449
+ }
450
+
451
+ /**
452
+ * 疑似改名配对(W2-5,纯函数;M2 定论:status XML 无 copyfrom,只能客户端启发式)。
453
+ * 配对条件从严:basename 相同 **且** 双侧 size 均已知且相等(size 缺失不配对,防误报)。
454
+ * @author ddj 2026年09月20号
455
+ * @param entries 变更清单
456
+ * @param sizes 路径 → 字节大小(未知为 null/缺失)
457
+ * @returns 配对数组(每个 missing 至多配一个,先到先得保持输入顺序)
458
+ */
459
+ export function pairMissingWithUnversioned(
460
+ entries: readonly SvnChangeEntry[],
461
+ sizes: Record<string, number | null>,
462
+ ): SvnRenamePair[] {
463
+ const unversioned = entries.filter((entry) => entry.status === 'unversioned')
464
+ const pairs: SvnRenamePair[] = []
465
+ for (const missing of entries) {
466
+ if (missing.status !== 'missing') continue
467
+ const name = baseNameOf(missing.path)
468
+ for (const candidate of unversioned) {
469
+ if (baseNameOf(candidate.path) !== name) continue
470
+ const a = sizes[missing.path]
471
+ const b = sizes[candidate.path]
472
+ if (typeof a !== 'number' || typeof b !== 'number' || a !== b) continue
473
+ pairs.push({ missingPath: missing.path, unversionedPath: candidate.path })
474
+ break
475
+ }
476
+ }
477
+ return pairs
478
+ }
479
+
480
+ /** W2-2 目录对比单条(`svn diff --summarize --xml` 的 `<path>`;M5 实测形态)。 */
481
+ export interface SvnSumEntry {
482
+ /** 工作副本相对路径(host 已从绝对路径归一)。 */
483
+ path: string
484
+ /** 变更动作(svn 词汇:added/deleted/replaced/modified/normal/none)。 */
485
+ item: string
486
+ /** 节点类型(file/dir;异常输出回 'none')。 */
487
+ kind: string
488
+ /** 属性变更(none/modified)。 */
489
+ props: string
490
+ }
491
+
492
+ /** W2-3 远端落后条目(`svn status -u --xml` 的 repos-status;M3 实测形态)。 */
493
+ export interface SvnRemoteOutdatedEntry {
494
+ /** 工作副本相对路径。 */
495
+ path: string
496
+ /** 远端状态(实测落后文件为 modified;'none' = 远端无变化)。 */
497
+ item: string
498
+ }
499
+
500
+ /** W2-2 summarize 动作 → 展示字母(对齐 svn status 首列字母;normal/none 不出徽标)。 */
501
+ export const SVN_SUM_LETTER: Record<string, string> = {
502
+ added: 'A',
503
+ deleted: 'D',
504
+ replaced: 'R',
505
+ modified: 'M',
506
+ normal: ' ',
507
+ none: ' ',
508
+ 'props-deleted': ' ',
509
+ 'props-modified': ' ',
510
+ }
511
+
512
+ /** W2-4 冲突副本文件(`.mine`/`.working`/`.rN`)。 */
513
+ export interface SvnConflictArtifact {
514
+ /** 工作副本相对路径。 */
515
+ path: string
516
+ /** 文件名(含冲突后缀)。 */
517
+ name: string
518
+ /** 种类:mine = 我的(.mine)/ working = 基础工作版(.working)/ rev = 仓库版本(.rN)。 */
519
+ kind: 'mine' | 'working' | 'rev'
520
+ /** rev 种类时的修订号。 */
521
+ rev?: number
522
+ }
523
+
524
+ // --endregion
@@ -131,6 +131,26 @@ export const SVN_ACTIONS: readonly SvnActionDef[] = [
131
131
  targets: ['file', 'directory'],
132
132
  gates: ['versioned'],
133
133
  },
134
+ {
135
+ id: 'create-patch',
136
+ commandId: 'edrv.svnCreatePatch',
137
+ label: '生成补丁…',
138
+ order: 20,
139
+ surfaces: ['palette'],
140
+ capability: 'cli',
141
+ targets: ['file', 'directory'],
142
+ needsEditorModel: true,
143
+ },
144
+ {
145
+ id: 'diff-summarize',
146
+ commandId: 'edrv.svnDiffSum',
147
+ label: '目录对比(两修订)…',
148
+ order: 21,
149
+ surfaces: ['palette'],
150
+ capability: 'cli',
151
+ // 目录对比以活动文件所在目录为默认目标(无活动文件 = 工作副本根),不限制目标类型
152
+ needsEditorModel: false,
153
+ },
134
154
  {
135
155
  id: 'cleanup',
136
156
  commandId: 'edrv.svnCleanup',
package/src/svn.ts CHANGED
@@ -6,14 +6,14 @@
6
6
  * TortoiseProc 为长驻 GUI 进程:发射后不 await done(只挂 no-op catch 防未处理 rejection)。
7
7
  * 作者 ddj 2026年09月16号
8
8
  */
9
- import { stat } from 'node:fs/promises'
10
- import { dirname, join } from 'node:path'
9
+ import { readdir, stat } from 'node:fs/promises'
10
+ import { basename, dirname, join } 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'
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'
14
+ import type { SvnAction, SvnChangeEntry, SvnConflictArtifact, SvnFeature, SvnItemStatus, SvnLogEntry, SvnRemoteOutdatedEntry, SvnStatusPayload, SvnSumEntry } from './shared/svn.js'
15
+ import { SVN_CLI_DEFAULT, TORTOISE_EXE, contentGuardOf, tortoiseLaunchArgv } from './shared/svn.js'
16
+ import { attrOf, numAttrOf, scanXmlTags, textOf, unescapeXml } from './svnXml.js'
17
17
  import { TEXT_OUTPUT_CAP, batchResultOf, tailOfText, updateResultOf } from './svnText.js'
18
18
  import { LOG_CAP, LOG_DEFAULT_LIMIT, SVN_LOG_SHOW_ALL_CAP, parseLogXml, parseSvnInfoRevision } from './svnLog.js'
19
19
  export { batchResultOf, commitResultOf, updateResultOf } from './svnText.js'
@@ -58,6 +58,23 @@ const MUTATE_GRACE_MS = 120_000
58
58
  export const CHANGES_TTL_MS = 5_000
59
59
  /** 文本读取上限(对齐 rpc.ts READ_CAP:>8MB 拒绝整文件读取/差异)。 */
60
60
  const READ_CAP = 8 * 1024 * 1024
61
+ /**
62
+ * W2-1 补丁文本输出上限(16MB)。实测教训复用(PROGRESS §六.1):DSH stdout 收集器
63
+ * 超限**保留尾部**——补丁被截断时头部 `Index:` 会丢失,host 以「不以 Index: 开头」
64
+ * 标记 truncated,客户端明示「不完整」而非让用户拿半份补丁去用。
65
+ */
66
+ const PATCH_OUTPUT_CAP = 16 * 1024 * 1024
67
+ /** W2-2 目录对比 summarize 输出上限(8MB;大目录长区间的变更条目 XML)。 */
68
+ const SUM_OUTPUT_CAP = 8 * 1024 * 1024
69
+ /**
70
+ * W2-3 远端检查超时(15s)。M3 实测:无凭据/离线时 `status -u` 会长时间挂起
71
+ * (受限沙箱内 120s 无输出),远端检查必须短超时 + 失败降级,绝不阻塞面板。
72
+ */
73
+ const REMOTE_GRACE_MS = 15_000
74
+ /** W2-2 summarize 服务器往返预算(大目录长区间可能较慢)。 */
75
+ const SUM_GRACE_MS = 60_000
76
+ /** W2-5 fileSizes 单次查询路径上限(配对候选是少数条目,防滥用)。 */
77
+ export const FILE_SIZES_CAP = 200
61
78
 
62
79
  /** 文件存在性探测(可注入替身)。 */
63
80
  export type ExistsFn = (path: string) => Promise<boolean>
@@ -227,6 +244,120 @@ export function parseStatusXml(xml: string, cap: number = CHANGES_CAP): { entrie
227
244
  return { entries, truncated }
228
245
  }
229
246
 
247
+ /**
248
+ * 绝对路径 → 工作副本相对路径(W2-2/W2-3:M3/M5 实测 XML 中 path 为绝对路径)。
249
+ * Windows 大小写不敏感文件系统:前缀匹配按小写比较,剥离后保留原大小写。
250
+ * @author ddj 2026年09月20号
251
+ * @param abs 绝对路径(`/` 或 `\` 分隔均可)
252
+ * @param wcRoot 工作副本根绝对路径
253
+ * @returns 相对路径(`/` 分隔);不在根下返回 null
254
+ */
255
+ export function relPathOfAbs(abs: string, wcRoot: string): string | null {
256
+ const norm = (p: string) => String(p ?? '').replace(/\\/g, '/').replace(/\/+$/, '')
257
+ const a = norm(abs)
258
+ const r = norm(wcRoot)
259
+ if (!r || !a.toLowerCase().startsWith(r.toLowerCase() + '/')) return null
260
+ return a.slice(r.length + 1)
261
+ }
262
+
263
+ /**
264
+ * W2-1 补丁的 `-x` 扩展选项组合(纯函数)。
265
+ * 冒烟实测修正(2026-09-20):本机 svn 内部 diff 的上下文行数只认 `-U<N>`,
266
+ * `--unified=N` 报 E200016 erroneous argument(07 计划 §1 当年只核对了 help 文案,未实测)。
267
+ * @author ddj 2026年09月20号
268
+ * @param opts 空白语义 / EOL 忽略 / 上下文行数
269
+ * @returns 空格连接的单个 `-x` 值;无选项时空串
270
+ */
271
+ export function patchExtOf(opts: { whitespace?: string; ignoreEol?: boolean; unified?: number }): string {
272
+ const parts: string[] = []
273
+ if (opts.whitespace === 'b') parts.push('-b')
274
+ if (opts.whitespace === 'w') parts.push('-w')
275
+ if (opts.ignoreEol === true) parts.push('--ignore-eol-style')
276
+ const unified = Math.floor(Number(opts.unified))
277
+ if (Number.isFinite(unified) && unified >= 0 && unified <= 100 && unified !== 3) parts.push('-U' + unified)
278
+ return parts.join(' ')
279
+ }
280
+
281
+ /**
282
+ * 解析 `svn diff --summarize --xml` 输出(W2-2;M5 实测形态,svn 1.14.5):
283
+ * `<diff><paths><path item="modified" kind="file" props="none">ABS:\\PATH</path>...`
284
+ * - path 的**文本内容**是绝对路径(非属性)→ relPathOfAbs 归一为工作副本相对;
285
+ * - 属性输出顺序不稳定(与 M1 log 同款教训),只按字段名取值、不做字符串快照;
286
+ * - 区间无变更时 `<paths>` 为空 → entries 为空数组(非错误)。
287
+ * @author ddj 2026年09月20号
288
+ * @param xml summarize --xml 的 stdout
289
+ * @param wcRoot 工作副本根(路径归一锚点)
290
+ * @param cap 条目上限(超出标记 truncated)
291
+ * @returns 变更条目与是否截断
292
+ */
293
+ export function parseSummarizeXml(xml: string, wcRoot: string, cap: number = CHANGES_CAP): { entries: SvnSumEntry[]; truncated: boolean } {
294
+ const entries: SvnSumEntry[] = []
295
+ let truncated = false
296
+ if (typeof xml !== 'string' || !xml) return { entries, truncated }
297
+ const re = /<path\b([^>]*?)>([\s\S]*?)<\/path>/g
298
+ let match: RegExpExecArray | null
299
+ while ((match = re.exec(xml)) !== null) {
300
+ const abs = unescapeXml(match[2].trim())
301
+ // 路径形态双兼容(冒烟实测 2026-09-20):绝对路径目标 → svn 回显绝对路径;相对路径目标
302
+ // (host 常态,cwd=wcRoot)→ svn 回显相对路径。先按绝对剥离前缀,失败按相对归一。
303
+ const rel = relPathOfAbs(abs, wcRoot) ?? changePathOf(abs)
304
+ if (!rel) continue
305
+ if (entries.length >= cap) { truncated = true; break }
306
+ entries.push({
307
+ path: rel,
308
+ item: attrOf(match[1], 'item') || 'none',
309
+ kind: attrOf(match[1], 'kind') || 'none',
310
+ props: attrOf(match[1], 'props') || 'none',
311
+ })
312
+ }
313
+ return { entries, truncated }
314
+ }
315
+
316
+ /**
317
+ * 解析 `svn status -u --xml` 输出(W2-3;M3 实测形态,svn 1.14.5):
318
+ * `<entry path="ABS"><wc-status ...>...</wc-status><repos-status item="modified" props="none"/></entry>` + `<against revision="N"/>`
319
+ * - 落后锚点 = 存在 `<repos-status>` 且 `item !== 'none'`(实测落后文件为 modified);
320
+ * 远端无变化的条目**没有** repos-status 元素(省略,非 'none' 值);
321
+ * - 离线/无凭据时会长时间挂起(M3 实测 120s+),调用方必须用 REMOTE_GRACE_MS 短超时。
322
+ * @author ddj 2026年09月20号
323
+ * @param xml status -u --xml 的 stdout
324
+ * @param wcRoot 工作副本根(路径归一锚点)
325
+ * @param cap 条目上限(超出标记 truncated)
326
+ * @returns 落后清单 + against 修订
327
+ */
328
+ export function parseRemoteStatusXml(
329
+ xml: string,
330
+ wcRoot: string,
331
+ cap: number = CHANGES_CAP,
332
+ ): { outdated: SvnRemoteOutdatedEntry[]; againstRev: number | null; truncated: boolean } {
333
+ const outdated: SvnRemoteOutdatedEntry[] = []
334
+ let againstRev: number | null = null
335
+ let truncated = false
336
+ if (typeof xml !== 'string' || !xml) return { outdated, againstRev, truncated }
337
+ // 开标签属性截取(M3 实测长形态:`<repos-status\n item="...">\n</repos-status>`,
338
+ // 开标签 `>` 与闭标签之间可能有换行,故只截开标签而非要求紧邻闭标签)
339
+ const against = /<against\b([^>]*?)\/?>/.exec(xml)
340
+ if (against) {
341
+ const rev = numAttrOf(against[1], 'revision')
342
+ if (rev !== undefined) againstRev = rev
343
+ }
344
+ const re = /<entry\b([^>]*?)>([\s\S]*?)<\/entry>/g
345
+ let match: RegExpExecArray | null
346
+ while ((match = re.exec(xml)) !== null) {
347
+ const repos = /<repos-status\b([^>]*?)\/?>/.exec(match[2])
348
+ if (!repos) continue
349
+ const item = attrOf(repos[1], 'item')
350
+ if (!item || item === 'none') continue
351
+ // 路径形态双兼容(同 parseSummarizeXml:绝对/相对取决于调用目标形态)
352
+ const rawPath = attrOf(match[1], 'path')
353
+ const rel = relPathOfAbs(rawPath, wcRoot) ?? changePathOf(rawPath)
354
+ if (!rel) continue
355
+ if (outdated.length >= cap) { truncated = true; break }
356
+ outdated.push({ path: rel, item })
357
+ }
358
+ return { outdated, againstRev, truncated }
359
+ }
360
+
230
361
  // --endregion
231
362
 
232
363
  /** 一次 svn CLI 运行结果(输出可能因 stdio 回退而为空)。 */
@@ -639,6 +770,42 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
639
770
  }
640
771
  }
641
772
 
773
+ /**
774
+ * 扫描冲突副本文件(W2-4;svn 冲突时的标准后缀 `.mine`/`.working`/`.rN`,纯本地 fs 只读)。
775
+ * @author ddj 2026年09月20号
776
+ * @param wcRoot 工作副本根
777
+ * @param rel 冲突文件的工作副本相对路径
778
+ * @returns 副本清单(rev 升序 → working → mine;默认对比对 = 首个 .rN ↔ .mine)
779
+ */
780
+ const conflictArtifactsOf = async (wcRoot: string, rel: string): Promise<SvnConflictArtifact[]> => {
781
+ const abs = join(wcRoot, rel)
782
+ const base = basename(abs)
783
+ const names = await readdir(dirname(abs))
784
+ const rankOf = (kind: SvnConflictArtifact['kind']) => (kind === 'rev' ? 0 : kind === 'working' ? 1 : 2)
785
+ const artifacts: SvnConflictArtifact[] = []
786
+ for (const name of names) {
787
+ let kind: SvnConflictArtifact['kind'] | null = null
788
+ let rev: number | undefined
789
+ if (name === base + '.mine') kind = 'mine'
790
+ else if (name === base + '.working') kind = 'working'
791
+ else if (name.startsWith(base + '.r') && /^\d+$/.test(name.slice(base.length + 2))) {
792
+ kind = 'rev'
793
+ rev = Number.parseInt(name.slice(base.length + 2), 10)
794
+ }
795
+ if (kind === null) continue
796
+ const artifactPath = relPathOfAbs(join(dirname(abs), name), wcRoot)
797
+ if (!artifactPath) continue
798
+ artifacts.push({ path: artifactPath, name, kind, ...(rev !== undefined ? { rev } : {}) })
799
+ }
800
+ return artifacts.sort((a, b) => rankOf(a.kind) - rankOf(b.kind) || (a.rev ?? 0) - (b.rev ?? 0) || a.name.localeCompare(b.name))
801
+ }
802
+
803
+ /** W2-6:双侧内容护栏标记(干净时返回空对象,展开进载荷不添字段)。 */
804
+ const guardSides = (a: string | null, b: string | null): { binary?: boolean; encodingHint?: boolean } => {
805
+ const merged = { ...contentGuardOf(a ?? ''), ...contentGuardOf(b ?? '') }
806
+ return merged.binary || merged.encodingHint ? merged : {}
807
+ }
808
+
642
809
  const handlers = {
643
810
  'svn.status': async (args: { sessionId?: string; force?: boolean }) => {
644
811
  const sc = await requireSvnSession(ctx, args.sessionId)
@@ -685,9 +852,9 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
685
852
  // added/未提交条目没有 pristine:非致命,按原因返回(客户端提示而非报错面板)
686
853
  const text = (outcome.stderr || outcome.stdout).trim()
687
854
  const reason = /pristine|E200009/i.test(text) ? 'no-pristine' : 'cat-failed'
688
- return { ok: true as const, base: null, working, reason, error: tailOfText(text) }
855
+ return { ok: true as const, base: null, working, reason, error: tailOfText(text), ...guardSides(null, working) }
689
856
  }
690
- return { ok: true as const, base: outcome.stdout, working }
857
+ return { ok: true as const, base: outcome.stdout, working, ...guardSides(outcome.stdout, working) }
691
858
  } catch (error) {
692
859
  return { ok: false as const, error: '读取基线失败:' + String(error) }
693
860
  }
@@ -731,7 +898,7 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
731
898
  if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
732
899
  try {
733
900
  const result = await diffRevSides(status.wcRoot, rel, Math.floor(revision))
734
- return { ok: true as const, ...result }
901
+ return { ok: true as const, ...result, ...guardSides(result.left, result.right) }
735
902
  } catch (error) {
736
903
  return { ok: false as const, error: '读取版本差异失败:' + String(error) }
737
904
  }
@@ -751,7 +918,7 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
751
918
  if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
752
919
  try {
753
920
  const result = await pairSides(status.wcRoot, rel, Math.floor(revA), Math.floor(revB))
754
- return { ok: true as const, ...result }
921
+ return { ok: true as const, ...result, ...guardSides(result.left, result.right) }
755
922
  } catch (error) {
756
923
  return { ok: false as const, error: '读取版本差异失败:' + String(error) }
757
924
  }
@@ -782,13 +949,155 @@ export function createSvnRpc(deps: SvnRpcDeps): { handlers: Record<string, unkno
782
949
  const leftOutcome = await runSvn(status.wcRoot, ['cat', '-r', String(Math.floor(revision)), '--', rel], READ_GRACE_MS)
783
950
  if (leftOutcome.code !== 0) {
784
951
  const text = (leftOutcome.stderr || leftOutcome.stdout).trim()
785
- return { ok: true as const, left: null, right: working, reason: 'not-exist', error: tailOfText(text) }
952
+ return { ok: true as const, left: null, right: working, reason: 'not-exist', error: tailOfText(text), ...guardSides(null, working) }
786
953
  }
787
- return { ok: true as const, left: leftOutcome.stdout, right: working }
954
+ return { ok: true as const, left: leftOutcome.stdout, right: working, ...guardSides(leftOutcome.stdout, working) }
788
955
  } catch (error) {
789
956
  return { ok: false as const, error: '读取工作副本差异失败:' + String(error) }
790
957
  }
791
958
  },
959
+ 'svn.patchText': async (args: { sessionId?: string; path?: string; whitespace?: 'none' | 'b' | 'w'; ignoreEol?: boolean; unified?: number }) => {
960
+ const sc = await requireSvnSession(ctx, args.sessionId)
961
+ if ('err' in sc) return { ok: false as const, error: sc.err }
962
+ const rel = safeRel(args.path)
963
+ if (rel === null || rel === '') return { ok: false as const, error: '路径不合法' }
964
+ const status = await statusOf(sc.cwd)
965
+ if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
966
+ if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
967
+ try {
968
+ // W2-1:`-x` 单值承载全部扩展选项(-b/-w/--ignore-eol-style/--unified=N,patchExtOf 组合)
969
+ const ext = patchExtOf({ whitespace: args.whitespace, ignoreEol: args.ignoreEol === true, unified: args.unified })
970
+ const argv = ['diff', ...(ext ? ['-x', ext] : []), '--', rel]
971
+ const outcome = await runSvn(status.wcRoot, argv, READ_GRACE_MS, PATCH_OUTPUT_CAP)
972
+ if (outcome.code !== 0) {
973
+ return { ok: false as const, error: '生成补丁失败:' + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join('\n')) }
974
+ }
975
+ const text = outcome.stdout
976
+ // 截断判定(PROGRESS §六.1):收集器超限保留尾部 → 头部丢失;补丁恒以 Index:/Property changes on: 开头
977
+ const head = text.slice(0, 200)
978
+ const truncated = !head.startsWith('Index:') && !head.startsWith('Property changes on:')
979
+ // M4 实测:svn diff 对二进制只输出 "Cannot display" 通知(几百字节),如实标记给客户端
980
+ return { ok: true as const, text, truncated, binary: text.includes('Cannot display:') }
981
+ } catch (error) {
982
+ return { ok: false as const, error: '生成补丁失败:' + String(error) }
983
+ }
984
+ },
985
+ 'svn.diffSum': async (args: { sessionId?: string; path?: string; revA?: number; revB?: number }) => {
986
+ const sc = await requireSvnSession(ctx, args.sessionId)
987
+ if ('err' in sc) return { ok: false as const, error: sc.err }
988
+ const rel = safeRel(args.path)
989
+ if (rel === null) return { ok: false as const, error: '路径不合法' }
990
+ const revA = Number(args.revA)
991
+ const revB = Number(args.revB)
992
+ if (!Number.isFinite(revA) || revA < 1 || !Number.isFinite(revB) || revB < 1) {
993
+ return { ok: false as const, error: '版本号不合法' }
994
+ }
995
+ const status = await statusOf(sc.cwd)
996
+ if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
997
+ if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
998
+ try {
999
+ // W2-2:M5 实测 —— summarize 输出的 path 是绝对路径,parseSummarizeXml 内归一为相对
1000
+ const argv = ['diff', '--summarize', '--xml', '-r', String(Math.floor(revA)) + ':' + String(Math.floor(revB))]
1001
+ if (rel) argv.push('--', rel)
1002
+ const outcome = await runSvn(status.wcRoot, argv, SUM_GRACE_MS, SUM_OUTPUT_CAP)
1003
+ if (outcome.code !== 0) {
1004
+ return { ok: false as const, error: '目录对比失败:' + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join('\n')) }
1005
+ }
1006
+ return { ok: true as const, ...parseSummarizeXml(outcome.stdout, status.wcRoot) }
1007
+ } catch (error) {
1008
+ return { ok: false as const, error: '目录对比失败:' + String(error) }
1009
+ }
1010
+ },
1011
+ 'svn.remoteStatus': async (args: { sessionId?: string; path?: string }) => {
1012
+ const sc = await requireSvnSession(ctx, args.sessionId)
1013
+ if ('err' in sc) return { ok: false as const, error: sc.err }
1014
+ const rel = safeRel(args.path)
1015
+ if (rel === null) return { ok: false as const, error: '路径不合法' }
1016
+ const status = await statusOf(sc.cwd)
1017
+ if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
1018
+ if (!status.svnCli) return { ok: false as const, error: 'svn 命令不可用(可在设置页配置 svn 路径)' }
1019
+ try {
1020
+ // W2-3:M3 实测 —— 离线/无凭据会长时间挂起,REMOTE_GRACE_MS 短超时是硬约束
1021
+ const argv = ['status', '-u', '--xml']
1022
+ if (rel) argv.push('--', rel)
1023
+ const outcome = await runSvn(status.wcRoot, argv, REMOTE_GRACE_MS, SUM_OUTPUT_CAP)
1024
+ if (outcome.code !== 0) {
1025
+ return { ok: false as const, error: '远端检查失败:' + tailOfText([outcome.stderr, outcome.stdout].filter(Boolean).join('\n')) }
1026
+ }
1027
+ return { ok: true as const, ...parseRemoteStatusXml(outcome.stdout, status.wcRoot) }
1028
+ } catch (error) {
1029
+ return { ok: false as const, error: '远端检查失败:' + String(error) }
1030
+ }
1031
+ },
1032
+ 'svn.conflictArtifacts': async (args: { sessionId?: string; path?: string }) => {
1033
+ const sc = await requireSvnSession(ctx, args.sessionId)
1034
+ if ('err' in sc) return { ok: false as const, error: sc.err }
1035
+ const rel = safeRel(args.path)
1036
+ if (rel === null || rel === '') return { ok: false as const, error: '路径不合法' }
1037
+ const status = await statusOf(sc.cwd)
1038
+ if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
1039
+ try {
1040
+ // W2-4:纯本地 fs 只读扫描,不跑 svn、不触碰 resolve
1041
+ const artifacts = await conflictArtifactsOf(status.wcRoot, rel)
1042
+ return { ok: true as const, artifacts }
1043
+ } catch (error) {
1044
+ return { ok: false as const, error: '扫描冲突副本失败:' + String(error) }
1045
+ }
1046
+ },
1047
+ 'svn.diffLocalPair': async (args: { sessionId?: string; left?: string; right?: string }) => {
1048
+ const sc = await requireSvnSession(ctx, args.sessionId)
1049
+ if ('err' in sc) return { ok: false as const, error: sc.err }
1050
+ // W2-4:双侧均为本地文件(冲突副本对比);safeRel 兜底防越界,与 diffBase 同一安全口径
1051
+ const left = safeRel(args.left)
1052
+ const right = safeRel(args.right)
1053
+ if (!left || !right) return { ok: false as const, error: '路径不合法(需工作副本内相对路径)' }
1054
+ const status = await statusOf(sc.cwd)
1055
+ if (!status.managed || !status.wcRoot) return { ok: false as const, error: '当前工作区不受 SVN 管理' }
1056
+ const fs = ctx.get('fs') as {
1057
+ resolve?: (p: string, o: object) => Promise<unknown>
1058
+ stat?: (t: unknown) => Promise<{ type?: string; size?: number } | null>
1059
+ readText?: (t: unknown) => Promise<string>
1060
+ } | null | undefined
1061
+ if (!fs?.resolve || !fs?.stat || !fs?.readText) return { ok: false as const, error: '缺少 fs' }
1062
+ try {
1063
+ const sides: string[] = []
1064
+ for (const rel of [left, right]) {
1065
+ const resolved = await fs.resolve(rel, { cwd: sc.cwd })
1066
+ const info = await fs.stat(resolved)
1067
+ if (!info || info.type !== 'file') return { ok: false as const, error: '文件不存在:' + rel }
1068
+ if ((info.size ?? 0) > READ_CAP) return { ok: false as const, error: '文件过大(>8MB):' + rel }
1069
+ sides.push(await fs.readText(resolved))
1070
+ }
1071
+ return { ok: true as const, left: sides[0], right: sides[1], ...guardSides(sides[0], sides[1]) }
1072
+ } catch (error) {
1073
+ return { ok: false as const, error: '读取冲突副本失败:' + String(error) }
1074
+ }
1075
+ },
1076
+ 'svn.fileSizes': async (args: { sessionId?: string; paths?: string[] }) => {
1077
+ const sc = await requireSvnSession(ctx, args.sessionId)
1078
+ if ('err' in sc) return { ok: false as const, error: sc.err }
1079
+ // W2-5:配对候选是少数条目;上限护栏 + 单条失败按 null(未知)处理,不因个别路径失败整体报错
1080
+ const list = Array.isArray(args.paths) ? args.paths.slice(0, FILE_SIZES_CAP) : []
1081
+ if (!list.length) return { ok: true as const, sizes: [] }
1082
+ const fs = ctx.get('fs') as {
1083
+ resolve?: (p: string, o: object) => Promise<unknown>
1084
+ stat?: (t: unknown) => Promise<{ type?: string; size?: number } | null>
1085
+ } | null | undefined
1086
+ if (!fs?.resolve || !fs?.stat) return { ok: false as const, error: '缺少 fs' }
1087
+ const sizes: Array<{ path: string; size: number | null }> = []
1088
+ for (const raw of list) {
1089
+ const rel = safeRel(raw)
1090
+ if (rel === null || rel === '') { sizes.push({ path: String(raw ?? ''), size: null }); continue }
1091
+ try {
1092
+ const resolved = await fs.resolve(rel, { cwd: sc.cwd })
1093
+ const info = await fs.stat(resolved)
1094
+ sizes.push({ path: rel, size: info && info.type === 'file' ? (info.size ?? null) : null })
1095
+ } catch {
1096
+ sizes.push({ path: rel, size: null })
1097
+ }
1098
+ }
1099
+ return { ok: true as const, sizes }
1100
+ },
792
1101
  'svn.wcRev': async (args: { sessionId?: string; path?: string }) => {
793
1102
  const sc = await requireSvnSession(ctx, args.sessionId)
794
1103
  if ('err' in sc) return { ok: false as const, error: sc.err }
package/src/workspace.ts DELETED
@@ -1,75 +0,0 @@
1
- /**
2
- * dsh-vscode-mode host — 工作区文件清单扫描与缓存(快速打开/搜索用)。
3
- * 迁移自原 src/index.ts 的 listWorkspaceFiles,语义不改。
4
- * 作者 ddj 2026-08-20
5
- */
6
- import type { Ctx, Session } from './store.js'
7
- import { policyOf } from './store.js'
8
- import { log } from './log.js'
9
-
10
- /** 文件清单缓存 TTL 与扫描上限(避免大工作区反复全量扫描)。 */
11
- const FILE_INDEX_TTL = 60_000
12
- const SCAN_CAP = 6000
13
- /** cwd → { at, files }(随会话销毁清理)。 */
14
- const fileIndex = new Map<string, { at: number; files: string[] }>()
15
-
16
- /**
17
- * 扫描工作区文件清单(快速打开/搜索用):subprocess(Windows powershell / POSIX find),
18
- * 排除常见重目录,容量上限 SCAN_CAP;结果缓存(TTL FILE_INDEX_TTL)。失败返回 null。
19
- * @author ddj 2026年08月20号
20
- * @param ctx DSH 上下文
21
- * @param session 会话(取工作区根)
22
- * @param cwd 工作区目录
23
- * @returns 绝对路径清单;不可用(无 subprocess/fs)或扫描失败返回 null
24
- */
25
- export async function listWorkspaceFiles(ctx: Ctx, session: Session, cwd: string): Promise<string[] | null> {
26
- const cached = fileIndex.get(cwd)
27
- if (cached && Date.now() - cached.at < FILE_INDEX_TTL) return cached.files
28
- const sub = ctx.get('subprocess')
29
- const fs = ctx.get('fs')
30
- if (!sub || !fs) return null
31
- const policy = policyOf(ctx, session)
32
- let root: string
33
- try {
34
- const rootTarget = await fs.resolve(policy?.workspaceRoot ?? '.', {})
35
- root = fs.processPath(rootTarget)
36
- } catch (error) {
37
- root = cwd
38
- }
39
- const lines: string[] = []
40
- const run = async (argv: string[]): Promise<void> => {
41
- const handle = sub.spawn({
42
- argv,
43
- stdio: { stdout: { maxBytes: 8 << 20 }, stderr: { maxBytes: 1 << 16 }, stdin: 'ignore' },
44
- graceMs: 20000,
45
- })
46
- const outcome = await handle.done
47
- const code = outcome?.exitCode ?? outcome?.code
48
- if (code !== 0) throw new Error('scan exit ' + code)
49
- const text = handle.collected?.stdout?.readFrom(0).text ?? ''
50
- for (const ln of text.split(/\r?\n/)) {
51
- const s = ln.trim()
52
- if (s) lines.push(s)
53
- }
54
- }
55
- try {
56
- if (process.platform === 'win32') {
57
- const cmd = 'Get-ChildItem -LiteralPath "' + String(root).replace(/"/g, '""') + '" -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.FullName -notmatch \'(node_modules|\\.git|\\.tmp|\\.cache|dist|build|vendor|coverage|__pycache__)\' } | Select-Object -First ' + SCAN_CAP + ' | ForEach-Object { $_.FullName }'
58
- await run(['powershell', '-NoProfile', '-NonInteractive', '-Command', cmd])
59
- } else {
60
- const cmd = 'find ' + JSON.stringify(root) + ' -type f -not -path \'*/node_modules/*\' -not -path \'*/.git/*\' -not -path \'*/.tmp/*\' -not -path \'*/.cache/*\' -not -path \'*/dist/*\' -not -path \'*/build/*\' -not -path \'*/vendor/*\' -not -path \'*/coverage/*\' 2>/dev/null | head -n ' + SCAN_CAP
61
- await run(['/bin/sh', '-c', cmd])
62
- }
63
- } catch (error) {
64
- log.error('scan failed: ' + String(error))
65
- return null
66
- }
67
- const files = lines.slice(0, SCAN_CAP)
68
- if (files.length) fileIndex.set(cwd, { at: Date.now(), files })
69
- return files
70
- }
71
-
72
- /** 会话销毁时清理文件索引缓存。 */
73
- export function dropFileIndex(cwd: string): void {
74
- fileIndex.delete(cwd)
75
- }