dsh-vscode-mode 0.11.0 → 0.13.0

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.
Files changed (76) hide show
  1. package/README.md +158 -111
  2. package/assets/shell/dsh-open.cs +65 -149
  3. package/assets/shell/dsh-open.mjs +241 -0
  4. package/assets/shell/dsh-open.ps1 +28 -47
  5. package/assets/shell/dsh-open.sh +25 -66
  6. package/lib/client.js +13362 -11806
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +4110 -2507
  9. package/lib/index.js.map +1 -1
  10. package/package.json +9 -8
  11. package/skills/dsh-vscodemode-mcp/SKILL.md +94 -132
  12. package/src/childEnv.ts +19 -0
  13. package/src/client/activePathStore.ts +28 -0
  14. package/src/client/addToConversation.ts +4 -2
  15. package/src/client/ai/inlineProvider.ts +1 -8
  16. package/src/client/commandBridge.ts +14 -42
  17. package/src/client/externalOpen.ts +40 -19
  18. package/src/client/index.ts +101 -18
  19. package/src/client/keybindings.ts +36 -73
  20. package/src/client/monaco/loader.ts +3 -1
  21. package/src/client/openFlow.ts +36 -28
  22. package/src/client/openReceipt.ts +64 -0
  23. package/src/client/panelCommands.ts +101 -0
  24. package/src/client/pdf/pdfLoader.ts +4 -2
  25. package/src/client/receiptState.ts +120 -0
  26. package/src/client/rpc.ts +30 -4
  27. package/src/client/shortcutRecorder.ts +161 -0
  28. package/src/client/shortcutsOfficial.ts +460 -0
  29. package/src/client/sidebar/panels/RulesPanel.ts +15 -11
  30. package/src/client/sidebarBridge.ts +7 -0
  31. package/src/client/ui/DiffBadge.ts +2 -1
  32. package/src/client/ui/EditorView.ts +246 -137
  33. package/src/client/ui/McpSettings.ts +10 -8
  34. package/src/client/ui/OfficialSideTab.ts +5 -2
  35. package/src/client/ui/QuickOpen.ts +16 -16
  36. package/src/client/ui/ShortcutSettings.ts +274 -0
  37. package/src/client/ui/commandCatalog.ts +39 -43
  38. package/src/client/useOpenReceipt.ts +128 -0
  39. package/src/compat.ts +5 -3
  40. package/src/dap/manager.ts +46 -13
  41. package/src/devForm.ts +252 -92
  42. package/src/externalHandoff.ts +93 -52
  43. package/src/fileOpenSettings.ts +4 -24
  44. package/src/hostImport.ts +19 -0
  45. package/src/httpGuard.ts +101 -0
  46. package/src/index.ts +106 -97
  47. package/src/integrate.ts +56 -50
  48. package/src/lsp/transport.ts +84 -127
  49. package/src/mcp.ts +58 -9
  50. package/src/mcpIsolation.ts +211 -102
  51. package/src/mcpProject.ts +220 -133
  52. package/src/mcpRuntime.ts +343 -0
  53. package/src/openBridge.ts +69 -0
  54. package/src/openInbox.ts +207 -0
  55. package/src/paths.ts +8 -5
  56. package/src/processTree.ts +63 -0
  57. package/src/reveal.ts +5 -3
  58. package/src/routes.ts +156 -154
  59. package/src/rpc.ts +14 -13
  60. package/src/rules.ts +100 -44
  61. package/src/shared/appUrl.ts +19 -0
  62. package/src/shared/externalOpen.ts +71 -0
  63. package/src/shared/integration.ts +2 -0
  64. package/src/shared/keybindings.ts +256 -0
  65. package/src/shared/mcp.ts +4 -1
  66. package/src/shared/rpc.ts +7 -4
  67. package/src/shared/rules.ts +9 -1
  68. package/src/shared/svn.ts +6 -8
  69. package/src/svnPlanTool.ts +51 -0
  70. package/src/unityBridge.ts +26 -3
  71. package/unity/com.dsh.editor/Editor/DshCodeEditor.cs +99 -203
  72. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs +166 -0
  73. package/unity/com.dsh.editor/Editor/DshOpenWorker.cs.meta +2 -0
  74. package/unity/com.dsh.editor/README.md +16 -25
  75. package/unity/com.dsh.editor/package.json +2 -2
  76. package/src/client/ui/KeybindingsPanel.ts +0 -190
@@ -8,11 +8,15 @@
8
8
  * 作者 ddj 2026-08-20
9
9
  */
10
10
  import React from 'react'
11
+ import { useOpenReceipt } from '../useOpenReceipt.js'
12
+ import { acceptsLoad, receiptErrorFor, receiptPath, receiptReadyFor, receiptTabPath } from '../receiptState.js'
13
+ import { bindPanelCommand } from '../panelCommands.js'
11
14
  import { dbg, rpc } from '../rpc.js'
12
15
  import { emitFileChanged, emitRefresh } from '../events.js'
13
16
  import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
14
17
  import { clampZoom, clearZoomMem, dataUrlOf, isImagePath, isSvgPath, recallZoom, rememberZoom, zoomKeyOf, zoomStepOf } from '../imagePreview.js'
15
18
  import { isMarkdownPath } from '../markdownPreview.js'
19
+ import { setActiveEditorPath } from '../activePathStore.js'
16
20
  import { MarkdownPanel } from '../md/mdPanel.js'
