@kgdiff/viewer 0.1.0 → 0.2.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/src/ui/detail.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Detail panel (docs/interaction.md §Detail panel): for the selected hunk (or node) — title,
3
- * family pill, prev / next, reviewed toggle; identity, name (character diff), attributes
4
- * (word diff), synonyms / types chips, hierarchy, relations as sentences, merge / split
5
- * composition and match evidence. Unchanged nodes show their attributes and relations.
3
+ * family pill, prev / next, reviewed toggle; then what changed, first: one row per changed
4
+ * facet, before → after (character diff for names, word diff for text, set changes paired into
5
+ * edits); merge / split composition, relations as sentences and match evidence; the unchanged
6
+ * rest last, its attributes folded. Added, removed and inspected nodes show their overview,
7
+ * attributes and relations.
6
8
  */
7
9
  import type {
8
10
  AttrChange,
9
- AttrValue,
10
11
  DiffResult,
11
12
  EdgeFacet,
12
13
  MatchEvidence,
@@ -19,12 +20,14 @@ import type { Family, Scene, SceneHunk } from '@kgdiff/scene';
19
20
  import type { Topic } from '../controller.js';
20
21
  import { attr, h, icon, iconButton, replaceKeepingFocus } from '../dom.js';
21
22
  import { formatPercent, type Translator } from '../i18n.js';
22
- import { type DiffSegment, diffChars, diffWords } from '../text-diff.js';
23
+ import { type DiffSegment, diffChars, diffWords, pairItems } from '../text-diff.js';
23
24
  import { formatValue, listItems, sceneHunkGlyph, type UiContext } from './context.js';
24
25
 
25
26
  const MAX_NODES = 12;
26
27
  const MAX_RELATIONS = 12;
27
28
  const CLAMP_CHARS = 180;
29
+ /** Up to this many characters (both sides together) a value change reads `before → after`. */
30
+ const INLINE_CHARS = 60;
28
31
 
29
32
  type Child = Node | string | null | false | undefined;
30
33
 
@@ -80,6 +83,63 @@ function chips(
80
83
  );
81
84
  }
82
85
 
