github.com/lineaje-labs/syft@v0.98.1-0.20231227153149-9e393f60ff1b/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/syft/event"
    18  	"github.com/lineaje-labs/syft/internal/bus"
    19  	"github.com/lineaje-labs/syft/internal/log"
    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  	if !force {
    82  		m.handler.Wait()
    83  		m.program.Quit()
    84  		// typically in all cases we would want to wait for the UI to finish. However there are still error cases
    85  		// that are not accounted for, resulting in hangs. For now, we'll just wait for the UI to finish in the
    86  		// happy path only. There will always be an indication of the problem to the user via reporting the error
    87  		// string from the worker (outside of the UI after teardown).
    88  		m.running.Wait()
    89  	} else {
    90  		_ = runWithTimeout(250*time.Millisecond, func() error {
    91  			m.handler.Wait()
    92  			return nil
    93  		})
    94  
    95  		// it may be tempting to use Kill() however it has been found that this can cause the terminal to be left in
    96  		// a bad state (where Ctrl+C and other control characters no longer works for future processes in that terminal).
    97  		m.program.Quit()
    98  
    99  		_ = runWithTimeout(250*time.Millisecond, func() error {
   100  			m.running.Wait()
   101  			return nil
   102  		})
   103  	}
   104  
   105  	// TODO: allow for writing out the full log output to the screen (only a partial log is shown currently)
   106  	// this needs coordination to know what the last frame event is to change the state accordingly (which isn't possible now)
   107  	return writeEvents(m.out, m.err, m.quiet, m.finalizeEvents...)
   108  }
   109  
   110  // bubbletea.Model functions
   111  
   112  func (m UI) Init() tea.Cmd {
   113  	return m.frame.Init()
   114  }
   115  
   116  func (m UI) RespondsTo() []partybus.EventType {
   117  	return append([]partybus.EventType{
   118  		event.CLIReport,
   119  		event.CLINotification,
   120  		event.CLIAppUpdateAvailable,
   121  	}, m.handler.RespondsTo()...)
   122  }
   123  
   124  func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
   125  	// note: we need a pointer receiver such that the same instance of UI used in Teardown is referenced (to keep finalize events)
   126  
   127  	var cmds []tea.Cmd
   128  
   129  	// allow for non-partybus UI updates (such as window size events). Note: these must not affect existing models,
   130  	// that is the responsibility of the frame object on this UI object. The handler is a factory of models
   131  	// which the frame is responsible for the lifecycle of. This update allows for injecting the initial state
   132  	// of the world when creating those models.
   133  	m.handler.OnMessage(msg)
   134  
   135  	switch msg := msg.(type) {
   136  	case tea.KeyMsg:
   137  		switch msg.String() {
   138  		// today we treat esc and ctrl+c the same, but in the future when the worker has a graceful way to
   139  		// cancel in-flight work via a context, we can wire up esc to this path with bus.Exit()
   140  		case "esc", "ctrl+c":
   141  			bus.ExitWithInterrupt()
   142  			return m, tea.Quit
   143  		}
   144  
   145  	case partybus.Event:
   146  		log.WithFields("component", "ui").Tracef("event: %q", msg.Type)
   147  
   148  		switch msg.Type {
   149  		case event.CLIReport, event.CLINotification, event.CLIAppUpdateAvailable:
   150  			// keep these for when the UI is terminated to show to the screen (or perform other events)
   151  			m.finalizeEvents = append(m.finalizeEvents, msg)
   152  
   153  			// why not return tea.Quit here for exit events? because there may be UI components that still need the update-render loop.
   154  			// for this reason we'll let the event loop call Teardown() which will explicitly wait for these components
   155  			return m, nil
   156  		}
   157  
   158  		for _, newModel := range m.handler.Handle(msg) {
   159  			if newModel == nil {
   160  				continue
   161  			}
   162  			cmds = append(cmds, newModel.Init())
   163  			m.frame.(*frame.Frame).AppendModel(newModel)
   164  		}
   165  		// intentionally fallthrough to update the frame model
   166  	}
   167  
   168  	frameModel, cmd := m.frame.Update(msg)
   169  	m.frame = frameModel
   170  	cmds = append(cmds, cmd)
   171  
   172  	return m, tea.Batch(cmds...)
   173  }
   174  
   175  func (m UI) View() string {
   176  	return m.frame.View()
   177  }
   178  
   179  func runWithTimeout(timeout time.Duration, fn func() error) (err error) {
   180  	c := make(chan struct{}, 1)
   181  	go func() {
   182  		err = fn()
   183  		c <- struct{}{}
   184  	}()
   185  	select {
   186  	case <-c:
   187  	case <-time.After(timeout):
   188  		return fmt.Errorf("timed out after %v", timeout)
   189  	}
   190  	return err
   191  }