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 }