224 lines
4.9 KiB
Go
224 lines
4.9 KiB
Go
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/lixenwraith/terminal"
|
|
"github.com/lixenwraith/terminal/tui"
|
|
"github.com/lixenwraith/vif-log/internal/ui"
|
|
)
|
|
|
|
// fileRow is one entry in the open panel.
|
|
type fileRow struct {
|
|
name string
|
|
dir bool
|
|
up bool
|
|
marked bool
|
|
size int64
|
|
mtime time.Time
|
|
}
|
|
|
|
// browser is the directory navigator behind the open overlay.
|
|
type browser struct {
|
|
dir string
|
|
rows []fileRow
|
|
panel ui.Panel
|
|
err string
|
|
}
|
|
|
|
// load reads dir: parent first, then directories, then files, each group most
|
|
// recently modified first — the log you just produced is the one you want.
|
|
func (b *browser) load(dir string) {
|
|
if abs, err := filepath.Abs(dir); err == nil {
|
|
dir = abs
|
|
}
|
|
b.dir, b.err, b.rows = dir, "", b.rows[:0]
|
|
|
|
des, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
b.err = err.Error()
|
|
}
|
|
if parent := filepath.Dir(dir); parent != dir {
|
|
b.rows = append(b.rows, fileRow{name: "..", dir: true, up: true})
|
|
}
|
|
|
|
rows := make([]fileRow, 0, len(des))
|
|
for _, de := range des {
|
|
if strings.HasPrefix(de.Name(), ".") {
|
|
continue
|
|
}
|
|
fi, err := de.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
rows = append(rows, fileRow{
|
|
name: de.Name(), dir: de.IsDir(), size: fi.Size(), mtime: fi.ModTime(),
|
|
})
|
|
}
|
|
slices.SortFunc(rows, func(x, y fileRow) int {
|
|
if x.dir != y.dir {
|
|
if x.dir {
|
|
return -1
|
|
}
|
|
return 1
|
|
}
|
|
return y.mtime.Compare(x.mtime)
|
|
})
|
|
|
|
b.rows = append(b.rows, rows...)
|
|
b.panel.Reset()
|
|
}
|
|
|
|
func (b *browser) selected() (fileRow, bool) {
|
|
if b.panel.Sel < 0 || b.panel.Sel >= len(b.rows) {
|
|
return fileRow{}, false
|
|
}
|
|
return b.rows[b.panel.Sel], true
|
|
}
|
|
|
|
// toggleMark selects a file for multi-open; directories are not markable.
|
|
func (b *browser) toggleMark() {
|
|
if b.panel.Sel < 0 || b.panel.Sel >= len(b.rows) || b.rows[b.panel.Sel].dir {
|
|
return
|
|
}
|
|
b.rows[b.panel.Sel].marked = !b.rows[b.panel.Sel].marked
|
|
b.panel.Move(1)
|
|
}
|
|
|
|
// marked returns the absolute paths of every marked file.
|
|
func (b *browser) marked() []string {
|
|
var out []string
|
|
for _, r := range b.rows {
|
|
if r.marked {
|
|
out = append(out, filepath.Join(b.dir, r.name))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// --- app hooks -------------------------------------------------------------
|
|
|
|
// openBrowser shows the file panel, refreshing the listing.
|
|
func (a *App) openBrowser() {
|
|
dir := a.browse.dir
|
|
if dir == "" {
|
|
dir = "."
|
|
}
|
|
a.browse.load(dir)
|
|
a.overlay = ovOpen
|
|
}
|
|
|
|
// browserEnter descends into the selected directory, or loads the marked set
|
|
// (falling back to the cursor file when nothing is marked).
|
|
func (a *App) browserEnter() {
|
|
if paths := a.browse.marked(); len(paths) > 0 {
|
|
if err := a.openFiles(paths); err != nil {
|
|
a.say(tui.ToastError, err.Error())
|
|
return
|
|
}
|
|
a.overlay = ovNone
|
|
return
|
|
}
|
|
row, ok := a.browse.selected()
|
|
if !ok {
|
|
return
|
|
}
|
|
path := filepath.Join(a.browse.dir, row.name)
|
|
if row.dir {
|
|
a.browse.load(path)
|
|
return
|
|
}
|
|
if err := a.openFiles([]string{path}); err != nil {
|
|
a.say(tui.ToastError, err.Error())
|
|
return
|
|
}
|
|
a.overlay = ovNone
|
|
}
|
|
|
|
func (a *App) browserUp() { a.browse.load(filepath.Dir(a.browse.dir)) }
|
|
|
|
// --- render ----------------------------------------------------------------
|
|
|
|
func (a *App) renderBrowser(root tui.Region) {
|
|
b := &a.browse
|
|
b.panel.Rows = len(b.rows)
|
|
b.panel.Title = "open - " + tui.TruncateLeft(b.dir, max(12, root.W/3))
|
|
b.panel.Hint = "spc mark enter open esc close"
|
|
b.panel.Status = b.status()
|
|
|
|
body := b.panel.Render(root, &a.th)
|
|
if body.W < 24 || body.H < 1 {
|
|
return
|
|
}
|
|
|
|
const sizeW, timeW = 7, 16
|
|
nameW := max(8, body.W-sizeW-timeW-2)
|
|
|
|
for y := 0; y < body.H; y++ {
|
|
i := b.panel.Scroll + y
|
|
if i >= len(b.rows) {
|
|
break
|
|
}
|
|
row := b.rows[i]
|
|
|
|
bg := a.th.FocusBg
|
|
if i == b.panel.Sel {
|
|
bg = a.th.CursorBg
|
|
}
|
|
for x := 0; x < body.W; x++ {
|
|
body.Cell(x, y, ' ', a.th.Fg, bg, terminal.AttrNone)
|
|
}
|
|
|
|
name, fg, attr := row.name, a.th.Fg, terminal.AttrNone
|
|
if row.dir {
|
|
name, fg, attr = name+"/", a.th.Accent, terminal.AttrBold
|
|
}
|
|
if row.marked {
|
|
name, fg, attr = "◆ "+name, a.th.Selected, terminal.AttrBold
|
|
}
|
|
body.Text(0, y, tui.Truncate(name, nameW), fg, bg, attr)
|
|
if !row.dir {
|
|
body.Text(nameW+1, y, tui.PadLeft(humanSize(row.size), sizeW),
|
|
a.th.NumFg, bg, terminal.AttrNone)
|
|
}
|
|
if !row.up {
|
|
body.Text(nameW+sizeW+2, y, row.mtime.Format("2006-01-02 15:04"),
|
|
a.th.HintFg, bg, terminal.AttrDim)
|
|
}
|
|
}
|
|
}
|
|
|
|
// status is the untruncated name of the selection, or the read error.
|
|
func (b *browser) status() string {
|
|
if b.err != "" {
|
|
return b.err
|
|
}
|
|
if row, ok := b.selected(); ok {
|
|
return row.name
|
|
}
|
|
return b.dir
|
|
}
|
|
|
|
// humanSize renders a byte count in at most six characters.
|
|
func humanSize(n int64) string {
|
|
const unit = 1024
|
|
if n < unit {
|
|
return fmt.Sprintf("%dB", n)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for m := n / unit; m >= unit; m /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
v := float64(n) / float64(div)
|
|
if v < 10 {
|
|
return fmt.Sprintf("%.1f%c", v, "KMGTPE"[exp])
|
|
}
|
|
return fmt.Sprintf("%.0f%c", v, "KMGTPE"[exp])
|
|
}
|