xtralab 0.4.0 → 0.6.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 (96) hide show
  1. package/README.md +110 -73
  2. package/lib/agentSessions.d.ts +50 -0
  3. package/lib/agentSessions.js +45 -0
  4. package/lib/fileBrowser/index.js +5 -1
  5. package/lib/git/commands.d.ts +12 -13
  6. package/lib/git/commands.js +7 -28
  7. package/lib/git/diffProvider.d.ts +8 -0
  8. package/lib/git/diffProvider.js +314 -0
  9. package/lib/git/diffSurface.d.ts +115 -0
  10. package/lib/git/diffSurface.js +342 -0
  11. package/lib/git/diffWidget.d.ts +8 -13
  12. package/lib/git/diffWidget.js +65 -311
  13. package/lib/git/imageDiff.d.ts +23 -0
  14. package/lib/git/imageDiff.js +256 -0
  15. package/lib/git/index.d.ts +10 -8
  16. package/lib/git/index.js +37 -52
  17. package/lib/index.d.ts +4 -2
  18. package/lib/index.js +14 -8
  19. package/lib/launcher/agents.d.ts +1 -1
  20. package/lib/launcher/agents.js +22 -13
  21. package/lib/launcher/commands.d.ts +32 -6
  22. package/lib/launcher/commands.js +81 -57
  23. package/lib/launcher/dashboard.d.ts +15 -0
  24. package/lib/launcher/dashboard.js +119 -5
  25. package/lib/launcher/editorRegistry.d.ts +43 -0
  26. package/lib/launcher/editorRegistry.js +96 -0
  27. package/lib/launcher/editors.d.ts +78 -0
  28. package/lib/launcher/editors.js +110 -0
  29. package/lib/launcher/icons.d.ts +25 -6
  30. package/lib/launcher/icons.js +116 -19
  31. package/lib/launcher/index.d.ts +2 -22
  32. package/lib/launcher/index.js +48 -25
  33. package/lib/launcher/registry.d.ts +21 -0
  34. package/lib/launcher/registry.js +28 -0
  35. package/lib/launcher/tokens.d.ts +41 -0
  36. package/lib/launcher/tokens.js +17 -0
  37. package/lib/terminals/detection.d.ts +11 -0
  38. package/lib/terminals/detection.js +50 -0
  39. package/lib/terminals/index.d.ts +27 -0
  40. package/lib/terminals/index.js +194 -0
  41. package/lib/terminals/model.d.ts +191 -0
  42. package/lib/terminals/model.js +418 -0
  43. package/lib/terminals/widget.d.ts +72 -0
  44. package/lib/terminals/widget.js +93 -0
  45. package/lib/topBar/icons.d.ts +13 -0
  46. package/lib/topBar/icons.js +27 -0
  47. package/lib/topBar/index.d.ts +16 -0
  48. package/lib/topBar/index.js +86 -0
  49. package/package.json +52 -35
  50. package/schema/launcher.json +56 -1
  51. package/src/agentSessions.ts +94 -0
  52. package/src/fileBrowser/index.ts +5 -1
  53. package/src/git/commands.ts +12 -38
  54. package/src/git/diffProvider.tsx +462 -0
  55. package/src/git/diffSurface.tsx +588 -0
  56. package/src/git/diffWidget.tsx +105 -465
  57. package/src/git/imageDiff.tsx +469 -0
  58. package/src/git/index.ts +38 -68
  59. package/src/index.ts +14 -8
  60. package/src/launcher/agents.ts +23 -14
  61. package/src/launcher/commands.ts +98 -60
  62. package/src/launcher/dashboard.tsx +205 -3
  63. package/src/launcher/editorRegistry.ts +149 -0
  64. package/src/launcher/editors.ts +183 -0
  65. package/src/launcher/icons.ts +120 -20
  66. package/src/launcher/index.ts +62 -27
  67. package/src/launcher/registry.ts +33 -0
  68. package/src/launcher/tokens.ts +51 -0
  69. package/src/terminals/detection.ts +65 -0
  70. package/src/terminals/index.ts +222 -0
  71. package/src/terminals/model.ts +497 -0
  72. package/src/terminals/widget.tsx +240 -0
  73. package/src/topBar/icons.ts +30 -0
  74. package/src/topBar/index.ts +121 -0
  75. package/style/git.css +267 -266
  76. package/style/index.css +2 -1
  77. package/style/index.js +2 -1
  78. package/style/launcher.css +124 -2
  79. package/style/terminals.css +235 -0
  80. package/style/topBar.css +38 -0
  81. package/lib/git/icons.d.ts +0 -14
  82. package/lib/git/icons.js +0 -19
  83. package/lib/git/panel.d.ts +0 -29
  84. package/lib/git/panel.js +0 -42
  85. package/lib/git/panelComponent.d.ts +0 -28
  86. package/lib/git/panelComponent.js +0 -320
  87. package/lib/statusBar/index.d.ts +0 -14
  88. package/lib/statusBar/index.js +0 -89
  89. package/lib/statusBar/widget.d.ts +0 -121
  90. package/lib/statusBar/widget.js +0 -237
  91. package/src/git/icons.ts +0 -20
  92. package/src/git/panel.tsx +0 -56
  93. package/src/git/panelComponent.tsx +0 -575
  94. package/src/statusBar/index.ts +0 -115
  95. package/src/statusBar/widget.tsx +0 -318
  96. package/style/statusBar.css +0 -95
