xtralab 0.1.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.
Files changed (56) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +69 -0
  3. package/lib/fileBrowser/commands.d.ts +62 -0
  4. package/lib/fileBrowser/commands.js +418 -0
  5. package/lib/fileBrowser/contents.d.ts +28 -0
  6. package/lib/fileBrowser/contents.js +48 -0
  7. package/lib/fileBrowser/fileBrowser.d.ts +24 -0
  8. package/lib/fileBrowser/fileBrowser.js +535 -0
  9. package/lib/fileBrowser/icons.d.ts +37 -0
  10. package/lib/fileBrowser/icons.js +153 -0
  11. package/lib/fileBrowser/index.d.ts +7 -0
  12. package/lib/fileBrowser/index.js +38 -0
  13. package/lib/fileBrowser/widget.d.ts +97 -0
  14. package/lib/fileBrowser/widget.js +95 -0
  15. package/lib/git/api.d.ts +39 -0
  16. package/lib/git/api.js +164 -0
  17. package/lib/git/commands.d.ts +69 -0
  18. package/lib/git/commands.js +67 -0
  19. package/lib/git/diffWidget.d.ts +108 -0
  20. package/lib/git/diffWidget.js +624 -0
  21. package/lib/git/icons.d.ts +14 -0
  22. package/lib/git/icons.js +19 -0
  23. package/lib/git/index.d.ts +11 -0
  24. package/lib/git/index.js +74 -0
  25. package/lib/git/notebookDiff.d.ts +176 -0
  26. package/lib/git/notebookDiff.js +805 -0
  27. package/lib/git/panel.d.ts +29 -0
  28. package/lib/git/panel.js +42 -0
  29. package/lib/git/panelComponent.d.ts +26 -0
  30. package/lib/git/panelComponent.js +300 -0
  31. package/lib/git/tokens.d.ts +78 -0
  32. package/lib/git/tokens.js +6 -0
  33. package/lib/index.d.ts +10 -0
  34. package/lib/index.js +14 -0
  35. package/package.json +216 -0
  36. package/schema/plugin.json +67 -0
  37. package/src/fileBrowser/commands.ts +507 -0
  38. package/src/fileBrowser/contents.ts +66 -0
  39. package/src/fileBrowser/fileBrowser.tsx +635 -0
  40. package/src/fileBrowser/icons.ts +172 -0
  41. package/src/fileBrowser/index.ts +56 -0
  42. package/src/fileBrowser/widget.tsx +202 -0
  43. package/src/git/api.ts +207 -0
  44. package/src/git/commands.ts +143 -0
  45. package/src/git/diffWidget.tsx +903 -0
  46. package/src/git/icons.ts +20 -0
  47. package/src/git/index.ts +109 -0
  48. package/src/git/notebookDiff.tsx +1294 -0
  49. package/src/git/panel.tsx +56 -0
  50. package/src/git/panelComponent.tsx +558 -0
  51. package/src/git/tokens.ts +91 -0
  52. package/src/index.ts +18 -0
  53. package/style/base.css +175 -0
  54. package/style/git.css +770 -0
  55. package/style/index.css +2 -0
  56. package/style/index.js +2 -0
