502 lines
11 KiB
Go
502 lines
11 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 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
|
|
|
|
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 = &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
|
|
}
|
|
|
|
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
|
|
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 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)
|
|
|
|
isVirtualTerminal := false
|
|
if stdoutIsTTY {
|
|
if err := enableVirtualTerminalProcessing(os.Stdout); err == nil {
|
|
isVirtualTerminal = true
|
|
}
|
|
}
|
|
|
|
io := &IOStreams{
|
|
In: os.Stdin,
|
|
originalOut: os.Stdout,
|
|
Out: colorable.NewColorable(os.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{}
|
|
return &IOStreams{
|
|
In: io.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
|
|
}
|