xtralab 0.8.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 (53) hide show
  1. package/README.md +13 -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 +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 -540
  41. package/src/git/diffSurface.tsx +44 -28
  42. package/src/git/diffTheme.ts +40 -0
  43. package/src/git/diffWidget.tsx +256 -448
  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/git.css +12 -9
  50. package/style/index.css +1 -0
  51. package/style/index.js +1 -0
  52. package/style/launcher.css +46 -18
  53. package/style/sidebar.css +14 -0
@@ -1,20 +1,144 @@
1
- import { createGitDiffWidget, diffWidgetId, updateGitDiffWidget } from './diffWidget';
1
+ import { MainAreaWidget } from '@jupyterlab/apputils';
2
+ import { ToolbarButton, launchIcon } from '@jupyterlab/ui-components';
3
+ import { Signal } from '@lumino/signaling';
4
+ import { getTreeIcon } from '../fileBrowser/icons';
5
+ import { fileChangeToDiffModel } from './diffModel';
6
+ import { DIFF_WIDGET_CSS_CLASS, XtralabDiffWidget, addDiffToolbarItems } from './diffWidget';
2
7
  /**
3
- * Command IDs exposed by the launcher's git diff path. The launcher
4
- * dashboard's "Changes" section drives {@link CommandIDs.openDiff} to open
5
- * its own diff tab, independent of `jupyterlab-git` (whose panel reaches
6
- * the same rendering via the providers in `diffProvider.tsx`).
8
+ * Command IDs exposed by the launcher's git diff path.
7
9
  */
8
10
  export var CommandIDs;
9
11
  (function (CommandIDs) {
10
12
  CommandIDs.openDiff = 'xtralab:git:open-diff';
11
13
  })(CommandIDs || (CommandIDs = {}));
