dsh-vscode-mode 0.5.3 → 0.7.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 (77) hide show
  1. package/README.md +72 -2
  2. package/lib/client.js +12082 -6420
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +2750 -147
  5. package/lib/index.js.map +1 -1
  6. package/package.json +1 -1
  7. package/src/client/copyText.ts +28 -0
  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/fileClipboard.ts +46 -0
  27. package/src/client/index.ts +12 -3
  28. package/src/client/monaco/lsp/index.ts +15 -0
  29. package/src/client/monaco/lsp/providers.ts +2 -0
  30. package/src/client/monaco/theme.ts +15 -0
  31. package/src/client/openWithDialog.ts +66 -0
  32. package/src/client/searchSeed.ts +36 -0
  33. package/src/client/sidebar/contextMenu.ts +51 -10
  34. package/src/client/sidebar/menuItems.ts +473 -50
  35. package/src/client/sidebar/panels/DebugPanel.ts +613 -0
  36. package/src/client/sidebar/panels/SearchPanel.ts +24 -3
  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 +8 -0
  40. package/src/client/styles/editor.css +176 -0
  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 +22 -10
  45. package/src/client/ui/EditorView.ts +625 -23
  46. package/src/client/ui/OfficialSideTab.ts +1 -0
  47. package/src/client/ui/PromptDialog.ts +63 -0
  48. package/src/client/ui/SideBySideDiff.tsx +214 -29
  49. package/src/client/ui/SideEditorTab.ts +1 -0
  50. package/src/client/ui/SvnDiffPanel.ts +21 -8
  51. package/src/client/ui/SvnLogDialog.ts +44 -2
  52. package/src/client/ui/SvnPatchDialog.ts +63 -0
  53. package/src/client/ui/SvnSumDialog.ts +77 -0
  54. package/src/client/ui/commandCatalog.ts +63 -0
  55. package/src/client/ui/svnExport.ts +71 -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/index.ts +7 -3
  66. package/src/lsp/providers.ts +3 -2
  67. package/src/reveal.ts +31 -20
  68. package/src/rpc.ts +209 -5
  69. package/src/search/ripgrep.ts +127 -11
  70. package/src/shared/dap.ts +262 -0
  71. package/src/shared/fsNames.ts +115 -0
  72. package/src/shared/keybindings.ts +9 -0
  73. package/src/shared/rpc.ts +52 -2
  74. package/src/shared/svn.ts +147 -0
  75. package/src/shared/svnActions.ts +20 -0
  76. package/src/svn.ts +320 -11
  77. package/src/workspace.ts +0 -75
@@ -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
- }