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:
parent
8352441bda
commit
fd2aa80894
3 changed files with 335 additions and 33 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = ©JobState{
|
m.copyJob = ©JobState{
|
||||||
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...",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue