dsh-vscode-mode 0.25.1 → 0.27.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 (42) hide show
  1. package/README.md +25 -11
  2. package/lib/client.js +1969 -1344
  3. package/lib/client.js.map +2 -2
  4. package/lib/index.js +1443 -716
  5. package/lib/index.js.map +1 -1
  6. package/package.json +1 -1
  7. package/src/client/compatSummary.ts +4 -0
  8. package/src/client/contextMenusOfficial.ts +80 -0
  9. package/src/client/forgeBridge.ts +28 -0
  10. package/src/client/index.ts +18 -3
  11. package/src/client/monaco/lsp/lspClient.ts +25 -7
  12. package/src/client/officialSidebar.ts +15 -3
  13. package/src/client/reviewBar.ts +3 -0
  14. package/src/client/reviewBatch.ts +9 -1
  15. package/src/client/reviewKeepAll.ts +72 -0
  16. package/src/client/sidebar/menuItems.ts +6 -0
  17. package/src/client/sidebar/panels/FileExplorer.ts +26 -1
  18. package/src/client/sidebar/types.ts +3 -0
  19. package/src/client/state/diffSummary.ts +34 -7
  20. package/src/client/state/recordsFeed.ts +40 -3
  21. package/src/client/state/reviewFeed.ts +9 -2
  22. package/src/client/tabMenu.ts +5 -0
  23. package/src/client/ui/ClaimRouter.ts +11 -5
  24. package/src/client/ui/EditorPane.ts +9 -1
  25. package/src/client/ui/EditorView.ts +164 -39
  26. package/src/client/ui/OfficialSideTab.ts +13 -5
  27. package/src/client/ui/ReviewAllConfirm.ts +1 -1
  28. package/src/index.ts +1 -1
  29. package/src/pending/capture.ts +96 -47
  30. package/src/pending/persist.ts +94 -5
  31. package/src/pending/store.ts +171 -58
  32. package/src/pendingCompat.ts +72 -9
  33. package/src/pendingRpc.ts +10 -10
  34. package/src/reviewCapture.ts +64 -23
  35. package/src/reviewDb.ts +119 -8
  36. package/src/reviewEvidence.ts +1 -1
  37. package/src/reviewRpc.ts +115 -9
  38. package/src/rpc.ts +14 -10
  39. package/src/shared/review.ts +21 -0
  40. package/src/shared/rpc.ts +3 -1
  41. package/src/store.ts +44 -0
  42. package/src/toolTargets.ts +63 -0
@@ -4,7 +4,7 @@ import type { ReviewPage } from '../../shared/review.js'
4
4
 
5
5
  export interface ReviewFeed { page: ReviewPage | null; error: string }
6
6
  type Listener = (feed: ReviewFeed) => void
7
- interface Entry { listeners: Set<Listener>; value: ReviewFeed; controller: AbortController; timer?: ReturnType<typeof setTimeout>; loading: boolean; again: boolean; onRefresh: () => void }
7
+ interface Entry { listeners: Set<Listener>; value: ReviewFeed; controller: AbortController; timer?: ReturnType<typeof setTimeout>; loading: boolean; again: boolean; onRefresh: () => void; onVisible?: () => void }
8
8
  const feeds = new Map<string, Entry>()
9
9
 
10
10
  /** @private @author ddj 2026年10月26号 @param entry Shared feed. Notify mounted observers only. */
@@ -13,6 +13,7 @@ function notify(entry: Entry): void { for (const listener of entry.listeners) li
13
13
  /** @private @author ddj 2026年10月26号 @param sessionId Scope. @param entry Current generation. @returns First-page metadata update; no overlap or late publication. */
14
14
  async function refresh(sessionId: string, entry: Entry, force = false): Promise<void> {
15
15
  if (entry.controller.signal.aborted) return
16
+ if (typeof document !== 'undefined' && document.hidden) { entry.again = true; return }
16
17
  if (entry.loading) { entry.again = true; return }
17
18
  clearTimeout(entry.timer)
18
19
  entry.loading = true
@@ -46,14 +47,20 @@ export function subscribeReview(sessionId: string, listener: Listener): () => vo
46
47
  entry = { listeners: new Set(), value: { page: null, error: '' }, controller: new AbortController(), loading: false, again: false, onRefresh }
47
48
  feeds.set(sessionId, entry)
48
49
  if (typeof window !== 'undefined') window.addEventListener('edrv:refresh', onRefresh)
50
+ if (typeof document !== 'undefined') {
51
+ const current = entry
52
+ entry.onVisible = () => { if (!document.hidden) void refresh(sessionId, current) }
53
+ document.addEventListener('visibilitychange', entry.onVisible)
54
+ }
49
55
  void refresh(sessionId, entry)
50
56
  }
51
57
  entry.listeners.add(listener); listener(entry.value)
52
58
  return () => {
53
59
  entry.listeners.delete(listener)
54
- if (entry.listeners.size) return
60
+ if (entry.listeners.size || feeds.get(sessionId) !== entry) return
55
61
  entry.controller.abort(); clearTimeout(entry.timer); feeds.delete(sessionId)
56
62
  if (typeof window !== 'undefined') window.removeEventListener('edrv:refresh', entry.onRefresh)
63
+ if (entry.onVisible && typeof document !== 'undefined') document.removeEventListener('visibilitychange', entry.onVisible)
57
64
  }
58
65
  }
