diff --git a/pkg/remove/trash.go b/pkg/remove/trash.go new file mode 100644 index 000000000..4a52e0f8a --- /dev/null +++ b/pkg/remove/trash.go @@ -0,0 +1,254 @@ +//go:build !windows + +package remove + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/dundee/gdu/v5/pkg/fs" +) + +type trashInfoFile interface { + WriteString(string) (int, error) + Close() error +} + +type trashOSOps struct { + mkdirAll func(string, os.FileMode) error + abs func(string) (string, error) + userHomeDir func() (string, error) + lstat func(string) (os.FileInfo, error) + openTrashInfo func(string, int, os.FileMode) (trashInfoFile, error) + remove func(string) error + rename func(string, string) error + removeAll func(string) error + mkdir func(string, os.FileMode) error + readDir func(string) ([]os.DirEntry, error) + readlink func(string) (string, error) + symlink func(string, string) error + openSource func(string) (io.ReadCloser, error) + openDestination func(string, int, os.FileMode) (io.WriteCloser, error) + copy func(io.Writer, io.Reader) (int64, error) +} + +var trashOS = trashOSOps{ + mkdirAll: os.MkdirAll, + abs: filepath.Abs, + userHomeDir: os.UserHomeDir, + lstat: os.Lstat, + openTrashInfo: func(name string, flag int, perm os.FileMode) (trashInfoFile, error) { + return os.OpenFile(name, flag, perm) + }, + remove: os.Remove, + rename: renameNoReplace, + removeAll: os.RemoveAll, + mkdir: os.Mkdir, + readDir: os.ReadDir, + readlink: os.Readlink, + symlink: os.Symlink, + openSource: func(name string) (io.ReadCloser, error) { + return os.Open(name) + }, + openDestination: func(name string, flag int, perm os.FileMode) (io.WriteCloser, error) { + return os.OpenFile(name, flag, perm) + }, + copy: io.Copy, +} + +// MoveItemToTrash moves item into the XDG trash and updates the in-memory dir tree. +func MoveItemToTrash(dir, item fs.Item) error { + trashRoot, err := trashDir() + if err != nil { + return err + } + filesDir := filepath.Join(trashRoot, "files") + infoDir := filepath.Join(trashRoot, "info") + if err := trashOS.mkdirAll(filesDir, 0o700); err != nil { + return err + } + if err := trashOS.mkdirAll(infoDir, 0o700); err != nil { + return err + } + + absSrc, err := trashOS.abs(item.GetPath()) + if err != nil { + return err + } + + for range 10001 { + destName, infoPath, err := reserveTrashInfo(filesDir, infoDir, item.GetName(), absSrc) + if err != nil { + return err + } + destPath := filepath.Join(filesDir, destName) + + if err := movePath(absSrc, destPath); err != nil { + _ = trashOS.remove(infoPath) //nolint:errcheck // Best-effort rollback. + if os.IsExist(err) { + // Destination appeared after reservation; pick another name. + continue + } + return err + } + + dir.RemoveFile(item) + return nil + } + + return fmt.Errorf("could not find unique trash name for %s", item.GetName()) +} + +func trashDir() (string, error) { + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + return filepath.Join(xdg, "Trash"), nil + } + home, err := trashOS.userHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "share", "Trash"), nil +} + +func reserveTrashInfo(filesDir, infoDir, base, absSrc string) (name, infoPath string, err error) { + for attempt := 0; attempt <= 10000; attempt++ { + candidate := base + if attempt > 0 { + candidate = fmt.Sprintf("%s.%d", base, attempt+1) + } + + destPath := filepath.Join(filesDir, candidate) + if _, err := trashOS.lstat(destPath); err == nil { + continue + } else if !os.IsNotExist(err) { + return "", "", err + } + + infoPath := filepath.Join(infoDir, candidate+".trashinfo") + err := writeTrashInfo(infoPath, absSrc) + if os.IsExist(err) { + continue + } + if err != nil { + return "", "", err + } + + // The exclusive trashinfo file reserves this name among compliant + // trash implementations. Recheck files/ to avoid clobbering stale + // entries that have no corresponding trashinfo file. + if _, err := trashOS.lstat(destPath); os.IsNotExist(err) { + return candidate, infoPath, nil + } else if err != nil { + _ = trashOS.remove(infoPath) //nolint:errcheck // Best-effort rollback. + return "", "", err + } + + _ = trashOS.remove(infoPath) //nolint:errcheck // Best-effort rollback. + } + + return "", "", fmt.Errorf("could not find unique trash name for %s", base) +} + +func writeTrashInfo(infoPath, absSrc string) error { + content := fmt.Sprintf("[Trash Info]\nPath=%s\nDeletionDate=%s\n", + escapeTrashPath(absSrc), + time.Now().Format("2006-01-02T15:04:05"), + ) + + file, err := trashOS.openTrashInfo(infoPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + + if _, err := file.WriteString(content); err != nil { + _ = file.Close() + _ = trashOS.remove(infoPath) //nolint:errcheck // Best-effort rollback. + return err + } + if err := file.Close(); err != nil { + _ = trashOS.remove(infoPath) //nolint:errcheck // Best-effort rollback. + return err + } + return nil +} + +func escapeTrashPath(p string) string { + var b strings.Builder + for _, r := range p { + if r == '/' || r == '-' || r == '.' || r == '_' || r == '~' || + (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + for _, by := range []byte(string(r)) { + fmt.Fprintf(&b, "%%%02X", by) + } + } + } + return b.String() +} + +func movePath(src, dst string) error { + err := trashOS.rename(src, dst) + if err == nil { + return nil + } + if os.IsExist(err) || !isEXDEV(err) { + return err + } + if err := copyRecursively(src, dst); err != nil { + _ = trashOS.removeAll(dst) //nolint:errcheck // Best-effort rollback. + return err + } + return trashOS.removeAll(src) +} + +func copyRecursively(src, dst string) error { + info, err := trashOS.lstat(src) + if err != nil { + return err + } + if info.IsDir() { + if err := trashOS.mkdir(dst, info.Mode().Perm()); err != nil { + return err + } + entries, err := trashOS.readDir(src) + if err != nil { + return err + } + for _, e := range entries { + if err := copyRecursively(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil { + return err + } + } + return nil + } + if info.Mode()&os.ModeSymlink != 0 { + target, err := trashOS.readlink(src) + if err != nil { + return err + } + return trashOS.symlink(target, dst) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("unsupported file type %s: %s", info.Mode().Type(), src) + } + + in, err := trashOS.openSource(src) + if err != nil { + return err + } + defer in.Close() + out, err := trashOS.openDestination(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm()) + if err != nil { + return err + } + if _, err := trashOS.copy(out, in); err != nil { + _ = out.Close() + return err + } + return out.Close() +} diff --git a/pkg/remove/trash_error_test.go b/pkg/remove/trash_error_test.go new file mode 100644 index 000000000..d20036289 --- /dev/null +++ b/pkg/remove/trash_error_test.go @@ -0,0 +1,360 @@ +//go:build !windows + +package remove + +import ( + "bytes" + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dundee/gdu/v5/pkg/analyze" +) + +type stubTrashInfoFile struct { + writeErr error + closeErr error +} + +func (f *stubTrashInfoFile) WriteString(s string) (int, error) { + if f.writeErr != nil { + return 0, f.writeErr + } + return len(s), nil +} + +func (f *stubTrashInfoFile) Close() error { + return f.closeErr +} + +type stubWriteCloser struct { + bytes.Buffer + closeErr error +} + +func (w *stubWriteCloser) Close() error { + return w.closeErr +} + +func TestMoveItemToTrashSetupErrors(t *testing.T) { + sentinel := errors.New("sentinel") + + tests := []struct { + name string + configure func(*trashOSOps) + unsetXDG bool + }{ + { + name: "home directory", + configure: func(ops *trashOSOps) { + ops.userHomeDir = func() (string, error) { return "", sentinel } + }, + unsetXDG: true, + }, + { + name: "files directory", + configure: func(ops *trashOSOps) { + ops.mkdirAll = func(string, os.FileMode) error { return sentinel } + }, + }, + { + name: "info directory", + configure: func(ops *trashOSOps) { + calls := 0 + ops.mkdirAll = func(string, os.FileMode) error { + calls++ + if calls == 2 { + return sentinel + } + return nil + } + }, + }, + { + name: "absolute source path", + configure: func(ops *trashOSOps) { + ops.abs = func(string) (string, error) { return "", sentinel } + }, + }, + { + name: "trashinfo reservation", + configure: func(ops *trashOSOps) { + ops.openTrashInfo = func(string, int, os.FileMode) (trashInfoFile, error) { + return nil, sentinel + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.unsetXDG { + t.Setenv("XDG_DATA_HOME", "") + } else { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + } + mockTrashOS(t, tt.configure) + parent := &analyze.Dir{ + File: &analyze.File{Name: "parent"}, + BasePath: ".", + } + item := &analyze.File{Name: "item", Parent: parent} + + err := MoveItemToTrash(nil, item) + + require.ErrorIs(t, err, sentinel) + }) + } +} + +func TestMoveItemToTrashExhaustsDestinationRetries(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + mockTrashOS(t, func(ops *trashOSOps) { + ops.lstat = func(string) (os.FileInfo, error) { return nil, os.ErrNotExist } + ops.openTrashInfo = func(string, int, os.FileMode) (trashInfoFile, error) { + return &stubTrashInfoFile{}, nil + } + ops.rename = func(string, string) error { return os.ErrExist } + ops.remove = func(string) error { return nil } + }) + parent := &analyze.Dir{ + File: &analyze.File{Name: "parent"}, + BasePath: ".", + } + item := &analyze.File{Name: "item", Parent: parent} + + err := MoveItemToTrash(nil, item) + + require.Error(t, err) + assert.Contains(t, err.Error(), "could not find unique trash name") +} + +func TestReserveTrashInfoErrorPaths(t *testing.T) { + sentinel := errors.New("sentinel") + info, err := os.Stat(t.TempDir()) + require.NoError(t, err) + + t.Run("initial lstat", func(t *testing.T) { + mockTrashOS(t, func(ops *trashOSOps) { + ops.lstat = func(string) (os.FileInfo, error) { return nil, sentinel } + }) + + _, _, err := reserveTrashInfo("files", "info", "item", "/item") + + require.ErrorIs(t, err, sentinel) + }) + + t.Run("post-reservation lstat", func(t *testing.T) { + calls := 0 + removed := false + mockTrashOS(t, func(ops *trashOSOps) { + ops.lstat = func(string) (os.FileInfo, error) { + calls++ + if calls == 1 { + return nil, os.ErrNotExist + } + return nil, sentinel + } + ops.openTrashInfo = func(string, int, os.FileMode) (trashInfoFile, error) { + return &stubTrashInfoFile{}, nil + } + ops.remove = func(string) error { + removed = true + return nil + } + }) + + _, _, err := reserveTrashInfo("files", "info", "item", "/item") + + require.ErrorIs(t, err, sentinel) + assert.True(t, removed) + }) + + t.Run("destination appears after reservation", func(t *testing.T) { + calls := 0 + removed := false + mockTrashOS(t, func(ops *trashOSOps) { + ops.lstat = func(string) (os.FileInfo, error) { + calls++ + switch calls { + case 1, 3, 4: + return nil, os.ErrNotExist + default: + return info, nil + } + } + ops.openTrashInfo = func(string, int, os.FileMode) (trashInfoFile, error) { + return &stubTrashInfoFile{}, nil + } + ops.remove = func(string) error { + removed = true + return nil + } + }) + + name, _, err := reserveTrashInfo("files", "info", "item", "/item") + + require.NoError(t, err) + assert.Equal(t, "item.2", name) + assert.True(t, removed) + }) + + t.Run("all names occupied", func(t *testing.T) { + mockTrashOS(t, func(ops *trashOSOps) { + ops.lstat = func(string) (os.FileInfo, error) { return info, nil } + }) + + _, _, err := reserveTrashInfo("files", "info", "item", "/item") + + require.Error(t, err) + assert.Contains(t, err.Error(), "could not find unique trash name") + }) +} + +func TestWriteTrashInfoErrors(t *testing.T) { + sentinel := errors.New("sentinel") + + t.Run("write", func(t *testing.T) { + removed := false + mockTrashOS(t, func(ops *trashOSOps) { + ops.openTrashInfo = func(string, int, os.FileMode) (trashInfoFile, error) { + return &stubTrashInfoFile{writeErr: sentinel}, nil + } + ops.remove = func(string) error { + removed = true + return nil + } + }) + + err := writeTrashInfo("item.trashinfo", "/item") + + require.ErrorIs(t, err, sentinel) + assert.True(t, removed) + }) + + t.Run("close", func(t *testing.T) { + removed := false + mockTrashOS(t, func(ops *trashOSOps) { + ops.openTrashInfo = func(string, int, os.FileMode) (trashInfoFile, error) { + return &stubTrashInfoFile{closeErr: sentinel}, nil + } + ops.remove = func(string) error { + removed = true + return nil + } + }) + + err := writeTrashInfo("item.trashinfo", "/item") + + require.ErrorIs(t, err, sentinel) + assert.True(t, removed) + }) +} + +func TestCopyRecursivelyErrorPaths(t *testing.T) { + sentinel := errors.New("sentinel") + + t.Run("read directory", func(t *testing.T) { + src := t.TempDir() + mockTrashOS(t, func(ops *trashOSOps) { + ops.readDir = func(string) ([]os.DirEntry, error) { return nil, sentinel } + }) + + err := copyRecursively(src, filepath.Join(t.TempDir(), "dst")) + + require.ErrorIs(t, err, sentinel) + }) + + t.Run("child copy", func(t *testing.T) { + src := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(src, "child"), []byte("data"), 0o600)) + mockTrashOS(t, func(ops *trashOSOps) { + ops.openSource = func(string) (io.ReadCloser, error) { return nil, sentinel } + }) + + err := copyRecursively(src, filepath.Join(t.TempDir(), "dst")) + + require.ErrorIs(t, err, sentinel) + }) + + t.Run("readlink", func(t *testing.T) { + src := filepath.Join(t.TempDir(), "link") + require.NoError(t, os.Symlink("target", src)) + mockTrashOS(t, func(ops *trashOSOps) { + ops.readlink = func(string) (string, error) { return "", sentinel } + }) + + err := copyRecursively(src, filepath.Join(t.TempDir(), "dst")) + + require.ErrorIs(t, err, sentinel) + }) + + t.Run("symlink", func(t *testing.T) { + src := filepath.Join(t.TempDir(), "link") + require.NoError(t, os.Symlink("target", src)) + mockTrashOS(t, func(ops *trashOSOps) { + ops.symlink = func(string, string) error { return sentinel } + }) + + err := copyRecursively(src, filepath.Join(t.TempDir(), "dst")) + + require.ErrorIs(t, err, sentinel) + }) + + t.Run("open source", func(t *testing.T) { + src := filepath.Join(t.TempDir(), "file") + require.NoError(t, os.WriteFile(src, []byte("data"), 0o600)) + mockTrashOS(t, func(ops *trashOSOps) { + ops.openSource = func(string) (io.ReadCloser, error) { return nil, sentinel } + }) + + err := copyRecursively(src, filepath.Join(t.TempDir(), "dst")) + + require.ErrorIs(t, err, sentinel) + }) + + t.Run("open destination", func(t *testing.T) { + src := filepath.Join(t.TempDir(), "file") + require.NoError(t, os.WriteFile(src, []byte("data"), 0o600)) + mockTrashOS(t, func(ops *trashOSOps) { + ops.openDestination = func(string, int, os.FileMode) (io.WriteCloser, error) { + return nil, sentinel + } + }) + + err := copyRecursively(src, filepath.Join(t.TempDir(), "dst")) + + require.ErrorIs(t, err, sentinel) + }) + + t.Run("copy", func(t *testing.T) { + src := filepath.Join(t.TempDir(), "file") + require.NoError(t, os.WriteFile(src, []byte("data"), 0o600)) + mockTrashOS(t, func(ops *trashOSOps) { + ops.copy = func(io.Writer, io.Reader) (int64, error) { return 0, sentinel } + }) + + err := copyRecursively(src, filepath.Join(t.TempDir(), "dst")) + + require.ErrorIs(t, err, sentinel) + }) + + t.Run("close destination", func(t *testing.T) { + src := filepath.Join(t.TempDir(), "file") + require.NoError(t, os.WriteFile(src, []byte("data"), 0o600)) + mockTrashOS(t, func(ops *trashOSOps) { + ops.openDestination = func(string, int, os.FileMode) (io.WriteCloser, error) { + return &stubWriteCloser{closeErr: sentinel}, nil + } + }) + + err := copyRecursively(src, filepath.Join(t.TempDir(), "dst")) + + require.ErrorIs(t, err, sentinel) + }) +} diff --git a/pkg/remove/trash_rename_linux.go b/pkg/remove/trash_rename_linux.go new file mode 100644 index 000000000..688b772e5 --- /dev/null +++ b/pkg/remove/trash_rename_linux.go @@ -0,0 +1,17 @@ +//go:build linux + +package remove + +import ( + "os" + + "golang.org/x/sys/unix" +) + +func renameNoReplace(oldpath, newpath string) error { + err := unix.Renameat2(unix.AT_FDCWD, oldpath, unix.AT_FDCWD, newpath, unix.RENAME_NOREPLACE) + if err == unix.EEXIST { + return os.ErrExist + } + return err +} diff --git a/pkg/remove/trash_rename_other.go b/pkg/remove/trash_rename_other.go new file mode 100644 index 000000000..1a399b6a7 --- /dev/null +++ b/pkg/remove/trash_rename_other.go @@ -0,0 +1,14 @@ +//go:build !windows && !linux + +package remove + +import "os" + +func renameNoReplace(oldpath, newpath string) error { + if _, err := os.Lstat(newpath); err == nil { + return os.ErrExist + } else if !os.IsNotExist(err) { + return err + } + return os.Rename(oldpath, newpath) +} diff --git a/pkg/remove/trash_rename_other_test.go b/pkg/remove/trash_rename_other_test.go new file mode 100644 index 000000000..ba5430121 --- /dev/null +++ b/pkg/remove/trash_rename_other_test.go @@ -0,0 +1,21 @@ +//go:build !windows && !linux + +package remove + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestRenameNoReplacePropagatesLstatErrors(t *testing.T) { + root := t.TempDir() + loop := filepath.Join(root, "loop") + require.NoError(t, os.Symlink("loop", loop)) + + err := renameNoReplace(filepath.Join(root, "source"), filepath.Join(loop, "destination")) + + require.Error(t, err) +} diff --git a/pkg/remove/trash_special_unix_test.go b/pkg/remove/trash_special_unix_test.go new file mode 100644 index 000000000..33599c7fe --- /dev/null +++ b/pkg/remove/trash_special_unix_test.go @@ -0,0 +1,33 @@ +//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris + +package remove + +import ( + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCopyRecursivelyRejectsFIFOWithoutBlocking(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "fifo") + dst := filepath.Join(root, "copied-fifo") + require.NoError(t, syscall.Mkfifo(src, 0o600)) + + done := make(chan error, 1) + go func() { + done <- copyRecursively(src, dst) + }() + + select { + case err := <-done: + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported file type") + case <-time.After(500 * time.Millisecond): + t.Fatal("copyRecursively blocked while opening a FIFO") + } +} diff --git a/pkg/remove/trash_test.go b/pkg/remove/trash_test.go new file mode 100644 index 000000000..4affda6bd --- /dev/null +++ b/pkg/remove/trash_test.go @@ -0,0 +1,442 @@ +//go:build !windows + +package remove + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" +) + +func mockTrashOS(t *testing.T, configure func(*trashOSOps)) { + t.Helper() + original := trashOS + t.Cleanup(func() { trashOS = original }) + configure(&trashOS) +} + +func TestMoveItemToTrash(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + xdg := t.TempDir() + t.Setenv("XDG_DATA_HOME", xdg) + + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "test_dir", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: ".", + } + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "nested", + Size: 4, + Usage: 8, + Parent: dir, + }, + ItemCount: 2, + } + file := &analyze.File{ + Name: "file2", + Size: 3, + Usage: 4, + Parent: subdir, + } + dir.Files = fs.Files{subdir} + subdir.Files = fs.Files{file} + + err := MoveItemToTrash(subdir, file) + require.NoError(t, err) + + _, err = os.Stat("test_dir/nested/file2") + assert.True(t, os.IsNotExist(err)) + + trashFile := filepath.Join(xdg, "Trash", "files", "file2") + _, err = os.Stat(trashFile) + assert.NoError(t, err) + + infoPath := filepath.Join(xdg, "Trash", "info", "file2.trashinfo") + data, err := os.ReadFile(infoPath) + require.NoError(t, err) + assert.Contains(t, string(data), "[Trash Info]") + assert.Contains(t, string(data), "DeletionDate=") + wantPath, err := filepath.Abs("test_dir/nested/file2") + require.NoError(t, err) + assert.Contains(t, string(data), "Path="+escapeTrashPath(wantPath)) + + assert.Equal(t, 0, len(subdir.Files)) + assert.Equal(t, int64(1), subdir.ItemCount) +} + +func TestMoveItemToTrashNameConflict(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + xdg := t.TempDir() + t.Setenv("XDG_DATA_HOME", xdg) + + trashFiles := filepath.Join(xdg, "Trash", "files") + require.NoError(t, os.MkdirAll(trashFiles, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(trashFiles, "file2"), []byte("old"), 0o600)) + + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "test_dir", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: ".", + } + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "nested", + Size: 4, + Usage: 8, + Parent: dir, + }, + ItemCount: 2, + } + file := &analyze.File{ + Name: "file2", + Size: 3, + Usage: 4, + Parent: subdir, + } + dir.Files = fs.Files{subdir} + subdir.Files = fs.Files{file} + + err := MoveItemToTrash(subdir, file) + require.NoError(t, err) + + _, err = os.Stat("test_dir/nested/file2") + assert.True(t, os.IsNotExist(err)) + + oldData, err := os.ReadFile(filepath.Join(trashFiles, "file2")) + require.NoError(t, err) + assert.Equal(t, "old", string(oldData)) + + movedData, err := os.ReadFile(filepath.Join(trashFiles, "file2.2")) + require.NoError(t, err) + assert.Equal(t, "go", string(movedData)) + + infoPath := filepath.Join(xdg, "Trash", "info", "file2.2.trashinfo") + _, err = os.Stat(infoPath) + assert.NoError(t, err) + + assert.Equal(t, 0, len(subdir.Files)) + assert.Equal(t, int64(1), subdir.ItemCount) +} + +func TestReserveTrashInfoUsesUniqueNamesConcurrently(t *testing.T) { + trashRoot := t.TempDir() + filesDir := filepath.Join(trashRoot, "files") + infoDir := filepath.Join(trashRoot, "info") + require.NoError(t, os.MkdirAll(filesDir, 0o700)) + require.NoError(t, os.MkdirAll(infoDir, 0o700)) + + const workers = 16 + start := make(chan struct{}) + names := make(chan string, workers) + errs := make(chan error, workers) + + var wg sync.WaitGroup + for i := range workers { + wg.Add(1) + go func() { + defer wg.Done() + <-start + + name, _, err := reserveTrashInfo( + filesDir, + infoDir, + "same-name", + filepath.Join("/source", fmt.Sprintf("%d", i), "same-name"), + ) + if err != nil { + errs <- err + return + } + names <- name + }() + } + + close(start) + wg.Wait() + close(names) + close(errs) + + for err := range errs { + require.NoError(t, err) + } + + uniqueNames := make(map[string]struct{}, workers) + for name := range names { + uniqueNames[name] = struct{}{} + } + assert.Len(t, uniqueNames, workers) + + entries, err := os.ReadDir(infoDir) + require.NoError(t, err) + assert.Len(t, entries, workers) +} + +func TestCopyRecursivelyPreservesSymlink(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "target") + src := filepath.Join(root, "link") + dst := filepath.Join(root, "copied-link") + + require.NoError(t, os.WriteFile(target, []byte("target contents"), 0o600)) + require.NoError(t, os.Symlink("target", src)) + + require.NoError(t, copyRecursively(src, dst)) + + info, err := os.Lstat(dst) + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeSymlink) + + linkTarget, err := os.Readlink(dst) + require.NoError(t, err) + assert.Equal(t, "target", linkTarget) +} + +func TestMovePathDoesNotOverwriteExisting(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "src") + dst := filepath.Join(root, "dst") + require.NoError(t, os.WriteFile(src, []byte("new"), 0o600)) + require.NoError(t, os.WriteFile(dst, []byte("old"), 0o600)) + + err := movePath(src, dst) + require.Error(t, err) + assert.True(t, os.IsExist(err)) + + data, err := os.ReadFile(dst) + require.NoError(t, err) + assert.Equal(t, "old", string(data)) + + _, err = os.Stat(src) + assert.NoError(t, err) +} + +func TestTrashDirUsesHomeWhenXDGUnset(t *testing.T) { + t.Setenv("XDG_DATA_HOME", "") + dir, err := trashDir() + require.NoError(t, err) + home, err := os.UserHomeDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, ".local", "share", "Trash"), dir) +} + +func TestEscapeTrashPathPercentEncodesUnsafeBytes(t *testing.T) { + got := escapeTrashPath("/tmp/has space/%and\n") + assert.Equal(t, "/tmp/has%20space/%25and%0A", got) +} + +func TestIsEXDEV(t *testing.T) { + assert.True(t, isEXDEV(syscall.EXDEV)) + assert.True(t, isEXDEV(&os.PathError{Op: "rename", Err: syscall.EXDEV})) + assert.False(t, isEXDEV(os.ErrExist)) +} + +func TestCopyRecursivelyCopiesRegularFileAndDirectory(t *testing.T) { + root := t.TempDir() + srcDir := filepath.Join(root, "src") + dstDir := filepath.Join(root, "dst") + require.NoError(t, os.Mkdir(srcDir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("hello"), 0o600)) + require.NoError(t, os.Mkdir(filepath.Join(srcDir, "sub"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(srcDir, "sub", "b.txt"), []byte("world"), 0o600)) + + require.NoError(t, copyRecursively(srcDir, dstDir)) + + data, err := os.ReadFile(filepath.Join(dstDir, "a.txt")) + require.NoError(t, err) + assert.Equal(t, "hello", string(data)) + data, err = os.ReadFile(filepath.Join(dstDir, "sub", "b.txt")) + require.NoError(t, err) + assert.Equal(t, "world", string(data)) +} + +func TestCopyRecursivelyMissingSource(t *testing.T) { + err := copyRecursively(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dst")) + require.Error(t, err) +} + +func TestMovePathFallsBackOnEXDEV(t *testing.T) { + mockTrashOS(t, func(ops *trashOSOps) { + ops.rename = func(oldpath, newpath string) error { + return &os.PathError{Op: "rename", Path: oldpath, Err: syscall.EXDEV} + } + }) + + root := t.TempDir() + src := filepath.Join(root, "src") + dst := filepath.Join(root, "dst") + require.NoError(t, os.WriteFile(src, []byte("payload"), 0o600)) + + require.NoError(t, movePath(src, dst)) + + data, err := os.ReadFile(dst) + require.NoError(t, err) + assert.Equal(t, "payload", string(data)) + _, err = os.Stat(src) + assert.True(t, os.IsNotExist(err)) +} + +func TestMovePathEXDEVCleanupOnCopyFailure(t *testing.T) { + mockTrashOS(t, func(ops *trashOSOps) { + ops.rename = func(oldpath, newpath string) error { + return &os.PathError{Op: "rename", Path: oldpath, Err: syscall.EXDEV} + } + }) + + root := t.TempDir() + src := filepath.Join(root, "missing-src") + dst := filepath.Join(root, "dst") + + err := movePath(src, dst) + require.Error(t, err) + _, err = os.Stat(dst) + assert.True(t, os.IsNotExist(err)) +} + +func TestMoveItemToTrashRetriesWhenDestinationAppears(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + xdg := t.TempDir() + t.Setenv("XDG_DATA_HOME", xdg) + + attempts := 0 + mockTrashOS(t, func(ops *trashOSOps) { + ops.rename = func(oldpath, newpath string) error { + attempts++ + if attempts == 1 { + return os.ErrExist + } + return renameNoReplace(oldpath, newpath) + } + }) + + dir := &analyze.Dir{ + File: &analyze.File{ + Name: "test_dir", + Size: 5, + Usage: 12, + }, + ItemCount: 3, + BasePath: ".", + } + subdir := &analyze.Dir{ + File: &analyze.File{ + Name: "nested", + Size: 4, + Usage: 8, + Parent: dir, + }, + ItemCount: 2, + } + file := &analyze.File{ + Name: "file2", + Size: 3, + Usage: 4, + Parent: subdir, + } + dir.Files = fs.Files{subdir} + subdir.Files = fs.Files{file} + + require.NoError(t, MoveItemToTrash(subdir, file)) + assert.GreaterOrEqual(t, attempts, 2) + + _, err := os.Stat("test_dir/nested/file2") + assert.True(t, os.IsNotExist(err)) + assert.Equal(t, 0, len(subdir.Files)) +} + +func TestReserveTrashInfoSkipsExistingTrashinfo(t *testing.T) { + trashRoot := t.TempDir() + filesDir := filepath.Join(trashRoot, "files") + infoDir := filepath.Join(trashRoot, "info") + require.NoError(t, os.MkdirAll(filesDir, 0o700)) + require.NoError(t, os.MkdirAll(infoDir, 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(infoDir, "item.trashinfo"), []byte("stale"), 0o600)) + + name, infoPath, err := reserveTrashInfo(filesDir, infoDir, "item", "/tmp/item") + require.NoError(t, err) + assert.Equal(t, "item.2", name) + assert.Equal(t, filepath.Join(infoDir, "item.2.trashinfo"), infoPath) +} + +func TestMoveItemToTrashPropagatesMoveErrors(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + xdg := t.TempDir() + t.Setenv("XDG_DATA_HOME", xdg) + + mockTrashOS(t, func(ops *trashOSOps) { + ops.rename = func(oldpath, newpath string) error { + return os.ErrPermission + } + }) + + dir := &analyze.Dir{ + File: &analyze.File{Name: "test_dir", Size: 5, Usage: 12}, + ItemCount: 3, + BasePath: ".", + } + subdir := &analyze.Dir{ + File: &analyze.File{Name: "nested", Size: 4, Usage: 8, Parent: dir}, + ItemCount: 2, + } + file := &analyze.File{Name: "file2", Size: 3, Usage: 4, Parent: subdir} + dir.Files = fs.Files{subdir} + subdir.Files = fs.Files{file} + + err := MoveItemToTrash(subdir, file) + require.Error(t, err) + assert.ErrorIs(t, err, os.ErrPermission) + assert.FileExists(t, "test_dir/nested/file2") + assert.Len(t, subdir.Files, 1) +} + +func TestCopyRecursivelyRejectsExistingDestination(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "src") + dst := filepath.Join(root, "dst") + require.NoError(t, os.WriteFile(src, []byte("new"), 0o600)) + require.NoError(t, os.WriteFile(dst, []byte("old"), 0o600)) + + err := copyRecursively(src, dst) + require.Error(t, err) + data, err := os.ReadFile(dst) + require.NoError(t, err) + assert.Equal(t, "old", string(data)) +} + +func TestCopyRecursivelyMkdirFailsWhenDestinationIsFile(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "src") + dst := filepath.Join(root, "dst") + require.NoError(t, os.Mkdir(src, 0o700)) + require.NoError(t, os.WriteFile(dst, []byte("file"), 0o600)) + + err := copyRecursively(src, dst) + require.Error(t, err) +} diff --git a/pkg/remove/trash_unix.go b/pkg/remove/trash_unix.go new file mode 100644 index 000000000..14a4fbee0 --- /dev/null +++ b/pkg/remove/trash_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package remove + +import ( + "errors" + "syscall" +) + +func isEXDEV(err error) bool { + return errors.Is(err, syscall.EXDEV) +} diff --git a/pkg/remove/trash_windows.go b/pkg/remove/trash_windows.go new file mode 100644 index 000000000..66ddf47e2 --- /dev/null +++ b/pkg/remove/trash_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package remove + +import ( + "fmt" + + "github.com/dundee/gdu/v5/pkg/fs" +) + +// MoveItemToTrash is not supported on Windows; use Unix XDG trash builds instead. +func MoveItemToTrash(dir, item fs.Item) error { + return fmt.Errorf("move to trash is not supported on Windows") +} diff --git a/tui/actions.go b/tui/actions.go index 0c0119ac8..9b5036778 100644 --- a/tui/actions.go +++ b/tui/actions.go @@ -28,12 +28,6 @@ import ( const ( defaultLinesCount = 500 linesThreshold = 20 - - actionEmpty = "empty" - actionDelete = "delete" - - actingEmpty = "emptying" - actingDelete = "deleting" ) // ListDevices lists mounted devices and shows their disk usage @@ -197,31 +191,24 @@ func (ui *UI) ReadFromStorage(storagePath, path string) error { return nil } -func (ui *UI) delete(shouldEmpty bool) { +func (ui *UI) delete(action DeleteAction) { if len(ui.markedRows) > 0 { - ui.deleteMarked(shouldEmpty) + ui.deleteMarked(action) } else { - ui.deleteSelected(shouldEmpty) + ui.deleteSelected(action) } } -func (ui *UI) deleteSelected(shouldEmpty bool) { +func (ui *UI) deleteSelected(action DeleteAction) { row, column := ui.table.GetSelection() selectedItem := ui.table.GetCell(row, column).GetReference().(fs.Item) if ui.deleteInBackground { - ui.queueForDeletion([]fs.Item{selectedItem}, shouldEmpty) + ui.queueForDeletion([]fs.Item{selectedItem}, action) return } - var action, acting string - if shouldEmpty { - action = actionEmpty - acting = actingEmpty - } else { - action = actionDelete - acting = actingDelete - } + acting := action.Acting() modal := tview.NewModal().SetText( cases.Title(language.English).String(acting) + " " + @@ -232,7 +219,7 @@ func (ui *UI) deleteSelected(shouldEmpty bool) { var currentDir fs.Item var deleteItems []fs.Item - if shouldEmpty && selectedItem.IsDir() { + if action == ActionEmpty && selectedItem.IsDir() { currentDir = selectedItem for file := range currentDir.GetFiles(fs.SortBySize, fs.SortDesc) { deleteItems = append(deleteItems, file) @@ -243,15 +230,22 @@ func (ui *UI) deleteSelected(shouldEmpty bool) { } var deleteFun func(fs.Item, fs.Item) error - if shouldEmpty && !selectedItem.IsDir() { - deleteFun = ui.emptier - } else { + switch action { + case ActionEmpty: + if !selectedItem.IsDir() { + deleteFun = ui.emptier + } else { + deleteFun = ui.remover + } + case ActionMoveToTrash: + deleteFun = ui.trasher + case ActionDelete: deleteFun = ui.remover } go func() { for _, item := range deleteItems { if err := deleteFun(currentDir, item); err != nil { - msg := "Can't " + action + " " + tview.Escape(selectedItem.GetName()) + msg := "Can't " + action.Verb() + " " + tview.Escape(selectedItem.GetName()) ui.app.QueueUpdateDraw(func() { ui.pages.RemovePage(acting) ui.showErr(msg, err) diff --git a/tui/background.go b/tui/background.go index 7d529cf06..6e8b730d5 100644 --- a/tui/background.go +++ b/tui/background.go @@ -5,10 +5,10 @@ import ( "github.com/rivo/tview" ) -func (ui *UI) queueForDeletion(items []fs.Item, shouldEmpty bool) { +func (ui *UI) queueForDeletion(items []fs.Item, action DeleteAction) { go func() { for _, item := range items { - ui.deleteQueue <- deleteQueueItem{item: item, shouldEmpty: shouldEmpty} + ui.deleteQueue <- deleteQueueItem{item: item, action: action} } }() @@ -24,31 +24,31 @@ func (ui *UI) deleteWorker() { }() for item := range ui.deleteQueue { - ui.deleteItem(item.item, item.shouldEmpty) + ui.deleteItem(item.item, item.action) } } -func (ui *UI) deleteItem(item fs.Item, shouldEmpty bool) { +func (ui *UI) deleteItem(item fs.Item, action DeleteAction) { ui.increaseActiveWorkers() defer ui.decreaseActiveWorkers() - var action, acting string - if shouldEmpty { - action = actionEmpty - } else { - action = actionDelete - } - var deleteFun func(fs.Item, fs.Item) error - if shouldEmpty && !item.IsDir() { - deleteFun = ui.emptier - } else { + switch action { + case ActionEmpty: + if !item.IsDir() { + deleteFun = ui.emptier + } else { + deleteFun = ui.remover + } + case ActionMoveToTrash: + deleteFun = ui.trasher + case ActionDelete: deleteFun = ui.remover } var parentDir fs.Item var deleteItems []fs.Item - if shouldEmpty && item.IsDir() { + if action == ActionEmpty && item.IsDir() { parentDir = item for file := range item.GetFilesLocked(fs.SortBySize, fs.SortDesc) { deleteItems = append(deleteItems, file) @@ -60,9 +60,9 @@ func (ui *UI) deleteItem(item fs.Item, shouldEmpty bool) { for _, toDelete := range deleteItems { if err := deleteFun(parentDir, toDelete); err != nil { - msg := "Can't " + action + " " + tview.Escape(toDelete.GetName()) + msg := "Can't " + action.Verb() + " " + tview.Escape(toDelete.GetName()) ui.app.QueueUpdateDraw(func() { - ui.pages.RemovePage(acting) + ui.pages.RemovePage(action.Acting()) ui.showErr(msg, err) }) if ui.done != nil { diff --git a/tui/delete_action.go b/tui/delete_action.go new file mode 100644 index 000000000..d7f87185f --- /dev/null +++ b/tui/delete_action.go @@ -0,0 +1,34 @@ +package tui + +// DeleteAction selects how an item is removed from the directory tree. +type DeleteAction uint8 + +const ( + ActionDelete DeleteAction = iota + ActionEmpty + ActionMoveToTrash +) + +func (a DeleteAction) Verb() string { + switch a { + case ActionEmpty: + return "empty" + case ActionMoveToTrash: + return "move to trash" + case ActionDelete: + return "delete" + } + return "delete" +} + +func (a DeleteAction) Acting() string { + switch a { + case ActionEmpty: + return "emptying" + case ActionMoveToTrash: + return "moving to trash" + case ActionDelete: + return "deleting" + } + return "deleting" +} diff --git a/tui/delete_action_test.go b/tui/delete_action_test.go new file mode 100644 index 000000000..5d8b2e26b --- /dev/null +++ b/tui/delete_action_test.go @@ -0,0 +1,19 @@ +package tui + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDeleteActionVerbAndActing(t *testing.T) { + assert.Equal(t, "delete", ActionDelete.Verb()) + assert.Equal(t, "empty", ActionEmpty.Verb()) + assert.Equal(t, "move to trash", ActionMoveToTrash.Verb()) + assert.Equal(t, "delete", DeleteAction(99).Verb()) + + assert.Equal(t, "deleting", ActionDelete.Acting()) + assert.Equal(t, "emptying", ActionEmpty.Acting()) + assert.Equal(t, "moving to trash", ActionMoveToTrash.Acting()) + assert.Equal(t, "deleting", DeleteAction(99).Acting()) +} diff --git a/tui/keys.go b/tui/keys.go index f36a4d14b..4c78cf621 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -49,7 +49,8 @@ func (ui *UI) keyPressed(key *tcell.EventKey) *tcell.EventKey { if ui.pages.HasPage("progress") || ui.pages.HasPage("deleting") || - ui.pages.HasPage("emptying") { + ui.pages.HasPage("emptying") || + ui.pages.HasPage("moving to trash") { // allow peeking at the results found so far during a scan if key.Key() == tcell.KeyTab && ui.pages.HasPage("progress") { ui.enterPreview() @@ -422,13 +423,19 @@ func (ui *UI) handleMainActions(key *tcell.EventKey) *tcell.EventKey { ui.showErr("Deletion is not supported in archives", nil) return nil } - ui.handleDelete(false) + ui.handleDelete(ActionDelete) case 'e': if ui.isInArchive() { ui.showErr("Deletion is not supported in archives", nil) return nil } - ui.handleDelete(true) + ui.handleDelete(ActionEmpty) + case 'D': + if ui.isInArchive() { + ui.showErr("Deletion is not supported in archives", nil) + return nil + } + ui.handleDelete(ActionMoveToTrash) case 'v': if ui.isInArchive() { ui.showErr("Viewing content is not supported in archives", nil) @@ -578,7 +585,7 @@ func (ui *UI) handleRight() { } } -func (ui *UI) handleDelete(shouldEmpty bool) { +func (ui *UI) handleDelete(action DeleteAction) { if ui.currentDir == nil { return } @@ -590,9 +597,9 @@ func (ui *UI) handleDelete(shouldEmpty bool) { } if ui.askBeforeDelete { - ui.confirmDeletion(shouldEmpty) + ui.confirmDeletion(action) } else { - ui.delete(shouldEmpty) + ui.delete(action) } } diff --git a/tui/keys_test.go b/tui/keys_test.go index ba6f23286..2f77f129f 100644 --- a/tui/keys_test.go +++ b/tui/keys_test.go @@ -798,6 +798,84 @@ func TestDelete(t *testing.T) { assert.NoDirExists(t, "test_dir/nested") } +func TestMoveToTrash(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + var trashed fs.Item + ui.trasher = func(dir, item fs.Item) error { + trashed = item + dir.RemoveFile(item) + return nil + } + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'D', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NotNil(t, trashed) + assert.Equal(t, "nested", trashed.GetName()) + assert.Equal(t, 0, ui.table.GetRowCount()) +} + +func TestMoveToTrashWithNoDelete(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + var trashed fs.Item + ui.trasher = func(dir, item fs.Item) error { + trashed = item + return nil + } + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.SetNoDelete() + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'D', 0)) + + assert.Nil(t, trashed) + assert.DirExists(t, "test_dir/nested") +} + func TestDeleteWithNoDelete(t *testing.T) { fin := testdir.CreateTestDir() defer fin() @@ -866,6 +944,45 @@ func TestDeleteMarked(t *testing.T) { assert.NoDirExists(t, "test_dir/nested") } +func TestMoveMarkedToTrash(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + var trashed []string + ui.trasher = func(dir, item fs.Item) error { + trashed = append(trashed, item.GetName()) + dir.RemoveFile(item) + return nil + } + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + ui.table.Select(0, 0) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'D', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, []string{"nested"}, trashed) + assert.Equal(t, 0, ui.table.GetRowCount()) +} + func TestDeleteParent(t *testing.T) { fin := testdir.CreateTestDir() defer fin() @@ -1506,6 +1623,11 @@ func TestBlockedActionsInArchive(t *testing.T) { assert.True(t, ui.pages.HasPage("error")) ui.pages.RemovePage("error") + // Test 'D' (move to trash) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'D', 0)) + assert.True(t, ui.pages.HasPage("error")) + ui.pages.RemovePage("error") + // Test 'v' (view) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'v', 0)) assert.True(t, ui.pages.HasPage("error")) diff --git a/tui/marked.go b/tui/marked.go index 5cf583c2f..5f20e79a6 100644 --- a/tui/marked.go +++ b/tui/marked.go @@ -22,15 +22,8 @@ func (ui *UI) fileItemMarked(row int) { ui.table.Select(min(row+1, ui.table.GetRowCount()-1), 0) } -func (ui *UI) deleteMarked(shouldEmpty bool) { - var action, acting string - if shouldEmpty { - action = actionEmpty - acting = actingEmpty - } else { - action = actionDelete - acting = actingDelete - } +func (ui *UI) deleteMarked(action DeleteAction) { + acting := action.Acting() var currentDir fs.Item var markedItems []fs.Item @@ -40,7 +33,7 @@ func (ui *UI) deleteMarked(shouldEmpty bool) { } if ui.deleteInBackground { - ui.queueForDeletion(markedItems, shouldEmpty) + ui.queueForDeletion(markedItems, action) return } @@ -62,14 +55,21 @@ func (ui *UI) deleteMarked(shouldEmpty bool) { ) }) - if shouldEmpty && !one.IsDir() { - deleteFun = ui.emptier - } else { + switch action { + case ActionEmpty: + if !one.IsDir() { + deleteFun = ui.emptier + } else { + deleteFun = ui.remover + } + case ActionMoveToTrash: + deleteFun = ui.trasher + case ActionDelete: deleteFun = ui.remover } var deleteItems []fs.Item - if shouldEmpty && one.IsDir() { + if action == ActionEmpty && one.IsDir() { currentDir = one for file := range currentDir.GetFiles(fs.SortBySize, fs.SortDesc) { deleteItems = append(deleteItems, file) @@ -81,7 +81,7 @@ func (ui *UI) deleteMarked(shouldEmpty bool) { for _, item := range deleteItems { if err := deleteFun(currentDir, item); err != nil { - msg := "Can't " + action + " " + tview.Escape(one.GetName()) + msg := "Can't " + action.Verb() + " " + tview.Escape(one.GetName()) ui.app.QueueUpdateDraw(func() { ui.pages.RemovePage(acting) ui.showErr(msg, err) @@ -110,18 +110,11 @@ func (ui *UI) deleteMarked(shouldEmpty bool) { }() } -func (ui *UI) confirmDeletionMarked(shouldEmpty bool) { - var action string - if shouldEmpty { - action = actionEmpty - } else { - action = actionDelete - } - +func (ui *UI) confirmDeletionMarked(action DeleteAction) { modal := tview.NewModal(). SetText( "Are you sure you want to " + - action + " [::b]" + + action.Verb() + " [::b]" + strconv.Itoa(len(ui.markedRows)) + "[::-] items?", ). @@ -132,7 +125,7 @@ func (ui *UI) confirmDeletionMarked(shouldEmpty bool) { ui.askBeforeDelete = false fallthrough case 1: - ui.deleteMarked(shouldEmpty) + ui.deleteMarked(action) } ui.pages.RemovePage("confirm") }) diff --git a/tui/mouse.go b/tui/mouse.go index 204f49bcb..255de5094 100644 --- a/tui/mouse.go +++ b/tui/mouse.go @@ -17,6 +17,7 @@ func (ui *UI) onMouse(event *tcell.EventMouse, action tview.MouseAction) (*tcell ui.pages.HasPage("progress") || ui.pages.HasPage("deleting") || ui.pages.HasPage("emptying") || + ui.pages.HasPage("moving to trash") || ui.pages.HasPage("help") { return nil, action } diff --git a/tui/show.go b/tui/show.go index bb93321c0..254734dd9 100644 --- a/tui/show.go +++ b/tui/show.go @@ -36,6 +36,7 @@ var ( Item under cursor: [::b]d [white:black:-]Delete file or directory [::b]e [white:black:-]Empty file or directory + [::b]D [white:black:-]Move file or directory to trash [::b]space [white:black:-]Mark file or directory for deletion [::b]p [white:black:-]Print marked items paths to stdout after quitting [::b]I [white:black:-]Ignore file or directory @@ -389,7 +390,8 @@ func (ui *UI) formatHelpTextFor() string { } isFound := (strings.Contains(line, "Empty file or directory") || - strings.Contains(line, "Delete file or directory")) + strings.Contains(line, "Delete file or directory") || + strings.Contains(line, "Move file or directory to trash")) if ui.noDelete && isFound { lines[i] += helpDisabledSuffix diff --git a/tui/show_test.go b/tui/show_test.go index e7bf3173c..e47a8ef15 100644 --- a/tui/show_test.go +++ b/tui/show_test.go @@ -11,6 +11,18 @@ import ( "github.com/stretchr/testify/assert" ) +func TestHelpMoveToTrash(t *testing.T) { + app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) + defer simScreen.Fini() + + ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) + + helpText := ui.formatHelpTextFor() + + assert.True(t, strings.Contains(helpText, "Move file or directory to trash")) + assert.False(t, strings.Contains(helpText, "Move file or directory to trash (disabled)")) +} + func TestHelpNoSpawnShell(t *testing.T) { app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) defer simScreen.Fini() @@ -27,6 +39,7 @@ func TestHelpNoSpawnShell(t *testing.T) { assert.True(t, strings.Contains(helpText, "Delete file or directory (disabled)")) assert.True(t, strings.Contains(helpText, "Empty file or directory (disabled)")) + assert.True(t, strings.Contains(helpText, "Move file or directory to trash (disabled)")) assert.True(t, strings.Contains(helpText, "Spawn shell in current directory (disabled)")) assert.True(t, strings.Contains(helpText, "Open file or directory in external program (disabled)")) assert.True(t, strings.Contains(helpText, "Show content of file (disabled)")) diff --git a/tui/tui.go b/tui/tui.go index cddaa19bd..48daa49f1 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -47,6 +47,7 @@ type UI struct { done chan struct{} remover func(fs.Item, fs.Item) error emptier func(fs.Item, fs.Item) error + trasher func(fs.Item, fs.Item) error exec func(argv0 string, argv []string, envv []string) error changeCwdFn func(string) error linkedItems fs.HardLinkedItems @@ -108,8 +109,8 @@ type UI struct { } type deleteQueueItem struct { - item fs.Item - shouldEmpty bool + item fs.Item + action DeleteAction } // ResultRow is a struct for a row in the result table @@ -148,6 +149,7 @@ func CreateUI( showItemCount: false, remover: remove.ItemFromDir, emptier: remove.EmptyFileFromDir, + trasher: remove.MoveItemToTrash, exec: Execute, linkedItems: make(fs.HardLinkedItems, 10), selectedTextColor: tview.Styles.TitleColor, @@ -515,7 +517,7 @@ func (ui *UI) deviceItemSelected(row, column int) { } } -func (ui *UI) confirmDeletion(shouldEmpty bool) { +func (ui *UI) confirmDeletion(action DeleteAction) { if ui.noDelete { previousHeaderText := ui.header.GetText(false) @@ -550,25 +552,19 @@ func (ui *UI) confirmDeletion(shouldEmpty bool) { } if len(ui.markedRows) > 0 { - ui.confirmDeletionMarked(shouldEmpty) + ui.confirmDeletionMarked(action) } else { - ui.confirmDeletionSelected(shouldEmpty) + ui.confirmDeletionSelected(action) } } -func (ui *UI) confirmDeletionSelected(shouldEmpty bool) { +func (ui *UI) confirmDeletionSelected(action DeleteAction) { row, column := ui.table.GetSelection() selectedFile := ui.table.GetCell(row, column).GetReference().(fs.Item) - var action string - if shouldEmpty { - action = "empty" - } else { - action = "delete" - } modal := tview.NewModal(). SetText( "Are you sure you want to " + - action + + action.Verb() + " \"" + tview.Escape(selectedFile.GetName()) + "\"?", @@ -580,7 +576,7 @@ func (ui *UI) confirmDeletionSelected(shouldEmpty bool) { ui.askBeforeDelete = false fallthrough case 1: - ui.deleteSelected(shouldEmpty) + ui.deleteSelected(action) } ui.pages.RemovePage("confirm") }) diff --git a/tui/tui_test.go b/tui/tui_test.go index 16006fc3e..aae449630 100644 --- a/tui/tui_test.go +++ b/tui/tui_test.go @@ -18,6 +18,7 @@ import ( "github.com/dundee/gdu/v5/pkg/device" "github.com/dundee/gdu/v5/pkg/fs" "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" "github.com/stretchr/testify/assert" ) @@ -133,7 +134,7 @@ func TestHelp(t *testing.T) { b, _, _ := simScreen.GetContents() - cells := b[557 : 557+9] + cells := b[607 : 607+9] text := []byte("directory") for i, r := range cells { @@ -154,7 +155,7 @@ func TestHelpBw(t *testing.T) { b, _, _ := simScreen.GetContents() - cells := b[557 : 557+9] + cells := b[607 : 607+9] text := []byte("directory") for i, r := range cells { @@ -291,7 +292,7 @@ func TestConfirmDeletion(t *testing.T) { ui := getAnalyzedPathMockedApp(t, true, true, true) ui.table.Select(1, 0) - ui.confirmDeletion(false) + ui.confirmDeletion(ActionDelete) assert.True(t, ui.pages.HasPage("confirm")) } @@ -300,7 +301,7 @@ func TestConfirmDeletionBW(t *testing.T) { ui := getAnalyzedPathMockedApp(t, false, true, true) ui.table.Select(1, 0) - ui.confirmDeletion(false) + ui.confirmDeletion(ActionDelete) assert.True(t, ui.pages.HasPage("confirm")) } @@ -309,7 +310,7 @@ func TestConfirmEmpty(t *testing.T) { ui := getAnalyzedPathMockedApp(t, false, true, true) ui.table.Select(1, 0) - ui.confirmDeletion(true) + ui.confirmDeletion(ActionEmpty) assert.True(t, ui.pages.HasPage("confirm")) } @@ -319,7 +320,7 @@ func TestConfirmEmptyMarked(t *testing.T) { ui.table.Select(1, 0) ui.markedRows[1] = struct{}{} - ui.confirmDeletion(true) + ui.confirmDeletion(ActionEmpty) assert.True(t, ui.pages.HasPage("confirm")) } @@ -329,7 +330,7 @@ func TestConfirmDeletionMarked(t *testing.T) { ui.table.Select(1, 0) ui.markedRows[1] = struct{}{} - ui.confirmDeletion(false) + ui.confirmDeletion(ActionDelete) assert.True(t, ui.pages.HasPage("confirm")) } @@ -339,7 +340,7 @@ func TestConfirmDeletionMarkedBW(t *testing.T) { ui.table.Select(1, 0) ui.markedRows[1] = struct{}{} - ui.confirmDeletion(false) + ui.confirmDeletion(ActionDelete) assert.True(t, ui.pages.HasPage("confirm")) } @@ -355,7 +356,7 @@ func TestDeleteSelected(t *testing.T) { ui.table.Select(0, 0) - ui.deleteSelected(false) + ui.deleteSelected(ActionDelete) <-ui.done @@ -378,7 +379,7 @@ func TestDeleteSelectedInParallel(t *testing.T) { ui.table.Select(0, 0) - ui.deleteSelected(false) + ui.deleteSelected(ActionDelete) <-ui.done @@ -402,7 +403,7 @@ func TestDeleteSelectedInBackground(t *testing.T) { ui.table.Select(0, 0) - ui.deleteSelected(false) + ui.deleteSelected(ActionDelete) <-ui.done @@ -413,6 +414,34 @@ func TestDeleteSelectedInBackground(t *testing.T) { assert.NoDirExists(t, "test_dir/nested") } +func TestMoveToTrashSelectedInBackground(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, true, false) + ui.done = make(chan struct{}) + ui.SetDeleteInBackground() + var trashed string + ui.trasher = func(dir, item fs.Item) error { + trashed = item.GetName() + dir.RemoveFile(item) + return nil + } + + assert.Equal(t, 1, ui.table.GetRowCount()) + ui.table.Select(0, 0) + ui.deleteSelected(ActionMoveToTrash) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "nested", trashed) + assert.Equal(t, 0, ui.table.GetRowCount()) +} + func TestDeleteSelectedInBackgroundAndParallel(t *testing.T) { fin := testdir.CreateTestDir() defer fin() @@ -427,7 +456,7 @@ func TestDeleteSelectedInBackgroundAndParallel(t *testing.T) { ui.table.Select(0, 0) - ui.deleteSelected(false) + ui.deleteSelected(ActionDelete) <-ui.done @@ -450,7 +479,7 @@ func TestDeleteSelectedInBackgroundBW(t *testing.T) { ui.table.Select(0, 0) - ui.deleteSelected(false) + ui.deleteSelected(ActionDelete) <-ui.done @@ -473,7 +502,7 @@ func TestEmptyDirInBackground(t *testing.T) { ui.table.Select(0, 0) - ui.deleteSelected(true) + ui.deleteSelected(ActionEmpty) <-ui.done @@ -498,7 +527,7 @@ func TestEmptyFileInBackground(t *testing.T) { ui.fileItemSelected(0, 0) // nested ui.table.Select(2, 0) - ui.deleteSelected(true) + ui.deleteSelected(ActionEmpty) <-ui.done @@ -527,7 +556,7 @@ func TestDeleteSelectedWithErr(t *testing.T) { ui.table.Select(0, 0) - ui.delete(false) + ui.delete(ActionDelete) <-ui.done @@ -551,7 +580,7 @@ func TestDeleteSelectedInBackgroundWithErr(t *testing.T) { ui.table.Select(0, 0) - ui.delete(false) + ui.delete(ActionDelete) <-ui.done @@ -583,7 +612,7 @@ func TestDeleteMarkedWithErr(t *testing.T) { ui.table.Select(0, 0) ui.markedRows[0] = struct{}{} - ui.deleteMarked(false) + ui.deleteMarked(ActionDelete) <-ui.done @@ -609,7 +638,7 @@ func TestDeleteMarkedInBackground(t *testing.T) { ui.markedRows[1] = struct{}{} // subnested ui.markedRows[2] = struct{}{} // file2 - ui.deleteMarked(false) + ui.deleteMarked(ActionDelete) <-ui.done // wait for deletion of subnested <-ui.done // wait for deletion of file2 @@ -638,7 +667,7 @@ func TestDeleteMarkedInBackgroundWithStorage(t *testing.T) { ui.markedRows[1] = struct{}{} // subnested ui.markedRows[2] = struct{}{} // file2 - ui.deleteMarked(false) + ui.deleteMarked(ActionDelete) <-ui.done // wait for deletion of subnested <-ui.done // wait for deletion of file2 @@ -668,7 +697,7 @@ func TestDeleteMarkedInBackgroundWithStorageAndParallel(t *testing.T) { ui.markedRows[1] = struct{}{} // subnested ui.markedRows[2] = struct{}{} // file2 - ui.deleteMarked(false) + ui.deleteMarked(ActionDelete) <-ui.done // wait for deletion of subnested <-ui.done // wait for deletion of file2 @@ -695,7 +724,7 @@ func TestDeleteMarkedInBackgroundWithErr(t *testing.T) { ui.table.Select(0, 0) ui.markedRows[0] = struct{}{} - ui.deleteMarked(false) + ui.deleteMarked(ActionDelete) <-ui.done @@ -929,12 +958,71 @@ func TestConfirmDeletionSelectedButtonOrder(t *testing.T) { ui := getAnalyzedPathMockedApp(t, true, true, true) ui.table.Select(1, 0) - ui.confirmDeletionSelected(false) + ui.confirmDeletionSelected(ActionDelete) // Verify confirmation page is created assert.True(t, ui.pages.HasPage("confirm")) } +func selectConfirmationYes(t *testing.T, ui *UI) { + t.Helper() + _, primitive := ui.pages.GetFrontPage() + modal, ok := primitive.(*tview.Modal) + if !assert.True(t, ok) { + return + } + var setFocus func(tview.Primitive) + setFocus = func(p tview.Primitive) { + p.Focus(setFocus) + } + modal.SetFocus(1) + modal.Focus(setFocus) + modal.InputHandler()(tcell.NewEventKey(tcell.KeyEnter, 0, 0), setFocus) +} + +func TestConfirmMoveToTrashSelectedYes(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, true, false) + ui.done = make(chan struct{}) + var trashed string + ui.trasher = func(dir, item fs.Item) error { + trashed = item.GetName() + dir.RemoveFile(item) + return nil + } + ui.table.Select(0, 0) + selected := ui.table.GetCell(0, 0).GetReference().(fs.Item).GetName() + + ui.confirmDeletionSelected(ActionMoveToTrash) + selectConfirmationYes(t, ui) + <-ui.done + + assert.Equal(t, selected, trashed) +} + +func TestConfirmMoveToTrashMarkedYes(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, true, false) + ui.done = make(chan struct{}) + var trashed string + ui.trasher = func(dir, item fs.Item) error { + trashed = item.GetName() + dir.RemoveFile(item) + return nil + } + ui.markedRows[0] = struct{}{} + + ui.confirmDeletionMarked(ActionMoveToTrash) + selectConfirmationYes(t, ui) + <-ui.done + + assert.Equal(t, "nested", trashed) +} + func TestConfirmDeletionSelectedSafeDefault(t *testing.T) { fin := testdir.CreateTestDir() defer fin() @@ -946,7 +1034,7 @@ func TestConfirmDeletionSelectedSafeDefault(t *testing.T) { ui.table.Select(0, 0) // Create confirmation dialog - ui.confirmDeletionSelected(false) + ui.confirmDeletionSelected(ActionDelete) // Verify that the confirmation dialog exists with safer defaults assert.DirExists(t, "test_dir/nested") @@ -965,7 +1053,7 @@ func TestConfirmDeletionButtonIndexMapping(t *testing.T) { ui.table.Select(0, 0) // Test that deletion still works when explicitly called - ui.deleteSelected(false) + ui.deleteSelected(ActionDelete) <-ui.done @@ -980,7 +1068,7 @@ func TestConfirmEmptySelectedSafeDefault(t *testing.T) { ui := getAnalyzedPathMockedApp(t, true, true, true) ui.table.Select(1, 0) - ui.confirmDeletionSelected(true) + ui.confirmDeletionSelected(ActionEmpty) // Verify empty confirmation dialog is created safely assert.True(t, ui.pages.HasPage("confirm")) @@ -991,7 +1079,7 @@ func TestConfirmDeletionMarkedSafeDefault(t *testing.T) { ui.table.Select(1, 0) ui.markedRows[1] = struct{}{} - ui.confirmDeletionMarked(false) + ui.confirmDeletionMarked(ActionDelete) // Verify marked deletion confirmation dialog is created safely assert.True(t, ui.pages.HasPage("confirm")) @@ -1002,7 +1090,7 @@ func TestConfirmEmptyMarkedSafeDefault(t *testing.T) { ui.table.Select(1, 0) ui.markedRows[1] = struct{}{} - ui.confirmDeletionMarked(true) + ui.confirmDeletionMarked(ActionEmpty) // Verify marked empty confirmation dialog is created safely assert.True(t, ui.pages.HasPage("confirm")) @@ -1018,8 +1106,8 @@ func TestSaferConfirmationPreventDataLoss(t *testing.T) { ui.table.Select(0, 0) // Test that creating confirmation dialog doesn't accidentally trigger deletion - ui.confirmDeletionSelected(false) - ui.confirmDeletionSelected(true) // empty + ui.confirmDeletionSelected(ActionDelete) + ui.confirmDeletionSelected(ActionEmpty) // empty // Directory should still exist - no accidental deletion assert.DirExists(t, "test_dir/nested") @@ -1037,7 +1125,7 @@ func TestConfirmDeletionSelectedCase1(t *testing.T) { ui.table.Select(0, 0) // Test case 1 branch (yes button at index 1) by directly calling deleteSelected - ui.deleteSelected(false) + ui.deleteSelected(ActionDelete) <-ui.done @@ -1059,7 +1147,7 @@ func TestConfirmDeletionMarkedCase1(t *testing.T) { ui.markedRows[1] = struct{}{} // subnested // Test case 1 branch (yes button at index 1) by directly calling deleteMarked - ui.deleteMarked(false) + ui.deleteMarked(ActionDelete) <-ui.done