dsh-vscode-mode 0.4.3 → 0.4.5

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.
@@ -9,10 +9,20 @@
9
9
  */
10
10
  import React from 'react'
11
11
  import { dbg, rpc } from '../rpc.js'
12
- import { emitRefresh } from '../events.js'
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
15
  import { base64ToBytes, isPdfPath } from '../pdfPreview.js'
16
+ import {
17
+ clearBaseline,
18
+ clearReadVersion,
19
+ clearSync,
20
+ markReadVersion,
21
+ readSync,
22
+ recordBaseline,
23
+ type SyncFlag,
24
+ } from '../watchDecision.js'
25
+ import { useFileWatch } from './useFileWatch.js'
16
26
  import { createPdfPanel } from '../pdf/pdfPanel.js'
17
27
  import { applyOfficial, registerThemes, themeNameOf } from '../monaco/theme.js'
18
28
  import { createDiffRenderer } from '../monaco/diffRender.js'
@@ -185,6 +195,8 @@ export function EditorView(props) {
185
195
  activeRef.current = active
186
196
  const tabsRef = React.useRef([]) // 当前页签表的最新值(菜单动作按 id 分派时读,防陈旧闭包)
187
197
  tabsRef.current = tabs
198
+ const tabPathsRef = React.useRef([]) // 页签路径镜像(外部改动轮询读取)
199
+ tabPathsRef.current = tabs.map((t) => t.path)
188
200
  const dirtyRef = React.useRef({}) // 脏标记最新值(菜单禁用判定与关闭时落盘读)
189
201
  dirtyRef.current = dirtyMap
190
202
  const tabsHostRef = React.useRef(null) // 页签栏容器(切换后把当前页签滚入可见区)
@@ -203,6 +215,12 @@ export function EditorView(props) {
203
215
  const doSaveRef = React.useRef(null) // 保存动作的最新闭包(窗口级保存监听读取)
204
216
  const onEditRef = React.useRef(null) // 编辑置脏的最新闭包(Monaco 内容变化监听经 ref 调用,防首帧 active=null 陈旧闭包)
205
217
  const saveViewStateRef = React.useRef(null) // 视图状态保存的最新闭包(卸载清理读取,避免过期 active)
218
+ // 磁盘版本基线(path → 最后一次从磁盘读到的版本令牌):外部改动检测与保存护栏共用
219
+ const revRef = React.useRef({})
220
+ // 外部改动回调的最新闭包(轮询 hook 经 ref 调用,避免把轮询写进 React 依赖数组)
221
+ const diskChangeRef = React.useRef(null)
222
+ // 当前展示的外部同步提示(冲突/文件被删除;随 active 切换派生显示)
223
+ const [diskFlag, setDiskFlag] = React.useState(null)
206
224
  const diffRendererRef = React.useRef(null)
207
225
  const layoutRef = React.useRef(layout) // ensureEditor 空依赖闭包读取的稳定布局
208
226
  layoutRef.current = layout
@@ -380,6 +398,11 @@ export function EditorView(props) {
380
398
  const pdfCtl = pdfCtlRef.current.get(path)
381
399
  if (pdfCtl) { pdfCtl.destroy(); pdfCtlRef.current.delete(path) }
382
400
  pdfB64CacheRef.current.delete(path)
401
+ // 关闭即失去跟踪:清磁盘版本基线/已读台账/同步标记(重开时按最新内容重建)
402
+ clearBaseline(scope, path)
403
+ clearReadVersion(scope, path)
404
+ clearSync(scope, path)
405
+ delete revRef.current[path]
383
406
  }
384
407
 
385
408
  /**
@@ -474,21 +497,35 @@ export function EditorView(props) {
474
497
  /**
475
498
  * 加载文件内容(对齐 VSCode model 复用:会话内已打开的 model 直接秒显,
476
499
  * 后台静默 RPC 校验防陈旧;首次打开走原读取流程)。
477
- * @author ddj 2026年08月28号
500
+ * 每条成功分支都记下磁盘版本基线:外部改动轮询据此判断缓冲是否已陈旧,
501
+ * 保存时作为版本守卫令牌随 edrv.save 回传(读后被外部改过则拒绝写入)。
502
+ * @author ddj 2026年08月28号 / 2026年09月15号
478
503
  * @param path 文件路径
479
504
  * @param sid 会话 id
480
505
  * @param force 强制走 RPC(reloadFile 用,跳过 model 复用)
506
+ * @param version 已知磁盘版本(缺省走 RPC 返回值)
481
507
  */
482
- const loadContent = (path, sid, force) => {
508
+ const loadContent = (path, sid, force, version) => {
483
509
  const seq = ++loadSeqRef.current
510
+ /**
511
+ * 记下磁盘版本基线;markRead=true 表示本次真的从磁盘读了内容
512
+ * (已读版本台账随之失效,下一轮轮询按新版本重新比对)。
513
+ */
514
+ const mark = (ver, markRead) => {
515
+ if (typeof ver !== 'string' || !ver) return
516
+ revRef.current[path] = ver
517
+ recordBaseline(scope, path, ver)
518
+ clearSync(scope, path)
519
+ if (markRead === true) clearReadVersion(scope, path)
520
+ }
484
521
  // 图片文件走专用通道:base64 → data URL 预览,不建 Monaco model、不进文本/差异流程
485
522
  if (isImagePath(path) && !svgTextRef.current.has(path)) {
486
- loadImage(path, sid, seq)
523
+ loadImage(path, sid, seq, version)
487
524
  return
488
525
  }
489
526
  // PDF 文件走专用通道:base64 → PDF 面板(浏览 + 注释编辑),不建 Monaco model
490
527
  if (isPdfPath(path)) {
491
- loadPdf(path, sid, seq, force === true)
528
+ loadPdf(path, sid, seq, force === true, version)
492
529
  return
493
530
  }
494
531
  const cachedModel = force ? null : modelsRef.current.get(path)
@@ -500,6 +537,7 @@ export function EditorView(props) {
500
537
  setStatus('已加载')
501
538
  rpc('edrv.read', { sessionId: sid, path }).then((res) => {
502
539
  if (seq !== loadSeqRef.current || path !== active) return
540
+ if (res && res.ok) mark(res.version)
503
541
  if (res && res.ok && res.content !== cachedModel.getValue()) {
504
542
  saveViewState(path) // 内容更新前保留当前视图位置
505
543
  setContent(res.content)
@@ -513,6 +551,7 @@ export function EditorView(props) {
513
551
  rpc('edrv.read', { sessionId: sid, path }).then((res) => {
514
552
  if (seq !== loadSeqRef.current || path !== active) return
515
553
  if (res && res.ok) {
554
+ mark(res.version, true)
516
555
  setContent(res.content)
517
556
  setContentPath(path)
518
557
  setLoadStage((prev) => ({ progress: Math.max(84, prev.progress), message: '文件已读取,准备创建编辑器…' }))
@@ -536,12 +575,13 @@ export function EditorView(props) {
536
575
 
537
576
  /**
538
577
  * 加载图片文件为 data URL(编辑区只读预览);失败走 loadError 面板(重试经 loadContent 分派回此)。
539
- * @author ddj 2026年09月08号
578
+ * @author ddj 2026年09月08号 / 2026年09月15号
540
579
  * @param path 图片文件路径
541
580
  * @param sid 会话 id
542
581
  * @param seq 加载序号(过期响应丢弃)
582
+ * @param version 已知磁盘版本(图片同走外部改动轮询,读到新版本即重取)
543
583
  */
544
- const loadImage = (path, sid, seq) => {
584
+ const loadImage = (path, sid, seq, version) => {
545
585
  setImageSrc(null)
546
586
  setImgSize(null)
547
587
  setImgBroken(false)
@@ -555,6 +595,9 @@ export function EditorView(props) {
555
595
  setStatus('读取失败')
556
596
  return
557
597
  }
598
+ const imgVersion = res.version ?? version
599
+ recordBaseline(scope, path, imgVersion)
600
+ clearReadVersion(scope, path) // 刚读过内容:已读版本台账失效,下一轮按新版本比对
558
601
  setImageSrc(dataUrlOf(res.content, res.mime))
559
602
  setLoadStage({ progress: 100, message: '图片已就绪' })
560
603
  setStatus('已加载')
@@ -583,8 +626,9 @@ export function EditorView(props) {
583
626
  * @param sid 会话 id
584
627
  * @param seq 加载序号(过期响应丢弃)
585
628
  * @param force true=绕过缓存强制 RPC 重读(刷新按钮)
629
+ * @param version 已知磁盘版本(外部改动轮询触发时带入)
586
630
  */
587
- const loadPdf = (path, sid, seq, force) => {
631
+ const loadPdf = (path, sid, seq, force, version) => {
588
632
  setPdfBytes(null)
589
633
  setLoadStage({ progress: monaco ? 72 : 12, message: '读取 PDF…' })
590
634
  const cache = pdfB64CacheRef.current
@@ -601,6 +645,9 @@ export function EditorView(props) {
601
645
  rpc('edrv.read', { sessionId: sid, path, encoding: 'base64' }).then((res) => {
602
646
  if (seq !== loadSeqRef.current || path !== active) return
603
647
  if (res && res.ok && res.encoding === 'base64') {
648
+ const pdfVersion = res.version ?? version
649
+ recordBaseline(scope, path, pdfVersion)
650
+ clearReadVersion(scope, path) // 刚读过内容:已读版本台账失效,下一轮按新版本比对
604
651
  cache.set(path, res.content)
605
652
  while (cache.size > 6) cache.delete(cache.keys().next().value)
606
653
  setPdfBytes(base64ToBytes(res.content))
@@ -632,6 +679,66 @@ export function EditorView(props) {
632
679
  return () => { clearInterval(t); window.removeEventListener('edrv:refresh', onRefresh) }
633
680
  }, [sessionId])
634
681
 
682
+ /**
683
+ * 展示外部同步提示(按路径+类型去重:同一文件重复回调不重置已关闭的提示)。
684
+ * @author ddj 2026年09月15号
685
+ * @param flag 同步标记
686
+ */
687
+ const markDiskFlag = (flag) => {
688
+ setDiskFlag((prev) => (prev && prev.path === flag.path && prev.kind === flag.kind ? prev : flag))
689
+ }
690
+
691
+ /**
692
+ * 外部改动落地:干净缓冲直接刷入(含差异标记重算),脏缓冲只提示不覆盖。
693
+ * 经 ref 暴露给轮询 hook(hook 只负责观测,IO/UI 全在这里)。
694
+ * @author ddj 2026年09月15号
695
+ * @param change 轮询判定出的外部变更
696
+ */
697
+ const onDiskChange = (change) => {
698
+ if (!change || !change.path) return
699
+ // 文件树同步:目录缓存按该路径失效并强制重列(外部新增/删除/改名都能看到)
700
+ emitFileChanged(change.path)
701
+ if (change.kind === 'modified') {
702
+ // 干净缓冲:强制从磁盘重读(跳过 model 复用),差异标记随之重算
703
+ loadContent(change.path, sessionId, true)
704
+ clearSync(scope, change.path)
705
+ setDiskFlag((prev) => (prev && prev.path === change.path ? null : prev))
706
+ setStatus('已同步外部修改 ' + new Date().toTimeString().slice(0, 8))
707
+ emitRefresh()
708
+ return
709
+ }
710
+ const kind = change.kind === 'deleted' ? 'deleted' : 'conflict'
711
+ markDiskFlag(readSync(scope, change.path) ?? { path: change.path, kind, at: Date.now() })
712
+ setStatus(kind === 'deleted' ? '文件已被外部删除' : '外部已修改(未保存的编辑保留)')
713
+ }
714
+ diskChangeRef.current = onDiskChange
715
+
716
+ /**
717
+ * 版本变化时读磁盘并与缓冲比对:内容相同 → 只推进基线(不做无意义重载);
718
+ * 内容不同 → 由判定表决定自动刷入还是冲突提示。
719
+ * @author ddj 2026年09月15号
720
+ * @param path 文件路径
721
+ * @returns 比对结果;模型不可用/读失败 → null(按「需要重载」处理)
722
+ */
723
+ const readDiskCompare = (path) => {
724
+ const model = modelsRef.current?.get(path)
725
+ if (!model || typeof model.getValue !== 'function') return Promise.resolve(null)
726
+ return rpc('edrv.read', { sessionId, path }).then((res) => {
727
+ if (!res || !res.ok) return null
728
+ return { equal: res.content === model.getValue() }
729
+ }).catch(() => null)
730
+ }
731
+
732
+ // 外部改动轮询:观测交给 hook,动作落 onDiskChange(干净自动刷入 / 脏缓冲提示)
733
+ useFileWatch({
734
+ sessionId,
735
+ scope,
736
+ tabsRef: tabPathsRef,
737
+ dirtyRef,
738
+ onReadDisk: readDiskCompare,
739
+ onDiskChange: (change) => diskChangeRef.current?.(change),
740
+ })
741
+
635
742
  React.useEffect(() => {
636
743
  const publishLsp = (servers) => setLspServers(Array.isArray(servers) ? servers : [])
637
744
  const unsubscribe = onLspProgress(publishLsp)
@@ -1111,7 +1218,10 @@ export function EditorView(props) {
1111
1218
  let model = cache.get(path)
1112
1219
  if (!model) {
1113
1220
  // 注册表守卫:同 URI 已存在的 model 直接复用(HMR 换 bundle 后防重复创建抛错)
1114
- const uri = window.monaco.Uri.parse('edrv:///' + encodeURI(path))
1221
+ // edrv URI 装「工作区相对路径」(对齐 toEdrvUri 约定):绝对路径剥前导 /,
1222
+ // 否则 'edrv:///' + '/home/x' 拼出 edrv:////home/x(空 authority + // 开头)→
1223
+ // Monaco Uri.parse 抛 UriError → 官方侧栏/差异入口打开绝对路径文件白屏(issue #5/#6)
1224
+ const uri = window.monaco.Uri.parse('edrv:///' + encodeURI(String(path ?? '').replace(/^\/+/, '')))
1115
1225
  model = window.monaco.editor.getModel(uri) || window.monaco.editor.createModel(text ?? '', langOf(path), uri)
1116
1226
  rememberModel(cache, path, model)
1117
1227
  } else if (text !== undefined && model.getValue() !== text) {
@@ -1126,14 +1236,24 @@ export function EditorView(props) {
1126
1236
  * 提交待执行的防抖保存(**真正执行保存**,不是取消)。
1127
1237
  * 语义与缺陷背景见 saveDebounce.ts:`schedule` 的返回值是只 clearTimeout 的 disposer,
1128
1238
  * 旧实现把它当「立即保存」调用 → 防抖窗口内切页签/关闭文件会静默丢改动。
1129
- * @author ddj 2026年09月11号
1239
+ * 外部改动待处理(冲突/文件被删)时跳过:切页签/卸载不得用陈旧缓冲覆盖磁盘,
1240
+ * 缓冲内容仍在 model 里,用户处理完冲突后再保存。
1241
+ * @author ddj 2026年09月11号 / 2026年09月15号
1242
+ * @param path 目标路径(缺省 = 当前活动文件)
1243
+ * @returns 是否执行了保存
1130
1244
  */
1131
- const flushSave = () => {
1245
+ const flushSave = (path) => {
1246
+ const target = path ?? active
1247
+ if (target && readSync(scope, target)) return false
1132
1248
  saveTimerRef.current?.flush()
1249
+ return true
1133
1250
  }
1134
1251
 
1135
1252
  const doSave = (silent) => {
1136
1253
  if (!active) return
1254
+ // 外部改动待处理:自动保存(silent)直接放弃,避免陈旧缓冲覆盖磁盘;
1255
+ // 手工保存(Ctrl+S / 命令栏)继续走版本守卫,由 host 判定并给冲突提示。
1256
+ if (silent && readSync(scope, active)) { setStatus('外部已修改(未保存的编辑保留)'); return }
1137
1257
  // PDF tab:保存委托给面板控制器(saveDocument → base64 → edrv.saveBinary)
1138
1258
  if (isPdfPath(active)) {
1139
1259
  const ctl = pdfCtlRef.current.get(active)
@@ -1146,22 +1266,41 @@ export function EditorView(props) {
1146
1266
  if (!ed) return
1147
1267
  if (isImageActive) { setStatus('图片只读预览'); return }
1148
1268
  const text = ed.getValue()
1269
+ const path = active
1149
1270
  if (!silent) setStatus('保存中…')
1150
1271
  // 在途登记:关闭路径的 persistDirty 据此跳过重复提交(内容在同一 tick 内取,必然相同)
1151
- savingRef.current.add(active)
1152
- rpc('edrv.save', { sessionId, path: active, content: text }).then((res) => {
1272
+ savingRef.current.add(path)
1273
+ // 版本守卫令牌:上次读取/写入时的磁盘版本;与磁盘当前版本不符时 host 拒绝写入
1274
+ const rev = revRef.current[path]
1275
+ rpc('edrv.save', { sessionId, path, content: text, rev: typeof rev === 'string' && rev ? rev : undefined }).then((res) => {
1153
1276
  if (res && res.ok) {
1154
- setStatus('已保存 ' + new Date().toTimeString().slice(0, 8))
1277
+ if (res.rev) revRef.current[path] = res.rev
1278
+ recordBaseline(scope, path, res.rev)
1279
+ markReadVersion(scope, path, res.rev, true) // 缓冲即磁盘内容:同版本无需再读盘
1280
+ clearSync(scope, path)
1155
1281
  setContent(text)
1156
- setContentPath(active)
1157
- setDirtyMap((d) => Object.assign({}, d, { [active]: false }))
1282
+ setContentPath(path)
1283
+ setDirtyMap((d) => Object.assign({}, d, { [path]: false }))
1284
+ if (path === active) {
1285
+ setStatus('已保存 ' + new Date().toTimeString().slice(0, 8))
1286
+ setDiskFlag((prev) => (prev && prev.path === path ? null : prev))
1287
+ }
1158
1288
  refreshRecords()
1159
1289
  emitRefresh()
1160
1290
  // 片段配置文件保存后失效补全缓存(下次补全即读到新片段)
1161
- if (/\.code-snippets$/i.test(active)) window.dispatchEvent(new CustomEvent('edrv:snippets-changed'))
1162
- } else { setStatus('保存失败'); setError(res?.error ? String(res.error) : '保存失败') }
1291
+ if (/\.code-snippets$/i.test(path)) window.dispatchEvent(new CustomEvent('edrv:snippets-changed'))
1292
+ return
1293
+ }
1294
+ if (res && res.conflict) {
1295
+ // 磁盘已被外部改过:保留缓冲内容与脏标记,交给用户显式选择
1296
+ markDiskFlag({ path, kind: 'conflict', at: Date.now() })
1297
+ setStatus('保存被拒:文件已被外部修改')
1298
+ return
1299
+ }
1300
+ setStatus('保存失败')
1301
+ setError(res?.error ? String(res.error) : '保存失败')
1163
1302
  }).catch((e) => { setStatus('保存失败'); setError('保存异常:' + String(e)) })
1164
- .finally(() => { savingRef.current.delete(active) })
1303
+ .finally(() => { savingRef.current.delete(path) })
1165
1304
  }
1166
1305
  doSaveRef.current = doSave
1167
1306
 
@@ -1172,12 +1311,15 @@ export function EditorView(props) {
1172
1311
  * 这里再处理**仍标脏**的页签 —— 包括活动页签(其保存可能在途或失败),
1173
1312
  * 用 model 里的当前文本补一次,保证关闭前内容一定写到磁盘。
1174
1313
  * 保存失败只保留脏标记(不丢用户编辑),并在状态栏给出提示。
1175
- * @author ddj 2026年09月11号
1314
+ * 外部改动待处理的页签跳过(版本守卫下必被拒绝;缓冲仍在 model 里,不丢内容)。
1315
+ * @author ddj 2026年09月11号 / 2026年09月15号
1176
1316
  * @param paths 即将关闭的页签路径
1177
1317
  */
1178
1318
  const persistDirty = (paths) => {
1179
1319
  for (const path of paths) {
1180
1320
  if (!dirtyRef.current[path]) continue
1321
+ // 外部改动未处理:不落盘(避免覆盖),脏内容仍保留在 model 中
1322
+ if (readSync(scope, path)) { setStatus('未落盘(外部已修改):' + baseNameOf(path)); continue }
1181
1323
  // 已有在途保存(flushSave 刚提交的防抖保存):同一 tick 内容必然一致,跳过重复提交
1182
1324
  if (savingRef.current.has(path)) continue
1183
1325
  const pdfCtl = pdfCtlRef.current.get(path)
@@ -1186,8 +1328,16 @@ export function EditorView(props) {
1186
1328
  if (!model) { setStatus('未保存的修改无法落盘:' + baseNameOf(path)); continue }
1187
1329
  const content = model.getValue()
1188
1330
  savingRef.current.add(path)
1189
- rpc('edrv.save', { sessionId, path, content })
1190
- .then((res) => { if (res && res.ok) setDirtyMap((d) => Object.assign({}, d, { [path]: false })) })
1331
+ const rev = revRef.current[path]
1332
+ rpc('edrv.save', { sessionId, path, content, rev: typeof rev === 'string' && rev ? rev : undefined })
1333
+ .then((res) => {
1334
+ if (res && res.ok) {
1335
+ if (res.rev) revRef.current[path] = res.rev
1336
+ recordBaseline(scope, path, res.rev)
1337
+ markReadVersion(scope, path, res.rev, true) // 缓冲即磁盘内容:同版本无需再读盘
1338
+ setDirtyMap((d) => Object.assign({}, d, { [path]: false }))
1339
+ }
1340
+ })
1191
1341
  .catch((e) => dbg(sessionId, '关闭前落盘失败:' + path + ' · ' + String(e)))
1192
1342
  .finally(() => { savingRef.current.delete(path) })
1193
1343
  }
@@ -1198,6 +1348,9 @@ export function EditorView(props) {
1198
1348
  if (!ed || !active) return
1199
1349
  setDirtyMap((d) => Object.assign({}, d, { [active]: true }))
1200
1350
  setStatus('编辑中…')
1351
+ // 外部改动尚未处理(冲突/文件被删):抑制自动保存,避免用陈旧缓冲反复覆盖磁盘;
1352
+ // 手工 Ctrl+S 不受抑制(doSave 会走版本守卫并给冲突提示)。
1353
+ if (readSync(scope, active)) { setStatus('外部已修改(未保存的编辑保留)'); return }
1201
1354
  // 重新计时(arm 内部先取消上一轮);到点自动保存,切页签/关闭前由 flushSave 立即提交
1202
1355
  saveTimerRef.current?.arm(schedule, 700, () => doSave(true))
1203
1356
  }
@@ -1497,12 +1650,73 @@ export function EditorView(props) {
1497
1650
  else openFile(sum.pendingFiles[0].path, true)
1498
1651
  }
1499
1652
 
1653
+ /**
1654
+ * 重新从磁盘加载当前文件(工具栏 ⟳ / 冲突提示「重新加载」/ 差异决策后刷新)。
1655
+ * 一律强制重读(跳过 model 复用):这才是「用户要看到磁盘真实内容」的语义。
1656
+ * @author ddj 2026年09月15号
1657
+ * @param skipStale 差异记录刷新是否跳过 stale 清理
1658
+ */
1500
1659
  const reloadFile = (skipStale) => {
1501
1660
  if (!active) return
1502
- loadContent(active, sessionId, true)
1661
+ const path = active
1662
+ loadContent(path, sessionId, true)
1663
+ clearSync(scope, path)
1664
+ setDiskFlag((prev) => (prev && prev.path === path ? null : prev))
1503
1665
  refreshRecords(skipStale === true)
1504
1666
  }
1505
1667
 
1668
+ /**
1669
+ * 冲突处理:用编辑器内容覆盖磁盘(在缓冲内容为权威时用户显式选择)。
1670
+ * 覆盖不带版本令牌(host 无条件写入),成功后以返回的新版本重记基线。
1671
+ * @author ddj 2026年09月15号
1672
+ */
1673
+ const overwriteDisk = () => {
1674
+ const path = diskFlag?.path
1675
+ const model = path ? modelsRef.current.get(path) : null
1676
+ if (!path || !model) { setStatus('无法覆盖:文件未打开'); return }
1677
+ rpc('edrv.save', { sessionId, path, content: model.getValue() }).then((res) => {
1678
+ if (res && res.ok) {
1679
+ if (res.rev) revRef.current[path] = res.rev
1680
+ recordBaseline(scope, path, res.rev)
1681
+ markReadVersion(scope, path, res.rev, true) // 缓冲即磁盘内容:同版本无需再读盘
1682
+ clearSync(scope, path)
1683
+ setDirtyMap((d) => Object.assign({}, d, { [path]: false }))
1684
+ setDiskFlag((prev) => (prev && prev.path === path ? null : prev))
1685
+ setStatus('已覆盖磁盘')
1686
+ emitRefresh()
1687
+ return
1688
+ }
1689
+ setStatus('覆盖失败')
1690
+ setError(res?.error ? String(res.error) : '覆盖失败')
1691
+ }).catch((e) => setError('覆盖异常:' + String(e)))
1692
+ }
1693
+
1694
+ /**
1695
+ * 冲突处理:保留本地编辑(关掉提示),磁盘内容不取用。
1696
+ * 基线推进到磁盘当前版本,避免同一外部改动被反复提示;缓冲仍标脏,
1697
+ * 之后的手工保存会被版本守卫拒绝并再次给出选择。
1698
+ * @author ddj 2026年09月15号
1699
+ */
1700
+ const keepLocal = () => {
1701
+ const path = diskFlag?.path
1702
+ if (!path) return
1703
+ rpc('edrv.versions', { sessionId, paths: [path] }).then((res) => {
1704
+ const item = res && res.ok && Array.isArray(res.items) ? res.items[0] : null
1705
+ if (item && item.version) {
1706
+ recordBaseline(scope, path, item.version)
1707
+ // 该版本已判定为「与缓冲不同」:记入已读台账,避免下一轮轮询再读一次并重复弹提示
1708
+ markReadVersion(scope, path, item.version, false)
1709
+ revRef.current[path] = item.version
1710
+ }
1711
+ clearSync(scope, path)
1712
+ setDiskFlag((prev) => (prev && prev.path === path ? null : prev))
1713
+ setStatus('已保留本地编辑(磁盘内容未取用)')
1714
+ }).catch(() => {
1715
+ clearSync(scope, path)
1716
+ setDiskFlag(null)
1717
+ })
1718
+ }
1719
+
1506
1720
  /**
1507
1721
  * SVG 在图片预览与文本编辑间切换(按路径记忆;重载分派随集合状态自动路由)。
1508
1722
  * @author ddj 2026年09月08号
@@ -1963,9 +2177,19 @@ export function EditorView(props) {
1963
2177
  React.createElement('span', { className: 'edrv-sp-lsp' }, aiLabel))
1964
2178
  : null
1965
2179
 
1966
- // 底部状态栏:单行多段——LSP 段居左(弹性吸收空闲宽度),AI 段固定右对齐
1967
- const statusBar = (lspSeg || aiSeg)
1968
- ? React.createElement('div', { className: 'edrv-statusbar' }, lspSeg, aiSeg)
2180
+ // 外部改动段:仅当前文件有未处理的外部变更时出现(点「重新加载」即消解)
2181
+ const diskSeg = (active && diskFlag && sameFile(diskFlag.path, active))
2182
+ ? React.createElement('span', {
2183
+ className: 'edrv-status-seg',
2184
+ style: { display: 'inline-flex', alignItems: 'center', gap: '6px', minWidth: 0, flex: '0 0 auto', cursor: 'pointer' },
2185
+ title: '磁盘内容已被外部修改:重新加载 = 取磁盘版本;保留本地 = 继续编辑(保存时会被版本守卫拒绝)',
2186
+ onClick: () => reloadFile(),
2187
+ }, React.createElement('span', { className: 'edrv-sp-lsp' }, diskFlag.kind === 'deleted' ? '⚠ 外部已删除' : '⚠ 外部已修改'))
2188
+ : null
2189
+
2190
+ // 底部状态栏:单行多段——LSP 段居左(弹性吸收空闲宽度),外部改动段与 AI 段固定右对齐
2191
+ const statusBar = (lspSeg || aiSeg || diskSeg)
2192
+ ? React.createElement('div', { className: 'edrv-statusbar' }, lspSeg, diskSeg, aiSeg)
1969
2193
  : null
1970
2194
 
1971
2195
  // 导航历史按钮:目标条目名(tooltip 提示下一步会回到哪个文件)
@@ -1995,6 +2219,44 @@ export function EditorView(props) {
1995
2219
  React.createElement('button', { className: 'edrv-chip-btn', title: '刷新', onClick: reloadFile }, '⟳'),
1996
2220
  React.createElement(QuickOpen, { sessionId, onOpen: (p) => openFile(p, false) }))
1997
2221
 
2222
+ /**
2223
+ * 外部同步提示条(冲突 / 文件被删):仅在提示对象就是当前活动文件时显示。
2224
+ * 不复用 error 面板:内容照常可编辑,提示条只是把「磁盘已变、怎么处理」摆在眼前。
2225
+ * @author ddj 2026年09月15号
2226
+ * @returns 提示条元素或 null
2227
+ */
2228
+ const diskBanner = () => {
2229
+ const flag: SyncFlag | null = diskFlag && sameFile(diskFlag.path, active) ? diskFlag : null
2230
+ if (!flag) return null
2231
+ const deleted = flag.kind === 'deleted'
2232
+ const text = deleted
2233
+ ? '该文件已被外部删除(缓冲内容保留,保存会失败)'
2234
+ : '磁盘内容已被外部修改(当前有未保存编辑,未覆盖)'
2235
+ return React.createElement('div', {
2236
+ className: 'edrv-diskbar',
2237
+ style: {
2238
+ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0,
2239
+ padding: '4px 8px', fontSize: '12px',
2240
+ background: 'var(--dsw-alias-bg-layer-1, rgba(255,193,7,.12))',
2241
+ borderBottom: '1px solid var(--dsw-alias-border-l1, rgba(255,193,7,.35))',
2242
+ },
2243
+ },
2244
+ React.createElement('span', { title: flag.path }, '⚠ ' + text),
2245
+ React.createElement('span', { style: { flex: 1 } }),
2246
+ React.createElement('button', {
2247
+ className: 'edrv-pill edrv-pill-keep', title: '放弃缓冲内容,重新加载磁盘版本',
2248
+ onClick: () => reloadFile(),
2249
+ }, '重新加载'),
2250
+ (deleted ? null : React.createElement('button', {
2251
+ className: 'edrv-pill edrv-pill-undo', title: '用编辑器内容覆盖磁盘(放弃磁盘上的外部修改)',
2252
+ onClick: overwriteDisk,
2253
+ }, '覆盖磁盘')),
2254
+ (deleted ? null : React.createElement('button', {
2255
+ className: 'edrv-pill edrv-pill-ghost', title: '保留编辑器内容,不取用磁盘版本',
2256
+ onClick: keepLocal,
2257
+ }, '保留本地')))
2258
+ }
2259
+
1998
2260
  const otherFiles = sum.pendingFiles.filter((f) => f.path !== active)
1999
2261
 
2000
2262
  /**
@@ -2192,6 +2454,7 @@ export function EditorView(props) {
2192
2454
  pathBar,
2193
2455
  tabRow,
2194
2456
  sideHintEl,
2457
+ diskBanner(),
2195
2458
  editorArea,
2196
2459
  statusBar)
2197
2460
  // 编辑器根节点按 composer 顶部边界动态限高,底部对话区域继续由 DSH 原生渲染。
@@ -10,7 +10,7 @@
10
10
  import React from 'react'
11
11
  import { EditorView } from './EditorView.js'
12
12
  import { setSideEditorMounted } from '../sidebarBridge.js'
13
- import { editorMountEpoch, markEditorMounted, parseOfficialFileAddress, resolveNavLine, resolveNavOpen } from '../officialSidebar.js'
13
+ import { editorMountEpoch, markEditorActive, markEditorMounted, parseOfficialFileAddress, resolveNavLine, resolveNavOpen } from '../officialSidebar.js'
14
14
 
15
15
  /**
16
16
  * 官方侧边栏 Tab 正文组件(keyed slot sidebar.right.pane.tab 装配)。