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:
Alex Goodman
2023-07-06 09:00:46 -04:00
committed by GitHub
parent a00a3df10c
commit f8b832e6c3
77 changed files with 3250 additions and 618 deletions
@@ -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]
---
+44
View File
@@ -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())
})
})
}
}
+36
View File
@@ -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
}
+15
View File
@@ -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))
}
+163
View File
@@ -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()
}