59
66
 
@@ -48,6 +48,7 @@ export interface TabMenuState {
48
48
  canSplit?: boolean
49
49
  canFloat?: boolean
50
50
  canReturn?: boolean
51
+ forgeReady?: boolean
51
52
  }
52
53
 
53
54
  /** 一条页签菜单项(ContextMenu 的展示形状 + 动作 id)。 */
@@ -122,6 +123,10 @@ export function buildTabMenu(state: TabMenuState): TabMenuEntry[] {
122
123
  label: '添加到对话',
123
124
  disabled: !(hasSession && canAddToConversation),
124
125
  },
126
+ ...(state.forgeReady ? [
127
+ { id: 'forge-open', label: '打开 Forge' },
128
+ { id: 'forge-local', label: '查看本地变更(Forge)' },
129
+ ] : []),
125
130
  { id: 'close', label: '关闭', separator: true, ...(closeChord ? { hint: closeChord } : {}) },
126
131
  { id: 'close-others', label: '关闭其他', disabled: nothingToClose(closeOthers(tabs, path, active), tabs) },
127
132
  { id: 'close-right', label: '关闭右侧标签页', disabled: nothingToClose(closeRight(tabs, path, active), tabs) },
@@ -8,7 +8,7 @@
8
8
  * 作者 ddj 2026年09月10号
9
9
  */
10
10
  import React from 'react'
11
- import { forwardToEditor, parseOfficialFileAddress, resolveNavLine } from '../officialSidebar.js'
11
+ import { forwardToEditor, parseOfficialFileAddress, resolveNavLine, resolveNavColumn } from '../officialSidebar.js'
12
12
  import { log } from '../log.js'
13
13
 
14
14
  /** 转发尝试次数(含首次)与重试间隔。 */
