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
@@ -0,0 +1,588 @@
1
+ /**
2
+ * dsh-vscode-mode host — 调试会话管理器(DAP 桥核心,适配器通用)。
3
+ * 单例调试会话:spawn 扩展清单声明的 DAP 适配器(node js 或原生 exe)↔ 本管理器翻译成
4
+ * edrv.dap.* 视图。要点(emmylua 源自适配器源码逆向,探针验证;通用语义对齐 DAP 3.x):
5
+ * - 帧协议与 LSP 相同(Content-Length)→ 复用 lsp/jsonrpc.ts;
6
+ * - initialize → attach/launch 响应后适配器异步连目标,连上才发 initialized 事件,
7
+ * 此时需把已存断点全量重发;随后按能力位发 configurationDone(emmylua 未声明该能力
8
+ * 不发,行为不变;clrdbg 等声明了必须发,否则目标挂起等待);
9
+ * - 停帧后 stackTrace/scopes/variables/evaluate 才有效(适配器未连接时 evaluate 永不回包,
10
+ * 必须守卫 + 请求级超时);
11
+ * - stackTrace 期间 emmylua 适配器可能发 findFileReq **反向请求**等 findFileRsp
12
+ * (chunkname→文件由 IDE 侧解析),本管理器经注入的文件索引回调应答;
13
+ * 其它适配器直接上报可打开的源码路径,不走该扩展事件;
14
+ * - 线程:emmylua 固定 threadId=1;通用适配器取 stopped 事件的真实 threadId,
15
+ * 栈/单步/暂停均携带;
16
+ * - emmylua 的 scopes frameId 是 stacks 数组下标(私有语义),标准适配器用原生 frame id;
17
+ * - 适配器对无效 pid 会未捕获异常崩溃 → attach 前必须先经进程枚举校验 pid。
18
+ * 作者 ddj 2026年09月29号 / 2026年09月21号
19
+ */
20
+ import { spawn, type ChildProcess } from 'node:child_process'
21
+ import { join } from 'node:path'
22
+ import { createFrameParser, encodeMessage } from '../lsp/jsonrpc.js'
23
+ import { sourceBaseOf, sourceKeyOf, sourcePathOf, normalizeSourcePath } from './sourcePath.js'
24
+ import { bpAcksOfResponse, DAP_ACTIONS, needsConfigurationDone, requestOfConfig, threadIdOfStopped, type DapAction, type DapBreakpointInput, type DapBreakpointAck, type DapDebugConfig, type DapEventView, type DapFrameView, type DapPhase, type DapPollResult, type DapScopeView, type DapStateView, type DapVariableView } from '../shared/dap.js'
25
+ import { dapRequest, eventBody, initializeArgs, isDapEvent, isDapResponse, responseBody, type DapEvent, type DapMessage, type DapRequest } from './protocol.js'
26
+ import type { DapAdapterSpec } from './provider.js'
27
+ import { log } from '../log.js'
28
+
29
+ const EVENT_CAP = 500
30
+ const REQUEST_TIMEOUT_MS = 8000
31
+ /** stderr 尾部缓冲上限(字符):退出时附带进 fail 消息,根因(如缺 .NET 运行时)直达调试控制台。 */
32
+ const STDERR_TAIL_CAP = 2000
33
+ /** fail 消息附带的 stderr 尾部行数上限(防长堆栈刷屏)。 */
34
+ const STDERR_TAIL_LINES = 6
35
+ /** emmylua 兼容 shim 的来源扩展 id(extensionPath/pid 等缺省注入仅对其生效)。 */
36
+ const EMMYLUA_EXT_ID = 'tangzx.emmylua'
37
+
38
+ /** 判断 source 是否已经是可定位的源码路径(exts 由调用方按当前适配器给出)。 */
39
+ function hasSourceExtension(value: string, exts: readonly string[]): boolean {
40
+ const path = normalizeSourcePath(value).toLowerCase()
41
+ if (!path) return false
42
+ return exts.some((ext) => path.endsWith(String(ext).toLowerCase()))
43
+ }
44
+
45
+ /**
46
+ * 是否为「裸 chunkname」:无目录分隔符且非盘符/UNC 前缀。
47
+ *
48
+ * 这类值(如 xLua 上报的 `GuideSystemMgr`)被 {@link sourcePathOf} 处理后仍是原串,
49
+ * 客户端会按工作区根拼接(`<cwd>/GuideSystemMgr`)——该路径必然不存在,
50
+ * 于是断点命中后页签打不开、报「文件加载失败」。故反查失败时须判定为未解析,
51
+ * 而不是把它当成文件路径下发。
52
+ * @author ddj 2026年09月21号
53
+ * @param value 适配器上报的 source 值
54
+ * @returns 是否裸 chunkname
55
+ */
56
+ function isBareChunk(value: string): boolean {
57
+ const text = normalizeSourcePath(value)
58
+ if (!text) return false
59
+ if (text.includes('/')) return false
60
+ return !/^[a-z]:/i.test(text)
61
+ }
62
+
63
+ /** 注入依赖:工作区文件索引(findFile 反向匹配用)。 */
64
+ export interface DapSessionDeps {
65
+ /** 按 chunkname 搜索并排序工作区文件,返回绝对路径列表。 */
66
+ findFiles(workspacePath: string, chunk: string): Promise<string[]>
67
+ /** 诊断 trace(workspacePath → debug log),不改变调试逻辑。 */
68
+ trace?: (workspacePath: string, message: string) => void
69
+ }
70
+
71
+ /** 内部待发断点(绝对路径口径)。 */
72
+ interface PendingBp {
73
+ file: string
74
+ line: number
75
+ condition?: string
76
+ hitCondition?: string
77
+ logMessage?: string
78
+ }
79
+
80
+ /**
81
+ * 单例调试会话。生命周期:start → starting →(initialized)running →(stopped)paused
82
+ * →(terminated/disconnect)terminated → dispose 回 idle。
83
+ * @author ddj 2026年09月29号
84
+ */
85
+ export class DapSession {
86
+ private readonly deps: DapSessionDeps
87
+ private child: ChildProcess | null = null
88
+ private phase: DapPhase = 'idle'
89
+ private config: DapDebugConfig | null = null
90
+ private workspacePath = ''
91
+ private pid: number | undefined
92
+ private error: string | undefined
93
+ /** spawn 的扩展根目录(emmylua 兼容 shim 注入 extensionPath)。 */
94
+ private extPath = ''
95
+ /** 来源扩展 id(emmylua 兼容 shim 与 frameId 语义判定)。 */
96
+ private extId = ''
97
+ /** 当前适配器可下断点扩展名(断点语言过滤;空 = 不限)。 */
98
+ private exts: string[] = []
99
+ /** initialize 响应 capabilities(configurationDone 门控)。 */
100
+ private caps: Record<string, unknown> | null = null
101
+ /** 当前停帧线程(多线程适配器取 stopped 事件真实 threadId;emmylua 恒 1)。 */
102
+ private threadId = 1
103
+ /** 断点(绝对路径),initialized 与每次 setBreakpoints 时全量下发。 */
104
+ private breakpoints: PendingBp[] = []
105
+ /** findFileReq chunkname → 已排序候选首路径(frame source.path 缺失时回退)。 */
106
+ private readonly sourceFiles = new Map<string, string>()
107
+ /** 停帧调用栈缓存(paused 期间供 stackTrace/topFrame)。 */
108
+ private frames: DapFrameView[] = []
109
+ private stateTop: DapStateView['topFrame'] = null
110
+ private events: DapEventView[] = []
111
+ private eventSeq = 0
112
+ private msgSeq = 1
113
+ /** 适配器 stderr 尾部缓冲(保留末尾 STDERR_TAIL_CAP 字符,退出 fail 消息附带用)。 */
114
+ private stderrTail = ''
115
+ private readonly pending = new Map<number, { resolve: (r: DapMessage | null) => void; timer: NodeJS.Timeout }>()
116
+
117
+ constructor(deps: DapSessionDeps) {
118
+ this.deps = deps
119
+ }
120
+
121
+ /** 当前状态视图。 */
122
+ stateOf(): DapStateView {
123
+ return {
124
+ phase: this.phase,
125
+ configName: this.config?.name,
126
+ configType: this.config?.type,
127
+ pid: this.pid,
128
+ error: this.error,
129
+ topFrame: this.phase === 'paused' ? this.stateTop : null,
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 启动调试会话(异步推进:spawn → initialize → attach/launch)。
135
+ * @author ddj 2026年09月29号
136
+ * @param spec 适配器启动规格
137
+ * @param config 调试配置
138
+ * @param workspacePath 工作区绝对路径(断点/findFile 基准)
139
+ * @param pid 附加目标 pid(attach 型必填且必须已校验存在)
140
+ */
141
+ start(spec: DapAdapterSpec, config: DapDebugConfig, workspacePath: string, pid?: number): void {
142
+ if (this.child) this.killChild()
143
+ this.config = config
144
+ this.workspacePath = workspacePath
145
+ this.extPath = spec.extensionPath
146
+ this.extId = spec.extensionId
147
+ this.exts = spec.exts
148
+ this.caps = null
149
+ this.threadId = 1
150
+ this.pid = pid
151
+ this.error = undefined
152
+ this.stderrTail = ''
153
+ this.frames = []
154
+ this.sourceFiles.clear()
155
+ this.stateTop = null
156
+ this.setPhase('starting')
157
+ try {
158
+ this.spawnAdapter(spec)
159
+ void this.handshake(spec, config)
160
+ } catch (error) {
161
+ this.fail('启动适配器失败:' + String(error))
162
+ }
163
+ }
164
+
165
+ /** 停止会话(disconnect 尽力发送 + 杀进程树)。 */
166
+ stop(): void {
167
+ if (!this.child) { this.setPhase('idle'); return }
168
+ this.request('disconnect').catch(() => {})
169
+ this.killChild()
170
+ this.pushEvent({ kind: 'terminated' })
171
+ this.setPhase('terminated')
172
+ }
173
+
174
+ /** 释放会话(插件卸载/进程退出):杀进程树并复位。 */
175
+ dispose(): void {
176
+ this.killChild()
177
+ this.setPhase('idle')
178
+ }
179
+
180
+ /**
181
+ * poll:状态 + since 之后的增量事件(纯读,无副作用)。
182
+ * @author ddj 2026年09月29号
183
+ * @param since 客户端已有游标
184
+ */
185
+ poll(since: number): DapPollResult {
186
+ const events = this.events.filter((e) => e.seq > since)
187
+ const nextSeq = this.eventSeq
188
+ return { state: this.stateOf(), events, nextSeq }
189
+ }
190
+
191
+ /**
192
+ * 设置某文件断点(DAP per-source replace-all 语义)。
193
+ * 不属于当前适配器语言的文件不入适配器(防跨语言串发),回执 verified=false 带原因;
194
+ * 适配器在线时消费真实回执(可能挪行/判不可验证),离线时按旧口径乐观回执(start 后 flush 重发)。
195
+ * @author ddj 2026年09月29号 / 2026年09月21号
196
+ * @param absFile 目标文件绝对路径
197
+ * @param items 该文件断点行列表
198
+ */
199
+ async setBreakpoints(absFile: string, items: DapBreakpointInput[]): Promise<DapBreakpointAck[]> {
200
+ const allowed = this.bpAllowed(absFile)
201
+ this.breakpoints = this.breakpoints.filter((bp) => bp.file !== absFile)
202
+ if (allowed) {
203
+ for (const p of items) {
204
+ this.breakpoints.push({ file: absFile, line: p.line, condition: p.condition, hitCondition: p.hitCondition, logMessage: p.logMessage })
205
+ }
206
+ }
207
+ const res = allowed
208
+ ? await this.request('setBreakpoints', {
209
+ source: { name: absFile.split(/[\\/]/).pop() ?? '', path: absFile },
210
+ breakpoints: items.map((p) => ({ line: p.line, condition: p.condition, hitCondition: p.hitCondition, logMessage: p.logMessage })),
211
+ })
212
+ : null
213
+ if (!res || !isDapResponse(res) || !res.success) {
214
+ return items.map((p) => ({ line: p.line, verified: allowed, ...(allowed ? {} : { message: '当前调试器不处理该文件类型' }) }))
215
+ }
216
+ return bpAcksOfResponse(responseBody(res), items)
217
+ }
218
+
219
+ /** 调用栈(paused 限定;threadId 取当前停帧线程)。 */
220
+ async stackTrace(): Promise<DapFrameView[]> {
221
+ if (this.phase !== 'paused') return this.frames
222
+ const res = await this.request('stackTrace', { threadId: this.threadId })
223
+ if (!res || !isDapResponse(res) || !res.success) return this.frames
224
+ const raw = responseBody(res).stackFrames
225
+ if (!Array.isArray(raw)) return this.frames
226
+ this.frames = await Promise.all(raw.map((item, index) => this.frameViewOf(item, index)))
227
+ this.stateTop = this.frames[0]
228
+ ? { file: this.frames[0].file, rawFile: this.frames[0].rawFile, line: this.frames[0].line, name: this.frames[0].name }
229
+ : null
230
+ return this.frames
231
+ }
232
+
233
+ /** 作用域列表(paused 限定;frameId 语义随适配器:emmylua=栈下标,标准=原生 frame id)。 */
234
+ async scopes(frameId: number): Promise<DapScopeView[]> {
235
+ if (this.phase !== 'paused') return []
236
+ const res = await this.request('scopes', { frameId })
237
+ if (!res || !isDapResponse(res) || !res.success) return []
238
+ const raw = responseBody(res).scopes
239
+ if (!Array.isArray(raw)) return []
240
+ return raw.map((item) => {
241
+ const s = item as Record<string, unknown>
242
+ return {
243
+ name: String(s.name ?? ''),
244
+ ref: typeof s.variablesReference === 'number' ? s.variablesReference : 0,
245
+ hint: typeof s.presentationHint === 'string' ? s.presentationHint : undefined,
246
+ }
247
+ })
248
+ }
249
+
250
+ /** 变量子级(paused 限定)。 */
251
+ async variables(ref: number): Promise<DapVariableView[]> {
252
+ if (this.phase !== 'paused') return []
253
+ const res = await this.request('variables', { variablesReference: ref })
254
+ if (!res || !isDapResponse(res) || !res.success) return []
255
+ const raw = responseBody(res).variables
256
+ if (!Array.isArray(raw)) return []
257
+ return raw.map((item) => {
258
+ const v = item as Record<string, unknown>
259
+ return {
260
+ name: String(v.name ?? ''),
261
+ value: String(v.value ?? ''),
262
+ type: typeof v.type === 'string' ? v.type : undefined,
263
+ ref: typeof v.variablesReference === 'number' ? v.variablesReference : 0,
264
+ }
265
+ })
266
+ }
267
+
268
+ /** 表达式求值(REPL/监视;paused 限定,避免适配器挂起)。 */
269
+ async evaluate(expression: string, frameId?: number): Promise<{ result: string; type?: string; ref: number }> {
270
+ if (this.phase !== 'paused') return { result: '未暂停:仅在断点暂停时可求值', ref: 0 }
271
+ const res = await this.request('evaluate', { expression, frameId: frameId ?? 0, context: 'repl' })
272
+ if (!res || !isDapResponse(res) || !res.success) return { result: '求值失败', ref: 0 }
273
+ const body = responseBody(res)
274
+ return {
275
+ result: String(body.result ?? ''),
276
+ type: typeof body.type === 'string' ? body.type : undefined,
277
+ ref: typeof body.variablesReference === 'number' ? body.variablesReference : 0,
278
+ }
279
+ }
280
+
281
+ /** 调试控制动作(线程型命令携带当前停帧线程;emmylua 单线程 threadId 恒 1 不受影响)。 */
282
+ action(action: DapAction): void {
283
+ if (!DAP_ACTIONS.includes(action)) return
284
+ const command = action === 'disconnect' ? 'disconnect' : action
285
+ void this.request(command, action === 'disconnect' ? undefined : { threadId: this.threadId }).catch(() => {})
286
+ if (action === 'continue' || action === 'next' || action === 'stepIn' || action === 'stepOut') this.setPhase('running')
287
+ }
288
+
289
+ // ---------------------------------------------------------------- 内部实现
290
+
291
+ /** spawn 适配器进程并接好帧解析。 */
292
+ private spawnAdapter(spec: DapAdapterSpec): void {
293
+ const child = spawn(spec.command, spec.args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true })
294
+ this.child = child
295
+ const parser = createFrameParser()
296
+ child.stdout?.on('data', (chunk: Buffer) => {
297
+ for (const message of parser.push(chunk)) this.handleMessage(message as DapMessage)
298
+ })
299
+ child.stderr?.on('data', (chunk: Buffer) => {
300
+ log.debug('[dap-adapter] ' + chunk.toString('utf8').trim())
301
+ this.stderrTail = (this.stderrTail + chunk.toString('utf8')).slice(-STDERR_TAIL_CAP)
302
+ })
303
+ child.on('error', (error) => this.fail('适配器进程错误:' + String(error)))
304
+ child.on('exit', (code) => {
305
+ if (this.child === child && this.phase !== 'idle' && this.phase !== 'terminated') {
306
+ this.fail('适配器退出(code=' + code + ')' + this.stderrSuffix())
307
+ }
308
+ })
309
+ }
310
+
311
+ /**
312
+ * stderr 尾部摘要(供 fail 消息附带):取末尾若干非空行、以 | 折叠单行化。
313
+ * 无 stderr 内容时返回空串(不改变既有消息文案)。
314
+ * @author ddj 2026年09月22号
315
+ * @returns 形如「;stderr: xxx | yyy」的摘要,或空串
316
+ */
317
+ private stderrSuffix(): string {
318
+ const text = this.stderrTail.trim()
319
+ if (!text) return ''
320
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
321
+ if (!lines.length) return ''
322
+ return ';stderr: ' + lines.slice(-STDERR_TAIL_LINES).join(' | ')
323
+ }
324
+
325
+ /** initialize → attach/launch 握手(initialized 事件后重发断点 + 按能力位收尾)。 */
326
+ private async handshake(spec: DapAdapterSpec, config: DapDebugConfig): Promise<void> {
327
+ const init = await this.request('initialize', initializeArgs(spec.type))
328
+ if (!init || !isDapResponse(init) || !init.success) {
329
+ this.fail('initialize 失败:' + (init && isDapResponse(init) ? init.message ?? '' : '超时'))
330
+ return
331
+ }
332
+ this.caps = responseBody(init)
333
+ const request = requestOfConfig(config)
334
+ const res = await this.request(request, this.requestArgs(config))
335
+ if (!res || !isDapResponse(res) || !res.success) {
336
+ this.fail((request === 'attach' ? 'attach 失败:' : 'launch 失败:') + (res && isDapResponse(res) ? res.message ?? '' : '超时'))
337
+ }
338
+ }
339
+
340
+ /**
341
+ * attach/launch 请求参数:launch.json 原样透传(含未识别字段,对齐 VS Code 语义),
342
+ * emmylua 兼容缺省注入(对齐扩展自身 resolveDebugConfiguration:extensionPath/pid 等)。
343
+ * @author ddj 2026年09月21号
344
+ * @param config 调试配置
345
+ */
346
+ private requestArgs(config: DapDebugConfig): Record<string, unknown> {
347
+ const args = { ...(config.raw ?? {}) }
348
+ // cwd 兜底注入(对齐 VS Code 扩展侧 provider:cwd 缺省 = 工作区根)。
349
+ // DotRush unity 适配器按 <cwd>/Library/EditorInstance.json 定位编辑器实例,
350
+ // 缺 cwd 会回退到宿主进程启动目录导致 attach 必失败(实测根因,2026-09-22);显式 cwd 优先。
351
+ if (args.cwd === undefined && this.workspacePath) args.cwd = this.workspacePath
352
+ if (this.extId !== EMMYLUA_EXT_ID) {
353
+ // unity 适配器的 processId 是端口覆盖而非进程号(DotRush LaunchConfiguration:
354
+ // port = processId || 56000 + editorPid%1000),注入选中 pid 会连错端口 → 豁免不注入。
355
+ const isUnity = config.type === 'unity'
356
+ // 通用 attach:进程选择器解析出的 pid 注入 processId(coreclr 等的附加目标字段)
357
+ if (!isUnity && this.pid && requestOfConfig(config) === 'attach' && args.processId === undefined) args.processId = this.pid
358
+ return args
359
+ }
360
+ // emmylua 适配器从 attach/launch 参数读扩展根路径拼 emmy_tool.exe(EmmyAttachDebugSession:
361
+ // this.extensionPath = args.extensionPath)——VS Code 由扩展宿主自动带上,本插件必须显式注入;
362
+ // 缺失会拼出 undefined/.../emmy_tool.exe 致适配器立即退出(实测根因,2026-09-22)。
363
+ if (args.extensionPath === undefined) args.extensionPath = this.extPath
364
+ if (requestOfConfig(config) === 'attach') {
365
+ args.pid = this.pid ?? (typeof args.pid === 'number' && args.pid > 0 ? args.pid : 0)
366
+ if (!Array.isArray(args.sourcePaths)) args.sourcePaths = [this.workspacePath]
367
+ if (!Array.isArray(args.ext)) args.ext = ['.lua']
368
+ if (args.captureLog === undefined) args.captureLog = false
369
+ return args
370
+ }
371
+ if (args.host === undefined) args.host = 'localhost'
372
+ if (args.port === undefined) args.port = 9966
373
+ if (args.ideConnectDebugger === undefined) args.ideConnectDebugger = true
374
+ if (!Array.isArray(args.sourcePaths)) args.sourcePaths = [this.workspacePath]
375
+ if (!Array.isArray(args.ext)) args.ext = ['.lua']
376
+ return args
377
+ }
378
+
379
+ /** DAP 消息分流:响应 → pending;事件 → handleEvent。 */
380
+ private handleMessage(message: DapMessage): void {
381
+ if (isDapResponse(message)) {
382
+ const entry = this.pending.get(message.request_seq)
383
+ if (entry) { this.pending.delete(message.request_seq); entry.resolve(message) }
384
+ return
385
+ }
386
+ if (isDapEvent(message)) this.handleEvent(message)
387
+ }
388
+
389
+ /** 事件处理:initialized/stopped/terminated/output/log/findFileReq。 */
390
+ private handleEvent(event: DapEvent): void {
391
+ const body = eventBody(event)
392
+ if (event.event === 'initialized') {
393
+ this.setPhase('running')
394
+ this.flushBreakpoints()
395
+ // DAP 标准握手收尾:断点下发完 → configurationDone → 目标开跑。
396
+ // 按能力位门控:emmylua 未声明该能力(不发,行为不变);clrdbg 等声明了(必须发,否则挂起)。
397
+ if (needsConfigurationDone(this.caps)) void this.request('configurationDone').catch(() => {})
398
+ return
399
+ }
400
+ if (event.event === 'stopped') {
401
+ this.threadId = threadIdOfStopped(body)
402
+ void this.handleStopped(String(body.reason ?? 'breakpoint'))
403
+ return
404
+ }
405
+ if (event.event === 'terminated') {
406
+ // 初始化完成前终止 = attach/连接目标阶段失败。DotRush 类适配器把根因写自家日志、
407
+ // DAP 侧只发空 terminated,控制台无声「已结束」极难排查 → 补一行带日志指引的提示。
408
+ if (this.phase === 'starting') {
409
+ const hint = this.earlyTermHint()
410
+ this.pushEvent({ kind: 'output', text: hint + '\n' })
411
+ this.deps.trace?.(this.workspacePath, '[DEBUG terminated] ' + hint)
412
+ }
413
+ this.pushEvent({ kind: 'terminated' })
414
+ this.setPhase('terminated')
415
+ return
416
+ }
417
+ if (event.event === 'continued') { this.setPhase('running'); this.pushEvent({ kind: 'continued' }); return }
418
+ if (event.event === 'findFileReq') { void this.respondFindFile(body); return }
419
+ if (event.event === 'output' || event.event === 'log') {
420
+ this.pushEvent({ kind: 'output', text: String(body.output ?? body.message ?? '') })
421
+ }
422
+ }
423
+
424
+ /** 停帧:置 paused + 预拉调用栈(首帧进 state.topFrame 驱动跳转高亮)。 */
425
+ private async handleStopped(reason: string): Promise<void> {
426
+ this.pushEvent({ kind: 'stopped', reason })
427
+ this.setPhase('paused')
428
+ try { await this.stackTrace() } catch { /* 栈拉取失败不影响 paused 态 */ }
429
+ }
430
+
431
+ /**
432
+ * 初始化前终止的控制台提示:指明失败阶段并给出适配器日志位置(根因常在适配器自家日志)。
433
+ * DotRush 扩展的 monodbg 日志固定在扩展根 bin/DebuggerMono/logs,直接给绝对路径;
434
+ * 其它适配器回退到通用指引(宿主日志 [dap-adapter] 段有 stderr 镜像)。
435
+ * @author ddj 2026年09月22号
436
+ * @returns 提示文本(不含换行)
437
+ */
438
+ private earlyTermHint(): string {
439
+ const dotrushLog = this.extId === 'nromanov.dotrush' && this.extPath
440
+ ? ';适配器日志:' + join(this.extPath, 'bin', 'DebuggerMono', 'logs', 'Error.log')
441
+ : ';请查看适配器自身日志与宿主日志 [dap-adapter] 段'
442
+ return '会话在初始化完成前终止(attach/连接目标失败)' + dotrushLog
443
+ }
444
+
445
+ /** findFileReq 反向应答:按完整 chunkname 排序工作区候选并缓存首命中。 */
446
+ private async respondFindFile(body: Record<string, unknown>): Promise<void> {
447
+ const seq = typeof body.seq === 'number' ? body.seq : -1
448
+ const chunk = String(body.file ?? '')
449
+ const files = await this.deps.findFiles(this.workspacePath, chunk)
450
+ log.debug('[dap-trace] findFileReq seq=' + seq + ' chunk=' + chunk + ' candidates=' + files.length + ' first=' + (files[0] ?? ''))
451
+ this.deps.trace?.(this.workspacePath, '[DEBUG findFileReq] seq=' + seq + ' chunk=' + chunk + ' candidates=' + files.length + ' first=' + (files[0] ?? ''))
452
+ const first = files[0]
453
+ if (first) {
454
+ this.sourceFiles.set(sourceKeyOf(chunk), first)
455
+ this.sourceFiles.set(sourceKeyOf(sourceBaseOf(chunk)), first)
456
+ }
457
+ this.send({ seq: this.msgSeq++, type: 'request', command: 'findFileRsp', arguments: { seq, files } })
458
+ }
459
+
460
+ /** 发送已存断点(initialized 后调用一次;只发属于当前适配器语言的文件)。 */
461
+ private flushBreakpoints(): void {
462
+ if (!this.breakpoints.length) return
463
+ const byFile = new Map<string, PendingBp[]>()
464
+ for (const bp of this.breakpoints) {
465
+ if (!this.bpAllowed(bp.file)) continue
466
+ const list = byFile.get(bp.file) ?? []
467
+ list.push(bp)
468
+ byFile.set(bp.file, list)
469
+ }
470
+ for (const [file, list] of byFile) {
471
+ void this.request('setBreakpoints', {
472
+ source: { name: file.split(/[\\/]/).pop() ?? '', path: file },
473
+ breakpoints: list.map((bp) => ({ line: bp.line, condition: bp.condition, hitCondition: bp.hitCondition, logMessage: bp.logMessage })),
474
+ }).catch(() => {})
475
+ }
476
+ }
477
+
478
+ /** 断点文件是否属于当前适配器(按声明扩展名过滤;未知语言不过滤,兼容旧链路)。 */
479
+ private bpAllowed(file: string): boolean {
480
+ if (!this.exts.length) return true
481
+ const lower = file.toLowerCase()
482
+ return this.exts.some((ext) => lower.endsWith(ext))
483
+ }
484
+
485
+ /** 栈帧源码扩展名判定集:emmylua 沿用旧口径(.lua 家族 + 配置 ext),通用适配器用声明 exts。 */
486
+ private frameExts(): string[] {
487
+ if (this.extId !== EMMYLUA_EXT_ID) return this.exts
488
+ return [...new Set([...(this.config?.ext ?? []), '.lua', '.lua.bytes'])]
489
+ }
490
+
491
+ /** 栈帧 id:emmylua 的 scopes 以 stacks 下标为 frameId(私有语义);标准适配器用原生 id。 */
492
+ private frameIdOf(raw: Record<string, unknown>, index: number): number {
493
+ if (this.extId === EMMYLUA_EXT_ID) return index
494
+ return typeof raw.id === 'number' ? raw.id : index
495
+ }
496
+
497
+ /** 发送请求(seq 自增 + 超时守卫;适配器不回包时拒绝而非悬挂)。 */
498
+ private request(command: string, args?: unknown): Promise<DapMessage | null> {
499
+ if (!this.child?.stdin?.writable) return Promise.resolve(null)
500
+ const seq = this.msgSeq++
501
+ const frame = dapRequest(seq, command, args)
502
+ return new Promise((resolve) => {
503
+ const timer = setTimeout(() => { this.pending.delete(seq); resolve(null) }, REQUEST_TIMEOUT_MS)
504
+ this.pending.set(seq, { resolve, timer })
505
+ this.child!.stdin!.write(encodeMessage(frame))
506
+ })
507
+ }
508
+
509
+ /** 直发原始帧(反向请求应答等无需等待响应的场景)。 */
510
+ private send(frame: DapRequest): void {
511
+ if (!this.child?.stdin?.writable) return
512
+ this.child.stdin.write(encodeMessage(frame))
513
+ }
514
+
515
+ /** 追加事件(环形截断)。 */
516
+ private pushEvent(partial: Omit<DapEventView, 'seq'>): void {
517
+ this.eventSeq += 1
518
+ this.events.push({ seq: this.eventSeq, ...partial })
519
+ if (this.events.length > EVENT_CAP) this.events.splice(0, this.events.length - EVENT_CAP)
520
+ }
521
+
522
+ /** 记录错误并终止会话。 */
523
+ private fail(message: string): void {
524
+ this.error = message
525
+ log.warn('[dap] ' + message)
526
+ this.pushEvent({ kind: 'output', text: message + '\n' })
527
+ this.pushEvent({ kind: 'terminated' })
528
+ this.killChild()
529
+ this.setPhase('terminated')
530
+ }
531
+
532
+ /** 杀适配器进程树(Windows taskkill /T /F,同 LSP transport 口径)。 */
533
+ private killChild(): void {
534
+ const child = this.child
535
+ this.child = null
536
+ for (const entry of this.pending.values()) { clearTimeout(entry.timer); entry.resolve(null) }
537
+ this.pending.clear()
538
+ if (!child) return
539
+ try { child.kill() } catch { /* 忽略 */ }
540
+ if (process.platform === 'win32' && child.pid) {
541
+ try { spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }) } catch { /* 忽略 */ }
542
+ }
543
+ setTimeout(() => { try { child.kill('SIGKILL') } catch { /* 忽略 */ } }, 1500).unref?.()
544
+ }
545
+
546
+ /** 相位切换(running→running 等幂等跳过)。 */
547
+ private setPhase(phase: DapPhase): void {
548
+ if (this.phase === phase) return
549
+ this.phase = phase
550
+ if (phase === 'idle') { this.events = []; this.config = null; this.pid = undefined; this.error = undefined }
551
+ }
552
+
553
+ /** 适配器上报路径 → 帧视图(file 始终保留可打开的规范路径)。 */
554
+ private async frameViewOf(item: unknown, index: number): Promise<DapFrameView> {
555
+ const raw = item as Record<string, unknown>
556
+ const source = (raw.source ?? {}) as Record<string, unknown>
557
+ const sourcePath = typeof source.path === 'string' ? source.path : ''
558
+ const sourceName = typeof source.name === 'string' ? source.name : ''
559
+ const cached = this.sourceFiles.get(sourceKeyOf(sourcePath)) ?? this.sourceFiles.get(sourceKeyOf(sourceName))
560
+ let selected = cached || sourcePath || sourceName
561
+ const needsLookup = !selected || !hasSourceExtension(selected, this.frameExts())
562
+ log.debug('[dap-trace] frame rawPath=' + sourcePath + ' name=' + sourceName + ' cached=' + (cached ?? '') + ' needsLookup=' + needsLookup + ' selected=' + selected)
563
+ this.deps.trace?.(this.workspacePath, '[DEBUG frame] rawPath=' + sourcePath + ' name=' + sourceName + ' cached=' + (cached ?? '') + ' needsLookup=' + needsLookup + ' selected=' + selected)
564
+ if (!cached && needsLookup) {
565
+ const candidates = await this.deps.findFiles(this.workspacePath, sourceName || sourcePath)
566
+ log.debug('[dap-trace] frameLookup source=' + (sourceName || sourcePath) + ' candidates=' + candidates.length + ' first=' + (candidates[0] ?? ''))
567
+ this.deps.trace?.(this.workspacePath, '[DEBUG frameLookup] source=' + (sourceName || sourcePath) + ' candidates=' + candidates.length + ' first=' + (candidates[0] ?? ''))
568
+ if (candidates[0]) {
569
+ selected = candidates[0]
570
+ this.sourceFiles.set(sourceKeyOf(sourceName || sourcePath), candidates[0])
571
+ } else if (isBareChunk(sourceName || sourcePath)) {
572
+ // 反查失败 + 裸 chunkname:无可打开路径。置空而非回退原串,
573
+ // 避免客户端按工作区根拼出必然不存在的路径、打开注定失败的页签。
574
+ selected = ''
575
+ }
576
+ }
577
+ const finalFile = selected ? sourcePathOf(selected, this.workspacePath) : ''
578
+ log.debug('[dap-trace] frameFinal name=' + sourceName + ' file=' + finalFile + ' line=' + String(raw.line ?? 0))
579
+ this.deps.trace?.(this.workspacePath, '[DEBUG frameFinal] name=' + sourceName + ' file=' + finalFile + ' line=' + String(raw.line ?? 0))
580
+ return {
581
+ id: this.frameIdOf(raw, index),
582
+ name: String(raw.name ?? ''),
583
+ file: finalFile,
584
+ rawFile: sourceName || sourcePath || selected,
585
+ line: typeof raw.line === 'number' ? raw.line : 0,
586
+ }
587
+ }
588
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * dsh-vscode-mode host — 调试目标进程枚举(Windows)。
3
+ * 用 PowerShell Get-Process 取 窗口标题/进程名/路径(完整 Unicode,无 GBK 乱码),
4
+ * 等价替代 VS Code 扩展的 emmy_tool.exe list_processes(扩展侧 QuickPick 的数据源),
5
+ * 匹配语义一致:processName 对 窗口标题 或 进程文件名 做**包含**匹配。
6
+ * emmylua_attach 仅支持 Windows(适配器原生限制),非 Windows 返回空表。
7
+ * 作者 ddj 2026年09月29号
8
+ */
9
+ import { execFile } from 'node:child_process'
10
+ import type { DapProcessInfo } from '../shared/dap.js'
11
+
12
+ /** PowerShell 单行命令:仅带主窗口的进程,JSON 输出(UTF-8)。 */
13
+ export const PROCESS_LIST_CMD =
14
+ '$OutputEncoding=[Text.Encoding]::UTF8;[Console]::OutputEncoding=[Text.Encoding]::UTF8;' +
15
+ 'Get-Process | Where-Object { $_.MainWindowHandle -ne 0 } |' +
16
+ ' Select-Object Id,ProcessName,MainWindowTitle,Path | ConvertTo-Json -Compress -Depth 2'
17
+
18
+ /** 单进程 JSON 视图(ConvertTo-Json 输出形状)。 */
19
+ interface ProcessJsonRow {
20
+ Id?: number
21
+ ProcessName?: string
22
+ MainWindowTitle?: string
23
+ Path?: string | null
24
+ }
25
+
26
+ /**
27
+ * 解析 ConvertTo-Json 输出为进程列表(纯函数,可单测)。
28
+ * 单条时 PowerShell 输出对象而非数组,两种形状都接受。
29
+ * @author ddj 2026年09月29号
30
+ * @param text PowerShell stdout
31
+ * @returns 进程列表(无效行剔除)
32
+ */
33
+ export function parseProcessListJson(text: string): DapProcessInfo[] {
34
+ let data: unknown
35
+ try { data = JSON.parse(text) } catch { return [] }
36
+ const rows = Array.isArray(data) ? data : [data]
37
+ const out: DapProcessInfo[] = []
38
+ for (const row of rows) {
39
+ if (!row || typeof row !== 'object') continue
40
+ const item = row as ProcessJsonRow
41
+ const pid = typeof item.Id === 'number' ? item.Id : Number.NaN
42
+ if (!Number.isFinite(pid) || pid <= 0) continue
43
+ const path = typeof item.Path === 'string' ? item.Path : ''
44
+ const name = path ? path.split(/[\\/]/).pop() ?? (item.ProcessName ?? '') : (item.ProcessName ?? '') + '.exe'
45
+ out.push({ pid, title: typeof item.MainWindowTitle === 'string' ? item.MainWindowTitle : '', path, name })
46
+ }
47
+ return out
48
+ }
49
+
50
+ /**
51
+ * 按 processName 过滤进程(与 VS Code 扩展口径一致:标题或文件名包含匹配;空串全量)。
52
+ * @author ddj 2026年09月29号
53
+ * @param items 全量进程
54
+ * @param processName 过滤词
55
+ */
56
+ export function filterProcesses(items: DapProcessInfo[], processName?: string): DapProcessInfo[] {
57
+ const needle = (processName ?? '').trim()
58
+ if (!needle) return items
59
+ return items.filter((it) => it.title.includes(needle) || it.name.includes(needle))
60
+ }
61
+
62
+ /**
63
+ * 枚举带主窗口的进程(Windows;spawn 失败/非 Windows 返回空表不抛错)。
64
+ * @author ddj 2026年09月29号
65
+ */
66
+ export function listProcesses(): Promise<DapProcessInfo[]> {
67
+ if (process.platform !== 'win32') return Promise.resolve([])
68
+ return new Promise((resolve) => {
69
+ execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', PROCESS_LIST_CMD],
70
+ { encoding: 'utf8', windowsHide: true, timeout: 15000, maxBuffer: 8 << 20 },
71
+ (error, stdout) => {
72
+ if (error && !stdout) { resolve([]); return }
73
+ resolve(parseProcessListJson(String(stdout ?? '')))
74
+ })
75
+ })
76
+ }