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
+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
}