Showing preview only (208K chars total). Download the full file or copy to clipboard to get everything.
Repository: umlx5h/gtrash
Branch: main
Commit: a7418360f894
Files: 53
Total size: 194.5 KB
Directory structure:
gitextract_t5n8s8k6/
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ └── workflows/
│ ├── golangci-lint.yml
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── .goreleaser.yaml
├── LICENSE
├── Makefile
├── README.md
├── doc/
│ ├── alternatives.md
│ ├── configuration.md
│ └── image/
│ └── demo.tape
├── docker-compose.yaml
├── go.mod
├── go.sum
├── internal/
│ ├── cmd/
│ │ ├── find.go
│ │ ├── metafix.go
│ │ ├── prune.go
│ │ ├── prune_test.go
│ │ ├── put.go
│ │ ├── restore.go
│ │ ├── restoreGroup.go
│ │ ├── rm.go
│ │ ├── root.go
│ │ └── summary.go
│ ├── env/
│ │ └── env.go
│ ├── glog/
│ │ └── logger.go
│ ├── posix/
│ │ ├── dir.go
│ │ ├── file.go
│ │ ├── path.go
│ │ └── path_test.go
│ ├── trash/
│ │ ├── flag.go
│ │ └── trash.go
│ ├── tui/
│ │ ├── boolInputModel.go
│ │ ├── choiceInputModel.go
│ │ ├── multiRestore.go
│ │ ├── singleRestore.go
│ │ ├── table/
│ │ │ ├── table.go
│ │ │ └── table_test.go
│ │ └── tui.go
│ └── xdg/
│ ├── dirsizecache.go
│ ├── dirsizecache_test.go
│ ├── path.go
│ ├── trashdir.go
│ ├── trashdir_test.go
│ ├── trashinfo.go
│ └── trashinfo_test.go
├── itest/
│ ├── cli_test.go
│ ├── put_test.go
│ ├── setup.sh
│ └── trash_test.go
└── main.go
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
Please run the buggy command with `--debug` option and write down the results.
```
$ gtrash find --debug
```
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Version (please complete the following information):**
- OS: [e.g. Linux, Mac]
- Version [e.g. 0.0.1]
The version can be checked with `gtrash --version`
```
$ gtrash --version
```
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/workflows/golangci-lint.yml
================================================
# ref: https://github.com/golangci/golangci-lint-action#how-to-use
name: golangci-lint
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.22'
cache: false
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: v1.54
================================================
FILE: .github/workflows/release.yml
================================================
name: release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.22'
- name: Test
run: make test-all
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# needed by homebrew
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
# AUR
AUR_KEY: ${{ secrets.AUR_KEY }}
================================================
FILE: .github/workflows/test.yml
================================================
name: test
on:
pull_request:
branches: [ "main" ]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.22'
- name: Test
run: make test-all
================================================
FILE: .gitignore
================================================
gtrash
dist/
__debug_bin*
coverage
coverage.html
coverage.txt
================================================
FILE: .goreleaser.yaml
================================================
version: 1
before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- darwin
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser
flags:
- -trimpath
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# Only include binary in archive
files:
- none*
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
brews:
- repository:
owner: umlx5h
name: homebrew-tap
token: "{{ .Env.TAP_GITHUB_TOKEN }}"
homepage: "https://github.com/umlx5h/gtrash"
description: "A Trash CLI manager written in Go"
license: "MIT"
aurs:
-
name: gtrash-bin
homepage: "https://github.com/umlx5h/gtrash"
description: "A Trash CLI manager written in Go"
license: "MIT"
private_key: '{{ .Env.AUR_KEY }}'
git_url: 'ssh://aur@aur.archlinux.org/gtrash-bin.git'
package: |-
# bin
install -Dm755 "./gtrash" "${pkgdir}/usr/bin/gtrash"
# completions
mkdir -p "${pkgdir}/usr/share/bash-completion/completions/"
mkdir -p "${pkgdir}/usr/share/zsh/site-functions/"
mkdir -p "${pkgdir}/usr/share/fish/vendor_completions.d/"
./gtrash completion bash | install -Dm644 /dev/stdin "${pkgdir}/usr/share/bash-completion/completions/gtrash"
./gtrash completion zsh | install -Dm644 /dev/stdin "${pkgdir}/usr/share/zsh/site-functions/_gtrash"
./gtrash completion fish | install -Dm644 /dev/stdin "${pkgdir}/usr/share/fish/vendor_completions.d/gtrash.fish"
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2024 umlx5h
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: Makefile
================================================
.PHONY: clean test itest lint
build:
go build
clean:
rm -f gtrash
rm -rf coverage itest/coverage
rm -f coverage.txt coverage.html
test-all: clean test itest report-coverage
lint:
golangci-lint run
test:
mkdir -p coverage
go test -cover -v ./internal/... -args -test.gocoverdir="$$PWD/coverage"
itest:
mkdir -p itest/coverage
go build -cover
docker compose run itest
report-coverage:
go tool covdata percent -i=./coverage,./itest/coverage
go tool covdata textfmt -i=./coverage,./itest/coverage -o coverage.txt
go tool cover -html=coverage.txt -o coverage.html
================================================
FILE: README.md
================================================
# gtrash

