UNPKG

20.6 kBJavaScriptView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3import { Dialog, showDialog } from '@jupyterlab/apputils';
4import { PageConfig, PathExt } from '@jupyterlab/coreutils';
5import { shouldOverwrite } from '@jupyterlab/docmanager';
6import { nullTranslator } from '@jupyterlab/translation';
7import { ArrayExt, filter } from '@lumino/algorithm';
8import { PromiseDelegate } from '@lumino/coreutils';
9import { Poll } from '@lumino/polling';
10import { Signal } from '@lumino/signaling';
11/**
12 * The default duration of the auto-refresh in ms
13 */
14const DEFAULT_REFRESH_INTERVAL = 10000;
15/**
16 * The maximum upload size (in bytes) for notebook version < 5.1.0
17 */
18export const LARGE_FILE_SIZE = 15 * 1024 * 1024;
19/**
20 * The size (in bytes) of the biggest chunk we should upload at once.
21 */
22export const CHUNK_SIZE = 1024 * 1024;
23/**
24 * An implementation of a file browser model.
25 *
26 * #### Notes
27 * All paths parameters without a leading `'/'` are interpreted as relative to
28 * the current directory. Supports `'../'` syntax.
29 */
30export class FileBrowserModel {
31 /**
32 * Construct a new file browser model.
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 * A signal emitted when the file browser model loses connection.
93 */
94 get connectionFailure() {
95 return this._connectionFailure;
96 }
97 /**
98 * The drive name that gets prepended to the path.
99 */
100 get driveName() {
101 return this._driveName;
102 }
103 /**
104 * A promise that resolves when the model is first restored.
105 */
106 get restored() {
107 return this._restored.promise;
108 }
109 /**
110 * Get the file path changed signal.
111 */
112 get fileChanged() {
113 return this._fileChanged;
114 }
115 /**
116 * Get the current path.
117 */
118 get path() {
119 return this._model ? this._model.path : '';
120 }
121 /**
122 * Get the root path
123 */
124 get rootPath() {
125 return this._driveName ? this._driveName + ':' : '';
126 }
127 /**
128 * A signal emitted when the path changes.
129 */
130 get pathChanged() {
131 return this._pathChanged;
132 }
133 /**
134 * A signal emitted when the directory listing is refreshed.
135 */
136 get refreshed() {
137 return this._refreshed;
138 }
139 /**
140 * Get the kernel spec models.
141 */
142 get specs() {
143 return this.manager.services.kernelspecs.specs;
144 }
145 /**
146 * Get whether the model is disposed.
147 */
148 get isDisposed() {
149 return this._isDisposed;
150 }
151 /**
152 * A signal emitted when an upload progresses.
153 */
154 get uploadChanged() {
155 return this._uploadChanged;
156 }
157 /**
158 * Create an iterator over the status of all in progress uploads.
159 */
160 uploads() {
161 return this._uploads[Symbol.iterator]();
162 }
163 /**
164 * Dispose of the resources held by the model.
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 * Create an iterator over the model's items.
179 *
180 * @returns A new iterator over the model's items.
181 */
182 items() {
183 return this._items[Symbol.iterator]();
184 }
185 /**
186 * Create an iterator over the active sessions in the directory.
187 *
188 * @returns A new iterator over the model's active sessions.
189 */
190 sessions() {
191 return this._sessions[Symbol.iterator]();
192 }
193 /**
194 * Force a refresh of the directory contents.
195 */
196 async refresh() {
197 await this._poll.refresh();
198 await this._poll.tick;
199 this._refreshed.emit(void 0);
200 }
201 /**
202 * Change directory.
203 *
204 * @param path - The path to the file or directory.
205 *
206 * @returns A promise with the contents of the directory.
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 // Collapse requests to the same directory.
217 if (newValue === this._pendingPath) {
218 return this._pending;
219 }
220 // Otherwise wait for the pending request to complete before continuing.
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 // If there is a state database and a unique key, save the new path.
241 // We don't need to wait on the save to continue.
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 * Download a file.
273 *
274 * @param path - The path of the file to be downloaded.
275 *
276 * @returns A promise which resolves when the file has begun
277 * downloading.
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 * Restore the state of the file browser.
291 *
292 * @param id - The unique ID that is used to construct a state database key.
293 *
294 * @param populate - If `false`, the restoration ID will be set but the file
295 * browser state will not be fetched from the state database.
296 *
297 * @returns A promise when restoration is complete.
298 *
299 * #### Notes
300 * This function will only restore the model *once*. If it is called multiple
301 * times, all subsequent invocations are no-ops.
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 // Set the file browser key for state database fetch/save.
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 // need to return to root path if preferred dir is set
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 * Upload a `File` object.
340 *
341 * @param file - The `File` object to upload.
342 *
343 * @returns A promise containing the new file contents model.
344 *
345 * #### Notes
346 * On Notebook version < 5.1.0, this will fail to upload files that are too
347 * big to be sent in one request to the server. On newer versions, or on
348 * Jupyter Server, it will ask for confirmation then upload the file in 1 MB
349 * chunks.
350 */
351 async upload(file) {
352 // We do not support Jupyter Notebook version less than 4, and Jupyter
353 // Server advertises itself as version 1 and supports chunked
354 // uploading. We assume any version less than 4.0.0 to be Jupyter Server
355 // instead of Jupyter Notebook.
356 const serverVersion = PageConfig.getNotebookVersion();
357 const supportsChunked = serverVersion < [4, 0, 0] /* Jupyter Server */ ||
358 serverVersion >= [5, 1, 0]; /* Jupyter Notebook >= 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 * Perform the actual upload.
393 */
394 async _upload(file, chunked) {
395 // Gather the file model parameters.
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 // remove header https://stackoverflow.com/a/24289420/907060
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 * Handle an updated contents model.
487 */
488 handleContents(contents) {
489 // Update our internal data.
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 * Handle a change to the running sessions.
510 */
511 onRunningChanged(sender, models) {
512 this._populateSessions(models);
513 this._refreshed.emit(void 0);
514 }
515 /**
516 * Handle a change on the contents manager.
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 // If either the old value or the new value is in the current path, update.
528 if (value) {
529 void this._poll.refresh();
530 this._populateSessions(sessions.running());
531 this._fileChanged.emit(change);
532 return;
533 }
534 }
535 /**
536 * Populate the model's sessions collection.
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 * File browser model where hidden files inclusion can be toggled on/off.
549 */
550export class TogglableHiddenFileBrowserModel extends FileBrowserModel {
551 constructor(options) {
552 super(options);
553 this._includeHiddenFiles = options.includeHiddenFiles || false;
554 }
555 /**
556 * Create an iterator over the model's items filtering hidden files out if necessary.
557 *
558 * @returns A new iterator over the model's items.
559 */
560 items() {
561 return this._includeHiddenFiles
562 ? super.items()
563 : filter(super.items(), value => !value.name.startsWith('.'));
564 }
565 /**
566 * Set the inclusion of hidden files. Triggers a model refresh.
567 */
568 showHiddenFiles(value) {
569 this._includeHiddenFiles = value;
570 void this.refresh();
571 }
572}
573/**
574 * File browser model with optional filter on element.
575 */
576export 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 * Whether to filter directories.
588 */
589 get filterDirectories() {
590 return this._filterDirectories;
591 }
592 set filterDirectories(value) {
593 this._filterDirectories = value;
594 }
595 /**
596 * Create an iterator over the filtered model's items.
597 *
598 * @returns A new iterator over the model's items.
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//# sourceMappingURL=model.js.map
\No newline at end of file