dsh-vscode-mode 0.4.6 → 0.5.1

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 (55) hide show
  1. package/README.md +38 -2
  2. package/lib/client.js +9316 -4038
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +1852 -68
  5. package/lib/index.js.map +1 -1
  6. package/package.json +8 -8
  7. package/src/client/ai/inlineProvider.ts +42 -1
  8. package/src/client/index.ts +50 -45
  9. package/src/client/monaco/lsp/index.ts +33 -3
  10. package/src/client/monaco/lsp/lspClient.ts +3 -1
  11. package/src/client/monaco/lsp/providers.ts +60 -6
  12. package/src/client/navHighlight.ts +96 -0
  13. package/src/client/officialSidebar.ts +67 -3
  14. package/src/client/paths.ts +2 -0
  15. package/src/client/rpc.ts +32 -12
  16. package/src/client/sessionScope.ts +183 -0
  17. package/src/client/sidebar/SidebarView.ts +6 -2
  18. package/src/client/sidebar/menuItems.ts +93 -2
  19. package/src/client/sidebar/panels/FileExplorer.ts +18 -1
  20. package/src/client/sidebar/panels/SvnPanel.ts +303 -0
  21. package/src/client/sidebar/panels/index.ts +44 -3
  22. package/src/client/sidebar/types.ts +20 -0
  23. package/src/client/snippets/provider.ts +26 -4
  24. package/src/client/styles/editor.css +177 -0
  25. package/src/client/svnActions.ts +153 -0
  26. package/src/client/svnLog.ts +356 -0
  27. package/src/client/svnStatus.ts +363 -0
  28. package/src/client/svnStore.ts +150 -0
  29. package/src/client/tabActions.ts +30 -5
  30. package/src/client/tabMenu.ts +51 -0
  31. package/src/client/ui/AiSettings.ts +8 -0
  32. package/src/client/ui/EditorView.ts +632 -35
  33. package/src/client/ui/LogDialog.ts +333 -0
  34. package/src/client/ui/McpSettings.ts +98 -0
  35. package/src/client/ui/ModalShell.ts +79 -0
  36. package/src/client/ui/SideBySideDiff.tsx +139 -0
  37. package/src/client/ui/SnippetsPicker.ts +70 -58
  38. package/src/client/ui/SvnDiffPanel.ts +50 -0
  39. package/src/client/ui/SvnLogDialog.ts +1141 -0
  40. package/src/client/ui/SvnLogStats.ts +243 -0
  41. package/src/client/ui/commandCatalog.ts +72 -0
  42. package/src/compat.ts +1 -1
  43. package/src/debugLog.ts +81 -12
  44. package/src/dshVersion.ts +8 -1
  45. package/src/fileOpenSettings.ts +40 -3
  46. package/src/index.ts +5 -1
  47. package/src/rpc.ts +41 -4
  48. package/src/shared/ai.ts +25 -0
  49. package/src/shared/rpc.ts +53 -1
  50. package/src/shared/svn.ts +377 -0
  51. package/src/shared/svnActions.ts +296 -0
  52. package/src/svn.ts +870 -0
  53. package/src/svnLog.ts +315 -0
  54. package/src/svnText.ts +175 -0
  55. package/src/svnXml.ts +110 -0
@@ -24,6 +24,7 @@ import {
24
24
  } from '../watchDecision.js'
25
25
  import { useFileWatch } from './useFileWatch.js'
26
26
  import { createPdfPanel } from '../pdf/pdfPanel.js'
27
+ import { SvnDiffPanel } from './SvnDiffPanel.js'
27
28
  import { applyOfficial, registerThemes, themeNameOf } from '../monaco/theme.js'
28
29
  import { createDiffRenderer } from '../monaco/diffRender.js'
29
30
  import { ST, callIdAttr, noopHunk, summarize } from '../state/records.js'
@@ -41,6 +42,7 @@ import { setSidePending, SIDEBAR_INSTALL_CMD } from '../sidebarBridge.js'
41
42
  import { upsertViewState, viewStatesLoad, viewStatesSave } from '../state/viewStateCache.js'
42
43
  import { migrateScopedKeys, workspaceScopeOf } from '../state/scopeStore.js'
43
44
  import { modelsForScope, rememberModel } from '../state/modelCache.js'
45
+ import { navFlashRangeOf } from '../navHighlight.js'
44
46
  import { bindingsOf, chordOf, matchEvent, useKeybindingsVersion } from '../keybindings.js'
45
47
  import { getSidebarMinWidth } from '../sidebarMin.js'
46
48
  import { navHistoryFor } from '../navHistory.js'
@@ -54,11 +56,37 @@ import { SnippetsPicker } from './SnippetsPicker.js'
54
56
  import { invalidateSnippets, setSnippetsSession, setupSnippets } from '../snippets/provider.js'
55
57
  import {
56
58
  absoluteOf, ancestorDirsOf, applyClose, baseNameOf, closeAll, closeOthers, closeRight, closeSaved,
57
- insertTab, isTreeRevealable, normalizeTabs, pickActive, relativeOf, togglePin,
59
+ insertTab, isTreeRevealable, normalizeTabs, pickActive, relativeOf, tabPathOf, togglePin,
58
60
  } from '../tabActions.js'
59
61
  import { buildTabMenu } from '../tabMenu.js'
62
+ import { ensureSvnChanges, ensureSvnStatus, getSvnChanges, getSvnStatus, refreshSvnChanges, svnAdd, svnChangeMapOf, svnDiffBase, svnEditorActions, svnRevert, svnTortoise, svnUpdate } from '../svnStatus.js'
63
+ import { ensureSvnLog, getSvnLog, refreshSvnLog, svnDiffPair, svnDiffRev, svnDiffWorking, svnLogKeyOf, svnLogLoadedLimit, svnLogTruncated, svnWcRev, SVN_LOG_SHOW_ALL_LIMIT } from '../svnLog.js'
64
+ import { runSvnAction } from '../svnActions.js'
65
+ import { dshTrace } from '../svnStore.js'
66
+ import { SVN_ACTION_BY_ID, svnActionsFor } from '../../shared/svnActions.js'
67
+ import { isSvnDiffable } from '../../shared/svn.js'
68
+ import type { SvnAction } from '../../shared/svn.js'
60
69
  import { createSaveTimer } from '../saveDebounce.js'
61
70
  import { ContextMenu } from './ContextMenu.js'
71
+ import { SvnLogDialog } from './SvnLogDialog.js'
72
+ import { LogDialog } from './LogDialog.js'
73
+
74
+ /** 日志弹窗每次加载条数(「加载更多」按此步长递增;上限由 host 的 SVN_LOG_SHOW_ALL_CAP 约束)。 */
75
+ const SVN_LOG_PAGE = 100
76
+
77
+ /**
78
+ * 日志弹窗状态 → 取数选项(P1-4/P1-5/P1-6:soc/mrg/range 与状态层键保持同源)。
79
+ * @author ddj 2026年09月17号 / 2026年09月18号
80
+ * @param svnLog 日志弹窗状态(null = 关闭)
81
+ * @returns 状态层取数选项
82
+ */
83
+ function svnLogOptsOf(svnLog) {
84
+ if (!svnLog) return {}
85
+ return { stopOnCopy: svnLog.soc === true, showMerged: svnLog.merged === true, range: svnLog.range ?? null }
86
+ }
87
+
88
+ /** 跳转目标高亮的保留时长(LSP/搜索跳转落地后给用户的位置提示,到期自动清除)。 */
89
+ const NAV_FLASH_MS = 1200
62
90
 
