xtralab 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +69 -0
  3. package/lib/fileBrowser/commands.d.ts +62 -0
  4. package/lib/fileBrowser/commands.js +418 -0
  5. package/lib/fileBrowser/contents.d.ts +28 -0
  6. package/lib/fileBrowser/contents.js +48 -0
  7. package/lib/fileBrowser/fileBrowser.d.ts +24 -0
  8. package/lib/fileBrowser/fileBrowser.js +535 -0
  9. package/lib/fileBrowser/icons.d.ts +37 -0
  10. package/lib/fileBrowser/icons.js +153 -0
  11. package/lib/fileBrowser/index.d.ts +7 -0
  12. package/lib/fileBrowser/index.js +38 -0
  13. package/lib/fileBrowser/widget.d.ts +97 -0
  14. package/lib/fileBrowser/widget.js +95 -0
  15. package/lib/git/api.d.ts +39 -0
  16. package/lib/git/api.js +164 -0
  17. package/lib/git/commands.d.ts +69 -0
  18. package/lib/git/commands.js +67 -0
  19. package/lib/git/diffWidget.d.ts +108 -0
  20. package/lib/git/diffWidget.js +624 -0
  21. package/lib/git/icons.d.ts +14 -0
  22. package/lib/git/icons.js +19 -0
  23. package/lib/git/index.d.ts +11 -0
  24. package/lib/git/index.js +74 -0
  25. package/lib/git/notebookDiff.d.ts +176 -0
  26. package/lib/git/notebookDiff.js +805 -0
  27. package/lib/git/panel.d.ts +29 -0
  28. package/lib/git/panel.js +42 -0
  29. package/lib/git/panelComponent.d.ts +26 -0
  30. package/lib/git/panelComponent.js +300 -0
  31. package/lib/git/tokens.d.ts +78 -0
  32. package/lib/git/tokens.js +6 -0
  33. package/lib/index.d.ts +10 -0
  34. package/lib/index.js +14 -0
  35. package/package.json +216 -0
  36. package/schema/plugin.json +67 -0
  37. package/src/fileBrowser/commands.ts +507 -0
  38. package/src/fileBrowser/contents.ts +66 -0
  39. package/src/fileBrowser/fileBrowser.tsx +635 -0
  40. package/src/fileBrowser/icons.ts +172 -0
  41. package/src/fileBrowser/index.ts +56 -0
  42. package/src/fileBrowser/widget.tsx +202 -0
  43. package/src/git/api.ts +207 -0
  44. package/src/git/commands.ts +143 -0
  45. package/src/git/diffWidget.tsx +903 -0
  46. package/src/git/icons.ts +20 -0
  47. package/src/git/index.ts +109 -0
  48. package/src/git/notebookDiff.tsx +1294 -0
  49. package/src/git/panel.tsx +56 -0
  50. package/src/git/panelComponent.tsx +558 -0
  51. package/src/git/tokens.ts +91 -0
  52. package/src/index.ts +18 -0
  53. package/style/base.css +175 -0
  54. package/style/git.css +770 -0
  55. package/style/index.css +2 -0
  56. package/style/index.js +2 -0
