From aa81c0945ace96516fc7ad5fb5fb4fa794fbee7c Mon Sep 17 00:00:00 2001 From: Tim Reichen Date: Thu, 12 Dec 2024 06:36:41 +0100 Subject: [PATCH] feat(cli/unstable): add `promptMultipleSelect()` (#6223) Co-authored-by: Yoshiya Hinosawa --- _tools/check_docs.ts | 1 + cli/deno.json | 1 + cli/unstable_prompt_multiple_select.ts | 97 ++++ cli/unstable_prompt_multiple_select_test.ts | 548 ++++++++++++++++++++ 4 files changed, 647 insertions(+) create mode 100644 cli/unstable_prompt_multiple_select.ts create mode 100644 cli/unstable_prompt_multiple_select_test.ts diff --git a/_tools/check_docs.ts b/_tools/check_docs.ts index 4ab9af04f334..98bfed3a244b 100644 --- a/_tools/check_docs.ts +++ b/_tools/check_docs.ts @@ -38,6 +38,7 @@ const ENTRY_POINTS = [ "../cbor/mod.ts", "../cli/mod.ts", "../cli/unstable_spinner.ts", + "../cli/unstable_prompt_multiple_select.ts", "../crypto/mod.ts", "../collections/mod.ts", "../csv/mod.ts", diff --git a/cli/deno.json b/cli/deno.json index dc493bc592f0..ccc994fd0568 100644 --- a/cli/deno.json +++ b/cli/deno.json @@ -6,6 +6,7 @@ "./parse-args": "./parse_args.ts", "./prompt-secret": "./prompt_secret.ts", "./unstable-prompt-select": "./unstable_prompt_select.ts", + "./unstable-prompt-multiple-select": "./unstable_prompt_multiple_select.ts", "./unstable-spinner": "./unstable_spinner.ts", "./unicode-width": "./unicode_width.ts" } diff --git a/cli/unstable_prompt_multiple_select.ts b/cli/unstable_prompt_multiple_select.ts new file mode 100644 index 000000000000..b202b9653294 --- /dev/null +++ b/cli/unstable_prompt_multiple_select.ts @@ -0,0 +1,97 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +/** Options for {@linkcode promptMultipleSelect}. */ +export interface PromptMultipleSelectOptions { + /** 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 CHECKED = "◉"; +const UNCHECKED = "◯"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const CLEAR_ALL = encoder.encode("\x1b[J"); // Clear all lines after cursor +const HIDE_CURSOR = encoder.encode("\x1b[?25l"); +const SHOW_CURSOR = encoder.encode("\x1b[?25h"); + +/** + * 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 selected values as an array of strings. + * + * @example Usage + * ```ts ignore + * import { promptMultipleSelect } from "@std/cli/unstable-prompt-multiple-select"; + * + * const browsers = promptMultipleSelect("Please select browsers:", ["safari", "chrome", "firefox"], { clear: true }); + * ``` + */ +export function promptMultipleSelect( + message: string, + values: string[], + options: PromptMultipleSelectOptions = {}, +): string[] { + const { clear } = options; + const length = values.length; + let selectedIndex = 0; + const selectedIndexes = new Set(); + + Deno.stdin.setRaw(true); + Deno.stdout.writeSync(HIDE_CURSOR); + const buffer = new Uint8Array(4); + loop: + while (true) { + Deno.stdout.writeSync(encoder.encode(`${message}\r\n`)); + for (const [index, value] of values.entries()) { + const selected = index === selectedIndex; + const start = selected ? INDICATOR : PADDING; + const checked = selectedIndexes.has(index); + const state = checked ? CHECKED : UNCHECKED; + Deno.stdout.writeSync(encoder.encode(`${start} ${state} ${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: + Deno.stdout.writeSync(SHOW_CURSOR); + 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; + case " ": + if (selectedIndexes.has(selectedIndex)) { + selectedIndexes.delete(selectedIndex); + } else { + selectedIndexes.add(selectedIndex); + } + break; + } + Deno.stdout.writeSync(encoder.encode(`\x1b[${length + 1}A`)); + } + if (clear) { + Deno.stdout.writeSync(encoder.encode(`\x1b[${length + 1}A`)); + Deno.stdout.writeSync(CLEAR_ALL); + } + Deno.stdout.writeSync(SHOW_CURSOR); + Deno.stdin.setRaw(false); + return [...selectedIndexes].map((it) => values[it] as string); +} diff --git a/cli/unstable_prompt_multiple_select_test.ts b/cli/unstable_prompt_multiple_select_test.ts new file mode 100644 index 000000000000..a87b062f0f94 --- /dev/null +++ b/cli/unstable_prompt_multiple_select_test.ts @@ -0,0 +1,548 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { assertEquals } from "@std/assert/equals"; +import { promptMultipleSelect } from "./unstable_prompt_multiple_select.ts"; +import { restore, stub } from "@std/testing/mock"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +Deno.test("promptMultipleSelect() handles enter", () => { + stub(Deno.stdin, "setRaw"); + + const expectedOutput = [ + "\x1b[?25l", + "Please select browsers:\r\n", + "❯ ◯ safari\r\n", + " ◯ chrome\r\n", + " ◯ firefox\r\n", + + "\x1b[?25h", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + 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 browsers = promptMultipleSelect("Please select browsers:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browsers, []); + assertEquals(expectedOutput, actualOutput); + restore(); +}); + +Deno.test("promptMultipleSelect() handles selection", () => { + stub(Deno.stdin, "setRaw"); + + const expectedOutput = [ + "\x1b[?25l", + "Please select browsers:\r\n", + "❯ ◯ safari\r\n", + " ◯ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "Please select browsers:\r\n", + "❯ ◉ safari\r\n", + " ◯ chrome\r\n", + " ◯ firefox\r\n", + + "\x1b[?25h", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + 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 browsers = promptMultipleSelect("Please select browsers:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browsers, ["safari"]); + assertEquals(expectedOutput, actualOutput); + restore(); +}); + +Deno.test("promptMultipleSelect() handles multiple selection", () => { + stub(Deno.stdin, "setRaw"); + + const expectedOutput = [ + "\x1b[?25l", + "Please select browsers:\r\n", + "❯ ◯ safari\r\n", + " ◯ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "Please select browsers:\r\n", + "❯ ◉ safari\r\n", + " ◯ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "Please select browsers:\r\n", + " ◉ safari\r\n", + "❯ ◯ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "Please select browsers:\r\n", + " ◉ safari\r\n", + "❯ ◉ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "Please select browsers:\r\n", + " ◉ safari\r\n", + " ◉ chrome\r\n", + "❯ ◯ firefox\r\n", + "\x1b[4A", + + "Please select browsers:\r\n", + " ◉ safari\r\n", + " ◉ chrome\r\n", + "❯ ◉ firefox\r\n", + "\x1b[?25h", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + 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 browsers = promptMultipleSelect("Please select browsers:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browsers, ["safari", "chrome", "firefox"]); + assertEquals(expectedOutput, actualOutput); + restore(); +}); + +Deno.test("promptMultipleSelect() handles arrow down", () => { + stub(Deno.stdin, "setRaw"); + + const expectedOutput = [ + "\x1b[?25l", + "Please select browsers:\r\n", + "❯ ◯ safari\r\n", + " ◯ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "Please select browsers:\r\n", + " ◯ safari\r\n", + "❯ ◯ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "Please select browsers:\r\n", + " ◯ safari\r\n", + " ◯ chrome\r\n", + "❯ ◯ firefox\r\n", + "\x1b[4A", + + "Please select browsers:\r\n", + " ◯ safari\r\n", + " ◯ chrome\r\n", + "❯ ◉ firefox\r\n", + "\x1b[?25h", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + 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 browsers = promptMultipleSelect("Please select browsers:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browsers, ["firefox"]); + assertEquals(expectedOutput, actualOutput); + restore(); +}); + +Deno.test("promptMultipleSelect() handles arrow up", () => { + stub(Deno.stdin, "setRaw"); + + const expectedOutput = [ + "\x1b[?25l", + "Please select browsers:\r\n", + "❯ ◯ safari\r\n", + " ◯ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "Please select browsers:\r\n", + " ◯ safari\r\n", + "❯ ◯ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "Please select browsers:\r\n", + "❯ ◯ safari\r\n", + " ◯ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "Please select browsers:\r\n", + "❯ ◉ safari\r\n", + " ◯ chrome\r\n", + " ◯ firefox\r\n", + + "\x1b[?25h", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + 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 browsers = promptMultipleSelect("Please select browsers:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browsers, ["safari"]); + assertEquals(expectedOutput, actualOutput); + restore(); +}); + +Deno.test("promptMultipleSelect() handles up index overflow", () => { + stub(Deno.stdin, "setRaw"); + + const expectedOutput = [ + "\x1b[?25l", + "Please select browsers:\r\n", + "❯ ◯ safari\r\n", + " ◯ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "Please select browsers:\r\n", + " ◯ safari\r\n", + " ◯ chrome\r\n", + "❯ ◯ firefox\r\n", + "\x1b[4A", + + "Please select browsers:\r\n", + " ◯ safari\r\n", + " ◯ chrome\r\n", + "❯ ◉ firefox\r\n", + "\x1b[?25h", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + 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 browsers = promptMultipleSelect("Please select browsers:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browsers, ["firefox"]); + assertEquals(expectedOutput, actualOutput); + restore(); +}); + +Deno.test("promptMultipleSelect() handles down index overflow", () => { + stub(Deno.stdin, "setRaw"); + + const expectedOutput = [ + "\x1b[?25l", + "Please select browsers:\r\n", + "❯ ◯ safari\r\n", + " ◯ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "Please select browsers:\r\n", + " ◯ safari\r\n", + "❯ ◯ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "Please select browsers:\r\n", + " ◯ safari\r\n", + " ◯ chrome\r\n", + "❯ ◯ firefox\r\n", + "\x1b[4A", + + "Please select browsers:\r\n", + "❯ ◯ safari\r\n", + " ◯ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "Please select browsers:\r\n", + "❯ ◉ safari\r\n", + " ◯ chrome\r\n", + " ◯ firefox\r\n", + + "\x1b[?25h", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + 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 browsers = promptMultipleSelect("Please select browsers:", [ + "safari", + "chrome", + "firefox", + ]); + + assertEquals(browsers, ["safari"]); + assertEquals(expectedOutput, actualOutput); + restore(); +}); + +Deno.test("promptMultipleSelect() handles clear option", () => { + stub(Deno.stdin, "setRaw"); + + const expectedOutput = [ + "\x1b[?25l", + "Please select browsers:\r\n", + "❯ ◯ safari\r\n", + " ◯ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "Please select browsers:\r\n", + "❯ ◉ safari\r\n", + " ◯ chrome\r\n", + " ◯ firefox\r\n", + "\x1b[4A", + "\x1b[J", + "\x1b[?25h", + ]; + + const actualOutput: string[] = []; + + stub( + Deno.stdout, + "writeSync", + (data: Uint8Array) => { + const output = decoder.decode(data); + actualOutput.push(output); + 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 browsers = promptMultipleSelect("Please select browsers:", [ + "safari", + "chrome", + "firefox", + ], { clear: true }); + + assertEquals(browsers, ["safari"]); + assertEquals(expectedOutput, actualOutput); + restore(); +});