github.com/anchore/syft@v1.38.2/cmd/syft/internal/ui/ui.go (about)

     1  package ui
     2  
     3  import (
     4  	"fmt"
     5  	"io"
     6  	"os"
     7  	"sync"
     8  	"time"
     9  
    10  	tea "github.com/charmbracelet/bubbletea"
    11  	"github.com/wagoodman/go-partybus"
    12  
    13  	"github.com/anchore/bubbly"
    14  	"github.com/anchore/bubbly/bubbles/frame"
    15  	"github.com/anchore/clio"
    16  	"github.com/anchore/go-logger"
    17  	"github.com/anchore/syft/internal/bus"
    18  	"github.com/anchore/syft/internal/log"
    19  	"github.com/anchore/syft/syft/event"
    20  )
    21  
    22  var _ interface {
    23  	tea.Model
    24  	partybus.Responder
    25  	clio.UI
    26  } = (*UI)(nil)
    27  
    28  type UI struct {
    29  	out            io.Writer
    30  	err            io.Writer
    31  	program        *tea.Program
    32  	running        *sync.WaitGroup
    33  	quiet          bool
    34  	subscription   partybus.Unsubscribable
    35  	finalizeEvents []partybus.Event
    36  
    37  	handler *bubbly.HandlerCollection
    38  	frame   tea.Model
    39  }
    40  
    41  func New(out io.Writer, quiet bool, handlers ...bubbly.EventHandler) *UI {
    42  	return &UI{
    43  		out:     out,
    44  		err:     os.Stderr,
    45  		handler: bubbly.NewHandlerCollection(handlers...),
    46  		frame:   frame.New(),
    47  		running: &sync.WaitGroup{},
    48  		quiet:   quiet,
    49  	}
    50  }
    51  
    52  func (m *UI) Setup(subscription partybus.Unsubscribable) error {
    53  	// we still want to collect log messages, however, we also the logger shouldn't write to the screen directly
    54  	if logWrapper, ok := log.Get().(logger.Controller); ok {
    55  		logWrapper.SetOutput(m.frame.(*frame.Frame).Footer())
    56  	}
    57  
    58  	m.subscription = subscription
    59  	m.program = tea.NewProgram(m, tea.WithOutput(os.Stderr), tea.WithInput(os.Stdin), tea.WithoutSignalHandler())
    60  	m.running.Add(1)
    61  
    62  	go func() {
    63  		defer m.running.Done()
    64  		if _, err := m.program.Run(); err != nil {
    65  			log.Errorf("unable to start UI: %+v", err)
    66  			bus.ExitWithInterrupt()
    67  		}
    68  	}()
    69  
    70  	return nil
    71  }
    72  
    73  func (m *UI) Handle(e partybus.Event) error {
    74  	if m.program != nil {
    75  		m.program.Send(e)
    76  	}
    77  	return nil
    78  }
    79  
    80  func (m *UI) Teardown(force bool) error {
    81  	defer func() {
    82  		// allow for traditional logging to resume now that the UI is shutting down
    83  		if logWrapper, ok := log.Get().(logger.Controller); ok {
    84  			logWrapper.SetOutput(m.err)
    85  		}
    86  	}()
    87  
    88  	if !force {
    89  		m.handler.Wait()
    90  		m.program.Quit()
    91  		// typically in all cases we would want to wait for the UI to finish. However there are still error cases
    92  		// that are not accounted for, resulting in hangs. For now, we'll just wait for the UI to finish in the
    93  		// happy path only. There will always be an indication of the problem to the user via reporting the error
    94  		// string from the worker (outside of the UI after teardown).
    95  		m.running.Wait()
    96  	} else {
    97  		_ = runWithTimeout(250*time.Millisecond, func() error {
    98  			m.handler.Wait()
    99  			return nil
   100  		})
   101  
   102  		// it may be tempting to use Kill() however it has been found that this can cause the terminal to be left in
   103  		// a bad state (where Ctrl+C and other control characters no longer works for future processes in that terminal).
   104  		m.program.Quit()
   105  
   106  		_ = runWithTimeout(250*time.Millisecond, func() error {
   107  			m.running.Wait()
   108  			return nil
   109  		})
   110  	}
   111  
   112  	// TODO: allow for writing out the full log output to the screen (only a partial log is shown currently)
   113  	// this needs coordination to know what the last frame event is to change the state accordingly (which isn't possible now)
   114  	return writeEvents(m.out, m.err, m.quiet, m.finalizeEvents...)
   115  }
   116  
   117  // bubbletea.Model functions
   118  
   119  func (m UI) Init() tea.Cmd {
   120  	return m.frame.Init()
   121  }
   122  
   123  func (m UI) RespondsTo() []partybus.EventType {
   124  	return append([]partybus.EventType{
   125  		event.CLIReport,
   126  		event.CLINotification,
   127  		event.CLIAppUpdateAvailable,
   128  	}, m.handler.RespondsTo()...)
   129  }
   130  
   131  func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
   132  	// note: we need a pointer receiver such that the same instance of UI used in Teardown is referenced (to keep finalize events)
   133  
   134  	var cmds []tea.Cmd
   135  
   136  	// allow for non-partybus UI updates (such as window size events). Note: these must not affect existing models,
   137  	// that is the responsibility of the frame object on this UI object. The handler is a factory of models
   138  	// which the frame is responsible for the lifecycle of. This update allows for injecting the initial state
   139  	// of the world when creating those models.
   140  	m.handler.OnMessage(msg)
   141  
   142  	switch msg := msg.(type) {
   143  	case tea.KeyMsg:
   144  		switch msg.String() {
   145  		// today we treat esc and ctrl+c the same, but in the future when the worker has a graceful way to
   146  		// cancel in-flight work via a context, we can wire up esc to this path with bus.Exit()
   147  		case "esc", "ctrl+c":
   148  			bus.ExitWithInterrupt()
   149  			return m, tea.Quit
   150  		}
   151  
   152  	case partybus.Event:
   153  		log.WithFields("component", "ui", "event", msg.Type).Trace("event")
   154  
   155  		switch msg.Type {
   156  		case event.CLIReport, event.CLINotification, event.CLIAppUpdateAvailable:
   157  			// keep these for when the UI is terminated to show to the screen (or perform other events)
   158  			m.finalizeEvents = append(m.finalizeEvents, msg)
   159  
   160  			// why not return tea.Quit here for exit events? because there may be UI components that still need the update-render loop.
   161  			// for this reason we'll let the event loop call Teardown() which will explicitly wait for these components
   162  			return m, nil
   163  		}
   164  
   165  		models, cmd := m.handler.Handle(msg)
   166  		if cmd != nil {
   167  			cmds = append(cmds, cmd)
   168  		}
   169  		for _, newModel := range models {
   170  			if newModel == nil {
   171  				continue
   172  			}
   173  			cmds = append(cmds, newModel.Init())
   174  			m.frame.(*frame.Frame).AppendModel(newModel)
   175  		}
   176  		// intentionally fallthrough to update the frame model
   177  	}
   178  
   179  	frameModel, cmd := m.frame.Update(msg)
   180  	m.frame = frameModel
   181  	cmds = append(cmds, cmd)
   182  
   183  	return m, tea.Batch(cmds...)
   184  }
   185  
   186  func (m UI) View() string {
   187  	return m.frame.View()
   188  }
   189  
   190  func runWithTimeout(timeout time.Duration, fn func() error) (err error) {
   191  	c := make(chan struct{}, 1)
   192  	go func() {
   193  		err = fn()
   194  		c <- struct{}{}
   195  	}()
   196  	select {
   197  	case <-c:
   198  	case <-time.After(timeout):
   199  		return fmt.Errorf("timed out after %v", timeout)
   200  	}
   201  	return err
   202  }