dsh-vscode-mode 0.14.13 → 0.15.2

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.
@@ -8,6 +8,12 @@
8
8
  * 作者 ddj 2026-08-20
9
9
  */
10
10
  import React from 'react'
11
+ import { createPortal } from 'react-dom'
12
+ import { EditorPane, EditorSplit, PassiveEditor } from './EditorPane.js'
13
+ import { FloatingEditor } from './FloatingEditor.js'
14
+ import { createLayout, parseLayout, syncTabs, ownerOf, focusGroup, moveTab, returnGroup, GROUP_LIMIT, MAIN_GROUP } from '../state/editorLayout.js'
15
+ import { createTabDrag, hitTarget } from '../tabDrag.js'
16
+ import { createDocStore } from '../state/documentStore.js'
11
17
  import { useOpenReceipt } from '../useOpenReceipt.js'
12
18
  import { acceptsLoad, receiptErrorFor, receiptPath, receiptReadyFor, receiptTabPath } from '../receiptState.js'
13
19
  import { bindPanelCommand } from '../panelCommands.js'
@@ -59,7 +65,7 @@ import { createViewKeeper } from '../state/editorViewState.js'
59
65
  import { mdPreviewLoad, mdPreviewSave } from '../state/mdPreviewCache.js'
60
66
  import { onUiStateReady, reportUiStateEvent, uiStateSettled } from '../state/uiStatePersist.js'
61
67
  import { migrateScopedKeys, workspaceScopeOf } from '../state/scopeStore.js'
62
- import { modelsForScope, rememberModel } from '../state/modelCache.js'
68
+ import { modelsForScope, rememberModel, retainModels } from '../state/modelCache.js'
63
69
  import { navFlashRangeOf } from '../navHighlight.js'
64
70
  import { chordOf, useKeybindingsVersion } from '../keybindings.js'
65
71
  import { getSidebarMinWidth } from '../sidebarMin.js'
