From faea86cd948384deee5e192b7c3441c2d7861ef7 Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 11 Oct 2026 17:12:23 +0000 Subject: [PATCH] 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> --- go.mod | 4 +- go.sum | 4 + internal/terminal/browser.go | 59 ++++++++ internal/terminal/browser_test.go | 37 +++++ internal/terminal/color.go | 62 ++++++++ internal/terminal/color_test.go | 42 ++++++ internal/terminal/editor.go | 125 ++++++++++++++++ internal/terminal/editor_test.go | 129 ++++++++++++++++ internal/terminal/issue_edit_form.go | 89 +++++++++++ internal/terminal/issue_edit_form_test.go | 45 ++++++ internal/terminal/issue_form.go | 175 ++++++++++++++++++++++ internal/terminal/issue_form_test.go | 36 +++++ internal/terminal/merge_form.go | 39 +++++ internal/terminal/milestone_form.go | 37 +++++ internal/terminal/milestone_form_test.go | 18 +++ internal/terminal/output.go | 124 +++++++++++++++ internal/terminal/output_test.go | 24 +++ internal/terminal/pager.go | 69 +++++++++ internal/terminal/pager_test.go | 88 +++++++++++ internal/terminal/pull_form.go | 120 +++++++++++++++ internal/terminal/pull_form_test.go | 105 +++++++++++++ internal/terminal/review_form.go | 94 ++++++++++++ internal/terminal/review_form_test.go | 34 +++++ internal/terminal/streams.go | 130 ++++++++++++++++ internal/terminal/streams_test.go | 63 ++++++++ internal/terminal/table.go | 119 +++++++++++++++ internal/terminal/table_test.go | 71 +++++++++ 27 files changed, 1941 insertions(+), 1 deletion(-) create mode 100644 internal/terminal/browser.go create mode 100644 internal/terminal/browser_test.go create mode 100644 internal/terminal/color.go create mode 100644 internal/terminal/color_test.go create mode 100644 internal/terminal/editor.go create mode 100644 internal/terminal/editor_test.go create mode 100644 internal/terminal/issue_edit_form.go create mode 100644 internal/terminal/issue_edit_form_test.go create mode 100644 internal/terminal/issue_form.go create mode 100644 internal/terminal/issue_form_test.go create mode 100644 internal/terminal/merge_form.go create mode 100644 internal/terminal/milestone_form.go create mode 100644 internal/terminal/milestone_form_test.go create mode 100644 internal/terminal/output.go create mode 100644 internal/terminal/output_test.go create mode 100644 internal/terminal/pager.go create mode 100644 internal/terminal/pager_test.go create mode 100644 internal/terminal/pull_form.go create mode 100644 internal/terminal/pull_form_test.go create mode 100644 internal/terminal/review_form.go create mode 100644 internal/terminal/review_form_test.go create mode 100644 internal/terminal/streams.go create mode 100644 internal/terminal/streams_test.go create mode 100644 internal/terminal/table.go create mode 100644 internal/terminal/table_test.go diff --git a/go.mod b/go.mod index f8cfe88b..62903940 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,9 @@ require ( github.com/davidmz/go-pageant v1.0.2 github.com/enescakir/emoji v1.0.0 github.com/go-signet/sdk-go v1.1.0 + github.com/itchyny/gojq v0.12.17 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 + github.com/mattn/go-runewidth v0.0.23 github.com/muesli/termenv v0.16.0 github.com/olekukonko/tablewriter v1.1.4 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 @@ -60,10 +62,10 @@ require ( github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/go-version v1.9.0 // indirect + github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.23 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect diff --git a/go.sum b/go.sum index 00a409f6..6e8d695e 100644 --- a/go.sum +++ b/go.sum @@ -99,6 +99,10 @@ github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaX github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= +github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= diff --git a/internal/terminal/browser.go b/internal/terminal/browser.go new file mode 100644 index 00000000..e6b84513 --- /dev/null +++ b/internal/terminal/browser.go @@ -0,0 +1,59 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "context" + "errors" + "fmt" + "io" + "net/url" + "os/exec" + "runtime" + "strings" +) + +// Browser opens an explicitly requested resource using a configured command. +type Browser interface { + Open(context.Context, string, string) error +} + +// SystemBrowser runs the browser without a shell and routes diagnostics to stderr. +type SystemBrowser struct{ ErrOut io.Writer } + +// Open validates the resource and invokes the selected browser directly. +func (b SystemBrowser) Open(ctx context.Context, resource, command string) error { + u, err := url.Parse(resource) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.User != nil || strings.ContainsAny(resource, "\x00\r\n") { + return errors.New("browser requires an HTTP or HTTPS resource URL without credentials") + } + var args []string + if command == "" || command == "auto" { + switch runtime.GOOS { + case "darwin": + args = []string{"open"} + case "windows": + args = []string{"rundll32", "url.dll,FileProtocolHandler"} + default: + args = []string{"xdg-open"} + } + } else { + if command == "never" { + return errors.New("browser opening is disabled; configure browser or BROWSER") + } + var ok bool + if args, ok = commandArgs(command); !ok { + return errors.New("browser command contains malformed quoted arguments") + } + } + process := exec.CommandContext(ctx, args[0], append(args[1:], resource)...) + process.Stdout, process.Stderr = b.ErrOut, b.ErrOut + if err := process.Run(); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("browser could not be opened; configure browser or BROWSER: %w", err) + } + return nil +} diff --git a/internal/terminal/browser_test.go b/internal/terminal/browser_test.go new file mode 100644 index 00000000..4c74048e --- /dev/null +++ b/internal/terminal/browser_test.go @@ -0,0 +1,37 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "bytes" + "context" + "os/exec" + "testing" + + "github.com/kballard/go-shellquote" + "github.com/stretchr/testify/require" +) + +func TestBrowserRejectsInvalidURLsBeforeLaunchingCommand(t *testing.T) { + for _, resource := range []string{"javascript:alert(1)", "file:///tmp/file", "https://user:secret@example.org", "https://example.org\n"} { + err := (SystemBrowser{}).Open(context.Background(), resource, "missing-browser-command") + require.ErrorContains(t, err, "resource URL without credentials") + require.NotContains(t, err.Error(), "secret") + } +} + +func TestBrowserUsesLiteralArgumentsAndDiagnosticWriter(t *testing.T) { + printf, err := exec.LookPath("printf") + if err != nil { + t.Skip("printf executable is unavailable") + } + var diagnostics bytes.Buffer + resource := "https://example.org/$(echo-secret)" + command := shellquote.Join(printf, "%s") + require.NoError(t, (SystemBrowser{ErrOut: &diagnostics}).Open(context.Background(), resource, command)) + require.Equal(t, resource, diagnostics.String()) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + require.ErrorIs(t, (SystemBrowser{}).Open(ctx, resource, command), context.Canceled) +} diff --git a/internal/terminal/color.go b/internal/terminal/color.go new file mode 100644 index 00000000..f1761360 --- /dev/null +++ b/internal/terminal/color.go @@ -0,0 +1,62 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "fmt" + "strconv" + "strings" + "unicode" +) + +// ColorEnabled resolves the color preference against terminal and environment policy. +// auto colors TTY stdout unless NO_COLOR is set or TERM is dumb; always forces color; never disables it. +func ColorEnabled(preference string, outTTY bool, getenv func(string) string) bool { + switch preference { + case "always": + return true + case "never": + return false + default: // auto and unknown fall back to terminal detection + if getenv != nil && (getenv("NO_COLOR") != "" || getenv("TERM") == "dumb") { + return false + } + return outTTY + } +} + +// Colorize applies a 24-bit foreground color from a six-digit RGB hex value. +func Colorize(hex, text string, enabled bool) string { + if !enabled || text == "" { + return text + } + hex = strings.TrimPrefix(strings.TrimSpace(hex), "#") + if len(hex) != 6 { + return text + } + r, errR := strconv.ParseUint(hex[0:2], 16, 8) + g, errG := strconv.ParseUint(hex[2:4], 16, 8) + b, errB := strconv.ParseUint(hex[4:6], 16, 8) + if errR != nil || errG != nil || errB != nil { + return text + } + return fmt.Sprintf("\x1b[38;2;%d;%d;%dm%s\x1b[0m", r, g, b, text) +} + +// Emphasis applies simple bold styling for TTY human tables. +func Emphasis(text string, enabled bool) string { + if !enabled || text == "" { + return text + } + return "\x1b[1m" + text + "\x1b[0m" +} + +// Hyperlink wraps text in an OSC 8 terminal hyperlink to url. URLs with control +// characters are left unlinked so server data cannot inject escape sequences. +func Hyperlink(url, text string, enabled bool) string { + if !enabled || url == "" || text == "" || strings.ContainsFunc(url, unicode.IsControl) { + return text + } + return "\x1b]8;;" + url + "\x1b\\" + text + "\x1b]8;;\x1b\\" +} diff --git a/internal/terminal/color_test.go b/internal/terminal/color_test.go new file mode 100644 index 00000000..e0ad88b4 --- /dev/null +++ b/internal/terminal/color_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestColorEnabledRespectsPreferenceAndEnvironment(t *testing.T) { + getenv := func(key string) string { + if key == "NO_COLOR" { + return "1" + } + return "" + } + require.True(t, ColorEnabled("always", false, nil)) + require.False(t, ColorEnabled("never", true, nil)) + require.True(t, ColorEnabled("auto", true, func(string) string { return "" })) + require.False(t, ColorEnabled("auto", false, func(string) string { return "" })) + require.False(t, ColorEnabled("auto", true, getenv)) + require.True(t, ColorEnabled("always", true, getenv)) + require.False(t, ColorEnabled("auto", true, func(key string) string { return map[string]string{"TERM": "dumb"}[key] })) +} + +func TestColorizeAndEmphasis(t *testing.T) { + require.Equal(t, "bug", Colorize("ff0000", "bug", false)) + require.Equal(t, "bug", Colorize("bad", "bug", true)) + require.Equal(t, "\x1b[38;2;255;0;0mbug\x1b[0m", Colorize("#ff0000", "bug", true)) + require.Equal(t, "open", Emphasis("open", false)) + require.Equal(t, "\x1b[1mopen\x1b[0m", Emphasis("open", true)) +} + +func TestHyperlink(t *testing.T) { + require.Equal(t, "#7", Hyperlink("https://example.org/i/7", "#7", false)) + require.Equal(t, "\x1b]8;;https://example.org/i/7\x1b\\#7\x1b]8;;\x1b\\", Hyperlink("https://example.org/i/7", "#7", true)) + require.Equal(t, "#7", Hyperlink("", "#7", true)) + require.Equal(t, "#7", Hyperlink("https://example.org/\x1b[2J", "#7", true)) + require.Equal(t, "#7", Hyperlink("https://example.org/\u009b2J", "#7", true)) +} diff --git a/internal/terminal/editor.go b/internal/terminal/editor.go new file mode 100644 index 00000000..f40b13c1 --- /dev/null +++ b/internal/terminal/editor.go @@ -0,0 +1,125 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + + "github.com/kballard/go-shellquote" +) + +// Draft holds edited text and the private file retained until successful submission. +type Draft struct { + Path string + Body string +} + +// Editor edits drafts or existing files using an explicitly selected command. +type Editor interface { + Edit(context.Context, string, string) (Draft, error) + EditPath(context.Context, string, string) error +} + +// SystemEditor uses private temporary files and the application's terminal streams. +type SystemEditor struct { + Directory string + Streams Streams +} + +// Edit preserves the draft on failure; the caller removes it after submission. +func (e SystemEditor) Edit(ctx context.Context, command, body string) (Draft, error) { + if _, err := editorArgs(command); err != nil { + return Draft{}, err + } + if err := ctx.Err(); err != nil { + return Draft{}, err + } + file, err := os.CreateTemp(e.Directory, "tea-draft-*.md") + if err != nil { + return Draft{}, errors.New("cannot create a private editor draft") + } + draft := Draft{Path: file.Name(), Body: body} + _, writeErr := file.WriteString(body) + closeErr := file.Close() + if err := errors.Join(writeErr, closeErr); err != nil { + return draft, errors.New("cannot write editor draft") + } + runErr := e.EditPath(ctx, command, draft.Path) + info, err := os.Lstat(draft.Path) + if err != nil || !info.Mode().IsRegular() { + // A failed editor can remove its buffer or replace it with a non-file. + // Recover the original input separately without following that replacement. + recovery, recoveryErr := os.CreateTemp(e.Directory, "tea-draft-recovery-*.md") + if recoveryErr != nil { + return Draft{Body: body}, errors.Join(ctx.Err(), errors.New("editor draft is unavailable and original input could not be saved")) + } + draft.Path = recovery.Name() + _, writeErr := recovery.WriteString(body) + closeErr := recovery.Close() + if errors.Join(writeErr, closeErr) != nil { + return draft, errors.Join(ctx.Err(), errors.New("cannot save original editor input")) + } + return draft, errors.Join(ctx.Err(), errors.New("editor draft is unavailable; original input was saved separately")) + } + // Editors may replace the file rather than writing it in place, even on failure. + if err := os.Chmod(draft.Path, 0o600); err != nil { + return draft, errors.Join(ctx.Err(), errors.New("cannot restore private draft permissions")) + } + if runErr != nil { + if ctx.Err() != nil { + return draft, ctx.Err() + } + return draft, fmt.Errorf("%w; draft has been preserved", runErr) + } + data, err := os.ReadFile(draft.Path) + if err != nil { + return draft, errors.New("cannot read edited draft") + } + draft.Body = string(data) + return draft, nil +} + +// EditPath opens an existing file in the configured editor and blocks until it exits. +// Like Git, it waits for the editor instead of killing it on cancellation, so the +// editor can restore the terminal. +func (e SystemEditor) EditPath(ctx context.Context, command, path string) error { + args, err := editorArgs(command) + if err != nil { + return err + } + if err := ctx.Err(); err != nil { + return err + } + process := exec.Command(args[0], append(args[1:], path)...) + process.Stdin, process.Stdout, process.Stderr = e.Streams.In, e.Streams.ErrOut, e.Streams.ErrOut + if err := process.Run(); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("editor failed: %w", err) + } + return ctx.Err() +} + +func editorArgs(command string) ([]string, error) { + args, ok := commandArgs(command) + if !ok { + return nil, errors.New("configure an editor command using tea config set editor or EDITOR") + } + return args, nil +} + +// commandArgs splits a configured command line without a shell. A value that names an +// executable as a whole, such as an unquoted Windows path, is kept intact. +func commandArgs(command string) ([]string, bool) { + if _, err := exec.LookPath(command); err == nil { + return []string{command}, true + } + args, err := shellquote.Split(command) + return args, err == nil && len(args) > 0 && args[0] != "" +} diff --git a/internal/terminal/editor_test.go b/internal/terminal/editor_test.go new file mode 100644 index 00000000..c9a87002 --- /dev/null +++ b/internal/terminal/editor_test.go @@ -0,0 +1,129 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/kballard/go-shellquote" + "github.com/stretchr/testify/require" +) + +func TestEditorRetainsPrivateDraftAfterSuccessAndFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("editor fixture uses a POSIX executable") + } + for _, fail := range []bool{false, true} { + directory := t.TempDir() + program := filepath.Join(directory, "editor with spaces") + text := "#!/bin/sh\nprintf 'edited draft' > \"$1\"\nchmod 644 \"$1\"\nprintf 'editor diagnostic'\n" + if fail { + text += "exit 7\n" + } + require.NoError(t, os.WriteFile(program, []byte(text), 0o700)) + out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{} + draft, err := (SystemEditor{Directory: directory, Streams: Streams{Out: out, ErrOut: diagnostics}}).Edit(context.Background(), shellquote.Join(program), "initial text") + if fail { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, "edited draft", draft.Body) + } + data, readErr := os.ReadFile(draft.Path) + require.NoError(t, readErr) + require.Equal(t, "edited draft", string(data)) + info, statErr := os.Stat(draft.Path) + require.NoError(t, statErr) + require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + require.Empty(t, out.String()) + require.Equal(t, "editor diagnostic", diagnostics.String()) + } +} + +func TestEditorCanceledBeforeLaunchDoesNotCreateDraft(t *testing.T) { + directory := t.TempDir() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + draft, err := (SystemEditor{Directory: directory}).Edit(ctx, "editor", "text") + require.ErrorIs(t, err, context.Canceled) + require.Empty(t, draft.Path) + files, err := os.ReadDir(directory) + require.NoError(t, err) + require.Empty(t, files) +} + +func TestEditorIsNotKilledOnCancellation(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("editor fixture uses a POSIX executable") + } + directory := t.TempDir() + program := filepath.Join(directory, "slow-editor") + require.NoError(t, os.WriteFile(program, []byte("#!/bin/sh\nsleep 0.3\necho finished > \"$1\"\n"), 0o700)) + ctx, cancel := context.WithCancel(context.Background()) + time.AfterFunc(50*time.Millisecond, cancel) + draft, err := (SystemEditor{Directory: directory}).Edit(ctx, shellquote.Join(program), "text") + require.ErrorIs(t, err, context.Canceled) + data, err := os.ReadFile(draft.Path) + require.NoError(t, err) + require.Equal(t, "finished\n", string(data)) +} + +func TestEditorRecoversOriginalInputWhenBufferIsRemoved(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("editor fixture uses a POSIX executable") + } + directory := t.TempDir() + program := filepath.Join(directory, "remove-editor") + require.NoError(t, os.WriteFile(program, []byte("#!/bin/sh\nrm \"$1\"\nexit 1\n"), 0o700)) + draft, err := (SystemEditor{Directory: directory}).Edit(context.Background(), shellquote.Join(program), "original draft") + require.ErrorContains(t, err, "original input was saved separately") + data, err := os.ReadFile(draft.Path) + require.NoError(t, err) + require.Equal(t, "original draft", string(data)) + info, err := os.Stat(draft.Path) + require.NoError(t, err) + require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestCommandArgsKeepsUnquotedExecutablePaths(t *testing.T) { + name := `editor with spaces\and backslash` + if runtime.GOOS == "windows" { + name = "editor with spaces.bat" + } + program := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(program, []byte("#!/bin/sh\n"), 0o700)) + for _, tc := range []struct { + command string + want []string + }{ + {program, []string{program}}, + {shellquote.Join(program) + " --wait", []string{program, "--wait"}}, + {"code --wait", []string{"code", "--wait"}}, + } { + args, ok := commandArgs(tc.command) + require.True(t, ok, tc.command) + require.Equal(t, tc.want, args) + } + for _, command := range []string{"", `"unterminated`} { + _, ok := commandArgs(command) + require.False(t, ok, command) + } +} + +func TestSubprocessFailuresKeepTheirCause(t *testing.T) { + const missing = "tea-test-missing-command" + streams := Streams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}} + require.ErrorIs(t, (SystemEditor{Streams: streams}).EditPath(context.Background(), missing, filepath.Join(t.TempDir(), "draft")), exec.ErrNotFound) + require.ErrorIs(t, (SystemPager{}).Page(context.Background(), missing, strings.NewReader("text\n"), streams), exec.ErrNotFound) + require.ErrorIs(t, (SystemBrowser{ErrOut: streams.ErrOut}).Open(context.Background(), "https://example.org", missing), exec.ErrNotFound) + require.ErrorContains(t, (SystemBrowser{}).Open(context.Background(), "https://example.org", "never"), "browser opening is disabled") +} diff --git a/internal/terminal/issue_edit_form.go b/internal/terminal/issue_edit_form.go new file mode 100644 index 00000000..d96d185f --- /dev/null +++ b/internal/terminal/issue_edit_form.go @@ -0,0 +1,89 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "context" + "slices" + "strings" + "time" + + "charm.land/huh/v2" +) + +// IssueEditFormChoices supplies current issue values and selectable options for edit. +// Current values missing from the options stay selectable. +type IssueEditFormChoices struct { + Title, Body string + Assignees []string + Milestone *IssueMilestoneChoice + Labels []IssueLabelChoice + Deadline *time.Time + CanPush bool + AvailableAssignees []string + Milestones []IssueMilestoneChoice + AvailableLabels []IssueLabelChoice + EditBody BodyEditor +} + +// IssueEditFormValues holds interactively edited issue fields. Deadline is set +// only when the due date changed. +type IssueEditFormValues struct { + Title, Body string + Assignees []string + Milestone int64 + ClearMilestone bool + Labels []int64 + Deadline *time.Time + ClearDeadline bool + Draft Draft +} + +// IssueEditForm collects interactive issue edit input. +type IssueEditForm interface { + EditIssue(context.Context, IssueEditFormChoices) (IssueEditFormValues, error) +} + +// EditIssue runs the interactive issue edit form on stderr, seeded with the current values. +func (p *FormPrompter) EditIssue(ctx context.Context, choices IssueEditFormChoices) (IssueEditFormValues, error) { + deadline := "" + if choices.Deadline != nil && !choices.Deadline.IsZero() { + // Keep the server's offset: converting to UTC can move the date by a day. + deadline = choices.Deadline.Format(time.DateOnly) + } + fields := issueFields{title: choices.Title, body: choices.Body, deadline: deadline, assignees: choices.Assignees} + milestones := choices.Milestones + if choices.Milestone != nil { + fields.milestone, milestones = choices.Milestone.ID, withCurrent(milestones, *choices.Milestone) + } + for _, label := range choices.Labels { + fields.labels = append(fields.labels, label.ID) + } + groups := fields.groups(choices.EditBody, choices.CanPush, withCurrent(choices.AvailableAssignees, choices.Assignees...), milestones, withCurrent(choices.AvailableLabels, choices.Labels...)) + if err := p.runForm(ctx, huh.NewForm(groups...)); err != nil { + return IssueEditFormValues{}, err + } + values := IssueEditFormValues{Title: strings.TrimSpace(fields.title), Assignees: fields.assignees, Milestone: fields.milestone, Labels: fields.labels} + values.ClearMilestone = fields.milestone == 0 && choices.Milestone != nil + var err error + if values.Body, values.Draft, err = editBody(ctx, choices.EditBody, fields.body); err != nil { + return values, err + } + if strings.TrimSpace(fields.deadline) == deadline { + return values, nil + } + values.Deadline, err = parseDueDate(fields.deadline) + values.ClearDeadline = values.Deadline == nil && deadline != "" + return values, err +} + +// withCurrent appends current values missing from options; selects drop values they do not offer. +func withCurrent[T comparable](options []T, current ...T) []T { + for _, value := range current { + if !slices.Contains(options, value) { + options = append(slices.Clip(options), value) + } + } + return options +} diff --git a/internal/terminal/issue_edit_form_test.go b/internal/terminal/issue_edit_form_test.go new file mode 100644 index 00000000..c2b8ee6b --- /dev/null +++ b/internal/terminal/issue_edit_form_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "bytes" + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestEditIssueEditBodyPreservesDraftOnFailure(t *testing.T) { + p, _ := testPrompter(bytes.NewBufferString("New Title\r")) + path := filepath.Join(t.TempDir(), "draft.md") + values, err := p.EditIssue(context.Background(), IssueEditFormChoices{ + Title: "", Body: "current", + EditBody: func(context.Context, string) (Draft, error) { + return Draft{Path: path, Body: "partial"}, errors.New("editor failed") + }, + }) + require.ErrorContains(t, err, "editor failed") + require.Equal(t, path, values.Draft.Path) + require.Equal(t, "New Title", values.Title) +} + +func TestEditIssueKeepsUnchangedDeadline(t *testing.T) { + deadline := time.Date(2026, 10, 6, 23, 59, 59, 0, time.FixedZone("server", -5*3600)) + p, _ := testPrompter(bytes.NewBufferString("\r\r\r")) + values, err := p.EditIssue(context.Background(), IssueEditFormChoices{Title: "Title", Deadline: &deadline, CanPush: true}) + require.NoError(t, err) + require.Nil(t, values.Deadline) + require.False(t, values.ClearDeadline) +} + +func TestWithCurrentKeepsSeededValuesSelectable(t *testing.T) { + options := []IssueLabelChoice{{Name: "bug", ID: 2}} + merged := withCurrent(options, IssueLabelChoice{Name: "bug", ID: 2}, IssueLabelChoice{Name: "org", ID: 7}) + require.Equal(t, []IssueLabelChoice{{Name: "bug", ID: 2}, {Name: "org", ID: 7}}, merged) + require.Len(t, options, 1) +} diff --git a/internal/terminal/issue_form.go b/internal/terminal/issue_form.go new file mode 100644 index 00000000..c09d40ac --- /dev/null +++ b/internal/terminal/issue_form.go @@ -0,0 +1,175 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "charm.land/huh/v2" +) + +// IssueTemplateChoice seeds title/body when selected in an interactive create form. +type IssueTemplateChoice struct { + Name, About, Title, Body string +} + +// IssueMilestoneChoice identifies an open milestone for interactive selection. +type IssueMilestoneChoice struct { + Title string + ID int64 +} + +// IssueLabelChoice identifies a repository label for interactive selection. +type IssueLabelChoice struct { + Name string + ID int64 +} + +// BodyEditor collects a markdown body through the shared draft editor instead of +// an inline text field. Callers must finish the returned Draft. +type BodyEditor func(context.Context, string) (Draft, error) + +// IssueFormChoices supplies options for an interactive issue create form. +type IssueFormChoices struct { + Templates []IssueTemplateChoice + Assignees []string + Milestones []IssueMilestoneChoice + Labels []IssueLabelChoice + CanPush bool + EditBody BodyEditor +} + +// IssueFormValues holds interactively collected issue create fields. +type IssueFormValues struct { + Title, Body string + Assignees []string + Milestone int64 + Labels []int64 + Deadline *time.Time + Draft Draft +} + +// IssueForm collects interactive issue create input. +type IssueForm interface { + CreateIssue(context.Context, IssueFormChoices) (IssueFormValues, error) +} + +// CreateIssue runs the interactive issue create form on stderr. +func (p *FormPrompter) CreateIssue(ctx context.Context, choices IssueFormChoices) (IssueFormValues, error) { + title, body := "", "" + if len(choices.Templates) > 0 { + options := []huh.Option[int]{huh.NewOption("[none]", -1)} + for i, template := range choices.Templates { + label := template.Name + switch { + case template.Name != "" && template.About != "": + label = template.Name + " — " + template.About + case label == "": + label = template.About + } + if label == "" { + label = "template " + strconv.Itoa(i+1) + } + options = append(options, huh.NewOption(label, i)) + } + selected := -1 + if err := p.runForm(ctx, huh.NewForm(huh.NewGroup( + huh.NewSelect[int]().Title("Issue template:").Options(options...).Value(&selected), + ))); err != nil { + return IssueFormValues{}, err + } + if selected >= 0 { + title, body = choices.Templates[selected].Title, choices.Templates[selected].Body + } + } + return p.collectIssueProperties(ctx, title, body, choices) +} + +// issueFields holds the shared issue form inputs that create and edit bind to. +type issueFields struct { + title, body, deadline string + assignees []string + milestone int64 + labels []int64 +} + +// groups builds title/body and, when canPush, assignee/milestone/label/due-date fields. +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())} + if editBody == nil { + fields = append(fields, huh.NewText().Title("Issue description(markdown):").Value(&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))) + } + 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)) + } + groups = append(groups, huh.NewGroup(huh.NewSelect[int64]().Title("Milestone:").Options(options...).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)) + } + groups = append(groups, huh.NewGroup(huh.NewMultiSelect[int64]().Title("Labels:").Options(options...).Value(&f.labels))) + } + return append(groups, huh.NewGroup(dueDateInput("Due date:", &f.deadline))) +} + +// editBody runs the draft editor after the form, keeping the draft on failure. +func editBody(ctx context.Context, edit BodyEditor, body string) (string, Draft, error) { + if edit == nil { + return body, Draft{}, nil + } + draft, err := edit(ctx, body) + if err != nil { + return body, draft, err + } + return draft.Body, draft, nil +} + +func dueDateInput(title string, value *string) *huh.Input { + return huh.NewInput().Title(title).Placeholder("YYYY-MM-DD").Value(value).Validate(func(s string) error { + _, err := parseDueDate(s) + return err + }) +} + +// parseDueDate returns nil for an empty value. +func parseDueDate(value string) (*time.Time, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + parsed, err := time.Parse(time.DateOnly, value) + if err != nil { + return nil, errors.New("use YYYY-MM-DD") + } + return &parsed, nil +} + +func (p *FormPrompter) collectIssueProperties(ctx context.Context, title, body string, choices IssueFormChoices) (IssueFormValues, error) { + fields := issueFields{title: title, body: body} + if err := p.runForm(ctx, huh.NewForm(fields.groups(choices.EditBody, choices.CanPush, choices.Assignees, choices.Milestones, choices.Labels)...)); err != nil { + return IssueFormValues{}, 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.Deadline, err = parseDueDate(fields.deadline) + return values, err +} diff --git a/internal/terminal/issue_form_test.go b/internal/terminal/issue_form_test.go new file mode 100644 index 00000000..e8c83022 --- /dev/null +++ b/internal/terminal/issue_form_test.go @@ -0,0 +1,36 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "bytes" + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCollectIssuePropertiesUsesEditBodyDraft(t *testing.T) { + var initial string + p, _ := testPrompter(bytes.NewBufferString("Drafted Title\r")) + values, err := p.collectIssueProperties(context.Background(), "", "template body", IssueFormChoices{ + EditBody: func(_ context.Context, body string) (Draft, error) { + initial = body + return Draft{Path: filepath.Join(t.TempDir(), "draft.md"), Body: "edited body"}, nil + }, + }) + require.NoError(t, err) + require.Equal(t, "template body", initial) + require.Equal(t, "Drafted Title", values.Title) + require.Equal(t, "edited body", values.Body) + require.NotEmpty(t, values.Draft.Path) +} + +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):") +} diff --git a/internal/terminal/merge_form.go b/internal/terminal/merge_form.go new file mode 100644 index 00000000..62b451f4 --- /dev/null +++ b/internal/terminal/merge_form.go @@ -0,0 +1,39 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "context" + "errors" + "fmt" + + "charm.land/huh/v2" +) + +// MergePullChoice is one selectable open pull request for interactive merge. +type MergePullChoice struct { + Number int64 + Title, Head string +} + +// MergeForm collects which pull request to merge. +type MergeForm interface { + SelectPull(context.Context, []MergePullChoice) (int64, error) +} + +// SelectPull prompts for a pull request on stderr. +func (p *FormPrompter) SelectPull(ctx context.Context, choices []MergePullChoice) (int64, error) { + if len(choices) == 0 { + return 0, errors.New("no open pull requests match the current branch") + } + options := make([]huh.Option[int64], 0, len(choices)) + for _, choice := range choices { + options = append(options, huh.NewOption(fmt.Sprintf("#%d: %s (%s)", choice.Number, choice.Title, choice.Head), choice.Number)) + } + selected := choices[0].Number + err := p.runForm(ctx, huh.NewForm(huh.NewGroup( + huh.NewSelect[int64]().Title("Select a PR to merge:").Options(options...).Value(&selected), + ))) + return selected, err +} diff --git a/internal/terminal/milestone_form.go b/internal/terminal/milestone_form.go new file mode 100644 index 00000000..4933b63d --- /dev/null +++ b/internal/terminal/milestone_form.go @@ -0,0 +1,37 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "context" + "strings" + "time" + + "charm.land/huh/v2" +) + +// MilestoneFormValues holds interactively collected milestone create fields. +type MilestoneFormValues struct { + Title, Description string + Deadline *time.Time +} + +// MilestoneForm collects interactive milestone create input. +type MilestoneForm interface { + CreateMilestone(context.Context) (MilestoneFormValues, error) +} + +// CreateMilestone runs the interactive milestone create form on stderr. +func (p *FormPrompter) CreateMilestone(ctx context.Context) (MilestoneFormValues, error) { + title, description, deadline := "", "", "" + if err := p.runForm(ctx, huh.NewForm(huh.NewGroup( + huh.NewInput().Title("Milestone title (required):").Value(&title).Validate(huh.ValidateNotEmpty()), + huh.NewText().Title("Milestone description (markdown):").Value(&description), + dueDateInput("Milestone deadline:", &deadline), + ))); err != nil { + return MilestoneFormValues{}, err + } + parsed, err := parseDueDate(deadline) + return MilestoneFormValues{Title: strings.TrimSpace(title), Description: description, Deadline: parsed}, err +} diff --git a/internal/terminal/milestone_form_test.go b/internal/terminal/milestone_form_test.go new file mode 100644 index 00000000..b2b22249 --- /dev/null +++ b/internal/terminal/milestone_form_test.go @@ -0,0 +1,18 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMilestoneFormMarksTitleRequired(t *testing.T) { + p, rendered := accessiblePrompter(t, "v1\n\n\n") + _, err := p.CreateMilestone(context.Background()) + require.NoError(t, err) + require.Contains(t, rendered.String(), "Milestone title (required):") +} diff --git a/internal/terminal/output.go b/internal/terminal/output.go new file mode 100644 index 00000000..8beab7f1 --- /dev/null +++ b/internal/terminal/output.go @@ -0,0 +1,124 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/itchyny/gojq" +) + +// WriteJSON projects explicit result fields and applies an in-process jq filter. +func WriteJSON(w io.Writer, value any, fields, query string) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + var normalized any + if err := json.Unmarshal(data, &normalized); err != nil { + return err + } + if names := fieldNames(fields); names != nil { + selectFields := func(item any) (any, error) { + object, ok := item.(map[string]any) + if !ok { + return nil, errors.New("field selection requires resource objects") + } + selected := map[string]any{} + for _, name := range names { + v, exists := object[name] + if !exists { + return nil, fmt.Errorf("unknown JSON field %q", name) + } + selected[name] = v + } + return selected, nil + } + switch v := normalized.(type) { + case []any: + for i := range v { + v[i], err = selectFields(v[i]) + if err != nil { + return err + } + } + default: + normalized, err = selectFields(v) + if err != nil { + return err + } + } + } + encoder := json.NewEncoder(w) + encoder.SetEscapeHTML(false) + if query == "" { + return encoder.Encode(normalized) + } + parsed, err := parseQuery(query) + if err != nil { + return err + } + iter := parsed.Run(normalized) + for { + value, ok := iter.Next() + if !ok { + return nil + } + if err, ok := value.(error); ok { + return fmt.Errorf("evaluate --jq: %w", err) + } + if str, ok := value.(string); ok { + if _, err := fmt.Fprintln(w, str); err != nil { + return err + } + } else if err := encoder.Encode(value); err != nil { + return err + } + } +} + +// ValidateOutput catches invalid projection/filter syntax before a service mutates data. +func ValidateOutput(fields, query string, allowed []string) error { + if names := fieldNames(fields); names != nil && allowed != nil { + known := map[string]bool{} + for _, name := range allowed { + known[name] = true + } + for _, name := range names { + if !known[name] { + return fmt.Errorf("unknown JSON field %q", name) + } + } + } + if query != "" { + if _, err := parseQuery(query); err != nil { + return err + } + } + return nil +} + +// fieldNames returns the selected JSON fields, or nil when every field is selected. +func fieldNames(fields string) []string { + if fields == "" || fields == "*" { + return nil + } + names := strings.Split(fields, ",") + for i := range names { + names[i] = strings.TrimSpace(names[i]) + } + return names +} + +func parseQuery(query string) (*gojq.Query, error) { + parsed, err := gojq.Parse(query) + if err != nil { + return nil, fmt.Errorf("invalid --jq expression: %w", err) + } + return parsed, nil +} diff --git a/internal/terminal/output_test.go b/internal/terminal/output_test.go new file mode 100644 index 00000000..8d41a1a4 --- /dev/null +++ b/internal/terminal/output_test.go @@ -0,0 +1,24 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestJSONFieldSelectionAndJQ(t *testing.T) { + var out bytes.Buffer + value := []map[string]any{{"number": 1, "title": "first"}, {"number": 2, "title": "second"}} + require.NoError(t, WriteJSON(&out, value, "title", ".[0].title")) + require.Equal(t, "first\n", out.String()) + out.Reset() + require.NoError(t, WriteJSON(&out, []map[string]any{}, "*", "")) + require.Equal(t, "[]\n", out.String()) + require.Error(t, WriteJSON(&out, value, "unknown", "")) + require.NoError(t, ValidateOutput("number, title", "", []string{"number", "title"})) + require.EqualError(t, ValidateOutput("number, foo", "", []string{"number"}), `unknown JSON field "foo"`) +} diff --git a/internal/terminal/pager.go b/internal/terminal/pager.go new file mode 100644 index 00000000..9b305cec --- /dev/null +++ b/internal/terminal/pager.go @@ -0,0 +1,69 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" +) + +// Pager renders multi-line human output through an external command. +type Pager interface { + Page(context.Context, string, io.Reader, Streams) error +} + +// SystemPager runs the pager without a shell and routes diagnostics to stderr. +type SystemPager struct{} + +// Page validates the command and feeds content to the pager stdin. The pager +// handles interrupts itself; killing it on cancellation would leave the terminal raw. +func (SystemPager) Page(ctx context.Context, command string, input io.Reader, streams Streams) error { + args, ok := commandArgs(command) + if !ok { + return errors.New("pager command contains malformed quoted arguments") + } + process := exec.Command(args[0], args[1:]...) + process.Stdin = input + process.Stdout = streams.Out + process.Stderr = streams.ErrOut + env := os.Environ() + if os.Getenv("LESS") == "" { + env = append(env, "LESS=FRX") + } + process.Env = env + if err := process.Run(); err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + return fmt.Errorf("pager failed; configure pager or PAGER: %w", err) + } + return nil +} + +// WriteHuman writes human text directly, or through a pager when stdout is a TTY, +// a pager command is configured, and the content spans multiple lines. +// Prompting policy such as --no-input does not affect paging. +func WriteHuman(ctx context.Context, streams Streams, command string, pager Pager, text string) error { + if !shouldPage(streams, command, text) { + _, err := io.WriteString(streams.Out, text) + return err + } + if pager == nil { + pager = SystemPager{} + } + return pager.Page(ctx, command, strings.NewReader(text), streams) +} + +func shouldPage(streams Streams, command, text string) bool { + if !streams.OutTTY || command == "" || command == "cat" { + return false + } + trimmed := strings.TrimSuffix(text, "\n") + return strings.Contains(trimmed, "\n") +} diff --git a/internal/terminal/pager_test.go b/internal/terminal/pager_test.go new file mode 100644 index 00000000..d78bb9e5 --- /dev/null +++ b/internal/terminal/pager_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "bytes" + "context" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/kballard/go-shellquote" + "github.com/stretchr/testify/require" +) + +type recordingPager struct { + command string + input string + calls int +} + +func (p *recordingPager) Page(_ context.Context, command string, input io.Reader, streams Streams) error { + p.calls++ + p.command = command + data, err := io.ReadAll(input) + if err != nil { + return err + } + p.input = string(data) + _, err = streams.Out.Write(append([]byte("PAGED:"), data...)) + return err +} + +func TestWriteHumanPagesMultiLineTTYOutput(t *testing.T) { + out := &bytes.Buffer{} + pager := &recordingPager{} + streams := Streams{Out: out, OutTTY: true} + require.NoError(t, WriteHuman(context.Background(), streams, "less -R", pager, "one\ntwo\n")) + require.Equal(t, 1, pager.calls) + require.Equal(t, "less -R", pager.command) + require.Equal(t, "one\ntwo\n", pager.input) + require.Equal(t, "PAGED:one\ntwo\n", out.String()) +} + +func TestWriteHumanSkipsPagerWhenNotNeeded(t *testing.T) { + for _, tc := range []struct { + name string + streams Streams + command string + text string + }{ + {"non-tty", Streams{OutTTY: false}, "less", "one\ntwo\n"}, + {"empty pager", Streams{OutTTY: true}, "", "one\ntwo\n"}, + {"cat disables", Streams{OutTTY: true}, "cat", "one\ntwo\n"}, + {"single line", Streams{OutTTY: true}, "less", "one\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + out := &bytes.Buffer{} + tc.streams.Out = out + pager := &recordingPager{} + require.NoError(t, WriteHuman(context.Background(), tc.streams, tc.command, pager, tc.text)) + require.Zero(t, pager.calls) + require.Equal(t, tc.text, out.String()) + }) + } +} + +func TestSystemPagerUsesLiteralArguments(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("pager fixture uses a POSIX executable") + } + directory := t.TempDir() + program := filepath.Join(directory, "pager with spaces") + capture := filepath.Join(directory, "captured") + require.NoError(t, os.WriteFile(program, []byte("#!/bin/sh\ncat > \"$1\"\n"), 0o700)) + out, diagnostics := &bytes.Buffer{}, &bytes.Buffer{} + command := shellquote.Join(program, capture) + require.NoError(t, (SystemPager{}).Page(context.Background(), command, strings.NewReader("paged body\n"), Streams{Out: out, ErrOut: diagnostics})) + data, err := os.ReadFile(capture) + require.NoError(t, err) + require.Equal(t, "paged body\n", string(data)) + require.Empty(t, out.String()) + require.Empty(t, diagnostics.String()) +} diff --git a/internal/terminal/pull_form.go b/internal/terminal/pull_form.go new file mode 100644 index 00000000..8580d2b9 --- /dev/null +++ b/internal/terminal/pull_form.go @@ -0,0 +1,120 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "context" + "strings" + "unicode" + "unicode/utf8" + + "charm.land/huh/v2" +) + +// PullFormChoices supplies defaults and selectable options for interactive PR create. +type PullFormChoices struct { + BaseOwner, Base, HeadOwner, HeadBranch string + // Title and Body seed the form; Title applies only while HeadBranch is kept. + Title, Body string + AllowMaintainerEdits bool + Assignees []string + Milestones []IssueMilestoneChoice + Labels []IssueLabelChoice + CanPush bool + EditBody BodyEditor +} + +// PullFormValues holds interactively collected pull request create fields. +type PullFormValues struct { + IssueFormValues + Agit bool + Base, Head, Topic string + AllowMaintainerEdits bool +} + +// PullForm collects interactive pull create input. +type PullForm interface { + CreatePull(context.Context, PullFormChoices) (PullFormValues, error) +} + +// CreatePull runs the interactive pull create form on stderr. +func (p *FormPrompter) CreatePull(ctx context.Context, choices PullFormChoices) (PullFormValues, error) { + agit, err := p.Confirm(ctx, "Do you want to create an agit flow pull request?") + if err != nil { + return PullFormValues{}, err + } + + base := choices.Base + headOwner := choices.HeadOwner + headBranch := choices.HeadBranch + topic := headBranch + allowMaintainerEdits := choices.AllowMaintainerEdits + + fields := []huh.Field{ + huh.NewInput().Title("Target branch (required):").Value(&base).Validate(huh.ValidateNotEmpty()), + } + if agit { + fields = append(fields, huh.NewInput().Title("Topic branch (required):").Value(&topic).Validate(huh.ValidateNotEmpty())) + } else { + headField := huh.NewInput().Title("Source branch:").Value(&headBranch) + if strings.TrimSpace(headBranch) == "" { + headField = headField.Title("Source branch (required):").Validate(huh.ValidateNotEmpty()) + } + fields = append(fields, + huh.NewInput().Title("Source repo owner:").Value(&headOwner), + headField, + huh.NewConfirm().Title("Allow maintainers to push to the source branch:").Value(&allowMaintainerEdits), + ) + } + if err := p.runForm(ctx, huh.NewForm(huh.NewGroup(fields...))); err != nil { + return PullFormValues{}, err + } + + title := DefaultPullTitle(headBranch) + if choices.Title != "" && headBranch == choices.HeadBranch { + title = choices.Title + } + // AGit pushes carry only a title and description. + properties, err := p.collectIssueProperties(ctx, title, choices.Body, IssueFormChoices{ + Assignees: choices.Assignees, Milestones: choices.Milestones, Labels: choices.Labels, CanPush: choices.CanPush && !agit, + EditBody: choices.EditBody, + }) + if err != nil { + return PullFormValues{IssueFormValues: properties}, err + } + + headBranch = strings.TrimSpace(headBranch) + headOwner = strings.TrimSpace(headOwner) + head := headBranch + switch { + case agit && head == "": + head = "HEAD" + case !agit && headOwner != "" && headOwner != choices.BaseOwner: + head = headOwner + ":" + headBranch + } + return PullFormValues{ + IssueFormValues: properties, + Agit: agit, + Base: strings.TrimSpace(base), + Head: head, + Topic: strings.TrimSpace(topic), + AllowMaintainerEdits: allowMaintainerEdits, + }, nil +} + +// DefaultPullTitle turns a branch-like head into a readable pull title seed. +func DefaultPullTitle(head string) string { + if i := strings.LastIndex(head, ":"); i != -1 { + head = head[i+1:] + } + parts := strings.Fields(strings.ToLower(strings.NewReplacer("_", " ", "-", " ").Replace(head))) + if len(parts) == 0 { + return "" + } + for i, part := range parts { + r, size := utf8.DecodeRuneInString(part) + parts[i] = string(unicode.ToUpper(r)) + part[size:] + } + return strings.Join(parts, " ") +} diff --git a/internal/terminal/pull_form_test.go b/internal/terminal/pull_form_test.go new file mode 100644 index 00000000..d1ed38b2 --- /dev/null +++ b/internal/terminal/pull_form_test.go @@ -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) +} diff --git a/internal/terminal/review_form.go b/internal/terminal/review_form.go new file mode 100644 index 00000000..532b4681 --- /dev/null +++ b/internal/terminal/review_form.go @@ -0,0 +1,94 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "context" + "fmt" + "strings" + + "charm.land/huh/v2" +) + +// ReviewCommentDraft is an inline comment collected during interactive review. +type ReviewCommentDraft struct { + Path, Body string + OldLine, NewLine int64 +} + +// ReviewFormChoices supplies callbacks for interactive pull review. +type ReviewFormChoices struct { + // ReviewDiff, when non-nil, runs after the user opts into annotating the diff. + ReviewDiff func(context.Context) ([]ReviewCommentDraft, error) + EditBody BodyEditor +} + +// ReviewFormValues holds interactively collected review fields. +type ReviewFormValues struct { + Event string + Body string + Comments []ReviewCommentDraft + Draft Draft +} + +// ReviewForm collects interactive pull review input. +type ReviewForm interface { + CreateReview(context.Context, ReviewFormChoices) (ReviewFormValues, error) +} + +// CreateReview runs the interactive review form on stderr. +func (p *FormPrompter) CreateReview(ctx context.Context, choices ReviewFormChoices) (ReviewFormValues, error) { + var comments []ReviewCommentDraft + if choices.ReviewDiff != nil { + reviewDiff := true + if err := p.runForm(ctx, huh.NewForm(huh.NewGroup( + huh.NewConfirm().Title("Review / comment the diff?").Value(&reviewDiff), + ))); err != nil { + return ReviewFormValues{}, err + } + if reviewDiff { + parsed, err := choices.ReviewDiff(ctx) + if err != nil { + return ReviewFormValues{}, fmt.Errorf("diff review failed: %w", err) + } + comments = append(comments, parsed...) + fmt.Fprintf(p.Streams.ErrOut, "Found %d code comments in your review\n", len(comments)) + } + } + + event := "COMMENT" + if err := p.runForm(ctx, huh.NewForm(huh.NewGroup( + huh.NewSelect[string]().Title("Your assessment:").Options( + huh.NewOption("comment", "COMMENT"), + huh.NewOption("request changes", "REQUEST_CHANGES"), + huh.NewOption("approve", "APPROVED"), + ).Value(&event), + ))); err != nil { + return ReviewFormValues{}, err + } + + body := "" + values := ReviewFormValues{Event: event, Comments: comments} + required := (event == "COMMENT" && len(comments) == 0) || event == "REQUEST_CHANGES" + if choices.EditBody != nil { + var err error + if body, values.Draft, err = editBody(ctx, choices.EditBody, ""); err != nil { + return values, err + } + } else { + field := huh.NewText().Title("Concluding comment(markdown):").Value(&body) + if required { + 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 + } + } + body = strings.TrimSpace(body) + if required && body == "" { + return values, fmt.Errorf("concluding comment cannot be empty") + } + values.Body = body + return values, nil +} diff --git a/internal/terminal/review_form_test.go b/internal/terminal/review_form_test.go new file mode 100644 index 00000000..5b317012 --- /dev/null +++ b/internal/terminal/review_form_test.go @@ -0,0 +1,34 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateReviewAbortsWhenDiffReviewFails(t *testing.T) { + failure := errors.New("editor failed") + p, _ := accessiblePrompter(t, "y\n1\n") + _, err := p.CreateReview(context.Background(), ReviewFormChoices{ + ReviewDiff: func(context.Context) ([]ReviewCommentDraft, error) { return nil, failure }, + }) + require.ErrorIs(t, err, failure) +} + +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)") + + 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):") +} diff --git a/internal/terminal/streams.go b/internal/terminal/streams.go new file mode 100644 index 00000000..d5c9c3d0 --- /dev/null +++ b/internal/terminal/streams.go @@ -0,0 +1,130 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "charm.land/huh/v2" + "gitea.dev/tea/internal/clierr" + "golang.org/x/term" +) + +// Streams records injected IO and independently detected terminal capabilities. +type Streams struct { + In io.Reader + Out io.Writer + ErrOut io.Writer + InTTY bool + OutTTY bool + ErrTTY bool +} + +// System binds streams to the current process and detects each terminal. +func System() Streams { + return Streams{In: os.Stdin, Out: os.Stdout, ErrOut: os.Stderr, InTTY: term.IsTerminal(int(os.Stdin.Fd())), OutTTY: term.IsTerminal(int(os.Stdout.Fd())), ErrTTY: term.IsTerminal(int(os.Stderr.Fd()))} +} + +// Policy controls input interaction independently of supplied flag count. +type Policy struct{ Enabled, NoInput, Machine, Interactive bool } + +// CanPrompt requires terminal input and diagnostics, without requiring terminal stdout. +func (p Policy) CanPrompt(s Streams) bool { + return p.Enabled && !p.NoInput && (!p.Machine || p.Interactive) && s.InTTY && s.ErrTTY +} + +// Validate rejects an explicit interactive request incompatible with terminal policy. +func (p Policy) Validate(s Streams) error { + if p.Interactive && !p.CanPrompt(s) { + return clierr.Wrap(clierr.Usage, errors.New("--interactive requires terminal stdin/stderr, enabled prompting, and no --no-input")) + } + return nil +} + +// Prompter abstracts cancellable input collection and confirmation. +type Prompter interface { + Input(context.Context, string) (string, error) + SecretInput(context.Context, string) (string, error) + Confirm(context.Context, string) (bool, error) +} + +// FormPrompter uses cancellable terminal forms with explicitly injected streams. +type FormPrompter struct{ Streams Streams } + +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 err +} + +func (p *FormPrompter) runForm(ctx context.Context, form *huh.Form) error { + 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) { + var value string + err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewInput().Title(title).Value(&value)))) + return strings.TrimSpace(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 + err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewInput().Title(title).EchoMode(huh.EchoModePassword).Value(&value)))) + return value, err +} + +// Confirm collects a yes/no choice through cancellable terminal input. +func (p *FormPrompter) Confirm(ctx context.Context, title string) (bool, error) { + var accepted bool + 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) { + 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) + 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 { + if yes { + return nil + } + if !policy.CanPrompt(streams) { + return clierr.Wrap(clierr.Usage, errors.New("confirmation required: supply --yes")) + } + accepted, err := prompt.Confirm(ctx, description) + if err != nil { + return err + } + if !accepted { + return clierr.Wrap(clierr.Canceled, errors.New("operation canceled")) + } + return nil +} diff --git a/internal/terminal/streams_test.go b/internal/terminal/streams_test.go new file mode 100644 index 00000000..ff4248bc --- /dev/null +++ b/internal/terminal/streams_test.go @@ -0,0 +1,63 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// testPrompter returns a form prompter reading in and the buffer its forms render to. +func testPrompter(in io.Reader) (*FormPrompter, *bytes.Buffer) { + rendered := &bytes.Buffer{} + return &FormPrompter{Streams: Streams{In: in, Out: &bytes.Buffer{}, ErrOut: rendered, InTTY: true, ErrTTY: true}}, rendered +} + +// accessiblePrompter is testPrompter in accessible mode, which renders field titles as plain text. +func accessiblePrompter(t *testing.T, input string) (*FormPrompter, *bytes.Buffer) { + t.Helper() + t.Setenv("TERM", "dumb") + return testPrompter(strings.NewReader(input)) +} + +func noEdit(context.Context, string) (Draft, error) { return Draft{}, nil } + +type fakePrompt struct{ inputs int } + +func (f *fakePrompt) Input(context.Context, string) (string, error) { + f.inputs++ + return "provided", nil +} +func (f *fakePrompt) SecretInput(context.Context, string) (string, error) { return "", nil } +func (f *fakePrompt) Confirm(context.Context, string) (bool, error) { return false, nil } + +func TestPromptPolicy(t *testing.T) { + for _, tc := range []struct { + policy Policy + streams Streams + expected bool + }{ + {Policy{Enabled: true}, Streams{InTTY: true, ErrTTY: true}, true}, + {Policy{Enabled: true}, Streams{InTTY: true, OutTTY: true}, false}, + {Policy{Enabled: true, NoInput: true}, Streams{InTTY: true, ErrTTY: true}, false}, + {Policy{Enabled: true, Machine: true}, Streams{InTTY: true, ErrTTY: true}, false}, + {Policy{Enabled: true, Machine: true, Interactive: true}, Streams{InTTY: true, ErrTTY: true}, true}, + } { + require.Equal(t, tc.expected, tc.policy.CanPrompt(tc.streams)) + } + prompt := &fakePrompt{} + value := "supplied" + asked, err := Require(context.Background(), &value, "title", Streams{}, Policy{}, prompt) + require.NoError(t, err) + require.False(t, asked) + require.Zero(t, prompt.inputs) + value = "" + _, err = Require(context.Background(), &value, "title", Streams{}, Policy{}, prompt) + require.ErrorContains(t, err, "--title") +} diff --git a/internal/terminal/table.go b/internal/terminal/table.go new file mode 100644 index 00000000..4686ce44 --- /dev/null +++ b/internal/terminal/table.go @@ -0,0 +1,119 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "strings" + "unicode" + + "github.com/mattn/go-runewidth" +) + +// Cell is one value in a Table. On color terminals Link, Color (six-digit RGB hex) +// and Bold style the text after alignment, so escape sequences never count toward +// the column width. +type Cell struct { + Text, Link, Color string + Bold bool +} + +// style applies the cell's link, color and emphasis to its already safe text. +func (c Cell) style(text string) string { + return Hyperlink(c.Link, Emphasis(Colorize(c.Color, text, true), c.Bold), true) +} + +// Row returns unstyled cells. +func Row(texts ...string) []Cell { + cells := make([]Cell, len(texts)) + for i, text := range texts { + cells[i].Text = text + } + return cells +} + +// Table renders one line per row with columns aligned and separated by two spaces. +// Cell text passes through SafeLine, so server data can neither break the layout +// nor reach the terminal as escape sequences. +func Table(rows [][]Cell, color bool) string { + texts := make([][]string, len(rows)) + var widths []int + for r, row := range rows { + texts[r] = make([]string, len(row)) + for i, cell := range row { + texts[r][i] = SafeLine(cell.Text) + if i == len(widths) { + widths = append(widths, 0) + } + widths[i] = max(widths[i], runewidth.StringWidth(texts[r][i])) + } + } + var b strings.Builder + for r, row := range rows { + var line strings.Builder + for i, cell := range row { + text := texts[r][i] + if color { + text = cell.style(text) + } + line.WriteString(text) + if i < len(row)-1 { + line.WriteString(strings.Repeat(" ", widths[i]-runewidth.StringWidth(texts[r][i])+2)) + } + } + b.WriteString(strings.TrimRight(line.String(), " ")) + b.WriteByte('\n') + } + return b.String() +} + +// Record is one item for human readers: a heading line of cells separated by spaces, +// followed by text lines such as a body and a URL. +type Record struct { + Heading []Cell + Lines []string +} + +// Render formats the record. Heading cells pass through SafeLine and are styled like +// Table cells; lines pass through SafeText, so they keep their own line breaks. +func (r Record) Render(color bool) string { + var b strings.Builder + for i, cell := range r.Heading { + if i > 0 { + b.WriteByte(' ') + } + text := SafeLine(cell.Text) + if color { + text = cell.style(text) + } + b.WriteString(text) + } + b.WriteByte('\n') + for _, line := range r.Lines { + b.WriteString(SafeText(line)) + b.WriteByte('\n') + } + return b.String() +} + +// SafeLine reduces server text to one line: SafeText, with tabs and line breaks as spaces. +func SafeLine(s string) string { + return strings.Map(func(r rune) rune { + if r == '\t' || r == '\n' { + return ' ' + } + return r + }, SafeText(s)) +} + +// SafeText keeps tabs and line breaks in server text, normalizing CRLF to LF, and drops +// every other control or format character, such as escape sequences, lone carriage +// returns and bidirectional overrides, so the text cannot drive the terminal. +func SafeText(s string) string { + return strings.Map(func(r rune) rune { + if r != '\t' && r != '\n' && (unicode.IsControl(r) || unicode.Is(unicode.Cf, r)) { + return -1 + } + return r + }, strings.ReplaceAll(s, "\r\n", "\n")) +} diff --git a/internal/terminal/table_test.go b/internal/terminal/table_test.go new file mode 100644 index 00000000..70f5f2b4 --- /dev/null +++ b/internal/terminal/table_test.go @@ -0,0 +1,71 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package terminal + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTable(t *testing.T) { + for _, tc := range []struct { + name string + rows [][]Cell + color bool + want string + }{ + {"empty", nil, false, ""}, + {"aligns columns", [][]Cell{Row("7", "Fix login", "open"), Row("12", "Crash", "closed")}, false, "7 Fix login open\n12 Crash closed\n"}, + {"counts runes, not bytes", [][]Cell{Row("1", "äöü", "x"), Row("2", "abcd", "y")}, false, "1 äöü x\n2 abcd y\n"}, + {"counts wide runes twice", [][]Cell{Row("1", "修复", "x"), Row("2", "abcd", "y")}, false, "1 修复 x\n2 abcd y\n"}, + {"trims empty trailing cells", [][]Cell{Row("1", "bug", ""), Row("22", "feature", "new work")}, false, "1 bug\n22 feature new work\n"}, + {"pads ragged rows", [][]Cell{Row("1"), Row("2", "two")}, false, "1\n2 two\n"}, + { + "strips hostile title", + [][]Cell{Row("1", "a\x1b]52;c;cHduZWQ=\x07b\x1b[2J\u009b31m‮txt.exe\r\ntail\tend", "open")}, + false, + "1 a]52;c;cHduZWQ=b[2J31mtxt.exe tail end open\n", + }, + {"ignores styles without color", [][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000"}, {Text: "open", Bold: true}}}, false, "7 bug open\n"}, + { + "styles after alignment", + [][]Cell{{{Text: "7", Link: "https://example.org/7"}, {Text: "bug", Color: "ff0000"}, {Text: "open", Bold: true}}, Row("12", "feature", "closed")}, + true, + "\x1b]8;;https://example.org/7\x1b\\7\x1b]8;;\x1b\\ \x1b[38;2;255;0;0mbug\x1b[0m \x1b[1mopen\x1b[0m\n12 feature closed\n", + }, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, Table(tc.rows, tc.color)) + }) + } +} + +func TestRecord(t *testing.T) { + record := Record{ + Heading: []Cell{{Text: "#7", Bold: true}, {Text: "Crash\x1b[2J\nnow"}, {Text: "bob\u202e"}}, + Lines: []string{"Steps:\r\n\x1b]52;c;aGk=\x07run\n", "https://example.org/7"}, + } + require.Equal(t, "#7 Crash[2J now bob\nSteps:\n]52;c;aGk=run\n\nhttps://example.org/7\n", record.Render(false)) + require.Equal(t, "\x1b[1m#7\x1b[0m Crash[2J now bob\nSteps:\n]52;c;aGk=run\n\nhttps://example.org/7\n", record.Render(true)) +} + +func TestSafeText(t *testing.T) { + for _, tc := range []struct{ name, in, text, line string }{ + {"plain", "fix login", "fix login", "fix login"}, + {"keeps tabs and line breaks", "a\tb\r\nc\nd", "a\tb\nc\nd", "a b c d"}, + {"drops lone carriage returns", "safe\rFAKE", "safeFAKE", "safeFAKE"}, + { + "strips hostile body", + "Steps:\r\n\x1b]52;c;cHduZWQ=\x07run\x1b[2J\u009b31m\u202e\u200bit\x7f\n\x00done", + "Steps:\n]52;c;cHduZWQ=run[2J31mit\ndone", + "Steps: ]52;c;cHduZWQ=run[2J31mit done", + }, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.text, SafeText(tc.in)) + require.Equal(t, tc.line, SafeLine(tc.in)) + }) + } +}