255 lines
7.6 KiB
Go
255 lines
7.6 KiB
Go
package mujica
|
|
|
|
import (
|
|
"log/slog"
|
|
"path"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"mujicaui/icons"
|
|
"mujicaui/theme"
|
|
)
|
|
|
|
// FileEntry identifies a file or folder by slash-separated Path.
|
|
type FileEntry struct {
|
|
Path, Name string
|
|
Dir bool
|
|
Size int64
|
|
Modified time.Time
|
|
}
|
|
|
|
// FileExplorerOptions configure a FileExplorer.
|
|
type FileExplorerOptions struct {
|
|
// Roots are the top folders of the tree.
|
|
Roots []string
|
|
// Folders returns subfolders; required, nil means a leaf.
|
|
Folders func(dir string) []string
|
|
// Entries returns a folder's files and folders; required.
|
|
Entries func(dir string) []FileEntry
|
|
Disabled bool
|
|
// Revision changes when entries are edited in place.
|
|
Revision uint64
|
|
}
|
|
|
|
// FileExplorerState keeps navigation, selection and scroll position.
|
|
type FileExplorerState struct {
|
|
// Current defaults to the first root.
|
|
Current string
|
|
tree TreeState[string]
|
|
list ListState[string]
|
|
back, forward []string
|
|
data *FileEntry
|
|
n int
|
|
revision uint64
|
|
}
|
|
|
|
// Selected returns the chosen entries' paths.
|
|
func (s *FileExplorerState) Selected() []string {
|
|
var out []string
|
|
for p := range s.list.Selection.All() {
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Navigate records the current folder and shows dir.
|
|
func (s *FileExplorerState) Navigate(dir string) {
|
|
if dir == s.Current {
|
|
return
|
|
}
|
|
if s.Current != "" {
|
|
s.back = append(s.back, s.Current)
|
|
}
|
|
s.Current, s.forward = dir, nil
|
|
s.list.Selection.Clear()
|
|
slog.Debug("mujica: file_explorer navigate", "depth", strings.Count(dir, "/"))
|
|
}
|
|
|
|
func (s *FileExplorerState) step(from, to *[]string) {
|
|
n := len(*from)
|
|
if n == 0 {
|
|
return
|
|
}
|
|
*to = append(*to, s.Current)
|
|
s.Current, *from = (*from)[n-1], (*from)[:n-1]
|
|
s.list.Selection.Clear()
|
|
}
|
|
|
|
// FileExplorerResult is a FileExplorer and what happened in this frame.
|
|
type FileExplorerResult struct {
|
|
Element *ui.Element
|
|
// Navigated reports a new current folder; Changed a new selection.
|
|
Navigated, Changed bool
|
|
// Opened is a file the user opened.
|
|
Opened *FileEntry
|
|
}
|
|
|
|
// FileExplorer combines folder navigation, history and file selection.
|
|
func FileExplorer(c *ui.Context, s *FileExplorerState, opts FileExplorerOptions) FileExplorerResult {
|
|
if s == nil || opts.Folders == nil || opts.Entries == nil || len(opts.Roots) == 0 {
|
|
panic("mujica: FileExplorer needs a state, Roots, Folders and Entries")
|
|
}
|
|
if s.Current == "" {
|
|
s.Current = opts.Roots[0]
|
|
}
|
|
k := Tokens(c)
|
|
u := Density(c).Unit()
|
|
var res FileExplorerResult
|
|
res.Element = ui.Column(c).MinHeight(0).Gap(u * 2).Disabled(opts.Disabled)
|
|
disabled := res.Element.IsDisabled()
|
|
if disabled {
|
|
copy := *s
|
|
copy.list.Selection = filesSelectionCopy(s.list.Selection)
|
|
copy.tree.Selection = filesSelectionCopy(s.tree.Selection)
|
|
copy.tree.tree.outline.Open = filesSelectionCopy(s.tree.tree.outline.Open)
|
|
s = ©
|
|
}
|
|
at := s.Current
|
|
move := func(fn func()) {
|
|
if !disabled {
|
|
fn()
|
|
}
|
|
}
|
|
mac := runtime.GOOS == "darwin"
|
|
switch {
|
|
case mac && res.Element.Shortcut(ui.Super, ui.KeyBracketLeft), !mac && res.Element.Shortcut(ui.Alt, ui.KeyLeft):
|
|
move(func() { s.step(&s.back, &s.forward) })
|
|
case mac && res.Element.Shortcut(ui.Super, ui.KeyBracketRight), !mac && res.Element.Shortcut(ui.Alt, ui.KeyRight):
|
|
move(func() { s.step(&s.forward, &s.back) })
|
|
case mac && res.Element.Shortcut(ui.Super, ui.KeyUp), !mac && res.Element.Shortcut(ui.Alt, ui.KeyUp):
|
|
move(func() { s.Navigate(fileExplorerParent(s.Current, opts.Roots)) })
|
|
}
|
|
res.Element.Children(func() {
|
|
ui.Row(c).Gap(u).AlignItems(ui.Center).Shrink(0).Children(func() {
|
|
for _, b := range []struct {
|
|
icon, key string
|
|
on bool
|
|
do func()
|
|
}{
|
|
{"chevron-left", "file_explorer.back", len(s.back) > 0, func() { s.step(&s.back, &s.forward) }},
|
|
{"chevron-right", "file_explorer.forward", len(s.forward) > 0, func() { s.step(&s.forward, &s.back) }},
|
|
{"arrow-up", "file_explorer.up", fileExplorerParent(s.Current, opts.Roots) != s.Current, func() { s.Navigate(fileExplorerParent(s.Current, opts.Roots)) }},
|
|
} {
|
|
a := iconAction(c, icons.Must(b.icon), msg(c, b.key)).Disabled(!b.on)
|
|
if a.Clicked() && b.on {
|
|
move(b.do)
|
|
}
|
|
}
|
|
if r := PathBar(c, s.Current, PathBarOptions{Disabled: opts.Disabled}); r.Navigated != "" {
|
|
move(func() { s.Navigate(r.Navigated) })
|
|
}
|
|
})
|
|
ui.Row(c).Grow(1).MinHeight(Density(c).ControlHeight() * 4).Gap(u * 2).AlignItems(ui.Stretch).Children(func() {
|
|
fileExplorerTree(c, s, opts, move)
|
|
ui.Column(c).Grow(1).MinWidth(0).Children(func() {
|
|
entries := opts.Entries(s.Current)
|
|
var first *FileEntry
|
|
if len(entries) > 0 {
|
|
first = &entries[0]
|
|
}
|
|
if s.data != first || s.n != len(entries) || s.revision != opts.Revision {
|
|
valid := make(map[string]bool, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.Path == "" || valid[entry.Path] {
|
|
panic("mujica: FileExplorer needs unique entry paths")
|
|
}
|
|
valid[entry.Path] = true
|
|
}
|
|
for key := range s.list.Selection.All() {
|
|
if !valid[key] {
|
|
s.list.Selection.Remove(key)
|
|
}
|
|
}
|
|
s.data, s.n, s.revision = first, len(entries), opts.Revision
|
|
}
|
|
v := List(c, &s.list, len(entries), ListOptions[string]{Multiple: true,
|
|
Key: func(i int) string { return entries[i].Path }, Label: func(i int) string { return entries[i].Name },
|
|
Empty: msg(c, "file_explorer.empty")}, func(i int) { fileExplorerRow(c, k, entries[i]) })
|
|
v.Element.Grow(1)
|
|
res.Changed = v.Changed() && !disabled
|
|
if key, ok := s.list.Selected(); ok && v.Submitted() && !disabled {
|
|
for _, e := range entries {
|
|
if e.Path != key {
|
|
continue
|
|
}
|
|
if e.Dir {
|
|
s.Navigate(e.Path)
|
|
} else {
|
|
open := e
|
|
res.Opened = &open
|
|
slog.Debug("mujica: file_explorer open")
|
|
}
|
|
}
|
|
}
|
|
})
|
|
})
|
|
})
|
|
res.Navigated = s.Current != at
|
|
return res
|
|
}
|
|
|
|
func filesSelectionCopy(src ui.Selection[string]) ui.Selection[string] {
|
|
var dst ui.Selection[string]
|
|
for key := range src.All() {
|
|
dst.Add(key)
|
|
}
|
|
return dst
|
|
}
|
|
|
|
// fileExplorerTree shows the folders, choosing the current one.
|
|
func fileExplorerTree(c *ui.Context, s *FileExplorerState, opts FileExplorerOptions, move func(func())) {
|
|
for _, a := range fileExplorerAncestors(s.Current, opts.Roots) {
|
|
s.tree.SetOpen(a, true)
|
|
}
|
|
s.tree.tree.sel, s.tree.tree.has = s.Current, true
|
|
v := Tree(c, &s.tree, TreeOptions[string]{Roots: opts.Roots, Children: opts.Folders,
|
|
Label: func(p string) string { return path.Base(p) }})
|
|
v.Element.Width(220).Shrink(0)
|
|
if v.Changed() {
|
|
if dir, ok := s.tree.Selected(); ok && dir != s.Current {
|
|
move(func() { s.Navigate(dir) })
|
|
}
|
|
}
|
|
}
|
|
|
|
// fileExplorerAncestors lists the folders above dir, inside its root.
|
|
func fileExplorerAncestors(dir string, roots []string) []string {
|
|
var out []string
|
|
for p := dir; ; {
|
|
parent := fileExplorerParent(p, roots)
|
|
if parent == p {
|
|
return out
|
|
}
|
|
out = append(out, parent)
|
|
p = parent
|
|
}
|
|
}
|
|
|
|
// fileExplorerParent is dir's parent, or dir itself at a root.
|
|
func fileExplorerParent(dir string, roots []string) string {
|
|
for _, r := range roots {
|
|
if dir == r {
|
|
return dir
|
|
}
|
|
}
|
|
p := path.Dir(dir)
|
|
if p == "." || p == dir {
|
|
return dir
|
|
}
|
|
return p
|
|
}
|
|
|
|
func fileExplorerRow(c *ui.Context, k theme.Tokens, e FileEntry) {
|
|
FileIcon(c, e.Name, FileIconOptions{Folder: e.Dir})
|
|
ui.Text(c, e.Name).SingleLine().Grow(1).MinWidth(0)
|
|
if !e.Dir {
|
|
ui.Text(c, processListBytes(uint64(max(e.Size, 0)))).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Shrink(0)
|
|
}
|
|
if !e.Modified.IsZero() {
|
|
ui.Text(c, e.Modified.Format("2006-01-02 15:04")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Shrink(0)
|
|
}
|
|
}
|