From 3c6da2b97ab8e047a32e2649297ee564860570bc Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Tue, 7 May 2019 09:50:59 +0000 Subject: [PATCH] fix #4732 non-blocking upload API Signed-off-by: Anton Kosyakov --- CHANGELOG.md | 2 + packages/filesystem/package.json | 2 - .../file-download-command-contribution.ts | 34 -- .../browser/download/file-download-service.ts | 110 +----- .../src/browser/file-tree/file-tree-model.ts | 77 ---- .../browser/file-tree/file-tree-widget.tsx | 39 +- .../src/browser/file-upload-service.ts | 344 ++++++++++++++++++ .../filesystem/src/browser/files-endpoint.ts | 30 ++ .../filesystem-frontend-contribution.ts | 53 ++- .../src/browser/filesystem-frontend-module.ts | 11 +- .../node/download/file-download-endpoint.ts | 11 +- .../src/browser/navigator-contribution.ts | 3 +- .../src/browser/workspace-commands.ts | 3 +- yarn.lock | 6 +- 14 files changed, 478 insertions(+), 247 deletions(-) create mode 100644 packages/filesystem/src/browser/file-upload-service.ts create mode 100644 packages/filesystem/src/browser/files-endpoint.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a0df0d7371b67..ee19a050446b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ Breaking changes: - [preferences] refactored to integrate launch configurations as preferences +- [filesystem] extracted `FileUploadService` and refactored `FileTreeWidget` to use it [#5086](https://github.com/theia-ide/theia/pull/5086) + - moved `FileDownloadCommands.UPLOAD` to `FileSystemCommands.UPLOAD` ## v0.6.0 diff --git a/packages/filesystem/package.json b/packages/filesystem/package.json index 0e96465df1d11..fdb9ad3fa9758 100644 --- a/packages/filesystem/package.json +++ b/packages/filesystem/package.json @@ -4,7 +4,6 @@ "description": "Theia - FileSystem Extension", "dependencies": { "@theia/core": "^0.6.0", - "@types/base64-js": "^1.2.5", "@types/body-parser": "^1.17.0", "@types/formidable": "^1.0.31", "@types/fs-extra": "^4.0.2", @@ -13,7 +12,6 @@ "@types/tar-fs": "^1.16.1", "@types/touch": "0.0.1", "@types/uuid": "^3.4.3", - "base64-js": "^1.2.1", "body-parser": "^1.18.3", "drivelist": "^6.4.3", "formidable": "^1.2.1", diff --git a/packages/filesystem/src/browser/download/file-download-command-contribution.ts b/packages/filesystem/src/browser/download/file-download-command-contribution.ts index 4e19defa86b9f..c44b8275142d8 100644 --- a/packages/filesystem/src/browser/download/file-download-command-contribution.ts +++ b/packages/filesystem/src/browser/download/file-download-command-contribution.ts @@ -19,11 +19,7 @@ import URI from '@theia/core/lib/common/uri'; import { SelectionService } from '@theia/core/lib/common/selection-service'; import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/common/command'; import { UriAwareCommandHandler, UriCommandHandler } from '@theia/core/lib/common/uri-command-handler'; -import { ExpandableTreeNode } from '@theia/core/lib/browser/tree'; import { FileDownloadService } from './file-download-service'; -import { FileSelection } from '../file-selection'; -import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-selection'; -import { isCancelled } from '@theia/core/lib/common/cancellation'; @injectable() export class FileDownloadCommandContribution implements CommandContribution { @@ -37,30 +33,6 @@ export class FileDownloadCommandContribution implements CommandContribution { registerCommands(registry: CommandRegistry): void { const handler = new UriAwareCommandHandler(this.selectionService, this.downloadHandler(), { multi: true }); registry.registerCommand(FileDownloadCommands.DOWNLOAD, handler); - registry.registerCommand(FileDownloadCommands.UPLOAD, new FileSelection.CommandHandler(this.selectionService, { - multi: false, - isEnabled: selection => this.canUpload(selection), - isVisible: selection => this.canUpload(selection), - execute: selection => this.upload(selection) - })); - } - - protected canUpload({ fileStat }: FileSelection): boolean { - return fileStat.isDirectory; - } - - protected async upload(selection: FileSelection): Promise { - try { - const source = TreeWidgetSelection.getSource(this.selectionService.selection); - await this.downloadService.upload(selection.fileStat.uri); - if (ExpandableTreeNode.is(selection) && source) { - await source.model.expandNode(selection); - } - } catch (e) { - if (!isCancelled(e)) { - console.error(e); - } - } } protected downloadHandler(): UriCommandHandler { @@ -93,10 +65,4 @@ export namespace FileDownloadCommands { label: 'Download' }; - export const UPLOAD: Command = { - id: 'file.upload', - category: 'File', - label: 'Upload Files...' - }; - } diff --git a/packages/filesystem/src/browser/download/file-download-service.ts b/packages/filesystem/src/browser/download/file-download-service.ts index 9f057c905ca38..78226691a6a1f 100644 --- a/packages/filesystem/src/browser/download/file-download-service.ts +++ b/packages/filesystem/src/browser/download/file-download-service.ts @@ -14,16 +14,14 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { inject, injectable, postConstruct } from 'inversify'; +import { inject, injectable } from 'inversify'; import URI from '@theia/core/lib/common/uri'; -import { cancelled } from '@theia/core/lib/common/cancellation'; import { ILogger } from '@theia/core/lib/common/logger'; -import { Endpoint } from '@theia/core/lib/browser/endpoint'; import { StatusBar, StatusBarAlignment } from '@theia/core/lib/browser/status-bar'; import { FileSystem } from '../../common/filesystem'; import { FileDownloadData } from '../../common/download/file-download-data'; -import { Deferred } from '@theia/core/lib/common/promise-util'; import { MessageService } from '@theia/core/lib/common/message-service'; +import { FilesEndpoint } from '../files-endpoint'; @injectable() export class FileDownloadService { @@ -46,106 +44,8 @@ export class FileDownloadService { @inject(MessageService) protected readonly messageService: MessageService; - protected uploadForm: { - target: HTMLInputElement - file: HTMLInputElement - }; - - @postConstruct() - protected init(): void { - this.uploadForm = this.createUploadForm(); - } - - protected createUploadForm(): { - target: HTMLInputElement - file: HTMLInputElement - } { - const target = document.createElement('input'); - target.type = 'text'; - target.name = 'target'; - - const file = document.createElement('input'); - file.type = 'file'; - file.name = 'upload'; - file.multiple = true; - - const form = document.createElement('form'); - form.style.display = 'none'; - form.enctype = 'multipart/form-data'; - form.append(target); - form.append(file); - - document.body.appendChild(form); - - file.addEventListener('change', async () => { - if (file.value) { - const body = new FormData(form); - // clean up to allow upload to the same folder twice - file.value = ''; - const filesUrl = this.filesUrl(); - const deferredUpload = this.deferredUpload; - try { - const request = new XMLHttpRequest(); - - const cb = () => { - if (request.status === 200) { - deferredUpload.resolve(); - } else { - let statusText = request.statusText; - if (!statusText) { - if (request.status === 413) { - statusText = 'Payload Too Large'; - } else if (request.status) { - statusText = String(request.status); - } else { - statusText = 'Network Failure'; - } - } - const message = 'Upload Failed: ' + statusText; - deferredUpload.reject(new Error(message)); - this.messageService.error(message); - } - }; - request.addEventListener('load', cb); - request.addEventListener('error', cb); - request.addEventListener('abort', () => deferredUpload.reject(cancelled())); - - const progress = await this.messageService.showProgress({ - text: 'Uploading Files...', options: { cancelable: true } - }, () => { - request.upload.removeEventListener('progress', progressListener); - request.abort(); - }); - deferredUpload.promise.then(() => progress.cancel(), () => progress.cancel()); - const progressListener = (event: ProgressEvent) => { - if (event.lengthComputable) { - progress.report({ - work: { - done: event.loaded, - total: event.total - } - }); - } - }; - request.upload.addEventListener('progress', progressListener); - - request.open('POST', filesUrl); - request.send(body); - } catch (e) { - deferredUpload.reject(e); - } - } - }); - return { target, file }; - } - - protected deferredUpload = new Deferred(); - upload(targetUri: string | URI): Promise { - this.deferredUpload = new Deferred(); - this.uploadForm.target.value = String(targetUri); - this.uploadForm.file.click(); - return this.deferredUpload.promise; - } + @inject(FilesEndpoint) + protected readonly filesEndpoint: FilesEndpoint; async download(uris: URI[]): Promise { if (uris.length === 0) { @@ -272,7 +172,7 @@ export class FileDownloadService { } protected filesUrl(): string { - return new Endpoint({ path: 'files' }).getRestUrl().toString(); + return this.filesEndpoint.url.toString(); } } diff --git a/packages/filesystem/src/browser/file-tree/file-tree-model.ts b/packages/filesystem/src/browser/file-tree/file-tree-model.ts index dd62b594b37bf..2b03c01a0c594 100644 --- a/packages/filesystem/src/browser/file-tree/file-tree-model.ts +++ b/packages/filesystem/src/browser/file-tree/file-tree-model.ts @@ -22,7 +22,6 @@ import { FileSystemWatcher, FileChangeType, FileChange, FileMoveEvent } from '.. import { FileStatNode, DirNode, FileNode } from './file-tree'; import { LocationService } from '../location'; import { LabelProvider } from '@theia/core/lib/browser/label-provider'; -import * as base64 from 'base64-js'; @injectable() export class FileTreeModel extends TreeModelImpl implements LocationService { @@ -188,80 +187,4 @@ export class FileTreeModel extends TreeModelImpl implements LocationService { return !!await dialog.open(); } - upload(node: DirNode, items: DataTransferItemList): void { - for (let i = 0; i < items.length; i++) { - const entry = items[i].webkitGetAsEntry() as WebKitEntry; - this.uploadEntry(node.uri, entry); - } - } - - protected uploadEntry(base: URI, entry: WebKitEntry | null): void { - if (!entry) { - return; - } - if (entry.isDirectory) { - this.uploadDirectoryEntry(base, entry as WebKitDirectoryEntry); - } else { - this.uploadFileEntry(base, entry as WebKitFileEntry); - } - } - - protected async uploadDirectoryEntry(base: URI, entry: WebKitDirectoryEntry): Promise { - const newBase = base.resolve(entry.name); - const uri = newBase.toString(); - if (!await this.fileSystem.exists(uri)) { - await this.fileSystem.createFolder(uri); - } - this.readEntries(entry, items => this.uploadEntries(newBase, items)); - } - - /** - * Read all entries within a folder by block of 100 files or folders until the - * whole folder has been read. - */ - // tslint:disable-next-line:no-any - protected readEntries(entry: WebKitDirectoryEntry, cb: (items: any) => void): void { - const reader = entry.createReader(); - const getEntries = () => { - reader.readEntries(results => { - if (results) { - cb(results); - getEntries(); // loop to read all entries - } - }); - }; - getEntries(); - } - - protected uploadEntries(base: URI, entries: WebKitEntry[]): void { - for (let i = 0; i < entries.length; i++) { - this.uploadEntry(base, entries[i]); - } - } - - protected uploadFileEntry(base: URI, entry: WebKitFileEntry): void { - // tslint:disable-next-line:no-any - entry.file(file => this.uploadFile(base, file as any)); - } - - protected uploadFile(base: URI, file: File): void { - const reader = new FileReader(); - reader.onload = () => this.uploadFileContent(base.resolve(file.name), reader.result as ArrayBuffer); - reader.readAsArrayBuffer(file); - } - - protected async uploadFileContent(base: URI, fileContent: ArrayBuffer): Promise { - const uri = base.toString(); - const encoding = 'base64'; - const content = base64.fromByteArray(new Uint8Array(fileContent)); - const stat = await this.fileSystem.getFileStat(uri); - if (stat) { - if (!stat.isDirectory) { - await this.fileSystem.setContent(stat, content, { encoding }); - } - } else { - await this.fileSystem.createFile(uri, { content, encoding }); - } - } - } diff --git a/packages/filesystem/src/browser/file-tree/file-tree-widget.tsx b/packages/filesystem/src/browser/file-tree/file-tree-widget.tsx index a979fb0c04745..0d089ed5261e7 100644 --- a/packages/filesystem/src/browser/file-tree/file-tree-widget.tsx +++ b/packages/filesystem/src/browser/file-tree/file-tree-widget.tsx @@ -14,13 +14,15 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import * as React from 'react'; import { injectable, inject } from 'inversify'; +import { DisposableCollection, Disposable } from '@theia/core/lib/common'; +import { UriSelection } from '@theia/core/lib/common/selection'; +import { isCancelled } from '@theia/core/lib/common/cancellation'; import { ContextMenuRenderer, NodeProps, TreeProps, TreeNode, TreeWidget } from '@theia/core/lib/browser'; +import { FileUploadService } from '../file-upload-service'; import { DirNode, FileStatNode } from './file-tree'; import { FileTreeModel } from './file-tree-model'; -import { DisposableCollection, Disposable } from '@theia/core/lib/common'; -import { UriSelection } from '@theia/core/lib/common/selection'; -import * as React from 'react'; export const FILE_TREE_CLASS = 'theia-FileTree'; export const FILE_STAT_NODE_CLASS = 'theia-FileStatNode'; @@ -32,6 +34,9 @@ export class FileTreeWidget extends TreeWidget { protected readonly toCancelNodeExpansion = new DisposableCollection(); + @inject(FileUploadService) + protected readonly uploadService: FileUploadService; + constructor( @inject(TreeProps) readonly props: TreeProps, @inject(FileTreeModel) readonly model: FileTreeModel, @@ -127,17 +132,23 @@ export class FileTreeWidget extends TreeWidget { this.toCancelNodeExpansion.dispose(); } - protected handleDropEvent(node: TreeNode | undefined, event: React.DragEvent): void { - event.preventDefault(); - event.stopPropagation(); - event.dataTransfer.dropEffect = 'copy'; // Explicitly show this is a copy. - const containing = DirNode.getContainingDir(node); - if (containing) { - const source = this.getTreeNodeFromData(event.dataTransfer); - if (source) { - this.model.move(source, containing); - } else { - this.model.upload(containing, event.dataTransfer.items); + protected async handleDropEvent(node: TreeNode | undefined, event: React.DragEvent): Promise { + try { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = 'copy'; // Explicitly show this is a copy. + const containing = DirNode.getContainingDir(node); + if (containing) { + const source = this.getTreeNodeFromData(event.dataTransfer); + if (source) { + await this.model.move(source, containing); + } else { + await this.uploadService.upload(containing.uri, { source: event.dataTransfer }); + } + } + } catch (e) { + if (!isCancelled(e)) { + console.error(e); } } } diff --git a/packages/filesystem/src/browser/file-upload-service.ts b/packages/filesystem/src/browser/file-upload-service.ts new file mode 100644 index 0000000000000..7ed97139718b9 --- /dev/null +++ b/packages/filesystem/src/browser/file-upload-service.ts @@ -0,0 +1,344 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +// tslint:disable:no-any + +import { injectable, inject, postConstruct } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; +import { cancelled, CancellationTokenSource, CancellationToken, checkCancelled } from '@theia/core/lib/common/cancellation'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { MessageService } from '@theia/core/lib/common/message-service'; +import { Progress } from '@theia/core/src/common/message-service-protocol'; +import { MaybePromise } from '@theia/core/src/common/types'; +import { FilesEndpoint } from './files-endpoint'; + +// limit upload size to avoid out of memory in main process +const maxUploadSize = 64 * 1024 * 1024; + +export interface FileUploadParams { + source?: DataTransfer + progress?: FileUploadProgressParams +} +export interface FileUploadProgressParams { + text: string +} + +export interface FileUploadResult { + uploaded: URI[] +} + +@injectable() +export class FileUploadService { + + static TARGET = 'target'; + static UPLOAD = 'upload'; + + @inject(MessageService) + protected readonly messageService: MessageService; + + @inject(FilesEndpoint) + protected readonly endpoint: FilesEndpoint; + + protected uploadForm: FileUploadService.Form; + + @postConstruct() + protected init(): void { + this.uploadForm = this.createUploadForm(); + } + + protected createUploadForm(): FileUploadService.Form { + const targetInput = document.createElement('input'); + targetInput.type = 'text'; + targetInput.name = FileUploadService.TARGET; + + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.name = FileUploadService.UPLOAD; + fileInput.multiple = true; + + const form = document.createElement('form'); + form.style.display = 'none'; + form.enctype = 'multipart/form-data'; + form.append(targetInput); + form.append(fileInput); + + document.body.appendChild(form); + + fileInput.addEventListener('change', () => { + if (this.deferredUpload && fileInput.value) { + const body = new FormData(form); + // clean up to allow upload to the same folder twice + fileInput.value = ''; + const target = new URI(body.get(FileUploadService.TARGET)); + const uploaded = body.getAll(FileUploadService.UPLOAD).map((file: File) => target.resolve(file.name)); + const { resolve, reject } = this.deferredUpload; + this.deferredUpload = undefined; + this.withProgress((progress, token) => this.submitForm({ + body, token, + onDidProgress: event => { + if (event.lengthComputable) { + progress.report({ + work: { + done: event.loaded, + total: event.total + } + }); + } + } + }), this.uploadForm.progress).then(() => resolve({ uploaded }), reject); + } + }); + return { targetInput, fileInput }; + } + + protected deferredUpload: Deferred | undefined; + async upload(targetUri: string | URI, params: FileUploadParams = {}): Promise { + const { source } = params; + if (source) { + return this.withProgress(async (progress, token) => { + const context: FileUploadService.Context = { entries: [], progress, token }; + await this.indexDataTransfer(new URI(String(targetUri)), source, context); + return this.doUpload(context); + }, params.progress); + } + this.deferredUpload = new Deferred(); + this.uploadForm.targetInput.value = String(targetUri); + this.uploadForm.fileInput.click(); + this.uploadForm.progress = params.progress; + return this.deferredUpload.promise; + } + + protected async doUpload({ entries, progress, token }: FileUploadService.Context): Promise { + const result: FileUploadResult = { uploaded: [] }; + if (!entries.length) { + return result; + } + let done = 0; + const total = entries.length; + let chunkSize = 0; + let chunkLength = 0; + let body = new FormData(); + const upload = async () => { + progress.report({ work: { done, total } }); + await this.submitForm({ + body, token, + onDidProgress: event => { + if (event.lengthComputable) { + const chunkDone = chunkLength * event.loaded / event.total; + progress.report({ + work: { + done: done + chunkDone, + total + } + }); + } + } + }); + checkCancelled(token); + for (const file of body.getAll(FileUploadService.UPLOAD)) { + result.uploaded.push(new URI((file as File).name)); + } + done += chunkLength; + progress.report({ work: { done, total } }); + chunkLength = 0; + chunkSize = 0; + body = new FormData(); + }; + for (const entry of entries) { + const file = await entry.file(); + checkCancelled(token); + if (chunkLength && chunkSize + file.size > maxUploadSize) { + await upload(); + } + chunkLength++; + chunkSize += file.size; + body.append(FileUploadService.UPLOAD, file, entry.uri.toString()); + } + if (chunkLength) { + await upload(); + } + progress.report({ work: { done: total, total } }); + return result; + } + + protected async withProgress( + cb: (progress: Progress, token: CancellationToken) => Promise, + { text }: FileUploadProgressParams = { text: 'Uploading Files...' } + ): Promise { + const cancellationSource = new CancellationTokenSource(); + const { token } = cancellationSource; + const progress = await this.messageService.showProgress({ text, options: { cancelable: true } }, () => cancellationSource.cancel()); + try { + return await cb(progress, token); + } finally { + progress.cancel(); + } + } + + protected async indexDataTransfer(targetUri: URI, dataTransfer: DataTransfer, context: FileUploadService.Context): Promise { + checkCancelled(context.token); + if (dataTransfer.items) { + await this.indexDataTransferItemList(targetUri, dataTransfer.items, context); + } else { + this.indexFileList(targetUri, dataTransfer.files, context); + } + } + + protected indexFileList(targetUri: URI, files: FileList, context: FileUploadService.Context): void { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (file) { + this.indexFile(targetUri, file, context); + } + } + } + + protected indexFile(targetUri: URI, file: File, context: FileUploadService.Context): void { + context.entries.push({ + uri: targetUri.resolve(file.name), + file: () => file + }); + } + + protected async indexDataTransferItemList(targetUri: URI, items: DataTransferItemList, context: FileUploadService.Context): Promise { + checkCancelled(context.token); + const promises: Promise[] = []; + for (let i = 0; i < items.length; i++) { + const entry = items[i].webkitGetAsEntry() as WebKitEntry; + promises.push(this.indexEntry(targetUri, entry, context)); + } + await Promise.all(promises); + } + + protected async indexEntry(targetUri: URI, entry: WebKitEntry | null, context: FileUploadService.Context): Promise { + checkCancelled(context.token); + if (!entry) { + return; + } + if (entry.isDirectory) { + await this.indexDirectoryEntry(targetUri, entry as WebKitDirectoryEntry, context); + } else { + this.indexFileEntry(targetUri, entry as WebKitFileEntry, context); + } + } + + protected async indexDirectoryEntry(targetUri: URI, entry: WebKitDirectoryEntry, context: FileUploadService.Context): Promise { + checkCancelled(context.token); + const newTargetUri = targetUri.resolve(entry.name); + const promises: Promise[] = []; + await this.readEntries(entry, items => promises.push(this.indexEntries(newTargetUri, items, context)), context); + await Promise.all(promises); + } + + /** + * Read all entries within a folder by block of 100 files or folders until the + * whole folder has been read. + */ + protected async readEntries(entry: WebKitDirectoryEntry, cb: (items: any) => void, context: FileUploadService.Context): Promise { + return new Promise(async (resolve, reject) => { + const reader = entry.createReader(); + const getEntries = () => reader.readEntries(results => { + if (!context.token.isCancellationRequested && results && results.length) { + cb(results); + getEntries(); // loop to read all entries + } else { + resolve(); + } + }, reject); + getEntries(); + }); + } + + protected async indexEntries(targetUri: URI, entries: WebKitEntry[], context: FileUploadService.Context): Promise { + checkCancelled(context.token); + const promises: Promise[] = []; + for (let i = 0; i < entries.length; i++) { + promises.push(this.indexEntry(targetUri, entries[i], context)); + } + await Promise.all(promises); + } + + protected indexFileEntry(targetUri: URI, entry: WebKitFileEntry, context: FileUploadService.Context): void { + context.entries.push({ + uri: targetUri.resolve(entry.name), + file: () => new Promise((resolve, reject) => entry.file(resolve, reject)) + }); + } + + protected async submitForm({ body, token, onDidProgress }: { + body: FormData, + token: CancellationToken + onDidProgress: (event: ProgressEvent) => void + }): Promise { + const deferredUpload = new Deferred(); + try { + const request = new XMLHttpRequest(); + + const cb = () => { + if (request.status === 200) { + deferredUpload.resolve(); + } else { + let statusText = request.statusText; + if (!statusText) { + if (request.status === 413) { + statusText = 'Payload Too Large'; + } else if (request.status) { + statusText = String(request.status); + } else { + statusText = 'Network Failure'; + } + } + const message = 'Upload Failed: ' + statusText; + deferredUpload.reject(new Error(message)); + this.messageService.error(message); + } + }; + request.addEventListener('load', cb); + request.addEventListener('error', cb); + request.addEventListener('abort', () => deferredUpload.reject(cancelled())); + + token.onCancellationRequested(() => { + request.upload.removeEventListener('progress', onDidProgress); + request.abort(); + }); + request.upload.addEventListener('progress', onDidProgress); + + request.open('POST', this.endpoint.url.toString()); + request.send(body); + } catch (e) { + deferredUpload.reject(e); + } + return deferredUpload.promise; + } + +} + +export namespace FileUploadService { + export interface UploadEntry { + file: () => MaybePromise + uri: URI + } + export interface Context { + progress: Progress + token: CancellationToken + entries: UploadEntry[] + } + export interface Form { + targetInput: HTMLInputElement + fileInput: HTMLInputElement + progress?: FileUploadProgressParams + } +} diff --git a/packages/filesystem/src/browser/files-endpoint.ts b/packages/filesystem/src/browser/files-endpoint.ts new file mode 100644 index 0000000000000..ef3c746b453a0 --- /dev/null +++ b/packages/filesystem/src/browser/files-endpoint.ts @@ -0,0 +1,30 @@ +/******************************************************************************** + * Copyright (C) 2019 TypeFox and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +import { injectable } from 'inversify'; +import URI from '@theia/core/lib/common/uri'; +import { Endpoint } from '@theia/core/lib/browser/endpoint'; + +@injectable() +export class FilesEndpoint { + + private readonly endpoint = new Endpoint({ path: 'files' }); + + get url(): URI { + return this.endpoint.getRestUrl(); + } + +} diff --git a/packages/filesystem/src/browser/filesystem-frontend-contribution.ts b/packages/filesystem/src/browser/filesystem-frontend-contribution.ts index a4dcdf54ebb95..d250d2845a1a4 100644 --- a/packages/filesystem/src/browser/filesystem-frontend-contribution.ts +++ b/packages/filesystem/src/browser/filesystem-frontend-contribution.ts @@ -16,18 +16,32 @@ import { injectable, inject } from 'inversify'; import URI from '@theia/core/lib/common/uri'; -import { MaybePromise } from '@theia/core/lib/common'; +import { MaybePromise, SelectionService, isCancelled } from '@theia/core/lib/common'; +import { Command, CommandContribution, CommandRegistry } from '@theia/core/lib/common/command'; import { FrontendApplicationContribution, ApplicationShell, NavigatableWidget, NavigatableWidgetOptions, - Saveable, WidgetManager, StatefulWidget, FrontendApplication + Saveable, WidgetManager, StatefulWidget, FrontendApplication, ExpandableTreeNode } from '@theia/core/lib/browser'; import { FileSystemWatcher, FileChangeEvent, FileMoveEvent, FileChangeType } from './filesystem-watcher'; import { MimeService } from '@theia/core/lib/browser/mime-service'; +import { TreeWidgetSelection } from '@theia/core/lib/browser/tree/tree-widget-selection'; import { FileSystemPreferences } from './filesystem-preferences'; +import { FileSelection } from './file-selection'; +import { FileUploadService } from './file-upload-service'; + +export namespace FileSystemCommands { + + export const UPLOAD: Command = { + id: 'file.upload', + category: 'File', + label: 'Upload Files...' + }; + +} @injectable() -export class FileSystemFrontendContribution implements FrontendApplicationContribution { +export class FileSystemFrontendContribution implements FrontendApplicationContribution, CommandContribution { @inject(ApplicationShell) protected readonly shell: ApplicationShell; @@ -44,6 +58,12 @@ export class FileSystemFrontendContribution implements FrontendApplicationContri @inject(FileSystemPreferences) protected readonly preferences: FileSystemPreferences; + @inject(SelectionService) + protected readonly selectionService: SelectionService; + + @inject(FileUploadService) + protected readonly uploadService: FileUploadService; + initialize(): void { this.fileSystemWatcher.onFilesChanged(event => this.run(() => this.updateWidgets(event))); this.fileSystemWatcher.onDidMove(event => this.run(() => this.moveWidgets(event))); @@ -58,6 +78,33 @@ export class FileSystemFrontendContribution implements FrontendApplicationContri }); } + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(FileSystemCommands.UPLOAD, new FileSelection.CommandHandler(this.selectionService, { + multi: false, + isEnabled: selection => this.canUpload(selection), + isVisible: selection => this.canUpload(selection), + execute: selection => this.upload(selection) + })); + } + + protected canUpload({ fileStat }: FileSelection): boolean { + return fileStat.isDirectory; + } + + protected async upload(selection: FileSelection): Promise { + try { + const source = TreeWidgetSelection.getSource(this.selectionService.selection); + await this.uploadService.upload(selection.fileStat.uri); + if (ExpandableTreeNode.is(selection) && source) { + await source.model.expandNode(selection); + } + } catch (e) { + if (!isCancelled(e)) { + console.error(e); + } + } + } + protected pendingOperation = Promise.resolve(); protected run(operation: () => MaybePromise): Promise { return this.pendingOperation = this.pendingOperation.then(async () => { diff --git a/packages/filesystem/src/browser/filesystem-frontend-module.ts b/packages/filesystem/src/browser/filesystem-frontend-module.ts index f1c3d1a49f7b3..1f1f1241cf6c0 100644 --- a/packages/filesystem/src/browser/filesystem-frontend-module.ts +++ b/packages/filesystem/src/browser/filesystem-frontend-module.ts @@ -17,7 +17,7 @@ import '../../src/browser/style/index.css'; import { ContainerModule, interfaces } from 'inversify'; -import { ResourceResolver } from '@theia/core/lib/common'; +import { ResourceResolver, CommandContribution } from '@theia/core/lib/common'; import { WebSocketConnectionProvider, FrontendApplicationContribution, ConfirmDialog } from '@theia/core/lib/browser'; import { FileSystem, fileSystemPath, FileShouldOverwrite, FileStat } from '../common'; import { @@ -29,6 +29,8 @@ import { bindFileSystemPreferences } from './filesystem-preferences'; import { FileSystemWatcher } from './filesystem-watcher'; import { FileSystemFrontendContribution } from './filesystem-frontend-contribution'; import { FileSystemProxyFactory } from './filesystem-proxy-factory'; +import { FilesEndpoint } from './files-endpoint'; +import { FileUploadService } from './file-upload-service'; export default new ContainerModule(bind => { bindFileSystemPreferences(bind); @@ -56,7 +58,12 @@ export default new ContainerModule(bind => { bindFileResource(bind); - bind(FrontendApplicationContribution).to(FileSystemFrontendContribution).inSingletonScope(); + bind(FilesEndpoint).toSelf().inSingletonScope(); + bind(FileUploadService).toSelf().inSingletonScope(); + + bind(FileSystemFrontendContribution).toSelf().inSingletonScope(); + bind(CommandContribution).toService(FileSystemFrontendContribution); + bind(FrontendApplicationContribution).toService(FileSystemFrontendContribution); }); export function bindFileResource(bind: interfaces.Bind): void { diff --git a/packages/filesystem/src/node/download/file-download-endpoint.ts b/packages/filesystem/src/node/download/file-download-endpoint.ts index be81f6306df09..dccd7580b116e 100644 --- a/packages/filesystem/src/node/download/file-download-endpoint.ts +++ b/packages/filesystem/src/node/download/file-download-endpoint.ts @@ -14,6 +14,8 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ +import * as path from 'path'; +import * as fs from 'fs-extra'; import { injectable, inject, named } from 'inversify'; import { json } from 'body-parser'; // tslint:disable-next-line:no-implicit-dependencies @@ -67,10 +69,13 @@ export class FileDownloadEndpoint implements BackendApplicationContribution { } }); form.on('fileBegin', (_: string, file: formidable.File) => { - if (targetUri) { - file.path = FileUri.fsPath(targetUri.resolve(file.name)); + const uri = targetUri ? targetUri.resolve(file.name) : new URI(file.name); + const fsPath = FileUri.fsPath(uri); + if (path.isAbsolute(fsPath)) { + fs.mkdirsSync(path.dirname(fsPath)); + file.path = fsPath; } else { - clientErrors.push(`cannot upload "${file.name}", target is not provided`); + clientErrors.push(`cannot upload "${file.name}", neither target or file uri provided`); } }); form.on('error', (error: Error) => { diff --git a/packages/navigator/src/browser/navigator-contribution.ts b/packages/navigator/src/browser/navigator-contribution.ts index 3390364c18dfa..89d908671b999 100644 --- a/packages/navigator/src/browser/navigator-contribution.ts +++ b/packages/navigator/src/browser/navigator-contribution.ts @@ -31,6 +31,7 @@ import { FileNavigatorFilter } from './navigator-filter'; import { WorkspaceNode } from './navigator-tree'; import { NavigatorContextKeyService } from './navigator-context-key-service'; import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar'; +import { FileSystemCommands } from '@theia/filesystem/lib/browser/filesystem-frontend-contribution'; export namespace FileNavigatorCommands { export const REVEAL_IN_NAVIGATOR: Command = { @@ -195,7 +196,7 @@ export class FileNavigatorContribution extends AbstractViewContribution boolean = require('valid-filename'); @@ -144,7 +145,7 @@ export class FileMenuContribution implements MenuContribution { }); const downloadUploadMenu = [...CommonMenus.FILE, '4_downloadupload']; registry.registerMenuAction(downloadUploadMenu, { - commandId: FileDownloadCommands.UPLOAD.id, + commandId: FileSystemCommands.UPLOAD.id, order: 'a' }); registry.registerMenuAction(downloadUploadMenu, { diff --git a/yarn.lock b/yarn.lock index 09ed0997b331a..c8b3895064de6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -133,10 +133,6 @@ version "0.1.0" resolved "https://registry.yarnpkg.com/@types/base64-arraybuffer/-/base64-arraybuffer-0.1.0.tgz#739eea0a974d13ae831f96d97d882ceb0b187543" -"@types/base64-js@^1.2.5": - version "1.2.5" - resolved "https://registry.yarnpkg.com/@types/base64-js/-/base64-js-1.2.5.tgz#582b2476169a6cba460a214d476c744441d873d5" - "@types/body-parser@*", "@types/body-parser@^1.16.4", "@types/body-parser@^1.17.0": version "1.17.0" resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.17.0.tgz#9f5c9d9bd04bb54be32d5eb9fc0d8c974e6cf58c" @@ -1594,7 +1590,7 @@ base64-js@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978" -base64-js@^1.0.2, base64-js@^1.2.1: +base64-js@^1.0.2: version "1.3.0" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.0.tgz#cab1e6118f051095e58b5281aea8c1cd22bfc0e3"