github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/hud/hud.go (about) 1 package hud 2 3 import ( 4 "context" 5 "fmt" 6 "os" 7 "runtime" 8 "runtime/pprof" 9 "sync" 10 "time" 11 12 "github.com/gdamore/tcell" 13 "github.com/pkg/errors" 14 15 "github.com/tilt-dev/tilt/internal/analytics" 16 "github.com/tilt-dev/tilt/internal/hud/view" 17 "github.com/tilt-dev/tilt/internal/openurl" 18 "github.com/tilt-dev/tilt/internal/output" 19 "github.com/tilt-dev/tilt/internal/store" 20 "github.com/tilt-dev/tilt/pkg/logger" 21 "github.com/tilt-dev/tilt/pkg/model" 22 ) 23 24 // The main loop ensures the HUD updates at least this often 25 const DefaultRefreshInterval = 100 * time.Millisecond 26 27 // number of arrows a pgup/dn is equivalent to 28 // (we don't currently worry about trying to know how big a page is, and instead just support pgup/dn as "faster arrows" 29 const pgUpDownCount = 20 30 31 type HeadsUpDisplay interface { 32 store.Subscriber 33 34 Run(ctx context.Context, dispatch func(action store.Action), refreshRate time.Duration) error 35 } 36 37 type Hud struct { 38 r *Renderer 39 webURL model.WebURL 40 openurl openurl.OpenURL 41 42 currentView view.View 43 currentViewState view.ViewState 44 mu sync.RWMutex 45 isStarted bool 46 isRunning bool 47 a *analytics.TiltAnalytics 48 } 49 50 var _ HeadsUpDisplay = (*Hud)(nil) 51 52 func NewHud(renderer *Renderer, webURL model.WebURL, analytics *analytics.TiltAnalytics, openurl openurl.OpenURL) HeadsUpDisplay { 53 return &Hud{ 54 r: renderer, 55 webURL: webURL, 56 a: analytics, 57 openurl: openurl, 58 } 59 } 60 61 func (h *Hud) SetNarrationMessage(ctx context.Context, msg string) { 62 h.mu.Lock() 63 defer h.mu.Unlock() 64 currentViewState := h.currentViewState 65 currentViewState.ShowNarration = true 66 currentViewState.NarrationMessage = msg 67 h.setViewState(ctx, currentViewState) 68 } 69 70 func (h *Hud) Run(ctx context.Context, dispatch func(action store.Action), refreshRate time.Duration) error { 71 // Redirect stdout and stderr into our logger 72 err := output.CaptureAllOutput(logger.Get(ctx).Writer(logger.InfoLvl)) 73 if err != nil { 74 logger.Get(ctx).Infof("Error capturing stdout and stderr: %v", err) 75 } 76 77 h.mu.Lock() 78 h.isRunning = true 79 h.mu.Unlock() 80 81 defer func() { 82 h.mu.Lock() 83 h.isRunning = false 84 h.mu.Unlock() 85 }() 86 87 screenEvents, err := h.r.SetUp() 88 89 if err != nil { 90 return errors.Wrap(err, "setting up screen") 91 } 92 93 defer h.Close() 94 95 if refreshRate == 0 { 96 refreshRate = DefaultRefreshInterval 97 } 98 ticker := time.NewTicker(refreshRate) 99 100 for { 101 select { 102 case <-ctx.Done(): 103 err := ctx.Err() 104 if err != context.Canceled { 105 return err 106 } 107 return nil 108 case e := <-screenEvents: 109 done := h.handleScreenEvent(ctx, dispatch, e) 110 if done { 111 return nil 112 } 113 case <-ticker.C: 114 h.Refresh(ctx) 115 } 116 } 117 } 118 119 func (h *Hud) Close() { 120 h.r.Reset() 121 } 122 123 func (h *Hud) recordInteraction(name string) { 124 h.a.Incr(fmt.Sprintf("ui.interactions.%s", name), map[string]string{}) 125 } 126 127 func (h *Hud) handleScreenEvent(ctx context.Context, dispatch func(action store.Action), ev tcell.Event) (done bool) { 128 h.mu.Lock() 129 defer h.mu.Unlock() 130 131 escape := func() { 132 am := h.activeModal() 133 if am != nil { 134 am.Close(&h.currentViewState) 135 } 136 } 137 138 switch ev := ev.(type) { 139 case *tcell.EventKey: 140 switch ev.Key() { 141 case tcell.KeyEscape: 142 escape() 143 case tcell.KeyRune: 144 switch r := ev.Rune(); { 145 case r == 'b': // [B]rowser 146 // If we have an endpoint(s), open the first one 147 // TODO(nick): We might need some hints on what load balancer to 148 // open if we have multiple, or what path to default to on the opened manifest. 149 _, selected := h.selectedResource() 150 if len(selected.Endpoints) > 0 { 151 h.recordInteraction("open_preview") 152 err := h.openurl(selected.Endpoints[0], logger.Get(ctx).Writer(logger.InfoLvl)) 153 if err != nil { 154 h.currentViewState.AlertMessage = fmt.Sprintf("error opening url '%s' for resource '%s': %v", 155 selected.Endpoints[0], selected.Name, err) 156 } 157 } else { 158 h.currentViewState.AlertMessage = fmt.Sprintf("no urls for resource '%s' ¯\\_(ツ)_/¯", selected.Name) 159 } 160 case r == 'l': // Tilt [L]og 161 if h.webURL.Empty() { 162 break 163 } 164 url := h.webURL 165 url.Path = "/" 166 _ = h.openurl(url.String(), logger.Get(ctx).Writer(logger.InfoLvl)) 167 case r == 'k': 168 h.activeScroller().Up() 169 h.refreshSelectedIndex() 170 case r == 'j': 171 h.activeScroller().Down() 172 h.refreshSelectedIndex() 173 case r == 'q': // [Q]uit 174 escape() 175 case r == 'R': // hidden key for recovering from printf junk during demos 176 h.r.screen.Sync() 177 case r == 't': // [T]rigger resource update 178 _, selected := h.selectedResource() 179 h.recordInteraction("trigger_resource") 180 dispatch(store.AppendToTriggerQueueAction{Name: selected.Name, Reason: model.BuildReasonFlagTriggerHUD}) 181 case r == 'x': 182 h.recordInteraction("cycle_view_log_state") 183 h.currentViewState.CycleViewLogState() 184 case r == '1': 185 h.recordInteraction("tab_all_log") 186 h.currentViewState.TabState = view.TabAllLog 187 case r == '2': 188 h.recordInteraction("tab_build_log") 189 h.currentViewState.TabState = view.TabBuildLog 190 case r == '3': 191 h.recordInteraction("tab_pod_log") 192 h.currentViewState.TabState = view.TabRuntimeLog 193 } 194 case tcell.KeyUp: 195 h.activeScroller().Up() 196 h.refreshSelectedIndex() 197 case tcell.KeyDown: 198 h.activeScroller().Down() 199 h.refreshSelectedIndex() 200 case tcell.KeyPgUp: 201 for i := 0; i < pgUpDownCount; i++ { 202 h.activeScroller().Up() 203 } 204 h.refreshSelectedIndex() 205 case tcell.KeyPgDn: 206 for i := 0; i < pgUpDownCount; i++ { 207 h.activeScroller().Down() 208 } 209 h.refreshSelectedIndex() 210 case tcell.KeyEnter: 211 if len(h.currentView.Resources) == 0 { 212 break 213 } 214 _, r := h.selectedResource() 215 216 if h.webURL.Empty() { 217 break 218 } 219 url := h.webURL 220 221 // If the cursor is in the default position (Tiltfile), open the All log. 222 if r.Name != MainTiltfileManifestName { 223 url.Path = fmt.Sprintf("/r/%s/", r.Name) 224 } 225 226 h.a.Incr("ui.interactions.open_log", nil) 227 _ = h.openurl(url.String(), logger.Get(ctx).Writer(logger.InfoLvl)) 228 case tcell.KeyRight: 229 i, _ := h.selectedResource() 230 h.currentViewState.Resources[i].CollapseState = view.CollapseNo 231 case tcell.KeyLeft: 232 i, _ := h.selectedResource() 233 h.currentViewState.Resources[i].CollapseState = view.CollapseYes 234 case tcell.KeyHome: 235 h.activeScroller().Top() 236 case tcell.KeyEnd: 237 h.activeScroller().Bottom() 238 case tcell.KeyCtrlC: 239 h.Close() 240 dispatch(NewExitAction(nil)) 241 return true 242 case tcell.KeyCtrlD: 243 dispatch(DumpEngineStateAction{}) 244 case tcell.KeyCtrlO: 245 go writeHeapProfile(ctx) 246 } 247 248 case *tcell.EventResize: 249 // since we already refresh after the switch, don't need to do anything here 250 // just marking this as where sigwinch gets handled 251 } 252 253 h.refresh(ctx) 254 return false 255 } 256 257 func (h *Hud) isEnabled(st store.RStore) bool { 258 state := st.RLockState() 259 defer st.RUnlockState() 260 return state.TerminalMode == store.TerminalModeHUD 261 } 262 263 func (h *Hud) OnChange(ctx context.Context, st store.RStore, _ store.ChangeSummary) error { 264 if !h.isEnabled(st) { 265 return nil 266 } 267 268 if !h.isStarted { 269 h.isStarted = true 270 go func() { 271 err := h.Run(ctx, st.Dispatch, DefaultRefreshInterval) 272 if err != nil && err != context.Canceled { 273 st.Dispatch(store.PanicAction{Err: err}) 274 } 275 }() 276 } 277 278 h.mu.Lock() 279 defer h.mu.Unlock() 280 281 toPrint := "" 282 state := st.RLockState() 283 view := StateToTerminalView(state, st.StateMutex()) 284 285 // if the hud isn't running, make sure new logs are visible on stdout 286 if !h.isRunning { 287 toPrint = state.LogStore.ContinuingString(h.currentViewState.ProcessedLogs) 288 } 289 h.currentViewState.ProcessedLogs = state.LogStore.Checkpoint() 290 291 st.RUnlockState() 292 293 fmt.Print(toPrint) 294 295 // if we're going from 1 resource (i.e., the Tiltfile) to more than 1, reset 296 // the resource selection, so that we're not scrolled to the bottom with the Tiltfile selected 297 if len(h.currentView.Resources) == 1 && len(view.Resources) > 1 { 298 h.resetResourceSelection() 299 } 300 h.currentView = view 301 h.refreshSelectedIndex() 302 return nil 303 } 304 305 func (h *Hud) Refresh(ctx context.Context) { 306 h.mu.Lock() 307 defer h.mu.Unlock() 308 h.refresh(ctx) 309 } 310 311 // Must hold the lock 312 func (h *Hud) setViewState(ctx context.Context, currentViewState view.ViewState) { 313 h.currentViewState = currentViewState 314 h.refresh(ctx) 315 } 316 317 // Must hold the lock 318 func (h *Hud) refresh(ctx context.Context) { 319 // TODO: We don't handle the order of resources changing 320 for len(h.currentViewState.Resources) < len(h.currentView.Resources) { 321 h.currentViewState.Resources = append(h.currentViewState.Resources, view.ResourceViewState{}) 322 } 323 324 vs := h.currentViewState 325 vs.Resources = append(vs.Resources, h.currentViewState.Resources...) 326 327 h.r.Render(h.currentView, h.currentViewState) 328 } 329 330 func (h *Hud) resetResourceSelection() { 331 rty := h.r.RTY() 332 if rty == nil { 333 return 334 } 335 // wipe out any scroll/selection state for resources 336 // it will get re-set in the next call to render 337 rty.RegisterElementScroll("resources", []string{}) 338 } 339 340 func (h *Hud) refreshSelectedIndex() { 341 rty := h.r.RTY() 342 if rty == nil { 343 return 344 } 345 scroller := rty.ElementScroller("resources") 346 if scroller == nil { 347 return 348 } 349 i := scroller.GetSelectedIndex() 350 h.currentViewState.SelectedIndex = i 351 } 352 353 func (h *Hud) selectedResource() (i int, resource view.Resource) { 354 return selectedResource(h.currentView, h.currentViewState) 355 } 356 357 func selectedResource(view view.View, state view.ViewState) (i int, resource view.Resource) { 358 i = state.SelectedIndex 359 if i >= 0 && i < len(view.Resources) { 360 resource = view.Resources[i] 361 } 362 return i, resource 363 364 } 365 366 var _ store.Subscriber = &Hud{} 367 368 func writeHeapProfile(ctx context.Context) { 369 f, err := os.Create("tilt.heap_profile") 370 if err != nil { 371 logger.Get(ctx).Infof("error creating file for heap profile: %v", err) 372 return 373 } 374 runtime.GC() 375 logger.Get(ctx).Infof("writing heap profile to %s", f.Name()) 376 err = pprof.WriteHeapProfile(f) 377 if err != nil { 378 logger.Get(ctx).Infof("error writing heap profile: %v", err) 379 return 380 } 381 err = f.Close() 382 if err != nil { 383 logger.Get(ctx).Infof("error closing file for heap profile: %v", err) 384 return 385 } 386 logger.Get(ctx).Infof("wrote heap profile to %s", f.Name()) 387 }