Skip to content

Commit

Permalink
Renders the default for all Prompt types that accepts TextOptions. (
Browse files Browse the repository at this point in the history
#3508)

Co-authored-by: Maxwell Brown <maxwellbrown1990@gmail.com>
  • Loading branch information
cdierkens and IMax153 authored Aug 29, 2024
1 parent dd3512c commit 8e64b1a
Show file tree
Hide file tree
Showing 3 changed files with 329 additions and 7 deletions.
9 changes: 9 additions & 0 deletions .changeset/late-apricots-thank.md
Original file line number Diff line number Diff line change
@@ -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`.
39 changes: 35 additions & 4 deletions packages/cli/src/internal/prompt/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ interface State {
readonly error: Option.Option<string>
}

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) {
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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({
Expand All @@ -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)
Expand Down
288 changes: 285 additions & 3 deletions packages/cli/test/Prompt.test.ts
Original file line number Diff line number Diff line change
@@ -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 = <E, A>(
self: Effect.Effect<A, E, Terminal>
): Promise<A> => Effect.provide(self, MockTerminal.layer).pipe(Effect.runPromise)
self: Effect.Effect<A, E, CliApp.CliApp.Environment>
): Promise<A> => Effect.provide(self, MainLive).pipe(Effect.runPromise)

describe("Prompt", () => {
describe("text", () => {
Expand Down Expand Up @@ -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))
})
})

0 comments on commit 8e64b1a

Please sign in to comment.