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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -8,4 +8,4 @@ const (
|
||||
)
|
||||
|
||||
// Version is stamped at release time via -ldflags -X.
|
||||
var Version = "1.1.0"
|
||||
var Version = "1.2.0"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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") == ""
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") == ""
|
||||
}
|
||||
|
||||
@@ -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") }
|
||||
|
||||
+28
-11
@@ -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 ----------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user