Files
mapsize/internal/tui/model.go
T
Andy Dixon f4ef12a0dd Fix review findings: hostile snapshots, stale async results, trash safety
- snapshot: depth limit (stack overflow in exporters), decompressed-size
  limit (gzip bombs), per-node size cap and saturating aggregation
- export: sanitise exclusion patterns from snapshot metadata
- tui: drop duplicate/trash/filter results that belong to a replaced
  tree; cancel duplicate search on rescan
- trash: refuse symlinked or foreign shared-filesystem trash dirs; never
  overwrite an existing trashed item
- scan: no symlink following without inode identity; correct unscanned
  count and keep real read errors after cancellation
2026-10-02 08:46:52 +01:00

448 lines
12 KiB
Go

// 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"
"log/slog"
"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
// debugPanic lets developers verify terminal restoration on crashes:
// MAPSIZE_DEBUG_PANIC=ui panics on the first key press; =scanner panics in
// a background goroutine shortly after start.
var debugPanic = os.Getenv("MAPSIZE_DEBUG_PANIC")
// Init starts ticking and waits for scan completion.
func (m *Model) Init() tea.Cmd {
cmds := []tea.Cmd{tea.RequestWindowSize}
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:
if debugPanic == "ui" {
panic("MAPSIZE_DEBUG_PANIC=ui: deliberate panic to test terminal restoration")
}
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()
slog.Info("scan finished", "files", files, "dirs", st.Dirs, "bytes", total, "errors", errs,
"cancelled", st.Cancelled, "elapsed", el)
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{}
if m.dups != nil {
m.dups.cancel()
m.dups = nil
// Drop the duplicates view: its rows referred to the old tree.
for i, v := range m.views {
if _, ok := v.(*dupView); ok {
m.views = append(m.views[:i], m.views[i+1:]...)
m.view = 0
break
}
}
}
m.search.result = nil
m.search.gen++ // results computed against the old tree are discarded
m.invalidate()
return tea.Batch(waitScan(s), m.startTicking(), m.info("Rescanning…"), m.applyFilter())
}
// 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
}