Archived
1
0

Add support for running extensions in the browser

This commit is contained in:
Asher
2019-10-04 18:14:07 -05:00
parent 846dcbb947
commit 548d095611
24 changed files with 727 additions and 51 deletions

369
src/browser/api.ts Normal file
View File

@ -0,0 +1,369 @@
import * as vscode from "vscode";
import { CoderApi, VSCodeApi } from "../../typings/api";
import { createCSSRule } from "vs/base/browser/dom";
import { Emitter, Event } from "vs/base/common/event";
import { IDisposable } from "vs/base/common/lifecycle";
import { URI } from "vs/base/common/uri";
import { generateUuid } from "vs/base/common/uuid";
import { localize } from "vs/nls";
import { SyncActionDescriptor } from "vs/platform/actions/common/actions";
import { CommandsRegistry, ICommandService } from "vs/platform/commands/common/commands";
import { IConfigurationService } from "vs/platform/configuration/common/configuration";
import { IContextMenuService } from "vs/platform/contextview/browser/contextView";
import { FileDeleteOptions, FileOpenOptions, FileOverwriteOptions, FileSystemProviderCapabilities, FileType, FileWriteOptions, IFileChange, IFileService, IFileSystemProvider, IStat, IWatchOptions } from "vs/platform/files/common/files";
import { IInstantiationService, ServiceIdentifier } from "vs/platform/instantiation/common/instantiation";
import { ServiceCollection } from "vs/platform/instantiation/common/serviceCollection";
import { INotificationService } from "vs/platform/notification/common/notification";
import { Registry } from "vs/platform/registry/common/platform";
import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, StatusbarAlignment } from "vs/platform/statusbar/common/statusbar";
import { IStorageService } from "vs/platform/storage/common/storage";
import { ITelemetryService } from "vs/platform/telemetry/common/telemetry";
import { IThemeService } from "vs/platform/theme/common/themeService";
import { IWorkspaceContextService } from "vs/platform/workspace/common/workspace";
import * as extHostTypes from "vs/workbench/api/common/extHostTypes";
import { CustomTreeView, CustomTreeViewPanel } from "vs/workbench/browser/parts/views/customView";
import { ViewContainerViewlet } from "vs/workbench/browser/parts/views/viewsViewlet";
import { Extensions as ViewletExtensions, ShowViewletAction, ViewletDescriptor, ViewletRegistry } from "vs/workbench/browser/viewlet";
import { Extensions as ActionExtensions, IWorkbenchActionRegistry } from "vs/workbench/common/actions";
import { Extensions as ViewsExtensions, ITreeItem, ITreeViewDataProvider, ITreeViewDescriptor, IViewContainersRegistry, IViewsRegistry, TreeItemCollapsibleState } from "vs/workbench/common/views";
import { IEditorGroupsService } from "vs/workbench/services/editor/common/editorGroupsService";
import { IEditorService } from "vs/workbench/services/editor/common/editorService";
import { IExtensionService } from "vs/workbench/services/extensions/common/extensions";
import { IWorkbenchLayoutService } from "vs/workbench/services/layout/browser/layoutService";
import { IViewletService } from "vs/workbench/services/viewlet/browser/viewlet";
/**
* Client-side implementation of VS Code's API.
* TODO: Views aren't quite working.
* TODO: Implement menu items for views (for item actions).
* TODO: File system provider doesn't work.
*/
export const vscodeApi = (serviceCollection: ServiceCollection): VSCodeApi => {
const getService = <T>(id: ServiceIdentifier<T>): T => serviceCollection.get<T>(id) as T;
const commandService = getService(ICommandService);
const notificationService = getService(INotificationService);
const fileService = getService(IFileService);
const viewsRegistry = Registry.as<IViewsRegistry>(ViewsExtensions.ViewsRegistry);
const statusbarService = getService(IStatusbarService);
// It would be nice to just export what VS Code creates but it looks to me
// that it assumes it's running in the extension host and wouldn't work here.
// It is probably possible to create an extension host that runs in the
// browser's main thread, but I'm not sure how much jank that would require.
// We could have a web worker host but we want DOM access.
return {
EventEmitter: <any>Emitter, // It can take T so T | undefined should work.
FileSystemError: extHostTypes.FileSystemError,
FileType,
StatusBarAlignment: extHostTypes.StatusBarAlignment,
ThemeColor: extHostTypes.ThemeColor,
TreeItemCollapsibleState: extHostTypes.TreeItemCollapsibleState,
Uri: URI,
commands: {
executeCommand: <T = any>(commandId: string, ...args: any[]): Promise<T | undefined> => {
return commandService.executeCommand(commandId, ...args);
},
registerCommand: (id: string, command: (...args: any[]) => any): IDisposable => {
return CommandsRegistry.registerCommand(id, command);
},
},
window: {
createStatusBarItem(alignmentOrOptions?: extHostTypes.StatusBarAlignment | vscode.window.StatusBarItemOptions, priority?: number): StatusBarEntry {
return new StatusBarEntry(statusbarService, alignmentOrOptions, priority);
},
registerTreeDataProvider: <T>(id: string, dataProvider: vscode.TreeDataProvider<T>): IDisposable => {
const tree = new TreeViewDataProvider(dataProvider);
const view = viewsRegistry.getView(id);
(view as ITreeViewDescriptor).treeView.dataProvider = tree;
return {
dispose: () => tree.dispose(),
};
},
showErrorMessage: async (message: string): Promise<string | undefined> => {
notificationService.error(message);
return undefined;
},
},
workspace: {
registerFileSystemProvider: (scheme: string, provider: vscode.FileSystemProvider): IDisposable => {
return fileService.registerProvider(scheme, new FileSystemProvider(provider));
},
},
};
};
/**
* Coder API. This should only provide functionality that can't be made
* available through the VS Code API.
*/
export const coderApi = (serviceCollection: ServiceCollection): CoderApi => {
const getService = <T>(id: ServiceIdentifier<T>): T => serviceCollection.get<T>(id) as T;
return {
registerView: (viewId, viewName, containerId, containerName, icon): void => {
const cssClass = `extensionViewlet-${containerId}`;
const id = `workbench.view.extension.${containerId}`;
class CustomViewlet extends ViewContainerViewlet {
public constructor(
@IConfigurationService configurationService: IConfigurationService,
@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService,
@ITelemetryService telemetryService: ITelemetryService,
@IWorkspaceContextService contextService: IWorkspaceContextService,
@IStorageService storageService: IStorageService,
@IEditorService _editorService: IEditorService,
@IInstantiationService instantiationService: IInstantiationService,
@IThemeService themeService: IThemeService,
@IContextMenuService contextMenuService: IContextMenuService,
@IExtensionService extensionService: IExtensionService,
) {
super(id, `${id}.state`, true, configurationService, layoutService, telemetryService, storageService, instantiationService, themeService, contextMenuService, extensionService, contextService);
}
}
Registry.as<ViewletRegistry>(ViewletExtensions.Viewlets).registerViewlet(
new ViewletDescriptor(CustomViewlet as any, id, containerName, cssClass, undefined, URI.parse(icon)),
);
Registry.as<IWorkbenchActionRegistry>(ActionExtensions.WorkbenchActions).registerWorkbenchAction(
new SyncActionDescriptor(OpenCustomViewletAction as any, id, localize("showViewlet", "Show {0}", containerName)),
"View: Show {0}",
localize("view", "View"),
);
// Generate CSS to show the icon in the activity bar.
const iconClass = `.monaco-workbench .activitybar .monaco-action-bar .action-label.${cssClass}`;
createCSSRule(iconClass, `-webkit-mask: url('${icon}') no-repeat 50% 50%`);
const container = Registry.as<IViewContainersRegistry>(ViewsExtensions.ViewContainersRegistry).registerViewContainer(containerId);
Registry.as<IViewsRegistry>(ViewsExtensions.ViewsRegistry).registerViews([{
id: viewId,
name: viewName,
ctorDescriptor: { ctor: CustomTreeViewPanel },
treeView: getService(IInstantiationService).createInstance(CustomTreeView as any, viewId, container),
}] as ITreeViewDescriptor[], container);
},
};
};
class OpenCustomViewletAction extends ShowViewletAction {
public constructor(
id: string, label: string,
@IViewletService viewletService: IViewletService,
@IEditorGroupsService editorGroupService: IEditorGroupsService,
@IWorkbenchLayoutService layoutService: IWorkbenchLayoutService,
) {
super(id, label, id, viewletService, editorGroupService, layoutService);
}
}
class FileSystemProvider implements IFileSystemProvider {
private readonly _onDidChange = new Emitter<IFileChange[]>();
public readonly onDidChangeFile: Event<IFileChange[]> = this._onDidChange.event;
public readonly capabilities: FileSystemProviderCapabilities;
public readonly onDidChangeCapabilities: Event<void> = Event.None;
public constructor(private readonly provider: vscode.FileSystemProvider) {
this.capabilities = FileSystemProviderCapabilities.Readonly;
}
public watch(resource: URI, opts: IWatchOptions): IDisposable {
return this.provider.watch(resource, opts);
}
public async stat(resource: URI): Promise<IStat> {
return this.provider.stat(resource);
}
public async readFile(resource: URI): Promise<Uint8Array> {
return this.provider.readFile(resource);
}
public async writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise<void> {
return this.provider.writeFile(resource, content, opts);
}
public async delete(resource: URI, opts: FileDeleteOptions): Promise<void> {
return this.provider.delete(resource, opts);
}
public mkdir(_resource: URI): Promise<void> {
throw new Error("not implemented");
}
public async readdir(resource: URI): Promise<[string, FileType][]> {
return this.provider.readDirectory(resource);
}
public async rename(resource: URI, target: URI, opts: FileOverwriteOptions): Promise<void> {
return this.provider.rename(resource, target, opts);
}
public async copy(resource: URI, target: URI, opts: FileOverwriteOptions): Promise<void> {
return this.provider.copy!(resource, target, opts);
}
public open(_resource: URI, _opts: FileOpenOptions): Promise<number> {
throw new Error("not implemented");
}
public close(_fd: number): Promise<void> {
throw new Error("not implemented");
}
public read(_fd: number, _pos: number, _data: Uint8Array, _offset: number, _length: number): Promise<number> {
throw new Error("not implemented");
}
public write(_fd: number, _pos: number, _data: Uint8Array, _offset: number, _length: number): Promise<number> {
throw new Error("not implemented");
}
}
class TreeViewDataProvider<T> implements ITreeViewDataProvider {
private readonly root = Symbol("root");
private readonly values = new Map<string, T>();
private readonly children = new Map<T | Symbol, ITreeItem[]>();
public constructor(private readonly provider: vscode.TreeDataProvider<T>) {}
public async getChildren(item?: ITreeItem): Promise<ITreeItem[]> {
const value = item && this.itemToValue(item);
const children = await Promise.all(
(await this.provider.getChildren(value) || [])
.map(async (childValue) => {
const treeItem = await this.provider.getTreeItem(childValue);
const handle = this.createHandle(treeItem);
this.values.set(handle, childValue);
return {
handle,
collapsibleState: TreeItemCollapsibleState.Collapsed,
};
})
);
this.clear(value || this.root, item);
this.children.set(value || this.root, children);
return children;
}
public dispose(): void {
throw new Error("not implemented");
}
private itemToValue(item: ITreeItem): T {
if (!this.values.has(item.handle)) {
throw new Error(`No element found with handle ${item.handle}`);
}
return this.values.get(item.handle)!;
}
private clear(value: T | Symbol, item?: ITreeItem): void {
if (this.children.has(value)) {
this.children.get(value)!.map((c) => this.clear(this.itemToValue(c), c));
this.children.delete(value);
}
if (item) {
this.values.delete(item.handle);
}
}
private createHandle(item: vscode.TreeItem): string {
return item.id
? `coder-tree-item-id/${item.id}`
: `coder-tree-item-uuid/${generateUuid()}`;
}
}
interface IStatusBarEntry extends IStatusbarEntry {
alignment: StatusbarAlignment;
priority?: number;
}
class StatusBarEntry implements vscode.StatusBarItem {
private static ID = 0;
private _id: number;
private entry: IStatusBarEntry;
private visible: boolean;
private disposed: boolean;
private statusId: string;
private statusName: string;
private accessor?: IStatusbarEntryAccessor;
private timeout: any;
constructor(private readonly statusbarService: IStatusbarService, alignmentOrOptions?: extHostTypes.StatusBarAlignment | vscode.window.StatusBarItemOptions, priority?: number) {
this._id = StatusBarEntry.ID--;
if (alignmentOrOptions && typeof alignmentOrOptions !== "number") {
this.statusId = alignmentOrOptions.id;
this.statusName = alignmentOrOptions.name;
this.entry = {
alignment: alignmentOrOptions.alignment === extHostTypes.StatusBarAlignment.Right
? StatusbarAlignment.RIGHT : StatusbarAlignment.LEFT,
priority,
text: "",
};
} else {
this.statusId = "web-api";
this.statusName = "Web API";
this.entry = {
alignment: alignmentOrOptions === extHostTypes.StatusBarAlignment.Right
? StatusbarAlignment.RIGHT : StatusbarAlignment.LEFT,
priority,
text: "",
};
}
}
public get alignment(): extHostTypes.StatusBarAlignment {
return this.entry.alignment === StatusbarAlignment.RIGHT
? extHostTypes.StatusBarAlignment.Right : extHostTypes.StatusBarAlignment.Left;
}
public get id(): number { return this._id; }
public get priority(): number | undefined { return this.entry.priority; }
public get text(): string { return this.entry.text; }
public get tooltip(): string | undefined { return this.entry.tooltip; }
public get color(): string | extHostTypes.ThemeColor | undefined { return this.entry.color; }
public get command(): string | undefined { return this.entry.command; }
public set text(text: string) { this.update({ text }); }
public set tooltip(tooltip: string | undefined) { this.update({ tooltip }); }
public set color(color: string | extHostTypes.ThemeColor | undefined) { this.update({ color }); }
public set command(command: string | undefined) { this.update({ command }); }
public show(): void {
this.visible = true;
this.update();
}
public hide(): void {
clearTimeout(this.timeout);
this.visible = false;
if (this.accessor) {
this.accessor.dispose();
this.accessor = undefined;
}
}
private update(values?: Partial<IStatusBarEntry>): void {
this.entry = { ...this.entry, ...values };
if (this.disposed || !this.visible) {
return;
}
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
if (!this.accessor) {
this.accessor = this.statusbarService.addEntry(this.entry, this.statusId, this.statusName, this.entry.alignment, this.priority);
} else {
this.accessor.update(this.entry);
}
}, 0);
}
public dispose(): void {
this.hide();
this.disposed = true;
}
}

