From fd2aa8089439d91b7481d68399be764f8b11e6d8 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 13 May 2026 01:11:55 +0300 Subject: [PATCH] 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) --- internal/fs/remote/client.go | 145 ++++++++++++++++++++--- internal/fs/remote/host.go | 6 + internal/ui/model.go | 217 ++++++++++++++++++++++++++++++++--- 3 files changed, 335 insertions(+), 33 deletions(-) diff --git a/internal/fs/remote/client.go b/internal/fs/remote/client.go index d509221..147dda0 100644 --- a/internal/fs/remote/client.go +++ b/internal/fs/remote/client.go @@ -1,6 +1,8 @@ package remote import ( + "bufio" + "context" "fmt" "io" "net" @@ -310,6 +312,60 @@ func (c *SSHClient) IsConnected() bool { 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. // For directories, it walks and removes all children first. 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. 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 { if err != nil { return err } - + if ctx != nil { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + } relPath, _ := filepath.Rel(localDir, localPath) remotePath := path.Join(remoteDir, relPath) - if info.IsDir() { return c.MkdirAll(remotePath) } - - return c.CopyFileToRemote(localPath, remotePath) + if err := c.CopyFileToRemote(localPath, remotePath); err != nil { + return err + } + done++ + if onFile != nil { + onFile(remotePath, done, 0) + } + return nil }) } // CopyDirFromRemote recursively copies a remote directory to a local path. 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 { + if ctx != nil { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + } if err != nil { return err } - relPath, _ := filepath.Rel(remoteDir, remotePath) localPath := filepath.Join(localDir, relPath) - if info.IsDir() { return os.MkdirAll(localPath, 0o755) } - - return c.CopyFileFromRemote(remotePath, localPath) + if err := c.CopyFileFromRemote(remotePath, localPath); err != nil { + 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. -// 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 { + 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 { if err != nil { return err } - + if ctx != nil { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + } relPath, _ := filepath.Rel(srcDir, remotePath) dstPath := path.Join(dstDir, relPath) - if info.IsDir() { return dstClient.MkdirAll(dstPath) } - - return CopyFileBetweenRemotes(srcClient, dstClient, remotePath, dstPath) + if err := CopyFileBetweenRemotes(srcClient, dstClient, remotePath, dstPath); err != nil { + 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. // This is a simplified version of filepath.Walk for SFTP. type walkFunc func(path string, info os.FileInfo, err error) error diff --git a/internal/fs/remote/host.go b/internal/fs/remote/host.go index 5604858..1b70d09 100644 --- a/internal/fs/remote/host.go +++ b/internal/fs/remote/host.go @@ -51,6 +51,12 @@ func (h SSHHost) Addr() string { 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. type HostStore struct { customHosts []SSHHost diff --git a/internal/ui/model.go b/internal/ui/model.go index 2e9a239..c96a8f9 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -1717,6 +1717,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.copyJob.background = true m.modal = modalState{} m.status = "Transfer continues in background" + log.Printf("[MODAL] CopyProgress — background requested (job=%d)", m.copyJob.id) return m, nil } 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 { m.copyJob.cancel() + log.Printf("[MODAL] CopyProgress — cancel requested (job=%d)", m.copyJob.id) } m.status = strings.Title(operationVerb(m.copyJob.kind)) + " cancelling..." return m, nil @@ -4086,7 +4088,7 @@ func renderModalNoteLine(raw string, width int, palette theme.Palette, fallback keyStyle := lipgloss.NewStyle(). Width(keyWidth). Background(palette.Panel). - Foreground(palette.FooterKey). + Foreground(palette.Accent). Bold(true) actionStyle := lipgloss.NewStyle(). Width(actionWidth). @@ -4105,9 +4107,9 @@ func renderModalHintTokens(raw string, width int, palette theme.Palette, baseCol color lipgloss.Color } tokens := []tokenStyle{ - {token: "Background", color: palette.Info}, + {token: "Background", color: palette.Accent}, {token: "Cancel", color: palette.CancelButton}, - {token: "Enter", color: palette.ConfirmButton}, + {token: "Enter", color: palette.Accent}, {token: "Esc", color: palette.CancelButton}, } contains := false @@ -4350,7 +4352,12 @@ func renderCopyProgressModal(job copyJobState, palette theme.Palette, width int) } 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 { lines = append(lines, mutedStyle.Render("Transfer continues in background")) } @@ -4431,7 +4438,10 @@ func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, widt lines = append(lines, 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 { switch job.kind { @@ -6284,7 +6294,7 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir m.nextCopyJob++ jobID := m.nextCopyJob - _, cancel := context.WithCancel(context.Background()) + ctx, cancel := context.WithCancel(context.Background()) m.copyJob = ©JobState{ id: jobID, @@ -6313,24 +6323,108 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir go func() { doneFiles := 0 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) 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) + m.copyProgress <- copyProgressMsg{ + jobID: jobID, + progress: vfs.CopyProgress{ + FilesDone: doneFiles, + FilesTotal: totalFiles, + CurrentPath: sourcePath, + Stage: "Transferring files...", + }, + } + var info os.FileInfo 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. switch { case !sourceIsRemote && targetIsRemote: // Local → Remote + log.Printf("[JOB] remoteCopy — direction: Local→Remote job=%d", jobID) info, err = os.Stat(sourcePath) if err == nil { if info.IsDir() { 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 { 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) @@ -6339,11 +6433,23 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir case sourceIsRemote && !targetIsRemote: // Remote → Local + log.Printf("[JOB] remoteCopy — direction: Remote→Local job=%d", jobID) info, err = srcClient.Lstat(sourcePath) if err == nil { if info.IsDir() { 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 { 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) @@ -6351,14 +6457,80 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir } 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) - 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() { - log.Printf("[JOB] remoteCopy — remote→remote dir: job=%d source=%s target=%s", jobID, sourcePath, targetPath) - err = remote.CopyDirBetweenRemotes(srcClient, dstClient, sourcePath, targetPath) + err = remote.CopyDirBetweenRemotesProgress(srcClient, dstClient, 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 { - 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) } } @@ -6375,7 +6547,8 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir } // 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) if !sourceIsRemote { 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() { doneBytes += info.Size() } @@ -6410,11 +6589,11 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir jobID: jobID, progress: vfs.CopyProgress{ FilesDone: doneFiles, - FilesTotal: stats.FilesTotal, + FilesTotal: totalFiles, BytesDone: doneBytes, - BytesTotal: stats.BytesTotal, + BytesTotal: 0, CurrentPath: sourcePath, - Stage: "Transferring data", + Stage: "Transferring files...", }, } }