dsh-config-manager 0.1.73 → 0.1.76
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/lib/client.d.ts +8 -4
- package/lib/client.js +814 -682
- package/lib/core/rollback-ledger.d.ts +29 -3
- package/lib/core/rollback-ledger.js +125 -7
- package/lib/core/rollback-point.d.ts +221 -3
- package/lib/core/rollback-point.js +790 -153
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/sync/messages.d.ts +1 -0
- package/lib/sync/messages.js +3 -0
- package/lib/sync/sync-engine.js +56 -7
- package/lib/ui/i18n.d.ts +1 -1
- package/lib/ui/i18n.js +2 -2
- package/lib/utils/bundle-scan.d.ts +5 -3
- package/lib/utils/bundle-scan.js +5 -3
- package/lib/utils/line-diff.d.ts +7 -1
- package/lib/utils/line-diff.js +8 -2
- package/package.json +1 -1
- package/src/client/chat/ChatEntryView.tsx +38 -9
- package/src/client/chat/RollbackFileIcon.tsx +7 -45
- package/src/client/chat/chat-entry-view.test.ts +164 -1
- package/src/client/chat/chat-entry-view.ts +127 -10
- package/src/client/chat/chat-node-registration.ts +40 -6
- package/src/client/common/DiffTable.tsx +12 -23
- package/src/client/common/host-primitives.ts +78 -0
- package/src/client/config-manager.module.css +33 -12
- package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
- package/src/client/index.ts +7 -1
- package/src/client/library/RestorePlanView.tsx +0 -1
- package/src/client/locales.ts +0 -2
- package/src/client/recovery/RollbackDiffPane.tsx +13 -6
- package/src/client/recovery/recovery-locales.ts +2 -4
- package/src/client/recovery/rollback-file-changes.test.ts +37 -1
- package/src/client/sync/PushPreviewCard.tsx +57 -0
- package/src/client/sync/SyncPanel.tsx +2 -37
- package/src/client/sync/SyncSettingsView.test.ts +3 -0
- package/src/client/sync/sync-push-preview.test.ts +14 -0
- package/src/client/sync/sync-view.ts +9 -1
- package/src/core/rollback-ledger.ts +123 -7
- package/src/core/rollback-point.test.ts +592 -17
- package/src/core/rollback-point.ts +861 -155
- package/src/index.ts +1 -1
- package/src/sync/messages.ts +3 -0
- package/src/sync/sync-engine.ts +55 -7
- package/src/sync/sync-preview-push.test.ts +158 -0
- package/src/ui/i18n.ts +2 -2
- package/src/utils/bundle-scan.ts +5 -3
- package/src/utils/line-diff.test.ts +12 -0
- package/src/utils/line-diff.ts +9 -3
|
@@ -42,6 +42,9 @@ import {
|
|
|
42
42
|
planChatEntryAttachments,
|
|
43
43
|
planChatNodeRegistration,
|
|
44
44
|
projectChatEntryText,
|
|
45
|
+
projectChatEntryTextWithHost,
|
|
46
|
+
chatNodeUserOccupant,
|
|
47
|
+
CHAT_NODE_YIELD_WARN_PREFIX,
|
|
45
48
|
resetChatPointCache,
|
|
46
49
|
shouldRetryChatPoint,
|
|
47
50
|
CHAT_POINT_LOOKUP_WINDOW_MS,
|
|
@@ -55,7 +58,7 @@ import {
|
|
|
55
58
|
retryChatPointUntilHit,
|
|
56
59
|
type ChatPointWindowState,
|
|
57
60
|
} from './chat-point-window.ts'
|
|
58
|
-
import { declaredSlotKind, registerChatNodeEntry } from './chat-node-registration.ts'
|
|
61
|
+
import { chatNodeOccupantOf, declaredSlotKind, registerChatNodeEntry } from './chat-node-registration.ts'
|
|
59
62
|
import { en as chatEn, zh as chatZh } from './chat-locales.ts'
|
|
60
63
|
import type { RollbackListResult, RollbackPointPort, RollbackPointSummary } from '../../ui/rollback-view.ts'
|
|
61
64
|
|
|
@@ -110,6 +113,52 @@ test('registration plan: a keyed declaration registers; anything else is skipped
|
|
|
110
113
|
assert.deepEqual(planChatNodeRegistration('list'), { kind: 'skip', reason: 'slot-kind-mismatch', declared: 'list' })
|
|
111
114
|
})
|
|
112
115
|
|
|
116
|
+
/**
|
|
117
|
+
* 让位判据(2026-10-15,跨插件冲突):keyed cell「最低的渲染」——别人 priority 更低时我们必定轮不到渲染,
|
|
118
|
+
* 所以**先读占用再决定注册**:更低 → 让位并告警(不抢格、不静默);相同(只可能是残留的自己)→ 照常注册
|
|
119
|
+
* (真撞上由 try/catch 兜住);宿主那条 priority 0 → 我们 -1 本来就要遮蔽它,当然注册。
|
|
120
|
+
*/
|
|
121
|
+
test('registration plan: yields when another renderer already owns the user cell at a LOWER priority', () => {
|
|
122
|
+
const host = { priority: 0, registrant: null }
|
|
123
|
+
const oursOwn = { priority: -1, registrant: 'dsh-config-manager' }
|
|
124
|
+
const theirs = { priority: -2, registrant: '@someone/other-rewind' }
|
|
125
|
+
assert.deepEqual(planChatNodeRegistration('keyed', host), { kind: 'register' })
|
|
126
|
+
assert.deepEqual(planChatNodeRegistration('keyed', oursOwn), { kind: 'register' })
|
|
127
|
+
assert.deepEqual(planChatNodeRegistration('keyed', theirs), {
|
|
128
|
+
kind: 'skip',
|
|
129
|
+
reason: 'shadowed',
|
|
130
|
+
declared: 'keyed',
|
|
131
|
+
occupant: theirs,
|
|
132
|
+
})
|
|
133
|
+
// 形态不符的判据优先于占用(按 keyed 参数注册本来就会抛)
|
|
134
|
+
assert.equal(planChatNodeRegistration('list', theirs).kind, 'skip')
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('occupant reader: reads the winning entry per cell from entriesOfSlot shape; anything else is null', () => {
|
|
138
|
+
const entry = (key: string, priority?: number, registrant?: string) => ({
|
|
139
|
+
options: { key, ...(priority === undefined ? {} : { priority }) },
|
|
140
|
+
...(registrant === undefined ? {} : { registrant }),
|
|
141
|
+
})
|
|
142
|
+
assert.equal(chatNodeUserOccupant(null), null)
|
|
143
|
+
assert.equal(chatNodeUserOccupant({}), null)
|
|
144
|
+
assert.equal(chatNodeUserOccupant([]), null)
|
|
145
|
+
assert.deepEqual(chatNodeUserOccupant([entry('agent-teams', -5)]), null, 'other keys are not our cell')
|
|
146
|
+
// 缺省 priority = 0(宿主缺省),registrant 读不到如实回 null(绝不猜)
|
|
147
|
+
assert.deepEqual(chatNodeUserOccupant([entry('user')]), { priority: 0, registrant: null })
|
|
148
|
+
// 多条同 key:取最低的那条(= 会被渲染的那条)
|
|
149
|
+
assert.deepEqual(
|
|
150
|
+
chatNodeUserOccupant([entry('user', 0, 'host'), entry('user', -2, 'other')]),
|
|
151
|
+
{ priority: -2, registrant: 'other' },
|
|
152
|
+
)
|
|
153
|
+
// 非数值 priority 当成缺省 0;空 registrant 归 null
|
|
154
|
+
assert.deepEqual(chatNodeUserOccupant([entry('user', Number.NaN, '')]), { priority: 0, registrant: null })
|
|
155
|
+
// registrant 也可能挂在 options 上(宿主两个位置都读过)
|
|
156
|
+
assert.deepEqual(chatNodeUserOccupant([{ options: { key: 'user', priority: -3, registrant: 'x' } }]), {
|
|
157
|
+
priority: -3,
|
|
158
|
+
registrant: 'x',
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
113
162
|
test('register shell: keyed declaration registers exactly once; non-keyed declaration is skipped, not thrown', () => {
|
|
114
163
|
const warn: string[] = []
|
|
115
164
|
let called = 0
|
|
@@ -136,6 +185,31 @@ test('register shell: keyed declaration registers exactly once; non-keyed declar
|
|
|
136
185
|
assert.equal(declaredSlotKind({ spec: () => { throw new Error('boom') } }), null)
|
|
137
186
|
})
|
|
138
187
|
|
|
188
|
+
test('register shell: yields (does NOT register) when a lower-priority renderer owns the user cell', () => {
|
|
189
|
+
const warn: string[] = []
|
|
190
|
+
let called = 0
|
|
191
|
+
const disposer = registerChatNodeEntry({
|
|
192
|
+
probe: {
|
|
193
|
+
spec: () => ({ kind: 'keyed', scope: 'session' }),
|
|
194
|
+
entriesOfSlot: () => [{ options: { key: 'user', priority: -2 }, registrant: '@someone/other-rewind' }],
|
|
195
|
+
},
|
|
196
|
+
register: () => { called += 1; return () => {} },
|
|
197
|
+
warn: (message) => { warn.push(message) },
|
|
198
|
+
})
|
|
199
|
+
assert.equal(called, 0, 'we must not fight for a cell another renderer already owns at a lower priority')
|
|
200
|
+
assert.equal(typeof disposer, 'function')
|
|
201
|
+
assert.ok(warn.some((line) => line.startsWith(CHAT_NODE_YIELD_WARN_PREFIX)), 'the yield must be logged, never silent')
|
|
202
|
+
assert.ok(warn.some((line) => line.includes('-2') && line.includes('@someone/other-rewind')))
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('occupant probe: missing entriesOfSlot / a throwing probe / a foreign shape all mean “nobody occupies it”', () => {
|
|
206
|
+
assert.equal(chatNodeOccupantOf(undefined), null)
|
|
207
|
+
assert.equal(chatNodeOccupantOf({ spec: () => ({ kind: 'keyed' }) }), null, 'old hosts expose no entriesOfSlot')
|
|
208
|
+
assert.equal(chatNodeOccupantOf({ spec: () => {}, entriesOfSlot: () => { throw new Error('boom') } }), null)
|
|
209
|
+
assert.equal(chatNodeOccupantOf({ spec: () => {}, entriesOfSlot: () => ({ not: 'an array' }) }), null)
|
|
210
|
+
assert.deepEqual(chatNodeOccupantOf({ spec: () => {}, entriesOfSlot: () => [{ options: { key: 'user', priority: 0 } }] }), { priority: 0, registrant: null })
|
|
211
|
+
})
|
|
212
|
+
|
|
139
213
|
test('register shell: a throwing register() is contained (never takes the plugin down)', () => {
|
|
140
214
|
const warn: string[] = []
|
|
141
215
|
const disposer = registerChatNodeEntry({
|
|
@@ -204,6 +278,95 @@ test('样式纪律:icon 入口与预览文件行只用既有 token/类,未
|
|
|
204
278
|
}
|
|
205
279
|
})
|
|
206
280
|
|
|
281
|
+
/**
|
|
282
|
+
* 底色必须跟随宿主(2026-10-15 用户反馈「浅色主题下用户消息气泡看不见」)。
|
|
283
|
+
*
|
|
284
|
+
* 背景:接管 user 渲染器后我们自复刻那条气泡,底色曾写 --cm-surface-2(= --dsw-alias-bg-layer-2)——
|
|
285
|
+
* 浅色主题下它 = #fff,而页面底 --dsw-alias-bg-base 也是 #fff ⇒ 与页面同色(对比度 0);
|
|
286
|
+
* 宿主真机用的是 --dsw-specific-bubble(浅色 = --dsw-static-deepseek-50 = #edf3fe)。
|
|
287
|
+
* 深色下两者恰好同值,所以这条差距当时被误判成「可接受的近似档」。
|
|
288
|
+
*/
|
|
289
|
+
test('气泡底色跟随宿主的 --dsw-specific-bubble(不得再用 --cm-surface-2 近似)', () => {
|
|
290
|
+
const cssSheet = readFileSync(join(HERE, '..', 'config-manager.module.css'), 'utf8')
|
|
291
|
+
// ① 定义行:宿主 token + **可见**兜底(无兜底的 var() 在宿主删档时会整条失效 → transparent;
|
|
292
|
+
// 兜成 bg-layer-2 也没用 —— 浅色下它同样是 #fff,气泡照样消失)
|
|
293
|
+
const defs = cssSheet.match(/--cm-chat-bubble:\s*var\(--dsw-specific-bubble,\s*color-mix\(in srgb, var\(--cm-text-1\) 12%, transparent\)\)/g) ?? []
|
|
294
|
+
assert.ok(defs.length >= 2, '--cm-chat-bubble 必须在 §1 body 与 .chatEntryScope 各定义一次(且兜底是可见色),实际 ' + String(defs.length) + ' 处')
|
|
295
|
+
assert.equal(
|
|
296
|
+
(cssSheet.match(/--cm-chat-bubble:[^;]*--dsw-alias-bg-layer-2/g) ?? []).length,
|
|
297
|
+
0,
|
|
298
|
+
'兜底不得用 --dsw-alias-bg-layer-2(浅色下 = #fff ⇒ 等于没兜底)',
|
|
299
|
+
)
|
|
300
|
+
// ② 消费点:.chatBubble 只写 --cm-*(三层单向依赖),且不得再回到 --cm-surface-2
|
|
301
|
+
const bubble = cssSheet.match(/\.chatBubble\s*\{[^}]*\}/)?.[0] ?? ''
|
|
302
|
+
assert.match(bubble, /background:\s*var\(--cm-chat-bubble\)/, '.chatBubble 的底色必须消费 --cm-chat-bubble')
|
|
303
|
+
assert.doesNotMatch(bubble, /--cm-surface-2|--dsw-/, '.chatBubble 不得再用 --cm-surface-2 近似、也不得裸写 --dsw-*')
|
|
304
|
+
// ③ 作用域重声明:聊天流不在插件设置页子树内
|
|
305
|
+
const scope = cssSheet.match(/\.chatEntryScope\s*\{[^}]*\}/)?.[0] ?? ''
|
|
306
|
+
assert.match(scope, /--cm-chat-bubble:\s*var\(--dsw-specific-bubble/, '.chatEntryScope 必须就地重声明 --cm-chat-bubble')
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
/** 文件卡描边同轮对齐(2026-10-15):宿主 = `.5px solid --dsw-alias-border-l2`,我们此前用更淡的 border-l1。 */
|
|
310
|
+
test('文件卡描边与宿主同档:.5px + --cm-line-2(不是更淡的 --cm-line-1)', () => {
|
|
311
|
+
const cssSheet = readFileSync(join(HERE, '..', 'config-manager.module.css'), 'utf8')
|
|
312
|
+
// 注释里会出现 token 名(解释为什么改),负向断言必须先剥注释 —— 与 css-token-guard.test.ts 同法
|
|
313
|
+
const card = cssSheet.replace(/\/\*[\s\S]*?\*\//g, '').match(/\.chatFileCard\s*\{[^}]*\}/)?.[0] ?? ''
|
|
314
|
+
assert.match(card, /border:\s*0\.5px solid var\(--cm-line-2\)/, '文件卡描边必须与宿主一致:.5px + --cm-line-2')
|
|
315
|
+
assert.doesNotMatch(card, /--cm-line-1/, '文件卡描边不得再退回更淡的 --cm-line-1')
|
|
316
|
+
const scope = cssSheet.match(/\.chatEntryScope\s*\{[^}]*\}/)?.[0] ?? ''
|
|
317
|
+
assert.match(scope, /--cm-line-2:\s*var\(--dsw-alias-border-l2\)/, '.chatEntryScope 必须就地重声明 --cm-line-2')
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 宿主正文投影(2026-10-15):气泡正文优先交给宿主自己的 projectUserText(会话引用/技能 → chip)。
|
|
322
|
+
* 四条回落判据必须逐条可证 —— 任一条不成立都回落我们自己的纯文本投影(绝不空白、绝不抛)。
|
|
323
|
+
*/
|
|
324
|
+
test('host text projection: four fallback criteria, and every argument goes through verbatim', () => {
|
|
325
|
+
const calls: unknown[][] = []
|
|
326
|
+
// 返回值用字符串(合法的 ReactNode)—— 纯层只要求「非空节点」,chip 的真实形状由宿主决定
|
|
327
|
+
const projector = (text: string, labels: readonly string[], skills: readonly string[], kind: string, references?: { openFile?: unknown; openSkill?: unknown }): string => {
|
|
328
|
+
calls.push([text, labels, skills, kind, references])
|
|
329
|
+
return '[chip]'
|
|
330
|
+
}
|
|
331
|
+
const options = { sessionLabels: ['会话甲'], skillNames: ['i-have-adhd'], openFile: 'of', openSkill: 'os' }
|
|
332
|
+
assert.deepEqual(projectChatEntryTextWithHost(projector, 'raw', options), '[chip]')
|
|
333
|
+
assert.deepEqual(calls[0], ['raw', ['会话甲'], ['i-have-adhd'], 'skill', { openFile: 'of', openSkill: 'os' }])
|
|
334
|
+
// ① 没有投影器(老宿主模块表里没有 ui-primitives)
|
|
335
|
+
assert.equal(projectChatEntryTextWithHost(null, 'raw', options), null)
|
|
336
|
+
assert.equal(projectChatEntryTextWithHost(undefined, 'raw', options), null)
|
|
337
|
+
// ② 原文为空
|
|
338
|
+
assert.equal(projectChatEntryTextWithHost(projector, '', options), null)
|
|
339
|
+
// ③ 投影自己抛错
|
|
340
|
+
assert.equal(projectChatEntryTextWithHost(() => { throw new Error('boom') }, 'raw', options), null)
|
|
341
|
+
// ④ 投影拒绝了这段文本(null / undefined / false)
|
|
342
|
+
assert.equal(projectChatEntryTextWithHost(() => null, 'raw', options), null)
|
|
343
|
+
assert.equal(projectChatEntryTextWithHost(() => undefined, 'raw', options), null)
|
|
344
|
+
assert.equal(projectChatEntryTextWithHost(() => false, 'raw', options), null)
|
|
345
|
+
// 缺省选项也要把完整参数交给投影器(空数组 + 空打开器),绝不缺参
|
|
346
|
+
calls.length = 0
|
|
347
|
+
assert.deepEqual(projectChatEntryTextWithHost(projector, 'x'), '[chip]')
|
|
348
|
+
assert.deepEqual(calls[0], ['x', [], [], 'skill', { openFile: undefined, openSkill: undefined }])
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test('bubble keeps the UNprojected text for the host projector and the projected text as the fallback', () => {
|
|
352
|
+
const view = buildChatEntryBubble([{ type: 'text', text: '看这条 @[会话甲](dsh-session:s9) 引用' }])
|
|
353
|
+
assert.equal(view.text, '看这条 会话甲 引用')
|
|
354
|
+
assert.equal(view.rawText, '看这条 @[会话甲](dsh-session:s9) 引用')
|
|
355
|
+
// 多个文本块:两边都按 content 顺序拼接(非文本块不参与拼接)
|
|
356
|
+
const multi = buildChatEntryBubble([
|
|
357
|
+
{ type: 'text', text: 'A' },
|
|
358
|
+
{ type: 'image', attachment: { id: 'i' } },
|
|
359
|
+
{ type: 'text', text: 'B' },
|
|
360
|
+
])
|
|
361
|
+
assert.equal(multi.text, 'AB')
|
|
362
|
+
assert.equal(multi.rawText, 'AB')
|
|
363
|
+
// 怪形状:字符串 content 两边都有值;空 content 两边都是空串(不编造)
|
|
364
|
+
assert.deepEqual([buildChatEntryBubble('hi').text, buildChatEntryBubble('hi').rawText], ['hi', 'hi'])
|
|
365
|
+
const empty = buildChatEntryBubble(null)
|
|
366
|
+
assert.equal(empty.text, '')
|
|
367
|
+
assert.equal(empty.rawText, '')
|
|
368
|
+
})
|
|
369
|
+
|
|
207
370
|
/* ------------------------------------------------------------ ⑤ 回合号只认可核实字段 */
|
|
208
371
|
|
|
209
372
|
test('turn number: only node.location.kind turn|step counts (same read as the host turnTail)', () => {
|
|
@@ -45,20 +45,63 @@ export const CHAT_NODE_USER_KEY = 'user'
|
|
|
45
45
|
*/
|
|
46
46
|
export const CHAT_NODE_PRIORITY = -1
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* 同一个 cell(key=user)当前**会被渲染的那一条**(= priority 最小的那一条)。
|
|
50
|
+
*
|
|
51
|
+
* 为什么要读它(2026-10-15,跨插件冲突排查的落地):`conversation.chat.node` 是 keyed 遮蔽位,
|
|
52
|
+
* 「最低的渲染」——别的插件若也用 key=user 且 priority 比我们更低(≤ -2),它会赢而我们**静默失效**
|
|
53
|
+
* (框架不报错)。所以我们在注册**之前**读一次占用:赢家比我们更低就让位并告警,不去抢。
|
|
54
|
+
*/
|
|
55
|
+
export interface ChatNodeOccupant {
|
|
56
|
+
/** 该条的 priority(宿主缺省 0)。 */
|
|
57
|
+
priority: number
|
|
58
|
+
/** 诊断用标识(宿主用 fiber 名盖的戳);读不到回 null,**绝不猜**。 */
|
|
59
|
+
registrant: string | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 从 `entriesOfSlot('conversation.chat.node')` 的返回值里读 user cell 的赢家(形状不符一律 null)。 */
|
|
63
|
+
export function chatNodeUserOccupant(entries: unknown): ChatNodeOccupant | null {
|
|
64
|
+
if (!Array.isArray(entries)) return null
|
|
65
|
+
let winner: ChatNodeOccupant | null = null
|
|
66
|
+
for (const entry of entries) {
|
|
67
|
+
const record = asRecord(entry)
|
|
68
|
+
const options = asRecord(record?.['options'])
|
|
69
|
+
if (options === null || options['key'] !== CHAT_NODE_USER_KEY) continue
|
|
70
|
+
const raw = options['priority']
|
|
71
|
+
const priority = typeof raw === 'number' && Number.isFinite(raw) ? raw : 0
|
|
72
|
+
const label = record?.['registrant'] ?? options['registrant']
|
|
73
|
+
const registrant = typeof label === 'string' && label !== '' ? label : null
|
|
74
|
+
if (winner === null || priority < winner.priority) winner = { priority, registrant }
|
|
75
|
+
}
|
|
76
|
+
return winner
|
|
77
|
+
}
|
|
78
|
+
|
|
48
79
|
/**
|
|
49
80
|
* 槽位登记计划:**只在 0.2 的 keyed 形态时接管**(2026-10-12:入口只剩「每条用户消息」一种形态,
|
|
50
81
|
* 旧的「整回合」档与偏好门已随 chat-entry-pref / chat-entry-mount 一起删除)。
|
|
51
82
|
*
|
|
52
83
|
* 跳过必须是**失败软化**(只丢这一个入口,不动本插件其余槽位、不动宿主启动):
|
|
53
84
|
* · `slot-kind-mismatch`:0.1.x 的 ui-chat 把同一 key 声明成别的形态(对照 turnTail 的
|
|
54
|
-
* list/chain 双形态实测),按 keyed 参数(key/priority
|
|
85
|
+
* list/chain 双形态实测),按 keyed 参数(key/priority)注册会抛;
|
|
86
|
+
* · `shadowed`(2026-10-15):user cell 已被**更低优先级**的条目占住 —— 我们注册也永远轮不到渲染
|
|
87
|
+
* (「最低的渲染」),不如让位并如实告警(不静默、也不去抢别人的格子)。
|
|
88
|
+
*
|
|
89
|
+
* 注意 `occupant.priority === CHAT_NODE_PRIORITY` **不算遮蔽**:同一 cell 同 priority 只可能有一条
|
|
90
|
+
* (再注册会抛,由登记处的 try/catch 兜住),所以那时该试的是注册而不是让位。
|
|
55
91
|
*/
|
|
56
92
|
export type ChatNodeRegistrationPlan =
|
|
57
93
|
| { kind: 'register' }
|
|
58
94
|
| { kind: 'skip'; reason: 'slot-kind-mismatch'; declared: string | null }
|
|
95
|
+
| { kind: 'skip'; reason: 'shadowed'; declared: string | null; occupant: ChatNodeOccupant }
|
|
59
96
|
|
|
60
|
-
export function planChatNodeRegistration(
|
|
97
|
+
export function planChatNodeRegistration(
|
|
98
|
+
declaredKind: string | null,
|
|
99
|
+
occupant: ChatNodeOccupant | null = null,
|
|
100
|
+
): ChatNodeRegistrationPlan {
|
|
61
101
|
if (declaredKind !== 'keyed') return { kind: 'skip', reason: 'slot-kind-mismatch', declared: declaredKind }
|
|
102
|
+
if (occupant !== null && occupant.priority < CHAT_NODE_PRIORITY) {
|
|
103
|
+
return { kind: 'skip', reason: 'shadowed', declared: declaredKind, occupant }
|
|
104
|
+
}
|
|
62
105
|
return { kind: 'register' }
|
|
63
106
|
}
|
|
64
107
|
|
|
@@ -151,6 +194,11 @@ export type ChatEntryAttachment =
|
|
|
151
194
|
export interface ChatEntryBubbleView {
|
|
152
195
|
/** 文本块拼接结果(宿主 `texts.join('')`,逐字保留换行与空白;会话引用的线格式已投影成 label)。 */
|
|
153
196
|
text: string
|
|
197
|
+
/**
|
|
198
|
+
* **未投影**的文本拼接(会话线格式原样保留)—— 只给宿主 `projectUserText` 用:
|
|
199
|
+
* 它自己认 `@[label](dsh-session:…)` 并把引用/技能渲染成 chip;我们自己的 `text` 是它的兜底。
|
|
200
|
+
*/
|
|
201
|
+
rawText: string
|
|
154
202
|
/** **有序附件列表**:渲染层必须按这个顺序输出(宿主不区分「先图片后文件」)。 */
|
|
155
203
|
attachments: ChatEntryAttachment[]
|
|
156
204
|
/** 图片附件(= `attachments` 里的 image 项,顺序一致;用于计数与「无渲染位」提示)。 */
|
|
@@ -244,11 +292,12 @@ export function formatChatEntryClock(time: number | null): string | null {
|
|
|
244
292
|
export const CHAT_SESSION_WIRE_RE = /@\[([^\]\n]+)\]\(dsh-session:[^)\s]+\)/gu
|
|
245
293
|
|
|
246
294
|
/**
|
|
247
|
-
*
|
|
295
|
+
* 正文的**最小文本投影**(宿主投影不可用时的兜底):把会话引用线格式换成它的人类可读 label。
|
|
248
296
|
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
297
|
+
* 2026-10-15 起:气泡正文**优先走宿主自己的** `projectUserText`(见 `projectChatEntryTextWithHost`
|
|
298
|
+
* —— 它能把会话引用与技能渲染成 chip,并在给了打开器时可点开);本函数是**兜底与纯文本路径**
|
|
299
|
+
* (复制按钮、错误边界降级、老宿主没有原语包时)的唯一实现:任何情况下都**不把线格式原样打给用户看**
|
|
300
|
+
* (那是机器的编码,正常人读不了 —— t4 复核 D5,medium)。
|
|
252
301
|
* 与宿主一致的边界:只认 `dsh-session:` 的括号形式;`g` flag 下逐处替换,正则自身 lastIndex 置零。
|
|
253
302
|
*/
|
|
254
303
|
export function projectChatEntryText(text: string): string {
|
|
@@ -257,6 +306,61 @@ export function projectChatEntryText(text: string): string {
|
|
|
257
306
|
return text.replace(CHAT_SESSION_WIRE_RE, '$1')
|
|
258
307
|
}
|
|
259
308
|
|
|
309
|
+
/**
|
|
310
|
+
* 宿主 `projectUserText` 的**可注入面**(宽类型:本仓库不引用 ui-primitives 的类型,也不 import 它)。
|
|
311
|
+
* 返回值是不透明 ReactNode(chip 节点等);缺省 null = 这台宿主没有该能力。
|
|
312
|
+
*/
|
|
313
|
+
export type ChatEntryTextProjector = (
|
|
314
|
+
text: string,
|
|
315
|
+
sessionLabels: readonly string[],
|
|
316
|
+
slashNames: readonly string[],
|
|
317
|
+
slashKind: string,
|
|
318
|
+
references?: { openFile?: unknown; openSkill?: unknown },
|
|
319
|
+
) => ReactNode
|
|
320
|
+
|
|
321
|
+
/** 宿主投影的入参(全部来自节点 data 与 owner props;缺省即空)。 */
|
|
322
|
+
export interface ChatEntryHostTextOptions {
|
|
323
|
+
/** `data.referenceLabels`(引用会话的 label 列表)。 */
|
|
324
|
+
sessionLabels?: readonly string[]
|
|
325
|
+
/** `data.skillNames`(宿主把它们渲染成 chip —— 我们此前一个元素都不产出)。 */
|
|
326
|
+
skillNames?: readonly string[]
|
|
327
|
+
/** 宿主给的文件 / 技能打开器(给了才能点开 chip;没有就只显示 chip 外观)。 */
|
|
328
|
+
openFile?: unknown
|
|
329
|
+
openSkill?: unknown
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 用**宿主自己**的 `projectUserText` 投影正文(2026-10-15:按生态既有做法补齐 G-39 差距①)。
|
|
334
|
+
*
|
|
335
|
+
* 实证(为什么这条现在成立):ui-primitives 由 DSH shell 的模块表作为 static module seed 预置,
|
|
336
|
+
* 插件 bundle 的 `require` 能命中 —— 第三方插件 dsh-reedit 就是这么拿 `projectUserText` 的,
|
|
337
|
+
* 本仓的 `RollbackFileIcon` 也早就这样拿 `FileTypeIcon`。**只能帧内惰取**(顶层 import 在老宿主
|
|
338
|
+
* 会打挂整个浏览器半加载),入口在 `common/host-primitives.ts`。
|
|
339
|
+
*
|
|
340
|
+
* 四条判据缺一不可(本函数**绝不抛**,任一条不满足就回 null ⇒ 调用方回落 `projectChatEntryText`):
|
|
341
|
+
* ① 有投影器(老宿主没有原语包 → null);② 原文非空;
|
|
342
|
+
* ③ 投影不抛错;④ 投影返回**非空节点**(null/undefined/false = 宿主拒绝了这段文本)。
|
|
343
|
+
*
|
|
344
|
+
* 为什么投影要当参数传进来:纯层与单测都不认识模块表 —— 抽成参数后,上面四条判据能逐条覆盖。
|
|
345
|
+
*/
|
|
346
|
+
export function projectChatEntryTextWithHost(
|
|
347
|
+
projector: ChatEntryTextProjector | null | undefined,
|
|
348
|
+
rawText: string,
|
|
349
|
+
options: ChatEntryHostTextOptions = {},
|
|
350
|
+
): ReactNode | null {
|
|
351
|
+
if (typeof projector !== 'function') return null
|
|
352
|
+
if (rawText === '') return null
|
|
353
|
+
try {
|
|
354
|
+
const node = projector(rawText, options.sessionLabels ?? [], options.skillNames ?? [], 'skill', {
|
|
355
|
+
openFile: options.openFile,
|
|
356
|
+
openSkill: options.openSkill,
|
|
357
|
+
})
|
|
358
|
+
return node === null || node === undefined || node === false ? null : node
|
|
359
|
+
} catch {
|
|
360
|
+
return null
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
260
364
|
/**
|
|
261
365
|
* 扩展名 → 文件卡上的短徽标:宿主 `fileExtension` + 宿主那行 meta 的同一截断
|
|
262
366
|
* (`fileExtension(name).toUpperCase().slice(0, 8)`);后缀为空(无点 / 点在末尾)回 null。
|
|
@@ -310,12 +414,13 @@ export function fileSizeTextOf(bytes: number | null): string | null {
|
|
|
310
414
|
export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
|
|
311
415
|
if (typeof content === 'string') {
|
|
312
416
|
const text = projectChatEntryText(content)
|
|
313
|
-
return { text, attachments: [], images: [], files: [], raw: [], showBubble: text !== '' }
|
|
417
|
+
return { text, rawText: content, attachments: [], images: [], files: [], raw: [], showBubble: text !== '' }
|
|
314
418
|
}
|
|
315
419
|
if (!Array.isArray(content)) {
|
|
316
|
-
return { text: '', attachments: [], images: [], files: [], raw: content === null || content === undefined ? [] : [{ id: '0', payload: content }], showBubble: content !== null && content !== undefined }
|
|
420
|
+
return { text: '', rawText: '', attachments: [], images: [], files: [], raw: content === null || content === undefined ? [] : [{ id: '0', payload: content }], showBubble: content !== null && content !== undefined }
|
|
317
421
|
}
|
|
318
422
|
const texts: string[] = []
|
|
423
|
+
const rawTexts: string[] = []
|
|
319
424
|
const attachments: ChatEntryAttachment[] = []
|
|
320
425
|
const raw: ChatEntryRawBlock[] = []
|
|
321
426
|
content.forEach((block, index) => {
|
|
@@ -323,6 +428,8 @@ export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
|
|
|
323
428
|
const type = record?.['type']
|
|
324
429
|
if (type === 'text' && typeof record?.['text'] === 'string') {
|
|
325
430
|
// 宿主把这条文本交给 projectUserText(会话引用渲染成 chip);我们做**最小文本投影**:线格式 → label
|
|
431
|
+
// (rawTexts 同时留一份未投影原文,宿主投影可用时优先用它 —— 见 ChatEntryBubbleView.rawText)
|
|
432
|
+
rawTexts.push(record['text'] as string)
|
|
326
433
|
texts.push(projectChatEntryText(record['text'] as string))
|
|
327
434
|
return
|
|
328
435
|
}
|
|
@@ -347,10 +454,11 @@ export function buildChatEntryBubble(content: unknown): ChatEntryBubbleView {
|
|
|
347
454
|
raw.push({ id: String(index), payload: block })
|
|
348
455
|
})
|
|
349
456
|
const text = texts.join('')
|
|
457
|
+
const rawText = rawTexts.join('')
|
|
350
458
|
// images / files 是 attachments 的**派生视图**(不独立积累,避免两处真相与顺序漂移)
|
|
351
459
|
const images = attachments.flatMap((item) => (item.kind === 'image' ? [item.image] : []))
|
|
352
460
|
const files = attachments.flatMap((item) => (item.kind === 'file' ? [item.file] : []))
|
|
353
|
-
return { text, attachments, images, files, raw, showBubble: text !== '' || raw.length > 0 }
|
|
461
|
+
return { text, rawText, attachments, images, files, raw, showBubble: text !== '' || raw.length > 0 }
|
|
354
462
|
}
|
|
355
463
|
|
|
356
464
|
/** 渲染计划的一项:图片带 owner 载荷,文件带名称/扩展名/体积文本。 */
|
|
@@ -517,7 +625,10 @@ export interface ChatEntryView {
|
|
|
517
625
|
bubble: ChatEntryBubbleView
|
|
518
626
|
/** 动作行的时间文本(null = 节点没给可用的时间戳)。 */
|
|
519
627
|
clock: string | null
|
|
520
|
-
/**
|
|
628
|
+
/**
|
|
629
|
+
* 宿主 data 的另外两个字段:`referenceLabels` 进引用汇总行;两者都作为 `projectUserText`
|
|
630
|
+
* 的入参传给**宿主的**正文投影(2026-10-15 起 —— 此前它们只是「如实记下的差距」)。
|
|
631
|
+
*/
|
|
521
632
|
referenceLabels: string[]
|
|
522
633
|
skillNames: string[]
|
|
523
634
|
}
|
|
@@ -600,6 +711,12 @@ export function rawBlockText(payload: unknown): string {
|
|
|
600
711
|
/** 错误边界的 ASCII 诊断前缀(console.* 实参不触发 i18n 守卫;界面文案一律在字典里)。 */
|
|
601
712
|
export const CHAT_ENTRY_WARN_PREFIX = '[config-manager] chat user node renderer degraded to plain text: '
|
|
602
713
|
|
|
714
|
+
/**
|
|
715
|
+
* 让位告警的 ASCII 前缀(诊断用,绝不当作界面文案)—— 见 `planChatNodeRegistration` 的 `shadowed`:
|
|
716
|
+
* 别的插件用更低优先级占住了 key=user 时,我们**不注册**(注册也永远轮不到渲染),只留这条日志。
|
|
717
|
+
*/
|
|
718
|
+
export const CHAT_NODE_YIELD_WARN_PREFIX = '[config-manager] chat user node entry yielded: another renderer owns key "user" at a lower priority: '
|
|
719
|
+
|
|
603
720
|
/* ------------------------------------------------------------ 回滚点惰取(按会话缓存 + 在途去重) */
|
|
604
721
|
|
|
605
722
|
/** 缓存 TTL:同一会话里的所有按钮共用一次列表读取;跨分钟自然过期(列表变化由宿主写,非热点)。 */
|
|
@@ -9,11 +9,23 @@
|
|
|
9
9
|
* (`chat-entry-pref` / `chat-entry-mount`)与回合尾入口(`conversation.chat.turnTail`)一并删除 ——
|
|
10
10
|
* 入口恒为每条用户消息气泡里的那一颗,于是这里不再有偏好门,也不再需要「切档时重新评估」的订阅。
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
CHAT_NODE_SLOT,
|
|
14
|
+
CHAT_NODE_YIELD_WARN_PREFIX,
|
|
15
|
+
chatNodeUserOccupant,
|
|
16
|
+
planChatNodeRegistration,
|
|
17
|
+
type ChatNodeOccupant,
|
|
18
|
+
} from './chat-entry-view.ts'
|
|
13
19
|
|
|
14
|
-
/**
|
|
20
|
+
/** 槽位探测面(`ctx.slots`):读声明的 kind,以及该 cell 当前会被渲染的那一条。 */
|
|
15
21
|
export interface ChatNodeSlotProbe {
|
|
22
|
+
/** 只读 spec(宽类型读回 kind)。 */
|
|
16
23
|
spec: (key: string) => unknown
|
|
24
|
+
/**
|
|
25
|
+
* 每个 cell 当前会被渲染的那一条(SlotRegistry.entriesOfSlot)。
|
|
26
|
+
* **可选**:老宿主没有这个方法(那时不做占用探测,照常注册)。
|
|
27
|
+
*/
|
|
28
|
+
entriesOfSlot?: (key: string) => unknown
|
|
17
29
|
}
|
|
18
30
|
|
|
19
31
|
/** 诊断(ASCII,与界面文案无关;console.* 实参不触发 i18n 守卫)。 */
|
|
@@ -33,12 +45,24 @@ export function declaredSlotKind(probe: ChatNodeSlotProbe | undefined | null): s
|
|
|
33
45
|
}
|
|
34
46
|
}
|
|
35
47
|
|
|
48
|
+
/** user cell 的当前赢家(没有探测面 / 形状不符 / 探测抛错都回 null —— 探测失败不当成「有人占」)。 */
|
|
49
|
+
export function chatNodeOccupantOf(probe: ChatNodeSlotProbe | undefined | null): ChatNodeOccupant | null {
|
|
50
|
+
if (probe === undefined || probe === null || typeof probe.entriesOfSlot !== 'function') return null
|
|
51
|
+
try {
|
|
52
|
+
return chatNodeUserOccupant(probe.entriesOfSlot(CHAT_NODE_SLOT))
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
36
58
|
/**
|
|
37
59
|
* 登记「接管 conversation.chat.node / user」的渲染器。
|
|
38
60
|
*
|
|
39
|
-
*
|
|
61
|
+
* 三道门(顺序即优先级):
|
|
40
62
|
* ① 声明形态不是 `keyed`(0.1.x 的 ui-chat 契约不同)→ 跳过(按 keyed 参数注册会抛);
|
|
41
|
-
* ②
|
|
63
|
+
* ② **user cell 已被更低优先级占住**(别的插件也在接管用户消息,2026-10-15)→ 让位并如实告警:
|
|
64
|
+
* 注册也永远轮不到渲染(keyed cell「最低的渲染」),不抢别人的格子、也不静默;
|
|
65
|
+
* ③ 注册本身抛错 → 吞掉并告警(只丢这一个入口)。
|
|
42
66
|
*
|
|
43
67
|
* @returns disposer;跳过 / 失败时是 no-op。
|
|
44
68
|
*/
|
|
@@ -51,8 +75,18 @@ export function registerChatNodeEntry(options: {
|
|
|
51
75
|
}): () => void {
|
|
52
76
|
const warn = options.warn ?? warnChatNode
|
|
53
77
|
const declared = declaredSlotKind(options.probe)
|
|
54
|
-
|
|
55
|
-
|
|
78
|
+
const plan = planChatNodeRegistration(declared, chatNodeOccupantOf(options.probe))
|
|
79
|
+
if (plan.kind === 'skip') {
|
|
80
|
+
if (plan.reason === 'shadowed') {
|
|
81
|
+
warn(
|
|
82
|
+
CHAT_NODE_YIELD_WARN_PREFIX +
|
|
83
|
+
'priority ' + String(plan.occupant.priority) +
|
|
84
|
+
(plan.occupant.registrant === null ? '' : ' (registered by ' + plan.occupant.registrant + ')') +
|
|
85
|
+
' — entry not registered',
|
|
86
|
+
)
|
|
87
|
+
} else {
|
|
88
|
+
warn('[config-manager] chat user node entry skipped: slot declaration is ' + (declared === null ? 'missing' : '"' + declared + '"') + ' (expected "keyed")')
|
|
89
|
+
}
|
|
56
90
|
return () => {}
|
|
57
91
|
}
|
|
58
92
|
try {
|
|
@@ -13,18 +13,20 @@
|
|
|
13
13
|
* · 内容栏只在增删行上着色(`.diffCellAdd` / `.diffCellDel`,12% 混色),上下文行不上色;
|
|
14
14
|
* · 两栏之间一条竖分割线(块头与正文都要有)。
|
|
15
15
|
*
|
|
16
|
+
* 规模:**全部画出来,不截断**(2026-10-15 用户:「为什么最多只能加载 400 行?我需要能完整的加载」)。
|
|
17
|
+
* 到这里的 hunks 已经是宿主给的**全部**内容(单文件字节上限在宿主侧:回滚预览 1 MiB、恢复计划 maxSideBytes),
|
|
18
|
+
* 渲染层再砍一刀只会让同一份数据在界面上少半截,而用户没有任何办法看到剩下的
|
|
19
|
+
*(2026-10-14 起那道「最多 400 行对」的上限已删除,连文案一起去掉)。
|
|
20
|
+
*
|
|
16
21
|
* 分工:行对对齐 / 块头 / 行号列宽 / 行数统计全来自 `src/ui/diff-view.ts`(纯函数、node 单测);本组件只画。
|
|
17
22
|
* 安全:单元格文本是磁盘 / 宿主原文 ⇒ 展示前**必须**过调用方传进来的 sanitize(本该是 redact)。
|
|
18
23
|
*/
|
|
19
24
|
import { Fragment } from 'react'
|
|
20
25
|
import { redact } from '../../security/redaction.ts'
|
|
21
|
-
import {
|
|
26
|
+
import { diffLineCounts, maxLineDigits, toSideBySide, type SideBySideRow } from '../../ui/diff-view.ts'
|
|
22
27
|
import type { DiffHunk } from '../../utils/line-diff.ts'
|
|
23
28
|
import css from '../config-manager.module.css'
|
|
24
29
|
|
|
25
|
-
/** 单文件最多渲染多少行对:diff 上限 6000 行,全量渲染会卡住弹窗(数据完整,只是没画出来)。 */
|
|
26
|
-
export const DIFF_ROW_CAP = 400
|
|
27
|
-
|
|
28
30
|
export interface DiffTableProps {
|
|
29
31
|
hunks: readonly DiffHunk[]
|
|
30
32
|
/** 左侧列头(before = 当前) */
|
|
@@ -36,12 +38,8 @@ export interface DiffTableProps {
|
|
|
36
38
|
* 调用方**已在别处写过这份路径**(恢复计划行、聊天预览的路径行)时**不要传** —— 同一路径不画两遍。
|
|
37
39
|
*/
|
|
38
40
|
title?: string
|
|
39
|
-
/** 被渲染上限截断时的说明文案(由调用方按自己的字典出话;缺省 = 不提示) */
|
|
40
|
-
truncatedNotice?: (shown: number, total: number) => string
|
|
41
41
|
/** 单元格文本安全化(**缺省就是 redact**:调用方只能换成别的脱敏器,换不成不脱敏) */
|
|
42
42
|
sanitize?: (text: string) => string
|
|
43
|
-
/** 渲染上限(缺省 DIFF_ROW_CAP) */
|
|
44
|
-
rowCap?: number
|
|
45
43
|
}
|
|
46
44
|
|
|
47
45
|
/** 左侧单元格:成对修改 / 纯删除 → 红底;上下文 → 中性。 */
|
|
@@ -65,17 +63,13 @@ function rightNoClass(kind: SideBySideRow['kind']): string {
|
|
|
65
63
|
}
|
|
66
64
|
|
|
67
65
|
/** 左右双栏逐行对照(文件头 + 表头 + 块头 + 行号 + 单元格)。 */
|
|
68
|
-
export function DiffTable({ hunks, beforeLabel, afterLabel, title,
|
|
66
|
+
export function DiffTable({ hunks, beforeLabel, afterLabel, title, sanitize }: DiffTableProps) {
|
|
69
67
|
const cell = sanitize ?? ((text: string): string => redact(text))
|
|
70
68
|
const groups = toSideBySide(hunks)
|
|
71
|
-
const total = countSideRows(groups)
|
|
72
69
|
const counts = diffLineCounts(hunks)
|
|
73
70
|
// 行号列宽 = 实际出现的最大行号位数(1–3/4/5/6+ 位四档 ch),内容列平分剩余空间
|
|
74
71
|
const digits = maxLineDigits(hunks)
|
|
75
72
|
const noColClass = digits <= 3 ? css.diffNoColW3 : digits === 4 ? css.diffNoColW4 : digits === 5 ? css.diffNoColW5 : css.diffNoColW6
|
|
76
|
-
let rendered = 0
|
|
77
|
-
const capped = total > rowCap
|
|
78
|
-
const notice = capped && truncatedNotice !== undefined ? truncatedNotice(rowCap, total) : null
|
|
79
73
|
return (
|
|
80
74
|
<>
|
|
81
75
|
{title !== undefined && (
|
|
@@ -88,7 +82,6 @@ export function DiffTable({ hunks, beforeLabel, afterLabel, title, truncatedNoti
|
|
|
88
82
|
</span>
|
|
89
83
|
</div>
|
|
90
84
|
)}
|
|
91
|
-
{notice !== null && <div className={css.hint}>{notice}</div>}
|
|
92
85
|
<div className={css.diffScroll}>
|
|
93
86
|
<table className={css.diffTable}>
|
|
94
87
|
<colgroup>
|
|
@@ -104,17 +97,14 @@ export function DiffTable({ hunks, beforeLabel, afterLabel, title, truncatedNoti
|
|
|
104
97
|
</tr>
|
|
105
98
|
</thead>
|
|
106
99
|
<tbody>
|
|
107
|
-
{groups.map((group) =>
|
|
108
|
-
|
|
109
|
-
rendered += rows.length
|
|
110
|
-
return (
|
|
111
|
-
<Fragment key={group.header}>
|
|
100
|
+
{groups.map((group) => (
|
|
101
|
+
<Fragment key={group.header}>
|
|
112
102
|
{/* 块头:两栏各一格、同一段文本(编辑器式单文件 diff 的观感),中间那条竖线由 CSS 补 */}
|
|
113
103
|
<tr className={css.diffHunkRow}>
|
|
114
104
|
<td colSpan={2}>{group.header}</td>
|
|
115
105
|
<td colSpan={2}>{group.header}</td>
|
|
116
106
|
</tr>
|
|
117
|
-
{rows.map((row, index) => (
|
|
107
|
+
{group.rows.map((row, index) => (
|
|
118
108
|
<tr key={group.header + '-' + String(index)}>
|
|
119
109
|
<td className={leftNoClass(row.kind)}>{row.left?.oldNo ?? ''}</td>
|
|
120
110
|
<td className={leftCellClass(row.kind)}>{row.left === null ? '' : cell(row.left.text)}</td>
|
|
@@ -122,9 +112,8 @@ export function DiffTable({ hunks, beforeLabel, afterLabel, title, truncatedNoti
|
|
|
122
112
|
<td className={rightCellClass(row.kind)}>{row.right === null ? '' : cell(row.right.text)}</td>
|
|
123
113
|
</tr>
|
|
124
114
|
))}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
})}
|
|
115
|
+
</Fragment>
|
|
116
|
+
))}
|
|
128
117
|
</tbody>
|
|
129
118
|
</table>
|
|
130
119
|
</div>
|