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>
145 lines
4.7 KiB
Go
145 lines
4.7 KiB
Go
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()
|
|
}
|