Files
gitea-tea/internal/terminal/streams.go
T
bircni 50a604f169 refactor: remove helpers the new output and prompts left unused
Drop writeDownload, terminal.Require, terminal.Confirm, terminal.Row,
terminal.Record and Pulls.FindOpenByHead, which no command calls anymore,
and point their tests at the code that replaced them.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-12 10:00:05 +02:00

265 lines
9.2 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package terminal
import (
"context"
"errors"
"fmt"
"io"
"os"
"strconv"
"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
// Width is the number of columns human output on Out fits in; 0 means unlimited.
Width int
}
// 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())),
Width: terminalWidth(int(os.Stdout.Fd()), os.Getenv),
}
}
// terminalWidth returns the width of the terminal at fd, then $COLUMNS, then 80. A pty without a
// window size, such as `script` run without a terminal, reports a width of 0.
func terminalWidth(fd int, getenv func(string) string) int {
if width, _, err := term.GetSize(fd); err == nil && width > 0 {
return width
}
if width, err := strconv.Atoi(getenv("COLUMNS")); err == nil && width > 0 {
return width
}
return 80
}
// 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
}
// ErrCanceled reports a declined confirmation or an aborted prompt. It exits with code 3 and is
// meant to be reported as "Canceled." rather than as an error.
var ErrCanceled = clierr.Wrap(clierr.Canceled, errors.New("canceled"))
// 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)
}
// Asker is the richer prompt API behind ConfirmAction and RequireInput: inline validation,
// multi-line text and confirmations with a default answer. FormPrompter implements it; a
// Prompter without it gets plain Input and Confirm behavior.
type Asker interface {
// Ask collects one line, re-prompting inline until validate accepts it.
Ask(ctx context.Context, title string, validate func(string) error) (string, error)
// AskText collects multi-line markdown, starting from initial.
AskText(ctx context.Context, title, initial string) (string, error)
// ConfirmDefault asks a yes/no question that Enter answers with defaultYes.
ConfirmDefault(ctx context.Context, title string, defaultYes bool) (bool, error)
}
// FormPrompter uses cancellable terminal forms with explicitly injected streams.
type FormPrompter struct {
Streams Streams
// size reports the size of the terminal that forms render on; nil queries ErrOut.
size func() (width, height int, err error)
}
func promptError(ctx context.Context, err error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if errors.Is(err, huh.ErrUserAborted) {
return ErrCanceled
}
return err
}
// errNoTerminalSize refuses forms on a terminal without a size, such as a pty that `script` opens
// without a terminal. Forms size their fields from it, and a zero size panics in bubbles; nobody
// could see the form there anyway.
var errNoTerminalSize = clierr.Wrap(clierr.Usage, errors.New("terminal size unknown; pass the values as flags or use --no-input"))
func (p *FormPrompter) runForm(ctx context.Context, form *huh.Form) error {
size := p.size
if file, ok := p.Streams.ErrOut.(*os.File); ok && size == nil {
size = func() (int, int, error) { return term.GetSize(int(file.Fd())) }
}
if size != nil {
if width, height, err := size(); err == nil && (width <= 0 || height <= 0) {
return errNoTerminalSize
}
}
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) {
return p.Ask(ctx, title, nil)
}
// Ask collects one line, re-prompting inline until validate accepts the trimmed answer.
func (p *FormPrompter) Ask(ctx context.Context, title string, validate func(string) error) (string, error) {
var value string
input := huh.NewInput().Title(title).Value(&value)
if validate != nil {
input = input.Validate(func(s string) error { return validate(strings.TrimSpace(s)) })
}
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(input)))
return strings.TrimSpace(value), err
}
// AskText collects multi-line markdown in an inline field, starting from initial.
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
}
// 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 asks a yes/no question that Enter answers with no.
func (p *FormPrompter) Confirm(ctx context.Context, title string) (bool, error) {
return p.ConfirmDefault(ctx, title, false)
}
// ConfirmDefault asks a yes/no question that Enter answers with defaultYes.
func (p *FormPrompter) ConfirmDefault(ctx context.Context, title string, defaultYes bool) (bool, error) {
accepted := defaultYes
err := p.runForm(ctx, huh.NewForm(huh.NewGroup(huh.NewConfirm().Title(title).Value(&accepted))))
return accepted, err
}
// RequireInput fills a missing value from a prompt titled label, re-prompting inline until the
// answer is non-empty and validate, when set, accepts it. When prompts are unavailable it returns
// a usage error naming --flag. It reports whether it prompted.
func RequireInput(ctx context.Context, value *string, flag, label string, validate func(string) error, 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))
}
var entered string
var err error
if asker, ok := prompt.(Asker); ok {
entered, err = asker.Ask(ctx, label, func(s string) error {
if s == "" {
return errors.New("a value is required")
}
if validate != nil {
return validate(s)
}
return nil
})
} else if entered, err = prompt.Input(ctx, label); err == nil {
switch {
case entered == "":
err = clierr.Wrap(clierr.Usage, fmt.Errorf("--%s cannot be empty", flag))
case validate != nil:
if invalid := validate(entered); invalid != nil {
err = clierr.Wrap(clierr.Usage, fmt.Errorf("--%s: %w", flag, invalid))
}
}
}
if err != nil {
return false, err
}
*value = entered
return true, nil
}
// Confirmation describes the question asked before an action.
type Confirmation struct {
// Prompt asks about the action in sentence case: `Delete label "bug" from alice/x?`.
Prompt string
// DefaultYes lets Enter accept, for constructive actions the user just asked for, such as
// submitting a form they filled in. Destructive actions keep the default no.
DefaultYes bool
// Typed, when set, must be typed to confirm, for irreversible actions such as deleting a repository.
Typed string
// NonTTY names the action in the error returned when no prompt is possible and --yes is
// missing: `delete label "bug" from alice/x`.
NonTTY string
}
// ConfirmAction asks c unless yes is set. A declined or aborted confirmation returns ErrCanceled.
func ConfirmAction(ctx context.Context, c Confirmation, yes bool, streams Streams, policy Policy, prompt Prompter) error {
if yes {
return nil
}
if !policy.CanPrompt(streams) {
action := ""
if c.NonTTY != "" {
action = " to " + c.NonTTY
}
return clierr.Wrap(clierr.Usage, fmt.Errorf("confirmation required%s; rerun with --yes", action))
}
asker, rich := prompt.(Asker)
if c.Typed != "" {
title := c.Prompt + " Type " + c.Typed + " to confirm:"
matches := func(s string) bool { return strings.EqualFold(strings.TrimSpace(s), c.Typed) }
if rich {
_, err := asker.Ask(ctx, title, func(s string) error {
if !matches(s) {
return fmt.Errorf("type %s to confirm", c.Typed)
}
return nil
})
return err
}
typed, err := prompt.Input(ctx, title)
if err == nil && !matches(typed) {
err = ErrCanceled
}
return err
}
var accepted bool
var err error
if rich {
accepted, err = asker.ConfirmDefault(ctx, c.Prompt, c.DefaultYes)
} else {
accepted, err = prompt.Confirm(ctx, c.Prompt)
}
if err == nil && !accepted {
err = ErrCanceled
}
return err
}