refactor: add terminal output and forms package (#1124)

- `internal/terminal`: streams and the prompting policy, interactive forms (issue, PR, review, merge, milestone), editor / pager / browser launching, JSON and `--jq` output.
- An aligned table renderer and a record renderer for human output; both strip control and escape characters from server-supplied text so titles and bodies cannot inject terminal sequences.

Reviewed-on: https://gitea.com/gitea/tea/pulls/1124
Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
This commit is contained in:
bircni committed 2026-10-11 17:12:23 +00:00
1 parent 88b91faa54
commit faea86cd94
27 files changed
+1941 -1

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"context"
"errors"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestDefaultPullTitle(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"", ""},
{"feature", "Feature"},
{"fix-my-bug", "Fix My Bug"},
{"alice:add_login", "Add Login"},
{"über-fix", "Über Fix"},
} {
require.Equal(t, tc.want, DefaultPullTitle(tc.in), tc.in)
}
}
func TestPullFormMarksRequiredFields(t *testing.T) {
for _, tc := range []struct {
name, input string
choices PullFormChoices
want, without []string
}{
{
"without head", "n\nmain\nalice\nfeature\ny\nTitle\n",
PullFormChoices{},
[]string{"Target branch (required):", "Source branch (required):", "Issue title (required):"},
nil,
},
{
"with head", "n\n\n\n\n\nTitle\n",
PullFormChoices{Base: "main", HeadOwner: "alice", HeadBranch: "feature"},
[]string{"Source branch:", "Allow maintainers to push to the source branch:"},
[]string{"Source branch (required)"},
},
{"agit", "y\nmain\ntopic\nTitle\n", PullFormChoices{}, []string{"Topic branch (required):"}, nil},
} {
t.Run(tc.name, func(t *testing.T) {
p, rendered := accessiblePrompter(t, tc.input)
tc.choices.EditBody = noEdit
_, err := p.CreatePull(context.Background(), tc.choices)
require.NoError(t, err)
for _, want := range tc.want {
require.Contains(t, rendered.String(), want)
}
for _, unwanted := range tc.without {
require.NotContains(t, rendered.String(), unwanted)
}
})
}
}
// stagedInput feeds one form's keystrokes per stage. A finished form's canceled read loop can still be inside Read,
// and bubbletea drops the byte that Read returns. When a stage runs dry, the finished form's loop blocks first and
// the next form's loop second, so the next stage is handed to that second reader and never to the stale one.
type stagedInput struct {
mu sync.Mutex
cur string
stages []string
idle int
}
func (s *stagedInput) Read(p []byte) (int, error) {
s.mu.Lock()
for s.cur == "" {
s.idle++
if s.idle < 2 || len(s.stages) == 0 {
s.mu.Unlock()
select {} // no more input for this reader; the loop is abandoned with its form
}
s.cur, s.stages, s.idle = s.stages[0], s.stages[1:], 0
}
p[0], s.cur = s.cur[0], s.cur[1:]
s.mu.Unlock()
return 1, nil
}
func TestCreatePullPreservesDraftOnEditorFailure(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
path := filepath.Join(t.TempDir(), "draft.md")
called := false
p, _ := testPrompter(&stagedInput{cur: "n\r", stages: []string{"main\ralice\rfeature\ry\r", "Title\r"}})
values, err := p.CreatePull(ctx, PullFormChoices{
BaseOwner: "alice", Base: "main", HeadOwner: "alice", HeadBranch: "feature",
EditBody: func(context.Context, string) (Draft, error) {
called = true
return Draft{Path: path, Body: "partial"}, errors.New("editor failed")
},
})
require.True(t, called)
require.ErrorContains(t, err, "editor failed")
require.Equal(t, path, values.Draft.Path)
}