xtralab 0.8.0 → 0.10.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 (57) hide show
  1. package/README.md +30 -6
  2. package/lib/fileBrowser/commands.d.ts +7 -0
  3. package/lib/fileBrowser/commands.js +74 -1
  4. package/lib/fileBrowser/fileBrowser.js +4 -1
  5. package/lib/fileBrowser/icons.d.ts +5 -9
  6. package/lib/fileBrowser/icons.js +97 -13
  7. package/lib/fileBrowser/index.js +5 -3
  8. package/lib/fileTypeIcons/index.d.ts +6 -0
  9. package/lib/fileTypeIcons/index.js +144 -0
  10. package/lib/git/commands.d.ts +34 -33
  11. package/lib/git/commands.js +132 -10
  12. package/lib/git/diffModel.d.ts +14 -0
  13. package/lib/git/diffModel.js +75 -0
  14. package/lib/git/diffProvider.d.ts +0 -5
  15. package/lib/git/diffProvider.js +6 -376
  16. package/lib/git/diffSurface.d.ts +36 -26
  17. package/lib/git/diffSurface.js +13 -20
  18. package/lib/git/diffTheme.d.ts +9 -0
  19. package/lib/git/diffTheme.js +31 -0
  20. package/lib/git/diffWidget.d.ts +54 -91
  21. package/lib/git/diffWidget.js +184 -361
  22. package/lib/git/index.d.ts +1 -6
  23. package/lib/git/index.js +7 -23
  24. package/lib/git/notebookDiff.d.ts +6 -1
  25. package/lib/git/notebookDiff.js +4 -3
  26. package/lib/index.js +4 -0
  27. package/lib/terminalNotifications/index.d.ts +15 -0
  28. package/lib/terminalNotifications/index.js +213 -0
  29. package/lib/terminals/model.d.ts +1 -1
  30. package/lib/terminals/model.js +1 -1
  31. package/lib/terminals/widget.d.ts +1 -1
  32. package/lib/terminals/widget.js +1 -1
  33. package/package.json +3 -3
  34. package/schema/plugin.json +6 -0
  35. package/schema/terminal-notifications.json +21 -0
  36. package/src/fileBrowser/commands.ts +91 -2
  37. package/src/fileBrowser/fileBrowser.tsx +4 -1
  38. package/src/fileBrowser/icons.ts +103 -13
  39. package/src/fileBrowser/index.ts +9 -3
  40. package/src/fileTypeIcons/index.ts +174 -0
  41. package/src/git/commands.ts +177 -43
  42. package/src/git/diffModel.ts +102 -0
  43. package/src/git/diffProvider.tsx +8 -540
  44. package/src/git/diffSurface.tsx +44 -28
  45. package/src/git/diffTheme.ts +40 -0
  46. package/src/git/diffWidget.tsx +256 -448
  47. package/src/git/index.ts +14 -28
  48. package/src/git/notebookDiff.tsx +10 -3
  49. package/src/index.ts +4 -0
  50. package/src/terminalNotifications/index.ts +295 -0
  51. package/src/terminals/model.ts +1 -1
  52. package/src/terminals/widget.tsx +1 -1
  53. package/style/git.css +12 -9
  54. package/style/index.css +1 -0
  55. package/style/index.js +1 -0
  56. package/style/launcher.css +46 -18
  57. package/style/sidebar.css +14 -0
@@ -1,25 +1,22 @@
1
1
  import * as React from 'react';
2
2
 
3
- import { IThemeManager, MainAreaWidget } from '@jupyterlab/apputils';
3
+ import { IThemeManager } from '@jupyterlab/apputils';
4
4
  import { PathExt } from '@jupyterlab/coreutils';
5
+ import { Git } from '@jupyterlab/git';
5
6
  import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
6
7
  import { Contents } from '@jupyterlab/services';
7
- import {
8
- ReactWidget,
9
- ToolbarButton,
10
- launchIcon
11
- } from '@jupyterlab/ui-components';
8
+ import { ReactWidget } from '@jupyterlab/ui-components';
9
+ import { PromiseDelegate } from '@lumino/coreutils';
12
10
  import { ISignal, Signal } from '@lumino/signaling';
11
+ import { Widget } from '@lumino/widgets';
13
12
 
