From 8492a386eea447494f8dbb42ba513cc1856ce4a0 Mon Sep 17 00:00:00 2001 From: Mikhail Angelov Date: Mon, 19 Oct 2026 09:45:16 +0300 Subject: [PATCH 1/3] feat(remove): move items to the Finder trash on macOS via /usr/bin/trash --- ...3-macos-trash-uses-system-trash-command.md | 41 +++++++++++++ pkg/remove/trash_darwin.go | 33 ++++++++++- pkg/remove/trash_darwin_test.go | 58 ++++++++++++++++++- 3 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 docs/adr/0003-macos-trash-uses-system-trash-command.md diff --git a/docs/adr/0003-macos-trash-uses-system-trash-command.md b/docs/adr/0003-macos-trash-uses-system-trash-command.md new file mode 100644 index 000000000..25e7e7c2c --- /dev/null +++ b/docs/adr/0003-macos-trash-uses-system-trash-command.md @@ -0,0 +1,41 @@ +# 3. The macOS trash runs the system trash command + +Date: 2026-10-19 + +## Status + +Accepted + +## Context + +The built-in trash targets the FreeDesktop trash, which Finder ignores, so on +darwin it was a stub that returned "not supported". ADR 0002 left macOS users a +workaround (`--trash-command`) and noted that probing for a trash CLI remained +open. + +The web UI's cleanup projections make moving many items to the trash a common +action, so it has to work without configuration on the platform most of its +users run. + +Current macOS ships `/usr/bin/trash`, which moves items into the Finder trash, +where they can be put back. + +## Decision + +On darwin, `MoveItemToTrash` runs `/usr/bin/trash ` directly, without a +shell. `trash` does not understand `--`, but the path is always absolute, so it +can never be read as an option. + +As with the trash command, the tree is updated from the filesystem: the item is +dropped only when its path is really gone. + +When `/usr/bin/trash` does not exist, the error points the user at +`trash-command`, which still takes precedence when set. + +## Consequences + +- `D` in the terminal UI and "Move to trash" in the web UI work on macOS out of + the box, and trashed items can be restored from Finder. +- Older macOS releases without `/usr/bin/trash` keep the previous behavior, an + error, with a hint towards the workaround. +- No Objective-C bridge or cgo is needed. diff --git a/pkg/remove/trash_darwin.go b/pkg/remove/trash_darwin.go index 6810ed337..5d22b4945 100644 --- a/pkg/remove/trash_darwin.go +++ b/pkg/remove/trash_darwin.go @@ -3,13 +3,40 @@ package remove import ( + "bytes" + "errors" "fmt" + "os" "github.com/dundee/gdu/v5/pkg/fs" ) -// MoveItemToTrash is not supported on macOS. The XDG trash location is ignored -// by Finder, so use permanent delete (d) or empty (e) instead. +// macOSTrashBin is the trash CLI shipped with macOS. It moves items into the +// Finder trash, so they can be put back from there. +var macOSTrashBin = "/usr/bin/trash" + +// MoveItemToTrash moves the item into the Finder trash using the system trash +// command. The path is passed as an argument without a shell; it is absolute, +// so it can never be taken for an option. The tree is updated only when the +// path is really gone, as for TrashCommand. func MoveItemToTrash(dir, item fs.Item) error { - return fmt.Errorf("move to trash is not supported on macOS") + if _, err := os.Stat(macOSTrashBin); errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("%s not found; set trash-command to a trash CLI instead", macOSTrashBin) + } + + absPath, err := trashCommandOS.abs(item.GetPath()) + if err != nil { + return err + } + + var stderr bytes.Buffer + if err := trashCommandOS.run(macOSTrashBin, []string{absPath}, trashCommandOS.environ(), &stderr); err != nil { + return commandError(macOSTrashBin, err, stderr.String()) + } + if _, err := trashCommandOS.lstat(absPath); err == nil { + return nil + } + + dir.RemoveFile(item) + return nil } diff --git a/pkg/remove/trash_darwin_test.go b/pkg/remove/trash_darwin_test.go index 466077434..234dc9cfd 100644 --- a/pkg/remove/trash_darwin_test.go +++ b/pkg/remove/trash_darwin_test.go @@ -3,15 +3,67 @@ package remove import ( + "io" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/dundee/gdu/v5/pkg/analyze" ) -func TestMoveItemToTrashUnsupportedOnDarwin(t *testing.T) { - err := MoveItemToTrash(nil, nil) +func darwinTrashFixture(t *testing.T) (*analyze.Dir, *analyze.File, string) { + t.Helper() + base := t.TempDir() + path := filepath.Join(base, "-rf file") + require.NoError(t, os.WriteFile(path, []byte("x"), 0o600)) + + dir := &analyze.Dir{File: &analyze.File{Name: filepath.Base(base)}, BasePath: filepath.Dir(base)} + item := &analyze.File{Name: "-rf file", Parent: dir} + dir.Files = append(dir.Files, item) + return dir, item, path +} + +func TestMoveItemToTrashRunsSystemTrash(t *testing.T) { + dir, item, path := darwinTrashFixture(t) + + var gotArgv0 string + var gotArgv []string + orig := trashCommandOS.run + trashCommandOS.run = func(argv0 string, argv, _ []string, _ io.Writer) error { + gotArgv0, gotArgv = argv0, argv + return os.Remove(path) + } + defer func() { trashCommandOS.run = orig }() + + require.NoError(t, MoveItemToTrash(dir, item)) + assert.Equal(t, macOSTrashBin, gotArgv0) + assert.Equal(t, []string{path}, gotArgv) + assert.Empty(t, dir.Files) +} + +func TestMoveItemToTrashKeepsItemThatIsStillThere(t *testing.T) { + dir, item, _ := darwinTrashFixture(t) + + orig := trashCommandOS.run + trashCommandOS.run = func(string, []string, []string, io.Writer) error { return nil } + defer func() { trashCommandOS.run = orig }() + + require.NoError(t, MoveItemToTrash(dir, item)) + assert.Len(t, dir.Files, 1) +} + +func TestMoveItemToTrashMissingBinary(t *testing.T) { + dir, item, _ := darwinTrashFixture(t) + + orig := macOSTrashBin + macOSTrashBin = filepath.Join(t.TempDir(), "no-trash") + defer func() { macOSTrashBin = orig }() + err := MoveItemToTrash(dir, item) require.Error(t, err) - assert.Contains(t, err.Error(), "not supported on macOS") + assert.Contains(t, err.Error(), "trash-command") + assert.Len(t, dir.Files, 1) } From fbdba1a45176b4d4b32c314240a5baf82658ed5c Mon Sep 17 00:00:00 2001 From: Daniel Milde Date: Mon, 19 Oct 2026 22:17:55 +0200 Subject: [PATCH 2/3] docs: describe the macOS system trash and link ADR 0003 to ADR 0002 --- configuration.md | 2 +- docs/adr/0002-external-trash-command.md | 4 +++- .../adr/0003-macos-trash-uses-system-trash-command.md | 11 ++++++----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/configuration.md b/configuration.md index 19ac4ca2b..52eb15aa8 100644 --- a/configuration.md +++ b/configuration.md @@ -183,7 +183,7 @@ Delete items in parallel, which might increase the speed of deletion #### `trash-command` -Command used by the `D` key to move items to trash, replacing the built-in [FreeDesktop trash](https://specifications.freedesktop.org/trash-spec/latest/) implementation. Useful to trash into a non-default location, or to use a trash tool on systems where the built-in trash is not available (macOS). +Command used by the `D` key to move items to trash, replacing the built-in [FreeDesktop trash](https://specifications.freedesktop.org/trash-spec/latest/) implementation. Useful to trash into a non-default location, or to use a different trash tool. On macOS the built-in trash runs the system `/usr/bin/trash` (macOS 15 and later), which moves items into the Finder trash; on older releases set this option to a trash CLI instead. The command is evaluated by `/bin/sh` with the absolute path of the item appended as an argument, so shell syntax such as variable expansion works: diff --git a/docs/adr/0002-external-trash-command.md b/docs/adr/0002-external-trash-command.md index fea598290..9d1c084fb 100644 --- a/docs/adr/0002-external-trash-command.md +++ b/docs/adr/0002-external-trash-command.md @@ -4,7 +4,9 @@ Date: 2026-09-02 ## Status -Accepted +Accepted. The macOS consequence is partly superseded by +[ADR 0003](0003-macos-trash-uses-system-trash-command.md): the built-in trash +now runs `/usr/bin/trash` on macOS. ## Context diff --git a/docs/adr/0003-macos-trash-uses-system-trash-command.md b/docs/adr/0003-macos-trash-uses-system-trash-command.md index 25e7e7c2c..e9206b915 100644 --- a/docs/adr/0003-macos-trash-uses-system-trash-command.md +++ b/docs/adr/0003-macos-trash-uses-system-trash-command.md @@ -9,16 +9,17 @@ Accepted ## Context The built-in trash targets the FreeDesktop trash, which Finder ignores, so on -darwin it was a stub that returned "not supported". ADR 0002 left macOS users a -workaround (`--trash-command`) and noted that probing for a trash CLI remained -open. +darwin it was a stub that returned "not supported". +[ADR 0002 (external trash command)](0002-external-trash-command.md) left macOS +users a workaround (`--trash-command`) and noted that probing for a trash CLI +remained open. The web UI's cleanup projections make moving many items to the trash a common action, so it has to work without configuration on the platform most of its users run. -Current macOS ships `/usr/bin/trash`, which moves items into the Finder trash, -where they can be put back. +macOS 15 (Sequoia) and later ship `/usr/bin/trash`, which moves items into the +Finder trash, where they can be put back. It is missing on macOS 14 and older. ## Decision From 3b63baf124467d4e7c6efcf5aa96b0c68e4d4ec7 Mon Sep 17 00:00:00 2001 From: Daniel Milde Date: Mon, 19 Oct 2026 22:18:17 +0200 Subject: [PATCH 3/3] test(remove): move a file into the real Trash on macOS in CI --- .github/workflows/test.yml | 3 +++ pkg/remove/trash_darwin_test.go | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 240a7b715..c1d18c47e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,6 +41,9 @@ jobs: uses: actions/checkout@v7 - name: Run tests run: go test -v -covermode=count ./... + env: + # lets the darwin-only test move a file into the runner's real Trash + GDU_TEST_SYSTEM_TRASH: "1" coverage: runs-on: ubuntu-latest diff --git a/pkg/remove/trash_darwin_test.go b/pkg/remove/trash_darwin_test.go index 234dc9cfd..2015fed4a 100644 --- a/pkg/remove/trash_darwin_test.go +++ b/pkg/remove/trash_darwin_test.go @@ -67,3 +67,21 @@ func TestMoveItemToTrashMissingBinary(t *testing.T) { assert.Contains(t, err.Error(), "trash-command") assert.Len(t, dir.Files, 1) } + +// TestMoveItemToTrashWithSystemTrash runs the real /usr/bin/trash, which puts +// the test file into the Trash of whoever runs it, so it only runs when +// GDU_TEST_SYSTEM_TRASH=1 is set (as in CI). +func TestMoveItemToTrashWithSystemTrash(t *testing.T) { + if os.Getenv("GDU_TEST_SYSTEM_TRASH") != "1" { + t.Skip("set GDU_TEST_SYSTEM_TRASH=1 to move a test file into the real Trash") + } + if _, err := os.Stat(macOSTrashBin); err != nil { + t.Skipf("%s is not available: %s", macOSTrashBin, err) + } + dir, item, path := darwinTrashFixture(t) + + require.NoError(t, MoveItemToTrash(dir, item)) + _, err := os.Lstat(path) + assert.ErrorIs(t, err, os.ErrNotExist) + assert.Empty(t, dir.Files) +}