xtralab 0.3.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.
Files changed (60) hide show
  1. package/README.md +11 -4
  2. package/lib/editorBreadcrumbs/index.d.ts +11 -0
  3. package/lib/editorBreadcrumbs/index.js +41 -0
  4. package/lib/editorBreadcrumbs/widget.d.ts +34 -0
  5. package/lib/editorBreadcrumbs/widget.js +131 -0
  6. package/lib/editorIndent/index.d.ts +17 -0
  7. package/lib/editorIndent/index.js +142 -0
  8. package/lib/fileBrowser/commands.d.ts +3 -0
  9. package/lib/fileBrowser/commands.js +40 -1
  10. package/lib/fileBrowser/fileBrowser.js +393 -10
  11. package/lib/fileBrowser/gitStatus.d.ts +13 -0
  12. package/lib/fileBrowser/gitStatus.js +60 -0
  13. package/lib/fileBrowser/widget.d.ts +48 -0
  14. package/lib/fileBrowser/widget.js +24 -0
  15. package/lib/git/commands.d.ts +12 -13
  16. package/lib/git/commands.js +7 -28
  17. package/lib/git/diffProvider.d.ts +8 -0
  18. package/lib/git/diffProvider.js +314 -0
  19. package/lib/git/diffSurface.d.ts +115 -0
  20. package/lib/git/diffSurface.js +342 -0
  21. package/lib/git/diffWidget.d.ts +8 -13
  22. package/lib/git/diffWidget.js +65 -311
  23. package/lib/git/imageDiff.d.ts +23 -0
  24. package/lib/git/imageDiff.js +184 -0
  25. package/lib/git/index.d.ts +10 -8
  26. package/lib/git/index.js +37 -52
  27. package/lib/index.d.ts +3 -2
  28. package/lib/index.js +9 -4
  29. package/lib/launcher/dashboard.js +27 -15
  30. package/package.json +24 -4
  31. package/src/editorBreadcrumbs/index.ts +70 -0
  32. package/src/editorBreadcrumbs/widget.ts +158 -0
  33. package/src/editorIndent/index.ts +177 -0
  34. package/src/fileBrowser/commands.ts +43 -1
  35. package/src/fileBrowser/fileBrowser.tsx +427 -11
  36. package/src/fileBrowser/gitStatus.ts +66 -0
  37. package/src/fileBrowser/widget.tsx +77 -0
  38. package/src/git/commands.ts +12 -38
  39. package/src/git/diffProvider.tsx +462 -0
  40. package/src/git/diffSurface.tsx +588 -0
  41. package/src/git/diffWidget.tsx +105 -465
  42. package/src/git/imageDiff.tsx +368 -0
  43. package/src/git/index.ts +38 -68
  44. package/src/index.ts +9 -4
  45. package/src/launcher/dashboard.tsx +28 -15
  46. package/style/editorBreadcrumbs.css +86 -0
  47. package/style/git.css +158 -266
  48. package/style/index.css +2 -0
  49. package/style/index.js +1 -0
  50. package/style/launcher.css +0 -5
  51. package/style/tabs.css +66 -0
  52. package/lib/git/icons.d.ts +0 -14
  53. package/lib/git/icons.js +0 -19
  54. package/lib/git/panel.d.ts +0 -29
  55. package/lib/git/panel.js +0 -42
  56. package/lib/git/panelComponent.d.ts +0 -28
  57. package/lib/git/panelComponent.js +0 -303
  58. package/src/git/icons.ts +0 -20
  59. package/src/git/panel.tsx +0 -56
  60. package/src/git/panelComponent.tsx +0 -561