17
21
  import { base64ToBytes, bytesToBase64, isPdfPath } from '../pdfPreview.js'
18
22
  import { inNativePath, tryNativeOpen } from '../nativeOpenStore.js'
@@ -49,7 +53,7 @@ import { upsertViewState, viewStatesLoad, viewStatesSave } from '../state/viewSt
49
53
  import { migrateScopedKeys, workspaceScopeOf } from '../state/scopeStore.js'
50
54
  import { modelsForScope, rememberModel } from '../state/modelCache.js'
51
55
  import { navFlashRangeOf } from '../navHighlight.js'
52
- import { bindingsOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybindings.js'
56
+ import { chordOf, useKeybindingsVersion } from '../keybindings.js'
53
57
  import { getSidebarMinWidth } from '../sidebarMin.js'
54
58
  import { navHistoryFor } from '../navHistory.js'
55
59
  import { statusOfAdd } from '../addToConversation.js'
@@ -234,6 +238,7 @@ export function EditorView(props) {
234
238
  const [dirtyMap, setDirtyMap] = React.useState({})
235
239
  const [content, setContent] = React.useState(null)
236
240
  const [contentPath, setContentPath] = React.useState(null)
241
+ const [receiptLoad, setReceiptLoad] = React.useState(null) // { sessionId, path, sequence, status, error }
237
242
  const [imageSrc, setImageSrc] = React.useState(null) // 图片预览 data URL(图片 tab 专用,文本 tab 恒为 null)
238
243
  const [imgSize, setImgSize] = React.useState(null) // 图片自然尺寸 { w, h }(路径栏 meta)
239
244
  const [imgBroken, setImgBroken] = React.useState(false) // 图片解码失败(onError),显示占位与重试
@@ -335,6 +340,9 @@ export function EditorView(props) {
335
340
  const rowNavMoveRef = React.useRef(false) // 本次光标变化是否由整行移动触发(否则清空期望列)
336
341
  const activeRef = React.useRef(null) // 当前活动文件的最新值(空依赖闭包/指令回调读取)
337
342
  activeRef.current = active
343
+ // 活动文件路径镜像到模块级 store:命令目录可用性判定读取(如 Markdown 预览切换仅 .md 吞键)
344
+ setActiveEditorPath(active)
345
+ React.useEffect(() => () => setActiveEditorPath(null), [])
338
346
  const tabsRef = React.useRef([]) // 当前页签表的最新值(菜单动作按 id 分派时读,防陈旧闭包)
339
347
  tabsRef.current = tabs
340
348
  const tabPathsRef = React.useRef([]) // 页签路径镜像(外部改动轮询读取)
@@ -800,6 +808,87 @@ export function EditorView(props) {
800
808
  }).catch((e) => setError('list异常:' + String(e)))
801
809
  }
802
810
 
811
+ /**
812
+ * Reject callbacks for a replaced file or loader generation.
813
+ * @private @author ddj 2026年09月28号
814
+ * @param path Normalized tab path.
815
+ * @param sequence Loader generation.
816
+ * @returns Whether this load still owns the active view.
817
+ */
818
+ function loadCurrent(path, sequence) {
819
+ return sequence === loadSeqRef.current && path === activeRef.current
820
+ }
821
+
822
+ /**
823
+ * Commit receipt metadata in the same React batch as loaded content.
824
+ * @private @author ddj 2026年09月28号
825
+ * @param path Loaded tab path.
826
+ * @param sid Session owning the operation.
827
+ * @param sequence Loader generation.
828
+ * @param failure Optional target-specific read/render error.
829
+ */
830
+ function stampLoad(path, sid, sequence, failure) {
831
+ if (!loadCurrent(path, sequence)) return
832
+ setReceiptLoad({ path, cwd, sessionId: sid, sequence, status: failure ? 'error' : 'ready', error: failure })
833
+ }
834
+
835
+ /**
836
+ * Report a read failure without attributing another operation's error to the file.
837
+ * @private @author ddj 2026年09月28号
838
+ * @param path Failed tab path.
839
+ * @param sid Session owning the load.
840
+ * @param sequence Loader generation.
841
+ * @param message Read failure description.
842
+ */
843
+ function failLoad(path, sid, sequence, message) {
844
+ if (!loadCurrent(path, sequence)) return
845
+ stampLoad(path, sid, sequence, message)
846
+ setLoadError(message)
847
+ setError(message)
848
+ setStatus('读取失败')
849
+ }
850
+
851
+ /**
852
+ * Attribute a preview decoder failure only to its still-current byte generation.
853
+ * @private @author ddj 2026年09月28号
854
+ * @param path Rendered file path.
855
+ * @param sid Rendering session.
856
+ * @param sequence Byte-load generation captured by the renderer.
857
+ * @param message Decoder failure.
858
+ */
859
+ function renderLoadFail(path, sid, sequence, message) {
860
+ if (!loadCurrent(path, sequence)) return
861
+ /** @author ddj 2026年09月28号 @param current Committed load state. @returns Correlated failure or unchanged state. */
862
+ const reject = (current) => acceptsLoad(current, sid, path, sequence, loadSeqRef.current)
863
+ ? { ...current, status: 'error', error: String(message) } : current
864
+ setReceiptLoad(reject)
865
+ }
866
+
867
+ /**
868
+ * Revalidate clean cached text without replacing edits made during the request.
869
+ * @private @author ddj 2026年09月28号
870
+ * @param model Cached text model.
871
+ * @param path Normalized target path.
872
+ * @param sid Session owning the read.
873
+ * @param sequence Loader generation.
874
+ * @param mark Existing disk-version bookkeeping callback.
875
+ */
876
+ function checkCached(model, path, sid, sequence, mark) {
877
+ if (dirtyRef.current[path]) return
878
+ const before = model.getValue()
879
+ /** @author ddj 2026年09月28号 @param res Disk content for a still-clean cached model. */
880
+ const accept = (res) => {
881
+ if (!loadCurrent(path, sequence) || dirtyRef.current[path] || model.getValue() !== before) return
882
+ if (!res?.ok) return
883
+ mark(res.version)
884
+ if (res.content === before) return
885
+ saveViewState(path)
886
+ setContent(res.content)
887
+ setStatus('已加载')
888
+ }
889
+ rpc('edrv.read', { sessionId: sid, path }).then(accept).catch(() => {})
890
+ }
891
+
803
892
  /**
804
893
  * 加载文件内容(对齐 VSCode model 复用:会话内已打开的 model 直接秒显,
805
894
  * 后台静默 RPC 校验防陈旧;首次打开走原读取流程)。
@@ -813,6 +902,8 @@ export function EditorView(props) {
813
902
  */
814
903
  const loadContent = (path, sid, force, version) => {
815
904
  const seq = ++loadSeqRef.current
905
+ setReceiptLoad({ path, cwd, sessionId: sid, sequence: seq, status: 'loading' })
906
+ setLoadError(null)
816
907
  /**
817
908
  * 记下磁盘版本基线;markRead=true 表示本次真的从磁盘读了内容
818
909
  * (已读版本台账随之失效,下一轮轮询按新版本重新比对)。
@@ -841,25 +932,18 @@ export function EditorView(props) {
841
932
  setContentPath(path)
842
933
  setLoadStage((prev) => ({ progress: Math.max(84, prev.progress), message: '文件已读取,准备创建编辑器…' }))
843
934
  setStatus('已加载')
844
- rpc('edrv.read', { sessionId: sid, path }).then((res) => {
845
- if (seq !== loadSeqRef.current || path !== active) return
846
- if (res && res.ok) mark(res.version)
847
- if (res && res.ok && res.content !== cachedModel.getValue()) {
848
- saveViewState(path) // 内容更新前保留当前视图位置
849
- setContent(res.content)
850
- setStatus('已加载')
851
- }
852
- }).catch(() => { /* 校验失败保留缓存内容 */ })
935
+ stampLoad(path, sid, seq)
936
+ checkCached(cachedModel, path, sid, seq, mark)
853
937
  return
