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.
- package/LICENSE +28 -0
- package/README.md +69 -0
- package/lib/fileBrowser/commands.d.ts +62 -0
- package/lib/fileBrowser/commands.js +418 -0
- package/lib/fileBrowser/contents.d.ts +28 -0
- package/lib/fileBrowser/contents.js +48 -0
- package/lib/fileBrowser/fileBrowser.d.ts +24 -0
- package/lib/fileBrowser/fileBrowser.js +535 -0
- package/lib/fileBrowser/icons.d.ts +37 -0
- package/lib/fileBrowser/icons.js +153 -0
- package/lib/fileBrowser/index.d.ts +7 -0
- package/lib/fileBrowser/index.js +38 -0
- package/lib/fileBrowser/widget.d.ts +97 -0
- package/lib/fileBrowser/widget.js +95 -0
- package/lib/git/api.d.ts +39 -0
- package/lib/git/api.js +164 -0
- package/lib/git/commands.d.ts +69 -0
- package/lib/git/commands.js +67 -0
- package/lib/git/diffWidget.d.ts +108 -0
- package/lib/git/diffWidget.js +624 -0
- package/lib/git/icons.d.ts +14 -0
- package/lib/git/icons.js +19 -0
- package/lib/git/index.d.ts +11 -0
- package/lib/git/index.js +74 -0
- package/lib/git/notebookDiff.d.ts +176 -0
- package/lib/git/notebookDiff.js +805 -0
- package/lib/git/panel.d.ts +29 -0
- package/lib/git/panel.js +42 -0
- package/lib/git/panelComponent.d.ts +26 -0
- package/lib/git/panelComponent.js +300 -0
- package/lib/git/tokens.d.ts +78 -0
- package/lib/git/tokens.js +6 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.js +14 -0
- package/package.json +216 -0
- package/schema/plugin.json +67 -0
- package/src/fileBrowser/commands.ts +507 -0
- package/src/fileBrowser/contents.ts +66 -0
- package/src/fileBrowser/fileBrowser.tsx +635 -0
- package/src/fileBrowser/icons.ts +172 -0
- package/src/fileBrowser/index.ts +56 -0
- package/src/fileBrowser/widget.tsx +202 -0
- package/src/git/api.ts +207 -0
- package/src/git/commands.ts +143 -0
- package/src/git/diffWidget.tsx +903 -0
- package/src/git/icons.ts +20 -0
- package/src/git/index.ts +109 -0
- package/src/git/notebookDiff.tsx +1294 -0
- package/src/git/panel.tsx +56 -0
- package/src/git/panelComponent.tsx +558 -0
- package/src/git/tokens.ts +91 -0
- package/src/index.ts +18 -0
- package/style/base.css +175 -0
- package/style/git.css +770 -0
- package/style/index.css +2 -0
- 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;
|