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
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue