dsh-vscode-mode 0.1.17 → 0.1.20

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.
@@ -13,9 +13,11 @@ import { createDiffRenderer } from '../monaco/diffRender.js'
13
13
  import { ST, callIdAttr, noopHunk, summarize } from '../state/records.js'
14
14
  import { diffRegions } from '../state/regions.js'
15
15
  import { QuickOpen } from './QuickOpen.js'
16
- import { DiffBox } from './DiffBox.js'
17
- import { DiffBarEmpty } from './DiffBarEmpty.js'
18
16
  import { DiffLauncher } from './DiffLauncher.js'
17
+ import { SidebarView } from '../sidebar/SidebarView.js'
18
+ import { clearDiffDock, publishDiffDock } from '../diffDockStore.js'
19
+ import { displayDiffTotal, editorDockMode } from '../diffDock.js'
20
+ import { editorHeight } from '../editorLayout.js'
19
21
 
20
22
  /**
21
23
  * 中央编辑区:文件页签(脏点/关闭/打开路径)+ Ctrl+P 搜索 + Monaco 编辑器 +
@@ -26,6 +28,7 @@ import { DiffLauncher } from './DiffLauncher.js'
26
28
  export function EditorView(props) {
27
29
  const sessionId = props?.sessionId
28
30
  const schedule = props.schedule
31
+ const addToConversation = props.addToConversation
29
32
  const [monaco, setMonaco] = React.useState(null)
30
33
  const [monacoErr, setMonacoErr] = React.useState(null)
31
34
  const [records, setRecords] = React.useState({})
@@ -33,6 +36,7 @@ export function EditorView(props) {
33
36
  const [active, setActive] = React.useState(null)
34
37
  const [dirtyMap, setDirtyMap] = React.useState({})
35
38
  const [content, setContent] = React.useState(null)
39
+ const [contentPath, setContentPath] = React.useState(null)
36
40
  const [status, setStatus] = React.useState('')
37
41
  const [error, setError] = React.useState(null)
38
42
  const [loadError, setLoadError] = React.useState(null)
@@ -45,7 +49,13 @@ export function EditorView(props) {
45
49
  const [hoverAct, setHoverAct] = React.useState(null) // { region, top } 编辑区 hover 差异块的 Keep/Undo 浮层
46
50
  const [diffIdx, setDiffIdx] = React.useState(0) // 当前文件内差异位置(x/x 显示)
47
51
  const [fileIdx, setFileIdx] = React.useState(0) // 全局差异文件位置(x/x 文件 显示)
52
+ const [tabMenu, setTabMenu] = React.useState(null) // Tab 右键菜单 { x,y,path }(编辑区右键走 Monaco 原生菜单,无此浮层)
53
+ const [sidebarOn, setSidebarOn] = React.useState(true) // 侧边栏显隐
54
+ const [sidebarW, setSidebarW] = React.useState(240) // 侧边栏宽度
55
+ const [activePanel, setActivePanel] = React.useState('explorer') // 激活面板 id
56
+ const [focusRequest, setFocusRequest] = React.useState(0) // 外部差异聚焦请求版本
48
57
  const editorRef = React.useRef(null)
58
+ const viewRootRef = React.useRef(null)
49
59
  const monacoRef = React.useRef(null)
50
60
  const modelsRef = React.useRef(new Map())
51
61
  const saveTimerRef = React.useRef(null)
@@ -62,6 +72,7 @@ export function EditorView(props) {
62
72
  const hoverEditorRef = React.useRef(false) // 鼠标是否仍在编辑器命中区
63
73
  const hoverPanelRef = React.useRef(false) // 鼠标是否已进入 Keep/Undo 浮层
64
74
  const batchBusyRef = React.useRef(false) // 批量 Keep All/Undo All 防重入
75
+ const menuHandlersRef = React.useRef(null) // 右键菜单动作的最新闭包(Monaco addAction 空依赖回调读取)
65
76
  const diffRendererRef = React.useRef(null)
66
77
  if (!diffRendererRef.current) diffRendererRef.current = createDiffRenderer((sid, t) => dbg(sid, t))
67
78
 
@@ -71,7 +82,8 @@ export function EditorView(props) {
71
82
  return list
72
83
  }, [records, active])
73
84
 
74
- const regions = React.useMemo(() => diffRegions(currentRecords, content).filter((r) => !r.superseded), [currentRecords, content])
85
+ const contentReady = content !== null && contentPath === active
86
+ const regions = React.useMemo(() => diffRegions(currentRecords, contentReady ? content : null).filter((r) => !r.superseded), [currentRecords, content, contentPath, active])
75
87
  // useMemo 稳定引用:否则 hover 等重渲染会让 view zone effect 反复重建(- 号闪烁)
76
88
  const pendingRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && !r.stale), [regions])
77
89
  const staleRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && r.stale), [regions])
@@ -108,9 +120,9 @@ export function EditorView(props) {
108
120
  })
109
121
  }
110
122
 
111
- const refreshRecords = () => {
123
+ const refreshRecords = (skipStale) => {
112
124
  if (!sessionId) return
113
- rpc('edrv.list', { sessionId }).then((res) => {
125
+ rpc('edrv.list', { sessionId, ...(skipStale ? { skipStale: true } : {}) }).then((res) => {
114
126
  if (!res || !res.ok || !Array.isArray(res.records)) return
115
127
  const map = {}
116
128
  for (const r of res.records) {
@@ -129,6 +141,7 @@ export function EditorView(props) {
129
141
  if (seq !== loadSeqRef.current || path !== active) return
130
142
  if (res && res.ok) {
131
143
  setContent(res.content)
144
+ setContentPath(path)
132
145
  setLoadStage((prev) => ({ progress: Math.max(84, prev.progress), message: '文件已读取,准备创建编辑器…' }))
133
146
  setStatus('已加载')
134
147
  } else {
@@ -159,9 +172,18 @@ export function EditorView(props) {
159
172
  React.useEffect(() => {
160
173
  const onOpen = (e) => {
161
174
  const p = e?.detail?.path
162
- if (p) addTab(p, true)
175
+ if (!p) return
176
+ if (e?.detail?.focusDiff === true) {
177
+ pendingFocusRef.current = { path: p, region: null }
178
+ setFocusRequest((value) => value + 1)
179
+ }
180
+ addTab(p, true)
181
+ }
182
+ const onShowLauncher = (event) => {
183
+ const tab = event?.detail?.tab
184
+ if (tab === 'pending' || tab === 'archive') setLauncherTab(tab)
185
+ setLauncherOpen(true)
163
186
  }
164
- const onShowLauncher = () => setLauncherOpen(true)
165
187
  window.addEventListener('edrv:open-editor', onOpen)
166
188
  window.addEventListener('edrv:show-launcher', onShowLauncher)
167
189
  return () => {
@@ -170,6 +192,64 @@ export function EditorView(props) {
170
192
  }
171
193
  }, [sessionId])
172
194
 
195
+ // 对话输入区的唯一差异 dock 读取此会话快照;编辑器卸载时只清理自己的发布者令牌。
196
+ const dockSourceRef = React.useRef({})
197
+ React.useEffect(() => () => clearDiffDock(sessionId, dockSourceRef.current), [sessionId])
198
+
199
+ // DSH composer 保持原生布局;编辑区只读取几何边界并同步自己的高度。
200
+ React.useLayoutEffect(() => {
201
+ const root = viewRootRef.current
202
+ const scroll = root?.closest?.('[data-conversation-scroll]')
203
+ if (!root || !scroll) return
204
+ let seat = null
205
+ let frame = null
206
+ let resizeObserver = null
207
+ const update = () => {
208
+ frame = null
209
+ const rootRect = root.getBoundingClientRect()
210
+ const scrollRect = scroll.getBoundingClientRect()
211
+ const seatRect = seat?.getBoundingClientRect?.()
212
+ const height = editorHeight(rootRect.top, scrollRect.bottom, seatRect?.top)
213
+ root.style.setProperty('--edrv-editor-height', height + 'px')
214
+ }
215
+ const scheduleUpdate = () => {
216
+ if (frame !== null) return
217
+ frame = requestAnimationFrame(update)
218
+ }
219
+ const attachSeat = () => {
220
+ const next = scroll.querySelector?.('[data-composer-seat]')
221
+ if (next === seat) {
222
+ scheduleUpdate()
223
+ return
224
+ }
225
+ resizeObserver?.disconnect()
226
+ seat = next
227
+ resizeObserver = null
228
+ if (seat && typeof ResizeObserver === 'function') {
229
+ resizeObserver = new ResizeObserver(scheduleUpdate)
230
+ resizeObserver.observe(seat)
231
+ }
232
+ scheduleUpdate()
233
+ }
234
+ attachSeat()
235
+ const mutationObserver = typeof MutationObserver === 'function'
236
+ ? new MutationObserver(attachSeat)
237
+ : null
238
+ mutationObserver?.observe(scroll, { childList: true })
239
+ const scrollObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(scheduleUpdate) : null
240
+ scrollObserver?.observe(scroll)
241
+ window.addEventListener('resize', scheduleUpdate)
242
+ scheduleUpdate()
243
+ return () => {
244
+ if (frame !== null) cancelAnimationFrame(frame)
245
+ resizeObserver?.disconnect()
246
+ scrollObserver?.disconnect()
247
+ mutationObserver?.disconnect()
248
+ window.removeEventListener('resize', scheduleUpdate)
249
+ root.style.removeProperty('--edrv-editor-height')
250
+ }
251
+ }, [sessionId])
252
+
173
253
  // localStorage v2 恢复页签
174
254
  React.useEffect(() => {
175
255
  if (bootRef.current || !sessionId) return
@@ -192,15 +272,64 @@ export function EditorView(props) {
192
272
  catch (e) { /* 忽略 */ }