@@ -0,0 +1,184 @@
1
+ import * as React from 'react';
2
+ /**
3
+ * xtralab's image diff, registered in place of `@jupyterlab/git:image-diff`.
4
+ *
5
+ * The git server base64-encodes binary content, so both diff hosts receive
6
+ * the two sides as base64 strings through the same `content()` getters used
7
+ * for text. This module turns those strings into `<img>` elements and
8
+ * offers the 2-up / swipe / onion-skin comparison modes; no diffing library
9
+ * is involved (`@pierre/diffs` is text-only).
10
+ */
11
+ /**
12
+ * Raster image extensions xtralab renders as an image diff, mapped to the
13
+ * MIME subtype used in the `data:image/<subtype>;base64,…` URL.
14
+ *
15
+ * `.svg` is intentionally absent: it is XML text, so the `@pierre/diffs`
16
+ * text diff (the fallback provider) is more useful for it than an image
17
+ * comparison.
18
+ */
19
+ const IMAGE_DATA_TYPES = {
20
+ '.png': 'png',
21
+ '.jpg': 'jpeg',
22
+ '.jpeg': 'jpeg',
23
+ '.gif': 'gif',
24
+ '.webp': 'webp',
25
+ '.bmp': 'bmp',
26
+ '.ico': 'x-icon'
27
+ };
28
+ /** The file extensions registered as the xtralab image diff provider. */
29
+ export const IMAGE_DIFF_EXTENSIONS = Object.keys(IMAGE_DATA_TYPES);
30
+ /**
31
+ * The `data:` MIME subtype for a path xtralab renders as an image, or
32
+ * `null` when the path is not a supported raster image.
33
+ */
34
+ export function imageDataType(path) {
35
+ var _a;
36
+ const lower = path.toLowerCase();
37
+ const dot = lower.lastIndexOf('.');
38
+ if (dot < 0) {
39
+ return null;
40
+ }
41
+ return (_a = IMAGE_DATA_TYPES[lower.slice(dot)]) !== null && _a !== void 0 ? _a : null;
42
+ }
43
+ const IMAGE_DIFF_VIEW_MODE_STORAGE_KEY = 'xtralab:image-diff-view-mode';
44
+ function readStoredImageViewMode() {
45
+ try {
46
+ const raw = window.localStorage.getItem(IMAGE_DIFF_VIEW_MODE_STORAGE_KEY);
47
+ if (raw === '2-up' || raw === 'swipe' || raw === 'onion') {
48
+ return raw;
49
+ }
50
+ }
51
+ catch (_a) {
52
+ // localStorage can throw in privacy/sandboxed contexts — 2-up is the
53
+ // sensible default anyway.
54
+ }
55
+ return '2-up';
56
+ }
57
+ function writeStoredImageViewMode(mode) {
58
+ try {
59
+ window.localStorage.setItem(IMAGE_DIFF_VIEW_MODE_STORAGE_KEY, mode);
60
+ }
61
+ catch (_a) {
62
+ // Best-effort.
63
+ }
64
+ }
65
+ /**
66
+ * Strip whitespace from a base64 payload. Python's `base64.encodebytes`
67
+ * (used by the git server) inserts a newline every 76 characters; most
68
+ * browsers tolerate that inside a `data:` URL but some are stricter, and
69
+ * the cleaned length also lets us size the file accurately.
70
+ */
71
+ function cleanBase64(value) {
72
+ return value.replace(/\s+/g, '');
73
+ }
74
+ /** Decoded byte length of a (whitespace-free) base64 string. */
75
+ function base64ByteLength(clean) {
76
+ if (clean.length === 0) {
77
+ return 0;
78
+ }
79
+ const padding = clean.endsWith('==') ? 2 : clean.endsWith('=') ? 1 : 0;
80
+ return Math.floor((clean.length * 3) / 4) - padding;
81
+ }
82
+ function formatBytes(bytes) {
83
+ if (bytes < 1024) {
84
+ return `${bytes} B`;
85
+ }
86
+ const units = ['KB', 'MB', 'GB'];
87
+ let value = bytes / 1024;
88
+ let unit = 0;
89
+ while (value >= 1024 && unit < units.length - 1) {
90
+ value /= 1024;
91
+ unit += 1;
92
+ }
93
+ return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`;
94
+ }
95
+ function toSide(raw, fileType) {
96
+ const data = cleanBase64(raw);
97
+ const present = data.length > 0;
98
+ return {
99
+ data,
100
+ present,
101
+ uri: present ? `data:image/${fileType};base64,${data}` : null,
102
+ bytes: base64ByteLength(data)
103
+ };
104
+ }
105
+ /**
106
+ * The image comparison surface: a segmented mode selector (reusing the
107
+ * diff segmented-control styling so it matches the Notebook/JSON toggle)
108
+ * plus the selected 2-up / swipe / onion-skin view.
109
+ */
110
+ export function ImageDiffView(props) {
111
+ const { reference, challenger, fileType } = props;
112
+ const ref = React.useMemo(() => toSide(reference, fileType), [reference, fileType]);
113
+ const chall = React.useMemo(() => toSide(challenger, fileType), [challenger, fileType]);
114
+ const [mode, setMode] = React.useState(() => readStoredImageViewMode());
115
+ const changeMode = React.useCallback((next) => {
116
+ setMode(next);
117
+ writeStoredImageViewMode(next);
118
+ }, []);
119
+ return (React.createElement("div", { className: "jp-xtralab-ImageDiff" },
120
+ React.createElement("div", { className: "jp-xtralab-ImageDiff-header" },
121
+ React.createElement("div", { className: "jp-xtralab-DiffWidget-segmented", role: "tablist", "aria-label": "Image diff view mode" }, [
122
+ ['2-up', '2-up'],
123
+ ['swipe', 'Swipe'],
124
+ ['onion', 'Onion Skin']
125
+ ].map(([value, label]) => (React.createElement("button", { key: value, type: "button", role: "tab", "aria-selected": mode === value, "data-active": mode === value, className: "jp-xtralab-DiffWidget-segmentedButton", onClick: () => changeMode(value) }, label))))),
126
+ React.createElement("div", { className: "jp-xtralab-ImageDiff-body" }, mode === '2-up' ? (React.createElement(TwoUp, { reference: ref, challenger: chall })) : mode === 'swipe' ? (React.createElement(Swipe, { reference: ref, challenger: chall })) : (React.createElement(OnionSkin, { reference: ref, challenger: chall })))));
127
+ }
128
+ function useNaturalSize() {
129
+ const [size, setSize] = React.useState(null);
130
+ const onLoad = React.useCallback((event) => {
131
+ setSize([
132
+ event.currentTarget.naturalWidth,
133
+ event.currentTarget.naturalHeight
134
+ ]);
135
+ }, []);
136
+ return [size, onLoad];
137
+ }
138
+ function caption(side, size, emptyLabel) {
139
+ if (!side.present) {
140
+ return emptyLabel;
141
+ }
142
+ const dims = size !== null ? `${size[0]}×${size[1]} · ` : '';
143
+ return `${dims}${formatBytes(side.bytes)}`;
144
+ }
145
+ function TwoUp({ reference, challenger }) {
146
+ const [refSize, onRefLoad] = useNaturalSize();
147
+ const [challSize, onChallLoad] = useNaturalSize();
148
+ return (React.createElement("div", { className: "jp-xtralab-ImageDiff-twoUp" },
149
+ React.createElement("figure", { className: "jp-xtralab-ImageDiff-col" },
150
+ React.createElement("div", { className: "jp-xtralab-ImageDiff-frame", "data-side": "reference" }, reference.uri !== null ? (React.createElement("img", { className: "jp-xtralab-ImageDiff-img", src: reference.uri, alt: "Reference revision", onLoad: onRefLoad })) : (React.createElement("div", { className: "jp-xtralab-ImageDiff-empty" }, "No previous image"))),
151
+ React.createElement("figcaption", { className: "jp-xtralab-ImageDiff-caption" }, caption(reference, refSize, 'Added'))),
152
+ React.createElement("figure", { className: "jp-xtralab-ImageDiff-col" },
153
+ React.createElement("div", { className: "jp-xtralab-ImageDiff-frame", "data-side": "challenger" }, challenger.uri !== null ? (React.createElement("img", { className: "jp-xtralab-ImageDiff-img", src: challenger.uri, alt: "Challenger revision", onLoad: onChallLoad })) : (React.createElement("div", { className: "jp-xtralab-ImageDiff-empty" }, "Deleted"))),
154
+ React.createElement("figcaption", { className: "jp-xtralab-ImageDiff-caption" }, caption(challenger, challSize, 'Deleted')))));
155
+ }
156
+ /**
157
+ * Range slider shared by the swipe and onion-skin views. A native
158
+ * `<input type="range">` (styled via CSS) keeps the bundle free of a UI
159
+ * component dependency.
160
+ */
161
+ function RangeSlider(props) {
162
+ const { value, onChange, ariaLabel } = props;
163
+ return (React.createElement("input", { className: "jp-xtralab-ImageDiff-slider", type: "range", min: 0, max: 100, step: 1, value: value, "aria-label": ariaLabel, onChange: event => onChange(Number(event.target.value)) }));
164
+ }
165
+ function Swipe({ reference, challenger }) {
166
+ const [position, setPosition] = React.useState(50);
167
+ return (React.createElement("div", { className: "jp-xtralab-ImageDiff-interactive" },
168
+ React.createElement("div", { className: "jp-xtralab-ImageDiff-stack" },
169
+ reference.uri !== null ? (React.createElement("img", { className: "jp-xtralab-ImageDiff-stackImg", src: reference.uri, alt: "Reference revision", style: {
170
+ clipPath: `inset(0 ${100 - position}% 0 0)`
171
+ } })) : null,
172
+ challenger.uri !== null ? (React.createElement("img", { className: "jp-xtralab-ImageDiff-stackImg", src: challenger.uri, alt: "Challenger revision", style: {
173
+ clipPath: `inset(0 0 0 ${position}%)`
174
+ } })) : null),
175
+ React.createElement(RangeSlider, { value: position, onChange: setPosition, ariaLabel: "Swipe between reference and challenger" })));
176
+ }
177
+ function OnionSkin({ reference, challenger }) {
178
+ const [opacity, setOpacity] = React.useState(50);
179
+ return (React.createElement("div", { className: "jp-xtralab-ImageDiff-interactive" },
180
+ React.createElement("div", { className: "jp-xtralab-ImageDiff-stack" },
181
+ reference.uri !== null ? (React.createElement("img", { className: "jp-xtralab-ImageDiff-stackImg", src: reference.uri, alt: "Reference revision" })) : null,
182
+ challenger.uri !== null ? (React.createElement("img", { className: "jp-xtralab-ImageDiff-stackImg", src: challenger.uri, alt: "Challenger revision", style: { opacity: opacity / 100 } })) : null),
183
+ React.createElement(RangeSlider, { value: opacity, onChange: setOpacity, ariaLabel: "Fade between reference and challenger" })));
184
+ }
@@ -1,11 +1,13 @@
1
1
  import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
+ import { CommandArguments, CommandIDs } from './commands';
2
3
  /**
3
- * The git plugin. Adds a VS Code-style "Source Control" panel to the
4
- * JupyterLab left sidebar and opens file diffs in the main area when the
5
- * user clicks an entry in the panel. Backed by the `jupyterlab_git` server
6
- * extension's REST API; the bundled `@jupyterlab/git` frontend is disabled
7
- * via `package.json`'s `jupyterlab.disabledExtensions` so the two panels do
8
- * not coexist.
4
+ * Every plugin contributed by xtralab's git integration:
5
+ *
6
+ * 1. {@link diffCommandPlugin} — the launcher's `xtralab:git:open-diff`.
7
+ * 2. `diffProviderPlugin` — registers the `@pierre/diffs` rendering as
8
+ * jupyterlab-git's diff providers (replacing its disabled
9
+ * notebook/text diff plugins).
9
10
  */
10
- declare const plugin: JupyterFrontEndPlugin<void>;
11
- export default plugin;
11
+ declare const plugins: JupyterFrontEndPlugin<unknown>[];
12
+ export { CommandArguments, CommandIDs };
13
+ export default plugins;
package/lib/git/index.js CHANGED
@@ -1,36 +1,29 @@
1
- import { ILayoutRestorer } from '@jupyterlab/application';
2
- import { Dialog, ICommandPalette, IThemeManager, WidgetTracker, showDialog, showErrorMessage } from '@jupyterlab/apputils';
1
+ import { IThemeManager, WidgetTracker } from '@jupyterlab/apputils';
3
2
  import { IRenderMimeRegistry } from '@jupyterlab/rendermime';
4
3
  import { CommandIDs, registerGitCommands } from './commands';
5
4
  import { PREVIEW_DIFF_WIDGET_ID, pinnedDiffWidgetId } from './diffWidget';
6
- import { GitPanel } from './panel';
7
- const GIT_PLUGIN_ID = 'xtralab:git';
5
+ import diffProviderPlugin from './diffProvider';
6
+ const GIT_DIFF_COMMAND_PLUGIN_ID = 'xtralab:git-diff-command';
8
7
  const GIT_DIFF_TRACKER_NAMESPACE = 'xtralab-git-diff';
9
8
  /**
10
- * The git plugin. Adds a VS Code-style "Source Control" panel to the
11
- * JupyterLab left sidebar and opens file diffs in the main area when the
12
- * user clicks an entry in the panel. Backed by the `jupyterlab_git` server
13
- * extension's REST API; the bundled `@jupyterlab/git` frontend is disabled
14
- * via `package.json`'s `jupyterlab.disabledExtensions` so the two panels do
15
- * not coexist.
9
+ * The launcher's git diff command plugin.
10
+ *
11
+ * The `@jupyterlab/git` frontend stays enabled (only its diff plugins are
12
+ * swapped — see `diffProvider.tsx`), so the upstream git panel is what the
13
+ * user sees in the sidebar. This plugin is the launcher dashboard's
14
+ * independent diff path: the `xtralab:git:open-diff` command the
15
+ * dashboard's "Changes" section calls, plus a tracker so reopening a diff
16
+ * reveals the existing tab and the preview/pin behavior keeps working.
16
17
  */
17
- const plugin = {
18
- id: GIT_PLUGIN_ID,
19
- description: 'A VS Code-style git changes panel and diff viewer powered by jupyterlab-git and @pierre/diffs.',
18
+ const diffCommandPlugin = {
19
+ id: GIT_DIFF_COMMAND_PLUGIN_ID,
20
+ description: "The launcher dashboard's side-by-side git diff command, powered by @pierre/diffs.",
20
21
  autoStart: true,
21
- optional: [
22
- ILayoutRestorer,
23
- IThemeManager,
24
- ICommandPalette,
25
- IRenderMimeRegistry
26
- ],
27
- activate: (app, restorer, themeManager, commandPalette, rendermime) => {
28
- // Match the panel's repo path: empty string means "use the JupyterLab
29
- // server's root and let git resolve the enclosing repo".
30
- const repoPath = '';
31
- // Track open diff widgets so the layout restorer can recreate them on
32
- // reload, and so the openDiff handler can reveal an already-open diff
33
- // for the same file instead of creating a duplicate.
22
+ optional: [IThemeManager, IRenderMimeRegistry],
23
+ activate: (app, themeManager, rendermime) => {
24
+ // Track open diff widgets so the openDiff handler can reveal an
25
+ // already-open diff for the same file instead of creating a duplicate
26
+ // (and so a promoted preview can find its pinned twin).
34
27
  const tracker = new WidgetTracker({
35
28
  namespace: GIT_DIFF_TRACKER_NAMESPACE
36
29
  });
@@ -39,36 +32,15 @@ const plugin = {
39
32
  const existing = tracker.find(widget => !widget.isDisposed && widget.id === id);
40
33
  return existing !== null && existing !== void 0 ? existing : undefined;
41
34
  };
42
- const panel = new GitPanel({
43
- openDiff: (change, options) => {
44
- const args = { repoPath, change };
45
- if ((options === null || options === void 0 ? void 0 : options.pin) === true) {
46
- args.pin = true;
47
- }
48
- void app.commands.execute(CommandIDs.openDiff, args);
49
- },
50
- confirm: async (title, body, accept) => {
51
- const result = await showDialog({
52
- title,
53
- body,
54
- buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: accept })]
55
- });
56
- return result.button.accept === true;
57
- },
58
- showError: showErrorMessage
59
- });
60
- app.shell.add(panel, 'left', { rank: 60 });
61
- if (restorer !== null) {
62
- restorer.add(panel, panel.id);
63
- }
64
35
  registerGitCommands({
65
36
  app,
66
- panel,
67
37
  themeManager,
68
- commandPalette,
69
38
  contentsManager: app.serviceManager.contents,
70
39
  rendermime,
71
- onChanged: () => panel.refresh(),
40
+ // The launcher polls git status on its own cadence and the
41
+ // jupyterlab-git panel auto-refreshes on the contents `fileChanged`
42
+ // signal a hunk-discard save emits, so no extra refresh is needed.
43
+ onChanged: () => undefined,
72
44
  trackDiff: widget => tracker.add(widget),
73
45
  onPinned: current => {
74
46
  const existing = findDiff(current.content.change, true);
@@ -87,4 +59,17 @@ const plugin = {
87
59
  });
88
60
  }
89
61
  };
90
- export default plugin;
62
+ /**
63
+ * Every plugin contributed by xtralab's git integration:
64
+ *
65
+ * 1. {@link diffCommandPlugin} — the launcher's `xtralab:git:open-diff`.
66
+ * 2. `diffProviderPlugin` — registers the `@pierre/diffs` rendering as
67
+ * jupyterlab-git's diff providers (replacing its disabled
68
+ * notebook/text diff plugins).
69
+ */
70
+ const plugins = [
71
+ diffCommandPlugin,
72
+ diffProviderPlugin
73
+ ];
74
+ export { CommandIDs };
75
+ export default plugins;
package/lib/index.d.ts CHANGED
@@ -2,9 +2,10 @@ import { JupyterFrontEndPlugin } from '@jupyterlab/application';
2
2
  /**
3
3
  * Every plugin contributed by `xtralab`. The entry point of the
4
4
  * labextension is an array because the package bundles several independent
5
- * enhancements (file browser, git changes panel, …) — JupyterLab activates
5
+ * enhancements (file browser, git diff providers, …) — JupyterLab activates
6
6
  * each plugin individually and only the ones whose required tokens are
7
- * available end up running.
7
+ * available end up running. `gitPlugins` is itself an array (the launcher
8
+ * diff command + the jupyterlab-git diff providers), so it is spread in.
8
9
  */
9
10
  declare const plugins: JupyterFrontEndPlugin<unknown>[];
10
11
  export default plugins;
package/lib/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import fileBrowserPlugin from './fileBrowser';
2
- import gitPlugin from './git';
2
+ import editorBreadcrumbsPlugin from './editorBreadcrumbs';
3
+ import editorIndentPlugin from './editorIndent';
4
+ import gitPlugins from './git';
3
5
  import launcherPlugin from './launcher';
4
6
  import menusPlugin from './menus';
5
7
  import sidebarPlugin from './sidebar';
@@ -7,13 +9,16 @@ import statusBarPlugin from './statusBar';
7
9
  /**
8
10
  * Every plugin contributed by `xtralab`. The entry point of the
9
11
  * labextension is an array because the package bundles several independent
10
- * enhancements (file browser, git changes panel, …) — JupyterLab activates
12
+ * enhancements (file browser, git diff providers, …) — JupyterLab activates
11
13
  * each plugin individually and only the ones whose required tokens are
12
- * available end up running.
14
+ * available end up running. `gitPlugins` is itself an array (the launcher
15
+ * diff command + the jupyterlab-git diff providers), so it is spread in.
13
16
  */
14
17
  const plugins = [
18
+ editorBreadcrumbsPlugin,
19
+ editorIndentPlugin,
15
20
  fileBrowserPlugin,
16
- gitPlugin,
21
+ ...gitPlugins,
17
22
  launcherPlugin,
18
23
  menusPlugin,
19
24
  sidebarPlugin,
@@ -1,4 +1,5 @@
1
1
  import * as React from 'react';
2
+ import { Poll } from '@lumino/polling';
2
3
  import { LabIcon, ReactWidget, consoleIcon, notebookIcon, refreshIcon, terminalIcon } from '@jupyterlab/ui-components';
3
4
  import { expandStatusFiles, status } from '../git/api';
4
5
  import { CommandIDs as GitCommandIDs } from '../git/commands';
@@ -24,6 +25,12 @@ export class LauncherDashboard extends ReactWidget {
24
25
  * only opened intentionally, and the grid does not need to be live.
25
26
  */
26
27
  const CHANGES_POLL_INTERVAL_MS = 8000;
28
+ /**
29
+ * Upper bound on the exponential backoff when the status request fails
30
+ * repeatedly. Matches the rest of the plugin's polls so behavior is
31
+ * consistent across the dashboard, the git panel, and the file tree.
32
+ */
33
+ const CHANGES_POLL_MAX_MS = 300000;
27
34
  function LauncherDashboardComponent(props) {
28
35
  const { commands, agents, onAgentLaunch, repoPath, cwd } = props;
29
36
  const [prompt, setPrompt] = React.useState('');
@@ -45,12 +52,23 @@ function LauncherDashboardComponent(props) {
45
52
  });
46
53
  }
47
54
  }, [repoPath]);
55
+ // Driven by a Lumino `Poll` so the dashboard stops hitting the git
56
+ // endpoint while the JupyterLab tab is hidden, and so repeated
57
+ // failures back off exponentially instead of pegging the 8s cadence.
48
58
  React.useEffect(() => {
49
- void refresh();
50
- const handle = window.setInterval(() => {
51
- void refresh();
52
- }, CHANGES_POLL_INTERVAL_MS);
53
- return () => window.clearInterval(handle);
59
+ const poll = new Poll({
60
+ name: '@xtralab/launcher:gitChanges',
61
+ factory: () => refresh(),
62
+ frequency: {
63
+ interval: CHANGES_POLL_INTERVAL_MS,
64
+ backoff: true,
65
+ max: CHANGES_POLL_MAX_MS
66
+ },
67
+ standby: 'when-hidden'
68
+ });
69
+ return () => {
70
+ poll.dispose();
71
+ };
54
72
  }, [refresh]);
55
73
  const launch = React.useCallback(async (agent) => {
56
74
  const args = { cwd };
@@ -154,10 +172,9 @@ function AgentSection(props) {
154
172
  const { agents, prompt, onPromptChange, onLaunch } = props;
155
173
  const trimmed = prompt.trim();
156
174
  const promptActive = trimmed.length > 0;
157
- // The "primary" agent is the first prompt-capable one in the rendered
158
- // order. We mark it visually so the keyboard shortcut target isn't a
159
- // mystery, and reuse the same agent when Cmd/Ctrl+Enter fires from the
160
- // textarea — picking by the same rule keeps the two behaviors in sync.
175
+ // The first prompt-capable agent is what Cmd/Ctrl+Enter fires; the hint
176
+ // text below the textarea names it so the keyboard shortcut target
177
+ // isn't a mystery.
161
178
  const primaryAgent = agents.find(agent => agent.promptArgs !== undefined);
162
179
  const hintId = 'jp-xtralab-Launcher-agent-hint';
163
180
  return (React.createElement("section", { className: "jp-xtralab-Launcher-section" },
@@ -177,15 +194,10 @@ function AgentSection(props) {
177
194
  React.createElement("div", { className: "jp-xtralab-Launcher-agents" }, agents.map(agent => {
178
195
  const supportsPrompt = agent.promptArgs !== undefined;
179
196
  const disabled = promptActive && !supportsPrompt;
180
- const isPrimary = agent === primaryAgent;
181
197
  const tooltip = disabled
182
198
  ? `${agent.label} doesn't accept an initial prompt — clear the prompt to launch.`
183
199
  : agent.caption;
184
- const classes = ['jp-xtralab-Launcher-agent'];
185
- if (isPrimary) {
186
- classes.push('jp-xtralab-Launcher-agent-primary');
187
- }
188
- return (React.createElement("button", { key: agent.id, type: "button", className: classes.join(' '), title: tooltip, "aria-label": agent.label, "aria-describedby": hintId, disabled: disabled, onClick: () => onLaunch(agent) },
200
+ return (React.createElement("button", { key: agent.id, type: "button", className: "jp-xtralab-Launcher-agent", title: tooltip, "aria-label": agent.label, "aria-describedby": hintId, disabled: disabled, onClick: () => onLaunch(agent) },
189
201
  React.createElement(LabIcon.resolveReact, { icon: agent.icon, tag: "span", className: "jp-xtralab-Launcher-agent-icon" }),
190
202
  React.createElement("span", { className: "jp-xtralab-Launcher-agent-label" }, agent.label)));
191
203
  }))));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "xtralab",
