cli/pkg/iostreams/iostreams.go
2023-08-28 08:08:43 -05:00

535 lines
11 KiB
Go

package iostreams
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"strings"
"sync"
"time"
"github.com/briandowns/spinner"
ghTerm "github.com/cli/go-gh/v2/pkg/term"
"github.com/cli/safeexec"
"github.com/google/shlex"
"github.com/mattn/go-colorable"
"github.com/mattn/go-isatty"
)
const DefaultWidth = 80
// ErrClosedPagerPipe is the error returned when writing to a pager that has been closed.
type ErrClosedPagerPipe struct {
error
}
type fileWriter interface {
io.Writer
Fd() uintptr
}
type fileReader interface {
io.ReadCloser
Fd() uintptr
}
type term interface {
IsTerminalOutput() bool
IsColorEnabled() bool
Is256ColorSupported() bool
IsTrueColorSupported() bool
Theme() string
Size() (int, int, error)
}
type IOStreams struct {
term term
In fileReader
Out fileWriter
ErrOut fileWriter
terminalTheme string
progressIndicatorEnabled bool
progressIndicator *spinner.Spinner
progressIndicatorMu sync.Mutex
alternateScreenBufferEnabled bool
alternateScreenBufferActive bool
alternateScreenBufferMu sync.Mutex
stdinTTYOverride bool
stdinIsTTY bool
stdoutTTYOverride bool
stdoutIsTTY bool
stderrTTYOverride bool
stderrIsTTY bool
colorOverride bool
colorEnabled bool
pagerCommand string
pagerProcess *os.Process
neverPrompt bool
TempFileOverride *os.File
}
func (s *IOStreams) ColorEnabled() bool {
if s.colorOverride {
return s.colorEnabled
}
return s.term.IsColorEnabled()
}
func (s *IOStreams) ColorSupport256() bool {
if s.colorOverride {
return s.colorEnabled
}
return s.term.Is256ColorSupported()
}
func (s *IOStreams) HasTrueColor() bool {
if s.colorOverride {
return s.colorEnabled
}
return s.term.IsTrueColorSupported()
}
// DetectTerminalTheme is a utility to call before starting the output pager so that the terminal background
// can be reliably detected.
func (s *IOStreams) DetectTerminalTheme() {
if !s.ColorEnabled() || s.pagerProcess != nil {
s.terminalTheme = "none"
return
}
style := os.Getenv("GLAMOUR_STYLE")
if style != "" && style != "auto" {
// ensure GLAMOUR_STYLE takes precedence over "light" and "dark" themes
s.terminalTheme = "none"
return
}
s.terminalTheme = s.term.Theme()
}
// TerminalTheme returns "light", "dark", or "none" depending on the background color of the terminal.
func (s *IOStreams) TerminalTheme() string {
if s.terminalTheme == "" {
s.DetectTerminalTheme()
}
return s.terminalTheme
}
func (s *IOStreams) SetColorEnabled(colorEnabled bool) {
s.colorOverride = true
s.colorEnabled = colorEnabled
}
func (s *IOStreams) SetStdinTTY(isTTY bool) {
s.stdinTTYOverride = true
s.stdinIsTTY = isTTY
}
func (s *IOStreams) IsStdinTTY() bool {
if s.stdinTTYOverride {
return s.stdinIsTTY
}
if stdin, ok := s.In.(*os.File); ok {
return isTerminal(stdin)
}
return false
}
func (s *IOStreams) SetStdoutTTY(isTTY bool) {
s.stdoutTTYOverride = true
s.stdoutIsTTY = isTTY
}
func (s *IOStreams) IsStdoutTTY() bool {
if s.stdoutTTYOverride {
return s.stdoutIsTTY
}
// support GH_FORCE_TTY
if s.term.IsTerminalOutput() {
return true
}
stdout, ok := s.Out.(*os.File)
return ok && isCygwinTerminal(stdout.Fd())
}
func (s *IOStreams) SetStderrTTY(isTTY bool) {
s.stderrTTYOverride = true
s.stderrIsTTY = isTTY
}
func (s *IOStreams) IsStderrTTY() bool {
if s.stderrTTYOverride {
return s.stderrIsTTY
}
if stderr, ok := s.ErrOut.(*os.File); ok {
return isTerminal(stderr)
}
return false
}
func (s *IOStreams) SetPager(cmd string) {
s.pagerCommand = cmd
}
func (s *IOStreams) GetPager() string {
return s.pagerCommand
}
func (s *IOStreams) StartPager() error {
if s.pagerCommand == "" || s.pagerCommand == "cat" || !s.IsStdoutTTY() {
return nil
}
pagerArgs, err := shlex.Split(s.pagerCommand)
if err != nil {
return err
}
pagerEnv := os.Environ()
for i := len(pagerEnv) - 1; i >= 0; i-- {
if strings.HasPrefix(pagerEnv[i], "PAGER=") {
pagerEnv = append(pagerEnv[0:i], pagerEnv[i+1:]...)
}
}
if _, ok := os.LookupEnv("LESS"); !ok {
pagerEnv = append(pagerEnv, "LESS=FRX")
}
if _, ok := os.LookupEnv("LV"); !ok {
pagerEnv = append(pagerEnv, "LV=-c")
}
pagerExe, err := safeexec.LookPath(pagerArgs[0])
if err != nil {
return err
}
pagerCmd := exec.Command(pagerExe, pagerArgs[1:]...)
pagerCmd.Env = pagerEnv
pagerCmd.Stdout = s.Out
pagerCmd.Stderr = s.ErrOut
pagedOut, err := pagerCmd.StdinPipe()
if err != nil {
return err
}
s.Out = &fdWriteCloser{
fd: s.Out.Fd(),
WriteCloser: &pagerWriter{pagedOut},
}
err = pagerCmd.Start()
if err != nil {
return err
}
s.pagerProcess = pagerCmd.Process
return nil
}
func (s *IOStreams) StopPager() {
if s.pagerProcess == nil {
return
}
// if a pager was started, we're guaranteed to have a WriteCloser
_ = s.Out.(io.WriteCloser).Close()
_, _ = s.pagerProcess.Wait()
s.pagerProcess = nil
}
func (s *IOStreams) CanPrompt() bool {
if s.neverPrompt {
return false
}
return s.IsStdinTTY() && s.IsStdoutTTY()
}
func (s *IOStreams) GetNeverPrompt() bool {
return s.neverPrompt
}
func (s *IOStreams) SetNeverPrompt(v bool) {
s.neverPrompt = v
}
func (s *IOStreams) StartProgressIndicator() {
s.StartProgressIndicatorWithLabel("")
}
func (s *IOStreams) StartProgressIndicatorWithLabel(label string) {
if !s.progressIndicatorEnabled {
return
}
s.progressIndicatorMu.Lock()
defer s.progressIndicatorMu.Unlock()
if s.progressIndicator != nil {
if label == "" {
s.progressIndicator.Prefix = ""
} else {
s.progressIndicator.Prefix = label + " "
}
return
}
// https://github.com/briandowns/spinner#available-character-sets
dotStyle := spinner.CharSets[11]
sp := spinner.New(dotStyle, 120*time.Millisecond, spinner.WithWriter(s.ErrOut), spinner.WithColor("fgCyan"))
if label != "" {
sp.Prefix = label + " "
}
sp.Start()
s.progressIndicator = sp
}
func (s *IOStreams) StopProgressIndicator() {
s.progressIndicatorMu.Lock()
defer s.progressIndicatorMu.Unlock()
if s.progressIndicator == nil {
return
}
s.progressIndicator.Stop()
s.progressIndicator = nil
}
func (s *IOStreams) RunWithProgress(label string, run func() error) error {
s.StartProgressIndicatorWithLabel(label)
defer s.StopProgressIndicator()
return run()
}
func (s *IOStreams) StartAlternateScreenBuffer() {
if s.alternateScreenBufferEnabled {
s.alternateScreenBufferMu.Lock()
defer s.alternateScreenBufferMu.Unlock()
if _, err := fmt.Fprint(s.Out, "\x1b[?1049h"); err == nil {
s.alternateScreenBufferActive = true
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
go func() {
<-ch
s.StopAlternateScreenBuffer()
os.Exit(1)
}()
}
}
}
func (s *IOStreams) StopAlternateScreenBuffer() {
s.alternateScreenBufferMu.Lock()
defer s.alternateScreenBufferMu.Unlock()
if s.alternateScreenBufferActive {
fmt.Fprint(s.Out, "\x1b[?1049l")
s.alternateScreenBufferActive = false
}
}
func (s *IOStreams) SetAlternateScreenBufferEnabled(enabled bool) {
s.alternateScreenBufferEnabled = enabled
}
func (s *IOStreams) RefreshScreen() {
if s.IsStdoutTTY() {
// Move cursor to 0,0
fmt.Fprint(s.Out, "\x1b[0;0H")
// Clear from cursor to bottom of screen
fmt.Fprint(s.Out, "\x1b[J")
}
}
// TerminalWidth returns the width of the terminal that controls the process
func (s *IOStreams) TerminalWidth() int {
w, _, err := s.term.Size()
if err == nil && w > 0 {
return w
}
return DefaultWidth
}
func (s *IOStreams) ColorScheme() *ColorScheme {
return NewColorScheme(s.ColorEnabled(), s.ColorSupport256(), s.HasTrueColor())
}
func (s *IOStreams) ReadUserFile(fn string) ([]byte, error) {
var r io.ReadCloser
if fn == "-" {
r = s.In
} else {
var err error
r, err = os.Open(fn)
if err != nil {
return nil, err
}
}
defer r.Close()
return io.ReadAll(r)
}
func (s *IOStreams) TempFile(dir, pattern string) (*os.File, error) {
if s.TempFileOverride != nil {
return s.TempFileOverride, nil
}
return os.CreateTemp(dir, pattern)
}
func System() *IOStreams {
terminal := ghTerm.FromEnv()
var stdout fileWriter = os.Stdout
// On Windows with no virtual terminal processing support, translate ANSI escape
// sequences to console syscalls.
if colorableStdout := colorable.NewColorable(os.Stdout); colorableStdout != os.Stdout {
// Ensure that the file descriptor of the original stdout is preserved.
stdout = &fdWriter{
fd: os.Stdout.Fd(),
Writer: colorableStdout,
}
}
var stderr fileWriter = os.Stderr
// On Windows with no virtual terminal processing support, translate ANSI escape
// sequences to console syscalls.
if colorableStderr := colorable.NewColorable(os.Stderr); colorableStderr != os.Stderr {
// Ensure that the file descriptor of the original stderr is preserved.
stderr = &fdWriter{
fd: os.Stderr.Fd(),
Writer: colorableStderr,
}
}
io := &IOStreams{
In: os.Stdin,
Out: stdout,
ErrOut: stderr,
pagerCommand: os.Getenv("PAGER"),
term: &terminal,
}
stdoutIsTTY := io.IsStdoutTTY()
stderrIsTTY := io.IsStderrTTY()
if stdoutIsTTY && stderrIsTTY {
io.progressIndicatorEnabled = true
}
if stdoutIsTTY && hasAlternateScreenBuffer(terminal.IsTrueColorSupported()) {
io.alternateScreenBufferEnabled = true
}
return io
}
type fakeTerm struct{}
func (t fakeTerm) IsTerminalOutput() bool {
return false
}
func (t fakeTerm) IsColorEnabled() bool {
return false
}
func (t fakeTerm) Is256ColorSupported() bool {
return false
}
func (t fakeTerm) IsTrueColorSupported() bool {
return false
}
func (t fakeTerm) Theme() string {
return ""
}
func (t fakeTerm) Size() (int, int, error) {
return 80, -1, nil
}
func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) {
in := &bytes.Buffer{}
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
io := &IOStreams{
In: &fdReader{
fd: 0,
ReadCloser: io.NopCloser(in),
},
Out: &fdWriter{fd: 1, Writer: out},
ErrOut: &fdWriter{fd: 2, Writer: errOut},
term: &fakeTerm{},
}
io.SetStdinTTY(false)
io.SetStdoutTTY(false)
io.SetStderrTTY(false)
return io, in, out, errOut
}
func isTerminal(f *os.File) bool {
return ghTerm.IsTerminal(f) || isCygwinTerminal(f.Fd())
}
func isCygwinTerminal(fd uintptr) bool {
return isatty.IsCygwinTerminal(fd)
}
// pagerWriter implements a WriteCloser that wraps all EPIPE errors in an ErrClosedPagerPipe type.
type pagerWriter struct {
io.WriteCloser
}
func (w *pagerWriter) Write(d []byte) (int, error) {
n, err := w.WriteCloser.Write(d)
if err != nil && (errors.Is(err, io.ErrClosedPipe) || isEpipeError(err)) {
return n, &ErrClosedPagerPipe{err}
}
return n, err
}
// fdWriter represents a wrapped stdout Writer that preserves the original file descriptor
type fdWriter struct {
io.Writer
fd uintptr
}
func (w *fdWriter) Fd() uintptr {
return w.fd
}
// fdWriteCloser represents a wrapped stdout Writer that preserves the original file descriptor
type fdWriteCloser struct {
io.WriteCloser
fd uintptr
}
func (w *fdWriteCloser) Fd() uintptr {
return w.fd
}
// fdWriter represents a wrapped stdin ReadCloser that preserves the original file descriptor
type fdReader struct {
io.ReadCloser
fd uintptr
}
func (r *fdReader) Fd() uintptr {
return r.fd
}