UNPKG

@jupyterlab/filebrowser

Version:
869 lines (781 loc) 23.8 kB
// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import { Dialog, showDialog } from '@jupyterlab/apputils'; import { IChangedArgs, PageConfig, PathExt } from '@jupyterlab/coreutils'; import { IDocumentManager, shouldOverwrite } from '@jupyterlab/docmanager'; import { Contents, KernelSpec, Session } from '@jupyterlab/services'; import { IStateDB } from '@jupyterlab/statedb'; import { ITranslator, nullTranslator, TranslationBundle } from '@jupyterlab/translation'; import { IFilterBoxProps, IScore } from '@jupyterlab/ui-components'; import { ArrayExt, filter } from '@lumino/algorithm'; import { PromiseDelegate, ReadonlyJSONObject } from '@lumino/coreutils'; import { IDisposable } from '@lumino/disposable'; import { Poll } from '@lumino/polling'; import { ISignal, Signal } from '@lumino/signaling'; /** * The default duration of the auto-refresh in ms */ const DEFAULT_REFRESH_INTERVAL = 10000; /** * The maximum upload size (in bytes) for notebook version < 5.1.0 */ export const LARGE_FILE_SIZE = 15 * 1024 * 1024; /** * The size (in bytes) of the biggest chunk we should upload at once. */ export const CHUNK_SIZE = 1024 * 1024; /** * An upload progress event for a file at `path`. */ export interface IUploadModel { path: string; /** * % uploaded [0, 1) */ progress: number; } /** * An implementation of a file browser model. * * #### Notes * All paths parameters without a leading `'/'` are interpreted as relative to * the current directory. Supports `'../'` syntax. */ export class FileBrowserModel implements IDisposable { /** * Construct a new file browser model. */ constructor(options: FileBrowserModel.IOptions) { this.manager = options.manager; this.translator = options.translator || nullTranslator; this._trans = this.translator.load('jupyterlab'); this._driveName = options.driveName || ''; this._model = { path: this.rootPath, name: PathExt.basename(this.rootPath), type: 'directory', content: undefined, writable: false, created: 'unknown', last_modified: 'unknown', mimetype: 'text/plain', format: 'text' }; this._state = options.state || null; const refreshInterval = options.refreshInterval || DEFAULT_REFRESH_INTERVAL; const { services } = options.manager; services.contents.fileChanged.connect(this.onFileChanged, this); services.sessions.runningChanged.connect(this.onRunningChanged, this); this._unloadEventListener = (e: Event) => { if (this._uploads.length > 0) { const confirmationMessage = this._trans.__('Files still uploading'); (e as any).returnValue = confirmationMessage; return confirmationMessage; } }; window.addEventListener('beforeunload', this._unloadEventListener); this._poll = new Poll({ auto: options.auto ?? true, name: '@jupyterlab/filebrowser:Model', factory: () => this.cd('.'), frequency: { interval: refreshInterval, backoff: true, max: 300 * 1000 }, standby: options.refreshStandby || 'when-hidden' }); } /** * The document manager instance used by the file browser model. */ readonly manager: IDocumentManager; /** * A signal emitted when the file browser model loses connection. */ get connectionFailure(): ISignal<this, Error> { return this._connectionFailure; } /** * The drive name that gets prepended to the path. */ get driveName(): string { return this._driveName; } /** * A promise that resolves when the model is first restored. */ get restored(): Promise<void> { return this._restored.promise; } /** * Get the file path changed signal. */ get fileChanged(): ISignal<this, Contents.IChangedArgs> { return this._fileChanged; } /** * Get the current path. */ get path(): string { return this._model ? this._model.path : ''; } /** * Get the root path */ get rootPath(): string { return this._driveName ? this._driveName + ':' : ''; } /** * A signal emitted when the path changes. */ get pathChanged(): ISignal<this, IChangedArgs<string>> { return this._pathChanged; } /** * A signal emitted when the directory listing is refreshed. */ get refreshed(): ISignal<this, void> { return this._refreshed; } /** * Get the kernel spec models. */ get specs(): KernelSpec.ISpecModels | null { return this.manager.services.kernelspecs.specs; } /** * Get whether the model is disposed. */ get isDisposed(): boolean { return this._isDisposed; } /** * A signal emitted when an upload progresses. */ get uploadChanged(): ISignal<this, IChangedArgs<IUploadModel | null>> { return this._uploadChanged; } /** * Create an iterator over the status of all in progress uploads. */ uploads(): IterableIterator<IUploadModel> { return this._uploads[Symbol.iterator](); } /** * Dispose of the resources held by the model. */ dispose(): void { if (this.isDisposed) { return; } window.removeEventListener('beforeunload', this._unloadEventListener); this._isDisposed = true; this._poll.dispose(); this._sessions.length = 0; this._items.length = 0; Signal.clearData(this); } /** * Create an iterator over the model's items. * * @returns A new iterator over the model's items. */ items(): IterableIterator<Contents.IModel> { return this._items[Symbol.iterator](); } /** * Create an iterator over the active sessions in the directory. * * @returns A new iterator over the model's active sessions. */ sessions(): IterableIterator<Session.IModel> { return this._sessions[Symbol.iterator](); } /** * Force a refresh of the directory contents. */ async refresh(): Promise<void> { await this._poll.refresh(); await this._poll.tick; this._refreshed.emit(void 0); } /** * Change directory. * * @param path The path to the file or directory. * * @returns A promise with the contents of the directory. */ async cd(path = '.'): Promise<void> { if (path !== '.') { path = this.manager.services.contents.resolvePath(this._model.path, path); } else { path = this._pendingPath || this._model.path; } if (this._pending) { // Collapse requests to the same directory. if (path === this._pendingPath) { return this._pending; } // Otherwise wait for the pending request to complete before continuing. await this._pending; } const oldValue = this.path; const options: Contents.IFetchOptions = { content: true }; this._pendingPath = path; if (oldValue !== path) { this._sessions.length = 0; } const services = this.manager.services; this._pending = services.contents .get(path, options) .then(contents => { if (this.isDisposed) { return; } this.handleContents(contents); this._pendingPath = null; this._pending = null; if (oldValue !== path) { // If there is a state database and a unique key, save the new path. // We don't need to wait on the save to continue. if (this._state && this._key) { void this._state.save(this._key, { path }); } this._pathChanged.emit({ name: 'path', oldValue, newValue: path }); } this.onRunningChanged(services.sessions, services.sessions.running()); this._refreshed.emit(void 0); }) .catch(error => { this._pendingPath = null; this._pending = null; if (error.response && error.response.status === 404 && path !== '/') { error.message = this._trans.__( 'Directory not found: "%1"', this._model.path ); console.error(error); this._connectionFailure.emit(error); return this.cd('/'); } else { this._connectionFailure.emit(error); } }); return this._pending; } /** * Download a file. * * @param path - The path of the file to be downloaded. * * @returns A promise which resolves when the file has begun * downloading. */ async download(path: string): Promise<void> { const url = await this.manager.services.contents.getDownloadUrl(path); const element = document.createElement('a'); element.href = url; element.download = ''; document.body.appendChild(element); element.click(); document.body.removeChild(element); return void 0; } /** * Restore the state of the file browser. * * @param id - The unique ID that is used to construct a state database key. * * @param populate - If `false`, the restoration ID will be set but the file * browser state will not be fetched from the state database. * * @returns A promise when restoration is complete. * * #### Notes * This function will only restore the model *once*. If it is called multiple * times, all subsequent invocations are no-ops. */ async restore(id: string, populate = true): Promise<void> { const { manager } = this; const key = `file-browser-${id}:cwd`; const state = this._state; const restored = !!this._key; if (restored) { return; } // Set the file browser key for state database fetch/save. this._key = key; if (!populate || !state) { this._restored.resolve(undefined); return; } await manager.services.ready; try { const value = await state.fetch(key); if (!value) { this._restored.resolve(undefined); return; } const path = (value as ReadonlyJSONObject)['path'] as string; // need to return to root path if preferred dir is set if (path) { await this.cd('/'); } const localPath = manager.services.contents.localPath(path); await manager.services.contents.get(path); await this.cd(localPath); } catch (error) { await state.remove(key); } this._restored.resolve(undefined); } /** * Upload a `File` object. * * @param file - The `File` object to upload. * @param path - The directory into which the file should be uploaded; defaults to current directory. * * @returns A promise containing the new file contents model. * * #### Notes * On Notebook version < 5.1.0, this will fail to upload files that are too * big to be sent in one request to the server. On newer versions, or on * Jupyter Server, it will ask for confirmation then upload the file in 1 MB * chunks. */ async upload(file: File, path?: string): Promise<Contents.IModel> { // We do not support Jupyter Notebook version less than 4, and Jupyter // Server advertises itself as version 1 and supports chunked // uploading. We assume any version less than 4.0.0 to be Jupyter Server // instead of Jupyter Notebook. const serverVersion = PageConfig.getNotebookVersion(); const supportsChunked = serverVersion < [4, 0, 0] /* Jupyter Server */ || serverVersion >= [5, 1, 0]; /* Jupyter Notebook >= 5.1.0 */ const largeFile = file.size > LARGE_FILE_SIZE; if (largeFile && !supportsChunked) { const msg = this._trans.__( 'Cannot upload file (>%1 MB). %2', LARGE_FILE_SIZE / (1024 * 1024), file.name ); console.warn(msg); throw msg; } const err = 'File not uploaded'; if (largeFile && !(await this._shouldUploadLarge(file))) { throw 'Cancelled large file upload'; } await this._uploadCheckDisposed(); await this.refresh(); await this._uploadCheckDisposed(); if ( this._items.find(i => i.name === file.name) && !(await shouldOverwrite(file.name)) ) { throw err; } await this._uploadCheckDisposed(); const chunkedUpload = supportsChunked && file.size > CHUNK_SIZE; return await this._upload(file, chunkedUpload, path); } private async _shouldUploadLarge(file: File): Promise<boolean> { const { button } = await showDialog({ title: this._trans.__('Large file size warning'), body: this._trans.__( 'The file size is %1 MB. Do you still want to upload it?', Math.round(file.size / (1024 * 1024)) ), buttons: [ Dialog.cancelButton({ label: this._trans.__('Cancel') }), Dialog.warnButton({ label: this._trans.__('Upload') }) ] }); return button.accept; } /** * Perform the actual upload. */ private async _upload( file: File, chunked: boolean, uploadPath?: string ): Promise<Contents.IModel> { // Gather the file model parameters. let path = typeof uploadPath === 'undefined' ? this._model.path : uploadPath; path = path ? path + '/' + file.name : file.name; const name = file.name; const type: Contents.ContentType = 'file'; const format: Contents.FileFormat = 'base64'; const uploadInner = async ( blob: Blob, chunk?: number ): Promise<Contents.IModel> => { await this._uploadCheckDisposed(); const reader = new FileReader(); reader.readAsDataURL(blob); await new Promise((resolve, reject) => { reader.onload = resolve; reader.onerror = event => reject(`Failed to upload "${file.name}":` + event); }); await this._uploadCheckDisposed(); // remove header https://stackoverflow.com/a/24289420/907060 const content = (reader.result as string).split(',')[1]; const model: Partial<Contents.IModel> = { type, format, name, chunk, content }; return await this.manager.services.contents.save(path, model); }; if (!chunked) { try { return await uploadInner(file); } catch (err) { ArrayExt.removeFirstWhere(this._uploads, uploadIndex => { return file.name === uploadIndex.path; }); throw err; } } let finalModel: Contents.IModel | undefined; let upload = { path, progress: 0 }; this._uploadChanged.emit({ name: 'start', newValue: upload, oldValue: null }); for (let start = 0; !finalModel; start += CHUNK_SIZE) { const end = start + CHUNK_SIZE; const lastChunk = end >= file.size; const chunk = lastChunk ? -1 : end / CHUNK_SIZE; const newUpload = { path, progress: start / file.size }; this._uploads.splice(this._uploads.indexOf(upload)); this._uploads.push(newUpload); this._uploadChanged.emit({ name: 'update', newValue: newUpload, oldValue: upload }); upload = newUpload; let currentModel: Contents.IModel; try { currentModel = await uploadInner(file.slice(start, end), chunk); } catch (err) { ArrayExt.removeFirstWhere(this._uploads, uploadIndex => { return file.name === uploadIndex.path; }); this._uploadChanged.emit({ name: 'failure', newValue: upload, oldValue: null }); throw err; } if (lastChunk) { finalModel = currentModel; } } this._uploads.splice(this._uploads.indexOf(upload)); this._uploadChanged.emit({ name: 'finish', newValue: null, oldValue: upload }); return finalModel; } private _uploadCheckDisposed(): Promise<void> { if (this.isDisposed) { return Promise.reject('Filemanager disposed. File upload canceled'); } return Promise.resolve(); } /** * Handle an updated contents model. */ protected handleContents(contents: Contents.IModel): void { // Update our internal data. this._model = { name: contents.name, path: contents.path, type: contents.type, content: undefined, writable: contents.writable, created: contents.created, last_modified: contents.last_modified, size: contents.size, mimetype: contents.mimetype, format: contents.format }; this._items = contents.content; this._paths.clear(); contents.content.forEach((model: Contents.IModel) => { this._paths.add(model.path); }); } /** * Handle a change to the running sessions. */ protected onRunningChanged( sender: Session.IManager, models: Iterable<Session.IModel> ): void { this._populateSessions(models); this._refreshed.emit(void 0); } /** * Handle a change on the contents manager. */ protected onFileChanged( sender: Contents.IManager, change: Contents.IChangedArgs ): void { const path = this._model.path; const { sessions } = this.manager.services; const { oldValue, newValue } = change; const prefix = this.driveName.length > 0 ? this.driveName + ':' : ''; const value = oldValue && oldValue.path && prefix + PathExt.dirname(oldValue.path) === path ? oldValue : newValue && newValue.path && prefix + PathExt.dirname(newValue.path) === path ? newValue : undefined; // If either the old value or the new value is in the current path, update. if (value) { void this._poll.refresh(); this._populateSessions(sessions.running()); this._fileChanged.emit(change); return; } } /** * Populate the model's sessions collection. */ private _populateSessions(models: Iterable<Session.IModel>): void { this._sessions.length = 0; for (const model of models) { if (this._paths.has(model.path)) { this._sessions.push(model); } } } protected translator: ITranslator; private _trans: TranslationBundle; private _connectionFailure = new Signal<this, Error>(this); private _fileChanged = new Signal<this, Contents.IChangedArgs>(this); private _items: Contents.IModel[] = []; private _key: string = ''; private _model: Contents.IModel; private _pathChanged = new Signal<this, IChangedArgs<string>>(this); private _paths = new Set<string>(); private _pending: Promise<void> | null = null; private _pendingPath: string | null = null; private _refreshed = new Signal<this, void>(this); private _sessions: Session.IModel[] = []; private _state: IStateDB | null = null; private _driveName: string; private _isDisposed = false; private _restored = new PromiseDelegate<void>(); private _uploads: IUploadModel[] = []; private _uploadChanged = new Signal<this, IChangedArgs<IUploadModel | null>>( this ); private _unloadEventListener: (e: Event) => string | undefined; private _poll: Poll; } /** * The namespace for the `FileBrowserModel` class statics. */ export namespace FileBrowserModel { /** * An options object for initializing a file browser. */ export interface IOptions { /** * Whether a file browser automatically loads its initial path. * The default is `true`. */ auto?: boolean; /** * An optional `Contents.IDrive` name for the model. * If given, the model will prepend `driveName:` to * all paths used in file operations. */ driveName?: string; /** * A document manager instance. */ manager: IDocumentManager; /** * The time interval for browser refreshing, in ms. */ refreshInterval?: number; /** * When the model stops polling the API. Defaults to `when-hidden`. */ refreshStandby?: Poll.Standby | (() => boolean | Poll.Standby); /** * An optional state database. If provided, the model will restore which * folder was last opened when it is restored. */ state?: IStateDB; /** * The application language translator. */ translator?: ITranslator; } } /** * File browser model where hidden files inclusion can be toggled on/off. */ export class TogglableHiddenFileBrowserModel extends FileBrowserModel { constructor(options: TogglableHiddenFileBrowserModel.IOptions) { super(options); this._includeHiddenFiles = options.includeHiddenFiles || false; } /** * Create an iterator over the model's items filtering hidden files out if necessary. * * @returns A new iterator over the model's items. */ items(): IterableIterator<Contents.IModel> { return this._includeHiddenFiles ? super.items() : filter(super.items(), value => !value.name.startsWith('.')); } /** * Set the inclusion of hidden files. Triggers a model refresh. */ showHiddenFiles(value: boolean): void { this._includeHiddenFiles = value; void this.refresh(); } private _includeHiddenFiles: boolean; } /** * Namespace for the togglable hidden file browser model */ export namespace TogglableHiddenFileBrowserModel { /** * Constructor options */ export interface IOptions extends FileBrowserModel.IOptions { /** * Whether hidden files should be included in the items. */ includeHiddenFiles?: boolean; } } /** * File browser model with optional filter on element. */ export class FilterFileBrowserModel extends TogglableHiddenFileBrowserModel { constructor(options: FilterFileBrowserModel.IOptions) { super(options); this._filter = options.filter ?? (model => { return {}; }); this._filterDirectories = options.filterDirectories ?? true; this._useFuzzyFilter = options.useFuzzyFilter ?? true; } /** * Whether to filter directories. */ get filterDirectories(): boolean { return this._filterDirectories; } set filterDirectories(value: boolean) { this._filterDirectories = value; } /** * Whether to apply fuzzy filter. */ get useFuzzyFilter(): boolean { return this._useFuzzyFilter; } set useFuzzyFilter(value: boolean) { if (this._useFuzzyFilter === value) { return; } this._useFuzzyFilter = value; this._filterSettingsChanged.emit({ useFuzzyFilter: value }); } /** * Signal for settings changed */ get filterSettingsChanged(): ISignal< FileBrowserModel, { [P in keyof IFilterBoxProps]?: IFilterBoxProps[P] } > { return this._filterSettingsChanged; } /** * Create an iterator over the filtered model's items. * * @returns A new iterator over the model's items. */ items(): IterableIterator<Contents.IModel> { return filter(super.items(), value => { if (!this._filterDirectories && value.type === 'directory') { return true; } else { const filtered = this._filter(value); value.indices = filtered?.indices; return !!filtered; } }); } setFilter(filter: (value: Contents.IModel) => Partial<IScore> | null): void { this._filter = filter; void this.refresh(); } private _filter: (value: Contents.IModel) => Partial<IScore> | null; private _filterDirectories: boolean; private _useFuzzyFilter: boolean; private _filterSettingsChanged = new Signal< FileBrowserModel, { [P in keyof IFilterBoxProps]?: IFilterBoxProps[P] } >(this); } /** * Namespace for the filtered file browser model */ export namespace FilterFileBrowserModel { /** * Constructor options */ export interface IOptions extends TogglableHiddenFileBrowserModel.IOptions { /** * Filter function on file browser item model */ filter?: (value: Contents.IModel) => Partial<IScore> | null; /** * Filter directories */ filterDirectories?: boolean; /** * Use Fuzzy Filter */ useFuzzyFilter?: boolean; } }