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
@@ -12,6 +12,8 @@ import { dbg, rpc } from '../rpc.js'
12
12
  import { emitFileChanged, emitRefresh } from '../events.js'
13
13
  import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
14
14
  import { dataUrlOf, isImagePath, isSvgPath } from '../imagePreview.js'
15
+ import { isMarkdownPath } from '../markdownPreview.js'
16
+ import { MarkdownPanel } from '../md/mdPanel.js'
15
17
  import { base64ToBytes, isPdfPath } from '../pdfPreview.js'
16
18
  import {
17
19
  clearBaseline,
@@ -30,6 +32,7 @@ import { createDiffRenderer } from '../monaco/diffRender.js'
30
32
  import { ST, callIdAttr, noopHunk, summarize } from '../state/records.js'
31
33
  import { diffRegions } from '../state/regions.js'
32
34
  import { QuickOpen } from './QuickOpen.js'
35
+ import { attachHorizontalWheel } from './horizontalWheel.js'
33
36
  import { CommandPalette } from './CommandPalette.js'
34
37
  import { closeCommandPalette } from '../commandPaletteStore.js'
35
38
  import { DiffLauncher } from './DiffLauncher.js'
@@ -47,6 +50,7 @@ import { bindingsOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybi
47
50
  import { getSidebarMinWidth } from '../sidebarMin.js'
48
51
  import { navHistoryFor } from '../navHistory.js'
49
52
  import { statusOfAdd } from '../addToConversation.js'
53
+ import { setSearchSeed } from '../searchSeed.js'
50
54
  import { CACHE_KEY } from '../paths.js'
51
55
  import { runGoToDefinition, runFindReferences, hideReferencesOverlay } from '../monaco/lsp/providers.js'
52
56
  import { bindLspUnderline } from '../monaco/lsp/underline.js'
@@ -54,12 +58,15 @@ import { onLspProgress, refreshStatus, setSession as setLspSession } from '../mo
54
58
  import { setupAiInline, trackAiEditor, aiInlineEnabled } from '../ai/inlineProvider.js'
55
59
  import { SnippetsPicker } from './SnippetsPicker.js'
56
60
  import { invalidateSnippets, setSnippetsSession, setupSnippets } from '../snippets/provider.js'
61
+ import { setupLaunchJson, prefetchSnippets } from '../dap/launchSnippetProvider.js'
62
+ import { findArrayPos, LAUNCH_JSON_RE } from '../dap/launchInsert.js'
57
63
  import {
58
64
  absoluteOf, ancestorDirsOf, applyClose, baseNameOf, closeAll, closeOthers, closeRight, closeSaved,
59
- insertTab, isTreeRevealable, normalizeTabs, pickActive, relativeOf, tabPathOf, togglePin,
65
+ evictPlan, insertTab, isTreeRevealable, normalizeTabs, pickActive, relativeOf, tabPathOf, togglePin,
60
66
  } from '../tabActions.js'
67
+ import { getMaxOpenEditors } from '../editorLimit.js'
61
68
  import { buildTabMenu } from '../tabMenu.js'
62
- import { ensureSvnChanges, ensureSvnStatus, getSvnChanges, getSvnStatus, refreshSvnChanges, svnAdd, svnChangeMapOf, svnDiffBase, svnEditorActions, svnRevert, svnTortoise, svnUpdate } from '../svnStatus.js'
69
+ import { ensureSvnChanges, ensureSvnStatus, getSvnChanges, getSvnStatus, refreshSvnChanges, svnAdd, svnChangeMapOf, svnDiffBase, svnDiffLocalPair, svnEditorActions, svnRevert, svnTortoise, svnUpdate } from '../svnStatus.js'
63
70
  import { ensureSvnLog, getSvnLog, refreshSvnLog, svnDiffPair, svnDiffRev, svnDiffWorking, svnLogKeyOf, svnLogLoadedLimit, svnLogTruncated, svnWcRev, SVN_LOG_SHOW_ALL_LIMIT } from '../svnLog.js'
64
71
  import { runSvnAction } from '../svnActions.js'
65
72
  import { dshTrace } from '../svnStore.js'
@@ -69,7 +76,18 @@ import type { SvnAction } from '../../shared/svn.js'
69
76
  import { createSaveTimer } from '../saveDebounce.js'
70
77
  import { ContextMenu } from './ContextMenu.js'
71
78
  import { SvnLogDialog } from './SvnLogDialog.js'
79
+ import { SvnPatchDialog } from './SvnPatchDialog.js'
80
+ import { SvnSumDialog } from './SvnSumDialog.js'
72
81
  import { LogDialog } from './LogDialog.js'
82
+ import { dapStore } from '../dap/store.js'
83
+ import { bpDisabledLinesOf, bpLinesOf } from '../dap/breakpoints.js'
84
+ import { applyBpDecorations, setEditingLine, setHintLine, setPausedLine } from '../dap/decorate.js'
85
+ import { hintLineFor } from '../dap/hintLine.js'
86
+ import { createBpWidget } from '../dap/BpWidget.js'
87
+ import { buildBpMenu } from '../dap/bpMenu.js'
88
+ import { clampToolbarPosition, loadToolbarPosition, saveToolbarPosition } from '../dap/toolbarDrag.js'
89
+ import { editorModelPathOf } from '../dap/modelPath.js'
90
+ import { dapTrace } from '../dap/trace.js'
73
91
 
74
92
  /** 日志弹窗每次加载条数(「加载更多」按此步长递增;上限由 host 的 SVN_LOG_SHOW_ALL_CAP 约束)。 */
75
93
  const SVN_LOG_PAGE = 100
@@ -85,6 +103,18 @@ function svnLogOptsOf(svnLog) {
85
103
  return { stopOnCopy: svnLog.soc === true, showMerged: svnLog.merged === true, range: svnLog.range ?? null }
86
104
  }
87
105
 
106
+ /**
107
+ * 工作区相对路径 → 所在目录相对路径(W2-2 目录对比的默认目标;无目录 = 工作副本根)。
108
+ * @author ddj 2026年09月20号
109
+ * @param relPath 工作区相对路径(可空)
110
+ * @returns 目录相对路径('' = 工作副本根)
111
+ */
112
+ function dirOfRel(relPath) {
113
+ const value = String(relPath ?? '').replace(/\\/g, '/')
114
+ const cut = value.lastIndexOf('/')
115
+ return cut > 0 ? value.slice(0, cut) : ''
116
+ }
117
+
88
118
  /** 跳转目标高亮的保留时长(LSP/搜索跳转落地后给用户的位置提示,到期自动清除)。 */
89
119
  const NAV_FLASH_MS = 1200
90
120
 
@@ -165,6 +195,7 @@ export function EditorView(props) {
165
195
  const [imgSize, setImgSize] = React.useState(null) // 图片自然尺寸 { w, h }(路径栏 meta)
166
196
  const [imgBroken, setImgBroken] = React.useState(false) // 图片解码失败(onError),显示占位与重试
167
197
  const svgTextRef = React.useRef(new Set()) // 强制以文本打开的 SVG 路径集合(toggleSvgText 维护)
198
+ const mdPreviewRef = React.useRef(new Set()) // 处于预览态的 Markdown 路径集合(toggleMdPreview 维护;不持久化)
168
199
  const [pdfBytes, setPdfBytes] = React.useState(null) // 当前 PDF tab 的原始字节(null=加载中/非 PDF)
169
200
  const pdfB64CacheRef = React.useRef(new Map()) // path → base64(tab 切回免重读;FIFO 上限防内存膨胀)
170
201
  const pdfCtlRef = React.useRef(new Map()) // path → PDF 面板控制器(mount 产出,关闭/卸载销毁)
@@ -188,7 +219,9 @@ export function EditorView(props) {
188
219
  const [tabMenu, setTabMenu] = React.useState(null) // Tab 右键菜单 { x,y,path }(编辑区右键走 Monaco 原生菜单,无此浮层)
189
220
  const [svnStatus, setSvnStatus] = React.useState(null) // SVN 能力状态(SvnStatusPayload;null=未加载,SVN 入口隐藏)
190
221
  const [svnChanges, setSvnChanges] = React.useState(null) // SVN 变更清单(null=未加载;徽标/菜单判定)
191
- const [svnDiff, setSvnDiff] = React.useState(null) // 差异视图 { path, base, working, reason, message, leftLabel, rightLabel, scheme }(null=关闭)
222
+ const [svnDiff, setSvnDiff] = React.useState(null) // 差异视图 { path, base, working, reason, message, leftLabel, rightLabel, scheme, logTarget?, binary?, encodingHint? }(null=关闭;logTarget 存在 = 来自日志弹窗,差异条可返回日志)
223
+ const [patchDialog, setPatchDialog] = React.useState(null) // W2-1 补丁对话框 { path, text, truncated, binary }(null = 关闭)
224
+ const [sumDialog, setSumDialog] = React.useState(null) // W2-2 目录对比对话框 { path }(null = 关闭)
192
225
  const [svnLog, setSvnLog] = React.useState(null) // 日志弹窗 { target, limit, soc?, range? }(null=关闭;P1-4/P1-6 增选项维度)
193
226
  const [svnLogData, setSvnLogData] = React.useState(null) // 日志条目(null=未加载)
194
227
  const [svnLogBusy, setSvnLogBusy] = React.useState(false) // 日志在途
@@ -229,6 +262,7 @@ export function EditorView(props) {
229
262
  const navBackRef = React.useRef(null) // 后退动作最新闭包(窗口级键盘监听读取)
230
263
  const navForwardRef = React.useRef(null) // 前进动作最新闭包(窗口级键盘监听读取)
231
264
  const cycleTabRef = React.useRef(null) // 页签循环动作最新闭包(Ctrl+Alt+←/→、Ctrl+PgUp/PgDn)
265
+ const toggleMdPreviewRef = React.useRef(null) // Markdown 预览切换动作最新闭包(窗口键位监听读取,防陈旧闭包)
232
266
  const rowNavColRef = React.useRef(null) // 整行上下移动的期望列(连续移动保持列位)
233
267
  const rowNavMoveRef = React.useRef(false) // 本次光标变化是否由整行移动触发(否则清空期望列)
234
268
  const activeRef = React.useRef(null) // 当前活动文件的最新值(空依赖闭包/指令回调读取)
@@ -240,6 +274,9 @@ export function EditorView(props) {
240
274
  const dirtyRef = React.useRef({}) // 脏标记最新值(菜单禁用判定与关闭时落盘读)
241
275
  dirtyRef.current = dirtyMap
242
276
  const tabsHostRef = React.useRef(null) // 页签栏容器(切换后把当前页签滚入可见区)
277
+ // 页签上限与 LRU 使用序(超限淘汰最久未用者;见下方 tabTouchEffect / tabEvictEffect)
278
+ const [tabLimit, setTabLimit] = React.useState(() => getMaxOpenEditors())
279
+ const tabUseRef = React.useRef({ seq: 0, used: {} })
243
280
  const [navTick, setNavTick] = React.useState(0) // 历史可用性版本(按钮 disabled 重渲染)
244
281
  const hoverRegionsRef = React.useRef([]) // 当前 pending 区域镜像(稳定回调读取)
245
282
  const lineRegionMapRef = React.useRef(new Map()) // 行 → 区域 映射(hover 命中)
@@ -252,6 +289,82 @@ export function EditorView(props) {
252
289
  const batchBusyRef = React.useRef(false) // 批量 Keep All/Undo All 防重入
253
290
  const menuHandlersRef = React.useRef(null) // 右键菜单动作的最新闭包(Monaco addAction 空依赖回调读取)
254
291
  const svnMenuDisposersRef = React.useRef([]) // Monaco 右键 SVN 组当前 disposables(同步时先注销旧组)
292
+ // 调试(DAP):store 订阅 tick 驱动重渲 + gutter/F9 切换闭包(ensureEditor 空依赖读取)
293
+ const [dapTick, setDapTick] = React.useState(0)
294
+ React.useEffect(() => dapStore.subscribe(() => setDapTick((t) => t + 1)), [])
295
+ const dapSnap = dapStore.getSnapshot()
296
+ const [dapBarPos, setDapBarPos] = React.useState(() => loadToolbarPosition(scope))
297
+ const [dapBarDragging, setDapBarDragging] = React.useState(false)
298
+ const dapBarRef = React.useRef(null)
299
+ const dapDragRef = React.useRef(null)
300
+ React.useEffect(() => {
301
+ setDapBarPos(loadToolbarPosition(scope))
302
+ }, [scope])
303
+ React.useEffect(() => {
304
+ const onMove = (event) => {
305
+ const drag = dapDragRef.current
306
+ const bar = dapBarRef.current
307
+ const area = bar?.parentElement
308
+ if (!drag || !bar || !area) return
309
+ const areaRect = area.getBoundingClientRect()
310
+ const barRect = bar.getBoundingClientRect()
311
+ const next = clampToolbarPosition({
312
+ left: event.clientX - areaRect.left - drag.offsetX,
313
+ top: event.clientY - areaRect.top - drag.offsetY,
314
+ }, {
315
+ width: areaRect.width,
316
+ height: areaRect.height,
317
+ barWidth: barRect.width,
318
+ barHeight: barRect.height,
319
+ })
320
+ setDapBarPos(next)
321
+ }
322
+ const onUp = () => {
323
+ if (!dapDragRef.current) return
324
+ dapDragRef.current = null
325
+ setDapBarDragging(false)
326
+ const latest = dapBarPosRef.current
327
+ if (latest) saveToolbarPosition(scope, latest)
328
+ }
329
+ document.addEventListener('pointermove', onMove)
330
+ document.addEventListener('pointerup', onUp)
331
+ return () => {
332
+ document.removeEventListener('pointermove', onMove)
333
+ document.removeEventListener('pointerup', onUp)
334
+ }
335
+ }, [scope])
336
+ const dapBarPosRef = React.useRef(dapBarPos)
337
+ dapBarPosRef.current = dapBarPos
338
+ const startDapBarDrag = (event) => {
339
+ if (event.button !== 0 || !dapBarRef.current) return
340
+ const rect = dapBarRef.current.getBoundingClientRect()
341
+ event.preventDefault()
342
+ event.stopPropagation()
343
+ dapDragRef.current = { offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top }
344
+ setDapBarDragging(true)
345
+ event.currentTarget.setPointerCapture?.(event.pointerId)
346
+ }
347
+ // 进程选择器选中项(多候选附加时;候选集变化回落首个)
348
+ const [dapPickPid, setDapPickPid] = React.useState(0)
349
+ React.useEffect(() => { setDapPickPid(dapSnap.candidates?.[0]?.pid ?? 0) }, [dapSnap.candidates])
350
+ // 断点右键菜单(glyph 区右键)与行内断点编辑浮层(Monaco content widget)
351
+ const [dapBpMenu, setDapBpMenu] = React.useState(null)
352
+ const bpWidgetRef = React.useRef(null) // { close, path, line } | null
353
+ const dapScopeRef = React.useRef(scope)
354
+ const dapCwdRef = React.useRef(cwd)
355
+ dapScopeRef.current = scope
356
+ dapCwdRef.current = cwd
357
+ const dapToggleRef = React.useRef(null)
358
+ dapToggleRef.current = (path, line) => {
359
+ if (!cwd || !path || !line) return
360
+ // 严格对齐 CodeBuddy:任意文件都可切换断点(不再限定 .lua 调试目标)
361
+ dapStore.toggleAt(scope, cwd, path, line)
362
+ }
363
+ // 工作区变化同步调试 store(配置清单/启动基准);有 cwd 时预拉 launch.json 配置
364
+ React.useEffect(() => {
365
+ dapStore.setWorkspace(cwd || '')
366
+ if (cwd) void dapStore.ensureConfigs()
367
+ }, [cwd])
255
368
  const svnStatusRef = React.useRef(null) // svnStatus 渲染值镜像(ensureEditor 空依赖闭包读取最新状态)
256
369
  const [snippetPicker, setSnippetPicker] = React.useState(null) // 'configure' | 'insert' | null(代码片段浮层)
257
370
  const doSaveRef = React.useRef(null) // 保存动作的最新闭包(窗口级保存监听读取)
@@ -282,6 +395,9 @@ export function EditorView(props) {
282
395
  const isImageActive = !!active && isImagePath(active) && !svgTextRef.current.has(active)
283
396
  // 当前 tab 是否为 PDF 面板(与 loadContent 分派条件同源)
284
397
  const isPdfActive = !!active && isPdfPath(active)
398
+ // 当前 tab 是否为可预览的 Markdown,以及是否正处于预览态(预览态替换 Monaco host,只读)
399
+ const isMdActive = !!active && isMarkdownPath(active)
400
+ const mdPreviewing = isMdActive && mdPreviewRef.current.has(active)
285
401
  const regions = React.useMemo(() => diffRegions(currentRecords, contentReady ? content : null).filter((r) => !r.superseded), [currentRecords, content, contentPath, active])
286
402
  // useMemo 稳定引用:否则 hover 等重渲染会让 view zone effect 反复重建(- 号闪烁)
287
403
  const pendingRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && !r.stale), [regions])
@@ -627,8 +743,9 @@ export function EditorView(props) {
627
743
  setStatus('已加载')
628
744
  } else {
629
745
  const base = res?.error ? String(res.error) : '读取失败'
746
+ dapTrace('read-failed', { path, error: base, resolvedPath: res?.resolvedPath, cwd })
630
747
  // 带出 host 解析后的真实路径:跳转失败时一眼看出是路径解析错还是目标不存在
631
- const message = res?.resolvedPath ? base + ':' + String(res.resolvedPath) : base
748
+ const message = res?.resolvedPath ? base + ':' + relativeOf(String(res.resolvedPath), cwd) : base
632
749
  setLoadError(message)
633
750
  setError(message)
634
751
  setStatus('读取失败')
@@ -972,10 +1089,18 @@ export function EditorView(props) {
972
1089
  // G9:迁移历史持久化里的绝对路径页签(旧版差异入口写入),并顺带按新形态去重
973
1090
  const restored = normalizeTabs(saved?.tabs, cwd)
974
1091
  if (restored.length) {
975
- setTabs(restored)
976
1092
  // 活动路径同形态归一,否则恢复后匹配不到任何页签(pickActive 回退首个)
977
1093
  const wanted = typeof saved?.active === 'string' ? tabPathOf(saved.active, cwd) : saved?.active
978
- setActive(pickActive(restored, wanted))
1094
+ const activePath = pickActive(restored, wanted)
1095
+ // 上限已下调过(或历史存档超限):恢复即收敛。此时页签均未编辑、无脏数据,
1096
+ // 故只做纯计算裁剪,不走 closeTabs(避免无谓的落盘探测与状态栏提示)。
1097
+ // evictPlan 已保护活动页签,无需额外 keep。
1098
+ const limit = getMaxOpenEditors()
1099
+ const plan = evictPlan(restored, limit, { active: activePath, used: {} })
1100
+ const dropped = new Set(plan)
1101
+ const kept = plan.length ? restored.filter((tab) => !dropped.has(tab.path)) : restored
1102
+ setTabs(kept)
1103
+ setActive(kept.some((tab) => tab.path === activePath) ? activePath : (kept[0]?.path ?? null))
979
1104
  }
980
1105
  }
981
1106
  } catch (e) { /* 损坏忽略 */ }
@@ -999,6 +1124,45 @@ export function EditorView(props) {
999
1124
  catch (e) { /* 忽略 */ }
1000
1125
  }, [tabs, active, sessionId, scope])
1001
1126
 
1127
+ /**
1128
+ * 标记页签为「最近使用」(LRU 序号单调递增;编辑动作也算使用,见 onEdit)。
1129
+ * @author ddj 2026年09月18号
1130
+ * @param path 页签路径
1131
+ */
1132
+ const touchTab = (path) => {
1133
+ if (!path) return
1134
+ const state = tabUseRef.current
1135
+ state.seq += 1
1136
+ state.used[path] = state.seq
1137
+ }
1138
+
1139
+ // 页签上限设置变更(edrv:max-open-editors)→ 更新上限态并触发下方淘汰 effect 复算
1140
+ React.useEffect(() => {
1141
+ const onLimit = () => setTabLimit(getMaxOpenEditors())
1142
+ window.addEventListener('edrv:max-open-editors', onLimit)
1143
+ return () => window.removeEventListener('edrv:max-open-editors', onLimit)
1144
+ }, [])
1145
+
1146
+ // LRU 使用序维护:活动页签变化即标记;顺带把 used 表裁剪到当前页签集合,
1147
+ // 否则长会话中已关闭页签的条目会永久堆积(内存无界增长)。
1148
+ // ⚠️ 本 effect 必须声明在淘汰 effect 之前:先标记再淘汰,才能保证「刚打开/刚切到的
1149
+ // 页签」不会在同一次 commit 里被当作最久未用者淘汰。
1150
+ React.useEffect(() => {
1151
+ if (active) touchTab(active)
1152
+ const alive = new Set(tabs.map((t) => t.path))
1153
+ const used = tabUseRef.current.used
1154
+ for (const path of Object.keys(used)) if (!alive.has(path)) delete used[path]
1155
+ }, [tabs, active])
1156
+
1157
+ // 页签超限淘汰:关闭最久未使用的页签(固定页签与活动页签受保护;见 tabActions.evictPlan)。
1158
+ // 复用 closeTabs:它已实现 flushSave + persistDirty(脏页签先静默落盘)+ releaseTab + commitClose,
1159
+ // 故「超限淘汰脏页签」不会丢用户编辑。淘汰后 tabs 变化 → 本 effect 复算 evictPlan 返回空 → 自然收敛。
1160
+ React.useEffect(() => {
1161
+ const plan = evictPlan(tabsRef.current, tabLimit, { active: activeRef.current, used: tabUseRef.current.used })
1162
+ if (!plan.length) return
1163
+ closeTabs(applyClose(tabsRef.current, new Set(plan), activeRef.current), '已关闭最久未使用的页签')
1164
+ }, [tabs, tabLimit, active])
1165
+
1002
1166
  // 侧边栏状态:恢复(显隐/宽度/激活面板);侧栏形态独立键(默认收起,不共享页签形态偏好)
1003
1167
  // 宽度下限来自通用设置 sidebarMinWidth(默认 300),恢复值按其重夹
1004
1168
  const sidebarKey = CACHE_KEY.sidebar + (layout === 'side' ? 'side.' : '') + String(scope)
@@ -1056,14 +1220,51 @@ export function EditorView(props) {
1056
1220
  return () => window.removeEventListener('keydown', onKey, true)
1057
1221
  }, [])
1058
1222
 
1059
- // Ctrl+Shift+F 全局搜索:展开侧边栏 + 激活搜索页签,随后聚焦搜索输入框
1223
+ /**
1224
+ * 打开搜索面板(Ctrl+Shift+F 与命令栏「在工作区中搜索」的唯一动作)。
1225
+ *
1226
+ * 若编辑器有选区,把选区文本作为一次性种子交给搜索面板自动填入(需求 1);
1227
+ * 无选区/无编辑器时种子为空,行为与改动前一致(仅展开并聚焦)。
1228
+ * 种子经 searchSeed 模块交棒而非事件 detail:侧栏原本收起时搜索面板尚未挂载,
1229
+ * 事件 detail 会丢;面板挂载后自行取走槽内种子。
1230
+ * @author ddj 2026年09月18号
1231
+ */
1232
+ const openSearchPanel = () => {
1233
+ const ed = editorRef.current
1234
+ const model = ed?.getModel?.()
1235
+ const sel = ed?.getSelection?.()
1236
+ let selected = ''
1237
+ if (model && sel && (sel.startLineNumber !== sel.endLineNumber || sel.startColumn !== sel.endColumn)) {
1238
+ // 选中区间文本(多行时 seedQueryOf 只取首行)
1239
+ selected = model.getValueInRange?.(sel) ?? ''
1240
+ }
1241
+ setSearchSeed(selected)
1242
+ setSidebarOn(true)
1243
+ setActivePanel('search')
1244
+ setTimeout(() => window.dispatchEvent(new CustomEvent('edrv:search-focus')), 0)
1245
+ }
1246
+
1247
+ // Ctrl+Shift+F 全局搜索:展开侧边栏 + 激活搜索页签,随后聚焦搜索输入框(有选中则填入)
1060
1248
  React.useEffect(() => {
1061
1249
  const onKey = (e) => {
1062
1250
  if (!matchEvent(e, bindingsOf('edrv.searchInFiles'))) return
1063
1251
  e.preventDefault(); e.stopPropagation()
1064
- setSidebarOn(true)
1065
- setActivePanel('search')
1066
- setTimeout(() => window.dispatchEvent(new CustomEvent('edrv:search-focus')), 0)
1252
+ openSearchPanel()
1253
+ }
1254
+ window.addEventListener('keydown', onKey, true)
1255
+ return () => window.removeEventListener('keydown', onKey, true)
1256
+ }, [])
1257
+
1258
+ // Ctrl+Shift+V 切换 Markdown 预览:仅活动文件是 Markdown 时吞键;
1259
+ // 其余情况直接放行(不 preventDefault),保留浏览器/输入框原生的「无格式粘贴」语义
1260
+ // ——与 closeTab / addSelectionRef「不可用时放行按键」同款约定。
1261
+ React.useEffect(() => {
1262
+ const onKey = (e) => {
1263
+ if (!matchEvent(e, bindingsOf('edrv.toggleMarkdownPreview'))) return
1264
+ const path = activeRef.current
1265
+ if (!isMarkdownPath(path)) return
1266
+ e.preventDefault(); e.stopPropagation()
1267
+ toggleMdPreviewRef.current?.(path)
1067
1268
  }
1068
1269
  window.addEventListener('keydown', onKey, true)
1069
1270
  return () => window.removeEventListener('keydown', onKey, true)
@@ -1181,10 +1382,11 @@ export function EditorView(props) {
1181
1382
  ['edrv.command.save', () => { if (editorRef.current?.getModel?.()) { flushSave(); doSaveRef.current?.(false) } }],
1182
1383
  // 快速打开由 QuickOpen 自己接该事件(它持有搜索框 ref),此处不重复实现
1183
1384
  ['edrv.command.toggleSidebar', () => setSidebarOn((v) => !v)],
1184
- ['edrv.command.searchInFiles', () => {
1185
- setSidebarOn(true)
1186
- setActivePanel('search')
1187
- setTimeout(() => window.dispatchEvent(new CustomEvent('edrv:search-focus')), 0)
1385
+ ['edrv.command.searchInFiles', () => openSearchPanel()],
1386
+ ['edrv.command.toggleMarkdownPreview', () => {
1387
+ const path = activeRef.current
1388
+ if (!isMarkdownPath(path)) { setStatus('当前文件不是 Markdown'); return }
1389
+ toggleMdPreviewRef.current?.(path)
1188
1390
  }],
1189
1391
  ['edrv.command.navigateBack', () => navBackRef.current?.()],
1190
1392
  ['edrv.command.navigateForward', () => navForwardRef.current?.()],
@@ -1226,6 +1428,20 @@ export function EditorView(props) {
1226
1428
  ['edrv.command.svnAdd', () => runSvnAdd(activeRef.current)],
1227
1429
  ['edrv.command.svnRevertCli', () => runSvnRevertCli(activeRef.current)],
1228
1430
  ['edrv.command.svnLog', () => openSvnLog(activeRef.current || undefined)],
1431
+ ['edrv.command.svnCreatePatch', () => runSvnAction(SVN_ACTION_BY_ID['create-patch'], {
1432
+ sessionId,
1433
+ scope,
1434
+ path: activeRef.current ?? '',
1435
+ notify: (message) => setStatus(message),
1436
+ openPatchDialog: (payload) => setPatchDialog(payload),
1437
+ })],
1438
+ ['edrv.command.svnDiffSum', () => runSvnAction(SVN_ACTION_BY_ID['diff-summarize'], {
1439
+ sessionId,
1440
+ scope,
1441
+ path: dirOfRel(activeRef.current ?? ''),
1442
+ notify: (message) => setStatus(message),
1443
+ openSumDialog: (target) => setSumDialog({ path: target }),
1444
+ })],
1229
1445
  ['edrv.command.svnCleanup', () => runSvnAction(SVN_ACTION_BY_ID.cleanup, {
1230
1446
  sessionId,
1231
1447
  scope,
@@ -1265,6 +1481,10 @@ export function EditorView(props) {
1265
1481
  else if (right > host.scrollLeft + host.clientWidth) host.scrollLeft = right - host.clientWidth
1266
1482
  }, [active, tabs.length])
1267
1483
 
1484
+ // 页签栏滚轮横向滚动(需求 3):页签栏溢出时,鼠标滚轮(含触控板纵向手势)横滚页签栏,
1485
+ // 不溢出时不接管(保持页面垂直滚动);Shift+滚轮交由浏览器原生横滚。详见 horizontalWheel.ts。
1486
+ React.useEffect(() => attachHorizontalWheel(tabsHostRef.current), [])
1487
+
1268
1488
  // 鼠标侧键后退/前进:Logitech 官方默认「后退/前进」= XButton 鼠标事件(button 3/4),不产生键盘事件。
1269
1489
  // pointerdown 触发导航(preventDefault 后兼容 mousedown 可能不再触发,避免双触发);
1270
1490
  // mousedown 兜底仅取消浏览器历史导航默认动作(MDN:preventDefault mousedown/pointerdown 可抑制)。
@@ -1326,6 +1546,8 @@ export function EditorView(props) {
1326
1546
  setupAiInline(m)
1327
1547
  // 代码片段补全 provider 注册(幂等;条目按会话工作区懒加载)
1328
1548
  setupSnippets(m)
1549
+ // launch.json「添加配置」补全 provider 注册(幂等;仅 launch.json configurations 数组内出条目)
1550
+ setupLaunchJson(m)
1329
1551
  }).catch((e) => {
1330
1552
  if (alive) {
1331
1553
  setMonacoErr(String(e?.message ?? e))
@@ -1359,10 +1581,7 @@ export function EditorView(props) {
1359
1581
  monacoRef.current = monaco
1360
1582
 
1361
1583
  /** 从编辑器实例 model URI 解析活动文件路径(工作区相对;无模型返回 null)。 */
1362
- const modelPathOf = (edx) => {
1363
- const uriPath = edx?.getModel?.()?.uri?.path
1364
- return uriPath ? decodeURIComponent(String(uriPath).replace(/^\//, '')) : null
1365
- }
1584
+ const modelPathOf = (edx) => editorModelPathOf(edx)
1366
1585
 
1367
1586
  /**
1368
1587
  * 同步 Monaco 右键菜单 SVN 组:先注销旧组,再按最新 svn 状态注册(空状态 = 全部移除)。
@@ -1401,6 +1620,7 @@ export function EditorView(props) {
1401
1620
  path: def.id === 'update' ? (relPath ?? '') : relPath,
1402
1621
  notify: (message) => setStatus(message),
1403
1622
  openSvnDiff: (p) => runSvnDiffBase(p),
1623
+ openSvnLocalPair: runSvnDiffLocalPair,
1404
1624
  openSvnLog: (p) => openSvnLog(p ?? relPath),
1405
1625
  refreshChanges: () => refreshSvnChanges(sessionId, scope),
1406
1626
  })
@@ -1550,6 +1770,9 @@ export function EditorView(props) {
1550
1770
  const ed = editorRef.current
1551
1771
  if (!ed || !active) return
1552
1772
  setDirtyMap((d) => Object.assign({}, d, { [active]: true }))
1773
+ // 编辑即「使用」:需求 2 的淘汰口径是「最近没有改动」,故有改动时刷新 LRU 序号,
1774
+ // 使长时间只在某文件里编辑的页签不会被其它文件的打开动作挤出。
1775
+ touchTab(active)
1553
1776
  setStatus('编辑中…')
1554
1777
  // 外部改动尚未处理(冲突/文件被删):抑制自动保存,避免用陈旧缓冲反复覆盖磁盘;
1555
1778
  // 手工 Ctrl+S 不受抑制(doSave 会走版本守卫并给冲突提示)。
@@ -1564,6 +1787,9 @@ export function EditorView(props) {
1564
1787
  // ⚠️ 必须以 contentReady(contentPath === active)为门,不能用 `content !== null`:
1565
1788
  // 切页签的那一帧 content 仍是**上一个文件**的内容,据此建 model 会先塞入陈旧文本,
1566
1789
  // 待真实内容到达再由 getModel→setValue 覆盖,光标随之被重置(见下方跳转 effect 注释)。
1790
+ // ⚠️ 依赖必须含 mdPreviewing:Markdown 预览态不渲染 Monaco host(见 body 分派的 mdPreviewing 分支),
1791
+ // 切回源码时 ensureEditor 会**新建**编辑器实例,而 active/content 都没变;缺此依赖则本 effect
1792
+ // 不重跑 → 空 model、行内差异标记消失。同款原因见下方差异自绘 effect。
1567
1793
  React.useEffect(() => {
1568
1794
  if (!monaco || !editorRef.current || !active || !contentReady) return
1569
1795
  const ed = editorRef.current
@@ -1571,7 +1797,7 @@ export function EditorView(props) {
1571
1797
  if (ed.getModel() !== model) ed.setModel(model)
1572
1798
  restoreViewState(active)
1573
1799
  setLoadStage((prev) => ({ progress: Math.max(96, prev.progress), message: '创建编辑器视图…' }))
1574
- }, [monaco, active, content, contentReady])
1800
+ }, [monaco, active, content, contentReady, mdPreviewing])
1575
1801
 
1576
1802
  // PDF 面板外壳 ref 回调(div 仅在 PDF 分支渲染,refs 先于 effect 就绪)
1577
1803
  const ensurePdfHost = (node) => { pdfHostRef.current = node }
@@ -1598,11 +1824,13 @@ export function EditorView(props) {
1598
1824
  }, [isPdfActive, pdfBytes, active])
1599
1825
 
1600
1826
  // 行内差异自绘(decorations / view zones / minus overlay)→ diffRenderer
1827
+ // ⚠️ 依赖含 mdPreviewing:切回源码时编辑器实例被重建(见上方 model 同步 effect 的说明),
1828
+ // 不重跑则新实例上没有任何差异装饰,表现为「差异标记全部消失」。
1601
1829
  React.useEffect(() => {
1602
1830
  if (!monaco || !editorRef.current || !active || content === null) return
1603
1831
  diffRendererRef.current.render(monaco, editorRef.current, pendingRegions, sessionId)
1604
1832
  setLoadStage({ progress: 100, message: '编辑器已就绪' })
1605
- }, [monaco, active, content, pendingRegions])
1833
+ }, [monaco, active, content, pendingRegions, mdPreviewing])
1606
1834
 
1607
1835
  React.useEffect(() => () => {
1608
1836
  flushSave()
@@ -1777,9 +2005,105 @@ export function EditorView(props) {
1777
2005
  if (hideTimerRef.current) { clearTimeout(hideTimerRef.current); hideTimerRef.current = null }
1778
2006
  setHoverAct({ region: hit, top, right })
1779
2007
  })
2008
+ // 调试断点:行首 glyph 圆点区**左键**点击切换(右键走下方 contextmenu 菜单,
2009
+ // 必须区分按键——否则右键会既 toggle 断点又弹菜单)。
2010
+ // ⚠️ 枚举在 monaco.editor 子命名空间(monaco.editor.MouseTargetType,值为 2);
2011
+ // 取顶层 m.MouseTargetType 是 undefined,会让点击永远静默返回(实测踩坑)。
2012
+ ed.onMouseDown((e) => {
2013
+ const target = e?.target
2014
+ if (!e?.event?.leftButton) return
2015
+ const GUTTER = m.editor?.MouseTargetType?.GUTTER_GLYPH_MARGIN ?? 2
2016
+ if (!target || target.type !== GUTTER) return
2017
+ const line = target.position?.lineNumber
2018
+ const path = pathOf(ed)
2019
+ if (line && path) dapToggleRef.current?.(path, line)
2020
+ })
2021
+ // 断点 hover 预览(对齐 VS Code `ensureBreakpointHintDecoration`):
2022
+ // 鼠标移到 glyph 区(type=2)或行号区(type=3)的可下断点行 → 半透明红点 + pointer 光标;
2023
+ // 移开/离开编辑器 → 清除。已有断点的行不显示预览(避免与实心红点重叠)。
2024
+ ed.onMouseMove((e) => {
2025
+ const target = e?.target
2026
+ const path = pathOf(ed)
2027
+ const list = path ? (dapStore.bpMapOf(dapScopeRef.current)[path] ?? []) : []
2028
+ const line = hintLineFor(target?.type, target?.position?.lineNumber, list.map((it) => it.line))
2029
+ setHintLine(ed, line || null)
2030
+ })
2031
+ ed.onMouseLeave(() => setHintLine(ed, null))
2032
+ // 断点右键菜单:glyph 区右键**始终**由本插件接管(严格对齐 CodeBuddy:任意文件都出菜单,
2033
+ // 不再限定 .lua)。命中行改用 Monaco 官方 getTargetAtClientPoint——
2034
+ // 手算 floor((relY+scrollTop)/lineHeight)+1 **未扣编辑器 padding-top**(实测 6px),
2035
+ // 点在某行框下半部会被算成下一行 → 对空行显示"添加"菜单(实测踩坑)。
2036
+ const glyphHitLine = (ev) => {
2037
+ const edi = editorRef.current
2038
+ if (!edi?.getTargetAtClientPoint) return null
2039
+ const rect = edi.getDomNode?.()?.getBoundingClientRect?.()
2040
+ if (!rect) return null
2041
+ const layout = edi.getLayoutInfo?.()
2042
+ if (!layout) return null
2043
+ const relX = ev.clientX - rect.left
2044
+ if (relX < layout.glyphMarginLeft || relX > layout.glyphMarginLeft + layout.glyphMarginWidth) return null
2045
+ const GUTTER = m.editor?.MouseTargetType?.GUTTER_GLYPH_MARGIN ?? 2
2046
+ const target = edi.getTargetAtClientPoint(ev.clientX, ev.clientY)
2047
+ if (!target || target.type !== GUTTER) return null
2048
+ return target.position?.lineNumber ?? null
2049
+ }
2050
+ const onBpCtxCapture = (ev) => {
2051
+ const line = glyphHitLine(ev)
2052
+ if (!line) return
2053
+ const path = pathOf(editorRef.current)
2054
+ if (!path) return
2055
+ const entry = (dapStore.bpMapOf(dapScopeRef.current)[path] ?? []).find((it) => it.line === line)
2056
+ ev.preventDefault()
2057
+ ev.stopPropagation()
2058
+ setDapBpMenu({
2059
+ x: ev.clientX,
2060
+ y: ev.clientY,
2061
+ path,
2062
+ line,
2063
+ hasBp: !!entry,
2064
+ enabled: entry ? entry.enabled !== false : true,
2065
+ isLogpoint: !!(entry && entry.logMessage),
2066
+ })
2067
+ }
2068
+ node.addEventListener('contextmenu', onBpCtxCapture, true)
1780
2069
  editorRef.current = ed
1781
2070
  }, [])
1782
2071
 
2072
+ // 调试断点装饰:断点表/活动文件变化时全量替换行首圆点(独立 collection,与差异/下划线互不干扰);
2073
+ // 适配器真实回执 verified:false 的行渲染空心红圈(对齐 VS Code 未验证语义)
2074
+ React.useEffect(() => {
2075
+ const ed = editorRef.current
2076
+ if (!ed || !active) return
2077
+ const map = dapStore.bpMapOf(scope)
2078
+ const unverified = bpLinesOf(map, active).filter((line) => dapStore.ackOf(active, line)?.verified === false)
2079
+ applyBpDecorations(ed, bpLinesOf(map, active), bpDisabledLinesOf(map, active), unverified)
2080
+ }, [scope, active, contentPath, dapSnap.bpVersion, dapSnap.bpAckVersion, monaco])
2081
+
2082
+ // 行内断点编辑浮层:切换文件 / 编辑器重建时关闭(浮层锚定旧 model 的行号,留着会错位)
2083
+ React.useEffect(() => {
2084
+ closeBpWidget()
2085
+ }, [active, contentPath])
2086
+
2087
+ // 编辑浮层的目标断点被删除(如 agent 清理埋点/用户在面板删除)→ 浮层自动关闭
2088
+ React.useEffect(() => {
2089
+ const held = bpWidgetRef.current
2090
+ if (!held) return
2091
+ const entry = (dapStore.bpMapOf(scope)[held.path] ?? []).some((it) => it.line === held.line)
2092
+ if (!entry) closeBpWidget()
2093
+ }, [scope, dapSnap.bpVersion])
2094
+
2095
+ // 编辑器卸载时移除浮层(content widget 不随 React 树回收)
2096
+ React.useEffect(() => () => closeBpWidget(), [])
2097
+
2098
+ // 调试停帧行:paused 且首帧属于当前文件时高亮该行;继续/停止/切走自动清除
2099
+ React.useEffect(() => {
2100
+ const ed = editorRef.current
2101
+ if (!ed) return
2102
+ const top = dapSnap.topFrame
2103
+ const hit = top && top.file && sameFile(top.file, active)
2104
+ setPausedLine(ed, hit ? top.line : null)
2105
+ }, [dapSnap.phase, dapSnap.topFrame, active, contentPath, sameFile])
2106
+
1783
2107
  /**
1784
2108
  * 跳转落地后给目标区域挂临时高亮(默认 1.2s 后自动清除)。
1785
2109
  *
@@ -1811,6 +2135,14 @@ export function EditorView(props) {
1811
2135
 
1812
2136
  // 打开文件后的跳转(导航历史恢复/差异聚焦/搜索与 LSP 行列跳转,目标内容就绪后执行一次)
1813
2137
  //
2138
+ // ⚠️ 依赖必须含 monaco:contentReady 在**文本到达**时即为真,可能早于 Monaco 就绪;
2139
+ // 那一帧 editorRef.current 仍是 null → 本 effect 提前返回并保留 pendingFocus,随后
2140
+ // Monaco 载入、ensureEditor 建好编辑器,若 effect 不因 monaco 变化重跑,pendingFocus
2141
+ // 就永久滞留 —— 观感是「首次跳转只打开文件不定位,手动再触发一次才落到目标行」
2142
+ // (实测复现:Monaco 未就绪时派发 line=500,首次停在 1:1,二次触发才到 500)。
2143
+ // ⚠️ 依赖也必须含 mdPreviewing:预览态切换会替换 Monaco host 并**重建**编辑器实例,
2144
+ // 而 active/content 都没变;缺此依赖同样不重跑(同下方 model 同步 effect)。
2145
+ //
1814
2146
  // ⚠️ 门控必须用 contentReady(contentPath === active),不能用 `content === null`:
1815
2147
  // 跨文件跳转时切页签的那一帧 content 仍是**上一个文件**的内容(非 null),据此判定
1816
2148
  // 「已就绪」会提前定位;随后真实内容到达触发 model 重设/内容写入,Monaco 会重置光标,
@@ -1852,7 +2184,7 @@ export function EditorView(props) {
1852
2184
  ed.revealLineInCenter(Math.max(1, target.start ?? 1))
1853
2185
  ed.setPosition({ lineNumber: Math.max(1, target.start ?? 1), column: 1 })
1854
2186
  ed.focus()
1855
- }, [active, content, contentReady, pendingRegions, focusRequest])
2187
+ }, [active, content, contentReady, pendingRegions, focusRequest, monaco, mdPreviewing])
1856
2188
 
1857
2189
  const jumpTo = (region) => {
1858
2190
  if (editorRef.current) {
@@ -1982,6 +2314,32 @@ export function EditorView(props) {
1982
2314
  loadContent(path, sessionId, true)
1983
2315
  }
1984
2316
 
2317
+ /**
2318
+ * Markdown 在源码编辑与预览之间切换(按路径记忆,不持久化)。
2319
+ *
2320
+ * ⚠️ 进入预览前必须 flushSave():预览态会替换 Monaco host,切回时编辑器实例是新建的;
2321
+ * 若此刻还有未到期的 700ms 防抖保存,定时器到点时 editorRef.current 已被清空
2322
+ * (见 EditorView 卸载清理),doSave 会静默 return —— 用户改动永不落盘。
2323
+ * 同时保存视图状态,保证切回源码后光标/滚动位置不丢。
2324
+ * @author ddj 2026年09月18号
2325
+ * @param path Markdown 文件路径
2326
+ */
2327
+ const toggleMdPreview = (path) => {
2328
+ if (!path) return
2329
+ const next = new Set(mdPreviewRef.current)
2330
+ const entering = !next.has(path)
2331
+ if (entering) {
2332
+ flushSave()
2333
+ saveViewState(path)
2334
+ next.add(path)
2335
+ } else {
2336
+ next.delete(path)
2337
+ }
2338
+ mdPreviewRef.current = next
2339
+ setStatus(entering ? '已切换为 Markdown 预览' : '已切换为源码编辑')
2340
+ }
2341
+ toggleMdPreviewRef.current = toggleMdPreview
2342
+
1985
2343
  /**
1986
2344
  * 图片预览面板:工具条(尺寸 meta / SVG 文本切换 / 刷新)+ 棋盘底自适应图片。
1987
2345
  * @author ddj 2026年09月08号
@@ -2243,6 +2601,8 @@ export function EditorView(props) {
2243
2601
  leftLabel: 'BASE',
2244
2602
  rightLabel: '工作区',
2245
2603
  scheme: 'edrv-svn-base',
2604
+ binary: outcome.binary === true,
2605
+ encodingHint: outcome.encodingHint === true,
2246
2606
  })
2247
2607
  setStatus(outcome.message)
2248
2608
  })
@@ -2254,8 +2614,9 @@ export function EditorView(props) {
2254
2614
  * @author ddj 2026年09月16号
2255
2615
  * @param relPath 目标工作区相对路径
2256
2616
  * @param revision 目标版本
2617
+ * @param logTarget 来自日志弹窗时的日志目标(不传 = 非日志入口)
2257
2618
  */
2258
- const runSvnDiffRev = (relPath, revision) => {
2619
+ const runSvnDiffRev = (relPath, revision, logTarget) => {
2259
2620
  if (!relPath) { setStatus('无目标文件'); return }
2260
2621
  setStatus('读取 r' + revision + ' 差异…')
2261
2622
  void svnDiffRev(sessionId, relPath, revision).then((outcome) => {
@@ -2270,6 +2631,9 @@ export function EditorView(props) {
2270
2631
  leftLabel: 'r' + Math.max(1, revision - 1),
2271
2632
  rightLabel: 'r' + revision,
2272
2633
  scheme: 'edrv-svn-rev' + revision,
2634
+ binary: outcome.binary === true,
2635
+ encodingHint: outcome.encodingHint === true,
2636
+ logTarget,
2273
2637
  })
2274
2638
  setStatus('已打开 r' + revision + ' 差异')
2275
2639
  })
@@ -2282,8 +2646,9 @@ export function EditorView(props) {
2282
2646
  * @param relPath 目标工作区相对路径
2283
2647
  * @param revA 左侧版本(选中顺序在前)
2284
2648
  * @param revB 右侧版本(选中顺序在后)
2649
+ * @param logTarget 来自日志弹窗时的日志目标(不传 = 非日志入口)
2285
2650
  */
2286
- const runSvnDiffPair = (relPath, revA, revB) => {
2651
+ const runSvnDiffPair = (relPath, revA, revB, logTarget) => {
2287
2652
  if (!relPath) { setStatus('仅文件目标支持比较两个修订'); return }
2288
2653
  setStatus('读取 r' + revA + ' ↔ r' + revB + ' 差异…')
2289
2654
  void svnDiffPair(sessionId, relPath, revA, revB).then((outcome) => {
@@ -2300,6 +2665,9 @@ export function EditorView(props) {
2300
2665
  leftLabel: 'r' + revA,
2301
2666
  rightLabel: 'r' + revB,
2302
2667
  scheme: 'edrv-svn-pair-' + revA + '-' + revB,
2668
+ binary: outcome.binary === true,
2669
+ encodingHint: outcome.encodingHint === true,
2670
+ logTarget,
2303
2671
  })
2304
2672
  setStatus('已打开 r' + revA + ' ↔ r' + revB + ' 差异')
2305
2673
  })
@@ -2311,8 +2679,9 @@ export function EditorView(props) {
2311
2679
  * @author ddj 2026年09月17号
2312
2680
  * @param relPath 目标工作区相对路径
2313
2681
  * @param revision 左侧版本
2682
+ * @param logTarget 来自日志弹窗时的日志目标(不传 = 非日志入口)
2314
2683
  */
2315
- const runSvnDiffWorking = (relPath, revision) => {
2684
+ const runSvnDiffWorking = (relPath, revision, logTarget) => {
2316
2685
  if (!relPath) { setStatus('仅文件目标支持与工作副本比较'); return }
2317
2686
  setStatus('读取 r' + revision + ' ↔ 工作副本 差异…')
2318
2687
  void svnDiffWorking(sessionId, relPath, revision).then((outcome) => {
@@ -2326,11 +2695,42 @@ export function EditorView(props) {
2326
2695
  leftLabel: 'r' + revision,
2327
2696
  rightLabel: '工作区',
2328
2697
  scheme: 'edrv-svn-work-' + revision,
2698
+ binary: outcome.binary === true,
2699
+ encodingHint: outcome.encodingHint === true,
2700
+ logTarget,
2329
2701
  })
2330
2702
  setStatus('已打开 r' + revision + ' ↔ 工作副本 差异')
2331
2703
  })
2332
2704
  }
2333
2705
 
2706
+ /**
2707
+ * 打开双侧本地文件差异(W2-4 冲突副本 `.rN` ↔ `.mine`,只读并排;resolve 由用户自行执行)。
2708
+ * @author ddj 2026年09月20号
2709
+ * @param left 左侧工作区相对路径
2710
+ * @param right 右侧工作区相对路径
2711
+ * @param leftLabel 左侧标签(如 `.r293524`)
2712
+ * @param rightLabel 右侧标签(如 `.mine`)
2713
+ * @param title 标题(冲突文件路径)
2714
+ */
2715
+ const runSvnDiffLocalPair = (left, right, leftLabel, rightLabel, title) => {
2716
+ setStatus('读取冲突副本差异…')
2717
+ void svnDiffLocalPair(sessionId, left, right).then((outcome) => {
2718
+ if (!outcome.ok) { setStatus(outcome.message); return }
2719
+ setSvnDiff({
2720
+ path: title,
2721
+ base: outcome.leftText ?? null,
2722
+ working: outcome.rightText ?? '',
2723
+ message: '解决后执行 svn resolve --accept working(本插件不自动执行)',
2724
+ leftLabel,
2725
+ rightLabel,
2726
+ scheme: 'edrv-svn-local',
2727
+ binary: outcome.binary === true,
2728
+ encodingHint: outcome.encodingHint === true,
2729
+ })
2730
+ setStatus('已打开冲突副本对比')
2731
+ })
2732
+ }
2733
+
2334
2734
  /**
2335
2735
  * 打开日志弹窗(P3):加载该目标的提交历史。
2336
2736
  * @author ddj 2026年09月16号
@@ -2346,6 +2746,17 @@ export function EditorView(props) {
2346
2746
  ensureSvnLog(sessionId, scope, target, SVN_LOG_PAGE)
2347
2747
  }
2348
2748
 
2749
+ /**
2750
+ * 从日志弹窗打开的差异视图返回日志:按打开时记录的日志目标重开弹窗。
2751
+ * 日志数据走 scope 缓存(openSvnLog → ensureSvnLog 命中即不重查);选中项不复原。
2752
+ * @author ddj 2026年09月18号
2753
+ */
2754
+ const backToSvnLog = () => {
2755
+ const target = svnDiff?.logTarget ?? ''
2756
+ setSvnDiff(null)
2757
+ openSvnLog(target)
2758
+ }
2759
+
2349
2760
  /** 加入版本控制(活动文件;未纳入版本控制的文件才有意义,host 侧会再校验)。 */
2350
2761
  const runSvnAdd = (relPath) => {
2351
2762
  if (!relPath) { setStatus('无活动文件'); return }
@@ -2448,6 +2859,7 @@ export function EditorView(props) {
2448
2859
  path,
2449
2860
  notify: (message) => setStatus(message),
2450
2861
  openSvnDiff: (p) => runSvnDiffBase(p),
2862
+ openSvnLocalPair: runSvnDiffLocalPair,
2451
2863
  openSvnLog: (p) => openSvnLog(p ?? path),
2452
2864
  refreshChanges: () => refreshSvnChanges(sessionId, scope),
2453
2865
  })
@@ -2545,6 +2957,8 @@ export function EditorView(props) {
2545
2957
  */
2546
2958
  const openFileAt = (path, line, column, endLine, endColumn) => {
2547
2959
  if (!path) return
2960
+ const tracePath = tabPathOf(path, cwd)
2961
+ dapTrace('open-file-at', { input: path, normalized: tracePath, line, cwd })
2548
2962
  recordNav()
2549
2963
  // G9:同 openFile,先归一再进页签,保证待跳转路径与 active 同形态
2550
2964
  const normalized = addTabNorm(path, true)
@@ -2553,6 +2967,51 @@ export function EditorView(props) {
2553
2967
  setFocusRequest((value) => value + 1)
2554
2968
  }
2555
2969
 
2970
+ // 停帧跳转/调试命令监听(openFileAt 每渲染重建,经 ref 让空依赖监听读最新闭包)
2971
+ const dapOpenRef = React.useRef(null)
2972
+ dapOpenRef.current = openFileAt
2973
+ React.useEffect(() => {
2974
+ const onStopped = (e) => {
2975
+ const detail = e?.detail
2976
+ dapTrace('debug-stopped', detail)
2977
+ if (!detail?.file) {
2978
+ // 适配器只给了 chunkname 且工作区未定位到源文件:明确告知,不留静默无反应
2979
+ setStatus('已暂停:未定位到源文件')
2980
+ return
2981
+ }
2982
+ dapOpenRef.current?.(detail.file, detail.line, 1)
2983
+ }
2984
+ const onToggle = () => {
2985
+ const ed = editorRef.current
2986
+ const path = modelPathOf(ed)
2987
+ const line = ed?.getPosition?.()?.lineNumber
2988
+ if (path && line) dapToggleRef.current?.(path, line)
2989
+ else setStatus('无活动文件,无法切换断点')
2990
+ }
2991
+ const onToggleEvt = () => onToggle()
2992
+ const onStart = () => dapStore.startOrContinue()
2993
+ const onStop = () => { void dapStore.stop() }
2994
+ const onStepOver = () => { void dapStore.action('next') }
2995
+ const onStepIn = () => { void dapStore.action('stepIn') }
2996
+ const onStepOut = () => { void dapStore.action('stepOut') }
2997
+ window.addEventListener('edrv:debug-stopped', onStopped)
2998
+ window.addEventListener('edrv.command.debugToggleBreakpoint', onToggleEvt)
2999
+ window.addEventListener('edrv.command.debugStartContinue', onStart)
3000
+ window.addEventListener('edrv.command.debugStepOver', onStepOver)
3001
+ window.addEventListener('edrv.command.debugStepInto', onStepIn)
3002
+ window.addEventListener('edrv.command.debugStepOut', onStepOut)
3003
+ window.addEventListener('edrv.command.debugStop', onStop)
3004
+ return () => {
3005
+ window.removeEventListener('edrv:debug-stopped', onStopped)
3006
+ window.removeEventListener('edrv.command.debugToggleBreakpoint', onToggleEvt)
3007
+ window.removeEventListener('edrv.command.debugStartContinue', onStart)
3008
+ window.removeEventListener('edrv.command.debugStepOver', onStepOver)
3009
+ window.removeEventListener('edrv.command.debugStepInto', onStepIn)
3010
+ window.removeEventListener('edrv.command.debugStepOut', onStepOut)
3011
+ window.removeEventListener('edrv.command.debugStop', onStop)
3012
+ }
3013
+ }, [])
3014
+
2556
3015
  const openPath = () => {
2557
3016
  const p = (pathDraft || '').trim()
2558
3017
  if (!p) return
@@ -2598,6 +3057,13 @@ export function EditorView(props) {
2598
3057
  const pathBar = React.createElement('div', { className: 'edrv-pathbar', title: active || '' },
2599
3058
  React.createElement('span', { className: 'edrv-pb-name' }, active ? String(active).split(/[\\/]/).pop() : '未打开文件'),
2600
3059
  React.createElement('span', { className: 'edrv-pb-full' }, active || '使用右上搜索框 (' + (chordOf('edrv.quickOpen') ?? 'Ctrl+P') + ') 打开文件'),
3060
+ // Markdown 预览/源码切换(需求 4):仅 .md 文件出现;命令栏与 Ctrl+Shift+V 为同一动作
3061
+ (isMdActive ? React.createElement('button', {
3062
+ className: 'edrv-pill edrv-pill-ghost edrv-pb-mdbtn',
3063
+ title: (mdPreviewing ? '切回源码编辑' : '预览 Markdown') + '(' + (chordOf('edrv.toggleMarkdownPreview') ?? '未绑定') + ')',
3064
+ onClick: () => toggleMdPreview(active),
3065
+ }, mdPreviewing ? '源码' : '预览') : null),
3066
+ (mdPreviewing ? React.createElement('span', { className: 'edrv-pb-meta' }, '预览') : null),
2601
3067
  (active && !isImageActive && langOf(active) ? React.createElement('span', { className: 'edrv-pb-meta' }, langOf(active)) : null),
2602
3068
  (isImageActive && imgSize ? React.createElement('span', { className: 'edrv-pb-meta' }, imgSize.w + '×' + imgSize.h) : null),
2603
3069
  (cursor ? React.createElement('span', { className: 'edrv-pb-meta' }, cursor) : null),
@@ -2757,6 +3223,7 @@ export function EditorView(props) {
2757
3223
  message: svnDiff.message,
2758
3224
  leftLabel: svnDiff.leftLabel,
2759
3225
  rightLabel: svnDiff.rightLabel,
3226
+ onBack: svnDiff.logTarget !== undefined ? backToSvnLog : undefined,
2760
3227
  onClose: () => setSvnDiff(null),
2761
3228
  })
2762
3229
  } else if (!monaco && !monacoErr) {
@@ -2786,6 +3253,17 @@ export function EditorView(props) {
2786
3253
  body = React.createElement('div', { className: 'edrv-pdf-host', ref: ensurePdfHost, key: active })
2787
3254
  } else if (isPdfActive) {
2788
3255
  body = loadingBody(loadStage.message || '读取 PDF…', loadStage.progress)
3256
+ } else if (mdPreviewing && contentReady) {
3257
+ // Markdown 预览(需求 4):复用已加载的文本内容渲染,不额外请求;编辑仍在源码态完成
3258
+ body = React.createElement(MarkdownPanel, {
3259
+ key: 'edrv-md-' + active,
3260
+ text: content,
3261
+ onToggleSource: () => toggleMdPreview(active),
3262
+ onReload: () => reloadFile(),
3263
+ onOpenFile: (path, line) => openFileAt(path, line ?? 1),
3264
+ })
3265
+ } else if (mdPreviewing) {
3266
+ body = loadingBody(loadStage.message || '读取 Markdown…', loadStage.progress)
2789
3267
  } else if (content === null) {
2790
3268
  body = loadingBody(loadStage.message || '读取文件内容…', loadStage.progress)
2791
3269
  } else {
@@ -2910,14 +3388,125 @@ export function EditorView(props) {
2910
3388
  svnChanges,
2911
3389
  svnChangeMap: svnChangeMapOf(scope),
2912
3390
  openSvnDiff: (p) => runSvnDiffBase(p),
3391
+ openSvnLocalPair: runSvnDiffLocalPair,
2913
3392
  refreshSvnChanges: () => refreshSvnChanges(sessionId, scope),
2914
3393
  openSvnLog: (p) => openSvnLog(p),
2915
3394
  confirm: (message) => (typeof window === 'undefined' ? false : window.confirm(message)),
2916
3395
  notify: (message) => setStatus(message),
2917
3396
  }
2918
3397
 
3398
+ // 调试工具条(VS Code 浮动条紧凑版,图2 形态):配置下拉 + 启动/继续 + 单步组 + 停止 + 相位。
3399
+ // 配置来自工作区 .dsh/launch.json(插件专属,与 VS Code 互不干扰);无配置时下拉提示且按钮禁用。
3400
+ const dapPhaseTextOf = () => ({
3401
+ idle: '', starting: '启动中…', waiting: '等待目标…', running: '运行中', paused: '已暂停', terminated: '已结束',
3402
+ }[dapSnap.phase] || '')
3403
+ const dapActive = dapSnap.phase !== 'idle' && dapSnap.phase !== 'terminated'
3404
+ const dapPaused = dapSnap.phase === 'paused'
3405
+ const dapBtn = (label, title, disabled, onClick) => React.createElement('button', {
3406
+ key: title, className: 'edrv-dapbar-btn', title, disabled: disabled === true, onClick,
3407
+ }, label)
3408
+ const dapBarVisible = dapActive || Boolean(dapSnap.candidates)
3409
+ const dapBarStyle = dapBarPos
3410
+ ? { left: dapBarPos.left, top: dapBarPos.top, right: 'auto' }
3411
+ : undefined
3412
+ const dapBar = dapBarVisible ? React.createElement('div', {
3413
+ ref: dapBarRef,
3414
+ className: 'edrv-dapbar' + (dapBarVisible ? ' on' : '') + (dapBarDragging ? ' dragging' : ''),
3415
+ style: dapBarStyle,
3416
+ },
3417
+ React.createElement('span', {
3418
+ className: 'edrv-dapbar-grip',
3419
+ role: 'button',
3420
+ tabIndex: 0,
3421
+ title: '拖动调试工具条',
3422
+ 'aria-label': '拖动调试工具条',
3423
+ onPointerDown: startDapBarDrag,
3424
+ }, '⠿'),
3425
+ (dapSnap.candidates
3426
+ ? [
3427
+ React.createElement('select', {
3428
+ key: 'pick', className: 'edrv-dapbar-cfg', title: '选择附加目标进程(processName 命中多个候选)',
3429
+ value: String(dapPickPid || dapSnap.candidates[0]?.pid || ''),
3430
+ onChange: (e) => setDapPickPid(Number(e.target.value)),
3431
+ },
3432
+ dapSnap.candidates.map((it) => React.createElement('option', { key: it.pid, value: String(it.pid) },
3433
+ it.pid + ' : ' + it.name + (it.title ? ' — ' + it.title.slice(0, 44) : '')))),
3434
+ dapBtn('✓', '附加到选中进程', false, () => dapStore.confirmPick(dapPickPid || dapSnap.candidates![0]?.pid || 0)),
3435
+ dapBtn('✕', '取消', false, () => dapStore.cancelPick()),
3436
+ ]
3437
+ : React.createElement('select', {
3438
+ className: 'edrv-dapbar-cfg',
3439
+ title: '调试配置(读取工作区 .dsh/launch.json;适配器由扩展清单 contributes.debuggers 提供)',
3440
+ value: dapSnap.selectedConfig ?? '',
3441
+ onChange: (e) => dapStore.selectConfig(e.target.value),
3442
+ },
3443
+ dapSnap.configs.length
3444
+ ? dapSnap.configs.map((c) => {
3445
+ // 旧 host 无 adapters 数据时视为全部可用(保持兼容);有数据则按可用性置灰并给原因
3446
+ const adapter = dapSnap.adapters.find((a) => a.type === c.type)
3447
+ const ready = dapSnap.adapters.length === 0 || adapter?.available === true
3448
+ const suffix = ready ? '' : '(' + (adapter?.reason || '未发现该类型适配器') + ')'
3449
+ return React.createElement('option', { key: c.name, value: c.name, disabled: !ready }, c.name + suffix)
3450
+ })
3451
+ : React.createElement('option', { value: '' }, '无可用调试配置'))),
3452
+ dapBtn(dapPaused ? '▶' : '▶', '启动 / 继续(F5)', !dapPaused && (!dapSnap.configs.some((c) => dapSnap.adapters.length === 0 || dapSnap.adapters.find((a) => a.type === c.type)?.available === true) || dapActive), () => dapStore.startOrContinue()),
3453
+ dapBtn('⏸', '暂停', !dapActive, () => { void dapStore.action('pause') }),
3454
+ dapBtn('⤵', '单步跳过(F10)', !dapPaused, () => { void dapStore.action('next') }),
3455
+ dapBtn('↓', '单步步入(F11)', !dapPaused, () => { void dapStore.action('stepIn') }),
3456
+ dapBtn('↑', '单步步出(Shift+F11)', !dapPaused, () => { void dapStore.action('stepOut') }),
3457
+ dapBtn('⏹', '停止(Shift+F5)', !dapActive, () => { void dapStore.stop() }),
3458
+ (dapPhaseTextOf()
3459
+ ? React.createElement('span', { className: 'edrv-dapbar-phase' + (dapPaused ? ' paused' : '') },
3460
+ dapPhaseTextOf() + (dapSnap.pid ? ' · pid ' + dapSnap.pid : ''))
3461
+ : null),
3462
+ (dapSnap.error
3463
+ ? React.createElement('span', { className: 'edrv-dapbar-err', title: dapSnap.error }, '⚠ ' + dapSnap.error.slice(0, 40))
3464
+ : null)) : null
3465
+
3466
+ /**
3467
+ * 「添加配置」按钮点击:定位 configurations 数组插入点(数组开头行首),
3468
+ * 显式拉起 Monaco 补全下拉选择调试配置片段插入。
3469
+ * @author ddj 2026年09月22号
3470
+ */
3471
+ const addLaunchConfig = async () => {
3472
+ const ed = editorRef.current
3473
+ if (!ed) { setStatus('无活动编辑器'); return }
3474
+ const path = modelPathOf(ed)
3475
+ if (!path || !LAUNCH_JSON_RE.test(String(path).replace(/\\/g, '/'))) {
3476
+ setStatus('请先打开 .dsh/launch.json')
3477
+ return
3478
+ }
3479
+ const model = ed.getModel && ed.getModel()
3480
+ const pos = model ? findArrayPos(model.getValue()) : null
3481
+ if (!pos) { setStatus('未找到 configurations 数组'); return }
3482
+ // 预拉片段并预检可用条数:全不可用时不弹空 suggest(否则按钮点了无反馈)
3483
+ const snippets = await prefetchSnippets().catch(() => [])
3484
+ if (!snippets.some((snip) => snip.available !== false)) {
3485
+ setStatus('无可用适配器的配置模板(适配器入口缺失或未发现该类型适配器)')
3486
+ return
3487
+ }
3488
+ ed.focus()
3489
+ ed.setPosition({ lineNumber: pos.line, column: pos.column })
3490
+ if (typeof ed.revealPositionInCenterIfOutsideViewport === 'function') {
3491
+ ed.revealPositionInCenterIfOutsideViewport({ lineNumber: pos.line, column: pos.column })
3492
+ }
3493
+ ed.trigger('edrv-addcfg', 'editor.action.triggerSuggest', null)
3494
+ setStatus('选择要插入的调试配置')
3495
+ }
3496
+
3497
+ // 「添加配置」按钮(VSCode 同款形态,编辑区右下角):仅插件专属 launch.json 打开且非差异审阅态显示
3498
+ const addCfgBtn = (active && !svnDiff && LAUNCH_JSON_RE.test(String(active).replace(/\\/g, '/')))
3499
+ ? React.createElement('button', {
3500
+ className: 'edrv-addcfg',
3501
+ title: '添加调试配置(插入到 configurations 数组开头)',
3502
+ onClick: addLaunchConfig,
3503
+ }, '添加配置…')
3504
+ : null
3505
+
2919
3506
  // 主编辑列(侧边栏右侧):pathBar + tabRow + 编辑/差异区(底部整条留给 DSH 对话输入栏)
2920
3507
  const editorArea = React.createElement('div', { className: 'edrv-editor-area' },
3508
+ dapBar,
3509
+ addCfgBtn,
2921
3510
  body,
2922
3511
  hoverEl,
2923
3512
  overlay)
@@ -3062,31 +3651,124 @@ export function EditorView(props) {
3062
3651
  setSvnLog({ ...svnLog, limit: SVN_LOG_SHOW_ALL_LIMIT })
3063
3652
  ensureSvnLog(sessionId, scope, svnLog.target || '', SVN_LOG_SHOW_ALL_LIMIT, true, svnLogOptsOf(svnLog))
3064
3653
  },
3065
- onOpenDiff: (relPath, revision) => { setSvnLog(null); runSvnDiffRev(relPath, revision) },
3066
- onComparePair: (revA, revB) => { setSvnLog(null); runSvnDiffPair(svnLog.target || '', revA, revB) },
3067
- onCompareWorking: (revision) => { setSvnLog(null); runSvnDiffWorking(svnLog.target || '', revision) },
3654
+ onOpenDiff: (relPath, revision) => { setSvnLog(null); runSvnDiffRev(relPath, revision, svnLog.target || '') },
3655
+ onComparePair: (revA, revB) => { setSvnLog(null); runSvnDiffPair(svnLog.target || '', revA, revB, svnLog.target || '') },
3656
+ onCompareWorking: (revision) => { setSvnLog(null); runSvnDiffWorking(svnLog.target || '', revision, svnLog.target || '') },
3068
3657
  onOpenFile: (relPath) => { setSvnLog(null); openFile(relPath, false) },
3069
3658
  onClose: () => { setSvnLog(null); setSvnLogError('') },
3070
3659
  })
3071
3660
  : null
3661
+ // W2-1 补丁对话框 / W2-2 目录对比对话框:portal 到 body,与日志弹窗同一挂载方式
3662
+ const patchDialogEl = patchDialog
3663
+ ? React.createElement(SvnPatchDialog, {
3664
+ key: 'edrv-svn-patch',
3665
+ path: patchDialog.path,
3666
+ text: patchDialog.text,
3667
+ truncated: patchDialog.truncated === true,
3668
+ binary: patchDialog.binary === true,
3669
+ onNote: (message) => setStatus(message),
3670
+ onClose: () => setPatchDialog(null),
3671
+ })
3672
+ : null
3673
+ const sumDialogEl = sumDialog
3674
+ ? React.createElement(SvnSumDialog, {
3675
+ key: 'edrv-svn-sum',
3676
+ sessionId,
3677
+ path: sumDialog.path,
3678
+ onNote: (message) => setStatus(message),
3679
+ onClose: () => setSumDialog(null),
3680
+ })
3681
+ : null
3072
3682
  // 诊断日志弹窗:portal 到 body,与命令栏/SVN 日志弹窗同一挂载方式
3073
3683
  const dlogEl = dlogOpen
3074
3684
  ? React.createElement(LogDialog, { key: 'edrv-dlog', sessionId, onClose: () => setDlogOpen(false) })
3075
3685
  : null
3686
+ // 断点右键菜单(逐条对齐 CodeBuddy:4 项添加类 / 删除·编辑·启停 / 记录点名词 / 暂停追加运行到行)
3687
+ const runBpMenuAction = (action) => {
3688
+ const scope = dapScopeRef.current
3689
+ const cwd = dapCwdRef.current
3690
+ const menu = dapBpMenu
3691
+ if (!menu) return
3692
+ const { path, line } = menu
3693
+ if (action === 'add') { dapStore.toggleAt(scope, cwd, path, line); return }
3694
+ if (action === 'delete') { if (menu.hasBp) dapStore.toggleAt(scope, cwd, path, line); return }
3695
+ if (action === 'disable' || action === 'enable') { dapStore.setBpEnabled(scope, cwd, path, line, action === 'enable'); return }
3696
+ if (action === 'edit') { openBpEditor(path, line); return }
3697
+ if (action === 'run-to-line') { dapStore.runTo(cwd, path, line); return }
3698
+ if (action === 'add-triggered') { setStatus('当前 Lua 调试器不支持触发的断点'); return }
3699
+ // 添加条件断点 / 添加记录点:先建点,再开浮层并选中对应模式(0 表达式 / 2 日志消息)
3700
+ dapStore.toggleAt(scope, cwd, path, line)
3701
+ openBpEditor(path, line, action === 'add-logpoint' ? 2 : 0)
3702
+ }
3703
+ const openBpEditor = (path, line, mode) => {
3704
+ const entry = (dapStore.bpMapOf(dapScopeRef.current)[path] ?? []).find((it) => it.line === line)
3705
+ if (!entry) return
3706
+ const ed = editorRef.current
3707
+ if (!ed?.changeViewZones) return
3708
+ // 行内浮层(View Zone,整编辑区宽 + 推开内容,对齐 VS Code breakpointWidget):同一时刻只挂一个
3709
+ closeBpWidget()
3710
+ bpWidgetRef.current = { ...createBpWidget(ed, {
3711
+ line,
3712
+ entry,
3713
+ mode,
3714
+ onSave: (fields) => {
3715
+ dapStore.applyBpFields(dapScopeRef.current, dapCwdRef.current, path, line, fields)
3716
+ setStatus('断点已更新')
3717
+ closeBpWidget()
3718
+ },
3719
+ onClose: () => closeBpWidget(),
3720
+ }), path, line }
3721
+ }
3722
+ const closeBpWidget = () => {
3723
+ const held = bpWidgetRef.current
3724
+ if (!held) return
3725
+ bpWidgetRef.current = null
3726
+ try { held.close() } catch { /* 编辑器可能已销毁 */ }
3727
+ }
3728
+ const dapBpMenuEl = dapBpMenu
3729
+ ? React.createElement(ContextMenu, {
3730
+ x: dapBpMenu.x,
3731
+ y: dapBpMenu.y,
3732
+ onClose: () => setDapBpMenu(null),
3733
+ entries: buildBpMenu({
3734
+ hasBreakpoint: dapBpMenu.hasBp,
3735
+ enabled: dapBpMenu.enabled,
3736
+ isLogpoint: dapBpMenu.isLogpoint,
3737
+ paused: dapSnap.phase === 'paused',
3738
+ canRunTo: true,
3739
+ canTriggered: false,
3740
+ }).map((item) => ({
3741
+ id: 'dap-bp-' + item.id,
3742
+ label: item.label,
3743
+ hint: item.hint,
3744
+ separator: item.separator,
3745
+ onClick: () => runBpMenuAction(item.id),
3746
+ })),
3747
+ })
3748
+ : null
3749
+ const dapBpEditEl = null
3076
3750
  const rootEl = layout === 'side'
3077
3751
  ? React.createElement('div', { ref: viewRootRef, 'data-edrv-view': '1', 'data-edrv-layout': 'side', className: 'edrv-view-side', style: Object.assign({}, baseStyle, { height: '100%' }) },
3078
3752
  editorRow,
3079
3753
  tabMenuEl,
3080
3754
  paletteEl,
3081
3755
  svnLogEl,
3756
+ patchDialogEl,
3757
+ sumDialogEl,
3082
3758
  dlogEl,
3083
- snippetPickerEl)
3759
+ snippetPickerEl,
3760
+ dapBpMenuEl,
3761
+ dapBpEditEl)
3084
3762
  : React.createElement('div', { ref: viewRootRef, 'data-edrv-view': '1', style: Object.assign({}, baseStyle, { height: 'var(--edrv-editor-height, 100%)', maxHeight: 'var(--edrv-editor-height, 100%)' }) },
3085
3763
  editorRow,
3086
3764
  tabMenuEl,
3087
3765
  paletteEl,
3088
3766
  svnLogEl,
3767
+ patchDialogEl,
3768
+ sumDialogEl,
3089
3769
  dlogEl,
3090
- snippetPickerEl)
3770
+ snippetPickerEl,
3771
+ dapBpMenuEl,
3772
+ dapBpEditEl)
3091
3773
  return rootEl
3092
3774
  }