dsh-vscode-mode 0.21.4 → 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 +50 -4
- package/lib/client.js +72 -98
- 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/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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 待审差异构造:把两侧全文变成官方视图复用的 hunk,并提供块级 Keep/Revert 的文本合成。
|
|
3
|
+
* @author ddj 2026年10月29号
|
|
4
|
+
*/
|
|
5
|
+
import type { OfficialDiff, ReviewHunk } from '../shared/review.js'
|
|
6
|
+
import type { ReviewBlock } from '../shared/reviewRegions.js'
|
|
7
|
+
import { logicalLines } from '../shared/lineEndings.js'
|
|
8
|
+
|
|
9
|
+
/** 每个 hunk 的上下文行数。 */
|
|
10
|
+
const CONTEXT = 3
|
|
11
|
+
/** LCS 单元格上限:超过后退化为整段替换,避免大文件卡死。 */
|
|
12
|
+
const CELL_LIMIT = 1_000_000
|
|
13
|
+
|
|
14
|
+
/** 一行差异操作。 */
|
|
15
|
+
type LineOp = { kind: ' ' | '-' | '+'; text: string }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @author ddj 2026年10月29号
|
|
19
|
+
* @description: 构造可直接交给官方渲染器的差异;两侧完全相同时返回空 hunk 列表。
|
|
20
|
+
* @param path 相对路径
|
|
21
|
+
* @param display 展示名
|
|
22
|
+
* @param oldText 基线全文
|
|
23
|
+
* @param newText 当前全文
|
|
24
|
+
* @returns 文本差异
|
|
25
|
+
*/
|
|
26
|
+
export function textDiff(path: string, display: string, oldText: string, newText: string): OfficialDiff {
|
|
27
|
+
const hunks = buildHunks(logicalLines(oldText), logicalLines(newText))
|
|
28
|
+
return { kind: 'text', path, display, before: true, after: true, hunks, coarse: hunks.length > 0 && isCoarse(oldText, newText) }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @author ddj 2026年10月29号
|
|
33
|
+
* @description: 块级 Keep:把该块的新内容写进基线,返回推进后的基线。
|
|
34
|
+
* @param oldText 当前基线
|
|
35
|
+
* @param block 目标块
|
|
36
|
+
* @returns 新基线;块在当前基线中无法唯一定位时返回 null
|
|
37
|
+
*/
|
|
38
|
+
export function keepBlock(oldText: string, block: ReviewBlock): string | null {
|
|
39
|
+
return spliceBlock(oldText, block.oldLines, block.newLines, block)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @author ddj 2026年10月29号
|
|
44
|
+
* @description: 块级 Revert:把该块在最新内容里换回基线内容,返回换回后的内容。
|
|
45
|
+
* @param newText 当前内容
|
|
46
|
+
* @param block 目标块
|
|
47
|
+
* @returns 新内容;块无法唯一定位时返回 null
|
|
48
|
+
*/
|
|
49
|
+
export function revertBlock(newText: string, block: ReviewBlock): string | null {
|
|
50
|
+
return spliceBlock(newText, block.newLines, block.oldLines, block)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @author ddj 2026年10月29号
|
|
55
|
+
* @description: 按"上下文 + 目标行"唯一定位后做一次整块替换,定位不唯一就不动。
|
|
56
|
+
* @param content 目标全文
|
|
57
|
+
* @param from 要被替换的块内行
|
|
58
|
+
* @param to 替换成的块内行
|
|
59
|
+
* @param block 来源块,提供上下文;缺上下文时退化为只看块内行
|
|
60
|
+
* @returns 新全文或 null
|
|
61
|
+
*/
|
|
62
|
+
function spliceBlock(content: string, from: string[], to: string[], block: ReviewBlock): string | null {
|
|
63
|
+
if (from.length === 0 && to.length === 0) return null
|
|
64
|
+
const lines = logicalLines(content)
|
|
65
|
+
const withContext = locateSpan(lines, [...block.contextBefore, ...from, ...block.contextAfter])
|
|
66
|
+
if (withContext !== null) {
|
|
67
|
+
const start = withContext + block.contextBefore.length
|
|
68
|
+
return spliceAt(lines, start, from.length, to)
|
|
69
|
+
}
|
|
70
|
+
const bare = locateSpan(lines, from)
|
|
71
|
+
return bare === null ? null : spliceAt(lines, bare, from.length, to)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @author ddj 2026年10月29号
|
|
76
|
+
* @description: 在指定偏移处把连续若干行换成另一组行。
|
|
77
|
+
* @param lines 目标行集合
|
|
78
|
+
* @param start 起始偏移
|
|
79
|
+
* @param count 被替换行数
|
|
80
|
+
* @param to 替换成的行
|
|
81
|
+
* @returns 新全文
|
|
82
|
+
*/
|
|
83
|
+
function spliceAt(lines: string[], start: number, count: number, to: string[]): string {
|
|
84
|
+
return [...lines.slice(0, start), ...to, ...lines.slice(start + count)].join('\n')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @author ddj 2026年10月29号
|
|
89
|
+
* @description: 唯一定位一段行;出现多处命中即视为不可定位。
|
|
90
|
+
* @param lines 目标行集合
|
|
91
|
+
* @param pattern 待定位行
|
|
92
|
+
* @returns 唯一偏移或 null
|
|
93
|
+
*/
|
|
94
|
+
function locateSpan(lines: string[], pattern: string[]): number | null {
|
|
95
|
+
if (pattern.length === 0) return null
|
|
96
|
+
let at = -1
|
|
97
|
+
for (let offset = 0; offset + pattern.length <= lines.length; offset++) {
|
|
98
|
+
if (!pattern.every((text, index) => lines[offset + index] === text)) continue
|
|
99
|
+
if (at >= 0) return null
|
|
100
|
+
at = offset
|
|
101
|
+
}
|
|
102
|
+
return at >= 0 ? at : null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @author ddj 2026年10月29号
|
|
107
|
+
* @description: 生成带上下文的标准 hunk 列表。
|
|
108
|
+
* @param oldLines 基线行
|
|
109
|
+
* @param newLines 当前行
|
|
110
|
+
* @returns hunk 列表
|
|
111
|
+
*/
|
|
112
|
+
function buildHunks(oldLines: string[], newLines: string[]): ReviewHunk[] {
|
|
113
|
+
const ops = diffLines(oldLines, newLines)
|
|
114
|
+
const hunks: ReviewHunk[] = []
|
|
115
|
+
let index = 0
|
|
116
|
+
let oldLine = 0
|
|
117
|
+
let newLine = 0
|
|
118
|
+
while (index < ops.length) {
|
|
119
|
+
if (ops[index].kind === ' ') {
|
|
120
|
+
oldLine++
|
|
121
|
+
newLine++
|
|
122
|
+
index++
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
const head = ops.slice(Math.max(0, index - CONTEXT), index)
|
|
126
|
+
const collected: LineOp[] = []
|
|
127
|
+
let probe = index
|
|
128
|
+
while (probe < ops.length) {
|
|
129
|
+
const op = ops[probe]
|
|
130
|
+
if (op.kind !== ' ') { collected.push(op); probe++; continue }
|
|
131
|
+
const rest = ops.slice(probe, probe + CONTEXT * 2 + 1)
|
|
132
|
+
if (rest.some((item) => item.kind !== ' ')) { collected.push(op); probe++; continue }
|
|
133
|
+
break
|
|
134
|
+
}
|
|
135
|
+
const tail = ops.slice(probe, probe + CONTEXT)
|
|
136
|
+
const oldStartLine = oldLine - head.length + 1
|
|
137
|
+
const newStartLine = newLine - head.length + 1
|
|
138
|
+
hunks.push(makeHunk([...head, ...collected, ...tail], oldStartLine, newStartLine))
|
|
139
|
+
for (const op of collected) {
|
|
140
|
+
if (op.kind !== '+') oldLine++
|
|
141
|
+
if (op.kind !== '-') newLine++
|
|
142
|
+
}
|
|
143
|
+
oldLine += head.length + tail.length
|
|
144
|
+
newLine += head.length + tail.length
|
|
145
|
+
index = probe + tail.length
|
|
146
|
+
}
|
|
147
|
+
return hunks
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @author ddj 2026年10月29号
|
|
152
|
+
* @description: 由一段操作装配 hunk。
|
|
153
|
+
* @param ops 该 hunk 的操作
|
|
154
|
+
* @param oldStart 基线起始行
|
|
155
|
+
* @param newStart 当前起始行
|
|
156
|
+
* @returns hunk
|
|
157
|
+
*/
|
|
158
|
+
function makeHunk(ops: LineOp[], oldStart: number, newStart: number): ReviewHunk {
|
|
159
|
+
const oldCount = ops.filter((op) => op.kind !== '+').length
|
|
160
|
+
const newCount = ops.filter((op) => op.kind !== '-').length
|
|
161
|
+
return { oldStart, oldLines: oldCount, newStart, newLines: newCount, lines: ops.map((op) => op.kind + op.text) }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @author ddj 2026年10月29号
|
|
166
|
+
* @description: 行级差异:先剪公共前后缀,剩余部分在限额内做 LCS,超限按整段替换。
|
|
167
|
+
* @param oldLines 基线行
|
|
168
|
+
* @param newLines 当前行
|
|
169
|
+
* @returns 顺序完整的操作列表
|
|
170
|
+
*/
|
|
171
|
+
function diffLines(oldLines: string[], newLines: string[]): LineOp[] {
|
|
172
|
+
let head = 0
|
|
173
|
+
while (head < oldLines.length && head < newLines.length && oldLines[head] === newLines[head]) head++
|
|
174
|
+
let tail = 0
|
|
175
|
+
while (tail < oldLines.length - head && tail < newLines.length - head
|
|
176
|
+
&& oldLines[oldLines.length - 1 - tail] === newLines[newLines.length - 1 - tail]) tail++
|
|
177
|
+
const left = oldLines.slice(head, oldLines.length - tail)
|
|
178
|
+
const right = newLines.slice(head, newLines.length - tail)
|
|
179
|
+
const middle = left.length * right.length > CELL_LIMIT ? coarseOps(left, right) : lcsOps(left, right)
|
|
180
|
+
return [
|
|
181
|
+
...oldLines.slice(0, head).map((text) => ({ kind: ' ' as const, text })),
|
|
182
|
+
...middle,
|
|
183
|
+
...oldLines.slice(oldLines.length - tail).map((text) => ({ kind: ' ' as const, text })),
|
|
184
|
+
]
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @author ddj 2026年10月29号
|
|
189
|
+
* @description: 超限时的退化口径:整段删除再整段新增,绝不谎报逐行对应。
|
|
190
|
+
* @param left 基线剩余行
|
|
191
|
+
* @param right 当前剩余行
|
|
192
|
+
* @returns 操作列表
|
|
193
|
+
*/
|
|
194
|
+
function coarseOps(left: string[], right: string[]): LineOp[] {
|
|
195
|
+
return [
|
|
196
|
+
...left.map((text) => ({ kind: '-' as const, text })),
|
|
197
|
+
...right.map((text) => ({ kind: '+' as const, text })),
|
|
198
|
+
]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @author ddj 2026年10月29号
|
|
203
|
+
* @description: 限额内的 LCS 行差异。
|
|
204
|
+
* @param left 基线剩余行
|
|
205
|
+
* @param right 当前剩余行
|
|
206
|
+
* @returns 操作列表
|
|
207
|
+
*/
|
|
208
|
+
function lcsOps(left: string[], right: string[]): LineOp[] {
|
|
209
|
+
const rows = left.length + 1
|
|
210
|
+
const cols = right.length + 1
|
|
211
|
+
const table = new Uint32Array(rows * cols)
|
|
212
|
+
for (let i = left.length - 1; i >= 0; i--) {
|
|
213
|
+
for (let j = right.length - 1; j >= 0; j--) {
|
|
214
|
+
table[i * cols + j] = left[i] === right[j]
|
|
215
|
+
? table[(i + 1) * cols + j + 1] + 1
|
|
216
|
+
: Math.max(table[(i + 1) * cols + j], table[i * cols + j + 1])
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const ops: LineOp[] = []
|
|
220
|
+
let i = 0
|
|
221
|
+
let j = 0
|
|
222
|
+
while (i < left.length && j < right.length) {
|
|
223
|
+
if (left[i] === right[j]) { ops.push({ kind: ' ', text: left[i] }); i++; j++; continue }
|
|
224
|
+
if (table[(i + 1) * cols + j] >= table[i * cols + j + 1]) { ops.push({ kind: '-', text: left[i] }); i++ }
|
|
225
|
+
else { ops.push({ kind: '+', text: right[j] }); j++ }
|
|
226
|
+
}
|
|
227
|
+
while (i < left.length) ops.push({ kind: '-', text: left[i++] })
|
|
228
|
+
while (j < right.length) ops.push({ kind: '+', text: right[j++] })
|
|
229
|
+
return ops
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @author ddj 2026年10月29号
|
|
234
|
+
* @description: 判断这次差异是否触发了大文件退化口径。
|
|
235
|
+
* @param oldText 基线全文
|
|
236
|
+
* @param newText 当前全文
|
|
237
|
+
* @returns 是否粗粒度
|
|
238
|
+
*/
|
|
239
|
+
function isCoarse(oldText: string, newText: string): boolean {
|
|
240
|
+
return logicalLines(oldText).length * logicalLines(newText).length > CELL_LIMIT
|
|
241
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 待审条目内核:纯状态机,逐条对齐 dsh-diff-approval 的 pending 语义。
|
|
3
|
+
* 无 IO、无时钟、不改入参;调用方传入 now 与内容,便于测试与复用。
|
|
4
|
+
* @author ddj 2026年10月29号
|
|
5
|
+
*/
|
|
6
|
+
import type { PendingEarlier, PendingLineage } from '../shared/pendingReview.js'
|
|
7
|
+
|
|
8
|
+
/** 内存态条目:比展示投影多保存两侧全文。 */
|
|
9
|
+
export interface PendingEntry {
|
|
10
|
+
id: string
|
|
11
|
+
path: string
|
|
12
|
+
earlierVersion: PendingEarlier
|
|
13
|
+
oldText: string
|
|
14
|
+
newText: string
|
|
15
|
+
updatedAt: number
|
|
16
|
+
unseen?: boolean
|
|
17
|
+
sessionId: string
|
|
18
|
+
sessionIds: string[]
|
|
19
|
+
lineage?: PendingLineage
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 新建条目的入参。 */
|
|
23
|
+
export interface NewPending {
|
|
24
|
+
id: string
|
|
25
|
+
path: string
|
|
26
|
+
sessionId: string
|
|
27
|
+
earlierVersion: PendingEarlier
|
|
28
|
+
oldText: string
|
|
29
|
+
newText: string
|
|
30
|
+
updatedAt: number
|
|
31
|
+
lineage?: PendingLineage
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 决定后对条目的局部推进;未出现的字段表示不改。 */
|
|
35
|
+
export interface PendingPatch {
|
|
36
|
+
oldText?: string
|
|
37
|
+
newText?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @author ddj 2026年10月29号
|
|
42
|
+
* @description: 严格比较两侧文本:不做 BOM/行尾归一化,归一化只允许用于展示。
|
|
43
|
+
* @param left 一侧全文
|
|
44
|
+
* @param right 另一侧全文
|
|
45
|
+
* @returns 两者是否逐码元相等
|
|
46
|
+
*/
|
|
47
|
+
export function sameEntryText(left: string, right: string): boolean {
|
|
48
|
+
return left === right
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @author ddj 2026年10月29号
|
|
53
|
+
* @description: 净零判定:基线等于当前内容即为已解决,但条目仍留在列表等待移除。
|
|
54
|
+
* @param entry 待审条目
|
|
55
|
+
* @returns 是否已无净差异
|
|
56
|
+
*/
|
|
57
|
+
export function isSettled(entry: PendingEntry): boolean {
|
|
58
|
+
return sameEntryText(entry.oldText, entry.newText)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @author ddj 2026年10月29号
|
|
63
|
+
* @description: 合并同一身份的两条条目:基线取较早来源,最新内容取较晚来源,会话取并集。
|
|
64
|
+
* @param earlier 较早记录
|
|
65
|
+
* @param later 较晚记录
|
|
66
|
+
* @returns 合并后的新条目
|
|
67
|
+
*/
|
|
68
|
+
export function mergeEntries(earlier: PendingEntry, later: PendingEntry): PendingEntry {
|
|
69
|
+
const merged: PendingEntry = {
|
|
70
|
+
...later,
|
|
71
|
+
updatedAt: later.updatedAt,
|
|
72
|
+
sessionIds: unionSessions(earlier, later),
|
|
73
|
+
}
|
|
74
|
+
if (later.lineage === undefined && earlier.lineage !== undefined) merged.lineage = earlier.lineage
|
|
75
|
+
if (earlier.earlierVersion === 'none' && later.earlierVersion === 'none') {
|
|
76
|
+
merged.earlierVersion = 'none'
|
|
77
|
+
merged.oldText = earlier.oldText
|
|
78
|
+
return merged
|
|
79
|
+
}
|
|
80
|
+
// 基线一旦有历史版本,Revert 就是写回内容而不是删除文件;创建期的内容成为新基线。
|
|
81
|
+
merged.earlierVersion = 'file'
|
|
82
|
+
merged.oldText = earlier.earlierVersion === 'none' ? earlier.newText : earlier.oldText
|
|
83
|
+
return merged
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @author ddj 2026年10月29号
|
|
88
|
+
* @description: 由新建入参构造条目,并把首个会话写进会话集合。
|
|
89
|
+
* @param input 新建入参
|
|
90
|
+
* @returns 新条目
|
|
91
|
+
*/
|
|
92
|
+
export function entryFromNew(input: NewPending): PendingEntry {
|
|
93
|
+
const entry: PendingEntry = {
|
|
94
|
+
id: input.id,
|
|
95
|
+
path: input.path,
|
|
96
|
+
earlierVersion: input.earlierVersion,
|
|
97
|
+
oldText: input.oldText,
|
|
98
|
+
newText: input.newText,
|
|
99
|
+
updatedAt: input.updatedAt,
|
|
100
|
+
sessionId: input.sessionId,
|
|
101
|
+
sessionIds: [input.sessionId],
|
|
102
|
+
}
|
|
103
|
+
if (input.lineage !== undefined) entry.lineage = input.lineage
|
|
104
|
+
return entry
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @author ddj 2026年10月29号
|
|
109
|
+
* @description: 应用一次决定带来的局部推进:块级 Keep 推进基线,块级 Revert 推进当前内容。
|
|
110
|
+
* @param entry 待审条目
|
|
111
|
+
* @param patch 要推进的一侧
|
|
112
|
+
* @param now 本次更新时间
|
|
113
|
+
* @returns 新条目;基线被推进时"创建"身份结束
|
|
114
|
+
*/
|
|
115
|
+
export function applyUpdate(entry: PendingEntry, patch: PendingPatch, now: number): PendingEntry {
|
|
116
|
+
const next: PendingEntry = { ...entry, updatedAt: now }
|
|
117
|
+
if (patch.oldText !== undefined) {
|
|
118
|
+
next.oldText = patch.oldText
|
|
119
|
+
next.earlierVersion = 'file'
|
|
120
|
+
}
|
|
121
|
+
if (patch.newText !== undefined) next.newText = patch.newText
|
|
122
|
+
delete next.unseen
|
|
123
|
+
return next
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @author ddj 2026年10月29号
|
|
128
|
+
* @description: 采纳外部改动:只替换当前内容并标记未读,基线保持不动。
|
|
129
|
+
* @param entry 待审条目
|
|
130
|
+
* @param content 磁盘上的最新全文
|
|
131
|
+
* @param now 本次更新时间
|
|
132
|
+
* @returns 新条目
|
|
133
|
+
*/
|
|
134
|
+
export function adoptExternal(entry: PendingEntry, content: string, now: number): PendingEntry {
|
|
135
|
+
return { ...entry, newText: content, updatedAt: now, unseen: true }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @author ddj 2026年10月29号
|
|
140
|
+
* @description: 设置或清除未读圆点。
|
|
141
|
+
* @param entry 待审条目
|
|
142
|
+
* @param seen 是否已读
|
|
143
|
+
* @returns 新条目
|
|
144
|
+
*/
|
|
145
|
+
export function touchSeen(entry: PendingEntry, seen: boolean): PendingEntry {
|
|
146
|
+
const next: PendingEntry = { ...entry }
|
|
147
|
+
if (seen) delete next.unseen
|
|
148
|
+
else next.unseen = true
|
|
149
|
+
return next
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @author ddj 2026年10月29号
|
|
154
|
+
* @description: 撤销重放:条目内容回滚到快照,未读标记保留实时值。
|
|
155
|
+
* @param entry 快照条目
|
|
156
|
+
* @param live 实时条目,用于取未读标记
|
|
157
|
+
* @returns 重放后的新条目
|
|
158
|
+
*/
|
|
159
|
+
export function withUnseen(entry: PendingEntry, live: PendingEntry | undefined): PendingEntry {
|
|
160
|
+
return touchSeen(entry, live ? live.unseen !== true : entry.unseen !== true)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @author ddj 2026年10月29号
|
|
165
|
+
* @description: 列表排序:按更新时间升序,与上游一致。
|
|
166
|
+
* @param left 左侧条目
|
|
167
|
+
* @param right 右侧条目
|
|
168
|
+
* @returns 比较结果
|
|
169
|
+
*/
|
|
170
|
+
export function byUpdatedAt(left: PendingEntry, right: PendingEntry): number {
|
|
171
|
+
return left.updatedAt - right.updatedAt
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @author ddj 2026年10月29号
|
|
176
|
+
* @description: 合并两条条目的会话集合并去重,且保证首会话在列。
|
|
177
|
+
* @param earlier 较早记录
|
|
178
|
+
* @param later 较晚记录
|
|
179
|
+
* @returns 去重后的会话集合
|
|
180
|
+
*/
|
|
181
|
+
function unionSessions(earlier: PendingEntry, later: PendingEntry): string[] {
|
|
182
|
+
const merged: string[] = []
|
|
183
|
+
for (const sessionId of [earlier.sessionId, ...earlier.sessionIds, later.sessionId, ...later.sessionIds]) {
|
|
184
|
+
if (sessionId && !merged.includes(sessionId)) merged.push(sessionId)
|
|
185
|
+
}
|
|
186
|
+
return merged
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 内容版本号分配器:只有内容变化才递增,条目删除后编号不复用。
|
|
191
|
+
* @author ddj 2026年10月29号
|
|
192
|
+
*/
|
|
193
|
+
export class ContentVersions {
|
|
194
|
+
/** 已分配编号与快照文本。 */
|
|
195
|
+
private readonly state = new Map<string, { version: number; text: string }>()
|
|
196
|
+
/** 下一个可用编号。 */
|
|
197
|
+
private next = 1
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* @author ddj 2026年10月29号
|
|
201
|
+
* @description: 记录条目当前内容并返回其版本号;内容未变时沿用旧号。
|
|
202
|
+
* @param id 条目身份
|
|
203
|
+
* @param text 当前内容
|
|
204
|
+
* @returns 该条目的版本号
|
|
205
|
+
*/
|
|
206
|
+
note(id: string, text: string): number {
|
|
207
|
+
const known = this.state.get(id)
|
|
208
|
+
if (known && known.text === text) return known.version
|
|
209
|
+
const version = this.next++
|
|
210
|
+
this.state.set(id, { version, text })
|
|
211
|
+
return version
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @author ddj 2026年10月29号
|
|
216
|
+
* @description: 查询已分配版本号,供评论锚定校验。
|
|
217
|
+
* @param id 条目身份
|
|
218
|
+
* @returns 版本号,未记录时为 undefined
|
|
219
|
+
*/
|
|
220
|
+
versionOf(id: string): number | undefined {
|
|
221
|
+
return this.state.get(id)?.version
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @author ddj 2026年10月29号
|
|
226
|
+
* @description: 条目离开列表时丢弃其版本号,且不回收编号。
|
|
227
|
+
* @param id 条目身份
|
|
228
|
+
* @returns 无
|
|
229
|
+
*/
|
|
230
|
+
drop(id: string): void {
|
|
231
|
+
this.state.delete(id)
|
|
232
|
+
}
|
|
233
|
+
}
|