From 8e64b1a6ed4310fbc767910c6141adda9d19463c Mon Sep 17 00:00:00 2001 From: Christopher Dierkens Date: Wed, 28 Aug 2024 20:19:58 -0400 Subject: [PATCH] Renders the default for all `Prompt` types that accepts `TextOptions`. (#3508) Co-authored-by: Maxwell Brown --- .changeset/late-apricots-thank.md | 9 + packages/cli/src/internal/prompt/text.ts | 39 ++- packages/cli/test/Prompt.test.ts | 288 ++++++++++++++++++++++- 3 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 .changeset/late-apricots-thank.md diff --git a/.changeset/late-apricots-thank.md b/.changeset/late-apricots-thank.md new file mode 100644 index 0000000000..584b10f222 --- /dev/null +++ b/.changeset/late-apricots-thank.md @@ -0,0 +1,9 @@ +--- +"@effect/cli": patch +--- + +Renders the default for all `Prompt` types that accepts `TextOptions`. + +- The default value will be rendered as ghost text for `Prompt.text` and `Prompt.list`. +- The default value will be rendered as redacted ghost text for `Prompt.password`. +- The default value will remain hidden for `Prompt.hidden`. diff --git a/packages/cli/src/internal/prompt/text.ts b/packages/cli/src/internal/prompt/text.ts index bbc27086d4..a78867d85c 100644 --- a/packages/cli/src/internal/prompt/text.ts +++ b/packages/cli/src/internal/prompt/text.ts @@ -25,6 +25,10 @@ interface State { readonly error: Option.Option } +function getValue(state: State, options: Options): string { + return state.value.length > 0 ? state.value : options.default +} + const renderBeep = Doc.render(Doc.beep, { style: "pretty" }) function renderClearScreen(state: State, options: Options) { @@ -58,19 +62,32 @@ function renderClearScreen(state: State, options: Options) { } function renderInput(nextState: State, options: Options, submitted: boolean) { + const text = getValue(nextState, options) + const annotation = Option.match(nextState.error, { - onNone: () => submitted ? Ansi.white : Ansi.combine(Ansi.underlined, Ansi.cyanBright), + onNone: () => { + if (submitted) { + return Ansi.white + } + + if (nextState.value.length === 0) { + return Ansi.blackBright + } + + return Ansi.combine(Ansi.underlined, Ansi.cyanBright) + }, onSome: () => Ansi.red }) + switch (options.type) { case "hidden": { return Doc.empty } case "password": { - return Doc.annotate(Doc.text("*".repeat(nextState.value.length)), annotation) + return Doc.annotate(Doc.text("*".repeat(text.length)), annotation) } case "text": { - return Doc.annotate(Doc.text(nextState.value), annotation) + return Doc.annotate(Doc.text(text), annotation) } } } @@ -191,6 +208,17 @@ function processCursorRight(state: State) { })) } +function processTab(state: State, options: Options) { + if (state.value === options.default) { + return Effect.succeed(Action.Beep()) + } + const value = getValue(state, options) + const cursor = value.length + return Effect.succeed(Action.NextFrame({ + state: { ...state, value, cursor, error: Option.none() } + })) +} + function defaultProcessor(input: string, state: State) { const beforeCursor = state.value.slice(0, state.cursor) const afterCursor = state.value.slice(state.cursor) @@ -232,7 +260,7 @@ function handleProcess(options: Options) { } case "enter": case "return": { - const value = state.value.length > 0 ? state.value : options.default + const value = getValue(state, options) return Effect.match(options.validate(value), { onFailure: (error) => Action.NextFrame({ @@ -241,6 +269,9 @@ function handleProcess(options: Options) { onSuccess: (value) => Action.Submit({ value }) }) } + case "tab": { + return processTab(state, options) + } default: { const value = Option.getOrElse(input.input, () => "") return defaultProcessor(value, state) diff --git a/packages/cli/test/Prompt.test.ts b/packages/cli/test/Prompt.test.ts index 9f6f771a12..cf31988691 100644 --- a/packages/cli/test/Prompt.test.ts +++ b/packages/cli/test/Prompt.test.ts @@ -1,13 +1,30 @@ +import type * as CliApp from "@effect/cli/CliApp" import * as Prompt from "@effect/cli/Prompt" +import * as MockConsole from "@effect/cli/test/services/MockConsole" import * as MockTerminal from "@effect/cli/test/services/MockTerminal" -import type { Terminal } from "@effect/platform/Terminal" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import * as Ansi from "@effect/printer-ansi/Ansi" +import * as Doc from "@effect/printer-ansi/AnsiDoc" +import * as Console from "effect/Console" import * as Effect from "effect/Effect" import * as Fiber from "effect/Fiber" +import * as Layer from "effect/Layer" +import * as Redacted from "effect/Redacted" import { describe, expect, it } from "vitest" +const MainLive = Effect.gen(function*() { + const console = yield* MockConsole.make + return Layer.mergeAll( + Console.setConsole(console), + NodeFileSystem.layer, + MockTerminal.layer, + NodePath.layer + ) +}).pipe(Layer.unwrapEffect) + const runEffect = ( - self: Effect.Effect -): Promise => Effect.provide(self, MockTerminal.layer).pipe(Effect.runPromise) + self: Effect.Effect +): Promise => Effect.provide(self, MainLive).pipe(Effect.runPromise) describe("Prompt", () => { describe("text", () => { @@ -37,5 +54,270 @@ describe("Prompt", () => { expect(result).toBe("default-value") }).pipe(runEffect)) + + it("should render the default value when the default is provided", () => + Effect.gen(function*() { + const prompt = Prompt.text({ + message: "Test Prompt", + default: "default-value" + }) + + const fiber = yield* Effect.fork(prompt) + + yield* MockTerminal.inputKey("enter") + yield* Fiber.join(fiber) + + const lines = yield* MockConsole.getLines() + + const unsubmittedValue = Doc.annotate(Doc.text("default-value"), Ansi.blackBright).pipe(Doc.render({ + style: "pretty" + })) + + const submittedValue = Doc.annotate(Doc.text("default-value"), Ansi.white).pipe(Doc.render({ + style: "pretty" + })) + + expect(lines).toEqual( + expect.arrayContaining([ + expect.stringContaining( + unsubmittedValue + ), + expect.stringContaining( + submittedValue + ) + ]) + ) + + expect(lines.findIndex((line) => line.includes(unsubmittedValue))).toBeLessThan( + lines.findIndex((line) => line.includes(submittedValue)) + ) + }).pipe(runEffect)) + + it("should accept the default value when the tab is pressed", () => + Effect.gen(function*() { + const prompt = Prompt.text({ + message: "Test Prompt", + default: "default-value" + }) + + const fiber = yield* Effect.fork(prompt) + + yield* MockTerminal.inputKey("tab") + yield* MockTerminal.inputKey("enter") + yield* Fiber.join(fiber) + + const lines = yield* MockConsole.getLines() + + const unsubmittedValue = Doc.annotate(Doc.text("default-value"), Ansi.blackBright).pipe(Doc.render({ + style: "pretty" + })) + + const enteredValue = Doc.annotate(Doc.text("default-value"), Ansi.combine(Ansi.underlined, Ansi.cyanBright)) + .pipe(Doc.render({ + style: "pretty" + })) + + expect(lines).toEqual( + expect.arrayContaining([ + expect.stringContaining( + unsubmittedValue + ), + expect.stringContaining( + enteredValue + ) + ]) + ) + + expect(lines.findIndex((line) => line.includes(unsubmittedValue))).toBeLessThan( + lines.findIndex((line) => line.includes(enteredValue)) + ) + }).pipe(runEffect)) + }) + + describe("hidden", () => { + it("should use the prompt value when no default is provided", () => + Effect.gen(function*() { + const prompt = Prompt.hidden({ + message: "This does not have a default" + }) + + const fiber = yield* Effect.fork(prompt) + yield* MockTerminal.inputKey("enter") + const result = yield* Fiber.join(fiber) + + expect(result).toEqual(Redacted.make("")) + }).pipe(runEffect)) + + it("should use the default value when the default is provided", () => + Effect.gen(function*() { + const prompt = Prompt.hidden({ + message: "This should have a default", + default: "default-value" + }) + + const fiber = yield* Effect.fork(prompt) + yield* MockTerminal.inputKey("enter") + const result = yield* Fiber.join(fiber) + + expect(result).toEqual(Redacted.make("default-value")) + }).pipe(runEffect)) + + it("should not render the default value when the default is provided", () => + Effect.gen(function*() { + const prompt = Prompt.hidden({ + message: "Test Prompt", + default: "default-value" + }) + + const fiber = yield* Effect.fork(prompt) + + yield* MockTerminal.inputKey("enter") + yield* Fiber.join(fiber) + + const lines = yield* MockConsole.getLines({ stripAnsi: true }) + + expect(lines).not.toEqual( + expect.arrayContaining([ + expect.stringContaining( + "default-value" + ) + ]) + ) + }).pipe(runEffect)) + }) + + describe("list", () => { + it("should use the prompt value when no default is provided", () => + Effect.gen(function*() { + const prompt = Prompt.list({ + message: "This does not have a default" + }) + + const fiber = yield* Effect.fork(prompt) + yield* MockTerminal.inputKey("enter") + const result = yield* Fiber.join(fiber) + + expect(result).toEqual([""]) + }).pipe(runEffect)) + + it("should use the default value when the default is provided", () => + Effect.gen(function*() { + const prompt = Prompt.list({ + message: "This should have a default", + default: "default-value" + }) + + const fiber = yield* Effect.fork(prompt) + yield* MockTerminal.inputKey("enter") + const result = yield* Fiber.join(fiber) + + expect(result).toEqual(["default-value"]) + }).pipe(runEffect)) + + it("should render the default value when the default is provided", () => + Effect.gen(function*() { + const prompt = Prompt.list({ + message: "Test Prompt", + default: "default-value" + }) + + const fiber = yield* Effect.fork(prompt) + + yield* MockTerminal.inputKey("enter") + yield* Fiber.join(fiber) + + const lines = yield* MockConsole.getLines() + + const unsubmittedValue = Doc.annotate(Doc.text("default-value"), Ansi.blackBright).pipe(Doc.render({ + style: "pretty" + })) + + const submittedValue = Doc.annotate(Doc.text("default-value"), Ansi.white).pipe(Doc.render({ + style: "pretty" + })) + + expect(lines).toEqual( + expect.arrayContaining([ + expect.stringContaining( + unsubmittedValue + ), + expect.stringContaining( + submittedValue + ) + ]) + ) + + expect(lines.findIndex((line) => line.includes(unsubmittedValue))).toBeLessThan( + lines.findIndex((line) => line.includes(submittedValue)) + ) + }).pipe(runEffect)) + }) + + describe("password", () => { + it("should use the prompt value when no default is provided", () => + Effect.gen(function*() { + const prompt = Prompt.password({ + message: "This does not have a default" + }) + + const fiber = yield* Effect.fork(prompt) + yield* MockTerminal.inputKey("enter") + const result = yield* Fiber.join(fiber) + + expect(result).toEqual(Redacted.make("")) + }).pipe(runEffect)) + + it("should use the default value when the default is provided", () => + Effect.gen(function*() { + const prompt = Prompt.password({ + message: "This should have a default", + default: "default-value" + }) + + const fiber = yield* Effect.fork(prompt) + yield* MockTerminal.inputKey("enter") + const result = yield* Fiber.join(fiber) + + expect(result).toEqual(Redacted.make("default-value")) + }).pipe(runEffect)) + + it("should render the redacted default value when the default is provided", () => + Effect.gen(function*() { + const prompt = Prompt.password({ + message: "Test Prompt", + default: "default-value" + }) + + const fiber = yield* Effect.fork(prompt) + + yield* MockTerminal.inputKey("enter") + yield* Fiber.join(fiber) + + const lines = yield* MockConsole.getLines() + + const redactedValue = "*".repeat("default-value".length) + const unsubmittedValue = Doc.annotate(Doc.text(redactedValue), Ansi.blackBright).pipe(Doc.render({ + style: "pretty" + })) + + const submittedValue = Doc.annotate(Doc.text(redactedValue), Ansi.white).pipe(Doc.render({ + style: "pretty" + })) + + expect(lines).toEqual( + expect.arrayContaining([ + expect.stringContaining( + unsubmittedValue + ), + expect.stringContaining( + submittedValue + ) + ]) + ) + + expect(lines.findIndex((line) => line.includes(unsubmittedValue))).toBeLessThan( + lines.findIndex((line) => line.includes(submittedValue)) + ) + }).pipe(runEffect)) }) })