fix: pin parent directory and verify owner/type before trashing Trash renamed the item by path after the confirmation dialog, so a parent directory swapped for a symlink since the scan (e.g. by another user, while an administrator cleans their home) redirected the move to an unrelated file. The parent is now opened once and the item is checked (owner and file type as scanned) and renamed relative to that fd. macOS volume trash directories now get the same ownership/symlink checks as freedesktop.org ones on Linux. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
260 lines
7.4 KiB
Go
260 lines
7.4 KiB
Go
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
|
||
}
|
||
|
||
// 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)
|
||
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)))
|
||
}
|
||
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 {
|
||
t := m.tree
|
||
return func() tea.Msg { return trashDoneMsg{t, id, platform.Trash(path, want)} }
|
||
},
|
||
})
|
||
return nil
|
||
}
|
||
|
||
func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd {
|
||
if msg.err != nil {
|
||
return m.warn("Trash 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("Moved to " + platform.TrashName)
|
||
}
|
||
// 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 {
|
||
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
|
||
}
|