@@ -68,17 +68,22 @@ export function createClaimRouter(spec) {
68
68
  const doneRef = React.useRef(-1)
69
69
 
70
70
  React.useEffect(() => {
71
- if (revision === doneRef.current) return
72
- doneRef.current = revision
71
+ const intent = JSON.stringify([props?.sessionId, tab?.id, revision, navigation?.address])
72
+ if (intent === doneRef.current) return
73
73
  const parsed = parseOfficialFileAddress(navigation?.address)
74
74
  if (!parsed) {
75
75
  setFailed(true)
76
76
  return
77
77
  }
78
78
  const line = resolveNavLine(navigation?.params)
79
+ const column = resolveNavColumn(navigation?.params)
80
+ let alive = true
81
+ setFailed(false)
79
82
  let left = FORWARD_ATTEMPTS
80
83
  const attempt = () => {
81
- if (forwardToEditor(service, parsed.path, line, navigation?.params?.preview)) {
84
+ if (!alive || tab?.signal?.aborted) return
85
+ if (forwardToEditor(typeof tab?.actions?.openTab === 'function' ? tab.actions : service, parsed.path, line, navigation?.params?.preview, column)) {
86
+ doneRef.current = intent
82
87
  closeTab(tab)
83
88
  return
84
89
  }
@@ -87,7 +92,8 @@ export function createClaimRouter(spec) {
87
92
  else setFailed(true)
88
93
  }
89
94
  attempt()
90
- }, [revision])
95
+ return () => { alive = false }
96
+ }, [revision, props?.sessionId, tab?.id, navigation?.address])
91
97
 
92
98
  if (!failed || typeof fallback !== 'function') return null
93
99
  return React.createElement(fallback, props)
@@ -43,7 +43,15 @@ export function EditorPane(props: PaneProps): React.ReactElement {
43
43
  /** @private @author ddj 2026年10月14号 @param event Close press. Never start a drag. */
44
44
  function stop(event: React.PointerEvent): void { event.stopPropagation() }
45
45
  /** @private @author ddj 2026年10月14号 @param event Keyboard activation. */
46
- function key(event: React.KeyboardEvent): void { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); props.onSelect(group.id, tab.path) } }
46
+ function key(event: React.KeyboardEvent): void {
47
+ if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) {
48
+ event.preventDefault(); event.stopPropagation()
49
+ const rect = event.currentTarget.getBoundingClientRect()
50
+ props.onMenu(tab.path, { clientX: rect.left, clientY: rect.bottom } as React.MouseEvent)
51
+ return
52
+ }
53
+ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); props.onSelect(group.id, tab.path) }
54
+ }
47
55
  return React.createElement('div', {
48
56
  key: tab.path, 'data-edrv-tab-path': tab.path, role: 'tab', tabIndex: 0, 'aria-selected': selected,
49
57
  className: 'edrv-tab' + (selected ? ' edrv-tab-active' : '') + (tab.pinned ? ' edrv-tab-pinned' : ''),
@@ -24,10 +24,12 @@ import { dbg, isDebug, rpc } from '../rpc.js'
24
24
  import { refreshFeed, subscribeErrors, subscribeRecords } from '../state/recordsFeed.js'
25
25
  import { subscribeReview, refreshReview } from '../state/reviewFeed.js'
26
26
  import { runReviewBatch } from '../reviewBatch.js'
27
+ import { runKeepAll } from '../reviewKeepAll.js'
28
+ import { toastDom } from '../externalOpen.js'
27
29
  import { ReviewAllConfirm } from './ReviewAllConfirm.js'
28
30
  import { reviewRegions } from '../../shared/reviewRegions.js'
29
31
  import { PENDING_PREFIX } from '../../shared/pendingReview.js'
30
- import { diffSummary, officialPath } from '../state/diffSummary.js'
32
+ import { coveredPathKeys, diffPathKey, diffSummary } from '../state/diffSummary.js'
31
33
  import { emitFileChanged, emitRefresh } from '../events.js'
32
34
  import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
33
35
  import { bindEditActions, runEditAction } from '../monaco/editActions.js'
@@ -134,7 +136,7 @@ import { clampToolbarPosition, loadToolbarPosition, saveToolbarPosition } from '
134
136
  import { editorModelPathOf } from '../dap/modelPath.js'
135
137
  import { dapTrace } from '../dap/trace.js'
136
138
  import { isSessionMissingError, sessionErrorText, sessionMissingDetail, sessionMissingTitle } from '../sessionError.js'
137
- import { officialDownView, SESSION_DOWN_NOTICE } from '../reviewBar.js'
139
+ import { officialDownView, SESSION_DOWN_NOTICE, SIDECAR_KEEP_DONE } from '../reviewBar.js'
138
140
 
139
141
  /** 官方差异读取失败前缀:仅用于清理同源陈旧错误,不吞并保存类错误。@author ddj 2026年10月27号 */
140
142
  const REVIEW_LOAD_ERROR = '官方差异读取失败:'
@@ -272,11 +274,11 @@ export function EditorView(props) {
272
274
  const [monacoErr, setMonacoErr] = React.useState(null)
273
275
  const [records, setRecords] = React.useState({})
274
276
  const [review, setReview] = React.useState({ page: null, error: '' })
277
+ React.useEffect(() => { setReview({ page: null, error: '' }) }, [sessionId])
275
278
  React.useEffect(() => {
276
- setReview({ page: null, error: '' })
277
- if (!sessionId) return undefined
279
+ if (!sessionId || props.visible === false) return undefined
278
280
  return subscribeReview(sessionId, setReview)
279
- }, [sessionId])
281
+ }, [sessionId, props.visible])
280
282
  const [tabs, setTabs] = React.useState([])
281
283
  const [active, setDiskActive] = React.useState(null)
282
284
  const [planTabs, setPlanTabs] = React.useState([])
@@ -565,14 +567,17 @@ export function EditorView(props) {
565
567
  layoutRef.current = layout
566
568
  if (!diffRendererRef.current) diffRendererRef.current = createDiffRenderer((sid, t) => dbg(sid, t))
567
569
 
570
+ // 已有权威通道(官方汇总 / 待审累计基线)在管的路径:旧工具记录不得再画出来,也不得被批量纳入
571
+ const coveredPaths = React.useMemo(() => coveredPathKeys(review.page), [review.page])
572
+
568
573
  const currentRecords = React.useMemo(() => {
569
574
  const list = []
570
575
  for (const rec of Object.values(records)) {
571
- if (rec.archived === true || officialPath(rec.path, review.page)) continue
576
+ if (rec.archived === true || coveredPaths.has(diffPathKey(rec.path))) continue
572
577
  if (sameFile(rec.path, active)) list.push(rec)
573
578
  }
574
579
  return list
575
- }, [records, active, cwd, review.page])
580
+ }, [records, active, cwd, coveredPaths])
576
581
 
577
582
  const reviewOpsRef = React.useRef(new Map())
578
583
  // 已自动结案的记录 id:防止「改动已不在文件中」的记录在刷新期间被反复提交判定
@@ -1449,12 +1454,12 @@ export function EditorView(props) {
1449
1454
 
1450
1455
  // 挂载:接入会话级共享订阅(单定时器由 recordsFeed 持有,本组件不再自建 interval)
1451
1456
  React.useEffect(() => {
1452
- if (!sessionId) return undefined
1457
+ if (!sessionId || props.visible === false) return undefined
1453
1458
  const offRecords = subscribeRecords(sessionId, applyRecords)
1454
1459
  const offErrors = subscribeErrors(sessionId, (message) => setError('list异常:' + message))
1455
1460
  refreshRecords()
1456
1461
  return () => { offRecords(); offErrors() }
1457
- }, [sessionId])
1462
+ }, [sessionId, props.visible])
1458
1463
 
