xtralab 0.4.0 → 0.5.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.
@@ -0,0 +1,368 @@
1
+ import * as React from 'react';
2
+
3
+ /**
4
+ * xtralab's image diff, registered in place of `@jupyterlab/git:image-diff`.
5
+ *
6
+ * The git server base64-encodes binary content, so both diff hosts receive
7
+ * the two sides as base64 strings through the same `content()` getters used
8
+ * for text. This module turns those strings into `<img>` elements and
9
+ * offers the 2-up / swipe / onion-skin comparison modes; no diffing library
10
+ * is involved (`@pierre/diffs` is text-only).
11
+ */
12
+
13
+ /**
14
+ * Raster image extensions xtralab renders as an image diff, mapped to the
15
+ * MIME subtype used in the `data:image/<subtype>;base64,…` URL.
16
+ *
17
+ * `.svg` is intentionally absent: it is XML text, so the `@pierre/diffs`
18
+ * text diff (the fallback provider) is more useful for it than an image
19
+ * comparison.
20
+ */
21
+ const IMAGE_DATA_TYPES: Record<string, string> = {
22
+ '.png': 'png',
23
+ '.jpg': 'jpeg',
24
+ '.jpeg': 'jpeg',
25
+ '.gif': 'gif',
26
+ '.webp': 'webp',
27
+ '.bmp': 'bmp',
28
+ '.ico': 'x-icon'
29
+ };
30
+
31
+ /** The file extensions registered as the xtralab image diff provider. */
32
+ export const IMAGE_DIFF_EXTENSIONS = Object.keys(IMAGE_DATA_TYPES);
33
+
34
+ /**
35
+ * The `data:` MIME subtype for a path xtralab renders as an image, or
36
+ * `null` when the path is not a supported raster image.
37
+ */
38
+ export function imageDataType(path: string): string | null {
39
+ const lower = path.toLowerCase();
40
+ const dot = lower.lastIndexOf('.');
41
+ if (dot < 0) {
42
+ return null;
43
+ }
44
+ return IMAGE_DATA_TYPES[lower.slice(dot)] ?? null;
45
+ }
46
+
47
+ /** Comparison layouts, matching jupyterlab-git's image diff modes. */
48
+ type ImageDiffViewMode = '2-up' | 'swipe' | 'onion';
49
+
50
+ const IMAGE_DIFF_VIEW_MODE_STORAGE_KEY = 'xtralab:image-diff-view-mode';
51
+
52
+ function readStoredImageViewMode(): ImageDiffViewMode {
53
+ try {
54
+ const raw = window.localStorage.getItem(IMAGE_DIFF_VIEW_MODE_STORAGE_KEY);
55
+ if (raw === '2-up' || raw === 'swipe' || raw === 'onion') {
56
+ return raw;
57
+ }
58
+ } catch {
59
+ // localStorage can throw in privacy/sandboxed contexts — 2-up is the
60
+ // sensible default anyway.
61
+ }
62
+ return '2-up';
63
+ }
64
+
65
+ function writeStoredImageViewMode(mode: ImageDiffViewMode): void {
66
+ try {
67
+ window.localStorage.setItem(IMAGE_DIFF_VIEW_MODE_STORAGE_KEY, mode);
68
+ } catch {
69
+ // Best-effort.
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Strip whitespace from a base64 payload. Python's `base64.encodebytes`
75
+ * (used by the git server) inserts a newline every 76 characters; most
76
+ * browsers tolerate that inside a `data:` URL but some are stricter, and
77
+ * the cleaned length also lets us size the file accurately.
78
+ */
79
+ function cleanBase64(value: string): string {
80
+ return value.replace(/\s+/g, '');
81
+ }
82
+
83
+ /** Decoded byte length of a (whitespace-free) base64 string. */
84
+ function base64ByteLength(clean: string): number {
85
+ if (clean.length === 0) {
86
+ return 0;
87
+ }
88
+ const padding = clean.endsWith('==') ? 2 : clean.endsWith('=') ? 1 : 0;
89
+ return Math.floor((clean.length * 3) / 4) - padding;
90
+ }
91
+
92
+ function formatBytes(bytes: number): string {
93
+ if (bytes < 1024) {
94
+ return `${bytes} B`;
95
+ }
96
+ const units = ['KB', 'MB', 'GB'];
97
+ let value = bytes / 1024;
98
+ let unit = 0;
99
+ while (value >= 1024 && unit < units.length - 1) {
100
+ value /= 1024;
101
+ unit += 1;
102
+ }
103
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
104
+ }
105
+
106
+ interface IImageSide {
107
+ /** Cleaned base64 payload; empty string means the side does not exist. */
108
+ data: string;
109
+ present: boolean;
110
+ uri: string | null;
111
+ bytes: number;
112
+ }
113
+
114
+ function toSide(raw: string, fileType: string): IImageSide {
115
+ const data = cleanBase64(raw);
116
+ const present = data.length > 0;
117
+ return {
118
+ data,
119
+ present,
120
+ uri: present ? `data:image/${fileType};base64,${data}` : null,
121
+ bytes: base64ByteLength(data)
122
+ };
123
+ }
124
+
125
+ interface IImageDiffViewProps {
126
+ /** Base64 of the reference (old) revision; empty when added. */
127
+ reference: string;
128
+ /** Base64 of the challenger (new) revision; empty when deleted. */
129
+ challenger: string;
130
+ /** MIME subtype from {@link imageDataType}. */
131
+ fileType: string;
132
+ }
133
+
134
+ /**
135
+ * The image comparison surface: a segmented mode selector (reusing the
136
+ * diff segmented-control styling so it matches the Notebook/JSON toggle)
137
+ * plus the selected 2-up / swipe / onion-skin view.
138
+ */
139
+ export function ImageDiffView(props: IImageDiffViewProps): React.ReactElement {
140
+ const { reference, challenger, fileType } = props;
141
+ const ref = React.useMemo(
142
+ () => toSide(reference, fileType),
143
+ [reference, fileType]
144
+ );
145
+ const chall = React.useMemo(
146
+ () => toSide(challenger, fileType),
147
+ [challenger, fileType]
148
+ );
149
+
150
+ const [mode, setMode] = React.useState<ImageDiffViewMode>(() =>
151
+ readStoredImageViewMode()
152
+ );
153
+ const changeMode = React.useCallback((next: ImageDiffViewMode) => {
154
+ setMode(next);
155
+ writeStoredImageViewMode(next);
156
+ }, []);
157
+
158
+ return (
159
+ <div className="jp-xtralab-ImageDiff">
160
+ <div className="jp-xtralab-ImageDiff-header">
161
+ <div
162
+ className="jp-xtralab-DiffWidget-segmented"
163
+ role="tablist"
164
+ aria-label="Image diff view mode"
165
+ >
166
+ {(
167
+ [
168
+ ['2-up', '2-up'],
169
+ ['swipe', 'Swipe'],
170
+ ['onion', 'Onion Skin']
171
+ ] as [ImageDiffViewMode, string][]
172
+ ).map(([value, label]) => (
173
+ <button
174
+ key={value}
175
+ type="button"
176
+ role="tab"
177
+ aria-selected={mode === value}
178
+ data-active={mode === value}
179
+ className="jp-xtralab-DiffWidget-segmentedButton"
180
+ onClick={() => changeMode(value)}
181
+ >
182
+ {label}
183
+ </button>
184
+ ))}
185
+ </div>
186
+ </div>
187
+ <div className="jp-xtralab-ImageDiff-body">
188
+ {mode === '2-up' ? (
189
+ <TwoUp reference={ref} challenger={chall} />
190
+ ) : mode === 'swipe' ? (
191
+ <Swipe reference={ref} challenger={chall} />
192
+ ) : (
193
+ <OnionSkin reference={ref} challenger={chall} />
194
+ )}
195
+ </div>
196
+ </div>
197
+ );
198
+ }
199
+
200
+ interface ISideViewProps {
201
+ reference: IImageSide;
202
+ challenger: IImageSide;
203
+ }
204
+
205
+ function useNaturalSize(): [
206
+ [number, number] | null,
207
+ (event: React.SyntheticEvent<HTMLImageElement>) => void
208
+ ] {
209
+ const [size, setSize] = React.useState<[number, number] | null>(null);
210
+ const onLoad = React.useCallback(
211
+ (event: React.SyntheticEvent<HTMLImageElement>) => {
212
+ setSize([
213
+ event.currentTarget.naturalWidth,
214
+ event.currentTarget.naturalHeight
215
+ ]);
216
+ },
217
+ []
218
+ );
219
+ return [size, onLoad];
220
+ }
221
+
222
+ function caption(
223
+ side: IImageSide,
224
+ size: [number, number] | null,
225
+ emptyLabel: string
226
+ ): string {
227
+ if (!side.present) {
228
+ return emptyLabel;
229
+ }
230
+ const dims = size !== null ? `${size[0]}×${size[1]} · ` : '';
231
+ return `${dims}${formatBytes(side.bytes)}`;
232
+ }
233
+
234
+ function TwoUp({ reference, challenger }: ISideViewProps): React.ReactElement {
235
+ const [refSize, onRefLoad] = useNaturalSize();
236
+ const [challSize, onChallLoad] = useNaturalSize();
237
+ return (
238
+ <div className="jp-xtralab-ImageDiff-twoUp">
239
+ <figure className="jp-xtralab-ImageDiff-col">
240
+ <div className="jp-xtralab-ImageDiff-frame" data-side="reference">
241
+ {reference.uri !== null ? (
242
+ <img
243
+ className="jp-xtralab-ImageDiff-img"
244
+ src={reference.uri}
245
+ alt="Reference revision"
246
+ onLoad={onRefLoad}
247
+ />
248
+ ) : (
249
+ <div className="jp-xtralab-ImageDiff-empty">No previous image</div>
250
+ )}
251
+ </div>
252
+ <figcaption className="jp-xtralab-ImageDiff-caption">
253
+ {caption(reference, refSize, 'Added')}
254
+ </figcaption>
255
+ </figure>
256
+ <figure className="jp-xtralab-ImageDiff-col">
257
+ <div className="jp-xtralab-ImageDiff-frame" data-side="challenger">
258
+ {challenger.uri !== null ? (
259
+ <img
260
+ className="jp-xtralab-ImageDiff-img"
261
+ src={challenger.uri}
262
+ alt="Challenger revision"
263
+ onLoad={onChallLoad}
264
+ />
265
+ ) : (
266
+ <div className="jp-xtralab-ImageDiff-empty">Deleted</div>
267
+ )}
268
+ </div>
269
+ <figcaption className="jp-xtralab-ImageDiff-caption">
270
+ {caption(challenger, challSize, 'Deleted')}
271
+ </figcaption>
272
+ </figure>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ /**
278
+ * Range slider shared by the swipe and onion-skin views. A native
279
+ * `<input type="range">` (styled via CSS) keeps the bundle free of a UI
280
+ * component dependency.
281
+ */
282
+ function RangeSlider(props: {
283
+ value: number;
284
+ onChange: (value: number) => void;
285
+ ariaLabel: string;
286
+ }): React.ReactElement {
287
+ const { value, onChange, ariaLabel } = props;
288
+ return (
289
+ <input
290
+ className="jp-xtralab-ImageDiff-slider"
291
+ type="range"
292
+ min={0}
293
+ max={100}
294
+ step={1}
295
+ value={value}
296
+ aria-label={ariaLabel}
297
+ onChange={event => onChange(Number(event.target.value))}
298
+ />
299
+ );
300
+ }
301
+
302
+ function Swipe({ reference, challenger }: ISideViewProps): React.ReactElement {
303
+ const [position, setPosition] = React.useState(50);
304
+ return (
305
+ <div className="jp-xtralab-ImageDiff-interactive">
306
+ <div className="jp-xtralab-ImageDiff-stack">
307
+ {reference.uri !== null ? (
308
+ <img
309
+ className="jp-xtralab-ImageDiff-stackImg"
310
+ src={reference.uri}
311
+ alt="Reference revision"
312
+ style={{
313
+ clipPath: `inset(0 ${100 - position}% 0 0)`
314
+ }}
315
+ />
316
+ ) : null}
317
+ {challenger.uri !== null ? (
318
+ <img
319
+ className="jp-xtralab-ImageDiff-stackImg"
320
+ src={challenger.uri}
321
+ alt="Challenger revision"
322
+ style={{
323
+ clipPath: `inset(0 0 0 ${position}%)`
324
+ }}
325
+ />
326
+ ) : null}
327
+ </div>
328
+ <RangeSlider
329
+ value={position}
330
+ onChange={setPosition}
331
+ ariaLabel="Swipe between reference and challenger"
332
+ />
333
+ </div>
334
+ );
335
+ }
336
+
337
+ function OnionSkin({
338
+ reference,
339
+ challenger
340
+ }: ISideViewProps): React.ReactElement {
341
+ const [opacity, setOpacity] = React.useState(50);
342
+ return (
343
+ <div className="jp-xtralab-ImageDiff-interactive">
344
+ <div className="jp-xtralab-ImageDiff-stack">
345
+ {reference.uri !== null ? (
346
+ <img
347
+ className="jp-xtralab-ImageDiff-stackImg"
348
+ src={reference.uri}
349
+ alt="Reference revision"
350
+ />
351
+ ) : null}
352
+ {challenger.uri !== null ? (
353
+ <img
354
+ className="jp-xtralab-ImageDiff-stackImg"
355
+ src={challenger.uri}
356
+ alt="Challenger revision"
357
+ style={{ opacity: opacity / 100 }}
358
+ />
359
+ ) : null}
360
+ </div>
361
+ <RangeSlider
362
+ value={opacity}
363
+ onChange={setOpacity}
364
+ ariaLabel="Fade between reference and challenger"
365
+ />
366
+ </div>
367
+ );
368
+ }
package/src/git/index.ts CHANGED
@@ -1,66 +1,47 @@
1
1
  import {
2
- ILayoutRestorer,
3
2
  JupyterFrontEnd,
4
3
  JupyterFrontEndPlugin
5
4
  } from '@jupyterlab/application';
6
- import {
7
- Dialog,
8
- ICommandPalette,
9
- IThemeManager,
10
- WidgetTracker,
11
- showDialog,
12
- showErrorMessage
13
- } from '@jupyterlab/apputils';
5
+ import { IThemeManager, WidgetTracker } from '@jupyterlab/apputils';
14
6
  import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
15
7
 
16
- import { ReadonlyPartialJSONObject } from '@lumino/coreutils';
17
-
18
8
  import { CommandArguments, CommandIDs, registerGitCommands } from './commands';
19
9
  import {
20
10
  DiffContentWidget,
21
11
  PREVIEW_DIFF_WIDGET_ID,
22
12
  pinnedDiffWidgetId
23
13
  } from './diffWidget';
14
+ import diffProviderPlugin from './diffProvider';
24
15
  import type { MainAreaWidget } from '@jupyterlab/apputils';
25
- import { GitPanel } from './panel';
26
16
  import { IFileChange } from './tokens';
27
17
 
28
- const GIT_PLUGIN_ID = 'xtralab:git';
18
+ const GIT_DIFF_COMMAND_PLUGIN_ID = 'xtralab:git-diff-command';
29
19
  const GIT_DIFF_TRACKER_NAMESPACE = 'xtralab-git-diff';
30
20
 
31
21
  /**
32
- * The git plugin. Adds a VS Code-style "Source Control" panel to the
33
- * JupyterLab left sidebar and opens file diffs in the main area when the
34
- * user clicks an entry in the panel. Backed by the `jupyterlab_git` server
35
- * extension's REST API; the bundled `@jupyterlab/git` frontend is disabled
36
- * via `package.json`'s `jupyterlab.disabledExtensions` so the two panels do
37
- * not coexist.
22
+ * The launcher's git diff command plugin.
23
+ *
24
+ * The `@jupyterlab/git` frontend stays enabled (only its diff plugins are
25
+ * swapped — see `diffProvider.tsx`), so the upstream git panel is what the
26
+ * user sees in the sidebar. This plugin is the launcher dashboard's
27
+ * independent diff path: the `xtralab:git:open-diff` command the
28
+ * dashboard's "Changes" section calls, plus a tracker so reopening a diff
29
+ * reveals the existing tab and the preview/pin behavior keeps working.
38
30
  */
39
- const plugin: JupyterFrontEndPlugin<void> = {
40
- id: GIT_PLUGIN_ID,
31
+ const diffCommandPlugin: JupyterFrontEndPlugin<void> = {
32
+ id: GIT_DIFF_COMMAND_PLUGIN_ID,
41
33
  description:
42
- 'A VS Code-style git changes panel and diff viewer powered by jupyterlab-git and @pierre/diffs.',
34
+ "The launcher dashboard's side-by-side git diff command, powered by @pierre/diffs.",
43
35
  autoStart: true,
44
- optional: [
45
- ILayoutRestorer,
46
- IThemeManager,
47
- ICommandPalette,
48
- IRenderMimeRegistry
49
- ],
36
+ optional: [IThemeManager, IRenderMimeRegistry],
50
37
  activate: (
51
38
  app: JupyterFrontEnd,
52
- restorer: ILayoutRestorer | null,
53
39
  themeManager: IThemeManager | null,
54
- commandPalette: ICommandPalette | null,
55
40
  rendermime: IRenderMimeRegistry | null
56
41
  ): void => {
57
- // Match the panel's repo path: empty string means "use the JupyterLab
58
- // server's root and let git resolve the enclosing repo".
59
- const repoPath = '';
60
-
61
- // Track open diff widgets so the layout restorer can recreate them on
62
- // reload, and so the openDiff handler can reveal an already-open diff
63
- // for the same file instead of creating a duplicate.
42
+ // Track open diff widgets so the openDiff handler can reveal an
43
+ // already-open diff for the same file instead of creating a duplicate
44
+ // (and so a promoted preview can find its pinned twin).
64
45
  const tracker = new WidgetTracker<MainAreaWidget<DiffContentWidget>>({
65
46
  namespace: GIT_DIFF_TRACKER_NAMESPACE
66
47
  });
@@ -76,41 +57,15 @@ const plugin: JupyterFrontEndPlugin<void> = {
76
57
  return existing ?? undefined;
77
58
  };
78
59
 
79
- const panel = new GitPanel({
80
- openDiff: (change, options) => {
81
- const args: CommandArguments.IOpenDiff = { repoPath, change };
82
- if (options?.pin === true) {
83
- args.pin = true;
84
- }
85
- void app.commands.execute(
86
- CommandIDs.openDiff,
87
- args as unknown as ReadonlyPartialJSONObject
88
- );
89
- },
90
- confirm: async (title, body, accept) => {
91
- const result = await showDialog({
92
- title,
93
- body,
94
- buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: accept })]
95
- });
96
- return result.button.accept === true;
97
- },
98
- showError: showErrorMessage
99
- });
100
-
101
- app.shell.add(panel, 'left', { rank: 60 });
102
- if (restorer !== null) {
103
- restorer.add(panel, panel.id);
104
- }
105
-
106
60
  registerGitCommands({
107
61
  app,
108
- panel,
109
62
  themeManager,
110
- commandPalette,
111
63
  contentsManager: app.serviceManager.contents,
112
64
  rendermime,
113
- onChanged: () => panel.refresh(),
65
+ // The launcher polls git status on its own cadence and the
66
+ // jupyterlab-git panel auto-refreshes on the contents `fileChanged`
67
+ // signal a hunk-discard save emits, so no extra refresh is needed.
68
+ onChanged: () => undefined,
114
69
  trackDiff: widget => tracker.add(widget),
115
70
  onPinned: current => {
116
71
  const existing = findDiff(current.content.change, true);
@@ -132,4 +87,19 @@ const plugin: JupyterFrontEndPlugin<void> = {
132
87
  }
133
88
  };
134
89
 
135
- export default plugin;
90
+ /**
91
+ * Every plugin contributed by xtralab's git integration:
92
+ *
93
+ * 1. {@link diffCommandPlugin} — the launcher's `xtralab:git:open-diff`.
94
+ * 2. `diffProviderPlugin` — registers the `@pierre/diffs` rendering as
95
+ * jupyterlab-git's diff providers (replacing its disabled
96
+ * notebook/text diff plugins).
97
+ */
98
+ const plugins: JupyterFrontEndPlugin<unknown>[] = [
99
+ diffCommandPlugin,
100
+ diffProviderPlugin
101
+ ];
102
+
103
+ export { CommandArguments, CommandIDs };
104
+
105
+ export default plugins;
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
3
3
  import fileBrowserPlugin from './fileBrowser';
4
4
  import editorBreadcrumbsPlugin from './editorBreadcrumbs';
5
5
  import editorIndentPlugin from './editorIndent';
6
- import gitPlugin from './git';
6
+ import gitPlugins from './git';
7
7
  import launcherPlugin from './launcher';
8
8
  import menusPlugin from './menus';
9
9
  import sidebarPlugin from './sidebar';
@@ -12,15 +12,16 @@ import statusBarPlugin from './statusBar';
12
12
  /**
13
13
  * Every plugin contributed by `xtralab`. The entry point of the
14
14
  * labextension is an array because the package bundles several independent
15
- * enhancements (file browser, git changes panel, …) — JupyterLab activates
15
+ * enhancements (file browser, git diff providers, …) — JupyterLab activates
16
16
  * each plugin individually and only the ones whose required tokens are
17
- * available end up running.
17
+ * available end up running. `gitPlugins` is itself an array (the launcher
18
+ * diff command + the jupyterlab-git diff providers), so it is spread in.
18
19
  */
19
20
  const plugins: JupyterFrontEndPlugin<unknown>[] = [
20
21
  editorBreadcrumbsPlugin,
21
22
  editorIndentPlugin,
22
23
  fileBrowserPlugin,
23
- gitPlugin,
24
+ ...gitPlugins,
24
25
  launcherPlugin,
25
26
  menusPlugin,
26
27
  sidebarPlugin,