diff --git a/package-lock.json b/package-lock.json index 44e5008..033eed5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@tinyhttp/content-disposition": "^2.2.0", "async-retry": "^1.3.3", "chalk": "^5.3.0", + "ci-info": "^4.0.0", "cli-spinners": "^2.9.2", "commander": "^10.0.0", "eventemitter3": "^5.0.1", @@ -3209,6 +3210,20 @@ "node": "*" } }, + "node_modules/ci-info": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz", + "integrity": "sha512-TdHqgGf9odd8SXNuxtUBVx8Nv+qZOejE6qyqiy5NtbYYQOeFa6zmHkxlPzmaLxWWHsU6nJmB7AETdVPi+2NBUg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, "node_modules/clean-stack": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-5.2.0.tgz", diff --git a/package.json b/package.json index e5e0be0..a427316 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "@tinyhttp/content-disposition": "^2.2.0", "async-retry": "^1.3.3", "chalk": "^5.3.0", + "ci-info": "^4.0.0", "cli-spinners": "^2.9.2", "commander": "^10.0.0", "eventemitter3": "^5.0.1", diff --git a/src/cli/cli.ts b/src/cli/cli.ts index db80bfc..71c359e 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -5,6 +5,7 @@ import {packageJson} from "../const.js"; import {downloadFile, downloadSequence} from "../download/node-download.js"; import {setCommand} from "./commands/set.js"; import findDownloadDir, {downloadToDirectory, findFileName} from "./utils/find-download-dir.js"; +import {AvailableCLIProgressStyle} from "../download/transfer-visualize/transfer-cli/progress-bars/switch-cli-progress-style.js"; const pullCommand = new Command(); @@ -13,9 +14,16 @@ pullCommand .argument("[files...]", "Files to pull/copy") .option("-s --save [path]", "Save location (directory/file)") .option("-c --connections [number]", "Number of parallel connections", "4") + .addOption(new Option("-st --style [type]", "The style of the CLI progress bar").choices(["basic", "fancy", "ci", "summary"])) .addOption(new Option("-p --program [type]", "The download strategy").choices(["stream", "chunks"])) .option("-t --truncate-name", "Truncate file names in the CLI status to make them appear shorter") - .action(async (files: string[] = [], {save: saveLocation, truncateName, number, program}: { save?: string, truncateName?: boolean, number: string, program: string }) => { + .action(async (files: string[] = [], {save: saveLocation, truncateName, number, program, style}: { + save?: string, + truncateName?: boolean, + number: string, + program: string, + style: AvailableCLIProgressStyle + }) => { if (files.length === 0) { pullCommand.outputHelp(); process.exit(0); @@ -41,7 +49,7 @@ pullCommand const downloader = await downloadSequence({ truncateName, cliProgress: true, - cliStyle: "fancy" + cliStyle: style }, ...fileDownloads); await downloader.download(); }) diff --git a/src/download/download-engine/download-file/progress-status-file.ts b/src/download/download-engine/download-file/progress-status-file.ts index 63e64f5..eec9011 100644 --- a/src/download/download-engine/download-file/progress-status-file.ts +++ b/src/download/download-engine/download-file/progress-status-file.ts @@ -23,6 +23,7 @@ export enum DownloadStatus { export enum DownloadFlags { Existing = "Existing", + DownloadSequence = "DownloadSequence" } export default class ProgressStatusFile { diff --git a/src/download/download-engine/engine/download-engine-multi-download.ts b/src/download/download-engine/engine/download-engine-multi-download.ts index 7a416bd..be35311 100644 --- a/src/download/download-engine/engine/download-engine-multi-download.ts +++ b/src/download/download-engine/engine/download-engine-multi-download.ts @@ -4,6 +4,7 @@ import ProgressStatisticsBuilder, {ProgressStatusWithIndex} from "../../transfer import BaseDownloadEngine, {BaseDownloadEngineEvents} from "./base-download-engine.js"; import DownloadAlreadyStartedError from "./error/download-already-started-error.js"; import {concurrency} from "./utils/concurrency.js"; +import {DownloadFlags, DownloadStatus} from "../download-file/progress-status-file.js"; const DEFAULT_PARALLEL_DOWNLOADS = 1; @@ -44,6 +45,8 @@ export default class DownloadEngineMultiDownload { + progress.downloadFlags = progress.downloadFlags.concat([DownloadFlags.DownloadSequence]); this.emit("progress", progress); }); } @@ -64,8 +68,10 @@ export default class DownloadEngineMultiDownload { if (this._aborted) return; @@ -78,6 +84,7 @@ export default class DownloadEngineMultiDownload engine.pause()); } public resume(): void { + this._progressStatisticsBuilder.downloadStatus = DownloadStatus.Active; this._activeEngines.forEach(engine => engine.resume()); } @@ -115,11 +124,15 @@ export default class DownloadEngineMultiDownload engine.close()); await Promise.all(closePromises); - this.emit("closed"); + this.emit("closed"); } protected static _extractEngines(engines: Engine[]) { diff --git a/src/download/transfer-visualize/progress-statistics-builder.ts b/src/download/transfer-visualize/progress-statistics-builder.ts index 3d8dee8..d2f2891 100644 --- a/src/download/transfer-visualize/progress-statistics-builder.ts +++ b/src/download/transfer-visualize/progress-statistics-builder.ts @@ -1,9 +1,9 @@ import BaseDownloadEngine from "../download-engine/engine/base-download-engine.js"; import {EventEmitter} from "eventemitter3"; import TransferStatistics from "./transfer-statistics.js"; -import DownloadEngineMultiDownload from "../download-engine/engine/download-engine-multi-download.js"; import {createFormattedStatus, FormattedStatus} from "./format-transfer-status.js"; import DownloadEngineFile from "../download-engine/download-file/download-engine-file.js"; +import {DownloadStatus, ProgressStatus} from "../download-engine/download-file/progress-status-file.js"; export type ProgressStatusWithIndex = FormattedStatus & { index: number, @@ -13,18 +13,23 @@ interface CliProgressBuilderEvents { progress: (progress: ProgressStatusWithIndex) => void; } -export type AnyEngine = DownloadEngineFile | BaseDownloadEngine | DownloadEngineMultiDownload; +export type AnyEngine = DownloadEngineFile | BaseDownloadEngine; export default class ProgressStatisticsBuilder extends EventEmitter { - protected _engines: AnyEngine[] = []; - protected _activeTransfers: { [index: number]: number } = {}; - protected _totalBytes = 0; - protected _transferredBytes = 0; - protected statistics = new TransferStatistics(); + private _engines: AnyEngine[] = []; + private _activeTransfers: { [index: number]: number } = {}; + private _totalBytes = 0; + private _transferredBytes = 0; + private _totalDownloadParts = 0; + private _activeDownloadPart = 0; + private _startTime = 0; + private statistics = new TransferStatistics(); + public downloadStatus: DownloadStatus = null!; public get totalBytes() { return this._totalBytes; } + public get transferredBytesWithActiveTransfers() { return this._transferredBytes + Object.values(this._activeTransfers) .reduce((acc, bytes) => acc + bytes, 0); @@ -36,22 +41,15 @@ export default class ProgressStatisticsBuilder extends EventEmitter { - this._activeTransfers[index] = data.transferredBytes; - const progress = this.statistics.updateProgress(this.transferredBytesWithActiveTransfers, this.totalBytes); - - this.emit("progress", { - ...createFormattedStatus({ - ...data, - ...progress - }), - index - }); + this._sendProgress(data, index, downloadPartStart); }); engine.on("finished", () => { @@ -60,6 +58,34 @@ export default class ProgressStatisticsBuilder extends EventEmitter this._activeDownloadPart) { + this._activeDownloadPart = downloadPartStart + data.downloadPart; + } + + const progress = this.statistics.updateProgress(this.transferredBytesWithActiveTransfers, this.totalBytes); + const activeDownloads = Object.keys(this._activeTransfers).length; + + this.emit("progress", { + ...createFormattedStatus({ + ...progress, + downloadPart: this._activeDownloadPart, + totalDownloadParts: this._totalDownloadParts, + startTime: this._startTime, + fileName: data.fileName, + comment: data.comment, + transferAction: data.transferAction, + downloadStatus: this.downloadStatus || data.downloadStatus, + endTime: activeDownloads <= 1 ? data.endTime : 0, + downloadFlags: data.downloadFlags + }), + index + }); + } + static oneStatistics(engine: DownloadEngineFile) { const progress = engine.status; const statistics = TransferStatistics.oneStatistics(progress.transferredBytes, progress.totalBytes); diff --git a/src/download/transfer-visualize/transfer-cli/cli-animation-wrapper.ts b/src/download/transfer-visualize/transfer-cli/cli-animation-wrapper.ts index 16ca15b..4b7d5ac 100644 --- a/src/download/transfer-visualize/transfer-cli/cli-animation-wrapper.ts +++ b/src/download/transfer-visualize/transfer-cli/cli-animation-wrapper.ts @@ -3,9 +3,9 @@ import DownloadEngineMultiDownload from "../../download-engine/engine/download-e import switchCliProgressStyle, {AvailableCLIProgressStyle} from "./progress-bars/switch-cli-progress-style.js"; import {CliFormattedStatus} from "./progress-bars/base-transfer-cli-progress-bar.js"; import TransferCli, {CLI_LEVEL, TransferCliOptions} from "./transfer-cli.js"; -import {BaseMultiProgressBar} from "./multiProgressBars/baseMultiProgressBar.js"; +import {BaseMultiProgressBar} from "./multiProgressBars/BaseMultiProgressBar.js"; -const DEFAULT_CLI_STYLE: AvailableCLIProgressStyle = "fancy"; +const DEFAULT_CLI_STYLE: AvailableCLIProgressStyle = "auto"; type AllowedDownloadEngines = DownloadEngineNodejs | DownloadEngineMultiDownload; export type CliProgressDownloadEngineOptions = { @@ -47,7 +47,10 @@ export default class CliAnimationWrapper { } cliOptions.createProgressBar = typeof this._options.cliStyle === "function" ? - this._options.cliStyle : + { + createStatusLine: this._options.cliStyle, + multiProgressBar: this._options.createMultiProgressBar ?? BaseMultiProgressBar + } : switchCliProgressStyle(this._options.cliStyle ?? DEFAULT_CLI_STYLE, {truncateName: this._options.truncateName}); this._activeCLI = new TransferCli(cliOptions, this._options.cliLevel); @@ -64,8 +67,8 @@ export default class CliAnimationWrapper { engine.once("start", () => { this._activeCLI?.start(); - engine.on("progress", () => { - this._activeCLI?.updateStatues(engine.downloadStatues); + engine.on("progress", (progress) => { + this._activeCLI?.updateStatues(engine.downloadStatues, progress); }); engine.on("closed", () => { diff --git a/src/download/transfer-visualize/transfer-cli/loading-animation/base-loading-animation.ts b/src/download/transfer-visualize/transfer-cli/loading-animation/base-loading-animation.ts index 8ecdcff..610fec6 100644 --- a/src/download/transfer-visualize/transfer-cli/loading-animation/base-loading-animation.ts +++ b/src/download/transfer-visualize/transfer-cli/loading-animation/base-loading-animation.ts @@ -1,13 +1,16 @@ import UpdateManager from "stdout-update"; import sleep from "sleep-promise"; +import {CLIProgressPrintType} from "../multiProgressBars/BaseMultiProgressBar.js"; export type BaseLoadingAnimationOptions = { - updateIntervalMs?: number; + updateIntervalMs?: number | null; loadingText?: string; + logType: CLIProgressPrintType }; export const DEFAULT_LOADING_ANIMATION_OPTIONS: BaseLoadingAnimationOptions = { - loadingText: "Gathering information" + loadingText: "Gathering information", + logType: "update" }; const DEFAULT_UPDATE_INTERVAL_MS = 300; @@ -24,7 +27,13 @@ export default abstract class BaseLoadingAnimation { } protected _render(): void { - this.stdoutManager.update([this.createFrame()]); + const frame = this.createFrame(); + + if (this.options.logType === "update") { + this.stdoutManager.update([frame]); + } else { + console.log(frame); + } } protected abstract createFrame(): string; @@ -32,7 +41,9 @@ export default abstract class BaseLoadingAnimation { async start() { process.on("SIGINT", this._processExit); - this.stdoutManager.hook(); + if (this.options.logType === "update") { + this.stdoutManager.hook(); + } this._animationActive = true; while (this._animationActive) { @@ -47,8 +58,11 @@ export default abstract class BaseLoadingAnimation { } this._animationActive = false; - this.stdoutManager.erase(); - this.stdoutManager.unhook(false); + + if (this.options.logType === "update") { + this.stdoutManager.erase(); + this.stdoutManager.unhook(false); + } process.off("SIGINT", this._processExit); } diff --git a/src/download/transfer-visualize/transfer-cli/multiProgressBars/baseMultiProgressBar.ts b/src/download/transfer-visualize/transfer-cli/multiProgressBars/BaseMultiProgressBar.ts similarity index 81% rename from src/download/transfer-visualize/transfer-cli/multiProgressBars/baseMultiProgressBar.ts rename to src/download/transfer-visualize/transfer-cli/multiProgressBars/BaseMultiProgressBar.ts index edf9438..435dffd 100644 --- a/src/download/transfer-visualize/transfer-cli/multiProgressBars/baseMultiProgressBar.ts +++ b/src/download/transfer-visualize/transfer-cli/multiProgressBars/BaseMultiProgressBar.ts @@ -1,4 +1,4 @@ -import {CliFormattedStatus} from "../progress-bars/base-transfer-cli-progress-bar.js"; +import {TransferCliProgressBar} from "../progress-bars/base-transfer-cli-progress-bar.js"; import {FormattedStatus} from "../../format-transfer-status.js"; import {DownloadStatus} from "../../../download-engine/download-file/progress-status-file.js"; import chalk from "chalk"; @@ -6,18 +6,23 @@ import prettyBytes from "pretty-bytes"; export type MultiProgressBarOptions = { maxViewDownloads: number; - createProgressBar: (status: CliFormattedStatus) => string + createProgressBar: TransferCliProgressBar action?: string; }; +export type CLIProgressPrintType = "update" | "log"; + export class BaseMultiProgressBar { + public readonly updateIntervalMs: null | number = null; + public readonly printType: CLIProgressPrintType = "update"; + public constructor(protected options: MultiProgressBarOptions) { } protected createProgresses(statuses: FormattedStatus[]): string { return statuses.map((status) => { status.transferAction = this.options.action ?? status.transferAction; - return this.options.createProgressBar(status); + return this.options.createProgressBar.createStatusLine(status); }) .join("\n"); } @@ -41,7 +46,8 @@ export class BaseMultiProgressBar { }; } - createMultiProgressBar(statuses: FormattedStatus[]) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + createMultiProgressBar(statuses: FormattedStatus[], oneStatus: FormattedStatus) { if (statuses.length < this.options.maxViewDownloads) { return this.createProgresses(statuses); } diff --git a/src/download/transfer-visualize/transfer-cli/multiProgressBars/CIMultiProgressBar.ts b/src/download/transfer-visualize/transfer-cli/multiProgressBars/CIMultiProgressBar.ts new file mode 100644 index 0000000..1b35d60 --- /dev/null +++ b/src/download/transfer-visualize/transfer-cli/multiProgressBars/CIMultiProgressBar.ts @@ -0,0 +1,6 @@ +import {SummaryMultiProgressBar} from "./SummaryMultiProgressBar.js"; + +export class CIMultiProgressBar extends SummaryMultiProgressBar { + public override readonly printType = "log"; + public override readonly updateIntervalMs = parseInt(process.env.IPULL_CI_UPDATE_INTERVAL ?? "0") || 8_000; +} diff --git a/src/download/transfer-visualize/transfer-cli/multiProgressBars/SummaryMultiProgressBar.ts b/src/download/transfer-visualize/transfer-cli/multiProgressBars/SummaryMultiProgressBar.ts new file mode 100644 index 0000000..db5bb3c --- /dev/null +++ b/src/download/transfer-visualize/transfer-cli/multiProgressBars/SummaryMultiProgressBar.ts @@ -0,0 +1,40 @@ +import {BaseMultiProgressBar, CLIProgressPrintType} from "./BaseMultiProgressBar.js"; +import {FormattedStatus} from "../../format-transfer-status.js"; +import {DownloadStatus} from "../../../download-engine/download-file/progress-status-file.js"; + +export class SummaryMultiProgressBar extends BaseMultiProgressBar { + public override readonly printType: CLIProgressPrintType = "update"; + public override readonly updateIntervalMs: number = 0; + private _parallelDownloads = 0; + private _lastStatuses: FormattedStatus[] = []; + + override createMultiProgressBar(statuses: FormattedStatus[], oneStatus: FormattedStatus) { + const linesToPrint: FormattedStatus[] = []; + + let index = 0; + for (const status of statuses) { + const isStatusChanged = this._lastStatuses[index++]?.downloadStatus !== status.downloadStatus; + const copyStatus = structuredClone(status); + + if (isStatusChanged) { + linesToPrint.push(copyStatus); + } + } + + if (this.printType === "log") { + this._lastStatuses = structuredClone(statuses); + } + + const {allStatusesSorted} = this.recorderStatusByImportance(linesToPrint); + const filterStatusesSliced = allStatusesSorted.slice(0, this.options.maxViewDownloads); + + filterStatusesSliced.push(oneStatus); + + const activeDownloads = statuses.filter((status) => status.downloadStatus === DownloadStatus.Active).length; + this._parallelDownloads ||= activeDownloads; + const finishedDownloads = statuses.filter((status) => status.downloadStatus === DownloadStatus.Finished).length; + oneStatus.comment = `${finishedDownloads}/${statuses.length} files done${this._parallelDownloads > 1 ? ` (${activeDownloads} active)` : ""}`; + + return this.createProgresses(filterStatusesSliced); + } +} diff --git a/src/download/transfer-visualize/transfer-cli/progress-bars/base-transfer-cli-progress-bar.ts b/src/download/transfer-visualize/transfer-cli/progress-bars/base-transfer-cli-progress-bar.ts index 1bb5280..10400e3 100644 --- a/src/download/transfer-visualize/transfer-cli/progress-bars/base-transfer-cli-progress-bar.ts +++ b/src/download/transfer-visualize/transfer-cli/progress-bars/base-transfer-cli-progress-bar.ts @@ -1,8 +1,15 @@ import chalk from "chalk"; -import {centerPad, TRUNCATE_TEXT_MAX_LENGTH, truncateText} from "../../utils/cli-text.js"; +import {truncateText} from "../../utils/cli-text.js"; import {clamp} from "../../utils/numbers.js"; import {FormattedStatus} from "../../format-transfer-status.js"; import {DownloadStatus} from "../../../download-engine/download-file/progress-status-file.js"; +import {BaseMultiProgressBar} from "../multiProgressBars/BaseMultiProgressBar.js"; +import {STATUS_ICONS} from "../../utils/progressBarIcons.js"; +import {DataLine, DataPart, renderDataLine} from "../../utils/data-line.js"; + +const SKIP_ETA_START_TIME = 1000 * 2; +const MIN_NAME_LENGTH = 20; +const MIN_COMMENT_LENGTH = 15; export type CliFormattedStatus = FormattedStatus & { transferAction: string @@ -12,18 +19,102 @@ export type BaseCliOptions = { truncateName?: boolean | number }; +export interface TransferCliProgressBar { + multiProgressBar: typeof BaseMultiProgressBar; + + createStatusLine(status: CliFormattedStatus): string; +} + /** * A class to display transfer progress in the terminal, with a progress bar and other information. */ -export default class BaseTransferCliProgressBar { - protected status: CliFormattedStatus; +export default class BaseTransferCliProgressBar implements TransferCliProgressBar { + public multiProgressBar = BaseMultiProgressBar; + protected status: CliFormattedStatus = null!; protected options: BaseCliOptions; - protected constructor(status: CliFormattedStatus, options: BaseCliOptions) { - this.status = status; + + public constructor(options: BaseCliOptions) { this.options = options; } + protected get showETA(): boolean { + return this.status.startTime < Date.now() - SKIP_ETA_START_TIME; + } + + protected getNameAndCommentDataParts(): DataPart[] { + const {fileName, comment, downloadStatus} = this.status; + + let fullComment = comment; + if (downloadStatus === DownloadStatus.Cancelled || downloadStatus === DownloadStatus.Paused) { + if (fullComment) { + fullComment += " | " + downloadStatus; + } else { + fullComment = downloadStatus; + } + } + + return [{ + type: "name", + fullText: fileName, + size: this.options.truncateName === false + ? fileName.length + : typeof this.options.truncateName === "number" + ? this.options.truncateName + : Math.min(fileName.length, MIN_NAME_LENGTH), + flex: typeof this.options.truncateName === "number" + ? undefined + : 1, + maxSize: fileName.length, + cropper: truncateText, + formatter: (text) => chalk.bold(text) + }, ...( + (fullComment == null || fullComment.length === 0) + ? [] + : [{ + type: "spacer", + fullText: " (", + size: " (".length, + formatter: (text) => chalk.dim(text) + }, { + type: "nameComment", + fullText: fullComment, + size: Math.min(fullComment.length, MIN_COMMENT_LENGTH), + maxSize: fullComment.length, + flex: 1, + cropper: truncateText, + formatter: (text) => chalk.dim(text) + }, { + type: "spacer", + fullText: ")", + size: ")".length, + formatter: (text) => chalk.dim(text) + }] satisfies DataPart[] + )]; + } + + + protected getETA(spacer = " | "): DataLine { + if (this.showETA) { + return [{ + type: "spacer", + fullText: spacer, + size: spacer.length, + formatter: (text) => text + }, { + type: "timeLeft", + fullText: this.status.formatTimeLeft, + size: Math.max("100ms".length, this.status.formatTimeLeft.length) + }, { + type: "timeLeft", + fullText: " left", + size: " left".length + }]; + } + + return []; + } + protected createProgressBarLine(length: number) { const percentage = clamp(this.status.transferredBytes / this.status.totalBytes, 0, 1); const fullLength = Math.floor(percentage * length); @@ -32,50 +123,139 @@ export default class BaseTransferCliProgressBar { return `${"=".repeat(fullLength)}>${" ".repeat(emptyLength)}`; } - protected createProgressBarFormat(): string { - const {formattedComment, formattedSpeed, formatTransferredOfTotal, formatTimeLeft, formattedPercentage} = this.status; + protected renderProgressLine(): string { + const {formattedPercentage, formattedSpeed, formatTransferredOfTotal, formatTotal} = this.status; - return `${chalk.cyan(this.status.transferAction)} ${this.getFileName()} ${chalk.dim(formattedComment)} -${chalk.green(formattedPercentage.padEnd(7)) - .padStart(6)} [${chalk.cyan(this.createProgressBarLine(50))}] ${centerPad(formatTransferredOfTotal, 18)} ${centerPad(formattedSpeed, 10)} ${centerPad(formatTimeLeft, 5)} left`; + return renderDataLine([ + { + type: "status", + fullText: this.status.transferAction, + size: this.status.transferAction.length, + formatter: (text) => chalk.cyan(text) + }, + { + type: "spacer", + fullText: " ", + size: " ".length, + formatter: (text) => text + }, + ...this.getNameAndCommentDataParts(), + { + type: "spacer", + fullText: "\n", + size: 1, + formatter: (text) => text + }, + { + type: "percentage", + fullText: formattedPercentage, + size: "100.00%".length, + formatter: () => chalk.green(formattedPercentage) + }, + { + type: "spacer", + fullText: " ", + size: " ".length, + formatter: (text) => text + }, + { + type: "progressBar", + size: "[=====>]".length, + fullText: this.createProgressBarLine(10), + flex: 4, + addEndPadding: 4, + maxSize: 40, + formatter: (_, size) => { + return `[${chalk.cyan(this.createProgressBarLine(size))}]`; + } + }, + { + type: "spacer", + fullText: " ", + size: " ".length, + formatter: (text) => text + }, + { + type: "transferred", + fullText: formatTransferredOfTotal, + size: `1024.00MB/${formatTotal}`.length + }, + { + type: "spacer", + fullText: " | ", + size: " | ".length, + formatter: (text) => text + }, + { + type: "speed", + fullText: formattedSpeed, + size: "000.00kB/s".length + }, + ...this.getETA() + ]); } - protected transferEnded() { - const status = this.status.downloadStatus === DownloadStatus.Finished ? chalk.green("✓") : chalk.red("✗"); - return `${status} ${this.getFileName()} ${this.status.formatTransferred} ${chalk.dim(this.status.formattedComment)}`; - } + protected renderFinishedLine() { + const status = this.status.downloadStatus === DownloadStatus.Finished ? chalk.green(STATUS_ICONS.done) : chalk.red(STATUS_ICONS.failed); - protected transferNotStarted() { - return `⌛ ${this.getFileName()} ${this.status.formatTotal} ${chalk.dim(this.status.formattedComment)}`; + return renderDataLine([ + { + type: "status", + fullText: "", + size: 1, + formatter: () => status + }, + { + type: "spacer", + fullText: " ", + size: " ".length, + formatter: (text) => text + }, + ...this.getNameAndCommentDataParts() + ]); } - protected getFileName() { - const {fileName} = this.status; - - if (this.options.truncateName && fileName) { - const length = typeof this.options.truncateName === "number" - ? this.options.truncateName - : TRUNCATE_TEXT_MAX_LENGTH; - return truncateText(fileName, length); - } - return fileName; + protected renderPendingLine() { + return renderDataLine([ + { + type: "status", + fullText: "", + size: 1, + formatter: () => STATUS_ICONS.pending + }, + { + type: "spacer", + fullText: " ", + size: " ".length, + formatter: (text) => text + }, + ...this.getNameAndCommentDataParts(), + { + type: "spacer", + fullText: " ", + size: " ".length, + formatter: (text) => text + }, + { + type: "description", + fullText: this.status.formatTotal, + size: this.status.formatTotal.length, + formatter: (text) => chalk.dim(text) + } + ]); } - public createStatusLine(): string { + public createStatusLine(status: CliFormattedStatus): string { + this.status = status; + if ([DownloadStatus.Finished, DownloadStatus.Error, DownloadStatus.Cancelled].includes(this.status.downloadStatus)) { - return this.transferEnded(); + return this.renderFinishedLine(); } if (this.status.downloadStatus === DownloadStatus.NotStarted) { - return this.transferNotStarted(); + return this.renderPendingLine(); } - return this.createProgressBarFormat(); - } - - public static createLineRenderer(options: BaseCliOptions) { - return (status: CliFormattedStatus) => { - return new BaseTransferCliProgressBar(status, options).createStatusLine(); - }; + return this.renderProgressLine(); } } diff --git a/src/download/transfer-visualize/transfer-cli/progress-bars/ci-transfer-cli-progress-bar.ts b/src/download/transfer-visualize/transfer-cli/progress-bars/ci-transfer-cli-progress-bar.ts new file mode 100644 index 0000000..dda5515 --- /dev/null +++ b/src/download/transfer-visualize/transfer-cli/progress-bars/ci-transfer-cli-progress-bar.ts @@ -0,0 +1,7 @@ +import SummaryTransferCliProgressBar from "./summary-transfer-cli-progress-bar.js"; +import {CIMultiProgressBar} from "../multiProgressBars/CIMultiProgressBar.js"; + + +export default class CiTransferCliProgressBar extends SummaryTransferCliProgressBar { + override multiProgressBar = CIMultiProgressBar; +} diff --git a/src/download/transfer-visualize/transfer-cli/progress-bars/fancy-transfer-cli-progress-bar.ts b/src/download/transfer-visualize/transfer-cli/progress-bars/fancy-transfer-cli-progress-bar.ts index da68f88..cc41607 100644 --- a/src/download/transfer-visualize/transfer-cli/progress-bars/fancy-transfer-cli-progress-bar.ts +++ b/src/download/transfer-visualize/transfer-cli/progress-bars/fancy-transfer-cli-progress-bar.ts @@ -1,58 +1,28 @@ import chalk from "chalk"; -import {truncateText} from "../../utils/cli-text.js"; -import {FormattedStatus, PRETTY_MS_OPTIONS} from "../../format-transfer-status.js"; -import isUnicodeSupported from "is-unicode-supported"; -import {DataPart, renderDataLine} from "../../utils/data-line.js"; +import {PRETTY_MS_OPTIONS} from "../../format-transfer-status.js"; +import {renderDataLine} from "../../utils/data-line.js"; import prettyMilliseconds from "pretty-ms"; import sliceAnsi from "slice-ansi"; import stripAnsi from "strip-ansi"; import {DownloadStatus} from "../../../download-engine/download-file/progress-status-file.js"; - -export type FancyCliOptions = { - truncateName?: boolean | number -}; - -const minNameLength = 20; -const minCommentLength = 15; - -const statusIcons = isUnicodeSupported() - ? { - activeDownload: chalk.blue("⏵"), - done: chalk.green("✔"), - failed: chalk.red("✖"), - pending: chalk.yellow("\u25f7") - } - : { - activeDownload: chalk.blue.bold(">"), - done: chalk.green("√"), - failed: chalk.red("×"), - pending: chalk.yellow.bold("-") - }; +import BaseTransferCliProgressBar from "./base-transfer-cli-progress-bar.js"; +import {STATUS_ICONS} from "../../utils/progressBarIcons.js"; /** * A class to display transfer progress in the terminal, with a progress bar and other information. */ -export default class FancyTransferCliProgressBar { - protected status: FormattedStatus; - protected options: FancyCliOptions; - - protected constructor(status: FormattedStatus, options: FancyCliOptions = {}) { - this.status = status; - this.options = options; - } - - protected renderProgressLine(): string { - const {formattedSpeed, formatTimeLeft, formatTransferred, formatTotal, formattedPercentage, percentage} = this.status; +export default class FancyTransferCliProgressBar extends BaseTransferCliProgressBar { + protected override renderProgressLine(): string { + const {formattedSpeed, formatTransferred, formatTotal, formattedPercentage, percentage} = this.status; const formattedPercentageWithPadding = formattedPercentage.padEnd(6, " "); const progressBarText = ` ${formattedPercentageWithPadding} (${formatTransferred}/${formatTotal}) `; - const etaText = formatTimeLeft + " left"; return renderDataLine([{ type: "status", fullText: "", size: 1, - formatter: () => statusIcons.activeDownload + formatter: () => STATUS_ICONS.activeDownload }, { type: "spacer", fullText: " ", @@ -89,72 +59,11 @@ export default class FancyTransferCliProgressBar { }, { type: "speed", fullText: formattedSpeed, - size: formattedSpeed.length - }, { - type: "spacer", - fullText: " | ", - size: " | ".length, - formatter: (text) => chalk.dim(text) - }, { - type: "timeLeft", - fullText: etaText, - size: etaText.length, - formatter: (text) => chalk.dim(text) - }]); - } - - protected getNameAndCommentDataParts(): DataPart[] { - const {fileName, comment, downloadStatus} = this.status; - - let fullComment = comment; - if (downloadStatus === DownloadStatus.Cancelled || downloadStatus === DownloadStatus.Paused) { - if (fullComment) { - fullComment += " | " + downloadStatus; - } else { - fullComment = downloadStatus; - } - } - - return [{ - type: "name", - fullText: fileName, - size: this.options.truncateName === false - ? fileName.length - : typeof this.options.truncateName === "number" - ? this.options.truncateName - : Math.min(fileName.length, minNameLength), - flex: typeof this.options.truncateName === "number" - ? undefined - : 1, - maxSize: fileName.length, - cropper: truncateText, - formatter: (text) => chalk.bold(text) - }, ...( - (fullComment == null || fullComment.length === 0) - ? [] - : [{ - type: "spacer", - fullText: " (", - size: " (".length, - formatter: (text) => chalk.dim(text) - }, { - type: "nameComment", - fullText: fullComment, - size: Math.min(fullComment.length, minCommentLength), - maxSize: fullComment.length, - flex: 1, - cropper: truncateText, - formatter: (text) => chalk.dim(text) - }, { - type: "spacer", - fullText: ")", - size: ")".length, - formatter: (text) => chalk.dim(text) - }] satisfies DataPart[] - )]; + size: "000.00kB/s".length + }, ...this.getETA(" ")]); } - protected renderFinishedLine() { + protected override renderFinishedLine() { const wasSuccessful = this.status.downloadStatus === DownloadStatus.Finished; const {endTime, startTime} = this.status; @@ -169,8 +78,8 @@ export default class FancyTransferCliProgressBar { size: 1, formatter: () => ( wasSuccessful - ? statusIcons.done - : statusIcons.failed + ? STATUS_ICONS.done + : STATUS_ICONS.failed ) }, { type: "spacer", @@ -190,14 +99,14 @@ export default class FancyTransferCliProgressBar { }]); } - protected renderPendingLine() { + protected override renderPendingLine() { const pendingText = `will download ${this.status.formatTotal}`; return renderDataLine([{ type: "status", fullText: "", size: 1, - formatter: () => statusIcons.pending + formatter: () => STATUS_ICONS.pending }, { type: "spacer", fullText: " ", @@ -215,24 +124,6 @@ export default class FancyTransferCliProgressBar { formatter: (text) => chalk.dim(text) }]); } - - public renderStatusLine(): string { - if ([DownloadStatus.Finished, DownloadStatus.Error, DownloadStatus.Cancelled].includes(this.status.downloadStatus)) { - return this.renderFinishedLine(); - } - - if (this.status.downloadStatus === DownloadStatus.NotStarted) { - return this.renderPendingLine(); - } - - return this.renderProgressLine(); - } - - public static createLineRenderer(options: FancyCliOptions) { - return (status: FormattedStatus) => { - return new FancyTransferCliProgressBar(status, options).renderStatusLine(); - }; - } } function renderProgressBar({barText, backgroundText, length, loadedPercentage, barStyle, backgroundStyle}: { diff --git a/src/download/transfer-visualize/transfer-cli/progress-bars/summary-transfer-cli-progress-bar.ts b/src/download/transfer-visualize/transfer-cli/progress-bars/summary-transfer-cli-progress-bar.ts new file mode 100644 index 0000000..5aa63a4 --- /dev/null +++ b/src/download/transfer-visualize/transfer-cli/progress-bars/summary-transfer-cli-progress-bar.ts @@ -0,0 +1,129 @@ +import chalk from "chalk"; +import {SummaryMultiProgressBar} from "../multiProgressBars/SummaryMultiProgressBar.js"; +import {DataLine, renderDataLine} from "../../utils/data-line.js"; +import FancyTransferCliProgressBar from "./fancy-transfer-cli-progress-bar.js"; +import {STATUS_ICONS} from "../../utils/progressBarIcons.js"; +import {DownloadFlags} from "../../../download-engine/download-file/progress-status-file.js"; + + +export default class SummaryTransferCliProgressBar extends FancyTransferCliProgressBar { + override multiProgressBar = SummaryMultiProgressBar; + + switchTransferToIcon() { + switch (this.status.transferAction) { + case "Downloading": + return "↓"; + case "Copying": + return "→"; + } + + return this.status.transferAction; + } + + override renderProgressLine(): string { + if (this.status.downloadFlags.includes(DownloadFlags.DownloadSequence)) { + return this.renderDownloadSequence(); + } + + const pendingText = `downloading ${this.status.formatTotal}`; + return renderDataLine([{ + type: "status", + fullText: "", + size: 1, + formatter: () => STATUS_ICONS.activeDownload + }, { + type: "spacer", + fullText: " ", + size: " ".length, + formatter: (text) => text + }, ...this.getNameAndCommentDataParts(), { + type: "spacer", + fullText: " ", + size: " ".length, + formatter: (text) => text + }, { + type: "description", + fullText: pendingText, + size: pendingText.length, + formatter: (text) => chalk.dim(text) + }]); + } + + protected renderDownloadSequence(): string { + const {formatTransferredOfTotal, formattedSpeed, formatTimeLeft, comment, formattedPercentage} = this.status; + const dataLine: DataLine = [ + { + type: "status", + fullText: "", + size: 1, + formatter: () => chalk.cyan(this.switchTransferToIcon()) + }, + { + type: "spacer", + fullText: " ", + size: " ".length, + formatter: (text) => text + }, + { + type: "percentage", + fullText: formattedPercentage, + size: 6, + formatter: (text) => text + }, + { + type: "spacer", + fullText: " ", + size: " ".length, + formatter: (text) => text + }, + { + type: "progressBar", + fullText: `(${formatTransferredOfTotal})`, + size: 19, + formatter: (text) => text + }, + { + type: "spacer", + fullText: " | ", + size: " | ".length, + formatter: (text) => chalk.dim(text) + }, + { + type: "nameComment", + fullText: comment || "", + size: (comment || "").length, + formatter: (text) => text + }, + { + type: "spacer", + fullText: " | ", + size: " | ".length, + formatter: (text) => chalk.dim(text) + }, + { + type: "speed", + fullText: formattedSpeed, + size: formattedSpeed.length + } + ]; + + if (this.showETA) { + dataLine.push( + { + type: "spacer", + fullText: " | ", + size: " | ".length, + formatter: (text) => chalk.dim(text) + }, + { + type: "timeLeft", + fullText: formatTimeLeft, + size: formatTimeLeft.length, + formatter: (text) => text + } + ); + } + + return renderDataLine(dataLine); + } +} diff --git a/src/download/transfer-visualize/transfer-cli/progress-bars/switch-cli-progress-style.ts b/src/download/transfer-visualize/transfer-cli/progress-bars/switch-cli-progress-style.ts index 8228813..4e4eb99 100644 --- a/src/download/transfer-visualize/transfer-cli/progress-bars/switch-cli-progress-style.ts +++ b/src/download/transfer-visualize/transfer-cli/progress-bars/switch-cli-progress-style.ts @@ -1,15 +1,31 @@ import BaseTransferCliProgressBar from "./base-transfer-cli-progress-bar.js"; import FancyTransferCliProgressBar from "./fancy-transfer-cli-progress-bar.js"; +import SummaryTransferCliProgressBar from "./summary-transfer-cli-progress-bar.js"; +import ci from "ci-info"; +import CiTransferCliProgressBar from "./ci-transfer-cli-progress-bar.js"; -export type AvailableCLIProgressStyle = "basic" | "fancy"; +export type AvailableCLIProgressStyle = "basic" | "fancy" | "ci" | "summary" | "auto"; -export default function switchCliProgressStyle(cliStyle: AvailableCLIProgressStyle, {truncateName}: { truncateName?: boolean | number }) { +export default function switchCliProgressStyle(cliStyle: AvailableCLIProgressStyle, options: { truncateName?: boolean | number }) { switch (cliStyle) { case "basic": - return BaseTransferCliProgressBar.createLineRenderer({truncateName}); + return new BaseTransferCliProgressBar(options); case "fancy": - return FancyTransferCliProgressBar.createLineRenderer({truncateName}); + return new FancyTransferCliProgressBar(options); + + case "summary": + return new SummaryTransferCliProgressBar(options); + + case "ci": + return new CiTransferCliProgressBar(options); + + case "auto": + if (ci.isCI || process.env.IPULL_USE_CI_STYLE) { + return switchCliProgressStyle("ci", options); + } else { + return switchCliProgressStyle("fancy", options); + } } void (cliStyle satisfies never); diff --git a/src/download/transfer-visualize/transfer-cli/transfer-cli.ts b/src/download/transfer-visualize/transfer-cli/transfer-cli.ts index 0fe920b..45c8810 100644 --- a/src/download/transfer-visualize/transfer-cli/transfer-cli.ts +++ b/src/download/transfer-visualize/transfer-cli/transfer-cli.ts @@ -1,11 +1,11 @@ import UpdateManager from "stdout-update"; import debounce from "lodash.debounce"; -import {CliFormattedStatus} from "./progress-bars/base-transfer-cli-progress-bar.js"; +import {TransferCliProgressBar} from "./progress-bars/base-transfer-cli-progress-bar.js"; import cliSpinners from "cli-spinners"; import CliSpinnersLoadingAnimation from "./loading-animation/cli-spinners-loading-animation.js"; import {FormattedStatus} from "../format-transfer-status.js"; import switchCliProgressStyle from "./progress-bars/switch-cli-progress-style.js"; -import {BaseMultiProgressBar} from "./multiProgressBars/baseMultiProgressBar.js"; +import {BaseMultiProgressBar} from "./multiProgressBars/BaseMultiProgressBar.js"; export type TransferCliOptions = { action?: string, @@ -14,7 +14,7 @@ export type TransferCliOptions = { truncateName: boolean | number; debounceWait: number; maxDebounceWait: number; - createProgressBar: (status: CliFormattedStatus) => string; + createProgressBar: TransferCliProgressBar; createMultiProgressBar: typeof BaseMultiProgressBar, loadingAnimation: cliSpinners.SpinnerName, loadingText?: string; @@ -25,7 +25,7 @@ export const DEFAULT_TRANSFER_CLI_OPTIONS: TransferCliOptions = { truncateName: true, debounceWait: 20, maxDebounceWait: 100, - createProgressBar: switchCliProgressStyle("basic", {truncateName: true}), + createProgressBar: switchCliProgressStyle("auto", {truncateName: true}), loadingAnimation: "dots", loadingText: "Gathering information", createMultiProgressBar: BaseMultiProgressBar @@ -43,31 +43,38 @@ export default class TransferCli { protected options: TransferCliOptions; protected stdoutManager = UpdateManager.getInstance(); protected myCLILevel: number; - protected latestProgress: FormattedStatus[] = []; + protected latestProgress: [FormattedStatus[], FormattedStatus] = null!; private _cliStopped = true; private readonly _updateStatuesDebounce: () => void; private _multiProgressBar: BaseMultiProgressBar; + private _isFirstPrint = true; public constructor(options: Partial, myCLILevel = CLI_LEVEL.LOW) { TransferCli.activeCLILevel = this.myCLILevel = myCLILevel; this.options = {...DEFAULT_TRANSFER_CLI_OPTIONS, ...options}; + this._multiProgressBar = new this.options.createProgressBar.multiProgressBar(this.options); - this._updateStatuesDebounce = debounce(this._updateStatues.bind(this), this.options.debounceWait, { - maxWait: this.options.maxDebounceWait + const maxDebounceWait = this._multiProgressBar.updateIntervalMs || this.options.maxDebounceWait; + this._updateStatuesDebounce = debounce(this._updateStatues.bind(this), maxDebounceWait, { + maxWait: maxDebounceWait }); this.loadingAnimation = new CliSpinnersLoadingAnimation(cliSpinners[this.options.loadingAnimation], { - loadingText: this.options.loadingText + loadingText: this.options.loadingText, + updateIntervalMs: this._multiProgressBar.updateIntervalMs, + logType: this._multiProgressBar.printType }); this._processExit = this._processExit.bind(this); - this._multiProgressBar = new this.options.createMultiProgressBar(this.options); + } start() { if (this.myCLILevel !== TransferCli.activeCLILevel) return; this._cliStopped = false; - this.stdoutManager.hook(); + if (this._multiProgressBar.printType === "update") { + this.stdoutManager.hook(); + } process.on("SIGINT", this._processExit); } @@ -75,7 +82,9 @@ export default class TransferCli { if (this._cliStopped || this.myCLILevel !== TransferCli.activeCLILevel) return; this._updateStatues(); this._cliStopped = true; - this.stdoutManager.unhook(false); + if (this._multiProgressBar.printType === "update") { + this.stdoutManager.unhook(false); + } process.off("SIGINT", this._processExit); } @@ -84,9 +93,15 @@ export default class TransferCli { process.exit(0); } - updateStatues(statues: FormattedStatus[]) { - this.latestProgress = statues; - this._updateStatuesDebounce(); + updateStatues(statues: FormattedStatus[], oneStatus: FormattedStatus) { + this.latestProgress = [statues, oneStatus]; + + if (this._isFirstPrint) { + this._isFirstPrint = false; + this._updateStatues(); + } else { + this._updateStatuesDebounce(); + } } private _updateStatues() { @@ -94,11 +109,17 @@ export default class TransferCli { return; // Do not update if there is a higher level CLI, meaning that this CLI is sub-CLI } - const printLog = this._multiProgressBar.createMultiProgressBar(this.latestProgress); - this._logUpdate(printLog); + const printLog = this._multiProgressBar.createMultiProgressBar(...this.latestProgress); + if (printLog) { + this._logUpdate(printLog); + } } protected _logUpdate(text: string) { - this.stdoutManager.update(text.split("\n")); + if (this._multiProgressBar.printType === "update") { + this.stdoutManager.update(text.split("\n")); + } else { + console.log(text); + } } } diff --git a/src/download/transfer-visualize/utils/data-line.ts b/src/download/transfer-visualize/utils/data-line.ts index 7aa695d..34b981d 100644 --- a/src/download/transfer-visualize/utils/data-line.ts +++ b/src/download/transfer-visualize/utils/data-line.ts @@ -1,5 +1,5 @@ export type DataPart = { - type: "status" | "name" | "nameComment" | "progressBar" | "speed" | "timeLeft" | "spacer" | "description", + type: "status" | "name" | "nameComment" | "progressBar" | "percentage" | "transferred" | "speed" | "timeLeft" | "spacer" | "description", fullText: string, size: number, addEndPadding?: number, @@ -20,14 +20,17 @@ export function renderDataPart(dataPart: DataPart) { let text = dataPart.fullText; if (dataPart.cropper != null) { - text = dataPart - .cropper(text, dataPart.size) - .slice(0, dataPart.size) - .padEnd(dataPart.size); + text = padEqually( + dataPart + .cropper(text, dataPart.size) + .slice(0, dataPart.size), + dataPart.size + ); } else { - text = text - .slice(0, dataPart.size) - .padEnd(dataPart.size); + text = padEqually( + text.slice(0, dataPart.size), + dataPart.size + ); } if (dataPart.formatter != null) { @@ -93,3 +96,17 @@ export function resizeDataLine(dataLine: DataLine, lineLength: number) { return res; } + + +function padEqually(text: string, size: number) { + const padAmount = Math.max(0, size - text.length); + for (let i = 0; i < padAmount; i++) { + if (i % 2 == 0) { + text = " " + text; + } else { + text = text + " "; + } + } + + return text; +} diff --git a/src/download/transfer-visualize/utils/progressBarIcons.ts b/src/download/transfer-visualize/utils/progressBarIcons.ts new file mode 100644 index 0000000..a5625f9 --- /dev/null +++ b/src/download/transfer-visualize/utils/progressBarIcons.ts @@ -0,0 +1,16 @@ +import isUnicodeSupported from "is-unicode-supported"; +import chalk from "chalk"; + +export const STATUS_ICONS = isUnicodeSupported() + ? { + activeDownload: chalk.blue("⏵"), + done: chalk.green("✔"), + failed: chalk.red("✖"), + pending: chalk.yellow("\u25f7") + } + : { + activeDownload: chalk.blue.bold(">"), + done: chalk.green("√"), + failed: chalk.red("×"), + pending: chalk.yellow.bold("-") + }; diff --git a/src/index.ts b/src/index.ts index 9c9c28f..bf0da63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,7 +14,7 @@ import DownloadEngineMultiDownload from "./download/download-engine/engine/downl import HttpError from "./download/download-engine/streams/download-engine-fetch-stream/errors/http-error.js"; import BaseDownloadEngine from "./download/download-engine/engine/base-download-engine.js"; import {InvalidOptionError} from "./download/download-engine/engine/error/InvalidOptionError.js"; -import {BaseMultiProgressBar, MultiProgressBarOptions} from "./download/transfer-visualize/transfer-cli/multiProgressBars/baseMultiProgressBar.js"; +import {BaseMultiProgressBar, MultiProgressBarOptions} from "./download/transfer-visualize/transfer-cli/multiProgressBars/BaseMultiProgressBar.js"; export {