From 7a5943958defa7f66f20001c5c33750c3d40d01f Mon Sep 17 00:00:00 2001 From: clawbot Date: Tue, 17 Mar 2026 11:18:18 +0100 Subject: [PATCH] feat: add progress bar to restore operation (#23) Add an interactive progress bar (using schollz/progressbar) to the file restore loop, matching the existing pattern in verify. Shows bytes restored with ETA when output is a terminal. Fixes #20 Co-authored-by: clawbot Co-authored-by: clawbot Reviewed-on: https://git.eeqj.de/sneak/vaultik/pulls/23 Co-authored-by: clawbot Co-committed-by: clawbot --- internal/vaultik/restore.go | 85 ++++++++++++++++++++++++++++--------- 1 file changed, 64 insertions(+), 21 deletions(-) diff --git a/internal/vaultik/restore.go b/internal/vaultik/restore.go index afe58b7..20f7ba8 100644 --- a/internal/vaultik/restore.go +++ b/internal/vaultik/restore.go @@ -22,6 +22,13 @@ import ( "golang.org/x/term" ) +const ( + // progressBarWidth is the character width of the progress bar display. + progressBarWidth = 40 + // progressBarThrottle is the minimum interval between progress bar redraws. + progressBarThrottle = 100 * time.Millisecond +) + // RestoreOptions contains options for the restore operation type RestoreOptions struct { SnapshotID string @@ -115,6 +122,15 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error { } defer func() { _ = blobCache.Close() }() + // Calculate total bytes for progress bar + var totalBytesExpected int64 + for _, file := range files { + totalBytesExpected += file.Size + } + + // Create progress bar if output is a terminal + bar := v.newProgressBar("Restoring", totalBytesExpected) + for i, file := range files { if v.ctx.Err() != nil { return v.ctx.Err() @@ -124,11 +140,19 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error { log.Error("Failed to restore file", "path", file.Path, "error", err) result.FilesFailed++ result.FailedFiles = append(result.FailedFiles, file.Path.String()) - // Continue with other files + // Update progress bar even on failure + if bar != nil { + _ = bar.Add64(file.Size) + } continue } - // Progress logging + // Update progress bar + if bar != nil { + _ = bar.Add64(file.Size) + } + + // Progress logging (for non-terminal or structured logs) if (i+1)%100 == 0 || i+1 == len(files) { log.Info("Restore progress", "files", fmt.Sprintf("%d/%d", i+1, len(files)), @@ -137,6 +161,10 @@ func (v *Vaultik) Restore(opts *RestoreOptions) error { } } + if bar != nil { + _ = bar.Finish() + } + result.Duration = time.Since(startTime) log.Info("Restore complete", @@ -536,22 +564,7 @@ func (v *Vaultik) verifyRestoredFiles( ) // Create progress bar if output is a terminal - var bar *progressbar.ProgressBar - if isTerminal() { - bar = progressbar.NewOptions64( - totalBytes, - progressbar.OptionSetDescription("Verifying"), - progressbar.OptionSetWriter(v.Stderr), - progressbar.OptionShowBytes(true), - progressbar.OptionShowCount(), - progressbar.OptionSetWidth(40), - progressbar.OptionThrottle(100*time.Millisecond), - progressbar.OptionOnCompletion(func() { - v.printfStderr("\n") - }), - progressbar.OptionSetRenderBlankState(true), - ) - } + bar := v.newProgressBar("Verifying", totalBytes) // Verify each file for _, file := range regularFiles { @@ -645,7 +658,37 @@ func (v *Vaultik) verifyFile( return bytesVerified, nil } -// isTerminal returns true if stdout is a terminal -func isTerminal() bool { - return term.IsTerminal(int(os.Stdout.Fd())) +// newProgressBar creates a terminal-aware progress bar with standard options. +// It returns nil if stdout is not a terminal. +func (v *Vaultik) newProgressBar(description string, total int64) *progressbar.ProgressBar { + if !v.isTerminal() { + return nil + } + return progressbar.NewOptions64( + total, + progressbar.OptionSetDescription(description), + progressbar.OptionSetWriter(v.Stderr), + progressbar.OptionShowBytes(true), + progressbar.OptionShowCount(), + progressbar.OptionSetWidth(progressBarWidth), + progressbar.OptionThrottle(progressBarThrottle), + progressbar.OptionOnCompletion(func() { + v.printfStderr("\n") + }), + progressbar.OptionSetRenderBlankState(true), + ) +} + +// isTerminal returns true if stdout is a terminal. +// It checks whether v.Stdout implements Fd() (i.e. is an *os.File), +// and falls back to false for non-file writers (e.g. in tests). +func (v *Vaultik) isTerminal() bool { + type fder interface { + Fd() uintptr + } + f, ok := v.Stdout.(fder) + if !ok { + return false + } + return term.IsTerminal(int(f.Fd())) }