Skip to content

Commit

Permalink
GH-1905: Implemented the file download functionality.
Browse files Browse the repository at this point in the history
Closes #1905.

Signed-off-by: Akos Kitta <kittaakos@gmail.com>
  • Loading branch information
kittaakos committed Jun 19, 2018
1 parent e7eb69f commit 98488de
Show file tree
Hide file tree
Showing 19 changed files with 904 additions and 20 deletions.
2 changes: 1 addition & 1 deletion examples/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,4 @@
"devDependencies": {
"@theia/cli": "^0.3.11"
}
}
}
2 changes: 1 addition & 1 deletion examples/electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,4 @@
"devDependencies": {
"@theia/cli": "^0.3.11"
}
}
}
6 changes: 3 additions & 3 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"@theia/application-package": "^0.3.11",
"@types/body-parser": "^1.16.4",
"@types/bunyan": "^1.8.0",
"@types/express": "^4.0.36",
"@types/express": "^4.16.0",
"@types/lodash.debounce": "4.0.3",
"@types/lodash.throttle": "^4.1.3",
"@types/route-parser": "^0.1.1",
Expand All @@ -20,7 +20,7 @@
"bunyan": "^1.8.10",
"electron": "1.8.2-beta.5",
"es6-promise": "^4.2.4",
"express": "^4.15.3",
"express": "^4.16.3",
"file-icons-js": "^1.0.3",
"font-awesome": "^4.7.0",
"inversify": "^4.2.0",
Expand Down Expand Up @@ -80,4 +80,4 @@
"nyc": {
"extends": "../../configs/nyc.json"
}
}
}
7 changes: 7 additions & 0 deletions packages/core/src/common/objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,10 @@ const _hasOwnProperty = Object.prototype.hasOwnProperty;
export function notEmpty<T>(arg: T | undefined | null): arg is T {
return arg !== undefined && arg !== null;
}