854
938
  }
855
- setLoadError(null)
856
939
  setLoadStage({ progress: monaco ? 72 : 12, message: '读取文件内容…' })
857
940
  rpc('edrv.read', { sessionId: sid, path }).then((res) => {
858
- if (seq !== loadSeqRef.current || path !== active) return
941
+ if (!loadCurrent(path, seq)) return
859
942
  if (res && res.ok) {
860
943
  mark(res.version, true)
861
944
  setContent(res.content)
862
945
  setContentPath(path)
946
+ stampLoad(path, sid, seq)
863
947
  setLoadStage((prev) => ({ progress: Math.max(84, prev.progress), message: '文件已读取,准备创建编辑器…' }))
864
948
  setStatus('已加载')
865
949
  } else {
@@ -867,16 +951,12 @@ export function EditorView(props) {
867
951
  dapTrace('read-failed', { path, error: base, resolvedPath: res?.resolvedPath, cwd })
868
952
  // 带出 host 解析后的真实路径:跳转失败时一眼看出是路径解析错还是目标不存在
869
953
  const message = res?.resolvedPath ? base + ':' + relativeOf(String(res.resolvedPath), cwd) : base
870
- setLoadError(message)
871
- setError(message)
872
- setStatus('读取失败')
954
+ failLoad(path, sid, seq, message)
873
955
  }
874
956
  }).catch((e) => {
875
- if (seq !== loadSeqRef.current) return
957
+ if (!loadCurrent(path, seq)) return
876
958
  const message = 'read异常:' + String(e)
877
- setLoadError(message)
878
- setError(message)
879
- setStatus('读取失败')
959
+ failLoad(path, sid, seq, message)
880
960
  })
881
961
  }
882
962
 
