From 43e19d8f388e5bd955a87f2c634034278a6262f8 Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Fri, 30 Jan 2026 14:47:43 -0800 Subject: [PATCH] Improve self-update output with progress bar and changelog link Replace the info-prefixed update messages with cleaner output: progress bar during download (TTY only), "Already using the latest version" for no-ops, and a changelog URL after successful updates. Pass --silent for background auto-update subprocess. --- integration/integration_test.go | 5 +- internal/update/progress.go | 259 ++++++++++++++++++++++++++++++++ internal/update/update.go | 89 ++++++++--- main.go | 2 +- 4 files changed, 331 insertions(+), 24 deletions(-) create mode 100644 internal/update/progress.go diff --git a/integration/integration_test.go b/integration/integration_test.go index fdedc26..351753c 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -1163,7 +1163,7 @@ func TestMain(t *testing.T) { // Test update using latest version. res := runFetch(t, fetchPath, server.URL, "--update") assertExitCode(t, 0, res) - assertBufContains(t, res.stderr, "currently using the latest version") + assertBufContains(t, res.stderr, "Already using the latest version") if s := listFiles(t, filepath.Dir(fetchPath)); len(s) > 1 { t.Fatalf("unexpected files after updating: %v", s) } @@ -1176,7 +1176,8 @@ func TestMain(t *testing.T) { newVersion.Store(&newStr) res = runFetch(t, fetchPath, server.URL, "--update") assertExitCode(t, 0, res) - assertBufContains(t, res.stderr, "fetch successfully updated") + assertBufContains(t, res.stderr, "Updated fetch:") + assertBufContains(t, res.stderr, "Changelog:") if s := listFiles(t, filepath.Dir(fetchPath)); len(s) > 1 { t.Fatalf("unexpected files after updating: %v", s) } diff --git a/internal/update/progress.go b/internal/update/progress.go new file mode 100644 index 0000000..b6561c3 --- /dev/null +++ b/internal/update/progress.go @@ -0,0 +1,259 @@ +package update + +import ( + "io" + "strconv" + "sync" + "time" + + "github.com/ryanfowler/fetch/internal/core" +) + +// updateProgress wraps an io.ReadCloser and displays a progress bar to stderr. +type updateProgress struct { + rc io.ReadCloser + printer *core.Printer + bytesRead int64 + totalBytes int64 + chRead chan int64 + wg sync.WaitGroup +} + +func newUpdateProgress(rc io.ReadCloser, p *core.Printer, totalBytes int64) *updateProgress { + up := &updateProgress{ + rc: rc, + printer: p, + totalBytes: totalBytes, + chRead: make(chan int64, 1), + } + up.wg.Add(1) + go up.renderLoop() + return up +} + +func (up *updateProgress) Read(p []byte) (int, error) { + n, err := up.rc.Read(p) + if n > 0 { + up.chRead <- int64(n) + } + return n, err +} + +func (up *updateProgress) Close() error { + err := up.rc.Close() + close(up.chRead) + up.wg.Wait() + up.clearLine() + return err +} + +func (up *updateProgress) renderLoop() { + defer up.wg.Done() + + start := time.Now() + var chTimeout <-chan time.Time + for { + select { + case <-chTimeout: + chTimeout = nil + case n, ok := <-up.chRead: + if !ok { + up.render() + return + } + up.bytesRead += n + + if chTimeout != nil { + continue + } + + dur := time.Until(start.Add(100 * time.Millisecond)) + if dur > 0 { + chTimeout = time.After(dur) + continue + } + start = time.Now() + } + + up.render() + } +} + +func (up *updateProgress) render() { + const barWidth = 30 + percentage := up.bytesRead * 100 / up.totalBytes + completedWidth := min(barWidth*percentage/100, barWidth) + + p := up.printer + + p.WriteString("\r") + + p.Set(core.Bold) + p.WriteString("[") + p.Set(core.Green) + for range completedWidth { + p.WriteString("=") + } + p.Reset() + for range barWidth - completedWidth { + p.WriteString(" ") + } + p.Set(core.Bold) + p.WriteString("] ") + + pctStr := strconv.FormatInt(percentage, 10) + for i := len(pctStr); i < 3; i++ { + p.WriteString(" ") + } + p.WriteString(pctStr) + p.WriteString("%") + p.Reset() + + p.WriteString(" (") + size := updateFormatSize(up.bytesRead) + for range 7 - len(size) { + p.WriteString(" ") + } + p.WriteString(size) + p.WriteString(" / ") + p.WriteString(updateFormatSize(up.totalBytes)) + p.WriteString(")") + p.Flush() +} + +func (up *updateProgress) clearLine() { + p := up.printer + p.WriteString("\r") + for range 60 { + p.WriteString(" ") + } + p.WriteString("\r") + p.Flush() +} + +// updateSpinner wraps an io.ReadCloser and displays a bouncing spinner to stderr. +type updateSpinner struct { + rc io.ReadCloser + printer *core.Printer + bytesRead int64 + chRead chan int64 + position int64 + wg sync.WaitGroup +} + +func newUpdateSpinner(rc io.ReadCloser, p *core.Printer) *updateSpinner { + us := &updateSpinner{ + rc: rc, + printer: p, + chRead: make(chan int64, 1), + } + us.wg.Add(1) + go us.renderLoop() + return us +} + +func (us *updateSpinner) Read(p []byte) (int, error) { + n, err := us.rc.Read(p) + if n > 0 { + us.chRead <- int64(n) + } + return n, err +} + +func (us *updateSpinner) Close() error { + err := us.rc.Close() + close(us.chRead) + us.wg.Wait() + us.clearLine() + return err +} + +func (us *updateSpinner) renderLoop() { + defer us.wg.Done() + + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + us.render() + us.position++ + case n, ok := <-us.chRead: + if !ok { + us.render() + return + } + us.bytesRead += n + } + } +} + +func (us *updateSpinner) render() { + const width = 20 + + var value string + var offset int + position := us.position % (int64(width) * 2) + if position < int64(width) { + value = "=>" + offset = int(position) + } else { + value = "<=" + offset = int(int64(width)*2 - position - 1) + } + + p := us.printer + p.WriteString("\r") + p.Set(core.Bold) + p.WriteString("[") + for range offset { + p.WriteString(" ") + } + p.Set(core.Green) + p.WriteString(value) + p.Reset() + for range width - offset - 1 { + p.WriteString(" ") + } + p.Set(core.Bold) + p.WriteString("]") + p.Reset() + + p.WriteString(" ") + size := updateFormatSize(us.bytesRead) + for range 7 - len(size) { + p.WriteString(" ") + } + p.WriteString(size) + + p.Flush() +} + +func (us *updateSpinner) clearLine() { + p := us.printer + p.WriteString("\r") + for range 40 { + p.WriteString(" ") + } + p.WriteString("\r") + p.Flush() +} + +// updateFormatSize converts bytes to a human-readable string. +func updateFormatSize(bytes int64) string { + const units = "KMGTPE" + const unit = 1024 + if bytes < unit { + return strconv.FormatInt(bytes, 10) + "B" + } + div, exp := int64(unit), 0 + for n := bytes / unit; n >= 1000; n /= unit { + div *= unit + exp++ + } + value := float64(bytes) / float64(div) + if exp >= len(units) { + return "NaN" + } + return strconv.FormatFloat(value, 'f', 1, 64) + string(units[exp]) + "B" +} diff --git a/internal/update/update.go b/internal/update/update.go index f4c27ca..abae949 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -85,15 +85,22 @@ func updateInner(ctx context.Context, p *core.Printer, silent bool) error { return err } - writeInfo(p, silent, "fetching latest release tag") + writeMsg(p, silent, "Fetching latest release...\n") latest, err := getLatestRelease(ctx, c) if err != nil { - return fmt.Errorf("fetching latest release: %w", err) + return fmt.Errorf("unable to fetch the latest release: %w", err) } if latest.TagName == version { // Already using the latest version, exit successfully. - writeInfo(p, silent, fmt.Sprintf("currently using the latest version (%s)", version)) + if !silent { + p.WriteString("Already using the latest version (") + p.Set(core.Bold) + p.WriteString(version) + p.Reset() + p.WriteString(").\n") + p.Flush() + } return nil } @@ -103,20 +110,33 @@ func updateInner(ctx context.Context, p *core.Printer, silent bool) error { return errNoReleaseArtifact{} } - writeInfo(p, silent, fmt.Sprintf("downloading latest version (%s)", latest.TagName)) - rc, err := getArtifactReader(ctx, c, artifactURL) + if !silent { + p.WriteString("Downloading ") + p.Set(core.Bold) + p.WriteString(latest.TagName) + p.Reset() + p.WriteString("\n\n") + p.Flush() + } + + rc, contentLength, err := getArtifactReader(ctx, c, artifactURL) if err != nil { return fmt.Errorf("fetching artifact: %w", err) } - defer rc.Close() // Create a temporary directory, and unpack the artifact into it. tempDir, err := os.MkdirTemp("", "fetch-") if err != nil { + rc.Close() return err } defer os.RemoveAll(tempDir) + + // Wrap reader with progress indicator if appropriate. + rc = wrapProgress(rc, p, silent, contentLength) + err = unpackArtifact(tempDir, rc) + rc.Close() if err != nil { return err } @@ -128,8 +148,7 @@ func updateInner(ctx context.Context, p *core.Printer, silent bool) error { return err } - msg := fmt.Sprintf("fetch successfully updated (%s -> %s)", version, latest.TagName) - writeInfo(p, silent, msg) + writeUpdateSuccess(p, silent, version, latest.TagName) return nil } @@ -194,11 +213,12 @@ func getLatestRelease(ctx context.Context, c *client.Client) (*Release, error) { return &release, nil } -// getArtifactReader returns an io.ReadCloser of the artifact data. -func getArtifactReader(ctx context.Context, c *client.Client, urlStr string) (io.ReadCloser, error) { +// getArtifactReader returns an io.ReadCloser of the artifact data and the +// content length (or -1 if unknown). +func getArtifactReader(ctx context.Context, c *client.Client, urlStr string) (io.ReadCloser, int64, error) { u, err := url.Parse(urlStr) if err != nil { - return nil, err + return nil, 0, err } cfg := client.RequestConfig{ @@ -207,20 +227,31 @@ func getArtifactReader(ctx context.Context, c *client.Client, urlStr string) (io } req, err := c.NewRequest(ctx, cfg) if err != nil { - return nil, err + return nil, 0, err } resp, err := c.Do(req) if err != nil { - return nil, err + return nil, 0, err } if resp.StatusCode != 200 { resp.Body.Close() - return nil, fmt.Errorf("downloading artifact: received status: %d", resp.StatusCode) + return nil, 0, fmt.Errorf("downloading artifact: received status: %d", resp.StatusCode) } - return resp.Body, nil + return resp.Body, resp.ContentLength, nil +} + +// wrapProgress wraps the reader with a progress indicator if appropriate. +func wrapProgress(rc io.ReadCloser, p *core.Printer, silent bool, contentLength int64) io.ReadCloser { + if silent || !core.IsStderrTerm { + return rc + } + if contentLength > 0 { + return newUpdateProgress(rc, p, contentLength) + } + return newUpdateSpinner(rc, p) } // getArtifactURL finds and returns the artifact URL for the current OS and @@ -258,18 +289,34 @@ func getFetchFilename() string { return name } -func writeInfo(p *core.Printer, silent bool, s string) { +func writeMsg(p *core.Printer, silent bool, s string) { if silent { return } + p.WriteString(s) + p.Flush() +} +func writeUpdateSuccess(p *core.Printer, silent bool, oldVersion, newVersion string) { + if silent { + return + } + + p.WriteString("Updated fetch: ") + p.WriteString(oldVersion) + p.WriteString(" -> ") p.Set(core.Bold) - p.Set(core.Green) - p.WriteString("info") + p.WriteString(newVersion) + p.Reset() + p.WriteString("\n\n") + + p.WriteString("Changelog: ") + p.Set(core.Underline) + p.WriteString("https://github.com/ryanfowler/fetch/compare/") + p.WriteString(oldVersion) + p.WriteString("...") + p.WriteString(newVersion) p.Reset() - p.WriteString(": ") - - p.WriteString(s) p.WriteString("\n") p.Flush() } diff --git a/main.go b/main.go index 4728bc0..e3bfa87 100644 --- a/main.go +++ b/main.go @@ -271,7 +271,7 @@ func checkForUpdate(ctx context.Context, p *core.Printer, dur time.Duration) { if err != nil { return } - _ = exec.Command(path, "--update", "--timeout=300").Start() + _ = exec.Command(path, "--update", "--timeout=300", "--silent").Start() } // writeCLIErr writes the provided CLI error to the Printer.