From d05c50f5b3f2c8ba9cec2f4150c058542026eb4b Mon Sep 17 00:00:00 2001 From: Anton Kosyakov Date: Wed, 27 Mar 2019 10:05:12 +0000 Subject: [PATCH] fix #4088: Add 'Upload Files...' menu entries Signed-off-by: Anton Kosyakov --- CHANGELOG.md | 1 + .../core/src/browser/tree/tree-expansion.ts | 6 +- .../core/src/browser/tree/tree-widget.tsx | 7 +- packages/core/src/browser/tree/tree.ts | 2 +- .../src/common/selection-command-handler.ts | 101 ++++++++++++++++++ packages/filesystem/package.json | 2 + .../file-download-command-contribution.ts | 50 +++++---- .../browser/download/file-download-service.ts | 64 ++++++++++- .../filesystem/src/browser/file-selection.ts | 43 ++++++++ .../src/browser/file-tree/file-tree.ts | 4 +- packages/filesystem/src/common/filesystem.ts | 6 +- .../node/download/file-download-endpoint.ts | 42 +++++++- .../src/browser/navigator-contribution.ts | 11 +- .../src/browser/workspace-commands.ts | 10 ++ yarn.lock | 13 +++ 15 files changed, 323 insertions(+), 39 deletions(-) create mode 100644 packages/core/src/common/selection-command-handler.ts create mode 100644 packages/filesystem/src/browser/file-selection.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a321583907e8d..706a080dbc33e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - [cpp] added new clang-tidy and clang-tidy-checks preferences to lint cpp program when clangd v9+ is used. - [plugin] `workspace.openTextDocument` API now respects the contributed `FileSystemProviders` - [search-in-workspace] added a new preference `search.lineNumbers` to control whether to show line numbers for search results. +- [workspace] added the menu item `Upload Files...` to easily upload files into a workspace Breaking changes: diff --git a/packages/core/src/browser/tree/tree-expansion.ts b/packages/core/src/browser/tree/tree-expansion.ts index a275bbfe7ca08..01c8dcfb737be 100644 --- a/packages/core/src/browser/tree/tree-expansion.ts +++ b/packages/core/src/browser/tree/tree-expansion.ts @@ -65,15 +65,15 @@ export interface ExpandableTreeNode extends CompositeTreeNode { } export namespace ExpandableTreeNode { - export function is(node: TreeNode | undefined): node is ExpandableTreeNode { + export function is(node: Object | undefined): node is ExpandableTreeNode { return !!node && CompositeTreeNode.is(node) && 'expanded' in node; } - export function isExpanded(node: TreeNode | undefined): node is ExpandableTreeNode { + export function isExpanded(node: Object | undefined): node is ExpandableTreeNode { return ExpandableTreeNode.is(node) && node.expanded; } - export function isCollapsed(node: TreeNode | undefined): node is ExpandableTreeNode { + export function isCollapsed(node: Object | undefined): node is ExpandableTreeNode { return ExpandableTreeNode.is(node) && !node.expanded; } } diff --git a/packages/core/src/browser/tree/tree-widget.tsx b/packages/core/src/browser/tree/tree-widget.tsx index 74cbc61871dda..78045a4bad6dd 100644 --- a/packages/core/src/browser/tree/tree-widget.tsx +++ b/packages/core/src/browser/tree/tree-widget.tsx @@ -272,9 +272,6 @@ export class TreeWidget extends ReactWidget implements StatefulWidget { protected onActivateRequest(msg: Message): void { super.onActivateRequest(msg); - if (this.props.globalSelection) { - this.updateGlobalSelection(); - } this.node.focus(); if (this.model.selectedNodes.length === 0) { const root = this.model.root; @@ -287,6 +284,10 @@ export class TreeWidget extends ReactWidget implements StatefulWidget { } } } + // it has to be called after nodes are selected + if (this.props.globalSelection) { + this.updateGlobalSelection(); + } this.forceUpdate(); } diff --git a/packages/core/src/browser/tree/tree.ts b/packages/core/src/browser/tree/tree.ts index 66d265e55ef32..41d923832ef17 100644 --- a/packages/core/src/browser/tree/tree.ts +++ b/packages/core/src/browser/tree/tree.ts @@ -116,7 +116,7 @@ export interface CompositeTreeNode extends TreeNode { } export namespace CompositeTreeNode { - export function is(node: TreeNode | undefined): node is CompositeTreeNode { + export function is(node: Object | undefined): node is CompositeTreeNode { return !!node && 'children' in node; } diff --git a/packages/core/src/common/selection-command-handler.ts b/packages/core/src/common/selection-command-handler.ts new file mode 100644 index 0000000000000..d8be975b16c0f --- /dev/null +++ b/packages/core/src/common/selection-command-handler.ts @@ -0,0 +1,101 @@ +/******************************************************************************** + * 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 { CommandHandler } from './command'; +import { SelectionService } from '../common/selection-service'; + +export class SelectionCommandHandler implements CommandHandler { + + constructor( + protected readonly selectionService: SelectionService, + protected readonly toSelection: (arg: any) => S | undefined, + protected readonly options: SelectionCommandHandler.Options + ) { } + + execute(...args: any[]): Object | undefined { + const selection = this.getSelection(...args); + return selection ? (this.options.execute as any)(selection, ...args) : undefined; + } + + isVisible(...args: any[]): boolean { + const selection = this.getSelection(...args); + return !!selection && (!this.options.isVisible || (this.options.isVisible as any)(selection as any, ...args)); + } + + isEnabled(...args: any[]): boolean { + const selection = this.getSelection(...args); + return !!selection && (!this.options.isEnabled || (this.options.isEnabled as any)(selection as any, ...args)); + } + + protected isMulti(): boolean { + return this.options && !!this.options.multi; + } + + protected getSelection(...args: any[]): S | S[] | undefined { + const givenSelection = args.length && this.toSelection(args[0]); + if (givenSelection) { + return this.isMulti() ? [givenSelection] : givenSelection; + } + const globalSelection = this.getSingleSelection(this.selectionService.selection); + if (this.isMulti()) { + return this.getMulitSelection(globalSelection); + } + return this.getSingleSelection(globalSelection); + } + + protected getSingleSelection(arg: Object | undefined): S | undefined { + let selection = this.toSelection(arg); + if (selection) { + return selection; + } + if (Array.isArray(arg)) { + for (const element of arg) { + selection = this.toSelection(element); + if (selection) { + return selection; + } + } + } + return undefined; + } + + protected getMulitSelection(arg: Object | undefined): S[] | undefined { + let selection = this.toSelection(arg); + if (selection) { + return [selection]; + } + const result = []; + if (Array.isArray(arg)) { + for (const element of arg) { + selection = this.toSelection(element); + if (selection) { + result.push(selection); + } + } + } + return result.length ? result : undefined; + } +} +export namespace SelectionCommandHandler { + export type Options = SelectionOptions | SelectionOptions; + export interface SelectionOptions { + multi: Multi; + execute(selection: T, ...args: any[]): any; + isEnabled?(selection: T, ...args: any[]): boolean; + isVisible?(selection: T, ...args: any[]): boolean; + } +} diff --git a/packages/filesystem/package.json b/packages/filesystem/package.json index 32339e1aca79e..ced53e8a584c3 100644 --- a/packages/filesystem/package.json +++ b/packages/filesystem/package.json @@ -6,6 +6,7 @@ "@theia/core": "^0.4.0", "@types/base64-js": "^1.2.5", "@types/body-parser": "^1.17.0", + "@types/formidable": "^1.0.31", "@types/fs-extra": "^4.0.2", "@types/mime-types": "^2.1.0", "@types/rimraf": "^2.0.2", @@ -15,6 +16,7 @@ "base64-js": "^1.2.1", "body-parser": "^1.18.3", "drivelist": "^6.4.3", + "formidable": "^1.2.1", "fs-extra": "^4.0.2", "http-status-codes": "^1.3.0", "mime-types": "^2.1.18", 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 079ff055c1359..77a6c30a502bf 100644 --- a/packages/filesystem/src/browser/download/file-download-command-contribution.ts +++ b/packages/filesystem/src/browser/download/file-download-command-contribution.ts @@ -16,12 +16,13 @@ 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 { ExpandableTreeNode, TreeWidget } from '@theia/core/lib/browser/tree'; import { FileDownloadService } from './file-download-service'; +import { FileSelection } from '../file-selection'; +import { TreeWidgetSelection } from '@theia/core/src/browser/tree/tree-widget-selection'; @injectable() export class FileDownloadCommandContribution implements CommandContribution { @@ -35,6 +36,24 @@ 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 { + const source = TreeWidgetSelection.getSource(this.selectionService.selection); + await this.downloadService.upload(selection.fileStat.uri); + if (ExpandableTreeNode.is(selection) && source) { + await source.model.expandNode(selection); + } } protected downloadHandler(): UriCommandHandler { @@ -57,29 +76,20 @@ export class FileDownloadCommandContribution implements CommandContribution { return this.isDownloadEnabled(uris); } - 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' + id: 'file.download', + category: 'File', + 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 25d795ab9ec47..d5c38969e4881 100644 --- a/packages/filesystem/src/browser/download/file-download-service.ts +++ b/packages/filesystem/src/browser/download/file-download-service.ts @@ -14,13 +14,14 @@ * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 ********************************************************************************/ -import { inject, injectable } from 'inversify'; +import { inject, injectable, postConstruct } 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 { 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'; @injectable() export class FileDownloadService { @@ -40,6 +41,61 @@ export class FileDownloadService { @inject(StatusBar) protected readonly statusBar: StatusBar; + 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); + const filesUrl = this.filesUrl(); + const deferredUpload = this.deferredUpload; + try { + await fetch(filesUrl, { method: 'POST', body }); + deferredUpload.resolve(); + } 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; + } + async download(uris: URI[]): Promise { if (uris.length === 0) { return; @@ -155,8 +211,12 @@ export class FileDownloadService { } protected endpoint(): string { - const url = new Endpoint({ path: 'files' }).getRestUrl().toString(); + const url = this.filesUrl(); return url.endsWith('/') ? url.slice(0, -1) : url; } + protected filesUrl(): string { + return new Endpoint({ path: 'files' }).getRestUrl().toString(); + } + } diff --git a/packages/filesystem/src/browser/file-selection.ts b/packages/filesystem/src/browser/file-selection.ts new file mode 100644 index 0000000000000..d26fff13eedbd --- /dev/null +++ b/packages/filesystem/src/browser/file-selection.ts @@ -0,0 +1,43 @@ +/******************************************************************************** + * 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 { SelectionService } from '@theia/core/lib/common/selection-service'; +import { SelectionCommandHandler } from '@theia/core/lib/common/selection-command-handler'; +import { FileStat } from '../common/filesystem'; + +export interface FileSelection { + fileStat: FileStat +} +export namespace FileSelection { + export function is(arg: Object | undefined): arg is FileSelection { + return typeof arg === 'object' && ('fileStat' in arg) && FileStat.is(arg['fileStat']); + } + export class CommandHandler extends SelectionCommandHandler { + + constructor( + protected readonly selectionService: SelectionService, + protected readonly options: SelectionCommandHandler.Options + ) { + super( + selectionService, + arg => FileSelection.is(arg) ? arg : undefined, + options + ); + } + + } + +} diff --git a/packages/filesystem/src/browser/file-tree/file-tree.ts b/packages/filesystem/src/browser/file-tree/file-tree.ts index c796d8325641c..3466ea959d8eb 100644 --- a/packages/filesystem/src/browser/file-tree/file-tree.ts +++ b/packages/filesystem/src/browser/file-tree/file-tree.ts @@ -20,6 +20,7 @@ import { TreeNode, CompositeTreeNode, SelectableTreeNode, ExpandableTreeNode, Tr import { FileSystem, FileStat } from '../../common'; import { LabelProvider } from '@theia/core/lib/browser/label-provider'; import { UriSelection } from '@theia/core/lib/common/selection'; +import { FileSelection } from '../file-selection'; @injectable() export class FileTree extends TreeImpl { @@ -91,8 +92,7 @@ export class FileTree extends TreeImpl { } } -export interface FileStatNode extends SelectableTreeNode, UriSelection { - fileStat: FileStat; +export interface FileStatNode extends SelectableTreeNode, UriSelection, FileSelection { } export namespace FileStatNode { export function is(node: object | undefined): node is FileStatNode { diff --git a/packages/filesystem/src/common/filesystem.ts b/packages/filesystem/src/common/filesystem.ts index 8971948c48785..cfddd74da11af 100644 --- a/packages/filesystem/src/common/filesystem.ts +++ b/packages/filesystem/src/common/filesystem.ts @@ -258,10 +258,8 @@ export interface FileStat { } export namespace FileStat { - export function is(candidate: object): candidate is FileStat { - return candidate.hasOwnProperty('uri') - && candidate.hasOwnProperty('lastModification') - && candidate.hasOwnProperty('isDirectory'); + export function is(candidate: Object | undefined): candidate is FileStat { + return typeof candidate === 'object' && ('uri' in candidate) && ('lastModification' in candidate) && ('isDirectory' in candidate); } export function equals(one: object | undefined, other: object | undefined): boolean { diff --git a/packages/filesystem/src/node/download/file-download-endpoint.ts b/packages/filesystem/src/node/download/file-download-endpoint.ts index 86212d79662f8..172d945d726dd 100644 --- a/packages/filesystem/src/node/download/file-download-endpoint.ts +++ b/packages/filesystem/src/node/download/file-download-endpoint.ts @@ -16,7 +16,11 @@ import { injectable, inject, named } from 'inversify'; import { json } from 'body-parser'; -import { Application, Router } from 'express'; +// tslint:disable-next-line:no-implicit-dependencies +import { Application, Router, Request, Response, NextFunction } from 'express'; +import * as formidable from 'formidable'; +import URI from '@theia/core/lib/common/uri'; +import { FileUri } from '@theia/core/lib/node/file-uri'; import { BackendApplicationContribution } from '@theia/core/lib/node/backend-application'; import { FileDownloadHandler } from './file-download-handler'; @@ -34,12 +38,48 @@ export class FileDownloadEndpoint implements BackendApplicationContribution { protected readonly multiFileDownloadHandler: FileDownloadHandler; configure(app: Application): void { + const upload = this.upload.bind(this); const router = Router(); router.get('/', (request, response) => this.singleFileDownloadHandler.handle(request, response)); router.put('/', (request, response) => this.multiFileDownloadHandler.handle(request, response)); + router.post('/', upload); // Content-Type: application/json app.use(json()); app.use(FileDownloadEndpoint.PATH, router); } + protected upload(req: Request, res: Response, next: NextFunction): void { + const form = new formidable.IncomingForm(); + let targetUri: URI | undefined; + const clientErrors: string[] = []; + let serverError: Error | undefined; + form.on('field', (name: string, value: string) => { + if (name === 'target') { + targetUri = new URI(value); + } + }); + form.on('fileBegin', (_: string, file: formidable.File) => { + if (targetUri) { + file.path = FileUri.fsPath(targetUri.resolve(file.name)); + } else { + clientErrors.push(`cannot upload "${file.name}", target is not provided`); + } + }); + form.on('error', (error: Error) => { + console.error(error); + serverError = error; + }); + form.on('end', () => { + if (clientErrors.length) { + res.writeHead(400, clientErrors.join('\n')); + } else if (serverError) { + res.writeHead(500, String(serverError)); + } else { + res.writeHead(200); + } + res.end(); + }); + form.parse(req); + } + } diff --git a/packages/navigator/src/browser/navigator-contribution.ts b/packages/navigator/src/browser/navigator-contribution.ts index c47348f460206..6dbbd41ca1b22 100644 --- a/packages/navigator/src/browser/navigator-contribution.ts +++ b/packages/navigator/src/browser/navigator-contribution.ts @@ -192,10 +192,15 @@ export class FileNavigatorContribution extends AbstractViewContribution boolean = require('valid-filename'); @@ -136,6 +137,15 @@ export class FileMenuContribution implements MenuContribution { registry.registerMenuAction(CommonMenus.FILE_NEW, { commandId: WorkspaceCommands.NEW_FOLDER.id }); + const downloadUploadMenu = [...CommonMenus.FILE, '4_downloadupload']; + registry.registerMenuAction(downloadUploadMenu, { + commandId: FileDownloadCommands.UPLOAD.id, + order: 'a' + }); + registry.registerMenuAction(downloadUploadMenu, { + commandId: FileDownloadCommands.DOWNLOAD.id, + order: 'b' + }); } } diff --git a/yarn.lock b/yarn.lock index 46a4b252fd7cc..04790c7b5db27 100644 --- a/yarn.lock +++ b/yarn.lock @@ -212,6 +212,14 @@ dependencies: "@types/node" "*" +"@types/formidable@^1.0.31": + version "1.0.31" + resolved "https://registry.yarnpkg.com/@types/formidable/-/formidable-1.0.31.tgz#274f9dc2d0a1a9ce1feef48c24ca0859e7ec947b" + integrity sha512-dIhM5t8lRP0oWe2HF8MuPvdd1TpPTjhDMAqemcq6oIZQCBQTovhBAdTQ5L5veJB4pdQChadmHuxtB0YzqvfU3Q== + dependencies: + "@types/events" "*" + "@types/node" "*" + "@types/fs-extra@^4.0.2": version "4.0.8" resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-4.0.8.tgz#6957ddaf9173195199cb96da3db44c74700463d2" @@ -4082,6 +4090,11 @@ formatio@1.2.0: dependencies: samsam "1.x" +formidable@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.1.tgz#70fb7ca0290ee6ff961090415f4b3df3d2082659" + integrity sha512-Fs9VRguL0gqGHkXS5GQiMCr1VhZBxz0JnJs4JmMp/2jL18Fmbzvv7vOFRU+U8TBkHEE/CX1qDXzJplVULgsLeg== + forwarded@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"