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>
269 lines
7.2 KiB
Go
269 lines
7.2 KiB
Go
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()
|
|
}
|
|
}
|