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 }