mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
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:
1 parent
88b91faa54
commit
faea86cd94
27 files changed
+1941
-1
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user