github.com/tilt-dev/tilt@v0.36.0/internal/hud/prompt/prompt.go (about)

     1  package prompt
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"strings"
     8  	"sync"
     9  
    10  	"github.com/fatih/color"
    11  	tty "github.com/mattn/go-tty"
    12  
    13  	"github.com/tilt-dev/tilt/internal/analytics"
    14  	"github.com/tilt-dev/tilt/internal/hud"
    15  	"github.com/tilt-dev/tilt/internal/openurl"
    16  	"github.com/tilt-dev/tilt/internal/store"
    17  	"github.com/tilt-dev/tilt/pkg/model"
    18  )
    19  
    20  //nolint:govet
    21  type TerminalInput interface {
    22  	ReadNextRune() (rune, error)
    23  	Close() error
    24  }
    25  
    26  type OpenInput func() (TerminalInput, error)
    27  
    28  // workaround for
    29  // https://github.com/mattn/go-tty/issues/53
    30  type TTYWrapper struct {
    31  	*tty.TTY
    32  }
    33  
    34  func (t *TTYWrapper) ReadNextRune() (rune, error) {
    35  	return t.TTY.ReadRune()
    36  }
    37  
    38  func TTYOpen() (TerminalInput, error) {
    39  	t, err := tty.Open()
    40  	if err != nil {
    41  		return nil, err
    42  	}
    43  	return &TTYWrapper{t}, nil
    44  }
    45  
    46  type TerminalPrompt struct {
    47  	a         *analytics.TiltAnalytics
    48  	openInput OpenInput
    49  	openURL   openurl.OpenURL
    50  	stdout    hud.Stdout
    51  	host      model.WebHost
    52  	url       model.WebURL
    53  
    54  	printed bool
    55  	term    TerminalInput
    56  
    57  	// Make sure that Close() completes both during the teardown sequence and when
    58  	// we switch modes.
    59  	closeOnce sync.Once
    60  
    61  	initOutput *bytes.Buffer
    62  }
    63  
    64  func NewTerminalPrompt(a *analytics.TiltAnalytics, openInput OpenInput,
    65  	openURL openurl.OpenURL, stdout hud.Stdout,
    66  	host model.WebHost, url model.WebURL) *TerminalPrompt {
    67  
    68  	return &TerminalPrompt{
    69  		a:         a,
    70  		openInput: openInput,
    71  		openURL:   openURL,
    72  		stdout:    stdout,
    73  		host:      host,
    74  		url:       url,
    75  	}
    76  }
    77  
    78  // Copy initial warnings and info logs from the logstore into the terminal
    79  // prompt, so that they get shown as part of the prompt.
    80  //
    81  // This sits at the intersection of two incompatible interfaces:
    82  //
    83  //  1. The LogStore is an asynchronous, streaming log interface that makes sure
    84  //     all logs are shown everywhere (across stdout, hud, web, snapshots, etc).
    85  //
    86  //  2. The TerminalPrompt is a synchronous interface that shows a deliberately
    87  //     short "greeting" message, then blocks on user input.
    88  //
    89  // Rather than make these two interfaces interoperate well, we just have
    90  // the internal/cli code copy over the logs during the init sequence.
    91  // It's OK if logs show up twice.
    92  func (p *TerminalPrompt) SetInitOutput(buf *bytes.Buffer) {
    93  	p.initOutput = buf
    94  }
    95  
    96  func (p *TerminalPrompt) tiltBuild(st store.RStore) model.TiltBuild {
    97  	state := st.RLockState()
    98  	defer st.RUnlockState()
    99  	return state.TiltBuildInfo
   100  }
   101  
   102  func (p *TerminalPrompt) isEnabled(st store.RStore) bool {
   103  	state := st.RLockState()
   104  	defer st.RUnlockState()
   105  	return state.TerminalMode == store.TerminalModePrompt
   106  }
   107  
   108  func (p *TerminalPrompt) TearDown(ctx context.Context) {
   109  	if p.term != nil {
   110  		p.closeOnce.Do(func() {
   111  			_ = p.term.Close()
   112  		})
   113  	}
   114  }
   115  
   116  func (p *TerminalPrompt) OnChange(ctx context.Context, st store.RStore, _ store.ChangeSummary) error {
   117  	if !p.isEnabled(st) {
   118  		return nil
   119  	}
   120  
   121  	if p.printed {
   122  		return nil
   123  	}
   124  
   125  	build := p.tiltBuild(st)
   126  	buildStamp := build.HumanBuildStamp()
   127  	firstLine := StartStatusLine(p.url, p.host)
   128  	_, _ = fmt.Fprintf(p.stdout, "%s\n", firstLine)
   129  	_, _ = fmt.Fprintf(p.stdout, "%s\n\n", buildStamp)
   130  
   131  	// Print all the init output. See comments on SetInitOutput()
   132  	infoLines := strings.Split(strings.TrimRight(p.initOutput.String(), "\n"), "\n")
   133  	needsNewline := false
   134  	for _, line := range infoLines {
   135  		if strings.HasPrefix(line, firstLine) || strings.HasPrefix(line, buildStamp) {
   136  			continue
   137  		}
   138  		_, _ = fmt.Fprintf(p.stdout, "%s\n", line)
   139  		needsNewline = true
   140  	}
   141  
   142  	if needsNewline {
   143  		_, _ = fmt.Fprintf(p.stdout, "\n")
   144  	}
   145  
   146  	hasBrowserUI := !p.url.Empty()
   147  	if hasBrowserUI {
   148  		_, _ = fmt.Fprintf(p.stdout, "(space) to open the browser\n")
   149  	}
   150  
   151  	_, _ = fmt.Fprintf(p.stdout, "(s) to stream logs (--stream=true)\n")
   152  	_, _ = fmt.Fprintf(p.stdout, "(t) to open legacy terminal mode (--legacy=true)\n")
   153  	_, _ = fmt.Fprintf(p.stdout, "(ctrl-c) to exit\n")
   154  
   155  	p.printed = true
   156  
   157  	t, err := p.openInput()
   158  	if err != nil {
   159  		st.Dispatch(store.ErrorAction{Error: err})
   160  		return nil
   161  	}
   162  	p.term = t
   163  
   164  	keyCh := make(chan runeMessage)
   165  
   166  	// One goroutine just pulls input from TTY.
   167  	go func() {
   168  		for ctx.Err() == nil {
   169  			r, err := t.ReadNextRune()
   170  			if err != nil {
   171  				st.Dispatch(store.ErrorAction{Error: err})
   172  				return
   173  			}
   174  
   175  			msg := runeMessage{
   176  				rune:   r,
   177  				stopCh: make(chan bool),
   178  			}
   179  			keyCh <- msg
   180  
   181  			close := <-msg.stopCh
   182  			if close {
   183  				break
   184  			}
   185  		}
   186  		close(keyCh)
   187  	}()
   188  
   189  	// Another goroutine processes the input. Doing this
   190  	// on a separate goroutine allows us to clean up the TTY
   191  	// even if it's still blocking on the ReadRune
   192  	go func() {
   193  		defer func() {
   194  			p.closeOnce.Do(func() {
   195  				_ = p.term.Close()
   196  			})
   197  		}()
   198  
   199  		for ctx.Err() == nil {
   200  			select {
   201  			case <-ctx.Done():
   202  				return
   203  			case msg, ok := <-keyCh:
   204  				if !ok {
   205  					return
   206  				}
   207  
   208  				r := msg.rune
   209  				switch r {
   210  				case 's':
   211  					p.a.Incr("ui.prompt.switch", map[string]string{"type": "stream"})
   212  					st.Dispatch(SwitchTerminalModeAction{Mode: store.TerminalModeStream})
   213  					msg.stopCh <- true
   214  
   215  				case 't', 'h':
   216  					p.a.Incr("ui.prompt.switch", map[string]string{"type": "hud"})
   217  					st.Dispatch(SwitchTerminalModeAction{Mode: store.TerminalModeHUD})
   218  
   219  					msg.stopCh <- true
   220  
   221  				case ' ':
   222  					p.a.Incr("ui.prompt.browser", map[string]string{})
   223  					_, _ = fmt.Fprintf(p.stdout, "Opening browser: %s\n", p.url.String())
   224  					err := p.openURL(p.url.String(), p.stdout)
   225  					if err != nil {
   226  						_, _ = fmt.Fprintf(p.stdout, "Error: %v\n", err)
   227  					}
   228  					msg.stopCh <- false
   229  				default:
   230  					msg.stopCh <- false
   231  
   232  				}
   233  			}
   234  		}
   235  	}()
   236  
   237  	return nil
   238  }
   239  
   240  type runeMessage struct {
   241  	rune rune
   242  
   243  	// The receiver of this message should
   244  	// ACK the channel when they're done.
   245  	//
   246  	// Sending 'true' indicates that we're switching to a different mode and the
   247  	// input goroutine should stop reading TTY input.
   248  	stopCh chan bool
   249  }
   250  
   251  func StartStatusLine(url model.WebURL, host model.WebHost) string {
   252  	hasBrowserUI := !url.Empty()
   253  	serverStatus := "(without browser UI)"
   254  	if hasBrowserUI {
   255  		if host == "0.0.0.0" {
   256  			serverStatus = fmt.Sprintf("on %s (listening on 0.0.0.0)", url)
   257  		} else {
   258  			serverStatus = fmt.Sprintf("on %s", url)
   259  		}
   260  	}
   261  
   262  	return color.GreenString(fmt.Sprintf("Tilt started %s", serverStatus))
   263  }
   264  
   265  var _ store.Subscriber = &TerminalPrompt{}
   266  var _ store.TearDowner = &TerminalPrompt{}