63
91
  /**
64
92
  * 从 Monaco 语言目录取可选语言 id 列表(代码片段新建时的语言下拉来源)。
@@ -158,6 +186,16 @@ export function EditorView(props) {
158
186
  const [diffIdx, setDiffIdx] = React.useState(0) // 当前文件内差异位置(x/x 显示)
159
187
  const [fileIdx, setFileIdx] = React.useState(0) // 全局差异文件位置(x/x 文件 显示)
160
188
  const [tabMenu, setTabMenu] = React.useState(null) // Tab 右键菜单 { x,y,path }(编辑区右键走 Monaco 原生菜单,无此浮层)
189
+ const [svnStatus, setSvnStatus] = React.useState(null) // SVN 能力状态(SvnStatusPayload;null=未加载,SVN 入口隐藏)
190
+ const [svnChanges, setSvnChanges] = React.useState(null) // SVN 变更清单(null=未加载;徽标/菜单判定)
191
+ const [svnDiff, setSvnDiff] = React.useState(null) // 差异视图 { path, base, working, reason, message, leftLabel, rightLabel, scheme }(null=关闭)
192
+ const [svnLog, setSvnLog] = React.useState(null) // 日志弹窗 { target, limit, soc?, range? }(null=关闭;P1-4/P1-6 增选项维度)
193
+ const [svnLogData, setSvnLogData] = React.useState(null) // 日志条目(null=未加载)
194
+ const [svnLogBusy, setSvnLogBusy] = React.useState(false) // 日志在途
195
+ const [svnLogError, setSvnLogError] = React.useState('') // 日志错误文案
196
+ const [svnLogWcRev, setSvnLogWcRev] = React.useState(null) // P1-9:文件目标的工作副本版号(null = 不加粗)
197
+ const [dlogOpen, setDlogOpen] = React.useState(false) // 诊断日志弹窗(自取数据,null 语义无需状态层)
198
+ const [svnUpdateResult, setSvnUpdateResult] = React.useState(null) // update 条目级结果条(null=不显示)
161
199
  const [sidebarOn, setSidebarOn] = React.useState(layout !== 'side') // 侧边栏显隐(侧栏形态默认收起)
162
200
  const [sidebarW, setSidebarW] = React.useState(() => getSidebarMinWidth()) // 侧边栏宽度(初值 = 最小宽度,默认 300)
163
201
  const [activePanel, setActivePanel] = React.useState('explorer') // 激活面板 id
@@ -180,7 +218,9 @@ export function EditorView(props) {
180
218
  const loadSeqRef = React.useRef(0)
181
219
  const programmaticRef = React.useRef(false)
182
220
  const restoredScopeRef = React.useRef(null) // 已恢复状态的作用域(cwd 晚到 sid→ws 时允许重恢复)
183
- const pendingFocusRef = React.useRef(null) // { path, region } 内容加载后跳转
221
+ const pendingFocusRef = React.useRef(null) // { path, region, line, column } 内容加载后跳转
222
+ const navFlashRef = React.useRef([]) // 跳转目标高亮装饰 id(独立于 diff/下划线,随跳转替换)
223
+ const navFlashTimerRef = React.useRef(null) // 跳转目标高亮自动清除计时器
184
224
  // 导航历史(后退/前进):跨文件焦点位置;按会话隔离,会话切换重置
185
225
  const navRef = React.useRef(null)
186
226
  if (!navRef.current) navRef.current = navHistoryFor(scope)
@@ -211,6 +251,8 @@ export function EditorView(props) {
211
251
  const hoverPanelRef = React.useRef(false) // 鼠标是否已进入 Keep/Undo 浮层
212
252
  const batchBusyRef = React.useRef(false) // 批量 Keep All/Undo All 防重入
213
253
  const menuHandlersRef = React.useRef(null) // 右键菜单动作的最新闭包(Monaco addAction 空依赖回调读取)
254
+ const svnMenuDisposersRef = React.useRef([]) // Monaco 右键 SVN 组当前 disposables(同步时先注销旧组)
255
+ const svnStatusRef = React.useRef(null) // svnStatus 渲染值镜像(ensureEditor 空依赖闭包读取最新状态)
214
256
  const [snippetPicker, setSnippetPicker] = React.useState(null) // 'configure' | 'insert' | null(代码片段浮层)
215
257
  const doSaveRef = React.useRef(null) // 保存动作的最新闭包(窗口级保存监听读取)
216
258
  const onEditRef = React.useRef(null) // 编辑置脏的最新闭包(Monaco 内容变化监听经 ref 调用,防首帧 active=null 陈旧闭包)
@@ -265,6 +307,33 @@ export function EditorView(props) {
265
307
  if (select) setActive(path)
266
308
  }
267
309
 
310
+ /**
311
+ * 页签规范路径(G9):统一收敛为工作区相对路径后再进页签。
312
+ *
313
+ * 差异记录路径取自工具结果 `target.displayPath`(官方恒绝对),而资源管理器树给相对路径;
314
+ * 不归一会导致地址栏形态不一致、同文件出现两个页签(insertTab 按原串去重)、
315
+ * 且绝对路径被 isTreeRevealable 判为不可定位(「在资源管理器视图中显示」失效)。
316
+ * 工作区外文件由 relativeOf 回退原绝对路径,语义不变。
317
+ * @author ddj 2026年09月18号
318
+ * @param path 原始路径(绝对或相对)
319
+ * @returns 页签规范路径
320
+ */
321
+ const tabPath = (path) => tabPathOf(path, cwd)
322
+ /**
323
+ * 打开文件入口的统一收敛(G9):所有「进页签」路径都经此归一,避免逐点打补丁。
324
+ * @author ddj 2026年09月18号
325
+ * @param path 原始路径
326
+ * @param select 是否设为活动页签
327
+ * @returns 归一后的路径(空值返回 null)
328
+ */
329
+ const addTabNorm = (path, select) => {
330
+ if (!path) return null
331
+ const normalized = tabPath(path)
332
+ if (!normalized) return null
333
+ addTab(normalized, select)
334
+ return normalized
335
+ }
336
+
268
337
  /**
269
338
  * 保存当前活动文件的视图状态(光标/滚动/折叠)到工作区作用域缓存。
270
339
  * @author ddj 2026年08月28号
@@ -346,7 +415,7 @@ export function EditorView(props) {
346
415
  flushSave()
347
416
  saveViewState(active)
348
417
  navPendingRef.current = entry
349
- addTab(entry.path, true)
418
+ addTabNorm(entry.path, true)
350
419
  setFocusRequest((value) => value + 1) // 目标已是活动文件时也触发恢复
351
420
  }
352
421
 
@@ -790,20 +859,23 @@ export function EditorView(props) {
790
859
  const onOpen = (e) => {
791
860
  const p = e?.detail?.path
792
861
  if (!p) return
862
+ // G9:入口统一归一为工作区相对路径(差异栏/对话链接/LSP 跳转等都经此事件)
863
+ const normalized = tabPath(p)
864
+ if (!normalized) return
793
865
  if (e?.detail?.focusDiff === true) {
794
866
  recordNav()
795
- pendingFocusRef.current = { path: p, region: null }
867
+ pendingFocusRef.current = { path: normalized, region: null }
796
868
  setFocusRequest((value) => value + 1)
797
- addTab(p, true)
869
+ addTab(normalized, true)
798
870
  return
799
871
  }
800
872
  if (e?.detail?.line != null) {
801
- // LSP/搜索跳转:打开并定位到行列
802
- openFileAt(p, e?.detail?.line, e?.detail?.column)
873
+ // LSP/搜索跳转:打开并定位到行列(endLine/endColumn 为目标区间,供落地高亮)
874
+ openFileAt(normalized, e?.detail?.line, e?.detail?.column, e?.detail?.endLine, e?.detail?.endColumn)
803
875
  return
804
876
  }
805
877
  recordNav()
806
- addTab(p, true)
878
+ addTab(normalized, true)
807
879
  }
808
880
  const onShowLauncher = (event) => {
809
881
  const tab = event?.detail?.tab
@@ -897,10 +969,13 @@ export function EditorView(props) {
897
969
  ?? localStorage.getItem(CACHE_KEY.editorLegacy + String(scope))
898
970
  if (raw) {
899
971
  const saved = JSON.parse(raw)
900
- const restored = normalizeTabs(saved?.tabs)
972
+ // G9:迁移历史持久化里的绝对路径页签(旧版差异入口写入),并顺带按新形态去重
973
+ const restored = normalizeTabs(saved?.tabs, cwd)
901
974
  if (restored.length) {
902
975
  setTabs(restored)
903
- setActive(pickActive(restored, saved?.active))
976
+ // 活动路径同形态归一,否则恢复后匹配不到任何页签(pickActive 回退首个)
977
+ const wanted = typeof saved?.active === 'string' ? tabPathOf(saved.active, cwd) : saved?.active
978
+ setActive(pickActive(restored, wanted))
904
979
  }
905
980
  }
906
981
  } catch (e) { /* 损坏忽略 */ }
