diff --git a/cli/unstable_prompt_select.ts b/cli/unstable_prompt_select.ts new file mode 100644 index 000000000000..c46970a01dfa --- /dev/null +++ b/cli/unstable_prompt_select.ts @@ -0,0 +1,76 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +/** Options for {@linkcode promptSelect}. */ +export interface PromptSelectOptions { + /** Clear the lines after the user's input. */ + clear?: boolean; +} + +const ETX = "\x03"; +const ARROW_UP = "\u001B[A"; +const ARROW_DOWN = "\u001B[B"; +const CR = "\r"; +const INDICATOR = "❯"; +const PADDING = " ".repeat(INDICATOR.length); + +const CLR = "\r\u001b[K"; // Clear the current line + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** + * Shows the given message and waits for the user's input. Returns the user's selected value as string. + * + * @param message The prompt message to show to the user. + * @param values The values for the prompt. + * @param options The options for the prompt. + * @returns The string that was entered or `null` if stdin is not a TTY. + * + * @example Usage + * ```ts ignore + * import { promptSelect } from "@std/cli/prompt-select"; + * + * const browser = promptSelect("Please select a browser:", ["safari", "chrome", "firefox"], { clear: true }); + * ``` + */ +export function promptSelect( + message: string, + values: string[], + { clear }: PromptSelectOptions = {}, +): string | null { + const length = values.length; + let selectedIndex = 0; + + Deno.stdout.writeSync(encoder.encode(`${message}\r\n`)); + Deno.stdin.setRaw(true); + + const buffer = new Uint8Array(4); + loop: + while (true) { + for (const [index, value] of values.entries()) { + const start = index === selectedIndex ? INDICATOR : PADDING; + Deno.stdout.writeSync(encoder.encode(`${start} ${value}\r\n`)); + } + const n = Deno.stdin.readSync(buffer); + if (n === null || n === 0) break; + const input = decoder.decode(buffer.slice(0, n)); + switch (input) { + case ETX: + return Deno.exit(0); + case ARROW_UP: + selectedIndex = (selectedIndex - 1 + length) % length; + break; + case ARROW_DOWN: + selectedIndex = (selectedIndex + 1) % length; + break; + case CR: + break loop; + } + Deno.stdout.writeSync(encoder.encode(`\x1b[1A${CLR}`.repeat(length))); + } + if (clear) { + Deno.stdout.writeSync(encoder.encode(`\x1b[1A${CLR}`.repeat(length + 1))); // clear values and message + } + Deno.stdin.setRaw(false); + return values[selectedIndex] ?? null; +} diff --git a/cli/unstable_prompt_select_test.ts b/cli/unstable_prompt_select_test.ts new file mode 100644 index 000000000000..2aa03c6429d4 --- /dev/null +++ b/cli/unstable_prompt_select_test.ts @@ -0,0 +1,349 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { assertEquals } from "@std/assert/equals"; +import { promptSelect } from "./unstable_prompt_select.ts"; +import { restore, stub } from "@std/testing/mock"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +Deno.test("promptSelect() handles enter", () => { + stub(Deno.stdin, "setRaw"); + + const expectedOutput = [ + "Please select a browser:\r\n", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", + ]; + + let writeIndex = 0; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + assertEquals(output, expectedOutput[writeIndex]); + writeIndex++; + return data.length; + }, + ); + + let readIndex = 0; + + const inputs = [ + "\r", + ]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const browser = promptSelect("Please select a browser:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browser, "safari"); + restore(); +}); + +Deno.test("promptSelect() handles arrow down", () => { + stub(Deno.stdin, "setRaw"); + + const expectedOutput = [ + "Please select a browser:\r\n", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + " safari\r\n", + "❯ chrome\r\n", + " firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + " safari\r\n", + " chrome\r\n", + "❯ firefox\r\n", + ]; + + let writeIndex = 0; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + assertEquals(output, expectedOutput[writeIndex]); + writeIndex++; + return data.length; + }, + ); + + let readIndex = 0; + + const inputs = [ + "\u001B[B", + "\u001B[B", + "\r", + ]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const browser = promptSelect("Please select a browser:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browser, "firefox"); + restore(); +}); + +Deno.test("promptSelect() handles arrow up", () => { + stub(Deno.stdin, "setRaw"); + + const expectedOutput = [ + "Please select a browser:\r\n", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + " safari\r\n", + "❯ chrome\r\n", + " firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", + ]; + + let writeIndex = 0; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + assertEquals(output, expectedOutput[writeIndex]); + writeIndex++; + return data.length; + }, + ); + + let readIndex = 0; + + const inputs = [ + "\u001B[B", + "\u001B[A", + "\r", + ]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const browser = promptSelect("Please select a browser:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browser, "safari"); + restore(); +}); + +Deno.test("promptSelect() handles up index overflow", () => { + stub(Deno.stdin, "setRaw"); + + const expectedOutput = [ + "Please select a browser:\r\n", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + " safari\r\n", + " chrome\r\n", + "❯ firefox\r\n", + ]; + + let writeIndex = 0; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + assertEquals(output, expectedOutput[writeIndex]); + writeIndex++; + return data.length; + }, + ); + + let readIndex = 0; + + const inputs = [ + "\u001B[A", + "\r", + ]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const browser = promptSelect("Please select a browser:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browser, "firefox"); + restore(); +}); + +Deno.test("promptSelect() handles down index overflow", () => { + stub(Deno.stdin, "setRaw"); + + const expectedOutput = [ + "Please select a browser:\r\n", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + " safari\r\n", + "❯ chrome\r\n", + " firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + " safari\r\n", + " chrome\r\n", + "❯ firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", + ]; + + let writeIndex = 0; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + assertEquals(output, expectedOutput[writeIndex]); + writeIndex++; + return data.length; + }, + ); + + let readIndex = 0; + + const inputs = [ + "\u001B[B", + "\u001B[B", + "\u001B[B", + "\r", + ]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const browser = promptSelect("Please select a browser:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browser, "safari"); + restore(); +}); + +Deno.test("promptSelect() handles clear option", () => { + stub(Deno.stdin, "setRaw"); + + const expectedOutput = [ + "Please select a browser:\r\n", + "❯ safari\r\n", + " chrome\r\n", + " firefox\r\n", + "\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K\x1b[1A\r\x1b[K", + ]; + + let writeIndex = 0; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + assertEquals(output, expectedOutput[writeIndex]); + writeIndex++; + return data.length; + }, + ); + + let readIndex = 0; + + const inputs = [ + "\r", + ]; + + stub( + Deno.stdin, + "readSync", + (data: Uint8Array) => { + const input = inputs[readIndex++]; + const bytes = encoder.encode(input); + data.set(bytes); + return bytes.length; + }, + ); + + const browser = promptSelect("Please select a browser:", [ + "safari", + "chrome", + "firefox", + ], { clear: true }); + + assertEquals(browser, "safari"); + restore(); +});