1459
1464
  /**
1460
1465
  * 展示外部同步提示(按路径+类型去重:同一文件重复回调不重置已关闭的提示)。
@@ -1555,12 +1560,21 @@ export function EditorView(props) {
1555
1560
  })
1556
1561
 
1557
1562
  React.useEffect(() => {
1558
- const publishLsp = (servers) => setLspServers(Array.isArray(servers) ? servers : [])
1563
+ let alive = true
1564
+ let loading = false
1565
+ const publishLsp = (servers) => { if (alive) setLspServers(Array.isArray(servers) ? servers : []) }
1566
+ const refreshLsp = async () => {
1567
+ if (loading || props.visible === false || document.hidden) return
1568
+ loading = true
1569
+ try { publishLsp(await refreshStatus(true)) }
1570
+ finally { loading = false }
1571
+ }
1559
1572
  const unsubscribe = onLspProgress(publishLsp)
1560
- void refreshStatus(true).then(publishLsp)
1561
- const timer = setInterval(() => { void refreshStatus(true).then(publishLsp) }, 1500)
1562
- return () => { unsubscribe(); clearInterval(timer) }
1563
- }, [sessionId])
1573
+ void refreshLsp()
1574
+ const timer = setInterval(refreshLsp, 1500)
1575
+ document.addEventListener('visibilitychange', refreshLsp)
1576
+ return () => { alive = false; unsubscribe(); clearInterval(timer); document.removeEventListener('visibilitychange', refreshLsp) }
1577
+ }, [sessionId, props.visible])
1564
1578
 
1565
1579
  // LSP 会话:EditorView 持有权威 sessionId,直接写入 lspClient 并广播。
1566
1580
  // 直接调用不依赖事件时序(订阅可能晚于首次派发),广播供其它消费方复用。
@@ -1604,6 +1618,9 @@ export function EditorView(props) {
1604
1618
  const fileOpenRef = React.useRef(null)
1605
1619
  /** @private @author ddj 2026年10月08号 @param e Routed file request. Always uses the current workspace and save/preview callbacks. */
1606
1620
  fileOpenRef.current = (e) => {
1621
+ if (e?.detail?.sessionId && e.detail.sessionId !== sessionId) return
1622
+ if (e?.detail?.ownerId && e.detail.ownerId !== props?.ownerId) return
1623
+ if (!e?.detail?.ownerId && props?.visible === false) return
1607
1624
  const p = e?.detail?.path
1608
1625
  if (!p) return
1609
1626
  // G9:入口统一归一为工作区相对路径(差异栏/对话链接/LSP 跳转等都经此事件)
@@ -3315,8 +3332,10 @@ export function EditorView(props) {
3315
3332
  }
3316
3333
  // #endregion
3317
3334
  if (!res || !res.ok || !Array.isArray(res.results)) {
3318
- setError(res?.error ? String(res.error) : '批量处理失败')
3319
- return { ok: 0, fail: items.length, stale: 0 }
3335
+ const failure = res?.error ? String(res.error) : '批量处理失败'
3336
+ setError(failure)
3337
+ // 真实原因要一路带到调用处:调用方按计数兜底会把 Host 原文盖掉,用户只看得到泛化文案
3338
+ return { ok: 0, fail: items.length, stale: 0, error: failure }
3320
3339
  }
3321
3340
  let ok = 0
3322
3341
  let fail = 0
@@ -3336,8 +3355,9 @@ export function EditorView(props) {
3336
3355
  if (Object.keys(next).length) setRecords((prev) => Object.assign({}, prev, next))
3337
3356
  return { ok, fail, stale }
3338
3357
  }).catch((e) => {
3339
- setError('批量处理异常:' + String(e))
3340
- return { ok: 0, fail: items.length, stale: 0 }
3358
+ const failure = '批量处理异常:' + String(e)
3359
+ setError(failure)
3360
+ return { ok: 0, fail: items.length, stale: 0, error: failure }
3341
3361
  })
3342
3362
  }
3343
3363
 
@@ -3470,6 +3490,33 @@ export function EditorView(props) {
3470
3490
  finally { batchBusyRef.current = false; if (lifetime?.live) setReviewBatchBusy(false) }
3471
3491
  }
3472
3492
 
