While a gh command is writing stdout to a pager, the user may choose to
close the pager program before the pager has read all the data on its
standard input. In that case, the parent gh process will receive an
EPIPE error, which would bubble up its error handling and cause it to
print something like:
write |1: broken pipe
Since this was caused by an explicit user action of closing the pager,
and since the user probably doesn't want to see this uninformative
error, this informs our global error handling of this error and causes
it to be ignored.
450 lines
9.5 KiB
Go
450 lines
9.5 KiB
Go
package iostreams
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"os"
|
|
"os/exec"
|
|
"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 IOStreams struct {
|
|
In io.ReadCloser
|
|
Out io.Writer
|
|
ErrOut io.Writer
|
|
|
|
// the original (non-colorable) output stream
|
|
originalOut io.Writer
|
|
colorEnabled bool
|
|
is256enabled bool
|
|
hasTrueColor bool
|
|
terminalTheme string
|
|
|
|
progressIndicatorEnabled bool
|
|
progressIndicator *spinner.Spinner
|
|
progressIndicatorMu 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 = &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
|
|
}
|
|
|
|
_ = 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
|
|
}
|
|
|
|
// 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
|
|
out := s.Out
|
|
if s.originalOut != nil {
|
|
out = s.originalOut
|
|
}
|
|
|
|
if w, _, err := terminalSize(out); err == nil {
|
|
return w
|
|
}
|
|
|
|
if isCygwinTerminal(out) {
|
|
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 ioutil.ReadAll(r)
|
|
}
|
|
|
|
func (s *IOStreams) TempFile(dir, pattern string) (*os.File, error) {
|
|
if s.TempFileOverride != nil {
|
|
return s.TempFileOverride, nil
|
|
}
|
|
return ioutil.TempFile(dir, pattern)
|
|
}
|
|
|
|
func System() *IOStreams {
|
|
stdoutIsTTY := isTerminal(os.Stdout)
|
|
stderrIsTTY := isTerminal(os.Stderr)
|
|
|
|
assumeTrueColor := false
|
|
if stdoutIsTTY {
|
|
if err := enableVirtualTerminalProcessing(os.Stdout); err == nil {
|
|
assumeTrueColor = true
|
|
}
|
|
}
|
|
|
|
io := &IOStreams{
|
|
In: os.Stdin,
|
|
originalOut: os.Stdout,
|
|
Out: colorable.NewColorable(os.Stdout),
|
|
ErrOut: colorable.NewColorable(os.Stderr),
|
|
colorEnabled: EnvColorForced() || (!EnvColorDisabled() && stdoutIsTTY),
|
|
is256enabled: assumeTrueColor || Is256ColorSupported(),
|
|
hasTrueColor: assumeTrueColor || IsTrueColorSupported(),
|
|
pagerCommand: os.Getenv("PAGER"),
|
|
ttySize: ttySize,
|
|
}
|
|
|
|
if stdoutIsTTY && stderrIsTTY {
|
|
io.progressIndicatorEnabled = 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{}
|
|
return &IOStreams{
|
|
In: ioutil.NopCloser(in),
|
|
Out: out,
|
|
ErrOut: errOut,
|
|
ttySize: func() (int, int, error) {
|
|
return -1, -1, errors.New("ttySize not implemented in tests")
|
|
},
|
|
}, in, out, errOut
|
|
}
|
|
|
|
func isTerminal(f *os.File) bool {
|
|
return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
|
|
}
|
|
|
|
func isCygwinTerminal(w io.Writer) bool {
|
|
if f, isFile := w.(*os.File); isFile {
|
|
return isatty.IsCygwinTerminal(f.Fd())
|
|
}
|
|
return false
|
|
}
|
|
|
|
// terminalSize measures the viewport of the terminal that the output stream is connected to
|
|
func terminalSize(w io.Writer) (int, int, error) {
|
|
if f, isFile := w.(*os.File); isFile {
|
|
return term.GetSize(int(f.Fd()))
|
|
}
|
|
return 0, 0, fmt.Errorf("%v is not a file", w)
|
|
}
|
|
|
|
// 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
|
|
}
|