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:
Andy Dixon
2026-10-02 08:25:35 +01:00
parent e8c831cb47
commit df8b10c6dd
35 changed files with 5294 additions and 25 deletions
+1
View File
@@ -36,6 +36,7 @@ type Config struct {
Theme string
SizeMode inventory.SizeMode
Mouse bool
Color string
Settings config.Settings
Stdout io.Writer
Stderr io.Writer
+8 -3
View File
@@ -2,17 +2,22 @@ package app
import (
"context"
"errors"
"fmt"
"github.com/andydixon/mapsize/internal/duplicate"
"github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/scan"
"github.com/andydixon/mapsize/internal/snapshot"
"github.com/andydixon/mapsize/internal/textutil"
"github.com/andydixon/mapsize/internal/tui"
)
func runTUI(*Config) error {
return errors.New("the interactive UI is not built yet; use --no-ui, --top, --json or --csv")
func runTUI(cfg *Config) error {
return tui.Run(tui.Options{
ReadOnly: cfg.ReadOnly, Theme: cfg.Theme, SizeMode: cfg.SizeMode, Mouse: cfg.Mouse, Color: cfg.Color,
Save: cfg.Save, Settings: cfg.Settings,
Start: func(ctx context.Context) (*scan.Scanner, *inventory.Tree, error) { return startSource(ctx, cfg) },
})
}
func loadSnapshot(path string) (*inventory.Tree, error) { return snapshot.LoadFile(path) }
+1
View File
@@ -51,6 +51,7 @@ const (
FlagAllocUnknown // platform cannot report allocated size
FlagBrokenLink // symlink target does not exist
FlagHardlinked // regular file with link count > 1
FlagDeleted // moved to trash from the UI
)
// Node is one filesystem object. Sizes are own sizes; Tot* fields are
+32
View File
@@ -0,0 +1,32 @@
package platform
import (
"errors"
"os"
"os/exec"
"path/filepath"
)
// startDetached runs a helper program with an argument vector (never a
// shell), detached from the terminal so it cannot disturb the TUI.
func startDetached(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = nil, nil, nil
if err := cmd.Start(); err != nil {
return err
}
go cmd.Wait()
return nil
}
// checkAbs guards helper invocations: an absolute path can never be parsed
// as an option by the helper program.
func checkAbs(path string) error {
if !filepath.IsAbs(path) {
return errors.New("refusing relative path")
}
if _, err := os.Lstat(path); err != nil {
return err
}
return nil
}
+72
View File
@@ -0,0 +1,72 @@
package platform
import (
"errors"
"fmt"
"os"
"path/filepath"
"syscall"
)
// TrashName is the user-facing name of the trash.
const TrashName = "Trash"
// Reveal selects the item in Finder.
func Reveal(path string) error {
if err := checkAbs(path); err != nil {
return err
}
return startDetached("open", "-R", path)
}
// Trash moves path into ~/.Trash (same volume) or the volume's
// .Trashes/<uid> directory. It never copies and never deletes.
func Trash(path string) error {
if err := checkAbs(path); err != nil {
return err
}
home, err := os.UserHomeDir()
if err != nil {
return err
}
dirs := []string{filepath.Join(home, ".Trash")}
if vol := volumeOf(path); vol != "" {
dirs = append(dirs, filepath.Join(vol, ".Trashes", fmt.Sprint(os.Getuid())))
}
base := filepath.Base(path)
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0o700); err != nil {
continue
}
for i := 1; i < 10000; i++ {
name := base
if i > 1 {
name = fmt.Sprintf("%s %d", base, i)
}
dst := filepath.Join(dir, name)
if _, err := os.Lstat(dst); err == nil {
continue
}
err := os.Rename(path, dst)
if errors.Is(err, syscall.EXDEV) {
break // try the next trash location
}
return err
}
}
return errors.New("trash: item is on a volume without a usable trash")
}
func volumeOf(path string) string {
const prefix = "/Volumes/"
if len(path) > len(prefix) && path[:len(prefix)] == prefix {
rest := path[len(prefix):]
for i := 0; i < len(rest); i++ {
if rest[i] == '/' {
return prefix + rest[:i]
}
}
return path
}
return ""
}
+113
View File
@@ -0,0 +1,113 @@
package platform
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"syscall"
"time"
)
// TrashName is the user-facing name of the trash.
const TrashName = "trash"
// Reveal opens the item's directory in the desktop file manager.
func Reveal(path string) error {
if err := checkAbs(path); err != nil {
return err
}
fi, err := os.Lstat(path)
if err == nil && !fi.IsDir() {
path = filepath.Dir(path)
}
return startDetached("xdg-open", path)
}
// Trash moves path to the freedesktop.org trash: the home trash when on the
// same filesystem, otherwise $topdir/.Trash-$uid on the item's filesystem.
// It never copies across filesystems and never deletes.
func Trash(path string) error {
if err := checkAbs(path); err != nil {
return err
}
st, err := os.Lstat(path)
if err != nil {
return err
}
dev := st.Sys().(*syscall.Stat_t).Dev
dataHome := os.Getenv("XDG_DATA_HOME")
if dataHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return err
}
dataHome = filepath.Join(home, ".local", "share")
}
home := filepath.Join(dataHome, "Trash")
var trash, infoPath string
if hs, err := os.Stat(dataHome); err == nil && hs.Sys().(*syscall.Stat_t).Dev == dev {
trash, infoPath = home, path
} else {
top := mountTop(path, dev)
trash = filepath.Join(top, ".Trash-"+strconv.Itoa(os.Getuid()))
rel, err := filepath.Rel(top, path)
if err != nil {
return err
}
infoPath = rel
}
files, info := filepath.Join(trash, "files"), filepath.Join(trash, "info")
if err := os.MkdirAll(files, 0o700); err != nil {
return err
}
if err := os.MkdirAll(info, 0o700); err != nil {
return err
}
base := filepath.Base(path)
for i := 1; i < 10000; i++ {
name := base
if i > 1 {
name = fmt.Sprintf("%s.%d", base, i)
}
ip := filepath.Join(info, name+".trashinfo")
f, err := os.OpenFile(ip, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
continue
}
if err != nil {
return err
}
u := (&url.URL{Path: infoPath}).EscapedPath()
_, werr := fmt.Fprintf(f, "[Trash Info]\nPath=%s\nDeletionDate=%s\n", u, time.Now().Format("2006-01-02T15:04:05"))
cerr := f.Close()
if werr != nil || cerr != nil {
os.Remove(ip)
return errors.Join(werr, cerr)
}
if err := os.Rename(path, filepath.Join(files, name)); err != nil {
os.Remove(ip)
return err
}
return nil
}
return errors.New("trash: too many items with the same name")
}
// mountTop returns the top directory of the filesystem containing path.
func mountTop(path string, dev uint64) string {
cur := filepath.Dir(path)
for {
parent := filepath.Dir(cur)
if parent == cur {
return cur
}
st, err := os.Stat(parent)
if err != nil || st.Sys().(*syscall.Stat_t).Dev != dev {
return cur
}
cur = parent
}
}
+14
View File
@@ -0,0 +1,14 @@
//go:build !linux && !darwin && !windows
package platform
import "errors"
// TrashName is the user-facing name of the trash.
const TrashName = "trash"
// Reveal is unsupported on this platform.
func Reveal(string) error { return errors.New("not supported on this platform") }
// Trash is unsupported on this platform.
func Trash(string) error { return errors.New("trash not supported on this platform") }
+71
View File
@@ -0,0 +1,71 @@
package platform
import (
"errors"
"strconv"
"syscall"
"unsafe"
)
// TrashName is the user-facing name of the trash.
const TrashName = "Recycle Bin"
// Reveal selects the item in Explorer.
func Reveal(path string) error {
if err := checkAbs(path); err != nil {
return err
}
return startDetached("explorer.exe", "/select,", path)
}
var (
shell32 = syscall.NewLazyDLL("shell32.dll")
procSHFileOpertn = shell32.NewProc("SHFileOperationW")
)
// shFileOpStruct mirrors SHFILEOPSTRUCTW (64-bit natural alignment).
type shFileOpStruct struct {
hwnd uintptr
wFunc uint32
pFrom *uint16
pTo *uint16
fFlags uint16
fAnyOperationsAborted int32
hNameMappings uintptr
lpszProgressTitle *uint16
}
const (
foDelete = 0x3
fofSilent = 0x4
fofNoConfirmation = 0x10
fofAllowUndo = 0x40
fofNoErrorUI = 0x400
fofNoConfirmMkdir = 0x200
)
// Trash moves path to the Recycle Bin via SHFileOperationW with
// FOF_ALLOWUNDO. It never deletes permanently.
func Trash(path string) error {
if err := checkAbs(path); err != nil {
return err
}
if unsafe.Sizeof(uintptr(0)) != 8 {
return errors.New("recycle bin support requires a 64-bit build")
}
u, err := syscall.UTF16FromString(path)
if err != nil {
return err
}
u = append(u, 0) // double NUL terminated list
op := shFileOpStruct{wFunc: foDelete, pFrom: &u[0],
fFlags: fofAllowUndo | fofNoConfirmation | fofSilent | fofNoErrorUI | fofNoConfirmMkdir}
r, _, _ := procSHFileOpertn.Call(uintptr(unsafe.Pointer(&op)))
if r != 0 {
return errors.New("SHFileOperation failed: code " + strconv.Itoa(int(r)))
}
if op.fAnyOperationsAborted != 0 {
return errors.New("operation aborted")
}
return nil
}
+14
View File
@@ -0,0 +1,14 @@
package scan
import "runtime/debug"
// OnPanic is called if a scanner goroutine panics. The default re-panics
// (crashing the process); the TUI replaces it so it can restore the terminal
// before reporting. Programmer bugs are never silently swallowed.
var OnPanic = func(v any, stack []byte) { panic(v) }
func guard() {
if v := recover(); v != nil {
OnPanic(v, debug.Stack())
}
}
+2
View File
@@ -203,6 +203,7 @@ type controller struct {
}
func (c *controller) run(ctx context.Context) {
defer guard()
defer close(c.s.done)
jobs := make(chan *job, c.opts.JobBuffer)
results := make(chan result, c.opts.ResultBuf)
@@ -211,6 +212,7 @@ func (c *controller) run(ctx context.Context) {
wg.Add(1)
go func() {
defer wg.Done()
defer guard()
worker(ctx, jobs, results, c.opts.ChunkSize)
}()
}
+56 -21
View File
@@ -7,6 +7,7 @@ import (
"strings"
"unicode/utf8"
"github.com/charmbracelet/x/ansi"
"github.com/rivo/uniseg"
)
@@ -52,8 +53,40 @@ func isBidiControl(r rune) bool {
r == 0x200e || r == 0x200f || r == 0x061c
}
// Clusters calls fn for each grapheme cluster of s with its display width.
// Clusters whose width terminals disagree on (per-rune wcwidth versus
// grapheme width, e.g. emoji ZWJ sequences) are replaced by U+FFFD with
// width 1, and zero-width clusters are dropped, so that measuring and
// drawing always agree with the terminal. fn returns false to stop.
func Clusters(s string, fn func(c string, w int) bool) {
state := -1
for s != "" {
var c string
var w int
c, s, w, state = uniseg.FirstGraphemeClusterInString(s, state)
if w == 0 {
continue
}
if len(c) > 1 && ansi.WcWidth.StringWidth(c) != w {
c, w = "\uFFFD", 1
}
if !fn(c, w) {
return
}
}
}
// Width returns the display width of s in terminal cells.
func Width(s string) int { return uniseg.StringWidth(s) }
func Width(s string) int {
n := 0
for i := 0; i < len(s); i++ {
if s[i] < 0x20 || s[i] >= 0x7f {
Clusters(s, func(_ string, w int) bool { n += w; return true })
return n
}
}
return len(s)
}
// Truncate clips s to at most w cells, appending "…" if anything was cut.
func Truncate(s string, w int) string {
@@ -77,33 +110,35 @@ func TruncateLeft(s string, w int) string {
return s
}
skip := total - (w - 1)
state := -1
rest := s
for rest != "" && skip > 0 {
var cw int
_, rest, cw, state = uniseg.FirstGraphemeClusterInString(rest, state)
skip -= cw
}
// A wide cluster may have overshot by one cell; pad to keep width exact.
return "…" + strings.Repeat(" ", -skip) + rest
var b strings.Builder
Clusters(s, func(c string, cw int) bool {
if skip > 0 {
skip -= cw
if skip < 0 {
// A wide cluster overshot by one cell; pad to keep width exact.
b.WriteString(strings.Repeat(" ", -skip))
}
return true
}
b.WriteString(c)
return true
})
return "…" + b.String()
}
// clip returns the longest prefix of s that fits in w cells.
func clip(s string, w int) string {
state := -1
used, end := 0, 0
rest := s
for rest != "" {
var cluster string
var cw int
cluster, rest, cw, state = uniseg.FirstGraphemeClusterInString(rest, state)
var b strings.Builder
used := 0
Clusters(s, func(c string, cw int) bool {
if used+cw > w {
break
return false
}
used += cw
end += len(cluster)
}
return s[:end]
b.WriteString(c)
return true
})
return b.String()
}
// PadRight pads s with spaces to exactly w cells (truncating if longer).
+10
View File
@@ -62,3 +62,13 @@ func TestSize(t *testing.T) {
t.Errorf("SignedSize got %q", got)
}
}
func TestClustersAgreeWithWidth(t *testing.T) {
for _, s := range []string{"abc", "日本", "é", "👨‍👩‍👧", "🎉x", "​"} {
n := 0
Clusters(s, func(_ string, w int) bool { n += w; return true })
if n != Width(s) {
t.Errorf("%q: clusters %d width %d", s, n, Width(s))
}
}
}
+250
View File
@@ -0,0 +1,250 @@
package tui
import (
"context"
"fmt"
"time"
tea "charm.land/bubbletea/v2"
"github.com/andydixon/mapsize/internal/duplicate"
"github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/platform"
"github.com/andydixon/mapsize/internal/textutil"
"github.com/andydixon/mapsize/internal/treemap"
)
// ---- Trash ---------------------------------------------------------------
type trashDoneMsg struct {
id inventory.NodeID
err error
}
// requestTrash asks for confirmation before moving the selection to the
// trash. Permanent deletion is deliberately not offered.
func (m *Model) requestTrash() tea.Cmd {
switch {
case m.opts.ReadOnly:
return m.warn("Read-only mode: modifying actions are disabled")
case m.snapshot:
return m.warn("Snapshot view: nothing to delete on the live filesystem")
case m.scanning:
return m.warn("Wait for the scan to finish before trashing items")
}
id := m.selectedNode()
if id == inventory.NoNode || id == m.tree.Root() {
return nil
}
n := m.tree.Node(id)
path := m.tree.Path(id)
what := "file"
if n.IsDir() {
what = fmt.Sprintf("directory with %s files", textutil.Count(int64(n.Files)))
}
m.openModal(&confirmModal{
ttl: "Move to trash?",
body: []string{textutil.Sanitize(path), "",
fmt.Sprintf("%s · %s", what, textutil.Size(n.Total(m.sizeMode))),
"", "It will be moved to the " + platform.TrashName + ", not permanently deleted."},
yes: "move to " + platform.TrashName,
onYes: func(m *Model) tea.Cmd {
return func() tea.Msg { return trashDoneMsg{id, platform.Trash(path)} }
},
})
return nil
}
func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd {
if msg.err != nil {
return m.warn("Trash failed: " + textutil.Sanitize(msg.err.Error()))
}
// The scan has finished, so the UI goroutine is the tree's only writer.
t := m.tree
t.Lock()
n := t.Node(msg.id)
n.Flags |= inventory.FlagDeleted
d := inventory.Delta{Size: -n.TotSize, Alloc: -n.TotAlloc, Files: -int64(n.Files), Dirs: -int64(n.Dirs)}
if n.IsDir() {
d.Dirs--
for i, v := range t.CatSizes(msg.id) {
d.Cat[i] = -v
}
} else {
d.Files--
d.Cat[n.Cat] = -n.TotAlloc
}
t.Propagate(n.Parent, d)
n.TotSize, n.TotAlloc = 0, 0
name := n.Name
t.Unlock()
m.invalidate()
return m.info("Moved to " + platform.TrashName + ": " + textutil.Sanitize(name))
}
// ---- Duplicates ----------------------------------------------------------
type dupState struct {
running bool
finder *duplicate.Finder
groups []duplicate.Group
err error
cancel context.CancelFunc
started time.Time
}
type (
dupProgressMsg struct{}
dupDoneMsg struct {
groups []duplicate.Group
err error
}
)
func (m *Model) startDuplicates() tea.Cmd {
if m.snapshot {
return m.warn("Duplicate detection needs the live filesystem")
}
if m.scanning {
return m.warn("Wait for the scan to finish first")
}
if m.dups != nil {
m.ensureDupView()
return nil
}
m.openModal(&confirmModal{
ttl: "Find duplicate files?",
body: []string{
"Files are grouped by size, then sampled, then fully hashed with SHA-256.",
"Only identical hashes are reported as duplicates.",
"", "This reads file contents (it may take a while and can update access times).",
},
yes: "start",
onYes: func(m *Model) tea.Cmd {
ctx, cancel := context.WithCancel(context.Background())
f := &duplicate.Finder{}
m.dups = &dupState{running: true, finder: f, cancel: cancel, started: time.Now()}
m.ensureDupView()
t := m.tree
return tea.Batch(m.startTicking(), func() tea.Msg {
g, err := f.Find(ctx, t, duplicate.Options{MinSize: 1, Workers: 4})
return dupDoneMsg{g, err}
})
},
})
return nil
}
func (m *Model) ensureDupView() {
for i, v := range m.views {
if _, ok := v.(*dupView); ok {
m.setView(i)
return
}
}
m.views = append(m.views, &dupView{})
m.setView(len(m.views) - 1)
}
func (m *Model) dupMsg(msg tea.Msg) tea.Cmd {
if d, ok := msg.(dupDoneMsg); ok && m.dups != nil {
m.dups.running = false
m.dups.groups, m.dups.err = d.groups, d.err
m.dirty = true
var wasted int64
for _, g := range d.groups {
wasted += g.Wasted()
}
return m.info(fmt.Sprintf("Duplicates: %d verified groups, %s reclaimable", len(d.groups), textutil.Size(wasted)))
}
return nil
}
type dupView struct {
tb table
rows []dupRow
}
type dupRow struct {
group int
id inventory.NodeID // NoNode for a group header
}
func (*dupView) name() string { return "Dups" }
func (v *dupView) paint(m *Model, cv *Canvas, r treemap.Rect) {
t := m.theme
d := m.dups
if d == nil {
cv.TextCentered(r.X, r.Y+r.H/2, r.W, "Press D to search for duplicates", t.muted())
return
}
if d.running {
p := d.finder.Progress()
cv.Fill(r, " ", t.base())
y := r.Y + r.H/2 - 2
cv.TextCentered(r.X, y, r.W, "Finding duplicates — "+p.Stage+"…", Style{FG: t.Accent, BG: t.Bg, Attr: Bold})
cv.TextCentered(r.X, y+2, r.W, fmt.Sprintf("%s candidate files in %s size groups", textutil.Count(p.Candidates),
textutil.Count(p.CandidateGroups)), t.base())
if p.BytesToHash > 0 {
frac := float64(p.BytesHashed) / float64(p.BytesToHash)
bw := min(60, r.W-10)
m.paintBar(cv, r.X+(r.W-bw)/2, y+4, bw, frac, t.Accent, false)
cv.TextCentered(r.X, y+5, r.W, fmt.Sprintf("%s of %s hashed · %s skipped", textutil.Size(p.BytesHashed),
textutil.Size(p.BytesToHash), textutil.Count(p.Skipped)), t.muted())
}
return
}
v.rows = v.rows[:0]
var tr []tableRow
for gi, g := range d.groups {
v.rows = append(v.rows, dupRow{gi, inventory.NoNode})
tr = append(tr, tableRow{
cells: []string{textutil.Size(g.Wasted()), fmt.Sprintf("%d × %s", len(g.Files), textutil.Size(g.Size)), fmt.Sprintf("verified identical · sha256 %x…", g.Hash[:8])},
styles: []*Style{{FG: t.Warn, BG: t.Bg, Attr: Bold}, {FG: t.Fg, BG: t.Bg, Attr: Bold}, {FG: t.OK, BG: t.Bg}}, bar: -1,
})
for _, id := range g.Files {
v.rows = append(v.rows, dupRow{gi, id})
tr = append(tr, tableRow{cells: []string{"", "", " " + textutil.Sanitize(m.tree.Path(id))}, bar: -1})
}
}
title := fmt.Sprintf("Duplicates — %d groups (Enter details · Space go to)", len(d.groups))
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)
}
func (v *dupView) 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) {
r := v.rows[v.tb.cursor]
if r.id == inventory.NoNode && m.dups != nil && r.group < len(m.dups.groups) {
r.id = m.dups.groups[r.group].Files[0]
}
if r.id != inventory.NoNode {
switch k {
case "enter":
m.openModal(newInfoModal(m, r.id))
return true, nil
case "space":
m.jumpTo(r.id)
return true, nil
case "c", "o", "d":
m.sel = int64(r.id)
}
}
}
if k == "esc" && m.dups != nil && m.dups.running {
m.dups.cancel()
return true, nil
}
return false, nil
}
func (v *dupView) wheel(m *Model, up bool) tea.Cmd {
v.tb.move(map[bool]int{true: -3, false: 3}[up], len(v.rows))
return nil
}
+303
View File
@@ -0,0 +1,303 @@
package tui
import (
"strconv"
"strings"
"github.com/andydixon/mapsize/internal/textutil"
"github.com/andydixon/mapsize/internal/treemap"
)
// Color is 0 for the terminal default, otherwise 0x01RRGGBB.
type Color uint32
// RGB builds a colour.
func RGB(r, g, b uint8) Color { return Color(1<<24 | uint32(r)<<16 | uint32(g)<<8 | uint32(b)) }
// Hex parses "#rrggbb".
func Hex(s string) (Color, bool) {
s = strings.TrimPrefix(s, "#")
if len(s) != 6 {
return 0, false
}
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return 0, false
}
return Color(1<<24 | uint32(v)), true
}
func (c Color) rgb() (r, g, b float64) {
return float64(c>>16&0xff) / 255, float64(c>>8&0xff) / 255, float64(c&0xff) / 255
}
// Mix blends c towards o by t (0..1). Default colours are returned as-is.
func (c Color) Mix(o Color, t float64) Color {
if c == 0 || o == 0 {
return c
}
r1, g1, b1 := c.rgb()
r2, g2, b2 := o.rgb()
f := func(a, b float64) uint8 { return uint8((a + (b-a)*t) * 255) }
return RGB(f(r1, r2), f(g1, g2), f(b1, b2))
}
// Luma returns perceived brightness 0..1.
func (c Color) Luma() float64 {
r, g, b := c.rgb()
return 0.299*r + 0.587*g + 0.114*b
}
// Attr is a text attribute bit set.
type Attr uint8
const (
Bold Attr = 1 << iota
Faint
Italic
Underline
Reverse
)
// Style is a cell style.
type Style struct {
FG, BG Color
Attr Attr
}
type cell struct {
s string // "" marks the continuation of a wide cluster
st Style
}
// Canvas is an in-memory cell grid that every view paints into. It is
// serialised once per frame.
type Canvas struct {
W, H int
c []cell
}
// NewCanvas creates a canvas filled with spaces in style st.
func NewCanvas(w, h int, st Style) *Canvas {
cv := &Canvas{W: max(w, 0), H: max(h, 0)}
cv.c = make([]cell, cv.W*cv.H)
for i := range cv.c {
cv.c[i] = cell{" ", st}
}
return cv
}
func (cv *Canvas) in(x, y int) bool { return x >= 0 && y >= 0 && x < cv.W && y < cv.H }
// put writes one cluster of width w at (x, y), repairing any wide cluster it
// overlaps so the grid never holds half a character.
func (cv *Canvas) put(x, y int, s string, w int, st Style) {
if !cv.in(x, y) || x+w > cv.W {
return
}
row := y * cv.W
for i := x; i < x+w; i++ {
c := &cv.c[row+i]
if c.s == "" && i > 0 && i == x { // overwriting the tail of a wide cluster
cv.c[row+i-1].s = " "
}
if c.s != "" && textutil.Width(c.s) > 1 && i+1 < cv.W { // overwriting its head
if n := &cv.c[row+i+1]; n.s == "" {
n.s = " "
}
}
}
cv.c[row+x] = cell{s, st}
for i := 1; i < w; i++ {
cv.c[row+x+i] = cell{"", st}
}
}
// Text draws s at (x, y), clipped to maxW cells and the canvas. Control
// characters are replaced as a second line of defence (callers sanitise
// untrusted strings first). It returns the number of cells used.
func (cv *Canvas) Text(x, y int, s string, maxW int, st Style) int {
if y < 0 || y >= cv.H {
return 0
}
limit := min(x+maxW, cv.W)
cx := x
textutil.Clusters(s, func(c string, w int) bool {
if r := c[0]; r < 0x20 || r == 0x7f || (len(c) > 1 && c[0] == 0xc2 && c[1] < 0xa0) {
c, w = "?", 1
}
if cx+w > limit {
// A wide cluster that does not fit leaves a blank cell.
for ; cx < limit; cx++ {
if cx >= 0 {
cv.put(cx, y, " ", 1, st)
}
}
return false
}
if cx >= 0 {
cv.put(cx, y, c, w, st)
}
cx += w
return true
})
return cx - x
}
// TextCentered draws s centred within [x, x+w).
func (cv *Canvas) TextCentered(x, y, w int, s string, st Style) {
s = textutil.Truncate(s, w)
cv.Text(x+(w-textutil.Width(s))/2, y, s, w, st)
}
// TextRight draws s right-aligned ending at x+w.
func (cv *Canvas) TextRight(x, y, w int, s string, st Style) {
s = textutil.Truncate(s, w)
cv.Text(x+w-textutil.Width(s), y, s, w, st)
}
// Fill fills r with the cluster ch.
func (cv *Canvas) Fill(r treemap.Rect, ch string, st Style) {
for y := max(r.Y, 0); y < min(r.Y+r.H, cv.H); y++ {
for x := max(r.X, 0); x < min(r.X+r.W, cv.W); x++ {
cv.put(x, y, ch, 1, st)
}
}
}
// Restyle applies fn to the style of every cell in r.
func (cv *Canvas) Restyle(r treemap.Rect, fn func(Style) Style) {
for y := max(r.Y, 0); y < min(r.Y+r.H, cv.H); y++ {
for x := max(r.X, 0); x < min(r.X+r.W, cv.W); x++ {
c := &cv.c[y*cv.W+x]
c.st = fn(c.st)
}
}
}
// Box glyph sets.
type BoxChars struct{ H, V, TL, TR, BL, BR string }
var (
BoxLight = BoxChars{"─", "│", "┌", "┐", "└", "┘"}
BoxRound = BoxChars{"─", "│", "╭", "╮", "╰", "╯"}
BoxDouble = BoxChars{"═", "║", "╔", "╗", "╚", "╝"}
BoxHeavy = BoxChars{"━", "┃", "┏", "┓", "┗", "┛"}
BoxASCII = BoxChars{"-", "|", "+", "+", "+", "+"}
BoxASCIISel = BoxChars{"=", "#", "#", "#", "#", "#"}
)
// Box draws a border around r (which must be at least 2×2).
func (cv *Canvas) Box(r treemap.Rect, b BoxChars, st Style) {
if r.W < 2 || r.H < 2 {
return
}
x1, y1 := r.X+r.W-1, r.Y+r.H-1
for x := r.X + 1; x < x1; x++ {
cv.put(x, r.Y, b.H, 1, st)
cv.put(x, y1, b.H, 1, st)
}
for y := r.Y + 1; y < y1; y++ {
cv.put(r.X, y, b.V, 1, st)
cv.put(x1, y, b.V, 1, st)
}
cv.put(r.X, r.Y, b.TL, 1, st)
cv.put(x1, r.Y, b.TR, 1, st)
cv.put(r.X, y1, b.BL, 1, st)
cv.put(x1, y1, b.BR, 1, st)
}
// HLine draws a horizontal rule.
func (cv *Canvas) HLine(x, y, w int, ch string, st Style) {
for i := x; i < x+w; i++ {
cv.put(i, y, ch, 1, st)
}
}
// Cell returns the text and style at (x, y) (for tests).
func (cv *Canvas) Cell(x, y int) (string, Style) {
if !cv.in(x, y) {
return "", Style{}
}
c := cv.c[y*cv.W+x]
return c.s, c.st
}
// Line returns the plain text of row y (for tests).
func (cv *Canvas) Line(y int) string {
var b strings.Builder
for x := 0; x < cv.W; x++ {
b.WriteString(cv.c[y*cv.W+x].s)
}
return b.String()
}
func sgr(b *strings.Builder, st Style, p256 bool) {
b.WriteString("\x1b[0")
if st.Attr&Bold != 0 {
b.WriteString(";1")
}
if st.Attr&Faint != 0 {
b.WriteString(";2")
}
if st.Attr&Italic != 0 {
b.WriteString(";3")
}
if st.Attr&Underline != 0 {
b.WriteString(";4")
}
if st.Attr&Reverse != 0 {
b.WriteString(";7")
}
col := func(c Color, base string) {
if c == 0 {
return
}
if p256 {
b.WriteString(";" + base + ";5;")
b.WriteString(strconv.Itoa(int(quant256(c))))
return
}
b.WriteString(";" + base + ";2;")
b.WriteString(strconv.Itoa(int(c >> 16 & 0xff)))
b.WriteByte(';')
b.WriteString(strconv.Itoa(int(c >> 8 & 0xff)))
b.WriteByte(';')
b.WriteString(strconv.Itoa(int(c & 0xff)))
}
col(st.FG, "38")
col(st.BG, "48")
b.WriteByte('m')
}
// String serialises the canvas as lines of text with SGR styling. Colours
// are emitted as 24-bit, or as 256-colour indices from our own quantizer
// when p256 is set; Bubble Tea downsamples further (16 colours, none) to
// the terminal's profile.
func (cv *Canvas) String() string { return cv.Render(false) }
// Render serialises the canvas; see String.
func (cv *Canvas) Render(p256 bool) string {
var b strings.Builder
b.Grow(cv.W * cv.H * 4)
for y := 0; y < cv.H; y++ {
if y > 0 {
b.WriteByte('\n')
}
var cur Style
first := true
for x := 0; x < cv.W; x++ {
c := cv.c[y*cv.W+x]
if c.s == "" {
continue
}
if first || c.st != cur {
sgr(&b, c.st, p256)
cur, first = c.st, false
}
b.WriteString(c.s)
}
b.WriteString("\x1b[0m")
}
return b.String()
}
+42
View File
@@ -0,0 +1,42 @@
package tui
import "sync"
// The stock 256-colour conversion maps dark, slightly tinted colours to
// saturated cube entries (olive, navy), which wrecks the subtle shading the
// treemap relies on. quant256 picks the perceptually nearest entry (redmean
// distance) among the cube and the grey ramp, never the palette-dependent
// first 16 entries.
var (
cubeLevels = [6]int{0, 95, 135, 175, 215, 255}
q256Cache sync.Map // Color -> uint8
)
func redmean(r1, g1, b1, r2, g2, b2 int) int {
rm := (r1 + r2) / 2
dr, dg, db := r1-r2, g1-g2, b1-b2
return ((512+rm)*dr*dr)>>8 + 4*dg*dg + ((767-rm)*db*db)>>8
}
func quant256(c Color) uint8 {
if v, ok := q256Cache.Load(c); ok {
return v.(uint8)
}
r, g, b := int(c>>16&0xff), int(c>>8&0xff), int(c&0xff)
best, bestD := 16, 1<<62
for i := 0; i < 216; i++ {
cr, cg, cb := cubeLevels[i/36], cubeLevels[i/6%6], cubeLevels[i%6]
if d := redmean(r, g, b, cr, cg, cb); d < bestD {
best, bestD = 16+i, d
}
}
for i := 0; i < 24; i++ {
v := 8 + 10*i
if d := redmean(r, g, b, v, v, v); d < bestD {
best, bestD = 232+i, d
}
}
q256Cache.Store(c, uint8(best))
return uint8(best)
}
+199
View File
@@ -0,0 +1,199 @@
package tui
import (
"sort"
"github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/snapshot"
)
// kids is a cached, size-sorted child list (zero-size entries removed).
type kids struct {
ids []inventory.NodeID
sizes []int64
total int64
}
// sizeOf returns the size that drives layout: the filtered size when a
// filter is active, otherwise the aggregate in the current size mode.
// Callers hold the tree read lock.
func (m *Model) sizeOf(id inventory.NodeID) int64 {
if r := m.search.result; r != nil {
return r.Size(id)
}
n := m.tree.Node(id)
if n.Flags&inventory.FlagDeleted != 0 {
return 0
}
return n.Total(m.sizeMode)
}
// kidsOf returns the sorted children of id, cached until data changes.
func (m *Model) kidsOf(id inventory.NodeID) *kids {
if m.kidsVer != m.dataVer {
clear(m.kidsCache)
m.kidsVer = m.dataVer
}
if k, ok := m.kidsCache[id]; ok {
return k
}
k := &kids{}
m.tree.Children(id, func(c inventory.NodeID, _ *inventory.Node) {
if s := m.sizeOf(c); s > 0 {
k.ids = append(k.ids, c)
k.sizes = append(k.sizes, s)
k.total += s
}
})
sort.Sort(byKidSize{k})
m.kidsCache[id] = k
return k
}
type byKidSize struct{ *kids }
func (b byKidSize) Len() int { return len(b.ids) }
func (b byKidSize) Less(i, j int) bool {
if b.sizes[i] != b.sizes[j] {
return b.sizes[i] > b.sizes[j]
}
return b.ids[i] < b.ids[j]
}
func (b byKidSize) Swap(i, j int) {
b.ids[i], b.ids[j] = b.ids[j], b.ids[i]
b.sizes[i], b.sizes[j] = b.sizes[j], b.sizes[i]
}
// selectedNode returns the selected inventory node (NoNode for groups).
func (m *Model) selectedNode() inventory.NodeID {
if m.sel == noSel || isGroup(m.sel) || m.sel >= int64(m.tree.Len()) {
return inventory.NoNode
}
return inventory.NodeID(m.sel)
}
// ensureSelection keeps the selection meaningful after any change: if the
// selected node is no longer a direct, visible child of the zoom root it
// falls back to the visible ancestor, then the group containing it, then
// the largest child. Tree read lock held.
func (m *Model) ensureSelection() {
k := m.kidsOf(m.zoom)
if len(k.ids) == 0 {
m.sel = noSel
return
}
if !m.userSel {
// Until the user picks something, follow the largest block (it
// changes while a scan is running).
m.sel = int64(k.ids[0])
return
}
if isGroup(m.sel) {
if groupParent(m.sel) == m.zoom {
return
}
m.sel = noSel
}
id := m.selectedNode()
if id != inventory.NoNode {
// Walk up to the direct child of the zoom root.
for cur := id; cur != inventory.NoNode; cur = m.tree.Node(cur).Parent {
if m.tree.Node(cur).Parent == m.zoom {
if m.sizeOf(cur) > 0 {
m.sel = int64(cur)
return
}
break
}
}
}
m.sel = int64(k.ids[0])
}
// visibleSel maps the selection onto the treemap blocks: a node collapsed
// into the LOD group selects the group.
func (m *Model) visibleSel(blocks map[int64]bool) int64 {
if blocks[m.sel] {
return m.sel
}
if !isGroup(m.sel) && blocks[groupID(m.zoom)] {
return groupID(m.zoom)
}
return m.sel
}
// zoomTo makes dir the treemap root.
func (m *Model) zoomTo(dir inventory.NodeID) {
if dir == m.zoom {
return
}
prev := m.zoom
m.zoom = dir
// Coming back out, keep the directory we left selected.
if m.tree.IsAncestor(dir, prev) {
m.sel = int64(prev)
} else {
m.sel = noSel
}
m.userSel = m.sel != noSel
m.zoomed = true
m.dirty = true
}
func (m *Model) setView(i int) {
if i >= 0 && i < len(m.views) {
m.view = i
m.dirty = true
}
}
// colorOf returns the base colour for a block.
func (m *Model) colorOf(id int64) Color {
t := m.theme
if isGroup(id) {
return t.Group
}
nid := inventory.NodeID(id)
n := m.tree.Node(nid)
if d := m.opts.Diff; d != nil {
switch d.StatusOf(nid, m.sizeMode) {
case snapshot.Added:
return t.DiffNew
case snapshot.Grew:
old, _ := d.OldSize(nid, m.sizeMode)
return t.DiffSame.Mix(t.DiffGrow, ratio(n.Total(m.sizeMode)-old, old))
case snapshot.Shrank:
old, _ := d.OldSize(nid, m.sizeMode)
return t.DiffSame.Mix(t.DiffShrink, ratio(old-n.Total(m.sizeMode), old))
}
return t.DiffSame
}
if !n.IsDir() {
if t.Ext != nil {
if c, ok := t.Ext[m.tree.ExtName(n)]; ok {
return c
}
}
return t.Cat[n.Cat]
}
cs := m.tree.CatSizes(nid)
best, bestV := -1, int64(0)
for i, v := range cs {
if v > bestV {
best, bestV = i, v
}
}
if best < 0 {
return t.EmptyDir
}
return t.Cat[best]
}
// ratio maps a relative change to 0.35..1 for colour intensity.
func ratio(delta, base int64) float64 {
if base <= 0 {
return 1
}
r := float64(delta) / float64(base)
return min(1, 0.35+r*0.65)
}
+159
View File
@@ -0,0 +1,159 @@
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(e.Msg), textutil.Sanitize(p)},
styles: []*Style{{FG: t.Err, BG: t.Bg}, {FG: t.Muted, BG: t.Bg}, nil}, 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)
}
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
}
+247
View File
@@ -0,0 +1,247 @@
package tui
import (
"fmt"
"os"
"path/filepath"
"time"
tea "charm.land/bubbletea/v2"
"github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/platform"
"github.com/andydixon/mapsize/internal/textutil"
"github.com/andydixon/mapsize/internal/treemap"
)
// view is one of the Tab-cycled main views.
type view interface {
name() string
paint(m *Model, cv *Canvas, r treemap.Rect)
key(m *Model, k string) (bool, tea.Cmd)
wheel(m *Model, up bool) tea.Cmd
}
func keyName(k tea.KeyPressMsg) string {
switch s := k.String(); s {
case " ":
return "space"
default:
return s
}
}
// handleKey dispatches a key press. It holds the tree read lock for the
// whole handler; helpers it calls must not lock again (a recursive RLock
// can deadlock against a waiting writer).
func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {
k := keyName(msg)
if k == "ctrl+c" {
return m.ctrlCPressed()
}
t := m.tree
t.RLock()
defer t.RUnlock()
m.ensureSelection()
if len(m.modals) > 0 {
return m.modalKey(msg)
}
if m.search.editing {
return m.searchKey(msg)
}
handled, cmd := m.views[m.view].key(m, k)
if handled {
return cmd
}
switch k {
case "q":
m.quit = true
case "?", "f1":
m.openModal(newHelpModal(m))
case "/":
m.search.begin()
case "esc":
if m.search.query != nil {
m.clearFilter()
} else if m.zoom != m.tree.Root() {
m.zoomOut()
}
case "tab":
m.setView((m.view + 1) % len(m.views))
case "shift+tab":
m.setView((m.view + len(m.views) - 1) % len(m.views))
case "1", "2", "3", "4", "5", "6", "7":
m.setView(int(k[0] - '1'))
case "enter":
return m.inspectSelection()
case "space", "right":
return m.zoomIntoSelection()
case "backspace", "left":
m.zoomOut()
case "home":
m.zoomTo(m.tree.Root())
case "a":
if m.sizeMode == inventory.SizeAllocated {
m.sizeMode = inventory.SizeLogical
} else {
m.sizeMode = inventory.SizeAllocated
}
m.invalidate()
var cmds []tea.Cmd
if m.search.query != nil {
cmds = append(cmds, m.applyFilter())
}
cmds = append(cmds, m.info("Sizing by "+m.sizeMode.String()+" size"))
return tea.Batch(cmds...)
case "r":
return m.rescan()
case "s":
m.openModal(newSaveModal(m))
case "e":
m.setViewByName("Scan")
if v, ok := m.views[m.view].(*infoView); ok {
v.focusErrors(m)
}
case "x":
m.setViewByName("Types")
case "g":
m.setViewByName("Top")
case "D":
return m.startDuplicates()
case "c":
return m.copyPath()
case "o":
return m.reveal()
case "d", "delete":
return m.requestTrash()
case "T":
names := ThemeNames()
cur := 0
for i, n := range names {
if n == m.theme.Name {
cur = i
}
}
ascii := m.theme.ASCII
m.theme = LoadTheme(names[(cur+1)%len(names)], m.opts.Settings)
m.theme.ASCII = ascii
return m.info("Theme: " + m.theme.Name)
}
return nil
}
func (m *Model) setViewByName(n string) {
for i, v := range m.views {
if v.name() == n {
m.setView(i)
}
}
}
func (m *Model) inspectSelection() tea.Cmd {
if isGroup(m.sel) {
if g, ok := m.groupInfo(m.sel); ok {
m.openModal(newGroupModal(m, g))
}
return nil
}
if id := m.selectedNode(); id != inventory.NoNode {
m.openModal(newInfoModal(m, id))
}
return nil
}
func (m *Model) zoomIntoSelection() tea.Cmd {
if isGroup(m.sel) {
// Groups cannot be zoomed: show their members in the list view.
if g, ok := m.groupInfo(m.sel); ok && len(g.ids) > 0 {
m.sel = int64(g.ids[0])
m.setViewByName("List")
}
return nil
}
id := m.selectedNode()
if id == inventory.NoNode {
return nil
}
if !m.tree.Node(id).IsDir() {
return m.info("Not a directory — Enter shows details")
}
if len(m.kidsOf(id).ids) == 0 {
return m.info("Directory is empty")
}
m.zoomTo(id)
return nil
}
func (m *Model) zoomOut() {
if p := m.tree.Node(m.zoom).Parent; p != inventory.NoNode {
m.zoomTo(p)
}
}
// jumpTo zooms to a node's parent directory and selects it.
func (m *Model) jumpTo(id inventory.NodeID) {
p := m.tree.Node(id).Parent
if p == inventory.NoNode {
m.zoomTo(id)
return
}
m.zoomTo(p)
m.sel = int64(id)
m.setViewByName("Map")
}
func (m *Model) selectedPath() (string, bool) {
id := m.selectedNode()
if id == inventory.NoNode {
return "", false
}
return m.tree.Path(id), true
}
func (m *Model) copyPath() tea.Cmd {
p, ok := m.selectedPath()
if !ok {
return nil
}
return tea.Batch(tea.SetClipboard(p), m.info("Copied path to clipboard (OSC 52)"))
}
func (m *Model) reveal() tea.Cmd {
if m.snapshot {
return m.warn("Snapshot — the files may not exist on this machine")
}
p, ok := m.selectedPath()
if !ok {
return nil
}
if err := platform.Reveal(p); err != nil {
return m.warn("Open failed: " + textutil.Sanitize(err.Error()))
}
return m.info("Opened in file manager")
}
func (m *Model) saveSnapshot(path string) tea.Cmd {
t := m.tree
return func() tea.Msg {
t.RLock()
err := saveFile(path, t)
t.RUnlock()
return saveDoneMsg{path, err}
}
}
type saveDoneMsg struct {
path string
err error
}
func defaultSnapshotName(root string) string {
base := filepath.Base(root)
if base == string(filepath.Separator) || base == "." || base == "" {
base = "root"
}
wd, _ := os.Getwd()
return filepath.Join(wd, fmt.Sprintf("%s-%s.msz", base, time.Now().Format("20060102-1504")))
}
+71
View File
@@ -0,0 +1,71 @@
package tui
import (
"context"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/scan"
)
// TestLiveScanRendering renders, navigates, filters and resizes while the
// scanner mutates the inventory. Run with -race to verify the locking
// discipline.
func TestLiveScanRendering(t *testing.T) {
root := t.TempDir()
for i := range 30 {
d := filepath.Join(root, fmt.Sprintf("d%02d", i), "sub")
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
for j := range 40 {
os.WriteFile(filepath.Join(d, fmt.Sprintf("f%d.log", j)), make([]byte, (i+1)*(j+1)*100), 0o644)
}
}
start := func(ctx context.Context) (*scan.Scanner, *inventory.Tree, error) {
s, err := scan.Start(ctx, root, scan.Options{Workers: 4, ChunkSize: 3})
if err != nil {
return nil, nil, err
}
return s, s.Tree, nil
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, tr, err := start(ctx)
if err != nil {
t.Fatal(err)
}
m := newModel(Options{Start: start}, s, tr, cancel)
sizes := [][2]int{{120, 40}, {80, 24}, {200, 60}, {45, 12}}
deadline := time.After(20 * time.Second)
for i := 0; ; i++ {
send(m, size(sizes[i%len(sizes)][0], sizes[i%len(sizes)][1]), tickMsg(time.Now()))
send(m, key([]string{"right", "down", "left", "up"}[i%4]))
if i%5 == 0 {
send(m, key("tab"))
}
if i == 3 {
m.search.ed.set("ext = log")
m.queryChanged()
send(m, m.applyFilter()())
}
m.dirty = true
m.View()
select {
case <-s.Done():
send(m, scanDoneMsg{s})
m.View()
if m.scanning {
t.Fatal("scan should be finished")
}
return
case <-deadline:
t.Fatal("scan did not finish")
default:
}
}
}
+354
View File
@@ -0,0 +1,354 @@
package tui
import (
tea "charm.land/bubbletea/v2"
"github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/textutil"
"github.com/andydixon/mapsize/internal/treemap"
)
// tmLayout is the treemap geometry for one (zoom, rect, data version). It is
// recomputed from scratch whenever any of those change — after a resize the
// layout algorithm runs again for the new rectangle rather than scaling the
// old one.
type tmLayout struct {
zoom inventory.NodeID
rect treemap.Rect
ver int
blocks []treemap.Block // top level: navigable
nested []nested // previews inside directories: drawn only
groups map[int64]*group
visible map[int64]bool
inner map[int64]bool // top-level blocks with nested previews
}
type nested struct {
id int64
r treemap.Rect
depth int
alt bool
inner bool // has nested children drawn inside it
}
type group struct {
parent inventory.NodeID
ids []inventory.NodeID
size int64
}
// LOD parameters.
const (
topMinArea = 12 // cells: room for a 4×3 framed block
topMaxItems = 400
nestMinArea = 3
nestMaxItems = 120
maxNestDepth = 3
nestMinW = 4
nestMinH = 2
nestRecurseW = 14
nestRecurseH = 5
)
func (m *Model) layoutTreemap(r treemap.Rect) *tmLayout {
if l := m.tm; l != nil && l.zoom == m.zoom && l.rect == r && l.ver == m.dataVer {
return l
}
l := &tmLayout{zoom: m.zoom, rect: r, ver: m.dataVer, groups: map[int64]*group{}, visible: map[int64]bool{}, inner: map[int64]bool{}}
l.blocks = m.layoutLevel(l, m.zoom, r, topMinArea, topMaxItems)
for _, b := range l.blocks {
l.visible[b.ID] = true
if b.ID < 0 || b.W < 3 || b.H < 2 {
continue
}
if n := m.tree.Node(inventory.NodeID(b.ID)); n.IsDir() {
in := b.Inset(1)
if in.W >= nestMinW && in.H >= nestMinH {
before := len(l.nested)
m.layoutNested(l, inventory.NodeID(b.ID), in, 2)
l.inner[b.ID] = len(l.nested) > before
}
}
}
m.tm = l
return l
}
// layoutLevel lays out the children of parent in r with level-of-detail
// grouping: children too small to be useful collapse into one group block.
func (m *Model) layoutLevel(l *tmLayout, parent inventory.NodeID, r treemap.Rect, minArea, maxItems int) []treemap.Block {
k := m.kidsOf(parent)
if len(k.ids) == 0 || r.Empty() {
return nil
}
sizes := make([]float64, len(k.sizes))
for i, s := range k.sizes {
sizes[i] = float64(s)
}
vis := treemap.Visible(sizes, float64(k.total), r.Area(), minArea, maxItems)
items := make([]treemap.Item, 0, vis+1)
for i := range vis {
items = append(items, treemap.Item{ID: int64(k.ids[i]), Size: sizes[i]})
}
if vis < len(k.ids) {
g := &group{parent: parent, ids: k.ids[vis:]}
for _, s := range k.sizes[vis:] {
g.size += s
}
gid := groupID(parent)
l.groups[gid] = g
items = append(items, treemap.Item{ID: gid, Size: float64(g.size)})
}
return treemap.Squarify(items, r)
}
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.
blocks := m.layoutLevel(l, parent, r, nestMinArea*(depth-1)*(depth-1), nestMaxItems)
for i, b := range blocks {
nb := nested{id: b.ID, r: b.Rect, depth: depth, alt: i%2 == 1}
idx := len(l.nested)
l.nested = append(l.nested, nb)
if depth < maxNestDepth && b.ID >= 0 && b.W >= nestRecurseW && b.H >= nestRecurseH &&
m.tree.Node(inventory.NodeID(b.ID)).IsDir() {
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)
}
}
}
// groupInfo returns the LOD group for a group ID in the current layout.
func (m *Model) groupInfo(id int64) (*group, bool) {
if m.tm == nil {
return nil, false
}
g, ok := m.tm.groups[id]
return g, ok
}
type mapView struct{}
func (*mapView) name() string { return "Map" }
func (v *mapView) paint(m *Model, cv *Canvas, r treemap.Rect) {
t := m.theme
l := m.layoutTreemap(r)
if len(l.blocks) == 0 {
msg := "Empty directory"
switch {
case m.search.query != nil:
msg = "No matches here — Esc clears the filter"
case m.scanning:
msg = "Scanning…"
case !m.tree.Node(m.zoom).IsDir():
msg = textutil.Sanitize(m.tree.Node(m.zoom).Name) + " — " + textutil.Size(m.sizeOf(m.zoom))
}
cv.TextCentered(r.X, r.Y+r.H/2, r.W, msg, t.muted())
return
}
sel := m.visibleSel(l.visible)
total := m.sizeOf(m.zoom)
for _, b := range l.blocks {
m.paintTop(cv, b, b.ID == sel, b.ID == m.hover, total)
}
for _, nb := range l.nested {
m.paintNested(cv, nb)
}
for _, b := range l.blocks {
id := b.ID
m.hits = append(m.hits, hit{b.Rect, func(m *Model, double bool) tea.Cmd { return m.clickBlock(id, double) }})
}
}
// blockName returns a sanitised display name for a block.
func (m *Model) blockName(id int64) string {
if isGroup(id) {
if g, ok := m.groupInfo(id); ok {
return textutil.Count(int64(len(g.ids))) + " smaller items"
}
return "smaller items"
}
n := m.tree.Node(inventory.NodeID(id))
name := textutil.Sanitize(n.Name)
if n.IsDir() && inventory.NodeID(id) != m.tree.Root() {
name += "/"
}
return name
}
func (m *Model) blockSize(id int64) int64 {
if isGroup(id) {
if g, ok := m.groupInfo(id); ok {
return g.size
}
return 0
}
return m.sizeOf(inventory.NodeID(id))
}
func (m *Model) paintTop(cv *Canvas, b treemap.Block, sel, hov bool, total int64) {
t := m.theme
base := m.colorOf(b.ID)
fill := base.Mix(t.Bg, 0.62)
fillCh := " "
if isGroup(b.ID) {
fillCh = "░"
if t.ASCII {
fillCh = "."
}
}
name := m.blockName(b.ID)
size := m.blockSize(b.ID)
if b.W < 3 || b.H < 2 {
// Too small for a frame: a solid tile.
st := Style{FG: base, BG: base.Mix(t.Bg, 0.3)}
ch := fillCh
if sel {
st = Style{FG: t.textOn(t.Sel), BG: t.Sel, Attr: Bold | m.monoRev()}
ch = "◆"
if t.ASCII {
ch = "#"
}
} else if t.Mono {
ch = "▒"
}
cv.Fill(b.Rect, " ", st)
cv.Text(b.X+(b.W-1)/2, b.Y+(b.H-1)/2, ch, 1, st)
return
}
cv.Fill(b.Rect, fillCh, Style{FG: base.Mix(t.Bg, 0.35), BG: fill})
frame := Style{FG: base.Mix(t.Bg, 0.15), BG: fill}
box := t.box()
title := Style{FG: base.Mix(RGB(255, 255, 255), 0.35), BG: fill, Attr: Bold}
switch {
case sel:
box = t.selBox()
frame = Style{FG: t.Sel, BG: fill, Attr: Bold}
title = Style{FG: t.textOn(t.Sel), BG: t.Sel, Attr: Bold | m.monoRev()}
case hov:
frame.FG = base.Mix(RGB(255, 255, 255), 0.5)
}
if t.Mono && !sel {
title.Attr = Bold
}
cv.Box(b.Rect, box, frame)
cv.Text(b.X+1, b.Y, textutil.Truncate(" "+name+" ", b.W-2), b.W-2, title)
label := textutil.Size(size)
if pct := textutil.Percent(size, total); b.W-2 >= textutil.Width(label)+textutil.Width(pct)+5 {
label += " · " + pct
} else if b.W-2 < textutil.Width(label)+2 {
label = textutil.SizeCompact(size)
}
label = " " + label + " "
if b.H >= 3 && textutil.Width(label) <= b.W-2 {
cv.TextRight(b.X+1, b.Y+b.H-1, b.W-2, label, Style{FG: frame.FG, BG: fill, Attr: frame.Attr})
}
// Interior labels for blocks without nested previews.
in := b.Inset(1)
if in.Empty() || m.hasNested(b.ID) {
return
}
n := inventory.NoNode
if !isGroup(b.ID) {
n = inventory.NodeID(b.ID)
}
lines := []string{}
if n != inventory.NoNode {
nd := m.tree.Node(n)
if !nd.IsDir() {
lines = append(lines, kindLabel(m.tree, nd))
} else if nd.Files > 0 {
lines = append(lines, textutil.Count(int64(nd.Files))+" files")
}
}
if b.H < 4 || in.W < 6 {
return
}
y0 := in.Y + (in.H-len(lines))/2
for i, s := range lines {
cv.TextCentered(in.X, y0+i, in.W, s, Style{FG: base.Mix(t.Fg, 0.4), BG: fill})
}
}
func (m *Model) hasNested(id int64) bool { return m.tm != nil && m.tm.inner[id] }
func (m *Model) paintNested(cv *Canvas, nb nested) {
t := m.theme
base := m.colorOf(nb.id)
f := 0.38 - 0.1*float64(nb.depth-2)
if nb.alt {
f += 0.07
}
bg := base.Mix(t.Bg, f)
shadow := bg.Mix(RGB(0, 0, 0), 0.45)
st := Style{FG: t.textOn(bg), BG: bg}
ch := " "
if isGroup(nb.id) {
ch = "░"
if t.ASCII {
ch = "."
}
st.FG = bg.Mix(t.Fg, 0.25)
}
r := nb.r
cv.Fill(r, ch, st)
if t.Mono {
// No colour: draw a light outline so neighbours stay distinguishable.
if r.W >= 2 && r.H >= 2 {
cv.Box(r, BoxRound, Style{Attr: Faint})
}
} else {
if r.W >= 4 {
cv.Fill(treemap.Rect{X: r.X + r.W - 1, Y: r.Y, W: 1, H: r.H}, " ", Style{BG: shadow})
}
if r.H >= 3 {
cv.Fill(treemap.Rect{X: r.X, Y: r.Y + r.H - 1, W: r.W, H: 1}, " ", Style{BG: shadow})
}
}
lw := r.W - 1
if t.Mono {
lw = r.W - 2
}
if lw < 5 {
return
}
lx := r.X
if t.Mono {
lx++
}
ly := r.Y
if t.Mono && r.H >= 3 {
ly++
}
name := m.blockName(nb.id)
if textutil.Width(name) > lw && lw < 8 {
return // a stub like "te…" is noise, not information
}
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 {
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})
}
}
func (v *mapView) key(m *Model, k string) (bool, tea.Cmd) {
dirs := map[string]treemap.Direction{"left": treemap.Left, "right": treemap.Right, "up": treemap.Up, "down": treemap.Down,
"h": treemap.Left, "l": treemap.Right, "k": treemap.Up, "j": treemap.Down}
if d, ok := dirs[k]; ok {
if l := m.tm; l != nil {
if id, ok := treemap.Neighbour(l.blocks, m.visibleSel(l.visible), d); ok {
m.sel = id
}
}
return true, nil
}
return false, nil
}
func (v *mapView) wheel(m *Model, up bool) tea.Cmd {
if up {
return m.zoomIntoSelection()
}
m.zoomOut()
return nil
}
+221
View File
@@ -0,0 +1,221 @@
package tui
import (
"strings"
tea "charm.land/bubbletea/v2"
"github.com/andydixon/mapsize/internal/textutil"
"github.com/andydixon/mapsize/internal/treemap"
)
// span is a run of styled text; a line is a slice of spans.
type span struct {
s string
st *Style // nil = modal default
}
type line []span
func txt(s string) line { return line{{s, nil}} }
func styled(s string, st Style) line { return line{{s, &st}} }
func kv(k, v string, kst, vst Style) line { return line{{textutil.PadRight(k, 18), &kst}, {v, &vst}} }
// modal is an overlay. Modals receive all keys while open; Esc always
// closes them (handled by the frame before the modal sees it, unless the
// modal is editing text).
type modal interface {
title() string
content(m *Model, width int) []line // rebuilt every frame, so resizes reflow
footer(m *Model) string
key(m *Model, k string) (closeModal bool, cmd tea.Cmd)
width() int // preferred inner width
}
// modalFrame wraps a modal with scroll state.
type modalFrame struct {
modal
scroll int
viewH int // content rows visible in the last paint
contentN int
}
func (m *Model) openModal(md modal) {
m.modals = append(m.modals, &modalFrame{modal: md})
m.dirty = true
}
func (m *Model) closeModal() {
if len(m.modals) > 0 {
m.modals = m.modals[:len(m.modals)-1]
}
m.dirty = true
}
// textInputModal is implemented by modals that consume printable keys.
type textInputModal interface {
editing() bool
editor() *lineEditor
}
func (m *Model) modalKey(msg tea.KeyPressMsg) tea.Cmd {
k := keyName(msg)
f := m.modals[len(m.modals)-1].(*modalFrame)
if ti, ok := f.modal.(textInputModal); ok && ti.editing() && k != "esc" && k != "enter" {
ti.editor().key(msg)
return nil
}
if ti, ok := f.modal.(textInputModal); !ok || !ti.editing() {
page := max(f.viewH-1, 1)
switch k {
case "esc", "q":
m.closeModal()
return nil
case "up", "k":
f.scroll--
case "down", "j":
f.scroll++
case "pgup":
f.scroll -= page
case "pgdown", "space":
f.scroll += page
case "home":
f.scroll = 0
case "end":
f.scroll = f.contentN
default:
goto modalSpecific
}
f.clampScroll()
return nil
}
modalSpecific:
closeIt, cmd := f.key(m, k)
if closeIt {
// Close this modal specifically (it may have opened another).
for i, x := range m.modals {
if x == f {
m.modals = append(m.modals[:i], m.modals[i+1:]...)
break
}
}
m.dirty = true
}
return cmd
}
func (f *modalFrame) clampScroll() {
f.scroll = max(0, min(f.scroll, f.contentN-f.viewH))
}
// paintModal dims the base screen and draws the top modal centred,
// clamped to the terminal with a one-cell margin. Content that does not fit
// scrolls.
func (m *Model) paintModal(cv *Canvas) {
t := m.theme
// Dim everything underneath.
cv.Restyle(treemap.Rect{X: 0, Y: 0, W: cv.W, H: cv.H}, func(s Style) Style {
if t.Mono {
s.Attr |= Faint
return s
}
s.FG = s.FG.Mix(t.Bg, 0.6)
s.BG = s.BG.Mix(RGB(0, 0, 0), 0.45)
s.Attr &^= Bold
return s
})
f := m.modals[len(m.modals)-1].(*modalFrame)
maxW := cv.W - 2
w := min(f.width()+4, maxW)
w = max(w, min(30, maxW))
inner := w - 4
lines := f.content(m, inner)
foot := f.footer(m)
chrome := 4 // border top/bottom, title rule
if foot != "" {
chrome += 2
}
h := min(len(lines)+chrome, cv.H-2)
h = max(h, min(chrome+1, cv.H))
x0, y0 := (cv.W-w)/2, (cv.H-h)/2
r := treemap.Rect{X: x0, Y: y0, W: w, H: h}
bg := t.PanelBg
if bg == 0 {
bg = t.Bg
}
base := Style{FG: t.Fg, BG: bg}
// Drop shadow.
if !t.Mono {
cv.Restyle(treemap.Rect{X: x0 + 1, Y: y0 + h, W: w, H: 1}, func(s Style) Style { s.BG = RGB(0, 0, 0); return s })
cv.Restyle(treemap.Rect{X: x0 + w, Y: y0 + 1, W: 1, H: h}, func(s Style) Style { s.BG = RGB(0, 0, 0); return s })
}
cv.Fill(r, " ", base)
border := Style{FG: t.Accent, BG: bg, Attr: Bold}
box := BoxRound
if t.ASCII {
box = BoxASCII
}
cv.Box(r, box, border)
cv.Text(x0+2, y0+1, strings.ToUpper(f.title()), inner, Style{FG: t.Accent, BG: bg, Attr: Bold})
rule := "─"
if t.ASCII {
rule = "-"
}
cv.HLine(x0+1, y0+2, w-2, rule, Style{FG: t.Faint, BG: bg})
f.viewH = h - chrome
f.contentN = len(lines)
f.clampScroll()
for i := 0; i < f.viewH && f.scroll+i < len(lines); i++ {
x := x0 + 2
for _, sp := range lines[f.scroll+i] {
st := base
if sp.st != nil {
st = *sp.st
if st.BG == 0 {
st.BG = bg
}
}
x += cv.Text(x, y0+3+i, sp.s, x0+2+inner-x, st)
}
}
if len(lines) > f.viewH && f.viewH > 0 {
ind := textutil.Count(int64(f.scroll+1)) + "–" + textutil.Count(int64(min(f.scroll+f.viewH, len(lines)))) +
"/" + textutil.Count(int64(len(lines)))
if f.scroll > 0 {
ind = "▲ " + ind
}
if f.scroll+f.viewH < len(lines) {
ind += " ▼"
}
cv.TextRight(x0+1, y0+1, w-3, ind, Style{FG: t.Muted, BG: bg})
}
if foot != "" {
cv.HLine(x0+1, y0+h-3, w-2, rule, Style{FG: t.Faint, BG: bg})
cv.Text(x0+2, y0+h-2, foot, inner, Style{FG: t.Muted, BG: bg})
}
m.hits = append(m.hits[:0], hit{r, func(*Model, bool) tea.Cmd { return nil }})
}
// wrap breaks s into chunks of at most w cells (used for long paths).
func wrap(s string, w int) []string {
if w <= 0 {
return nil
}
var out []string
var cur strings.Builder
cw := 0
textutil.Clusters(s, func(c string, cwid int) bool {
if cw+cwid > w {
out = append(out, cur.String())
cur.Reset()
cw = 0
}
cur.WriteString(c)
cw += cwid
return true
})
if cur.Len() > 0 || len(out) == 0 {
out = append(out, cur.String())
}
return out
}
+373
View File
@@ -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
}
+424
View File
@@ -0,0 +1,424 @@
// Package tui is the interactive terminal interface. All drawing goes to a
// Canvas; Bubble Tea only transports the finished frame.
//
// Threading: Update and View run on Bubble Tea's event goroutine. The scan
// controller mutates the inventory concurrently, so every read of tree data
// happens under tree.RLock (taken once per frame in View, and briefly in key
// handlers). The model itself is only touched on the event goroutine.
package tui
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/colorprofile"
"github.com/andydixon/mapsize/internal/brand"
"github.com/andydixon/mapsize/internal/config"
"github.com/andydixon/mapsize/internal/filter"
"github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/scan"
"github.com/andydixon/mapsize/internal/snapshot"
"github.com/andydixon/mapsize/internal/textutil"
"github.com/andydixon/mapsize/internal/treemap"
)
// Options configures the interactive session.
type Options struct {
ReadOnly bool
Theme string
SizeMode inventory.SizeMode
Mouse bool
Color string // auto, truecolor, 256, 16, none
Save string // save a snapshot here when the scan completes
Settings config.Settings
Diff *snapshot.Diff // compare mode
// Start begins a scan (or returns a loaded tree with a nil scanner).
Start func(ctx context.Context) (*scan.Scanner, *inventory.Tree, error)
}
// Messages.
type (
tickMsg time.Time
scanDoneMsg struct{ s *scan.Scanner }
fatalMsg struct {
v any
stack []byte
}
filterMsg struct {
gen int
res *filter.Result
err error
}
filterDebounceMsg struct{ gen int }
)
// Model is the Bubble Tea model.
type Model struct {
opts Options
theme *Theme
scanner *scan.Scanner
tree *inventory.Tree
cancel context.CancelFunc
scanning bool
snapshot bool // tree was loaded from a snapshot (not the live filesystem)
ctrlC bool // a Ctrl+C already cancelled the scan
w, h int
profile colorprofile.Profile
forced bool // profile forced by --color
sizeMode inventory.SizeMode
views []view
view int
zoom inventory.NodeID
sel int64 // selected block: NodeID, or groupID(parent) for an LOD group
// userSel is set once the user has chosen a selection; before that the
// selection follows the largest block.
userSel bool
zoomed bool // zoom changed during this message (zoomTo set userSel)
search searchState
modals []modal
toast string
toastStyle Style
toastUntil time.Time
// Frame and data caches.
dirty bool
frame string
dataVer int // bumped when sizes/filters change; invalidates caches
lastData time.Time // last time dataVer advanced during a scan
kidsCache map[inventory.NodeID]*kids
kidsVer int
tm *tmLayout
hits []hit // clickable regions from the last frame
hover int64
lastClick time.Time
lastClickI int64
ticking bool
pendingZoom string // path to re-zoom after a rescan
dups *dupState
fatal *fatalMsg
quit bool
}
// hit is a clickable region recorded while painting.
type hit struct {
r treemap.Rect
fn func(m *Model, double bool) tea.Cmd
}
func groupID(parent inventory.NodeID) int64 { return -1 - int64(parent) }
func isGroup(id int64) bool { return id < 0 }
func groupParent(id int64) inventory.NodeID { return inventory.NodeID(-1 - id) }
func newModel(opts Options, s *scan.Scanner, t *inventory.Tree, cancel context.CancelFunc) *Model {
m := &Model{
opts: opts, scanner: s, tree: t, cancel: cancel, scanning: s != nil, snapshot: s == nil,
sizeMode: opts.SizeMode, dirty: true, kidsCache: map[inventory.NodeID]*kids{}, hover: noSel,
lastClickI: noSel,
}
name := opts.Theme
if name == "" || name == "default" {
if _, ok := os.LookupEnv("NO_COLOR"); ok {
name = "mono"
}
}
m.theme = LoadTheme(name, opts.Settings)
if strings.EqualFold(os.Getenv("TERM"), "linux") || os.Getenv("MAPSIZE_ASCII") != "" {
m.theme.ASCII = true
}
m.views = []view{&mapView{}, &listView{}, &extView{}, &topView{}, &infoView{}}
if opts.Diff != nil {
m.views = append(m.views, &changesView{})
}
m.zoom = t.Root()
m.sel = noSel
return m
}
const noSel int64 = 1 << 62
// Init starts ticking and waits for scan completion.
func (m *Model) Init() tea.Cmd {
cmds := []tea.Cmd{tea.RequestWindowSize}
if m.scanning {
cmds = append(cmds, waitScan(m.scanner), m.startTicking())
}
return tea.Batch(cmds...)
}
func waitScan(s *scan.Scanner) tea.Cmd {
return func() tea.Msg { <-s.Done(); return scanDoneMsg{s} }
}
func (m *Model) refreshInterval() time.Duration {
hz := m.opts.Settings.RefreshHz
if hz <= 0 {
hz = 10
}
return time.Second / time.Duration(hz)
}
// startTicking returns a tick command unless one is already pending.
func (m *Model) startTicking() tea.Cmd {
if m.ticking {
return nil
}
m.ticking = true
return tea.Tick(m.refreshInterval(), func(t time.Time) tea.Msg { return tickMsg(t) })
}
func (m *Model) needsTicks() bool {
return m.scanning || time.Now().Before(m.toastUntil) || (m.dups != nil && m.dups.running)
}
// notify shows a transient status message.
func (m *Model) notify(s string, st Style) tea.Cmd {
m.toast, m.toastStyle, m.toastUntil = s, st, time.Now().Add(4*time.Second)
m.dirty = true
return m.startTicking()
}
func (m *Model) info(s string) tea.Cmd {
return m.notify(s, Style{FG: m.theme.Fg, BG: m.theme.StatusBg})
}
func (m *Model) warn(s string) tea.Cmd {
return m.notify(s, Style{FG: m.theme.Warn, BG: m.theme.StatusBg, Attr: Bold})
}
// invalidate marks size-dependent caches stale.
func (m *Model) invalidate() {
m.dataVer++
m.dirty = true
}
// Update handles one message.
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmd := m.update(msg)
if m.quit {
return m, tea.Quit
}
return m, cmd
}
func (m *Model) update(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
// Only record the size; layout is recomputed lazily in View, so a
// storm of resize events costs at most one layout per frame.
if msg.Width != m.w || msg.Height != m.h {
m.w, m.h = msg.Width, msg.Height
m.dirty = true
}
case tickMsg:
m.ticking = false
if m.scanning {
m.dirty = true
if time.Since(m.lastData) >= 400*time.Millisecond {
m.invalidate()
m.lastData = time.Now()
}
m.resolvePendingZoom()
}
if m.toast != "" && time.Now().After(m.toastUntil) {
m.toast = ""
m.dirty = true
}
var cmds []tea.Cmd
if m.scanning && m.search.query != nil && time.Since(m.search.appliedAt) > time.Second {
cmds = append(cmds, m.applyFilter())
}
if m.dups != nil && m.dups.running {
m.dirty = true
}
if m.needsTicks() {
cmds = append(cmds, m.startTicking())
}
return tea.Batch(cmds...)
case scanDoneMsg:
if msg.s != m.scanner {
return nil // a superseded scan
}
return m.scanFinished()
case filterMsg:
return m.filterResult(msg)
case filterDebounceMsg:
if msg.gen == m.search.gen {
return m.applyFilter()
}
case dupProgressMsg, dupDoneMsg:
return m.dupMsg(msg)
case trashDoneMsg:
return m.trashDone(msg)
case saveDoneMsg:
if msg.err != nil {
return m.warn("Snapshot save failed: " + textutil.Sanitize(msg.err.Error()))
}
return m.info("Saved snapshot " + textutil.Sanitize(msg.path))
case fatalMsg:
m.fatal = &msg
m.quit = true
case tea.KeyPressMsg:
m.dirty = true
prev := m.sel
m.zoomed = false
cmd := m.handleKey(msg)
m.noteSelection(prev)
return cmd
case tea.MouseClickMsg:
prev := m.sel
m.zoomed = false
cmd := m.mouseClick(msg.Mouse())
m.noteSelection(prev)
return cmd
case tea.MouseWheelMsg:
return m.mouseWheel(msg.Mouse())
case tea.MouseMotionMsg:
m.mouseMotion(msg.Mouse())
case tea.ColorProfileMsg:
if !m.forced {
m.profile = msg.Profile
}
m.dirty = true
}
return nil
}
// noteSelection records that the user chose a selection explicitly.
func (m *Model) noteSelection(prev int64) {
if !m.zoomed && m.sel != prev && m.sel != noSel {
m.userSel = true
}
}
func (m *Model) scanFinished() tea.Cmd {
m.scanning = false
m.invalidate()
m.resolvePendingZoom()
t := m.tree
t.RLock()
st := t.Stats
r := t.Node(t.Root())
total := r.Total(m.sizeMode)
files := st.Files + st.Symlinks + st.Others
errs := st.TotalErrors()
el := st.Elapsed()
t.RUnlock()
var cmds []tea.Cmd
rate := float64(files+st.Dirs) / max(el.Seconds(), 0.001)
msg := fmt.Sprintf("✓ Scan complete %s · %s files · %s dirs · %s · %s/s",
textutil.Duration(el), textutil.Count(files), textutil.Count(st.Dirs), textutil.Size(total), textutil.Count(int64(rate)))
if st.Cancelled {
msg = "✗ Scan cancelled — totals are incomplete"
}
if errs > 0 {
msg += fmt.Sprintf(" · %s errors (e)", textutil.Count(errs))
}
if st.Cancelled || errs > 0 {
cmds = append(cmds, m.warn(msg))
} else {
cmds = append(cmds, m.notify(msg, Style{FG: m.theme.OK, BG: m.theme.StatusBg, Attr: Bold}))
}
if m.opts.Save != "" && !st.Cancelled {
cmds = append(cmds, m.saveSnapshot(m.opts.Save))
}
if m.search.query != nil {
cmds = append(cmds, m.applyFilter())
}
return tea.Batch(cmds...)
}
// ctrlCPressed: first press cancels a running scan, second quits.
func (m *Model) ctrlCPressed() tea.Cmd {
if m.scanning && !m.ctrlC {
m.ctrlC = true
m.cancel()
return m.warn("Cancelling scan… press Ctrl+C again to quit")
}
m.quit = true
return nil
}
func (m *Model) rescan() tea.Cmd {
if m.snapshot {
return m.warn("Snapshot loaded — nothing to rescan")
}
// Called from handleKey, which holds the old tree's read lock.
m.pendingZoom = m.tree.Path(m.zoom)
m.cancel()
ctx, cancel := context.WithCancel(context.Background())
s, t, err := m.opts.Start(ctx)
if err != nil {
cancel()
return m.warn("Rescan failed: " + textutil.Sanitize(err.Error()))
}
m.scanner, m.tree, m.cancel, m.scanning, m.ctrlC = s, t, cancel, s != nil, false
m.zoom, m.sel = t.Root(), noSel
m.kidsCache = map[inventory.NodeID]*kids{}
m.dups = nil
m.search.result = nil
m.invalidate()
return tea.Batch(waitScan(s), m.startTicking(), m.info("Rescanning…"))
}
// resolvePendingZoom re-zooms into the remembered directory once the new
// scan has discovered it.
func (m *Model) resolvePendingZoom() {
if m.pendingZoom == "" {
return
}
m.tree.RLock()
defer m.tree.RUnlock()
root := m.tree.Path(m.tree.Root())
rel, err := filepath.Rel(root, m.pendingZoom)
if err != nil || strings.HasPrefix(rel, "..") {
m.pendingZoom = ""
return
}
id := m.tree.Root()
if rel != "." {
for _, part := range strings.Split(rel, string(filepath.Separator)) {
next := inventory.NoNode
m.tree.Children(id, func(c inventory.NodeID, n *inventory.Node) {
if n.Name == part {
next = c
}
})
if next == inventory.NoNode {
return // not discovered yet
}
id = next
}
}
m.zoom = id
m.pendingZoom = ""
m.dirty = true
}
// View renders the frame. Expensive work happens only when something
// changed; otherwise the cached frame is returned.
func (m *Model) View() tea.View {
if m.dirty || m.frame == "" {
m.frame = m.render()
m.dirty = false
}
v := tea.NewView(m.frame)
v.AltScreen = true
if m.opts.Mouse {
v.MouseMode = tea.MouseModeAllMotion
}
v.WindowTitle = brand.Name
return v
}
+110
View File
@@ -0,0 +1,110 @@
package tui
import (
"time"
tea "charm.land/bubbletea/v2"
"github.com/andydixon/mapsize/internal/inventory"
)
const doubleClick = 400 * time.Millisecond
func (m *Model) mouseClick(ev tea.Mouse) tea.Cmd {
t := m.tree
t.RLock()
defer t.RUnlock()
m.dirty = true
if len(m.modals) > 0 {
// Clicking outside the modal closes it.
if len(m.hits) > 0 && !m.hits[0].r.Contains(ev.X, ev.Y) {
m.closeModal()
}
return nil
}
if ev.Button == tea.MouseRight {
for _, h := range m.hits {
if h.r.Contains(ev.X, ev.Y) {
h.fn(m, false)
return m.inspectSelection()
}
}
return nil
}
if ev.Button != tea.MouseLeft {
return nil
}
for i := len(m.hits) - 1; i >= 0; i-- {
h := m.hits[i]
if h.r.Contains(ev.X, ev.Y) {
key := int64(ev.Y)<<32 | int64(i)
double := time.Since(m.lastClick) < doubleClick && m.lastClickI == key
m.lastClick, m.lastClickI = time.Now(), key
if double {
m.lastClickI = noSel
}
return h.fn(m, double)
}
}
return nil
}
// clickBlock selects a treemap block; a double click zooms (or inspects).
func (m *Model) clickBlock(id int64, double bool) tea.Cmd {
m.sel = id
if !double {
return nil
}
if !isGroup(id) && !m.tree.Node(inventory.NodeID(id)).IsDir() {
return m.inspectSelection()
}
return m.zoomIntoSelection()
}
func (m *Model) mouseWheel(ev tea.Mouse) tea.Cmd {
t := m.tree
t.RLock()
defer t.RUnlock()
m.dirty = true
up := ev.Button == tea.MouseWheelUp
if ev.Button != tea.MouseWheelUp && ev.Button != tea.MouseWheelDown {
return nil
}
if len(m.modals) > 0 {
f := m.modals[len(m.modals)-1].(*modalFrame)
if up {
f.scroll -= 3
} else {
f.scroll += 3
}
f.clampScroll()
return nil
}
if up {
// Zoom towards the block under the pointer.
if l := m.tm; l != nil && m.view == 0 {
for _, b := range l.blocks {
if b.Contains(ev.X, ev.Y) {
m.sel = b.ID
}
}
}
}
return m.views[m.view].wheel(m, up)
}
func (m *Model) mouseMotion(ev tea.Mouse) {
h := noSel
if l := m.tm; l != nil && m.view == 0 && len(m.modals) == 0 {
for _, b := range l.blocks {
if b.Contains(ev.X, ev.Y) {
h = b.ID
break
}
}
}
if h != m.hover {
m.hover = h
m.dirty = true
}
}
+408
View File
@@ -0,0 +1,408 @@
package tui
import (
"fmt"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/colorprofile"
"github.com/andydixon/mapsize/internal/brand"
"github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/textutil"
"github.com/andydixon/mapsize/internal/treemap"
)
// Minimum usable terminal size.
const minW, minH = 40, 10
type sizeClass int
const (
tooSmall sizeClass = iota
small
medium
large
)
// geometry is the panel layout for one terminal size. It is recomputed from
// scratch for every frame, so resizing can never leave stale geometry.
type geometry struct {
class sizeClass
tabs, crumbs treemap.Rect // tabs is empty in the small layout
main, side, info, stat treemap.Rect
}
func computeGeometry(w, h int) geometry {
g := geometry{}
switch {
case w < minW || h < minH:
g.class = tooSmall
return g
case w >= 150 && h >= 36:
g.class = large
case w >= 90 && h >= 22:
g.class = medium
default:
g.class = small
}
y := 0
if g.class >= medium {
g.tabs = treemap.Rect{X: 0, Y: 0, W: w, H: 1}
y = 1
}
g.crumbs = treemap.Rect{X: 0, Y: y, W: w, H: 1}
y++
bottom := h - 1
g.stat = treemap.Rect{X: 0, Y: h - 1, W: w, H: 1}
if g.class >= medium {
g.info = treemap.Rect{X: 0, Y: h - 2, W: w, H: 1}
bottom = h - 2
}
g.main = treemap.Rect{X: 0, Y: y, W: w, H: bottom - y}
if g.class == large {
sw := min(40, w/5)
g.side = treemap.Rect{X: 0, Y: y, W: sw, H: g.main.H}
g.main.X, g.main.W = sw, w-sw
}
return g
}
func (m *Model) render() string {
t := m.theme
cv := NewCanvas(m.w, m.h, t.base())
m.hits = m.hits[:0]
g := computeGeometry(m.w, m.h)
if g.class == tooSmall {
m.renderTooSmall(cv)
return cv.Render(m.profile == colorprofile.ANSI256)
}
m.tree.RLock()
defer m.tree.RUnlock()
m.ensureSelection()
if g.tabs.W > 0 {
m.paintTabs(cv, g.tabs)
}
m.paintCrumbs(cv, g.crumbs)
if !g.side.Empty() {
m.paintSide(cv, g.side)
}
m.views[m.view].paint(m, cv, g.main)
if !g.info.Empty() {
m.paintInfoLine(cv, g.info)
}
m.paintStatus(cv, g.stat, g.class)
if g.class == small && m.search.editing {
m.paintSearch(cv, g.stat)
}
if len(m.modals) > 0 {
m.paintModal(cv)
}
return cv.Render(m.profile == colorprofile.ANSI256)
}
func (m *Model) renderTooSmall(cv *Canvas) {
st := m.theme.muted()
lines := []string{"Terminal too small", fmt.Sprintf("%d×%d — need %d×%d", m.w, m.h, minW, minH), "q quits"}
y0 := max(0, (m.h-len(lines))/2)
for i, l := range lines {
cv.TextCentered(0, y0+i, m.w, l, st)
}
}
// paintTabs draws the view tabs and mode chips.
func (m *Model) paintTabs(cv *Canvas, r treemap.Rect) {
t := m.theme
cv.Fill(r, " ", t.header())
badge := " ◧ " + brand.Name + " "
if t.ASCII {
badge = " " + brand.Name + " "
}
x := cv.Text(r.X, r.Y, badge, r.W, Style{FG: t.textOn(t.Accent), BG: t.Accent, Attr: Bold | m.monoRev()})
x++
for i, v := range m.views {
label := " " + v.name() + " "
st := Style{FG: t.Muted, BG: t.HeaderBg}
if i == m.view {
st = Style{FG: t.Fg, BG: t.Bg, Attr: Bold | Underline}
}
if x+textutil.Width(label) >= r.W-30 {
break
}
w := cv.Text(x, r.Y, label, r.W-x, st)
idx := i
m.hits = append(m.hits, hit{treemap.Rect{X: x, Y: r.Y, W: w, H: 1}, func(m *Model, _ bool) tea.Cmd { m.setView(idx); return nil }})
x += w
}
// Right-aligned chips.
var chips []struct {
s string
bg Color
}
add := func(s string, bg Color) {
chips = append(chips, struct {
s string
bg Color
}{s, bg})
}
if m.opts.ReadOnly {
add("READ ONLY", t.Err)
}
if m.snapshot {
add("SNAPSHOT", t.Accent)
}
if m.opts.Diff != nil {
add("COMPARE", t.DiffNew)
}
if m.search.query != nil {
add("FILTER", t.Warn)
}
if len(m.tree.Stats.Excludes) > 0 {
add(fmt.Sprintf("EXCL %d", len(m.tree.Stats.Excludes)), t.Faint)
}
add(strings.ToUpper(m.sizeMode.String()), t.Faint)
rx := r.X + r.W
for i := len(chips) - 1; i >= 0; i-- {
s := " " + chips[i].s + " "
w := textutil.Width(s)
if rx-w-1 < x {
break
}
rx -= w
cv.Text(rx, r.Y, s, w, Style{FG: t.textOn(chips[i].bg), BG: chips[i].bg, Attr: Bold | m.monoRev()})
rx--
}
}
func (m *Model) monoRev() Attr {
if m.theme.Mono {
return Reverse
}
return 0
}
// paintCrumbs draws "/ › home › andy" breadcrumbs, truncated from the left,
// with the zoom root's size on the right.
func (m *Model) paintCrumbs(cv *Canvas, r treemap.Rect) {
t := m.theme
cv.Fill(r, " ", t.header())
tr := m.tree
z := tr.Node(m.zoom)
size := m.sizeOf(m.zoom)
right := " " + textutil.Size(size)
if root := m.sizeOf(tr.Root()); m.zoom != tr.Root() && root > 0 {
right += " " + textutil.Percent(size, root) + " of scan"
}
if m.search.result != nil {
right += fmt.Sprintf(" (%s matches)", textutil.Count(m.search.result.Count))
}
if z.IsDir() {
right += fmt.Sprintf(" %s files", textutil.Count(int64(z.Files)))
}
right += " "
rw := textutil.Width(right)
if rw > r.W/2 {
right = " " + textutil.SizeCompact(size) + " "
rw = textutil.Width(right)
}
cv.TextRight(r.X, r.Y, r.W, right, Style{FG: t.HeaderFg, BG: t.HeaderBg, Attr: Bold})
sep := " › "
if t.ASCII {
sep = " > "
}
chain := tr.Ancestors(m.zoom)
type seg struct {
id inventory.NodeID
s string
}
segs := make([]seg, len(chain))
for i, id := range chain {
segs[i] = seg{id, textutil.Sanitize(tr.Node(id).Name)}
}
avail := r.W - rw - 2
// Drop leading segments until the rest fits; keep at least the last.
total := func(from int) int {
w := 0
for i := from; i < len(segs); i++ {
w += textutil.Width(segs[i].s)
if i > from {
w += textutil.Width(sep)
}
}
if from > 0 {
w += textutil.Width("…" + sep)
}
return w
}
from := 0
for from < len(segs)-1 && total(from) > avail {
from++
}
x := r.X + 1
if from > 0 {
x += cv.Text(x, r.Y, "…"+sep, avail, Style{FG: t.Muted, BG: t.HeaderBg})
}
for i := from; i < len(segs); i++ {
if i > from {
x += cv.Text(x, r.Y, sep, r.X+1+avail-x, Style{FG: t.Faint, BG: t.HeaderBg})
}
st := Style{FG: t.Muted, BG: t.HeaderBg}
if i == len(segs)-1 {
st = Style{FG: t.Fg, BG: t.HeaderBg, Attr: Bold}
}
s := segs[i].s
if i == len(segs)-1 {
s = textutil.TruncateLeft(s, max(1, r.X+1+avail-x))
}
w := cv.Text(x, r.Y, s, r.X+1+avail-x, st)
id := segs[i].id
m.hits = append(m.hits, hit{treemap.Rect{X: x, Y: r.Y, W: w, H: 1}, func(m *Model, _ bool) tea.Cmd { m.zoomTo(id); return nil }})
x += w
}
}
// paintInfoLine describes the selection (or shows the search editor).
func (m *Model) paintInfoLine(cv *Canvas, r treemap.Rect) {
t := m.theme
cv.Fill(r, " ", t.panel())
if m.search.editing {
m.paintSearch(cv, r)
return
}
if m.search.query != nil {
m.paintFilterSummary(cv, r)
return
}
cv.Text(r.X+1, r.Y, m.describeSelection(), r.W-2, t.panel())
}
func (m *Model) describeSelection() string {
id := m.selectedNode()
tr := m.tree
if isGroup(m.sel) {
if g, ok := m.groupInfo(m.sel); ok {
return fmt.Sprintf("▸ %s smaller items · %s · %s of %s · Enter lists them",
textutil.Count(int64(len(g.ids))), textutil.Size(g.size), textutil.Percent(g.size, m.sizeOf(m.zoom)),
textutil.Sanitize(tr.Node(m.zoom).Name))
}
}
if id == inventory.NoNode {
return "Nothing selected"
}
n := tr.Node(id)
parts := []string{"▸ " + textutil.Sanitize(n.Name)}
parts = append(parts, textutil.Size(m.sizeOf(id)))
if p := n.Parent; p != inventory.NoNode {
parts = append(parts, textutil.Percent(m.sizeOf(id), m.sizeOf(p))+" of parent")
}
if n.IsDir() {
parts = append(parts, textutil.Count(int64(n.Files))+" files", textutil.Count(int64(n.Dirs))+" dirs")
if n.Flags&inventory.FlagScanned == 0 && m.scanning {
parts = append(parts, "scanning…")
}
} else {
parts = append(parts, kindLabel(tr, n))
}
if n.MTime > 0 {
parts = append(parts, "modified "+time.Unix(0, n.MTime).Format("2006-01-02"))
}
if n.Errors > 0 {
parts = append(parts, fmt.Sprintf("%d errors", n.Errors))
}
if m.opts.Diff != nil {
parts = append(parts, textutil.SignedSize(m.opts.Diff.Delta(id, m.sizeMode))+" since "+"snapshot")
}
return strings.Join(parts, " · ")
}
func kindLabel(tr *inventory.Tree, n *inventory.Node) string {
switch n.Kind {
case inventory.KindFile:
return n.Cat.String()
case inventory.KindSymlink:
return "symlink"
}
return n.Kind.String()
}
var spinner = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
// paintStatus draws the live status bar.
func (m *Model) paintStatus(cv *Canvas, r treemap.Rect, class sizeClass) {
t := m.theme
cv.Fill(r, " ", t.status())
help := " ? help "
if class == small {
help = " ? "
}
hw := cv.Text(r.X+r.W-textutil.Width(help), r.Y, help, r.W, Style{FG: t.Muted, BG: t.StatusBg})
avail := r.W - hw - 1
if m.toast != "" {
cv.Text(r.X+1, r.Y, m.toast, avail-1, m.toastStyle)
return
}
st := &m.tree.Stats
root := m.tree.Node(m.tree.Root())
files := st.Files + st.Symlinks + st.Others
var parts []struct {
s string
fg Color
}
add := func(s string, fg Color) {
parts = append(parts, struct {
s string
fg Color
}{s, fg})
}
switch {
case m.scanning:
frame := spinner[int(time.Now().UnixMilli()/100)%len(spinner)]
if t.ASCII {
frame = "*"
}
add(frame+" scanning", t.Accent)
case st.Cancelled:
add("✗ cancelled", t.Warn)
case m.snapshot:
add("◉ snapshot", t.Accent)
default:
add("✓ complete", t.OK)
}
add(textutil.Count(files)+" files", t.StatusFg)
add(textutil.Size(root.Total(m.sizeMode)), t.StatusFg)
if m.scanning {
el := st.Elapsed()
p := m.scanner.Progress()
add(textutil.Count(int64(float64(p.Entries)/max(el.Seconds(), 0.001)))+"/s", t.StatusFg)
if class != small {
add(fmt.Sprintf("%d workers", p.Workers), t.Muted)
add(fmt.Sprintf("queue %s", textutil.Count(p.Pending)), t.Muted)
}
add(textutil.Duration(el), t.Muted)
} else if !m.snapshot && class != small {
add(textutil.Duration(st.Elapsed()), t.Muted)
}
if e := st.TotalErrors(); e > 0 {
add(fmt.Sprintf("%s errors", textutil.Count(e)), t.Err)
}
if st.Excluded > 0 {
add(fmt.Sprintf("%s excluded", textutil.Count(st.Excluded)), t.Warn)
}
if st.Incomplete() && !m.scanning && class != small {
add("totals incomplete", t.Warn)
}
x := r.X + 1
for i, p := range parts {
s := p.s
if i > 0 {
s = " " + s
}
if x+textutil.Width(s) > r.X+avail {
break
}
x += cv.Text(x, r.Y, s, avail-x, Style{FG: p.fg, BG: t.StatusBg, Attr: map[bool]Attr{true: Bold}[i == 0]})
}
}
+59
View File
@@ -0,0 +1,59 @@
package tui
import (
"context"
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/colorprofile"
"github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/scan"
"github.com/andydixon/mapsize/internal/snapshot"
"github.com/andydixon/mapsize/internal/textutil"
)
var profiles = map[string]colorprofile.Profile{
"truecolor": colorprofile.TrueColor, "24bit": colorprofile.TrueColor,
"256": colorprofile.ANSI256, "16": colorprofile.ANSI, "none": colorprofile.ASCII,
}
func saveFile(path string, t *inventory.Tree) error { return snapshot.SaveFile(path, t) }
// Run starts the interactive UI. The terminal is restored on every exit
// path: normal quit, Ctrl+C, errors and panics (Bubble Tea recovers panics
// in Update/View; scanner goroutine panics are routed here via
// scan.OnPanic and reported after the terminal is restored).
func Run(opts Options) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, t, err := opts.Start(ctx)
if err != nil {
return err
}
m := newModel(opts, s, t, cancel)
var popts []tea.ProgramOption
if p, ok := profiles[opts.Color]; ok {
m.profile, m.forced = p, true
popts = append(popts, tea.WithColorProfile(p))
} else if opts.Color != "" && opts.Color != "auto" {
return fmt.Errorf("invalid --color %q (auto, truecolor, 256, 16, none)", opts.Color)
}
p := tea.NewProgram(m, popts...)
prev := scan.OnPanic
scan.OnPanic = func(v any, stack []byte) {
p.Send(fatalMsg{v, stack})
select {} // park the broken goroutine; the program is quitting
}
defer func() { scan.OnPanic = prev }()
_, err = p.Run()
cancel()
if m.fatal != nil {
fmt.Fprintf(os.Stderr, "mapsize: internal error in scanner: %s\n\n%s\n",
textutil.Sanitize(fmt.Sprint(m.fatal.v)), m.fatal.stack)
return fmt.Errorf("scanner crashed")
}
return err
}
+267
View File
@@ -0,0 +1,267 @@
package tui
import (
"context"
"fmt"
"time"
tea "charm.land/bubbletea/v2"
"github.com/andydixon/mapsize/internal/filter"
"github.com/andydixon/mapsize/internal/textutil"
"github.com/andydixon/mapsize/internal/treemap"
)
// lineEditor is a minimal single-line text editor.
type lineEditor struct {
buf []rune
cur int
}
func (e *lineEditor) String() string { return string(e.buf) }
func (e *lineEditor) set(s string) { e.buf, e.cur = []rune(s), len([]rune(s)) }
// key applies an editing key; it reports whether the text changed.
func (e *lineEditor) key(msg tea.KeyPressMsg) (changed, handled bool) {
switch msg.Keystroke() {
case "left", "ctrl+b":
e.cur = max(0, e.cur-1)
case "right", "ctrl+f":
e.cur = min(len(e.buf), e.cur+1)
case "home", "ctrl+a":
e.cur = 0
case "end", "ctrl+e":
e.cur = len(e.buf)
case "backspace", "ctrl+h":
if e.cur > 0 {
e.buf = append(e.buf[:e.cur-1], e.buf[e.cur:]...)
e.cur--
return true, true
}
case "delete", "ctrl+d":
if e.cur < len(e.buf) {
e.buf = append(e.buf[:e.cur], e.buf[e.cur+1:]...)
return true, true
}
case "ctrl+u":
e.buf, e.cur = e.buf[e.cur:], 0
return true, true
case "ctrl+k":
e.buf = e.buf[:e.cur]
return true, true
case "ctrl+w", "alt+backspace":
i := e.cur
for i > 0 && e.buf[i-1] == ' ' {
i--
}
for i > 0 && e.buf[i-1] != ' ' {
i--
}
e.buf = append(e.buf[:i], e.buf[e.cur:]...)
e.cur = i
return true, true
default:
txt := msg.Text
if txt == "" || msg.Mod&(tea.ModCtrl|tea.ModAlt) != 0 {
return false, false
}
var rs []rune
for _, r := range txt {
if r >= 0x20 && r != 0x7f {
rs = append(rs, r)
}
}
if len(rs) == 0 || len(e.buf)+len(rs) > 1024 {
return false, true
}
e.buf = append(e.buf[:e.cur], append(rs, e.buf[e.cur:]...)...)
e.cur += len(rs)
return true, true
}
return false, true
}
type searchState struct {
editing bool
ed lineEditor
query *filter.Query
result *filter.Result
err string
gen int
busy bool
appliedAt time.Time
cancel context.CancelFunc
}
func (s *searchState) begin() {
s.editing = true
if s.query != nil {
s.ed.set(s.query.String())
}
}
func (m *Model) searchKey(msg tea.KeyPressMsg) tea.Cmd {
s := &m.search
switch msg.Keystroke() {
case "esc":
s.editing = false
m.clearFilter()
return nil
case "enter":
s.editing = false
if s.err != "" {
return m.warn("Invalid query: " + s.err)
}
return nil
}
changed, _ := s.ed.key(msg)
if changed {
return m.queryChanged()
}
return nil
}
// queryChanged parses the editor contents and schedules a debounced apply.
func (m *Model) queryChanged() tea.Cmd {
s := &m.search
s.gen++
text := s.ed.String()
if text == "" {
s.err = ""
m.dropFilter()
return nil
}
q, err := filter.Parse(text, time.Now())
if err != nil {
s.err = err.Error()
return nil
}
s.err = ""
s.query = q
gen := s.gen
return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return filterDebounceMsg{gen} })
}
// setFilter replaces the filter with q and applies it immediately.
func (m *Model) setFilter(q string) tea.Cmd {
m.search.ed.set(q)
m.search.editing = false
m.queryChanged()
if m.search.err != "" {
return m.warn("Invalid query: " + m.search.err)
}
return m.applyFilter()
}
// applyFilter evaluates the current query in the background. The tree is
// read-locked by the worker; stale results are discarded by generation.
func (m *Model) applyFilter() tea.Cmd {
s := &m.search
if s.query == nil {
return nil
}
if s.cancel != nil {
s.cancel()
}
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
s.busy = true
s.appliedAt = time.Now()
t, q, mode, gen := m.tree, s.query, m.sizeMode, s.gen
return func() tea.Msg {
t.RLock()
res, err := filter.Apply(ctx, t, q, mode)
t.RUnlock()
return filterMsg{gen: gen, res: res, err: err}
}
}
func (m *Model) filterResult(msg filterMsg) tea.Cmd {
s := &m.search
if msg.gen != s.gen || s.query == nil {
return nil
}
s.busy = false
if msg.err != nil {
return nil
}
s.result = msg.res
m.invalidate()
return nil
}
// dropFilter removes the active filter but keeps the editor open.
func (m *Model) dropFilter() {
s := &m.search
if s.cancel != nil {
s.cancel()
}
s.query, s.result, s.busy = nil, nil, false
m.invalidate()
}
func (m *Model) clearFilter() {
m.dropFilter()
m.search.ed.set("")
m.search.err = ""
m.search.gen++
}
func (m *Model) paintSearch(cv *Canvas, r treemap.Rect) {
t := m.theme
s := &m.search
st := Style{FG: t.Fg, BG: t.PanelBg}
cv.Fill(r, " ", st)
x := r.X + 1
x += cv.Text(x, r.Y, "/ ", 2, Style{FG: t.Accent, BG: t.PanelBg, Attr: Bold})
status, statusSt := "", Style{FG: t.Muted, BG: t.PanelBg}
switch {
case s.err != "":
status, statusSt.FG = "✗ "+s.err, t.Err
case s.busy:
status = "filtering…"
case s.result != nil:
status = fmt.Sprintf("%s matches · %s", textutil.Count(s.result.Count), textutil.Size(s.result.Total))
case len(s.ed.buf) == 0:
status = "name, *.iso, size > 1GB, ext IN (iso,vmdk), age > 365d … Enter keep · Esc clear"
}
sw := min(textutil.Width(status), r.W/2)
avail := r.W - (x - r.X) - sw - 3
// Scroll the text so the cursor stays visible.
text := s.ed.buf
start := 0
if s.ed.cur > avail-1 {
start = s.ed.cur - avail + 1
}
vis := string(text[start:min(len(text), start+avail)])
cv.Text(x, r.Y, textutil.Sanitize(vis), avail, st)
cx := x + textutil.Width(textutil.Sanitize(string(text[start:s.ed.cur])))
under := " "
if s.ed.cur < len(text) {
under = textutil.Sanitize(string(text[s.ed.cur]))
}
cv.Text(cx, r.Y, under, 2, Style{FG: t.PanelBg, BG: t.Fg, Attr: Reverse * Attr(boolInt(t.Mono))})
cv.TextRight(r.X, r.Y, r.W-1, textutil.Truncate(status, sw), statusSt)
}
func boolInt(b bool) int {
if b {
return 1
}
return 0
}
func (m *Model) paintFilterSummary(cv *Canvas, r treemap.Rect) {
t := m.theme
s := &m.search
x := r.X + 1
x += cv.Text(x, r.Y, " FILTER ", 8, Style{FG: t.textOn(t.Warn), BG: t.Warn, Attr: Bold | m.monoRev()})
x += cv.Text(x+1, r.Y, textutil.Sanitize(s.query.String()), r.W/2, Style{FG: t.Fg, BG: t.PanelBg, Attr: Bold}) + 1
info := "filtering…"
if s.result != nil && !s.busy {
info = fmt.Sprintf("%s matches · %s of %s · / edit · Esc clear", textutil.Count(s.result.Count),
textutil.Size(s.result.Total), textutil.Size(m.tree.Node(0).Total(m.sizeMode)))
}
cv.Text(x+2, r.Y, info, r.X+r.W-x-3, Style{FG: t.Muted, BG: t.PanelBg})
}
+118
View File
@@ -0,0 +1,118 @@
package tui
import (
"fmt"
"sort"
"github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/textutil"
"github.com/andydixon/mapsize/internal/treemap"
)
// paintSide draws the large-layout side panel: directory summary, category
// breakdown and the selected item.
func (m *Model) paintSide(cv *Canvas, r treemap.Rect) {
t := m.theme
ps := t.panel()
cv.Fill(r, " ", ps)
cv.Fill(treemap.Rect{X: r.X + r.W - 1, Y: r.Y, W: 1, H: r.H}, "│", Style{FG: t.Faint, BG: t.PanelBg})
w := r.W - 3
x := r.X + 1
y := r.Y + 1
end := r.Y + r.H
head := func(s string) {
if y < end {
cv.Text(x, y, s, w, Style{FG: t.Accent, BG: t.PanelBg, Attr: Bold})
y++
}
}
kvl := func(k, v string, vs Style) {
if y < end {
cv.Text(x, y, k, w, Style{FG: t.Muted, BG: t.PanelBg})
cv.TextRight(x, y, w, v, vs)
y++
}
}
val := Style{FG: t.Fg, BG: t.PanelBg, Attr: Bold}
tr := m.tree
z := tr.Node(m.zoom)
head("DIRECTORY")
for _, l := range wrap(textutil.Sanitize(z.Name), w) {
if y < end {
cv.Text(x, y, l, w, Style{FG: t.Fg, BG: t.PanelBg, Attr: Bold})
y++
}
}
y++
kvl("Size", textutil.Size(m.sizeOf(m.zoom)), val)
kvl("Logical", textutil.Size(z.TotSize), Style{FG: t.Fg, BG: t.PanelBg})
kvl("Allocated", textutil.Size(z.TotAlloc), Style{FG: t.Fg, BG: t.PanelBg})
kvl("Files", textutil.Count(int64(z.Files)), Style{FG: t.Fg, BG: t.PanelBg})
kvl("Directories", textutil.Count(int64(z.Dirs)), Style{FG: t.Fg, BG: t.PanelBg})
if z.Errors > 0 {
kvl("Errors", textutil.Count(int64(z.Errors)), Style{FG: t.Err, BG: t.PanelBg, Attr: Bold})
}
y++
// Category breakdown with proportional bars.
if cs := tr.CatSizes(m.zoom); cs != nil {
head("CATEGORIES")
type cat struct {
c inventory.Category
v int64
}
var cats []cat
var total int64
for i, v := range cs {
if v > 0 {
cats = append(cats, cat{inventory.Category(i), v})
total += v
}
}
sort.Slice(cats, func(i, j int) bool { return cats[i].v > cats[j].v })
for _, c := range cats {
if y >= end-8 {
break
}
col := t.Cat[c.c]
sw := "██ "
if t.Mono || t.ASCII {
sw = " "
}
cv.Text(x, y, sw, 3, Style{FG: col, BG: t.PanelBg})
cv.Text(x+3, y, c.c.String(), w-3, Style{FG: t.Fg, BG: t.PanelBg})
cv.TextRight(x, y, w, textutil.Percent(c.v, total), Style{FG: t.Muted, BG: t.PanelBg})
y++
m.paintBar(cv, x+3, y, w-3, float64(c.v)/float64(cats[0].v), col, false)
y++
}
y++
}
// Selected item.
if y < end-4 {
head("SELECTED")
desc := []string{}
if isGroup(m.sel) {
if g, ok := m.groupInfo(m.sel); ok {
desc = append(desc, fmt.Sprintf("%s smaller items", textutil.Count(int64(len(g.ids)))), textutil.Size(g.size))
}
} else if id := m.selectedNode(); id != inventory.NoNode {
n := tr.Node(id)
desc = append(desc, wrap(textutil.Sanitize(n.Name), w)...)
desc = append(desc, textutil.Size(m.sizeOf(id))+" · "+textutil.Percent(m.sizeOf(id), m.sizeOf(m.zoom)))
if n.IsDir() {
desc = append(desc, textutil.Count(int64(n.Files))+" files")
} else {
desc = append(desc, kindLabel(tr, n))
}
}
for _, d := range desc {
if y < end {
cv.Text(x, y, d, w, Style{FG: t.Fg, BG: t.PanelBg})
y++
}
}
}
}
+207
View File
@@ -0,0 +1,207 @@
package tui
import (
tea "charm.land/bubbletea/v2"
"github.com/andydixon/mapsize/internal/textutil"
"github.com/andydixon/mapsize/internal/treemap"
)
// column describes one table column.
type column struct {
title string
width int // 0 = flexible (takes the remaining space)
right bool
}
// tableRow is one rendered row: cell strings plus optional per-cell styles.
type tableRow struct {
cells []string
styles []*Style // nil = default
bar float64 // 0..1 for an optional bar column (-1 = none)
barCol Color
}
// table is a scrollable list with a cursor, shared by the list-style views.
type table struct {
cursor, offset int
height int // rows visible in the last paint
}
func (tb *table) move(d, n int) {
if n == 0 {
tb.cursor = 0
return
}
tb.cursor = min(max(tb.cursor+d, 0), n-1)
}
// key handles navigation keys common to all tables.
func (tb *table) key(k string, n int) bool {
page := max(tb.height-1, 1)
switch k {
case "up", "k":
tb.move(-1, n)
case "down", "j":
tb.move(1, n)
case "pgup", "ctrl+b":
tb.move(-page, n)
case "pgdown", "ctrl+f":
tb.move(page, n)
case "end", "G":
tb.move(n, n)
case "g":
tb.cursor = 0
default:
return false
}
return true
}
func (tb *table) paint(m *Model, cv *Canvas, r treemap.Rect, cols []column, rows []tableRow, title string) {
t := m.theme
cv.Fill(r, " ", t.base())
if r.H < 3 {
return
}
y := r.Y
if title != "" {
cv.Text(r.X+1, y, title, r.W-2, Style{FG: t.Accent, BG: t.Bg, Attr: Bold})
y++
}
// Resolve flexible width.
fixed := 0
for _, c := range cols {
fixed += c.width + 1
}
flex := max(r.W-2-fixed, 8)
x := r.X + 1
hdr := Style{FG: t.Muted, BG: t.Bg, Attr: Bold}
for _, c := range cols {
w := c.width
if w == 0 {
w = flex
}
s := c.title
if c.right {
s = textutil.PadLeft(s, w)
}
cv.Text(x, y, s, w, hdr)
x += w + 1
}
y++
cv.HLine(r.X+1, y, r.W-2, "─", Style{FG: t.Faint, BG: t.Bg})
y++
tb.height = r.Y + r.H - y
if tb.cursor >= len(rows) {
tb.cursor = max(len(rows)-1, 0)
}
if tb.cursor < tb.offset {
tb.offset = tb.cursor
}
if tb.cursor >= tb.offset+tb.height {
tb.offset = tb.cursor - tb.height + 1
}
tb.offset = max(0, min(tb.offset, len(rows)-tb.height))
for i := tb.offset; i < len(rows) && y < r.Y+r.H; i++ {
row := rows[i]
sel := i == tb.cursor
base := t.base()
if sel {
base = Style{FG: t.textOn(t.Sel), BG: t.Sel, Attr: Bold | m.monoRev()}
cv.Fill(treemap.Rect{X: r.X, Y: y, W: r.W, H: 1}, " ", base)
}
x := r.X + 1
for ci, c := range cols {
w := c.width
if w == 0 {
w = flex
}
s := ""
if ci < len(row.cells) {
s = row.cells[ci]
}
st := base
if !sel && ci < len(row.styles) && row.styles[ci] != nil {
st = *row.styles[ci]
}
if c.title == "" && row.bar >= 0 && c.width > 0 {
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 {
cv.Text(x, y, textutil.Truncate(s, w), w, st)
}
x += w + 1
}
idx := i
m.hits = append(m.hits, hit{treemap.Rect{X: r.X, Y: y, W: r.W, H: 1}, func(m *Model, double bool) tea.Cmd {
tb.cursor = idx
if double {
return m.handleKeyName("enter")
}
return nil
}})
y++
}
if len(rows) == 0 {
cv.TextCentered(r.X, r.Y+r.H/2, r.W, "Nothing to show", t.muted())
}
if len(rows) > tb.height && tb.height > 0 {
pos := textutil.Count(int64(tb.cursor+1)) + "/" + textutil.Count(int64(len(rows)))
cv.TextRight(r.X, r.Y, r.W-1, pos, t.muted())
}
}
// paintBar draws a proportional bar with eighth-block precision.
func (m *Model) paintBar(cv *Canvas, x, y, w int, frac float64, col Color, sel bool) {
t := m.theme
if col == 0 {
col = t.Accent
}
bg := t.Bg.Mix(t.Fg, 0.08)
if sel {
bg = t.Sel.Mix(RGB(0, 0, 0), 0.25)
}
eighths := int(frac*float64(w*8) + 0.5)
parts := []string{"", "▏", "▎", "▍", "▌", "▋", "▊", "▉"}
for i := 0; i < w; i++ {
ch := " "
switch {
case eighths >= 8:
ch = "█"
case eighths > 0:
ch = parts[eighths]
}
if t.ASCII {
ch = map[bool]string{true: "#", false: " "}[eighths >= 4]
}
eighths -= 8
st := Style{FG: col, BG: bg}
if t.Mono {
st = Style{}
}
cv.Text(x+i, y, ch, 1, st)
}
}
// handleKeyName lets mouse actions reuse key handling (lock already held).
func (m *Model) handleKeyName(k string) tea.Cmd {
if handled, cmd := m.views[m.view].key(m, k); handled {
return cmd
}
switch k {
case "enter":
return m.inspectSelection()
case "space":
return m.zoomIntoSelection()
}
return nil
}
func plural(n int, s string) string {
if n == 1 {
return "1 " + s
}
return textutil.Count(int64(n)) + " " + s + "s"
}
+165
View File
@@ -0,0 +1,165 @@
package tui
import (
"sort"
"strings"
"github.com/andydixon/mapsize/internal/config"
"github.com/andydixon/mapsize/internal/inventory"
)
// Theme holds every colour the UI uses. Nothing else in the TUI hard-codes
// colours.
type Theme struct {
Name string
Mono bool // no colour: rely on attributes and glyphs
ASCII bool // no box-drawing glyphs
Bg, Fg, Muted, Faint Color
Accent, Sel Color
HeaderBg, HeaderFg Color
StatusBg, StatusFg Color
PanelBg Color
Warn, Err, OK Color
Group Color
EmptyDir Color
Cat [inventory.NumCategories]Color
DiffGrow, DiffShrink Color
DiffNew, DiffSame Color
Ext map[string]Color
}
var themes = map[string]func() *Theme{
"default": defaultTheme,
"dark": darkTheme,
"high-contrast": highContrastTheme,
"mono": monoTheme,
}
// ThemeNames lists built-in themes.
func ThemeNames() []string {
var out []string
for k := range themes {
out = append(out, k)
}
sort.Strings(out)
return out
}
func defaultTheme() *Theme {
return &Theme{
Name: "default",
Bg: RGB(0x16, 0x18, 0x1d), Fg: RGB(0xdc, 0xdf, 0xe4), Muted: RGB(0x8a, 0x91, 0x9c), Faint: RGB(0x4a, 0x50, 0x5a),
Accent: RGB(0x61, 0xaf, 0xef), Sel: RGB(0xff, 0xd8, 0x66),
HeaderBg: RGB(0x21, 0x25, 0x2b), HeaderFg: RGB(0xdc, 0xdf, 0xe4),
StatusBg: RGB(0x2c, 0x31, 0x3a), StatusFg: RGB(0xc8, 0xcc, 0xd4),
PanelBg: RGB(0x1c, 0x1f, 0x25),
Warn: RGB(0xe5, 0xc0, 0x7b), Err: RGB(0xe0, 0x6c, 0x75), OK: RGB(0x98, 0xc3, 0x79),
Group: RGB(0x5c, 0x63, 0x70), EmptyDir: RGB(0x4b, 0x52, 0x63),
Cat: [inventory.NumCategories]Color{
inventory.CatOther: RGB(0x7f, 0x8c, 0x9d),
inventory.CatImage: RGB(0xe5, 0xc0, 0x7b),
inventory.CatVideo: RGB(0xe0, 0x6c, 0x75),
inventory.CatAudio: RGB(0xd1, 0x9a, 0x66),
inventory.CatArchive: RGB(0xc6, 0x78, 0xdd),
inventory.CatDiskImage: RGB(0x8e, 0x7c, 0xf0),
inventory.CatDatabase: RGB(0x56, 0xb6, 0xc2),
inventory.CatCode: RGB(0x98, 0xc3, 0x79),
inventory.CatDocument: RGB(0x61, 0xaf, 0xef),
inventory.CatExecutable: RGB(0xbe, 0x50, 0x46),
inventory.CatLog: RGB(0xa3, 0xa8, 0x6a),
inventory.CatCache: RGB(0x6b, 0x77, 0x88),
},
DiffGrow: RGB(0xf0, 0x5a, 0x5a), DiffShrink: RGB(0x5a, 0xd0, 0x7a),
DiffNew: RGB(0x4f, 0xa8, 0xff), DiffSame: RGB(0x50, 0x56, 0x60),
}
}
func darkTheme() *Theme {
t := defaultTheme()
t.Name = "dark"
t.Bg, t.PanelBg = RGB(0, 0, 0), RGB(0x0c, 0x0c, 0x0e)
t.HeaderBg, t.StatusBg = RGB(0x12, 0x12, 0x16), RGB(0x1a, 0x1a, 0x20)
for i, c := range t.Cat {
t.Cat[i] = c.Mix(RGB(0, 0, 0), 0.2)
}
return t
}
func highContrastTheme() *Theme {
t := defaultTheme()
t.Name = "high-contrast"
t.Bg, t.Fg, t.Muted = RGB(0, 0, 0), RGB(0xff, 0xff, 0xff), RGB(0xd0, 0xd0, 0xd0)
t.HeaderBg, t.StatusBg, t.PanelBg = RGB(0, 0, 0), RGB(0x20, 0x20, 0x20), RGB(0, 0, 0)
t.Sel, t.Accent = RGB(0xff, 0xff, 0x00), RGB(0x00, 0xff, 0xff)
t.Cat = [inventory.NumCategories]Color{
RGB(0xb0, 0xb0, 0xb0), RGB(0xff, 0xd7, 0x00), RGB(0xff, 0x30, 0x30), RGB(0xff, 0x8c, 0x00),
RGB(0xff, 0x00, 0xff), RGB(0x9b, 0x7b, 0xff), RGB(0x00, 0xe5, 0xff), RGB(0x00, 0xff, 0x00),
RGB(0x40, 0x9c, 0xff), RGB(0xff, 0x55, 0x55), RGB(0xc8, 0xff, 0x00), RGB(0x90, 0x90, 0xa0),
}
return t
}
func monoTheme() *Theme {
return &Theme{Name: "mono", Mono: true}
}
// LoadTheme returns a built-in theme with user overrides applied.
func LoadTheme(name string, s config.Settings) *Theme {
f, ok := themes[strings.ToLower(name)]
if !ok {
f = defaultTheme
}
t := f()
if t.Mono {
return t
}
for k, v := range s.CategoryColors {
if c, ok := inventory.ParseCategory(k); ok {
if col, ok := Hex(v); ok {
t.Cat[c] = col
}
}
}
for k, v := range s.ExtensionColors {
if col, ok := Hex(v); ok {
if t.Ext == nil {
t.Ext = map[string]Color{}
}
t.Ext[strings.ToLower(strings.TrimPrefix(k, "."))] = col
}
}
return t
}
// Styles derived from the theme.
func (t *Theme) base() Style { return Style{FG: t.Fg, BG: t.Bg} }
func (t *Theme) muted() Style { return Style{FG: t.Muted, BG: t.Bg} }
func (t *Theme) header() Style { return Style{FG: t.HeaderFg, BG: t.HeaderBg} }
func (t *Theme) status() Style { return Style{FG: t.StatusFg, BG: t.StatusBg} }
func (t *Theme) panel() Style { return Style{FG: t.Fg, BG: t.PanelBg} }
func (t *Theme) box() BoxChars {
if t.ASCII {
return BoxASCII
}
return BoxLight
}
func (t *Theme) selBox() BoxChars {
if t.ASCII {
return BoxASCIISel
}
return BoxDouble
}
// textOn picks a readable text colour for a background.
func (t *Theme) textOn(bg Color) Color {
if bg == 0 {
return t.Fg
}
if bg.Luma() > 0.55 {
return RGB(0x10, 0x12, 0x16)
}
return RGB(0xf2, 0xf4, 0xf7)
}
+420
View File
@@ -0,0 +1,420 @@
package tui
import (
"context"
"fmt"
"math/rand"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"
"github.com/andydixon/mapsize/internal/inventory"
"github.com/andydixon/mapsize/internal/scan"
"github.com/andydixon/mapsize/internal/treemap"
)
const hostile = "evil\x1b]0;pwned\x07\x1b[2J.iso"
func testTree() *inventory.Tree {
t := inventory.New("/srv", inventory.KindDir)
add := func(p inventory.NodeID, name string, size int64) inventory.NodeID {
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)
n.MTime = 1_700_000_000_000_000_000
return id
}
dir := func(p inventory.NodeID, name string) inventory.NodeID {
id := t.Add(p, name, inventory.KindDir)
t.Node(id).Flags |= inventory.FlagScanned
return id
}
backups := dir(0, "backups")
for i := range 40 {
add(backups, fmt.Sprintf("dump-%02d.tar.gz", i), int64(1+i)<<30)
}
vms := dir(0, "vms")
add(vms, "ubuntu.qcow2", 86<<30)
add(vms, "win.vmdk", 40<<30)
media := dir(0, "media")
photos := dir(media, "photos 日本語")
for i := range 300 {
add(photos, fmt.Sprintf("img%03d.jpg", i), int64(i+1)<<20)
}
add(media, hostile, 9<<30)
add(media, "👨‍👩‍👧 family.mp4", 12<<30)
tiny := dir(0, "tiny")
for i := range 2000 {
add(tiny, fmt.Sprintf("f%d", i), 100)
}
add(0, "README", 10)
t.Recompute()
t.Stats.Complete = true
return t
}
func newTestModel(t *testing.T) *Model {
t.Helper()
tr := testTree()
m := newModel(Options{Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, tr, nil }},
nil, tr, func() {})
m.profile = colorprofile.TrueColor
return m
}
func send(m *Model, msgs ...tea.Msg) {
for _, msg := range msgs {
m.Update(msg)
}
}
func key(s string) tea.KeyPressMsg {
switch s {
case "up":
return tea.KeyPressMsg{Code: tea.KeyUp}
case "down":
return tea.KeyPressMsg{Code: tea.KeyDown}
case "left":
return tea.KeyPressMsg{Code: tea.KeyLeft}
case "right":
return tea.KeyPressMsg{Code: tea.KeyRight}
case "enter":
return tea.KeyPressMsg{Code: tea.KeyEnter}
case "esc":
return tea.KeyPressMsg{Code: tea.KeyEscape}
case "space":
return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "}
case "backspace":
return tea.KeyPressMsg{Code: tea.KeyBackspace}
case "home":
return tea.KeyPressMsg{Code: tea.KeyHome}
case "tab":
return tea.KeyPressMsg{Code: tea.KeyTab}
}
r := []rune(s)[0]
return tea.KeyPressMsg{Code: r, Text: s}
}
func size(w, h int) tea.WindowSizeMsg { return tea.WindowSizeMsg{Width: w, Height: h} }
// checkFrame verifies the rendered frame exactly fills w×h cells and that no
// filename control sequence leaked through.
func checkFrame(t *testing.T, m *Model, w, h int) string {
t.Helper()
content := m.View().Content
if strings.Contains(content, "\x1b]") || strings.Contains(content, "\x07") || strings.Contains(content, "\x1b[2J") {
t.Fatalf("terminal control sequence from a filename leaked into the frame")
}
lines := strings.Split(content, "\n")
if len(lines) != h {
t.Fatalf("%dx%d: frame has %d lines", w, h, len(lines))
}
for i, l := range lines {
if got := ansi.StringWidth(l); got != w {
t.Fatalf("%dx%d: line %d has width %d: %q", w, h, i, got, ansi.Strip(l))
}
}
return ansi.Strip(content)
}
func TestResizeTransitionsPreserveState(t *testing.T) {
m := newTestModel(t)
send(m, size(120, 40))
checkFrame(t, m, 120, 40)
// Zoom into media and select something that is not the default.
m.tree.RLock()
media := m.kidsOf(0)
m.tree.RUnlock()
var mediaID inventory.NodeID
for _, id := range media.ids {
if m.tree.Node(id).Name == "media" {
mediaID = id
}
}
m.sel, m.userSel = int64(mediaID), true
send(m, key("space"))
if m.zoom != mediaID {
t.Fatalf("zoom = %d want media %d", m.zoom, mediaID)
}
checkFrame(t, m, 120, 40)
send(m, key("right"))
sel := m.sel
for _, sz := range [][2]int{{80, 24}, {120, 40}, {300, 80}, {70, 20}, {180, 50}, {40, 10}, {39, 9}, {200, 60}} {
send(m, size(sz[0], sz[1]))
checkFrame(t, m, sz[0], sz[1])
if m.zoom != mediaID {
t.Fatalf("%v: zoom lost", sz)
}
if m.sel != sel && !isGroup(m.sel) {
t.Fatalf("%v: selection changed from %d to %d", sz, sel, m.sel)
}
}
// The layout must be recomputed for the new geometry, not stretched.
if m.tm == nil || m.tm.rect.W > 200 || m.tm.rect.H > 60 {
t.Fatalf("layout not recomputed for final size: %+v", m.tm.rect)
}
for _, b := range m.tm.blocks {
r := m.tm.rect
if b.X < r.X || b.Y < r.Y || b.X+b.W > r.X+r.W || b.Y+b.H > r.Y+r.H {
t.Fatalf("block %+v outside treemap %+v", b, r)
}
}
}
func TestResizeStorm(t *testing.T) {
m := newTestModel(t)
r := rand.New(rand.NewSource(1))
send(m, size(120, 40))
m.View()
send(m, key("right"), key("down"))
sel := m.sel
for i := 0; i < 300; i++ {
send(m, size(20+r.Intn(300), 5+r.Intn(90)))
if i%7 == 0 {
m.View() // renders interleave with resizes like the real event loop
}
}
send(m, size(150, 45))
checkFrame(t, m, 150, 45)
if m.sel != sel {
t.Fatalf("selection lost in resize storm")
}
}
func TestModalSurvivesResize(t *testing.T) {
m := newTestModel(t)
send(m, size(120, 40), key("enter"))
if len(m.modals) != 1 {
t.Fatal("Enter should open the info modal")
}
for _, sz := range [][2]int{{120, 40}, {60, 14}, {41, 10}, {300, 80}, {45, 12}} {
send(m, size(sz[0], sz[1]))
out := checkFrame(t, m, sz[0], sz[1])
if !strings.Contains(out, "ITEM INFORMATION") {
t.Fatalf("%v: modal title missing:\n%s", sz, out)
}
f := m.modals[0].(*modalFrame)
if f.viewH < 1 || f.viewH > sz[1] {
t.Fatalf("%v: modal viewport %d", sz, f.viewH)
}
}
// Scrolling in a tiny terminal must stay in range.
send(m, size(45, 12))
for range 100 {
send(m, key("down"))
}
checkFrame(t, m, 45, 12)
f := m.modals[0].(*modalFrame)
if f.scroll < 0 || f.scroll > f.contentN {
t.Fatalf("scroll out of range: %d of %d", f.scroll, f.contentN)
}
send(m, key("esc"))
if len(m.modals) != 0 {
t.Fatal("Esc should close the modal")
}
}
func TestSpatialNavigationUsesGeometry(t *testing.T) {
m := newTestModel(t)
send(m, size(160, 45))
m.View()
blocks := m.tm.blocks
for _, dir := range []string{"right", "down", "left", "up"} {
before := m.sel
send(m, key(dir))
m.View()
if m.sel == before {
continue // edge of the map
}
var a, b = findBlock(blocks, before), findBlock(blocks, m.sel)
switch dir {
case "right":
if b.X < a.X+a.W {
t.Fatalf("right moved to a block that is not to the right: %+v -> %+v", a, b)
}
case "left":
if b.X+b.W > a.X {
t.Fatalf("left moved wrongly: %+v -> %+v", a, b)
}
case "down":
if b.Y < a.Y+a.H {
t.Fatalf("down moved wrongly: %+v -> %+v", a, b)
}
case "up":
if b.Y+b.H > a.Y {
t.Fatalf("up moved wrongly: %+v -> %+v", a, b)
}
}
}
}
func findBlock(bs []treemap.Block, id int64) treemap.Block {
for _, b := range bs {
if b.ID == id {
return b
}
}
return treemap.Block{}
}
func TestZoomBreadcrumbsAndHome(t *testing.T) {
m := newTestModel(t)
send(m, size(100, 30))
m.tree.RLock()
var media, photos inventory.NodeID
m.tree.Walk(0, func(id inventory.NodeID, n *inventory.Node) bool {
switch n.Name {
case "media":
media = id
case "photos 日本語":
photos = id
}
return true
})
m.tree.RUnlock()
m.sel, m.userSel = int64(media), true
send(m, key("space"))
m.sel = int64(photos)
send(m, key("space"))
if m.zoom != photos {
t.Fatal("did not zoom into photos")
}
out := checkFrame(t, m, 100, 30)
if !strings.Contains(out, "media › photos 日本語") {
t.Fatalf("breadcrumbs missing:\n%s", out)
}
// Narrow terminal: breadcrumbs truncate from the left, keeping the tail.
send(m, size(42, 12))
out = checkFrame(t, m, 42, 12)
if !strings.Contains(out, "photos") {
t.Fatalf("breadcrumb tail missing:\n%s", out)
}
send(m, key("backspace"))
if m.zoom != media || m.sel != int64(photos) {
t.Fatalf("backspace: zoom %d sel %d", m.zoom, m.sel)
}
send(m, key("home"))
if m.zoom != 0 {
t.Fatal("home did not return to root")
}
}
func TestFilterTransformsMap(t *testing.T) {
m := newTestModel(t)
send(m, size(120, 40), key("/"))
for _, r := range "ext = gz" {
send(m, tea.KeyPressMsg{Code: r, Text: string(r)})
}
if m.search.err != "" || m.search.query == nil {
t.Fatalf("query not parsed: %q", m.search.err)
}
msg := m.applyFilter()()
send(m, msg, key("enter"))
if m.search.result == nil {
t.Fatal("no filter result")
}
out := checkFrame(t, m, 120, 40)
if strings.Contains(out, "vms/") || !strings.Contains(out, "backups/") {
t.Fatalf("filter not applied to the map:\n%s", out)
}
if !strings.Contains(out, "FILTER") {
t.Fatal("filter indicator missing")
}
send(m, key("esc"))
if m.search.query != nil || m.search.result != nil {
t.Fatal("Esc should clear the filter")
}
out = checkFrame(t, m, 120, 40)
if !strings.Contains(out, "vms/") {
t.Fatalf("map not restored after clearing filter:\n%s", out)
}
}
func TestHostileNameSanitisedInModal(t *testing.T) {
m := newTestModel(t)
send(m, size(120, 40))
var id inventory.NodeID
m.tree.Walk(0, func(i inventory.NodeID, n *inventory.Node) bool {
if n.Name == hostile {
id = i
}
return true
})
m.openModal(newInfoModal(m, id))
out := checkFrame(t, m, 120, 40)
if !strings.Contains(out, `\x1b]0;pwned\x07`) {
t.Fatalf("escaped name not shown:\n%s", out)
}
}
func TestViewsRenderAtManySizes(t *testing.T) {
m := newTestModel(t)
for range m.views {
for _, sz := range [][2]int{{40, 10}, {80, 24}, {150, 45}, {91, 22}} {
send(m, size(sz[0], sz[1]))
checkFrame(t, m, sz[0], sz[1])
send(m, key("down"), key("down"))
checkFrame(t, m, sz[0], sz[1])
}
send(m, key("tab"))
}
send(m, key("?"))
send(m, size(60, 15))
checkFrame(t, m, 60, 15)
}
func TestLevelOfDetailGroupsTinyFiles(t *testing.T) {
m := newTestModel(t)
send(m, size(100, 30))
m.tree.RLock()
var tiny inventory.NodeID
m.tree.Children(0, func(id inventory.NodeID, n *inventory.Node) {
if n.Name == "tiny" {
tiny = id
}
})
m.tree.RUnlock()
m.zoomTo(tiny)
m.View()
if len(m.tm.blocks) > 100*30/topMinArea+1 {
t.Fatalf("%d blocks for 2000 equal files: LOD not applied", len(m.tm.blocks))
}
if _, ok := m.tm.groups[groupID(tiny)]; !ok {
t.Fatal("expected a group block for small items")
}
}
func TestCtrlCQuitsWhenIdle(t *testing.T) {
m := newTestModel(t)
send(m, size(80, 24))
_, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
if cmd == nil || !m.quit {
t.Fatal("Ctrl+C should quit when no scan runs")
}
}
func BenchmarkRenderFrame(b *testing.B) {
tr := testTree()
m := newModel(Options{}, nil, tr, func() {})
m.profile = colorprofile.TrueColor
send(m, size(200, 60))
for b.Loop() {
m.dirty = true
m.View()
}
}
func BenchmarkResizeRelayout(b *testing.B) {
tr := testTree()
m := newModel(Options{}, nil, tr, func() {})
i := 0
for b.Loop() {
send(m, size(100+i%100, 30+i%30))
m.View()
i++
}
}
+456
View File
@@ -0,0 +1,456 @@
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
}