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.
- package/README.md +18 -1
- package/lib/client.js +737 -40
- package/lib/client.js.map +1 -1
- package/lib/index.js +240 -21
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/events.ts +11 -0
- package/src/client/monaco/loader.ts +55 -9
- package/src/client/monaco/lsp/lspClient.ts +5 -1
- package/src/client/sidebar/panels/FileExplorer.ts +47 -0
- package/src/client/ui/EditorView.ts +289 -26
- package/src/client/ui/OfficialSideTab.ts +1 -1
- 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/rpc.ts +50 -7
- package/src/shared/rpc.ts +23 -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
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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(
|
|
1152
|
-
|
|
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
|
-
|
|
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(
|
|
1157
|
-
setDirtyMap((d) => Object.assign({}, d, { [
|
|
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(
|
|
1162
|
-
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1967
|
-
const
|
|
1968
|
-
? React.createElement('
|
|
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 装配)。
|