dsh-vscode-mode 0.4.2 → 0.4.4

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
@@ -226,6 +244,8 @@ export function EditorView(props) {
226
244
  // useMemo 稳定引用:否则 hover 等重渲染会让 view zone effect 反复重建(- 号闪烁)
227
245
  const pendingRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && !r.stale), [regions])
228
246
  const staleRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && r.stale), [regions])
247
+ // 单文件 Keep/Undo 的作用域:可定位差异 + 无法定位的冲突差异(后者否则永久留在待处理列表)
248
+ const decideRegions = React.useMemo(() => [...pendingRegions, ...staleRegions], [pendingRegions, staleRegions])
229
249
  hoverRegionsRef.current = pendingRegions
230
250
  // 行 → 差异区域 映射(hover O(1) 命中;每行归属其区域)
231
251
  const lineRegionMap = React.useMemo(() => {
@@ -378,6 +398,11 @@ export function EditorView(props) {
378
398
  const pdfCtl = pdfCtlRef.current.get(path)
379
399
  if (pdfCtl) { pdfCtl.destroy(); pdfCtlRef.current.delete(path) }
380
400
  pdfB64CacheRef.current.delete(path)
401
+ // 关闭即失去跟踪:清磁盘版本基线/已读台账/同步标记(重开时按最新内容重建)
402
+ clearBaseline(scope, path)
403
+ clearReadVersion(scope, path)
404
+ clearSync(scope, path)
405
+ delete revRef.current[path]
381
406
  }
382
407
 
383
408
  /**
@@ -472,21 +497,35 @@ export function EditorView(props) {
472
497
  /**
473
498
  * 加载文件内容(对齐 VSCode model 复用:会话内已打开的 model 直接秒显,
474
499
  * 后台静默 RPC 校验防陈旧;首次打开走原读取流程)。
475
- * @author ddj 2026年08月28号
500
+ * 每条成功分支都记下磁盘版本基线:外部改动轮询据此判断缓冲是否已陈旧,
501
+ * 保存时作为版本守卫令牌随 edrv.save 回传(读后被外部改过则拒绝写入)。
502
+ * @author ddj 2026年08月28号 / 2026年09月15号
476
503
  * @param path 文件路径
477
504
  * @param sid 会话 id
478
505
  * @param force 强制走 RPC(reloadFile 用,跳过 model 复用)
506
+ * @param version 已知磁盘版本(缺省走 RPC 返回值)
479
507
  */
480
- const loadContent = (path, sid, force) => {
508
+ const loadContent = (path, sid, force, version) => {
481
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
+ }
482
521
  // 图片文件走专用通道:base64 → data URL 预览,不建 Monaco model、不进文本/差异流程
483
522
  if (isImagePath(path) && !svgTextRef.current.has(path)) {
484
- loadImage(path, sid, seq)
523
+ loadImage(path, sid, seq, version)
485
524
  return
486
525
  }
487
526
  // PDF 文件走专用通道:base64 → PDF 面板(浏览 + 注释编辑),不建 Monaco model
488
527
  if (isPdfPath(path)) {
489
- loadPdf(path, sid, seq, force === true)
528
+ loadPdf(path, sid, seq, force === true, version)
490
529
  return
491
530
  }
492
531
  const cachedModel = force ? null : modelsRef.current.get(path)
@@ -498,6 +537,7 @@ export function EditorView(props) {
498
537
  setStatus('已加载')
499
538
  rpc('edrv.read', { sessionId: sid, path }).then((res) => {
500
539
  if (seq !== loadSeqRef.current || path !== active) return
540
+ if (res && res.ok) mark(res.version)
501
541
  if (res && res.ok && res.content !== cachedModel.getValue()) {
502
542
  saveViewState(path) // 内容更新前保留当前视图位置
503
543
  setContent(res.content)
@@ -511,6 +551,7 @@ export function EditorView(props) {
511
551
  rpc('edrv.read', { sessionId: sid, path }).then((res) => {
512
552
  if (seq !== loadSeqRef.current || path !== active) return
513
553
  if (res && res.ok) {
554
+ mark(res.version, true)
514
555
  setContent(res.content)
515
556
  setContentPath(path)
516
557
  setLoadStage((prev) => ({ progress: Math.max(84, prev.progress), message: '文件已读取,准备创建编辑器…' }))
@@ -534,12 +575,13 @@ export function EditorView(props) {
534
575
 
535
576
  /**
536
577
  * 加载图片文件为 data URL(编辑区只读预览);失败走 loadError 面板(重试经 loadContent 分派回此)。
537
- * @author ddj 2026年09月08号
578
+ * @author ddj 2026年09月08号 / 2026年09月15号
538
579
  * @param path 图片文件路径
539
580
  * @param sid 会话 id
540
581
  * @param seq 加载序号(过期响应丢弃)
582
+ * @param version 已知磁盘版本(图片同走外部改动轮询,读到新版本即重取)
541
583
  */
542
- const loadImage = (path, sid, seq) => {
584
+ const loadImage = (path, sid, seq, version) => {
543
585
  setImageSrc(null)
544
586
  setImgSize(null)
545
587
  setImgBroken(false)
@@ -553,6 +595,9 @@ export function EditorView(props) {
553
595
  setStatus('读取失败')
554
596
  return
555
597
  }
598
+ const imgVersion = res.version ?? version
599
+ recordBaseline(scope, path, imgVersion)
600
+ clearReadVersion(scope, path) // 刚读过内容:已读版本台账失效,下一轮按新版本比对
556
601
  setImageSrc(dataUrlOf(res.content, res.mime))
557
602
  setLoadStage({ progress: 100, message: '图片已就绪' })
558
603
  setStatus('已加载')
@@ -581,8 +626,9 @@ export function EditorView(props) {
581
626
  * @param sid 会话 id
582
627
  * @param seq 加载序号(过期响应丢弃)
583
628
  * @param force true=绕过缓存强制 RPC 重读(刷新按钮)
629
+ * @param version 已知磁盘版本(外部改动轮询触发时带入)
584
630
  */
585
- const loadPdf = (path, sid, seq, force) => {
631
+ const loadPdf = (path, sid, seq, force, version) => {
586
632
  setPdfBytes(null)
587
633
  setLoadStage({ progress: monaco ? 72 : 12, message: '读取 PDF…' })
588
634
  const cache = pdfB64CacheRef.current
@@ -599,6 +645,9 @@ export function EditorView(props) {
599
645
  rpc('edrv.read', { sessionId: sid, path, encoding: 'base64' }).then((res) => {
600
646
  if (seq !== loadSeqRef.current || path !== active) return
601
647
  if (res && res.ok && res.encoding === 'base64') {
648
+ const pdfVersion = res.version ?? version
649
+ recordBaseline(scope, path, pdfVersion)
650
+ clearReadVersion(scope, path) // 刚读过内容:已读版本台账失效,下一轮按新版本比对
602
651
  cache.set(path, res.content)
603
652
  while (cache.size > 6) cache.delete(cache.keys().next().value)
604
653
  setPdfBytes(base64ToBytes(res.content))
@@ -630,6 +679,66 @@ export function EditorView(props) {
630
679
  return () => { clearInterval(t); window.removeEventListener('edrv:refresh', onRefresh) }
631
680
  }, [sessionId])
632
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
+
633
742
  React.useEffect(() => {
634
743
  const publishLsp = (servers) => setLspServers(Array.isArray(servers) ? servers : [])
635
744
  const unsubscribe = onLspProgress(publishLsp)
@@ -1124,14 +1233,24 @@ export function EditorView(props) {
1124
1233
  * 提交待执行的防抖保存(**真正执行保存**,不是取消)。
1125
1234
  * 语义与缺陷背景见 saveDebounce.ts:`schedule` 的返回值是只 clearTimeout 的 disposer,
1126
1235
  * 旧实现把它当「立即保存」调用 → 防抖窗口内切页签/关闭文件会静默丢改动。
1127
- * @author ddj 2026年09月11号
1236
+ * 外部改动待处理(冲突/文件被删)时跳过:切页签/卸载不得用陈旧缓冲覆盖磁盘,
1237
+ * 缓冲内容仍在 model 里,用户处理完冲突后再保存。
1238
+ * @author ddj 2026年09月11号 / 2026年09月15号
1239
+ * @param path 目标路径(缺省 = 当前活动文件)
1240
+ * @returns 是否执行了保存
1128
1241
  */
1129
- const flushSave = () => {
1242
+ const flushSave = (path) => {
1243
+ const target = path ?? active
1244
+ if (target && readSync(scope, target)) return false
1130
1245
  saveTimerRef.current?.flush()
1246
+ return true
1131
1247
  }
1132
1248
 
1133
1249
  const doSave = (silent) => {
1134
1250
  if (!active) return
1251
+ // 外部改动待处理:自动保存(silent)直接放弃,避免陈旧缓冲覆盖磁盘;
1252
+ // 手工保存(Ctrl+S / 命令栏)继续走版本守卫,由 host 判定并给冲突提示。
1253
+ if (silent && readSync(scope, active)) { setStatus('外部已修改(未保存的编辑保留)'); return }
1135
1254
  // PDF tab:保存委托给面板控制器(saveDocument → base64 → edrv.saveBinary)
1136
1255
  if (isPdfPath(active)) {
1137
1256
  const ctl = pdfCtlRef.current.get(active)
@@ -1144,22 +1263,41 @@ export function EditorView(props) {
1144
1263
  if (!ed) return
1145
1264
  if (isImageActive) { setStatus('图片只读预览'); return }
1146
1265
  const text = ed.getValue()
1266
+ const path = active
1147
1267
  if (!silent) setStatus('保存中…')
1148
1268
  // 在途登记:关闭路径的 persistDirty 据此跳过重复提交(内容在同一 tick 内取,必然相同)
1149
- savingRef.current.add(active)
1150
- rpc('edrv.save', { sessionId, path: active, content: text }).then((res) => {
1269
+ savingRef.current.add(path)
1270
+ // 版本守卫令牌:上次读取/写入时的磁盘版本;与磁盘当前版本不符时 host 拒绝写入
1271
+ const rev = revRef.current[path]
1272
+ rpc('edrv.save', { sessionId, path, content: text, rev: typeof rev === 'string' && rev ? rev : undefined }).then((res) => {
1151
1273
  if (res && res.ok) {
1152
- setStatus('已保存 ' + new Date().toTimeString().slice(0, 8))
1274
+ if (res.rev) revRef.current[path] = res.rev
1275
+ recordBaseline(scope, path, res.rev)
1276
+ markReadVersion(scope, path, res.rev, true) // 缓冲即磁盘内容:同版本无需再读盘
1277
+ clearSync(scope, path)
1153
1278
  setContent(text)
1154
- setContentPath(active)
1155
- setDirtyMap((d) => Object.assign({}, d, { [active]: false }))
1279
+ setContentPath(path)
1280
+ setDirtyMap((d) => Object.assign({}, d, { [path]: false }))
1281
+ if (path === active) {
1282
+ setStatus('已保存 ' + new Date().toTimeString().slice(0, 8))
1283
+ setDiskFlag((prev) => (prev && prev.path === path ? null : prev))
1284
+ }
1156
1285
  refreshRecords()
1157
1286
  emitRefresh()
1158
1287
  // 片段配置文件保存后失效补全缓存(下次补全即读到新片段)
1159
- if (/\.code-snippets$/i.test(active)) window.dispatchEvent(new CustomEvent('edrv:snippets-changed'))
1160
- } else { setStatus('保存失败'); setError(res?.error ? String(res.error) : '保存失败') }
1288
+ if (/\.code-snippets$/i.test(path)) window.dispatchEvent(new CustomEvent('edrv:snippets-changed'))
1289
+ return
1290
+ }
1291
+ if (res && res.conflict) {
1292
+ // 磁盘已被外部改过:保留缓冲内容与脏标记,交给用户显式选择
1293
+ markDiskFlag({ path, kind: 'conflict', at: Date.now() })
1294
+ setStatus('保存被拒:文件已被外部修改')
1295
+ return
1296
+ }
1297
+ setStatus('保存失败')
1298
+ setError(res?.error ? String(res.error) : '保存失败')
1161
1299
  }).catch((e) => { setStatus('保存失败'); setError('保存异常:' + String(e)) })
1162
- .finally(() => { savingRef.current.delete(active) })
1300
+ .finally(() => { savingRef.current.delete(path) })
1163
1301
  }
1164
1302
  doSaveRef.current = doSave
1165
1303
 
@@ -1170,12 +1308,15 @@ export function EditorView(props) {
1170
1308
  * 这里再处理**仍标脏**的页签 —— 包括活动页签(其保存可能在途或失败),
1171
1309
  * 用 model 里的当前文本补一次,保证关闭前内容一定写到磁盘。
1172
1310
  * 保存失败只保留脏标记(不丢用户编辑),并在状态栏给出提示。
1173
- * @author ddj 2026年09月11号
1311
+ * 外部改动待处理的页签跳过(版本守卫下必被拒绝;缓冲仍在 model 里,不丢内容)。
1312
+ * @author ddj 2026年09月11号 / 2026年09月15号
1174
1313
  * @param paths 即将关闭的页签路径
1175
1314
  */
1176
1315
  const persistDirty = (paths) => {
1177
1316
  for (const path of paths) {
1178
1317
  if (!dirtyRef.current[path]) continue
1318
+ // 外部改动未处理:不落盘(避免覆盖),脏内容仍保留在 model 中
1319
+ if (readSync(scope, path)) { setStatus('未落盘(外部已修改):' + baseNameOf(path)); continue }
1179
1320
  // 已有在途保存(flushSave 刚提交的防抖保存):同一 tick 内容必然一致,跳过重复提交
1180
1321
  if (savingRef.current.has(path)) continue
1181
1322
  const pdfCtl = pdfCtlRef.current.get(path)
@@ -1184,8 +1325,16 @@ export function EditorView(props) {
1184
1325
  if (!model) { setStatus('未保存的修改无法落盘:' + baseNameOf(path)); continue }
1185
1326
  const content = model.getValue()
1186
1327
  savingRef.current.add(path)
1187
- rpc('edrv.save', { sessionId, path, content })
1188
- .then((res) => { if (res && res.ok) setDirtyMap((d) => Object.assign({}, d, { [path]: false })) })
1328
+ const rev = revRef.current[path]
1329
+ rpc('edrv.save', { sessionId, path, content, rev: typeof rev === 'string' && rev ? rev : undefined })
1330
+ .then((res) => {
1331
+ if (res && res.ok) {
1332
+ if (res.rev) revRef.current[path] = res.rev
1333
+ recordBaseline(scope, path, res.rev)
1334
+ markReadVersion(scope, path, res.rev, true) // 缓冲即磁盘内容:同版本无需再读盘
1335
+ setDirtyMap((d) => Object.assign({}, d, { [path]: false }))
1336
+ }
1337
+ })
1189
1338
  .catch((e) => dbg(sessionId, '关闭前落盘失败:' + path + ' · ' + String(e)))
1190
1339
  .finally(() => { savingRef.current.delete(path) })
1191
1340
  }
@@ -1196,6 +1345,9 @@ export function EditorView(props) {
1196
1345
  if (!ed || !active) return
1197
1346
  setDirtyMap((d) => Object.assign({}, d, { [active]: true }))
1198
1347
  setStatus('编辑中…')
1348
+ // 外部改动尚未处理(冲突/文件被删):抑制自动保存,避免用陈旧缓冲反复覆盖磁盘;
1349
+ // 手工 Ctrl+S 不受抑制(doSave 会走版本守卫并给冲突提示)。
1350
+ if (readSync(scope, active)) { setStatus('外部已修改(未保存的编辑保留)'); return }
1199
1351
  // 重新计时(arm 内部先取消上一轮);到点自动保存,切页签/关闭前由 flushSave 立即提交
1200
1352
  saveTimerRef.current?.arm(schedule, 700, () => doSave(true))
1201
1353
  }
@@ -1495,12 +1647,73 @@ export function EditorView(props) {
1495
1647
  else openFile(sum.pendingFiles[0].path, true)
1496
1648
  }
1497
1649
 
1650
+ /**
1651
+ * 重新从磁盘加载当前文件(工具栏 ⟳ / 冲突提示「重新加载」/ 差异决策后刷新)。
1652
+ * 一律强制重读(跳过 model 复用):这才是「用户要看到磁盘真实内容」的语义。
1653
+ * @author ddj 2026年09月15号
1654
+ * @param skipStale 差异记录刷新是否跳过 stale 清理
1655
+ */
1498
1656
  const reloadFile = (skipStale) => {
1499
1657
  if (!active) return
1500
- loadContent(active, sessionId, true)
1658
+ const path = active
1659
+ loadContent(path, sessionId, true)
1660
+ clearSync(scope, path)
1661
+ setDiskFlag((prev) => (prev && prev.path === path ? null : prev))
1501
1662
  refreshRecords(skipStale === true)
1502
1663
  }
1503
1664
 
1665
+ /**
1666
+ * 冲突处理:用编辑器内容覆盖磁盘(在缓冲内容为权威时用户显式选择)。
1667
+ * 覆盖不带版本令牌(host 无条件写入),成功后以返回的新版本重记基线。
1668
+ * @author ddj 2026年09月15号
1669
+ */
1670
+ const overwriteDisk = () => {
1671
+ const path = diskFlag?.path
1672
+ const model = path ? modelsRef.current.get(path) : null
1673
+ if (!path || !model) { setStatus('无法覆盖:文件未打开'); return }
1674
+ rpc('edrv.save', { sessionId, path, content: model.getValue() }).then((res) => {
1675
+ if (res && res.ok) {
1676
+ if (res.rev) revRef.current[path] = res.rev
1677
+ recordBaseline(scope, path, res.rev)
1678
+ markReadVersion(scope, path, res.rev, true) // 缓冲即磁盘内容:同版本无需再读盘
1679
+ clearSync(scope, path)
1680
+ setDirtyMap((d) => Object.assign({}, d, { [path]: false }))
1681
+ setDiskFlag((prev) => (prev && prev.path === path ? null : prev))
1682
+ setStatus('已覆盖磁盘')
1683
+ emitRefresh()
1684
+ return
1685
+ }
1686
+ setStatus('覆盖失败')
1687
+ setError(res?.error ? String(res.error) : '覆盖失败')
1688
+ }).catch((e) => setError('覆盖异常:' + String(e)))
1689
+ }
1690
+
1691
+ /**
1692
+ * 冲突处理:保留本地编辑(关掉提示),磁盘内容不取用。
1693
+ * 基线推进到磁盘当前版本,避免同一外部改动被反复提示;缓冲仍标脏,
1694
+ * 之后的手工保存会被版本守卫拒绝并再次给出选择。
1695
+ * @author ddj 2026年09月15号
1696
+ */
1697
+ const keepLocal = () => {
1698
+ const path = diskFlag?.path
1699
+ if (!path) return
1700
+ rpc('edrv.versions', { sessionId, paths: [path] }).then((res) => {
1701
+ const item = res && res.ok && Array.isArray(res.items) ? res.items[0] : null
1702
+ if (item && item.version) {
1703
+ recordBaseline(scope, path, item.version)
1704
+ // 该版本已判定为「与缓冲不同」:记入已读台账,避免下一轮轮询再读一次并重复弹提示
1705
+ markReadVersion(scope, path, item.version, false)
1706
+ revRef.current[path] = item.version
1707
+ }
1708
+ clearSync(scope, path)
1709
+ setDiskFlag((prev) => (prev && prev.path === path ? null : prev))
1710
+ setStatus('已保留本地编辑(磁盘内容未取用)')
1711
+ }).catch(() => {
1712
+ clearSync(scope, path)
1713
+ setDiskFlag(null)
1714
+ })
1715
+ }
1716
+
1504
1717
  /**
1505
1718
  * SVG 在图片预览与文本编辑间切换(按路径记忆;重载分派随集合状态自动路由)。
1506
1719
  * @author ddj 2026年09月08号
@@ -1591,21 +1804,23 @@ export function EditorView(props) {
1591
1804
  * 单次 setRecords 合并全部结果,避免逐条往返读写整个 sidecar。
1592
1805
  * @author ddj 2026年08月25号
1593
1806
  * @param items 决策项数组(callId/scope/hunkIndex/decision)
1594
- * @returns Promise<{ok:number; fail:number}> 成功/失败计数
1807
+ * @returns Promise<{ok:number; fail:number; stale:number}> 成功/失败/已不存在计数
1595
1808
  */
1596
1809
  const actMany = (items) => {
1597
- if (!items.length) return Promise.resolve({ ok: 0, fail: 0 })
1810
+ if (!items.length) return Promise.resolve({ ok: 0, fail: 0, stale: 0 })
1598
1811
  return rpc('edrv.decideBatch', { sessionId, items }).then((res) => {
1599
1812
  if (!res || !res.ok || !Array.isArray(res.results)) {
1600
1813
  setError(res?.error ? String(res.error) : '批量处理失败')
1601
- return { ok: 0, fail: items.length }
1814
+ return { ok: 0, fail: items.length, stale: 0 }
1602
1815
  }
1603
1816
  let ok = 0
1604
1817
  let fail = 0
1818
+ let stale = 0
1605
1819
  const next = {}
1606
1820
  for (const item of res.results) {
1607
1821
  if (item && item.ok) {
1608
1822
  ok++
1823
+ if (item.stale === true) stale++
1609
1824
  if (item.record) next[item.callId] = item.record
1610
1825
  } else {
1611
1826
  fail++
@@ -1614,13 +1829,25 @@ export function EditorView(props) {
1614
1829
  // 合并结果无变化(批量全部失败/重复点击)时不 setRecords:避免 records 换引用
1615
1830
  // → pendingRegions 换引用 → diff 全量重渲染(内容相同,纯空转)
1616
1831
  if (Object.keys(next).length) setRecords((prev) => Object.assign({}, prev, next))
1617
- return { ok, fail }
1832
+ return { ok, fail, stale }
1618
1833
  }).catch((e) => {
1619
1834
  setError('批量处理异常:' + String(e))
1620
- return { ok: 0, fail: items.length }
1835
+ return { ok: 0, fail: items.length, stale: 0 }
1621
1836
  })
1622
1837
  }
1623
1838
 
1839
+ /**
1840
+ * 批量决策状态文案(含"差异已不存在于文件"提示)。
1841
+ * @author ddj 2026年09月15号
1842
+ * @param prefix 动作前缀(已采纳/已不采纳)
1843
+ * @param result 批量决策计数
1844
+ * @returns 状态栏文案
1845
+ */
1846
+ const decideStatus = (prefix, result) => {
1847
+ const text = prefix + result.ok + ' 处差异' + (result.fail ? ',' + result.fail + ' 处失败' : '')
1848
+ return result.stale ? text + '(其中 ' + result.stale + ' 处已不存在于文件,未改动)' : text
1849
+ }
1850
+
1624
1851
  /**
1625
1852
  * 决策项构造(与单条 actHunk 的 scope 语义一致:create 记录走 call 作用域)。
1626
1853
  * @author ddj 2026年08月25号
@@ -1631,25 +1858,25 @@ export function EditorView(props) {
1631
1858
  const itemOf = (r, reject) => ({ callId: r.callId, scope: r.create ? 'call' : 'hunk', hunkIndex: r.idx, decision: reject ? 'rejected' : 'accepted' })
1632
1859
 
1633
1860
  const acceptFile = () => {
1634
- if (batchBusyRef.current || !pendingRegions.length) return
1861
+ if (batchBusyRef.current || !decideRegions.length) return
1635
1862
  batchBusyRef.current = true
1636
- actMany(pendingRegions.map((r) => itemOf(r, false))).then(({ ok, fail }) => {
1863
+ actMany(decideRegions.map((r) => itemOf(r, false))).then((result) => {
1637
1864
  batchBusyRef.current = false
1638
1865
  reloadFile(true)
1639
1866
  emitRefresh()
1640
- setStatus('已采纳 ' + ok + ' 处差异' + (fail ? ',' + fail + ' 处失败' : ''))
1641
- if (fail) setError(fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
1867
+ setStatus(decideStatus('已采纳 ', result))
1868
+ if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
1642
1869
  }).catch(() => { batchBusyRef.current = false })
1643
1870
  }
1644
1871
  const undoFile = () => {
1645
- if (batchBusyRef.current || !pendingRegions.length) return
1872
+ if (batchBusyRef.current || !decideRegions.length) return
1646
1873
  batchBusyRef.current = true
1647
- actMany([...pendingRegions].reverse().map((r) => itemOf(r, true))).then(({ ok, fail }) => {
1874
+ actMany([...decideRegions].reverse().map((r) => itemOf(r, true))).then((result) => {
1648
1875
  batchBusyRef.current = false
1649
1876
  reloadFile(true)
1650
1877
  emitRefresh()
1651
- setStatus('已不采纳 ' + ok + ' 处差异' + (fail ? ',' + fail + ' 处失败' : ''))
1652
- if (fail) setError(fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
1878
+ setStatus(decideStatus('已不采纳 ', result))
1879
+ if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
1653
1880
  }).catch(() => { batchBusyRef.current = false })
1654
1881
  }
1655
1882
 
@@ -1677,12 +1904,12 @@ export function EditorView(props) {
1677
1904
  if (batchBusyRef.current || !allPending.length) return
1678
1905
  batchBusyRef.current = true
1679
1906
  const list = reject ? [...allPending].sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : b.idx - a.idx)) : allPending
1680
- actMany(list.map((r) => itemOf(r, reject))).then(({ ok, fail }) => {
1907
+ actMany(list.map((r) => itemOf(r, reject))).then((result) => {
1681
1908
  batchBusyRef.current = false
1682
1909
  reloadFile(true)
1683
1910
  emitRefresh()
1684
- setStatus((reject ? '已不采纳 ' : '已采纳 ') + ok + ' 处差异' + (fail ? ',' + fail + ' 处失败' : ''))
1685
- if (fail) setError(fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
1911
+ setStatus(decideStatus(reject ? '已不采纳 ' : '已采纳 ', result))
1912
+ if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
1686
1913
  }).catch(() => { batchBusyRef.current = false })
1687
1914
  }
1688
1915
  const acceptAllFiles = () => actAllPending(false)
@@ -1947,9 +2174,19 @@ export function EditorView(props) {
1947
2174
  React.createElement('span', { className: 'edrv-sp-lsp' }, aiLabel))
1948
2175
  : null
1949
2176
 
1950
- // 底部状态栏:单行多段——LSP 段居左(弹性吸收空闲宽度),AI 段固定右对齐
1951
- const statusBar = (lspSeg || aiSeg)
1952
- ? React.createElement('div', { className: 'edrv-statusbar' }, lspSeg, aiSeg)
2177
+ // 外部改动段:仅当前文件有未处理的外部变更时出现(点「重新加载」即消解)
2178
+ const diskSeg = (active && diskFlag && sameFile(diskFlag.path, active))
2179
+ ? React.createElement('span', {
2180
+ className: 'edrv-status-seg',
2181
+ style: { display: 'inline-flex', alignItems: 'center', gap: '6px', minWidth: 0, flex: '0 0 auto', cursor: 'pointer' },
2182
+ title: '磁盘内容已被外部修改:重新加载 = 取磁盘版本;保留本地 = 继续编辑(保存时会被版本守卫拒绝)',
2183
+ onClick: () => reloadFile(),
2184
+ }, React.createElement('span', { className: 'edrv-sp-lsp' }, diskFlag.kind === 'deleted' ? '⚠ 外部已删除' : '⚠ 外部已修改'))
2185
+ : null
2186
+
2187
+ // 底部状态栏:单行多段——LSP 段居左(弹性吸收空闲宽度),外部改动段与 AI 段固定右对齐
2188
+ const statusBar = (lspSeg || aiSeg || diskSeg)
2189
+ ? React.createElement('div', { className: 'edrv-statusbar' }, lspSeg, diskSeg, aiSeg)
1953
2190
  : null
1954
2191
 
1955
2192
  // 导航历史按钮:目标条目名(tooltip 提示下一步会回到哪个文件)
@@ -1979,6 +2216,44 @@ export function EditorView(props) {
1979
2216
  React.createElement('button', { className: 'edrv-chip-btn', title: '刷新', onClick: reloadFile }, '⟳'),
1980
2217
  React.createElement(QuickOpen, { sessionId, onOpen: (p) => openFile(p, false) }))
1981
2218
 
2219
+ /**
2220
+ * 外部同步提示条(冲突 / 文件被删):仅在提示对象就是当前活动文件时显示。
2221
+ * 不复用 error 面板:内容照常可编辑,提示条只是把「磁盘已变、怎么处理」摆在眼前。
2222
+ * @author ddj 2026年09月15号
2223
+ * @returns 提示条元素或 null
2224
+ */
2225
+ const diskBanner = () => {
2226
+ const flag: SyncFlag | null = diskFlag && sameFile(diskFlag.path, active) ? diskFlag : null
2227
+ if (!flag) return null
2228
+ const deleted = flag.kind === 'deleted'
2229
+ const text = deleted
2230
+ ? '该文件已被外部删除(缓冲内容保留,保存会失败)'
2231
+ : '磁盘内容已被外部修改(当前有未保存编辑,未覆盖)'
2232
+ return React.createElement('div', {
2233
+ className: 'edrv-diskbar',
2234
+ style: {
2235
+ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0,
2236
+ padding: '4px 8px', fontSize: '12px',
2237
+ background: 'var(--dsw-alias-bg-layer-1, rgba(255,193,7,.12))',
2238
+ borderBottom: '1px solid var(--dsw-alias-border-l1, rgba(255,193,7,.35))',
2239
+ },
2240
+ },
2241
+ React.createElement('span', { title: flag.path }, '⚠ ' + text),
2242
+ React.createElement('span', { style: { flex: 1 } }),
2243
+ React.createElement('button', {
2244
+ className: 'edrv-pill edrv-pill-keep', title: '放弃缓冲内容,重新加载磁盘版本',
2245
+ onClick: () => reloadFile(),
2246
+ }, '重新加载'),
2247
+ (deleted ? null : React.createElement('button', {
2248
+ className: 'edrv-pill edrv-pill-undo', title: '用编辑器内容覆盖磁盘(放弃磁盘上的外部修改)',
2249
+ onClick: overwriteDisk,
2250
+ }, '覆盖磁盘')),
2251
+ (deleted ? null : React.createElement('button', {
2252
+ className: 'edrv-pill edrv-pill-ghost', title: '保留编辑器内容,不取用磁盘版本',
2253
+ onClick: keepLocal,
2254
+ }, '保留本地')))
2255
+ }
2256
+
1982
2257
  const otherFiles = sum.pendingFiles.filter((f) => f.path !== active)
1983
2258
 
1984
2259
  /**
@@ -2176,6 +2451,7 @@ export function EditorView(props) {
2176
2451
  pathBar,
2177
2452
  tabRow,
2178
2453
  sideHintEl,
2454
+ diskBanner(),
2179
2455
  editorArea,
2180
2456
  statusBar)
2181
2457
  // 编辑器根节点按 composer 顶部边界动态限高,底部对话区域继续由 DSH 原生渲染。