dsh-vscode-mode 0.23.1 → 0.23.3

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.3",
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,6 +1,30 @@
1
1
  import type { MissingReview } from '../shared/types.js'
2
2
  import { splitLines } from '../shared/diff.js'
3
3
 
4
+ /** 缺失文件终态:文件已在磁盘删除,且没有可展示的整文件预览。 */
5
+ export interface GoneState {
6
+ title: string
7
+ message: string
8
+ /** 该文件还有官方待审改动:Keep 可在差异栏结案,Undo 不可用。 */
9
+ official: boolean
10
+ }
11
+
12
+ /**
13
+ * @public
14
+ * @author ddj 2026年10月31号
15
+ * @description: 把 missingReview 的失败归并成「文件已删除」终态。只有确认删除的拒绝码才是终态;
16
+ * 会话缺失、权限、文件重新出现等失败返回 null,调用方继续走通用加载失败(可重试、保留跨端提示)。
17
+ * @param failure RPC 失败结果(error 必填,code 可选)
18
+ * @returns 终态信息或 null
19
+ */
20
+ export function goneStateOf(failure: { error?: unknown; code?: unknown } | null | undefined): GoneState | null {
21
+ const code = failure?.code
22
+ const official = code === 'official-covered'
23
+ if (!official && code !== 'official-decided' && code !== 'no-records' && code !== 'no-evidence' && code !== 'too-large') return null
24
+ const message = String(failure?.error ?? '文件已在磁盘删除').replace(/^Error:\s*/, '')
25
+ return { title: official ? '文件已在磁盘删除 · 有官方待审改动' : '文件已在磁盘删除', message, official }
26
+ }
27
+
4
28
  /** @public @author ddj 2026年10月28号 @param review Frozen complete snapshot. @returns One whole-file deletion, including empty files; no invented current lines. */
