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
@@ -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
+ }