dsh-vscode-mode 0.21.3 → 0.22.0
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 +51 -3
- package/lib/client.js +254 -174
- package/lib/client.js.map +2 -2
- package/lib/index.js +2419 -32
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/addToConversation.ts +79 -32
- package/src/client/sidebar/panels/SvnPanel.ts +38 -30
- package/src/client/state/diffSummary.ts +43 -4
- package/src/client/ui/DiffLauncher.ts +1 -1
- package/src/client/ui/EditorView.ts +10 -2
- package/src/client/ui/OfficialDiffBar.tsx +1 -12
- package/src/client/ui/OfficialReview.ts +13 -3
- package/src/client/ui/SidecarDiffBar.tsx +0 -22
- package/src/index.ts +9 -0
- package/src/missingReview.ts +36 -14
- package/src/pending/capture.ts +136 -0
- package/src/pending/decide.ts +213 -0
- package/src/pending/hunks.ts +241 -0
- package/src/pending/kernel.ts +233 -0
- package/src/pending/persist.ts +324 -0
- package/src/pending/store.ts +467 -0
- package/src/pending/vcs.ts +444 -0
- package/src/pendingCompat.ts +189 -0
- package/src/pendingRpc.ts +128 -0
- package/src/reviewDb.ts +3 -0
- package/src/reviewRpc.ts +52 -1
- package/src/rpc.ts +29 -2
- package/src/shared/pendingReview.ts +105 -0
- package/src/shared/rpc.ts +18 -0
- package/src/store.ts +19 -0
package/src/missingReview.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createHash, randomUUID } from 'node:crypto'
|
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
4
|
import type { DiffRecord, MissingReview } from './shared/types.js'
|
|
5
5
|
import type { Ctx, Session } from './store.js'
|
|
6
|
-
import { archiveRecords, policyOf, READ_CAP, resolveTargetIn } from './store.js'
|
|
6
|
+
import { archiveRecords, policyOf, READ_CAP, resolveTargetIn, targetPathOf } from './store.js'
|
|
7
7
|
import { hasOfficial } from './reviewDb.js'
|
|
8
8
|
import { recordResolved } from './model.js'
|
|
9
9
|
|
|
@@ -31,13 +31,20 @@ function revisionOf(records: DiffRecord[]): string {
|
|
|
31
31
|
return createHash('sha256').update(JSON.stringify(evidence)).digest('hex')
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** @private @author ddj 2026年10月30号 @param ctx Host capability. @param target Resolve 结果:宿主路径字符串或不透明 FsTarget。 @returns 平台一致的物理身份;无法还原时返回空串。 */
|
|
35
|
+
function fileKeyOf(ctx: Ctx, target: unknown): string {
|
|
36
|
+
const path = targetPathOf(ctx, target)
|
|
37
|
+
return path ? fileKey(path) : ''
|
|
38
|
+
}
|
|
39
|
+
|
|
34
40
|
/** @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
41
|
async function fileRecords(ctx: Ctx, cwd: string, bucket: Map<string, DiffRecord>, target: string): Promise<DiffRecord[]> {
|
|
36
42
|
const matches: DiffRecord[] = []
|
|
37
|
-
|
|
43
|
+
// 解析结果可能是不透明对象,比较前必须先统一还原成宿主路径。
|
|
44
|
+
const identity = fileKeyOf(ctx, target)
|
|
38
45
|
for (const record of bucket.values()) {
|
|
39
46
|
if (record.superseded) continue
|
|
40
|
-
if (
|
|
47
|
+
if (fileKeyOf(ctx, await resolveTargetIn(ctx, cwd, record.path)) === identity) matches.push(record)
|
|
41
48
|
}
|
|
42
49
|
return matches.sort((left, right) => left.at.localeCompare(right.at))
|
|
43
50
|
}
|
|
@@ -57,9 +64,12 @@ export async function fileAbsent(ctx: Ctx, cwd: string, path: string): Promise<b
|
|
|
57
64
|
/** @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
65
|
export async function missingReview(ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>, path: string): Promise<MissingReview> {
|
|
59
66
|
const target = await resolveTargetIn(ctx, cwd, path)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
// 解析结果可能是不透明 FsTarget,凡按路径字符串使用的分支都必须先还原。
|
|
68
|
+
const abs = targetPathOf(ctx, target)
|
|
69
|
+
if (!abs) throw new Error('无法解析目标文件路径')
|
|
70
|
+
if (hasOfficial(cwd, path) || hasOfficial(cwd, abs)) throw new Error('该文件已有官方审查记录,请使用官方审查入口')
|
|
71
|
+
if (!await fileAbsent(ctx, cwd, abs)) throw new Error('文件已重新出现,请刷新')
|
|
72
|
+
const records = await fileRecords(ctx, cwd, bucket, abs)
|
|
63
73
|
const latest = records.at(-1)
|
|
64
74
|
if (!records.some((record) => !record.archived && !recordResolved(record))) throw new Error('没有待审历史记录')
|
|
65
75
|
if (!latest || typeof latest.after !== 'string') throw new Error('缺少可靠的完整历史快照,无法展示或恢复;不会用局部差异拼接文件')
|
|
@@ -88,33 +98,45 @@ async function applyDisk(ctx: Ctx, item: FrozenReview, target: string, undo: boo
|
|
|
88
98
|
if (await fs.readText(target) !== item.view.content) throw new Error('恢复内容校验失败,未结清记录')
|
|
89
99
|
}
|
|
90
100
|
|
|
101
|
+
/**
|
|
102
|
+
* @author ddj 2026年10月29号
|
|
103
|
+
* @description: 取删除预览对应的文件路径,供服务端判定该路径是否已由待审投影接管。
|
|
104
|
+
* @param token 冻结预览 token
|
|
105
|
+
* @returns 文件路径,token 无效时为 undefined
|
|
106
|
+
*/
|
|
107
|
+
export function missingPath(token: string): string | undefined {
|
|
108
|
+
return frozen.get(token)?.view.path
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
/** @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
112
|
export async function missingDecide(ctx: Ctx, session: Session, cwd: string, bucket: Map<string, DiffRecord>, token: string, operationId: string, undo: boolean): Promise<string[]> {
|
|
93
113
|
const item = frozen.get(token)
|
|
94
114
|
if (!item || item.expires < Date.now() || item.cwd !== cwd || item.session.id !== session.id) throw new Error('删除预览已过期,请刷新后重试')
|
|
95
115
|
if (!operationId || operationId.length > 128) throw new Error('缺少有效操作 ID')
|
|
96
116
|
const target = await resolveTargetIn(ctx, cwd, item.view.path)
|
|
97
|
-
const
|
|
117
|
+
const abs = targetPathOf(ctx, target)
|
|
118
|
+
if (!abs) throw new Error('无法解析目标文件路径')
|
|
119
|
+
const key = JSON.stringify([cwd, fileKey(abs)])
|
|
98
120
|
if (locks.has(key)) throw new Error('该文件正在处理,请稍后重试')
|
|
99
121
|
locks.add(key)
|
|
100
122
|
item.session = session
|
|
101
|
-
try { return await commitMissing(ctx, item, bucket, target, operationId, undo) }
|
|
123
|
+
try { return await commitMissing(ctx, item, bucket, target, abs, operationId, undo) }
|
|
102
124
|
finally { locks.delete(key) }
|
|
103
125
|
}
|
|
104
126
|
|
|
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[]> {
|
|
127
|
+
/** @private @author ddj 2026年10月28号 @param ctx Host. @param item Frozen preview. @param bucket Live records. @param target Resolved target. @param abs Host path of the target. @param operationId Retry identity. @param undo Restore action. @returns Archived pending call IDs; disk success survives bookkeeping failure in the same Host lifetime. */
|
|
128
|
+
async function commitMissing(ctx: Ctx, item: FrozenReview, bucket: Map<string, DiffRecord>, target: string, abs: string, operationId: string, undo: boolean): Promise<string[]> {
|
|
107
129
|
const previous = item.operation
|
|
108
130
|
if (previous && (previous.id !== operationId || previous.undo !== undo)) throw new Error('已有不同操作,请刷新后重试')
|
|
109
131
|
if (previous?.done) return item.view.callIds
|
|
110
|
-
if (hasOfficial(item.cwd,
|
|
111
|
-
const records = await fileRecords(ctx, item.cwd, bucket,
|
|
132
|
+
if (hasOfficial(item.cwd, abs) || hasOfficial(item.cwd, item.view.path)) throw new Error('该文件已有官方审查记录,请刷新')
|
|
133
|
+
const records = await fileRecords(ctx, item.cwd, bucket, abs)
|
|
112
134
|
if (revisionOf(records) !== item.revision) throw new Error('历史记录已变化,请刷新删除预览')
|
|
113
135
|
item.operation ??= { id: operationId, undo, restored: false, done: false }
|
|
114
|
-
await applyDisk(ctx, item,
|
|
136
|
+
await applyDisk(ctx, item, abs, undo)
|
|
115
137
|
|
|
116
138
|
// Recheck after awaited IO: capture/another review must not silently join this decision.
|
|
117
|
-
const current = await fileRecords(ctx, item.cwd, bucket,
|
|
139
|
+
const current = await fileRecords(ctx, item.cwd, bucket, abs)
|
|
118
140
|
if (revisionOf(current) !== item.revision) throw new Error('历史记录已变化,未结清记录,请刷新')
|
|
119
141
|
const pending = current.filter((record) => item.view.callIds.includes(record.callId))
|
|
120
142
|
const decisions = pending.map((record) => record.decisions)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 待审差异采集:环绕一次文件写入读取真实前后内容,只有内容确实变化才落库。
|
|
3
|
+
* @author ddj 2026年10月29号
|
|
4
|
+
*/
|
|
5
|
+
import { cwdOf } from '../registry.js'
|
|
6
|
+
import { resolveTarget } from '../store.js'
|
|
7
|
+
import type { Ctx } from '../store.js'
|
|
8
|
+
import { log } from '../log.js'
|
|
9
|
+
|
|
10
|
+
/** 可被采信为待审来源的写文件工具。 */
|
|
11
|
+
const WRITE_TOOLS = ['edit', 'write', 'str_replace_editor']
|
|
12
|
+
/** 单文件文本上限:超过即标注 oversized,不静默截断。 */
|
|
13
|
+
const MAX_TEXT = 1024 * 1024
|
|
14
|
+
|
|
15
|
+
/** 一次已确认的写入。 */
|
|
16
|
+
export interface PendingWrite {
|
|
17
|
+
cwd: string
|
|
18
|
+
sessionId: string
|
|
19
|
+
path: string
|
|
20
|
+
identity: string
|
|
21
|
+
before: string | null
|
|
22
|
+
after: string | null
|
|
23
|
+
now: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 采集器。 */
|
|
27
|
+
export interface PendingCapture {
|
|
28
|
+
/** 释放监听并丢弃暂存。 */
|
|
29
|
+
dispose(): void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @author ddj 2026年10月29号
|
|
34
|
+
* @description: 安装写文件采集;读写失败只影响待审来源,绝不影响用户工具本身。
|
|
35
|
+
* @param ctx Host 生命周期
|
|
36
|
+
* @param sink 已确认写入的接收方
|
|
37
|
+
* @returns 可释放的采集器
|
|
38
|
+
*/
|
|
39
|
+
export function installPendingCapture(ctx: Ctx, sink: (write: PendingWrite) => void): PendingCapture {
|
|
40
|
+
const busy = new Set<string>()
|
|
41
|
+
let stopped = false
|
|
42
|
+
ctx.on('tools/execute', aroundTool)
|
|
43
|
+
return { dispose }
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @author ddj 2026年10月29号
|
|
47
|
+
* @description: 写工具执行前后各读一次,内容变化才上报。
|
|
48
|
+
* @param exec 工具分发上下文
|
|
49
|
+
* @param next 原始分发
|
|
50
|
+
* @returns 原始工具结果,永不因采集失败而改变
|
|
51
|
+
*/
|
|
52
|
+
async function aroundTool(exec: any, next: () => Promise<any>): Promise<any> {
|
|
53
|
+
const session = exec.agent?.session
|
|
54
|
+
const path = exec.arguments?.file_path ?? exec.arguments?.path
|
|
55
|
+
if (stopped || !session?.id || typeof path !== 'string' || !WRITE_TOOLS.includes(exec.name)) return next()
|
|
56
|
+
const cwd = cwdOf(session)
|
|
57
|
+
if (!cwd) return next()
|
|
58
|
+
let before: string | null = null
|
|
59
|
+
let identity: string | null = null
|
|
60
|
+
try {
|
|
61
|
+
const fs = ctx.get('fs')
|
|
62
|
+
const target = await resolveTarget(ctx, session, path)
|
|
63
|
+
const key = fs.processPath(target)
|
|
64
|
+
if (typeof key !== 'string' || !key) return next()
|
|
65
|
+
if (busy.has(key)) return next()
|
|
66
|
+
before = await readText(fs, target)
|
|
67
|
+
if (before !== null && before.length > MAX_TEXT) return next()
|
|
68
|
+
identity = key
|
|
69
|
+
busy.add(key)
|
|
70
|
+
} catch (error) {
|
|
71
|
+
log.debug('待审采集跳过:' + String(error))
|
|
72
|
+
return next()
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return await next()
|
|
76
|
+
} finally {
|
|
77
|
+
if (identity !== null) {
|
|
78
|
+
const key: string = identity
|
|
79
|
+
try { report(String(cwd), String(session.id), String(path), key, before, exec) } catch (error) {
|
|
80
|
+
log.debug('待审上报失败:' + String(error))
|
|
81
|
+
}
|
|
82
|
+
busy.delete(key)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @author ddj 2026年10月29号
|
|
89
|
+
* @description: 写后读回并比较,内容一致视为无净变化不产生条目。
|
|
90
|
+
* @param cwd 工作区
|
|
91
|
+
* @param sessionId 会话
|
|
92
|
+
* @param path 展示路径
|
|
93
|
+
* @param identity 目标身份
|
|
94
|
+
* @param before 写前内容
|
|
95
|
+
* @param exec 工具分发上下文
|
|
96
|
+
* @returns 无
|
|
97
|
+
*/
|
|
98
|
+
function report(
|
|
99
|
+
cwd: string, sessionId: string, path: string, identity: string, before: string | null, exec: any,
|
|
100
|
+
): void {
|
|
101
|
+
const fs = ctx.get('fs')
|
|
102
|
+
void resolveTarget(ctx, exec.agent.session, path).then(async (target: any) => {
|
|
103
|
+
const after = await readText(fs, target)
|
|
104
|
+
if (after !== null && after.length > MAX_TEXT) return
|
|
105
|
+
if (before === after) return
|
|
106
|
+
sink({ cwd, sessionId, path, identity, before, after, now: Date.now() })
|
|
107
|
+
}).catch((error: unknown) => log.debug('待审读回失败:' + String(error)))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @author ddj 2026年10月29号
|
|
112
|
+
* @description: 释放监听。
|
|
113
|
+
* @returns 无
|
|
114
|
+
*/
|
|
115
|
+
function dispose(): void {
|
|
116
|
+
stopped = true
|
|
117
|
+
busy.clear()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* @author ddj 2026年10月29号
|
|
123
|
+
* @description: 读取全文;缺失或不可读按"无内容"处理,交由上层决定语义。
|
|
124
|
+
* @param fs 文件系统
|
|
125
|
+
* @param target 目标
|
|
126
|
+
* @returns 文本或 null
|
|
127
|
+
*/
|
|
128
|
+
export async function readText(fs: any, target: any): Promise<string | null> {
|
|
129
|
+
try {
|
|
130
|
+
return String(await fs.readText(target))
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const code = (error as { code?: string }).code
|
|
133
|
+
if (code === 'ENOENT' || code === 'FS_NOT_FOUND' || code === 'FS_TOO_LARGE') return null
|
|
134
|
+
throw error
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 待审判定内核:Keep 只推进投影,Revert 才写磁盘;写盘由注入的 writer 完成以便测试零写断言。
|
|
3
|
+
* @author ddj 2026年10月29号
|
|
4
|
+
*/
|
|
5
|
+
import type { PendingDecide } from '../shared/pendingReview.js'
|
|
6
|
+
import type { ReviewBlock, ReviewBlockState } from '../shared/reviewRegions.js'
|
|
7
|
+
import { reviewBlocks } from '../shared/reviewRegions.js'
|
|
8
|
+
import type { OfficialDiff } from '../shared/review.js'
|
|
9
|
+
import { keepBlock, revertBlock, textDiff } from './hunks.js'
|
|
10
|
+
import type { PendingEntry } from './kernel.js'
|
|
11
|
+
import { applyUpdate, isSettled } from './kernel.js'
|
|
12
|
+
import type { PendingFlags } from './persist.js'
|
|
13
|
+
|
|
14
|
+
/** 写盘结果:`content` 是读回校验通过后的最终内容,null 表示删除。 */
|
|
15
|
+
export type WriteResult = { ok: true; content: string | null } | { ok: false; reason: string }
|
|
16
|
+
|
|
17
|
+
/** 写盘能力,由 host 侧实现;纯判定层只调用它一次。 */
|
|
18
|
+
export type WriteBack = (content: string | null) => Promise<WriteResult>
|
|
19
|
+
|
|
20
|
+
/** 一次判定的输入。 */
|
|
21
|
+
export interface DecideInput {
|
|
22
|
+
request: PendingDecide
|
|
23
|
+
entry: PendingEntry
|
|
24
|
+
flags: PendingFlags
|
|
25
|
+
blocks: ReviewBlockState[]
|
|
26
|
+
now: number
|
|
27
|
+
write: WriteBack
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 一次判定的输出。 */
|
|
31
|
+
export interface DecideOutcome {
|
|
32
|
+
ok: boolean
|
|
33
|
+
reason: string
|
|
34
|
+
entry: PendingEntry
|
|
35
|
+
flags: PendingFlags
|
|
36
|
+
blocks: ReviewBlockState[]
|
|
37
|
+
/** 是否真正写过磁盘(Keep 恒为 false)。 */
|
|
38
|
+
wrote: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @author ddj 2026年10月29号
|
|
43
|
+
* @description: 执行一次 Keep/Revert;失败时返回原状态,调用方不得落库。
|
|
44
|
+
* @param input 判定输入
|
|
45
|
+
* @returns 判定结果
|
|
46
|
+
*/
|
|
47
|
+
export async function decide(input: DecideInput): Promise<DecideOutcome> {
|
|
48
|
+
const failed = (reason: string): DecideOutcome => ({
|
|
49
|
+
ok: false, reason, entry: input.entry, flags: input.flags, blocks: input.blocks, wrote: false,
|
|
50
|
+
})
|
|
51
|
+
if (input.request.revision !== input.flags.revision) return failed('revision-mismatch')
|
|
52
|
+
if (input.flags.oversized) return failed('oversized')
|
|
53
|
+
if (input.entry.oldText === input.entry.newText) return failed('settled')
|
|
54
|
+
|
|
55
|
+
const diff = textDiff(input.entry.path, input.entry.path, input.entry.oldText, input.entry.newText)
|
|
56
|
+
const targets = pickBlocks(reviewBlocks(diff), input)
|
|
57
|
+
if (targets === null) return failed('unknown-block')
|
|
58
|
+
if (input.request.scope !== 'file' && input.request.scope !== 'all' && targets.length === 0) {
|
|
59
|
+
return failed('no-block')
|
|
60
|
+
}
|
|
61
|
+
const isKeep = input.request.decision === 'accepted'
|
|
62
|
+
const whole = input.request.scope === 'file' || input.request.scope === 'all'
|
|
63
|
+
return isKeep ? keep(input, targets, whole) : revert(input, targets, whole)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @author ddj 2026年10月29号
|
|
68
|
+
* @description: Keep:只把目标块或整文件的新内容并入基线,绝不写目标文件。
|
|
69
|
+
* @param input 判定输入
|
|
70
|
+
* @param targets 目标块(整文件时为空)
|
|
71
|
+
* @param whole 是否整文件
|
|
72
|
+
* @returns 判定结果
|
|
73
|
+
*/
|
|
74
|
+
function keep(input: DecideInput, targets: ReviewBlock[], whole: boolean): DecideOutcome {
|
|
75
|
+
if (whole) {
|
|
76
|
+
const entry = applyUpdate(input.entry, { oldText: input.entry.newText }, input.now)
|
|
77
|
+
return {
|
|
78
|
+
ok: true, reason: '', entry, wrote: false,
|
|
79
|
+
flags: { ...input.flags, revision: input.flags.revision + 1 },
|
|
80
|
+
blocks: markAll(input.blocks, targets, 'accepted'),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
let oldText = input.entry.oldText
|
|
84
|
+
for (const block of targets) {
|
|
85
|
+
const next = keepBlock(oldText, block)
|
|
86
|
+
if (next === null) {
|
|
87
|
+
return { ok: false, reason: 'ambiguous-block', entry: input.entry, flags: input.flags, blocks: input.blocks, wrote: false }
|
|
88
|
+
}
|
|
89
|
+
oldText = next
|
|
90
|
+
}
|
|
91
|
+
const entry = applyUpdate(input.entry, { oldText }, input.now)
|
|
92
|
+
return {
|
|
93
|
+
ok: true, reason: '', entry, wrote: false,
|
|
94
|
+
flags: { ...input.flags, revision: input.flags.revision + 1 },
|
|
95
|
+
blocks: markAll(input.blocks, targets, 'accepted'),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @author ddj 2026年10月29号
|
|
101
|
+
* @description: Revert:合成回退内容后交给 writer 写盘并读回,写失败则状态不变。
|
|
102
|
+
* @param input 判定输入
|
|
103
|
+
* @param targets 目标块(整文件时为空)
|
|
104
|
+
* @param whole 是否整文件
|
|
105
|
+
* @returns 判定结果
|
|
106
|
+
*/
|
|
107
|
+
async function revert(input: DecideInput, targets: ReviewBlock[], whole: boolean): Promise<DecideOutcome> {
|
|
108
|
+
const failed = (reason: string): DecideOutcome => ({
|
|
109
|
+
ok: false, reason, entry: input.entry, flags: input.flags, blocks: input.blocks, wrote: false,
|
|
110
|
+
})
|
|
111
|
+
const next = whole ? wholeRevert(input.entry) : blockRevert(input.entry, targets)
|
|
112
|
+
if (next === undefined) return failed('ambiguous-block')
|
|
113
|
+
|
|
114
|
+
const written = await input.write(next)
|
|
115
|
+
if (!written.ok) return failed(written.reason)
|
|
116
|
+
|
|
117
|
+
const entry = applyUpdate(input.entry, { newText: written.content ?? '' }, input.now)
|
|
118
|
+
return {
|
|
119
|
+
ok: true, reason: '', entry, wrote: true,
|
|
120
|
+
flags: { ...input.flags, revision: input.flags.revision + 1, missing: written.content === null },
|
|
121
|
+
blocks: markAll(input.blocks, targets, 'rejected'),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @author ddj 2026年10月29号
|
|
127
|
+
* @description: 整文件回退目标:基线为"文件不存在"时删除,否则写回基线全文。
|
|
128
|
+
* @param entry 待审条目
|
|
129
|
+
* @returns 目标内容,null 表示删除
|
|
130
|
+
*/
|
|
131
|
+
function wholeRevert(entry: PendingEntry): string | null {
|
|
132
|
+
return entry.earlierVersion === 'none' ? null : entry.oldText
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* @author ddj 2026年10月29号
|
|
137
|
+
* @description: 块级回退:在最新内容里依次换回目标块,任一不可定位即整体放弃。
|
|
138
|
+
* @param entry 待审条目
|
|
139
|
+
* @param targets 目标块
|
|
140
|
+
* @returns 新内容或 undefined
|
|
141
|
+
*/
|
|
142
|
+
function blockRevert(entry: PendingEntry, targets: ReviewBlock[]): string | null | undefined {
|
|
143
|
+
let newText = entry.newText
|
|
144
|
+
for (const block of targets) {
|
|
145
|
+
const next = revertBlock(newText, block)
|
|
146
|
+
if (next === null) return undefined
|
|
147
|
+
newText = next
|
|
148
|
+
}
|
|
149
|
+
return newText
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @author ddj 2026年10月29号
|
|
154
|
+
* @description: 按请求粒度挑出目标块;块不存在时返回 null 以区分"没有块"。
|
|
155
|
+
* @param blocks 当前差异块
|
|
156
|
+
* @param input 判定输入
|
|
157
|
+
* @returns 目标块,未知块 ID 时为 null
|
|
158
|
+
*/
|
|
159
|
+
function pickBlocks(blocks: ReviewBlock[], input: DecideInput): ReviewBlock[] | null {
|
|
160
|
+
const scope = input.request.scope
|
|
161
|
+
if (scope === 'file' || scope === 'all') return []
|
|
162
|
+
if (scope === 'block') {
|
|
163
|
+
const block = blocks.find((item) => item.id === input.request.blockId)
|
|
164
|
+
return block ? [block] : null
|
|
165
|
+
}
|
|
166
|
+
const start = input.request.startLine ?? 0
|
|
167
|
+
const end = input.request.endLine ?? start
|
|
168
|
+
return blocks.filter((block) => block.newStart <= end && block.newStart + Math.max(block.newLines.length, 1) >= start)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* @author ddj 2026年10月29号
|
|
173
|
+
* @description: 把目标块标记为已决定;整文件时把全部块一起标记。
|
|
174
|
+
* @param states 已存块决定
|
|
175
|
+
* @param targets 目标块
|
|
176
|
+
* @param decision 决定
|
|
177
|
+
* @returns 新的块决定列表
|
|
178
|
+
*/
|
|
179
|
+
function markAll(states: ReviewBlockState[], targets: ReviewBlock[], decision: 'accepted' | 'rejected'): ReviewBlockState[] {
|
|
180
|
+
const next = states.slice()
|
|
181
|
+
for (const block of targets) {
|
|
182
|
+
const at = next.findIndex((state) => state.id === block.id)
|
|
183
|
+
if (at >= 0) next[at] = { id: block.id, decision }
|
|
184
|
+
else next.push({ id: block.id, decision })
|
|
185
|
+
}
|
|
186
|
+
return next
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @author ddj 2026年10月29号
|
|
191
|
+
* @description: 能力口径:Keep 恒可;Revert 受超大/缺失/已解决限制,拒绝原因要能展示。
|
|
192
|
+
* @param entry 待审条目
|
|
193
|
+
* @param flags 持久标记
|
|
194
|
+
* @returns 能力与原因
|
|
195
|
+
*/
|
|
196
|
+
export function capabilityOf(entry: PendingEntry, flags: PendingFlags): { keep: boolean; revert: boolean; reason: string } {
|
|
197
|
+
if (isSettled(entry)) return { keep: false, revert: false, reason: 'settled' }
|
|
198
|
+
if (flags.oversized) return { keep: false, revert: false, reason: 'oversized' }
|
|
199
|
+
if (entry.earlierVersion === 'none' && entry.newText.length === 0) {
|
|
200
|
+
return { keep: true, revert: false, reason: 'empty-create' }
|
|
201
|
+
}
|
|
202
|
+
return { keep: true, revert: true, reason: '' }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* @author ddj 2026年10月29号
|
|
207
|
+
* @description: 供详情接口复用的差异构造。
|
|
208
|
+
* @param entry 待审条目
|
|
209
|
+
* @returns 官方差异
|
|
210
|
+
*/
|
|
211
|
+
export function diffOf(entry: PendingEntry): OfficialDiff {
|
|
212
|
+
return textDiff(entry.path, entry.path, entry.oldText, entry.newText)
|
|
213
|
+
}
|