dsh-vscode-mode 0.4.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车",
5
5
  "keywords": [
6
6
  "dsh",
@@ -54,6 +54,17 @@ export function emitRefresh(): void {
54
54
  window.dispatchEvent(new CustomEvent('edrv:refresh'))
55
55
  }
56
56
 
57
+ /**
58
+ * 磁盘文件变化(外部写入/删除):文件树按路径失效对应目录并强制重列。
59
+ * 与 edrv:refresh 分开:这里不触发差异记录重算与 stale 清理,只动目录缓存。
60
+ * @author ddj 2026年09月15号
61
+ * @param path 发生变化的文件路径
62
+ */
63
+ export function emitFileChanged(path: string): void {
64
+ if (!path) return
65
+ window.dispatchEvent(new CustomEvent('edrv:file-changed', { detail: { path } }))
66
+ }
67
+
57
68
  /** 打开指定路径到编辑区页签。 */
58
69
  export function emitOpenEditor(path: string): void {
59
70
  window.dispatchEvent(new CustomEvent('edrv:open-editor', { detail: { path } }))
@@ -28,6 +28,8 @@ const PREFETCH_EXCLUDED = new Set(['node_modules', '.git', '.hg', '.svn', '.pnpm
28
28
  const REVEAL_HIGHLIGHT_MS = 2000
29
29
  const REVEAL_RETRY_MAX = 6
30
30
  const REVEAL_RETRY_MS = 120
31
+ /** 外部文件变化重列去抖:一轮外部批量写入(如 agent 连写多文件)合并为一次重列。 */
32
+ const FILE_CHANGE_DEBOUNCE_MS = 400
31
33
 
32
34
  // --region 行图标(官方原语:目录文件夹图标 + 文件类型图标;缺失时回落纯文本)
33
35
 
@@ -125,6 +127,11 @@ export function FileExplorer(props) {
125
127
  const revealTryRef = React.useRef(0) // 当前定位的重试计数(行渲染需等目录加载)
126
128
  const revealInTreeRef = React.useRef(null) // 定位动作最新闭包(窗口监听读取)
127
129
  const treeRef = React.useRef(null) // 目录树容器(定位时按 data-edrv-path 查行)
130
+ const reloadDirRef = React.useRef(null) // loadDir 最新闭包(文件变化监听读取,防陈旧闭包)
131
+ const changedTimerRef = React.useRef(null) // 文件变化合并去抖计时器
132
+ const changedRelRef = React.useRef(new Set()) // 待重列的相对目录集合(去抖窗口内合并)
133
+ const dirsMapRef = React.useRef(null) // loadDir 最新闭包(文件变化监听读取,防陈旧闭包)
134
+ dirsMapRef.current = loadDir
128
135
 
129
136
  /** 渲染取数:内存态 → 本地条目缓存 → null(显示加载态)。 */
130
137
  const entriesOf = (rel) => dirsRef.current[rel] ?? entriesCacheGet(scope, rel) ?? null
@@ -261,6 +268,46 @@ export function FileExplorer(props) {
261
268
  void loadDir('', { force: true, prefetch: true })
262
269
  }
263
270
  refreshRef.current = refresh
271
+ reloadDirRef.current = loadDir
272
+
273
+ /**
274
+ * 磁盘文件变化(外部写入/删除/改名):把变化路径的父目录并入待重列集合,
275
+ * 去抖合并后对「已展开且存在」的目录强制重列(force 跳过 host 索引命中)。
276
+ * host 侧 fileVersions 已顺手失效目录树缓存,这里补上「已渲染行」的即时刷新。
277
+ * @author ddj 2026年09月15号
278
+ * @param path 发生变化的文件路径(工作区相对;绝对路径按最长已展开祖先匹配)
279
+ */
280
+ const onFileChanged = (path) => {
281
+ if (typeof path !== 'string' || !path) return
282
+ const rel = path.replace(/\\/g, '/').replace(/^\.\//, '')
283
+ const parts = rel.split('/').filter(Boolean)
284
+ if (parts.length > 1) changedRelRef.current.add(parts.slice(0, -1).join('/'))
285
+ // 绝对路径(或已在更深层目录):补上所有已展开的祖先目录
286
+ for (const dir of ancestorDirsOf(rel)) {
287
+ if (expandedRef.current[dir] === true) changedRelRef.current.add(dir)
288
+ }
289
+ if (changedTimerRef.current) return
290
+ changedTimerRef.current = window.setTimeout(() => {
291
+ changedTimerRef.current = null
292
+ const targets = [...changedRelRef.current]
293
+ changedRelRef.current.clear()
294
+ for (const dir of targets) {
295
+ if (dir !== '' && expandedRef.current[dir] !== true) continue
296
+ void reloadDirRef.current?.(dir, { force: true, prefetch: false })
297
+ }
298
+ }, FILE_CHANGE_DEBOUNCE_MS)
299
+ }
300
+
301
+ // edrv:file-changed(外部改动同步):按变化路径强制重列对应目录(去抖合并)
302
+ React.useEffect(() => {
303
+ const handler = (event) => onFileChanged(event?.detail?.path)
304
+ window.addEventListener('edrv:file-changed', handler)
305
+ return () => {
306
+ window.removeEventListener('edrv:file-changed', handler)
307
+ if (changedTimerRef.current) { clearTimeout(changedTimerRef.current); changedTimerRef.current = null }
308
+ }
309
+ // eslint-disable-next-line react-hooks/exhaustive-deps
310
+ }, [])
264
311
 
265
312
  React.useEffect(() => {
266
313
  tokensRef.current = {}
@@ -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)
@@ -1126,14 +1233,24 @@ export function EditorView(props) {
1126
1233
  * 提交待执行的防抖保存(**真正执行保存**,不是取消)。
1127
1234
  * 语义与缺陷背景见 saveDebounce.ts:`schedule` 的返回值是只 clearTimeout 的 disposer,
1128
1235
  * 旧实现把它当「立即保存」调用 → 防抖窗口内切页签/关闭文件会静默丢改动。
1129
- * @author ddj 2026年09月11号
1236
+ * 外部改动待处理(冲突/文件被删)时跳过:切页签/卸载不得用陈旧缓冲覆盖磁盘,
1237
+ * 缓冲内容仍在 model 里,用户处理完冲突后再保存。
1238
+ * @author ddj 2026年09月11号 / 2026年09月15号
1239
+ * @param path 目标路径(缺省 = 当前活动文件)
1240
+ * @returns 是否执行了保存
1130
1241
  */
1131
- const flushSave = () => {
1242
+ const flushSave = (path) => {
1243
+ const target = path ?? active
1244
+ if (target && readSync(scope, target)) return false
1132
1245
  saveTimerRef.current?.flush()
1246
+ return true
1133
1247
  }
1134
1248
 
1135
1249
  const doSave = (silent) => {
1136
1250
  if (!active) return
1251
+ // 外部改动待处理:自动保存(silent)直接放弃,避免陈旧缓冲覆盖磁盘;
1252
+ // 手工保存(Ctrl+S / 命令栏)继续走版本守卫,由 host 判定并给冲突提示。
1253
+ if (silent && readSync(scope, active)) { setStatus('外部已修改(未保存的编辑保留)'); return }
1137
1254
  // PDF tab:保存委托给面板控制器(saveDocument → base64 → edrv.saveBinary)
1138
1255
  if (isPdfPath(active)) {
1139
1256
  const ctl = pdfCtlRef.current.get(active)
@@ -1146,22 +1263,41 @@ export function EditorView(props) {
1146
1263
  if (!ed) return
1147
1264
  if (isImageActive) { setStatus('图片只读预览'); return }
1148
1265
  const text = ed.getValue()
1266
+ const path = active
1149
1267
  if (!silent) setStatus('保存中…')
1150
1268
  // 在途登记:关闭路径的 persistDirty 据此跳过重复提交(内容在同一 tick 内取,必然相同)
1151
- savingRef.current.add(active)
1152
- 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) => {
1153
1273
  if (res && res.ok) {
1154
- 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)
1155
1278
  setContent(text)
1156
- setContentPath(active)
1157
- 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
+ }
1158
1285
  refreshRecords()
1159
1286
  emitRefresh()
1160
1287
  // 片段配置文件保存后失效补全缓存(下次补全即读到新片段)
1161
- if (/\.code-snippets$/i.test(active)) window.dispatchEvent(new CustomEvent('edrv:snippets-changed'))
1162
- } 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) : '保存失败')
1163
1299
  }).catch((e) => { setStatus('保存失败'); setError('保存异常:' + String(e)) })
1164
- .finally(() => { savingRef.current.delete(active) })
1300
+ .finally(() => { savingRef.current.delete(path) })
1165
1301
  }
1166
1302
  doSaveRef.current = doSave
1167
1303
 
@@ -1172,12 +1308,15 @@ export function EditorView(props) {
1172
1308
  * 这里再处理**仍标脏**的页签 —— 包括活动页签(其保存可能在途或失败),
1173
1309
  * 用 model 里的当前文本补一次,保证关闭前内容一定写到磁盘。
1174
1310
  * 保存失败只保留脏标记(不丢用户编辑),并在状态栏给出提示。
1175
- * @author ddj 2026年09月11号
1311
+ * 外部改动待处理的页签跳过(版本守卫下必被拒绝;缓冲仍在 model 里,不丢内容)。
1312
+ * @author ddj 2026年09月11号 / 2026年09月15号
1176
1313
  * @param paths 即将关闭的页签路径
1177
1314
  */
1178
1315
  const persistDirty = (paths) => {
1179
1316
  for (const path of paths) {
1180
1317
  if (!dirtyRef.current[path]) continue
1318
+ // 外部改动未处理:不落盘(避免覆盖),脏内容仍保留在 model 中
1319
+ if (readSync(scope, path)) { setStatus('未落盘(外部已修改):' + baseNameOf(path)); continue }
1181
1320
  // 已有在途保存(flushSave 刚提交的防抖保存):同一 tick 内容必然一致,跳过重复提交
1182
1321
  if (savingRef.current.has(path)) continue
1183
1322
  const pdfCtl = pdfCtlRef.current.get(path)
@@ -1186,8 +1325,16 @@ export function EditorView(props) {
1186
1325
  if (!model) { setStatus('未保存的修改无法落盘:' + baseNameOf(path)); continue }
1187
1326
  const content = model.getValue()
1188
1327
  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 })) })
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
+ })
1191
1338
  .catch((e) => dbg(sessionId, '关闭前落盘失败:' + path + ' · ' + String(e)))
1192
1339
  .finally(() => { savingRef.current.delete(path) })
1193
1340
  }
@@ -1198,6 +1345,9 @@ export function EditorView(props) {
1198
1345
  if (!ed || !active) return
1199
1346
  setDirtyMap((d) => Object.assign({}, d, { [active]: true }))
1200
1347
  setStatus('编辑中…')
1348
+ // 外部改动尚未处理(冲突/文件被删):抑制自动保存,避免用陈旧缓冲反复覆盖磁盘;
1349
+ // 手工 Ctrl+S 不受抑制(doSave 会走版本守卫并给冲突提示)。
1350
+ if (readSync(scope, active)) { setStatus('外部已修改(未保存的编辑保留)'); return }
1201
1351
  // 重新计时(arm 内部先取消上一轮);到点自动保存,切页签/关闭前由 flushSave 立即提交
1202
1352
  saveTimerRef.current?.arm(schedule, 700, () => doSave(true))
1203
1353
  }
@@ -1497,12 +1647,73 @@ export function EditorView(props) {
1497
1647
  else openFile(sum.pendingFiles[0].path, true)
1498
1648
  }
1499
1649
 
1650
+ /**
1651
+ * 重新从磁盘加载当前文件(工具栏 ⟳ / 冲突提示「重新加载」/ 差异决策后刷新)。
1652
+ * 一律强制重读(跳过 model 复用):这才是「用户要看到磁盘真实内容」的语义。
1653
+ * @author ddj 2026年09月15号
1654
+ * @param skipStale 差异记录刷新是否跳过 stale 清理
1655
+ */
1500
1656
  const reloadFile = (skipStale) => {
1501
1657
  if (!active) return
1502
- 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))
1503
1662
  refreshRecords(skipStale === true)
