Add Bubble Tea TUI with interactive treemap
Cell canvas renderer with frame cache and a perceptual 256-colour quantizer; responsive geometry (large/medium/small/too-small); squarified treemap with nested previews, LOD grouping, category colouring and adaptive labels; spatial arrow navigation with stable NodeID selection; zoom and breadcrumbs; modal system (info, help, confirm, save); list, file-type, top-N, scan-info, duplicate and comparison views; live search/filter; mouse support; move-to-trash.
This commit is contained in:
+2
-1
@@ -55,6 +55,7 @@ func run(args []string) int {
|
|||||||
si = fs.Bool("si", settings.Units == "si", "use SI units (kB, MB) instead of IEC (KiB, MiB)")
|
si = fs.Bool("si", settings.Units == "si", "use SI units (kB, MB) instead of IEC (KiB, MiB)")
|
||||||
logical = fs.Bool("apparent", settings.SizeMode == "logical", "size by logical (apparent) size instead of disk usage")
|
logical = fs.Bool("apparent", settings.SizeMode == "logical", "size by logical (apparent) size instead of disk usage")
|
||||||
theme = fs.String("theme", settings.Theme, "colour theme: default, dark, high-contrast, mono")
|
theme = fs.String("theme", settings.Theme, "colour theme: default, dark, high-contrast, mono")
|
||||||
|
colorMode = fs.String("color", "auto", "colour support: auto, truecolor, 256, 16, none")
|
||||||
noMouse = fs.Bool("no-mouse", !settings.Mouse, "disable mouse support")
|
noMouse = fs.Bool("no-mouse", !settings.Mouse, "disable mouse support")
|
||||||
logFile = fs.String("log", "", "write a debug log to `FILE`")
|
logFile = fs.String("log", "", "write a debug log to `FILE`")
|
||||||
logLevel = fs.String("log-level", "info", "log level: error, warn, info, debug, trace")
|
logLevel = fs.String("log-level", "info", "log level: error, warn, info, debug, trace")
|
||||||
@@ -117,7 +118,7 @@ func run(args []string) int {
|
|||||||
|
|
||||||
cfg := &app.Config{
|
cfg := &app.Config{
|
||||||
NoUI: *noUI, JSON: *jsonOut, CSV: *csvOut, Depth: *depth, Top: *top, LargestFiles: *largest,
|
NoUI: *noUI, JSON: *jsonOut, CSV: *csvOut, Depth: *depth, Top: *top, LargestFiles: *largest,
|
||||||
Duplicates: *dups, ReadOnly: *readOnly, Theme: *theme, Mouse: !*noMouse, Load: *load,
|
Duplicates: *dups, ReadOnly: *readOnly, Theme: *theme, Mouse: !*noMouse, Color: *colorMode, Load: *load,
|
||||||
Settings: settings,
|
Settings: settings,
|
||||||
}
|
}
|
||||||
if *logical {
|
if *logical {
|
||||||
|
|||||||
@@ -6,3 +6,20 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7
|
github.com/rivo/uniseg v0.4.7
|
||||||
golang.org/x/sys v0.48.0
|
golang.org/x/sys v0.48.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
charm.land/bubbletea/v2 v2.0.10 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,4 +1,32 @@
|
|||||||
|
charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=
|
||||||
|
charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw=
|
||||||
|
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||||
|
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||||
|
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||||
|
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||||
|
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||||
|
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||||
|
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ type Config struct {
|
|||||||
Theme string
|
Theme string
|
||||||
SizeMode inventory.SizeMode
|
SizeMode inventory.SizeMode
|
||||||
Mouse bool
|
Mouse bool
|
||||||
|
Color string
|
||||||
Settings config.Settings
|
Settings config.Settings
|
||||||
Stdout io.Writer
|
Stdout io.Writer
|
||||||
Stderr io.Writer
|
Stderr io.Writer
|
||||||
|
|||||||
@@ -2,17 +2,22 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/andydixon/mapsize/internal/duplicate"
|
"github.com/andydixon/mapsize/internal/duplicate"
|
||||||
"github.com/andydixon/mapsize/internal/inventory"
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/scan"
|
||||||
"github.com/andydixon/mapsize/internal/snapshot"
|
"github.com/andydixon/mapsize/internal/snapshot"
|
||||||
"github.com/andydixon/mapsize/internal/textutil"
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
"github.com/andydixon/mapsize/internal/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func runTUI(*Config) error {
|
func runTUI(cfg *Config) error {
|
||||||
return errors.New("the interactive UI is not built yet; use --no-ui, --top, --json or --csv")
|
return tui.Run(tui.Options{
|
||||||
|
ReadOnly: cfg.ReadOnly, Theme: cfg.Theme, SizeMode: cfg.SizeMode, Mouse: cfg.Mouse, Color: cfg.Color,
|
||||||
|
Save: cfg.Save, Settings: cfg.Settings,
|
||||||
|
Start: func(ctx context.Context) (*scan.Scanner, *inventory.Tree, error) { return startSource(ctx, cfg) },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSnapshot(path string) (*inventory.Tree, error) { return snapshot.LoadFile(path) }
|
func loadSnapshot(path string) (*inventory.Tree, error) { return snapshot.LoadFile(path) }
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const (
|
|||||||
FlagAllocUnknown // platform cannot report allocated size
|
FlagAllocUnknown // platform cannot report allocated size
|
||||||
FlagBrokenLink // symlink target does not exist
|
FlagBrokenLink // symlink target does not exist
|
||||||
FlagHardlinked // regular file with link count > 1
|
FlagHardlinked // regular file with link count > 1
|
||||||
|
FlagDeleted // moved to trash from the UI
|
||||||
)
|
)
|
||||||
|
|
||||||
// Node is one filesystem object. Sizes are own sizes; Tot* fields are
|
// Node is one filesystem object. Sizes are own sizes; Tot* fields are
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startDetached runs a helper program with an argument vector (never a
|
||||||
|
// shell), detached from the terminal so it cannot disturb the TUI.
|
||||||
|
func startDetached(name string, args ...string) error {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go cmd.Wait()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAbs guards helper invocations: an absolute path can never be parsed
|
||||||
|
// as an option by the helper program.
|
||||||
|
func checkAbs(path string) error {
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return errors.New("refusing relative path")
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TrashName is the user-facing name of the trash.
|
||||||
|
const TrashName = "Trash"
|
||||||
|
|
||||||
|
// Reveal selects the item in Finder.
|
||||||
|
func Reveal(path string) error {
|
||||||
|
if err := checkAbs(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return startDetached("open", "-R", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trash moves path into ~/.Trash (same volume) or the volume's
|
||||||
|
// .Trashes/<uid> directory. It never copies and never deletes.
|
||||||
|
func Trash(path string) error {
|
||||||
|
if err := checkAbs(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dirs := []string{filepath.Join(home, ".Trash")}
|
||||||
|
if vol := volumeOf(path); vol != "" {
|
||||||
|
dirs = append(dirs, filepath.Join(vol, ".Trashes", fmt.Sprint(os.Getuid())))
|
||||||
|
}
|
||||||
|
base := filepath.Base(path)
|
||||||
|
for _, dir := range dirs {
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := 1; i < 10000; i++ {
|
||||||
|
name := base
|
||||||
|
if i > 1 {
|
||||||
|
name = fmt.Sprintf("%s %d", base, i)
|
||||||
|
}
|
||||||
|
dst := filepath.Join(dir, name)
|
||||||
|
if _, err := os.Lstat(dst); err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err := os.Rename(path, dst)
|
||||||
|
if errors.Is(err, syscall.EXDEV) {
|
||||||
|
break // try the next trash location
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("trash: item is on a volume without a usable trash")
|
||||||
|
}
|
||||||
|
|
||||||
|
func volumeOf(path string) string {
|
||||||
|
const prefix = "/Volumes/"
|
||||||
|
if len(path) > len(prefix) && path[:len(prefix)] == prefix {
|
||||||
|
rest := path[len(prefix):]
|
||||||
|
for i := 0; i < len(rest); i++ {
|
||||||
|
if rest[i] == '/' {
|
||||||
|
return prefix + rest[:i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TrashName is the user-facing name of the trash.
|
||||||
|
const TrashName = "trash"
|
||||||
|
|
||||||
|
// Reveal opens the item's directory in the desktop file manager.
|
||||||
|
func Reveal(path string) error {
|
||||||
|
if err := checkAbs(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fi, err := os.Lstat(path)
|
||||||
|
if err == nil && !fi.IsDir() {
|
||||||
|
path = filepath.Dir(path)
|
||||||
|
}
|
||||||
|
return startDetached("xdg-open", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trash moves path to the freedesktop.org trash: the home trash when on the
|
||||||
|
// same filesystem, otherwise $topdir/.Trash-$uid on the item's filesystem.
|
||||||
|
// It never copies across filesystems and never deletes.
|
||||||
|
func Trash(path string) error {
|
||||||
|
if err := checkAbs(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := os.Lstat(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dev := st.Sys().(*syscall.Stat_t).Dev
|
||||||
|
dataHome := os.Getenv("XDG_DATA_HOME")
|
||||||
|
if dataHome == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dataHome = filepath.Join(home, ".local", "share")
|
||||||
|
}
|
||||||
|
home := filepath.Join(dataHome, "Trash")
|
||||||
|
var trash, infoPath string
|
||||||
|
if hs, err := os.Stat(dataHome); err == nil && hs.Sys().(*syscall.Stat_t).Dev == dev {
|
||||||
|
trash, infoPath = home, path
|
||||||
|
} else {
|
||||||
|
top := mountTop(path, dev)
|
||||||
|
trash = filepath.Join(top, ".Trash-"+strconv.Itoa(os.Getuid()))
|
||||||
|
rel, err := filepath.Rel(top, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
infoPath = rel
|
||||||
|
}
|
||||||
|
files, info := filepath.Join(trash, "files"), filepath.Join(trash, "info")
|
||||||
|
if err := os.MkdirAll(files, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(info, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
base := filepath.Base(path)
|
||||||
|
for i := 1; i < 10000; i++ {
|
||||||
|
name := base
|
||||||
|
if i > 1 {
|
||||||
|
name = fmt.Sprintf("%s.%d", base, i)
|
||||||
|
}
|
||||||
|
ip := filepath.Join(info, name+".trashinfo")
|
||||||
|
f, err := os.OpenFile(ip, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||||
|
if errors.Is(err, os.ErrExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u := (&url.URL{Path: infoPath}).EscapedPath()
|
||||||
|
_, werr := fmt.Fprintf(f, "[Trash Info]\nPath=%s\nDeletionDate=%s\n", u, time.Now().Format("2006-01-02T15:04:05"))
|
||||||
|
cerr := f.Close()
|
||||||
|
if werr != nil || cerr != nil {
|
||||||
|
os.Remove(ip)
|
||||||
|
return errors.Join(werr, cerr)
|
||||||
|
}
|
||||||
|
if err := os.Rename(path, filepath.Join(files, name)); err != nil {
|
||||||
|
os.Remove(ip)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errors.New("trash: too many items with the same name")
|
||||||
|
}
|
||||||
|
|
||||||
|
// mountTop returns the top directory of the filesystem containing path.
|
||||||
|
func mountTop(path string, dev uint64) string {
|
||||||
|
cur := filepath.Dir(path)
|
||||||
|
for {
|
||||||
|
parent := filepath.Dir(cur)
|
||||||
|
if parent == cur {
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
st, err := os.Stat(parent)
|
||||||
|
if err != nil || st.Sys().(*syscall.Stat_t).Dev != dev {
|
||||||
|
return cur
|
||||||
|
}
|
||||||
|
cur = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//go:build !linux && !darwin && !windows
|
||||||
|
|
||||||
|
package platform
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// TrashName is the user-facing name of the trash.
|
||||||
|
const TrashName = "trash"
|
||||||
|
|
||||||
|
// Reveal is unsupported on this platform.
|
||||||
|
func Reveal(string) error { return errors.New("not supported on this platform") }
|
||||||
|
|
||||||
|
// Trash is unsupported on this platform.
|
||||||
|
func Trash(string) error { return errors.New("trash not supported on this platform") }
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TrashName is the user-facing name of the trash.
|
||||||
|
const TrashName = "Recycle Bin"
|
||||||
|
|
||||||
|
// Reveal selects the item in Explorer.
|
||||||
|
func Reveal(path string) error {
|
||||||
|
if err := checkAbs(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return startDetached("explorer.exe", "/select,", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
shell32 = syscall.NewLazyDLL("shell32.dll")
|
||||||
|
procSHFileOpertn = shell32.NewProc("SHFileOperationW")
|
||||||
|
)
|
||||||
|
|
||||||
|
// shFileOpStruct mirrors SHFILEOPSTRUCTW (64-bit natural alignment).
|
||||||
|
type shFileOpStruct struct {
|
||||||
|
hwnd uintptr
|
||||||
|
wFunc uint32
|
||||||
|
pFrom *uint16
|
||||||
|
pTo *uint16
|
||||||
|
fFlags uint16
|
||||||
|
fAnyOperationsAborted int32
|
||||||
|
hNameMappings uintptr
|
||||||
|
lpszProgressTitle *uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
foDelete = 0x3
|
||||||
|
fofSilent = 0x4
|
||||||
|
fofNoConfirmation = 0x10
|
||||||
|
fofAllowUndo = 0x40
|
||||||
|
fofNoErrorUI = 0x400
|
||||||
|
fofNoConfirmMkdir = 0x200
|
||||||
|
)
|
||||||
|
|
||||||
|
// Trash moves path to the Recycle Bin via SHFileOperationW with
|
||||||
|
// FOF_ALLOWUNDO. It never deletes permanently.
|
||||||
|
func Trash(path string) error {
|
||||||
|
if err := checkAbs(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if unsafe.Sizeof(uintptr(0)) != 8 {
|
||||||
|
return errors.New("recycle bin support requires a 64-bit build")
|
||||||
|
}
|
||||||
|
u, err := syscall.UTF16FromString(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u = append(u, 0) // double NUL terminated list
|
||||||
|
op := shFileOpStruct{wFunc: foDelete, pFrom: &u[0],
|
||||||
|
fFlags: fofAllowUndo | fofNoConfirmation | fofSilent | fofNoErrorUI | fofNoConfirmMkdir}
|
||||||
|
r, _, _ := procSHFileOpertn.Call(uintptr(unsafe.Pointer(&op)))
|
||||||
|
if r != 0 {
|
||||||
|
return errors.New("SHFileOperation failed: code " + strconv.Itoa(int(r)))
|
||||||
|
}
|
||||||
|
if op.fAnyOperationsAborted != 0 {
|
||||||
|
return errors.New("operation aborted")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package scan
|
||||||
|
|
||||||
|
import "runtime/debug"
|
||||||
|
|
||||||
|
// OnPanic is called if a scanner goroutine panics. The default re-panics
|
||||||
|
// (crashing the process); the TUI replaces it so it can restore the terminal
|
||||||
|
// before reporting. Programmer bugs are never silently swallowed.
|
||||||
|
var OnPanic = func(v any, stack []byte) { panic(v) }
|
||||||
|
|
||||||
|
func guard() {
|
||||||
|
if v := recover(); v != nil {
|
||||||
|
OnPanic(v, debug.Stack())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,6 +203,7 @@ type controller struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *controller) run(ctx context.Context) {
|
func (c *controller) run(ctx context.Context) {
|
||||||
|
defer guard()
|
||||||
defer close(c.s.done)
|
defer close(c.s.done)
|
||||||
jobs := make(chan *job, c.opts.JobBuffer)
|
jobs := make(chan *job, c.opts.JobBuffer)
|
||||||
results := make(chan result, c.opts.ResultBuf)
|
results := make(chan result, c.opts.ResultBuf)
|
||||||
@@ -211,6 +212,7 @@ func (c *controller) run(ctx context.Context) {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
defer guard()
|
||||||
worker(ctx, jobs, results, c.opts.ChunkSize)
|
worker(ctx, jobs, results, c.opts.ChunkSize)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-19
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
"github.com/rivo/uniseg"
|
"github.com/rivo/uniseg"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,8 +53,40 @@ func isBidiControl(r rune) bool {
|
|||||||
r == 0x200e || r == 0x200f || r == 0x061c
|
r == 0x200e || r == 0x200f || r == 0x061c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clusters calls fn for each grapheme cluster of s with its display width.
|
||||||
|
// Clusters whose width terminals disagree on (per-rune wcwidth versus
|
||||||
|
// grapheme width, e.g. emoji ZWJ sequences) are replaced by U+FFFD with
|
||||||
|
// width 1, and zero-width clusters are dropped, so that measuring and
|
||||||
|
// drawing always agree with the terminal. fn returns false to stop.
|
||||||
|
func Clusters(s string, fn func(c string, w int) bool) {
|
||||||
|
state := -1
|
||||||
|
for s != "" {
|
||||||
|
var c string
|
||||||
|
var w int
|
||||||
|
c, s, w, state = uniseg.FirstGraphemeClusterInString(s, state)
|
||||||
|
if w == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(c) > 1 && ansi.WcWidth.StringWidth(c) != w {
|
||||||
|
c, w = "\uFFFD", 1
|
||||||
|
}
|
||||||
|
if !fn(c, w) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Width returns the display width of s in terminal cells.
|
// Width returns the display width of s in terminal cells.
|
||||||
func Width(s string) int { return uniseg.StringWidth(s) }
|
func Width(s string) int {
|
||||||
|
n := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
if s[i] < 0x20 || s[i] >= 0x7f {
|
||||||
|
Clusters(s, func(_ string, w int) bool { n += w; return true })
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(s)
|
||||||
|
}
|
||||||
|
|
||||||
// Truncate clips s to at most w cells, appending "…" if anything was cut.
|
// Truncate clips s to at most w cells, appending "…" if anything was cut.
|
||||||
func Truncate(s string, w int) string {
|
func Truncate(s string, w int) string {
|
||||||
@@ -77,33 +110,35 @@ func TruncateLeft(s string, w int) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
skip := total - (w - 1)
|
skip := total - (w - 1)
|
||||||
state := -1
|
var b strings.Builder
|
||||||
rest := s
|
Clusters(s, func(c string, cw int) bool {
|
||||||
for rest != "" && skip > 0 {
|
if skip > 0 {
|
||||||
var cw int
|
|
||||||
_, rest, cw, state = uniseg.FirstGraphemeClusterInString(rest, state)
|
|
||||||
skip -= cw
|
skip -= cw
|
||||||
|
if skip < 0 {
|
||||||
|
// A wide cluster overshot by one cell; pad to keep width exact.
|
||||||
|
b.WriteString(strings.Repeat(" ", -skip))
|
||||||
}
|
}
|
||||||
// A wide cluster may have overshot by one cell; pad to keep width exact.
|
return true
|
||||||
return "…" + strings.Repeat(" ", -skip) + rest
|
}
|
||||||
|
b.WriteString(c)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return "…" + b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// clip returns the longest prefix of s that fits in w cells.
|
// clip returns the longest prefix of s that fits in w cells.
|
||||||
func clip(s string, w int) string {
|
func clip(s string, w int) string {
|
||||||
state := -1
|
var b strings.Builder
|
||||||
used, end := 0, 0
|
used := 0
|
||||||
rest := s
|
Clusters(s, func(c string, cw int) bool {
|
||||||
for rest != "" {
|
|
||||||
var cluster string
|
|
||||||
var cw int
|
|
||||||
cluster, rest, cw, state = uniseg.FirstGraphemeClusterInString(rest, state)
|
|
||||||
if used+cw > w {
|
if used+cw > w {
|
||||||
break
|
return false
|
||||||
}
|
}
|
||||||
used += cw
|
used += cw
|
||||||
end += len(cluster)
|
b.WriteString(c)
|
||||||
}
|
return true
|
||||||
return s[:end]
|
})
|
||||||
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// PadRight pads s with spaces to exactly w cells (truncating if longer).
|
// PadRight pads s with spaces to exactly w cells (truncating if longer).
|
||||||
|
|||||||
@@ -62,3 +62,13 @@ func TestSize(t *testing.T) {
|
|||||||
t.Errorf("SignedSize got %q", got)
|
t.Errorf("SignedSize got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClustersAgreeWithWidth(t *testing.T) {
|
||||||
|
for _, s := range []string{"abc", "日本", "é", "👨👩👧", "🎉x", ""} {
|
||||||
|
n := 0
|
||||||
|
Clusters(s, func(_ string, w int) bool { n += w; return true })
|
||||||
|
if n != Width(s) {
|
||||||
|
t.Errorf("%q: clusters %d width %d", s, n, Width(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/duplicate"
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/platform"
|
||||||
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
"github.com/andydixon/mapsize/internal/treemap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Trash ---------------------------------------------------------------
|
||||||
|
|
||||||
|
type trashDoneMsg struct {
|
||||||
|
id inventory.NodeID
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestTrash asks for confirmation before moving the selection to the
|
||||||
|
// trash. Permanent deletion is deliberately not offered.
|
||||||
|
func (m *Model) requestTrash() tea.Cmd {
|
||||||
|
switch {
|
||||||
|
case m.opts.ReadOnly:
|
||||||
|
return m.warn("Read-only mode: modifying actions are disabled")
|
||||||
|
case m.snapshot:
|
||||||
|
return m.warn("Snapshot view: nothing to delete on the live filesystem")
|
||||||
|
case m.scanning:
|
||||||
|
return m.warn("Wait for the scan to finish before trashing items")
|
||||||
|
}
|
||||||
|
id := m.selectedNode()
|
||||||
|
if id == inventory.NoNode || id == m.tree.Root() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n := m.tree.Node(id)
|
||||||
|
path := m.tree.Path(id)
|
||||||
|
what := "file"
|
||||||
|
if n.IsDir() {
|
||||||
|
what = fmt.Sprintf("directory with %s files", textutil.Count(int64(n.Files)))
|
||||||
|
}
|
||||||
|
m.openModal(&confirmModal{
|
||||||
|
ttl: "Move to trash?",
|
||||||
|
body: []string{textutil.Sanitize(path), "",
|
||||||
|
fmt.Sprintf("%s · %s", what, textutil.Size(n.Total(m.sizeMode))),
|
||||||
|
"", "It will be moved to the " + platform.TrashName + ", not permanently deleted."},
|
||||||
|
yes: "move to " + platform.TrashName,
|
||||||
|
onYes: func(m *Model) tea.Cmd {
|
||||||
|
return func() tea.Msg { return trashDoneMsg{id, platform.Trash(path)} }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd {
|
||||||
|
if msg.err != nil {
|
||||||
|
return m.warn("Trash failed: " + textutil.Sanitize(msg.err.Error()))
|
||||||
|
}
|
||||||
|
// The scan has finished, so the UI goroutine is the tree's only writer.
|
||||||
|
t := m.tree
|
||||||
|
t.Lock()
|
||||||
|
n := t.Node(msg.id)
|
||||||
|
n.Flags |= inventory.FlagDeleted
|
||||||
|
d := inventory.Delta{Size: -n.TotSize, Alloc: -n.TotAlloc, Files: -int64(n.Files), Dirs: -int64(n.Dirs)}
|
||||||
|
if n.IsDir() {
|
||||||
|
d.Dirs--
|
||||||
|
for i, v := range t.CatSizes(msg.id) {
|
||||||
|
d.Cat[i] = -v
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
d.Files--
|
||||||
|
d.Cat[n.Cat] = -n.TotAlloc
|
||||||
|
}
|
||||||
|
t.Propagate(n.Parent, d)
|
||||||
|
n.TotSize, n.TotAlloc = 0, 0
|
||||||
|
name := n.Name
|
||||||
|
t.Unlock()
|
||||||
|
m.invalidate()
|
||||||
|
return m.info("Moved to " + platform.TrashName + ": " + textutil.Sanitize(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Duplicates ----------------------------------------------------------
|
||||||
|
|
||||||
|
type dupState struct {
|
||||||
|
running bool
|
||||||
|
finder *duplicate.Finder
|
||||||
|
groups []duplicate.Group
|
||||||
|
err error
|
||||||
|
cancel context.CancelFunc
|
||||||
|
started time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type (
|
||||||
|
dupProgressMsg struct{}
|
||||||
|
dupDoneMsg struct {
|
||||||
|
groups []duplicate.Group
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Model) startDuplicates() tea.Cmd {
|
||||||
|
if m.snapshot {
|
||||||
|
return m.warn("Duplicate detection needs the live filesystem")
|
||||||
|
}
|
||||||
|
if m.scanning {
|
||||||
|
return m.warn("Wait for the scan to finish first")
|
||||||
|
}
|
||||||
|
if m.dups != nil {
|
||||||
|
m.ensureDupView()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.openModal(&confirmModal{
|
||||||
|
ttl: "Find duplicate files?",
|
||||||
|
body: []string{
|
||||||
|
"Files are grouped by size, then sampled, then fully hashed with SHA-256.",
|
||||||
|
"Only identical hashes are reported as duplicates.",
|
||||||
|
"", "This reads file contents (it may take a while and can update access times).",
|
||||||
|
},
|
||||||
|
yes: "start",
|
||||||
|
onYes: func(m *Model) tea.Cmd {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
f := &duplicate.Finder{}
|
||||||
|
m.dups = &dupState{running: true, finder: f, cancel: cancel, started: time.Now()}
|
||||||
|
m.ensureDupView()
|
||||||
|
t := m.tree
|
||||||
|
return tea.Batch(m.startTicking(), func() tea.Msg {
|
||||||
|
g, err := f.Find(ctx, t, duplicate.Options{MinSize: 1, Workers: 4})
|
||||||
|
return dupDoneMsg{g, err}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) ensureDupView() {
|
||||||
|
for i, v := range m.views {
|
||||||
|
if _, ok := v.(*dupView); ok {
|
||||||
|
m.setView(i)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.views = append(m.views, &dupView{})
|
||||||
|
m.setView(len(m.views) - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) dupMsg(msg tea.Msg) tea.Cmd {
|
||||||
|
if d, ok := msg.(dupDoneMsg); ok && m.dups != nil {
|
||||||
|
m.dups.running = false
|
||||||
|
m.dups.groups, m.dups.err = d.groups, d.err
|
||||||
|
m.dirty = true
|
||||||
|
var wasted int64
|
||||||
|
for _, g := range d.groups {
|
||||||
|
wasted += g.Wasted()
|
||||||
|
}
|
||||||
|
return m.info(fmt.Sprintf("Duplicates: %d verified groups, %s reclaimable", len(d.groups), textutil.Size(wasted)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dupView struct {
|
||||||
|
tb table
|
||||||
|
rows []dupRow
|
||||||
|
}
|
||||||
|
|
||||||
|
type dupRow struct {
|
||||||
|
group int
|
||||||
|
id inventory.NodeID // NoNode for a group header
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*dupView) name() string { return "Dups" }
|
||||||
|
|
||||||
|
func (v *dupView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
||||||
|
t := m.theme
|
||||||
|
d := m.dups
|
||||||
|
if d == nil {
|
||||||
|
cv.TextCentered(r.X, r.Y+r.H/2, r.W, "Press D to search for duplicates", t.muted())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d.running {
|
||||||
|
p := d.finder.Progress()
|
||||||
|
cv.Fill(r, " ", t.base())
|
||||||
|
y := r.Y + r.H/2 - 2
|
||||||
|
cv.TextCentered(r.X, y, r.W, "Finding duplicates — "+p.Stage+"…", Style{FG: t.Accent, BG: t.Bg, Attr: Bold})
|
||||||
|
cv.TextCentered(r.X, y+2, r.W, fmt.Sprintf("%s candidate files in %s size groups", textutil.Count(p.Candidates),
|
||||||
|
textutil.Count(p.CandidateGroups)), t.base())
|
||||||
|
if p.BytesToHash > 0 {
|
||||||
|
frac := float64(p.BytesHashed) / float64(p.BytesToHash)
|
||||||
|
bw := min(60, r.W-10)
|
||||||
|
m.paintBar(cv, r.X+(r.W-bw)/2, y+4, bw, frac, t.Accent, false)
|
||||||
|
cv.TextCentered(r.X, y+5, r.W, fmt.Sprintf("%s of %s hashed · %s skipped", textutil.Size(p.BytesHashed),
|
||||||
|
textutil.Size(p.BytesToHash), textutil.Count(p.Skipped)), t.muted())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.rows = v.rows[:0]
|
||||||
|
var tr []tableRow
|
||||||
|
for gi, g := range d.groups {
|
||||||
|
v.rows = append(v.rows, dupRow{gi, inventory.NoNode})
|
||||||
|
tr = append(tr, tableRow{
|
||||||
|
cells: []string{textutil.Size(g.Wasted()), fmt.Sprintf("%d × %s", len(g.Files), textutil.Size(g.Size)), fmt.Sprintf("verified identical · sha256 %x…", g.Hash[:8])},
|
||||||
|
styles: []*Style{{FG: t.Warn, BG: t.Bg, Attr: Bold}, {FG: t.Fg, BG: t.Bg, Attr: Bold}, {FG: t.OK, BG: t.Bg}}, bar: -1,
|
||||||
|
})
|
||||||
|
for _, id := range g.Files {
|
||||||
|
v.rows = append(v.rows, dupRow{gi, id})
|
||||||
|
tr = append(tr, tableRow{cells: []string{"", "", " " + textutil.Sanitize(m.tree.Path(id))}, bar: -1})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title := fmt.Sprintf("Duplicates — %d groups (Enter details · Space go to)", len(d.groups))
|
||||||
|
if d.err != nil {
|
||||||
|
title += " — stopped: " + textutil.Sanitize(d.err.Error())
|
||||||
|
}
|
||||||
|
v.tb.paint(m, cv, r, []column{{"WASTED", 10, true}, {"COPIES", 16, false}, {"", 0, false}}, tr, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *dupView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||||
|
if v.tb.key(k, len(v.rows)) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if v.tb.cursor < len(v.rows) {
|
||||||
|
r := v.rows[v.tb.cursor]
|
||||||
|
if r.id == inventory.NoNode && m.dups != nil && r.group < len(m.dups.groups) {
|
||||||
|
r.id = m.dups.groups[r.group].Files[0]
|
||||||
|
}
|
||||||
|
if r.id != inventory.NoNode {
|
||||||
|
switch k {
|
||||||
|
case "enter":
|
||||||
|
m.openModal(newInfoModal(m, r.id))
|
||||||
|
return true, nil
|
||||||
|
case "space":
|
||||||
|
m.jumpTo(r.id)
|
||||||
|
return true, nil
|
||||||
|
case "c", "o", "d":
|
||||||
|
m.sel = int64(r.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if k == "esc" && m.dups != nil && m.dups.running {
|
||||||
|
m.dups.cancel()
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *dupView) wheel(m *Model, up bool) tea.Cmd {
|
||||||
|
v.tb.move(map[bool]int{true: -3, false: 3}[up], len(v.rows))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
"github.com/andydixon/mapsize/internal/treemap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Color is 0 for the terminal default, otherwise 0x01RRGGBB.
|
||||||
|
type Color uint32
|
||||||
|
|
||||||
|
// RGB builds a colour.
|
||||||
|
func RGB(r, g, b uint8) Color { return Color(1<<24 | uint32(r)<<16 | uint32(g)<<8 | uint32(b)) }
|
||||||
|
|
||||||
|
// Hex parses "#rrggbb".
|
||||||
|
func Hex(s string) (Color, bool) {
|
||||||
|
s = strings.TrimPrefix(s, "#")
|
||||||
|
if len(s) != 6 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseUint(s, 16, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return Color(1<<24 | uint32(v)), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Color) rgb() (r, g, b float64) {
|
||||||
|
return float64(c>>16&0xff) / 255, float64(c>>8&0xff) / 255, float64(c&0xff) / 255
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mix blends c towards o by t (0..1). Default colours are returned as-is.
|
||||||
|
func (c Color) Mix(o Color, t float64) Color {
|
||||||
|
if c == 0 || o == 0 {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
r1, g1, b1 := c.rgb()
|
||||||
|
r2, g2, b2 := o.rgb()
|
||||||
|
f := func(a, b float64) uint8 { return uint8((a + (b-a)*t) * 255) }
|
||||||
|
return RGB(f(r1, r2), f(g1, g2), f(b1, b2))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Luma returns perceived brightness 0..1.
|
||||||
|
func (c Color) Luma() float64 {
|
||||||
|
r, g, b := c.rgb()
|
||||||
|
return 0.299*r + 0.587*g + 0.114*b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attr is a text attribute bit set.
|
||||||
|
type Attr uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
Bold Attr = 1 << iota
|
||||||
|
Faint
|
||||||
|
Italic
|
||||||
|
Underline
|
||||||
|
Reverse
|
||||||
|
)
|
||||||
|
|
||||||
|
// Style is a cell style.
|
||||||
|
type Style struct {
|
||||||
|
FG, BG Color
|
||||||
|
Attr Attr
|
||||||
|
}
|
||||||
|
|
||||||
|
type cell struct {
|
||||||
|
s string // "" marks the continuation of a wide cluster
|
||||||
|
st Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas is an in-memory cell grid that every view paints into. It is
|
||||||
|
// serialised once per frame.
|
||||||
|
type Canvas struct {
|
||||||
|
W, H int
|
||||||
|
c []cell
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCanvas creates a canvas filled with spaces in style st.
|
||||||
|
func NewCanvas(w, h int, st Style) *Canvas {
|
||||||
|
cv := &Canvas{W: max(w, 0), H: max(h, 0)}
|
||||||
|
cv.c = make([]cell, cv.W*cv.H)
|
||||||
|
for i := range cv.c {
|
||||||
|
cv.c[i] = cell{" ", st}
|
||||||
|
}
|
||||||
|
return cv
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cv *Canvas) in(x, y int) bool { return x >= 0 && y >= 0 && x < cv.W && y < cv.H }
|
||||||
|
|
||||||
|
// put writes one cluster of width w at (x, y), repairing any wide cluster it
|
||||||
|
// overlaps so the grid never holds half a character.
|
||||||
|
func (cv *Canvas) put(x, y int, s string, w int, st Style) {
|
||||||
|
if !cv.in(x, y) || x+w > cv.W {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row := y * cv.W
|
||||||
|
for i := x; i < x+w; i++ {
|
||||||
|
c := &cv.c[row+i]
|
||||||
|
if c.s == "" && i > 0 && i == x { // overwriting the tail of a wide cluster
|
||||||
|
cv.c[row+i-1].s = " "
|
||||||
|
}
|
||||||
|
if c.s != "" && textutil.Width(c.s) > 1 && i+1 < cv.W { // overwriting its head
|
||||||
|
if n := &cv.c[row+i+1]; n.s == "" {
|
||||||
|
n.s = " "
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cv.c[row+x] = cell{s, st}
|
||||||
|
for i := 1; i < w; i++ {
|
||||||
|
cv.c[row+x+i] = cell{"", st}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text draws s at (x, y), clipped to maxW cells and the canvas. Control
|
||||||
|
// characters are replaced as a second line of defence (callers sanitise
|
||||||
|
// untrusted strings first). It returns the number of cells used.
|
||||||
|
func (cv *Canvas) Text(x, y int, s string, maxW int, st Style) int {
|
||||||
|
if y < 0 || y >= cv.H {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
limit := min(x+maxW, cv.W)
|
||||||
|
cx := x
|
||||||
|
textutil.Clusters(s, func(c string, w int) bool {
|
||||||
|
if r := c[0]; r < 0x20 || r == 0x7f || (len(c) > 1 && c[0] == 0xc2 && c[1] < 0xa0) {
|
||||||
|
c, w = "?", 1
|
||||||
|
}
|
||||||
|
if cx+w > limit {
|
||||||
|
// A wide cluster that does not fit leaves a blank cell.
|
||||||
|
for ; cx < limit; cx++ {
|
||||||
|
if cx >= 0 {
|
||||||
|
cv.put(cx, y, " ", 1, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if cx >= 0 {
|
||||||
|
cv.put(cx, y, c, w, st)
|
||||||
|
}
|
||||||
|
cx += w
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return cx - x
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextCentered draws s centred within [x, x+w).
|
||||||
|
func (cv *Canvas) TextCentered(x, y, w int, s string, st Style) {
|
||||||
|
s = textutil.Truncate(s, w)
|
||||||
|
cv.Text(x+(w-textutil.Width(s))/2, y, s, w, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TextRight draws s right-aligned ending at x+w.
|
||||||
|
func (cv *Canvas) TextRight(x, y, w int, s string, st Style) {
|
||||||
|
s = textutil.Truncate(s, w)
|
||||||
|
cv.Text(x+w-textutil.Width(s), y, s, w, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill fills r with the cluster ch.
|
||||||
|
func (cv *Canvas) Fill(r treemap.Rect, ch string, st Style) {
|
||||||
|
for y := max(r.Y, 0); y < min(r.Y+r.H, cv.H); y++ {
|
||||||
|
for x := max(r.X, 0); x < min(r.X+r.W, cv.W); x++ {
|
||||||
|
cv.put(x, y, ch, 1, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restyle applies fn to the style of every cell in r.
|
||||||
|
func (cv *Canvas) Restyle(r treemap.Rect, fn func(Style) Style) {
|
||||||
|
for y := max(r.Y, 0); y < min(r.Y+r.H, cv.H); y++ {
|
||||||
|
for x := max(r.X, 0); x < min(r.X+r.W, cv.W); x++ {
|
||||||
|
c := &cv.c[y*cv.W+x]
|
||||||
|
c.st = fn(c.st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Box glyph sets.
|
||||||
|
type BoxChars struct{ H, V, TL, TR, BL, BR string }
|
||||||
|
|
||||||
|
var (
|
||||||
|
BoxLight = BoxChars{"─", "│", "┌", "┐", "└", "┘"}
|
||||||
|
BoxRound = BoxChars{"─", "│", "╭", "╮", "╰", "╯"}
|
||||||
|
BoxDouble = BoxChars{"═", "║", "╔", "╗", "╚", "╝"}
|
||||||
|
BoxHeavy = BoxChars{"━", "┃", "┏", "┓", "┗", "┛"}
|
||||||
|
BoxASCII = BoxChars{"-", "|", "+", "+", "+", "+"}
|
||||||
|
BoxASCIISel = BoxChars{"=", "#", "#", "#", "#", "#"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Box draws a border around r (which must be at least 2×2).
|
||||||
|
func (cv *Canvas) Box(r treemap.Rect, b BoxChars, st Style) {
|
||||||
|
if r.W < 2 || r.H < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x1, y1 := r.X+r.W-1, r.Y+r.H-1
|
||||||
|
for x := r.X + 1; x < x1; x++ {
|
||||||
|
cv.put(x, r.Y, b.H, 1, st)
|
||||||
|
cv.put(x, y1, b.H, 1, st)
|
||||||
|
}
|
||||||
|
for y := r.Y + 1; y < y1; y++ {
|
||||||
|
cv.put(r.X, y, b.V, 1, st)
|
||||||
|
cv.put(x1, y, b.V, 1, st)
|
||||||
|
}
|
||||||
|
cv.put(r.X, r.Y, b.TL, 1, st)
|
||||||
|
cv.put(x1, r.Y, b.TR, 1, st)
|
||||||
|
cv.put(r.X, y1, b.BL, 1, st)
|
||||||
|
cv.put(x1, y1, b.BR, 1, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HLine draws a horizontal rule.
|
||||||
|
func (cv *Canvas) HLine(x, y, w int, ch string, st Style) {
|
||||||
|
for i := x; i < x+w; i++ {
|
||||||
|
cv.put(i, y, ch, 1, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cell returns the text and style at (x, y) (for tests).
|
||||||
|
func (cv *Canvas) Cell(x, y int) (string, Style) {
|
||||||
|
if !cv.in(x, y) {
|
||||||
|
return "", Style{}
|
||||||
|
}
|
||||||
|
c := cv.c[y*cv.W+x]
|
||||||
|
return c.s, c.st
|
||||||
|
}
|
||||||
|
|
||||||
|
// Line returns the plain text of row y (for tests).
|
||||||
|
func (cv *Canvas) Line(y int) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for x := 0; x < cv.W; x++ {
|
||||||
|
b.WriteString(cv.c[y*cv.W+x].s)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sgr(b *strings.Builder, st Style, p256 bool) {
|
||||||
|
b.WriteString("\x1b[0")
|
||||||
|
if st.Attr&Bold != 0 {
|
||||||
|
b.WriteString(";1")
|
||||||
|
}
|
||||||
|
if st.Attr&Faint != 0 {
|
||||||
|
b.WriteString(";2")
|
||||||
|
}
|
||||||
|
if st.Attr&Italic != 0 {
|
||||||
|
b.WriteString(";3")
|
||||||
|
}
|
||||||
|
if st.Attr&Underline != 0 {
|
||||||
|
b.WriteString(";4")
|
||||||
|
}
|
||||||
|
if st.Attr&Reverse != 0 {
|
||||||
|
b.WriteString(";7")
|
||||||
|
}
|
||||||
|
col := func(c Color, base string) {
|
||||||
|
if c == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p256 {
|
||||||
|
b.WriteString(";" + base + ";5;")
|
||||||
|
b.WriteString(strconv.Itoa(int(quant256(c))))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteString(";" + base + ";2;")
|
||||||
|
b.WriteString(strconv.Itoa(int(c >> 16 & 0xff)))
|
||||||
|
b.WriteByte(';')
|
||||||
|
b.WriteString(strconv.Itoa(int(c >> 8 & 0xff)))
|
||||||
|
b.WriteByte(';')
|
||||||
|
b.WriteString(strconv.Itoa(int(c & 0xff)))
|
||||||
|
}
|
||||||
|
col(st.FG, "38")
|
||||||
|
col(st.BG, "48")
|
||||||
|
b.WriteByte('m')
|
||||||
|
}
|
||||||
|
|
||||||
|
// String serialises the canvas as lines of text with SGR styling. Colours
|
||||||
|
// are emitted as 24-bit, or as 256-colour indices from our own quantizer
|
||||||
|
// when p256 is set; Bubble Tea downsamples further (16 colours, none) to
|
||||||
|
// the terminal's profile.
|
||||||
|
func (cv *Canvas) String() string { return cv.Render(false) }
|
||||||
|
|
||||||
|
// Render serialises the canvas; see String.
|
||||||
|
func (cv *Canvas) Render(p256 bool) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(cv.W * cv.H * 4)
|
||||||
|
for y := 0; y < cv.H; y++ {
|
||||||
|
if y > 0 {
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
var cur Style
|
||||||
|
first := true
|
||||||
|
for x := 0; x < cv.W; x++ {
|
||||||
|
c := cv.c[y*cv.W+x]
|
||||||
|
if c.s == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if first || c.st != cur {
|
||||||
|
sgr(&b, c.st, p256)
|
||||||
|
cur, first = c.st, false
|
||||||
|
}
|
||||||
|
b.WriteString(c.s)
|
||||||
|
}
|
||||||
|
b.WriteString("\x1b[0m")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
|
// The stock 256-colour conversion maps dark, slightly tinted colours to
|
||||||
|
// saturated cube entries (olive, navy), which wrecks the subtle shading the
|
||||||
|
// treemap relies on. quant256 picks the perceptually nearest entry (redmean
|
||||||
|
// distance) among the cube and the grey ramp, never the palette-dependent
|
||||||
|
// first 16 entries.
|
||||||
|
|
||||||
|
var (
|
||||||
|
cubeLevels = [6]int{0, 95, 135, 175, 215, 255}
|
||||||
|
q256Cache sync.Map // Color -> uint8
|
||||||
|
)
|
||||||
|
|
||||||
|
func redmean(r1, g1, b1, r2, g2, b2 int) int {
|
||||||
|
rm := (r1 + r2) / 2
|
||||||
|
dr, dg, db := r1-r2, g1-g2, b1-b2
|
||||||
|
return ((512+rm)*dr*dr)>>8 + 4*dg*dg + ((767-rm)*db*db)>>8
|
||||||
|
}
|
||||||
|
|
||||||
|
func quant256(c Color) uint8 {
|
||||||
|
if v, ok := q256Cache.Load(c); ok {
|
||||||
|
return v.(uint8)
|
||||||
|
}
|
||||||
|
r, g, b := int(c>>16&0xff), int(c>>8&0xff), int(c&0xff)
|
||||||
|
best, bestD := 16, 1<<62
|
||||||
|
for i := 0; i < 216; i++ {
|
||||||
|
cr, cg, cb := cubeLevels[i/36], cubeLevels[i/6%6], cubeLevels[i%6]
|
||||||
|
if d := redmean(r, g, b, cr, cg, cb); d < bestD {
|
||||||
|
best, bestD = 16+i, d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < 24; i++ {
|
||||||
|
v := 8 + 10*i
|
||||||
|
if d := redmean(r, g, b, v, v, v); d < bestD {
|
||||||
|
best, bestD = 232+i, d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q256Cache.Store(c, uint8(best))
|
||||||
|
return uint8(best)
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/snapshot"
|
||||||
|
)
|
||||||
|
|
||||||
|
// kids is a cached, size-sorted child list (zero-size entries removed).
|
||||||
|
type kids struct {
|
||||||
|
ids []inventory.NodeID
|
||||||
|
sizes []int64
|
||||||
|
total int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// sizeOf returns the size that drives layout: the filtered size when a
|
||||||
|
// filter is active, otherwise the aggregate in the current size mode.
|
||||||
|
// Callers hold the tree read lock.
|
||||||
|
func (m *Model) sizeOf(id inventory.NodeID) int64 {
|
||||||
|
if r := m.search.result; r != nil {
|
||||||
|
return r.Size(id)
|
||||||
|
}
|
||||||
|
n := m.tree.Node(id)
|
||||||
|
if n.Flags&inventory.FlagDeleted != 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n.Total(m.sizeMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// kidsOf returns the sorted children of id, cached until data changes.
|
||||||
|
func (m *Model) kidsOf(id inventory.NodeID) *kids {
|
||||||
|
if m.kidsVer != m.dataVer {
|
||||||
|
clear(m.kidsCache)
|
||||||
|
m.kidsVer = m.dataVer
|
||||||
|
}
|
||||||
|
if k, ok := m.kidsCache[id]; ok {
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
k := &kids{}
|
||||||
|
m.tree.Children(id, func(c inventory.NodeID, _ *inventory.Node) {
|
||||||
|
if s := m.sizeOf(c); s > 0 {
|
||||||
|
k.ids = append(k.ids, c)
|
||||||
|
k.sizes = append(k.sizes, s)
|
||||||
|
k.total += s
|
||||||
|
}
|
||||||
|
})
|
||||||
|
sort.Sort(byKidSize{k})
|
||||||
|
m.kidsCache[id] = k
|
||||||
|
return k
|
||||||
|
}
|
||||||
|
|
||||||
|
type byKidSize struct{ *kids }
|
||||||
|
|
||||||
|
func (b byKidSize) Len() int { return len(b.ids) }
|
||||||
|
func (b byKidSize) Less(i, j int) bool {
|
||||||
|
if b.sizes[i] != b.sizes[j] {
|
||||||
|
return b.sizes[i] > b.sizes[j]
|
||||||
|
}
|
||||||
|
return b.ids[i] < b.ids[j]
|
||||||
|
}
|
||||||
|
func (b byKidSize) Swap(i, j int) {
|
||||||
|
b.ids[i], b.ids[j] = b.ids[j], b.ids[i]
|
||||||
|
b.sizes[i], b.sizes[j] = b.sizes[j], b.sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectedNode returns the selected inventory node (NoNode for groups).
|
||||||
|
func (m *Model) selectedNode() inventory.NodeID {
|
||||||
|
if m.sel == noSel || isGroup(m.sel) || m.sel >= int64(m.tree.Len()) {
|
||||||
|
return inventory.NoNode
|
||||||
|
}
|
||||||
|
return inventory.NodeID(m.sel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSelection keeps the selection meaningful after any change: if the
|
||||||
|
// selected node is no longer a direct, visible child of the zoom root it
|
||||||
|
// falls back to the visible ancestor, then the group containing it, then
|
||||||
|
// the largest child. Tree read lock held.
|
||||||
|
func (m *Model) ensureSelection() {
|
||||||
|
k := m.kidsOf(m.zoom)
|
||||||
|
if len(k.ids) == 0 {
|
||||||
|
m.sel = noSel
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !m.userSel {
|
||||||
|
// Until the user picks something, follow the largest block (it
|
||||||
|
// changes while a scan is running).
|
||||||
|
m.sel = int64(k.ids[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isGroup(m.sel) {
|
||||||
|
if groupParent(m.sel) == m.zoom {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.sel = noSel
|
||||||
|
}
|
||||||
|
id := m.selectedNode()
|
||||||
|
if id != inventory.NoNode {
|
||||||
|
// Walk up to the direct child of the zoom root.
|
||||||
|
for cur := id; cur != inventory.NoNode; cur = m.tree.Node(cur).Parent {
|
||||||
|
if m.tree.Node(cur).Parent == m.zoom {
|
||||||
|
if m.sizeOf(cur) > 0 {
|
||||||
|
m.sel = int64(cur)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.sel = int64(k.ids[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibleSel maps the selection onto the treemap blocks: a node collapsed
|
||||||
|
// into the LOD group selects the group.
|
||||||
|
func (m *Model) visibleSel(blocks map[int64]bool) int64 {
|
||||||
|
if blocks[m.sel] {
|
||||||
|
return m.sel
|
||||||
|
}
|
||||||
|
if !isGroup(m.sel) && blocks[groupID(m.zoom)] {
|
||||||
|
return groupID(m.zoom)
|
||||||
|
}
|
||||||
|
return m.sel
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoomTo makes dir the treemap root.
|
||||||
|
func (m *Model) zoomTo(dir inventory.NodeID) {
|
||||||
|
if dir == m.zoom {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prev := m.zoom
|
||||||
|
m.zoom = dir
|
||||||
|
// Coming back out, keep the directory we left selected.
|
||||||
|
if m.tree.IsAncestor(dir, prev) {
|
||||||
|
m.sel = int64(prev)
|
||||||
|
} else {
|
||||||
|
m.sel = noSel
|
||||||
|
}
|
||||||
|
m.userSel = m.sel != noSel
|
||||||
|
m.zoomed = true
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) setView(i int) {
|
||||||
|
if i >= 0 && i < len(m.views) {
|
||||||
|
m.view = i
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// colorOf returns the base colour for a block.
|
||||||
|
func (m *Model) colorOf(id int64) Color {
|
||||||
|
t := m.theme
|
||||||
|
if isGroup(id) {
|
||||||
|
return t.Group
|
||||||
|
}
|
||||||
|
nid := inventory.NodeID(id)
|
||||||
|
n := m.tree.Node(nid)
|
||||||
|
if d := m.opts.Diff; d != nil {
|
||||||
|
switch d.StatusOf(nid, m.sizeMode) {
|
||||||
|
case snapshot.Added:
|
||||||
|
return t.DiffNew
|
||||||
|
case snapshot.Grew:
|
||||||
|
old, _ := d.OldSize(nid, m.sizeMode)
|
||||||
|
return t.DiffSame.Mix(t.DiffGrow, ratio(n.Total(m.sizeMode)-old, old))
|
||||||
|
case snapshot.Shrank:
|
||||||
|
old, _ := d.OldSize(nid, m.sizeMode)
|
||||||
|
return t.DiffSame.Mix(t.DiffShrink, ratio(old-n.Total(m.sizeMode), old))
|
||||||
|
}
|
||||||
|
return t.DiffSame
|
||||||
|
}
|
||||||
|
if !n.IsDir() {
|
||||||
|
if t.Ext != nil {
|
||||||
|
if c, ok := t.Ext[m.tree.ExtName(n)]; ok {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t.Cat[n.Cat]
|
||||||
|
}
|
||||||
|
cs := m.tree.CatSizes(nid)
|
||||||
|
best, bestV := -1, int64(0)
|
||||||
|
for i, v := range cs {
|
||||||
|
if v > bestV {
|
||||||
|
best, bestV = i, v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best < 0 {
|
||||||
|
return t.EmptyDir
|
||||||
|
}
|
||||||
|
return t.Cat[best]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ratio maps a relative change to 0.35..1 for colour intensity.
|
||||||
|
func ratio(delta, base int64) float64 {
|
||||||
|
if base <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
r := float64(delta) / float64(base)
|
||||||
|
return min(1, 0.35+r*0.65)
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
"github.com/andydixon/mapsize/internal/treemap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// infoView shows scan statistics and the error list.
|
||||||
|
type infoView struct{ tb table }
|
||||||
|
|
||||||
|
func (*infoView) name() string { return "Scan" }
|
||||||
|
|
||||||
|
func (v *infoView) focusErrors(m *Model) { v.tb.cursor = 0 }
|
||||||
|
|
||||||
|
func (v *infoView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
||||||
|
t := m.theme
|
||||||
|
cv.Fill(r, " ", t.base())
|
||||||
|
st := &m.tree.Stats
|
||||||
|
root := m.tree.Node(m.tree.Root())
|
||||||
|
lw := min(52, r.W/2)
|
||||||
|
if r.W < 100 {
|
||||||
|
lw = r.W - 2
|
||||||
|
}
|
||||||
|
y := r.Y + 1
|
||||||
|
x := r.X + 2
|
||||||
|
label := Style{FG: t.Muted, BG: t.Bg}
|
||||||
|
value := Style{FG: t.Fg, BG: t.Bg, Attr: Bold}
|
||||||
|
line := func(k, val string, vs Style) {
|
||||||
|
if y >= r.Y+r.H {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cv.Text(x, y, k, 20, label)
|
||||||
|
cv.Text(x+20, y, val, lw-20, vs)
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
head := func(s string) {
|
||||||
|
if y >= r.Y+r.H-1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
y++
|
||||||
|
cv.Text(x, y, s, lw, Style{FG: t.Accent, BG: t.Bg, Attr: Bold})
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
state := "Complete"
|
||||||
|
stateSt := Style{FG: t.OK, BG: t.Bg, Attr: Bold}
|
||||||
|
switch {
|
||||||
|
case m.scanning:
|
||||||
|
state, stateSt.FG = "Scanning…", t.Accent
|
||||||
|
case st.Cancelled:
|
||||||
|
state, stateSt.FG = "Cancelled — totals incomplete", t.Warn
|
||||||
|
case st.Incomplete():
|
||||||
|
state, stateSt.FG = "Complete, with unreadable areas", t.Warn
|
||||||
|
}
|
||||||
|
cv.Text(x, y, "SCAN INFORMATION", lw, Style{FG: t.Accent, BG: t.Bg, Attr: Bold})
|
||||||
|
y++
|
||||||
|
line("Root", textutil.Sanitize(st.Root), value)
|
||||||
|
line("State", state, stateSt)
|
||||||
|
if st.FromSnapshot != "" {
|
||||||
|
line("Snapshot", textutil.Sanitize(st.FromSnapshot), value)
|
||||||
|
}
|
||||||
|
files := st.Files + st.Symlinks + st.Others
|
||||||
|
line("Items scanned", textutil.Count(files+st.Dirs), value)
|
||||||
|
line(" Files", textutil.Count(st.Files), value)
|
||||||
|
line(" Directories", textutil.Count(st.Dirs), value)
|
||||||
|
line(" Symlinks", textutil.Count(st.Symlinks), value)
|
||||||
|
line(" Special", textutil.Count(st.Others), value)
|
||||||
|
line("Logical size", fmt.Sprintf("%s (%s bytes)", textutil.Size(root.TotSize), textutil.Count(root.TotSize)), value)
|
||||||
|
line("Allocated size", fmt.Sprintf("%s (%s bytes)", textutil.Size(root.TotAlloc), textutil.Count(root.TotAlloc)), value)
|
||||||
|
el := st.Elapsed()
|
||||||
|
if st.FromSnapshot == "" {
|
||||||
|
line("Elapsed", textutil.Duration(el), value)
|
||||||
|
line("Rate", textutil.Count(int64(float64(files+st.Dirs)/max(el.Seconds(), 0.001)))+" items/s", value)
|
||||||
|
line("Workers", fmt.Sprintf("%d (%d CPUs)", st.Workers, runtime.NumCPU()), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
head("ERRORS AND OMISSIONS")
|
||||||
|
anyIssue := false
|
||||||
|
for k, c := range st.ErrCounts {
|
||||||
|
if c > 0 {
|
||||||
|
line(inventory.ErrKind(k).String(), textutil.Count(c), Style{FG: t.Err, BG: t.Bg, Attr: Bold})
|
||||||
|
anyIssue = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type row struct {
|
||||||
|
k string
|
||||||
|
v int64
|
||||||
|
}
|
||||||
|
for _, rr := range []row{{"Unscanned dirs", st.Unscanned}, {"Excluded", st.Excluded}, {"Other FS skipped", st.SkippedMounts},
|
||||||
|
{"Virtual FS skipped", st.VirtualSkipped}, {"Loops skipped", st.LoopsSkipped}, {"Broken symlinks", st.BrokenLinks},
|
||||||
|
{"Hard-link dups", st.HardlinkDups}} {
|
||||||
|
if rr.v > 0 {
|
||||||
|
line(rr.k, textutil.Count(rr.v), Style{FG: t.Warn, BG: t.Bg, Attr: Bold})
|
||||||
|
anyIssue = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !anyIssue {
|
||||||
|
line("None", "every entry was read", Style{FG: t.OK, BG: t.Bg})
|
||||||
|
}
|
||||||
|
head("OPTIONS")
|
||||||
|
excl := "none"
|
||||||
|
if len(st.Excludes) > 0 {
|
||||||
|
excl = textutil.Sanitize(strings.Join(st.Excludes, ", "))
|
||||||
|
}
|
||||||
|
line("Exclusions", excl, value)
|
||||||
|
line("One filesystem", fmt.Sprint(st.OneFileSystem), value)
|
||||||
|
line("Follow symlinks", st.Follow, value)
|
||||||
|
line("Size mode", m.sizeMode.String(), value)
|
||||||
|
line("Units", map[bool]string{true: "SI (1000)", false: "IEC (1024)"}[textutil.SI], value)
|
||||||
|
if root.Flags&inventory.FlagAllocUnknown != 0 {
|
||||||
|
line("Note", "allocated size unavailable on this platform", Style{FG: t.Warn, BG: t.Bg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error list on the right (or below in narrow terminals).
|
||||||
|
er := treemap.Rect{X: r.X + lw + 4, Y: r.Y, W: r.W - lw - 4, H: r.H}
|
||||||
|
if r.W < 100 {
|
||||||
|
er = treemap.Rect{X: r.X, Y: y + 1, W: r.W, H: r.Y + r.H - y - 1}
|
||||||
|
}
|
||||||
|
if er.W < 30 || er.H < 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows := make([]tableRow, len(st.Errors))
|
||||||
|
for i, e := range st.Errors {
|
||||||
|
p := m.tree.Path(e.Node)
|
||||||
|
if e.Name != "" {
|
||||||
|
p += "/" + e.Name
|
||||||
|
}
|
||||||
|
rows[i] = tableRow{cells: []string{e.Kind.String(), textutil.Sanitize(e.Msg), textutil.Sanitize(p)},
|
||||||
|
styles: []*Style{{FG: t.Err, BG: t.Bg}, {FG: t.Muted, BG: t.Bg}, nil}, bar: -1}
|
||||||
|
}
|
||||||
|
title := fmt.Sprintf("ERROR LIST (%s)", textutil.Count(st.TotalErrors()))
|
||||||
|
if int64(len(st.Errors)) < st.TotalErrors() {
|
||||||
|
title += fmt.Sprintf(" — first %s shown", textutil.Count(int64(len(st.Errors))))
|
||||||
|
}
|
||||||
|
v.tb.paint(m, cv, er, []column{{"KIND", 20, false}, {"DETAIL", 24, false}, {"PATH", 0, false}}, rows, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *infoView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||||
|
errs := m.tree.Stats.Errors
|
||||||
|
if v.tb.key(k, len(errs)) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if v.tb.cursor < len(errs) && (k == "enter" || k == "space") {
|
||||||
|
m.jumpTo(errs[v.tb.cursor].Node)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *infoView) wheel(m *Model, up bool) tea.Cmd {
|
||||||
|
v.tb.move(map[bool]int{true: -3, false: 3}[up], len(m.tree.Stats.Errors))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/platform"
|
||||||
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
"github.com/andydixon/mapsize/internal/treemap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// view is one of the Tab-cycled main views.
|
||||||
|
type view interface {
|
||||||
|
name() string
|
||||||
|
paint(m *Model, cv *Canvas, r treemap.Rect)
|
||||||
|
key(m *Model, k string) (bool, tea.Cmd)
|
||||||
|
wheel(m *Model, up bool) tea.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyName(k tea.KeyPressMsg) string {
|
||||||
|
switch s := k.String(); s {
|
||||||
|
case " ":
|
||||||
|
return "space"
|
||||||
|
default:
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKey dispatches a key press. It holds the tree read lock for the
|
||||||
|
// whole handler; helpers it calls must not lock again (a recursive RLock
|
||||||
|
// can deadlock against a waiting writer).
|
||||||
|
func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
|
||||||
|
k := keyName(msg)
|
||||||
|
if k == "ctrl+c" {
|
||||||
|
return m.ctrlCPressed()
|
||||||
|
}
|
||||||
|
t := m.tree
|
||||||
|
t.RLock()
|
||||||
|
defer t.RUnlock()
|
||||||
|
m.ensureSelection()
|
||||||
|
if len(m.modals) > 0 {
|
||||||
|
return m.modalKey(msg)
|
||||||
|
}
|
||||||
|
if m.search.editing {
|
||||||
|
return m.searchKey(msg)
|
||||||
|
}
|
||||||
|
handled, cmd := m.views[m.view].key(m, k)
|
||||||
|
if handled {
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
switch k {
|
||||||
|
case "q":
|
||||||
|
m.quit = true
|
||||||
|
case "?", "f1":
|
||||||
|
m.openModal(newHelpModal(m))
|
||||||
|
case "/":
|
||||||
|
m.search.begin()
|
||||||
|
case "esc":
|
||||||
|
if m.search.query != nil {
|
||||||
|
m.clearFilter()
|
||||||
|
} else if m.zoom != m.tree.Root() {
|
||||||
|
m.zoomOut()
|
||||||
|
}
|
||||||
|
case "tab":
|
||||||
|
m.setView((m.view + 1) % len(m.views))
|
||||||
|
case "shift+tab":
|
||||||
|
m.setView((m.view + len(m.views) - 1) % len(m.views))
|
||||||
|
case "1", "2", "3", "4", "5", "6", "7":
|
||||||
|
m.setView(int(k[0] - '1'))
|
||||||
|
case "enter":
|
||||||
|
return m.inspectSelection()
|
||||||
|
case "space", "right":
|
||||||
|
return m.zoomIntoSelection()
|
||||||
|
case "backspace", "left":
|
||||||
|
m.zoomOut()
|
||||||
|
case "home":
|
||||||
|
m.zoomTo(m.tree.Root())
|
||||||
|
case "a":
|
||||||
|
if m.sizeMode == inventory.SizeAllocated {
|
||||||
|
m.sizeMode = inventory.SizeLogical
|
||||||
|
} else {
|
||||||
|
m.sizeMode = inventory.SizeAllocated
|
||||||
|
}
|
||||||
|
m.invalidate()
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
if m.search.query != nil {
|
||||||
|
cmds = append(cmds, m.applyFilter())
|
||||||
|
}
|
||||||
|
cmds = append(cmds, m.info("Sizing by "+m.sizeMode.String()+" size"))
|
||||||
|
return tea.Batch(cmds...)
|
||||||
|
case "r":
|
||||||
|
return m.rescan()
|
||||||
|
case "s":
|
||||||
|
m.openModal(newSaveModal(m))
|
||||||
|
case "e":
|
||||||
|
m.setViewByName("Scan")
|
||||||
|
if v, ok := m.views[m.view].(*infoView); ok {
|
||||||
|
v.focusErrors(m)
|
||||||
|
}
|
||||||
|
case "x":
|
||||||
|
m.setViewByName("Types")
|
||||||
|
case "g":
|
||||||
|
m.setViewByName("Top")
|
||||||
|
case "D":
|
||||||
|
return m.startDuplicates()
|
||||||
|
case "c":
|
||||||
|
return m.copyPath()
|
||||||
|
case "o":
|
||||||
|
return m.reveal()
|
||||||
|
case "d", "delete":
|
||||||
|
return m.requestTrash()
|
||||||
|
case "T":
|
||||||
|
names := ThemeNames()
|
||||||
|
cur := 0
|
||||||
|
for i, n := range names {
|
||||||
|
if n == m.theme.Name {
|
||||||
|
cur = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ascii := m.theme.ASCII
|
||||||
|
m.theme = LoadTheme(names[(cur+1)%len(names)], m.opts.Settings)
|
||||||
|
m.theme.ASCII = ascii
|
||||||
|
return m.info("Theme: " + m.theme.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) setViewByName(n string) {
|
||||||
|
for i, v := range m.views {
|
||||||
|
if v.name() == n {
|
||||||
|
m.setView(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) inspectSelection() tea.Cmd {
|
||||||
|
if isGroup(m.sel) {
|
||||||
|
if g, ok := m.groupInfo(m.sel); ok {
|
||||||
|
m.openModal(newGroupModal(m, g))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if id := m.selectedNode(); id != inventory.NoNode {
|
||||||
|
m.openModal(newInfoModal(m, id))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) zoomIntoSelection() tea.Cmd {
|
||||||
|
if isGroup(m.sel) {
|
||||||
|
// Groups cannot be zoomed: show their members in the list view.
|
||||||
|
if g, ok := m.groupInfo(m.sel); ok && len(g.ids) > 0 {
|
||||||
|
m.sel = int64(g.ids[0])
|
||||||
|
m.setViewByName("List")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
id := m.selectedNode()
|
||||||
|
if id == inventory.NoNode {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !m.tree.Node(id).IsDir() {
|
||||||
|
return m.info("Not a directory — Enter shows details")
|
||||||
|
}
|
||||||
|
if len(m.kidsOf(id).ids) == 0 {
|
||||||
|
return m.info("Directory is empty")
|
||||||
|
}
|
||||||
|
m.zoomTo(id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) zoomOut() {
|
||||||
|
if p := m.tree.Node(m.zoom).Parent; p != inventory.NoNode {
|
||||||
|
m.zoomTo(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jumpTo zooms to a node's parent directory and selects it.
|
||||||
|
func (m *Model) jumpTo(id inventory.NodeID) {
|
||||||
|
p := m.tree.Node(id).Parent
|
||||||
|
if p == inventory.NoNode {
|
||||||
|
m.zoomTo(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.zoomTo(p)
|
||||||
|
m.sel = int64(id)
|
||||||
|
m.setViewByName("Map")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) selectedPath() (string, bool) {
|
||||||
|
id := m.selectedNode()
|
||||||
|
if id == inventory.NoNode {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return m.tree.Path(id), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) copyPath() tea.Cmd {
|
||||||
|
p, ok := m.selectedPath()
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tea.Batch(tea.SetClipboard(p), m.info("Copied path to clipboard (OSC 52)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) reveal() tea.Cmd {
|
||||||
|
if m.snapshot {
|
||||||
|
return m.warn("Snapshot — the files may not exist on this machine")
|
||||||
|
}
|
||||||
|
p, ok := m.selectedPath()
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := platform.Reveal(p); err != nil {
|
||||||
|
return m.warn("Open failed: " + textutil.Sanitize(err.Error()))
|
||||||
|
}
|
||||||
|
return m.info("Opened in file manager")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) saveSnapshot(path string) tea.Cmd {
|
||||||
|
t := m.tree
|
||||||
|
return func() tea.Msg {
|
||||||
|
t.RLock()
|
||||||
|
err := saveFile(path, t)
|
||||||
|
t.RUnlock()
|
||||||
|
return saveDoneMsg{path, err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type saveDoneMsg struct {
|
||||||
|
path string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSnapshotName(root string) string {
|
||||||
|
base := filepath.Base(root)
|
||||||
|
if base == string(filepath.Separator) || base == "." || base == "" {
|
||||||
|
base = "root"
|
||||||
|
}
|
||||||
|
wd, _ := os.Getwd()
|
||||||
|
return filepath.Join(wd, fmt.Sprintf("%s-%s.msz", base, time.Now().Format("20060102-1504")))
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/scan"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLiveScanRendering renders, navigates, filters and resizes while the
|
||||||
|
// scanner mutates the inventory. Run with -race to verify the locking
|
||||||
|
// discipline.
|
||||||
|
func TestLiveScanRendering(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
for i := range 30 {
|
||||||
|
d := filepath.Join(root, fmt.Sprintf("d%02d", i), "sub")
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for j := range 40 {
|
||||||
|
os.WriteFile(filepath.Join(d, fmt.Sprintf("f%d.log", j)), make([]byte, (i+1)*(j+1)*100), 0o644)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start := func(ctx context.Context) (*scan.Scanner, *inventory.Tree, error) {
|
||||||
|
s, err := scan.Start(ctx, root, scan.Options{Workers: 4, ChunkSize: 3})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return s, s.Tree, nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
s, tr, err := start(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := newModel(Options{Start: start}, s, tr, cancel)
|
||||||
|
sizes := [][2]int{{120, 40}, {80, 24}, {200, 60}, {45, 12}}
|
||||||
|
deadline := time.After(20 * time.Second)
|
||||||
|
for i := 0; ; i++ {
|
||||||
|
send(m, size(sizes[i%len(sizes)][0], sizes[i%len(sizes)][1]), tickMsg(time.Now()))
|
||||||
|
send(m, key([]string{"right", "down", "left", "up"}[i%4]))
|
||||||
|
if i%5 == 0 {
|
||||||
|
send(m, key("tab"))
|
||||||
|
}
|
||||||
|
if i == 3 {
|
||||||
|
m.search.ed.set("ext = log")
|
||||||
|
m.queryChanged()
|
||||||
|
send(m, m.applyFilter()())
|
||||||
|
}
|
||||||
|
m.dirty = true
|
||||||
|
m.View()
|
||||||
|
select {
|
||||||
|
case <-s.Done():
|
||||||
|
send(m, scanDoneMsg{s})
|
||||||
|
m.View()
|
||||||
|
if m.scanning {
|
||||||
|
t.Fatal("scan should be finished")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("scan did not finish")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
"github.com/andydixon/mapsize/internal/treemap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tmLayout is the treemap geometry for one (zoom, rect, data version). It is
|
||||||
|
// recomputed from scratch whenever any of those change — after a resize the
|
||||||
|
// layout algorithm runs again for the new rectangle rather than scaling the
|
||||||
|
// old one.
|
||||||
|
type tmLayout struct {
|
||||||
|
zoom inventory.NodeID
|
||||||
|
rect treemap.Rect
|
||||||
|
ver int
|
||||||
|
blocks []treemap.Block // top level: navigable
|
||||||
|
nested []nested // previews inside directories: drawn only
|
||||||
|
groups map[int64]*group
|
||||||
|
visible map[int64]bool
|
||||||
|
inner map[int64]bool // top-level blocks with nested previews
|
||||||
|
}
|
||||||
|
|
||||||
|
type nested struct {
|
||||||
|
id int64
|
||||||
|
r treemap.Rect
|
||||||
|
depth int
|
||||||
|
alt bool
|
||||||
|
inner bool // has nested children drawn inside it
|
||||||
|
}
|
||||||
|
|
||||||
|
type group struct {
|
||||||
|
parent inventory.NodeID
|
||||||
|
ids []inventory.NodeID
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// LOD parameters.
|
||||||
|
const (
|
||||||
|
topMinArea = 12 // cells: room for a 4×3 framed block
|
||||||
|
topMaxItems = 400
|
||||||
|
nestMinArea = 3
|
||||||
|
nestMaxItems = 120
|
||||||
|
maxNestDepth = 3
|
||||||
|
nestMinW = 4
|
||||||
|
nestMinH = 2
|
||||||
|
nestRecurseW = 14
|
||||||
|
nestRecurseH = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Model) layoutTreemap(r treemap.Rect) *tmLayout {
|
||||||
|
if l := m.tm; l != nil && l.zoom == m.zoom && l.rect == r && l.ver == m.dataVer {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
l := &tmLayout{zoom: m.zoom, rect: r, ver: m.dataVer, groups: map[int64]*group{}, visible: map[int64]bool{}, inner: map[int64]bool{}}
|
||||||
|
l.blocks = m.layoutLevel(l, m.zoom, r, topMinArea, topMaxItems)
|
||||||
|
for _, b := range l.blocks {
|
||||||
|
l.visible[b.ID] = true
|
||||||
|
if b.ID < 0 || b.W < 3 || b.H < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n := m.tree.Node(inventory.NodeID(b.ID)); n.IsDir() {
|
||||||
|
in := b.Inset(1)
|
||||||
|
if in.W >= nestMinW && in.H >= nestMinH {
|
||||||
|
before := len(l.nested)
|
||||||
|
m.layoutNested(l, inventory.NodeID(b.ID), in, 2)
|
||||||
|
l.inner[b.ID] = len(l.nested) > before
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.tm = l
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// layoutLevel lays out the children of parent in r with level-of-detail
|
||||||
|
// grouping: children too small to be useful collapse into one group block.
|
||||||
|
func (m *Model) layoutLevel(l *tmLayout, parent inventory.NodeID, r treemap.Rect, minArea, maxItems int) []treemap.Block {
|
||||||
|
k := m.kidsOf(parent)
|
||||||
|
if len(k.ids) == 0 || r.Empty() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sizes := make([]float64, len(k.sizes))
|
||||||
|
for i, s := range k.sizes {
|
||||||
|
sizes[i] = float64(s)
|
||||||
|
}
|
||||||
|
vis := treemap.Visible(sizes, float64(k.total), r.Area(), minArea, maxItems)
|
||||||
|
items := make([]treemap.Item, 0, vis+1)
|
||||||
|
for i := range vis {
|
||||||
|
items = append(items, treemap.Item{ID: int64(k.ids[i]), Size: sizes[i]})
|
||||||
|
}
|
||||||
|
if vis < len(k.ids) {
|
||||||
|
g := &group{parent: parent, ids: k.ids[vis:]}
|
||||||
|
for _, s := range k.sizes[vis:] {
|
||||||
|
g.size += s
|
||||||
|
}
|
||||||
|
gid := groupID(parent)
|
||||||
|
l.groups[gid] = g
|
||||||
|
items = append(items, treemap.Item{ID: gid, Size: float64(g.size)})
|
||||||
|
}
|
||||||
|
return treemap.Squarify(items, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) layoutNested(l *tmLayout, parent inventory.NodeID, r treemap.Rect, depth int) {
|
||||||
|
// Deeper levels need bigger blocks to stay legible rather than noisy.
|
||||||
|
blocks := m.layoutLevel(l, parent, r, nestMinArea*(depth-1)*(depth-1), nestMaxItems)
|
||||||
|
for i, b := range blocks {
|
||||||
|
nb := nested{id: b.ID, r: b.Rect, depth: depth, alt: i%2 == 1}
|
||||||
|
idx := len(l.nested)
|
||||||
|
l.nested = append(l.nested, nb)
|
||||||
|
if depth < maxNestDepth && b.ID >= 0 && b.W >= nestRecurseW && b.H >= nestRecurseH &&
|
||||||
|
m.tree.Node(inventory.NodeID(b.ID)).IsDir() {
|
||||||
|
l.nested[idx].inner = true
|
||||||
|
m.layoutNested(l, inventory.NodeID(b.ID), treemap.Rect{X: b.X, Y: b.Y + 1, W: b.W - 1, H: b.H - 2}, depth+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// groupInfo returns the LOD group for a group ID in the current layout.
|
||||||
|
func (m *Model) groupInfo(id int64) (*group, bool) {
|
||||||
|
if m.tm == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
g, ok := m.tm.groups[id]
|
||||||
|
return g, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
type mapView struct{}
|
||||||
|
|
||||||
|
func (*mapView) name() string { return "Map" }
|
||||||
|
|
||||||
|
func (v *mapView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
||||||
|
t := m.theme
|
||||||
|
l := m.layoutTreemap(r)
|
||||||
|
if len(l.blocks) == 0 {
|
||||||
|
msg := "Empty directory"
|
||||||
|
switch {
|
||||||
|
case m.search.query != nil:
|
||||||
|
msg = "No matches here — Esc clears the filter"
|
||||||
|
case m.scanning:
|
||||||
|
msg = "Scanning…"
|
||||||
|
case !m.tree.Node(m.zoom).IsDir():
|
||||||
|
msg = textutil.Sanitize(m.tree.Node(m.zoom).Name) + " — " + textutil.Size(m.sizeOf(m.zoom))
|
||||||
|
}
|
||||||
|
cv.TextCentered(r.X, r.Y+r.H/2, r.W, msg, t.muted())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sel := m.visibleSel(l.visible)
|
||||||
|
total := m.sizeOf(m.zoom)
|
||||||
|
for _, b := range l.blocks {
|
||||||
|
m.paintTop(cv, b, b.ID == sel, b.ID == m.hover, total)
|
||||||
|
}
|
||||||
|
for _, nb := range l.nested {
|
||||||
|
m.paintNested(cv, nb)
|
||||||
|
}
|
||||||
|
for _, b := range l.blocks {
|
||||||
|
id := b.ID
|
||||||
|
m.hits = append(m.hits, hit{b.Rect, func(m *Model, double bool) tea.Cmd { return m.clickBlock(id, double) }})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockName returns a sanitised display name for a block.
|
||||||
|
func (m *Model) blockName(id int64) string {
|
||||||
|
if isGroup(id) {
|
||||||
|
if g, ok := m.groupInfo(id); ok {
|
||||||
|
return textutil.Count(int64(len(g.ids))) + " smaller items"
|
||||||
|
}
|
||||||
|
return "smaller items"
|
||||||
|
}
|
||||||
|
n := m.tree.Node(inventory.NodeID(id))
|
||||||
|
name := textutil.Sanitize(n.Name)
|
||||||
|
if n.IsDir() && inventory.NodeID(id) != m.tree.Root() {
|
||||||
|
name += "/"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) blockSize(id int64) int64 {
|
||||||
|
if isGroup(id) {
|
||||||
|
if g, ok := m.groupInfo(id); ok {
|
||||||
|
return g.size
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return m.sizeOf(inventory.NodeID(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) paintTop(cv *Canvas, b treemap.Block, sel, hov bool, total int64) {
|
||||||
|
t := m.theme
|
||||||
|
base := m.colorOf(b.ID)
|
||||||
|
fill := base.Mix(t.Bg, 0.62)
|
||||||
|
fillCh := " "
|
||||||
|
if isGroup(b.ID) {
|
||||||
|
fillCh = "░"
|
||||||
|
if t.ASCII {
|
||||||
|
fillCh = "."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name := m.blockName(b.ID)
|
||||||
|
size := m.blockSize(b.ID)
|
||||||
|
if b.W < 3 || b.H < 2 {
|
||||||
|
// Too small for a frame: a solid tile.
|
||||||
|
st := Style{FG: base, BG: base.Mix(t.Bg, 0.3)}
|
||||||
|
ch := fillCh
|
||||||
|
if sel {
|
||||||
|
st = Style{FG: t.textOn(t.Sel), BG: t.Sel, Attr: Bold | m.monoRev()}
|
||||||
|
ch = "◆"
|
||||||
|
if t.ASCII {
|
||||||
|
ch = "#"
|
||||||
|
}
|
||||||
|
} else if t.Mono {
|
||||||
|
ch = "▒"
|
||||||
|
}
|
||||||
|
cv.Fill(b.Rect, " ", st)
|
||||||
|
cv.Text(b.X+(b.W-1)/2, b.Y+(b.H-1)/2, ch, 1, st)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cv.Fill(b.Rect, fillCh, Style{FG: base.Mix(t.Bg, 0.35), BG: fill})
|
||||||
|
frame := Style{FG: base.Mix(t.Bg, 0.15), BG: fill}
|
||||||
|
box := t.box()
|
||||||
|
title := Style{FG: base.Mix(RGB(255, 255, 255), 0.35), BG: fill, Attr: Bold}
|
||||||
|
switch {
|
||||||
|
case sel:
|
||||||
|
box = t.selBox()
|
||||||
|
frame = Style{FG: t.Sel, BG: fill, Attr: Bold}
|
||||||
|
title = Style{FG: t.textOn(t.Sel), BG: t.Sel, Attr: Bold | m.monoRev()}
|
||||||
|
case hov:
|
||||||
|
frame.FG = base.Mix(RGB(255, 255, 255), 0.5)
|
||||||
|
}
|
||||||
|
if t.Mono && !sel {
|
||||||
|
title.Attr = Bold
|
||||||
|
}
|
||||||
|
cv.Box(b.Rect, box, frame)
|
||||||
|
cv.Text(b.X+1, b.Y, textutil.Truncate(" "+name+" ", b.W-2), b.W-2, title)
|
||||||
|
|
||||||
|
label := textutil.Size(size)
|
||||||
|
if pct := textutil.Percent(size, total); b.W-2 >= textutil.Width(label)+textutil.Width(pct)+5 {
|
||||||
|
label += " · " + pct
|
||||||
|
} else if b.W-2 < textutil.Width(label)+2 {
|
||||||
|
label = textutil.SizeCompact(size)
|
||||||
|
}
|
||||||
|
label = " " + label + " "
|
||||||
|
if b.H >= 3 && textutil.Width(label) <= b.W-2 {
|
||||||
|
cv.TextRight(b.X+1, b.Y+b.H-1, b.W-2, label, Style{FG: frame.FG, BG: fill, Attr: frame.Attr})
|
||||||
|
}
|
||||||
|
// Interior labels for blocks without nested previews.
|
||||||
|
in := b.Inset(1)
|
||||||
|
if in.Empty() || m.hasNested(b.ID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := inventory.NoNode
|
||||||
|
if !isGroup(b.ID) {
|
||||||
|
n = inventory.NodeID(b.ID)
|
||||||
|
}
|
||||||
|
lines := []string{}
|
||||||
|
if n != inventory.NoNode {
|
||||||
|
nd := m.tree.Node(n)
|
||||||
|
if !nd.IsDir() {
|
||||||
|
lines = append(lines, kindLabel(m.tree, nd))
|
||||||
|
} else if nd.Files > 0 {
|
||||||
|
lines = append(lines, textutil.Count(int64(nd.Files))+" files")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.H < 4 || in.W < 6 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
y0 := in.Y + (in.H-len(lines))/2
|
||||||
|
for i, s := range lines {
|
||||||
|
cv.TextCentered(in.X, y0+i, in.W, s, Style{FG: base.Mix(t.Fg, 0.4), BG: fill})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) hasNested(id int64) bool { return m.tm != nil && m.tm.inner[id] }
|
||||||
|
|
||||||
|
func (m *Model) paintNested(cv *Canvas, nb nested) {
|
||||||
|
t := m.theme
|
||||||
|
base := m.colorOf(nb.id)
|
||||||
|
f := 0.38 - 0.1*float64(nb.depth-2)
|
||||||
|
if nb.alt {
|
||||||
|
f += 0.07
|
||||||
|
}
|
||||||
|
bg := base.Mix(t.Bg, f)
|
||||||
|
shadow := bg.Mix(RGB(0, 0, 0), 0.45)
|
||||||
|
st := Style{FG: t.textOn(bg), BG: bg}
|
||||||
|
ch := " "
|
||||||
|
if isGroup(nb.id) {
|
||||||
|
ch = "░"
|
||||||
|
if t.ASCII {
|
||||||
|
ch = "."
|
||||||
|
}
|
||||||
|
st.FG = bg.Mix(t.Fg, 0.25)
|
||||||
|
}
|
||||||
|
r := nb.r
|
||||||
|
cv.Fill(r, ch, st)
|
||||||
|
if t.Mono {
|
||||||
|
// No colour: draw a light outline so neighbours stay distinguishable.
|
||||||
|
if r.W >= 2 && r.H >= 2 {
|
||||||
|
cv.Box(r, BoxRound, Style{Attr: Faint})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if r.W >= 4 {
|
||||||
|
cv.Fill(treemap.Rect{X: r.X + r.W - 1, Y: r.Y, W: 1, H: r.H}, " ", Style{BG: shadow})
|
||||||
|
}
|
||||||
|
if r.H >= 3 {
|
||||||
|
cv.Fill(treemap.Rect{X: r.X, Y: r.Y + r.H - 1, W: r.W, H: 1}, " ", Style{BG: shadow})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lw := r.W - 1
|
||||||
|
if t.Mono {
|
||||||
|
lw = r.W - 2
|
||||||
|
}
|
||||||
|
if lw < 5 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lx := r.X
|
||||||
|
if t.Mono {
|
||||||
|
lx++
|
||||||
|
}
|
||||||
|
ly := r.Y
|
||||||
|
if t.Mono && r.H >= 3 {
|
||||||
|
ly++
|
||||||
|
}
|
||||||
|
name := m.blockName(nb.id)
|
||||||
|
if textutil.Width(name) > lw && lw < 8 {
|
||||||
|
return // a stub like "te…" is noise, not information
|
||||||
|
}
|
||||||
|
cv.Text(lx, ly, textutil.Truncate(name, lw), lw, Style{FG: st.FG, BG: bg, Attr: Bold})
|
||||||
|
if !nb.inner && r.H >= 3 && lw >= 5 {
|
||||||
|
cv.Text(lx, ly+1, textutil.Truncate(textutil.SizeCompact(m.blockSize(nb.id)), lw), lw, Style{FG: bg.Mix(st.FG, 0.7), BG: bg})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *mapView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||||
|
dirs := map[string]treemap.Direction{"left": treemap.Left, "right": treemap.Right, "up": treemap.Up, "down": treemap.Down,
|
||||||
|
"h": treemap.Left, "l": treemap.Right, "k": treemap.Up, "j": treemap.Down}
|
||||||
|
if d, ok := dirs[k]; ok {
|
||||||
|
if l := m.tm; l != nil {
|
||||||
|
if id, ok := treemap.Neighbour(l.blocks, m.visibleSel(l.visible), d); ok {
|
||||||
|
m.sel = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *mapView) wheel(m *Model, up bool) tea.Cmd {
|
||||||
|
if up {
|
||||||
|
return m.zoomIntoSelection()
|
||||||
|
}
|
||||||
|
m.zoomOut()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
"github.com/andydixon/mapsize/internal/treemap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// span is a run of styled text; a line is a slice of spans.
|
||||||
|
type span struct {
|
||||||
|
s string
|
||||||
|
st *Style // nil = modal default
|
||||||
|
}
|
||||||
|
|
||||||
|
type line []span
|
||||||
|
|
||||||
|
func txt(s string) line { return line{{s, nil}} }
|
||||||
|
func styled(s string, st Style) line { return line{{s, &st}} }
|
||||||
|
func kv(k, v string, kst, vst Style) line { return line{{textutil.PadRight(k, 18), &kst}, {v, &vst}} }
|
||||||
|
|
||||||
|
// modal is an overlay. Modals receive all keys while open; Esc always
|
||||||
|
// closes them (handled by the frame before the modal sees it, unless the
|
||||||
|
// modal is editing text).
|
||||||
|
type modal interface {
|
||||||
|
title() string
|
||||||
|
content(m *Model, width int) []line // rebuilt every frame, so resizes reflow
|
||||||
|
footer(m *Model) string
|
||||||
|
key(m *Model, k string) (closeModal bool, cmd tea.Cmd)
|
||||||
|
width() int // preferred inner width
|
||||||
|
}
|
||||||
|
|
||||||
|
// modalFrame wraps a modal with scroll state.
|
||||||
|
type modalFrame struct {
|
||||||
|
modal
|
||||||
|
scroll int
|
||||||
|
viewH int // content rows visible in the last paint
|
||||||
|
contentN int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) openModal(md modal) {
|
||||||
|
m.modals = append(m.modals, &modalFrame{modal: md})
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) closeModal() {
|
||||||
|
if len(m.modals) > 0 {
|
||||||
|
m.modals = m.modals[:len(m.modals)-1]
|
||||||
|
}
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// textInputModal is implemented by modals that consume printable keys.
|
||||||
|
type textInputModal interface {
|
||||||
|
editing() bool
|
||||||
|
editor() *lineEditor
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) modalKey(msg tea.KeyPressMsg) tea.Cmd {
|
||||||
|
k := keyName(msg)
|
||||||
|
f := m.modals[len(m.modals)-1].(*modalFrame)
|
||||||
|
if ti, ok := f.modal.(textInputModal); ok && ti.editing() && k != "esc" && k != "enter" {
|
||||||
|
ti.editor().key(msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ti, ok := f.modal.(textInputModal); !ok || !ti.editing() {
|
||||||
|
page := max(f.viewH-1, 1)
|
||||||
|
switch k {
|
||||||
|
case "esc", "q":
|
||||||
|
m.closeModal()
|
||||||
|
return nil
|
||||||
|
case "up", "k":
|
||||||
|
f.scroll--
|
||||||
|
case "down", "j":
|
||||||
|
f.scroll++
|
||||||
|
case "pgup":
|
||||||
|
f.scroll -= page
|
||||||
|
case "pgdown", "space":
|
||||||
|
f.scroll += page
|
||||||
|
case "home":
|
||||||
|
f.scroll = 0
|
||||||
|
case "end":
|
||||||
|
f.scroll = f.contentN
|
||||||
|
default:
|
||||||
|
goto modalSpecific
|
||||||
|
}
|
||||||
|
f.clampScroll()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
modalSpecific:
|
||||||
|
closeIt, cmd := f.key(m, k)
|
||||||
|
if closeIt {
|
||||||
|
// Close this modal specifically (it may have opened another).
|
||||||
|
for i, x := range m.modals {
|
||||||
|
if x == f {
|
||||||
|
m.modals = append(m.modals[:i], m.modals[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *modalFrame) clampScroll() {
|
||||||
|
f.scroll = max(0, min(f.scroll, f.contentN-f.viewH))
|
||||||
|
}
|
||||||
|
|
||||||
|
// paintModal dims the base screen and draws the top modal centred,
|
||||||
|
// clamped to the terminal with a one-cell margin. Content that does not fit
|
||||||
|
// scrolls.
|
||||||
|
func (m *Model) paintModal(cv *Canvas) {
|
||||||
|
t := m.theme
|
||||||
|
// Dim everything underneath.
|
||||||
|
cv.Restyle(treemap.Rect{X: 0, Y: 0, W: cv.W, H: cv.H}, func(s Style) Style {
|
||||||
|
if t.Mono {
|
||||||
|
s.Attr |= Faint
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s.FG = s.FG.Mix(t.Bg, 0.6)
|
||||||
|
s.BG = s.BG.Mix(RGB(0, 0, 0), 0.45)
|
||||||
|
s.Attr &^= Bold
|
||||||
|
return s
|
||||||
|
})
|
||||||
|
f := m.modals[len(m.modals)-1].(*modalFrame)
|
||||||
|
maxW := cv.W - 2
|
||||||
|
w := min(f.width()+4, maxW)
|
||||||
|
w = max(w, min(30, maxW))
|
||||||
|
inner := w - 4
|
||||||
|
lines := f.content(m, inner)
|
||||||
|
foot := f.footer(m)
|
||||||
|
chrome := 4 // border top/bottom, title rule
|
||||||
|
if foot != "" {
|
||||||
|
chrome += 2
|
||||||
|
}
|
||||||
|
h := min(len(lines)+chrome, cv.H-2)
|
||||||
|
h = max(h, min(chrome+1, cv.H))
|
||||||
|
x0, y0 := (cv.W-w)/2, (cv.H-h)/2
|
||||||
|
r := treemap.Rect{X: x0, Y: y0, W: w, H: h}
|
||||||
|
bg := t.PanelBg
|
||||||
|
if bg == 0 {
|
||||||
|
bg = t.Bg
|
||||||
|
}
|
||||||
|
base := Style{FG: t.Fg, BG: bg}
|
||||||
|
// Drop shadow.
|
||||||
|
if !t.Mono {
|
||||||
|
cv.Restyle(treemap.Rect{X: x0 + 1, Y: y0 + h, W: w, H: 1}, func(s Style) Style { s.BG = RGB(0, 0, 0); return s })
|
||||||
|
cv.Restyle(treemap.Rect{X: x0 + w, Y: y0 + 1, W: 1, H: h}, func(s Style) Style { s.BG = RGB(0, 0, 0); return s })
|
||||||
|
}
|
||||||
|
cv.Fill(r, " ", base)
|
||||||
|
border := Style{FG: t.Accent, BG: bg, Attr: Bold}
|
||||||
|
box := BoxRound
|
||||||
|
if t.ASCII {
|
||||||
|
box = BoxASCII
|
||||||
|
}
|
||||||
|
cv.Box(r, box, border)
|
||||||
|
cv.Text(x0+2, y0+1, strings.ToUpper(f.title()), inner, Style{FG: t.Accent, BG: bg, Attr: Bold})
|
||||||
|
rule := "─"
|
||||||
|
if t.ASCII {
|
||||||
|
rule = "-"
|
||||||
|
}
|
||||||
|
cv.HLine(x0+1, y0+2, w-2, rule, Style{FG: t.Faint, BG: bg})
|
||||||
|
f.viewH = h - chrome
|
||||||
|
f.contentN = len(lines)
|
||||||
|
f.clampScroll()
|
||||||
|
for i := 0; i < f.viewH && f.scroll+i < len(lines); i++ {
|
||||||
|
x := x0 + 2
|
||||||
|
for _, sp := range lines[f.scroll+i] {
|
||||||
|
st := base
|
||||||
|
if sp.st != nil {
|
||||||
|
st = *sp.st
|
||||||
|
if st.BG == 0 {
|
||||||
|
st.BG = bg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
x += cv.Text(x, y0+3+i, sp.s, x0+2+inner-x, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(lines) > f.viewH && f.viewH > 0 {
|
||||||
|
ind := textutil.Count(int64(f.scroll+1)) + "–" + textutil.Count(int64(min(f.scroll+f.viewH, len(lines)))) +
|
||||||
|
"/" + textutil.Count(int64(len(lines)))
|
||||||
|
if f.scroll > 0 {
|
||||||
|
ind = "▲ " + ind
|
||||||
|
}
|
||||||
|
if f.scroll+f.viewH < len(lines) {
|
||||||
|
ind += " ▼"
|
||||||
|
}
|
||||||
|
cv.TextRight(x0+1, y0+1, w-3, ind, Style{FG: t.Muted, BG: bg})
|
||||||
|
}
|
||||||
|
if foot != "" {
|
||||||
|
cv.HLine(x0+1, y0+h-3, w-2, rule, Style{FG: t.Faint, BG: bg})
|
||||||
|
cv.Text(x0+2, y0+h-2, foot, inner, Style{FG: t.Muted, BG: bg})
|
||||||
|
}
|
||||||
|
m.hits = append(m.hits[:0], hit{r, func(*Model, bool) tea.Cmd { return nil }})
|
||||||
|
}
|
||||||
|
|
||||||
|
// wrap breaks s into chunks of at most w cells (used for long paths).
|
||||||
|
func wrap(s string, w int) []string {
|
||||||
|
if w <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
var cur strings.Builder
|
||||||
|
cw := 0
|
||||||
|
textutil.Clusters(s, func(c string, cwid int) bool {
|
||||||
|
if cw+cwid > w {
|
||||||
|
out = append(out, cur.String())
|
||||||
|
cur.Reset()
|
||||||
|
cw = 0
|
||||||
|
}
|
||||||
|
cur.WriteString(c)
|
||||||
|
cw += cwid
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if cur.Len() > 0 || len(out) == 0 {
|
||||||
|
out = append(out, cur.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/export"
|
||||||
|
"github.com/andydixon/mapsize/internal/filter"
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/platform"
|
||||||
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Item information -------------------------------------------------------
|
||||||
|
|
||||||
|
type infoModal struct {
|
||||||
|
id inventory.NodeID
|
||||||
|
btime int64 // live birth time (0 if unknown)
|
||||||
|
live bool // metadata re-read from the filesystem
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInfoModal(m *Model, id inventory.NodeID) modal {
|
||||||
|
md := &infoModal{id: id}
|
||||||
|
if !m.snapshot {
|
||||||
|
// Birth time is not kept in the inventory; read it live.
|
||||||
|
if meta, err := platform.Lstat(m.tree.Path(id)); err == nil {
|
||||||
|
md.btime, md.live = meta.BTime, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return md
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*infoModal) width() int { return 72 }
|
||||||
|
|
||||||
|
func (md *infoModal) title() string { return "Item information" }
|
||||||
|
|
||||||
|
func (md *infoModal) footer(m *Model) string {
|
||||||
|
s := "Esc close · c copy path · o reveal · Space go to"
|
||||||
|
if !m.opts.ReadOnly && !m.snapshot {
|
||||||
|
s += " · d trash"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (md *infoModal) content(m *Model, w int) []line {
|
||||||
|
t := m.theme
|
||||||
|
tr := m.tree
|
||||||
|
n := tr.Node(md.id)
|
||||||
|
ks := Style{FG: t.Muted}
|
||||||
|
vs := Style{FG: t.Fg}
|
||||||
|
bold := Style{FG: t.Fg, Attr: Bold}
|
||||||
|
warn := Style{FG: t.Warn, Attr: Bold}
|
||||||
|
var out []line
|
||||||
|
add := func(k, v string) { out = append(out, kv(k, v, ks, vs)) }
|
||||||
|
addS := func(k, v string, st Style) { out = append(out, kv(k, v, ks, st)) }
|
||||||
|
blank := func() { out = append(out, txt("")) }
|
||||||
|
|
||||||
|
col := m.colorOf(int64(md.id))
|
||||||
|
title := Style{FG: col.Mix(RGB(255, 255, 255), 0.3), Attr: Bold}
|
||||||
|
for _, l := range wrap(textutil.Sanitize(n.Name), w) {
|
||||||
|
out = append(out, styled(l, title))
|
||||||
|
}
|
||||||
|
blank()
|
||||||
|
out = append(out, styled("Path", ks))
|
||||||
|
for _, l := range wrap(textutil.Sanitize(tr.Path(md.id)), w) {
|
||||||
|
out = append(out, styled(l, vs))
|
||||||
|
}
|
||||||
|
if textutil.Sanitize(n.Name) != n.Name {
|
||||||
|
out = append(out, styled("Name contains control or invalid characters (shown escaped)", warn))
|
||||||
|
}
|
||||||
|
blank()
|
||||||
|
if n.IsDir() {
|
||||||
|
addS("Total logical", fmt.Sprintf("%s (%s bytes)", textutil.Size(n.TotSize), textutil.Count(n.TotSize)), bold)
|
||||||
|
addS("Total allocated", fmt.Sprintf("%s (%s bytes)", textutil.Size(n.TotAlloc), textutil.Count(n.TotAlloc)), bold)
|
||||||
|
add("Files", textutil.Count(int64(n.Files)))
|
||||||
|
add("Directories", textutil.Count(int64(n.Dirs)))
|
||||||
|
if p := n.Parent; p != inventory.NoNode {
|
||||||
|
add("Of parent", textutil.Percent(n.Total(m.sizeMode), tr.Node(p).Total(m.sizeMode)))
|
||||||
|
}
|
||||||
|
add("Of scan", textutil.Percent(n.Total(m.sizeMode), tr.Node(0).Total(m.sizeMode)))
|
||||||
|
k := m.kidsOf(md.id)
|
||||||
|
if len(k.ids) > 0 {
|
||||||
|
add("Largest child", fmt.Sprintf("%s (%s)", textutil.Sanitize(tr.Node(k.ids[0]).Name), textutil.Size(k.sizes[0])))
|
||||||
|
}
|
||||||
|
if n.Errors > 0 {
|
||||||
|
addS("Errors beneath", textutil.Count(int64(n.Errors)), Style{FG: t.Err, Attr: Bold})
|
||||||
|
}
|
||||||
|
state := "complete"
|
||||||
|
switch {
|
||||||
|
case n.Flags&inventory.FlagIncomplete != 0:
|
||||||
|
state = "incomplete (not fully read)"
|
||||||
|
case n.Flags&inventory.FlagScanned == 0 && m.scanning:
|
||||||
|
state = "scanning…"
|
||||||
|
case n.Flags&(inventory.FlagSkippedFS|inventory.FlagVirtualFS|inventory.FlagLoop) != 0:
|
||||||
|
state = "not descended"
|
||||||
|
}
|
||||||
|
add("Scan state", state)
|
||||||
|
} else {
|
||||||
|
addS("Logical size", fmt.Sprintf("%s (%s bytes)", textutil.Size(n.Size), textutil.Count(n.Size)), bold)
|
||||||
|
alloc := fmt.Sprintf("%s (%s bytes)", textutil.Size(n.Alloc), textutil.Count(n.Alloc))
|
||||||
|
if n.Flags&inventory.FlagAllocUnknown != 0 {
|
||||||
|
alloc = "unknown on this platform"
|
||||||
|
}
|
||||||
|
addS("Allocated size", alloc, bold)
|
||||||
|
if p := n.Parent; p != inventory.NoNode {
|
||||||
|
add("Of parent", textutil.Percent(n.Own(m.sizeMode), tr.Node(p).Total(m.sizeMode)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r := m.search.result; r != nil {
|
||||||
|
add("Matching filter", textutil.Size(r.Size(md.id)))
|
||||||
|
}
|
||||||
|
if d := m.opts.Diff; d != nil {
|
||||||
|
if old, ok := d.OldSize(md.id, m.sizeMode); ok {
|
||||||
|
add("In old snapshot", textutil.Size(old))
|
||||||
|
addS("Change", textutil.SignedSize(n.Total(m.sizeMode)-old), bold)
|
||||||
|
} else {
|
||||||
|
addS("Change", "new since old snapshot", Style{FG: t.DiffNew, Attr: Bold})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blank()
|
||||||
|
add("Type", n.Kind.String())
|
||||||
|
if !n.IsDir() {
|
||||||
|
if e := tr.ExtName(n); e != "" {
|
||||||
|
add("Extension", textutil.Sanitize(e))
|
||||||
|
}
|
||||||
|
add("Category", n.Cat.String())
|
||||||
|
}
|
||||||
|
blank()
|
||||||
|
if n.MTime > 0 {
|
||||||
|
add("Modified", fmtTime(n.MTime))
|
||||||
|
}
|
||||||
|
if md.btime > 0 {
|
||||||
|
add("Created", fmtTime(md.btime))
|
||||||
|
}
|
||||||
|
blank()
|
||||||
|
add("Mode", fs.FileMode(n.Mode).String())
|
||||||
|
add("Owner", textutil.Sanitize(platform.UserName(n.UID)))
|
||||||
|
add("Group", textutil.Sanitize(platform.GroupName(n.GID)))
|
||||||
|
if !n.IsDir() {
|
||||||
|
add("Hard links", fmt.Sprint(n.Nlink))
|
||||||
|
sparse := "No"
|
||||||
|
if n.Flags&inventory.FlagSparse != 0 {
|
||||||
|
sparse = fmt.Sprintf("Yes (or compressed) — %s not allocated", textutil.Size(n.Size-n.Alloc))
|
||||||
|
}
|
||||||
|
add("Sparse", sparse)
|
||||||
|
}
|
||||||
|
if fl := export.FlagList(n.Flags &^ (inventory.FlagScanned | inventory.FlagSparse)); len(fl) > 0 {
|
||||||
|
addS("Flags", strings.Join(fl, ", "), warn)
|
||||||
|
}
|
||||||
|
if n.Flags&inventory.FlagHardlinkDup != 0 {
|
||||||
|
out = append(out, styled("Another path to this inode was counted; this one adds nothing to totals.", Style{FG: t.Muted}))
|
||||||
|
}
|
||||||
|
if m.snapshot {
|
||||||
|
out = append(out, txt(""), styled("From snapshot — metadata as recorded, not live.", Style{FG: t.Muted}))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtTime(ns int64) string { return time.Unix(0, ns).Format("2006-01-02 15:04:05") }
|
||||||
|
|
||||||
|
func (md *infoModal) key(m *Model, k string) (bool, tea.Cmd) {
|
||||||
|
switch k {
|
||||||
|
case "c":
|
||||||
|
p := m.tree.Path(md.id)
|
||||||
|
return false, tea.Batch(tea.SetClipboard(p), m.info("Copied path to clipboard"))
|
||||||
|
case "o":
|
||||||
|
m.sel = int64(md.id)
|
||||||
|
return false, m.reveal()
|
||||||
|
case "enter":
|
||||||
|
return true, nil
|
||||||
|
case "g", "right":
|
||||||
|
m.jumpTo(md.id)
|
||||||
|
return true, nil
|
||||||
|
case "d", "delete":
|
||||||
|
m.sel = int64(md.id)
|
||||||
|
return true, m.requestTrash()
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- LOD group ------------------------------------------------------------------
|
||||||
|
|
||||||
|
type groupModal struct{ g *group }
|
||||||
|
|
||||||
|
func newGroupModal(_ *Model, g *group) modal { return &groupModal{g} }
|
||||||
|
|
||||||
|
func (*groupModal) width() int { return 70 }
|
||||||
|
func (*groupModal) title() string { return "Smaller items (grouped)" }
|
||||||
|
func (*groupModal) footer(*Model) string { return "Esc close · Space shows them in the list view" }
|
||||||
|
|
||||||
|
func (md *groupModal) content(m *Model, w int) []line {
|
||||||
|
t := m.theme
|
||||||
|
out := []line{
|
||||||
|
txt(fmt.Sprintf("%s items too small to draw individually at this size,", textutil.Count(int64(len(md.g.ids))))),
|
||||||
|
txt(fmt.Sprintf("totalling %s. Zoom in or enlarge the terminal to see them.", textutil.Size(md.g.size))),
|
||||||
|
txt(""),
|
||||||
|
}
|
||||||
|
for i, id := range md.g.ids {
|
||||||
|
if i == 200 {
|
||||||
|
out = append(out, styled(fmt.Sprintf("… and %s more", textutil.Count(int64(len(md.g.ids)-200))), Style{FG: t.Muted}))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n := m.tree.Node(id)
|
||||||
|
name := textutil.Sanitize(n.Name)
|
||||||
|
if n.IsDir() {
|
||||||
|
name += "/"
|
||||||
|
}
|
||||||
|
out = append(out, line{{textutil.PadLeft(textutil.Size(m.sizeOf(id)), 11) + " ", &Style{FG: t.Muted}},
|
||||||
|
{textutil.Truncate(name, w-13), &Style{FG: m.colorOf(int64(id)).Mix(t.Fg, 0.4)}}})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (md *groupModal) key(m *Model, k string) (bool, tea.Cmd) {
|
||||||
|
if k == "enter" {
|
||||||
|
m.sel = int64(md.g.ids[0])
|
||||||
|
m.setViewByName("List")
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Help ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
type helpModal struct{}
|
||||||
|
|
||||||
|
func newHelpModal(*Model) modal { return helpModal{} }
|
||||||
|
|
||||||
|
func (helpModal) width() int { return 74 }
|
||||||
|
func (helpModal) title() string { return "Keyboard reference" }
|
||||||
|
func (helpModal) footer(*Model) string { return "Esc close · ↑↓ scroll" }
|
||||||
|
|
||||||
|
func (helpModal) content(m *Model, _ int) []line {
|
||||||
|
t := m.theme
|
||||||
|
h := Style{FG: t.Accent, Attr: Bold}
|
||||||
|
k := Style{FG: t.Sel, Attr: Bold}
|
||||||
|
d := Style{FG: t.Fg}
|
||||||
|
var out []line
|
||||||
|
sec := func(s string) {
|
||||||
|
if len(out) > 0 {
|
||||||
|
out = append(out, txt(""))
|
||||||
|
}
|
||||||
|
out = append(out, styled(s, h))
|
||||||
|
}
|
||||||
|
row := func(keys, desc string) { out = append(out, line{{" " + textutil.PadRight(keys, 16), &k}, {desc, &d}}) }
|
||||||
|
sec("NAVIGATION")
|
||||||
|
row("↑ ↓ ← → hjkl", "move spatially through the treemap")
|
||||||
|
row("Enter", "inspect the selected item")
|
||||||
|
row("Space →", "zoom into the selected directory")
|
||||||
|
row("Backspace ←", "zoom out one level")
|
||||||
|
row("Home", "back to the scan root")
|
||||||
|
row("click / dbl-click", "select / zoom (mouse)")
|
||||||
|
row("wheel", "zoom in/out (map), scroll (lists)")
|
||||||
|
sec("SEARCH & FILTER")
|
||||||
|
row("/", "search or filter; the map shows only matches")
|
||||||
|
row("Esc", "clear the filter")
|
||||||
|
out = append(out, styled(" ubuntu *.iso size > 5GB ext IN (iso,qcow2) age > 365d", Style{FG: t.Muted}))
|
||||||
|
out = append(out, styled(" path contains cache AND NOT type = dir owner = andy flag = sparse", Style{FG: t.Muted}))
|
||||||
|
out = append(out, styled(" fields: "+filter.Fields, Style{FG: t.Muted}))
|
||||||
|
out = append(out, styled(" sizes: KiB/MiB/GiB (1024), kB/MB/GB (1000); ages: s min h d w mo y", Style{FG: t.Muted}))
|
||||||
|
sec("VIEWS")
|
||||||
|
row("Tab Shift+Tab", "next / previous view")
|
||||||
|
row("1 – 7", "jump to a view")
|
||||||
|
row("x", "file types (extension statistics)")
|
||||||
|
row("g", "top lists: largest, oldest, sparse, hard links …")
|
||||||
|
row("e", "scan information and errors")
|
||||||
|
row("D", "find duplicate files (reads file contents)")
|
||||||
|
sec("APPLICATION")
|
||||||
|
row("a", "toggle allocated / logical (apparent) size")
|
||||||
|
row("c", "copy path to clipboard")
|
||||||
|
row("o", "reveal in file manager")
|
||||||
|
if !m.opts.ReadOnly {
|
||||||
|
row("d", "move to trash (asks first)")
|
||||||
|
}
|
||||||
|
row("s", "save snapshot")
|
||||||
|
row("r", "rescan")
|
||||||
|
row("T", "cycle colour theme")
|
||||||
|
row("Ctrl+C", "cancel scan; press again to quit")
|
||||||
|
row("q", "quit")
|
||||||
|
out = append(out, txt(""))
|
||||||
|
units := "IEC (KiB = 1024 bytes)"
|
||||||
|
if textutil.SI {
|
||||||
|
units = "SI (kB = 1000 bytes)"
|
||||||
|
}
|
||||||
|
out = append(out, styled("Sizes: "+m.sizeMode.String()+" · units: "+units, Style{FG: t.Muted}))
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (helpModal) key(*Model, string) (bool, tea.Cmd) { return false, nil }
|
||||||
|
|
||||||
|
// ---- Confirmation -------------------------------------------------------------
|
||||||
|
|
||||||
|
type confirmModal struct {
|
||||||
|
ttl string
|
||||||
|
body []string
|
||||||
|
yes string
|
||||||
|
onYes func(m *Model) tea.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *confirmModal) width() int {
|
||||||
|
w := 40
|
||||||
|
for _, b := range c.body {
|
||||||
|
w = max(w, textutil.Width(b))
|
||||||
|
}
|
||||||
|
return min(w, 90)
|
||||||
|
}
|
||||||
|
func (c *confirmModal) title() string { return c.ttl }
|
||||||
|
func (c *confirmModal) footer(*Model) string { return "y " + c.yes + " · n / Esc cancel" }
|
||||||
|
func (c *confirmModal) content(m *Model, w int) []line {
|
||||||
|
var out []line
|
||||||
|
for _, b := range c.body {
|
||||||
|
for _, l := range wrap(b, w) {
|
||||||
|
out = append(out, txt(l))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
func (c *confirmModal) key(m *Model, k string) (bool, tea.Cmd) {
|
||||||
|
switch k {
|
||||||
|
case "y", "Y":
|
||||||
|
return true, c.onYes(m)
|
||||||
|
case "n", "N", "enter":
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Save snapshot ---------------------------------------------------------------
|
||||||
|
|
||||||
|
type saveModal struct{ ed lineEditor }
|
||||||
|
|
||||||
|
func newSaveModal(m *Model) modal {
|
||||||
|
md := &saveModal{}
|
||||||
|
md.ed.set(defaultSnapshotName(m.tree.Stats.Root))
|
||||||
|
return md
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*saveModal) editing() bool { return true }
|
||||||
|
func (md *saveModal) editor() *lineEditor { return &md.ed }
|
||||||
|
func (*saveModal) width() int { return 70 }
|
||||||
|
func (*saveModal) title() string { return "Save snapshot" }
|
||||||
|
func (*saveModal) footer(*Model) string { return "Enter save · Esc cancel" }
|
||||||
|
|
||||||
|
func (md *saveModal) content(m *Model, w int) []line {
|
||||||
|
t := m.theme
|
||||||
|
out := []line{txt("File:")}
|
||||||
|
s := textutil.Sanitize(md.ed.String())
|
||||||
|
for _, l := range wrap(s+"█", w) {
|
||||||
|
out = append(out, styled(l, Style{FG: t.Fg, Attr: Bold}))
|
||||||
|
}
|
||||||
|
if m.scanning {
|
||||||
|
out = append(out, txt(""), styled("The scan is still running: the snapshot will be marked incomplete.", Style{FG: t.Warn}))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (md *saveModal) key(m *Model, k string) (bool, tea.Cmd) {
|
||||||
|
switch k {
|
||||||
|
case "esc":
|
||||||
|
return true, nil
|
||||||
|
case "enter":
|
||||||
|
p := strings.TrimSpace(md.ed.String())
|
||||||
|
if p == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, tea.Batch(m.saveSnapshot(p), m.info("Saving snapshot…"))
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
// Package tui is the interactive terminal interface. All drawing goes to a
|
||||||
|
// Canvas; Bubble Tea only transports the finished frame.
|
||||||
|
//
|
||||||
|
// Threading: Update and View run on Bubble Tea's event goroutine. The scan
|
||||||
|
// controller mutates the inventory concurrently, so every read of tree data
|
||||||
|
// happens under tree.RLock (taken once per frame in View, and briefly in key
|
||||||
|
// handlers). The model itself is only touched on the event goroutine.
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"github.com/charmbracelet/colorprofile"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/brand"
|
||||||
|
"github.com/andydixon/mapsize/internal/config"
|
||||||
|
"github.com/andydixon/mapsize/internal/filter"
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/scan"
|
||||||
|
"github.com/andydixon/mapsize/internal/snapshot"
|
||||||
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
"github.com/andydixon/mapsize/internal/treemap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options configures the interactive session.
|
||||||
|
type Options struct {
|
||||||
|
ReadOnly bool
|
||||||
|
Theme string
|
||||||
|
SizeMode inventory.SizeMode
|
||||||
|
Mouse bool
|
||||||
|
Color string // auto, truecolor, 256, 16, none
|
||||||
|
Save string // save a snapshot here when the scan completes
|
||||||
|
Settings config.Settings
|
||||||
|
Diff *snapshot.Diff // compare mode
|
||||||
|
// Start begins a scan (or returns a loaded tree with a nil scanner).
|
||||||
|
Start func(ctx context.Context) (*scan.Scanner, *inventory.Tree, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages.
|
||||||
|
type (
|
||||||
|
tickMsg time.Time
|
||||||
|
scanDoneMsg struct{ s *scan.Scanner }
|
||||||
|
fatalMsg struct {
|
||||||
|
v any
|
||||||
|
stack []byte
|
||||||
|
}
|
||||||
|
filterMsg struct {
|
||||||
|
gen int
|
||||||
|
res *filter.Result
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
filterDebounceMsg struct{ gen int }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model is the Bubble Tea model.
|
||||||
|
type Model struct {
|
||||||
|
opts Options
|
||||||
|
theme *Theme
|
||||||
|
|
||||||
|
scanner *scan.Scanner
|
||||||
|
tree *inventory.Tree
|
||||||
|
cancel context.CancelFunc
|
||||||
|
scanning bool
|
||||||
|
snapshot bool // tree was loaded from a snapshot (not the live filesystem)
|
||||||
|
ctrlC bool // a Ctrl+C already cancelled the scan
|
||||||
|
|
||||||
|
w, h int
|
||||||
|
profile colorprofile.Profile
|
||||||
|
forced bool // profile forced by --color
|
||||||
|
sizeMode inventory.SizeMode
|
||||||
|
|
||||||
|
views []view
|
||||||
|
view int
|
||||||
|
|
||||||
|
zoom inventory.NodeID
|
||||||
|
sel int64 // selected block: NodeID, or groupID(parent) for an LOD group
|
||||||
|
// userSel is set once the user has chosen a selection; before that the
|
||||||
|
// selection follows the largest block.
|
||||||
|
userSel bool
|
||||||
|
zoomed bool // zoom changed during this message (zoomTo set userSel)
|
||||||
|
|
||||||
|
search searchState
|
||||||
|
modals []modal
|
||||||
|
|
||||||
|
toast string
|
||||||
|
toastStyle Style
|
||||||
|
toastUntil time.Time
|
||||||
|
|
||||||
|
// Frame and data caches.
|
||||||
|
dirty bool
|
||||||
|
frame string
|
||||||
|
dataVer int // bumped when sizes/filters change; invalidates caches
|
||||||
|
lastData time.Time // last time dataVer advanced during a scan
|
||||||
|
kidsCache map[inventory.NodeID]*kids
|
||||||
|
kidsVer int
|
||||||
|
tm *tmLayout
|
||||||
|
hits []hit // clickable regions from the last frame
|
||||||
|
hover int64
|
||||||
|
lastClick time.Time
|
||||||
|
lastClickI int64
|
||||||
|
ticking bool
|
||||||
|
|
||||||
|
pendingZoom string // path to re-zoom after a rescan
|
||||||
|
dups *dupState
|
||||||
|
|
||||||
|
fatal *fatalMsg
|
||||||
|
quit bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// hit is a clickable region recorded while painting.
|
||||||
|
type hit struct {
|
||||||
|
r treemap.Rect
|
||||||
|
fn func(m *Model, double bool) tea.Cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func groupID(parent inventory.NodeID) int64 { return -1 - int64(parent) }
|
||||||
|
func isGroup(id int64) bool { return id < 0 }
|
||||||
|
func groupParent(id int64) inventory.NodeID { return inventory.NodeID(-1 - id) }
|
||||||
|
|
||||||
|
func newModel(opts Options, s *scan.Scanner, t *inventory.Tree, cancel context.CancelFunc) *Model {
|
||||||
|
m := &Model{
|
||||||
|
opts: opts, scanner: s, tree: t, cancel: cancel, scanning: s != nil, snapshot: s == nil,
|
||||||
|
sizeMode: opts.SizeMode, dirty: true, kidsCache: map[inventory.NodeID]*kids{}, hover: noSel,
|
||||||
|
lastClickI: noSel,
|
||||||
|
}
|
||||||
|
name := opts.Theme
|
||||||
|
if name == "" || name == "default" {
|
||||||
|
if _, ok := os.LookupEnv("NO_COLOR"); ok {
|
||||||
|
name = "mono"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.theme = LoadTheme(name, opts.Settings)
|
||||||
|
if strings.EqualFold(os.Getenv("TERM"), "linux") || os.Getenv("MAPSIZE_ASCII") != "" {
|
||||||
|
m.theme.ASCII = true
|
||||||
|
}
|
||||||
|
m.views = []view{&mapView{}, &listView{}, &extView{}, &topView{}, &infoView{}}
|
||||||
|
if opts.Diff != nil {
|
||||||
|
m.views = append(m.views, &changesView{})
|
||||||
|
}
|
||||||
|
m.zoom = t.Root()
|
||||||
|
m.sel = noSel
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
const noSel int64 = 1 << 62
|
||||||
|
|
||||||
|
// Init starts ticking and waits for scan completion.
|
||||||
|
func (m *Model) Init() tea.Cmd {
|
||||||
|
cmds := []tea.Cmd{tea.RequestWindowSize}
|
||||||
|
if m.scanning {
|
||||||
|
cmds = append(cmds, waitScan(m.scanner), m.startTicking())
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitScan(s *scan.Scanner) tea.Cmd {
|
||||||
|
return func() tea.Msg { <-s.Done(); return scanDoneMsg{s} }
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) refreshInterval() time.Duration {
|
||||||
|
hz := m.opts.Settings.RefreshHz
|
||||||
|
if hz <= 0 {
|
||||||
|
hz = 10
|
||||||
|
}
|
||||||
|
return time.Second / time.Duration(hz)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTicking returns a tick command unless one is already pending.
|
||||||
|
func (m *Model) startTicking() tea.Cmd {
|
||||||
|
if m.ticking {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.ticking = true
|
||||||
|
return tea.Tick(m.refreshInterval(), func(t time.Time) tea.Msg { return tickMsg(t) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) needsTicks() bool {
|
||||||
|
return m.scanning || time.Now().Before(m.toastUntil) || (m.dups != nil && m.dups.running)
|
||||||
|
}
|
||||||
|
|
||||||
|
// notify shows a transient status message.
|
||||||
|
func (m *Model) notify(s string, st Style) tea.Cmd {
|
||||||
|
m.toast, m.toastStyle, m.toastUntil = s, st, time.Now().Add(4*time.Second)
|
||||||
|
m.dirty = true
|
||||||
|
return m.startTicking()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) info(s string) tea.Cmd {
|
||||||
|
return m.notify(s, Style{FG: m.theme.Fg, BG: m.theme.StatusBg})
|
||||||
|
}
|
||||||
|
func (m *Model) warn(s string) tea.Cmd {
|
||||||
|
return m.notify(s, Style{FG: m.theme.Warn, BG: m.theme.StatusBg, Attr: Bold})
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidate marks size-dependent caches stale.
|
||||||
|
func (m *Model) invalidate() {
|
||||||
|
m.dataVer++
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles one message.
|
||||||
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
cmd := m.update(msg)
|
||||||
|
if m.quit {
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) update(msg tea.Msg) tea.Cmd {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
// Only record the size; layout is recomputed lazily in View, so a
|
||||||
|
// storm of resize events costs at most one layout per frame.
|
||||||
|
if msg.Width != m.w || msg.Height != m.h {
|
||||||
|
m.w, m.h = msg.Width, msg.Height
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
case tickMsg:
|
||||||
|
m.ticking = false
|
||||||
|
if m.scanning {
|
||||||
|
m.dirty = true
|
||||||
|
if time.Since(m.lastData) >= 400*time.Millisecond {
|
||||||
|
m.invalidate()
|
||||||
|
m.lastData = time.Now()
|
||||||
|
}
|
||||||
|
m.resolvePendingZoom()
|
||||||
|
}
|
||||||
|
if m.toast != "" && time.Now().After(m.toastUntil) {
|
||||||
|
m.toast = ""
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
if m.scanning && m.search.query != nil && time.Since(m.search.appliedAt) > time.Second {
|
||||||
|
cmds = append(cmds, m.applyFilter())
|
||||||
|
}
|
||||||
|
if m.dups != nil && m.dups.running {
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
if m.needsTicks() {
|
||||||
|
cmds = append(cmds, m.startTicking())
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
|
case scanDoneMsg:
|
||||||
|
if msg.s != m.scanner {
|
||||||
|
return nil // a superseded scan
|
||||||
|
}
|
||||||
|
return m.scanFinished()
|
||||||
|
case filterMsg:
|
||||||
|
return m.filterResult(msg)
|
||||||
|
case filterDebounceMsg:
|
||||||
|
if msg.gen == m.search.gen {
|
||||||
|
return m.applyFilter()
|
||||||
|
}
|
||||||
|
case dupProgressMsg, dupDoneMsg:
|
||||||
|
return m.dupMsg(msg)
|
||||||
|
case trashDoneMsg:
|
||||||
|
return m.trashDone(msg)
|
||||||
|
case saveDoneMsg:
|
||||||
|
if msg.err != nil {
|
||||||
|
return m.warn("Snapshot save failed: " + textutil.Sanitize(msg.err.Error()))
|
||||||
|
}
|
||||||
|
return m.info("Saved snapshot " + textutil.Sanitize(msg.path))
|
||||||
|
case fatalMsg:
|
||||||
|
m.fatal = &msg
|
||||||
|
m.quit = true
|
||||||
|
case tea.KeyPressMsg:
|
||||||
|
m.dirty = true
|
||||||
|
prev := m.sel
|
||||||
|
m.zoomed = false
|
||||||
|
cmd := m.handleKey(msg)
|
||||||
|
m.noteSelection(prev)
|
||||||
|
return cmd
|
||||||
|
case tea.MouseClickMsg:
|
||||||
|
prev := m.sel
|
||||||
|
m.zoomed = false
|
||||||
|
cmd := m.mouseClick(msg.Mouse())
|
||||||
|
m.noteSelection(prev)
|
||||||
|
return cmd
|
||||||
|
case tea.MouseWheelMsg:
|
||||||
|
return m.mouseWheel(msg.Mouse())
|
||||||
|
case tea.MouseMotionMsg:
|
||||||
|
m.mouseMotion(msg.Mouse())
|
||||||
|
case tea.ColorProfileMsg:
|
||||||
|
if !m.forced {
|
||||||
|
m.profile = msg.Profile
|
||||||
|
}
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteSelection records that the user chose a selection explicitly.
|
||||||
|
func (m *Model) noteSelection(prev int64) {
|
||||||
|
if !m.zoomed && m.sel != prev && m.sel != noSel {
|
||||||
|
m.userSel = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) scanFinished() tea.Cmd {
|
||||||
|
m.scanning = false
|
||||||
|
m.invalidate()
|
||||||
|
m.resolvePendingZoom()
|
||||||
|
t := m.tree
|
||||||
|
t.RLock()
|
||||||
|
st := t.Stats
|
||||||
|
r := t.Node(t.Root())
|
||||||
|
total := r.Total(m.sizeMode)
|
||||||
|
files := st.Files + st.Symlinks + st.Others
|
||||||
|
errs := st.TotalErrors()
|
||||||
|
el := st.Elapsed()
|
||||||
|
t.RUnlock()
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
rate := float64(files+st.Dirs) / max(el.Seconds(), 0.001)
|
||||||
|
msg := fmt.Sprintf("✓ Scan complete %s · %s files · %s dirs · %s · %s/s",
|
||||||
|
textutil.Duration(el), textutil.Count(files), textutil.Count(st.Dirs), textutil.Size(total), textutil.Count(int64(rate)))
|
||||||
|
if st.Cancelled {
|
||||||
|
msg = "✗ Scan cancelled — totals are incomplete"
|
||||||
|
}
|
||||||
|
if errs > 0 {
|
||||||
|
msg += fmt.Sprintf(" · %s errors (e)", textutil.Count(errs))
|
||||||
|
}
|
||||||
|
if st.Cancelled || errs > 0 {
|
||||||
|
cmds = append(cmds, m.warn(msg))
|
||||||
|
} else {
|
||||||
|
cmds = append(cmds, m.notify(msg, Style{FG: m.theme.OK, BG: m.theme.StatusBg, Attr: Bold}))
|
||||||
|
}
|
||||||
|
if m.opts.Save != "" && !st.Cancelled {
|
||||||
|
cmds = append(cmds, m.saveSnapshot(m.opts.Save))
|
||||||
|
}
|
||||||
|
if m.search.query != nil {
|
||||||
|
cmds = append(cmds, m.applyFilter())
|
||||||
|
}
|
||||||
|
return tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ctrlCPressed: first press cancels a running scan, second quits.
|
||||||
|
func (m *Model) ctrlCPressed() tea.Cmd {
|
||||||
|
if m.scanning && !m.ctrlC {
|
||||||
|
m.ctrlC = true
|
||||||
|
m.cancel()
|
||||||
|
return m.warn("Cancelling scan… press Ctrl+C again to quit")
|
||||||
|
}
|
||||||
|
m.quit = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) rescan() tea.Cmd {
|
||||||
|
if m.snapshot {
|
||||||
|
return m.warn("Snapshot loaded — nothing to rescan")
|
||||||
|
}
|
||||||
|
// Called from handleKey, which holds the old tree's read lock.
|
||||||
|
m.pendingZoom = m.tree.Path(m.zoom)
|
||||||
|
m.cancel()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
s, t, err := m.opts.Start(ctx)
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return m.warn("Rescan failed: " + textutil.Sanitize(err.Error()))
|
||||||
|
}
|
||||||
|
m.scanner, m.tree, m.cancel, m.scanning, m.ctrlC = s, t, cancel, s != nil, false
|
||||||
|
m.zoom, m.sel = t.Root(), noSel
|
||||||
|
m.kidsCache = map[inventory.NodeID]*kids{}
|
||||||
|
m.dups = nil
|
||||||
|
m.search.result = nil
|
||||||
|
m.invalidate()
|
||||||
|
return tea.Batch(waitScan(s), m.startTicking(), m.info("Rescanning…"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePendingZoom re-zooms into the remembered directory once the new
|
||||||
|
// scan has discovered it.
|
||||||
|
func (m *Model) resolvePendingZoom() {
|
||||||
|
if m.pendingZoom == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.tree.RLock()
|
||||||
|
defer m.tree.RUnlock()
|
||||||
|
root := m.tree.Path(m.tree.Root())
|
||||||
|
rel, err := filepath.Rel(root, m.pendingZoom)
|
||||||
|
if err != nil || strings.HasPrefix(rel, "..") {
|
||||||
|
m.pendingZoom = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := m.tree.Root()
|
||||||
|
if rel != "." {
|
||||||
|
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
||||||
|
next := inventory.NoNode
|
||||||
|
m.tree.Children(id, func(c inventory.NodeID, n *inventory.Node) {
|
||||||
|
if n.Name == part {
|
||||||
|
next = c
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if next == inventory.NoNode {
|
||||||
|
return // not discovered yet
|
||||||
|
}
|
||||||
|
id = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.zoom = id
|
||||||
|
m.pendingZoom = ""
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the frame. Expensive work happens only when something
|
||||||
|
// changed; otherwise the cached frame is returned.
|
||||||
|
func (m *Model) View() tea.View {
|
||||||
|
if m.dirty || m.frame == "" {
|
||||||
|
m.frame = m.render()
|
||||||
|
m.dirty = false
|
||||||
|
}
|
||||||
|
v := tea.NewView(m.frame)
|
||||||
|
v.AltScreen = true
|
||||||
|
if m.opts.Mouse {
|
||||||
|
v.MouseMode = tea.MouseModeAllMotion
|
||||||
|
}
|
||||||
|
v.WindowTitle = brand.Name
|
||||||
|
return v
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
const doubleClick = 400 * time.Millisecond
|
||||||
|
|
||||||
|
func (m *Model) mouseClick(ev tea.Mouse) tea.Cmd {
|
||||||
|
t := m.tree
|
||||||
|
t.RLock()
|
||||||
|
defer t.RUnlock()
|
||||||
|
m.dirty = true
|
||||||
|
if len(m.modals) > 0 {
|
||||||
|
// Clicking outside the modal closes it.
|
||||||
|
if len(m.hits) > 0 && !m.hits[0].r.Contains(ev.X, ev.Y) {
|
||||||
|
m.closeModal()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ev.Button == tea.MouseRight {
|
||||||
|
for _, h := range m.hits {
|
||||||
|
if h.r.Contains(ev.X, ev.Y) {
|
||||||
|
h.fn(m, false)
|
||||||
|
return m.inspectSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ev.Button != tea.MouseLeft {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i := len(m.hits) - 1; i >= 0; i-- {
|
||||||
|
h := m.hits[i]
|
||||||
|
if h.r.Contains(ev.X, ev.Y) {
|
||||||
|
key := int64(ev.Y)<<32 | int64(i)
|
||||||
|
double := time.Since(m.lastClick) < doubleClick && m.lastClickI == key
|
||||||
|
m.lastClick, m.lastClickI = time.Now(), key
|
||||||
|
if double {
|
||||||
|
m.lastClickI = noSel
|
||||||
|
}
|
||||||
|
return h.fn(m, double)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// clickBlock selects a treemap block; a double click zooms (or inspects).
|
||||||
|
func (m *Model) clickBlock(id int64, double bool) tea.Cmd {
|
||||||
|
m.sel = id
|
||||||
|
if !double {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isGroup(id) && !m.tree.Node(inventory.NodeID(id)).IsDir() {
|
||||||
|
return m.inspectSelection()
|
||||||
|
}
|
||||||
|
return m.zoomIntoSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) mouseWheel(ev tea.Mouse) tea.Cmd {
|
||||||
|
t := m.tree
|
||||||
|
t.RLock()
|
||||||
|
defer t.RUnlock()
|
||||||
|
m.dirty = true
|
||||||
|
up := ev.Button == tea.MouseWheelUp
|
||||||
|
if ev.Button != tea.MouseWheelUp && ev.Button != tea.MouseWheelDown {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(m.modals) > 0 {
|
||||||
|
f := m.modals[len(m.modals)-1].(*modalFrame)
|
||||||
|
if up {
|
||||||
|
f.scroll -= 3
|
||||||
|
} else {
|
||||||
|
f.scroll += 3
|
||||||
|
}
|
||||||
|
f.clampScroll()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if up {
|
||||||
|
// Zoom towards the block under the pointer.
|
||||||
|
if l := m.tm; l != nil && m.view == 0 {
|
||||||
|
for _, b := range l.blocks {
|
||||||
|
if b.Contains(ev.X, ev.Y) {
|
||||||
|
m.sel = b.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.views[m.view].wheel(m, up)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) mouseMotion(ev tea.Mouse) {
|
||||||
|
h := noSel
|
||||||
|
if l := m.tm; l != nil && m.view == 0 && len(m.modals) == 0 {
|
||||||
|
for _, b := range l.blocks {
|
||||||
|
if b.Contains(ev.X, ev.Y) {
|
||||||
|
h = b.ID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h != m.hover {
|
||||||
|
m.hover = h
|
||||||
|
m.dirty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"github.com/charmbracelet/colorprofile"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/brand"
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
"github.com/andydixon/mapsize/internal/treemap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Minimum usable terminal size.
|
||||||
|
const minW, minH = 40, 10
|
||||||
|
|
||||||
|
type sizeClass int
|
||||||
|
|
||||||
|
const (
|
||||||
|
tooSmall sizeClass = iota
|
||||||
|
small
|
||||||
|
medium
|
||||||
|
large
|
||||||
|
)
|
||||||
|
|
||||||
|
// geometry is the panel layout for one terminal size. It is recomputed from
|
||||||
|
// scratch for every frame, so resizing can never leave stale geometry.
|
||||||
|
type geometry struct {
|
||||||
|
class sizeClass
|
||||||
|
tabs, crumbs treemap.Rect // tabs is empty in the small layout
|
||||||
|
main, side, info, stat treemap.Rect
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeGeometry(w, h int) geometry {
|
||||||
|
g := geometry{}
|
||||||
|
switch {
|
||||||
|
case w < minW || h < minH:
|
||||||
|
g.class = tooSmall
|
||||||
|
return g
|
||||||
|
case w >= 150 && h >= 36:
|
||||||
|
g.class = large
|
||||||
|
case w >= 90 && h >= 22:
|
||||||
|
g.class = medium
|
||||||
|
default:
|
||||||
|
g.class = small
|
||||||
|
}
|
||||||
|
y := 0
|
||||||
|
if g.class >= medium {
|
||||||
|
g.tabs = treemap.Rect{X: 0, Y: 0, W: w, H: 1}
|
||||||
|
y = 1
|
||||||
|
}
|
||||||
|
g.crumbs = treemap.Rect{X: 0, Y: y, W: w, H: 1}
|
||||||
|
y++
|
||||||
|
bottom := h - 1
|
||||||
|
g.stat = treemap.Rect{X: 0, Y: h - 1, W: w, H: 1}
|
||||||
|
if g.class >= medium {
|
||||||
|
g.info = treemap.Rect{X: 0, Y: h - 2, W: w, H: 1}
|
||||||
|
bottom = h - 2
|
||||||
|
}
|
||||||
|
g.main = treemap.Rect{X: 0, Y: y, W: w, H: bottom - y}
|
||||||
|
if g.class == large {
|
||||||
|
sw := min(40, w/5)
|
||||||
|
g.side = treemap.Rect{X: 0, Y: y, W: sw, H: g.main.H}
|
||||||
|
g.main.X, g.main.W = sw, w-sw
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) render() string {
|
||||||
|
t := m.theme
|
||||||
|
cv := NewCanvas(m.w, m.h, t.base())
|
||||||
|
m.hits = m.hits[:0]
|
||||||
|
g := computeGeometry(m.w, m.h)
|
||||||
|
if g.class == tooSmall {
|
||||||
|
m.renderTooSmall(cv)
|
||||||
|
return cv.Render(m.profile == colorprofile.ANSI256)
|
||||||
|
}
|
||||||
|
m.tree.RLock()
|
||||||
|
defer m.tree.RUnlock()
|
||||||
|
|
||||||
|
m.ensureSelection()
|
||||||
|
if g.tabs.W > 0 {
|
||||||
|
m.paintTabs(cv, g.tabs)
|
||||||
|
}
|
||||||
|
m.paintCrumbs(cv, g.crumbs)
|
||||||
|
if !g.side.Empty() {
|
||||||
|
m.paintSide(cv, g.side)
|
||||||
|
}
|
||||||
|
m.views[m.view].paint(m, cv, g.main)
|
||||||
|
if !g.info.Empty() {
|
||||||
|
m.paintInfoLine(cv, g.info)
|
||||||
|
}
|
||||||
|
m.paintStatus(cv, g.stat, g.class)
|
||||||
|
if g.class == small && m.search.editing {
|
||||||
|
m.paintSearch(cv, g.stat)
|
||||||
|
}
|
||||||
|
if len(m.modals) > 0 {
|
||||||
|
m.paintModal(cv)
|
||||||
|
}
|
||||||
|
return cv.Render(m.profile == colorprofile.ANSI256)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) renderTooSmall(cv *Canvas) {
|
||||||
|
st := m.theme.muted()
|
||||||
|
lines := []string{"Terminal too small", fmt.Sprintf("%d×%d — need %d×%d", m.w, m.h, minW, minH), "q quits"}
|
||||||
|
y0 := max(0, (m.h-len(lines))/2)
|
||||||
|
for i, l := range lines {
|
||||||
|
cv.TextCentered(0, y0+i, m.w, l, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// paintTabs draws the view tabs and mode chips.
|
||||||
|
func (m *Model) paintTabs(cv *Canvas, r treemap.Rect) {
|
||||||
|
t := m.theme
|
||||||
|
cv.Fill(r, " ", t.header())
|
||||||
|
badge := " ◧ " + brand.Name + " "
|
||||||
|
if t.ASCII {
|
||||||
|
badge = " " + brand.Name + " "
|
||||||
|
}
|
||||||
|
x := cv.Text(r.X, r.Y, badge, r.W, Style{FG: t.textOn(t.Accent), BG: t.Accent, Attr: Bold | m.monoRev()})
|
||||||
|
x++
|
||||||
|
for i, v := range m.views {
|
||||||
|
label := " " + v.name() + " "
|
||||||
|
st := Style{FG: t.Muted, BG: t.HeaderBg}
|
||||||
|
if i == m.view {
|
||||||
|
st = Style{FG: t.Fg, BG: t.Bg, Attr: Bold | Underline}
|
||||||
|
}
|
||||||
|
if x+textutil.Width(label) >= r.W-30 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
w := cv.Text(x, r.Y, label, r.W-x, st)
|
||||||
|
idx := i
|
||||||
|
m.hits = append(m.hits, hit{treemap.Rect{X: x, Y: r.Y, W: w, H: 1}, func(m *Model, _ bool) tea.Cmd { m.setView(idx); return nil }})
|
||||||
|
x += w
|
||||||
|
}
|
||||||
|
// Right-aligned chips.
|
||||||
|
var chips []struct {
|
||||||
|
s string
|
||||||
|
bg Color
|
||||||
|
}
|
||||||
|
add := func(s string, bg Color) {
|
||||||
|
chips = append(chips, struct {
|
||||||
|
s string
|
||||||
|
bg Color
|
||||||
|
}{s, bg})
|
||||||
|
}
|
||||||
|
if m.opts.ReadOnly {
|
||||||
|
add("READ ONLY", t.Err)
|
||||||
|
}
|
||||||
|
if m.snapshot {
|
||||||
|
add("SNAPSHOT", t.Accent)
|
||||||
|
}
|
||||||
|
if m.opts.Diff != nil {
|
||||||
|
add("COMPARE", t.DiffNew)
|
||||||
|
}
|
||||||
|
if m.search.query != nil {
|
||||||
|
add("FILTER", t.Warn)
|
||||||
|
}
|
||||||
|
if len(m.tree.Stats.Excludes) > 0 {
|
||||||
|
add(fmt.Sprintf("EXCL %d", len(m.tree.Stats.Excludes)), t.Faint)
|
||||||
|
}
|
||||||
|
add(strings.ToUpper(m.sizeMode.String()), t.Faint)
|
||||||
|
rx := r.X + r.W
|
||||||
|
for i := len(chips) - 1; i >= 0; i-- {
|
||||||
|
s := " " + chips[i].s + " "
|
||||||
|
w := textutil.Width(s)
|
||||||
|
if rx-w-1 < x {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
rx -= w
|
||||||
|
cv.Text(rx, r.Y, s, w, Style{FG: t.textOn(chips[i].bg), BG: chips[i].bg, Attr: Bold | m.monoRev()})
|
||||||
|
rx--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) monoRev() Attr {
|
||||||
|
if m.theme.Mono {
|
||||||
|
return Reverse
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// paintCrumbs draws "/ › home › andy" breadcrumbs, truncated from the left,
|
||||||
|
// with the zoom root's size on the right.
|
||||||
|
func (m *Model) paintCrumbs(cv *Canvas, r treemap.Rect) {
|
||||||
|
t := m.theme
|
||||||
|
cv.Fill(r, " ", t.header())
|
||||||
|
tr := m.tree
|
||||||
|
z := tr.Node(m.zoom)
|
||||||
|
size := m.sizeOf(m.zoom)
|
||||||
|
right := " " + textutil.Size(size)
|
||||||
|
if root := m.sizeOf(tr.Root()); m.zoom != tr.Root() && root > 0 {
|
||||||
|
right += " " + textutil.Percent(size, root) + " of scan"
|
||||||
|
}
|
||||||
|
if m.search.result != nil {
|
||||||
|
right += fmt.Sprintf(" (%s matches)", textutil.Count(m.search.result.Count))
|
||||||
|
}
|
||||||
|
if z.IsDir() {
|
||||||
|
right += fmt.Sprintf(" %s files", textutil.Count(int64(z.Files)))
|
||||||
|
}
|
||||||
|
right += " "
|
||||||
|
rw := textutil.Width(right)
|
||||||
|
if rw > r.W/2 {
|
||||||
|
right = " " + textutil.SizeCompact(size) + " "
|
||||||
|
rw = textutil.Width(right)
|
||||||
|
}
|
||||||
|
cv.TextRight(r.X, r.Y, r.W, right, Style{FG: t.HeaderFg, BG: t.HeaderBg, Attr: Bold})
|
||||||
|
|
||||||
|
sep := " › "
|
||||||
|
if t.ASCII {
|
||||||
|
sep = " > "
|
||||||
|
}
|
||||||
|
chain := tr.Ancestors(m.zoom)
|
||||||
|
type seg struct {
|
||||||
|
id inventory.NodeID
|
||||||
|
s string
|
||||||
|
}
|
||||||
|
segs := make([]seg, len(chain))
|
||||||
|
for i, id := range chain {
|
||||||
|
segs[i] = seg{id, textutil.Sanitize(tr.Node(id).Name)}
|
||||||
|
}
|
||||||
|
avail := r.W - rw - 2
|
||||||
|
// Drop leading segments until the rest fits; keep at least the last.
|
||||||
|
total := func(from int) int {
|
||||||
|
w := 0
|
||||||
|
for i := from; i < len(segs); i++ {
|
||||||
|
w += textutil.Width(segs[i].s)
|
||||||
|
if i > from {
|
||||||
|
w += textutil.Width(sep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if from > 0 {
|
||||||
|
w += textutil.Width("…" + sep)
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
from := 0
|
||||||
|
for from < len(segs)-1 && total(from) > avail {
|
||||||
|
from++
|
||||||
|
}
|
||||||
|
x := r.X + 1
|
||||||
|
if from > 0 {
|
||||||
|
x += cv.Text(x, r.Y, "…"+sep, avail, Style{FG: t.Muted, BG: t.HeaderBg})
|
||||||
|
}
|
||||||
|
for i := from; i < len(segs); i++ {
|
||||||
|
if i > from {
|
||||||
|
x += cv.Text(x, r.Y, sep, r.X+1+avail-x, Style{FG: t.Faint, BG: t.HeaderBg})
|
||||||
|
}
|
||||||
|
st := Style{FG: t.Muted, BG: t.HeaderBg}
|
||||||
|
if i == len(segs)-1 {
|
||||||
|
st = Style{FG: t.Fg, BG: t.HeaderBg, Attr: Bold}
|
||||||
|
}
|
||||||
|
s := segs[i].s
|
||||||
|
if i == len(segs)-1 {
|
||||||
|
s = textutil.TruncateLeft(s, max(1, r.X+1+avail-x))
|
||||||
|
}
|
||||||
|
w := cv.Text(x, r.Y, s, r.X+1+avail-x, st)
|
||||||
|
id := segs[i].id
|
||||||
|
m.hits = append(m.hits, hit{treemap.Rect{X: x, Y: r.Y, W: w, H: 1}, func(m *Model, _ bool) tea.Cmd { m.zoomTo(id); return nil }})
|
||||||
|
x += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// paintInfoLine describes the selection (or shows the search editor).
|
||||||
|
func (m *Model) paintInfoLine(cv *Canvas, r treemap.Rect) {
|
||||||
|
t := m.theme
|
||||||
|
cv.Fill(r, " ", t.panel())
|
||||||
|
if m.search.editing {
|
||||||
|
m.paintSearch(cv, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.search.query != nil {
|
||||||
|
m.paintFilterSummary(cv, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cv.Text(r.X+1, r.Y, m.describeSelection(), r.W-2, t.panel())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) describeSelection() string {
|
||||||
|
id := m.selectedNode()
|
||||||
|
tr := m.tree
|
||||||
|
if isGroup(m.sel) {
|
||||||
|
if g, ok := m.groupInfo(m.sel); ok {
|
||||||
|
return fmt.Sprintf("▸ %s smaller items · %s · %s of %s · Enter lists them",
|
||||||
|
textutil.Count(int64(len(g.ids))), textutil.Size(g.size), textutil.Percent(g.size, m.sizeOf(m.zoom)),
|
||||||
|
textutil.Sanitize(tr.Node(m.zoom).Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id == inventory.NoNode {
|
||||||
|
return "Nothing selected"
|
||||||
|
}
|
||||||
|
n := tr.Node(id)
|
||||||
|
parts := []string{"▸ " + textutil.Sanitize(n.Name)}
|
||||||
|
parts = append(parts, textutil.Size(m.sizeOf(id)))
|
||||||
|
if p := n.Parent; p != inventory.NoNode {
|
||||||
|
parts = append(parts, textutil.Percent(m.sizeOf(id), m.sizeOf(p))+" of parent")
|
||||||
|
}
|
||||||
|
if n.IsDir() {
|
||||||
|
parts = append(parts, textutil.Count(int64(n.Files))+" files", textutil.Count(int64(n.Dirs))+" dirs")
|
||||||
|
if n.Flags&inventory.FlagScanned == 0 && m.scanning {
|
||||||
|
parts = append(parts, "scanning…")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts = append(parts, kindLabel(tr, n))
|
||||||
|
}
|
||||||
|
if n.MTime > 0 {
|
||||||
|
parts = append(parts, "modified "+time.Unix(0, n.MTime).Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
if n.Errors > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d errors", n.Errors))
|
||||||
|
}
|
||||||
|
if m.opts.Diff != nil {
|
||||||
|
parts = append(parts, textutil.SignedSize(m.opts.Diff.Delta(id, m.sizeMode))+" since "+"snapshot")
|
||||||
|
}
|
||||||
|
return strings.Join(parts, " · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func kindLabel(tr *inventory.Tree, n *inventory.Node) string {
|
||||||
|
switch n.Kind {
|
||||||
|
case inventory.KindFile:
|
||||||
|
return n.Cat.String()
|
||||||
|
case inventory.KindSymlink:
|
||||||
|
return "symlink"
|
||||||
|
}
|
||||||
|
return n.Kind.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
var spinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||||
|
|
||||||
|
// paintStatus draws the live status bar.
|
||||||
|
func (m *Model) paintStatus(cv *Canvas, r treemap.Rect, class sizeClass) {
|
||||||
|
t := m.theme
|
||||||
|
cv.Fill(r, " ", t.status())
|
||||||
|
help := " ? help "
|
||||||
|
if class == small {
|
||||||
|
help = " ? "
|
||||||
|
}
|
||||||
|
hw := cv.Text(r.X+r.W-textutil.Width(help), r.Y, help, r.W, Style{FG: t.Muted, BG: t.StatusBg})
|
||||||
|
avail := r.W - hw - 1
|
||||||
|
if m.toast != "" {
|
||||||
|
cv.Text(r.X+1, r.Y, m.toast, avail-1, m.toastStyle)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st := &m.tree.Stats
|
||||||
|
root := m.tree.Node(m.tree.Root())
|
||||||
|
files := st.Files + st.Symlinks + st.Others
|
||||||
|
var parts []struct {
|
||||||
|
s string
|
||||||
|
fg Color
|
||||||
|
}
|
||||||
|
add := func(s string, fg Color) {
|
||||||
|
parts = append(parts, struct {
|
||||||
|
s string
|
||||||
|
fg Color
|
||||||
|
}{s, fg})
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case m.scanning:
|
||||||
|
frame := spinner[int(time.Now().UnixMilli()/100)%len(spinner)]
|
||||||
|
if t.ASCII {
|
||||||
|
frame = "*"
|
||||||
|
}
|
||||||
|
add(frame+" scanning", t.Accent)
|
||||||
|
case st.Cancelled:
|
||||||
|
add("✗ cancelled", t.Warn)
|
||||||
|
case m.snapshot:
|
||||||
|
add("◉ snapshot", t.Accent)
|
||||||
|
default:
|
||||||
|
add("✓ complete", t.OK)
|
||||||
|
}
|
||||||
|
add(textutil.Count(files)+" files", t.StatusFg)
|
||||||
|
add(textutil.Size(root.Total(m.sizeMode)), t.StatusFg)
|
||||||
|
if m.scanning {
|
||||||
|
el := st.Elapsed()
|
||||||
|
p := m.scanner.Progress()
|
||||||
|
add(textutil.Count(int64(float64(p.Entries)/max(el.Seconds(), 0.001)))+"/s", t.StatusFg)
|
||||||
|
if class != small {
|
||||||
|
add(fmt.Sprintf("%d workers", p.Workers), t.Muted)
|
||||||
|
add(fmt.Sprintf("queue %s", textutil.Count(p.Pending)), t.Muted)
|
||||||
|
}
|
||||||
|
add(textutil.Duration(el), t.Muted)
|
||||||
|
} else if !m.snapshot && class != small {
|
||||||
|
add(textutil.Duration(st.Elapsed()), t.Muted)
|
||||||
|
}
|
||||||
|
if e := st.TotalErrors(); e > 0 {
|
||||||
|
add(fmt.Sprintf("%s errors", textutil.Count(e)), t.Err)
|
||||||
|
}
|
||||||
|
if st.Excluded > 0 {
|
||||||
|
add(fmt.Sprintf("%s excluded", textutil.Count(st.Excluded)), t.Warn)
|
||||||
|
}
|
||||||
|
if st.Incomplete() && !m.scanning && class != small {
|
||||||
|
add("totals incomplete", t.Warn)
|
||||||
|
}
|
||||||
|
x := r.X + 1
|
||||||
|
for i, p := range parts {
|
||||||
|
s := p.s
|
||||||
|
if i > 0 {
|
||||||
|
s = " " + s
|
||||||
|
}
|
||||||
|
if x+textutil.Width(s) > r.X+avail {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
x += cv.Text(x, r.Y, s, avail-x, Style{FG: p.fg, BG: t.StatusBg, Attr: map[bool]Attr{true: Bold}[i == 0]})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"github.com/charmbracelet/colorprofile"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/scan"
|
||||||
|
"github.com/andydixon/mapsize/internal/snapshot"
|
||||||
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
var profiles = map[string]colorprofile.Profile{
|
||||||
|
"truecolor": colorprofile.TrueColor, "24bit": colorprofile.TrueColor,
|
||||||
|
"256": colorprofile.ANSI256, "16": colorprofile.ANSI, "none": colorprofile.ASCII,
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveFile(path string, t *inventory.Tree) error { return snapshot.SaveFile(path, t) }
|
||||||
|
|
||||||
|
// Run starts the interactive UI. The terminal is restored on every exit
|
||||||
|
// path: normal quit, Ctrl+C, errors and panics (Bubble Tea recovers panics
|
||||||
|
// in Update/View; scanner goroutine panics are routed here via
|
||||||
|
// scan.OnPanic and reported after the terminal is restored).
|
||||||
|
func Run(opts Options) error {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
s, t, err := opts.Start(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m := newModel(opts, s, t, cancel)
|
||||||
|
var popts []tea.ProgramOption
|
||||||
|
if p, ok := profiles[opts.Color]; ok {
|
||||||
|
m.profile, m.forced = p, true
|
||||||
|
popts = append(popts, tea.WithColorProfile(p))
|
||||||
|
} else if opts.Color != "" && opts.Color != "auto" {
|
||||||
|
return fmt.Errorf("invalid --color %q (auto, truecolor, 256, 16, none)", opts.Color)
|
||||||
|
}
|
||||||
|
p := tea.NewProgram(m, popts...)
|
||||||
|
prev := scan.OnPanic
|
||||||
|
scan.OnPanic = func(v any, stack []byte) {
|
||||||
|
p.Send(fatalMsg{v, stack})
|
||||||
|
select {} // park the broken goroutine; the program is quitting
|
||||||
|
}
|
||||||
|
defer func() { scan.OnPanic = prev }()
|
||||||
|
|
||||||
|
_, err = p.Run()
|
||||||
|
cancel()
|
||||||
|
if m.fatal != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "mapsize: internal error in scanner: %s\n\n%s\n",
|
||||||
|
textutil.Sanitize(fmt.Sprint(m.fatal.v)), m.fatal.stack)
|
||||||
|
return fmt.Errorf("scanner crashed")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/filter"
|
||||||
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
"github.com/andydixon/mapsize/internal/treemap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lineEditor is a minimal single-line text editor.
|
||||||
|
type lineEditor struct {
|
||||||
|
buf []rune
|
||||||
|
cur int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *lineEditor) String() string { return string(e.buf) }
|
||||||
|
|
||||||
|
func (e *lineEditor) set(s string) { e.buf, e.cur = []rune(s), len([]rune(s)) }
|
||||||
|
|
||||||
|
// key applies an editing key; it reports whether the text changed.
|
||||||
|
func (e *lineEditor) key(msg tea.KeyPressMsg) (changed, handled bool) {
|
||||||
|
switch msg.Keystroke() {
|
||||||
|
case "left", "ctrl+b":
|
||||||
|
e.cur = max(0, e.cur-1)
|
||||||
|
case "right", "ctrl+f":
|
||||||
|
e.cur = min(len(e.buf), e.cur+1)
|
||||||
|
case "home", "ctrl+a":
|
||||||
|
e.cur = 0
|
||||||
|
case "end", "ctrl+e":
|
||||||
|
e.cur = len(e.buf)
|
||||||
|
case "backspace", "ctrl+h":
|
||||||
|
if e.cur > 0 {
|
||||||
|
e.buf = append(e.buf[:e.cur-1], e.buf[e.cur:]...)
|
||||||
|
e.cur--
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
case "delete", "ctrl+d":
|
||||||
|
if e.cur < len(e.buf) {
|
||||||
|
e.buf = append(e.buf[:e.cur], e.buf[e.cur+1:]...)
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
case "ctrl+u":
|
||||||
|
e.buf, e.cur = e.buf[e.cur:], 0
|
||||||
|
return true, true
|
||||||
|
case "ctrl+k":
|
||||||
|
e.buf = e.buf[:e.cur]
|
||||||
|
return true, true
|
||||||
|
case "ctrl+w", "alt+backspace":
|
||||||
|
i := e.cur
|
||||||
|
for i > 0 && e.buf[i-1] == ' ' {
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
for i > 0 && e.buf[i-1] != ' ' {
|
||||||
|
i--
|
||||||
|
}
|
||||||
|
e.buf = append(e.buf[:i], e.buf[e.cur:]...)
|
||||||
|
e.cur = i
|
||||||
|
return true, true
|
||||||
|
default:
|
||||||
|
txt := msg.Text
|
||||||
|
if txt == "" || msg.Mod&(tea.ModCtrl|tea.ModAlt) != 0 {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
var rs []rune
|
||||||
|
for _, r := range txt {
|
||||||
|
if r >= 0x20 && r != 0x7f {
|
||||||
|
rs = append(rs, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rs) == 0 || len(e.buf)+len(rs) > 1024 {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
e.buf = append(e.buf[:e.cur], append(rs, e.buf[e.cur:]...)...)
|
||||||
|
e.cur += len(rs)
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
type searchState struct {
|
||||||
|
editing bool
|
||||||
|
ed lineEditor
|
||||||
|
query *filter.Query
|
||||||
|
result *filter.Result
|
||||||
|
err string
|
||||||
|
gen int
|
||||||
|
busy bool
|
||||||
|
appliedAt time.Time
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *searchState) begin() {
|
||||||
|
s.editing = true
|
||||||
|
if s.query != nil {
|
||||||
|
s.ed.set(s.query.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) searchKey(msg tea.KeyPressMsg) tea.Cmd {
|
||||||
|
s := &m.search
|
||||||
|
switch msg.Keystroke() {
|
||||||
|
case "esc":
|
||||||
|
s.editing = false
|
||||||
|
m.clearFilter()
|
||||||
|
return nil
|
||||||
|
case "enter":
|
||||||
|
s.editing = false
|
||||||
|
if s.err != "" {
|
||||||
|
return m.warn("Invalid query: " + s.err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
changed, _ := s.ed.key(msg)
|
||||||
|
if changed {
|
||||||
|
return m.queryChanged()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryChanged parses the editor contents and schedules a debounced apply.
|
||||||
|
func (m *Model) queryChanged() tea.Cmd {
|
||||||
|
s := &m.search
|
||||||
|
s.gen++
|
||||||
|
text := s.ed.String()
|
||||||
|
if text == "" {
|
||||||
|
s.err = ""
|
||||||
|
m.dropFilter()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
q, err := filter.Parse(text, time.Now())
|
||||||
|
if err != nil {
|
||||||
|
s.err = err.Error()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.err = ""
|
||||||
|
s.query = q
|
||||||
|
gen := s.gen
|
||||||
|
return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return filterDebounceMsg{gen} })
|
||||||
|
}
|
||||||
|
|
||||||
|
// setFilter replaces the filter with q and applies it immediately.
|
||||||
|
func (m *Model) setFilter(q string) tea.Cmd {
|
||||||
|
m.search.ed.set(q)
|
||||||
|
m.search.editing = false
|
||||||
|
m.queryChanged()
|
||||||
|
if m.search.err != "" {
|
||||||
|
return m.warn("Invalid query: " + m.search.err)
|
||||||
|
}
|
||||||
|
return m.applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyFilter evaluates the current query in the background. The tree is
|
||||||
|
// read-locked by the worker; stale results are discarded by generation.
|
||||||
|
func (m *Model) applyFilter() tea.Cmd {
|
||||||
|
s := &m.search
|
||||||
|
if s.query == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.cancel != nil {
|
||||||
|
s.cancel()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
s.cancel = cancel
|
||||||
|
s.busy = true
|
||||||
|
s.appliedAt = time.Now()
|
||||||
|
t, q, mode, gen := m.tree, s.query, m.sizeMode, s.gen
|
||||||
|
return func() tea.Msg {
|
||||||
|
t.RLock()
|
||||||
|
res, err := filter.Apply(ctx, t, q, mode)
|
||||||
|
t.RUnlock()
|
||||||
|
return filterMsg{gen: gen, res: res, err: err}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) filterResult(msg filterMsg) tea.Cmd {
|
||||||
|
s := &m.search
|
||||||
|
if msg.gen != s.gen || s.query == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.busy = false
|
||||||
|
if msg.err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.result = msg.res
|
||||||
|
m.invalidate()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropFilter removes the active filter but keeps the editor open.
|
||||||
|
func (m *Model) dropFilter() {
|
||||||
|
s := &m.search
|
||||||
|
if s.cancel != nil {
|
||||||
|
s.cancel()
|
||||||
|
}
|
||||||
|
s.query, s.result, s.busy = nil, nil, false
|
||||||
|
m.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) clearFilter() {
|
||||||
|
m.dropFilter()
|
||||||
|
m.search.ed.set("")
|
||||||
|
m.search.err = ""
|
||||||
|
m.search.gen++
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) paintSearch(cv *Canvas, r treemap.Rect) {
|
||||||
|
t := m.theme
|
||||||
|
s := &m.search
|
||||||
|
st := Style{FG: t.Fg, BG: t.PanelBg}
|
||||||
|
cv.Fill(r, " ", st)
|
||||||
|
x := r.X + 1
|
||||||
|
x += cv.Text(x, r.Y, "/ ", 2, Style{FG: t.Accent, BG: t.PanelBg, Attr: Bold})
|
||||||
|
status, statusSt := "", Style{FG: t.Muted, BG: t.PanelBg}
|
||||||
|
switch {
|
||||||
|
case s.err != "":
|
||||||
|
status, statusSt.FG = "✗ "+s.err, t.Err
|
||||||
|
case s.busy:
|
||||||
|
status = "filtering…"
|
||||||
|
case s.result != nil:
|
||||||
|
status = fmt.Sprintf("%s matches · %s", textutil.Count(s.result.Count), textutil.Size(s.result.Total))
|
||||||
|
case len(s.ed.buf) == 0:
|
||||||
|
status = "name, *.iso, size > 1GB, ext IN (iso,vmdk), age > 365d … Enter keep · Esc clear"
|
||||||
|
}
|
||||||
|
sw := min(textutil.Width(status), r.W/2)
|
||||||
|
avail := r.W - (x - r.X) - sw - 3
|
||||||
|
// Scroll the text so the cursor stays visible.
|
||||||
|
text := s.ed.buf
|
||||||
|
start := 0
|
||||||
|
if s.ed.cur > avail-1 {
|
||||||
|
start = s.ed.cur - avail + 1
|
||||||
|
}
|
||||||
|
vis := string(text[start:min(len(text), start+avail)])
|
||||||
|
cv.Text(x, r.Y, textutil.Sanitize(vis), avail, st)
|
||||||
|
cx := x + textutil.Width(textutil.Sanitize(string(text[start:s.ed.cur])))
|
||||||
|
under := " "
|
||||||
|
if s.ed.cur < len(text) {
|
||||||
|
under = textutil.Sanitize(string(text[s.ed.cur]))
|
||||||
|
}
|
||||||
|
cv.Text(cx, r.Y, under, 2, Style{FG: t.PanelBg, BG: t.Fg, Attr: Reverse * Attr(boolInt(t.Mono))})
|
||||||
|
cv.TextRight(r.X, r.Y, r.W-1, textutil.Truncate(status, sw), statusSt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) paintFilterSummary(cv *Canvas, r treemap.Rect) {
|
||||||
|
t := m.theme
|
||||||
|
s := &m.search
|
||||||
|
x := r.X + 1
|
||||||
|
x += cv.Text(x, r.Y, " FILTER ", 8, Style{FG: t.textOn(t.Warn), BG: t.Warn, Attr: Bold | m.monoRev()})
|
||||||
|
x += cv.Text(x+1, r.Y, textutil.Sanitize(s.query.String()), r.W/2, Style{FG: t.Fg, BG: t.PanelBg, Attr: Bold}) + 1
|
||||||
|
info := "filtering…"
|
||||||
|
if s.result != nil && !s.busy {
|
||||||
|
info = fmt.Sprintf("%s matches · %s of %s · / edit · Esc clear", textutil.Count(s.result.Count),
|
||||||
|
textutil.Size(s.result.Total), textutil.Size(m.tree.Node(0).Total(m.sizeMode)))
|
||||||
|
}
|
||||||
|
cv.Text(x+2, r.Y, info, r.X+r.W-x-3, Style{FG: t.Muted, BG: t.PanelBg})
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
"github.com/andydixon/mapsize/internal/treemap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// paintSide draws the large-layout side panel: directory summary, category
|
||||||
|
// breakdown and the selected item.
|
||||||
|
func (m *Model) paintSide(cv *Canvas, r treemap.Rect) {
|
||||||
|
t := m.theme
|
||||||
|
ps := t.panel()
|
||||||
|
cv.Fill(r, " ", ps)
|
||||||
|
cv.Fill(treemap.Rect{X: r.X + r.W - 1, Y: r.Y, W: 1, H: r.H}, "│", Style{FG: t.Faint, BG: t.PanelBg})
|
||||||
|
w := r.W - 3
|
||||||
|
x := r.X + 1
|
||||||
|
y := r.Y + 1
|
||||||
|
end := r.Y + r.H
|
||||||
|
head := func(s string) {
|
||||||
|
if y < end {
|
||||||
|
cv.Text(x, y, s, w, Style{FG: t.Accent, BG: t.PanelBg, Attr: Bold})
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kvl := func(k, v string, vs Style) {
|
||||||
|
if y < end {
|
||||||
|
cv.Text(x, y, k, w, Style{FG: t.Muted, BG: t.PanelBg})
|
||||||
|
cv.TextRight(x, y, w, v, vs)
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val := Style{FG: t.Fg, BG: t.PanelBg, Attr: Bold}
|
||||||
|
tr := m.tree
|
||||||
|
z := tr.Node(m.zoom)
|
||||||
|
|
||||||
|
head("DIRECTORY")
|
||||||
|
for _, l := range wrap(textutil.Sanitize(z.Name), w) {
|
||||||
|
if y < end {
|
||||||
|
cv.Text(x, y, l, w, Style{FG: t.Fg, BG: t.PanelBg, Attr: Bold})
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
y++
|
||||||
|
kvl("Size", textutil.Size(m.sizeOf(m.zoom)), val)
|
||||||
|
kvl("Logical", textutil.Size(z.TotSize), Style{FG: t.Fg, BG: t.PanelBg})
|
||||||
|
kvl("Allocated", textutil.Size(z.TotAlloc), Style{FG: t.Fg, BG: t.PanelBg})
|
||||||
|
kvl("Files", textutil.Count(int64(z.Files)), Style{FG: t.Fg, BG: t.PanelBg})
|
||||||
|
kvl("Directories", textutil.Count(int64(z.Dirs)), Style{FG: t.Fg, BG: t.PanelBg})
|
||||||
|
if z.Errors > 0 {
|
||||||
|
kvl("Errors", textutil.Count(int64(z.Errors)), Style{FG: t.Err, BG: t.PanelBg, Attr: Bold})
|
||||||
|
}
|
||||||
|
y++
|
||||||
|
|
||||||
|
// Category breakdown with proportional bars.
|
||||||
|
if cs := tr.CatSizes(m.zoom); cs != nil {
|
||||||
|
head("CATEGORIES")
|
||||||
|
type cat struct {
|
||||||
|
c inventory.Category
|
||||||
|
v int64
|
||||||
|
}
|
||||||
|
var cats []cat
|
||||||
|
var total int64
|
||||||
|
for i, v := range cs {
|
||||||
|
if v > 0 {
|
||||||
|
cats = append(cats, cat{inventory.Category(i), v})
|
||||||
|
total += v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(cats, func(i, j int) bool { return cats[i].v > cats[j].v })
|
||||||
|
for _, c := range cats {
|
||||||
|
if y >= end-8 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
col := t.Cat[c.c]
|
||||||
|
sw := "██ "
|
||||||
|
if t.Mono || t.ASCII {
|
||||||
|
sw = " "
|
||||||
|
}
|
||||||
|
cv.Text(x, y, sw, 3, Style{FG: col, BG: t.PanelBg})
|
||||||
|
cv.Text(x+3, y, c.c.String(), w-3, Style{FG: t.Fg, BG: t.PanelBg})
|
||||||
|
cv.TextRight(x, y, w, textutil.Percent(c.v, total), Style{FG: t.Muted, BG: t.PanelBg})
|
||||||
|
y++
|
||||||
|
m.paintBar(cv, x+3, y, w-3, float64(c.v)/float64(cats[0].v), col, false)
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selected item.
|
||||||
|
if y < end-4 {
|
||||||
|
head("SELECTED")
|
||||||
|
desc := []string{}
|
||||||
|
if isGroup(m.sel) {
|
||||||
|
if g, ok := m.groupInfo(m.sel); ok {
|
||||||
|
desc = append(desc, fmt.Sprintf("%s smaller items", textutil.Count(int64(len(g.ids)))), textutil.Size(g.size))
|
||||||
|
}
|
||||||
|
} else if id := m.selectedNode(); id != inventory.NoNode {
|
||||||
|
n := tr.Node(id)
|
||||||
|
desc = append(desc, wrap(textutil.Sanitize(n.Name), w)...)
|
||||||
|
desc = append(desc, textutil.Size(m.sizeOf(id))+" · "+textutil.Percent(m.sizeOf(id), m.sizeOf(m.zoom)))
|
||||||
|
if n.IsDir() {
|
||||||
|
desc = append(desc, textutil.Count(int64(n.Files))+" files")
|
||||||
|
} else {
|
||||||
|
desc = append(desc, kindLabel(tr, n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range desc {
|
||||||
|
if y < end {
|
||||||
|
cv.Text(x, y, d, w, Style{FG: t.Fg, BG: t.PanelBg})
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
"github.com/andydixon/mapsize/internal/treemap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// column describes one table column.
|
||||||
|
type column struct {
|
||||||
|
title string
|
||||||
|
width int // 0 = flexible (takes the remaining space)
|
||||||
|
right bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// tableRow is one rendered row: cell strings plus optional per-cell styles.
|
||||||
|
type tableRow struct {
|
||||||
|
cells []string
|
||||||
|
styles []*Style // nil = default
|
||||||
|
bar float64 // 0..1 for an optional bar column (-1 = none)
|
||||||
|
barCol Color
|
||||||
|
}
|
||||||
|
|
||||||
|
// table is a scrollable list with a cursor, shared by the list-style views.
|
||||||
|
type table struct {
|
||||||
|
cursor, offset int
|
||||||
|
height int // rows visible in the last paint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *table) move(d, n int) {
|
||||||
|
if n == 0 {
|
||||||
|
tb.cursor = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tb.cursor = min(max(tb.cursor+d, 0), n-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// key handles navigation keys common to all tables.
|
||||||
|
func (tb *table) key(k string, n int) bool {
|
||||||
|
page := max(tb.height-1, 1)
|
||||||
|
switch k {
|
||||||
|
case "up", "k":
|
||||||
|
tb.move(-1, n)
|
||||||
|
case "down", "j":
|
||||||
|
tb.move(1, n)
|
||||||
|
case "pgup", "ctrl+b":
|
||||||
|
tb.move(-page, n)
|
||||||
|
case "pgdown", "ctrl+f":
|
||||||
|
tb.move(page, n)
|
||||||
|
case "end", "G":
|
||||||
|
tb.move(n, n)
|
||||||
|
case "g":
|
||||||
|
tb.cursor = 0
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tb *table) paint(m *Model, cv *Canvas, r treemap.Rect, cols []column, rows []tableRow, title string) {
|
||||||
|
t := m.theme
|
||||||
|
cv.Fill(r, " ", t.base())
|
||||||
|
if r.H < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
y := r.Y
|
||||||
|
if title != "" {
|
||||||
|
cv.Text(r.X+1, y, title, r.W-2, Style{FG: t.Accent, BG: t.Bg, Attr: Bold})
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
// Resolve flexible width.
|
||||||
|
fixed := 0
|
||||||
|
for _, c := range cols {
|
||||||
|
fixed += c.width + 1
|
||||||
|
}
|
||||||
|
flex := max(r.W-2-fixed, 8)
|
||||||
|
x := r.X + 1
|
||||||
|
hdr := Style{FG: t.Muted, BG: t.Bg, Attr: Bold}
|
||||||
|
for _, c := range cols {
|
||||||
|
w := c.width
|
||||||
|
if w == 0 {
|
||||||
|
w = flex
|
||||||
|
}
|
||||||
|
s := c.title
|
||||||
|
if c.right {
|
||||||
|
s = textutil.PadLeft(s, w)
|
||||||
|
}
|
||||||
|
cv.Text(x, y, s, w, hdr)
|
||||||
|
x += w + 1
|
||||||
|
}
|
||||||
|
y++
|
||||||
|
cv.HLine(r.X+1, y, r.W-2, "─", Style{FG: t.Faint, BG: t.Bg})
|
||||||
|
y++
|
||||||
|
tb.height = r.Y + r.H - y
|
||||||
|
if tb.cursor >= len(rows) {
|
||||||
|
tb.cursor = max(len(rows)-1, 0)
|
||||||
|
}
|
||||||
|
if tb.cursor < tb.offset {
|
||||||
|
tb.offset = tb.cursor
|
||||||
|
}
|
||||||
|
if tb.cursor >= tb.offset+tb.height {
|
||||||
|
tb.offset = tb.cursor - tb.height + 1
|
||||||
|
}
|
||||||
|
tb.offset = max(0, min(tb.offset, len(rows)-tb.height))
|
||||||
|
for i := tb.offset; i < len(rows) && y < r.Y+r.H; i++ {
|
||||||
|
row := rows[i]
|
||||||
|
sel := i == tb.cursor
|
||||||
|
base := t.base()
|
||||||
|
if sel {
|
||||||
|
base = Style{FG: t.textOn(t.Sel), BG: t.Sel, Attr: Bold | m.monoRev()}
|
||||||
|
cv.Fill(treemap.Rect{X: r.X, Y: y, W: r.W, H: 1}, " ", base)
|
||||||
|
}
|
||||||
|
x := r.X + 1
|
||||||
|
for ci, c := range cols {
|
||||||
|
w := c.width
|
||||||
|
if w == 0 {
|
||||||
|
w = flex
|
||||||
|
}
|
||||||
|
s := ""
|
||||||
|
if ci < len(row.cells) {
|
||||||
|
s = row.cells[ci]
|
||||||
|
}
|
||||||
|
st := base
|
||||||
|
if !sel && ci < len(row.styles) && row.styles[ci] != nil {
|
||||||
|
st = *row.styles[ci]
|
||||||
|
}
|
||||||
|
if c.title == "" && row.bar >= 0 && c.width > 0 {
|
||||||
|
m.paintBar(cv, x, y, w, row.bar, row.barCol, sel)
|
||||||
|
} else if c.right {
|
||||||
|
cv.Text(x, y, textutil.PadLeft(s, w), w, st)
|
||||||
|
} else {
|
||||||
|
cv.Text(x, y, textutil.Truncate(s, w), w, st)
|
||||||
|
}
|
||||||
|
x += w + 1
|
||||||
|
}
|
||||||
|
idx := i
|
||||||
|
m.hits = append(m.hits, hit{treemap.Rect{X: r.X, Y: y, W: r.W, H: 1}, func(m *Model, double bool) tea.Cmd {
|
||||||
|
tb.cursor = idx
|
||||||
|
if double {
|
||||||
|
return m.handleKeyName("enter")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}})
|
||||||
|
y++
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
cv.TextCentered(r.X, r.Y+r.H/2, r.W, "Nothing to show", t.muted())
|
||||||
|
}
|
||||||
|
if len(rows) > tb.height && tb.height > 0 {
|
||||||
|
pos := textutil.Count(int64(tb.cursor+1)) + "/" + textutil.Count(int64(len(rows)))
|
||||||
|
cv.TextRight(r.X, r.Y, r.W-1, pos, t.muted())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// paintBar draws a proportional bar with eighth-block precision.
|
||||||
|
func (m *Model) paintBar(cv *Canvas, x, y, w int, frac float64, col Color, sel bool) {
|
||||||
|
t := m.theme
|
||||||
|
if col == 0 {
|
||||||
|
col = t.Accent
|
||||||
|
}
|
||||||
|
bg := t.Bg.Mix(t.Fg, 0.08)
|
||||||
|
if sel {
|
||||||
|
bg = t.Sel.Mix(RGB(0, 0, 0), 0.25)
|
||||||
|
}
|
||||||
|
eighths := int(frac*float64(w*8) + 0.5)
|
||||||
|
parts := []string{"", "▏", "▎", "▍", "▌", "▋", "▊", "▉"}
|
||||||
|
for i := 0; i < w; i++ {
|
||||||
|
ch := " "
|
||||||
|
switch {
|
||||||
|
case eighths >= 8:
|
||||||
|
ch = "█"
|
||||||
|
case eighths > 0:
|
||||||
|
ch = parts[eighths]
|
||||||
|
}
|
||||||
|
if t.ASCII {
|
||||||
|
ch = map[bool]string{true: "#", false: " "}[eighths >= 4]
|
||||||
|
}
|
||||||
|
eighths -= 8
|
||||||
|
st := Style{FG: col, BG: bg}
|
||||||
|
if t.Mono {
|
||||||
|
st = Style{}
|
||||||
|
}
|
||||||
|
cv.Text(x+i, y, ch, 1, st)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleKeyName lets mouse actions reuse key handling (lock already held).
|
||||||
|
func (m *Model) handleKeyName(k string) tea.Cmd {
|
||||||
|
if handled, cmd := m.views[m.view].key(m, k); handled {
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
switch k {
|
||||||
|
case "enter":
|
||||||
|
return m.inspectSelection()
|
||||||
|
case "space":
|
||||||
|
return m.zoomIntoSelection()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func plural(n int, s string) string {
|
||||||
|
if n == 1 {
|
||||||
|
return "1 " + s
|
||||||
|
}
|
||||||
|
return textutil.Count(int64(n)) + " " + s + "s"
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/config"
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Theme holds every colour the UI uses. Nothing else in the TUI hard-codes
|
||||||
|
// colours.
|
||||||
|
type Theme struct {
|
||||||
|
Name string
|
||||||
|
Mono bool // no colour: rely on attributes and glyphs
|
||||||
|
ASCII bool // no box-drawing glyphs
|
||||||
|
|
||||||
|
Bg, Fg, Muted, Faint Color
|
||||||
|
Accent, Sel Color
|
||||||
|
HeaderBg, HeaderFg Color
|
||||||
|
StatusBg, StatusFg Color
|
||||||
|
PanelBg Color
|
||||||
|
Warn, Err, OK Color
|
||||||
|
Group Color
|
||||||
|
EmptyDir Color
|
||||||
|
Cat [inventory.NumCategories]Color
|
||||||
|
DiffGrow, DiffShrink Color
|
||||||
|
DiffNew, DiffSame Color
|
||||||
|
Ext map[string]Color
|
||||||
|
}
|
||||||
|
|
||||||
|
var themes = map[string]func() *Theme{
|
||||||
|
"default": defaultTheme,
|
||||||
|
"dark": darkTheme,
|
||||||
|
"high-contrast": highContrastTheme,
|
||||||
|
"mono": monoTheme,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThemeNames lists built-in themes.
|
||||||
|
func ThemeNames() []string {
|
||||||
|
var out []string
|
||||||
|
for k := range themes {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultTheme() *Theme {
|
||||||
|
return &Theme{
|
||||||
|
Name: "default",
|
||||||
|
Bg: RGB(0x16, 0x18, 0x1d), Fg: RGB(0xdc, 0xdf, 0xe4), Muted: RGB(0x8a, 0x91, 0x9c), Faint: RGB(0x4a, 0x50, 0x5a),
|
||||||
|
Accent: RGB(0x61, 0xaf, 0xef), Sel: RGB(0xff, 0xd8, 0x66),
|
||||||
|
HeaderBg: RGB(0x21, 0x25, 0x2b), HeaderFg: RGB(0xdc, 0xdf, 0xe4),
|
||||||
|
StatusBg: RGB(0x2c, 0x31, 0x3a), StatusFg: RGB(0xc8, 0xcc, 0xd4),
|
||||||
|
PanelBg: RGB(0x1c, 0x1f, 0x25),
|
||||||
|
Warn: RGB(0xe5, 0xc0, 0x7b), Err: RGB(0xe0, 0x6c, 0x75), OK: RGB(0x98, 0xc3, 0x79),
|
||||||
|
Group: RGB(0x5c, 0x63, 0x70), EmptyDir: RGB(0x4b, 0x52, 0x63),
|
||||||
|
Cat: [inventory.NumCategories]Color{
|
||||||
|
inventory.CatOther: RGB(0x7f, 0x8c, 0x9d),
|
||||||
|
inventory.CatImage: RGB(0xe5, 0xc0, 0x7b),
|
||||||
|
inventory.CatVideo: RGB(0xe0, 0x6c, 0x75),
|
||||||
|
inventory.CatAudio: RGB(0xd1, 0x9a, 0x66),
|
||||||
|
inventory.CatArchive: RGB(0xc6, 0x78, 0xdd),
|
||||||
|
inventory.CatDiskImage: RGB(0x8e, 0x7c, 0xf0),
|
||||||
|
inventory.CatDatabase: RGB(0x56, 0xb6, 0xc2),
|
||||||
|
inventory.CatCode: RGB(0x98, 0xc3, 0x79),
|
||||||
|
inventory.CatDocument: RGB(0x61, 0xaf, 0xef),
|
||||||
|
inventory.CatExecutable: RGB(0xbe, 0x50, 0x46),
|
||||||
|
inventory.CatLog: RGB(0xa3, 0xa8, 0x6a),
|
||||||
|
inventory.CatCache: RGB(0x6b, 0x77, 0x88),
|
||||||
|
},
|
||||||
|
DiffGrow: RGB(0xf0, 0x5a, 0x5a), DiffShrink: RGB(0x5a, 0xd0, 0x7a),
|
||||||
|
DiffNew: RGB(0x4f, 0xa8, 0xff), DiffSame: RGB(0x50, 0x56, 0x60),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func darkTheme() *Theme {
|
||||||
|
t := defaultTheme()
|
||||||
|
t.Name = "dark"
|
||||||
|
t.Bg, t.PanelBg = RGB(0, 0, 0), RGB(0x0c, 0x0c, 0x0e)
|
||||||
|
t.HeaderBg, t.StatusBg = RGB(0x12, 0x12, 0x16), RGB(0x1a, 0x1a, 0x20)
|
||||||
|
for i, c := range t.Cat {
|
||||||
|
t.Cat[i] = c.Mix(RGB(0, 0, 0), 0.2)
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func highContrastTheme() *Theme {
|
||||||
|
t := defaultTheme()
|
||||||
|
t.Name = "high-contrast"
|
||||||
|
t.Bg, t.Fg, t.Muted = RGB(0, 0, 0), RGB(0xff, 0xff, 0xff), RGB(0xd0, 0xd0, 0xd0)
|
||||||
|
t.HeaderBg, t.StatusBg, t.PanelBg = RGB(0, 0, 0), RGB(0x20, 0x20, 0x20), RGB(0, 0, 0)
|
||||||
|
t.Sel, t.Accent = RGB(0xff, 0xff, 0x00), RGB(0x00, 0xff, 0xff)
|
||||||
|
t.Cat = [inventory.NumCategories]Color{
|
||||||
|
RGB(0xb0, 0xb0, 0xb0), RGB(0xff, 0xd7, 0x00), RGB(0xff, 0x30, 0x30), RGB(0xff, 0x8c, 0x00),
|
||||||
|
RGB(0xff, 0x00, 0xff), RGB(0x9b, 0x7b, 0xff), RGB(0x00, 0xe5, 0xff), RGB(0x00, 0xff, 0x00),
|
||||||
|
RGB(0x40, 0x9c, 0xff), RGB(0xff, 0x55, 0x55), RGB(0xc8, 0xff, 0x00), RGB(0x90, 0x90, 0xa0),
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func monoTheme() *Theme {
|
||||||
|
return &Theme{Name: "mono", Mono: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadTheme returns a built-in theme with user overrides applied.
|
||||||
|
func LoadTheme(name string, s config.Settings) *Theme {
|
||||||
|
f, ok := themes[strings.ToLower(name)]
|
||||||
|
if !ok {
|
||||||
|
f = defaultTheme
|
||||||
|
}
|
||||||
|
t := f()
|
||||||
|
if t.Mono {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
for k, v := range s.CategoryColors {
|
||||||
|
if c, ok := inventory.ParseCategory(k); ok {
|
||||||
|
if col, ok := Hex(v); ok {
|
||||||
|
t.Cat[c] = col
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range s.ExtensionColors {
|
||||||
|
if col, ok := Hex(v); ok {
|
||||||
|
if t.Ext == nil {
|
||||||
|
t.Ext = map[string]Color{}
|
||||||
|
}
|
||||||
|
t.Ext[strings.ToLower(strings.TrimPrefix(k, "."))] = col
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles derived from the theme.
|
||||||
|
func (t *Theme) base() Style { return Style{FG: t.Fg, BG: t.Bg} }
|
||||||
|
func (t *Theme) muted() Style { return Style{FG: t.Muted, BG: t.Bg} }
|
||||||
|
func (t *Theme) header() Style { return Style{FG: t.HeaderFg, BG: t.HeaderBg} }
|
||||||
|
func (t *Theme) status() Style { return Style{FG: t.StatusFg, BG: t.StatusBg} }
|
||||||
|
func (t *Theme) panel() Style { return Style{FG: t.Fg, BG: t.PanelBg} }
|
||||||
|
|
||||||
|
func (t *Theme) box() BoxChars {
|
||||||
|
if t.ASCII {
|
||||||
|
return BoxASCII
|
||||||
|
}
|
||||||
|
return BoxLight
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Theme) selBox() BoxChars {
|
||||||
|
if t.ASCII {
|
||||||
|
return BoxASCIISel
|
||||||
|
}
|
||||||
|
return BoxDouble
|
||||||
|
}
|
||||||
|
|
||||||
|
// textOn picks a readable text colour for a background.
|
||||||
|
func (t *Theme) textOn(bg Color) Color {
|
||||||
|
if bg == 0 {
|
||||||
|
return t.Fg
|
||||||
|
}
|
||||||
|
if bg.Luma() > 0.55 {
|
||||||
|
return RGB(0x10, 0x12, 0x16)
|
||||||
|
}
|
||||||
|
return RGB(0xf2, 0xf4, 0xf7)
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
"github.com/charmbracelet/colorprofile"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/scan"
|
||||||
|
"github.com/andydixon/mapsize/internal/treemap"
|
||||||
|
)
|
||||||
|
|
||||||
|
const hostile = "evil\x1b]0;pwned\x07\x1b[2J.iso"
|
||||||
|
|
||||||
|
func testTree() *inventory.Tree {
|
||||||
|
t := inventory.New("/srv", inventory.KindDir)
|
||||||
|
add := func(p inventory.NodeID, name string, size int64) inventory.NodeID {
|
||||||
|
id := t.Add(p, name, inventory.KindFile)
|
||||||
|
n := t.Node(id)
|
||||||
|
n.Size, n.Alloc = size, size
|
||||||
|
n.Cat = inventory.CategoryFor(inventory.Ext(name), false, false)
|
||||||
|
n.MTime = 1_700_000_000_000_000_000
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
dir := func(p inventory.NodeID, name string) inventory.NodeID {
|
||||||
|
id := t.Add(p, name, inventory.KindDir)
|
||||||
|
t.Node(id).Flags |= inventory.FlagScanned
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
backups := dir(0, "backups")
|
||||||
|
for i := range 40 {
|
||||||
|
add(backups, fmt.Sprintf("dump-%02d.tar.gz", i), int64(1+i)<<30)
|
||||||
|
}
|
||||||
|
vms := dir(0, "vms")
|
||||||
|
add(vms, "ubuntu.qcow2", 86<<30)
|
||||||
|
add(vms, "win.vmdk", 40<<30)
|
||||||
|
media := dir(0, "media")
|
||||||
|
photos := dir(media, "photos 日本語")
|
||||||
|
for i := range 300 {
|
||||||
|
add(photos, fmt.Sprintf("img%03d.jpg", i), int64(i+1)<<20)
|
||||||
|
}
|
||||||
|
add(media, hostile, 9<<30)
|
||||||
|
add(media, "👨👩👧 family.mp4", 12<<30)
|
||||||
|
tiny := dir(0, "tiny")
|
||||||
|
for i := range 2000 {
|
||||||
|
add(tiny, fmt.Sprintf("f%d", i), 100)
|
||||||
|
}
|
||||||
|
add(0, "README", 10)
|
||||||
|
t.Recompute()
|
||||||
|
t.Stats.Complete = true
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestModel(t *testing.T) *Model {
|
||||||
|
t.Helper()
|
||||||
|
tr := testTree()
|
||||||
|
m := newModel(Options{Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, tr, nil }},
|
||||||
|
nil, tr, func() {})
|
||||||
|
m.profile = colorprofile.TrueColor
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(m *Model, msgs ...tea.Msg) {
|
||||||
|
for _, msg := range msgs {
|
||||||
|
m.Update(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func key(s string) tea.KeyPressMsg {
|
||||||
|
switch s {
|
||||||
|
case "up":
|
||||||
|
return tea.KeyPressMsg{Code: tea.KeyUp}
|
||||||
|
case "down":
|
||||||
|
return tea.KeyPressMsg{Code: tea.KeyDown}
|
||||||
|
case "left":
|
||||||
|
return tea.KeyPressMsg{Code: tea.KeyLeft}
|
||||||
|
case "right":
|
||||||
|
return tea.KeyPressMsg{Code: tea.KeyRight}
|
||||||
|
case "enter":
|
||||||
|
return tea.KeyPressMsg{Code: tea.KeyEnter}
|
||||||
|
case "esc":
|
||||||
|
return tea.KeyPressMsg{Code: tea.KeyEscape}
|
||||||
|
case "space":
|
||||||
|
return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}
|
||||||
|
case "backspace":
|
||||||
|
return tea.KeyPressMsg{Code: tea.KeyBackspace}
|
||||||
|
case "home":
|
||||||
|
return tea.KeyPressMsg{Code: tea.KeyHome}
|
||||||
|
case "tab":
|
||||||
|
return tea.KeyPressMsg{Code: tea.KeyTab}
|
||||||
|
}
|
||||||
|
r := []rune(s)[0]
|
||||||
|
return tea.KeyPressMsg{Code: r, Text: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
func size(w, h int) tea.WindowSizeMsg { return tea.WindowSizeMsg{Width: w, Height: h} }
|
||||||
|
|
||||||
|
// checkFrame verifies the rendered frame exactly fills w×h cells and that no
|
||||||
|
// filename control sequence leaked through.
|
||||||
|
func checkFrame(t *testing.T, m *Model, w, h int) string {
|
||||||
|
t.Helper()
|
||||||
|
content := m.View().Content
|
||||||
|
if strings.Contains(content, "\x1b]") || strings.Contains(content, "\x07") || strings.Contains(content, "\x1b[2J") {
|
||||||
|
t.Fatalf("terminal control sequence from a filename leaked into the frame")
|
||||||
|
}
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
if len(lines) != h {
|
||||||
|
t.Fatalf("%dx%d: frame has %d lines", w, h, len(lines))
|
||||||
|
}
|
||||||
|
for i, l := range lines {
|
||||||
|
if got := ansi.StringWidth(l); got != w {
|
||||||
|
t.Fatalf("%dx%d: line %d has width %d: %q", w, h, i, got, ansi.Strip(l))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ansi.Strip(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResizeTransitionsPreserveState(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
send(m, size(120, 40))
|
||||||
|
checkFrame(t, m, 120, 40)
|
||||||
|
// Zoom into media and select something that is not the default.
|
||||||
|
m.tree.RLock()
|
||||||
|
media := m.kidsOf(0)
|
||||||
|
m.tree.RUnlock()
|
||||||
|
var mediaID inventory.NodeID
|
||||||
|
for _, id := range media.ids {
|
||||||
|
if m.tree.Node(id).Name == "media" {
|
||||||
|
mediaID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.sel, m.userSel = int64(mediaID), true
|
||||||
|
send(m, key("space"))
|
||||||
|
if m.zoom != mediaID {
|
||||||
|
t.Fatalf("zoom = %d want media %d", m.zoom, mediaID)
|
||||||
|
}
|
||||||
|
checkFrame(t, m, 120, 40)
|
||||||
|
send(m, key("right"))
|
||||||
|
sel := m.sel
|
||||||
|
for _, sz := range [][2]int{{80, 24}, {120, 40}, {300, 80}, {70, 20}, {180, 50}, {40, 10}, {39, 9}, {200, 60}} {
|
||||||
|
send(m, size(sz[0], sz[1]))
|
||||||
|
checkFrame(t, m, sz[0], sz[1])
|
||||||
|
if m.zoom != mediaID {
|
||||||
|
t.Fatalf("%v: zoom lost", sz)
|
||||||
|
}
|
||||||
|
if m.sel != sel && !isGroup(m.sel) {
|
||||||
|
t.Fatalf("%v: selection changed from %d to %d", sz, sel, m.sel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The layout must be recomputed for the new geometry, not stretched.
|
||||||
|
if m.tm == nil || m.tm.rect.W > 200 || m.tm.rect.H > 60 {
|
||||||
|
t.Fatalf("layout not recomputed for final size: %+v", m.tm.rect)
|
||||||
|
}
|
||||||
|
for _, b := range m.tm.blocks {
|
||||||
|
r := m.tm.rect
|
||||||
|
if b.X < r.X || b.Y < r.Y || b.X+b.W > r.X+r.W || b.Y+b.H > r.Y+r.H {
|
||||||
|
t.Fatalf("block %+v outside treemap %+v", b, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResizeStorm(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
r := rand.New(rand.NewSource(1))
|
||||||
|
send(m, size(120, 40))
|
||||||
|
m.View()
|
||||||
|
send(m, key("right"), key("down"))
|
||||||
|
sel := m.sel
|
||||||
|
for i := 0; i < 300; i++ {
|
||||||
|
send(m, size(20+r.Intn(300), 5+r.Intn(90)))
|
||||||
|
if i%7 == 0 {
|
||||||
|
m.View() // renders interleave with resizes like the real event loop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send(m, size(150, 45))
|
||||||
|
checkFrame(t, m, 150, 45)
|
||||||
|
if m.sel != sel {
|
||||||
|
t.Fatalf("selection lost in resize storm")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModalSurvivesResize(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
send(m, size(120, 40), key("enter"))
|
||||||
|
if len(m.modals) != 1 {
|
||||||
|
t.Fatal("Enter should open the info modal")
|
||||||
|
}
|
||||||
|
for _, sz := range [][2]int{{120, 40}, {60, 14}, {41, 10}, {300, 80}, {45, 12}} {
|
||||||
|
send(m, size(sz[0], sz[1]))
|
||||||
|
out := checkFrame(t, m, sz[0], sz[1])
|
||||||
|
if !strings.Contains(out, "ITEM INFORMATION") {
|
||||||
|
t.Fatalf("%v: modal title missing:\n%s", sz, out)
|
||||||
|
}
|
||||||
|
f := m.modals[0].(*modalFrame)
|
||||||
|
if f.viewH < 1 || f.viewH > sz[1] {
|
||||||
|
t.Fatalf("%v: modal viewport %d", sz, f.viewH)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Scrolling in a tiny terminal must stay in range.
|
||||||
|
send(m, size(45, 12))
|
||||||
|
for range 100 {
|
||||||
|
send(m, key("down"))
|
||||||
|
}
|
||||||
|
checkFrame(t, m, 45, 12)
|
||||||
|
f := m.modals[0].(*modalFrame)
|
||||||
|
if f.scroll < 0 || f.scroll > f.contentN {
|
||||||
|
t.Fatalf("scroll out of range: %d of %d", f.scroll, f.contentN)
|
||||||
|
}
|
||||||
|
send(m, key("esc"))
|
||||||
|
if len(m.modals) != 0 {
|
||||||
|
t.Fatal("Esc should close the modal")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpatialNavigationUsesGeometry(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
send(m, size(160, 45))
|
||||||
|
m.View()
|
||||||
|
blocks := m.tm.blocks
|
||||||
|
for _, dir := range []string{"right", "down", "left", "up"} {
|
||||||
|
before := m.sel
|
||||||
|
send(m, key(dir))
|
||||||
|
m.View()
|
||||||
|
if m.sel == before {
|
||||||
|
continue // edge of the map
|
||||||
|
}
|
||||||
|
var a, b = findBlock(blocks, before), findBlock(blocks, m.sel)
|
||||||
|
switch dir {
|
||||||
|
case "right":
|
||||||
|
if b.X < a.X+a.W {
|
||||||
|
t.Fatalf("right moved to a block that is not to the right: %+v -> %+v", a, b)
|
||||||
|
}
|
||||||
|
case "left":
|
||||||
|
if b.X+b.W > a.X {
|
||||||
|
t.Fatalf("left moved wrongly: %+v -> %+v", a, b)
|
||||||
|
}
|
||||||
|
case "down":
|
||||||
|
if b.Y < a.Y+a.H {
|
||||||
|
t.Fatalf("down moved wrongly: %+v -> %+v", a, b)
|
||||||
|
}
|
||||||
|
case "up":
|
||||||
|
if b.Y+b.H > a.Y {
|
||||||
|
t.Fatalf("up moved wrongly: %+v -> %+v", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findBlock(bs []treemap.Block, id int64) treemap.Block {
|
||||||
|
for _, b := range bs {
|
||||||
|
if b.ID == id {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return treemap.Block{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZoomBreadcrumbsAndHome(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
send(m, size(100, 30))
|
||||||
|
m.tree.RLock()
|
||||||
|
var media, photos inventory.NodeID
|
||||||
|
m.tree.Walk(0, func(id inventory.NodeID, n *inventory.Node) bool {
|
||||||
|
switch n.Name {
|
||||||
|
case "media":
|
||||||
|
media = id
|
||||||
|
case "photos 日本語":
|
||||||
|
photos = id
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
m.tree.RUnlock()
|
||||||
|
m.sel, m.userSel = int64(media), true
|
||||||
|
send(m, key("space"))
|
||||||
|
m.sel = int64(photos)
|
||||||
|
send(m, key("space"))
|
||||||
|
if m.zoom != photos {
|
||||||
|
t.Fatal("did not zoom into photos")
|
||||||
|
}
|
||||||
|
out := checkFrame(t, m, 100, 30)
|
||||||
|
if !strings.Contains(out, "media › photos 日本語") {
|
||||||
|
t.Fatalf("breadcrumbs missing:\n%s", out)
|
||||||
|
}
|
||||||
|
// Narrow terminal: breadcrumbs truncate from the left, keeping the tail.
|
||||||
|
send(m, size(42, 12))
|
||||||
|
out = checkFrame(t, m, 42, 12)
|
||||||
|
if !strings.Contains(out, "photos") {
|
||||||
|
t.Fatalf("breadcrumb tail missing:\n%s", out)
|
||||||
|
}
|
||||||
|
send(m, key("backspace"))
|
||||||
|
if m.zoom != media || m.sel != int64(photos) {
|
||||||
|
t.Fatalf("backspace: zoom %d sel %d", m.zoom, m.sel)
|
||||||
|
}
|
||||||
|
send(m, key("home"))
|
||||||
|
if m.zoom != 0 {
|
||||||
|
t.Fatal("home did not return to root")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterTransformsMap(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
send(m, size(120, 40), key("/"))
|
||||||
|
for _, r := range "ext = gz" {
|
||||||
|
send(m, tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||||
|
}
|
||||||
|
if m.search.err != "" || m.search.query == nil {
|
||||||
|
t.Fatalf("query not parsed: %q", m.search.err)
|
||||||
|
}
|
||||||
|
msg := m.applyFilter()()
|
||||||
|
send(m, msg, key("enter"))
|
||||||
|
if m.search.result == nil {
|
||||||
|
t.Fatal("no filter result")
|
||||||
|
}
|
||||||
|
out := checkFrame(t, m, 120, 40)
|
||||||
|
if strings.Contains(out, "vms/") || !strings.Contains(out, "backups/") {
|
||||||
|
t.Fatalf("filter not applied to the map:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "FILTER") {
|
||||||
|
t.Fatal("filter indicator missing")
|
||||||
|
}
|
||||||
|
send(m, key("esc"))
|
||||||
|
if m.search.query != nil || m.search.result != nil {
|
||||||
|
t.Fatal("Esc should clear the filter")
|
||||||
|
}
|
||||||
|
out = checkFrame(t, m, 120, 40)
|
||||||
|
if !strings.Contains(out, "vms/") {
|
||||||
|
t.Fatalf("map not restored after clearing filter:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostileNameSanitisedInModal(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
send(m, size(120, 40))
|
||||||
|
var id inventory.NodeID
|
||||||
|
m.tree.Walk(0, func(i inventory.NodeID, n *inventory.Node) bool {
|
||||||
|
if n.Name == hostile {
|
||||||
|
id = i
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
m.openModal(newInfoModal(m, id))
|
||||||
|
out := checkFrame(t, m, 120, 40)
|
||||||
|
if !strings.Contains(out, `\x1b]0;pwned\x07`) {
|
||||||
|
t.Fatalf("escaped name not shown:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewsRenderAtManySizes(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
for range m.views {
|
||||||
|
for _, sz := range [][2]int{{40, 10}, {80, 24}, {150, 45}, {91, 22}} {
|
||||||
|
send(m, size(sz[0], sz[1]))
|
||||||
|
checkFrame(t, m, sz[0], sz[1])
|
||||||
|
send(m, key("down"), key("down"))
|
||||||
|
checkFrame(t, m, sz[0], sz[1])
|
||||||
|
}
|
||||||
|
send(m, key("tab"))
|
||||||
|
}
|
||||||
|
send(m, key("?"))
|
||||||
|
send(m, size(60, 15))
|
||||||
|
checkFrame(t, m, 60, 15)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLevelOfDetailGroupsTinyFiles(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
send(m, size(100, 30))
|
||||||
|
m.tree.RLock()
|
||||||
|
var tiny inventory.NodeID
|
||||||
|
m.tree.Children(0, func(id inventory.NodeID, n *inventory.Node) {
|
||||||
|
if n.Name == "tiny" {
|
||||||
|
tiny = id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
m.tree.RUnlock()
|
||||||
|
m.zoomTo(tiny)
|
||||||
|
m.View()
|
||||||
|
if len(m.tm.blocks) > 100*30/topMinArea+1 {
|
||||||
|
t.Fatalf("%d blocks for 2000 equal files: LOD not applied", len(m.tm.blocks))
|
||||||
|
}
|
||||||
|
if _, ok := m.tm.groups[groupID(tiny)]; !ok {
|
||||||
|
t.Fatal("expected a group block for small items")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCtrlCQuitsWhenIdle(t *testing.T) {
|
||||||
|
m := newTestModel(t)
|
||||||
|
send(m, size(80, 24))
|
||||||
|
_, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
|
||||||
|
if cmd == nil || !m.quit {
|
||||||
|
t.Fatal("Ctrl+C should quit when no scan runs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkRenderFrame(b *testing.B) {
|
||||||
|
tr := testTree()
|
||||||
|
m := newModel(Options{}, nil, tr, func() {})
|
||||||
|
m.profile = colorprofile.TrueColor
|
||||||
|
send(m, size(200, 60))
|
||||||
|
for b.Loop() {
|
||||||
|
m.dirty = true
|
||||||
|
m.View()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkResizeRelayout(b *testing.B) {
|
||||||
|
tr := testTree()
|
||||||
|
m := newModel(Options{}, nil, tr, func() {})
|
||||||
|
i := 0
|
||||||
|
for b.Loop() {
|
||||||
|
send(m, size(100+i%100, 30+i%30))
|
||||||
|
m.View()
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"github.com/andydixon/mapsize/internal/inventory"
|
||||||
|
"github.com/andydixon/mapsize/internal/snapshot"
|
||||||
|
"github.com/andydixon/mapsize/internal/textutil"
|
||||||
|
"github.com/andydixon/mapsize/internal/treemap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Directory list -------------------------------------------------------
|
||||||
|
|
||||||
|
type listView struct {
|
||||||
|
tb table
|
||||||
|
lastSel int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*listView) name() string { return "List" }
|
||||||
|
|
||||||
|
// sync reconciles the table cursor with the shared selection: if the
|
||||||
|
// selection changed elsewhere (treemap, jump) the cursor follows it;
|
||||||
|
// otherwise the cursor (moved by keys or clicks) drives the selection.
|
||||||
|
func (v *listView) sync(m *Model) {
|
||||||
|
k := m.kidsOf(m.zoom)
|
||||||
|
if m.sel != v.lastSel {
|
||||||
|
for i, id := range k.ids {
|
||||||
|
if int64(id) == m.sel {
|
||||||
|
v.tb.cursor = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if v.tb.cursor < len(k.ids) {
|
||||||
|
m.sel = int64(k.ids[v.tb.cursor])
|
||||||
|
}
|
||||||
|
v.lastSel = m.sel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *listView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
||||||
|
t := m.theme
|
||||||
|
k := m.kidsOf(m.zoom)
|
||||||
|
v.sync(m)
|
||||||
|
total := m.sizeOf(m.zoom)
|
||||||
|
rows := make([]tableRow, len(k.ids))
|
||||||
|
for i, id := range k.ids {
|
||||||
|
n := m.tree.Node(id)
|
||||||
|
name := textutil.Sanitize(n.Name)
|
||||||
|
files := ""
|
||||||
|
if n.IsDir() {
|
||||||
|
name += "/"
|
||||||
|
files = textutil.Count(int64(n.Files))
|
||||||
|
}
|
||||||
|
mod := ""
|
||||||
|
if n.MTime > 0 {
|
||||||
|
mod = time.Unix(0, n.MTime).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
col := m.colorOf(int64(id))
|
||||||
|
nameSt := &Style{FG: col.Mix(t.Fg, 0.45), BG: t.Bg}
|
||||||
|
if n.IsDir() {
|
||||||
|
nameSt.Attr = Bold
|
||||||
|
}
|
||||||
|
frac := 0.0
|
||||||
|
if k.sizes[0] > 0 {
|
||||||
|
frac = float64(k.sizes[i]) / float64(k.sizes[0])
|
||||||
|
}
|
||||||
|
rows[i] = tableRow{
|
||||||
|
cells: []string{textutil.Size(k.sizes[i]), textutil.Percent(k.sizes[i], total), "", files, mod, name},
|
||||||
|
styles: []*Style{nil, {FG: t.Muted, BG: t.Bg}, nil, {FG: t.Muted, BG: t.Bg}, {FG: t.Muted, BG: t.Bg}, nameSt},
|
||||||
|
bar: frac, barCol: col,
|
||||||
|
}
|
||||||
|
if m.opts.Diff != nil {
|
||||||
|
rows[i].cells[4] = textutil.SignedSize(m.opts.Diff.Delta(id, m.sizeMode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cols := []column{{"SIZE", 10, true}, {"%", 5, true}, {"", 12, false}, {"FILES", 10, true}, {"MODIFIED", 10, false}, {"NAME", 0, false}}
|
||||||
|
if m.opts.Diff != nil {
|
||||||
|
cols[4] = column{"CHANGE", 12, true}
|
||||||
|
}
|
||||||
|
if r.W < 70 {
|
||||||
|
cols = []column{{"SIZE", 10, true}, {"%", 5, true}, {"NAME", 0, false}}
|
||||||
|
for i := range rows {
|
||||||
|
rows[i].cells = []string{rows[i].cells[0], rows[i].cells[1], rows[i].cells[5]}
|
||||||
|
rows[i].styles = []*Style{nil, rows[i].styles[1], rows[i].styles[5]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.tb.paint(m, cv, r, cols, rows, "")
|
||||||
|
v.sync(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *listView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||||
|
v.sync(m)
|
||||||
|
defer v.sync(m)
|
||||||
|
return v.tb.key(k, len(m.kidsOf(m.zoom).ids)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *listView) wheel(m *Model, up bool) tea.Cmd {
|
||||||
|
v.sync(m)
|
||||||
|
v.tb.move(map[bool]int{true: -3, false: 3}[up], len(m.kidsOf(m.zoom).ids))
|
||||||
|
v.sync(m)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Extension statistics ------------------------------------------------
|
||||||
|
|
||||||
|
type extView struct {
|
||||||
|
tb table
|
||||||
|
cache []inventory.ExtStat
|
||||||
|
key_ [2]int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*extView) name() string { return "Types" }
|
||||||
|
|
||||||
|
func (v *extView) stats(m *Model) []inventory.ExtStat {
|
||||||
|
k := [2]int{int(m.zoom), m.dataVer*2 + int(m.sizeMode)}
|
||||||
|
if v.cache == nil || v.key_ != k {
|
||||||
|
v.cache = m.tree.ExtStats(m.zoom)
|
||||||
|
sort.Slice(v.cache, func(i, j int) bool {
|
||||||
|
a, b := v.cache[i], v.cache[j]
|
||||||
|
as, bs := a.Alloc, b.Alloc
|
||||||
|
if m.sizeMode == inventory.SizeLogical {
|
||||||
|
as, bs = a.Size, b.Size
|
||||||
|
}
|
||||||
|
if as != bs {
|
||||||
|
return as > bs
|
||||||
|
}
|
||||||
|
return a.Ext < b.Ext
|
||||||
|
})
|
||||||
|
v.key_ = k
|
||||||
|
}
|
||||||
|
return v.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *extView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
||||||
|
t := m.theme
|
||||||
|
st := v.stats(m)
|
||||||
|
var total int64
|
||||||
|
for _, s := range st {
|
||||||
|
total += pick(m, s)
|
||||||
|
}
|
||||||
|
rows := make([]tableRow, len(st))
|
||||||
|
for i, s := range st {
|
||||||
|
ext := "." + s.Ext
|
||||||
|
if s.Ext == "" {
|
||||||
|
ext = "(none)"
|
||||||
|
}
|
||||||
|
col := t.Cat[s.Cat]
|
||||||
|
if c, ok := t.Ext[s.Ext]; ok {
|
||||||
|
col = c
|
||||||
|
}
|
||||||
|
frac := 0.0
|
||||||
|
if v0 := pick(m, st[0]); v0 > 0 {
|
||||||
|
frac = float64(pick(m, s)) / float64(v0)
|
||||||
|
}
|
||||||
|
rows[i] = tableRow{
|
||||||
|
cells: []string{textutil.Sanitize(ext), s.Cat.String(), textutil.Count(s.Count), textutil.Size(pick(m, s)), textutil.Percent(pick(m, s), total), ""},
|
||||||
|
styles: []*Style{{FG: col, BG: t.Bg, Attr: Bold}, {FG: t.Muted, BG: t.Bg}, nil, nil, {FG: t.Muted, BG: t.Bg}, nil},
|
||||||
|
bar: frac, barCol: col,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cols := []column{{"EXTENSION", 14, false}, {"CATEGORY", 12, false}, {"FILES", 11, true}, {"SIZE", 10, true}, {"%", 5, true}, {"", 0, false}}
|
||||||
|
v.tb.paint(m, cv, r, cols, rows, fmt.Sprintf("File types under %s — Enter filters the map by extension", textutil.Sanitize(m.tree.Node(m.zoom).Name)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func pick(m *Model, s inventory.ExtStat) int64 {
|
||||||
|
if m.sizeMode == inventory.SizeLogical {
|
||||||
|
return s.Size
|
||||||
|
}
|
||||||
|
return s.Alloc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *extView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||||
|
st := v.stats(m)
|
||||||
|
if v.tb.key(k, len(st)) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if (k == "enter" || k == "space") && v.tb.cursor < len(st) {
|
||||||
|
e := st[v.tb.cursor].Ext
|
||||||
|
q := "ext = \"" + e + "\""
|
||||||
|
if e == "" {
|
||||||
|
q = "type = file AND ext = \"\""
|
||||||
|
}
|
||||||
|
m.setViewByName("Map")
|
||||||
|
return true, m.setFilter(q)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *extView) wheel(m *Model, up bool) tea.Cmd {
|
||||||
|
v.tb.move(map[bool]int{true: -3, false: 3}[up], len(v.stats(m)))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Top-N investigation views --------------------------------------------
|
||||||
|
|
||||||
|
type topMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
topFiles topMode = iota
|
||||||
|
topDirs
|
||||||
|
topOld
|
||||||
|
topNew
|
||||||
|
topCount
|
||||||
|
topSparse
|
||||||
|
topHardlinks
|
||||||
|
numTopModes
|
||||||
|
)
|
||||||
|
|
||||||
|
var topNames = [...]string{"Largest files", "Largest directories (own files)", "Oldest large files",
|
||||||
|
"Newest large files", "Most files", "Sparse files", "Hard-linked files"}
|
||||||
|
|
||||||
|
type topView struct {
|
||||||
|
tb table
|
||||||
|
mode topMode
|
||||||
|
ids []inventory.NodeID
|
||||||
|
vals []int64
|
||||||
|
key_ [3]int
|
||||||
|
limit int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*topView) name() string { return "Top" }
|
||||||
|
|
||||||
|
const topLimit = 500
|
||||||
|
|
||||||
|
func (v *topView) compute(m *Model) {
|
||||||
|
k := [3]int{int(m.zoom), m.dataVer*2 + int(m.sizeMode), int(v.mode)}
|
||||||
|
if v.ids != nil && v.key_ == k {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v.key_ = k
|
||||||
|
tr := m.tree
|
||||||
|
isFile := func(n *inventory.Node) bool {
|
||||||
|
return n.Kind == inventory.KindFile && n.Flags&(inventory.FlagHardlinkDup|inventory.FlagDeleted) == 0
|
||||||
|
}
|
||||||
|
matches := func(id inventory.NodeID) bool {
|
||||||
|
return m.search.result == nil || m.search.result.Size(id) > 0
|
||||||
|
}
|
||||||
|
tk := inventory.NewTopK(topLimit)
|
||||||
|
val := map[inventory.NodeID]int64{}
|
||||||
|
const largeFile = 1 << 20
|
||||||
|
switch v.mode {
|
||||||
|
case topDirs:
|
||||||
|
own := map[inventory.NodeID]int64{}
|
||||||
|
tr.Walk(m.zoom, func(id inventory.NodeID, n *inventory.Node) bool {
|
||||||
|
if isFile(n) && matches(id) {
|
||||||
|
own[n.Parent] += n.Own(m.sizeMode)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
for id, s := range own {
|
||||||
|
tk.Offer(id, s)
|
||||||
|
val[id] = s
|
||||||
|
}
|
||||||
|
case topCount:
|
||||||
|
tr.Walk(m.zoom, func(id inventory.NodeID, n *inventory.Node) bool {
|
||||||
|
if n.IsDir() && matches(id) {
|
||||||
|
c := int64(tr.ChildCount(id))
|
||||||
|
tk.Offer(id, c)
|
||||||
|
val[id] = c
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
tr.Walk(m.zoom, func(id inventory.NodeID, n *inventory.Node) bool {
|
||||||
|
if !isFile(n) || !matches(id) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
sz := n.Own(m.sizeMode)
|
||||||
|
switch v.mode {
|
||||||
|
case topFiles:
|
||||||
|
tk.Offer(id, sz)
|
||||||
|
val[id] = sz
|
||||||
|
case topOld, topNew:
|
||||||
|
if sz >= largeFile && n.MTime > 0 {
|
||||||
|
key := n.MTime
|
||||||
|
if v.mode == topOld {
|
||||||
|
key = -key
|
||||||
|
}
|
||||||
|
tk.Offer(id, key)
|
||||||
|
val[id] = sz
|
||||||
|
}
|
||||||
|
case topSparse:
|
||||||
|
if n.Flags&inventory.FlagSparse != 0 {
|
||||||
|
tk.Offer(id, n.Size-n.Alloc)
|
||||||
|
val[id] = n.Size - n.Alloc
|
||||||
|
}
|
||||||
|
case topHardlinks:
|
||||||
|
if n.Flags&inventory.FlagHardlinked != 0 {
|
||||||
|
tk.Offer(id, n.Size)
|
||||||
|
val[id] = n.Size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
v.ids = tk.Sorted()
|
||||||
|
v.vals = v.vals[:0]
|
||||||
|
for _, id := range v.ids {
|
||||||
|
v.vals = append(v.vals, val[id])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *topView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
||||||
|
t := m.theme
|
||||||
|
v.compute(m)
|
||||||
|
rows := make([]tableRow, len(v.ids))
|
||||||
|
var first int64
|
||||||
|
if len(v.vals) > 0 {
|
||||||
|
first = v.vals[0]
|
||||||
|
}
|
||||||
|
for i, id := range v.ids {
|
||||||
|
n := m.tree.Node(id)
|
||||||
|
val := textutil.Size(v.vals[i])
|
||||||
|
if v.mode == topCount {
|
||||||
|
val = textutil.Count(v.vals[i])
|
||||||
|
}
|
||||||
|
mod := ""
|
||||||
|
if n.MTime > 0 {
|
||||||
|
mod = time.Unix(0, n.MTime).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
frac := 0.0
|
||||||
|
if first > 0 && v.mode != topOld && v.mode != topNew {
|
||||||
|
frac = float64(v.vals[i]) / float64(first)
|
||||||
|
}
|
||||||
|
col := m.colorOf(int64(id))
|
||||||
|
rows[i] = tableRow{
|
||||||
|
cells: []string{val, mod, "", textutil.Sanitize(m.tree.Path(id))},
|
||||||
|
styles: []*Style{nil, {FG: t.Muted, BG: t.Bg}, nil, {FG: col.Mix(t.Fg, 0.45), BG: t.Bg}},
|
||||||
|
bar: frac, barCol: col,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title := fmt.Sprintf("◂ %s ▸ (←/→ change list · Enter details · Space jump to it)", topNames[v.mode])
|
||||||
|
if m.search.result != nil {
|
||||||
|
title += " · filtered"
|
||||||
|
}
|
||||||
|
valTitle := "SIZE"
|
||||||
|
switch v.mode {
|
||||||
|
case topCount:
|
||||||
|
valTitle = "ENTRIES"
|
||||||
|
case topSparse:
|
||||||
|
valTitle = "SAVED"
|
||||||
|
}
|
||||||
|
cols := []column{{valTitle, 10, true}, {"MODIFIED", 10, false}, {"", 10, false}, {"PATH", 0, false}}
|
||||||
|
v.tb.paint(m, cv, r, cols, rows, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *topView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||||
|
v.compute(m)
|
||||||
|
switch k {
|
||||||
|
case "left", "h":
|
||||||
|
v.mode = (v.mode + numTopModes - 1) % numTopModes
|
||||||
|
v.tb.cursor = 0
|
||||||
|
return true, nil
|
||||||
|
case "right", "l":
|
||||||
|
v.mode = (v.mode + 1) % numTopModes
|
||||||
|
v.tb.cursor = 0
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if v.tb.key(k, len(v.ids)) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if v.tb.cursor < len(v.ids) {
|
||||||
|
id := v.ids[v.tb.cursor]
|
||||||
|
switch k {
|
||||||
|
case "enter":
|
||||||
|
m.openModal(newInfoModal(m, id))
|
||||||
|
return true, nil
|
||||||
|
case "space":
|
||||||
|
m.jumpTo(id)
|
||||||
|
return true, nil
|
||||||
|
case "c", "o", "d":
|
||||||
|
m.sel = int64(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *topView) wheel(m *Model, up bool) tea.Cmd {
|
||||||
|
v.tb.move(map[bool]int{true: -3, false: 3}[up], len(v.ids))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Snapshot comparison -----------------------------------------------------
|
||||||
|
|
||||||
|
type changesView struct {
|
||||||
|
tb table
|
||||||
|
rows []snapshot.Change
|
||||||
|
key_ int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*changesView) name() string { return "Changes" }
|
||||||
|
|
||||||
|
func (v *changesView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
||||||
|
t := m.theme
|
||||||
|
d := m.opts.Diff
|
||||||
|
if v.rows == nil || v.key_ != int(m.sizeMode) {
|
||||||
|
v.rows = d.Changes(1000, m.sizeMode)
|
||||||
|
v.key_ = int(m.sizeMode)
|
||||||
|
}
|
||||||
|
var maxAbs int64 = 1
|
||||||
|
for _, c := range v.rows {
|
||||||
|
maxAbs = max(maxAbs, abs64(c.Delta))
|
||||||
|
}
|
||||||
|
rows := make([]tableRow, len(v.rows))
|
||||||
|
for i, c := range v.rows {
|
||||||
|
col := map[snapshot.Status]Color{snapshot.Added: t.DiffNew, snapshot.Removed: t.Muted,
|
||||||
|
snapshot.Grew: t.DiffGrow, snapshot.Shrank: t.DiffShrink}[c.Status]
|
||||||
|
rows[i] = tableRow{
|
||||||
|
cells: []string{textutil.SignedSize(c.Delta), c.Status.String(), textutil.Size(c.Old), textutil.Size(c.New), "", textutil.Sanitize(c.Path)},
|
||||||
|
styles: []*Style{{FG: col, BG: t.Bg, Attr: Bold}, {FG: col, BG: t.Bg}, {FG: t.Muted, BG: t.Bg}, nil, nil, nil},
|
||||||
|
bar: float64(abs64(c.Delta)) / float64(maxAbs), barCol: col,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cols := []column{{"CHANGE", 13, true}, {"STATUS", 9, false}, {"OLD", 10, true}, {"NEW", 10, true}, {"", 10, false}, {"PATH", 0, false}}
|
||||||
|
ro, rn := d.Old.Node(0).Total(m.sizeMode), d.New.Node(0).Total(m.sizeMode)
|
||||||
|
title := fmt.Sprintf("Changes %s → %s (%s)", textutil.Size(ro), textutil.Size(rn), textutil.SignedSize(rn-ro))
|
||||||
|
v.tb.paint(m, cv, r, cols, rows, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs64(v int64) int64 {
|
||||||
|
if v < 0 {
|
||||||
|
return -v
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *changesView) key(m *Model, k string) (bool, tea.Cmd) {
|
||||||
|
if v.tb.key(k, len(v.rows)) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if v.tb.cursor < len(v.rows) {
|
||||||
|
c := v.rows[v.tb.cursor]
|
||||||
|
if c.Node == inventory.NoNode {
|
||||||
|
if k == "enter" || k == "space" {
|
||||||
|
return true, m.info("Removed since the old snapshot: " + textutil.Sanitize(c.Path))
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
switch k {
|
||||||
|
case "enter":
|
||||||
|
m.openModal(newInfoModal(m, c.Node))
|
||||||
|
return true, nil
|
||||||
|
case "space":
|
||||||
|
m.jumpTo(c.Node)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *changesView) wheel(m *Model, up bool) tea.Cmd {
|
||||||
|
v.tb.move(map[bool]int{true: -3, false: 3}[up], len(v.rows))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user