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.
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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
|
## 1.0.0 — 2026-10-02
|
||||||
|
|
||||||
First stable release. The snapshot format (version 1) is now stable: later
|
First stable release. The snapshot format (version 1) is now stable: later
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
.\" Manual page for mapsize. View with: man -l docs/mapsize.1
|
.\" 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
|
.SH NAME
|
||||||
mapsize \- interactive terminal disk usage analyser with a navigable treemap
|
mapsize \- interactive terminal disk usage analyser with a navigable treemap
|
||||||
.SH SYNOPSIS
|
.SH SYNOPSIS
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Version is stamped at release time via -ldflags -X.
|
// Version is stamped at release time via -ldflags -X.
|
||||||
var Version = "1.0.0"
|
var Version = "1.1.0"
|
||||||
|
|||||||
+45
-8
@@ -43,7 +43,7 @@ const (
|
|||||||
topMaxItems = 400
|
topMaxItems = 400
|
||||||
nestMinArea = 3
|
nestMinArea = 3
|
||||||
nestMaxItems = 120
|
nestMaxItems = 120
|
||||||
maxNestDepth = 3
|
maxNestDepth = 8 // block-size thresholds stop recursion long before this
|
||||||
nestMinW = 4
|
nestMinW = 4
|
||||||
nestMinH = 2
|
nestMinH = 2
|
||||||
nestRecurseW = 14
|
nestRecurseW = 14
|
||||||
@@ -65,7 +65,7 @@ func (m *Model) layoutTreemap(r treemap.Rect) *tmLayout {
|
|||||||
in := b.Inset(1)
|
in := b.Inset(1)
|
||||||
if in.W >= nestMinW && in.H >= nestMinH {
|
if in.W >= nestMinW && in.H >= nestMinH {
|
||||||
before := len(l.nested)
|
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
|
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) {
|
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.
|
// 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 {
|
for i, b := range blocks {
|
||||||
nb := nested{id: b.ID, r: b.Rect, depth: depth, alt: i%2 == 1}
|
nb := nested{id: b.ID, r: b.Rect, depth: depth, alt: i%2 == 1}
|
||||||
idx := len(l.nested)
|
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 &&
|
if depth < maxNestDepth && b.ID >= 0 && b.W >= nestRecurseW && b.H >= nestRecurseH &&
|
||||||
m.tree.Node(inventory.NodeID(b.ID)).IsDir() {
|
m.tree.Node(inventory.NodeID(b.ID)).IsDir() {
|
||||||
l.nested[idx].inner = true
|
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.
|
// groupInfo returns the LOD group for a group ID in the current layout.
|
||||||
func (m *Model) groupInfo(id int64) (*group, bool) {
|
func (m *Model) groupInfo(id int64) (*group, bool) {
|
||||||
if m.tm == nil {
|
if m.tm == nil {
|
||||||
@@ -168,10 +181,15 @@ func (m *Model) blockName(id int64) string {
|
|||||||
}
|
}
|
||||||
return "smaller items"
|
return "smaller items"
|
||||||
}
|
}
|
||||||
n := m.tree.Node(inventory.NodeID(id))
|
nid := inventory.NodeID(id)
|
||||||
|
n := m.tree.Node(nid)
|
||||||
name := textutil.Sanitize(n.Name)
|
name := textutil.Sanitize(n.Name)
|
||||||
if n.IsDir() && inventory.NodeID(id) != m.tree.Root() {
|
if n.IsDir() && nid != m.tree.Root() {
|
||||||
name += "/"
|
name += "/"
|
||||||
|
for end := m.chainEnd(nid); nid != end; {
|
||||||
|
nid = m.kidsOf(nid).ids[0]
|
||||||
|
name += textutil.Sanitize(m.tree.Node(nid).Name) + "/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
@@ -186,6 +204,20 @@ func (m *Model) blockSize(id int64) int64 {
|
|||||||
return m.sizeOf(inventory.NodeID(id))
|
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) {
|
func (m *Model) paintTop(cv *Canvas, b treemap.Block, sel, hov bool, total int64) {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
base := m.colorOf(b.ID)
|
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) {
|
func (m *Model) paintNested(cv *Canvas, nb nested) {
|
||||||
t := m.theme
|
t := m.theme
|
||||||
base := m.colorOf(nb.id)
|
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 {
|
if nb.alt {
|
||||||
f += 0.07
|
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})
|
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 {
|
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})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user