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
package/src/rpc.ts CHANGED
@@ -20,8 +20,8 @@ import {
20
20
  resolveTarget,
21
21
  saveBucket,
22
22
  } from './store.js'
23
- import { mkdir, readFile, rm, stat, writeFile, readdir } from 'node:fs/promises'
24
- import { basename, join } from 'node:path'
23
+ import { cp, mkdir, readFile, rename, rm, stat, writeFile, readdir } from 'node:fs/promises'
24
+ import { basename, dirname, join } from 'node:path'
25
25
  import { archiveEntryFor, markDecision, recordResolved, reconstructOriginal } from './model.js'
26
26
  import type { Registry } from './registry.js'
27
27
  import { bucketOf, cwdOf, sessionOf } from './registry.js'
@@ -42,6 +42,7 @@ import { handoffOpen, pendingState, pollPending } from './externalHandoff.js'
42
42
  import { buildReport } from './compat.js'
43
43
  import { findProfileDir, readDevForm, setDevForm } from './devForm.js'
44
44
  import { normalizeRel } from './tree.js'
45
+ import { baseNameOf, checkNewName, checkRenameName, isSubPath, joinRelPath, parentRelOf } from './shared/fsNames.js'
45
46
  import { invalidateIndex, listDirCached } from './treeIndex.js'
46
47
  import { revealInExplorer } from './reveal.js'
47
48
  import { dshHome, debugLogFile, pluginLogRoot } from './paths.js'
@@ -246,6 +247,106 @@ async function integrationBaseUrlOf(ctx: Ctx): Promise<string> {
246
247
  return raw || INTEGRATION_BASE_DEFAULT
247
248
  }
248
249
 