14
- import { getTreeIcon } from '../fileBrowser/icons';
15
- import { content } from './api';
16
- import { imageDataType } from './imageDiff';
17
13
  import {
18
14
  DIFF_WIDGET_CSS_CLASS,
19
15
  DiffStyleControl,
20
16
  DiffSurface,
21
17
  NotebookViewModeControl,
22
18
  isDarkTheme,
19
+ isPierreTheme,
23
20
  readStoredDiffStyle,
24
21
  readStoredNotebookViewMode,
25
22
  writeStoredDiffStyle,
@@ -27,228 +24,96 @@ import {
27
24
  type DiffStyle,
28
25
  type NotebookDiffViewMode
29
26
  } from './diffSurface';
30
- import { GitReference, IFileChange } from './tokens';
27
+ import { imageDataType } from './imageDiff';
31
28
 
32
29
  export { DIFF_WIDGET_CSS_CLASS };
33
30
 
34
- export const PREVIEW_DIFF_WIDGET_ID = 'xtralab:diff:preview';
35
-
36
- export function pinnedDiffWidgetId(change: IFileChange): string {
37
- return `xtralab:diff:pinned:${change.group}:${change.path}`;
38
- }
39
-
40
31
  /**
41
- * Build the deterministic widget id used for a diff against a particular
42
- * file/group pair. Reused across plugin and command code so reopening a
43
- * diff for the same file reveals the existing tab instead of opening a
44
- * second one.
32
+ * `Git.Diff.IModel` plus optional xtralab-only metadata.
45
33
  */
46
- export function diffWidgetId(change: IFileChange): string {
47
- return `xtralab:diff:${change.group}:${change.path}`;
34
+ export interface IXtralabDiffModel extends Git.Diff.IModel {
35
+ isBinary?: boolean;
36
+ canDiscard?: boolean;
37
+ oldFilename?: string;
48
38
  }
49
39
 
50
- function toServerPath(repoPath: string, filePath: string): string {
51
- return repoPath.length > 0 ? PathExt.join(repoPath, filePath) : filePath;
40
+ /**
41
+ * Application-level services the diff model does not carry.
42
+ */
43
+ export interface IXtralabDiffContext {
44
+ contentsManager: Contents.IManager;
45
+ rendermime: IRenderMimeRegistry | null;
46
+ themeManager: IThemeManager | null;
52
47
  }
53
48
 
54
49
  /**
55
- * Resolve the pair of git references whose `content` should appear on the
56
- * left and right side of the diff for a given file change. Mirrors VS
57
- * Code's source control diff: staged-side compares INDEX vs HEAD,
58
- * unstaged-side compares WORKING vs INDEX, and untracked files have no
59
- * "previous" version to compare against.
50
+ * Shared diff widget used by the launcher and by `jupyterlab-git`.
60
51
  */
61
- export function resolveReferences(change: IFileChange): {
62
- oldRef: GitReference | null;
63
- newRef: GitReference | null;
64
- } {
65
- if (change.status === 'untracked') {
66
- return { oldRef: null, newRef: { special: 'WORKING' } };
52
+ export class XtralabDiffWidget
53
+ extends ReactWidget
54
+ implements Git.Diff.IDiffWidget
55
+ {
56
+ constructor(model: Git.Diff.IModel, context: IXtralabDiffContext) {
57
+ super();
58
+ this._model = model;
59
+ this._context = context;
60
+ this._notebookViewMode = readStoredNotebookViewMode();
61
+ this._diffStyle = readStoredDiffStyle();
62
+ // Reuse jupyterlab-git's host sizing while applying xtralab styling.
63
+ this.addClass('jp-git-diff-root');
64
+ this.addClass(DIFF_WIDGET_CSS_CLASS);
67
65
  }
68
- if (change.group === 'staged') {
69
- return { oldRef: { git: 'HEAD' }, newRef: { special: 'INDEX' } };
66
+
67
+ get model(): Git.Diff.IModel {
68
+ return this._model;
70
69
  }
71
- // Unstaged: WORKING vs INDEX. If the file isn't in the index yet (e.g.
72
- // a freshly-`git add`-ed file that has been modified again) the server
73
- // returns the HEAD blob for INDEX instead, which is still the right
74
- // baseline for the diff.
75
- return { oldRef: { special: 'INDEX' }, newRef: { special: 'WORKING' } };
76
- }
77
70
 
78
- export interface IGitDiffWidgetOptions {
79
- repoPath: string;
80
- change: IFileChange;
81
- themeManager: IThemeManager | null;
82
- contentsManager: Contents.IManager;
83
71
  /**
84
- * Rendermime registry used by the notebook diff path to render outputs
85
- * and markdown cells. May be `null` in stripped-down hosts; the notebook
86
- * diff falls back to a textual representation in that case.
72
+ * Swap the rendered model when the launcher reuses its preview tab.
87
73
  */
88
- rendermime: IRenderMimeRegistry | null;
89
- /**
90
- * Called after a hunk-level operation mutates the working tree, so the
91
- * surrounding plugin can refresh the changes panel.
92
- */
93
- onChanged: () => void;
74
+ setModel(model: IXtralabDiffModel): void {
75
+ this._model = model;
76
+ this.update();
77
+ }
78
+
94
79
  /**
95
- * Called when a post-discard reload finds no hunks left in the diff.
96
- * Wired by {@link createGitDiffWidget} to close the host
97
- * `MainAreaWidget` — there is nothing left to view, so the empty pane
98
- * would just be visual noise.
80
+ * This renderer has no merge editor, so it never blocks mark-as-resolved.
99
81
  */
100
- onEmpty?: () => void;
101
- onPinned?: (widget: MainAreaWidget<DiffContentWidget>) => void;
102
- pinned?: boolean;
103
- }
104
-
105
- /**
106
- * Build a `MainAreaWidget`-hosted diff viewer for a single file change.
107
- * Wrapping the `ReactWidget` in a `MainAreaWidget` is what gives it the
108
- * usual document chrome — toolbar slot, focus tracker integration, and
109
- * spinner-while-loading hooks — and matches the pattern used for other
110
- * read-only main-area widgets in the JupyterLab ecosystem.
111
- *
112
- * This is the launcher dashboard's own diff path
113
- * (`xtralab:git:open-diff`), kept independent of `jupyterlab-git`. The
114
- * actual diff rendering is delegated to the shared {@link DiffSurface};
115
- * `jupyterlab-git`'s panel reaches the same surface through the providers
116
- * registered in `diffProvider.tsx`.
117
- */
118
- export function createGitDiffWidget(
119
- options: IGitDiffWidgetOptions
120
- ): MainAreaWidget<DiffContentWidget> {
121
- // `widget` is captured by the `onEmpty` closure we pass into the content,
122
- // but doesn't exist yet at construction time — declare it up front so the
123
- // closure can read it once the widget is wired.
124
- let widget: MainAreaWidget<DiffContentWidget> | null = null;
125
- const content = new DiffContentWidget({
126
- ...options,
127
- onEmpty: () => {
128
- if (widget !== null && !widget.isDisposed) {
129
- widget.close();
130
- }
131
- options.onEmpty?.();
132
- }
133
- });
134
- widget = new MainAreaWidget<DiffContentWidget>({ content });
135
- widget.id =
136
- options.pinned === true
137
- ? pinnedDiffWidgetId(options.change)
138
- : PREVIEW_DIFF_WIDGET_ID;
139
- widget.title.label = formatTitle(options.change);
140
- widget.title.caption = options.change.path;
141
- widget.title.closable = true;
142
- widget.title.icon = getTreeIcon(options.change.path);
143
- widget.title.className = options.pinned === true ? '' : 'jp-mod-preview';
144
- const onPinned = options.onPinned ?? (() => {});
145
- widget.addClass(DIFF_WIDGET_CSS_CLASS);
146
- // Wire the notebook view-mode selector into the `MainAreaWidget` toolbar
147
- // — the proper home for document chrome controls. The toolbar starts
148
- // hidden and becomes visible once the React content reports a parsed
149
- // notebook diff via `setHasNotebookView`. Plain text / code diffs never
150
- // flip that flag, and pin state can hide the toolbar again, so the host
151
- // chrome tracks the active mode.
152
- const pinButton = new ToolbarButton({
153
- icon: launchIcon,
154
- tooltip: 'Pin tab',
155
- onClick: () => content.pin()
156
- });
157
- if (content.pinned) {
158
- pinButton.hide();
82
+ get isFileResolved(): boolean {
83
+ return true;
159
84
  }
160
- widget.toolbar.addItem('pin', pinButton);
161
- widget.toolbar.addItem(
162
- 'notebook-view-mode',
163
- new NotebookViewModeToolbarItem(content)
164
- );
165
- widget.toolbar.addItem('diff-style', new DiffStyleToolbarItem(content));
166
- const syncToolbarVisibility = (): void => {
167
- if (widget === null || widget.isDisposed) {
168
- return;
169
- }
170
- if (!content.pinned || content.hasNotebookView || content.fileDiffActive) {
171
- widget.toolbar.show();
172
- } else {
173
- widget.toolbar.hide();
174
- }
175
- };
176
- const onToolbarStateChanged = (): void => {
177
- if (widget === null || widget.isDisposed) {
178
- return;
179
- }
180
- syncToolbarVisibility();
181
- };
182
- const onPinnedChanged = (sender: DiffContentWidget, value: boolean): void => {
183
- if (widget === null || widget.isDisposed) {
184
- return;
185
- }
186
- void sender;
187
- if (value) {
188
- pinButton.hide();
189
- // Keep toolbar state and title styling consistent for promoted tabs.
190
- onPinned(widget);
191
- if (widget.isDisposed) {
192
- return;
193
- }
194
- widget.title.className = '';
195
- }
196
- syncToolbarVisibility();
197
- };
198
- content.hasNotebookViewChanged.connect(onToolbarStateChanged);
199
- content.fileDiffActiveChanged.connect(onToolbarStateChanged);
200
- content.pinnedChanged.connect(onPinnedChanged);
201
- syncToolbarVisibility();
202
- widget.disposed.connect(() => {
203
- content.hasNotebookViewChanged.disconnect(onToolbarStateChanged);
204
- content.fileDiffActiveChanged.disconnect(onToolbarStateChanged);
205
- content.pinnedChanged.disconnect(onPinnedChanged);
206
- });
207
- return widget;
208
- }
209
-
210
- export function updateGitDiffWidget(
211
- widget: MainAreaWidget<DiffContentWidget>,
212
- change: IFileChange
213
- ): void {
214
- widget.content.setChange(change);
215
- widget.title.label = formatTitle(change);
216
- widget.title.caption = change.path;
217
- widget.title.icon = getTreeIcon(change.path);
218
- }
219
85
 
220
- /**
221
- * The actual React-rendered region of the diff viewer. Held in its own
222
- * `ReactWidget` so the surrounding `MainAreaWidget` can manage chrome
223
- * without React having to know about it.
224
- *
225
- * Owns the persistent UI state that the surrounding `MainAreaWidget`
226
- * toolbar needs to read and write (`notebookViewMode` and whether a
227
- * notebook view is even available for the current file). Storing this on
228
- * the widget — and exposing change signals — lets the toolbar widget and
229
- * the React content stay in sync without either side knowing about the
230
- * other.
231
- */
232
- export class DiffContentWidget extends ReactWidget {
233
- constructor(options: IGitDiffWidgetOptions) {
234
- super();
235
- this._options = options;
236
- this._pinned = options.pinned === true;
237
- this._notebookViewMode = readStoredNotebookViewMode();
238
- this._diffStyle = readStoredDiffStyle();
86
+ /**
87
+ * Return the content jupyterlab-git should save for mark-as-resolved.
88
+ */
89
+ async getResolvedFile(): Promise<Partial<Contents.IModel>> {
90
+ const source =
91
+ this._model.hasConflict === true && this._model.base !== undefined
92
+ ? this._model.base
93
+ : this._model.challenger;
94
+ const content = await source.content();
95
+ return {
96
+ type: 'file',
97
+ format: imageDataType(this._model.filename) !== null ? 'base64' : 'text',
98
+ content
99
+ };
239
100
  }
240
101
 
241
- /** The change this widget renders, used by callers to identify the widget. */
242
- get change(): IFileChange {
243
- return this._options.change;
102
+ /**
103
+ * Re-pull both sides of the model and re-render.
104
+ */
105
+ async refresh(): Promise<void> {
106
+ const done = new PromiseDelegate<void>();
107
+ this._pendingRefresh = done;
108
+ this._reloadNonce += 1;
109
+ this.update();
110
+ await done.promise;
244
111
  }
245
112
 
246
- /** The user's current rendered-vs-JSON view choice for notebook diffs. */
247
113
  get notebookViewMode(): NotebookDiffViewMode {
248
114
  return this._notebookViewMode;
249
115
  }
250
116
 
251
- /** Update the notebook view mode and persist it across sessions. */
252
117
  setNotebookViewMode(mode: NotebookDiffViewMode): void {
253
118
  if (mode === this._notebookViewMode) {
254
119
  return;
@@ -258,16 +123,10 @@ export class DiffContentWidget extends ReactWidget {
258
123
  this._notebookViewModeChanged.emit(mode);
259
124
  }
260
125
 
261
- /** Emits whenever {@link notebookViewMode} changes. */
262
126
  get notebookViewModeChanged(): ISignal<this, NotebookDiffViewMode> {
263
127
  return this._notebookViewModeChanged;
264
128
  }
265
129
 
266
- /**
267
- * Whether a rendered notebook view is currently available. Set by the
268
- * React content once it has parsed the diff; the toolbar reads this to
269
- * decide whether to show the view-mode selector.
270
- */
271
130
  get hasNotebookView(): boolean {
272
131
  return this._hasNotebookView;
273
132
  }
@@ -280,17 +139,14 @@ export class DiffContentWidget extends ReactWidget {
280
139
  this._hasNotebookViewChanged.emit(value);
281
140
  }
282
141
 
283
- /** Emits whenever {@link hasNotebookView} changes. */
284
142
  get hasNotebookViewChanged(): ISignal<this, boolean> {
285
143
  return this._hasNotebookViewChanged;
286
144
  }
287
145
 
288
- /** The user's current split-vs-unified layout choice for file diffs. */
289
146
  get diffStyle(): DiffStyle {
290
147
  return this._diffStyle;
291
148
  }
292
149
 
293
- /** Update the diff layout and persist it across sessions. */
294
150
  setDiffStyle(style: DiffStyle): void {
295
151
  if (style === this._diffStyle) {
296
152
  return;
@@ -300,15 +156,12 @@ export class DiffContentWidget extends ReactWidget {
300
156
  this._diffStyleChanged.emit(style);
301
157
  }
302
158
 
303
- /** Emits whenever {@link diffStyle} changes. */
304
159
  get diffStyleChanged(): ISignal<this, DiffStyle> {
305
160
  return this._diffStyleChanged;
306
161
  }
307
162
 
308
163
  /**
309
- * Whether the textual/code file diff (the only view the split-vs-unified
310
- * choice affects) is currently active. Set by the React content; the
311
- * toolbar reads it to decide whether to show the Split/Unified selector.
164
+ * Whether the Split/Unified toolbar selector currently applies.
312
165
  */
313
166
  get fileDiffActive(): boolean {
314
167
  return this._fileDiffActive;
@@ -322,37 +175,43 @@ export class DiffContentWidget extends ReactWidget {
322
175
  this._fileDiffActiveChanged.emit(value);
323
176
  }
324
177
 
325
- /** Emits whenever {@link fileDiffActive} changes. */
326
178
  get fileDiffActiveChanged(): ISignal<this, boolean> {
327
179
  return this._fileDiffActiveChanged;
328
180
  }
329
181
 
330
- get pinned(): boolean {
331
- return this._pinned;
182
+ /**
183
+ * Emitted when a post-discard reload leaves the diff with no hunks.
184
+ */
185
+ get emptied(): ISignal<this, void> {
186
+ return this._emptied;
332
187
  }
333
188
 
334
- pin(): void {
335
- if (this._pinned) {
336
- return;
337
- }
338
- this._pinned = true;
339
- this._pinnedChanged.emit(true);
189
+ notifyEmptied(): void {
190
+ this._emptied.emit();
340
191
  }
341
192
 
342
- get pinnedChanged(): ISignal<this, boolean> {
343
- return this._pinnedChanged;
193
+ settleRefresh(): void {
194
+ const pending = this._pendingRefresh;
195
+ this._pendingRefresh = null;
196
+ pending?.resolve();
344
197
  }
345
198
 
346
- setChange(change: IFileChange): void {
347
- this._options = { ...this._options, change };
348
- this.update();
199
+ get reloadNonce(): number {
200
+ return this._reloadNonce;
201
+ }
202
+
203
+ get context(): IXtralabDiffContext {
204
+ return this._context;
349
205
  }
350
206
 
351
207
  protected render(): React.ReactElement {
352
- return <DiffViewer {...this._options} widget={this} />;
208
+ return <ModelDiffView widget={this} />;
353
209
  }
354
210
 
355
- private _options: IGitDiffWidgetOptions;
211
+ private _model: IXtralabDiffModel;
212
+ private _context: IXtralabDiffContext;
213
+ private _reloadNonce = 0;
214
+ private _pendingRefresh: PromiseDelegate<void> | null = null;
356
215
  private _notebookViewMode: NotebookDiffViewMode;
357
216
  private _notebookViewModeChanged = new Signal<this, NotebookDiffViewMode>(
358
217
  this
@@ -363,95 +222,70 @@ export class DiffContentWidget extends ReactWidget {
363
222
  private _diffStyleChanged = new Signal<this, DiffStyle>(this);
364
223
  private _fileDiffActive = false;
365
224
  private _fileDiffActiveChanged = new Signal<this, boolean>(this);
366
- private _pinned: boolean;
367
- private _pinnedChanged = new Signal<this, boolean>(this);
225
+ private _emptied = new Signal<this, void>(this);
368
226
  }
369
227
 
370
- interface IDiffState {
228
+ interface IModelDiffState {
371
229
  loading: boolean;
372
- /**
373
- * Resolved old/reference and new/challenger text. The shared
374
- * {@link DiffSurface} turns these into the line/notebook diff; owning
375
- * only the raw strings here keeps the fetch path decoupled from how the
376
- * diff is rendered.
377
- */
378
230
  oldText: string;
379
231
  newText: string;
380
- /**
381
- * Server-relative path of the file on disk. Computed from the repo path
382
- * + the file's git-root-relative path. We need it to write the file back
383
- * via the contents manager when discarding a hunk.
384
- */
385
- serverPath: string;
386
- isBinary: boolean;
387
232
  error: string | null;
388
233
  }
389
234
 
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>({
235
+ function ModelDiffView(props: {
236
+ widget: XtralabDiffWidget;
237
+ }): React.ReactElement {
238
+ const { widget } = props;
239
+ const model = widget.model as IXtralabDiffModel;
240
+ const { contentsManager, rendermime, themeManager } = widget.context;
241
+ const nonce = widget.reloadNonce;
242
+
243
+ const [state, setState] = React.useState<IModelDiffState>({
404
244
  loading: true,
405
245
  oldText: '',
406
246
  newText: '',
407
- serverPath: '',
408
- isBinary: false,
409
247
  error: null
410
248
  });
411
- // Bumping `reloadKey` re-enters the fetch effect. Used after a successful
412
- // hunk discard, so the diff reflects the new working-tree contents
413
- // without the user having to close and reopen the tab.
414
- const [reloadKey, setReloadKey] = React.useState(0);
415
- // Mirror the widget's notebook view mode into local state so that
416
- // toolbar-driven changes re-render the diff. The widget owns the
417
- // canonical value (so the toolbar can read and write it without going
418
- // through React); the React copy is just a cache for rendering.
419
- const [notebookViewMode, setNotebookViewModeLocal] =
249
+
250
+ // Mirror toolbar-driven notebook view changes into React state.
251
+ const [notebookViewMode, setNotebookViewMode] =
420
252
  React.useState<NotebookDiffViewMode>(() => widget.notebookViewMode);
421
253
  React.useEffect(() => {
422
254
  const handler = (
423
- _sender: DiffContentWidget,
255
+ _sender: XtralabDiffWidget,
424
256
  mode: NotebookDiffViewMode
425
257
  ): void => {
426
- setNotebookViewModeLocal(mode);
258
+ setNotebookViewMode(mode);
427
259
  };
428
260
  widget.notebookViewModeChanged.connect(handler);
429
- // Re-sync in case the widget changed before this effect ran.
430
- setNotebookViewModeLocal(widget.notebookViewMode);
261
+ setNotebookViewMode(widget.notebookViewMode);
431
262
  return () => {
432
263
  widget.notebookViewModeChanged.disconnect(handler);
433
264
  };
434
265
  }, [widget]);
435
266
 
436
- // Mirror the widget's diff-style choice the same way (the widget owns the
437
- // canonical value so the toolbar can read and write it without going
438
- // through React).
439
- const [diffStyle, setDiffStyleLocal] = React.useState<DiffStyle>(
267
+ const handleNotebookAvailabilityChange = React.useCallback(
268
+ (available: boolean) => {
269
+ widget.setHasNotebookView(available);
270
+ },
271
+ [widget]
272
+ );
273
+
274
+ // Mirror toolbar-driven diff-style changes into React state.
275
+ const [diffStyle, setDiffStyle] = React.useState<DiffStyle>(
440
276
  () => widget.diffStyle
441
277
  );
442
278
  React.useEffect(() => {
443
- const handler = (_sender: DiffContentWidget, style: DiffStyle): void => {
444
- setDiffStyleLocal(style);
279
+ const handler = (_sender: XtralabDiffWidget, style: DiffStyle): void => {
280
+ setDiffStyle(style);
445
281
  };
446
282
  widget.diffStyleChanged.connect(handler);
447
- setDiffStyleLocal(widget.diffStyle);
283
+ setDiffStyle(widget.diffStyle);
448
284
  return () => {
449
285
  widget.diffStyleChanged.disconnect(handler);
450
286
  };
451
287
  }, [widget]);
452
288
 
453
- // Tell the host widget whether the textual/code file diff is the active
454
- // view, so the toolbar's Split/Unified selector only shows when it applies.
455
289
  const handleFileDiffActiveChange = React.useCallback(
456
290
  (active: boolean) => {
457
291
  widget.setFileDiffActive(active);
@@ -459,68 +293,24 @@ function DiffViewer(
459
293
  [widget]
460
294
  );
461
295
 
462
- // Tell the host widget whether a rendered notebook view is currently
463
- // available. The toolbar item that lives in the `MainAreaWidget`
464
- // toolbar reads this flag to decide whether to show the
465
- // Notebook/JSON selector — and the toolbar bar itself stays hidden
466
- // when the file isn't a parseable notebook.
467
- const handleNotebookAvailabilityChange = React.useCallback(
468
- (available: boolean) => {
469
- widget.setHasNotebookView(available);
470
- },
471
- [widget]
472
- );
473
-
474
- // The shared surface reports its hunk count after every (re)computation;
475
- // we keep the latest so the auto-close-on-empty effect can fire after a
476
- // discard empties the diff.
477
- const [hunkCount, setHunkCount] = React.useState<number | null>(null);
478
- const handleMetadataChange = React.useCallback(
479
- (info: { hunkCount: number | null }) => {
480
- setHunkCount(info.hunkCount);
481
- },
482
- []
483
- );
484
-
485
- // Auto-close after a discard that emptied the diff. Gated on `reloadKey > 0`
486
- // so a file that opens already-empty (uncommon but possible) is not closed
487
- // before the user has had a chance to look at it.
488
- React.useEffect(() => {
489
- if (
490
- !state.loading &&
491
- state.error === null &&
492
- !state.isBinary &&
493
- hunkCount === 0 &&
494
- reloadKey > 0
495
- ) {
496
- onEmpty?.();
497
- }
498
- }, [
499
- state.loading,
500
- state.error,
501
- state.isBinary,
502
- hunkCount,
503
- reloadKey,
504
- onEmpty
505
- ]);
506
296
  const [dark, setDark] = React.useState<boolean>(() =>
507
297
  isDarkTheme(themeManager)
508
298
  );
509
-
510
- // Track theme changes so the diff viewer recolors with the rest of
511
- // JupyterLab. We listen on `IThemeManager.themeChanged` when available
512
- // and fall back to a `<body>` attribute observer otherwise.
299
+ const [pierre, setPierre] = React.useState<boolean>(() =>
300
+ isPierreTheme(themeManager)
301
+ );
513
302
  React.useEffect(() => {
303
+ const sync = (): void => {
304
+ setDark(isDarkTheme(themeManager));
305
+ setPierre(isPierreTheme(themeManager));
306
+ };
514
307
  if (themeManager !== null) {
515
- const handler = (): void => setDark(isDarkTheme(themeManager));
516
- themeManager.themeChanged.connect(handler);
308
+ themeManager.themeChanged.connect(sync);
517
309
  return () => {
518
- themeManager.themeChanged.disconnect(handler);
310
+ themeManager.themeChanged.disconnect(sync);
519
311
  };
520
312
  }
521
- const observer = new MutationObserver(() =>
522
- setDark(isDarkTheme(themeManager))
523
- );
313
+ const observer = new MutationObserver(sync);
524
314
  observer.observe(document.body, {
525
315
  attributes: true,
526
316
  attributeFilter: ['data-jp-theme-light']
@@ -530,56 +320,48 @@ function DiffViewer(
530
320
  };
531
321
  }, [themeManager]);
532
322
 
323
+ // Image diffs use the server's base64 payloads; other binaries show a placeholder.
324
+ const isImage = React.useMemo(
325
+ () => imageDataType(model.filename) !== null,
326
+ [model.filename]
327
+ );
328
+ const isBinary = model.isBinary === true && !isImage;
329
+
330
+ // Used by the launcher to auto-close an emptied diff after discard.
331
+ const [hunkCount, setHunkCount] = React.useState<number | null>(null);
332
+ const handleMetadataChange = React.useCallback(
333
+ (info: { hunkCount: number | null }) => {
334
+ setHunkCount(info.hunkCount);
335
+ },
336
+ []
337
+ );
338
+
339
+ // Fetch both sides whenever the model or reload nonce changes.
533
340
  React.useEffect(() => {
534
341
  let cancelled = false;
535
- const serverPath = toServerPath(repoPath, change.path);
536
- setState({
537
- loading: true,
538
- oldText: '',
539
- newText: '',
540
- serverPath,
541
- isBinary: false,
542
- error: null
543
- });
544
- const { oldRef, newRef } = resolveReferences(change);
545
- // Raster images are still binary, but the git server base64-encodes
546
- // binary content, so we fetch them like text and let the surface render
547
- // an image diff. Other binary files have no useful textual diff.
548
- const isImage = imageDataType(change.path) !== null;
342
+ // Non-image binaries do not need content fetches.
343
+ if (isBinary) {
344
+ setState({ loading: false, oldText: '', newText: '', error: null });
345
+ // An in-flight `refresh()` still has to resolve.
346
+ widget.settleRefresh();
347
+ return () => {
348
+ cancelled = true;
349
+ };
350
+ }
351
+ setState({ loading: true, oldText: '', newText: '', error: null });
549
352
  void (async () => {
550
353
  try {
551
- if (change.isBinary === true && !isImage) {
552
- if (cancelled) {
553
- return;
554
- }
555
- setState({
556
- loading: false,
557
- oldText: '',
558
- newText: '',
559
- serverPath,
560
- isBinary: true,
561
- error: null
562
- });
563
- return;
564
- }
565
- const fetchOld =
566
- oldRef === null
567
- ? Promise.resolve({ content: '' })
568
- : content(repoPath, change.from ?? change.path, oldRef);
569
- const fetchNew =
570
- newRef === null
571
- ? Promise.resolve({ content: '' })
572
- : content(repoPath, change.path, newRef);
573
- const [oldRes, newRes] = await Promise.all([fetchOld, fetchNew]);
354
+ const [oldText, newText] = await Promise.all([
355
+ model.reference.content(),
356
+ model.challenger.content()
357
+ ]);
574
358
  if (cancelled) {
575
359
  return;
576
360
  }
577
361
  setState({
578
362
  loading: false,
579
- oldText: oldRes.content ?? '',
580
- newText: newRes.content ?? '',
581
- serverPath,
582
- isBinary: false,
363
+ oldText: oldText ?? '',
364
+ newText: newText ?? '',
583
365
  error: null
584
366
  });
585
367
  } catch (err) {
@@ -590,52 +372,73 @@ function DiffViewer(
590
372
  loading: false,
591
373
  oldText: '',
592
374
  newText: '',
593
- serverPath,
594
- isBinary: false,
595
375
  error: err instanceof Error ? err.message : String(err)
596
376
  });
377
+ } finally {
378
+ if (!cancelled) {
379
+ // Let `refresh()` settle once content has loaded.
380
+ widget.settleRefresh();
381
+ }
597
382
  }
598
383
  })();
599
384
  return () => {
600
385
  cancelled = true;
601
386
  };
602
- }, [repoPath, change, reloadKey]);
387
+ }, [widget, model, nonce, isBinary]);
603
388
 
604
- // Per-hunk discard buttons are only meaningful for unstaged modifications:
605
- // staged-vs-HEAD diffs would need an index manipulation we can't do
606
- // through the available endpoints, and untracked files have no previous
607
- // version to revert to.
389
+ // Only close after a reload, not for a file that opens already empty.
390
+ React.useEffect(() => {
391
+ if (
392
+ !state.loading &&
393
+ state.error === null &&
394
+ !isBinary &&
395
+ hunkCount === 0 &&
396
+ nonce > 0
397
+ ) {
398
+ widget.notifyEmptied();
399
+ }
400
+ }, [state.loading, state.error, isBinary, hunkCount, nonce, widget]);
401
+
402
+ // The launcher sets `canDiscard`; jupyterlab-git models fall back to source.
608
403
  const canDiscardHunk =
609
- change.group === 'unstaged' && change.status !== 'untracked';
404
+ model.canDiscard ??
405
+ (model.challenger.source === Git.Diff.SpecialRef.WORKING &&
406
+ model.hasConflict !== true);
407
+
408
+ const serverPath = React.useMemo(
409
+ () => PathExt.join(model.repositoryPath ?? '', model.filename),
410
+ [model.repositoryPath, model.filename]
411
+ );
610
412
 
611
413
  const hunkDiscard = React.useMemo(
612
414
  () => ({
613
415
  enabled: canDiscardHunk,
614
416
  save: async (text: string) => {
615
- await contentsManager.save(state.serverPath, {
417
+ await contentsManager.save(serverPath, {
616
418
  type: 'file',
617
419
  format: 'text',
618
420
  content: text
619
421
  });
620
422
  },
621
423
  onAfterSave: () => {
622
- onChanged();
623
- setReloadKey(key => key + 1);
424
+ // Re-pull so the diff reflects the reverted hunk.
425
+ void widget.refresh();
624
426
  }
625
427
  }),
626
- [canDiscardHunk, contentsManager, onChanged, state.serverPath]
428
+ [canDiscardHunk, contentsManager, serverPath, widget]
627
429
  );
628
430
 
629
431
  return (
630
432
  <DiffSurface
631
433
  loading={state.loading}
632
434
  error={state.error}
633
- isBinary={state.isBinary}
435
+ isBinary={isBinary}
634
436
  oldText={state.oldText}
635
437
  newText={state.newText}
636
- newName={change.path}
637
- oldName={change.from ?? change.path}
438
+ newName={model.filename}
439
+ oldName={model.oldFilename ?? model.filename}
638
440
  dark={dark}
441
+ pierreTheme={pierre}
639
442
  rendermime={rendermime}
640
443
  notebookViewMode={notebookViewMode}
641
444
  diffStyle={diffStyle}
@@ -648,124 +451,129 @@ function DiffViewer(
648
451
  }
649
452
 
650
453
  /**
651
- * Segmented Notebook/JSON selector mounted into the `MainAreaWidget`
652
- * toolbar for `.ipynb` diffs (launcher path). The selector reads from and
653
- * writes to the {@link DiffContentWidget} so React state changes (via the
654
- * diff content) and toolbar interactions stay in sync without either side
655
- * knowing about the other. The host toolbar widget's visibility is toggled
656
- * by {@link createGitDiffWidget} based on the same `hasNotebookView`
657
- * signal, so this control is always meaningful when it appears.
454
+ * Notebook/JSON toggle mounted into a host-owned toolbar.
658
455
  */
659
456
  class NotebookViewModeToolbarItem extends ReactWidget {
660
- constructor(content: DiffContentWidget) {
457
+ constructor(widget: XtralabDiffWidget) {
661
458
  super();
662
- this._content = content;
459
+ this._widget = widget;
663
460
  this.addClass('jp-xtralab-DiffWidget-viewModeToolbarItem');
664
461
  }
665
462
 
666
463
  protected render(): React.ReactElement {
667
- return <NotebookViewModeToolbarControl content={this._content} />;
464
+ return <NotebookViewModeToolbarControl widget={this._widget} />;
668
465
  }
669
466
 
670
- private _content: DiffContentWidget;
467
+ private _widget: XtralabDiffWidget;
671
468
  }
672
469
 
673
470
  function NotebookViewModeToolbarControl(props: {
674
- content: DiffContentWidget;
471
+ widget: XtralabDiffWidget;
675
472
  }): React.ReactElement {
676
- const { content } = props;
473
+ const { widget } = props;
677
474
  const [mode, setMode] = React.useState<NotebookDiffViewMode>(
678
- () => content.notebookViewMode
475
+ () => widget.notebookViewMode
679
476
  );
680
477
  const [available, setAvailable] = React.useState<boolean>(
681
- () => content.hasNotebookView
478
+ () => widget.hasNotebookView
682
479
  );
683
480
  React.useEffect(() => {
684
481
  const onMode = (
685
- _sender: DiffContentWidget,
482
+ _sender: XtralabDiffWidget,
686
483
  next: NotebookDiffViewMode
687
484
  ): void => {
688
485
  setMode(next);
689
486
  };
690
- const onAvailable = (_sender: DiffContentWidget, next: boolean): void => {
487
+ const onAvailable = (_sender: XtralabDiffWidget, next: boolean): void => {
691
488
  setAvailable(next);
692
489
  };
693
- content.notebookViewModeChanged.connect(onMode);
694
- content.hasNotebookViewChanged.connect(onAvailable);
695
- setMode(content.notebookViewMode);
696
- setAvailable(content.hasNotebookView);
490
+ widget.notebookViewModeChanged.connect(onMode);
491
+ widget.hasNotebookViewChanged.connect(onAvailable);
492
+ setMode(widget.notebookViewMode);
493
+ setAvailable(widget.hasNotebookView);
697
494
  return () => {
698
- content.notebookViewModeChanged.disconnect(onMode);
699
- content.hasNotebookViewChanged.disconnect(onAvailable);
495
+ widget.notebookViewModeChanged.disconnect(onMode);
496
+ widget.hasNotebookViewChanged.disconnect(onAvailable);
700
497
  };
701
- }, [content]);
498
+ }, [widget]);
702
499
 
703
500
  return (
704
501
  <NotebookViewModeControl
705
502
  mode={mode}
706
503
  available={available}
707
- onChange={next => content.setNotebookViewMode(next)}
504
+ onChange={next => widget.setNotebookViewMode(next)}
708
505
  />
709
506
  );
710
507
  }
711
508
 
712
509
  /**
713
- * Split/Unified selector mounted into the `MainAreaWidget` toolbar. Mirrors
714
- * {@link NotebookViewModeToolbarItem}: it reads from and writes to the
715
- * {@link DiffContentWidget} so toolbar interactions and React state stay in
716
- * sync, and only appears while the textual/code file diff is the active view
717
- * (driven by the same `fileDiffActive` signal that gates toolbar visibility).
510
+ * Split/Unified toggle mounted into a host-owned toolbar.
718
511
  */
719
512
  class DiffStyleToolbarItem extends ReactWidget {
720
- constructor(content: DiffContentWidget) {
513
+ constructor(widget: XtralabDiffWidget) {
721
514
  super();
722
- this._content = content;
515
+ this._widget = widget;
723
516
  this.addClass('jp-xtralab-DiffWidget-diffStyleToolbarItem');
724
517
  }
725
518
 
726
519
  protected render(): React.ReactElement {
727
- return <DiffStyleToolbarControl content={this._content} />;
520
+ return <DiffStyleToolbarControl widget={this._widget} />;
728
521
  }
729
522
 
730
- private _content: DiffContentWidget;
523
+ private _widget: XtralabDiffWidget;
731
524
  }
732
525
 
733
526
  function DiffStyleToolbarControl(props: {
734
- content: DiffContentWidget;
527
+ widget: XtralabDiffWidget;
735
528
  }): React.ReactElement {
736
- const { content } = props;
737
- const [style, setStyle] = React.useState<DiffStyle>(() => content.diffStyle);
529
+ const { widget } = props;
530
+ const [style, setStyle] = React.useState<DiffStyle>(() => widget.diffStyle);
738
531
  const [available, setAvailable] = React.useState<boolean>(
739
- () => content.fileDiffActive
532
+ () => widget.fileDiffActive
740
533
  );
741
534
  React.useEffect(() => {
742
- const onStyle = (_sender: DiffContentWidget, next: DiffStyle): void => {
535
+ const onStyle = (_sender: XtralabDiffWidget, next: DiffStyle): void => {
743
536
  setStyle(next);
744
537
  };
745
- const onAvailable = (_sender: DiffContentWidget, next: boolean): void => {
538
+ const onAvailable = (_sender: XtralabDiffWidget, next: boolean): void => {
746
539
  setAvailable(next);
747
540
  };
748
- content.diffStyleChanged.connect(onStyle);
749
- content.fileDiffActiveChanged.connect(onAvailable);
750
- setStyle(content.diffStyle);
751
- setAvailable(content.fileDiffActive);
541
+ widget.diffStyleChanged.connect(onStyle);
542
+ widget.fileDiffActiveChanged.connect(onAvailable);
543
+ setStyle(widget.diffStyle);
544
+ setAvailable(widget.fileDiffActive);
752
545
  return () => {
753
- content.diffStyleChanged.disconnect(onStyle);
754
- content.fileDiffActiveChanged.disconnect(onAvailable);
546
+ widget.diffStyleChanged.disconnect(onStyle);
547
+ widget.fileDiffActiveChanged.disconnect(onAvailable);
755
548
  };
756
- }, [content]);
549
+ }, [widget]);
757
550
 
758
551
  return (
759
552
  <DiffStyleControl
760
553
  diffStyle={style}
761
554
  available={available}
762
- onChange={next => content.setDiffStyle(next)}
555
+ onChange={next => widget.setDiffStyle(next)}
763
556
  />
764
557
  );
765
558
  }
766
559
 
767
- function formatTitle(change: IFileChange): string {
768
- const name = change.path.split('/').pop() ?? change.path;
769
- const groupLabel = change.group === 'staged' ? 'Staged' : 'Working';
770
- return `${name} (${groupLabel})`;
560
+ /**
561
+ * Structural toolbar type shared across host package boundaries.
562
+ */
563
+ interface IDiffToolbar {
564
+ addItem(name: string, widget: Widget): boolean;
565
+ }
566
+
567
+ /**
568
+ * Mount the shared diff toolbar controls into a host-owned toolbar.
569
+ */
570
+ export function addDiffToolbarItems(
571
+ toolbar: IDiffToolbar,
572
+ widget: XtralabDiffWidget
573
+ ): void {
574
+ toolbar.addItem(
575
+ 'xtralab-notebook-view-mode',
576
+ new NotebookViewModeToolbarItem(widget)
577
+ );
578
+ toolbar.addItem('xtralab-diff-style', new DiffStyleToolbarItem(widget));
771
579
  }