3493
+ /**
3494
+ * 批量失败提示:Host 原文优先,只有拿不到原因时才按计数兜底。
3495
+ * @author ddj 2026年11月3号
3496
+ * @param result actMany 的结果
3497
+ * @returns 提示文案;没有失败返回 null
3498
+ */
3499
+ const batchError = (result) => {
3500
+ if (!result.error) return batchFailText(result)
3501
+ // 会话没在当前端激活:写类 RPC 一律被 requireSession 拒绝(冷读列表却正常),
3502
+ // 只把「会话不存在」原文丢给用户会像新故障,这里改说清「没执行、记录没动、怎么恢复」。
3503
+ if (isSessionMissingError(result.error)) return '这个会话没在当前端激活,Keep/Undo 没有执行(记录未改动);在会话里发一条消息激活后重试'
3504
+ return result.error
3505
+ }
3506
+
3507
+ /**
3508
+ * 批量结果没有 Host 原文时的计数兜底文案。
3509
+ * @author ddj 2026年11月3号
3510
+ * @param result actMany 的结果
3511
+ * @returns 提示文案;没有失败返回 null
3512
+ */
3513
+ const batchFailText = (result) => {
3514
+ if (!result.fail) return null
3515
+ return result.stale
3516
+ ? `${result.fail} 处未处理(其中 ${result.stale} 处已不在文件中,只更新了记录),可刷新后重试`
3517
+ : `${result.fail} 处未处理,可刷新后重试`
3518
+ }
3519
+
3473
3520
  const acceptFile = () => {
3474
3521
  if (missingNow && !officialDetail) return decideMissing(false)
3475
3522
  if (officialDetail) return decideOfficial(false)
@@ -3479,8 +3526,11 @@ export function EditorView(props) {
3479
3526
  batchBusyRef.current = false
3480
3527
  reloadFile(true)
3481
3528
  emitRefresh({ scope, paths: active ? [active] : undefined, kind: 'structure' })
3482
- setStatus(decideStatus('已采纳 ', result))
3483
- if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
3529
+ // 能走到这里的都是「历史改动」通道:Keep 只把记录标成已看过,不写盘,
3530
+ // 用「已采纳」会让人以为文件被改过,这里必须换成准确措辞
3531
+ setStatus(result.ok ? `${SIDECAR_KEEP_DONE}(${result.ok} 处)` : decideStatus('已采纳 ', result))
3532
+ const message = batchError(result)
3533
+ if (message) setError(message)
3484
3534
  }).catch(() => { batchBusyRef.current = false })
3485
3535
  }
3486
3536
  const undoFile = () => {
@@ -3493,7 +3543,8 @@ export function EditorView(props) {
3493
3543
  reloadFile(true)
3494
3544
  emitRefresh({ scope, paths: active ? [active] : undefined, kind: 'structure' })
3495
3545
  setStatus(decideStatus('已不采纳 ', result))
3496
- if (result.fail) setError(result.fail + ' 处差异处理失败(可能已被后续修改影响),可刷新后重试')
3546
+ const message = batchError(result)
3547
+ if (message) setError(message)
3497
3548
  }).catch(() => { batchBusyRef.current = false })
3498
3549
  }
3499
3550
 
