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.
@@ -7,33 +7,25 @@ import { Contents } from '@jupyterlab/services';
7
7
  import {
8
8
  ReactWidget,
9
9
  ToolbarButton,
10
- launchIcon,
11
- undoIcon
10
+ launchIcon
12
11
  } from '@jupyterlab/ui-components';
13
12
  import { ISignal, Signal } from '@lumino/signaling';
14
- import { FileDiff } from '@pierre/diffs/react';
15
- import {
16
- diffAcceptRejectHunk,
17
- parseDiffFromFile,
18
- type DiffLineAnnotation,
19
- type FileContents,
20
- type FileDiffMetadata
21
- } from '@pierre/diffs';
22
13
 
23
14
  import { getTreeIcon } from '../fileBrowser/icons';
24
15
  import { content } from './api';
16
+ import { imageDataType } from './imageDiff';
25
17
  import {
26
- buildNotebookDiff,
27
- NotebookDiffView,
28
- type INotebookDiffResult
29
- } from './notebookDiff';
18
+ DIFF_WIDGET_CSS_CLASS,
19
+ DiffSurface,
20
+ NotebookViewModeControl,
21
+ isDarkTheme,
22
+ readStoredNotebookViewMode,
23
+ writeStoredNotebookViewMode,
24
+ type NotebookDiffViewMode
25
+ } from './diffSurface';
30
26
  import { GitReference, IFileChange } from './tokens';
31
27
 
32
- /**
33
- * The CSS class added to the diff main-area widget. The selectors that
34
- * style the embedded `@pierre/diffs` viewer hang off this class.
35
- */
36
- export const DIFF_WIDGET_CSS_CLASS = 'jp-xtralab-DiffWidget';
28
+ export { DIFF_WIDGET_CSS_CLASS };
37
29
 
38
30
  export const PREVIEW_DIFF_WIDGET_ID = 'xtralab:diff:preview';
39
31
 
@@ -41,109 +33,6 @@ export function pinnedDiffWidgetId(change: IFileChange): string {
41
33
  return `xtralab:diff:pinned:${change.group}:${change.path}`;
42
34
  }
43
35
 