86
src/browser/client.ts Normal file
View File

@ -0,0 +1,86 @@
import { Emitter } from "vs/base/common/event";
import { URI } from "vs/base/common/uri";
import { registerSingleton } from "vs/platform/instantiation/common/extensions";
import { ServiceCollection } from "vs/platform/instantiation/common/serviceCollection";
import { ILocalizationsService } from "vs/platform/localizations/common/localizations";
import { LocalizationsService } from "vs/platform/localizations/electron-browser/localizationsService";
import { ITelemetryService } from "vs/platform/telemetry/common/telemetry";
import { IUpdateService } from "vs/platform/update/common/update";
import { UpdateService } from "vs/platform/update/electron-browser/updateService";
import { coderApi, vscodeApi } from "vs/server/src/browser/api";
import { IUploadService, UploadService } from "vs/server/src/browser/upload";
import { INodeProxyService, NodeProxyChannelClient } from "vs/server/src/common/nodeProxy";
import { TelemetryChannelClient } from "vs/server/src/common/telemetry";
import "vs/workbench/contrib/localizations/browser/localizations.contribution";
import "vs/workbench/contrib/update/electron-browser/update.contribution";
import { IRemoteAgentService } from "vs/workbench/services/remote/common/remoteAgentService";
class TelemetryService extends TelemetryChannelClient {
public constructor(
@IRemoteAgentService remoteAgentService: IRemoteAgentService,
) {
super(remoteAgentService.getConnection()!.getChannel("telemetry"));
}
}
class NodeProxyService extends NodeProxyChannelClient implements INodeProxyService {
private readonly _onClose = new Emitter<void>();
public readonly onClose = this._onClose.event;
private readonly _onDown = new Emitter<void>();
public readonly onDown = this._onDown.event;
private readonly _onUp = new Emitter<void>();
public readonly onUp = this._onUp.event;
public constructor(
@IRemoteAgentService remoteAgentService: IRemoteAgentService,
) {
// TODO: up/down/close
super(remoteAgentService.getConnection()!.getChannel("nodeProxy"));
}
}
registerSingleton(ILocalizationsService, LocalizationsService);
registerSingleton(INodeProxyService, NodeProxyService);
registerSingleton(ITelemetryService, TelemetryService);
registerSingleton(IUpdateService, UpdateService);
registerSingleton(IUploadService, UploadService, true);
/**
* This is called by vs/workbench/browser/web.main.ts after the workbench has
* been initialized so we can initialize our own client-side code.
*/
export const initialize = async (services: ServiceCollection): Promise<void> => {
const target = window as any;
target.ide = coderApi(services);
target.vscode = vscodeApi(services);
const event = new CustomEvent("ide-ready");
(event as any).ide = target.ide;
(event as any).vscode = target.vscode;
window.dispatchEvent(event);
};
export interface Query {
[key: string]: string | undefined;
}
/**
* Return the URL modified with the specified query variables. It's pretty
* stupid so it probably doesn't cover any edge cases. Undefined values will
* unset existing values. Doesn't allow duplicates.
*/
export const withQuery = (url: string, replace: Query): string => {
const uri = URI.parse(url);
const query = { ...replace };
uri.query.split("&").forEach((kv) => {
const [key, value] = kv.split("=", 2);
if (!(key in query)) {
query[key] = value;
}
});
return uri.with({
query: Object.keys(query)
.filter((k) => typeof query[k] !== "undefined")
.map((k) => `${k}=${query[k]}`).join("&"),
}).toString(true);
};

