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,635 @@
1
+ import * as React from 'react';
2
+
3
+ import { IDocumentManager } from '@jupyterlab/docmanager';
4
+ import { Contents } from '@jupyterlab/services';
5
+ import { fileIcon } from '@jupyterlab/ui-components';
6
+ import { MimeData, PromiseDelegate } from '@lumino/coreutils';
7
+ import { Drag } from '@lumino/dragdrop';
8
+
9
+ import { FileTree, useFileTree } from '@pierre/trees/react';
10
+ import type {
11
+ FileTreeBatchOperation,
12
+ FileTreeDirectoryHandle
13
+ } from '@pierre/trees';
14
+
15
+ import { ROOT_LOAD_KEY, listDirectory, toServerPath } from './contents';
16
+ import { FILE_BROWSER_ICONS } from './icons';
17
+ import type { XtralabFileBrowser } from './widget';
18
+
19
+ /**
20
+ * Load state for a directory in the file tree. Directories are tracked from
21
+ * the moment they are first observed (as a child of a loaded parent) so the
22
+ * subscribe/diff loop can decide whether to fetch their contents on expand.
23
+ */
24
+ type LoadState = 'unloaded' | 'loading' | 'loaded';
25
+
26
+ /**
27
+ * MIME types used by the dock panel and other JupyterLab drop targets.
28
+ * `FACTORY_MIME` is the lumino dock-panel contract: the value must be a
29
+ * synchronous function that returns a Widget. The contents MIME types
30
+ * mirror what the default file browser sends so other drop targets that
31
+ * understand them (the file browser itself, custom drop zones) keep
32
+ * working when the user drags out of Xtralab.
33
+ */
34
+ const FACTORY_MIME = 'application/vnd.lumino.widget-factory';
35
+ const CONTENTS_MIME = 'application/x-jupyter-icontents';
36
+
37
+ /**
38
+ * Threshold in pixels before a press-and-drag is treated as a drag rather
39
+ * than a click. Matches the value used by the default JupyterLab listing.
40
+ */
41
+ const DRAG_THRESHOLD = 5;
42
+
43
+ /**
44
+ * The custom-element tag used by `@pierre/trees` for its shadow host. Kept
45
+ * as a constant rather than imported so we don't pay the `@pierre/trees`
46
+ * resolution cost just for one string.
47
+ */
48
+ const FILE_TREE_TAG = 'file-tree-container';
49
+
50
+ export interface IFileBrowserProps {
51
+ contentsManager: Contents.IManager;
52
+ docManager: IDocumentManager;
53
+ onOpenFile?: (serverPath: string) => void;
54
+ /**
55
+ * The host widget. Selection-change events are pushed up so context-menu
56
+ * commands can react to what the user has selected.
57
+ */
58
+ widget?: XtralabFileBrowser;
59
+ }
60
+
61
+ /**
62
+ * Renders a `@pierre/trees` file tree backed by the Jupyter contents API.
63
+ *
64
+ * The Jupyter contents API only returns one directory level per request, so
65
+ * the tree is populated lazily: the root is fetched on mount, and each
66
+ * directory is fetched the first time the user expands it. Expansion is
67
+ * detected by subscribing to the model and diffing against an in-memory load
68
+ * state map.
69
+ */
70
+ export function FileBrowserComponent(
71
+ props: IFileBrowserProps
72
+ ): React.ReactElement {
73
+ const { contentsManager, docManager, onOpenFile, widget } = props;
74
+
75
+ const { model } = useFileTree({
76
+ paths: [],
77
+ initialExpansion: 'closed',
78
+ search: true,
79
+ icons: FILE_BROWSER_ICONS
80
+ });
81
+
82
+ React.useEffect(() => {
83
+ const knownDirs = new Map<string, LoadState>();
84
+ let cancelled = false;
85
+
86
+ const fetchDirectory = async (canonicalPath: string): Promise<void> => {
87
+ const current = knownDirs.get(canonicalPath);
88
+ if (current === 'loading' || current === 'loaded') {
89
+ return;
90
+ }
91
+ knownDirs.set(canonicalPath, 'loading');
92
+ try {
93
+ const serverPath = toServerPath(canonicalPath);
94
+ const { paths, subdirectories } = await listDirectory(
95
+ contentsManager,
96
+ serverPath
97
+ );
98
+ if (cancelled) {
99
+ return;
100
+ }
101
+ if (canonicalPath === ROOT_LOAD_KEY) {
102
+ model.resetPaths(paths);
103
+ } else {
104
+ // Filter out paths already in the model. The path-store throws
105
+ // when an explicit directory is added a second time, so any
106
+ // entry created out-of-band (e.g. by the "new folder" command's
107
+ // `notifyPathAdded` callback) must be skipped here.
108
+ const operations: FileTreeBatchOperation[] = paths
109
+ .filter(path => model.getItem(path) === null)
110
+ .map(path => ({ type: 'add', path }));
111
+ if (operations.length > 0) {
112
+ model.batch(operations);
113
+ }
114
+ }
115
+ for (const subdir of subdirectories) {
116
+ if (!knownDirs.has(subdir)) {
117
+ knownDirs.set(subdir, 'unloaded');
118
+ }
119
+ }
120
+ knownDirs.set(canonicalPath, 'loaded');
121
+ } catch (err) {
122
+ console.error(
123
+ `xtralab: failed to load directory "${canonicalPath}"`,
124
+ err
125
+ );
126
+ knownDirs.set(canonicalPath, 'unloaded');
127
+ }
128
+ };
129
+
130
+ /**
131
+ * Compute the canonical parent path for a given canonical path.
132
+ * Returns {@link ROOT_LOAD_KEY} for top-level entries.
133
+ */
134
+ const parentOf = (canonicalPath: string): string => {
135
+ const trimmed = canonicalPath.endsWith('/')
136
+ ? canonicalPath.slice(0, -1)
137
+ : canonicalPath;
138
+ const idx = trimmed.lastIndexOf('/');
139
+ if (idx < 0) {
140
+ return ROOT_LOAD_KEY;
141
+ }
142
+ return `${trimmed.slice(0, idx)}/`;
143
+ };
144
+
145
+ /**
146
+ * Refresh every directory currently loaded into the tree. Walks down
147
+ * from the root through the previously-expanded subdirectories so the
148
+ * tree state mirrors what's on disk while preserving the user's
149
+ * expansion state.
150
+ */
151
+ const refreshAll = async (): Promise<void> => {
152
+ const expandedPaths = new Set<string>();
153
+ knownDirs.forEach((state, path) => {
154
+ if (path === ROOT_LOAD_KEY || state !== 'loaded') {
155
+ return;
156
+ }
157
+ const item = model.getItem(path);
158
+ if (
159
+ item !== null &&
160
+ item.isDirectory() &&
161
+ (item as FileTreeDirectoryHandle).isExpanded()
162
+ ) {
163
+ expandedPaths.add(path);
164
+ }
165
+ });
166
+
167
+ knownDirs.clear();
168
+ knownDirs.set(ROOT_LOAD_KEY, 'loading');
169
+
170
+ let rootPaths: string[];
171
+ let rootSubdirs: string[];
172
+ try {
173
+ const result = await listDirectory(contentsManager, '');
174
+ rootPaths = result.paths;
175
+ rootSubdirs = result.subdirectories;
176
+ } catch (err) {
177
+ console.error('xtralab: refresh failed at the root', err);
178
+ knownDirs.set(ROOT_LOAD_KEY, 'unloaded');
179
+ return;
180
+ }
181
+ if (cancelled) {
182
+ return;
183
+ }
184
+
185
+ const allPaths: string[] = [...rootPaths];
186
+
187
+ // BFS through the previously-expanded subtree, fetching only the
188
+ // directories the user had opened so the refresh doesn't walk the
189
+ // entire workspace.
190
+ const subdirsByParent = new Map<string, string[]>();
191
+ subdirsByParent.set(ROOT_LOAD_KEY, rootSubdirs);
192
+ const queue = rootSubdirs.filter(s => expandedPaths.has(s));
193
+ while (queue.length > 0) {
194
+ const dir = queue.shift()!;
195
+ try {
196
+ const serverPath = toServerPath(dir);
197
+ const { paths, subdirectories } = await listDirectory(
198
+ contentsManager,
199
+ serverPath
200
+ );
201
+ if (cancelled) {
202
+ return;
203
+ }
204
+ allPaths.push(...paths);
205
+ subdirsByParent.set(dir, subdirectories);
206
+ for (const subdir of subdirectories) {
207
+ if (expandedPaths.has(subdir)) {
208
+ queue.push(subdir);
209
+ }
210
+ }
211
+ } catch (err) {
212
+ console.error(`xtralab: refresh failed for "${dir}"`, err);
213
+ }
214
+ }
215
+
216
+ // Reset the tree contents and restore the load-state map so future
217
+ // expansions know which directories still need fetching.
218
+ model.resetPaths(allPaths);
219
+ knownDirs.set(ROOT_LOAD_KEY, 'loaded');
220
+ subdirsByParent.forEach(subdirs => {
221
+ for (const subdir of subdirs) {
222
+ knownDirs.set(
223
+ subdir,
224
+ expandedPaths.has(subdir) ? 'loaded' : 'unloaded'
225
+ );
226
+ }
227
+ });
228
+
229
+ // Re-expand the directories that were expanded before the refresh.
230
+ // We have to do this after `resetPaths` because the reset starts
231
+ // every directory in its initial collapsed state.
232
+ for (const path of expandedPaths) {
233
+ const item = model.getItem(path);
234
+ if (item !== null && item.isDirectory()) {
235
+ (item as FileTreeDirectoryHandle).expand();
236
+ }
237
+ }
238
+ };
239
+
240
+ /**
241
+ * Insert a newly-created path (typically from "new folder" or
242
+ * "duplicate") into the tree without doing a full refresh. Expands
243
+ * the parent so the user sees the newly created entry immediately.
244
+ */
245
+ const handlePathAdded = (canonicalPath: string): void => {
246
+ if (model.getItem(canonicalPath) === null) {
247
+ try {
248
+ model.add(canonicalPath);
249
+ } catch (err) {
250
+ console.error(
251
+ `xtralab: failed to add path "${canonicalPath}"`,
252
+ err
253
+ );
254
+ return;
255
+ }
256
+ }
257
+ if (canonicalPath.endsWith('/') && !knownDirs.has(canonicalPath)) {
258
+ // The new directory has no children yet, so mark it as already
259
+ // loaded — there's nothing to fetch and we don't want a stale
260
+ // "unloaded" entry to trigger a fetch on the next expand.
261
+ knownDirs.set(canonicalPath, 'loaded');
262
+ }
263
+ const parent = parentOf(canonicalPath);
264
+ if (parent === ROOT_LOAD_KEY) {
265
+ return;
266
+ }
267
+ const parentItem = model.getItem(parent);
268
+ if (parentItem === null || !parentItem.isDirectory()) {
269
+ return;
270
+ }
271
+ const parentDir = parentItem as FileTreeDirectoryHandle;
272
+ if (!parentDir.isExpanded()) {
273
+ parentDir.expand();
274
+ }
275
+ };
276
+
277
+ knownDirs.set(ROOT_LOAD_KEY, 'unloaded');
278
+ void fetchDirectory(ROOT_LOAD_KEY);
279
+
280
+ const unsubscribe = model.subscribe(() => {
281
+ knownDirs.forEach((state, canonicalPath) => {
282
+ if (state !== 'unloaded') {
283
+ return;
284
+ }
285
+ if (canonicalPath === ROOT_LOAD_KEY) {
286
+ return;
287
+ }
288
+ const item = model.getItem(canonicalPath);
289
+ if (
290
+ item !== null &&
291
+ item.isDirectory() &&
292
+ (item as FileTreeDirectoryHandle).isExpanded()
293
+ ) {
294
+ void fetchDirectory(canonicalPath);
295
+ }
296
+ });
297
+ });
298
+
299
+ let refreshSlot: (() => void) | undefined;
300
+ let pathAddedSlot: ((sender: unknown, path: string) => void) | undefined;
301
+ if (widget !== undefined) {
302
+ refreshSlot = (): void => {
303
+ void refreshAll();
304
+ };
305
+ pathAddedSlot = (_sender, path): void => {
306
+ handlePathAdded(path);
307
+ };
308
+ widget.refreshRequested.connect(refreshSlot);
309
+ widget.pathAdded.connect(pathAddedSlot);
310
+ }
311
+
312
+ return () => {
313
+ cancelled = true;
314
+ unsubscribe();
315
+ if (widget !== undefined) {
316
+ if (refreshSlot !== undefined) {
317
+ widget.refreshRequested.disconnect(refreshSlot);
318
+ }
319
+ if (pathAddedSlot !== undefined) {
320
+ widget.pathAdded.disconnect(pathAddedSlot);
321
+ }
322
+ }
323
+ };
324
+ }, [model, contentsManager, widget]);
325
+
326
+ // Bridge the tree's selection state up to the widget so command handlers
327
+ // can read it without depending on React internals. The tree exposes its
328
+ // selection through `getSelectedPaths()` and emits a generic notification
329
+ // through `subscribe`, so we diff against a snapshot to avoid spamming the
330
+ // widget on every unrelated mutation.
331
+ React.useEffect(() => {
332
+ if (widget === undefined) {
333
+ return;
334
+ }
335
+ let lastSnapshot: readonly string[] = [];
336
+ const sync = (): void => {
337
+ const next = model.getSelectedPaths();
338
+ if (
339
+ next.length === lastSnapshot.length &&
340
+ next.every((path, index) => path === lastSnapshot[index])
341
+ ) {
342
+ return;
343
+ }
344
+ lastSnapshot = next;
345
+ widget.updateSelection(next);
346
+ };
347
+ sync();
348
+ const unsubscribe = model.subscribe(sync);
349
+ return () => {
350
+ unsubscribe();
351
+ };
352
+ }, [model, widget]);
353
+
354
+ const wrapperRef = React.useRef<HTMLDivElement | null>(null);
355
+
356
+ React.useEffect(() => {
357
+ const wrapper = wrapperRef.current;
358
+ if (wrapper === null || onOpenFile === undefined) {
359
+ return;
360
+ }
361
+ const handleDoubleClick = (event: MouseEvent): void => {
362
+ for (const target of event.composedPath()) {
363
+ if (!(target instanceof HTMLElement)) {
364
+ continue;
365
+ }
366
+ if (target.dataset.type !== 'item') {
367
+ continue;
368
+ }
369
+ if (target.dataset.itemType !== 'file') {
370
+ return;
371
+ }
372
+ const itemPath = target.dataset.itemPath;
373
+ if (itemPath !== undefined && itemPath.length > 0) {
374
+ event.preventDefault();
375
+ onOpenFile(toServerPath(itemPath));
376
+ }
377
+ return;
378
+ }
379
+ };
380
+ wrapper.addEventListener('dblclick', handleDoubleClick);
381
+ return () => {
382
+ wrapper.removeEventListener('dblclick', handleDoubleClick);
383
+ };
384
+ }, [onOpenFile]);
385
+
386
+ // Bridge contextmenu events out of the `<file-tree-container>` shadow DOM.
387
+ //
388
+ // `@pierre/trees` mounts the tree under an open shadow root attached to the
389
+ // `<file-tree-container>` custom element. When the user right-clicks a row
390
+ // inside the shadow tree, the event is retargeted to the host element when
391
+ // observed from the light DOM, and `app.contextMenu` walks via
392
+ // `parentElement` — it never enters the shadow tree, so the `[data-type=
393
+ // "item"]` selectors registered in `schema/plugin.json` never match.
394
+ //
395
+ // We listen in the capture phase (so we run before the application's
396
+ // document-level handler) and copy the right-clicked row's data attributes
397
+ // onto the host. Lumino then matches the host as if it were the row, and
398
+ // `app.contextMenuHitTest` from command handlers reads the same attributes
399
+ // back to recover the path. When the right-click misses any row we clear
400
+ // the attributes so empty-area clicks don't show stale per-item entries.
401
+ React.useEffect(() => {
402
+ const wrapper = wrapperRef.current;
403
+ if (wrapper === null) {
404
+ return;
405
+ }
406
+ const handleContextMenu = (event: MouseEvent): void => {
407
+ let host: HTMLElement | null = null;
408
+ let rowItem: HTMLElement | null = null;
409
+ for (const target of event.composedPath()) {
410
+ if (!(target instanceof HTMLElement)) {
411
+ continue;
412
+ }
413
+ if (host === null && target.tagName.toLowerCase() === FILE_TREE_TAG) {
414
+ host = target;
415
+ }
416
+ if (rowItem === null && target.dataset.type === 'item') {
417
+ rowItem = target;
418
+ }
419
+ if (host !== null && rowItem !== null) {
420
+ break;
421
+ }
422
+ }
423
+ if (host === null) {
424
+ return;
425
+ }
426
+ if (rowItem !== null) {
427
+ host.dataset.type = 'item';
428
+ host.dataset.itemType = rowItem.dataset.itemType ?? '';
429
+ host.dataset.itemPath = rowItem.dataset.itemPath ?? '';
430
+ } else {
431
+ delete host.dataset.type;
432
+ delete host.dataset.itemType;
433
+ delete host.dataset.itemPath;
434
+ }
435
+ };
436
+ wrapper.addEventListener('contextmenu', handleContextMenu, true);
437
+ return () => {
438
+ wrapper.removeEventListener('contextmenu', handleContextMenu, true);
439
+ };
440
+ }, []);
441
+
442
+ // Drag a file row out of the tree onto the JupyterLab main area. The row
443
+ // lives in the `@pierre/trees` shadow DOM, so we listen to mousedown on
444
+ // the wrapper and walk `composedPath()` to recover the row. The drag
445
+ // payload uses Lumino's `FACTORY_MIME` contract: the dock panel calls the
446
+ // factory function on drop and adds the returned widget to its layout.
447
+ React.useEffect(() => {
448
+ const wrapper = wrapperRef.current;
449
+ if (wrapper === null) {
450
+ return;
451
+ }
452
+
453
+ let press: { x: number; y: number; path: string } | null = null;
454
+ let activeDrag: Drag | null = null;
455
+
456
+ const findRow = (event: MouseEvent): HTMLElement | null => {
457
+ for (const target of event.composedPath()) {
458
+ if (!(target instanceof HTMLElement)) {
459
+ continue;
460
+ }
461
+ if (target.dataset.type === 'item') {
462
+ return target;
463
+ }
464
+ }
465
+ return null;
466
+ };
467
+
468
+ const cleanup = (): void => {
469
+ press = null;
470
+ document.removeEventListener('mousemove', handleMouseMove, true);
471
+ document.removeEventListener('mouseup', handleMouseUp, true);
472
+ };
473
+
474
+ const handleMouseDown = (event: MouseEvent): void => {
475
+ // Only react to the primary button. Do not interfere with right-click
476
+ // (handled by the contextmenu bridge) or middle-click (browser default).
477
+ if (event.button !== 0) {
478
+ return;
479
+ }
480
+ if (activeDrag !== null) {
481
+ return;
482
+ }
483
+ const row = findRow(event);
484
+ if (row === null || row.dataset.itemType !== 'file') {
485
+ return;
486
+ }
487
+ const itemPath = row.dataset.itemPath;
488
+ if (itemPath === undefined || itemPath.length === 0) {
489
+ return;
490
+ }
491
+ press = { x: event.clientX, y: event.clientY, path: itemPath };
492
+ document.addEventListener('mousemove', handleMouseMove, true);
493
+ document.addEventListener('mouseup', handleMouseUp, true);
494
+ };
495
+
496
+ const handleMouseMove = (event: MouseEvent): void => {
497
+ if (press === null) {
498
+ return;
499
+ }
500
+ const dx = Math.abs(event.clientX - press.x);
501
+ const dy = Math.abs(event.clientY - press.y);
502
+ if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) {
503
+ return;
504
+ }
505
+ const startedFrom = press;
506
+ cleanup();
507
+ startDrag(startedFrom.path, event.clientX, event.clientY);
508
+ };
509
+
510
+ const handleMouseUp = (): void => {
511
+ cleanup();
512
+ };
513
+
514
+ /**
515
+ * Build the list of file paths to drag. If the user pressed on a row
516
+ * that is part of the current selection, drag every selected file.
517
+ * Otherwise, drag just the row that was pressed. Folders never join a
518
+ * drag-out — they have no docmanager widget to open.
519
+ */
520
+ const collectDragPaths = (sourcePath: string): string[] => {
521
+ const selection = model.getSelectedPaths();
522
+ const sourceInSelection = selection.includes(sourcePath);
523
+ const candidates = sourceInSelection ? selection : [sourcePath];
524
+ const fileServerPaths: string[] = [];
525
+ for (const candidate of candidates) {
526
+ if (candidate.endsWith('/')) {
527
+ continue;
528
+ }
529
+ fileServerPaths.push(toServerPath(candidate));
530
+ }
531
+ // The source row is always a file, so the list cannot be empty.
532
+ if (fileServerPaths.length === 0) {
533
+ fileServerPaths.push(toServerPath(sourcePath));
534
+ }
535
+ return fileServerPaths;
536
+ };
537
+
538
+ const startDrag = (
539
+ sourcePath: string,
540
+ clientX: number,
541
+ clientY: number
542
+ ): void => {
543
+ const paths = collectDragPaths(sourcePath);
544
+ const sourceServerPath = toServerPath(sourcePath);
545
+
546
+ const dragImage = createDragImage(paths.length);
547
+
548
+ const drag = new Drag({
549
+ dragImage,
550
+ mimeData: new MimeData(),
551
+ supportedActions: 'copy-move',
552
+ proposedAction: 'move'
553
+ });
554
+
555
+ drag.mimeData.setData(CONTENTS_MIME, paths);
556
+
557
+ // The factory is called by the lumino dock panel on drop. It must
558
+ // return a Widget synchronously. For multi-file drags we open the
559
+ // remaining files asynchronously after the first one is placed,
560
+ // mirroring the default file browser's behavior.
561
+ const otherPaths = paths.filter(p => p !== sourceServerPath);
562
+ drag.mimeData.setData(FACTORY_MIME, () => {
563
+ let widget = docManager.findWidget(sourceServerPath);
564
+ if (widget === undefined) {
565
+ widget = docManager.open(sourceServerPath);
566
+ }
567
+ if (otherPaths.length > 0) {
568
+ const firstPlaced = new PromiseDelegate<void>();
569
+ void firstPlaced.promise.then(() => {
570
+ let prev = widget;
571
+ for (const otherPath of otherPaths) {
572
+ const opened = docManager.openOrReveal(
573
+ otherPath,
574
+ undefined,
575
+ undefined,
576
+ prev !== undefined
577
+ ? { ref: prev.id, mode: 'tab-after' }
578
+ : undefined
579
+ );
580
+ if (opened !== undefined) {
581
+ prev = opened;
582
+ }
583
+ }
584
+ });
585
+ firstPlaced.resolve();
586
+ }
587
+ return widget;
588
+ });
589
+
590
+ activeDrag = drag;
591
+ void drag.start(clientX, clientY).then(() => {
592
+ activeDrag = null;
593
+ });
594
+ };
595
+
596
+ wrapper.addEventListener('mousedown', handleMouseDown);
597
+ return () => {
598
+ wrapper.removeEventListener('mousedown', handleMouseDown);
599
+ cleanup();
600
+ activeDrag?.dispose();
601
+ activeDrag = null;
602
+ };
603
+ }, [model, docManager]);
604
+
605
+ return (
606
+ <div
607
+ ref={wrapperRef}
608
+ style={{ display: 'flex', flex: '1 1 auto', minHeight: 0 }}
609
+ >
610
+ <FileTree model={model} style={{ height: '100%', width: '100%' }} />
611
+ </div>
612
+ );
613
+ }
614
+
615
+ /**
616
+ * Build the small badge shown next to the cursor while dragging. The
617
+ * default file browser renders a richer image with the file's icon, but
618
+ * we don't have a per-file icon at this layer — just the count of files
619
+ * being dragged is enough to give the user feedback.
620
+ */
621
+ function createDragImage(count: number): HTMLElement {
622
+ const node = document.createElement('div');
623
+ node.className = 'jp-xtralab-DragImage';
624
+ const iconWrapper = document.createElement('span');
625
+ iconWrapper.className = 'jp-xtralab-DragImage-icon';
626
+ fileIcon.element({ container: iconWrapper, stylesheet: 'menuItem' });
627
+ node.appendChild(iconWrapper);
628
+ if (count > 1) {
629
+ const badge = document.createElement('span');
630
+ badge.className = 'jp-xtralab-DragImage-count';
631
+ badge.textContent = String(count);
632
+ node.appendChild(badge);
633
+ }
634
+ return node;
635
+ }