dsh-vscode-mode 0.23.1 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vscode-mode",
3
- "version": "0.23.1",
3
+ "version": "0.23.2",
4
4
  "description": "DSH 上的类 VSCode 编码体验:Monaco 编辑器(文件页签/QuickOpen/状态栏,可驻 DSH 0.1.5+ 官方右侧 Sidebar 与对话同屏)+ 命令栏(Ctrl+Shift+P)与可扩展指令系统 + VS Code 兼容代码片段(.code-snippets 全局/项目,IntelliSense 展开)+ Agent 编辑差异审查(整文件差异/采纳/拒绝/归档/回滚)+ SVN 集成(侧栏变更面板/日志弹窗/并排差异)+ 语言服务器(LSP)智能(跳转定义/引用/hover/大纲 + VSIX 扩展市场安装),状态持久化到工作区旁车,界面状态(页签/光标/侧栏/编辑 Tab/导航历史)镜像到 DSH home 跨重启恢复",
5
5
  "icon": "assets/icon.svg",
6
6
  "keywords": [
@@ -1,5 +1,6 @@
1
1
  /** Official bulk decisions use persisted metadata, never the visible history page. @author ddj 2026年10月27号 */
2
2
  import type { ReviewDetail, ReviewItem, ReviewPage, ReviewResult } from '../shared/review.js'
3
+ import { PENDING_PREFIX } from '../shared/pendingReview.js'
3
4
 
4
5
  export interface BatchPort {
5
6
  snapshot: () => Promise<ReviewPage>
@@ -41,6 +42,10 @@ export async function runReviewBatch(port: BatchPort, reject: boolean): Promise<
41
42
  for (const item of items) {
42
43
  port.guard()
43
44
  const detail = await port.detail(item.id)
45
+ // 待审条目判定要在会话活跃的一端做:冷端能力位已关闭,这里给出比「版本变化」更准确的原因
46
+ if (detail.capability.live === false && item.id.startsWith(PENDING_PREFIX)) {
47
+ throw new Error(`${item.display}:待审条目需要在会话活跃的一端判定,请先在那一端处理或在本端打开该会话`)
48
+ }
44
49
  if (!detail.item.current || detail.item.seq !== item.seq || detail.item.revision !== item.revision
45
50
  || !detail.capability.keep) throw new Error(`${item.display}:审查版本变化,请刷新后重试`)
46
51
  if (reject && !detail.capability.undo) throw new Error(`${item.display}:${detail.capability.reason}`)
@@ -569,6 +569,8 @@ export function EditorView(props) {
569
569
  }, [records, active, cwd, review.page])
570
570
 
571
571
  const reviewOpsRef = React.useRef(new Map())
572
+ // 已自动结案的记录 id:防止「改动已不在文件中」的记录在刷新期间被反复提交判定
573
+ const settledRef = React.useRef(new Set())
572
574
  const [officialDetail, setOfficialDetail] = React.useState(null)
573
575
  const [officialRecords, setOfficialRecords] = React.useState([])
574
576
  const [selectedReview, setSelectedReview] = React.useState(null)
@@ -588,6 +590,9 @@ export function EditorView(props) {
588
590
  .find((path) => sameFile(path, active))
589
591
  const coveredPath = review.page?.overview?.coveredPaths.find((path) => sameFile(path, active))
590
592
  const reviewPath = coveredPath ?? pendingPath
593
+ dbg(sessionId, `[DEBUG reviewPath] active=${active ?? '(null)'} covered=${coveredPath ?? '(none)'}`
594
+ + ` pending=${pendingPath ?? '(none)'} coveredCount=${review.page?.overview?.coveredPaths.length ?? -1}`
595
+ + ` items=${review.page?.items?.length ?? -1} overview=${review.page?.overview ? 1 : 0}`)
591
596
  if (!active || !reviewPath) {
592
597
  setOfficialDetail(null)
593
598
  setOfficialRecords([])
@@ -601,9 +606,15 @@ export function EditorView(props) {
601
606
  if (controller.signal.aborted) return
602
607
  if (!page.ok) throw new Error(page.error)
603
608
  const selected = page.page.items.find((item) => item.id === selectedReview) ?? page.page.items[0]
609
+ dbg(sessionId, `[DEBUG reviewList] path=${reviewPath} items=${page.page.items.length}`
610
+ + ` waiting=${page.page.pending} selected=${selected?.id ?? '(none)'}`)
604
611
  if (!selected) { setOfficialRecords([]); setOfficialDetail(null); return }
605
612
  const result = await rpc('edrv.review.detail', { sessionId, id: selected.id }, controller.signal)
606
613
  if (controller.signal.aborted) return
614
+ dbg(sessionId, `[DEBUG reviewDetail] id=${selected.id} ok=${result.ok ? 1 : 0}`
615
+ + ` keep=${result.ok ? (result.detail.capability?.keep === true ? 1 : 0) : '-'}`
616
+ + ` live=${result.ok ? String(result.detail.capability?.live) : '-'}`
617
+ + ` err=${result.ok ? '' : String(result.error).slice(0, 120)}`)
607
618
  if (!result.ok) throw new Error(result.error)
608
619
  setOfficialRecords((previous) => JSON.stringify(previous) === JSON.stringify(page.page.items) ? previous : page.page.items)
609
620
  setOfficialDetail((previous) => JSON.stringify(previous) === JSON.stringify(result.detail) ? previous : result.detail)
@@ -672,6 +683,15 @@ export function EditorView(props) {
672
683
  const noBlocks = !!officialDetail && (officialDetail.diff.kind !== 'text' || officialDetail.diff.hunks.length === 0)
673
684
  // 单文件 Keep/Undo 的作用域:可定位差异 + 无法定位的冲突差异(后者否则永久留在待处理列表)
674
685
  const decideRegions = React.useMemo(() => [...pendingRegions, ...staleRegions], [pendingRegions, staleRegions])
686
+ // 记录的改动已全部不在当前文件中时没有可见差异可审:自动结案,不再让「改动已失效」占用差异栏。
687
+ React.useEffect(() => {
688
+ if (!officialDetail || contentReady !== true || batchBusyRef.current) return
689
+ if (pendingRegions.length > 0 || staleRegions.length === 0) return
690
+ if (noBlocks || officialDetail.capability?.keep !== true) return
691
+ if (dirtyRef.current[active]) return
692
+
693
+ void settleInvisibleRecord(officialDetail)
694
+ }, [officialDetail, contentReady, pendingRegions, staleRegions, noBlocks, active])
675
695
  hoverRegionsRef.current = pendingRegions
676
696
  // 行 → 差异区域 映射(hover O(1) 命中;每行归属其区域)
677
697
  const lineRegionMap = React.useMemo(() => {
@@ -3303,33 +3323,84 @@ export function EditorView(props) {
3303
3323
  */
3304
3324
  const itemOf = (r, reject) => ({ callId: r.callId, scope: r.create ? 'call' : 'hunk', hunkIndex: r.idx, decision: reject ? 'rejected' : 'accepted' })
3305
3325
 
3326
+ /**
3327
+ * @private
3328
+ * @author ddj 2026年10月31号
3329
+ * @description: 提交一条官方记录的判定;operationId 按决策键复用,网络重试保持幂等。
3330
+ * @param detail 目标记录(含冻结的 seq / revision)
3331
+ * @param reject true=Undo
3332
+ * @param blockId 可选的连续块;缺省为整文件
3333
+ * @returns 成功返回提交结果,失败抛出 host 给出的原因
3334
+ */
3335
+ async function submitOfficialDecision(detail, reject, blockId) {
3336
+ const source = detail.item
3337
+ const opKey = JSON.stringify([sessionId, source.id, source.seq, source.revision, blockId, reject])
3338
+ let operationId = reviewOpsRef.current.get(opKey)
3339
+ if (!operationId) { operationId = crypto.randomUUID(); reviewOpsRef.current.set(opKey, operationId) }
3340
+
3341
+ const result = await rpc('edrv.review.decide', { sessionId, id: source.id, seq: source.seq,
3342
+ revision: source.revision, operationId, scope: blockId ? 'hunk' : 'file', blockId,
3343
+ decision: reject ? 'rejected' : 'accepted' })
3344
+ if (!result.ok) throw new Error(result.error)
3345
+
3346
+ reviewOpsRef.current.delete(opKey)
3347
+ return result
3348
+ }
3349
+
3350
+ /**
3351
+ * @private
3352
+ * @author ddj 2026年10月31号
3353
+ * @description: 结案一条改动已不在当前文件中的官方记录:等价于 Keep(只标记已采纳,不改写磁盘)。
3354
+ * 这类记录没有可见差异可审,留在差异栏只会让用户去处理一个不存在的东西。
3355
+ * @param detail 待结案的记录
3356
+ */
3357
+ async function settleInvisibleRecord(detail) {
3358
+ if (batchBusyRef.current) return
3359
+
3360
+ const settled = settledRef.current
3361
+ if (settled.has(detail.item.id)) return
3362
+ settled.add(detail.item.id)
3363
+
3364
+ batchBusyRef.current = true
3365
+ try {
3366
+ await submitOfficialDecision(detail, false)
3367
+ refreshReview(sessionId)
3368
+ } catch (cause) {
3369
+ // 结案失败不打扰用户:记录仍留在待审列表,下次打开该文件再试
3370
+ settled.delete(detail.item.id)
3371
+ dbg(sessionId, `[DEBUG settleInvisible] id=${detail.item.id} err=${String(cause).slice(0, 160)}`)
3372
+ } finally { batchBusyRef.current = false }
3373
+ }
3374
+
3306
3375
  /** @private @author ddj 2026年10月27号 @param reject Undo instead of Keep. @param blockId Optional contiguous block. @returns Persisted decision result; never reloads over a dirty buffer. */
3307
3376
  async function decideOfficial(reject, blockId) {
3308
- if (!officialDetail || batchBusyRef.current) return false
3377
+ dbg(sessionId, `[DEBUG decideOfficial] reject=${reject ? 1 : 0} detail=${officialDetail ? 1 : 0}`
3378
+ + ` busy=${batchBusyRef.current ? 1 : 0} active=${active ?? '(null)'}`)
3379
+ if (!officialDetail || batchBusyRef.current) {
3380
+ // 详情缺失时按钮仍可点(能力位来自列表数据),必须给可读原因而不是静默返回
3381
+ if (!officialDetail && !batchBusyRef.current) setError('官方差异详情尚未加载完成,请稍后重试;若一直如此,请切换文件或刷新页面。')
3382
+ return false
3383
+ }
3309
3384
  if (reject && (dirtyRef.current[active] || savingRef.current.has(active) || saveTimerRef.current.isPending())) {
3310
3385
  setError('存在未保存或正在保存的修改,请等待完成后重试'); return false
3311
3386
  }
3312
3387
  batchBusyRef.current = true
3313
- const source = officialDetail.item
3388
+ const detail = officialDetail
3314
3389
  const undoPath = active
3315
3390
  const undoModel = editorRef.current?.getModel?.()
3316
3391
  const undoVersion = undoModel?.getVersionId?.()
3317
- const opKey = JSON.stringify([sessionId, source.id, source.seq, source.revision, blockId, reject])
3318
- let operationId = reviewOpsRef.current.get(opKey)
3319
- if (!operationId) { operationId = crypto.randomUUID(); reviewOpsRef.current.set(opKey, operationId) }
3320
3392
  try {
3321
- const result = await rpc('edrv.review.decide', { sessionId, id: source.id, seq: source.seq,
3322
- revision: source.revision, operationId, scope: blockId ? 'hunk' : 'file', blockId,
3323
- decision: reject ? 'rejected' : 'accepted' })
3324
- if (!result.ok) throw new Error(result.error)
3325
- reviewOpsRef.current.delete(opKey)
3393
+ await submitOfficialDecision(detail, reject, blockId)
3326
3394
  if (reject && activeRef.current === undoPath && !dirtyRef.current[undoPath]
3327
3395
  && !savingRef.current.has(undoPath) && undoModel?.getVersionId?.() === undoVersion) reloadFile(true)
3328
3396
  else if (reject) setError('Undo 已完成,编辑器在等待期间变化;未覆盖当前缓冲区,请核对磁盘后刷新')
3329
3397
  refreshReview(sessionId)
3330
3398
  setStatus(reject ? '已 Undo 选中差异' : '已 Keep 选中差异')
3331
3399
  return true
3332
- } catch (cause) { setError(String(cause)); return false }
3400
+ } catch (cause) {
3401
+ dbg(sessionId, `[DEBUG decideOfficialFail] id=${detail.item.id} reject=${reject ? 1 : 0} err=${String(cause).slice(0, 160)}`)
3402
+ setError(String(cause)); return false
3403
+ }
3333
3404
  finally { batchBusyRef.current = false }
3334
3405
  }
3335
3406
 
@@ -3466,10 +3537,26 @@ export function EditorView(props) {
3466
3537
  return operationId
3467
3538
  }
3468
3539
 
3540
+ /**
3541
+ * @private
3542
+ * @author ddj 2026年10月31号
3543
+ * @description: 批量操作要求官方待审列表已就绪;切换会话后列表还在载入时,
3544
+ * 先触发一次刷新并给出可读提示,而不是直接报错中止(用户看到红色「尚未就绪」会以为功能坏了)。
3545
+ * @param action 面向用户的动作名
3546
+ * @returns 列表已就绪时为 true
3547
+ */
3548
+ function reviewReadyForBatch(action) {
3549
+ if (review.page?.overview) return true
3550
+
3551
+ refreshReview(sessionId)
3552
+ setStatus(`官方待审列表正在载入,载入完成后请再次点击${action}`)
3553
+ return false
3554
+ }
3555
+
3469
3556
  /** @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. */
3470
3557
  async function actReviewAll(reject) {
3471
3558
  if (batchBusyRef.current) return
3472
- if (!review.page?.overview) { setError('官方待审列表尚未就绪,未执行批量操作'); return }
3559
+ if (!reviewReadyForBatch(reject ? 'Undo All' : 'Keep All')) return
3473
3560
  if (!sum.pendingFiles.length) return
3474
3561
  const token = reviewLifeRef.current
3475
3562
  const undoPath = activeRef.current
@@ -3504,7 +3591,7 @@ export function EditorView(props) {
3504
3591
  /** @private @author ddj 2026年10月27号 @param reject Whether to undo pending files. @description Open a DSH confirmation without starting any mutations. */
3505
3592
  function askReviewAll(reject) {
3506
3593
  if (batchBusyRef.current || !sum.pendingFiles.length) return
3507
- if (!review.page?.overview) { setError('官方待审列表尚未就绪,未执行批量操作'); return }
3594
+ if (!reviewReadyForBatch(reject ? 'Undo All' : 'Keep All')) return
3508
3595
  setReviewAllPrompt({ reject, count: sum.pendingFiles.length, token: reviewLifeRef.current })
3509
3596
  }
3510
3597
 
@@ -4697,6 +4784,9 @@ export function EditorView(props) {
4697
4784
  const sessionDown = isSessionMissingError(rawReviewError)
4698
4785
  const reviewErrorTone = sessionDown ? 'info' : 'error'
4699
4786
  const reviewErrorText = !rawReviewError ? undefined : sessionDown ? SESSION_DOWN_NOTICE : sessionErrorText(rawReviewError)
4787
+ // 官方元数据未到位且没有报错 = 首次载入中:此时还不知道哪些历史记录会被官方通道接管,
4788
+ // 直接按历史记录报数会在每次重启时闪出几十个「待处理文件」,故先显示占位、批量也先不放开。
4789
+ const reviewLoading = review.page === null && !review.error
4700
4790
  const buildDockSnapshot = () => ({
4701
4791
  mode: editorDockMode(active),
4702
4792
  missingDeletion: !!missingNow,
@@ -4718,7 +4808,7 @@ export function EditorView(props) {
4718
4808
  onUndoFile: undoFile,
4719
4809
  onAcceptAllFiles: acceptAllFiles,
4720
4810
  onUndoAllFiles: undoAllFiles,
4721
- allPendingCount: allPending.length + (review.page?.pending ?? 0),
4811
+ allPendingCount: reviewLoading ? 0 : allPending.length + (review.page?.pending ?? 0),
4722
4812
  reviewBatchBusy,
4723
4813
  batchUnavailable: !review.page?.overview ? '官方待审列表尚未就绪' : undefined,
4724
4814
  onRollback: rollbackFile,
@@ -4732,7 +4822,7 @@ export function EditorView(props) {
4732
4822
  contentReady,
4733
4823
  diffTotal: displayDiffTotal(contentReady, pendingRegions.length, sum.files.find((file) => sameFile(file.path, active))?.pending ?? 0),
4734
4824
  fileIdx,
4735
- fileTotal: sum.pendingFiles.length,
4825
+ fileTotal: reviewLoading ? null : sum.pendingFiles.length,
4736
4826
  onPrevDiff: () => gotoDiff(-1),
4737
4827
  onNextDiff: () => gotoDiff(1),
4738
4828
  onPrevFile: () => gotoFile(-1),
@@ -15,7 +15,8 @@ export interface OfficialBarProps {
15
15
  diffIdx?: number
16
16
  /** 编辑器内容是否就绪;未就绪时不得断言差异失效 */
17
17
  contentReady?: boolean
18
- fileTotal?: number
18
+ /** 待审文件总数;null 表示官方元数据尚未就绪,栏内显示占位 */
19
+ fileTotal?: number | null
19
20
  fileIdx?: number
20
21
  allPendingCount?: number
21
22
  reviewBatchBusy?: boolean
@@ -51,6 +52,20 @@ export function barIcon(mark: Mark): React.ReactElement {
51
52
  return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={marks[mark]} /></svg>
52
53
  }
53
54
 
55
+ /**
56
+ * @private
57
+ * @author ddj 2026年10月31号
58
+ * @description: Keep 按钮提示。会话不在本端活跃时 Keep 仍可执行(只写共享审查库),
59
+ * 提示里补一句说明,免得用户把它当成跨端不可用的按钮。
60
+ * @param hint 差异栏给出的 Keep 说明
61
+ * @param live 会话是否在本端活跃
62
+ * @returns 按钮 title 文本
63
+ */
64
+ function keepTitle(hint: string, live: boolean): string {
65
+ if (live) return hint
66
+ return `${hint}(会话在另一端激活:Keep 写入共享审查库,仍可执行)`
67
+ }
68
+
54
69
  /** @public @author ddj 2026年10月27号 @param props Existing review callbacks and immutable record metadata. @returns Responsive review toolbar; no disk or decision side effects until an explicit action. */
55
70
  export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
56
71
  const { official, pendingRegions = [], staleRegions = [], diffIdx = 0 } = props
@@ -75,7 +90,7 @@ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
75
90
 
76
91
  return <div className="edrv-diffbar edrv-diffbar-dock edrv-reviewbar" data-edrv-diffbox="1" data-edrv-diff-source="official" role="region" aria-label="文件差异审查">
77
92
  <ReviewAllRow
78
- total={props.fileTotal ?? 0}
93
+ total={props.fileTotal ?? null}
79
94
  current={(props.fileTotal ?? 0) && (props.fileIdx ?? -1) >= 0 ? (props.fileIdx ?? 0) + 1 : 0}
80
95
  busy={props.reviewBatchBusy}
81
96
  unavailable={props.batchUnavailable}
@@ -104,7 +119,7 @@ export function OfficialDiffBar(props: OfficialBarProps): React.ReactElement {
104
119
  </div>
105
120
  <div className="edrv-review-actions">
106
121
  <Button size="sm" variant="ghost" className="edrv-review-undo" icon={barIcon('undo')} onClick={props.onUndoFile} disabled={props.reviewBatchBusy || !detail?.capability.undo} title={detail?.capability.reason}>Undo</Button>
107
- <Button size="sm" variant="primary" className="edrv-review-keep" icon={barIcon('check')} onClick={props.onAcceptFile} disabled={props.reviewBatchBusy || !detail?.capability.keep} title={detail?.capability.keep ? view.keepHint : detail?.capability.reason}>Keep</Button>
122
+ <Button size="sm" variant="primary" className="edrv-review-keep" icon={barIcon('check')} onClick={props.onAcceptFile} disabled={props.reviewBatchBusy || !detail?.capability.keep} title={detail?.capability.keep ? keepTitle(view.keepHint, detail.capability.live !== false) : detail?.capability.reason}>Keep</Button>
108
123
  <Menu open={menu === 'more'} anchor={moreTrigger} items={[{ id: 'pending', label: '差异总览' }, { id: 'archive', label: '归档 / 历史' }]} onSelect={openOverview} onClose={closeMenu} side="top" align="end" portal compact autoFocus />
109
124
  </div>
110
125
  </div>
@@ -29,7 +29,8 @@ export interface SidecarBarProps {
29
29
  /** 编辑器内容是否就绪;未就绪时不得断言差异失效 */
30
30
  contentReady?: boolean
31
31
  fileIdx?: number
32
- fileTotal?: number
32
+ /** 待审文件总数;null 表示官方元数据尚未就绪,栏内显示占位 */
33
+ fileTotal?: number | null
33
34
  allPendingCount?: number
34
35
  reviewBatchBusy?: boolean
35
36
  /** 官方元数据通道失败时的可操作提示 */
@@ -86,7 +87,7 @@ export function SidecarDiffBar(props: SidecarBarProps): React.ReactElement {
86
87
 
87
88
  return <div className="edrv-diffbar edrv-diffbar-dock edrv-reviewbar" data-edrv-diffbox="1" data-edrv-diff-source="sidecar" role="region" aria-label="文件差异审查">
88
89
  <ReviewAllRow
89
- total={props.fileTotal ?? 0}
90
+ total={props.fileTotal ?? null}
90
91
  current={(props.fileTotal ?? 0) && (props.fileIdx ?? -1) >= 0 ? (props.fileIdx ?? 0) + 1 : 0}
91
92
  busy={busy}
92
93
  pending={props.allPendingCount}
@@ -5,8 +5,8 @@ import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
5
5
  export interface ReviewAllRowProps {
6
6
  /** 当前文件序号(1 基,0 表示未定位) */
7
7
  current: number
8
- /** 差异文件总数 */
9
- total: number
8
+ /** 差异文件总数;null 表示官方元数据尚未就绪、归属还分不清,显示占位而不报个数 */
9
+ total: number | null
10
10
  /** 批量操作进行中 */
11
11
  busy?: boolean
12
12
  /** 批量不可用原因(悬浮提示) */
@@ -34,12 +34,13 @@ export interface ReviewAllRowProps {
34
34
  * @returns 持久化的全工作区控制条
35
35
  */
36
36
  export function ReviewAllRow(props: ReviewAllRowProps): React.ReactElement {
37
- const total = props.total ?? 0
37
+ const known = typeof props.total === 'number'
38
+ const total = known ? props.total as number : 0
38
39
  const current = total && props.current > 0 ? props.current : 0
39
40
  const disabled = props.busy || !!props.unavailable || !props.pending
40
41
 
41
42
  return <div className="edrv-review-global" role="group" aria-label="全部待审文件">
42
- <Button size="sm" variant="ghost" className="edrv-review-total" onClick={props.onOpen} title="打开当前工作区全部待审差异文件">差异文件 <strong>{current} / {total}</strong></Button>
43
+ <Button size="sm" variant="ghost" className="edrv-review-total" onClick={props.onOpen} title="打开当前工作区全部待审差异文件">差异文件 <strong>{known ? `${current} / ${total}` : '—'}</strong></Button>
43
44
  <span className="edrv-review-all-actions">
44
45
  <Button size="sm" variant="ghost" onClick={props.onKeepAll} disabled={disabled || !props.onKeepAll} title={props.unavailable || props.keepTitle || 'Keep 当前工作区全部待审文件'}>Keep All</Button>
45
46
  <Button size="sm" variant="ghost" className="edrv-review-undo" onClick={props.onUndoAll} disabled={disabled || !props.onUndoAll} title={props.unavailable || props.undoTitle || 'Undo 当前工作区全部待审文件'}>Undo All</Button>
package/src/reviewRpc.ts CHANGED
@@ -9,10 +9,19 @@ import { PENDING_PREFIX, mergedPage, pendingDetailOf, pendingItemOf, pendingSame
9
9
  import { bucketOf, resolveScope, sessionOf } from './registry.js'
10
10
  import type { Registry } from './registry.js'
11
11
  import type { Ctx } from './store.js'
12
+ import { log } from './log.js'
12
13
 
13
14
  /** 批量写入快照的硬上限:待审积压超过它说明状态异常,宁可拒绝也不做无界写循环。 */
14
15
  const BATCH_LIMIT = 2000
15
16
 
17
+ /** 冷端 Undo 说明:Undo 会校验证据并写回源文件,只能在持有该会话的那一端执行。 */
18
+ const UNDO_NEEDS_LIVE = 'Undo 需要在本端活跃的会话上执行:该会话当前在另一端(Web / 桌面)激活。'
19
+ + 'Keep 不受影响,可在任一端执行;或在本端打开该会话后重试。'
20
+
21
+ /** 冷端待审判定说明:待审库是按工作区的本地存储,判定需要与正在编辑的那一端保持一致。 */
22
+ const PENDING_NEEDS_LIVE = '待审条目的判定需要在本端活跃的会话上执行:该会话当前在另一端激活,'
23
+ + '请在那一端处理,或在本端打开该会话后重试。'
24
+
16
25
  /**
17
26
  * @private
18
27
  * @author ddj 2026年10月31号
@@ -55,20 +64,52 @@ function batchSnapshot(cwd: string, pending: PendingPage): { ok: true; page: Rev
55
64
  * @returns 审查相关处理器表
56
65
  */
57
66
  export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandlerMap> {
58
- /** @private @author ddj 2026年10月28号 @param sessionId 请求作用域 @returns 已迁移工作区(允许跨实例冷读) */
59
- async function workspace(sessionId: string): Promise<string> {
67
+ /**
68
+ * @private
69
+ * @author ddj 2026年10月31号
70
+ * @description: 解析会话作用域,并标明本端是否持有该会话的活跃实例。
71
+ * 工作区一律由 host 解析,绝不采信调用方 cwd;冷读作用域只带 cwd,不带会话实例。
72
+ * @param sessionId 请求作用域
73
+ * @returns 已迁移工作区与活跃标记
74
+ * @throws 会话不存在、会话无工作区或活跃库迁移未完成
75
+ */
76
+ async function scopeOf(sessionId: string): Promise<{ cwd: string; live: boolean }> {
60
77
  const scope = await resolveScope(ctx, sessionId)
61
78
  if ('err' in scope) throw new Error('会话不存在或没有工作区')
79
+
62
80
  await bucketOf(registry, ctx, scope.cwd)
63
- return scope.cwd
81
+ return { cwd: scope.cwd, live: !!scope.session }
64
82
  }
65
83
 
66
- /** @private @author ddj 2026年10月28号 @param sessionId 请求作用域 @returns 本端 live 会话的工作区;冷读作用域一律拒绝写入 */
67
- async function liveWorkspace(sessionId: string): Promise<string> {
68
- const scope = await resolveScope(ctx, sessionId)
69
- if ('err' in scope || !scope.session) throw new Error('会话不存在或没有工作区')
70
- await bucketOf(registry, ctx, scope.cwd)
71
- return scope.cwd
84
+ /**
85
+ * @private
86
+ * @author ddj 2026年10月31号
87
+ * @description: 本端是否持有该会话的活跃实例(纯诊断用,不解析工作区)。
88
+ * @param sessionId 请求作用域
89
+ * @returns 本端加载了该会话时为 true
90
+ */
91
+ function liveOf(sessionId: string): boolean {
92
+ return !!sessionOf(ctx, sessionId)
93
+ }
94
+
95
+ /**
96
+ * @private
97
+ * @author ddj 2026年10月31号
98
+ * @description: 按本端是否活跃覆写面板能力位。活跃端保持数据库结论;
99
+ * 冷端关闭依赖会话的操作(Undo、待审判定)并给出跨端原因,Keep 仍按数据库结论放行。
100
+ * @param capability 数据库推导的能力位
101
+ * @param live 本端是否持有活跃会话
102
+ * @param pending 是否为待审投影条目
103
+ * @returns 面板可用的能力位
104
+ */
105
+ function liveCapabilityOf(
106
+ capability: { keep: boolean; undo: boolean; reason: string },
107
+ live: boolean,
108
+ pending: boolean,
109
+ ): { keep: boolean; undo: boolean; reason: string; live: boolean } {
110
+ if (live) return { ...capability, live: true }
111
+ if (pending) return { keep: false, undo: false, reason: PENDING_NEEDS_LIVE, live: false }
112
+ return { ...capability, undo: false, live: false, reason: capability.undo ? UNDO_NEEDS_LIVE : capability.reason }
72
113
  }
73
114
 
74
115
  /**
@@ -81,13 +122,19 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
81
122
  * @returns 待审页(失败为空页)
82
123
  */
83
124
  function pendingPageOf(cwd: string, sessionId: string): PendingPage {
84
- try { return loadPage(cwd, sessionId) }
85
- catch { return { items: [], pending: 0, nextCursor: null, gaps: [] } }
125
+ try {
126
+ const page = loadPage(cwd, sessionId)
127
+ // 已结算(老文本与新文本一致)的投影没有净变化可审:留着会顶掉同路径的官方待审记录,
128
+ // 让差异栏选中一条 Keep 不可用的条目,表现为「按钮亮着却点不动」。
129
+ return page.items.some((item) => item.settled === true)
130
+ ? { ...page, items: page.items.filter((item) => item.settled !== true) }
131
+ : page
132
+ } catch { return { items: [], pending: 0, nextCursor: null, gaps: [] } }
86
133
  }
87
134
 
88
135
  return {
89
136
  'edrv.review.list': async (args) => {
90
- const cwd = await workspace(args.sessionId)
137
+ const { cwd } = await scopeOf(args.sessionId)
91
138
  // 批量通道要的是一次性完整快照:分页口径的 pending 只是页内展示计数,不能当完整性不变式
92
139
  if (args.all && !args.history && args.path === undefined) {
93
140
  return batchSnapshot(cwd, pendingPageOf(cwd, args.sessionId))
@@ -106,43 +153,69 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
106
153
  return { ok: true, page: { ...official, items: [...own, ...history] } }
107
154
  }
108
155
  // 待审投影就位后由它承接被覆盖路径,官方旧行让位,同一路径只保留一个权威
109
- return { ok: true, page: mergedPage(official, pending, cwd) }
156
+ const merged = mergedPage(official, pending, cwd)
157
+ log.debug(`[review] list live=${liveOf(args.sessionId) ? 1 : 0} path=${args.path ?? '(all)'}`
158
+ + ` items=${merged.items.length} pending=${merged.pending} covered=${merged.overview?.coveredPaths.length ?? -1}`)
159
+ return { ok: true, page: merged }
110
160
  },
111
161
  'edrv.review.detail': async (args) => {
112
- const cwd = await workspace(args.sessionId)
162
+ const scope = await scopeOf(args.sessionId)
163
+ const cwd = scope.cwd
113
164
  if (args.id.startsWith(PENDING_PREFIX)) {
114
165
  const detail = loadDetail(cwd, args.id.slice(PENDING_PREFIX.length))
115
166
  if (!detail) return { ok: false, error: '待审条目不存在' }
116
- return { ok: true, detail: pendingDetailOf(detail, new Date(detail.item.updatedAt).toISOString(), cwd) }
167
+
168
+ const mapped = pendingDetailOf(detail, new Date(detail.item.updatedAt).toISOString(), cwd)
169
+ // 待审判定走本地待审库,跨端会与另一端 agent 的写入抢同一份 JSON,故冷端一律关闭
170
+ return { ok: true, detail: { ...mapped, capability: liveCapabilityOf(mapped.capability, scope.live, true) } }
117
171
  }
172
+
118
173
  const detail = reviewDetail(cwd, args.id)
119
- return detail ? { ok: true, detail } : { ok: false, error: '官方审查记录不存在' }
174
+ if (!detail) {
175
+ log.debug(`[review] detail live=${scope.live ? 1 : 0} id=${args.id} found=0`)
176
+ return { ok: false, error: '官方审查记录不存在' }
177
+ }
178
+ // Keep 只写共享库,冷端放行;Undo 依赖会话证据与源文件写回,冷端关闭
179
+ const capability = liveCapabilityOf(detail.capability, scope.live, false)
180
+ log.debug(`[review] detail live=${scope.live ? 1 : 0} id=${args.id} found=1`
181
+ + ` keep=${capability.keep ? 1 : 0} undo=${capability.undo ? 1 : 0}`)
182
+ return { ok: true, detail: { ...detail, capability } }
120
183
  },
121
184
  'edrv.review.decide': async (args) => {
122
- // 写类操作要求 live 会话:避免两端并发写同一工作区审查库,回滚也依赖本端会话内容
123
- const cwd = await liveWorkspace(args.sessionId)
124
- if (args.id.startsWith(PENDING_PREFIX)) {
125
- // 待审条目走累计基线:Keep 零写盘,Revert 写回并读回校验
126
- const result = await applyDecide(ctx.get('fs'), cwd, {
127
- sessionId: args.sessionId,
128
- id: args.id.slice(PENDING_PREFIX.length),
129
- revision: args.revision,
130
- operationId: args.operationId,
131
- decision: args.decision,
132
- scope: args.scope === 'hunk' ? 'block' : 'file',
133
- ...(args.blockId === undefined ? {} : { blockId: args.blockId }),
134
- })
135
- if (!result.ok) return { ok: false, error: result.reason }
136
- return { ok: true, revision: result.flags.revision }
137
- }
185
+ // Keep 只写共享审查库(CAS + operationId 幂等 + WAL),冷端也放行;
186
+ // Undo 与待审判定依赖本端会话内容或本地待审库,冷端返回可读原因而不是 500。
138
187
  try {
139
- const result = args.decision === 'rejected'
140
- ? await undoReview(ctx, sessionOf(ctx, args.sessionId), cwd, args)
141
- : decideReview(cwd, args)
188
+ const scope = await scopeOf(args.sessionId)
189
+ if (args.id.startsWith(PENDING_PREFIX)) {
190
+ if (!scope.live) return { ok: false, error: PENDING_NEEDS_LIVE }
191
+ // 待审条目走累计基线:Keep 零写盘,Revert 写回并读回校验
192
+ const result = await applyDecide(ctx.get('fs'), scope.cwd, {
193
+ sessionId: args.sessionId,
194
+ id: args.id.slice(PENDING_PREFIX.length),
195
+ revision: args.revision,
196
+ operationId: args.operationId,
197
+ decision: args.decision,
198
+ scope: args.scope === 'hunk' ? 'block' : 'file',
199
+ ...(args.blockId === undefined ? {} : { blockId: args.blockId }),
200
+ })
201
+ if (!result.ok) return { ok: false, error: result.reason }
202
+ return { ok: true, revision: result.flags.revision }
203
+ }
204
+
205
+ if (args.decision === 'rejected') {
206
+ if (!scope.live) return { ok: false, error: UNDO_NEEDS_LIVE }
207
+ const result = await undoReview(ctx, sessionOf(ctx, args.sessionId), scope.cwd, args)
208
+ return { ok: true, ...result }
209
+ }
210
+
211
+ const result = decideReview(scope.cwd, args)
212
+ if (!scope.live) log.info(`[review] 冷端 Keep 官方记录 ${args.id}`)
142
213
  return { ok: true, ...result }
143
214
  } catch (error) {
144
215
  // Guard failures are review results, not transport failures: preserve their reason.
145
- return { ok: false, error: error instanceof Error ? error.message : String(error) }
216
+ const message = error instanceof Error ? error.message : String(error)
217
+ log.warn(`[review] decide 失败 id=${args.id} decision=${args.decision}: ${message}`)
218
+ return { ok: false, error: message }
146
219
  }
147
220
  },
148
221
  }
@@ -56,7 +56,8 @@ export interface ReviewDetail {
56
56
  diff: OfficialDiff
57
57
  capturedAt: string
58
58
  blocks: import('./reviewRegions.js').ReviewBlockState[]
59
- capability: { keep: boolean; undo: boolean; reason: string }
59
+ /** live 缺省视为未知:只有 host 明确给出 false 时才代表会话不在本端活跃。 */
60
+ capability: { keep: boolean; undo: boolean; reason: string; live?: boolean }
60
61
  }
61
62
  export interface CaptureGap {
62
63
  sessionId: string