dsh-vscode-mode 0.21.3 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -3
- package/lib/client.js +254 -174
- package/lib/client.js.map +2 -2
- package/lib/index.js +2419 -32
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/addToConversation.ts +79 -32
- package/src/client/sidebar/panels/SvnPanel.ts +38 -30
- package/src/client/state/diffSummary.ts +43 -4
- package/src/client/ui/DiffLauncher.ts +1 -1
- package/src/client/ui/EditorView.ts +10 -2
- package/src/client/ui/OfficialDiffBar.tsx +1 -12
- package/src/client/ui/OfficialReview.ts +13 -3
- package/src/client/ui/SidecarDiffBar.tsx +0 -22
- package/src/index.ts +9 -0
- package/src/missingReview.ts +36 -14
- package/src/pending/capture.ts +136 -0
- package/src/pending/decide.ts +213 -0
- package/src/pending/hunks.ts +241 -0
- package/src/pending/kernel.ts +233 -0
- package/src/pending/persist.ts +324 -0
- package/src/pending/store.ts +467 -0
- package/src/pending/vcs.ts +444 -0
- package/src/pendingCompat.ts +189 -0
- package/src/pendingRpc.ts +128 -0
- package/src/reviewDb.ts +3 -0
- package/src/reviewRpc.ts +52 -1
- package/src/rpc.ts +29 -2
- package/src/shared/pendingReview.ts +105 -0
- package/src/shared/rpc.ts +18 -0
- package/src/store.ts +19 -0
|
@@ -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
|
+
}
|