1 |
|
2 |
|
3 | import { Dialog, showDialog } from '@jupyterlab/apputils';
|
4 | import { PageConfig, PathExt } from '@jupyterlab/coreutils';
|
5 | import { shouldOverwrite } from '@jupyterlab/docmanager';
|
6 | import { nullTranslator } from '@jupyterlab/translation';
|
7 | import { ArrayExt, filter } from '@lumino/algorithm';
|
8 | import { PromiseDelegate } from '@lumino/coreutils';
|
9 | import { Poll } from '@lumino/polling';
|
10 | import { Signal } from '@lumino/signaling';
|
11 |
|
12 |
|
13 |
|
14 | const DEFAULT_REFRESH_INTERVAL = 10000;
|
15 |
|
16 |
|
17 |
|
18 | export const LARGE_FILE_SIZE = 15 * 1024 * 1024;
|
19 |
|
20 |
|
21 |
|
22 | export const CHUNK_SIZE = 1024 * 1024;
|
23 |
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 |
|
30 | export class FileBrowserModel {
|
31 | |
32 |
|
33 |
|
34 | constructor(options) {
|
35 | var _a;
|
36 | this._connectionFailure = new Signal(this);
|
37 | this._fileChanged = new Signal(this);
|
38 | this._items = [];
|
39 | this._key = '';
|
40 | this._pathChanged = new Signal(this);
|
41 | this._paths = new Set();
|
42 | this._pending = null;
|
43 | this._pendingPath = null;
|
44 | this._refreshed = new Signal(this);
|
45 | this._sessions = [];
|
46 | this._state = null;
|
47 | this._isDisposed = false;
|
48 | this._restored = new PromiseDelegate();
|
49 | this._uploads = [];
|
50 | this._uploadChanged = new Signal(this);
|
51 | this.manager = options.manager;
|
52 | this.translator = options.translator || nullTranslator;
|
53 | this._trans = this.translator.load('jupyterlab');
|
54 | this._driveName = options.driveName || '';
|
55 | this._model = {
|
56 | path: this.rootPath,
|
57 | name: PathExt.basename(this.rootPath),
|
58 | type: 'directory',
|
59 | content: undefined,
|
60 | writable: false,
|
61 | created: 'unknown',
|
62 | last_modified: 'unknown',
|
63 | mimetype: 'text/plain',
|
64 | format: 'text'
|
65 | };
|
66 | this._state = options.state || null;
|
67 | const refreshInterval = options.refreshInterval || DEFAULT_REFRESH_INTERVAL;
|
68 | const { services } = options.manager;
|
69 | services.contents.fileChanged.connect(this.onFileChanged, this);
|
70 | services.sessions.runningChanged.connect(this.onRunningChanged, this);
|
71 | this._unloadEventListener = (e) => {
|
72 | if (this._uploads.length > 0) {
|
73 | const confirmationMessage = this._trans.__('Files still uploading');
|
74 | e.returnValue = confirmationMessage;
|
75 | return confirmationMessage;
|
76 | }
|
77 | };
|
78 | window.addEventListener('beforeunload', this._unloadEventListener);
|
79 | this._poll = new Poll({
|
80 | auto: (_a = options.auto) !== null && _a !== void 0 ? _a : true,
|
81 | name: '@jupyterlab/filebrowser:Model',
|
82 | factory: () => this.cd('.'),
|
83 | frequency: {
|
84 | interval: refreshInterval,
|
85 | backoff: true,
|
86 | max: 300 * 1000
|
87 | },
|
88 | standby: options.refreshStandby || 'when-hidden'
|
89 | });
|
90 | }
|
91 | |
92 |
|
93 |
|
94 | get connectionFailure() {
|
95 | return this._connectionFailure;
|
96 | }
|
97 | |
98 |
|
99 |
|
100 | get driveName() {
|
101 | return this._driveName;
|
102 | }
|
103 | |
104 |
|
105 |
|
106 | get restored() {
|
107 | return this._restored.promise;
|
108 | }
|
109 | |
110 |
|
111 |
|
112 | get fileChanged() {
|
113 | return this._fileChanged;
|
114 | }
|
115 | |
116 |
|
117 |
|
118 | get path() {
|
119 | return this._model ? this._model.path : '';
|
120 | }
|
121 | |
122 |
|
123 |
|
124 | get rootPath() {
|
125 | return this._driveName ? this._driveName + ':' : '';
|
126 | }
|
127 | |
128 |
|
129 |
|
130 | get pathChanged() {
|
131 | return this._pathChanged;
|
132 | }
|
133 | |
134 |
|
135 |
|
136 | get refreshed() {
|
137 | return this._refreshed;
|
138 | }
|
139 | |
140 |
|
141 |
|
142 | get specs() {
|
143 | return this.manager.services.kernelspecs.specs;
|
144 | }
|
145 | |
146 |
|
147 |
|
148 | get isDisposed() {
|
149 | return this._isDisposed;
|
150 | }
|
151 | |
152 |
|
153 |
|
154 | get uploadChanged() {
|
155 | return this._uploadChanged;
|
156 | }
|
157 | |
158 |
|
159 |
|
160 | uploads() {
|
161 | return this._uploads[Symbol.iterator]();
|
162 | }
|
163 | |
164 |
|
165 |
|
166 | dispose() {
|
167 | if (this.isDisposed) {
|
168 | return;
|
169 | }
|
170 | window.removeEventListener('beforeunload', this._unloadEventListener);
|
171 | this._isDisposed = true;
|
172 | this._poll.dispose();
|
173 | this._sessions.length = 0;
|
174 | this._items.length = 0;
|
175 | Signal.clearData(this);
|
176 | }
|
177 | |
178 |
|
179 |
|
180 |
|
181 |
|
182 | items() {
|
183 | return this._items[Symbol.iterator]();
|
184 | }
|
185 | |
186 |
|
187 |
|
188 |
|
189 |
|
190 | sessions() {
|
191 | return this._sessions[Symbol.iterator]();
|
192 | }
|
193 | |
194 |
|
195 |
|
196 | async refresh() {
|
197 | await this._poll.refresh();
|
198 | await this._poll.tick;
|
199 | this._refreshed.emit(void 0);
|
200 | }
|
201 | |
202 |
|
203 |
|
204 |
|
205 |
|
206 |
|
207 |
|
208 | async cd(newValue = '.') {
|
209 | if (newValue !== '.') {
|
210 | newValue = this.manager.services.contents.resolvePath(this._model.path, newValue);
|
211 | }
|
212 | else {
|
213 | newValue = this._pendingPath || this._model.path;
|
214 | }
|
215 | if (this._pending) {
|
216 |
|
217 | if (newValue === this._pendingPath) {
|
218 | return this._pending;
|
219 | }
|
220 |
|
221 | await this._pending;
|
222 | }
|
223 | const oldValue = this.path;
|
224 | const options = { content: true };
|
225 | this._pendingPath = newValue;
|
226 | if (oldValue !== newValue) {
|
227 | this._sessions.length = 0;
|
228 | }
|
229 | const services = this.manager.services;
|
230 | this._pending = services.contents
|
231 | .get(newValue, options)
|
232 | .then(contents => {
|
233 | if (this.isDisposed) {
|
234 | return;
|
235 | }
|
236 | this.handleContents(contents);
|
237 | this._pendingPath = null;
|
238 | this._pending = null;
|
239 | if (oldValue !== newValue) {
|
240 |
|
241 |
|
242 | if (this._state && this._key) {
|
243 | void this._state.save(this._key, { path: newValue });
|
244 | }
|
245 | this._pathChanged.emit({
|
246 | name: 'path',
|
247 | oldValue,
|
248 | newValue
|
249 | });
|
250 | }
|
251 | this.onRunningChanged(services.sessions, services.sessions.running());
|
252 | this._refreshed.emit(void 0);
|
253 | })
|
254 | .catch(error => {
|
255 | this._pendingPath = null;
|
256 | this._pending = null;
|
257 | if (error.response &&
|
258 | error.response.status === 404 &&
|
259 | newValue !== '/') {
|
260 | error.message = this._trans.__('Directory not found: "%1"', this._model.path);
|
261 | console.error(error);
|
262 | this._connectionFailure.emit(error);
|
263 | return this.cd('/');
|
264 | }
|
265 | else {
|
266 | this._connectionFailure.emit(error);
|
267 | }
|
268 | });
|
269 | return this._pending;
|
270 | }
|
271 | |
272 |
|
273 |
|
274 |
|
275 |
|
276 |
|
277 |
|
278 |
|
279 | async download(path) {
|
280 | const url = await this.manager.services.contents.getDownloadUrl(path);
|
281 | const element = document.createElement('a');
|
282 | element.href = url;
|
283 | element.download = '';
|
284 | document.body.appendChild(element);
|
285 | element.click();
|
286 | document.body.removeChild(element);
|
287 | return void 0;
|
288 | }
|
289 | |
290 |
|
291 |
|
292 |
|
293 |
|
294 |
|
295 |
|
296 |
|
297 |
|
298 |
|
299 |
|
300 |
|
301 |
|
302 |
|
303 | async restore(id, populate = true) {
|
304 | const { manager } = this;
|
305 | const key = `file-browser-${id}:cwd`;
|
306 | const state = this._state;
|
307 | const restored = !!this._key;
|
308 | if (restored) {
|
309 | return;
|
310 | }
|
311 |
|
312 | this._key = key;
|
313 | if (!populate || !state) {
|
314 | this._restored.resolve(undefined);
|
315 | return;
|
316 | }
|
317 | await manager.services.ready;
|
318 | try {
|
319 | const value = await state.fetch(key);
|
320 | if (!value) {
|
321 | this._restored.resolve(undefined);
|
322 | return;
|
323 | }
|
324 | const path = value['path'];
|
325 |
|
326 | if (path) {
|
327 | await this.cd('/');
|
328 | }
|
329 | const localPath = manager.services.contents.localPath(path);
|
330 | await manager.services.contents.get(path);
|
331 | await this.cd(localPath);
|
332 | }
|
333 | catch (error) {
|
334 | await state.remove(key);
|
335 | }
|
336 | this._restored.resolve(undefined);
|
337 | }
|
338 | |
339 |
|
340 |
|
341 |
|
342 |
|
343 |
|
344 |
|
345 |
|
346 |
|
347 |
|
348 |
|
349 |
|
350 |
|
351 | async upload(file) {
|
352 |
|
353 |
|
354 |
|
355 |
|
356 | const serverVersion = PageConfig.getNotebookVersion();
|
357 | const supportsChunked = serverVersion < [4, 0, 0] ||
|
358 | serverVersion >= [5, 1, 0];
|
359 | const largeFile = file.size > LARGE_FILE_SIZE;
|
360 | if (largeFile && !supportsChunked) {
|
361 | const msg = this._trans.__('Cannot upload file (>%1 MB). %2', LARGE_FILE_SIZE / (1024 * 1024), file.name);
|
362 | console.warn(msg);
|
363 | throw msg;
|
364 | }
|
365 | const err = 'File not uploaded';
|
366 | if (largeFile && !(await this._shouldUploadLarge(file))) {
|
367 | throw 'Cancelled large file upload';
|
368 | }
|
369 | await this._uploadCheckDisposed();
|
370 | await this.refresh();
|
371 | await this._uploadCheckDisposed();
|
372 | if (this._items.find(i => i.name === file.name) &&
|
373 | !(await shouldOverwrite(file.name))) {
|
374 | throw err;
|
375 | }
|
376 | await this._uploadCheckDisposed();
|
377 | const chunkedUpload = supportsChunked && file.size > CHUNK_SIZE;
|
378 | return await this._upload(file, chunkedUpload);
|
379 | }
|
380 | async _shouldUploadLarge(file) {
|
381 | const { button } = await showDialog({
|
382 | title: this._trans.__('Large file size warning'),
|
383 | body: this._trans.__('The file size is %1 MB. Do you still want to upload it?', Math.round(file.size / (1024 * 1024))),
|
384 | buttons: [
|
385 | Dialog.cancelButton({ label: this._trans.__('Cancel') }),
|
386 | Dialog.warnButton({ label: this._trans.__('Upload') })
|
387 | ]
|
388 | });
|
389 | return button.accept;
|
390 | }
|
391 | |
392 |
|
393 |
|
394 | async _upload(file, chunked) {
|
395 |
|
396 | let path = this._model.path;
|
397 | path = path ? path + '/' + file.name : file.name;
|
398 | const name = file.name;
|
399 | const type = 'file';
|
400 | const format = 'base64';
|
401 | const uploadInner = async (blob, chunk) => {
|
402 | await this._uploadCheckDisposed();
|
403 | const reader = new FileReader();
|
404 | reader.readAsDataURL(blob);
|
405 | await new Promise((resolve, reject) => {
|
406 | reader.onload = resolve;
|
407 | reader.onerror = event => reject(`Failed to upload "${file.name}":` + event);
|
408 | });
|
409 | await this._uploadCheckDisposed();
|
410 |
|
411 | const content = reader.result.split(',')[1];
|
412 | const model = {
|
413 | type,
|
414 | format,
|
415 | name,
|
416 | chunk,
|
417 | content
|
418 | };
|
419 | return await this.manager.services.contents.save(path, model);
|
420 | };
|
421 | if (!chunked) {
|
422 | try {
|
423 | return await uploadInner(file);
|
424 | }
|
425 | catch (err) {
|
426 | ArrayExt.removeFirstWhere(this._uploads, uploadIndex => {
|
427 | return file.name === uploadIndex.path;
|
428 | });
|
429 | throw err;
|
430 | }
|
431 | }
|
432 | let finalModel;
|
433 | let upload = { path, progress: 0 };
|
434 | this._uploadChanged.emit({
|
435 | name: 'start',
|
436 | newValue: upload,
|
437 | oldValue: null
|
438 | });
|
439 | for (let start = 0; !finalModel; start += CHUNK_SIZE) {
|
440 | const end = start + CHUNK_SIZE;
|
441 | const lastChunk = end >= file.size;
|
442 | const chunk = lastChunk ? -1 : end / CHUNK_SIZE;
|
443 | const newUpload = { path, progress: start / file.size };
|
444 | this._uploads.splice(this._uploads.indexOf(upload));
|
445 | this._uploads.push(newUpload);
|
446 | this._uploadChanged.emit({
|
447 | name: 'update',
|
448 | newValue: newUpload,
|
449 | oldValue: upload
|
450 | });
|
451 | upload = newUpload;
|
452 | let currentModel;
|
453 | try {
|
454 | currentModel = await uploadInner(file.slice(start, end), chunk);
|
455 | }
|
456 | catch (err) {
|
457 | ArrayExt.removeFirstWhere(this._uploads, uploadIndex => {
|
458 | return file.name === uploadIndex.path;
|
459 | });
|
460 | this._uploadChanged.emit({
|
461 | name: 'failure',
|
462 | newValue: upload,
|
463 | oldValue: null
|
464 | });
|
465 | throw err;
|
466 | }
|
467 | if (lastChunk) {
|
468 | finalModel = currentModel;
|
469 | }
|
470 | }
|
471 | this._uploads.splice(this._uploads.indexOf(upload));
|
472 | this._uploadChanged.emit({
|
473 | name: 'finish',
|
474 | newValue: null,
|
475 | oldValue: upload
|
476 | });
|
477 | return finalModel;
|
478 | }
|
479 | _uploadCheckDisposed() {
|
480 | if (this.isDisposed) {
|
481 | return Promise.reject('Filemanager disposed. File upload canceled');
|
482 | }
|
483 | return Promise.resolve();
|
484 | }
|
485 | |
486 |
|
487 |
|
488 | handleContents(contents) {
|
489 |
|
490 | this._model = {
|
491 | name: contents.name,
|
492 | path: contents.path,
|
493 | type: contents.type,
|
494 | content: undefined,
|
495 | writable: contents.writable,
|
496 | created: contents.created,
|
497 | last_modified: contents.last_modified,
|
498 | size: contents.size,
|
499 | mimetype: contents.mimetype,
|
500 | format: contents.format
|
501 | };
|
502 | this._items = contents.content;
|
503 | this._paths.clear();
|
504 | contents.content.forEach((model) => {
|
505 | this._paths.add(model.path);
|
506 | });
|
507 | }
|
508 | |
509 |
|
510 |
|
511 | onRunningChanged(sender, models) {
|
512 | this._populateSessions(models);
|
513 | this._refreshed.emit(void 0);
|
514 | }
|
515 | |
516 |
|
517 |
|
518 | onFileChanged(sender, change) {
|
519 | const path = this._model.path;
|
520 | const { sessions } = this.manager.services;
|
521 | const { oldValue, newValue } = change;
|
522 | const value = oldValue && oldValue.path && PathExt.dirname(oldValue.path) === path
|
523 | ? oldValue
|
524 | : newValue && newValue.path && PathExt.dirname(newValue.path) === path
|
525 | ? newValue
|
526 | : undefined;
|
527 |
|
528 | if (value) {
|
529 | void this._poll.refresh();
|
530 | this._populateSessions(sessions.running());
|
531 | this._fileChanged.emit(change);
|
532 | return;
|
533 | }
|
534 | }
|
535 | |
536 |
|
537 |
|
538 | _populateSessions(models) {
|
539 | this._sessions.length = 0;
|
540 | for (const model of models) {
|
541 | if (this._paths.has(model.path)) {
|
542 | this._sessions.push(model);
|
543 | }
|
544 | }
|
545 | }
|
546 | }
|
547 |
|
548 |
|
549 |
|
550 | export class TogglableHiddenFileBrowserModel extends FileBrowserModel {
|
551 | constructor(options) {
|
552 | super(options);
|
553 | this._includeHiddenFiles = options.includeHiddenFiles || false;
|
554 | }
|
555 | |
556 |
|
557 |
|
558 |
|
559 |
|
560 | items() {
|
561 | return this._includeHiddenFiles
|
562 | ? super.items()
|
563 | : filter(super.items(), value => !value.name.startsWith('.'));
|
564 | }
|
565 | |
566 |
|
567 |
|
568 | showHiddenFiles(value) {
|
569 | this._includeHiddenFiles = value;
|
570 | void this.refresh();
|
571 | }
|
572 | }
|
573 |
|
574 |
|
575 |
|
576 | export class FilterFileBrowserModel extends TogglableHiddenFileBrowserModel {
|
577 | constructor(options) {
|
578 | var _a, _b;
|
579 | super(options);
|
580 | this._filter =
|
581 | (_a = options.filter) !== null && _a !== void 0 ? _a : (model => {
|
582 | return {};
|
583 | });
|
584 | this._filterDirectories = (_b = options.filterDirectories) !== null && _b !== void 0 ? _b : true;
|
585 | }
|
586 | |
587 |
|
588 |
|
589 | get filterDirectories() {
|
590 | return this._filterDirectories;
|
591 | }
|
592 | set filterDirectories(value) {
|
593 | this._filterDirectories = value;
|
594 | }
|
595 | |
596 |
|
597 |
|
598 |
|
599 |
|
600 | items() {
|
601 | return filter(super.items(), value => {
|
602 | if (!this._filterDirectories && value.type === 'directory') {
|
603 | return true;
|
604 | }
|
605 | else {
|
606 | const filtered = this._filter(value);
|
607 | value.indices = filtered === null || filtered === void 0 ? void 0 : filtered.indices;
|
608 | return !!filtered;
|
609 | }
|
610 | });
|
611 | }
|
612 | setFilter(filter) {
|
613 | this._filter = filter;
|
614 | void this.refresh();
|
615 | }
|
616 | }
|
617 |
|
\ | No newline at end of file |