This commit refactors the color format around table headers to ensure the GitHub CLI uses thematically appropriate colors based on dark background, light background, or no color at all. In order to do so, `ColorScheme` needs information from the terminal about the background appearance (dark, light, none) to determine appropriate muted color.
535 lines
11 KiB
Go
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(), s.TerminalTheme())
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// fdReader 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
|
|
}
|