diff --git a/internal/prompter/huh_prompter.go b/internal/prompter/huh_prompter.go index 40a27d507..c6bec9fb3 100644 --- a/internal/prompter/huh_prompter.go +++ b/internal/prompter/huh_prompter.go @@ -1,10 +1,12 @@ package prompter import ( + "errors" "fmt" "slices" "charm.land/huh/v2" + "github.com/AlecAivazis/survey/v2/terminal" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/surveyext" ghPrompter "github.com/cli/go-gh/v2/pkg/prompter" @@ -24,6 +26,18 @@ func (p *huhPrompter) newForm(groups ...*huh.Group) *huh.Form { WithOutput(p.stdout) } +func (p *huhPrompter) runForm(form *huh.Form) error { + err := form.Run() + if errors.Is(err, huh.ErrUserAborted) { + // TODO(huh-prompter-improvements) + // It's unfortunate that we take a dependency on survey/terminal here, but our clean cancellation logic + // in cmd.go expects it. Better would be to have a prompter.Cancelled sentinel error, but then we need to + // go and change non-experimental code to do so, and I don't think we should take that on right now. + return terminal.InterruptErr + } + return err +} + func (p *huhPrompter) buildSelectForm(prompt, defaultValue string, options []string) (*huh.Form, *int) { var result int @@ -52,7 +66,7 @@ func (p *huhPrompter) buildSelectForm(prompt, defaultValue string, options []str func (p *huhPrompter) Select(prompt, defaultValue string, options []string) (int, error) { form, result := p.buildSelectForm(prompt, defaultValue, options) - err := form.Run() + err := p.runForm(form) return *result, err } @@ -85,7 +99,7 @@ func (p *huhPrompter) buildMultiSelectForm(prompt string, defaults []string, opt func (p *huhPrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { form, result := p.buildMultiSelectForm(prompt, defaults, options) - err := form.Run() + err := p.runForm(form) if err != nil { return nil, err } @@ -100,7 +114,7 @@ func (p *huhPrompter) buildMultiSelectWithSearchForm(prompt, searchPrompt string func (p *huhPrompter) MultiSelectWithSearch(prompt, searchPrompt string, defaultValues, persistentValues []string, searchFunc func(string) MultiSelectSearchResult) ([]string, error) { form, field := p.buildMultiSelectWithSearchForm(prompt, searchPrompt, defaultValues, persistentValues, searchFunc) - err := form.Run() + err := p.runForm(form) if err != nil { return nil, err } @@ -121,7 +135,7 @@ func (p *huhPrompter) buildInputForm(prompt, defaultValue string) (*huh.Form, *s func (p *huhPrompter) Input(prompt, defaultValue string) (string, error) { form, result := p.buildInputForm(prompt, defaultValue) - err := form.Run() + err := p.runForm(form) return *result, err } @@ -140,7 +154,7 @@ func (p *huhPrompter) buildPasswordForm(prompt string) (*huh.Form, *string) { func (p *huhPrompter) Password(prompt string) (string, error) { form, result := p.buildPasswordForm(prompt) - err := form.Run() + err := p.runForm(form) if err != nil { return "", err } @@ -161,7 +175,7 @@ func (p *huhPrompter) buildConfirmForm(prompt string, defaultValue bool) (*huh.F func (p *huhPrompter) Confirm(prompt string, defaultValue bool) (bool, error) { form, result := p.buildConfirmForm(prompt, defaultValue) - err := form.Run() + err := p.runForm(form) if err != nil { return false, err } @@ -189,7 +203,7 @@ func (p *huhPrompter) buildAuthTokenForm() (*huh.Form, *string) { func (p *huhPrompter) AuthToken() (string, error) { form, result := p.buildAuthTokenForm() - err := form.Run() + err := p.runForm(form) return *result, err } @@ -209,7 +223,7 @@ func (p *huhPrompter) buildConfirmDeletionForm(requiredValue string) *huh.Form { } func (p *huhPrompter) ConfirmDeletion(requiredValue string) error { - return p.buildConfirmDeletionForm(requiredValue).Run() + return p.runForm(p.buildConfirmDeletionForm(requiredValue)) } func (p *huhPrompter) buildInputHostnameForm() (*huh.Form, *string) { @@ -227,7 +241,7 @@ func (p *huhPrompter) buildInputHostnameForm() (*huh.Form, *string) { func (p *huhPrompter) InputHostname() (string, error) { form, result := p.buildInputHostnameForm() - err := form.Run() + err := p.runForm(form) if err != nil { return "", err } @@ -258,7 +272,7 @@ func (p *huhPrompter) buildMarkdownEditorForm(prompt string, blankAllowed bool) func (p *huhPrompter) MarkdownEditor(prompt, defaultValue string, blankAllowed bool) (string, error) { form, result := p.buildMarkdownEditorForm(prompt, blankAllowed) - err := form.Run() + err := p.runForm(form) if err != nil { return "", err } diff --git a/internal/prompter/huh_prompter_test.go b/internal/prompter/huh_prompter_test.go index 404867d23..30ec22551 100644 --- a/internal/prompter/huh_prompter_test.go +++ b/internal/prompter/huh_prompter_test.go @@ -6,6 +6,7 @@ import ( "time" "charm.land/huh/v2" + "github.com/AlecAivazis/survey/v2/terminal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -618,3 +619,25 @@ func TestHuhPrompterMultiSelectWithSearchBackspace(t *testing.T) { assert.Equal(t, []string{"alice"}, result.selectedKeys()) }) } + +func TestRunFormTranslatesErrUserAborted(t *testing.T) { + p := newTestHuhPrompter() + form, _ := p.buildSelectForm("Pick one:", "", []string{"a", "b", "c"}) + + r, w := io.Pipe() + form.WithInput(r).WithOutput(io.Discard).WithWidth(80) + + errCh := make(chan error, 1) + go func() { errCh <- p.runForm(form) }() + + // Send Ctrl+C to trigger huh.ErrUserAborted + _, err := w.Write([]byte{0x03}) + require.NoError(t, err) + + select { + case err := <-errCh: + assert.ErrorIs(t, err, terminal.InterruptErr, "expected huh.ErrUserAborted to be translated to terminal.InterruptErr") + case <-time.After(5 * time.Second): + t.Fatal("runForm did not complete in time") + } +}