1504
1663
  }
1505
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
+
1506
1717
  /**
1507
1718
  * SVG 在图片预览与文本编辑间切换(按路径记忆;重载分派随集合状态自动路由)。
1508
1719
  * @author ddj 2026年09月08号
@@ -1963,9 +2174,19 @@ export function EditorView(props) {
1963
2174
  React.createElement('span', { className: 'edrv-sp-lsp' }, aiLabel))
1964
2175
  : null
1965
2176
 
1966
- // 底部状态栏:单行多段——LSP 段居左(弹性吸收空闲宽度),AI 段固定右对齐
1967
- const statusBar = (lspSeg || aiSeg)
1968
- ? 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)
1969
2190
  : null
1970
2191
 
1971
2192
  // 导航历史按钮:目标条目名(tooltip 提示下一步会回到哪个文件)
@@ -1995,6 +2216,44 @@ export function EditorView(props) {
1995
2216
  React.createElement('button', { className: 'edrv-chip-btn', title: '刷新', onClick: reloadFile }, '⟳'),
1996
2217
  React.createElement(QuickOpen, { sessionId, onOpen: (p) => openFile(p, false) }))
1997
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
+
1998
2257
  const otherFiles = sum.pendingFiles.filter((f) => f.path !== active)
1999
2258
 
2000
2259
  /**
@@ -2192,6 +2451,7 @@ export function EditorView(props) {
2192
2451
  pathBar,
2193
2452
  tabRow,
2194
2453
  sideHintEl,
2454
+ diskBanner(),
2195
2455
  editorArea,
2196
2456
  statusBar)
2197
2457
  // 编辑器根节点按 composer 顶部边界动态限高,底部对话区域继续由 DSH 原生渲染。