-
Notifications
You must be signed in to change notification settings - Fork 2.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[filesystem] improve download and allow downloading of big files and parallel downloads. #4890
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -19,19 +19,16 @@ 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 { addClipboardListener } from '@theia/core/lib/browser/widgets'; | ||
|
||
@injectable() | ||
export class FileDownloadService { | ||
|
||
protected static PREPARING_DOWNLOAD_ID = 'theia-preparing-download'; | ||
|
||
protected anchor: HTMLAnchorElement | undefined; | ||
protected downloadQueue: number[] = []; | ||
protected downloadCounter: number = 0; | ||
|
||
@inject(ILogger) | ||
|
@@ -40,8 +37,6 @@ export class FileDownloadService { | |
@inject(FileSystem) | ||
protected readonly fileSystem: FileSystem; | ||
|
||
@inject(StatusBar) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From the CI:
|
||
protected readonly statusBar: StatusBar; | ||
|
||
@inject(MessageService) | ||
protected readonly messageService: MessageService; | ||
|
@@ -147,54 +142,68 @@ export class FileDownloadService { | |
return this.deferredUpload.promise; | ||
} | ||
|
||
protected handleCopy(event: ClipboardEvent, downloadUrl: string) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All methods and functions with Please, apply this rule to all the methods you have introduced. |
||
if (downloadUrl) { | ||
event.clipboardData.setData('text/plain', downloadUrl); | ||
event.preventDefault(); | ||
this.messageService.info('Download link copied!'); | ||
} | ||
} | ||
|
||
async cancelDownload(id: string) { | ||
await fetch(`${this.endpoint()}/download/?id=${id}&cancel=true`); | ||
} | ||
|
||
async download(uris: URI[]): Promise<void> { | ||
let cancel = false; | ||
if (uris.length === 0) { | ||
return; | ||
} | ||
let downloadId: number | undefined; | ||
try { | ||
downloadId = this.downloadCounter++; | ||
if (this.downloadQueue.length === 0) { | ||
await this.statusBar.setElement(FileDownloadService.PREPARING_DOWNLOAD_ID, { | ||
alignment: StatusBarAlignment.RIGHT, | ||
text: '$(spinner~spin) Preparing download...', | ||
tooltip: 'Preparing download...', | ||
priority: 1 | ||
}); | ||
const [progress, response] = await Promise.all([ | ||
this.messageService.showProgress({ | ||
text: 'Preparing download link...', options: { cancelable: true } | ||
}, () => { cancel = true; }), | ||
fetch(this.request(uris)) | ||
]); | ||
const jsonResponse = await response.json(); | ||
if (cancel) { | ||
this.cancelDownload(jsonResponse.id); | ||
return; | ||
} | ||
this.downloadQueue.push(downloadId); | ||
const response = await fetch(this.request(uris)); | ||
await this.statusBar.removeElement(FileDownloadService.PREPARING_DOWNLOAD_ID); | ||
const title = await this.title(response, uris); | ||
const { status, statusText } = response; | ||
if (status === 200) { | ||
await this.forceDownload(response, decodeURIComponent(title)); | ||
progress.cancel(); | ||
const downloadUrl = `${this.endpoint()}/download/?id=${jsonResponse.id}`; | ||
this.messageService.info(downloadUrl, 'Download', 'Copy Download Link').then(action => { | ||
if (action === 'Download') { | ||
this.forceDownload(jsonResponse.id, decodeURIComponent(jsonResponse.name)); | ||
this.messageService.info('Download started!'); | ||
} else if (action === 'Copy Download Link') { | ||
if (document.documentElement) { | ||
addClipboardListener(document.documentElement, 'copy', e => this.handleCopy(e, downloadUrl)); | ||
document.execCommand('copy'); | ||
} | ||
} else { | ||
this.cancelDownload(jsonResponse.id); | ||
} | ||
}); | ||
} 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); | ||
} finally { | ||
if (downloadId !== undefined) { | ||
const indexOf = this.downloadQueue.indexOf(downloadId); | ||
if (indexOf !== -1) { | ||
this.downloadQueue.splice(indexOf, 1); | ||
} | ||
if (this.downloadQueue.length === 0) { | ||
this.statusBar.removeElement(FileDownloadService.PREPARING_DOWNLOAD_ID); | ||
} | ||
} | ||
} | ||
} | ||
|
||
protected async forceDownload(response: Response, title: string): Promise<void> { | ||
protected async forceDownload(id: string, 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'); | ||
} | ||
const endpoint = this.endpoint(); | ||
url = `${endpoint}/download/?id=${id}`; | ||
this.anchor.href = url; | ||
this.anchor.style.display = 'none'; | ||
this.anchor.download = title; | ||
|
@@ -211,24 +220,6 @@ export class FileDownloadService { | |
} | ||
} | ||
|
||
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); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,88 @@ | ||
/******************************************************************************** | ||
* Copyright (C) 2019 Bitsler 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, inject } from 'inversify'; | ||
import { ILogger } from '@theia/core/lib/common/logger'; | ||
import * as rimraf from 'rimraf'; | ||
|
||
export interface DownloadStorageItem { | ||
file: string; | ||
root?: string; | ||
size: number; | ||
remove: boolean; | ||
expire?: number; | ||
} | ||
|
||
@injectable() | ||
export class FileDownloadCache { | ||
|
||
@inject(ILogger) | ||
protected readonly logger: ILogger; | ||
protected readonly downloads = new Map<string, DownloadStorageItem>(); | ||
protected readonly expireTimeInMinutes: number = 1; | ||
|
||
addDownload(id: string, downloadInfo: DownloadStorageItem): void { | ||
downloadInfo.file = encodeURIComponent(downloadInfo.file); | ||
if (downloadInfo.root) { | ||
downloadInfo.root = encodeURIComponent(downloadInfo.root); | ||
} | ||
// expires in 1 minute enough for parallel connections to be connected. | ||
downloadInfo.expire = Date.now() + (this.expireTimeInMinutes * 600000); | ||
this.downloads.set(id, downloadInfo); | ||
} | ||
|
||
getDownload(id: string): DownloadStorageItem | undefined { | ||
this.expireDownloads(); | ||
const downloadInfo = this.downloads.get(id); | ||
if (downloadInfo) { | ||
|
||
downloadInfo.file = decodeURIComponent(downloadInfo.file); | ||
if (downloadInfo.root) { | ||
downloadInfo.root = decodeURIComponent(downloadInfo.root); | ||
} | ||
} | ||
return downloadInfo; | ||
} | ||
|
||
deleteDownload(id: string): void { | ||
const downloadInfo = this.downloads.get(id); | ||
if (downloadInfo && downloadInfo.remove) { | ||
this.deleteRecursively(downloadInfo.root || downloadInfo.file); | ||
} | ||
this.downloads.delete(id); | ||
} | ||
|
||
values(): { [key: string]: DownloadStorageItem } { | ||
this.expireDownloads(); | ||
return [...this.downloads.entries()].reduce((downloads, [key, value]) => ({ ...downloads, [key]: value }), {}); | ||
} | ||
|
||
protected deleteRecursively(pathToDelete: string): void { | ||
rimraf(pathToDelete, error => { | ||
if (error) { | ||
this.logger.warn(`An error occurred while deleting the temporary data from the disk. Cannot clean up: ${pathToDelete}.`, error); | ||
} | ||
}); | ||
} | ||
|
||
protected expireDownloads(): void { | ||
const time = Date.now(); | ||
for (const [id, download] of this.downloads.entries()) { | ||
if (download.expire && download.expire <= time) { | ||
this.deleteDownload(id); | ||
} | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
All symbols with
protected
andpublic
(default) visibility are part of the API, if you change it, you have to declare your modifications as a breaking change in the changelog.