@@ -1030,6 +1105,56 @@ export function EditorView(props) {
1030
1105
 
1031
1106
  // 整行上下移动的实现见下方「指令系统接线」effect(moveRow 单点定义,命令栏与键位共用)。
1032
1107
 
1108
+ // SVN 能力状态:scope 变化拉取 + `edrv:svn-status` 事件驱动刷新(菜单/页签/命令面板显隐数据源)
1109
+ React.useEffect(() => {
1110
+ ensureSvnStatus(sessionId, scope)
1111
+ const onSvnStatus = (event) => {
1112
+ if (!event?.detail?.scope || event.detail.scope === scope) setSvnStatus(getSvnStatus(scope))
1113
+ }
1114
+ window.addEventListener('edrv:svn-status', onSvnStatus)
1115
+ return () => window.removeEventListener('edrv:svn-status', onSvnStatus)
1116
+ }, [sessionId, scope])
1117
+
1118
+ // SVN 变更清单:状态就绪且受管理时才拉取(非 SVN 工作区零请求)+ `edrv:svn-changes` 驱动刷新
1119
+ // 依赖里放 managed/svnCli:状态后到时会自动补拉(首次渲染时状态尚为 null)
1120
+ React.useEffect(() => {
1121
+ const ready = Boolean(svnStatus?.managed && svnStatus?.svnCli)
1122
+ if (ready) ensureSvnChanges(sessionId, scope)
1123
+ const onSvnChanges = (event) => {
1124
+ if (!event?.detail?.scope || event.detail.scope === scope) setSvnChanges(getSvnChanges(scope))
1125
+ }
1126
+ window.addEventListener('edrv:svn-changes', onSvnChanges)
1127
+ // 状态刚到达(同轮已拉到清单)时也同步一次当前值,避免事件早于监听错过
1128
+ setSvnChanges(getSvnChanges(scope))
1129
+ return () => window.removeEventListener('edrv:svn-changes', onSvnChanges)
1130
+ }, [sessionId, scope, svnStatus?.managed, svnStatus?.svnCli])
1131
+
1132
+ // SVN 日志:弹窗打开期间订阅 `edrv:svn-log`(键经 svnLogKeyOf 统一拼装,含 P1-4/P1-5/P1-6 选项维度)
1133
+ React.useEffect(() => {
1134
+ if (!svnLog) return undefined
1135
+ const target = svnLog.target || ''
1136
+ const opts = svnLogOptsOf(svnLog)
1137
+ const key = svnLogKeyOf(scope, target, opts)
1138
+ const sync = (event) => {
1139
+ dshTrace('svnLog.syncEvent', { got: event?.detail?.key ?? null, expected: key })
1140
+ if (event?.detail?.key && event.detail.key !== key) return
1141
+ setSvnLogData(getSvnLog(scope, target, 0, opts))
1142
+ setSvnLogBusy(false)
1143
+ }
1144
+ window.addEventListener('edrv:svn-log', sync)
1145
+ // 打开时先同步一次(状态层可能已缓存)
1146
+ setSvnLogData(getSvnLog(scope, target, 0, opts))
1147
+ return () => window.removeEventListener('edrv:svn-log', sync)
1148
+ }, [svnLog, scope])
1149
+
1150
+ // P1-9:文件目标拉取工作副本版号(目录/根不拉,官方对目录需 crawl 工作副本,本插件不做)
1151
+ React.useEffect(() => {
1152
+ if (!svnLog || !svnLog.target) { setSvnLogWcRev(null); return undefined }
1153
+ let live = true
1154
+ void svnWcRev(sessionId, svnLog.target).then((rev) => { if (live) setSvnLogWcRev(rev) })
1155
+ return () => { live = false }
1156
+ }, [svnLog?.target, sessionId])
1157
+
1033
1158
  /**
1034
1159
  * 指令系统接线:命令栏/键位/第三方派发的 `edrv.command.*` 事件落到编辑器动作。
1035
1160
  * 全部动作经最新闭包执行(与窗口级键位监听同源);事件名逐条字面书写,
@@ -1094,6 +1219,26 @@ export function EditorView(props) {
1094
1219
  if (!addToConversation) { setStatus('添加到对话不可用'); return }
1095
1220
  addToConversation.appendReference(sessionId, path, range).then((o) => setStatus(statusOfAdd(o, '已添加选中内容为引用')))
1096
1221
  }],
1222
+ // SVN:命令面板动作(活动文件;无活动文件时 update/commit 用工作区根,其余提示)
1223
+ ['edrv.command.svnUpdate', () => runSvnUpdate(activeRef.current ?? '')],
1224
+ ['edrv.command.svnRefreshChanges', () => runSvnReload()],
1225
+ ['edrv.command.svnDiffBase', () => runSvnDiffBase(activeRef.current)],
1226
+ ['edrv.command.svnAdd', () => runSvnAdd(activeRef.current)],
1227
+ ['edrv.command.svnRevertCli', () => runSvnRevertCli(activeRef.current)],
1228
+ ['edrv.command.svnLog', () => openSvnLog(activeRef.current || undefined)],
1229
+ ['edrv.command.svnCleanup', () => runSvnAction(SVN_ACTION_BY_ID.cleanup, {
1230
+ sessionId,
1231
+ scope,
1232
+ notify: (message) => setStatus(message),
1233
+ refreshChanges: () => refreshSvnChanges(sessionId, scope),
1234
+ })],
1235
+ ['edrv.command.svnTortoiseCommit', () => runSvnTortoise('commit', activeRef.current ?? '')],
1236
+ ['edrv.command.svnTortoiseLog', () => runSvnTortoise('log', activeRef.current)],
1237
+ ['edrv.command.svnTortoiseDiff', () => runSvnTortoise('diff', activeRef.current)],
1238
+ ['edrv.command.svnTortoiseBlame', () => runSvnTortoise('blame', activeRef.current)],
1239
+ ['edrv.command.svnTortoiseRevert', () => runSvnTortoise('revert', activeRef.current)],
1240
+ // 诊断日志弹窗(同片段选择器:执行一条命令即关命令栏,避免残浮层遮挡)
1241
+ ['edrv.command.showLogs', () => { closeCommandPalette(); setDlogOpen(true) }],
1097
1242
  ]
1098
1243
  const byName = new Map(handlers)
1099
1244
  const onCommand = (event) => {
@@ -1213,6 +1358,64 @@ export function EditorView(props) {
1213
1358
 
1214
1359
  monacoRef.current = monaco
1215
1360
 
1361
+ /** 从编辑器实例 model URI 解析活动文件路径(工作区相对;无模型返回 null)。 */
1362
+ const modelPathOf = (edx) => {
1363
+ const uriPath = edx?.getModel?.()?.uri?.path
1364
+ return uriPath ? decodeURIComponent(String(uriPath).replace(/^\//, '')) : null
1365
+ }
1366
+
1367
+ /**
1368
+ * 同步 Monaco 右键菜单 SVN 组:先注销旧组,再按最新 svn 状态注册(空状态 = 全部移除)。
1369
+ * ensureEditor(编辑器创建)与 svn 状态变化两条通道都会调用,幂等。
1370
+ * @author ddj 2026年09月16号
1371
+ * @param ed Monaco 编辑器实例(未创建传 null)
1372
+ */
1373
+ const syncSvnMenuActs = (ed) => {
1374
+ const stale = svnMenuDisposersRef.current
1375
+ svnMenuDisposersRef.current = []
1376
+ for (const dispose of stale) {
1377
+ try { dispose?.() } catch { /* dispose 异常忽略,不阻塞重注册 */ }
1378
+ }
1379
+ if (!ed || typeof ed.addAction !== 'function') return
1380
+ const changeMap = svnChangeMapOf(scope)
1381
+ const activeEntry = activeRef.current ? changeMap[activeRef.current] : undefined
1382
+ for (const item of svnEditorActions(svnStatusRef.current, {
1383
+ versioned: activeEntry ? activeEntry.versioned : true,
1384
+ status: activeEntry?.status,
1385
+ diffable: activeRef.current ? isSvnDiffable(activeRef.current, activeEntry?.status) : true,
1386
+ })) {
1387
+ const handle = ed.addAction({
1388
+ id: item.id,
1389
+ label: item.label,
1390
+ contextMenuGroupId: '2_edrv',
1391
+ precondition: 'editorTextFocus',
1392
+ run: (edx) => {
1393
+ const relPath = modelPathOf(edx)
1394
+ if (item.kind === 'tortoise') { menuHandlersRef.current?.svnTortoise?.(item.tortoiseAction, relPath); return }
1395
+ // 自研动作经动作目录统一执行(与树/页签/命令栏同一执行器)
1396
+ const def = SVN_ACTION_BY_ID[item.actionId]
1397
+ if (!def) return
1398
+ runSvnAction(def, {
1399
+ sessionId,
1400
+ scope,
1401
+ path: def.id === 'update' ? (relPath ?? '') : relPath,
1402
+ notify: (message) => setStatus(message),
1403
+ openSvnDiff: (p) => runSvnDiffBase(p),
1404
+ openSvnLog: (p) => openSvnLog(p ?? relPath),
1405
+ refreshChanges: () => refreshSvnChanges(sessionId, scope),
1406
+ })
1407
+ },
1408
+ })
1409
+ svnMenuDisposersRef.current.push(handle?.dispose ? () => handle.dispose() : null)
1410
+ }
1411
+ }
1412
+
1413
+ // Monaco 右键 SVN 组随 svn 状态与变更清单增减;编辑器尚未创建时 no-op(创建时由 ensureEditor 补注册)
1414
+ React.useEffect(() => {
1415
+ syncSvnMenuActs(editorRef.current)
1416
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1417
+ }, [monaco, svnStatus, svnChanges])
1418
+
1216
1419
  const getModel = (path, text) => {
1217
1420
  const cache = modelsRef.current
1218
1421
  let model = cache.get(path)
@@ -1358,14 +1561,17 @@ export function EditorView(props) {
1358
1561
  onEditRef.current = onEdit
1359
1562
 
1360
1563
  // model 同步(当前内容)
1564
+ // ⚠️ 必须以 contentReady(contentPath === active)为门,不能用 `content !== null`:
1565
+ // 切页签的那一帧 content 仍是**上一个文件**的内容,据此建 model 会先塞入陈旧文本,
1566
+ // 待真实内容到达再由 getModel→setValue 覆盖,光标随之被重置(见下方跳转 effect 注释)。
1361
1567
  React.useEffect(() => {
1362
- if (!monaco || !editorRef.current || !active || content === null) return
1568
+ if (!monaco || !editorRef.current || !active || !contentReady) return
1363
1569
  const ed = editorRef.current
1364
1570
  const model = getModel(active, content)
1365
1571
  if (ed.getModel() !== model) ed.setModel(model)
1366
1572
  restoreViewState(active)
1367
1573
  setLoadStage((prev) => ({ progress: Math.max(96, prev.progress), message: '创建编辑器视图…' }))
1368
- }, [monaco, active, content])
1574
+ }, [monaco, active, content, contentReady])
1369
1575
 
1370
1576
  // PDF 面板外壳 ref 回调(div 仅在 PDF 分支渲染,refs 先于 effect 就绪)
1371
1577
  const ensurePdfHost = (node) => { pdfHostRef.current = node }
@@ -1406,6 +1612,9 @@ export function EditorView(props) {
1406
1612
  saveViewStateRef.current?.()
1407
1613
  diffRendererRef.current?.dispose?.()
1408
1614
  hideReferencesOverlay()
1615
+ // 跳转目标高亮的计时器随卸载清理(装饰随编辑器 dispose 一并消失)
1616
+ if (navFlashTimerRef.current) { clearTimeout(navFlashTimerRef.current); navFlashTimerRef.current = null }
1617
+ navFlashRef.current = []
1409
1618
  if (editorRef.current) { editorRef.current.dispose(); editorRef.current = null }
1410
1619
  // model 不在此销毁:跨挂载缓存按作用域存活(modelCache 切作用域时统一释放)
1411
1620
  for (const ctl of pdfCtlRef.current.values()) ctl.destroy()
@@ -1468,11 +1677,7 @@ export function EditorView(props) {
1468
1677
  // 有选区时额外显示「选中内容」项:context key edrvSelection 由光标选区变化驱动。
1469
1678
  // 路径/选区在点击时从 model/editor 实时读取(不依赖闭包里的过期 active)。
1470
1679
  const menuHandlers = () => menuHandlersRef.current
1471
- const pathOf = (edx) => {
1472
- const model = edx?.getModel?.()
1473
- const uriPath = model?.uri?.path
1474
- return uriPath ? decodeURIComponent(String(uriPath).replace(/^\//, '')) : null
1475
- }
1680
+ const pathOf = modelPathOf
1476
1681
  const selectionOf = (edx) => {
1477
1682
  const s = edx?.getSelection?.()
1478
1683
  if (!s) return null
@@ -1532,6 +1737,8 @@ export function EditorView(props) {
1532
1737
  id: 'edrv.showCommands', label: '显示所有命令', contextMenuGroupId: '1_edrv',
1533
1738
  run: () => window.dispatchEvent(new CustomEvent('edrv.command.showCommands')),
1534
1739
  })
1740
+ // SVN 组按创建时的最新状态注册(此后由 [monaco, svnStatus] effect 随状态增减)
1741
+ syncSvnMenuActs(ed)
1535
1742
  bindLspUnderline(ed, m)
1536
1743
  // AI 补全:编辑器实例登记(差异静默判定用)+ Alt+\ 手动触发 ghost text
1537
1744
  trackAiEditor(ed)
@@ -1573,13 +1780,48 @@ export function EditorView(props) {
1573
1780
  editorRef.current = ed
1574
1781
  }, [])
1575
1782
 
1576
- // 打开文件后的跳转(导航历史恢复/差异聚焦/搜索行列跳转,内容就绪后执行一次)
1783
+ /**
1784
+ * 跳转落地后给目标区域挂临时高亮(默认 1.2s 后自动清除)。
1785
+ *
1786
+ * 为什么自绘:原生 `_openReference` 跳转后会挂 `symbolHighlight` 装饰并在 350ms 后清除,
1787
+ * 但本插件用 registerEditorOpener 接管了打开动作(自行派发 edrv:open-editor),
1788
+ * 原生那段续作不会执行 → 跳转后没有任何目标提示。这里补上同等语义的高亮。
1789
+ * 装饰 id 独立保存,与差异渲染(diffRender)和 Ctrl+hover 下划线(underline)互不干扰;
1790
+ * 连续跳转先替换旧装饰,避免叠加残留。
1791
+ * @author ddj 2026年09月17号
1792
+ * @param ed Monaco 编辑器
1793
+ * @param target 目标位置 { line, column, endLine?, endColumn? }(均 1-based)
1794
+ */
1795
+ const flashNavTarget = (ed, target) => {
1796
+ const model = ed?.getModel?.()
1797
+ if (!model || !target) return
1798
+ const range = navFlashRangeOf(model, target)
1799
+ if (!range) return
1800
+ if (navFlashTimerRef.current) { clearTimeout(navFlashTimerRef.current); navFlashTimerRef.current = null }
1801
+ navFlashRef.current = ed.deltaDecorations(navFlashRef.current, [{
1802
+ range,
1803
+ options: { className: 'edrv-nav-target', isWholeLine: false },
1804
+ }])
1805
+ navFlashTimerRef.current = setTimeout(() => {
1806
+ navFlashTimerRef.current = null
1807
+ const current = editorRef.current
1808
+ navFlashRef.current = current ? current.deltaDecorations(navFlashRef.current, []) : []
1809
+ }, NAV_FLASH_MS)
1810
+ }
1811
+
1812
+ // 打开文件后的跳转(导航历史恢复/差异聚焦/搜索与 LSP 行列跳转,目标内容就绪后执行一次)
1813
+ //
1814
+ // ⚠️ 门控必须用 contentReady(contentPath === active),不能用 `content === null`:
1815
+ // 跨文件跳转时切页签的那一帧 content 仍是**上一个文件**的内容(非 null),据此判定
1816
+ // 「已就绪」会提前定位;随后真实内容到达触发 model 重设/内容写入,Monaco 会重置光标,
1817
+ // 落点丢失且 pendingFocus 已消费,最终停在 {1,1}。实测(v0.4.6,Ctrl+点击枚举成员):
1818
+ // opener 正确派发 line=349,落地后编辑器实际停在 1:1 且无高亮;同文件跳转不换内容故不复现。
1577
1819
  React.useEffect(() => {
1578
1820
  const ed = editorRef.current
1579
- if (!ed || content === null) return
1821
+ if (!ed || !contentReady) return
1580
1822
  // 导航历史恢复优先:viewState 快照恢复滚动/折叠,行列补定位
1581
1823
  const nav = navPendingRef.current
1582
- if (nav && nav.path === active) {
1824
+ if (nav && sameFile(nav.path, active)) {
1583
1825
  navPendingRef.current = null
1584
1826
  if (nav.viewState) {
1585
1827
  try { ed.restoreViewState(nav.viewState) } catch (e) { /* 非法快照忽略(行号越界自动兜底) */ }
@@ -1592,12 +1834,15 @@ export function EditorView(props) {
1592
1834
  return
1593
1835
  }
1594
1836
  const pf = pendingFocusRef.current
1595
- if (!pf || pf.path !== active) return
1837
+ if (!pf || !sameFile(pf.path, active)) return
1596
1838
  if (pf.line != null) {
1597
- // 搜索命中跳转:直接定位到行/列(不依赖差异区域)
1839
+ // 搜索命中 / LSP 跳转:直接定位到行/列(不依赖差异区域)并高亮目标区域
1598
1840
  pendingFocusRef.current = null
1599
- ed.revealLineInCenter(Math.max(1, pf.line))
1600
- ed.setPosition({ lineNumber: Math.max(1, pf.line), column: Math.max(1, pf.column ?? 1) })
1841
+ const line = Math.max(1, pf.line)
1842
+ const column = Math.max(1, pf.column ?? 1)
1843
+ ed.revealLineInCenter(line)
1844
+ ed.setPosition({ lineNumber: line, column })
1845
+ flashNavTarget(ed, { line, column, endLine: pf.endLine, endColumn: pf.endColumn })
1601
1846
  ed.focus()
1602
1847
  return
1603
1848
  }
@@ -1607,7 +1852,7 @@ export function EditorView(props) {
1607
1852
  ed.revealLineInCenter(Math.max(1, target.start ?? 1))
1608
1853
  ed.setPosition({ lineNumber: Math.max(1, target.start ?? 1), column: 1 })
1609
1854
  ed.focus()
1610
- }, [active, content, pendingRegions, focusRequest])
1855
+ }, [active, content, contentReady, pendingRegions, focusRequest])
1611
1856
 
1612
1857
  const jumpTo = (region) => {
1613
1858
  if (editorRef.current) {
@@ -1959,7 +2204,186 @@ export function EditorView(props) {
1959
2204
  }
1960
2205
 
1961
2206
  // 供 Monaco 原生右键菜单 addAction 读取的最新动作闭包(空依赖回调不随渲染重建)
1962
- menuHandlersRef.current = { addRefToChat, openInExplorer }
2207
+ /**
2208
+ * SVN 更新(CLI,全平台):页签菜单/命令面板/编辑区右键共用;path 缺省 = 工作区根。
2209
+ * P3:结果含条目级明细,成功后展示可关闭的结果条(冲突红字高亮)。
2210
+ */
2211
+ const runSvnUpdate = (relPath) => {
2212
+ void svnUpdate(sessionId, relPath ?? '').then((o) => {
2213
+ setStatus(o.message)
2214
+ // 更新会改工作副本状态:成功后重查变更清单(徽标/面板跟随)
2215
+ if (o.ok) refreshSvnChanges(sessionId, scope)
2216
+ // P3:有条目级明细时展示结果条(无变化则清空,不留旧结果)
2217
+ setSvnUpdateResult(o.ok ? { summary: o.message, entries: o.entries ?? [], conflicts: o.conflicts ?? [] } : null)
2218
+ })
2219
+ }
2220
+ /** 强制重查 SVN 变更清单(命令栏「SVN 刷新变更」)。 */
2221
+ const runSvnReload = () => {
2222
+ refreshSvnChanges(sessionId, scope)
2223
+ setStatus('已刷新 SVN 变更')
2224
+ }
2225
+
2226
+ /**
2227
+ * 打开基线差异视图(BASE 左 / 工作区右,只读)。
2228
+ * 编辑区右上差异视图以活动文件为语义:无活动文件直接提示,不猜目标。
2229
+ * @author ddj 2026年09月16号
2230
+ * @param relPath 目标工作区相对路径
2231
+ */
2232
+ const runSvnDiffBase = (relPath) => {
2233
+ if (!relPath) { setStatus('无活动文件'); return }
2234
+ setStatus('读取基线…')
2235
+ void svnDiffBase(sessionId, relPath).then((outcome) => {
2236
+ if (!outcome.ok) { setStatus(outcome.message); return }
2237
+ setSvnDiff({
2238
+ path: relPath,
2239
+ base: outcome.base,
2240
+ working: outcome.working,
2241
+ reason: outcome.reason,
2242
+ message: outcome.base === null ? '' : outcome.message,
2243
+ leftLabel: 'BASE',
2244
+ rightLabel: '工作区',
2245
+ scheme: 'edrv-svn-base',
2246
+ })
2247
+ setStatus(outcome.message)
2248
+ })
2249
+ }
2250
+
2251
+ /**
2252
+ * 打开某版本的差异视图(REV-1 ↔ REV 只读并排)。
2253
+ * 左侧缺失(该版本尚无此文件 = 新增)属正常语义,不改文案为错误。
2254
+ * @author ddj 2026年09月16号
2255
+ * @param relPath 目标工作区相对路径
2256
+ * @param revision 目标版本
2257
+ */
2258
+ const runSvnDiffRev = (relPath, revision) => {
2259
+ if (!relPath) { setStatus('无目标文件'); return }
2260
+ setStatus('读取 r' + revision + ' 差异…')
2261
+ void svnDiffRev(sessionId, relPath, revision).then((outcome) => {
2262
+ if (!outcome.ok) { setStatus('读取版本差异失败:' + (outcome.error ?? '未知错误')); return }
2263
+ const isAdd = outcome.left === null
2264
+ setSvnDiff({
2265
+ path: relPath,
2266
+ base: outcome.left,
2267
+ working: outcome.right,
2268
+ reason: outcome.reason,
2269
+ message: isAdd ? '该版本新增此文件(无上一版可比)' : '',
2270
+ leftLabel: 'r' + Math.max(1, revision - 1),
2271
+ rightLabel: 'r' + revision,
2272
+ scheme: 'edrv-svn-rev' + revision,
2273
+ })
2274
+ setStatus('已打开 r' + revision + ' 差异')
2275
+ })
2276
+ }
2277
+
2278
+ /**
2279
+ * 打开两个版本的差异视图(P1-2 比较两个修订;左右标签按传入顺序,不自动排序)。
2280
+ * 左右任一侧缺失(新增/删除)属正常语义,渲染为空而非报错。
2281
+ * @author ddj 2026年09月17号
2282
+ * @param relPath 目标工作区相对路径
2283
+ * @param revA 左侧版本(选中顺序在前)
2284
+ * @param revB 右侧版本(选中顺序在后)
2285
+ */
2286
+ const runSvnDiffPair = (relPath, revA, revB) => {
2287
+ if (!relPath) { setStatus('仅文件目标支持比较两个修订'); return }
2288
+ setStatus('读取 r' + revA + ' ↔ r' + revB + ' 差异…')
2289
+ void svnDiffPair(sessionId, relPath, revA, revB).then((outcome) => {
2290
+ if (!outcome.ok) { setStatus('读取版本差异失败:' + (outcome.error ?? '未知错误')); return }
2291
+ const notes = []
2292
+ if (outcome.left === null) notes.push('r' + revA + ' 无此文件')
2293
+ if (outcome.right === null) notes.push('r' + revB + ' 无此文件')
2294
+ setSvnDiff({
2295
+ path: relPath,
2296
+ base: outcome.left,
2297
+ working: outcome.right,
2298
+ reason: outcome.reason,
2299
+ message: notes.join(';'),
2300
+ leftLabel: 'r' + revA,
2301
+ rightLabel: 'r' + revB,
2302
+ scheme: 'edrv-svn-pair-' + revA + '-' + revB,
2303
+ })
2304
+ setStatus('已打开 r' + revA + ' ↔ r' + revB + ' 差异')
2305
+ })
2306
+ }
2307
+
2308
+ /**
2309
+ * 打开「某版本 ↔ 工作副本」差异视图(P1-1 Compare with working copy,只读并排)。
2310
+ * 左侧缺失(该版本尚无此文件)属正常语义,渲染为空而非报错。
2311
+ * @author ddj 2026年09月17号
2312
+ * @param relPath 目标工作区相对路径
2313
+ * @param revision 左侧版本
2314
+ */
2315
+ const runSvnDiffWorking = (relPath, revision) => {
2316
+ if (!relPath) { setStatus('仅文件目标支持与工作副本比较'); return }
2317
+ setStatus('读取 r' + revision + ' ↔ 工作副本 差异…')
2318
+ void svnDiffWorking(sessionId, relPath, revision).then((outcome) => {
2319
+ if (!outcome.ok) { setStatus('读取工作副本差异失败:' + (outcome.error ?? '未知错误')); return }
2320
+ setSvnDiff({
2321
+ path: relPath,
2322
+ base: outcome.left,
2323
+ working: outcome.right,
2324
+ reason: outcome.reason,
2325
+ message: outcome.left === null ? '该版本尚无此文件(右侧为工作副本内容)' : '',
2326
+ leftLabel: 'r' + revision,
2327
+ rightLabel: '工作区',
2328
+ scheme: 'edrv-svn-work-' + revision,
2329
+ })
2330
+ setStatus('已打开 r' + revision + ' ↔ 工作副本 差异')
2331
+ })
2332
+ }
2333
+
2334
+ /**
2335
+ * 打开日志弹窗(P3):加载该目标的提交历史。
2336
+ * @author ddj 2026年09月16号
2337
+ * @param relPath 目标相对路径(缺省 = 工作区根)
2338
+ */
2339
+ const openSvnLog = (relPath) => {
2340
+ const target = relPath || ''
2341
+ dshTrace('svnLog.open', { target, scope, hasSession: Boolean(sessionId) })
2342
+ setSvnLog({ target, limit: SVN_LOG_PAGE })
2343
+ setSvnLogData(getSvnLog(scope, target, SVN_LOG_PAGE))
2344
+ setSvnLogError('')
2345
+ setSvnLogBusy(true)
2346
+ ensureSvnLog(sessionId, scope, target, SVN_LOG_PAGE)
2347
+ }
2348
+
2349
+ /** 加入版本控制(活动文件;未纳入版本控制的文件才有意义,host 侧会再校验)。 */
2350
+ const runSvnAdd = (relPath) => {
2351
+ if (!relPath) { setStatus('无活动文件'); return }
2352
+ void svnAdd(sessionId, [relPath]).then((o) => {
2353
+ setStatus(o.message)
2354
+ refreshSvnChanges(sessionId, scope)
2355
+ })
2356
+ }
2357
+
2358
+ /** 还原(活动文件,CLI):confirm 守卫,避免误触丢弃本地改动。 */
2359
+ const runSvnRevertCli = (relPath) => {
2360
+ if (!relPath) { setStatus('无活动文件'); return }
2361
+ if (!window.confirm('确认还原「' + relPath + '」的本地改动?新增(A)文件在磁盘上会保留,仅取消登记。')) return
2362
+ void svnRevert(sessionId, [relPath]).then((o) => {
2363
+ setStatus(o.message)
2364
+ refreshSvnChanges(sessionId, scope)
2365
+ // 当前打开的文件被还原:重载磁盘内容,避免编辑器仍显示已丢弃的改动
2366
+ if (o.ok && activeRef.current === relPath) reloadFile()
2367
+ })
2368
+ }
2369
+
2370
+ /** TortoiseSVN 动作(Windows):需要文件/目录目标(编辑区右键无活动文件时提示)。 */
2371
+ const runSvnTortoise = (action: SvnAction, relPath) => {
2372
+ if (!relPath) { setStatus('无活动文件'); return }
2373
+ void svnTortoise(sessionId, action, relPath).then((o) => setStatus(o.message))
2374
+ }
2375
+
2376
+ // 渲染期镜像(赋值在被引用 const 定义之后,防 TDZ):Monaco addAction 空依赖回调读取最新动作与 svn 状态
2377
+ svnStatusRef.current = svnStatus
2378
+ menuHandlersRef.current = {
2379
+ addRefToChat,
2380
+ openInExplorer,
2381
+ svnUpdate: runSvnUpdate,
2382
+ svnDiffBase: runSvnDiffBase,
2383
+ svnAdd: runSvnAdd,
2384
+ svnRevertCli: runSvnRevertCli,
2385
+ svnTortoise: runSvnTortoise,
2386
+ }
1963
2387
 
1964
2388
  /**
1965
2389
  * 复制文本到剪贴板(状态栏反馈;浏览器拒绝时提示,不抛异常)。
@@ -2009,6 +2433,28 @@ export function EditorView(props) {
2009
2433
  closeTab(activeRef.current)
2010
2434
  }
2011
2435
 
2436
+ /**
2437
+ * 页签菜单的 SVN 动作分派(由 shared 动作目录生成,与树菜单/命令栏共用执行器与显隐规则)。
2438
+ * @author ddj 2026年09月16号
2439
+ * @param path 右键目标页签路径
2440
+ * @returns id → 动作
2441
+ */
2442
+ const svnTabActions = (path) => {
2443
+ const out = {}
2444
+ for (const action of svnActionsFor('tab')) {
2445
+ out['svn-' + action.id] = () => runSvnAction(action, {
2446
+ sessionId,
2447
+ scope,
2448
+ path,
2449
+ notify: (message) => setStatus(message),
2450
+ openSvnDiff: (p) => runSvnDiffBase(p),
2451
+ openSvnLog: (p) => openSvnLog(p ?? path),
2452
+ refreshChanges: () => refreshSvnChanges(sessionId, scope),
2453
+ })
2454
+ }
2455
+ return out
2456
+ }
2457
+
2012
2458
  /**
2013
2459
  * 页签右键菜单动作表(按菜单条目 id 分派;全部读 ref 取最新状态,防陈旧闭包)。
2014
2460
  * @author ddj 2026年09月11号
@@ -2027,6 +2473,7 @@ export function EditorView(props) {
2027
2473
  'reveal-in-os': () => openInExplorer(path),
2028
2474
  'reveal-in-view': () => revealTabInView(path),
2029
2475
  'toggle-pinned': () => toggleTabPin(path),
2476
+ ...svnTabActions(path),
2030
2477
  })
2031
2478
 
2032
2479
  /**
@@ -2037,6 +2484,7 @@ export function EditorView(props) {
2037
2484
  const tabMenuEntries = () => {
2038
2485
  if (!tabMenu) return []
2039
2486
  const actions = tabMenuActions(tabMenu.path)
2487
+ const changeMap = svnChangeMapOf(scope)
2040
2488
  return buildTabMenu({
2041
2489
  path: tabMenu.path,
2042
2490
  tabs: tabsRef.current,
@@ -2045,6 +2493,12 @@ export function EditorView(props) {
2045
2493
  cwd,
2046
2494
  hasSession: Boolean(sessionId),
2047
2495
  canAddToConversation: Boolean(addToConversation),
2496
+ svnReady: Boolean(svnStatus?.managed && svnStatus?.svnCli),
2497
+ tortoiseReady: Boolean(svnStatus?.managed && svnStatus?.tortoise),
2498
+ // 自研替换时间线:自研就绪的能力对应 Tortoise 项不出现
2499
+ svnFeatures: svnStatus?.svnFeatures,
2500
+ // 目标文件的 SVN 状态:CLI 三项(比较/加入/还原)按它显隐
2501
+ svnStatusOfTarget: changeMap[tabMenu.path]?.status,
2048
2502
  closeChord: chordOf('edrv.closeTab'),
2049
2503
  }).map((entry) => Object.assign({}, entry, { onClick: actions[entry.id] }))
2050
2504
  }
@@ -2071,23 +2525,31 @@ export function EditorView(props) {
2071
2525
 
2072
2526
  const openFile = (path, focusDiff) => {
2073
2527
  if (!path) return
2528
+ // 打开文件即离开基线差异审阅态(差异视图是「当前文件」的临时视图)
2529
+ setSvnDiff(null)
2074
2530
  recordNav()
2075
- addTab(path, true)
2076
- if (focusDiff) pendingFocusRef.current = { path, region: null }
2531
+ // G9:统一归一为页签规范形态(差异栏/启动器/树/命令栏等入口一致)
2532
+ const normalized = addTabNorm(path, true)
2533
+ if (focusDiff && normalized) pendingFocusRef.current = { path: normalized, region: null }
2077
2534
  }
2078
2535
 
2079
2536
  /**
2080
- * 打开文件并跳转到指定行列(搜索面板命中跳转)。
2081
- * @author ddj 2026年08月26号
2537
+ * 打开文件并跳转到指定行列(搜索面板命中 / LSP 定义与引用跳转)。
2538
+ * 落地后会把目标区域短暂高亮(见 flashNavTarget),便于在长文件中一眼看到落点。
2539
+ * @author ddj 2026年08月26号 / 2026年09月17号
2082
2540
  * @param path 文件路径
2083
2541
  * @param line 目标行(缺省仅打开不跳转)
2084
2542
  * @param column 目标列(缺省 1)
2543
+ * @param endLine 目标区间结束行(LSP 定义选区,可空 = 按单词/整行推定)
2544
+ * @param endColumn 目标区间结束列(可空)
2085
2545
  */
2086
- const openFileAt = (path, line, column) => {
2546
+ const openFileAt = (path, line, column, endLine, endColumn) => {
2087
2547
  if (!path) return
2088
2548
  recordNav()
2089
- addTab(path, true)
2090
- pendingFocusRef.current = { path, region: null, line: line ?? null, column: column ?? 1 }
2549
+ // G9:同 openFile,先归一再进页签,保证待跳转路径与 active 同形态
2550
+ const normalized = addTabNorm(path, true)
2551
+ if (!normalized) return
2552
+ pendingFocusRef.current = { path: normalized, region: null, line: line ?? null, column: column ?? 1, endLine: endLine ?? null, endColumn: endColumn ?? null }
2091
2553
  setFocusRequest((value) => value + 1)
2092
2554
  }
2093
2555
 
@@ -2257,7 +2719,9 @@ export function EditorView(props) {
2257
2719
  }, '保留本地')))
2258
2720
  }
2259
2721
 
2260
- const otherFiles = sum.pendingFiles.filter((f) => f.path !== active)
2722
+ // G9:记录路径为绝对、active 为页签规范形态(相对),必须经 sameFile 归一比较,
2723
+ // 否则当前活动文件会被误列入「其他差异文件」。
2724
+ const otherFiles = sum.pendingFiles.filter((f) => !sameFile(f.path, active))
2261
2725
 
2262
2726
  /**
2263
2727
  * 渲染编辑器/文件加载进度面板。
@@ -2281,7 +2745,21 @@ export function EditorView(props) {
2281
2745
  retry ? React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: retry }, '重试') : null)
2282
2746
 
2283
2747
  let body
2284
- if (!monaco && !monacoErr) {
2748
+ if (svnDiff) {
2749
+ // 差异视图优先占位(覆盖编辑器主体):只读审阅态,关闭后回到原编辑器
2750
+ body = React.createElement(SvnDiffPanel, {
2751
+ key: 'edrv-svndiff:' + svnDiff.scheme + ':' + svnDiff.path,
2752
+ monaco,
2753
+ path: svnDiff.path,
2754
+ base: svnDiff.base,
2755
+ working: svnDiff.working,
2756
+ reason: svnDiff.reason,
2757
+ message: svnDiff.message,
2758
+ leftLabel: svnDiff.leftLabel,
2759
+ rightLabel: svnDiff.rightLabel,
2760
+ onClose: () => setSvnDiff(null),
2761
+ })
2762
+ } else if (!monaco && !monacoErr) {
2285
2763
  body = loadingBody(loadStage.message, loadStage.progress)
2286
2764
  } else if (monacoErr) {
2287
2765
  body = React.createElement('div', { className: 'edrv-empty' },
@@ -2427,6 +2905,14 @@ export function EditorView(props) {
2427
2905
  outlineSources: props.outlineSources,
2428
2906
  fileMenuItems: props.fileMenuItems,
2429
2907
  addToConversation,
2908
+ svn: svnStatus,
2909
+ // SVN 变更:面板列表 + 文件树徽标查表(客户端合成,不改 listDir 契约)
2910
+ svnChanges,
2911
+ svnChangeMap: svnChangeMapOf(scope),
2912
+ openSvnDiff: (p) => runSvnDiffBase(p),
2913
+ refreshSvnChanges: () => refreshSvnChanges(sessionId, scope),
2914
+ openSvnLog: (p) => openSvnLog(p),
2915
+ confirm: (message) => (typeof window === 'undefined' ? false : window.confirm(message)),
2430
2916
  notify: (message) => setStatus(message),
2431
2917
  }
2432
2918
 
@@ -2450,11 +2936,49 @@ export function EditorView(props) {
2450
2936
  React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', title: '复制安装命令', onClick: copyInstallCmd }, '复制命令'),
2451
2937
  React.createElement('button', { className: 'edrv-side-hint-close', title: '关闭提示', 'aria-label': '关闭提示', onClick: dismissHint }, '×'))
2452
2938
  : null
2939
+ /**
2940
+ * SVN 更新结果条(P3):逐条展示 update 的条目级状态,冲突红字高亮,可关闭。
2941
+ * 只在本次 update 有条目明细时渲染(无明细/失败时不占用编辑区高度)。
2942
+ * @author ddj 2026年09月16号
2943
+ * @returns 结果条元素或 null
2944
+ */
2945
+ const svnResultBar = () => {
2946
+ const result = svnUpdateResult
2947
+ if (!result || !result.entries.length) return null
2948
+ const conflicts = new Set(result.conflicts ?? [])
2949
+ return React.createElement('div', { className: 'edrv-svnres' },
2950
+ React.createElement('span', { className: 'edrv-svnres-head' }, 'SVN 更新'),
2951
+ React.createElement('div', { className: 'edrv-svnres-list' },
2952
+ result.entries.map((entry, idx) => React.createElement('span', {
2953
+ key: entry.path + ':' + idx,
2954
+ className: 'edrv-svnres-item' + (conflicts.has(entry.path) ? ' edrv-svnres-conflict' : ''),
2955
+ title: entry.label + ' ' + entry.path,
2956
+ }, entry.action + ' ' + entry.path))),
2957
+ React.createElement('span', { style: { flex: 1 } }),
2958
+ (conflicts.size
2959
+ ? React.createElement('button', {
2960
+ className: 'edrv-svn-act',
2961
+ title: '在工作区中显示第一个冲突文件',
2962
+ onClick: () => {
2963
+ const first = [...conflicts][0]
2964
+ const hit = result.entries.find((entry) => entry.path === first)
2965
+ if (hit?.path) openFile(hit.path, false)
2966
+ },
2967
+ }, '查看冲突文件')
2968
+ : null),
2969
+ React.createElement('button', {
2970
+ className: 'edrv-svn-act',
2971
+ title: '关闭结果条',
2972
+ onClick: () => setSvnUpdateResult(null),
2973
+ }, '✕'))
2974
+ }
2975
+
2453
2976
  const mainCol = React.createElement('div', { className: 'edrv-main-col', style: { flex: 1, minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' } },
2454
2977
  pathBar,
2455
2978
  tabRow,
2456
2979
  sideHintEl,
2457
2980
  diskBanner(),
2981
+ svnResultBar(),
2458
2982
  editorArea,
2459
2983
  statusBar)
2460
2984
  // 编辑器根节点按 composer 顶部边界动态限高,底部对话区域继续由 DSH 原生渲染。
@@ -2480,16 +3004,89 @@ export function EditorView(props) {
2480
3004
  // 命令栏浮层(Ctrl+Shift+P / F1):portal 到 body,但需挂在插件自己的 React 树里;
2481
3005
  // 三种布局形态都渲染 EditorView,故这里挂载即可,多宿主由 store 单实例认领兜底。
2482
3006
  const paletteEl = React.createElement(CommandPalette, { key: 'edrv-palette', sessionId })
3007
+ // SVN 日志弹窗(P3):portal 到 body,与命令栏/片段选择器同一挂载方式
3008
+ const svnLogEl = svnLog
3009
+ ? React.createElement(SvnLogDialog, {
3010
+ key: 'edrv-svnlog',
3011
+ target: svnLog.target,
3012
+ entries: svnLogData,
3013
+ wcRev: svnLogWcRev,
3014
+ truncated: svnLogTruncated(scope, svnLog.target || '', svnLog.limit || SVN_LOG_PAGE, svnLogOptsOf(svnLog)),
3015
+ error: svnLogError,
3016
+ busy: svnLogBusy,
3017
+ stopOnCopy: svnLog.soc === true,
3018
+ includeMerged: svnLog.merged === true,
3019
+ rangeActive: Array.isArray(svnLog.range),
3020
+ onShowRange: (start, end) => {
3021
+ const next = { ...svnLog, range: [start, end], limit: SVN_LOG_PAGE }
3022
+ setSvnLog(next)
3023
+ setSvnLogBusy(true)
3024
+ // 换键取数(range 维度隔离缓存)
3025
+ ensureSvnLog(sessionId, scope, next.target || '', SVN_LOG_PAGE, false, svnLogOptsOf(next))
3026
+ },
3027
+ onRangeReset: () => {
3028
+ const next = { ...svnLog, range: null, limit: SVN_LOG_PAGE }
3029
+ setSvnLog(next)
3030
+ setSvnLogBusy(true)
3031
+ ensureSvnLog(sessionId, scope, next.target || '', SVN_LOG_PAGE, false, svnLogOptsOf(next))
3032
+ },
3033
+ onStopCopy: (on) => {
3034
+ const next = { ...svnLog, soc: on }
3035
+ setSvnLog(next)
3036
+ setSvnLogBusy(true)
3037
+ // 换键取数(soc 维度隔离缓存);已加载过默认窗口时无需 force
3038
+ ensureSvnLog(sessionId, scope, next.target || '', next.limit || SVN_LOG_PAGE, false, svnLogOptsOf(next))
3039
+ },
3040
+ onShowMerged: (on) => {
3041
+ const next = { ...svnLog, merged: on }
3042
+ setSvnLog(next)
3043
+ setSvnLogBusy(true)
3044
+ // 换键取数(mrg 维度隔离缓存,P1-5);与 soc 同口径
3045
+ ensureSvnLog(sessionId, scope, next.target || '', next.limit || SVN_LOG_PAGE, false, svnLogOptsOf(next))
3046
+ },
3047
+ onRefresh: () => {
3048
+ setSvnLogError('')
3049
+ setSvnLogBusy(true)
3050
+ refreshSvnLog(sessionId, scope, svnLog.target || '', svnLog.limit || SVN_LOG_PAGE, svnLogOptsOf(svnLog))
3051
+ },
3052
+ onLoadMore: () => {
3053
+ // P1-8:按已加载上限递增(防旧 limit 倒退),force 绕过缓存命中后原地替换同键数据
3054
+ const opts = svnLogOptsOf(svnLog)
3055
+ const loaded = svnLogLoadedLimit(scope, svnLog.target || '', opts) || SVN_LOG_PAGE
3056
+ setSvnLogBusy(true)
3057
+ setSvnLog({ ...svnLog, limit: loaded + SVN_LOG_PAGE })
3058
+ ensureSvnLog(sessionId, scope, svnLog.target || '', loaded + SVN_LOG_PAGE, true, opts)
3059
+ },
3060
+ onShowAll: () => {
3061
+ setSvnLogBusy(true)
3062
+ setSvnLog({ ...svnLog, limit: SVN_LOG_SHOW_ALL_LIMIT })
3063
+ ensureSvnLog(sessionId, scope, svnLog.target || '', SVN_LOG_SHOW_ALL_LIMIT, true, svnLogOptsOf(svnLog))
3064
+ },
3065
+ onOpenDiff: (relPath, revision) => { setSvnLog(null); runSvnDiffRev(relPath, revision) },
3066
+ onComparePair: (revA, revB) => { setSvnLog(null); runSvnDiffPair(svnLog.target || '', revA, revB) },
3067
+ onCompareWorking: (revision) => { setSvnLog(null); runSvnDiffWorking(svnLog.target || '', revision) },
3068
+ onOpenFile: (relPath) => { setSvnLog(null); openFile(relPath, false) },
3069
+ onClose: () => { setSvnLog(null); setSvnLogError('') },
3070
+ })
3071
+ : null
3072
+ // 诊断日志弹窗:portal 到 body,与命令栏/SVN 日志弹窗同一挂载方式
3073
+ const dlogEl = dlogOpen
3074
+ ? React.createElement(LogDialog, { key: 'edrv-dlog', sessionId, onClose: () => setDlogOpen(false) })
3075
+ : null
2483
3076
  const rootEl = layout === 'side'
2484
3077
  ? React.createElement('div', { ref: viewRootRef, 'data-edrv-view': '1', 'data-edrv-layout': 'side', className: 'edrv-view-side', style: Object.assign({}, baseStyle, { height: '100%' }) },
2485
3078
  editorRow,
2486
3079
  tabMenuEl,
2487
3080
  paletteEl,
3081
+ svnLogEl,
3082
+ dlogEl,
2488
3083
  snippetPickerEl)
2489
3084
  : React.createElement('div', { ref: viewRootRef, 'data-edrv-view': '1', style: Object.assign({}, baseStyle, { height: 'var(--edrv-editor-height, 100%)', maxHeight: 'var(--edrv-editor-height, 100%)' }) },
2490
3085
  editorRow,
2491
3086
  tabMenuEl,
2492
3087
  paletteEl,
3088
+ svnLogEl,
3089
+ dlogEl,
2493
3090
  snippetPickerEl)
2494
3091
  return rootEl
2495
3092
  }