Add Bubble Tea TUI with interactive treemap
Cell canvas renderer with frame cache and a perceptual 256-colour quantizer; responsive geometry (large/medium/small/too-small); squarified treemap with nested previews, LOD grouping, category colouring and adaptive labels; spatial arrow navigation with stable NodeID selection; zoom and breadcrumbs; modal system (info, help, confirm, save); list, file-type, top-N, scan-info, duplicate and comparison views; live search/filter; mouse support; move-to-trash.
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
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 reveal · Space go to"
|
||||
if !m.opts.ReadOnly && !m.snapshot {
|
||||
s += " · d trash"
|
||||
}
|
||||
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":
|
||||
p := m.tree.Path(md.id)
|
||||
return false, tea.Batch(tea.SetClipboard(p), m.info("Copied path to clipboard"))
|
||||
case "o":
|
||||
m.sel = int64(md.id)
|
||||
return false, 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 74 }
|
||||
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, 16), &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}))
|
||||
out = append(out, styled(" fields: "+filter.Fields, 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", "reveal in file manager")
|
||||
if !m.opts.ReadOnly {
|
||||
row("d", "move to trash (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
|
||||
}
|
||||
Reference in New Issue
Block a user