View File

@ -0,0 +1,46 @@
import { Emitter } from "vs/base/common/event";
import { createDecorator } from 'vs/platform/instantiation/common/instantiation';
import { ExtHostNodeProxyShape, MainContext, MainThreadNodeProxyShape } from "vs/workbench/api/common/extHost.protocol";
import { IExtHostRpcService } from "vs/workbench/api/common/extHostRpcService";
export class ExtHostNodeProxy implements ExtHostNodeProxyShape {
_serviceBrand: any;
private readonly _onMessage = new Emitter<string>();
public readonly onMessage = this._onMessage.event;
private readonly _onClose = new Emitter<void>();
public readonly onClose = this._onClose.event;
private readonly _onDown = new Emitter<void>();
public readonly onDown = this._onDown.event;
private readonly _onUp = new Emitter<void>();
public readonly onUp = this._onUp.event;
private readonly proxy: MainThreadNodeProxyShape;
constructor(@IExtHostRpcService rpc: IExtHostRpcService) {
this.proxy = rpc.getProxy(MainContext.MainThreadNodeProxy);
}
public $onMessage(message: string): void {
this._onMessage.fire(message);
}
public $onClose(): void {
this._onClose.fire();
}
public $onUp(): void {
this._onUp.fire();
}
public $onDown(): void {
this._onDown.fire();
}
public send(message: string): void {
this.proxy.$send(message);
}
}
export interface IExtHostNodeProxy extends ExtHostNodeProxy { }
export const IExtHostNodeProxy = createDecorator<IExtHostNodeProxy>('IExtHostNodeProxy');