14
+ /**
15
+ * Widget id of the single, reused preview diff tab.
16
+ */
17
+ export const PREVIEW_DIFF_WIDGET_ID = 'xtralab:diff:preview';
18
+ /**
19
+ * Deterministic widget id for a pinned file/group pair.
20
+ */
21
+ export function pinnedDiffWidgetId(change) {
22
+ return `xtralab:diff:pinned:${change.group}:${change.path}`;
23
+ }
24
+ function formatTitle(change) {
25
+ var _a;
26
+ const name = (_a = change.path.split('/').pop()) !== null && _a !== void 0 ? _a : change.path;
27
+ const groupLabel = change.group === 'staged' ? 'Staged' : 'Working';
28
+ return `${name} (${groupLabel})`;
29
+ }
30
+ /**
31
+ * Launcher-owned host for a shared diff widget.
32
+ */
33
+ export class DiffMainAreaWidget extends MainAreaWidget {
34
+ constructor(options, change, pinned) {
35
+ super(options);
36
+ this._pinnedChanged = new Signal(this);
37
+ this._change = change;
38
+ this._pinned = pinned;
39
+ }
40
+ get change() {
41
+ return this._change;
42
+ }
43
+ get pinned() {
44
+ return this._pinned;
45
+ }
46
+ pin() {
47
+ if (this._pinned) {
48
+ return;
49
+ }
50
+ this._pinned = true;
51
+ this._pinnedChanged.emit(true);
52
+ }
53
+ get pinnedChanged() {
54
+ return this._pinnedChanged;
55
+ }
56
+ setChange(repoPath, change) {
57
+ this._change = change;
58
+ this.content.setModel(fileChangeToDiffModel(repoPath, change));
59
+ this.title.label = formatTitle(change);
60
+ this.title.caption = change.path;
61
+ this.title.icon = getTreeIcon(change.path);
62
+ }
63
+ }
64
+ /**
65
+ * Build the launcher's diff tab around the shared diff widget.
66
+ */
67
+ function createDiffWidget(options) {
68
+ var _a;
69
+ const { repoPath, change, themeManager, contentsManager, rendermime } = options;
70
+ const pinned = options.pinned === true;
71
+ const content = new XtralabDiffWidget(fileChangeToDiffModel(repoPath, change), { contentsManager, rendermime, themeManager });
72
+ const widget = new DiffMainAreaWidget({ content }, change, pinned);
73
+ widget.id = pinned ? pinnedDiffWidgetId(change) : PREVIEW_DIFF_WIDGET_ID;
74
+ widget.title.label = formatTitle(change);
75
+ widget.title.caption = change.path;
76
+ widget.title.closable = true;
77
+ widget.title.icon = getTreeIcon(change.path);
78
+ widget.title.className = pinned ? '' : 'jp-mod-preview';
79
+ widget.addClass(DIFF_WIDGET_CSS_CLASS);
80
+ const onPinned = (_a = options.onPinned) !== null && _a !== void 0 ? _a : (() => undefined);
81
+ const pinButton = new ToolbarButton({
82
+ icon: launchIcon,
83
+ tooltip: 'Pin tab',
84
+ onClick: () => widget.pin()
85
+ });
86
+ if (widget.pinned) {
87
+ pinButton.hide();
88
+ }
89
+ widget.toolbar.addItem('pin', pinButton);
90
+ addDiffToolbarItems(widget.toolbar, content);
91
+ // Keep chrome visible only while there is a relevant toolbar control.
92
+ const syncToolbarVisibility = () => {
93
+ if (widget.isDisposed) {
94
+ return;
95
+ }
96
+ if (!widget.pinned || content.hasNotebookView || content.fileDiffActive) {
97
+ widget.toolbar.show();
98
+ }
99
+ else {
100
+ widget.toolbar.hide();
101
+ }
102
+ };
103
+ const onPinnedChanged = (_sender, value) => {
104
+ if (widget.isDisposed) {
105
+ return;
106
+ }
107
+ if (value) {
108
+ pinButton.hide();
109
+ onPinned(widget);
110
+ if (widget.isDisposed) {
111
+ return;
112
+ }
113
+ widget.title.className = '';
114
+ }
115
+ syncToolbarVisibility();
116
+ };
117
+ // Auto-close once a discard leaves nothing to show.
118
+ const onEmptied = () => {
119
+ if (!widget.isDisposed) {
120
+ widget.close();
121
+ }
122
+ };
123
+ content.hasNotebookViewChanged.connect(syncToolbarVisibility);
124
+ content.fileDiffActiveChanged.connect(syncToolbarVisibility);
125
+ content.emptied.connect(onEmptied);
126
+ widget.pinnedChanged.connect(onPinnedChanged);
127
+ syncToolbarVisibility();
128
+ widget.disposed.connect(() => {
129
+ content.hasNotebookViewChanged.disconnect(syncToolbarVisibility);
130
+ content.fileDiffActiveChanged.disconnect(syncToolbarVisibility);
131
+ content.emptied.disconnect(onEmptied);
132
+ widget.pinnedChanged.disconnect(onPinnedChanged);
133
+ });
134
+ return widget;
135
+ }
12
136
  /**
13
137
  * Register the launcher's git diff command on the application command
14
138
  * registry.
15
139
  */
