dsh-vscode-mode 0.21.0 → 0.21.1
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 +5 -4
- package/lib/client.js +246 -16
- package/lib/client.js.map +2 -2
- package/lib/index.js +210 -1
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/missingReview.ts +40 -0
- package/src/client/ui/EditorView.ts +118 -14
- package/src/client/ui/SidecarDiffBar.tsx +4 -2
- package/src/missingReview.ts +127 -0
- package/src/rpc.ts +32 -6
- package/src/shared/rpc.ts +5 -1
- package/src/shared/types.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.1",
|
|
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": [
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { MissingReview } from '../shared/types.js'
|
|
2
|
+
import { splitLines } from '../shared/diff.js'
|
|
3
|
+
|
|
4
|
+
/** @public @author ddj 2026年10月28号 @param review Frozen complete snapshot. @returns One whole-file deletion, including empty files; no invented current lines. */
|
|
5
|
+
export function missingRegion(review: MissingReview) {
|
|
6
|
+
return {
|
|
7
|
+
callId: review.callIds[0], idx: 0, start: 1, end: 1, oldStart: 1,
|
|
8
|
+
oldLines: splitLines(review.content.replace(/\r\n/g, '\n')), newLines: [], whole: true,
|
|
9
|
+
status: 'pending', create: false, superseded: false, sourceKind: 'missing',
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface MissingBatchApi {
|
|
14
|
+
query(path: string): Promise<{ ok: true; review: MissingReview | null } | { ok: false; error: string }>
|
|
15
|
+
decide(review: MissingReview, operationId: string): Promise<{ ok: true; callIds: string[] } | { ok: false; error: string }>
|
|
16
|
+
guard(): void
|
|
17
|
+
operation(review: MissingReview): string
|
|
18
|
+
committed(review: MissingReview, callIds: string[]): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** @public @author ddj 2026年10月28号 @param paths Batch paths. @param api Guarded Host access. @returns Missing paths handled once; a partial failure stops before legacy hunk writes. @throws Missing evidence, changed lifecycle or Host decision failure. */
|
|
22
|
+
export async function missingBatch(paths: string[], api: MissingBatchApi): Promise<Set<string>> {
|
|
23
|
+
const candidates: MissingReview[] = []
|
|
24
|
+
for (const path of new Set(paths)) {
|
|
25
|
+
api.guard()
|
|
26
|
+
const result = await api.query(path)
|
|
27
|
+
if (!result.ok) throw new Error(result.error)
|
|
28
|
+
if (result.review) candidates.push(result.review)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const completed = new Set<string>()
|
|
32
|
+
for (const review of candidates) {
|
|
33
|
+
api.guard()
|
|
34
|
+
const result = await api.decide(review, api.operation(review))
|
|
35
|
+
if (!result.ok) throw new Error(`已处理 ${completed.size} 个删除文件,其余未处理。${result.error}`)
|
|
36
|
+
completed.add(review.path)
|
|
37
|
+
api.committed(review, result.callIds)
|
|
38
|
+
}
|
|
39
|
+
return completed
|
|
40
|
+
}
|
|
@@ -61,6 +61,7 @@ import { applyOfficial, editorFont, registerThemes, themeNameOf } from '../monac
|
|
|
61
61
|
import { createDiffRenderer } from '../monaco/diffRender.js'
|
|
62
62
|
import { ST, callIdAttr, noopHunk, summarize } from '../state/records.js'
|
|
63
63
|
import { diffRegions } from '../state/regions.js'
|
|
64
|
+
import { missingBatch, missingRegion } from '../missingReview.js'
|
|
64
65
|
import { QuickOpen } from './QuickOpen.js'
|
|
65
66
|
import { attachHorizontalWheel } from './horizontalWheel.js'
|
|
66
67
|
import { CommandPalette } from './CommandPalette.js'
|
|
@@ -319,6 +320,9 @@ export function EditorView(props) {
|
|
|
319
320
|
const [dirtyMap, setDirtyMap] = React.useState({})
|
|
320
321
|
const [content, setContent] = React.useState(null)
|
|
321
322
|
const [contentPath, setContentPath] = React.useState(null)
|
|
323
|
+
const [missingPreview, setMissingPreview] = React.useState(null)
|
|
324
|
+
const missingRef = React.useRef(new Map())
|
|
325
|
+
const missingNow = missingPreview?.path === active && missingPreview?.sessionId === sessionId ? missingPreview : null
|
|
322
326
|
const [receiptLoad, setReceiptLoad] = React.useState(null) // { sessionId, path, sequence, status, error }
|
|
323
327
|
const [imageSrc, setImageSrc] = React.useState(null) // 图片预览 data URL(图片 tab 专用,文本 tab 恒为 null)
|
|
324
328
|
const [imgSize, setImgSize] = React.useState(null) // 图片自然尺寸 { w, h }(路径栏 meta)
|
|
@@ -634,17 +638,25 @@ export function EditorView(props) {
|
|
|
634
638
|
const trace = isDebug() && contentReady && currentRecords.length > 0
|
|
635
639
|
const start = trace ? performance.now() : 0
|
|
636
640
|
// #endregion
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
641
|
+
let next
|
|
642
|
+
if (officialDetail) {
|
|
643
|
+
next = reviewRegions(officialDetail.diff, officialDetail.item.id, officialDetail.blocks ?? [], contentReady ? content : null)
|
|
644
|
+
} else if (missingNow && contentReady) {
|
|
645
|
+
next = missingNow.resolved ? [] : [missingRegion(missingNow)]
|
|
646
|
+
} else {
|
|
647
|
+
next = diffRegions(currentRecords, contentReady ? content : null).filter((r) => !r.superseded)
|
|
648
|
+
}
|
|
640
649
|
// #region debug log
|
|
641
650
|
if (trace) {
|
|
642
651
|
try { dbg(sessionId, `[DEBUG diffRegions] records=${currentRecords.length} bytes=${content.length} regions=${next.length} ms=${(performance.now() - start).toFixed(1)}`) } catch (e) { /* 诊断不可影响渲染 */ }
|
|
643
652
|
}
|
|
644
653
|
// #endregion
|
|
645
654
|
return next
|
|
646
|
-
}, [currentRecords, content, contentPath, active, officialDetail])
|
|
655
|
+
}, [currentRecords, content, contentPath, active, officialDetail, missingNow])
|
|
647
656
|
// useMemo 稳定引用:否则 hover 等重渲染会让 view zone effect 反复重建(- 号闪烁)
|
|
657
|
+
React.useEffect(() => {
|
|
658
|
+
editorRef.current?.updateOptions({ readOnly: !!missingNow })
|
|
659
|
+
}, [missingNow, active, content, monaco])
|
|
648
660
|
const pendingRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && !r.stale), [regions])
|
|
649
661
|
const staleRegions = React.useMemo(() => regions.filter((r) => r.status === ST.PENDING && r.stale), [regions])
|
|
650
662
|
const staleMissing = staleRegions.filter((region) => region.staleReason === 'missing').length
|
|
@@ -1109,7 +1121,10 @@ export function EditorView(props) {
|
|
|
1109
1121
|
/** @author ddj 2026年09月28号 @param res Disk content for a still-clean cached model. */
|
|
1110
1122
|
const accept = (res) => {
|
|
1111
1123
|
if (!loadCurrent(path, sequence) || dirtyRef.current[path] || model.getValue() !== before) return
|
|
1124
|
+
if (res?.code === 'file-missing') { void loadMissing(path, sid, sequence); return }
|
|
1112
1125
|
if (!res?.ok) return
|
|
1126
|
+
missingRef.current.delete(path)
|
|
1127
|
+
setMissingPreview(null)
|
|
1113
1128
|
mark(res.version)
|
|
1114
1129
|
if (res.content === before) return
|
|
1115
1130
|
saveViewState(path)
|
|
@@ -1130,6 +1145,27 @@ export function EditorView(props) {
|
|
|
1130
1145
|
* @param force 强制走 RPC(reloadFile 用,跳过 model 复用)
|
|
1131
1146
|
* @param version 已知磁盘版本(缺省走 RPC 返回值)
|
|
1132
1147
|
*/
|
|
1148
|
+
/** @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. */
|
|
1149
|
+
async function loadMissing(path, sid, seq) {
|
|
1150
|
+
try {
|
|
1151
|
+
const result = await rpc('edrv.missingReview', { sessionId: sid, path })
|
|
1152
|
+
if (!loadCurrent(path, seq)) return
|
|
1153
|
+
if (!result.ok) { failLoad(path, sid, seq, result.error); return }
|
|
1154
|
+
if (!result.review) { loadContent(path, sid, true); return }
|
|
1155
|
+
if (dirtyRef.current[path] || savingRef.current.has(path)) { failLoad(path, sid, seq, '文件已删除,但有未保存编辑;请先处理本地缓冲'); return }
|
|
1156
|
+
const preview = { ...result.review, sessionId: sid, keepId: crypto.randomUUID(), undoId: crypto.randomUUID() }
|
|
1157
|
+
missingRef.current.set(path, preview)
|
|
1158
|
+
setMissingPreview(preview)
|
|
1159
|
+
setContent('')
|
|
1160
|
+
setContentPath(path)
|
|
1161
|
+
setLoadError(null)
|
|
1162
|
+
setError(null)
|
|
1163
|
+
stampLoad(path, sid, seq)
|
|
1164
|
+
setLoadStage({ progress: 100, message: '文件已删除 · 展示最后记录快照' })
|
|
1165
|
+
setStatus('文件已删除 · 展示最后记录快照(只读)')
|
|
1166
|
+
} catch (cause) { if (loadCurrent(path, seq)) failLoad(path, sid, seq, String(cause)) }
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1133
1169
|
const loadContent = (path, sid, force, version) => {
|
|
1134
1170
|
const seq = ++loadSeqRef.current
|
|
1135
1171
|
setReceiptLoad({ path, cwd, sessionId: sid, sequence: seq, status: 'loading' })
|
|
@@ -1176,7 +1212,7 @@ export function EditorView(props) {
|
|
|
1176
1212
|
loadPdf(path, sid, seq, force === true, version)
|
|
1177
1213
|
return
|
|
1178
1214
|
}
|
|
1179
|
-
const cachedModel = force ? null : modelsRef.current.get(path)
|
|
1215
|
+
const cachedModel = force || missingRef.current.has(path) ? null : modelsRef.current.get(path)
|
|
1180
1216
|
if (cachedModel) {
|
|
1181
1217
|
// model 命中:直接显示其内容(切 tab 零等待),后台静默校验
|
|
1182
1218
|
setContent(cachedModel.getValue())
|
|
@@ -1199,6 +1235,8 @@ export function EditorView(props) {
|
|
|
1199
1235
|
// #endregion
|
|
1200
1236
|
if (!loadCurrent(path, seq)) return
|
|
1201
1237
|
if (res && res.ok) {
|
|
1238
|
+
missingRef.current.delete(path)
|
|
1239
|
+
setMissingPreview(null)
|
|
1202
1240
|
mark(res.version, true)
|
|
1203
1241
|
setContent(res.content)
|
|
1204
1242
|
setContentPath(path)
|
|
@@ -1206,6 +1244,7 @@ export function EditorView(props) {
|
|
|
1206
1244
|
setLoadStage((prev) => ({ progress: Math.max(84, prev.progress), message: '文件已读取,准备创建编辑器…' }))
|
|
1207
1245
|
setStatus('已加载')
|
|
1208
1246
|
} else {
|
|
1247
|
+
if (res?.code === 'file-missing') { void loadMissing(path, sid, seq); return }
|
|
1209
1248
|
if (res?.code === 'file-too-large') setLargeCandidate(JSON.stringify([scope, sid, path]))
|
|
1210
1249
|
const base = res?.error ? String(res.error) : '读取失败'
|
|
1211
1250
|
dapTrace('read-failed', { path, error: base, resolvedPath: res?.resolvedPath, cwd })
|
|
@@ -1409,6 +1448,9 @@ export function EditorView(props) {
|
|
|
1409
1448
|
emitRefresh({ scope, paths: [change.path], kind: 'text', eventId })
|
|
1410
1449
|
return
|
|
1411
1450
|
}
|
|
1451
|
+
if (change.kind === 'deleted' && change.path === activeRef.current && !dirtyRef.current[change.path] && !savingRef.current.has(change.path)) {
|
|
1452
|
+
loadContent(change.path, sessionId, true)
|
|
1453
|
+
}
|
|
1412
1454
|
const kind = change.kind === 'deleted' ? 'deleted' : 'conflict'
|
|
1413
1455
|
markDiskFlag(readSync(scope, change.path) ?? { path: change.path, kind, at: Date.now() })
|
|
1414
1456
|
setStatus(kind === 'deleted' ? '文件已被外部删除' : '外部已修改(未保存的编辑保留)')
|
|
@@ -2351,6 +2393,7 @@ export function EditorView(props) {
|
|
|
2351
2393
|
/** @private @author ddj 2026年10月14号 @param path Canonical path. @param manual Explicit save. @returns Guarded save, never another pane's content. */
|
|
2352
2394
|
function saveDocument(path, manual) {
|
|
2353
2395
|
if (isExcelPath(path)) return Promise.resolve({ ok: false })
|
|
2396
|
+
if (missingRef.current.has(path)) { setStatus('删除预览只读;请使用 Keep / Undo'); return Promise.resolve({ ok: false }) }
|
|
2354
2397
|
const model = modelsRef.current.get(path)
|
|
2355
2398
|
if (!model) return Promise.resolve({ ok: false })
|
|
2356
2399
|
const store = documents()
|
|
@@ -2409,7 +2452,7 @@ export function EditorView(props) {
|
|
|
2409
2452
|
const ed = editorRef.current
|
|
2410
2453
|
const model = ed?.getModel()
|
|
2411
2454
|
const path = [...modelsRef.current].find(([, item]) => item === model)?.[0]
|
|
2412
|
-
if (!ed || !path) return
|
|
2455
|
+
if (!ed || !path || missingRef.current.has(path)) return
|
|
2413
2456
|
setDirtyMap((d) => Object.assign({}, d, { [path]: true }))
|
|
2414
2457
|
// 编辑即「使用」:需求 2 的淘汰口径是「最近没有改动」,故有改动时刷新 LRU 序号,
|
|
2415
2458
|
// 使长时间只在某文件里编辑的页签不会被其它文件的打开动作挤出。
|
|
@@ -2533,6 +2576,7 @@ export function EditorView(props) {
|
|
|
2533
2576
|
const side = layoutRef.current === 'side'
|
|
2534
2577
|
const ed = m.editor.create(node, {
|
|
2535
2578
|
value: '',
|
|
2579
|
+
readOnly: missingRef.current.has(activeRef.current),
|
|
2536
2580
|
language: 'plaintext',
|
|
2537
2581
|
theme: themeNameOf(),
|
|
2538
2582
|
fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)',
|
|
@@ -3149,6 +3193,7 @@ export function EditorView(props) {
|
|
|
3149
3193
|
* @returns Promise<boolean> 是否成功
|
|
3150
3194
|
*/
|
|
3151
3195
|
const actHunk = (region, reject, silent) => {
|
|
3196
|
+
if (region.sourceKind === 'missing') return decideMissing(reject)
|
|
3152
3197
|
if (region.sourceKind === 'official') return decideOfficial(reject, region.id)
|
|
3153
3198
|
const method = reject ? 'edrv.reject' : 'edrv.accept'
|
|
3154
3199
|
// #region debug log
|
|
@@ -3280,7 +3325,35 @@ export function EditorView(props) {
|
|
|
3280
3325
|
finally { batchBusyRef.current = false }
|
|
3281
3326
|
}
|
|
3282
3327
|
|
|
3328
|
+
/** @private @author ddj 2026年10月28号 @param reject Restore the shown snapshot instead of keeping absence. @returns Safe whole-file decision, never a hunk reversal. */
|
|
3329
|
+
async function decideMissing(reject) {
|
|
3330
|
+
if (batchBusyRef.current || !missingNow || missingNow.resolved) return false
|
|
3331
|
+
const preview = missingNow
|
|
3332
|
+
const lifetime = reviewLifeRef.current
|
|
3333
|
+
batchBusyRef.current = true
|
|
3334
|
+
setReviewBatchBusy(true)
|
|
3335
|
+
try {
|
|
3336
|
+
guardReviewAll(reject, lifetime)
|
|
3337
|
+
const result = await rpc('edrv.missingDecide', { sessionId, token: preview.token, operationId: reject ? preview.undoId : preview.keepId, undo: reject })
|
|
3338
|
+
if (!lifetime?.live) return false
|
|
3339
|
+
if (!result.ok) { setError(result.error); return false }
|
|
3340
|
+
setRecords((previous) => Object.fromEntries(Object.entries(previous).filter(([id]) => !result.callIds.includes(id))))
|
|
3341
|
+
missingRef.current.set(preview.path, { ...preview, resolved: true })
|
|
3342
|
+
if (activeRef.current === preview.path) {
|
|
3343
|
+
if (reject) loadContent(preview.path, sessionId, true)
|
|
3344
|
+
else setMissingPreview({ ...preview, resolved: true })
|
|
3345
|
+
}
|
|
3346
|
+
refreshRecords()
|
|
3347
|
+
emitRefresh({ scope, paths: [preview.path], kind: 'structure' })
|
|
3348
|
+
setStatus(reject ? '已恢复展示的完整历史快照' : '已保留文件删除')
|
|
3349
|
+
setError(null)
|
|
3350
|
+
return true
|
|
3351
|
+
} catch (cause) { if (lifetime?.live) setError(String(cause)); return false }
|
|
3352
|
+
finally { batchBusyRef.current = false; if (lifetime?.live) setReviewBatchBusy(false) }
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3283
3355
|
const acceptFile = () => {
|
|
3356
|
+
if (missingNow && !officialDetail) return decideMissing(false)
|
|
3284
3357
|
if (officialDetail) return decideOfficial(false)
|
|
3285
3358
|
if (batchBusyRef.current || !decideRegions.length) return
|
|
3286
3359
|
batchBusyRef.current = true
|
|
@@ -3293,6 +3366,7 @@ export function EditorView(props) {
|
|
|
3293
3366
|
}).catch(() => { batchBusyRef.current = false })
|
|
3294
3367
|
}
|
|
3295
3368
|
const undoFile = () => {
|
|
3369
|
+
if (missingNow && !officialDetail) return decideMissing(true)
|
|
3296
3370
|
if (officialDetail) return decideOfficial(true)
|
|
3297
3371
|
if (batchBusyRef.current || !decideRegions.length) return
|
|
3298
3372
|
batchBusyRef.current = true
|
|
@@ -3325,18 +3399,47 @@ export function EditorView(props) {
|
|
|
3325
3399
|
* @author ddj 2026年08月20号
|
|
3326
3400
|
* @param reject true=全部不采纳(回滚),false=全部采纳
|
|
3327
3401
|
*/
|
|
3328
|
-
const actAllPending = (reject) => {
|
|
3402
|
+
const actAllPending = async (reject) => {
|
|
3329
3403
|
if (batchBusyRef.current || !allPending.length) return
|
|
3404
|
+
const lifetime = reviewLifeRef.current
|
|
3330
3405
|
batchBusyRef.current = true
|
|
3331
3406
|
const list = reject ? [...allPending].sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : b.idx - a.idx)) : allPending
|
|
3332
3407
|
const paths = [...new Set(list.map((r) => records[r.callId]?.path).filter(Boolean))]
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3408
|
+
try {
|
|
3409
|
+
const handled = await missingBatch(paths, {
|
|
3410
|
+
query: async (path) => {
|
|
3411
|
+
// The active deletion must use exactly the evidence the user saw.
|
|
3412
|
+
const cached = missingRef.current.get(path)
|
|
3413
|
+
if (cached?.sessionId === sessionId && !cached.resolved) return { ok: true, review: cached }
|
|
3414
|
+
const result = await rpc('edrv.missingReview', { sessionId, path })
|
|
3415
|
+
if (result.ok && result.review) {
|
|
3416
|
+
const preview = { ...result.review, sessionId, keepId: crypto.randomUUID(), undoId: crypto.randomUUID() }
|
|
3417
|
+
missingRef.current.set(path, preview)
|
|
3418
|
+
return { ok: true, review: preview }
|
|
3419
|
+
}
|
|
3420
|
+
return result
|
|
3421
|
+
},
|
|
3422
|
+
decide: (preview, operationId) => rpc('edrv.missingDecide', { sessionId, token: preview.token, operationId, undo: reject }),
|
|
3423
|
+
guard: () => guardReviewAll(reject, lifetime),
|
|
3424
|
+
operation: (preview) => reject ? preview.undoId : preview.keepId,
|
|
3425
|
+
committed: (preview, callIds) => {
|
|
3426
|
+
if (!lifetime?.live) return
|
|
3427
|
+
missingRef.current.set(preview.path, { ...preview, resolved: true })
|
|
3428
|
+
setRecords((previous) => Object.fromEntries(Object.entries(previous).filter(([id]) => !callIds.includes(id))))
|
|
3429
|
+
if (!reject && sameFile(activeRef.current, preview.path)) setMissingPreview({ ...preview, path: activeRef.current, sessionId, resolved: true })
|
|
3430
|
+
emitRefresh({ scope, paths: [preview.path], kind: 'structure' })
|
|
3431
|
+
},
|
|
3432
|
+
})
|
|
3433
|
+
guardReviewAll(reject, lifetime)
|
|
3434
|
+
const remaining = list.filter((item) => !handled.has(records[item.callId]?.path))
|
|
3435
|
+
const result = await actMany(remaining.map((item) => itemOf(item, reject)))
|
|
3436
|
+
if (!lifetime?.live) return
|
|
3437
|
+
if (reject || !missingNow) reloadFile(true)
|
|
3438
|
+
emitRefresh({ scope, paths, kind: 'structure' })
|
|
3439
|
+
setStatus(decideStatus(reject ? '已不采纳 ' : '已采纳 ', result) + `;删除文件 ${handled.size} 个`)
|
|
3440
|
+
if (result.fail) setError(result.fail + ' 处差异处理失败,可刷新后重试')
|
|
3441
|
+
} catch (cause) { if (lifetime?.live) setError(String(cause)) }
|
|
3442
|
+
finally { batchBusyRef.current = false; if (lifetime?.live) refreshRecords() }
|
|
3340
3443
|
}
|
|
3341
3444
|
/** @private @author ddj 2026年10月27号 @param reject Undo requires every local buffer to be saved. @param token Starting session lifetime. @returns Throws before the next mutation when unsafe. */
|
|
3342
3445
|
function guardReviewAll(reject, token) {
|
|
@@ -4584,6 +4687,7 @@ export function EditorView(props) {
|
|
|
4584
4687
|
const reviewErrorText = !rawReviewError ? undefined : sessionDown ? SESSION_DOWN_NOTICE : sessionErrorText(rawReviewError)
|
|
4585
4688
|
const buildDockSnapshot = () => ({
|
|
4586
4689
|
mode: editorDockMode(active),
|
|
4690
|
+
missingDeletion: !!missingNow,
|
|
4587
4691
|
official: officialFile ? { sessionId, page: review.page, path: officialFile,
|
|
4588
4692
|
pending: sum.files.find((file) => sameFile(file.path, active))?.pending ?? 0,
|
|
4589
4693
|
detail: officialDetail, records: officialRecords, onSelect: setSelectedReview } : null,
|
|
@@ -22,6 +22,7 @@ export interface SidecarRegion {
|
|
|
22
22
|
/** 侧车栏输入:字段与官方栏对齐,缺的是官方记录元数据,多的是侧车区域的 keep/undo 能力。 */
|
|
23
23
|
export interface SidecarBarProps {
|
|
24
24
|
activePath?: string
|
|
25
|
+
missingDeletion?: boolean
|
|
25
26
|
pendingRegions?: SidecarRegion[]
|
|
26
27
|
staleRegions?: SidecarRegion[]
|
|
27
28
|
diffIdx?: number
|
|
@@ -115,6 +116,7 @@ export function SidecarDiffBar(props: SidecarBarProps): React.ReactElement {
|
|
|
115
116
|
keepTitle="把这批历史改动全部标记为已看过(只改记录,不动磁盘文件)"
|
|
116
117
|
undoTitle="逐项撤销这些历史改动;缺少证据或遇到冲突即停止"
|
|
117
118
|
/>
|
|
119
|
+
{props.missingDeletion ? <div className="edrv-review-notice" role="status">文件已删除 · 展示最后记录快照。Keep 保留删除;Undo 恢复展示的完整快照。</div> : null}
|
|
118
120
|
{busy ? <div className="edrv-review-notice" role="status">正在处理历史改动…</div> : null}
|
|
119
121
|
{down ? <div className="edrv-review-notice edrv-review-notice-warn edrv-sidecar-down" role="status">
|
|
120
122
|
<span className="edrv-sidecar-down-text">{down.text}</span>
|
|
@@ -137,8 +139,8 @@ export function SidecarDiffBar(props: SidecarBarProps): React.ReactElement {
|
|
|
137
139
|
{navButton('down', '下一个差异', props.onNextDiff, !count, view.navNextTitle)}
|
|
138
140
|
</div>
|
|
139
141
|
<div className="edrv-review-actions">
|
|
140
|
-
<Button size="sm" variant="ghost" className="edrv-review-undo" icon={barIcon('undo')} onClick={props.onUndoFile} disabled={!canDecide} title={SIDECAR_UNDO_TITLE}>Undo</Button>
|
|
141
|
-
<Button size="sm" variant="primary" className="edrv-review-keep" icon={barIcon('check')} onClick={props.onAcceptFile} disabled={!canDecide} title={SIDECAR_KEEP_TITLE}>Keep</Button>
|
|
142
|
+
<Button size="sm" variant="ghost" className="edrv-review-undo" icon={barIcon('undo')} onClick={props.onUndoFile} disabled={!canDecide} title={props.missingDeletion ? '恢复展示的完整历史快照(不会覆盖重新出现的文件)' : SIDECAR_UNDO_TITLE}>Undo</Button>
|
|
143
|
+
<Button size="sm" variant="primary" className="edrv-review-keep" icon={barIcon('check')} onClick={props.onAcceptFile} disabled={!canDecide} title={props.missingDeletion ? '保持文件删除并结清覆盖的待审历史记录,不写文件' : SIDECAR_KEEP_TITLE}>Keep</Button>
|
|
142
144
|
<Button size="sm" variant="ghost" className="edrv-review-details" onClick={() => setExpanded(!expanded)} aria-expanded={expanded} aria-label={expanded ? '收起差异' : '展开差异'} title={expanded ? '收起差异' : '展开差异'}>详情{barIcon(expanded ? 'up' : 'down')}</Button>
|
|
143
145
|
<Menu open={menu === 'more'} anchor={moreTrigger} items={[{ id: 'pending', label: '差异总览' }, { id: 'archive', label: '归档 / 历史' }, { id: 'rollback', label: '回滚当前文件' }, { id: 'refresh', label: '刷新' }]} onSelect={(id: string) => {
|
|
144
146
|
if (id === 'rollback') { setMenu(null); props.onRollback?.(); return }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** Missing historical files: frozen, whole-file deletion reviews. */
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import type { DiffRecord, MissingReview } from './shared/types.js'
|
|
5
|
+
import type { Ctx, Session } from './store.js'
|
|
6
|
+
import { archiveRecords, policyOf, READ_CAP, resolveTargetIn } from './store.js'
|
|
7
|
+
import { hasOfficial } from './reviewDb.js'
|
|
8
|
+
import { recordResolved } from './model.js'
|
|
9
|
+
|
|
10
|
+
interface FrozenReview {
|
|
11
|
+
view: MissingReview
|
|
12
|
+
cwd: string
|
|
13
|
+
session: Session
|
|
14
|
+
revision: string
|
|
15
|
+
expires: number
|
|
16
|
+
operation?: { id: string; undo: boolean; restored: boolean; done: boolean }
|
|
17
|
+
}
|
|
18
|
+
const frozen = new Map<string, FrozenReview>()
|
|
19
|
+
const locks = new Set<string>()
|
|
20
|
+
|
|
21
|
+
/** @private @author ddj 2026年10月28号 @param target Resolved absolute path. @returns Platform file identity. */
|
|
22
|
+
function fileKey(target: string): string {
|
|
23
|
+
const normalized = resolve(target).replace(/\\/g, '/')
|
|
24
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @private @author ddj 2026年10月28号 @param records Ordered records. @returns Full evidence and decision revision, excluding transient conflict flags. */
|
|
28
|
+
function revisionOf(records: DiffRecord[]): string {
|
|
29
|
+
const evidence = records.map(({ callId, path, at, after, hunks, decisions, archived, superseded }) =>
|
|
30
|
+
({ callId, path, at, after, hunks, decisions, archived, superseded }))
|
|
31
|
+
return createHash('sha256').update(JSON.stringify(evidence)).digest('hex')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @private @author ddj 2026年10月28号 @param ctx Host capability. @param cwd Workspace. @param bucket Records. @param target Resolved file. @returns Records for the same physical path spelling, oldest first. */
|
|
35
|
+
async function fileRecords(ctx: Ctx, cwd: string, bucket: Map<string, DiffRecord>, target: string): Promise<DiffRecord[]> {
|
|
36
|
+
const matches: DiffRecord[] = []
|
|
37
|
+
const identity = fileKey(target)
|
|
38
|
+
for (const record of bucket.values()) {
|
|
39
|
+
if (record.superseded) continue
|
|
40
|
+
if (fileKey(await resolveTargetIn(ctx, cwd, record.path)) === identity) matches.push(record)
|
|
41
|
+
}
|
|
42
|
+
return matches.sort((left, right) => left.at.localeCompare(right.at))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @public @author ddj 2026年10月28号 @param ctx Host. @param cwd Workspace. @param path Requested file. @returns True only for confirmed absence; directories and IO failures are not missing files. */
|
|
46
|
+
export async function fileAbsent(ctx: Ctx, cwd: string, path: string): Promise<boolean> {
|
|
47
|
+
const fs = ctx.get('fs')
|
|
48
|
+
if (!fs) throw new Error('缺少 fs')
|
|
49
|
+
const target = await resolveTargetIn(ctx, cwd, path)
|
|
50
|
+
try { return !(await fs.stat(target)) }
|
|
51
|
+
catch (error) {
|
|
52
|
+
if (['ENOENT', 'FS_NOT_FOUND'].includes(String((error as { code?: string }).code))) return true
|
|
53
|
+
throw error
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @public @author ddj 2026年10月28号 @param ctx Host. @param session Live session. @param cwd Workspace. @param bucket Records. @param path Requested file. @returns Frozen complete snapshot; never constructs text from hunks or writes files. @throws Missing evidence or nonmissing target. */
|
|
58
|
+
export async function missingReview(ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>, path: string): Promise<MissingReview> {
|
|
59
|
+
const target = await resolveTargetIn(ctx, cwd, path)
|
|
60
|
+
if (hasOfficial(cwd, path) || hasOfficial(cwd, target)) throw new Error('该文件已有官方审查记录,请使用官方审查入口')
|
|
61
|
+
if (!await fileAbsent(ctx, cwd, target)) throw new Error('文件已重新出现,请刷新')
|
|
62
|
+
const records = await fileRecords(ctx, cwd, bucket, target)
|
|
63
|
+
const latest = records.at(-1)
|
|
64
|
+
if (!records.some((record) => !record.archived && !recordResolved(record))) throw new Error('没有待审历史记录')
|
|
65
|
+
if (!latest || typeof latest.after !== 'string') throw new Error('缺少可靠的完整历史快照,无法展示或恢复;不会用局部差异拼接文件')
|
|
66
|
+
if (Buffer.byteLength(latest.after, 'utf8') > READ_CAP) throw new Error('历史快照过大,无法整文件预览')
|
|
67
|
+
|
|
68
|
+
for (const [token, item] of frozen) if (item.expires < Date.now()) frozen.delete(token)
|
|
69
|
+
if (frozen.size >= 256) throw new Error('缺失文件预览过多,请稍后重试')
|
|
70
|
+
const view: MissingReview = { token: randomUUID(), path, content: latest.after, callIds: records.filter((record) => !record.archived && !recordResolved(record)).map((record) => record.callId), at: latest.at }
|
|
71
|
+
frozen.set(view.token, { view, cwd, session, revision: revisionOf(records), expires: Date.now() + 30 * 60_000 })
|
|
72
|
+
return view
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen evidence. @param target Resolved path. @param undo Restore instead of keeping absence. @sideEffects Atomically creates absent file, verifies exact bytes; retries never overwrite an existing file. */
|
|
76
|
+
async function applyDisk(ctx: Ctx, item: FrozenReview, target: string, undo: boolean): Promise<void> {
|
|
77
|
+
const fs = ctx.get('fs')
|
|
78
|
+
const absent = await fileAbsent(ctx, item.cwd, target)
|
|
79
|
+
if (!undo) { if (!absent) throw new Error('文件已重新出现,未执行 Keep'); return }
|
|
80
|
+
const operation = item.operation!
|
|
81
|
+
if (!absent && !operation.restored) throw new Error('文件已重新出现,未执行 Undo')
|
|
82
|
+
if (absent && !operation.restored) {
|
|
83
|
+
// Publish through the official capability and session sandbox, never unconditional overwrite.
|
|
84
|
+
await fs.writeText(target, item.view.content, { kind: 'createIfAbsent' }, undefined, policyOf(ctx, item.session))
|
|
85
|
+
operation.restored = true
|
|
86
|
+
}
|
|
87
|
+
if (absent && operation.restored && !await fs.stat(target)) throw new Error('恢复后的文件再次缺失,未结清记录')
|
|
88
|
+
if (await fs.readText(target) !== item.view.content) throw new Error('恢复内容校验失败,未结清记录')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @public @author ddj 2026年10月28号 @param ctx Host. @param session Requesting session. @param cwd Workspace. @param bucket Records. @param token Frozen preview token. @param operationId Stable retry ID. @param undo True restores shown snapshot; false keeps deletion. @returns Covered call IDs. @throws Changed evidence, concurrent action or sandbox/IO failure. */
|
|
92
|
+
export async function missingDecide(ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>, token: string, operationId: string, undo: boolean): Promise<string[]> {
|
|
93
|
+
const item = frozen.get(token)
|
|
94
|
+
if (!item || item.expires < Date.now() || item.cwd !== cwd || item.session.id !== session.id) throw new Error('删除预览已过期,请刷新后重试')
|
|
95
|
+
if (!operationId || operationId.length > 128) throw new Error('缺少有效操作 ID')
|
|
96
|
+
const target = await resolveTargetIn(ctx, cwd, item.view.path)
|
|
97
|
+
const key = JSON.stringify([cwd, fileKey(target)])
|
|
98
|
+
if (locks.has(key)) throw new Error('该文件正在处理,请稍后重试')
|
|
99
|
+
locks.add(key)
|
|
100
|
+
item.session = session
|
|
101
|
+
try { return await commitMissing(ctx, item, bucket, target, operationId, undo) }
|
|
102
|
+
finally { locks.delete(key) }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen preview. @param bucket Live records. @param target Resolved target. @param operationId Retry identity. @param undo Restore action. @returns Archived pending call IDs; disk success survives bookkeeping failure in the same Host lifetime. */
|
|
106
|
+
async function commitMissing(ctx: Ctx, item: FrozenReview, bucket: Map<string, DiffRecord>, target: string, operationId: string, undo: boolean): Promise<string[]> {
|
|
107
|
+
const previous = item.operation
|
|
108
|
+
if (previous && (previous.id !== operationId || previous.undo !== undo)) throw new Error('已有不同操作,请刷新后重试')
|
|
109
|
+
if (previous?.done) return item.view.callIds
|
|
110
|
+
if (hasOfficial(item.cwd, target) || hasOfficial(item.cwd, item.view.path)) throw new Error('该文件已有官方审查记录,请刷新')
|
|
111
|
+
const records = await fileRecords(ctx, item.cwd, bucket, target)
|
|
112
|
+
if (revisionOf(records) !== item.revision) throw new Error('历史记录已变化,请刷新删除预览')
|
|
113
|
+
item.operation ??= { id: operationId, undo, restored: false, done: false }
|
|
114
|
+
await applyDisk(ctx, item, target, undo)
|
|
115
|
+
|
|
116
|
+
// Recheck after awaited IO: capture/another review must not silently join this decision.
|
|
117
|
+
const current = await fileRecords(ctx, item.cwd, bucket, target)
|
|
118
|
+
if (revisionOf(current) !== item.revision) throw new Error('历史记录已变化,未结清记录,请刷新')
|
|
119
|
+
const pending = current.filter((record) => item.view.callIds.includes(record.callId))
|
|
120
|
+
const decisions = pending.map((record) => record.decisions)
|
|
121
|
+
const decision = undo ? 'rejected' : 'accepted'
|
|
122
|
+
for (const record of pending) record.decisions = { call: decision, perHunk: record.hunks.map(() => decision) }
|
|
123
|
+
try { await archiveRecords(ctx, item.session, item.cwd, bucket, pending, undo ? '已恢复删除文件快照' : '已保留文件删除') }
|
|
124
|
+
catch (error) { pending.forEach((record, index) => { record.decisions = decisions[index] }); throw error }
|
|
125
|
+
item.operation.done = true
|
|
126
|
+
return item.view.callIds
|
|
127
|
+
}
|
package/src/rpc.ts
CHANGED
|
@@ -60,6 +60,7 @@ import type { FileVersions } from './fileVersions.js'
|
|
|
60
60
|
import { settingsDocPath } from './settingsDocPath.js'
|
|
61
61
|
import { createTextPages } from './textPages.js'
|
|
62
62
|
import { reviewHandlers } from './reviewRpc.js'
|
|
63
|
+
import { fileAbsent, missingReview, missingDecide } from './missingReview.js'
|
|
63
64
|
|
|
64
65
|
/** Process-local cursor signer; reads must stay on the Host filesystem capability. */
|
|
65
66
|
const readTextPage = createTextPages()
|
|
@@ -169,20 +170,19 @@ async function readTargetOf(
|
|
|
169
170
|
ctx: Ctx,
|
|
170
171
|
sc: SessionScope,
|
|
171
172
|
path: string,
|
|
172
|
-
): Promise<ReadTargetOk | { err: string; resolvedPath?: string; code?: 'file-too-large' }> {
|
|
173
|
+
): Promise<ReadTargetOk | { err: string; resolvedPath?: string; code?: 'file-too-large' | 'file-missing' }> {
|
|
173
174
|
const fs = ctx.get('fs')
|
|
174
175
|
if (!fs) return { err: '缺少 fs' }
|
|
175
176
|
try {
|
|
176
177
|
const target = await resolveTargetIn(ctx, sc.cwd, path)
|
|
177
178
|
debugRecord(ctx, sc.cwd, '[DEBUG path.resolve] input=' + String(path ?? '') + ' resolved=' + fs.processPath(target), 'debug')
|
|
178
179
|
const info = await fs.stat(target)
|
|
179
|
-
if (!info
|
|
180
|
-
|
|
181
|
-
return { err: '文件不存在', resolvedPath: fs.processPath(target) }
|
|
182
|
-
}
|
|
180
|
+
if (!info) return { err: '文件不存在', resolvedPath: fs.processPath(target), code: 'file-missing' }
|
|
181
|
+
if (info.type !== 'file') return { err: '目标不是文件', resolvedPath: fs.processPath(target) }
|
|
183
182
|
if ((info.size ?? 0) > BINARY_READ_CAP) return { err: '文件过大(>32MB),不支持整文件预览', code: 'file-too-large' }
|
|
184
183
|
return { target, fs, info }
|
|
185
184
|
} catch (error) {
|
|
185
|
+
if (['ENOENT', 'FS_NOT_FOUND'].includes(String((error as { code?: string }).code))) return { err: '文件不存在', code: 'file-missing' }
|
|
186
186
|
return { err: '读取失败:' + String(error) }
|
|
187
187
|
}
|
|
188
188
|
}
|
|
@@ -193,7 +193,7 @@ async function readTargetOf(
|
|
|
193
193
|
* @param prep readTargetOf 的失败分支
|
|
194
194
|
* @returns { ok:false } 形态载荷
|
|
195
195
|
*/
|
|
196
|
-
function targetErrOf(prep: { err: string; resolvedPath?: string; code?: 'file-too-large' }): { ok: false; error: string; resolvedPath?: string; code?: 'file-too-large' } {
|
|
196
|
+
function targetErrOf(prep: { err: string; resolvedPath?: string; code?: 'file-too-large' | 'file-missing' }): { ok: false; error: string; resolvedPath?: string; code?: 'file-too-large' | 'file-missing' } {
|
|
197
197
|
return { ok: false, error: prep.err,
|
|
198
198
|
...(prep.resolvedPath !== undefined ? { resolvedPath: prep.resolvedPath } : {}),
|
|
199
199
|
...(prep.code ? { code: prep.code } : {}) }
|
|
@@ -290,6 +290,15 @@ async function applyDecisions(
|
|
|
290
290
|
results.push({ callId: item.callId, ok: false, error: '记录已归档,无法重复决策' })
|
|
291
291
|
continue
|
|
292
292
|
}
|
|
293
|
+
try {
|
|
294
|
+
if (await fileAbsent(ctx, cwd, record.path)) {
|
|
295
|
+
results.push({ callId: item.callId, ok: false, error: '文件已删除,请刷新后按整文件删除预览执行 Keep / Undo' })
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
} catch (error) {
|
|
299
|
+
results.push({ callId: item.callId, ok: false, error: '无法确认文件状态:' + String(error) })
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
293
302
|
// 重试已执行的决定只补持久化,不能再次修改磁盘上的同一个 hunk。
|
|
294
303
|
const previous = item.scope === 'hunk'
|
|
295
304
|
? record.decisions.perHunk[item.hunkIndex ?? -1] : record.decisions.call
|
|
@@ -693,6 +702,23 @@ export function buildHandlers(
|
|
|
693
702
|
if (!item?.ok) return { ok: false, error: item?.error ?? '操作失败' }
|
|
694
703
|
return { ok: true, record: item.record! }
|
|
695
704
|
},
|
|
705
|
+
'edrv.missingReview': async (args) => {
|
|
706
|
+
const sc = await requireSession(ctx, args.sessionId)
|
|
707
|
+
if ('err' in sc) return { ok: false, error: sc.err }
|
|
708
|
+
try {
|
|
709
|
+
const bucket = await bucketOf(registry, ctx, sc.cwd)
|
|
710
|
+
if (!await fileAbsent(ctx, sc.cwd, args.path)) return { ok: true, review: null }
|
|
711
|
+
return { ok: true, review: await missingReview(ctx, sc.session, sc.cwd, bucket, args.path) }
|
|
712
|
+
} catch (error) { return { ok: false, error: String(error) } }
|
|
713
|
+
},
|
|
714
|
+
'edrv.missingDecide': async (args) => {
|
|
715
|
+
const sc = await requireSession(ctx, args.sessionId)
|
|
716
|
+
if ('err' in sc) return { ok: false, error: sc.err }
|
|
717
|
+
try {
|
|
718
|
+
const bucket = await bucketOf(registry, ctx, sc.cwd)
|
|
719
|
+
return { ok: true, callIds: await missingDecide(ctx, sc.session, sc.cwd, bucket, args.token, args.operationId, args.undo === true) }
|
|
720
|
+
} catch (error) { return { ok: false, error: String(error) } }
|
|
721
|
+
},
|
|
696
722
|
'edrv.decideBatch': async (args) => {
|
|
697
723
|
const sc = await requireSession(ctx, args.sessionId)
|
|
698
724
|
if ('err' in sc) return { ok: false, error: sc.err }
|
package/src/shared/rpc.ts
CHANGED
|
@@ -377,6 +377,8 @@ export interface RpcRequestMap {
|
|
|
377
377
|
'edrv.reject': { sessionId?: string; callId: string; scope?: RpcScope; hunkIndex?: number }
|
|
378
378
|
'edrv.decideBatch': { sessionId?: string; items: DecideItem[] }
|
|
379
379
|
'edrv.textPage': import('./textPages.js').TextPageArgs
|
|
380
|
+
'edrv.missingReview': { sessionId?: string; path: string }
|
|
381
|
+
'edrv.missingDecide': { sessionId?: string; token: string; operationId: string; undo: boolean }
|
|
380
382
|
'edrv.read': { sessionId?: string; path: string; encoding?: 'base64' }
|
|
381
383
|
'edrv.readBinary': { sessionId?: string; path: string }
|
|
382
384
|
'edrv.versions': { sessionId?: string; paths: string[] }
|
|
@@ -549,6 +551,8 @@ export interface RpcOkMap {
|
|
|
549
551
|
'edrv.reject': { record: RecordView }
|
|
550
552
|
'edrv.decideBatch': { results: DecideResult[] }
|
|
551
553
|
'edrv.textPage': Extract<import('./textPages.js').TextPageResult, { ok: true }>
|
|
554
|
+
'edrv.missingReview': { review: import('./types.js').MissingReview | null }
|
|
555
|
+
'edrv.missingDecide': { callIds: string[] }
|
|
552
556
|
'edrv.read': { content: string; size: number; encoding?: 'base64'; mime?: string; version?: string }
|
|
553
557
|
'edrv.readBinary': { binary: BinaryReadPayload }
|
|
554
558
|
'edrv.versions': { items: FileVersionItem[] }
|
|
@@ -707,7 +711,7 @@ export interface RpcOkMap {
|
|
|
707
711
|
/** 统一响应:{ok:true, ...payload} 或 {ok:false, error}。 */
|
|
708
712
|
export type RpcResult<M extends RpcMethod> =
|
|
709
713
|
| ({ ok: true } & RpcOkMap[M])
|
|
710
|
-
| { ok: false; error: string; code?: 'file-too-large'; reason?: Extract<import('./textPages.js').TextPageResult, { ok: false }>['reason'] }
|
|
714
|
+
| { ok: false; error: string; code?: 'file-too-large' | 'file-missing'; reason?: Extract<import('./textPages.js').TextPageResult, { ok: false }>['reason'] }
|
|
711
715
|
|
|
712
716
|
/** 每个方法的 handler 签名(host 分发表与 client 类型检查共用)。 */
|
|
713
717
|
export type RpcHandler<M extends RpcMethod> = (
|
package/src/shared/types.ts
CHANGED
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
* 作者 ddj 2026-08-20
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
/** Frozen whole-file deletion preview, backed by a complete Host snapshot. */
|
|
12
|
+
export interface MissingReview {
|
|
13
|
+
token: string
|
|
14
|
+
path: string
|
|
15
|
+
content: string
|
|
16
|
+
callIds: string[]
|
|
17
|
+
at: string
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
/** 单条差异块的决策状态。 */
|
|
12
21
|
export type Decision = 'pending' | 'accepted' | 'rejected'
|
|
13
22
|
|