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
+144
View File
@@ -0,0 +1,144 @@
package ui
import (
"fmt"
"sync"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"golang.org/x/crypto/ssh"
"gitea.apointless.space/bsncubed/haul/internal/sshx"
"gitea.apointless.space/bsncubed/haul/internal/xfer"
)
// The prompts in this file are all shaped the same way, because they are all
// answers a background goroutine is blocked waiting for: the goroutine makes a
// buffered channel, asks for the prompt to be shown on the UI thread, and reads
// its answer. Every dismissal path — button, escape, window close — must send
// exactly once, or the transfer goroutine hangs forever holding the connection.
// sync.Once is what guarantees the "exactly".
func confirmDialog(title, msg string, win fyne.Window, cb func(bool)) {
dialog.ShowConfirm(title, msg, cb, win)
}
func newFormDialog(title, confirm string, items []*widget.FormItem, win fyne.Window, cb func(bool)) dialog.Dialog {
d := dialog.NewForm(title, confirm, "Cancel", items, cb, win)
d.Resize(fyne.NewSize(420, 0))
return d
}
// conflictAnswer is the user's decision about one pre-existing destination file.
type conflictAnswer struct {
choice xfer.Conflict
all bool // apply this decision to every remaining conflict
}
// askConflict shows the overwrite prompt. It must be called on the UI thread.
func askConflict(win fyne.Window, name string, reply chan<- conflictAnswer) {
var once sync.Once
send := func(a conflictAnswer) { once.Do(func() { reply <- a }) }
all := widget.NewCheck("Apply to all remaining conflicts", nil)
msg := widget.NewLabel(fmt.Sprintf("%s already exists at the destination.", name))
msg.Wrapping = fyne.TextWrapWord
var d *dialog.CustomDialog
button := func(label string, c xfer.Conflict) *widget.Button {
return widget.NewButton(label, func() {
send(conflictAnswer{choice: c, all: all.Checked})
d.Hide()
})
}
overwrite := button("Overwrite", xfer.Overwrite)
overwrite.Importance = widget.HighImportance
content := container.NewVBox(
msg,
all,
container.NewHBox(layout.NewSpacer(),
button("Skip", xfer.Skip),
button("Keep both", xfer.Rename),
overwrite,
),
)
d = dialog.NewCustomWithoutButtons("File exists", content, win)
// Dismissing without choosing is treated as "skip this file": the safe
// reading of "I didn't answer" is "don't destroy anything".
d.SetOnClosed(func() { send(conflictAnswer{choice: xfer.Skip}) })
d.Resize(fyne.NewSize(460, 0))
d.Show()
}
// askSecret prompts for a password or key passphrase. An empty result means the
// user declined, which callers pass on to sshx as "skip this method".
func askSecret(win fyne.Window, title, prompt string, reply chan<- []byte) {
var once sync.Once
send := func(b []byte) { once.Do(func() { reply <- b }) }
entry := widget.NewPasswordEntry()
label := widget.NewLabel(prompt)
label.Wrapping = fyne.TextWrapWord
var d *dialog.CustomDialog
submit := func() {
send([]byte(entry.Text))
d.Hide()
}
entry.OnSubmitted = func(string) { submit() }
ok := widget.NewButton("OK", submit)
ok.Importance = widget.HighImportance
cancel := widget.NewButton("Cancel", func() {
send(nil)
d.Hide()
})
content := container.NewVBox(
label,
entry,
container.NewHBox(layout.NewSpacer(), cancel, ok),
)
d = dialog.NewCustomWithoutButtons(title, content, win)
d.SetOnClosed(func() { send(nil) })
d.Resize(fyne.NewSize(420, 0))
d.Show()
win.Canvas().Focus(entry)
}
// askHostKey shows the unknown-host prompt, the GUI equivalent of ssh's
// "authenticity of host ... can't be established". The fingerprint is shown in
// the same SHA256 form ssh prints, so it can be compared by eye.
func askHostKey(win fyne.Window, host string, key ssh.PublicKey, reply chan<- bool) {
var once sync.Once
send := func(b bool) { once.Do(func() { reply <- b }) }
msg := widget.NewLabel(fmt.Sprintf(
"The authenticity of host %s can't be established.\n\n%s key fingerprint:", host, key.Type()))
msg.Wrapping = fyne.TextWrapWord
fp := widget.NewLabelWithStyle(sshx.FingerprintSHA256(key), fyne.TextAlignCenter, fyne.TextStyle{Monospace: true})
warn := widget.NewLabel("Only continue if you recognise this fingerprint. It will be added to your known_hosts file.")
warn.Wrapping = fyne.TextWrapWord
var d *dialog.CustomDialog
accept := widget.NewButton("Connect and record key", func() {
send(true)
d.Hide()
})
accept.Importance = widget.HighImportance
reject := widget.NewButton("Cancel", func() {
send(false)
d.Hide()
})
content := container.NewVBox(msg, fp, warn,
container.NewHBox(layout.NewSpacer(), reject, accept))
d = dialog.NewCustomWithoutButtons("Unknown host key", content, win)
d.SetOnClosed(func() { send(false) })
d.Resize(fyne.NewSize(520, 0))
d.Show()
}