3
- "version": "0.3.0",
4
- "description": "An opinionated JupyterLab meta-package that bundles a curated set of extensions, ships a path-first file browser and a VS Code-style git changes panel, and applies a quieter default workspace configuration.",
3
+ "version": "0.5.0",
4
+ "description": "An opinionated JupyterLab meta-package that bundles a curated set of extensions, ships a path-first file browser, renders jupyterlab-git's text, notebook and image diffs with its own viewers, and applies a quieter default workspace configuration.",
5
5
  "keywords": [
6
6
  "jupyter",
7
7
  "jupyterlab",
@@ -57,11 +57,16 @@
57
57
  "watch:labextension": "jupyter-builder watch ."
58
58
  },
59
59
  "dependencies": {
60
+ "@codemirror/language": "^6.12.1",
61
+ "@codemirror/state": "^6.5.4",
62
+ "@codemirror/view": "^6.39.14",
60
63
  "@jupyterlab/application": "^4.6.0-alpha.5",
61
64
  "@jupyterlab/apputils": "^4.7.0-alpha.5",
65
+ "@jupyterlab/codemirror": "^4.6.0-alpha.5",
62
66
  "@jupyterlab/coreutils": "^6.6.0-alpha.5",
63
67
  "@jupyterlab/docmanager": "^4.6.0-alpha.5",
64
68
  "@jupyterlab/docregistry": "^4.6.0-alpha.5",
69
+ "@jupyterlab/git": "^0.54.0-a0",
65
70
  "@jupyterlab/launcher": "^4.6.0-alpha.5",
66
71
  "@jupyterlab/mainmenu": "^4.6.0-alpha.5",
67
72
  "@jupyterlab/nbformat": "^4.6.0-alpha.5",
@@ -78,10 +83,12 @@
78
83
  "@lumino/disposable": "^2.0.0",
79
84
  "@lumino/dragdrop": "^2.0.0",
80
85
  "@lumino/messaging": "^2.0.0",
86
+ "@lumino/polling": "^2.1.5",
81
87
  "@lumino/signaling": "^2.0.0",
82
88
  "@lumino/widgets": "^2.0.0",
83
89
  "@pierre/diffs": "^1.1.20",
84
90
  "@pierre/trees": "1.0.0-beta.3",
91
+ "detect-indent": "^7.0.2",
85
92
  "ignore": "^5.3.2",
86
93
  "react": "^18.2.0",
87
94
  "react-dom": "^18.2.0"
@@ -125,8 +132,16 @@
125
132
  "extension": true,
126
133
  "outputDir": "xtralab/labextension",
127
134
  "schemaDir": "schema",
135
+ "sharedPackages": {
136
+ "@jupyterlab/git": {
137
+ "bundled": false,
138
+ "singleton": true
139
+ }
140
+ },
128
141
  "disabledExtensions": [
129
- "@jupyterlab/git",
142
+ "@jupyterlab/git:notebook-diff",
143
+ "@jupyterlab/git:plain-text-diff",
144
+ "@jupyterlab/git:image-diff",
130
145
  "@jupyterlab/launcher-extension"
131
146
  ]
132
147
  },
