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.
Files changed (56) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +69 -0
  3. package/lib/fileBrowser/commands.d.ts +62 -0
  4. package/lib/fileBrowser/commands.js +418 -0
  5. package/lib/fileBrowser/contents.d.ts +28 -0
  6. package/lib/fileBrowser/contents.js +48 -0
  7. package/lib/fileBrowser/fileBrowser.d.ts +24 -0
  8. package/lib/fileBrowser/fileBrowser.js +535 -0
  9. package/lib/fileBrowser/icons.d.ts +37 -0
  10. package/lib/fileBrowser/icons.js +153 -0
  11. package/lib/fileBrowser/index.d.ts +7 -0
  12. package/lib/fileBrowser/index.js +38 -0
  13. package/lib/fileBrowser/widget.d.ts +97 -0
  14. package/lib/fileBrowser/widget.js +95 -0
  15. package/lib/git/api.d.ts +39 -0
  16. package/lib/git/api.js +164 -0
  17. package/lib/git/commands.d.ts +69 -0
  18. package/lib/git/commands.js +67 -0
  19. package/lib/git/diffWidget.d.ts +108 -0
  20. package/lib/git/diffWidget.js +624 -0
  21. package/lib/git/icons.d.ts +14 -0
  22. package/lib/git/icons.js +19 -0
  23. package/lib/git/index.d.ts +11 -0
  24. package/lib/git/index.js +74 -0
  25. package/lib/git/notebookDiff.d.ts +176 -0
  26. package/lib/git/notebookDiff.js +805 -0
  27. package/lib/git/panel.d.ts +29 -0
  28. package/lib/git/panel.js +42 -0
  29. package/lib/git/panelComponent.d.ts +26 -0
  30. package/lib/git/panelComponent.js +300 -0
  31. package/lib/git/tokens.d.ts +78 -0
  32. package/lib/git/tokens.js +6 -0
  33. package/lib/index.d.ts +10 -0
  34. package/lib/index.js +14 -0
  35. package/package.json +216 -0
  36. package/schema/plugin.json +67 -0
  37. package/src/fileBrowser/commands.ts +507 -0
  38. package/src/fileBrowser/contents.ts +66 -0
  39. package/src/fileBrowser/fileBrowser.tsx +635 -0
  40. package/src/fileBrowser/icons.ts +172 -0
  41. package/src/fileBrowser/index.ts +56 -0
  42. package/src/fileBrowser/widget.tsx +202 -0
  43. package/src/git/api.ts +207 -0
  44. package/src/git/commands.ts +143 -0
  45. package/src/git/diffWidget.tsx +903 -0
  46. package/src/git/icons.ts +20 -0
  47. package/src/git/index.ts +109 -0
  48. package/src/git/notebookDiff.tsx +1294 -0
  49. package/src/git/panel.tsx +56 -0
  50. package/src/git/panelComponent.tsx +558 -0
  51. package/src/git/tokens.ts +91 -0
  52. package/src/index.ts +18 -0
  53. package/style/base.css +175 -0
  54. package/style/git.css +770 -0
  55. package/style/index.css +2 -0
  56. package/style/index.js +2 -0
