Files
mapsize/internal/tui/infoview.go
T
Andy Dixon 0e5b9f7cc3 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.
2026-10-02 08:28:52 +01:00

160 lines
5.0 KiB
Go

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", st.Follow, value)
line("Size mode", m.sizeMode.String(), value)
line("Units", map[bool]string{true: "SI (1000)", false: "IEC (1024)"}[textutil.SI], 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.move(map[bool]int{true: -3, false: 3}[up], len(m.tree.Stats.Errors))
return nil
}