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,805 @@
1
+ import * as React from 'react';
2
+ import { OutputAreaModel, SimplifiedOutputArea } from '@jupyterlab/outputarea';
3
+ import { MimeModel } from '@jupyterlab/rendermime';
4
+ import { MessageLoop } from '@lumino/messaging';
5
+ import { Widget } from '@lumino/widgets';
6
+ import { FileDiff } from '@pierre/diffs/react';
7
+ import { parseDiffFromFile } from '@pierre/diffs';
8
+ /**
9
+ * The CSS class added to the root of the notebook diff. Selectors that
10
+ * style cell sections, headers, and the per-cell diff blocks hang off this
11
+ * class.
12
+ */
13
+ export const NOTEBOOK_DIFF_CSS_CLASS = 'jp-xtralab-NotebookDiff';
14
+ /**
15
+ * Parse notebook JSON. Returns `null` if the text doesn't parse or doesn't
16
+ * look like a notebook (missing `cells` array). The caller falls back to a
17
+ * raw text diff so a malformed file is still inspectable.
18
+ */
19
+ export function parseNotebook(text) {
20
+ if (text.length === 0) {
21
+ return emptyNotebook();
22
+ }
23
+ let value;
24
+ try {
25
+ value = JSON.parse(text);
26
+ }
27
+ catch (_a) {
28
+ return null;
29
+ }
30
+ if (value === null ||
31
+ typeof value !== 'object' ||
32
+ !Array.isArray(value.cells)) {
33
+ return null;
34
+ }
35
+ const nb = value;
36
+ if (nb.metadata === undefined || typeof nb.metadata !== 'object') {
37
+ nb.metadata = {};
38
+ }
39
+ return nb;
40
+ }
41
+ function emptyNotebook() {
42
+ return {
43
+ cells: [],
44
+ metadata: {},
45
+ nbformat: 4,
46
+ nbformat_minor: 5
47
+ };
48
+ }
49
+ /**
50
+ * nbformat allows multiline string fields (`source`, stream `text`,
51
+ * `text/plain` outputs, …) to be either a single string or an array of
52
+ * strings. Normalize to one string so equality checks and diffs see the
53
+ * same shape regardless of how the writer chose to serialise it.
54
+ */
55
+ export function joinMultiline(value) {
56
+ if (value === undefined) {
57
+ return '';
58
+ }
59
+ return Array.isArray(value) ? value.join('') : value;
60
+ }
61
+ export function cellSource(cell) {
62
+ return joinMultiline(cell.source);
63
+ }
64
+ /**
65
+ * Canonical text representation of a cell's outputs, used both for equality
66
+ * checks and for the per-cell output diff. Stream and `text/plain` data
67
+ * round-trip verbatim; richer mime types (image/png, text/html, …) collapse
68
+ * to a `<mime-type>` placeholder so diffing image bytes doesn't drown the
69
+ * panel in base64 noise.
70
+ */
71
+ export function canonicalOutputs(cell) {
72
+ var _a;
73
+ const outputs = (_a = cell.outputs) !== null && _a !== void 0 ? _a : [];
74
+ if (outputs.length === 0) {
75
+ return '';
76
+ }
77
+ return outputs.map(formatOutput).join('\n');
78
+ }
79
+ function formatOutput(output) {
80
+ var _a, _b, _c, _d, _e, _f;
81
+ switch (output.output_type) {
82
+ case 'stream': {
83
+ const stream = (_a = output.name) !== null && _a !== void 0 ? _a : 'stdout';
84
+ return `[stream:${stream}]\n${joinMultiline(output.text)}`;
85
+ }
86
+ case 'execute_result':
87
+ case 'display_data': {
88
+ const data = (_b = output.data) !== null && _b !== void 0 ? _b : {};
89
+ const lines = [];
90
+ const tag = output.output_type === 'execute_result'
91
+ ? `[execute_result:${(_c = output.execution_count) !== null && _c !== void 0 ? _c : '?'}]`
92
+ : '[display_data]';
93
+ lines.push(tag);
94
+ if ('text/plain' in data) {
95
+ lines.push(joinMultiline(data['text/plain']));
96
+ }
97
+ const otherKeys = Object.keys(data)
98
+ .filter(key => key !== 'text/plain')
99
+ .sort();
100
+ for (const key of otherKeys) {
101
+ lines.push(`<${key}>`);
102
+ }
103
+ return lines.join('\n');
104
+ }
105
+ case 'error': {
106
+ const ename = (_d = output.ename) !== null && _d !== void 0 ? _d : 'Error';
107
+ const evalue = (_e = output.evalue) !== null && _e !== void 0 ? _e : '';
108
+ // Tracebacks contain ANSI escape codes by default. Strip them so the
109
+ // diff stays readable — coloring information has no analog in a plain
110
+ // text diff, and the escapes themselves would inflate every traceback
111
+ // line into a fake change.
112
+ const traceback = ((_f = output.traceback) !== null && _f !== void 0 ? _f : []).map(stripAnsi).join('\n');
113
+ return `[error]\n${ename}: ${evalue}\n${traceback}`;
114
+ }
115
+ default:
116
+ return `[${output.output_type}]`;
117
+ }
118
+ }
119
+ // Matching ANSI escape codes inherently requires a control character in
120
+ // the pattern, which is what no-control-regex flags. Disable the rule on
121
+ // this single literal — silently emitting un-stripped escapes would just
122
+ // reintroduce the very noise stripAnsi exists to remove.
123
+ // eslint-disable-next-line no-control-regex
124
+ const ANSI_ESCAPE_RE = /\x1B\[[0-9;]*[A-Za-z]/g;
125
+ function stripAnsi(value) {
126
+ return value.replace(ANSI_ESCAPE_RE, '');
127
+ }
128
+ /**
129
+ * Stable JSON of the cell's `metadata` object. Keys are sorted recursively
130
+ * so a writer that re-orders the metadata object doesn't surface as a diff.
131
+ * An empty metadata object returns the empty string so freshly-added cells
132
+ * that carry the default `{}` don't produce a `+{}` placeholder section.
133
+ */
134
+ export function canonicalMetadata(cell) {
135
+ var _a;
136
+ const md = (_a = cell.metadata) !== null && _a !== void 0 ? _a : {};
137
+ if (Object.keys(md).length === 0) {
138
+ return '';
139
+ }
140
+ return stableStringify(md);
141
+ }
142
+ /**
143
+ * Stable JSON of the notebook-level metadata bundle (kernelspec, language
144
+ * info, nbformat). We diff the bundle as a single block so the user sees
145
+ * all environment-level changes in one place.
146
+ */
147
+ export function canonicalNotebookMetadata(notebook) {
148
+ return stableStringify({
149
+ metadata: notebook.metadata,
150
+ nbformat: notebook.nbformat,
151
+ nbformat_minor: notebook.nbformat_minor
152
+ });
153
+ }
154
+ function stableStringify(value) {
155
+ return JSON.stringify(value, sortKeysReplacer, 2);
156
+ }
157
+ function sortKeysReplacer(_key, value) {
158
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
159
+ const sorted = {};
160
+ for (const key of Object.keys(value).sort()) {
161
+ sorted[key] = value[key];
162
+ }
163
+ return sorted;
164
+ }
165
+ return value;
166
+ }
167
+ /**
168
+ * Equality check used to classify matched cells as `unchanged` vs
169
+ * `modified`. Compares the three things we actually render — type, source,
170
+ * outputs, metadata — using the same canonical representations the diff
171
+ * itself sees, so an "equal" verdict here implies all per-cell diff blocks
172
+ * would be empty.
173
+ */
174
+ export function cellsAreEqual(a, b) {
175
+ return (a.cell_type === b.cell_type &&
176
+ cellSource(a) === cellSource(b) &&
177
+ canonicalOutputs(a) === canonicalOutputs(b) &&
178
+ canonicalMetadata(a) === canonicalMetadata(b));
179
+ }
180
+ /**
181
+ * Align cells between two notebook revisions. Cells that carry a stable id
182
+ * (nbformat ≥ 4.5) match on their id; remaining cells fall back to
183
+ * positional matching against the next un-consumed unidentified old cell so
184
+ * older notebooks still produce a sensible diff.
185
+ *
186
+ * Output ordering: entries follow new-file order, with cells that exist
187
+ * only in the old revision appended at the end as `removed`. We don't try
188
+ * to interleave removals at their original position — for typical notebook
189
+ * edits this is more readable than guessing alignment between unrelated
190
+ * insertions and deletions.
191
+ */
192
+ export function alignNotebookCells(oldCells, newCells) {
193
+ const oldById = new Map();
194
+ oldCells.forEach((cell, index) => {
195
+ if (cell.id !== undefined && cell.id.length > 0) {
196
+ oldById.set(cell.id, index);
197
+ }
198
+ });
199
+ const consumed = new Set();
200
+ const entries = [];
201
+ // Cursor used for positional matching of unidentified cells. Only advances
202
+ // forward so we don't double-match a single old cell.
203
+ let positionalCursor = 0;
204
+ for (let newIndex = 0; newIndex < newCells.length; newIndex++) {
205
+ const newCell = newCells[newIndex];
206
+ let matchedOldIndex;
207
+ if (newCell.id !== undefined && newCell.id.length > 0) {
208
+ const candidate = oldById.get(newCell.id);
209
+ if (candidate !== undefined && !consumed.has(candidate)) {
210
+ matchedOldIndex = candidate;
211
+ }
212
+ }
213
+ else {
214
+ while (positionalCursor < oldCells.length) {
215
+ const candidateIndex = positionalCursor;
216
+ positionalCursor += 1;
217
+ if (consumed.has(candidateIndex)) {
218
+ continue;
219
+ }
220
+ const candidate = oldCells[candidateIndex];
221
+ if (candidate.id === undefined || candidate.id.length === 0) {
222
+ matchedOldIndex = candidateIndex;
223
+ break;
224
+ }
225
+ }
226
+ }
227
+ if (matchedOldIndex !== undefined) {
228
+ consumed.add(matchedOldIndex);
229
+ const oldCell = oldCells[matchedOldIndex];
230
+ const kind = cellsAreEqual(oldCell, newCell)
231
+ ? 'unchanged'
232
+ : 'modified';
233
+ entries.push({
234
+ kind,
235
+ oldCell,
236
+ newCell,
237
+ oldIndex: matchedOldIndex,
238
+ newIndex
239
+ });
240
+ }
241
+ else {
242
+ entries.push({ kind: 'added', newCell, newIndex });
243
+ }
244
+ }
245
+ oldCells.forEach((oldCell, oldIndex) => {
246
+ if (!consumed.has(oldIndex)) {
247
+ entries.push({ kind: 'removed', oldCell, oldIndex });
248
+ }
249
+ });
250
+ return entries;
251
+ }
252
+ /**
253
+ * Build a complete notebook diff from the textual contents of two
254
+ * revisions. Returns `null` if either side fails to parse as a notebook;
255
+ * the caller is expected to fall back to a raw text diff in that case.
256
+ *
257
+ * An empty string on either side is treated as an empty notebook (no
258
+ * cells, default metadata) so a freshly-added or deleted notebook still
259
+ * produces a sensible cell-by-cell diff.
260
+ */
261
+ export function buildNotebookDiff(options) {
262
+ var _a;
263
+ const oldNotebook = parseNotebook(options.oldText);
264
+ const newNotebook = parseNotebook(options.newText);
265
+ if (oldNotebook === null || newNotebook === null) {
266
+ return null;
267
+ }
268
+ const language = (_a = detectLanguage(newNotebook)) !== null && _a !== void 0 ? _a : detectLanguage(oldNotebook);
269
+ const cells = alignNotebookCells(oldNotebook.cells, newNotebook.cells);
270
+ const oldMd = canonicalNotebookMetadata(oldNotebook);
271
+ const newMd = canonicalNotebookMetadata(newNotebook);
272
+ let notebookMetadataDiff = null;
273
+ if (oldMd !== newMd) {
274
+ notebookMetadataDiff = parseDiffFromFile({ name: 'notebook-metadata.json', contents: oldMd }, { name: 'notebook-metadata.json', contents: newMd });
275
+ }
276
+ return {
277
+ oldNotebook,
278
+ newNotebook,
279
+ cells,
280
+ language,
281
+ notebookMetadataDiff
282
+ };
283
+ }
284
+ function detectLanguage(notebook) {
285
+ var _a;
286
+ const li = (_a = notebook.metadata) === null || _a === void 0 ? void 0 : _a.language_info;
287
+ if (li !== undefined && typeof li.name === 'string') {
288
+ return li.name;
289
+ }
290
+ return undefined;
291
+ }
292
+ /**
293
+ * Pick a filename whose extension drives `@pierre/diffs`'s syntax
294
+ * highlighter. The library never reads the file from disk — `name` only
295
+ * influences token-level rendering, so any plausible extension that maps
296
+ * to the right language is fine.
297
+ */
298
+ function cellFilename(cell, language) {
299
+ if (cell.cell_type === 'markdown') {
300
+ return 'cell.md';
301
+ }
302
+ if (cell.cell_type === 'raw') {
303
+ return 'cell.txt';
304
+ }
305
+ switch ((language !== null && language !== void 0 ? language : '').toLowerCase()) {
306
+ case 'python':
307
+ return 'cell.py';
308
+ case 'javascript':
309
+ case 'node':
310
+ return 'cell.js';
311
+ case 'typescript':
312
+ return 'cell.ts';
313
+ case 'r':
314
+ return 'cell.r';
315
+ case 'julia':
316
+ return 'cell.jl';
317
+ case 'rust':
318
+ return 'cell.rs';
319
+ case 'bash':
320
+ case 'sh':
321
+ return 'cell.sh';
322
+ default:
323
+ return 'cell.txt';
324
+ }
325
+ }
326
+ /**
327
+ * Build the FileContents pair the diff library wants. `kind` controls the
328
+ * filename so each per-cell sub-diff (source, metadata) gets the right
329
+ * extension and therefore the right highlighter.
330
+ */
331
+ function pierreFiles(kind, oldText, newText, oldCell, newCell, language) {
332
+ let name;
333
+ if (kind === 'metadata') {
334
+ name = 'cell-metadata.json';
335
+ }
336
+ else {
337
+ // Use the new cell's type/language when available; fall back to the old
338
+ // side so a deleted cell still gets the right highlighter.
339
+ const reference = newCell !== null && newCell !== void 0 ? newCell : oldCell;
340
+ name = reference !== null ? cellFilename(reference, language) : 'cell.txt';
341
+ }
342
+ return {
343
+ oldFile: { name, contents: oldText },
344
+ newFile: { name, contents: newText }
345
+ };
346
+ }
347
+ /**
348
+ * Compute the source / metadata sub-diffs for a single cell entry.
349
+ * Returns the empty list for `unchanged` entries so the caller can collapse
350
+ * them; for `added` / `removed` entries the populated side is paired with
351
+ * an empty counterpart so the library treats every line as an addition or
352
+ * deletion respectively.
353
+ *
354
+ * Outputs are deliberately *not* included here — they're rendered through
355
+ * rendermime side-by-side in {@link OutputsSection} so images render as
356
+ * images, HTML as HTML, etc. The canonical text form survives for equality
357
+ * (it drives the `unchanged` classification) but not for display.
358
+ */
359
+ function buildCellSubDiffs(entry, language) {
360
+ if (entry.kind === 'unchanged') {
361
+ return [];
362
+ }
363
+ const oldCell = entry.kind === 'added' ? null : entry.oldCell;
364
+ const newCell = entry.kind === 'removed' ? null : entry.newCell;
365
+ const oldSource = oldCell !== null ? cellSource(oldCell) : '';
366
+ const newSource = newCell !== null ? cellSource(newCell) : '';
367
+ const oldMd = oldCell !== null ? canonicalMetadata(oldCell) : '';
368
+ const newMd = newCell !== null ? canonicalMetadata(newCell) : '';
369
+ const subdiffs = [];
370
+ const sections = [
371
+ { kind: 'source', oldText: oldSource, newText: newSource },
372
+ { kind: 'metadata', oldText: oldMd, newText: newMd }
373
+ ];
374
+ for (const section of sections) {
375
+ if (section.oldText === section.newText) {
376
+ continue;
377
+ }
378
+ const { oldFile, newFile } = pierreFiles(section.kind, section.oldText, section.newText, oldCell, newCell, language);
379
+ subdiffs.push({
380
+ kind: section.kind,
381
+ metadata: parseDiffFromFile(oldFile, newFile)
382
+ });
383
+ }
384
+ return subdiffs;
385
+ }
386
+ /**
387
+ * Common diff library options shared across every per-cell sub-diff and
388
+ * the notebook-level metadata diff. Split mode matches the file-diff path
389
+ * and gives the user the same left=old / right=new mental model inside
390
+ * each cell — cells stack vertically but each cell internally reads
391
+ * side-by-side, which matches how nbdime renders.
392
+ */
393
+ function diffLibraryOptions(theme, dark) {
394
+ return {
395
+ diffStyle: 'split',
396
+ disableFileHeader: true,
397
+ theme,
398
+ themeType: dark ? 'dark' : 'light'
399
+ };
400
+ }
401
+ /**
402
+ * Mount a Lumino {@link Widget} inside a React tree. The widget is the
403
+ * source of truth for its DOM — React just owns the host element and
404
+ * Lumino owns the content the widget paints into it. Detach + dispose
405
+ * happens on unmount or when a new widget arrives, so the parent doesn't
406
+ * need to manage the widget's lifecycle separately.
407
+ */
408
+ function LuminoWidget(props) {
409
+ const { widget, className } = props;
410
+ const ref = React.useRef(null);
411
+ React.useEffect(() => {
412
+ const host = ref.current;
413
+ if (host === null || widget.isDisposed) {
414
+ return;
415
+ }
416
+ try {
417
+ Widget.attach(widget, host);
418
+ }
419
+ catch (err) {
420
+ // Lumino refuses to attach a widget whose host isn't connected to
421
+ // the document, or that's already attached elsewhere. Either way
422
+ // there's nothing useful to do here — log and bail so the parent
423
+ // tree still mounts.
424
+ console.warn('xtralab: Widget.attach failed', err);
425
+ return;
426
+ }
427
+ return () => {
428
+ // Lumino's `Widget.detach` throws "Widget is not attached" when
429
+ // either the `IsAttached` flag is false *or* the node is no longer
430
+ // connected to the document. During a parent React unmount the
431
+ // host element gets removed from the DOM before this cleanup
432
+ // runs, so `node.isConnected` is already `false` while the
433
+ // `IsAttached` flag is still set from our earlier `Widget.attach`
434
+ // call. Calling `Widget.detach` in that state would throw, and —
435
+ // worse — `widget.dispose()` re-enters the same detach branch
436
+ // internally (its `else if (this.isAttached)` guard does not
437
+ // check the DOM connection), so leaving the flag stale produces
438
+ // the noisy "Widget is not attached" warning out of `dispose`.
439
+ //
440
+ // Resolve the race by driving Lumino's detach lifecycle by hand
441
+ // when the host has already been torn down: send `BeforeDetach`
442
+ // and `AfterDetach` so the layout cleanup hooks run and the flag
443
+ // clears, then dispose. When the host is still connected the
444
+ // ordinary `Widget.detach` path handles both steps for us.
445
+ if (widget.isAttached) {
446
+ if (widget.node.isConnected) {
447
+ try {
448
+ Widget.detach(widget);
449
+ }
450
+ catch (err) {
451
+ console.warn('xtralab: Widget.detach failed', err);
452
+ }
453
+ }
454
+ else {
455
+ try {
456
+ MessageLoop.sendMessage(widget, Widget.Msg.BeforeDetach);
457
+ MessageLoop.sendMessage(widget, Widget.Msg.AfterDetach);
458
+ }
459
+ catch (err) {
460
+ console.warn('xtralab: Widget detach messaging failed', err);
461
+ }
462
+ }
463
+ }
464
+ try {
465
+ if (!widget.isDisposed) {
466
+ widget.dispose();
467
+ }
468
+ }
469
+ catch (err) {
470
+ console.warn('xtralab: Widget.dispose failed', err);
471
+ }
472
+ };
473
+ }, [widget]);
474
+ return React.createElement("div", { ref: ref, className: className });
475
+ }
476
+ /**
477
+ * Render a sequence of nbformat outputs through JupyterLab's rendermime,
478
+ * exactly the way a live notebook would. Wrapped here so the side-by-side
479
+ * cell layout can drop a fully-rendered output column on either side
480
+ * without re-implementing rich mime rendering.
481
+ *
482
+ * The output area is created `trusted` because the source git ref is on
483
+ * the user's machine (working tree / index / HEAD). A diff viewer that
484
+ * sandboxed its own user's outputs would just be inconvenient.
485
+ */
486
+ function OutputsPreview(props) {
487
+ const { outputs, rendermime, className } = props;
488
+ const widget = React.useMemo(() => {
489
+ const model = new OutputAreaModel({
490
+ values: outputs,
491
+ trusted: true
492
+ });
493
+ return new SimplifiedOutputArea({ model, rendermime });
494
+ }, [outputs, rendermime]);
495
+ return React.createElement(LuminoWidget, { widget: widget, className: className });
496
+ }
497
+ /**
498
+ * Render markdown source through rendermime so the cell preview matches
499
+ * what JupyterLab would render in a live notebook (LaTeX, syntax-highlighted
500
+ * code fences, sanitized HTML, …). Used for both the rendered preview that
501
+ * sits next to a markdown cell's source diff, and for unchanged markdown
502
+ * cells when the user expands them.
503
+ */
504
+ function MarkdownPreview(props) {
505
+ const { source, rendermime, className } = props;
506
+ const renderer = React.useMemo(() => rendermime.createRenderer('text/markdown'), [rendermime]);
507
+ React.useEffect(() => {
508
+ const model = new MimeModel({
509
+ data: { 'text/markdown': source },
510
+ trusted: true
511
+ });
512
+ renderer.renderModel(model).catch(err => {
513
+ console.error('xtralab: markdown render failed', err);
514
+ });
515
+ }, [renderer, source]);
516
+ return React.createElement(LuminoWidget, { widget: renderer, className: className });
517
+ }
518
+ /**
519
+ * Two-column "old | new" layout used by both {@link OutputsSection} and
520
+ * {@link MarkdownPreviewSection}. The visible side(s) depend on the cell
521
+ * kind: modified shows both, added shows only new, removed shows only old,
522
+ * unchanged collapses to a single full-width pane (since both sides
523
+ * render the same content).
524
+ */
525
+ function SideBySidePanes(props) {
526
+ const { label, oldNode, newNode, hasOld, hasNew } = props;
527
+ if (!hasOld && !hasNew) {
528
+ return null;
529
+ }
530
+ const both = hasOld && hasNew;
531
+ return (React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-sideBySide`, "data-layout": both ? 'split' : 'single' },
532
+ React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-subdiffLabel` }, label),
533
+ React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-sideBySideGrid` },
534
+ both || hasOld ? (React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-sidePane`, "data-side": "old" }, oldNode)) : null,
535
+ both || hasNew ? (React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-sidePane`, "data-side": "new" }, newNode)) : null)));
536
+ }
537
+ /**
538
+ * Rendered outputs section for a cell. Renders old / new outputs through
539
+ * rendermime in two columns; for added or removed cells only the populated
540
+ * side is shown. Returns `null` (so the section is hidden entirely) when
541
+ * neither side has any outputs, or when the same set of outputs appears
542
+ * on both sides and the host doesn't want the duplicate render.
543
+ */
544
+ function OutputsSection(props) {
545
+ var _a, _b;
546
+ const { entry, rendermime, placement } = props;
547
+ const oldCell = 'oldCell' in entry ? entry.oldCell : null;
548
+ const newCell = 'newCell' in entry ? entry.newCell : null;
549
+ const oldOutputs = (_a = oldCell === null || oldCell === void 0 ? void 0 : oldCell.outputs) !== null && _a !== void 0 ? _a : [];
550
+ const newOutputs = (_b = newCell === null || newCell === void 0 ? void 0 : newCell.outputs) !== null && _b !== void 0 ? _b : [];
551
+ const hasOld = oldOutputs.length > 0;
552
+ const hasNew = newOutputs.length > 0;
553
+ if (!hasOld && !hasNew) {
554
+ return null;
555
+ }
556
+ // For added / removed cells the parent already lives in a single outer
557
+ // column — rendering the populated side full width within that column
558
+ // is the right move. SideBySidePanes itself collapses to a single grid
559
+ // column when only one side has content, so we can use it for both
560
+ // cases by feeding it only the side(s) we want.
561
+ const showOld = placement !== 'right' && hasOld;
562
+ const showNew = placement !== 'left' && hasNew;
563
+ if (rendermime === null) {
564
+ return (React.createElement(SideBySidePanes, { label: "Outputs", hasOld: showOld, hasNew: showNew, oldNode: React.createElement("pre", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-fallbackText` }, oldCell !== null ? canonicalOutputs(oldCell) : ''), newNode: React.createElement("pre", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-fallbackText` }, newCell !== null ? canonicalOutputs(newCell) : '') }));
565
+ }
566
+ return (React.createElement(SideBySidePanes, { label: "Outputs", hasOld: showOld, hasNew: showNew, oldNode: React.createElement(OutputsPreview, { outputs: oldOutputs, rendermime: rendermime }), newNode: React.createElement(OutputsPreview, { outputs: newOutputs, rendermime: rendermime }) }));
567
+ }
568
+ /**
569
+ * Rendered markdown preview section. Sits next to (and below) the markdown
570
+ * source diff so the user sees both the source-level changes and how the
571
+ * rendered cell looks. Skipped for non-markdown cells.
572
+ */
573
+ function MarkdownPreviewSection(props) {
574
+ const { entry, rendermime, placement } = props;
575
+ const isMarkdown = referenceCellType(entry) === 'markdown';
576
+ if (!isMarkdown || rendermime === null) {
577
+ return null;
578
+ }
579
+ const oldSource = 'oldCell' in entry ? cellSource(entry.oldCell) : '';
580
+ const newSource = 'newCell' in entry ? cellSource(entry.newCell) : '';
581
+ const showOld = placement !== 'right' && oldSource.length > 0;
582
+ const showNew = placement !== 'left' && newSource.length > 0;
583
+ if (!showOld && !showNew) {
584
+ return null;
585
+ }
586
+ return (React.createElement(SideBySidePanes, { label: "Rendered", hasOld: showOld, hasNew: showNew, oldNode: React.createElement(MarkdownPreview, { source: oldSource, rendermime: rendermime }), newNode: React.createElement(MarkdownPreview, { source: newSource, rendermime: rendermime }) }));
587
+ }
588
+ /**
589
+ * The notebook-level diff view. Lays out cells in a 2-column grid where
590
+ * the left column tracks the *old* notebook and the right column tracks
591
+ * the *new* one — modified and unchanged cells span both columns (their
592
+ * internal split-mode FileDiff aligns with the outer columns), added
593
+ * cells live in the right column with an empty placeholder on the left,
594
+ * and removed cells live in the left column with an empty placeholder on
595
+ * the right. The diff library's red/green tint within each cell makes
596
+ * the orientation clear without a separate column header.
597
+ */
598
+ export function NotebookDiffView(props) {
599
+ const { diff, dark, rendermime } = props;
600
+ const theme = dark ? 'pierre-dark' : 'pierre-light';
601
+ const summary = React.useMemo(() => summarize(diff.cells), [diff.cells]);
602
+ return (React.createElement("div", { className: NOTEBOOK_DIFF_CSS_CLASS },
603
+ React.createElement(NotebookDiffHeader, { summary: summary }),
604
+ React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-grid` }, diff.cells.map(entry => (React.createElement(CellEntryRow, { key: cellEntryKey(entry), entry: entry, language: diff.language, theme: theme, dark: dark, rendermime: rendermime })))),
605
+ diff.notebookMetadataDiff !== null ? (React.createElement(NotebookMetadataBlock, { metadata: diff.notebookMetadataDiff, theme: theme, dark: dark })) : null));
606
+ }
607
+ /**
608
+ * Place a single cell entry into the outer 2-column grid. Modified /
609
+ * unchanged entries span the full row; added entries take the right
610
+ * column with an empty placeholder on the left; removed entries take the
611
+ * left column with an empty placeholder on the right. The placeholders
612
+ * are visible (subtle dashed outline) so the user can see *where* a
613
+ * cell was inserted or removed relative to the other side.
614
+ */
615
+ function CellEntryRow(props) {
616
+ const { entry } = props;
617
+ if (entry.kind === 'added') {
618
+ return (React.createElement(React.Fragment, null,
619
+ React.createElement(EmptyCellPlaceholder, { side: "old", reason: "added" }),
620
+ React.createElement(CellDiffBlock, { ...props, placement: "right" })));
621
+ }
622
+ if (entry.kind === 'removed') {
623
+ return (React.createElement(React.Fragment, null,
624
+ React.createElement(CellDiffBlock, { ...props, placement: "left" }),
625
+ React.createElement(EmptyCellPlaceholder, { side: "new", reason: "removed" })));
626
+ }
627
+ return React.createElement(CellDiffBlock, { ...props, placement: "full" });
628
+ }
629
+ function EmptyCellPlaceholder(props) {
630
+ const { side, reason } = props;
631
+ const label = reason === 'added' ? 'cell added on the right' : 'cell removed';
632
+ return (React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-empty`, "data-side": side, "aria-label": label },
633
+ React.createElement("span", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-emptyLabel` }, reason === 'added' ? '— no cell —' : '— cell removed —')));
634
+ }
635
+ function summarize(cells) {
636
+ const summary = {
637
+ added: 0,
638
+ removed: 0,
639
+ modified: 0,
640
+ unchanged: 0
641
+ };
642
+ for (const entry of cells) {
643
+ summary[entry.kind] += 1;
644
+ }
645
+ return summary;
646
+ }
647
+ function NotebookDiffHeader(props) {
648
+ const { summary } = props;
649
+ const total = summary.added + summary.removed + summary.modified + summary.unchanged;
650
+ return (React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-header` },
651
+ React.createElement("span", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-headerCount` },
652
+ total,
653
+ " cell",
654
+ total === 1 ? '' : 's'),
655
+ summary.modified > 0 ? (React.createElement("span", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-stat`, "data-kind": "modified" },
656
+ summary.modified,
657
+ " modified")) : null,
658
+ summary.added > 0 ? (React.createElement("span", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-stat`, "data-kind": "added" },
659
+ summary.added,
660
+ " added")) : null,
661
+ summary.removed > 0 ? (React.createElement("span", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-stat`, "data-kind": "removed" },
662
+ summary.removed,
663
+ " removed")) : null,
664
+ summary.unchanged > 0 ? (React.createElement("span", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-stat`, "data-kind": "unchanged" },
665
+ summary.unchanged,
666
+ " unchanged")) : null));
667
+ }
668
+ function cellEntryKey(entry) {
669
+ var _a, _b, _c, _d;
670
+ if (entry.kind === 'added') {
671
+ return `added:${entry.newIndex}:${(_a = entry.newCell.id) !== null && _a !== void 0 ? _a : ''}`;
672
+ }
673
+ if (entry.kind === 'removed') {
674
+ return `removed:${entry.oldIndex}:${(_b = entry.oldCell.id) !== null && _b !== void 0 ? _b : ''}`;
675
+ }
676
+ return `${entry.kind}:${entry.newIndex}:${(_d = (_c = entry.newCell.id) !== null && _c !== void 0 ? _c : entry.oldCell.id) !== null && _d !== void 0 ? _d : ''}`;
677
+ }
678
+ function CellDiffBlock(props) {
679
+ var _a, _b;
680
+ const { entry, language, theme, dark, rendermime, placement } = props;
681
+ // Unchanged cells start collapsed — the user opted out of seeing those
682
+ // sections by virtue of them being unchanged. A toggle lets them peek if
683
+ // they want.
684
+ const [collapsed, setCollapsed] = React.useState(entry.kind === 'unchanged');
685
+ const subdiffs = React.useMemo(() => (collapsed ? [] : buildCellSubDiffs(entry, language)), [collapsed, entry, language]);
686
+ const cellType = referenceCellType(entry);
687
+ const indexLabel = cellIndexLabel(entry);
688
+ const isCodeCell = cellType === 'code';
689
+ // Output rendering is only meaningful for code cells. Markdown / raw cells
690
+ // never carry outputs in nbformat.
691
+ const showOutputs = isCodeCell && entry.kind !== 'unchanged';
692
+ return (React.createElement("section", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-cell`, "data-kind": entry.kind, "data-cell-type": cellType, "data-placement": placement },
693
+ React.createElement("header", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-cellHeader`, onClick: () => setCollapsed(c => !c), role: "button", tabIndex: 0, "aria-expanded": !collapsed, onKeyDown: event => {
694
+ if (event.key === 'Enter' || event.key === ' ') {
695
+ event.preventDefault();
696
+ setCollapsed(c => !c);
697
+ }
698
+ } },
699
+ React.createElement("span", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-cellChevron` }, collapsed ? '▸' : '▾'),
700
+ React.createElement("span", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-cellBadge`, "data-kind": entry.kind }, kindBadge(entry.kind)),
701
+ React.createElement("span", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-cellLabel` },
702
+ indexLabel,
703
+ " \u00B7 ",
704
+ cellType),
705
+ entry.kind === 'unchanged' ? (React.createElement("span", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-cellHint` }, "unchanged")) : null),
706
+ !collapsed ? (React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-cellBody` }, entry.kind === 'unchanged' ? (React.createElement(UnchangedCellBody, { entry: entry, rendermime: rendermime })) : (React.createElement(React.Fragment, null,
707
+ subdiffs.map(sub => (React.createElement(CellSubDiff, { key: sub.kind, kind: sub.kind, metadata: sub.metadata, theme: theme, dark: dark, placement: placement }))),
708
+ React.createElement(MarkdownPreviewSection, { entry: entry, rendermime: rendermime, placement: placement }),
709
+ showOutputs ? (React.createElement(OutputsSection, { entry: entry, rendermime: rendermime, placement: placement })) : null,
710
+ subdiffs.length === 0 &&
711
+ !(showOutputs &&
712
+ (('oldCell' in entry && ((_a = entry.oldCell.outputs) === null || _a === void 0 ? void 0 : _a.length)) ||
713
+ ('newCell' in entry && ((_b = entry.newCell.outputs) === null || _b === void 0 ? void 0 : _b.length)))) ? (React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-cellEmpty` }, "No content differences detected.")) : null)))) : null));
714
+ }
715
+ function CellSubDiff(props) {
716
+ const { kind, metadata, theme, dark, placement } = props;
717
+ // For modified / unchanged cells the diff spans both outer columns, and
718
+ // split mode lets it visually align with the outer old | new lanes. For
719
+ // added / removed cells the diff sits inside a single outer column, so
720
+ // unified mode keeps the content readable instead of leaving half the
721
+ // column empty.
722
+ const diffStyle = placement === 'full' ? 'split' : 'unified';
723
+ return (React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-subdiff`, "data-section": kind },
724
+ React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-subdiffLabel` }, subdiffLabel(kind)),
725
+ React.createElement(FileDiff, { fileDiff: metadata,
726
+ // See diffWidget.tsx — the worker bootstrap can't resolve through
727
+ // JupyterLab's federation pipeline, so every diff in this extension
728
+ // runs on the main thread. Cell diffs are small enough that this is
729
+ // not a performance concern.
730
+ disableWorkerPool: true, options: { ...diffLibraryOptions(theme, dark), diffStyle } })));
731
+ }
732
+ function NotebookMetadataBlock(props) {
733
+ const { metadata, theme, dark } = props;
734
+ return (React.createElement("section", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-cell`, "data-kind": "modified", "data-cell-type": "metadata" },
735
+ React.createElement("header", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-cellHeader`, "aria-label": "Notebook metadata" },
736
+ React.createElement("span", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-cellChevron` }, "\u25BE"),
737
+ React.createElement("span", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-cellBadge`, "data-kind": "modified" }, "M"),
738
+ React.createElement("span", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-cellLabel` }, "Notebook metadata")),
739
+ React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-cellBody` },
740
+ React.createElement(CellSubDiff, { kind: "metadata", metadata: metadata, theme: theme, dark: dark, placement: "full" }))));
741
+ }
742
+ /**
743
+ * Body for an unchanged cell when the user has expanded it. The cell's
744
+ * content matches on both sides, so we render once full-width: markdown
745
+ * cells go through rendermime (if available) so they look like the live
746
+ * notebook would render them, code cells fall through to a verbatim
747
+ * pre-formatted block, and any code outputs render through rendermime
748
+ * below the source.
749
+ */
750
+ function UnchangedCellBody(props) {
751
+ var _a;
752
+ const { entry, rendermime } = props;
753
+ const cell = entry.newCell;
754
+ const source = cellSource(cell);
755
+ const isMarkdown = cell.cell_type === 'markdown';
756
+ const outputs = (_a = cell.outputs) !== null && _a !== void 0 ? _a : [];
757
+ const hasOutputs = outputs.length > 0;
758
+ return (React.createElement(React.Fragment, null,
759
+ isMarkdown && rendermime !== null && source.length > 0 ? (React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-unchangedRendered` },
760
+ React.createElement(MarkdownPreview, { source: source, rendermime: rendermime }))) : (React.createElement("pre", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-unchangedSource` }, source.length > 0 ? source : '(empty cell)')),
761
+ hasOutputs && rendermime !== null ? (React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-unchangedOutputs` },
762
+ React.createElement("div", { className: `${NOTEBOOK_DIFF_CSS_CLASS}-subdiffLabel` }, "Outputs"),
763
+ React.createElement(OutputsPreview, { outputs: outputs, rendermime: rendermime }))) : null));
764
+ }
765
+ function referenceCellType(entry) {
766
+ if (entry.kind === 'added') {
767
+ return entry.newCell.cell_type;
768
+ }
769
+ if (entry.kind === 'removed') {
770
+ return entry.oldCell.cell_type;
771
+ }
772
+ return entry.newCell.cell_type;
773
+ }
774
+ function cellIndexLabel(entry) {
775
+ if (entry.kind === 'added') {
776
+ return `Cell ${entry.newIndex + 1}`;
777
+ }
778
+ if (entry.kind === 'removed') {
779
+ return `Cell ${entry.oldIndex + 1} (removed)`;
780
+ }
781
+ if (entry.newIndex === entry.oldIndex) {
782
+ return `Cell ${entry.newIndex + 1}`;
783
+ }
784
+ return `Cell ${entry.oldIndex + 1} → ${entry.newIndex + 1}`;
785
+ }
786
+ function kindBadge(kind) {
787
+ switch (kind) {
788
+ case 'added':
789
+ return 'A';
790
+ case 'removed':
791
+ return 'D';
792
+ case 'modified':
793
+ return 'M';
794
+ case 'unchanged':
795
+ return '·';
796
+ }
797
+ }
798
+ function subdiffLabel(kind) {
799
+ switch (kind) {
800
+ case 'source':
801
+ return 'Source';
802
+ case 'metadata':
803
+ return 'Metadata';
804
+ }
805
+ }