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.
457 lines
12 KiB
Go
457 lines
12 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"github.com/andydixon/mapsize/internal/inventory"
|
|
"github.com/andydixon/mapsize/internal/snapshot"
|
|
"github.com/andydixon/mapsize/internal/textutil"
|
|
"github.com/andydixon/mapsize/internal/treemap"
|
|
)
|
|
|
|
// ---- Directory list -------------------------------------------------------
|
|
|
|
type listView struct {
|
|
tb table
|
|
lastSel int64
|
|
}
|
|
|
|
func (*listView) name() string { return "List" }
|
|
|
|
// sync reconciles the table cursor with the shared selection: if the
|
|
// selection changed elsewhere (treemap, jump) the cursor follows it;
|
|
// otherwise the cursor (moved by keys or clicks) drives the selection.
|
|
func (v *listView) sync(m *Model) {
|
|
k := m.kidsOf(m.zoom)
|
|
if m.sel != v.lastSel {
|
|
for i, id := range k.ids {
|
|
if int64(id) == m.sel {
|
|
v.tb.cursor = i
|
|
}
|
|
}
|
|
} else if v.tb.cursor < len(k.ids) {
|
|
m.sel = int64(k.ids[v.tb.cursor])
|
|
}
|
|
v.lastSel = m.sel
|
|
}
|
|
|
|
func (v *listView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
|
t := m.theme
|
|
k := m.kidsOf(m.zoom)
|
|
v.sync(m)
|
|
total := m.sizeOf(m.zoom)
|
|
rows := make([]tableRow, len(k.ids))
|
|
for i, id := range k.ids {
|
|
n := m.tree.Node(id)
|
|
name := textutil.Sanitize(n.Name)
|
|
files := ""
|
|
if n.IsDir() {
|
|
name += "/"
|
|
files = textutil.Count(int64(n.Files))
|
|
}
|
|
mod := ""
|
|
if n.MTime > 0 {
|
|
mod = time.Unix(0, n.MTime).Format("2006-01-02")
|
|
}
|
|
col := m.colorOf(int64(id))
|
|
nameSt := &Style{FG: col.Mix(t.Fg, 0.45), BG: t.Bg}
|
|
if n.IsDir() {
|
|
nameSt.Attr = Bold
|
|
}
|
|
frac := 0.0
|
|
if k.sizes[0] > 0 {
|
|
frac = float64(k.sizes[i]) / float64(k.sizes[0])
|
|
}
|
|
rows[i] = tableRow{
|
|
cells: []string{textutil.Size(k.sizes[i]), textutil.Percent(k.sizes[i], total), "", files, mod, name},
|
|
styles: []*Style{nil, {FG: t.Muted, BG: t.Bg}, nil, {FG: t.Muted, BG: t.Bg}, {FG: t.Muted, BG: t.Bg}, nameSt},
|
|
bar: frac, barCol: col,
|
|
}
|
|
if m.opts.Diff != nil {
|
|
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}}
|
|
if m.opts.Diff != nil {
|
|
cols[4] = column{"CHANGE", 12, true}
|
|
}
|
|
if r.W < 70 {
|
|
cols = []column{{"SIZE", 10, true}, {"%", 5, true}, {"NAME", 0, 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]}
|
|
}
|
|
}
|
|
v.tb.paint(m, cv, r, cols, rows, "")
|
|
v.sync(m)
|
|
}
|
|
|
|
func (v *listView) key(m *Model, k string) (bool, tea.Cmd) {
|
|
v.sync(m)
|
|
defer v.sync(m)
|
|
return v.tb.key(k, len(m.kidsOf(m.zoom).ids)), nil
|
|
}
|
|
|
|
func (v *listView) wheel(m *Model, up bool) tea.Cmd {
|
|
v.sync(m)
|
|
v.tb.move(map[bool]int{true: -3, false: 3}[up], len(m.kidsOf(m.zoom).ids))
|
|
v.sync(m)
|
|
return nil
|
|
}
|
|
|
|
// ---- Extension statistics ------------------------------------------------
|
|
|
|
type extView struct {
|
|
tb table
|
|
cache []inventory.ExtStat
|
|
key_ [2]int
|
|
}
|
|
|
|
func (*extView) name() string { return "Types" }
|
|
|
|
func (v *extView) stats(m *Model) []inventory.ExtStat {
|
|
k := [2]int{int(m.zoom), m.dataVer*2 + int(m.sizeMode)}
|
|
if v.cache == nil || v.key_ != k {
|
|
v.cache = m.tree.ExtStats(m.zoom)
|
|
sort.Slice(v.cache, func(i, j int) bool {
|
|
a, b := v.cache[i], v.cache[j]
|
|
as, bs := a.Alloc, b.Alloc
|
|
if m.sizeMode == inventory.SizeLogical {
|
|
as, bs = a.Size, b.Size
|
|
}
|
|
if as != bs {
|
|
return as > bs
|
|
}
|
|
return a.Ext < b.Ext
|
|
})
|
|
v.key_ = k
|
|
}
|
|
return v.cache
|
|
}
|
|
|
|
func (v *extView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
|
t := m.theme
|
|
st := v.stats(m)
|
|
var total int64
|
|
for _, s := range st {
|
|
total += pick(m, s)
|
|
}
|
|
rows := make([]tableRow, len(st))
|
|
for i, s := range st {
|
|
ext := "." + s.Ext
|
|
if s.Ext == "" {
|
|
ext = "(none)"
|
|
}
|
|
col := t.Cat[s.Cat]
|
|
if c, ok := t.Ext[s.Ext]; ok {
|
|
col = c
|
|
}
|
|
frac := 0.0
|
|
if v0 := pick(m, st[0]); v0 > 0 {
|
|
frac = float64(pick(m, s)) / float64(v0)
|
|
}
|
|
rows[i] = tableRow{
|
|
cells: []string{textutil.Sanitize(ext), s.Cat.String(), textutil.Count(s.Count), textutil.Size(pick(m, s)), textutil.Percent(pick(m, s), total), ""},
|
|
styles: []*Style{{FG: col, BG: t.Bg, Attr: Bold}, {FG: t.Muted, BG: t.Bg}, nil, nil, {FG: t.Muted, BG: t.Bg}, nil},
|
|
bar: frac, barCol: col,
|
|
}
|
|
}
|
|
cols := []column{{"EXTENSION", 14, false}, {"CATEGORY", 12, false}, {"FILES", 11, true}, {"SIZE", 10, true}, {"%", 5, true}, {"", 0, 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)))
|
|
}
|
|
|
|
func pick(m *Model, s inventory.ExtStat) int64 {
|
|
if m.sizeMode == inventory.SizeLogical {
|
|
return s.Size
|
|
}
|
|
return s.Alloc
|
|
}
|
|
|
|
func (v *extView) key(m *Model, k string) (bool, tea.Cmd) {
|
|
st := v.stats(m)
|
|
if v.tb.key(k, len(st)) {
|
|
return true, nil
|
|
}
|
|
if (k == "enter" || k == "space") && v.tb.cursor < len(st) {
|
|
e := st[v.tb.cursor].Ext
|
|
q := "ext = \"" + e + "\""
|
|
if e == "" {
|
|
q = "type = file AND ext = \"\""
|
|
}
|
|
m.setViewByName("Map")
|
|
return true, m.setFilter(q)
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (v *extView) wheel(m *Model, up bool) tea.Cmd {
|
|
v.tb.move(map[bool]int{true: -3, false: 3}[up], len(v.stats(m)))
|
|
return nil
|
|
}
|
|
|
|
// ---- Top-N investigation views --------------------------------------------
|
|
|
|
type topMode int
|
|
|
|
const (
|
|
topFiles topMode = iota
|
|
topDirs
|
|
topOld
|
|
topNew
|
|
topCount
|
|
topSparse
|
|
topHardlinks
|
|
numTopModes
|
|
)
|
|
|
|
var topNames = [...]string{"Largest files", "Largest directories (own files)", "Oldest large files",
|
|
"Newest large files", "Most files", "Sparse files", "Hard-linked files"}
|
|
|
|
type topView struct {
|
|
tb table
|
|
mode topMode
|
|
ids []inventory.NodeID
|
|
vals []int64
|
|
key_ [3]int
|
|
limit int
|
|
}
|
|
|
|
func (*topView) name() string { return "Top" }
|
|
|
|
const topLimit = 500
|
|
|
|
func (v *topView) compute(m *Model) {
|
|
k := [3]int{int(m.zoom), m.dataVer*2 + int(m.sizeMode), int(v.mode)}
|
|
if v.ids != nil && v.key_ == k {
|
|
return
|
|
}
|
|
v.key_ = k
|
|
tr := m.tree
|
|
isFile := func(n *inventory.Node) bool {
|
|
return n.Kind == inventory.KindFile && n.Flags&(inventory.FlagHardlinkDup|inventory.FlagDeleted) == 0
|
|
}
|
|
matches := func(id inventory.NodeID) bool {
|
|
return m.search.result == nil || m.search.result.Size(id) > 0
|
|
}
|
|
tk := inventory.NewTopK(topLimit)
|
|
val := map[inventory.NodeID]int64{}
|
|
const largeFile = 1 << 20
|
|
switch v.mode {
|
|
case topDirs:
|
|
own := map[inventory.NodeID]int64{}
|
|
tr.Walk(m.zoom, func(id inventory.NodeID, n *inventory.Node) bool {
|
|
if isFile(n) && matches(id) {
|
|
own[n.Parent] += n.Own(m.sizeMode)
|
|
}
|
|
return true
|
|
})
|
|
for id, s := range own {
|
|
tk.Offer(id, s)
|
|
val[id] = s
|
|
}
|
|
case topCount:
|
|
tr.Walk(m.zoom, func(id inventory.NodeID, n *inventory.Node) bool {
|
|
if n.IsDir() && matches(id) {
|
|
c := int64(tr.ChildCount(id))
|
|
tk.Offer(id, c)
|
|
val[id] = c
|
|
}
|
|
return true
|
|
})
|
|
default:
|
|
tr.Walk(m.zoom, func(id inventory.NodeID, n *inventory.Node) bool {
|
|
if !isFile(n) || !matches(id) {
|
|
return true
|
|
}
|
|
sz := n.Own(m.sizeMode)
|
|
switch v.mode {
|
|
case topFiles:
|
|
tk.Offer(id, sz)
|
|
val[id] = sz
|
|
case topOld, topNew:
|
|
if sz >= largeFile && n.MTime > 0 {
|
|
key := n.MTime
|
|
if v.mode == topOld {
|
|
key = -key
|
|
}
|
|
tk.Offer(id, key)
|
|
val[id] = sz
|
|
}
|
|
case topSparse:
|
|
if n.Flags&inventory.FlagSparse != 0 {
|
|
tk.Offer(id, n.Size-n.Alloc)
|
|
val[id] = n.Size - n.Alloc
|
|
}
|
|
case topHardlinks:
|
|
if n.Flags&inventory.FlagHardlinked != 0 {
|
|
tk.Offer(id, n.Size)
|
|
val[id] = n.Size
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
v.ids = tk.Sorted()
|
|
v.vals = v.vals[:0]
|
|
for _, id := range v.ids {
|
|
v.vals = append(v.vals, val[id])
|
|
}
|
|
}
|
|
|
|
func (v *topView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
|
t := m.theme
|
|
v.compute(m)
|
|
rows := make([]tableRow, len(v.ids))
|
|
var first int64
|
|
if len(v.vals) > 0 {
|
|
first = v.vals[0]
|
|
}
|
|
for i, id := range v.ids {
|
|
n := m.tree.Node(id)
|
|
val := textutil.Size(v.vals[i])
|
|
if v.mode == topCount {
|
|
val = textutil.Count(v.vals[i])
|
|
}
|
|
mod := ""
|
|
if n.MTime > 0 {
|
|
mod = time.Unix(0, n.MTime).Format("2006-01-02")
|
|
}
|
|
frac := 0.0
|
|
if first > 0 && v.mode != topOld && v.mode != topNew {
|
|
frac = float64(v.vals[i]) / float64(first)
|
|
}
|
|
col := m.colorOf(int64(id))
|
|
rows[i] = tableRow{
|
|
cells: []string{val, mod, "", textutil.Sanitize(m.tree.Path(id))},
|
|
styles: []*Style{nil, {FG: t.Muted, BG: t.Bg}, nil, {FG: col.Mix(t.Fg, 0.45), BG: t.Bg}},
|
|
bar: frac, barCol: col,
|
|
}
|
|
}
|
|
title := fmt.Sprintf("◂ %s ▸ (←/→ change list · Enter details · Space jump to it)", topNames[v.mode])
|
|
if m.search.result != nil {
|
|
title += " · filtered"
|
|
}
|
|
valTitle := "SIZE"
|
|
switch v.mode {
|
|
case topCount:
|
|
valTitle = "ENTRIES"
|
|
case topSparse:
|
|
valTitle = "SAVED"
|
|
}
|
|
cols := []column{{valTitle, 10, true}, {"MODIFIED", 10, false}, {"", 10, false}, {"PATH", 0, false}}
|
|
v.tb.paint(m, cv, r, cols, rows, title)
|
|
}
|
|
|
|
func (v *topView) key(m *Model, k string) (bool, tea.Cmd) {
|
|
v.compute(m)
|
|
switch k {
|
|
case "left", "h":
|
|
v.mode = (v.mode + numTopModes - 1) % numTopModes
|
|
v.tb.cursor = 0
|
|
return true, nil
|
|
case "right", "l":
|
|
v.mode = (v.mode + 1) % numTopModes
|
|
v.tb.cursor = 0
|
|
return true, nil
|
|
}
|
|
if v.tb.key(k, len(v.ids)) {
|
|
return true, nil
|
|
}
|
|
if v.tb.cursor < len(v.ids) {
|
|
id := v.ids[v.tb.cursor]
|
|
switch k {
|
|
case "enter":
|
|
m.openModal(newInfoModal(m, id))
|
|
return true, nil
|
|
case "space":
|
|
m.jumpTo(id)
|
|
return true, nil
|
|
case "c", "o", "d":
|
|
m.sel = int64(id)
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (v *topView) wheel(m *Model, up bool) tea.Cmd {
|
|
v.tb.move(map[bool]int{true: -3, false: 3}[up], len(v.ids))
|
|
return nil
|
|
}
|
|
|
|
// ---- Snapshot comparison -----------------------------------------------------
|
|
|
|
type changesView struct {
|
|
tb table
|
|
rows []snapshot.Change
|
|
key_ int
|
|
}
|
|
|
|
func (*changesView) name() string { return "Changes" }
|
|
|
|
func (v *changesView) paint(m *Model, cv *Canvas, r treemap.Rect) {
|
|
t := m.theme
|
|
d := m.opts.Diff
|
|
if v.rows == nil || v.key_ != int(m.sizeMode) {
|
|
v.rows = d.Changes(1000, m.sizeMode)
|
|
v.key_ = int(m.sizeMode)
|
|
}
|
|
var maxAbs int64 = 1
|
|
for _, c := range v.rows {
|
|
maxAbs = max(maxAbs, abs64(c.Delta))
|
|
}
|
|
rows := make([]tableRow, len(v.rows))
|
|
for i, c := range v.rows {
|
|
col := map[snapshot.Status]Color{snapshot.Added: t.DiffNew, snapshot.Removed: t.Muted,
|
|
snapshot.Grew: t.DiffGrow, snapshot.Shrank: t.DiffShrink}[c.Status]
|
|
rows[i] = tableRow{
|
|
cells: []string{textutil.SignedSize(c.Delta), c.Status.String(), textutil.Size(c.Old), textutil.Size(c.New), "", textutil.Sanitize(c.Path)},
|
|
styles: []*Style{{FG: col, BG: t.Bg, Attr: Bold}, {FG: col, BG: t.Bg}, {FG: t.Muted, BG: t.Bg}, nil, nil, nil},
|
|
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}}
|
|
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)
|
|
}
|
|
|
|
func abs64(v int64) int64 {
|
|
if v < 0 {
|
|
return -v
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (v *changesView) key(m *Model, k string) (bool, tea.Cmd) {
|
|
if v.tb.key(k, len(v.rows)) {
|
|
return true, nil
|
|
}
|
|
if v.tb.cursor < len(v.rows) {
|
|
c := v.rows[v.tb.cursor]
|
|
if c.Node == inventory.NoNode {
|
|
if k == "enter" || k == "space" {
|
|
return true, m.info("Removed since the old snapshot: " + textutil.Sanitize(c.Path))
|
|
}
|
|
return false, nil
|
|
}
|
|
switch k {
|
|
case "enter":
|
|
m.openModal(newInfoModal(m, c.Node))
|
|
return true, nil
|
|
case "space":
|
|
m.jumpTo(c.Node)
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (v *changesView) wheel(m *Model, up bool) tea.Cmd {
|
|
v.tb.move(map[bool]int{true: -3, false: 3}[up], len(v.rows))
|
|
return nil
|
|
}
|