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.
- package/README.md +9 -4
- package/lib/git/commands.d.ts +12 -13
- package/lib/git/commands.js +7 -28
- package/lib/git/diffProvider.d.ts +8 -0
- package/lib/git/diffProvider.js +314 -0
- package/lib/git/diffSurface.d.ts +115 -0
- package/lib/git/diffSurface.js +342 -0
- package/lib/git/diffWidget.d.ts +8 -13
- package/lib/git/diffWidget.js +65 -311
- package/lib/git/imageDiff.d.ts +23 -0
- package/lib/git/imageDiff.js +184 -0
- package/lib/git/index.d.ts +10 -8
- package/lib/git/index.js +37 -52
- package/lib/index.d.ts +3 -2
- package/lib/index.js +5 -4
- package/package.json +18 -4
- package/src/git/commands.ts +12 -38
- package/src/git/diffProvider.tsx +462 -0
- package/src/git/diffSurface.tsx +588 -0
- package/src/git/diffWidget.tsx +105 -465
- package/src/git/imageDiff.tsx +368 -0
- package/src/git/index.ts +38 -68
- package/src/index.ts +5 -4
- package/style/git.css +158 -266
- package/lib/git/icons.d.ts +0 -14
- package/lib/git/icons.js +0 -19
- package/lib/git/panel.d.ts +0 -29
- package/lib/git/panel.js +0 -42
- package/lib/git/panelComponent.d.ts +0 -28
- package/lib/git/panelComponent.js +0 -320
- package/src/git/icons.ts +0 -20
- package/src/git/panel.tsx +0 -56
- package/src/git/panelComponent.tsx +0 -575
|
@@ -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
|
+
}
|