`gtrash` is a trash CLI manager that fully complies with the [FreeDesktop.org specification](https://specifications.freedesktop.org/trash-spec/latest/).
Unlike `rm`, `gtrash` moves files to the system trash can, enabling easy restoration of important files at any time.
If you usually use `rm` in the shell, `gtrash` can serve as a substitute.
This tool utilizes the system trash can on Linux, enabling seamless integration with other CLI and desktop applications.
Additionally, `gtrash` features a modern TUI interface, making it very intuitive to restore any file.
The interface is made with an awesome [bubbletea](https://github.com/charmbracelet/bubbletea) TUI framework.
## Table of Contents
- [Features](#features)
- [Supported OS](#supported-os)
- [Installation](#installation)
- [Usage](#usage)
- [How it works](#how-it-works)
- [FAQ](#faq)
- [Tips](#tips)
- [Configuration](#configuration)
- [Related projects](#related-projects)
## Features
- Intuitive TUI interface for file restoration
- Allows incremental search for trashed files, enabling the restoration of multiple files simultaneously.
- Full compliance with FreeDesktop.org specification
- Supports directory size caching, enabling fast size-based filtering and pruning.
- Close compatibility with rm interface
- Has rm-like mode, You can customize -r, -d behavior
- Multi subcommands design in a single static binary written in Go
- Restoration of co-deleted files together
- Easy integration with other CLI tools, such as fzf
- Safe and Ergonomic
- Ensures safety by displaying a list and confirmation prompt whenever a file is permanently deleted.
## Supported OS
### Linux
Supported
### Mac
Supported
but Mac's system trash can is not used
### Windows
Not supported
It works perfectly on WSL2 because it is real Linux
## Installation
### From binaries
Download the binary from [GitHub Releases](https://github.com/umlx5h/gtrash/releases/latest) and place it in your `$PATH`.
Install the latest binary to `/usr/local/bin`:
```bash
curl -L "https://github.com/umlx5h/gtrash/releases/latest/download/gtrash_$(uname -s)_$(uname -m).tar.gz" | tar xz
chmod a+x ./gtrash
sudo mv ./gtrash /usr/local/bin/gtrash
```
### AUR (Arch User Repository)
with any AUR helpers
```
yay -S gtrash-bin
paru -S gtrash-bin
```
### Nixpkgs (NixOS)
```
nix-env -iA nixpkgs.gtrash
```
### Homebrew (macOS)
```
brew install umlx5h/tap/gtrash
```
### Go install
```
go install github.com/umlx5h/gtrash@latest
```
### Build from source
```bash
git clone https://github.com/umlx5h/gtrash.git --depth 1
cd gtrash
go build
./gtrash
sudo cp ./gtrash /usr/local/bin/gtrash
```
## Usage
To trash a file, use the `put` subcommand.
Deleting a directory doesn't require the `-r` option by default.
(This behavior can be adjusted using --rm-mode.)
```bash
$ cd && mkdir dir && touch file1 file2
$ gtrash put dir file1 file2
```
The `summary` subcommand provides information about the trash can, displaying item count and total size.
There is a path name, the file has been moved to this path.
```
$ gtrash summary
[/home/user/.local/share/Trash]
item: 3
size: 4.1 kB
```
The `find` subcommand lists the files in the trash.
The `Path` field shows the original file location, not the one in the trash.
```bash
# gtrash f is also acceptable
$ gtrash find
Date Path
2024-01-01 00:00:00 /home/user/dir
2024-01-01 00:00:00 /home/user/file1
2024-01-01 00:00:00 /home/user/file2
```
String queries can be passed as command line arguments for searching files in the trash, using regular expressions by default.
```bash
$ gtrash find file1 dir
Date Path
2024-01-01 00:00:00 /home/user/dir
2024-01-01 00:00:00 /home/user/file1
```
There are several ways to restore a file.
To restore with an interactive TUI, use the `restore` subcommand.
```bash
# gtrash r is also acceptable
$ gtrash restore
```

Within `restore`, multiple files can be selected for restoration.
The table on the left is the list of files in the trash and the table on the right is the list to be restored.
Press `?` for detailed operations.
Navigate using `j`, `k`, or the cursor keys.
Use `l` or `right arrow key` or `Space` to move files to the right table.
Vim key bindings are used.
Incremental searches can be performed with `/`.
Press `Enter` after selecting files to restore.
A list of selected files and a confirmation prompt will appear. Confirm restoration by pressing `y`.
```bash
$ gtrash restore
Date Path
2024-01-01 00:00:00 /home/user/dir
2024-01-01 00:00:00 /home/user/file1
Selected 2 trashed files
Are you sure you want to restore? yes/no
```
There is another type of restoration with TUI.
To restore all the deleted files together in one `put` command, use the `restore-group` subcommand.
```bash
# gtrash rg is also acceptable
$ gtrash restore-group
```
In above example, `dir1`, `file1`, and `file2` can be restored together.
This is useful when many files were deleted together but you want to restore them at once.
For non-TUI restoration, use the `--restore` option with `find`.
```bash
$ gtrash find file1 dir --restore
Date Path
2024-01-01 00:00:00 /home/user/dir
2024-01-01 00:00:00 /home/user/file1
Found 2 trashed files
Are you sure you want to restore? yes/no
```
To permanently delete files in the trash, use the `--rm` option with `find`.
Be aware that this action is irreversible, akin to rm, and the files cannot be restored.
```
# Delete specific files
$ gtrash find file1 --rm
# Delete all files
$ gtrash find --rm
```
Help can be viewed with the `-h` option.
Examples are provided for each subcommand.
```
$ gtrash -h
$ gtrash put -h
```
## How it works
`gtrash` adheres to the [FreeDesktop.org specification](https://specifications.freedesktop.org/trash-spec/latest/).
Its primary function is akin to `mv`, but it extends functionality by recording meta-information and automatically transferring files to the trash can in the external file system.
Files within the main file system are moved to the following paths in the home directory.
```bash
# Standard
$HOME/.local/share/Trash
# If $XDG_DATA_HOME is set
$XDG_DATA_HOME/Trash
```
The files are moved to the `files` directory, while metadata is stored in the `info` directory.
```bash
$ gtrash put file1
# Records meta information
$ cat ~/.local/share/Trash/info/file1.trashinfo
[Trash Info]
Path=/home/user/file1
DeletionDate=2024-01-01T00:00:00
# Actual file
$ ls ~/.local/share/Trash/files/file1
/home/user/.local/share/Trash/files/file1
```
Files within an external file system will be moved to either of the subsequent paths.
```bash
# If a .Trash folder already exists, it will be used.
# ($uid folder is created automatically)
# The .Trash directory requires a sticky bit set (can be added using chmod +t)
$MOUNTPOINT/.Trash/$uid
# Used when the above directory is unavailable (typically used)
$MOUNTPOINT/.Trash-$uid
```
To use the first directory, create a `.Trash` directory in advance:
```bash
# You can check with the df command
$ cd $MOUNTPOINT
$ mkdir .Trash
$ chmod a+rw .Trash
# Set the sticky bit
$ chmod +t .Trash
```
`$MOUNTPOINT` is the same as the information displayed in the `df` command.
```
# Mounted on
$ df foo
Filesystem Size Used Avail Use% Mounted on
/dev/sda 54G 48G 3.6G 93% /
```
The `mv` command copies and deletes files when moving across file systems.
This process consumes more time and increases disk usage on the destination file system.
The inability to use the [rename(2)](https://man7.org/linux/man-pages/man2/rename.2.html) syscall across different file systems necessitates this behavior.
For this reason, `gtrash` attempts to move files to the trash can within the same file system whenever possible.
You can also configure it to always use the trash can in the `$HOME` directory.
Refer to the [Configuration](doc/configuration.md) for further details.
The `summary` subcommand lists paths to all trash cans:
```bash
$ gtrash summary
```
Using the `--show-trashpath` option with the `find` command displays the real path for each trashed file.
```bash
$ gtrash find --show-trashpath
```
For detailed behavior insights, run the command with the `--debug` option to view internal processes.
## FAQ
### What's the difference between this command and the rm command?
While `rm` uses the [unlink](https://man7.org/linux/man-pages/man2/unlink.2.html) syscall, rendering file deletion irreversible, `gtrash` moves files using the [rename](https://man7.org/linux/man-pages/man2/rename.2.html) syscall, enabling restoration.
`gtrash` aims to mirror the `rm` interface but ignores `-r`, `-R`, `--recursive`, and `-d` by default.
```bash
$ gtrash put -h
Flags:
-d, --dir ignored unless --rm-mode set
-f, --force ignore nonexistent files and arguments
-i, --interactive prompt before every removal
-I, --interactive-once prompt once before trashing
-r, -R, --recursive ignored unless --rm-mode set
--rm-mode enable rm-like mode (change behavior -r, -R, -d)
-v, --verbose explain what is being done
```
The `-r` option is not necessary for deleting folders since files are restorable even if mistakenly removed.
However, some users may prefer the `rm` behavior. In such cases, enable the above option with `--rm-mode`.
(Although it is not completely compatible.)
```bash
# To delete a folder, -r or -d is required.
$ gtrash put --rm-mode dir1/
gtrash: cannot trash "dir1/": Is a directory
$ gtrash put --rm-mode -r dir1/
```
This behavior can be set using an environment variable or an alias, whichever suits your preference.
```
# Same as --rm-mode
$ GTRASH_PUT_RM_MODE="true" gtrash put -r dir/
# Alias is also possible
$ alias gtrash-put="gtrash put --rm-mode"
```
### What are the advantages of using a system trash can?
`gtrash` offers several benefits over similar applications like [rip](https://github.com/nivekuil/rip) that don't utilize the Linux system trash can.
* Seamless integration with CLI and desktop applications following the FreeDesktop specification.
* Support for various trash cans, even on different file systems, enabling fast file movement between them.
* Compatibility with standard specifications ensures smoother migration to alternative applications adhering to the same standards.
* Unique specifications become a problem when they are no longer maintained.
### Can I alias `rm=gtrash put`?
You can but I do not recommend due to potential risks, unintentionally executing actual `rm` commands, such as `sudo rm` or on SSH servers.
As `gtrash` isn't fully compatible with `rm`, it's prudent to establish different aliases to avoid confusion and prevent accidental deletion of files.
Consider setting up alternative short aliases, such as:
```bash
alias gp='gtrash put' # gtrash put
alias gm='gtrash put' # gtrash move (easy to change to rm)
alias tp='gtrash put' # trash put
alias tm='gtrash put' # trash move (easy to change to rm)
alias tt='gtrash put' # to trash
```
If you are in the habit of using rm, consider creating an alias that displays a cautionary message.
```bash
alias rm="echo -e 'If you want to use rm really, then use \"\\\\rm\" instead.'; false"
```
When you want to execute the actual rm, use `\rm` to bypass the alias.
```bash
$ rm foo
If you want to use rm really, then use "\rm" instead.
$ \rm foo
```
### Can I run `trash put` in one command without using alias?
In certain situations, supporting trash cans within programs might necessitate working with a trash CLI without the ability to specify a subcommand like `gtrash put`.
In such cases, consider a simple wrapper script.
```bash
# Locate gtrash binary path
$ which gtrash
/usr/local/bin/gtrash
$ sudo vim /usr/local/bin/gtrash-put
#!/bin/sh
# Specify gtrash binary path
exec /usr/local/bin/gtrash put "$@"
$ sudo chmod a+x /usr/local/bin/gtrash-put
```
With this setup, execute a single command.
```bash
$ gtrash-put somefile
```
This wrapper facilitates direct execution without needing an alias.
### Is gtrash compatible with the gio trash and trash-put commands?
It uses the exact same trash FreeDesktop specification as `gio trash` and `trash-cli`, so they are compatible.
If some program depends on these clis and cannot be changed, there is no need to change to `gtrash put`.
### Typing `gtrash` takes too long
Set up an different alias from put.
Example
```bash
alias gp="gtrash put"
alias g="gtrash"
```
Or you could setup a symbolic link.
```bash
sudo ln -s /usr/local/bin/gtrash /usr/local/bin/g
```
Note that gtrash works perfectly well with any binary name.
If you don't like the name "gtrash", you can change it to whatever name you like.
```
sudo mv /usr/local/bin/gtrash /usr/local/bin/rip
```
However, be aware that if you are installing via a package manager, you may not be able to update it.
### What happens when I run it with sudo?
Files are moved to the Trash under the `root` user's home directory.
```
$ sudo bash -c 'echo $HOME'
/root
```
The `-v` option displays the location where the file was moved.
```
$ sudo gtrash put -v file1
trashed "file1" to /root/.local/share/Trash
```
The `summary` subcommand can also reveal the Trash can location.
```
$ sudo gtrash summary
[/root/.local/share/Trash]
item: 10
size: 62 kB
```
To restore or delete a file deleted with sudo, you need to use sudo.
### How does the `restore-group` subcommand work?
The `restore-group` subcommand can restore multiple deleted files simultaneously with one command.
```bash
$ gtrash put file1 file2 dir
# You can restore file1, file2, dir together
$ gtrash restore-group
```
However, it's not an exact grouping of files deleted at the same time.
Files with the same deletion timestamp recorded in seconds are simply grouped together.
When files are trashed using `gtrash put`, they are designed to have the same timestamp, allowing reliable grouping.
But this isn't guaranteed if trashed via other apps.
Note that multiple `gtrash put` commands executed within one second are also grouped together.
In an interactive shell executing `gtrash put`, timestamps rarely match within seconds. However, caution is needed when running it via a shell script.
In such cases, use the `restore` subcommand to select specific files.
### What does the `metafix` subcommand do?
`trash put` command records meta-information in the `info` folder in the Trash directory and moves files to the `files` directory.
```bash
$ gtrash put file1
# Records meta information
$ cat ~/.local/share/Trash/info/file1.trashinfo
[Trash Info]
Path=/home/user/file1
DeletionDate=2024-01-01T00:00:00
# Actual file
$ ls ~/.local/share/Trash/files/file1
/home/user/.local/share/Trash/files/file1
```
For instance, if you manually delete files from the `files` directory, the trash will become inconsistent.
```bash
# Deletes the file only, not the meta info
$ rm ~/.local/share/Trash/files/file1
```
In such cases, `find` and `restore` commands won't display inconsistent orphaned meta-information.
`metafix` can detect this condition and remove unnecessary meta-information.
```bash
$ gtrash metafix
Date Path
2024-01-01 00:00:00 /home/user/file1
Found invalid metadata: 1
Are you sure you want to remove invalid metadata? yes/no
```
The following trashinfo file will be deleted instead of the file.
```bash
$ ls ~/.local/share/Trash/info/file1.trashinfo
ls: cannot access '/home/user/.local/share/Trash/info/file1.trashinfo': No such file or directory
```
### The display in the TUI is corrupted
It seems that the table in TUI may be corrupted on certain terminals.
The display of the library used itself may be corrupted and may not be able to be fixed.
In that case, I recommend migrating the terminal.
Terminal confirmed that it cannot be fixed
* KDE Konsole
Terminal confirmed to work
* Wezterm
* Alacritty
* Kitty
* GNOME Terminal
* Xfce Terminal
* Windows Terminal
* Mac Terminal
* Mac iTerm2
If you find a problem, please open an ticket.
## Tips
### Shell Integration
`gtrash` supports `bash`, `zsh`, `fish` shell integration.
See `--help` for further details.
```bash
gtrash completion bash --help
gtrash completion zsh --help
gtrash completion fish --help
```
### Filtering by the current working directory or specific directory
By default, `find` and `restore` display all files, not limited to the current directory.
This differs from other applications.
You can filter using the `-c` option (`--cwd`).
```bash
# --cwd is also acceptable
$ gtrash find -c
$ gtrash restore -c
```
The `restore` subcommand also supports filtering by the current directory in the TUI.
Avoid using `-c`, directly access the TUI, and press the `c` key to toggle filtering.
```bash
$ gtrash restore
# Press the c key
```
The `-d` or `--directory` option allows filtering in directories other than the current one.
```bash
# Specify an absolute path
$ gtrash find -d /tmp
# relative path is also supported
$ gtrash find -d ./foo
# Same as -c
$ gtrash find -d .
```
### Fuzzy find
Fuzzy find isn't currently implemented due to complexity.
However, `gtrash` is designed to work seamlessly with other commands like fzf.
The find subcommand outputs a tab-delimited table if it detects pipe or file output other than a terminal.
This enables easy field extraction using tools like awk.
Example with fzf:
```bash
# Fuzzy find one item and get the original path
# Specify -F'\t' due to tab-delimited output
$ gtrash find | fzf | awk -F'\t' '{print $2}'
# Fuzzy find multiple items and get the original path
$ gtrash find | fzf --multi | awk -F'\t' '{print $2}'
```
For permanent removal or restoration, specify the original path as a command-line argument in the `rm` or `restore` subcommand.
Note that the `-o` option must be specified when using `xargs` to display the confirmation prompt.
```bash
# Fuzzy find multiple items and permanently remove them
$ gtrash find | fzf --multi | awk -F'\t' '{print $2}' | xargs -o gtrash rm
# Fuzzy find multiple items and restore them
$ gtrash find | fzf --multi | awk -F'\t' '{print $2}' | xargs -o gtrash restore
```
### Pruning the trash can by size and date criteria
Date-based:
Currently possible only by day.
```bash
# Remove files deleted over a week ago
$ gtrash prune --day 7
# Almost the same as prune
$ gtrash find --day-old 7 --rm
```
Size-based:
There are two methods.
`find` filters by the specified size and removes them.
```bash
# Remove trashed files larger than 10MB
$ gtrash find --size-large 10mb --rm
# '10m' is also acceptable
$ gtrash find --size-large 10m --rm
# Remove trashed files larger than 1GB
$ gtrash find --size-large 1gb --rm
# Remove empty trashed files
$ gtrash find --size-small 0 --rm
```
`prune` removes large files first so that the overall trash size is smaller than the specified size:
```
# After this, the size of the trash can is guaranteed to be less than 5 GB.
$ gtrash prune --size 5GB
# If you want to exclude recently deleted files, you can also specify day.
$ gtrash prune --size 5GB --day 7
```
Sizes and dates can be combined in `find`, and other filters can be applied:
```bash
# Remove files older than a week and larger than 10MB
$ gtrash find --day-old 7 --size-large 10mb --rm
# Remove files older than a week, larger than 10MB, and containing 'foo' in the path
$ gtrash find --day-old 7 --size-large 10mb --rm foo
```
## Configuration
Certain behaviors can be altered by setting environment variables.
Refer to the [Configuration](doc/configuration.md).
## Related projects
### Using system trash can
* [andreafrancia/trash-cli](https://github.com/andreafrancia/trash-cli)
* [oberblastmeister/trashy](https://github.com/oberblastmeister/trashy)
* [rushsteve1/trash-d](https://github.com/rushsteve1/trash-d)
For a comparison, You can see [alternatives.md](doc/alternatives.md).
### Not using system trash can
* [nivekuil/rip](https://github.com/nivekuil/rip)
* [babarot/gomi](https://github.com/babarot/gomi)
This program is mainly inspired by [babarot/gomi](https://github.com/babarot/gomi).
================================================
FILE: doc/alternatives.md
================================================
# Alternatives
| | gtrash | [trash-cli](https://github.com/andreafrancia/trash-cli) | [trashy](https://github.com/oberblastmeister/trashy) | [trash-d](https://github.com/rushsteve1/trash-d) |
| ------------------------------------------------------------ | --------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------ |
| Language | Go | Python | Rust | D |
| Supported OS | Linux,Mac | Linux,Mac | Linux,Windows | Linux,Mac |
| Architecture | Single binary & Multi subcommands | Multi commands | Single binary & Multi subcommands | Single binary |
| Has rm-like interface | ✔️ | ✔️ | ❌ | ✔️ |
| Restore with TUI (incremental search & multi select items) | ✔️ | ❌ | ❌ | ❌ |
| Restore as a group | ✔️ | ❌ | ❌ | ❌ |
| Can show file and directory size | ✔️ | ❌ | ❌ | ❌ |
| Can show summary of trash cans (total items, size) | ✔️ | ❌ | ❌ | ❌ |
| Support FreeDesktop.org directorysize cache | ✔️ | ❌ | ❌ | ✔ (only put, can not list) |
| Support FreeDesktop.org fallback to home trash | ✔️ | ✔️ | ❌ | Not support external filesystem trash can |
| Size-based pruning | ✔️ | ❌ | ❌ | ❌ |
| Date-based pruning | ✔️ | ✔️ | ✔️ | ❌ |
| Safe (Always show a confirmation prompt before deleting files by default?) | ✔️ | ❌ | ✔️ | ❌ |
| Sort trashed items by deletion date by default? | ✔️ | ❌ | ✔️ | ❌ |
If you think that some entries in this table are outdated or wrong, please open a issue or pull request.
================================================
FILE: doc/configuration.md
================================================
# Configration
Certain behaviors can be altered by setting environment variables.
## GTRASH_HOME_TRASH_DIR
- Type: string
- Default: `$XDG_DATA_HOME/Trash ($HOME/.local/share/Trash)`
Change the location of the main file system's trash can by specifying the full path.
Example: If you prefer placing it directly under your home directory:
```bash
export GTRASH_HOME_TRASH_DIR="$HOME/.gtrash"
```
## GTRASH_ONLY_HOME_TRASH
- Type: bool ('true' or 'false')
- Default: `false`
Enabling this option ensures the sole usage of the home directory's trash can.
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.
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.
If you encounter such issues, enabling this option can be helpful.
```bash
export GTRASH_ONLY_HOME_TRASH="true"
```
## GTRASH_HOME_TRASH_FALLBACK_COPY
- Type: bool ('true' or 'false')
- Default: `false`
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.
It can also be set using the `--home-fallback` option.
```bash
$ gtrash put --home-fallback /external/file1
# Equivalent to the above
$ GTRASH_HOME_TRASH_FALLBACK_COPY="true" gtrash put /external/file1
# To disable it when enabled in the environment variable
$ GTRASH_HOME_TRASH_FALLBACK_COPY="true" gtrash put --home-fallback=false /external/file1
```
```bash
export GTRASH_HOME_TRASH_FALLBACK_COPY="true"
```
## GTRASH_PUT_RM_MODE
- Type: bool ('true' or 'false')
- Default: `false`
Enabling this option changes the behavior of the `put` command as closely as possible to `rm`.
The `-r`, `--recursive`, `-R`, `-d` options closely resemble `rm` behavior. When set to false, these options are completely ignored.
This setting can also be configured using the `--rm-mode` option.
```bash
$ gtrash put --rm-mode -r dir1/
# Equivalent to the above
$ GTRASH_PUT_RM_MODE="true" gtrash put -r dir/
```
```bash
export GTRASH_PUT_RM_MODE="true"
```
================================================
FILE: doc/image/demo.tape
================================================
# VHS documentation
#
# Output:
# Output <path>.gif Create a GIF output at the given <path>
# Output <path>.mp4 Create an MP4 output at the given <path>
# Output <path>.webm Create a WebM output at the given <path>
#
# Require:
# Require <string> Ensure a program is on the $PATH to proceed
#
# Settings:
# Set FontSize <number> Set the font size of the terminal
# Set FontFamily <string> Set the font family of the terminal
# Set Height <number> Set the height of the terminal
# Set Width <number> Set the width of the terminal
# Set LetterSpacing <float> Set the font letter spacing (tracking)
# Set LineHeight <float> Set the font line height
# Set LoopOffset <float>% Set the starting frame offset for the GIF loop
# Set Theme <json|string> Set the theme of the terminal
# Set Padding <number> Set the padding of the terminal
# Set Framerate <number> Set the framerate of the recording
# Set PlaybackSpeed <float> Set the playback speed of the recording
# Set MarginFill <file|#000000> Set the file or color the margin will be filled with.
# Set Margin <number> Set the size of the margin. Has no effect if MarginFill isn't set.
# Set BorderRadius <number> Set terminal border radius, in pixels.
# Set WindowBar <string> Set window bar type. (one of: Rings, RingsRight, Colorful, ColorfulRight)
# Set WindowBarSize <number> Set window bar size, in pixels. Default is 40.
# Set TypingSpeed <time> Set the typing speed of the terminal. Default is 50ms.
#
# Sleep:
# Sleep <time> Sleep for a set amount of <time> in seconds
#
# Type:
# Type[@<time>] "<characters>" Type <characters> into the terminal with a
# <time> delay between each character
#
# Keys:
# Escape[@<time>] [number] Press the Escape key
# Backspace[@<time>] [number] Press the Backspace key
# Delete[@<time>] [number] Press the Delete key
# Insert[@<time>] [number] Press the Insert key
# Down[@<time>] [number] Press the Down key
# Enter[@<time>] [number] Press the Enter key
# Space[@<time>] [number] Press the Space key
# Tab[@<time>] [number] Press the Tab key
# Left[@<time>] [number] Press the Left Arrow key
# Right[@<time>] [number] Press the Right Arrow key
# Up[@<time>] [number] Press the Up Arrow key
# Down[@<time>] [number] Press the Down Arrow key
# PageUp[@<time>] [number] Press the Page Up key
# PageDown[@<time>] [number] Press the Page Down key
# Ctrl+<key> Press the Control key + <key> (e.g. Ctrl+C)
#
# Display:
# Hide Hide the subsequent commands from the output
# Show Show the subsequent commands in the output
Output ../demo.gif
# Output ../demo.webm
Require gtrash
Set Shell "zsh"
Set FontSize 24
Set Width 1240
Set Height 800
Set Theme "Snazzy"
Hide
Type "setopt interactivecomments" Enter
Type "clear" Enter
Show
Type "ls" Sleep 1000ms Enter
Type "gtrash put *" Sleep 500ms
Tab@100ms
Sleep 500ms Enter
Type "ls" Sleep 500ms Enter
Type "gtrash find" Enter
Sleep 1s
Type "# Lets restore files with TUI interface!" Sleep 50ms Enter
Type "gtrash restore" Sleep 300ms Enter
Sleep 2s
Type "j" Sleep 150ms
Type "l" Sleep 500ms
Type "j" Sleep 150ms
Type "/" Sleep 100ms
Type "main.go" Sleep 300ms Enter
Type "l" Sleep 800ms Enter
Sleep 1000ms Type "y"
Type "ls" Sleep 100ms Enter
Sleep 1s
Type "# Lets restore deleted files all at once!" Sleep 50ms Enter
Type "gtrash restore-group" Sleep 300ms Enter
Sleep 2s Enter Sleep 2s Type "y" Sleep 300ms
Type "ls" Enter
Sleep 1s
Type "# Now restored!" Enter
Sleep 2s
================================================
FILE: docker-compose.yaml
================================================
services:
itest:
image: golang:1.22
working_dir: /app
tmpfs:
- /external
- /external_alt
environment:
- GOCOVERDIR=./coverage
volumes:
- ./gtrash:/app/gtrash:ro
- ./itest:/app/itest
- ./go.mod:/app/go.mod:ro
# privileged: true
command:
- /bin/bash
- -c
- |
set -eu
bash ./itest/setup.sh
go test -v ./itest
================================================
FILE: go.mod
================================================
module github.com/umlx5h/gtrash
go 1.22.4
require (
github.com/charmbracelet/bubbles v0.18.0
github.com/charmbracelet/bubbletea v0.26.6
github.com/charmbracelet/lipgloss v0.11.0
github.com/dustin/go-humanize v1.0.1
github.com/gobwas/glob v0.2.3
github.com/juju/ansiterm v1.0.0
github.com/lmittmann/tint v1.0.4
github.com/moby/sys/mountinfo v0.7.1
github.com/otiai10/copy v1.14.0
github.com/rs/xid v1.5.0
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.8.4
github.com/umlx5h/go-runewidth v0.0.0-20240106112317-9bbbb3702d5f
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8
golang.org/x/term v0.21.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/x/ansi v0.1.2 // indirect
github.com/charmbracelet/x/input v0.1.2 // indirect
github.com/charmbracelet/x/term v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.1.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lunixbochs/vtclean v1.0.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.21.0 // indirect
golang.org/x/text v0.16.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
================================================
FILE: go.sum
================================================
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
github.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s=
github.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk=
github.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g=
github.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8=
github.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY=
github.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/charmbracelet/x/input v0.1.2 h1:QJAZr33eOhDowkkEQ24rsJy4Llxlm+fRDf/cQrmqJa0=
github.com/charmbracelet/x/input v0.1.2/go.mod h1:LGBim0maUY4Pitjn/4fHnuXb4KirU3DODsyuHuXdOyA=
github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=
github.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=
github.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg=
github.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/juju/ansiterm v1.0.0 h1:gmMvnZRq7JZJx6jkfSq9/+2LMrVEwGwt7UR6G+lmDEg=
github.com/juju/ansiterm v1.0.0/go.mod h1:PyXUpnI3olx3bsPcHt98FGPX/KCFZ1Fi+hw1XLI6384=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc=
github.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8=
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
github.com/mattn/go-colorable v0.1.10/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/moby/sys/mountinfo v0.7.1 h1:/tTvQaSJRr2FshkhXiIpux6fQ2Zvc4j7tAhMTStAG2g=
github.com/moby/sys/mountinfo v0.7.1/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=
github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=
github.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks=
github.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/umlx5h/go-runewidth v0.0.0-20240106112317-9bbbb3702d5f h1:T8MNFeOIelXNJyNQ5WIMz2zUXYpUq71+3Z5dbXqWCd8=
github.com/umlx5h/go-runewidth v0.0.0-20240106112317-9bbbb3702d5f/go.mod h1:+aP7JKaGs4irGEvKbEMTjKb1uKLoRZKMrrUwdGzajsk=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
================================================
FILE: internal/cmd/find.go
================================================
package cmd
import (
"errors"
"fmt"
"log/slog"
"os"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/juju/ansiterm"
"github.com/spf13/cobra"
"github.com/umlx5h/gtrash/internal/glog"
"github.com/umlx5h/gtrash/internal/trash"
"github.com/umlx5h/gtrash/internal/tui"
)
type findCmd struct {
cmd *cobra.Command
opts findOptions
}
type findOptions struct {
directory string
cwd bool
sortBy trash.SortByType
modeBy trash.ModeByType
// do options
doRemove bool
doRestore bool
force bool
dayNew int // unit day
dayOld int
sizeLarge string
sizeSmall string
reverse bool
last int
// control display info
showSize bool
showTrashPath bool
restoreTo string
trashDir string
}
func newFindCmd() *findCmd {
root := &findCmd{}
cmd := &cobra.Command{
Use: "find [QUERY...]",
Aliases: []string{"f"},
Short: "Find trashed files and do restore or remove them (f)",
Long: `Description:
Displays and searches all trashed files.
You can search by entering a string as a command-line argument.
To delete or restore the searched files, use the --rm and --restore options, respectively.`,
Example: ` # Show all trashed files
$ gtrash find
# Show files under the current directory
$ gtrash find --cwd
# Searching for files using regular expressions and do restore
# If you use special symbols, please use quotes to prevent shell expansion
$ gtrash find 'regex' --restore
# Display the actual file path and file size at the same time
$ gtrash find --show-size --show-trashpath
# Showing the 10 most recently deleted
$ gtrash find -n 10
# Showing 10 files sorted by file size
$ gtrash find -n 10 --sort size
# Delete all files (CAUTION)
$ gtrash find --rm
# Restore all files
$ gtrash find --restore
# Remove files deleted over a week ago
$ gtrash find --day-old 7 --rm
# Remove trashed files larger than 10MB
$ gtrash find --size-large 10mb --rm
# Fuzzy find multiple items and remove them permanently
# The -o in xargs is necessary for the confirmation prompt to display.
$ gtrash find | fzf --multi | awk -F'\t' '{print $2}' | xargs -o gtrash rm`,
SilenceUsage: true,
RunE: func(_ *cobra.Command, args []string) error {
if err := findCmdRun(args, root.opts); err != nil {
return err
}
if glog.ExitCode() > 0 {
return errContinue
}
return nil
},
}
cmd.Flags().StringVarP(&root.opts.directory, "directory", "d", "", "Filter by directory")
cmd.Flags().StringVar(&root.opts.sizeLarge, "size-large", "", "Filter by size larger (e.g. 5MB, 1GB)")
cmd.Flags().StringVar(&root.opts.sizeSmall, "size-small", "", "Filter by size smaller (e.g. 5MB, 1GB)")
cmd.Flags().BoolVarP(&root.opts.cwd, "cwd", "c", false, "Filter by current working directory")
cmd.Flags().VarP(&root.opts.sortBy, "sort", "s", "Sort by")
cmd.Flags().VarP(&root.opts.modeBy, "mode", "m", `query mode
regex (default):
Go language regular expression engine is used.
You can test it at the following site
ref: https://regex101.com
glob:
Glob patterns can be specified.
The following engine is used, please refer to the following site for notation.
ref: https://github.com/gobwas/glob
literal:
Ignores case and performs literal matching
If it matches part of the path, it will hit.
full:
Matches an exact match to a full path.
Case sensitive.`)
cmd.Flags().BoolVar(&root.opts.doRemove, "rm", false, "Do remove PERMANENTLY")
cmd.Flags().BoolVar(&root.opts.doRestore, "restore", false, "Do restore")
cmd.Flags().BoolVarP(&root.opts.force, "force", "f", false, `Always do --rm or --restore without confirmation prompt
This is not necessary if running outside of a terminal`)
cmd.Flags().IntVar(&root.opts.dayNew, "day-new", 0, "Filter by deletion date (within X day)")
cmd.Flags().IntVar(&root.opts.dayOld, "day-old", 0, "Filter by deletion date (before X day)")
cmd.Flags().BoolVarP(&root.opts.showSize, "show-size", "S", false, `Show size always
Automatically enabled if --sort size, --size-large, --size-small specified
If the size could not be obtained, it will be displayed as '-'
Note that this may take longer due to recursive size calcuration for directories.
The folder size is cached, so it will run faster the next time.
`)
cmd.Flags().BoolVar(&root.opts.showTrashPath, "show-trashpath", false, "Show trash path")
cmd.Flags().BoolVarP(&root.opts.reverse, "reverse", "r", false, "Reverse sort order (default: ascending)")
cmd.Flags().StringVar(&root.opts.restoreTo, "restore-to", "", "Restore to this path instead of original path")
cmd.Flags().IntVarP(&root.opts.last, "last", "n", 0, "Show n last files")
cmd.Flags().StringVar(&root.opts.trashDir, "trash-dir", "", `Specify a full path if you want to search only a specific trash can
By default, all trash cans are searched.
For $HOME trash only:
--trash-dir "$HOME/.local/share/Trash"
`)
cmd.MarkFlagsMutuallyExclusive("rm", "restore")
cmd.MarkFlagsMutuallyExclusive("directory", "cwd")
cmd.MarkFlagsMutuallyExclusive("day-new", "day-old")
cmd.MarkFlagsMutuallyExclusive("size-large", "size-small")
if err := cmd.RegisterFlagCompletionFunc("sort", trash.SortByFlagCompletionFunc); err != nil {
panic(err)
}
if err := cmd.RegisterFlagCompletionFunc("mode", trash.ModeByFlagCompletionFunc); err != nil {
panic(err)
}
root.cmd = cmd
return root
}
func findCmdRun(args []string, opts findOptions) error {
slog.Debug("starting find", "args", args, "doRemove", opts.doRemove, "doRestore", opts.doRestore)
if err := checkOptRestoreTo(&opts.restoreTo); err != nil {
return err
}
box := trash.NewBox(
trash.WithAscend(!opts.reverse),
trash.WithGetSize(opts.showSize),
trash.WithDirectory(opts.directory),
trash.WithCWD(opts.cwd),
trash.WithQueries(args),
trash.WithSortBy(opts.sortBy),
trash.WithQueryMode(opts.modeBy),
trash.WithDay(opts.dayNew, opts.dayOld), // TODO: also set in restore?
trash.WithSize(opts.sizeLarge, opts.sizeSmall),
trash.WithLimitLast(opts.last),
trash.WithTrashDir(opts.trashDir),
)
if err := box.Open(); err != nil {
// no error only remove mode (consider executing via batch)
if opts.doRemove && errors.Is(err, trash.ErrNotFound) {
fmt.Printf("do nothing: %s\n", err)
return nil
} else {
return err
}
}
listFiles(box.Files, box.GetSize, opts.showTrashPath)
if !opts.doRemove && !opts.doRestore {
if isTerminal {
fmt.Printf("\nFound %d trashed files. You can restore or remove PERMANENTLY these by --restore, --rm.\n", len(box.Files))
if len(box.OrphanMeta) > 0 {
fmt.Printf("\nFound invalid metadata: %d\nYou can remove invalid metadata by 'gtrash metafix'\n", len(box.OrphanMeta))
}
}
return nil
}
fmt.Printf("\nFound %d trashed files\n", len(box.Files))
if opts.doRemove {
if !opts.force && isTerminal && !tui.BoolPrompt("Are you sure you want to remove PERMANENTLY? ") {
return errors.New("do nothing")
}
doRemove(box.Files)
} else if opts.doRestore {
if opts.restoreTo != "" {
fmt.Printf("Will restore to %q instead of original path\n", opts.restoreTo)
}
if !opts.force && isTerminal && !tui.BoolPrompt("Are you sure you want to restore? ") {
return errors.New("do nothing")
}
if err := doRestore(box.Files, opts.restoreTo, isTerminal && !opts.force); err != nil {
return err
}
}
return nil
}
// TODO: refactor
func listFiles(files []trash.File, showSize, showTrashPath bool) {
if isTerminal {
// colored, tabular view
green := lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
// replacement to tabwriter (color supported)
w := ansiterm.NewTabWriter(os.Stdout, 0, 0, 2, ' ', 0)
if showSize {
fmt.Fprintf(w, "%s\t%s\t%s", green.Render("Date"), green.Render("Size"), green.Render("Path"))
} else {
fmt.Fprintf(w, "%s\t%s", green.Render("Date"), green.Render("Path"))
}
if showTrashPath {
fmt.Fprintf(w, "\t%s\n", green.Render("TrashPath"))
} else {
fmt.Fprintf(w, "\n")
}
for _, f := range files {
if showSize {
fmt.Fprintf(w, "%v\t%v\t%v", f.DeletedAt.Format(time.DateTime), f.SizeHuman(), f.OriginalPathFormat(false, true))
} else {
fmt.Fprintf(w, "%v\t%v", f.DeletedAt.Format(time.DateTime), f.OriginalPathFormat(false, true))
}
if showTrashPath {
fmt.Fprintf(w, "\t%v\n", f.TrashPathColor())
} else {
fmt.Fprintf(w, "\n")
}
}
w.Flush()
} else {
// no colored, splitted by TAB
for _, f := range files {
if showSize {
fmt.Printf("%v\t%v\t%v", f.DeletedAt.Format(time.DateTime), f.SizeHuman(), f.OriginalPath)
} else {
fmt.Printf("%v\t%v", f.DeletedAt.Format(time.DateTime), f.OriginalPath)
}
if showTrashPath {
fmt.Printf("\t%v\n", f.TrashPath)
} else {
fmt.Printf("\n")
}
}
}
}
================================================
FILE: internal/cmd/metafix.go
================================================
package cmd
import (
"errors"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/umlx5h/gtrash/internal/glog"
"github.com/umlx5h/gtrash/internal/trash"
"github.com/umlx5h/gtrash/internal/tui"
)
type metafixCmd struct {
cmd *cobra.Command
opts metafixOptions
}
type metafixOptions struct {
force bool
}
func newMetafixCmd() *metafixCmd {
root := &metafixCmd{}
cmd := &cobra.Command{
Use: "metafix",
Short: "Fix trashcan metadata",
Long: `Description:
Detect and delete meta-information without corresponding files.
This command is useful after manually removing files in the Trash directory.
Refer below for detailed information.
https://github.com/umlx5h/gtrash#what-does-the-metafix-subcommand-do`,
SilenceUsage: true,
Args: cobra.NoArgs,
ValidArgsFunction: cobra.NoFileCompletions,
RunE: func(_ *cobra.Command, _ []string) error {
if err := metafixCmdRun(root.opts); err != nil {
return err
}
if glog.ExitCode() > 0 {
return errContinue
}
return nil
},
}
cmd.Flags().BoolVarP(&root.opts.force, "force", "f", false, `Always execute without confirmation prompt
This is not necessary if running outside of a terminal`)
root.cmd = cmd
return root
}
func metafixCmdRun(opts metafixOptions) error {
box := trash.NewBox(
trash.WithSortBy(trash.SortByName),
)
if err := box.Open(); err != nil {
if errors.Is(err, trash.ErrNotFound) {
fmt.Printf("do nothing: %s\n", err)
return nil
} else {
return err
}
}
if len(box.OrphanMeta) == 0 {
fmt.Println("not found invalid metadata")
return nil
}
listFiles(box.OrphanMeta, false, false)
// TODO: Add functionality to allow deletion of orphaned files as well
// (those for which trashinfo exists but the file does not).
fmt.Printf("\nFound invalid metadata: %d\n", len(box.OrphanMeta))
if !opts.force && isTerminal && !tui.BoolPrompt("Are you sure you want to remove invalid metadata? ") {
return errors.New("do nothing")
}
var failed int
for _, f := range box.OrphanMeta {
if err := os.Remove(f.TrashInfoPath); err != nil {
failed++
glog.Errorf("cannot remove .trashinfo: %q: %s\n", f.TrashInfoPath, err)
}
}
fmt.Printf("Deleted invalid metadata: %d\n", len(box.OrphanMeta)-failed)
return nil
}
================================================
FILE: internal/cmd/prune.go
================================================
package cmd
import (
"errors"
"fmt"
"github.com/dustin/go-humanize"
"github.com/spf13/cobra"
"github.com/umlx5h/gtrash/internal/glog"
"github.com/umlx5h/gtrash/internal/trash"
"github.com/umlx5h/gtrash/internal/tui"
)
type pruneCmd struct {
cmd *cobra.Command
opts pruneOptions
}
type pruneOptions struct {
force bool
day int
size string // human size (e.g. 10MB, 1G)
maxTotalSize uint64 // byte, parse from size
trashDir string // $HOME/.local/share/Trash
}
func (o *pruneOptions) check() error {
if o.size != "" {
byte, err := humanize.ParseBytes(o.size)
if err != nil {
return fmt.Errorf("--size unit is invalid: %w", err)
}
o.maxTotalSize = byte
}
return nil
}
func newPruneCmd() *pruneCmd {
root := &pruneCmd{}
cmd := &cobra.Command{
Use: "prune",
Short: "Prune trash cans by day or size",
Long: `Description:
Pruning trash cans by day or size criteria.
Either the --day or --size option is required.
This command is also intended for use via cron.
By default, you may be prompted multiple times for each trash can.
If the file to be pruned does not exist, the program exits normally without doing anything.`,
Example: ` # Delete all files deleted a week ago
$ gtrash prune --day 7
# Delete all files deleted a week ago only within $HOME trash
$ gtrash prune --day 7 --trash-dir "$HOME/.local/share/Trash"
# Delete files in order from the largest to the smaller one so that the total size of the trash can is less than 5GB.
# This is useful when you want to keep as many files as possible, including old files, but want to reduce the size of the trash can below a certain level.
$ gtrash prune --size 5GB
# Delete large files first to keep the total remaining size under 5GB, while excluding files deleted in the last week.
# Note that adding the most recently deleted files may exceed 5GB.
$ gtrash prune --size 5GB --day 7`,
SilenceUsage: true,
Args: cobra.NoArgs,
ValidArgsFunction: cobra.NoFileCompletions,
RunE: func(_ *cobra.Command, _ []string) error {
if err := pruneCmdRun(root.opts); err != nil {
return err
}
if glog.ExitCode() > 0 {
return errContinue
}
return nil
},
}
cmd.Flags().StringVar(&root.opts.size, "size", "", `Remove files in order from the largest to the smaller one so that the overall size of the trash can is less than the specified size.
If the total size of the trash can is smaller than the specified size, nothing is done.
The total size is calculated by each trash can.
If you want to delete files larger than the specified size, use the "find --size-large XX --rm" command.
Can be specified in human format (e.g. 5MB, 1GB)
If --day and --size are specified at the same time, the most recent X days are excluded from the calculation.
This may be useful when you do not want to delete large files that have been recently deleted.
`)
cmd.Flags().IntVar(&root.opts.day, "day", 0, "Remove all files deleted before X days")
cmd.Flags().BoolVarP(&root.opts.force, "force", "f", false, `Always execute without confirmation prompt
This is not necessary if running outside of a terminal
`)
cmd.Flags().StringVar(&root.opts.trashDir, "trash-dir", "", `Specify a full path if you want to prune only a specific trash can
By default, all trash cans are pruned.
For $HOME trash only:
--trash-dir "$HOME/.local/share/Trash"
`)
cmd.Root().MarkFlagsOneRequired("size", "day")
root.cmd = cmd
return root
}
// Returns files to be deleted from files based on maxTotalSize
// If maxTotalSize > total, nil is returned.
//
// Prerequisite: files are sorted in ascending order by size
func getPruneFiles(files []trash.File, maxTotalSize uint64) (prune []trash.File, deleted uint64, total uint64) {
for i, f := range files {
// If the size cannot be obtained, it is treated as a minus value and should be at the top.
// This is always skipped and is not considered for deletion.
if f.Size == nil {
continue
}
size := uint64(*f.Size)
total += size
if prune == nil {
if total > maxTotalSize {
prune = files[i:]
}
}
if prune != nil {
deleted += size
}
}
if prune == nil {
return nil, 0, total
} else {
return prune, deleted, total
}
}
func pruneCmdRun(opts pruneOptions) error {
if err := opts.check(); err != nil {
return err
}
sortMethod := trash.SortByDeletedAt
sizeMode := opts.size != ""
if opts.size != "" {
sortMethod = trash.SortBySize
}
box := trash.NewBox(
trash.WithSortBy(sortMethod),
trash.WithGetSize(sizeMode),
trash.WithAscend(true),
trash.WithDay(0, opts.day),
trash.WithTrashDir(opts.trashDir),
)
if err := box.Open(); err != nil {
if errors.Is(err, trash.ErrNotFound) {
fmt.Printf("do nothing: %s\n", err)
return nil
} else {
return err
}
}
for i, trashDir := range box.TrashDirs {
files := box.FilesByTrashDir[trashDir]
if len(files) == 0 {
continue
}
var deleted, total uint64
if sizeMode {
files, deleted, total = getPruneFiles(files, opts.maxTotalSize)
if len(files) == 0 {
fmt.Printf("do nothing: trash size %s is smaller than %s (%s) in %s\n", humanize.Bytes(total), humanize.Bytes(opts.maxTotalSize), opts.size, trashDir)
continue
}
}
listFiles(files, sizeMode, false)
fmt.Printf("\nSelected %d files in %s\n", len(files), trashDir)
if sizeMode {
fmt.Printf("Current: %s, Deleted: %s, After: %s, Specified: %s\n\n", humanize.Bytes(total), humanize.Bytes(deleted), humanize.Bytes(total-deleted), humanize.Bytes(opts.maxTotalSize))
}
if !opts.force && isTerminal && !tui.BoolPrompt("Are you sure you want to remove PERMANENTLY? ") {
return errors.New("do nothing")
}
doRemove(files)
if i != len(box.TrashDirs)-1 {
fmt.Println("")
}
}
return nil
}
================================================
FILE: internal/cmd/prune_test.go
================================================
package cmd
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/umlx5h/gtrash/internal/trash"
)
func newInt(i int64) *int64 {
return &i
}
func TestGetPruneFiles(t *testing.T) {
t.Run("should return prune files", func(t *testing.T) {
got, deleted, total := getPruneFiles([]trash.File{
{
Name: "a",
Size: newInt(20),
},
{
Name: "b",
Size: newInt(30),
},
{
Name: "c",
Size: newInt(50),
},
{
Name: "d",
Size: newInt(100),
},
{
Name: "e",
Size: newInt(150),
},
}, 100)
want := []trash.File{
{
Name: "d",
Size: newInt(100),
},
{
Name: "e",
Size: newInt(150),
},
}
assert.Equal(t, want, got)
assert.EqualValues(t, 250, deleted)
assert.EqualValues(t, 350, total)
})
t.Run("should prune files from larger files", func(t *testing.T) {
got, deleted, total := getPruneFiles([]trash.File{
{
Name: "a",
Size: newInt(20),
},
{
Name: "b",
Size: newInt(30),
},
{
Name: "c",
Size: newInt(50),
},
}, 30)
want := []trash.File{
{
Name: "b",
Size: newInt(30),
},
{
Name: "c",
Size: newInt(50),
},
}
assert.Equal(t, want, got)
assert.EqualValues(t, 80, deleted)
assert.EqualValues(t, 100, total)
})
t.Run("should return nil", func(t *testing.T) {
got, deleted, total := getPruneFiles([]trash.File{
{
Name: "a",
Size: newInt(20),
},
{
Name: "b",
Size: newInt(30),
},
{
Name: "c",
Size: newInt(50),
},
}, 100)
assert.Nil(t, got)
assert.EqualValues(t, 0, deleted)
assert.EqualValues(t, 100, total)
})
}
================================================
FILE: internal/cmd/put.go
================================================
package cmd
import (
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"time"
cp "github.com/otiai10/copy"
"github.com/spf13/cobra"
"github.com/umlx5h/gtrash/internal/env"
"github.com/umlx5h/gtrash/internal/glog"
"github.com/umlx5h/gtrash/internal/posix"
"github.com/umlx5h/gtrash/internal/tui"
"github.com/umlx5h/gtrash/internal/xdg"
)
type putCmd struct {
cmd *cobra.Command
opts putOptions
}
type putOptions struct {
prompt bool
promptOnce bool
force bool
verbose bool
rmMode bool
recursive bool
dir bool
homeFallback bool
}
func newPutCmd() *putCmd {
root := &putCmd{}
cmd := &cobra.Command{
Use: "put PATH...",
Aliases: []string{"p"},
Short: "Put files to trash (p)",
Long: `Description:
A substitute for 'rm', moving files to the trash.
For files in the main file system, they're moved to the following folder:
$XDG_DATA_HOME/Trash ($HOME/.local/share/Trash)
For files in external file systems, they're moved to either of the following locations at the root of the mount point:
1. $MOUNTPOINT/.Trash/$uid
2. $MOUNTPOINT/.Trash-$uid
Folder 1 takes precedence but requires pre-creation with a set sticky bit ($uid part is created automatically).
Folder 2 is created automatically.
To identify the folder where files will be moved, use the -v or --debug option.
To display the path in the trash can, use --show-trashpath with the find command:
$ gtrash find --show-trashpath
By default, the options -d, -r, -R, and --recursive are ignored.
They are unnecessary for file removal but required when using --rm-mode.`,
Example: ` # -r is unnecessary to delete a folder
$ gtrash put file1 file2 dir1/ dir2
# For files starting with a hyphen, specify the filename after the '--'
$ gtrash put -- -foo
# If expanded in the shell, you can use glob patterns
$ gtrash put foo*`,
Args: cobra.MinimumNArgs(1),
SilenceUsage: true,
SilenceErrors: true,
RunE: func(_ *cobra.Command, args []string) error {
if err := putCmdRun(args, root.opts); err != nil {
return err
}
if glog.ExitCode() > 0 {
return errContinue
}
return nil
},
}
cmd.Flags().BoolVarP(&root.opts.force, "force", "f", false, "ignore nonexistent files and arguments")
cmd.Flags().BoolVarP(&root.opts.prompt, "interactive", "i", false, "prompt before every removal")
// short only options are not available
cmd.Flags().BoolVarP(&root.opts.promptOnce, "interactive-once", "I", false, "prompt once before trashing")
cmd.Flags().BoolVarP(&root.opts.verbose, "verbose", "v", false, "explain what is being done")
// rm mode options if --rm-mode used
cmd.Flags().BoolVar(&root.opts.rmMode, "rm-mode", env.PUT_RM_MODE, "enable rm-like mode (change behavior -r, -R, -d)")
cmd.Flags().BoolVarP(&root.opts.dir, "dir", "d", false, `ignored unless --rm-mode set
remove empty directories (--rm-mode)`)
cmd.Flags().BoolVarP(&root.opts.recursive, "recursive", "r", false, `ignored unless --rm-mode set
remove directories and their contents recursively (--rm-mode)`)
// TODO: Since short only options are not available, have no choice but to assign a long name.
cmd.Flags().BoolVarP(&root.opts.recursive, "Recursive", "R", false, "same as -r")
cmd.Flags().BoolVar(&root.opts.homeFallback, "home-fallback", env.HOME_TRASH_FALLBACK_COPY, `Enable fallback to home directory trash
If the deletion of a file in an external file system fails, this option may help.`)
root.cmd = cmd
return root
}
func putCmdRun(args []string, opts putOptions) error {
if isDebug {
opts.verbose = true
}
if opts.force {
// If both are specified, force is preferred.
opts.prompt = false
opts.promptOnce = false
}
slog.Debug("starting put", "args", args, "home-fallback", opts.homeFallback, "rm-mode", opts.rmMode)
if (opts.prompt || opts.promptOnce) && !isTerminal {
return errors.New("cannot use -i without tty")
}
if opts.promptOnce {
// -I confirmation dialog
for _, a := range args {
fmt.Println(a)
}
fmt.Println("")
yes := tui.BoolPrompt(fmt.Sprintf("Do you trash above %d items? ", len(args)))
if !yes {
return errors.New("canceled")
}
}
// could restore-group to work, reuse deleteTime
var deleteTime time.Time
for _, arg := range args {
// same as rm
if slices.Contains([]string{".", ".."}, filepath.Base(arg)) {
glog.Errorf("refusing to remove '.' or '..' directory: skipping %q\n", arg)
continue
}
slog.Debug("checking for the existence of files with lstat(2)", "file", arg)
st, err := os.Lstat(arg)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
if !opts.force {
glog.Errorf("cannot trash %q: No such file or directory\n", arg)
}
} else {
glog.Errorf("cannot trash %q: %s\n", err)
}
continue
}
if opts.rmMode {
if st.IsDir() {
if !opts.recursive && !opts.dir {
glog.Errorf("cannot trash %q: Is a directory\n", arg)
continue
}
if !opts.recursive && opts.dir {
// check if directory is empty
empty, err := posix.DirEmpty(arg)
if err != nil {
glog.Errorf("cannot trash %q: check dir empty: %s\n", arg, err)
continue
}
if !empty {
glog.Errorf("cannot trash %q: Directory not empty\n", arg)
continue
}
}
}
}
// -i confirmation dialog
if opts.prompt {
prompt := fmt.Sprintf("Do you trash %s %q? ", posix.FileType(st), arg)
choices := []string{"yes", "no", "all-yes", "quit"}
selected, err := tui.ChoicePrompt(prompt, choices)
if err != nil {
// canceled
return err
}
switch selected {
case "no":
continue // skip
case "all-yes":
// disable prompt later
opts.prompt = false
}
}
path, err := filepath.Abs(arg)
if err != nil {
glog.Errorf("cannot trash %q: get abspath: %s\n", arg, err)
continue
}
// for -v logging
var usedDir xdg.TrashDir
slog.Debug("looking up trash_dir", "path", path)
// TODO: Add integration test
homeDir, externalDir, err := xdg.LookupTrashDir(path)
slog.Debug("looked up trash_dir", "homeDir", homeDir, "externalDir", externalDir, "error", err)
if err != nil {
if !opts.homeFallback || (homeDir == nil && externalDir == nil) {
glog.Errorf("cannot trash %q: lookup trash directory: %s\n", arg, err)
continue
}
// fallback to home trash
slog.Debug("fallback to home trash because external trash is not found", "error", err)
}
// preferred if an external trash can is available.
if externalDir != nil {
slog.Debug("will use external trash, will use rename(2) to move", "trashDir", externalDir.Dir)
// external trash only uses rename, not copy
if err := trashFile(*externalDir, path, &deleteTime, false); err != nil {
if !opts.homeFallback {
glog.Errorf("cannot trash %q: %s\n", arg, err)
continue
}
// fallback to home trash
slog.Debug("fallback to home trash because moving failed by rename(2)", "error", err)
} else {
usedDir = *externalDir
goto SUCCESS
}
}
if opts.homeFallback || env.ONLY_HOME_TRASH {
slog.Debug("will use home trash, will use rename(2) and copy to move", "trashDir", homeDir.Dir)
} else {
slog.Debug("will use home trash, will use rename(2) to move", "trashDir", homeDir.Dir)
}
if err := trashFile(*homeDir, path, &deleteTime, opts.homeFallback || env.ONLY_HOME_TRASH); err != nil {
glog.Errorf("cannot trash %q: %s\n", arg, err)
continue
}
usedDir = *homeDir
SUCCESS:
if opts.verbose {
fmt.Printf("trashed %q to %s\n", arg, posix.AbsPathToTilde(usedDir.Dir))
}
}
return nil
}
func trashFile(trashDir xdg.TrashDir, path string, deleteTime *time.Time, fallbackCopy bool) error {
if err := trashDir.CreateDir(); err != nil {
return fmt.Errorf("create trash directory: %w\n", err)
}
infoPath := path
if trashDir.UseRelativePath() {
// get relative path from $topDir
if p, err := filepath.Rel(trashDir.Root, path); err == nil {
// it MUST not include a “..” directory, and for files not “under” that directory, absolute pathnames must be used
if p != ".." && !strings.HasPrefix(p, ".."+string(os.PathSeparator)) {
infoPath = p
}
} else {
// should not come here
slog.Warn("cannot convert absolute to relative path, use absolute path instead", "file", path, "root", trashDir.Root, "error", err)
}
}
if deleteTime.IsZero() {
*deleteTime = time.Now()
}
info := xdg.Info{
Path: infoPath,
DeletionDate: *deleteTime,
}
filename := filepath.Base(path)
// before rename(2), write .trashinfo metadata atomically
saveName, deleteFn, err := info.Save(trashDir, filename)
if err != nil {
return fmt.Errorf("save trashinfo: %w\n", err)
}
slog.Debug("saved .trashinfo metadata", "path", filepath.Join(trashDir.InfoDir(), saveName+".trashinfo"))
// move file to trash
dstPath := filepath.Join(trashDir.FilesDir(), saveName)
slog.Debug("executing rename(2) to move", "from", path, "to", dstPath)
if err := os.Rename(path, dstPath); err != nil {
if fallbackCopy {
// rename(2) failed, fallback to copy and delete
slog.Debug("executing copy and delete to move because rename(2) failed", "from", path, "to", dstPath, "error", err)
// copy recursively
if err := cp.Copy(path, dstPath); err != nil {
_ = deleteFn()
return fmt.Errorf("fallback copy: %w", err)
}
// if copy success, then remove recursively
if err = os.RemoveAll(path); err != nil {
_ = deleteFn()
return fmt.Errorf("delete after fallback copy: %w", err)
}
return nil
}
// delete corresponding .trashinfo file
_ = deleteFn()
return fmt.Errorf("move: %w", err)
}
return nil
}
================================================
FILE: internal/cmd/restore.go
================================================
package cmd
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
cp "github.com/otiai10/copy"
"github.com/rs/xid"
"github.com/spf13/cobra"
"github.com/umlx5h/gtrash/internal/glog"
"github.com/umlx5h/gtrash/internal/trash"
"github.com/umlx5h/gtrash/internal/tui"
)
type restoreCmd struct {
cmd *cobra.Command
opts restoreOptions
}
type restoreOptions struct {
directory string
cwd bool
restoreTo string
force bool
}
func newRestoreCmd() *restoreCmd {
root := &restoreCmd{}
cmd := &cobra.Command{
Use: "restore [PATH...]",
Aliases: []string{"r"},
Short: "Restore trashed files interactively (r)",
Long: `Description:
Use the TUI interface to restore files, enabling multiple file selection.
Press the ? key within the TUI interface for usage help.
When specifying the full path in the command-line argument, restoration is performed without using the TUI interface.`,
Example: ` # Restore interactively
$ gtrash restore
# Restore files without TUI
# Must specify full paths
$ gtrash restore /home/user/file1 /home/user/file2
# Fuzzy find multiple items and restore them
# The -o in xargs is necessary for the confirmation prompt to display.
$ gtrash find | fzf --multi | awk -F'\t' '{print $2}' | xargs -o gtrash restore`,
SilenceUsage: true,
RunE: func(_ *cobra.Command, args []string) error {
if err := restoreCmdRun(args, root.opts); err != nil {
return err
}
if glog.ExitCode() > 0 {
return errContinue
}
return nil
},
}
cmd.Flags().StringVarP(&root.opts.directory, "directory", "d", "", "Filter by directory")
cmd.Flags().BoolVarP(&root.opts.cwd, "cwd", "c", false, "Filter by current working directory")
cmd.Flags().StringVar(&root.opts.restoreTo, "restore-to", "", "Restore to this path instead of original path")
cmd.Flags().BoolVarP(&root.opts.force, "force", "f", false, `Always execute without confirmation prompt
This is not necessary if running outside of a terminal`)
root.cmd = cmd
return root
}
func restoreCmdRun(args []string, opts restoreOptions) (err error) {
if err := checkOptRestoreTo(&opts.restoreTo); err != nil {
return err
}
slog.Debug("starting restore", "args", args)
box := trash.NewBox(
trash.WithDirectory(opts.directory),
trash.WithCWD(opts.cwd),
trash.WithQueries(args), // only used when specifying command args
trash.WithQueryMode(trash.ModeByFull), // only support full match
)
if err := box.Open(); err != nil {
return err
}
if len(args) == 0 {
if !isTerminal {
return errors.New("cannot use tui interface, please specify restore path to command line args")
}
// interactive restore when not specifying command line args
box.Files, err = tui.FilesSelect(box.Files)
if err != nil {
return err
}
}
listFiles(box.Files, false, false)
for _, arg := range args {
if box.HitByPath(arg) == 0 {
glog.Errorf("cannot restore %q: not found in trashcan\n", arg)
}
}
fmt.Printf("\nSelected %d trashed files\n", len(box.Files))
if opts.restoreTo != "" {
fmt.Printf("Will restore to %q instead of original path\n", opts.restoreTo)
}
if !opts.force && isTerminal && !tui.BoolPrompt("Are you sure you want to restore? ") {
return errors.New("do nothing")
}
if err := doRestore(box.Files, opts.restoreTo, isTerminal && !opts.force); err != nil {
return err
}
return nil
}
func checkOptRestoreTo(restoreTo *string) error {
if restoreTo == nil {
return nil
}
if *restoreTo != "" {
fi, err := os.Stat(*restoreTo)
if err != nil {
return fmt.Errorf("--restore-to path must be existing directory: %w", err)
}
if !fi.IsDir() {
return fmt.Errorf("--restore-to path must be directory")
}
// convert to absolute path
abs, err := filepath.Abs(*restoreTo)
if err != nil {
return fmt.Errorf("--restore-to path must be valid directory: %w", err)
}
*restoreTo = abs
}
return nil
}
func checkRestoreDup(files []trash.File) error {
// Detect and abort duplicate restore destinations
fileByPath := make(map[string][]trash.File)
for _, f := range files {
fileByPath[f.OriginalPath] = append(fileByPath[f.OriginalPath], f)
}
var conflicted bool
for path, files := range fileByPath {
if len(files) >= 2 {
conflicted = true
glog.Errorf("conflict restore %d files: %q\n", len(files), path)
}
}
if conflicted {
return errors.New("canceled: restore conflict detected")
}
return nil
}
func doRestore(files []trash.File, restoreTo string, prompt bool) error {
if !prompt {
if err := checkRestoreDup(files); err != nil {
return err
}
}
var (
success int
failed []trash.File
)
printResult := func() {
if restoreTo != "" {
fmt.Printf("Restored to %q\n", restoreTo)
}
fmt.Printf("Restored %d/%d trashed files\n", success, len(files))
if len(failed) > 0 {
fmt.Printf("Following %d files could not be restored.\n", len(failed))
listFiles(failed, false, true)
}
}
defer printResult()
var (
repeat bool
selected string
prevSelect string
)
for _, file := range files {
// option to change the restore destination.
restorePath := file.OriginalPath
if restoreTo != "" {
restorePath = filepath.Join(restoreTo, file.OriginalPath)
}
// Check to see if the file already exists in the destination path.
// This is necessary because rename(2) overwrites the file.
if _, err := os.Lstat(restorePath); err == nil {
if !prompt {
glog.Errorf("cannot restore %q: restore path already exists\n", file.OriginalPath)
failed = append(failed, file)
continue
}
if !repeat {
choice := []string{"new-name", "skip", "quit"}
if prevSelect != "" {
choice = []string{"new-name", "skip", "repeat-prev", "quit"}
}
// TODO: Make the message easy to understand
selected, err = tui.ChoicePrompt(fmt.Sprintf("Conflicted restore path %q\n\tPlease choose one of the following: ", file.OriginalPath), choice)
if err != nil {
return err
}
}
SWITCH:
switch selected {
case "new-name":
// give a random string to avoid duplicates
restorePath = restorePath + "." + xid.New().String()
fmt.Printf("Restoring to %q (original: %q)\n", restorePath, file.Name)
prevSelect = selected
case "skip":
prevSelect = selected
continue
case "repeat-prev":
repeat = true
selected = prevSelect
goto SWITCH
}
}
// ensure to have directory to restore
if err := os.MkdirAll(filepath.Dir(restorePath), 0o777); err != nil {
glog.Errorf("cannot restore %q: mkdir restorePath: %s\n", file.OriginalPath, err)
failed = append(failed, file)
continue
}
// "overwrite" is not an option because it only works when the source and destination files are both files.
// old new
// file file old overwrites new
// dir file error: not a directory
// file dir error: file exists
// dir dir error: file exists
slog.Debug("executing rename(2) to restore", "from", file.TrashPath, "to", restorePath)
if err := os.Rename(file.TrashPath, restorePath); err != nil {
// rename(2) failed, fallback to copy and delete
slog.Debug("executing copy and delete to restore because rename(2) failed", "from", file.TrashPath, "to", restorePath)
// copy recursively
if err := cp.Copy(file.TrashPath, restorePath); err != nil {
glog.Errorf("cannot restore %q: fallback copy: %s\n", file.OriginalPath)
failed = append(failed, file)
continue
}
// if copy success, then remove recursively
if err = os.RemoveAll(file.TrashPath); err != nil {
slog.Warn("restored successfully but cannot delete trashed file", "trashPath", file.TrashPath, "restoreTo", file.OriginalPath, "error", err)
}
}
if err := file.Delete(); err != nil {
slog.Warn("restored successfully but cannot delete .trashinfo", "trashInfoPath", file.TrashInfoPath, "restoreTo", file.OriginalPath, "error", err)
}
success++
}
return nil
}
================================================
FILE: internal/cmd/restoreGroup.go
================================================
package cmd
import (
"errors"
"fmt"
"github.com/spf13/cobra"
"github.com/umlx5h/gtrash/internal/glog"
"github.com/umlx5h/gtrash/internal/trash"
"github.com/umlx5h/gtrash/internal/tui"
)
type restoreGroupCmd struct {
cmd *cobra.Command
opts restoreGroupOptions
}
type restoreGroupOptions struct{}
func newRestoreGroupCmd() *restoreGroupCmd {
root := &restoreGroupCmd{}
cmd := &cobra.Command{
Use: "restore-group",
Aliases: []string{"rg"},
Short: "Restore trashed files as a group interactively (rg)",
Long: `Description:
Use the TUI interface for file restoration.
Unlike the 'restore' command, files deleted simultaneously are grouped together.
Multiple selections of groups are not allowed.
Actually, files deleted using 'gtrash put' may not be grouped accurately.
Files with deletion times matching in seconds are grouped together.
Refer below for detailed information.
ref: https://github.com/umlx5h/gtrash#how-does-the-restore-group-subcommand-work
`,
SilenceUsage: true,
Args: cobra.NoArgs,
ValidArgsFunction: cobra.NoFileCompletions,
RunE: func(_ *cobra.Command, _ []string) error {
if err := restoreGroupCmdRun(root.opts); err != nil {
return err
}
if glog.ExitCode() > 0 {
return errContinue
}
return nil
},
}
root.cmd = cmd
return root
}
func restoreGroupCmdRun(_ restoreGroupOptions) error {
box := trash.NewBox()
if err := box.Open(); err != nil {
return err
}
groups := box.ToGroups()
group, err := tui.GroupSelect(groups)
if err != nil {
return err
}
listFiles(group.Files, false, false)
fmt.Printf("\nSelected %d trashed files\n", len(group.Files))
if isTerminal && !tui.BoolPrompt("Are you sure you want to restore? ") {
return errors.New("do nothing")
}
if err := doRestore(group.Files, "", true); err != nil {
return err
}
return nil
}
================================================
FILE: internal/cmd/rm.go
================================================
package cmd
import (
"errors"
"fmt"
"log/slog"
"os"
"github.com/spf13/cobra"
"github.com/umlx5h/gtrash/internal/glog"
"github.com/umlx5h/gtrash/internal/trash"
"github.com/umlx5h/gtrash/internal/tui"
)
type removeCmd struct {
cmd *cobra.Command
opts removeOptions
}
type removeOptions struct {
force bool
}
func newRemoveCmd() *removeCmd {
root := &removeCmd{}
cmd := &cobra.Command{
Use: "rm PATH...",
Short: "Remove trashed files PERMANENTLY in the cmd arguments",
Long: `Descricption:
Permanently remove the files specified as command-line arguments.
Paths must be specified as full paths.
This command is intended to be used alongside other commands like fzf.
Generally, using 'find --rm' is recommended over this command.`,
Example: ` # Permanently remove files by providing full paths..
$ gtrash rm /home/user/file1 /home/user/file2
# Fuzzy find multiple items and permanently remove them.
# The -o in xargs is necessary for the confirmation prompt to display.
$ gtrash find | fzf --multi | awk -F'\t' '{print $2}' | xargs -o gtrash rm`,
SilenceUsage: true,
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
if err := removeCmdRun(args, root.opts); err != nil {
return err
}
if glog.ExitCode() > 0 {
return errContinue
}
return nil
},
}
cmd.Flags().BoolVarP(&root.opts.force, "force", "f", false, `Always execute without confirmation prompt
This is not necessary if running outside of a terminal`)
root.cmd = cmd
return root
}
func removeCmdRun(args []string, opts removeOptions) error {
box := trash.NewBox(
trash.WithAscend(true),
trash.WithQueries(args),
trash.WithQueryMode(trash.ModeByFull),
)
if err := box.Open(); err != nil {
return err
}
listFiles(box.Files, false, false)
for _, arg := range args {
if box.HitByPath(arg) == 0 {
glog.Errorf("cannot trash %q: not found in trashcan\n", arg)
}
}
fmt.Printf("\nFound %d trashed files\n", len(box.Files))
if !opts.force && isTerminal && !tui.BoolPrompt("Are you sure you want to remove PERMANENTLY? ") {
return errors.New("do nothing")
}
doRemove(box.Files)
return nil
}
func doRemove(files []trash.File) {
var failed []trash.File
for _, file := range files {
slog.Debug("removing a trashed file", "path", file.TrashPath)
if err := os.RemoveAll(file.TrashPath); err != nil {
if !errors.Is(err, os.ErrNotExist) {
glog.Errorf("cannot trash %q: remove: %s\n", file.TrashPath, err)
failed = append(failed, file)
continue
}
}
if err := file.Delete(); err != nil {
// already read, so it is usually not reached
slog.Warn("removed trashed file but cannot delete .trashinfo", "deletedFile", file.TrashPath, "trashInfoPath", file.TrashInfoPath, "error", err)
}
}
fmt.Printf("Removed %d/%d trashed files\n", len(files)-len(failed), len(files))
if len(failed) > 0 {
fmt.Printf("Following %d files could not be deleted.\n", len(failed))
listFiles(failed, false, true)
}
}
================================================
FILE: internal/cmd/root.go
================================================
package cmd
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime/debug"
"strings"
"github.com/lmittmann/tint"
"github.com/spf13/cobra"
"github.com/umlx5h/gtrash/internal/env"
"golang.org/x/term"
)
var (
progName = filepath.Base(os.Args[0])
errContinue = errors.New("")
isTerminal bool
)
func init() {
if term.IsTerminal(int(os.Stdout.Fd())) && term.IsTerminal(int(os.Stdin.Fd())) {
isTerminal = true
}
}
func Execute(version Version) {
err := newRootCmd(version).cmd.Execute()
if err != nil {
if !errors.Is(err, errContinue) {
fmt.Fprintf(os.Stderr, "%s: error: %s\n", progName, err)
}
os.Exit(1)
}
}
type Version struct {
Version string
Commit string
Date string
BuiltBy string
}
func (v Version) Print() string {
var s strings.Builder
fmt.Fprintln(&s, "gtrash: Trash CLI Manager written in Go")
fmt.Fprintln(&s, "https://github.com/umlx5h/gtrash")
fmt.Fprintln(&s, "")
fmt.Fprintln(&s, "version: "+v.Version)
fmt.Fprintln(&s, "commit: "+v.Commit)
fmt.Fprintln(&s, "buildDate: "+v.Date)
fmt.Fprintln(&s, "builtBy: "+v.BuiltBy)
return s.String()
}
// global options
var (
isDebug bool
)
type rootCmd struct {
cmd *cobra.Command
}
func newRootCmd(version Version) *rootCmd {
// if version is not set, probably go install
if version.Version == "unknown" {
if info, ok := debug.ReadBuildInfo(); ok {
version.Version = info.Main.Version
}
}
root := &rootCmd{}
cmd := &cobra.Command{
Use: progName,
SilenceErrors: true,
Short: "Trash CLI manager written in Go",
Long: `Trash CLI manager written in Go
https://github.com/umlx5h/gtrash`,
Version: version.Print(),
PersistentPreRun: func(_ *cobra.Command, _ []string) {
// setup debug log level
lvl := &slog.LevelVar{}
lvl.Set(slog.LevelWarn)
if isDebug {
lvl.Set(slog.LevelDebug)
}
// colored format
logger := slog.New(tint.NewHandler(os.Stderr, &tint.Options{
Level: lvl,
TimeFormat: "15:04:05.000",
NoColor: !isTerminal,
}))
slog.SetDefault(logger)
slog.Debug("gtrash version", "version", fmt.Sprintf("%+v", version))
slog.Debug("enviornment variable",
"HOME_TRASH_DIR", env.HOME_TRASH_DIR,
"ONLY_HOME_TRASH", env.ONLY_HOME_TRASH,
)
},
}
cmd.SetVersionTemplate("{{.Version}}")
cmd.PersistentFlags().BoolVar(&isDebug, "debug", false, "debug mode")
cmd.PersistentFlags()
// disable help subcommand
cmd.SetHelpCommand(&cobra.Command{
Use: "no-help",
Hidden: true,
})
// prefix program name
cmd.SetErrPrefix(fmt.Sprintf("%s: error:", progName))
// Add subcommands
cmd.AddCommand(
newPutCmd().cmd,
newFindCmd().cmd,
newRestoreCmd().cmd,
newRestoreGroupCmd().cmd,
newRemoveCmd().cmd,
newSummaryCmd().cmd,
newMetafixCmd().cmd,
newPruneCmd().cmd,
)
root.cmd = cmd
return root
}
================================================
FILE: internal/cmd/summary.go
================================================
package cmd
import (
"errors"
"fmt"
"github.com/dustin/go-humanize"
"github.com/spf13/cobra"
"github.com/umlx5h/gtrash/internal/glog"
"github.com/umlx5h/gtrash/internal/trash"
)
type summaryCmd struct {
cmd *cobra.Command
opts summaryOptions
}
type summaryOptions struct{}
func newSummaryCmd() *summaryCmd {
root := &summaryCmd{}
cmd := &cobra.Command{
Use: "summary",
Short: "Show summary of all trash cans (s)",
Aliases: []string{"s"},
Long: `Description:
Displays statistics summarizing all trash cans.
Shows the count of files (and folders) and their total size.
When multiple trash cans are detected, the statistics for each and the total are displayed.`,
SilenceUsage: true,
Args: cobra.NoArgs,
ValidArgsFunction: cobra.NoFileCompletions,
RunE: func(_ *cobra.Command, _ []string) error {
if err := summaryCmdRun(root.opts); err != nil {
return err
}
if glog.ExitCode() > 0 {
return errContinue
}
return nil
},
}
root.cmd = cmd
return root
}
func summaryCmdRun(_ summaryOptions) error {
box := trash.NewBox(
trash.WithGetSize(true),
)
if err := box.Open(); err != nil {
if !errors.Is(err, trash.ErrNotFound) {
return err
}
}
var (
totalSize int64
totalItem int
)
for i, trashDir := range box.TrashDirs {
var (
size int64
item int
)
for _, f := range box.FilesByTrashDir[trashDir] {
item++
if f.Size != nil {
size += *f.Size
}
}
fmt.Printf("[%s]\n", trashDir)
fmt.Printf("item: %d\n", item)
fmt.Printf("size: %s\n", humanize.Bytes(uint64(size)))
if i != len(box.TrashDirs)-1 {
fmt.Println("")
}
totalSize += size
totalItem += item
}
if len(box.TrashDirs) > 1 {
fmt.Printf("\n[total]\n")
fmt.Printf("item: %d\n", totalItem)
fmt.Printf("size: %s\n", humanize.Bytes(uint64(totalSize)))
}
return nil
}
================================================
FILE: internal/env/env.go
================================================
package env
import (
"fmt"
"os"
"path/filepath"
"strings"
)
var (
// Copy files to the trash can in the home directory when they cannot be renamed to the external trash can
// Disk usage of the main file system will increase because files are copied across different filesystems, also also take time to copy.
// Automatically enabled if ONLY_HOME_TRASH enabled
// Default: false
HOME_TRASH_FALLBACK_COPY bool
// Use only the trash can in the home directory, not the one in the external file system
// Default: false
ONLY_HOME_TRASH bool
// Specify the directory for home trash can
// Default: $XDG_DATA_HOME/Trash ($HOME/.local/share/Trash)
HOME_TRASH_DIR string
// Whether to get as close to rm behavior as possible
// Default: false
PUT_RM_MODE bool
)
func init() {
if e, ok := os.LookupEnv("GTRASH_HOME_TRASH_FALLBACK_COPY"); ok {
if strings.ToLower(strings.TrimSpace(e)) == "true" {
HOME_TRASH_FALLBACK_COPY = true
}
}
if e, ok := os.LookupEnv("GTRASH_ONLY_HOME_TRASH"); ok {
if strings.ToLower(strings.TrimSpace(e)) == "true" {
ONLY_HOME_TRASH = true
// Also enable this
HOME_TRASH_FALLBACK_COPY = true
}
}
if e, ok := os.LookupEnv("GTRASH_PUT_RM_MODE"); ok {
if strings.ToLower(strings.TrimSpace(e)) == "true" {
PUT_RM_MODE = true
}
}
if e, ok := os.LookupEnv("GTRASH_HOME_TRASH_DIR"); ok {
if e != "" {
path, err := filepath.Abs(e)
if err != nil {
fmt.Fprintf(os.Stderr, "ENV $GTRASH_HOME_TRASH_DIR is not valid path: %s", err)
os.Exit(1)
}
// Ensure to have directory in advance
if err := os.MkdirAll(path, 0o700); err != nil {
fmt.Fprintf(os.Stderr, "ENV $GTRASH_HOME_TRASH_DIR could not be created: %s", err)
os.Exit(1)
}
HOME_TRASH_DIR = path
}
}
}
================================================
FILE: internal/glog/logger.go
================================================
package glog
import (
"fmt"
"io"
"os"
"path/filepath"
)
var (
errorCalled int
progName = filepath.Base(os.Args[0])
)
var stderr io.Writer = os.Stderr
func Error(msg string) {
errorCalled++
fmt.Fprintln(stderr, progName+":", msg)
}
func Errorf(format string, args ...any) {
errorCalled++
fmt.Fprintf(stderr, progName+": "+format, args...)
}
func ExitCode() int {
if errorCalled == 0 {
return 0
} else {
return 1
}
}
================================================
FILE: internal/posix/dir.go
================================================
package posix
import (
"errors"
"io"
"os"
"path/filepath"
"syscall"
)
// same as du -B1 or du -sh
// 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).
func DirSize(path string) (int64, error) {
var block int64
err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
sys, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return errors.New("cannot get stat_t")
}
block += sys.Blocks
return err
})
return block * 512, err
}
// Look at both block-size and apparent-size and choose the larger one.
// Because there are file systems for which block size cannot be obtained.
// max(du -sB1, du -sb)
func DirSizeFallback(path string) (int64, error) {
var size int64
err := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
sys, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return errors.New("cannot get stat_t")
}
// stat(2)
// blkcnt_t st_blocks; /* Number of 512B blocks allocated */
size += max(sys.Size, sys.Blocks*512)
return err
})
return size, err
}
// check name path is empty directory
func DirEmpty(name string) (bool, error) {
f, err := os.Open(name)
if err != nil {
return false, err
}
defer f.Close()
_, err = f.Readdirnames(1)
if err == io.EOF {
return true, nil
}
return false, err
}
================================================
FILE: internal/posix/file.go
================================================
package posix
import (
"bufio"
"errors"
"fmt"
"io"
"io/fs"
"os"
"github.com/charmbracelet/lipgloss"
"github.com/umlx5h/go-runewidth"
)
func IsBinary(content io.ReadSeeker, fileSize int64) (bool, error) {
headSize := min(fileSize, 1024)
head := make([]byte, headSize)
if _, err := content.Read(head); err != nil {
return false, err
}
if _, err := content.Seek(0, io.SeekStart); err != nil {
return false, err
}
// ref: https://github.com/file/file/blob/5e33fd6ee7766d40382a084c8e7554c2d43c0b7e/src/encoding.c#L183-L260
for _, b := range head {
if b < 7 || b == 11 || (13 < b && b < 27) || (27 < b && b < 0x20) || b == 0x7f {
return true, nil
}
}
return false, nil
}
func FileHead(path string, width int, maxLines int) string {
fi, err := os.Lstat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return "(error: not found)"
} else {
return "(error: could not stat)"
}
}
content := func(isDir bool, lines []string) string {
if len(lines) == 0 {
if isDir {
return "(empty directory)\n"
} else {
return "(empty file)\n"
}
}
var content string
var i int
for _, line := range lines {
i++
content += fmt.Sprintf(" %s\n", line)
}
if isDir {
return "(directory)" + "\n" + content
} else {
return "(text)" + "\n" + content
}
}
var lines []string
var isDir bool
switch {
case fi.Mode().Type() == fs.ModeSymlink:
return "(symbolic link)"
case fi.IsDir():
isDir = true
dirs, _ := os.ReadDir(path)
for i, dir := range dirs {
if i == maxLines {
break
}
dinfo, err := dir.Info()
if err != nil {
return "(error: open directory)"
}
name := runewidth.Truncate(dir.Name(), width-15, "…")
if dir.IsDir() {
// folder is blue color
name = lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Render(name)
}
l := fmt.Sprintf("%s %s", dinfo.Mode().Perm().String(), name)
lines = append(lines, l)
}
case fi.Mode().IsRegular():
f, err := os.Open(path)
if err != nil {
return "(error: open file)"
}
defer f.Close()
if binary, err := IsBinary(f, fi.Size()); err != nil {
return "(error: read file)"
} else if binary {
return "(binary file)"
}
// if file is text, read maxLines lines
s := bufio.NewScanner(f)
var n int
for s.Scan() {
if n == maxLines {
break
}
t := s.Text()
// truncate to screen width
lines = append(lines, runewidth.Truncate(t, width-3, "…"))
n++
}
default:
return "(unknown file type)"
}
return content(isDir, lines)
}
func FileType(st fs.FileInfo) string {
if st.IsDir() {
return "directory"
} else if st.Mode().IsRegular() {
if st.Size() == 0 {
return "regular empty file"
} else {
return "regular file"
}
}
switch st.Mode().Type() {
case fs.ModeSymlink:
return "symbolic link"
case fs.ModeNamedPipe:
return "fifo"
case fs.ModeSocket:
return "socket"
}
return "unknown type file"
}
================================================
FILE: internal/posix/path.go
================================================
package posix
import (
"os"
"path/filepath"
"strings"
)
var euid int
func init() {
euid = os.Geteuid()
}
func AbsPathToTilde(absPath string) string {
// if executed as root, disable
if euid == 0 {
return absPath
}
homeDir, ok := os.LookupEnv("HOME")
if !ok {
return absPath
}
if strings.HasPrefix(absPath, homeDir) {
return strings.Replace(absPath, homeDir, "~", 1)
}
return absPath
}
// Check if sub is a subdirectory of parent
func CheckSubPath(parent, sub string) (bool, error) {
up := ".." + string(os.PathSeparator)
rel, err := filepath.Rel(parent, sub)
if err != nil {
return false, err
}
if !strings.HasPrefix(rel, up) && rel != ".." {
return true, nil
}
return false, nil
}
================================================
FILE: internal/posix/path_test.go
================================================
package posix
import (
"os"
"testing"
)
func TestAbsPathToTilde(t *testing.T) {
home := os.Getenv("HOME")
tests := []struct {
absPath string
expectedPath string
}{
{home + "/example/file.txt", "~/example/file.txt"},
{"/home/user/another/file.txt", "/home/user/another/file.txt"},
{"", ""},
}
for _, tt := range tests {
result := AbsPathToTilde(tt.absPath)
if result != tt.expectedPath {
t.Errorf("Expected %s, but got %s for path %s", tt.expectedPath, result, tt.absPath)
}
}
}
func TestCheckSubPath(t *testing.T) {
tests := []struct {
parentPath string
subPath string
expected bool
}{
{"/home/user", "/home/user/Documents", true},
{"/home/user", "/home/user/Documents/foo", true},
{"/home/user", "/home/user", true},
{"/home/user", "/var/www", false},
{"/home/user", "/home", false},
{"/", "/", true},
}
for _, tt := range tests {
result, err := CheckSubPath(tt.parentPath, tt.subPath)
if err != nil {
t.Errorf("Error occurred: %s", err)
}
if result != tt.expected {
t.Errorf("Expected %v, but got %v for parent: %q, sub: %q", tt.expected, result, tt.parentPath, tt.subPath)
}
}
}
================================================
FILE: internal/trash/flag.go
================================================
package trash
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"golang.org/x/exp/maps"
)
func FlagCompletionFunc(allCompletions []string) func(*cobra.Command, []string, string) (
[]string, cobra.ShellCompDirective,
) {
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
var completions []string
for _, completion := range allCompletions {
if strings.HasPrefix(completion, toComplete) {
completions = append(completions, completion)
}
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
}
// --sort, -s
var (
sortByWellKnownStrings = map[string]SortByType{
"date": SortByDeletedAt,
"size": SortBySize,
"name": SortByName,
}
SortByFlagCompletionFunc = FlagCompletionFunc(
maps.Keys(sortByWellKnownStrings),
)
)
func (s *SortByType) Set(str string) error {
if value, ok := sortByWellKnownStrings[strings.ToLower(str)]; ok {
*s = value
return nil
}
return fmt.Errorf("must be %s", s.Type())
}
func (s SortByType) String() string {
switch s {
case SortByDeletedAt:
return "date"
case SortBySize:
return "size"
case SortByName:
return "name"
default:
panic("invalid SortByType value")
}
}
func (s SortByType) Type() string {
return "date|size|name"
}
// --mode, -m
var _ pflag.Value = (*ModeByType)(nil)
type ModeByType int
const (
ModeByRegex ModeByType = iota
ModeByGlob // default
ModeByLiteral
ModeByFull
)
var (
modeByWellKnownStrings = map[string]ModeByType{
"regex": ModeByRegex,
"glob": ModeByGlob,
"literal": ModeByLiteral,
"full": ModeByFull,
}
ModeByFlagCompletionFunc = FlagCompletionFunc(
maps.Keys(modeByWellKnownStrings),
)
)
func (s *ModeByType) Set(str string) error {
if value, ok := modeByWellKnownStrings[strings.ToLower(str)]; ok {
*s = value
return nil
}
return fmt.Errorf("must be %s", s.Type())
}
func (s ModeByType) String() string {
switch s {
case ModeByGlob:
return "glob"
case ModeByRegex:
return "regex"
case ModeByLiteral:
return "literal"
case ModeByFull:
return "full"
default:
panic("invalid ModeByType value")
}
}
func (s ModeByType) Type() string {
return "regex|glob|literal|full"
}
================================================
FILE: internal/trash/trash.go
================================================
package trash
import (
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/dustin/go-humanize"
"github.com/gobwas/glob"
"github.com/spf13/pflag"
"github.com/umlx5h/gtrash/internal/posix"
"github.com/umlx5h/gtrash/internal/xdg"
)
var _ pflag.Value = (*SortByType)(nil)
type SortByType int
const (
SortByDeletedAt SortByType = iota // default
SortBySize
SortByName
)
type Box struct {
Files []File
FilesByTrashDir map[string][]File // key: trash_dir, value: array of Files
TrashDirs []string
hitByPath map[string]int // key: originalPath, value: number of files to hit
OrphanMeta []File // .trashinfo exists but there is no real file in the files folder
// set by cli flags
// sort options
ascend bool
sortBy SortByType
// filter options
cwd bool
directory string
queries []string
queriesReg []*regexp.Regexp
queriesGlob []glob.Glob
queryModeBy ModeByType
// filter by date
day int
dayPoint time.Time // --day-new, --day-old
newer bool
// filter by size
size uint64 // byte, convert from sizeHuman
sizeHuman string // human size (e.g. 10MB)
sizeLarger bool // if true, filter by size > X
trashDir string // $HOME/.local/share/Trash
// Whether to use stat(2) to get size and mode
GetSize bool
noFilterApply bool // true if select all trashcan
limitLast int
}
func NewBox(opts ...BoxOption) Box {
b := Box{
FilesByTrashDir: make(map[string][]File),
hitByPath: make(map[string]int),
}
for _, o := range opts {
o(&b)
}
return b
}
type BoxOption func(*Box)
func WithAscend(ascend bool) BoxOption {
return func(b *Box) {
b.ascend = ascend
}
}
func WithTrashDir(trashDir string) BoxOption {
return func(b *Box) {
b.trashDir = trashDir
}
}
func WithSortBy(sortBy SortByType) BoxOption {
return func(b *Box) {
b.sortBy = sortBy
}
}
func WithDirectory(directory string) BoxOption {
return func(b *Box) {
b.directory = directory
}
}
func WithCWD(cwd bool) BoxOption {
return func(b *Box) {
b.cwd = cwd
}
}
func WithQueries(queries []string) BoxOption {
return func(b *Box) {
b.queries = queries
}
}
func WithQueryMode(mode ModeByType) BoxOption {
return func(b *Box) {
b.queryModeBy = mode
}
}
// TODO: Support for notations other than day?
func WithDay(dayNew int, dayOld int) BoxOption {
day := max(dayNew, dayOld) // either specified
point := time.Now().AddDate(0, 0, -day)
return func(b *Box) {
b.day = day
b.dayPoint = point
b.newer = dayNew > 0
}
}
func WithLimitLast(last int) BoxOption {
return func(b *Box) {
b.limitLast = last
}
}
func WithSize(large string, small string) BoxOption {
var larger bool
size := small
if large != "" {
larger = true
size = large
}
return func(b *Box) {
b.sizeHuman = size // either specified
b.sizeLarger = larger
}
}
func WithGetSize(get bool) BoxOption {
return func(b *Box) {
b.GetSize = get
}
}
// validate and adjust options
func (b *Box) checkOptions() error {
var err error
// convert to absolute path
if b.directory != "" {
if abs, err := filepath.Abs(b.directory); err == nil {
b.directory = abs
}
}
// get cwd
if b.cwd {
b.directory, err = os.Getwd()
if err != nil {
return fmt.Errorf("-c,--cwd get cwd: %w", err)
}
}
// compile queries
if len(b.queries) > 0 {
switch b.queryModeBy {
case ModeByRegex:
regs := make([]*regexp.Regexp, len(b.queries))
for i, q := range b.queries {
r, err := regexp.Compile(q)
if err != nil {
return fmt.Errorf("regex syntax in query is not valid: %q: %q", q, err)
}
regs[i] = r
}
b.queriesReg = regs
case ModeByGlob:
globs := make([]glob.Glob, len(b.queries))
for i, q := range b.queries {
g, err := glob.Compile(q)
if err != nil {
return fmt.Errorf("glob syntax in query is not valid: %q: %w", q, err)
}
globs[i] = g
}
b.queriesGlob = globs
}
}
// compile human-size byte to byte
if b.sizeHuman != "" {
byte, err := humanize.ParseBytes(b.sizeHuman)
if err != nil {
return fmt.Errorf("--size unit is invalid: %w", err)
}
b.size = byte
}
// set GetSize true based options
if !b.GetSize {
b.GetSize = b.sizeHuman != "" || b.sortBy == SortBySize
}
// validate as absolute path and normalize and check existence
if b.trashDir != "" {
// validate
if !filepath.IsAbs(b.trashDir) {
return fmt.Errorf("--trash-dir is not absolute path")
}
// normalize
b.trashDir, _ = filepath.Abs(b.trashDir)
// check existence
if fi, err := os.Stat(b.trashDir); err != nil {
return fmt.Errorf("--trash-dir must be a existing directory: %w", err)
} else {
if !fi.IsDir() {
return fmt.Errorf("--trash-dir must be a directory")
}
}
}
// check if select all trashcan
if len(b.queries) == 0 && b.sizeHuman == "" && b.day == 0 && b.directory == "" {
b.noFilterApply = true
}
return nil
}
var ErrNotFound = errors.New("not found")
func (b *Box) Open() error {
// validation Box options
if err := b.checkOptions(); err != nil {
return err
}
var trashDirs []xdg.TrashDir
if b.trashDir == "" {
// Automatically searches for trash can paths by default
slog.Debug("scanning trash directories")
// Retrieve trash from all mount points
trashDirs = xdg.ScanTrashDirs()
if len(trashDirs) == 0 {
return fmt.Errorf("%w: trash directories", ErrNotFound)
}
slog.Debug("found trash directories", "number", len(trashDirs), "trashDirs", trashDirs)
} else {
// If --trash-dir is specified, it is used as is.
slog.Debug("using manual trash directory", "trashDir", b.trashDir)
trashDirs = []xdg.TrashDir{xdg.NewTrashDirManual(b.trashDir)}
}
for _, trashDir := range trashDirs {
slog.Debug("starting to read trashDir", "trashDir", trashDir.Dir)
// Scan the files directory to check for the existence of files.
// Whether the file is a directory or not can be obtained at this stage.
dirents, err := os.ReadDir(trashDir.FilesDir())
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
slog.Warn("cannot read files folder in trashDir, skipped", "trashDir", trashDir, "error", err)
continue
}
// files folder not exists in this mountpoint
slog.Debug("not found files folder in trashDir, skipped", "trashDir", trashDir)
continue
}
// convert to slices to map
fileEntries := make(map[string]bool, len(dirents)) // key: filename, value: isDir
for _, ent := range dirents {
fileEntries[ent.Name()] = ent.IsDir()
}
dirents, err = os.ReadDir(trashDir.InfoDir())
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
slog.Warn("cannot read info folder in trashDir, skipped", "trashDir", trashDir, "error", err)
continue
}
slog.Debug("not found info folder in trashDir, skipped", "trashDir", trashDir)
continue
}
// Load directory size cache into map
// Not used when nil.
var dirCache xdg.DirCache // key: directory name, value: cache entry
directorySizesPath := filepath.Join(trashDir.Dir, "directorysizes")
if b.GetSize {
// init map
dirCache = make(xdg.DirCache)
slog.Debug("reading directorysizes cache", "path", directorySizesPath)
if f, err := os.Open(directorySizesPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
slog.Debug("not found directorysizes cache", "path", directorySizesPath, "error", err)
} else {
slog.Warn("failed to read directorysizes cache", "path", directorySizesPath)
}
} else {
if c, err := xdg.NewDirCache(f); err != nil {
slog.Warn("failed to parse directorysizes cache, it will be recreated", "path", directorySizesPath, "error", err)
} else {
// got cache from file
dirCache = c
}
f.Close()
}
}
slog.Debug("starting to read directory entries", "file_entries", len(fileEntries), "info_entries", len(dirents))
// True if the cache expires or an entry is added.
var dirCacheUpdated bool
files := b.getFiles(dirents, fileEntries, trashDir, dirCache, &dirCacheUpdated)
slog.Debug("found trashed files", "number", len(files), "trashDir", trashDir.Dir)
// save directorysize cache
if dirCache != nil && dirCacheUpdated {
slog.Debug("saving directorysizes cache", "path", directorySizesPath, "isTruncate", b.noFilterApply)
// When all selections are made, the cache file is rewritten.
// (To delete old entries that are no longer needed.)
if err := dirCache.Save(trashDir.Dir, b.noFilterApply); err != nil { // if
slog.Warn("failed to save directorysizes cache", "path", directorySizesPath, "error", err)
}
}
b.TrashDirs = append(b.TrashDirs, trashDir.Dir)
if len(files) > 0 {
// TODO: perf: run only when necessary
sortFiles(files, b.sortBy, b.ascend)
b.FilesByTrashDir[trashDir.Dir] = files
}
b.Files = append(b.Files, files...)
}
if len(b.Files) == 0 {
return fmt.Errorf("%w: trashed files", ErrNotFound)
}
sortFiles(b.Files, b.sortBy, b.ascend)
// truncate to last n items
if b.limitLast > 0 {
if len(b.Files) > b.limitLast {
n := len(b.Files)
b.Files = b.Files[n-b.limitLast : n]
}
}
return nil
}
func (b *Box) getFiles(dirents []fs.DirEntry, fileEntries map[string]bool, trashDir xdg.TrashDir, dirCache xdg.DirCache, dirCacheUpdated *bool) []File {
var files []File
for _, ent := range dirents {
if ent.Type().IsRegular() && strings.HasSuffix(ent.Name(), ".trashinfo") {
trashInfoPath := filepath.Join(trashDir.InfoDir(), ent.Name())
if strings.HasPrefix(ent.Name(), "._") {
// exclude mac resource fork
slog.Debug("skipped mac resource fork of .trashinfo", "path", trashInfoPath)
continue
}
f, err := os.Open(trashInfoPath)
if err != nil {
slog.Warn("failed to open .trashinfo, skipped", "path", trashInfoPath, "error", err)
continue
}
info, err := xdg.NewInfo(f)
// It is better to close each time from a performance standpoint.
f.Close()
if err != nil {
slog.Warn("failed to parse .trashinfo, skipped", "path", trashInfoPath, "error", err)
continue
}
if !strings.HasPrefix(info.Path, string(os.PathSeparator)) {
// If it was a relative path, convert it to an absolute path
info.Path = filepath.Join(trashDir.Root, info.Path)
}
trashFileName := strings.TrimSuffix(ent.Name(), ".trashinfo")
file := File{
Name: filepath.Base(info.Path),
OriginalPath: info.Path,
TrashPath: filepath.Join(trashDir.FilesDir(), trashFileName),
TrashInfoPath: trashInfoPath,
DeletedAt: info.DeletionDate,
IsDir: fileEntries[trashFileName],
}
// If the corresponding trashed file does not exist, it is assumed to be invalid metadata and skipped
if _, ok := fileEntries[trashFileName]; !ok {
slog.Debug("file in the meta information does not exist, skipped", "trashInfoPath", file.TrashInfoPath, "trashPath", file.TrashPath)
b.OrphanMeta = append(b.OrphanMeta, file)
continue
}
// filter by directory
if b.directory != "" {
subpath, _ := posix.CheckSubPath(b.directory, file.OriginalPath)
if !subpath {
continue
}
}
// filter by original path
if len(b.queries) > 0 {
switch b.queryModeBy {
case ModeByFull:
if !slices.Contains(b.queries, file.OriginalPath) {
continue
}
case ModeByLiteral:
var match bool
for _, q := range b.queries {
if strings.Contains(file.OriginalPath, q) {
match = true
break
}
}
if !match {
continue
}
case ModeByRegex:
var match bool
for _, reg := range b.queriesReg {
if reg.MatchString(file.OriginalPath) {
match = true
break
}
}
if !match {
continue
}
case ModeByGlob:
var match bool
for _, glob := range b.queriesGlob {
if glob.Match(file.OriginalPath) {
match = true
break
}
}
if !match {
continue
}
}
}
// filter by deletedAt
if b.day > 0 {
if b.newer {
if b.dayPoint.After(info.DeletionDate) {
continue
}
} else {
if b.dayPoint.Before(info.DeletionDate) {
continue
}
}
}
// calculate file or directory size
if b.GetSize {
fi, err := os.Lstat(file.TrashPath)
if err != nil {
slog.Warn("cannot lstat(2) to the trashed file for getting size", "trashPath", file.TrashPath, "error", err)
goto BREAK_GET_SIZE
}
file.Mode = fi.Mode()
file.IsDir = fi.IsDir()
if !fi.IsDir() {
// if regular file
// Files can be retrieved by stat.
s := fi.Size()
file.Size = &s
goto BREAK_GET_SIZE
}
// For directory, refer to cache and recursively calculate size if cache misses
// Check the update time of the trashinfo file to see if the cache has become stale
fi, err = os.Stat(file.TrashInfoPath)
if err != nil {
// Since the file has already been loaded, it is unlikely to reach this point
slog.Warn("cannot stat(2) to the trashinfo file for calculating directory size", "trashInfoPath", file.TrashInfoPath, "error", err)
goto BREAK_GET_SIZE
}
// if directory, get size recursively while referring to cache
var size int64
// check cache entry
if item, ok := dirCache[trashFileName]; ok && item.Item.Mtime.Unix() == fi.ModTime().Unix() {
// cache hit and cache is not stale
size = item.Item.Size
item.Seen = true
} else {
if item == nil {
slog.Debug("calculating directory size", "reason", "CACHE_NOT_HIT", "trashPath", file.TrashPath)
} else {
slog.Debug("calculating directory size", "reason", "CACHE_STALE", "trashPath", file.TrashPath)
}
*dirCacheUpdated = true
// calculate directory size
s, err := posix.DirSizeFallback(file.TrashPath)
if err != nil {
// Even if rename(2) succeeds, the file inside may not be readable depending on the permissions.
slog.Warn("cannot calculate directory size", "trashPath", file.TrashPath, "error", err)
// Delete from cache because size could not be retrieved
// noop when there is no cache
delete(dirCache, trashFileName)
goto BREAK_GET_SIZE
}
size = s
// update cache
if item == nil {
// cache not hit
// add cache entry
dirCache[trashFileName] = &struct {
Item xdg.DirCacheItem
Seen bool
}{
Item: xdg.DirCacheItem{
Size: size,
Mtime: fi.ModTime(),
DirName: trashFileName,
},
Seen: true,
}
} else {
// cache hit but stale
// update new size and mtime
item.Item.Size = size
item.Item.Mtime = fi.ModTime()
item.Seen = true
}
}
// succeed to get folder size
file.Size = &size
}
BREAK_GET_SIZE:
// filter by size
if b.sizeHuman != "" { // See sizeHuman to allow filtering even with 0
// If the size is not obtained, it is nil then skipped.
if file.Size == nil {
continue
}
if b.sizeLarger {
if uint64(*file.Size) < b.size {
continue
}
} else {
if uint64(*file.Size) > b.size {
continue
}
}
}
b.hitByPath[file.OriginalPath]++
files = append(files, file)
}
}
return files
}
// TODO: refactor
func sortFiles(files []File, sortBy SortByType, ascend bool) {
switch sortBy {
case SortByDeletedAt: // default
sort.Slice(files, func(i, j int) bool {
if !ascend {
i, j = j, i
}
return files[i].DeletedAt.Before(files[j].DeletedAt)
})
case SortBySize:
sort.Slice(files, func(i, j int) bool {
if !ascend {
i, j = j, i
}
// If size is not available, treat as less than 0
si, sj := files[i].Size, files[j].Size
var minus int64 = -1
if si == nil {
si = &minus
}
if sj == nil {
sj = &minus
}
return *si < *sj
})
case SortByName:
sort.Slice(files, func(i, j int) bool {
if !ascend {
i, j = j, i
}
return files[i].OriginalPath < files[j].OriginalPath
})
}
}
func (b *Box) HitByPath(originalPath string) int {
return b.hitByPath[originalPath]
}
type File struct {
Name string // .vimrc
OriginalPath string // ~/.vimrc (Info.Path)
TrashPath string // ~/.local/share/Trash/files/.vimrc
TrashInfoPath string // ~/.local/share/Trash/info/.vimrc.trashinfo
DeletedAt time.Time // 2023-01-01T00:00:00 (Info.DeletionDate)
IsDir bool
// optionals below
Size *int64 // nil if could not get, It may not be able to be taken due to permission violation, etc.
Mode fs.FileMode
}
type Group struct {
Dir string
IsDirCommon bool // Whether Dir is the same for all files
DeletedAt time.Time // pick one from Files
Files []File
}
func (f *File) OriginalPathFormat(tilde bool, color bool) string {
p := f.OriginalPath
if tilde {
p = posix.AbsPathToTilde(p)
}
if color {
return f.pathColor(p)
} else {
return p
}
}
func (f *File) TrashPathColor() string {
return f.pathColor(f.TrashPath)
}
func (f *File) SizeHuman() string {
if f.Size == nil {
return "-"
} else {
return humanize.Bytes(uint64(*f.Size))
}
}
func (f *File) pathColor(s string) string {
var color lipgloss.Color
if f.IsDir {
color = lipgloss.Color("12") // blue
} else if f.Mode != 0 {
switch {
case f.Mode&0o111 > 0: // may be binary (x flag being set)
color = lipgloss.Color("9") // red
}
}
return lipgloss.NewStyle().Foreground(color).Render(s)
}
func (b *Box) ToGroups() []Group {
files := b.Files
// group by deletedAt
filesByDeletedAt := make(map[time.Time][]File)
for _, file := range files {
filesByDeletedAt[file.DeletedAt] = append(filesByDeletedAt[file.DeletedAt], file)
}
hasMultiDirs := func(files []File) bool {
var dirs []string
unique := make(map[string]bool)
for _, file := range files {
dir := filepath.Dir(file.OriginalPath)
if !unique[dir] {
unique[dir] = true
dirs = append(dirs, dir)
}
}
return len(dirs) > 1
}
var groups []Group
for deletedAt, files := range filesByDeletedAt {
dir := filepath.Dir(files[0].OriginalPath)
isDirCommon := true
if hasMultiDirs(files) {
dir = "(multiple directories)"
isDirCommon = false
}
groups = append(groups, Group{
Dir: dir,
DeletedAt: deletedAt,
Files: files,
IsDirCommon: isDirCommon,
})
}
sort.Slice(groups, func(i, j int) bool {
return groups[i].DeletedAt.After(groups[j].DeletedAt)
})
return groups
}
func (f *File) Delete() error {
slog.Debug("removing .trashinfo", "trashInfoPath", f.TrashInfoPath)
return os.Remove(f.TrashInfoPath)
}
================================================
FILE: internal/tui/boolInputModel.go
================================================
package tui
import (
"errors"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type boolInputModel struct {
textInput textinput.Model
confirmed bool
}
func yesno(s string) (bool, string, error) {
if s == "" {
return false, "", errors.New("empty")
}
switch strings.ToLower(s[0:1]) {
case "y":
return true, "Yes", nil
case "n":
return false, "No", nil
}
return false, "", errors.New("unknown")
}
func newBoolInputModel(prompt string) boolInputModel {
textInput := textinput.New()
textInput.Prompt = prompt
textInput.Placeholder = "(Yes/No)"
textInput.Validate = func(value string) error {
_, _, err := yesno(value)
return err
}
textInput.Focus()
return boolInputModel{
textInput: textInput,
}
}
func (m boolInputModel) Confirmed() bool {
return m.confirmed
}
func (m boolInputModel) Init() tea.Cmd {
return textinput.Blink
}
func (m boolInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.Type {
case tea.KeyCtrlC, tea.KeyEsc:
m.textInput.Blur()
return m, tea.Quit
}
}
var cmd tea.Cmd
m.textInput, cmd = m.textInput.Update(msg)
if _, value, err := yesno(m.textInput.Value()); err == nil {
m.textInput.Blur()
m.textInput.SetValue(value)
m.confirmed = true
return m, tea.Quit
}
return m, cmd
}
func (m boolInputModel) Value() bool {
valueStr := m.textInput.Value()
v, _, _ := yesno(valueStr)
return v
}
func (m boolInputModel) View() string {
return m.textInput.View() + "\n"
}
================================================
FILE: internal/tui/choiceInputModel.go
================================================
package tui
import (
"errors"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type choiceInputModel struct {
textInput textinput.Model
keys map[string]string
confirmed bool
}
func newChoiceInputModel(prompt string, choices []string) choiceInputModel {
textInput := textinput.New()
textInput.Prompt = prompt
for i := range choices {
choices[i] = strings.ToUpper(choices[i][:1]) + choices[i][1:]
}
textInput.Placeholder = "(" + strings.Join(choices, "/") + ")"
keys := make(map[string]string)
for _, choice := range choices {
keys[strings.ToLower(choice[0:1])] = choice
}
textInput.Validate = func(s string) error {
if s == "" {
return errors.New("empty")
}
if _, ok := keys[strings.ToLower(s[0:1])]; ok {
return nil
}
return errors.New("unknown")
}
textInput.Focus()
return choiceInputModel{
textInput: textInput,
keys: keys,
}
}
func (m choiceInputModel) Confirmed() bool {
return m.confirmed
}
func (m choiceInputModel) Init() tea.Cmd {
return textinput.Blink
}
func (m choiceInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.Type {
case tea.KeyCtrlC, tea.KeyEsc:
m.textInput.Blur()
return m, tea.Quit
}
}
var cmd tea.Cmd
m.textInput, cmd = m.textInput.Update(msg)
if value, ok := m.keys[strings.ToLower(m.textInput.Value())]; ok {
m.textInput.Blur()
m.textInput.SetValue(value)
m.confirmed = true
return m, tea.Quit
}
return m, cmd
}
func (m choiceInputModel) Value() string {
value := m.textInput.Value()
return strings.ToLower(value)
}
func (m choiceInputModel) View() string {
return m.textInput.View() + "\n"
}
================================================
FILE: internal/tui/multiRestore.go
================================================
package tui
import (
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/dustin/go-humanize"
"github.com/umlx5h/gtrash/internal/posix"
"github.com/umlx5h/gtrash/internal/trash"
"github.com/umlx5h/gtrash/internal/tui/table"
"golang.org/x/term"
)
const (
paddingHeight = 6
shortWidth = 90
)
var notFocusBorderStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240"))
var focusBorderStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("70"))
var greyStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("246"))
var inputCursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("70"))
var baseHelp = help.New()
// 'Konsole Terminal' will collapse the table display, but if the width is not shortened, the layout will collapse even further, so it should be handled individually.
var isKonsole bool
var (
focusRowStyle = table.DefaultStyles()
notFocusRowStyle = table.DefaultStyles()
)
func init() {
focusRowStyle.Header = focusRowStyle.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("70")).
BorderBottom(true).
Bold(false)
focusRowStyle.Selected = focusRowStyle.Selected.
Foreground(lipgloss.Color("15")).
Background(lipgloss.Color("240")).
Bold(true)
notFocusRowStyle.Header = notFocusRowStyle.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(false)
notFocusRowStyle.Selected = notFocusRowStyle.Selected.
Foreground(lipgloss.Color("246")).
Background(lipgloss.Color("240")).
Bold(false)
// help text color lighter
baseHelp.Styles.ShortKey = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#909090",
// Dark: "#626262",
Dark: "246",
})
baseHelp.Styles.ShortDesc = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#B2B2B2",
// Dark: "#4A4A4A",
Dark: "242",
})
baseHelp.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
Light: "#DDDADA",
// Dark: "#3C3C3C",
Dark: "239",
})
baseHelp.Styles.Ellipsis = baseHelp.Styles.ShortSeparator.Copy()
baseHelp.Styles.FullKey = baseHelp.Styles.ShortKey.Copy()
baseHelp.Styles.FullDesc = baseHelp.Styles.ShortDesc.Copy()
baseHelp.Styles.FullSeparator = baseHelp.Styles.ShortSeparator.Copy()
if _, ok := os.LookupEnv("KONSOLE_VERSION"); ok {
isKonsole = true
}
}
var baseKeymap = keymap{
quit: key.NewBinding(
key.WithKeys("q", "ctrl+c"),
key.WithHelp("q/CTRL-C", "quit"),
),
runRestore: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("Enter", "restore"),
),
filter: key.NewBinding(
key.WithKeys("/"),
key.WithHelp("/", "filter"),
),
clear: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("ESC", "clear filter"),
),
pageup: key.NewBinding(
key.WithKeys("u", "pgup"),
key.WithHelp("u/PageUp", "page up"),
),
pagedown: key.NewBinding(
key.WithKeys("d", "pgdn"),
key.WithHelp("d/PageDown", "page down"),
),
top: key.NewBinding(
key.WithKeys("g", "home"),
key.WithHelp("g/Home", "go to top"),
),
bottom: key.NewBinding(
key.WithKeys("G", "end"),
key.WithHelp("G/End", "go to bottom"),
),
}
type keymap struct {
help, quit, focus, moveRight, moveLeft, runRestore, filter key.Binding
move, moveRightALL, moveLeftALL, filterCWD, clear, pageup, pagedown, top, bottom, togglePreview key.Binding
}
type filterTable struct {
title string
t table.Model
input textinput.Model
hit, total, hitWidth int // updated when filtering
}
func (t *filterTable) getSelectedIdx() int {
idx, err := strconv.Atoi(t.t.SelectedRow()[0])
if err != nil {
panic(err)
}
return idx - 1
}
func (t *filterTable) updateInputPrompt(cwd bool) {
// TODO: Set hit to "-" when no filter is applied
// (to distinguish between unfiltered and all hits)
if cwd {
t.input.Prompt = fmt.Sprintf("%s (cwd) %*d/%d > ", t.title, t.hitWidth, t.hit, t.total)
} else {
t.input.Prompt = fmt.Sprintf("%s %*d/%d > ", t.title, t.hitWidth, t.hit, t.total)
}
}
func (m *multiRestoreModel) updateHit() {
m.trashTable.total = len(m.files) - len(m.selected)
m.trashTable.hit = len(m.trashTable.t.Rows())
m.trashTable.updateInputPrompt(m.filterCWD)
m.restoreTable.total = len(m.selected)
m.restoreTable.hit = len(m.restoreTable.t.Rows())
m.restoreTable.updateInputPrompt(false)
}
// Convert from rows to an array of indices
func (t *filterTable) getIndices() []int {
rows := t.t.Rows()
if len(rows) == 0 {
return nil
}
indices := make([]int, len(rows))
for n, r := range rows {
idx, err := strconv.Atoi(r[0])
if err != nil {
panic(err)
}
indices[n] = idx - 1
}
return indices
}
func (t filterTable) View(focus bool) string {
var body strings.Builder
body.WriteString(" " + t.input.View() + "\n")
if focus {
body.WriteString(focusBorderStyle.Render(t.t.View()))
} else {
body.WriteString(notFocusBorderStyle.Render(t.t.View()))
}
return body.String()
}
var _ tea.Model = multiRestoreModel{}
type multiRestoreModel struct {
width int
height int
fixedWidth int
tableHeight int
wrapStyle lipgloss.Style
keymap keymap
help help.Model
trashTable *filterTable // left table
restoreTable *filterTable // right table
files []trash.File // table source
selected map[int]struct{} // selected the indices of files
rightFocus bool // focus to restoreTable
showHelp bool
showPreview bool
filterCWD bool
filesCWD map[int]struct{} // Specify the indices of files when filtered by cwd
confirmed bool // true when confirmed by pressing Enter
restoreFiles []trash.File // return value
}
func (m *multiRestoreModel) getFocusTable() (focus *filterTable, notFocus *filterTable) {
if !m.rightFocus {
return m.trashTable, m.restoreTable
} else {
return m.restoreTable, m.trashTable
}
}
func (m *multiRestoreModel) getRestoreFiles() []trash.File {
if len(m.selected) == 0 {
return nil
}
files := make([]trash.File, len(m.selected))
indices := make([]int, len(m.selected))
var i int
for idx := range m.selected {
indices[i] = idx
i++
}
sort.Slice(indices, func(i, j int) bool {
return indices[i] < indices[j]
})
for i, idx := range indices {
files[i] = m.files[idx]
}
return files
}
func makeFileRow(idx int, f trash.File) table.Row {
return []string{
strconv.Itoa(idx + 1),
humanize.Time(f.DeletedAt),
// Prevent color display problems with table records
strings.TrimSuffix(f.OriginalPathFormat(true, true), "\033[0m"),
}
}
func getTermSize() (width int, height int) {
w, h, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil {
panic(err)
}
return w, h
}
func makeFilterTables(files []trash.File) (left, right filterTable, fixedWidth, tableHeight int) {
width, height := getTermSize()
var (
dateWidth int
noWidth = len(strconv.Itoa(len(files)))
)
if noWidth <= 1 {
noWidth = 2
}
if isKonsole {
noWidth += 1
}
rows := make([]table.Row, len(files))
for i, f := range files {
r := makeFileRow(i, f)
// Only ASCII characters are used, so it should match the character length
w := len(r[1])
if w > dateWidth {
dateWidth = w
}
rows[i] = r
}
paddingWidth := 4 * 2 // (columns + 1) * 2
fixedWidth = noWidth + dateWidth + paddingWidth
// make table shorter
if isKonsole {
fixedWidth += 4
}
pathWidth := (width / 2) - fixedWidth
// Must be separate instances to prevent data race
getColumn := func() []table.Column {
columns := []table.Column{
{Title: "No", Width: noWidth},
{Title: "DeletedAt", Width: dateWidth},
{Title: "Path", Width: pathWidth},
}
return columns
}
tableHeight = int(float64(height)*0.55) - paddingHeight
leftInput := textinput.New()
leftInput.PromptStyle = greyStyle
leftInput.Cursor.Style = inputCursorStyle
left = filterTable{
title: "Trash",
total: len(rows),
hit: len(rows),
hitWidth: len(strconv.Itoa(len(rows))),
t: table.New(
table.WithColumns(getColumn()),
table.WithHeight(tableHeight),
table.WithRows(rows),
table.WithFocused(true),
table.WithStyles(focusRowStyle),
table.WithShortColumn(1, 2),
),
input: leftInput,
}
left.updateInputPrompt(false)
rightInput := textinput.New()
rightInput.PromptStyle = greyStyle
rightInput.Cursor.Style = inputCursorStyle
right = filterTable{
title: "Restore",
hit: 0,
total: 0,
hitWidth: left.hitWidth,
t: table.New(
table.WithColumns(getColumn()),
table.WithHeight(tableHeight),
table.WithStyles(notFocusRowStyle),
table.WithShortColumn(1, 2),
),
input: rightInput,
}
right.t.SetShortMode(true)
right.updateInputPrompt(false)
return left, right, fixedWidth, tableHeight
}
func newMultiRestoreModel(files []trash.File) multiRestoreModel {
trashTable, restoreTable, fixedWidth, tableHeight := makeFilterTables(files)
width, height := getTermSize()
h := baseHelp
km := baseKeymap
km.help = key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "help"),
)
km.focus = key.NewBinding(
key.WithKeys("tab"),
key.WithHelp("TAB", "focus"),
)
km.moveRight = key.NewBinding(
key.WithKeys("l", "right"),
key.WithHelp("l/→", "move right"),
)
km.moveLeft = key.NewBinding(
key.WithKeys("h", "left"),
key.WithHelp("h/←", "move left"),
)
km.moveRightALL = key.NewBinding(
key.WithKeys("L"),
key.WithHelp("L", "move right all"),
)
km.moveLeftALL = key.NewBinding(
key.WithKeys("H"),
key.WithHelp("H", "move left all"),
)
km.move = key.NewBinding(
key.WithKeys(" "),
key.WithHelp("Space", "move other side"),
)
km.filterCWD = key.NewBinding(
key.WithKeys("c"),
key.WithHelp("c", "filter by cwd"),
)
km.togglePreview = key.NewBinding(
key.WithKeys("p"),
key.WithHelp("p", "toggle preview"),
)
m := multiRestoreModel{
trashTable: &trashTable,
restoreTable: &restoreTable,
width: width,
height: height,
fixedWidth: fixedWidth,
tableHeight: tableHeight,
wrapStyle: lipgloss.NewStyle().Width(width).Height(height).MaxWidth(width).MaxHeight(height),
showPreview: true,
files: files,
help: h,
selected: make(map[int]struct{}),
keymap: km,
}
return m
}
func (m *multiRestoreModel) updateScreenSize() {
m.wrapStyle = m.wrapStyle.Width(m.width).Height(m.height).MaxWidth(m.width).MaxHeight(m.height)
if m.width < shortWidth {
m.trashTable.t.SetShortMode(true)
} else {
m.trashTable.t.SetShortMode(false)
}
// symmetry
// newWidth := m.width/2 - m.fixedWidth
// m.trashTable.t.SetColWidthLast(newWidth)
// m.restoreTable.t.SetColWidthLast(newWidth)
// Make the table on the left a little larger.
m.trashTable.t.SetColWidthLast(int(float64(m.width)*0.6) - m.fixedWidth)
m.restoreTable.t.SetColWidthLast(int(float64(m.width)*0.4) - m.fixedWidth)
newHeight := int(float64(m.height)*0.55 - paddingHeight)
if newHeight < 1 {
newHeight = 0
}
m.trashTable.t.SetHeight(newHeight)
m.restoreTable.t.SetHeight(newHeight)
m.tableHeight = newHeight
}
func (m multiRestoreModel) Init() tea.Cmd {
return nil
}
func (m multiRestoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
ft, nft := m.getFocusTable()
switch msg := msg.(type) {
case tea.KeyMsg:
// when focused to table
if ft.t.Focused() {
switch {
case key.Matches(msg, m.keymap.focus):
ft.t.Blur()
ft.t.SetStyles(notFocusRowStyle)
nft.t.Focus()
nft.t.SetStyles(focusRowStyle)
m.rightFocus = !m.rightFocus
case key.Matches(msg, m.keymap.moveRight):
if !m.rightFocus {
m.moveRow()
}
case key.Matches(msg, m.keymap.moveLeft):
if m.rightFocus {
m.moveRow()
}
case key.Matches(msg, m.keymap.move):
m.moveRow()
case key.Matches(msg, m.keymap.moveRightALL):
if !m.rightFocus {
m.moveRowALL()
}
case key.Matches(msg, m.keymap.moveLeftALL):
if m.rightFocus {
m.moveRowALL()
}
case key.Matches(msg, m.keymap.quit):
return m, tea.Quit
case key.Matches(msg, m.keymap.filter):
ft.t.Blur()
ft.input.Focus()
return m, nil
case key.Matches(msg, m.keymap.clear):
if ft.input.Value() != "" {
ft.input.Reset()
m.filterApply()
}
return m, nil
case key.Matches(msg, m.keymap.help):
m.showHelp = !m.showHelp
return m, nil
case key.Matches(msg, m.keymap.togglePreview):
m.showPreview = !m.showPreview
return m, nil
case key.Matches(msg, m.keymap.filterCWD):
// only used in left table
if m.rightFocus {
return m, nil
}
m.filterCWD = !m.filterCWD
if m.filterCWD && m.filesCWD == nil {
// Filter by cwd the first time it is called and cache the results
cwd, err := os.Getwd()
if err != nil {
return m, nil
}
filesCWD := make(map[int]struct{})
for i, f := range m.files {
subpath, _ := posix.CheckSubPath(cwd, f.OriginalPath)
if !subpath {
continue
}
filesCWD[i] = struct{}{}
}
m.filesCWD = filesCWD
}
m.trashTable.input.Reset()
if m.filterCWD {
m.trashTable.t.SetColumnNameLast("Path (cwd)")
} else {
m.trashTable.t.SetColumnNameLast("Path")
}
m.filterApply()
return m, nil
case key.Matches(msg, m.keymap.runRestore):
files := m.getRestoreFiles()
// If the file to be restored is not selected, nothing is done.
if len(files) == 0 {
return m, nil
}
m.confirmed = true
m.restoreFiles = files
return m, tea.Quit
}
ft.t, cmd = ft.t.Update(msg)
return m, cmd
} else if ft.input.Focused() {
// when focused to filter textinput
switch msg.String() {
case "enter", "esc":
ft.input.Blur()
ft.t.Focus()
case "ctrl+c":
ft.input.Blur()
ft.t.Focus()
return m, nil
}
ft.input, cmd = ft.input.Update(msg)
// Reflecting filter to the table
m.filterApply()
return m, cmd
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.updateScreenSize()
}
// ft.t, cmd = ft.t.Update(msg)
// return m, cmd
return m, nil
}
func deleteRow(rows []table.Row, cursor int) []table.Row {
return rows[:cursor+copy(rows[cursor:], rows[cursor+1:])]
}
func addRows(rows []table.Row, adds []table.Row) []table.Row {
if len(rows) == 0 {
return adds
}
// TODO: perf
rows = append(rows, adds...)
sort.Slice(rows, func(i, j int) bool {
i, _ = strconv.Atoi(rows[i][0])
j, _ = strconv.Atoi(rows[j][0])
return i < j
})
return rows
}
func addRow(rows []table.Row, add table.Row) []table.Row {
if len(rows) == 0 {
return []table.Row{add}
}
// TODO: perf
rows = append(rows, add)
sort.Slice(rows, func(i, j int) bool {
i, _ = strconv.Atoi(rows[i][0])
j, _ = strconv.Atoi(rows[j][0])
return i < j
})
return rows
}
func (m *multiRestoreModel) moveRow() {
from := m.trashTable
to := m.restoreTable
if m.rightFocus {
from = m.restoreTable
to = m.trashTable
}
if from.t.SelectedRow() == nil {
return
}
// apply to selected
idx := from.getSelectedIdx()
if !m.rightFocus {
m.selected[idx] = struct{}{}
} else {
delete(m.selected, idx)
}
// delete row from focus table
rows := from.t.Rows()
selectedRow := from.t.SelectedRow()
cursor := from.t.Cursor()
if len(rows) >= 2 && len(rows) == cursor+1 {
// When the last line is selected, shift the focus up one line
from.t.SetCursor(cursor - 1)
}
rows = deleteRow(rows, cursor)
from.t.SetRows(rows)
// add to other side table if filter matches
if to.input.Value() == "" || findMatch(selectedRow[len(selectedRow)-1], to.input.Value()) {
rows = to.t.Rows()
rows = addRow(rows, selectedRow)
to.t.SetRows(rows)
}
m.updateHit()
}
func (m *multiRestoreModel) moveRowALL() {
from := m.trashTable
to := m.restoreTable
if m.rightFocus {
from = m.restoreTable
to = m.trashTable
}
if len(from.t.Rows()) == 0 {
return
}
// apply to selected
for _, idx := range from.getIndices() {
if !m.rightFocus {
m.selected[idx] = struct{}{}
} else {
delete(m.selected, idx)
}
}
// delete all rows from focus table
rows := from.t.Rows()
from.t.SetCursor(0)
from.t.SetRows(nil)
// add to other side table if filter matches
if to.input.Value() == "" { // if filter not used, append all
rows = addRows(to.t.Rows(), rows)
to.t.SetRows(rows)
} else {
filterRows := make([]table.Row, 0)
for _, r := range rows {
if findMatch(r[len(r)-1], to.input.Value()) {
filterRows = append(filterRows, r)
}
}
rows = addRows(to.t.Rows(), filterRows)
to.t.SetRows(rows)
}
m.updateHit()
}
func (m *multiRestoreModel) filterApply() {
ft, _ := m.getFocusTable()
// Move the cursor to the top as the record changes
ft.t.GotoTop()
var rows []table.Row
for i, f := range m.files {
// Exclude already selected rows from filtering
if !m.rightFocus {
if _, ok := m.selected[i]; ok {
continue
}
// Apply cwd filtering
if m.filterCWD {
if _, ok := m.filesCWD[i]; !ok {
continue
}
}
} else {
if _, ok := m.selected[i]; !ok {
continue
}
}
if ft.input.Value() == "" || findMatch(f.OriginalPath, ft.input.Value()) {
rows = append(rows, makeFileRow(i, f))
}
}
ft.t.SetRows(rows)
m.updateHit()
}
func findMatch(text, pattern string) bool {
return strings.Contains(strings.ToLower(text), strings.ToLower(pattern))
}
func (m multiRestoreModel) View() string {
var body strings.Builder
body.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, m.trashTable.View(!m.rightFocus), m.restoreTable.View(m.rightFocus)))
if m.showHelp {
help := m.help.FullHelpView([][]key.Binding{
{
m.keymap.moveRight,
m.keymap.moveLeft,
m.keymap.move,
m.keymap.moveRightALL,
m.keymap.moveLeftALL,
},
{
m.keymap.focus,
m.keymap.filter,
m.keymap.filterCWD,
m.keymap.clear,
m.keymap.togglePreview,
},
{
m.keymap.pageup,
m.keymap.runRestore,
m.keymap.pagedown,
m.keymap.top,
m.keymap.bottom,
},
{
m.keymap.quit,
m.keymap.help,
},
})
body.WriteString("\n" + help)
} else {
help := m.help.ShortHelpView([]key.Binding{
m.keymap.help,
m.keymap.quit,
m.keymap.focus,
m.keymap.moveRight,
m.keymap.moveLeft,
m.keymap.runRestore,
m.keymap.filter,
})
body.WriteString("\n" + help)
body.WriteString("\n" + m.viewMetadata())
}
// Use wrapStyle to prevent buggy table display when moving the screen width repeatedly.
return m.wrapStyle.Render(body.String())
}
func (m multiRestoreModel) viewMetadata() string {
ft, _ := m.getFocusTable()
var body strings.Builder
if ft.t.SelectedRow() == nil {
return ""
}
f := m.files[ft.getSelectedIdx()]
body.WriteString(greyStyle.Render("FileName: ") + f.Name + "\n")
body.WriteString(greyStyle.Render("OriginalPath: ") + f.OriginalPathFormat(false, true) + "\n")
body.WriteString(greyStyle.Render("TrashPath: ") + f.TrashPathColor() + "\n")
body.WriteString(greyStyle.Render("DeletedAt: ") + fmt.Sprintf("%s (%s)", f.DeletedAt.Format(time.DateTime), ft.t.SelectedRow()[1]) + "\n")
if m.showPreview {
body.WriteString(greyStyle.Render("Preview: ") + posix.FileHead(f.TrashPath, m.width, m.height-m.tableHeight-paddingHeight-6))
}
return body.String()
}
================================================
FILE: internal/tui/singleRestore.go
================================================
package tui
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/dustin/go-humanize"
"github.com/umlx5h/gtrash/internal/posix"
"github.com/umlx5h/gtrash/internal/trash"
"github.com/umlx5h/gtrash/internal/tui/table"
)
var _ tea.Model = singleRestoreModel{}
var baseStyle = lipgloss.NewStyle().
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240"))
type singleRestoreModel struct {
width int
height int
fixedWidth int
tableHeight int
wrapStyle lipgloss.Style
table table.Model
input textinput.Model // filter
groups []trash.Group // data source
keymap keymap
help help.Model
confirmed bool // true when confirmed by pressing Enter
selected int // groups index,
hit, total, hitWidth int
}
func makeGroupRow(idx int, g trash.Group) table.Row {
return []string{
strconv.Itoa(idx + 1),
humanize.Time(g.DeletedAt),
strconv.Itoa(len(g.Files)),
posix.AbsPathToTilde(g.Dir),
}
}
func newSingleRestoreModel(groups []trash.Group) singleRestoreModel {
width, height := getTermSize()
var (
noWidth = len(strconv.Itoa(len(groups)))
dateWidth int
filesWidth int
)
if noWidth <= 1 {
noWidth = 2
}
if isKonsole {
noWidth += 1
}
rows := make([]table.Row, len(groups))
for i, g := range groups {
r := makeGroupRow(i, g)
// Only ASCII characters are used, so it should match the character length
w := len(r[1])
if w > dateWidth {
dateWidth = w
}
w = len(r[2])
if w > filesWidth {
filesWidth = w
}
rows[i] = r
}
filesWidth = max(filesWidth, len("Files"))
paddingWidth := 5 * 2 // (columns + 1) * 2
fixedWidth := noWidth + dateWidth + filesWidth + paddingWidth
// make table shorter
if isKonsole {
fixedWidth += 6
}
pathWidth := width - fixedWidth
columns := []table.Column{
{Title: "No", Width: noWidth},
{Title: "DeletedAt", Width: dateWidth},
{Title: "Files", Width: filesWidth},
{Title: "RestoreDir", Width: pathWidth},
}
tableHeight := int(float64(height)*0.55) - paddingHeight
t := table.New(
table.WithColumns(columns),
table.WithRows(rows),
table.WithFocused(true),
table.WithHeight(tableHeight),
)
s := table.DefaultStyles()
s.Header = s.Header.
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("240")).
BorderBottom(true).
Bold(false)
s.Selected = s.Selected.
Foreground(lipgloss.Color("15")).
Background(lipgloss.Color("240")).
Bold(true)
t.SetStyles(s)
i := textinput.New()
i.PromptStyle = greyStyle
i.Cursor.Style = inputCursorStyle
h := baseHelp
km := baseKeymap
m := singleRestoreModel{
width: width,
height: height,
fixedWidth: fixedWidth,
tableHeight: tableHeight,
wrapStyle: lipgloss.NewStyle().Width(width).Height(height).MaxWidth(width).MaxHeight(height),
table: t,
input: i,
groups: groups,
keymap: km,
help: h,
total: len(rows),
hit: len(rows),
hitWidth: len(strconv.Itoa(len(rows))),
}
m.updateInputPrompt()
return m
}
func (m singleRestoreModel) Init() tea.Cmd {
return nil
}
func (m singleRestoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
// when focused to table
if m.table.Focused() {
switch {
case key.Matches(msg, m.keymap.quit):
return m, tea.Quit
case key.Matches(msg, m.keymap.filter):
m.table.Blur()
m.input.Focus()
return m, nil
case key.Matches(msg, m.keymap.clear):
if m.input.Value() != "" {
m.input.Reset()
m.filterApply()
}
return m, nil
case key.Matches(msg, m.keymap.runRestore):
selected := m.table.SelectedRow()
if selected == nil {
return m, nil
}
idx, err := strconv.Atoi(selected[0])
if err != nil {
panic(err)
}
m.confirmed = true
m.selected = idx - 1
return m, tea.Quit
}
m.table, cmd = m.table.Update(msg)
return m, cmd
} else if m.input.Focused() {
// when focused to filter textinput
switch msg.String() {
case "enter", "esc":
m.input.Blur()
m.table.Focus()
case "ctrl+c":
m.input.Blur()
m.table.Focus()
return m, nil
}
m.input, cmd = m.input.Update(msg)
// Reflecting filter to the table
m.filterApply()
return m, cmd
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.updateScreenSize()
}
return m, nil
}
func (m *singleRestoreModel) updateScreenSize() {
m.wrapStyle = m.wrapStyle.Width(m.width).Height(m.height).MaxWidth(m.width).MaxHeight(m.height)
m.table.SetColWidthLast(m.width - m.fixedWidth)
newHeight := int(float64(m.height)*0.55 - paddingHeight)
if newHeight < 1 {
newHeight = 0
}
m.table.SetHeight(newHeight)
m.tableHeight = newHeight
}
func (m *singleRestoreModel) updateInputPrompt() {
m.input.Prompt = fmt.Sprintf("Trash Group %*d/%d > ", m.hitWidth, m.hit, m.total)
}
func (m *singleRestoreModel) updateHit() {
m.total = len(m.groups)
m.hit = len(m.table.Rows())
m.updateInputPrompt()
}
func (m *singleRestoreModel) filterApply() {
// Move the cursor to the top as the record changes
m.table.GotoTop()
var rows []table.Row
for i, g := range m.groups {
for _, f := range g.Files {
// search by original path
if m.input.Value() == "" || findMatch(f.OriginalPath, m.input.Value()) {
rows = append(rows, makeGroupRow(i, g))
break
}
}
}
m.table.SetRows(rows)
m.updateHit()
}
func (m singleRestoreModel) View() string {
var body strings.Builder
body.WriteString(" " + m.input.View() + "\n")
body.WriteString(baseStyle.Render(m.table.View()) + "\n")
help := m.help.ShortHelpView([]key.Binding{
m.keymap.quit,
m.keymap.runRestore,
m.keymap.filter,
m.keymap.clear,
m.keymap.pageup,
m.keymap.pagedown,
})
body.WriteString(help + "\n")
body.WriteString(m.viewMetadata())
// Use wrapStyle to prevent buggy table display when moving the screen width repeatedly.
return m.wrapStyle.Render(body.String())
}
func (m singleRestoreModel) viewMetadata() string {
var body strings.Builder
row := m.table.SelectedRow()
if row == nil {
return ""
}
selected, _ := strconv.Atoi(row[0])
g := m.groups[selected-1]
body.WriteString(greyStyle.Render("DeletedAt: ") + fmt.Sprintf("%s (%s)", g.DeletedAt.Format(time.DateTime), row[1]) + "\n")
body.WriteString(greyStyle.Render("RestoreDir: ") + g.Dir + "\n")
body.WriteString(greyStyle.Render("Number of Files: ") + row[2] + "\n")
body.WriteString(greyStyle.Render("Files:") + "\n")
// TODO: make scrollable
for i, f := range g.Files {
// TODO: calculation correct?
if i > m.height-m.tableHeight-11 {
break
}
body.WriteString(" - " + f.OriginalPathFormat(false, true) + "\n")
}
return body.String()
}
================================================
FILE: internal/tui/table/table.go
================================================
package table
import (
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/umlx5h/go-runewidth"
)
// Forked from https://github.com/charmbracelet/bubbles/blob/f36aa3c4b5369f2ecefb4e35dbb2c924906932ca/table/table.go
// Copyright (c) 2020-2023 Charmbracelet, Inc
// https://github.com/charmbracelet/bubbles/blob/f36aa3c4b5369f2ecefb4e35dbb2c924906932ca/LICENSE
// MIT License
//
// Copyright (c) 2020-2023 Charmbracelet, Inc
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
// Model defines a state for the table widget.
type Model struct {
KeyMap KeyMap
cols []Column
rows []Row
cursor int
focus bool
styles Styles
// If true, hide columns in shortColIdx and add width to shortAppendColIdx
shortMode bool
shortColIdx int
shortAppendColIdx int
viewport viewport.Model
start int
end int
}
// Row represents one line in the table.
type Row []string
// Column defines the table structure.
type Column struct {
Title string
Width int
}
// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which
// is used to render the menu.
type KeyMap struct {
LineUp key.Binding
LineDown key.Binding
PageUp key.Binding
PageDown key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
GotoTop key.Binding
GotoBottom key.Binding
}
// DefaultKeyMap returns a default set of keybindings.
func DefaultKeyMap() KeyMap {
// const spacebar = " "
return KeyMap{
LineUp: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑/k", "up"),
),
LineDown: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓/j", "down"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup", "ctrl+b"),
key.WithHelp("pgup", "page up"),
),
PageDown: key.NewBinding(
key.WithKeys("pgdown", "ctrl+f"),
key.WithHelp("pgdn", "page down"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("u", "ctrl+u"),
key.WithHelp("u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("d", "ctrl+d"),
key.WithHelp("d", "½ page down"),
),
GotoTop: key.NewBinding(
key.WithKeys("home", "g"),
key.WithHelp("g/home", "go to start"),
),
GotoBottom: key.NewBinding(
key.WithKeys("end", "G"),
key.WithHelp("G/end", "go to end"),
),
}
}
// Styles contains style definitions for this list component. By default, these
// values are generated by DefaultStyles.
type Styles struct {
Header lipgloss.Style
Cell lipgloss.Style
Selected lipgloss.Style
}
// DefaultStyles returns a set of default style definitions for this table.
func DefaultStyles() Styles {
return Styles{
Selected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212")),
Header: lipgloss.NewStyle().Bold(true).Padding(0, 1),
Cell: lipgloss.NewStyle().Padding(0, 1),
}
}
// SetStyles sets the table styles.
func (m *Model) SetStyles(s Styles) {
m.styles = s
m.UpdateViewport()
}
// Option is used to set options in New. For example:
//
// table := New(WithColumns([]Column{{Title: "ID", Width: 10}}))
type Option func(*Model)
// New creates a new model for the table widget.
func New(opts ...Option) Model {
m := Model{
cursor: 0,
viewport: viewport.New(0, 20),
KeyMap: DefaultKeyMap(),
styles: DefaultStyles(),
}
for _, opt := range opts {
opt(&m)
}
m.UpdateViewport()
return m
}
// WithColumns sets the table columns (headers).
func WithColumns(cols []Column) Option {
return func(m *Model) {
m.cols = cols
}
}
// WithRows sets the table rows (data).
func WithRows(rows []Row) Option {
return func(m *Model) {
m.rows = rows
}
}
// WithHeight sets the height of the table.
func WithHeight(h int) Option {
return func(m *Model) {
m.viewport.Height = h
}
}
// WithWidth sets the width of the table.
func WithWidth(w int) Option {
return func(m *Model) {
m.viewport.Width = w
}
}
// WithFocused sets the focus state of the table.
func WithFocused(f bool) Option {
return func(m *Model) {
m.focus = f
}
}
// WithStyles sets the table styles.
func WithStyles(s Styles) Option {
return func(m *Model) {
m.styles = s
}
}
// WithKeyMap sets the key map.
func WithKeyMap(km KeyMap) Option {
return func(m *Model) {
m.KeyMap = km
}
}
// WithShortColumn sets the short column options.
func WithShortColumn(shortColIdx, shortAppendColIdx int) Option {
return func(m *Model) {
m.shortColIdx = shortColIdx
m.shortAppendColIdx = shortAppendColIdx
}
}
// Update is the Bubble Tea update loop.
func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
if !m.focus {
return m, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.KeyMap.LineUp):
m.MoveUp(1)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.PageUp):
m.MoveUp(m.viewport.Height)
case key.Matches(msg, m.KeyMap.PageDown):
m.MoveDown(m.viewport.Height)
case key.Matches(msg, m.KeyMap.HalfPageUp):
m.MoveUp(m.viewport.Height / 2)
case key.Matches(msg, m.KeyMap.HalfPageDown):
m.MoveDown(m.viewport.Height / 2)
case key.Matches(msg, m.KeyMap.LineDown):
m.MoveDown(1)
case key.Matches(msg, m.KeyMap.GotoTop):
m.GotoTop()
case key.Matches(msg, m.KeyMap.GotoBottom):
m.GotoBottom()
}
}
return m, nil
}
// Focused returns the focus state of the table.
func (m Model) Focused() bool {
return m.focus
}
// Focus focuses the table, allowing the user to move around the rows and
// interact.
func (m *Model) Focus() {
m.focus = true
m.UpdateViewport()
}
// Blur blurs the table, preventing selection or movement.
func (m *Model) Blur() {
m.focus = false
m.UpdateViewport()
}
// View renders the component.
func (m Model) View() string {
return m.headersView() + "\n" + m.viewport.View()
}
// UpdateViewport updates the list content based on the previously defined
// columns and rows.
func (m *Model) UpdateViewport() {
renderedRows := make([]string, 0, len(m.rows))
// Render only rows from: m.cursor-m.viewport.Height to: m.cursor+m.viewport.Height
// Constant runtime, independent of number of rows in a table.
// Limits the number of renderedRows to a maximum of 2*m.viewport.Height
if m.cursor >= 0 {
m.start = clamp(m.cursor-m.viewport.Height, 0, m.cursor)
} else {
m.start = 0
}
m.end = clamp(m.cursor+m.viewport.Height, m.cursor, len(m.rows))
for i := m.start; i < m.end; i++ {
renderedRows = append(renderedRows, m.renderRow(i))
}
m.viewport.SetContent(
lipgloss.JoinVertical(lipgloss.Left, renderedRows...),
)
}
// SelectedRow returns the selected row.
// You can cast it to your own implementation.
func (m Model) SelectedRow() Row {
if m.cursor < 0 || m.cursor >= len(m.rows) {
return nil
}
return m.rows[m.cursor]
}
// Rows returns the current rows.
func (m Model) Rows() []Row {
return m.rows
}
// SetRows sets a new rows state.
func (m *Model) SetRows(r []Row) {
m.rows = r
m.UpdateViewport()
}
// SetColumns sets a new columns state.
func (m *Model) SetColumns(c []Column) {
m.cols = c
m.UpdateViewport()
}
// SetColumnNameLast sets new name to last column.
func (m *Model) SetColumnNameLast(n string) {
m.cols[len(m.cols)-1].Title = n
m.UpdateViewport()
}
// SetWidth sets the width of the viewport of the table.
func (m *Model) SetWidth(w int) {
m.viewport.Width = w
m.UpdateViewport()
}
// SetShortMode sets the mode of the table.
func (m *Model) SetShortMode(sm bool) {
if m.shortMode != sm {
m.shortMode = sm
m.UpdateViewport()
}
}
// Update the width of the rightmost column
func (m *Model) SetColWidthLast(w int) {
m.cols[len(m.cols)-1].Width = w
m.UpdateViewport()
}
// SetHeight sets the height of the viewport of the table.
func (m *Model) SetHeight(h int) {
m.viewport.Height = h
m.UpdateViewport()
}
// Height returns the viewport height of the table.
func (m Model) Height() int {
return m.viewport.Height
}
// Width returns the viewport width of the table.
func (m Model) Width() int {
return m.viewport.Width
}
// Cursor returns the index of the selected row.
func (m Model) Cursor() int {
return m.cursor
}
// SetCursor sets the cursor position in the table.
func (m *Model) SetCursor(n int) {
m.cursor = clamp(n, 0, len(m.rows)-1)
m.UpdateViewport()
}
// MoveUp moves the selection up by any number of rows.
// It can not go above the first row.
func (m *Model) MoveUp(n int) {
m.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)
switch {
case m.start == 0:
m.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor))
case m.start < m.viewport.Height:
m.viewport.SetYOffset(clamp(m.viewport.YOffset+n, 0, m.cursor))
case m.viewport.YOffset >= 1:
m.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height)
}
m.UpdateViewport()
}
// MoveDown moves the selection down by any number of rows.
// It can not go below the last row.
func (m *Model) MoveDown(n int) {
m.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)
m.UpdateViewport()
switch {
case m.end == len(m.rows):
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height))
case m.cursor > (m.end-m.start)/2:
m.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor))
case m.viewport.YOffset > 1:
case m.cursor > m.viewport.YOffset+m.viewport.Height-1:
m.viewport.SetYOffset(clamp(m.viewport.YOffset+1, 0, 1))
}
}
// GotoTop moves the selection to the first row.
func (m *Model) GotoTop() {
m.MoveUp(m.cursor)
}
// GotoBottom moves the selection to the last row.
func (m *Model) GotoBottom() {
m.MoveDown(len(m.rows))
}
// FromValues create the table rows from a simple string. It uses `\n` by
// default for getting all the rows and the given separator for the fields on
// each row.
func (m *Model) FromValues(value, separator string) {
rows := []Row{}
for _, line := range strings.Split(value, "\n") {
r := Row{}
for _, field := range strings.Split(line, separator) {
r = append(r, field)
}
rows = append(rows, r)
}
m.SetRows(rows)
}
func (m Model) headersView() string {
var s = make([]string, 0, len(m.cols))
var appendWidth int
if m.shortMode {
// Width of column to hide
appendWidth = m.cols[m.shortColIdx].Width + 2 // columns * 2
}
for i, col := range m.cols {
colWidth := col.Width
if m.shortMode {
if m.shortColIdx == i {
continue
}
if m.shortAppendColIdx == i {
colWidth += appendWidth
}
}
style := lipgloss.NewStyle().Width(colWidth).MaxWidth(colWidth).Inline(true)
renderedCell := style.Render(runewidth.Truncate(col.Title, colWidth, "…"))
s = append(s, m.styles.Header.Render(renderedCell))
}
return lipgloss.JoinHorizontal(lipgloss.Left, s...)
}
func (m *Model) renderRow(rowID int) string {
var s = make([]string, 0, len(m.cols))
var appendWidth int
if m.shortMode {
appendWidth = m.cols[m.shortColIdx].Width + 2
}
truncate := runewidth.Truncate
for i, value := range m.rows[rowID] {
// change truncatePrefix in last column
if i == len(m.rows[rowID])-1 {
truncate = runewidth.TruncatePrefix
}
colWidth := m.cols[i].Width
if m.shortMode {
if m.shortColIdx == i {
continue
}
if m.shortAppendColIdx == i {
colWidth += appendWidth
}
}
style := lipgloss.NewStyle().Width(colWidth).MaxWidth(colWidth).Inline(true)
renderedCell := m.styles.Cell.Render(style.Render(truncate(value, colWidth, "…")))
s = append(s, renderedCell)
}
row := lipgloss.JoinHorizontal(lipgloss.Left, s...)
if rowID == m.cursor {
return m.styles.Selected.Render(row)
}
return row
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func clamp(v, low, high int) int {
return min(max(v, low), high)
}
================================================
FILE: internal/tui/table/table_test.go
================================================
package table
import "testing"
// Copied from https://github.com/charmbracelet/bubbles/blob/f36aa3c4b5369f2ecefb4e35dbb2c924906932ca/table/table_test.go
// https://github.com/charmbracelet/bubbles/blob/f36aa3c4b5369f2ecefb4e35dbb2c924906932ca/LICENSE
// MIT License
//
// Copyright (c) 2020-2023 Charmbracelet, Inc
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
func TestFromValues(t *testing.T) {
input := "foo1,bar1\nfoo2,bar2\nfoo3,bar3"
table := New(WithColumns([]Column{{Title: "Foo"}, {Title: "Bar"}}))
table.FromValues(input, ",")
if len(table.rows) != 3 {
t.Fatalf("expect table to have 3 rows but it has %d", len(table.rows))
}
expect := []Row{
{"foo1", "bar1"},
{"foo2", "bar2"},
{"foo3", "bar3"},
}
if !deepEqual(table.rows, expect) {
t.Fatal("table rows is not equals to the input")
}
}
func TestFromValuesWithTabSeparator(t *testing.T) {
input := "foo1.\tbar1\nfoo,bar,baz\tbar,2"
table := New(WithColumns([]Column{{Title: "Foo"}, {Title: "Bar"}}))
table.FromValues(input, "\t")
if len(table.rows) != 2 {
t.Fatalf("expect table to have 2 rows but it has %d", len(table.rows))
}
expect := []Row{
{"foo1.", "bar1"},
{"foo,bar,baz", "bar,2"},
}
if !deepEqual(table.rows, expect) {
t.Fatal("table rows is not equals to the input")
}
}
func deepEqual(a, b []Row) bool {
if len(a) != len(b) {
return false
}
for i, r := range a {
for j, f := range r {
if f != b[i][j] {
return false
}
}
}
return true
}
================================================
FILE: internal/tui/tui.go
================================================
package tui
import (
"errors"
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/umlx5h/gtrash/internal/trash"
)
func FilesSelect(files []trash.File) ([]trash.File, error) {
m := newMultiRestoreModel(files)
result, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
if err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
if r, ok := result.(multiRestoreModel); ok {
if r.confirmed {
return r.restoreFiles, nil
}
}
return nil, errors.New("no selected")
}
func GroupSelect(groups []trash.Group) (trash.Group, error) {
m := newSingleRestoreModel(groups)
result, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
if err != nil {
fmt.Println("Error running program:", err)
os.Exit(1)
}
if r, ok := result.(singleRestoreModel); ok {
if r.confirmed {
return groups[r.selected], nil
}
}
return trash.Group{}, errors.New("no selected")
}
func BoolPrompt(prompt string) bool {
m := newBoolInputModel(prompt)
result, err := tea.NewProgram(m).Run()
if err != nil {
return false
}
if m, ok := result.(boolInputModel); ok {
return m.Confirmed() && m.Value()
}
return false
}
func ChoicePrompt(prompt string, choices []string) (string, error) {
model := newChoiceInputModel(prompt, choices)
result, err := tea.NewProgram(model).Run()
if err != nil {
return "", err
}
if m, ok := result.(choiceInputModel); ok {
if !m.Confirmed() || m.Value() == "quit" { // hard code quit
return "", errors.New("canceled")
}
return m.Value(), err
}
return "", errors.New("unexpected error in ChoicePrompt")
}
================================================
FILE: internal/xdg/dirsizecache.go
================================================
package xdg
import (
"bufio"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/exp/maps"
)
type DirCache map[string]*struct {
Item DirCacheItem
Seen bool
}
type DirCacheItem struct {
Size int64
Mtime time.Time
DirName string
}
func NewDirCache(r io.Reader) (DirCache, error) {
scan := bufio.NewScanner(r)
dirCache := make(DirCache)
for scan.Scan() {
line := scan.Text()
parseErr := fmt.Errorf("parse line: %s", line)
cols := strings.SplitN(line, " ", 3)
if len(cols) != 3 {
return nil, parseErr
}
size, err := strconv.ParseInt(cols[0], 10, 64)
if err != nil {
return nil, parseErr
}
ts, err := strconv.ParseInt(cols[1], 10, 64)
if err != nil {
return nil, parseErr
}
folder, err := url.QueryUnescape(cols[2])
if err != nil {
return nil, parseErr
}
dirCache[folder] = &struct {
Item DirCacheItem
Seen bool
}{
Item: DirCacheItem{
Size: size,
Mtime: time.Unix(ts, 0),
DirName: folder,
},
}
}
return dirCache, nil
}
func (i DirCacheItem) String() string {
return fmt.Sprintf("%d %d %s\n", i.Size, i.Mtime.Unix(), queryEscapePath(i.DirName))
}
func (c DirCache) ToFile(truncate bool) string {
dirs := maps.Keys(c)
sort.Slice(dirs, func(i, j int) bool {
return dirs[i] < dirs[j]
})
var s strings.Builder
for _, d := range dirs {
// remove unseen cache entry
if truncate && !c[d].Seen {
continue
}
s.WriteString(c[d].Item.String())
}
return s.String()
}
func (c DirCache) Save(trashDir string, truncate bool) error {
// xdg ref: To update the directorysizes file, implementations MUST use a temporary
// file followed by an atomic rename() operation, in order to avoid
// corruption due to two implementations writing to the file at the same
// time.
f, err := os.CreateTemp("", "directorysizes_gtrash_")
if err != nil {
return err
}
defer f.Close()
defer os.Remove(f.Name())
if _, err = f.WriteString(c.ToFile(truncate)); err != nil {
return err
}
cachePath := filepath.Join(trashDir, "directorysizes")
if err := os.Rename(f.Name(), cachePath); err != nil {
// External trash will definitely cause cross-device link errors.
// so copied trash directory, then rename(2)
tmpDstPath := filepath.Join(trashDir, filepath.Base(f.Name()))
dst, err := os.Create(tmpDstPath)
if err != nil {
return err
}
defer dst.Close()
defer os.Remove(tmpDstPath)
// to copy from start, set offset to 0
if _, err := f.Seek(0, io.SeekStart); err != nil {
return err
}
// file copy
if _, err := io.Copy(dst, f); err != nil {
return err
}
// then rename atomically
if err := os.Rename(tmpDstPath, cachePath); err != nil {
return err
}
}
return nil
}
================================================
FILE: internal/xdg/dirsizecache_test.go
================================================
package xdg
import (
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewDirCache(t *testing.T) {
want := make(DirCache)
want["bar"] = &struct {
Item DirCacheItem
Seen bool
}{
Item: DirCacheItem{
Size: 10000,
Mtime: time.Unix(1672531200, 0),
DirName: "bar",
},
Seen: false,
}
want["foo"] = &struct {
Item DirCacheItem
Seen bool
}{
Item: DirCacheItem{
Size: 20000,
Mtime: time.Unix(1672531200, 0),
DirName: "foo",
},
Seen: false,
}
want["あい うえお"] = &struct {
Item DirCacheItem
Seen bool
}{
Item: DirCacheItem{
Size: 40000,
Mtime: time.Unix(1672531200, 0),
DirName: "あい うえお",
},
Seen: false,
}
file := `10000 1672531200 bar
20000 1672531200 foo
40000 1672531200 %E3%81%82%E3%81%84%20%E3%81%86%E3%81%88%E3%81%8A
`
got, err := NewDirCache(strings.NewReader(file))
require.NoError(t, err)
assert.EqualValues(t, want, got, "parse directorysizes")
assert.Equal(t, file, got.ToFile(false), "back to directorysizes text")
t.Run("skip not seen item when truncate on", func(t *testing.T) {
got["foo"].Seen = true
assert.Equal(t, "20000 1672531200 foo\n", got.ToFile(true))
})
}
================================================
FILE: internal/xdg/path.go
================================================
package xdg
import (
"os"
"os/user"
"path/filepath"
"github.com/umlx5h/gtrash/internal/env"
)
var (
// $HOME
dirHome string
// $XDG_DATA_HOME
dirDataHome string
DirHomeTrash string
)
func init() {
dirHome = os.Getenv("HOME")
if dirHome == "" {
// fallback to get home dir
u, err := user.Current()
if err == nil {
dirHome = u.HomeDir
}
}
dirDataHome = filepath.Join(dirHome, ".local", "share")
if d, ok := os.LookupEnv("XDG_DATA_HOME"); ok {
if abs, err := filepath.Abs(d); err == nil {
dirDataHome = abs
}
}
// Can be changed by environment variables
if env.HOME_TRASH_DIR != "" {
DirHomeTrash = env.HOME_TRASH_DIR
} else {
DirHomeTrash = filepath.Join(dirDataHome, "Trash")
}
}
================================================
FILE: internal/xdg/trashdir.go
================================================
package xdg
import (
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"github.com/moby/sys/mountinfo"
"github.com/umlx5h/gtrash/internal/env"
)
type trashDirType string
const (
trashDirTypeHome trashDirType = "HOME" // $XDG_DATA_HOME/Trash
trashDirTypeExternal trashDirType = "EXTERNAL" // $root/.Trash/$uid
trashDirTypeExternalAlt trashDirType = "EXTERNAL_ALT" // $root/.Trash-$uid
trashDirTypeManual trashDirType = "MANUAL" // any directory, specify by --trash-dir
)
type TrashDir struct {
Root string // $XDG_DATA_HOME or $rootDir (used for relative path)
Dir string // $XDG_DATA_HOME/Trash or $rootDir/.Trash/$uid or $rootDir/.Trash-$uid (has info and files directory)
dirType trashDirType
}
func (d TrashDir) InfoDir() string {
return filepath.Join(d.Dir, "info")
}
func (d TrashDir) FilesDir() string {
return filepath.Join(d.Dir, "files")
}
// Use relative paths for external trash
func (d TrashDir) UseRelativePath() bool {
switch d.dirType {
case trashDirTypeHome: // use absolute path
return false
case trashDirTypeExternal, trashDirTypeExternalAlt: // use relative path
return true
default:
panic("not reached")
}
}
func (d TrashDir) CreateDir() error {
if err := os.MkdirAll(d.InfoDir(), 0o700); err != nil {
return err
}
if err := os.MkdirAll(d.FilesDir(), 0o700); err != nil {
return err
}
return nil
}
func NewTrashDirManual(dir string) TrashDir {
return TrashDir{
Root: filepath.Dir(dir),
Dir: dir,
dirType: trashDirTypeManual,
}
}
// Scan and returns trash directories from all mountpoints
// The existence of the 'files' and 'info' directories is not checked
func ScanTrashDirs() []TrashDir {
var trashDirList []TrashDir
// 1. First get the trash can in the home directory
if _, err := os.Stat(DirHomeTrash); err == nil {
trashDirList = append(trashDirList, TrashDir{
Root: dirDataHome,
Dir: DirHomeTrash,
dirType: trashDirTypeHome,
})
slog.Debug("found home trash", "directory", DirHomeTrash)
}
if env.ONLY_HOME_TRASH {
return trashDirList
}
// Get all mount points to get external trash cans
slog.Debug("getting all mountpoints")
topDirs, err := getAllMountpoints()
if err != nil {
slog.Warn("failed to get all mountpoints, do not use external trash", "error", err)
return trashDirList
}
uid := strconv.Itoa(os.Getuid())
// Check to see if the .Trash directory exists
for _, topDir := range topDirs {
// 2. check $topDir/.Trash/$uid
trashDir := filepath.Join(topDir, ".Trash", uid)
if _, err := os.Stat(trashDir); err == nil {
trashDirList = append(trashDirList, TrashDir{
Root: topDir,
Dir: trashDir,
dirType: trashDirTypeExternal,
})
slog.Debug("found external trash", "directory", trashDir)
}
// 3. check $topDir/Trash-$uid
trashDir = filepath.Join(topDir, fmt.Sprintf(".Trash-%s", uid))
if _, err = os.Stat(trashDir); err == nil {
trashDirList = append(trashDirList, TrashDir{
Root: topDir,
Dir: trashDir,
dirType: trashDirTypeExternalAlt,
})
slog.Debug("found external alternative trash", "directory", trashDir)
}
}
return trashDirList
}
// Returns the trash directory associated with the file
// Return the home directory for fallback as well.
func LookupTrashDir(path string) (home *TrashDir, external *TrashDir, err error) {
homeTrash := &TrashDir{
Root: dirDataHome,
Dir: DirHomeTrash,
dirType: trashDirTypeHome,
}
// always using home trash
if env.ONLY_HOME_TRASH {
// already create dir in env.go
return homeTrash, nil, nil
}
// 1. Determine whether to use the trash can in the home directory
// stat(2) each file and determine that they have the same file system if the device number (st_dev) matches.
// implementation varies by program.
sameFS, err := useHomeTrash(path)
if err != nil {
// unexpected error
return nil, nil, fmt.Errorf("home_trash: %w", err)
}
if sameFS {
// use home_trash
return homeTrash, nil, nil
}
// obtain a mount point associated with a file
topDir, err := getMountpoint(path)
if err != nil {
return homeTrash, nil, fmt.Errorf("get mountpoint: %w", err)
}
// 2. Check $topDir/.Trash/$uid available
if trashDir, err := useExternalTrash(topDir); err == nil {
return homeTrash, &TrashDir{
Root: topDir,
Dir: trashDir,
dirType: trashDirTypeExternal,
}, nil
}
// 3. Check $topDir/Trash-$uid available
if trashDir, err := useExternalTrashAlt(topDir); err == nil {
return homeTrash, &TrashDir{
Root: topDir,
Dir: trashDir,
dirType: trashDirTypeExternalAlt,
}, nil
} else {
return homeTrash, nil, fmt.Errorf("external_trash: %w", err)
}
}
// Exclude file systems from find that are clearly unnecessary
var skipFSType = []string{
"binfmt_misc",
"cgroup",
"cgroup2",
"debugfs",
"devpts",
"devtmpfs",
"hugetlbfs",
"mqueue",
"proc",
"sysfs",
"tracefs",
"nsfs",
"fusectl",
}
func getAllMountpoints() ([]string, error) {
infos, err := mountinfo.GetMounts(func(i *mountinfo.Info) (skip bool, stop bool) {
if slices.Contains(skipFSType, i.FSType) {
return true, false
}
// Read-only file systems are also excluded.
if i.Options == "ro" || strings.HasPrefix(i.Options, "ro,") {
return true, false
}
return false, false
})
if err != nil {
return nil, err
}
// sometimes, same mountpoint exists, so must take a unique
mountpoints := make([]string, 0, len(infos))
exists := make(map[string]struct{}, len(infos))
for i := range infos {
m := infos[i].Mountpoint
if _, ok := exists[m]; ok {
// duplicate entry detected
slog.Debug("duplicated mountpoint is detected", "mountpoint", m)
continue
}
mountpoints = append(mountpoints, m)
exists[m] = struct{}{}
}
return mountpoints, nil
}
var mountinfo_Mounted = mountinfo.Mounted
var EvalSymLinks = filepath.EvalSymlinks
// Obtain a mount point associated with a file.
// Same as df <PATH>
func getMountpoint(path string) (string, error) {
// iterate over the real (without symlinks) parents of path until we find a mount point
candidate, err := EvalSymLinks(filepath.Dir(path))
if err != nil {
return "", err
}
for {
// root is always mounted
if candidate == string(os.PathSeparator) {
slog.Debug("root mountpoint is detected", "path", path)
break
}
if candidate == "." {
// should not reached here
// check to prevent busy loop
return "", errors.New("mountpoint is '.'")
}
mounted, err := mountinfo_Mounted(candidate)
if err != nil {
return "", err
}
if mounted {
break
}
candidate = filepath.Dir(candidate)
}
return candidate, nil
}
func useHomeTrash(path string) (sameFS bool, err error) {
// do not follow symlink
fi, err := os.Lstat(path)
if err != nil {
// must be already checked
return false, err
}
ti, err := os.Stat(DirHomeTrash)
if err != nil {
// if home trash folder do not exist, create it
if errors.Is(err, os.ErrNotExist) {
if err := os.MkdirAll(DirHomeTrash, 0o700); err != nil {
return false, fmt.Errorf("create trash_dir: %w", err)
}
// re-execute stat
ti, err = os.Stat(DirHomeTrash)
}
}
if err != nil {
return false, fmt.Errorf("stat(2) trash_dir: %w", err)
}
fromInfo, ok := fi.Sys().(*syscall.Stat_t)
if !ok {
return false, fmt.Errorf("get stat(2) dev_ino")
}
toInfo, ok := ti.Sys().(*syscall.Stat_t)
if !ok {
return false, fmt.Errorf("get stat(2) dev_ino from trash_dir")
}
// stat(2) struct stat { dev_t st_dev }
if fromInfo.Dev == toInfo.Dev {
// If the device number matches, the home trash can be used because it is the same file system.
return true, nil
}
// different file system
return false, nil
}
func useExternalTrash(topDir string) (string, error) {
// xdg ref: When trashing a file from a non-home partition/device4 , an
// implementation (if it supports trashing in top directories) MUST
// check for the presence of $topdir/.Trash.
trashDir := filepath.Join(topDir, ".Trash")
info, err := os.Lstat(trashDir)
if err != nil {
return "", errors.New(".Trash not found")
}
if !info.IsDir() {
return "", errors.New(".Trash is not directory")
}
// xdg ref: The implementation also MUST check that this directory is not a symbolic link.
if info.Mode().Type() == fs.ModeSymlink {
return "", errors.New(".Trash is symlink")
}
// xdg ref: If this directory is present, the implementation MUST, by default, check for the “sticky bit”.
if info.Mode()&os.ModeSticky == 0 {
return "", errors.New(".Trash sticky bit not set")
}
trashDir = filepath.Join(trashDir, strconv.Itoa(os.Getuid()))
// Ensure to have $topDir/$uid directory
if err := os.MkdirAll(trashDir, 0o700); err != nil {
return "", fmt.Errorf("%q not created: %w", trashDir, err)
}
return trashDir, nil
}
func useExternalTrashAlt(topDir string) (string, error) {
trashDir := filepath.Join(topDir, fmt.Sprintf(".Trash-%d", os.Getuid()))
// Ensure to have $topDir-$uid directory
if err := os.MkdirAll(trashDir, 0o700); err != nil {
return "", fmt.Errorf("%q not created: %w", trashDir, err)
}
return trashDir, nil
}
================================================
FILE: internal/xdg/trashdir_test.go
================================================
package xdg
import (
"slices"
"testing"
"github.com/stretchr/testify/require"
)
func TestGetMountpoint(t *testing.T) {
// replace to stub
mountinfo_Mounted = func(fpath string) (bool, error) {
mounts := []string{
"/",
"/foo/bar",
"/foo",
"/fooo/bar",
"/ffoo/bar",
}
return slices.Contains(mounts, fpath), nil
}
// not evaluating each component here, just the entire path
symlinked := map[string]string{
// file is a link
"/foo/link.txt": "/foo/bar/target.txt",
// first component is a link
"/link": "/foo/bar",
}
EvalSymLinks = func(path string) (string, error) {
if symlink, ok := symlinked[path]; ok {
return symlink, nil
}
return path, nil
}
testsNormal := []struct {
path string
want string
}{
{path: "/a.txt", want: "/"},
{path: "/foo/bar/a.txt", want: "/foo/bar"},
{path: "/foo/bar/aaa/b.txt", want: "/foo/bar"},
{path: "/ffoo/bar/a.txt", want: "/ffoo/bar"},
{path: "/aaa/bbb/ccc/ddd.txt", want: "/"},
{path: "/", want: "/"},
{path: "/foo/link.txt", want: "/foo"},
{path: "/link/a.txt", want: "/foo/bar"},
}
t.Run("normal", func(t *testing.T) {
for _, tt := range testsNormal {
got, err := getMountpoint(tt.path)
require.NoError(t, err)
if got != tt.want {
t.Errorf("getMountpoint(%q) = %q, want %q", tt.path, got, tt.want)
}
}
})
t.Run("error", func(t *testing.T) {
got, err := getMountpoint("")
require.Error(t, err, got)
})
}
================================================
FILE: internal/xdg/trashinfo.go
================================================
package xdg
import (
"bufio"
"errors"
"fmt"
"io"
"io/fs"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
const (
trashHeader = `[Trash Info]`
timeFormat = "2006-01-02T15:04:05"
)
// XDG specifications
// https://specifications.freedesktop.org/trash-spec/latest/
// https://specifications.freedesktop.org/desktop-entry-spec/latest/basic-format.html
type Info struct {
Path string // $PWD/file.go (url decoded)
DeletionDate time.Time // 2023-01-01T00:00:00
}
func NewInfo(r io.Reader) (Info, error) {
scanner := bufio.NewScanner(r)
var info Info
var (
groupFound bool
pathFound bool
dateFound bool
)
for scanner.Scan() {
line := scanner.Text()
if line == trashHeader {
groupFound = true
continue
}
if len(line) > 0 && line[0] == '[' {
// other group found, so exit
break
}
if strings.Contains(line, "=") {
kv := strings.SplitN(line, "=", 2)
switch strings.TrimSpace(kv[0]) {
case "Path":
if pathFound {
continue
}
u, err := url.QueryUnescape(strings.TrimSpace(kv[1]))
if err != nil {
break
}
info.Path = u
pathFound = true
case "DeletionDate":
if dateFound {
continue
}
parsed, err := time.ParseInLocation(timeFormat, strings.TrimSpace(kv[1]), time.Local)
if err != nil {
break
}
info.DeletionDate = parsed
dateFound = true
}
}
}
if scanner.Err() != nil {
return Info{}, scanner.Err()
}
if !groupFound || !pathFound || !dateFound {
return Info{}, errors.New("unable to parse trashinfo")
}
return info, nil
}
// represent INI format
func (i Info) String() string {
return fmt.Sprintf("%s\nPath=%s\nDeletionDate=%s\n", trashHeader, queryEscapePath(i.Path), i.DeletionDate.Format(timeFormat))
}
func (i Info) Save(trashDir TrashDir, filename string) (saveName string, deleteFn func() error, err error) {
revision := 1
var trashinfoFile *os.File
saveName = filename
for {
if revision > 1 {
saveName = fmt.Sprintf("%s_%d", filename, revision)
}
// Considering files for which there is no associated trashinfo, check for duplicates under the files directory
// Since the trashed file may be overwritten by subsequent rename(2)
if _, err := os.Lstat(filepath.Join(trashDir.FilesDir(), saveName)); err == nil {
revision++
continue
}
// create .trashinfo file atomically using O_EXCL
f, err := os.OpenFile(filepath.Join(trashDir.InfoDir(), saveName+".trashinfo"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
// conflict detected, so change to another name
if errors.Is(err, fs.ErrExist) {
revision++
continue
} else {
return "", nil, fmt.Errorf("open failed: %w", err)
}
}
defer f.Close()
trashinfoFile = f
break
}
// Have this called when the file fails to move.
deleteFn = func() error {
return os.Remove(trashinfoFile.Name())
}
if _, err := trashinfoFile.WriteString(i.String()); err != nil {
_ = deleteFn()
return "", nil, fmt.Errorf("write failed: %w", err)
}
return saveName, deleteFn, nil
}
// Do not escape '/'
// Escape ' ' as '%20', not '+'
func queryEscapePath(s string) string {
// do not escape '/'
a := strings.Split(s, "/")
for i := 0; i < len(a); i++ {
// escape ' ' as %20 instead of '+'
b := strings.Split(a[i], " ")
for j := 0; j < len(b); j++ {
b[j] = url.QueryEscape(b[j])
}
a[i] = strings.Join(b, "%20")
}
return strings.Join(a, "/")
}
================================================
FILE: internal/xdg/trashinfo_test.go
================================================
package xdg
import (
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewInfoSuccess(t *testing.T) {
wantInfo := Info{
Path: "/dummy",
}
date, err := time.ParseInLocation(timeFormat, "2023-01-01T00:00:00", time.Local)
if err != nil {
panic(err)
}
wantInfo.DeletionDate = date
t.Run("normal", func(t *testing.T) {
info, err := NewInfo(strings.NewReader(`[Trash Info]
Path=/dummy
DeletionDate=2023-01-01T00:00:00
`))
require.NoError(t, err)
assert.Equal(t, wantInfo, info)
})
t.Run("ignore_comment_and_blankline", func(t *testing.T) {
info, err := NewInfo(strings.NewReader(`# comment 1
[Trash Info]
Path=/dummy
# comment 2
DeletionDate=2023-01-01T00:00:00
`))
require.NoError(t, err)
assert.Equal(t, wantInfo, info)
})
t.Run("contain_space_between_key_value", func(t *testing.T) {
info, err := NewInfo(strings.NewReader(`[Trash Info]
DeletionDate = 2023-01-01T00:00:00
Path = /dummy`))
require.NoError(t, err)
assert.Equal(t, wantInfo, info)
})
// xdg ref: If a string that starts with “Path=” or “DeletionDate=” occurs
// several times, the first occurence is to be used.
t.Run("high_priority_to_first_key_pair", func(t *testing.T) {
info, err := NewInfo(strings.NewReader(`[Trash Info]
Path=/dummy
DeletionDate=2023-01-01T00:00:00
DeletionDate=2099-01-01T00:00:00
Path=/notused
`))
require.NoError(t, err)
assert.Equal(t, wantInfo, info)
})
}
func TestNewInfoError(t *testing.T) {
t.Run("detect_other_group", func(t *testing.T) {
_, err := NewInfo(strings.NewReader(`[Trash Info]
Path=/dummy
[dummy group]
DeletionDate=2023-01-01T00:00:00
`))
require.Error(t, err)
})
}
func TestQueryEscape(t *testing.T) {
tests := []struct {
input string
want string
}{
{"/foo/bar", "/foo/bar"},
{"/foo/foo bar", "/foo/foo%20bar"},
{"/foo/b a r", "/foo/b%20%20a%20%20r"},
{"/foo/あ い", "/foo/%E3%81%82%20%E3%81%84"},
{"/foo/mycool+blog&about,stuff", "/foo/mycool%2Bblog%26about%2Cstuff"},
}
t.Run("escape", func(t *testing.T) {
for _, tt := range tests {
assert.Equal(t, tt.want, queryEscapePath(tt.input))
}
})
t.Run("unescape", func(t *testing.T) {
for _, tt := range tests {
e, err := url.QueryUnescape(tt.want)
require.NoError(t, err)
assert.Equal(t, e, tt.input)
}
})
}
gitextract_t5n8s8k6/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ ├── golangci-lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── Makefile ├── README.md ├── doc/ │ ├── alternatives.md │ ├── configuration.md │ └── image/ │ └── demo.tape ├── docker-compose.yaml ├── go.mod ├── go.sum ├── internal/ │ ├── cmd/ │ │ ├── find.go │ │ ├── metafix.go │ │ ├── prune.go │ │ ├── prune_test.go │ │ ├── put.go │ │ ├── restore.go │ │ ├── restoreGroup.go │ │ ├── rm.go │ │ ├── root.go │ │ └── summary.go │ ├── env/ │ │ └── env.go │ ├── glog/ │ │ └── logger.go │ ├── posix/ │ │ ├── dir.go │ │ ├── file.go │ │ ├── path.go │ │ └── path_test.go │ ├── trash/ │ │ ├── flag.go │ │ └── trash.go │ ├── tui/ │ │ ├── boolInputModel.go │ │ ├── choiceInputModel.go │ │ ├── multiRestore.go │ │ ├── singleRestore.go │ │ ├── table/ │ │ │ ├── table.go │ │ │ └── table_test.go │ │ └── tui.go │ └── xdg/ │ ├── dirsizecache.go │ ├── dirsizecache_test.go │ ├── path.go │ ├── trashdir.go │ ├── trashdir_test.go │ ├── trashinfo.go │ └── trashinfo_test.go ├── itest/ │ ├── cli_test.go │ ├── put_test.go │ ├── setup.sh │ └── trash_test.go └── main.go
SYMBOL INDEX (263 symbols across 36 files)
FILE: internal/cmd/find.go
type findCmd (line 18) | type findCmd struct
type findOptions (line 23) | type findOptions struct
function newFindCmd (line 52) | func newFindCmd() *findCmd {
function findCmdRun (line 173) | func findCmdRun(args []string, opts findOptions) error {
function listFiles (line 240) | func listFiles(files []trash.File, showSize, showTrashPath bool) {
FILE: internal/cmd/metafix.go
type metafixCmd (line 14) | type metafixCmd struct
type metafixOptions (line 19) | type metafixOptions struct
function newMetafixCmd (line 23) | func newMetafixCmd() *metafixCmd {
function metafixCmdRun (line 55) | func metafixCmdRun(opts metafixOptions) error {
FILE: internal/cmd/prune.go
type pruneCmd (line 14) | type pruneCmd struct
type pruneOptions (line 19) | type pruneOptions struct
method check (line 30) | func (o *pruneOptions) check() error {
function newPruneCmd (line 41) | func newPruneCmd() *pruneCmd {
function getPruneFiles (line 113) | func getPruneFiles(files []trash.File, maxTotalSize uint64) (prune []tra...
function pruneCmdRun (line 142) | func pruneCmdRun(opts pruneOptions) error {
FILE: internal/cmd/prune_test.go
function newInt (line 10) | func newInt(i int64) *int64 {
function TestGetPruneFiles (line 14) | func TestGetPruneFiles(t *testing.T) {
FILE: internal/cmd/put.go
type putCmd (line 23) | type putCmd struct
type putOptions (line 28) | type putOptions struct
function newPutCmd (line 41) | func newPutCmd() *putCmd {
function putCmdRun (line 111) | func putCmdRun(args []string, opts putOptions) error {
function trashFile (line 267) | func trashFile(trashDir xdg.TrashDir, path string, deleteTime *time.Time...
FILE: internal/cmd/restore.go
type restoreCmd (line 18) | type restoreCmd struct
type restoreOptions (line 23) | type restoreOptions struct
function newRestoreCmd (line 30) | func newRestoreCmd() *restoreCmd {
function restoreCmdRun (line 75) | func restoreCmdRun(args []string, opts restoreOptions) (err error) {
function checkOptRestoreTo (line 129) | func checkOptRestoreTo(restoreTo *string) error {
function checkRestoreDup (line 155) | func checkRestoreDup(files []trash.File) error {
function doRestore (line 178) | func doRestore(files []trash.File, restoreTo string, prompt bool) error {
FILE: internal/cmd/restoreGroup.go
type restoreGroupCmd (line 13) | type restoreGroupCmd struct
type restoreGroupOptions (line 18) | type restoreGroupOptions struct
function newRestoreGroupCmd (line 20) | func newRestoreGroupCmd() *restoreGroupCmd {
function restoreGroupCmdRun (line 57) | func restoreGroupCmdRun(_ restoreGroupOptions) error {
FILE: internal/cmd/rm.go
type removeCmd (line 15) | type removeCmd struct
type removeOptions (line 20) | type removeOptions struct
function newRemoveCmd (line 24) | func newRemoveCmd() *removeCmd {
function removeCmdRun (line 61) | func removeCmdRun(args []string, opts removeOptions) error {
function doRemove (line 89) | func doRemove(files []trash.File) {
FILE: internal/cmd/root.go
function init (line 25) | func init() {
function Execute (line 31) | func Execute(version Version) {
type Version (line 41) | type Version struct
method Print (line 48) | func (v Version) Print() string {
type rootCmd (line 66) | type rootCmd struct
function newRootCmd (line 70) | func newRootCmd(version Version) *rootCmd {
FILE: internal/cmd/summary.go
type summaryCmd (line 13) | type summaryCmd struct
type summaryOptions (line 18) | type summaryOptions struct
function newSummaryCmd (line 20) | func newSummaryCmd() *summaryCmd {
function summaryCmdRun (line 48) | func summaryCmdRun(_ summaryOptions) error {
FILE: internal/env/env.go
function init (line 30) | func init() {
FILE: internal/glog/logger.go
function Error (line 17) | func Error(msg string) {
function Errorf (line 22) | func Errorf(format string, args ...any) {
function ExitCode (line 27) | func ExitCode() int {
FILE: internal/posix/dir.go
function DirSize (line 13) | func DirSize(path string) (int64, error) {
function DirSizeFallback (line 34) | func DirSizeFallback(path string) (int64, error) {
function DirEmpty (line 56) | func DirEmpty(name string) (bool, error) {
FILE: internal/posix/file.go
function IsBinary (line 15) | func IsBinary(content io.ReadSeeker, fileSize int64) (bool, error) {
function FileHead (line 35) | func FileHead(path string, width int, maxLines int) string {
function FileType (line 120) | func FileType(st fs.FileInfo) string {
FILE: internal/posix/path.go
function init (line 11) | func init() {
function AbsPathToTilde (line 15) | func AbsPathToTilde(absPath string) string {
function CheckSubPath (line 33) | func CheckSubPath(parent, sub string) (bool, error) {
FILE: internal/posix/path_test.go
function TestAbsPathToTilde (line 8) | func TestAbsPathToTilde(t *testing.T) {
function TestCheckSubPath (line 28) | func TestCheckSubPath(t *testing.T) {
FILE: internal/trash/flag.go
function FlagCompletionFunc (line 12) | func FlagCompletionFunc(allCompletions []string) func(*cobra.Command, []...
method Set (line 40) | func (s *SortByType) Set(str string) error {
method String (line 49) | func (s SortByType) String() string {
method Type (line 62) | func (s SortByType) Type() string {
type ModeByType (line 70) | type ModeByType
method Set (line 92) | func (s *ModeByType) Set(str string) error {
method String (line 101) | func (s ModeByType) String() string {
method Type (line 116) | func (s ModeByType) Type() string {
constant ModeByRegex (line 73) | ModeByRegex ModeByType = iota
constant ModeByGlob (line 74) | ModeByGlob
constant ModeByLiteral (line 75) | ModeByLiteral
constant ModeByFull (line 76) | ModeByFull
FILE: internal/trash/trash.go
type SortByType (line 26) | type SortByType
constant SortByDeletedAt (line 29) | SortByDeletedAt SortByType = iota
constant SortBySize (line 30) | SortBySize
constant SortByName (line 31) | SortByName
type Box (line 34) | type Box struct
method checkOptions (line 170) | func (b *Box) checkOptions() error {
method Open (line 260) | func (b *Box) Open() error {
method getFiles (line 386) | func (b *Box) getFiles(dirents []fs.DirEntry, fileEntries map[string]b...
method HitByPath (line 656) | func (b *Box) HitByPath(originalPath string) int {
method ToGroups (line 718) | func (b *Box) ToGroups() []Group {
function NewBox (line 74) | func NewBox(opts ...BoxOption) Box {
type BoxOption (line 87) | type BoxOption
function WithAscend (line 89) | func WithAscend(ascend bool) BoxOption {
function WithTrashDir (line 95) | func WithTrashDir(trashDir string) BoxOption {
function WithSortBy (line 101) | func WithSortBy(sortBy SortByType) BoxOption {
function WithDirectory (line 107) | func WithDirectory(directory string) BoxOption {
function WithCWD (line 113) | func WithCWD(cwd bool) BoxOption {
function WithQueries (line 119) | func WithQueries(queries []string) BoxOption {
function WithQueryMode (line 125) | func WithQueryMode(mode ModeByType) BoxOption {
function WithDay (line 132) | func WithDay(dayNew int, dayOld int) BoxOption {
function WithLimitLast (line 143) | func WithLimitLast(last int) BoxOption {
function WithSize (line 149) | func WithSize(large string, small string) BoxOption {
function WithGetSize (line 163) | func WithGetSize(get bool) BoxOption {
function sortFiles (line 618) | func sortFiles(files []File, sortBy SortByType, ascend bool) {
type File (line 660) | type File struct
method OriginalPathFormat (line 679) | func (f *File) OriginalPathFormat(tilde bool, color bool) string {
method TrashPathColor (line 691) | func (f *File) TrashPathColor() string {
method SizeHuman (line 695) | func (f *File) SizeHuman() string {
method pathColor (line 703) | func (f *File) pathColor(s string) string {
method Delete (line 764) | func (f *File) Delete() error {
type Group (line 672) | type Group struct
FILE: internal/tui/boolInputModel.go
type boolInputModel (line 11) | type boolInputModel struct
method Confirmed (line 43) | func (m boolInputModel) Confirmed() bool {
method Init (line 47) | func (m boolInputModel) Init() tea.Cmd {
method Update (line 51) | func (m boolInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
method Value (line 70) | func (m boolInputModel) Value() bool {
method View (line 76) | func (m boolInputModel) View() string {
function yesno (line 16) | func yesno(s string) (bool, string, error) {
function newBoolInputModel (line 29) | func newBoolInputModel(prompt string) boolInputModel {
FILE: internal/tui/choiceInputModel.go
type choiceInputModel (line 11) | type choiceInputModel struct
method Confirmed (line 46) | func (m choiceInputModel) Confirmed() bool {
method Init (line 50) | func (m choiceInputModel) Init() tea.Cmd {
method Update (line 54) | func (m choiceInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
method Value (line 74) | func (m choiceInputModel) Value() string {
method View (line 79) | func (m choiceInputModel) View() string {
function newChoiceInputModel (line 17) | func newChoiceInputModel(prompt string, choices []string) choiceInputMod...
FILE: internal/tui/multiRestore.go
constant paddingHeight (line 24) | paddingHeight = 6
constant shortWidth (line 25) | shortWidth = 90
function init (line 51) | func init() {
type keymap (line 133) | type keymap struct
type filterTable (line 138) | type filterTable struct
method getSelectedIdx (line 147) | func (t *filterTable) getSelectedIdx() int {
method updateInputPrompt (line 156) | func (t *filterTable) updateInputPrompt(cwd bool) {
method getIndices (line 179) | func (t *filterTable) getIndices() []int {
method View (line 197) | func (t filterTable) View(focus bool) string {
type multiRestoreModel (line 212) | type multiRestoreModel struct
method updateHit (line 166) | func (m *multiRestoreModel) updateHit() {
method getFocusTable (line 240) | func (m *multiRestoreModel) getFocusTable() (focus *filterTable, notFo...
method getRestoreFiles (line 248) | func (m *multiRestoreModel) getRestoreFiles() []trash.File {
method updateScreenSize (line 450) | func (m *multiRestoreModel) updateScreenSize() {
method Init (line 476) | func (m multiRestoreModel) Init() tea.Cmd {
method Update (line 480) | func (m multiRestoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
method moveRow (line 651) | func (m *multiRestoreModel) moveRow() {
method moveRowALL (line 694) | func (m *multiRestoreModel) moveRowALL() {
method filterApply (line 741) | func (m *multiRestoreModel) filterApply() {
method View (line 781) | func (m multiRestoreModel) View() string {
method viewMetadata (line 834) | func (m multiRestoreModel) viewMetadata() string {
function makeFileRow (line 271) | func makeFileRow(idx int, f trash.File) table.Row {
function getTermSize (line 280) | func getTermSize() (width int, height int) {
function makeFilterTables (line 289) | func makeFilterTables(files []trash.File) (left, right filterTable, fixe...
function newMultiRestoreModel (line 384) | func newMultiRestoreModel(files []trash.File) multiRestoreModel {
function deleteRow (line 614) | func deleteRow(rows []table.Row, cursor int) []table.Row {
function addRows (line 618) | func addRows(rows []table.Row, adds []table.Row) []table.Row {
function addRow (line 635) | func addRow(rows []table.Row, add table.Row) []table.Row {
function findMatch (line 777) | func findMatch(text, pattern string) bool {
FILE: internal/tui/singleRestore.go
type singleRestoreModel (line 26) | type singleRestoreModel struct
method Init (line 160) | func (m singleRestoreModel) Init() tea.Cmd {
method Update (line 164) | func (m singleRestoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
method updateScreenSize (line 229) | func (m *singleRestoreModel) updateScreenSize() {
method updateInputPrompt (line 241) | func (m *singleRestoreModel) updateInputPrompt() {
method updateHit (line 246) | func (m *singleRestoreModel) updateHit() {
method filterApply (line 253) | func (m *singleRestoreModel) filterApply() {
method View (line 273) | func (m singleRestoreModel) View() string {
method viewMetadata (line 295) | func (m singleRestoreModel) viewMetadata() string {
function makeGroupRow (line 48) | func makeGroupRow(idx int, g trash.Group) table.Row {
function newSingleRestoreModel (line 57) | func newSingleRestoreModel(groups []trash.Group) singleRestoreModel {
FILE: internal/tui/table/table.go
type Model (line 40) | type Model struct
method SetStyles (line 138) | func (m *Model) SetStyles(s Styles) {
method Update (line 225) | func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
method Focused (line 258) | func (m Model) Focused() bool {
method Focus (line 264) | func (m *Model) Focus() {
method Blur (line 270) | func (m *Model) Blur() {
method View (line 276) | func (m Model) View() string {
method UpdateViewport (line 282) | func (m *Model) UpdateViewport() {
method SelectedRow (line 305) | func (m Model) SelectedRow() Row {
method Rows (line 314) | func (m Model) Rows() []Row {
method SetRows (line 319) | func (m *Model) SetRows(r []Row) {
method SetColumns (line 325) | func (m *Model) SetColumns(c []Column) {
method SetColumnNameLast (line 331) | func (m *Model) SetColumnNameLast(n string) {
method SetWidth (line 337) | func (m *Model) SetWidth(w int) {
method SetShortMode (line 343) | func (m *Model) SetShortMode(sm bool) {
method SetColWidthLast (line 351) | func (m *Model) SetColWidthLast(w int) {
method SetHeight (line 357) | func (m *Model) SetHeight(h int) {
method Height (line 363) | func (m Model) Height() int {
method Width (line 368) | func (m Model) Width() int {
method Cursor (line 373) | func (m Model) Cursor() int {
method SetCursor (line 378) | func (m *Model) SetCursor(n int) {
method MoveUp (line 385) | func (m *Model) MoveUp(n int) {
method MoveDown (line 400) | func (m *Model) MoveDown(n int) {
method GotoTop (line 416) | func (m *Model) GotoTop() {
method GotoBottom (line 421) | func (m *Model) GotoBottom() {
method FromValues (line 428) | func (m *Model) FromValues(value, separator string) {
method headersView (line 441) | func (m Model) headersView() string {
method renderRow (line 467) | func (m *Model) renderRow(rowID int) string {
type Row (line 60) | type Row
type Column (line 63) | type Column struct
type KeyMap (line 70) | type KeyMap struct
function DefaultKeyMap (line 82) | func DefaultKeyMap() KeyMap {
type Styles (line 122) | type Styles struct
function DefaultStyles (line 129) | func DefaultStyles() Styles {
type Option (line 146) | type Option
function New (line 149) | func New(opts ...Option) Model {
function WithColumns (line 168) | func WithColumns(cols []Column) Option {
function WithRows (line 175) | func WithRows(rows []Row) Option {
function WithHeight (line 182) | func WithHeight(h int) Option {
function WithWidth (line 189) | func WithWidth(w int) Option {
function WithFocused (line 196) | func WithFocused(f bool) Option {
function WithStyles (line 203) | func WithStyles(s Styles) Option {
function WithKeyMap (line 210) | func WithKeyMap(km KeyMap) Option {
function WithShortColumn (line 217) | func WithShortColumn(shortColIdx, shortAppendColIdx int) Option {
function max (line 505) | func max(a, b int) int {
function min (line 513) | func min(a, b int) int {
function clamp (line 521) | func clamp(v, low, high int) int {
FILE: internal/tui/table/table_test.go
function TestFromValues (line 30) | func TestFromValues(t *testing.T) {
function TestFromValuesWithTabSeparator (line 49) | func TestFromValuesWithTabSeparator(t *testing.T) {
function deepEqual (line 67) | func deepEqual(a, b []Row) bool {
FILE: internal/tui/tui.go
function FilesSelect (line 13) | func FilesSelect(files []trash.File) ([]trash.File, error) {
function GroupSelect (line 30) | func GroupSelect(groups []trash.Group) (trash.Group, error) {
function BoolPrompt (line 47) | func BoolPrompt(prompt string) bool {
function ChoicePrompt (line 62) | func ChoicePrompt(prompt string, choices []string) (string, error) {
FILE: internal/xdg/dirsizecache.go
type DirCache (line 18) | type DirCache
method ToFile (line 78) | func (c DirCache) ToFile(truncate bool) string {
method Save (line 96) | func (c DirCache) Save(trashDir string, truncate bool) error {
type DirCacheItem (line 23) | type DirCacheItem struct
method String (line 74) | func (i DirCacheItem) String() string {
function NewDirCache (line 29) | func NewDirCache(r io.Reader) (DirCache, error) {
FILE: internal/xdg/dirsizecache_test.go
function TestNewDirCache (line 12) | func TestNewDirCache(t *testing.T) {
FILE: internal/xdg/path.go
function init (line 20) | func init() {
FILE: internal/xdg/trashdir.go
type trashDirType (line 19) | type trashDirType
constant trashDirTypeHome (line 22) | trashDirTypeHome trashDirType = "HOME"
constant trashDirTypeExternal (line 23) | trashDirTypeExternal trashDirType = "EXTERNAL"
constant trashDirTypeExternalAlt (line 24) | trashDirTypeExternalAlt trashDirType = "EXTERNAL_ALT"
constant trashDirTypeManual (line 25) | trashDirTypeManual trashDirType = "MANUAL"
type TrashDir (line 28) | type TrashDir struct
method InfoDir (line 34) | func (d TrashDir) InfoDir() string {
method FilesDir (line 38) | func (d TrashDir) FilesDir() string {
method UseRelativePath (line 43) | func (d TrashDir) UseRelativePath() bool {
method CreateDir (line 54) | func (d TrashDir) CreateDir() error {
function NewTrashDirManual (line 66) | func NewTrashDirManual(dir string) TrashDir {
function ScanTrashDirs (line 76) | func ScanTrashDirs() []TrashDir {
function LookupTrashDir (line 134) | func LookupTrashDir(path string) (home *TrashDir, external *TrashDir, er...
function getAllMountpoints (line 205) | func getAllMountpoints() ([]string, error) {
function getMountpoint (line 246) | func getMountpoint(path string) (string, error) {
function useHomeTrash (line 283) | func useHomeTrash(path string) (sameFS bool, err error) {
function useExternalTrash (line 326) | func useExternalTrash(topDir string) (string, error) {
function useExternalTrashAlt (line 359) | func useExternalTrashAlt(topDir string) (string, error) {
FILE: internal/xdg/trashdir_test.go
function TestGetMountpoint (line 10) | func TestGetMountpoint(t *testing.T) {
FILE: internal/xdg/trashinfo.go
constant trashHeader (line 17) | trashHeader = `[Trash Info]`
constant timeFormat (line 18) | timeFormat = "2006-01-02T15:04:05"
type Info (line 25) | type Info struct
method String (line 91) | func (i Info) String() string {
method Save (line 95) | func (i Info) Save(trashDir TrashDir, filename string) (saveName strin...
function NewInfo (line 30) | func NewInfo(r io.Reader) (Info, error) {
function queryEscapePath (line 145) | func queryEscapePath(s string) string {
FILE: internal/xdg/trashinfo_test.go
function TestNewInfoSuccess (line 13) | func TestNewInfoSuccess(t *testing.T) {
function TestNewInfoError (line 68) | func TestNewInfoError(t *testing.T) {
function TestQueryEscape (line 79) | func TestQueryEscape(t *testing.T) {
FILE: itest/cli_test.go
function TestMain (line 12) | func TestMain(m *testing.M) {
function checkFileMoved (line 21) | func checkFileMoved(t *testing.T, from string, to string) {
function mustError (line 33) | func mustError(t testing.TB, err error, msg ...string) {
function mustNoError (line 45) | func mustNoError(t testing.TB, err error, msg ...string) {
function assertEmpty (line 57) | func assertEmpty(t *testing.T, s string) {
function assertContains (line 65) | func assertContains(t *testing.T, s string, substr string, msg ...string) {
function assertEqual (line 76) | func assertEqual(t *testing.T, got string, want string, msg ...string) {
FILE: itest/put_test.go
function TestSkipDotEndingPath (line 12) | func TestSkipDotEndingPath(t *testing.T) {
FILE: itest/trash_test.go
function cleanTrash (line 23) | func cleanTrash(t *testing.T) {
function TestTrashAllType (line 39) | func TestTrashAllType(t *testing.T) {
FILE: main.go
function main (line 15) | func main() {
Condensed preview — 53 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (221K chars).
[
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 831,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the b"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 595,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your fea"
},
{
"path": ".github/workflows/golangci-lint.yml",
"chars": 477,
"preview": "# ref: https://github.com/golangci/golangci-lint-action#how-to-use\nname: golangci-lint\non:\n pull_request:\n workflow_di"
},
{
"path": ".github/workflows/release.yml",
"chars": 777,
"preview": "name: release\non:\n push:\n tags:\n - \"v*\"\npermissions:\n contents: write\njobs:\n release:\n runs-on: ubuntu-lat"
},
{
"path": ".github/workflows/test.yml",
"chars": 298,
"preview": "name: test\non:\n pull_request:\n branches: [ \"main\" ]\n workflow_dispatch:\njobs:\n test:\n runs-on: ubuntu-latest\n "
},
{
"path": ".gitignore",
"chars": 62,
"preview": "gtrash\ndist/\n__debug_bin*\ncoverage\ncoverage.html\ncoverage.txt\n"
},
{
"path": ".goreleaser.yaml",
"chars": 1899,
"preview": "version: 1\n\nbefore:\n hooks:\n - go mod tidy\n\nbuilds:\n - env:\n - CGO_ENABLED=0\n goos:\n - linux\n - d"
},
{
"path": "LICENSE",
"chars": 1063,
"preview": "MIT License\n\nCopyright (c) 2024 umlx5h\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof "
},
{
"path": "Makefile",
"chars": 579,
"preview": ".PHONY: clean test itest lint\n\nbuild:\n\tgo build\n\nclean:\n\trm -f gtrash\n\trm -rf coverage itest/coverage\n\trm -f coverage.tx"
},
{
"path": "README.md",
"chars": 20311,
"preview": "# gtrash\n\n\n\n`gtrash` is a trash CLI manager that fully complies with the [FreeDesktop.org spe"
},
{
"path": "doc/alternatives.md",
"chars": 4395,
"preview": "# Alternatives\n\n| | gtrash | [tr"
},
{
"path": "doc/configuration.md",
"chars": 2398,
"preview": "# Configration\n\nCertain behaviors can be altered by setting environment variables. \n\n## GTRASH_HOME_TRASH_DIR\n\n- Type: "
},
{
"path": "doc/image/demo.tape",
"chars": 3931,
"preview": "# VHS documentation\n#\n# Output:\n# Output <path>.gif Create a GIF output at the given <path>\n# Output <"
},
{
"path": "docker-compose.yaml",
"chars": 414,
"preview": "services:\n itest:\n image: golang:1.22\n working_dir: /app\n tmpfs:\n - /external\n - /external_alt\n e"
},
{
"path": "go.mod",
"chars": 2122,
"preview": "module github.com/umlx5h/gtrash\n\ngo 1.22.4\n\nrequire (\n\tgithub.com/charmbracelet/bubbles v0.18.0\n\tgithub.com/charmbracele"
},
{
"path": "go.sum",
"chars": 10331,
"preview": "github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=\ngithub.com/atotto/clipboard v0.1.4/go"
},
{
"path": "internal/cmd/find.go",
"chars": 8807,
"preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/juju"
},
{
"path": "internal/cmd/metafix.go",
"chars": 2279,
"preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/umlx5h/gtrash/internal/glog\"\n\t\"gith"
},
{
"path": "internal/cmd/prune.go",
"chars": 5801,
"preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/dustin/go-humanize\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/umlx5h/g"
},
{
"path": "internal/cmd/prune_test.go",
"chars": 1663,
"preview": "package cmd\n\nimport (\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/umlx5h/gtrash/internal/trash\"\n)\n\nfu"
},
{
"path": "internal/cmd/put.go",
"chars": 9722,
"preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\tcp \"gi"
},
{
"path": "internal/cmd/restore.go",
"chars": 7984,
"preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\n\tcp \"github.com/otiai10/copy\"\n\t\"github.com/rs"
},
{
"path": "internal/cmd/restoreGroup.go",
"chars": 1885,
"preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/umlx5h/gtrash/internal/glog\"\n\t\"github.com"
},
{
"path": "internal/cmd/rm.go",
"chars": 3027,
"preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/umlx5h/gtrash/internal/"
},
{
"path": "internal/cmd/root.go",
"chars": 2850,
"preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime/debug\"\n\t\"strings\"\n\n\t\"github.com/lmit"
},
{
"path": "internal/cmd/summary.go",
"chars": 1877,
"preview": "package cmd\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\n\t\"github.com/dustin/go-humanize\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/umlx5h/g"
},
{
"path": "internal/env/env.go",
"chars": 1765,
"preview": "package env\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\nvar (\n\t// Copy files to the trash can in the home dire"
},
{
"path": "internal/glog/logger.go",
"chars": 441,
"preview": "package glog\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nvar (\n\terrorCalled int\n\tprogName = filepath.Base(os.Arg"
},
{
"path": "internal/posix/dir.go",
"chars": 1500,
"preview": "package posix\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"syscall\"\n)\n\n// same as du -B1 or du -sh\n// The size is "
},
{
"path": "internal/posix/file.go",
"chars": 2941,
"preview": "package posix\n\nimport (\n\t\"bufio\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"os\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.co"
},
{
"path": "internal/posix/path.go",
"chars": 720,
"preview": "package posix\n\nimport (\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\nvar euid int\n\nfunc init() {\n\teuid = os.Geteuid()\n}\n\nfunc Ab"
},
{
"path": "internal/posix/path_test.go",
"chars": 1165,
"preview": "package posix\n\nimport (\n\t\"os\"\n\t\"testing\"\n)\n\nfunc TestAbsPathToTilde(t *testing.T) {\n\thome := os.Getenv(\"HOME\")\n\n\ttests :"
},
{
"path": "internal/trash/flag.go",
"chars": 2239,
"preview": "package trash\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n\n\t\"github.com/spf13/cobra\"\n\t\"github.com/spf13/pflag\"\n\t\"golang.org/x/exp/maps\"\n"
},
{
"path": "internal/trash/trash.go",
"chars": 18743,
"preview": "package trash\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"slices\"\n\t\"sort\"\n\t\"strin"
},
{
"path": "internal/tui/boolInputModel.go",
"chars": 1556,
"preview": "package tui\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\ttea \"github.com/charmbracelet"
},
{
"path": "internal/tui/choiceInputModel.go",
"chars": 1724,
"preview": "package tui\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\ttea \"github.com/charmbracelet"
},
{
"path": "internal/tui/multiRestore.go",
"chars": 19544,
"preview": "package tui\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/bubbles/help\"\n\t\"git"
},
{
"path": "internal/tui/singleRestore.go",
"chars": 6958,
"preview": "package tui\n\nimport (\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/bubbles/help\"\n\t\"github.com/charmb"
},
{
"path": "internal/tui/table/table.go",
"chars": 12825,
"preview": "package table\n\nimport (\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/key\"\n\t\"github.com/charmbracelet/bubbles/viewport\""
},
{
"path": "internal/tui/table/table_test.go",
"chars": 2505,
"preview": "package table\n\nimport \"testing\"\n\n// Copied from https://github.com/charmbracelet/bubbles/blob/f36aa3c4b5369f2ecefb4e35db"
},
{
"path": "internal/tui/tui.go",
"chars": 1596,
"preview": "package tui\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\n\ttea \"github.com/charmbracelet/bubbletea\"\n\n\t\"github.com/umlx5h/gtrash/inte"
},
{
"path": "internal/xdg/dirsizecache.go",
"chars": 2769,
"preview": "package xdg\n\nimport (\n\t\"bufio\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"g"
},
{
"path": "internal/xdg/dirsizecache_test.go",
"chars": 1238,
"preview": "package xdg\n\nimport (\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stretchr/testify"
},
{
"path": "internal/xdg/path.go",
"chars": 727,
"preview": "package xdg\n\nimport (\n\t\"os\"\n\t\"os/user\"\n\t\"path/filepath\"\n\n\t\"github.com/umlx5h/gtrash/internal/env\"\n)\n\nvar (\n\t// $HOME\n\tdi"
},
{
"path": "internal/xdg/trashdir.go",
"chars": 9198,
"preview": "package xdg\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"log/slog\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sys"
},
{
"path": "internal/xdg/trashdir_test.go",
"chars": 1445,
"preview": "package xdg\n\nimport (\n\t\"slices\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestGetMountpoint(t *testing."
},
{
"path": "internal/xdg/trashinfo.go",
"chars": 3447,
"preview": "package xdg\n\nimport (\n\t\"bufio\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n)\n\nc"
},
{
"path": "internal/xdg/trashinfo_test.go",
"chars": 2348,
"preview": "package xdg\n\nimport (\n\t\"net/url\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n\t\"github.com/stret"
},
{
"path": "itest/cli_test.go",
"chars": 1580,
"preview": "package itest\n\nimport (\n\t\"log\"\n\t\"os\"\n\t\"strings\"\n\t\"testing\"\n)\n\nvar execBinary = \"/app/gtrash\"\n\nfunc TestMain(m *testing.M"
},
{
"path": "itest/put_test.go",
"chars": 568,
"preview": "package itest\n\nimport (\n\t\"fmt\"\n\t\"os/exec\"\n\t\"testing\"\n)\n\n// Paths ending in a dot must be skipped.\n// $ gtrash put .\n// g"
},
{
"path": "itest/setup.sh",
"chars": 387,
"preview": "#!/bin/bash\n\nset -eu\n#\n# mkdir -p /tmp/external /tmp/external_alt\n#\n# # use tmpfs for test\n# mount -t tmpfs external /tm"
},
{
"path": "itest/trash_test.go",
"chars": 2541,
"preview": "package itest\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"strconv\"\n\t\"testing\"\n)\n\nvar (\n\tHOME_TRASH = \"/root/.lo"
},
{
"path": "main.go",
"chars": 295,
"preview": "package main\n\nimport (\n\t\"github.com/umlx5h/gtrash/internal/cmd\"\n)\n\n// set by CI\nvar (\n\tversion = \"unknown\"\n\tcommit = \"u"
}
]
About this extraction
This page contains the full source code of the umlx5h/gtrash GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 53 files (194.5 KB), approximately 60.4k tokens, and a symbol index with 263 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.