dsh-vscode-mode 0.19.2 → 0.20.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 +26 -15
- package/lib/client.js +4427 -3510
- package/lib/client.js.map +1 -1
- package/lib/index.js +937 -42
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/archiveDb.ts +8 -8
- package/src/capture.ts +2 -2
- package/src/client/monaco/diffRender.ts +2 -2
- package/src/client/reviewBatch.ts +64 -0
- package/src/client/state/diffSummary.ts +30 -0
- package/src/client/state/reviewFeed.ts +64 -0
- package/src/client/styles/editor.css +39 -0
- package/src/client/ui/ConversationDiffDock.ts +9 -10
- package/src/client/ui/DiffBadge.ts +8 -6
- package/src/client/ui/DiffBox.ts +3 -0
- package/src/client/ui/DiffLauncher.ts +21 -5
- package/src/client/ui/EditorView.ts +163 -13
- package/src/client/ui/OfficialDiffBar.tsx +112 -0
- package/src/client/ui/OfficialReview.ts +93 -0
- package/src/client-primitives.d.ts +22 -0
- package/src/index.ts +6 -0
- package/src/model.ts +7 -3
- package/src/officialCapture.ts +203 -0
- package/src/reviewCapture.ts +112 -0
- package/src/reviewDb.ts +285 -0
- package/src/reviewEvidence.ts +70 -0
- package/src/reviewRpc.ts +42 -0
- package/src/reviewUndo.ts +104 -0
- package/src/rpc.ts +14 -18
- package/src/shared/review.ts +95 -0
- package/src/shared/reviewRegions.ts +74 -0
- package/src/shared/rpc.ts +7 -0
- package/src/store.ts +2 -2
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** Raw UTF-8 evidence and patch correspondence; never reconstructs a historical file from disk. @author ddj 2026年10月27号 */
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { reviewBlocks } from './shared/reviewRegions.js'
|
|
4
|
+
import type { OfficialDiff } from './shared/review.js'
|
|
5
|
+
|
|
6
|
+
export interface RawReviewFile { present: boolean; bytes: string; hash: string }
|
|
7
|
+
export interface ReviewEvidence { before: RawReviewFile; after: RawReviewFile }
|
|
8
|
+
export interface RawReviewBlock { id: string; before: string; after: string; offset: number }
|
|
9
|
+
|
|
10
|
+
/** @public @author ddj 2026年10月27号 @param bytes File bytes, or null for confirmed absence. @returns Existence-sensitive immutable digest and base64. */
|
|
11
|
+
export function rawReviewFile(bytes: Uint8Array | null): RawReviewFile {
|
|
12
|
+
const payload = bytes === null ? Buffer.alloc(0) : Buffer.from(bytes)
|
|
13
|
+
return { present: bytes !== null, bytes: payload.toString('base64'), hash: createHash('sha256')
|
|
14
|
+
.update(bytes === null ? 'absent:' : 'present:').update(payload).digest('hex') }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** @private @author ddj 2026年10月27号 @param file Persisted raw image. @returns UTF-8 storage text preserving BOM and terminators. @throws Binary, invalid encoding or corrupt hash. */
|
|
18
|
+
function rawText(file: RawReviewFile): string {
|
|
19
|
+
const bytes = Buffer.from(file.bytes, 'base64')
|
|
20
|
+
if (bytes.length > 8 * 1024 * 1024 || bytes.includes(0)) throw new Error('回滚证据不是受支持的 UTF-8 文本')
|
|
21
|
+
if (rawReviewFile(file.present ? bytes : null).hash !== file.hash) throw new Error('回滚证据哈希不匹配')
|
|
22
|
+
// ignoreBOM=true retains the BOM codepoint, allowing byte-identical writeText.
|
|
23
|
+
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)
|
|
24
|
+
if (!Buffer.from(text, 'utf8').equals(bytes)) throw new Error('回滚证据无法字节往返')
|
|
25
|
+
return text
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @private @author ddj 2026年10月27号 @param text Storage text. @returns Physical lines, retaining each original line ending and excluding BOM. */
|
|
29
|
+
function rawLines(text: string): string[] {
|
|
30
|
+
const body = text.replace(/^\uFEFF/, '')
|
|
31
|
+
return body.match(/[^\r\n]*(?:\r\n|\n|\r|$)/g)?.filter((line, index, lines) => line !== '' || index !== lines.length - 1) ?? []
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @private @author ddj 2026年10月27号 @param lines Physical storage lines. @returns Official logical-line comparison basis. */
|
|
35
|
+
function logical(lines: string[]): string[] { return lines.map((line) => line.replace(/(?:\r\n|\r|\n)$/, '')) }
|
|
36
|
+
|
|
37
|
+
/** @public @author ddj 2026年10月27号 @param diff Official source. @param evidence Independent raw captures. @returns Exact raw segments only when every official hunk reproduces the entire captured result. @throws Incomplete chain, coarse patch or mismatched context/content. */
|
|
38
|
+
export function evidenceBlocks(diff: OfficialDiff, evidence: ReviewEvidence): RawReviewBlock[] {
|
|
39
|
+
if (diff.kind !== 'text' || diff.coarse) throw new Error('该官方差异没有可靠文本基线')
|
|
40
|
+
if (diff.before !== evidence.before.present || diff.after !== evidence.after.present) throw new Error('文件存在性证据不一致')
|
|
41
|
+
const beforeText = rawText(evidence.before), afterText = rawText(evidence.after)
|
|
42
|
+
const before = rawLines(beforeText), after = rawLines(afterText)
|
|
43
|
+
const expected = logical(before)
|
|
44
|
+
let delta = 0
|
|
45
|
+
for (const hunk of diff.hunks) {
|
|
46
|
+
const old = hunk.lines.filter((line) => line[0] === ' ' || line[0] === '-').map((line) => line.slice(1))
|
|
47
|
+
const next = hunk.lines.filter((line) => line[0] === ' ' || line[0] === '+').map((line) => line.slice(1))
|
|
48
|
+
if (old.length !== hunk.oldLines || next.length !== hunk.newLines) throw new Error('官方差异行数不一致')
|
|
49
|
+
const position = Math.max(0, hunk.oldStart - 1) + delta
|
|
50
|
+
if (JSON.stringify(expected.slice(position, position + old.length)) !== JSON.stringify(old)) throw new Error('原始基线与官方上下文不一致')
|
|
51
|
+
expected.splice(position, old.length, ...next)
|
|
52
|
+
delta += next.length - old.length
|
|
53
|
+
}
|
|
54
|
+
if (JSON.stringify(expected) !== JSON.stringify(logical(after))) throw new Error('采集链未覆盖全部官方变更')
|
|
55
|
+
if (!diff.hunks.length && beforeText !== afterText) throw new Error('官方没有提供可撤销的变更块')
|
|
56
|
+
const blocks = reviewBlocks(diff)
|
|
57
|
+
const segments = blocks.map((block) => ({ id: block.id,
|
|
58
|
+
before: before.slice(block.oldStart - 1, block.oldStart - 1 + block.oldLines.length).join(''),
|
|
59
|
+
after: after.slice(block.newStart - 1, block.newStart - 1 + block.newLines.length).join(''),
|
|
60
|
+
offset: (afterText.startsWith('\uFEFF') ? 1 : 0) + after.slice(0, block.newStart - 1).join('').length }))
|
|
61
|
+
let reversed = afterText
|
|
62
|
+
for (const segment of [...segments].reverse()) reversed = reversed.slice(0, segment.offset) + segment.before + reversed.slice(segment.offset + segment.after.length)
|
|
63
|
+
if (reversed !== beforeText) throw new Error('官方变更外的 BOM、行尾或末尾换行发生变化,证据不足')
|
|
64
|
+
return segments
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @public @author ddj 2026年10月27号 @param evidence Raw original images. @returns Exact before and after UTF-8 storage text for guarded writes. */
|
|
68
|
+
export function evidenceText(evidence: ReviewEvidence): { before: string; after: string } {
|
|
69
|
+
return { before: rawText(evidence.before), after: rawText(evidence.after) }
|
|
70
|
+
}
|
package/src/reviewRpc.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Session-scoped official review RPC. @author ddj 2026年10月26号 */
|
|
2
|
+
import type { RpcHandlerMap } from './shared/rpc.js'
|
|
3
|
+
import { decideReview, reviewDetail, reviewPage } from './reviewDb.js'
|
|
4
|
+
import { undoReview } from './reviewUndo.js'
|
|
5
|
+
import { bucketOf, cwdOfSession, sessionOf } from './registry.js'
|
|
6
|
+
import type { Registry } from './registry.js'
|
|
7
|
+
import type { Ctx } from './store.js'
|
|
8
|
+
|
|
9
|
+
/** @public @author ddj 2026年10月26号 @param ctx Host. @param registry Workspace cache. @returns Handlers resolving workspace from the live authenticated session, never caller cwd. */
|
|
10
|
+
export function reviewHandlers(ctx: Ctx, registry: Registry): Partial<RpcHandlerMap> {
|
|
11
|
+
/** @private @author ddj 2026年10月26号 @param sessionId Request scope. @returns Migrated workspace, or explicit failure without fallback. */
|
|
12
|
+
async function workspace(sessionId: string): Promise<string> {
|
|
13
|
+
const session = sessionOf(ctx, sessionId)
|
|
14
|
+
const cwd = cwdOfSession(ctx, session)
|
|
15
|
+
if (!session || !cwd) throw new Error('会话不存在或没有工作区')
|
|
16
|
+
await bucketOf(registry, ctx, cwd)
|
|
17
|
+
return cwd
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
'edrv.review.list': async (args) => {
|
|
21
|
+
const cwd = await workspace(args.sessionId)
|
|
22
|
+
return { ok: true, page: reviewPage(cwd, args.cursor, args.history, args.path) }
|
|
23
|
+
},
|
|
24
|
+
'edrv.review.detail': async (args) => {
|
|
25
|
+
const cwd = await workspace(args.sessionId)
|
|
26
|
+
const detail = reviewDetail(cwd, args.id)
|
|
27
|
+
return detail ? { ok: true, detail } : { ok: false, error: '官方审查记录不存在' }
|
|
28
|
+
},
|
|
29
|
+
'edrv.review.decide': async (args) => {
|
|
30
|
+
const cwd = await workspace(args.sessionId)
|
|
31
|
+
try {
|
|
32
|
+
const result = args.decision === 'rejected'
|
|
33
|
+
? await undoReview(ctx, sessionOf(ctx, args.sessionId), cwd, args)
|
|
34
|
+
: decideReview(cwd, args)
|
|
35
|
+
return { ok: true, ...result }
|
|
36
|
+
} catch (error) {
|
|
37
|
+
// Guard failures are review results, not transport failures: preserve their reason.
|
|
38
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** Guarded official undo with a durable write-intent journal. @author ddj 2026年10月27号 */
|
|
2
|
+
import { changeBlocks, reviewDb, reviewDetail } from './reviewDb.js'
|
|
3
|
+
import { evidenceBlocks, evidenceText, rawReviewFile } from './reviewEvidence.js'
|
|
4
|
+
import type { ReviewEvidence } from './reviewEvidence.js'
|
|
5
|
+
import type { DecideReview } from './shared/review.js'
|
|
6
|
+
import type { Ctx, Session } from './store.js'
|
|
7
|
+
import { policyOf, resolveTarget } from './store.js'
|
|
8
|
+
|
|
9
|
+
type OperationRow = { request: string; state: string; result: string | null; evidence: string }
|
|
10
|
+
interface UndoJournal { before: string; after: string }
|
|
11
|
+
const writes = new Map<string, Promise<unknown>>()
|
|
12
|
+
|
|
13
|
+
/** @public @author ddj 2026年10月27号 @param ctx Host capability. @param session Authenticated caller. @param cwd Workspace. @param request Frozen file/block version. @returns Durable decision; retries reconcile disk hashes and never blindly repeat a write. @throws Missing evidence, stale state, policy or uncertain write failure. */
|
|
14
|
+
export async function undoReview(ctx: Ctx, session: Session, cwd: string, request: DecideReview): Promise<{ revision: number }> {
|
|
15
|
+
const detail = reviewDetail(cwd, request.id)
|
|
16
|
+
const key = JSON.stringify([cwd, detail?.item.currentPath ?? request.id])
|
|
17
|
+
const prior = writes.get(key) ?? Promise.resolve()
|
|
18
|
+
const operation = prior.catch(() => undefined).then(() => runUndo(ctx, session, cwd, request))
|
|
19
|
+
writes.set(key, operation)
|
|
20
|
+
try { return await operation }
|
|
21
|
+
finally { if (writes.get(key) === operation) writes.delete(key) }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @private @author ddj 2026年10月27号 @param ctx Host. @param session Caller. @param cwd Workspace. @param request Frozen decision. @returns Serialized guarded mutation and journal reconciliation. @throws Conflicting bytes or decisions. */
|
|
25
|
+
async function runUndo(ctx: Ctx, session: Session, cwd: string, request: DecideReview): Promise<{ revision: number }> {
|
|
26
|
+
const db = reviewDb(cwd)
|
|
27
|
+
const encoded = JSON.stringify(request)
|
|
28
|
+
if (!request.operationId || request.operationId.length > 128) throw new Error('operationId 无效')
|
|
29
|
+
const prior = db.prepare('SELECT * FROM review_ops WHERE operation_id=?').get(request.operationId) as OperationRow | undefined
|
|
30
|
+
if (prior && prior.request !== encoded) throw new Error('operationId 已用于其他请求')
|
|
31
|
+
if (prior?.state === 'committed') return JSON.parse(prior.result!)
|
|
32
|
+
const detail = reviewDetail(cwd, request.id)
|
|
33
|
+
if (!detail || !detail.item.current || detail.item.seq !== request.seq || detail.item.revision !== request.revision
|
|
34
|
+
|| detail.item.decision !== 'pending') throw new Error('审查版本冲突,请刷新后重试')
|
|
35
|
+
const fs = ctx.get('fs')
|
|
36
|
+
if (!fs?.readBytes || !fs?.writeText || !fs?.stat) throw new Error('后端缺少受控回滚能力')
|
|
37
|
+
const target = await resolveTarget(ctx, session, detail.item.currentPath)
|
|
38
|
+
const info = await fs.stat(target)
|
|
39
|
+
if (!info || info.type !== 'file' || !info.version) throw new Error('文件不存在或缺少版本守卫,不能安全 Undo')
|
|
40
|
+
const bytes = await fs.readBytes(target, undefined, 8 * 1024 * 1024)
|
|
41
|
+
const current = rawReviewFile(bytes)
|
|
42
|
+
const observed = await fs.stat(target)
|
|
43
|
+
if (observed?.version !== info.version) throw new Error('文件在回滚读取期间变化,未修改文件')
|
|
44
|
+
if (prior) {
|
|
45
|
+
const journal = JSON.parse(prior.evidence) as UndoJournal
|
|
46
|
+
if (current.hash === journal.after) return finishUndo(cwd, request)
|
|
47
|
+
if (current.hash !== journal.before) throw new Error('上次 Undo 状态不确定:磁盘既非写前也非写后,禁止重复执行')
|
|
48
|
+
}
|
|
49
|
+
const unresolved = db.prepare("SELECT operation_id FROM review_ops WHERE state='prepared' AND json_extract(request,'$.id')=? AND operation_id<>?")
|
|
50
|
+
.get(request.id, request.operationId)
|
|
51
|
+
if (unresolved) throw new Error('存在未核对的 Undo,请使用原操作重试,不能创建另一项写入')
|
|
52
|
+
const row = db.prepare('SELECT payload FROM review_evidence WHERE file_id=?').get(request.id) as { payload: string } | undefined
|
|
53
|
+
if (!row) throw new Error('该历史记录缺少可信原始字节证据,不能 Undo;差异仍可展示和 Keep')
|
|
54
|
+
const evidence = JSON.parse(row.payload) as ReviewEvidence
|
|
55
|
+
const segments = evidenceBlocks(detail.diff, evidence)
|
|
56
|
+
const texts = evidenceText(evidence)
|
|
57
|
+
if (!evidence.before.present || !evidence.after.present) throw new Error('当前后端未提供受控原子删除/重建能力')
|
|
58
|
+
const selected = detail.blocks.filter((block) => block.decision === 'pending' && (request.scope !== 'hunk' || block.id === request.blockId))
|
|
59
|
+
if (!selected.length) throw new Error('差异块不存在或已处理')
|
|
60
|
+
// Build the expected disk version by removing only previously rejected blocks.
|
|
61
|
+
const rejected = new Set(detail.blocks.filter((block) => block.decision === 'rejected').map((block) => block.id))
|
|
62
|
+
const expected = reverseBlocks(texts.after, segments, rejected)
|
|
63
|
+
if (rawReviewFile(Buffer.from(expected)).hash !== current.hash) throw new Error('当前文件已被后续编辑,版本与回滚证据冲突,未修改文件')
|
|
64
|
+
const undo = new Set([...rejected, ...selected.map((block) => block.id)])
|
|
65
|
+
const next = reverseBlocks(texts.after, segments, undo)
|
|
66
|
+
const journal = { before: current.hash, after: rawReviewFile(Buffer.from(next)).hash }
|
|
67
|
+
const latest = reviewDetail(cwd, request.id)
|
|
68
|
+
if (!latest?.item.current || latest.item.revision !== request.revision || latest.item.decision !== 'pending') {
|
|
69
|
+
throw new Error('审查版本在回滚校验期间变化,未修改文件')
|
|
70
|
+
}
|
|
71
|
+
db.prepare("INSERT INTO review_ops VALUES(?,?,'prepared',NULL,?) ON CONFLICT(operation_id) DO NOTHING")
|
|
72
|
+
.run(request.operationId, encoded, JSON.stringify(journal))
|
|
73
|
+
// Provider checks the exact observation inside its atomic mutation critical section.
|
|
74
|
+
await fs.writeText(target, next, { kind: 'replaceIfVersion', version: info.version }, undefined, policyOf(ctx, session))
|
|
75
|
+
const written = rawReviewFile(await fs.readBytes(target, undefined, 8 * 1024 * 1024))
|
|
76
|
+
if (written.hash !== journal.after) throw new Error('Undo 写后内容不确定,请保留原操作重试核对')
|
|
77
|
+
return finishUndo(cwd, request)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @private @author ddj 2026年10月27号 @param text Captured result. @param segments Validated raw ranges. @param rejected Blocks to reverse. @returns Raw storage text preserving unselected blocks. */
|
|
81
|
+
function reverseBlocks(text: string, segments: ReturnType<typeof evidenceBlocks>, rejected: Set<string>): string {
|
|
82
|
+
let result = text
|
|
83
|
+
for (const segment of [...segments].reverse()) {
|
|
84
|
+
if (!rejected.has(segment.id)) continue
|
|
85
|
+
result = result.slice(0, segment.offset) + segment.before + result.slice(segment.offset + segment.after.length)
|
|
86
|
+
}
|
|
87
|
+
return result
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @private @author ddj 2026年10月27号 @param cwd Workspace. @param request Frozen action. @returns Transactionally committed status, or a retryable prepared journal if the database fails. */
|
|
91
|
+
function finishUndo(cwd: string, request: DecideReview): { revision: number } {
|
|
92
|
+
const db = reviewDb(cwd)
|
|
93
|
+
db.exec('BEGIN IMMEDIATE')
|
|
94
|
+
try {
|
|
95
|
+
const updated = db.prepare("UPDATE review_files SET revision=revision+1 WHERE id=? AND seq=? AND revision=? AND current=1 AND decision='pending'")
|
|
96
|
+
.run(request.id, request.seq, request.revision)
|
|
97
|
+
if (updated.changes !== 1) throw new Error('审查版本冲突,请核对已执行 Undo')
|
|
98
|
+
changeBlocks(db, request)
|
|
99
|
+
const result = { revision: request.revision + 1 }
|
|
100
|
+
db.prepare("UPDATE review_ops SET state='committed',result=? WHERE operation_id=?").run(JSON.stringify(result), request.operationId)
|
|
101
|
+
db.exec('COMMIT')
|
|
102
|
+
return result
|
|
103
|
+
} catch (error) { if (db.isTransaction) db.exec('ROLLBACK'); throw error }
|
|
104
|
+
}
|
package/src/rpc.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { cp, mkdir, readFile, rename, rm, stat, writeFile, readdir } from 'node:
|
|
|
23
23
|
import { basename, dirname, join } from 'node:path'
|
|
24
24
|
import { archiveEntryFor, markDecision, recordResolved, reconstructOriginal } from './model.js'
|
|
25
25
|
import { clearRevertState, listArchives, pageArchives, readArchives, revertState, setRevertState, startReverts } from './archiveDb.js'
|
|
26
|
+
import { hasOfficial } from './reviewDb.js'
|
|
26
27
|
import { log } from './log.js'
|
|
27
28
|
import type { Registry } from './registry.js'
|
|
28
29
|
import { bucketOf, cwdOfSession, sessionOf } from './registry.js'
|
|
@@ -57,6 +58,7 @@ import { patchHasPerfConfig, patchInsertPerfConfig, patchRemovePerfConfig, perfC
|
|
|
57
58
|
import type { FileVersions } from './fileVersions.js'
|
|
58
59
|
import { settingsDocPath } from './settingsDocPath.js'
|
|
59
60
|
import { createTextPages } from './textPages.js'
|
|
61
|
+
import { reviewHandlers } from './reviewRpc.js'
|
|
60
62
|
|
|
61
63
|
/** Process-local cursor signer; reads must stay on the Host filesystem capability. */
|
|
62
64
|
const readTextPage = createTextPages()
|
|
@@ -239,17 +241,8 @@ async function afterManualSave(
|
|
|
239
241
|
): Promise<void> {
|
|
240
242
|
// 手动保存后目录树可能变化(新建/删除文件):父目录+祖先进失效,后台自愈。
|
|
241
243
|
invalidateIndex(ctx, sc.cwd, path)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
for (const rec of bucket.values()) {
|
|
245
|
-
if (rec.path === path && rec.superseded !== true) { rec.superseded = true; rec.at = new Date().toISOString(); changed = true }
|
|
246
|
-
}
|
|
247
|
-
if (changed) {
|
|
248
|
-
const done: DiffRecord[] = []
|
|
249
|
-
for (const rec of bucket.values()) if (rec.path === path && rec.superseded) done.push(rec)
|
|
250
|
-
if (done.length) await archiveRecords(ctx, sc.session, sc.cwd, bucket, done, '被手动编辑覆盖')
|
|
251
|
-
else await saveBucket(ctx, sc.cwd, bucket, sc.session, { upserts: [...bucket.values()].filter((rec) => rec.path === path), removed: [] })
|
|
252
|
-
}
|
|
244
|
+
// Editing disk is not a review decision. Keep all historical pending records;
|
|
245
|
+
// location checks may report conflict but must not silently accept or archive them.
|
|
253
246
|
}
|
|
254
247
|
|
|
255
248
|
/**
|
|
@@ -288,6 +281,10 @@ async function applyDecisions(
|
|
|
288
281
|
results.push({ callId: item.callId, ok: false, error: '记录不存在' })
|
|
289
282
|
continue
|
|
290
283
|
}
|
|
284
|
+
if (hasOfficial(cwd, record.path)) {
|
|
285
|
+
results.push({ callId: item.callId, ok: false, error: '该文件已有官方审查记录;旧工具记录禁止决策或回滚,请使用官方审查入口' })
|
|
286
|
+
continue
|
|
287
|
+
}
|
|
291
288
|
if (record.archived) {
|
|
292
289
|
results.push({ callId: item.callId, ok: false, error: '记录已归档,无法重复决策' })
|
|
293
290
|
continue
|
|
@@ -364,8 +361,8 @@ async function applyDecisions(
|
|
|
364
361
|
// #endregion
|
|
365
362
|
grouped.delete(index)
|
|
366
363
|
try {
|
|
367
|
-
if (outcome.ok
|
|
368
|
-
else if (outcome.error === '找不到该差异块' || outcome.error === '回滚失败:文件不存在'
|
|
364
|
+
if (outcome.ok) setRevertState(cwd, record.callId, scope, hunkIndex, 'applied')
|
|
365
|
+
else if (outcome.stale === true || outcome.error === '找不到该差异块' || outcome.error === '回滚失败:文件不存在'
|
|
369
366
|
|| outcome.error === '回滚失败:文件过大,无法安全定位') {
|
|
370
367
|
clearRevertState(cwd, record.callId, scope, hunkIndex)
|
|
371
368
|
}
|
|
@@ -375,11 +372,8 @@ async function applyDecisions(
|
|
|
375
372
|
continue
|
|
376
373
|
}
|
|
377
374
|
if (!outcome.ok) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
continue
|
|
381
|
-
}
|
|
382
|
-
revertedStale = true
|
|
375
|
+
results.push({ callId: item.callId, ok: false, error: outcome.error || '差异已偏移,未修改文件,保留待处理' })
|
|
376
|
+
continue
|
|
383
377
|
}
|
|
384
378
|
}
|
|
385
379
|
const wasResolved = recordResolved(record)
|
|
@@ -595,6 +589,7 @@ export function buildHandlers(
|
|
|
595
589
|
...((svnHandlers ?? {}) as RpcHandlerMap),
|
|
596
590
|
// edrv.dap.* 由 createDapRpc 提供(调试会话单例),这里并入。
|
|
597
591
|
...((dapHandlers ?? {}) as RpcHandlerMap),
|
|
592
|
+
...(reviewHandlers(ctx, registry) as RpcHandlerMap),
|
|
598
593
|
'edrv.list': async (args) => {
|
|
599
594
|
// #region debug log
|
|
600
595
|
const start = performance.now()
|
|
@@ -945,6 +940,7 @@ export function buildHandlers(
|
|
|
945
940
|
if (!fs) return { ok: false, error: '缺少 fs' }
|
|
946
941
|
const batch = args.batch
|
|
947
942
|
const bucket = await bucketOf(registry, ctx, sc.cwd)
|
|
943
|
+
if (hasOfficial(sc.cwd, args.path)) return { ok: false, error: '该文件已有官方审查记录,禁止使用旧版批次回滚覆盖当前文件' }
|
|
948
944
|
try {
|
|
949
945
|
let affected: DiffRecord[] = []
|
|
950
946
|
let beforeRec: DiffRecord | null = null
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** Official facts are immutable; review decisions never imply a disk version. @author ddj 2026年10月26号 */
|
|
2
|
+
export interface ChangedFile {
|
|
3
|
+
path: string
|
|
4
|
+
display: string
|
|
5
|
+
added: number
|
|
6
|
+
deleted: number
|
|
7
|
+
binary?: true
|
|
8
|
+
oversized?: true
|
|
9
|
+
}
|
|
10
|
+
export interface ReviewHunk {
|
|
11
|
+
oldStart: number
|
|
12
|
+
oldLines: number
|
|
13
|
+
newStart: number
|
|
14
|
+
newLines: number
|
|
15
|
+
lines: string[]
|
|
16
|
+
}
|
|
17
|
+
export type OfficialDiff = { kind: 'text'; path: string; display: string; before: boolean; after: boolean; hunks: ReviewHunk[]; coarse: boolean }
|
|
18
|
+
| { kind: 'binary' | 'oversized'; path: string; display: string }
|
|
19
|
+
export interface OfficialSummary {
|
|
20
|
+
turn: number
|
|
21
|
+
cwd: string
|
|
22
|
+
files: ChangedFile[]
|
|
23
|
+
total: number
|
|
24
|
+
added: number
|
|
25
|
+
deleted: number
|
|
26
|
+
snapshot?: { before: string; after: string }
|
|
27
|
+
}
|
|
28
|
+
export interface ReviewSnapshot {
|
|
29
|
+
sourceKind: 'official'
|
|
30
|
+
sessionId: string
|
|
31
|
+
turn: number
|
|
32
|
+
seq: number
|
|
33
|
+
capturedAt: string
|
|
34
|
+
summary: OfficialSummary
|
|
35
|
+
files: { identity: string; diff: OfficialDiff }[]
|
|
36
|
+
}
|
|
37
|
+
export type ReviewDecision = 'pending' | 'accepted' | 'rejected'
|
|
38
|
+
export type ReviewFileDecision = ReviewDecision | 'mixed'
|
|
39
|
+
export interface ReviewItem {
|
|
40
|
+
id: string
|
|
41
|
+
sessionId: string
|
|
42
|
+
turn: number
|
|
43
|
+
seq: number
|
|
44
|
+
path: string
|
|
45
|
+
currentPath: string
|
|
46
|
+
display: string
|
|
47
|
+
added: number
|
|
48
|
+
deleted: number
|
|
49
|
+
kind: OfficialDiff['kind']
|
|
50
|
+
decision: ReviewFileDecision
|
|
51
|
+
revision: number
|
|
52
|
+
current: boolean
|
|
53
|
+
}
|
|
54
|
+
export interface ReviewDetail {
|
|
55
|
+
item: ReviewItem
|
|
56
|
+
diff: OfficialDiff
|
|
57
|
+
capturedAt: string
|
|
58
|
+
blocks: import('./reviewRegions.js').ReviewBlockState[]
|
|
59
|
+
capability: { keep: boolean; undo: boolean; reason: string }
|
|
60
|
+
}
|
|
61
|
+
export interface CaptureGap {
|
|
62
|
+
sessionId: string
|
|
63
|
+
turn: number
|
|
64
|
+
seq: number
|
|
65
|
+
error: string
|
|
66
|
+
}
|
|
67
|
+
export interface ReviewFile {
|
|
68
|
+
sourceKind: 'official'
|
|
69
|
+
path: string
|
|
70
|
+
label: string
|
|
71
|
+
pending: number
|
|
72
|
+
}
|
|
73
|
+
export interface ReviewOverview {
|
|
74
|
+
files: ReviewFile[]
|
|
75
|
+
/** Every official identity, including decided files: never revive legacy actions for them. */
|
|
76
|
+
coveredPaths: string[]
|
|
77
|
+
}
|
|
78
|
+
export interface ReviewPage {
|
|
79
|
+
overview?: ReviewOverview
|
|
80
|
+
items: ReviewItem[]
|
|
81
|
+
pending: number
|
|
82
|
+
nextCursor: number | null
|
|
83
|
+
gaps: CaptureGap[]
|
|
84
|
+
}
|
|
85
|
+
export interface ReviewRequest { sessionId: string }
|
|
86
|
+
export interface DecideReview extends ReviewRequest {
|
|
87
|
+
id: string
|
|
88
|
+
seq: number
|
|
89
|
+
revision: number
|
|
90
|
+
operationId: string
|
|
91
|
+
scope?: 'file' | 'hunk'
|
|
92
|
+
blockId?: string
|
|
93
|
+
decision: 'accepted' | 'rejected'
|
|
94
|
+
}
|
|
95
|
+
export type ReviewResult<T> = ({ ok: true } & T) | { ok: false; error: string }
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** Official patch parsing shared by review decisions and editor display. @author ddj 2026年10月27号 */
|
|
2
|
+
import type { OfficialDiff, ReviewDecision } from './review.js'
|
|
3
|
+
|
|
4
|
+
export interface ReviewBlock {
|
|
5
|
+
id: string
|
|
6
|
+
hunkIndex: number
|
|
7
|
+
oldStart: number
|
|
8
|
+
newStart: number
|
|
9
|
+
oldLines: string[]
|
|
10
|
+
newLines: string[]
|
|
11
|
+
contextBefore: string[]
|
|
12
|
+
contextAfter: string[]
|
|
13
|
+
}
|
|
14
|
+
export interface ReviewBlockState { id: string; decision: ReviewDecision }
|
|
15
|
+
export interface ReviewRegion extends ReviewBlock {
|
|
16
|
+
sourceKind: 'official'
|
|
17
|
+
recordId: string
|
|
18
|
+
idx: number
|
|
19
|
+
status: ReviewDecision
|
|
20
|
+
stale: boolean
|
|
21
|
+
start?: number
|
|
22
|
+
end?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @public @author ddj 2026年10月27号 @param diff Immutable official patch. @returns Contiguous change runs with stable source-coordinate identities. */
|
|
26
|
+
export function reviewBlocks(diff: OfficialDiff): ReviewBlock[] {
|
|
27
|
+
if (diff.kind !== 'text') return []
|
|
28
|
+
const blocks: ReviewBlock[] = []
|
|
29
|
+
for (const [hunkIndex, hunk] of diff.hunks.entries()) {
|
|
30
|
+
let oldLine = hunk.oldStart, newLine = hunk.newStart
|
|
31
|
+
let before: string[] = []
|
|
32
|
+
let current: ReviewBlock | undefined
|
|
33
|
+
for (const line of hunk.lines) {
|
|
34
|
+
const marker = line[0], text = line.slice(1)
|
|
35
|
+
if (marker === ' ') {
|
|
36
|
+
if (current) { current.contextAfter.push(text); current = undefined }
|
|
37
|
+
else if (blocks.at(-1)?.hunkIndex === hunkIndex) blocks.at(-1)!.contextAfter.push(text)
|
|
38
|
+
before.push(text); oldLine++; newLine++
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
if (marker !== '+' && marker !== '-') continue
|
|
42
|
+
if (!current) {
|
|
43
|
+
current = { id: `${hunkIndex}:${oldLine}:${newLine}`, hunkIndex, oldStart: oldLine, newStart: newLine,
|
|
44
|
+
oldLines: [], newLines: [], contextBefore: before.slice(-3), contextAfter: [] }
|
|
45
|
+
blocks.push(current); before = []
|
|
46
|
+
}
|
|
47
|
+
if (marker === '-') { current.oldLines.push(text); oldLine++ }
|
|
48
|
+
else { current.newLines.push(text); newLine++ }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return blocks
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @public @author ddj 2026年10月27号 @param diff Source patch. @param recordId Official record, not a tool call. @param states Persisted per-block decisions. @param content Current editor text; null has no current-file location. @returns Regions only located by unique full contextual matches, never by guessed history line numbers. */
|
|
55
|
+
export function reviewRegions(diff: OfficialDiff, recordId: string, states: ReviewBlockState[], content: string | null): ReviewRegion[] {
|
|
56
|
+
const lines = content === null ? null : content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').split('\n')
|
|
57
|
+
return reviewBlocks(diff).map((block, idx) => {
|
|
58
|
+
const status = states.find((state) => state.id === block.id)?.decision ?? 'pending'
|
|
59
|
+
const region: ReviewRegion = { ...block, sourceKind: 'official', recordId, idx, status, stale: true }
|
|
60
|
+
if (!lines) return region
|
|
61
|
+
const pattern = [...block.contextBefore, ...block.newLines, ...block.contextAfter]
|
|
62
|
+
if (!pattern.length) return region
|
|
63
|
+
const matches: number[] = []
|
|
64
|
+
for (let offset = 0; offset + pattern.length <= lines.length; offset++) {
|
|
65
|
+
if (pattern.every((text, index) => lines[offset + index] === text)) matches.push(offset)
|
|
66
|
+
if (matches.length > 1) break
|
|
67
|
+
}
|
|
68
|
+
if (matches.length !== 1) return region
|
|
69
|
+
region.start = matches[0] + block.contextBefore.length + 1
|
|
70
|
+
region.end = region.start + block.newLines.length
|
|
71
|
+
region.stale = false
|
|
72
|
+
return region
|
|
73
|
+
})
|
|
74
|
+
}
|
package/src/shared/rpc.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { DAP_ACTIONS } from './dap.js'
|
|
|
25
25
|
import { appUrl } from './appUrl.js'
|
|
26
26
|
import type { LoggerLevel } from './logger.js'
|
|
27
27
|
import type { SvnViewRequest, SvnViewPayload } from './svnExplorer.js'
|
|
28
|
+
import type { DecideReview, ReviewDetail, ReviewPage, ReviewRequest } from './review.js'
|
|
28
29
|
|
|
29
30
|
/** webServer 精确路由。 */
|
|
30
31
|
export const RPC_PATH = '/edrv/rpc'
|
|
@@ -368,6 +369,9 @@ export interface UiStatePatchInput {
|
|
|
368
369
|
|
|
369
370
|
/** 每个方法的请求参数(sessionId 为公共可选字段)。 */
|
|
370
371
|
export interface RpcRequestMap {
|
|
372
|
+
'edrv.review.list': ReviewRequest & { cursor?: number; history?: boolean; path?: string }
|
|
373
|
+
'edrv.review.detail': ReviewRequest & { id: string }
|
|
374
|
+
'edrv.review.decide': DecideReview
|
|
371
375
|
'edrv.list': { sessionId?: string; callIds?: string[]; skipStale?: boolean }
|
|
372
376
|
'edrv.accept': { sessionId?: string; callId: string; scope?: RpcScope; hunkIndex?: number }
|
|
373
377
|
'edrv.reject': { sessionId?: string; callId: string; scope?: RpcScope; hunkIndex?: number }
|
|
@@ -537,6 +541,9 @@ export type RpcMethod = keyof RpcRequestMap
|
|
|
537
541
|
|
|
538
542
|
/** 每个方法成功(ok:true)时的附加载荷。 */
|
|
539
543
|
export interface RpcOkMap {
|
|
544
|
+
'edrv.review.list': { page: ReviewPage }
|
|
545
|
+
'edrv.review.detail': { detail: ReviewDetail }
|
|
546
|
+
'edrv.review.decide': { revision: number }
|
|
540
547
|
'edrv.list': { records: RecordView[] }
|
|
541
548
|
'edrv.accept': { record: RecordView }
|
|
542
549
|
'edrv.reject': { record: RecordView }
|
package/src/store.ts
CHANGED
|
@@ -221,10 +221,10 @@ export async function readForCache(ctx: Ctx, session: Session, cache: Map<string
|
|
|
221
221
|
*/
|
|
222
222
|
export async function recordIsStale(ctx: Ctx, session: Session, cache: Map<string, ReadState>, rec: DiffRecord): Promise<boolean> {
|
|
223
223
|
if (rec.superseded === true) return true
|
|
224
|
-
if (!Array.isArray(rec.hunks) || !rec.hunks.length) return
|
|
224
|
+
if (!Array.isArray(rec.hunks) || !rec.hunks.length) { rec.conflict = true; return false }
|
|
225
225
|
const state = await readForCache(ctx, session, cache, rec.path)
|
|
226
226
|
if (state.kind === 'unavailable') return false
|
|
227
|
-
if (state.kind === 'missing') return
|
|
227
|
+
if (state.kind === 'missing') { rec.conflict = true; return false }
|
|
228
228
|
if (rec.create === true) return false
|
|
229
229
|
const pending = rec.hunks.map((_, idx) => ({ idx, hunk: preciseHunk(rec, idx), status: rec.decisions.perHunk[idx] ?? rec.decisions.call }))
|
|
230
230
|
.filter((item) => item.status === 'pending' && item.hunk && !isNoopHunk(item.hunk))
|