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,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
|
+
}
|