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