View File

@ -0,0 +1,37 @@
import { IDisposable } from "vs/base/common/lifecycle";
import { INodeProxyService } from "vs/server/src/common/nodeProxy";
import { ExtHostContext, IExtHostContext, MainContext, MainThreadNodeProxyShape } from "vs/workbench/api/common/extHost.protocol";
import { extHostNamedCustomer } from "vs/workbench/api/common/extHostCustomers";
@extHostNamedCustomer(MainContext.MainThreadNodeProxy)
export class MainThreadNodeProxy implements MainThreadNodeProxyShape {
private disposed = false;
private disposables = <IDisposable[]>[];
constructor(
extHostContext: IExtHostContext,
@INodeProxyService private readonly proxyService: INodeProxyService,
) {
if (!extHostContext.remoteAuthority) { // HACK: A terrible way to detect if running in the worker.
const proxy = extHostContext.getProxy(ExtHostContext.ExtHostNodeProxy);
this.disposables = [
this.proxyService.onMessage((message: string) => proxy.$onMessage(message)),
this.proxyService.onClose(() => proxy.$onClose()),
this.proxyService.onDown(() => proxy.$onDown()),
this.proxyService.onUp(() => proxy.$onUp()),
];
}
}
$send(message: string): void {
if (!this.disposed) {
this.proxyService.send(message);
}
}
dispose(): void {
this.disposables.forEach((d) => d.dispose());
this.disposables = [];
this.disposed = true;
}
}

