mirror of
https://github.com/anchore/syft.git
synced 2026-11-01 15:06:36 +01:00
Switch UI to bubbletea (#1888)
* add bubbletea UI Signed-off-by: Alex Goodman <alex.goodman@anchore.com> * swap pipeline to go 1.20.x and add attest guard for cosign binary Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * update note in developing.md about the required golang version Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * fix merge conflict for windows path handling Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * temp test for attest handler Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> * add addtional test iterations for background reader Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com> --------- Signed-off-by: Alex Goodman <alex.goodman@anchore.com> Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
|
||||
[Test_postUIEventWriter_write/no_events/stdout - 1]
|
||||
|
||||
---
|
||||
|
||||
[Test_postUIEventWriter_write/no_events/stderr - 1]
|
||||
|
||||
---
|
||||
|
||||
[Test_postUIEventWriter_write/all_events/stdout - 1]
|
||||
|
||||
|
||||
<my --
|
||||
-
|
||||
-
|
||||
report 1!!>
|
||||
<report 2>
|
||||
|
||||
---
|
||||
|
||||
[Test_postUIEventWriter_write/all_events/stderr - 1]
|
||||
|
||||
|
||||
<my notification 1!!
|
||||
...still notifying>
|
||||
|
||||
|
||||
<notification 2>
|
||||
<notification 3>
|
||||
|
||||
|
||||
<my app can be updated!!
|
||||
...to this version>
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[Test_postUIEventWriter_write/quiet_only_shows_report/stdout - 1]
|
||||
<report 1>
|
||||
|
||||
---
|
||||
|
||||
[Test_postUIEventWriter_write/quiet_only_shows_report/stderr - 1]
|
||||
|
||||
---
|
||||
@@ -0,0 +1,44 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/wagoodman/go-partybus"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/syft/syft/event"
|
||||
)
|
||||
|
||||
var _ clio.UI = (*NoUI)(nil)
|
||||
|
||||
type NoUI struct {
|
||||
finalizeEvents []partybus.Event
|
||||
subscription partybus.Unsubscribable
|
||||
quiet bool
|
||||
}
|
||||
|
||||
func None(quiet bool) *NoUI {
|
||||
return &NoUI{
|
||||
quiet: quiet,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NoUI) Setup(subscription partybus.Unsubscribable) error {
|
||||
n.subscription = subscription
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *NoUI) Handle(e partybus.Event) error {
|
||||
switch e.Type {
|
||||
case event.CLIReport, event.CLINotification:
|
||||
// keep these for when the UI is terminated to show to the screen (or perform other events)
|
||||
n.finalizeEvents = append(n.finalizeEvents, e)
|
||||
case event.CLIExit:
|
||||
return n.subscription.Unsubscribe()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n NoUI) Teardown(_ bool) error {
|
||||
return newPostUIEventWriter(os.Stdout, os.Stderr).write(n.quiet, n.finalizeEvents...)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/event"
|
||||
"github.com/anchore/syft/syft/event/parsers"
|
||||
)
|
||||
|
||||
type postUIEventWriter struct {
|
||||
handles []postUIHandle
|
||||
}
|
||||
|
||||
type postUIHandle struct {
|
||||
respectQuiet bool
|
||||
event partybus.EventType
|
||||
writer io.Writer
|
||||
dispatch eventWriter
|
||||
}
|
||||
|
||||
type eventWriter func(io.Writer, ...partybus.Event) error
|
||||
|
||||
func newPostUIEventWriter(stdout, stderr io.Writer) *postUIEventWriter {
|
||||
return &postUIEventWriter{
|
||||
handles: []postUIHandle{
|
||||
{
|
||||
event: event.CLIReport,
|
||||
respectQuiet: false,
|
||||
writer: stdout,
|
||||
dispatch: writeReports,
|
||||
},
|
||||
{
|
||||
event: event.CLINotification,
|
||||
respectQuiet: true,
|
||||
writer: stderr,
|
||||
dispatch: writeNotifications,
|
||||
},
|
||||
{
|
||||
event: event.CLIAppUpdateAvailable,
|
||||
respectQuiet: true,
|
||||
writer: stderr,
|
||||
dispatch: writeAppUpdate,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (w postUIEventWriter) write(quiet bool, events ...partybus.Event) error {
|
||||
var errs error
|
||||
for _, h := range w.handles {
|
||||
if quiet && h.respectQuiet {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, e := range events {
|
||||
if e.Type != h.event {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := h.dispatch(h.writer, e); err != nil {
|
||||
errs = multierror.Append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func writeReports(writer io.Writer, events ...partybus.Event) error {
|
||||
var reports []string
|
||||
for _, e := range events {
|
||||
_, report, err := parsers.ParseCLIReport(e)
|
||||
if err != nil {
|
||||
log.WithFields("error", err).Warn("failed to gather final report")
|
||||
continue
|
||||
}
|
||||
|
||||
// remove all whitespace padding from the end of the report
|
||||
reports = append(reports, strings.TrimRight(report, "\n ")+"\n")
|
||||
}
|
||||
|
||||
// prevent the double new-line at the end of the report
|
||||
report := strings.Join(reports, "\n")
|
||||
|
||||
if _, err := fmt.Fprint(writer, report); err != nil {
|
||||
return fmt.Errorf("failed to write final report to stdout: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeNotifications(writer io.Writer, events ...partybus.Event) error {
|
||||
// 13 = high intensity magenta (ANSI 16 bit code)
|
||||
style := lipgloss.NewStyle().Foreground(lipgloss.Color("13"))
|
||||
|
||||
for _, e := range events {
|
||||
_, notification, err := parsers.ParseCLINotification(e)
|
||||
if err != nil {
|
||||
log.WithFields("error", err).Warn("failed to parse notification")
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintln(writer, style.Render(notification)); err != nil {
|
||||
// don't let this be fatal
|
||||
log.WithFields("error", err).Warn("failed to write final notifications")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeAppUpdate(writer io.Writer, events ...partybus.Event) error {
|
||||
// 13 = high intensity magenta (ANSI 16 bit code) + italics
|
||||
style := lipgloss.NewStyle().Foreground(lipgloss.Color("13")).Italic(true)
|
||||
|
||||
for _, e := range events {
|
||||
notice, err := parsers.ParseCLIAppUpdateAvailable(e)
|
||||
if err != nil {
|
||||
log.WithFields("error", err).Warn("failed to parse app update notification")
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintln(writer, style.Render(notice)); err != nil {
|
||||
// don't let this be fatal
|
||||
log.WithFields("error", err).Warn("failed to write app update notification")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/gkampitakis/go-snaps/snaps"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
|
||||
"github.com/anchore/syft/syft/event"
|
||||
)
|
||||
|
||||
func Test_postUIEventWriter_write(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
quiet bool
|
||||
events []partybus.Event
|
||||
wantErr require.ErrorAssertionFunc
|
||||
}{
|
||||
{
|
||||
name: "no events",
|
||||
},
|
||||
{
|
||||
name: "all events",
|
||||
events: []partybus.Event{
|
||||
{
|
||||
Type: event.CLINotification,
|
||||
Value: "\n\n<my notification 1!!\n...still notifying>\n\n",
|
||||
},
|
||||
{
|
||||
Type: event.CLINotification,
|
||||
Value: "<notification 2>",
|
||||
},
|
||||
{
|
||||
Type: event.CLIAppUpdateAvailable,
|
||||
Value: "\n\n<my app can be updated!!\n...to this version>\n\n",
|
||||
},
|
||||
{
|
||||
Type: event.CLINotification,
|
||||
Value: "<notification 3>",
|
||||
},
|
||||
{
|
||||
Type: event.CLIReport,
|
||||
Value: "\n\n<my --\n-\n-\nreport 1!!>\n\n",
|
||||
},
|
||||
{
|
||||
Type: event.CLIReport,
|
||||
Value: "<report 2>",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "quiet only shows report",
|
||||
quiet: true,
|
||||
events: []partybus.Event{
|
||||
|
||||
{
|
||||
Type: event.CLINotification,
|
||||
Value: "<notification 1>",
|
||||
},
|
||||
{
|
||||
Type: event.CLIAppUpdateAvailable,
|
||||
Value: "<app update>",
|
||||
},
|
||||
{
|
||||
Type: event.CLIReport,
|
||||
Value: "<report 1>",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.wantErr == nil {
|
||||
tt.wantErr = require.NoError
|
||||
}
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
w := newPostUIEventWriter(stdout, stderr)
|
||||
|
||||
tt.wantErr(t, w.write(tt.quiet, tt.events...))
|
||||
|
||||
t.Run("stdout", func(t *testing.T) {
|
||||
snaps.MatchSnapshot(t, stdout.String())
|
||||
})
|
||||
|
||||
t.Run("stderr", func(t *testing.T) {
|
||||
snaps.MatchSnapshot(t, stderr.String())
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//go:build linux || darwin || netbsd
|
||||
// +build linux darwin netbsd
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
|
||||
"golang.org/x/term"
|
||||
|
||||
"github.com/anchore/clio"
|
||||
handler "github.com/anchore/syft/cmd/syft/cli/ui"
|
||||
)
|
||||
|
||||
// Select is responsible for determining the specific UI function given select user option, the current platform
|
||||
// config values, and environment status (such as a TTY being present). The first UI in the returned slice of UIs
|
||||
// is intended to be used and the UIs that follow are meant to be attempted only in a fallback posture when there
|
||||
// are environmental problems (e.g. cannot write to the terminal). A writer is provided to capture the output of
|
||||
// the final SBOM report.
|
||||
func Select(verbose, quiet bool) (uis []clio.UI) {
|
||||
isStdoutATty := term.IsTerminal(int(os.Stdout.Fd()))
|
||||
isStderrATty := term.IsTerminal(int(os.Stderr.Fd()))
|
||||
notATerminal := !isStderrATty && !isStdoutATty
|
||||
|
||||
switch {
|
||||
case runtime.GOOS == "windows" || verbose || quiet || notATerminal || !isStderrATty:
|
||||
uis = append(uis, None(quiet))
|
||||
default:
|
||||
// TODO: it may make sense in the future to pass handler options into select
|
||||
h := handler.New(handler.DefaultHandlerConfig())
|
||||
uis = append(uis, New(h, verbose, quiet))
|
||||
}
|
||||
|
||||
return uis
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package ui
|
||||
|
||||
import "github.com/anchore/clio"
|
||||
|
||||
// Select is responsible for determining the specific UI function given select user option, the current platform
|
||||
// config values, and environment status (such as a TTY being present). The first UI in the returned slice of UIs
|
||||
// is intended to be used and the UIs that follow are meant to be attempted only in a fallback posture when there
|
||||
// are environmental problems (e.g. cannot write to the terminal). A writer is provided to capture the output of
|
||||
// the final SBOM report.
|
||||
func Select(verbose, quiet bool) (uis []clio.UI) {
|
||||
return append(uis, None(quiet))
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/wagoodman/go-partybus"
|
||||
|
||||
"github.com/anchore/bubbly/bubbles/frame"
|
||||
"github.com/anchore/clio"
|
||||
"github.com/anchore/go-logger"
|
||||
handler "github.com/anchore/syft/cmd/syft/cli/ui"
|
||||
"github.com/anchore/syft/internal/bus"
|
||||
"github.com/anchore/syft/internal/log"
|
||||
"github.com/anchore/syft/syft/event"
|
||||
)
|
||||
|
||||
var _ interface {
|
||||
tea.Model
|
||||
partybus.Responder
|
||||
clio.UI
|
||||
} = (*UI)(nil)
|
||||
|
||||
type UI struct {
|
||||
program *tea.Program
|
||||
running *sync.WaitGroup
|
||||
quiet bool
|
||||
subscription partybus.Unsubscribable
|
||||
finalizeEvents []partybus.Event
|
||||
|
||||
handler *handler.Handler
|
||||
frame tea.Model
|
||||
}
|
||||
|
||||
func New(h *handler.Handler, _, quiet bool) *UI {
|
||||
return &UI{
|
||||
handler: h,
|
||||
frame: frame.New(),
|
||||
running: &sync.WaitGroup{},
|
||||
quiet: quiet,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *UI) Setup(subscription partybus.Unsubscribable) error {
|
||||
// we still want to collect log messages, however, we also the logger shouldn't write to the screen directly
|
||||
if logWrapper, ok := log.Get().(logger.Controller); ok {
|
||||
logWrapper.SetOutput(m.frame.(*frame.Frame).Footer())
|
||||
}
|
||||
|
||||
m.subscription = subscription
|
||||
m.program = tea.NewProgram(m, tea.WithOutput(os.Stderr), tea.WithInput(os.Stdin))
|
||||
m.running.Add(1)
|
||||
|
||||
go func() {
|
||||
defer m.running.Done()
|
||||
if _, err := m.program.Run(); err != nil {
|
||||
log.Errorf("unable to start UI: %+v", err)
|
||||
m.exit()
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *UI) exit() {
|
||||
// stop the event loop
|
||||
bus.Exit()
|
||||
}
|
||||
|
||||
func (m *UI) Handle(e partybus.Event) error {
|
||||
if m.program != nil {
|
||||
m.program.Send(e)
|
||||
if e.Type == event.CLIExit {
|
||||
return m.subscription.Unsubscribe()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *UI) Teardown(force bool) error {
|
||||
if !force {
|
||||
m.handler.Running.Wait()
|
||||
m.program.Quit()
|
||||
} else {
|
||||
m.program.Kill()
|
||||
}
|
||||
|
||||
m.running.Wait()
|
||||
|
||||
// TODO: allow for writing out the full log output to the screen (only a partial log is shown currently)
|
||||
// this needs coordination to know what the last frame event is to change the state accordingly (which isn't possible now)
|
||||
|
||||
return newPostUIEventWriter(os.Stdout, os.Stderr).write(m.quiet, m.finalizeEvents...)
|
||||
}
|
||||
|
||||
// bubbletea.Model functions
|
||||
|
||||
func (m UI) Init() tea.Cmd {
|
||||
return m.frame.Init()
|
||||
}
|
||||
|
||||
func (m UI) RespondsTo() []partybus.EventType {
|
||||
return append([]partybus.EventType{
|
||||
event.CLIReport,
|
||||
event.CLINotification,
|
||||
event.CLIExit,
|
||||
event.CLIAppUpdateAvailable,
|
||||
}, m.handler.RespondsTo()...)
|
||||
}
|
||||
|
||||
func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// note: we need a pointer receiver such that the same instance of UI used in Teardown is referenced (to keep finalize events)
|
||||
|
||||
var cmds []tea.Cmd
|
||||
|
||||
// allow for non-partybus UI updates (such as window size events). Note: these must not affect existing models,
|
||||
// that is the responsibility of the frame object on this UI object. The handler is a factory of models
|
||||
// which the frame is responsible for the lifecycle of. This update allows for injecting the initial state
|
||||
// of the world when creating those models.
|
||||
m.handler.Update(msg)
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc", "ctrl+c":
|
||||
m.exit()
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case partybus.Event:
|
||||
log.WithFields("component", "ui").Tracef("event: %q", msg.Type)
|
||||
|
||||
switch msg.Type {
|
||||
case event.CLIReport, event.CLINotification, event.CLIExit, event.CLIAppUpdateAvailable:
|
||||
// keep these for when the UI is terminated to show to the screen (or perform other events)
|
||||
m.finalizeEvents = append(m.finalizeEvents, msg)
|
||||
|
||||
// why not return tea.Quit here for exit events? because there may be UI components that still need the update-render loop.
|
||||
// for this reason we'll let the syft event loop call Teardown() which will explicitly wait for these components
|
||||
return m, nil
|
||||
}
|
||||
|
||||
for _, newModel := range m.handler.Handle(msg) {
|
||||
if newModel == nil {
|
||||
continue
|
||||
}
|
||||
cmds = append(cmds, newModel.Init())
|
||||
m.frame.(*frame.Frame).AppendModel(newModel)
|
||||
}
|
||||
// intentionally fallthrough to update the frame model
|
||||
}
|
||||
|
||||
frameModel, cmd := m.frame.Update(msg)
|
||||
m.frame = frameModel
|
||||
cmds = append(cmds, cmd)
|
||||
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m UI) View() string {
|
||||
return m.frame.View()
|
||||
}
|
||||
Reference in New Issue
Block a user