@@ -253,7 +259,33 @@ export function EditorView(props) {
253
259
  const planHostRef = React.useRef(null)
254
260
  React.useEffect(() => { setPlanTabs([]); setPlanActive(null) }, [sessionId, cwd])
255
261
  /** @private @author ddj 2026年10月08号 @param path Disk tab selection. Leave read-only plan mode before any disk navigation. */
256
- function setActive(path) { setPlanActive(null); setDiskActive(path) }
262
+ function setActive(path) {
263
+ setPlanActive(null)
264
+ setDiskActive(path)
265
+ setWorkbench((current) => syncTabs(current, tabsRef.current, path))
266
+ }
267
+ const [workbench, setWorkbench] = React.useState(() => createLayout())
268
+ const [layoutScope, setLayoutScope] = React.useState(null)
269
+ const benchRef = React.useRef(workbench)
270
+ benchRef.current = workbench
271
+ const [dragPreview, setDragPreview] = React.useState(null)
272
+ const dragRef = React.useRef(null)
273
+ const benchOps = React.useRef(null)
274
+ const fullHost = React.useMemo(() => document.createElement('div'), [])
275
+ fullHost.className = 'edrv-full-view'
276
+ const pdfNodes = React.useRef(new Map())
277
+ const paneEditors = React.useRef(new Map())
278
+ const docRef = React.useRef(null)
279
+ const docStores = React.useRef(new Map())
280
+ const docOps = React.useRef(null)
281
+ React.useEffect(() => {
282
+ setWorkbench((current) => syncTabs(current, tabs, active))
283
+ }, [tabs, active])
284
+ React.useEffect(() => {
285
+ dragRef.current?.cancel()
286
+ return () => dragRef.current?.cancel()
287
+ }, [sessionId, scope])
288
+
257
289
  const [dirtyMap, setDirtyMap] = React.useState({})
258
290
  const [content, setContent] = React.useState(null)
259
291
  const [contentPath, setContentPath] = React.useState(null)
@@ -368,6 +400,7 @@ export function EditorView(props) {
368
400
  tabsRef.current = tabs
369
401
  const tabPathsRef = React.useRef([]) // 页签路径镜像(外部改动轮询读取)
370
402
  tabPathsRef.current = tabs.map((t) => t.path)
403
+ retainModels(modelsRef.current, [...tabPathsRef.current, ...Object.keys(dirtyMap).filter((path) => dirtyMap[path])])
371
404
  const dirtyRef = React.useRef({}) // 脏标记最新值(菜单禁用判定与关闭时落盘读)
372
405
  dirtyRef.current = dirtyMap
373
406
  const tabsHostRef = React.useRef(null) // 页签栏容器(切换后把当前页签滚入可见区)
@@ -571,7 +604,7 @@ export function EditorView(props) {
571
604
  /** @private @author ddj 2026年10月12号 @returns Unsafe editor state without saving or discarding file content. */
572
605
  function checkReload() {
573
606
  if (Object.values(dirtyRef.current).some(Boolean)) return '存在未保存文件'
574
- if (saveTimerRef.current.isPending() || savingRef.current.size) return '文件正在等待保存或保存中'
607
+ if (saveTimerRef.current.isPending() || savingRef.current.size || docRef.current?.unsafe()) return '文件正在等待保存或保存中'
575
608
  for (const ctl of pdfCtlRef.current.values()) {
576
609
  if (ctl.isDirty?.()) return '存在未保存 PDF'
577
610
  }
@@ -690,6 +723,8 @@ export function EditorView(props) {
690
723
  if (path === activeRef.current) { saveViewState(path); recordNav() }
691
724
  const pdfCtl = pdfCtlRef.current.get(path)
692
725
  if (pdfCtl) { pdfCtl.destroy(); pdfCtlRef.current.delete(path) }
726
+ pdfNodes.current.delete(path)
727
+ docRef.current?.release(path)
693
728
  pdfB64CacheRef.current.delete(path)
694
729
  // 关闭即失去跟踪:清磁盘版本基线/已读台账/同步标记(重开时按最新内容重建)
695
730
  clearBaseline(scope, path)
@@ -764,7 +799,12 @@ export function EditorView(props) {
764
799
  for (const key of Object.keys(prev)) next[remapPathOf(key, from, to)] = prev[key]
765
800
  return next
766
801
  })
767
- setActive((prev) => (prev ? remapPathOf(prev, from, to) : prev))
802
+ setWorkbench((current) => ({ ...current, groups: current.groups.map((group) => ({ ...group,
803
+ tabs: group.tabs.map((tab) => ({ ...tab, path: remapPathOf(tab.path, from, to) })),
804
+ active: group.active ? remapPathOf(group.active, from, to) : null,
805
+ })) }))
806
+ setPlanActive(null)
807
+ setDiskActive((prev) => (prev ? remapPathOf(prev, from, to) : prev))
768
808
  }
769
809
 
770
810
  // 文件树右键的重命名/删除 → 页签同步(动作在 sidebar/menuItems.ts,经窗口事件桥接;
@@ -797,9 +837,10 @@ export function EditorView(props) {
797
837
  * @param step 步进(+1 下一个 / -1 上一个)
798
838
  */
799
839
  const cycleTab = (step) => {
800
- if (tabs.length < 2) return
801
- const idx = tabs.findIndex((t) => t.path === active)
802
- const next = tabs[(idx + step + tabs.length) % tabs.length]
840
+ const groupTabs = benchRef.current.groups.find((group) => group.id === benchRef.current.focused)?.tabs ?? tabs
841
+ if (groupTabs.length < 2) return
842
+ const idx = groupTabs.findIndex((t) => t.path === active)
843
+ const next = groupTabs[(idx + step + groupTabs.length) % groupTabs.length]
803
844
  if (!next || next.path === active) return
804
845
  flushSave()
805
846
  saveViewState(active)
@@ -1083,9 +1124,16 @@ export function EditorView(props) {
1083
1124
  * @param version 已知磁盘版本(外部改动轮询触发时带入)
1084
1125
  */
1085
1126
  const loadPdf = (path, sid, seq, force, version) => {
1127
+ if (force && pdfCtlRef.current.get(path)?.isDirty()) { setStatus('PDF 含未保存批注,未覆盖'); return }
1086
1128
  setPdfBytes(null)
1087
1129
  setLoadStage({ progress: monaco ? 72 : 12, message: '读取 PDF…' })
1088
1130
  const cache = pdfB64CacheRef.current
1131
+ if (force && pdfCtlRef.current.has(path)) {
1132
+ const controller = pdfCtlRef.current.get(path)
1133
+ controller.destroy()
1134
+ pdfCtlRef.current.delete(path)
1135
+ pdfNodes.current.delete(path)
1136
+ }
1089
1137
  const hit = !force ? cache.get(path) : undefined
1090
1138
  if (typeof hit === 'string' && hit) {
1091
1139
  // 刷新插入序(Map 按插入序 FIFO 驱逐)后秒切
@@ -1164,6 +1212,22 @@ export function EditorView(props) {
1164
1212
  if (!change || !change.path) return
1165
1213
  // 文件树同步:目录缓存按该路径失效并强制重列(外部新增/删除/改名都能看到)
1166
1214
  const eventId = emitFileChanged(change.path, { scope, kind: change.kind === 'deleted' ? 'structure' : 'text' })
1215
+ if (change.kind === 'modified' && change.path !== activeRef.current) {
1216
+ const currentScope = scope, map = modelsRef.current
1217
+ void rpc('edrv.read', { sessionId, path: change.path }).then((result) => {
1218
+ if (!result?.ok || map !== modelsRef.current || dirtyRef.current[change.path]) return
1219
+ const model = map.get(change.path)
1220
+ if (model && !model.isDisposed?.()) {
1221
+ programmaticRef.current = true
1222
+ try { model.setValue(result.content) } finally { programmaticRef.current = false }
1223
+ }
1224
+ revRef.current[change.path] = result.version
1225
+ docRef.current?.baseline(change.path, result.version, false)
1226
+ recordBaseline(currentScope, change.path, result.version)
1227
+ clearSync(currentScope, change.path)
1228
+ }).catch(() => {})
1229
+ return
1230
+ }
1167
1231
  if (change.kind === 'modified') {
1168
1232
  // 干净缓冲:强制从磁盘重读(跳过 model 复用),差异标记随之重算
1169
1233
  loadContent(change.path, sessionId, true)
@@ -1361,11 +1425,42 @@ export function EditorView(props) {
1361
1425
  React.useEffect(() => {
1362
1426
  if (!sessionId || restoredScopeRef.current === scope) return
1363
1427
  restoredScopeRef.current = scope
1428
+ setLayoutScope(scope)
1364
1429
  // model 缓存跟随作用域(切工作区释放旧作用域模型;同工作区跨挂载复用)
1365
1430
  modelsRef.current = modelsForScope(scope)
1366
1431
  // Markdown 预览态同样按作用域恢复(重启/换端口后由界面状态镜像回填,见 lateHydrateRef 分支)
1367
1432
  mdPreviewRef.current = mdPreviewLoad(String(scope))
1433
+ docRef.current = docStores.current.get(scope) ?? null
1434
+ for (const controller of pdfCtlRef.current.values()) controller.destroy()
1435
+ pdfCtlRef.current.clear()
1436
+ pdfNodes.current.clear()
1437
+ pdfB64CacheRef.current.clear()
1438
+ revRef.current = {}
1439
+ savingRef.current.clear()
1440
+ const restoredDirty = {}
1441
+ for (const path of modelsRef.current.keys()) {
1442
+ const item = docRef.current?.state(path)
1443
+ if (item?.dirty) restoredDirty[path] = true
1444
+ if (item?.rev) revRef.current[path] = item.rev
1445
+ if (item?.saving) savingRef.current.add(path)
1446
+ }
1447
+ setDirtyMap(restoredDirty)
1448
+ setTabs([])
1449
+ setDiskActive(null)
1450
+ setContent(null)
1451
+ setContentPath(null)
1368
1452
  try {
1453
+ const savedLayout = parseLayout(localStorage.getItem(CACHE_KEY.editorLayout + String(scope)), cwd)
1454
+ if (savedLayout) {
1455
+ const restored = savedLayout.groups.flatMap((group) => group.tabs)
1456
+ const selected = savedLayout.groups.find((group) => group.id === savedLayout.focused)?.active ?? null
1457
+ setWorkbench(savedLayout)
1458
+ setTabs(restored)
1459
+ setPlanActive(null)
1460
+ setDiskActive(selected)
1461
+ return
1462
+ }
1463
+ setWorkbench(createLayout())
1369
1464
  // v3 优先;缺失时回读 v2(旧版纯路径数组)——normalizeTabs 兼容两种形状
1370
1465
  const raw = localStorage.getItem(CACHE_KEY.editor + String(scope))
1371
1466
  ?? localStorage.getItem(CACHE_KEY.editorLegacy + String(scope))
@@ -1429,6 +1524,11 @@ export function EditorView(props) {
1429
1524
  setNavTick((v) => v + 1)
1430
1525
  }, [scope, hydrateTick])
1431
1526
 
1527
+ React.useEffect(() => {
1528
+ if (!sessionId || layoutScope !== scope || !uiStateSettled()) return
1529
+ try { localStorage.setItem(CACHE_KEY.editorLayout + String(scope), JSON.stringify(workbench)) } catch { /* Storage can be unavailable. */ }
1530
+ }, [workbench, layoutScope, sessionId, scope, hydrateTick])
1531
+
1432
1532
  // 导航双栈落 localStorage(同原文去重写;镜像层负责跨重启):刷新/重启后前进后退与当前位置仍在
1433
1533
  React.useEffect(() => {
1434
1534
  if (!sessionId || !uiStateSettled()) return
@@ -1479,7 +1579,9 @@ export function EditorView(props) {
1479
1579
  // 复用 closeTabs:它已实现 flushSave + persistDirty(脏页签先静默落盘)+ releaseTab + commitClose,
1480
1580
  // 故「超限淘汰脏页签」不会丢用户编辑。淘汰后 tabs 变化 → 本 effect 复算 evictPlan 返回空 → 自然收敛。
1481
1581
  React.useEffect(() => {
1582
+ const protectedPaths = new Set(benchRef.current.groups.map((group) => group.active).filter(Boolean))
1482
1583
  const plan = evictPlan(tabsRef.current, tabLimit, { active: activeRef.current, used: tabUseRef.current.used })
1584
+ .filter((path) => !protectedPaths.has(path) && !dirtyRef.current[path] && !savingRef.current.has(path))
1483
1585
  if (!plan.length) return
1484
1586
  closeTabs(applyClose(tabsRef.current, new Set(plan), activeRef.current), '已关闭最久未使用的页签')
1485
1587
  }, [tabs, tabLimit, active])
@@ -1917,7 +2019,7 @@ export function EditorView(props) {
1917
2019
  const uri = window.monaco.Uri.parse('edrv:///' + encodeURI(String(path ?? '').replace(/^\/+/, '')))
1918
2020
  model = window.monaco.editor.getModel(uri) || window.monaco.editor.createModel(text ?? '', langOf(path), uri)
1919
2021
  rememberModel(cache, path, model)
1920
- } else if (text !== undefined && model.getValue() !== text) {
2022
+ } else if (text !== undefined && !dirtyRef.current[path] && model.getValue() !== text) {
1921
2023
  programmaticRef.current = true
1922
2024
  model.setValue(text)
1923
2025
  programmaticRef.current = false
@@ -1939,6 +2041,7 @@ export function EditorView(props) {
1939
2041
  const target = path ?? active
1940
2042
  if (target && readSync(scope, target)) return false
1941
2043
  saveTimerRef.current?.flush()
2044
+ if (docRef.current) void docRef.current.flush(target)
1942
2045
  return true
1943
2046
  }
1944
2047
 
@@ -1959,6 +2062,59 @@ export function EditorView(props) {
1959
2062
  setDiskFlag((prev) => (prev && prev.path === path ? null : prev))
1960
2063
  }
1961
2064
 
2065
+ /** @private @author ddj 2026年10月14号 @returns One document save owner using latest workspace callbacks. */
2066
+ function documents() {
2067
+ if (!docRef.current) {
2068
+ const documentScope = scope
2069
+ const store = createDocStore({
2070
+ save: (request) => rpc('edrv.save', { sessionId, ...request }),
2071
+ changed: (path, item) => { if (docRef.current === store) docOps.current.changed(path, item) },
2072
+ saved: (path, text, model, result) => {
2073
+ recordBaseline(documentScope, path, result.rev)
2074
+ if (docRef.current === store) docOps.current.saved(path, text, model, result)
2075
+ },
2076
+ failed: (path, result) => { if (docRef.current === store) docOps.current.failed(path, result) },
2077
+ blocked: (path) => !!readSync(documentScope, path),
2078
+ })
2079
+ docRef.current = store
2080
+ docStores.current.set(scope, store)
2081
+ }
2082
+ return docRef.current
2083
+ }
2084
+ docOps.current = {
2085
+ save: (request) => rpc('edrv.save', { sessionId, ...request }),
2086
+ changed: (path, item) => {
2087
+ if (item.saving) savingRef.current.add(path)
2088
+ else savingRef.current.delete(path)
2089
+ setDirtyMap((current) => current[path] === item.dirty ? current : { ...current, [path]: item.dirty })
2090
+ },
2091
+ saved: (path, text, model, result) => {
2092
+ if (result.rev) revRef.current[path] = result.rev
2093
+ if (/\.code-snippets$/i.test(path)) window.dispatchEvent(new CustomEvent('edrv:snippets-changed'))
2094
+ recordBaseline(scope, path, result.rev)
2095
+ markReadVersion(scope, path, result.rev, true)
2096
+ clearSync(scope, path)
2097
+ acceptSaved(path, text, model)
2098
+ refreshRecords()
2099
+ emitRefresh({ scope, paths: [path], kind: 'text' })
2100
+ },
2101
+ failed: (path, result) => {
2102
+ if (result.conflict) markDiskFlag({ path, kind: 'conflict', at: Date.now() })
2103
+ setStatus(result.conflict ? '保存被拒:文件已被外部修改' : '保存失败')
2104
+ if (result.error) setError(result.error)
2105
+ },
2106
+ }
2107
+ /** @private @author ddj 2026年10月14号 @param path Canonical path. @param manual Explicit save. @returns Guarded save, never another pane's content. */
2108
+ function saveDocument(path, manual) {
2109
+ const model = modelsRef.current.get(path)
2110
+ if (!model) return Promise.resolve({ ok: false })
2111
+ const store = documents()
2112
+ store.bind(path, model, revRef.current[path])
2113
+ store.state(path).dirty = !!dirtyRef.current[path]
2114
+ store.baseline(path, revRef.current[path], !!readSync(scope, path))
2115
+ return store.save(path, manual)
2116
+ }
2117
+
1962
2118
  const doSave = (silent) => {
1963
2119
  if (!active) return
1964
2120
  // 外部改动待处理:自动保存(silent)直接放弃,避免陈旧缓冲覆盖磁盘;
@@ -1972,40 +2128,9 @@ export function EditorView(props) {
1972
2128
  void ctl.savePdf()
1973
2129
  return
1974
2130
  }
1975
- const ed = editorRef.current
1976
- if (!ed) return
1977
2131
  if (isImageActive) { setStatus('图片只读预览'); return }
1978
- const text = ed.getValue()
1979
- const savedModel = ed.getModel?.()
1980
- const path = active
1981
2132
  if (!silent) setStatus('保存中…')
1982
- // 在途登记:关闭路径的 persistDirty 据此跳过重复提交(内容在同一 tick 内取,必然相同)
1983
- savingRef.current.add(path)
1984
- // 版本守卫令牌:上次读取/写入时的磁盘版本;与磁盘当前版本不符时 host 拒绝写入
1985
- const rev = revRef.current[path]
1986
- rpc('edrv.save', { sessionId, path, content: text, rev: typeof rev === 'string' && rev ? rev : undefined }).then((res) => {
1987
- if (res && res.ok) {
1988
- if (res.rev) revRef.current[path] = res.rev
1989
- recordBaseline(scope, path, res.rev)
1990
- markReadVersion(scope, path, res.rev, true) // 缓冲即磁盘内容:同版本无需再读盘
1991
- clearSync(scope, path)
1992
- acceptSaved(path, text, savedModel)
1993
- refreshRecords()
1994
- emitRefresh({ scope, paths: [path], kind: 'text' })
1995
- // 片段配置文件保存后失效补全缓存(下次补全即读到新片段)
1996
- if (/\.code-snippets$/i.test(path)) window.dispatchEvent(new CustomEvent('edrv:snippets-changed'))
1997
- return
1998
- }
1999
- if (res && res.conflict) {
2000
- // 磁盘已被外部改过:保留缓冲内容与脏标记,交给用户显式选择
2001
- markDiskFlag({ path, kind: 'conflict', at: Date.now() })
2002
- setStatus('保存被拒:文件已被外部修改')
2003
- return
2004
- }
2005
- setStatus('保存失败')
2006
- setError(res?.error ? String(res.error) : '保存失败')
2007
- }).catch((e) => { setStatus('保存失败'); setError('保存异常:' + String(e)) })
2008
- .finally(() => { savingRef.current.delete(path) })
2133
+ void saveDocument(active, !silent)
2009
2134
  }
2010
2135
  doSaveRef.current = doSave
2011
2136
 
@@ -2029,38 +2154,26 @@ export function EditorView(props) {
2029
2154
  if (savingRef.current.has(path)) continue
2030
2155
  const pdfCtl = pdfCtlRef.current.get(path)
2031
2156
  if (pdfCtl) { if (pdfCtl.isDirty()) void pdfCtl.savePdf(); continue }
2032
- const model = modelsRef.current.get(path)
2033
- if (!model) { setStatus('未保存的修改无法落盘:' + baseNameOf(path)); continue }
2034
- const content = model.getValue()
2035
- savingRef.current.add(path)
2036
- const rev = revRef.current[path]
2037
- rpc('edrv.save', { sessionId, path, content, rev: typeof rev === 'string' && rev ? rev : undefined })
2038
- .then((res) => {
2039
- if (res && res.ok) {
2040
- if (res.rev) revRef.current[path] = res.rev
2041
- recordBaseline(scope, path, res.rev)
2042
- markReadVersion(scope, path, res.rev, true) // 缓冲即磁盘内容:同版本无需再读盘
2043
- setDirtyMap((d) => Object.assign({}, d, { [path]: false }))
2044
- }
2045
- })
2046
- .catch((e) => dbg(sessionId, '关闭前落盘失败:' + path + ' · ' + String(e)))
2047
- .finally(() => { savingRef.current.delete(path) })
2157
+ void saveDocument(path, false)
2048
2158
  }
2049
2159
  }
2050
2160
 
2051
2161
  const onEdit = () => {
2052
2162
  const ed = editorRef.current
2053
- if (!ed || !active) return
2054
- setDirtyMap((d) => Object.assign({}, d, { [active]: true }))
2163
+ const model = ed?.getModel()
2164
+ const path = [...modelsRef.current].find(([, item]) => item === model)?.[0]
2165
+ if (!ed || !path) return
2166
+ setDirtyMap((d) => Object.assign({}, d, { [path]: true }))
2055
2167
  // 编辑即「使用」:需求 2 的淘汰口径是「最近没有改动」,故有改动时刷新 LRU 序号,
2056
2168
  // 使长时间只在某文件里编辑的页签不会被其它文件的打开动作挤出。
2057
- touchTab(active)
2169
+ touchTab(path)
2058
2170
  setStatus('编辑中…')
2059
2171
  // 外部改动尚未处理(冲突/文件被删):抑制自动保存,避免用陈旧缓冲反复覆盖磁盘;
2060
2172
  // 手工 Ctrl+S 不受抑制(doSave 会走版本守卫并给冲突提示)。
2061
- if (readSync(scope, active)) { setStatus('外部已修改(未保存的编辑保留)'); return }
2062
- // 重新计时(arm 内部先取消上一轮);到点自动保存,切页签/关闭前由 flushSave 立即提交
2063
- saveTimerRef.current?.arm(schedule, 700, () => doSave(true))
2173
+ const store = documents()
2174
+ store.bind(path, model, revRef.current[path])
2175
+ store.edit(path)
2176
+ if (readSync(scope, path)) setStatus('外部已修改(未保存的编辑保留)')
2064
2177
  }
2065
2178
  // @author ddj 2026年09月09号 空依赖监听只持有首帧 onEdit(active 恒 null→提前返回,星号与自动保存从未生效):每次渲染同步最新闭包
2066
2179
  onEditRef.current = onEdit
@@ -2082,7 +2195,18 @@ export function EditorView(props) {
2082
2195
  }, [monaco, scope, active, content, contentReady, mdPreviewing, svnDiff])
2083
2196
 
2084
2197
  // PDF 面板外壳 ref 回调(div 仅在 PDF 分支渲染,refs 先于 effect 就绪)
2085
- const ensurePdfHost = (node) => { pdfHostRef.current = node }
2198
+ /** @private @author ddj 2026年10月14号 @param path PDF document. @returns Stable panel container across groups and focus switches. */
2199
+ function pdfNode(path) {
2200
+ let node = pdfNodes.current.get(path)
2201
+ if (!node) { node = document.createElement('div'); node.className = 'edrv-pdf-host'; pdfNodes.current.set(path, node) }
2202
+ return node
2203
+ }
2204
+ const ensurePdfHost = (node) => {
2205
+ if (!node || !active) { pdfHostRef.current = null; return }
2206
+ const stable = pdfNode(active)
2207
+ node.appendChild(stable)
2208
+ pdfHostRef.current = stable
2209
+ }
2086
2210
 
2087
2211
  // PDF 面板装配:控制器挂进外壳 div,切换 tab/卸载即销毁(重开重挂,base64 缓存兜底秒切)
2088
2212
  React.useEffect(() => {
@@ -2099,6 +2223,7 @@ export function EditorView(props) {
2099
2223
  }
2100
2224
  /** @author ddj 2026年09月28号 End startup error attribution before later save operations. */
2101
2225
  const mounted = () => { mounting = false }
2226
+ if (pdfCtlRef.current.has(path)) { mounting = false; return }
2102
2227
  const ctl = createPdfPanel({
2103
2228
  sessionId,
2104
2229
  path,
@@ -2111,8 +2236,7 @@ export function EditorView(props) {
2111
2236
  void ctl.mount(host, pdfBytes).then(mounted, pdfError)
2112
2237
  return () => {
2113
2238
  mounting = false
2114
- ctl.destroy()
2115
- if (pdfCtlRef.current.get(path) === ctl) pdfCtlRef.current.delete(path)
2239
+ // The document owns this controller. Reparenting/focus changes must retain unsaved annotations.
2116
2240
  }
2117
2241
  }, [isPdfActive, pdfBytes, active, receiptLoad?.sequence])
2118
2242
 
@@ -2127,6 +2251,7 @@ export function EditorView(props) {
2127
2251
 
2128
2252
  React.useEffect(() => () => {
2129
2253
  flushSave()
2254
+ for (const store of docStores.current.values()) void store.flush().finally(() => store.dispose())
2130
2255
  // 卸载即切作用域/重挂载:先把待记录光标落进历史,再存当前文件视图状态
2131
2256
  //(经 ref 取最新闭包,修正旧实现捕获挂载时 active 的问题)
2132
2257
  flushNavCursor()
@@ -3287,13 +3412,35 @@ export function EditorView(props) {
3287
3412
  * @param path 右键目标页签路径
3288
3413
  * @returns id → 动作
3289
3414
  */
3415
+ /** @private @author ddj 2026年10月14号 @param path Menu target. @param kind Close operation. Close only tabs in the owning group, using visible order. */
3416
+ function closeInGroup(path, kind) {
3417
+ const group = ownerOf(benchRef.current, path)
3418
+ if (!group) return
3419
+ const actions = {
3420
+ others: () => closeOthers(group.tabs, path, group.active),
3421
+ right: () => closeRight(group.tabs, path, group.active),
3422
+ saved: () => closeSaved(group.tabs, dirtyRef.current, group.active),
3423
+ all: () => closeAll(group.tabs, group.active),
3424
+ }
3425
+ const result = actions[kind]?.()
3426
+ if (!result) return
3427
+ const kept = new Set(result.tabs.map((tab) => tab.path))
3428
+ const closing = new Set(group.tabs.filter((tab) => !kept.has(tab.path)).map((tab) => tab.path))
3429
+ closeTabs(applyClose(tabsRef.current, closing, activeRef.current), '已关闭编辑组页签')
3430
+ }
3290
3431
  const tabMenuActions = (path) => ({
3291
3432
  'add-to-conversation': () => addRefToChat(path),
3433
+ 'split-left': () => splitTab(path, 'left'),
3434
+ 'split-right': () => splitTab(path, 'right'),
3435
+ 'split-top': () => splitTab(path, 'top'),
3436
+ 'split-bottom': () => splitTab(path, 'bottom'),
3437
+ 'move-new-window': () => dropTab(path, { kind: 'float', rect: { left: 80, top: 80, width: 600, height: 400 } }),
3438
+ 'return-main': () => dropTab(path, { kind: 'group', group: MAIN_GROUP }),
3292
3439
  close: () => closeTab(path),
3293
- 'close-others': () => closeTabs(closeOthers(tabsRef.current, path, activeRef.current), '已关闭其他页签'),
3294
- 'close-right': () => closeTabs(closeRight(tabsRef.current, path, activeRef.current), '已关闭右侧页签'),
3295
- 'close-saved': () => closeTabs(closeSaved(tabsRef.current, dirtyRef.current, activeRef.current), '已关闭已保存页签'),
3296
- 'close-all': () => closeTabs(closeAll(tabsRef.current, activeRef.current), '已关闭全部页签'),
3440
+ 'close-others': () => closeInGroup(path, 'others'),
3441
+ 'close-right': () => closeInGroup(path, 'right'),
3442
+ 'close-saved': () => closeInGroup(path, 'saved'),
3443
+ 'close-all': () => closeInGroup(path, 'all'),
3297
3444
  'copy-path': () => copyText(absoluteOf(path, cwd), '已复制路径'),
3298
3445
  'copy-relative-path': () => copyText(relativeOf(path, cwd), '已复制相对路径'),
3299
3446
  'reveal-in-os': () => openInExplorer(path),
@@ -3313,12 +3460,15 @@ export function EditorView(props) {
3313
3460
  const changeMap = svnChangeMapOf(scope)
3314
3461
  return buildTabMenu({
3315
3462
  path: tabMenu.path,
3316
- tabs: tabsRef.current,
3317
- active: activeRef.current,
3463
+ tabs: ownerOf(workbench, tabMenu.path)?.tabs ?? tabsRef.current,
3464
+ active: ownerOf(workbench, tabMenu.path)?.active ?? activeRef.current,
3318
3465
  dirty: dirtyRef.current,
3319
3466
  cwd,
3320
3467
  hasSession: Boolean(sessionId),
3321
3468
  canAddToConversation: Boolean(addToConversation),
3469
+ canSplit: workbench.groups.length < GROUP_LIMIT && !workbench.floats.some((item) => item.group === ownerOf(workbench, tabMenu.path)?.id),
3470
+ canFloat: workbench.groups.length < GROUP_LIMIT,
3471
+ canReturn: ownerOf(workbench, tabMenu.path)?.id !== MAIN_GROUP,
3322
3472
  svnReady: Boolean(svnStatus?.managed && svnStatus?.svnCli),
3323
3473
  tortoiseReady: Boolean(svnStatus?.managed && svnStatus?.tortoise),
3324
3474
  // 自研替换时间线:自研就绪的能力对应 Tortoise 项不出现
@@ -3547,26 +3697,136 @@ export function EditorView(props) {
3547
3697
  }).catch((e) => { setStatus('打开失败'); setError('打开异常:' + String(e)) })
3548
3698
  }
3549
3699
 
3700
+ // --region Workbench drag and independent views
3701
+ /** @private @author ddj 2026年10月14号 @param group Group id. @param path Optional selection. Route global actions only to this group. */
3702
+ function selectGroup(group, path) {
3703
+ saveViewState()
3704
+ recordNav()
3705
+ const next = focusGroup(benchRef.current, group, path)
3706
+ setWorkbench(next)
3707
+ setPlanActive(null)
3708
+ setDiskActive(next.groups.find((item) => item.id === group)?.active ?? null)
3709
+ }
3710
+ /** @private @author ddj 2026年10月14号 @param next Atomic layout. Accept ownership without close/save side effects. */
3711
+ function acceptLayout(next) {
3712
+ saveViewState()
3713
+ setWorkbench(next)
3714
+ setPlanActive(null)
3715
+ setDiskActive(next.groups.find((group) => group.id === next.focused)?.active ?? null)
3716
+ }
3717
+ /** @private @author ddj 2026年10月14号 @param path File. @param edge Split direction. Move the file into a new group. */
3718
+ function splitTab(path, edge) {
3719
+ const group = ownerOf(benchRef.current, path)
3720
+ if (group) dropTab(path, { kind: 'split', group: group.id, edge })
3721
+ }
3722
+ /** @private @author ddj 2026年10月14号 @param path File. @param target Valid destination. Move only after acceptance. */
3723
+ function dropTab(path, target) {
3724
+ if (!ownerOf(benchRef.current, path)) return
3725
+ if (target.kind === 'reference') { addRefToChat(path); return }
3726
+ acceptLayout(moveTab(benchRef.current, path, target))
3727
+ }
3728
+ /** @private @author ddj 2026年10月14号 @param point Viewport pointer. @returns Current same-workbench geometry; ambiguous composers rejected. */
3729
+ function dragHit(point) {
3730
+ const root = viewRootRef.current
3731
+ const groups = [...(root?.querySelectorAll('[data-edrv-group]') ?? []), ...document.querySelectorAll('[data-edrv-float][data-edrv-owner="' + String(scope).replace(/["\\]/g, '') + '"] [data-edrv-group]')]
3732
+ const regions = groups.map((node) => ({
3733
+ group: node.getAttribute('data-edrv-group'), rect: node.getBoundingClientRect(), floating: !!node.closest('[data-edrv-float]'),
3734
+ tabRect: node.querySelector('[data-edrv-group-tabs]')?.getBoundingClientRect(),
3735
+ tabs: [...node.querySelectorAll('[data-edrv-tab-path]')].map((tab) => ({ path: tab.getAttribute('data-edrv-tab-path'), rect: tab.getBoundingClientRect() })),
3736
+ })).filter((region) => region.rect.width && region.rect.height).reverse()
3737
+ const scroll = root?.closest('[data-conversation-scroll]')
3738
+ const seats = [...(scroll ?? document).querySelectorAll('[data-composer-seat]')].filter((node) => { const rect = node.getBoundingClientRect(); return rect.width > 0 && rect.height > 0 })
3739
+ const element = document.elementFromPoint(point.x, point.y)
3740
+ const composer = seats.length === 1 && seats[0].contains(element) ? seats[0].getBoundingClientRect() : null
3741
+ const hitGroup = element?.closest('[data-edrv-group]')?.getAttribute('data-edrv-group')
3742
+ const visibleRegions = regions.filter((region) => region.group === hitGroup)
3743
+ const blank = !!element && !element.closest('button,input,textarea,a,[contenteditable],.monaco-editor,[role="dialog"]')
3744
+ && (element === document.body || element.matches('[data-conversation-scroll], [data-edrv-view], .edrv-editor-row, .edrv-workbench, .edrv-group-body'))
3745
+ return hitTarget({ point, viewport: { width: window.innerWidth, height: window.innerHeight }, regions: visibleRegions, composer, blank, allowCreate: benchRef.current.groups.length < GROUP_LIMIT })
3746
+ }
3747
+ benchOps.current = { hit: dragHit, drop: dropTab }
3748
+ React.useEffect(() => {
3749
+ const controller = createTabDrag({ hit: (point) => benchOps.current.hit(point), drop: (path, target) => benchOps.current.drop(path, target), preview: setDragPreview }, window)
3750
+ dragRef.current = controller
3751
+ return () => { controller.cancel(); if (dragRef.current === controller) dragRef.current = null }
3752
+ }, [])
3753
+ /** @private @author ddj 2026年10月14号 @param path Trusted file. @param event Tab press. */
3754
+ function dragTab(path, event) { dragRef.current?.start(path, event) }
3755
+ /** @private @author ddj 2026年10月14号 @returns Whether pointer release completed a drag. */
3756
+ function consumeDrag() { return dragRef.current?.consumeClick() ?? false }
3757
+ /** @private @author ddj 2026年10月14号 @param path File. @param event Context trigger. */
3758
+ function paneMenu(path, event) { setTabMenu({ path, x: event.clientX, y: event.clientY }) }
3759
+ /** @private @author ddj 2026年10月14号 @param group Auxiliary group. Return documents rather than closing them. */
3760
+ function returnPane(group) { acceptLayout(returnGroup(benchRef.current, group)) }
3761
+ /** @private @author ddj 2026年10月14号 @param group Float id. @param rect Viewport-safe bounds. */
3762
+ function floatBounds(group, rect) {
3763
+ setWorkbench((current) => ({ ...current, floats: current.floats.map((item) => item.group === group ? { ...rect, group } : item) }))
3764
+ }
3765
+ /** @private @author ddj 2026年10月14号 @param host Group body. Keep the full editor subtree stable while changing group/window ownership. */
3766
+ function mountFull(host) { if (host && fullHost.parentElement !== host) { host.appendChild(fullHost); editorRef.current?.layout() } }
3767
+ /** @private @author ddj 2026年10月14号 @param host Passive body. @param path Canonical file. @returns View-only cleanup, models remain workbench-owned. */
3768
+ function mountPassive(host, path) {
3769
+ let disposed = false, keeper = null, subscription = null, observer = null
3770
+ const focus = () => { const owner = ownerOf(benchRef.current, path); if (owner) selectGroup(owner.id, path) }
3771
+ host.addEventListener('pointerdown', focus)
3772
+ if (isPdfPath(path) && pdfNodes.current.has(path)) {
3773
+ host.appendChild(pdfNode(path))
3774
+ return () => host.removeEventListener('pointerdown', focus)
3775
+ }
3776
+ host.textContent = '加载中…'
3777
+ const binary = isImagePath(path) || isPdfPath(path)
3778
+ const cached = modelsRef.current.get(path)
3779
+ const request = binary ? readBinaryPreview(rpc, { sessionId, path }) : cached ? Promise.resolve({ ok: true, content: cached.getValue() }) : rpc('edrv.read', { sessionId, path })
3780
+ void request.then((result) => {
3781
+ if (disposed) return
3782
+ host.textContent = ''
3783
+ if (!result?.ok) { host.textContent = result?.error || '文件加载失败'; return }
3784
+ if (binary) {
3785
+ const image = document.createElement(isPdfPath(path) ? 'div' : 'img')
3786
+ if (isPdfPath(path)) image.textContent = 'PDF · 点击此编辑组继续查看和编辑'
3787
+ else { image.src = dataUrlOf(result.via === 'binary' ? bytesToBase64(result.bytes) : result.content, result.mime); image.alt = path; image.style.maxWidth = '100%' }
3788
+ host.appendChild(image)
3789
+ return
3790
+ }
3791
+ if (!window.monaco) { host.textContent = '编辑器正在加载'; return }
3792
+ const model = cached || getModel(path, result.content)
3793
+ if (result.version) { revRef.current[path] = result.version; recordBaseline(scope, path, result.version) }
3794
+ const editor = window.monaco.editor.create(host, { model, automaticLayout: true, minimap: { enabled: false }, scrollBeyondLastLine: false })
3795
+ keeper = createViewKeeper(editor)
3796
+ keeper.bind(scope, path, model)
3797
+ paneEditors.current.set(path, editor)
3798
+ const store = documents()
3799
+ store.bind(path, model, revRef.current[path])
3800
+ subscription = editor.onDidChangeModelContent(() => { if (!programmaticRef.current) store.edit(path) })
3801
+ observer = new ResizeObserver(() => editor.layout())
3802
+ observer.observe(host)
3803
+ }).catch((failure) => { if (!disposed) host.textContent = '文件加载失败:' + String(failure) })
3804
+ return () => { disposed = true; host.removeEventListener('pointerdown', focus); observer?.disconnect(); subscription?.dispose(); keeper?.dispose(); paneEditors.current.delete(path) }
3805
+ }
3806
+ const passiveOps = React.useRef(mountPassive)
3807
+ passiveOps.current = mountPassive
3808
+ const passiveMount = React.useCallback((host, path) => passiveOps.current(host, path), [])
3809
+ /** @private @author ddj 2026年10月14号 @param node Split branch. @param ratio Updated branch ratio. */
3810
+ function changeRatio(node, ratio) {
3811
+ const replace = (current) => current === node ? { ...current, ratio } : 'group' in current ? current : { ...current, first: replace(current.first), second: replace(current.second) }
3812
+ setWorkbench((current) => ({ ...current, tree: replace(current.tree) }))
3813
+ }
3814
+ /** @private @author ddj 2026年10月14号 @param id Leaf id. @returns Tabs and either focused full-feature editor or independent passive view. */
3815
+ function renderGroup(id) {
3816
+ const group = workbench.groups.find((item) => item.id === id)
3817
+ if (!group) return null
3818
+ const focused = id === workbench.focused
3819
+ return React.createElement(EditorPane, { key: id, group, focused, dirty: dirtyMap, onSelect: selectGroup, onClose: closeTab, onMenu: paneMenu, onDrag: dragTab, consumeClick: consumeDrag },
3820
+ focused ? React.createElement('div', { className: 'edrv-full-slot', ref: mountFull }) : React.createElement(PassiveEditor, { key: String(scope) + ':' + group.active, path: group.active, mount: passiveMount }))
3821
+ }
3822
+ /** @private @author ddj 2026年10月14号 @param node Layout subtree. @returns Recursive resizable panes. */
3823
+ function renderTree(node) {
3824
+ if ('group' in node) return renderGroup(node.group)
3825
+ return React.createElement(EditorSplit, { axis: node.axis, ratio: node.ratio, first: renderTree(node.first), second: renderTree(node.second), onRatio: (ratio) => changeRatio(node, ratio) })
3826
+ }
3827
+ // --endregion
3828
+
3550
3829
  const tabsEl = React.createElement('div', { className: 'edrv-tabs', ref: tabsHostRef, style: { flex: '1 1 auto', minWidth: 0 } },
3551
- tabs.map((t) => React.createElement('div', {
3552
- key: t.path,
3553
- className: 'edrv-tab' + (t.path === active ? ' edrv-tab-active' : '') + (t.pinned ? ' edrv-tab-pinned' : ''),
3554
- title: t.pinned ? t.path + '(已固定)' : t.path,
3555
- onClick: () => { if (t.path !== active) { flushSave(); saveViewState(active); recordNav(); setActive(t.path) } },
3556
- onContextMenu: (e) => {
3557
- e.preventDefault()
3558
- e.stopPropagation()
3559
- setTabMenu({ x: e.clientX, y: e.clientY, path: t.path })
3560
- },
3561
- },
3562
- // 固定页签在文件名前显示 📌 标记,且不渲染 × (与参考图一致:固定页签不显示关闭按钮)
3563
- // @author ddj 2026年09月11号
3564
- (t.pinned ? React.createElement('span', { className: 'edrv-tab-pin', title: '已固定', 'aria-label': '已固定' }, '📌') : null),
3565
- React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: 180 } }, t.path.split(/[\\/]/).pop() || t.path),
3566
- // 未保存修改页签显示 * 星号(保存成功消失、失败持续);替换原圆点脏标记
3567
- // @author ddj 2026年09月09号
3568
- (dirtyMap[t.path] ? React.createElement('span', { className: 'edrv-tab-star', title: '未保存修改' }, '*') : null),
3569
- (t.pinned ? null : React.createElement('span', { className: 'edrv-tab-x', title: '关闭', onClick: (e) => { e.stopPropagation(); closeTab(t.path) } }, '×')))),
3570
3830
  planTabs.map((plan) => React.createElement('div', {
3571
3831
  key: plan.address, className: 'edrv-tab' + (plan.address === planActive ? ' edrv-tab-active' : ''),
3572
3832
  title: plan.title + '(计划预览 · 只读)', onClick: () => openPlanDoc(plan),
@@ -4107,13 +4367,14 @@ export function EditorView(props) {
4107
4367
  React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', title: '复制安装命令', onClick: copyInstallCmd }, '复制命令'),
4108
4368
  React.createElement('button', { className: 'edrv-side-hint-close', title: '关闭提示', 'aria-label': '关闭提示', onClick: dismissHint }, '×'))
4109
4369
  : null
4110
- const mainCol = React.createElement('div', { className: 'edrv-main-col', style: { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
4111
- pathBar,
4112
- tabRow,
4113
- sideHintEl,
4114
- diskBanner(),
4115
- editorArea,
4116
- statusBar)
4370
+ const fullView = createPortal(React.createElement('div', { 'data-edrv-view': '1', style: { display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, minHeight: 0, overflow: 'hidden' } }, pathBar, sideHintEl, diskBanner(), editorArea, statusBar), fullHost, 'edrv-full-editor')
4371
+ const mainCol = React.createElement('div', { className: 'edrv-main-col edrv-workbench', style: { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
4372
+ React.createElement('div', { className: 'edrv-workbench-tools' }, tabRow),
4373
+ renderTree(workbench.tree), fullView,
4374
+ workbench.floats.map((item, layer) => React.createElement(FloatingEditor, { key: item.group, owner: String(scope), group: item.group, rect: item, layer, focused: item.group === workbench.focused, onBounds: floatBounds, onFocus: selectGroup, onReturn: returnPane }, renderGroup(item.group))),
4375
+ dragPreview ? createPortal(React.createElement('div', { 'data-edrv-view': '1', style: { display: 'contents', pointerEvents: 'none' } },
4376
+ dragPreview.rect ? React.createElement('div', { className: 'edrv-drag-overlay', style: dragPreview.rect }, React.createElement('span', { className: 'edrv-drop-label' }, dragPreview.label)) : null,
4377
+ React.createElement('div', { className: 'edrv-drag-ghost', style: { left: dragPreview.point.x + 12, top: dragPreview.point.y + 12 } }, baseNameOf(dragPreview.path) + ' · ' + dragPreview.label)), document.body) : null)
4117
4378
  // 编辑器根节点按 composer 顶部边界动态限高,底部对话区域继续由 DSH 原生渲染。
4118
4379
  // 侧栏形态由面板容器给高(100%),不做 composer 几何同步。
4119
4380
  const editorRow = React.createElement('div', { className: 'edrv-editor-row' },