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,186 +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, DiffSurface, NotebookViewModeControl, isDarkTheme, readStoredNotebookViewMode, 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
- /**
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`.
59
- */
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
- const syncToolbarVisibility = () => {
105
- if (widget === null || widget.isDisposed) {
106
- return;
107
- }
108
- if (!content.pinned || content.hasNotebookView) {
109
- widget.toolbar.show();
110
- }
111
- else {
112
- widget.toolbar.hide();
113
- }
114
- };
115
- const onHasNotebookViewChanged = () => {
116
- if (widget === null || widget.isDisposed) {
117
- return;
118
- }
119
- syncToolbarVisibility();
120
- };
121
- const onPinnedChanged = (sender, value) => {
122
- if (widget === null || widget.isDisposed) {
123
- return;
124
- }
125
- void sender;
126
- if (value) {
127
- pinButton.hide();
128
- // Keep toolbar state and title styling consistent for promoted tabs.
129
- onPinned(widget);
130
- if (widget.isDisposed) {
131
- return;
132
- }
133
- widget.title.className = '';
134
- }
135
- syncToolbarVisibility();
136
- };
137
- content.hasNotebookViewChanged.connect(onHasNotebookViewChanged);
138
- content.pinnedChanged.connect(onPinnedChanged);
139
- syncToolbarVisibility();
140
- widget.disposed.connect(() => {
141
- content.hasNotebookViewChanged.disconnect(onHasNotebookViewChanged);
142
- content.pinnedChanged.disconnect(onPinnedChanged);
143
- });
144
- return widget;
145
- }
146
- export function updateGitDiffWidget(widget, change) {
147
- widget.content.setChange(change);
148
- widget.title.label = formatTitle(change);
149
- widget.title.caption = change.path;
150
- widget.title.icon = getTreeIcon(change.path);
151
- }
152
10
  /**
153
- * The actual React-rendered region of the diff viewer. Held in its own
154
- * `ReactWidget` so the surrounding `MainAreaWidget` can manage chrome
155
- * without React having to know about it.
156
- *
157
- * Owns the persistent UI state that the surrounding `MainAreaWidget`
158
- * toolbar needs to read and write (`notebookViewMode` and whether a
159
- * notebook view is even available for the current file). Storing this on
160
- * the widget — and exposing change signals — lets the toolbar widget and
161
- * the React content stay in sync without either side knowing about the
162
- * other.
11
+ * Shared diff widget used by the launcher and by `jupyterlab-git`.
163
12
  */
