1 Commits
Author SHA1 Message Date
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
15 changed files with 214 additions and 27 deletions
+3
View File
@@ -301,4 +301,7 @@ Filenames and snapshot contents are untrusted.
Aggregates are recomputed with saturating arithmetic rather than trusted.
* No shell is ever invoked. "Open/reveal" and trash use direct `exec` with
argument vectors or native APIs; paths are passed as single arguments.
* Permanent delete (headless only) checks the item's owner and type against
the scan and removes it through an `os.Root` on the parent directory, so
a symlink swapped in since the scan cannot redirect it.
* `--read-only` disables every mutating action in the UI.
+9
View File
@@ -1,5 +1,14 @@
# Changelog
## 1.2.0 — 2026-10-03
### Changed
- On a headless Linux or BSD system (no `DISPLAY` or `WAYLAND_DISPLAY`), `d`
deletes permanently after an "are you sure" confirmation instead of moving
to a trash nobody empties, and `o` shows the folder in the map instead of
trying to open a file manager. Desktop sessions, macOS and Windows still
use the trash and the file manager.
## 1.1.0 — 2026-10-03
### Fixed
+12 -5
View File
@@ -56,8 +56,8 @@ file-manager integration have not yet been tested there.
| x, g, e | file types, top lists, scan info & errors |
| D | find duplicates |
| a | toggle allocated / logical size |
| c, o | copy path, reveal in file manager |
| d | move to trash (asks; disabled with `--read-only`) |
| c, o | copy path, reveal in file manager (headless: show the folder in the map) |
| d | move to trash, or delete permanently when headless (asks; disabled with `--read-only`) |
| s, r | save snapshot, rescan |
| T | cycle theme |
| ? | help |
@@ -160,9 +160,16 @@ uses ordinary filesystem APIs, and duplicate detection reads file content
`d` moves the selected item to the system trash (freedesktop.org trash on
Linux, `~/.Trash` on macOS, the Recycle Bin on Windows) after confirmation,
using native file operations — never a shell. Permanent deletion is not
offered. Trash is unavailable while scanning, for snapshots, and in
read-only mode.
using native file operations — never a shell.
On a headless Linux or BSD system (neither `DISPLAY` nor `WAYLAND_DISPLAY`
set) nothing ever empties the trash, so trashing would not free any space.
There `d` deletes permanently instead, after a confirmation that says it
cannot be undone, and `o` shows the folder in the map rather than opening a
file manager.
Deletion is unavailable while scanning, for snapshots, and in read-only
mode.
## Documentation
+8 -4
View File
@@ -1,5 +1,5 @@
.\" Manual page for mapsize. View with: man -l docs/mapsize.1
.TH MAPSIZE 1 "October 2026" "mapsize 1.1.0" "User Commands"
.TH MAPSIZE 1 "October 2026" "mapsize 1.2.0" "User Commands"
.SH NAME
mapsize \- interactive terminal disk usage analyser with a navigable treemap
.SH SYNOPSIS
@@ -95,7 +95,7 @@ Useful over SSH, where \fBCOLORTERM\fR is often not forwarded.
Disable mouse support. Everything works from the keyboard.
.TP
.B \-\-read\-only
Disable every action that modifies the filesystem (moving to trash) and
Disable every action that modifies the filesystem (trash and delete) and
show \fBREAD ONLY\fR in the header. Saving a snapshot file is still
allowed. This does not make
.B mapsize
@@ -223,10 +223,14 @@ Toggle allocated / logical size.
Copy the selected path to the clipboard (OSC 52; terminal support varies).
.TP
.B o
Reveal the selected item in the desktop file manager.
Reveal the selected item in the desktop file manager. On a headless system
(no \fBDISPLAY\fR or \fBWAYLAND_DISPLAY\fR), show the folder in the map
instead.
.TP
.BR d ", " Delete
Move the selected item to the trash after confirmation. Disabled with
Move the selected item to the trash after confirmation. On a headless
Linux or BSD system, where nothing would empty the trash, delete it
permanently instead after a confirmation that says so. Disabled with
\fB\-\-read\-only\fR, for snapshots, and while scanning.
.TP
.B s
+1 -1
View File
@@ -8,4 +8,4 @@ const (
)
// Version is stamped at release time via -ldflags -X.
var Version = "1.1.0"
var Version = "1.2.0"
+35
View File
@@ -0,0 +1,35 @@
//go:build unix
package platform
import (
"errors"
"io/fs"
"os"
"path/filepath"
"syscall"
)
// Delete permanently removes path (recursively for a directory) if it still
// matches want. The check and the removal both go through an os.Root on the
// parent directory, so a symlink swapped in since the scan cannot redirect
// the removal outside it.
func Delete(path string, want Expect) error {
if err := checkAbs(path); err != nil {
return err
}
dir, base := filepath.Dir(path), filepath.Base(path)
root, err := os.OpenRoot(dir)
if err != nil {
return err
}
defer root.Close()
fi, err := root.Lstat(base)
if err != nil {
return err
}
if fi.Sys().(*syscall.Stat_t).Uid != want.UID || fi.Mode()&fs.ModeType != want.Mode&fs.ModeType {
return errors.New("item changed since the scan (owner or type differs); rescan first")
}
return root.RemoveAll(base)
}
+4
View File
@@ -75,3 +75,7 @@ func volumeOf(path string) string {
}
return ""
}
// Headless is always false: macOS has Finder and the Trash even without
// $DISPLAY (which it does not use).
func Headless() bool { return false }
+7
View File
@@ -123,3 +123,10 @@ func mountTop(path string, dev uint64) string {
cur = parent
}
}
// Headless reports whether there is no graphical session (no X or Wayland
// display), so there is no file manager to open and no desktop trash anyone
// will empty.
func Headless() bool {
return os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == ""
}
+40
View File
@@ -113,3 +113,43 @@ func TestTrashRefusesSwappedItem(t *testing.T) {
t.Fatal(err)
}
}
func TestDeletePermanently(t *testing.T) {
base := t.TempDir()
dir := filepath.Join(base, "S01")
os.MkdirAll(filepath.Join(dir, "extras"), 0o755)
os.WriteFile(filepath.Join(dir, "extras", "E01.mkv"), []byte("x"), 0o644)
// A symlink inside must be removed, not followed.
keep := filepath.Join(base, "keep")
os.WriteFile(keep, []byte("k"), 0o644)
os.Symlink(keep, filepath.Join(dir, "link"))
w := want(t, dir)
if err := Delete(dir, Expect{UID: w.UID, Mode: 0}); err == nil {
t.Fatal("type mismatch accepted")
}
if err := Delete(dir, w); err != nil {
t.Fatal(err)
}
if _, err := os.Lstat(dir); !os.IsNotExist(err) {
t.Fatal("directory still present")
}
if _, err := os.Lstat(keep); err != nil {
t.Fatal("symlink target was deleted")
}
if err := Delete("relative/path", Expect{}); err == nil {
t.Fatal("relative paths must be refused")
}
}
func TestHeadless(t *testing.T) {
t.Setenv("DISPLAY", "")
t.Setenv("WAYLAND_DISPLAY", "")
if !Headless() {
t.Fatal("no display should be headless")
}
t.Setenv("WAYLAND_DISPLAY", "wayland-0")
if Headless() {
t.Fatal("wayland session reported headless")
}
}
+11 -1
View File
@@ -2,7 +2,10 @@
package platform
import "errors"
import (
"errors"
"os"
)
// TrashName is the user-facing name of the trash.
const TrashName = "trash"
@@ -12,3 +15,10 @@ func Reveal(string) error { return errors.New("not supported on this platform")
// Trash is unsupported on this platform.
func Trash(string, Expect) error { return errors.New("trash not supported on this platform") }
// Headless reports whether there is no graphical session (no X or Wayland
// display), so there is no file manager to open and no desktop trash anyone
// will empty.
func Headless() bool {
return os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == ""
}
+6
View File
@@ -70,3 +70,9 @@ func Trash(path string, _ Expect) error {
}
return nil
}
// Headless is always false: Windows always has Explorer and the Recycle Bin.
func Headless() bool { return false }
// Delete is not offered on Windows; items go to the Recycle Bin.
func Delete(string, Expect) error { return errors.New("permanent delete not supported on Windows") }
+26 -9
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."},
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))
}
}