├── doc ├── image │ ├── demo.gif │ ├── restore_table.jpg │ └── demo.tape ├── configuration.md └── alternatives.md ├── .gitignore ├── main.go ├── .github ├── workflows │ ├── test.yml │ ├── golangci-lint.yml │ └── release.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── itest ├── setup.sh ├── put_test.go ├── cli_test.go └── trash_test.go ├── docker-compose.yaml ├── internal ├── glog │ └── logger.go ├── posix │ ├── path.go │ ├── path_test.go │ ├── dir.go │ └── file.go ├── xdg │ ├── path.go │ ├── dirsizecache_test.go │ ├── trashdir_test.go │ ├── trashinfo_test.go │ ├── dirsizecache.go │ ├── trashinfo.go │ └── trashdir.go ├── tui │ ├── boolInputModel.go │ ├── tui.go │ ├── choiceInputModel.go │ ├── table │ │ ├── table_test.go │ │ └── table.go │ └── singleRestore.go ├── env │ └── env.go ├── cmd │ ├── restoreGroup.go │ ├── prune_test.go │ ├── summary.go │ ├── metafix.go │ ├── root.go │ ├── rm.go │ ├── prune.go │ ├── restore.go │ ├── find.go │ └── put.go └── trash │ ├── flag.go │ └── trash.go ├── Makefile ├── LICENSE ├── .goreleaser.yaml ├── go.mod ├── go.sum └── README.md /doc/image/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/umlx5h/gtrash/HEAD/doc/image/demo.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | gtrash 2 | dist/ 3 | __debug_bin* 4 | coverage 5 | coverage.html 6 | coverage.txt 7 | -------------------------------------------------------------------------------- /doc/image/restore_table.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/umlx5h/gtrash/HEAD/doc/image/restore_table.jpg -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/umlx5h/gtrash/internal/cmd" 5 | ) 6 | 7 | // set by CI 8 | var ( 9 | version = "unknown" 10 | commit = "unknown" 11 | date = "unknown" 12 | builtBy = "unknown" 13 | ) 14 | 15 | func main() { 16 | cmd.Execute(cmd.Version{ 17 | Version: version, 18 | Commit: commit, 19 | Date: date, 20 | BuiltBy: builtBy, 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | pull_request: 4 | branches: [ "main" ] 5 | workflow_dispatch: 6 | jobs: 7 | test: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | 12 | - name: Set up Go 13 | uses: actions/setup-go@v4 14 | with: 15 | go-version: '1.22' 16 | 17 | - name: Test 18 | run: make test-all 19 | -------------------------------------------------------------------------------- /itest/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | # 5 | # mkdir -p /tmp/external /tmp/external_alt 6 | # 7 | # # use tmpfs for test 8 | # mount -t tmpfs external /tmp/external 9 | # mount -t tmpfs external_alt /tmp/external_alt 10 | # 11 | # Create .Trash folder beforehand 12 | mkdir -p "/external/.Trash" 13 | mkdir -p "/external_alt/.Trash" 14 | 15 | chmod a+rw /external/.Trash /external_alt/.Trash 16 | 17 | # sticky bit set only in /external 18 | chmod +t /external/.Trash 19 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | itest: 3 | image: golang:1.22 4 | working_dir: /app 5 | tmpfs: 6 | - /external 7 | - /external_alt 8 | environment: 9 | - GOCOVERDIR=./coverage 10 | volumes: 11 | - ./gtrash:/app/gtrash:ro 12 | - ./itest:/app/itest 13 | - ./go.mod:/app/go.mod:ro 14 | # privileged: true 15 | command: 16 | - /bin/bash 17 | - -c 18 | - | 19 | set -eu 20 | bash ./itest/setup.sh 21 | go test -v ./itest 22 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | # ref: https://github.com/golangci/golangci-lint-action#how-to-use 2 | name: golangci-lint 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | permissions: 7 | contents: read 8 | jobs: 9 | golangci: 10 | name: lint 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-go@v4 15 | with: 16 | go-version: '1.22' 17 | cache: false 18 | - name: golangci-lint 19 | uses: golangci/golangci-lint-action@v3 20 | with: 21 | version: v1.54 22 | -------------------------------------------------------------------------------- /internal/glog/logger.go: -------------------------------------------------------------------------------- 1 | package glog 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | var ( 11 | errorCalled int 12 | progName = filepath.Base(os.Args[0]) 13 | ) 14 | 15 | var stderr io.Writer = os.Stderr 16 | 17 | func Error(msg string) { 18 | errorCalled++ 19 | fmt.Fprintln(stderr, progName+":", msg) 20 | } 21 | 22 | func Errorf(format string, args ...any) { 23 | errorCalled++ 24 | fmt.Fprintf(stderr, progName+": "+format, args...) 25 | } 26 | 27 | func ExitCode() int { 28 | if errorCalled == 0 { 29 | return 0 30 | } else { 31 | return 1 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /itest/put_test.go: -------------------------------------------------------------------------------- 1 | package itest 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "testing" 7 | ) 8 | 9 | // Paths ending in a dot must be skipped. 10 | // $ gtrash put . 11 | // gtrash: refusing to remove '.' or '..' directory: skipping "." 12 | func TestSkipDotEndingPath(t *testing.T) { 13 | paths := []string{".", "./", "..", "../", "../."} 14 | 15 | for _, path := range paths { 16 | t.Run(fmt.Sprintf("skipped path %q", path), func(t *testing.T) { 17 | 18 | cmd := exec.Command(execBinary, "put", path) 19 | out, err := cmd.CombinedOutput() 20 | mustError(t, err) 21 | assertContains(t, string(out), "refusing to remove '.' or '..' directory") 22 | }) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean test itest lint 2 | 3 | build: 4 | go build 5 | 6 | clean: 7 | rm -f gtrash 8 | rm -rf coverage itest/coverage 9 | rm -f coverage.txt coverage.html 10 | 11 | test-all: clean test itest report-coverage 12 | 13 | lint: 14 | golangci-lint run 15 | 16 | test: 17 | mkdir -p coverage 18 | go test -cover -v ./internal/... -args -test.gocoverdir="$$PWD/coverage" 19 | 20 | itest: 21 | mkdir -p itest/coverage 22 | go build -cover 23 | docker compose run itest 24 | 25 | report-coverage: 26 | go tool covdata percent -i=./coverage,./itest/coverage 27 | go tool covdata textfmt -i=./coverage,./itest/coverage -o coverage.txt 28 | go tool cover -html=coverage.txt -o coverage.html 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /internal/posix/path.go: -------------------------------------------------------------------------------- 1 | package posix 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | var euid int 10 | 11 | func init() { 12 | euid = os.Geteuid() 13 | } 14 | 15 | func AbsPathToTilde(absPath string) string { 16 | // if executed as root, disable 17 | if euid == 0 { 18 | return absPath 19 | } 20 | homeDir, ok := os.LookupEnv("HOME") 21 | if !ok { 22 | return absPath 23 | } 24 | 25 | if strings.HasPrefix(absPath, homeDir) { 26 | return strings.Replace(absPath, homeDir, "~", 1) 27 | } 28 | 29 | return absPath 30 | } 31 | 32 | // Check if sub is a subdirectory of parent 33 | func CheckSubPath(parent, sub string) (bool, error) { 34 | up := ".." + string(os.PathSeparator) 35 | 36 | rel, err := filepath.Rel(parent, sub) 37 | if err != nil { 38 | return false, err 39 | } 40 | if !strings.HasPrefix(rel, up) && rel != ".." { 41 | return true, nil 42 | } 43 | return false, nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/xdg/path.go: -------------------------------------------------------------------------------- 1 | package xdg 2 | 3 | import ( 4 | "os" 5 | "os/user" 6 | "path/filepath" 7 | 8 | "github.com/umlx5h/gtrash/internal/env" 9 | ) 10 | 11 | var ( 12 | // $HOME 13 | dirHome string 14 | // $XDG_DATA_HOME 15 | dirDataHome string 16 | 17 | DirHomeTrash string 18 | ) 19 | 20 | func init() { 21 | dirHome = os.Getenv("HOME") 22 | if dirHome == "" { 23 | // fallback to get home dir 24 | u, err := user.Current() 25 | if err == nil { 26 | dirHome = u.HomeDir 27 | } 28 | } 29 | 30 | dirDataHome = filepath.Join(dirHome, ".local", "share") 31 | if d, ok := os.LookupEnv("XDG_DATA_HOME"); ok { 32 | if abs, err := filepath.Abs(d); err == nil { 33 | dirDataHome = abs 34 | } 35 | } 36 | 37 | // Can be changed by environment variables 38 | if env.HOME_TRASH_DIR != "" { 39 | DirHomeTrash = env.HOME_TRASH_DIR 40 | } else { 41 | DirHomeTrash = filepath.Join(dirDataHome, "Trash") 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | permissions: 7 | contents: write 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v4 19 | with: 20 | go-version: '1.22' 21 | 22 | - name: Test 23 | run: make test-all 24 | 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v5 27 | with: 28 | distribution: goreleaser 29 | version: latest 30 | args: release --clean 31 | env: 32 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 33 | # needed by homebrew 34 | TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} 35 | # AUR 36 | AUR_KEY: ${{ secrets.AUR_KEY }} 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | Please run the buggy command with `--debug` option and write down the results. 21 | ``` 22 | $ gtrash find --debug 23 | ``` 24 | 25 | **Expected behavior** 26 | A clear and concise description of what you expected to happen. 27 | 28 | **Screenshots** 29 | If applicable, add screenshots to help explain your problem. 30 | 31 | **Version (please complete the following information):** 32 | - OS: [e.g. Linux, Mac] 33 | - Version [e.g. 0.0.1] 34 | 35 | The version can be checked with `gtrash --version` 36 | ``` 37 | $ gtrash --version 38 | ``` 39 | 40 | **Additional context** 41 | Add any other context about the problem here. 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 umlx5h 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /internal/posix/path_test.go: -------------------------------------------------------------------------------- 1 | package posix 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestAbsPathToTilde(t *testing.T) { 9 | home := os.Getenv("HOME") 10 | 11 | tests := []struct { 12 | absPath string 13 | expectedPath string 14 | }{ 15 | {home + "/example/file.txt", "~/example/file.txt"}, 16 | {"/home/user/another/file.txt", "/home/user/another/file.txt"}, 17 | {"", ""}, 18 | } 19 | 20 | for _, tt := range tests { 21 | result := AbsPathToTilde(tt.absPath) 22 | if result != tt.expectedPath { 23 | t.Errorf("Expected %s, but got %s for path %s", tt.expectedPath, result, tt.absPath) 24 | } 25 | } 26 | } 27 | 28 | func TestCheckSubPath(t *testing.T) { 29 | tests := []struct { 30 | parentPath string 31 | subPath string 32 | expected bool 33 | }{ 34 | {"/home/user", "/home/user/Documents", true}, 35 | {"/home/user", "/home/user/Documents/foo", true}, 36 | {"/home/user", "/home/user", true}, 37 | {"/home/user", "/var/www", false}, 38 | {"/home/user", "/home", false}, 39 | {"/", "/", true}, 40 | } 41 | 42 | for _, tt := range tests { 43 | result, err := CheckSubPath(tt.parentPath, tt.subPath) 44 | if err != nil { 45 | t.Errorf("Error occurred: %s", err) 46 | } 47 | 48 | if result != tt.expected { 49 | t.Errorf("Expected %v, but got %v for parent: %q, sub: %q", tt.expected, result, tt.parentPath, tt.subPath) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/xdg/dirsizecache_test.go: -------------------------------------------------------------------------------- 1 | package xdg 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestNewDirCache(t *testing.T) { 13 | want := make(DirCache) 14 | want["bar"] = &struct { 15 | Item DirCacheItem 16 | Seen bool 17 | }{ 18 | Item: DirCacheItem{ 19 | Size: 10000, 20 | Mtime: time.Unix(1672531200, 0), 21 | DirName: "bar", 22 | }, 23 | Seen: false, 24 | } 25 | 26 | want["foo"] = &struct { 27 | Item DirCacheItem 28 | Seen bool 29 | }{ 30 | Item: DirCacheItem{ 31 | Size: 20000, 32 | Mtime: time.Unix(1672531200, 0), 33 | DirName: "foo", 34 | }, 35 | Seen: false, 36 | } 37 | 38 | want["あい うえお"] = &struct { 39 | Item DirCacheItem 40 | Seen bool 41 | }{ 42 | Item: DirCacheItem{ 43 | Size: 40000, 44 | Mtime: time.Unix(1672531200, 0), 45 | DirName: "あい うえお", 46 | }, 47 | Seen: false, 48 | } 49 | 50 | file := `10000 1672531200 bar 51 | 20000 1672531200 foo 52 | 40000 1672531200 %E3%81%82%E3%81%84%20%E3%81%86%E3%81%88%E3%81%8A 53 | ` 54 | 55 | got, err := NewDirCache(strings.NewReader(file)) 56 | require.NoError(t, err) 57 | assert.EqualValues(t, want, got, "parse directorysizes") 58 | 59 | assert.Equal(t, file, got.ToFile(false), "back to directorysizes text") 60 | 61 | t.Run("skip not seen item when truncate on", func(t *testing.T) { 62 | got["foo"].Seen = true 63 | assert.Equal(t, "20000 1672531200 foo\n", got.ToFile(true)) 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /internal/xdg/trashdir_test.go: -------------------------------------------------------------------------------- 1 | package xdg 2 | 3 | import ( 4 | "slices" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestGetMountpoint(t *testing.T) { 11 | // replace to stub 12 | mountinfo_Mounted = func(fpath string) (bool, error) { 13 | mounts := []string{ 14 | "/", 15 | "/foo/bar", 16 | "/foo", 17 | "/fooo/bar", 18 | "/ffoo/bar", 19 | } 20 | return slices.Contains(mounts, fpath), nil 21 | } 22 | 23 | // not evaluating each component here, just the entire path 24 | symlinked := map[string]string{ 25 | // file is a link 26 | "/foo/link.txt": "/foo/bar/target.txt", 27 | 28 | // first component is a link 29 | "/link": "/foo/bar", 30 | } 31 | 32 | EvalSymLinks = func(path string) (string, error) { 33 | if symlink, ok := symlinked[path]; ok { 34 | return symlink, nil 35 | } 36 | return path, nil 37 | } 38 | 39 | testsNormal := []struct { 40 | path string 41 | want string 42 | }{ 43 | {path: "/a.txt", want: "/"}, 44 | {path: "/foo/bar/a.txt", want: "/foo/bar"}, 45 | {path: "/foo/bar/aaa/b.txt", want: "/foo/bar"}, 46 | {path: "/ffoo/bar/a.txt", want: "/ffoo/bar"}, 47 | {path: "/aaa/bbb/ccc/ddd.txt", want: "/"}, 48 | {path: "/", want: "/"}, 49 | 50 | {path: "/foo/link.txt", want: "/foo"}, 51 | {path: "/link/a.txt", want: "/foo/bar"}, 52 | } 53 | 54 | t.Run("normal", func(t *testing.T) { 55 | for _, tt := range testsNormal { 56 | got, err := getMountpoint(tt.path) 57 | require.NoError(t, err) 58 | if got != tt.want { 59 | t.Errorf("getMountpoint(%q) = %q, want %q", tt.path, got, tt.want) 60 | } 61 | } 62 | }) 63 | 64 | t.Run("error", func(t *testing.T) { 65 | got, err := getMountpoint("") 66 | require.Error(t, err, got) 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /internal/posix/dir.go: -------------------------------------------------------------------------------- 1 | package posix 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "syscall" 9 | ) 10 | 11 | // same as du -B1 or du -sh 12 | // The size is calculated as the disk space used by the directory and its contents, that is, the size of the blocks, in bytes (in the same way as the `du -B1` command calculates). 13 | func DirSize(path string) (int64, error) { 14 | var block int64 15 | err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { 16 | if err != nil { 17 | return err 18 | } 19 | 20 | sys, ok := info.Sys().(*syscall.Stat_t) 21 | if !ok { 22 | return errors.New("cannot get stat_t") 23 | } 24 | 25 | block += sys.Blocks 26 | return err 27 | }) 28 | return block * 512, err 29 | } 30 | 31 | // Look at both block-size and apparent-size and choose the larger one. 32 | // Because there are file systems for which block size cannot be obtained. 33 | // max(du -sB1, du -sb) 34 | func DirSizeFallback(path string) (int64, error) { 35 | var size int64 36 | err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error { 37 | if err != nil { 38 | return err 39 | } 40 | 41 | sys, ok := info.Sys().(*syscall.Stat_t) 42 | if !ok { 43 | return errors.New("cannot get stat_t") 44 | } 45 | 46 | // stat(2) 47 | // blkcnt_t st_blocks; /* Number of 512B blocks allocated */ 48 | size += max(sys.Size, sys.Blocks*512) 49 | return err 50 | }) 51 | 52 | return size, err 53 | } 54 | 55 | // check name path is empty directory 56 | func DirEmpty(name string) (bool, error) { 57 | f, err := os.Open(name) 58 | if err != nil { 59 | return false, err 60 | } 61 | defer f.Close() 62 | 63 | _, err = f.Readdirnames(1) 64 | if err == io.EOF { 65 | return true, nil 66 | } 67 | return false, err 68 | } 69 | -------------------------------------------------------------------------------- /internal/tui/boolInputModel.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/charmbracelet/bubbles/textinput" 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | type boolInputModel struct { 12 | textInput textinput.Model 13 | confirmed bool 14 | } 15 | 16 | func yesno(s string) (bool, string, error) { 17 | if s == "" { 18 | return false, "", errors.New("empty") 19 | } 20 | switch strings.ToLower(s[0:1]) { 21 | case "y": 22 | return true, "Yes", nil 23 | case "n": 24 | return false, "No", nil 25 | } 26 | return false, "", errors.New("unknown") 27 | } 28 | 29 | func newBoolInputModel(prompt string) boolInputModel { 30 | textInput := textinput.New() 31 | textInput.Prompt = prompt 32 | textInput.Placeholder = "(Yes/No)" 33 | textInput.Validate = func(value string) error { 34 | _, _, err := yesno(value) 35 | return err 36 | } 37 | textInput.Focus() 38 | return boolInputModel{ 39 | textInput: textInput, 40 | } 41 | } 42 | 43 | func (m boolInputModel) Confirmed() bool { 44 | return m.confirmed 45 | } 46 | 47 | func (m boolInputModel) Init() tea.Cmd { 48 | return textinput.Blink 49 | } 50 | 51 | func (m boolInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 52 | if keyMsg, ok := msg.(tea.KeyMsg); ok { 53 | switch keyMsg.Type { 54 | case tea.KeyCtrlC, tea.KeyEsc: 55 | m.textInput.Blur() 56 | return m, tea.Quit 57 | } 58 | } 59 | var cmd tea.Cmd 60 | m.textInput, cmd = m.textInput.Update(msg) 61 | if _, value, err := yesno(m.textInput.Value()); err == nil { 62 | m.textInput.Blur() 63 | m.textInput.SetValue(value) 64 | m.confirmed = true 65 | return m, tea.Quit 66 | } 67 | return m, cmd 68 | } 69 | 70 | func (m boolInputModel) Value() bool { 71 | valueStr := m.textInput.Value() 72 | v, _, _ := yesno(valueStr) 73 | return v 74 | } 75 | 76 | func (m boolInputModel) View() string { 77 | return m.textInput.View() + "\n" 78 | } 79 | -------------------------------------------------------------------------------- /internal/tui/tui.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | tea "github.com/charmbracelet/bubbletea" 9 | 10 | "github.com/umlx5h/gtrash/internal/trash" 11 | ) 12 | 13 | func FilesSelect(files []trash.File) ([]trash.File, error) { 14 | m := newMultiRestoreModel(files) 15 | result, err := tea.NewProgram(m, tea.WithAltScreen()).Run() 16 | if err != nil { 17 | fmt.Println("Error running program:", err) 18 | os.Exit(1) 19 | } 20 | 21 | if r, ok := result.(multiRestoreModel); ok { 22 | if r.confirmed { 23 | return r.restoreFiles, nil 24 | } 25 | } 26 | 27 | return nil, errors.New("no selected") 28 | } 29 | 30 | func GroupSelect(groups []trash.Group) (trash.Group, error) { 31 | m := newSingleRestoreModel(groups) 32 | result, err := tea.NewProgram(m, tea.WithAltScreen()).Run() 33 | if err != nil { 34 | fmt.Println("Error running program:", err) 35 | os.Exit(1) 36 | } 37 | 38 | if r, ok := result.(singleRestoreModel); ok { 39 | if r.confirmed { 40 | return groups[r.selected], nil 41 | } 42 | } 43 | 44 | return trash.Group{}, errors.New("no selected") 45 | } 46 | 47 | func BoolPrompt(prompt string) bool { 48 | m := newBoolInputModel(prompt) 49 | 50 | result, err := tea.NewProgram(m).Run() 51 | if err != nil { 52 | return false 53 | } 54 | 55 | if m, ok := result.(boolInputModel); ok { 56 | return m.Confirmed() && m.Value() 57 | } 58 | 59 | return false 60 | } 61 | 62 | func ChoicePrompt(prompt string, choices []string) (string, error) { 63 | model := newChoiceInputModel(prompt, choices) 64 | result, err := tea.NewProgram(model).Run() 65 | if err != nil { 66 | return "", err 67 | } 68 | 69 | if m, ok := result.(choiceInputModel); ok { 70 | if !m.Confirmed() || m.Value() == "quit" { // hard code quit 71 | return "", errors.New("canceled") 72 | } 73 | 74 | return m.Value(), err 75 | } 76 | return "", errors.New("unexpected error in ChoicePrompt") 77 | } 78 | -------------------------------------------------------------------------------- /itest/cli_test.go: -------------------------------------------------------------------------------- 1 | package itest 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | var execBinary = "/app/gtrash" 11 | 12 | func TestMain(m *testing.M) { 13 | if _, err := os.Stat("/.dockerenv"); err != nil { 14 | log.Println("please execute on docker enviornment.") 15 | os.Exit(1) 16 | } 17 | ret := m.Run() 18 | os.Exit(ret) 19 | } 20 | 21 | func checkFileMoved(t *testing.T, from string, to string) { 22 | t.Helper() 23 | 24 | if _, err := os.Stat(from); err == nil { 25 | t.Errorf("from still exists. from=%q", from) 26 | } 27 | 28 | if _, err := os.Stat(to); err != nil { 29 | t.Errorf("to not found. to=%q", to) 30 | } 31 | } 32 | 33 | func mustError(t testing.TB, err error, msg ...string) { 34 | t.Helper() 35 | 36 | if err == nil { 37 | if len(msg) == 0 { 38 | t.Fatalf("Received unexpected error: %v", err) 39 | } else { 40 | t.Fatalf("Received unexpected error: %s: %v", msg[0], err) 41 | } 42 | } 43 | } 44 | 45 | func mustNoError(t testing.TB, err error, msg ...string) { 46 | t.Helper() 47 | 48 | if err != nil { 49 | if len(msg) == 0 { 50 | t.Fatalf("Received unexpected error: %v", err) 51 | } else { 52 | t.Fatalf("Received unexpected error: %s: %v", msg[0], err) 53 | } 54 | } 55 | } 56 | 57 | func assertEmpty(t *testing.T, s string) { 58 | t.Helper() 59 | 60 | if s != "" { 61 | t.Errorf("Received non empty value: %v", s) 62 | } 63 | } 64 | 65 | func assertContains(t *testing.T, s string, substr string, msg ...string) { 66 | t.Helper() 67 | 68 | if !strings.Contains(s, substr) { 69 | if len(msg) > 0 { 70 | t.Logf(msg[0]) 71 | } 72 | t.Errorf("%q does not contain %q", s, substr) 73 | } 74 | } 75 | 76 | func assertEqual(t *testing.T, got string, want string, msg ...string) { 77 | t.Helper() 78 | 79 | if want != got { 80 | if len(msg) > 0 { 81 | t.Logf(msg[0]) 82 | } 83 | t.Errorf("does not match\nExpected:\n\t%q\nActual:\n\t%q\n", want, got) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /internal/env/env.go: -------------------------------------------------------------------------------- 1 | package env 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | // Copy files to the trash can in the home directory when they cannot be renamed to the external trash can 12 | // Disk usage of the main file system will increase because files are copied across different filesystems, also also take time to copy. 13 | // Automatically enabled if ONLY_HOME_TRASH enabled 14 | // Default: false 15 | HOME_TRASH_FALLBACK_COPY bool 16 | 17 | // Use only the trash can in the home directory, not the one in the external file system 18 | // Default: false 19 | ONLY_HOME_TRASH bool 20 | 21 | // Specify the directory for home trash can 22 | // Default: $XDG_DATA_HOME/Trash ($HOME/.local/share/Trash) 23 | HOME_TRASH_DIR string 24 | 25 | // Whether to get as close to rm behavior as possible 26 | // Default: false 27 | PUT_RM_MODE bool 28 | ) 29 | 30 | func init() { 31 | if e, ok := os.LookupEnv("GTRASH_HOME_TRASH_FALLBACK_COPY"); ok { 32 | if strings.ToLower(strings.TrimSpace(e)) == "true" { 33 | HOME_TRASH_FALLBACK_COPY = true 34 | } 35 | } 36 | 37 | if e, ok := os.LookupEnv("GTRASH_ONLY_HOME_TRASH"); ok { 38 | if strings.ToLower(strings.TrimSpace(e)) == "true" { 39 | ONLY_HOME_TRASH = true 40 | // Also enable this 41 | HOME_TRASH_FALLBACK_COPY = true 42 | } 43 | } 44 | 45 | if e, ok := os.LookupEnv("GTRASH_PUT_RM_MODE"); ok { 46 | if strings.ToLower(strings.TrimSpace(e)) == "true" { 47 | PUT_RM_MODE = true 48 | } 49 | } 50 | 51 | if e, ok := os.LookupEnv("GTRASH_HOME_TRASH_DIR"); ok { 52 | if e != "" { 53 | path, err := filepath.Abs(e) 54 | if err != nil { 55 | fmt.Fprintf(os.Stderr, "ENV $GTRASH_HOME_TRASH_DIR is not valid path: %s", err) 56 | os.Exit(1) 57 | } 58 | 59 | // Ensure to have directory in advance 60 | if err := os.MkdirAll(path, 0o700); err != nil { 61 | fmt.Fprintf(os.Stderr, "ENV $GTRASH_HOME_TRASH_DIR could not be created: %s", err) 62 | os.Exit(1) 63 | } 64 | 65 | HOME_TRASH_DIR = path 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /internal/tui/choiceInputModel.go: -------------------------------------------------------------------------------- 1 | package tui 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/charmbracelet/bubbles/textinput" 8 | tea "github.com/charmbracelet/bubbletea" 9 | ) 10 | 11 | type choiceInputModel struct { 12 | textInput textinput.Model 13 | keys map[string]string 14 | confirmed bool 15 | } 16 | 17 | func newChoiceInputModel(prompt string, choices []string) choiceInputModel { 18 | textInput := textinput.New() 19 | textInput.Prompt = prompt 20 | 21 | for i := range choices { 22 | choices[i] = strings.ToUpper(choices[i][:1]) + choices[i][1:] 23 | } 24 | 25 | textInput.Placeholder = "(" + strings.Join(choices, "/") + ")" 26 | keys := make(map[string]string) 27 | for _, choice := range choices { 28 | keys[strings.ToLower(choice[0:1])] = choice 29 | } 30 | textInput.Validate = func(s string) error { 31 | if s == "" { 32 | return errors.New("empty") 33 | } 34 | if _, ok := keys[strings.ToLower(s[0:1])]; ok { 35 | return nil 36 | } 37 | return errors.New("unknown") 38 | } 39 | textInput.Focus() 40 | return choiceInputModel{ 41 | textInput: textInput, 42 | keys: keys, 43 | } 44 | } 45 | 46 | func (m choiceInputModel) Confirmed() bool { 47 | return m.confirmed 48 | } 49 | 50 | func (m choiceInputModel) Init() tea.Cmd { 51 | return textinput.Blink 52 | } 53 | 54 | func (m choiceInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 55 | if keyMsg, ok := msg.(tea.KeyMsg); ok { 56 | switch keyMsg.Type { 57 | case tea.KeyCtrlC, tea.KeyEsc: 58 | m.textInput.Blur() 59 | return m, tea.Quit 60 | } 61 | } 62 | 63 | var cmd tea.Cmd 64 | m.textInput, cmd = m.textInput.Update(msg) 65 | if value, ok := m.keys[strings.ToLower(m.textInput.Value())]; ok { 66 | m.textInput.Blur() 67 | m.textInput.SetValue(value) 68 | m.confirmed = true 69 | return m, tea.Quit 70 | } 71 | return m, cmd 72 | } 73 | 74 | func (m choiceInputModel) Value() string { 75 | value := m.textInput.Value() 76 | return strings.ToLower(value) 77 | } 78 | 79 | func (m choiceInputModel) View() string { 80 | return m.textInput.View() + "\n" 81 | } 82 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 1 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - env: 9 | - CGO_ENABLED=0 10 | goos: 11 | - linux 12 | - darwin 13 | ldflags: 14 | - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser 15 | flags: 16 | - -trimpath 17 | 18 | archives: 19 | - format: tar.gz 20 | # this name template makes the OS and Arch compatible with the results of `uname`. 21 | name_template: >- 22 | {{ .ProjectName }}_ 23 | {{- title .Os }}_ 24 | {{- if eq .Arch "amd64" }}x86_64 25 | {{- else if eq .Arch "386" }}i386 26 | {{- else }}{{ .Arch }}{{ end }} 27 | {{- if .Arm }}v{{ .Arm }}{{ end }} 28 | # Only include binary in archive 29 | files: 30 | - none* 31 | 32 | changelog: 33 | sort: asc 34 | filters: 35 | exclude: 36 | - "^docs:" 37 | - "^test:" 38 | 39 | brews: 40 | - repository: 41 | owner: umlx5h 42 | name: homebrew-tap 43 | token: "{{ .Env.TAP_GITHUB_TOKEN }}" 44 | homepage: "https://github.com/umlx5h/gtrash" 45 | description: "A Trash CLI manager written in Go" 46 | license: "MIT" 47 | 48 | aurs: 49 | - 50 | name: gtrash-bin 51 | homepage: "https://github.com/umlx5h/gtrash" 52 | description: "A Trash CLI manager written in Go" 53 | license: "MIT" 54 | private_key: '{{ .Env.AUR_KEY }}' 55 | git_url: 'ssh://aur@aur.archlinux.org/gtrash-bin.git' 56 | package: |- 57 | # bin 58 | install -Dm755 "./gtrash" "${pkgdir}/usr/bin/gtrash" 59 | 60 | # completions 61 | mkdir -p "${pkgdir}/usr/share/bash-completion/completions/" 62 | mkdir -p "${pkgdir}/usr/share/zsh/site-functions/" 63 | mkdir -p "${pkgdir}/usr/share/fish/vendor_completions.d/" 64 | 65 | ./gtrash completion bash | install -Dm644 /dev/stdin "${pkgdir}/usr/share/bash-completion/completions/gtrash" 66 | ./gtrash completion zsh | install -Dm644 /dev/stdin "${pkgdir}/usr/share/zsh/site-functions/_gtrash" 67 | ./gtrash completion fish | install -Dm644 /dev/stdin "${pkgdir}/usr/share/fish/vendor_completions.d/gtrash.fish" 68 | -------------------------------------------------------------------------------- /internal/cmd/restoreGroup.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/umlx5h/gtrash/internal/glog" 9 | "github.com/umlx5h/gtrash/internal/trash" 10 | "github.com/umlx5h/gtrash/internal/tui" 11 | ) 12 | 13 | type restoreGroupCmd struct { 14 | cmd *cobra.Command 15 | opts restoreGroupOptions 16 | } 17 | 18 | type restoreGroupOptions struct{} 19 | 20 | func newRestoreGroupCmd() *restoreGroupCmd { 21 | root := &restoreGroupCmd{} 22 | 23 | cmd := &cobra.Command{ 24 | Use: "restore-group", 25 | Aliases: []string{"rg"}, 26 | Short: "Restore trashed files as a group interactively (rg)", 27 | Long: `Description: 28 | Use the TUI interface for file restoration. 29 | Unlike the 'restore' command, files deleted simultaneously are grouped together. 30 | 31 | Multiple selections of groups are not allowed. 32 | 33 | Actually, files deleted using 'gtrash put' may not be grouped accurately. 34 | Files with deletion times matching in seconds are grouped together. 35 | 36 | Refer below for detailed information. 37 | ref: https://github.com/umlx5h/gtrash#how-does-the-restore-group-subcommand-work 38 | `, 39 | SilenceUsage: true, 40 | Args: cobra.NoArgs, 41 | ValidArgsFunction: cobra.NoFileCompletions, 42 | RunE: func(_ *cobra.Command, _ []string) error { 43 | if err := restoreGroupCmdRun(root.opts); err != nil { 44 | return err 45 | } 46 | if glog.ExitCode() > 0 { 47 | return errContinue 48 | } 49 | return nil 50 | }, 51 | } 52 | 53 | root.cmd = cmd 54 | return root 55 | } 56 | 57 | func restoreGroupCmdRun(_ restoreGroupOptions) error { 58 | box := trash.NewBox() 59 | if err := box.Open(); err != nil { 60 | return err 61 | } 62 | 63 | groups := box.ToGroups() 64 | 65 | group, err := tui.GroupSelect(groups) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | listFiles(group.Files, false, false) 71 | fmt.Printf("\nSelected %d trashed files\n", len(group.Files)) 72 | 73 | if isTerminal && !tui.BoolPrompt("Are you sure you want to restore? ") { 74 | return errors.New("do nothing") 75 | } 76 | 77 | if err := doRestore(group.Files, "", true); err != nil { 78 | return err 79 | } 80 | 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /internal/cmd/prune_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/umlx5h/gtrash/internal/trash" 8 | ) 9 | 10 | func newInt(i int64) *int64 { 11 | return &i 12 | } 13 | 14 | func TestGetPruneFiles(t *testing.T) { 15 | t.Run("should return prune files", func(t *testing.T) { 16 | got, deleted, total := getPruneFiles([]trash.File{ 17 | { 18 | Name: "a", 19 | Size: newInt(20), 20 | }, 21 | { 22 | Name: "b", 23 | Size: newInt(30), 24 | }, 25 | { 26 | Name: "c", 27 | Size: newInt(50), 28 | }, 29 | { 30 | Name: "d", 31 | Size: newInt(100), 32 | }, 33 | { 34 | Name: "e", 35 | Size: newInt(150), 36 | }, 37 | }, 100) 38 | 39 | want := []trash.File{ 40 | { 41 | Name: "d", 42 | Size: newInt(100), 43 | }, 44 | { 45 | Name: "e", 46 | Size: newInt(150), 47 | }, 48 | } 49 | 50 | assert.Equal(t, want, got) 51 | assert.EqualValues(t, 250, deleted) 52 | assert.EqualValues(t, 350, total) 53 | }) 54 | 55 | t.Run("should prune files from larger files", func(t *testing.T) { 56 | got, deleted, total := getPruneFiles([]trash.File{ 57 | { 58 | Name: "a", 59 | Size: newInt(20), 60 | }, 61 | { 62 | Name: "b", 63 | Size: newInt(30), 64 | }, 65 | { 66 | Name: "c", 67 | Size: newInt(50), 68 | }, 69 | }, 30) 70 | 71 | want := []trash.File{ 72 | { 73 | Name: "b", 74 | Size: newInt(30), 75 | }, 76 | { 77 | Name: "c", 78 | Size: newInt(50), 79 | }, 80 | } 81 | 82 | assert.Equal(t, want, got) 83 | assert.EqualValues(t, 80, deleted) 84 | assert.EqualValues(t, 100, total) 85 | }) 86 | 87 | t.Run("should return nil", func(t *testing.T) { 88 | got, deleted, total := getPruneFiles([]trash.File{ 89 | { 90 | Name: "a", 91 | Size: newInt(20), 92 | }, 93 | { 94 | Name: "b", 95 | Size: newInt(30), 96 | }, 97 | { 98 | Name: "c", 99 | Size: newInt(50), 100 | }, 101 | }, 100) 102 | 103 | assert.Nil(t, got) 104 | assert.EqualValues(t, 0, deleted) 105 | assert.EqualValues(t, 100, total) 106 | }) 107 | 108 | } 109 | -------------------------------------------------------------------------------- /internal/cmd/summary.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/dustin/go-humanize" 8 | "github.com/spf13/cobra" 9 | "github.com/umlx5h/gtrash/internal/glog" 10 | "github.com/umlx5h/gtrash/internal/trash" 11 | ) 12 | 13 | type summaryCmd struct { 14 | cmd *cobra.Command 15 | opts summaryOptions 16 | } 17 | 18 | type summaryOptions struct{} 19 | 20 | func newSummaryCmd() *summaryCmd { 21 | root := &summaryCmd{} 22 | cmd := &cobra.Command{ 23 | Use: "summary", 24 | Short: "Show summary of all trash cans (s)", 25 | Aliases: []string{"s"}, 26 | Long: `Description: 27 | Displays statistics summarizing all trash cans. 28 | Shows the count of files (and folders) and their total size. 29 | When multiple trash cans are detected, the statistics for each and the total are displayed.`, 30 | SilenceUsage: true, 31 | Args: cobra.NoArgs, 32 | ValidArgsFunction: cobra.NoFileCompletions, 33 | RunE: func(_ *cobra.Command, _ []string) error { 34 | if err := summaryCmdRun(root.opts); err != nil { 35 | return err 36 | } 37 | if glog.ExitCode() > 0 { 38 | return errContinue 39 | } 40 | return nil 41 | }, 42 | } 43 | 44 | root.cmd = cmd 45 | return root 46 | } 47 | 48 | func summaryCmdRun(_ summaryOptions) error { 49 | box := trash.NewBox( 50 | trash.WithGetSize(true), 51 | ) 52 | 53 | if err := box.Open(); err != nil { 54 | if !errors.Is(err, trash.ErrNotFound) { 55 | return err 56 | } 57 | } 58 | 59 | var ( 60 | totalSize int64 61 | totalItem int 62 | ) 63 | 64 | for i, trashDir := range box.TrashDirs { 65 | var ( 66 | size int64 67 | item int 68 | ) 69 | 70 | for _, f := range box.FilesByTrashDir[trashDir] { 71 | item++ 72 | if f.Size != nil { 73 | size += *f.Size 74 | } 75 | } 76 | 77 | fmt.Printf("[%s]\n", trashDir) 78 | fmt.Printf("item: %d\n", item) 79 | fmt.Printf("size: %s\n", humanize.Bytes(uint64(size))) 80 | 81 | if i != len(box.TrashDirs)-1 { 82 | fmt.Println("") 83 | } 84 | 85 | totalSize += size 86 | totalItem += item 87 | } 88 | 89 | if len(box.TrashDirs) > 1 { 90 | fmt.Printf("\n[total]\n") 91 | fmt.Printf("item: %d\n", totalItem) 92 | fmt.Printf("size: %s\n", humanize.Bytes(uint64(totalSize))) 93 | } 94 | 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/umlx5h/gtrash 2 | 3 | go 1.22.4 4 | 5 | require ( 6 | github.com/charmbracelet/bubbles v0.18.0 7 | github.com/charmbracelet/bubbletea v0.26.6 8 | github.com/charmbracelet/lipgloss v0.11.0 9 | github.com/dustin/go-humanize v1.0.1 10 | github.com/gobwas/glob v0.2.3 11 | github.com/juju/ansiterm v1.0.0 12 | github.com/lmittmann/tint v1.0.4 13 | github.com/moby/sys/mountinfo v0.7.1 14 | github.com/otiai10/copy v1.14.0 15 | github.com/rs/xid v1.5.0 16 | github.com/spf13/cobra v1.8.1 17 | github.com/spf13/pflag v1.0.5 18 | github.com/stretchr/testify v1.8.4 19 | github.com/umlx5h/go-runewidth v0.0.0-20240106112317-9bbbb3702d5f 20 | golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 21 | golang.org/x/term v0.21.0 22 | ) 23 | 24 | require ( 25 | github.com/atotto/clipboard v0.1.4 // indirect 26 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 27 | github.com/charmbracelet/x/ansi v0.1.2 // indirect 28 | github.com/charmbracelet/x/input v0.1.2 // indirect 29 | github.com/charmbracelet/x/term v0.1.1 // indirect 30 | github.com/charmbracelet/x/windows v0.1.2 // indirect 31 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 32 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 33 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 34 | github.com/kr/pretty v0.3.1 // indirect 35 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 36 | github.com/lunixbochs/vtclean v1.0.0 // indirect 37 | github.com/mattn/go-colorable v0.1.13 // indirect 38 | github.com/mattn/go-isatty v0.0.20 // indirect 39 | github.com/mattn/go-localereader v0.0.1 // indirect 40 | github.com/mattn/go-runewidth v0.0.15 // indirect 41 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 42 | github.com/muesli/cancelreader v0.2.2 // indirect 43 | github.com/muesli/termenv v0.15.2 // indirect 44 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 45 | github.com/rivo/uniseg v0.4.7 // indirect 46 | github.com/rogpeppe/go-internal v1.11.0 // indirect 47 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 48 | golang.org/x/sync v0.7.0 // indirect 49 | golang.org/x/sys v0.21.0 // indirect 50 | golang.org/x/text v0.16.0 // indirect 51 | gopkg.in/yaml.v3 v3.0.1 // indirect 52 | ) 53 | -------------------------------------------------------------------------------- /internal/cmd/metafix.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/umlx5h/gtrash/internal/glog" 10 | "github.com/umlx5h/gtrash/internal/trash" 11 | "github.com/umlx5h/gtrash/internal/tui" 12 | ) 13 | 14 | type metafixCmd struct { 15 | cmd *cobra.Command 16 | opts metafixOptions 17 | } 18 | 19 | type metafixOptions struct { 20 | force bool 21 | } 22 | 23 | func newMetafixCmd() *metafixCmd { 24 | root := &metafixCmd{} 25 | cmd := &cobra.Command{ 26 | Use: "metafix", 27 | Short: "Fix trashcan metadata", 28 | Long: `Description: 29 | Detect and delete meta-information without corresponding files. 30 | This command is useful after manually removing files in the Trash directory. 31 | Refer below for detailed information. 32 | 33 | https://github.com/umlx5h/gtrash#what-does-the-metafix-subcommand-do`, 34 | SilenceUsage: true, 35 | Args: cobra.NoArgs, 36 | ValidArgsFunction: cobra.NoFileCompletions, 37 | RunE: func(_ *cobra.Command, _ []string) error { 38 | if err := metafixCmdRun(root.opts); err != nil { 39 | return err 40 | } 41 | if glog.ExitCode() > 0 { 42 | return errContinue 43 | } 44 | return nil 45 | }, 46 | } 47 | 48 | cmd.Flags().BoolVarP(&root.opts.force, "force", "f", false, `Always execute without confirmation prompt 49 | This is not necessary if running outside of a terminal`) 50 | 51 | root.cmd = cmd 52 | return root 53 | } 54 | 55 | func metafixCmdRun(opts metafixOptions) error { 56 | box := trash.NewBox( 57 | trash.WithSortBy(trash.SortByName), 58 | ) 59 | if err := box.Open(); err != nil { 60 | if errors.Is(err, trash.ErrNotFound) { 61 | fmt.Printf("do nothing: %s\n", err) 62 | return nil 63 | } else { 64 | return err 65 | } 66 | } 67 | 68 | if len(box.OrphanMeta) == 0 { 69 | fmt.Println("not found invalid metadata") 70 | return nil 71 | } 72 | 73 | listFiles(box.OrphanMeta, false, false) 74 | 75 | // TODO: Add functionality to allow deletion of orphaned files as well 76 | // (those for which trashinfo exists but the file does not). 77 | fmt.Printf("\nFound invalid metadata: %d\n", len(box.OrphanMeta)) 78 | 79 | if !opts.force && isTerminal && !tui.BoolPrompt("Are you sure you want to remove invalid metadata? ") { 80 | return errors.New("do nothing") 81 | } 82 | 83 | var failed int 84 | for _, f := range box.OrphanMeta { 85 | if err := os.Remove(f.TrashInfoPath); err != nil { 86 | failed++ 87 | glog.Errorf("cannot remove .trashinfo: %q: %s\n", f.TrashInfoPath, err) 88 | } 89 | } 90 | 91 | fmt.Printf("Deleted invalid metadata: %d\n", len(box.OrphanMeta)-failed) 92 | 93 | return nil 94 | } 95 | -------------------------------------------------------------------------------- /doc/configuration.md: -------------------------------------------------------------------------------- 1 | # Configration 2 | 3 | Certain behaviors can be altered by setting environment variables. 4 | 5 | ## GTRASH_HOME_TRASH_DIR 6 | 7 | - Type: string 8 | - Default: `$XDG_DATA_HOME/Trash ($HOME/.local/share/Trash)` 9 | 10 | Change the location of the main file system's trash can by specifying the full path. 11 | 12 | Example: If you prefer placing it directly under your home directory: 13 | 14 | ```bash 15 | export GTRASH_HOME_TRASH_DIR="$HOME/.gtrash" 16 | ``` 17 | 18 | ## GTRASH_ONLY_HOME_TRASH 19 | 20 | - Type: bool ('true' or 'false') 21 | - Default: `false` 22 | 23 | Enabling this option ensures the sole usage of the home directory's trash can. 24 | 25 | When files from external file systems are deleted using the `put` command, they're copied to the trash can in `$HOME`. This process might take longer due to copying and increase the main file system's disk space. 26 | 27 | By default (false), it searches for trash cans across all mount points and displays them using `find` and `restore` commands. This includes network and USB drives, potentially causing slower operation. 28 | 29 | If you encounter such issues, enabling this option can be helpful. 30 | 31 | ```bash 32 | export GTRASH_ONLY_HOME_TRASH="true" 33 | ``` 34 | 35 | ## GTRASH_HOME_TRASH_FALLBACK_COPY 36 | 37 | - Type: bool ('true' or 'false') 38 | - Default: `false` 39 | 40 | Enable this option to fallback to using the home directory's trash can when the external file system's trash can is unavailable. Enabling this option might resolve errors encountered while deleting files on an external file system using the `put` command. 41 | 42 | It can also be set using the `--home-fallback` option. 43 | 44 | ```bash 45 | $ gtrash put --home-fallback /external/file1 46 | 47 | # Equivalent to the above 48 | $ GTRASH_HOME_TRASH_FALLBACK_COPY="true" gtrash put /external/file1 49 | 50 | # To disable it when enabled in the environment variable 51 | $ GTRASH_HOME_TRASH_FALLBACK_COPY="true" gtrash put --home-fallback=false /external/file1 52 | ``` 53 | 54 | ```bash 55 | export GTRASH_HOME_TRASH_FALLBACK_COPY="true" 56 | ``` 57 | 58 | ## GTRASH_PUT_RM_MODE 59 | 60 | - Type: bool ('true' or 'false') 61 | - Default: `false` 62 | 63 | Enabling this option changes the behavior of the `put` command as closely as possible to `rm`. 64 | 65 | The `-r`, `--recursive`, `-R`, `-d` options closely resemble `rm` behavior. When set to false, these options are completely ignored. 66 | 67 | This setting can also be configured using the `--rm-mode` option. 68 | 69 | ```bash 70 | $ gtrash put --rm-mode -r dir1/ 71 | 72 | # Equivalent to the above 73 | $ GTRASH_PUT_RM_MODE="true" gtrash put -r dir/ 74 | ``` 75 | 76 | ```bash 77 | export GTRASH_PUT_RM_MODE="true" 78 | ``` 79 | -------------------------------------------------------------------------------- /internal/tui/table/table_test.go: -------------------------------------------------------------------------------- 1 | package table 2 | 3 | import "testing" 4 | 5 | // Copied from https://github.com/charmbracelet/bubbles/blob/f36aa3c4b5369f2ecefb4e35dbb2c924906932ca/table/table_test.go 6 | // https://github.com/charmbracelet/bubbles/blob/f36aa3c4b5369f2ecefb4e35dbb2c924906932ca/LICENSE 7 | 8 | // MIT License 9 | // 10 | // Copyright (c) 2020-2023 Charmbracelet, Inc 11 | // 12 | // Permission is hereby granted, free of charge, to any person obtaining a copy 13 | // of this software and associated documentation files (the "Software"), to deal 14 | // in the Software without restriction, including without limitation the rights 15 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 16 | // copies of the Software, and to permit persons to whom the Software is 17 | // furnished to do so, subject to the following conditions: 18 | // 19 | // The above copyright notice and this permission notice shall be included in all 20 | // copies or substantial portions of the Software. 21 | // 22 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 23 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 24 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 25 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 26 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 27 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 28 | // SOFTWARE. 29 | 30 | func TestFromValues(t *testing.T) { 31 | input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3" 32 | table := New(WithColumns([]Column{{Title: "Foo"}, {Title: "Bar"}})) 33 | table.FromValues(input, ",") 34 | 35 | if len(table.rows) != 3 { 36 | t.Fatalf("expect table to have 3 rows but it has %d", len(table.rows)) 37 | } 38 | 39 | expect := []Row{ 40 | {"foo1", "bar1"}, 41 | {"foo2", "bar2"}, 42 | {"foo3", "bar3"}, 43 | } 44 | if !deepEqual(table.rows, expect) { 45 | t.Fatal("table rows is not equals to the input") 46 | } 47 | } 48 | 49 | func TestFromValuesWithTabSeparator(t *testing.T) { 50 | input := "foo1.\tbar1\nfoo,bar,baz\tbar,2" 51 | table := New(WithColumns([]Column{{Title: "Foo"}, {Title: "Bar"}})) 52 | table.FromValues(input, "\t") 53 | 54 | if len(table.rows) != 2 { 55 | t.Fatalf("expect table to have 2 rows but it has %d", len(table.rows)) 56 | } 57 | 58 | expect := []Row{ 59 | {"foo1.", "bar1"}, 60 | {"foo,bar,baz", "bar,2"}, 61 | } 62 | if !deepEqual(table.rows, expect) { 63 | t.Fatal("table rows is not equals to the input") 64 | } 65 | } 66 | 67 | func deepEqual(a, b []Row) bool { 68 | if len(a) != len(b) { 69 | return false 70 | } 71 | for i, r := range a { 72 | for j, f := range r { 73 | if f != b[i][j] { 74 | return false 75 | } 76 | } 77 | } 78 | return true 79 | } 80 | -------------------------------------------------------------------------------- /internal/trash/flag.go: -------------------------------------------------------------------------------- 1 | package trash 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/pflag" 9 | "golang.org/x/exp/maps" 10 | ) 11 | 12 | func FlagCompletionFunc(allCompletions []string) func(*cobra.Command, []string, string) ( 13 | []string, cobra.ShellCompDirective, 14 | ) { 15 | return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) { 16 | var completions []string 17 | for _, completion := range allCompletions { 18 | if strings.HasPrefix(completion, toComplete) { 19 | completions = append(completions, completion) 20 | } 21 | } 22 | return completions, cobra.ShellCompDirectiveNoFileComp 23 | } 24 | } 25 | 26 | // --sort, -s 27 | 28 | var ( 29 | sortByWellKnownStrings = map[string]SortByType{ 30 | "date": SortByDeletedAt, 31 | "size": SortBySize, 32 | "name": SortByName, 33 | } 34 | 35 | SortByFlagCompletionFunc = FlagCompletionFunc( 36 | maps.Keys(sortByWellKnownStrings), 37 | ) 38 | ) 39 | 40 | func (s *SortByType) Set(str string) error { 41 | if value, ok := sortByWellKnownStrings[strings.ToLower(str)]; ok { 42 | *s = value 43 | return nil 44 | } 45 | 46 | return fmt.Errorf("must be %s", s.Type()) 47 | } 48 | 49 | func (s SortByType) String() string { 50 | switch s { 51 | case SortByDeletedAt: 52 | return "date" 53 | case SortBySize: 54 | return "size" 55 | case SortByName: 56 | return "name" 57 | default: 58 | panic("invalid SortByType value") 59 | } 60 | } 61 | 62 | func (s SortByType) Type() string { 63 | return "date|size|name" 64 | } 65 | 66 | // --mode, -m 67 | 68 | var _ pflag.Value = (*ModeByType)(nil) 69 | 70 | type ModeByType int 71 | 72 | const ( 73 | ModeByRegex ModeByType = iota 74 | ModeByGlob // default 75 | ModeByLiteral 76 | ModeByFull 77 | ) 78 | 79 | var ( 80 | modeByWellKnownStrings = map[string]ModeByType{ 81 | "regex": ModeByRegex, 82 | "glob": ModeByGlob, 83 | "literal": ModeByLiteral, 84 | "full": ModeByFull, 85 | } 86 | 87 | ModeByFlagCompletionFunc = FlagCompletionFunc( 88 | maps.Keys(modeByWellKnownStrings), 89 | ) 90 | ) 91 | 92 | func (s *ModeByType) Set(str string) error { 93 | if value, ok := modeByWellKnownStrings[strings.ToLower(str)]; ok { 94 | *s = value 95 | return nil 96 | } 97 | 98 | return fmt.Errorf("must be %s", s.Type()) 99 | } 100 | 101 | func (s ModeByType) String() string { 102 | switch s { 103 | case ModeByGlob: 104 | return "glob" 105 | case ModeByRegex: 106 | return "regex" 107 | case ModeByLiteral: 108 | return "literal" 109 | case ModeByFull: 110 | return "full" 111 | default: 112 | panic("invalid ModeByType value") 113 | } 114 | } 115 | 116 | func (s ModeByType) Type() string { 117 | return "regex|glob|literal|full" 118 | } 119 | -------------------------------------------------------------------------------- /internal/xdg/trashinfo_test.go: -------------------------------------------------------------------------------- 1 | package xdg 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestNewInfoSuccess(t *testing.T) { 14 | wantInfo := Info{ 15 | Path: "/dummy", 16 | } 17 | date, err := time.ParseInLocation(timeFormat, "2023-01-01T00:00:00", time.Local) 18 | if err != nil { 19 | panic(err) 20 | } 21 | wantInfo.DeletionDate = date 22 | 23 | t.Run("normal", func(t *testing.T) { 24 | info, err := NewInfo(strings.NewReader(`[Trash Info] 25 | Path=/dummy 26 | DeletionDate=2023-01-01T00:00:00 27 | `)) 28 | require.NoError(t, err) 29 | assert.Equal(t, wantInfo, info) 30 | }) 31 | 32 | t.Run("ignore_comment_and_blankline", func(t *testing.T) { 33 | info, err := NewInfo(strings.NewReader(`# comment 1 34 | [Trash Info] 35 | 36 | Path=/dummy 37 | 38 | # comment 2 39 | 40 | DeletionDate=2023-01-01T00:00:00 41 | `)) 42 | require.NoError(t, err) 43 | assert.Equal(t, wantInfo, info) 44 | }) 45 | 46 | t.Run("contain_space_between_key_value", func(t *testing.T) { 47 | info, err := NewInfo(strings.NewReader(`[Trash Info] 48 | DeletionDate = 2023-01-01T00:00:00 49 | Path = /dummy`)) 50 | require.NoError(t, err) 51 | assert.Equal(t, wantInfo, info) 52 | }) 53 | 54 | // xdg ref: If a string that starts with “Path=” or “DeletionDate=” occurs 55 | // several times, the first occurence is to be used. 56 | t.Run("high_priority_to_first_key_pair", func(t *testing.T) { 57 | info, err := NewInfo(strings.NewReader(`[Trash Info] 58 | Path=/dummy 59 | DeletionDate=2023-01-01T00:00:00 60 | DeletionDate=2099-01-01T00:00:00 61 | Path=/notused 62 | `)) 63 | require.NoError(t, err) 64 | assert.Equal(t, wantInfo, info) 65 | }) 66 | } 67 | 68 | func TestNewInfoError(t *testing.T) { 69 | t.Run("detect_other_group", func(t *testing.T) { 70 | _, err := NewInfo(strings.NewReader(`[Trash Info] 71 | Path=/dummy 72 | [dummy group] 73 | DeletionDate=2023-01-01T00:00:00 74 | `)) 75 | require.Error(t, err) 76 | }) 77 | } 78 | 79 | func TestQueryEscape(t *testing.T) { 80 | tests := []struct { 81 | input string 82 | want string 83 | }{ 84 | {"/foo/bar", "/foo/bar"}, 85 | {"/foo/foo bar", "/foo/foo%20bar"}, 86 | {"/foo/b a r", "/foo/b%20%20a%20%20r"}, 87 | {"/foo/あ い", "/foo/%E3%81%82%20%E3%81%84"}, 88 | {"/foo/mycool+blog&about,stuff", "/foo/mycool%2Bblog%26about%2Cstuff"}, 89 | } 90 | 91 | t.Run("escape", func(t *testing.T) { 92 | for _, tt := range tests { 93 | assert.Equal(t, tt.want, queryEscapePath(tt.input)) 94 | } 95 | }) 96 | 97 | t.Run("unescape", func(t *testing.T) { 98 | for _, tt := range tests { 99 | e, err := url.QueryUnescape(tt.want) 100 | require.NoError(t, err) 101 | assert.Equal(t, e, tt.input) 102 | } 103 | }) 104 | } 105 | -------------------------------------------------------------------------------- /itest/trash_test.go: -------------------------------------------------------------------------------- 1 | package itest 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strconv" 9 | "testing" 10 | ) 11 | 12 | var ( 13 | HOME_TRASH = "/root/.local/share/Trash" 14 | 15 | EXTERNAL_ROOT = "/external" 16 | EXTERNAL_ALT_ROOT = "/external_alt" 17 | 18 | EXTERNAL_TRASH = filepath.Join(EXTERNAL_ROOT, ".Trash", strconv.Itoa(os.Getuid())) 19 | EXTERNAL_ALT_TRASH = filepath.Join(EXTERNAL_ALT_ROOT, fmt.Sprintf(".Trash-%d", os.Getuid())) 20 | ) 21 | 22 | // remove all trash 23 | func cleanTrash(t *testing.T) { 24 | t.Helper() 25 | 26 | // clean home trash 27 | err := os.RemoveAll(HOME_TRASH) 28 | mustNoError(t, err) 29 | 30 | // clean external trash 31 | err = os.RemoveAll(EXTERNAL_TRASH) 32 | mustNoError(t, err) 33 | 34 | // clean external_alt trash 35 | err = os.RemoveAll(EXTERNAL_ALT_TRASH) 36 | mustNoError(t, err) 37 | } 38 | 39 | func TestTrashAllType(t *testing.T) { 40 | tests := []struct { 41 | name string 42 | fileDir string 43 | trashDir string 44 | }{ 45 | {name: "HOME_TRASH", fileDir: "", trashDir: HOME_TRASH}, // use /tmp 46 | {name: "EXTERNAL_TRASH", fileDir: EXTERNAL_ROOT, trashDir: EXTERNAL_TRASH}, 47 | {name: "EXTERNAL_ALT_TRASH", fileDir: EXTERNAL_ALT_ROOT, trashDir: EXTERNAL_ALT_TRASH}, 48 | } 49 | 50 | for _, tt := range tests { 51 | t.Run(tt.name, func(t *testing.T) { 52 | cleanTrash(t) 53 | 54 | f, err := os.CreateTemp(tt.fileDir, "foo") 55 | mustNoError(t, err) 56 | trashFilePath := filepath.Join(tt.trashDir, "files", filepath.Base(f.Name())) 57 | 58 | // 1. should be trashed to specific type trashDir 59 | cmd := exec.Command(execBinary, "put", f.Name()) 60 | out, err := cmd.CombinedOutput() 61 | mustNoError(t, err) 62 | assertEmpty(t, string(out)) 63 | 64 | checkFileMoved(t, f.Name(), trashFilePath) 65 | 66 | // 2. should list trashed file 67 | cmd = exec.Command(execBinary, "find") 68 | out, err = cmd.CombinedOutput() 69 | mustNoError(t, err, string(out)) 70 | assertContains(t, string(out), f.Name(), "it should list deleted file") 71 | 72 | // 3. should show summary 73 | cmd = exec.Command(execBinary, "summary") 74 | out, err = cmd.CombinedOutput() 75 | mustNoError(t, err, string(out)) 76 | assertEqual(t, string(out), fmt.Sprintf("[%s]\nitem: 1\nsize: 0 B\n", tt.trashDir)) 77 | 78 | // 4. should be restored to original path 79 | cmd = exec.Command(execBinary, "restore", f.Name()) 80 | out, err = cmd.CombinedOutput() 81 | mustNoError(t, err, string(out)) 82 | assertContains(t, string(out), "Restored 1/1 trashed files") 83 | checkFileMoved(t, trashFilePath, f.Name()) 84 | 85 | // 5. should not list restored file 86 | cmd = exec.Command(execBinary, "find") 87 | out, err = cmd.CombinedOutput() 88 | mustError(t, err, string(out)) 89 | assertContains(t, string(out), "not found: trashed files", "should not list deleted file") 90 | }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /internal/xdg/dirsizecache.go: -------------------------------------------------------------------------------- 1 | package xdg 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "net/url" 8 | "os" 9 | "path/filepath" 10 | "sort" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "golang.org/x/exp/maps" 16 | ) 17 | 18 | type DirCache map[string]*struct { 19 | Item DirCacheItem 20 | Seen bool 21 | } 22 | 23 | type DirCacheItem struct { 24 | Size int64 25 | Mtime time.Time 26 | DirName string 27 | } 28 | 29 | func NewDirCache(r io.Reader) (DirCache, error) { 30 | scan := bufio.NewScanner(r) 31 | 32 | dirCache := make(DirCache) 33 | 34 | for scan.Scan() { 35 | line := scan.Text() 36 | 37 | parseErr := fmt.Errorf("parse line: %s", line) 38 | 39 | cols := strings.SplitN(line, " ", 3) 40 | if len(cols) != 3 { 41 | return nil, parseErr 42 | } 43 | 44 | size, err := strconv.ParseInt(cols[0], 10, 64) 45 | if err != nil { 46 | return nil, parseErr 47 | } 48 | 49 | ts, err := strconv.ParseInt(cols[1], 10, 64) 50 | if err != nil { 51 | return nil, parseErr 52 | } 53 | 54 | folder, err := url.QueryUnescape(cols[2]) 55 | if err != nil { 56 | return nil, parseErr 57 | } 58 | 59 | dirCache[folder] = &struct { 60 | Item DirCacheItem 61 | Seen bool 62 | }{ 63 | Item: DirCacheItem{ 64 | Size: size, 65 | Mtime: time.Unix(ts, 0), 66 | DirName: folder, 67 | }, 68 | } 69 | } 70 | 71 | return dirCache, nil 72 | } 73 | 74 | func (i DirCacheItem) String() string { 75 | return fmt.Sprintf("%d %d %s\n", i.Size, i.Mtime.Unix(), queryEscapePath(i.DirName)) 76 | } 77 | 78 | func (c DirCache) ToFile(truncate bool) string { 79 | dirs := maps.Keys(c) 80 | sort.Slice(dirs, func(i, j int) bool { 81 | return dirs[i] < dirs[j] 82 | }) 83 | 84 | var s strings.Builder 85 | for _, d := range dirs { 86 | // remove unseen cache entry 87 | if truncate && !c[d].Seen { 88 | continue 89 | } 90 | s.WriteString(c[d].Item.String()) 91 | } 92 | 93 | return s.String() 94 | } 95 | 96 | func (c DirCache) Save(trashDir string, truncate bool) error { 97 | // xdg ref: To update the directorysizes file, implementations MUST use a temporary 98 | // file followed by an atomic rename() operation, in order to avoid 99 | // corruption due to two implementations writing to the file at the same 100 | // time. 101 | f, err := os.CreateTemp("", "directorysizes_gtrash_") 102 | if err != nil { 103 | return err 104 | } 105 | defer f.Close() 106 | defer os.Remove(f.Name()) 107 | 108 | if _, err = f.WriteString(c.ToFile(truncate)); err != nil { 109 | return err 110 | } 111 | 112 | cachePath := filepath.Join(trashDir, "directorysizes") 113 | if err := os.Rename(f.Name(), cachePath); err != nil { 114 | // External trash will definitely cause cross-device link errors. 115 | // so copied trash directory, then rename(2) 116 | tmpDstPath := filepath.Join(trashDir, filepath.Base(f.Name())) 117 | dst, err := os.Create(tmpDstPath) 118 | if err != nil { 119 | return err 120 | } 121 | defer dst.Close() 122 | defer os.Remove(tmpDstPath) 123 | 124 | // to copy from start, set offset to 0 125 | if _, err := f.Seek(0, io.SeekStart); err != nil { 126 | return err 127 | } 128 | // file copy 129 | if _, err := io.Copy(dst, f); err != nil { 130 | return err 131 | } 132 | 133 | // then rename atomically 134 | if err := os.Rename(tmpDstPath, cachePath); err != nil { 135 | return err 136 | } 137 | } 138 | 139 | return nil 140 | } 141 | -------------------------------------------------------------------------------- /internal/cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "path/filepath" 9 | "runtime/debug" 10 | "strings" 11 | 12 | "github.com/lmittmann/tint" 13 | "github.com/spf13/cobra" 14 | "github.com/umlx5h/gtrash/internal/env" 15 | "golang.org/x/term" 16 | ) 17 | 18 | var ( 19 | progName = filepath.Base(os.Args[0]) 20 | errContinue = errors.New("") 21 | 22 | isTerminal bool 23 | ) 24 | 25 | func init() { 26 | if term.IsTerminal(int(os.Stdout.Fd())) && term.IsTerminal(int(os.Stdin.Fd())) { 27 | isTerminal = true 28 | } 29 | } 30 | 31 | func Execute(version Version) { 32 | err := newRootCmd(version).cmd.Execute() 33 | if err != nil { 34 | if !errors.Is(err, errContinue) { 35 | fmt.Fprintf(os.Stderr, "%s: error: %s\n", progName, err) 36 | } 37 | os.Exit(1) 38 | } 39 | } 40 | 41 | type Version struct { 42 | Version string 43 | Commit string 44 | Date string 45 | BuiltBy string 46 | } 47 | 48 | func (v Version) Print() string { 49 | var s strings.Builder 50 | fmt.Fprintln(&s, "gtrash: Trash CLI Manager written in Go") 51 | fmt.Fprintln(&s, "https://github.com/umlx5h/gtrash") 52 | fmt.Fprintln(&s, "") 53 | fmt.Fprintln(&s, "version: "+v.Version) 54 | fmt.Fprintln(&s, "commit: "+v.Commit) 55 | fmt.Fprintln(&s, "buildDate: "+v.Date) 56 | fmt.Fprintln(&s, "builtBy: "+v.BuiltBy) 57 | 58 | return s.String() 59 | } 60 | 61 | // global options 62 | var ( 63 | isDebug bool 64 | ) 65 | 66 | type rootCmd struct { 67 | cmd *cobra.Command 68 | } 69 | 70 | func newRootCmd(version Version) *rootCmd { 71 | // if version is not set, probably go install 72 | if version.Version == "unknown" { 73 | if info, ok := debug.ReadBuildInfo(); ok { 74 | version.Version = info.Main.Version 75 | } 76 | } 77 | 78 | root := &rootCmd{} 79 | cmd := &cobra.Command{ 80 | Use: progName, 81 | SilenceErrors: true, 82 | Short: "Trash CLI manager written in Go", 83 | Long: `Trash CLI manager written in Go 84 | https://github.com/umlx5h/gtrash`, 85 | Version: version.Print(), 86 | PersistentPreRun: func(_ *cobra.Command, _ []string) { 87 | // setup debug log level 88 | lvl := &slog.LevelVar{} 89 | 90 | lvl.Set(slog.LevelWarn) 91 | if isDebug { 92 | lvl.Set(slog.LevelDebug) 93 | } 94 | // colored format 95 | logger := slog.New(tint.NewHandler(os.Stderr, &tint.Options{ 96 | Level: lvl, 97 | TimeFormat: "15:04:05.000", 98 | NoColor: !isTerminal, 99 | })) 100 | 101 | slog.SetDefault(logger) 102 | 103 | slog.Debug("gtrash version", "version", fmt.Sprintf("%+v", version)) 104 | slog.Debug("enviornment variable", 105 | "HOME_TRASH_DIR", env.HOME_TRASH_DIR, 106 | "ONLY_HOME_TRASH", env.ONLY_HOME_TRASH, 107 | ) 108 | }, 109 | } 110 | 111 | cmd.SetVersionTemplate("{{.Version}}") 112 | cmd.PersistentFlags().BoolVar(&isDebug, "debug", false, "debug mode") 113 | cmd.PersistentFlags() 114 | 115 | // disable help subcommand 116 | cmd.SetHelpCommand(&cobra.Command{ 117 | Use: "no-help", 118 | Hidden: true, 119 | }) 120 | 121 | // prefix program name 122 | cmd.SetErrPrefix(fmt.Sprintf("%s: error:", progName)) 123 | 124 | // Add subcommands 125 | cmd.AddCommand( 126 | newPutCmd().cmd, 127 | newFindCmd().cmd, 128 | newRestoreCmd().cmd, 129 | newRestoreGroupCmd().cmd, 130 | newRemoveCmd().cmd, 131 | newSummaryCmd().cmd, 132 | newMetafixCmd().cmd, 133 | newPruneCmd().cmd, 134 | ) 135 | root.cmd = cmd 136 | return root 137 | } 138 | -------------------------------------------------------------------------------- /internal/cmd/rm.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | "github.com/umlx5h/gtrash/internal/glog" 11 | "github.com/umlx5h/gtrash/internal/trash" 12 | "github.com/umlx5h/gtrash/internal/tui" 13 | ) 14 | 15 | type removeCmd struct { 16 | cmd *cobra.Command 17 | opts removeOptions 18 | } 19 | 20 | type removeOptions struct { 21 | force bool 22 | } 23 | 24 | func newRemoveCmd() *removeCmd { 25 | root := &removeCmd{} 26 | cmd := &cobra.Command{ 27 | Use: "rm PATH...", 28 | Short: "Remove trashed files PERMANENTLY in the cmd arguments", 29 | Long: `Descricption: 30 | Permanently remove the files specified as command-line arguments. 31 | Paths must be specified as full paths. 32 | 33 | This command is intended to be used alongside other commands like fzf. 34 | Generally, using 'find --rm' is recommended over this command.`, 35 | Example: ` # Permanently remove files by providing full paths.. 36 | $ gtrash rm /home/user/file1 /home/user/file2 37 | 38 | # Fuzzy find multiple items and permanently remove them. 39 | # The -o in xargs is necessary for the confirmation prompt to display. 40 | $ gtrash find | fzf --multi | awk -F'\t' '{print $2}' | xargs -o gtrash rm`, 41 | SilenceUsage: true, 42 | Args: cobra.MinimumNArgs(1), 43 | RunE: func(_ *cobra.Command, args []string) error { 44 | if err := removeCmdRun(args, root.opts); err != nil { 45 | return err 46 | } 47 | if glog.ExitCode() > 0 { 48 | return errContinue 49 | } 50 | return nil 51 | }, 52 | } 53 | 54 | cmd.Flags().BoolVarP(&root.opts.force, "force", "f", false, `Always execute without confirmation prompt 55 | This is not necessary if running outside of a terminal`) 56 | 57 | root.cmd = cmd 58 | return root 59 | } 60 | 61 | func removeCmdRun(args []string, opts removeOptions) error { 62 | box := trash.NewBox( 63 | trash.WithAscend(true), 64 | trash.WithQueries(args), 65 | trash.WithQueryMode(trash.ModeByFull), 66 | ) 67 | if err := box.Open(); err != nil { 68 | return err 69 | } 70 | 71 | listFiles(box.Files, false, false) 72 | 73 | for _, arg := range args { 74 | if box.HitByPath(arg) == 0 { 75 | glog.Errorf("cannot trash %q: not found in trashcan\n", arg) 76 | } 77 | } 78 | fmt.Printf("\nFound %d trashed files\n", len(box.Files)) 79 | 80 | if !opts.force && isTerminal && !tui.BoolPrompt("Are you sure you want to remove PERMANENTLY? ") { 81 | return errors.New("do nothing") 82 | } 83 | 84 | doRemove(box.Files) 85 | 86 | return nil 87 | } 88 | 89 | func doRemove(files []trash.File) { 90 | var failed []trash.File 91 | 92 | for _, file := range files { 93 | slog.Debug("removing a trashed file", "path", file.TrashPath) 94 | if err := os.RemoveAll(file.TrashPath); err != nil { 95 | if !errors.Is(err, os.ErrNotExist) { 96 | glog.Errorf("cannot trash %q: remove: %s\n", file.TrashPath, err) 97 | failed = append(failed, file) 98 | continue 99 | } 100 | } 101 | if err := file.Delete(); err != nil { 102 | // already read, so it is usually not reached 103 | slog.Warn("removed trashed file but cannot delete .trashinfo", "deletedFile", file.TrashPath, "trashInfoPath", file.TrashInfoPath, "error", err) 104 | } 105 | } 106 | 107 | fmt.Printf("Removed %d/%d trashed files\n", len(files)-len(failed), len(files)) 108 | if len(failed) > 0 { 109 | fmt.Printf("Following %d files could not be deleted.\n", len(failed)) 110 | listFiles(failed, false, true) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /internal/posix/file.go: -------------------------------------------------------------------------------- 1 | package posix 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "io/fs" 9 | "os" 10 | 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/umlx5h/go-runewidth" 13 | ) 14 | 15 | func IsBinary(content io.ReadSeeker, fileSize int64) (bool, error) { 16 | headSize := min(fileSize, 1024) 17 | head := make([]byte, headSize) 18 | if _, err := content.Read(head); err != nil { 19 | return false, err 20 | } 21 | if _, err := content.Seek(0, io.SeekStart); err != nil { 22 | return false, err 23 | } 24 | 25 | // ref: https://github.com/file/file/blob/5e33fd6ee7766d40382a084c8e7554c2d43c0b7e/src/encoding.c#L183-L260 26 | for _, b := range head { 27 | if b < 7 || b == 11 || (13 < b && b < 27) || (27 < b && b < 0x20) || b == 0x7f { 28 | return true, nil 29 | } 30 | } 31 | 32 | return false, nil 33 | } 34 | 35 | func FileHead(path string, width int, maxLines int) string { 36 | fi, err := os.Lstat(path) 37 | if err != nil { 38 | if errors.Is(err, os.ErrNotExist) { 39 | return "(error: not found)" 40 | } else { 41 | return "(error: could not stat)" 42 | } 43 | } 44 | content := func(isDir bool, lines []string) string { 45 | if len(lines) == 0 { 46 | if isDir { 47 | return "(empty directory)\n" 48 | } else { 49 | return "(empty file)\n" 50 | } 51 | } 52 | var content string 53 | var i int 54 | for _, line := range lines { 55 | i++ 56 | content += fmt.Sprintf(" %s\n", line) 57 | } 58 | if isDir { 59 | return "(directory)" + "\n" + content 60 | } else { 61 | return "(text)" + "\n" + content 62 | } 63 | } 64 | 65 | var lines []string 66 | var isDir bool 67 | switch { 68 | case fi.Mode().Type() == fs.ModeSymlink: 69 | return "(symbolic link)" 70 | case fi.IsDir(): 71 | isDir = true 72 | dirs, _ := os.ReadDir(path) 73 | for i, dir := range dirs { 74 | if i == maxLines { 75 | break 76 | } 77 | dinfo, err := dir.Info() 78 | if err != nil { 79 | return "(error: open directory)" 80 | } 81 | name := runewidth.Truncate(dir.Name(), width-15, "…") 82 | if dir.IsDir() { 83 | // folder is blue color 84 | name = lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Render(name) 85 | } 86 | l := fmt.Sprintf("%s %s", dinfo.Mode().Perm().String(), name) 87 | lines = append(lines, l) 88 | } 89 | case fi.Mode().IsRegular(): 90 | f, err := os.Open(path) 91 | if err != nil { 92 | return "(error: open file)" 93 | } 94 | defer f.Close() 95 | 96 | if binary, err := IsBinary(f, fi.Size()); err != nil { 97 | return "(error: read file)" 98 | } else if binary { 99 | return "(binary file)" 100 | } 101 | 102 | // if file is text, read maxLines lines 103 | s := bufio.NewScanner(f) 104 | var n int 105 | for s.Scan() { 106 | if n == maxLines { 107 | break 108 | } 109 | t := s.Text() 110 | // truncate to screen width 111 | lines = append(lines, runewidth.Truncate(t, width-3, "…")) 112 | n++ 113 | } 114 | default: 115 | return "(unknown file type)" 116 | } 117 | return content(isDir, lines) 118 | } 119 | 120 | func FileType(st fs.FileInfo) string { 121 | if st.IsDir() { 122 | return "directory" 123 | } else if st.Mode().IsRegular() { 124 | if st.Size() == 0 { 125 | return "regular empty file" 126 | } else { 127 | return "regular file" 128 | } 129 | } 130 | 131 | switch st.Mode().Type() { 132 | case fs.ModeSymlink: 133 | return "symbolic link" 134 | case fs.ModeNamedPipe: 135 | return "fifo" 136 | case fs.ModeSocket: 137 | return "socket" 138 | } 139 | 140 | return "unknown type file" 141 | } 142 | -------------------------------------------------------------------------------- /internal/xdg/trashinfo.go: -------------------------------------------------------------------------------- 1 | package xdg 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "io/fs" 9 | "net/url" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | const ( 17 | trashHeader = `[Trash Info]` 18 | timeFormat = "2006-01-02T15:04:05" 19 | ) 20 | 21 | // XDG specifications 22 | // https://specifications.freedesktop.org/trash-spec/latest/ 23 | // https://specifications.freedesktop.org/desktop-entry-spec/latest/basic-format.html 24 | 25 | type Info struct { 26 | Path string // $PWD/file.go (url decoded) 27 | DeletionDate time.Time // 2023-01-01T00:00:00 28 | } 29 | 30 | func NewInfo(r io.Reader) (Info, error) { 31 | scanner := bufio.NewScanner(r) 32 | 33 | var info Info 34 | 35 | var ( 36 | groupFound bool 37 | pathFound bool 38 | dateFound bool 39 | ) 40 | for scanner.Scan() { 41 | line := scanner.Text() 42 | 43 | if line == trashHeader { 44 | groupFound = true 45 | continue 46 | } 47 | if len(line) > 0 && line[0] == '[' { 48 | // other group found, so exit 49 | break 50 | } 51 | if strings.Contains(line, "=") { 52 | kv := strings.SplitN(line, "=", 2) 53 | 54 | switch strings.TrimSpace(kv[0]) { 55 | case "Path": 56 | if pathFound { 57 | continue 58 | } 59 | u, err := url.QueryUnescape(strings.TrimSpace(kv[1])) 60 | if err != nil { 61 | break 62 | } 63 | info.Path = u 64 | pathFound = true 65 | case "DeletionDate": 66 | if dateFound { 67 | continue 68 | } 69 | parsed, err := time.ParseInLocation(timeFormat, strings.TrimSpace(kv[1]), time.Local) 70 | if err != nil { 71 | break 72 | } 73 | info.DeletionDate = parsed 74 | dateFound = true 75 | } 76 | } 77 | } 78 | 79 | if scanner.Err() != nil { 80 | return Info{}, scanner.Err() 81 | } 82 | 83 | if !groupFound || !pathFound || !dateFound { 84 | return Info{}, errors.New("unable to parse trashinfo") 85 | } 86 | 87 | return info, nil 88 | } 89 | 90 | // represent INI format 91 | func (i Info) String() string { 92 | return fmt.Sprintf("%s\nPath=%s\nDeletionDate=%s\n", trashHeader, queryEscapePath(i.Path), i.DeletionDate.Format(timeFormat)) 93 | } 94 | 95 | func (i Info) Save(trashDir TrashDir, filename string) (saveName string, deleteFn func() error, err error) { 96 | revision := 1 97 | 98 | var trashinfoFile *os.File 99 | saveName = filename 100 | 101 | for { 102 | if revision > 1 { 103 | saveName = fmt.Sprintf("%s_%d", filename, revision) 104 | } 105 | 106 | // Considering files for which there is no associated trashinfo, check for duplicates under the files directory 107 | // Since the trashed file may be overwritten by subsequent rename(2) 108 | if _, err := os.Lstat(filepath.Join(trashDir.FilesDir(), saveName)); err == nil { 109 | revision++ 110 | continue 111 | } 112 | 113 | // create .trashinfo file atomically using O_EXCL 114 | f, err := os.OpenFile(filepath.Join(trashDir.InfoDir(), saveName+".trashinfo"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) 115 | if err != nil { 116 | // conflict detected, so change to another name 117 | if errors.Is(err, fs.ErrExist) { 118 | revision++ 119 | continue 120 | } else { 121 | return "", nil, fmt.Errorf("open failed: %w", err) 122 | } 123 | } 124 | defer f.Close() 125 | 126 | trashinfoFile = f 127 | break 128 | } 129 | 130 | // Have this called when the file fails to move. 131 | deleteFn = func() error { 132 | return os.Remove(trashinfoFile.Name()) 133 | } 134 | 135 | if _, err := trashinfoFile.WriteString(i.String()); err != nil { 136 | _ = deleteFn() 137 | return "", nil, fmt.Errorf("write failed: %w", err) 138 | } 139 | 140 | return saveName, deleteFn, nil 141 | } 142 | 143 | // Do not escape '/' 144 | // Escape ' ' as '%20', not '+' 145 | func queryEscapePath(s string) string { 146 | // do not escape '/' 147 | a := strings.Split(s, "/") 148 | for i := 0; i < len(a); i++ { 149 | // escape ' ' as %20 instead of '+' 150 | b := strings.Split(a[i], " ") 151 | for j := 0; j < len(b); j++ { 152 | b[j] = url.QueryEscape(b[j]) 153 | } 154 | a[i] = strings.Join(b, "%20") 155 | } 156 | return strings.Join(a, "/") 157 | } 158 | -------------------------------------------------------------------------------- /doc/alternatives.md: -------------------------------------------------------------------------------- 1 | # Alternatives 2 | 3 | | | gtrash | [trash-cli](https://github.com/andreafrancia/trash-cli) | [trashy](https://github.com/oberblastmeister/trashy) | [trash-d](https://github.com/rushsteve1/trash-d) | 4 | | ------------------------------------------------------------ | --------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------ | 5 | | Language | Go | Python | Rust | D | 6 | | Supported OS | Linux,Mac | Linux,Mac | Linux,Windows | Linux,Mac | 7 | | Architecture | Single binary & Multi subcommands | Multi commands | Single binary & Multi subcommands | Single binary | 8 | | Has rm-like interface | ✔️ | ✔️ | ❌ | ✔️ | 9 | | Restore with TUI (incremental search & multi select items) | ✔️ | ❌ | ❌ | ❌ | 10 | | Restore as a group | ✔️ | ❌ | ❌ | ❌ | 11 | | Can show file and directory size | ✔️ | ❌ | ❌ | ❌ | 12 | | Can show summary of trash cans (total items, size) | ✔️ | ❌ | ❌ | ❌ | 13 | | Support FreeDesktop.org directorysize cache | ✔️ | ❌ | ❌ | ✔ (only put, can not list) | 14 | | Support FreeDesktop.org fallback to home trash | ✔️ | ✔️ | ❌ | Not support external filesystem trash can | 15 | | Size-based pruning | ✔️ | ❌ | ❌ | ❌ | 16 | | Date-based pruning | ✔️ | ✔️ | ✔️ | ❌ | 17 | | Safe (Always show a confirmation prompt before deleting files by default?) | ✔️ | ❌ | ✔️ | ❌ | 18 | | Sort trashed items by deletion date by default? | ✔️ | ❌ | ✔️ | ❌ | 19 | 20 | 21 | If you think that some entries in this table are outdated or wrong, please open a issue or pull request. 22 | -------------------------------------------------------------------------------- /doc/image/demo.tape: -------------------------------------------------------------------------------- 1 | # VHS documentation 2 | # 3 | # Output: 4 | # Output .gif Create a GIF output at the given 5 | # Output .mp4 Create an MP4 output at the given 6 | # Output .webm Create a WebM output at the given 7 | # 8 | # Require: 9 | # Require Ensure a program is on the $PATH to proceed 10 | # 11 | # Settings: 12 | # Set FontSize Set the font size of the terminal 13 | # Set FontFamily Set the font family of the terminal 14 | # Set Height Set the height of the terminal 15 | # Set Width Set the width of the terminal 16 | # Set LetterSpacing Set the font letter spacing (tracking) 17 | # Set LineHeight Set the font line height 18 | # Set LoopOffset % Set the starting frame offset for the GIF loop 19 | # Set Theme Set the theme of the terminal 20 | # Set Padding Set the padding of the terminal 21 | # Set Framerate Set the framerate of the recording 22 | # Set PlaybackSpeed Set the playback speed of the recording 23 | # Set MarginFill Set the file or color the margin will be filled with. 24 | # Set Margin Set the size of the margin. Has no effect if MarginFill isn't set. 25 | # Set BorderRadius Set terminal border radius, in pixels. 26 | # Set WindowBar Set window bar type. (one of: Rings, RingsRight, Colorful, ColorfulRight) 27 | # Set WindowBarSize Set window bar size, in pixels. Default is 40. 28 | # Set TypingSpeed