@@ -3501,7 +3552,7 @@ export function EditorView(props) {
3501
3552
  const allPending = React.useMemo(() => {
3502
3553
  const out = []
3503
3554
  for (const rec of Object.values(records)) {
3504
- if (rec.superseded === true || officialPath(rec.path, review.page)) continue
3555
+ if (rec.superseded === true || coveredPaths.has(diffPathKey(rec.path))) continue
3505
3556
  const perHunk = Array.isArray(rec.decisions?.perHunk) ? rec.decisions.perHunk : []
3506
3557
  const hunks = Array.isArray(rec.hunks) ? rec.hunks : []
3507
3558
  for (let i = 0; i < hunks.length; i++) {
@@ -3555,7 +3606,8 @@ export function EditorView(props) {
3555
3606
  if (reject || !missingNow) reloadFile(true)
3556
3607
  emitRefresh({ scope, paths, kind: 'structure' })
3557
3608
  setStatus(decideStatus(reject ? '已不采纳 ' : '已采纳 ', result) + `;删除文件 ${handled.size} 个`)
3558
- if (result.fail) setError(result.fail + ' 处差异处理失败,可刷新后重试')
3609
+ const message = batchError(result)
3610
+ if (message) setError(message)
3559
3611
  } catch (cause) { if (lifetime?.live) setError(String(cause)) }
3560
3612
  finally { batchBusyRef.current = false; if (lifetime?.live) refreshRecords() }
3561
3613
  }
@@ -3592,6 +3644,68 @@ export function EditorView(props) {
3592
3644
  return false
3593
3645
  }
3594
3646
 
3647
+ /** @private @author ddj 2026年11月03号 @description 批量通道的一次性完整快照(分页口径的 pending 只是页内计数)。 @returns 完整待审页 */
3648
+ async function batchSnapshot() {
3649
+ const res = await rpc('edrv.review.list', { sessionId, all: true })
3650
+ if (!res.ok) throw new Error(res.error)
3651
+ return res.page
3652
+ }
3653
+
3654
+ /** @private @author ddj 2026年11月03号 @param token 起批时的会话寿命 @returns 逐条 Undo 的精确部分完成(冲突处停止) */
3655
+ function undoAllBatch(token) {
3656
+ return runReviewBatch({
3657
+ snapshot: batchSnapshot,
3658
+ detail: async (id) => { const res = await rpc('edrv.review.detail', { sessionId, id }); if (!res.ok) throw new Error(res.error); return res.detail },
3659
+ decide: (detail, undo, operationId) => rpc('edrv.review.decide', { sessionId, id: detail.item.id, seq: detail.item.seq, revision: detail.item.revision, operationId, scope: 'file', decision: undo ? 'rejected' : 'accepted' }),
3660
+ guard: () => guardReviewAll(true, token), operation: batchOperation,
3661
+ }, true)
3662
+ }
3663
+
3664
+ /** @private @author ddj 2026年11月03号 @param token 起批时的会话寿命 @returns 一次 RPC 的批量 Keep 结果(跳过项不算失败) */
3665
+ function keepAllBatch(token) {
3666
+ return runKeepAll({
3667
+ snapshot: batchSnapshot,
3668
+ keepAll: (items, batchId) => rpc('edrv.review.keepAll', { sessionId, batchId, items }),
3669
+ guard: () => guardReviewAll(false, token),
3670
+ })
3671
+ }
3672
+
3673
+ /**
3674
+ * @private
3675
+ * @author ddj 2026年11月03号
3676
+ * @description: 批量结果落界面:刷新列表、状态栏计数;Keep 的跳过只做提示不当错误,
3677
+ * Undo 保持「遇冲突停止」口径。
3678
+ * @param result 批量结果
3679
+ * @param reject 本次是 Undo 还是 Keep
3680
+ * @param undoPath 起批时的活动文件
3681
+ * @param undoModel 起批时的编辑器模型
3682
+ * @param undoVersion 起批时的模型版本
3683
+ * @returns 官方段是否完全成功(legacy 通道是否继续)
3684
+ */
3685
+ function finishReviewAll(result, reject, undoPath, undoModel, undoVersion) {
3686
+ refreshReview(sessionId)
3687
+ if (result.paths.length) emitRefresh({ scope, paths: result.paths, kind: 'structure' })
3688
+ if (reject) {
3689
+ if (result.paths.some((path) => sameFile(path, undoPath)) && activeRef.current === undoPath
3690
+ && !dirtyRef.current[undoPath] && !savingRef.current.has(undoPath) && undoModel?.getVersionId?.() === undoVersion) reloadFile(true)
3691
+ setStatus(`Undo All:官方记录已完成 ${result.completed} / ${result.total}`)
3692
+ if (result.error) setError(`批量已停止,已完成 ${result.completed} 项,其余未处理。${result.error}`)
3693
+ return !result.error
3694
+ }
3695
+
3696
+ const skipped = result.skipped ?? []
3697
+ setStatus(`Keep All:已保留 ${result.completed} / ${result.total}` + (skipped.length ? `,跳过 ${skipped.length} 条` : ''))
3698
+ if (result.error) setError(`Keep All 未全部完成:${result.error}`)
3699
+ if (skipped.length) toastDom(skippedTip(skipped))
3700
+ return !result.error
3701
+ }
3702
+
3703
+ /** @private @author ddj 2026年11月03号 @param skipped 被跳过的条目 @returns 最多 3 条、余量汇总的提示文案 */
3704
+ function skippedTip(skipped) {
3705
+ const head = skipped.slice(0, 3).map((item) => `${item.display}:${item.reason}`).join(';')
3706
+ return skipped.length > 3 ? `${head};等 ${skipped.length} 条` : head
3707
+ }
3708
+
3595
3709
  /** @private @author ddj 2026年10月27号 @param reject Undo or Keep all workspace pending records, including previous rounds. @returns Explicit partial results; legacy proceeds only when official stage fully succeeds. */
3596
3710
  async function actReviewAll(reject) {
3597
3711
  if (batchBusyRef.current) return
@@ -3604,23 +3718,9 @@ export function EditorView(props) {
3604
3718
  batchBusyRef.current = true; setReviewBatchBusy(true)
3605
3719
  setStatus(reject ? '正在检查并 Undo 全部待审记录…' : '正在 Keep 全部待审记录…')
3606
3720
  try {
3607
- const result = await runReviewBatch({
3608
- snapshot: async () => {
3609
- const res = await rpc('edrv.review.list', { sessionId, all: true })
3610
- if (!res.ok) throw new Error(res.error)
3611
- return res.page
3612
- },
3613
- detail: async (id) => { const res = await rpc('edrv.review.detail', { sessionId, id }); if (!res.ok) throw new Error(res.error); return res.detail },
3614
- decide: (detail, undo, operationId) => rpc('edrv.review.decide', { sessionId, id: detail.item.id, seq: detail.item.seq, revision: detail.item.revision, operationId, scope: 'file', decision: undo ? 'rejected' : 'accepted' }),
3615
- guard: () => guardReviewAll(reject, token), operation: batchOperation,
3616
- }, reject)
3721
+ const result = reject ? await undoAllBatch(token) : await keepAllBatch(token)
3617
3722
  if (!token?.live) return
3618
- refreshReview(sessionId)
3619
- if (reject && result.paths.some((path) => sameFile(path, undoPath)) && activeRef.current === undoPath
3620
- && !dirtyRef.current[undoPath] && !savingRef.current.has(undoPath) && undoModel?.getVersionId?.() === undoVersion) reloadFile(true)
3621
- if (result.paths.length) emitRefresh({ scope, paths: result.paths, kind: 'structure' })
3622
- setStatus(`${reject ? 'Undo All' : 'Keep All'}:官方记录已完成 ${result.completed} / ${result.total}`)
3623
- if (result.error) { setError(`批量已停止,已完成 ${result.completed} 项,其余未处理。${result.error}`); return }
3723
+ if (!finishReviewAll(result, reject, undoPath, undoModel, undoVersion)) return
3624
3724
  guardReviewAll(reject, token)
3625
3725
  // Official-owned paths are excluded from allPending; never run legacy writes for official history.
3626
3726
  if (allPending.length) { batchBusyRef.current = false; await actAllPending(reject) }
@@ -4020,6 +4120,8 @@ export function EditorView(props) {
4020
4120
  }
4021
4121
  const tabMenuActions = (path) => ({
4022
4122
  'add-to-conversation': () => addRefToChat(path),
4123
+ 'forge-open': () => props.forge?.open(sessionId),
4124
+ 'forge-local': () => props.forge?.open(sessionId, true),
4023
4125
  'split-left': () => splitTab(path, 'left'),
4024
4126
  'split-right': () => splitTab(path, 'right'),
4025
4127
  'split-top': () => splitTab(path, 'top'),
@@ -4056,6 +4158,7 @@ export function EditorView(props) {
4056
4158
  cwd,
4057
4159
  hasSession: Boolean(sessionId),
4058
4160
  canAddToConversation: Boolean(addToConversation),
4161
+ forgeReady: props.forge?.available() === true,
4059
4162
  canSplit: workbench.groups.length < GROUP_LIMIT && !workbench.floats.some((item) => item.group === ownerOf(workbench, tabMenu.path)?.id),
4060
4163
  canFloat: workbench.groups.length < GROUP_LIMIT,
4061
4164
  canReturn: ownerOf(workbench, tabMenu.path)?.id !== MAIN_GROUP,
@@ -4354,6 +4457,25 @@ export function EditorView(props) {
4354
4457
  function consumeDrag() { return dragRef.current?.consumeClick() ?? false }
4355
4458
  /** @private @author ddj 2026年10月14号 @param path File. @param event Context trigger. */
4356
4459
  function paneMenu(path, event) { setTabMenu({ path, x: event.clientX, y: event.clientY }) }
4460
+ const menuOwner = React.useRef({ sessionId, scope, alive: true })
4461
+ menuOwner.current.sessionId = sessionId
4462
+ menuOwner.current.scope = scope
4463
+ const menuValid = React.useRef(null)
4464
+ React.useEffect(() => {
4465
+ menuOwner.current.alive = props.visible !== false
4466
+ return () => { menuOwner.current.alive = false; if (menuValid.current) props.officialMenus?.invalidate(menuValid.current) }
4467
+ }, [sessionId, scope, props.visible, props.officialMenus])
4468
+ React.useEffect(() => {
4469
+ if (!tabMenu || !props.officialMenus || !sessionId) return
4470
+ const capturedSession = sessionId
4471
+ const capturedScope = scope
4472
+ const path = tabMenu.path
4473
+ const groupId = ownerOf(benchRef.current, path)?.id
4474
+ const valid = () => menuOwner.current.alive && menuOwner.current.sessionId === capturedSession && menuOwner.current.scope === capturedScope && ownerOf(benchRef.current, path)?.id === groupId && tabsRef.current.some((tab) => tab.path === path)
4475
+ menuValid.current = valid
4476
+ const items = tabMenuEntries().map((entry) => ({ ...entry, run: entry.onClick ?? (() => {}) }))
4477
+ if (props.officialMenus.open('tab', sessionId, path, { x: tabMenu.x, y: tabMenu.y }, items, valid)) setTabMenu(null)
4478
+ }, [tabMenu, props.officialMenus, sessionId, scope])
4357
4479
  /** @private @author ddj 2026年10月14号 @param group Auxiliary group. Return documents rather than closing them. */
4358
4480
  function returnPane(group) { acceptLayout(returnGroup(benchRef.current, group)) }
4359
4481
  /** @private @author ddj 2026年10月14号 @param group Float id. @param rect Viewport-safe bounds. */
@@ -4959,6 +5081,9 @@ export function EditorView(props) {
4959
5081
  editor: () => editorRef.current,
4960
5082
  outlineSources: props.outlineSources,
4961
5083
  fileMenuItems: props.fileMenuItems,
5084
+ officialMenus: props.officialMenus,
5085
+ visible: props.visible,
5086
+ forge: props.forge,
4962
5087
  addToConversation,
4963
5088
  svn: svnStatus,
4964
5089
  // SVN 变更:面板列表 + 文件树徽标查表(客户端合成,不改 listDir 契约)
@@ -11,7 +11,7 @@ import React from 'react'
11
11
  import { pickSessionScope } from '../sessionScope.js'
12
12
  import { EditorView } from './EditorView.js'
13
13
  import { setSideEditorMounted } from '../sidebarBridge.js'
14
- import { editorMountEpoch, markEditorActive, markEditorMounted, parseOfficialFileAddress, resolveNavLine, resolveNavOpen } from '../officialSidebar.js'
14
+ import { editorMountEpoch, markEditorActive, markEditorMounted, parseOfficialFileAddress, resolveNavLine, resolveNavColumn, resolveNavOpen } from '../officialSidebar.js'
15
15
 
16
16
  /**
17
17
  * 官方侧边栏 Tab 正文组件(keyed slot sidebar.right.pane.tab 装配)。
@@ -22,10 +22,14 @@ export function OfficialSideTab(props) {
22
22
  const sessionId = props?.sessionId
23
23
  const useTabInfo = props?.useTabInfo
24
24
  let navigation
25
+ let tab
26
+ let visible = true
25
27
  // useTabInfo 是 slot 框架按组件实例生成的 hook,须在组件顶层无条件调用
26
28
  if (typeof useTabInfo === 'function') {
27
29
  try {
28
- navigation = useTabInfo()?.tab?.navigation
30
+ tab = useTabInfo()?.tab
31
+ navigation = tab?.navigation
32
+ visible = tab?.visible !== false
29
33
  } catch {
30
34
  navigation = undefined
31
35
  }
@@ -67,16 +71,16 @@ export function OfficialSideTab(props) {
67
71
  // file 资源 Tab:地址即文件(解码出真实路径,透传 params.line 行号定位)
68
72
  const fileHit = parseOfficialFileAddress(navigation?.address)
69
73
  if (fileHit) {
70
- const line = navigation?.params?.line
74
+ const line = resolveNavLine(navigation?.params)
71
75
  window.dispatchEvent(new CustomEvent('edrv:open-editor', {
72
- detail: { path: fileHit.path, line: typeof line === 'number' ? line : undefined, preview: navigation?.params?.preview },
76
+ detail: { sessionId, ownerId: tab?.id, path: fileHit.path, line, column: resolveNavColumn(navigation?.params), preview: navigation?.params?.preview },
73
77
  }))
74
78
  return
75
79
  }
76
80
  // 页类型 Tab:params 携带打开请求(line = 行引用/工具行跳转的行号定位)
77
81
  const request = resolveNavOpen(navigation?.params)
78
82
  window.dispatchEvent(new CustomEvent('edrv:open-editor', {
79
- detail: { path: request.path, focusDiff: request.focusDiff, line: resolveNavLine(navigation?.params), preview: navigation?.params?.preview },
83
+ detail: { sessionId, ownerId: tab?.id, path: request.path, focusDiff: request.focusDiff, line: resolveNavLine(navigation?.params), column: resolveNavColumn(navigation?.params), preview: navigation?.params?.preview },
80
84
  }))
81
85
  })
82
86
 
@@ -84,11 +88,15 @@ export function OfficialSideTab(props) {
84
88
  key: sessionId,
85
89
  sessionId,
86
90
  layout: 'side',
91
+ visible,
92
+ ownerId: tab?.id,
87
93
  schedule: props?.schedule,
88
94
  addToConversation: props?.addToConversation,
89
95
  sidebarPanels: props?.sidebarPanels,
90
96
  outlineSources: props?.outlineSources,
91
97
  fileMenuItems: props?.fileMenuItems,
98
+ officialMenus: props?.officialMenus,
99
+ forge: props?.forge,
92
100
  fileOpeners: props?.fileOpeners,
93
101
  sessions: props?.sessions,
94
102
  })
@@ -19,7 +19,7 @@ export function ReviewAllConfirm(props: ReviewAllProps): React.ReactElement {
19
19
  const action = props.reject ? 'Undo All' : 'Keep All'
20
20
  const warning = props.reject
21
21
  ? '撤销会改写磁盘;逐项安全校验,遇到冲突停止,已完成项不会自动恢复。'
22
- : '只标记待审修改为保留,不改写文件。'
22
+ : '只标记待审修改为保留,不改写文件;个别条目若已变化或需在活跃端判定会被跳过并留在列表里,可逐个处理。'
23
23
  const footer = React.createElement(React.Fragment, null,
24
24
  React.createElement(Button, { variant: 'ghost', onClick: props.onCancel, autoFocus: true }, '取消'),
25
25
  React.createElement(Button, { variant: 'primary', onClick: props.onConfirm }, '确认 ' + action))
package/src/index.ts CHANGED
@@ -76,7 +76,7 @@ function attachRuntime(ctx: Ctx, runtime: Runtime): void {
76
76
  const stopCapture = installCapture(ctx, (cwd, snapshot) => { evidence.persist(cwd, snapshot); return true })
77
77
  // 待审累计基线:只有写后内容确实变化才落库,异常一律不打断用户工具调用
78
78
  const pending = installPending(ctx, (write) => {
79
- try { trackPendingCover(write.cwd, write.path, true) } catch { /* 内存登记失败不阻断采集 */ }
79
+ try { trackPendingCover(write.cwd, write.path, true, write.sessionId) } catch { /* 内存登记失败不阻断采集 */ }
80
80
  try { recordWrite(write) } catch (error) { log.warn('待审差异落库失败:' + String(error)) }
81
81
  })
82
82
  /** @author ddj 2026年09月28号 @param exec Tool execution. @param result Tool result. */