fix: harden copy/move/delete transfers, add CI, dead code cleanup
All checks were successful
CI / test (push) Successful in 10m0s
All checks were successful
CI / test (push) Successful in 10m0s
Audited every copy/move/delete path (local, remote same-host, cross-host)
and fixed:
- Shell command injection in same-host remote cp/mv (unescaped paths
interpolated into exec strings); added remote.ShellQuote.
- Missing guard against copying/moving a directory into its own
subdirectory, locally and on same-host remote transfers (unbounded
recursion / disk fill). New regression tests in ops_test.go.
- Remote-to-remote same-path file copy silently truncating the source
file via Create() before it finished being read.
- Overwrite warning ("N existing target(s)") never shown for remote
destinations, only local ones.
- Remote same-host server-side cp never advancing the file-done counter
(waited on -v output that was never requested).
- Removed a dead "plan-then-confirm" subsystem (5 functions, 2 message
types, 2 unreachable Update() cases) superseded by the current design.
- Remote delete (SFTP) now runs as a cancellable job with the same
progress modal as local delete, instead of a single blocking call.
Also add a CI workflow that runs build/vet/test on every push and PR,
since previously they only ran on release tags.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
c15ade247a
commit
135f5e0d19
5 changed files with 268 additions and 321 deletions
32
.github/workflows/ci.yml
vendored
Normal file
32
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Vendor Go modules
|
||||||
|
run: go mod vendor
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: GOFLAGS=-mod=vendor go build ./...
|
||||||
|
|
||||||
|
- name: Vet
|
||||||
|
run: GOFLAGS=-mod=vendor go vet ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: GOFLAGS=-mod=vendor go test ./...
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -67,6 +68,13 @@ func CopyPathWithProgressContext(ctx context.Context, srcPath string, dstDir str
|
||||||
} else if same {
|
} else if same {
|
||||||
return "", fmt.Errorf("source and target are the same: %s", targetPath)
|
return "", fmt.Errorf("source and target are the same: %s", targetPath)
|
||||||
}
|
}
|
||||||
|
if srcInfo.IsDir() {
|
||||||
|
if within, err := pathWithin(targetPath, srcPath); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if within {
|
||||||
|
return "", fmt.Errorf("cannot copy %q into itself", srcPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if exists, err := PathExists(targetPath); err != nil {
|
if exists, err := PathExists(targetPath); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -189,6 +197,13 @@ func MovePathWithProgressContext(ctx context.Context, srcPath string, dstDir str
|
||||||
} else if same {
|
} else if same {
|
||||||
return "", fmt.Errorf("source and target are the same: %s", targetPath)
|
return "", fmt.Errorf("source and target are the same: %s", targetPath)
|
||||||
}
|
}
|
||||||
|
if srcInfo, statErr := os.Lstat(srcPath); statErr == nil && srcInfo.IsDir() {
|
||||||
|
if within, err := pathWithin(targetPath, srcPath); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if within {
|
||||||
|
return "", fmt.Errorf("cannot move %q into itself", srcPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if exists, err := PathExists(targetPath); err != nil {
|
if exists, err := PathExists(targetPath); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
@ -527,3 +542,22 @@ func samePath(left string, right string) (bool, error) {
|
||||||
}
|
}
|
||||||
return leftAbs == rightAbs, nil
|
return leftAbs == rightAbs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pathWithin reports whether child is parent itself or a path nested inside
|
||||||
|
// it. Used to reject copying/moving a directory into one of its own
|
||||||
|
// descendants, which would otherwise make copyDir recurse into the very
|
||||||
|
// destination it's writing, growing without bound until the disk fills.
|
||||||
|
func pathWithin(child, parent string) (bool, error) {
|
||||||
|
childAbs, err := filepath.Abs(child)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
parentAbs, err := filepath.Abs(parent)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if childAbs == parentAbs {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(childAbs, parentAbs+string(filepath.Separator)), nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,56 @@ func TestMovePathWithProgressContextCancelledBeforeStartKeepsSource(t *testing.T
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCopyPathWithProgressContextRejectsCopyIntoOwnSubdirectory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
srcDir := filepath.Join(root, "src")
|
||||||
|
if err := os.MkdirAll(srcDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir src: %v", err)
|
||||||
|
}
|
||||||
|
nestedDir := filepath.Join(srcDir, "nested")
|
||||||
|
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir nested: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := CopyPathWithProgressContext(context.Background(), srcDir, nestedDir, false, TransferStats{}, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error copying directory into its own subdirectory, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, readErr := os.ReadDir(nestedDir)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("read nested dir: %v", readErr)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Fatalf("expected nested dir to remain empty, found %d entries", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMovePathWithProgressContextRejectsMoveIntoOwnSubdirectory(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
srcDir := filepath.Join(root, "src")
|
||||||
|
if err := os.MkdirAll(srcDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir src: %v", err)
|
||||||
|
}
|
||||||
|
nestedDir := filepath.Join(srcDir, "nested")
|
||||||
|
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir nested: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := MovePathWithProgressContext(context.Background(), srcDir, nestedDir, false, TransferStats{}, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error moving directory into its own subdirectory, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, statErr := os.Stat(srcDir); statErr != nil {
|
||||||
|
t.Fatalf("expected source to remain in place, stat err=%v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRenamePath(t *testing.T) {
|
func TestRenamePath(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -406,6 +406,15 @@ func (c *SSHClient) IsConnected() bool {
|
||||||
return c.sftpCli != nil && c.sshConn != nil
|
return c.sftpCli != nil && c.sshConn != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShellQuote wraps s in single quotes so it's safe to interpolate into a
|
||||||
|
// shell command string passed to Exec/ExecWithProgress, regardless of
|
||||||
|
// spaces, `$`, backticks, or other shell metacharacters it contains. Any
|
||||||
|
// single quote in s is escaped by closing the quote, emitting an escaped
|
||||||
|
// literal quote, and reopening it (the standard POSIX shell idiom).
|
||||||
|
func ShellQuote(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
// Exec runs a shell command on the remote server and returns combined output.
|
// Exec runs a shell command on the remote server and returns combined output.
|
||||||
func (c *SSHClient) Exec(cmd string) ([]byte, error) {
|
func (c *SSHClient) Exec(cmd string) ([]byte, error) {
|
||||||
if c.sshConn == nil {
|
if c.sshConn == nil {
|
||||||
|
|
@ -678,6 +687,12 @@ func CopyFileBetweenRemotes(srcClient, dstClient *SSHClient, srcPath, dstPath st
|
||||||
if dstClient.sftpCli == nil {
|
if dstClient.sftpCli == nil {
|
||||||
return fmt.Errorf("destination client not connected")
|
return fmt.Errorf("destination client not connected")
|
||||||
}
|
}
|
||||||
|
if srcClient.SameHostAs(dstClient) && path.Clean(srcPath) == path.Clean(dstPath) {
|
||||||
|
// Same file on the same host: dstClient.Create() below would
|
||||||
|
// truncate it before srcFile has finished being read, corrupting
|
||||||
|
// the "copy" into an empty file.
|
||||||
|
return fmt.Errorf("source and target are the same: %s", dstPath)
|
||||||
|
}
|
||||||
|
|
||||||
srcFile, err := srcClient.sftpCli.Open(srcPath)
|
srcFile, err := srcClient.sftpCli.Open(srcPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -711,6 +726,13 @@ func CopyDirBetweenRemotes(srcClient, dstClient *SSHClient, srcDir, dstDir strin
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyDirBetweenRemotes(srcClient, dstClient *SSHClient, srcDir, dstDir string, onFile func(path string, done, total int), ctx context.Context) error {
|
func copyDirBetweenRemotes(srcClient, dstClient *SSHClient, srcDir, dstDir string, onFile func(path string, done, total int), ctx context.Context) error {
|
||||||
|
if srcClient.SameHostAs(dstClient) && remotePathWithin(dstDir, srcDir) {
|
||||||
|
// Same host, and the destination is srcDir itself or nested inside
|
||||||
|
// it: MkdirAll below would create the destination inside the very
|
||||||
|
// tree Walk is reading, so the walk would recurse into its own
|
||||||
|
// output and never terminate on its own.
|
||||||
|
return fmt.Errorf("cannot copy %q into itself", srcDir)
|
||||||
|
}
|
||||||
done := 0
|
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 {
|
||||||
|
|
@ -815,6 +837,18 @@ func (c *SSHClient) walk(dirPath string, walkFn walkFunc, info os.FileInfo) erro
|
||||||
// filepathSkipDir is used as a return value from Walk to skip a directory.
|
// filepathSkipDir is used as a return value from Walk to skip a directory.
|
||||||
var filepathSkipDir = fmt.Errorf("skip this directory")
|
var filepathSkipDir = fmt.Errorf("skip this directory")
|
||||||
|
|
||||||
|
// remotePathWithin reports whether child is parent itself or a path nested
|
||||||
|
// inside it, using POSIX path semantics (remote paths are always "/"
|
||||||
|
// separated regardless of the local OS).
|
||||||
|
func remotePathWithin(child, parent string) bool {
|
||||||
|
childClean := path.Clean(child)
|
||||||
|
parentClean := path.Clean(parent)
|
||||||
|
if childClean == parentClean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(childClean, parentClean+"/")
|
||||||
|
}
|
||||||
|
|
||||||
// isSymlinkToDir reports whether the walk entry at remotePath is a symlink
|
// isSymlinkToDir reports whether the walk entry at remotePath is a symlink
|
||||||
// whose target is a directory. Such entries can't be copied with a plain
|
// whose target is a directory. Such entries can't be copied with a plain
|
||||||
// Open()/Create() file copy (the server rejects opening a directory for
|
// Open()/Create() file copy (the server rejects opening a directory for
|
||||||
|
|
|
||||||
|
|
@ -110,35 +110,11 @@ type opMsg struct {
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
type copyPlanMsg struct {
|
|
||||||
kind fileOpKind
|
|
||||||
sourcePaths []string
|
|
||||||
targetDir string
|
|
||||||
overwrite bool
|
|
||||||
existingTargets int
|
|
||||||
stats vfs.TransferStats
|
|
||||||
err error
|
|
||||||
|
|
||||||
// Remote operation fields
|
|
||||||
srcClient *remote.SSHClient
|
|
||||||
dstClient *remote.SSHClient
|
|
||||||
}
|
|
||||||
|
|
||||||
type copyProgressMsg struct {
|
type copyProgressMsg struct {
|
||||||
jobID int
|
jobID int
|
||||||
progress vfs.CopyProgress
|
progress vfs.CopyProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
type deletePlanMsg struct {
|
|
||||||
kind fileOpKind
|
|
||||||
sourcePaths []string
|
|
||||||
stats vfs.TransferStats
|
|
||||||
err error
|
|
||||||
|
|
||||||
// Remote operation — non-nil when source is remote
|
|
||||||
srcClient *remote.SSHClient
|
|
||||||
}
|
|
||||||
|
|
||||||
type archivePlanMsg struct {
|
type archivePlanMsg struct {
|
||||||
sourcePaths []string
|
sourcePaths []string
|
||||||
targetDir string
|
targetDir string
|
||||||
|
|
@ -570,86 +546,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
|
||||||
return m, m.loadPreviewCmd()
|
return m, m.loadPreviewCmd()
|
||||||
|
|
||||||
case copyPlanMsg:
|
|
||||||
m.busy = false
|
|
||||||
if msg.err != nil {
|
|
||||||
log.Printf("[ERROR] copyPlanMsg: err=%v", msg.err)
|
|
||||||
m.status = msg.err.Error()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteInfo := ""
|
|
||||||
if msg.srcClient != nil {
|
|
||||||
remoteInfo = " source=remote"
|
|
||||||
}
|
|
||||||
if msg.dstClient != nil {
|
|
||||||
remoteInfo += " target=remote"
|
|
||||||
}
|
|
||||||
log.Printf("[PLAN] copyPlanMsg: kind=%d sources=%d files=%d size=%d targetDir=%s%s",
|
|
||||||
msg.kind, len(msg.sourcePaths), msg.stats.FilesTotal, msg.stats.BytesTotal, msg.targetDir, remoteInfo)
|
|
||||||
|
|
||||||
verb := operationVerb(msg.kind)
|
|
||||||
title := fmt.Sprintf("%s selected entry?", strings.Title(verb))
|
|
||||||
if msg.srcClient != nil || msg.dstClient != nil {
|
|
||||||
title = fmt.Sprintf("%s selected entry via SFTP?", strings.Title(verb))
|
|
||||||
}
|
|
||||||
body := strings.Join([]string{
|
|
||||||
fmt.Sprintf("Items: %d", len(msg.sourcePaths)),
|
|
||||||
fmt.Sprintf("Files: %d", msg.stats.FilesTotal),
|
|
||||||
fmt.Sprintf("Size: %s", formatSize(msg.stats.BytesTotal, true)),
|
|
||||||
}, "\n")
|
|
||||||
note := "confirm-actions"
|
|
||||||
m.openConfirmModal(title, body, note, pendingOperation{
|
|
||||||
kind: msg.kind,
|
|
||||||
sourcePaths: append([]string(nil), msg.sourcePaths...),
|
|
||||||
targetDir: msg.targetDir,
|
|
||||||
overwrite: msg.overwrite,
|
|
||||||
existingTargets: msg.existingTargets,
|
|
||||||
stats: msg.stats,
|
|
||||||
srcClient: msg.srcClient,
|
|
||||||
dstClient: msg.dstClient,
|
|
||||||
})
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case deletePlanMsg:
|
|
||||||
m.busy = false
|
|
||||||
if msg.err != nil {
|
|
||||||
log.Printf("[ERROR] deletePlanMsg: err=%v", msg.err)
|
|
||||||
m.status = msg.err.Error()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[PLAN] deletePlanMsg: kind=%d sources=%d files=%d size=%d remote=%v",
|
|
||||||
msg.kind, len(msg.sourcePaths), msg.stats.FilesTotal, msg.stats.BytesTotal, msg.srcClient != nil)
|
|
||||||
|
|
||||||
title := "Move selected entr" + pluralSuffix(len(msg.sourcePaths), "y", "ies") + " to trash?"
|
|
||||||
if msg.srcClient != nil {
|
|
||||||
title = "Delete selected entr" + pluralSuffix(len(msg.sourcePaths), "y", "ies") + " from remote?"
|
|
||||||
} else if m.deleteKind == "permanent" {
|
|
||||||
title = "Permanently delete selected entr" + pluralSuffix(len(msg.sourcePaths), "y", "ies") + "?"
|
|
||||||
}
|
|
||||||
bodyLines := []string{
|
|
||||||
fmt.Sprintf("Items: %d", len(msg.sourcePaths)),
|
|
||||||
fmt.Sprintf("Files: %d", msg.stats.FilesTotal),
|
|
||||||
fmt.Sprintf("Size: %s", formatSize(msg.stats.BytesTotal, true)),
|
|
||||||
}
|
|
||||||
note := "confirm-actions"
|
|
||||||
if msg.srcClient == nil {
|
|
||||||
note = fmt.Sprintf("Mode: %s (D/d to change)\nEnter / y to confirm, Esc / n to cancel", m.deleteKind)
|
|
||||||
}
|
|
||||||
m.openConfirmModal(
|
|
||||||
title,
|
|
||||||
strings.Join(bodyLines, "\n"),
|
|
||||||
note,
|
|
||||||
pendingOperation{
|
|
||||||
kind: msg.kind,
|
|
||||||
sourcePaths: append([]string(nil), msg.sourcePaths...),
|
|
||||||
stats: msg.stats,
|
|
||||||
srcClient: msg.srcClient,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case archivePlanMsg:
|
case archivePlanMsg:
|
||||||
m.busy = false
|
m.busy = false
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
|
|
@ -1487,11 +1383,14 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
return m.executeSSHHostDelete(hostName)
|
return m.executeSSHHostDelete(hostName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remote delete
|
// Remote delete — same progress modal + cancel support as local delete
|
||||||
if pending.srcClient != nil {
|
if pending.srcClient != nil {
|
||||||
m.busy = true
|
if m.archiveJob != nil {
|
||||||
|
m.status = "Delete is already running"
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
log.Printf("[MODAL] Confirm — remote delete sources=%d", len(pending.sourcePaths))
|
log.Printf("[MODAL] Confirm — remote delete sources=%d", len(pending.sourcePaths))
|
||||||
return m, m.remoteDeleteCmd(pending.sourcePaths, pending.srcClient)
|
return m, m.startRemoteDeleteJob(pending.sourcePaths, pending.srcClient)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust delete kind based on mode toggle
|
// Adjust delete kind based on mode toggle
|
||||||
|
|
@ -2404,21 +2303,33 @@ func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) {
|
||||||
log.Printf("[ACTION] Transfer: kind=%d active=%s srcPath=%s dstPath=%s srcRemote=%v dstRemote=%v sources=%v",
|
log.Printf("[ACTION] Transfer: kind=%d active=%s srcPath=%s dstPath=%s srcRemote=%v dstRemote=%v sources=%v",
|
||||||
kind, m.active, srcPane.Path, dstPane.Path, srcHasRemote, dstHasRemote, sources)
|
kind, m.active, srcPane.Path, dstPane.Path, srcHasRemote, dstHasRemote, sources)
|
||||||
|
|
||||||
// Check for existing targets (fast — one Stat per top-level item)
|
// Check for existing targets (fast — one Stat per top-level item), so
|
||||||
// Skip when target is remote — local fs check doesn't apply.
|
// the confirm dialog below can warn about overwrites regardless of
|
||||||
|
// whether the destination is local or remote.
|
||||||
existingTargets := 0
|
existingTargets := 0
|
||||||
if !dstHasRemote {
|
for _, sourcePath := range sources {
|
||||||
for _, sourcePath := range sources {
|
targetPath := filepath.Join(targetDir, filepath.Base(sourcePath))
|
||||||
targetPath := filepath.Join(targetDir, filepath.Base(sourcePath))
|
var exists bool
|
||||||
exists, err := vfs.PathExists(targetPath)
|
if dstHasRemote {
|
||||||
|
_, statErr := dstRemote.Client.Lstat(targetPath)
|
||||||
|
if statErr == nil {
|
||||||
|
exists = true
|
||||||
|
} else if !os.IsNotExist(statErr) {
|
||||||
|
log.Printf("[ERROR] Transfer: remote path check failed: %s err=%v", targetPath, statErr)
|
||||||
|
m.status = statErr.Error()
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
exists, err = vfs.PathExists(targetPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[ERROR] Transfer: path check failed: %s err=%v", targetPath, err)
|
log.Printf("[ERROR] Transfer: path check failed: %s err=%v", targetPath, err)
|
||||||
m.status = err.Error()
|
m.status = err.Error()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if exists {
|
}
|
||||||
existingTargets++
|
if exists {
|
||||||
}
|
existingTargets++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
overwrite := existingTargets > 0
|
overwrite := existingTargets > 0
|
||||||
|
|
@ -4764,134 +4675,6 @@ func remoteDirSizeCmd(client *remote.SSHClient, path string) tea.Cmd {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyPlanCmd(kind fileOpKind, sourcePaths []string, targetDir string, overwrite bool, existingTargets int) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
stats := vfs.TransferStats{}
|
|
||||||
var err error
|
|
||||||
for _, sourcePath := range sourcePaths {
|
|
||||||
part, statErr := vfs.CopyStats(sourcePath)
|
|
||||||
if statErr != nil {
|
|
||||||
err = statErr
|
|
||||||
break
|
|
||||||
}
|
|
||||||
stats.FilesTotal += part.FilesTotal
|
|
||||||
stats.BytesTotal += part.BytesTotal
|
|
||||||
}
|
|
||||||
return copyPlanMsg{
|
|
||||||
kind: kind,
|
|
||||||
sourcePaths: append([]string(nil), sourcePaths...),
|
|
||||||
targetDir: targetDir,
|
|
||||||
overwrite: overwrite,
|
|
||||||
existingTargets: existingTargets,
|
|
||||||
stats: stats,
|
|
||||||
err: err,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// remoteCopyPlanCmd computes transfer stats for remote-involved copy/move operations.
|
|
||||||
// For local sources it uses vfs.CopyStats; for remote sources it walks the SFTP tree.
|
|
||||||
func (m *Model) remoteCopyPlanCmd(kind fileOpKind, sourcePaths []string, targetDir string,
|
|
||||||
sourceIsRemote, targetIsRemote bool, srcClient, dstClient *remote.SSHClient) tea.Cmd {
|
|
||||||
|
|
||||||
return func() tea.Msg {
|
|
||||||
stats := vfs.TransferStats{}
|
|
||||||
var err error
|
|
||||||
|
|
||||||
for _, sourcePath := range sourcePaths {
|
|
||||||
if !sourceIsRemote {
|
|
||||||
// Local source — use existing stats function
|
|
||||||
part, statErr := vfs.CopyStats(sourcePath)
|
|
||||||
if statErr != nil {
|
|
||||||
err = statErr
|
|
||||||
break
|
|
||||||
}
|
|
||||||
stats.FilesTotal += part.FilesTotal
|
|
||||||
stats.BytesTotal += part.BytesTotal
|
|
||||||
} else {
|
|
||||||
// Remote source — walk via SFTP
|
|
||||||
info, statErr := srcClient.Lstat(sourcePath)
|
|
||||||
if statErr != nil {
|
|
||||||
err = statErr
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
stats.FilesTotal++
|
|
||||||
stats.BytesTotal += info.Size()
|
|
||||||
} else {
|
|
||||||
walkErr := srcClient.Walk(sourcePath, func(walkPath string, info os.FileInfo, walkErr error) error {
|
|
||||||
if walkErr != nil {
|
|
||||||
return walkErr
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
stats.FilesTotal++
|
|
||||||
stats.BytesTotal += info.Size()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if walkErr != nil {
|
|
||||||
err = walkErr
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return copyPlanMsg{
|
|
||||||
kind: kind,
|
|
||||||
sourcePaths: append([]string(nil), sourcePaths...),
|
|
||||||
targetDir: targetDir,
|
|
||||||
stats: stats,
|
|
||||||
err: err,
|
|
||||||
srcClient: srcClient,
|
|
||||||
dstClient: dstClient,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// remoteDeletePlanCmd computes delete stats for remote paths via SFTP.
|
|
||||||
func (m *Model) remoteDeletePlanCmd(sources []string, client *remote.SSHClient) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
stats := vfs.TransferStats{}
|
|
||||||
var err error
|
|
||||||
|
|
||||||
for _, sourcePath := range sources {
|
|
||||||
info, statErr := client.Lstat(sourcePath)
|
|
||||||
if statErr != nil {
|
|
||||||
err = statErr
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
stats.FilesTotal++
|
|
||||||
stats.BytesTotal += info.Size()
|
|
||||||
} else {
|
|
||||||
walkErr := client.Walk(sourcePath, func(walkPath string, info os.FileInfo, walkErr error) error {
|
|
||||||
if walkErr != nil {
|
|
||||||
return walkErr
|
|
||||||
}
|
|
||||||
if !info.IsDir() {
|
|
||||||
stats.FilesTotal++
|
|
||||||
stats.BytesTotal += info.Size()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if walkErr != nil {
|
|
||||||
err = walkErr
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return deletePlanMsg{
|
|
||||||
kind: opDelete,
|
|
||||||
sourcePaths: append([]string(nil), sources...),
|
|
||||||
stats: stats,
|
|
||||||
err: err,
|
|
||||||
srcClient: client,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) enterArchive(selected vfs.Entry) error {
|
func (m *Model) enterArchive(selected vfs.Entry) error {
|
||||||
pane := m.activePane()
|
pane := m.activePane()
|
||||||
// Save current path to history before opening the archive.
|
// Save current path to history before opening the archive.
|
||||||
|
|
@ -5302,50 +5085,6 @@ func deletePathsPermanentCmd(paths []string) tea.Cmd {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func trashPlanCmd(sourcePaths []string) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
stats := vfs.TransferStats{}
|
|
||||||
var err error
|
|
||||||
for _, sourcePath := range sourcePaths {
|
|
||||||
part, statErr := vfs.CopyStats(sourcePath)
|
|
||||||
if statErr != nil {
|
|
||||||
err = statErr
|
|
||||||
break
|
|
||||||
}
|
|
||||||
stats.FilesTotal += part.FilesTotal
|
|
||||||
stats.BytesTotal += part.BytesTotal
|
|
||||||
}
|
|
||||||
return deletePlanMsg{
|
|
||||||
kind: opDelete,
|
|
||||||
sourcePaths: append([]string(nil), sourcePaths...),
|
|
||||||
stats: stats,
|
|
||||||
err: err,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func deletePlanPermanentCmd(sourcePaths []string) tea.Cmd {
|
|
||||||
return func() tea.Msg {
|
|
||||||
stats := vfs.TransferStats{}
|
|
||||||
var err error
|
|
||||||
for _, sourcePath := range sourcePaths {
|
|
||||||
part, statErr := vfs.CopyStats(sourcePath)
|
|
||||||
if statErr != nil {
|
|
||||||
err = statErr
|
|
||||||
break
|
|
||||||
}
|
|
||||||
stats.FilesTotal += part.FilesTotal
|
|
||||||
stats.BytesTotal += part.BytesTotal
|
|
||||||
}
|
|
||||||
return deletePlanMsg{
|
|
||||||
kind: opPermanentDelete,
|
|
||||||
sourcePaths: append([]string(nil), sourcePaths...),
|
|
||||||
stats: stats,
|
|
||||||
err: err,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) mkdirCmd(parent, name string) tea.Cmd {
|
func (m *Model) mkdirCmd(parent, name string) tea.Cmd {
|
||||||
// Remote mkdir via SFTP
|
// Remote mkdir via SFTP
|
||||||
if mount, ok := m.activePane().CurrentRemote(); ok {
|
if mount, ok := m.activePane().CurrentRemote(); ok {
|
||||||
|
|
@ -6301,20 +6040,82 @@ func (m *Model) enterRemoteDir(entry vfs.Entry) (tea.Model, tea.Cmd) {
|
||||||
return m, m.loadPreviewCmd()
|
return m, m.loadPreviewCmd()
|
||||||
}
|
}
|
||||||
|
|
||||||
// remoteDeleteCmd deletes files/directories on a remote host via SFTP.
|
// startRemoteDeleteJob deletes files/directories on a remote host via SFTP,
|
||||||
func (m *Model) remoteDeleteCmd(sources []string, client *remote.SSHClient) tea.Cmd {
|
// with the same progress modal and mid-job cancellation support as the local
|
||||||
log.Printf("[ACTION] remoteDeleteCmd: sources=%v", sources)
|
// delete job (startDeleteJob). There is no remote trash, so this is always a
|
||||||
return func() tea.Msg {
|
// permanent delete.
|
||||||
for _, sourcePath := range sources {
|
func (m *Model) startRemoteDeleteJob(sources []string, client *remote.SSHClient) tea.Cmd {
|
||||||
log.Printf("[JOB] remoteDelete: removing path=%s", sourcePath)
|
m.nextArchiveJob++
|
||||||
if err := client.RemoveRecursive(sourcePath); err != nil {
|
jobID := m.nextArchiveJob
|
||||||
log.Printf("[ERROR] remoteDelete failed: path=%s err=%v", sourcePath, err)
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
return opMsg{kind: opDelete, sourcePath: sourcePath, err: err}
|
|
||||||
}
|
m.archiveJob = &archiveJobState{
|
||||||
}
|
id: jobID,
|
||||||
log.Printf("[DONE] remoteDelete: completed %d sources", len(sources))
|
kind: "delete",
|
||||||
return opMsg{kind: opDelete}
|
sourcePaths: append([]string(nil), sources...),
|
||||||
|
targetPath: "",
|
||||||
|
progress: vfs.CopyProgress{
|
||||||
|
FilesDone: 0,
|
||||||
|
FilesTotal: len(sources),
|
||||||
|
BytesDone: 0,
|
||||||
|
BytesTotal: 0,
|
||||||
|
CurrentPath: sources[0],
|
||||||
|
Stage: "delete permanently",
|
||||||
|
},
|
||||||
|
cancel: cancel,
|
||||||
|
startedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
m.modal = modalState{kind: modalArchiveProgress}
|
||||||
|
m.status = "Deleting started"
|
||||||
|
|
||||||
|
log.Printf("[ACTION] startRemoteDeleteJob: job=%d sources=%v", jobID, sources)
|
||||||
|
|
||||||
|
return tea.Batch(
|
||||||
|
func() tea.Msg {
|
||||||
|
go func() {
|
||||||
|
for i, sourcePath := range sources {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
m.archiveProgress <- archiveDoneMsg{
|
||||||
|
jobID: jobID,
|
||||||
|
sourcePaths: append([]string(nil), sources...),
|
||||||
|
err: context.Canceled,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[JOB] remoteDelete: job=%d removing path=%s", jobID, sourcePath)
|
||||||
|
if err := client.RemoveRecursive(sourcePath); err != nil {
|
||||||
|
log.Printf("[ERROR] remoteDelete failed: job=%d path=%s err=%v", jobID, sourcePath, err)
|
||||||
|
m.archiveProgress <- archiveDoneMsg{
|
||||||
|
jobID: jobID,
|
||||||
|
sourcePaths: append([]string(nil), sources...),
|
||||||
|
err: fmt.Errorf("delete %s: %w", sourcePath, err),
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.archiveProgress <- archiveProgressMsg{
|
||||||
|
jobID: jobID,
|
||||||
|
progress: vfs.CopyProgress{
|
||||||
|
FilesDone: i + 1,
|
||||||
|
FilesTotal: len(sources),
|
||||||
|
CurrentPath: sourcePath,
|
||||||
|
Stage: "delete permanently",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("[DONE] remoteDelete: job=%d completed %d sources", jobID, len(sources))
|
||||||
|
m.archiveProgress <- archiveDoneMsg{
|
||||||
|
jobID: jobID,
|
||||||
|
sourcePaths: append([]string(nil), sources...),
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
waitArchiveProgressCmd(m.archiveProgress),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// remoteTransferCmd copies or moves files between local and remote filesystems.
|
// remoteTransferCmd copies or moves files between local and remote filesystems.
|
||||||
|
|
@ -6516,8 +6317,8 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir
|
||||||
|
|
||||||
if srcClient.SameHostAs(dstClient) {
|
if srcClient.SameHostAs(dstClient) {
|
||||||
// Same host: use server-side commands, no local streaming
|
// Same host: use server-side commands, no local streaming
|
||||||
srcEscaped := "'" + sourcePath + "'"
|
srcEscaped := remote.ShellQuote(sourcePath)
|
||||||
dstEscaped := "'" + targetPath + "'"
|
dstEscaped := remote.ShellQuote(targetPath)
|
||||||
if kind == opMove {
|
if kind == opMove {
|
||||||
log.Printf("[JOB] remoteCopy — same-host move: job=%d src=%s dst=%s", jobID, sourcePath, targetPath)
|
log.Printf("[JOB] remoteCopy — same-host move: job=%d src=%s dst=%s", jobID, sourcePath, targetPath)
|
||||||
_, err = srcClient.Exec("mv " + srcEscaped + " " + dstEscaped)
|
_, err = srcClient.Exec("mv " + srcEscaped + " " + dstEscaped)
|
||||||
|
|
@ -6542,20 +6343,13 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir
|
||||||
if !info.IsDir() {
|
if !info.IsDir() {
|
||||||
cmd = "cp"
|
cmd = "cp"
|
||||||
}
|
}
|
||||||
err = srcClient.ExecWithProgress(cmd+" "+srcEscaped+" "+dstEscaped, func(line string) {
|
// No live per-file progress here: without -v, cp
|
||||||
// cp -rv outputs one line per copied file
|
// prints nothing to parse, and -v's output isn't
|
||||||
doneFiles++
|
// guaranteed to map 1:1 onto our file-only counts
|
||||||
log.Printf("[JOB] remoteCopy — same-host progress: job=%d doneFiles=%d line=%s", jobID, doneFiles, line)
|
// (it also reports directories). doneFiles is
|
||||||
m.copyProgress <- copyProgressMsg{
|
// credited in one step below once the command
|
||||||
jobID: jobID,
|
// completes instead.
|
||||||
progress: vfs.CopyProgress{
|
_, err = srcClient.Exec(cmd + " " + srcEscaped + " " + dstEscaped)
|
||||||
FilesDone: doneFiles,
|
|
||||||
FilesTotal: totalFiles,
|
|
||||||
CurrentPath: line,
|
|
||||||
Stage: "Copying files...",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
serverSide = true
|
serverSide = true
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -6630,10 +6424,13 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir
|
||||||
|
|
||||||
log.Printf("[JOB] remoteCopy — source done: job=%d path=%s err=%v doneFiles=%d totalFiles=%d serverSide=%v",
|
log.Printf("[JOB] remoteCopy — source done: job=%d path=%s err=%v doneFiles=%d totalFiles=%d serverSide=%v",
|
||||||
jobID, sourcePath, err, doneFiles, totalFiles, serverSide)
|
jobID, sourcePath, err, doneFiles, totalFiles, serverSide)
|
||||||
if !serverSide || kind == opMove {
|
// Credit this source's files once, however the transfer
|
||||||
if !trackedProgress {
|
// happened. Callbacks that already reported per-file
|
||||||
doneFiles += sourceCounts[i]
|
// progress (trackedProgress) are the only case to skip,
|
||||||
}
|
// to avoid double-counting; server-side cp has no such
|
||||||
|
// callback, so it must be credited here too.
|
||||||
|
if !trackedProgress {
|
||||||
|
doneFiles += sourceCounts[i]
|
||||||
}
|
}
|
||||||
if info != nil && !info.IsDir() {
|
if info != nil && !info.IsDir() {
|
||||||
doneBytes += info.Size()
|
doneBytes += info.Size()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue