feat: same-host SSH optimization (Exec cp/mv), per-file progress for all remote copies

- SSHClient.Exec() and ExecWithProgress() for server-side commands
- SSHHost.SameAs() and SSHClient.SameHostAs() for host comparison
- Same-host copy uses ExecWithProgress('cp -rv') with per-line progress
- Same-host move uses Exec('mv'), instant server-side rename
- CopyDirFromRemote/ToRemote/BetweenRemotes with per-file progress callbacks
- Context cancellation support for all remote copy operations
- Two-phase remote copy: count files first, then transfer with progress bar
- Fix: consistent modal colors (Enter=Accent, Cancel=CancelButton, Background=Accent)
This commit is contained in:
vrubelroman 2026-05-13 01:11:55 +03:00
parent 8352441bda
commit fd2aa80894
3 changed files with 335 additions and 33 deletions

View file

@ -1,6 +1,8 @@
package remote package remote
import ( import (
"bufio"
"context"
"fmt" "fmt"
"io" "io"
"net" "net"
@ -310,6 +312,60 @@ func (c *SSHClient) IsConnected() bool {
return c.sftpCli != nil && c.sshConn != nil return c.sftpCli != nil && c.sshConn != nil
} }
// Exec runs a shell command on the remote server and returns combined output.
func (c *SSHClient) Exec(cmd string) ([]byte, error) {
if c.sshConn == nil {
return nil, fmt.Errorf("not connected")
}
session, err := c.sshConn.NewSession()
if err != nil {
return nil, fmt.Errorf("open session: %w", err)
}
defer session.Close()
return session.CombinedOutput(cmd)
}
// ExecWithProgress runs a shell command on the remote server and calls onLine
// for each line of stdout output.
func (c *SSHClient) ExecWithProgress(cmd string, onLine func(line string)) error {
if c.sshConn == nil {
return fmt.Errorf("not connected")
}
session, err := c.sshConn.NewSession()
if err != nil {
return fmt.Errorf("open session: %w", err)
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return fmt.Errorf("stdout pipe: %w", err)
}
if err := session.Start(cmd); err != nil {
return fmt.Errorf("start command: %w", err)
}
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
onLine(scanner.Text())
}
if scanErr := scanner.Err(); scanErr != nil {
return scanErr
}
return session.Wait()
}
// SameHostAs returns true if this client and other are connected to the same server.
func (c *SSHClient) SameHostAs(other *SSHClient) bool {
if c == nil || other == nil {
return false
}
return c.Host.SameAs(other.Host)
}
// RemoveRecursive recursively deletes a remote file or directory. // RemoveRecursive recursively deletes a remote file or directory.
// For directories, it walks and removes all children first. // For directories, it walks and removes all children first.
func (c *SSHClient) RemoveRecursive(path string) error { func (c *SSHClient) RemoveRecursive(path string) error {
@ -431,37 +487,79 @@ func (c *SSHClient) CopyFileFromRemote(remotePath, localPath string) error {
// CopyDirToRemote recursively copies a local directory to a remote path. // CopyDirToRemote recursively copies a local directory to a remote path.
func (c *SSHClient) CopyDirToRemote(localDir, remoteDir string) error { func (c *SSHClient) CopyDirToRemote(localDir, remoteDir string) error {
return c.copyDirToRemote(localDir, remoteDir, nil, nil)
}
// CopyDirToRemoteProgress is like CopyDirToRemote but calls onFile after each copy.
func (c *SSHClient) CopyDirToRemoteProgress(localDir, remoteDir string, onFile func(path string, done, total int), ctx context.Context) error {
return c.copyDirToRemote(localDir, remoteDir, onFile, ctx)
}
func (c *SSHClient) copyDirToRemote(localDir, remoteDir string, onFile func(path string, done, total int), ctx context.Context) error {
done := 0
return filepath.Walk(localDir, func(localPath string, info os.FileInfo, err error) error { return filepath.Walk(localDir, func(localPath string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
if ctx != nil {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
relPath, _ := filepath.Rel(localDir, localPath) relPath, _ := filepath.Rel(localDir, localPath)
remotePath := path.Join(remoteDir, relPath) remotePath := path.Join(remoteDir, relPath)
if info.IsDir() { if info.IsDir() {
return c.MkdirAll(remotePath) return c.MkdirAll(remotePath)
} }
if err := c.CopyFileToRemote(localPath, remotePath); err != nil {
return c.CopyFileToRemote(localPath, remotePath) return err
}
done++
if onFile != nil {
onFile(remotePath, done, 0)
}
return nil
}) })
} }
// CopyDirFromRemote recursively copies a remote directory to a local path. // CopyDirFromRemote recursively copies a remote directory to a local path.
func (c *SSHClient) CopyDirFromRemote(remoteDir, localDir string) error { func (c *SSHClient) CopyDirFromRemote(remoteDir, localDir string) error {
return c.copyDirFromRemote(remoteDir, localDir, nil, nil)
}
// CopyDirFromRemoteProgress is like CopyDirFromRemote but calls onFile after each copy.
func (c *SSHClient) CopyDirFromRemoteProgress(remoteDir, localDir string, onFile func(path string, done, total int), ctx context.Context) error {
return c.copyDirFromRemote(remoteDir, localDir, onFile, ctx)
}
func (c *SSHClient) copyDirFromRemote(remoteDir, localDir string, onFile func(path string, done, total int), ctx context.Context) error {
done := 0
return c.Walk(remoteDir, func(remotePath string, info os.FileInfo, err error) error { return c.Walk(remoteDir, func(remotePath string, info os.FileInfo, err error) error {
if ctx != nil {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
if err != nil { if err != nil {
return err return err
} }
relPath, _ := filepath.Rel(remoteDir, remotePath) relPath, _ := filepath.Rel(remoteDir, remotePath)
localPath := filepath.Join(localDir, relPath) localPath := filepath.Join(localDir, relPath)
if info.IsDir() { if info.IsDir() {
return os.MkdirAll(localPath, 0o755) return os.MkdirAll(localPath, 0o755)
} }
if err := c.CopyFileFromRemote(remotePath, localPath); err != nil {
return c.CopyFileFromRemote(remotePath, localPath) return err
}
done++
if onFile != nil {
onFile(localPath, done, 0)
}
return nil
}) })
} }
@ -503,25 +601,44 @@ func CopyFileBetweenRemotes(srcClient, dstClient *SSHClient, srcPath, dstPath st
} }
// CopyDirBetweenRemotes recursively copies a directory from one remote host to another. // CopyDirBetweenRemotes recursively copies a directory from one remote host to another.
// Directories are created on the destination first, then all files are streamed through
// the local machine.
func CopyDirBetweenRemotes(srcClient, dstClient *SSHClient, srcDir, dstDir string) error { func CopyDirBetweenRemotes(srcClient, dstClient *SSHClient, srcDir, dstDir string) error {
return copyDirBetweenRemotes(srcClient, dstClient, srcDir, dstDir, nil, nil)
}
func copyDirBetweenRemotes(srcClient, dstClient *SSHClient, srcDir, dstDir string, onFile func(path string, done, total int), ctx context.Context) error {
done := 0
return srcClient.Walk(srcDir, func(remotePath string, info os.FileInfo, err error) error { return srcClient.Walk(srcDir, func(remotePath string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return err return err
} }
if ctx != nil {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
}
relPath, _ := filepath.Rel(srcDir, remotePath) relPath, _ := filepath.Rel(srcDir, remotePath)
dstPath := path.Join(dstDir, relPath) dstPath := path.Join(dstDir, relPath)
if info.IsDir() { if info.IsDir() {
return dstClient.MkdirAll(dstPath) return dstClient.MkdirAll(dstPath)
} }
if err := CopyFileBetweenRemotes(srcClient, dstClient, remotePath, dstPath); err != nil {
return CopyFileBetweenRemotes(srcClient, dstClient, remotePath, dstPath) return err
}
done++
if onFile != nil {
onFile(remotePath, done, 0)
}
return nil
}) })
} }
// CopyDirBetweenRemotesProgress is like CopyDirBetweenRemotes with progress and context support.
func CopyDirBetweenRemotesProgress(srcClient, dstClient *SSHClient, srcDir, dstDir string, onFile func(path string, done, total int), ctx context.Context) error {
return copyDirBetweenRemotes(srcClient, dstClient, srcDir, dstDir, onFile, ctx)
}
// Walk walks the remote filesystem tree rooted at root, calling walkFn for each file/dir. // Walk walks the remote filesystem tree rooted at root, calling walkFn for each file/dir.
// This is a simplified version of filepath.Walk for SFTP. // This is a simplified version of filepath.Walk for SFTP.
type walkFunc func(path string, info os.FileInfo, err error) error type walkFunc func(path string, info os.FileInfo, err error) error

View file

@ -51,6 +51,12 @@ func (h SSHHost) Addr() string {
return h.HostName + ":" + h.Port return h.HostName + ":" + h.Port
} }
// SameAs returns true if two hosts point to the same server.
func (h SSHHost) SameAs(other SSHHost) bool {
return h.HostName == other.HostName &&
(h.Port == other.Port || (h.Port == "" && other.Port == "22") || (h.Port == "22" && other.Port == ""))
}
// HostStore manages SSH hosts from both ~/.ssh/config and user-added hosts. // HostStore manages SSH hosts from both ~/.ssh/config and user-added hosts.
type HostStore struct { type HostStore struct {
customHosts []SSHHost customHosts []SSHHost

View file

@ -1717,6 +1717,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.copyJob.background = true m.copyJob.background = true
m.modal = modalState{} m.modal = modalState{}
m.status = "Transfer continues in background" m.status = "Transfer continues in background"
log.Printf("[MODAL] CopyProgress — background requested (job=%d)", m.copyJob.id)
return m, nil return m, nil
} }
if key.Matches(msg, m.keys.ProgressCancel) { if key.Matches(msg, m.keys.ProgressCancel) {
@ -1726,6 +1727,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
if m.copyJob.cancel != nil { if m.copyJob.cancel != nil {
m.copyJob.cancel() m.copyJob.cancel()
log.Printf("[MODAL] CopyProgress — cancel requested (job=%d)", m.copyJob.id)
} }
m.status = strings.Title(operationVerb(m.copyJob.kind)) + " cancelling..." m.status = strings.Title(operationVerb(m.copyJob.kind)) + " cancelling..."
return m, nil return m, nil
@ -4086,7 +4088,7 @@ func renderModalNoteLine(raw string, width int, palette theme.Palette, fallback
keyStyle := lipgloss.NewStyle(). keyStyle := lipgloss.NewStyle().
Width(keyWidth). Width(keyWidth).
Background(palette.Panel). Background(palette.Panel).
Foreground(palette.FooterKey). Foreground(palette.Accent).
Bold(true) Bold(true)
actionStyle := lipgloss.NewStyle(). actionStyle := lipgloss.NewStyle().
Width(actionWidth). Width(actionWidth).
@ -4105,9 +4107,9 @@ func renderModalHintTokens(raw string, width int, palette theme.Palette, baseCol
color lipgloss.Color color lipgloss.Color
} }
tokens := []tokenStyle{ tokens := []tokenStyle{
{token: "Background", color: palette.Info}, {token: "Background", color: palette.Accent},
{token: "Cancel", color: palette.CancelButton}, {token: "Cancel", color: palette.CancelButton},
{token: "Enter", color: palette.ConfirmButton}, {token: "Enter", color: palette.Accent},
{token: "Esc", color: palette.CancelButton}, {token: "Esc", color: palette.CancelButton},
} }
contains := false contains := false
@ -4350,7 +4352,12 @@ func renderCopyProgressModal(job copyJobState, palette theme.Palette, width int)
} }
lines = append(lines, barAndPct...) lines = append(lines, barAndPct...)
} }
lines = append(lines, spacer, renderModalNoteLine("Cancel / c", contentWidth, palette, mutedStyle)) bgStyle := lipgloss.NewStyle().Foreground(palette.Accent).Background(palette.Panel)
cancelStyle := lipgloss.NewStyle().Foreground(palette.CancelButton).Background(palette.Panel)
bgAndCancel := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(
bgStyle.Render("Background / b") + " " + cancelStyle.Render("Cancel / c"),
)
lines = append(lines, spacer, bgAndCancel)
if job.background { if job.background {
lines = append(lines, mutedStyle.Render("Transfer continues in background")) lines = append(lines, mutedStyle.Render("Transfer continues in background"))
} }
@ -4431,7 +4438,10 @@ func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, widt
lines = append(lines, lines = append(lines,
spacer, spacer,
renderModalNoteLine("Background / b, Cancel / c", contentWidth, palette, mutedStyle), lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(
lipgloss.NewStyle().Foreground(palette.Info).Background(palette.Panel).Render("Background / b")+" "+
lipgloss.NewStyle().Foreground(palette.CancelButton).Background(palette.Panel).Render("Cancel / c"),
),
) )
if job.background { if job.background {
switch job.kind { switch job.kind {
@ -6284,7 +6294,7 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir
m.nextCopyJob++ m.nextCopyJob++
jobID := m.nextCopyJob jobID := m.nextCopyJob
_, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
m.copyJob = &copyJobState{ m.copyJob = &copyJobState{
id: jobID, id: jobID,
@ -6313,24 +6323,108 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir
go func() { go func() {
doneFiles := 0 doneFiles := 0
var doneBytes int64 var doneBytes int64
totalFiles := 0
for _, sourcePath := range sources { log.Printf("[JOB] remoteCopy — goroutine started: job=%d kind=%d sources=%d srcRemote=%v dstRemote=%v",
jobID, kind, len(sources), sourceIsRemote, targetIsRemote)
// Phase 1: count files across all sources
sourceCounts := make([]int, len(sources))
for i, sourcePath := range sources {
if err := ctx.Err(); err != nil {
m.copyProgress <- copyDoneMsg{jobID: jobID, kind: kind, sourcePaths: sources, targetDir: targetDir, err: err}
return
}
var count int
if sourceIsRemote {
count = 1
info, err := srcClient.Lstat(sourcePath)
if err != nil {
log.Printf("[JOB] remoteCopy — Phase1 Lstat failed: job=%d path=%s err=%v", jobID, sourcePath, err)
m.copyProgress <- copyDoneMsg{jobID: jobID, kind: kind, sourcePaths: sources, targetDir: targetDir, err: err}
return
}
if info.IsDir() {
err := srcClient.Walk(sourcePath, func(_ string, info os.FileInfo, err error) error {
if err != nil { return err }
if !info.IsDir() { count++ }
return nil
})
if err != nil {
m.copyProgress <- copyDoneMsg{jobID: jobID, kind: kind, sourcePaths: sources, targetDir: targetDir, err: err}
return
}
}
} else {
entryStats, err := vfs.CopyStats(sourcePath)
if err != nil {
m.copyProgress <- copyDoneMsg{jobID: jobID, kind: kind, sourcePaths: sources, targetDir: targetDir, err: err}
return
}
count = entryStats.FilesTotal
}
sourceCounts[i] = count
totalFiles += count
m.copyProgress <- copyProgressMsg{
jobID: jobID,
progress: vfs.CopyProgress{
FilesDone: 0,
FilesTotal: totalFiles,
CurrentPath: sourcePath,
Stage: fmt.Sprintf("Counting files... %d found", totalFiles),
},
}
}
// Phase 2: transfer with known total
log.Printf("[JOB] remoteCopy — Phase2 start: job=%d totalFiles=%d", jobID, totalFiles)
for i, sourcePath := range sources {
if err := ctx.Err(); err != nil {
m.copyProgress <- copyDoneMsg{jobID: jobID, kind: kind, sourcePaths: sources, targetDir: targetDir, err: err}
return
}
baseName := filepath.Base(sourcePath) baseName := filepath.Base(sourcePath)
targetPath := filepath.Join(targetDir, baseName) targetPath := filepath.Join(targetDir, baseName)
log.Printf("[JOB] remoteCopy — Phase2 source[%d]: job=%d path=%s target=%s doneFiles=%d totalFiles=%d",
i, jobID, sourcePath, targetPath, doneFiles, totalFiles)
log.Printf("[JOB] remoteCopy — starting: job=%d source=%s target=%s", jobID, sourcePath, targetPath) log.Printf("[JOB] remoteCopy — starting: job=%d source=%s target=%s", jobID, sourcePath, targetPath)
m.copyProgress <- copyProgressMsg{
jobID: jobID,
progress: vfs.CopyProgress{
FilesDone: doneFiles,
FilesTotal: totalFiles,
CurrentPath: sourcePath,
Stage: "Transferring files...",
},
}
var info os.FileInfo var info os.FileInfo
var err error var err error
serverSide := false // true if copy/move happened on server without streaming
trackedProgress := false // true if per-file progress callback was used
// Get file info and perform copy depending on direction. // Get file info and perform copy depending on direction.
switch { switch {
case !sourceIsRemote && targetIsRemote: case !sourceIsRemote && targetIsRemote:
// Local → Remote // Local → Remote
log.Printf("[JOB] remoteCopy — direction: Local→Remote job=%d", jobID)
info, err = os.Stat(sourcePath) info, err = os.Stat(sourcePath)
if err == nil { if err == nil {
if info.IsDir() { if info.IsDir() {
log.Printf("[JOB] remoteCopy — local→remote dir: job=%d source=%s target=%s", jobID, sourcePath, targetPath) log.Printf("[JOB] remoteCopy — local→remote dir: job=%d source=%s target=%s", jobID, sourcePath, targetPath)
err = dstClient.CopyDirToRemote(sourcePath, targetPath) err = dstClient.CopyDirToRemoteProgress(sourcePath, targetPath, func(path string, done, total int) {
trackedProgress = true
m.copyProgress <- copyProgressMsg{
jobID: jobID,
progress: vfs.CopyProgress{
FilesDone: doneFiles + done,
FilesTotal: totalFiles,
CurrentPath: path,
Stage: "Copying files...",
},
}
}, ctx)
} else { } else {
log.Printf("[JOB] remoteCopy — local→remote file: job=%d source=%s target=%s size=%d", jobID, sourcePath, targetPath, info.Size()) log.Printf("[JOB] remoteCopy — local→remote file: job=%d source=%s target=%s size=%d", jobID, sourcePath, targetPath, info.Size())
err = dstClient.CopyFileToRemote(sourcePath, targetPath) err = dstClient.CopyFileToRemote(sourcePath, targetPath)
@ -6339,11 +6433,23 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir
case sourceIsRemote && !targetIsRemote: case sourceIsRemote && !targetIsRemote:
// Remote → Local // Remote → Local
log.Printf("[JOB] remoteCopy — direction: Remote→Local job=%d", jobID)
info, err = srcClient.Lstat(sourcePath) info, err = srcClient.Lstat(sourcePath)
if err == nil { if err == nil {
if info.IsDir() { if info.IsDir() {
log.Printf("[JOB] remoteCopy — remote→local dir: job=%d source=%s target=%s", jobID, sourcePath, targetPath) log.Printf("[JOB] remoteCopy — remote→local dir: job=%d source=%s target=%s", jobID, sourcePath, targetPath)
err = srcClient.CopyDirFromRemote(sourcePath, targetPath) err = srcClient.CopyDirFromRemoteProgress(sourcePath, targetPath, func(path string, done, total int) {
trackedProgress = true
m.copyProgress <- copyProgressMsg{
jobID: jobID,
progress: vfs.CopyProgress{
FilesDone: doneFiles + done,
FilesTotal: totalFiles,
CurrentPath: path,
Stage: "Copying files...",
},
}
}, ctx)
} else { } else {
log.Printf("[JOB] remoteCopy — remote→local file: job=%d source=%s target=%s size=%d", jobID, sourcePath, targetPath, info.Size()) log.Printf("[JOB] remoteCopy — remote→local file: job=%d source=%s target=%s size=%d", jobID, sourcePath, targetPath, info.Size())
err = srcClient.CopyFileFromRemote(sourcePath, targetPath) err = srcClient.CopyFileFromRemote(sourcePath, targetPath)
@ -6351,14 +6457,80 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir
} }
case sourceIsRemote && targetIsRemote: case sourceIsRemote && targetIsRemote:
// Remote → Remote (stream between two SFTP connections) // Remote → Remote
log.Printf("[JOB] remoteCopy — direction: Remote→Remote job=%d sameHost=%v", jobID, srcClient.SameHostAs(dstClient))
info, err = srcClient.Lstat(sourcePath) info, err = srcClient.Lstat(sourcePath)
if err == nil { if err != nil {
break
}
if srcClient.SameHostAs(dstClient) {
// Same host: use server-side commands, no local streaming
srcEscaped := "'" + sourcePath + "'"
dstEscaped := "'" + targetPath + "'"
if kind == opMove {
log.Printf("[JOB] remoteCopy — same-host move: job=%d src=%s dst=%s", jobID, sourcePath, targetPath)
_, err = srcClient.Exec("mv " + srcEscaped + " " + dstEscaped)
if err == nil {
serverSide = true
} else {
log.Printf("[JOB] remoteCopy — same-host mv failed, falling back: %v", err)
}
}
if !serverSide {
log.Printf("[JOB] remoteCopy — same-host copy: job=%d src=%s dst=%s", jobID, sourcePath, targetPath)
m.copyProgress <- copyProgressMsg{
jobID: jobID,
progress: vfs.CopyProgress{
FilesDone: doneFiles,
FilesTotal: totalFiles,
CurrentPath: sourcePath,
Stage: "Running remote command...",
},
}
cmd := "cp -r"
if !info.IsDir() {
cmd = "cp"
}
err = srcClient.ExecWithProgress(cmd+" "+srcEscaped+" "+dstEscaped, func(line string) {
// cp -rv outputs one line per copied file
doneFiles++
log.Printf("[JOB] remoteCopy — same-host progress: job=%d doneFiles=%d line=%s", jobID, doneFiles, line)
m.copyProgress <- copyProgressMsg{
jobID: jobID,
progress: vfs.CopyProgress{
FilesDone: doneFiles,
FilesTotal: totalFiles,
CurrentPath: line,
Stage: "Copying files...",
},
}
})
if err == nil {
serverSide = true
} else {
log.Printf("[JOB] remoteCopy — same-host cp failed, falling back: %v", err)
}
}
}
if !serverSide {
// Different hosts or same-host exec failed: stream through local
log.Printf("[JOB] remoteCopy — streaming through local: job=%d src=%s dst=%s", jobID, sourcePath, targetPath)
if info.IsDir() { if info.IsDir() {
log.Printf("[JOB] remoteCopy — remote→remote dir: job=%d source=%s target=%s", jobID, sourcePath, targetPath) err = remote.CopyDirBetweenRemotesProgress(srcClient, dstClient, sourcePath, targetPath, func(path string, done, total int) {
err = remote.CopyDirBetweenRemotes(srcClient, dstClient, sourcePath, targetPath) trackedProgress = true
m.copyProgress <- copyProgressMsg{
jobID: jobID,
progress: vfs.CopyProgress{
FilesDone: doneFiles + done,
FilesTotal: totalFiles,
CurrentPath: path,
Stage: "Copying files...",
},
}
}, ctx)
} else { } else {
log.Printf("[JOB] remoteCopy — remote→remote file: job=%d source=%s target=%s size=%d", jobID, sourcePath, targetPath, info.Size())
err = remote.CopyFileBetweenRemotes(srcClient, dstClient, sourcePath, targetPath) err = remote.CopyFileBetweenRemotes(srcClient, dstClient, sourcePath, targetPath)
} }
} }
@ -6375,7 +6547,8 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir
} }
// For move operations, delete the source after copying // For move operations, delete the source after copying
if kind == opMove { // Skip if server-side move was used (source already moved)
if kind == opMove && !serverSide {
log.Printf("[JOB] remoteCopy — move, removing source: job=%d source=%s", jobID, sourcePath) log.Printf("[JOB] remoteCopy — move, removing source: job=%d source=%s", jobID, sourcePath)
if !sourceIsRemote { if !sourceIsRemote {
if err := os.RemoveAll(sourcePath); err != nil { if err := os.RemoveAll(sourcePath); err != nil {
@ -6400,7 +6573,13 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir
} }
} }
doneFiles++ log.Printf("[JOB] remoteCopy — source done: job=%d path=%s err=%v doneFiles=%d totalFiles=%d serverSide=%v",
jobID, sourcePath, err, doneFiles, totalFiles, serverSide)
if !serverSide || kind == opMove {
if !trackedProgress {
doneFiles += sourceCounts[i]
}
}
if info != nil && !info.IsDir() { if info != nil && !info.IsDir() {
doneBytes += info.Size() doneBytes += info.Size()
} }
@ -6410,11 +6589,11 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir
jobID: jobID, jobID: jobID,
progress: vfs.CopyProgress{ progress: vfs.CopyProgress{
FilesDone: doneFiles, FilesDone: doneFiles,
FilesTotal: stats.FilesTotal, FilesTotal: totalFiles,
BytesDone: doneBytes, BytesDone: doneBytes,
BytesTotal: stats.BytesTotal, BytesTotal: 0,
CurrentPath: sourcePath, CurrentPath: sourcePath,
Stage: "Transferring data", Stage: "Transferring files...",
}, },
} }
} }