diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index f4a8b2a9c50..e5b9492f3c0 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -17,6 +17,7 @@ import { FileAccess, RemoteAuthorities } from 'vs/base/common/network'; import { BrowserFeatures } from 'vs/base/browser/canIUse'; import { insane, InsaneOptions } from 'vs/base/common/insane/insane'; import { KeyCode } from 'vs/base/common/keyCodes'; +import { withNullAsUndefined } from 'vs/base/common/types'; export function clearNode(node: HTMLElement): void { while (node.firstChild) { @@ -1257,6 +1258,29 @@ export function triggerDownload(dataOrUri: Uint8Array | URI, name: string): void setTimeout(() => document.body.removeChild(anchor)); } +export function triggerUpload(): Promise { + return new Promise(resolve => { + + // In order to upload to the browser, create a + // input element of type `file` and click it + // to gather the selected files + const input = document.createElement('input'); + document.body.appendChild(input); + input.type = 'file'; + input.multiple = true; + + // Resolve once the input event has fired once + Event.once(Event.fromDOMEventEmitter(input, 'input'))(() => { + resolve(withNullAsUndefined(input.files)); + }); + + input.click(); + + // Ensure to remove the element from DOM eventually + setTimeout(() => document.body.removeChild(input)); + }); +} + export enum DetectedFullscreenMode { /** diff --git a/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts b/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts index ab99f725f5a..c5d151a2893 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts @@ -5,7 +5,7 @@ import * as nls from 'vs/nls'; import { Registry } from 'vs/platform/registry/common/platform'; -import { ToggleAutoSaveAction, FocusFilesExplorer, GlobalCompareResourcesAction, ShowActiveFileInExplorer, CompareWithClipboardAction, NEW_FILE_COMMAND_ID, NEW_FILE_LABEL, NEW_FOLDER_COMMAND_ID, NEW_FOLDER_LABEL, TRIGGER_RENAME_LABEL, MOVE_FILE_TO_TRASH_LABEL, COPY_FILE_LABEL, PASTE_FILE_LABEL, FileCopiedContext, renameHandler, moveFileToTrashHandler, copyFileHandler, pasteFileHandler, deleteFileHandler, cutFileHandler, DOWNLOAD_COMMAND_ID, openFilePreserveFocusHandler, DOWNLOAD_LABEL, ShowOpenedFileInNewWindow } from 'vs/workbench/contrib/files/browser/fileActions'; +import { ToggleAutoSaveAction, FocusFilesExplorer, GlobalCompareResourcesAction, ShowActiveFileInExplorer, CompareWithClipboardAction, NEW_FILE_COMMAND_ID, NEW_FILE_LABEL, NEW_FOLDER_COMMAND_ID, NEW_FOLDER_LABEL, TRIGGER_RENAME_LABEL, MOVE_FILE_TO_TRASH_LABEL, COPY_FILE_LABEL, PASTE_FILE_LABEL, FileCopiedContext, renameHandler, moveFileToTrashHandler, copyFileHandler, pasteFileHandler, deleteFileHandler, cutFileHandler, DOWNLOAD_COMMAND_ID, openFilePreserveFocusHandler, DOWNLOAD_LABEL, ShowOpenedFileInNewWindow, UPLOAD_COMMAND_ID, UPLOAD_LABEL } from 'vs/workbench/contrib/files/browser/fileActions'; import { revertLocalChangesCommand, acceptLocalChangesCommand, CONFLICT_RESOLUTION_CONTEXT } from 'vs/workbench/contrib/files/browser/editors/textFileSaveErrorHandler'; import { SyncActionDescriptor, MenuId, MenuRegistry, ILocalizedString } from 'vs/platform/actions/common/actions'; import { IWorkbenchActionRegistry, Extensions as ActionExtensions } from 'vs/workbench/common/actions'; @@ -485,8 +485,23 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { }); MenuRegistry.appendMenuItem(MenuId.ExplorerContext, ({ - group: '5_cutcopypaste', - order: 30, + group: '5b_importexport', + order: 10, + command: { + id: UPLOAD_COMMAND_ID, + title: UPLOAD_LABEL, + }, + when: ContextKeyExpr.and( + // only in web + IsWebContext, + // only on folders + ExplorerFolderContext + ) +})); + +MenuRegistry.appendMenuItem(MenuId.ExplorerContext, ({ + group: '5b_importexport', + order: 20, command: { id: DOWNLOAD_COMMAND_ID, title: DOWNLOAD_LABEL, @@ -503,14 +518,14 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, ({ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { group: '6_copypath', - order: 30, + order: 10, command: copyPathCommand, when: ResourceContextKey.IsFileSystemResource }); MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { group: '6_copypath', - order: 30, + order: 20, command: copyRelativePathCommand, when: ResourceContextKey.IsFileSystemResource }); diff --git a/src/vs/workbench/contrib/files/browser/fileActions.ts b/src/vs/workbench/contrib/files/browser/fileActions.ts index cde7f40ee79..f569437b2c2 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.ts @@ -4,16 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import * as nls from 'vs/nls'; -import { isWindows, isWeb } from 'vs/base/common/platform'; +import { isWindows } from 'vs/base/common/platform'; import * as extpath from 'vs/base/common/extpath'; import { extname, basename } from 'vs/base/common/path'; import * as resources from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import { toErrorMessage } from 'vs/base/common/errorMessage'; import { Action } from 'vs/base/common/actions'; -import { DisposableStore, dispose, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { dispose, IDisposable } from 'vs/base/common/lifecycle'; import { VIEWLET_ID, IFilesConfiguration, VIEW_ID } from 'vs/workbench/contrib/files/common/files'; -import { ByteSize, IFileService, IFileStatWithMetadata } from 'vs/platform/files/common/files'; +import { IFileService } from 'vs/platform/files/common/files'; import { EditorResourceAccessor, SideBySideEditor } from 'vs/workbench/common/editor'; import { IQuickInputService, ItemActivation } from 'vs/platform/quickinput/common/quickInput'; import { IViewletService } from 'vs/workbench/services/viewlet/browser/viewlet'; @@ -28,8 +28,8 @@ import { IModeService } from 'vs/editor/common/services/modeService'; import { IModelService } from 'vs/editor/common/services/modelService'; import { ICommandService, CommandsRegistry } from 'vs/platform/commands/common/commands'; import { RawContextKey } from 'vs/platform/contextkey/common/contextkey'; -import { FileAccess, Schemas } from 'vs/base/common/network'; -import { IDialogService, IConfirmationResult, getFileNamesMessage, IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { Schemas } from 'vs/base/common/network'; +import { IDialogService, IConfirmationResult, getFileNamesMessage } from 'vs/platform/dialogs/common/dialogs'; import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { Constants } from 'vs/base/common/uint'; @@ -37,24 +37,19 @@ import { CLOSE_EDITORS_AND_GROUP_COMMAND_ID } from 'vs/workbench/browser/parts/e import { coalesce } from 'vs/base/common/arrays'; import { ExplorerItem, NewExplorerItem } from 'vs/workbench/contrib/files/common/explorerModel'; import { getErrorMessage } from 'vs/base/common/errors'; -import { WebFileSystemAccess, triggerDownload } from 'vs/base/browser/dom'; -import { mnemonicButtonLabel } from 'vs/base/common/labels'; +import { triggerUpload } from 'vs/base/browser/dom'; import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; import { IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; import { IWorkingCopy } from 'vs/workbench/services/workingCopy/common/workingCopy'; -import { RunOnceWorker, sequence, timeout } from 'vs/base/common/async'; +import { timeout } from 'vs/base/common/async'; import { IWorkingCopyFileService } from 'vs/workbench/services/workingCopy/common/workingCopyFileService'; -import { once } from 'vs/base/common/functional'; import { Codicon } from 'vs/base/common/codicons'; import { IViewsService } from 'vs/workbench/common/views'; import { trim, rtrim } from 'vs/base/common/strings'; -import { IProgressService, IProgressStep, ProgressLocation } from 'vs/platform/progress/common/progress'; -import { CancellationTokenSource } from 'vs/base/common/cancellation'; -import { ILogService } from 'vs/platform/log/common/log'; import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; import { ResourceFileEdit } from 'vs/editor/browser/services/bulkEditService'; import { IExplorerService } from 'vs/workbench/contrib/files/browser/files'; -import { listenStream } from 'vs/base/common/stream'; +import { BrowserFileUpload, FileDownload } from 'vs/workbench/contrib/files/browser/fileImportExport'; export const NEW_FILE_COMMAND_ID = 'explorer.newFile'; export const NEW_FILE_LABEL = nls.localize('newFile', "New File"); @@ -65,7 +60,10 @@ export const MOVE_FILE_TO_TRASH_LABEL = nls.localize('delete', "Delete"); export const COPY_FILE_LABEL = nls.localize('copyFile', "Copy"); export const PASTE_FILE_LABEL = nls.localize('pasteFile', "Paste"); export const FileCopiedContext = new RawContextKey('fileCopied', false); +export const DOWNLOAD_COMMAND_ID = 'explorer.download'; export const DOWNLOAD_LABEL = nls.localize('download', "Download..."); +export const UPLOAD_COMMAND_ID = 'explorer.upload'; +export const UPLOAD_LABEL = nls.localize('upload', "Upload..."); const CONFIRM_DELETE_SETTING_KEY = 'explorer.confirmDelete'; const MAX_UNDO_FILE_SIZE = 5000000; // 5mb @@ -930,240 +928,15 @@ export const cutFileHandler = async (accessor: ServicesAccessor) => { } }; -export const DOWNLOAD_COMMAND_ID = 'explorer.download'; const downloadFileHandler = async (accessor: ServicesAccessor) => { - const logService = accessor.get(ILogService); - const fileService = accessor.get(IFileService); - const fileDialogService = accessor.get(IFileDialogService); const explorerService = accessor.get(IExplorerService); - const progressService = accessor.get(IProgressService); + const instantiationService = accessor.get(IInstantiationService); const context = explorerService.getContext(true); const explorerItems = context.length ? context : explorerService.roots; - const cts = new CancellationTokenSource(); - - await progressService.withProgress({ - location: ProgressLocation.Window, - delay: 800, - cancellable: isWeb, - title: nls.localize('downloadingFiles', "Downloading") - }, async progress => { - return sequence(explorerItems.map(explorerItem => async () => { - if (cts.token.isCancellationRequested) { - return; - } - - // Web: use DOM APIs to download files with optional support - // for folders and large files - if (isWeb) { - const stat = await fileService.resolve(explorerItem.resource, { resolveMetadata: true }); - - if (cts.token.isCancellationRequested) { - return; - } - - const maxBlobDownloadSize = 32 * ByteSize.MB; // avoid to download via blob-trick >32MB to avoid memory pressure - const preferFileSystemAccessWebApis = stat.isDirectory || stat.size > maxBlobDownloadSize; - - // Folder: use FS APIs to download files and folders if available and preferred - if (preferFileSystemAccessWebApis && WebFileSystemAccess.supported(window)) { - - interface IDownloadOperation { - startTime: number; - progressScheduler: RunOnceWorker; - - filesTotal: number; - filesDownloaded: number; - - totalBytesDownloaded: number; - fileBytesDownloaded: number; - } - - async function downloadFileBuffered(resource: URI, target: FileSystemWritableFileStream, operation: IDownloadOperation): Promise { - const contents = await fileService.readFileStream(resource); - if (cts.token.isCancellationRequested) { - target.close(); - return; - } - - return new Promise((resolve, reject) => { - const sourceStream = contents.value; - - const disposables = new DisposableStore(); - disposables.add(toDisposable(() => target.close())); - - let disposed = false; - disposables.add(toDisposable(() => disposed = true)); - - disposables.add(once(cts.token.onCancellationRequested)(() => { - disposables.dispose(); - reject(); - })); - - listenStream(sourceStream, { - onData: data => { - if (!disposed) { - target.write(data.buffer); - reportProgress(contents.name, contents.size, data.byteLength, operation); - } - }, - onError: error => { - disposables.dispose(); - reject(error); - }, - onEnd: () => { - disposables.dispose(); - resolve(); - } - }); - }); - } - - async function downloadFileUnbuffered(resource: URI, target: FileSystemWritableFileStream, operation: IDownloadOperation): Promise { - const contents = await fileService.readFile(resource); - if (!cts.token.isCancellationRequested) { - target.write(contents.value.buffer); - reportProgress(contents.name, contents.size, contents.value.byteLength, operation); - } - - target.close(); - } - - async function downloadFile(targetFolder: FileSystemDirectoryHandle, file: IFileStatWithMetadata, operation: IDownloadOperation): Promise { - - // Report progress - operation.filesDownloaded++; - operation.fileBytesDownloaded = 0; // reset for this file - reportProgress(file.name, 0, 0, operation); - - // Start to download - const targetFile = await targetFolder.getFileHandle(file.name, { create: true }); - const targetFileWriter = await targetFile.createWritable(); - - // For large files, write buffered using streams - if (file.size > ByteSize.MB) { - return downloadFileBuffered(file.resource, targetFileWriter, operation); - } - - // For small files prefer to write unbuffered to reduce overhead - return downloadFileUnbuffered(file.resource, targetFileWriter, operation); - } - - async function downloadFolder(folder: IFileStatWithMetadata, targetFolder: FileSystemDirectoryHandle, operation: IDownloadOperation): Promise { - if (folder.children) { - operation.filesTotal += (folder.children.map(child => child.isFile)).length; - - for (const child of folder.children) { - if (cts.token.isCancellationRequested) { - return; - } - - if (child.isFile) { - await downloadFile(targetFolder, child, operation); - } else { - const childFolder = await targetFolder.getDirectoryHandle(child.name, { create: true }); - const resolvedChildFolder = await fileService.resolve(child.resource, { resolveMetadata: true }); - - await downloadFolder(resolvedChildFolder, childFolder, operation); - } - } - } - } - - function reportProgress(name: string, fileSize: number, bytesDownloaded: number, operation: IDownloadOperation): void { - operation.fileBytesDownloaded += bytesDownloaded; - operation.totalBytesDownloaded += bytesDownloaded; - - const bytesDownloadedPerSecond = operation.totalBytesDownloaded / ((Date.now() - operation.startTime) / 1000); - - // Small file - let message: string; - if (fileSize < ByteSize.MB) { - if (operation.filesTotal === 1) { - message = name; - } else { - message = nls.localize('downloadProgressSmallMany', "{0} of {1} files ({2}/s)", operation.filesDownloaded, operation.filesTotal, ByteSize.formatSize(bytesDownloadedPerSecond)); - } - } - - // Large file - else { - message = nls.localize('downloadProgressLarge', "{0} ({1} of {2}, {3}/s)", name, ByteSize.formatSize(operation.fileBytesDownloaded), ByteSize.formatSize(fileSize), ByteSize.formatSize(bytesDownloadedPerSecond)); - } - - // Report progress but limit to update only once per second - operation.progressScheduler.work({ message }); - } - - try { - const parentFolder: FileSystemDirectoryHandle = await window.showDirectoryPicker(); - const operation: IDownloadOperation = { - startTime: Date.now(), - progressScheduler: new RunOnceWorker(steps => { progress.report(steps[steps.length - 1]); }, 1000), - - filesTotal: stat.isDirectory ? 0 : 1, // folders increment filesTotal within downloadFolder method - filesDownloaded: 0, - - totalBytesDownloaded: 0, - fileBytesDownloaded: 0 - }; - - if (stat.isDirectory) { - const targetFolder = await parentFolder.getDirectoryHandle(stat.name, { create: true }); - await downloadFolder(stat, targetFolder, operation); - } else { - await downloadFile(parentFolder, stat, operation); - } - - operation.progressScheduler.dispose(); - } catch (error) { - logService.warn(error); - cts.cancel(); // `showDirectoryPicker` will throw an error when the user cancels - } - } - - // File: use traditional download to circumvent browser limitations - else if (stat.isFile) { - let bufferOrUri: Uint8Array | URI; - try { - bufferOrUri = (await fileService.readFile(stat.resource, { limits: { size: maxBlobDownloadSize } })).value.buffer; - } catch (error) { - bufferOrUri = FileAccess.asBrowserUri(stat.resource); - } - - if (!cts.token.isCancellationRequested) { - triggerDownload(bufferOrUri, stat.name); - } - } - } - - // Native: use working copy file service to get at the contents - else { - progress.report({ message: explorerItem.name }); - - let defaultUri = explorerItem.isDirectory ? await fileDialogService.defaultFolderPath(Schemas.file) : await fileDialogService.defaultFilePath(Schemas.file); - defaultUri = resources.joinPath(defaultUri, explorerItem.name); - - const destination = await fileDialogService.showSaveDialog({ - availableFileSystems: [Schemas.file], - saveLabel: mnemonicButtonLabel(nls.localize('downloadButton', "Download")), - title: nls.localize('chooseWhereToDownload', "Choose Where to Download"), - defaultUri - }); - - if (destination) { - await explorerService.applyBulkEdit([new ResourceFileEdit(explorerItem.resource, destination, { overwrite: true, copy: true })], { - undoLabel: nls.localize('downloadBulkEdit', "Download {0}", explorerItem.name), - progressLabel: nls.localize('downloadingBulkEdit', "Downloading {0}", explorerItem.name), - progressLocation: ProgressLocation.Explorer - }); - } else { - cts.cancel(); // User canceled a download. In case there were multiple files selected we should cancel the remainder of the prompts #86100 - } - } - })); - }, () => cts.dispose(true)); + const downloadHandler = instantiationService.createInstance(FileDownload); + return downloadHandler.download(explorerItems); }; CommandsRegistry.registerCommand({ @@ -1171,6 +944,25 @@ CommandsRegistry.registerCommand({ handler: downloadFileHandler }); +const uploadFileHandler = async (accessor: ServicesAccessor) => { + const explorerService = accessor.get(IExplorerService); + const instantiationService = accessor.get(IInstantiationService); + + const context = explorerService.getContext(true); + const element = context.length ? context[0] : explorerService.roots[0]; + + const files = await triggerUpload(); + if (files) { + const browserUpload = instantiationService.createInstance(BrowserFileUpload); + return browserUpload.upload(element, files); + } +}; + +CommandsRegistry.registerCommand({ + id: UPLOAD_COMMAND_ID, + handler: uploadFileHandler +}); + export const pasteFileHandler = async (accessor: ServicesAccessor) => { const clipboardService = accessor.get(IClipboardService); const explorerService = accessor.get(IExplorerService); diff --git a/src/vs/workbench/contrib/files/browser/fileImportExport.ts b/src/vs/workbench/contrib/files/browser/fileImportExport.ts new file mode 100644 index 00000000000..2994e8c0bcb --- /dev/null +++ b/src/vs/workbench/contrib/files/browser/fileImportExport.ts @@ -0,0 +1,799 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from 'vs/nls'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { getFileNamesMessage, IConfirmation, IDialogService, IFileDialogService } from 'vs/platform/dialogs/common/dialogs'; +import { ByteSize, FileSystemProviderCapabilities, IFileService, IFileStatWithMetadata } from 'vs/platform/files/common/files'; +import { Severity } from 'vs/platform/notification/common/notification'; +import { IProgress, IProgressService, IProgressStep, ProgressLocation } from 'vs/platform/progress/common/progress'; +import { IExplorerService } from 'vs/workbench/contrib/files/browser/files'; +import { VIEW_ID } from 'vs/workbench/contrib/files/common/files'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { RunOnceWorker, sequence } from 'vs/base/common/async'; +import { newWriteableBufferStream, VSBuffer } from 'vs/base/common/buffer'; +import { basename, joinPath } from 'vs/base/common/resources'; +import { ResourceFileEdit } from 'vs/editor/browser/services/bulkEditService'; +import { ExplorerItem } from 'vs/workbench/contrib/files/common/explorerModel'; +import { URI } from 'vs/base/common/uri'; +import { IHostService } from 'vs/workbench/services/host/browser/host'; +import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { extractResources } from 'vs/workbench/browser/dnd'; +import { IWorkspaceEditingService } from 'vs/workbench/services/workspaces/common/workspaceEditing'; +import { isWeb } from 'vs/base/common/platform'; +import { triggerDownload, WebFileSystemAccess } from 'vs/base/browser/dom'; +import { ILogService } from 'vs/platform/log/common/log'; +import { FileAccess, Schemas } from 'vs/base/common/network'; +import { mnemonicButtonLabel } from 'vs/base/common/labels'; +import { listenStream } from 'vs/base/common/stream'; +import { DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; +import { once } from 'vs/base/common/functional'; +import { coalesce } from 'vs/base/common/arrays'; + +//#region Browser File Upload (drag and drop, input element) + +interface IBrowserUploadOperation { + startTime: number; + progressScheduler: RunOnceWorker; + + filesTotal: number; + filesUploaded: number; + + totalBytesUploaded: number; +} + +interface IWebkitDataTransfer { + items: IWebkitDataTransferItem[]; +} + +interface IWebkitDataTransferItem { + webkitGetAsEntry(): IWebkitDataTransferItemEntry; +} + +interface IWebkitDataTransferItemEntry { + name: string | undefined; + isFile: boolean; + isDirectory: boolean; + + file(resolve: (file: File) => void, reject: () => void): void; + createReader(): IWebkitDataTransferItemEntryReader; +} + +interface IWebkitDataTransferItemEntryReader { + readEntries(resolve: (file: IWebkitDataTransferItemEntry[]) => void, reject: () => void): void +} + +export class BrowserFileUpload { + + constructor( + @IProgressService private readonly progressService: IProgressService, + @IDialogService private readonly dialogService: IDialogService, + @IExplorerService private readonly explorerService: IExplorerService, + @IEditorService private readonly editorService: IEditorService, + @IFileService private readonly fileService: IFileService + ) { + } + + upload(target: ExplorerItem, source: DragEvent | FileList): Promise { + const cts = new CancellationTokenSource(); + + // Indicate progress globally + const uploadPromise = this.progressService.withProgress( + { + location: ProgressLocation.Window, + delay: 800, + cancellable: true, + title: localize('uploadingFiles', "Uploading") + }, + async progress => this.doUpload(target, this.toTransfer(source), progress, cts.token), + () => cts.dispose(true) + ); + + // Also indicate progress in the files view + this.progressService.withProgress({ location: VIEW_ID, delay: 500 }, () => uploadPromise); + + return uploadPromise; + } + + private toTransfer(source: DragEvent | FileList): IWebkitDataTransfer { + if (source instanceof DragEvent) { + return source.dataTransfer as unknown as IWebkitDataTransfer; + } + + const transfer: IWebkitDataTransfer = { items: [] }; + + // We want to reuse the same code for uploading from + // Drag & Drop as well as input element based upload + // so we convert into webkit data transfer when the + // input element approach is used (simplified). + for (const file of source) { + transfer.items.push({ + webkitGetAsEntry: () => { + return { + name: file.name, + isDirectory: false, + isFile: true, + createReader: () => { throw new Error('Unsupported for files'); }, + file: resolve => resolve(file) + }; + } + }); + } + + return transfer; + } + + private async doUpload(target: ExplorerItem, source: IWebkitDataTransfer, progress: IProgress, token: CancellationToken): Promise { + const items = source.items; + + // Somehow the items thing is being modified at random, maybe as a security + // measure since this is a DND operation. As such, we copy the items into + // an array we own as early as possible before using it. + const entries: IWebkitDataTransferItemEntry[] = []; + for (const item of items) { + entries.push(item.webkitGetAsEntry()); + } + + const results: { isFile: boolean, resource: URI }[] = []; + const operation: IBrowserUploadOperation = { + startTime: Date.now(), + progressScheduler: new RunOnceWorker(steps => { progress.report(steps[steps.length - 1]); }, 1000), + + filesTotal: entries.length, + filesUploaded: 0, + + totalBytesUploaded: 0 + }; + + for (const entry of entries) { + if (token.isCancellationRequested) { + break; + } + + // Confirm overwrite as needed + if (target && entry.name && target.getChild(entry.name)) { + const { confirmed } = await this.dialogService.confirm(getFileOverwriteConfirm(entry.name)); + if (!confirmed) { + continue; + } + + await this.explorerService.applyBulkEdit([new ResourceFileEdit(joinPath(target.resource, entry.name), undefined, { recursive: true })], { + undoLabel: localize('overwrite', "Overwrite {0}", entry.name), + progressLabel: localize('overwriting', "Overwriting {0}", entry.name), + }); + + if (token.isCancellationRequested) { + break; + } + } + + // Upload entry + const result = await this.doUploadEntry(entry, target.resource, target, progress, operation, token); + if (result) { + results.push(result); + } + } + + operation.progressScheduler.dispose(); + + // Open uploaded file in editor only if we upload just one + const firstUploadedFile = results[0]; + if (!token.isCancellationRequested && firstUploadedFile?.isFile) { + await this.editorService.openEditor({ resource: firstUploadedFile.resource, options: { pinned: true } }); + } + } + + private async doUploadEntry(entry: IWebkitDataTransferItemEntry, parentResource: URI, target: ExplorerItem | undefined, progress: IProgress, operation: IBrowserUploadOperation, token: CancellationToken): Promise<{ isFile: boolean, resource: URI } | undefined> { + if (token.isCancellationRequested || !entry.name || (!entry.isFile && !entry.isDirectory)) { + return undefined; + } + + // Report progress + let fileBytesUploaded = 0; + const reportProgress = (fileSize: number, bytesUploaded: number): void => { + fileBytesUploaded += bytesUploaded; + operation.totalBytesUploaded += bytesUploaded; + + const bytesUploadedPerSecond = operation.totalBytesUploaded / ((Date.now() - operation.startTime) / 1000); + + // Small file + let message: string; + if (fileSize < ByteSize.MB) { + if (operation.filesTotal === 1) { + message = `${entry.name}`; + } else { + message = localize('uploadProgressSmallMany', "{0} of {1} files ({2}/s)", operation.filesUploaded, operation.filesTotal, ByteSize.formatSize(bytesUploadedPerSecond)); + } + } + + // Large file + else { + message = localize('uploadProgressLarge', "{0} ({1} of {2}, {3}/s)", entry.name, ByteSize.formatSize(fileBytesUploaded), ByteSize.formatSize(fileSize), ByteSize.formatSize(bytesUploadedPerSecond)); + } + + // Report progress but limit to update only once per second + operation.progressScheduler.work({ message }); + }; + operation.filesUploaded++; + reportProgress(0, 0); + + // Handle file upload + const resource = joinPath(parentResource, entry.name); + if (entry.isFile) { + const file = await new Promise((resolve, reject) => entry.file(resolve, reject)); + + if (token.isCancellationRequested) { + return undefined; + } + + // Chrome/Edge/Firefox support stream method, but only use it for + // larger files to reduce the overhead of the streaming approach + if (typeof file.stream === 'function' && file.size > ByteSize.MB) { + await this.doUploadFileBuffered(resource, file, reportProgress, token); + } + + // Fallback to unbuffered upload for other browsers or small files + else { + await this.doUploadFileUnbuffered(resource, file, reportProgress); + } + + return { isFile: true, resource }; + } + + // Handle folder upload + else { + + // Create target folder + await this.fileService.createFolder(resource); + + if (token.isCancellationRequested) { + return undefined; + } + + // Recursive upload files in this directory + const dirReader = entry.createReader(); + const childEntries: IWebkitDataTransferItemEntry[] = []; + let done = false; + do { + const childEntriesChunk = await new Promise((resolve, reject) => dirReader.readEntries(resolve, reject)); + if (childEntriesChunk.length > 0) { + childEntries.push(...childEntriesChunk); + } else { + done = true; // an empty array is a signal that all entries have been read + } + } while (!done && !token.isCancellationRequested); + + // Update operation total based on new counts + operation.filesTotal += childEntries.length; + + // Upload all entries as files to target + const folderTarget = target && target.getChild(entry.name) || undefined; + for (const childEntry of childEntries) { + await this.doUploadEntry(childEntry, resource, folderTarget, progress, operation, token); + } + + return { isFile: false, resource }; + } + } + + private async doUploadFileBuffered(resource: URI, file: File, progressReporter: (fileSize: number, bytesUploaded: number) => void, token: CancellationToken): Promise { + const writeableStream = newWriteableBufferStream({ + // Set a highWaterMark to prevent the stream + // for file upload to produce large buffers + // in-memory + highWaterMark: 10 + }); + const writeFilePromise = this.fileService.writeFile(resource, writeableStream); + + // Read the file in chunks using File.stream() web APIs + try { + const reader: ReadableStreamDefaultReader = file.stream().getReader(); + + let res = await reader.read(); + while (!res.done) { + if (token.isCancellationRequested) { + return undefined; + } + + // Write buffer into stream but make sure to wait + // in case the highWaterMark is reached + const buffer = VSBuffer.wrap(res.value); + await writeableStream.write(buffer); + + if (token.isCancellationRequested) { + return undefined; + } + + // Report progress + progressReporter(file.size, buffer.byteLength); + + res = await reader.read(); + } + writeableStream.end(undefined); + } catch (error) { + writeableStream.error(error); + writeableStream.end(); + } + + if (token.isCancellationRequested) { + return undefined; + } + + // Wait for file being written to target + await writeFilePromise; + } + + private doUploadFileUnbuffered(resource: URI, file: File, progressReporter: (fileSize: number, bytesUploaded: number) => void): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = async event => { + try { + if (event.target?.result instanceof ArrayBuffer) { + const buffer = VSBuffer.wrap(new Uint8Array(event.target.result)); + await this.fileService.writeFile(resource, buffer); + + // Report progress + progressReporter(file.size, buffer.byteLength); + } else { + throw new Error('Could not read from dropped file.'); + } + + resolve(); + } catch (error) { + reject(error); + } + }; + + // Start reading the file to trigger `onload` + reader.readAsArrayBuffer(file); + }); + } +} + +//#endregion + +//#region Native File Import (drag and drop) + +export class NativeFileImport { + + constructor( + @IFileService private readonly fileService: IFileService, + @IHostService private readonly hostService: IHostService, + @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, + @IDialogService private readonly dialogService: IDialogService, + @IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService, + @IExplorerService private readonly explorerService: IExplorerService, + @IEditorService private readonly editorService: IEditorService, + @IProgressService private readonly progressService: IProgressService + ) { + } + + async import(target: ExplorerItem, source: DragEvent): Promise { + const cts = new CancellationTokenSource(); + + // Indicate progress globally + const importPromise = this.progressService.withProgress( + { + location: ProgressLocation.Window, + delay: 800, + cancellable: true, + title: localize('copyingFiles', "Copying...") + }, + async () => await this.doImport(target, source, cts.token), + () => cts.dispose(true) + ); + + // Also indicate progress in the files view + this.progressService.withProgress({ location: VIEW_ID, delay: 500 }, () => importPromise); + + return importPromise; + } + + private async doImport(target: ExplorerItem, source: DragEvent, token: CancellationToken): Promise { + + // Check for dropped external files to be folders + const droppedResources = extractResources(source, true); + const resolvedFiles = await this.fileService.resolveAll(droppedResources.map(droppedResource => ({ resource: droppedResource.resource }))); + + if (token.isCancellationRequested) { + return; + } + + // Pass focus to window + this.hostService.focus(); + + // Handle folders by adding to workspace if we are in workspace context and if dropped on top + const folders = resolvedFiles.filter(resolvedFile => resolvedFile.success && resolvedFile.stat?.isDirectory).map(resolvedFile => ({ uri: resolvedFile.stat!.resource })); + if (folders.length > 0 && target.isRoot) { + const buttons = [ + folders.length > 1 ? + localize('copyFolders', "&&Copy Folders") : + localize('copyFolder', "&&Copy Folder"), + localize('cancel', "Cancel") + ]; + + let message: string; + + // We only allow to add a folder to the workspace if there is already a workspace folder with that scheme + const workspaceFolderSchemas = this.contextService.getWorkspace().folders.map(folder => folder.uri.scheme); + if (folders.some(folder => workspaceFolderSchemas.indexOf(folder.uri.scheme) >= 0)) { + buttons.unshift(folders.length > 1 ? localize('addFolders', "&&Add Folders to Workspace") : localize('addFolder', "&&Add Folder to Workspace")); + message = folders.length > 1 ? + localize('dropFolders', "Do you want to copy the folders or add the folders to the workspace?") : + localize('dropFolder', "Do you want to copy '{0}' or add '{0}' as a folder to the workspace?", basename(folders[0].uri)); + } else { + message = folders.length > 1 ? + localize('copyfolders', "Are you sure to want to copy folders?") : + localize('copyfolder', "Are you sure to want to copy '{0}'?", basename(folders[0].uri)); + } + + const { choice } = await this.dialogService.show(Severity.Info, message, buttons); + + // Add folders + if (choice === buttons.length - 3) { + return this.workspaceEditingService.addFolders(folders); + } + + // Copy resources + if (choice === buttons.length - 2) { + return this.importResources(target, droppedResources.map(res => res.resource), token); + } + } + + // Handle dropped files (only support FileStat as target) + else if (target instanceof ExplorerItem) { + return this.importResources(target, droppedResources.map(res => res.resource), token); + } + } + + private async importResources(target: ExplorerItem, resources: URI[], token: CancellationToken): Promise { + if (resources && resources.length > 0) { + + // Resolve target to check for name collisions and ask user + const targetStat = await this.fileService.resolve(target.resource); + + if (token.isCancellationRequested) { + return; + } + + // Check for name collisions + const targetNames = new Set(); + const caseSensitive = this.fileService.hasCapability(target.resource, FileSystemProviderCapabilities.PathCaseSensitive); + if (targetStat.children) { + targetStat.children.forEach(child => { + targetNames.add(caseSensitive ? child.name : child.name.toLowerCase()); + }); + } + + const resourcesFiltered = coalesce((await Promise.all(resources.map(async resource => { + if (targetNames.has(caseSensitive ? basename(resource) : basename(resource).toLowerCase())) { + const confirmationResult = await this.dialogService.confirm(getFileOverwriteConfirm(basename(resource))); + if (!confirmationResult.confirmed) { + return undefined; + } + } + + return resource; + })))); + + // Copy resources through bulk edit API + const resourceFileEdits = resourcesFiltered.map(resource => { + const sourceFileName = basename(resource); + const targetFile = joinPath(target.resource, sourceFileName); + + return new ResourceFileEdit(resource, targetFile, { overwrite: true, copy: true }); + }); + + await this.explorerService.applyBulkEdit(resourceFileEdits, { + undoLabel: resourcesFiltered.length === 1 ? + localize('copyFile', "Copy {0}", basename(resourcesFiltered[0])) : + localize('copynFile', "Copy {0} resources", resourcesFiltered.length), + progressLabel: resourcesFiltered.length === 1 ? + localize('copyingFile', "Copying {0}", basename(resourcesFiltered[0])) : + localize('copyingnFile', "Copying {0} resources", resourcesFiltered.length), + progressLocation: ProgressLocation.Window + }); + + // if we only add one file, just open it directly + if (resourceFileEdits.length === 1) { + const item = this.explorerService.findClosest(resourceFileEdits[0].newResource!); + if (item && !item.isDirectory) { + this.editorService.openEditor({ resource: item.resource, options: { pinned: true } }); + } + } + } + } +} + +//#endregion + +//#region Download (web, native) + +interface IDownloadOperation { + startTime: number; + progressScheduler: RunOnceWorker; + + filesTotal: number; + filesDownloaded: number; + + totalBytesDownloaded: number; + fileBytesDownloaded: number; +} + +export class FileDownload { + + constructor( + @IFileService private readonly fileService: IFileService, + @IExplorerService private readonly explorerService: IExplorerService, + @IProgressService private readonly progressService: IProgressService, + @ILogService private readonly logService: ILogService, + @IFileDialogService private readonly fileDialogService: IFileDialogService + ) { + } + + download(source: ExplorerItem[]): Promise { + const cts = new CancellationTokenSource(); + + // Indicate progress globally + const downloadPromise = this.progressService.withProgress( + { + location: ProgressLocation.Window, + delay: 800, + cancellable: isWeb, + title: localize('downloadingFiles', "Downloading") + }, + async progress => this.doDownload(source, progress, cts), + () => cts.dispose(true) + ); + + // Also indicate progress in the files view + this.progressService.withProgress({ location: VIEW_ID, delay: 500 }, () => downloadPromise); + + return downloadPromise; + } + + private async doDownload(source: ExplorerItem[], progress: IProgress, cts: CancellationTokenSource): Promise { + await sequence(source.map(explorerItem => async () => { + if (cts.token.isCancellationRequested) { + return; + } + + // Web: use DOM APIs to download files with optional support + // for folders and large files + if (isWeb) { + return this.doDownloadBrowser(explorerItem.resource, progress, cts); + } + + // Native: use working copy file service to get at the contents + return this.doDownloadNative(explorerItem, progress, cts); + })); + } + + private async doDownloadBrowser(resource: URI, progress: IProgress, cts: CancellationTokenSource): Promise { + const stat = await this.fileService.resolve(resource, { resolveMetadata: true }); + + if (cts.token.isCancellationRequested) { + return; + } + + const maxBlobDownloadSize = 32 * ByteSize.MB; // avoid to download via blob-trick >32MB to avoid memory pressure + const preferFileSystemAccessWebApis = stat.isDirectory || stat.size > maxBlobDownloadSize; + + // Folder: use FS APIs to download files and folders if available and preferred + if (preferFileSystemAccessWebApis && WebFileSystemAccess.supported(window)) { + try { + const parentFolder: FileSystemDirectoryHandle = await window.showDirectoryPicker(); + const operation: IDownloadOperation = { + startTime: Date.now(), + progressScheduler: new RunOnceWorker(steps => { progress.report(steps[steps.length - 1]); }, 1000), + + filesTotal: stat.isDirectory ? 0 : 1, // folders increment filesTotal within downloadFolder method + filesDownloaded: 0, + + totalBytesDownloaded: 0, + fileBytesDownloaded: 0 + }; + + if (stat.isDirectory) { + const targetFolder = await parentFolder.getDirectoryHandle(stat.name, { create: true }); + await this.downloadFolderBrowser(stat, targetFolder, operation, cts.token); + } else { + await this.downloadFileBrowser(parentFolder, stat, operation, cts.token); + } + + operation.progressScheduler.dispose(); + } catch (error) { + this.logService.warn(error); + cts.cancel(); // `showDirectoryPicker` will throw an error when the user cancels + } + } + + // File: use traditional download to circumvent browser limitations + else if (stat.isFile) { + let bufferOrUri: Uint8Array | URI; + try { + bufferOrUri = (await this.fileService.readFile(stat.resource, { limits: { size: maxBlobDownloadSize } })).value.buffer; + } catch (error) { + bufferOrUri = FileAccess.asBrowserUri(stat.resource); + } + + if (!cts.token.isCancellationRequested) { + triggerDownload(bufferOrUri, stat.name); + } + } + } + + private async downloadFileBufferedBrowser(resource: URI, target: FileSystemWritableFileStream, operation: IDownloadOperation, token: CancellationToken): Promise { + const contents = await this.fileService.readFileStream(resource); + if (token.isCancellationRequested) { + target.close(); + return; + } + + return new Promise((resolve, reject) => { + const sourceStream = contents.value; + + const disposables = new DisposableStore(); + disposables.add(toDisposable(() => target.close())); + + let disposed = false; + disposables.add(toDisposable(() => disposed = true)); + + disposables.add(once(token.onCancellationRequested)(() => { + disposables.dispose(); + reject(); + })); + + listenStream(sourceStream, { + onData: data => { + if (!disposed) { + target.write(data.buffer); + this.reportProgress(contents.name, contents.size, data.byteLength, operation); + } + }, + onError: error => { + disposables.dispose(); + reject(error); + }, + onEnd: () => { + disposables.dispose(); + resolve(); + } + }); + }); + } + + private async downloadFileUnbufferedBrowser(resource: URI, target: FileSystemWritableFileStream, operation: IDownloadOperation, token: CancellationToken): Promise { + const contents = await this.fileService.readFile(resource); + if (!token.isCancellationRequested) { + target.write(contents.value.buffer); + this.reportProgress(contents.name, contents.size, contents.value.byteLength, operation); + } + + target.close(); + } + + private async downloadFileBrowser(targetFolder: FileSystemDirectoryHandle, file: IFileStatWithMetadata, operation: IDownloadOperation, token: CancellationToken): Promise { + + // Report progress + operation.filesDownloaded++; + operation.fileBytesDownloaded = 0; // reset for this file + this.reportProgress(file.name, 0, 0, operation); + + // Start to download + const targetFile = await targetFolder.getFileHandle(file.name, { create: true }); + const targetFileWriter = await targetFile.createWritable(); + + // For large files, write buffered using streams + if (file.size > ByteSize.MB) { + return this.downloadFileBufferedBrowser(file.resource, targetFileWriter, operation, token); + } + + // For small files prefer to write unbuffered to reduce overhead + return this.downloadFileUnbufferedBrowser(file.resource, targetFileWriter, operation, token); + } + + private async downloadFolderBrowser(folder: IFileStatWithMetadata, targetFolder: FileSystemDirectoryHandle, operation: IDownloadOperation, token: CancellationToken): Promise { + if (folder.children) { + operation.filesTotal += (folder.children.map(child => child.isFile)).length; + + for (const child of folder.children) { + if (token.isCancellationRequested) { + return; + } + + if (child.isFile) { + await this.downloadFileBrowser(targetFolder, child, operation, token); + } else { + const childFolder = await targetFolder.getDirectoryHandle(child.name, { create: true }); + const resolvedChildFolder = await this.fileService.resolve(child.resource, { resolveMetadata: true }); + + await this.downloadFolderBrowser(resolvedChildFolder, childFolder, operation, token); + } + } + } + } + + private reportProgress(name: string, fileSize: number, bytesDownloaded: number, operation: IDownloadOperation): void { + operation.fileBytesDownloaded += bytesDownloaded; + operation.totalBytesDownloaded += bytesDownloaded; + + const bytesDownloadedPerSecond = operation.totalBytesDownloaded / ((Date.now() - operation.startTime) / 1000); + + // Small file + let message: string; + if (fileSize < ByteSize.MB) { + if (operation.filesTotal === 1) { + message = name; + } else { + message = localize('downloadProgressSmallMany', "{0} of {1} files ({2}/s)", operation.filesDownloaded, operation.filesTotal, ByteSize.formatSize(bytesDownloadedPerSecond)); + } + } + + // Large file + else { + message = localize('downloadProgressLarge', "{0} ({1} of {2}, {3}/s)", name, ByteSize.formatSize(operation.fileBytesDownloaded), ByteSize.formatSize(fileSize), ByteSize.formatSize(bytesDownloadedPerSecond)); + } + + // Report progress but limit to update only once per second + operation.progressScheduler.work({ message }); + } + + private async doDownloadNative(explorerItem: ExplorerItem, progress: IProgress, cts: CancellationTokenSource): Promise { + progress.report({ message: explorerItem.name }); + + const defaultUri = joinPath( + explorerItem.isDirectory ? + await this.fileDialogService.defaultFolderPath(Schemas.file) : + await this.fileDialogService.defaultFilePath(Schemas.file), + explorerItem.name + ); + + const destination = await this.fileDialogService.showSaveDialog({ + availableFileSystems: [Schemas.file], + saveLabel: mnemonicButtonLabel(localize('downloadButton', "Download")), + title: localize('chooseWhereToDownload', "Choose Where to Download"), + defaultUri + }); + + if (destination) { + await this.explorerService.applyBulkEdit([new ResourceFileEdit(explorerItem.resource, destination, { overwrite: true, copy: true })], { + undoLabel: localize('downloadBulkEdit', "Download {0}", explorerItem.name), + progressLabel: localize('downloadingBulkEdit', "Downloading {0}", explorerItem.name), + progressLocation: ProgressLocation.Window + }); + } else { + cts.cancel(); // User canceled a download. In case there were multiple files selected we should cancel the remainder of the prompts #86100 + } + } +} + +//#endregion + +//#region Helpers + +export function getFileOverwriteConfirm(name: string): IConfirmation { + return { + message: localize('confirmOverwrite', "A file or folder with the name '{0}' already exists in the destination folder. Do you want to replace it?", name), + detail: localize('irreversible', "This action is irreversible!"), + primaryButton: localize({ key: 'replaceButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Replace"), + type: 'warning' + }; +} + +export function getMultipleFilesOverwriteConfirm(files: URI[]): IConfirmation { + if (files.length > 1) { + return { + message: localize('confirmManyOverwrites', "The following {0} files and/or folders already exist in the destination folder. Do you want to replace them?", files.length), + detail: getFileNamesMessage(files) + '\n' + localize('irreversible', "This action is irreversible!"), + primaryButton: localize({ key: 'replaceButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Replace"), + type: 'warning' + }; + } + + return getFileOverwriteConfirm(basename(files[0])); +} + +//#endregion diff --git a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts index 2736a52bb20..d87372512ac 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts @@ -7,9 +7,9 @@ import { IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; import * as DOM from 'vs/base/browser/dom'; import * as glob from 'vs/base/common/glob'; import { IListVirtualDelegate, ListDragOverEffect } from 'vs/base/browser/ui/list/list'; -import { IProgressService, ProgressLocation, IProgressStep, IProgress } from 'vs/platform/progress/common/progress'; +import { IProgressService, ProgressLocation, } from 'vs/platform/progress/common/progress'; import { INotificationService, Severity } from 'vs/platform/notification/common/notification'; -import { IFileService, FileKind, FileOperationError, FileOperationResult, FileSystemProviderCapabilities, ByteSize } from 'vs/platform/files/common/files'; +import { IFileService, FileKind, FileOperationError, FileOperationResult } from 'vs/platform/files/common/files'; import { IWorkbenchLayoutService } from 'vs/workbench/services/layout/browser/layoutService'; import { IWorkspaceContextService, WorkbenchState } from 'vs/platform/workspace/common/workspace'; import { IDisposable, Disposable, dispose, toDisposable, DisposableStore } from 'vs/base/common/lifecycle'; @@ -19,8 +19,8 @@ import { ITreeNode, ITreeFilter, TreeVisibility, IAsyncDataSource, ITreeSorter, import { IContextViewService } from 'vs/platform/contextview/browser/contextView'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; -import { IFilesConfiguration, VIEW_ID } from 'vs/workbench/contrib/files/common/files'; -import { dirname, joinPath, basename, distinctParents } from 'vs/base/common/resources'; +import { IFilesConfiguration } from 'vs/workbench/contrib/files/common/files'; +import { dirname, joinPath, distinctParents } from 'vs/base/common/resources'; import { InputBox, MessageType } from 'vs/base/browser/ui/inputbox/inputBox'; import { localize } from 'vs/nls'; import { attachInputBoxStyler } from 'vs/platform/theme/common/styler'; @@ -30,17 +30,15 @@ import { equals, deepClone } from 'vs/base/common/objects'; import * as path from 'vs/base/common/path'; import { ExplorerItem, NewExplorerItem } from 'vs/workbench/contrib/files/common/explorerModel'; import { compareFileExtensionsDefault, compareFileNamesDefault, compareFileNamesUpper, compareFileExtensionsUpper, compareFileNamesLower, compareFileExtensionsLower, compareFileNamesUnicode, compareFileExtensionsUnicode } from 'vs/base/common/comparers'; -import { fillResourceDataTransfers, CodeDataTransfers, extractResources, containsDragType } from 'vs/workbench/browser/dnd'; +import { fillResourceDataTransfers, CodeDataTransfers, containsDragType } from 'vs/workbench/browser/dnd'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IDragAndDropData, DataTransfers } from 'vs/base/browser/dnd'; import { Schemas } from 'vs/base/common/network'; import { NativeDragAndDropData, ExternalElementsDragAndDropData, ElementsDragAndDropData } from 'vs/base/browser/ui/list/listView'; import { isMacintosh, isWeb } from 'vs/base/common/platform'; -import { IDialogService, IConfirmation, getFileNamesMessage } from 'vs/platform/dialogs/common/dialogs'; -import { IHostService } from 'vs/workbench/services/host/browser/host'; +import { IDialogService, getFileNamesMessage } from 'vs/platform/dialogs/common/dialogs'; import { IWorkspaceEditingService } from 'vs/workbench/services/workspaces/common/workspaceEditing'; import { URI } from 'vs/base/common/uri'; -import { RunOnceWorker } from 'vs/base/common/async'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IWorkspaceFolderCreationData } from 'vs/platform/workspaces/common/workspaces'; import { findValidPasteFileTarget } from 'vs/workbench/contrib/files/browser/fileActions'; @@ -49,16 +47,16 @@ import { Emitter, Event, EventMultiplexer } from 'vs/base/common/event'; import { ITreeCompressionDelegate } from 'vs/base/browser/ui/tree/asyncDataTree'; import { ICompressibleTreeRenderer } from 'vs/base/browser/ui/tree/objectTree'; import { ICompressedTreeNode } from 'vs/base/browser/ui/tree/compressedObjectTreeModel'; -import { VSBuffer, newWriteableBufferStream } from 'vs/base/common/buffer'; import { ILabelService } from 'vs/platform/label/common/label'; import { isNumber } from 'vs/base/common/types'; import { domEvent } from 'vs/base/browser/event'; import { IEditableData } from 'vs/workbench/common/views'; import { IEditorInput } from 'vs/workbench/common/editor'; -import { CancellationTokenSource, CancellationToken } from 'vs/base/common/cancellation'; import { IUriIdentityService } from 'vs/workbench/services/uriIdentity/common/uriIdentity'; import { ResourceFileEdit } from 'vs/editor/browser/services/bulkEditService'; import { IExplorerService } from 'vs/workbench/contrib/files/browser/files'; +import { BrowserFileUpload, NativeFileImport, getMultipleFilesOverwriteConfirm } from 'vs/workbench/contrib/files/browser/fileImportExport'; +import { toErrorMessage } from 'vs/base/common/errorMessage'; export class ExplorerDelegate implements IListVirtualDelegate { @@ -754,59 +752,6 @@ export class FileSorter implements ITreeSorter { } } -function getFileOverwriteConfirm(name: string): IConfirmation { - return { - message: localize('confirmOverwrite', "A file or folder with the name '{0}' already exists in the destination folder. Do you want to replace it?", name), - detail: localize('irreversible', "This action is irreversible!"), - primaryButton: localize({ key: 'replaceButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Replace"), - type: 'warning' - }; -} - -function getMultipleFilesOverwriteConfirm(files: URI[]): IConfirmation { - if (files.length > 1) { - return { - message: localize('confirmManyOverwrites', "The following {0} files and/or folders already exist in the destination folder. Do you want to replace them?", files.length), - detail: getFileNamesMessage(files) + '\n' + localize('irreversible', "This action is irreversible!"), - primaryButton: localize({ key: 'replaceButtonLabel', comment: ['&& denotes a mnemonic'] }, "&&Replace"), - type: 'warning' - }; - } - - return getFileOverwriteConfirm(basename(files[0])); -} - -interface IWebkitDataTransfer { - items: IWebkitDataTransferItem[]; -} - -interface IWebkitDataTransferItem { - webkitGetAsEntry(): IWebkitDataTransferItemEntry; -} - -interface IWebkitDataTransferItemEntry { - name: string | undefined; - isFile: boolean; - isDirectory: boolean; - - file(resolve: (file: File) => void, reject: () => void): void; - createReader(): IWebkitDataTransferItemEntryReader; -} - -interface IWebkitDataTransferItemEntryReader { - readEntries(resolve: (file: IWebkitDataTransferItemEntry[]) => void, reject: () => void): void -} - -interface IUploadOperation { - startTime: number; - progressScheduler: RunOnceWorker; - - filesTotal: number; - filesUploaded: number; - - totalBytesUploaded: number; -} - export class FileDragAndDrop implements ITreeDragAndDrop { private static readonly CONFIRM_DND_SETTING_KEY = 'explorer.confirmDragAndDrop'; @@ -817,7 +762,6 @@ export class FileDragAndDrop implements ITreeDragAndDrop { private dropEnabled = false; constructor( - @INotificationService private notificationService: INotificationService, @IExplorerService private explorerService: IExplorerService, @IEditorService private editorService: IEditorService, @IDialogService private dialogService: IDialogService, @@ -825,9 +769,7 @@ export class FileDragAndDrop implements ITreeDragAndDrop { @IFileService private fileService: IFileService, @IConfigurationService private configurationService: IConfigurationService, @IInstantiationService private instantiationService: IInstantiationService, - @IHostService private hostService: IHostService, @IWorkspaceEditingService private workspaceEditingService: IWorkspaceEditingService, - @IProgressService private readonly progressService: IProgressService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService ) { this.toDispose = []; @@ -1024,358 +966,25 @@ export class FileDragAndDrop implements ITreeDragAndDrop { return; } - // Desktop DND (Import file) - if (data instanceof NativeDragAndDropData) { - const cts = new CancellationTokenSource(); - - if (isWeb) { - // Indicate progress globally - const dropPromise = this.progressService.withProgress({ - location: ProgressLocation.Window, - delay: 800, - cancellable: true, - title: localize('uploadingFiles', "Uploading") - }, async progress => { - try { - await this.handleWebExternalDrop(resolvedTarget, originalEvent, progress, cts.token); - } catch (error) { - this.notificationService.warn(error); - } - }, () => cts.dispose(true)); - // Also indicate progress in the files view - this.progressService.withProgress({ location: VIEW_ID, delay: 500 }, () => dropPromise); - } else { - try { - await this.handleExternalDrop(resolvedTarget, originalEvent, cts.token); - } catch (error) { - this.notificationService.warn(error); - } - } - } - // In-Explorer DND (Move/Copy file) - else { - this.handleExplorerDrop(data as ElementsDragAndDropData, resolvedTarget, originalEvent).then(undefined, e => this.notificationService.warn(e)); - } - } - - private async handleWebExternalDrop(target: ExplorerItem, originalEvent: DragEvent, progress: IProgress, token: CancellationToken): Promise { - const items = (originalEvent.dataTransfer as unknown as IWebkitDataTransfer).items; - - // Somehow the items thing is being modified at random, maybe as a security - // measure since this is a DND operation. As such, we copy the items into - // an array we own as early as possible before using it. - const entries: IWebkitDataTransferItemEntry[] = []; - for (const item of items) { - entries.push(item.webkitGetAsEntry()); - } - - const results: { isFile: boolean, resource: URI }[] = []; - const operation: IUploadOperation = { - startTime: Date.now(), - progressScheduler: new RunOnceWorker(steps => { progress.report(steps[steps.length - 1]); }, 1000), - - filesTotal: entries.length, - filesUploaded: 0, - - totalBytesUploaded: 0 - }; - - for (let entry of entries) { - if (token.isCancellationRequested) { - break; - } - - // Confirm overwrite as needed - if (target && entry.name && target.getChild(entry.name)) { - const { confirmed } = await this.dialogService.confirm(getFileOverwriteConfirm(entry.name)); - if (!confirmed) { - continue; - } - - await this.explorerService.applyBulkEdit([new ResourceFileEdit(joinPath(target.resource, entry.name), undefined, { recursive: true })], { - undoLabel: localize('overwrite', "Overwrite {0}", entry.name), - progressLabel: localize('overwriting', "Overwriting {0}", entry.name), - }); - - if (token.isCancellationRequested) { - break; - } - } - - // Upload entry - const result = await this.doUploadWebFileEntry(entry, target.resource, target, progress, operation, token); - if (result) { - results.push(result); - } - } - - operation.progressScheduler.dispose(); - - // Open uploaded file in editor only if we upload just one - const firstUploadedFile = results[0]; - if (!token.isCancellationRequested && firstUploadedFile?.isFile) { - await this.editorService.openEditor({ resource: firstUploadedFile.resource, options: { pinned: true } }); - } - } - - private async doUploadWebFileEntry(entry: IWebkitDataTransferItemEntry, parentResource: URI, target: ExplorerItem | undefined, progress: IProgress, operation: IUploadOperation, token: CancellationToken): Promise<{ isFile: boolean, resource: URI } | undefined> { - if (token.isCancellationRequested || !entry.name || (!entry.isFile && !entry.isDirectory)) { - return undefined; - } - - // Report progress - let fileBytesUploaded = 0; - const reportProgress = (fileSize: number, bytesUploaded: number): void => { - fileBytesUploaded += bytesUploaded; - operation.totalBytesUploaded += bytesUploaded; - - const bytesUploadedPerSecond = operation.totalBytesUploaded / ((Date.now() - operation.startTime) / 1000); - - // Small file - let message: string; - if (fileSize < ByteSize.MB) { - if (operation.filesTotal === 1) { - message = `${entry.name}`; - } else { - message = localize('uploadProgressSmallMany', "{0} of {1} files ({2}/s)", operation.filesUploaded, operation.filesTotal, ByteSize.formatSize(bytesUploadedPerSecond)); - } - } - - // Large file - else { - message = localize('uploadProgressLarge', "{0} ({1} of {2}, {3}/s)", entry.name, ByteSize.formatSize(fileBytesUploaded), ByteSize.formatSize(fileSize), ByteSize.formatSize(bytesUploadedPerSecond)); - } - - // Report progress but limit to update only once per second - operation.progressScheduler.work({ message }); - }; - operation.filesUploaded++; - reportProgress(0, 0); - - // Handle file upload - const resource = joinPath(parentResource, entry.name); - if (entry.isFile) { - const file = await new Promise((resolve, reject) => entry.file(resolve, reject)); - - if (token.isCancellationRequested) { - return undefined; - } - - // Chrome/Edge/Firefox support stream method, but only use it for - // larger files to reduce the overhead of the streaming approach - if (typeof file.stream === 'function' && file.size > ByteSize.MB) { - await this.doUploadWebFileEntryBuffered(resource, file, reportProgress, token); - } - - // Fallback to unbuffered upload for other browsers or small files - else { - await this.doUploadWebFileEntryUnbuffered(resource, file, reportProgress); - } - - return { isFile: true, resource }; - } - - // Handle folder upload - else { - - // Create target folder - await this.fileService.createFolder(resource); - - if (token.isCancellationRequested) { - return undefined; - } - - // Recursive upload files in this directory - const dirReader = entry.createReader(); - const childEntries: IWebkitDataTransferItemEntry[] = []; - let done = false; - do { - const childEntriesChunk = await new Promise((resolve, reject) => dirReader.readEntries(resolve, reject)); - if (childEntriesChunk.length > 0) { - childEntries.push(...childEntriesChunk); - } else { - done = true; // an empty array is a signal that all entries have been read - } - } while (!done && !token.isCancellationRequested); - - // Update operation total based on new counts - operation.filesTotal += childEntries.length; - - // Upload all entries as files to target - const folderTarget = target && target.getChild(entry.name) || undefined; - for (let childEntry of childEntries) { - await this.doUploadWebFileEntry(childEntry, resource, folderTarget, progress, operation, token); - } - - return { isFile: false, resource }; - } - } - - private async doUploadWebFileEntryBuffered(resource: URI, file: File, progressReporter: (fileSize: number, bytesUploaded: number) => void, token: CancellationToken): Promise { - const writeableStream = newWriteableBufferStream({ - // Set a highWaterMark to prevent the stream - // for file upload to produce large buffers - // in-memory - highWaterMark: 10 - }); - const writeFilePromise = this.fileService.writeFile(resource, writeableStream); - - // Read the file in chunks using File.stream() web APIs try { - const reader: ReadableStreamDefaultReader = file.stream().getReader(); - let res = await reader.read(); - while (!res.done) { - if (token.isCancellationRequested) { - return undefined; + // Desktop DND (Import file) + if (data instanceof NativeDragAndDropData) { + if (isWeb) { + const browserUpload = this.instantiationService.createInstance(BrowserFileUpload); + await browserUpload.upload(target, originalEvent); + } else { + const nativeImport = this.instantiationService.createInstance(NativeFileImport); + await nativeImport.import(resolvedTarget, originalEvent); } - - // Write buffer into stream but make sure to wait - // in case the highWaterMark is reached - const buffer = VSBuffer.wrap(res.value); - await writeableStream.write(buffer); - - if (token.isCancellationRequested) { - return undefined; - } - - // Report progress - progressReporter(file.size, buffer.byteLength); - - res = await reader.read(); } - writeableStream.end(undefined); + + // In-Explorer DND (Move/Copy file) + else { + await this.handleExplorerDrop(data as ElementsDragAndDropData, resolvedTarget, originalEvent); + } } catch (error) { - writeableStream.error(error); - writeableStream.end(); - } - - if (token.isCancellationRequested) { - return undefined; - } - - // Wait for file being written to target - await writeFilePromise; - } - - private doUploadWebFileEntryUnbuffered(resource: URI, file: File, progressReporter: (fileSize: number, bytesUploaded: number) => void): Promise { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = async event => { - try { - if (event.target?.result instanceof ArrayBuffer) { - const buffer = VSBuffer.wrap(new Uint8Array(event.target.result)); - await this.fileService.writeFile(resource, buffer); - - // Report progress - progressReporter(file.size, buffer.byteLength); - } else { - throw new Error('Could not read from dropped file.'); - } - - resolve(); - } catch (error) { - reject(error); - } - }; - - // Start reading the file to trigger `onload` - reader.readAsArrayBuffer(file); - }); - } - - private async handleExternalDrop(target: ExplorerItem, originalEvent: DragEvent, token: CancellationToken): Promise { - - // Check for dropped external files to be folders - const droppedResources = extractResources(originalEvent, true); - const result = await this.fileService.resolveAll(droppedResources.map(droppedResource => ({ resource: droppedResource.resource }))); - - if (token.isCancellationRequested) { - return; - } - - // Pass focus to window - this.hostService.focus(); - - // Handle folders by adding to workspace if we are in workspace context and if dropped on top - const folders = result.filter(r => r.success && r.stat && r.stat.isDirectory).map(result => ({ uri: result.stat!.resource })); - if (folders.length > 0 && target.isRoot) { - const buttons = [ - folders.length > 1 ? localize('copyFolders', "&&Copy Folders") : localize('copyFolder', "&&Copy Folder"), - localize('cancel', "Cancel") - ]; - const workspaceFolderSchemas = this.contextService.getWorkspace().folders.map(f => f.uri.scheme); - let message = folders.length > 1 ? localize('copyfolders', "Are you sure to want to copy folders?") : localize('copyfolder', "Are you sure to want to copy '{0}'?", basename(folders[0].uri)); - if (folders.some(f => workspaceFolderSchemas.indexOf(f.uri.scheme) >= 0)) { - // We only allow to add a folder to the workspace if there is already a workspace folder with that scheme - buttons.unshift(folders.length > 1 ? localize('addFolders', "&&Add Folders to Workspace") : localize('addFolder', "&&Add Folder to Workspace")); - message = folders.length > 1 ? localize('dropFolders', "Do you want to copy the folders or add the folders to the workspace?") - : localize('dropFolder', "Do you want to copy '{0}' or add '{0}' as a folder to the workspace?", basename(folders[0].uri)); - } - - const { choice } = await this.dialogService.show(Severity.Info, message, buttons); - if (choice === buttons.length - 3) { - return this.workspaceEditingService.addFolders(folders); - } - if (choice === buttons.length - 2) { - return this.addResources(target, droppedResources.map(res => res.resource), token); - } - - return undefined; - } - - // Handle dropped files (only support FileStat as target) - else if (target instanceof ExplorerItem) { - return this.addResources(target, droppedResources.map(res => res.resource), token); - } - } - - private async addResources(target: ExplorerItem, resources: URI[], token: CancellationToken): Promise { - if (resources && resources.length > 0) { - - // Resolve target to check for name collisions and ask user - const targetStat = await this.fileService.resolve(target.resource); - - if (token.isCancellationRequested) { - return; - } - - // Check for name collisions - const targetNames = new Set(); - const caseSensitive = this.fileService.hasCapability(target.resource, FileSystemProviderCapabilities.PathCaseSensitive); - if (targetStat.children) { - targetStat.children.forEach(child => { - targetNames.add(caseSensitive ? child.name : child.name.toLowerCase()); - }); - } - - const resourcesFiltered = (await Promise.all(resources.map(async resource => { - if (targetNames.has(caseSensitive ? basename(resource) : basename(resource).toLowerCase())) { - const confirmationResult = await this.dialogService.confirm(getFileOverwriteConfirm(basename(resource))); - if (!confirmationResult.confirmed) { - return undefined; - } - } - return resource; - }))).filter(r => r instanceof URI) as URI[]; - const resourceFileEdits = resourcesFiltered.map(resource => { - const sourceFileName = basename(resource); - const targetFile = joinPath(target.resource, sourceFileName); - return new ResourceFileEdit(resource, targetFile, { overwrite: true, copy: true }); - }); - - await this.explorerService.applyBulkEdit(resourceFileEdits, { - undoLabel: resourcesFiltered.length === 1 ? localize('copyFile', "Copy {0}", basename(resourcesFiltered[0])) : localize('copynFile', "Copy {0} resources", resourcesFiltered.length), - progressLabel: resourcesFiltered.length === 1 ? localize('copyingFile', "Copying {0}", basename(resourcesFiltered[0])) : localize('copyingnFile', "Copying {0} resources", resourcesFiltered.length) - }); - - // if we only add one file, just open it directly - if (resourceFileEdits.length === 1) { - const item = this.explorerService.findClosest(resourceFileEdits[0].newResource!); - if (item && !item.isDirectory) { - this.editorService.openEditor({ resource: item.resource, options: { pinned: true } }); - } - } + this.dialogService.show(Severity.Error, toErrorMessage(error), [localize('ok', 'OK')]); } } @@ -1417,15 +1026,15 @@ export class FileDragAndDrop implements ITreeDragAndDrop { const sources = items.filter(s => !s.isRoot); if (isCopy) { - await this.doHandleExplorerDropOnCopy(sources, target); - } else { - return this.doHandleExplorerDropOnMove(sources, target); + return this.doHandleExplorerDropOnCopy(sources, target); } + + return this.doHandleExplorerDropOnMove(sources, target); } - private doHandleRootDrop(roots: ExplorerItem[], target: ExplorerItem): Promise { + private async doHandleRootDrop(roots: ExplorerItem[], target: ExplorerItem): Promise { if (roots.length === 0) { - return Promise.resolve(undefined); + return; } const folders = this.contextService.getWorkspace().folders; @@ -1453,10 +1062,12 @@ export class FileDragAndDrop implements ITreeDragAndDrop { } workspaceCreationData.splice(targetIndex, 0, ...rootsToMove); + return this.workspaceEditingService.updateFolders(0, workspaceCreationData.length, workspaceCreationData); } private async doHandleExplorerDropOnCopy(sources: ExplorerItem[], target: ExplorerItem): Promise { + // Reuse duplicate action when user copies const incrementalNaming = this.configurationService.getValue().explorer.incrementalNaming; const resourceFileEdits = sources.map(({ resource, isDirectory }) => (new ResourceFileEdit(resource, findValidPasteFileTarget(this.explorerService, target, { resource, isDirectory, allowOverwrite: false }, incrementalNaming), { copy: true }))); @@ -1487,6 +1098,7 @@ export class FileDragAndDrop implements ITreeDragAndDrop { try { await this.explorerService.applyBulkEdit(resourceFileEdits, options); } catch (error) { + // Conflict if ((error).fileOperationResult === FileOperationResult.FILE_MOVE_CONFLICT) { @@ -1497,20 +1109,17 @@ export class FileDragAndDrop implements ITreeDragAndDrop { } } - const confirm = getMultipleFilesOverwriteConfirm(overwrites); // Move with overwrite if the user confirms + const confirm = getMultipleFilesOverwriteConfirm(overwrites); const { confirmed } = await this.dialogService.confirm(confirm); if (confirmed) { - try { - await this.explorerService.applyBulkEdit(resourceFileEdits.map(re => new ResourceFileEdit(re.oldResource, re.newResource, { overwrite: true })), options); - } catch (error) { - this.notificationService.error(error); - } + await this.explorerService.applyBulkEdit(resourceFileEdits.map(re => new ResourceFileEdit(re.oldResource, re.newResource, { overwrite: true })), options); } } - // Any other error + + // Any other error: bubble up else { - this.notificationService.error(error); + throw error; } } }