164
- export class DiffContentWidget extends ReactWidget {
165
- constructor(options) {
13
+ export class XtralabDiffWidget extends ReactWidget {
14
+ constructor(model, context) {
166
15
  super();
16
+ this._reloadNonce = 0;
17
+ this._pendingRefresh = null;
167
18
  this._notebookViewModeChanged = new Signal(this);
168
19
  this._hasNotebookView = false;
169
20
  this._hasNotebookViewChanged = new Signal(this);
170
- this._pinnedChanged = new Signal(this);
171
- this._options = options;
172
- this._pinned = options.pinned === true;
21
+ this._diffStyleChanged = new Signal(this);
22
+ this._fileDiffActive = false;
23
+ this._fileDiffActiveChanged = new Signal(this);
24
+ this._emptied = new Signal(this);
25
+ this._model = model;
26
+ this._context = context;
173
27
  this._notebookViewMode = readStoredNotebookViewMode();
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;
48
+ }
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
+ };
174
62
  }
175
- /** The change this widget renders, used by callers to identify the widget. */
176
- get change() {
177
- return this._options.change;
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;
178
72
  }
179
- /** The user's current rendered-vs-JSON view choice for notebook diffs. */
180
73
  get notebookViewMode() {
181
74
  return this._notebookViewMode;
182
75
  }
183
- /** Update the notebook view mode and persist it across sessions. */
184
76
  setNotebookViewMode(mode) {
185
77
  if (mode === this._notebookViewMode) {
186
78
  return;
@@ -189,15 +81,9 @@ export class DiffContentWidget extends ReactWidget {
189
81
  writeStoredNotebookViewMode(mode);
190
82
  this._notebookViewModeChanged.emit(mode);
191
83
  }
192
- /** Emits whenever {@link notebookViewMode} changes. */
193
84
  get notebookViewModeChanged() {
194
85
  return this._notebookViewModeChanged;
195
86
  }
196
- /**
197
- * Whether a rendered notebook view is currently available. Set by the
198
- * React content once it has parsed the diff; the toolbar reads this to
199
- * decide whether to show the view-mode selector.
200
- */
201
87
  get hasNotebookView() {
202
88
  return this._hasNotebookView;
203
89
  }
@@ -208,109 +94,119 @@ export class DiffContentWidget extends ReactWidget {
208
94
  this._hasNotebookView = value;
209
95
  this._hasNotebookViewChanged.emit(value);
210
96
  }
211
- /** Emits whenever {@link hasNotebookView} changes. */
212
97
  get hasNotebookViewChanged() {
213
98
  return this._hasNotebookViewChanged;
214
99
  }
215
- get pinned() {
216
- return this._pinned;
100
+ get diffStyle() {
101
+ return this._diffStyle;
217
102
  }
218
- pin() {
219
- if (this._pinned) {
103
+ setDiffStyle(style) {
104
+ if (style === this._diffStyle) {
220
105
  return;
221
106
  }
222
- this._pinned = true;
223
- this._pinnedChanged.emit(true);
107
+ this._diffStyle = style;
108
+ writeStoredDiffStyle(style);
109
+ this._diffStyleChanged.emit(style);
224
110
  }
225
- get pinnedChanged() {
226
- return this._pinnedChanged;
111
+ get diffStyleChanged() {
112
+ return this._diffStyleChanged;
227
113
  }
228
- setChange(change) {
229
- this._options = { ...this._options, change };
230
- this.update();
114
+ /**
115
+ * Whether the Split/Unified toolbar selector currently applies.
116
+ */
117
+ get fileDiffActive() {
118
+ return this._fileDiffActive;
119
+ }
120
+ setFileDiffActive(value) {
121
+ if (value === this._fileDiffActive) {
122
+ return;
123
+ }
124
+ this._fileDiffActive = value;
125
+ this._fileDiffActiveChanged.emit(value);
126
+ }
127
+ get fileDiffActiveChanged() {
128
+ return this._fileDiffActiveChanged;
129
+ }
130
+ /**
131
+ * Emitted when a post-discard reload leaves the diff with no hunks.
132
+ */
133
+ get emptied() {
134
+ return this._emptied;
135
+ }
136
+ notifyEmptied() {
137
+ this._emptied.emit();
138
+ }
139
+ settleRefresh() {
140
+ const pending = this._pendingRefresh;
141
+ this._pendingRefresh = null;
142
+ pending === null || pending === void 0 ? void 0 : pending.resolve();
143
+ }
144
+ get reloadNonce() {
145
+ return this._reloadNonce;
146
+ }
147
+ get context() {
148
+ return this._context;
231
149
  }
232
150
  render() {
233
- return React.createElement(DiffViewer, { ...this._options, widget: this });
151
+ return React.createElement(ModelDiffView, { widget: this });
234
152
  }
235
153
  }
236
- function DiffViewer(props) {
237
- var _a;
238
- 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;
239
160
  const [state, setState] = React.useState({
240
161
  loading: true,
241
162
  oldText: '',
242
163
  newText: '',
243
- serverPath: '',
244
- isBinary: false,
245
164
  error: null
246
165
  });
247
- // Bumping `reloadKey` re-enters the fetch effect. Used after a successful
248
- // hunk discard, so the diff reflects the new working-tree contents
249
- // without the user having to close and reopen the tab.
250
- const [reloadKey, setReloadKey] = React.useState(0);
251
- // Mirror the widget's notebook view mode into local state so that
252
- // toolbar-driven changes re-render the diff. The widget owns the
253
- // canonical value (so the toolbar can read and write it without going
254
- // through React); the React copy is just a cache for rendering.
255
- 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);
256
168
  React.useEffect(() => {
257
169
  const handler = (_sender, mode) => {
258
- setNotebookViewModeLocal(mode);
170
+ setNotebookViewMode(mode);
259
171
  };
260
172
  widget.notebookViewModeChanged.connect(handler);
261
- // Re-sync in case the widget changed before this effect ran.
262
- setNotebookViewModeLocal(widget.notebookViewMode);
173
+ setNotebookViewMode(widget.notebookViewMode);
263
174
  return () => {
264
175
  widget.notebookViewModeChanged.disconnect(handler);
265
176
  };
266
177
  }, [widget]);
267
- // Tell the host widget whether a rendered notebook view is currently
268
- // available. The toolbar item that lives in the `MainAreaWidget`
269
- // toolbar reads this flag to decide whether to show the
270
- // Notebook/JSON selector — and the toolbar bar itself stays hidden
271
- // when the file isn't a parseable notebook.
272
178
  const handleNotebookAvailabilityChange = React.useCallback((available) => {
273
179
  widget.setHasNotebookView(available);
274
180
  }, [widget]);
275
- // The shared surface reports its hunk count after every (re)computation;
276
- // we keep the latest so the auto-close-on-empty effect can fire after a
277
- // discard empties the diff.
278
- const [hunkCount, setHunkCount] = React.useState(null);
279
- const handleMetadataChange = React.useCallback((info) => {
280
- setHunkCount(info.hunkCount);
281
- }, []);
282
- // Auto-close after a discard that emptied the diff. Gated on `reloadKey > 0`
283
- // so a file that opens already-empty (uncommon but possible) is not closed
284
- // before the user has had a chance to look at it.
181
+ // Mirror toolbar-driven diff-style changes into React state.
182
+ const [diffStyle, setDiffStyle] = React.useState(() => widget.diffStyle);
285
183
  React.useEffect(() => {
286
- if (!state.loading &&
287
- state.error === null &&
288
- !state.isBinary &&
289
- hunkCount === 0 &&
290
- reloadKey > 0) {
291
- onEmpty === null || onEmpty === void 0 ? void 0 : onEmpty();
292
- }
293
- }, [
294
- state.loading,
295
- state.error,
296
- state.isBinary,
297
- hunkCount,
298
- reloadKey,
299
- onEmpty
300
- ]);
184
+ const handler = (_sender, style) => {
185
+ setDiffStyle(style);
186
+ };
187
+ widget.diffStyleChanged.connect(handler);
188
+ setDiffStyle(widget.diffStyle);
189
+ return () => {
190
+ widget.diffStyleChanged.disconnect(handler);
191
+ };
192
+ }, [widget]);
193
+ const handleFileDiffActiveChange = React.useCallback((active) => {
194
+ widget.setFileDiffActive(active);
195
+ }, [widget]);
301
196
  const [dark, setDark] = React.useState(() => isDarkTheme(themeManager));
302
- // Track theme changes so the diff viewer recolors with the rest of
303
- // JupyterLab. We listen on `IThemeManager.themeChanged` when available
304
- // and fall back to a `<body>` attribute observer otherwise.
197
+ const [pierre, setPierre] = React.useState(() => isPierreTheme(themeManager));
305
198
  React.useEffect(() => {
199
+ const sync = () => {
200
+ setDark(isDarkTheme(themeManager));
201
+ setPierre(isPierreTheme(themeManager));
202
+ };
306
203
  if (themeManager !== null) {
307
- const handler = () => setDark(isDarkTheme(themeManager));
308
- themeManager.themeChanged.connect(handler);
204
+ themeManager.themeChanged.connect(sync);
309
205
  return () => {
310
- themeManager.themeChanged.disconnect(handler);
206
+ themeManager.themeChanged.disconnect(sync);
311
207
  };
312
208
  }
313
- const observer = new MutationObserver(() => setDark(isDarkTheme(themeManager)));
209
+ const observer = new MutationObserver(sync);
314
210
  observer.observe(document.body, {
315
211
  attributes: true,
316
212
  attributeFilter: ['data-jp-theme-light']
@@ -319,55 +215,40 @@ function DiffViewer(props) {
319
215
  observer.disconnect();
320
216
  };
321
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.
322
227
  React.useEffect(() => {
323
228
  let cancelled = false;
324
- const serverPath = toServerPath(repoPath, change.path);
325
- setState({
326
- loading: true,
327
- oldText: '',
328
- newText: '',
329
- serverPath,
330
- isBinary: false,
331
- error: null
332
- });
333
- const { oldRef, newRef } = resolveReferences(change);
334
- // Raster images are still binary, but the git server base64-encodes
335
- // binary content, so we fetch them like text and let the surface render
336
- // an image diff. Other binary files have no useful textual diff.
337
- 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 });
338
239
  void (async () => {
339
- var _a, _b, _c;
340
240
  try {
341
- if (change.isBinary === true && !isImage) {
342
- if (cancelled) {
343
- return;
344
- }
345
- setState({
346
- loading: false,
347
- oldText: '',
348
- newText: '',
349
- serverPath,
350
- isBinary: true,
351
- error: null
352
- });
353
- return;
354
- }
355
- const fetchOld = oldRef === null
356
- ? Promise.resolve({ content: '' })
357
- : content(repoPath, (_a = change.from) !== null && _a !== void 0 ? _a : change.path, oldRef);
358
- const fetchNew = newRef === null
359
- ? Promise.resolve({ content: '' })
360
- : content(repoPath, change.path, newRef);
361
- const [oldRes, newRes] = await Promise.all([fetchOld, fetchNew]);
241
+ const [oldText, newText] = await Promise.all([
242
+ model.reference.content(),
243
+ model.challenger.content()
244
+ ]);
362
245
  if (cancelled) {
363
246
  return;
364
247
  }
365
248
  setState({
366
249
  loading: false,
367
- oldText: (_b = oldRes.content) !== null && _b !== void 0 ? _b : '',
368
- newText: (_c = newRes.content) !== null && _c !== void 0 ? _c : '',
369
- serverPath,
370
- isBinary: false,
250
+ oldText: oldText !== null && oldText !== void 0 ? oldText : '',
251
+ newText: newText !== null && newText !== void 0 ? newText : '',
371
252
  error: null
372
253
  });
373
254
  }
@@ -379,60 +260,67 @@ function DiffViewer(props) {
379
260
  loading: false,
380
261
  oldText: '',
381
262
  newText: '',
382
- serverPath,
383
- isBinary: false,
384
263
  error: err instanceof Error ? err.message : String(err)
385
264
  });
386
265
  }
266
+ finally {
267
+ if (!cancelled) {
268
+ // Let `refresh()` settle once content has loaded.
269
+ widget.settleRefresh();
270
+ }
271
+ }
387
272
  })();
388
273
  return () => {
389
274
  cancelled = true;
390
275
  };
391
- }, [repoPath, change, reloadKey]);
392
- // Per-hunk discard buttons are only meaningful for unstaged modifications:
393
- // staged-vs-HEAD diffs would need an index manipulation we can't do
394
- // through the available endpoints, and untracked files have no previous
395
- // version to revert to.
396
- 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]);
397
291
  const hunkDiscard = React.useMemo(() => ({
398
292
  enabled: canDiscardHunk,
399
293
  save: async (text) => {
400
- await contentsManager.save(state.serverPath, {
294
+ await contentsManager.save(serverPath, {
401
295
  type: 'file',
402
296
  format: 'text',
403
297
  content: text
404
298
  });
405
299
  },
406
300
  onAfterSave: () => {
407
- onChanged();
408
- setReloadKey(key => key + 1);
301
+ // Re-pull so the diff reflects the reverted hunk.
302
+ void widget.refresh();
409
303
  }
410
- }), [canDiscardHunk, contentsManager, onChanged, state.serverPath]);
411
- 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, onNotebookAvailabilityChange: handleNotebookAvailabilityChange, 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 }));
412
306
  }
413
307
  /**
414
- * Segmented Notebook/JSON selector mounted into the `MainAreaWidget`
415
- * toolbar for `.ipynb` diffs (launcher path). The selector reads from and
416
- * writes to the {@link DiffContentWidget} so React state changes (via the
417
- * diff content) and toolbar interactions stay in sync without either side
418
- * knowing about the other. The host toolbar widget's visibility is toggled
419
- * by {@link createGitDiffWidget} based on the same `hasNotebookView`
420
- * signal, so this control is always meaningful when it appears.
308
+ * Notebook/JSON toggle mounted into a host-owned toolbar.
421
309
  */
422
310
  class NotebookViewModeToolbarItem extends ReactWidget {
423
- constructor(content) {
311
+ constructor(widget) {
424
312
  super();
425
- this._content = content;
313
+ this._widget = widget;
426
314
  this.addClass('jp-xtralab-DiffWidget-viewModeToolbarItem');
427
315
  }
428
316
  render() {
429
- return React.createElement(NotebookViewModeToolbarControl, { content: this._content });
317
+ return React.createElement(NotebookViewModeToolbarControl, { widget: this._widget });
430
318
  }
431
319
  }
432
320
  function NotebookViewModeToolbarControl(props) {
433
- const { content } = props;
434
- const [mode, setMode] = React.useState(() => content.notebookViewMode);
435
- 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);
436
324
  React.useEffect(() => {
437
325
  const onMode = (_sender, next) => {
438
326
  setMode(next);
@@ -440,20 +328,56 @@ function NotebookViewModeToolbarControl(props) {
440
328
  const onAvailable = (_sender, next) => {
441
329
  setAvailable(next);
442
330
  };
443
- content.notebookViewModeChanged.connect(onMode);
444
- content.hasNotebookViewChanged.connect(onAvailable);
445
- setMode(content.notebookViewMode);
446
- setAvailable(content.hasNotebookView);
331
+ widget.notebookViewModeChanged.connect(onMode);
332
+ widget.hasNotebookViewChanged.connect(onAvailable);
333
+ setMode(widget.notebookViewMode);
334
+ setAvailable(widget.hasNotebookView);
447
335
  return () => {
448
- content.notebookViewModeChanged.disconnect(onMode);
449
- content.hasNotebookViewChanged.disconnect(onAvailable);
336
+ widget.notebookViewModeChanged.disconnect(onMode);
337
+ widget.hasNotebookViewChanged.disconnect(onAvailable);
450
338
  };
451
- }, [content]);
452
- 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) }));
453
341
  }
