xtralab 0.4.0 → 0.5.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.
@@ -0,0 +1,462 @@
1
+ import * as React from 'react';
2
+
3
+ import {
4
+ JupyterFrontEnd,
5
+ JupyterFrontEndPlugin
6
+ } from '@jupyterlab/application';
7
+ import { IThemeManager } from '@jupyterlab/apputils';
8
+ import { PathExt } from '@jupyterlab/coreutils';
9
+ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
10
+ import { Contents } from '@jupyterlab/services';
11
+ import { ReactWidget } from '@jupyterlab/ui-components';
12
+ import { Git, IGitExtension } from '@jupyterlab/git';
13
+ import { PromiseDelegate } from '@lumino/coreutils';
14
+ import { ISignal, Signal } from '@lumino/signaling';
15
+
16
+ import {
17
+ DIFF_WIDGET_CSS_CLASS,
18
+ DiffSurface,
19
+ NotebookViewModeControl,
20
+ isDarkTheme,
21
+ readStoredNotebookViewMode,
22
+ writeStoredNotebookViewMode,
23
+ type NotebookDiffViewMode
24
+ } from './diffSurface';
25
+ import { IMAGE_DIFF_EXTENSIONS, imageDataType } from './imageDiff';
26
+
27
+ /**
28
+ * `jupyterlab-git` diff providers backed by xtralab's `@pierre/diffs`
29
+ * rendering.
30
+ *
31
+ * jupyterlab-git's notebook, plain-text and image diff plugins are disabled
32
+ * (see `package.json`); this module registers a diff factory in their place
33
+ * via `registerDiffProvider` / `registerFallbackDiffProvider`. The upstream
34
+ * git panel, commands, status bar and context menus keep working unchanged;
35
+ * only the rendered diff is xtralab's.
36
+ *
37
+ * The factory adapts a `Git.Diff.IModel` (reference vs. challenger content
38
+ * getters) onto the shared {@link DiffSurface}, so a diff opened from the
39
+ * git panel looks identical to one opened from the launcher dashboard.
40
+ */
41
+
42
+ interface IXtralabDiffContext {
43
+ contentsManager: Contents.IManager;
44
+ rendermime: IRenderMimeRegistry | null;
45
+ themeManager: IThemeManager | null;
46
+ }
47
+
48
+ /**
49
+ * A `Git.Diff.IDiffWidget` that renders through {@link DiffSurface}.
50
+ *
51
+ * `jupyterlab-git` constructs this via the registered factory, hosts it in
52
+ * its own `PreviewMainAreaWidget`, and reads `model` / calls `refresh()` /
53
+ * `getResolvedFile()` on it. It owns the notebook view-mode state (mounted
54
+ * into the diff toolbar `jupyterlab-git` hands the factory) and a reload
55
+ * nonce so `refresh()` re-pulls the model's content.
56
+ */
57
+ class XtralabDiffWidget extends ReactWidget implements Git.Diff.IDiffWidget {
58
+ constructor(model: Git.Diff.IModel, context: IXtralabDiffContext) {
59
+ super();
60
+ this._model = model;
61
+ this._context = context;
62
+ this._notebookViewMode = readStoredNotebookViewMode();
63
+ // `jp-git-diff-root` borrows jupyterlab-git's host sizing (height:100%
64
+ // flex column inside its PreviewMainAreaWidget); `jp-xtralab-DiffWidget`
65
+ // brings our diff theming + the `.jp-xtralab-DiffWidget-*` styles.
66
+ this.addClass('jp-git-diff-root');
67
+ this.addClass(DIFF_WIDGET_CSS_CLASS);
68
+ }
69
+
70
+ /** The diff model, as required by `Git.Diff.IDiffWidget`. */
71
+ get model(): Git.Diff.IModel {
72
+ return this._model;
73
+ }
74
+
75
+ /**
76
+ * We render a two-way (reference vs. challenger) view and do not provide
77
+ * a merge editor, so there is nothing to leave unresolved — report
78
+ * resolved so jupyterlab-git's merge-conflict flow is not blocked.
79
+ */
80
+ get isFileResolved(): boolean {
81
+ return true;
82
+ }
83
+
84
+ /**
85
+ * Used by jupyterlab-git's "Mark as resolved" button. For merge
86
+ * conflicts, jupyterlab-git passes the working-tree result as `base`;
87
+ * keep that content rather than overwriting it with either compared side.
88
+ */
89
+ async getResolvedFile(): Promise<Partial<Contents.IModel>> {
90
+ const source =
91
+ this._model.hasConflict === true && this._model.base !== undefined
92
+ ? this._model.base
93
+ : this._model.challenger;
94
+ const content = await source.content();
95
+ return {
96
+ type: 'file',
97
+ format: imageDataType(this._model.filename) !== null ? 'base64' : 'text',
98
+ content
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Re-pull both sides of the model and re-render. jupyterlab-git wires a
104
+ * "Refresh" toolbar button to this whenever `model.changed` fires (e.g.
105
+ * the compared `HEAD` moved).
106
+ */
107
+ async refresh(): Promise<void> {
108
+ const done = new PromiseDelegate<void>();
109
+ this._pendingRefresh = done;
110
+ this._reloadNonce += 1;
111
+ this.update();
112
+ await done.promise;
113
+ }
114
+
115
+ /** The notebook rendered-vs-JSON choice; mirrored into the toolbar. */
116
+ get notebookViewMode(): NotebookDiffViewMode {
117
+ return this._notebookViewMode;
118
+ }
119
+
120
+ setNotebookViewMode(mode: NotebookDiffViewMode): void {
121
+ if (mode === this._notebookViewMode) {
122
+ return;
123
+ }
124
+ this._notebookViewMode = mode;
125
+ writeStoredNotebookViewMode(mode);
126
+ this._notebookViewModeChanged.emit(mode);
127
+ }
128
+
129
+ get notebookViewModeChanged(): ISignal<this, NotebookDiffViewMode> {
130
+ return this._notebookViewModeChanged;
131
+ }
132
+
133
+ get hasNotebookView(): boolean {
134
+ return this._hasNotebookView;
135
+ }
136
+
137
+ setHasNotebookView(value: boolean): void {
138
+ if (value === this._hasNotebookView) {
139
+ return;
140
+ }
141
+ this._hasNotebookView = value;
142
+ this._hasNotebookViewChanged.emit(value);
143
+ }
144
+
145
+ get hasNotebookViewChanged(): ISignal<this, boolean> {
146
+ return this._hasNotebookViewChanged;
147
+ }
148
+
149
+ /** Resolve the in-flight {@link refresh} promise, if any. */
150
+ settleRefresh(): void {
151
+ const pending = this._pendingRefresh;
152
+ this._pendingRefresh = null;
153
+ pending?.resolve();
154
+ }
155
+
156
+ get reloadNonce(): number {
157
+ return this._reloadNonce;
158
+ }
159
+
160
+ get context(): IXtralabDiffContext {
161
+ return this._context;
162
+ }
163
+
164
+ protected render(): React.ReactElement {
165
+ return <ModelDiffView widget={this} />;
166
+ }
167
+
168
+ private _model: Git.Diff.IModel;
169
+ private _context: IXtralabDiffContext;
170
+ private _reloadNonce = 0;
171
+ private _pendingRefresh: PromiseDelegate<void> | null = null;
172
+ private _notebookViewMode: NotebookDiffViewMode;
173
+ private _notebookViewModeChanged = new Signal<this, NotebookDiffViewMode>(
174
+ this
175
+ );
176
+ private _hasNotebookView = false;
177
+ private _hasNotebookViewChanged = new Signal<this, boolean>(this);
178
+ }
179
+
180
+ interface IModelDiffState {
181
+ loading: boolean;
182
+ oldText: string;
183
+ newText: string;
184
+ error: string | null;
185
+ }
186
+
187
+ function ModelDiffView(props: {
188
+ widget: XtralabDiffWidget;
189
+ }): React.ReactElement {
190
+ const { widget } = props;
191
+ const model = widget.model;
192
+ const { contentsManager, rendermime, themeManager } = widget.context;
193
+ const nonce = widget.reloadNonce;
194
+
195
+ const [state, setState] = React.useState<IModelDiffState>({
196
+ loading: true,
197
+ oldText: '',
198
+ newText: '',
199
+ error: null
200
+ });
201
+
202
+ // Mirror the widget's notebook view mode so toolbar-driven changes
203
+ // re-render the diff. The widget owns the canonical value (so the
204
+ // toolbar can read/write it without going through React).
205
+ const [notebookViewMode, setNotebookViewMode] =
206
+ React.useState<NotebookDiffViewMode>(() => widget.notebookViewMode);
207
+ React.useEffect(() => {
208
+ const handler = (
209
+ _sender: XtralabDiffWidget,
210
+ mode: NotebookDiffViewMode
211
+ ): void => {
212
+ setNotebookViewMode(mode);
213
+ };
214
+ widget.notebookViewModeChanged.connect(handler);
215
+ setNotebookViewMode(widget.notebookViewMode);
216
+ return () => {
217
+ widget.notebookViewModeChanged.disconnect(handler);
218
+ };
219
+ }, [widget]);
220
+
221
+ const handleNotebookAvailabilityChange = React.useCallback(
222
+ (available: boolean) => {
223
+ widget.setHasNotebookView(available);
224
+ },
225
+ [widget]
226
+ );
227
+
228
+ const [dark, setDark] = React.useState<boolean>(() =>
229
+ isDarkTheme(themeManager)
230
+ );
231
+ React.useEffect(() => {
232
+ if (themeManager !== null) {
233
+ const handler = (): void => setDark(isDarkTheme(themeManager));
234
+ themeManager.themeChanged.connect(handler);
235
+ return () => {
236
+ themeManager.themeChanged.disconnect(handler);
237
+ };
238
+ }
239
+ const observer = new MutationObserver(() =>
240
+ setDark(isDarkTheme(themeManager))
241
+ );
242
+ observer.observe(document.body, {
243
+ attributes: true,
244
+ attributeFilter: ['data-jp-theme-light']
245
+ });
246
+ return () => {
247
+ observer.disconnect();
248
+ };
249
+ }, [themeManager]);
250
+
251
+ // Fetch both sides whenever the model or the reload nonce changes. The
252
+ // model's `content()` getters target the right git refs internally
253
+ // (working tree, index, a commit, …) so we stay content-source-agnostic.
254
+ React.useEffect(() => {
255
+ let cancelled = false;
256
+ setState({ loading: true, oldText: '', newText: '', error: null });
257
+ void (async () => {
258
+ try {
259
+ const [oldText, newText] = await Promise.all([
260
+ model.reference.content(),
261
+ model.challenger.content()
262
+ ]);
263
+ if (cancelled) {
264
+ return;
265
+ }
266
+ setState({
267
+ loading: false,
268
+ oldText: oldText ?? '',
269
+ newText: newText ?? '',
270
+ error: null
271
+ });
272
+ } catch (err) {
273
+ if (cancelled) {
274
+ return;
275
+ }
276
+ setState({
277
+ loading: false,
278
+ oldText: '',
279
+ newText: '',
280
+ error: err instanceof Error ? err.message : String(err)
281
+ });
282
+ } finally {
283
+ if (!cancelled) {
284
+ // Let an in-flight `widget.refresh()` resolve once the reload
285
+ // has settled (jupyterlab-git only awaits it to hide its
286
+ // refresh button).
287
+ widget.settleRefresh();
288
+ }
289
+ }
290
+ })();
291
+ return () => {
292
+ cancelled = true;
293
+ };
294
+ }, [widget, model, nonce]);
295
+
296
+ // Per-hunk discard is only meaningful when the challenger is the working
297
+ // tree (an unstaged change) and we are not in a three-way merge view.
298
+ const canDiscardHunk =
299
+ model.challenger.source === Git.Diff.SpecialRef.WORKING &&
300
+ model.hasConflict !== true;
301
+
302
+ const serverPath = React.useMemo(
303
+ () => PathExt.join(model.repositoryPath ?? '', model.filename),
304
+ [model.repositoryPath, model.filename]
305
+ );
306
+
307
+ const hunkDiscard = React.useMemo(
308
+ () => ({
309
+ enabled: canDiscardHunk,
310
+ save: async (text: string) => {
311
+ await contentsManager.save(serverPath, {
312
+ type: 'file',
313
+ format: 'text',
314
+ content: text
315
+ });
316
+ },
317
+ onAfterSave: () => {
318
+ // jupyterlab-git's panel auto-refreshes off the contents
319
+ // `fileChanged` signal the save above emits; we just need to
320
+ // re-pull so the diff reflects the reverted hunk. (We do not
321
+ // auto-close: jupyterlab-git owns the diff tab.)
322
+ void widget.refresh();
323
+ }
324
+ }),
325
+ [canDiscardHunk, contentsManager, serverPath, widget]
326
+ );
327
+
328
+ return (
329
+ <DiffSurface
330
+ loading={state.loading}
331
+ error={state.error}
332
+ isBinary={false}
333
+ oldText={state.oldText}
334
+ newText={state.newText}
335
+ newName={model.filename}
336
+ oldName={model.filename}
337
+ dark={dark}
338
+ rendermime={rendermime}
339
+ notebookViewMode={notebookViewMode}
340
+ onNotebookAvailabilityChange={handleNotebookAvailabilityChange}
341
+ hunkDiscard={hunkDiscard}
342
+ />
343
+ );
344
+ }
345
+
346
+ /**
347
+ * Notebook/JSON toggle mounted into the diff toolbar that jupyterlab-git
348
+ * passes to the factory. Bound to the widget's signals so it stays in sync
349
+ * with the rendered surface and only appears for parseable notebooks.
350
+ */
351
+ class NotebookViewModeToolbarItem extends ReactWidget {
352
+ constructor(widget: XtralabDiffWidget) {
353
+ super();
354
+ this._widget = widget;
355
+ this.addClass('jp-xtralab-DiffWidget-viewModeToolbarItem');
356
+ }
357
+
358
+ protected render(): React.ReactElement {
359
+ return <ToolbarControl widget={this._widget} />;
360
+ }
361
+
362
+ private _widget: XtralabDiffWidget;
363
+ }
364
+
365
+ function ToolbarControl(props: {
366
+ widget: XtralabDiffWidget;
367
+ }): React.ReactElement {
368
+ const { widget } = props;
369
+ const [mode, setMode] = React.useState<NotebookDiffViewMode>(
370
+ () => widget.notebookViewMode
371
+ );
372
+ const [available, setAvailable] = React.useState<boolean>(
373
+ () => widget.hasNotebookView
374
+ );
375
+ React.useEffect(() => {
376
+ const onMode = (
377
+ _sender: XtralabDiffWidget,
378
+ next: NotebookDiffViewMode
379
+ ): void => {
380
+ setMode(next);
381
+ };
382
+ const onAvailable = (_sender: XtralabDiffWidget, next: boolean): void => {
383
+ setAvailable(next);
384
+ };
385
+ widget.notebookViewModeChanged.connect(onMode);
386
+ widget.hasNotebookViewChanged.connect(onAvailable);
387
+ setMode(widget.notebookViewMode);
388
+ setAvailable(widget.hasNotebookView);
389
+ return () => {
390
+ widget.notebookViewModeChanged.disconnect(onMode);
391
+ widget.hasNotebookViewChanged.disconnect(onAvailable);
392
+ };
393
+ }, [widget]);
394
+
395
+ return (
396
+ <NotebookViewModeControl
397
+ mode={mode}
398
+ available={available}
399
+ onChange={next => widget.setNotebookViewMode(next)}
400
+ />
401
+ );
402
+ }
403
+
404
+ /**
405
+ * Build the `Git.Diff.Factory` xtralab registers with jupyterlab-git. The
406
+ * factory closes over the application-level context (contents manager for
407
+ * hunk discard, rendermime for notebook outputs, theme manager) that the
408
+ * `Git.Diff.IFactoryOptions` does not carry.
409
+ */
410
+ function makeXtralabDiffFactory(
411
+ context: IXtralabDiffContext
412
+ ): Git.Diff.Factory {
413
+ return async (options: Git.Diff.IFactoryOptions) => {
414
+ const widget = new XtralabDiffWidget(options.model, context);
415
+ if (options.toolbar) {
416
+ options.toolbar.addItem(
417
+ 'xtralab-notebook-view-mode',
418
+ new NotebookViewModeToolbarItem(widget)
419
+ );
420
+ }
421
+ return widget;
422
+ };
423
+ }
424
+
425
+ /**
426
+ * Registers xtralab's diff rendering as jupyterlab-git's diff providers:
427
+ * `@pierre/diffs` for text and notebooks, the bundled `<img>`-based view
428
+ * for raster images.
429
+ */
430
+ const diffProviderPlugin: JupyterFrontEndPlugin<void> = {
431
+ id: 'xtralab:git-diff-providers',
432
+ description:
433
+ "Replaces jupyterlab-git's notebook, text and image diff plugins with xtralab's renderer.",
434
+ autoStart: true,
435
+ requires: [IGitExtension],
436
+ optional: [IRenderMimeRegistry, IThemeManager],
437
+ activate: (
438
+ app: JupyterFrontEnd,
439
+ gitExtension: IGitExtension,
440
+ rendermime: IRenderMimeRegistry | null,
441
+ themeManager: IThemeManager | null
442
+ ): void => {
443
+ const factory = makeXtralabDiffFactory({
444
+ contentsManager: app.serviceManager.contents,
445
+ rendermime,
446
+ themeManager
447
+ });
448
+
449
+ // Notebooks and raster images get extension-specific providers; every
450
+ // other text file falls back to the same factory. One factory serves
451
+ // all three — the surface auto-detects notebooks/images by filename.
452
+ gitExtension.registerDiffProvider('XtralabNotebook', ['.ipynb'], factory);
453
+ gitExtension.registerDiffProvider(
454
+ 'XtralabImage',
455
+ IMAGE_DIFF_EXTENSIONS,
456
+ factory
457
+ );
458
+ gitExtension.registerFallbackDiffProvider(factory);
459
+ }
460
+ };
461
+
462
+ export default diffProviderPlugin;