@@ -894,12 +974,13 @@ export function EditorView(props) {
894
974
  setImgBroken(false)
895
975
  setLoadStage({ progress: monaco ? 72 : 12, message: '读取图片…' })
896
976
  readBinaryPreview(rpc, { sessionId: sid, path }).then((out) => {
897
- if (seq !== loadSeqRef.current || path !== active) return
977
+ if (!loadCurrent(path, seq)) return
898
978
  if (out.ok && out.via === 'binary') {
899
979
  // 新通道:原始字节本地组 data URL(线上省 base64,本地编码为 img src 所需不可省)
900
980
  recordBaseline(scope, path, out.version || version)
901
981
  clearReadVersion(scope, path) // 刚读过内容:已读版本台账失效,下一轮按新版本比对
902
982
  setImageSrc(dataUrlOf(bytesToBase64(out.bytes), out.mime))
983
+ stampLoad(path, sid, seq)
903
984
  setLoadStage({ progress: 100, message: '图片已就绪' })
904
985
  setStatus('已加载')
905
986
  return
@@ -907,15 +988,14 @@ export function EditorView(props) {
907
988
  noteBinFallback(sid, out)
908
989
  if (out.ok && out.via === 'base64') {
909
990
  if (!out.mime) {
910
- setLoadError('host 版本过旧:图片响应缺少 mime')
911
- setError('host 版本过旧:图片响应缺少 mime')
912
- setStatus('读取失败')
991
+ failLoad(path, sid, seq, 'host 版本过旧:图片响应缺少 mime')
913
992
  return
914
993
  }
915
994
  const imgVersion = out.version ?? version
916
995
  recordBaseline(scope, path, imgVersion)
917
996
  clearReadVersion(scope, path) // 刚读过内容:已读版本台账失效,下一轮按新版本比对
918
997
  setImageSrc(dataUrlOf(out.content, out.mime))
998
+ stampLoad(path, sid, seq)
919
999
  setLoadStage({ progress: 100, message: '图片已就绪' })
920
1000
  setStatus('已加载')
921
1001
  return
@@ -923,15 +1003,11 @@ export function EditorView(props) {
923
1003
  const base = out.error ? String(out.error) : '读取失败'
924
1004
  // 带出 host 解析后的真实路径:跳转失败时一眼看出是路径解析错还是目标不存在
925
1005
  const message = out.resolvedPath ? base + ':' + String(out.resolvedPath) : base
926
- setLoadError(message)
927
- setError(message)
928
- setStatus('读取失败')
1006
+ failLoad(path, sid, seq, message)
929
1007
  }).catch((e) => {
930
- if (seq !== loadSeqRef.current) return
1008
+ if (!loadCurrent(path, seq)) return
931
1009
  const message = 'read异常:' + String(e)
932
- setLoadError(message)
933
- setError(message)
934
- setStatus('读取失败')
1010
+ failLoad(path, sid, seq, message)
935
1011
  })
936
1012
  }
937
1013
 
@@ -955,12 +1031,13 @@ export function EditorView(props) {
955
1031
  cache.delete(path)
956
1032
  cache.set(path, hit)
957
1033
  setPdfBytes(base64ToBytes(hit))
1034
+ stampLoad(path, sid, seq)
958
1035
  setLoadStage({ progress: 100, message: 'PDF 已就绪' })
959
1036
  setStatus('已加载')
960
1037
  return
961
1038
  }
962
1039
  readBinaryPreview(rpc, { sessionId: sid, path }).then((out) => {
963
- if (seq !== loadSeqRef.current || path !== active) return
1040
+ if (!loadCurrent(path, seq)) return
964
1041
  if (out.ok && out.via === 'binary') {
965
1042
  const pdfVersion = out.version || version
966
1043
  recordBaseline(scope, path, pdfVersion)
@@ -969,6 +1046,7 @@ export function EditorView(props) {
969
1046
  cache.set(path, bytesToBase64(out.bytes))
970
1047
  while (cache.size > 6) cache.delete(cache.keys().next().value)
971
1048
  setPdfBytes(out.bytes)
1049
+ stampLoad(path, sid, seq)
972
1050
  setLoadStage({ progress: 100, message: 'PDF 已就绪' })
973
1051
  setStatus('已加载')
974
1052
  return
@@ -981,21 +1059,18 @@ export function EditorView(props) {
981
1059
  cache.set(path, out.content)
982
1060
  while (cache.size > 6) cache.delete(cache.keys().next().value)
983
1061
  setPdfBytes(base64ToBytes(out.content))
1062
+ stampLoad(path, sid, seq)
984
1063
  setLoadStage({ progress: 100, message: 'PDF 已就绪' })
985
1064
  setStatus('已加载')
986
1065
  return
987
1066
  }
988
1067
  const base = out.error ? String(out.error) : '读取失败'
989
1068
  const message = out.resolvedPath ? base + ':' + String(out.resolvedPath) : base
990
- setLoadError(message)
991
- setError(message)
992
- setStatus('读取失败')
1069
+ failLoad(path, sid, seq, message)
993
1070
  }).catch((e) => {
994
- if (seq !== loadSeqRef.current) return
1071
+ if (!loadCurrent(path, seq)) return
995
1072
  const message = 'read异常:' + String(e)
996
- setLoadError(message)
997
- setError(message)
998
- setStatus('读取失败')
1073
+ failLoad(path, sid, seq, message)
999
1074
  })
1000
1075
  }
1001
1076
 
@@ -1340,29 +1415,10 @@ export function EditorView(props) {
1340
1415
  try { localStorage.setItem(sidebarKey, JSON.stringify({ on: sidebarOn, width: sidebarW, panel: activePanel })) }
1341
1416
  catch (e) { /* 忽略 */ }
1342
1417
  }, [sidebarOn, sidebarW, activePanel, sessionId, sidebarKey])
1343
- // 切换侧边栏(capture 抢占,避免与 DSH 全局冲突;键位随快捷键配置)
1344
- React.useEffect(() => {
1345
- const onKey = (e) => {
1346
- if (!matchEvent(e, bindingsOf('edrv.toggleSidebar'))) return
1347
- e.preventDefault(); e.stopPropagation()
1348
- setSidebarOn((v) => !v)
1349
- }
1350
- window.addEventListener('keydown', onKey, true)
1351
- return () => window.removeEventListener('keydown', onKey, true)
1352
- }, [])
1353
-
1354
- // 保存文件(窗口级):编辑器有活动模型才拦截执行,无文件时放行(浏览器默认行为)
1355
- React.useEffect(() => {
1356
- const onKey = (e) => {
1357
- if (!matchEvent(e, bindingsOf('edrv.save'))) return
1358
- if (!editorRef.current?.getModel?.()) return
1359
- e.preventDefault(); e.stopPropagation()
1360
- flushSave()
1361
- doSaveRef.current(false)
1362
- }
1363
- window.addEventListener('keydown', onKey, true)
1364
- return () => window.removeEventListener('keydown', onKey, true)
1365
- }, [])
1418
+ // 编辑器键位(侧栏/保存/搜索/Markdown 预览/导航/页签循环)已归官方 shortcuts 机制派发:
1419
+ // 官方 resolve → 指令注册表 run → `edrv.command.*` 事件 → 下方「指令系统接线」表执行。
1420
+ // 历史 capture 监听移除(与官方派发并存会双执行);Markdown 预览的「非 .md 不吞键」
1421
+ // 语义由命令 available()(activePathStore + isMarkdownPath)承载,可用性为假时官方派发放行。
1366
1422
 
1367
1423
  /**
1368
1424
  * 打开搜索面板(Ctrl+Shift+F 与命令栏「在工作区中搜索」的唯一动作)。
@@ -1388,65 +1444,9 @@ export function EditorView(props) {
1388
1444
  setTimeout(() => window.dispatchEvent(new CustomEvent('edrv:search-focus')), 0)
1389
1445
  }
1390
1446
 
1391
- // Ctrl+Shift+F 全局搜索:展开侧边栏 + 激活搜索页签,随后聚焦搜索输入框(有选中则填入)
1392
- React.useEffect(() => {
1393
- const onKey = (e) => {
1394
- if (!matchEvent(e, bindingsOf('edrv.searchInFiles'))) return
1395
- e.preventDefault(); e.stopPropagation()
1396
- openSearchPanel()
1397
- }
1398
- window.addEventListener('keydown', onKey, true)
1399
- return () => window.removeEventListener('keydown', onKey, true)
1400
- }, [])
1401
-
1402
- // Ctrl+Shift+V 切换 Markdown 预览:仅活动文件是 Markdown 时吞键;
1403
- // 其余情况直接放行(不 preventDefault),保留浏览器/输入框原生的「无格式粘贴」语义
1404
- // ——与 closeTab / addSelectionRef「不可用时放行按键」同款约定。
1405
- React.useEffect(() => {
1406
- const onKey = (e) => {
1407
- if (!matchEvent(e, bindingsOf('edrv.toggleMarkdownPreview'))) return
1408
- const path = activeRef.current
1409
- if (!isMarkdownPath(path)) return
1410
- e.preventDefault(); e.stopPropagation()
1411
- toggleMdPreviewRef.current?.(path)
1412
- }
1413
- window.addEventListener('keydown', onKey, true)
1414
- return () => window.removeEventListener('keydown', onKey, true)
1415
- }, [])
1416
-
1417
- // 后退/前进:导航历史(跨文件焦点位置;经 ref 读最新闭包,避免空依赖过期)
1418
- React.useEffect(() => {
1419
- const onKey = (e) => {
1420
- if (!matchEvent(e, bindingsOf('edrv.navigateBack'))) return
1421
- e.preventDefault(); e.stopPropagation()
1422
- if (navBackRef.current) navBackRef.current()
1423
- }
1424
- window.addEventListener('keydown', onKey, true)
1425
- return () => window.removeEventListener('keydown', onKey, true)
1426
- }, [])
1427
- React.useEffect(() => {
1428
- const onKey = (e) => {
1429
- if (!matchEvent(e, bindingsOf('edrv.navigateForward'))) return
1430
- e.preventDefault(); e.stopPropagation()
1431
- if (navForwardRef.current) navForwardRef.current()
1432
- }
1433
- window.addEventListener('keydown', onKey, true)
1434
- return () => window.removeEventListener('keydown', onKey, true)
1435
- }, [])
1447
+ React.useEffect(() => bindPanelCommand('searchInFiles', { sessionId, cwd: cwd ?? undefined }, openSearchPanel), [sessionId, cwd])
1436
1448
 
1437
- // 页签循环切换:Ctrl+Alt+←/→(主候选,浏览器不占用)或 Ctrl+PgUp/PgDn(部分宿主可用)
1438
- React.useEffect(() => {
1439
- const onKey = (e) => {
1440
- const step = matchEvent(e, bindingsOf('edrv.nextTab')) ? 1
1441
- : matchEvent(e, bindingsOf('edrv.prevTab')) ? -1
1442
- : 0
1443
- if (step === 0) return
1444
- e.preventDefault(); e.stopPropagation()
1445
- if (cycleTabRef.current) cycleTabRef.current(step)
1446
- }
1447
- window.addEventListener('keydown', onKey, true)
1448
- return () => window.removeEventListener('keydown', onKey, true)
1449
- }, [])
1449
+ // Ctrl+Shift+F / Ctrl+Shift+V / Alt+←→ / Ctrl+Alt+←→ 的 capture 监听已随官方派发改造移除(见上)。
1450
1450
 
1451
1451
  // 整行上下移动的实现见下方「指令系统接线」effect(moveRow 单点定义,命令栏与键位共用)。
1452
1452
 
@@ -1530,7 +1530,6 @@ export function EditorView(props) {
1530
1530
  ['edrv.command.save', () => { if (editorRef.current?.getModel?.()) { flushSave(); doSaveRef.current?.(false) } }],
1531
1531
  // 快速打开由 QuickOpen 自己接该事件(它持有搜索框 ref),此处不重复实现
1532
1532
  ['edrv.command.toggleSidebar', () => setSidebarOn((v) => !v)],
1533
- ['edrv.command.searchInFiles', () => openSearchPanel()],
1534
1533
  ['edrv.command.toggleMarkdownPreview', () => {
1535
1534
  const path = activeRef.current
1536
1535
  if (!isMarkdownPath(path)) { setStatus('当前文件不是 Markdown'); return }
@@ -1826,6 +1825,23 @@ export function EditorView(props) {
1826
1825
  return true
1827
1826
  }
1828
1827
 
1828
+ /**
1829
+ * Apply a save completion only to the unchanged model that supplied its text.
1830
+ * @private @author ddj 2026年09月28号
1831
+ * @param path Saved tab path.
1832
+ * @param text Text actually sent to disk.
1833
+ * @param model Model captured before the save request.
1834
+ */
1835
+ function acceptSaved(path, text, model) {
1836
+ if (!model || modelsRef.current.get(path) !== model || model.isDisposed?.() || model.getValue() !== text) return
1837
+ setDirtyMap((current) => ({ ...current, [path]: false }))
1838
+ if (activeRef.current !== path) return
1839
+ setContent(text)
1840
+ setContentPath(path)
1841
+ setStatus('已保存 ' + new Date().toTimeString().slice(0, 8))
1842
+ setDiskFlag((prev) => (prev && prev.path === path ? null : prev))
1843
+ }
1844
+
1829
1845
  const doSave = (silent) => {
1830
1846
  if (!active) return
1831
1847
  // 外部改动待处理:自动保存(silent)直接放弃,避免陈旧缓冲覆盖磁盘;
@@ -1843,6 +1859,7 @@ export function EditorView(props) {
1843
1859
  if (!ed) return
1844
1860
  if (isImageActive) { setStatus('图片只读预览'); return }
1845
1861
  const text = ed.getValue()
1862
+ const savedModel = ed.getModel?.()
1846
1863
  const path = active
1847
1864
  if (!silent) setStatus('保存中…')
1848
1865
  // 在途登记:关闭路径的 persistDirty 据此跳过重复提交(内容在同一 tick 内取,必然相同)
@@ -1855,13 +1872,7 @@ export function EditorView(props) {
1855
1872
  recordBaseline(scope, path, res.rev)
1856
1873
  markReadVersion(scope, path, res.rev, true) // 缓冲即磁盘内容:同版本无需再读盘
1857
1874
  clearSync(scope, path)
1858
- setContent(text)
1859
- setContentPath(path)
1860
- setDirtyMap((d) => Object.assign({}, d, { [path]: false }))
1861
- if (path === active) {
1862
- setStatus('已保存 ' + new Date().toTimeString().slice(0, 8))
1863
- setDiskFlag((prev) => (prev && prev.path === path ? null : prev))
1864
- }
1875
+ acceptSaved(path, text, savedModel)
1865
1876
  refreshRecords()
1866
1877
  emitRefresh()
1867
1878
  // 片段配置文件保存后失效补全缓存(下次补全即读到新片段)
@@ -1952,7 +1963,7 @@ export function EditorView(props) {
1952
1963
  syncTypeSuggest(ed, monaco)
1953
1964
  restoreViewState(active)
1954
1965
  setLoadStage((prev) => ({ progress: Math.max(96, prev.progress), message: '创建编辑器视图…' }))
1955
- }, [monaco, active, content, contentReady, mdPreviewing])
1966
+ }, [monaco, active, content, contentReady, mdPreviewing, svnDiff])
1956
1967
 
1957
1968
  // PDF 面板外壳 ref 回调(div 仅在 PDF 分支渲染,refs 先于 effect 就绪)
1958
1969
  const ensurePdfHost = (node) => { pdfHostRef.current = node }
@@ -1962,21 +1973,32 @@ export function EditorView(props) {
1962
1973
  const host = pdfHostRef.current
1963
1974
  if (!isPdfActive || !pdfBytes || !host) return
1964
1975
  const path = active
1976
+ const sequence = receiptLoad?.sequence
1977
+ if (!acceptsLoad(receiptLoad, sessionId, path, sequence, loadSeqRef.current)) return
1978
+ let mounting = true
1979
+ /** @author ddj 2026年09月28号 @param message PDF error; only startup failures affect an open receipt. */
1980
+ const pdfError = (message) => {
1981
+ setError(message)
1982
+ if (mounting && message) renderLoadFail(path, sessionId, sequence, message)
1983
+ }
1984
+ /** @author ddj 2026年09月28号 End startup error attribution before later save operations. */
1985
+ const mounted = () => { mounting = false }
1965
1986
  const ctl = createPdfPanel({
1966
1987
  sessionId,
1967
1988
  path,
1968
1989
  setStatus,
1969
- setError,
1990
+ setError: pdfError,
1970
1991
  onDirtyChange: (d) => setDirtyMap((prev) => Object.assign({}, prev, { [path]: d })),
1971
1992
  onReload: () => loadContent(path, sessionId, true),
1972
1993
  })
1973
1994
  pdfCtlRef.current.set(path, ctl)
1974
- void ctl.mount(host, pdfBytes)
1995
+ void ctl.mount(host, pdfBytes).then(mounted, pdfError)
1975
1996
  return () => {
1997
+ mounting = false
1976
1998
  ctl.destroy()
1977
1999
  if (pdfCtlRef.current.get(path) === ctl) pdfCtlRef.current.delete(path)
1978
2000
  }
1979
- }, [isPdfActive, pdfBytes, active])
2001
+ }, [isPdfActive, pdfBytes, active, receiptLoad?.sequence])
1980
2002
 
1981
2003
  // 行内差异自绘(decorations / view zones / minus overlay)→ diffRenderer
1982
2004
  // ⚠️ 依赖含 mdPreviewing:切回源码时编辑器实例被重建(见上方 model 同步 effect 的说明),
@@ -1985,7 +2007,7 @@ export function EditorView(props) {
1985
2007
  if (!monaco || !editorRef.current || !active || content === null) return
1986
2008
  diffRendererRef.current.render(monaco, editorRef.current, pendingRegions, sessionId)
1987
2009
  setLoadStage({ progress: 100, message: '编辑器已就绪' })
1988
- }, [monaco, active, content, pendingRegions, mdPreviewing])
2010
+ }, [monaco, active, content, pendingRegions, mdPreviewing, svnDiff])
1989
2011
 