86
+ /** `before → after` on one line, wrapping when long. */
87
+ function pair(before: Child[], after: Child[], tr: Translator): HTMLElement {
88
+ return h(
89
+ 'div',
90
+ { class: 'pair' },
91
+ h('span', { class: 'b' }, before),
92
+ h('span', { class: 'arr', 'aria-hidden': 'true', text: '→' }),
93
+ h('span', { class: 'sr-only', text: tr.t('detail.changedTo') }),
94
+ h('span', null, after),
95
+ );
96
+ }
97
+
98
+ /** `before → after` with the changed characters marked. */
99
+ function textPair(before: string, after: string, tr: Translator): HTMLElement {
100
+ const segs = diffChars(before, after);
101
+ return pair(marked(segs, 'before'), marked(segs, 'after'), tr);
102
+ }
103
+
104
+ /** One side of a change on a line of its own: `− before` or `+ after`. */
105
+ function signed(side: 'old' | 'new', content: Child): HTMLElement {
106
+ return h(
107
+ 'div',
108
+ { class: `line ${side}` },
109
+ h('span', { class: 'sign', 'aria-hidden': 'true', text: side === 'old' ? '−' : '+' }),
110
+ h('div', null, content),
111
+ );
112
+ }
113
+
114
+ /** A set change (synonyms, types, parents, list values): edits as pairs, the rest as chips. */
115
+ function items(
116
+ added: readonly string[],
117
+ removed: readonly string[],
118
+ tr: Translator,
119
+ same: readonly string[] = [],
120
+ ): Child[] {
121
+ const p = pairItems(removed, added);
122
+ const out: Child[] = p.pairs.map(([b, a]) => textPair(b, a, tr));
123
+ if (p.added.length > 0 || p.removed.length > 0 || same.length > 0) {
124
+ out.push(chips(p.added, p.removed, same));
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /** Key · value rows. */
130
+ function table(rows: readonly (readonly [string, Child | Child[]])[], cls = 'attrs'): HTMLElement {
131
+ return h(
132
+ 'table',
133
+ { class: cls },
134
+ h('colgroup', null, h('col', { class: 'k' }), h('col')),
135
+ h(
136
+ 'tbody',
137
+ null,
138
+ rows.map(([k, v]) => h('tr', null, h('td', { class: 'k', text: k }), h('td', null, v))),
139
+ ),
140
+ );
141
+ }
142
+
83
143
  function familyOfEdge(e: UnionEdge): Family {
84
144
  if (e.status === 'added') return 'added';
85
145
  if (e.status === 'removed') {
@@ -228,11 +288,15 @@ export class Detail {
228
288
  ];
229
289
  const nodeIds = core?.nodes ?? [];
230
290
  if (nodeIds.length > 1) parts.push(this.#nodeList(nodeIds, focusId, scene, diff, tr));
231
- if (focus) parts.push(...this.#nodeSections(focus, diff, tr, nodeIds.length > 1));
291
+ const [lead, rest] = focus
292
+ ? this.#nodeSections(focus, diff, tr, nodeIds.length > 1, true)
293
+ : [[], []];
232
294
  const edges = (core?.edges ?? [])
233
295
  .map((id) => diff.index.edge(id))
234
296
  .filter((e): e is UnionEdge => e !== undefined);
297
+ parts.push(...lead);
235
298
  if (edges.length > 0) parts.push(this.#relations(edges, focusId, scene, diff, tr, true));
299
+ parts.push(...rest);
236
300
  replaceKeepingFocus(this.#ctx.root, this.#body, this.#body, ...parts);
237
301
  }
238
302
 
@@ -245,6 +309,7 @@ export class Detail {
245
309
  return;
246
310
  }
247
311
  const edges = diff.index.edgesOf(node.id);
312
+ const [lead, rest] = this.#nodeSections(node, diff, tr, false, false);
248
313
  replaceKeepingFocus(
249
314
  this.#ctx.root,
250
315
  this.#body,
@@ -261,8 +326,9 @@ export class Detail {
261
326
  this.#nav(tr, undefined),
262
327
  ),
263
328
  h('h2', { class: 'd-title', text: node.label }),
264
- ...this.#nodeSections(node, diff, tr, false),
329
+ ...lead,
265
330
  edges.length > 0 ? this.#relations(edges, node.id, scene, diff, tr, false) : null,
331
+ ...rest,
266
332
  );
267
333
  }
268
334
 
@@ -329,265 +395,207 @@ export class Detail {
329
395
  );
330
396
  }
331
397
 
398
+ /**
399
+ * The node's sections, split around the relations. A modified node (or an unchanged one a hunk
400
+ * points at) leads with what changed and ends with its unchanged rest; added, removed and
401
+ * inspected nodes lead with what they are.
402
+ */
332
403
  #nodeSections(
333
404
  node: UnionNode,
334
405
  diff: DiffResult,
335
406
  tr: Translator,
336
407
  showName: boolean,
337
- ): HTMLElement[] {
338
- const out: HTMLElement[] = [];
339
- const facets = node.facets;
340
- const find = <K extends NodeFacet['kind']>(kind: K) =>
341
- facets.find((f): f is Extract<NodeFacet, { kind: K }> => f.kind === kind);
408
+ inHunk: boolean,
409
+ ): [HTMLElement[], HTMLElement[]] {
342
410
  const label = (id: UnionId): string => diff.index.node(id)?.label ?? id;
343
-
344
- // Identity
345
- const rekey = find('rekey');
346
- const types = find('types');
347
- const deprecate = find('deprecate');
348
- const current = node.after ?? node.before;
349
- const idRows: [string, Child | Child[]][] = [];
350
- if (showName) idRows.push([tr.t('detail.name'), h('span', { text: node.label })]);
351
- idRows.push([
352
- tr.t('detail.id'),
353
- rekey
354
- ? [
355
- h(
356
- 'span',
357
- { class: 'id-text' },
358
- h('del', { class: 'hl', text: rekey.from }),
359
- ' → ',
360
- h('ins', { class: 'hl', text: rekey.to }),
361
- ),
362
- rekey.score < 1
363
- ? h('span', {
364
- class: 'note-line',
365
- text: ` · ${tr.t('detail.matchScore', { p: formatPercent(rekey.score) })}`,
366
- })
367
- : null,
368
- ]
369
- : h('span', { class: 'id-text', text: node.afterId ?? node.beforeId ?? node.id }),
370
- ]);
371
- if (types) idRows.push([tr.t('detail.types'), chips(types.added, types.removed)]);
372
- else if (node.types.length > 0) idRows.push([tr.t('detail.types'), chips([], [], node.types)]);
373
- const statusBits: Child[] = [tr.t(`status.${node.status}`)];
374
- if (current?.deprecated && !deprecate) statusBits.push(` · ${tr.t('detail.deprecated')}`);
375
- if (deprecate) {
376
- statusBits.push(
377
- ' · ',
378
- h('strong', {
379
- text: tr.t(deprecate.deprecated ? 'detail.deprecated' : 'detail.undeprecated'),
380
- }),
381
- );
382
- }
383
- if (current?.implicit) statusBits.push(` · ${tr.t('detail.implicit')}`);
384
- idRows.push([tr.t('detail.status'), statusBits]);
385
- out.push(section(tr.t('detail.identity'), null, kv(idRows)));
386
-
387
- // Name
388
- const rename = find('rename');
389
- if (rename) {
390
- const segs = diffChars(rename.from, rename.to);
391
- out.push(
392
- section(
393
- tr.t('detail.name'),
394
- null,
395
- h(
396
- 'div',
397
- { class: 'namediff' },
398
- h(
399
- 'div',
400
- { class: 'line old', 'aria-label': `${tr.t('detail.before')}: ${rename.from}` },
401
- h('span', { class: 'sign', 'aria-hidden': 'true', text: '−' }),
402
- h('span', null, ...marked(segs, 'before')),
403
- ),
404
- h(
405
- 'div',
406
- { class: 'line new', 'aria-label': `${tr.t('detail.after')}: ${rename.to}` },
407
- h('span', { class: 'sign', 'aria-hidden': 'true', text: '+' }),
408
- h('span', null, ...marked(segs, 'after')),
409
- ),
410
- ),
411
- ),
412
- );
413
- }
414
-
415
- // Attributes
416
- const attrs = find('attrs');
417
- const changes = attrs?.changes ?? [];
418
- const all = current?.attrs ?? {};
419
- const changedKeys = new Set(changes.map((x) => x.key));
420
- const unchanged = Object.keys(all)
421
- .filter((k) => !changedKeys.has(k))
422
- .sort();
423
- if (changes.length > 0 || unchanged.length > 0) {
424
- out.push(this.#attributes(node.id, changes, unchanged, all, tr));
425
- }
426
-
427
- // Synonyms
428
- const aliases = find('aliases');
429
- if (aliases)
430
- out.push(section(tr.t('detail.aliases'), null, chips(aliases.added, aliases.removed)));
431
- else if (current && current.aliases.length > 0 && node.status === 'unchanged') {
432
- out.push(section(tr.t('detail.aliases'), null, chips([], [], current.aliases)));
433
- }
434
-
435
- // Hierarchy
436
- const move = find('move');
437
- if (move) {
438
- const dl = h('dl', { class: 'hier' });
439
- dl.append(
440
- h('dt', { text: tr.t('detail.before') }),
441
- h(
442
- 'dd',
443
- null,
444
- move.from.length
445
- ? move.from.map((id) => chip(label(id), 'del'))
446
- : chip(tr.t('detail.none'), 'same'),
447
- ),
448
- h('dt', { text: tr.t('detail.after') }),
449
- h(
450
- 'dd',
451
- null,
452
- move.to.length
453
- ? move.to.map((id) => chip(label(id), 'add'))
454
- : chip(tr.t('detail.none'), 'same'),
455
- ),
456
- );
457
- out.push(section(tr.t('detail.hierarchy'), null, dl));
458
- }
459
-
460
- // Replacement links
461
- const replacement = find('replacement');
462
- if (replacement) {
463
- const rows: [string, Child][] = [];
464
- const { replacedBy, replaces } = replacement;
465
- if (replacedBy.added.length || replacedBy.removed.length) {
466
- rows.push([tr.t('detail.replacedBy'), chips(replacedBy.added, replacedBy.removed)]);
467
- }
468
- if (replaces.added.length || replaces.removed.length) {
469
- rows.push([tr.t('detail.replaces'), chips(replaces.added, replaces.removed)]);
470
- }
471
- if (rows.length) out.push(section(tr.t('detail.deprecated'), null, kv(rows)));
472
- }
473
-
474
- // Composition
411
+ const fold = node.status === 'modified' || (inHunk && node.status === 'unchanged');
412
+ const changes = this.#changes(node, diff, tr, label);
475
413
  const comp = this.#composition(node, diff, tr, label);
476
- if (comp) out.push(comp);
477
-
478
- // Evidence
479
414
  const evidence = this.#evidence(node, diff, tr, label);
480
- if (evidence) out.push(evidence);
481
- return out;
415
+ const overview = this.#overview(node, tr, showName, fold);
416
+ const present = (xs: readonly (HTMLElement | null)[]): HTMLElement[] =>
417
+ xs.filter((x): x is HTMLElement => x !== null);
418
+ return fold
419
+ ? [present([comp, changes]), present([evidence, ...overview])]
420
+ : [present([...overview, changes, comp, evidence]), []];
482
421
  }
483
422
 
484
- #attributes(
485
- nodeId: string,
486
- changes: readonly AttrChange[],
487
- unchanged: readonly string[],
488
- all: Readonly<Record<string, AttrValue>>,
423
+ /** What changed on the node: one row per facet (per key for attributes), in facet order. */
424
+ #changes(
425
+ node: UnionNode,
426
+ diff: DiffResult,
489
427
  tr: Translator,
490
- ): HTMLElement {
491
- const table = h(
492
- 'table',
493
- { class: 'attrs' },
494
- h('colgroup', null, h('col', { class: 'k' }), h('col'), h('col')),
495
- );
496
- if (changes.length > 0) {
497
- table.appendChild(
498
- h(
499
- 'thead',
500
- null,
501
- h(
502
- 'tr',
503
- null,
504
- h('th', { scope: 'col', text: tr.t('detail.key') }),
505
- h('th', { scope: 'col', text: tr.t('detail.before') }),
506
- h('th', { scope: 'col', text: tr.t('detail.after') }),
507
- ),
508
- ),
509
- );
510
- }
511
- const body = h('tbody');
512
- for (const ch of changes) body.appendChild(this.#changeRow(nodeId, ch, tr));
513
- if (this.#showUnchangedAttrs || changes.length === 0) {
514
- for (const k of unchanged) {
515
- body.appendChild(
516
- h(
517
- 'tr',
518
- { class: 'unchanged' },
519
- h('td', { class: 'k', text: k }),
520
- h('td', { colspan: '2' }, this.#value(`${nodeId}|u|${k}`, formatValue(all[k]), tr)),
521
- ),
522
- );
428
+ label: (id: UnionId) => string,
429
+ ): HTMLElement | null {
430
+ // Replacement links hold ids as written: name the node they point at when there is one.
431
+ const named = (ids: readonly string[]): string[] =>
432
+ ids.map((id) => (diff.index.nodeByAfter(id) ?? diff.index.nodeByBefore(id))?.label ?? id);
433
+ const rows: [string, Child | Child[]][] = [];
434
+ for (const f of node.facets) {
435
+ switch (f.kind) {
436
+ case 'rekey':
437
+ rows.push([
438
+ tr.t('detail.id'),
439
+ [
440
+ h('div', { class: 'id-text' }, textPair(f.from, f.to, tr)),
441
+ f.score < 1
442
+ ? h('div', {
443
+ class: 'note-line',
444
+ text: tr.t('detail.matchScore', { p: formatPercent(f.score) }),
445
+ })
446
+ : null,
447
+ ],
448
+ ]);
449
+ break;
450
+ case 'rename':
451
+ rows.push([tr.t('detail.name'), textPair(f.from, f.to, tr)]);
452
+ break;
453
+ case 'types':
454
+ case 'aliases':
455
+ rows.push([tr.t(`detail.${f.kind}`), items(f.added, f.removed, tr)]);
456
+ break;
457
+ case 'attrs':
458
+ for (const ch of f.changes) rows.push([ch.key, this.#attrChange(node.id, ch, tr)]);
459
+ break;
460
+ case 'move':
461
+ rows.push([tr.t('detail.hierarchy'), items(f.to.map(label), f.from.map(label), tr)]);
462
+ break;
463
+ case 'deprecate':
464
+ rows.push([
465
+ tr.t('detail.status'),
466
+ h('strong', {
467
+ text: tr.t(f.deprecated ? 'detail.deprecated' : 'detail.undeprecated'),
468
+ }),
469
+ ]);
470
+ break;
471
+ case 'replacement':
472
+ for (const [key, links] of [
473
+ ['detail.replacedBy', f.replacedBy],
474
+ ['detail.replaces', f.replaces],
475
+ ] as const) {
476
+ if (links.added.length > 0 || links.removed.length > 0) {
477
+ rows.push([tr.t(key), items(named(links.added), named(links.removed), tr)]);
478
+ }
479
+ }
480
+ break;
481
+ default:
482
+ // Merges and splits: the composition section.
483
+ break;
523
484
  }
524
485
  }
525
- table.appendChild(body);
526
- const toggle =
527
- changes.length > 0 && unchanged.length > 0
528
- ? h(
529
- 'button',
530
- {
531
- type: 'button',
532
- class: 'disclosure',
533
- 'data-focus-key': 'attrs-more',
534
- 'aria-expanded': String(this.#showUnchangedAttrs),
535
- onclick: () => {
536
- this.#showUnchangedAttrs = !this.#showUnchangedAttrs;
537
- this.#render();
538
- },
539
- },
540
- tr.plural('detail.unchangedAttrs', unchanged.length),
541
- icon('chevron-down'),
542
- )
543
- : null;
544
- return section(tr.t('detail.attributes'), null, table, toggle);
486
+ return rows.length > 0
487
+ ? section(tr.t('detail.changes'), rows.length, table(rows, 'attrs changes'))
488
+ : null;
545
489
  }
546
490
 
547
- #changeRow(nodeId: string, ch: AttrChange, tr: Translator): HTMLElement {
548
- const empty = (): HTMLElement => h('span', { class: 'empty', text: tr.t('detail.emptyValue') });
491
+ /** One attribute's change: lists as set changes, short values inline, long ones a line a side. */
492
+ #attrChange(nodeId: string, ch: AttrChange, tr: Translator): Child[] {
549
493
  const beforeList = listItems(ch.before);
550
494
  const afterList = listItems(ch.after);
551
- let before: Child | Child[];
552
- let after: Child | Child[];
553
495
  if (beforeList && afterList) {
554
- const a = new Set(afterList);
555
496
  const b = new Set(beforeList);
556
- before = h(
557
- 'div',
558
- { class: 'chips' },
559
- beforeList.map((x) => chip(x, a.has(x) ? 'same' : 'del')),
560
- );
561
- after = h(
562
- 'div',
563
- { class: 'chips' },
564
- afterList.map((x) => chip(x, b.has(x) ? 'same' : 'add')),
497
+ const a = new Set(afterList);
498
+ return items(
499
+ afterList.filter((x) => !b.has(x)),
500
+ beforeList.filter((x) => !a.has(x)),
501
+ tr,
502
+ afterList.filter((x) => b.has(x)),
565
503
  );
566
- } else if (typeof ch.before === 'string' && typeof ch.after === 'string') {
567
- const segs = diffWords(ch.before, ch.after);
568
- before = this.#value(`${nodeId}|b|${ch.key}`, ch.before, tr, marked(segs, 'before'));
569
- after = this.#value(`${nodeId}|a|${ch.key}`, ch.after, tr, marked(segs, 'after'));
570
- } else {
571
- before =
572
- ch.before === undefined
573
- ? empty()
574
- : this.#value(`${nodeId}|b|${ch.key}`, formatValue(ch.before), tr, [
575
- h('del', { class: 'hl', text: formatValue(ch.before) }),
576
- ]);
577
- after =
578
- ch.after === undefined
579
- ? empty()
580
- : this.#value(`${nodeId}|a|${ch.key}`, formatValue(ch.after), tr, [
581
- h('ins', { class: 'hl', text: formatValue(ch.after) }),
582
- ]);
583
504
  }
584
- return h(
585
- 'tr',
586
- { class: 'changed' },
587
- h('td', { class: 'k', text: ch.key }),
588
- h('td', { class: 'b' }, ...[before].flat()),
589
- h('td', { class: 'a' }, ...[after].flat()),
590
- );
505
+ const key = `${nodeId}|${ch.key}`;
506
+ const before = formatValue(ch.before);
507
+ const after = formatValue(ch.after);
508
+ if (ch.before === undefined) {
509
+ const ins = h('ins', { class: 'hl', text: after });
510
+ return [signed('new', this.#value(`${key}|a`, after, tr, [ins]))];
511
+ }
512
+ if (ch.after === undefined) {
513
+ const del = h('del', { class: 'hl', text: before });
514
+ return [signed('old', this.#value(`${key}|b`, before, tr, [del]))];
515
+ }
516
+ const segs: readonly DiffSegment[] =
517
+ typeof ch.before === 'string' && typeof ch.after === 'string'
518
+ ? diffWords(before, after)
519
+ : [
520
+ { op: 'delete', text: before },
521
+ { op: 'insert', text: after },
522
+ ];
523
+ const b = marked(segs, 'before');
524
+ const a = marked(segs, 'after');
525
+ return before.length + after.length <= INLINE_CHARS && !/\n/.test(before + after)
526
+ ? [pair(b, a, tr)]
527
+ : [
528
+ signed('old', this.#value(`${key}|b`, before, tr, b)),
529
+ signed('new', this.#value(`${key}|a`, after, tr, a)),
530
+ ];
531
+ }
532
+
533
+ /**
534
+ * Identity and attributes, without what the changes section shows. Folded, the attributes
535
+ * (all unchanged) hide behind a toggle; otherwise they get a section of their own.
536
+ */
537
+ #overview(node: UnionNode, tr: Translator, showName: boolean, fold: boolean): HTMLElement[] {
538
+ const has = (kind: NodeFacet['kind']): boolean => node.facets.some((f) => f.kind === kind);
539
+ const current = node.after ?? node.before;
540
+ const rows: [string, Child | Child[]][] = [];
541
+ if (showName && !has('rename')) rows.push([tr.t('detail.name'), node.label]);
542
+ if (!has('rekey')) {
543
+ rows.push([
544
+ tr.t('detail.id'),
545
+ h('span', { class: 'id-text', text: node.afterId ?? node.beforeId ?? node.id }),
546
+ ]);
547
+ }
548
+ if (!has('types') && node.types.length > 0) {
549
+ rows.push([tr.t('detail.types'), chips([], [], node.types)]);
550
+ }
551
+ if (!has('aliases') && current && current.aliases.length > 0) {
552
+ rows.push([tr.t('detail.aliases'), chips([], [], current.aliases)]);
553
+ }
554
+ const status: Child[] = [tr.t(`status.${node.status}`)];
555
+ if (current?.deprecated && !has('deprecate')) status.push(` · ${tr.t('detail.deprecated')}`);
556
+ if (current?.implicit) status.push(` · ${tr.t('detail.implicit')}`);
557
+ rows.push([tr.t('detail.status'), status]);
558
+
559
+ const all = current?.attrs ?? {};
560
+ const changed = new Set<string>();
561
+ for (const f of node.facets) {
562
+ if (f.kind === 'attrs') for (const c of f.changes) changed.add(c.key);
563
+ }
564
+ const keys = Object.keys(all)
565
+ .filter((k) => !changed.has(k))
566
+ .sort();
567
+ const attrs = (): HTMLElement =>
568
+ table(keys.map((k) => [k, this.#value(`${node.id}|u|${k}`, formatValue(all[k]), tr)]));
569
+ if (!fold) {
570
+ const out = [section(tr.t('detail.identity'), null, kv(rows))];
571
+ if (keys.length > 0) out.push(section(tr.t('detail.attributes'), null, attrs()));
572
+ return out;
573
+ }
574
+ return [
575
+ section(
576
+ tr.t('detail.identity'),
577
+ null,
578
+ kv(rows),
579
+ keys.length > 0
580
+ ? h(
581
+ 'button',
582
+ {
583
+ type: 'button',
584
+ class: 'disclosure',
585
+ 'data-focus-key': 'attrs-more',
586
+ 'aria-expanded': String(this.#showUnchangedAttrs),
587
+ onclick: () => {
588
+ this.#showUnchangedAttrs = !this.#showUnchangedAttrs;
589
+ this.#render();
590
+ },
591
+ },
592
+ tr.plural('detail.unchangedAttrs', keys.length),
593
+ icon('chevron-down'),
594
+ )
595
+ : null,
596
+ keys.length > 0 && this.#showUnchangedAttrs ? attrs() : null,
597
+ ),
598
+ ];
591
599
  }
592
600
 
593
601
  /** A value cell, clamped to 4 lines with “Show more” when long. */