Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): Intro createProgressBar() & new ProgressBarStream() #6378

Open
wants to merge 16 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
".": "./mod.ts",
"./parse-args": "./parse_args.ts",
"./prompt-secret": "./prompt_secret.ts",
"./unstable-progress-bar": "./unstable_progress_bar.ts",
"./unstable-progress-bar-stream": "./unstable_progress_bar_stream.ts",
"./unstable-prompt-select": "./unstable_prompt_select.ts",
"./unstable-prompt-multiple-select": "./unstable_prompt_multiple_select.ts",
"./unstable-spinner": "./unstable_spinner.ts",
Expand Down
216 changes: 216 additions & 0 deletions cli/unstable_progress_bar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
// Copyright 2018-2025 the Deno authors. MIT license.

/**
* The properties provided to the fmt function upon every visual update.
*/
export interface ProgressBarFormatter {
/**
* A function that returns a formatted version of the duration.
* `[mm:ss] `
*/
styledTime: () => string;
/**
* A function that returns a formatted version of the data received.
* `[0.40/97.66 KiB] `
* @param fractions The number of decimal places the values should have.
*/
styledData: (fractions?: number) => string;
/**
* The progress bar string.
* Default Style: `[###-------] `
*/
progressBar: string;
/**
* The duration of the progress bar.
*/
time: number;
/**
* The duration passed to the last call.
*/
previousTime: number;
/**
* The current value the progress bar is sitting at.
*/
value: number;
/**
* The value passed to the last call.
*/
previousValue: number;
/**
* The max value expected to receive.
*/
max: number;
}

/**
* The options that are provided to a {@link createProgressBar} or
* {@link ProgressBarStream}.
*/
export interface ProgressBarOptions {
/**
* The offset size of the input if progress is resuming part way through.
* @default {0}
*/
value?: number;
/**
* The total size expected to receive.
*/
max: number;
/**
* The length that the progress bar should be, in characters.
* @default {50}
*/
barLength?: number;
/**
* The character to fill the progress bar up with as it makes progress.
* @default {'#'}
*/
fillChar?: string;
/**
* The character the progress bar starts out with.
* @default {'-'}
*/
emptyChar?: string;
/**
* Whether the progress bar should be removed after completion.
* @default {false}
*/
clear?: boolean;
/**
* A function that creates the style of the progress bar.
* Default Style: `[mm:ss] [###-------] [0.24/97.6 KiB]`.
*/
fmt?: (fmt: ProgressBarFormatter) => string;
keepOpen?: boolean;
}

/**
* `createProgressBar` is a customisable function that reports updates to a
* {@link WritableStream} on a 1s interval. Progress is communicated by calling
* the function returned from `createProgressBar`.
*
* @param writable The {@link WritableStream} that will receive the progress bar
* reports.
* @param options The options to configure various settings of the progress bar.
* @returns A function to update the amount of progress made.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @example Basic Usage
* ```ts
* import { createProgressBar } from "@std/cli/unstable-progress-bar";
*
* const gen = async function* (): AsyncGenerator<Uint8Array> {
* for (let i = 0; i < 100; ++i) {
* yield new Uint8Array(1000).fill(97);
* await new Promise((a) => setTimeout(a, (Math.random() * 200) | 0));
* }
* }();
* const writer = (await Deno.create("./_tmp/output.txt")).writable.getWriter();
*
* const addProgress = createProgressBar(Deno.stdout.writable, { max: 100_000 });
*
* for await (const buffer of gen) {
* addProgress(buffer.length);
* await writer.write(buffer);
* }
* await addProgress(0, true);
* await writer.close();
* ```
*/
export function createProgressBar(
writable: WritableStream<Uint8Array>,
options: ProgressBarOptions,
): {
(addSize: number, done?: false): void;
(addSize: number, done: true): Promise<void>;
} {
options.value = options.value ?? 0;
options.barLength = options.barLength ?? 50;
options.fillChar = options.fillChar ?? "#";
options.emptyChar = options.emptyChar ?? "-";
options.clear = options.clear ?? false;
options.fmt = options.fmt ?? function (x) {
return x.styledTime() + x.progressBar + x.styledData();
};
options.keepOpen = options.keepOpen ?? true;

const [unit, rate] = function (): [string, number] {
if (options.max < 2 ** 20) return ["KiB", 2 ** 10];
if (options.max < 2 ** 30) return ["MiB", 2 ** 20];
if (options.max < 2 ** 40) return ["GiB", 2 ** 30];
if (options.max < 2 ** 50) return ["TiB", 2 ** 40];
return ["PiB", 2 ** 50];
}();

const writer = function () {
const stream = new TextEncoderStream();
stream.readable
.pipeTo(writable, { preventClose: options.keepOpen })
.catch(() => clearInterval(id));
return stream.writable.getWriter();
}();
const startTime = performance.now();
const id = setInterval(print, 1_000);
let lastTime = startTime;
let lastValue = options.value!;

return addProgress;
/**
* @param addSize The amount of bytes of progressed made since last call.
* @param done Whether or not you're done reporting progress.
*/
function addProgress(addSize: number, done?: false): void;
function addProgress(addSize: number, done: true): Promise<void>;
function addProgress(addSize: number, done = false): void | Promise<void> {
options.value! += addSize;
if (done) {
clearInterval(id);
return print()
.then(() => writer.write(options.clear ? "\r\u001b[K" : "\n"))
.then(() => writer.close())
.catch(() => {});
}
}
async function print(): Promise<void> {
const currentTime = performance.now();
const size = options.value! /
options.max *
options.barLength! | 0;
const x: ProgressBarFormatter = {
styledTime() {
return "[" +
(this.time / 1000 / 60 | 0)
.toString()
.padStart(2, "0") +
":" +
(this.time / 1000 % 60 | 0)
.toString()
.padStart(2, "0") +
"] ";
},
styledData(fractions = 2) {
return "[" +
(this.value / rate).toFixed(fractions) +
"/" +
(this.max / rate).toFixed(fractions) +
" " +
unit +
"] ";
},
progressBar: "[" +
options.fillChar!.repeat(size) +
options.emptyChar!.repeat(options.barLength! - size) +
"] ",
time: currentTime - startTime,
previousTime: lastTime - startTime,
value: options.value!,
previousValue: lastValue,
max: options.max,
};
lastTime = currentTime;
lastValue = options.value!;
await writer.write("\r\u001b[K" + options.fmt!(x))
.catch(() => {});
}
}
54 changes: 54 additions & 0 deletions cli/unstable_progress_bar_stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright 2018-2025 the Deno authors. MIT license.

