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
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { JupyterFrontEnd } from '@jupyterlab/application';
|
|
2
|
+
import {
|
|
3
|
+
Clipboard,
|
|
4
|
+
Dialog,
|
|
5
|
+
InputDialog,
|
|
6
|
+
showDialog,
|
|
7
|
+
showErrorMessage
|
|
8
|
+
} from '@jupyterlab/apputils';
|
|
9
|
+
import { PageConfig, PathExt } from '@jupyterlab/coreutils';
|
|
10
|
+
import {
|
|
11
|
+
addIcon,
|
|
12
|
+
closeIcon,
|
|
13
|
+
CommandToolbarButton,
|
|
14
|
+
copyIcon,
|
|
15
|
+
downloadIcon,
|
|
16
|
+
editIcon,
|
|
17
|
+
fileIcon,
|
|
18
|
+
IDisposableMenuItem,
|
|
19
|
+
newFolderIcon,
|
|
20
|
+
RankedMenu,
|
|
21
|
+
refreshIcon
|
|
22
|
+
} from '@jupyterlab/ui-components';
|
|
23
|
+
import { ReadonlyPartialJSONObject } from '@lumino/coreutils';
|
|
24
|
+
import { ContextMenu } from '@lumino/widgets';
|
|
25
|
+
|
|
26
|
+
import { toCanonicalPath, toServerPath } from './contents';
|
|
27
|
+
import { IXtralabFileBrowser } from './widget';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Command identifiers exposed by the Xtralab browser. We deliberately namespace
|
|
31
|
+
* these under `xtralab:` rather than reusing the `filebrowser:` ids
|
|
32
|
+
* because the core `filebrowser:*` commands look up a `FileBrowser` instance
|
|
33
|
+
* via `IFileBrowserFactory.tracker` — our widget is not a `FileBrowser`, so
|
|
34
|
+
* those handlers would never see it.
|
|
35
|
+
*/
|
|
36
|
+
export namespace CommandIDs {
|
|
37
|
+
export const open = 'xtralab:open';
|
|
38
|
+
export const openBrowserTab = 'xtralab:open-browser-tab';
|
|
39
|
+
export const rename = 'xtralab:rename';
|
|
40
|
+
export const del = 'xtralab:delete';
|
|
41
|
+
export const duplicate = 'xtralab:duplicate';
|
|
42
|
+
export const copyPath = 'xtralab:copy-path';
|
|
43
|
+
export const download = 'xtralab:download';
|
|
44
|
+
export const refresh = 'xtralab:refresh';
|
|
45
|
+
export const createNewDirectory = 'xtralab:create-new-directory';
|
|
46
|
+
export const newLauncher = 'xtralab:new-launcher';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Submenu id used to host the dynamically populated "Open With" entries.
|
|
51
|
+
* Distinct from the core file browser's `jp-contextmenu-open-with` so the
|
|
52
|
+
* core's populator does not try to fill ours with items derived from the
|
|
53
|
+
* default file browser's selection.
|
|
54
|
+
*/
|
|
55
|
+
export const OPEN_WITH_SUBMENU_ID = 'jp-contextmenu-xtralab-open-with';
|
|
56
|
+
|
|
57
|
+
export interface IRegisterCommandsOptions {
|
|
58
|
+
app: JupyterFrontEnd;
|
|
59
|
+
browser: IXtralabFileBrowser;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the path of the item the user right-clicked. We prefer the
|
|
64
|
+
* contextmenu event's target — that is how `app.contextMenu` resolves which
|
|
65
|
+
* items match a selector — and fall back to the first item in the tree's
|
|
66
|
+
* selection if the command was invoked without an active contextmenu event
|
|
67
|
+
* (e.g. from the command palette).
|
|
68
|
+
*/
|
|
69
|
+
function getTargetPath(
|
|
70
|
+
app: JupyterFrontEnd,
|
|
71
|
+
browser: IXtralabFileBrowser
|
|
72
|
+
): string | undefined {
|
|
73
|
+
const node = app.contextMenuHitTest(
|
|
74
|
+
n => n.dataset !== undefined && n.dataset.type === 'item'
|
|
75
|
+
);
|
|
76
|
+
const fromContext = node?.dataset.itemPath;
|
|
77
|
+
if (fromContext !== undefined && fromContext.length > 0) {
|
|
78
|
+
return fromContext;
|
|
79
|
+
}
|
|
80
|
+
return browser.selectedPaths[0];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the kind of the item that was right-clicked, based on the data
|
|
85
|
+
* attributes emitted by `@pierre/trees` rows. Returns `undefined` if no
|
|
86
|
+
* contextmenu event is active or the target is not a tree item.
|
|
87
|
+
*/
|
|
88
|
+
function getTargetKind(app: JupyterFrontEnd): 'file' | 'folder' | undefined {
|
|
89
|
+
const node = app.contextMenuHitTest(
|
|
90
|
+
n => n.dataset !== undefined && n.dataset.type === 'item'
|
|
91
|
+
);
|
|
92
|
+
const kind = node?.dataset.itemType;
|
|
93
|
+
return kind === 'file' || kind === 'folder' ? kind : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Resolve the canonical paths the "Open" command should act on. When the
|
|
98
|
+
* user right-clicks a row that is part of the current selection, every
|
|
99
|
+
* selected file is opened — matching the default file browser, where
|
|
100
|
+
* "Open" on any file in a multi-selection opens them all. If the
|
|
101
|
+
* right-clicked row is *not* in the selection, only that row is opened.
|
|
102
|
+
*
|
|
103
|
+
* Folders are always filtered out: there is no "current directory" in
|
|
104
|
+
* this tree, so opening a folder via "Open" is a no-op.
|
|
105
|
+
*/
|
|
106
|
+
function getOpenPaths(
|
|
107
|
+
app: JupyterFrontEnd,
|
|
108
|
+
browser: IXtralabFileBrowser
|
|
109
|
+
): string[] {
|
|
110
|
+
const node = app.contextMenuHitTest(
|
|
111
|
+
n => n.dataset !== undefined && n.dataset.type === 'item'
|
|
112
|
+
);
|
|
113
|
+
const fromContext = node?.dataset.itemPath;
|
|
114
|
+
const selection = browser.selectedPaths;
|
|
115
|
+
|
|
116
|
+
let candidates: readonly string[];
|
|
117
|
+
if (fromContext !== undefined && fromContext.length > 0) {
|
|
118
|
+
candidates = selection.includes(fromContext) ? selection : [fromContext];
|
|
119
|
+
} else {
|
|
120
|
+
candidates = selection;
|
|
121
|
+
}
|
|
122
|
+
return candidates.filter(path => !path.endsWith('/'));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** True iff there is an actionable target for a command on the right-click. */
|
|
126
|
+
function hasTarget(app: JupyterFrontEnd, browser: IXtralabFileBrowser): boolean {
|
|
127
|
+
return getTargetPath(app, browser) !== undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the directory used as the working directory for actions that
|
|
132
|
+
* create a new file or folder. Folder selections become their own cwd;
|
|
133
|
+
* file selections fall back to their parent. Returns the empty string
|
|
134
|
+
* when the tree has no selection — that matches the contents API's
|
|
135
|
+
* convention for "the root directory".
|
|
136
|
+
*/
|
|
137
|
+
function getWorkingDirectory(browser: IXtralabFileBrowser): string {
|
|
138
|
+
const first = browser.selectedPaths[0];
|
|
139
|
+
if (first === undefined || first.length === 0) {
|
|
140
|
+
return '';
|
|
141
|
+
}
|
|
142
|
+
const serverPath = toServerPath(first);
|
|
143
|
+
if (first.endsWith('/')) {
|
|
144
|
+
return serverPath;
|
|
145
|
+
}
|
|
146
|
+
return PathExt.dirname(serverPath);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Refresh the "Open With" submenu's items from the document factories that
|
|
151
|
+
* can open the file under the right-click. Mirrors the
|
|
152
|
+
* `@jupyterlab/filebrowser-extension:open-with` pattern: the schema declares
|
|
153
|
+
* an empty submenu by id, and we look it up on the open context menu and
|
|
154
|
+
* fill it before the user can hover.
|
|
155
|
+
*
|
|
156
|
+
* `preferredWidgetFactories(path)` is path-only, so the populator stays
|
|
157
|
+
* synchronous and the submenu is ready before the user can hover over it.
|
|
158
|
+
*/
|
|
159
|
+
function makeOpenWithUpdater(
|
|
160
|
+
app: JupyterFrontEnd,
|
|
161
|
+
browser: IXtralabFileBrowser
|
|
162
|
+
): (contextMenu: ContextMenu) => void {
|
|
163
|
+
let items: IDisposableMenuItem[] = [];
|
|
164
|
+
|
|
165
|
+
return (contextMenu: ContextMenu): void => {
|
|
166
|
+
items.forEach(item => item.dispose());
|
|
167
|
+
items = [];
|
|
168
|
+
|
|
169
|
+
const submenu =
|
|
170
|
+
(contextMenu.menu.items.find(
|
|
171
|
+
item =>
|
|
172
|
+
item.type === 'submenu' && item.submenu?.id === OPEN_WITH_SUBMENU_ID
|
|
173
|
+
)?.submenu as RankedMenu | undefined) ?? null;
|
|
174
|
+
if (submenu === null) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
submenu.clearItems();
|
|
178
|
+
|
|
179
|
+
if (getTargetKind(app) !== 'file') {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const targetPath = getTargetPath(app, browser);
|
|
183
|
+
if (targetPath === undefined) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const serverPath = toServerPath(targetPath);
|
|
187
|
+
const factories = app.docRegistry.preferredWidgetFactories(serverPath);
|
|
188
|
+
items = factories.map(factory =>
|
|
189
|
+
submenu.addItem({
|
|
190
|
+
args: { factory: factory.name, label: factory.label || factory.name },
|
|
191
|
+
command: CommandIDs.open
|
|
192
|
+
})
|
|
193
|
+
);
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Register every Xtralab command on the application command registry, attach
|
|
199
|
+
* the items to the application context menu, and wire up the dynamic
|
|
200
|
+
* "Open With" submenu populator.
|
|
201
|
+
*
|
|
202
|
+
* @returns A function that detaches the menu items and signal listener.
|
|
203
|
+
* The commands themselves are owned by the registry for the lifetime of
|
|
204
|
+
* the plugin.
|
|
205
|
+
*/
|
|
206
|
+
export function registerCommands(opts: IRegisterCommandsOptions): () => void {
|
|
207
|
+
const { app, browser } = opts;
|
|
208
|
+
const { commands } = app;
|
|
209
|
+
|
|
210
|
+
commands.addCommand(CommandIDs.open, {
|
|
211
|
+
label: args =>
|
|
212
|
+
((args.label as string) ?? (args.factory as string) ?? 'Open') as string,
|
|
213
|
+
icon: args => {
|
|
214
|
+
const factoryName = args.factory as string | undefined;
|
|
215
|
+
if (factoryName !== undefined) {
|
|
216
|
+
const fileType = app.docRegistry.getFileType(factoryName);
|
|
217
|
+
return fileType?.icon?.bindprops({ stylesheet: 'menuItem' });
|
|
218
|
+
}
|
|
219
|
+
return fileIcon.bindprops({ stylesheet: 'menuItem' });
|
|
220
|
+
},
|
|
221
|
+
mnemonic: 0,
|
|
222
|
+
isEnabled: () => getOpenPaths(app, browser).length > 0,
|
|
223
|
+
execute: async (args: ReadonlyPartialJSONObject) => {
|
|
224
|
+
const targets = getOpenPaths(app, browser);
|
|
225
|
+
if (targets.length === 0) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const factory = (args.factory as string | undefined) ?? undefined;
|
|
229
|
+
await Promise.all(
|
|
230
|
+
targets.map(target =>
|
|
231
|
+
commands.execute('docmanager:open', {
|
|
232
|
+
path: toServerPath(target),
|
|
233
|
+
factory
|
|
234
|
+
})
|
|
235
|
+
)
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
commands.addCommand(CommandIDs.openBrowserTab, {
|
|
241
|
+
label: 'Open in New Browser Tab',
|
|
242
|
+
icon: fileIcon.bindprops({ stylesheet: 'menuItem' }),
|
|
243
|
+
isEnabled: () => getTargetKind(app) === 'file',
|
|
244
|
+
isVisible: () => getTargetKind(app) === 'file',
|
|
245
|
+
execute: async () => {
|
|
246
|
+
const targetPath = getTargetPath(app, browser);
|
|
247
|
+
if (targetPath === undefined) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
return commands.execute('docmanager:open-browser-tab', {
|
|
251
|
+
path: toServerPath(targetPath)
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
commands.addCommand(CommandIDs.rename, {
|
|
257
|
+
label: 'Rename…',
|
|
258
|
+
icon: editIcon.bindprops({ stylesheet: 'menuItem' }),
|
|
259
|
+
mnemonic: 0,
|
|
260
|
+
isEnabled: () => hasTarget(app, browser),
|
|
261
|
+
execute: async () => {
|
|
262
|
+
const targetPath = getTargetPath(app, browser);
|
|
263
|
+
if (targetPath === undefined) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const serverPath = toServerPath(targetPath);
|
|
267
|
+
const oldName = PathExt.basename(serverPath);
|
|
268
|
+
const result = await InputDialog.getText({
|
|
269
|
+
title: 'Rename',
|
|
270
|
+
label: 'Enter a new name',
|
|
271
|
+
text: oldName,
|
|
272
|
+
okLabel: 'Rename'
|
|
273
|
+
});
|
|
274
|
+
const newName = result.value?.trim();
|
|
275
|
+
if (
|
|
276
|
+
result.button.accept !== true ||
|
|
277
|
+
newName === undefined ||
|
|
278
|
+
newName.length === 0 ||
|
|
279
|
+
newName === oldName
|
|
280
|
+
) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const newPath = PathExt.join(PathExt.dirname(serverPath), newName);
|
|
284
|
+
try {
|
|
285
|
+
await browser.contentsManager.rename(serverPath, newPath);
|
|
286
|
+
browser.refresh();
|
|
287
|
+
} catch (err) {
|
|
288
|
+
await showErrorMessage('Rename failed', err as Error);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
commands.addCommand(CommandIDs.del, {
|
|
294
|
+
label: () =>
|
|
295
|
+
PageConfig.getOption('delete_to_trash') === 'true'
|
|
296
|
+
? 'Move to Trash'
|
|
297
|
+
: 'Delete',
|
|
298
|
+
icon: closeIcon.bindprops({ stylesheet: 'menuItem' }),
|
|
299
|
+
mnemonic: 0,
|
|
300
|
+
isEnabled: () => hasTarget(app, browser),
|
|
301
|
+
execute: async () => {
|
|
302
|
+
const targetPath = getTargetPath(app, browser);
|
|
303
|
+
if (targetPath === undefined) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const serverPath = toServerPath(targetPath);
|
|
307
|
+
const trashing = PageConfig.getOption('delete_to_trash') === 'true';
|
|
308
|
+
const result = await showDialog({
|
|
309
|
+
title: trashing ? 'Move to Trash' : 'Delete',
|
|
310
|
+
body: `Are you sure you want to permanently delete: ${serverPath}?`,
|
|
311
|
+
buttons: [
|
|
312
|
+
Dialog.cancelButton(),
|
|
313
|
+
Dialog.warnButton({ label: trashing ? 'Move to Trash' : 'Delete' })
|
|
314
|
+
]
|
|
315
|
+
});
|
|
316
|
+
if (result.button.accept !== true) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
await browser.contentsManager.delete(serverPath);
|
|
321
|
+
browser.refresh();
|
|
322
|
+
} catch (err) {
|
|
323
|
+
await showErrorMessage('Delete failed', err as Error);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
commands.addCommand(CommandIDs.duplicate, {
|
|
329
|
+
label: 'Duplicate',
|
|
330
|
+
icon: copyIcon.bindprops({ stylesheet: 'menuItem' }),
|
|
331
|
+
isEnabled: () => getTargetKind(app) === 'file',
|
|
332
|
+
isVisible: () => getTargetKind(app) === 'file',
|
|
333
|
+
execute: async () => {
|
|
334
|
+
const targetPath = getTargetPath(app, browser);
|
|
335
|
+
if (targetPath === undefined) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const serverPath = toServerPath(targetPath);
|
|
339
|
+
const dir = PathExt.dirname(serverPath);
|
|
340
|
+
try {
|
|
341
|
+
const created = await browser.contentsManager.copy(serverPath, dir);
|
|
342
|
+
browser.notifyPathAdded(toCanonicalPath(created));
|
|
343
|
+
} catch (err) {
|
|
344
|
+
await showErrorMessage('Duplicate failed', err as Error);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
commands.addCommand(CommandIDs.copyPath, {
|
|
350
|
+
label: 'Copy Path',
|
|
351
|
+
icon: fileIcon.bindprops({ stylesheet: 'menuItem' }),
|
|
352
|
+
isEnabled: () => hasTarget(app, browser),
|
|
353
|
+
execute: async () => {
|
|
354
|
+
const targetPath = getTargetPath(app, browser);
|
|
355
|
+
if (targetPath === undefined) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const serverPath = toServerPath(targetPath);
|
|
359
|
+
if (PageConfig.getOption('copyAbsolutePath') === 'true') {
|
|
360
|
+
Clipboard.copyToSystem(
|
|
361
|
+
PathExt.joinWithLeadingSlash(
|
|
362
|
+
PageConfig.getOption('serverRoot') ?? '',
|
|
363
|
+
serverPath
|
|
364
|
+
)
|
|
365
|
+
);
|
|
366
|
+
} else {
|
|
367
|
+
Clipboard.copyToSystem(serverPath);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
commands.addCommand(CommandIDs.download, {
|
|
373
|
+
label: 'Download',
|
|
374
|
+
icon: downloadIcon.bindprops({ stylesheet: 'menuItem' }),
|
|
375
|
+
isEnabled: () => getTargetKind(app) === 'file',
|
|
376
|
+
isVisible: () => getTargetKind(app) === 'file',
|
|
377
|
+
execute: async () => {
|
|
378
|
+
const targetPath = getTargetPath(app, browser);
|
|
379
|
+
if (targetPath === undefined) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const serverPath = toServerPath(targetPath);
|
|
383
|
+
try {
|
|
384
|
+
const url = await browser.contentsManager.getDownloadUrl(serverPath);
|
|
385
|
+
const anchor = document.createElement('a');
|
|
386
|
+
anchor.href = url;
|
|
387
|
+
anchor.download = '';
|
|
388
|
+
document.body.appendChild(anchor);
|
|
389
|
+
anchor.click();
|
|
390
|
+
document.body.removeChild(anchor);
|
|
391
|
+
} catch (err) {
|
|
392
|
+
await showErrorMessage('Download failed', err as Error);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
commands.addCommand(CommandIDs.refresh, {
|
|
398
|
+
label: 'Refresh File List',
|
|
399
|
+
caption: 'Refresh the file browser',
|
|
400
|
+
icon: refreshIcon.bindprops({ stylesheet: 'menuItem' }),
|
|
401
|
+
execute: () => {
|
|
402
|
+
browser.refresh();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
commands.addCommand(CommandIDs.createNewDirectory, {
|
|
407
|
+
label: 'New Folder',
|
|
408
|
+
caption: 'Create a new folder',
|
|
409
|
+
icon: newFolderIcon.bindprops({ stylesheet: 'menuItem' }),
|
|
410
|
+
execute: async () => {
|
|
411
|
+
const cwd = getWorkingDirectory(browser);
|
|
412
|
+
try {
|
|
413
|
+
const created = await browser.contentsManager.newUntitled({
|
|
414
|
+
path: cwd,
|
|
415
|
+
type: 'directory'
|
|
416
|
+
});
|
|
417
|
+
browser.notifyPathAdded(toCanonicalPath(created));
|
|
418
|
+
} catch (err) {
|
|
419
|
+
await showErrorMessage('Could not create folder', err as Error);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
commands.addCommand(CommandIDs.newLauncher, {
|
|
425
|
+
label: 'New Launcher',
|
|
426
|
+
caption: 'Open a new launcher',
|
|
427
|
+
// Skip the `menuItem` bindprops the other commands use: this command
|
|
428
|
+
// lives on the toolbar, where the launcher-extension's blue button
|
|
429
|
+
// styling expects the raw `jp-icon3` paths from `addIcon` so it can
|
|
430
|
+
// recolor them against the brand background.
|
|
431
|
+
icon: addIcon,
|
|
432
|
+
execute: () => {
|
|
433
|
+
const cwd = getWorkingDirectory(browser);
|
|
434
|
+
return commands.execute('launcher:create', { cwd });
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
// Re-evaluate enabled/visible state when the user changes the tree
|
|
439
|
+
// selection, in case a command's predicate depends on the selection.
|
|
440
|
+
browser.selectionChanged.connect(() => {
|
|
441
|
+
for (const id of Object.values(CommandIDs)) {
|
|
442
|
+
commands.notifyCommandChanged(id);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
// The static items are declared in `schema/plugin.json` under
|
|
447
|
+
// `jupyter.lab.menus.context` so users can override or disable them. The
|
|
448
|
+
// schema also declares an empty submenu placeholder with id
|
|
449
|
+
// `OPEN_WITH_SUBMENU_ID`; we fill it on every open from the document
|
|
450
|
+
// factories that can open the right-clicked file.
|
|
451
|
+
const updateOpenWithMenu = makeOpenWithUpdater(app, browser);
|
|
452
|
+
app.contextMenu.opened.connect(updateOpenWithMenu);
|
|
453
|
+
|
|
454
|
+
return () => {
|
|
455
|
+
app.contextMenu.opened.disconnect(updateOpenWithMenu);
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The names we register with the toolbar. Kept distinct from
|
|
461
|
+
* {@link CommandIDs} because the toolbar API uses opaque names rather
|
|
462
|
+
* than commands.
|
|
463
|
+
*/
|
|
464
|
+
export namespace ToolbarNames {
|
|
465
|
+
export const newLauncher = 'new-launcher';
|
|
466
|
+
export const newDirectory = 'new-directory';
|
|
467
|
+
export const refresh = 'refresh';
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Populate the file browser's toolbar with the buttons that mirror the
|
|
472
|
+
* default JupyterLab file browser: a "+" launcher button, a new-folder
|
|
473
|
+
* button, and a refresh button. Returns a list of button names so callers
|
|
474
|
+
* can extend it or remove items if they need to.
|
|
475
|
+
*/
|
|
476
|
+
export function populateToolbar(opts: {
|
|
477
|
+
app: JupyterFrontEnd;
|
|
478
|
+
browser: IXtralabFileBrowser;
|
|
479
|
+
}): void {
|
|
480
|
+
const { app, browser } = opts;
|
|
481
|
+
const { commands } = app;
|
|
482
|
+
|
|
483
|
+
browser.toolbar.addItem(
|
|
484
|
+
ToolbarNames.newLauncher,
|
|
485
|
+
new CommandToolbarButton({
|
|
486
|
+
commands,
|
|
487
|
+
id: CommandIDs.newLauncher,
|
|
488
|
+
label: ''
|
|
489
|
+
})
|
|
490
|
+
);
|
|
491
|
+
browser.toolbar.addItem(
|
|
492
|
+
ToolbarNames.newDirectory,
|
|
493
|
+
new CommandToolbarButton({
|
|
494
|
+
commands,
|
|
495
|
+
id: CommandIDs.createNewDirectory,
|
|
496
|
+
label: ''
|
|
497
|
+
})
|
|
498
|
+
);
|
|
499
|
+
browser.toolbar.addItem(
|
|
500
|
+
ToolbarNames.refresh,
|
|
501
|
+
new CommandToolbarButton({
|
|
502
|
+
commands,
|
|
503
|
+
id: CommandIDs.refresh,
|
|
504
|
+
label: ''
|
|
505
|
+
})
|
|
506
|
+
);
|
|
507
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Contents } from '@jupyterlab/services';
|
|
2
|
+
|
|
3
|
+
const DIR_SUFFIX = '/';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The canonical path used by the root of the in-memory load-state map.
|
|
7
|
+
* The tree itself does not surface a path for its root.
|
|
8
|
+
*/
|
|
9
|
+
export const ROOT_LOAD_KEY = '';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Convert a {@link Contents.IModel} into the canonical path expected by
|
|
13
|
+
* `@pierre/trees`. Directories carry a trailing slash so empty directories can
|
|
14
|
+
* still be represented in the flat path list.
|
|
15
|
+
*/
|
|
16
|
+
export function toCanonicalPath(child: Contents.IModel): string {
|
|
17
|
+
return child.type === 'directory' ? `${child.path}${DIR_SUFFIX}` : child.path;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Strip the trailing slash that `@pierre/trees` uses for canonical directory
|
|
22
|
+
* paths so the value can be sent to the Jupyter contents API.
|
|
23
|
+
*/
|
|
24
|
+
export function toServerPath(canonicalPath: string): string {
|
|
25
|
+
if (canonicalPath === ROOT_LOAD_KEY) {
|
|
26
|
+
return '';
|
|
27
|
+
}
|
|
28
|
+
return canonicalPath.endsWith(DIR_SUFFIX)
|
|
29
|
+
? canonicalPath.slice(0, -DIR_SUFFIX.length)
|
|
30
|
+
: canonicalPath;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface IListedDirectory {
|
|
34
|
+
/** Canonical paths for every immediate child of the requested directory. */
|
|
35
|
+
paths: string[];
|
|
36
|
+
/** Subset of {@link IListedDirectory.paths} that are directories. */
|
|
37
|
+
subdirectories: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Fetch the immediate children of a directory through the Jupyter contents API
|
|
42
|
+
* and return them as canonical `@pierre/trees` paths.
|
|
43
|
+
*/
|
|
44
|
+
export async function listDirectory(
|
|
45
|
+
manager: Contents.IManager,
|
|
46
|
+
serverPath: string
|
|
47
|
+
): Promise<IListedDirectory> {
|
|
48
|
+
const model = await manager.get(serverPath, { content: true });
|
|
49
|
+
if (model.type !== 'directory') {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`Expected a directory at "${serverPath}", got "${model.type}"`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const children =
|
|
55
|
+
(model.content as ReadonlyArray<Contents.IModel> | null) ?? [];
|
|
56
|
+
const paths: string[] = [];
|
|
57
|
+
const subdirectories: string[] = [];
|
|
58
|
+
for (const child of children) {
|
|
59
|
+
const canonical = toCanonicalPath(child);
|
|
60
|
+
paths.push(canonical);
|
|
61
|
+
if (child.type === 'directory') {
|
|
62
|
+
subdirectories.push(canonical);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { paths, subdirectories };
|
|
66
|
+
}
|