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.
@@ -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
+ }