@@ -0,0 +1,1294 @@
1
+ import * as React from 'react';
2
+
3
+ import type * as nbformat from '@jupyterlab/nbformat';
4
+ import { OutputAreaModel, SimplifiedOutputArea } from '@jupyterlab/outputarea';
5
+ import { IRenderMimeRegistry, MimeModel } from '@jupyterlab/rendermime';
6
+ import { MessageLoop } from '@lumino/messaging';
7
+ import { Widget } from '@lumino/widgets';
8
+ import { FileDiff } from '@pierre/diffs/react';
9
+ import {
10
+ parseDiffFromFile,
11
+ type DiffsThemeNames,
12
+ type FileContents,
13
+ type FileDiffMetadata
14
+ } from '@pierre/diffs';
15
+
16
+ /**
17
+ * The CSS class added to the root of the notebook diff. Selectors that
18
+ * style cell sections, headers, and the per-cell diff blocks hang off this
19
+ * class.
20
+ */
21
+ export const NOTEBOOK_DIFF_CSS_CLASS = 'jp-xtralab-NotebookDiff';
22
+
23
+ /**
24
+ * Minimal slice of nbformat 4.x that the diff inspects. Fields we don't
25
+ * read (attachments, e.g.) are intentionally not narrowed — we still pass
26
+ * them around as part of the cell, but we never project them into the diff.
27
+ */
28
+ export interface INotebookCell {
29
+ cell_type: string;
30
+ id?: string;
31
+ source: string | string[];
32
+ outputs?: INotebookOutput[];
33
+ metadata?: Record<string, unknown>;
34
+ execution_count?: number | null;
35
+ attachments?: Record<string, unknown>;
36
+ }
37
+
38
+ export interface INotebookOutput {
39
+ output_type: string;
40
+ data?: Record<string, unknown>;
41
+ text?: string | string[];
42
+ name?: string;
43
+ ename?: string;
44
+ evalue?: string;
45
+ traceback?: string[];
46
+ execution_count?: number | null;
47
+ metadata?: Record<string, unknown>;
48
+ }
49
+
50
+ export interface INotebook {
51
+ cells: INotebookCell[];
52
+ metadata: Record<string, unknown>;
53
+ nbformat: number;
54
+ nbformat_minor: number;
55
+ }
56
+
57
+ /**
58
+ * One entry in the per-cell diff. `unchanged` cells are rendered as a
59
+ * collapsed summary; the other variants render their source / outputs /
60
+ * metadata via {@link FileDiff}. The `modified` and `unchanged` branches
61
+ * carry the same payload but live on separate union members so `Extract`
62
+ * narrowing works on `kind` literals downstream.
63
+ */
64
+ export type NotebookCellDiff =
65
+ | {
66
+ kind: 'modified';
67
+ oldCell: INotebookCell;
68
+ newCell: INotebookCell;
69
+ newIndex: number;
70
+ oldIndex: number;
71
+ }
72
+ | {
73
+ kind: 'unchanged';
74
+ oldCell: INotebookCell;
75
+ newCell: INotebookCell;
76
+ newIndex: number;
77
+ oldIndex: number;
78
+ }
79
+ | {
80
+ kind: 'added';
81
+ newCell: INotebookCell;
82
+ newIndex: number;
83
+ }
84
+ | {
85
+ kind: 'removed';
86
+ oldCell: INotebookCell;
87
+ oldIndex: number;
88
+ };
89
+
90
+ /**
91
+ * Notebook-level diff result returned by {@link buildNotebookDiff}. Returns
92
+ * `null` from the builder when either side fails to parse — the caller then
93
+ * falls back to a raw JSON file diff.
94
+ */
95
+ export interface INotebookDiffResult {
96
+ oldNotebook: INotebook;
97
+ newNotebook: INotebook;
98
+ cells: NotebookCellDiff[];
99
+ /**
100
+ * Kernel language pulled from `metadata.language_info.name`, used to pick
101
+ * a syntax-highlighting filename for code cells.
102
+ */
103
+ language: string | undefined;
104
+ /**
105
+ * Pre-computed diff for notebook-level metadata (kernelspec, language_info,
106
+ * nbformat) when it differs between revisions; `null` otherwise so the
107
+ * section can be hidden without further checks.
108
+ */
109
+ notebookMetadataDiff: FileDiffMetadata | null;
110
+ }
111
+
112
+ /**
113
+ * Parse notebook JSON. Returns `null` if the text doesn't parse or doesn't
114
+ * look like a notebook (missing `cells` array). The caller falls back to a
115
+ * raw text diff so a malformed file is still inspectable.
116
+ */
117
+ export function parseNotebook(text: string): INotebook | null {
118
+ if (text.length === 0) {
119
+ return emptyNotebook();
120
+ }
121
+ let value: unknown;
122
+ try {
123
+ value = JSON.parse(text);
124
+ } catch {
125
+ return null;
126
+ }
127
+ if (
128
+ value === null ||
129
+ typeof value !== 'object' ||
130
+ !Array.isArray((value as { cells?: unknown }).cells)
131
+ ) {
132
+ return null;
133
+ }
134
+ const nb = value as INotebook;
135
+ if (nb.metadata === undefined || typeof nb.metadata !== 'object') {
136
+ nb.metadata = {};
137
+ }
138
+ return nb;
139
+ }
140
+
141
+ function emptyNotebook(): INotebook {
142
+ return {
143
+ cells: [],
144
+ metadata: {},
145
+ nbformat: 4,
146
+ nbformat_minor: 5
147
+ };
148
+ }
149
+
150
+ /**
151
+ * nbformat allows multiline string fields (`source`, stream `text`,
152
+ * `text/plain` outputs, …) to be either a single string or an array of
153
+ * strings. Normalize to one string so equality checks and diffs see the
154
+ * same shape regardless of how the writer chose to serialise it.
155
+ */
156
+ export function joinMultiline(value: string | string[] | undefined): string {
157
+ if (value === undefined) {
158
+ return '';
159
+ }
160
+ return Array.isArray(value) ? value.join('') : value;
161
+ }
162
+
163
+ export function cellSource(cell: INotebookCell): string {
164
+ return joinMultiline(cell.source);
165
+ }
166
+
167
+ /**
168
+ * Canonical text representation of a cell's outputs, used both for equality
169
+ * checks and for the per-cell output diff. Stream and `text/plain` data
170
+ * round-trip verbatim; richer mime types (image/png, text/html, …) collapse
171
+ * to a `<mime-type>` placeholder so diffing image bytes doesn't drown the
172
+ * panel in base64 noise.
173
+ */
174
+ export function canonicalOutputs(cell: INotebookCell): string {
175
+ const outputs = cell.outputs ?? [];
176
+ if (outputs.length === 0) {
177
+ return '';
178
+ }
179
+ return outputs.map(formatOutput).join('\n');
180
+ }
181
+
182
+ function formatOutput(output: INotebookOutput): string {
183
+ switch (output.output_type) {
184
+ case 'stream': {
185
+ const stream = output.name ?? 'stdout';
186
+ return `[stream:${stream}]\n${joinMultiline(output.text)}`;
187
+ }
188
+ case 'execute_result':
189
+ case 'display_data': {
190
+ const data = output.data ?? {};
191
+ const lines: string[] = [];
192
+ const tag =
193
+ output.output_type === 'execute_result'
194
+ ? `[execute_result:${output.execution_count ?? '?'}]`
195
+ : '[display_data]';
196
+ lines.push(tag);
197
+ if ('text/plain' in data) {
198
+ lines.push(
199
+ joinMultiline(data['text/plain'] as string | string[] | undefined)
200
+ );
201
+ }
202
+ const otherKeys = Object.keys(data)
203
+ .filter(key => key !== 'text/plain')
204
+ .sort();
205
+ for (const key of otherKeys) {
206
+ lines.push(`<${key}>`);
207
+ }
208
+ return lines.join('\n');
209
+ }
210
+ case 'error': {
211
+ const ename = output.ename ?? 'Error';
212
+ const evalue = output.evalue ?? '';
213
+ // Tracebacks contain ANSI escape codes by default. Strip them so the
214
+ // diff stays readable — coloring information has no analog in a plain
215
+ // text diff, and the escapes themselves would inflate every traceback
216
+ // line into a fake change.
217
+ const traceback = (output.traceback ?? []).map(stripAnsi).join('\n');
218
+ return `[error]\n${ename}: ${evalue}\n${traceback}`;
219
+ }
220
+ default:
221
+ return `[${output.output_type}]`;
222
+ }
223
+ }
224
+
225
+ // Matching ANSI escape codes inherently requires a control character in
226
+ // the pattern, which is what no-control-regex flags. Disable the rule on
227
+ // this single literal — silently emitting un-stripped escapes would just
228
+ // reintroduce the very noise stripAnsi exists to remove.
229
+ // eslint-disable-next-line no-control-regex
230
+ const ANSI_ESCAPE_RE = /\x1B\[[0-9;]*[A-Za-z]/g;
231
+
232
+ function stripAnsi(value: string): string {
233
+ return value.replace(ANSI_ESCAPE_RE, '');
234
+ }
235
+
236
+ /**
237
+ * Stable JSON of the cell's `metadata` object. Keys are sorted recursively
238
+ * so a writer that re-orders the metadata object doesn't surface as a diff.
239
+ * An empty metadata object returns the empty string so freshly-added cells
240
+ * that carry the default `{}` don't produce a `+{}` placeholder section.
241
+ */
242
+ export function canonicalMetadata(cell: INotebookCell): string {
243
+ const md = cell.metadata ?? {};
244
+ if (Object.keys(md).length === 0) {
245
+ return '';
246
+ }
247
+ return stableStringify(md);
248
+ }
249
+
250
+ /**
251
+ * Stable JSON of the notebook-level metadata bundle (kernelspec, language
252
+ * info, nbformat). We diff the bundle as a single block so the user sees
253
+ * all environment-level changes in one place.
254
+ */
255
+ export function canonicalNotebookMetadata(notebook: INotebook): string {
256
+ return stableStringify({
257
+ metadata: notebook.metadata,
258
+ nbformat: notebook.nbformat,
259
+ nbformat_minor: notebook.nbformat_minor
260
+ });
261
+ }
262
+
263
+ function stableStringify(value: unknown): string {
264
+ return JSON.stringify(value, sortKeysReplacer, 2);
265
+ }
266
+
267
+ function sortKeysReplacer(_key: string, value: unknown): unknown {
268
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
269
+ const sorted: Record<string, unknown> = {};
270
+ for (const key of Object.keys(value).sort()) {
271
+ sorted[key] = (value as Record<string, unknown>)[key];
272
+ }
273
+ return sorted;
274
+ }
275
+ return value;
276
+ }
277
+
278
+ /**
279
+ * Equality check used to classify matched cells as `unchanged` vs
280
+ * `modified`. Compares the three things we actually render — type, source,
281
+ * outputs, metadata — using the same canonical representations the diff
282
+ * itself sees, so an "equal" verdict here implies all per-cell diff blocks
283
+ * would be empty.
284
+ */
285
+ export function cellsAreEqual(a: INotebookCell, b: INotebookCell): boolean {
286
+ return (
287
+ a.cell_type === b.cell_type &&
288
+ cellSource(a) === cellSource(b) &&
289
+ canonicalOutputs(a) === canonicalOutputs(b) &&
290
+ canonicalMetadata(a) === canonicalMetadata(b)
291
+ );
292
+ }
293
+
294
+ /**
295
+ * Align cells between two notebook revisions. Cells that carry a stable id
296
+ * (nbformat ≥ 4.5) match on their id; remaining cells fall back to
297
+ * positional matching against the next un-consumed unidentified old cell so
298
+ * older notebooks still produce a sensible diff.
299
+ *
300
+ * Output ordering: entries follow new-file order, with cells that exist
301
+ * only in the old revision appended at the end as `removed`. We don't try
302
+ * to interleave removals at their original position — for typical notebook
303
+ * edits this is more readable than guessing alignment between unrelated
304
+ * insertions and deletions.
305
+ */
306
+ export function alignNotebookCells(
307
+ oldCells: INotebookCell[],
308
+ newCells: INotebookCell[]
309
+ ): NotebookCellDiff[] {
310
+ const oldById = new Map<string, number>();
311
+ oldCells.forEach((cell, index) => {
312
+ if (cell.id !== undefined && cell.id.length > 0) {
313
+ oldById.set(cell.id, index);
314
+ }
315
+ });
316
+
317
+ const consumed = new Set<number>();
318
+ const entries: NotebookCellDiff[] = [];
319
+
320
+ // Cursor used for positional matching of unidentified cells. Only advances
321
+ // forward so we don't double-match a single old cell.
322
+ let positionalCursor = 0;
323
+
324
+ for (let newIndex = 0; newIndex < newCells.length; newIndex++) {
325
+ const newCell = newCells[newIndex];
326
+ let matchedOldIndex: number | undefined;
327
+ if (newCell.id !== undefined && newCell.id.length > 0) {
328
+ const candidate = oldById.get(newCell.id);
329
+ if (candidate !== undefined && !consumed.has(candidate)) {
330
+ matchedOldIndex = candidate;
331
+ }
332
+ } else {
333
+ while (positionalCursor < oldCells.length) {
334
+ const candidateIndex = positionalCursor;
335
+ positionalCursor += 1;
336
+ if (consumed.has(candidateIndex)) {
337
+ continue;
338
+ }
339
+ const candidate = oldCells[candidateIndex];
340
+ if (candidate.id === undefined || candidate.id.length === 0) {
341
+ matchedOldIndex = candidateIndex;
342
+ break;
343
+ }
344
+ }
345
+ }
346
+
347
+ if (matchedOldIndex !== undefined) {
348
+ consumed.add(matchedOldIndex);
349
+ const oldCell = oldCells[matchedOldIndex];
350
+ const kind: 'unchanged' | 'modified' = cellsAreEqual(oldCell, newCell)
351
+ ? 'unchanged'
352
+ : 'modified';
353
+ entries.push({
354
+ kind,
355
+ oldCell,
356
+ newCell,
357
+ oldIndex: matchedOldIndex,
358
+ newIndex
359
+ });
360
+ } else {
361
+ entries.push({ kind: 'added', newCell, newIndex });
362
+ }
363
+ }
364
+
365
+ oldCells.forEach((oldCell, oldIndex) => {
366
+ if (!consumed.has(oldIndex)) {
367
+ entries.push({ kind: 'removed', oldCell, oldIndex });
368
+ }
369
+ });
370
+
371
+ return entries;
372
+ }
373
+
374
+ export interface IBuildNotebookDiffOptions {
375
+ oldText: string;
376
+ newText: string;
377
+ }
378
+
379
+ /**
380
+ * Build a complete notebook diff from the textual contents of two
381
+ * revisions. Returns `null` if either side fails to parse as a notebook;
382
+ * the caller is expected to fall back to a raw text diff in that case.
383
+ *
384
+ * An empty string on either side is treated as an empty notebook (no
385
+ * cells, default metadata) so a freshly-added or deleted notebook still
386
+ * produces a sensible cell-by-cell diff.
387
+ */
388
+ export function buildNotebookDiff(
389
+ options: IBuildNotebookDiffOptions
390
+ ): INotebookDiffResult | null {
391
+ const oldNotebook = parseNotebook(options.oldText);
392
+ const newNotebook = parseNotebook(options.newText);
393
+ if (oldNotebook === null || newNotebook === null) {
394
+ return null;
395
+ }
396
+ const language = detectLanguage(newNotebook) ?? detectLanguage(oldNotebook);
397
+ const cells = alignNotebookCells(oldNotebook.cells, newNotebook.cells);
398
+ const oldMd = canonicalNotebookMetadata(oldNotebook);
399
+ const newMd = canonicalNotebookMetadata(newNotebook);
400
+ let notebookMetadataDiff: FileDiffMetadata | null = null;
401
+ if (oldMd !== newMd) {
402
+ notebookMetadataDiff = parseDiffFromFile(
403
+ { name: 'notebook-metadata.json', contents: oldMd },
404
+ { name: 'notebook-metadata.json', contents: newMd }
405
+ );
406
+ }
407
+ return {
408
+ oldNotebook,
409
+ newNotebook,
410
+ cells,
411
+ language,
412
+ notebookMetadataDiff
413
+ };
414
+ }
415
+
416
+ function detectLanguage(notebook: INotebook): string | undefined {
417
+ const li = notebook.metadata?.language_info as { name?: unknown } | undefined;
418
+ if (li !== undefined && typeof li.name === 'string') {
419
+ return li.name;
420
+ }
421
+ return undefined;
422
+ }
423
+
424
+ /**
425
+ * Pick a filename whose extension drives `@pierre/diffs`'s syntax
426
+ * highlighter. The library never reads the file from disk — `name` only
427
+ * influences token-level rendering, so any plausible extension that maps
428
+ * to the right language is fine.
429
+ */
430
+ function cellFilename(
431
+ cell: INotebookCell,
432
+ language: string | undefined
433
+ ): string {
434
+ if (cell.cell_type === 'markdown') {
435
+ return 'cell.md';
436
+ }
437
+ if (cell.cell_type === 'raw') {
438
+ return 'cell.txt';
439
+ }
440
+ switch ((language ?? '').toLowerCase()) {
441
+ case 'python':
442
+ return 'cell.py';
443
+ case 'javascript':
444
+ case 'node':
445
+ return 'cell.js';
446
+ case 'typescript':
447
+ return 'cell.ts';
448
+ case 'r':
449
+ return 'cell.r';
450
+ case 'julia':
451
+ return 'cell.jl';
452
+ case 'rust':
453
+ return 'cell.rs';
454
+ case 'bash':
455
+ case 'sh':
456
+ return 'cell.sh';
457
+ default:
458
+ return 'cell.txt';
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Build the FileContents pair the diff library wants. `kind` controls the
464
+ * filename so each per-cell sub-diff (source, metadata) gets the right
465
+ * extension and therefore the right highlighter.
466
+ */
467
+ function pierreFiles(
468
+ kind: 'source' | 'metadata',
469
+ oldText: string,
470
+ newText: string,
471
+ oldCell: INotebookCell | null,
472
+ newCell: INotebookCell | null,
473
+ language: string | undefined
474
+ ): { oldFile: FileContents; newFile: FileContents } {
475
+ let name: string;
476
+ if (kind === 'metadata') {
477
+ name = 'cell-metadata.json';
478
+ } else {
479
+ // Use the new cell's type/language when available; fall back to the old
480
+ // side so a deleted cell still gets the right highlighter.
481
+ const reference = newCell ?? oldCell;
482
+ name = reference !== null ? cellFilename(reference, language) : 'cell.txt';
483
+ }
484
+ return {
485
+ oldFile: { name, contents: oldText },
486
+ newFile: { name, contents: newText }
487
+ };
488
+ }
489
+
490
+ interface ICellSubDiff {
491
+ kind: 'source' | 'metadata';
492
+ metadata: FileDiffMetadata;
493
+ }
494
+
495
+ /**
496
+ * Compute the source / metadata sub-diffs for a single cell entry.
497
+ * Returns the empty list for `unchanged` entries so the caller can collapse
498
+ * them; for `added` / `removed` entries the populated side is paired with
499
+ * an empty counterpart so the library treats every line as an addition or
500
+ * deletion respectively.
501
+ *
502
+ * Outputs are deliberately *not* included here — they're rendered through
503
+ * rendermime side-by-side in {@link OutputsSection} so images render as
504
+ * images, HTML as HTML, etc. The canonical text form survives for equality
505
+ * (it drives the `unchanged` classification) but not for display.
506
+ */
507
+ function buildCellSubDiffs(
508
+ entry: NotebookCellDiff,
509
+ language: string | undefined
510
+ ): ICellSubDiff[] {
511
+ if (entry.kind === 'unchanged') {
512
+ return [];
513
+ }
514
+ const oldCell = entry.kind === 'added' ? null : entry.oldCell;
515
+ const newCell = entry.kind === 'removed' ? null : entry.newCell;
516
+
517
+ const oldSource = oldCell !== null ? cellSource(oldCell) : '';
518
+ const newSource = newCell !== null ? cellSource(newCell) : '';
519
+ const oldMd = oldCell !== null ? canonicalMetadata(oldCell) : '';
520
+ const newMd = newCell !== null ? canonicalMetadata(newCell) : '';
521
+
522
+ const subdiffs: ICellSubDiff[] = [];
523
+ const sections: Array<{
524
+ kind: 'source' | 'metadata';
525
+ oldText: string;
526
+ newText: string;
527
+ }> = [
528
+ { kind: 'source', oldText: oldSource, newText: newSource },
529
+ { kind: 'metadata', oldText: oldMd, newText: newMd }
530
+ ];
531
+ for (const section of sections) {
532
+ if (section.oldText === section.newText) {
533
+ continue;
534
+ }
535
+ const { oldFile, newFile } = pierreFiles(
536
+ section.kind,
537
+ section.oldText,
538
+ section.newText,
539
+ oldCell,
540
+ newCell,
541
+ language
542
+ );
543
+ subdiffs.push({
544
+ kind: section.kind,
545
+ metadata: parseDiffFromFile(oldFile, newFile)
546
+ });
547
+ }
548
+
549
+ return subdiffs;
550
+ }
551
+
552
+ /**
553
+ * Common diff library options shared across every per-cell sub-diff and
554
+ * the notebook-level metadata diff. Split mode matches the file-diff path
555
+ * and gives the user the same left=old / right=new mental model inside
556
+ * each cell — cells stack vertically but each cell internally reads
557
+ * side-by-side, which matches how nbdime renders.
558
+ */
559
+ function diffLibraryOptions(theme: DiffsThemeNames, dark: boolean) {
560
+ return {
561
+ diffStyle: 'split' as const,
562
+ disableFileHeader: true,
563
+ theme,
564
+ themeType: dark ? ('dark' as const) : ('light' as const)
565
+ };
566
+ }
567
+
568
+ /**
569
+ * Mount a Lumino {@link Widget} inside a React tree. The widget is the
570
+ * source of truth for its DOM — React just owns the host element and
571
+ * Lumino owns the content the widget paints into it. Detach + dispose
572
+ * happens on unmount or when a new widget arrives, so the parent doesn't
573
+ * need to manage the widget's lifecycle separately.
574
+ */
575
+ function LuminoWidget(props: {
576
+ widget: Widget;
577
+ className?: string;
578
+ }): React.ReactElement {
579
+ const { widget, className } = props;
580
+ const ref = React.useRef<HTMLDivElement | null>(null);
581
+ React.useEffect(() => {
582
+ const host = ref.current;
583
+ if (host === null || widget.isDisposed) {
584
+ return;
585
+ }
586
+ try {
587
+ Widget.attach(widget, host);
588
+ } catch (err) {
589
+ // Lumino refuses to attach a widget whose host isn't connected to
590
+ // the document, or that's already attached elsewhere. Either way
591
+ // there's nothing useful to do here — log and bail so the parent
592
+ // tree still mounts.
593
+ console.warn('xtralab: Widget.attach failed', err);
594
+ return;
595
+ }
596
+ return () => {
597
+ // Lumino's `Widget.detach` throws "Widget is not attached" when
598
+ // either the `IsAttached` flag is false *or* the node is no longer
599
+ // connected to the document. During a parent React unmount the
600
+ // host element gets removed from the DOM before this cleanup
601
+ // runs, so `node.isConnected` is already `false` while the
602
+ // `IsAttached` flag is still set from our earlier `Widget.attach`
603
+ // call. Calling `Widget.detach` in that state would throw, and —
604
+ // worse — `widget.dispose()` re-enters the same detach branch
605
+ // internally (its `else if (this.isAttached)` guard does not
606
+ // check the DOM connection), so leaving the flag stale produces
607
+ // the noisy "Widget is not attached" warning out of `dispose`.
608
+ //
609
+ // Resolve the race by driving Lumino's detach lifecycle by hand
610
+ // when the host has already been torn down: send `BeforeDetach`
611
+ // and `AfterDetach` so the layout cleanup hooks run and the flag
612
+ // clears, then dispose. When the host is still connected the
613
+ // ordinary `Widget.detach` path handles both steps for us.
614
+ if (widget.isAttached) {
615
+ if (widget.node.isConnected) {
616
+ try {
617
+ Widget.detach(widget);
618
+ } catch (err) {
619
+ console.warn('xtralab: Widget.detach failed', err);
620
+ }
621
+ } else {
622
+ try {
623
+ MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
624
+ MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
625
+ } catch (err) {
626
+ console.warn(
627
+ 'xtralab: Widget detach messaging failed',
628
+ err
629
+ );
630
+ }
631
+ }
632
+ }
633
+ try {
634
+ if (!widget.isDisposed) {
635
+ widget.dispose();
636
+ }
637
+ } catch (err) {
638
+ console.warn('xtralab: Widget.dispose failed', err);
639
+ }
640
+ };
641
+ }, [widget]);
642
+ return <div ref={ref} className={className} />;
643
+ }
644
+
645
+ /**
646
+ * Render a sequence of nbformat outputs through JupyterLab's rendermime,
647
+ * exactly the way a live notebook would. Wrapped here so the side-by-side
648
+ * cell layout can drop a fully-rendered output column on either side
649
+ * without re-implementing rich mime rendering.
650
+ *
651
+ * The output area is created `trusted` because the source git ref is on
652
+ * the user's machine (working tree / index / HEAD). A diff viewer that
653
+ * sandboxed its own user's outputs would just be inconvenient.
654
+ */
655
+ function OutputsPreview(props: {
656
+ outputs: INotebookOutput[];
657
+ rendermime: IRenderMimeRegistry;
658
+ className?: string;
659
+ }): React.ReactElement {
660
+ const { outputs, rendermime, className } = props;
661
+ const widget = React.useMemo(() => {
662
+ const model = new OutputAreaModel({
663
+ values: outputs as nbformat.IOutput[],
664
+ trusted: true
665
+ });
666
+ return new SimplifiedOutputArea({ model, rendermime });
667
+ }, [outputs, rendermime]);
668
+ return <LuminoWidget widget={widget} className={className} />;
669
+ }
670
+
671
+ /**
672
+ * Render markdown source through rendermime so the cell preview matches
673
+ * what JupyterLab would render in a live notebook (LaTeX, syntax-highlighted
674
+ * code fences, sanitized HTML, …). Used for both the rendered preview that
675
+ * sits next to a markdown cell's source diff, and for unchanged markdown
676
+ * cells when the user expands them.
677
+ */
678
+ function MarkdownPreview(props: {
679
+ source: string;
680
+ rendermime: IRenderMimeRegistry;
681
+ className?: string;
682
+ }): React.ReactElement {
683
+ const { source, rendermime, className } = props;
684
+ const renderer = React.useMemo(
685
+ () => rendermime.createRenderer('text/markdown'),
686
+ [rendermime]
687
+ );
688
+ React.useEffect(() => {
689
+ const model = new MimeModel({
690
+ data: { 'text/markdown': source },
691
+ trusted: true
692
+ });
693
+ renderer.renderModel(model).catch(err => {
694
+ console.error('xtralab: markdown render failed', err);
695
+ });
696
+ }, [renderer, source]);
697
+ return <LuminoWidget widget={renderer} className={className} />;
698
+ }
699
+
700
+ /**
701
+ * Two-column "old | new" layout used by both {@link OutputsSection} and
702
+ * {@link MarkdownPreviewSection}. The visible side(s) depend on the cell
703
+ * kind: modified shows both, added shows only new, removed shows only old,
704
+ * unchanged collapses to a single full-width pane (since both sides
705
+ * render the same content).
706
+ */
707
+ function SideBySidePanes(props: {
708
+ label: string;
709
+ oldNode: React.ReactNode;
710
+ newNode: React.ReactNode;
711
+ hasOld: boolean;
712
+ hasNew: boolean;
713
+ }): React.ReactElement | null {
714
+ const { label, oldNode, newNode, hasOld, hasNew } = props;
715
+ if (!hasOld && !hasNew) {
716
+ return null;
717
+ }
718
+ const both = hasOld && hasNew;
719
+ return (
720
+ <div
721
+ className={`${NOTEBOOK_DIFF_CSS_CLASS}-sideBySide`}
722
+ data-layout={both ? 'split' : 'single'}
723
+ >
724
+ <div className={`${NOTEBOOK_DIFF_CSS_CLASS}-subdiffLabel`}>{label}</div>
725
+ <div className={`${NOTEBOOK_DIFF_CSS_CLASS}-sideBySideGrid`}>
726
+ {both || hasOld ? (
727
+ <div
728
+ className={`${NOTEBOOK_DIFF_CSS_CLASS}-sidePane`}
729
+ data-side="old"
730
+ >
731
+ {oldNode}
732
+ </div>
733
+ ) : null}
734
+ {both || hasNew ? (
735
+ <div
736
+ className={`${NOTEBOOK_DIFF_CSS_CLASS}-sidePane`}
737
+ data-side="new"
738
+ >
739
+ {newNode}
740
+ </div>
741
+ ) : null}
742
+ </div>
743
+ </div>
744
+ );
745
+ }
746
+
747
+ /**
748
+ * Rendered outputs section for a cell. Renders old / new outputs through
749
+ * rendermime in two columns; for added or removed cells only the populated
750
+ * side is shown. Returns `null` (so the section is hidden entirely) when
751
+ * neither side has any outputs, or when the same set of outputs appears
752
+ * on both sides and the host doesn't want the duplicate render.
753
+ */
754
+ function OutputsSection(props: {
755
+ entry: NotebookCellDiff;
756
+ rendermime: IRenderMimeRegistry | null;
757
+ placement: CellPlacement;
758
+ }): React.ReactElement | null {
759
+ const { entry, rendermime, placement } = props;
760
+ const oldCell = 'oldCell' in entry ? entry.oldCell : null;
761
+ const newCell = 'newCell' in entry ? entry.newCell : null;
762
+ const oldOutputs = oldCell?.outputs ?? [];
763
+ const newOutputs = newCell?.outputs ?? [];
764
+ const hasOld = oldOutputs.length > 0;
765
+ const hasNew = newOutputs.length > 0;
766
+ if (!hasOld && !hasNew) {
767
+ return null;
768
+ }
769
+ // For added / removed cells the parent already lives in a single outer
770
+ // column — rendering the populated side full width within that column
771
+ // is the right move. SideBySidePanes itself collapses to a single grid
772
+ // column when only one side has content, so we can use it for both
773
+ // cases by feeding it only the side(s) we want.
774
+ const showOld = placement !== 'right' && hasOld;
775
+ const showNew = placement !== 'left' && hasNew;
776
+ if (rendermime === null) {
777
+ return (
778
+ <SideBySidePanes
779
+ label="Outputs"
780
+ hasOld={showOld}
781
+ hasNew={showNew}
782
+ oldNode={
783
+ <pre className={`${NOTEBOOK_DIFF_CSS_CLASS}-fallbackText`}>
784
+ {oldCell !== null ? canonicalOutputs(oldCell) : ''}
785
+ </pre>
786
+ }
787
+ newNode={
788
+ <pre className={`${NOTEBOOK_DIFF_CSS_CLASS}-fallbackText`}>
789
+ {newCell !== null ? canonicalOutputs(newCell) : ''}
790
+ </pre>
791
+ }
792
+ />
793
+ );
794
+ }
795
+ return (
796
+ <SideBySidePanes
797
+ label="Outputs"
798
+ hasOld={showOld}
799
+ hasNew={showNew}
800
+ oldNode={<OutputsPreview outputs={oldOutputs} rendermime={rendermime} />}
801
+ newNode={<OutputsPreview outputs={newOutputs} rendermime={rendermime} />}
802
+ />
803
+ );
804
+ }
805
+
806
+ /**
807
+ * Rendered markdown preview section. Sits next to (and below) the markdown
808
+ * source diff so the user sees both the source-level changes and how the
809
+ * rendered cell looks. Skipped for non-markdown cells.
810
+ */
811
+ function MarkdownPreviewSection(props: {
812
+ entry: NotebookCellDiff;
813
+ rendermime: IRenderMimeRegistry | null;
814
+ placement: CellPlacement;
815
+ }): React.ReactElement | null {
816
+ const { entry, rendermime, placement } = props;
817
+ const isMarkdown = referenceCellType(entry) === 'markdown';
818
+ if (!isMarkdown || rendermime === null) {
819
+ return null;
820
+ }
821
+ const oldSource = 'oldCell' in entry ? cellSource(entry.oldCell) : '';
822
+ const newSource = 'newCell' in entry ? cellSource(entry.newCell) : '';
823
+ const showOld = placement !== 'right' && oldSource.length > 0;
824
+ const showNew = placement !== 'left' && newSource.length > 0;
825
+ if (!showOld && !showNew) {
826
+ return null;
827
+ }
828
+ return (
829
+ <SideBySidePanes
830
+ label="Rendered"
831
+ hasOld={showOld}
832
+ hasNew={showNew}
833
+ oldNode={<MarkdownPreview source={oldSource} rendermime={rendermime} />}
834
+ newNode={<MarkdownPreview source={newSource} rendermime={rendermime} />}
835
+ />
836
+ );
837
+ }
838
+
839
+ interface INotebookDiffViewProps {
840
+ diff: INotebookDiffResult;
841
+ dark: boolean;
842
+ rendermime: IRenderMimeRegistry | null;
843
+ }
844
+
845
+ /**
846
+ * The notebook-level diff view. Lays out cells in a 2-column grid where
847
+ * the left column tracks the *old* notebook and the right column tracks
848
+ * the *new* one — modified and unchanged cells span both columns (their
849
+ * internal split-mode FileDiff aligns with the outer columns), added
850
+ * cells live in the right column with an empty placeholder on the left,
851
+ * and removed cells live in the left column with an empty placeholder on
852
+ * the right. The diff library's red/green tint within each cell makes
853
+ * the orientation clear without a separate column header.
854
+ */
855
+ export function NotebookDiffView(
856
+ props: INotebookDiffViewProps
857
+ ): React.ReactElement {
858
+ const { diff, dark, rendermime } = props;
859
+ const theme: DiffsThemeNames = dark ? 'pierre-dark' : 'pierre-light';
860
+ const summary = React.useMemo(() => summarize(diff.cells), [diff.cells]);
861
+ return (
862
+ <div className={NOTEBOOK_DIFF_CSS_CLASS}>
863
+ <NotebookDiffHeader summary={summary} />
864
+ <div className={`${NOTEBOOK_DIFF_CSS_CLASS}-grid`}>
865
+ {diff.cells.map(entry => (
866
+ <CellEntryRow
867
+ key={cellEntryKey(entry)}
868
+ entry={entry}
869
+ language={diff.language}
870
+ theme={theme}
871
+ dark={dark}
872
+ rendermime={rendermime}
873
+ />
874
+ ))}
875
+ </div>
876
+ {diff.notebookMetadataDiff !== null ? (
877
+ <NotebookMetadataBlock
878
+ metadata={diff.notebookMetadataDiff}
879
+ theme={theme}
880
+ dark={dark}
881
+ />
882
+ ) : null}
883
+ </div>
884
+ );
885
+ }
886
+
887
+ /**
888
+ * Place a single cell entry into the outer 2-column grid. Modified /
889
+ * unchanged entries span the full row; added entries take the right
890
+ * column with an empty placeholder on the left; removed entries take the
891
+ * left column with an empty placeholder on the right. The placeholders
892
+ * are visible (subtle dashed outline) so the user can see *where* a
893
+ * cell was inserted or removed relative to the other side.
894
+ */
895
+ function CellEntryRow(props: ICellDiffBlockProps): React.ReactElement {
896
+ const { entry } = props;
897
+ if (entry.kind === 'added') {
898
+ return (
899
+ <>
900
+ <EmptyCellPlaceholder side="old" reason="added" />
901
+ <CellDiffBlock {...props} placement="right" />
902
+ </>
903
+ );
904
+ }
905
+ if (entry.kind === 'removed') {
906
+ return (
907
+ <>
908
+ <CellDiffBlock {...props} placement="left" />
909
+ <EmptyCellPlaceholder side="new" reason="removed" />
910
+ </>
911
+ );
912
+ }
913
+ return <CellDiffBlock {...props} placement="full" />;
914
+ }
915
+
916
+ function EmptyCellPlaceholder(props: {
917
+ side: 'old' | 'new';
918
+ reason: 'added' | 'removed';
919
+ }): React.ReactElement {
920
+ const { side, reason } = props;
921
+ const label = reason === 'added' ? 'cell added on the right' : 'cell removed';
922
+ return (
923
+ <div
924
+ className={`${NOTEBOOK_DIFF_CSS_CLASS}-empty`}
925
+ data-side={side}
926
+ aria-label={label}
927
+ >
928
+ <span className={`${NOTEBOOK_DIFF_CSS_CLASS}-emptyLabel`}>
929
+ {reason === 'added' ? '— no cell —' : '— cell removed —'}
930
+ </span>
931
+ </div>
932
+ );
933
+ }
934
+
935
+ interface INotebookDiffSummary {
936
+ added: number;
937
+ removed: number;
938
+ modified: number;
939
+ unchanged: number;
940
+ }
941
+
942
+ function summarize(cells: NotebookCellDiff[]): INotebookDiffSummary {
943
+ const summary: INotebookDiffSummary = {
944
+ added: 0,
945
+ removed: 0,
946
+ modified: 0,
947
+ unchanged: 0
948
+ };
949
+ for (const entry of cells) {
950
+ summary[entry.kind] += 1;
951
+ }
952
+ return summary;
953
+ }
954
+
955
+ function NotebookDiffHeader(props: {
956
+ summary: INotebookDiffSummary;
957
+ }): React.ReactElement {
958
+ const { summary } = props;
959
+ const total =
960
+ summary.added + summary.removed + summary.modified + summary.unchanged;
961
+ return (
962
+ <div className={`${NOTEBOOK_DIFF_CSS_CLASS}-header`}>
963
+ <span className={`${NOTEBOOK_DIFF_CSS_CLASS}-headerCount`}>
964
+ {total} cell{total === 1 ? '' : 's'}
965
+ </span>
966
+ {summary.modified > 0 ? (
967
+ <span
968
+ className={`${NOTEBOOK_DIFF_CSS_CLASS}-stat`}
969
+ data-kind="modified"
970
+ >
971
+ {summary.modified} modified
972
+ </span>
973
+ ) : null}
974
+ {summary.added > 0 ? (
975
+ <span className={`${NOTEBOOK_DIFF_CSS_CLASS}-stat`} data-kind="added">
976
+ {summary.added} added
977
+ </span>
978
+ ) : null}
979
+ {summary.removed > 0 ? (
980
+ <span className={`${NOTEBOOK_DIFF_CSS_CLASS}-stat`} data-kind="removed">
981
+ {summary.removed} removed
982
+ </span>
983
+ ) : null}
984
+ {summary.unchanged > 0 ? (
985
+ <span
986
+ className={`${NOTEBOOK_DIFF_CSS_CLASS}-stat`}
987
+ data-kind="unchanged"
988
+ >
989
+ {summary.unchanged} unchanged
990
+ </span>
991
+ ) : null}
992
+ </div>
993
+ );
994
+ }
995
+
996
+ function cellEntryKey(entry: NotebookCellDiff): string {
997
+ if (entry.kind === 'added') {
998
+ return `added:${entry.newIndex}:${entry.newCell.id ?? ''}`;
999
+ }
1000
+ if (entry.kind === 'removed') {
1001
+ return `removed:${entry.oldIndex}:${entry.oldCell.id ?? ''}`;
1002
+ }
1003
+ return `${entry.kind}:${entry.newIndex}:${entry.newCell.id ?? entry.oldCell.id ?? ''}`;
1004
+ }
1005
+
1006
+ interface ICellDiffBlockProps {
1007
+ entry: NotebookCellDiff;
1008
+ language: string | undefined;
1009
+ theme: DiffsThemeNames;
1010
+ dark: boolean;
1011
+ rendermime: IRenderMimeRegistry | null;
1012
+ }
1013
+
1014
+ /**
1015
+ * How the cell card should sit inside the outer 2-column grid:
1016
+ * - `full` → spans both columns (modified / unchanged cells)
1017
+ * - `left` → left column only (removed cells)
1018
+ * - `right` → right column only (added cells)
1019
+ *
1020
+ * Drives both grid placement and the inner layout choices: a single-column
1021
+ * placement uses unified-mode FileDiff for the source (since one side is
1022
+ * empty) and renders only the populated side of outputs / markdown
1023
+ * previews, while a `full` placement keeps split mode and side-by-side
1024
+ * panes.
1025
+ */
1026
+ type CellPlacement = 'full' | 'left' | 'right';
1027
+
1028
+ interface IPlacedCellDiffBlockProps extends ICellDiffBlockProps {
1029
+ placement: CellPlacement;
1030
+ }
1031
+
1032
+ function CellDiffBlock(props: IPlacedCellDiffBlockProps): React.ReactElement {
1033
+ const { entry, language, theme, dark, rendermime, placement } = props;
1034
+ // Unchanged cells start collapsed — the user opted out of seeing those
1035
+ // sections by virtue of them being unchanged. A toggle lets them peek if
1036
+ // they want.
1037
+ const [collapsed, setCollapsed] = React.useState<boolean>(
1038
+ entry.kind === 'unchanged'
1039
+ );
1040
+
1041
+ const subdiffs = React.useMemo(
1042
+ () => (collapsed ? [] : buildCellSubDiffs(entry, language)),
1043
+ [collapsed, entry, language]
1044
+ );
1045
+
1046
+ const cellType = referenceCellType(entry);
1047
+ const indexLabel = cellIndexLabel(entry);
1048
+ const isCodeCell = cellType === 'code';
1049
+ // Output rendering is only meaningful for code cells. Markdown / raw cells
1050
+ // never carry outputs in nbformat.
1051
+ const showOutputs = isCodeCell && entry.kind !== 'unchanged';
1052
+
1053
+ return (
1054
+ <section
1055
+ className={`${NOTEBOOK_DIFF_CSS_CLASS}-cell`}
1056
+ data-kind={entry.kind}
1057
+ data-cell-type={cellType}
1058
+ data-placement={placement}
1059
+ >
1060
+ <header
1061
+ className={`${NOTEBOOK_DIFF_CSS_CLASS}-cellHeader`}
1062
+ onClick={() => setCollapsed(c => !c)}
1063
+ role="button"
1064
+ tabIndex={0}
1065
+ aria-expanded={!collapsed}
1066
+ onKeyDown={event => {
1067
+ if (event.key === 'Enter' || event.key === ' ') {
1068
+ event.preventDefault();
1069
+ setCollapsed(c => !c);
1070
+ }
1071
+ }}
1072
+ >
1073
+ <span className={`${NOTEBOOK_DIFF_CSS_CLASS}-cellChevron`}>
1074
+ {collapsed ? '▸' : '▾'}
1075
+ </span>
1076
+ <span
1077
+ className={`${NOTEBOOK_DIFF_CSS_CLASS}-cellBadge`}
1078
+ data-kind={entry.kind}
1079
+ >
1080
+ {kindBadge(entry.kind)}
1081
+ </span>
1082
+ <span className={`${NOTEBOOK_DIFF_CSS_CLASS}-cellLabel`}>
1083
+ {indexLabel} · {cellType}
1084
+ </span>
1085
+ {entry.kind === 'unchanged' ? (
1086
+ <span className={`${NOTEBOOK_DIFF_CSS_CLASS}-cellHint`}>
1087
+ unchanged
1088
+ </span>
1089
+ ) : null}
1090
+ </header>
1091
+ {!collapsed ? (
1092
+ <div className={`${NOTEBOOK_DIFF_CSS_CLASS}-cellBody`}>
1093
+ {entry.kind === 'unchanged' ? (
1094
+ <UnchangedCellBody entry={entry} rendermime={rendermime} />
1095
+ ) : (
1096
+ <>
1097
+ {subdiffs.map(sub => (
1098
+ <CellSubDiff
1099
+ key={sub.kind}
1100
+ kind={sub.kind}
1101
+ metadata={sub.metadata}
1102
+ theme={theme}
1103
+ dark={dark}
1104
+ placement={placement}
1105
+ />
1106
+ ))}
1107
+ <MarkdownPreviewSection
1108
+ entry={entry}
1109
+ rendermime={rendermime}
1110
+ placement={placement}
1111
+ />
1112
+ {showOutputs ? (
1113
+ <OutputsSection
1114
+ entry={entry}
1115
+ rendermime={rendermime}
1116
+ placement={placement}
1117
+ />
1118
+ ) : null}
1119
+ {subdiffs.length === 0 &&
1120
+ !(
1121
+ showOutputs &&
1122
+ (('oldCell' in entry && entry.oldCell.outputs?.length) ||
1123
+ ('newCell' in entry && entry.newCell.outputs?.length))
1124
+ ) ? (
1125
+ <div className={`${NOTEBOOK_DIFF_CSS_CLASS}-cellEmpty`}>
1126
+ No content differences detected.
1127
+ </div>
1128
+ ) : null}
1129
+ </>
1130
+ )}
1131
+ </div>
1132
+ ) : null}
1133
+ </section>
1134
+ );
1135
+ }
1136
+
1137
+ function CellSubDiff(props: {
1138
+ kind: 'source' | 'metadata';
1139
+ metadata: FileDiffMetadata;
1140
+ theme: DiffsThemeNames;
1141
+ dark: boolean;
1142
+ placement: CellPlacement;
1143
+ }): React.ReactElement {
1144
+ const { kind, metadata, theme, dark, placement } = props;
1145
+ // For modified / unchanged cells the diff spans both outer columns, and
1146
+ // split mode lets it visually align with the outer old | new lanes. For
1147
+ // added / removed cells the diff sits inside a single outer column, so
1148
+ // unified mode keeps the content readable instead of leaving half the
1149
+ // column empty.
1150
+ const diffStyle: 'split' | 'unified' =
1151
+ placement === 'full' ? 'split' : 'unified';
1152
+ return (
1153
+ <div className={`${NOTEBOOK_DIFF_CSS_CLASS}-subdiff`} data-section={kind}>
1154
+ <div className={`${NOTEBOOK_DIFF_CSS_CLASS}-subdiffLabel`}>
1155
+ {subdiffLabel(kind)}
1156
+ </div>
1157
+ <FileDiff
1158
+ fileDiff={metadata}
1159
+ // See diffWidget.tsx — the worker bootstrap can't resolve through
1160
+ // JupyterLab's federation pipeline, so every diff in this extension
1161
+ // runs on the main thread. Cell diffs are small enough that this is
1162
+ // not a performance concern.
1163
+ disableWorkerPool={true}
1164
+ options={{ ...diffLibraryOptions(theme, dark), diffStyle }}
1165
+ />
1166
+ </div>
1167
+ );
1168
+ }
1169
+
1170
+ function NotebookMetadataBlock(props: {
1171
+ metadata: FileDiffMetadata;
1172
+ theme: DiffsThemeNames;
1173
+ dark: boolean;
1174
+ }): React.ReactElement {
1175
+ const { metadata, theme, dark } = props;
1176
+ return (
1177
+ <section
1178
+ className={`${NOTEBOOK_DIFF_CSS_CLASS}-cell`}
1179
+ data-kind="modified"
1180
+ data-cell-type="metadata"
1181
+ >
1182
+ <header
1183
+ className={`${NOTEBOOK_DIFF_CSS_CLASS}-cellHeader`}
1184
+ aria-label="Notebook metadata"
1185
+ >
1186
+ <span className={`${NOTEBOOK_DIFF_CSS_CLASS}-cellChevron`}>▾</span>
1187
+ <span
1188
+ className={`${NOTEBOOK_DIFF_CSS_CLASS}-cellBadge`}
1189
+ data-kind="modified"
1190
+ >
1191
+ M
1192
+ </span>
1193
+ <span className={`${NOTEBOOK_DIFF_CSS_CLASS}-cellLabel`}>
1194
+ Notebook metadata
1195
+ </span>
1196
+ </header>
1197
+ <div className={`${NOTEBOOK_DIFF_CSS_CLASS}-cellBody`}>
1198
+ <CellSubDiff
1199
+ kind="metadata"
1200
+ metadata={metadata}
1201
+ theme={theme}
1202
+ dark={dark}
1203
+ placement="full"
1204
+ />
1205
+ </div>
1206
+ </section>
1207
+ );
1208
+ }
1209
+
1210
+ /**
1211
+ * Body for an unchanged cell when the user has expanded it. The cell's
1212
+ * content matches on both sides, so we render once full-width: markdown
1213
+ * cells go through rendermime (if available) so they look like the live
1214
+ * notebook would render them, code cells fall through to a verbatim
1215
+ * pre-formatted block, and any code outputs render through rendermime
1216
+ * below the source.
1217
+ */
1218
+ function UnchangedCellBody(props: {
1219
+ entry: Extract<NotebookCellDiff, { kind: 'unchanged' }>;
1220
+ rendermime: IRenderMimeRegistry | null;
1221
+ }): React.ReactElement {
1222
+ const { entry, rendermime } = props;
1223
+ const cell = entry.newCell;
1224
+ const source = cellSource(cell);
1225
+ const isMarkdown = cell.cell_type === 'markdown';
1226
+ const outputs = cell.outputs ?? [];
1227
+ const hasOutputs = outputs.length > 0;
1228
+ return (
1229
+ <>
1230
+ {isMarkdown && rendermime !== null && source.length > 0 ? (
1231
+ <div className={`${NOTEBOOK_DIFF_CSS_CLASS}-unchangedRendered`}>
1232
+ <MarkdownPreview source={source} rendermime={rendermime} />
1233
+ </div>
1234
+ ) : (
1235
+ <pre className={`${NOTEBOOK_DIFF_CSS_CLASS}-unchangedSource`}>
1236
+ {source.length > 0 ? source : '(empty cell)'}
1237
+ </pre>
1238
+ )}
1239
+ {hasOutputs && rendermime !== null ? (
1240
+ <div className={`${NOTEBOOK_DIFF_CSS_CLASS}-unchangedOutputs`}>
1241
+ <div className={`${NOTEBOOK_DIFF_CSS_CLASS}-subdiffLabel`}>
1242
+ Outputs
1243
+ </div>
1244
+ <OutputsPreview outputs={outputs} rendermime={rendermime} />
1245
+ </div>
1246
+ ) : null}
1247
+ </>
1248
+ );
1249
+ }
1250
+
1251
+ function referenceCellType(entry: NotebookCellDiff): string {
1252
+ if (entry.kind === 'added') {
1253
+ return entry.newCell.cell_type;
1254
+ }
1255
+ if (entry.kind === 'removed') {
1256
+ return entry.oldCell.cell_type;
1257
+ }
1258
+ return entry.newCell.cell_type;
1259
+ }
1260
+
1261
+ function cellIndexLabel(entry: NotebookCellDiff): string {
1262
+ if (entry.kind === 'added') {
1263
+ return `Cell ${entry.newIndex + 1}`;
1264
+ }
1265
+ if (entry.kind === 'removed') {
1266
+ return `Cell ${entry.oldIndex + 1} (removed)`;
1267
+ }
1268
+ if (entry.newIndex === entry.oldIndex) {
1269
+ return `Cell ${entry.newIndex + 1}`;
1270
+ }
1271
+ return `Cell ${entry.oldIndex + 1} → ${entry.newIndex + 1}`;
1272
+ }
1273
+
1274
+ function kindBadge(kind: NotebookCellDiff['kind']): string {
1275
+ switch (kind) {
1276
+ case 'added':
1277
+ return 'A';
1278
+ case 'removed':
1279
+ return 'D';
1280
+ case 'modified':
1281
+ return 'M';
1282
+ case 'unchanged':
1283
+ return '·';
1284
+ }
1285
+ }
1286
+
1287
+ function subdiffLabel(kind: 'source' | 'metadata'): string {
1288
+ switch (kind) {
1289
+ case 'source':
1290
+ return 'Source';
1291
+ case 'metadata':
1292
+ return 'Metadata';
1293
+ }
1294
+ }