From df8b10c6ddfeb93502d3e204946ec5cb0e38b0a4 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Fri, 2 Oct 2026 08:25:35 +0100 Subject: [PATCH] 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. --- cmd/mapsize/main.go | 3 +- go.mod | 17 ++ go.sum | 28 ++ internal/app/app.go | 1 + internal/app/modes.go | 11 +- internal/inventory/tree.go | 1 + internal/platform/ops.go | 32 +++ internal/platform/ops_darwin.go | 72 +++++ internal/platform/ops_linux.go | 113 ++++++++ internal/platform/ops_other.go | 14 + internal/platform/ops_windows.go | 71 +++++ internal/scan/panic.go | 14 + internal/scan/scan.go | 2 + internal/textutil/text.go | 77 ++++-- internal/textutil/text_test.go | 10 + internal/tui/actions.go | 250 +++++++++++++++++ internal/tui/canvas.go | 303 ++++++++++++++++++++ internal/tui/color256.go | 42 +++ internal/tui/data.go | 199 ++++++++++++++ internal/tui/infoview.go | 159 +++++++++++ internal/tui/keys.go | 247 +++++++++++++++++ internal/tui/live_test.go | 71 +++++ internal/tui/mapview.go | 354 ++++++++++++++++++++++++ internal/tui/modal.go | 221 +++++++++++++++ internal/tui/modals.go | 373 +++++++++++++++++++++++++ internal/tui/model.go | 424 ++++++++++++++++++++++++++++ internal/tui/mouse.go | 110 ++++++++ internal/tui/render.go | 408 +++++++++++++++++++++++++++ internal/tui/run.go | 59 ++++ internal/tui/search.go | 267 ++++++++++++++++++ internal/tui/side.go | 118 ++++++++ internal/tui/table.go | 207 ++++++++++++++ internal/tui/theme.go | 165 +++++++++++ internal/tui/tui_test.go | 420 ++++++++++++++++++++++++++++ internal/tui/views.go | 456 +++++++++++++++++++++++++++++++ 35 files changed, 5294 insertions(+), 25 deletions(-) create mode 100644 internal/platform/ops.go create mode 100644 internal/platform/ops_darwin.go create mode 100644 internal/platform/ops_linux.go create mode 100644 internal/platform/ops_other.go create mode 100644 internal/platform/ops_windows.go create mode 100644 internal/scan/panic.go create mode 100644 internal/tui/actions.go create mode 100644 internal/tui/canvas.go create mode 100644 internal/tui/color256.go create mode 100644 internal/tui/data.go create mode 100644 internal/tui/infoview.go create mode 100644 internal/tui/keys.go create mode 100644 internal/tui/live_test.go create mode 100644 internal/tui/mapview.go create mode 100644 internal/tui/modal.go create mode 100644 internal/tui/modals.go create mode 100644 internal/tui/model.go create mode 100644 internal/tui/mouse.go create mode 100644 internal/tui/render.go create mode 100644 internal/tui/run.go create mode 100644 internal/tui/search.go create mode 100644 internal/tui/side.go create mode 100644 internal/tui/table.go create mode 100644 internal/tui/theme.go create mode 100644 internal/tui/tui_test.go create mode 100644 internal/tui/views.go diff --git a/cmd/mapsize/main.go b/cmd/mapsize/main.go index c931aac..fef72ec 100644 --- a/cmd/mapsize/main.go +++ b/cmd/mapsize/main.go @@ -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)") 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") + colorMode = fs.String("color", "auto", "colour support: auto, truecolor, 256, 16, none") noMouse = fs.Bool("no-mouse", !settings.Mouse, "disable mouse support") logFile = fs.String("log", "", "write a debug log to `FILE`") 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{ 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, } if *logical { diff --git a/go.mod b/go.mod index 196a898..c788942 100644 --- a/go.mod +++ b/go.mod @@ -6,3 +6,20 @@ require ( github.com/rivo/uniseg v0.4.7 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 +) diff --git a/go.sum b/go.sum index 6820e69..1b9a097 100644 --- a/go.sum +++ b/go.sum @@ -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/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/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= diff --git a/internal/app/app.go b/internal/app/app.go index 68522cc..5c1180e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -36,6 +36,7 @@ type Config struct { Theme string SizeMode inventory.SizeMode Mouse bool + Color string Settings config.Settings Stdout io.Writer Stderr io.Writer diff --git a/internal/app/modes.go b/internal/app/modes.go index 2a1fd06..936ec9e 100644 --- a/internal/app/modes.go +++ b/internal/app/modes.go @@ -2,17 +2,22 @@ package app import ( "context" - "errors" "fmt" "github.com/andydixon/mapsize/internal/duplicate" "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/tui" ) -func runTUI(*Config) error { - return errors.New("the interactive UI is not built yet; use --no-ui, --top, --json or --csv") +func runTUI(cfg *Config) error { + 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) } diff --git a/internal/inventory/tree.go b/internal/inventory/tree.go index 3f2a71d..c38d9f6 100644 --- a/internal/inventory/tree.go +++ b/internal/inventory/tree.go @@ -51,6 +51,7 @@ const ( FlagAllocUnknown // platform cannot report allocated size FlagBrokenLink // symlink target does not exist 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 diff --git a/internal/platform/ops.go b/internal/platform/ops.go new file mode 100644 index 0000000..1c8da8c --- /dev/null +++ b/internal/platform/ops.go @@ -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 +} diff --git a/internal/platform/ops_darwin.go b/internal/platform/ops_darwin.go new file mode 100644 index 0000000..6e4a481 --- /dev/null +++ b/internal/platform/ops_darwin.go @@ -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/ 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 "" +} diff --git a/internal/platform/ops_linux.go b/internal/platform/ops_linux.go new file mode 100644 index 0000000..bca1bab --- /dev/null +++ b/internal/platform/ops_linux.go @@ -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 + } +} diff --git a/internal/platform/ops_other.go b/internal/platform/ops_other.go new file mode 100644 index 0000000..9794878 --- /dev/null +++ b/internal/platform/ops_other.go @@ -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") } diff --git a/internal/platform/ops_windows.go b/internal/platform/ops_windows.go new file mode 100644 index 0000000..028677d --- /dev/null +++ b/internal/platform/ops_windows.go @@ -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 +} diff --git a/internal/scan/panic.go b/internal/scan/panic.go new file mode 100644 index 0000000..f1e4796 --- /dev/null +++ b/internal/scan/panic.go @@ -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()) + } +} diff --git a/internal/scan/scan.go b/internal/scan/scan.go index 2a2de94..ba640b4 100644 --- a/internal/scan/scan.go +++ b/internal/scan/scan.go @@ -203,6 +203,7 @@ type controller struct { } func (c *controller) run(ctx context.Context) { + defer guard() defer close(c.s.done) jobs := make(chan *job, c.opts.JobBuffer) results := make(chan result, c.opts.ResultBuf) @@ -211,6 +212,7 @@ func (c *controller) run(ctx context.Context) { wg.Add(1) go func() { defer wg.Done() + defer guard() worker(ctx, jobs, results, c.opts.ChunkSize) }() } diff --git a/internal/textutil/text.go b/internal/textutil/text.go index 74e0486..7ee1e0c 100644 --- a/internal/textutil/text.go +++ b/internal/textutil/text.go @@ -7,6 +7,7 @@ import ( "strings" "unicode/utf8" + "github.com/charmbracelet/x/ansi" "github.com/rivo/uniseg" ) @@ -52,8 +53,40 @@ func isBidiControl(r rune) bool { 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. -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. func Truncate(s string, w int) string { @@ -77,33 +110,35 @@ func TruncateLeft(s string, w int) string { return s } skip := total - (w - 1) - state := -1 - rest := s - for rest != "" && skip > 0 { - var cw int - _, rest, cw, state = uniseg.FirstGraphemeClusterInString(rest, state) - skip -= cw - } - // A wide cluster may have overshot by one cell; pad to keep width exact. - return "…" + strings.Repeat(" ", -skip) + rest + var b strings.Builder + Clusters(s, func(c string, cw int) bool { + if skip > 0 { + skip -= cw + if skip < 0 { + // A wide cluster overshot by one cell; pad to keep width exact. + b.WriteString(strings.Repeat(" ", -skip)) + } + return true + } + b.WriteString(c) + return true + }) + return "…" + b.String() } // clip returns the longest prefix of s that fits in w cells. func clip(s string, w int) string { - state := -1 - used, end := 0, 0 - rest := s - for rest != "" { - var cluster string - var cw int - cluster, rest, cw, state = uniseg.FirstGraphemeClusterInString(rest, state) + var b strings.Builder + used := 0 + Clusters(s, func(c string, cw int) bool { if used+cw > w { - break + return false } used += cw - end += len(cluster) - } - return s[:end] + b.WriteString(c) + return true + }) + return b.String() } // PadRight pads s with spaces to exactly w cells (truncating if longer). diff --git a/internal/textutil/text_test.go b/internal/textutil/text_test.go index e9e45c0..6eb0804 100644 --- a/internal/textutil/text_test.go +++ b/internal/textutil/text_test.go @@ -62,3 +62,13 @@ func TestSize(t *testing.T) { 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)) + } + } +} diff --git a/internal/tui/actions.go b/internal/tui/actions.go new file mode 100644 index 0000000..c761975 --- /dev/null +++ b/internal/tui/actions.go @@ -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 +} diff --git a/internal/tui/canvas.go b/internal/tui/canvas.go new file mode 100644 index 0000000..7d1e808 --- /dev/null +++ b/internal/tui/canvas.go @@ -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() +} diff --git a/internal/tui/color256.go b/internal/tui/color256.go new file mode 100644 index 0000000..e4e9c41 --- /dev/null +++ b/internal/tui/color256.go @@ -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) +} diff --git a/internal/tui/data.go b/internal/tui/data.go new file mode 100644 index 0000000..1874b26 --- /dev/null +++ b/internal/tui/data.go @@ -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) +} diff --git a/internal/tui/infoview.go b/internal/tui/infoview.go new file mode 100644 index 0000000..1798e85 --- /dev/null +++ b/internal/tui/infoview.go @@ -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 +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..25e9f31 --- /dev/null +++ b/internal/tui/keys.go @@ -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"))) +} diff --git a/internal/tui/live_test.go b/internal/tui/live_test.go new file mode 100644 index 0000000..12c1a8f --- /dev/null +++ b/internal/tui/live_test.go @@ -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: + } + } +} diff --git a/internal/tui/mapview.go b/internal/tui/mapview.go new file mode 100644 index 0000000..0dd0c07 --- /dev/null +++ b/internal/tui/mapview.go @@ -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 +} diff --git a/internal/tui/modal.go b/internal/tui/modal.go new file mode 100644 index 0000000..5a0b6a7 --- /dev/null +++ b/internal/tui/modal.go @@ -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 +} diff --git a/internal/tui/modals.go b/internal/tui/modals.go new file mode 100644 index 0000000..d633c68 --- /dev/null +++ b/internal/tui/modals.go @@ -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 +} diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..6ac36f6 --- /dev/null +++ b/internal/tui/model.go @@ -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 +} diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go new file mode 100644 index 0000000..7fe2f7b --- /dev/null +++ b/internal/tui/mouse.go @@ -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 + } +} diff --git a/internal/tui/render.go b/internal/tui/render.go new file mode 100644 index 0000000..3602996 --- /dev/null +++ b/internal/tui/render.go @@ -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]}) + } +} diff --git a/internal/tui/run.go b/internal/tui/run.go new file mode 100644 index 0000000..cd36353 --- /dev/null +++ b/internal/tui/run.go @@ -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 +} diff --git a/internal/tui/search.go b/internal/tui/search.go new file mode 100644 index 0000000..7abd4ee --- /dev/null +++ b/internal/tui/search.go @@ -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}) +} diff --git a/internal/tui/side.go b/internal/tui/side.go new file mode 100644 index 0000000..d27bdce --- /dev/null +++ b/internal/tui/side.go @@ -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++ + } + } + } +} diff --git a/internal/tui/table.go b/internal/tui/table.go new file mode 100644 index 0000000..e4a82f6 --- /dev/null +++ b/internal/tui/table.go @@ -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" +} diff --git a/internal/tui/theme.go b/internal/tui/theme.go new file mode 100644 index 0000000..6959999 --- /dev/null +++ b/internal/tui/theme.go @@ -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) +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000..a7bb7ec --- /dev/null +++ b/internal/tui/tui_test.go @@ -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++ + } +} diff --git a/internal/tui/views.go b/internal/tui/views.go new file mode 100644 index 0000000..076afe9 --- /dev/null +++ b/internal/tui/views.go @@ -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 +}