Add interactive snapshot comparison, CLI tests and treegen

--compare opens the TUI with growth/shrink colouring unless a report is
requested. treegen builds deterministic synthetic trees (Unicode,
hostile names, sparse files). MAPSIZE_DEBUG_PANIC verifies terminal
restoration on UI and scanner panics.
This commit is contained in:
Andy Dixon
2026-10-02 08:28:52 +01:00
parent df8b10c6dd
commit 0e5b9f7cc3
10 changed files with 288 additions and 20 deletions
+1 -1
View File
@@ -212,7 +212,7 @@ func (v *dupView) paint(m *Model, cv *Canvas, r treemap.Rect) {
if d.err != nil {
title += " — stopped: " + textutil.Sanitize(d.err.Error())
}
v.tb.paint(m, cv, r, []column{{"WASTED", 10, true}, {"COPIES", 16, false}, {"", 0, false}}, tr, title)
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) {
+3 -3
View File
@@ -131,14 +131,14 @@ func (v *infoView) paint(m *Model, cv *Canvas, r treemap.Rect) {
if e.Name != "" {
p += "/" + e.Name
}
rows[i] = tableRow{cells: []string{e.Kind.String(), textutil.Sanitize(e.Msg), textutil.Sanitize(p)},
styles: []*Style{{FG: t.Err, BG: t.Bg}, {FG: t.Muted, BG: t.Bg}, nil}, bar: -1}
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", 20, false}, {"DETAIL", 24, false}, {"PATH", 0, false}}, rows, title)
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) {
+8
View File
@@ -150,6 +150,11 @@ func newModel(opts Options, s *scan.Scanner, t *inventory.Tree, cancel context.C
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}
@@ -271,6 +276,9 @@ func (m *Model) update(msg tea.Msg) tea.Cmd {
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
+13
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"os"
"runtime/debug"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/colorprofile"
@@ -47,6 +49,17 @@ func Run(opts Options) error {
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()
+6 -3
View File
@@ -9,9 +9,10 @@ import (
// column describes one table column.
type column struct {
title string
width int // 0 = flexible (takes the remaining space)
right bool
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.
@@ -129,6 +130,8 @@ func (tb *table) paint(m *Model, cv *Canvas, r treemap.Rect, cols []column, rows
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)
}
+7 -6
View File
@@ -75,12 +75,12 @@ func (v *listView) paint(m *Model, cv *Canvas, r treemap.Rect) {
rows[i].cells[4] = textutil.SignedSize(m.opts.Diff.Delta(id, m.sizeMode))
}
}
cols := []column{{"SIZE", 10, true}, {"%", 5, true}, {"", 12, false}, {"FILES", 10, true}, {"MODIFIED", 10, false}, {"NAME", 0, false}}
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}
cols[4] = column{"CHANGE", 12, true, false}
}
if r.W < 70 {
cols = []column{{"SIZE", 10, true}, {"%", 5, true}, {"NAME", 0, false}}
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]}
@@ -160,7 +160,7 @@ func (v *extView) paint(m *Model, cv *Canvas, r treemap.Rect) {
bar: frac, barCol: col,
}
}
cols := []column{{"EXTENSION", 14, false}, {"CATEGORY", 12, false}, {"FILES", 11, true}, {"SIZE", 10, true}, {"%", 5, true}, {"", 0, false}}
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)))
}
@@ -342,7 +342,7 @@ func (v *topView) paint(m *Model, cv *Canvas, r treemap.Rect) {
case topSparse:
valTitle = "SAVED"
}
cols := []column{{valTitle, 10, true}, {"MODIFIED", 10, false}, {"", 10, false}, {"PATH", 0, false}}
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)
}
@@ -413,7 +413,8 @@ func (v *changesView) paint(m *Model, cv *Canvas, r treemap.Rect) {
bar: float64(abs64(c.Delta)) / float64(maxAbs), barCol: col,
}
}
cols := []column{{"CHANGE", 13, true}, {"STATUS", 9, false}, {"OLD", 10, true}, {"NEW", 10, true}, {"", 10, false}, {"PATH", 0, false}}
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)