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