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
+388
View File
@@ -0,0 +1,388 @@
package ui
import (
"fmt"
"os"
"strings"
"sync"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"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/sshconf"
"gitea.apointless.space/bsncubed/haul/internal/sshx"
)
// connectWindow is haul's front door: the hosts already in ~/.ssh/config on one
// tab, an ad-hoc target on the other. It stays alive behind each session so
// closing a session returns here rather than quitting.
type connectWindow struct {
app fyne.App
win fyne.Window
cfgPath string
hosts []sshconf.Host // everything parsed from the config
shown []sshconf.Host // what the filter currently leaves visible
list *widget.List
selected int
qHost *widget.Entry
qPort *widget.Entry
qUser *widget.Entry
qPass *widget.Entry
qKey *widget.Entry
tabs *container.AppTabs
connectBtn *widget.Button
status *widget.Label
loadErr string // config problem found before the status label existed
}
func newConnectWindow(app fyne.App) *connectWindow {
c := &connectWindow{
app: app,
win: app.NewWindow("haul"),
selected: -1,
}
c.loadHosts()
savedTab := container.NewTabItemWithIcon("Saved hosts", theme.ComputerIcon(), c.buildSavedTab())
quickTab := container.NewTabItemWithIcon("Quick connect", theme.SearchIcon(), c.buildQuickTab())
c.tabs = container.NewAppTabs(savedTab, quickTab)
c.status = widget.NewLabel(c.loadErr)
c.status.Wrapping = fyne.TextWrapWord
c.connectBtn = widget.NewButtonWithIcon("Connect", theme.ConfirmIcon(), c.connect)
c.connectBtn.Importance = widget.HighImportance
bottom := container.NewBorder(widget.NewSeparator(), nil, nil, c.connectBtn, c.status)
c.win.SetContent(container.NewBorder(nil, bottom, nil, nil, c.tabs))
c.win.Resize(fyne.NewSize(620, 520))
return c
}
// --- saved hosts ---
func (c *connectWindow) loadHosts() {
path, err := sshconf.DefaultConfigPath()
if err != nil {
return
}
c.cfgPath = path
hosts, err := sshconf.Parse(path)
if err != nil {
// A malformed config shouldn't stop haul launching — quick connect still
// works — so the problem is held here and shown once the window is built.
c.loadErr = "Could not read " + path + ": " + err.Error()
return
}
c.hosts = hosts
c.shown = hosts
}
func (c *connectWindow) buildSavedTab() fyne.CanvasObject {
search := widget.NewEntry()
search.SetPlaceHolder("Filter hosts…")
search.OnChanged = c.filter
c.list = widget.NewList(
func() int { return len(c.shown) },
func() fyne.CanvasObject {
return container.NewVBox(
widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
widget.NewLabel(""),
)
},
func(id widget.ListItemID, o fyne.CanvasObject) {
box, ok := o.(*fyne.Container)
if !ok || id < 0 || id >= len(c.shown) {
return
}
h := c.shown[id]
alias := box.Objects[0].(*widget.Label)
detail := box.Objects[1].(*widget.Label)
alias.SetText(h.Alias)
detail.SetText(describeHost(h))
},
)
c.list.OnSelected = func(id widget.ListItemID) { c.selected = id }
// The "no hosts" note is only added when it has something to say — an empty
// label still occupies a full row, which reads as a rendering glitch.
top := fyne.CanvasObject(search)
if len(c.hosts) == 0 {
note := widget.NewLabel("No hosts found in " + c.cfgPath + " — use Quick connect.")
note.Wrapping = fyne.TextWrapWord
top = container.NewVBox(search, note)
}
return container.NewBorder(top, nil, nil, nil, c.list)
}
// describeHost is the one-line summary under each alias in the list.
func describeHost(h sshconf.Host) string {
target := h.HostName
if target == "" {
target = h.Alias
}
if h.User != "" {
target = h.User + "@" + target
}
if h.Port != "" && h.Port != "22" {
target += ":" + h.Port
}
if h.ProxyJump != "" {
target += " (via " + h.ProxyJump + ")"
}
if len(h.Tags) > 0 {
target += " [" + strings.Join(h.Tags, ", ") + "]"
}
return target
}
func (c *connectWindow) filter(q string) {
q = strings.ToLower(strings.TrimSpace(q))
if q == "" {
c.shown = c.hosts
} else {
c.shown = nil
for _, h := range c.hosts {
if strings.Contains(strings.ToLower(h.Alias), q) ||
strings.Contains(strings.ToLower(h.HostName), q) ||
strings.Contains(strings.ToLower(strings.Join(h.Tags, " ")), q) {
c.shown = append(c.shown, h)
}
}
}
c.selected = -1
c.list.UnselectAll()
c.list.Refresh()
}
// --- quick connect ---
func (c *connectWindow) buildQuickTab() fyne.CanvasObject {
c.qHost = widget.NewEntry()
c.qHost.SetPlaceHolder("hostname or IP")
c.qPort = widget.NewEntry()
c.qPort.SetText("22")
c.qUser = widget.NewEntry()
c.qUser.SetText(currentUser())
c.qPass = widget.NewPasswordEntry()
c.qPass.SetPlaceHolder("leave blank to use ssh-agent / key")
c.qKey = widget.NewEntry()
c.qKey.SetPlaceHolder("optional private key file")
browse := widget.NewButtonWithIcon("", theme.FolderOpenIcon(), func() {
dialog.ShowFileOpen(func(r fyne.URIReadCloser, err error) {
if err != nil || r == nil {
return
}
defer r.Close()
c.qKey.SetText(r.URI().Path())
}, c.win)
})
form := widget.NewForm(
widget.NewFormItem("Host", c.qHost),
widget.NewFormItem("Port", c.qPort),
widget.NewFormItem("User", c.qUser),
widget.NewFormItem("Password", c.qPass),
widget.NewFormItem("Key file", container.NewBorder(nil, nil, nil, browse, c.qKey)),
)
// Enter anywhere in the form connects.
c.qHost.OnSubmitted = func(string) { c.connect() }
c.qPass.OnSubmitted = func(string) { c.connect() }
note := widget.NewLabel("Quick connect doesn't touch ~/.ssh/config. An unrecognised host key is " +
"shown for confirmation before it's recorded in known_hosts.")
note.Wrapping = fyne.TextWrapWord
return container.NewVBox(form, widget.NewSeparator(), note)
}
func currentUser() string {
for _, k := range []string{"USER", "USERNAME", "LOGNAME"} {
if v := os.Getenv(k); v != "" {
return v
}
}
return ""
}
// --- connecting ---
func (c *connectWindow) connect() {
if c.tabs.SelectedIndex() == 1 {
c.connectQuick()
return
}
c.connectSaved()
}
func (c *connectWindow) connectSaved() {
if c.selected < 0 || c.selected >= len(c.shown) {
c.setStatus("Select a host first.")
return
}
alias := c.shown[c.selected].Alias
c.start(alias, "", func(opts sshx.Options) (*ssh.Client, error) {
return sshx.Connect(alias, opts)
})
}
func (c *connectWindow) connectQuick() {
host := strings.TrimSpace(c.qHost.Text)
if host == "" {
c.setStatus("Enter a hostname.")
return
}
// "user@host:port" typed into the host box is understood, since that's how
// people paste a target.
user := strings.TrimSpace(c.qUser.Text)
port := strings.TrimSpace(c.qPort.Text)
if u, rest, ok := strings.Cut(host, "@"); ok {
user, host = u, rest
}
if h, p, ok := strings.Cut(host, ":"); ok {
host, port = h, p
}
if port == "" {
port = "22"
}
p := sshx.Params{
Alias: host,
HostName: host,
User: user,
Port: port,
}
if key := strings.TrimSpace(c.qKey.Text); key != "" {
p.IdentityFiles = []string{key}
p.IdentitiesOnly = true
}
label := host
if user != "" {
label = user + "@" + host
}
c.start(label, c.qPass.Text, func(opts sshx.Options) (*ssh.Client, error) {
return sshx.ConnectParams(p, opts)
})
}
// start runs a connection attempt off the UI thread and opens a session window
// on success. dial is whichever sshx entry point the chosen tab implies.
func (c *connectWindow) start(name, password string, dial func(sshx.Options) (*ssh.Client, error)) {
c.connectBtn.Disable()
c.setStatus("Connecting to " + name + "…")
go func() {
var diagMu sync.Mutex
var diag []string
opts := c.sshOptions(password, func(format string, args ...any) {
diagMu.Lock()
defer diagMu.Unlock()
diag = append(diag, fmt.Sprintf(format, args...))
})
client, err := dial(opts)
if err != nil {
diagMu.Lock()
detail := strings.Join(diag, "\n · ")
diagMu.Unlock()
fyne.Do(func() {
c.connectBtn.Enable()
c.setStatus("")
c.showConnectError(err, detail)
})
return
}
sf, err := sftp.NewClient(client)
if err != nil {
client.Close()
fyne.Do(func() {
c.connectBtn.Enable()
c.setStatus("")
dialog.ShowError(fmt.Errorf("opening SFTP subsystem: %w", err), c.win)
})
return
}
fyne.Do(func() {
c.connectBtn.Enable()
c.setStatus("")
c.win.Hide()
newSession(c.app, name, client, sf, func() { c.win.Show() })
})
}()
}
// sshOptions wires sshx's blocking callbacks to dialogs on the UI thread. Each
// callback is invoked from the connecting goroutine and waits for an answer.
func (c *connectWindow) sshOptions(initialPassword string, logf func(string, ...any)) sshx.Options {
var mu sync.Mutex
cached := initialPassword
return sshx.Options{
Passphrase: func(keyfile string) ([]byte, error) {
reply := make(chan []byte, 1)
fyne.Do(func() {
askSecret(c.win, "Key passphrase", "Enter the passphrase for "+keyfile, reply)
})
return <-reply, nil
},
Password: func(user, host string) ([]byte, error) {
mu.Lock()
defer mu.Unlock()
// A password typed on the quick-connect form is reused for the
// keyboard-interactive round rather than prompting twice.
if cached != "" {
return []byte(cached), nil
}
reply := make(chan []byte, 1)
target := host
if user != "" {
target = user + "@" + host
}
fyne.Do(func() {
askSecret(c.win, "Password", "Password for "+target, reply)
})
b := <-reply
cached = string(b)
return b, nil
},
AcceptHostKey: func(host string, key ssh.PublicKey) (bool, error) {
reply := make(chan bool, 1)
fyne.Do(func() { askHostKey(c.win, host, key, reply) })
return <-reply, nil
},
Logf: logf,
}
}
// showConnectError presents the failure with the auth diagnostics sshx
// collected, which is usually what actually explains it.
func (c *connectWindow) showConnectError(err error, detail string) {
msg := err.Error()
if detail != "" {
msg += "\n\nAuth diagnostics:\n · " + detail
}
label := widget.NewLabel(msg)
label.Wrapping = fyne.TextWrapWord
d := dialog.NewCustom("Connection failed", "Close", container.NewVScroll(label), c.win)
d.Resize(fyne.NewSize(560, 320))
d.Show()
}
func (c *connectWindow) setStatus(s string) { c.status.SetText(s) }
func (c *connectWindow) showAndRun() {
c.win.ShowAndRun()
}