cli/pkg/iostreams/iostreams.go
Mislav Marohnić 58ef50ae1f Ensure that subprocesses connect to the original os.Stdout
On non-Windows platforms, this avoids wrapping `IOStreams.Out` in a
`fdWriter` and thus causing subprocesses to lose connection to the
terminal that gh is connected to.
2022-09-14 16:47:42 +02:00

552 lines
12 KiB
Go

package iostreams
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"strconv"
"strings"
"sync"
"time"
"github.com/briandowns/spinner"
"github.com/cli/safeexec"
"github.com/google/shlex"
"github.com/mattn/go-colorable"
"github.com/mattn/go-isatty"
"github.com/muesli/termenv"
"golang.org/x/term"
)
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 IOStreams struct {
In fileReader
Out fileWriter
ErrOut io.Writer
colorEnabled bool
is256enabled bool
hasTrueColor bool
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
termWidthOverride int
ttySize func() (int, int, error)
pagerCommand string
pagerProcess *os.Process
neverPrompt bool
TempFileOverride *os.File
}
func (s *IOStreams) ColorEnabled() bool {
return s.colorEnabled
}
func (s *IOStreams) ColorSupport256() bool {
return s.is256enabled
}
func (s *IOStreams) HasTrueColor() bool {
return s.hasTrueColor
}
// 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.terminalTheme = "none"
return
}
if s.pagerProcess != nil {
s.terminalTheme = "none"
return
}
style := os.Getenv("GLAMOUR_STYLE")
if style != "" && style != "auto" {
s.terminalTheme = "none"
return
}
if termenv.HasDarkBackground() {
s.terminalTheme = "dark"
return
}
s.terminalTheme = "light"
}
// 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.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
}
if stdout, ok := s.Out.(*os.File); ok {
return isTerminal(stdout)
}
return false
}
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) 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.stdoutIsTTY {
// 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 stdout is attached to.
// TODO: investigate whether ProcessTerminalWidth could replace all this.
func (s *IOStreams) TerminalWidth() int {
if s.termWidthOverride > 0 {
return s.termWidthOverride
}
defaultWidth := DefaultWidth
if w, _, err := terminalSize(s.Out.Fd()); err == nil {
return w
}
if isCygwinTerminal(s.Out.Fd()) {
tputExe, err := safeexec.LookPath("tput")
if err != nil {
return defaultWidth
}
tputCmd := exec.Command(tputExe, "cols")
tputCmd.Stdin = os.Stdin
if out, err := tputCmd.Output(); err == nil {
if w, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil {
return w
}
}
}
return defaultWidth
}
// ProcessTerminalWidth returns the width of the terminal that the process is attached to.
func (s *IOStreams) ProcessTerminalWidth() int {
w, _, err := s.ttySize()
if err != nil {
return DefaultWidth
}
return w
}
func (s *IOStreams) ForceTerminal(spec string) {
s.colorEnabled = !EnvColorDisabled()
s.SetStdoutTTY(true)
if w, err := strconv.Atoi(spec); err == nil {
s.termWidthOverride = w
return
}
ttyWidth, _, err := s.ttySize()
if err != nil {
return
}
s.termWidthOverride = ttyWidth
if strings.HasSuffix(spec, "%") {
if p, err := strconv.Atoi(spec[:len(spec)-1]); err == nil {
s.termWidthOverride = int(float64(s.termWidthOverride) * (float64(p) / 100))
}
}
}
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 {
stdoutIsTTY := isTerminal(os.Stdout)
stderrIsTTY := isTerminal(os.Stderr)
var stdout fileWriter = os.Stdout
isVirtualTerminal := false
if stdoutIsTTY {
// enables ANSI escape sequences on modern Windows
if err := enableVirtualTerminalProcessing(os.Stdout.Fd()); err == nil {
isVirtualTerminal = true
} else {
// as a fallback, translate ANSI escape sequences to Windows console syscalls
colorableStdout := colorable.NewColorable(os.Stdout)
if colorableStdout != os.Stdout {
// ensure that the file descriptor of the original stdout is preserved
stdout = &fdWriter{
fd: os.Stdout.Fd(),
Writer: colorableStdout,
}
}
}
}
io := &IOStreams{
In: os.Stdin,
Out: stdout,
ErrOut: colorable.NewColorable(os.Stderr),
colorEnabled: EnvColorForced() || (!EnvColorDisabled() && stdoutIsTTY),
is256enabled: isVirtualTerminal || Is256ColorSupported(),
hasTrueColor: isVirtualTerminal || IsTrueColorSupported(),
pagerCommand: os.Getenv("PAGER"),
ttySize: ttySize,
}
if stdoutIsTTY && stderrIsTTY {
io.progressIndicatorEnabled = true
}
if stdoutIsTTY && isVirtualTerminal {
io.alternateScreenBufferEnabled = true
}
// prevent duplicate isTerminal queries now that we know the answer
io.SetStdoutTTY(stdoutIsTTY)
io.SetStderrTTY(stderrIsTTY)
return io
}
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: errOut,
ttySize: func() (int, int, error) {
return -1, -1, errors.New("ttySize not implemented in tests")
},
}
io.SetStdinTTY(false)
io.SetStdoutTTY(false)
io.SetStderrTTY(false)
return io, in, out, errOut
}
func isTerminal(f *os.File) bool {
return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
}
func isCygwinTerminal(fd uintptr) bool {
return isatty.IsCygwinTerminal(fd)
}
// terminalSize measures the viewport of the terminal that the output stream is connected to
func terminalSize(fd uintptr) (int, int, error) {
return term.GetSize(int(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
}