125 lines
2.9 KiB
Go
125 lines
2.9 KiB
Go
package tui
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// entry is a single item in the file picker list.
|
|
type entry struct {
|
|
name string
|
|
isDir bool
|
|
}
|
|
|
|
// picker is the state for the send file picker page.
|
|
type picker struct {
|
|
dir string // current directory being browsed
|
|
entries []entry // unfiltered entries in dir
|
|
filtered []entry // entries matching query
|
|
query string // current filter string
|
|
cursor int // index within filtered
|
|
queryFocused bool // true = typing goes to query; false = list navigation only
|
|
}
|
|
|
|
func newPicker(startDir string) picker {
|
|
p := picker{dir: startDir, queryFocused: false}
|
|
p.entries = readDir(startDir)
|
|
p.filtered = p.entries
|
|
return p
|
|
}
|
|
|
|
// readDir lists the entries of a directory, dirs first then files.
|
|
func readDir(dir string) []entry {
|
|
infos, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var dirs, files []entry
|
|
for _, d := range infos {
|
|
name := d.Name()
|
|
if strings.HasPrefix(name, ".") {
|
|
continue // skip hidden
|
|
}
|
|
if d.IsDir() {
|
|
dirs = append(dirs, entry{name: name + "/", isDir: true})
|
|
} else {
|
|
files = append(files, entry{name: name, isDir: false})
|
|
}
|
|
}
|
|
return append(dirs, files...)
|
|
}
|
|
|
|
// applyFilter rebuilds filtered from entries using query.
|
|
func (p picker) applyFilter() picker {
|
|
if p.query == "" {
|
|
p.filtered = p.entries
|
|
} else {
|
|
q := strings.ToLower(p.query)
|
|
var out []entry
|
|
for _, e := range p.entries {
|
|
if strings.Contains(strings.ToLower(e.name), q) {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
p.filtered = out
|
|
}
|
|
p.cursor = 0
|
|
return p
|
|
}
|
|
|
|
// descend enters a subdirectory.
|
|
func (p picker) descend(name string) picker {
|
|
// strip trailing slash added for display
|
|
name = strings.TrimSuffix(name, "/")
|
|
next := filepath.Join(p.dir, name)
|
|
p.dir = next
|
|
p.entries = readDir(next)
|
|
p.query = ""
|
|
p.filtered = p.entries
|
|
p.cursor = 0
|
|
return p
|
|
}
|
|
|
|
// ascend goes up one directory level.
|
|
func (p picker) ascend() picker {
|
|
parent := filepath.Dir(p.dir)
|
|
if parent == p.dir {
|
|
return p // already at root
|
|
}
|
|
p.dir = parent
|
|
p.entries = readDir(parent)
|
|
p.query = ""
|
|
p.filtered = p.entries
|
|
p.cursor = 0
|
|
return p
|
|
}
|
|
|
|
// selectedPath returns the full path of the currently highlighted entry,
|
|
// or empty string if the list is empty.
|
|
func (p picker) selectedPath() string {
|
|
if len(p.filtered) == 0 || p.cursor < 0 || p.cursor >= len(p.filtered) {
|
|
return ""
|
|
}
|
|
e := p.filtered[p.cursor]
|
|
name := strings.TrimSuffix(e.name, "/")
|
|
return filepath.Join(p.dir, name)
|
|
}
|
|
|
|
// typeRune appends a rune to the query and re-filters.
|
|
func (p picker) typeRune(r rune) picker {
|
|
p.query += string(r)
|
|
return p.applyFilter()
|
|
}
|
|
|
|
// backspace removes the last rune from the query.
|
|
// If query is already empty, ascend instead.
|
|
func (p picker) backspace() (picker, bool) {
|
|
if p.query == "" {
|
|
return p.ascend(), false // false = did not consume (went up)
|
|
}
|
|
runes := []rune(p.query)
|
|
p.query = string(runes[:len(runes)-1])
|
|
return p.applyFilter(), true
|
|
}
|