import {
createProgressBar,
type ProgressBarOptions,
} from "./unstable_progress_bar.ts";

/**
* `ProgressBarStream` is a {@link TransformStream} class that reports updates
* to a separate {@link WritableStream} on a 1s interval.
*
* @experimental **UNSTABLE**: New API, yet to be vetted.
*
* @example Basic Usage
* ```ts
* import { ProgressBarStream } from "@std/cli/unstable-progress-bar-stream";
*
* const response = await fetch("https://example.com/");
* const max = Number(response.headers.get("content-length"));
* let readable = response.body
* if (max) {
* readable = readable
* ?.pipeThrough(new ProgressBarStream(Deno.stdout.writable, { max })) ?? null;
* }
* await readable?.pipeTo((await Deno.create("./_tmp/example.com.html")).writable);
* ```
*/
export class ProgressBarStream extends TransformStream<Uint8Array, Uint8Array> {
/**
* Constructs a new instance.
*
* @param writable The {@link WritableStream} that will receive the progress bar
* reports.
* @param options The options to configure various settings of the progress bar.
*/
constructor(
writable: WritableStream<Uint8Array>,
options: ProgressBarOptions,
) {
const addProgress = createProgressBar(writable, options);
super({
transform(chunk, controller) {
addProgress(chunk.length);
controller.enqueue(chunk);
},
flush(_controller) {
addProgress(0, true);
},
cancel() {
addProgress(0, true);
},
});
}
}
42 changes: 42 additions & 0 deletions cli/unstable_progress_bar_stream_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright 2018-2025 the Deno authors. MIT license.

import { assertEquals } from "@std/assert";
import { ProgressBarStream } from "./unstable_progress_bar_stream.ts";

async function* getData(
loops: number,
bufferSize: number,
): AsyncGenerator<Uint8Array> {
for (let i = 0; i < loops; ++i) {
yield new Uint8Array(bufferSize);
await new Promise((a) => setTimeout(a, Math.random() * 500 + 500));
}
}

Deno.test("ProgressBarStream() flushes", async () => {
const { readable, writable } = new TransformStream();

for await (
const _ of ReadableStream
.from(getData(10, 1000))
.pipeThrough(
new ProgressBarStream(writable, { max: 10 * 1000, keepOpen: false }),
)
// deno-lint-ignore no-empty
) {}

assertEquals((await new Response(readable).bytes()).subarray(-1)[0], 10);
});

Deno.test("ProgressBarStream() cancels", async () => {
const { readable, writable } = new TransformStream();

await ReadableStream
.from(getData(10, 1000))
.pipeThrough(
new ProgressBarStream(writable, { max: 10 * 1000, keepOpen: false }),
)
.cancel();

assertEquals((await new Response(readable).bytes()).subarray(-1)[0], 10);
});
Loading
Loading