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