feat: SFTP file transfer progress bar + auto-refresh
- Progress bar: [████████░░░░] 67% 12.3MB/18.5MB - progressWriter wraps io.Writer, reports bytes via callback - Footer shows live progress during upload/download - Auto-refresh destination pane after transfer completes - Success message shown for 1 second before clearing
This commit is contained in:
@@ -46,6 +46,13 @@
|
|||||||
- `R` key now clears cache for current directory and forces re-read
|
- `R` key now clears cache for current directory and forces re-read
|
||||||
- Cache is per-path: navigating to a previously visited folder is instant
|
- 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)
|
## v1.0.0 (2025-01-30)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+67
-10
@@ -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) {
|
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.mu.Lock()
|
||||||
t.transferring = true
|
t.transferring = true
|
||||||
t.transferMsg = fmt.Sprintf("Copying %s...", name)
|
t.transferMsg = fmt.Sprintf("Copying %s... 0%%", name)
|
||||||
t.mu.Unlock()
|
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
|
var err error
|
||||||
if srcType == paneRemote && dstType == paneLocal {
|
if srcType == paneRemote && dstType == paneLocal {
|
||||||
// Download
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
client := t.right.sftpClient
|
client := t.right.sftpClient
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
if client != nil {
|
if client != nil {
|
||||||
err = downloadFile(client, srcPath, dstPath)
|
err = downloadFile(client, srcPath, dstPath, progressFn)
|
||||||
}
|
}
|
||||||
} else if srcType == paneLocal && dstType == paneRemote {
|
} else if srcType == paneLocal && dstType == paneRemote {
|
||||||
// Upload
|
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
client := t.right.sftpClient
|
client := t.right.sftpClient
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
if client != nil {
|
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 {
|
if err != nil {
|
||||||
t.err = fmt.Errorf("copy %s: %w", name, err)
|
t.err = fmt.Errorf("copy %s: %w", name, err)
|
||||||
} else {
|
} else {
|
||||||
t.transferMsg = ""
|
t.transferMsg = fmt.Sprintf("Done: %s", name)
|
||||||
}
|
}
|
||||||
t.transferring = false
|
t.transferring = false
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
// Auto-refresh destination pane
|
||||||
if dstType == paneRemote {
|
if dstType == paneRemote {
|
||||||
t.refreshRemote()
|
t.refreshRemote()
|
||||||
} else {
|
} else {
|
||||||
t.refreshLocal()
|
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)
|
src, err := client.Open(remotePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -478,11 +517,12 @@ func downloadFile(client *sftp.Client, remotePath, localPath string) error {
|
|||||||
}
|
}
|
||||||
defer dst.Close()
|
defer dst.Close()
|
||||||
|
|
||||||
_, err = io.Copy(dst, src)
|
pw := &progressWriter{w: dst, total: 0, fn: progressFn}
|
||||||
|
_, err = io.Copy(pw, src)
|
||||||
return err
|
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)
|
src, err := os.Open(localPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -495,10 +535,27 @@ func uploadFile(client *sftp.Client, localPath, remotePath string) error {
|
|||||||
}
|
}
|
||||||
defer dst.Close()
|
defer dst.Close()
|
||||||
|
|
||||||
_, err = io.Copy(dst, src)
|
pw := &progressWriter{w: dst, total: 0, fn: progressFn}
|
||||||
|
_, err = io.Copy(pw, src)
|
||||||
return err
|
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) {
|
func (t *SFTPBrowserTab) deleteItem(fullPath string, isDir bool, isRemote bool) {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
t.transferring = true
|
t.transferring = true
|
||||||
|
|||||||
Reference in New Issue
Block a user