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:
+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