193
273
  }, [tabs, active, sessionId])
194
274
 
275
+ // 侧边栏状态:恢复(显隐/宽度/激活面板)
276
+ React.useEffect(() => {
277
+ if (!sessionId) return
278
+ try {
279
+ const raw = localStorage.getItem('edrv.sidebar.' + String(sessionId))
280
+ if (raw) {
281
+ const saved = JSON.parse(raw)
282
+ if (typeof saved.on === 'boolean') setSidebarOn(saved.on)
283
+ if (typeof saved.width === 'number') setSidebarW(Math.max(180, Math.min(560, saved.width)))
284
+ if (typeof saved.panel === 'string') setActivePanel(saved.panel)
285
+ }
286
+ } catch (e) { /* 损坏忽略 */ }
287
+ }, [sessionId])
288
+
289
+ // 侧边栏状态持久化
290
+ React.useEffect(() => {
291
+ if (!sessionId) return
292
+ try { localStorage.setItem('edrv.sidebar.' + String(sessionId), JSON.stringify({ on: sidebarOn, width: sidebarW, panel: activePanel })) }
293
+ catch (e) { /* 忽略 */ }
294
+ }, [sidebarOn, sidebarW, activePanel, sessionId])
295
+
296
+ // Ctrl+B 切换侧边栏(capture 抢占,避免与 DSH 全局冲突)
297
+ React.useEffect(() => {
298
+ const onKey = (e) => {
299
+ if ((e.ctrlKey || e.metaKey) && String(e.key).toLowerCase() === 'b') {
300
+ e.preventDefault(); e.stopPropagation()
301
+ setSidebarOn((v) => !v)
302
+ }
303
+ }
304
+ window.addEventListener('keydown', onKey, true)
305
+ return () => window.removeEventListener('keydown', onKey, true)
306
+ }, [])
307
+
308
+ // 文件 → 待处理差异数 映射(侧边栏角标)
309
+ const pendingByPath = React.useMemo(() => {
310
+ const out = {}
311
+ for (const f of sum.files) if (f.pending > 0) out[f.path] = f.pending
312
+ return out
313
+ }, [sum])
314
+
195
315
  React.useEffect(() => {
196
316
  if (!active) return
197
317
  setContent(null)
318
+ setContentPath(null)
198
319
  setLoadError(null)
199
320
  setLoadStage({ progress: 10, message: '准备读取文件…' })
200
321
  setStatus('加载中…')
201
322
  loadContent(active, sessionId)
202
323
  }, [active, sessionId])
