Skip to content

Commit

Permalink
fix #4732 non-blocking upload API
Browse files Browse the repository at this point in the history
Signed-off-by: Anton Kosyakov <anton.kosyakov@typefox.io>
  • Loading branch information
akosyakov committed May 9, 2019
1 parent d196825 commit ecf2d25
Show file tree
Hide file tree
Showing 14 changed files with 522 additions and 248 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 0 additions & 2 deletions packages/filesystem/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -37,30 +33,6 @@ export class FileDownloadCommandContribution implements CommandContribution {
registerCommands(registry: CommandRegistry): void {
const handler = new UriAwareCommandHandler<URI[]>(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<void> {
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<URI[]> {
Expand Down Expand Up @@ -93,10 +65,4 @@ export namespace FileDownloadCommands {
label: 'Download'
};

export const UPLOAD: Command = {
id: 'file.upload',
category: 'File',
label: 'Upload Files...'
};

}
110 changes: 5 additions & 105 deletions packages/filesystem/src/browser/download/file-download-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<void>();
upload(targetUri: string | URI): Promise<void> {
this.deferredUpload = new Deferred<void>();
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<void> {
if (uris.length === 0) {
Expand Down Expand Up @@ -272,7 +172,7 @@ export class FileDownloadService {
}

protected filesUrl(): string {
return new Endpoint({ path: 'files' }).getRestUrl().toString();
return this.filesEndpoint.url.toString();
}

}
77 changes: 0 additions & 77 deletions packages/filesystem/src/browser/file-tree/file-tree-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<void> {
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<void> {
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 });
}
}

}
39 changes: 25 additions & 14 deletions packages/filesystem/src/browser/file-tree/file-tree-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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<void> {
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);
}
}
}
Expand Down
Loading

0 comments on commit ecf2d25

Please sign in to comment.