@@ -0,0 +1,624 @@
1
+ import * as React from 'react';
2
+ import { MainAreaWidget } from '@jupyterlab/apputils';
3
+ import { PathExt } from '@jupyterlab/coreutils';
4
+ import { ReactWidget, undoIcon } from '@jupyterlab/ui-components';
5
+ import { Signal } from '@lumino/signaling';
6
+ import { FileDiff } from '@pierre/diffs/react';
7
+ import { diffAcceptRejectHunk, parseDiffFromFile } from '@pierre/diffs';
8
+ import { getTreeIcon } from '../fileBrowser/icons';
9
+ import { content } from './api';
10
+ import { buildNotebookDiff, NotebookDiffView } from './notebookDiff';
11
+ /**
12
+ * The CSS class added to the diff main-area widget. The selectors that
13
+ * style the embedded `@pierre/diffs` viewer hang off this class.
14
+ */
15
+ export const DIFF_WIDGET_CSS_CLASS = 'jp-xtralab-DiffWidget';
16
+ /**
17
+ * `localStorage` key for the user's last-chosen split ratio. Persisted so
18
+ * the preference survives reloads — the library has no built-in way to
19
+ * remember per-host UI state, and reopening every diff at 50/50 is
20
+ * annoying once the user has dialed in a layout they like.
21
+ */
22
+ const SPLIT_RATIO_STORAGE_KEY = 'xtralab:diff-split-ratio';
23
+ /**
24
+ * Bounds on the left-pane fraction. Keeping a few percent on either side
25
+ * guarantees both panes remain visible — dragging to 0/100 would hide a
26
+ * column and is almost never what the user wants.
27
+ */
28
+ const MIN_SPLIT_RATIO = 0.1;
29
+ const MAX_SPLIT_RATIO = 0.9;
30
+ const DEFAULT_SPLIT_RATIO = 0.5;
31
+ /**
32
+ * CSS injected via the `@pierre/diffs` `unsafeCSS` option. The library
33
+ * places it inside the shadow root under `@layer unsafe`, which beats
34
+ * the library's own `@layer base` rule that hardcodes
35
+ * `grid-template-columns: 1fr 1fr` on the split-mode container.
36
+ *
37
+ * The override reads from `--xtralab-split-cols`, a custom property we set
38
+ * on the host element via the `style` prop. Custom properties inherit
39
+ * through the shadow DOM boundary, so adjusting it on the host instantly
40
+ * resizes the columns without re-rendering the diff.
41
+ */
42
+ const SPLIT_RESIZE_CSS = `pre[data-diff-type="split"][data-overflow="scroll"] {
43
+ grid-template-columns: var(--xtralab-split-cols, 1fr 1fr);
44
+ }`;
45
+ function readStoredSplitRatio() {
46
+ try {
47
+ const raw = window.localStorage.getItem(SPLIT_RATIO_STORAGE_KEY);
48
+ if (raw === null) {
49
+ return DEFAULT_SPLIT_RATIO;
50
+ }
51
+ const parsed = Number.parseFloat(raw);
52
+ if (Number.isFinite(parsed) &&
53
+ parsed >= MIN_SPLIT_RATIO &&
54
+ parsed <= MAX_SPLIT_RATIO) {
55
+ return parsed;
56
+ }
57
+ }
58
+ catch (_a) {
59
+ // localStorage may throw in privacy mode or sandboxed contexts. Falling
60
+ // back to the default is fine — the user can resize again on this run.
61
+ }
62
+ return DEFAULT_SPLIT_RATIO;
63
+ }
64
+ function writeStoredSplitRatio(ratio) {
65
+ try {
66
+ window.localStorage.setItem(SPLIT_RATIO_STORAGE_KEY, ratio.toString());
67
+ }
68
+ catch (_a) {
69
+ // See readStoredSplitRatio — best-effort persistence.
70
+ }
71
+ }
72
+ const NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY = 'xtralab:notebook-diff-view-mode';
73
+ function readStoredNotebookViewMode() {
74
+ try {
75
+ const raw = window.localStorage.getItem(NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY);
76
+ if (raw === 'json' || raw === 'notebook') {
77
+ return raw;
78
+ }
79
+ }
80
+ catch (_a) {
81
+ // See readStoredSplitRatio — privacy-mode / sandboxed contexts can
82
+ // throw on access. The notebook view is the better default anyway.
83
+ }
84
+ return 'notebook';
85
+ }
86
+ function writeStoredNotebookViewMode(mode) {
87
+ try {
88
+ window.localStorage.setItem(NOTEBOOK_DIFF_VIEW_MODE_STORAGE_KEY, mode);
89
+ }
90
+ catch (_a) {
91
+ // Best-effort.
92
+ }
93
+ }
94
+ /**
95
+ * Build the deterministic widget id used for a diff against a particular
96
+ * file/group pair. Reused across plugin and command code so reopening a
97
+ * diff for the same file reveals the existing tab instead of opening a
98
+ * second one.
99
+ */
100
+ export function diffWidgetId(change) {
101
+ return `xtralab:diff:${change.group}:${change.path}`;
102
+ }
103
+ /**
104
+ * Resolve the pair of git references whose `content` should appear on the
105
+ * left and right side of the diff for a given file change. Mirrors VS
106
+ * Code's source control diff: staged-side compares INDEX vs HEAD,
107
+ * unstaged-side compares WORKING vs INDEX, and untracked files have no
108
+ * "previous" version to compare against.
109
+ */
110
+ export function resolveReferences(change) {
111
+ if (change.status === 'untracked') {
112
+ return { oldRef: null, newRef: { special: 'WORKING' } };
113
+ }
114
+ if (change.group === 'staged') {
115
+ return { oldRef: { git: 'HEAD' }, newRef: { special: 'INDEX' } };
116
+ }
117
+ // Unstaged: WORKING vs INDEX. If the file isn't in the index yet (e.g.
118
+ // a freshly-`git add`-ed file that has been modified again) the server
119
+ // returns the HEAD blob for INDEX instead, which is still the right
120
+ // baseline for the diff.
121
+ return { oldRef: { special: 'INDEX' }, newRef: { special: 'WORKING' } };
122
+ }
123
+ /**
124
+ * Build a `MainAreaWidget`-hosted diff viewer for a single file change.
125
+ * Wrapping the `ReactWidget` in a `MainAreaWidget` is what gives it the
126
+ * usual document chrome — toolbar slot, focus tracker integration, and
127
+ * spinner-while-loading hooks — and matches the pattern used for other
128
+ * read-only main-area widgets in the JupyterLab ecosystem.
129
+ */
130
+ export function createGitDiffWidget(options) {
131
+ // `widget` is captured by the `onEmpty` closure we pass into the content,
132
+ // but doesn't exist yet at construction time — declare it up front so the
133
+ // closure can read it once the widget is wired.
134
+ let widget = null;
135
+ const content = new DiffContentWidget({
136
+ ...options,
137
+ onEmpty: () => {
138
+ var _a;
139
+ if (widget !== null && !widget.isDisposed) {
140
+ widget.close();
141
+ }
142
+ (_a = options.onEmpty) === null || _a === void 0 ? void 0 : _a.call(options);
143
+ }
144
+ });
145
+ widget = new MainAreaWidget({ content });
146
+ widget.id = diffWidgetId(options.change);
147
+ widget.title.label = formatTitle(options.change);
148
+ widget.title.caption = options.change.path;
149
+ widget.title.closable = true;
150
+ widget.title.icon = getTreeIcon(options.change.path);
151
+ widget.addClass(DIFF_WIDGET_CSS_CLASS);
152
+ // Wire the notebook view-mode selector into the `MainAreaWidget` toolbar
153
+ // — the proper home for document chrome controls. The toolbar starts
154
+ // hidden and becomes visible once the React content reports a parsed
155
+ // notebook diff via `setHasNotebookView`. Plain text / code diffs never
156
+ // flip that flag, so their toolbar bar stays collapsed and the
157
+ // resulting layout matches what other read-only main-area documents
158
+ // look like in JupyterLab.
159
+ widget.toolbar.addItem('notebook-view-mode', new NotebookViewModeToolbarItem(content));
160
+ widget.toolbar.hide();
161
+ const onHasNotebookViewChanged = (_sender, value) => {
162
+ if (widget === null || widget.isDisposed) {
163
+ return;
164
+ }
165
+ if (value) {
166
+ widget.toolbar.show();
167
+ }
168
+ else {
169
+ widget.toolbar.hide();
170
+ }
171
+ };
172
+ content.hasNotebookViewChanged.connect(onHasNotebookViewChanged);
173
+ widget.disposed.connect(() => {
174
+ content.hasNotebookViewChanged.disconnect(onHasNotebookViewChanged);
175
+ });
176
+ return widget;
177
+ }
178
+ /**
179
+ * The actual React-rendered region of the diff viewer. Held in its own
180
+ * `ReactWidget` so the surrounding `MainAreaWidget` can manage chrome
181
+ * without React having to know about it.
182
+ *
183
+ * Owns the persistent UI state that the surrounding `MainAreaWidget`
184
+ * toolbar needs to read and write (`notebookViewMode` and whether a
185
+ * notebook view is even available for the current file). Storing this on
186
+ * the widget — and exposing change signals — lets the toolbar widget and
187
+ * the React content stay in sync without either side knowing about the
188
+ * other.
189
+ */
190
+ export class DiffContentWidget extends ReactWidget {
191
+ constructor(options) {
192
+ super();
193
+ this._notebookViewModeChanged = new Signal(this);
194
+ this._hasNotebookView = false;
195
+ this._hasNotebookViewChanged = new Signal(this);
196
+ this._options = options;
197
+ this._notebookViewMode = readStoredNotebookViewMode();
198
+ }
199
+ /** The change this widget renders, used by callers to identify the widget. */
200
+ get change() {
201
+ return this._options.change;
202
+ }
203
+ /** The user's current rendered-vs-JSON view choice for notebook diffs. */
204
+ get notebookViewMode() {
205
+ return this._notebookViewMode;
206
+ }
207
+ /** Update the notebook view mode and persist it across sessions. */
208
+ setNotebookViewMode(mode) {
209
+ if (mode === this._notebookViewMode) {
210
+ return;
211
+ }
212
+ this._notebookViewMode = mode;
213
+ writeStoredNotebookViewMode(mode);
214
+ this._notebookViewModeChanged.emit(mode);
215
+ }
216
+ /** Emits whenever {@link notebookViewMode} changes. */
217
+ get notebookViewModeChanged() {
218
+ return this._notebookViewModeChanged;
219
+ }
220
+ /**
221
+ * Whether a rendered notebook view is currently available. Set by the
222
+ * React content once it has parsed the diff; the toolbar reads this to
223
+ * decide whether to show the view-mode selector.
224
+ */
225
+ get hasNotebookView() {
226
+ return this._hasNotebookView;
227
+ }
228
+ setHasNotebookView(value) {
229
+ if (value === this._hasNotebookView) {
230
+ return;
231
+ }
232
+ this._hasNotebookView = value;
233
+ this._hasNotebookViewChanged.emit(value);
234
+ }
235
+ /** Emits whenever {@link hasNotebookView} changes. */
236
+ get hasNotebookViewChanged() {
237
+ return this._hasNotebookViewChanged;
238
+ }
239
+ render() {
240
+ return React.createElement(DiffViewer, { ...this._options, widget: this });
241
+ }
242
+ }
243
+ /**
244
+ * Detect notebook files by extension. We only look at the suffix so the
245
+ * caller (the file-change tracker) doesn't need to plumb a content-type
246
+ * field through; mistaking another `.ipynb`-named file for a notebook is
247
+ * harmless because {@link buildNotebookDiff} validates the JSON shape and
248
+ * we fall back to the file diff if parsing fails.
249
+ */
250
+ function isNotebookPath(path) {
251
+ return path.toLowerCase().endsWith('.ipynb');
252
+ }
253
+ /**
254
+ * Decide whether the active JupyterLab theme is a dark theme. Prefers the
255
+ * `IThemeManager` token when available, and falls back to sniffing the
256
+ * `data-jp-theme-light` attribute that JupyterLab sets on `<body>` so the
257
+ * component still works in stripped-down hosts where the token isn't
258
+ * provided (lite, certain notebook configurations, …).
259
+ */
260
+ function isDarkTheme(themeManager) {
261
+ if (themeManager !== null && themeManager.theme !== null) {
262
+ return !themeManager.isLight(themeManager.theme);
263
+ }
264
+ return document.body.dataset.jpThemeLight === 'false';
265
+ }
266
+ function DiffViewer(props) {
267
+ const { repoPath, change, themeManager, contentsManager, rendermime, onChanged, onEmpty, widget } = props;
268
+ const [state, setState] = React.useState({
269
+ loading: true,
270
+ metadata: null,
271
+ notebookDiff: null,
272
+ serverPath: '',
273
+ isBinary: false,
274
+ error: null
275
+ });
276
+ // Split ratio for the diff columns: fraction of width given to the
277
+ // deletions (left) pane. Persisted across sessions so the user only
278
+ // dials in their layout once.
279
+ const [leftRatio, setLeftRatio] = React.useState(() => readStoredSplitRatio());
280
+ const wrapperRef = React.useRef(null);
281
+ // The drag handler reads the live ratio from a ref so the listeners we
282
+ // attach on pointer-down don't capture a stale value across renders.
283
+ const leftRatioRef = React.useRef(leftRatio);
284
+ React.useEffect(() => {
285
+ leftRatioRef.current = leftRatio;
286
+ }, [leftRatio]);
287
+ // Bumping `reloadKey` re-enters the fetch effect. Used after a successful
288
+ // hunk discard, so the diff reflects the new working-tree contents
289
+ // without the user having to close and reopen the tab.
290
+ const [reloadKey, setReloadKey] = React.useState(0);
291
+ // Mirror the widget's notebook view mode into local state so that
292
+ // toolbar-driven changes re-render the diff. The widget owns the
293
+ // canonical value (so the toolbar can read and write it without going
294
+ // through React); the React copy is just a cache for rendering.
295
+ const [notebookViewMode, setNotebookViewModeLocal] = React.useState(() => widget.notebookViewMode);
296
+ React.useEffect(() => {
297
+ const handler = (_sender, mode) => {
298
+ setNotebookViewModeLocal(mode);
299
+ };
300
+ widget.notebookViewModeChanged.connect(handler);
301
+ // Re-sync in case the widget changed before this effect ran.
302
+ setNotebookViewModeLocal(widget.notebookViewMode);
303
+ return () => {
304
+ widget.notebookViewModeChanged.disconnect(handler);
305
+ };
306
+ }, [widget]);
307
+ // Tell the host widget whether a rendered notebook view is currently
308
+ // available. The toolbar item that lives in the `MainAreaWidget`
309
+ // toolbar reads this flag to decide whether to show the
310
+ // Notebook/JSON selector — and the toolbar bar itself stays hidden
311
+ // when the file isn't a parseable notebook.
312
+ React.useEffect(() => {
313
+ widget.setHasNotebookView(state.notebookDiff !== null);
314
+ }, [widget, state.notebookDiff]);
315
+ // Auto-close after a discard that emptied the diff. Gated on `reloadKey > 0`
316
+ // so a file that opens already-empty (uncommon but possible) is not closed
317
+ // before the user has had a chance to look at it.
318
+ React.useEffect(() => {
319
+ if (!state.loading &&
320
+ state.error === null &&
321
+ !state.isBinary &&
322
+ state.metadata !== null &&
323
+ state.metadata.hunks.length === 0 &&
324
+ reloadKey > 0) {
325
+ onEmpty === null || onEmpty === void 0 ? void 0 : onEmpty();
326
+ }
327
+ }, [
328
+ state.loading,
329
+ state.error,
330
+ state.isBinary,
331
+ state.metadata,
332
+ reloadKey,
333
+ onEmpty
334
+ ]);
335
+ const [dark, setDark] = React.useState(() => isDarkTheme(themeManager));
336
+ // Track theme changes so the diff viewer recolors with the rest of
337
+ // JupyterLab. We listen on `IThemeManager.themeChanged` when available
338
+ // and fall back to a `<body>` attribute observer otherwise.
339
+ React.useEffect(() => {
340
+ if (themeManager !== null) {
341
+ const handler = () => setDark(isDarkTheme(themeManager));
342
+ themeManager.themeChanged.connect(handler);
343
+ return () => {
344
+ themeManager.themeChanged.disconnect(handler);
345
+ };
346
+ }
347
+ const observer = new MutationObserver(() => setDark(isDarkTheme(themeManager)));
348
+ observer.observe(document.body, {
349
+ attributes: true,
350
+ attributeFilter: ['data-jp-theme-light']
351
+ });
352
+ return () => {
353
+ observer.disconnect();
354
+ };
355
+ }, [themeManager]);
356
+ React.useEffect(() => {
357
+ let cancelled = false;
358
+ const { oldRef, newRef } = resolveReferences(change);
359
+ const serverPath = repoPath.length > 0 ? PathExt.join(repoPath, change.path) : change.path;
360
+ void (async () => {
361
+ var _a, _b, _c, _d;
362
+ try {
363
+ if (change.isBinary === true) {
364
+ if (cancelled) {
365
+ return;
366
+ }
367
+ setState({
368
+ loading: false,
369
+ metadata: null,
370
+ notebookDiff: null,
371
+ serverPath,
372
+ isBinary: true,
373
+ error: null
374
+ });
375
+ return;
376
+ }
377
+ const fetchOld = oldRef === null
378
+ ? Promise.resolve({ content: '' })
379
+ : content(repoPath, (_a = change.from) !== null && _a !== void 0 ? _a : change.path, oldRef);
380
+ const fetchNew = newRef === null
381
+ ? Promise.resolve({ content: '' })
382
+ : content(repoPath, change.path, newRef);
383
+ const [oldRes, newRes] = await Promise.all([fetchOld, fetchNew]);
384
+ if (cancelled) {
385
+ return;
386
+ }
387
+ const oldText = (_b = oldRes.content) !== null && _b !== void 0 ? _b : '';
388
+ const newText = (_c = newRes.content) !== null && _c !== void 0 ? _c : '';
389
+ // For .ipynb files we precompute *both* the cell-by-cell
390
+ // notebook diff and the raw JSON file diff up front. The user
391
+ // toggles between them via the in-widget toolbar, so keeping
392
+ // both around (instead of re-fetching on switch) makes the
393
+ // toggle feel instant. If parsing fails, `notebookDiff` stays
394
+ // null and we silently fall back to the file diff path.
395
+ let notebookDiff = null;
396
+ if (isNotebookPath(change.path)) {
397
+ notebookDiff = buildNotebookDiff({ oldText, newText });
398
+ }
399
+ const oldFile = {
400
+ name: (_d = change.from) !== null && _d !== void 0 ? _d : change.path,
401
+ contents: oldText
402
+ };
403
+ const newFile = {
404
+ name: change.path,
405
+ contents: newText
406
+ };
407
+ const metadata = parseDiffFromFile(oldFile, newFile);
408
+ setState({
409
+ loading: false,
410
+ metadata,
411
+ notebookDiff,
412
+ serverPath,
413
+ isBinary: false,
414
+ error: null
415
+ });
416
+ }
417
+ catch (err) {
418
+ if (cancelled) {
419
+ return;
420
+ }
421
+ setState({
422
+ loading: false,
423
+ metadata: null,
424
+ notebookDiff: null,
425
+ serverPath,
426
+ isBinary: false,
427
+ error: err instanceof Error ? err.message : String(err)
428
+ });
429
+ }
430
+ })();
431
+ return () => {
432
+ cancelled = true;
433
+ };
434
+ }, [repoPath, change, reloadKey]);
435
+ // Per-hunk discard buttons are only meaningful for unstaged modifications:
436
+ // staged-vs-HEAD diffs would need an index manipulation we can't do
437
+ // through the available endpoints, and untracked files have no previous
438
+ // version to revert to. The annotation list collapses to empty in those
439
+ // cases and the diff renders without action buttons.
440
+ const canDiscardHunk = change.group === 'unstaged' && change.status !== 'untracked';
441
+ const lineAnnotations = React.useMemo(() => {
442
+ if (!canDiscardHunk || state.metadata === null) {
443
+ return [];
444
+ }
445
+ return state.metadata.hunks.map((hunk, hunkIndex) => ({
446
+ side: 'additions',
447
+ // The first line of each hunk in the new file. We anchor the button
448
+ // there so it sits at the top of the hunk's body.
449
+ lineNumber: hunk.additionStart,
450
+ metadata: { hunkIndex }
451
+ }));
452
+ }, [canDiscardHunk, state.metadata]);
453
+ const handleDiscardHunk = React.useCallback(async (hunkIndex) => {
454
+ if (state.metadata === null) {
455
+ return;
456
+ }
457
+ const updated = diffAcceptRejectHunk(state.metadata, hunkIndex, 'reject');
458
+ // `additionLines` after a 'reject' contains the full new file with
459
+ // the hunk reverted back to the old content. The library splits
460
+ // file contents with `/(?<=\n)/` (a lookbehind that keeps the
461
+ // trailing `\n` on each line), so the entries already carry their
462
+ // own line endings — joining with `''` rebuilds the original text
463
+ // verbatim, while joining with `'\n'` would double every newline.
464
+ const text = updated.additionLines.join('');
465
+ try {
466
+ await contentsManager.save(state.serverPath, {
467
+ type: 'file',
468
+ format: 'text',
469
+ content: text
470
+ });
471
+ onChanged();
472
+ setReloadKey(key => key + 1);
473
+ }
474
+ catch (err) {
475
+ console.error('xtralab: failed to discard hunk', err);
476
+ }
477
+ }, [contentsManager, onChanged, state.metadata, state.serverPath]);
478
+ const renderAnnotation = React.useCallback((annotation) => {
479
+ if (annotation.metadata === undefined) {
480
+ return null;
481
+ }
482
+ const { hunkIndex } = annotation.metadata;
483
+ return (React.createElement("div", { className: "jp-xtralab-DiffWidget-hunkAnnotation" },
484
+ React.createElement("button", { type: "button", className: "jp-xtralab-DiffWidget-hunkButton", title: "Discard this hunk's changes", "aria-label": "Discard hunk", onClick: () => void handleDiscardHunk(hunkIndex) },
485
+ React.createElement(undoIcon.react, { tag: "span", className: "jp-xtralab-DiffWidget-hunkButton-icon", elementSize: "normal" }))));
486
+ }, [handleDiscardHunk]);
487
+ const handleResizerPointerDown = React.useCallback((event) => {
488
+ // Only react to primary-button drags. Right-click / middle-click on
489
+ // the handle should pass through to the browser default.
490
+ if (event.button !== 0) {
491
+ return;
492
+ }
493
+ const wrapper = wrapperRef.current;
494
+ if (wrapper === null) {
495
+ return;
496
+ }
497
+ event.preventDefault();
498
+ const handle = event.currentTarget;
499
+ handle.setPointerCapture(event.pointerId);
500
+ handle.dataset.dragging = 'true';
501
+ const rect = wrapper.getBoundingClientRect();
502
+ const startX = event.clientX;
503
+ const startRatio = leftRatioRef.current;
504
+ const onPointerMove = (ev) => {
505
+ if (rect.width <= 0) {
506
+ return;
507
+ }
508
+ const next = startRatio + (ev.clientX - startX) / rect.width;
509
+ const clamped = Math.max(MIN_SPLIT_RATIO, Math.min(MAX_SPLIT_RATIO, next));
510
+ setLeftRatio(clamped);
511
+ };
512
+ const onPointerEnd = (ev) => {
513
+ handle.releasePointerCapture(ev.pointerId);
514
+ delete handle.dataset.dragging;
515
+ handle.removeEventListener('pointermove', onPointerMove);
516
+ handle.removeEventListener('pointerup', onPointerEnd);
517
+ handle.removeEventListener('pointercancel', onPointerEnd);
518
+ // Only persist on drag-end so a quick drag isn't followed by a
519
+ // burst of writes for every intermediate position.
520
+ writeStoredSplitRatio(leftRatioRef.current);
521
+ };
522
+ handle.addEventListener('pointermove', onPointerMove);
523
+ handle.addEventListener('pointerup', onPointerEnd);
524
+ handle.addEventListener('pointercancel', onPointerEnd);
525
+ }, []);
526
+ const handleResizerDoubleClick = React.useCallback(() => {
527
+ setLeftRatio(DEFAULT_SPLIT_RATIO);
528
+ writeStoredSplitRatio(DEFAULT_SPLIT_RATIO);
529
+ }, []);
530
+ if (state.loading) {
531
+ return (React.createElement("div", { className: "jp-xtralab-DiffWidget-status" }, "Loading diff\u2026"));
532
+ }
533
+ if (state.error !== null) {
534
+ return (React.createElement("div", { className: "jp-xtralab-DiffWidget-status", "data-error": "true" }, state.error));
535
+ }
536
+ if (state.isBinary) {
537
+ return (React.createElement("div", { className: "jp-xtralab-DiffWidget-status" }, "Binary file \u2014 diff not supported."));
538
+ }
539
+ // Decide which view to render. The notebook view requires a successful
540
+ // nbformat parse; if that failed, the toolbar is hidden and we only
541
+ // show the raw JSON file diff. The toolbar itself is sticky at the top
542
+ // of the content area, sitting above the scroll viewport.
543
+ const hasNotebookView = state.notebookDiff !== null;
544
+ const showNotebookView = hasNotebookView && notebookViewMode === 'notebook';
545
+ const showFileDiff = !showNotebookView && state.metadata !== null;
546
+ if (!showNotebookView && !showFileDiff) {
547
+ return (React.createElement("div", { className: "jp-xtralab-DiffWidget-status" }, "No content to diff."));
548
+ }
549
+ // Custom property set on the diffs-container host. The library's
550
+ // shadow-root rule consumes it via `var(--xtralab-split-cols, …)` (see
551
+ // SPLIT_RESIZE_CSS) and recomputes the column tracks instantly. Only
552
+ // relevant in the file-diff path; the notebook view doesn't use it.
553
+ const leftPercent = leftRatio * 100;
554
+ const hostStyle = {
555
+ '--xtralab-split-cols': `${leftPercent}% ${100 - leftPercent}%`
556
+ };
557
+ return (React.createElement("div", { className: "jp-xtralab-DiffWidget-content" },
558
+ React.createElement("div", { ref: wrapperRef, className: "jp-xtralab-DiffWidget-body" },
559
+ React.createElement("div", { className: "jp-xtralab-DiffWidget-scroll" }, showNotebookView && state.notebookDiff !== null ? (React.createElement(NotebookDiffView, { diff: state.notebookDiff, dark: dark, rendermime: rendermime })) : showFileDiff && state.metadata !== null ? (React.createElement(FileDiff, { fileDiff: state.metadata, lineAnnotations: lineAnnotations, renderAnnotation: renderAnnotation, style: hostStyle,
560
+ // Disable the worker pool: JupyterLab's webpack federation
561
+ // pipeline doesn't ship the library's
562
+ // `@pierre/diffs/worker/worker.js` file at a URL the worker
563
+ // bootstrap can resolve from, so leaving the pool enabled
564
+ // crashes on instantiation. Running on the main thread is
565
+ // fine for the small files git diffs typically operate on.
566
+ disableWorkerPool: true, options: {
567
+ diffStyle: 'split',
568
+ // Drop the file header — the tab title already shows the
569
+ // file name and the panel header carries the change
570
+ // context.
571
+ disableFileHeader: true,
572
+ theme: dark ? 'pierre-dark' : 'pierre-light',
573
+ themeType: dark ? 'dark' : 'light',
574
+ // Inject the column-resize override into the shadow root
575
+ // via the library's `@layer unsafe` channel. Keeping the
576
+ // string constant lets the library short-circuit the
577
+ // re-render path it uses when this option actually
578
+ // changes.
579
+ unsafeCSS: SPLIT_RESIZE_CSS
580
+ } })) : null),
581
+ showFileDiff ? (React.createElement("div", { className: "jp-xtralab-DiffWidget-resizer", style: { left: `${leftPercent}%` }, role: "separator", "aria-orientation": "vertical", "aria-valuenow": Math.round(leftPercent), "aria-valuemin": Math.round(MIN_SPLIT_RATIO * 100), "aria-valuemax": Math.round(MAX_SPLIT_RATIO * 100), title: "Drag to resize the diff panes (double-click to reset)", onPointerDown: handleResizerPointerDown, onDoubleClick: handleResizerDoubleClick })) : null)));
582
+ }
583
+ /**
584
+ * Segmented Notebook/JSON selector mounted into the `MainAreaWidget`
585
+ * toolbar for `.ipynb` diffs. The selector reads from and writes to the
586
+ * `DiffContentWidget` so React state changes (via the diff content) and
587
+ * toolbar interactions stay in sync without either side knowing about
588
+ * the other. The host toolbar widget's visibility is toggled by
589
+ * {@link createGitDiffWidget} based on the same `hasNotebookView`
590
+ * signal, so this control is always meaningful when it appears.
591
+ */
592
+ class NotebookViewModeToolbarItem extends ReactWidget {
593
+ constructor(content) {
594
+ super();
595
+ this._content = content;
596
+ this.addClass('jp-xtralab-DiffWidget-viewModeToolbarItem');
597
+ }
598
+ render() {
599
+ return React.createElement(NotebookViewModeControl, { content: this._content });
600
+ }
601
+ }
602
+ function NotebookViewModeControl(props) {
603
+ const { content } = props;
604
+ const [mode, setMode] = React.useState(() => content.notebookViewMode);
605
+ React.useEffect(() => {
606
+ const handler = (_sender, next) => {
607
+ setMode(next);
608
+ };
609
+ content.notebookViewModeChanged.connect(handler);
610
+ setMode(content.notebookViewMode);
611
+ return () => {
612
+ content.notebookViewModeChanged.disconnect(handler);
613
+ };
614
+ }, [content]);
615
+ return (React.createElement("div", { className: "jp-xtralab-DiffWidget-segmented", role: "tablist", "aria-label": "Notebook diff view mode" },
616
+ React.createElement("button", { type: "button", role: "tab", "aria-selected": mode === 'notebook', "data-active": mode === 'notebook', className: "jp-xtralab-DiffWidget-segmentedButton", onClick: () => content.setNotebookViewMode('notebook') }, "Notebook"),
617
+ React.createElement("button", { type: "button", role: "tab", "aria-selected": mode === 'json', "data-active": mode === 'json', className: "jp-xtralab-DiffWidget-segmentedButton", onClick: () => content.setNotebookViewMode('json') }, "JSON")));
618
+ }
619
+ function formatTitle(change) {
620
+ var _a;
621
+ const name = (_a = change.path.split('/').pop()) !== null && _a !== void 0 ? _a : change.path;
622
+ const groupLabel = change.group === 'staged' ? 'Staged' : 'Working';
623
+ return `${name} (${groupLabel})`;
624
+ }
@@ -0,0 +1,14 @@
1
+ import { LabIcon } from '@jupyterlab/ui-components';
2
+ /**
3
+ * The branch glyph used as the git panel's sidebar tab icon. Defined as a
4
+ * `LabIcon` (rather than a CSS-only mask) so it picks up JupyterLab's icon
5
+ * recoloring and inverse-color sidebar painting automatically.
6
+ *
7
+ * Source: `git-branch` from SVG Repo (https://www.svgrepo.com/svg/377352/git-branch),
8
+ * part of the same Pixelarticons family as the Xtralab file browser's `file-tree`
9
+ * icon, so the two sidebar tab glyphs read as a matched set. The original
10
+ * `fill="#000000"` is replaced with `fill="currentColor"` and the root element
11
+ * is given the `jp-icon3` class so the icon picks up JupyterLab's theme color
12
+ * and the sidebar's inverse-color painting automatically.
13
+ */
14
+ export declare const gitIcon: LabIcon;
@@ -0,0 +1,19 @@
1
+ import { LabIcon } from '@jupyterlab/ui-components';
2
+ /**
3
+ * The branch glyph used as the git panel's sidebar tab icon. Defined as a
4
+ * `LabIcon` (rather than a CSS-only mask) so it picks up JupyterLab's icon
5
+ * recoloring and inverse-color sidebar painting automatically.
6
+ *
7
+ * Source: `git-branch` from SVG Repo (https://www.svgrepo.com/svg/377352/git-branch),
8
+ * part of the same Pixelarticons family as the Xtralab file browser's `file-tree`
9
+ * icon, so the two sidebar tab glyphs read as a matched set. The original
10
+ * `fill="#000000"` is replaced with `fill="currentColor"` and the root element
11
+ * is given the `jp-icon3` class so the icon picks up JupyterLab's theme color
12
+ * and the sidebar's inverse-color painting automatically.
13
+ */
14
+ export const gitIcon = new LabIcon({
15
+ name: 'xtralab:git',
16
+ svgstr: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="jp-icon3" fill="currentColor">
17
+ <path d="M5 2h2v12h3v3h7v-7h-3V2h8v8h-3v9h-9v3H2v-8h3V2zm15 6V4h-4v4h4zM8 19v-3H4v4h4v-1z"/>
18
+ </svg>`
19
+ });
@@ -0,0 +1,11 @@
1
+ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ /**
3
+ * The git plugin. Adds a VS Code-style "Source Control" panel to the
4
+ * JupyterLab left sidebar and opens file diffs in the main area when the
5
+ * user clicks an entry in the panel. Backed by the `jupyterlab_git` server
6
+ * extension's REST API; the bundled `@jupyterlab/git` frontend is disabled
7
+ * via `package.json`'s `jupyterlab.disabledExtensions` so the two panels do
8
+ * not coexist.
9
+ */
10
+ declare const plugin: JupyterFrontEndPlugin<void>;
11
+ export default plugin;