xtralab 0.1.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 (56) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +69 -0
  3. package/lib/fileBrowser/commands.d.ts +62 -0
  4. package/lib/fileBrowser/commands.js +418 -0
  5. package/lib/fileBrowser/contents.d.ts +28 -0
  6. package/lib/fileBrowser/contents.js +48 -0
  7. package/lib/fileBrowser/fileBrowser.d.ts +24 -0
  8. package/lib/fileBrowser/fileBrowser.js +535 -0
  9. package/lib/fileBrowser/icons.d.ts +37 -0
  10. package/lib/fileBrowser/icons.js +153 -0
  11. package/lib/fileBrowser/index.d.ts +7 -0
  12. package/lib/fileBrowser/index.js +38 -0
  13. package/lib/fileBrowser/widget.d.ts +97 -0
  14. package/lib/fileBrowser/widget.js +95 -0
  15. package/lib/git/api.d.ts +39 -0
  16. package/lib/git/api.js +164 -0
  17. package/lib/git/commands.d.ts +69 -0
  18. package/lib/git/commands.js +67 -0
  19. package/lib/git/diffWidget.d.ts +108 -0
  20. package/lib/git/diffWidget.js +624 -0
  21. package/lib/git/icons.d.ts +14 -0
  22. package/lib/git/icons.js +19 -0
  23. package/lib/git/index.d.ts +11 -0
  24. package/lib/git/index.js +74 -0
  25. package/lib/git/notebookDiff.d.ts +176 -0
  26. package/lib/git/notebookDiff.js +805 -0
  27. package/lib/git/panel.d.ts +29 -0
  28. package/lib/git/panel.js +42 -0
  29. package/lib/git/panelComponent.d.ts +26 -0
  30. package/lib/git/panelComponent.js +300 -0
  31. package/lib/git/tokens.d.ts +78 -0
  32. package/lib/git/tokens.js +6 -0
  33. package/lib/index.d.ts +10 -0
  34. package/lib/index.js +14 -0
  35. package/package.json +216 -0
  36. package/schema/plugin.json +67 -0
  37. package/src/fileBrowser/commands.ts +507 -0
  38. package/src/fileBrowser/contents.ts +66 -0
  39. package/src/fileBrowser/fileBrowser.tsx +635 -0
  40. package/src/fileBrowser/icons.ts +172 -0
  41. package/src/fileBrowser/index.ts +56 -0
  42. package/src/fileBrowser/widget.tsx +202 -0
  43. package/src/git/api.ts +207 -0
  44. package/src/git/commands.ts +143 -0
  45. package/src/git/diffWidget.tsx +903 -0
  46. package/src/git/icons.ts +20 -0
  47. package/src/git/index.ts +109 -0
  48. package/src/git/notebookDiff.tsx +1294 -0
  49. package/src/git/panel.tsx +56 -0
  50. package/src/git/panelComponent.tsx +558 -0
  51. package/src/git/tokens.ts +91 -0
  52. package/src/index.ts +18 -0
  53. package/style/base.css +175 -0
  54. package/style/git.css +770 -0
  55. package/style/index.css +2 -0
  56. package/style/index.js +2 -0