16
140
  export function registerGitCommands(options) {
17
- const { app, themeManager, contentsManager, rendermime, onChanged, trackDiff, findDiff, onPinned } = options;
141
+ const { app, themeManager, contentsManager, rendermime, trackDiff, findDiff, onPinned } = options;
18
142
  const { commands } = app;
19
143
  commands.addCommand(CommandIDs.openDiff, {
20
144
  label: 'Open Git Diff',
@@ -28,18 +152,17 @@ export function registerGitCommands(options) {
28
152
  const existing = findDiff(typed.change, pin);
29
153
  if (existing !== undefined && !existing.isDisposed) {
30
154
  if (!pin) {
31
- updateGitDiffWidget(existing, typed.change);
155
+ existing.setChange(typed.repoPath, typed.change);
32
156
  }
33
157
  app.shell.activateById(existing.id);
34
158
  return;
35
159
  }
36
- const widget = createGitDiffWidget({
160
+ const widget = createDiffWidget({
37
161
  repoPath: typed.repoPath,
38
162
  change: typed.change,
39
163
  themeManager,
40
164
  contentsManager,
41
165
  rendermime,
42
- onChanged,
43
166
  pinned: pin,
44
167
  onPinned
45
168
  });
@@ -49,4 +172,3 @@ export function registerGitCommands(options) {
49
172
  }
50
173
  });
51
174
  }
52
- export { diffWidgetId };
@@ -0,0 +1,14 @@
1
+ import type { IXtralabDiffModel } from './diffWidget';
2
+ import type { GitReference, IFileChange } from './tokens';
3
+ /**
4
+ * Resolve the old/new git refs for a launcher file change. Staged diffs are
5
+ * INDEX vs HEAD; unstaged diffs are WORKING vs INDEX.
6
+ */
7
+ export declare function resolveReferences(change: IFileChange): {
8
+ oldRef: GitReference | null;
9
+ newRef: GitReference | null;
10
+ };
11
+ /**
12
+ * Build a diff model for a single {@link IFileChange}.
13
+ */
14
+ export declare function fileChangeToDiffModel(repoPath: string, change: IFileChange): IXtralabDiffModel;
@@ -0,0 +1,75 @@
1
+ import { Git } from '@jupyterlab/git';
2
+ import { Signal } from '@lumino/signaling';
3
+ import { content } from './api';
4
+ /**
5
+ * Resolve the old/new git refs for a launcher file change. Staged diffs are
6
+ * INDEX vs HEAD; unstaged diffs are WORKING vs INDEX.
7
+ */
8
+ export function resolveReferences(change) {
9
+ if (change.status === 'untracked') {
10
+ return { oldRef: null, newRef: { special: 'WORKING' } };
11
+ }
12
+ if (change.group === 'staged') {
13
+ return { oldRef: { git: 'HEAD' }, newRef: { special: 'INDEX' } };
14
+ }
15
+ // If INDEX has no blob, the server falls back to HEAD as the baseline.
16
+ return { oldRef: { special: 'INDEX' }, newRef: { special: 'WORKING' } };
17
+ }
18
+ /**
19
+ * Map a launcher git ref onto `Git.Diff.IContent.source`; keep JupyterLab's
20
+ * enum values where downstream diff code checks for them.
21
+ */
22
+ function referenceSource(ref) {
23
+ if (ref === null) {
24
+ return null;
25
+ }
26
+ if ('git' in ref) {
27
+ return ref.git;
28
+ }
29
+ if (ref.special === 'WORKING') {
30
+ return Git.Diff.SpecialRef.WORKING;
31
+ }
32
+ if (ref.special === 'INDEX') {
33
+ return Git.Diff.SpecialRef.INDEX;
34
+ }
35
+ return Git.Diff.SpecialRef.BASE;
36
+ }
37
+ /**
38
+ * `Git.Diff.IModel` adapter for launcher file changes.
39
+ */
40
+ class FileChangeDiffModel {
41
+ constructor(repoPath, change) {
42
+ var _a;
43
+ this.changed = new Signal(this);
44
+ const { oldRef, newRef } = resolveReferences(change);
45
+ // Renames compare the new path against the old path's previous content.
46
+ const oldName = (_a = change.from) !== null && _a !== void 0 ? _a : change.path;
47
+ this.filename = change.path;
48
+ this.oldFilename = change.from;
49
+ this.repositoryPath = repoPath;
50
+ this.isBinary = change.isBinary === true;
51
+ // Only unstaged tracked files can be reverted through a working-tree save.
52
+ this.canDiscard =
53
+ change.group === 'unstaged' && change.status !== 'untracked';
54
+ this.reference = {
55
+ label: oldName,
56
+ source: referenceSource(oldRef),
57
+ content: () => oldRef === null
58
+ ? Promise.resolve('')
59
+ : content(repoPath, oldName, oldRef).then(result => { var _a; return (_a = result.content) !== null && _a !== void 0 ? _a : ''; })
60
+ };
61
+ this.challenger = {
62
+ label: change.path,
63
+ source: referenceSource(newRef),
64
+ content: () => newRef === null
65
+ ? Promise.resolve('')
66
+ : content(repoPath, change.path, newRef).then(result => { var _a; return (_a = result.content) !== null && _a !== void 0 ? _a : ''; })
67
+ };
68
+ }
69
+ }
70
+ /**
71
+ * Build a diff model for a single {@link IFileChange}.
72
+ */
73
+ export function fileChangeToDiffModel(repoPath, change) {
74
+ return new FileChangeDiffModel(repoPath, change);
75
+ }
@@ -1,8 +1,3 @@
1
1
  import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
- /**
3
- * Registers xtralab's diff rendering as jupyterlab-git's diff providers:
4
- * `@pierre/diffs` for text and notebooks, the bundled `<img>`-based view
5
- * for raster images.
6
- */
7
2
  declare const diffProviderPlugin: JupyterFrontEndPlugin<void>;
8
3
  export default diffProviderPlugin;
@@ -1,388 +1,20 @@
1
- import * as React from 'react';
2
1
  import { IThemeManager } from '@jupyterlab/apputils';
3
- import { PathExt } from '@jupyterlab/coreutils';
4
2
  import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
5
- import { ReactWidget } from '@jupyterlab/ui-components';
6
- import { Git, IGitExtension } from '@jupyterlab/git';
7
- import { PromiseDelegate } from '@lumino/coreutils';
8
- import { Signal } from '@lumino/signaling';
9
- import { DIFF_WIDGET_CSS_CLASS, DiffStyleControl, DiffSurface, NotebookViewModeControl, isDarkTheme, readStoredDiffStyle, readStoredNotebookViewMode, writeStoredDiffStyle, writeStoredNotebookViewMode } from './diffSurface';
10
- import { IMAGE_DIFF_EXTENSIONS, imageDataType } from './imageDiff';
3
+ import { IGitExtension } from '@jupyterlab/git';
4
+ import { XtralabDiffWidget, addDiffToolbarItems } from './diffWidget';
5
+ import { IMAGE_DIFF_EXTENSIONS } from './imageDiff';
11
6
  /**
12
- * A `Git.Diff.IDiffWidget` that renders through {@link DiffSurface}.
13
- *
14
- * `jupyterlab-git` constructs this via the registered factory, hosts it in
15
- * its own `PreviewMainAreaWidget`, and reads `model` / calls `refresh()` /
16
- * `getResolvedFile()` on it. It owns the notebook view-mode state (mounted
17
- * into the diff toolbar `jupyterlab-git` hands the factory) and a reload
18
- * nonce so `refresh()` re-pulls the model's content.
19
- */
20
- class XtralabDiffWidget extends ReactWidget {
21
- constructor(model, context) {
22
- super();
23
- this._reloadNonce = 0;
24
- this._pendingRefresh = null;
25
- this._notebookViewModeChanged = new Signal(this);
26
- this._hasNotebookView = false;
27
- this._hasNotebookViewChanged = new Signal(this);
28
- this._diffStyleChanged = new Signal(this);
29
- this._fileDiffActive = false;
30
- this._fileDiffActiveChanged = new Signal(this);
31
- this._model = model;
32
- this._context = context;
33
- this._notebookViewMode = readStoredNotebookViewMode();
34
- this._diffStyle = readStoredDiffStyle();
35
- // `jp-git-diff-root` borrows jupyterlab-git's host sizing (height:100%
36
- // flex column inside its PreviewMainAreaWidget); `jp-xtralab-DiffWidget`
37
- // brings our diff theming + the `.jp-xtralab-DiffWidget-*` styles.
38
- this.addClass('jp-git-diff-root');
39
- this.addClass(DIFF_WIDGET_CSS_CLASS);
40
- }
41
- /** The diff model, as required by `Git.Diff.IDiffWidget`. */
42
- get model() {
43
- return this._model;
44
- }
45
- /**
46
- * We render a two-way (reference vs. challenger) view and do not provide
47
- * a merge editor, so there is nothing to leave unresolved — report
48
- * resolved so jupyterlab-git's merge-conflict flow is not blocked.
49
- */
50
- get isFileResolved() {
51
- return true;
52
- }
53
- /**
54
- * Used by jupyterlab-git's "Mark as resolved" button. For merge
55
- * conflicts, jupyterlab-git passes the working-tree result as `base`;
56
- * keep that content rather than overwriting it with either compared side.
57
- */
58
- async getResolvedFile() {
59
- const source = this._model.hasConflict === true && this._model.base !== undefined
60
- ? this._model.base
61
- : this._model.challenger;
62
- const content = await source.content();
63
- return {
64
- type: 'file',
65
- format: imageDataType(this._model.filename) !== null ? 'base64' : 'text',
66
- content
67
- };
68
- }
69
- /**
70
- * Re-pull both sides of the model and re-render. jupyterlab-git wires a
71
- * "Refresh" toolbar button to this whenever `model.changed` fires (e.g.
72
- * the compared `HEAD` moved).
73
- */
74
- async refresh() {
75
- const done = new PromiseDelegate();
76
- this._pendingRefresh = done;
77
- this._reloadNonce += 1;
78
- this.update();
79
- await done.promise;
80
- }
81
- /** The notebook rendered-vs-JSON choice; mirrored into the toolbar. */
82
- get notebookViewMode() {
83
- return this._notebookViewMode;
84
- }
85
- setNotebookViewMode(mode) {
86
- if (mode === this._notebookViewMode) {
87
- return;
88
- }
89
- this._notebookViewMode = mode;
90
- writeStoredNotebookViewMode(mode);
91
- this._notebookViewModeChanged.emit(mode);
92
- }
93
- get notebookViewModeChanged() {
94
- return this._notebookViewModeChanged;
95
- }
96
- get hasNotebookView() {
97
- return this._hasNotebookView;
98
- }
99
- setHasNotebookView(value) {
100
- if (value === this._hasNotebookView) {
101
- return;
102
- }
103
- this._hasNotebookView = value;
104
- this._hasNotebookViewChanged.emit(value);
105
- }
106
- get hasNotebookViewChanged() {
107
- return this._hasNotebookViewChanged;
108
- }
109
- /** The user's current split-vs-unified layout choice for file diffs. */
110
- get diffStyle() {
111
- return this._diffStyle;
112
- }
113
- setDiffStyle(style) {
114
- if (style === this._diffStyle) {
115
- return;
116
- }
117
- this._diffStyle = style;
118
- writeStoredDiffStyle(style);
119
- this._diffStyleChanged.emit(style);
120
- }
121
- get diffStyleChanged() {
122
- return this._diffStyleChanged;
123
- }
124
- /**
125
- * Whether the textual/code file diff (the only view the split-vs-unified
126
- * choice affects) is currently active; mirrored into the toolbar so the
127
- * Split/Unified selector only shows when it applies.
128
- */
129
- get fileDiffActive() {
130
- return this._fileDiffActive;
131
- }
132
- setFileDiffActive(value) {
133
- if (value === this._fileDiffActive) {
134
- return;
135
- }
136
- this._fileDiffActive = value;
137
- this._fileDiffActiveChanged.emit(value);
138
- }
139
- get fileDiffActiveChanged() {
140
- return this._fileDiffActiveChanged;
141
- }
142
- /** Resolve the in-flight {@link refresh} promise, if any. */
143
- settleRefresh() {
144
- const pending = this._pendingRefresh;
145
- this._pendingRefresh = null;
146
- pending === null || pending === void 0 ? void 0 : pending.resolve();
147
- }
148
- get reloadNonce() {
149
- return this._reloadNonce;
150
- }
151
- get context() {
152
- return this._context;
153
- }
154
- render() {
155
- return React.createElement(ModelDiffView, { widget: this });
156
- }
157
- }
158
- function ModelDiffView(props) {
159
- const { widget } = props;
160
- const model = widget.model;
161
- const { contentsManager, rendermime, themeManager } = widget.context;
162
- const nonce = widget.reloadNonce;
163
- const [state, setState] = React.useState({
164
- loading: true,
165
- oldText: '',
166
- newText: '',
167
- error: null
168
- });
169
- // Mirror the widget's notebook view mode so toolbar-driven changes
170
- // re-render the diff. The widget owns the canonical value (so the
171
- // toolbar can read/write it without going through React).
172
- const [notebookViewMode, setNotebookViewMode] = React.useState(() => widget.notebookViewMode);
173
- React.useEffect(() => {
174
- const handler = (_sender, mode) => {
175
- setNotebookViewMode(mode);
176
- };
177
- widget.notebookViewModeChanged.connect(handler);
178
- setNotebookViewMode(widget.notebookViewMode);
179
- return () => {
180
- widget.notebookViewModeChanged.disconnect(handler);
181
- };
182
- }, [widget]);
183
- const handleNotebookAvailabilityChange = React.useCallback((available) => {
184
- widget.setHasNotebookView(available);
185
- }, [widget]);
186
- // Mirror the widget's diff-style choice so toolbar-driven changes
187
- // re-render the diff. The widget owns the canonical value.
188
- const [diffStyle, setDiffStyle] = React.useState(() => widget.diffStyle);
189
- React.useEffect(() => {
190
- const handler = (_sender, style) => {
191
- setDiffStyle(style);
192
- };
193
- widget.diffStyleChanged.connect(handler);
194
- setDiffStyle(widget.diffStyle);
195
- return () => {
196
- widget.diffStyleChanged.disconnect(handler);
197
- };
198
- }, [widget]);
199
- const handleFileDiffActiveChange = React.useCallback((active) => {
200
- widget.setFileDiffActive(active);
201
- }, [widget]);
202
- const [dark, setDark] = React.useState(() => isDarkTheme(themeManager));
203
- React.useEffect(() => {
204
- if (themeManager !== null) {
205
- const handler = () => setDark(isDarkTheme(themeManager));
206
- themeManager.themeChanged.connect(handler);
207
- return () => {
208
- themeManager.themeChanged.disconnect(handler);
209
- };
210
- }
211
- const observer = new MutationObserver(() => setDark(isDarkTheme(themeManager)));
212
- observer.observe(document.body, {
213
- attributes: true,
214
- attributeFilter: ['data-jp-theme-light']
215
- });
216
- return () => {
217
- observer.disconnect();
218
- };
219
- }, [themeManager]);
220
- // Fetch both sides whenever the model or the reload nonce changes. The
221
- // model's `content()` getters target the right git refs internally
222
- // (working tree, index, a commit, …) so we stay content-source-agnostic.
223
- React.useEffect(() => {
224
- let cancelled = false;
225
- setState({ loading: true, oldText: '', newText: '', error: null });
226
- void (async () => {
227
- try {
228
- const [oldText, newText] = await Promise.all([
229
- model.reference.content(),
230
- model.challenger.content()
231
- ]);
232
- if (cancelled) {
233
- return;
234
- }
235
- setState({
236
- loading: false,
237
- oldText: oldText !== null && oldText !== void 0 ? oldText : '',
238
- newText: newText !== null && newText !== void 0 ? newText : '',
239
- error: null
240
- });
241
- }
242
- catch (err) {
243
- if (cancelled) {
244
- return;
245
- }
246
- setState({
247
- loading: false,
248
- oldText: '',
249
- newText: '',
250
- error: err instanceof Error ? err.message : String(err)
251
- });
252
- }
253
- finally {
254
- if (!cancelled) {
255
- // Let an in-flight `widget.refresh()` resolve once the reload
256
- // has settled (jupyterlab-git only awaits it to hide its
257
- // refresh button).
258
- widget.settleRefresh();
259
- }
260
- }
261
- })();
262
- return () => {
263
- cancelled = true;
264
- };
265
- }, [widget, model, nonce]);
266
- // Per-hunk discard is only meaningful when the challenger is the working
267
- // tree (an unstaged change) and we are not in a three-way merge view.
268
- const canDiscardHunk = model.challenger.source === Git.Diff.SpecialRef.WORKING &&
269
- model.hasConflict !== true;
270
- const serverPath = React.useMemo(() => { var _a; return PathExt.join((_a = model.repositoryPath) !== null && _a !== void 0 ? _a : '', model.filename); }, [model.repositoryPath, model.filename]);
271
- const hunkDiscard = React.useMemo(() => ({
272
- enabled: canDiscardHunk,
273
- save: async (text) => {
274
- await contentsManager.save(serverPath, {
275
- type: 'file',
276
- format: 'text',
277
- content: text
278
- });
279
- },
280
- onAfterSave: () => {
281
- // jupyterlab-git's panel auto-refreshes off the contents
282
- // `fileChanged` signal the save above emits; we just need to
283
- // re-pull so the diff reflects the reverted hunk. (We do not
284
- // auto-close: jupyterlab-git owns the diff tab.)
285
- void widget.refresh();
286
- }
287
- }), [canDiscardHunk, contentsManager, serverPath, widget]);
288
- return (React.createElement(DiffSurface, { loading: state.loading, error: state.error, isBinary: false, oldText: state.oldText, newText: state.newText, newName: model.filename, oldName: model.filename, dark: dark, rendermime: rendermime, notebookViewMode: notebookViewMode, diffStyle: diffStyle, onNotebookAvailabilityChange: handleNotebookAvailabilityChange, onFileDiffActiveChange: handleFileDiffActiveChange, hunkDiscard: hunkDiscard }));
289
- }
290
- /**
291
- * Notebook/JSON toggle mounted into the diff toolbar that jupyterlab-git
292
- * passes to the factory. Bound to the widget's signals so it stays in sync
293
- * with the rendered surface and only appears for parseable notebooks.
294
- */
295
- class NotebookViewModeToolbarItem extends ReactWidget {
296
- constructor(widget) {
297
- super();
298
- this._widget = widget;
299
- this.addClass('jp-xtralab-DiffWidget-viewModeToolbarItem');
300
- }
301
- render() {
302
- return React.createElement(ToolbarControl, { widget: this._widget });
303
- }
304
- }
305
- function ToolbarControl(props) {
306
- const { widget } = props;
307
- const [mode, setMode] = React.useState(() => widget.notebookViewMode);
308
- const [available, setAvailable] = React.useState(() => widget.hasNotebookView);
309
- React.useEffect(() => {
310
- const onMode = (_sender, next) => {
311
- setMode(next);
312
- };
313
- const onAvailable = (_sender, next) => {
314
- setAvailable(next);
315
- };
316
- widget.notebookViewModeChanged.connect(onMode);
317
- widget.hasNotebookViewChanged.connect(onAvailable);
318
- setMode(widget.notebookViewMode);
319
- setAvailable(widget.hasNotebookView);
320
- return () => {
321
- widget.notebookViewModeChanged.disconnect(onMode);
322
- widget.hasNotebookViewChanged.disconnect(onAvailable);
323
- };
324
- }, [widget]);
325
- return (React.createElement(NotebookViewModeControl, { mode: mode, available: available, onChange: next => widget.setNotebookViewMode(next) }));
326
- }
327
- /**
328
- * Split/Unified toggle mounted into the diff toolbar jupyterlab-git passes
329
- * to the factory. Bound to the widget's signals so it stays in sync with the
330
- * rendered surface and only appears while the textual/code file diff is the
331
- * active view.
332
- */
333
- class DiffStyleToolbarItem extends ReactWidget {
334
- constructor(widget) {
335
- super();
336
- this._widget = widget;
337
- this.addClass('jp-xtralab-DiffWidget-diffStyleToolbarItem');
338
- }
339
- render() {
340
- return React.createElement(DiffStyleControlBound, { widget: this._widget });
341
- }
342
- }
343
- function DiffStyleControlBound(props) {
344
- const { widget } = props;
345
- const [style, setStyle] = React.useState(() => widget.diffStyle);
346
- const [available, setAvailable] = React.useState(() => widget.fileDiffActive);
347
- React.useEffect(() => {
348
- const onStyle = (_sender, next) => {
349
- setStyle(next);
350
- };
351
- const onAvailable = (_sender, next) => {
352
- setAvailable(next);
353
- };
354
- widget.diffStyleChanged.connect(onStyle);
355
- widget.fileDiffActiveChanged.connect(onAvailable);
356
- setStyle(widget.diffStyle);
357
- setAvailable(widget.fileDiffActive);
358
- return () => {
359
- widget.diffStyleChanged.disconnect(onStyle);
360
- widget.fileDiffActiveChanged.disconnect(onAvailable);
361
- };
362
- }, [widget]);
363
- return (React.createElement(DiffStyleControl, { diffStyle: style, available: available, onChange: next => widget.setDiffStyle(next) }));
364
- }
365
- /**
366
- * Build the `Git.Diff.Factory` xtralab registers with jupyterlab-git. The
367
- * factory closes over the application-level context (contents manager for
368
- * hunk discard, rendermime for notebook outputs, theme manager) that the
369
- * `Git.Diff.IFactoryOptions` does not carry.
7
+ * Build the `Git.Diff.Factory` registered with jupyterlab-git.
370
8
  */
371
9
  function makeXtralabDiffFactory(context) {
372
10
  return async (options) => {
373
11
  const widget = new XtralabDiffWidget(options.model, context);
374
12
  if (options.toolbar) {
375
- options.toolbar.addItem('xtralab-notebook-view-mode', new NotebookViewModeToolbarItem(widget));
376
- options.toolbar.addItem('xtralab-diff-style', new DiffStyleToolbarItem(widget));
13
+ addDiffToolbarItems(options.toolbar, widget);
377
14
  }
378
15
  return widget;
379
16
  };
380
17
  }
381
- /**
382
- * Registers xtralab's diff rendering as jupyterlab-git's diff providers:
383
- * `@pierre/diffs` for text and notebooks, the bundled `<img>`-based view
384
- * for raster images.
385
- */
386
18
  const diffProviderPlugin = {
387
19
  id: 'xtralab:git-diff-providers',
388
20
  description: "Replaces jupyterlab-git's notebook, text and image diff plugins with xtralab's renderer.",
@@ -395,9 +27,7 @@ const diffProviderPlugin = {
395
27
  rendermime,
396
28
  themeManager
397
29
  });
398
- // Notebooks and raster images get extension-specific providers; every
399
- // other text file falls back to the same factory. One factory serves
400
- // all three — the surface auto-detects notebooks/images by filename.
30
+ // Use one shared factory; the surface detects notebooks/images by filename.
401
31
  gitExtension.registerDiffProvider('XtralabNotebook', ['.ipynb'], factory);
402
32
  gitExtension.registerDiffProvider('XtralabImage', IMAGE_DIFF_EXTENSIONS, factory);
403
33
  gitExtension.registerFallbackDiffProvider(factory);