xtralab 0.3.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 +11 -4
- package/lib/editorBreadcrumbs/index.d.ts +11 -0
- package/lib/editorBreadcrumbs/index.js +41 -0
- package/lib/editorBreadcrumbs/widget.d.ts +34 -0
- package/lib/editorBreadcrumbs/widget.js +131 -0
- package/lib/editorIndent/index.d.ts +17 -0
- package/lib/editorIndent/index.js +142 -0
- package/lib/fileBrowser/commands.d.ts +3 -0
- package/lib/fileBrowser/commands.js +40 -1
- package/lib/fileBrowser/fileBrowser.js +393 -10
- package/lib/fileBrowser/gitStatus.d.ts +13 -0
- package/lib/fileBrowser/gitStatus.js +60 -0
- package/lib/fileBrowser/widget.d.ts +48 -0
- package/lib/fileBrowser/widget.js +24 -0
- 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 +9 -4
- package/lib/launcher/dashboard.js +27 -15
- package/package.json +24 -4
- package/src/editorBreadcrumbs/index.ts +70 -0
- package/src/editorBreadcrumbs/widget.ts +158 -0
- package/src/editorIndent/index.ts +177 -0
- package/src/fileBrowser/commands.ts +43 -1
- package/src/fileBrowser/fileBrowser.tsx +427 -11
- package/src/fileBrowser/gitStatus.ts +66 -0
- package/src/fileBrowser/widget.tsx +77 -0
- 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 +9 -4
- package/src/launcher/dashboard.tsx +28 -15
- package/style/editorBreadcrumbs.css +86 -0
- package/style/git.css +158 -266
- package/style/index.css +2 -0
- package/style/index.js +1 -0
- package/style/launcher.css +0 -5
- package/style/tabs.css +66 -0
- 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 -303
- package/src/git/icons.ts +0 -20
- package/src/git/panel.tsx +0 -56
- package/src/git/panelComponent.tsx +0 -561
package/src/git/diffWidget.tsx
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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.
|
|
539
|
-
|
|
540
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
597
|
-
|
|
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
|
-
|
|
612
|
-
|
|
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
|
-
|
|
657
|
-
|
|
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
|
-
|
|
669
|
-
|
|
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.
|
|
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
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
773
|
-
|
|
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
|
-
<
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
|
916
|
-
*
|
|
917
|
-
* toolbar interactions stay in sync without either side
|
|
918
|
-
* the other. The host toolbar widget's visibility is toggled
|
|
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 <
|
|
585
|
+
return <NotebookViewModeToolbarControl content={this._content} />;
|
|
931
586
|
}
|
|
932
587
|
|
|
933
588
|
private _content: DiffContentWidget;
|
|
934
589
|
}
|
|
935
590
|
|
|
936
|
-
function
|
|
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
|
|
602
|
+
const onMode = (
|
|
945
603
|
_sender: DiffContentWidget,
|
|
946
604
|
next: NotebookDiffViewMode
|
|
947
605
|
): void => {
|
|
948
606
|
setMode(next);
|
|
949
607
|
};
|
|
950
|
-
|
|
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(
|
|
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
|
-
<
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
|