Files
gitea-tea/internal/terminal/streams.go
T
bircni 55574a9fd0 feat: fit list tables to the terminal and write TSV when piped
Lists had no header, wrapped every row once a title was longer than
the terminal, and were space-padded when piped, so cut -f and awk -F'\t'
could not split them and empty cells collapsed.

terminal.Table now takes column specs and a Display. On a terminal it
prints an uppercase header, aligns columns two spaces apart and shrinks
flexible columns, widest first, to Streams.Width (the terminal, then
$COLUMNS, then 80), cutting by display width with "…". States use the
terminal's ANSI 16 colors, hex colors fall back to the 256-color palette
unless COLORTERM advertises 24-bit, and OSC 8 links appear only on the
first column, independently of color (TEA_HYPERLINKS=auto|always|never).
Piped tables are tab-separated without header, color or links, and times
are RFC3339 UTC instead of "about 2 hours ago".

terminal.View lays out single resources (title, badges, metadata, fields,
body, footer URL) and renders markdown bodies with glamour on a terminal
only (TEA_MARKDOWN=raw keeps them raw). Shared formatters cover relative
times, timestamps, sizes, durations and status icons.

In the app, writeTable and writeView are the new list and view writers:
a "Showing N of M …" context line, an empty-state message on stderr and
the next-page hint. writeList, writeItem and batch keep working and now
render through the new table: tab-separated when piped, truncated at the
widest column on a terminal, with headers for issue list and pr list.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-11 22:43:10 +02:00

150 lines
4.8 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
}
// 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
}