diff --git a/cmd/acp/main.go b/cmd/acp/main.go index 99e422c..5653100 100644 --- a/cmd/acp/main.go +++ b/cmd/acp/main.go @@ -112,7 +112,10 @@ func transfer(ctx context.Context, conf *config.Config, filenames []string, logg return } - var status interface{ Next(tea.Model) } + var status interface { + Next(tea.Model) string + Logf(string, ...any) + } if len(filenames) > 0 { var s io.WriteCloser strategyFinal := strategyConsensus(strategy, info.Strategy) @@ -122,7 +125,7 @@ func transfer(ctx context.Context, conf *config.Config, filenames []string, logg } s, status = monitor(s) logger.Debugf("sending...") - err = sendFiles(filenames, s) + err = sendFiles(filenames, s, status.Logf) } else { var s io.ReadCloser strategyFinal := strategyConsensus(info.Strategy, strategy) @@ -136,7 +139,7 @@ func transfer(ctx context.Context, conf *config.Config, filenames []string, logg } if !*debug { - status.Next(loggerModel) + exitStatement = status.Next(loggerModel) // save in-transit log, if any } checkErr(err) } diff --git a/cmd/acp/stream.go b/cmd/acp/stream.go index 6f715b2..5691b2c 100644 --- a/cmd/acp/stream.go +++ b/cmd/acp/stream.go @@ -11,7 +11,7 @@ import ( "github.com/klauspost/pgzip" ) -func sendFiles(filenames []string, to io.WriteCloser) (err error) { +func sendFiles(filenames []string, to io.WriteCloser, errorf func(string, ...any)) (err error) { defer to.Close() z := pgzip.NewWriter(to) defer z.Close() @@ -29,7 +29,7 @@ func sendFiles(filenames []string, to io.WriteCloser) (err error) { if err != nil { return err } - err = tarWalk(fname, tz) + err = tarWalk(fname, tz, errorf) if err != nil { return fmt.Errorf("tar: %w", err) } diff --git a/cmd/acp/tar.go b/cmd/acp/tar.go index b698e0a..20e29a0 100644 --- a/cmd/acp/tar.go +++ b/cmd/acp/tar.go @@ -12,7 +12,7 @@ import ( "runtime" ) -func tarWalk(source string, t *tar.Writer) error { +func tarWalk(source string, t *tar.Writer, errorf func(string, ...any)) error { sourceInfo, err := os.Stat(source) if err != nil { return fmt.Errorf("%s: stat: %w", source, err) @@ -20,21 +20,25 @@ func tarWalk(source string, t *tar.Writer) error { sourceIsDir := sourceInfo.IsDir() return filepath.Walk(source, func(fpath string, info os.FileInfo, err error) error { if err != nil { - return fmt.Errorf("traversing %s: %w", fpath, err) + errorf("traversing %s: %v", fpath, err) + return nil } if info == nil { - return fmt.Errorf("no file info for %s", fpath) + errorf("no file info for %s", fpath) + return nil } // build the name to be used within the archive relName, err := nameInArchive(sourceIsDir, source, fpath) if err != nil { - return err + errorf("name in archive: %v", err) + return nil } var linkTarget string if info.Mode()&os.ModeSymlink != 0 { linkTarget, err = os.Readlink(fpath) if err != nil { - return fmt.Errorf("%s: readlink: %v", fpath, err) + errorf("%s: readlink: %v", fpath, err) + return nil } linkTarget = filepath.ToSlash(linkTarget) } @@ -42,7 +46,8 @@ func tarWalk(source string, t *tar.Writer) error { if info.Mode().IsRegular() { file, err = os.Open(fpath) if err != nil { - return fmt.Errorf("%s: opening: %w", fpath, err) + errorf("%s: opening: %v", fpath, err) + return nil } defer file.Close() } diff --git a/pkg/tui/auxlogger.go b/pkg/tui/auxlogger.go new file mode 100644 index 0000000..cd489c0 --- /dev/null +++ b/pkg/tui/auxlogger.go @@ -0,0 +1,82 @@ +package tui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +type ( + auxLoggerControl struct { + ch chan tea.Msg + chFinalLog chan string + maxRows int + } + auxLoggerQuitMsg struct{} +) + +func newAuxLoggerControl(maxRows int) auxLoggerControl { + return auxLoggerControl{make(chan tea.Msg), make(chan string), maxRows} +} + +// Logf logs a message +func (c auxLoggerControl) Logf(format string, a ...any) { + c.ch <- logMsg(fmt.Sprintf(format, a...)) +} + +// epilogue shuts down the logger and returns the current displayed log +func (c auxLoggerControl) epilogue() string { + c.ch <- auxLoggerQuitMsg{} + close(c.ch) + return <-c.chFinalLog +} + +// auxLoggerModel is a model that displays a rolling log alongside other models. +// i.e. it is intended to be used as an embedded model +type auxLoggerModel struct { + logger auxLoggerControl + logs []string + logRepr string +} + +func newAuxLoggerModel(c auxLoggerControl) tea.Model { + return auxLoggerModel{logger: c} +} + +func (m auxLoggerModel) waitForLog() tea.Msg { + return <-m.logger.ch +} + +func (m auxLoggerModel) Init() tea.Cmd { + return m.waitForLog +} + +func (m auxLoggerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case logMsg: + m.logs = append(m.logs, string(msg)) + if len(m.logs) > m.logger.maxRows { + m.logs = m.logs[1:] + } + m.logRepr = strings.Join(m.logs, "\n") + return m, m.waitForLog + case auxLoggerQuitMsg: + final := m.logRepr + if final != "" { + heading := "Skipped errors:" + if len(m.logs) == m.logger.maxRows { + heading += fmt.Sprintf(" (showing only last %d entries)", m.logger.maxRows) + } + final = heading + "\n" + final + } + m.logger.chFinalLog <- final + close(m.logger.chFinalLog) + return m, nil + } + return m, nil +} + +func (m auxLoggerModel) View() string { + return m.logRepr +} diff --git a/pkg/tui/status.go b/pkg/tui/status.go index 59e345d..e9fa428 100644 --- a/pkg/tui/status.go +++ b/pkg/tui/status.go @@ -15,13 +15,15 @@ type ( // A StatusControl is the user handler for a StausModel StatusControl[T io.Closer] struct { *meteredReadWriteCloser[T] + auxLoggerControl chNext chan tea.Msg } ) func NewStatusControl[T io.Closer]() *StatusControl[T] { return &StatusControl[T]{ - chNext: make(chan tea.Msg), + auxLoggerControl: newAuxLoggerControl(5), + chNext: make(chan tea.Msg), } } @@ -31,10 +33,11 @@ func (c *StatusControl[T]) Monitor(stream T) T { return any(c.meteredReadWriteCloser).(T) } -// Next switches to the next BubbleTea Model and shut down current StatusModel -func (c *StatusControl[_]) Next(m tea.Model) { +// Next switches to the next BubbleTea Model, shuts down current StatusModel and passes the final log +func (c *StatusControl[_]) Next(m tea.Model) string { c.chNext <- modelSwitchMsg{m} close(c.chNext) + return c.auxLoggerControl.epilogue() } type meteredReadWriteCloser[T io.Closer] struct { @@ -90,12 +93,14 @@ func (m *meteredReadWriteCloser[_]) Close() error { // A StatusModel displays a updating stats of data transfer type StatusModel[T io.Closer] struct { spinner spinner.Model + logger tea.Model status *StatusControl[T] } func NewStatusModel[T io.Closer](c *StatusControl[T]) tea.Model { return StatusModel[T]{ spinner: spinner.New(spinner.WithSpinner(spinner.Points)), + logger: newAuxLoggerModel(c.auxLoggerControl), status: c, } } @@ -105,7 +110,7 @@ func (m StatusModel[_]) waitForNext() tea.Msg { } func (m StatusModel[_]) Init() tea.Cmd { - return tea.Batch(m.spinner.Tick, m.waitForNext) + return tea.Batch(m.spinner.Tick, m.logger.Init(), m.waitForNext) } func (m StatusModel[_]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -114,6 +119,10 @@ func (m StatusModel[_]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd + case logMsg, auxLoggerQuitMsg: + var cmd tea.Cmd + m.logger, cmd = m.logger.Update(msg) + return m, cmd case modelSwitchMsg: return modelSwitchTo(msg.model), nil case tea.KeyMsg: @@ -128,5 +137,9 @@ func (m StatusModel[_]) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m StatusModel[_]) View() string { rate, total := m.status.rate.Load(), m.status.total.Load() - return fmt.Sprintf("%s %6s/s %6s", m.spinner.View(), humanize.Bytes(rate), humanize.Bytes(total)) + spinnerView := fmt.Sprintf("%s %6s/s %6s", m.spinner.View(), humanize.Bytes(rate), humanize.Bytes(total)) + if loggerView := m.logger.View(); loggerView != "" { + return spinnerView + "\n" + loggerView + } + return spinnerView }