372
src/browser/upload.ts Normal file
View File

@ -0,0 +1,372 @@
import { DesktopDragAndDropData } from "vs/base/browser/ui/list/listView";
import { VSBuffer, VSBufferReadableStream } from "vs/base/common/buffer";
import { Disposable } from "vs/base/common/lifecycle";
import * as path from "vs/base/common/path";
import { URI } from "vs/base/common/uri";
import { generateUuid } from "vs/base/common/uuid";
import { IFileService } from "vs/platform/files/common/files";
import { createDecorator, IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { INotificationService, Severity } from "vs/platform/notification/common/notification";
import { IProgress, IProgressService, IProgressStep, ProgressLocation } from "vs/platform/progress/common/progress";
import { IWindowsService } from "vs/platform/windows/common/windows";
import { IWorkspaceContextService } from "vs/platform/workspace/common/workspace";
import { ExplorerItem } from "vs/workbench/contrib/files/common/explorerModel";
import { IEditorGroup } from "vs/workbench/services/editor/common/editorGroupsService";
import { IEditorService } from "vs/workbench/services/editor/common/editorService";
export const IUploadService = createDecorator<IUploadService>("uploadService");
export interface IUploadService {
_serviceBrand: undefined;
handleDrop(event: DragEvent, resolveTargetGroup: () => IEditorGroup | undefined, afterDrop: (targetGroup: IEditorGroup | undefined) => void, targetIndex?: number): Promise<void>;
handleExternalDrop(data: DesktopDragAndDropData, target: ExplorerItem, originalEvent: DragEvent): Promise<void>;
}
export class UploadService extends Disposable implements IUploadService {
public _serviceBrand: undefined;
public upload: Upload;
public constructor(
@IInstantiationService instantiationService: IInstantiationService,
@IWorkspaceContextService private readonly contextService: IWorkspaceContextService,
@IWindowsService private readonly windowsService: IWindowsService,
@IEditorService private readonly editorService: IEditorService,
) {
super();
this.upload = instantiationService.createInstance(Upload);
}
public async handleDrop(event: DragEvent, resolveTargetGroup: () => IEditorGroup | undefined, afterDrop: (targetGroup: IEditorGroup | undefined) => void, targetIndex?: number): Promise<void> {
// TODO: should use the workspace for the editor it was dropped on?
const target =this.contextService.getWorkspace().folders[0].uri;
const uris = (await this.upload.uploadDropped(event, target)).map((u) => URI.file(u));
if (uris.length > 0) {
await this.windowsService.addRecentlyOpened(uris.map((u) => ({ fileUri: u })));
}
const editors = uris.map((uri) => ({
resource: uri,
options: {
pinned: true,
index: targetIndex,
},
}));
const targetGroup = resolveTargetGroup();
this.editorService.openEditors(editors, targetGroup);
afterDrop(targetGroup);
}
public async handleExternalDrop(_data: DesktopDragAndDropData, target: ExplorerItem, originalEvent: DragEvent): Promise<void> {
await this.upload.uploadDropped(originalEvent, target.resource);
}
}
/**
* There doesn't seem to be a provided type for entries, so here is an
* incomplete version.
*/
interface IEntry {
name: string;
isFile: boolean;
file: (cb: (file: File) => void) => void;
createReader: () => ({
readEntries: (cb: (entries: Array<IEntry>) => void) => void;
});
}
/**
* Handles file uploads.
*/
class Upload {
private readonly maxParallelUploads = 100;
private readonly uploadingFiles = new Map<string, Reader | undefined>();
private readonly fileQueue = new Map<string, File>();
private progress: IProgress<IProgressStep> | undefined;
private uploadPromise: Promise<string[]> | undefined;
private resolveUploadPromise: (() => void) | undefined;
private uploadedFilePaths = <string[]>[];
private _total = 0;
private _uploaded = 0;
private lastPercent = 0;
public constructor(
@INotificationService private notificationService: INotificationService,
@IProgressService private progressService: IProgressService,
@IFileService private fileService: IFileService,
) {}
/**
* Upload dropped files. This will try to upload everything it can. Errors
* will show via notifications. If an upload operation is ongoing, the files
* will be added to that operation.
*/
public async uploadDropped(event: DragEvent, uploadDir: URI): Promise<string[]> {
await this.queueFiles(event, uploadDir);
if (!this.uploadPromise) {
this.uploadPromise = this.progressService.withProgress({
cancellable: true,
location: ProgressLocation.Notification,
title: "Uploading files...",
}, (progress) => {
return new Promise((resolve): void => {
this.progress = progress;
this.resolveUploadPromise = (): void => {
const uploaded = this.uploadedFilePaths;
this.uploadPromise = undefined;
this.resolveUploadPromise = undefined;
this.uploadedFilePaths = [];
this.lastPercent = 0;
this._uploaded = 0;
this._total = 0;
resolve(uploaded);
};
});
}, () => this.cancel());
}
this.uploadFiles();
return this.uploadPromise;
}
/**
* Cancel all file uploads.
*/
public async cancel(): Promise<void> {
this.fileQueue.clear();
this.uploadingFiles.forEach((r) => r && r.abort());
}
private get total(): number { return this._total; }
private set total(total: number) {
this._total = total;
this.updateProgress();
}
private get uploaded(): number { return this._uploaded; }
private set uploaded(uploaded: number) {
this._uploaded = uploaded;
this.updateProgress();
}
private updateProgress(): void {
if (this.progress && this.total > 0) {
const percent = Math.floor((this.uploaded / this.total) * 100);
this.progress.report({ increment: percent - this.lastPercent });
this.lastPercent = percent;
}
}
/**
* Upload as many files as possible. When finished, resolve the upload
* promise.
*/
private uploadFiles(): void {
while (this.fileQueue.size > 0 && this.uploadingFiles.size < this.maxParallelUploads) {
const [path, file] = this.fileQueue.entries().next().value;
this.fileQueue.delete(path);
if (this.uploadingFiles.has(path)) {
this.notificationService.error(new Error(`Already uploading ${path}`));
} else {
this.uploadingFiles.set(path, undefined);
this.uploadFile(path, file).catch((error) => {
this.notificationService.error(error);
}).finally(() => {
this.uploadingFiles.delete(path);
this.uploadFiles();
});
}
}
if (this.fileQueue.size === 0 && this.uploadingFiles.size === 0) {
this.resolveUploadPromise!();
}
}
/**
* Upload a file, asking to override if necessary.
*/
private async uploadFile(filePath: string, file: File): Promise<void> {
const uri = URI.file(filePath);
if (await this.fileService.exists(uri)) {
const overwrite = await new Promise<boolean>((resolve): void => {
this.notificationService.prompt(
Severity.Error,
`${filePath} already exists. Overwrite?`,
[
{ label: "Yes", run: (): void => resolve(true) },
{ label: "No", run: (): void => resolve(false) },
],
{ onCancel: () => resolve(false) },
);
});
if (!overwrite) {
return;
}
}
const tempUri = uri.with({
path: path.join(
path.dirname(uri.path),
`.code-server-partial-upload-${path.basename(uri.path)}-${generateUuid()}`,
),
});
const reader = new Reader(file);
reader.on("data", (data) => {
if (data && data.byteLength > 0) {
this.uploaded += data.byteLength;
}
});
this.uploadingFiles.set(filePath, reader);
await this.fileService.writeFile(tempUri, reader);
if (reader.aborted) {
this.uploaded += (file.size - reader.offset);
await this.fileService.del(tempUri);
} else {
await this.fileService.move(tempUri, uri, true);
this.uploadedFilePaths.push(filePath);
}
}
/**
* Queue files from a drop event. We have to get the files first; we can't do
* it in tandem with uploading or the entries will disappear.
*/
private async queueFiles(event: DragEvent, uploadDir: URI): Promise<void> {
const promises: Array<Promise<void>> = [];
for (let i = 0; event.dataTransfer && event.dataTransfer.items && i < event.dataTransfer.items.length; ++i) {
const item = event.dataTransfer.items[i];
if (typeof item.webkitGetAsEntry === "function") {
promises.push(this.traverseItem(item.webkitGetAsEntry(), uploadDir.fsPath));
} else {
const file = item.getAsFile();
if (file) {
this.addFile(uploadDir.fsPath + "/" + file.name, file);
}
}
}
await Promise.all(promises);
}
/**
* Traverses an entry and add files to the queue.
*/
private async traverseItem(entry: IEntry, path: string): Promise<void> {
if (entry.isFile) {
return new Promise<void>((resolve): void => {
entry.file((file) => {
resolve(this.addFile(path + "/" + file.name, file));
});
});
}
path += "/" + entry.name;
await new Promise((resolve): void => {
const promises: Array<Promise<void>> = [];
const dirReader = entry.createReader();
// According to the spec, readEntries() must be called until it calls
// the callback with an empty array.
const readEntries = (): void => {
dirReader.readEntries((entries) => {
if (entries.length === 0) {
Promise.all(promises).then(resolve).catch((error) => {
this.notificationService.error(error);
resolve();
});
} else {
promises.push(...entries.map((c) => this.traverseItem(c, path)));
readEntries();
}
});
};
readEntries();
});
}
/**
* Add a file to the queue.
*/
private addFile(path: string, file: File): void {
this.total += file.size;
this.fileQueue.set(path, file);
}
}
class Reader implements VSBufferReadableStream {
private _offset = 0;
private readonly size = 32000; // ~32kb max while reading in the file.
private _aborted = false;
private readonly reader = new FileReader();
private paused = true;
private buffer?: VSBuffer;
private callbacks = new Map<string, Array<(...args: any[]) => void>>();
public constructor(private readonly file: File) {
this.reader.addEventListener("load", this.onLoad);
}
public get offset(): number { return this._offset; }
public get aborted(): boolean { return this._aborted; }
public on(event: "data" | "error" | "end", callback: (...args:any[]) => void): void {
if (!this.callbacks.has(event)) {
this.callbacks.set(event, []);
}
this.callbacks.get(event)!.push(callback);
if (this.aborted) {
return this.emit("error", new Error("stream has been aborted"));
} else if (this.done) {
return this.emit("error", new Error("stream has ended"));
} else if (event === "end") { // Once this is being listened to we can safely start outputting data.
this.resume();
}
}
public abort = (): void => {
this._aborted = true;
this.reader.abort();
this.reader.removeEventListener("load", this.onLoad);
this.emit("end");
}
public pause(): void {
this.paused = true;
}
public resume(): void {
if (this.paused) {
this.paused = false;
this.readNextChunk();
}
}
public destroy(): void {
this.abort();
}
private onLoad = (): void => {
this.buffer = VSBuffer.wrap(new Uint8Array(this.reader.result as ArrayBuffer));
if (!this.paused) {
this.readNextChunk();
}
}
private readNextChunk(): void {
if (this.buffer) {
this._offset += this.buffer.byteLength;
this.emit("data", this.buffer);
this.buffer = undefined;
}
if (!this.paused) { // Could be paused during the data event.
if (this.done) {
this.emit("end");
} else {
this.reader.readAsArrayBuffer(this.file.slice(this.offset, this.offset + this.size));
}
}
}
private emit(event: "data" | "error" | "end", ...args: any[]): void {
if (this.callbacks.has(event)) {
this.callbacks.get(event)!.forEach((cb) => cb(...args));
}
}
private get done(): boolean {
return this.offset >= this.file.size;
}
}