5
29
  export function missingRegion(review: MissingReview) {
6
30
  return {
@@ -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}`)
@@ -62,7 +62,7 @@ import { applyOfficial, editorFont, registerThemes, themeNameOf } from '../monac
62
62
  import { createDiffRenderer } from '../monaco/diffRender.js'
63
63
  import { ST, callIdAttr, noopHunk, summarize } from '../state/records.js'
64
64
  import { diffRegions } from '../state/regions.js'
65
- import { missingBatch, missingRegion } from '../missingReview.js'
65
+ import { goneStateOf, missingBatch, missingRegion } from '../missingReview.js'
66
66
  import { QuickOpen } from './QuickOpen.js'
67
67
  import { attachHorizontalWheel } from './horizontalWheel.js'
68
68
  import { CommandPalette } from './CommandPalette.js'
@@ -343,6 +343,11 @@ export function EditorView(props) {
343
343
  const aiStatusTimer = React.useRef(null)
344
344
  const [error, setError] = React.useState(null)
345
345
  const [loadError, setLoadError] = React.useState(null)
346
+ // 缺失文件的终态:{ path, sessionId, title, message, official }。
347
+ // 与 loadError 区分开——加载失败可以重试,磁盘删除是终态,不能一直卡在进度条 0%。
348
+ const [goneFile, setGoneFile] = React.useState(null)
349
+ const missingReadRef = React.useRef(new Map()) // path → 已重读次数(防 文件缺失↔重现 抖动死循环)
350
+ const goneNow = goneFile && goneFile.path === active && goneFile.sessionId === sessionId ? goneFile : null
346
351
  const [largeCandidate, setLargeCandidate] = React.useState(null)
347
352
  const [pagedFile, setPagedFile] = React.useState(null)
348
353
  const [pageChanged, setPageChanged] = React.useState(null)
@@ -569,6 +574,8 @@ export function EditorView(props) {
569
574
  }, [records, active, cwd, review.page])
570
575
 
571
576
  const reviewOpsRef = React.useRef(new Map())
577
+ // 已自动结案的记录 id:防止「改动已不在文件中」的记录在刷新期间被反复提交判定
578
+ const settledRef = React.useRef(new Set())
572
579
  const [officialDetail, setOfficialDetail] = React.useState(null)
573
580
  const [officialRecords, setOfficialRecords] = React.useState([])
574
581
  const [selectedReview, setSelectedReview] = React.useState(null)
@@ -588,6 +595,9 @@ export function EditorView(props) {
588
595
  .find((path) => sameFile(path, active))
589
596
  const coveredPath = review.page?.overview?.coveredPaths.find((path) => sameFile(path, active))
590
597
  const reviewPath = coveredPath ?? pendingPath
598
+ dbg(sessionId, `[DEBUG reviewPath] active=${active ?? '(null)'} covered=${coveredPath ?? '(none)'}`
599
+ + ` pending=${pendingPath ?? '(none)'} coveredCount=${review.page?.overview?.coveredPaths.length ?? -1}`
600
+ + ` items=${review.page?.items?.length ?? -1} overview=${review.page?.overview ? 1 : 0}`)
591
601
  if (!active || !reviewPath) {
592
602
  setOfficialDetail(null)
593
603
  setOfficialRecords([])
@@ -601,9 +611,15 @@ export function EditorView(props) {
601
611
  if (controller.signal.aborted) return
602
612
  if (!page.ok) throw new Error(page.error)
603
613
  const selected = page.page.items.find((item) => item.id === selectedReview) ?? page.page.items[0]
614
+ dbg(sessionId, `[DEBUG reviewList] path=${reviewPath} items=${page.page.items.length}`
615
+ + ` waiting=${page.page.pending} selected=${selected?.id ?? '(none)'}`)
604
616
  if (!selected) { setOfficialRecords([]); setOfficialDetail(null); return }
605
617
  const result = await rpc('edrv.review.detail', { sessionId, id: selected.id }, controller.signal)
606
618
  if (controller.signal.aborted) return
619
+ dbg(sessionId, `[DEBUG reviewDetail] id=${selected.id} ok=${result.ok ? 1 : 0}`
620
+ + ` keep=${result.ok ? (result.detail.capability?.keep === true ? 1 : 0) : '-'}`
621
+ + ` live=${result.ok ? String(result.detail.capability?.live) : '-'}`
622
+ + ` err=${result.ok ? '' : String(result.error).slice(0, 120)}`)
607
623
  if (!result.ok) throw new Error(result.error)
608
624
  setOfficialRecords((previous) => JSON.stringify(previous) === JSON.stringify(page.page.items) ? previous : page.page.items)
609
625
  setOfficialDetail((previous) => JSON.stringify(previous) === JSON.stringify(result.detail) ? previous : result.detail)
@@ -672,6 +688,15 @@ export function EditorView(props) {
672
688
  const noBlocks = !!officialDetail && (officialDetail.diff.kind !== 'text' || officialDetail.diff.hunks.length === 0)
673
689
  // 单文件 Keep/Undo 的作用域:可定位差异 + 无法定位的冲突差异(后者否则永久留在待处理列表)
674
690
  const decideRegions = React.useMemo(() => [...pendingRegions, ...staleRegions], [pendingRegions, staleRegions])
691
+ // 记录的改动已全部不在当前文件中时没有可见差异可审:自动结案,不再让「改动已失效」占用差异栏。
692
+ React.useEffect(() => {
693
+ if (!officialDetail || contentReady !== true || batchBusyRef.current) return
694
+ if (pendingRegions.length > 0 || staleRegions.length === 0) return
695
+ if (noBlocks || officialDetail.capability?.keep !== true) return
696
+ if (dirtyRef.current[active]) return
697
+
698
+ void settleInvisibleRecord(officialDetail)
699
+ }, [officialDetail, contentReady, pendingRegions, staleRegions, noBlocks, active])
675
700
  hoverRegionsRef.current = pendingRegions
676
701
  // 行 → 差异区域 映射(hover O(1) 命中;每行归属其区域)
677
702
  const lineRegionMap = React.useMemo(() => {
@@ -1153,17 +1178,49 @@ export function EditorView(props) {
1153
1178
  * @param force 强制走 RPC(reloadFile 用,跳过 model 复用)
1154
1179
  * @param version 已知磁盘版本(缺省走 RPC 返回值)
1155
1180
  */
1156
- /** @private @author ddj 2026年10月28号 @param path Missing text file. @param sid Owning session. @param seq Loader generation. @sideEffects Loads deletion evidence without creating or saving the file. */
1181
+ /**
1182
+ * 把「文件已在磁盘删除」记成终态面板:不占进度条、不反复重试,并允许直接关闭页签。
1183
+ * 只有确认删除的原因才走终态;会话/权限类失败仍回退通用加载失败,保留跨端提示。
1184
+ * @private @author ddj 2026年10月31号
1185
+ * @param path 目标文件
1186
+ * @param sid 会话 id
1187
+ * @param seq 加载序号(过期响应丢弃)
1188
+ * @param failure 宿主拒绝原因(可带稳定 code)
1189
+ */
1190
+ function settleGone(path, sid, seq, failure) {
1191
+ if (!loadCurrent(path, seq)) return
1192
+ const error = failure && typeof failure === 'object' ? failure : { error: failure }
1193
+ const gone = goneStateOf(error)
1194
+ ?? (error.deleted === true ? { title: '文件已在磁盘删除', official: false, message: String(error.error ?? '') } : null)
1195
+ if (!gone) { failLoad(path, sid, seq, String(error.error ?? error)); return }
1196
+ stampLoad(path, sid, seq, gone.message)
1197
+ setLoadError(null)
1198
+ setError(null)
1199
+ setGoneFile({ ...gone, path, sessionId: sid })
1200
+ setStatus(gone.official ? '文件已删除 · 差异栏可 Keep 结案' : '文件已删除')
1201
+ }
1202
+
1203
+ /** @private @author ddj 2026年10月28号 / 2026年10月31号 @param path Missing text file. @param sid Owning session. @param seq Loader generation. @sideEffects Loads deletion evidence without creating or saving the file. */
1157
1204
  async function loadMissing(path, sid, seq) {
1158
1205
  try {
1159
1206
  const result = await rpc('edrv.missingReview', { sessionId: sid, path })
1160
1207
  if (!loadCurrent(path, seq)) return
1161
- if (!result.ok) { failLoad(path, sid, seq, result.error); return }
1162
- if (!result.review) { loadContent(path, sid, true); return }
1208
+ if (!result.ok) { settleGone(path, sid, seq, result); return }
1209
+ if (!result.review) {
1210
+ // 宿主观测到文件仍在磁盘(与读取时的 file-missing 竞态):重读一次即可;
1211
+ // 同一文件反复抖动时收尾为终态,绝不再递归重读。
1212
+ const attempts = (missingReadRef.current.get(path) ?? 0) + 1
1213
+ missingReadRef.current.set(path, attempts)
1214
+ if (attempts <= 2) { loadContent(path, sid, true); return }
1215
+ settleGone(path, sid, seq, { deleted: true, error: '文件状态在磁盘上反复变化,请稍后重试,或关闭该标签页' })
1216
+ return
1217
+ }
1218
+ missingReadRef.current.delete(path)
1163
1219
  if (dirtyRef.current[path] || savingRef.current.has(path)) { failLoad(path, sid, seq, '文件已删除,但有未保存编辑;请先处理本地缓冲'); return }
1164
1220
  const preview = { ...result.review, sessionId: sid, keepId: crypto.randomUUID(), undoId: crypto.randomUUID() }
1165
1221
  missingRef.current.set(path, preview)
1166
1222
  setMissingPreview(preview)
1223
+ setGoneFile(null)
1167
1224
  setContent('')
1168
1225
  setContentPath(path)
1169
1226
  setLoadError(null)
@@ -1178,6 +1235,7 @@ export function EditorView(props) {
1178
1235
  const seq = ++loadSeqRef.current
1179
1236
  setReceiptLoad({ path, cwd, sessionId: sid, sequence: seq, status: 'loading' })
1180
1237
  setLoadError(null)
1238
+ setGoneFile(null)
1181
1239
  setLargeCandidate(null)
1182
1240
  setPagedFile(null)
1183
1241
  setPageChanged(null)
@@ -3303,33 +3361,84 @@ export function EditorView(props) {
3303
3361
  */
3304
3362
  const itemOf = (r, reject) => ({ callId: r.callId, scope: r.create ? 'call' : 'hunk', hunkIndex: r.idx, decision: reject ? 'rejected' : 'accepted' })
3305
3363
 
3364
+ /**
3365
+ * @private
3366
+ * @author ddj 2026年10月31号
3367
+ * @description: 提交一条官方记录的判定;operationId 按决策键复用,网络重试保持幂等。
3368
+ * @param detail 目标记录(含冻结的 seq / revision)
3369
+ * @param reject true=Undo
3370
+ * @param blockId 可选的连续块;缺省为整文件
3371
+ * @returns 成功返回提交结果,失败抛出 host 给出的原因
3372
+ */
3373
+ async function submitOfficialDecision(detail, reject, blockId) {
3374
+ const source = detail.item
3375
+ const opKey = JSON.stringify([sessionId, source.id, source.seq, source.revision, blockId, reject])
3376
+ let operationId = reviewOpsRef.current.get(opKey)
3377
+ if (!operationId) { operationId = crypto.randomUUID(); reviewOpsRef.current.set(opKey, operationId) }
3378
+
3379
+ const result = await rpc('edrv.review.decide', { sessionId, id: source.id, seq: source.seq,
3380
+ revision: source.revision, operationId, scope: blockId ? 'hunk' : 'file', blockId,
3381
+ decision: reject ? 'rejected' : 'accepted' })
3382
+ if (!result.ok) throw new Error(result.error)
3383
+
3384
+ reviewOpsRef.current.delete(opKey)
3385
+ return result
3386
+ }
3387
+
3388
+ /**
3389
+ * @private
3390
+ * @author ddj 2026年10月31号
3391
+ * @description: 结案一条改动已不在当前文件中的官方记录:等价于 Keep(只标记已采纳,不改写磁盘)。
3392
+ * 这类记录没有可见差异可审,留在差异栏只会让用户去处理一个不存在的东西。
3393
+ * @param detail 待结案的记录
3394
+ */
3395
+ async function settleInvisibleRecord(detail) {
3396
+ if (batchBusyRef.current) return
3397
+
3398
+ const settled = settledRef.current
3399
+ if (settled.has(detail.item.id)) return
3400
+ settled.add(detail.item.id)
3401
+
3402
+ batchBusyRef.current = true
3403
+ try {
3404
+ await submitOfficialDecision(detail, false)
3405
+ refreshReview(sessionId)
3406
+ } catch (cause) {
3407
+ // 结案失败不打扰用户:记录仍留在待审列表,下次打开该文件再试
3408
+ settled.delete(detail.item.id)
3409
+ dbg(sessionId, `[DEBUG settleInvisible] id=${detail.item.id} err=${String(cause).slice(0, 160)}`)
3410
+ } finally { batchBusyRef.current = false }
3411
+ }
3412
+
3306
3413
  /** @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
3414
  async function decideOfficial(reject, blockId) {
3308
- if (!officialDetail || batchBusyRef.current) return false
3415
+ dbg(sessionId, `[DEBUG decideOfficial] reject=${reject ? 1 : 0} detail=${officialDetail ? 1 : 0}`
3416
+ + ` busy=${batchBusyRef.current ? 1 : 0} active=${active ?? '(null)'}`)
3417
+ if (!officialDetail || batchBusyRef.current) {
3418
+ // 详情缺失时按钮仍可点(能力位来自列表数据),必须给可读原因而不是静默返回
3419
+ if (!officialDetail && !batchBusyRef.current) setError('官方差异详情尚未加载完成,请稍后重试;若一直如此,请切换文件或刷新页面。')
3420
+ return false
3421
+ }
3309
3422
  if (reject && (dirtyRef.current[active] || savingRef.current.has(active) || saveTimerRef.current.isPending())) {
3310
3423
  setError('存在未保存或正在保存的修改,请等待完成后重试'); return false
3311
3424
  }
3312
3425
  batchBusyRef.current = true
3313
- const source = officialDetail.item
3426
+ const detail = officialDetail
3314
3427
  const undoPath = active
3315
3428
  const undoModel = editorRef.current?.getModel?.()
3316
3429
  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
3430
  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)
3431
+ await submitOfficialDecision(detail, reject, blockId)
3326
3432
  if (reject && activeRef.current === undoPath && !dirtyRef.current[undoPath]
3327
3433
  && !savingRef.current.has(undoPath) && undoModel?.getVersionId?.() === undoVersion) reloadFile(true)
3328
3434
  else if (reject) setError('Undo 已完成,编辑器在等待期间变化;未覆盖当前缓冲区,请核对磁盘后刷新')
3329
3435
  refreshReview(sessionId)
3330
3436
  setStatus(reject ? '已 Undo 选中差异' : '已 Keep 选中差异')
3331
3437
  return true
3332
- } catch (cause) { setError(String(cause)); return false }
3438
+ } catch (cause) {
3439
+ dbg(sessionId, `[DEBUG decideOfficialFail] id=${detail.item.id} reject=${reject ? 1 : 0} err=${String(cause).slice(0, 160)}`)
3440
+ setError(String(cause)); return false
3441
+ }
3333
3442
  finally { batchBusyRef.current = false }
3334
3443
  }
3335
3444
 
@@ -3466,10 +3575,26 @@ export function EditorView(props) {
3466
3575
  return operationId
3467
3576
  }
3468
3577
 
3578
+ /**
3579
+ * @private
3580
+ * @author ddj 2026年10月31号
3581
+ * @description: 批量操作要求官方待审列表已就绪;切换会话后列表还在载入时,
3582
+ * 先触发一次刷新并给出可读提示,而不是直接报错中止(用户看到红色「尚未就绪」会以为功能坏了)。
3583
+ * @param action 面向用户的动作名
3584
+ * @returns 列表已就绪时为 true
3585
+ */
3586
+ function reviewReadyForBatch(action) {
3587
+ if (review.page?.overview) return true
3588
+
3589
+ refreshReview(sessionId)
3590
+ setStatus(`官方待审列表正在载入,载入完成后请再次点击${action}`)
3591
+ return false
3592
+ }
3593
+
3469
3594
  /** @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
3595
  async function actReviewAll(reject) {
3471
3596
  if (batchBusyRef.current) return
3472
- if (!review.page?.overview) { setError('官方待审列表尚未就绪,未执行批量操作'); return }
3597
+ if (!reviewReadyForBatch(reject ? 'Undo All' : 'Keep All')) return
3473
3598
  if (!sum.pendingFiles.length) return
3474
3599
  const token = reviewLifeRef.current
3475
3600
  const undoPath = activeRef.current
@@ -3504,7 +3629,7 @@ export function EditorView(props) {
3504
3629
  /** @private @author ddj 2026年10月27号 @param reject Whether to undo pending files. @description Open a DSH confirmation without starting any mutations. */
3505
3630
  function askReviewAll(reject) {
3506
3631
  if (batchBusyRef.current || !sum.pendingFiles.length) return
3507
- if (!review.page?.overview) { setError('官方待审列表尚未就绪,未执行批量操作'); return }
3632
+ if (!reviewReadyForBatch(reject ? 'Undo All' : 'Keep All')) return
3508
3633
  setReviewAllPrompt({ reject, count: sum.pendingFiles.length, token: reviewLifeRef.current })
3509
3634
  }
3510
3635
 
@@ -4581,6 +4706,25 @@ export function EditorView(props) {
4581
4706
  React.createElement('div', { className: 'edrv-loading-percent' }, Math.round(progress) + '%'),
4582
4707
  retry ? React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: retry }, '重试') : null)
4583
4708
 
4709
+ /**
4710
+ * 渲染缺失文件终态面板:说明 + 重试 + 关闭页签(不显示进度条,避免卡在 0%)。
4711
+ * @private @author ddj 2026年10月31号
4712
+ * @param gone 终态信息
4713
+ * @param retry 重新读取回调
4714
+ * @param close 关闭该标签页回调
4715
+ * @returns 终态面板 React 元素
4716
+ */
4717
+ const goneBody = (gone, retry, close) => React.createElement('div', { className: 'edrv-empty edrv-loading', 'aria-live': 'polite', role: 'status' },
4718
+ React.createElement('div', { className: 'edrv-loading-title' }, gone.title),
4719
+ React.createElement('div', { style: { fontSize: 12, maxWidth: 460, lineHeight: 1.6 } }, gone.message),
4720
+ gone.official
4721
+ ? React.createElement('div', { style: { fontSize: 12, maxWidth: 460, lineHeight: 1.6, opacity: 0.8 } },
4722
+ '待审改动在差异栏:Keep 保留删除结果并结案;Undo 需要该文件的原始内容,磁盘已删除时不可用。')
4723
+ : null,
4724
+ React.createElement('div', { style: { display: 'flex', gap: 8 } },
4725
+ React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: retry }, '重试'),
4726
+ React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: close }, '关闭标签页')))
4727
+
4584
4728
  let body
4585
4729
  if (activePlan) {
4586
4730
  const labels = PLAN_MD_LABELS
@@ -4609,6 +4753,12 @@ export function EditorView(props) {
4609
4753
  body = React.createElement('div', { className: 'edrv-empty' },
4610
4754
  React.createElement('div', null, '暂无打开的文件'),
4611
4755
  React.createElement('div', { style: { fontSize: 12 } }, '使用右上搜索框 (' + (chordOf('edrv.quickOpen') ?? 'Ctrl+P') + ') 打开工作区文件;' + (chordOf('edrv.showCommands') ?? 'Ctrl+Shift+P') + ' 打开命令栏;agent 修改文件后顶部会出现差异角标'))
4756
+ } else if (goneNow) {
4757
+ // 终态:文件已在磁盘删除且没有可用预览。不再显示 0% 进度条,也不无限重试。
4758
+ body = goneBody(goneNow, () => {
4759
+ missingReadRef.current.delete(goneNow.path)
4760
+ loadContent(active, sessionId, true)
4761
+ }, () => { void closeTab(goneNow.path) })
4612
4762
  } else if (content === null && loadError && isSessionMissingError(loadError)) {
4613
4763
  // 跨端会话缺失:终态提示而非加载失败卡死(会话仅在一端激活,本端 RPC 均被拒)
4614
4764
  body = React.createElement('div', { className: 'edrv-empty edrv-loading', 'aria-live': 'polite' },
@@ -4697,6 +4847,9 @@ export function EditorView(props) {
4697
4847
  const sessionDown = isSessionMissingError(rawReviewError)
4698
4848
  const reviewErrorTone = sessionDown ? 'info' : 'error'
4699
4849
  const reviewErrorText = !rawReviewError ? undefined : sessionDown ? SESSION_DOWN_NOTICE : sessionErrorText(rawReviewError)
4850
+ // 官方元数据未到位且没有报错 = 首次载入中:此时还不知道哪些历史记录会被官方通道接管,
4851
+ // 直接按历史记录报数会在每次重启时闪出几十个「待处理文件」,故先显示占位、批量也先不放开。
4852
+ const reviewLoading = review.page === null && !review.error
4700
4853
  const buildDockSnapshot = () => ({
4701
4854
  mode: editorDockMode(active),
4702
4855
  missingDeletion: !!missingNow,
@@ -4718,7 +4871,7 @@ export function EditorView(props) {
4718
4871
  onUndoFile: undoFile,
4719
4872
  onAcceptAllFiles: acceptAllFiles,
4720
4873
  onUndoAllFiles: undoAllFiles,
4721
- allPendingCount: allPending.length + (review.page?.pending ?? 0),
4874
+ allPendingCount: reviewLoading ? 0 : allPending.length + (review.page?.pending ?? 0),
4722
4875
  reviewBatchBusy,
4723
4876
  batchUnavailable: !review.page?.overview ? '官方待审列表尚未就绪' : undefined,
4724
4877
  onRollback: rollbackFile,
@@ -4732,7 +4885,7 @@ export function EditorView(props) {
4732
4885
  contentReady,
4733
4886
  diffTotal: displayDiffTotal(contentReady, pendingRegions.length, sum.files.find((file) => sameFile(file.path, active))?.pending ?? 0),
4734
4887
  fileIdx,
4735
- fileTotal: sum.pendingFiles.length,
4888
+ fileTotal: reviewLoading ? null : sum.pendingFiles.length,
4736
4889
  onPrevDiff: () => gotoDiff(-1),
4737
4890
  onNextDiff: () => gotoDiff(1),
4738
4891
  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>
@@ -2,9 +2,10 @@
2
2
  import { createHash, randomUUID } from 'node:crypto'
3
3
  import { resolve } from 'node:path'
4
4
  import type { DiffRecord, MissingReview } from './shared/types.js'
5
+ import type { MissingRefusal } from './shared/rpc.js'
5
6
  import type { Ctx, Session } from './store.js'
6
7
  import { archiveRecords, policyOf, READ_CAP, resolveTargetIn, targetPathOf } from './store.js'
7
- import { hasOfficial } from './reviewDb.js'
8
+ import { hasOfficial, officialPending } from './reviewDb.js'
8
9
  import { recordResolved } from './model.js'
9
10
 
10
11
  interface FrozenReview {
@@ -18,6 +19,32 @@ interface FrozenReview {
18
19
  const frozen = new Map<string, FrozenReview>()
19
20
  const locks = new Set<string>()
20
21
 
22
+ /**
23
+ * @public
24
+ * @author ddj 2026年10月31号
25
+ * @description: 给缺失文件预览的可预期拒绝挂稳定错误码。客户端据此显示终态说明,
26
+ * 而不是把内部错误原文当「加载失败」反复重试;也不让上游把 SQLite 报错原文透给编辑器。
27
+ * @param code 稳定错误码(official-covered / no-records / no-evidence / too-large)
28
+ * @param message 可读原因
29
+ * @returns 带 code 的 Error
30
+ */
31
+ export function refusal(code: MissingRefusal, message: string): Error {
32
+ return Object.assign(new Error(message), { code })
33
+ }
34
+
35
+ /**
36
+ * @public
37
+ * @author ddj 2026年10月31号
38
+ * @description: 从任意错误里取回缺失文件预览的稳定拒绝码;不是本模块抛出的错误一律返回 undefined。
39
+ * @param error 捕获到的错误
40
+ * @returns 稳定拒绝码或 undefined
41
+ */
42
+ export function refusalCodeOf(error: unknown): MissingRefusal | undefined {
43
+ const code = (error as { code?: unknown } | null)?.code
44
+ return code === 'official-covered' || code === 'official-decided' || code === 'no-records'
45
+ || code === 'no-evidence' || code === 'too-large' ? code : undefined
46
+ }
47
+
21
48
  /** @private @author ddj 2026年10月28号 @param target Resolved absolute path. @returns Platform file identity. */
22
49
  function fileKey(target: string): string {
23
50
  const normalized = resolve(target).replace(/\\/g, '/')
@@ -67,13 +94,25 @@ export async function missingReview(ctx: Ctx, session: Session, cwd: string, buc
67
94
  // 解析结果可能是不透明 FsTarget,凡按路径字符串使用的分支都必须先还原。
68
95
  const abs = targetPathOf(ctx, target)
69
96
  if (!abs) throw new Error('无法解析目标文件路径')
70
- if (hasOfficial(cwd, path) || hasOfficial(cwd, abs)) throw new Error('该文件已有官方审查记录,请使用官方审查入口')
97
+ // 官方记录覆盖该文件时不再给整文件删除预览:删除内容只能用差异栏的 Keep 结案,
98
+ // 但必须带稳定错误码,客户端才能显示「已删除」终态而不是卡在加载失败。
99
+ if (hasOfficial(cwd, path) || hasOfficial(cwd, abs)) {
100
+ if (!officialPending(cwd, abs) && !officialPending(cwd, path)) {
101
+ throw refusal('official-decided', '该文件已在磁盘删除,官方审查记录均已处理:可直接关闭该标签页')
102
+ }
103
+ throw refusal('official-covered', '该文件已有官方审查记录(磁盘上已删除):请在差异栏对该待审条目执行 Keep 结案;'
104
+ + '整文件删除预览只适用于待审历史记录')
105
+ }
71
106
  if (!await fileAbsent(ctx, cwd, abs)) throw new Error('文件已重新出现,请刷新')
72
107
  const records = await fileRecords(ctx, cwd, bucket, abs)
73
108
  const latest = records.at(-1)
74
- if (!records.some((record) => !record.archived && !recordResolved(record))) throw new Error('没有待审历史记录')
75
- if (!latest || typeof latest.after !== 'string') throw new Error('缺少可靠的完整历史快照,无法展示或恢复;不会用局部差异拼接文件')
76
- if (Buffer.byteLength(latest.after, 'utf8') > READ_CAP) throw new Error('历史快照过大,无法整文件预览')
109
+ if (!records.some((record) => !record.archived && !recordResolved(record))) {
110
+ throw refusal('no-records', '磁盘上已没有该文件,且没有待审历史记录:可直接关闭该标签页')
111
+ }
112
+ if (!latest || typeof latest.after !== 'string') {
113
+ throw refusal('no-evidence', '磁盘上已没有该文件,且缺少可靠的完整历史快照,无法展示或恢复;不会用局部差异拼接文件')
114
+ }
115
+ if (Buffer.byteLength(latest.after, 'utf8') > READ_CAP) throw refusal('too-large', '磁盘上已没有该文件,且历史快照过大,无法整文件预览')
77
116
 
78
117
  for (const [token, item] of frozen) if (item.expires < Date.now()) frozen.delete(token)
79
118
  if (frozen.size >= 256) throw new Error('缺失文件预览过多,请稍后重试')
@@ -184,6 +184,11 @@ export function installCapture(ctx: Ctx, onSnapshot?: (cwd: string, snapshot: Re
184
184
  log.warn('官方历史补采不可用:缺少 sessionQuery;后续实时事件仍会采集')
185
185
  return
186
186
  }
187
+ const source = ctx.get('workspaceChanges')
188
+ if (!source?.summary) {
189
+ log.warn('官方历史补采不可用:缺少 workspaceChanges;后续实时事件仍会采集')
190
+ return
191
+ }
187
192
  try {
188
193
  // Repair durable history independently: official summaries do not survive Host restart.
189
194
  // Each saved snapshot resolves against its own cwd, not the current session directory.
@@ -193,11 +198,17 @@ export function installCapture(ctx: Ctx, onSnapshot?: (cwd: string, snapshot: Re
193
198
  }
194
199
  // Semantic search omits structural events; only the raw metadata list contains workspace/changes.
195
200
  const events = await query.listEvents(session.id)
201
+ let expired = 0
196
202
  for (const event of events) {
197
203
  if (event.type !== 'workspace/changes' || event.seq < (session.inheritedEventCount ?? 0)) continue
204
+ // Official summaries live only in Host memory, so a restart makes every
205
+ // historical event unreadable. Probe before enqueue: queueing them records
206
+ // one durable gap per turn that can never be repaired or replayed.
207
+ if (!source.summary(session.id, event.seq)) { expired += 1; continue }
198
208
  const window = await query.readEvent({ sessionId: session.id, seq: event.seq })
199
209
  collector.enqueue(session, window.target)
200
210
  }
211
+ if (expired) log.info(`官方历史补采跳过 ${expired} 轮已失效事件:官方 summary 不跨宿主重启保留,实时事件照常采集`)
201
212
  } catch (error) { log.warn('官方历史补采失败:' + String(error)) }
202
213
  }
203
214
  }
package/src/reviewDb.ts CHANGED
@@ -205,6 +205,29 @@ export function reviewPage(cwd: string, cursor = 0, history = false, path?: stri
205
205
 
206
206
  }
207
207
 
208
+ /**
209
+ * @public
210
+ * @author ddj 2026年10月31号
211
+ * @description: 该文件是否还有未处理的官方待审改动。缺失文件预览据此区分「需要去差异栏 Keep 结案」
212
+ * 与「记录都已处理、可直接关闭标签页」,避免已结案的删除文件继续提示待审。
213
+ * @param cwd Migrated workspace
214
+ * @param path Host path of the file
215
+ * @returns 存在未决官方记录时为 true
216
+ */
217
+ export function officialPending(cwd: string, path: string): boolean {
218
+ if (typeof path !== 'string') return false
219
+ const db = dbFor(cwd)
220
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name='review_files' AND type='table'").get()) return false
221
+ // Windows 盘符路径按「反斜杠折成正斜杠 + 折大小写」比较(与 hasOfficial 的 SQL 归一化等价);
222
+ // 其余平台按身份精确比较,别把大小写不同的两个文件当成同一个。
223
+ if (path.length > 1 && path[1] === ':') {
224
+ const folded = path.split(String.fromCharCode(92)).join('/').toLowerCase()
225
+ return !!db.prepare("SELECT 1 FROM review_files WHERE current=1 AND decision='pending'"
226
+ + " AND lower(replace(identity,char(92),'/'))=? LIMIT 1").get(folded)
227
+ }
228
+ return !!db.prepare("SELECT 1 FROM review_files WHERE current=1 AND decision='pending' AND identity=? LIMIT 1").get(path)
229
+ }
230
+
208
231
  /** @public @author ddj 2026年10月26号 @param cwd Migrated workspace. @param path Legacy write target. @returns Whether any official version owns the file, including decided history. */
209
232
  export function hasOfficial(cwd: string, path: string): boolean {
210
233
  // 防御:解析结果若被误当成路径传进来(不透明对象),按「无官方记录」处理,