Initial commit: haul — desktop SFTP client
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>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user