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:
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"gitea.apointless.space/bsncubed/haul/internal/vfs"
|
||||
)
|
||||
|
||||
// Column layout, shared by the header and the cells.
|
||||
const (
|
||||
colMark = iota
|
||||
colName
|
||||
colSize
|
||||
colModified
|
||||
numCols
|
||||
)
|
||||
|
||||
// item is one row of a directory listing.
|
||||
type item struct {
|
||||
name string
|
||||
isDir bool
|
||||
size int64
|
||||
mod time.Time
|
||||
up bool // the synthetic ".." row
|
||||
}
|
||||
|
||||
// pane is one side of the browser: a path bar, a file table, and a footer. Both
|
||||
// the local disk and the remote host are the same widget over a different
|
||||
// vfs.FS, so anything that works on one works on the other.
|
||||
type pane struct {
|
||||
fs vfs.FS
|
||||
title string
|
||||
|
||||
cwd string
|
||||
entries []item
|
||||
marked map[string]bool // names in cwd marked for transfer
|
||||
cursor int // highlighted row, or -1 for none
|
||||
|
||||
table *widget.Table
|
||||
pathEntry *widget.Entry
|
||||
footer *widget.Label
|
||||
titleLbl *widget.Label
|
||||
root fyne.CanvasObject
|
||||
|
||||
win fyne.Window
|
||||
onError func(error)
|
||||
// onFocus tells the session which pane the user last touched, so toolbar
|
||||
// actions and keyboard shortcuts know what they apply to.
|
||||
onFocus func(*pane)
|
||||
}
|
||||
|
||||
func newPane(win fyne.Window, filesys vfs.FS, title string, onError func(error), onFocus func(*pane)) *pane {
|
||||
p := &pane{
|
||||
fs: filesys,
|
||||
title: title,
|
||||
marked: map[string]bool{},
|
||||
cursor: -1,
|
||||
win: win,
|
||||
onError: onError,
|
||||
onFocus: onFocus,
|
||||
}
|
||||
|
||||
p.titleLbl = widget.NewLabelWithStyle(title, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||
p.footer = widget.NewLabel("")
|
||||
|
||||
p.pathEntry = widget.NewEntry()
|
||||
p.pathEntry.SetPlaceHolder("path")
|
||||
// Enter in the path bar jumps straight there — the fastest way to a deep
|
||||
// directory, and the reason the path is an entry rather than a label.
|
||||
p.pathEntry.OnSubmitted = func(s string) {
|
||||
p.navigate(vfs.CleanPath(p.fs, s))
|
||||
}
|
||||
|
||||
p.table = widget.NewTable(p.tableSize, p.createCell, p.updateCell)
|
||||
p.table.ShowHeaderRow = true
|
||||
p.table.CreateHeader = func() fyne.CanvasObject {
|
||||
return widget.NewLabelWithStyle("", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||
}
|
||||
p.table.UpdateHeader = p.updateHeader
|
||||
p.table.OnSelected = p.onSelect
|
||||
p.table.SetColumnWidth(colMark, 32)
|
||||
p.table.SetColumnWidth(colName, 300)
|
||||
p.table.SetColumnWidth(colSize, 96)
|
||||
p.table.SetColumnWidth(colModified, 150)
|
||||
|
||||
upBtn := widget.NewButtonWithIcon("", theme.MoveUpIcon(), func() { p.up() })
|
||||
homeBtn := widget.NewButtonWithIcon("", theme.HomeIcon(), func() { p.navigate(p.fs.Home()) })
|
||||
refreshBtn := widget.NewButtonWithIcon("", theme.ViewRefreshIcon(), func() { p.reload() })
|
||||
|
||||
nav := container.NewBorder(nil, nil,
|
||||
container.NewHBox(upBtn, homeBtn),
|
||||
refreshBtn,
|
||||
p.pathEntry,
|
||||
)
|
||||
|
||||
actions := container.NewHBox(
|
||||
widget.NewButtonWithIcon("New folder", theme.FolderNewIcon(), p.promptMkdir),
|
||||
widget.NewButtonWithIcon("Rename", theme.DocumentCreateIcon(), p.promptRename),
|
||||
widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), p.promptDelete),
|
||||
)
|
||||
|
||||
top := container.NewVBox(p.titleLbl, nav)
|
||||
bottom := container.NewVBox(actions, p.footer)
|
||||
p.root = container.NewBorder(top, bottom, nil, nil, p.table)
|
||||
return p
|
||||
}
|
||||
|
||||
// --- table plumbing ---
|
||||
|
||||
func (p *pane) tableSize() (int, int) { return len(p.entries), numCols }
|
||||
|
||||
func (p *pane) createCell() fyne.CanvasObject {
|
||||
l := widget.NewLabel("")
|
||||
l.Truncation = fyne.TextTruncateEllipsis
|
||||
return l
|
||||
}
|
||||
|
||||
func (p *pane) updateCell(id widget.TableCellID, o fyne.CanvasObject) {
|
||||
l, ok := o.(*widget.Label)
|
||||
if !ok || id.Row < 0 || id.Row >= len(p.entries) {
|
||||
return
|
||||
}
|
||||
it := p.entries[id.Row]
|
||||
// Fields are assigned rather than SetText'd so each cell refreshes once;
|
||||
// update runs for every visible cell on every scroll tick.
|
||||
l.Alignment = fyne.TextAlignLeading
|
||||
l.TextStyle = fyne.TextStyle{}
|
||||
|
||||
switch id.Col {
|
||||
case colMark:
|
||||
l.Alignment = fyne.TextAlignCenter
|
||||
l.Text = ""
|
||||
if p.marked[it.name] {
|
||||
l.Text = "✓"
|
||||
}
|
||||
case colName:
|
||||
// Directories are bold and slash-suffixed: at a glance you can tell what
|
||||
// you can descend into without hunting for an icon column.
|
||||
if it.isDir {
|
||||
l.TextStyle = fyne.TextStyle{Bold: true}
|
||||
l.Text = it.name + "/"
|
||||
} else {
|
||||
l.Text = it.name
|
||||
}
|
||||
case colSize:
|
||||
l.Alignment = fyne.TextAlignTrailing
|
||||
l.Text = ""
|
||||
if !it.isDir && !it.up {
|
||||
l.Text = humanBytes(it.size)
|
||||
}
|
||||
case colModified:
|
||||
l.Text = ""
|
||||
if !it.up && !it.mod.IsZero() {
|
||||
l.Text = it.mod.Format("2006-01-02 15:04")
|
||||
}
|
||||
}
|
||||
l.Refresh()
|
||||
}
|
||||
|
||||
func (p *pane) updateHeader(id widget.TableCellID, o fyne.CanvasObject) {
|
||||
l, ok := o.(*widget.Label)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
l.Alignment = fyne.TextAlignLeading
|
||||
l.Text = ""
|
||||
switch id.Col {
|
||||
case colMark:
|
||||
l.Alignment = fyne.TextAlignCenter
|
||||
l.Text = "✓"
|
||||
case colName:
|
||||
l.Text = "Name"
|
||||
case colSize:
|
||||
l.Alignment = fyne.TextAlignTrailing
|
||||
l.Text = "Size"
|
||||
case colModified:
|
||||
l.Text = "Modified"
|
||||
}
|
||||
l.Refresh()
|
||||
}
|
||||
|
||||
// onSelect turns a cell click into the action that cell means: the tick column
|
||||
// marks a row for transfer, a directory name descends into it, and anything
|
||||
// else just moves the highlight.
|
||||
func (p *pane) onSelect(id widget.TableCellID) {
|
||||
if p.onFocus != nil {
|
||||
p.onFocus(p)
|
||||
}
|
||||
if id.Row < 0 || id.Row >= len(p.entries) {
|
||||
return
|
||||
}
|
||||
p.cursor = id.Row
|
||||
it := p.entries[id.Row]
|
||||
|
||||
switch {
|
||||
case id.Col == colMark && !it.up:
|
||||
if p.marked[it.name] {
|
||||
delete(p.marked, it.name)
|
||||
} else {
|
||||
p.marked[it.name] = true
|
||||
}
|
||||
p.table.Refresh()
|
||||
p.updateFooter()
|
||||
case id.Col == colName && it.up:
|
||||
p.up()
|
||||
case id.Col == colName && it.isDir:
|
||||
p.navigate(p.fs.Join(p.cwd, it.name))
|
||||
}
|
||||
}
|
||||
|
||||
// --- navigation ---
|
||||
|
||||
func (p *pane) up() {
|
||||
if p.fs.IsRoot(p.cwd) {
|
||||
return
|
||||
}
|
||||
p.navigate(p.fs.Dir(p.cwd))
|
||||
}
|
||||
|
||||
func (p *pane) reload() { p.navigateKeepMarks(p.cwd, true) }
|
||||
|
||||
// navigate moves to dir, clearing marks — they refer to names in the directory
|
||||
// being left, so carrying them across would transfer the wrong thing.
|
||||
func (p *pane) navigate(dir string) { p.navigateKeepMarks(dir, false) }
|
||||
|
||||
// navigateKeepMarks does the directory read off the UI thread. Remote listings
|
||||
// are network round-trips, and a GUI that freezes while one is in flight is the
|
||||
// main thing that makes an SFTP client feel slow.
|
||||
func (p *pane) navigateKeepMarks(dir string, keepMarks bool) {
|
||||
p.pathEntry.SetText(dir)
|
||||
go func() {
|
||||
entries, err := readDir(p.fs, dir)
|
||||
fyne.Do(func() {
|
||||
if err != nil {
|
||||
// Put the box back to where we actually still are.
|
||||
p.pathEntry.SetText(p.cwd)
|
||||
p.onError(err)
|
||||
return
|
||||
}
|
||||
sameDir := dir == p.cwd
|
||||
p.cwd = dir
|
||||
p.entries = entries
|
||||
if !keepMarks || !sameDir {
|
||||
p.marked = map[string]bool{}
|
||||
} else {
|
||||
p.pruneMarks()
|
||||
}
|
||||
p.table.UnselectAll()
|
||||
p.cursor = -1
|
||||
p.table.Refresh()
|
||||
if !sameDir {
|
||||
p.table.ScrollToTop()
|
||||
}
|
||||
p.updateFooter()
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// pruneMarks drops marks for names that no longer exist after a refresh.
|
||||
func (p *pane) pruneMarks() {
|
||||
present := make(map[string]bool, len(p.entries))
|
||||
for _, it := range p.entries {
|
||||
present[it.name] = true
|
||||
}
|
||||
for name := range p.marked {
|
||||
if !present[name] {
|
||||
delete(p.marked, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readDir lists dir, sorting directories first then by name, and prepends the
|
||||
// ".." row unless dir is a root.
|
||||
func readDir(filesys vfs.FS, dir string) ([]item, error) {
|
||||
infos, err := filesys.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]item, 0, len(infos)+1)
|
||||
for _, fi := range infos {
|
||||
items = append(items, item{
|
||||
name: fi.Name(),
|
||||
isDir: fi.IsDir(),
|
||||
size: fi.Size(),
|
||||
mod: fi.ModTime(),
|
||||
})
|
||||
}
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].isDir != items[j].isDir {
|
||||
return items[i].isDir
|
||||
}
|
||||
return items[i].name < items[j].name
|
||||
})
|
||||
if !filesys.IsRoot(dir) {
|
||||
items = append([]item{{name: "..", isDir: true, up: true}}, items...)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// --- selection ---
|
||||
|
||||
// sources returns the absolute paths a transfer should act on: everything
|
||||
// marked, or the highlighted row if nothing is marked. That second case is what
|
||||
// makes the common "send this one file" case a two-click operation.
|
||||
func (p *pane) sources() []string {
|
||||
if len(p.marked) > 0 {
|
||||
out := make([]string, 0, len(p.marked))
|
||||
// Walk entries, not the map, so the order matches what's on screen.
|
||||
for _, it := range p.entries {
|
||||
if p.marked[it.name] {
|
||||
out = append(out, p.fs.Join(p.cwd, it.name))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
if it, ok := p.highlighted(); ok && !it.up {
|
||||
return []string{p.fs.Join(p.cwd, it.name)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// highlighted returns the row the user last clicked, if it is still in range.
|
||||
func (p *pane) highlighted() (item, bool) {
|
||||
if p.cursor < 0 || p.cursor >= len(p.entries) {
|
||||
return item{}, false
|
||||
}
|
||||
return p.entries[p.cursor], true
|
||||
}
|
||||
|
||||
func (p *pane) updateFooter() {
|
||||
var files, dirs int
|
||||
var bytes int64
|
||||
for _, it := range p.entries {
|
||||
switch {
|
||||
case it.up:
|
||||
case it.isDir:
|
||||
dirs++
|
||||
default:
|
||||
files++
|
||||
bytes += it.size
|
||||
}
|
||||
}
|
||||
text := fmt.Sprintf("%d dirs, %d files (%s)", dirs, files, humanBytes(bytes))
|
||||
if n := len(p.marked); n > 0 {
|
||||
text += fmt.Sprintf(" — %d marked", n)
|
||||
}
|
||||
p.footer.SetText(text)
|
||||
}
|
||||
|
||||
// --- file operations ---
|
||||
|
||||
func (p *pane) promptMkdir() {
|
||||
entry := widget.NewEntry()
|
||||
entry.SetPlaceHolder("name")
|
||||
formItem := widget.NewFormItem("Folder", entry)
|
||||
d := newFormDialog("New folder in "+p.cwd, "Create", []*widget.FormItem{formItem}, p.win, func(ok bool) {
|
||||
if !ok || entry.Text == "" {
|
||||
return
|
||||
}
|
||||
p.runOp(func() error { return p.fs.Mkdir(p.fs.Join(p.cwd, entry.Text)) })
|
||||
})
|
||||
d.Show()
|
||||
}
|
||||
|
||||
func (p *pane) promptRename() {
|
||||
it, ok := p.highlighted()
|
||||
if !ok || it.up {
|
||||
p.onError(fmt.Errorf("select a file or folder to rename"))
|
||||
return
|
||||
}
|
||||
entry := widget.NewEntry()
|
||||
entry.SetText(it.name)
|
||||
formItem := widget.NewFormItem("New name", entry)
|
||||
d := newFormDialog("Rename "+it.name, "Rename", []*widget.FormItem{formItem}, p.win, func(ok bool) {
|
||||
if !ok || entry.Text == "" || entry.Text == it.name {
|
||||
return
|
||||
}
|
||||
p.runOp(func() error {
|
||||
return p.fs.Rename(p.fs.Join(p.cwd, it.name), p.fs.Join(p.cwd, entry.Text))
|
||||
})
|
||||
})
|
||||
d.Show()
|
||||
}
|
||||
|
||||
func (p *pane) promptDelete() {
|
||||
targets := p.sources()
|
||||
if len(targets) == 0 {
|
||||
p.onError(fmt.Errorf("select or mark something to delete"))
|
||||
return
|
||||
}
|
||||
msg := fmt.Sprintf("Delete %d item(s) from %s?\n\nDirectories are deleted with everything inside them. This cannot be undone.",
|
||||
len(targets), p.title)
|
||||
confirmDialog("Confirm delete", msg, p.win, func(ok bool) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p.runOp(func() error {
|
||||
for _, t := range targets {
|
||||
if err := p.fs.Remove(t); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// runOp performs a filesystem mutation off the UI thread and refreshes the pane
|
||||
// when it finishes.
|
||||
func (p *pane) runOp(fn func() error) {
|
||||
go func() {
|
||||
err := fn()
|
||||
fyne.Do(func() {
|
||||
if err != nil {
|
||||
p.onError(err)
|
||||
}
|
||||
p.marked = map[string]bool{}
|
||||
p.reload()
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// humanBytes formats a byte count in units that fit a narrow column.
|
||||
func humanBytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for m := n / unit; m >= unit && exp < 4; m /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTP"[exp])
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Package ui is haul's desktop GUI: a connection window backed by ~/.ssh/config
|
||||
// (plus ad-hoc quick connect), and a two-pane local/remote file browser per
|
||||
// connected host.
|
||||
//
|
||||
// The rule the whole package follows: Fyne widgets are only ever touched from
|
||||
// the UI thread. Every SFTP call — listing a directory, transferring, deleting
|
||||
// — runs on its own goroutine and comes back through fyne.Do. A GUI that blocks
|
||||
// its event loop on a network round-trip is exactly the thing that makes an
|
||||
// SFTP client feel slow, and remote round-trips are the common case here.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
)
|
||||
|
||||
// Run starts the GUI and blocks until the user quits.
|
||||
func Run() error {
|
||||
a := app.NewWithID("space.apointless.haul")
|
||||
a.Settings().SetTheme(&haulTheme{base: theme.DefaultTheme()})
|
||||
newConnectWindow(a).showAndRun()
|
||||
return nil
|
||||
}
|
||||
|
||||
// haulTheme keeps the high-contrast, low-light-friendly look haul had as a TUI:
|
||||
// a near-black background, bright text, and a clearly visible selection. It also
|
||||
// tightens padding, because a file manager lives or dies on how many rows fit on
|
||||
// screen.
|
||||
type haulTheme struct{ base fyne.Theme }
|
||||
|
||||
var (
|
||||
bgDark = color.NRGBA{R: 0x11, G: 0x13, B: 0x18, A: 0xff}
|
||||
panelDark = color.NRGBA{R: 0x1a, G: 0x1d, B: 0x24, A: 0xff}
|
||||
inputDark = color.NRGBA{R: 0x0b, G: 0x0d, B: 0x11, A: 0xff}
|
||||
fgDark = color.NRGBA{R: 0xf0, G: 0xf3, B: 0xf8, A: 0xff}
|
||||
mutedDark = color.NRGBA{R: 0x8b, G: 0x93, B: 0xa1, A: 0xff}
|
||||
separatorDark = color.NRGBA{R: 0x2c, G: 0x31, B: 0x3a, A: 0xff}
|
||||
accent = color.NRGBA{R: 0x4c, G: 0x9a, B: 0xff, A: 0xff}
|
||||
selectionDark = color.NRGBA{R: 0x1e, G: 0x3f, B: 0x66, A: 0xff}
|
||||
hoverDark = color.NRGBA{R: 0x25, G: 0x2a, B: 0x33, A: 0xff}
|
||||
)
|
||||
|
||||
func (t *haulTheme) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color {
|
||||
if v == theme.VariantLight {
|
||||
// In light mode only the accent is overridden; the stock light palette
|
||||
// already has the contrast this theme is trying to buy back in the dark.
|
||||
if n == theme.ColorNamePrimary {
|
||||
return accent
|
||||
}
|
||||
return t.base.Color(n, v)
|
||||
}
|
||||
|
||||
switch n {
|
||||
case theme.ColorNameBackground:
|
||||
return bgDark
|
||||
case theme.ColorNameForeground:
|
||||
return fgDark
|
||||
case theme.ColorNameButton, theme.ColorNameMenuBackground, theme.ColorNameOverlayBackground:
|
||||
return panelDark
|
||||
case theme.ColorNameHeaderBackground:
|
||||
return panelDark
|
||||
case theme.ColorNameInputBackground:
|
||||
return inputDark
|
||||
case theme.ColorNamePrimary:
|
||||
return accent
|
||||
case theme.ColorNameSelection:
|
||||
return selectionDark
|
||||
case theme.ColorNameHover:
|
||||
return hoverDark
|
||||
case theme.ColorNameSeparator:
|
||||
return separatorDark
|
||||
case theme.ColorNamePlaceHolder, theme.ColorNameDisabled:
|
||||
return mutedDark
|
||||
case theme.ColorNameScrollBar:
|
||||
return separatorDark
|
||||
}
|
||||
return t.base.Color(n, v)
|
||||
}
|
||||
|
||||
func (t *haulTheme) Font(s fyne.TextStyle) fyne.Resource { return t.base.Font(s) }
|
||||
|
||||
func (t *haulTheme) Icon(n fyne.ThemeIconName) fyne.Resource { return t.base.Icon(n) }
|
||||
|
||||
func (t *haulTheme) Size(n fyne.ThemeSizeName) float32 {
|
||||
switch n {
|
||||
case theme.SizeNamePadding:
|
||||
return 3 // stock is 4; buys roughly one extra row per screenful
|
||||
case theme.SizeNameInnerPadding:
|
||||
return 6
|
||||
case theme.SizeNameSeparatorThickness:
|
||||
return 1
|
||||
}
|
||||
return t.base.Size(n)
|
||||
}
|
||||
Reference in New Issue
Block a user