1990
2012
  React.useEffect(() => () => {
1991
2013
  flushSave()
@@ -2362,7 +2384,7 @@ export function EditorView(props) {
2362
2384
  ed.revealLineInCenter(Math.max(1, target.start ?? 1))
2363
2385
  ed.setPosition({ lineNumber: Math.max(1, target.start ?? 1), column: 1 })
2364
2386
  ed.focus()
2365
- }, [active, content, contentReady, pendingRegions, focusRequest, monaco, mdPreviewing])
2387
+ }, [active, content, contentReady, pendingRegions, focusRequest, monaco, mdPreviewing, svnDiff])
2366
2388
 
2367
2389
  const jumpTo = (region) => {
2368
2390
  if (editorRef.current) {
@@ -2546,6 +2568,16 @@ export function EditorView(props) {
2546
2568
  applyImgZoom(zoomStepOf(imgZoom ?? measured, dir))
2547
2569
  }
2548
2570
 
2571
+ /**
2572
+ * Preserve image UI behavior while attributing decoder failure to its rendered load.
2573
+ * @private @author ddj 2026年09月28号
2574
+ */
2575
+ function imageFailed() {
2576
+ if (!receiptLoad || !loadCurrent(receiptLoad.path, receiptLoad.sequence)) return
2577
+ setImgBroken(true)
2578
+ renderLoadFail(receiptLoad.path, receiptLoad.sessionId, receiptLoad.sequence, '图片解码失败')
2579
+ }
2580
+
2549
2581
  /**
2550
2582
  * 图片预览面板:工具条(尺寸 meta / 缩放 −+适应宽度 / SVG 文本切换 / 刷新)+ 棋盘底图片。
2551
2583
  * 初始适应宽度(等价 PDF page-width,0.1.7 官方统一缩放);按钮样式与 PDF 工具条同款。
@@ -2583,13 +2615,14 @@ export function EditorView(props) {
2583
2615
  : React.createElement('img', {
2584
2616
  className: 'edrv-imgview-img',
2585
2617
  ref: imgElRef,
2618
+ key: active + ':' + (receiptLoad?.sequence ?? 0),
2586
2619
  src: imageSrc,
2587
2620
  alt: active || '',
2588
2621
  style: (imgZoom !== null && imgSize)
2589
2622
  ? { width: Math.round(imgSize.w * imgZoom) + 'px', maxWidth: 'none', maxHeight: 'none' }
2590
2623
  : undefined,
2591
2624
  onLoad: (e) => { setImgBroken(false); setImgSize({ w: e.target.naturalWidth, h: e.target.naturalHeight }) },
2592
- onError: () => setImgBroken(true),
2625
+ onError: imageFailed,
2593
2626
  }))))
2594
2627
 
2595
2628
  /**
@@ -3191,13 +3224,89 @@ export function EditorView(props) {
3191
3224
  // G9:同 openFile,先归一再进页签,保证待跳转路径与 active 同形态
3192
3225
  const normalized = addTabNorm(path, true)
3193
3226
  if (!normalized) return
3227
+ stageFileAt(normalized, line, column, endLine, endColumn)
3228
+ }
3229
+
3230
+ /**
3231
+ * Stage a normalized navigation target using the existing reload-safe handoff.
3232
+ * @private @author ddj 2026年09月28号
3233
+ * @param normalized Exact tab path.
3234
+ * @param line Optional line.
3235
+ * @param column Optional column.
3236
+ * @param endLine Optional range end line.
3237
+ * @param endColumn Optional range end column.
3238
+ */
3239
+ function stageFileAt(normalized, line, column, endLine, endColumn) {
3194
3240
  pendingFocusRef.current = { path: normalized, region: null, line: line ?? null, column: column ?? 1, endLine: endLine ?? null, endColumn: endColumn ?? null }
3195
- // 第三次事故(2026-09-23):插件 HMR 热重载会重挂载本组件、挂载级 ref 随旧实例销毁 →
3196
- // 落点意图丢失。同步落 window 槽(跨热重载存活)做交接,落点成功后与 ref 一并清。
3197
3241
  putPendingNav({ path: normalized, line: line ?? null, column: column ?? 1, endLine: endLine ?? null, endColumn: endColumn ?? null })
3198
3242
  setFocusRequest((value) => value + 1)
3199
3243
  }
3200
3244
 
3245
+ /**
3246
+ * Open a receipt target without losing edits or leaving the requested source hidden.
3247
+ * @private @author ddj 2026年09月28号
3248
+ * @param request Claimed external open request.
3249
+ */
3250
+ function openReceiptFile(request) {
3251
+ if (!request.path) return
3252
+ let path = receiptTabPath(request.path, cwd)
3253
+ const existing = tabsRef.current.find((tab) => receiptPath(tab.path, cwd) === receiptPath(path, cwd))
3254
+ if (existing) path = existing.path
3255
+ flushSave()
3256
+ saveViewState(active)
3257
+ recordNav()
3258
+ setSvnDiff(null)
3259
+ const positioned = request.line != null || request.column != null
3260
+ if (positioned && mdPreviewRef.current.has(path)) toggleMdPreview(path)
3261
+ setError(null)
3262
+ if (monacoErr) setMonacoErr(null)
3263
+ addTab(path, true)
3264
+ if (positioned) stageFileAt(path, request.line ?? 1, request.column ?? 1)
3265
+ const moved = receiptLoad && receiptPath(receiptLoad.path, receiptLoad.cwd) !== receiptPath(path, cwd)
3266
+ if (path !== active || !(loadError || receiptLoad?.status === 'error' || moved)) return
3267
+ if (pdfCtlRef.current.get(path)?.isDirty?.()) return
3268
+ pdfB64CacheRef.current.delete(path)
3269
+ loadContent(path, sessionId, false)
3270
+ }
3271
+
3272
+ /**
3273
+ * Build facts from the current render; model identity is the exact cached instance.
3274
+ * @private @author ddj 2026年09月28号
3275
+ * @param request Claimed target and optional position.
3276
+ * @returns State consumed by the tested receipt predicates.
3277
+ */
3278
+ function receiptView(request) {
3279
+ const ed = editorRef.current
3280
+ const model = ed?.getModel?.()
3281
+ const modelMatches = Boolean(model && modelsRef.current.get(active) === model)
3282
+ let positionMatches = false
3283
+ if (modelMatches && (request.line != null || request.column != null)) {
3284
+ const expected = model.validatePosition({ lineNumber: request.line ?? 1, column: request.column ?? 1 })
3285
+ const actual = ed.getPosition()
3286
+ positionMatches = actual?.lineNumber === expected.lineNumber && actual?.column === expected.column
3287
+ }
3288
+ const image = imgElRef.current
3289
+ return {
3290
+ sessionId, cwd, active, sequence: loadSeqRef.current, load: receiptLoad,
3291
+ mounted: Boolean(viewRootRef.current), blocked: Boolean(svnDiff),
3292
+ kind: isImageActive ? 'image' : isPdfActive ? 'pdf' : mdPreviewing ? 'markdown' : 'text',
3293
+ contentPath, contentReady, modelMatches, positionMatches,
3294
+ imageLoaded: Boolean(imageSrc && !imgBroken && image?.getAttribute('src') === imageSrc && image.complete && image.naturalWidth > 0),
3295
+ pdfLoaded: Boolean(pdfBytes && pdfHostRef.current?.querySelector('.page[data-loaded="true"]')),
3296
+ engineError: monacoErr ? String(monacoErr) : null,
3297
+ }
3298
+ }
3299
+
3300
+ /** @private @author ddj 2026年09月28号 @param request Claimed target. @returns Correlated actual readiness. */
3301
+ function receiptReady(request) {
3302
+ return receiptReadyFor(request, receiptView(request))
3303
+ }
3304
+ /** @private @author ddj 2026年09月28号 @param request Claimed target. @returns Only its current load/render error. */
3305
+ function receiptError(request) {
3306
+ return receiptErrorFor(request, receiptView(request))
3307
+ }
3308
+ useOpenReceipt({ sessionId, open: openReceiptFile, ready: receiptReady, error: receiptError })
3309
+
3201
3310
  // 停帧跳转/调试命令监听(openFileAt 每渲染重建,经 ref 让空依赖监听读最新闭包)
3202
3311
  const dapOpenRef = React.useRef(null)
3203
3312
  dapOpenRef.current = openFileAt
@@ -3493,7 +3602,7 @@ export function EditorView(props) {
3493
3602
  } else if (isImageActive) {
3494
3603
  body = loadingBody(loadStage.message || '读取图片…', loadStage.progress)
3495
3604
  } else if (isPdfActive && pdfBytes) {
3496
- body = React.createElement('div', { className: 'edrv-pdf-host', ref: ensurePdfHost, key: active })
3605
+ body = React.createElement('div', { className: 'edrv-pdf-host', ref: ensurePdfHost, key: active + ':' + (receiptLoad?.sequence ?? 0) })
3497
3606
  } else if (isPdfActive) {
3498
3607
  body = loadingBody(loadStage.message || '读取 PDF…', loadStage.progress)
3499
3608
  } else if (mdPreviewing && contentReady) {