fix: address verified review findings (terminal-forms-1)

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-18 17:42:24 +02:00
1 parent caeed59bc6
commit 0910cf307a
21 files changed
+329 -53

No files matched your search

+37 -2
View File
@@ -3,7 +3,14 @@
package terminal
import "charm.land/huh/v2"
import (
"errors"
"strings"
"unicode"
"unicode/utf8"
"charm.land/huh/v2"
)
// BodyTitle labels the markdown body of issues, pull requests and comments.
const BodyTitle = "Body (markdown):"
@@ -11,9 +18,17 @@ const BodyTitle = "Body (markdown):"
// maxVisibleOptions bounds how many options a multi-select shows before it scrolls.
const maxVisibleOptions = 8
// notBlank rejects empty and whitespace-only answers, which huh.ValidateNotEmpty accepts.
func notBlank(s string) error {
if strings.TrimSpace(s) == "" {
return errors.New("input cannot be empty")
}
return nil
}
// 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())
return huh.NewInput().Title("Title (required):").Value(value).Validate(notBlank)
}
// bodyField is an inline multi-line markdown field. It has no external editor: forms run the
@@ -22,6 +37,26 @@ func bodyField(title string, value *string) *huh.Text {
return huh.NewText().Title(title).Value(value).ExternalEditor(false)
}
// fieldText is how a multi-line field shows seed: its textarea turns every CR and LF into a line
// break and a tab into four spaces, and drops other control characters.
func fieldText(seed string) string {
return strings.Map(func(r rune) rune {
if r == utf8.RuneError || (unicode.IsControl(r) && r != '\n') {
return -1
}
return r
}, strings.NewReplacer("\r", "\n", "\t", " ").Replace(seed))
}
// fieldResult is the answer of a multi-line field seeded with seed: seed itself when the user
// changed nothing, so a plain Enter does not rewrite existing text such as CRLF line endings.
func fieldResult(seed, answer string) string {
if answer == fieldText(seed) {
return seed
}
return answer
}
// 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] {
+25
View File
@@ -57,3 +57,28 @@ func TestFormsRefuseTerminalsWithoutSize(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "Title", values.Title)
}
func TestIssueFormSanitizesServerTextInHeadingAndOptions(t *testing.T) {
fields := issueFields{}
groups := fields.groups("Editing R#1 (\x1b]8;;https://evil.example\x07login docs\x1b]8;;\x07)", noEdit, true, nil,
[]IssueMilestoneChoice{{ID: 1, Title: "v1\x1b[8mhidden"}}, []IssueLabelChoice{{ID: 2, Name: "bug\x1b[8m"}})
rendered := view(huh.NewForm(groups...), 100, 40)
require.NotContains(t, rendered, "\x1b]8")
require.NotContains(t, rendered, "\x1b[8m")
require.Contains(t, rendered, "login docs")
}
func TestMilestoneSelectKeepsItsTitleOnShortTerminals(t *testing.T) {
var milestones []IssueMilestoneChoice
for i := range 40 {
milestones = append(milestones, IssueMilestoneChoice{ID: int64(i + 1), Title: fmt.Sprintf("v%d", i)})
}
fields := issueFields{}
groups := fields.groups("Editing R#1 (heading)", noEdit, true, nil, milestones, nil)
rendered := view(huh.NewForm(groups...), 80, 24)
require.LessOrEqual(t, strings.Count(rendered, "\n")+1, 24, "a taller frame scrolls its heading off the screen")
require.Contains(t, rendered, "Editing R#1")
rendered = view(huh.NewForm(groups[1]), 80, 24)
require.LessOrEqual(t, strings.Count(rendered, "\n")+1, 24)
require.Contains(t, rendered, "Milestone:")
}
+39 -27
View File
@@ -77,11 +77,11 @@ func (p *FormPrompter) CreateIssue(ctx context.Context, choices IssueFormChoices
if label == "" {
label = "template " + strconv.Itoa(i+1)
}
options = append(options, huh.NewOption(label, i))
options = append(options, huh.NewOption(SafeLine(label), i))
}
selected := -1
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(
huh.NewSelect[int]().Title("Issue template:").Options(options...).Value(&selected),
huh.NewSelect[int]().Title("Issue template:").Options(options...).Height(min(len(options), 10)+2).Value(&selected),
))); err != nil {
return IssueFormValues{}, err
}
@@ -113,7 +113,7 @@ func (f *issueFields) content(heading string, editBody BodyEditor) *huh.Group {
if editBody == nil {
fields = append(fields, bodyField(BodyTitle, &f.body))
}
return huh.NewGroup(fields...).Title(heading)
return huh.NewGroup(fields...).Title(SafeLine(heading))
}
// metadata asks for assignees, a milestone, labels and a due date, which only people who can push
@@ -129,14 +129,14 @@ func (f *issueFields) metadata(canPush bool, assignees []string, milestones []Is
if len(milestones) > 0 {
options := []huh.Option[int64]{huh.NewOption("[none]", int64(0))}
for _, milestone := range milestones {
options = append(options, huh.NewOption(milestone.Title, milestone.ID))
options = append(options, huh.NewOption(SafeLine(milestone.Title), milestone.ID))
}
groups = append(groups, huh.NewGroup(huh.NewSelect[int64]().Title("Milestone:").Options(options...).Value(&f.milestone)))
groups = append(groups, huh.NewGroup(huh.NewSelect[int64]().Title("Milestone:").Options(options...).Height(min(len(options), 10)+2).Value(&f.milestone)))
}
if len(labels) > 0 {
options := make([]huh.Option[int64], 0, len(labels))
for _, label := range labels {
options = append(options, huh.NewOption(label.Name, label.ID))
options = append(options, huh.NewOption(SafeLine(label.Name), label.ID))
}
groups = append(groups, huh.NewGroup(multiSelect("Labels:", options, &f.labels)))
}
@@ -188,34 +188,46 @@ const (
func (p *FormPrompter) collectIssueProperties(ctx context.Context, title, body string, choices IssueFormChoices) (IssueFormValues, error) {
fields := issueFields{title: title, body: body}
metadata := fields.metadata(choices.CanPush, choices.Assignees, choices.Milestones, choices.Labels)
groups := []*huh.Group{fields.content("", choices.EditBody)}
next := nextMetadata
if choices.AskNext {
content := fields.content("", choices.EditBody)
values := IssueFormValues{}
var err error
if !choices.AskNext {
if err := p.runForm(ctx, huh.NewForm(append([]*huh.Group{content}, metadata...)...)); err != nil {
return IssueFormValues{}, err
}
} else {
if err := p.runForm(ctx, huh.NewForm(content)); err != nil {
return IssueFormValues{}, err
}
// The body comes before "What's next?", so cancelling still reports the draft.
if values.Body, values.Draft, err = editBody(ctx, choices.EditBody, fields.body); err != nil {
return values, err
}
options := []huh.Option[int]{huh.NewOption("Submit", nextSubmit)}
if len(metadata) > 0 {
options = append(options, huh.NewOption("Add metadata", nextMetadata))
}
next = nextSubmit
options = append(options, huh.NewOption("Cancel", nextCancel))
groups = append(groups, huh.NewGroup(huh.NewSelect[int]().Title("What's next?").Options(options...).Value(&next)))
} else {
groups, metadata = append(groups, metadata...), nil
}
if err := p.runForm(ctx, huh.NewForm(groups...)); err != nil {
return IssueFormValues{}, err
}
switch {
case next == nextCancel:
return IssueFormValues{}, ErrCanceled
case next == nextMetadata && len(metadata) > 0:
if err := p.runForm(ctx, huh.NewForm(metadata...)); err != nil {
return IssueFormValues{}, err
for next := nextMetadata; next == nextMetadata; {
next = nextSubmit
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewSelect[int]().Title("What's next?").Options(options...).Value(&next)))); err != nil {
return values, err
}
switch next {
case nextCancel:
return values, ErrCanceled
case nextMetadata:
if err := p.runForm(ctx, huh.NewForm(metadata...)); err != nil {
return values, err
}
}
}
}
values := IssueFormValues{Title: strings.TrimSpace(fields.title), Assignees: fields.assignees, Milestone: fields.milestone, Labels: fields.labels}
var err error
if values.Body, values.Draft, err = editBody(ctx, choices.EditBody, fields.body); err != nil {
return values, err
values.Title, values.Assignees, values.Milestone, values.Labels = strings.TrimSpace(fields.title), fields.assignees, fields.milestone, fields.labels
if !choices.AskNext {
if values.Body, values.Draft, err = editBody(ctx, choices.EditBody, fields.body); err != nil {
return values, err
}
}
values.Deadline, err = parseDueDate(fields.deadline)
return values, err
+23
View File
@@ -60,3 +60,26 @@ func TestIssueFormMarksTitleRequired(t *testing.T) {
require.Contains(t, rendered.String(), "Title (required):")
require.NotContains(t, rendered.String(), "What's next?")
}
func TestIssueFormWritesBodyBeforeWhatsNext(t *testing.T) {
edited := false
choices := IssueFormChoices{AskNext: true, EditBody: func(context.Context, string) (Draft, error) {
edited = true
return Draft{Path: "draft.md", Body: "body"}, nil
}}
p, _ := linePrompter(t, "Title\n2\n")
values, err := p.collectIssueProperties(context.Background(), "", "", choices)
require.ErrorIs(t, err, ErrCanceled)
require.True(t, edited, "the editor runs before the user can cancel")
require.Equal(t, "draft.md", values.Draft.Path)
}
func TestRequiredFieldsRejectBlankAnswers(t *testing.T) {
require.Error(t, notBlank(" \t"))
require.Error(t, notBlank(""))
require.NoError(t, notBlank(" x "))
p, rendered := linePrompter(t, " \nTitle\n")
_, err := p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit})
require.NoError(t, err)
require.Contains(t, rendered.String(), "input cannot be empty")
}
+1 -1
View File
@@ -59,7 +59,7 @@ func (p *FormPrompter) SelectPull(ctx context.Context, choices []MergePullChoice
}
selected := choices[0].Number
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(
huh.NewSelect[int64]().Title("Pull request to merge:").Options(options...).Value(&selected),
huh.NewSelect[int64]().Title("Pull request to merge:").Options(options...).Height(min(len(options), 10)+2).Value(&selected),
)))
return selected, err
}
+11 -4
View File
@@ -15,6 +15,8 @@ import (
type MilestoneFormValues struct {
Title, Description string
Deadline *time.Time
// Editor is the configured editor command, input only: with one, ctrl+e opens it in the description.
Editor string
}
// MilestoneForm collects interactive milestone input.
@@ -30,13 +32,18 @@ func (p *FormPrompter) EditMilestone(ctx context.Context, heading string, curren
if current.Deadline != nil {
deadline = current.Deadline.Format(time.DateOnly)
}
descriptionTitle := "Milestone description (markdown):"
descriptionField := bodyField(descriptionTitle, &description)
if args, ok := commandArgs(current.Editor); ok {
descriptionField = bodyField(strings.TrimSuffix(descriptionTitle, "):")+", ctrl+e opens your editor):", &description).ExternalEditor(true).Editor(args...).EditorExtension("md")
}
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(
huh.NewInput().Title("Milestone title (required):").Value(&title).Validate(huh.ValidateNotEmpty()),
bodyField("Milestone description (markdown):", &description),
huh.NewInput().Title("Milestone title (required):").Value(&title).Validate(notBlank),
descriptionField,
dueDateInput("Milestone deadline:", &deadline),
).Title(heading))); err != nil {
).Title(SafeLine(heading)))); err != nil {
return MilestoneFormValues{}, err
}
parsed, err := parseDueDate(deadline)
return MilestoneFormValues{Title: strings.TrimSpace(title), Description: description, Deadline: parsed}, err
return MilestoneFormValues{Title: strings.TrimSpace(title), Description: fieldResult(current.Description, description), Deadline: parsed}, err
}
+29
View File
@@ -28,3 +28,32 @@ func TestMilestoneFormStartsFromCurrentValues(t *testing.T) {
require.Equal(t, "First", values.Description)
require.Equal(t, due, *values.Deadline)
}
func TestMilestoneFormKeepsSeededDescriptionOnEnter(t *testing.T) {
due := time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC)
seed := "a\r\nb\tc"
p, _ := testPrompter(strings.NewReader("\r\r\r"))
values, err := p.EditMilestone(context.Background(), "", MilestoneFormValues{Title: "v1.0", Description: seed, Deadline: &due})
require.NoError(t, err)
require.Equal(t, seed, values.Description)
}
func TestAskTextKeepsSeedOnEnter(t *testing.T) {
seed := "a\r\nb\tc\nd"
p, _ := testPrompter(strings.NewReader("\r\r"))
text, err := p.AskText(context.Background(), "Body", seed)
require.NoError(t, err)
require.Equal(t, seed, text)
}
func TestMilestoneFormOffersEditorWhenConfigured(t *testing.T) {
p, rendered := accessiblePrompter(t, "v1\n\n\n")
_, err := p.EditMilestone(context.Background(), "", MilestoneFormValues{Editor: "vi"})
require.NoError(t, err)
require.Contains(t, rendered.String(), "ctrl+e opens your editor")
p, rendered = accessiblePrompter(t, "v1\n\n\n")
_, err = p.EditMilestone(context.Background(), "", MilestoneFormValues{})
require.NoError(t, err)
require.NotContains(t, rendered.String(), "ctrl+e")
}
+5 -3
View File
@@ -61,7 +61,9 @@ const (
func (p *FormPrompter) CreatePull(ctx context.Context, choices PullFormChoices) (PullFormValues, error) {
branches := pullBranchFields{base: choices.Base, headOwner: choices.HeadOwner, headBranch: choices.HeadBranch, allowEdits: choices.AllowMaintainerEdits}
branches.topic = branches.headBranch
if strings.TrimSpace(branches.base) == "" || strings.TrimSpace(branches.headBranch) == "" {
// A head equal to the base is the usual slip of committing on the default branch: ask instead.
sameBranch := strings.TrimSpace(branches.base) == strings.TrimSpace(branches.headBranch) && (branches.headOwner == "" || branches.headOwner == choices.BaseOwner)
if strings.TrimSpace(branches.base) == "" || strings.TrimSpace(branches.headBranch) == "" || sameBranch {
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(branches.fields(!choices.CanPush)...))); err != nil {
return PullFormValues{}, err
}
@@ -133,13 +135,13 @@ type pullBranchFields struct {
// fields asks for the branches; offerAgit adds the choice of an AGit push for users who cannot
// push to the base repository.
func (b *pullBranchFields) fields(offerAgit bool) []huh.Field {
fields := []huh.Field{huh.NewInput().Title("Base branch (required):").Value(&b.base).Validate(huh.ValidateNotEmpty())}
fields := []huh.Field{huh.NewInput().Title("Base branch (required):").Value(&b.base).Validate(notBlank)}
if offerAgit {
fields = append(fields, huh.NewConfirm().Title("Push through AGit (refs/for) instead of a branch of your own?").Value(&b.agit))
}
return append(fields,
huh.NewInput().Title("Head repository owner:").Value(&b.headOwner),
huh.NewInput().Title("Head branch (required):").Value(&b.headBranch).Validate(huh.ValidateNotEmpty()),
huh.NewInput().Title("Head branch (required):").Value(&b.headBranch).Validate(notBlank),
huh.NewInput().Title("AGit topic (when pushing through AGit):").Value(&b.topic),
huh.NewConfirm().Title("Allow maintainers to push to the head branch?").Value(&b.allowEdits),
)
+1 -1
View File
@@ -78,7 +78,7 @@ func (p *FormPrompter) CreateReview(ctx context.Context, choices ReviewFormChoic
body = body.ExternalEditor(true).Editor(args...).EditorExtension("md")
}
if required {
body = body.Validate(huh.ValidateNotEmpty())
body = body.Validate(notBlank)
}
if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(body))); err != nil {
return values, err
+1 -1
View File
@@ -130,7 +130,7 @@ func (p *FormPrompter) Ask(ctx context.Context, title string, validate func(stri
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
return fieldResult(initial, value), err
}
// SecretInput collects a value without displaying it on the terminal.