dsh-vscode-mode 0.21.4 → 0.23.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 +52 -4
- package/lib/client.js +3442 -183
- package/lib/client.js.map +2 -2
- package/lib/index.js +3510 -222
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +166 -0
- package/src/client/quickMenuHost.ts +46 -0
- package/src/client/quickMenuStore.ts +260 -0
- package/src/client/state/diffSummary.ts +52 -8
- package/src/client/styles/quickmenu.css +1049 -0
- 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/QuickMenuDialog.tsx +835 -0
- package/src/client/ui/QuickMenuLauncher.tsx +792 -0
- package/src/client/ui/QuickMenuPickers.ts +577 -0
- package/src/client/ui/SidecarDiffBar.tsx +0 -22
- package/src/index.ts +9 -0
- package/src/missingReview.ts +36 -14
- package/src/paths.ts +10 -0
- package/src/pending/capture.ts +165 -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 +329 -0
- package/src/pending/store.ts +472 -0
- package/src/pending/vcs.ts +444 -0
- package/src/pendingCompat.ts +184 -0
- package/src/pendingRpc.ts +210 -0
- package/src/quickMenu.ts +269 -0
- package/src/reviewDb.ts +3 -0
- package/src/reviewRpc.ts +52 -1
- package/src/rpc.ts +66 -2
- package/src/shared/pathKey.ts +54 -0
- package/src/shared/pendingReview.ts +105 -0
- package/src/shared/quickMenu.ts +1009 -0
- package/src/shared/quickPick.ts +67 -0
- package/src/shared/rpc.ts +28 -0
- package/src/shared/skillCatalog.ts +60 -0
- package/src/skillCatalog.ts +138 -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)
|
package/src/paths.ts
CHANGED
|
@@ -204,6 +204,16 @@ export function archiveDbFile(cwd: string, home = dshHome()): string {
|
|
|
204
204
|
return join(home, PLUGIN_ID, 'data', 'archive', hashOf(cwd) + '.sqlite')
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/**
|
|
208
|
+
* 快捷菜单配置文本(用户数据,不受缓存清理预算约束,跨工作区共享一份)。
|
|
209
|
+
* @author ddj 2026年10月30号
|
|
210
|
+
* @param home DSH home(测试可注入)
|
|
211
|
+
* @returns 快捷菜单配置绝对路径
|
|
212
|
+
*/
|
|
213
|
+
export function quickMenuFile(home = dshHome()): string {
|
|
214
|
+
return join(home, PLUGIN_ID, 'data', 'quick-menu.json')
|
|
215
|
+
}
|
|
216
|
+
|
|
207
217
|
/** 插件包 assets 目录(import.meta.url 派生;config.imageDir 可覆盖,见 imageDirOf)。 */
|
|
208
218
|
export function assetsDirOf(moduleUrl: string): string {
|
|
209
219
|
return join(dirname(fileURLToPath(moduleUrl)), '..', 'assets')
|
|
@@ -0,0 +1,165 @@
|
|
|
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
|
+
interface CaptureState {
|
|
34
|
+
busy: Set<string>
|
|
35
|
+
stopped: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 工具的写工具参数形状;只取本采集关心的字段。 */
|
|
39
|
+
interface WriteExec {
|
|
40
|
+
name?: string
|
|
41
|
+
arguments?: { file_path?: unknown; path?: unknown }
|
|
42
|
+
agent?: { session?: { id?: string; cwd?: string } }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @author ddj 2026年10月30号
|
|
47
|
+
* @description: 安装写文件采集;读写失败只影响待审来源,绝不影响用户工具本身。
|
|
48
|
+
* @param ctx Host 生命周期
|
|
49
|
+
* @param sink 已确认写入的接收方
|
|
50
|
+
* @returns 可释放的采集器
|
|
51
|
+
*/
|
|
52
|
+
export function installPending(ctx: Ctx, sink: (write: PendingWrite) => void): PendingCapture {
|
|
53
|
+
const state: CaptureState = { busy: new Set<string>(), stopped: false }
|
|
54
|
+
ctx.on('tools/execute', (exec: WriteExec, next: () => Promise<unknown>) => aroundTool(ctx, state, sink, exec, next))
|
|
55
|
+
return {
|
|
56
|
+
dispose(): void {
|
|
57
|
+
state.stopped = true
|
|
58
|
+
state.busy.clear()
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @author ddj 2026年10月30号
|
|
65
|
+
* @description: 判定本次调用是否值得采集;不值得时返回 null,调用方直接透传原始分发。
|
|
66
|
+
* @param ctx Host 生命周期
|
|
67
|
+
* @param state 采集状态
|
|
68
|
+
* @param exec 工具分发上下文
|
|
69
|
+
* @returns 采集计划或 null
|
|
70
|
+
*/
|
|
71
|
+
async function planOf(ctx: Ctx, state: CaptureState, exec: WriteExec): Promise<CapturePlan | null> {
|
|
72
|
+
const session = exec.agent?.session
|
|
73
|
+
const requested = exec.arguments?.file_path ?? exec.arguments?.path
|
|
74
|
+
if (state.stopped || !session?.id || typeof requested !== 'string') return null
|
|
75
|
+
if (!WRITE_TOOLS.includes(String(exec.name))) return null
|
|
76
|
+
const cwd = cwdOf(session as never)
|
|
77
|
+
if (!cwd) return null
|
|
78
|
+
const fs = ctx.get('fs')
|
|
79
|
+
const target = await resolveTarget(ctx, session as never, requested)
|
|
80
|
+
const identity = fs.processPath(target)
|
|
81
|
+
if (typeof identity !== 'string' || !identity || state.busy.has(identity)) return null
|
|
82
|
+
const before = await readText(fs, target)
|
|
83
|
+
if (before !== null && before.length > MAX_TEXT) return null
|
|
84
|
+
return { cwd: String(cwd), sessionId: String(session.id), path: requested, identity, before, session: session as never }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 一次采集所需的固定信息。 */
|
|
88
|
+
interface CapturePlan {
|
|
89
|
+
cwd: string
|
|
90
|
+
sessionId: string
|
|
91
|
+
path: string
|
|
92
|
+
identity: string
|
|
93
|
+
before: string | null
|
|
94
|
+
session: never
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @author ddj 2026年10月30号
|
|
99
|
+
* @description: 写工具执行前后各读一次,内容变化才上报;任何采集异常都不改变工具结果。
|
|
100
|
+
* @param ctx Host 生命周期
|
|
101
|
+
* @param state 采集状态
|
|
102
|
+
* @param sink 已确认写入的接收方
|
|
103
|
+
* @param exec 工具分发上下文
|
|
104
|
+
* @param next 原始分发
|
|
105
|
+
* @returns 原始工具结果
|
|
106
|
+
*/
|
|
107
|
+
async function aroundTool(
|
|
108
|
+
ctx: Ctx, state: CaptureState, sink: (write: PendingWrite) => void,
|
|
109
|
+
exec: WriteExec, next: () => Promise<unknown>,
|
|
110
|
+
): Promise<unknown> {
|
|
111
|
+
let plan: CapturePlan | null = null
|
|
112
|
+
try {
|
|
113
|
+
plan = await planOf(ctx, state, exec)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
log.debug('待审采集跳过:' + String(error))
|
|
116
|
+
}
|
|
117
|
+
if (!plan) return next()
|
|
118
|
+
|
|
119
|
+
state.busy.add(plan.identity)
|
|
120
|
+
try {
|
|
121
|
+
return await next()
|
|
122
|
+
} finally {
|
|
123
|
+
state.busy.delete(plan.identity)
|
|
124
|
+
try {
|
|
125
|
+
reportWrite(ctx, sink, plan)
|
|
126
|
+
} catch (error) {
|
|
127
|
+
log.debug('待审上报失败:' + String(error))
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @author ddj 2026年10月29号
|
|
134
|
+
* @description: 写后读回并比较,内容一致视为无净变化不产生条目。
|
|
135
|
+
* @param ctx Host 生命周期
|
|
136
|
+
* @param sink 已确认写入的接收方
|
|
137
|
+
* @param plan 采集计划
|
|
138
|
+
* @returns 无;读回失败只记日志
|
|
139
|
+
*/
|
|
140
|
+
function reportWrite(ctx: Ctx, sink: (write: PendingWrite) => void, plan: CapturePlan): void {
|
|
141
|
+
const fs = ctx.get('fs')
|
|
142
|
+
void resolveTarget(ctx, plan.session, plan.path).then(async (target: unknown) => {
|
|
143
|
+
const after = await readText(fs, target)
|
|
144
|
+
if (after !== null && after.length > MAX_TEXT) return
|
|
145
|
+
if (plan.before === after) return
|
|
146
|
+
sink({ cwd: plan.cwd, sessionId: plan.sessionId, path: plan.path, identity: plan.identity, before: plan.before, after, now: Date.now() })
|
|
147
|
+
}).catch((error: unknown) => log.debug('待审读回失败:' + String(error)))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @author ddj 2026年10月29号
|
|
152
|
+
* @description: 读取全文;缺失或不可读按"无内容"处理,交由上层决定语义。
|
|
153
|
+
* @param fs 文件系统
|
|
154
|
+
* @param target 目标
|
|
155
|
+
* @returns 文本或 null
|
|
156
|
+
*/
|
|
157
|
+
export async function readText(fs: any, target: any): Promise<string | null> {
|
|
158
|
+
try {
|
|
159
|
+
return String(await fs.readText(target))
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const code = (error as { code?: string }).code
|
|
162
|
+
if (code === 'ENOENT' || code === 'FS_NOT_FOUND' || code === 'FS_TOO_LARGE') return null
|
|
163
|
+
throw error
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -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
|
+
}
|