Files
mapsize/internal/tui/actions.go
T
Andy Dixon 8d9344cfef mapsize 1.2.0: permanent delete and in-map reveal when headless
On a headless Linux or BSD system (no DISPLAY or WAYLAND_DISPLAY) nothing
empties the freedesktop trash, so trashing never freed space. There, d now
deletes permanently after an explicit confirmation, checking owner and type
against the scan and removing through an os.Root on the parent so a swapped
symlink cannot redirect it. o shows the folder in the map instead of
launching a file manager. Desktop sessions, macOS and Windows are unchanged.
2026-10-03 12:50:07 +01:00

277 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tui
import (
"context"
"fmt"
"io/fs"
"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 {
tree *inventory.Tree // the tree id belongs to; results for a replaced tree are dropped
id inventory.NodeID
err error
done string // "Moved to trash" or "Deleted"
}
// requestTrash asks for confirmation before moving the selection to the
// trash. On a headless system (no desktop, so nobody empties the trash and
// the space is never freed) it deletes permanently instead, after a
// confirmation that says so.
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 deleting items")
}
id := m.selectedNode()
if id == inventory.NoNode || id == m.tree.Root() {
return nil
}
n := m.tree.Node(id)
path := m.tree.Path(id)
want := platform.Expect{UID: n.UID, Mode: fs.FileMode(n.Mode)}
what := "file"
if n.IsDir() {
what = fmt.Sprintf("directory with %s files", textutil.Count(int64(n.Files)))
}
body := []string{textutil.Sanitize(path), "", fmt.Sprintf("%s · %s", what, textutil.Size(n.Total(m.sizeMode))), ""}
if platform.Headless() {
m.openModal(&confirmModal{
ttl: "Delete permanently? Are you sure?",
body: append(body, "This is a headless system (no X or Wayland display), so there is no trash:",
"it will be permanently deleted and cannot be recovered."),
yes: "delete permanently",
onYes: func(m *Model) tea.Cmd {
t := m.tree
return func() tea.Msg { return trashDoneMsg{t, id, platform.Delete(path, want), "Deleted"} }
},
})
return nil
}
m.openModal(&confirmModal{
ttl: "Move to trash?",
body: append(body, "It will be moved to the "+platform.TrashName+", not permanently deleted."),
yes: "move to " + platform.TrashName,
onYes: func(m *Model) tea.Cmd {
t := m.tree
return func() tea.Msg {
return trashDoneMsg{t, id, platform.Trash(path, want), "Moved to " + platform.TrashName}
}
},
})
return nil
}
func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd {
if msg.err != nil {
return m.warn("Delete failed: " + textutil.Sanitize(msg.err.Error()))
}
if msg.tree != m.tree || m.scanning {
// A rescan replaced the inventory; the new scan reflects the change.
return m.info(msg.done)
}
// 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(msg.done + ": " + 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 {
finder *duplicate.Finder // identifies the search; stale results are dropped
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{f, 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 && d.finder == m.dups.finder {
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, false}, {"COPIES", 16, false, false}, {"", 0, false, 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.scroll(up, len(v.rows))
return nil
}