/**
* `true` if the argument is an empty object. Otherwise, `false`.
*/
export function isEmpty(arg: Object): boolean {
return Object.keys(arg).length === 0 && arg.constructor === Object;
}
20 changes: 18 additions & 2 deletions packages/filesystem/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,25 @@
"dependencies": {
"@theia/core": "^0.3.11",
"@types/base64-js": "^1.2.5",
"@types/body-parser": "^1.17.0",
"@types/fs-extra": "^4.0.2",
"@types/mime-types": "^2.1.0",
"@types/rimraf": "^2.0.2",
"@types/touch": "0.0.1",
"@types/tar-fs": "^1.16.1",
"@types/uuid": "^3.4.3",
"base64-js": "^1.2.1",
"body-parser": "^1.18.3",
"fs-extra": "^4.0.2",
"http-status-codes": "^1.3.0",
"mime-types": "^2.1.18",
"mv": "^2.1.1",
"touch": "^3.1.0",
"trash": "^4.0.1"
"trash": "^4.0.1",
"rimraf": "^2.6.2",
"uuid": "^3.2.1",
"tar-fs": "^1.16.2",
"zip-dir": "^1.0.2"
},
"publishConfig": {
"access": "public"
Expand All @@ -20,6 +32,10 @@
{
"frontend": "lib/browser/filesystem-frontend-module",
"backend": "lib/node/filesystem-backend-module"
},
{
"frontend": "lib/browser/download/file-download-frontend-module",
"backend": "lib/node/download/file-download-backend-module"
}
],
"keywords": [
Expand Down Expand Up @@ -53,4 +69,4 @@
"nyc": {
"extends": "../../configs/nyc.json"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/*
* Copyright (C) 2018 TypeFox and others.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*/

import { inject, injectable } from 'inversify';
import URI from '@theia/core/lib/common/uri';
import { notEmpty } from '@theia/core/lib/common/objects';
import { UriSelection } from '@theia/core/lib/common/selection';
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 { FileDownloadService } from './file-download-service';

@injectable()
export class FileDownloadCommandContribution implements CommandContribution {

@inject(FileDownloadService)
protected readonly downloadService: FileDownloadService;

@inject(SelectionService)
protected readonly selectionService: SelectionService;

registerCommands(registry: CommandRegistry): void {
const options = {
multi: true,
isValid: this.isValid.bind(this)
};
const handler = new UriAwareCommandHandler<URI[]>(this.selectionService, this.downloadHandler(), options);
registry.registerCommand(FileDownloadCommands.DOWNLOAD, handler);
}

protected downloadHandler(): UriCommandHandler<URI[]> {
return {
execute: uri => this.executeDownload(uri),
isEnabled: (uri, args) => this.isDownloadEnabled(uri, args),
isVisible: (uri, args) => this.isDownloadVisible(uri, args),
};
}

protected async executeDownload(uris: URI[]): Promise<void> {
this.downloadService.download(uris);
}

// tslint:disable-next-line:no-any
protected isDownloadEnabled(uri: Object | undefined, ...args: any[]): boolean {
return this.getUris(uri).length > 0;
}

// tslint:disable-next-line:no-any
protected isDownloadVisible(uri: Object | undefined, ...args: any[]): boolean {
return this.isDownloadEnabled(uri, args);
}

protected isValid(uris: URI[]): boolean {
return uris.every(u => u.scheme === 'file');
}

protected getUris(uri: Object | undefined): URI[] {
if (uri === undefined) {
return [];
}
return (Array.isArray(uri) ? uri : [uri]).map(u => this.getUri(u)).filter(notEmpty);
}

protected getUri(uri: Object | undefined): URI | undefined {
if (uri instanceof URI) {
return uri;
}
if (UriSelection.is(uri)) {
return uri.uri;
}
return undefined;
}

}

export namespace FileDownloadCommands {

export const DOWNLOAD: Command = {
id: 'file.download'
};

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* Copyright (C) 2018 TypeFox and others.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*/

import { ContainerModule } from 'inversify';
import { CommandContribution } from '@theia/core/lib/common/command';
import { FileDownloadService } from './file-download-service';
import { FileDownloadCommandContribution } from './file-download-command-contribution';

export default new ContainerModule(bind => {
bind(FileDownloadService).toSelf().inSingletonScope();
bind(CommandContribution).to(FileDownloadCommandContribution).inSingletonScope();
});
123 changes: 123 additions & 0 deletions packages/filesystem/src/browser/download/file-download-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright (C) 2018 TypeFox and others.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*/

import { inject, injectable } from 'inversify';
import URI from '@theia/core/lib/common/uri';
import { ILogger } from '@theia/core/lib/common/logger';
import { Endpoint } from '@theia/core/lib/browser/endpoint';
import { FileSystem } from '../../common/filesystem';
import { FileDownloadData } from '../../common/download/file-download-data';

@injectable()
export class FileDownloadService {

protected anchor: HTMLAnchorElement | undefined;

@inject(ILogger)
protected readonly logger: ILogger;

@inject(FileSystem)
protected readonly fileSystem: FileSystem;

async download(uris: URI[]): Promise<void> {
if (uris.length === 0) {
return;
}
try {
const response = await fetch(this.request(uris));
const title = await this.title(response, uris);
const { status, statusText } = response;
if (status === 200) {
this.forceDownload(response, title);
} else {
throw new Error(`Received unexpected status code: ${status}. [${statusText}]`);
}
} catch (e) {
this.logger.error(`Error occurred when downloading: ${uris.map(u => u.toString(true))}.`, e);
}
}

protected async forceDownload(response: Response, title: string): Promise<void> {
let url: string | undefined;
try {
const blob = await response.blob();
url = URL.createObjectURL(blob);
if (this.anchor === undefined) {
this.anchor = document.createElement('a');
this.anchor.style.display = 'none';
}
this.anchor.href = url;
this.anchor.download = title;
this.anchor.click();
} finally {
if (url) {
URL.revokeObjectURL(url);
}
}
}

protected async title(response: Response, uris: URI[]): Promise<string> {
let title = (response.headers.get('Content-Disposition') || '').split('attachment; filename=').pop();
if (title) {
return title;
}
// tslint:disable-next-line:whitespace
const [uri,] = uris;
if (uris.length === 1) {
const stat = await this.fileSystem.getFileStat(uri.toString());
if (stat === undefined) {
throw new Error(`Unexpected error occurred when downloading file. Files does not exist. URI: ${uri.toString(true)}.`);
}
title = uri.path.base;
return stat.isDirectory ? `${title}.tar` : title;
}
return `${uri.parent.path.name}.tar`;
}

protected request(uris: URI[]): Request {
const url = this.url(uris);
const init = this.requestInit(uris);
return new Request(url, init);
}

protected requestInit(uris: URI[]): RequestInit {
if (uris.length === 1) {
return {
body: undefined,
method: 'GET'
};
}
return {
method: 'PUT',
body: JSON.stringify(this.body(uris)),
headers: new Headers({ 'Content-Type': 'application/json' }),
};
}

protected body(uris: URI[]): FileDownloadData {
return {
uris: uris.map(u => u.toString(true))
};
}

protected url(uris: URI[]): string {
const endpoint = this.endpoint();
if (uris.length === 1) {
// tslint:disable-next-line:whitespace
const [uri,] = uris;
return `${endpoint}/?uri=${uri.toString()}`;
}
return endpoint;

}

protected endpoint(): string {
const url = new Endpoint({ path: 'files' }).getRestUrl().toString();
return url.endsWith('/') ? url.slice(0, -1) : url;
}

}
30 changes: 30 additions & 0 deletions packages/filesystem/src/common/download/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Theia - File Download

Provides the file download contribution to the `Files` navigator.

Supports single and multi file downloads.
1. A single file will be downloaded as is.
2. Folders will be downloaded az tar archives.
3. When downloading multiple files, the name of the closest common parent directory will be used for the archive.
4. When downloading multiple files from multiple disks (for instance: `C:\` and `D:\` on Windows), then we apply rule `3.` per disks and we tar the tars.

### REST API

- To download a single file or folder use the following endpoint: `GET /files/?uri=/encoded/file/uri/to/the/resource`.
- Example: `curl -X GET http://localhost:3000/files/?uri=file:///Users/akos.kitta/git/theia/package.json`.

- To download multiple files (from the same folder) use the `PUT /files/` endpoint with the `application/json` content type header and the following body format:
```json
{
"uri": [
"/encoded/file/uri/to/the/resource",
"/another/encoded/file/uri/to/the/resource"
]
}
```
```
curl -X PUT -H "Content-Type: application/json" -d '{ "uris": ["file:///Users/akos.kitta/git/theia/package.json", "file:///Users/akos.kitta/git/theia/README.md"] }' http://localhost:3000/files/
```

## License
[Apache-2.0](https://github.com/theia-ide/theia/blob/master/LICENSE)
16 changes: 16 additions & 0 deletions packages/filesystem/src/common/download/file-download-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/*
* Copyright (C) 2018 TypeFox and others.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*/

export interface FileDownloadData {
readonly uris: string[];
}

export namespace FileDownloadData {
export function is(arg: Object | undefined): arg is FileDownloadData {
return !!arg && 'uris' in arg;
}
}
4 changes: 2 additions & 2 deletions packages/filesystem/src/common/filesystem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const FileSystem = Symbol("FileSystem");
export interface FileSystem extends JsonRpcServer<FileSystemClient> {

/**
* Returns the filestat for the given uri.
* Returns the file stat for the given URI.
*
* If the uri points to a folder it will contain one level of unresolved children.
*
Expand Down Expand Up @@ -102,7 +102,7 @@ export interface FileSystem extends JsonRpcServer<FileSystemClient> {
getRoots(): Promise<FileStat[]>;

/**
* Returns a promise the resolves to a file stat representing the current user's home directory.
* Returns a promise that resolves to a file stat representing the current user's home directory.
*/
getCurrentUserHome(): Promise<FileStat | undefined>;

Expand Down
Loading

0 comments on commit 98488de

Please sign in to comment.