package/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Jeremy Tuloup
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its contributors
16
+ may be used to endorse or promote products derived from this software
17
+ without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # xtralab
2
+
3
+ An opinionated JupyterLab meta-package.
4
+
5
+ `xtralab` bundles a curated set of JupyterLab extensions, ships its own
6
+ JupyterLab extension with a path-first file browser and a VS Code-style git
7
+ changes panel, and applies a quieter default workspace configuration. The
8
+ defaults are shipped under `etc/jupyter/labconfig` and the bundled
9
+ labextension is shipped under `share/jupyter/labextensions/xtralab`.
10
+
11
+ The goal is to keep the package easy to inspect: most behavior comes from the
12
+ upstream Jupyter packages, while `xtralab` defines the default environment we
13
+ want out of the box.
14
+
15
+ ## Approach
16
+
17
+ The package brings together:
18
+
19
+ - The `ajlab` meta-package (agent-ready JupyterLab — collaboration plumbing, MCP support, command tooling, and its own quieter defaults)
20
+ - JupyterLab 4.6+
21
+ - Git server integration (`jupyterlab-git` — backs the bundled changes panel)
22
+ - Quick file opening (`jupyterlab-quickopen`)
23
+ - Cursor styling helpers (`jupyterlab-cursor-light`, `jupyterlab-cursor-theme`)
24
+ - Day and night themes (`jupyterlab-day`, `jupyterlab-night`)
25
+
26
+ These are installed as normal Python dependencies, so deployments can still
27
+ override versions and Jupyter configuration in the usual ways.
28
+
29
+ ## Bundled labextension
30
+
31
+ `xtralab` ships its own prebuilt labextension that contributes:
32
+
33
+ - A path-first file browser in the left sidebar, built on `@pierre/trees`.
34
+ - A VS Code-style "Source Control" panel in the left sidebar, powered by the
35
+ `jupyterlab-git` server REST API and `@pierre/diffs`. The bundled
36
+ `@jupyterlab/git` frontend is disabled automatically so the two panels do
37
+ not coexist.
38
+
39
+ ## Default settings
40
+
41
+ `xtralab` ships the following JupyterLab defaults:
42
+
43
+ - The announcements plugin is disabled, so JupyterLab does not prompt to fetch
44
+ news from the Jupyter news feed and does not check for application updates.
45
+ - The Table of Contents, Debugger, and Notebook Tools panels are disabled so
46
+ they do not show up in the right sidebar by default.
47
+
48
+ The JupyterLab frontend defaults are shipped as `labconfig/*.d/00-xtralab.json`
49
+ fragments so downstream meta-packages can add their own Lab configuration
50
+ fragments without replacing `xtralab`'s files.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install xtralab
56
+ ```
57
+
58
+ ## Development
59
+
60
+ ```bash
61
+ # Install dependencies and the package in editable mode
62
+ uv pip install -e ".[dev]"
63
+ jlpm
64
+ jlpm build
65
+ ```
66
+
67
+ ## License
68
+
69
+ BSD-3-Clause
@@ -0,0 +1,62 @@
1
+ import { JupyterFrontEnd } from '@jupyterlab/application';
2
+ import { IXtralabFileBrowser } from './widget';
3
+ /**
4
+ * Command identifiers exposed by the Xtralab browser. We deliberately namespace
5
+ * these under `xtralab:` rather than reusing the `filebrowser:` ids
6
+ * because the core `filebrowser:*` commands look up a `FileBrowser` instance
7
+ * via `IFileBrowserFactory.tracker` — our widget is not a `FileBrowser`, so
8
+ * those handlers would never see it.
9
+ */
10
+ export declare namespace CommandIDs {
11
+ const open = "xtralab:open";
12
+ const openBrowserTab = "xtralab:open-browser-tab";
13
+ const rename = "xtralab:rename";
14
+ const del = "xtralab:delete";
15
+ const duplicate = "xtralab:duplicate";
16
+ const copyPath = "xtralab:copy-path";
17
+ const download = "xtralab:download";
18
+ const refresh = "xtralab:refresh";
19
+ const createNewDirectory = "xtralab:create-new-directory";
20
+ const newLauncher = "xtralab:new-launcher";
21
+ }
22
+ /**
23
+ * Submenu id used to host the dynamically populated "Open With" entries.
24
+ * Distinct from the core file browser's `jp-contextmenu-open-with` so the
25
+ * core's populator does not try to fill ours with items derived from the
26
+ * default file browser's selection.
27
+ */
28
+ export declare const OPEN_WITH_SUBMENU_ID = "jp-contextmenu-xtralab-open-with";
29
+ export interface IRegisterCommandsOptions {
30
+ app: JupyterFrontEnd;
31
+ browser: IXtralabFileBrowser;
32
+ }
33
+ /**
34
+ * Register every Xtralab command on the application command registry, attach
35
+ * the items to the application context menu, and wire up the dynamic
36
+ * "Open With" submenu populator.
37
+ *
38
+ * @returns A function that detaches the menu items and signal listener.
39
+ * The commands themselves are owned by the registry for the lifetime of
40
+ * the plugin.
41
+ */
42
+ export declare function registerCommands(opts: IRegisterCommandsOptions): () => void;
43
+ /**
44
+ * The names we register with the toolbar. Kept distinct from
45
+ * {@link CommandIDs} because the toolbar API uses opaque names rather
46
+ * than commands.
47
+ */
48
+ export declare namespace ToolbarNames {
49
+ const newLauncher = "new-launcher";
50
+ const newDirectory = "new-directory";
51
+ const refresh = "refresh";
52
+ }
53
+ /**
54
+ * Populate the file browser's toolbar with the buttons that mirror the
55
+ * default JupyterLab file browser: a "+" launcher button, a new-folder
56
+ * button, and a refresh button. Returns a list of button names so callers
57
+ * can extend it or remove items if they need to.
58
+ */
59
+ export declare function populateToolbar(opts: {
60
+ app: JupyterFrontEnd;
61
+ browser: IXtralabFileBrowser;
62
+ }): void;
@@ -0,0 +1,418 @@
1
+ import { Clipboard, Dialog, InputDialog, showDialog, showErrorMessage } from '@jupyterlab/apputils';
2
+ import { PageConfig, PathExt } from '@jupyterlab/coreutils';
3
+ import { addIcon, closeIcon, CommandToolbarButton, copyIcon, downloadIcon, editIcon, fileIcon, newFolderIcon, refreshIcon } from '@jupyterlab/ui-components';
4
+ import { toCanonicalPath, toServerPath } from './contents';
5
+ /**
6
+ * Command identifiers exposed by the Xtralab browser. We deliberately namespace
7
+ * these under `xtralab:` rather than reusing the `filebrowser:` ids
8
+ * because the core `filebrowser:*` commands look up a `FileBrowser` instance
9
+ * via `IFileBrowserFactory.tracker` — our widget is not a `FileBrowser`, so
10
+ * those handlers would never see it.
11
+ */
12
+ export var CommandIDs;
13
+ (function (CommandIDs) {
14
+ CommandIDs.open = 'xtralab:open';
15
+ CommandIDs.openBrowserTab = 'xtralab:open-browser-tab';
16
+ CommandIDs.rename = 'xtralab:rename';
17
+ CommandIDs.del = 'xtralab:delete';
18
+ CommandIDs.duplicate = 'xtralab:duplicate';
19
+ CommandIDs.copyPath = 'xtralab:copy-path';
20
+ CommandIDs.download = 'xtralab:download';
21
+ CommandIDs.refresh = 'xtralab:refresh';
22
+ CommandIDs.createNewDirectory = 'xtralab:create-new-directory';
23
+ CommandIDs.newLauncher = 'xtralab:new-launcher';
24
+ })(CommandIDs || (CommandIDs = {}));
25
+ /**
26
+ * Submenu id used to host the dynamically populated "Open With" entries.
27
+ * Distinct from the core file browser's `jp-contextmenu-open-with` so the
28
+ * core's populator does not try to fill ours with items derived from the
29
+ * default file browser's selection.
30
+ */
31
+ export const OPEN_WITH_SUBMENU_ID = 'jp-contextmenu-xtralab-open-with';
32
+ /**
33
+ * Resolve the path of the item the user right-clicked. We prefer the
34
+ * contextmenu event's target — that is how `app.contextMenu` resolves which
35
+ * items match a selector — and fall back to the first item in the tree's
36
+ * selection if the command was invoked without an active contextmenu event
37
+ * (e.g. from the command palette).
38
+ */
39
+ function getTargetPath(app, browser) {
40
+ const node = app.contextMenuHitTest(n => n.dataset !== undefined && n.dataset.type === 'item');
41
+ const fromContext = node === null || node === void 0 ? void 0 : node.dataset.itemPath;
42
+ if (fromContext !== undefined && fromContext.length > 0) {
43
+ return fromContext;
44
+ }
45
+ return browser.selectedPaths[0];
46
+ }
47
+ /**
48
+ * Resolve the kind of the item that was right-clicked, based on the data
49
+ * attributes emitted by `@pierre/trees` rows. Returns `undefined` if no
50
+ * contextmenu event is active or the target is not a tree item.
51
+ */
52
+ function getTargetKind(app) {
53
+ const node = app.contextMenuHitTest(n => n.dataset !== undefined && n.dataset.type === 'item');
54
+ const kind = node === null || node === void 0 ? void 0 : node.dataset.itemType;
55
+ return kind === 'file' || kind === 'folder' ? kind : undefined;
56
+ }
57
+ /**
58
+ * Resolve the canonical paths the "Open" command should act on. When the
59
+ * user right-clicks a row that is part of the current selection, every
60
+ * selected file is opened — matching the default file browser, where
61
+ * "Open" on any file in a multi-selection opens them all. If the
62
+ * right-clicked row is *not* in the selection, only that row is opened.
63
+ *
64
+ * Folders are always filtered out: there is no "current directory" in
65
+ * this tree, so opening a folder via "Open" is a no-op.
66
+ */
67
+ function getOpenPaths(app, browser) {
68
+ const node = app.contextMenuHitTest(n => n.dataset !== undefined && n.dataset.type === 'item');
69
+ const fromContext = node === null || node === void 0 ? void 0 : node.dataset.itemPath;
70
+ const selection = browser.selectedPaths;
71
+ let candidates;
72
+ if (fromContext !== undefined && fromContext.length > 0) {
73
+ candidates = selection.includes(fromContext) ? selection : [fromContext];
74
+ }
75
+ else {
76
+ candidates = selection;
77
+ }
78
+ return candidates.filter(path => !path.endsWith('/'));
79
+ }
80
+ /** True iff there is an actionable target for a command on the right-click. */
81
+ function hasTarget(app, browser) {
82
+ return getTargetPath(app, browser) !== undefined;
83
+ }
84
+ /**
85
+ * Resolve the directory used as the working directory for actions that
86
+ * create a new file or folder. Folder selections become their own cwd;
87
+ * file selections fall back to their parent. Returns the empty string
88
+ * when the tree has no selection — that matches the contents API's
89
+ * convention for "the root directory".
90
+ */
91
+ function getWorkingDirectory(browser) {
92
+ const first = browser.selectedPaths[0];
93
+ if (first === undefined || first.length === 0) {
94
+ return '';
95
+ }
96
+ const serverPath = toServerPath(first);
97
+ if (first.endsWith('/')) {
98
+ return serverPath;
99
+ }
100
+ return PathExt.dirname(serverPath);
101
+ }
102
+ /**
103
+ * Refresh the "Open With" submenu's items from the document factories that
104
+ * can open the file under the right-click. Mirrors the
105
+ * `@jupyterlab/filebrowser-extension:open-with` pattern: the schema declares
106
+ * an empty submenu by id, and we look it up on the open context menu and
107
+ * fill it before the user can hover.
108
+ *
109
+ * `preferredWidgetFactories(path)` is path-only, so the populator stays
110
+ * synchronous and the submenu is ready before the user can hover over it.
111
+ */
112
+ function makeOpenWithUpdater(app, browser) {
113
+ let items = [];
114
+ return (contextMenu) => {
115
+ var _a, _b;
116
+ items.forEach(item => item.dispose());
117
+ items = [];
118
+ const submenu = (_b = (_a = contextMenu.menu.items.find(item => { var _a; return item.type === 'submenu' && ((_a = item.submenu) === null || _a === void 0 ? void 0 : _a.id) === OPEN_WITH_SUBMENU_ID; })) === null || _a === void 0 ? void 0 : _a.submenu) !== null && _b !== void 0 ? _b : null;
119
+ if (submenu === null) {
120
+ return;
121
+ }
122
+ submenu.clearItems();
123
+ if (getTargetKind(app) !== 'file') {
124
+ return;
125
+ }
126
+ const targetPath = getTargetPath(app, browser);
127
+ if (targetPath === undefined) {
128
+ return;
129
+ }
130
+ const serverPath = toServerPath(targetPath);
131
+ const factories = app.docRegistry.preferredWidgetFactories(serverPath);
132
+ items = factories.map(factory => submenu.addItem({
133
+ args: { factory: factory.name, label: factory.label || factory.name },
134
+ command: CommandIDs.open
135
+ }));
136
+ };
137
+ }
138
+ /**
139
+ * Register every Xtralab command on the application command registry, attach
140
+ * the items to the application context menu, and wire up the dynamic
141
+ * "Open With" submenu populator.
142
+ *
143
+ * @returns A function that detaches the menu items and signal listener.
144
+ * The commands themselves are owned by the registry for the lifetime of
145
+ * the plugin.
146
+ */
147
+ export function registerCommands(opts) {
148
+ const { app, browser } = opts;
149
+ const { commands } = app;
150
+ commands.addCommand(CommandIDs.open, {
151
+ label: args => { var _a, _b; return ((_b = (_a = args.label) !== null && _a !== void 0 ? _a : args.factory) !== null && _b !== void 0 ? _b : 'Open'); },
152
+ icon: args => {
153
+ var _a;
154
+ const factoryName = args.factory;
155
+ if (factoryName !== undefined) {
156
+ const fileType = app.docRegistry.getFileType(factoryName);
157
+ return (_a = fileType === null || fileType === void 0 ? void 0 : fileType.icon) === null || _a === void 0 ? void 0 : _a.bindprops({ stylesheet: 'menuItem' });
158
+ }
159
+ return fileIcon.bindprops({ stylesheet: 'menuItem' });
160
+ },
161
+ mnemonic: 0,
162
+ isEnabled: () => getOpenPaths(app, browser).length > 0,
163
+ execute: async (args) => {
164
+ var _a;
165
+ const targets = getOpenPaths(app, browser);
166
+ if (targets.length === 0) {
167
+ return;
168
+ }
169
+ const factory = (_a = args.factory) !== null && _a !== void 0 ? _a : undefined;
170
+ await Promise.all(targets.map(target => commands.execute('docmanager:open', {
171
+ path: toServerPath(target),
172
+ factory
173
+ })));
174
+ }
175
+ });
176
+ commands.addCommand(CommandIDs.openBrowserTab, {
177
+ label: 'Open in New Browser Tab',
178
+ icon: fileIcon.bindprops({ stylesheet: 'menuItem' }),
179
+ isEnabled: () => getTargetKind(app) === 'file',
180
+ isVisible: () => getTargetKind(app) === 'file',
181
+ execute: async () => {
182
+ const targetPath = getTargetPath(app, browser);
183
+ if (targetPath === undefined) {
184
+ return;
185
+ }
186
+ return commands.execute('docmanager:open-browser-tab', {
187
+ path: toServerPath(targetPath)
188
+ });
189
+ }
190
+ });
191
+ commands.addCommand(CommandIDs.rename, {
192
+ label: 'Rename…',
193
+ icon: editIcon.bindprops({ stylesheet: 'menuItem' }),
194
+ mnemonic: 0,
195
+ isEnabled: () => hasTarget(app, browser),
196
+ execute: async () => {
197
+ var _a;
198
+ const targetPath = getTargetPath(app, browser);
199
+ if (targetPath === undefined) {
200
+ return;
201
+ }
202
+ const serverPath = toServerPath(targetPath);
203
+ const oldName = PathExt.basename(serverPath);
204
+ const result = await InputDialog.getText({
205
+ title: 'Rename',
206
+ label: 'Enter a new name',
207
+ text: oldName,
208
+ okLabel: 'Rename'
209
+ });
210
+ const newName = (_a = result.value) === null || _a === void 0 ? void 0 : _a.trim();
211
+ if (result.button.accept !== true ||
212
+ newName === undefined ||
213
+ newName.length === 0 ||
214
+ newName === oldName) {
215
+ return;
216
+ }
217
+ const newPath = PathExt.join(PathExt.dirname(serverPath), newName);
218
+ try {
219
+ await browser.contentsManager.rename(serverPath, newPath);
220
+ browser.refresh();
221
+ }
222
+ catch (err) {
223
+ await showErrorMessage('Rename failed', err);
224
+ }
225
+ }
226
+ });
227
+ commands.addCommand(CommandIDs.del, {
228
+ label: () => PageConfig.getOption('delete_to_trash') === 'true'
229
+ ? 'Move to Trash'
230
+ : 'Delete',
231
+ icon: closeIcon.bindprops({ stylesheet: 'menuItem' }),
232
+ mnemonic: 0,
233
+ isEnabled: () => hasTarget(app, browser),
234
+ execute: async () => {
235
+ const targetPath = getTargetPath(app, browser);
236
+ if (targetPath === undefined) {
237
+ return;
238
+ }
239
+ const serverPath = toServerPath(targetPath);
240
+ const trashing = PageConfig.getOption('delete_to_trash') === 'true';
241
+ const result = await showDialog({
242
+ title: trashing ? 'Move to Trash' : 'Delete',
243
+ body: `Are you sure you want to permanently delete: ${serverPath}?`,
244
+ buttons: [
245
+ Dialog.cancelButton(),
246
+ Dialog.warnButton({ label: trashing ? 'Move to Trash' : 'Delete' })
247
+ ]
248
+ });
249
+ if (result.button.accept !== true) {
250
+ return;
251
+ }
252
+ try {
253
+ await browser.contentsManager.delete(serverPath);
254
+ browser.refresh();
255
+ }
256
+ catch (err) {
257
+ await showErrorMessage('Delete failed', err);
258
+ }
259
+ }
260
+ });
261
+ commands.addCommand(CommandIDs.duplicate, {
262
+ label: 'Duplicate',
263
+ icon: copyIcon.bindprops({ stylesheet: 'menuItem' }),
264
+ isEnabled: () => getTargetKind(app) === 'file',
265
+ isVisible: () => getTargetKind(app) === 'file',
266
+ execute: async () => {
267
+ const targetPath = getTargetPath(app, browser);
268
+ if (targetPath === undefined) {
269
+ return;
270
+ }
271
+ const serverPath = toServerPath(targetPath);
272
+ const dir = PathExt.dirname(serverPath);
273
+ try {
274
+ const created = await browser.contentsManager.copy(serverPath, dir);
275
+ browser.notifyPathAdded(toCanonicalPath(created));
276
+ }
277
+ catch (err) {
278
+ await showErrorMessage('Duplicate failed', err);
279
+ }
280
+ }
281
+ });
282
+ commands.addCommand(CommandIDs.copyPath, {
283
+ label: 'Copy Path',
284
+ icon: fileIcon.bindprops({ stylesheet: 'menuItem' }),
285
+ isEnabled: () => hasTarget(app, browser),
286
+ execute: async () => {
287
+ var _a;
288
+ const targetPath = getTargetPath(app, browser);
289
+ if (targetPath === undefined) {
290
+ return;
291
+ }
292
+ const serverPath = toServerPath(targetPath);
293
+ if (PageConfig.getOption('copyAbsolutePath') === 'true') {
294
+ Clipboard.copyToSystem(PathExt.joinWithLeadingSlash((_a = PageConfig.getOption('serverRoot')) !== null && _a !== void 0 ? _a : '', serverPath));
295
+ }
296
+ else {
297
+ Clipboard.copyToSystem(serverPath);
298
+ }
299
+ }
300
+ });
301
+ commands.addCommand(CommandIDs.download, {
302
+ label: 'Download',
303
+ icon: downloadIcon.bindprops({ stylesheet: 'menuItem' }),
304
+ isEnabled: () => getTargetKind(app) === 'file',
305
+ isVisible: () => getTargetKind(app) === 'file',
306
+ execute: async () => {
307
+ const targetPath = getTargetPath(app, browser);
308
+ if (targetPath === undefined) {
309
+ return;
310
+ }
311
+ const serverPath = toServerPath(targetPath);
312
+ try {
313
+ const url = await browser.contentsManager.getDownloadUrl(serverPath);
314
+ const anchor = document.createElement('a');
315
+ anchor.href = url;
316
+ anchor.download = '';
317
+ document.body.appendChild(anchor);
318
+ anchor.click();
319
+ document.body.removeChild(anchor);
320
+ }
321
+ catch (err) {
322
+ await showErrorMessage('Download failed', err);
323
+ }
324
+ }
325
+ });
326
+ commands.addCommand(CommandIDs.refresh, {
327
+ label: 'Refresh File List',
328
+ caption: 'Refresh the file browser',
329
+ icon: refreshIcon.bindprops({ stylesheet: 'menuItem' }),
330
+ execute: () => {
331
+ browser.refresh();
332
+ }
333
+ });
334
+ commands.addCommand(CommandIDs.createNewDirectory, {
335
+ label: 'New Folder',
336
+ caption: 'Create a new folder',
337
+ icon: newFolderIcon.bindprops({ stylesheet: 'menuItem' }),
338
+ execute: async () => {
339
+ const cwd = getWorkingDirectory(browser);
340
+ try {
341
+ const created = await browser.contentsManager.newUntitled({
342
+ path: cwd,
343
+ type: 'directory'
344
+ });
345
+ browser.notifyPathAdded(toCanonicalPath(created));
346
+ }
347
+ catch (err) {
348
+ await showErrorMessage('Could not create folder', err);
349
+ }
350
+ }
351
+ });
352
+ commands.addCommand(CommandIDs.newLauncher, {
353
+ label: 'New Launcher',
354
+ caption: 'Open a new launcher',
355
+ // Skip the `menuItem` bindprops the other commands use: this command
356
+ // lives on the toolbar, where the launcher-extension's blue button
357
+ // styling expects the raw `jp-icon3` paths from `addIcon` so it can
358
+ // recolor them against the brand background.
359
+ icon: addIcon,
360
+ execute: () => {
361
+ const cwd = getWorkingDirectory(browser);
362
+ return commands.execute('launcher:create', { cwd });
363
+ }
364
+ });
365
+ // Re-evaluate enabled/visible state when the user changes the tree
366
+ // selection, in case a command's predicate depends on the selection.
367
+ browser.selectionChanged.connect(() => {
368
+ for (const id of Object.values(CommandIDs)) {
369
+ commands.notifyCommandChanged(id);
370
+ }
371
+ });
372
+ // The static items are declared in `schema/plugin.json` under
373
+ // `jupyter.lab.menus.context` so users can override or disable them. The
374
+ // schema also declares an empty submenu placeholder with id
375
+ // `OPEN_WITH_SUBMENU_ID`; we fill it on every open from the document
376
+ // factories that can open the right-clicked file.
377
+ const updateOpenWithMenu = makeOpenWithUpdater(app, browser);
378
+ app.contextMenu.opened.connect(updateOpenWithMenu);
379
+ return () => {
380
+ app.contextMenu.opened.disconnect(updateOpenWithMenu);
381
+ };
382
+ }
383
+ /**
384
+ * The names we register with the toolbar. Kept distinct from
385
+ * {@link CommandIDs} because the toolbar API uses opaque names rather
386
+ * than commands.
387
+ */
388
+ export var ToolbarNames;
389
+ (function (ToolbarNames) {
390
+ ToolbarNames.newLauncher = 'new-launcher';
391
+ ToolbarNames.newDirectory = 'new-directory';
392
+ ToolbarNames.refresh = 'refresh';
393
+ })(ToolbarNames || (ToolbarNames = {}));
394
+ /**
395
+ * Populate the file browser's toolbar with the buttons that mirror the
396
+ * default JupyterLab file browser: a "+" launcher button, a new-folder
397
+ * button, and a refresh button. Returns a list of button names so callers
398
+ * can extend it or remove items if they need to.
399
+ */
400
+ export function populateToolbar(opts) {
401
+ const { app, browser } = opts;
402
+ const { commands } = app;
403
+ browser.toolbar.addItem(ToolbarNames.newLauncher, new CommandToolbarButton({
404
+ commands,
405
+ id: CommandIDs.newLauncher,
406
+ label: ''
407
+ }));
408
+ browser.toolbar.addItem(ToolbarNames.newDirectory, new CommandToolbarButton({
409
+ commands,
410
+ id: CommandIDs.createNewDirectory,
411
+ label: ''
412
+ }));
413
+ browser.toolbar.addItem(ToolbarNames.refresh, new CommandToolbarButton({
414
+ commands,
415
+ id: CommandIDs.refresh,
416
+ label: ''
417
+ }));
418
+ }
@@ -0,0 +1,28 @@
1
+ import { Contents } from '@jupyterlab/services';
2
+ /**
3
+ * The canonical path used by the root of the in-memory load-state map.
4
+ * The tree itself does not surface a path for its root.
5
+ */
6
+ export declare const ROOT_LOAD_KEY = "";
7
+ /**
8
+ * Convert a {@link Contents.IModel} into the canonical path expected by
9
+ * `@pierre/trees`. Directories carry a trailing slash so empty directories can
10
+ * still be represented in the flat path list.
11
+ */
12
+ export declare function toCanonicalPath(child: Contents.IModel): string;
13
+ /**
14
+ * Strip the trailing slash that `@pierre/trees` uses for canonical directory
15
+ * paths so the value can be sent to the Jupyter contents API.
16
+ */
17
+ export declare function toServerPath(canonicalPath: string): string;
18
+ export interface IListedDirectory {
19
+ /** Canonical paths for every immediate child of the requested directory. */
20
+ paths: string[];
21
+ /** Subset of {@link IListedDirectory.paths} that are directories. */
22
+ subdirectories: string[];
23
+ }
24
+ /**
25
+ * Fetch the immediate children of a directory through the Jupyter contents API
26
+ * and return them as canonical `@pierre/trees` paths.
27
+ */
28
+ export declare function listDirectory(manager: Contents.IManager, serverPath: string): Promise<IListedDirectory>;
@@ -0,0 +1,48 @@
1
+ const DIR_SUFFIX = '/';
2
+ /**
3
+ * The canonical path used by the root of the in-memory load-state map.
4
+ * The tree itself does not surface a path for its root.
5
+ */
6
+ export const ROOT_LOAD_KEY = '';
7
+ /**
8
+ * Convert a {@link Contents.IModel} into the canonical path expected by
9
+ * `@pierre/trees`. Directories carry a trailing slash so empty directories can
10
+ * still be represented in the flat path list.
11
+ */
12
+ export function toCanonicalPath(child) {
13
+ return child.type === 'directory' ? `${child.path}${DIR_SUFFIX}` : child.path;
14
+ }
15
+ /**
16
+ * Strip the trailing slash that `@pierre/trees` uses for canonical directory
17
+ * paths so the value can be sent to the Jupyter contents API.
18
+ */
19
+ export function toServerPath(canonicalPath) {
20
+ if (canonicalPath === ROOT_LOAD_KEY) {
21
+ return '';
22
+ }
23
+ return canonicalPath.endsWith(DIR_SUFFIX)
24
+ ? canonicalPath.slice(0, -DIR_SUFFIX.length)
25
+ : canonicalPath;
26
+ }
27
+ /**
28
+ * Fetch the immediate children of a directory through the Jupyter contents API
29
+ * and return them as canonical `@pierre/trees` paths.
30
+ */
31
+ export async function listDirectory(manager, serverPath) {
32
+ var _a;
33
+ const model = await manager.get(serverPath, { content: true });
34
+ if (model.type !== 'directory') {
35
+ throw new Error(`Expected a directory at "${serverPath}", got "${model.type}"`);
36
+ }
37
+ const children = (_a = model.content) !== null && _a !== void 0 ? _a : [];
38
+ const paths = [];
39
+ const subdirectories = [];
40
+ for (const child of children) {
41
+ const canonical = toCanonicalPath(child);
42
+ paths.push(canonical);
43
+ if (child.type === 'directory') {
44
+ subdirectories.push(canonical);
45
+ }
46
+ }
47
+ return { paths, subdirectories };
48
+ }
@@ -0,0 +1,24 @@
1
+ import * as React from 'react';
2
+ import { IDocumentManager } from '@jupyterlab/docmanager';
3
+ import { Contents } from '@jupyterlab/services';
4
+ import type { XtralabFileBrowser } from './widget';
5
+ export interface IFileBrowserProps {
6
+ contentsManager: Contents.IManager;
7
+ docManager: IDocumentManager;
8
+ onOpenFile?: (serverPath: string) => void;
9
+ /**
10
+ * The host widget. Selection-change events are pushed up so context-menu
11
+ * commands can react to what the user has selected.
12
+ */
13
+ widget?: XtralabFileBrowser;
14
+ }
15
+ /**
16
+ * Renders a `@pierre/trees` file tree backed by the Jupyter contents API.
17
+ *
18
+ * The Jupyter contents API only returns one directory level per request, so
19
+ * the tree is populated lazily: the root is fetched on mount, and each
20
+ * directory is fetched the first time the user expands it. Expansion is
21
+ * detected by subscribing to the model and diffing against an in-memory load
22
+ * state map.
23
+ */
24
+ export declare function FileBrowserComponent(props: IFileBrowserProps): React.ReactElement;