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