993 lines
20 KiB
Go
993 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/lixenwraith/terminal"
|
|
"github.com/lixenwraith/terminal/tui"
|
|
)
|
|
|
|
const (
|
|
hexLoadCap = 8 << 20 // full-view byte cap; larger files load truncated
|
|
previewCap = 4096 // preview read size
|
|
maxMatches = 5000 // search result cap
|
|
toastFrames = 25 // ~2.5 s at 10 fps
|
|
)
|
|
|
|
// --- model -----------------------------------------------------------------
|
|
|
|
type sortMode uint8
|
|
|
|
const (
|
|
sortName sortMode = iota
|
|
sortSize
|
|
sortTime
|
|
sortModeCount
|
|
)
|
|
|
|
func (s sortMode) String() string { return [...]string{"name", "size", "time"}[s] }
|
|
|
|
type entry struct {
|
|
name string
|
|
dir bool
|
|
symlink bool
|
|
size int64
|
|
mode fs.FileMode
|
|
mtime time.Time
|
|
broken bool // stat failed
|
|
}
|
|
|
|
type browser struct {
|
|
cwd string
|
|
all []entry // sorted superset
|
|
view []int // indices into all, after hidden+filter
|
|
cursor int // index into view
|
|
scroll int
|
|
marks map[string]bool
|
|
filter string
|
|
sortBy sortMode
|
|
showHidden bool
|
|
|
|
parent []entry // left column
|
|
parentSel int
|
|
}
|
|
|
|
type previewKind uint8
|
|
|
|
const (
|
|
pvNone previewKind = iota
|
|
pvText
|
|
pvHex
|
|
pvDir
|
|
pvErr
|
|
)
|
|
|
|
type preview struct {
|
|
kind previewKind
|
|
path string // cache key
|
|
lines []string
|
|
raw []byte
|
|
entries []entry
|
|
info fs.FileInfo
|
|
errMsg string
|
|
}
|
|
|
|
type hexView struct {
|
|
path string
|
|
data []byte
|
|
truncated bool
|
|
cursor int
|
|
scrollRow int
|
|
bpr int // bytes/row, set by render
|
|
visRows int // set by render
|
|
starts []int
|
|
patLen int
|
|
matchIdx int
|
|
query string
|
|
queryHex bool
|
|
}
|
|
|
|
type viewMode uint8
|
|
|
|
const (
|
|
viewBrowse viewMode = iota
|
|
viewHex
|
|
)
|
|
|
|
type overlayMode uint8
|
|
|
|
const (
|
|
ovNone overlayMode = iota
|
|
ovPrompt
|
|
ovConfirm
|
|
ovHelp
|
|
)
|
|
|
|
type promptKind uint8
|
|
|
|
const (
|
|
prFilter promptKind = iota
|
|
prRename
|
|
prMkdir
|
|
prHexSearch
|
|
)
|
|
|
|
type rect struct{ x, y, w, h int }
|
|
|
|
func (r rect) contains(x, y int) bool { return x >= r.x && x < r.x+r.w && y >= r.y && y < r.y+r.h }
|
|
|
|
type appState struct {
|
|
term terminal.Terminal
|
|
width int
|
|
height int
|
|
frame int
|
|
quit bool
|
|
execShell bool
|
|
|
|
themeIdx int
|
|
view viewMode
|
|
overlay overlayMode
|
|
|
|
br browser
|
|
pv preview
|
|
hx hexView
|
|
|
|
prompt promptKind
|
|
promptTF *tui.TextFieldState
|
|
confirm *tui.ConfirmState
|
|
confirmMsg string
|
|
onConfirm func()
|
|
|
|
toast tui.ToastState
|
|
pendingG bool
|
|
clip []string
|
|
|
|
geom struct{ list, parent rect } // last-rendered hit-test rects
|
|
}
|
|
|
|
func newApp(t terminal.Terminal, cwd string) *appState {
|
|
w, h := t.Size()
|
|
return &appState{
|
|
term: t, width: w, height: h,
|
|
br: browser{cwd: cwd, marks: map[string]bool{}},
|
|
}
|
|
}
|
|
|
|
// --- filesystem ------------------------------------------------------------
|
|
|
|
func readEntries(dir string) ([]entry, error) {
|
|
des, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]entry, 0, len(des))
|
|
for _, de := range des {
|
|
e := entry{name: de.Name(), dir: de.IsDir(), symlink: de.Type()&fs.ModeSymlink != 0}
|
|
if fi, err := de.Info(); err == nil {
|
|
e.size, e.mode, e.mtime = fi.Size(), fi.Mode(), fi.ModTime()
|
|
} else {
|
|
e.broken = true
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *appState) sortEntries(es []entry) {
|
|
by := a.br.sortBy
|
|
slices.SortFunc(es, func(x, y entry) int {
|
|
if x.dir != y.dir { // dirs first, always
|
|
if x.dir {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
switch by {
|
|
case sortSize:
|
|
if c := cmp.Compare(y.size, x.size); c != 0 {
|
|
return c
|
|
}
|
|
case sortTime:
|
|
if c := y.mtime.Compare(x.mtime); c != 0 {
|
|
return c
|
|
}
|
|
}
|
|
return cmp.Compare(strings.ToLower(x.name), strings.ToLower(y.name))
|
|
})
|
|
}
|
|
|
|
// loadDir replaces browser state; keep selects the cursor entry by name.
|
|
func (a *appState) loadDir(dir, keep string) error {
|
|
es, err := readEntries(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.br.cwd = dir
|
|
a.br.all = es
|
|
a.sortEntries(a.br.all)
|
|
a.br.filter = ""
|
|
clear(a.br.marks)
|
|
a.rebuildView(keep)
|
|
a.loadParent()
|
|
a.pv.path = "" // invalidate preview cache
|
|
return nil
|
|
}
|
|
|
|
func (a *appState) loadParent() {
|
|
p := filepath.Dir(a.br.cwd)
|
|
if p == a.br.cwd { // at root
|
|
a.br.parent = []entry{{name: "/", dir: true}}
|
|
a.br.parentSel = 0
|
|
return
|
|
}
|
|
es, err := readEntries(p)
|
|
if err != nil {
|
|
a.br.parent, a.br.parentSel = nil, 0
|
|
return
|
|
}
|
|
a.sortEntries(es)
|
|
// parent column shows dirs only (lf-style)
|
|
dirs := es[:0]
|
|
for _, e := range es {
|
|
if e.dir {
|
|
dirs = append(dirs, e)
|
|
}
|
|
}
|
|
a.br.parent = dirs
|
|
base := filepath.Base(a.br.cwd)
|
|
a.br.parentSel = 0
|
|
for i, e := range dirs {
|
|
if e.name == base {
|
|
a.br.parentSel = i
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *appState) rebuildView(keep string) {
|
|
b := &a.br
|
|
b.view = b.view[:0]
|
|
f := strings.ToLower(b.filter)
|
|
for i, e := range b.all {
|
|
if !b.showHidden && strings.HasPrefix(e.name, ".") {
|
|
continue
|
|
}
|
|
if f != "" && !strings.Contains(strings.ToLower(e.name), f) {
|
|
continue
|
|
}
|
|
b.view = append(b.view, i)
|
|
}
|
|
b.cursor = 0
|
|
if keep != "" {
|
|
for vi, i := range b.view {
|
|
if b.all[i].name == keep {
|
|
b.cursor = vi
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if b.cursor >= len(b.view) {
|
|
b.cursor = max(0, len(b.view)-1)
|
|
}
|
|
b.scroll = 0
|
|
}
|
|
|
|
func (a *appState) cursorEntry() (entry, bool) {
|
|
b := &a.br
|
|
if b.cursor < 0 || b.cursor >= len(b.view) {
|
|
return entry{}, false
|
|
}
|
|
return b.all[b.view[b.cursor]], true
|
|
}
|
|
|
|
func (a *appState) cursorPath() (string, bool) {
|
|
e, ok := a.cursorEntry()
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
return filepath.Join(a.br.cwd, e.name), true
|
|
}
|
|
|
|
// targetSet returns marked entries, or the cursor entry if none marked.
|
|
func (a *appState) targetSet() []entry {
|
|
var out []entry
|
|
for _, i := range a.br.view {
|
|
if a.br.marks[a.br.all[i].name] {
|
|
out = append(out, a.br.all[i])
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
if e, ok := a.cursorEntry(); ok {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- preview ---------------------------------------------------------------
|
|
|
|
func (a *appState) refreshPreview() {
|
|
path, ok := a.cursorPath()
|
|
if !ok {
|
|
a.pv = preview{kind: pvNone, path: ""}
|
|
return
|
|
}
|
|
if a.pv.path == path {
|
|
return // cached
|
|
}
|
|
pv := preview{path: path}
|
|
fi, err := os.Lstat(path)
|
|
if err != nil {
|
|
pv.kind, pv.errMsg = pvErr, err.Error()
|
|
a.pv = pv
|
|
return
|
|
}
|
|
pv.info = fi
|
|
e, _ := a.cursorEntry()
|
|
if e.dir {
|
|
es, err := readEntries(path)
|
|
if err != nil {
|
|
pv.kind, pv.errMsg = pvErr, err.Error()
|
|
} else {
|
|
a.sortEntries(es)
|
|
if len(es) > 64 {
|
|
es = es[:64]
|
|
}
|
|
pv.kind, pv.entries = pvDir, es
|
|
}
|
|
a.pv = pv
|
|
return
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
pv.kind, pv.errMsg = pvErr, err.Error()
|
|
a.pv = pv
|
|
return
|
|
}
|
|
buf := make([]byte, previewCap)
|
|
n, _ := io.ReadFull(f, buf)
|
|
f.Close()
|
|
pv.raw = buf[:n]
|
|
if isBinary(pv.raw) {
|
|
pv.kind = pvHex
|
|
} else {
|
|
pv.kind = pvText
|
|
pv.lines = textLines(pv.raw, 200)
|
|
}
|
|
a.pv = pv
|
|
}
|
|
|
|
func isBinary(b []byte) bool {
|
|
if len(b) == 0 {
|
|
return false
|
|
}
|
|
n := min(len(b), 1024)
|
|
bad := 0
|
|
for _, c := range b[:n] {
|
|
if c == 0 {
|
|
return true
|
|
}
|
|
if c < 0x09 || (c > 0x0d && c < 0x20) {
|
|
bad++
|
|
}
|
|
}
|
|
return bad*10 > n*3
|
|
}
|
|
|
|
func textLines(b []byte, maxLines int) []string {
|
|
s := strings.ReplaceAll(string(b), "\t", " ")
|
|
lines := strings.Split(s, "\n")
|
|
if len(lines) > maxLines {
|
|
lines = lines[:maxLines]
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// --- hex viewer ------------------------------------------------------------
|
|
|
|
// openHex loads the file (capped), rendering a determinate progress overlay
|
|
// between chunks — synchronous, so no cross-goroutine state.
|
|
func (a *appState) openHex(path string) {
|
|
fi, err := os.Stat(path)
|
|
if err != nil {
|
|
a.say(tui.ToastError, err.Error())
|
|
return
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
a.say(tui.ToastError, err.Error())
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
total := fi.Size()
|
|
loadN := min(total, int64(hexLoadCap))
|
|
data := make([]byte, 0, loadN)
|
|
buf := make([]byte, 256<<10)
|
|
var read int64
|
|
for read < loadN {
|
|
want := min(int64(len(buf)), loadN-read)
|
|
n, err := f.Read(buf[:want])
|
|
if n > 0 {
|
|
data = append(data, buf[:n]...)
|
|
read += int64(n)
|
|
}
|
|
a.drawLoadFrame(filepath.Base(path), float64(read)/float64(max(loadN, 1)))
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
a.hx = hexView{path: path, data: data, truncated: total > loadN, matchIdx: -1}
|
|
a.view = viewHex
|
|
}
|
|
|
|
func decodeHexQuery(s string) ([]byte, bool) {
|
|
t := strings.ToLower(strings.NewReplacer(" ", "", "\t", "", "0x", "").Replace(s))
|
|
if t == "" || len(t)%2 != 0 {
|
|
return nil, false
|
|
}
|
|
out := make([]byte, len(t)/2)
|
|
for i := 0; i < len(t); i += 2 {
|
|
hi, ok1 := hexNibble(t[i])
|
|
lo, ok2 := hexNibble(t[i+1])
|
|
if !ok1 || !ok2 {
|
|
return nil, false
|
|
}
|
|
out[i/2] = hi<<4 | lo
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
func hexNibble(c byte) (byte, bool) {
|
|
switch {
|
|
case c >= '0' && c <= '9':
|
|
return c - '0', true
|
|
case c >= 'a' && c <= 'f':
|
|
return c - 'a' + 10, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func (a *appState) hexSearch(q string) {
|
|
h := &a.hx
|
|
pat, isHex := decodeHexQuery(q)
|
|
if !isHex {
|
|
pat = []byte(q)
|
|
}
|
|
h.query, h.queryHex, h.patLen = q, isHex, len(pat)
|
|
h.starts = h.starts[:0]
|
|
for off := 0; len(h.starts) < maxMatches; {
|
|
i := indexBytes(h.data[off:], pat)
|
|
if i < 0 {
|
|
break
|
|
}
|
|
h.starts = append(h.starts, off+i)
|
|
off += i + 1
|
|
}
|
|
if len(h.starts) == 0 {
|
|
h.matchIdx = -1
|
|
a.say(tui.ToastWarning, "no match: "+q)
|
|
return
|
|
}
|
|
// jump to first match at or after cursor, wrapping
|
|
i := sort.SearchInts(h.starts, h.cursor) % len(h.starts)
|
|
h.matchIdx = i
|
|
h.cursor = h.starts[i]
|
|
a.hexEnsureVisible()
|
|
a.say(tui.ToastSuccess, fmt.Sprintf("%d match(es)", len(h.starts)))
|
|
}
|
|
|
|
func indexBytes(hay, pat []byte) int {
|
|
if len(pat) == 0 || len(pat) > len(hay) {
|
|
return -1
|
|
}
|
|
return strings.Index(string(hay), string(pat)) // zero-copy in map/index contexts is not needed here; sizes are capped
|
|
}
|
|
|
|
func (a *appState) hexEnsureVisible() {
|
|
h := &a.hx
|
|
if h.bpr <= 0 || h.visRows <= 0 {
|
|
return
|
|
}
|
|
row := h.cursor / h.bpr
|
|
if row < h.scrollRow {
|
|
h.scrollRow = row
|
|
}
|
|
if row >= h.scrollRow+h.visRows {
|
|
h.scrollRow = row - h.visRows + 1
|
|
}
|
|
maxRow := (len(h.data) + h.bpr - 1) / h.bpr
|
|
h.scrollRow = max(0, min(h.scrollRow, max(0, maxRow-h.visRows)))
|
|
}
|
|
|
|
func (a *appState) hexMove(delta int) {
|
|
h := &a.hx
|
|
if len(h.data) == 0 {
|
|
return
|
|
}
|
|
h.cursor = max(0, min(len(h.data)-1, h.cursor+delta))
|
|
a.hexEnsureVisible()
|
|
}
|
|
|
|
// --- prompts / confirm / toast --------------------------------------------
|
|
|
|
func (a *appState) openPrompt(k promptKind, initial string) {
|
|
a.prompt = k
|
|
a.promptTF = tui.NewTextFieldState(initial)
|
|
a.overlay = ovPrompt
|
|
}
|
|
|
|
func (a *appState) openConfirm(msg string, fn func()) {
|
|
a.confirm = tui.NewConfirmState(false)
|
|
a.confirmMsg = msg
|
|
a.onConfirm = fn
|
|
a.overlay = ovConfirm
|
|
}
|
|
|
|
func (a *appState) say(sev tui.ToastSeverity, msg string) {
|
|
o := tui.DefaultToastOpts(msg, sev)
|
|
o.Position = tui.ToastBottomRight
|
|
o.Style = tui.ToastStyleRounded
|
|
a.toast.Show(o, toastFrames)
|
|
}
|
|
|
|
// --- operations ------------------------------------------------------------
|
|
|
|
func (a *appState) opEnter() {
|
|
e, ok := a.cursorEntry()
|
|
if !ok {
|
|
return
|
|
}
|
|
path := filepath.Join(a.br.cwd, e.name)
|
|
if e.dir {
|
|
if err := a.loadDir(path, ""); err != nil {
|
|
a.say(tui.ToastError, err.Error())
|
|
}
|
|
return
|
|
}
|
|
a.openHex(path)
|
|
}
|
|
|
|
func (a *appState) opParent() {
|
|
p := filepath.Dir(a.br.cwd)
|
|
if p == a.br.cwd {
|
|
return
|
|
}
|
|
keep := filepath.Base(a.br.cwd)
|
|
if err := a.loadDir(p, keep); err != nil {
|
|
a.say(tui.ToastError, err.Error())
|
|
}
|
|
}
|
|
|
|
func (a *appState) opDelete() {
|
|
ts := a.targetSet()
|
|
if len(ts) == 0 {
|
|
return
|
|
}
|
|
msg := fmt.Sprintf("Delete %d item(s)? (files and empty dirs only)", len(ts))
|
|
if len(ts) == 1 {
|
|
msg = "Delete '" + ts[0].name + "'?"
|
|
}
|
|
names := make([]string, len(ts))
|
|
for i, e := range ts {
|
|
names[i] = e.name
|
|
}
|
|
a.openConfirm(msg, func() {
|
|
ok, fail := 0, 0
|
|
for _, n := range names {
|
|
if err := os.Remove(filepath.Join(a.br.cwd, n)); err != nil {
|
|
fail++
|
|
} else {
|
|
ok++
|
|
}
|
|
}
|
|
keep := ""
|
|
if e, has := a.cursorEntry(); has {
|
|
keep = e.name
|
|
}
|
|
_ = a.loadDir(a.br.cwd, keep)
|
|
if fail > 0 {
|
|
a.say(tui.ToastWarning, fmt.Sprintf("deleted %d, failed %d", ok, fail))
|
|
} else {
|
|
a.say(tui.ToastSuccess, fmt.Sprintf("deleted %d", ok))
|
|
}
|
|
})
|
|
}
|
|
|
|
func (a *appState) commitPrompt() {
|
|
val := strings.TrimSpace(a.promptTF.Value())
|
|
k := a.prompt
|
|
a.overlay = ovNone
|
|
switch k {
|
|
case prFilter:
|
|
// live filter already applied per keystroke
|
|
case prHexSearch:
|
|
if val != "" {
|
|
a.hexSearch(val)
|
|
}
|
|
case prMkdir:
|
|
if val == "" || strings.ContainsRune(val, os.PathSeparator) {
|
|
a.say(tui.ToastWarning, "invalid name")
|
|
return
|
|
}
|
|
if err := os.Mkdir(filepath.Join(a.br.cwd, val), 0o755); err != nil {
|
|
a.say(tui.ToastError, err.Error())
|
|
return
|
|
}
|
|
_ = a.loadDir(a.br.cwd, val)
|
|
a.say(tui.ToastSuccess, "created "+val)
|
|
case prRename:
|
|
e, ok := a.cursorEntry()
|
|
if !ok || val == "" || val == e.name || strings.ContainsRune(val, os.PathSeparator) {
|
|
return
|
|
}
|
|
if err := os.Rename(filepath.Join(a.br.cwd, e.name), filepath.Join(a.br.cwd, val)); err != nil {
|
|
a.say(tui.ToastError, err.Error())
|
|
return
|
|
}
|
|
_ = a.loadDir(a.br.cwd, val)
|
|
a.say(tui.ToastSuccess, "renamed → "+val)
|
|
}
|
|
}
|
|
|
|
// --- event handling ---------------------------------------------------------
|
|
|
|
func (a *appState) handleEvent(ev terminal.Event) {
|
|
switch ev.Type {
|
|
case terminal.EventResize:
|
|
a.width, a.height = ev.Width, ev.Height
|
|
return
|
|
case terminal.EventClosed, terminal.EventError:
|
|
a.quit = true
|
|
return
|
|
case terminal.EventMouse:
|
|
a.handleMouse(ev)
|
|
return
|
|
}
|
|
if ev.Type != terminal.EventKey {
|
|
return
|
|
}
|
|
if ev.Key == terminal.KeyNone { // synthetic tick
|
|
a.frame++
|
|
if a.toast.Visible {
|
|
a.toast.Tick()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Global
|
|
switch ev.Key {
|
|
case terminal.KeyCtrlC, terminal.KeyCtrlQ:
|
|
a.quit = true
|
|
return
|
|
case terminal.KeyCtrlL:
|
|
a.term.Sync()
|
|
return
|
|
}
|
|
|
|
switch a.overlay {
|
|
case ovHelp:
|
|
a.overlay = ovNone
|
|
return
|
|
case ovConfirm:
|
|
if a.confirm.HandleKey(ev.Key, ev.Rune) {
|
|
a.overlay = ovNone
|
|
if a.confirm.Result == tui.ConfirmYes && a.onConfirm != nil {
|
|
a.onConfirm()
|
|
}
|
|
a.onConfirm = nil
|
|
}
|
|
return
|
|
case ovPrompt:
|
|
switch ev.Key {
|
|
case terminal.KeyEscape:
|
|
if a.prompt == prFilter {
|
|
a.br.filter = ""
|
|
a.rebuildView(currentName(a))
|
|
}
|
|
a.overlay = ovNone
|
|
case terminal.KeyEnter:
|
|
a.commitPrompt()
|
|
default:
|
|
if a.promptTF.HandleKey(ev.Key, ev.Rune, ev.Modifiers) && a.prompt == prFilter {
|
|
a.br.filter = a.promptTF.Value()
|
|
a.rebuildView("")
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if a.view == viewHex {
|
|
a.handleHexKey(ev)
|
|
} else {
|
|
a.handleBrowseKey(ev)
|
|
}
|
|
}
|
|
|
|
func currentName(a *appState) string {
|
|
if e, ok := a.cursorEntry(); ok {
|
|
return e.name
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (a *appState) moveCursor(delta int) {
|
|
b := &a.br
|
|
if len(b.view) == 0 {
|
|
return
|
|
}
|
|
b.cursor = max(0, min(len(b.view)-1, b.cursor+delta))
|
|
}
|
|
|
|
func (a *appState) handleBrowseKey(ev terminal.Event) {
|
|
b := &a.br
|
|
pendG := a.pendingG
|
|
a.pendingG = false
|
|
|
|
switch ev.Key {
|
|
case terminal.KeyUp:
|
|
a.moveCursor(-1)
|
|
case terminal.KeyDown:
|
|
a.moveCursor(1)
|
|
case terminal.KeyLeft, terminal.KeyBackspace:
|
|
a.opParent()
|
|
case terminal.KeyRight, terminal.KeyEnter:
|
|
a.opEnter()
|
|
case terminal.KeyHome:
|
|
b.cursor = 0
|
|
case terminal.KeyEnd:
|
|
b.cursor = max(0, len(b.view)-1)
|
|
case terminal.KeyPageUp:
|
|
a.moveCursor(-tui.PageDelta(a.listVisible()) * 2)
|
|
case terminal.KeyPageDown:
|
|
a.moveCursor(tui.PageDelta(a.listVisible()) * 2)
|
|
case terminal.KeyCtrlU:
|
|
a.moveCursor(-tui.PageDelta(a.listVisible()))
|
|
case terminal.KeyCtrlD:
|
|
a.moveCursor(tui.PageDelta(a.listVisible()))
|
|
case terminal.KeyEscape:
|
|
if b.filter != "" {
|
|
b.filter = ""
|
|
a.rebuildView(currentName(a))
|
|
} else {
|
|
clear(b.marks)
|
|
}
|
|
case terminal.KeyRune:
|
|
switch ev.Rune {
|
|
case 'q':
|
|
a.quit = true
|
|
case 'Q':
|
|
a.quit, a.execShell = true, true
|
|
case 'j':
|
|
a.moveCursor(1)
|
|
case 'k':
|
|
a.moveCursor(-1)
|
|
case 'h':
|
|
a.opParent()
|
|
case 'l':
|
|
a.opEnter()
|
|
case 'g':
|
|
if pendG {
|
|
b.cursor = 0
|
|
} else {
|
|
a.pendingG = true
|
|
}
|
|
case 'G':
|
|
b.cursor = max(0, len(b.view)-1)
|
|
case '~':
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
if err := a.loadDir(home, ""); err != nil {
|
|
a.say(tui.ToastError, err.Error())
|
|
}
|
|
}
|
|
case ' ':
|
|
if e, ok := a.cursorEntry(); ok {
|
|
if b.marks[e.name] {
|
|
delete(b.marks, e.name)
|
|
} else {
|
|
b.marks[e.name] = true
|
|
}
|
|
a.moveCursor(1)
|
|
}
|
|
case 'a':
|
|
for _, i := range b.view {
|
|
b.marks[b.all[i].name] = true
|
|
}
|
|
case 'u':
|
|
clear(b.marks)
|
|
case '.':
|
|
b.showHidden = !b.showHidden
|
|
a.rebuildView(currentName(a))
|
|
case 's':
|
|
b.sortBy = (b.sortBy + 1) % sortModeCount
|
|
keep := currentName(a)
|
|
a.sortEntries(b.all)
|
|
a.rebuildView(keep)
|
|
case '/':
|
|
a.openPrompt(prFilter, b.filter)
|
|
case 'r':
|
|
if e, ok := a.cursorEntry(); ok {
|
|
a.openPrompt(prRename, e.name)
|
|
}
|
|
case 'm':
|
|
a.openPrompt(prMkdir, "")
|
|
case 'D':
|
|
a.opDelete()
|
|
case 'y':
|
|
ts := a.targetSet()
|
|
a.clip = a.clip[:0]
|
|
for _, e := range ts {
|
|
a.clip = append(a.clip, filepath.Join(b.cwd, e.name))
|
|
}
|
|
a.say(tui.ToastInfo, fmt.Sprintf("yanked %d path(s)", len(a.clip)))
|
|
case 'T':
|
|
a.themeIdx = (a.themeIdx + 1) % len(palettes)
|
|
case '?':
|
|
a.overlay = ovHelp
|
|
}
|
|
}
|
|
// keep cursor entry sane after any mutation
|
|
if b.cursor >= len(b.view) {
|
|
b.cursor = max(0, len(b.view)-1)
|
|
}
|
|
}
|
|
|
|
func (a *appState) handleHexKey(ev terminal.Event) {
|
|
h := &a.hx
|
|
pendG := a.pendingG
|
|
a.pendingG = false
|
|
half := max(1, h.visRows/2) * h.bpr
|
|
|
|
switch ev.Key {
|
|
case terminal.KeyEscape:
|
|
a.view = viewBrowse
|
|
case terminal.KeyUp:
|
|
a.hexMove(-h.bpr)
|
|
case terminal.KeyDown:
|
|
a.hexMove(h.bpr)
|
|
case terminal.KeyLeft:
|
|
a.hexMove(-1)
|
|
case terminal.KeyRight:
|
|
a.hexMove(1)
|
|
case terminal.KeyHome:
|
|
h.cursor = 0
|
|
a.hexEnsureVisible()
|
|
case terminal.KeyEnd:
|
|
h.cursor = max(0, len(h.data)-1)
|
|
a.hexEnsureVisible()
|
|
case terminal.KeyPageUp:
|
|
a.hexMove(-h.visRows * h.bpr)
|
|
case terminal.KeyPageDown:
|
|
a.hexMove(h.visRows * h.bpr)
|
|
case terminal.KeyCtrlU:
|
|
a.hexMove(-half)
|
|
case terminal.KeyCtrlD:
|
|
a.hexMove(half)
|
|
case terminal.KeyRune:
|
|
switch ev.Rune {
|
|
case 'q', 'h':
|
|
if ev.Rune == 'h' {
|
|
a.hexMove(-1)
|
|
} else {
|
|
a.view = viewBrowse
|
|
}
|
|
case 'j':
|
|
a.hexMove(h.bpr)
|
|
case 'k':
|
|
a.hexMove(-h.bpr)
|
|
case 'l':
|
|
a.hexMove(1)
|
|
case '0':
|
|
a.hexMove(-(h.cursor % max(1, h.bpr)))
|
|
case '$':
|
|
if h.bpr > 0 {
|
|
a.hexMove(h.bpr - 1 - h.cursor%h.bpr)
|
|
}
|
|
case 'g':
|
|
if pendG {
|
|
h.cursor = 0
|
|
a.hexEnsureVisible()
|
|
} else {
|
|
a.pendingG = true
|
|
}
|
|
case 'G':
|
|
h.cursor = max(0, len(h.data)-1)
|
|
a.hexEnsureVisible()
|
|
case '/':
|
|
a.openPrompt(prHexSearch, h.query)
|
|
case 'n', 'N':
|
|
if len(h.starts) == 0 {
|
|
break
|
|
}
|
|
d := 1
|
|
if ev.Rune == 'N' {
|
|
d = -1
|
|
}
|
|
h.matchIdx = (h.matchIdx + d + len(h.starts)) % len(h.starts)
|
|
h.cursor = h.starts[h.matchIdx]
|
|
a.hexEnsureVisible()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *appState) handleMouse(ev terminal.Event) {
|
|
if ev.MouseAction != terminal.MouseActionPress {
|
|
return
|
|
}
|
|
switch ev.MouseBtn {
|
|
case terminal.MouseBtnWheelUp:
|
|
if a.view == viewHex {
|
|
a.hexMove(-3 * a.hx.bpr)
|
|
} else {
|
|
a.moveCursor(-3)
|
|
}
|
|
case terminal.MouseBtnWheelDown:
|
|
if a.view == viewHex {
|
|
a.hexMove(3 * a.hx.bpr)
|
|
} else {
|
|
a.moveCursor(3)
|
|
}
|
|
case terminal.MouseBtnLeft:
|
|
if a.view != viewBrowse || a.overlay != ovNone {
|
|
return
|
|
}
|
|
if a.geom.list.contains(ev.MouseX, ev.MouseY) {
|
|
idx := a.br.scroll + (ev.MouseY - a.geom.list.y)
|
|
if idx >= 0 && idx < len(a.br.view) {
|
|
if a.br.cursor == idx {
|
|
a.opEnter() // click on cursor row = open
|
|
} else {
|
|
a.br.cursor = idx
|
|
}
|
|
}
|
|
} else if a.geom.parent.contains(ev.MouseX, ev.MouseY) {
|
|
a.opParent()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *appState) listVisible() int { return a.geom.list.h }
|
|
|
|
// --- misc helpers -----------------------------------------------------------
|
|
|
|
func humanSize(n int64) string {
|
|
const unit = 1024
|
|
if n < unit {
|
|
return fmt.Sprintf("%d", n)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for m := n / unit; m >= unit; m /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
v := float64(n) / float64(div)
|
|
suffix := "KMGTPE"[exp]
|
|
if v < 10 {
|
|
return fmt.Sprintf("%.1f%c", v, suffix)
|
|
}
|
|
return fmt.Sprintf("%.0f%c", v, suffix)
|
|
}
|
|
|
|
func padRight(s string, w int) string {
|
|
n := 0
|
|
for range s {
|
|
n++
|
|
}
|
|
if n >= w {
|
|
return s
|
|
}
|
|
return s + strings.Repeat(" ", w-n)
|
|
}
|
|
|
|
func classify(r rune) bool { return unicode.IsPrint(r) }
|