44
- /**
45
- * `localStorage` key for the user's last-chosen split ratio. Persisted so
46
- * the preference survives reloads — the library has no built-in way to
47
- * remember per-host UI state, and reopening every diff at 50/50 is
48
- * annoying once the user has dialed in a layout they like.
49
- */
50
- const SPLIT_RATIO_STORAGE_KEY = 'xtralab:diff-split-ratio';
51
-
52
- /**
53
- * Bounds on the left-pane fraction. Keeping a few percent on either side
54
- * guarantees both panes remain visible — dragging to 0/100 would hide a
55
- * column and is almost never what the user wants.
56
- */
57
- const MIN_SPLIT_RATIO = 0.1;
58
- const MAX_SPLIT_RATIO = 0.9;
59
- const DEFAULT_SPLIT_RATIO = 0.5;
60
-
61
- /**
62
- * CSS injected via the `@pierre/diffs` `unsafeCSS` option. The library
63
- * places it inside the shadow root under `@layer unsafe`, which beats
64
- * the library's own `@layer base` rule that hardcodes
65
- * `grid-template-columns: 1fr 1fr` on the split-mode container.
66
- *
67
- * The override reads from `--xtralab-split-cols`, a custom property we set
68
- * on the host element via the `style` prop. Custom properties inherit
69
- * through the shadow DOM boundary, so adjusting it on the host instantly
70
- * resizes the columns without re-rendering the diff.
71
- */
72
- const SPLIT_RESIZE_CSS = `pre[data-diff-type="split"][data-overflow="scroll"] {
73
- grid-template-columns: var(--xtralab-split-cols, 1fr 1fr);
74
- }`;
75
-
76
- function readStoredSplitRatio(): number {
77
- try {
78
- const raw = window.localStorage.getItem(SPLIT_RATIO_STORAGE_KEY);
79
- if (raw === null) {
80
- return DEFAULT_SPLIT_RATIO;
81
- }
82
- const parsed = Number.parseFloat(raw);
83
- if (
84
- Number.isFinite(parsed) &&
85
- parsed >= MIN_SPLIT_RATIO &&
86
- parsed <= MAX_SPLIT_RATIO
87
- ) {
88
- return parsed;
89
- }
90
- } catch {
91
- // localStorage may throw in privacy mode or sandboxed contexts. Falling
92
- // back to the default is fine — the user can resize again on this run.
93
- }
94
- return DEFAULT_SPLIT_RATIO;
95
- }
96
-
97
- function writeStoredSplitRatio(ratio: number): void {
98
- try {
99
- window.localStorage.setItem(SPLIT_RATIO_STORAGE_KEY, ratio.toString());
100
- } catch {
101
- // See readStoredSplitRatio — best-effort persistence.
102
- }
103
- }
104
-
105
- /**
106
- * View modes available for `.ipynb` diffs. `notebook` is the cell-by-cell
107
- * rendered view; `json` falls back to the raw nbformat JSON file diff so
108
- * the user can inspect exactly which bytes changed (handy for nbformat
109
- * spelunking, seeing execution_count drift, etc.).
110
- */
111
- type NotebookDiffViewMode = 'notebook' | 'json';
112
-
113
- const NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY = 'xtralab:notebook-diff-view-mode';
114
-
115
- function readStoredNotebookViewMode(): NotebookDiffViewMode {
116
- try {
117
- const raw = window.localStorage.getItem(
118
- NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY
119
- );
120
- if (raw === 'json' || raw === 'notebook') {
121
- return raw;
122
- }
123
- } catch {
124
- // See readStoredSplitRatio — privacy-mode / sandboxed contexts can
125
- // throw on access. The notebook view is the better default anyway.
126
- }
127
- return 'notebook';
128
- }
129
-
130
- function writeStoredNotebookViewMode(mode: NotebookDiffViewMode): void {
131
- try {
132
- window.localStorage.setItem(NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY, mode);
133
- } catch {
134
- // Best-effort.
135
- }
136
- }
137
-
138
- /**
139
- * Annotation payload threaded through the diff library back into the
140
- * `renderAnnotation` callback. Carries the index of the hunk a particular
141
- * action button targets.
142
- */
143
- interface IHunkActionAnnotation {
144
- hunkIndex: number;
145
- }
146
-
147
36
  /**
148
37
  * Build the deterministic widget id used for a diff against a particular
149
38
  * file/group pair. Reused across plugin and command code so reopening a
@@ -215,6 +104,12 @@ export interface IGitDiffWidgetOptions {
215
104
  * usual document chrome — toolbar slot, focus tracker integration, and
216
105
  * spinner-while-loading hooks — and matches the pattern used for other
217
106
  * read-only main-area widgets in the JupyterLab ecosystem.
107
+ *
108
+ * This is the launcher dashboard's own diff path
109
+ * (`xtralab:git:open-diff`), kept independent of `jupyterlab-git`. The
110
+ * actual diff rendering is delegated to the shared {@link DiffSurface};
111
+ * `jupyterlab-git`'s panel reaches the same surface through the providers
112
+ * registered in `diffProvider.tsx`.
218
113
  */
