@startsimpli/ui 0.4.135 → 0.4.136

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,484 @@
1
+ /**
2
+ * The render-time fold, and what one field change actually SAYS
3
+ * (bd startsim-g4rwo; trail bd startsim-o1qib; extraction bd startsim-m7fdm.1).
4
+ *
5
+ * Pure — no React, no fetching — so every rule below unit-tests without a
6
+ * backend and without a render.
7
+ *
8
+ * ── WHY THE FOLD IS HERE AND NOT ON THE SERVER ──────────────────────────────
9
+ *
10
+ * There is NO server-side coalescing, deliberately: the trail is one bare
11
+ * INSERT per write, because growing an open row inside a window is a
12
+ * read-modify-write on a path where the autosave debounce is 1200ms and two
13
+ * saves from one reviewer are routinely in flight — which is precisely the
14
+ * mechanism that drops `data._edit_history` entries today, one table over. A
15
+ * revision that claims text the record does not hold is worse than a missing
16
+ * one. So the server stays dumb and correct, and the folding happens here.
17
+ *
18
+ * The consequence this module exists to absorb: a reviewer working through a
19
+ * 500-word blog issues DOZENS of PATCHes, so a sitting is dozens of rows. One
20
+ * row per PATCH is not accountability, it is a wall of near-identical
21
+ * timestamps that nobody reads — and the wall is worse than nothing, because it
22
+ * hides the one thing the customer asked for.
23
+ *
24
+ * ── WHY FIVE MINUTES ────────────────────────────────────────────────────────
25
+ *
26
+ * Not because five is special — because of where the gaps actually fall. Inside
27
+ * one sitting at the keyboard the gaps are SECONDS (the debounce is 1.2s and
28
+ * typing is continuous), so almost any window kills the autosave noise. What a
29
+ * window has to get right is the other side: the smallest gap that means "she
30
+ * stopped, and came back". Five minutes is comfortably longer than reading a
31
+ * rendered preview or fixing an issue in the rail, and comfortably shorter than
32
+ * leaving the desk. Longer windows only merge sittings that really were
33
+ * separate, which is a loss of truth for no gain in quiet.
34
+ *
35
+ * AND IT IS ONE NUMBER: 0 gives a row per save, higher gives a row per visit.
36
+ * Nothing else in this module knows which a tenant wants.
37
+ */
38
+ import type {
39
+ ActorKind,
40
+ ActorKindSource,
41
+ EntityRevision,
42
+ RevisionFieldChange,
43
+ } from './types';
44
+
45
+ /**
46
+ * How close two writes by the same actor have to be to count as one sitting.
47
+ * See this module's header for why five minutes. Carried over verbatim from
48
+ * `marketing-agents-web/src/lib/edit-history.ts`, whose `COLLAPSE_WINDOW_MS`
49
+ * this replaces.
50
+ */
51
+ export const COLLAPSE_WINDOW_MS = 5 * 60 * 1000;
52
+
53
+ /** A side of a field change, with ABSENCE made explicit.
54
+ *
55
+ * `hasBefore`/`hasAfter` exist because `before`/`after` can legitimately BE
56
+ * `null` — a stored null and a key that was never there are different facts,
57
+ * and `entry.after === undefined` collapses them. */
58
+ export interface FoldedFieldChange {
59
+ hasBefore: boolean;
60
+ before?: unknown;
61
+ hasAfter: boolean;
62
+ after?: unknown;
63
+ /** Either side was too large to store, anywhere in the fold. */
64
+ truncated: boolean;
65
+ /** The per-field read gate withheld this field from this caller. */
66
+ withheld: boolean;
67
+ /** The ROW was over the byte cap: the field moved, the values are not here. */
68
+ omitted: boolean;
69
+ }
70
+
71
+ /**
72
+ * What a field entry actually says, as one word.
73
+ *
74
+ * THE ORDER IS LOAD-BEARING. `withheld` and `omitted` are checked FIRST because
75
+ * both arrive as `{}` — indistinguishable from each other and from a created
76
+ * key by shape alone, and distinguishable only by the row's `redacted_fields`
77
+ * and `values_omitted`. `truncated` is checked BEFORE the created/deleted
78
+ * inference because a truncated side's absence says nothing, so calling it a
79
+ * deletion would invent one.
80
+ */
81
+ export type FieldChangeKind =
82
+ | 'withheld'
83
+ | 'omitted'
84
+ | 'truncated'
85
+ | 'created'
86
+ | 'deleted'
87
+ | 'updated';
88
+
89
+ export function classifyFieldChange(change: FoldedFieldChange): FieldChangeKind {
90
+ if (change.withheld) return 'withheld';
91
+ if (change.omitted) return 'omitted';
92
+ if (change.truncated) return 'truncated';
93
+ if (!change.hasBefore && change.hasAfter) return 'created';
94
+ if (change.hasBefore && !change.hasAfter) return 'deleted';
95
+ return 'updated';
96
+ }
97
+
98
+ /** Whether a within-field diff can honestly be rendered for this change.
99
+ *
100
+ * REFUSES A TRUNCATED SIDE, which is the whole point: a diff over a silently
101
+ * truncated value shows a deletion that never happened. Also refuses anything
102
+ * withheld or omitted (there are no values), and anything that is not text on
103
+ * both sides (a line diff over a dict is noise). */
104
+ export function canDiffFieldChange(change: FoldedFieldChange): boolean {
105
+ if (change.truncated || change.withheld || change.omitted) return false;
106
+ return typeof change.before === 'string' && typeof change.after === 'string';
107
+ }
108
+
109
+ /* -------------------------------------------------------------------------- */
110
+ /* READING THE WIRE */
111
+ /* -------------------------------------------------------------------------- */
112
+
113
+ /** First non-empty of several candidate spellings — the snake/camel tolerance
114
+ * described in `types.ts`. */
115
+ function pick<T>(...values: Array<T | null | undefined>): T | undefined {
116
+ for (const value of values) {
117
+ if (value !== null && value !== undefined && value !== '') return value;
118
+ }
119
+ return undefined;
120
+ }
121
+
122
+ function strings(value: unknown): string[] {
123
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [];
124
+ }
125
+
126
+ /** The three-state kind, FAILING CLOSED.
127
+ *
128
+ * Anything outside the vocabulary — absent, empty, a value from a future
129
+ * build — reads `unknown`, and NEVER `person`. A trail that labels a machine
130
+ * as your colleague is worse than no trail at all, so the floor is the state
131
+ * that claims nothing. */
132
+ export function actorKindOf(revision: EntityRevision): ActorKind {
133
+ const raw = pick(revision.actor_kind, revision.actorKind);
134
+ return raw === 'person' || raw === 'machine' ? raw : 'unknown';
135
+ }
136
+
137
+ /** Whether the kind was CLAIMED or GUESSED. `undefined` when the row does not
138
+ * say, which a row from a pre-rollout build will not. */
139
+ export function actorKindSourceOf(revision: EntityRevision): ActorKindSource | undefined {
140
+ const raw = pick(revision.actor_kind_source, revision.actorKindSource);
141
+ return raw === 'claim' || raw === 'inferred' ? raw : undefined;
142
+ }
143
+
144
+ /**
145
+ * One `changed` entry read into {@link FoldedFieldChange}.
146
+ *
147
+ * `'before' in entry` rather than `entry.before !== undefined`, because `null`
148
+ * is a legal stored value and the server never writes `after: null` to mean
149
+ * "deleted" — it omits the key. Collapsing the two would turn every
150
+ * null-valued field into a deletion.
151
+ */
152
+ export function normalizeFieldChange(
153
+ entry: RevisionFieldChange | undefined,
154
+ options: { withheld?: boolean; omitted?: boolean } = {},
155
+ ): FoldedFieldChange {
156
+ const raw = entry && typeof entry === 'object' ? entry : {};
157
+ const hasBefore = Object.prototype.hasOwnProperty.call(raw, 'before');
158
+ const hasAfter = Object.prototype.hasOwnProperty.call(raw, 'after');
159
+ return {
160
+ hasBefore,
161
+ ...(hasBefore ? { before: raw.before } : {}),
162
+ hasAfter,
163
+ ...(hasAfter ? { after: raw.after } : {}),
164
+ truncated: raw.truncated === true,
165
+ withheld: options.withheld === true,
166
+ omitted: options.omitted === true,
167
+ };
168
+ }
169
+
170
+ /** An ISO instant we can actually order by, or undefined. */
171
+ function instant(value: unknown): string | undefined {
172
+ if (typeof value !== 'string') return undefined;
173
+ return Number.isNaN(Date.parse(value)) ? undefined : value;
174
+ }
175
+
176
+ /** One revision row, read defensively into a single spelling. */
177
+ export interface NormalizedRevision {
178
+ id: string;
179
+ version: number;
180
+ action: string;
181
+ source: string;
182
+ precondition: string;
183
+ /** ISO-8601, or `''` when the row carried no readable timestamp. */
184
+ createdAt: string;
185
+ actorSub: string;
186
+ actorLabel: string;
187
+ actorKind: ActorKind;
188
+ actorKindSource?: ActorKindSource;
189
+ changed: Record<string, FoldedFieldChange>;
190
+ name?: FoldedFieldChange;
191
+ preserved: string[];
192
+ regenerate: string[];
193
+ redacted: string[];
194
+ valuesOmitted: boolean;
195
+ /** A machine ATTEMPT: something was held back and nothing moved. */
196
+ isAttempt: boolean;
197
+ }
198
+
199
+ export function normalizeRevision(revision: EntityRevision): NormalizedRevision {
200
+ const metadata = revision.metadata && typeof revision.metadata === 'object' ? revision.metadata : {};
201
+ const redacted = strings(pick(revision.redacted_fields, revision.redactedFields));
202
+ const valuesOmitted =
203
+ metadata.values_omitted === true || metadata.valuesOmitted === true;
204
+ const rawChanged =
205
+ metadata.changed && typeof metadata.changed === 'object' ? metadata.changed : {};
206
+
207
+ const changed: Record<string, FoldedFieldChange> = {};
208
+ for (const [field, entry] of Object.entries(rawChanged)) {
209
+ changed[field] = normalizeFieldChange(entry, {
210
+ withheld: redacted.includes(field),
211
+ omitted: valuesOmitted,
212
+ });
213
+ }
214
+
215
+ const preserved = strings(metadata.preserved);
216
+ return {
217
+ id: String(revision.id ?? ''),
218
+ version: typeof revision.version === 'number' ? revision.version : 0,
219
+ action: String(pick(revision.action) ?? 'updated'),
220
+ source: String(pick(revision.source) ?? ''),
221
+ precondition: String(pick(revision.precondition) ?? ''),
222
+ createdAt: instant(pick(revision.created_at, revision.createdAt)) ?? '',
223
+ actorSub: String(pick(revision.actor_sub, revision.actorSub) ?? ''),
224
+ actorLabel: String(pick(revision.actor_label, revision.actorLabel) ?? ''),
225
+ actorKind: actorKindOf(revision),
226
+ ...(actorKindSourceOf(revision) ? { actorKindSource: actorKindSourceOf(revision)! } : {}),
227
+ changed,
228
+ ...(metadata.name
229
+ ? { name: normalizeFieldChange(metadata.name, { omitted: valuesOmitted }) }
230
+ : {}),
231
+ preserved,
232
+ regenerate: strings(metadata.regenerate),
233
+ redacted,
234
+ valuesOmitted,
235
+ isAttempt: preserved.length > 0 && Object.keys(changed).length === 0,
236
+ };
237
+ }
238
+
239
+ /* -------------------------------------------------------------------------- */
240
+ /* THE FOLD */
241
+ /* -------------------------------------------------------------------------- */
242
+
243
+ /** One sitting: who, over what span, and what moved across the whole of it. */
244
+ export interface RevisionSitting {
245
+ /** Stable React key — the newest folded row's id. */
246
+ id: string;
247
+ /** The rows folded in, NEWEST FIRST, so "show each save" needs no re-sort. */
248
+ revisions: NormalizedRevision[];
249
+ actorSub: string;
250
+ actorLabel: string;
251
+ actorKind: ActorKind;
252
+ actorKindSource?: ActorKindSource;
253
+ /** Version of the OLDEST row folded in. */
254
+ fromVersion: number;
255
+ /** Version of the NEWEST row folded in. */
256
+ toVersion: number;
257
+ /** When the sitting started (oldest row). */
258
+ from: string;
259
+ /** When it last saved (newest row). */
260
+ at: string;
261
+ /** How many rows folded in. At least 1. */
262
+ saves: number;
263
+ /** `created` if the sitting contains the record's creation, else the newest
264
+ * row's action. */
265
+ action: string;
266
+ /** Per field: BEFORE from the oldest row that touched it, AFTER from the
267
+ * newest. */
268
+ changed: Record<string, FoldedFieldChange>;
269
+ name?: FoldedFieldChange;
270
+ preserved: string[];
271
+ regenerate: string[];
272
+ redacted: string[];
273
+ valuesOmitted: boolean;
274
+ isAttempt: boolean;
275
+ }
276
+
277
+ /**
278
+ * Whether two adjacent rows are the same sitting.
279
+ *
280
+ * FOUR REFUSALS, each of which would otherwise produce a confident lie:
281
+ *
282
+ * 1. A DIFFERENT ACTOR is a different sitting, obviously — and that includes a
283
+ * different ABSENCE of one, treated as its own actor rather than matching
284
+ * everybody.
285
+ * 2. A DIFFERENT `actorKind` OR `actorKindSource` NEVER FOLDS, even when the
286
+ * sub matches. During the healing window one person's rows legitimately
287
+ * carry `unknown` then `person`, and folding them would stamp the whole
288
+ * sitting with whichever end won — manufacturing a claim the data does not
289
+ * make. bd startsim-jkkn7.2 says fail closed on absence; refusing the fold
290
+ * is the honest version of that.
291
+ * 3. AN ATTEMPT NEVER FOLDS INTO A CHANGE. A `preserved` row records that a
292
+ * machine tried to write and was held back; merging it into a row that DID
293
+ * move a field would read as the attempt having succeeded, which inverts the
294
+ * one fact the actor axis exists to record.
295
+ * 4. A CREATE NEVER ABSORBS A LATER EDIT, so "created" keeps meaning the
296
+ * record's version 1 rather than "created, and then some".
297
+ *
298
+ * And the window itself is checked on the gap between the two rows, `>= 0`
299
+ * guarding a clock that went backwards: an out-of-order row extends the sitting
300
+ * rather than opening one that appears to precede it.
301
+ */
302
+ function sameSitting(
303
+ newer: NormalizedRevision,
304
+ older: NormalizedRevision,
305
+ windowMs: number,
306
+ ): boolean {
307
+ if (newer.actorSub !== older.actorSub) return false;
308
+ if (newer.actorKind !== older.actorKind) return false;
309
+ if (newer.actorKindSource !== older.actorKindSource) return false;
310
+ if (newer.isAttempt !== older.isAttempt) return false;
311
+ if (older.action === 'created' || newer.action === 'created') return false;
312
+ if (!newer.createdAt || !older.createdAt) return false;
313
+ const gap = Date.parse(newer.createdAt) - Date.parse(older.createdAt);
314
+ return gap >= 0 && gap <= windowMs;
315
+ }
316
+
317
+ /**
318
+ * Merge an OLDER row's field change into a sitting that already holds the
319
+ * newer one.
320
+ *
321
+ * THE DIRECTION IS THE WHOLE POINT. `before` must come from the OLDEST row that
322
+ * touched the field and `after` from the NEWEST, so a sitting reports what the
323
+ * paragraph was when she sat down and what it was when she left. A naive union
324
+ * takes whichever side it saw last and reports an intermediate autosave as the
325
+ * final text — which is exactly the kind of confidently-wrong value this trail
326
+ * exists to replace.
327
+ *
328
+ * Flags UNION rather than overwrite: a sitting that folded one truncated,
329
+ * withheld or omitted row carries that forward, because a reader who is shown
330
+ * a clean diff built partly from a withheld row has been told something false.
331
+ */
332
+ function mergeOlder(into: FoldedFieldChange, older: FoldedFieldChange): FoldedFieldChange {
333
+ return {
334
+ // BEFORE moves back to the older row's.
335
+ hasBefore: older.hasBefore,
336
+ ...(older.hasBefore ? { before: older.before } : {}),
337
+ // AFTER stays with the newer row's.
338
+ hasAfter: into.hasAfter,
339
+ ...(into.hasAfter ? { after: into.after } : {}),
340
+ truncated: into.truncated || older.truncated,
341
+ withheld: into.withheld || older.withheld,
342
+ omitted: into.omitted || older.omitted,
343
+ };
344
+ }
345
+
346
+ function startSitting(revision: NormalizedRevision): RevisionSitting {
347
+ return {
348
+ id: revision.id,
349
+ revisions: [revision],
350
+ actorSub: revision.actorSub,
351
+ actorLabel: revision.actorLabel,
352
+ actorKind: revision.actorKind,
353
+ ...(revision.actorKindSource ? { actorKindSource: revision.actorKindSource } : {}),
354
+ fromVersion: revision.version,
355
+ toVersion: revision.version,
356
+ from: revision.createdAt,
357
+ at: revision.createdAt,
358
+ saves: 1,
359
+ action: revision.action,
360
+ changed: { ...revision.changed },
361
+ ...(revision.name ? { name: revision.name } : {}),
362
+ preserved: [...revision.preserved],
363
+ regenerate: [...revision.regenerate],
364
+ redacted: [...revision.redacted],
365
+ valuesOmitted: revision.valuesOmitted,
366
+ isAttempt: revision.isAttempt,
367
+ };
368
+ }
369
+
370
+ function absorb(sitting: RevisionSitting, older: NormalizedRevision): void {
371
+ sitting.revisions.push(older);
372
+ sitting.saves += 1;
373
+ sitting.fromVersion = older.version;
374
+ sitting.from = older.createdAt || sitting.from;
375
+ if (older.action === 'created') sitting.action = 'created';
376
+
377
+ for (const [field, change] of Object.entries(older.changed)) {
378
+ sitting.changed[field] = sitting.changed[field]
379
+ ? mergeOlder(sitting.changed[field]!, change)
380
+ : change;
381
+ }
382
+ if (older.name) {
383
+ sitting.name = sitting.name ? mergeOlder(sitting.name, older.name) : older.name;
384
+ }
385
+ const union = (a: string[], b: string[]) => [...new Set([...a, ...b])].sort();
386
+ sitting.preserved = union(sitting.preserved, older.preserved);
387
+ sitting.regenerate = union(sitting.regenerate, older.regenerate);
388
+ sitting.redacted = union(sitting.redacted, older.redacted);
389
+ sitting.valuesOmitted = sitting.valuesOmitted || older.valuesOmitted;
390
+ }
391
+
392
+ /**
393
+ * Fold a NEWEST-FIRST page of revisions into sittings, newest first.
394
+ *
395
+ * Takes the rows exactly as the endpoint ordered them (`-version`, then
396
+ * `-created_at`) and folds only ADJACENT rows, so the result stays in the
397
+ * server's order and no row is reordered on the strength of a timestamp.
398
+ *
399
+ * FOLD OVER THE ACCUMULATED PAGES, NOT PER PAGE. The panel loads pages with
400
+ * `useInfiniteQuery` and folds the flattened array, which is why a sitting
401
+ * straddling a page boundary is folded once rather than appearing as "3 saves"
402
+ * followed by "2 saves". Calling this per page would be wrong and silently so.
403
+ */
404
+ export function foldRevisions(
405
+ revisions: EntityRevision[],
406
+ windowMs: number = COLLAPSE_WINDOW_MS,
407
+ ): RevisionSitting[] {
408
+ const rows = (revisions ?? []).map(normalizeRevision);
409
+ const out: RevisionSitting[] = [];
410
+ for (const row of rows) {
411
+ const open = out[out.length - 1];
412
+ // `open.revisions[open.revisions.length - 1]` is the OLDEST row folded so
413
+ // far, which is the one this (older still) row is adjacent to.
414
+ const tail = open?.revisions[open.revisions.length - 1];
415
+ if (open && tail && sameSitting(tail, row, windowMs)) {
416
+ absorb(open, row);
417
+ } else {
418
+ out.push(startSitting(row));
419
+ }
420
+ }
421
+ return out;
422
+ }
423
+
424
+ /**
425
+ * What the sitting collapsed, in words.
426
+ *
427
+ * A COLLAPSED SITTING MUST SAY IT IS A COLLAPSE. A bare timestamp over twelve
428
+ * folded saves quietly claims a precision it does not have — the reader should
429
+ * be able to see that this was a sitting, not a keystroke. Locale-free on
430
+ * purpose: the absolute time is rendered by the row, this is only the shape of
431
+ * the sitting.
432
+ */
433
+ export function sittingSummary(sitting: RevisionSitting): string {
434
+ if (sitting.saves <= 1) return '1 save';
435
+ const minutes = Math.floor((Date.parse(sitting.at) - Date.parse(sitting.from)) / 60_000);
436
+ return minutes >= 1 ? `${sitting.saves} saves over ${minutes} min` : `${sitting.saves} saves`;
437
+ }
438
+
439
+ /** The fields a sitting touched, in a stable order, so two renders of the same
440
+ * sitting never disagree about which field comes first. */
441
+ export function sittingFields(sitting: RevisionSitting): string[] {
442
+ return Object.keys(sitting.changed).sort();
443
+ }
444
+
445
+ /** "a", "a and b", "a, b and c" — an Oxford-free list, because these are field
446
+ * names in a sentence and not prose. */
447
+ export function joinNames(names: string[]): string {
448
+ if (names.length === 0) return '';
449
+ if (names.length === 1) return names[0]!;
450
+ return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
451
+ }
452
+
453
+ /**
454
+ * The sitting's headline — one line that says what happened.
455
+ *
456
+ * AN ATTEMPT GETS ITS OWN SENTENCE, and it is the sentence nobody could produce
457
+ * when the 41 machine-written notes could not be cleaned up: "tried to write
458
+ * team_verdict — held back". It must never read as a change, because the record
459
+ * did not move; what happened is that a machine was stopped.
460
+ *
461
+ * Beyond about three fields the names stop helping and the count starts: a
462
+ * headline listing nine attribute names is not scannable, and the full list is
463
+ * one row down anyway.
464
+ */
465
+ export function sittingHeadline(
466
+ sitting: RevisionSitting,
467
+ fieldLabel: (field: string) => string = (field) => field,
468
+ ): string {
469
+ if (sitting.isAttempt) {
470
+ const names = joinNames(sitting.preserved.map(fieldLabel));
471
+ return names ? `Tried to write ${names} — held back` : 'A write was held back';
472
+ }
473
+ const fields = sittingFields(sitting);
474
+ const renamed = sitting.name !== undefined;
475
+ if (sitting.action === 'created') {
476
+ return fields.length ? `Created the record with ${fields.length} ${fields.length === 1 ? 'field' : 'fields'}` : 'Created the record';
477
+ }
478
+ if (!fields.length) return renamed ? 'Renamed the record' : 'Saved the record';
479
+ const named =
480
+ fields.length <= 3
481
+ ? `Edited ${joinNames(fields.map(fieldLabel))}`
482
+ : `Edited ${fields.length} fields`;
483
+ return renamed ? `${named}, and renamed it` : named;
484
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Turn two revisions of one text field into a git-style unified diff string
3
+ * (bd startsim-g4rwo; originally marketing-agents-web `src/lib/blog-diff.ts`,
4
+ * bd 768w.16.10.5).
5
+ *
6
+ * This is what makes the epic's answer to "which paragraph moved" nearly free
7
+ * rather than a second build: {@link DiffViewer} already renders a RAW unified
8
+ * diff (it splits on `diff --git` headers, colourises `@@` hunks and `+`/`-`
9
+ * lines) and does no diffing itself, so showing a field against its previous
10
+ * value only needs that string synthesised from the two values. Pure and
11
+ * line-based (LCS), so it unit-tests without a backend and adds no dependency.
12
+ *
13
+ * RENAMED FROM `blog-diff` / `unifiedBlogDiff` ON THE WAY IN, deliberately.
14
+ * The algorithm never knew what a blog was — it takes two strings and a path
15
+ * label — and `packages/ui` is consumed by every foundry fork, most of which
16
+ * have no blogs. A shared module carrying one tenant's vocabulary is how
17
+ * surfaces end up unable to reuse it. `path` is what names the field in the
18
+ * rendered header, so a caller passes the field's own name.
19
+ *
20
+ * DO NOT CALL THIS ON A TRUNCATED SIDE. A diff over a silently truncated value
21
+ * shows a deletion that never happened; `canDiffFieldChange` in
22
+ * `revision-history.ts` is the gate, and the panel says why it refused rather
23
+ * than rendering nothing.
24
+ *
25
+ * The algorithm: an LCS edit script over lines, grouped into hunks with a few
26
+ * lines of surrounding context (like `git diff -U<n>`). Identical inputs yield
27
+ * '' so the viewer shows its own empty state rather than an all-context block.
28
+ */
29
+
30
+ type Op = { type: 'eq' | 'del' | 'ins'; line: string };
31
+
32
+ /** Split into lines, tolerating CRLF and a trailing newline (no phantom last line). */
33
+ function toLines(text: string): string[] {
34
+ if (text === '') return [];
35
+ return text.replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n');
36
+ }
37
+
38
+ /**
39
+ * A minimal LCS edit script between two line arrays. O(n*m) DP — a record field is a
40
+ * few hundred lines at most. Returns ops in output order: equal/deleted/inserted.
41
+ */
42
+ export function diffLines(oldLines: string[], newLines: string[]): Op[] {
43
+ const n = oldLines.length;
44
+ const m = newLines.length;
45
+ // lcs[i][j] = LCS length of oldLines[i:] and newLines[j:].
46
+ const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
47
+ for (let i = n - 1; i >= 0; i--) {
48
+ for (let j = m - 1; j >= 0; j--) {
49
+ lcs[i][j] =
50
+ oldLines[i] === newLines[j]
51
+ ? lcs[i + 1][j + 1] + 1
52
+ : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
53
+ }
54
+ }
55
+
56
+ const ops: Op[] = [];
57
+ let i = 0;
58
+ let j = 0;
59
+ while (i < n && j < m) {
60
+ if (oldLines[i] === newLines[j]) {
61
+ ops.push({ type: 'eq', line: oldLines[i] });
62
+ i++;
63
+ j++;
64
+ } else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
65
+ ops.push({ type: 'del', line: oldLines[i] });
66
+ i++;
67
+ } else {
68
+ ops.push({ type: 'ins', line: newLines[j] });
69
+ j++;
70
+ }
71
+ }
72
+ while (i < n) ops.push({ type: 'del', line: oldLines[i++] });
73
+ while (j < m) ops.push({ type: 'ins', line: newLines[j++] });
74
+ return ops;
75
+ }
76
+
77
+ /** A prefixed diff body line: ' ' context, '-' deletion, '+' insertion. */
78
+ function prefixed(op: Op): string {
79
+ const mark = op.type === 'eq' ? ' ' : op.type === 'del' ? '-' : '+';
80
+ return `${mark}${op.line}`;
81
+ }
82
+
83
+ /**
84
+ * Build the unified diff of two text values as a single-file `diff --git` block the
85
+ * DiffViewer can render. `context` is the number of unchanged lines kept around
86
+ * each change (git's default 3). Returns '' when the bodies are identical.
87
+ */
88
+ export function unifiedTextDiff(
89
+ oldText: string,
90
+ newText: string,
91
+ path = 'value.txt',
92
+ context = 3,
93
+ ): string {
94
+ if (oldText === newText) return '';
95
+ const ops = diffLines(toLines(oldText), toLines(newText));
96
+ if (!ops.some((op) => op.type !== 'eq')) return '';
97
+
98
+ // Indices of ops that are actual changes, expanded by `context` on each side and
99
+ // merged into contiguous hunk ranges.
100
+ const changed: number[] = [];
101
+ ops.forEach((op, idx) => {
102
+ if (op.type !== 'eq') changed.push(idx);
103
+ });
104
+
105
+ const ranges: Array<[number, number]> = [];
106
+ for (const idx of changed) {
107
+ const start = Math.max(0, idx - context);
108
+ const end = Math.min(ops.length - 1, idx + context);
109
+ const last = ranges[ranges.length - 1];
110
+ if (last && start <= last[1] + 1) {
111
+ last[1] = Math.max(last[1], end);
112
+ } else {
113
+ ranges.push([start, end]);
114
+ }
115
+ }
116
+
117
+ const lines: string[] = [`diff --git a/${path} b/${path}`];
118
+ for (const [start, end] of ranges) {
119
+ // 1-based line numbers of the hunk's first old/new line.
120
+ let oldStart = 1;
121
+ let newStart = 1;
122
+ for (let k = 0; k < start; k++) {
123
+ if (ops[k].type !== 'ins') oldStart++;
124
+ if (ops[k].type !== 'del') newStart++;
125
+ }
126
+ let oldCount = 0;
127
+ let newCount = 0;
128
+ const body: string[] = [];
129
+ for (let k = start; k <= end; k++) {
130
+ const op = ops[k];
131
+ if (op.type !== 'ins') oldCount++;
132
+ if (op.type !== 'del') newCount++;
133
+ body.push(prefixed(op));
134
+ }
135
+ lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
136
+ lines.push(...body);
137
+ }
138
+ return lines.join('\n');
139
+ }