250
+ // --region 文件操作(edrv.fs.*:资源管理器右键的新建/重命名/删除/复制/移动)
251
+
252
+ /**
253
+ * 文件操作目标解析:工作区相对路径 → 绝对路径,并做工作区边界检查。
254
+ * edrv.fs.* 各方法共用(对齐 edrv.saveBinary 的 contains 护栏口径)。
255
+ * @author ddj 2026年09月22号
256
+ * @param ctx DSH 上下文
257
+ * @param session 当前会话
258
+ * @param rel 工作区相对路径
259
+ * @returns 绝对路径;缺 fs/越界/解析失败返回错误文案
260
+ */
261
+ async function fsOpsTarget(ctx: Ctx, session: Session, rel: string): Promise<{ abs: string } | { err: string }> {
262
+ const fs = ctx.get('fs')
263
+ if (!fs) return { err: '缺少 fs' }
264
+ try {
265
+ const target = await resolveTarget(ctx, session, rel)
266
+ const rootTarget = await fs.resolve(policyOf(ctx, session)?.workspaceRoot ?? '.', {})
267
+ if (!fs.contains(rootTarget, target)) return { err: '拒绝操作:目标不在会话工作区内' }
268
+ return { abs: fs.processPath(target) }
269
+ } catch (error) {
270
+ return { err: '路径解析失败:' + String(error) }
271
+ }
272
+ }
273
+
274
+ /**
275
+ * 取异常的系统错误码(EEXIST/EXDEV 等分支判定用)。
276
+ * @author ddj 2026年09月22号
277
+ * @param error 捕获到的异常
278
+ * @returns 错误码;取不到返回空串
279
+ */
280
+ function fsErrCode(error: unknown): string {
281
+ const code = (error as { code?: unknown } | null)?.code
282
+ return typeof code === 'string' ? code : ''
283
+ }
284
+
285
+ /**
286
+ * 复制/移动共用核心(edrv.fsCopy / edrv.fsMove):目标目录存在 + 同名拒绝覆盖 +
287
+ * 「不能以自身或其子目录为目标」护栏;move 跨设备(EXDEV)回退「递归复制 + 删除源」。
288
+ * @author ddj 2026年09月22号
289
+ * @param ctx DSH 上下文
290
+ * @param sc requireSession 成功结果(session + cwd)
291
+ * @param args 请求载荷(from / toDir 均为工作区相对路径)
292
+ * @param mode copy = 复制;move = 移动
293
+ * @returns 成功返回 from/to 相对路径;失败返回错误文案
294
+ */
295
+ async function fsTransfer(
296
+ ctx: Ctx,
297
+ sc: { session: Session; cwd: string },
298
+ args: { from: string; toDir: string },
299
+ mode: 'copy' | 'move',
300
+ ): Promise<{ from: string; to: string } | { err: string }> {
301
+ const fromRel = normalizeRel(args.from)
302
+ const toDirRel = normalizeRel(args.toDir)
303
+ if (fromRel === null || fromRel === '' || toDirRel === null) return { err: '路径不合法' }
304
+ if (isSubPath(fromRel, toDirRel)) return { err: '不能以自身或其子目录为目标' }
305
+ const src = await fsOpsTarget(ctx, sc.session, fromRel)
306
+ if ('err' in src) return src
307
+ const dir = await fsOpsTarget(ctx, sc.session, toDirRel)
308
+ if ('err' in dir) return dir
309
+ const dirInfo = await stat(dir.abs).catch(() => null)
310
+ if (!dirInfo || !dirInfo.isDirectory()) return { err: '目标目录不存在' }
311
+ const toRel = joinRelPath(toDirRel, baseNameOf(fromRel))
312
+ const dst = await fsOpsTarget(ctx, sc.session, toRel)
313
+ if ('err' in dst) return dst
314
+ const srcInfo = await stat(src.abs).catch(() => null)
315
+ if (!srcInfo) return { err: '源不存在' }
316
+ if (await stat(dst.abs).catch(() => null)) return { err: '目标目录已存在同名文件或目录' }
317
+ try {
318
+ await transferEntry(src.abs, dst.abs, mode)
319
+ } catch (error) {
320
+ return { err: (mode === 'copy' ? '复制失败:' : '移动失败:') + String(error) }
321
+ }
322
+ invalidateIndex(ctx, sc.cwd, fromRel)
323
+ invalidateIndex(ctx, sc.cwd, toRel)
324
+ return { from: fromRel, to: toRel }
325
+ }
326
+
327
+ /**
328
+ * 单条目复制/移动落盘(文件/目录通用;move 跨设备回退复制+删除源)。
329
+ * @author ddj 2026年09月22号
330
+ * @param absFrom 源绝对路径
331
+ * @param absTo 目标绝对路径
332
+ * @param mode copy = 复制;move = 移动
333
+ */
334
+ async function transferEntry(absFrom: string, absTo: string, mode: 'copy' | 'move'): Promise<void> {
335
+ if (mode === 'copy') {
336
+ await cp(absFrom, absTo, { recursive: true, force: false, errorOnExist: true })
337
+ return
338
+ }
339
+ try {
340
+ await rename(absFrom, absTo)
341
+ } catch (error) {
342
+ if (fsErrCode(error) !== 'EXDEV') throw error
343
+ await cp(absFrom, absTo, { recursive: true, force: false, errorOnExist: true })
344
+ await rm(absFrom, { recursive: true })
345
+ }
346
+ }
347
+
348
+ // --endregion
349
+
249
350
  /** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
250
351
  export function buildHandlers(
251
352
  ctx: Ctx,
@@ -256,6 +357,7 @@ export function buildHandlers(
256
357
  aiHandlers?: Partial<RpcHandlerMap>,
257
358
  fileVersions?: FileVersions,
258
359
  svnHandlers?: Partial<RpcHandlerMap>,
360
+ dapHandlers?: Partial<RpcHandlerMap>,
259
361
  ): RpcHandlerMap {
260
362
  return {
261
363
  // edrv.lsp.* 由 createLspRpc 一次性提供(tracker 跨请求保留),这里并入。
@@ -264,6 +366,8 @@ export function buildHandlers(
264
366
  ...((aiHandlers ?? {}) as RpcHandlerMap),
265
367
  // svn.* 由 createSvnRpc 提供(SVN 检测/更新/Tortoise 发射),这里并入。
266
368
  ...((svnHandlers ?? {}) as RpcHandlerMap),
369
+ // edrv.dap.* 由 createDapRpc 提供(调试会话单例),这里并入。
370
+ ...((dapHandlers ?? {}) as RpcHandlerMap),
267
371
  'edrv.list': async (args) => {
268
372
  const sc = await requireSession(ctx, args.sessionId)
269
373
  if ('err' in sc) return { ok: false, error: sc.err }
@@ -333,6 +437,7 @@ export function buildHandlers(
333
437
  if (!fs) return { ok: false, error: '缺少 fs' }
334
438
  try {
335
439
  const target = await resolveTarget(ctx, sc.session, args.path)
440
+ debugRecord(ctx, sc.cwd, '[DEBUG path.resolve] input=' + String(args.path ?? '') + ' resolved=' + fs.processPath(target), 'debug')
336
441
  const info = await fs.stat(target)
337
442
  if (!info || info.type !== 'file') {
338
443
  // 带上解析后的真实路径:跳转失败时可直接看出是路径解析错还是目标本身不存在
@@ -366,6 +471,7 @@ export function buildHandlers(
366
471
  if (!fs) return { ok: false, error: '缺少 fs' }
367
472
  try {
368
473
  const target = await resolveTarget(ctx, sc.session, args.path)
474
+ debugRecord(ctx, sc.cwd, '[DEBUG path.resolve] input=' + String(args.path ?? '') + ' resolved=' + fs.processPath(target), 'debug')
369
475
  const info = await fs.stat(target)
370
476
  if (!info || info.type !== 'file') return { ok: false, error: '文件不存在' }
371
477
  if ((info.size ?? 0) > READ_CAP) return { ok: false, error: '文件过大(>8MB),不支持差异重建' }
@@ -399,6 +505,7 @@ export function buildHandlers(
399
505
  if (!fs) return { ok: false, error: '缺少 fs' }
400
506
  try {
401
507
  const target = await resolveTarget(ctx, sc.session, args.path)
508
+ debugRecord(ctx, sc.cwd, '[DEBUG path.resolve] input=' + String(args.path ?? '') + ' resolved=' + fs.processPath(target), 'debug')
402
509
  // 版本守卫(客户端带回上次读取的版本):文件在读取后被外部改过则拒绝写入,
403
510
  // 避免用陈旧缓冲静默覆盖外部改动;客户端未带 rev(旧版/无版本后端)时行为不变。
404
511
  const rev = typeof args.rev === 'string' && args.rev ? args.rev : null
@@ -421,6 +528,7 @@ export function buildHandlers(
421
528
  if (!fs) return { ok: false, error: '缺少 fs' }
422
529
  try {
423
530
  const target = await resolveTarget(ctx, sc.session, args.path)
531
+ debugRecord(ctx, sc.cwd, '[DEBUG path.resolve] input=' + String(args.path ?? '') + ' resolved=' + fs.processPath(target), 'debug')
424
532
  const info = await fs.stat(target)
425
533
  if (!info || info.type !== 'file') return { ok: false, error: '文件不存在' }
426
534
  // fs 服务仅提供文本写(writeText/editText),二进制回写经 node:fs 直写解析后的
@@ -540,7 +648,7 @@ export function buildHandlers(
540
648
  },
541
649
  'edrv.dlog.reveal': async () => {
542
650
  // 打开日志根目录(OS 文件管理器;目录形态 → 直接打开)
543
- const revealed = await revealInExplorer(ctx, pluginLogRoot(), true)
651
+ const revealed = await revealInExplorer(pluginLogRoot(), true)
544
652
  if (!revealed.ok) return { ok: false, error: revealed.error }
545
653
  return { ok: true, opened: true }
546
654
  },
@@ -598,13 +706,108 @@ export function buildHandlers(
598
706
  const info = await fs.stat(target)
599
707
  if (!info) return { ok: false, error: '路径不存在' }
600
708
  const abs = fs.processPath(target)
601
- const outcome = await revealInExplorer(ctx, abs, info.type === 'directory')
709
+ const outcome = await revealInExplorer(abs, info.type === 'directory')
602
710
  if (!outcome.ok) return { ok: false, error: outcome.error }
603
711
  return { ok: true, revealed: abs }
604
712
  } catch (error) {
605
713
  return { ok: false, error: '打开失败:' + String(error) }
606
714
  }
607
715
  },
716
+ 'edrv.fsCreateFile': async (args) => {
717
+ // 新建文件(资源管理器右键「新建文件…」):空内容独占创建(同名拒绝),
718
+ // 名称可含 a/b.c 嵌套段(父目录按需创建);名称校验与客户端弹窗共用 shared/fsNames。
719
+ const sc = await requireSession(ctx, args.sessionId)
720
+ if ('err' in sc) return { ok: false, error: sc.err }
721
+ const bad = checkNewName(args.path)
722
+ if (bad) return { ok: false, error: bad }
723
+ const r = await fsOpsTarget(ctx, sc.session, args.path)
724
+ if ('err' in r) return { ok: false, error: r.err }
725
+ try {
726
+ await mkdir(dirname(r.abs), { recursive: true })
727
+ await writeFile(r.abs, '', { flag: 'wx' })
728
+ } catch (error) {
729
+ const code = fsErrCode(error)
730
+ return { ok: false, error: code === 'EEXIST' ? '已存在同名文件或目录' : '新建文件失败:' + String(error) }
731
+ }
732
+ invalidateIndex(ctx, sc.cwd, args.path)
733
+ return { ok: true, path: args.path }
734
+ },
735
+ 'edrv.fsCreateDir': async (args) => {
736
+ // 新建文件夹(资源管理器右键「新建文件夹」):名称可含嵌套段(父目录按需创建),已存在拒绝。
737
+ const sc = await requireSession(ctx, args.sessionId)
738
+ if ('err' in sc) return { ok: false, error: sc.err }
739
+ const bad = checkNewName(args.path)
740
+ if (bad) return { ok: false, error: bad }
741
+ const r = await fsOpsTarget(ctx, sc.session, args.path)
742
+ if ('err' in r) return { ok: false, error: r.err }
743
+ try {
744
+ await mkdir(dirname(r.abs), { recursive: true })
745
+ await mkdir(r.abs)
746
+ } catch (error) {
747
+ const code = fsErrCode(error)
748
+ return { ok: false, error: code === 'EEXIST' ? '已存在同名文件或目录' : '新建文件夹失败:' + String(error) }
749
+ }
750
+ invalidateIndex(ctx, sc.cwd, args.path)
751
+ return { ok: true, path: args.path }
752
+ },
753
+ 'edrv.fsRename': async (args) => {
754
+ // 重命名(仅名称段、同父目录内):目标已存在拒绝;名称未变化按幂等成功返回。
755
+ const sc = await requireSession(ctx, args.sessionId)
756
+ if ('err' in sc) return { ok: false, error: sc.err }
757
+ const bad = checkRenameName(args.newName)
758
+ if (bad) return { ok: false, error: bad }
759
+ const fromRel = normalizeRel(args.path)
760
+ if (fromRel === null || fromRel === '') return { ok: false, error: '路径不合法' }
761
+ const toRel = joinRelPath(parentRelOf(fromRel), args.newName)
762
+ if (toRel === fromRel) return { ok: true, from: fromRel, to: fromRel }
763
+ const src = await fsOpsTarget(ctx, sc.session, fromRel)
764
+ if ('err' in src) return { ok: false, error: src.err }
765
+ const dst = await fsOpsTarget(ctx, sc.session, toRel)
766
+ if ('err' in dst) return { ok: false, error: dst.err }
767
+ if (!(await stat(src.abs).catch(() => null))) return { ok: false, error: '源不存在' }
768
+ try {
769
+ await rename(src.abs, dst.abs)
770
+ } catch (error) {
771
+ const code = fsErrCode(error)
772
+ const exists = code === 'EEXIST' || code === 'ENOTEMPTY' || code === 'EPERM'
773
+ return { ok: false, error: exists ? '已存在同名文件或目录' : '重命名失败:' + String(error) }
774
+ }
775
+ invalidateIndex(ctx, sc.cwd, fromRel)
776
+ invalidateIndex(ctx, sc.cwd, toRel)
777
+ return { ok: true, from: fromRel, to: toRel }
778
+ },
779
+ 'edrv.fsDelete': async (args) => {
780
+ // 删除(文件/文件夹递归;「删除/永久删除」共用):根拒绝;破坏性由客户端确认框把关。
781
+ const sc = await requireSession(ctx, args.sessionId)
782
+ if ('err' in sc) return { ok: false, error: sc.err }
783
+ const rel = normalizeRel(args.path)
784
+ if (rel === null || rel === '') return { ok: false, error: '路径不合法' }
785
+ const r = await fsOpsTarget(ctx, sc.session, rel)
786
+ if ('err' in r) return { ok: false, error: r.err }
787
+ try {
788
+ await rm(r.abs, { recursive: true })
789
+ } catch (error) {
790
+ return { ok: false, error: '删除失败:' + String(error) }
791
+ }
792
+ invalidateIndex(ctx, sc.cwd, rel)
793
+ return { ok: true, path: rel }
794
+ },
795
+ 'edrv.fsCopy': async (args) => {
796
+ // 复制到目标目录(文件/文件夹递归):同名拒绝覆盖(细节见 fsTransfer)。
797
+ const sc = await requireSession(ctx, args.sessionId)
798
+ if ('err' in sc) return { ok: false, error: sc.err }
799
+ const r = await fsTransfer(ctx, sc, args, 'copy')
800
+ if ('err' in r) return { ok: false, error: r.err }
801
+ return { ok: true, from: r.from, to: r.to }
802
+ },
803
+ 'edrv.fsMove': async (args) => {
804
+ // 移动到目标目录(文件/文件夹):同名拒绝覆盖(细节见 fsTransfer)。
805
+ const sc = await requireSession(ctx, args.sessionId)
806
+ if ('err' in sc) return { ok: false, error: sc.err }
807
+ const r = await fsTransfer(ctx, sc, args, 'move')
808
+ if ('err' in r) return { ok: false, error: r.err }
809
+ return { ok: true, from: r.from, to: r.to }
810
+ },
608
811
  'mcp.list': async () => ({ ok: true, ...listMcp(ctx) }),
609
812
  'mcp.save': async (args) => {
610
813
  try { return { ok: true, server: await saveMcp(ctx, args.config) } }
@@ -951,8 +1154,9 @@ export async function handleRpc<M extends RpcMethod>(
951
1154
  aiHandlers?: Partial<RpcHandlerMap>,
952
1155
  fileVersions?: FileVersions,
953
1156
  svnHandlers?: Partial<RpcHandlerMap>,
1157
+ dapHandlers?: Partial<RpcHandlerMap>,
954
1158
  ): Promise<RpcResult<M>> {
955
- const handlers = buildHandlers(ctx, registry, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions, svnHandlers)
1159
+ const handlers = buildHandlers(ctx, registry, searcher, contentSearcher, lspHandlers, aiHandlers, fileVersions, svnHandlers, dapHandlers)
956
1160
  const handler = handlers[method]
957
1161
  if (!handler) return { ok: false, error: '未知方法: ' + String(method) } as RpcResult<M>
958
1162
  return handler(args)
@@ -15,7 +15,12 @@ const STDOUT_CAP = 4 << 20
15
15
  const STDERR_CAP = 64 << 10
16
16
  const GRACE_MS = 20_000
17
17
 
18
- /** 默认排除目录(文件/内容搜索共用,导出供 provider 复用)。 */
18
+ /**
19
+ * 默认排除目录(文件/内容搜索共用,导出供 provider 复用)。
20
+ * 语义为**大小写敏感**的路径段匹配:`build` 只排除小写构建产物目录,
21
+ * 不得用 `--glob-case-insensitive` 之类的全局开关,「Module/Build」这类
22
+ * 源码目录(如 IslandSplash 的 Module/Build/Logic)必须保持可搜索。
23
+ */
19
24
  export const EXCLUDES = [
20
25
  'node_modules', '.git', '.tmp', '.cache', 'dist', 'build', 'vendor',
21
26
  'coverage', '__pycache__', '.pnpm-store', '.npm-cache', '.codegraph', '.workbuddy',
@@ -52,15 +57,32 @@ interface SearchSubprocess {
52
57
  }
53
58
 
54
59
  /**
55
- * 将 query 转成 rg 的字面 glob。
56
- * @author ddj 2026年08月24号
57
- * @param query 已规范化 query
60
+ * 将查询文本转成大小写不敏感的 rg 正选 glob。
61
+ * 逐字符把有大小写的字母展开成 `[aA]` 字符类,**不用**大小写不敏感开关:
62
+ * ① 全局 `--glob-case-insensitive` 会把 `-g/--glob` 全部当成 `--iglob`,
63
+ * 连带让 `!**\/build/**` 大小写不敏感,从而排除 `Module/Build/` 这类源码目录
64
+ * (Ctrl+P 搜 BuildLogic 恒「无匹配文件」);
65
+ * ② 把查询词改用 `--iglob` 也不行——rg 的正选 glob 会覆盖全部黑名单 glob,
66
+ * node_modules/.git 等排除项集体失效。
67
+ * 字符类方案同时满足「查询不区分大小写」且「排除项保持大小写敏感」。
68
+ * @author ddj 2026年08月24号 / 2026年09月20号
69
+ * @param query 已规范化查询文本
58
70
  * @returns 安全 glob
59
71
  */
60
72
  export function queryGlob(query: string): string {
61
73
  const value = pathText(query)
62
- const escaped = value.replace(/[\*?\[\]{}!]/g, (char) => '\\' + char)
63
- return '**/*' + escaped + '*'
74
+ let body = ''
75
+ for (const char of value) {
76
+ const lower = char.toLowerCase()
77
+ const upper = char.toUpperCase()
78
+ // 有大小写之分的字符(单字符映射,避开 ß→SS 这类长度变化)→ 字符类
79
+ if (lower !== upper && lower.length === 1 && upper.length === 1) {
80
+ body += '[' + lower + upper + ']'
81
+ continue
82
+ }
83
+ body += /[\*?\[\]{}!]/.test(char) ? '\\' + char : char
84
+ }
85
+ return '**/*' + body + '*'
64
86
  }
65
87
 
66
88
  /**
@@ -159,6 +181,24 @@ export function rgExitFailure(code: number | null | undefined, stderrText: strin
159
181
  return { kind: 'hard', message: 'ripgrep 退出码:' + String(code) + (detail ? '(' + detail + ')' : '') }
160
182
  }
161
183
 
184
+ /**
185
+ * 构造文件搜索 rg argv(查询 glob 大小写不敏感,排除 glob 大小写敏感)。
186
+ * 大小写不敏感**只能**靠 queryGlob 的字符类实现:全局 `--glob-case-insensitive`
187
+ * 会波及排除项(误排除 `Module/Build/`),查询词用 `--iglob` 又会覆盖全部黑名单 glob。
188
+ * @author ddj 2026年09月20号
189
+ * @param binary rg 可执行文件路径
190
+ * @param root 搜索根目录
191
+ * @param query 已规范化查询文本
192
+ * @returns 完整 argv(末位为根目录)
193
+ */
194
+ export function filesArgv(binary: string, root: string, query: string): string[] {
195
+ const argv = [binary, '--no-config', '--files', '--hidden', '--no-ignore', '--glob', queryGlob(query)]
196
+ for (const excluded of EXCLUDES) argv.push('--glob', '!**/' + excluded + '/**')
197
+ for (const excluded of FILE_EXCLUDES) argv.push('--glob', '!' + excluded)
198
+ argv.push('--', root)
199
+ return argv
200
+ }
201
+
162
202
  /**
163
203
  * 使用打包 ripgrep 搜索工作区文件。
164
204
  * @author ddj 2026年08月24号
@@ -172,12 +212,8 @@ export async function searchRipgrep(input: WorkspaceSearchInput): Promise<Worksp
172
212
  if (!binary) throw new Error('ripgrep 不可用')
173
213
  const root = input.root ?? await searchRoot(input.ctx, input.session)
174
214
  const query = prepareQuery(input.query)
175
- const argv = [binary, '--no-config', '--files', '--hidden', '--no-ignore', '--glob-case-insensitive', '--glob', queryGlob(query.text)]
176
- for (const excluded of EXCLUDES) argv.push('--glob', '!**/' + excluded + '/**')
177
- for (const excluded of FILE_EXCLUDES) argv.push('--glob', '!' + excluded)
178
- argv.push('--', root)
179
215
  const handle = sub.spawn({
180
- argv,
216
+ argv: filesArgv(binary, root, query.text),
181
217
  cwd: root,
182
218
  stdio: { stdin: 'ignore', stdout: { maxBytes: STDOUT_CAP }, stderr: { maxBytes: STDERR_CAP } },
183
219
  graceMs: GRACE_MS,
@@ -202,6 +238,86 @@ export async function searchRipgrep(input: WorkspaceSearchInput): Promise<Worksp
202
238
  return { ...parsed, source: 'ripgrep' }
203
239
  }
204
240
 
241
+ /**
242
+ * 构造 chunkname 定向查找的 rg argv。
243
+ * 与 {@link filesArgv} 同源(同样的排除 glob 与大小写口径),差异只在查询 glob 由
244
+ * chunkname 推导:chunkname 可能带 chunk 前缀(`@`)与无扩展名 stem,故先剥 `@`、
245
+ * 取 basename、去掉扩展名后作为片段匹配,仍走 {@link queryGlob} 的字符类方案
246
+ * ——避免 `--glob-case-insensitive` 波及排除项(见 queryGlob 注释)。
247
+ * @author ddj 2026年09月21号
248
+ * @param binary rg 可执行文件路径
249
+ * @param root 搜索根目录
250
+ * @param chunk 适配器上报的 chunkname
251
+ * @returns 完整 argv(末位为根目录);chunk 为空时返回 null
252
+ */
253
+ export function chunkFilesArgv(binary: string, root: string, chunk: string): string[] | null {
254
+ const clean = normalizeChunk(chunk)
255
+ if (!clean) return null
256
+ const argv = [binary, '--no-config', '--files', '--hidden', '--no-ignore', '--glob', queryGlob(clean)]
257
+ for (const excluded of EXCLUDES) argv.push('--glob', '!**/' + excluded + '/**')
258
+ for (const excluded of FILE_EXCLUDES) argv.push('--glob', '!' + excluded)
259
+ argv.push('--', root)
260
+ return argv
261
+ }
262
+
263
+ /**
264
+ * chunkname → 用于片段匹配的纯文件名片段。
265
+ * 剥 `@` 前缀、统一分隔符、取 basename、去掉扩展名(xLua chunkname 惯例不含扩展名),
266
+ * 使 `GuideSystemMgr` 能命中 `GuideSystemMgr.lua`。
267
+ * @author ddj 2026年09月21号
268
+ * @param chunk 适配器上报的 chunkname
269
+ * @returns 纯片段(空输入返回空串)
270
+ */
271
+ export function normalizeChunk(chunk: string): string {
272
+ const text = String(chunk ?? '').replace(/^@/, '').replaceAll('\\', '/').trim()
273
+ if (!text) return ''
274
+ const base = text.split('/').pop() ?? ''
275
+ return base.replace(/\.[^.]+$/, '')
276
+ }
277
+
278
+ /**
279
+ * 按 chunkname 定向查找工作区文件(DAP findFile 反向匹配专用)。
280
+ *
281
+ * 为什么不用工作区全量文件索引:22 万文件的 Unity 工程里,全量枚举按到达序截断,
282
+ * `Assets/Scripts/Lua/**` 会被 `GameData`/`Entitas` 等目录挤出配额,导致断点命中后
283
+ * 无法定位源文件(实测前 6000 条中 .lua 数为 0)。此处改为让 rg 在遍历时过滤,
284
+ * 命中规模与工作区总规模无关(实测同一工作区 0.37s 返回正确结果)。
285
+ * @author ddj 2026年09月21号
286
+ * @param ctx DSH 上下文
287
+ * @param root 工作区根(subprocess 可访问路径)
288
+ * @param chunk 适配器上报的 chunkname
289
+ * @param maxResults 候选上限
290
+ * @returns 绝对路径候选(任何失败都返回空数组,不抛错)
291
+ */
292
+ export async function findFilesByChunk(ctx: Ctx, root: string, chunk: string, maxResults = 50): Promise<string[]> {
293
+ const sub = ctx.get('subprocess') as SearchSubprocess | undefined
294
+ const binary = ripgrepPath()
295
+ if (!sub || !binary || !root) return []
296
+ const argv = chunkFilesArgv(binary, root, chunk)
297
+ if (!argv) return []
298
+ let handle: SearchHandle
299
+ try {
300
+ handle = sub.spawn({
301
+ argv,
302
+ cwd: root,
303
+ stdio: { stdin: 'ignore', stdout: { maxBytes: STDOUT_CAP }, stderr: { maxBytes: STDERR_CAP } },
304
+ graceMs: GRACE_MS,
305
+ })
306
+ } catch (error) {
307
+ return []
308
+ }
309
+ let code: number | null | undefined
310
+ try {
311
+ const outcome = await handle.done
312
+ code = outcome.exitCode ?? outcome.code
313
+ } catch (error) {
314
+ return []
315
+ }
316
+ // 0=有命中 1=无命中 2=部分不可访问(已收集结果仍有效):其余视为失败
317
+ if (code !== 0 && code !== 1 && code !== 2) return []
318
+ return parseOutput(handle, maxResults).files
319
+ }
320
+
205
321
  /**
206
322
  * 构造 ripgrep provider。
207
323
  * @author ddj 2026年08月24号