diff --git a/go.mod b/go.mod index b76d3770..994f0708 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.27.0 toolchain go1.27.1 require ( + charm.land/bubbletea/v2 v2.0.6 charm.land/glamour/v2 v2.0.1 charm.land/huh/v2 v2.0.3 code.gitea.io/gitea-vet v0.2.3 @@ -30,7 +31,6 @@ require ( require ( charm.land/bubbles/v2 v2.1.0 // indirect - charm.land/bubbletea/v2 v2.0.6 // indirect charm.land/lipgloss/v2 v2.0.5 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect diff --git a/internal/app/editor.go b/internal/app/editor.go index b00c3f5c..68a6d559 100644 --- a/internal/app/editor.go +++ b/internal/app/editor.go @@ -49,7 +49,17 @@ func (a *application) editDraft(cmd *cobra.Command, body string) (terminal.Draft return editor.Edit(cmd.Context(), command, body) } +// bodyEditor collects the body of an interactive form after the form ran. The editor is resolved +// first: without one, the body is typed into an inline multi-line field instead, so the input +// already given is never lost to a missing editor. func (a *application) bodyEditor(cmd *cobra.Command) terminal.BodyEditor { + preferences, err := a.preferences() + if asker, ok := a.deps.Prompter.(terminal.Asker); ok && err == nil && preferences.Editor == "" { + return func(ctx context.Context, initial string) (terminal.Draft, error) { + body, err := asker.AskText(ctx, terminal.BodyTitle, initial) + return terminal.Draft{Body: body}, err + } + } return func(_ context.Context, initial string) (terminal.Draft, error) { return a.editDraft(cmd, initial) } } diff --git a/internal/app/editor_test.go b/internal/app/editor_test.go index 3f537533..1309dc63 100644 --- a/internal/app/editor_test.go +++ b/internal/app/editor_test.go @@ -13,6 +13,7 @@ import ( "gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/services" + "gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/transport" "github.com/stretchr/testify/require" ) @@ -308,3 +309,30 @@ func TestReviewLifecycleEditorRejectsEmptyRequiredText(t *testing.T) { }) } } + +func TestBodyEditorTypesInlineWithoutAnEditor(t *testing.T) { + deps, _, _ := testDeps(t) + prompt := &askingPrompter{} + deps.Prompter = prompt + a, cmd := outputApplication(deps, "") + draft, err := a.bodyEditor(cmd)(context.Background(), "## Steps") + require.NoError(t, err) + require.Equal(t, terminal.Draft{Body: "typed body"}, draft) + require.Equal(t, []string{terminal.BodyTitle + " ## Steps"}, prompt.texts) + + editor, path := newDraftEditor(t, "edited body") + deps.Editor = editor + deps.Getenv = envFunc(map[string]string{"EDITOR": "vi"}) + a, cmd = outputApplication(deps, "") + draft, err = a.bodyEditor(cmd)(context.Background(), "## Steps") + require.NoError(t, err) + require.Equal(t, terminal.Draft{Path: path, Body: "edited body"}, draft) + require.Equal(t, "## Steps", editor.initial) + require.Len(t, prompt.texts, 1) + + deps.Prompter = &scriptedPrompter{} + deps.Getenv = envFunc(nil) + a, cmd = outputApplication(deps, "") + _, err = a.bodyEditor(cmd)(context.Background(), "") + require.ErrorContains(t, err, "configure an editor") +} diff --git a/internal/app/prompts.go b/internal/app/prompts.go new file mode 100644 index 00000000..79fd9dc8 --- /dev/null +++ b/internal/app/prompts.go @@ -0,0 +1,31 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package app + +import ( + "gitea.dev/tea/internal/terminal" + "github.com/spf13/cobra" +) + +// confirmAction asks c under the prompt policy unless --yes was supplied. Constructive actions set +// DefaultYes, destructive ones keep the default no, and irreversible ones set Typed. A declined +// confirmation returns terminal.ErrCanceled. +func (a *application) confirmAction(cmd *cobra.Command, c terminal.Confirmation) error { + s, err := a.deps.Settings.Load() + if err != nil { + return err + } + return terminal.ConfirmAction(cmd.Context(), c, a.options.yes, a.deps.Streams, a.policy(s), a.deps.Prompter) +} + +// requireInput fills a missing value from a prompt titled label, such as "Label name", re-prompting +// until the answer is non-empty and validate, when set, accepts it. Without prompts it returns a +// usage error naming --flag. It reports whether it prompted. +func (a *application) requireInput(cmd *cobra.Command, value *string, flag, label string, validate func(string) error) (bool, error) { + s, err := a.deps.Settings.Load() + if err != nil { + return false, err + } + return terminal.RequireInput(cmd.Context(), value, flag, label, validate, a.deps.Streams, a.policy(s), a.deps.Prompter) +} diff --git a/internal/app/prompts_test.go b/internal/app/prompts_test.go new file mode 100644 index 00000000..6f992626 --- /dev/null +++ b/internal/app/prompts_test.go @@ -0,0 +1,75 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package app + +import ( + "context" + "testing" + + "gitea.dev/tea/internal/clierr" + "gitea.dev/tea/internal/terminal" + "github.com/stretchr/testify/require" +) + +// askingPrompter is scriptedPrompter with the terminal.Asker extension that FormPrompter has. +type askingPrompter struct { + scriptedPrompter + texts []string + defaults []bool +} + +func (p *askingPrompter) Ask(ctx context.Context, title string, _ func(string) error) (string, error) { + return p.Input(ctx, title) +} + +func (p *askingPrompter) AskText(_ context.Context, title, initial string) (string, error) { + p.texts = append(p.texts, title+" "+initial) + return "typed body", nil +} + +func (p *askingPrompter) ConfirmDefault(ctx context.Context, title string, defaultYes bool) (bool, error) { + p.defaults = append(p.defaults, defaultYes) + return p.Confirm(ctx, title) +} + +func TestConfirmActionFollowsThePromptPolicy(t *testing.T) { + c := terminal.Confirmation{Prompt: `Delete label "bug" from alice/x?`, NonTTY: `delete label "bug" from alice/x`} + deps, _, _ := testDeps(t) + a, cmd := outputApplication(deps, "") + err := a.confirmAction(cmd, c) + require.Equal(t, clierr.Usage, clierr.ExitCode(err)) + require.EqualError(t, err, `confirmation required to delete label "bug" from alice/x; rerun with --yes`) + a.options.yes = true + require.NoError(t, a.confirmAction(cmd, c)) + + prompt := &askingPrompter{} + deps.Prompter = prompt + deps.Streams.InTTY, deps.Streams.ErrTTY = true, true + a, cmd = outputApplication(deps, "") + err = a.confirmAction(cmd, c) + require.ErrorIs(t, err, terminal.ErrCanceled) + require.Equal(t, clierr.Canceled, clierr.ExitCode(err)) + prompt.accept = true + require.NoError(t, a.confirmAction(cmd, terminal.Confirmation{Prompt: "Create pull request?", DefaultYes: true})) + require.Equal(t, []string{c.Prompt, "Create pull request?"}, prompt.confirmations) + require.Equal(t, []bool{false, true}, prompt.defaults) +} + +func TestRequireInputPromptsWithTheLabel(t *testing.T) { + prompt := &scriptedPrompter{input: "bug"} + deps, _, _ := testDeps(t) + deps.Prompter = prompt + a, cmd := outputApplication(deps, "") + name := "" + _, err := a.requireInput(cmd, &name, "name", "Label name", nil) + require.EqualError(t, err, "missing required input: supply --name") + + deps.Streams.InTTY, deps.Streams.ErrTTY = true, true + a, cmd = outputApplication(deps, "") + asked, err := a.requireInput(cmd, &name, "name", "Label name", nil) + require.NoError(t, err) + require.True(t, asked) + require.Equal(t, "bug", name) + require.Equal(t, []string{"Label name"}, prompt.asked) +} diff --git a/internal/terminal/form_fields.go b/internal/terminal/form_fields.go new file mode 100644 index 00000000..de851c93 --- /dev/null +++ b/internal/terminal/form_fields.go @@ -0,0 +1,29 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import "charm.land/huh/v2" + +// BodyTitle labels the markdown body of issues, pull requests and comments. +const BodyTitle = "Body (markdown):" + +// maxVisibleOptions bounds how many options a multi-select shows before it scrolls. +const maxVisibleOptions = 8 + +// titleField is the required one-line title of an issue, pull request or similar resource. +func titleField(value *string) *huh.Input { + return huh.NewInput().Title("Title (required):").Value(value).Validate(huh.ValidateNotEmpty()) +} + +// bodyField is an inline multi-line markdown field. It has no external editor: forms run the +// configured editor themselves, with private drafts, and never fall back to an unconfigured one. +func bodyField(title string, value *string) *huh.Text { + return huh.NewText().Title(title).Value(value).ExternalEditor(false) +} + +// multiSelect lists every option, scrolling past maxVisibleOptions. Without an explicit height, +// huh sizes the list to its options minus the title line and hides the last option. +func multiSelect[T comparable](title string, options []huh.Option[T], value *[]T) *huh.MultiSelect[T] { + return huh.NewMultiSelect[T]().Title(title).Options(options...).Value(value).Height(min(len(options), maxVisibleOptions) + 1) +} diff --git a/internal/terminal/form_fields_test.go b/internal/terminal/form_fields_test.go new file mode 100644 index 00000000..17ad48a2 --- /dev/null +++ b/internal/terminal/form_fields_test.go @@ -0,0 +1,68 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "context" + "fmt" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "gitea.dev/tea/internal/clierr" + "github.com/stretchr/testify/require" +) + +// view renders form after the terminal reports width×height. +func view(form *huh.Form, width, height int) string { + form.Init() + form.Update(tea.WindowSizeMsg{Width: width, Height: height}) + return form.View() +} + +func TestMultiSelectShowsEveryOption(t *testing.T) { + for _, count := range []int{1, 2, 5, maxVisibleOptions, 12} { + t.Run(fmt.Sprint(count), func(t *testing.T) { + var options []string + for i := range count { + options = append(options, fmt.Sprintf("user%02d", i)) + } + var selected []string + rendered := view(huh.NewForm(huh.NewGroup(multiSelect("Assignees:", huh.NewOptions(options...), &selected))), 80, 24) + for i, option := range options { + if i < maxVisibleOptions { + require.Contains(t, rendered, option) + } else { + require.NotContains(t, rendered, option, "options past the first page scroll") + } + } + }) + } +} + +func TestFormsRefuseTerminalsWithoutSize(t *testing.T) { + for _, size := range [][2]int{{0, 0}, {80, 0}, {0, 24}} { + p, rendered := testPrompter(strings.NewReader("Title\r")) + p.size = func() (int, int, error) { return size[0], size[1], nil } + _, err := p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit}) + require.ErrorIs(t, err, errNoTerminalSize, "%v", size) + require.Equal(t, clierr.Usage, clierr.ExitCode(err)) + require.Empty(t, rendered.String()) + } + p, _ := testPrompter(strings.NewReader("Title\r")) + p.size = func() (int, int, error) { return 80, 24, nil } + values, err := p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit}) + require.NoError(t, err) + require.Equal(t, "Title", values.Title) +} + +func TestPullFormNamesNoIssueFields(t *testing.T) { + p, rendered := accessiblePrompter(t, "n\nmain\nalice\nfeature\ny\nTitle\nBody\n") + _, err := p.CreatePull(context.Background(), PullFormChoices{}) + require.NoError(t, err) + require.Contains(t, rendered.String(), "Title (required):") + require.Contains(t, rendered.String(), BodyTitle) + require.NotContains(t, rendered.String(), "Issue") +} diff --git a/internal/terminal/issue_form.go b/internal/terminal/issue_form.go index c09d40ac..2e41186a 100644 --- a/internal/terminal/issue_form.go +++ b/internal/terminal/issue_form.go @@ -98,18 +98,19 @@ type issueFields struct { labels []int64 } -// groups builds title/body and, when canPush, assignee/milestone/label/due-date fields. +// groups builds title/body and, when canPush, assignee/milestone/label/due-date fields. The labels +// name no resource, as issues and pull requests share them. func (f *issueFields) groups(editBody BodyEditor, canPush bool, assignees []string, milestones []IssueMilestoneChoice, labels []IssueLabelChoice) []*huh.Group { - fields := []huh.Field{huh.NewInput().Title("Issue title (required):").Value(&f.title).Validate(huh.ValidateNotEmpty())} + fields := []huh.Field{titleField(&f.title)} if editBody == nil { - fields = append(fields, huh.NewText().Title("Issue description(markdown):").Value(&f.body)) + fields = append(fields, bodyField(BodyTitle, &f.body)) } groups := []*huh.Group{huh.NewGroup(fields...)} if !canPush { return groups } if len(assignees) > 0 { - groups = append(groups, huh.NewGroup(huh.NewMultiSelect[string]().Title("Assignees:").Options(huh.NewOptions(assignees...)...).Value(&f.assignees))) + groups = append(groups, huh.NewGroup(multiSelect("Assignees:", huh.NewOptions(assignees...), &f.assignees))) } if len(milestones) > 0 { options := []huh.Option[int64]{huh.NewOption("[none]", int64(0))} @@ -123,7 +124,7 @@ func (f *issueFields) groups(editBody BodyEditor, canPush bool, assignees []stri for _, label := range labels { options = append(options, huh.NewOption(label.Name, label.ID)) } - groups = append(groups, huh.NewGroup(huh.NewMultiSelect[int64]().Title("Labels:").Options(options...).Value(&f.labels))) + groups = append(groups, huh.NewGroup(multiSelect("Labels:", options, &f.labels))) } return append(groups, huh.NewGroup(dueDateInput("Due date:", &f.deadline))) } diff --git a/internal/terminal/issue_form_test.go b/internal/terminal/issue_form_test.go index e8c83022..393f0340 100644 --- a/internal/terminal/issue_form_test.go +++ b/internal/terminal/issue_form_test.go @@ -32,5 +32,5 @@ func TestIssueFormMarksTitleRequired(t *testing.T) { p, rendered := accessiblePrompter(t, "Title\n") _, err := p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit}) require.NoError(t, err) - require.Contains(t, rendered.String(), "Issue title (required):") + require.Contains(t, rendered.String(), "Title (required):") } diff --git a/internal/terminal/pull_form_test.go b/internal/terminal/pull_form_test.go index d1ed38b2..bbafaf17 100644 --- a/internal/terminal/pull_form_test.go +++ b/internal/terminal/pull_form_test.go @@ -35,7 +35,7 @@ func TestPullFormMarksRequiredFields(t *testing.T) { { "without head", "n\nmain\nalice\nfeature\ny\nTitle\n", PullFormChoices{}, - []string{"Target branch (required):", "Source branch (required):", "Issue title (required):"}, + []string{"Target branch (required):", "Source branch (required):", "Title (required):"}, nil, }, { diff --git a/internal/terminal/review_form.go b/internal/terminal/review_form.go index 532b4681..6c18563c 100644 --- a/internal/terminal/review_form.go +++ b/internal/terminal/review_form.go @@ -77,9 +77,9 @@ func (p *FormPrompter) CreateReview(ctx context.Context, choices ReviewFormChoic return values, err } } else { - field := huh.NewText().Title("Concluding comment(markdown):").Value(&body) + field := bodyField("Concluding comment (markdown):", &body) if required { - field = field.Title("Concluding comment(markdown) (required):").Validate(huh.ValidateNotEmpty()) + field = field.Title("Concluding comment (markdown, required):").Validate(huh.ValidateNotEmpty()) } if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(field))); err != nil { return ReviewFormValues{}, err diff --git a/internal/terminal/review_form_test.go b/internal/terminal/review_form_test.go index 5b317012..50bc4a06 100644 --- a/internal/terminal/review_form_test.go +++ b/internal/terminal/review_form_test.go @@ -24,11 +24,11 @@ func TestReviewFormRequiresCommentOnlyWhenRequestingChanges(t *testing.T) { p, rendered := accessiblePrompter(t, "3\n") _, err := p.CreateReview(context.Background(), ReviewFormChoices{}) require.NoError(t, err) - require.Contains(t, rendered.String(), "Concluding comment(markdown):") - require.NotContains(t, rendered.String(), "(required)") + require.Contains(t, rendered.String(), "Concluding comment (markdown):") + require.NotContains(t, rendered.String(), "required") p, rendered = accessiblePrompter(t, "2\n") _, err = p.CreateReview(context.Background(), ReviewFormChoices{}) require.ErrorContains(t, err, "concluding comment cannot be empty") - require.Contains(t, rendered.String(), "Concluding comment(markdown) (required):") + require.Contains(t, rendered.String(), "Concluding comment (markdown, required):") } diff --git a/internal/terminal/streams.go b/internal/terminal/streams.go index 724e81bc..107b40ba 100644 --- a/internal/terminal/streams.go +++ b/internal/terminal/streams.go @@ -66,6 +66,10 @@ func (p Policy) Validate(s Streams) error { return nil } +// ErrCanceled reports a declined confirmation or an aborted prompt. It exits with code 3 and is +// meant to be reported as "Canceled." rather than as an error. +var ErrCanceled = clierr.Wrap(clierr.Canceled, errors.New("canceled")) + // Prompter abstracts cancellable input collection and confirmation. type Prompter interface { Input(context.Context, string) (string, error) @@ -73,30 +77,76 @@ type Prompter interface { Confirm(context.Context, string) (bool, error) } +// Asker is the richer prompt API behind ConfirmAction and RequireInput: inline validation, +// multi-line text and confirmations with a default answer. FormPrompter implements it; a +// Prompter without it gets plain Input and Confirm behavior. +type Asker interface { + // Ask collects one line, re-prompting inline until validate accepts it. + Ask(ctx context.Context, title string, validate func(string) error) (string, error) + // AskText collects multi-line markdown, starting from initial. + AskText(ctx context.Context, title, initial string) (string, error) + // ConfirmDefault asks a yes/no question that Enter answers with defaultYes. + ConfirmDefault(ctx context.Context, title string, defaultYes bool) (bool, error) +} + // FormPrompter uses cancellable terminal forms with explicitly injected streams. -type FormPrompter struct{ Streams Streams } +type FormPrompter struct { + Streams Streams + // size reports the size of the terminal that forms render on; nil queries ErrOut. + size func() (width, height int, err error) +} func promptError(ctx context.Context, err error) error { if ctx.Err() != nil { return ctx.Err() } if errors.Is(err, huh.ErrUserAborted) { - return clierr.Wrap(clierr.Canceled, errors.New("input canceled")) + return ErrCanceled } return err } +// errNoTerminalSize refuses forms on a terminal without a size, such as a pty that `script` opens +// without a terminal. Forms size their fields from it, and a zero size panics in bubbles; nobody +// could see the form there anyway. +var errNoTerminalSize = clierr.Wrap(clierr.Usage, errors.New("terminal size unknown; pass the values as flags or use --no-input")) + func (p *FormPrompter) runForm(ctx context.Context, form *huh.Form) error { + size := p.size + if file, ok := p.Streams.ErrOut.(*os.File); ok && size == nil { + size = func() (int, int, error) { return term.GetSize(int(file.Fd())) } + } + if size != nil { + if width, height, err := size(); err == nil && (width <= 0 || height <= 0) { + return errNoTerminalSize + } + } return promptError(ctx, form.WithInput(p.Streams.In).WithOutput(p.Streams.ErrOut).RunWithContext(ctx)) } // Input collects one field using the configured terminal streams. func (p *FormPrompter) Input(ctx context.Context, title string) (string, error) { + return p.Ask(ctx, title, nil) +} + +// Ask collects one line, re-prompting inline until validate accepts the trimmed answer. +func (p *FormPrompter) Ask(ctx context.Context, title string, validate func(string) error) (string, error) { var value string - err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewInput().Title(title).Value(&value)))) + input := huh.NewInput().Title(title).Value(&value) + if validate != nil { + input = input.Validate(func(s string) error { return validate(strings.TrimSpace(s)) }) + } + err := p.runForm(ctx, huh.NewForm(huh.NewGroup(input))) return strings.TrimSpace(value), err } +// AskText collects multi-line markdown in an inline field, starting from initial. +func (p *FormPrompter) AskText(ctx context.Context, title, initial string) (string, error) { + value := initial + err := p.runForm(ctx, huh.NewForm(huh.NewGroup(bodyField(title, &value)))) + return value, err +} + // SecretInput collects a value without displaying it on the terminal. func (p *FormPrompter) SecretInput(ctx context.Context, title string) (string, error) { var value string @@ -104,46 +154,122 @@ func (p *FormPrompter) SecretInput(ctx context.Context, title string) (string, e return value, err } -// Confirm collects a yes/no choice through cancellable terminal input. +// Confirm asks a yes/no question that Enter answers with no. func (p *FormPrompter) Confirm(ctx context.Context, title string) (bool, error) { - var accepted bool + return p.ConfirmDefault(ctx, title, false) +} + +// ConfirmDefault asks a yes/no question that Enter answers with defaultYes. +func (p *FormPrompter) ConfirmDefault(ctx context.Context, title string, defaultYes bool) (bool, error) { + accepted := defaultYes err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewConfirm().Title(title).Value(&accepted)))) return accepted, err } -// Require fills missing input or returns an actionable usage error. -func Require(ctx context.Context, value *string, flag string, streams Streams, policy Policy, prompt Prompter) (bool, error) { +// RequireInput fills a missing value from a prompt titled label, re-prompting inline until the +// answer is non-empty and validate, when set, accepts it. When prompts are unavailable it returns +// a usage error naming --flag. It reports whether it prompted. +func RequireInput(ctx context.Context, value *string, flag, label string, validate func(string) error, streams Streams, policy Policy, prompt Prompter) (bool, error) { if *value != "" { return false, nil } if !policy.CanPrompt(streams) { return false, clierr.Wrap(clierr.Usage, fmt.Errorf("missing required input: supply --%s", flag)) } - entered, err := prompt.Input(ctx, flag) + var entered string + var err error + if asker, ok := prompt.(Asker); ok { + entered, err = asker.Ask(ctx, label, func(s string) error { + if s == "" { + return errors.New("a value is required") + } + if validate != nil { + return validate(s) + } + return nil + }) + } else if entered, err = prompt.Input(ctx, label); err == nil { + switch { + case entered == "": + err = clierr.Wrap(clierr.Usage, fmt.Errorf("--%s cannot be empty", flag)) + case validate != nil: + if invalid := validate(entered); invalid != nil { + err = clierr.Wrap(clierr.Usage, fmt.Errorf("--%s: %w", flag, invalid)) + } + } + } if err != nil { return false, err } - if entered == "" { - return false, clierr.Wrap(clierr.Usage, fmt.Errorf("--%s cannot be empty", flag)) - } *value = entered return true, nil } -// Confirm requires explicit acceptance unless confirmation was supplied by flag. -func Confirm(ctx context.Context, description string, yes bool, streams Streams, policy Policy, prompt Prompter) error { +// Require is RequireInput titled by the flag name, for callers without a human label. +func Require(ctx context.Context, value *string, flag string, streams Streams, policy Policy, prompt Prompter) (bool, error) { + return RequireInput(ctx, value, flag, flag, nil, streams, policy, prompt) +} + +// Confirmation describes the question asked before an action. +type Confirmation struct { + // Prompt asks about the action in sentence case: `Delete label "bug" from alice/x?`. + Prompt string + // DefaultYes lets Enter accept, for constructive actions the user just asked for, such as + // submitting a form they filled in. Destructive actions keep the default no. + DefaultYes bool + // Typed, when set, must be typed to confirm, for irreversible actions such as deleting a repository. + Typed string + // NonTTY names the action in the error returned when no prompt is possible and --yes is + // missing: `delete label "bug" from alice/x`. + NonTTY string +} + +// ConfirmAction asks c unless yes is set. A declined or aborted confirmation returns ErrCanceled. +func ConfirmAction(ctx context.Context, c Confirmation, yes bool, streams Streams, policy Policy, prompt Prompter) error { if yes { return nil } if !policy.CanPrompt(streams) { - return clierr.Wrap(clierr.Usage, errors.New("confirmation required: supply --yes")) + action := "" + if c.NonTTY != "" { + action = " to " + c.NonTTY + } + return clierr.Wrap(clierr.Usage, fmt.Errorf("confirmation required%s; rerun with --yes", action)) } - accepted, err := prompt.Confirm(ctx, description) - if err != nil { + asker, rich := prompt.(Asker) + if c.Typed != "" { + title := c.Prompt + " Type " + c.Typed + " to confirm:" + matches := func(s string) bool { return strings.EqualFold(strings.TrimSpace(s), c.Typed) } + if rich { + _, err := asker.Ask(ctx, title, func(s string) error { + if !matches(s) { + return fmt.Errorf("type %s to confirm", c.Typed) + } + return nil + }) + return err + } + typed, err := prompt.Input(ctx, title) + if err == nil && !matches(typed) { + err = ErrCanceled + } return err } - if !accepted { - return clierr.Wrap(clierr.Canceled, errors.New("operation canceled")) + var accepted bool + var err error + if rich { + accepted, err = asker.ConfirmDefault(ctx, c.Prompt, c.DefaultYes) + } else { + accepted, err = prompt.Confirm(ctx, c.Prompt) } - return nil + if err == nil && !accepted { + err = ErrCanceled + } + return err +} + +// Confirm requires explicit acceptance unless confirmation was supplied by flag. +// It is ConfirmAction for callers that predate Confirmation. +func Confirm(ctx context.Context, description string, yes bool, streams Streams, policy Policy, prompt Prompter) error { + return ConfirmAction(ctx, Confirmation{Prompt: description}, yes, streams, policy, prompt) } diff --git a/internal/terminal/streams_test.go b/internal/terminal/streams_test.go index ff4248bc..502dfceb 100644 --- a/internal/terminal/streams_test.go +++ b/internal/terminal/streams_test.go @@ -6,10 +6,13 @@ package terminal import ( "bytes" "context" + "errors" "io" "strings" "testing" + "charm.land/huh/v2" + "gitea.dev/tea/internal/clierr" "github.com/stretchr/testify/require" ) @@ -61,3 +64,139 @@ func TestPromptPolicy(t *testing.T) { _, err = Require(context.Background(), &value, "title", Streams{}, Policy{}, prompt) require.ErrorContains(t, err, "--title") } + +// askingPrompt answers Asker prompts from scripted answers. Ask skips answers that validate rejects, +// as an inline re-prompt does, and cancels when none is left. +type askingPrompt struct { + fakePrompt + answers, rejected, titles []string + accept bool + defaults []bool +} + +func (p *askingPrompt) Ask(_ context.Context, title string, validate func(string) error) (string, error) { + p.titles = append(p.titles, title) + for len(p.answers) > 0 { + answer := p.answers[0] + p.answers = p.answers[1:] + if validate == nil || validate(answer) == nil { + return answer, nil + } + p.rejected = append(p.rejected, answer) + } + return "", ErrCanceled +} + +func (p *askingPrompt) AskText(_ context.Context, title, initial string) (string, error) { + p.titles = append(p.titles, title) + return initial + " edited", nil +} + +func (p *askingPrompt) ConfirmDefault(_ context.Context, title string, defaultYes bool) (bool, error) { + p.titles = append(p.titles, title) + p.defaults = append(p.defaults, defaultYes) + return p.accept, nil +} + +func TestConfirmAction(t *testing.T) { + terminal := Streams{InTTY: true, ErrTTY: true} + deleteLabel := Confirmation{Prompt: `Delete label "bug" from alice/x?`, NonTTY: `delete label "bug" from alice/x`} + typed := Confirmation{Prompt: "Delete alice/x?", Typed: "alice/x"} + for _, tc := range []struct { + name string + c Confirmation + yes bool + streams Streams + prompt Prompter + code int + message string + rejected []string + }{ + {name: "accepted by flag", c: deleteLabel, yes: true, prompt: &fakePrompt{}}, + {name: "names the action without a terminal", c: deleteLabel, prompt: &fakePrompt{}, code: clierr.Usage, message: `confirmation required to delete label "bug" from alice/x; rerun with --yes`}, + {name: "without a terminal or action", c: Confirmation{Prompt: "Go?"}, prompt: &fakePrompt{}, code: clierr.Usage, message: "confirmation required; rerun with --yes"}, + {name: "accepted", c: deleteLabel, streams: terminal, prompt: &askingPrompt{accept: true}}, + {name: "declined", c: deleteLabel, streams: terminal, prompt: &askingPrompt{}, code: clierr.Canceled, message: "canceled"}, + {name: "declined to a plain prompter", c: deleteLabel, streams: terminal, prompt: &fakePrompt{}, code: clierr.Canceled, message: "canceled"}, + {name: "re-prompts until the name is typed", c: typed, streams: terminal, prompt: &askingPrompt{answers: []string{"alice/y", " Alice/X "}}, rejected: []string{"alice/y"}}, + {name: "typed wrong to a plain prompter", c: typed, streams: terminal, prompt: &fakePrompt{}, code: clierr.Canceled, message: "canceled"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := ConfirmAction(context.Background(), tc.c, tc.yes, tc.streams, Policy{Enabled: true}, tc.prompt) + require.Equal(t, tc.code, clierr.ExitCode(err), "%v", err) + if tc.message != "" { + require.EqualError(t, err, tc.message) + } + if tc.code == clierr.Canceled { + require.ErrorIs(t, err, ErrCanceled) + } + if asker, ok := tc.prompt.(*askingPrompt); ok { + require.Equal(t, tc.rejected, asker.rejected) + } + }) + } +} + +func TestConfirmActionPassesTheDefault(t *testing.T) { + prompt := &askingPrompt{accept: true} + streams := Streams{InTTY: true, ErrTTY: true} + require.NoError(t, ConfirmAction(context.Background(), Confirmation{Prompt: "Create pull request?", DefaultYes: true}, false, streams, Policy{Enabled: true}, prompt)) + require.NoError(t, Confirm(context.Background(), "Delete branch?", false, streams, Policy{Enabled: true}, prompt)) + require.Equal(t, []bool{true, false}, prompt.defaults) + require.Equal(t, []string{"Create pull request?", "Delete branch?"}, prompt.titles) +} + +func TestRequireInput(t *testing.T) { + streams, policy := Streams{InTTY: true, ErrTTY: true}, Policy{Enabled: true} + hex := func(s string) error { + if len(s) != 6 { + return errors.New("use six hex digits") + } + return nil + } + + asker := &askingPrompt{answers: []string{"", "red", "d73a4a"}} + value := "" + asked, err := RequireInput(context.Background(), &value, "color", "Color (hex)", hex, streams, policy, asker) + require.NoError(t, err) + require.True(t, asked) + require.Equal(t, "d73a4a", value) + require.Equal(t, []string{"", "red"}, asker.rejected) + require.Equal(t, []string{"Color (hex)"}, asker.titles) + + value = "" + _, err = RequireInput(context.Background(), &value, "color", "Color (hex)", hex, streams, policy, &fakePrompt{}) + require.Equal(t, clierr.Usage, clierr.ExitCode(err)) + require.EqualError(t, err, "--color: use six hex digits") + + _, err = RequireInput(context.Background(), &value, "color", "Color (hex)", hex, Streams{}, policy, asker) + require.EqualError(t, err, "missing required input: supply --color") +} + +func TestFormPrompterConfirmDefault(t *testing.T) { + for _, defaultYes := range []bool{true, false} { + p, _ := accessiblePrompter(t, "\n") + accepted, err := p.ConfirmDefault(context.Background(), "Submit?", defaultYes) + require.NoError(t, err) + require.Equal(t, defaultYes, accepted) + } +} + +func TestFormPrompterAskRepromptsInline(t *testing.T) { + p, rendered := accessiblePrompter(t, "\n bug \n") + value, err := p.Ask(context.Background(), "Label name", func(s string) error { + if s == "" { + return errors.New("a value is required") + } + return nil + }) + require.NoError(t, err) + require.Equal(t, "bug", value) + require.Contains(t, rendered.String(), "a value is required") +} + +func TestPromptAbortIsCanceled(t *testing.T) { + err := promptError(context.Background(), huh.ErrUserAborted) + require.ErrorIs(t, err, ErrCanceled) + require.Equal(t, clierr.Canceled, clierr.ExitCode(err)) +}