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