@@ -134,7 +149,12 @@
134
149
  "node_modules",
135
150
  "dist",
136
151
  "coverage",
137
- "**/*.d.ts"
152
+ "**/*.d.ts",
153
+ "desktop/out",
154
+ "desktop/dist",
155
+ "desktop/python/wheels",
156
+ "desktop/python/runtime",
157
+ "desktop/python/runtime.staging"
138
158
  ],
139
159
  "eslintConfig": {
140
160
  "extends": [
@@ -0,0 +1,70 @@
1
+ import {
2
+ JupyterFrontEnd,
3
+ JupyterFrontEndPlugin
4
+ } from '@jupyterlab/application';
5
+ import type {
6
+ DocumentRegistry,
7
+ IDocumentWidget
8
+ } from '@jupyterlab/docregistry';
9
+ import type { CommandRegistry } from '@lumino/commands';
10
+ import { DisposableDelegate, type IDisposable } from '@lumino/disposable';
11
+ import type { Widget } from '@lumino/widgets';
12
+
13
+ import { EditorBreadcrumbs } from './widget';
14
+
15
+ const PLUGIN_ID = 'xtralab:editor-breadcrumbs';
16
+ const EDITOR_FACTORY = 'Editor';
17
+ const TOOLBAR_ITEM_NAME = 'xtralab-editor-breadcrumbs';
18
+
19
+ class EditorBreadcrumbsExtension implements DocumentRegistry.IWidgetExtension<
20
+ IDocumentWidget<Widget, DocumentRegistry.IModel>,
21
+ DocumentRegistry.IModel
22
+ > {
23
+ constructor(commands: CommandRegistry) {
24
+ this._commands = commands;
25
+ }
26
+
27
+ createNew(
28
+ widget: IDocumentWidget<Widget, DocumentRegistry.IModel>,
29
+ context: DocumentRegistry.IContext<DocumentRegistry.IModel>
30
+ ): IDisposable | void {
31
+ const breadcrumbs = new EditorBreadcrumbs({
32
+ context,
33
+ commands: this._commands
34
+ });
35
+ const added = widget.toolbar.insertItem(0, TOOLBAR_ITEM_NAME, breadcrumbs);
36
+
37
+ if (!added) {
38
+ breadcrumbs.dispose();
39
+ return;
40
+ }
41
+
42
+ return new DisposableDelegate(() => {
43
+ breadcrumbs.dispose();
44
+ });
45
+ }
46
+
47
+ private _commands: CommandRegistry;
48
+ }
49
+
50
+ /**
51
+ * Adds a VS Code-style path breadcrumb to text editor toolbars. Each
52
+ * segment is clickable: clicking dispatches `xtralab:reveal-path` so
53
+ * any plugin that listens (today, the file browser) can surface the
54
+ * underlying folder or file. The breadcrumbs plugin therefore does not
55
+ * import the file browser at all — the JupyterLab command registry is
56
+ * the only seam between them.
57
+ */
58
+ const plugin: JupyterFrontEndPlugin<void> = {
59
+ id: PLUGIN_ID,
60
+ description: 'Display the active file path in text editor toolbars.',
61
+ autoStart: true,
62
+ activate: (app: JupyterFrontEnd): void => {
63
+ app.docRegistry.addWidgetExtension(
64
+ EDITOR_FACTORY,
65
+ new EditorBreadcrumbsExtension(app.commands)
66
+ );
67
+ }
68
+ };
69
+
70
+ export default plugin;