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/README.md +1 -0
- package/lib/client.js +105 -3
- package/lib/client.js.map +2 -2
- package/lib/index.js +97 -6
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/missingReview.ts +24 -0
- package/src/client/ui/EditorView.ts +67 -4
- package/src/missingReview.ts +44 -5
- package/src/officialCapture.ts +11 -0
- package/src/reviewDb.ts +23 -0
- package/src/reviewRpc.ts +32 -2
- package/src/rpc.ts +6 -2
- package/src/shared/rpc.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.23.
|
|
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
|
-
/**
|
|
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) {
|
|
1182
|
-
if (!result.review) {
|
|
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' },
|
package/src/missingReview.ts
CHANGED
|
@@ -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
|
-
|
|
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)))
|
|
75
|
-
|
|
76
|
-
|
|
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('缺失文件预览过多,请稍后重试')
|
package/src/officialCapture.ts
CHANGED
|
@@ -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) {
|
|
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> = (
|