@@ -0,0 +1,903 @@
1
+ import * as React from 'react';
2
+
3
+ import { IThemeManager, MainAreaWidget } from '@jupyterlab/apputils';
4
+ import { PathExt } from '@jupyterlab/coreutils';
5
+ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
6
+ import { Contents } from '@jupyterlab/services';
7
+ import { ReactWidget, undoIcon } from '@jupyterlab/ui-components';
8
+ import { ISignal, Signal } from '@lumino/signaling';
9
+ import { FileDiff } from '@pierre/diffs/react';
10
+ import {
11
+ diffAcceptRejectHunk,
12
+ parseDiffFromFile,
13
+ type DiffLineAnnotation,
14
+ type FileContents,
15
+ type FileDiffMetadata
16
+ } from '@pierre/diffs';
17
+
18
+ import { getTreeIcon } from '../fileBrowser/icons';
19
+ import { content } from './api';
20
+ import {
21
+ buildNotebookDiff,
22
+ NotebookDiffView,
23
+ type INotebookDiffResult
24
+ } from './notebookDiff';
25
+ import { GitReference, IFileChange } from './tokens';
26
+
27
+ /**
28
+ * The CSS class added to the diff main-area widget. The selectors that
29
+ * style the embedded `@pierre/diffs` viewer hang off this class.
30
+ */
31
+ export const DIFF_WIDGET_CSS_CLASS = 'jp-xtralab-DiffWidget';
32
+
33
+ /**
34
+ * `localStorage` key for the user's last-chosen split ratio. Persisted so
35
+ * the preference survives reloads — the library has no built-in way to
36
+ * remember per-host UI state, and reopening every diff at 50/50 is
37
+ * annoying once the user has dialed in a layout they like.
38
+ */
39
+ const SPLIT_RATIO_STORAGE_KEY = 'xtralab:diff-split-ratio';
40
+
41
+ /**
42
+ * Bounds on the left-pane fraction. Keeping a few percent on either side
43
+ * guarantees both panes remain visible — dragging to 0/100 would hide a
44
+ * column and is almost never what the user wants.
45
+ */
46
+ const MIN_SPLIT_RATIO = 0.1;
47
+ const MAX_SPLIT_RATIO = 0.9;
48
+ const DEFAULT_SPLIT_RATIO = 0.5;
49
+
50
+ /**
51
+ * CSS injected via the `@pierre/diffs` `unsafeCSS` option. The library
52
+ * places it inside the shadow root under `@layer unsafe`, which beats
53
+ * the library's own `@layer base` rule that hardcodes
54
+ * `grid-template-columns: 1fr 1fr` on the split-mode container.
55
+ *
56
+ * The override reads from `--xtralab-split-cols`, a custom property we set
57
+ * on the host element via the `style` prop. Custom properties inherit
58
+ * through the shadow DOM boundary, so adjusting it on the host instantly
59
+ * resizes the columns without re-rendering the diff.
60
+ */
61
+ const SPLIT_RESIZE_CSS = `pre[data-diff-type="split"][data-overflow="scroll"] {
62
+ grid-template-columns: var(--xtralab-split-cols, 1fr 1fr);
63
+ }`;
64
+
65
+ function readStoredSplitRatio(): number {
66
+ try {
67
+ const raw = window.localStorage.getItem(SPLIT_RATIO_STORAGE_KEY);
68
+ if (raw === null) {
69
+ return DEFAULT_SPLIT_RATIO;
70
+ }
71
+ const parsed = Number.parseFloat(raw);
72
+ if (
73
+ Number.isFinite(parsed) &&
74
+ parsed >= MIN_SPLIT_RATIO &&
75
+ parsed <= MAX_SPLIT_RATIO
76
+ ) {
77
+ return parsed;
78
+ }
79
+ } catch {
80
+ // localStorage may throw in privacy mode or sandboxed contexts. Falling
81
+ // back to the default is fine — the user can resize again on this run.
82
+ }
83
+ return DEFAULT_SPLIT_RATIO;
84
+ }
85
+
86
+ function writeStoredSplitRatio(ratio: number): void {
87
+ try {
88
+ window.localStorage.setItem(SPLIT_RATIO_STORAGE_KEY, ratio.toString());
89
+ } catch {
90
+ // See readStoredSplitRatio — best-effort persistence.
91
+ }
92
+ }
93
+
94
+ /**
95
+ * View modes available for `.ipynb` diffs. `notebook` is the cell-by-cell
96
+ * rendered view; `json` falls back to the raw nbformat JSON file diff so
97
+ * the user can inspect exactly which bytes changed (handy for nbformat
98
+ * spelunking, seeing execution_count drift, etc.).
99
+ */
100
+ type NotebookDiffViewMode = 'notebook' | 'json';
101
+
102
+ const NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY =
103
+ 'xtralab:notebook-diff-view-mode';
104
+
105
+ function readStoredNotebookViewMode(): NotebookDiffViewMode {
106
+ try {
107
+ const raw = window.localStorage.getItem(
108
+ NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY
109
+ );
110
+ if (raw === 'json' || raw === 'notebook') {
111
+ return raw;
112
+ }
113
+ } catch {
114
+ // See readStoredSplitRatio — privacy-mode / sandboxed contexts can
115
+ // throw on access. The notebook view is the better default anyway.
116
+ }
117
+ return 'notebook';
118
+ }
119
+
120
+ function writeStoredNotebookViewMode(mode: NotebookDiffViewMode): void {
121
+ try {
122
+ window.localStorage.setItem(NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY, mode);
123
+ } catch {
124
+ // Best-effort.
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Annotation payload threaded through the diff library back into the
130
+ * `renderAnnotation` callback. Carries the index of the hunk a particular
131
+ * action button targets.
132
+ */
133
+ interface IHunkActionAnnotation {
134
+ hunkIndex: number;
135
+ }
136
+
137
+ /**
138
+ * Build the deterministic widget id used for a diff against a particular
139
+ * file/group pair. Reused across plugin and command code so reopening a
140
+ * diff for the same file reveals the existing tab instead of opening a
141
+ * second one.
142
+ */
143
+ export function diffWidgetId(change: IFileChange): string {
144
+ return `xtralab:diff:${change.group}:${change.path}`;
145
+ }
146
+
147
+ /**
148
+ * Resolve the pair of git references whose `content` should appear on the
149
+ * left and right side of the diff for a given file change. Mirrors VS
150
+ * Code's source control diff: staged-side compares INDEX vs HEAD,
151
+ * unstaged-side compares WORKING vs INDEX, and untracked files have no
152
+ * "previous" version to compare against.
153
+ */
154
+ export function resolveReferences(change: IFileChange): {
155
+ oldRef: GitReference | null;
156
+ newRef: GitReference | null;
157
+ } {
158
+ if (change.status === 'untracked') {
159
+ return { oldRef: null, newRef: { special: 'WORKING' } };
160
+ }
161
+ if (change.group === 'staged') {
162
+ return { oldRef: { git: 'HEAD' }, newRef: { special: 'INDEX' } };
163
+ }
164
+ // Unstaged: WORKING vs INDEX. If the file isn't in the index yet (e.g.
165
+ // a freshly-`git add`-ed file that has been modified again) the server
166
+ // returns the HEAD blob for INDEX instead, which is still the right
167
+ // baseline for the diff.
168
+ return { oldRef: { special: 'INDEX' }, newRef: { special: 'WORKING' } };
169
+ }
170
+
171
+ export interface IGitDiffWidgetOptions {
172
+ repoPath: string;
173
+ change: IFileChange;
174
+ themeManager: IThemeManager | null;
175
+ contentsManager: Contents.IManager;
176
+ /**
177
+ * Rendermime registry used by the notebook diff path to render outputs
178
+ * and markdown cells. May be `null` in stripped-down hosts; the notebook
179
+ * diff falls back to a textual representation in that case.
180
+ */
181
+ rendermime: IRenderMimeRegistry | null;
182
+ /**
183
+ * Called after a hunk-level operation mutates the working tree, so the
184
+ * surrounding plugin can refresh the changes panel.
185
+ */
186
+ onChanged: () => void;
187
+ /**
188
+ * Called when a post-discard reload finds no hunks left in the diff.
189
+ * Wired by {@link createGitDiffWidget} to close the host
190
+ * `MainAreaWidget` — there is nothing left to view, so the empty pane
191
+ * would just be visual noise.
192
+ */
193
+ onEmpty?: () => void;
194
+ }
195
+
196
+ /**
197
+ * Build a `MainAreaWidget`-hosted diff viewer for a single file change.
198
+ * Wrapping the `ReactWidget` in a `MainAreaWidget` is what gives it the
199
+ * usual document chrome — toolbar slot, focus tracker integration, and
200
+ * spinner-while-loading hooks — and matches the pattern used for other
201
+ * read-only main-area widgets in the JupyterLab ecosystem.
202
+ */
203
+ export function createGitDiffWidget(
204
+ options: IGitDiffWidgetOptions
205
+ ): MainAreaWidget<DiffContentWidget> {
206
+ // `widget` is captured by the `onEmpty` closure we pass into the content,
207
+ // but doesn't exist yet at construction time — declare it up front so the
208
+ // closure can read it once the widget is wired.
209
+ let widget: MainAreaWidget<DiffContentWidget> | null = null;
210
+ const content = new DiffContentWidget({
211
+ ...options,
212
+ onEmpty: () => {
213
+ if (widget !== null && !widget.isDisposed) {
214
+ widget.close();
215
+ }
216
+ options.onEmpty?.();
217
+ }
218
+ });
219
+ widget = new MainAreaWidget<DiffContentWidget>({ content });
220
+ widget.id = diffWidgetId(options.change);
221
+ widget.title.label = formatTitle(options.change);
222
+ widget.title.caption = options.change.path;
223
+ widget.title.closable = true;
224
+ widget.title.icon = getTreeIcon(options.change.path);
225
+ widget.addClass(DIFF_WIDGET_CSS_CLASS);
226
+ // Wire the notebook view-mode selector into the `MainAreaWidget` toolbar
227
+ // — the proper home for document chrome controls. The toolbar starts
228
+ // hidden and becomes visible once the React content reports a parsed
229
+ // notebook diff via `setHasNotebookView`. Plain text / code diffs never
230
+ // flip that flag, so their toolbar bar stays collapsed and the
231
+ // resulting layout matches what other read-only main-area documents
232
+ // look like in JupyterLab.
233
+ widget.toolbar.addItem(
234
+ 'notebook-view-mode',
235
+ new NotebookViewModeToolbarItem(content)
236
+ );
237
+ widget.toolbar.hide();
238
+ const onHasNotebookViewChanged = (
239
+ _sender: DiffContentWidget,
240
+ value: boolean
241
+ ): void => {
242
+ if (widget === null || widget.isDisposed) {
243
+ return;
244
+ }
245
+ if (value) {
246
+ widget.toolbar.show();
247
+ } else {
248
+ widget.toolbar.hide();
249
+ }
250
+ };
251
+ content.hasNotebookViewChanged.connect(onHasNotebookViewChanged);
252
+ widget.disposed.connect(() => {
253
+ content.hasNotebookViewChanged.disconnect(onHasNotebookViewChanged);
254
+ });
255
+ return widget;
256
+ }
257
+
258
+ /**
259
+ * The actual React-rendered region of the diff viewer. Held in its own
260
+ * `ReactWidget` so the surrounding `MainAreaWidget` can manage chrome
261
+ * without React having to know about it.
262
+ *
263
+ * Owns the persistent UI state that the surrounding `MainAreaWidget`
264
+ * toolbar needs to read and write (`notebookViewMode` and whether a
265
+ * notebook view is even available for the current file). Storing this on
266
+ * the widget — and exposing change signals — lets the toolbar widget and
267
+ * the React content stay in sync without either side knowing about the
268
+ * other.
269
+ */
270
+ export class DiffContentWidget extends ReactWidget {
271
+ constructor(options: IGitDiffWidgetOptions) {
272
+ super();
273
+ this._options = options;
274
+ this._notebookViewMode = readStoredNotebookViewMode();
275
+ }
276
+
277
+ /** The change this widget renders, used by callers to identify the widget. */
278
+ get change(): IFileChange {
279
+ return this._options.change;
280
+ }
281
+
282
+ /** The user's current rendered-vs-JSON view choice for notebook diffs. */
283
+ get notebookViewMode(): NotebookDiffViewMode {
284
+ return this._notebookViewMode;
285
+ }
286
+
287
+ /** Update the notebook view mode and persist it across sessions. */
288
+ setNotebookViewMode(mode: NotebookDiffViewMode): void {
289
+ if (mode === this._notebookViewMode) {
290
+ return;
291
+ }
292
+ this._notebookViewMode = mode;
293
+ writeStoredNotebookViewMode(mode);
294
+ this._notebookViewModeChanged.emit(mode);
295
+ }
296
+
297
+ /** Emits whenever {@link notebookViewMode} changes. */
298
+ get notebookViewModeChanged(): ISignal<this, NotebookDiffViewMode> {
299
+ return this._notebookViewModeChanged;
300
+ }
301
+
302
+ /**
303
+ * Whether a rendered notebook view is currently available. Set by the
304
+ * React content once it has parsed the diff; the toolbar reads this to
305
+ * decide whether to show the view-mode selector.
306
+ */
307
+ get hasNotebookView(): boolean {
308
+ return this._hasNotebookView;
309
+ }
310
+
311
+ setHasNotebookView(value: boolean): void {
312
+ if (value === this._hasNotebookView) {
313
+ return;
314
+ }
315
+ this._hasNotebookView = value;
316
+ this._hasNotebookViewChanged.emit(value);
317
+ }
318
+
319
+ /** Emits whenever {@link hasNotebookView} changes. */
320
+ get hasNotebookViewChanged(): ISignal<this, boolean> {
321
+ return this._hasNotebookViewChanged;
322
+ }
323
+
324
+ protected render(): React.ReactElement {
325
+ return <DiffViewer {...this._options} widget={this} />;
326
+ }
327
+
328
+ private _options: IGitDiffWidgetOptions;
329
+ private _notebookViewMode: NotebookDiffViewMode;
330
+ private _notebookViewModeChanged = new Signal<this, NotebookDiffViewMode>(
331
+ this
332
+ );
333
+ private _hasNotebookView = false;
334
+ private _hasNotebookViewChanged = new Signal<this, boolean>(this);
335
+ }
336
+
337
+ interface IDiffState {
338
+ loading: boolean;
339
+ /**
340
+ * The pre-computed diff metadata for the current change. Owning the
341
+ * metadata in our component lets us pass it to {@link FileDiff} *and*
342
+ * thread the same instance through {@link diffAcceptRejectHunk} when the
343
+ * user discards a hunk — so hunk indexes line up between what the user
344
+ * clicked and what we mutate.
345
+ */
346
+ metadata: FileDiffMetadata | null;
347
+ /**
348
+ * Cell-by-cell notebook diff for `.ipynb` files. When non-null the
349
+ * renderer uses a notebook-aware view instead of the line-oriented
350
+ * file diff — kept distinct from {@link metadata} so a notebook whose
351
+ * JSON we cannot parse can fall back to the file diff path without
352
+ * losing the existing widget machinery.
353
+ */
354
+ notebookDiff: INotebookDiffResult | null;
355
+ /**
356
+ * Server-relative path of the file on disk. Computed from the repo path
357
+ * + the file's git-root-relative path. We need it to write the file back
358
+ * via the contents manager when discarding a hunk.
359
+ */
360
+ serverPath: string;
361
+ isBinary: boolean;
362
+ error: string | null;
363
+ }
364
+
365
+ /**
366
+ * Detect notebook files by extension. We only look at the suffix so the
367
+ * caller (the file-change tracker) doesn't need to plumb a content-type
368
+ * field through; mistaking another `.ipynb`-named file for a notebook is
369
+ * harmless because {@link buildNotebookDiff} validates the JSON shape and
370
+ * we fall back to the file diff if parsing fails.
371
+ */
372
+ function isNotebookPath(path: string): boolean {
373
+ return path.toLowerCase().endsWith('.ipynb');
374
+ }
375
+
376
+ /**
377
+ * Decide whether the active JupyterLab theme is a dark theme. Prefers the
378
+ * `IThemeManager` token when available, and falls back to sniffing the
379
+ * `data-jp-theme-light` attribute that JupyterLab sets on `<body>` so the
380
+ * component still works in stripped-down hosts where the token isn't
381
+ * provided (lite, certain notebook configurations, …).
382
+ */
383
+ function isDarkTheme(themeManager: IThemeManager | null): boolean {
384
+ if (themeManager !== null && themeManager.theme !== null) {
385
+ return !themeManager.isLight(themeManager.theme);
386
+ }
387
+ return document.body.dataset.jpThemeLight === 'false';
388
+ }
389
+
390
+ function DiffViewer(
391
+ props: IGitDiffWidgetOptions & { widget: DiffContentWidget }
392
+ ): React.ReactElement {
393
+ const {
394
+ repoPath,
395
+ change,
396
+ themeManager,
397
+ contentsManager,
398
+ rendermime,
399
+ onChanged,
400
+ onEmpty,
401
+ widget
402
+ } = props;
403
+ const [state, setState] = React.useState<IDiffState>({
404
+ loading: true,
405
+ metadata: null,
406
+ notebookDiff: null,
407
+ serverPath: '',
408
+ isBinary: false,
409
+ error: null
410
+ });
411
+ // Split ratio for the diff columns: fraction of width given to the
412
+ // deletions (left) pane. Persisted across sessions so the user only
413
+ // dials in their layout once.
414
+ const [leftRatio, setLeftRatio] = React.useState<number>(() =>
415
+ readStoredSplitRatio()
416
+ );
417
+ const wrapperRef = React.useRef<HTMLDivElement | null>(null);
418
+ // The drag handler reads the live ratio from a ref so the listeners we
419
+ // attach on pointer-down don't capture a stale value across renders.
420
+ const leftRatioRef = React.useRef(leftRatio);
421
+ React.useEffect(() => {
422
+ leftRatioRef.current = leftRatio;
423
+ }, [leftRatio]);
424
+ // Bumping `reloadKey` re-enters the fetch effect. Used after a successful
425
+ // hunk discard, so the diff reflects the new working-tree contents
426
+ // without the user having to close and reopen the tab.
427
+ const [reloadKey, setReloadKey] = React.useState(0);
428
+ // Mirror the widget's notebook view mode into local state so that
429
+ // toolbar-driven changes re-render the diff. The widget owns the
430
+ // canonical value (so the toolbar can read and write it without going
431
+ // through React); the React copy is just a cache for rendering.
432
+ const [notebookViewMode, setNotebookViewModeLocal] =
433
+ React.useState<NotebookDiffViewMode>(() => widget.notebookViewMode);
434
+ React.useEffect(() => {
435
+ const handler = (
436
+ _sender: DiffContentWidget,
437
+ mode: NotebookDiffViewMode
438
+ ): void => {
439
+ setNotebookViewModeLocal(mode);
440
+ };
441
+ widget.notebookViewModeChanged.connect(handler);
442
+ // Re-sync in case the widget changed before this effect ran.
443
+ setNotebookViewModeLocal(widget.notebookViewMode);
444
+ return () => {
445
+ widget.notebookViewModeChanged.disconnect(handler);
446
+ };
447
+ }, [widget]);
448
+
449
+ // Tell the host widget whether a rendered notebook view is currently
450
+ // available. The toolbar item that lives in the `MainAreaWidget`
451
+ // toolbar reads this flag to decide whether to show the
452
+ // Notebook/JSON selector — and the toolbar bar itself stays hidden
453
+ // when the file isn't a parseable notebook.
454
+ React.useEffect(() => {
455
+ widget.setHasNotebookView(state.notebookDiff !== null);
456
+ }, [widget, state.notebookDiff]);
457
+
458
+ // Auto-close after a discard that emptied the diff. Gated on `reloadKey > 0`
459
+ // so a file that opens already-empty (uncommon but possible) is not closed
460
+ // before the user has had a chance to look at it.
461
+ React.useEffect(() => {
462
+ if (
463
+ !state.loading &&
464
+ state.error === null &&
465
+ !state.isBinary &&
466
+ state.metadata !== null &&
467
+ state.metadata.hunks.length === 0 &&
468
+ reloadKey > 0
469
+ ) {
470
+ onEmpty?.();
471
+ }
472
+ }, [
473
+ state.loading,
474
+ state.error,
475
+ state.isBinary,
476
+ state.metadata,
477
+ reloadKey,
478
+ onEmpty
479
+ ]);
480
+ const [dark, setDark] = React.useState<boolean>(() =>
481
+ isDarkTheme(themeManager)
482
+ );
483
+
484
+ // Track theme changes so the diff viewer recolors with the rest of
485
+ // JupyterLab. We listen on `IThemeManager.themeChanged` when available
486
+ // and fall back to a `<body>` attribute observer otherwise.
487
+ React.useEffect(() => {
488
+ if (themeManager !== null) {
489
+ const handler = (): void => setDark(isDarkTheme(themeManager));
490
+ themeManager.themeChanged.connect(handler);
491
+ return () => {
492
+ themeManager.themeChanged.disconnect(handler);
493
+ };
494
+ }
495
+ const observer = new MutationObserver(() =>
496
+ setDark(isDarkTheme(themeManager))
497
+ );
498
+ observer.observe(document.body, {
499
+ attributes: true,
500
+ attributeFilter: ['data-jp-theme-light']
501
+ });
502
+ return () => {
503
+ observer.disconnect();
504
+ };
505
+ }, [themeManager]);
506
+
507
+ React.useEffect(() => {
508
+ let cancelled = false;
509
+ const { oldRef, newRef } = resolveReferences(change);
510
+ const serverPath =
511
+ repoPath.length > 0 ? PathExt.join(repoPath, change.path) : change.path;
512
+ void (async () => {
513
+ try {
514
+ if (change.isBinary === true) {
515
+ if (cancelled) {
516
+ return;
517
+ }
518
+ setState({
519
+ loading: false,
520
+ metadata: null,
521
+ notebookDiff: null,
522
+ serverPath,
523
+ isBinary: true,
524
+ error: null
525
+ });
526
+ return;
527
+ }
528
+ const fetchOld =
529
+ oldRef === null
530
+ ? Promise.resolve({ content: '' })
531
+ : content(repoPath, change.from ?? change.path, oldRef);
532
+ const fetchNew =
533
+ newRef === null
534
+ ? Promise.resolve({ content: '' })
535
+ : content(repoPath, change.path, newRef);
536
+ const [oldRes, newRes] = await Promise.all([fetchOld, fetchNew]);
537
+ if (cancelled) {
538
+ return;
539
+ }
540
+ const oldText = oldRes.content ?? '';
541
+ const newText = newRes.content ?? '';
542
+
543
+ // For .ipynb files we precompute *both* the cell-by-cell
544
+ // notebook diff and the raw JSON file diff up front. The user
545
+ // toggles between them via the in-widget toolbar, so keeping
546
+ // both around (instead of re-fetching on switch) makes the
547
+ // toggle feel instant. If parsing fails, `notebookDiff` stays
548
+ // null and we silently fall back to the file diff path.
549
+ let notebookDiff: INotebookDiffResult | null = null;
550
+ if (isNotebookPath(change.path)) {
551
+ notebookDiff = buildNotebookDiff({ oldText, newText });
552
+ }
553
+
554
+ const oldFile: FileContents = {
555
+ name: change.from ?? change.path,
556
+ contents: oldText
557
+ };
558
+ const newFile: FileContents = {
559
+ name: change.path,
560
+ contents: newText
561
+ };
562
+ const metadata = parseDiffFromFile(oldFile, newFile);
563
+ setState({
564
+ loading: false,
565
+ metadata,
566
+ notebookDiff,
567
+ serverPath,
568
+ isBinary: false,
569
+ error: null
570
+ });
571
+ } catch (err) {
572
+ if (cancelled) {
573
+ return;
574
+ }
575
+ setState({
576
+ loading: false,
577
+ metadata: null,
578
+ notebookDiff: null,
579
+ serverPath,
580
+ isBinary: false,
581
+ error: err instanceof Error ? err.message : String(err)
582
+ });
583
+ }
584
+ })();
585
+ return () => {
586
+ cancelled = true;
587
+ };
588
+ }, [repoPath, change, reloadKey]);
589
+
590
+ // Per-hunk discard buttons are only meaningful for unstaged modifications:
591
+ // staged-vs-HEAD diffs would need an index manipulation we can't do
592
+ // through the available endpoints, and untracked files have no previous
593
+ // version to revert to. The annotation list collapses to empty in those
594
+ // cases and the diff renders without action buttons.
595
+ const canDiscardHunk =
596
+ change.group === 'unstaged' && change.status !== 'untracked';
597
+
598
+ const lineAnnotations = React.useMemo<
599
+ DiffLineAnnotation<IHunkActionAnnotation>[]
600
+ >(() => {
601
+ if (!canDiscardHunk || state.metadata === null) {
602
+ return [];
603
+ }
604
+ return state.metadata.hunks.map((hunk, hunkIndex) => ({
605
+ side: 'additions',
606
+ // The first line of each hunk in the new file. We anchor the button
607
+ // there so it sits at the top of the hunk's body.
608
+ lineNumber: hunk.additionStart,
609
+ metadata: { hunkIndex }
610
+ }));
611
+ }, [canDiscardHunk, state.metadata]);
612
+
613
+ const handleDiscardHunk = React.useCallback(
614
+ async (hunkIndex: number) => {
615
+ if (state.metadata === null) {
616
+ return;
617
+ }
618
+ const updated = diffAcceptRejectHunk(state.metadata, hunkIndex, 'reject');
619
+ // `additionLines` after a 'reject' contains the full new file with
620
+ // the hunk reverted back to the old content. The library splits
621
+ // file contents with `/(?<=\n)/` (a lookbehind that keeps the
622
+ // trailing `\n` on each line), so the entries already carry their
623
+ // own line endings — joining with `''` rebuilds the original text
624
+ // verbatim, while joining with `'\n'` would double every newline.
625
+ const text = updated.additionLines.join('');
626
+ try {
627
+ await contentsManager.save(state.serverPath, {
628
+ type: 'file',
629
+ format: 'text',
630
+ content: text
631
+ });
632
+ onChanged();
633
+ setReloadKey(key => key + 1);
634
+ } catch (err) {
635
+ console.error('xtralab: failed to discard hunk', err);
636
+ }
637
+ },
638
+ [contentsManager, onChanged, state.metadata, state.serverPath]
639
+ );
640
+
641
+ const renderAnnotation = React.useCallback(
642
+ (
643
+ annotation: DiffLineAnnotation<IHunkActionAnnotation>
644
+ ): React.ReactNode => {
645
+ if (annotation.metadata === undefined) {
646
+ return null;
647
+ }
648
+ const { hunkIndex } = annotation.metadata;
649
+ return (
650
+ <div className="jp-xtralab-DiffWidget-hunkAnnotation">
651
+ <button
652
+ type="button"
653
+ className="jp-xtralab-DiffWidget-hunkButton"
654
+ title="Discard this hunk's changes"
655
+ aria-label="Discard hunk"
656
+ onClick={() => void handleDiscardHunk(hunkIndex)}
657
+ >
658
+ <undoIcon.react
659
+ tag="span"
660
+ className="jp-xtralab-DiffWidget-hunkButton-icon"
661
+ elementSize="normal"
662
+ />
663
+ </button>
664
+ </div>
665
+ );
666
+ },
667
+ [handleDiscardHunk]
668
+ );
669
+
670
+ const handleResizerPointerDown = React.useCallback(
671
+ (event: React.PointerEvent<HTMLDivElement>) => {
672
+ // Only react to primary-button drags. Right-click / middle-click on
673
+ // the handle should pass through to the browser default.
674
+ if (event.button !== 0) {
675
+ return;
676
+ }
677
+ const wrapper = wrapperRef.current;
678
+ if (wrapper === null) {
679
+ return;
680
+ }
681
+ event.preventDefault();
682
+ const handle = event.currentTarget;
683
+ handle.setPointerCapture(event.pointerId);
684
+ handle.dataset.dragging = 'true';
685
+ const rect = wrapper.getBoundingClientRect();
686
+ const startX = event.clientX;
687
+ const startRatio = leftRatioRef.current;
688
+
689
+ const onPointerMove = (ev: PointerEvent): void => {
690
+ if (rect.width <= 0) {
691
+ return;
692
+ }
693
+ const next = startRatio + (ev.clientX - startX) / rect.width;
694
+ const clamped = Math.max(
695
+ MIN_SPLIT_RATIO,
696
+ Math.min(MAX_SPLIT_RATIO, next)
697
+ );
698
+ setLeftRatio(clamped);
699
+ };
700
+ const onPointerEnd = (ev: PointerEvent): void => {
701
+ handle.releasePointerCapture(ev.pointerId);
702
+ delete handle.dataset.dragging;
703
+ handle.removeEventListener('pointermove', onPointerMove);
704
+ handle.removeEventListener('pointerup', onPointerEnd);
705
+ handle.removeEventListener('pointercancel', onPointerEnd);
706
+ // Only persist on drag-end so a quick drag isn't followed by a
707
+ // burst of writes for every intermediate position.
708
+ writeStoredSplitRatio(leftRatioRef.current);
709
+ };
710
+ handle.addEventListener('pointermove', onPointerMove);
711
+ handle.addEventListener('pointerup', onPointerEnd);
712
+ handle.addEventListener('pointercancel', onPointerEnd);
713
+ },
714
+ []
715
+ );
716
+
717
+ const handleResizerDoubleClick = React.useCallback(() => {
718
+ setLeftRatio(DEFAULT_SPLIT_RATIO);
719
+ writeStoredSplitRatio(DEFAULT_SPLIT_RATIO);
720
+ }, []);
721
+
722
+ if (state.loading) {
723
+ return (
724
+ <div className="jp-xtralab-DiffWidget-status">Loading diff…</div>
725
+ );
726
+ }
727
+ if (state.error !== null) {
728
+ return (
729
+ <div className="jp-xtralab-DiffWidget-status" data-error="true">
730
+ {state.error}
731
+ </div>
732
+ );
733
+ }
734
+ if (state.isBinary) {
735
+ return (
736
+ <div className="jp-xtralab-DiffWidget-status">
737
+ Binary file — diff not supported.
738
+ </div>
739
+ );
740
+ }
741
+ // Decide which view to render. The notebook view requires a successful
742
+ // nbformat parse; if that failed, the toolbar is hidden and we only
743
+ // show the raw JSON file diff. The toolbar itself is sticky at the top
744
+ // of the content area, sitting above the scroll viewport.
745
+ const hasNotebookView = state.notebookDiff !== null;
746
+ const showNotebookView = hasNotebookView && notebookViewMode === 'notebook';
747
+ const showFileDiff = !showNotebookView && state.metadata !== null;
748
+
749
+ if (!showNotebookView && !showFileDiff) {
750
+ return (
751
+ <div className="jp-xtralab-DiffWidget-status">
752
+ No content to diff.
753
+ </div>
754
+ );
755
+ }
756
+
757
+ // Custom property set on the diffs-container host. The library's
758
+ // shadow-root rule consumes it via `var(--xtralab-split-cols, …)` (see
759
+ // SPLIT_RESIZE_CSS) and recomputes the column tracks instantly. Only
760
+ // relevant in the file-diff path; the notebook view doesn't use it.
761
+ const leftPercent = leftRatio * 100;
762
+ const hostStyle = {
763
+ '--xtralab-split-cols': `${leftPercent}% ${100 - leftPercent}%`
764
+ } as React.CSSProperties;
765
+
766
+ return (
767
+ <div className="jp-xtralab-DiffWidget-content">
768
+ <div ref={wrapperRef} className="jp-xtralab-DiffWidget-body">
769
+ <div className="jp-xtralab-DiffWidget-scroll">
770
+ {showNotebookView && state.notebookDiff !== null ? (
771
+ <NotebookDiffView
772
+ diff={state.notebookDiff}
773
+ dark={dark}
774
+ rendermime={rendermime}
775
+ />
776
+ ) : showFileDiff && state.metadata !== null ? (
777
+ <FileDiff<IHunkActionAnnotation>
778
+ fileDiff={state.metadata}
779
+ lineAnnotations={lineAnnotations}
780
+ renderAnnotation={renderAnnotation}
781
+ style={hostStyle}
782
+ // Disable the worker pool: JupyterLab's webpack federation
783
+ // pipeline doesn't ship the library's
784
+ // `@pierre/diffs/worker/worker.js` file at a URL the worker
785
+ // bootstrap can resolve from, so leaving the pool enabled
786
+ // crashes on instantiation. Running on the main thread is
787
+ // fine for the small files git diffs typically operate on.
788
+ disableWorkerPool={true}
789
+ options={{
790
+ diffStyle: 'split',
791
+ // Drop the file header — the tab title already shows the
792
+ // file name and the panel header carries the change
793
+ // context.
794
+ disableFileHeader: true,
795
+ theme: dark ? 'pierre-dark' : 'pierre-light',
796
+ themeType: dark ? 'dark' : 'light',
797
+ // Inject the column-resize override into the shadow root
798
+ // via the library's `@layer unsafe` channel. Keeping the
799
+ // string constant lets the library short-circuit the
800
+ // re-render path it uses when this option actually
801
+ // changes.
802
+ unsafeCSS: SPLIT_RESIZE_CSS
803
+ }}
804
+ />
805
+ ) : null}
806
+ </div>
807
+ {showFileDiff ? (
808
+ <div
809
+ className="jp-xtralab-DiffWidget-resizer"
810
+ style={{ left: `${leftPercent}%` }}
811
+ role="separator"
812
+ aria-orientation="vertical"
813
+ aria-valuenow={Math.round(leftPercent)}
814
+ aria-valuemin={Math.round(MIN_SPLIT_RATIO * 100)}
815
+ aria-valuemax={Math.round(MAX_SPLIT_RATIO * 100)}
816
+ title="Drag to resize the diff panes (double-click to reset)"
817
+ onPointerDown={handleResizerPointerDown}
818
+ onDoubleClick={handleResizerDoubleClick}
819
+ />
820
+ ) : null}
821
+ </div>
822
+ </div>
823
+ );
824
+ }
825
+
826
+ /**
827
+ * Segmented Notebook/JSON selector mounted into the `MainAreaWidget`
828
+ * toolbar for `.ipynb` diffs. The selector reads from and writes to the
829
+ * `DiffContentWidget` so React state changes (via the diff content) and
830
+ * toolbar interactions stay in sync without either side knowing about
831
+ * the other. The host toolbar widget's visibility is toggled by
832
+ * {@link createGitDiffWidget} based on the same `hasNotebookView`
833
+ * signal, so this control is always meaningful when it appears.
834
+ */
835
+ class NotebookViewModeToolbarItem extends ReactWidget {
836
+ constructor(content: DiffContentWidget) {
837
+ super();
838
+ this._content = content;
839
+ this.addClass('jp-xtralab-DiffWidget-viewModeToolbarItem');
840
+ }
841
+
842
+ protected render(): React.ReactElement {
843
+ return <NotebookViewModeControl content={this._content} />;
844
+ }
845
+
846
+ private _content: DiffContentWidget;
847
+ }
848
+
849
+ function NotebookViewModeControl(props: {
850
+ content: DiffContentWidget;
851
+ }): React.ReactElement {
852
+ const { content } = props;
853
+ const [mode, setMode] = React.useState<NotebookDiffViewMode>(
854
+ () => content.notebookViewMode
855
+ );
856
+ React.useEffect(() => {
857
+ const handler = (
858
+ _sender: DiffContentWidget,
859
+ next: NotebookDiffViewMode
860
+ ): void => {
861
+ setMode(next);
862
+ };
863
+ content.notebookViewModeChanged.connect(handler);
864
+ setMode(content.notebookViewMode);
865
+ return () => {
866
+ content.notebookViewModeChanged.disconnect(handler);
867
+ };
868
+ }, [content]);
869
+ return (
870
+ <div
871
+ className="jp-xtralab-DiffWidget-segmented"
872
+ role="tablist"
873
+ aria-label="Notebook diff view mode"
874
+ >
875
+ <button
876
+ type="button"
877
+ role="tab"
878
+ aria-selected={mode === 'notebook'}
879
+ data-active={mode === 'notebook'}
880
+ className="jp-xtralab-DiffWidget-segmentedButton"
881
+ onClick={() => content.setNotebookViewMode('notebook')}
882
+ >
883
+ Notebook
884
+ </button>
885
+ <button
886
+ type="button"
887
+ role="tab"
888
+ aria-selected={mode === 'json'}
889
+ data-active={mode === 'json'}
890
+ className="jp-xtralab-DiffWidget-segmentedButton"
891
+ onClick={() => content.setNotebookViewMode('json')}
892
+ >
893
+ JSON
894
+ </button>
895
+ </div>
896
+ );
897
+ }
898
+
899
+ function formatTitle(change: IFileChange): string {
900
+ const name = change.path.split('/').pop() ?? change.path;
901
+ const groupLabel = change.group === 'staged' ? 'Staged' : 'Working';
902
+ return `${name} (${groupLabel})`;
903
+ }