@@ -0,0 +1,588 @@
1
+ import * as React from 'react';
2
+
3
+ import { IThemeManager } from '@jupyterlab/apputils';
4
+ import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
5
+ import { undoIcon } from '@jupyterlab/ui-components';
6
+ import { FileDiff } from '@pierre/diffs/react';
7
+ import {
8
+ diffAcceptRejectHunk,
9
+ parseDiffFromFile,
10
+ type DiffLineAnnotation,
11
+ type FileContents,
12
+ type FileDiffMetadata
13
+ } from '@pierre/diffs';
14
+
15
+ import { ImageDiffView, imageDataType } from './imageDiff';
16
+ import {
17
+ buildNotebookDiff,
18
+ NotebookDiffView,
19
+ type INotebookDiffResult
20
+ } from './notebookDiff';
21
+
22
+ /**
23
+ * Shared diff rendering surface used by both diff hosts in this extension:
24
+ *
25
+ * - the launcher dashboard's own `xtralab:git:open-diff` command
26
+ * (via {@link DiffContentWidget} in `diffWidget.tsx`), and
27
+ * - the `jupyterlab-git` diff providers registered into the upstream git
28
+ * panel (via `XtralabDiffWidget` in `diffProvider.tsx`).
29
+ *
30
+ * The surface is deliberately content-source agnostic: callers resolve the
31
+ * old/new text however they like (xtralab's REST helper, or a
32
+ * `Git.Diff.IModel`'s `content()` getters) and hand the resolved strings in.
33
+ * Everything visual — the `@pierre/diffs` split view, the cell-by-cell
34
+ * notebook view, the draggable column resizer, and the per-hunk discard
35
+ * affordance — lives here so the two hosts stay pixel-identical.
36
+ */
37
+
38
+ /**
39
+ * The CSS class added to the diff main-area widget. The selectors that
40
+ * style the embedded `@pierre/diffs` viewer hang off this class.
41
+ */
42
+ export const DIFF_WIDGET_CSS_CLASS = 'jp-xtralab-DiffWidget';
43
+
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 it).
48
+ */
49
+ const SPLIT_RATIO_STORAGE_KEY = 'xtralab:diff-split-ratio';
50
+
51
+ /**
52
+ * Bounds on the left-pane fraction. Keeping a few percent on either side
53
+ * guarantees both panes remain visible — dragging to 0/100 would hide a
54
+ * column and is almost never what the user wants.
55
+ */
56
+ const MIN_SPLIT_RATIO = 0.1;
57
+ const MAX_SPLIT_RATIO = 0.9;
58
+ const DEFAULT_SPLIT_RATIO = 0.5;
59
+
60
+ /**
61
+ * CSS injected via the `@pierre/diffs` `unsafeCSS` option. The library
62
+ * places it inside the shadow root under `@layer unsafe`, which beats
63
+ * the library's own `@layer base` rule that hardcodes
64
+ * `grid-template-columns: 1fr 1fr` on the split-mode container.
65
+ *
66
+ * The override reads from `--xtralab-split-cols`, a custom property we set
67
+ * on the host element via the `style` prop. Custom properties inherit
68
+ * through the shadow DOM boundary, so adjusting it on the host instantly
69
+ * resizes the columns without re-rendering the diff.
70
+ */
71
+ const SPLIT_RESIZE_CSS = `pre[data-diff-type="split"][data-overflow="scroll"] {
72
+ grid-template-columns: var(--xtralab-split-cols, 1fr 1fr);
73
+ }`;
74
+
75
+ function readStoredSplitRatio(): number {
76
+ try {
77
+ const raw = window.localStorage.getItem(SPLIT_RATIO_STORAGE_KEY);
78
+ if (raw === null) {
79
+ return DEFAULT_SPLIT_RATIO;
80
+ }
81
+ const parsed = Number.parseFloat(raw);
82
+ if (
83
+ Number.isFinite(parsed) &&
84
+ parsed >= MIN_SPLIT_RATIO &&
85
+ parsed <= MAX_SPLIT_RATIO
86
+ ) {
87
+ return parsed;
88
+ }
89
+ } catch {
90
+ // localStorage may throw in privacy mode or sandboxed contexts. Falling
91
+ // back to the default is fine — the user can resize again on this run.
92
+ }
93
+ return DEFAULT_SPLIT_RATIO;
94
+ }
95
+
96
+ function writeStoredSplitRatio(ratio: number): void {
97
+ try {
98
+ window.localStorage.setItem(SPLIT_RATIO_STORAGE_KEY, ratio.toString());
99
+ } catch {
100
+ // See readStoredSplitRatio — best-effort persistence.
101
+ }
102
+ }
103
+
104
+ /**
105
+ * View modes available for `.ipynb` diffs. `notebook` is the cell-by-cell
106
+ * rendered view; `json` is the raw nbformat JSON file diff, for inspecting
107
+ * exactly which bytes changed.
108
+ */
109
+ export type NotebookDiffViewMode = 'notebook' | 'json';
110
+
111
+ const NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY = 'xtralab:notebook-diff-view-mode';
112
+
113
+ export function readStoredNotebookViewMode(): NotebookDiffViewMode {
114
+ try {
115
+ const raw = window.localStorage.getItem(
116
+ NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY
117
+ );
118
+ if (raw === 'json' || raw === 'notebook') {
119
+ return raw;
120
+ }
121
+ } catch {
122
+ // See readStoredSplitRatio — privacy-mode / sandboxed contexts can
123
+ // throw on access. The notebook view is the better default anyway.
124
+ }
125
+ return 'notebook';
126
+ }
127
+
128
+ export function writeStoredNotebookViewMode(mode: NotebookDiffViewMode): void {
129
+ try {
130
+ window.localStorage.setItem(NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY, mode);
131
+ } catch {
132
+ // Best-effort.
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Annotation payload threaded through the diff library back into the
138
+ * `renderAnnotation` callback. Carries the index of the hunk a particular
139
+ * action button targets.
140
+ */
141
+ interface IHunkActionAnnotation {
142
+ hunkIndex: number;
143
+ }
144
+
145
+ /**
146
+ * Detect notebook files by extension. We only look at the suffix so the
147
+ * caller doesn't need to plumb a content-type field through; mistaking
148
+ * another `.ipynb`-named file for a notebook is harmless because
149
+ * {@link buildNotebookDiff} validates the JSON shape and we fall back to
150
+ * the file diff if parsing fails.
151
+ */
152
+ function isNotebookPath(path: string): boolean {
153
+ return path.toLowerCase().endsWith('.ipynb');
154
+ }
155
+
156
+ /**
157
+ * Decide whether the active JupyterLab theme is a dark theme. Prefers the
158
+ * `IThemeManager` token when available, and falls back to sniffing the
159
+ * `data-jp-theme-light` attribute that JupyterLab sets on `<body>` so the
160
+ * component still works in stripped-down hosts where the token isn't
161
+ * provided (lite, certain notebook configurations, …).
162
+ */
163
+ export function isDarkTheme(themeManager: IThemeManager | null): boolean {
164
+ if (themeManager !== null && themeManager.theme !== null) {
165
+ return !themeManager.isLight(themeManager.theme);
166
+ }
167
+ return document.body.dataset.jpThemeLight === 'false';
168
+ }
169
+
170
+ /**
171
+ * Optional per-hunk discard wiring. When supplied and `enabled`, each hunk
172
+ * in a plain-text/code diff gets an inline "discard" button; clicking it
173
+ * reverts that hunk and hands the rebuilt full-file text back to the host
174
+ * via {@link IHunkDiscard.save}. The host owns the actual write (it knows
175
+ * the on-disk path) and triggers any follow-up refresh in
176
+ * {@link IHunkDiscard.onAfterSave}.
177
+ */
178
+ export interface IHunkDiscard {
179
+ enabled: boolean;
180
+ save: (fullText: string) => Promise<void>;
181
+ onAfterSave: () => void;
182
+ }
183
+
184
+ export interface IDiffSurfaceProps {
185
+ /** Whether the host is still resolving the file contents. */
186
+ loading: boolean;
187
+ /** Fatal error message to show instead of a diff, or `null`. */
188
+ error: string | null;
189
+ /** Whether the file is binary (no textual diff is rendered). */
190
+ isBinary: boolean;
191
+ /** Resolved old/reference text. Ignored while loading/binary/errored. */
192
+ oldText: string;
193
+ /** Resolved new/challenger text. Ignored while loading/binary/errored. */
194
+ newText: string;
195
+ /**
196
+ * New-side path. Drives notebook detection and the `@pierre/diffs` file
197
+ * name on the additions side.
198
+ */
199
+ newName: string;
200
+ /**
201
+ * Old-side path. Differs from {@link newName} only for renames; defaults
202
+ * to {@link newName} when the host does not track a previous path.
203
+ */
204
+ oldName?: string;
205
+ /** Whether to render with the dark `@pierre/diffs` theme. */
206
+ dark: boolean;
207
+ /**
208
+ * Rendermime registry used by the notebook view to render outputs and
209
+ * markdown cells. May be `null` in stripped-down hosts; the notebook
210
+ * diff falls back to a textual representation in that case.
211
+ */
212
+ rendermime: IRenderMimeRegistry | null;
213
+ /** Current rendered-vs-JSON choice for notebook diffs (host-controlled). */
214
+ notebookViewMode: NotebookDiffViewMode;
215
+ /**
216
+ * Called whenever the availability of a rendered notebook view changes,
217
+ * so the host can show/hide its Notebook/JSON toolbar toggle.
218
+ */
219
+ onNotebookAvailabilityChange?: (available: boolean) => void;
220
+ /**
221
+ * Called after each (re)computation of the diff with the number of hunks
222
+ * (or `null` when no textual diff exists). Hosts use this to implement
223
+ * post-discard behaviors such as auto-closing an emptied diff.
224
+ */
225
+ onMetadataChange?: (info: { hunkCount: number | null }) => void;
226
+ /** Optional per-hunk discard wiring; omit for a read-only diff. */
227
+ hunkDiscard?: IHunkDiscard;
228
+ }
229
+
230
+ /**
231
+ * The shared, content-source-agnostic diff renderer. See the module
232
+ * docstring for the division of labor between this surface and its hosts.
233
+ */
234
+ export function DiffSurface(props: IDiffSurfaceProps): React.ReactElement {
235
+ const {
236
+ loading,
237
+ error,
238
+ isBinary,
239
+ oldText,
240
+ newText,
241
+ newName,
242
+ oldName,
243
+ dark,
244
+ rendermime,
245
+ notebookViewMode,
246
+ onNotebookAvailabilityChange,
247
+ onMetadataChange,
248
+ hunkDiscard
249
+ } = props;
250
+
251
+ const hasContent = !loading && error === null && !isBinary;
252
+
253
+ // Raster images take a dedicated `<img>`-based view instead of a
254
+ // text/notebook diff; the host passes the two sides as base64 strings in
255
+ // `oldText`/`newText` (the git server base64-encodes binary content).
256
+ const imageType = React.useMemo(() => imageDataType(newName), [newName]);
257
+
258
+ // Pre-compute the line-oriented diff metadata. Owning it here lets us
259
+ // pass it to {@link FileDiff} *and* thread the same instance through
260
+ // {@link diffAcceptRejectHunk} when the user discards a hunk — so hunk
261
+ // indexes line up between what the user clicked and what we mutate.
262
+ const metadata = React.useMemo<FileDiffMetadata | null>(() => {
263
+ if (!hasContent || imageType !== null) {
264
+ return null;
265
+ }
266
+ const oldFile: FileContents = {
267
+ name: oldName ?? newName,
268
+ contents: oldText
269
+ };
270
+ const newFile: FileContents = { name: newName, contents: newText };
271
+ return parseDiffFromFile(oldFile, newFile);
272
+ }, [hasContent, imageType, oldText, newText, oldName, newName]);
273
+
274
+ // Cell-by-cell notebook diff for `.ipynb` files. When non-null the
275
+ // renderer uses a notebook-aware view instead of the line-oriented file
276
+ // diff — kept distinct from {@link metadata} so a notebook whose JSON we
277
+ // cannot parse can fall back to the file diff path.
278
+ const notebookDiff = React.useMemo<INotebookDiffResult | null>(() => {
279
+ if (!hasContent || imageType !== null || !isNotebookPath(newName)) {
280
+ return null;
281
+ }
282
+ return buildNotebookDiff({ oldText, newText });
283
+ }, [hasContent, imageType, oldText, newText, newName]);
284
+
285
+ // Tell the host whether a rendered notebook view is currently available
286
+ // so it can show/hide its Notebook/JSON toggle.
287
+ React.useEffect(() => {
288
+ onNotebookAvailabilityChange?.(notebookDiff !== null);
289
+ }, [onNotebookAvailabilityChange, notebookDiff]);
290
+
291
+ // Report the diff shape so hosts can react (e.g. auto-close on empty).
292
+ React.useEffect(() => {
293
+ onMetadataChange?.({
294
+ hunkCount: metadata !== null ? metadata.hunks.length : null
295
+ });
296
+ }, [onMetadataChange, metadata]);
297
+
298
+ // Split ratio for the diff columns: fraction of width given to the
299
+ // deletions (left) pane. Persisted across sessions so the user only
300
+ // dials in their layout once.
301
+ const [leftRatio, setLeftRatio] = React.useState<number>(() =>
302
+ readStoredSplitRatio()
303
+ );
304
+ const wrapperRef = React.useRef<HTMLDivElement | null>(null);
305
+ // The drag handler reads the live ratio from a ref so the listeners we
306
+ // attach on pointer-down don't capture a stale value across renders.
307
+ const leftRatioRef = React.useRef(leftRatio);
308
+ React.useEffect(() => {
309
+ leftRatioRef.current = leftRatio;
310
+ }, [leftRatio]);
311
+
312
+ const canDiscardHunk = hunkDiscard?.enabled === true;
313
+
314
+ const lineAnnotations = React.useMemo<
315
+ DiffLineAnnotation<IHunkActionAnnotation>[]
316
+ >(() => {
317
+ if (!canDiscardHunk || metadata === null) {
318
+ return [];
319
+ }
320
+ return metadata.hunks.map((hunk, hunkIndex) => ({
321
+ side: 'additions',
322
+ // The first line of each hunk in the new file. We anchor the button
323
+ // there so it sits at the top of the hunk's body.
324
+ lineNumber: hunk.additionStart,
325
+ metadata: { hunkIndex }
326
+ }));
327
+ }, [canDiscardHunk, metadata]);
328
+
329
+ const handleDiscardHunk = React.useCallback(
330
+ async (hunkIndex: number) => {
331
+ if (metadata === null || hunkDiscard === undefined) {
332
+ return;
333
+ }
334
+ const updated = diffAcceptRejectHunk(metadata, hunkIndex, 'reject');
335
+ // `additionLines` after a 'reject' contains the full new file with
336
+ // the hunk reverted back to the old content. The library splits
337
+ // file contents with `/(?<=\n)/` (a lookbehind that keeps the
338
+ // trailing `\n` on each line), so the entries already carry their
339
+ // own line endings — joining with `''` rebuilds the original text
340
+ // verbatim, while joining with `'\n'` would double every newline.
341
+ const text = updated.additionLines.join('');
342
+ try {
343
+ await hunkDiscard.save(text);
344
+ hunkDiscard.onAfterSave();
345
+ } catch (err) {
346
+ console.error('xtralab: failed to discard hunk', err);
347
+ }
348
+ },
349
+ [hunkDiscard, metadata]
350
+ );
351
+
352
+ const renderAnnotation = React.useCallback(
353
+ (
354
+ annotation: DiffLineAnnotation<IHunkActionAnnotation>
355
+ ): React.ReactNode => {
356
+ if (annotation.metadata === undefined) {
357
+ return null;
358
+ }
359
+ const { hunkIndex } = annotation.metadata;
360
+ return (
361
+ <div className="jp-xtralab-DiffWidget-hunkAnnotation">
362
+ <button
363
+ type="button"
364
+ className="jp-xtralab-DiffWidget-hunkButton"
365
+ title="Discard this hunk's changes"
366
+ aria-label="Discard hunk"
367
+ onClick={() => void handleDiscardHunk(hunkIndex)}
368
+ >
369
+ <undoIcon.react
370
+ tag="span"
371
+ className="jp-xtralab-DiffWidget-hunkButton-icon"
372
+ elementSize="normal"
373
+ />
374
+ </button>
375
+ </div>
376
+ );
377
+ },
378
+ [handleDiscardHunk]
379
+ );
380
+
381
+ const handleResizerPointerDown = React.useCallback(
382
+ (event: React.PointerEvent<HTMLDivElement>) => {
383
+ // Only react to primary-button drags. Right-click / middle-click on
384
+ // the handle should pass through to the browser default.
385
+ if (event.button !== 0) {
386
+ return;
387
+ }
388
+ const wrapper = wrapperRef.current;
389
+ if (wrapper === null) {
390
+ return;
391
+ }
392
+ event.preventDefault();
393
+ const handle = event.currentTarget;
394
+ handle.setPointerCapture(event.pointerId);
395
+ handle.dataset.dragging = 'true';
396
+ const rect = wrapper.getBoundingClientRect();
397
+ const startX = event.clientX;
398
+ const startRatio = leftRatioRef.current;
399
+
400
+ const onPointerMove = (ev: PointerEvent): void => {
401
+ if (rect.width <= 0) {
402
+ return;
403
+ }
404
+ const next = startRatio + (ev.clientX - startX) / rect.width;
405
+ const clamped = Math.max(
406
+ MIN_SPLIT_RATIO,
407
+ Math.min(MAX_SPLIT_RATIO, next)
408
+ );
409
+ setLeftRatio(clamped);
410
+ };
411
+ const onPointerEnd = (ev: PointerEvent): void => {
412
+ handle.releasePointerCapture(ev.pointerId);
413
+ delete handle.dataset.dragging;
414
+ handle.removeEventListener('pointermove', onPointerMove);
415
+ handle.removeEventListener('pointerup', onPointerEnd);
416
+ handle.removeEventListener('pointercancel', onPointerEnd);
417
+ // Only persist on drag-end so a quick drag isn't followed by a
418
+ // burst of writes for every intermediate position.
419
+ writeStoredSplitRatio(leftRatioRef.current);
420
+ };
421
+ handle.addEventListener('pointermove', onPointerMove);
422
+ handle.addEventListener('pointerup', onPointerEnd);
423
+ handle.addEventListener('pointercancel', onPointerEnd);
424
+ },
425
+ []
426
+ );
427
+
428
+ const handleResizerDoubleClick = React.useCallback(() => {
429
+ setLeftRatio(DEFAULT_SPLIT_RATIO);
430
+ writeStoredSplitRatio(DEFAULT_SPLIT_RATIO);
431
+ }, []);
432
+
433
+ if (loading) {
434
+ return <div className="jp-xtralab-DiffWidget-status">Loading diff…</div>;
435
+ }
436
+ if (error !== null) {
437
+ return (
438
+ <div className="jp-xtralab-DiffWidget-status" data-error="true">
439
+ {error}
440
+ </div>
441
+ );
442
+ }
443
+ if (isBinary) {
444
+ return (
445
+ <div className="jp-xtralab-DiffWidget-status">
446
+ Binary file — diff not supported.
447
+ </div>
448
+ );
449
+ }
450
+ if (imageType !== null) {
451
+ // `oldText`/`newText` carry the two revisions as base64 here. The
452
+ // image view owns its own layout and mode selector, so it does not
453
+ // use the split resizer or the notebook/JSON toggle.
454
+ return (
455
+ <div className="jp-xtralab-DiffWidget-content">
456
+ <ImageDiffView
457
+ reference={oldText}
458
+ challenger={newText}
459
+ fileType={imageType}
460
+ />
461
+ </div>
462
+ );
463
+ }
464
+ // Decide which view to render. The notebook view requires a successful
465
+ // nbformat parse; if that failed, we only show the raw JSON file diff.
466
+ const hasNotebookView = notebookDiff !== null;
467
+ const showNotebookView = hasNotebookView && notebookViewMode === 'notebook';
468
+ const showFileDiff = !showNotebookView && metadata !== null;
469
+
470
+ if (!showNotebookView && !showFileDiff) {
471
+ return (
472
+ <div className="jp-xtralab-DiffWidget-status">No content to diff.</div>
473
+ );
474
+ }
475
+
476
+ // Custom property set on the diffs-container host. The library's
477
+ // shadow-root rule consumes it via `var(--xtralab-split-cols, …)` (see
478
+ // SPLIT_RESIZE_CSS) and recomputes the column tracks instantly. Only
479
+ // relevant in the file-diff path; the notebook view doesn't use it.
480
+ const leftPercent = leftRatio * 100;
481
+ const hostStyle = {
482
+ '--xtralab-split-cols': `${leftPercent}% ${100 - leftPercent}%`
483
+ } as React.CSSProperties;
484
+
485
+ return (
486
+ <div className="jp-xtralab-DiffWidget-content">
487
+ <div ref={wrapperRef} className="jp-xtralab-DiffWidget-body">
488
+ <div className="jp-xtralab-DiffWidget-scroll">
489
+ {showNotebookView && notebookDiff !== null ? (
490
+ <NotebookDiffView
491
+ diff={notebookDiff}
492
+ dark={dark}
493
+ rendermime={rendermime}
494
+ />
495
+ ) : showFileDiff && metadata !== null ? (
496
+ <FileDiff<IHunkActionAnnotation>
497
+ fileDiff={metadata}
498
+ lineAnnotations={lineAnnotations}
499
+ renderAnnotation={renderAnnotation}
500
+ style={hostStyle}
501
+ // Disable the worker pool: JupyterLab's webpack federation
502
+ // pipeline doesn't ship the library's
503
+ // `@pierre/diffs/worker/worker.js` file at a URL the worker
504
+ // bootstrap can resolve from, so leaving the pool enabled
505
+ // crashes on instantiation. Running on the main thread is
506
+ // fine for the small files git diffs typically operate on.
507
+ disableWorkerPool={true}
508
+ options={{
509
+ diffStyle: 'split',
510
+ // Drop the file header — the tab title already shows the
511
+ // file name and the panel header carries the change
512
+ // context.
513
+ disableFileHeader: true,
514
+ theme: dark ? 'pierre-dark' : 'pierre-light',
515
+ themeType: dark ? 'dark' : 'light',
516
+ // Inject the column-resize override into the shadow root
517
+ // via the library's `@layer unsafe` channel. Keeping the
518
+ // string constant lets the library short-circuit the
519
+ // re-render path it uses when this option actually
520
+ // changes.
521
+ unsafeCSS: SPLIT_RESIZE_CSS
522
+ }}
523
+ />
524
+ ) : null}
525
+ </div>
526
+ {showFileDiff ? (
527
+ <div
528
+ className="jp-xtralab-DiffWidget-resizer"
529
+ style={{ left: `${leftPercent}%` }}
530
+ role="separator"
531
+ aria-orientation="vertical"
532
+ aria-valuenow={Math.round(leftPercent)}
533
+ aria-valuemin={Math.round(MIN_SPLIT_RATIO * 100)}
534
+ aria-valuemax={Math.round(MAX_SPLIT_RATIO * 100)}
535
+ title="Drag to resize the diff panes (double-click to reset)"
536
+ onPointerDown={handleResizerPointerDown}
537
+ onDoubleClick={handleResizerDoubleClick}
538
+ />
539
+ ) : null}
540
+ </div>
541
+ </div>
542
+ );
543
+ }
544
+
545
+ /**
546
+ * Segmented Notebook/JSON selector. Hosts mount this into whatever toolbar
547
+ * they own (the launcher's `MainAreaWidget` toolbar, or the
548
+ * `jupyterlab-git`-provided diff toolbar) and drive its value/visibility
549
+ * from the same state they pass to {@link DiffSurface}.
550
+ */
551
+ export function NotebookViewModeControl(props: {
552
+ mode: NotebookDiffViewMode;
553
+ available: boolean;
554
+ onChange: (mode: NotebookDiffViewMode) => void;
555
+ }): React.ReactElement {
556
+ const { mode, available, onChange } = props;
557
+ if (!available) {
558
+ return <></>;
559
+ }
560
+ return (
561
+ <div
562
+ className="jp-xtralab-DiffWidget-segmented"
563
+ role="tablist"
564
+ aria-label="Notebook diff view mode"
565
+ >
566
+ <button
567
+ type="button"
568
+ role="tab"
569
+ aria-selected={mode === 'notebook'}
570
+ data-active={mode === 'notebook'}
571
+ className="jp-xtralab-DiffWidget-segmentedButton"
572
+ onClick={() => onChange('notebook')}
573
+ >
574
+ Notebook
575
+ </button>
576
+ <button
577
+ type="button"
578
+ role="tab"
579
+ aria-selected={mode === 'json'}
580
+ data-active={mode === 'json'}
581
+ className="jp-xtralab-DiffWidget-segmentedButton"
582
+ onClick={() => onChange('json')}
583
+ >
584
+ JSON
585
+ </button>
586
+ </div>
587
+ );
588
+ }