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  }