219
114
  export function createGitDiffWidget(
220
115
  options: IGitDiffWidgetOptions
@@ -421,21 +316,13 @@ export class DiffContentWidget extends ReactWidget {
421
316
  interface IDiffState {
422
317
  loading: boolean;
423
318
  /**
424
- * The pre-computed diff metadata for the current change. Owning the
425
- * metadata in our component lets us pass it to {@link FileDiff} *and*
426
- * thread the same instance through {@link diffAcceptRejectHunk} when the
427
- * user discards a hunk — so hunk indexes line up between what the user
428
- * clicked and what we mutate.
429
- */
430
- metadata: FileDiffMetadata | null;
431
- /**
432
- * Cell-by-cell notebook diff for `.ipynb` files. When non-null the
433
- * renderer uses a notebook-aware view instead of the line-oriented
434
- * file diff — kept distinct from {@link metadata} so a notebook whose
435
- * JSON we cannot parse can fall back to the file diff path without
436
- * losing the existing widget machinery.
319
+ * Resolved old/reference and new/challenger text. The shared
320
+ * {@link DiffSurface} turns these into the line/notebook diff; owning
321
+ * only the raw strings here keeps the fetch path decoupled from how the
322
+ * diff is rendered.
437
323
  */
438
- notebookDiff: INotebookDiffResult | null;
324
+ oldText: string;
325
+ newText: string;
439
326
  /**
440
327
  * Server-relative path of the file on disk. Computed from the repo path
441
328
  * + the file's git-root-relative path. We need it to write the file back
@@ -446,31 +333,6 @@ interface IDiffState {
446
333
  error: string | null;
447
334
  }
448
335
 
449
- /**
450
- * Detect notebook files by extension. We only look at the suffix so the
451
- * caller (the file-change tracker) doesn't need to plumb a content-type
452
- * field through; mistaking another `.ipynb`-named file for a notebook is
453
- * harmless because {@link buildNotebookDiff} validates the JSON shape and
454
- * we fall back to the file diff if parsing fails.
455
- */
456
- function isNotebookPath(path: string): boolean {
457
- return path.toLowerCase().endsWith('.ipynb');
458
- }
459
-
460
- /**
461
- * Decide whether the active JupyterLab theme is a dark theme. Prefers the
462
- * `IThemeManager` token when available, and falls back to sniffing the
463
- * `data-jp-theme-light` attribute that JupyterLab sets on `<body>` so the
464
- * component still works in stripped-down hosts where the token isn't
465
- * provided (lite, certain notebook configurations, …).
466
- */
467
- function isDarkTheme(themeManager: IThemeManager | null): boolean {
468
- if (themeManager !== null && themeManager.theme !== null) {
469
- return !themeManager.isLight(themeManager.theme);
470
- }
471
- return document.body.dataset.jpThemeLight === 'false';
472
- }
473
-
474
336
  function DiffViewer(
475
337
  props: IGitDiffWidgetOptions & { widget: DiffContentWidget }
476
338
  ): React.ReactElement {
@@ -486,25 +348,12 @@ function DiffViewer(
486
348
  } = props;
487
349
  const [state, setState] = React.useState<IDiffState>({
488
350
  loading: true,
489
- metadata: null,
490
- notebookDiff: null,
351
+ oldText: '',
352
+ newText: '',
491
353
  serverPath: '',
492
354
  isBinary: false,
493
355
  error: null
494
356
  });
495
- // Split ratio for the diff columns: fraction of width given to the
496
- // deletions (left) pane. Persisted across sessions so the user only
497
- // dials in their layout once.
498
- const [leftRatio, setLeftRatio] = React.useState<number>(() =>
499
- readStoredSplitRatio()
500
- );
501
- const wrapperRef = React.useRef<HTMLDivElement | null>(null);
502
- // The drag handler reads the live ratio from a ref so the listeners we
503
- // attach on pointer-down don't capture a stale value across renders.
504
- const leftRatioRef = React.useRef(leftRatio);
505
- React.useEffect(() => {
506
- leftRatioRef.current = leftRatio;
507
- }, [leftRatio]);
508
357
  // Bumping `reloadKey` re-enters the fetch effect. Used after a successful
509
358
  // hunk discard, so the diff reflects the new working-tree contents
510
359
  // without the user having to close and reopen the tab.
@@ -535,9 +384,23 @@ function DiffViewer(
535
384
  // toolbar reads this flag to decide whether to show the
536
385
  // Notebook/JSON selector — and the toolbar bar itself stays hidden
537
386
  // when the file isn't a parseable notebook.
538
- React.useEffect(() => {
539
- widget.setHasNotebookView(state.notebookDiff !== null);
540
- }, [widget, state.notebookDiff]);
387
+ const handleNotebookAvailabilityChange = React.useCallback(
388
+ (available: boolean) => {
389
+ widget.setHasNotebookView(available);
390
+ },
391
+ [widget]
392
+ );
393
+
394
+ // The shared surface reports its hunk count after every (re)computation;
395
+ // we keep the latest so the auto-close-on-empty effect can fire after a
396
+ // discard empties the diff.
397
+ const [hunkCount, setHunkCount] = React.useState<number | null>(null);
398
+ const handleMetadataChange = React.useCallback(
399
+ (info: { hunkCount: number | null }) => {
400
+ setHunkCount(info.hunkCount);
401
+ },
402
+ []
403
+ );
541
404
 
542
405
  // Auto-close after a discard that emptied the diff. Gated on `reloadKey > 0`
543
406
  // so a file that opens already-empty (uncommon but possible) is not closed
@@ -547,8 +410,7 @@ function DiffViewer(
547
410
  !state.loading &&
548
411
  state.error === null &&
549
412
  !state.isBinary &&
550
- state.metadata !== null &&
551
- state.metadata.hunks.length === 0 &&
413
+ hunkCount === 0 &&
552
414
  reloadKey > 0
553
415
  ) {
554
416
  onEmpty?.();
@@ -557,7 +419,7 @@ function DiffViewer(
557
419
  state.loading,
558
420
  state.error,
559
421
  state.isBinary,
560
- state.metadata,
422
+ hunkCount,
561
423
  reloadKey,
562
424
  onEmpty
563
425
  ]);
@@ -593,23 +455,27 @@ function DiffViewer(
593
455
  const serverPath = toServerPath(repoPath, change.path);
594
456
  setState({
595
457
  loading: true,
596
- metadata: null,
597
- notebookDiff: null,
458
+ oldText: '',
459
+ newText: '',
598
460
  serverPath,
599
461
  isBinary: false,
600
462
  error: null
601
463
  });
602
464
  const { oldRef, newRef } = resolveReferences(change);
465
+ // Raster images are still binary, but the git server base64-encodes
466
+ // binary content, so we fetch them like text and let the surface render
467
+ // an image diff. Other binary files have no useful textual diff.
468
+ const isImage = imageDataType(change.path) !== null;
603
469
  void (async () => {
604
470
  try {
605
- if (change.isBinary === true) {
471
+ if (change.isBinary === true && !isImage) {
606
472
  if (cancelled) {
607
473
  return;
608
474
  }
609
475
  setState({
610
476
  loading: false,
611
- metadata: null,
612
- notebookDiff: null,
477
+ oldText: '',
478
+ newText: '',
613
479
  serverPath,
614
480
  isBinary: true,
615
481
  error: null
@@ -628,33 +494,10 @@ function DiffViewer(
628
494
  if (cancelled) {
629
495
  return;
630
496
  }
631
- const oldText = oldRes.content ?? '';
632
- const newText = newRes.content ?? '';
633
-
634
- // For .ipynb files we precompute *both* the cell-by-cell
635
- // notebook diff and the raw JSON file diff up front. The user
636
- // toggles between them via the in-widget toolbar, so keeping
637
- // both around (instead of re-fetching on switch) makes the
638
- // toggle feel instant. If parsing fails, `notebookDiff` stays
639
- // null and we silently fall back to the file diff path.
640
- let notebookDiff: INotebookDiffResult | null = null;
641
- if (isNotebookPath(change.path)) {
642
- notebookDiff = buildNotebookDiff({ oldText, newText });
643
- }
644
-
645
- const oldFile: FileContents = {
646
- name: change.from ?? change.path,
647
- contents: oldText
648
- };
649
- const newFile: FileContents = {
650
- name: change.path,
651
- contents: newText
652
- };
653
- const metadata = parseDiffFromFile(oldFile, newFile);
654
497
  setState({
655
498
  loading: false,
656
- metadata,
657
- notebookDiff,
499
+ oldText: oldRes.content ?? '',
500
+ newText: newRes.content ?? '',
658
501
  serverPath,
659
502
  isBinary: false,
660
503
  error: null
@@ -665,8 +508,8 @@ function DiffViewer(
665
508
  }
666
509
  setState({
667
510
  loading: false,
668
- metadata: null,
669
- notebookDiff: null,
511
+ oldText: '',
512
+ newText: '',
670
513
  serverPath,
671
514
  isBinary: false,
672
515
  error: err instanceof Error ? err.message : String(err)
@@ -681,242 +524,54 @@ function DiffViewer(
681
524
  // Per-hunk discard buttons are only meaningful for unstaged modifications:
682
525
  // staged-vs-HEAD diffs would need an index manipulation we can't do
683
526
  // through the available endpoints, and untracked files have no previous
684
- // version to revert to. The annotation list collapses to empty in those
685
- // cases and the diff renders without action buttons.
527
+ // version to revert to.
686
528
  const canDiscardHunk =
687
529
  change.group === 'unstaged' && change.status !== 'untracked';
688
530
 
689
- const lineAnnotations = React.useMemo<
690
- DiffLineAnnotation<IHunkActionAnnotation>[]
691
- >(() => {
692
- if (!canDiscardHunk || state.metadata === null) {
693
- return [];
694
- }
695
- return state.metadata.hunks.map((hunk, hunkIndex) => ({
696
- side: 'additions',
697
- // The first line of each hunk in the new file. We anchor the button
698
- // there so it sits at the top of the hunk's body.
699
- lineNumber: hunk.additionStart,
700
- metadata: { hunkIndex }
701
- }));
702
- }, [canDiscardHunk, state.metadata]);
703
-
704
- const handleDiscardHunk = React.useCallback(
705
- async (hunkIndex: number) => {
706
- if (state.metadata === null) {
707
- return;
708
- }
709
- const updated = diffAcceptRejectHunk(state.metadata, hunkIndex, 'reject');
710
- // `additionLines` after a 'reject' contains the full new file with
711
- // the hunk reverted back to the old content. The library splits
712
- // file contents with `/(?<=\n)/` (a lookbehind that keeps the
713
- // trailing `\n` on each line), so the entries already carry their
714
- // own line endings — joining with `''` rebuilds the original text
715
- // verbatim, while joining with `'\n'` would double every newline.
716
- const text = updated.additionLines.join('');
717
- try {
531
+ const hunkDiscard = React.useMemo(
532
+ () => ({
533
+ enabled: canDiscardHunk,
534
+ save: async (text: string) => {
718
535
  await contentsManager.save(state.serverPath, {
719
536
  type: 'file',
720
537
  format: 'text',
721
538
  content: text
722
539
  });
540
+ },
541
+ onAfterSave: () => {
723
542
  onChanged();
724
543
  setReloadKey(key => key + 1);
725
- } catch (err) {
726
- console.error('xtralab: failed to discard hunk', err);
727
- }
728
- },
729
- [contentsManager, onChanged, state.metadata, state.serverPath]
730
- );
731
-
732
- const renderAnnotation = React.useCallback(
733
- (
734
- annotation: DiffLineAnnotation<IHunkActionAnnotation>
735
- ): React.ReactNode => {
736
- if (annotation.metadata === undefined) {
737
- return null;
738
- }
739
- const { hunkIndex } = annotation.metadata;
740
- return (
741
- <div className="jp-xtralab-DiffWidget-hunkAnnotation">
742
- <button
743
- type="button"
744
- className="jp-xtralab-DiffWidget-hunkButton"
745
- title="Discard this hunk's changes"
746
- aria-label="Discard hunk"
747
- onClick={() => void handleDiscardHunk(hunkIndex)}
748
- >
749
- <undoIcon.react
750
- tag="span"
751
- className="jp-xtralab-DiffWidget-hunkButton-icon"
752
- elementSize="normal"
753
- />
754
- </button>
755
- </div>
756
- );
757
- },
758
- [handleDiscardHunk]
759
- );
760
-
761
- const handleResizerPointerDown = React.useCallback(
762
- (event: React.PointerEvent<HTMLDivElement>) => {
763
- // Only react to primary-button drags. Right-click / middle-click on
764
- // the handle should pass through to the browser default.
765
- if (event.button !== 0) {
766
- return;
767
- }
768
- const wrapper = wrapperRef.current;
769
- if (wrapper === null) {
770
- return;
771
544
  }
772
- event.preventDefault();
773
- const handle = event.currentTarget;
774
- handle.setPointerCapture(event.pointerId);
775
- handle.dataset.dragging = 'true';
776
- const rect = wrapper.getBoundingClientRect();
777
- const startX = event.clientX;
778
- const startRatio = leftRatioRef.current;
779
-
780
- const onPointerMove = (ev: PointerEvent): void => {
781
- if (rect.width <= 0) {
782
- return;
783
- }
784
- const next = startRatio + (ev.clientX - startX) / rect.width;
785
- const clamped = Math.max(
786
- MIN_SPLIT_RATIO,
787
- Math.min(MAX_SPLIT_RATIO, next)
788
- );
789
- setLeftRatio(clamped);
790
- };
791
- const onPointerEnd = (ev: PointerEvent): void => {
792
- handle.releasePointerCapture(ev.pointerId);
793
- delete handle.dataset.dragging;
794
- handle.removeEventListener('pointermove', onPointerMove);
795
- handle.removeEventListener('pointerup', onPointerEnd);
796
- handle.removeEventListener('pointercancel', onPointerEnd);
797
- // Only persist on drag-end so a quick drag isn't followed by a
798
- // burst of writes for every intermediate position.
799
- writeStoredSplitRatio(leftRatioRef.current);
800
- };
801
- handle.addEventListener('pointermove', onPointerMove);
802
- handle.addEventListener('pointerup', onPointerEnd);
803
- handle.addEventListener('pointercancel', onPointerEnd);
804
- },
805
- []
545
+ }),
546
+ [canDiscardHunk, contentsManager, onChanged, state.serverPath]
806
547
  );
807
548
 
808
- const handleResizerDoubleClick = React.useCallback(() => {
809
- setLeftRatio(DEFAULT_SPLIT_RATIO);
810
- writeStoredSplitRatio(DEFAULT_SPLIT_RATIO);
811
- }, []);
812
-
813
- if (state.loading) {
814
- return <div className="jp-xtralab-DiffWidget-status">Loading diff…</div>;
815
- }
816
- if (state.error !== null) {
817
- return (
818
- <div className="jp-xtralab-DiffWidget-status" data-error="true">
819
- {state.error}
820
- </div>
821
- );
822
- }
823
- if (state.isBinary) {
824
- return (
825
- <div className="jp-xtralab-DiffWidget-status">
826
- Binary file — diff not supported.
827
- </div>
828
- );
829
- }
830
- // Decide which view to render. The notebook view requires a successful
831
- // nbformat parse; if that failed, the toolbar is hidden and we only
832
- // show the raw JSON file diff. The toolbar itself is sticky at the top
833
- // of the content area, sitting above the scroll viewport.
834
- const hasNotebookView = state.notebookDiff !== null;
835
- const showNotebookView = hasNotebookView && notebookViewMode === 'notebook';
836
- const showFileDiff = !showNotebookView && state.metadata !== null;
837
-
838
- if (!showNotebookView && !showFileDiff) {
839
- return (
840
- <div className="jp-xtralab-DiffWidget-status">No content to diff.</div>
841
- );
842
- }
843
-
844
- // Custom property set on the diffs-container host. The library's
845
- // shadow-root rule consumes it via `var(--xtralab-split-cols, …)` (see
846
- // SPLIT_RESIZE_CSS) and recomputes the column tracks instantly. Only
847
- // relevant in the file-diff path; the notebook view doesn't use it.
848
- const leftPercent = leftRatio * 100;
849
- const hostStyle = {
850
- '--xtralab-split-cols': `${leftPercent}% ${100 - leftPercent}%`
851
- } as React.CSSProperties;
852
-
853
549
  return (
854
- <div className="jp-xtralab-DiffWidget-content">
855
- <div ref={wrapperRef} className="jp-xtralab-DiffWidget-body">
856
- <div className="jp-xtralab-DiffWidget-scroll">
857
- {showNotebookView && state.notebookDiff !== null ? (
858
- <NotebookDiffView
859
- diff={state.notebookDiff}
860
- dark={dark}
861
- rendermime={rendermime}
862
- />
863
- ) : showFileDiff && state.metadata !== null ? (
864
- <FileDiff<IHunkActionAnnotation>
865
- fileDiff={state.metadata}
866
- lineAnnotations={lineAnnotations}
867
- renderAnnotation={renderAnnotation}
868
- style={hostStyle}
869
- // Disable the worker pool: JupyterLab's webpack federation
870
- // pipeline doesn't ship the library's
871
- // `@pierre/diffs/worker/worker.js` file at a URL the worker
872
- // bootstrap can resolve from, so leaving the pool enabled
873
- // crashes on instantiation. Running on the main thread is
874
- // fine for the small files git diffs typically operate on.
875
- disableWorkerPool={true}
876
- options={{
877
- diffStyle: 'split',
878
- // Drop the file header — the tab title already shows the
879
- // file name and the panel header carries the change
880
- // context.
881
- disableFileHeader: true,
882
- theme: dark ? 'pierre-dark' : 'pierre-light',
883
- themeType: dark ? 'dark' : 'light',
884
- // Inject the column-resize override into the shadow root
885
- // via the library's `@layer unsafe` channel. Keeping the
886
- // string constant lets the library short-circuit the
887
- // re-render path it uses when this option actually
888
- // changes.
889
- unsafeCSS: SPLIT_RESIZE_CSS
890
- }}
891
- />
892
- ) : null}
893
- </div>
894
- {showFileDiff ? (
895
- <div
896
- className="jp-xtralab-DiffWidget-resizer"
897
- style={{ left: `${leftPercent}%` }}
898
- role="separator"
899
- aria-orientation="vertical"
900
- aria-valuenow={Math.round(leftPercent)}
901
- aria-valuemin={Math.round(MIN_SPLIT_RATIO * 100)}
902
- aria-valuemax={Math.round(MAX_SPLIT_RATIO * 100)}
903
- title="Drag to resize the diff panes (double-click to reset)"
904
- onPointerDown={handleResizerPointerDown}
905
- onDoubleClick={handleResizerDoubleClick}
906
- />
907
- ) : null}
908
- </div>
909
- </div>
550
+ <DiffSurface
551
+ loading={state.loading}
552
+ error={state.error}
553
+ isBinary={state.isBinary}
554
+ oldText={state.oldText}
555
+ newText={state.newText}
556
+ newName={change.path}
557
+ oldName={change.from ?? change.path}
558
+ dark={dark}
559
+ rendermime={rendermime}
560
+ notebookViewMode={notebookViewMode}
561
+ onNotebookAvailabilityChange={handleNotebookAvailabilityChange}
562
+ onMetadataChange={handleMetadataChange}
563
+ hunkDiscard={hunkDiscard}
564
+ />
910
565
  );
911
566
  }
912
567
 
913
568
  /**
914
569
  * Segmented Notebook/JSON selector mounted into the `MainAreaWidget`
915
- * toolbar for `.ipynb` diffs. The selector reads from and writes to the
916
- * `DiffContentWidget` so React state changes (via the diff content) and
917
- * toolbar interactions stay in sync without either side knowing about
918
- * the other. The host toolbar widget's visibility is toggled by
919
- * {@link createGitDiffWidget} based on the same `hasNotebookView`
570
+ * toolbar for `.ipynb` diffs (launcher path). The selector reads from and
571
+ * writes to the {@link DiffContentWidget} so React state changes (via the
572
+ * diff content) and toolbar interactions stay in sync without either side
573
+ * knowing about the other. The host toolbar widget's visibility is toggled
574
+ * by {@link createGitDiffWidget} based on the same `hasNotebookView`
920
575
  * signal, so this control is always meaningful when it appears.
921
576
  */
922
577
  class NotebookViewModeToolbarItem extends ReactWidget {
@@ -927,63 +582,48 @@ class NotebookViewModeToolbarItem extends ReactWidget {
927
582
  }
928
583
 
929
584
  protected render(): React.ReactElement {
930
- return <NotebookViewModeControl content={this._content} />;
585
+ return <NotebookViewModeToolbarControl content={this._content} />;
931
586
  }
932
587
 
933
588
  private _content: DiffContentWidget;
934
589
  }
935
590
 
936
- function NotebookViewModeControl(props: {
591
+ function NotebookViewModeToolbarControl(props: {
937
592
  content: DiffContentWidget;
938
593
  }): React.ReactElement {
939
594
  const { content } = props;
940
595
  const [mode, setMode] = React.useState<NotebookDiffViewMode>(
941
596
  () => content.notebookViewMode
942
597
  );
598
+ const [available, setAvailable] = React.useState<boolean>(
599
+ () => content.hasNotebookView
600
+ );
943
601
  React.useEffect(() => {
944
- const handler = (
602
+ const onMode = (
945
603
  _sender: DiffContentWidget,
946
604
  next: NotebookDiffViewMode
947
605
  ): void => {
948
606
  setMode(next);
949
607
  };
950
- content.notebookViewModeChanged.connect(handler);
608
+ const onAvailable = (_sender: DiffContentWidget, next: boolean): void => {
609
+ setAvailable(next);
610
+ };
611
+ content.notebookViewModeChanged.connect(onMode);
612
+ content.hasNotebookViewChanged.connect(onAvailable);
951
613
  setMode(content.notebookViewMode);
614
+ setAvailable(content.hasNotebookView);
952
615
  return () => {
953
- content.notebookViewModeChanged.disconnect(handler);
616
+ content.notebookViewModeChanged.disconnect(onMode);
617
+ content.hasNotebookViewChanged.disconnect(onAvailable);
954
618
  };
955
619
  }, [content]);
956
- if (!content.hasNotebookView) {
957
- return <></>;
958
- }
959
620
 
960
621
  return (
961
- <div
962
- className="jp-xtralab-DiffWidget-segmented"
963
- role="tablist"
964
- aria-label="Notebook diff view mode"
965
- >
966
- <button
967
- type="button"
968
- role="tab"
969
- aria-selected={mode === 'notebook'}
970
- data-active={mode === 'notebook'}
971
- className="jp-xtralab-DiffWidget-segmentedButton"
972
- onClick={() => content.setNotebookViewMode('notebook')}
973
- >
974
- Notebook
975
- </button>
976
- <button
977
- type="button"
978
- role="tab"
979
- aria-selected={mode === 'json'}
980
- data-active={mode === 'json'}
981
- className="jp-xtralab-DiffWidget-segmentedButton"
982
- onClick={() => content.setNotebookViewMode('json')}
983
- >
984
- JSON
985
- </button>
986
- </div>
622
+ <NotebookViewModeControl
623
+ mode={mode}
624
+ available={available}
625
+ onChange={next => content.setNotebookViewMode(next)}
626
+ />
987
627
  );
988
628
  }
989
629