not finished
This commit is contained in:
94
packages/ide/src/client.ts
Normal file
94
packages/ide/src/client.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { field, logger, time, Time } from "@coder/logger";
|
||||
import { escapePath } from "@coder/node-browser";
|
||||
|
||||
export interface IClientOptions {
|
||||
mkDirs?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Client represents a general abstraction of an IDE client.
|
||||
*
|
||||
* Everything the client provides is asynchronous so you can wait on what
|
||||
* you need from it without blocking anything else.
|
||||
*
|
||||
* It also provides task management to help asynchronously load and time
|
||||
* external code.
|
||||
*/
|
||||
export class Client {
|
||||
|
||||
public readonly mkDirs: Promise<void>;
|
||||
private start: Time | undefined;
|
||||
private readonly progressElement: HTMLElement | undefined;
|
||||
private tasks: string[];
|
||||
private finishedTaskCount: number;
|
||||
|
||||
public constructor(options: IClientOptions) {
|
||||
this.tasks = [];
|
||||
this.finishedTaskCount = 0;
|
||||
this.progressElement = typeof document !== "undefined"
|
||||
? document.querySelector("#status > #progress > #fill") as HTMLElement
|
||||
: undefined;
|
||||
|
||||
this.mkDirs = this.wrapTask("Creating directories", 100, async () => {
|
||||
if (options.mkDirs && options.mkDirs.length > 0) {
|
||||
await promisify(exec)(`mkdir -p ${options.mkDirs.map(escapePath).join(" ")}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a task in some logging, timing, and progress updates. Can optionally
|
||||
* wait on other tasks which won't count towards this task's time.
|
||||
*/
|
||||
public async wrapTask<T>(description: string, duration: number, task: () => Promise<T>): Promise<T>;
|
||||
public async wrapTask<T, V>(description: string, duration: number, task: (v: V) => Promise<T>, t: Promise<V>): Promise<T>;
|
||||
public async wrapTask<T, V1, V2>(description: string, duration: number, task: (v1: V1, v2: V2) => Promise<T>, t1: Promise<V1>, t2: Promise<V2>): Promise<T>;
|
||||
public async wrapTask<T, V1, V2, V3>(description: string, duration: number, task: (v1: V1, v2: V2, v3: V3) => Promise<T>, t1: Promise<V1>, t2: Promise<V2>, t3: Promise<V3>): Promise<T>;
|
||||
public async wrapTask<T, V1, V2, V3, V4>(description: string, duration: number, task: (v1: V1, v2: V2, v3: V3, v4: V4) => Promise<T>, t1: Promise<V1>, t2: Promise<V2>, t3: Promise<V3>, t4: Promise<V4>): Promise<T>;
|
||||
public async wrapTask<T, V1, V2, V3, V4, V5>(description: string, duration: number, task: (v1: V1, v2: V2, v3: V3, v4: V4, v5: V5) => Promise<T>, t1: Promise<V1>, t2: Promise<V2>, t3: Promise<V3>, t4: Promise<V4>, t5: Promise<V5>): Promise<T>;
|
||||
public async wrapTask<T, V1, V2, V3, V4, V5, V6>(description: string, duration: number, task: (v1: V1, v2: V2, v3: V3, v4: V4, v5: V5, v6: V6) => Promise<T>, t1: Promise<V1>, t2: Promise<V2>, t3: Promise<V3>, t4: Promise<V4>, t5: Promise<V5>, t6: Promise<V6>): Promise<T>;
|
||||
public async wrapTask<T>(
|
||||
description: string, duration: number = 100, task: (...args: any[]) => Promise<T>, ...after: Array<Promise<any>> // tslint:disable-line no-any
|
||||
): Promise<T> {
|
||||
this.tasks.push(description);
|
||||
if (!this.start) {
|
||||
this.start = time(1000);
|
||||
}
|
||||
const updateProgress = (): void => {
|
||||
if (this.progressElement) {
|
||||
this.progressElement.style.width = `${Math.round((this.finishedTaskCount / (this.tasks.length + this.finishedTaskCount)) * 100)}%`;
|
||||
}
|
||||
};
|
||||
updateProgress();
|
||||
|
||||
let start: Time | undefined;
|
||||
try {
|
||||
const waitFor = await (after && after.length > 0 ? Promise.all(after) : Promise.resolve([]));
|
||||
start = time(duration);
|
||||
logger.info(description);
|
||||
const value = await task(...waitFor);
|
||||
logger.info(`Finished "${description}"`, field("duration", start));
|
||||
const index = this.tasks.indexOf(description);
|
||||
if (index !== -1) {
|
||||
this.tasks.splice(index, 1);
|
||||
}
|
||||
++this.finishedTaskCount;
|
||||
updateProgress();
|
||||
if (this.tasks.length === 0) {
|
||||
logger.info("Finished all queued tasks", field("duration", this.start), field("count", this.finishedTaskCount));
|
||||
this.start = undefined;
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch (error) {
|
||||
logger.error(`Failed "${description}"`, field("duration", typeof start !== "undefined" ? start : "not started"), field("error", error));
|
||||
if (this.progressElement) {
|
||||
this.progressElement.style.backgroundColor = "red";
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
4
packages/ide/src/index.ts
Normal file
4
packages/ide/src/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from "./client";
|
||||
export * from "./retry";
|
||||
export * from "./upload";
|
||||
export * from "./uri";
|
344
packages/ide/src/retry.ts
Normal file
344
packages/ide/src/retry.ts
Normal file
@ -0,0 +1,344 @@
|
||||
import { logger } from "@coder/logger";
|
||||
|
||||
/**
|
||||
* Handle for a notification that allows it to be closed and updated.
|
||||
*/
|
||||
export interface INotificationHandle {
|
||||
|
||||
/**
|
||||
* Closes the notification.
|
||||
*/
|
||||
close(): void;
|
||||
|
||||
/**
|
||||
* Update the message.
|
||||
*/
|
||||
updateMessage(message: string): void;
|
||||
|
||||
/**
|
||||
* Update the buttons.
|
||||
*/
|
||||
updateButtons(buttons: INotificationButton[]): void;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification severity.
|
||||
*/
|
||||
enum Severity {
|
||||
Ignore = 0,
|
||||
Info = 1,
|
||||
Warning = 2,
|
||||
Error = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
* Notification button.
|
||||
*/
|
||||
export interface INotificationButton {
|
||||
label: string;
|
||||
run(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional notification service.
|
||||
*/
|
||||
export interface INotificationService {
|
||||
|
||||
/**
|
||||
* Show a notification.
|
||||
*/
|
||||
prompt(severity: Severity, message: string, buttons: INotificationButton[], onCancel: () => void): INotificationHandle;
|
||||
|
||||
}
|
||||
|
||||
interface IRetryItem {
|
||||
count?: number;
|
||||
delay?: number; // In seconds.
|
||||
end?: number; // In ms.
|
||||
fn(): any | Promise<any>; // tslint:disable-line no-any can have different return values
|
||||
timeout?: number | NodeJS.Timer;
|
||||
running?: boolean;
|
||||
showInNotification: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry services. Handles multiple services so when a connection drops the
|
||||
* user doesn't get a separate notification for each service.
|
||||
*
|
||||
* Attempts to restart services silently up to a maximum number of tries, then
|
||||
* starts waiting for a delay that grows exponentially with each attempt with a
|
||||
* cap on the delay. Once the delay is long enough, it will show a notification
|
||||
* to the user explaining what is happening with an option to immediately retry.
|
||||
*/
|
||||
export class Retry {
|
||||
|
||||
private items: Map<string, IRetryItem>;
|
||||
|
||||
// Times are in seconds.
|
||||
private readonly retryMinDelay = 1;
|
||||
private readonly retryMaxDelay = 10;
|
||||
private readonly maxImmediateRetries = 5;
|
||||
private readonly retryExponent = 1.5;
|
||||
private blocked: string | boolean | undefined;
|
||||
|
||||
private notificationHandle: INotificationHandle | undefined;
|
||||
private updateDelay = 1;
|
||||
private updateTimeout: number | NodeJS.Timer | undefined;
|
||||
private notificationThreshold = 3;
|
||||
|
||||
// Time in milliseconds to wait before restarting a service. (See usage below
|
||||
// for reasoning.)
|
||||
private waitDelay = 50;
|
||||
|
||||
public constructor(private notificationService?: INotificationService) {
|
||||
this.items = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set notification service.
|
||||
*/
|
||||
public setNotificationService(notificationService?: INotificationService): void {
|
||||
this.notificationService = notificationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Block retries when we know they will fail (for example when starting Wush
|
||||
* back up). If a name is passed, that service will still be allowed to retry
|
||||
* (unless we have already blocked).
|
||||
*
|
||||
* Blocking without a name will override a block with a name.
|
||||
*/
|
||||
public block(name?: string): void {
|
||||
if (!this.blocked || !name) {
|
||||
this.blocked = name || true;
|
||||
this.items.forEach((item) => {
|
||||
this.stopItem(item);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock retries and run any that are pending.
|
||||
*/
|
||||
public unblock(): void {
|
||||
this.blocked = false;
|
||||
this.items.forEach((item, name) => {
|
||||
if (item.running) {
|
||||
this.runItem(name, item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a function to retry that starts/connects to a service.
|
||||
*
|
||||
* If the function returns a promise, it will automatically be retried,
|
||||
* recover, & unblock after calling `run` once (otherwise they need to be
|
||||
* called manually).
|
||||
*/
|
||||
// tslint:disable-next-line no-any can have different return values
|
||||
public register(name: string, fn: () => any | Promise<any>, showInNotification: boolean = true): void {
|
||||
if (this.items.has(name)) {
|
||||
throw new Error(`"${name}" is already registered`);
|
||||
}
|
||||
this.items.set(name, { fn, showInNotification });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a function to retry.
|
||||
*/
|
||||
public unregister(name: string): void {
|
||||
if (!this.items.has(name)) {
|
||||
throw new Error(`"${name}" is not registered`);
|
||||
}
|
||||
this.items.delete(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry a service.
|
||||
*/
|
||||
public run(name: string): void {
|
||||
if (!this.items.has(name)) {
|
||||
throw new Error(`"${name}" is not registered`);
|
||||
}
|
||||
|
||||
const item = this.items.get(name)!;
|
||||
if (item.running) {
|
||||
throw new Error(`"${name}" is already retrying`);
|
||||
}
|
||||
|
||||
item.running = true;
|
||||
// This timeout is for the case when the connection drops; this allows time
|
||||
// for the Wush service to come in and block everything because some other
|
||||
// services might make it here first and try to restart, which will fail.
|
||||
setTimeout(() => {
|
||||
if (this.blocked && this.blocked !== name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!item.count || item.count < this.maxImmediateRetries) {
|
||||
return this.runItem(name, item);
|
||||
}
|
||||
|
||||
if (!item.delay) {
|
||||
item.delay = this.retryMinDelay;
|
||||
} else {
|
||||
item.delay = Math.ceil(item.delay * this.retryExponent);
|
||||
if (item.delay > this.retryMaxDelay) {
|
||||
item.delay = this.retryMaxDelay;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Retrying ${name.toLowerCase()} in ${item.delay}s`);
|
||||
const itemDelayMs = item.delay * 1000;
|
||||
item.end = Date.now() + itemDelayMs;
|
||||
item.timeout = setTimeout(() => this.runItem(name, item), itemDelayMs);
|
||||
|
||||
this.updateNotification();
|
||||
}, this.waitDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a service after a successfully recovering.
|
||||
*/
|
||||
public recover(name: string): void {
|
||||
if (!this.items.has(name)) {
|
||||
throw new Error(`"${name}" is not registered`);
|
||||
}
|
||||
|
||||
const item = this.items.get(name)!;
|
||||
if (typeof item.timeout === "undefined" && !item.running && typeof item.count !== "undefined") {
|
||||
logger.info(`Recovered connection to ${name.toLowerCase()}`);
|
||||
item.delay = undefined;
|
||||
item.count = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an item.
|
||||
*/
|
||||
private runItem(name: string, item: IRetryItem): void {
|
||||
if (!item.count) {
|
||||
item.count = 1;
|
||||
} else {
|
||||
++item.count;
|
||||
}
|
||||
|
||||
const retryCountText = item.count <= this.maxImmediateRetries
|
||||
? `[${item.count}/${this.maxImmediateRetries}]`
|
||||
: `[${item.count}]`;
|
||||
logger.info(`Retrying ${name.toLowerCase()} ${retryCountText}...`);
|
||||
|
||||
const endItem = (): void => {
|
||||
this.stopItem(item);
|
||||
item.running = false;
|
||||
};
|
||||
|
||||
try {
|
||||
const maybePromise = item.fn();
|
||||
if (maybePromise instanceof Promise) {
|
||||
maybePromise.then(() => {
|
||||
endItem();
|
||||
this.recover(name);
|
||||
if (this.blocked === name) {
|
||||
this.unblock();
|
||||
}
|
||||
}).catch(() => {
|
||||
endItem();
|
||||
this.run(name);
|
||||
});
|
||||
} else {
|
||||
endItem();
|
||||
}
|
||||
} catch (error) {
|
||||
// Prevent an exception from causing the item to never run again.
|
||||
endItem();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update, close, or show the notification.
|
||||
*/
|
||||
private updateNotification(): void {
|
||||
if (!this.notificationService) {
|
||||
return;
|
||||
}
|
||||
|
||||
// tslint:disable-next-line no-any because NodeJS.Timer is valid.
|
||||
clearTimeout(this.updateTimeout as any);
|
||||
|
||||
const now = Date.now();
|
||||
const items = Array.from(this.items.entries()).filter(([_, item]) => {
|
||||
return item.showInNotification
|
||||
&& typeof item.end !== "undefined"
|
||||
&& item.end > now
|
||||
&& item.delay && item.delay >= this.notificationThreshold;
|
||||
}).sort((a, b) => {
|
||||
return a[1] < b[1] ? -1 : 1;
|
||||
});
|
||||
|
||||
if (items.length === 0) {
|
||||
if (this.notificationHandle) {
|
||||
this.notificationHandle.close();
|
||||
this.notificationHandle = undefined;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const join = (arr: string[]): string => {
|
||||
const last = arr.pop()!; // Assume length > 0.
|
||||
|
||||
return arr.length > 0 ? `${arr.join(", ")} and ${last}` : last;
|
||||
};
|
||||
|
||||
const servicesStr = join(items.map(([name, _]) => name.toLowerCase()));
|
||||
const message = `Lost connection to ${servicesStr}. Retrying in ${
|
||||
join(items.map(([_, item]) => `${Math.ceil((item.end! - now) / 1000)}s`))
|
||||
}.`;
|
||||
|
||||
const buttons = [{
|
||||
label: `Retry ${items.length > 1 ? "Services" : items[0][0]} Now`,
|
||||
run: (): void => {
|
||||
logger.info(`Forcing ${servicesStr} to restart now`);
|
||||
items.forEach(([name, item]) => {
|
||||
this.runItem(name, item);
|
||||
});
|
||||
this.updateNotification();
|
||||
},
|
||||
}];
|
||||
|
||||
if (!this.notificationHandle) {
|
||||
this.notificationHandle = this.notificationService.prompt(
|
||||
Severity.Info,
|
||||
message,
|
||||
buttons,
|
||||
() => {
|
||||
this.notificationHandle = undefined;
|
||||
// tslint:disable-next-line no-any because NodeJS.Timer is valid.
|
||||
clearTimeout(this.updateTimeout as any);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
this.notificationHandle.updateMessage(message);
|
||||
this.notificationHandle.updateButtons(buttons);
|
||||
}
|
||||
|
||||
this.updateTimeout = setTimeout(() => this.updateNotification(), this.updateDelay * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop an item's timer.
|
||||
*/
|
||||
private stopItem(item: IRetryItem): void {
|
||||
// tslint:disable-next-line no-any because NodeJS.Timer is valid.
|
||||
clearTimeout(item.timeout as any);
|
||||
item.timeout = undefined;
|
||||
item.end = undefined;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const retry = new Retry();
|
367
packages/ide/src/upload.ts
Normal file
367
packages/ide/src/upload.ts
Normal file
@ -0,0 +1,367 @@
|
||||
import { exec } from "child_process";
|
||||
import { appendFile } from "fs";
|
||||
import { promisify } from "util";
|
||||
import { logger, Logger } from "@coder/logger";
|
||||
import { escapePath } from "@coder/node-browser";
|
||||
import { IURI } from "./uri";
|
||||
|
||||
/**
|
||||
* Represents an uploadable directory, so we can query for existing files once.
|
||||
*/
|
||||
interface IUploadableDirectory {
|
||||
existingFiles: string[];
|
||||
filesToUpload: Map<string, File>;
|
||||
preparePromise?: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updatable progress.
|
||||
*/
|
||||
interface IProgress {
|
||||
|
||||
/**
|
||||
* Report progress. Progress is the completed percentage from 0 to 100.
|
||||
*/
|
||||
report(progress: number): void;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for reporting progress.
|
||||
*/
|
||||
interface IProgressService {
|
||||
|
||||
/**
|
||||
* Start a new progress bar that resolves & disappears when the task finishes.
|
||||
*/
|
||||
start<T>(title:string, task: (progress: IProgress) => Promise<T>): Promise<T>;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for notifications.
|
||||
*/
|
||||
interface INotificationService {
|
||||
|
||||
/**
|
||||
* Display an error message.
|
||||
*/
|
||||
error(error: Error): void;
|
||||
|
||||
/**
|
||||
* Ask for a decision.
|
||||
*/
|
||||
prompt(message: string, choices: string[]): Promise<string>;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles file uploads.
|
||||
*/
|
||||
export class Upload {
|
||||
|
||||
private readonly maxParallelUploads = 100;
|
||||
private readonly readSize = 32000; // ~32kb max while reading in the file.
|
||||
private readonly packetSize = 32000; // ~32kb max when writing.
|
||||
private readonly notificationService: INotificationService;
|
||||
private readonly progressService: IProgressService;
|
||||
private readonly logger: Logger;
|
||||
private readonly currentlyUploadingFiles: Map<string, File>;
|
||||
private readonly queueByDirectory: Map<string, IUploadableDirectory>;
|
||||
private progress: IProgress | undefined;
|
||||
private uploadPromise: Promise<string[]> | undefined;
|
||||
private resolveUploadPromise: (() => void) | undefined;
|
||||
private finished: number;
|
||||
private uploadedFilePaths: string[];
|
||||
private total: number;
|
||||
|
||||
public constructor(notificationService: INotificationService, progressService: IProgressService) {
|
||||
this.notificationService = notificationService;
|
||||
this.progressService = progressService;
|
||||
this.logger = logger.named("Upload");
|
||||
this.currentlyUploadingFiles = new Map();
|
||||
this.queueByDirectory = new Map();
|
||||
this.uploadedFilePaths = [];
|
||||
this.finished = 0;
|
||||
this.total = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: IURI): Promise<string[]> {
|
||||
this.addDirectory(uploadDir.path);
|
||||
await this.queueFiles(event, uploadDir);
|
||||
this.logger.debug( // -1 so we don't include the uploadDir itself.
|
||||
`Uploading ${this.queueByDirectory.size - 1} directories and ${this.total} files`,
|
||||
);
|
||||
await this.prepareDirectories();
|
||||
if (!this.uploadPromise) {
|
||||
this.uploadPromise = this.progressService.start("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.finished = 0;
|
||||
this.total = 0;
|
||||
resolve(uploaded);
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
this.uploadFiles();
|
||||
|
||||
return this.uploadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all file uploads.
|
||||
*/
|
||||
public async cancel(): Promise<void> {
|
||||
this.currentlyUploadingFiles.clear();
|
||||
this.queueByDirectory.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create directories and get existing files.
|
||||
* On failure, show the error and remove the failed directory from the queue.
|
||||
*/
|
||||
private async prepareDirectories(): Promise<void> {
|
||||
await Promise.all(Array.from(this.queueByDirectory).map(([path, dir]) => {
|
||||
if (!dir.preparePromise) {
|
||||
dir.preparePromise = this.prepareDirectory(path, dir);
|
||||
}
|
||||
|
||||
return dir.preparePromise;
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a directory and get existing files.
|
||||
* On failure, show the error and remove the directory from the queue.
|
||||
*/
|
||||
private async prepareDirectory(path: string, dir: IUploadableDirectory): Promise<void> {
|
||||
await Promise.all([
|
||||
promisify(exec)(`mkdir -p ${escapePath(path)}`).catch((error) => {
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes("file exists")) {
|
||||
throw new Error(`Unable to create directory at ${path} because a file exists there`);
|
||||
}
|
||||
throw new Error(error.message || `Unable to upload ${path}`);
|
||||
}),
|
||||
// Only get files, so we don't show an override option that will just
|
||||
// fail anyway.
|
||||
promisify(exec)(`find ${escapePath(path)} -maxdepth 1 -not -type d`).then((stdio) => {
|
||||
dir.existingFiles = stdio.stdout.split("\n");
|
||||
}),
|
||||
]).catch((error) => {
|
||||
this.queueByDirectory.delete(path);
|
||||
this.notificationService.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload as many files as possible. When finished, resolve the upload promise.
|
||||
*/
|
||||
private uploadFiles(): void {
|
||||
const finishFileUpload = (path: string): void => {
|
||||
++this.finished;
|
||||
this.currentlyUploadingFiles.delete(path);
|
||||
this.progress!.report(Math.floor((this.finished / this.total) * 100));
|
||||
this.uploadFiles();
|
||||
};
|
||||
while (this.queueByDirectory.size > 0 && this.currentlyUploadingFiles.size < this.maxParallelUploads) {
|
||||
const [dirPath, dir] = this.queueByDirectory.entries().next().value;
|
||||
if (dir.filesToUpload.size === 0) {
|
||||
this.queueByDirectory.delete(dirPath);
|
||||
continue;
|
||||
}
|
||||
const [filePath, item] = dir.filesToUpload.entries().next().value;
|
||||
this.currentlyUploadingFiles.set(filePath, item);
|
||||
dir.filesToUpload.delete(filePath);
|
||||
this.uploadFile(filePath, item, dir.existingFiles).then(() => {
|
||||
finishFileUpload(filePath);
|
||||
}).catch((error) => {
|
||||
this.notificationService.error(error);
|
||||
finishFileUpload(filePath);
|
||||
});
|
||||
}
|
||||
if (this.queueByDirectory.size === 0 && this.currentlyUploadingFiles.size === 0) {
|
||||
this.resolveUploadPromise!();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file.
|
||||
*/
|
||||
private async uploadFile(path: string, file: File, existingFiles: string[]): Promise<void> {
|
||||
if (existingFiles.includes(path)) {
|
||||
const choice = await this.notificationService.prompt(`${path} already exists. Overwrite?`, ["Yes", "No"]);
|
||||
if (choice !== "Yes") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise(async (resolve, reject): Promise<void> => {
|
||||
let readOffset = 0;
|
||||
const reader = new FileReader();
|
||||
const seek = (): void => {
|
||||
const slice = file.slice(readOffset, readOffset + this.readSize);
|
||||
readOffset += this.readSize;
|
||||
reader.readAsArrayBuffer(slice);
|
||||
};
|
||||
|
||||
const rm = async (): Promise<void> => {
|
||||
await promisify(exec)(`rm -f ${escapePath(path)}`);
|
||||
};
|
||||
|
||||
await rm();
|
||||
|
||||
reader.addEventListener("load", async () => {
|
||||
const buffer = new Uint8Array(reader.result as ArrayBuffer);
|
||||
let bufferOffset = 0;
|
||||
|
||||
while (bufferOffset <= buffer.length) {
|
||||
// Got canceled while sending data.
|
||||
if (!this.currentlyUploadingFiles.has(path)) {
|
||||
await rm();
|
||||
|
||||
return resolve();
|
||||
}
|
||||
const data = buffer.slice(bufferOffset, bufferOffset + this.packetSize);
|
||||
|
||||
try {
|
||||
await promisify(appendFile)(path, data);
|
||||
} catch (error) {
|
||||
await rm();
|
||||
|
||||
const message = error.message.toLowerCase();
|
||||
if (message.includes("no space")) {
|
||||
return reject(new Error("You are out of disk space"));
|
||||
} else if (message.includes("is a directory")) {
|
||||
return reject(new Error(`Unable to upload ${path} because there is a directory there`));
|
||||
}
|
||||
|
||||
return reject(new Error(error.message || `Unable to upload ${path}`));
|
||||
}
|
||||
|
||||
bufferOffset += this.packetSize;
|
||||
}
|
||||
|
||||
if (readOffset >= file.size) {
|
||||
this.uploadedFilePaths.push(path);
|
||||
|
||||
return resolve();
|
||||
}
|
||||
|
||||
seek();
|
||||
});
|
||||
|
||||
seek();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: IURI): Promise<void> {
|
||||
if (!event.dataTransfer || !event.dataTransfer.items) {
|
||||
return;
|
||||
}
|
||||
const promises: Array<Promise<void>> = [];
|
||||
for (let i = 0; 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).catch(this.notificationService.error));
|
||||
} else {
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
this.addFile(uploadDir.fsPath, uploadDir.fsPath + "/" + file.name, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverses an entry and add files to the queue.
|
||||
*/
|
||||
private async traverseItem(entry: IEntry, parentPath: string): Promise<void> {
|
||||
if (entry.isFile) {
|
||||
return new Promise<void>((resolve): void => {
|
||||
entry.file((file) => {
|
||||
this.addFile(
|
||||
parentPath,
|
||||
parentPath + "/" + file.name,
|
||||
file,
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
parentPath += "/" + entry.name;
|
||||
this.addDirectory(parentPath);
|
||||
|
||||
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((child) => this.traverseItem(child, parentPath)));
|
||||
readEntries();
|
||||
}
|
||||
});
|
||||
};
|
||||
readEntries();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a file to the queue.
|
||||
*/
|
||||
private addFile(parentPath: string, path: string, file: File): void {
|
||||
++this.total;
|
||||
this.addDirectory(parentPath);
|
||||
this.queueByDirectory.get(parentPath)!.filesToUpload.set(path, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a directory to the queue.
|
||||
*/
|
||||
private addDirectory(path: string): void {
|
||||
if (!this.queueByDirectory.has(path)) {
|
||||
this.queueByDirectory.set(path, {
|
||||
existingFiles: [],
|
||||
filesToUpload: new Map(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
45
packages/ide/src/uri.ts
Normal file
45
packages/ide/src/uri.ts
Normal file
@ -0,0 +1,45 @@
|
||||
export interface IURI {
|
||||
|
||||
readonly path: string;
|
||||
readonly fsPath: string;
|
||||
readonly scheme: string;
|
||||
|
||||
}
|
||||
|
||||
export interface IURIFactory {
|
||||
|
||||
/**
|
||||
* Convert the object to an instance of a real URI.
|
||||
*/
|
||||
create<T extends IURI>(uri: IURI): T;
|
||||
file(path: string): IURI;
|
||||
parse(raw: string): IURI;
|
||||
|
||||
}
|
||||
|
||||
let activeUriFactory: IURIFactory;
|
||||
|
||||
/**
|
||||
* Get the active URI factory
|
||||
*/
|
||||
export const getFactory = (): IURIFactory => {
|
||||
if (!activeUriFactory) {
|
||||
throw new Error("default uri factory not set");
|
||||
}
|
||||
|
||||
return activeUriFactory;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the active URI factory.
|
||||
*/
|
||||
export const setUriFactory = (factory: IURIFactory): void => {
|
||||
activeUriFactory = factory;
|
||||
};
|
||||
|
||||
export interface IUriSwitcher {
|
||||
|
||||
strip(uri: IURI): IURI;
|
||||
prepend(uri: IURI): IURI;
|
||||
|
||||
}
|
Reference in New Issue
Block a user