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:
2026-07-29 23:21:21 +10:00
commit 6b52e83bdc
18 changed files with 3223 additions and 0 deletions
+268
View File
@@ -0,0 +1,268 @@
package ui
import (
"errors"
"fmt"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
"gitea.apointless.space/bsncubed/haul/internal/vfs"
"gitea.apointless.space/bsncubed/haul/internal/xfer"
)
// session is one connected host: a window with the local disk on the left, the
// remote host on the right, and the transfer controls between them. It owns the
// SSH and SFTP connections and closes them when the window closes.
type session struct {
win fyne.Window
client *ssh.Client
sf *sftp.Client
engine *xfer.Engine
local *pane
remote *pane
active *pane
uploadBtn *widget.Button
downloadBtn *widget.Button
stopBtn *widget.Button
progBar *widget.ProgressBar
progLabel *widget.Label
busy bool
// onClose brings the connection window back when this session ends.
onClose func()
}
// newSession builds and shows the browser window for an established connection.
func newSession(app fyne.App, name string, client *ssh.Client, sf *sftp.Client, onClose func()) *session {
s := &session{
win: app.NewWindow("haul — " + name),
client: client,
sf: sf,
engine: xfer.New(sf),
onClose: onClose,
}
s.local = newPane(s.win, vfs.Local{}, "LOCAL", s.showError, s.setActive)
s.remote = newPane(s.win, vfs.Remote{C: sf}, "REMOTE — "+name, s.showError, s.setActive)
s.active = s.local
// The arrows in the labels carry the direction; an icon next to them only
// competes with it, since up/down icons say nothing about left/right panes.
s.uploadBtn = widget.NewButton("Upload →", func() { s.transfer(true) })
s.downloadBtn = widget.NewButton("← Download", func() { s.transfer(false) })
// Disconnect goes through the same guard as the window's close button, so a
// running transfer is never dropped without asking.
disconnect := widget.NewButtonWithIcon("Disconnect", theme.LogoutIcon(), s.closeRequested)
s.progBar = widget.NewProgressBar()
// An empty bar sitting at 0% reads as "stuck", so it only appears once
// there's a transfer to report on, and stays afterwards showing the result.
s.progBar.Hide()
s.progLabel = widget.NewLabel("Ready")
s.stopBtn = widget.NewButtonWithIcon("Stop", theme.CancelIcon(), func() { s.engine.Cancel() })
s.stopBtn.Disable()
controls := container.NewHBox(s.uploadBtn, s.downloadBtn)
transferBar := container.NewBorder(nil, nil, controls, disconnect)
progressBar := container.NewBorder(nil, nil, nil, s.stopBtn,
container.NewBorder(nil, nil, nil, s.progLabel, s.progBar))
split := container.NewHSplit(s.local.root, s.remote.root)
split.SetOffset(0.5)
s.win.SetContent(container.NewBorder(
nil,
container.NewVBox(widget.NewSeparator(), transferBar, progressBar),
nil, nil,
split,
))
s.win.Resize(fyne.NewSize(1180, 720))
s.win.SetCloseIntercept(s.closeRequested)
s.registerShortcuts()
s.local.navigate(s.local.fs.Home())
s.remote.navigate(s.remote.fs.Home())
s.win.Show()
return s
}
func (s *session) setActive(p *pane) { s.active = p }
func (s *session) other(p *pane) *pane {
if p == s.local {
return s.remote
}
return s.local
}
func (s *session) registerShortcuts() {
add := func(key fyne.KeyName, fn func()) {
s.win.Canvas().AddShortcut(
&desktop.CustomShortcut{KeyName: key, Modifier: fyne.KeyModifierControl},
func(fyne.Shortcut) { fn() },
)
}
add(fyne.KeyR, func() { s.active.reload() })
add(fyne.KeyUp, func() { s.active.up() })
// Ctrl+T sends the active pane's selection to the other side, so the same
// key uploads or downloads depending on which pane you were last in.
add(fyne.KeyT, func() { s.transfer(s.active == s.local) })
}
// --- transfers ---
// transfer copies the source pane's marked entries (or its highlighted row)
// into the other pane's current directory.
func (s *session) transfer(push bool) {
if s.busy {
return
}
src, dst := s.remote, s.local
if push {
src, dst = s.local, s.remote
}
sources := src.sources()
if len(sources) == 0 {
s.showError(fmt.Errorf("mark or select something in %s first", src.title))
return
}
destDir := dst.cwd
s.setBusy(true)
s.progBar.SetValue(0)
go func() {
// applyAll lives in this goroutine alone, so the "apply to all" answer
// needs no synchronisation with the UI.
var applyAll *xfer.Conflict
s.engine.OnProgress = s.progressSink()
s.engine.OnConflict = func(name string) xfer.Conflict {
if applyAll != nil {
return *applyAll
}
reply := make(chan conflictAnswer, 1)
fyne.Do(func() { askConflict(s.win, name, reply) })
a := <-reply
if a.all {
c := a.choice
applyAll = &c
}
return a.choice
}
var err error
if push {
err = s.engine.Push(sources, destDir)
} else {
err = s.engine.Pull(sources, destDir)
}
fyne.Do(func() {
s.setBusy(false)
src.marked = map[string]bool{}
src.table.Refresh()
src.updateFooter()
dst.reload() // show what just landed
switch {
case err == nil:
s.progBar.SetValue(1)
s.progLabel.SetText(fmt.Sprintf("Transferred %d item(s)", len(sources)))
case errors.Is(err, xfer.ErrCanceled):
s.progLabel.SetText("Transfer stopped — partial files can be resumed by transferring again")
default:
s.progLabel.SetText("Transfer failed")
s.showError(err)
}
})
}()
}
// progressSink returns the engine's progress callback, rate-limited before it
// reaches the UI thread. The engine reports on every write — tens of thousands
// of times for a large file — and repainting at that rate is slower than the
// transfer itself.
func (s *session) progressSink() xfer.ProgressFunc {
// Only the transfer goroutine calls this, so last needs no locking.
var last time.Time
const minInterval = 60 * time.Millisecond
return func(p xfer.Progress) {
now := time.Now()
done := p.FilesTotal > 0 && p.FilesDone == p.FilesTotal
if !done && now.Sub(last) < minInterval {
return
}
last = now
fyne.Do(func() { s.paintProgress(p) })
}
}
func (s *session) paintProgress(p xfer.Progress) {
if p.BytesTotal > 0 {
s.progBar.SetValue(float64(p.BytesDone) / float64(p.BytesTotal))
}
s.progLabel.SetText(fmt.Sprintf("%d/%d files · %s of %s · %s",
p.FilesDone, p.FilesTotal, humanBytes(p.BytesDone), humanBytes(p.BytesTotal), p.CurrentName))
}
func (s *session) setBusy(busy bool) {
s.busy = busy
if busy {
s.progBar.Show()
s.uploadBtn.Disable()
s.downloadBtn.Disable()
s.stopBtn.Enable()
return
}
s.uploadBtn.Enable()
s.downloadBtn.Enable()
s.stopBtn.Disable()
}
// --- lifecycle ---
func (s *session) showError(err error) {
if err == nil {
return
}
dialog.ShowError(err, s.win)
}
// closeRequested guards against closing the window out from under a running
// transfer, which would kill the connection mid-file.
func (s *session) closeRequested() {
if !s.busy {
s.shutdown()
return
}
confirmDialog("Transfer in progress",
"A transfer is still running. Stop it and disconnect?", s.win, func(ok bool) {
if !ok {
return
}
s.engine.Cancel()
s.shutdown()
})
}
func (s *session) shutdown() {
if s.sf != nil {
s.sf.Close()
}
if s.client != nil {
s.client.Close()
}
s.win.Close()
if s.onClose != nil {
s.onClose()
}
}