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,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])
|
||||
}
|
||||
Reference in New Issue
Block a user