Files
bsncubed 6b52e83bdc 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>
2026-07-29 23:21:21 +10:00

443 lines
11 KiB
Go

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])
}