203
324
 
325
+ // Tab 右键菜单打开时 Esc 关闭
326
+ React.useEffect(() => {
327
+ if (!tabMenu) return
328
+ const onKey = (e) => { if (e.key === 'Escape') dismissMenus() }
329
+ window.addEventListener('keydown', onKey, true)
330
+ return () => window.removeEventListener('keydown', onKey, true)
331
+ }, [tabMenu])
332
+
204
333
  React.useEffect(() => {
205
334
  if (monaco || monacoErr) return
206
335
  let alive = true
@@ -247,6 +376,7 @@ export function EditorView(props) {
247
376
  if (res && res.ok) {
248
377
  setStatus('已保存 ' + new Date().toTimeString().slice(0, 8))
249
378
  setContent(text)
379
+ setContentPath(active)
250
380
  setDirtyMap((d) => Object.assign({}, d, { [active]: false }))
251
381
  refreshRecords()
252
382
  emitRefresh()
@@ -327,6 +457,40 @@ export function EditorView(props) {
327
457
  ed.onDidChangeCursorPosition((e) => {
328
458
  setCursor('Ln ' + e.position.lineNumber + ', Col ' + e.position.column)
329
459
  })
460
+ // 编辑区右键 → 注入 Monaco 原生 context menu(与 Go to Definition 等同菜单,避免分离浮层)。
461
+ // 有选区时额外显示「选中内容」项:context key edrvSelection 由光标选区变化驱动。
462
+ // 路径/选区在点击时从 model/editor 实时读取(不依赖闭包里的过期 active)。
463
+ const menuHandlers = () => menuHandlersRef.current
464
+ const pathOf = (edx) => {
465
+ const model = edx?.getModel?.()
466
+ const uriPath = model?.uri?.path
467
+ return uriPath ? decodeURIComponent(String(uriPath).replace(/^\//, '')) : null
468
+ }
469
+ const selectionOf = (edx) => {
470
+ const s = edx?.getSelection?.()
471
+ if (!s) return null
472
+ if (s.startLineNumber === s.endLineNumber && s.startColumn === s.endColumn) return null
473
+ return { startLine: s.startLineNumber, endLine: s.endLineNumber }
474
+ }
475
+ const selKey = ed.createContextKey('edrvSelection', false)
476
+ ed.onDidChangeCursorSelection((e) => {
477
+ const s = e?.selection
478
+ selKey.set(!!(s && (s.startLineNumber !== s.endLineNumber || s.startColumn !== s.endColumn)))
479
+ })
480
+ ed.addAction({
481
+ id: 'edrv.addFileRef', label: '添加文件到对话', contextMenuGroupId: '1_edrv',
482
+ precondition: 'editorTextFocus',
483
+ run: (edx) => menuHandlers()?.addRefToChat(pathOf(edx)),
484
+ })
485
+ ed.addAction({
486
+ id: 'edrv.addSelectionRef', label: '添加选中内容为引用', contextMenuGroupId: '1_edrv',
487
+ precondition: 'edrvSelection',
488
+ run: (edx) => {
489
+ const s = selectionOf(edx)
490
+ const p = pathOf(edx)
491
+ if (s && p) menuHandlers()?.addRefToChat(p, { startLine: s.startLine, endLine: s.endLine })
492
+ },
493
+ })
330
494
  // hover 差异块 → 浮出 Keep/Undo(req:鼠标移到编辑区差异块时显示)
331
495
  // 防闪烁:① 区域不变不 setState(浮窗锚定差异块起始行,不跟随鼠标);② 延迟隐藏;
332
496
  // ③ 浮窗自身 onMouseEnter 取消隐藏计时(鼠标在浮窗与编辑器间移动不闪)。
@@ -373,7 +537,7 @@ export function EditorView(props) {
373
537
  ed.revealLineInCenter(Math.max(1, target.start ?? 1))
374
538
  ed.setPosition({ lineNumber: Math.max(1, target.start ?? 1), column: 1 })
375
539
  ed.focus()
376
- }, [active, content, pendingRegions])
540
+ }, [active, content, pendingRegions, focusRequest])
377
541
 
378
542
  const jumpTo = (region) => {
379
543
  if (editorRef.current) {
@@ -410,10 +574,16 @@ export function EditorView(props) {
410
574
  openFile(sum.pendingFiles[next].path, true)
411
575
  }
412
576
 
413
- const reloadFile = () => {
577
+ const openNextFile = () => {
578
+ if (!sum.pendingFiles.length) return
579
+ if (sum.pendingFiles.some((file) => file.path === active)) gotoFile(1)
580
+ else openFile(sum.pendingFiles[0].path, true)
581
+ }
582
+
583
+ const reloadFile = (skipStale) => {
414
584
  if (!active) return
415
585
  loadContent(active, sessionId)
416
- refreshRecords()
586
+ refreshRecords(skipStale === true)
417
587
  }
418
588
 
419
589
  /**
@@ -454,8 +624,64 @@ export function EditorView(props) {
454
624
  })
455
625
  }
456
626
 
457
- const acceptFile = () => { for (const r of pendingRegions) actHunk(r, false) }
458
- const undoFile = () => { for (const r of [...pendingRegions].reverse()) actHunk(r, true) }
627
+ /**
628
+ * 批量采纳/不采纳:一次 RPC 处理多个差异(Keep 整文件 / Keep All / Undo All)。
629
+ * 单次 setRecords 合并全部结果,避免逐条往返读写整个 sidecar。
630
+ * @author ddj 2026年08月25号
631
+ * @param items 决策项数组(callId/scope/hunkIndex/decision)
632
+ * @returns Promise<{ok:number; fail:number}> 成功/失败计数
633
+ */
634
+ const actMany = (items) => {
635
+ if (!items.length) return Promise.resolve({ ok: 0, fail: 0 })
636
+ return rpc('edrv.decideBatch', { sessionId, items }).then((res) => {
637
+ if (!res || !res.ok || !Array.isArray(res.results)) {
638
+ setError(res?.error ? String(res.error) : '批量处理失败')
639
+ return { ok: 0, fail: items.length }
640
+ }
641
+ let ok = 0
642
+ let fail = 0
643
+ const next = {}
644
+ for (const item of res.results) {
645
+ if (item && item.ok) {
646
+ ok++
647
+ if (item.record) next[item.callId] = item.record
648
+ } else {
649
+ fail++
650
+ }
651
+ }
652
+ setRecords((prev) => Object.assign({}, prev, next))
653
+ return { ok, fail }
654
+ }).catch((e) => {
655
+ setError('批量处理异常:' + String(e))
656
+ return { ok: 0, fail: items.length }
657
+ })
658
+ }
659
+
660
+ /**
661
+ * 决策项构造(与单条 actHunk 的 scope 语义一致:create 记录走 call 作用域)。
662
+ * @author ddj 2026年08月25号
663
+ * @param r 差异区域/待处理项(callId/idx/create)
664
+ * @param reject true=不采纳
665
+ * @returns decideBatch items 元素
666
+ */
667
+ const itemOf = (r, reject) => ({ callId: r.callId, scope: r.create ? 'call' : 'hunk', hunkIndex: r.idx, decision: reject ? 'rejected' : 'accepted' })
668
+
669
+ const acceptFile = () => {
670
+ actMany(pendingRegions.map((r) => itemOf(r, false))).then(({ ok, fail }) => {
671
+ reloadFile(true)
672
+ emitRefresh()
673
+ setStatus('已采纳 ' + ok + ' 处差异' + (fail ? ',' + fail + ' 处失败' : ''))
674
+ if (fail) setError(fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
675
+ })
676
+ }
677
+ const undoFile = () => {
678
+ actMany([...pendingRegions].reverse().map((r) => itemOf(r, true))).then(({ ok, fail }) => {
679
+ reloadFile(true)
680
+ emitRefresh()
681
+ setStatus('已不采纳 ' + ok + ' 处差异' + (fail ? ',' + fail + ' 处失败' : ''))
682
+ if (fail) setError(fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
683
+ })
684
+ }
459
685
 
460
686
  // 所有差异文件的待处理 hunk 列表(二级菜单 Keep All / Undo All 用)
461
687
  const allPending = React.useMemo(() => {
@@ -473,7 +699,7 @@ export function EditorView(props) {
473
699
  }, [records])
474
700
 
475
701
  /**
476
- * 批量处理所有差异文件:采纳全部 / 不采纳全部。不采纳按 at 降序(新改先回滚)串行执行。
702
+ * 批量处理所有差异文件:采纳全部 / 不采纳全部。不采纳按 at 降序(新改先回滚)排序后一次 RPC。
477
703
  * @author ddj 2026年08月20号
478
704
  * @param reject true=全部不采纳(回滚),false=全部采纳
479
705
  */
@@ -481,19 +707,13 @@ export function EditorView(props) {
481
707
  if (batchBusyRef.current || !allPending.length) return
482
708
  batchBusyRef.current = true
483
709
  const list = reject ? [...allPending].sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : b.idx - a.idx)) : allPending
484
- let ok = 0, fail = 0
485
- const run = async () => {
486
- for (const r of list) {
487
- if (await actHunk(r, reject, true)) ok++
488
- else fail++
489
- }
710
+ actMany(list.map((r) => itemOf(r, reject))).then(({ ok, fail }) => {
490
711
  batchBusyRef.current = false
491
- reloadFile()
712
+ reloadFile(true)
492
713
  emitRefresh()
493
714
  setStatus((reject ? '已不采纳 ' : '已采纳 ') + ok + ' 处差异' + (fail ? ',' + fail + ' 处失败' : ''))
494
715
  if (fail) setError(fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
495
- }
496
- run()
716
+ }).catch(() => { batchBusyRef.current = false })
497
717
  }
498
718
  const acceptAllFiles = () => actAllPending(false)
499
719
  const undoAllFiles = () => actAllPending(true)
@@ -508,6 +728,32 @@ export function EditorView(props) {
508
728
  }).catch((e) => setError('回滚异常:' + String(e)))
509
729
  }
510
730
 
731
+ /**
732
+ * 关闭 Tab 右键菜单。
733
+ * @author ddj 2026年08月25号
734
+ */
735
+ const dismissMenus = () => {
736
+ setTabMenu(null)
737
+ }
738
+
739
+ /** 把「添加到对话」返回状态映射为状态栏文案。
740
+ * @author ddj 2026年08月25号 */
741
+ const statusOfAdd = (outcome, okText) => {
742
+ if (outcome === 'ok') return okText
743
+ if (outcome === 'busy') return okText + '(输入框忙,已降级纯文本)'
744
+ return '无法添加到对话(无会话或输入框不可用)'
745
+ }
746
+
747
+ /** 添加文件/选中区引用到对话(异步,状态栏反馈)。 */
748
+ const addRefToChat = (path, range) => {
749
+ if (!path) { setStatus('无活动文件'); return }
750
+ if (!addToConversation) { setStatus('添加到对话不可用'); return }
751
+ addToConversation.appendReference(sessionId, path, range).then((o) => setStatus(statusOfAdd(o, '已添加文件引用')))
752
+ }
753
+
754
+ // 供 Monaco 原生右键菜单 addAction 读取的最新动作闭包(空依赖回调不随渲染重建)
755
+ menuHandlersRef.current = { addRefToChat }
756
+
511
757
  const openFile = (path, focusDiff) => {
512
758
  if (!path) return
513
759
  addTab(path, true)
@@ -532,6 +778,11 @@ export function EditorView(props) {
532
778
  className: 'edrv-tab' + (t.path === active ? ' edrv-tab-active' : ''),
533
779
  title: t.path,
534
780
  onClick: () => { if (t.path !== active) { flushSave(); setActive(t.path) } },
781
+ onContextMenu: (e) => {
782
+ e.preventDefault()
783
+ e.stopPropagation()
784
+ setTabMenu({ x: e.clientX, y: e.clientY, path: t.path })
785
+ },
535
786
  },
536
787
  React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 } }, t.path.split(/[\\/]/).pop() || t.path),
537
788
  (dirtyMap[t.path] ? React.createElement('span', { className: 'edrv-tab-dot' }) : null),
@@ -542,22 +793,19 @@ export function EditorView(props) {
542
793
 
543
794
  const pathBar = React.createElement('div', { className: 'edrv-pathbar', title: active || '' },
544
795
  React.createElement('span', { className: 'edrv-pb-name' }, active ? String(active).split(/[\\/]/).pop() : '未打开文件'),
545
- React.createElement('span', { className: 'edrv-pb-full' }, active || '使用右上搜索框 (Ctrl+P) 打开文件'))
796
+ React.createElement('span', { className: 'edrv-pb-full' }, active || '使用右上搜索框 (Ctrl+P) 打开文件'),
797
+ (active && langOf(active) ? React.createElement('span', { className: 'edrv-pb-meta' }, langOf(active)) : null),
798
+ (cursor ? React.createElement('span', { className: 'edrv-pb-meta' }, cursor) : null),
799
+ (status ? React.createElement('span', { className: 'edrv-pb-meta edrv-pb-status' }, status) : null))
546
800
 
547
801
  const tabRow = React.createElement('div', { style: { display: 'flex', alignItems: 'center', background: 'var(--dsw-alias-bg-layer-1,transparent)', flexShrink: 0 } },
548
802
  tabsEl,
549
- React.createElement(QuickOpen, { sessionId, onOpen: (p) => openFile(p, false) }))
550
-
551
- const statusBar = React.createElement('div', { className: 'edrv-statusbar' },
552
- React.createElement('span', null, '编辑'),
553
- React.createElement('span', null, active ? langOf(active) : ''),
554
- React.createElement('span', null, cursor),
555
- (status ? React.createElement('span', null, status) : null),
556
- React.createElement('span', { style: { flex: 1 } }),
557
803
  (sum.totalFiles > 0
558
804
  ? React.createElement('button', { className: 'edrv-diffchip', title: '打开差异总览/归档', onClick: () => setLauncherOpen((o) => !o) }, '⚠ 差异 ' + sum.totalFiles + ' 文件')
559
805
  : null),
560
- React.createElement('button', { className: 'edrv-chip-btn', title: '刷新', onClick: reloadFile }, '⟳'))
806
+ React.createElement('button', { className: 'edrv-chip-btn' + (sidebarOn ? ' edrv-chip-on' : ''), title: '切换侧边栏 (Ctrl+B)', onClick: () => setSidebarOn((v) => !v) }, '☰'),
807
+ React.createElement('button', { className: 'edrv-chip-btn', title: '刷新', onClick: reloadFile }, '⟳'),
808
+ React.createElement(QuickOpen, { sessionId, onOpen: (p) => openFile(p, false) }))
561
809
 
562
810
  const otherFiles = sum.pendingFiles.filter((f) => f.path !== active)
563
811
 
@@ -598,6 +846,7 @@ export function EditorView(props) {
598
846
  body = loadingBody('文件加载失败:' + loadError, 0, () => {
599
847
  setLoadError(null)
600
848
  setContent(null)
849
+ setContentPath(null)
601
850
  setLoadStage({ progress: 10, message: '重新读取文件…' })
602
851
  loadContent(active, sessionId)
603
852
  })
@@ -630,46 +879,104 @@ export function EditorView(props) {
630
879
  React.createElement('button', { className: 'edrv-pill edrv-pill-undo', onClick: () => { dismissHover(); actHunk(hoverAct.region, true) } }, '↩ Undo'))
631
880
  : null
632
881
 
882
+ React.useEffect(() => {
883
+ if (!sessionId) return
884
+ const mode = editorDockMode(active)
885
+ publishDiffDock(sessionId, {
886
+ mode,
887
+ pendingRegions,
888
+ staleRegions,
889
+ onAct: actHunk,
890
+ onAcceptFile: acceptFile,
891
+ onUndoFile: undoFile,
892
+ onAcceptAllFiles: acceptAllFiles,
893
+ onUndoAllFiles: undoAllFiles,
894
+ allPendingCount: allPending.length,
895
+ onRollback: rollbackFile,
896
+ onJump: jumpTo,
897
+ otherFiles,
898
+ onOpenOther: (path) => openFile(path, true),
899
+ onOpenLauncher: (tab) => { setLauncherTab(tab || 'pending'); setLauncherOpen(true) },
900
+ onRefresh: reloadFile,
901
+ activePath: active,
902
+ diffIdx: contentReady ? diffIdx : 0,
903
+ diffTotal: displayDiffTotal(contentReady, pendingRegions.length, sum.files.find((file) => file.path === active)?.pending ?? 0),
904
+ fileIdx,
905
+ fileTotal: sum.pendingFiles.length,
906
+ onPrevDiff: () => gotoDiff(-1),
907
+ onNextDiff: () => gotoDiff(1),
908
+ onPrevFile: () => gotoFile(-1),
909
+ onNextFile: () => gotoFile(1),
910
+ onOpenNextFile: openNextFile,
911
+ }, dockSourceRef.current)
912
+ })
913
+
633
914
  const overlay = launcherOpen
634
915
  ? React.createElement(React.Fragment, null,
635
916
  React.createElement('div', { style: { position: 'fixed', inset: 0, zIndex: 30 }, onClick: () => setLauncherOpen(false) }),
636
917
  React.createElement(DiffLauncher, { sessionId, sum, tab: launcherTab, onClose: () => setLauncherOpen(false), onOpenFile: (p) => { openFile(p, true); setLauncherOpen(false) } }))
637
- : (pendingRegions.length > 0
638
- ? React.createElement(DiffBox, {
639
- pendingRegions, staleRegions,
640
- onAct: actHunk,
641
- onAcceptFile: acceptFile,
642
- onUndoFile: undoFile,
643
- onAcceptAllFiles: acceptAllFiles,
644
- onUndoAllFiles: undoAllFiles,
645
- allPendingCount: allPending.length,
646
- onRollback: rollbackFile,
647
- onJump: jumpTo,
648
- otherFiles,
649
- onOpenOther: (p) => openFile(p, true),
650
- onOpenLauncher: (tab) => { setLauncherTab(tab || 'pending'); setLauncherOpen(true) },
651
- onRefresh: reloadFile,
652
- activePath: active,
653
- diffIdx, diffTotal: pendingRegions.length,
654
- fileIdx, fileTotal: sum.pendingFiles.length,
655
- onPrevDiff: () => gotoDiff(-1), onNextDiff: () => gotoDiff(1),
656
- onPrevFile: () => gotoFile(-1), onNextFile: () => gotoFile(1),
657
- })
658
- : (sum.pendingFiles.length > 0
659
- ? React.createElement(DiffBarEmpty, {
660
- sum, active, staleCount: staleRegions.length,
661
- onNextFile: () => { if (sum.pendingFiles.length) openFile(sum.pendingFiles[0].path, true) },
662
- onOpenLauncher: (tab) => { setLauncherTab(tab || 'pending'); setLauncherOpen(true) },
663
- onRefresh: reloadFile,
664
- })
665
- : null))
666
-
667
- return React.createElement('div', { 'data-edrv-view': '1', style: { height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--dsw-alias-bg-base,transparent)', overflow: 'hidden' } },
918
+ : null
919
+
920
+ // Tab 右键菜单浮层:全屏遮罩(点击/右键关闭)+ 菜单(固定定位,clamp 防越界)。
921
+ // 编辑区右键走 Monaco 原生 context menu(editor.addAction),不在此渲染浮层。
922
+ /**
923
+ * 计算右键菜单固定定位(clamp 防越出视口)。
924
+ * @author ddj 2026年08月25号
925
+ */
926
+ const menuPos = (x, y) => ({
927
+ left: Math.max(4, Math.min(x, (window.innerWidth || 800) - 224)),
928
+ top: Math.max(4, Math.min(y, (window.innerHeight || 600) - 176)),
929
+ })
930
+ const menuBackdrop = tabMenu
931
+ ? React.createElement('div', {
932
+ style: { position: 'fixed', inset: 0, zIndex: 70 },
933
+ onClick: dismissMenus,
934
+ onContextMenu: (e) => { e.preventDefault(); dismissMenus() },
935
+ })
936
+ : null
937
+ const tabMenuEl = tabMenu
938
+ ? React.createElement('div', { className: 'edrv-ctxmenu', style: menuPos(tabMenu.x, tabMenu.y) },
939
+ React.createElement('button', { className: 'edrv-ctxmenu-item', onClick: () => { addRefToChat(tabMenu.path); dismissMenus() } }, '添加文件到对话'),
940
+ React.createElement('div', { className: 'edrv-ctxmenu-sep' }),
941
+ React.createElement('button', { className: 'edrv-ctxmenu-item edrv-ctxmenu-danger', onClick: () => { closeTab(tabMenu.path); dismissMenus() } }, '关闭标签页'))
942
+ : null
943
+
944
+ const sidebarPanels = props.sidebarPanels
945
+ const sidebarCtx = {
946
+ sessionId,
947
+ openFile: (p) => openFile(p, false),
948
+ activePath: active,
949
+ pendingByPath,
950
+ sum: { totalFiles: sum.totalFiles, files: sum.files },
951
+ refreshRecords: () => refreshRecords(),
952
+ }
953
+
954
+ // 主编辑列(侧边栏右侧):pathBar + tabRow + 编辑/差异区(底部整条留给 DSH 对话输入栏)
955
+ const editorArea = React.createElement('div', { className: 'edrv-editor-area' },
956
+ body,
957
+ hoverEl,
958
+ overlay)
959
+ const mainCol = React.createElement('div', { className: 'edrv-main-col', style: { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
668
960
  pathBar,
669
961
  tabRow,
670
- React.createElement('div', { style: { position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } },
671
- body,
672
- hoverEl,
673
- overlay),
674
- statusBar)
962
+ editorArea)
963
+ // 编辑器根节点按 composer 顶部边界动态限高,底部对话区域继续由 DSH 原生渲染。
964
+ const editorRow = React.createElement('div', { className: 'edrv-editor-row' },
965
+ (sidebarOn && sidebarPanels
966
+ ? React.createElement(SidebarView, {
967
+ registry: sidebarPanels,
968
+ ctx: sidebarCtx,
969
+ activePanel,
970
+ onActive: setActivePanel,
971
+ width: sidebarW,
972
+ onWidth: setSidebarW,
973
+ onHide: () => setSidebarOn(false),
974
+ })
975
+ : null),
976
+ mainCol)
977
+
978
+ return React.createElement('div', { ref: viewRootRef, 'data-edrv-view': '1', style: { height: 'var(--edrv-editor-height, 100%)', maxHeight: 'var(--edrv-editor-height, 100%)', minHeight: 0, display: 'flex', flexDirection: 'column', background: 'var(--dsw-alias-bg-base,transparent)', overflow: 'hidden' } },
979
+ editorRow,
980
+ menuBackdrop,
981
+ tabMenuEl)
675
982
  }