454
- function formatTitle(change) {
455
- var _a;
456
- const name = (_a = change.path.split('/').pop()) !== null && _a !== void 0 ? _a : change.path;
457
- const groupLabel = change.group === 'staged' ? 'Staged' : 'Working';
458
- return `${name} (${groupLabel})`;
342
+ /**
343
+ * Split/Unified toggle mounted into a host-owned toolbar.
344
+ */
345
+ class DiffStyleToolbarItem extends ReactWidget {
346
+ constructor(widget) {
347
+ super();
348
+ this._widget = widget;
349
+ this.addClass('jp-xtralab-DiffWidget-diffStyleToolbarItem');
350
+ }
351
+ render() {
352
+ return React.createElement(DiffStyleToolbarControl, { widget: this._widget });
353
+ }
354
+ }
355
+ function DiffStyleToolbarControl(props) {
356
+ const { widget } = props;
357
+ const [style, setStyle] = React.useState(() => widget.diffStyle);
358
+ const [available, setAvailable] = React.useState(() => widget.fileDiffActive);
359
+ React.useEffect(() => {
360
+ const onStyle = (_sender, next) => {
361
+ setStyle(next);
362
+ };
363
+ const onAvailable = (_sender, next) => {
364
+ setAvailable(next);
365
+ };
366
+ widget.diffStyleChanged.connect(onStyle);
367
+ widget.fileDiffActiveChanged.connect(onAvailable);
368
+ setStyle(widget.diffStyle);
369
+ setAvailable(widget.fileDiffActive);
370
+ return () => {
371
+ widget.diffStyleChanged.disconnect(onStyle);
372
+ widget.fileDiffActiveChanged.disconnect(onAvailable);
373
+ };
374
+ }, [widget]);
375
+ return (React.createElement(DiffStyleControl, { diffStyle: style, available: available, onChange: next => widget.setDiffStyle(next) }));
376
+ }
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));
459
383
  }