Skip to content
Merged
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
1 change: 1 addition & 0 deletions .nextchanges/cli/prompt-blank-glyph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Prompts that validate their input, such as the `databricks configure` host prompt, no longer open with a red cross before anything is typed. ([#7314](https://github.com/databricks/cli/pull/7314))
87 changes: 87 additions & 0 deletions libs/cmdio/cmdiotest/prompt_validate_baseline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,90 @@ func TestPromptBaseline_Validate(t *testing.T) {
require.NoError(t, err, "raw output: %q", tm.Raw())
assert.Equal(t, "https://example.com", v, "snapshot:\n%s", tm.Snapshot())
}

// TestPromptBaseline_ValidateEnterBeforeEdit pins that Enter on an untouched
// prompt flips the glyph to "✗" along with the error line.
func TestPromptBaseline_ValidateEnterBeforeEdit(t *testing.T) {
Comment thread
maksim-zuev-db marked this conversation as resolved.
Resolved
t.Parallel()
tm := termtest.NewPrompt(t, cmdio.PromptOptions{
Label: "Workspace host",
Validate: func(s string) error {
if s == "" {
return errors.New("host is required")
}
return nil
},
})
tm.WaitFor("Workspace host")
tm.Type(termtest.KeyEnter)
tm.Golden("01-enter")

tm.Type(termtest.KeyCtrlC)
_, err := tm.Result()
require.Error(t, err)
assert.EqualError(t, err, "^C")
}

// TestPromptBaseline_ValidateValidBeforeEdit pins that an untouched prompt
// whose input already passes Validate shows "✔".
func TestPromptBaseline_ValidateValidBeforeEdit(t *testing.T) {
t.Parallel()
tm := termtest.NewPrompt(t, cmdio.PromptOptions{
Label: "Workspace host",
Validate: func(s string) error {
if strings.Contains(s, " ") {
return errors.New("must not contain spaces")
}
return nil
},
})
tm.WaitFor("Workspace host")
tm.Golden("01-initial")

tm.Type(termtest.KeyEnter)
v, err := tm.Result()
require.NoError(t, err, "raw output: %q", tm.Raw())
assert.Empty(t, v)
}

// TestPromptBaseline_ValidateNonEditKeysKeepBlankGlyph pins that keys which
// leave the buffer unchanged do not flip the blank glyph to "✗".
func TestPromptBaseline_ValidateNonEditKeysKeepBlankGlyph(t *testing.T) {
t.Parallel()
tm := termtest.NewPrompt(t, cmdio.PromptOptions{
Label: "Workspace host",
Validate: func(s string) error {
if s == "" {
return errors.New("host is required")
}
return nil
},
})
tm.WaitFor("Workspace host")
tm.Type(termtest.KeyLeft + termtest.KeyRight + termtest.KeyBackspace + termtest.KeyCtrlC)
_, err := tm.Result()
assert.EqualError(t, err, "^C")
// The prompt line is erased on exit, so check every rendered frame.
assert.NotContains(t, tm.Raw(), "✗")
}

// TestPromptBaseline_ValidateEmptyPasteKeepsBlankGlyph pins that an empty
// bracketed paste, which some terminals send for an empty clipboard, is not
// treated as an edit.
func TestPromptBaseline_ValidateEmptyPasteKeepsBlankGlyph(t *testing.T) {
t.Parallel()
tm := termtest.NewPrompt(t, cmdio.PromptOptions{
Label: "Workspace host",
Validate: func(s string) error {
if s == "" {
return errors.New("host is required")
}
return nil
},
})
tm.WaitFor("Workspace host")
tm.Type("\x1b[200~\x1b[201~" + termtest.KeyCtrlC)
_, err := tm.Result()
assert.EqualError(t, err, "^C")
assert.NotContains(t, tm.Raw(), "✗")
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
✗ Workspace host: █
Workspace host: █
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
✗ Workspace host: █
>> host is required
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
✔ Workspace host: █
32 changes: 26 additions & 6 deletions libs/cmdio/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import (
)

// Glyphs drawn into the prompt's rendered output. cursorBlock stands in for
// the (hidden) OS cursor at the current edit position.
// the (hidden) OS cursor at the current edit position. The glyphs must all be
// one column wide so the label does not shift when the glyph changes.
const (
cursorBlock = "█"
glyphValid = "✔"
glyphInvalid = "✗"
glyphBlank = " "
)

// PromptOptions configures a single-line text prompt shown by [RunPrompt].
Expand All @@ -33,7 +35,9 @@ type PromptOptions struct {
// Validate, when set, is called on every keystroke. While it returns a
// non-nil error the leading glyph flips from "✔" to "✗" and Enter is
// inert; pressing Enter while invalid surfaces the returned error
// below the prompt until the next edit.
// below the prompt until the next edit. Until the first edit or Enter,
// an invalid input shows a blank glyph instead, so an empty prompt does
// not open as an error.
Validate func(input string) error
}

Expand Down Expand Up @@ -73,6 +77,10 @@ type promptModel struct {
deleted bool
submitted bool

// touched is set by the first edit or Enter. Until then an invalid input
// shows a blank glyph, so a required field does not open as an error.
touched bool

// submitErr is the error from the last failed Enter attempt. Rendered
// below the prompt and cleared on the next edit.
submitErr error
Expand All @@ -92,12 +100,16 @@ func (m *promptModel) value() string {
}

// glyph returns the leading status indicator: bold-green ✔ when valid,
// bold-red ✗ when validate rejects the buffer.
// bold-red ✗ when validate rejects the buffer after an edit or Enter, and a
// blank before that.
func (m *promptModel) glyph() string {
if m.validate != nil && m.validate(m.value()) != nil {
return ansiBold + ansiRed + glyphInvalid + ansiReset
if m.validate == nil || m.validate(m.value()) == nil {
return ansiBold + ansiGreen + glyphValid + ansiReset
}
if !m.touched {
return glyphBlank
}
return ansiBold + ansiGreen + glyphValid + ansiReset
return ansiBold + ansiRed + glyphInvalid + ansiReset
}

func (m *promptModel) Init() tea.Cmd { return nil }
Expand All @@ -115,6 +127,7 @@ func (m *promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

case tea.KeyEnter, tea.KeyCtrlJ:
// Enter sends CR, Ctrl+J sends LF; treat both as submit.
m.touched = true
if m.validate != nil {
if err := m.validate(m.value()); err != nil {
m.submitErr = err
Expand Down Expand Up @@ -168,10 +181,17 @@ func (m *promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key.Type == tea.KeySpace {
typed = []rune{' '}
}
// Some terminals send an empty bracketed paste when the clipboard is
// empty. Nothing changes, so it must not count as an edit or clear the
// error line.
if len(typed) == 0 {
return m, nil
}
tail := append([]rune{}, m.runes[m.cursor:]...)
m.runes = append(m.runes[:m.cursor], typed...)
m.runes = append(m.runes, tail...)
m.cursor += len(typed)
m.touched = true
m.submitErr = nil

default:
Expand Down
Loading