diff --git a/CHANGELOG.md b/CHANGELOG.md index 12025d3..6a84a03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,13 @@ - `R` key now clears cache for current directory and forces re-read - Cache is per-path: navigating to a previously visited folder is instant +### SFTP File Transfer Progress Bar +- Added progress bar during upload/download: `[████████░░░░] 67% 12.3MB/18.5MB` +- `progressWriter` wraps `io.Writer` and reports bytes transferred +- Footer shows live progress during transfer +- Auto-refresh destination pane after transfer completes +- Success message shown for 1 second before clearing + ## v1.0.0 (2025-01-30) ### Added diff --git a/pkg/tui/sftp_browser_tab.go b/pkg/tui/sftp_browser_tab.go index 808760b..d2a945c 100644 --- a/pkg/tui/sftp_browser_tab.go +++ b/pkg/tui/sftp_browser_tab.go @@ -425,27 +425,59 @@ func (t *SFTPBrowserTab) Update(msg tea.Msg) (Tab, tea.Cmd) { } func (t *SFTPBrowserTab) copyFile(srcType, dstType paneType, srcPath, dstPath, name string) { + // Get file size for progress bar + var totalSize int64 + if srcType == paneRemote { + t.mu.Lock() + client := t.right.sftpClient + t.mu.Unlock() + if client != nil { + if info, err := client.Stat(srcPath); err == nil { + totalSize = info.Size() + } + } + } else { + if info, err := os.Stat(srcPath); err == nil { + totalSize = info.Size() + } + } + t.mu.Lock() t.transferring = true - t.transferMsg = fmt.Sprintf("Copying %s...", name) + t.transferMsg = fmt.Sprintf("Copying %s... 0%%", name) t.mu.Unlock() + progressFn := func(transferred int64) { + if totalSize <= 0 { + return + } + pct := int(transferred * 100 / totalSize) + barWidth := 20 + filled := int(float64(barWidth) * float64(transferred) / float64(totalSize)) + if filled > barWidth { + filled = barWidth + } + bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled) + t.mu.Lock() + t.transferMsg = fmt.Sprintf("Copying %s... [%s] %d%% %s/%s", + name, bar, pct, formatSize(transferred), formatSize(totalSize)) + t.mu.Unlock() + } + var err error if srcType == paneRemote && dstType == paneLocal { - // Download t.mu.Lock() client := t.right.sftpClient t.mu.Unlock() if client != nil { - err = downloadFile(client, srcPath, dstPath) + err = downloadFile(client, srcPath, dstPath, progressFn) } } else if srcType == paneLocal && dstType == paneRemote { - // Upload t.mu.Lock() client := t.right.sftpClient t.mu.Unlock() if client != nil { - err = uploadFile(client, srcPath, dstPath) + err = uploadFile(client, srcPath, dstPath, progressFn) } } @@ -453,19 +485,26 @@ func (t *SFTPBrowserTab) copyFile(srcType, dstType paneType, srcPath, dstPath, n if err != nil { t.err = fmt.Errorf("copy %s: %w", name, err) } else { - t.transferMsg = "" + t.transferMsg = fmt.Sprintf("Done: %s", name) } t.transferring = false t.mu.Unlock() + // Auto-refresh destination pane if dstType == paneRemote { t.refreshRemote() } else { t.refreshLocal() } + + // Clear success message after brief delay + time.Sleep(1 * time.Second) + t.mu.Lock() + t.transferMsg = "" + t.mu.Unlock() } -func downloadFile(client *sftp.Client, remotePath, localPath string) error { +func downloadFile(client *sftp.Client, remotePath, localPath string, progressFn func(int64)) error { src, err := client.Open(remotePath) if err != nil { return err @@ -478,11 +517,12 @@ func downloadFile(client *sftp.Client, remotePath, localPath string) error { } defer dst.Close() - _, err = io.Copy(dst, src) + pw := &progressWriter{w: dst, total: 0, fn: progressFn} + _, err = io.Copy(pw, src) return err } -func uploadFile(client *sftp.Client, localPath, remotePath string) error { +func uploadFile(client *sftp.Client, localPath, remotePath string, progressFn func(int64)) error { src, err := os.Open(localPath) if err != nil { return err @@ -495,10 +535,27 @@ func uploadFile(client *sftp.Client, localPath, remotePath string) error { } defer dst.Close() - _, err = io.Copy(dst, src) + pw := &progressWriter{w: dst, total: 0, fn: progressFn} + _, err = io.Copy(pw, src) return err } +// progressWriter wraps an io.Writer and reports progress via callback +type progressWriter struct { + w io.Writer + total int64 + fn func(int64) +} + +func (pw *progressWriter) Write(p []byte) (int, error) { + n, err := pw.w.Write(p) + pw.total += int64(n) + if pw.fn != nil { + pw.fn(pw.total) + } + return n, err +} + func (t *SFTPBrowserTab) deleteItem(fullPath string, isDir bool, isRemote bool) { t.mu.Lock() t.transferring = true