From 352d4292dffa4ed9ac07e2e58fc21074f2781956 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sat, 3 Oct 2026 10:18:57 +0100 Subject: [PATCH] mapsize 1.1.0: nested treemap reaches deep folders The map stopped nesting after a fixed 3 levels, so at / a chain like mnt/raid/media used the whole budget on full-size wrappers and showed empty boxes with only titles. Single-child directory chains now collapse into one label, nesting depth is bounded by block size instead of a fixed cap, and nested blocks show size and percentage of their parent. --- CHANGELOG.md | 9 +++++ docs/mapsize.1 | 2 +- internal/brand/brand.go | 2 +- internal/tui/mapview.go | 53 +++++++++++++++++++++++----- internal/tui/nest_test.go | 72 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 128 insertions(+), 10 deletions(-) create mode 100644 internal/tui/nest_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b89432..450fc24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.1.0 — 2026-10-03 + +### Fixed +- Map: folders that contain a single subfolder (`mnt/raid/media/`) now + collapse into one label instead of each taking a nesting level, so the + map at `/` reaches the shows, seasons and individual files. Nesting goes as + deep as the block sizes allow (previously a fixed 3 levels), and nested + blocks show their size and percentage of their parent folder. + ## 1.0.0 — 2026-10-02 First stable release. The snapshot format (version 1) is now stable: later diff --git a/docs/mapsize.1 b/docs/mapsize.1 index 487095a..71292e1 100644 --- a/docs/mapsize.1 +++ b/docs/mapsize.1 @@ -1,5 +1,5 @@ .\" Manual page for mapsize. View with: man -l docs/mapsize.1 -.TH MAPSIZE 1 "October 2026" "mapsize 1.0.0" "User Commands" +.TH MAPSIZE 1 "October 2026" "mapsize 1.1.0" "User Commands" .SH NAME mapsize \- interactive terminal disk usage analyser with a navigable treemap .SH SYNOPSIS diff --git a/internal/brand/brand.go b/internal/brand/brand.go index 08a1891..28eed09 100644 --- a/internal/brand/brand.go +++ b/internal/brand/brand.go @@ -8,4 +8,4 @@ const ( ) // Version is stamped at release time via -ldflags -X. -var Version = "1.0.0" +var Version = "1.1.0" diff --git a/internal/tui/mapview.go b/internal/tui/mapview.go index 0dd0c07..4d9f802 100644 --- a/internal/tui/mapview.go +++ b/internal/tui/mapview.go @@ -43,7 +43,7 @@ const ( topMaxItems = 400 nestMinArea = 3 nestMaxItems = 120 - maxNestDepth = 3 + maxNestDepth = 8 // block-size thresholds stop recursion long before this nestMinW = 4 nestMinH = 2 nestRecurseW = 14 @@ -65,7 +65,7 @@ func (m *Model) layoutTreemap(r treemap.Rect) *tmLayout { in := b.Inset(1) if in.W >= nestMinW && in.H >= nestMinH { before := len(l.nested) - m.layoutNested(l, inventory.NodeID(b.ID), in, 2) + m.layoutNested(l, m.chainEnd(inventory.NodeID(b.ID)), in, 2) l.inner[b.ID] = len(l.nested) > before } } @@ -104,7 +104,7 @@ func (m *Model) layoutLevel(l *tmLayout, parent inventory.NodeID, r treemap.Rect func (m *Model) layoutNested(l *tmLayout, parent inventory.NodeID, r treemap.Rect, depth int) { // Deeper levels need bigger blocks to stay legible rather than noisy. - blocks := m.layoutLevel(l, parent, r, nestMinArea*(depth-1)*(depth-1), nestMaxItems) + 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) @@ -112,11 +112,24 @@ func (m *Model) layoutNested(l *tmLayout, parent inventory.NodeID, r treemap.Rec if depth < maxNestDepth && b.ID >= 0 && b.W >= nestRecurseW && b.H >= nestRecurseH && m.tree.Node(inventory.NodeID(b.ID)).IsDir() { l.nested[idx].inner = true - m.layoutNested(l, inventory.NodeID(b.ID), treemap.Rect{X: b.X, Y: b.Y + 1, W: b.W - 1, H: b.H - 2}, depth+1) + 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 { @@ -168,10 +181,15 @@ func (m *Model) blockName(id int64) string { } return "smaller items" } - n := m.tree.Node(inventory.NodeID(id)) + nid := inventory.NodeID(id) + n := m.tree.Node(nid) name := textutil.Sanitize(n.Name) - if n.IsDir() && inventory.NodeID(id) != m.tree.Root() { + 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 } @@ -186,6 +204,20 @@ func (m *Model) blockSize(id int64) int64 { 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) @@ -276,7 +308,7 @@ func (m *Model) hasNested(id int64) bool { return m.tm != nil && m.tm.inner[id] func (m *Model) paintNested(cv *Canvas, nb nested) { t := m.theme base := m.colorOf(nb.id) - f := 0.38 - 0.1*float64(nb.depth-2) + f := max(0.38-0.1*float64(nb.depth-2), 0.1) if nb.alt { f += 0.07 } @@ -327,7 +359,12 @@ func (m *Model) paintNested(cv *Canvas, nb nested) { } cv.Text(lx, ly, textutil.Truncate(name, lw), lw, Style{FG: st.FG, BG: bg, Attr: Bold}) if !nb.inner && r.H >= 3 && lw >= 5 { - cv.Text(lx, ly+1, textutil.Truncate(textutil.SizeCompact(m.blockSize(nb.id)), lw), lw, Style{FG: bg.Mix(st.FG, 0.7), BG: bg}) + 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}) } } diff --git a/internal/tui/nest_test.go b/internal/tui/nest_test.go new file mode 100644 index 0000000..edff384 --- /dev/null +++ b/internal/tui/nest_test.go @@ -0,0 +1,72 @@ +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) + } +}