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.
This commit is contained in:
Andy Dixon
2026-10-03 12:50:07 +01:00
parent 352d4292df
commit 8d9344cfef
15 changed files with 214 additions and 27 deletions
+28 -11
View File
@@ -21,10 +21,13 @@ 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. Permanent deletion is deliberately not offered.
// 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:
@@ -32,7 +35,7 @@ func (m *Model) requestTrash() tea.Cmd {
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")
return m.warn("Wait for the scan to finish before deleting items")
}
id := m.selectedNode()
if id == inventory.NoNode || id == m.tree.Root() {
@@ -45,15 +48,29 @@ func (m *Model) requestTrash() tea.Cmd {
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: []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,
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)} }
return func() tea.Msg {
return trashDoneMsg{t, id, platform.Trash(path, want), "Moved to " + platform.TrashName}
}
},
})
return nil
@@ -61,11 +78,11 @@ func (m *Model) requestTrash() tea.Cmd {
func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd {
if msg.err != nil {
return m.warn("Trash failed: " + textutil.Sanitize(msg.err.Error()))
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("Moved to " + platform.TrashName)
return m.info(msg.done)
}
// The scan has finished, so the UI goroutine is the tree's only writer.
t := m.tree
@@ -87,7 +104,7 @@ func (m *Model) trashDone(msg trashDoneMsg) tea.Cmd {
name := n.Name
t.Unlock()
m.invalidate()
return m.info("Moved to " + platform.TrashName + ": " + textutil.Sanitize(name))
return m.info(msg.done + ": " + textutil.Sanitize(name))
}
// ---- Duplicates ----------------------------------------------------------
+11
View File
@@ -226,6 +226,17 @@ func (m *Model) reveal() tea.Cmd {
if !ok {
return nil
}
if platform.Headless() {
// No file manager: show the folder in the map instead.
id := m.selectedNode()
if m.tree.Node(id).IsDir() && len(m.kidsOf(id).ids) > 0 {
m.zoomTo(id)
m.setViewByName("Map")
} else {
m.jumpTo(id)
}
return m.info("Showing " + textutil.Sanitize(m.tree.Path(m.zoom)))
}
if err := platform.Reveal(p); err != nil {
return m.warn("Open failed: " + textutil.Sanitize(err.Error()))
}
+22 -5
View File
@@ -39,9 +39,9 @@ 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"
s := "Esc close · c copy path · o " + revealVerb() + " · Space go to"
if !m.opts.ReadOnly && !m.snapshot {
s += " · d trash"
s += " · d " + deleteVerb()
}
return s
}
@@ -168,7 +168,8 @@ func (md *infoModal) key(m *Model, k string) (bool, tea.Cmd) {
return false, m.copyPath(m.tree.Path(md.id))
case "o":
m.sel = int64(md.id)
return false, m.reveal()
// Headless, o navigates the map, which the modal would cover.
return platform.Headless(), m.reveal()
case "enter":
return true, nil
case "g", "right":
@@ -273,9 +274,9 @@ func (helpModal) content(m *Model, _ int) []line {
sec("APPLICATION")
row("a", "toggle allocated / logical (apparent) size")
row("c", "copy path to clipboard")
row("o", "reveal in file manager")
row("o", revealVerb())
if !m.opts.ReadOnly {
row("d", "move to trash (asks first)")
row("d", deleteVerb()+" (asks first)")
}
row("s", "save snapshot")
row("r", "rescan")
@@ -372,3 +373,19 @@ func (md *saveModal) key(m *Model, k string) (bool, tea.Cmd) {
}
return false, nil
}
// revealVerb and deleteVerb describe o and d, which change meaning on a
// headless system.
func revealVerb() string {
if platform.Headless() {
return "show folder in map"
}
return "reveal in file manager"
}
func deleteVerb() string {
if platform.Headless() {
return "delete permanently"
}
return "move to " + platform.TrashName
}
+17
View File
@@ -70,3 +70,20 @@ func TestNestingReachesDeepMedia(t *testing.T) {
t.Fatalf("only %d episode blocks drawn", files)
}
}
// Headless, o shows the selected folder in the map instead of launching a
// file manager.
func TestRevealHeadlessZoomsMap(t *testing.T) {
t.Setenv("DISPLAY", "")
t.Setenv("WAYLAND_DISPLAY", "")
tr := rootTree()
m := newModel(Options{Start: func(context.Context) (*scan.Scanner, *inventory.Tree, error) { return nil, tr, nil }}, nil, tr, func() {})
send(m, size(160, 45))
mnt := m.kidsOf(m.zoom).ids[0]
m.snapshot = false // reveal is refused for snapshots
m.sel, m.userSel = int64(mnt), true
send(m, key("o"))
if m.zoom != mnt {
t.Fatalf("zoom = %q, want mnt", tr.Path(m.zoom))
}
}