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.
- package/LICENSE +28 -0
- package/README.md +69 -0
- package/lib/fileBrowser/commands.d.ts +62 -0
- package/lib/fileBrowser/commands.js +418 -0
- package/lib/fileBrowser/contents.d.ts +28 -0
- package/lib/fileBrowser/contents.js +48 -0
- package/lib/fileBrowser/fileBrowser.d.ts +24 -0
- package/lib/fileBrowser/fileBrowser.js +535 -0
- package/lib/fileBrowser/icons.d.ts +37 -0
- package/lib/fileBrowser/icons.js +153 -0
- package/lib/fileBrowser/index.d.ts +7 -0
- package/lib/fileBrowser/index.js +38 -0
- package/lib/fileBrowser/widget.d.ts +97 -0
- package/lib/fileBrowser/widget.js +95 -0
- package/lib/git/api.d.ts +39 -0
- package/lib/git/api.js +164 -0
- package/lib/git/commands.d.ts +69 -0
- package/lib/git/commands.js +67 -0
- package/lib/git/diffWidget.d.ts +108 -0
- package/lib/git/diffWidget.js +624 -0
- package/lib/git/icons.d.ts +14 -0
- package/lib/git/icons.js +19 -0
- package/lib/git/index.d.ts +11 -0
- package/lib/git/index.js +74 -0
- package/lib/git/notebookDiff.d.ts +176 -0
- package/lib/git/notebookDiff.js +805 -0
- package/lib/git/panel.d.ts +29 -0
- package/lib/git/panel.js +42 -0
- package/lib/git/panelComponent.d.ts +26 -0
- package/lib/git/panelComponent.js +300 -0
- package/lib/git/tokens.d.ts +78 -0
- package/lib/git/tokens.js +6 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.js +14 -0
- package/package.json +216 -0
- package/schema/plugin.json +67 -0
- package/src/fileBrowser/commands.ts +507 -0
- package/src/fileBrowser/contents.ts +66 -0
- package/src/fileBrowser/fileBrowser.tsx +635 -0
- package/src/fileBrowser/icons.ts +172 -0
- package/src/fileBrowser/index.ts +56 -0
- package/src/fileBrowser/widget.tsx +202 -0
- package/src/git/api.ts +207 -0
- package/src/git/commands.ts +143 -0
- package/src/git/diffWidget.tsx +903 -0
- package/src/git/icons.ts +20 -0
- package/src/git/index.ts +109 -0
- package/src/git/notebookDiff.tsx +1294 -0
- package/src/git/panel.tsx +56 -0
- package/src/git/panelComponent.tsx +558 -0
- package/src/git/tokens.ts +91 -0
- package/src/index.ts +18 -0
- package/style/base.css +175 -0
- package/style/git.css +770 -0
- package/style/index.css +2 -0
- 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
|
+
}
|