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