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