6b52e83bdc
A two-pane local/remote file browser over SSH, split out of dial so file transfer lives in its own tool. The two share ~/.ssh/config as their base. - internal/sshconf: ~/.ssh/config parsing, for the saved-host list. - internal/sshx: dialling and auth — agent, keys, password, host-key verification, ProxyJump. - internal/vfs: the FS interface both sides are driven through, implemented over the local disk and over SFTP. - internal/xfer: recursive copy engine with progress reporting, cancellation, conflict policy, and resume of partial transfers. - internal/ui: the Fyne GUI — connect window (saved hosts plus quick connect) and the two-pane browser session. Build with `make build`; the Makefile locates the Go toolchain at ~/.local/go, which is deliberately kept off PATH. Cross-compiling needs a per-target C toolchain because Fyne is cgo, so `make dist` builds the native binary only — the alternatives are documented in the Makefile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
186 lines
6.2 KiB
Go
186 lines
6.2 KiB
Go
// Package vfs is the filesystem abstraction both sides of haul are driven
|
|
// through: the local disk and a remote SFTP connection look identical to the
|
|
// transfer engine (internal/xfer) and to the browser panes (internal/ui).
|
|
//
|
|
// The two differ in more than their I/O calls — local paths use the native
|
|
// separator and a platform-specific root, remote paths are always "/"-separated
|
|
// — so path manipulation is part of the interface rather than something callers
|
|
// are trusted to get right per side.
|
|
package vfs
|
|
|
|
import (
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/pkg/sftp"
|
|
)
|
|
|
|
// FS is the filesystem surface haul needs. Read/write methods cover the copy
|
|
// engine; the path helpers and the mutating operations cover the browser.
|
|
type FS interface {
|
|
// Open opens a file for reading with seek support (needed to resume).
|
|
Open(p string) (io.ReadSeekCloser, error)
|
|
// Create truncates/creates a file for writing.
|
|
Create(p string) (io.WriteCloser, error)
|
|
// OpenAt opens an existing file for writing positioned at offset, used to
|
|
// resume a partial transfer. (SFTP O_APPEND is unreliable — pkg/sftp writes
|
|
// at an explicit offset — so we seek instead.)
|
|
OpenAt(p string, offset int64) (io.WriteCloser, error)
|
|
Stat(p string) (os.FileInfo, error)
|
|
ReadDir(p string) ([]os.FileInfo, error)
|
|
MkdirAll(p string) error
|
|
Join(elem ...string) string
|
|
Base(p string) string
|
|
|
|
// Dir returns p's parent directory.
|
|
Dir(p string) string
|
|
// IsRoot reports whether p has no parent to ascend to.
|
|
IsRoot(p string) bool
|
|
// Home is the directory a pane opens at: the user's home, falling back to
|
|
// the working directory and then the root.
|
|
Home() string
|
|
// Mkdir creates a single directory.
|
|
Mkdir(p string) error
|
|
// Remove deletes a file, or a directory and everything under it.
|
|
Remove(p string) error
|
|
// Rename moves oldPath to newPath within this filesystem.
|
|
Rename(oldPath, newPath string) error
|
|
}
|
|
|
|
// Local is the local disk, using os + filepath (native separators).
|
|
type Local struct{}
|
|
|
|
func (Local) Open(p string) (io.ReadSeekCloser, error) { return os.Open(p) }
|
|
func (Local) Create(p string) (io.WriteCloser, error) {
|
|
return os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
|
}
|
|
func (Local) OpenAt(p string, offset int64) (io.WriteCloser, error) {
|
|
f, err := os.OpenFile(p, os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
return f, nil
|
|
}
|
|
func (Local) Stat(p string) (os.FileInfo, error) { return os.Stat(p) }
|
|
func (Local) MkdirAll(p string) error { return os.MkdirAll(p, 0o755) }
|
|
func (Local) Join(elem ...string) string { return filepath.Join(elem...) }
|
|
func (Local) Base(p string) string { return filepath.Base(p) }
|
|
func (Local) ReadDir(p string) ([]os.FileInfo, error) { return readDirInfos(p) }
|
|
func (Local) Dir(p string) string { return filepath.Dir(p) }
|
|
|
|
// IsRoot is true when p is its own parent, which is how both "/" and "C:\"
|
|
// behave under filepath.Dir.
|
|
func (Local) IsRoot(p string) bool { return p == filepath.Dir(p) }
|
|
|
|
func (l Local) Home() string {
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
return home
|
|
}
|
|
if wd, err := os.Getwd(); err == nil {
|
|
return wd
|
|
}
|
|
return string(filepath.Separator)
|
|
}
|
|
|
|
func (Local) Mkdir(p string) error { return os.Mkdir(p, 0o755) }
|
|
func (Local) Remove(p string) error { return os.RemoveAll(p) }
|
|
func (Local) Rename(a, b string) error { return os.Rename(a, b) }
|
|
|
|
func readDirInfos(p string) ([]os.FileInfo, error) {
|
|
entries, err := os.ReadDir(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]os.FileInfo, 0, len(entries))
|
|
for _, e := range entries {
|
|
fi, err := e.Info()
|
|
if err != nil {
|
|
// A file that vanished between the listing and the stat isn't an
|
|
// error worth failing the whole directory over.
|
|
continue
|
|
}
|
|
out = append(out, fi)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Remote is the SFTP side, using the "/"-separated path package.
|
|
type Remote struct{ C *sftp.Client }
|
|
|
|
func (r Remote) Open(p string) (io.ReadSeekCloser, error) { return r.C.Open(p) }
|
|
func (r Remote) Create(p string) (io.WriteCloser, error) {
|
|
return r.C.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC)
|
|
}
|
|
func (r Remote) OpenAt(p string, offset int64) (io.WriteCloser, error) {
|
|
f, err := r.C.OpenFile(p, os.O_WRONLY)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := f.Seek(offset, io.SeekStart); err != nil {
|
|
f.Close()
|
|
return nil, err
|
|
}
|
|
return f, nil
|
|
}
|
|
func (r Remote) Stat(p string) (os.FileInfo, error) { return r.C.Stat(p) }
|
|
func (r Remote) MkdirAll(p string) error { return r.C.MkdirAll(p) }
|
|
func (r Remote) Join(elem ...string) string { return path.Join(elem...) }
|
|
func (r Remote) Base(p string) string { return path.Base(p) }
|
|
func (r Remote) ReadDir(p string) ([]os.FileInfo, error) { return r.C.ReadDir(p) }
|
|
func (r Remote) Dir(p string) string { return path.Dir(p) }
|
|
func (r Remote) IsRoot(p string) bool { return p == "/" || p == "" }
|
|
|
|
// Home asks the server where it put us; the SFTP subsystem starts in the
|
|
// account's home directory, so Getwd is the answer without shelling out.
|
|
func (r Remote) Home() string {
|
|
if wd, err := r.C.Getwd(); err == nil && wd != "" {
|
|
return wd
|
|
}
|
|
return "/"
|
|
}
|
|
|
|
func (r Remote) Mkdir(p string) error { return r.C.Mkdir(p) }
|
|
func (r Remote) Rename(a, b string) error { return r.C.Rename(a, b) }
|
|
|
|
// Remove deletes p, recursing into directories: SFTP's RemoveDirectory only
|
|
// works on empty directories, so the tree has to be walked bottom-up.
|
|
func (r Remote) Remove(p string) error {
|
|
fi, err := r.C.Stat(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !fi.IsDir() {
|
|
return r.C.Remove(p)
|
|
}
|
|
entries, err := r.C.ReadDir(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, e := range entries {
|
|
if err := r.Remove(path.Join(p, e.Name())); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return r.C.RemoveDirectory(p)
|
|
}
|
|
|
|
// CleanPath normalises user-typed input for fs: trimming stray whitespace and
|
|
// collapsing "." and ".." segments, leaving a path fs can Stat.
|
|
func CleanPath(fs FS, p string) string {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
return fs.Home()
|
|
}
|
|
if _, ok := fs.(Remote); ok {
|
|
return path.Clean(p)
|
|
}
|
|
return filepath.Clean(p)
|
|
}
|