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.
- package/README.md +22 -1
- package/lib/client.js +729 -44
- package/lib/client.js.map +1 -1
- package/lib/index.js +261 -33
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/diffDock.ts +12 -0
- package/src/client/events.ts +11 -0
- package/src/client/sidebar/panels/FileExplorer.ts +47 -0
- package/src/client/ui/DiffBox.ts +5 -3
- package/src/client/ui/EditorView.ts +317 -41
- package/src/client/ui/useFileWatch.ts +214 -0
- package/src/client/watchDecision.ts +274 -0
- package/src/compat.ts +1 -1
- package/src/dshVersion.ts +42 -10
- package/src/fileOpenSettings.ts +26 -2
- package/src/fileVersions.ts +167 -0
- package/src/index.ts +6 -1
- package/src/revert.ts +4 -2
- package/src/rpc.ts +62 -12
- package/src/shared/rpc.ts +25 -3
|
@@ -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
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
1150
|
-
|
|
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
|
-
|
|
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(
|
|
1155
|
-
setDirtyMap((d) => Object.assign({}, d, { [
|
|
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(
|
|
1160
|
-
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
|
|
1188
|
-
|
|
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
|
-
|
|
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 || !
|
|
1861
|
+
if (batchBusyRef.current || !decideRegions.length) return
|
|
1635
1862
|
batchBusyRef.current = true
|
|
1636
|
-
actMany(
|
|
1863
|
+
actMany(decideRegions.map((r) => itemOf(r, false))).then((result) => {
|
|
1637
1864
|
batchBusyRef.current = false
|
|
1638
1865
|
reloadFile(true)
|
|
1639
1866
|
emitRefresh()
|
|
1640
|
-
setStatus('已采纳 '
|
|
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 || !
|
|
1872
|
+
if (batchBusyRef.current || !decideRegions.length) return
|
|
1646
1873
|
batchBusyRef.current = true
|
|
1647
|
-
actMany([...
|
|
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('已不采纳 '
|
|
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((
|
|
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 ? '已不采纳 ' : '已采纳 '
|
|
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
|
-
//
|
|
1951
|
-
const
|
|
1952
|
-
? React.createElement('
|
|
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 原生渲染。
|