dsh-vscode-mode 0.23.2 → 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.2",
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 {
@@ -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)
@@ -1173,17 +1178,49 @@ export function EditorView(props) {
1173
1178
  * @param force 强制走 RPC(reloadFile 用,跳过 model 复用)
1174
1179
  * @param version 已知磁盘版本(缺省走 RPC 返回值)
1175
1180
  */
1176
- /** @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. */
1177
1204
  async function loadMissing(path, sid, seq) {
1178
1205
  try {
1179
1206
  const result = await rpc('edrv.missingReview', { sessionId: sid, path })
1180
1207
  if (!loadCurrent(path, seq)) return
1181
- if (!result.ok) { failLoad(path, sid, seq, result.error); return }
1182
- 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)
1183
1219
  if (dirtyRef.current[path] || savingRef.current.has(path)) { failLoad(path, sid, seq, '文件已删除,但有未保存编辑;请先处理本地缓冲'); return }
1184
1220
  const preview = { ...result.review, sessionId: sid, keepId: crypto.randomUUID(), undoId: crypto.randomUUID() }
1185
1221
  missingRef.current.set(path, preview)
1186
1222
  setMissingPreview(preview)
1223
+ setGoneFile(null)
1187
1224
  setContent('')
1188
1225
  setContentPath(path)
1189
1226
  setLoadError(null)
@@ -1198,6 +1235,7 @@ export function EditorView(props) {
1198
1235
  const seq = ++loadSeqRef.current
1199
1236
  setReceiptLoad({ path, cwd, sessionId: sid, sequence: seq, status: 'loading' })
1200
1237
  setLoadError(null)
1238
+ setGoneFile(null)
1201
1239
  setLargeCandidate(null)
1202
1240
  setPagedFile(null)
1203
1241
  setPageChanged(null)
@@ -4668,6 +4706,25 @@ export function EditorView(props) {
4668
4706
  React.createElement('div', { className: 'edrv-loading-percent' }, Math.round(progress) + '%'),
4669
4707
  retry ? React.createElement('button', { className: 'edrv-pill edrv-pill-ghost', onClick: retry }, '重试') : null)
4670
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
+
4671
4728
  let body
4672
4729
  if (activePlan) {
4673
4730
  const labels = PLAN_MD_LABELS
@@ -4696,6 +4753,12 @@ export function EditorView(props) {
4696
4753
  body = React.createElement('div', { className: 'edrv-empty' },
4697
4754
  React.createElement('div', null, '暂无打开的文件'),
4698
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) })
4699
4762
  } else if (content === null && loadError && isSessionMissingError(loadError)) {
4700
4763
  // 跨端会话缺失:终态提示而非加载失败卡死(会话仅在一端激活,本端 RPC 均被拒)
4701
4764
  body = React.createElement('div', { className: 'edrv-empty edrv-loading', 'aria-live': 'polite' },
@@ -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
  // 防御:解析结果若被误当成路径传进来(不透明对象),按「无官方记录」处理,
package/src/reviewRpc.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Session-scoped official review RPC. @author ddj 2026年10月26号 */
2
2
  import type { RpcHandlerMap } from './shared/rpc.js'
3
- import type { ReviewItem, ReviewPage } from './shared/review.js'
3
+ import type { ReviewDetail, ReviewItem, ReviewPage } from './shared/review.js'
4
4
  import type { PendingPage } from './shared/pendingReview.js'
5
5
  import { decideReview, reviewDetail, reviewPage } from './reviewDb.js'
6
6
  import { undoReview } from './reviewUndo.js'
@@ -8,6 +8,7 @@ import { applyDecide, loadDetail, loadPage } from './pending/store.js'
8
8
  import { PENDING_PREFIX, mergedPage, pendingDetailOf, pendingItemOf, pendingSamePath } from './pendingCompat.js'
9
9
  import { bucketOf, resolveScope, sessionOf } from './registry.js'
10
10
  import type { Registry } from './registry.js'
11
+ import { resolveTargetIn } from './store.js'
11
12
  import type { Ctx } from './store.js'
12
13
  import { log } from './log.js'
13
14
 
@@ -54,6 +55,35 @@ function batchSnapshot(cwd: string, pending: PendingPage): { ok: true; page: Rev
54
55
  }
55
56
  }
56
57
 
58
+ /**
59
+ * @private
60
+ * @author ddj 2026年10月31号
61
+ * @description: 文件已在磁盘删除时修正官方记录的能力说明。Keep 只写审查库、仍可结案,
62
+ * Undo 需要读写源文件、必然不可用:把「缺少原始字节」这种误导性原因换成可执行说明。
63
+ * 只改文案与 undo 位,不参与判定写入。
64
+ * @param ctx Host 能力
65
+ * @param cwd 工作区
66
+ * @param detail 官方记录详情
67
+ * @param capability 已按冷/热端调整过的能力
68
+ * @returns 修正后的能力;无法确认删除时原样返回
69
+ */
70
+ async function absenceCapability(ctx: Ctx, cwd: string, detail: ReviewDetail, capability: ReviewDetail['capability']): Promise<ReviewDetail['capability']> {
71
+ if (!capability.keep) return capability
72
+ // 记录本身就是删除(after=false):Undo 要整文件内容,官方差异只有片段
73
+ if (detail.diff.kind === 'text' && !detail.diff.after) {
74
+ return { ...capability, undo: false, reason: '该记录删除了这个文件:Keep 按删除结案;Undo 需要完整文件内容,官方差异只有片段,不可用。' }
75
+ }
76
+
77
+ // 文件已被删除时,Undo 的失败原因是「文件不在」,不是「缺少原始字节」:先查磁盘再谈证据
78
+ const fs = ctx.get('fs')
79
+ if (!fs) return capability
80
+ try {
81
+ const target = await resolveTargetIn(ctx, cwd, detail.item.currentPath)
82
+ if (await fs.stat(target)) return capability
83
+ } catch { return capability }
84
+ return { ...capability, undo: false, reason: '文件已在磁盘删除:Keep 按删除结案;Undo 需要读写该文件,当前不可用。' }
85
+ }
86
+
57
87
  /**
58
88
  * @public
59
89
  * @author ddj 2026年10月26号 / 2026年10月28号
@@ -176,7 +206,7 @@ export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandler
176
206
  return { ok: false, error: '官方审查记录不存在' }
177
207
  }
178
208
  // Keep 只写共享库,冷端放行;Undo 依赖会话证据与源文件写回,冷端关闭
179
- const capability = liveCapabilityOf(detail.capability, scope.live, false)
209
+ const capability = await absenceCapability(ctx, cwd, detail, liveCapabilityOf(detail.capability, scope.live, false))
180
210
  log.debug(`[review] detail live=${scope.live ? 1 : 0} id=${args.id} found=1`
181
211
  + ` keep=${capability.keep ? 1 : 0} undo=${capability.undo ? 1 : 0}`)
182
212
  return { ok: true, detail: { ...detail, capability } }
package/src/rpc.ts CHANGED
@@ -63,7 +63,7 @@ import { settingsDocPath } from './settingsDocPath.js'
63
63
  import { createTextPages } from './textPages.js'
64
64
  import { reviewHandlers } from './reviewRpc.js'
65
65
  import { pendingHandlers } from './pendingRpc.js'
66
- import { fileAbsent, missingReview, missingDecide, missingPath } from './missingReview.js'
66
+ import { fileAbsent, missingReview, missingDecide, missingPath, refusalCodeOf } from './missingReview.js'
67
67
  import { pendingCovers } from './pendingCompat.js'
68
68
 
69
69
  /** Process-local cursor signer; reads must stay on the Host filesystem capability. */
@@ -733,7 +733,11 @@ export function buildHandlers(
733
733
  const bucket = await bucketOf(registry, ctx, sc.cwd)
734
734
  if (!await fileAbsent(ctx, sc.cwd, args.path)) return { ok: true, review: null }
735
735
  return { ok: true, review: await missingReview(ctx, sc.session, sc.cwd, bucket, args.path) }
736
- } catch (error) { return { ok: false, error: String(error) } }
736
+ } catch (error) {
737
+ // 可预期的拒绝带稳定错误码:客户端据此显示「已删除」终态,而不是把错误原文当加载失败重试
738
+ const code = refusalCodeOf(error)
739
+ return { ok: false, error: String(error), ...(code ? { code } : {}) }
740
+ }
737
741
  },
738
742
  'edrv.missingDecide': async (args) => {
739
743
  const sc = await requireSession(ctx, args.sessionId)
package/src/shared/rpc.ts CHANGED
@@ -736,10 +736,13 @@ export interface RpcOkMap {
736
736
  'edrv.uiState.set': object
737
737
  }
738
738
 
739
+ /** 缺失文件预览的稳定拒绝码:客户端据此显示「已删除」终态,而不是把错误原文当加载失败重试。 */
740
+ export type MissingRefusal = 'official-covered' | 'official-decided' | 'no-records' | 'no-evidence' | 'too-large'
741
+
739
742
  /** 统一响应:{ok:true, ...payload} 或 {ok:false, error}。 */
740
743
  export type RpcResult<M extends RpcMethod> =
741
744
  | ({ ok: true } & RpcOkMap[M])
742
- | { ok: false; error: string; code?: 'file-too-large' | 'file-missing'; reason?: Extract<import('./textPages.js').TextPageResult, { ok: false }>['reason'] }
745
+ | { ok: false; error: string; code?: 'file-too-large' | 'file-missing' | MissingRefusal; reason?: Extract<import('./textPages.js').TextPageResult, { ok: false }>['reason'] }
743
746
 
744
747
  /** 每个方法的 handler 签名(host 分发表与 client 类型检查共用)。 */
745
748
  export type RpcHandler<M extends RpcMethod> = (