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