dsh-vscode-mode 0.25.1 → 0.26.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 +6 -4
- package/lib/client.js +1482 -1309
- package/lib/client.js.map +2 -2
- package/lib/index.js +7028 -6516
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/reviewBar.ts +3 -0
- package/src/client/reviewBatch.ts +9 -1
- package/src/client/reviewKeepAll.ts +72 -0
- package/src/client/state/diffSummary.ts +34 -7
- package/src/client/ui/EditorView.ts +117 -29
- package/src/client/ui/ReviewAllConfirm.ts +1 -1
- package/src/index.ts +1 -1
- package/src/pending/persist.ts +94 -5
- package/src/pending/store.ts +76 -1
- package/src/pendingCompat.ts +80 -9
- package/src/pendingRpc.ts +7 -6
- package/src/reviewDb.ts +119 -8
- package/src/reviewRpc.ts +110 -3
- package/src/rpc.ts +14 -10
- package/src/shared/review.ts +21 -0
- package/src/shared/rpc.ts +3 -1
- package/src/store.ts +44 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-vscode-mode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
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": [
|
package/src/client/reviewBar.ts
CHANGED
|
@@ -126,6 +126,9 @@ export const SIDECAR_KEEP_TITLE = '标记这些历史改动为已看过:只改
|
|
|
126
126
|
/** 非官方 Undo 的作用域说明:走原有回滚校验写回磁盘,但不影响官方记录。 */
|
|
127
127
|
export const SIDECAR_UNDO_TITLE = '撤销这些历史改动(写回受回滚校验保护):只影响这份历史记录,不影响官方审查。'
|
|
128
128
|
|
|
129
|
+
/** 非官方 Keep 之后的完成态:该通道 Keep 不写盘,不能沿用「已采纳」这种会让人以为改过文件的措辞。 */
|
|
130
|
+
export const SIDECAR_KEEP_DONE = '已标记为看过(未改动文件)'
|
|
131
|
+
|
|
129
132
|
/** 当前端没有该会话时,差异栏里给中性说明而不是红色报错。 */
|
|
130
133
|
export const SESSION_DOWN_NOTICE = '这个会话没在当前端激活,官方审查数据暂时读不到;下面的历史改动仍可查看、Keep、Undo。'
|
|
131
134
|
|
|
@@ -30,7 +30,15 @@ export async function loadPending(port: Pick<BatchPort, 'snapshot'>): Promise<Re
|
|
|
30
30
|
return page.items
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* @public
|
|
35
|
+
* @author ddj 2026年10月27号
|
|
36
|
+
* @description: 逐条决策引擎,现仅供 Undo All 使用(Keep 已改走 `reviewKeepAll.ts` 的宿主批量通道);
|
|
37
|
+
* 保留 reject 形态以便对照既有契约与测试。
|
|
38
|
+
* @param port Guarded existing per-file decision adapter.
|
|
39
|
+
* @param reject Undo instead of Keep.
|
|
40
|
+
* @returns Precise partial result; Undo stops at first conflict, never claims atomic multi-file rollback.
|
|
41
|
+
*/
|
|
34
42
|
export async function runReviewBatch(port: BatchPort, reject: boolean): Promise<BatchResult> {
|
|
35
43
|
const result: BatchResult = { completed: 0, total: 0, paths: [], error: '' }
|
|
36
44
|
try {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 批量 Keep:一次完整快照 + 一次批量 RPC,单条基线问题只跳过自己,不再整批中止。
|
|
3
|
+
* Undo 仍走 reviewBatch.ts 的逐条通道(写盘要逐项校验并在冲突处停止)。
|
|
4
|
+
* @author ddj 2026年11月03号
|
|
5
|
+
*/
|
|
6
|
+
import type { KeepAllItem, KeepAllResult, ReviewItem, ReviewPage, ReviewResult } from '../shared/review.js'
|
|
7
|
+
import { loadPending } from './reviewBatch.js'
|
|
8
|
+
|
|
9
|
+
/** 批量 Keep 的 RPC 适配器;guard 在取快照前后各校验一次会话寿命。 */
|
|
10
|
+
export interface KeepAllPort {
|
|
11
|
+
snapshot: () => Promise<ReviewPage>
|
|
12
|
+
keepAll: (items: KeepAllItem[], batchId: string) => Promise<ReviewResult<{ result: KeepAllResult }>>
|
|
13
|
+
guard: () => void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @private
|
|
18
|
+
* @author ddj 2026年11月03号
|
|
19
|
+
* @description: 取一次完整待审快照;完整性校验失败时重取一次,避免瞬时漂移被当成不可恢复错误。
|
|
20
|
+
* @param port 已鉴权的 RPC 适配器
|
|
21
|
+
* @returns 冻结的批量目标
|
|
22
|
+
* @throws 两次都取不到一致快照时抛出原始原因
|
|
23
|
+
*/
|
|
24
|
+
async function snapshotOnce(port: KeepAllPort): Promise<KeepAllItem[]> {
|
|
25
|
+
try {
|
|
26
|
+
return toItems(await loadPending(port))
|
|
27
|
+
} catch {
|
|
28
|
+
// 分页期间数量漂移多为瞬时:guard 仍有效就重新取一次,仍不一致才放弃
|
|
29
|
+
port.guard()
|
|
30
|
+
return toItems(await loadPending(port))
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @private
|
|
36
|
+
* @author ddj 2026年11月03号
|
|
37
|
+
* @description: 把快照条目裁成批量目标,只带 host 需要的身份与版本线索。
|
|
38
|
+
* @param items 完整待审集
|
|
39
|
+
* @returns 批量目标
|
|
40
|
+
*/
|
|
41
|
+
function toItems(items: ReviewItem[]): KeepAllItem[] {
|
|
42
|
+
return items.map((item) => ({ id: item.id, display: item.display, seq: item.seq, revision: item.revision }))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @public
|
|
47
|
+
* @author ddj 2026年11月03号
|
|
48
|
+
* @description: 批量保留全部待审记录(Keep 不改写磁盘)。一次往返完成,跳过项只进 skipped,
|
|
49
|
+
* 段级异常才写 error;guard 失效时保留已完成计数交给调用方呈现。
|
|
50
|
+
* @param port 已鉴权的 RPC 适配器
|
|
51
|
+
* @returns 与 BatchResult 同构的批量结果(含跳过明细)
|
|
52
|
+
*/
|
|
53
|
+
export async function runKeepAll(port: KeepAllPort): Promise<KeepAllResult> {
|
|
54
|
+
const result: KeepAllResult = { completed: 0, total: 0, paths: [], skipped: [], error: '' }
|
|
55
|
+
try {
|
|
56
|
+
port.guard()
|
|
57
|
+
const items = await snapshotOnce(port)
|
|
58
|
+
result.total = items.length
|
|
59
|
+
if (!items.length) return result
|
|
60
|
+
|
|
61
|
+
port.guard()
|
|
62
|
+
const reply = await port.keepAll(items, crypto.randomUUID())
|
|
63
|
+
if (!reply.ok) {
|
|
64
|
+
result.error = reply.error
|
|
65
|
+
return result
|
|
66
|
+
}
|
|
67
|
+
return reply.result
|
|
68
|
+
} catch (cause) {
|
|
69
|
+
result.error = cause instanceof Error ? cause.message : String(cause)
|
|
70
|
+
return result
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -32,6 +32,39 @@ export function officialPath(path: string, page: ReviewPage | null): boolean {
|
|
|
32
32
|
return !!page?.overview?.coveredPaths.some((candidate) => diffPathKey(candidate) === key)
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* @author ddj 2026年11月3号
|
|
37
|
+
* @description: 本页待审条目占用的路径键:路径本身与其身份各一份。
|
|
38
|
+
* @param page 审查页
|
|
39
|
+
* @returns 待审覆盖的路径键集合
|
|
40
|
+
*/
|
|
41
|
+
function pendingCoverKeys(page: ReviewPage | null): Set<string> {
|
|
42
|
+
const keys = new Set<string>()
|
|
43
|
+
for (const item of page?.items ?? []) {
|
|
44
|
+
if (!item.id.startsWith(PENDING_PREFIX)) continue
|
|
45
|
+
// 待审条目的 path 取自写入时的原始写法(agent 常用工作区相对路径),官方导航用的却是绝对身份:
|
|
46
|
+
// 只按 path 建覆盖集时,同一文件的官方汇总行不会被压掉,差异栏把 3 个文件算成 6 条。
|
|
47
|
+
// 身份(id 去掉待审前缀)与官方 identity 同源,必须一起入集。
|
|
48
|
+
keys.add(diffPathKey(item.currentPath || item.path))
|
|
49
|
+
keys.add(diffPathKey(item.id.slice(PENDING_PREFIX.length)))
|
|
50
|
+
}
|
|
51
|
+
return keys
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @public
|
|
56
|
+
* @author ddj 2026年11月3号
|
|
57
|
+
* @description: 取本页"已有权威通道在管"的路径键:官方汇总覆盖集 + 待审条目。
|
|
58
|
+
* 编辑器与差异汇总必须同口径,否则同一改动在列表里被压掉、在编辑器里却仍然画得出来并可以点。
|
|
59
|
+
* @param page 审查页
|
|
60
|
+
* @returns 路径比较键集合
|
|
61
|
+
*/
|
|
62
|
+
export function coveredPathKeys(page: ReviewPage | null): Set<string> {
|
|
63
|
+
const keys = pendingCoverKeys(page)
|
|
64
|
+
for (const path of page?.overview?.coveredPaths ?? []) keys.add(diffPathKey(path))
|
|
65
|
+
return keys
|
|
66
|
+
}
|
|
67
|
+
|
|
35
68
|
/**
|
|
36
69
|
* @author ddj 2026年10月30号
|
|
37
70
|
* @description: 取出本页待审条目(id 带待审前缀)并折算成文件行。
|
|
@@ -40,15 +73,9 @@ export function officialPath(path: string, page: ReviewPage | null): boolean {
|
|
|
40
73
|
*/
|
|
41
74
|
function pendingRowsOf(page: ReviewPage | null): { rows: DiffFile[]; covered: Set<string> } {
|
|
42
75
|
const rows: DiffFile[] = []
|
|
43
|
-
const covered = new Set<string>()
|
|
44
76
|
for (const item of page?.items ?? []) {
|
|
45
77
|
if (!item.id.startsWith(PENDING_PREFIX)) continue
|
|
46
78
|
const path = item.currentPath || item.path
|
|
47
|
-
// 待审条目的 path 取自写入时的原始写法(agent 常用工作区相对路径),官方导航用的却是绝对身份:
|
|
48
|
-
// 只按 path 建覆盖集时,同一文件的官方汇总行不会被压掉,差异栏把 3 个文件算成 6 条。
|
|
49
|
-
// 身份(id 去掉待审前缀)与官方 identity 同源,必须一起入集。
|
|
50
|
-
covered.add(diffPathKey(path))
|
|
51
|
-
covered.add(diffPathKey(item.id.slice(PENDING_PREFIX.length)))
|
|
52
79
|
rows.push({
|
|
53
80
|
path,
|
|
54
81
|
label: item.display || path,
|
|
@@ -59,7 +86,7 @@ function pendingRowsOf(page: ReviewPage | null): { rows: DiffFile[]; covered: Se
|
|
|
59
86
|
sourceKind: 'pending',
|
|
60
87
|
})
|
|
61
88
|
}
|
|
62
|
-
return { rows, covered }
|
|
89
|
+
return { rows, covered: pendingCoverKeys(page) }
|
|
63
90
|
}
|
|
64
91
|
|
|
65
92
|
/** @public @author ddj 2026年10月26号 @param records Tool-call history. @param page Official file navigation, never only its first 50 rows. @returns Pending first, then official; legacy paths remain explicitly labelled. */
|
|
@@ -24,10 +24,12 @@ import { dbg, isDebug, rpc } from '../rpc.js'
|
|
|
24
24
|
import { refreshFeed, subscribeErrors, subscribeRecords } from '../state/recordsFeed.js'
|
|
25
25
|
import { subscribeReview, refreshReview } from '../state/reviewFeed.js'
|
|
26
26
|
import { runReviewBatch } from '../reviewBatch.js'
|
|
27
|
+
import { runKeepAll } from '../reviewKeepAll.js'
|
|
28
|
+
import { toastDom } from '../externalOpen.js'
|
|
27
29
|
import { ReviewAllConfirm } from './ReviewAllConfirm.js'
|
|
28
30
|
import { reviewRegions } from '../../shared/reviewRegions.js'
|
|
29
31
|
import { PENDING_PREFIX } from '../../shared/pendingReview.js'
|
|
30
|
-
import {
|
|
32
|
+
import { coveredPathKeys, diffPathKey, diffSummary } from '../state/diffSummary.js'
|
|
31
33
|
import { emitFileChanged, emitRefresh } from '../events.js'
|
|
32
34
|
import { langOf, loadMonaco, snippetLanguageOf } from '../monaco/loader.js'
|
|
33
35
|
import { bindEditActions, runEditAction } from '../monaco/editActions.js'
|
|
@@ -134,7 +136,7 @@ import { clampToolbarPosition, loadToolbarPosition, saveToolbarPosition } from '
|
|
|
134
136
|
import { editorModelPathOf } from '../dap/modelPath.js'
|
|
135
137
|
import { dapTrace } from '../dap/trace.js'
|
|
136
138
|
import { isSessionMissingError, sessionErrorText, sessionMissingDetail, sessionMissingTitle } from '../sessionError.js'
|
|
137
|
-
import { officialDownView, SESSION_DOWN_NOTICE } from '../reviewBar.js'
|
|
139
|
+
import { officialDownView, SESSION_DOWN_NOTICE, SIDECAR_KEEP_DONE } from '../reviewBar.js'
|
|
138
140
|
|
|
139
141
|
/** 官方差异读取失败前缀:仅用于清理同源陈旧错误,不吞并保存类错误。@author ddj 2026年10月27号 */
|
|
140
142
|
const REVIEW_LOAD_ERROR = '官方差异读取失败:'
|
|
@@ -565,14 +567,17 @@ export function EditorView(props) {
|
|
|
565
567
|
layoutRef.current = layout
|
|
566
568
|
if (!diffRendererRef.current) diffRendererRef.current = createDiffRenderer((sid, t) => dbg(sid, t))
|
|
567
569
|
|
|
570
|
+
// 已有权威通道(官方汇总 / 待审累计基线)在管的路径:旧工具记录不得再画出来,也不得被批量纳入
|
|
571
|
+
const coveredPaths = React.useMemo(() => coveredPathKeys(review.page), [review.page])
|
|
572
|
+
|
|
568
573
|
const currentRecords = React.useMemo(() => {
|
|
569
574
|
const list = []
|
|
570
575
|
for (const rec of Object.values(records)) {
|
|
571
|
-
if (rec.archived === true ||
|
|
576
|
+
if (rec.archived === true || coveredPaths.has(diffPathKey(rec.path))) continue
|
|
572
577
|
if (sameFile(rec.path, active)) list.push(rec)
|
|
573
578
|
}
|
|
574
579
|
return list
|
|
575
|
-
}, [records, active, cwd,
|
|
580
|
+
}, [records, active, cwd, coveredPaths])
|
|
576
581
|
|
|
577
582
|
const reviewOpsRef = React.useRef(new Map())
|
|
578
583
|
// 已自动结案的记录 id:防止「改动已不在文件中」的记录在刷新期间被反复提交判定
|
|
@@ -3315,8 +3320,10 @@ export function EditorView(props) {
|
|
|
3315
3320
|
}
|
|
3316
3321
|
// #endregion
|
|
3317
3322
|
if (!res || !res.ok || !Array.isArray(res.results)) {
|
|
3318
|
-
|
|
3319
|
-
|
|
3323
|
+
const failure = res?.error ? String(res.error) : '批量处理失败'
|
|
3324
|
+
setError(failure)
|
|
3325
|
+
// 真实原因要一路带到调用处:调用方按计数兜底会把 Host 原文盖掉,用户只看得到泛化文案
|
|
3326
|
+
return { ok: 0, fail: items.length, stale: 0, error: failure }
|
|
3320
3327
|
}
|
|
3321
3328
|
let ok = 0
|
|
3322
3329
|
let fail = 0
|
|
@@ -3336,8 +3343,9 @@ export function EditorView(props) {
|
|
|
3336
3343
|
if (Object.keys(next).length) setRecords((prev) => Object.assign({}, prev, next))
|
|
3337
3344
|
return { ok, fail, stale }
|
|
3338
3345
|
}).catch((e) => {
|
|
3339
|
-
|
|
3340
|
-
|
|
3346
|
+
const failure = '批量处理异常:' + String(e)
|
|
3347
|
+
setError(failure)
|
|
3348
|
+
return { ok: 0, fail: items.length, stale: 0, error: failure }
|
|
3341
3349
|
})
|
|
3342
3350
|
}
|
|
3343
3351
|
|
|
@@ -3470,6 +3478,33 @@ export function EditorView(props) {
|
|
|
3470
3478
|
finally { batchBusyRef.current = false; if (lifetime?.live) setReviewBatchBusy(false) }
|
|
3471
3479
|
}
|
|
3472
3480
|
|
|
3481
|
+
/**
|
|
3482
|
+
* 批量失败提示:Host 原文优先,只有拿不到原因时才按计数兜底。
|
|
3483
|
+
* @author ddj 2026年11月3号
|
|
3484
|
+
* @param result actMany 的结果
|
|
3485
|
+
* @returns 提示文案;没有失败返回 null
|
|
3486
|
+
*/
|
|
3487
|
+
const batchError = (result) => {
|
|
3488
|
+
if (!result.error) return batchFailText(result)
|
|
3489
|
+
// 会话没在当前端激活:写类 RPC 一律被 requireSession 拒绝(冷读列表却正常),
|
|
3490
|
+
// 只把「会话不存在」原文丢给用户会像新故障,这里改说清「没执行、记录没动、怎么恢复」。
|
|
3491
|
+
if (isSessionMissingError(result.error)) return '这个会话没在当前端激活,Keep/Undo 没有执行(记录未改动);在会话里发一条消息激活后重试'
|
|
3492
|
+
return result.error
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
/**
|
|
3496
|
+
* 批量结果没有 Host 原文时的计数兜底文案。
|
|
3497
|
+
* @author ddj 2026年11月3号
|
|
3498
|
+
* @param result actMany 的结果
|
|
3499
|
+
* @returns 提示文案;没有失败返回 null
|
|
3500
|
+
*/
|
|
3501
|
+
const batchFailText = (result) => {
|
|
3502
|
+
if (!result.fail) return null
|
|
3503
|
+
return result.stale
|
|
3504
|
+
? `${result.fail} 处未处理(其中 ${result.stale} 处已不在文件中,只更新了记录),可刷新后重试`
|
|
3505
|
+
: `${result.fail} 处未处理,可刷新后重试`
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3473
3508
|
const acceptFile = () => {
|
|
3474
3509
|
if (missingNow && !officialDetail) return decideMissing(false)
|
|
3475
3510
|
if (officialDetail) return decideOfficial(false)
|
|
@@ -3479,8 +3514,11 @@ export function EditorView(props) {
|
|
|
3479
3514
|
batchBusyRef.current = false
|
|
3480
3515
|
reloadFile(true)
|
|
3481
3516
|
emitRefresh({ scope, paths: active ? [active] : undefined, kind: 'structure' })
|
|
3482
|
-
|
|
3483
|
-
|
|
3517
|
+
// 能走到这里的都是「历史改动」通道:Keep 只把记录标成已看过,不写盘,
|
|
3518
|
+
// 用「已采纳」会让人以为文件被改过,这里必须换成准确措辞
|
|
3519
|
+
setStatus(result.ok ? `${SIDECAR_KEEP_DONE}(${result.ok} 处)` : decideStatus('已采纳 ', result))
|
|
3520
|
+
const message = batchError(result)
|
|
3521
|
+
if (message) setError(message)
|
|
3484
3522
|
}).catch(() => { batchBusyRef.current = false })
|
|
3485
3523
|
}
|
|
3486
3524
|
const undoFile = () => {
|
|
@@ -3493,7 +3531,8 @@ export function EditorView(props) {
|
|
|
3493
3531
|
reloadFile(true)
|
|
3494
3532
|
emitRefresh({ scope, paths: active ? [active] : undefined, kind: 'structure' })
|
|
3495
3533
|
setStatus(decideStatus('已不采纳 ', result))
|
|
3496
|
-
|
|
3534
|
+
const message = batchError(result)
|
|
3535
|
+
if (message) setError(message)
|
|
3497
3536
|
}).catch(() => { batchBusyRef.current = false })
|
|
3498
3537
|
}
|
|
3499
3538
|
|
|
@@ -3501,7 +3540,7 @@ export function EditorView(props) {
|
|
|
3501
3540
|
const allPending = React.useMemo(() => {
|
|
3502
3541
|
const out = []
|
|
3503
3542
|
for (const rec of Object.values(records)) {
|
|
3504
|
-
if (rec.superseded === true ||
|
|
3543
|
+
if (rec.superseded === true || coveredPaths.has(diffPathKey(rec.path))) continue
|
|
3505
3544
|
const perHunk = Array.isArray(rec.decisions?.perHunk) ? rec.decisions.perHunk : []
|
|
3506
3545
|
const hunks = Array.isArray(rec.hunks) ? rec.hunks : []
|
|
3507
3546
|
for (let i = 0; i < hunks.length; i++) {
|
|
@@ -3555,7 +3594,8 @@ export function EditorView(props) {
|
|
|
3555
3594
|
if (reject || !missingNow) reloadFile(true)
|
|
3556
3595
|
emitRefresh({ scope, paths, kind: 'structure' })
|
|
3557
3596
|
setStatus(decideStatus(reject ? '已不采纳 ' : '已采纳 ', result) + `;删除文件 ${handled.size} 个`)
|
|
3558
|
-
|
|
3597
|
+
const message = batchError(result)
|
|
3598
|
+
if (message) setError(message)
|
|
3559
3599
|
} catch (cause) { if (lifetime?.live) setError(String(cause)) }
|
|
3560
3600
|
finally { batchBusyRef.current = false; if (lifetime?.live) refreshRecords() }
|
|
3561
3601
|
}
|
|
@@ -3592,6 +3632,68 @@ export function EditorView(props) {
|
|
|
3592
3632
|
return false
|
|
3593
3633
|
}
|
|
3594
3634
|
|
|
3635
|
+
/** @private @author ddj 2026年11月03号 @description 批量通道的一次性完整快照(分页口径的 pending 只是页内计数)。 @returns 完整待审页 */
|
|
3636
|
+
async function batchSnapshot() {
|
|
3637
|
+
const res = await rpc('edrv.review.list', { sessionId, all: true })
|
|
3638
|
+
if (!res.ok) throw new Error(res.error)
|
|
3639
|
+
return res.page
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
/** @private @author ddj 2026年11月03号 @param token 起批时的会话寿命 @returns 逐条 Undo 的精确部分完成(冲突处停止) */
|
|
3643
|
+
function undoAllBatch(token) {
|
|
3644
|
+
return runReviewBatch({
|
|
3645
|
+
snapshot: batchSnapshot,
|
|
3646
|
+
detail: async (id) => { const res = await rpc('edrv.review.detail', { sessionId, id }); if (!res.ok) throw new Error(res.error); return res.detail },
|
|
3647
|
+
decide: (detail, undo, operationId) => rpc('edrv.review.decide', { sessionId, id: detail.item.id, seq: detail.item.seq, revision: detail.item.revision, operationId, scope: 'file', decision: undo ? 'rejected' : 'accepted' }),
|
|
3648
|
+
guard: () => guardReviewAll(true, token), operation: batchOperation,
|
|
3649
|
+
}, true)
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
/** @private @author ddj 2026年11月03号 @param token 起批时的会话寿命 @returns 一次 RPC 的批量 Keep 结果(跳过项不算失败) */
|
|
3653
|
+
function keepAllBatch(token) {
|
|
3654
|
+
return runKeepAll({
|
|
3655
|
+
snapshot: batchSnapshot,
|
|
3656
|
+
keepAll: (items, batchId) => rpc('edrv.review.keepAll', { sessionId, batchId, items }),
|
|
3657
|
+
guard: () => guardReviewAll(false, token),
|
|
3658
|
+
})
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
/**
|
|
3662
|
+
* @private
|
|
3663
|
+
* @author ddj 2026年11月03号
|
|
3664
|
+
* @description: 批量结果落界面:刷新列表、状态栏计数;Keep 的跳过只做提示不当错误,
|
|
3665
|
+
* Undo 保持「遇冲突停止」口径。
|
|
3666
|
+
* @param result 批量结果
|
|
3667
|
+
* @param reject 本次是 Undo 还是 Keep
|
|
3668
|
+
* @param undoPath 起批时的活动文件
|
|
3669
|
+
* @param undoModel 起批时的编辑器模型
|
|
3670
|
+
* @param undoVersion 起批时的模型版本
|
|
3671
|
+
* @returns 官方段是否完全成功(legacy 通道是否继续)
|
|
3672
|
+
*/
|
|
3673
|
+
function finishReviewAll(result, reject, undoPath, undoModel, undoVersion) {
|
|
3674
|
+
refreshReview(sessionId)
|
|
3675
|
+
if (result.paths.length) emitRefresh({ scope, paths: result.paths, kind: 'structure' })
|
|
3676
|
+
if (reject) {
|
|
3677
|
+
if (result.paths.some((path) => sameFile(path, undoPath)) && activeRef.current === undoPath
|
|
3678
|
+
&& !dirtyRef.current[undoPath] && !savingRef.current.has(undoPath) && undoModel?.getVersionId?.() === undoVersion) reloadFile(true)
|
|
3679
|
+
setStatus(`Undo All:官方记录已完成 ${result.completed} / ${result.total}`)
|
|
3680
|
+
if (result.error) setError(`批量已停止,已完成 ${result.completed} 项,其余未处理。${result.error}`)
|
|
3681
|
+
return !result.error
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
const skipped = result.skipped ?? []
|
|
3685
|
+
setStatus(`Keep All:已保留 ${result.completed} / ${result.total}` + (skipped.length ? `,跳过 ${skipped.length} 条` : ''))
|
|
3686
|
+
if (result.error) setError(`Keep All 未全部完成:${result.error}`)
|
|
3687
|
+
if (skipped.length) toastDom(skippedTip(skipped))
|
|
3688
|
+
return !result.error
|
|
3689
|
+
}
|
|
3690
|
+
|
|
3691
|
+
/** @private @author ddj 2026年11月03号 @param skipped 被跳过的条目 @returns 最多 3 条、余量汇总的提示文案 */
|
|
3692
|
+
function skippedTip(skipped) {
|
|
3693
|
+
const head = skipped.slice(0, 3).map((item) => `${item.display}:${item.reason}`).join(';')
|
|
3694
|
+
return skipped.length > 3 ? `${head};等 ${skipped.length} 条` : head
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3595
3697
|
/** @private @author ddj 2026年10月27号 @param reject Undo or Keep all workspace pending records, including previous rounds. @returns Explicit partial results; legacy proceeds only when official stage fully succeeds. */
|
|
3596
3698
|
async function actReviewAll(reject) {
|
|
3597
3699
|
if (batchBusyRef.current) return
|
|
@@ -3604,23 +3706,9 @@ export function EditorView(props) {
|
|
|
3604
3706
|
batchBusyRef.current = true; setReviewBatchBusy(true)
|
|
3605
3707
|
setStatus(reject ? '正在检查并 Undo 全部待审记录…' : '正在 Keep 全部待审记录…')
|
|
3606
3708
|
try {
|
|
3607
|
-
const result = await
|
|
3608
|
-
snapshot: async () => {
|
|
3609
|
-
const res = await rpc('edrv.review.list', { sessionId, all: true })
|
|
3610
|
-
if (!res.ok) throw new Error(res.error)
|
|
3611
|
-
return res.page
|
|
3612
|
-
},
|
|
3613
|
-
detail: async (id) => { const res = await rpc('edrv.review.detail', { sessionId, id }); if (!res.ok) throw new Error(res.error); return res.detail },
|
|
3614
|
-
decide: (detail, undo, operationId) => rpc('edrv.review.decide', { sessionId, id: detail.item.id, seq: detail.item.seq, revision: detail.item.revision, operationId, scope: 'file', decision: undo ? 'rejected' : 'accepted' }),
|
|
3615
|
-
guard: () => guardReviewAll(reject, token), operation: batchOperation,
|
|
3616
|
-
}, reject)
|
|
3709
|
+
const result = reject ? await undoAllBatch(token) : await keepAllBatch(token)
|
|
3617
3710
|
if (!token?.live) return
|
|
3618
|
-
|
|
3619
|
-
if (reject && result.paths.some((path) => sameFile(path, undoPath)) && activeRef.current === undoPath
|
|
3620
|
-
&& !dirtyRef.current[undoPath] && !savingRef.current.has(undoPath) && undoModel?.getVersionId?.() === undoVersion) reloadFile(true)
|
|
3621
|
-
if (result.paths.length) emitRefresh({ scope, paths: result.paths, kind: 'structure' })
|
|
3622
|
-
setStatus(`${reject ? 'Undo All' : 'Keep All'}:官方记录已完成 ${result.completed} / ${result.total}`)
|
|
3623
|
-
if (result.error) { setError(`批量已停止,已完成 ${result.completed} 项,其余未处理。${result.error}`); return }
|
|
3711
|
+
if (!finishReviewAll(result, reject, undoPath, undoModel, undoVersion)) return
|
|
3624
3712
|
guardReviewAll(reject, token)
|
|
3625
3713
|
// Official-owned paths are excluded from allPending; never run legacy writes for official history.
|
|
3626
3714
|
if (allPending.length) { batchBusyRef.current = false; await actAllPending(reject) }
|
|
@@ -19,7 +19,7 @@ export function ReviewAllConfirm(props: ReviewAllProps): React.ReactElement {
|
|
|
19
19
|
const action = props.reject ? 'Undo All' : 'Keep All'
|
|
20
20
|
const warning = props.reject
|
|
21
21
|
? '撤销会改写磁盘;逐项安全校验,遇到冲突停止,已完成项不会自动恢复。'
|
|
22
|
-
: '
|
|
22
|
+
: '只标记待审修改为保留,不改写文件;个别条目若已变化或需在活跃端判定会被跳过并留在列表里,可逐个处理。'
|
|
23
23
|
const footer = React.createElement(React.Fragment, null,
|
|
24
24
|
React.createElement(Button, { variant: 'ghost', onClick: props.onCancel, autoFocus: true }, '取消'),
|
|
25
25
|
React.createElement(Button, { variant: 'primary', onClick: props.onConfirm }, '确认 ' + action))
|
package/src/index.ts
CHANGED
|
@@ -76,7 +76,7 @@ function attachRuntime(ctx: Ctx, runtime: Runtime): void {
|
|
|
76
76
|
const stopCapture = installCapture(ctx, (cwd, snapshot) => { evidence.persist(cwd, snapshot); return true })
|
|
77
77
|
// 待审累计基线:只有写后内容确实变化才落库,异常一律不打断用户工具调用
|
|
78
78
|
const pending = installPending(ctx, (write) => {
|
|
79
|
-
try { trackPendingCover(write.cwd, write.path, true) } catch { /* 内存登记失败不阻断采集 */ }
|
|
79
|
+
try { trackPendingCover(write.cwd, write.path, true, write.sessionId) } catch { /* 内存登记失败不阻断采集 */ }
|
|
80
80
|
try { recordWrite(write) } catch (error) { log.warn('待审差异落库失败:' + String(error)) }
|
|
81
81
|
})
|
|
82
82
|
/** @author ddj 2026年09月28号 @param exec Tool execution. @param result Tool result. */
|
package/src/pending/persist.ts
CHANGED
|
@@ -107,8 +107,20 @@ export function loadEntries(cwd: string): StoredEntry[] {
|
|
|
107
107
|
* @returns 无
|
|
108
108
|
*/
|
|
109
109
|
export function saveEntry(cwd: string, stored: StoredEntry): void {
|
|
110
|
+
upsertEntry(pendingDb(cwd), stored)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @private
|
|
115
|
+
* @author ddj 2026年11月03号
|
|
116
|
+
* @description: 在给定连接上写入或更新一条条目(含标记),供单条与批量两个入口复用。
|
|
117
|
+
* @param db 已迁移连接(可能已在事务中)
|
|
118
|
+
* @param stored 条目与标记
|
|
119
|
+
* @returns 无
|
|
120
|
+
*/
|
|
121
|
+
function upsertEntry(db: DatabaseSync, stored: StoredEntry): void {
|
|
110
122
|
const { entry, flags } = stored
|
|
111
|
-
|
|
123
|
+
db.prepare(`INSERT INTO pending_entries
|
|
112
124
|
(id,path,earlier,old_text,new_text,updated_at,unseen,missing,diverged,oversized,revision,session_id,session_ids,lineage)
|
|
113
125
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
114
126
|
ON CONFLICT(id) DO UPDATE SET path=excluded.path,earlier=excluded.earlier,old_text=excluded.old_text,
|
|
@@ -165,9 +177,7 @@ export function saveBlocks(cwd: string, entryId: string, blocks: { id: string; d
|
|
|
165
177
|
const db = pendingDb(cwd)
|
|
166
178
|
db.exec('BEGIN IMMEDIATE')
|
|
167
179
|
try {
|
|
168
|
-
db
|
|
169
|
-
const insert = db.prepare('INSERT INTO pending_blocks VALUES(?,?,?)')
|
|
170
|
-
for (const block of blocks) insert.run(entryId, block.id, block.decision)
|
|
180
|
+
writeBlocks(db, entryId, blocks)
|
|
171
181
|
db.exec('COMMIT')
|
|
172
182
|
} catch (error) {
|
|
173
183
|
if (db.isTransaction) db.exec('ROLLBACK')
|
|
@@ -175,6 +185,21 @@ export function saveBlocks(cwd: string, entryId: string, blocks: { id: string; d
|
|
|
175
185
|
}
|
|
176
186
|
}
|
|
177
187
|
|
|
188
|
+
/**
|
|
189
|
+
* @private
|
|
190
|
+
* @author ddj 2026年11月03号
|
|
191
|
+
* @description: 在给定连接上覆盖某条目的块决定集合,供单条与批量两个入口复用。
|
|
192
|
+
* @param db 已迁移连接(可能已在事务中)
|
|
193
|
+
* @param entryId 条目身份
|
|
194
|
+
* @param blocks 块决定
|
|
195
|
+
* @returns 无
|
|
196
|
+
*/
|
|
197
|
+
function writeBlocks(db: DatabaseSync, entryId: string, blocks: { id: string; decision: string }[]): void {
|
|
198
|
+
db.prepare('DELETE FROM pending_blocks WHERE entry_id=?').run(entryId)
|
|
199
|
+
const insert = db.prepare('INSERT INTO pending_blocks VALUES(?,?,?)')
|
|
200
|
+
for (const block of blocks) insert.run(entryId, block.id, block.decision)
|
|
201
|
+
}
|
|
202
|
+
|
|
178
203
|
|
|
179
204
|
/**
|
|
180
205
|
* @author ddj 2026年10月29号
|
|
@@ -222,7 +247,21 @@ export function writeOp(
|
|
|
222
247
|
* @returns 无
|
|
223
248
|
*/
|
|
224
249
|
export function pushHistory(cwd: string, kind: string, entryId: string, snapshot: string, now: number): void {
|
|
225
|
-
|
|
250
|
+
insertHistory(pendingDb(cwd), kind, entryId, snapshot, now)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @private
|
|
255
|
+
* @author ddj 2026年11月03号
|
|
256
|
+
* @description: 在给定连接上追加撤销栈记录并截断重做分支,供单条与批量两个入口复用。
|
|
257
|
+
* @param db 已迁移连接(可能已在事务中)
|
|
258
|
+
* @param kind 操作种类
|
|
259
|
+
* @param entryId 涉及的条目
|
|
260
|
+
* @param snapshot 可重放快照
|
|
261
|
+
* @param now 时间
|
|
262
|
+
* @returns 无
|
|
263
|
+
*/
|
|
264
|
+
function insertHistory(db: DatabaseSync, kind: string, entryId: string, snapshot: string, now: number): void {
|
|
226
265
|
db.prepare("DELETE FROM pending_history WHERE state='undone'").run()
|
|
227
266
|
db.prepare("INSERT INTO pending_history(kind,entry_id,snapshot,created_at,state) VALUES(?,?,?,?,'applied')")
|
|
228
267
|
.run(kind, entryId, snapshot, now)
|
|
@@ -327,3 +366,53 @@ function flagsOfRow(row: Row): PendingFlags {
|
|
|
327
366
|
revision: Number(row.revision),
|
|
328
367
|
}
|
|
329
368
|
}
|
|
369
|
+
|
|
370
|
+
/** 批量 Keep 的一条落库记录:stored 提供 CAS 依据与撤销快照 before,next 是推进后的条目。 */
|
|
371
|
+
export interface KeepRecord {
|
|
372
|
+
entryId: string
|
|
373
|
+
stored: StoredEntry
|
|
374
|
+
next: StoredEntry
|
|
375
|
+
blocks: { id: string; decision: string }[]
|
|
376
|
+
snapshot: string
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* @public
|
|
381
|
+
* @author ddj 2026年11月03号
|
|
382
|
+
* @description: 在一次事务内批量推进多条待审条目(批量 Keep 用)。每条按 revision 做 CAS,
|
|
383
|
+
* 只跳过冲突的那一条,不牵连整批;块决定与撤销快照与单条路径同口径。
|
|
384
|
+
* @param cwd 工作区
|
|
385
|
+
* @param records 待落库记录
|
|
386
|
+
* @returns 因版本已变被跳过的条目
|
|
387
|
+
* @throws 事务级异常(SQL/程序错误)整体回滚后抛出
|
|
388
|
+
*/
|
|
389
|
+
export function saveDecisions(cwd: string, records: KeepRecord[]): { id: string; reason: string }[] {
|
|
390
|
+
const db = pendingDb(cwd)
|
|
391
|
+
const skipped: { id: string; reason: string }[] = []
|
|
392
|
+
const advance = db.prepare(`UPDATE pending_entries SET path=?,earlier=?,old_text=?,new_text=?,updated_at=?,unseen=?,
|
|
393
|
+
missing=?,diverged=?,oversized=?,revision=?,session_id=?,session_ids=?,lineage=? WHERE id=? AND revision=?`)
|
|
394
|
+
|
|
395
|
+
db.exec('BEGIN IMMEDIATE')
|
|
396
|
+
try {
|
|
397
|
+
for (const record of records) {
|
|
398
|
+
const { entry, flags } = record.next
|
|
399
|
+
const expected = record.stored.flags.revision
|
|
400
|
+
const changed = advance.run(entry.path, entry.earlierVersion, entry.oldText, entry.newText, entry.updatedAt,
|
|
401
|
+
entry.unseen === true ? 1 : 0, flags.missing ? 1 : 0, flags.diverged ? 1 : 0, flags.oversized ? 1 : 0,
|
|
402
|
+
flags.revision, entry.sessionId, JSON.stringify(entry.sessionIds),
|
|
403
|
+
entry.lineage ? JSON.stringify(entry.lineage) : null, record.entryId, expected)
|
|
404
|
+
// 读条目到落库之间被别的决定推进过:只放弃这一条
|
|
405
|
+
if (changed.changes !== 1) {
|
|
406
|
+
skipped.push({ id: record.entryId, reason: '待审条目已变化,请重试' })
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
writeBlocks(db, record.entryId, record.blocks)
|
|
410
|
+
insertHistory(db, 'decide', record.entryId, record.snapshot, Date.now())
|
|
411
|
+
}
|
|
412
|
+
db.exec('COMMIT')
|
|
413
|
+
return skipped
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (db.isTransaction) db.exec('ROLLBACK')
|
|
416
|
+
throw error
|
|
417
|
+
}
|
|
418
|
+
}
|
package/src/pending/store.ts
CHANGED
|
@@ -16,9 +16,10 @@ import { capabilityOf, decide, diffOf } from './decide.js'
|
|
|
16
16
|
import { entryFromNew, isSettled, mergeEntries } from './kernel.js'
|
|
17
17
|
import type { PendingEntry } from './kernel.js'
|
|
18
18
|
import type { PendingFlags, StoredEntry } from './persist.js'
|
|
19
|
+
import type { KeepRecord } from './persist.js'
|
|
19
20
|
import {
|
|
20
21
|
dropEntry, earliestUndone, latestApplied, loadBlocks, loadEntries, pushHistory,
|
|
21
|
-
readOp, saveBlocks, saveEntry, setHistoryState, writeOp,
|
|
22
|
+
readOp, saveBlocks, saveDecisions, saveEntry, setHistoryState, writeOp,
|
|
22
23
|
} from './persist.js'
|
|
23
24
|
|
|
24
25
|
/**
|
|
@@ -289,6 +290,80 @@ export async function applyDecide(
|
|
|
289
290
|
return outcome
|
|
290
291
|
}
|
|
291
292
|
|
|
293
|
+
/** 批量 Keep 的目标条目:display 只用于回报,operationId 由 host 按批次派生。 */
|
|
294
|
+
export interface PendingKeepTarget {
|
|
295
|
+
id: string
|
|
296
|
+
display: string
|
|
297
|
+
operationId: string
|
|
298
|
+
}
|
|
299
|
+
/** 批量 Keep 的结果:kept 是真正推进到保留态的条目,skipped 说明该条为什么没保留。 */
|
|
300
|
+
export interface PendingKeepOutcome {
|
|
301
|
+
kept: { id: string; path: string }[]
|
|
302
|
+
skipped: { id: string; display: string; reason: string }[]
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* @public
|
|
307
|
+
* @author ddj 2026年11月03号
|
|
308
|
+
* @description: 一次读取全表后批量保留待审条目(Keep 不写盘)。写入用刚读到的最新 revision,
|
|
309
|
+
* 客户端快照漂移不会整批中止;单条 oversized、不存在或落库时被抢先推进只跳过自己。
|
|
310
|
+
* @param cwd 工作区
|
|
311
|
+
* @param items 待保留条目
|
|
312
|
+
* @returns 保留成功与跳过的明细
|
|
313
|
+
* @throws 待审库迁移未就绪等存储级异常
|
|
314
|
+
*/
|
|
315
|
+
export async function keepAll(cwd: string, items: PendingKeepTarget[]): Promise<PendingKeepOutcome> {
|
|
316
|
+
const stored = new Map(loadEntries(cwd).map((item) => [item.entry.id, item]))
|
|
317
|
+
const now = Date.now()
|
|
318
|
+
const outcome: PendingKeepOutcome = { kept: [], skipped: [] }
|
|
319
|
+
const records: KeepRecord[] = []
|
|
320
|
+
|
|
321
|
+
for (const item of items) {
|
|
322
|
+
const before = stored.get(item.id)
|
|
323
|
+
if (!before) {
|
|
324
|
+
outcome.skipped.push({ id: item.id, display: item.display, reason: '待审条目不存在' })
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
327
|
+
if (before.flags.oversized) {
|
|
328
|
+
outcome.skipped.push({ id: item.id, display: item.display, reason: '文本超出上限,不能整文件保留' })
|
|
329
|
+
continue
|
|
330
|
+
}
|
|
331
|
+
// 已经是保留态:幂等无操作,仍算保留成功
|
|
332
|
+
if (isSettled(before.entry)) {
|
|
333
|
+
outcome.kept.push({ id: item.id, path: before.entry.path })
|
|
334
|
+
continue
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const beforeBlocks = blockStatesOf(before.entry)
|
|
338
|
+
const outcomeOf = await decide({
|
|
339
|
+
request: { sessionId: before.entry.sessionId, id: item.id, revision: before.flags.revision,
|
|
340
|
+
operationId: item.operationId, decision: 'accepted', scope: 'file' },
|
|
341
|
+
entry: before.entry, flags: before.flags, blocks: beforeBlocks, now,
|
|
342
|
+
// Keep 永不写盘:真走到写回说明逻辑被改坏,宁可炸掉也不静默落盘
|
|
343
|
+
write: () => { throw new Error('批量 Keep 不写盘') },
|
|
344
|
+
})
|
|
345
|
+
if (!outcomeOf.ok) {
|
|
346
|
+
outcome.skipped.push({ id: item.id, display: item.display, reason: outcomeOf.reason })
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
outcome.kept.push({ id: item.id, path: before.entry.path })
|
|
350
|
+
records.push({ entryId: item.id, stored: before, next: { entry: outcomeOf.entry, flags: outcomeOf.flags },
|
|
351
|
+
blocks: outcomeOf.blocks, snapshot: JSON.stringify({
|
|
352
|
+
before: { entry: before.entry, flags: before.flags, blocks: beforeBlocks },
|
|
353
|
+
after: { entry: outcomeOf.entry, flags: outcomeOf.flags, blocks: outcomeOf.blocks },
|
|
354
|
+
wrote: outcomeOf.wrote,
|
|
355
|
+
}) })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// 落库阶段被别的决定抢先推进的条目从 kept 挪到 skipped,如实反映磁盘状态
|
|
359
|
+
const displayOf = new Map(items.map((item) => [item.id, item.display]))
|
|
360
|
+
for (const conflict of saveDecisions(cwd, records)) {
|
|
361
|
+
outcome.kept = outcome.kept.filter((entry) => entry.id !== conflict.id)
|
|
362
|
+
outcome.skipped.push({ id: conflict.id, display: displayOf.get(conflict.id) ?? conflict.id, reason: conflict.reason })
|
|
363
|
+
}
|
|
364
|
+
return outcome
|
|
365
|
+
}
|
|
366
|
+
|
|
292
367
|
/**
|
|
293
368
|
* @author ddj 2026年10月30号
|
|
294
369
|
* @description: 版本库导入:把工作副本相对基线的改动折进待审条目,已存在条目按折叠规则并入。
|