diff --git a/src/vs/base/browser/dom.ts b/src/vs/base/browser/dom.ts index 8a3e808a871..2d753c91e78 100644 --- a/src/vs/base/browser/dom.ts +++ b/src/vs/base/browser/dom.ts @@ -1390,3 +1390,48 @@ function toBinary(str: string): string { export function multibyteAwareBtoa(str: string): string { return btoa(toBinary(str)); } + +/** + * Typings for the https://wicg.github.io/file-system-access + * + * Use `supported(window)` to find out if the browser supports this kind of API. + */ +export namespace WebFileSystemAccess { + + // https://wicg.github.io/file-system-access/#dom-window-showdirectorypicker + export interface FileSystemAccess { + showDirectoryPicker: () => Promise; + } + + // https://wicg.github.io/file-system-access/#api-filesystemdirectoryhandle + export interface FileSystemDirectoryHandle { + readonly kind: 'directory', + readonly name: string, + + getFileHandle: (name: string, options?: { create?: boolean }) => Promise; + getDirectoryHandle: (name: string, options?: { create?: boolean }) => Promise; + } + + // https://wicg.github.io/file-system-access/#api-filesystemfilehandle + export interface FileSystemFileHandle { + readonly kind: 'file', + readonly name: string, + + createWritable: (options?: { keepExistingData?: boolean }) => Promise; + } + + // https://wicg.github.io/file-system-access/#api-filesystemwritablefilestream + export interface FileSystemWritableFileStream { + write: (buffer: Uint8Array) => Promise; + close: () => Promise; + } + + export function supported(obj: any & Window): obj is FileSystemAccess { + const candidate = obj as FileSystemAccess; + if (typeof candidate?.showDirectoryPicker === 'function') { + return true; + } + + return false; + } +} diff --git a/src/vs/platform/files/common/files.ts b/src/vs/platform/files/common/files.ts index 52e9ecdf293..2d2cdcfeef4 100644 --- a/src/vs/platform/files/common/files.ts +++ b/src/vs/platform/files/common/files.ts @@ -11,7 +11,7 @@ import { createDecorator } from 'vs/platform/instantiation/common/instantiation' import { Event } from 'vs/base/common/event'; import { startsWithIgnoreCase } from 'vs/base/common/strings'; import { IDisposable } from 'vs/base/common/lifecycle'; -import { isUndefinedOrNull } from 'vs/base/common/types'; +import { isNumber, isUndefinedOrNull } from 'vs/base/common/types'; import { VSBuffer, VSBufferReadable, VSBufferReadableStream } from 'vs/base/common/buffer'; import { ReadableStreamEvents } from 'vs/base/common/stream'; import { CancellationToken } from 'vs/base/common/cancellation'; @@ -978,8 +978,12 @@ export class BinarySize { static readonly TB = BinarySize.GB * BinarySize.KB; static formatSize(size: number): string { + if (!isNumber(size)) { + size = 0; + } + if (size < BinarySize.KB) { - return localize('sizeB', "{0}B", size); + return localize('sizeB', "{0}B", size.toFixed(0)); } if (size < BinarySize.MB) { diff --git a/src/vs/workbench/browser/contextkeys.ts b/src/vs/workbench/browser/contextkeys.ts index 1e9fb3756bb..10e7b53ef34 100644 --- a/src/vs/workbench/browser/contextkeys.ts +++ b/src/vs/workbench/browser/contextkeys.ts @@ -8,7 +8,7 @@ import { Disposable } from 'vs/base/common/lifecycle'; import { IContextKeyService, IContextKey, RawContextKey } from 'vs/platform/contextkey/common/contextkey'; import { InputFocusedContext, IsMacContext, IsLinuxContext, IsWindowsContext, IsWebContext, IsMacNativeContext, IsDevelopmentContext } from 'vs/platform/contextkey/common/contextkeys'; import { ActiveEditorContext, EditorsVisibleContext, TextCompareEditorVisibleContext, TextCompareEditorActiveContext, ActiveEditorGroupEmptyContext, MultipleEditorGroupsContext, TEXT_DIFF_EDITOR_ID, SplitEditorsVertically, InEditorZenModeContext, IsCenteredLayoutContext, ActiveEditorGroupIndexContext, ActiveEditorGroupLastContext, ActiveEditorReadonlyContext, EditorAreaVisibleContext, ActiveEditorAvailableEditorIdsContext } from 'vs/workbench/common/editor'; -import { trackFocus, addDisposableListener, EventType } from 'vs/base/browser/dom'; +import { trackFocus, addDisposableListener, EventType, WebFileSystemAccess } from 'vs/base/browser/dom'; import { preferredSideBySideGroupDirection, GroupDirection, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; @@ -32,6 +32,9 @@ export const RemoteNameContext = new RawContextKey('remoteName', ''); export const IsFullscreenContext = new RawContextKey('isFullscreen', false); +// Support for FileSystemAccess web APIs (https://wicg.github.io/file-system-access) +export const HasWebFileSystemAccess = new RawContextKey('hasWebFileSystemAccess', false); + export class WorkbenchContextKeysHandler extends Disposable { private inputFocusedContext: IContextKey; @@ -85,6 +88,9 @@ export class WorkbenchContextKeysHandler extends Disposable { RemoteNameContext.bindTo(this.contextKeyService).set(getRemoteName(this.environmentService.remoteAuthority) || ''); + // Capabilities + HasWebFileSystemAccess.bindTo(this.contextKeyService).set(WebFileSystemAccess.supported(window)); + // Development IsDevelopmentContext.bindTo(this.contextKeyService).set(!this.environmentService.isBuilt || this.environmentService.isExtensionDevelopment); diff --git a/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts b/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts index 086fa8c123a..29b83a97cc2 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.contribution.ts @@ -22,7 +22,7 @@ import { AutoSaveAfterShortDelayContext } from 'vs/workbench/services/filesConfi import { ResourceContextKey } from 'vs/workbench/common/resources'; import { WorkbenchListDoubleSelection } from 'vs/platform/list/browser/listService'; import { Schemas } from 'vs/base/common/network'; -import { DirtyWorkingCopiesContext, EmptyWorkspaceSupportContext, WorkspaceFolderCountContext } from 'vs/workbench/browser/contextkeys'; +import { DirtyWorkingCopiesContext, EmptyWorkspaceSupportContext, HasWebFileSystemAccess, WorkspaceFolderCountContext } from 'vs/workbench/browser/contextkeys'; import { IsWebContext } from 'vs/platform/contextkey/common/contextkeys'; import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { OpenFileFolderAction, OpenFileAction, OpenFolderAction, OpenWorkspaceAction } from 'vs/workbench/browser/actions/workspaceActions'; @@ -222,7 +222,7 @@ appendToCommandPalette(COMPARE_WITH_SAVED_COMMAND_ID, { value: nls.localize('com appendToCommandPalette(SAVE_FILE_AS_COMMAND_ID, { value: SAVE_FILE_AS_LABEL, original: 'Save As...' }, category); appendToCommandPalette(NEW_FILE_COMMAND_ID, { value: NEW_FILE_LABEL, original: 'New File' }, category, WorkspaceFolderCountContext.notEqualsTo('0')); appendToCommandPalette(NEW_FOLDER_COMMAND_ID, { value: NEW_FOLDER_LABEL, original: 'New Folder' }, category, WorkspaceFolderCountContext.notEqualsTo('0')); -appendToCommandPalette(DOWNLOAD_COMMAND_ID, { value: DOWNLOAD_LABEL, original: 'Download' }, category, ContextKeyExpr.and(ResourceContextKey.Scheme.notEqualsTo(Schemas.file))); +appendToCommandPalette(DOWNLOAD_COMMAND_ID, { value: DOWNLOAD_LABEL, original: 'Download...' }, category, ContextKeyExpr.and(ResourceContextKey.Scheme.notEqualsTo(Schemas.file))); appendToCommandPalette(NEW_UNTITLED_FILE_COMMAND_ID, { value: NEW_UNTITLED_FILE_LABEL, original: 'New Untitled File' }, category); // Menu registration - open editors @@ -489,7 +489,14 @@ MenuRegistry.appendMenuItem(MenuId.ExplorerContext, ({ id: DOWNLOAD_COMMAND_ID, title: DOWNLOAD_LABEL, }, - when: ContextKeyExpr.or(ContextKeyExpr.and(ResourceContextKey.Scheme.notEqualsTo(Schemas.file), IsWebContext.toNegated()), ContextKeyExpr.and(ResourceContextKey.Scheme.notEqualsTo(Schemas.file), ExplorerFolderContext.toNegated(), ExplorerRootContext.toNegated())) + when: ContextKeyExpr.or( + // native: for any remote resource + ContextKeyExpr.and(IsWebContext.toNegated(), ResourceContextKey.Scheme.notEqualsTo(Schemas.file)), + // web: for any files + ContextKeyExpr.and(IsWebContext, ExplorerFolderContext.toNegated(), ExplorerRootContext.toNegated()), + // web: for any folders if file system API support is provided + ContextKeyExpr.and(IsWebContext, HasWebFileSystemAccess) + ) })); MenuRegistry.appendMenuItem(MenuId.ExplorerContext, { diff --git a/src/vs/workbench/contrib/files/browser/fileActions.ts b/src/vs/workbench/contrib/files/browser/fileActions.ts index b5437e7859a..f91217259bd 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.ts @@ -11,10 +11,10 @@ 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 { dispose, IDisposable } from 'vs/base/common/lifecycle'; +import { DisposableStore, dispose, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { VIEWLET_ID, IExplorerService, IFilesConfiguration, VIEW_ID } from 'vs/workbench/contrib/files/common/files'; import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; -import { IFileService, IFileStatWithMetadata } from 'vs/platform/files/common/files'; +import { BinarySize, IFileService, IFileStatWithMetadata, IFileStreamContent } from 'vs/platform/files/common/files'; import { EditorResourceAccessor, SideBySideEditor } from 'vs/workbench/common/editor'; import { ExplorerViewPaneContainer } from 'vs/workbench/contrib/files/browser/explorerViewlet'; import { IQuickInputService, ItemActivation } from 'vs/platform/quickinput/common/quickInput'; @@ -39,7 +39,7 @@ 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 { triggerDownload } from 'vs/base/browser/dom'; +import { WebFileSystemAccess, triggerDownload } from 'vs/base/browser/dom'; import { mnemonicButtonLabel } from 'vs/base/common/labels'; import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; import { IWorkingCopyService, IWorkingCopy } from 'vs/workbench/services/workingCopy/common/workingCopyService'; @@ -49,6 +49,9 @@ 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, ProgressLocation } from 'vs/platform/progress/common/progress'; +import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { ILogService } from 'vs/platform/log/common/log'; export const NEW_FILE_COMMAND_ID = 'explorer.newFile'; export const NEW_FILE_LABEL = nls.localize('newFile', "New File"); @@ -66,7 +69,7 @@ export const PASTE_FILE_LABEL = nls.localize('pasteFile', "Paste"); export const FileCopiedContext = new RawContextKey('fileCopied', false); -export const DOWNLOAD_LABEL = nls.localize('download', "Download"); +export const DOWNLOAD_LABEL = nls.localize('download', "Download..."); const CONFIRM_DELETE_SETTING_KEY = 'explorer.confirmDelete'; @@ -997,49 +1000,212 @@ export const cutFileHandler = async (accessor: ServicesAccessor) => { export const DOWNLOAD_COMMAND_ID = 'explorer.download'; const downloadFileHandler = (accessor: ServicesAccessor) => { + const logService = accessor.get(ILogService); const fileService = accessor.get(IFileService); const workingCopyFileService = accessor.get(IWorkingCopyFileService); const fileDialogService = accessor.get(IFileDialogService); const explorerService = accessor.get(IExplorerService); - const stats = explorerService.getContext(true); + const progressService = accessor.get(IProgressService); - let canceled = false; - sequence(stats.map(s => async () => { - if (canceled) { - return; - } + const context = explorerService.getContext(true); + const explorerItems = context.length ? context : explorerService.roots; - if (isWeb) { - if (!s.isDirectory) { - let bufferOrUri: Uint8Array | URI; - try { - bufferOrUri = (await fileService.readFile(s.resource, { limits: { size: 1024 * 1024 /* set a limit to reduce memory pressure */ } })).value.buffer; - } catch (error) { - bufferOrUri = FileAccess.asBrowserUri(s.resource); + const cts = new CancellationTokenSource(); + + const downloadPromise = 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; } - triggerDownload(bufferOrUri, s.name); - } - } else { - let defaultUri = s.isDirectory ? fileDialogService.defaultFolderPath(Schemas.file) : fileDialogService.defaultFilePath(Schemas.file); - if (defaultUri) { - defaultUri = resources.joinPath(defaultUri, s.name); + const maxBlobDownloadSize = 32 * BinarySize.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, + + filesTotal: number; + filesDownloaded: number; + + totalBytesDownloaded: 0 + fileBytesDownloaded: 0 + } + + async function pipeContents(name: string, source: IFileStreamContent, target: WebFileSystemAccess.FileSystemWritableFileStream, operation: IDownloadOperation): Promise { + return new Promise((resolve, reject) => { + const sourceStream = source.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(); + })); + + sourceStream.on('data', data => { + if (!disposed) { + target.write(data.buffer); + reportProgress(name, source.size, data.byteLength, operation); + } + }); + + sourceStream.on('error', error => { + disposables.dispose(); + reject(error); + }); + + sourceStream.on('end', () => { + disposables.dispose(); + resolve(); + }); + }); + } + + async function downloadFile(targetFolder: WebFileSystemAccess.FileSystemDirectoryHandle, name: string, resource: URI, operation: IDownloadOperation): Promise { + + // Report progress + operation.filesDownloaded++; + operation.fileBytesDownloaded = 0; // reset for this file + reportProgress(name, 0, 0, operation); + + // Start to download + const targetFile = await targetFolder.getFileHandle(name, { create: true }); + const targetFileWriter = await targetFile.createWritable(); + + return pipeContents(name, await fileService.readFileStream(resource), targetFileWriter, operation); + } + + async function downloadFolder(folder: IFileStatWithMetadata, targetFolder: WebFileSystemAccess.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.name, child.resource, 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 < BinarySize.MB) { + if (operation.filesTotal === 1) { + message = name; + } else { + message = nls.localize('downloadProgressSmallMany', "{0} of {1} files ({2}/s)", operation.filesDownloaded, operation.filesTotal, BinarySize.formatSize(bytesDownloadedPerSecond)); + } + } + + // Large file + else { + message = nls.localize('downloadProgressLarge', "{0} ({1} of {2}, {3}/s)", name, BinarySize.formatSize(operation.fileBytesDownloaded), BinarySize.formatSize(fileSize), BinarySize.formatSize(bytesDownloadedPerSecond)); + } + + progress.report({ message }); + } + + try { + const targetFolder: WebFileSystemAccess.FileSystemDirectoryHandle = await window.showDirectoryPicker(); + const operation: IDownloadOperation = { + startTime: Date.now(), + + filesTotal: stat.isDirectory ? 0 : 1, // folders increment filesTotal within downloadFolder method + filesDownloaded: 0, + + totalBytesDownloaded: 0, + fileBytesDownloaded: 0 + }; + + if (stat.isDirectory) { + await downloadFolder(stat, targetFolder, operation); + } else { + await downloadFile(targetFolder, stat.name, stat.resource, operation); + } + } catch (error) { + logService.trace(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); + } + } } - const destination = await fileDialogService.showSaveDialog({ - availableFileSystems: [Schemas.file], - saveLabel: mnemonicButtonLabel(nls.localize('download', "Download")), - title: s.isDirectory ? nls.localize('downloadFolder', "Download Folder") : nls.localize('downloadFile', "Download File"), - defaultUri - }); - if (destination) { - await workingCopyFileService.copy([{ source: s.resource, target: destination }], { overwrite: true }); - } else { - // User canceled a download. In case there were multiple files selected we should cancel the remainder of the prompts #86100 - canceled = true; + // Native: use working copy file service to get at the contents + else { + progress.report({ message: explorerItem.name }); + + let defaultUri = explorerItem.isDirectory ? fileDialogService.defaultFolderPath(Schemas.file) : fileDialogService.defaultFilePath(Schemas.file); + if (defaultUri) { + defaultUri = resources.joinPath(defaultUri, explorerItem.name); + } + + const destination = await fileDialogService.showSaveDialog({ + availableFileSystems: [Schemas.file], + saveLabel: mnemonicButtonLabel(nls.localize('downloadButton', "Download")), + title: explorerItem.isDirectory ? nls.localize('downloadFolder', "Download Folder") : nls.localize('downloadFile', "Download File"), + defaultUri + }); + + if (destination) { + await workingCopyFileService.copy([{ source: explorerItem.resource, target: destination }], { overwrite: true }); + } 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)); + + // Also indicate progress in the files view + progressService.withProgress({ location: VIEW_ID, delay: 800 }, () => downloadPromise); }; CommandsRegistry.registerCommand({ diff --git a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts index 4ed199508e9..01b3dbc05a5 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts @@ -1037,6 +1037,9 @@ export class FileDragAndDrop implements ITreeDragAndDrop { title: localize('uploadingFiles', "Uploading") }, async progress => { for (let entry of entries) { + if (cts.token.isCancellationRequested) { + break; + } // Confirm overwrite as needed if (target && entry.name && target.getChild(entry.name)) { @@ -1081,15 +1084,19 @@ export class FileDragAndDrop implements ITreeDragAndDrop { const bytesUploadedPerSecond = operation.bytesUploaded / ((Date.now() - operation.startTime) / 1000); + // Small file let message: string; - if (operation.filesTotal === 1 && entry.name) { - message = entry.name; - } else { - message = localize('uploadProgress', "{0} of {1} files ({2}/s)", operation.filesUploaded, operation.filesTotal, BinarySize.formatSize(bytesUploadedPerSecond)); + if (fileSize < BinarySize.MB) { + if (operation.filesTotal === 1) { + message = `${entry.name}`; + } else { + message = localize('uploadProgressSmallMany', "{0} of {1} files ({2}/s)", operation.filesUploaded, operation.filesTotal, BinarySize.formatSize(bytesUploadedPerSecond)); + } } - if (fileSize > BinarySize.MB) { - message = localize('uploadProgressDetail', "{0} ({1} of {2}, {3}/s)", message, BinarySize.formatSize(fileBytesUploaded), BinarySize.formatSize(fileSize), BinarySize.formatSize(bytesUploadedPerSecond)); + // Large file + else { + message = localize('uploadProgressLarge', "{0} ({1} of {2}, {3}/s)", entry.name, BinarySize.formatSize(fileBytesUploaded), BinarySize.formatSize(fileSize), BinarySize.formatSize(bytesUploadedPerSecond)); } progress.report({ message }); @@ -1140,7 +1147,7 @@ export class FileDragAndDrop implements ITreeDragAndDrop { } else { done = true; // an empty array is a signal that all entries have been read } - } while (!done); + } while (!done && !token.isCancellationRequested); // Update operation total based on new counts operation.filesTotal += childEntries.length;