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