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{}