From 8d9344cfef1df25a072c62d771c0a27dd631a2bd Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sat, 3 Oct 2026 12:50:07 +0100 Subject: [PATCH] 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. --- ARCHITECTURE.md | 3 +++ CHANGELOG.md | 9 +++++++ README.md | 17 ++++++++---- docs/mapsize.1 | 12 ++++++--- internal/brand/brand.go | 2 +- internal/platform/delete_unix.go | 35 +++++++++++++++++++++++++ internal/platform/ops_darwin.go | 4 +++ internal/platform/ops_linux.go | 7 +++++ internal/platform/ops_linux_test.go | 40 +++++++++++++++++++++++++++++ internal/platform/ops_other.go | 12 ++++++++- internal/platform/ops_windows.go | 6 +++++ internal/tui/actions.go | 39 ++++++++++++++++++++-------- internal/tui/keys.go | 11 ++++++++ internal/tui/modals.go | 27 +++++++++++++++---- internal/tui/nest_test.go | 17 ++++++++++++ 15 files changed, 214 insertions(+), 27 deletions(-) create mode 100644 internal/platform/delete_unix.go diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 11fde94..c74210b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 450fc24..1762481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 67f98cc..4d397b6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/mapsize.1 b/docs/mapsize.1 index 71292e1..723d283 100644 --- a/docs/mapsize.1 +++ b/docs/mapsize.1 @@ -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 diff --git a/internal/brand/brand.go b/internal/brand/brand.go index 28eed09..6a5c54c 100644 --- a/internal/brand/brand.go +++ b/internal/brand/brand.go @@ -8,4 +8,4 @@ const ( ) // Version is stamped at release time via -ldflags -X. -var Version = "1.1.0" +var Version = "1.2.0" diff --git a/internal/platform/delete_unix.go b/internal/platform/delete_unix.go new file mode 100644 index 0000000..68febcd --- /dev/null +++ b/internal/platform/delete_unix.go @@ -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) +} diff --git a/internal/platform/ops_darwin.go b/internal/platform/ops_darwin.go index b038a47..8880df1 100644 --- a/internal/platform/ops_darwin.go +++ b/internal/platform/ops_darwin.go @@ -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 } diff --git a/internal/platform/ops_linux.go b/internal/platform/ops_linux.go index 03eb026..56a1353 100644 --- a/internal/platform/ops_linux.go +++ b/internal/platform/ops_linux.go @@ -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") == "" +} diff --git a/internal/platform/ops_linux_test.go b/internal/platform/ops_linux_test.go index 9980d49..dc8af24 100644 --- a/internal/platform/ops_linux_test.go +++ b/internal/platform/ops_linux_test.go @@ -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") + } +} diff --git a/internal/platform/ops_other.go b/internal/platform/ops_other.go index ee3d9d5..02cc8e1 100644 --- a/internal/platform/ops_other.go +++ b/internal/platform/ops_other.go @@ -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") == "" +} diff --git a/internal/platform/ops_windows.go b/internal/platform/ops_windows.go index e1c7214..64d2344 100644 --- a/internal/platform/ops_windows.go +++ b/internal/platform/ops_windows.go @@ -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") } diff --git a/internal/tui/actions.go b/internal/tui/actions.go index 55fe3dc..7b3d7e2 100644 --- a/internal/tui/actions.go +++ b/internal/tui/actions.go @@ -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 ---------------------------------------------------------- diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 8e8012e..abe8951 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -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())) } diff --git a/internal/tui/modals.go b/internal/tui/modals.go index e2f5ed4..b21195f 100644 --- a/internal/tui/modals.go +++ b/internal/tui/modals.go @@ -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 +} diff --git a/internal/tui/nest_test.go b/internal/tui/nest_test.go index edff384..f8554ef 100644 --- a/internal/tui/nest_test.go +++ b/internal/tui/nest_test.go @@ -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)) + } +}