[
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the bug**\nA clear and concise description of what the bug is.\n\n**To Reproduce**\nSteps to reproduce the behavior:\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\nPlease run the buggy command with `--debug` option and write down the results.\n```\n$ gtrash find --debug\n```\n\n**Expected behavior**\nA clear and concise description of what you expected to happen.\n\n**Screenshots**\nIf applicable, add screenshots to help explain your problem.\n\n**Version (please complete the following information):**\n - OS: [e.g. Linux, Mac]\n - Version [e.g. 0.0.1]\n\nThe version can be checked with `gtrash --version`\n```\n$ gtrash --version\n```\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Describe the solution you'd like**\nA clear and concise description of what you want to happen.\n\n**Describe alternatives you've considered**\nA clear and concise description of any alternative solutions or features you've considered.\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n"
  },
  {
    "path": ".github/workflows/golangci-lint.yml",
    "content": "# ref: https://github.com/golangci/golangci-lint-action#how-to-use\nname: golangci-lint\non:\n  pull_request:\n  workflow_dispatch:\npermissions:\n  contents: read\njobs:\n  golangci:\n    name: lint\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v3\n      - uses: actions/setup-go@v4\n        with:\n          go-version: '1.22'\n          cache: false\n      - name: golangci-lint\n        uses: golangci/golangci-lint-action@v3\n        with:\n          version: v1.54\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: release\non:\n  push:\n    tags:\n      - \"v*\"\npermissions:\n  contents: write\njobs:\n  release:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Set up Go\n        uses: actions/setup-go@v4\n        with:\n          go-version: '1.22'\n\n      - name: Test\n        run: make test-all\n\n      - name: Run GoReleaser\n        uses: goreleaser/goreleaser-action@v5\n        with:\n          distribution: goreleaser\n          version: latest\n          args: release --clean\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          # needed by homebrew\n          TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}\n          # AUR\n          AUR_KEY: ${{ secrets.AUR_KEY }}\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: test\non:\n  pull_request:\n    branches: [ \"main\" ]\n  workflow_dispatch:\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n    - uses: actions/checkout@v3\n\n    - name: Set up Go\n      uses: actions/setup-go@v4\n      with:\n        go-version: '1.22'\n\n    - name: Test\n      run: make test-all\n"
  },
  {
    "path": ".gitignore",
    "content": "gtrash\ndist/\n__debug_bin*\ncoverage\ncoverage.html\ncoverage.txt\n"
  },
  {
    "path": ".goreleaser.yaml",
    "content": "version: 1\n\nbefore:\n  hooks:\n    - go mod tidy\n\nbuilds:\n  - env:\n      - CGO_ENABLED=0\n    goos:\n      - linux\n      - darwin\n    ldflags:\n      - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser\n    flags:\n      - -trimpath\n\narchives:\n  - format: tar.gz\n    # this name template makes the OS and Arch compatible with the results of `uname`.\n    name_template: >-\n      {{ .ProjectName }}_\n      {{- title .Os }}_\n      {{- if eq .Arch \"amd64\" }}x86_64\n      {{- else if eq .Arch \"386\" }}i386\n      {{- else }}{{ .Arch }}{{ end }}\n      {{- if .Arm }}v{{ .Arm }}{{ end }}\n    # Only include binary in archive\n    files:\n      - none*\n\nchangelog:\n  sort: asc\n  filters:\n    exclude:\n      - \"^docs:\"\n      - \"^test:\"\n\nbrews:\n  - repository:\n      owner: umlx5h\n      name: homebrew-tap\n      token: \"{{ .Env.TAP_GITHUB_TOKEN }}\"\n    homepage: \"https://github.com/umlx5h/gtrash\"\n    description: \"A Trash CLI manager written in Go\"\n    license: \"MIT\"\n\naurs:\n  -\n    name: gtrash-bin\n    homepage: \"https://github.com/umlx5h/gtrash\"\n    description: \"A Trash CLI manager written in Go\"\n    license: \"MIT\"\n    private_key: '{{ .Env.AUR_KEY }}'\n    git_url: 'ssh://aur@aur.archlinux.org/gtrash-bin.git'\n    package: |-\n      # bin\n      install -Dm755 \"./gtrash\" \"${pkgdir}/usr/bin/gtrash\"\n\n      # completions\n      mkdir -p \"${pkgdir}/usr/share/bash-completion/completions/\"\n      mkdir -p \"${pkgdir}/usr/share/zsh/site-functions/\"\n      mkdir -p \"${pkgdir}/usr/share/fish/vendor_completions.d/\"\n\n      ./gtrash completion bash | install -Dm644 /dev/stdin \"${pkgdir}/usr/share/bash-completion/completions/gtrash\"\n      ./gtrash completion zsh | install -Dm644 /dev/stdin \"${pkgdir}/usr/share/zsh/site-functions/_gtrash\"\n      ./gtrash completion fish | install -Dm644 /dev/stdin \"${pkgdir}/usr/share/fish/vendor_completions.d/gtrash.fish\"\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024 umlx5h\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": ".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.txt coverage.html\n\ntest-all: clean test itest report-coverage\n\nlint:\n\tgolangci-lint run\n\ntest:\n\tmkdir -p coverage\n\tgo test -cover -v ./internal/... -args -test.gocoverdir=\"$$PWD/coverage\"\n\nitest:\n\tmkdir -p itest/coverage\n\tgo build -cover\n\tdocker compose run itest\n\nreport-coverage:\n\tgo tool covdata percent -i=./coverage,./itest/coverage\n\tgo tool covdata textfmt -i=./coverage,./itest/coverage -o coverage.txt\n\tgo tool cover -html=coverage.txt -o coverage.html\n"
  },
  {
    "path": "README.md",
    "content": "# gtrash\n\n![demo](doc/image/demo.gif)\n\n`gtrash` is a trash CLI manager that fully complies with the [FreeDesktop.org specification](https://specifications.freedesktop.org/trash-spec/latest/).  \nUnlike `rm`, `gtrash` moves files to the system trash can, enabling easy restoration of important files at any time.\n\nIf you usually use `rm` in the shell, `gtrash` can serve as a substitute.\n\nThis tool utilizes the system trash can on Linux, enabling seamless integration with other CLI and desktop applications.\n\nAdditionally, `gtrash` features a modern TUI interface, making it very intuitive to restore any file.\n\nThe interface is made with an awesome [bubbletea](https://github.com/charmbracelet/bubbletea) TUI framework.\n\n## Table of Contents\n- [Features](#features)\n- [Supported OS](#supported-os)\n- [Installation](#installation)\n- [Usage](#usage)\n- [How it works](#how-it-works)\n- [FAQ](#faq)\n- [Tips](#tips)\n- [Configuration](#configuration)\n- [Related projects](#related-projects)\n\n## Features\n\n- Intuitive TUI interface for file restoration\n  - Allows incremental search for trashed files, enabling the restoration of multiple files simultaneously.\n- Full compliance with FreeDesktop.org specification\n  - Supports directory size caching, enabling fast size-based filtering and pruning.\n- Close compatibility with rm interface\n  - Has rm-like mode, You can customize -r, -d behavior\n- Multi subcommands design in a single static binary written in Go\n- Restoration of co-deleted files together\n- Easy integration with other CLI tools, such as fzf\n- Safe and Ergonomic\n  - Ensures safety by displaying a list and confirmation prompt whenever a file is permanently deleted.\n\n\n## Supported OS\n\n### Linux\nSupported\n\n### Mac\nSupported\n\nbut Mac's system trash can is not used\n\n### Windows\nNot supported\n\nIt works perfectly on WSL2 because it is real Linux\n\n## Installation\n\n### From binaries\n\nDownload the binary from [GitHub Releases](https://github.com/umlx5h/gtrash/releases/latest) and place it in your `$PATH`.\n\nInstall the latest binary to `/usr/local/bin`:\n\n```bash\ncurl -L \"https://github.com/umlx5h/gtrash/releases/latest/download/gtrash_$(uname -s)_$(uname -m).tar.gz\" | tar xz\nchmod a+x ./gtrash\nsudo mv ./gtrash /usr/local/bin/gtrash\n```\n\n### AUR (Arch User Repository)\n\nwith any AUR helpers\n```\nyay -S gtrash-bin\nparu -S gtrash-bin\n```\n\n### Nixpkgs (NixOS)\n\n```\nnix-env -iA nixpkgs.gtrash\n```\n\n### Homebrew (macOS)\n\n```\nbrew install umlx5h/tap/gtrash\n```\n\n### Go install\n\n```\ngo install github.com/umlx5h/gtrash@latest\n```\n\n### Build from source\n\n```bash\ngit clone https://github.com/umlx5h/gtrash.git --depth 1\ncd gtrash\ngo build\n./gtrash\nsudo cp ./gtrash /usr/local/bin/gtrash\n```\n\n## Usage\n\nTo trash a file, use the `put` subcommand.  \nDeleting a directory doesn't require the `-r` option by default.  \n(This behavior can be adjusted using --rm-mode.)\n\n```bash\n$ cd && mkdir dir && touch file1 file2\n$ gtrash put dir file1 file2\n```\n\nThe `summary` subcommand provides information about the trash can, displaying item count and total size.  \nThere is a path name, the file has been moved to this path.\n\n```\n$ gtrash summary\n[/home/user/.local/share/Trash]\nitem: 3\nsize: 4.1 kB\n```\n\nThe `find` subcommand lists the files in the trash.  \nThe `Path` field shows the original file location, not the one in the trash.\n\n```bash\n# gtrash f is also acceptable\n$ gtrash find\nDate                 Path\n2024-01-01 00:00:00  /home/user/dir\n2024-01-01 00:00:00  /home/user/file1\n2024-01-01 00:00:00  /home/user/file2\n```\n\nString queries can be passed as command line arguments for searching files in the trash, using regular expressions by default.\n\n```bash\n$ gtrash find file1 dir\nDate                 Path\n2024-01-01 00:00:00  /home/user/dir\n2024-01-01 00:00:00  /home/user/file1\n```\n\nThere are several ways to restore a file.  \nTo restore with an interactive TUI, use the `restore` subcommand.\n\n```bash\n# gtrash r is also acceptable\n$ gtrash restore\n```\n\n![restore-table](./doc/image/restore_table.jpg)\n\nWithin `restore`, multiple files can be selected for restoration.  \n\nThe table on the left is the list of files in the trash and the table on the right is the list to be restored.\n\nPress `?` for detailed operations.  \nNavigate using `j`, `k`, or the cursor keys.  \nUse `l` or `right arrow key` or `Space` to move files to the right table.  \n\nVim key bindings are used.  \nIncremental searches can be performed with `/`.  \nPress `Enter` after selecting files to restore.\nA list of selected files and a confirmation prompt will appear. Confirm restoration by pressing `y`.\n\n```bash\n$ gtrash restore\nDate                 Path\n2024-01-01 00:00:00  /home/user/dir\n2024-01-01 00:00:00  /home/user/file1\n\nSelected 2 trashed files\nAre you sure you want to restore? yes/no\n```\n\nThere is another type of restoration with TUI.  \nTo restore all the deleted files together in one `put` command, use the `restore-group` subcommand.\n\n```bash\n# gtrash rg is also acceptable\n$ gtrash restore-group\n```\n\nIn above example, `dir1`, `file1`, and `file2` can be restored together.  \nThis is useful when many files were deleted together but you want to restore them at once.\n\nFor non-TUI restoration, use the `--restore` option with `find`.\n\n```bash\n$ gtrash find file1 dir --restore\nDate                 Path\n2024-01-01 00:00:00  /home/user/dir\n2024-01-01 00:00:00  /home/user/file1\n\nFound 2 trashed files\nAre you sure you want to restore? yes/no\n```\n\nTo permanently delete files in the trash, use the `--rm` option with `find`.  \nBe aware that this action is irreversible, akin to rm, and the files cannot be restored.\n\n```\n# Delete specific files\n$ gtrash find file1 --rm\n\n# Delete all files\n$ gtrash find --rm\n```\n\nHelp can be viewed with the `-h` option.\nExamples are provided for each subcommand.\n\n```\n$ gtrash -h\n$ gtrash put -h\n```\n\n\n## How it works\n\n`gtrash` adheres to the [FreeDesktop.org specification](https://specifications.freedesktop.org/trash-spec/latest/).\n\nIts 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.\n\nFiles within the main file system are moved to the following paths in the home directory.\n\n```bash\n# Standard\n$HOME/.local/share/Trash\n\n# If $XDG_DATA_HOME is set\n$XDG_DATA_HOME/Trash\n```\n\nThe files are moved to the `files` directory, while metadata is stored in the `info` directory.\n\n```bash\n$ gtrash put file1\n\n# Records meta information\n$ cat ~/.local/share/Trash/info/file1.trashinfo\n[Trash Info]\nPath=/home/user/file1\nDeletionDate=2024-01-01T00:00:00\n\n# Actual file\n$ ls ~/.local/share/Trash/files/file1\n/home/user/.local/share/Trash/files/file1\n```\n\nFiles within an external file system will be moved to either of the subsequent paths.\n\n```bash\n# If a .Trash folder already exists, it will be used.\n# ($uid folder is created automatically)\n# The .Trash directory requires a sticky bit set (can be added using chmod +t)\n$MOUNTPOINT/.Trash/$uid\n\n# Used when the above directory is unavailable (typically used)\n$MOUNTPOINT/.Trash-$uid\n```\n\nTo use the first directory, create a `.Trash` directory in advance:\n\n```bash\n# You can check with the df command\n$ cd $MOUNTPOINT\n\n$ mkdir .Trash\n$ chmod a+rw .Trash\n\n# Set the sticky bit\n$ chmod +t .Trash\n```\n\n`$MOUNTPOINT` is the same as the information displayed in the `df` command.\n\n```\n# Mounted on\n$ df foo\nFilesystem Size  Used Avail Use% Mounted on\n/dev/sda   54G   48G  3.6G  93% /\n```\n\nThe `mv` command copies and deletes files when moving across file systems.  \nThis process consumes more time and increases disk usage on the destination file system.  \n\nThe inability to use the [rename(2)](https://man7.org/linux/man-pages/man2/rename.2.html) syscall across different file systems necessitates this behavior.\n\nFor this reason, `gtrash` attempts to move files to the trash can within the same file system whenever possible.  \nYou can also configure it to always use the trash can in the `$HOME` directory.\n\nRefer to the [Configuration](doc/configuration.md) for further details.\n\nThe `summary` subcommand lists paths to all trash cans:\n\n```bash\n$ gtrash summary\n```\n\nUsing the `--show-trashpath` option with the `find` command displays the real path for each trashed file.\n\n```bash\n$ gtrash find --show-trashpath\n```\n\nFor detailed behavior insights, run the command with the `--debug` option to view internal processes.\n\n\n## FAQ\n### What's the difference between this command and the rm command?\n\nWhile `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.\n\n`gtrash` aims to mirror the `rm` interface but ignores `-r`, `-R`, `--recursive`, and `-d` by default.\n\n```bash\n$ gtrash put -h\nFlags:\n  -d, --dir               ignored unless --rm-mode set\n  -f, --force             ignore nonexistent files and arguments\n  -i, --interactive       prompt before every removal\n  -I, --interactive-once  prompt once before trashing\n  -r, -R, --recursive     ignored unless --rm-mode set\n      --rm-mode           enable rm-like mode (change behavior -r, -R, -d)\n  -v, --verbose           explain what is being done\n```\n\nThe `-r` option is not necessary for deleting folders since files are restorable even if mistakenly removed.\n\nHowever, some users may prefer the `rm` behavior. In such cases, enable the above option with `--rm-mode`.\n(Although it is not completely compatible.)\n\n```bash\n# To delete a folder, -r or -d is required.\n$ gtrash put --rm-mode dir1/\ngtrash: cannot trash \"dir1/\": Is a directory\n\n$ gtrash put --rm-mode -r dir1/\n```\n\nThis behavior can be set using an environment variable or an alias, whichever suits your preference.\n\n```\n# Same as --rm-mode\n$ GTRASH_PUT_RM_MODE=\"true\" gtrash put -r dir/\n\n# Alias is also possible\n$ alias gtrash-put=\"gtrash put --rm-mode\"\n```\n\n### What are the advantages of using a system trash can?\n\n`gtrash` offers several benefits over similar applications like [rip](https://github.com/nivekuil/rip) that don't utilize the Linux system trash can.\n\n* Seamless integration with CLI and desktop applications following the FreeDesktop specification.\n* Support for various trash cans, even on different file systems, enabling fast file movement between them.\n* Compatibility with standard specifications ensures smoother migration to alternative applications adhering to the same standards.\n  * Unique specifications become a problem when they are no longer maintained.\n\n### Can I alias `rm=gtrash put`?\n\nYou can but I do not recommend due to potential risks, unintentionally executing actual `rm` commands, such as `sudo rm` or on SSH servers.\n\nAs `gtrash` isn't fully compatible with `rm`, it's prudent to establish different aliases to avoid confusion and prevent accidental deletion of files.\n\nConsider setting up alternative short aliases, such as:\n\n```bash\nalias gp='gtrash put' # gtrash put\nalias gm='gtrash put' # gtrash move (easy to change to rm)\nalias tp='gtrash put' # trash put\nalias tm='gtrash put' # trash move (easy to change to rm)\nalias tt='gtrash put' # to trash\n```\n\nIf you are in the habit of using rm, consider creating an alias that displays a cautionary message.\n\n```bash\nalias rm=\"echo -e 'If you want to use rm really, then use \\\"\\\\\\\\rm\\\" instead.'; false\"\n```\n\nWhen you want to execute the actual rm, use `\\rm` to bypass the alias.\n\n```bash\n$ rm foo\nIf you want to use rm really, then use \"\\rm\" instead.\n\n$ \\rm foo\n```\n\n### Can I run `trash put` in one command without using alias?\n\nIn certain situations, supporting trash cans within programs might necessitate working with a trash CLI without the ability to specify a subcommand like `gtrash put`.\n\nIn such cases, consider a simple wrapper script.\n\n```bash\n# Locate gtrash binary path\n$ which gtrash\n/usr/local/bin/gtrash\n\n$ sudo vim /usr/local/bin/gtrash-put\n#!/bin/sh\n\n# Specify gtrash binary path\nexec /usr/local/bin/gtrash put \"$@\"\n\n$ sudo chmod a+x /usr/local/bin/gtrash-put\n```\n\nWith this setup, execute a single command.\n\n```bash\n$ gtrash-put somefile\n```\n\nThis wrapper facilitates direct execution without needing an alias.\n\n### Is gtrash compatible with the gio trash and trash-put commands?\n\nIt uses the exact same trash FreeDesktop specification as `gio trash` and `trash-cli`, so they are compatible.  \nIf some program depends on these clis and cannot be changed, there is no need to change to `gtrash put`.\n\n### Typing `gtrash` takes too long\n\nSet up an different alias from put.\n\nExample\n```bash\nalias gp=\"gtrash put\"\nalias g=\"gtrash\"\n```\n\nOr you could setup a symbolic link.\n\n```bash\nsudo ln -s /usr/local/bin/gtrash /usr/local/bin/g\n```\n\nNote that gtrash works perfectly well with any binary name.\n\nIf you don't like the name \"gtrash\", you can change it to whatever name you like.\n\n```\nsudo mv /usr/local/bin/gtrash /usr/local/bin/rip\n```\n\nHowever, be aware that if you are installing via a package manager, you may not be able to update it.\n\n### What happens when I run it with sudo?\n\nFiles are moved to the Trash under the `root` user's home directory.\n\n```\n$ sudo bash -c 'echo $HOME'\n/root\n```\n\nThe `-v` option displays the location where the file was moved.\n\n```\n$ sudo gtrash put -v file1\ntrashed \"file1\" to /root/.local/share/Trash\n```\n\nThe `summary` subcommand can also reveal the Trash can location.\n```\n$ sudo gtrash summary\n[/root/.local/share/Trash]\nitem: 10\nsize: 62 kB\n```\n\nTo restore or delete a file deleted with sudo, you need to use sudo.\n\n### How does the `restore-group` subcommand work?\n\nThe `restore-group` subcommand can restore multiple deleted files simultaneously with one command.\n\n```bash\n$ gtrash put file1 file2 dir\n\n# You can restore file1, file2, dir together\n$ gtrash restore-group\n```\n\nHowever, it's not an exact grouping of files deleted at the same time.  \nFiles with the same deletion timestamp recorded in seconds are simply grouped together.\n\nWhen files are trashed using `gtrash put`, they are designed to have the same timestamp, allowing reliable grouping.  \nBut this isn't guaranteed if trashed via other apps.\n\nNote that multiple `gtrash put` commands executed within one second are also grouped together.\n\nIn an interactive shell executing `gtrash put`, timestamps rarely match within seconds. However, caution is needed when running it via a shell script.\n\nIn such cases, use the `restore` subcommand to select specific files.\n\n### What does the `metafix` subcommand do?\n\n`trash put` command records meta-information in the `info` folder in the Trash directory and moves files to the `files` directory.\n\n```bash\n$ gtrash put file1\n\n# Records meta information\n$ cat ~/.local/share/Trash/info/file1.trashinfo\n[Trash Info]\nPath=/home/user/file1\nDeletionDate=2024-01-01T00:00:00\n\n# Actual file\n$ ls ~/.local/share/Trash/files/file1\n/home/user/.local/share/Trash/files/file1\n```\n\nFor instance, if you manually delete files from the `files` directory, the trash will become inconsistent.\n\n```bash\n# Deletes the file only, not the meta info\n$ rm ~/.local/share/Trash/files/file1\n```\n\nIn such cases, `find` and `restore` commands won't display inconsistent orphaned meta-information.\n\n`metafix` can detect this condition and remove unnecessary meta-information.\n\n```bash\n$ gtrash metafix\nDate                 Path\n2024-01-01 00:00:00  /home/user/file1\n\nFound invalid metadata: 1\nAre you sure you want to remove invalid metadata? yes/no\n```\n\nThe following trashinfo file will be deleted instead of the file.\n\n```bash\n$ ls ~/.local/share/Trash/info/file1.trashinfo\nls: cannot access '/home/user/.local/share/Trash/info/file1.trashinfo': No such file or directory\n```\n\n### The display in the TUI is corrupted\n\nIt seems that the table in TUI may be corrupted on certain terminals.  \nThe display of the library used itself may be corrupted and may not be able to be fixed.  \nIn that case, I recommend migrating the terminal.\n\nTerminal confirmed that it cannot be fixed\n* KDE Konsole\n\nTerminal confirmed to work\n* Wezterm\n* Alacritty\n* Kitty\n* GNOME Terminal\n* Xfce Terminal\n* Windows Terminal\n* Mac Terminal\n* Mac iTerm2\n\nIf you find a problem, please open an ticket.\n\n## Tips\n\n### Shell Integration\n\n`gtrash` supports `bash`, `zsh`, `fish` shell integration.  \nSee `--help` for further details.\n\n```bash\ngtrash completion bash --help\ngtrash completion zsh --help\ngtrash completion fish --help\n```\n\n### Filtering by the current working directory or specific directory\n\nBy default, `find` and `restore` display all files, not limited to the current directory.  \nThis differs from other applications.\n\nYou can filter using the `-c` option (`--cwd`).\n\n```bash\n# --cwd is also acceptable\n$ gtrash find -c\n$ gtrash restore -c\n```\n\nThe `restore` subcommand also supports filtering by the current directory in the TUI.\n\nAvoid using `-c`, directly access the TUI, and press the `c` key to toggle filtering.\n\n```bash\n$ gtrash restore\n\n# Press the c key\n```\n\nThe `-d` or `--directory` option allows filtering in directories other than the current one.\n\n```bash\n# Specify an absolute path\n$ gtrash find -d /tmp\n\n# relative path is also supported\n$ gtrash find -d ./foo\n\n# Same as -c\n$ gtrash find -d .\n```\n\n### Fuzzy find\n\nFuzzy find isn't currently implemented due to complexity.  \nHowever, `gtrash` is designed to work seamlessly with other commands like fzf.\n\nThe find subcommand outputs a tab-delimited table if it detects pipe or file output other than a terminal.  \nThis enables easy field extraction using tools like awk.\n\nExample with fzf:\n\n```bash\n# Fuzzy find one item and get the original path\n# Specify -F'\\t' due to tab-delimited output\n$ gtrash find | fzf | awk -F'\\t' '{print $2}'\n\n# Fuzzy find multiple items and get the original path\n$ gtrash find | fzf --multi | awk -F'\\t' '{print $2}'\n```\n\nFor permanent removal or restoration, specify the original path as a command-line argument in the `rm` or `restore` subcommand.  \nNote that the `-o` option must be specified when using `xargs` to display the confirmation prompt.\n\n```bash\n# Fuzzy find multiple items and permanently remove them\n$ gtrash find | fzf --multi | awk -F'\\t' '{print $2}' | xargs -o gtrash rm\n\n# Fuzzy find multiple items and restore them\n$ gtrash find | fzf --multi | awk -F'\\t' '{print $2}' | xargs -o gtrash restore\n```\n\n### Pruning the trash can by size and date criteria\n\nDate-based:\n\nCurrently possible only by day.\n\n```bash\n# Remove files deleted over a week ago\n$ gtrash prune --day 7\n\n# Almost the same as prune\n$ gtrash find --day-old 7 --rm\n```\n\nSize-based:\n\nThere are two methods.\n\n`find` filters by the specified size and removes them.\n\n```bash\n# Remove trashed files larger than 10MB\n$ gtrash find --size-large 10mb --rm\n\n# '10m' is also acceptable\n$ gtrash find --size-large 10m --rm\n\n# Remove trashed files larger than 1GB\n$ gtrash find --size-large 1gb --rm\n\n# Remove empty trashed files\n$ gtrash find --size-small 0 --rm\n```\n\n`prune` removes large files first so that the overall trash size is smaller than the specified size:\n```\n# After this, the size of the trash can is guaranteed to be less than 5 GB.\n$ gtrash prune --size 5GB\n\n# If you want to exclude recently deleted files, you can also specify day.\n$ gtrash prune --size 5GB --day 7\n```\n\nSizes and dates can be combined in `find`, and other filters can be applied:\n```bash\n# Remove files older than a week and larger than 10MB\n$ gtrash find --day-old 7 --size-large 10mb --rm\n\n# Remove files older than a week, larger than 10MB, and containing 'foo' in the path\n$ gtrash find --day-old 7 --size-large 10mb --rm foo\n```\n\n## Configuration\n\nCertain behaviors can be altered by setting environment variables.  \nRefer to the [Configuration](doc/configuration.md).\n\n## Related projects\n\n### Using system trash can\n\n* [andreafrancia/trash-cli](https://github.com/andreafrancia/trash-cli)\n* [oberblastmeister/trashy](https://github.com/oberblastmeister/trashy)\n* [rushsteve1/trash-d](https://github.com/rushsteve1/trash-d)\n\nFor a comparison, You can see [alternatives.md](doc/alternatives.md).\n\n### Not using system trash can\n\n* [nivekuil/rip](https://github.com/nivekuil/rip)\n* [babarot/gomi](https://github.com/babarot/gomi)\n\nThis program is mainly inspired by [babarot/gomi](https://github.com/babarot/gomi).\n"
  },
  {
    "path": "doc/alternatives.md",
    "content": "# Alternatives\n\n|                                                              | gtrash                            | [trash-cli](https://github.com/andreafrancia/trash-cli) | [trashy](https://github.com/oberblastmeister/trashy) | [trash-d](https://github.com/rushsteve1/trash-d) |\n| ------------------------------------------------------------ | --------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- | ------------------------------------------------ |\n| Language                                                     | Go                                | Python                                                  | Rust                                                 | D                                                |\n| Supported OS                                                 | Linux,Mac                         | Linux,Mac                                               | Linux,Windows                                        | Linux,Mac                                        |\n| Architecture                                                 | Single binary & Multi subcommands | Multi commands                                          | Single binary & Multi subcommands                    | Single binary                                    |\n| Has rm-like interface                                        | ✔️                                 | ✔️                                                       | ❌                                                    | ✔️                                                |\n| Restore with TUI (incremental search & multi select items)   | ✔️                                 | ❌                                                       | ❌                                                    | ❌                                                |\n| Restore as a group                                           | ✔️                                 | ❌                                                       | ❌                                                    | ❌                                                |\n| Can show file and directory size                             | ✔️                                 | ❌                                                       | ❌                                                    | ❌                                                |\n| Can show summary of trash cans (total items, size)           | ✔️                                 | ❌                                                       | ❌                                                    | ❌                                                |\n| Support FreeDesktop.org directorysize cache                  | ✔️                                 | ❌                                                       | ❌                                                    | ✔ (only put, can not list)                       |\n| Support FreeDesktop.org fallback to home trash               | ✔️                                 | ✔️                                                       | ❌                                                    | Not support external filesystem trash can        |\n| Size-based pruning                                           | ✔️                                 | ❌                                                       | ❌                                                    | ❌                                                |\n| Date-based pruning                                           | ✔️                                 | ✔️                                                       | ✔️                                                    | ❌                                                |\n| Safe (Always show a confirmation prompt before deleting files by default?) | ✔️                                 | ❌                                                       | ✔️                                                    | ❌                                                |\n| Sort trashed items by deletion date by default?              | ✔️                                 | ❌                                                       | ✔️                                                    | ❌                                                |\n\n\nIf you think that some entries in this table are outdated or wrong, please open a issue or pull request.\n"
  },
  {
    "path": "doc/configuration.md",
    "content": "# Configration\n\nCertain behaviors can be altered by setting environment variables.  \n\n## GTRASH_HOME_TRASH_DIR\n\n- Type: string\n- Default: `$XDG_DATA_HOME/Trash ($HOME/.local/share/Trash)`\n\nChange the location of the main file system's trash can by specifying the full path.\n\nExample: If you prefer placing it directly under your home directory:\n\n```bash\nexport GTRASH_HOME_TRASH_DIR=\"$HOME/.gtrash\"\n```\n\n## GTRASH_ONLY_HOME_TRASH\n\n- Type: bool ('true' or 'false')\n- Default: `false`\n\nEnabling this option ensures the sole usage of the home directory's trash can.\n\nWhen 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.\n\nBy 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.\n\nIf you encounter such issues, enabling this option can be helpful.\n\n```bash\nexport GTRASH_ONLY_HOME_TRASH=\"true\"\n```\n\n## GTRASH_HOME_TRASH_FALLBACK_COPY\n\n- Type: bool ('true' or 'false')\n- Default: `false`\n\nEnable 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.\n\nIt can also be set using the `--home-fallback` option.\n\n```bash\n$ gtrash put --home-fallback /external/file1\n\n# Equivalent to the above\n$ GTRASH_HOME_TRASH_FALLBACK_COPY=\"true\" gtrash put /external/file1\n\n# To disable it when enabled in the environment variable\n$ GTRASH_HOME_TRASH_FALLBACK_COPY=\"true\" gtrash put --home-fallback=false /external/file1\n```\n\n```bash\nexport GTRASH_HOME_TRASH_FALLBACK_COPY=\"true\"\n```\n\n## GTRASH_PUT_RM_MODE\n\n- Type: bool ('true' or 'false')\n- Default: `false`\n\nEnabling this option changes the behavior of the `put` command as closely as possible to `rm`.\n\nThe `-r`, `--recursive`, `-R`, `-d` options closely resemble `rm` behavior. When set to false, these options are completely ignored.\n\nThis setting can also be configured using the `--rm-mode` option.\n\n```bash\n$ gtrash put --rm-mode -r dir1/\n\n# Equivalent to the above\n$ GTRASH_PUT_RM_MODE=\"true\" gtrash put -r dir/\n```\n\n```bash\nexport GTRASH_PUT_RM_MODE=\"true\"\n```\n"
  },
  {
    "path": "doc/image/demo.tape",
    "content": "# VHS documentation\n#\n# Output:\n#   Output <path>.gif               Create a GIF output at the given <path>\n#   Output <path>.mp4               Create an MP4 output at the given <path>\n#   Output <path>.webm              Create a WebM output at the given <path>\n#\n# Require:\n#   Require <string>                Ensure a program is on the $PATH to proceed\n#\n# Settings:\n#   Set FontSize <number>           Set the font size of the terminal\n#   Set FontFamily <string>         Set the font family of the terminal\n#   Set Height <number>             Set the height of the terminal\n#   Set Width <number>              Set the width of the terminal\n#   Set LetterSpacing <float>       Set the font letter spacing (tracking)\n#   Set LineHeight <float>          Set the font line height\n#   Set LoopOffset <float>%         Set the starting frame offset for the GIF loop\n#   Set Theme <json|string>         Set the theme of the terminal\n#   Set Padding <number>            Set the padding of the terminal\n#   Set Framerate <number>          Set the framerate of the recording\n#   Set PlaybackSpeed <float>       Set the playback speed of the recording\n#   Set MarginFill <file|#000000>   Set the file or color the margin will be filled with.\n#   Set Margin <number>             Set the size of the margin. Has no effect if MarginFill isn't set.\n#   Set BorderRadius <number>       Set terminal border radius, in pixels.\n#   Set WindowBar <string>          Set window bar type. (one of: Rings, RingsRight, Colorful, ColorfulRight)\n#   Set WindowBarSize <number>      Set window bar size, in pixels. Default is 40.\n#   Set TypingSpeed <time>          Set the typing speed of the terminal. Default is 50ms.\n#\n# Sleep:\n#   Sleep <time>                    Sleep for a set amount of <time> in seconds\n#\n# Type:\n#   Type[@<time>] \"<characters>\"    Type <characters> into the terminal with a\n#                                   <time> delay between each character\n#\n# Keys:\n#   Escape[@<time>] [number]        Press the Escape key\n#   Backspace[@<time>] [number]     Press the Backspace key\n#   Delete[@<time>] [number]        Press the Delete key\n#   Insert[@<time>] [number]        Press the Insert key\n#   Down[@<time>] [number]          Press the Down key\n#   Enter[@<time>] [number]         Press the Enter key\n#   Space[@<time>] [number]         Press the Space key\n#   Tab[@<time>] [number]           Press the Tab key\n#   Left[@<time>] [number]          Press the Left Arrow key\n#   Right[@<time>] [number]         Press the Right Arrow key\n#   Up[@<time>] [number]            Press the Up Arrow key\n#   Down[@<time>] [number]          Press the Down Arrow key\n#   PageUp[@<time>] [number]        Press the Page Up key\n#   PageDown[@<time>] [number]      Press the Page Down key\n#   Ctrl+<key>                      Press the Control key + <key> (e.g. Ctrl+C)\n#\n# Display:\n#   Hide                            Hide the subsequent commands from the output\n#   Show                            Show the subsequent commands in the output\nOutput ../demo.gif\n# Output ../demo.webm\n\nRequire gtrash\n\nSet Shell \"zsh\"\nSet FontSize 24\nSet Width 1240\nSet Height 800\nSet Theme \"Snazzy\"\n\nHide\nType \"setopt interactivecomments\" Enter\nType \"clear\" Enter\nShow\n\nType \"ls\" Sleep 1000ms Enter\nType \"gtrash put *\" Sleep 500ms\nTab@100ms\nSleep 500ms Enter\n\nType \"ls\" Sleep 500ms Enter\nType \"gtrash find\" Enter\nSleep 1s\n\nType \"# Lets restore files with TUI interface!\" Sleep 50ms Enter\nType \"gtrash restore\" Sleep 300ms Enter\n\nSleep 2s\n\nType \"j\" Sleep 150ms\nType \"l\" Sleep 500ms\nType \"j\" Sleep 150ms\nType \"/\" Sleep 100ms\nType \"main.go\" Sleep 300ms Enter\nType \"l\" Sleep 800ms Enter\nSleep 1000ms Type \"y\"\n\nType \"ls\" Sleep 100ms Enter\n\nSleep 1s\n\nType \"# Lets restore deleted files all at once!\" Sleep 50ms Enter\nType \"gtrash restore-group\" Sleep 300ms Enter\n\nSleep 2s Enter Sleep 2s Type \"y\" Sleep 300ms\nType \"ls\" Enter\nSleep 1s\n\nType \"# Now restored!\" Enter\nSleep 2s\n"
  },
  {
    "path": "docker-compose.yaml",
    "content": "services:\n  itest:\n    image: golang:1.22\n    working_dir: /app\n    tmpfs:\n      - /external\n      - /external_alt\n    environment:\n      - GOCOVERDIR=./coverage\n    volumes:\n      - ./gtrash:/app/gtrash:ro\n      - ./itest:/app/itest\n      - ./go.mod:/app/go.mod:ro\n    # privileged: true\n    command:\n      - /bin/bash\n      - -c\n      - |\n        set -eu\n        bash ./itest/setup.sh\n        go test -v ./itest\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/umlx5h/gtrash\n\ngo 1.22.4\n\nrequire (\n\tgithub.com/charmbracelet/bubbles v0.18.0\n\tgithub.com/charmbracelet/bubbletea v0.26.6\n\tgithub.com/charmbracelet/lipgloss v0.11.0\n\tgithub.com/dustin/go-humanize v1.0.1\n\tgithub.com/gobwas/glob v0.2.3\n\tgithub.com/juju/ansiterm v1.0.0\n\tgithub.com/lmittmann/tint v1.0.4\n\tgithub.com/moby/sys/mountinfo v0.7.1\n\tgithub.com/otiai10/copy v1.14.0\n\tgithub.com/rs/xid v1.5.0\n\tgithub.com/spf13/cobra v1.8.1\n\tgithub.com/spf13/pflag v1.0.5\n\tgithub.com/stretchr/testify v1.8.4\n\tgithub.com/umlx5h/go-runewidth v0.0.0-20240106112317-9bbbb3702d5f\n\tgolang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8\n\tgolang.org/x/term v0.21.0\n)\n\nrequire (\n\tgithub.com/atotto/clipboard v0.1.4 // indirect\n\tgithub.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect\n\tgithub.com/charmbracelet/x/ansi v0.1.2 // indirect\n\tgithub.com/charmbracelet/x/input v0.1.2 // indirect\n\tgithub.com/charmbracelet/x/term v0.1.1 // indirect\n\tgithub.com/charmbracelet/x/windows v0.1.2 // indirect\n\tgithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect\n\tgithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect\n\tgithub.com/inconshreveable/mousetrap v1.1.0 // indirect\n\tgithub.com/kr/pretty v0.3.1 // indirect\n\tgithub.com/lucasb-eyer/go-colorful v1.2.0 // indirect\n\tgithub.com/lunixbochs/vtclean v1.0.0 // indirect\n\tgithub.com/mattn/go-colorable v0.1.13 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20 // indirect\n\tgithub.com/mattn/go-localereader v0.0.1 // indirect\n\tgithub.com/mattn/go-runewidth v0.0.15 // indirect\n\tgithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect\n\tgithub.com/muesli/cancelreader v0.2.2 // indirect\n\tgithub.com/muesli/termenv v0.15.2 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgithub.com/rogpeppe/go-internal v1.11.0 // indirect\n\tgithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect\n\tgolang.org/x/sync v0.7.0 // indirect\n\tgolang.org/x/sys v0.21.0 // indirect\n\tgolang.org/x/text v0.16.0 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=\ngithub.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=\ngithub.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=\ngithub.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=\ngithub.com/charmbracelet/bubbletea v0.26.6 h1:zTCWSuST+3yZYZnVSvbXwKOPRSNZceVeqpzOLN2zq1s=\ngithub.com/charmbracelet/bubbletea v0.26.6/go.mod h1:dz8CWPlfCCGLFbBlTY4N7bjLiyOGDJEnd2Muu7pOWhk=\ngithub.com/charmbracelet/lipgloss v0.11.0 h1:UoAcbQ6Qml8hDwSWs0Y1cB5TEQuZkDPH/ZqwWWYTG4g=\ngithub.com/charmbracelet/lipgloss v0.11.0/go.mod h1:1UdRTH9gYgpcdNN5oBtjbu/IzNKtzVtb7sqN1t9LNn8=\ngithub.com/charmbracelet/x/ansi v0.1.2 h1:6+LR39uG8DE6zAmbu023YlqjJHkYXDF1z36ZwzO4xZY=\ngithub.com/charmbracelet/x/ansi v0.1.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=\ngithub.com/charmbracelet/x/input v0.1.2 h1:QJAZr33eOhDowkkEQ24rsJy4Llxlm+fRDf/cQrmqJa0=\ngithub.com/charmbracelet/x/input v0.1.2/go.mod h1:LGBim0maUY4Pitjn/4fHnuXb4KirU3DODsyuHuXdOyA=\ngithub.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI=\ngithub.com/charmbracelet/x/term v0.1.1/go.mod h1:wB1fHt5ECsu3mXYusyzcngVWWlu1KKUmmLhfgr/Flxw=\ngithub.com/charmbracelet/x/windows v0.1.2 h1:Iumiwq2G+BRmgoayww/qfcvof7W/3uLoelhxojXlRWg=\ngithub.com/charmbracelet/x/windows v0.1.2/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ=\ngithub.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=\ngithub.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=\ngithub.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=\ngithub.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=\ngithub.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=\ngithub.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=\ngithub.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=\ngithub.com/juju/ansiterm v1.0.0 h1:gmMvnZRq7JZJx6jkfSq9/+2LMrVEwGwt7UR6G+lmDEg=\ngithub.com/juju/ansiterm v1.0.0/go.mod h1:PyXUpnI3olx3bsPcHt98FGPX/KCFZ1Fi+hw1XLI6384=\ngithub.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=\ngithub.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/lmittmann/tint v1.0.4 h1:LeYihpJ9hyGvE0w+K2okPTGUdVLfng1+nDNVR4vWISc=\ngithub.com/lmittmann/tint v1.0.4/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=\ngithub.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=\ngithub.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8=\ngithub.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=\ngithub.com/mattn/go-colorable v0.1.10/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=\ngithub.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=\ngithub.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=\ngithub.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=\ngithub.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=\ngithub.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=\ngithub.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=\ngithub.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=\ngithub.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=\ngithub.com/moby/sys/mountinfo v0.7.1 h1:/tTvQaSJRr2FshkhXiIpux6fQ2Zvc4j7tAhMTStAG2g=\ngithub.com/moby/sys/mountinfo v0.7.1/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=\ngithub.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=\ngithub.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=\ngithub.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=\ngithub.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=\ngithub.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=\ngithub.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU=\ngithub.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w=\ngithub.com/otiai10/mint v1.5.1 h1:XaPLeE+9vGbuyEHem1JNk3bYc7KKqyI/na0/mLd/Kks=\ngithub.com/otiai10/mint v1.5.1/go.mod h1:MJm72SBthJjz8qhefc4z1PYEieWmy8Bku7CjcAqyUSM=\ngithub.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=\ngithub.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=\ngithub.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=\ngithub.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=\ngithub.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=\ngithub.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=\ngithub.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=\ngithub.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=\ngithub.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=\ngithub.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=\ngithub.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=\ngithub.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=\ngithub.com/umlx5h/go-runewidth v0.0.0-20240106112317-9bbbb3702d5f h1:T8MNFeOIelXNJyNQ5WIMz2zUXYpUq71+3Z5dbXqWCd8=\ngithub.com/umlx5h/go-runewidth v0.0.0-20240106112317-9bbbb3702d5f/go.mod h1:+aP7JKaGs4irGEvKbEMTjKb1uKLoRZKMrrUwdGzajsk=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=\ngolang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=\ngolang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=\ngolang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=\ngolang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=\ngolang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=\ngolang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=\ngolang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=\ngolang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=\ngolang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=\ngolang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=\ngolang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=\ngopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "internal/cmd/find.go",
    "content": "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/ansiterm\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/umlx5h/gtrash/internal/glog\"\n\t\"github.com/umlx5h/gtrash/internal/trash\"\n\t\"github.com/umlx5h/gtrash/internal/tui\"\n)\n\ntype findCmd struct {\n\tcmd  *cobra.Command\n\topts findOptions\n}\n\ntype findOptions struct {\n\tdirectory string\n\tcwd       bool\n\tsortBy    trash.SortByType\n\tmodeBy    trash.ModeByType\n\n\t// do options\n\tdoRemove  bool\n\tdoRestore bool\n\tforce     bool\n\n\tdayNew int // unit day\n\tdayOld int\n\n\tsizeLarge string\n\tsizeSmall string\n\n\treverse bool\n\tlast    int\n\n\t// control display info\n\tshowSize      bool\n\tshowTrashPath bool\n\n\trestoreTo string\n\n\ttrashDir string\n}\n\nfunc newFindCmd() *findCmd {\n\troot := &findCmd{}\n\tcmd := &cobra.Command{\n\t\tUse:     \"find [QUERY...]\",\n\t\tAliases: []string{\"f\"},\n\t\tShort:   \"Find trashed files and do restore or remove them (f)\",\n\t\tLong: `Description:\n  Displays and searches all trashed files.\n  You can search by entering a string as a command-line argument.\n\n  To delete or restore the searched files, use the --rm and --restore options, respectively.`,\n\t\tExample: `  # Show all trashed files\n  $ gtrash find\n\n  # Show files under the current directory\n  $ gtrash find --cwd\n\n  # Searching for files using regular expressions and do restore\n  # If you use special symbols, please use quotes to prevent shell expansion\n  $ gtrash find 'regex' --restore\n\n  # Display the actual file path and file size at the same time\n  $ gtrash find --show-size --show-trashpath\n\n  # Showing the 10 most recently deleted\n  $ gtrash find -n 10\n\n  # Showing 10 files sorted by file size\n  $ gtrash find -n 10 --sort size\n\n  # Delete all files (CAUTION)\n  $ gtrash find --rm\n\n  # Restore all files\n  $ gtrash find --restore\n\n  # Remove files deleted over a week ago\n  $ gtrash find --day-old 7 --rm\n\n  # Remove trashed files larger than 10MB\n  $ gtrash find --size-large 10mb --rm\n\n  # Fuzzy find multiple items and remove them permanently\n  # The -o in xargs is necessary for the confirmation prompt to display.\n  $ gtrash find | fzf --multi | awk -F'\\t' '{print $2}' | xargs -o gtrash rm`,\n\t\tSilenceUsage: true,\n\t\tRunE: func(_ *cobra.Command, args []string) error {\n\t\t\tif err := findCmdRun(args, root.opts); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif glog.ExitCode() > 0 {\n\t\t\t\treturn errContinue\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.Flags().StringVarP(&root.opts.directory, \"directory\", \"d\", \"\", \"Filter by directory\")\n\tcmd.Flags().StringVar(&root.opts.sizeLarge, \"size-large\", \"\", \"Filter by size larger  (e.g. 5MB, 1GB)\")\n\tcmd.Flags().StringVar(&root.opts.sizeSmall, \"size-small\", \"\", \"Filter by size smaller (e.g. 5MB, 1GB)\")\n\tcmd.Flags().BoolVarP(&root.opts.cwd, \"cwd\", \"c\", false, \"Filter by current working directory\")\n\tcmd.Flags().VarP(&root.opts.sortBy, \"sort\", \"s\", \"Sort by\")\n\tcmd.Flags().VarP(&root.opts.modeBy, \"mode\", \"m\", `query mode\nregex (default):\n    Go language regular expression engine is used.\n    You can test it at the following site\n    ref: https://regex101.com\n\nglob:\n    Glob patterns can be specified.\n    The following engine is used, please refer to the following site for notation.\n    ref: https://github.com/gobwas/glob\n\nliteral:\n    Ignores case and performs literal matching\n    If it matches part of the path, it will hit.\n\nfull:\n    Matches an exact match to a full path.\n    Case sensitive.`)\n\tcmd.Flags().BoolVar(&root.opts.doRemove, \"rm\", false, \"Do remove PERMANENTLY\")\n\tcmd.Flags().BoolVar(&root.opts.doRestore, \"restore\", false, \"Do restore\")\n\tcmd.Flags().BoolVarP(&root.opts.force, \"force\", \"f\", false, `Always do --rm or --restore without confirmation prompt\nThis is not necessary if running outside of a terminal`)\n\tcmd.Flags().IntVar(&root.opts.dayNew, \"day-new\", 0, \"Filter by deletion date (within X day)\")\n\tcmd.Flags().IntVar(&root.opts.dayOld, \"day-old\", 0, \"Filter by deletion date (before X day)\")\n\tcmd.Flags().BoolVarP(&root.opts.showSize, \"show-size\", \"S\", false, `Show size always\nAutomatically enabled if --sort size, --size-large, --size-small specified\n\nIf the size could not be obtained, it will be displayed as '-'\n\nNote that this may take longer due to recursive size calcuration for directories.\nThe folder size is cached, so it will run faster the next time.\n`)\n\tcmd.Flags().BoolVar(&root.opts.showTrashPath, \"show-trashpath\", false, \"Show trash path\")\n\tcmd.Flags().BoolVarP(&root.opts.reverse, \"reverse\", \"r\", false, \"Reverse sort order (default: ascending)\")\n\tcmd.Flags().StringVar(&root.opts.restoreTo, \"restore-to\", \"\", \"Restore to this path instead of original path\")\n\tcmd.Flags().IntVarP(&root.opts.last, \"last\", \"n\", 0, \"Show n last files\")\n\tcmd.Flags().StringVar(&root.opts.trashDir, \"trash-dir\", \"\", `Specify a full path if you want to search only a specific trash can\nBy default, all trash cans are searched.\n\nFor $HOME trash only:\n    --trash-dir \"$HOME/.local/share/Trash\"\n`)\n\n\tcmd.MarkFlagsMutuallyExclusive(\"rm\", \"restore\")\n\tcmd.MarkFlagsMutuallyExclusive(\"directory\", \"cwd\")\n\tcmd.MarkFlagsMutuallyExclusive(\"day-new\", \"day-old\")\n\tcmd.MarkFlagsMutuallyExclusive(\"size-large\", \"size-small\")\n\n\tif err := cmd.RegisterFlagCompletionFunc(\"sort\", trash.SortByFlagCompletionFunc); err != nil {\n\t\tpanic(err)\n\t}\n\tif err := cmd.RegisterFlagCompletionFunc(\"mode\", trash.ModeByFlagCompletionFunc); err != nil {\n\t\tpanic(err)\n\t}\n\n\troot.cmd = cmd\n\treturn root\n}\n\nfunc findCmdRun(args []string, opts findOptions) error {\n\tslog.Debug(\"starting find\", \"args\", args, \"doRemove\", opts.doRemove, \"doRestore\", opts.doRestore)\n\n\tif err := checkOptRestoreTo(&opts.restoreTo); err != nil {\n\t\treturn err\n\t}\n\n\tbox := trash.NewBox(\n\t\ttrash.WithAscend(!opts.reverse),\n\t\ttrash.WithGetSize(opts.showSize),\n\t\ttrash.WithDirectory(opts.directory),\n\t\ttrash.WithCWD(opts.cwd),\n\t\ttrash.WithQueries(args),\n\t\ttrash.WithSortBy(opts.sortBy),\n\t\ttrash.WithQueryMode(opts.modeBy),\n\t\ttrash.WithDay(opts.dayNew, opts.dayOld), // TODO: also set in restore?\n\t\ttrash.WithSize(opts.sizeLarge, opts.sizeSmall),\n\t\ttrash.WithLimitLast(opts.last),\n\t\ttrash.WithTrashDir(opts.trashDir),\n\t)\n\tif err := box.Open(); err != nil {\n\t\t// no error only remove mode (consider executing via batch)\n\t\tif opts.doRemove && errors.Is(err, trash.ErrNotFound) {\n\t\t\tfmt.Printf(\"do nothing: %s\\n\", err)\n\t\t\treturn nil\n\t\t} else {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tlistFiles(box.Files, box.GetSize, opts.showTrashPath)\n\n\tif !opts.doRemove && !opts.doRestore {\n\t\tif isTerminal {\n\t\t\tfmt.Printf(\"\\nFound %d trashed files. You can restore or remove PERMANENTLY these by --restore, --rm.\\n\", len(box.Files))\n\t\t\tif len(box.OrphanMeta) > 0 {\n\t\t\t\tfmt.Printf(\"\\nFound invalid metadata: %d\\nYou can remove invalid metadata by 'gtrash metafix'\\n\", len(box.OrphanMeta))\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t}\n\n\tfmt.Printf(\"\\nFound %d trashed files\\n\", len(box.Files))\n\n\tif opts.doRemove {\n\t\tif !opts.force && isTerminal && !tui.BoolPrompt(\"Are you sure you want to remove PERMANENTLY? \") {\n\t\t\treturn errors.New(\"do nothing\")\n\t\t}\n\t\tdoRemove(box.Files)\n\n\t} else if opts.doRestore {\n\t\tif opts.restoreTo != \"\" {\n\t\t\tfmt.Printf(\"Will restore to %q instead of original path\\n\", opts.restoreTo)\n\t\t}\n\n\t\tif !opts.force && isTerminal && !tui.BoolPrompt(\"Are you sure you want to restore? \") {\n\t\t\treturn errors.New(\"do nothing\")\n\t\t}\n\t\tif err := doRestore(box.Files, opts.restoreTo, isTerminal && !opts.force); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// TODO: refactor\nfunc listFiles(files []trash.File, showSize, showTrashPath bool) {\n\tif isTerminal {\n\t\t// colored, tabular view\n\t\tgreen := lipgloss.NewStyle().Foreground(lipgloss.Color(\"2\"))\n\n\t\t// replacement to tabwriter (color supported)\n\t\tw := ansiterm.NewTabWriter(os.Stdout, 0, 0, 2, ' ', 0)\n\t\tif showSize {\n\t\t\tfmt.Fprintf(w, \"%s\\t%s\\t%s\", green.Render(\"Date\"), green.Render(\"Size\"), green.Render(\"Path\"))\n\t\t} else {\n\t\t\tfmt.Fprintf(w, \"%s\\t%s\", green.Render(\"Date\"), green.Render(\"Path\"))\n\t\t}\n\n\t\tif showTrashPath {\n\t\t\tfmt.Fprintf(w, \"\\t%s\\n\", green.Render(\"TrashPath\"))\n\t\t} else {\n\t\t\tfmt.Fprintf(w, \"\\n\")\n\t\t}\n\n\t\tfor _, f := range files {\n\t\t\tif showSize {\n\t\t\t\tfmt.Fprintf(w, \"%v\\t%v\\t%v\", f.DeletedAt.Format(time.DateTime), f.SizeHuman(), f.OriginalPathFormat(false, true))\n\t\t\t} else {\n\t\t\t\tfmt.Fprintf(w, \"%v\\t%v\", f.DeletedAt.Format(time.DateTime), f.OriginalPathFormat(false, true))\n\t\t\t}\n\n\t\t\tif showTrashPath {\n\t\t\t\tfmt.Fprintf(w, \"\\t%v\\n\", f.TrashPathColor())\n\t\t\t} else {\n\t\t\t\tfmt.Fprintf(w, \"\\n\")\n\t\t\t}\n\n\t\t}\n\t\tw.Flush()\n\n\t} else {\n\t\t// no colored, splitted by TAB\n\t\tfor _, f := range files {\n\n\t\t\tif showSize {\n\t\t\t\tfmt.Printf(\"%v\\t%v\\t%v\", f.DeletedAt.Format(time.DateTime), f.SizeHuman(), f.OriginalPath)\n\t\t\t} else {\n\t\t\t\tfmt.Printf(\"%v\\t%v\", f.DeletedAt.Format(time.DateTime), f.OriginalPath)\n\t\t\t}\n\n\t\t\tif showTrashPath {\n\t\t\t\tfmt.Printf(\"\\t%v\\n\", f.TrashPath)\n\t\t\t} else {\n\t\t\t\tfmt.Printf(\"\\n\")\n\t\t\t}\n\t\t}\n\t}\n\n}\n"
  },
  {
    "path": "internal/cmd/metafix.go",
    "content": "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\"github.com/umlx5h/gtrash/internal/trash\"\n\t\"github.com/umlx5h/gtrash/internal/tui\"\n)\n\ntype metafixCmd struct {\n\tcmd  *cobra.Command\n\topts metafixOptions\n}\n\ntype metafixOptions struct {\n\tforce bool\n}\n\nfunc newMetafixCmd() *metafixCmd {\n\troot := &metafixCmd{}\n\tcmd := &cobra.Command{\n\t\tUse:   \"metafix\",\n\t\tShort: \"Fix trashcan metadata\",\n\t\tLong: `Description:\n  Detect and delete meta-information without corresponding files.\n  This command is useful after manually removing files in the Trash directory.\n  Refer below for detailed information.\n\n  https://github.com/umlx5h/gtrash#what-does-the-metafix-subcommand-do`,\n\t\tSilenceUsage:      true,\n\t\tArgs:              cobra.NoArgs,\n\t\tValidArgsFunction: cobra.NoFileCompletions,\n\t\tRunE: func(_ *cobra.Command, _ []string) error {\n\t\t\tif err := metafixCmdRun(root.opts); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif glog.ExitCode() > 0 {\n\t\t\t\treturn errContinue\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.Flags().BoolVarP(&root.opts.force, \"force\", \"f\", false, `Always execute without confirmation prompt\nThis is not necessary if running outside of a terminal`)\n\n\troot.cmd = cmd\n\treturn root\n}\n\nfunc metafixCmdRun(opts metafixOptions) error {\n\tbox := trash.NewBox(\n\t\ttrash.WithSortBy(trash.SortByName),\n\t)\n\tif err := box.Open(); err != nil {\n\t\tif errors.Is(err, trash.ErrNotFound) {\n\t\t\tfmt.Printf(\"do nothing: %s\\n\", err)\n\t\t\treturn nil\n\t\t} else {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tif len(box.OrphanMeta) == 0 {\n\t\tfmt.Println(\"not found invalid metadata\")\n\t\treturn nil\n\t}\n\n\tlistFiles(box.OrphanMeta, false, false)\n\n\t// TODO: Add functionality to allow deletion of orphaned files as well\n\t// (those for which trashinfo exists but the file does not).\n\tfmt.Printf(\"\\nFound invalid metadata: %d\\n\", len(box.OrphanMeta))\n\n\tif !opts.force && isTerminal && !tui.BoolPrompt(\"Are you sure you want to remove invalid metadata? \") {\n\t\treturn errors.New(\"do nothing\")\n\t}\n\n\tvar failed int\n\tfor _, f := range box.OrphanMeta {\n\t\tif err := os.Remove(f.TrashInfoPath); err != nil {\n\t\t\tfailed++\n\t\t\tglog.Errorf(\"cannot remove .trashinfo: %q: %s\\n\", f.TrashInfoPath, err)\n\t\t}\n\t}\n\n\tfmt.Printf(\"Deleted invalid metadata: %d\\n\", len(box.OrphanMeta)-failed)\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/cmd/prune.go",
    "content": "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/gtrash/internal/glog\"\n\t\"github.com/umlx5h/gtrash/internal/trash\"\n\t\"github.com/umlx5h/gtrash/internal/tui\"\n)\n\ntype pruneCmd struct {\n\tcmd  *cobra.Command\n\topts pruneOptions\n}\n\ntype pruneOptions struct {\n\tforce bool\n\n\tday  int\n\tsize string // human size (e.g. 10MB, 1G)\n\n\tmaxTotalSize uint64 // byte, parse from size\n\n\ttrashDir string // $HOME/.local/share/Trash\n}\n\nfunc (o *pruneOptions) check() error {\n\tif o.size != \"\" {\n\t\tbyte, err := humanize.ParseBytes(o.size)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"--size unit is invalid: %w\", err)\n\t\t}\n\t\to.maxTotalSize = byte\n\t}\n\treturn nil\n}\n\nfunc newPruneCmd() *pruneCmd {\n\troot := &pruneCmd{}\n\tcmd := &cobra.Command{\n\t\tUse:   \"prune\",\n\t\tShort: \"Prune trash cans by day or size\",\n\t\tLong: `Description:\n  Pruning trash cans by day or size criteria.\n  Either the --day or --size option is required.\n\n  This command is also intended for use via cron.\n  By default, you may be prompted multiple times for each trash can.\n\n  If the file to be pruned does not exist, the program exits normally without doing anything.`,\n\t\tExample: `  # Delete all files deleted a week ago\n  $ gtrash prune --day 7\n\n  # Delete all files deleted a week ago only within $HOME trash\n  $ gtrash prune --day 7 --trash-dir \"$HOME/.local/share/Trash\"\n\n  # Delete files in order from the largest to the smaller one so that the total size of the trash can is less than 5GB.\n  # 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.\n  $ gtrash prune --size 5GB\n\n  # Delete large files first to keep the total remaining size under 5GB, while excluding files deleted in the last week.\n  # Note that adding the most recently deleted files may exceed 5GB.\n  $ gtrash prune --size 5GB --day 7`,\n\t\tSilenceUsage:      true,\n\t\tArgs:              cobra.NoArgs,\n\t\tValidArgsFunction: cobra.NoFileCompletions,\n\t\tRunE: func(_ *cobra.Command, _ []string) error {\n\t\t\tif err := pruneCmdRun(root.opts); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif glog.ExitCode() > 0 {\n\t\t\t\treturn errContinue\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.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.\nIf the total size of the trash can is smaller than the specified size, nothing is done.\nThe total size is calculated by each trash can.\n\nIf you want to delete files larger than the specified size, use the \"find --size-large XX --rm\" command.\n\nCan be specified in human format (e.g. 5MB, 1GB)\n\nIf --day and --size are specified at the same time, the most recent X days are excluded from the calculation.\nThis may be useful when you do not want to delete large files that have been recently deleted.\n`)\n\tcmd.Flags().IntVar(&root.opts.day, \"day\", 0, \"Remove all files deleted before X days\")\n\n\tcmd.Flags().BoolVarP(&root.opts.force, \"force\", \"f\", false, `Always execute without confirmation prompt\nThis is not necessary if running outside of a terminal\n`)\n\tcmd.Flags().StringVar(&root.opts.trashDir, \"trash-dir\", \"\", `Specify a full path if you want to prune only a specific trash can\nBy default, all trash cans are pruned.\n\nFor $HOME trash only:\n    --trash-dir \"$HOME/.local/share/Trash\"\n`)\n\tcmd.Root().MarkFlagsOneRequired(\"size\", \"day\")\n\n\troot.cmd = cmd\n\treturn root\n}\n\n// Returns files to be deleted from files based on maxTotalSize\n// If maxTotalSize > total, nil is returned.\n//\n// Prerequisite: files are sorted in ascending order by size\nfunc getPruneFiles(files []trash.File, maxTotalSize uint64) (prune []trash.File, deleted uint64, total uint64) {\n\tfor i, f := range files {\n\t\t// If the size cannot be obtained, it is treated as a minus value and should be at the top.\n\t\t// This is always skipped and is not considered for deletion.\n\t\tif f.Size == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tsize := uint64(*f.Size)\n\t\ttotal += size\n\n\t\tif prune == nil {\n\t\t\tif total > maxTotalSize {\n\t\t\t\tprune = files[i:]\n\t\t\t}\n\t\t}\n\n\t\tif prune != nil {\n\t\t\tdeleted += size\n\t\t}\n\t}\n\n\tif prune == nil {\n\t\treturn nil, 0, total\n\t} else {\n\t\treturn prune, deleted, total\n\t}\n}\n\nfunc pruneCmdRun(opts pruneOptions) error {\n\tif err := opts.check(); err != nil {\n\t\treturn err\n\t}\n\n\tsortMethod := trash.SortByDeletedAt\n\n\tsizeMode := opts.size != \"\"\n\n\tif opts.size != \"\" {\n\t\tsortMethod = trash.SortBySize\n\t}\n\n\tbox := trash.NewBox(\n\t\ttrash.WithSortBy(sortMethod),\n\t\ttrash.WithGetSize(sizeMode),\n\t\ttrash.WithAscend(true),\n\t\ttrash.WithDay(0, opts.day),\n\t\ttrash.WithTrashDir(opts.trashDir),\n\t)\n\tif err := box.Open(); err != nil {\n\t\tif errors.Is(err, trash.ErrNotFound) {\n\t\t\tfmt.Printf(\"do nothing: %s\\n\", err)\n\t\t\treturn nil\n\t\t} else {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tfor i, trashDir := range box.TrashDirs {\n\t\tfiles := box.FilesByTrashDir[trashDir]\n\t\tif len(files) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar deleted, total uint64\n\n\t\tif sizeMode {\n\t\t\tfiles, deleted, total = getPruneFiles(files, opts.maxTotalSize)\n\t\t\tif len(files) == 0 {\n\t\t\t\tfmt.Printf(\"do nothing: trash size %s is smaller than %s (%s) in %s\\n\", humanize.Bytes(total), humanize.Bytes(opts.maxTotalSize), opts.size, trashDir)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tlistFiles(files, sizeMode, false)\n\n\t\tfmt.Printf(\"\\nSelected %d files in %s\\n\", len(files), trashDir)\n\n\t\tif sizeMode {\n\t\t\tfmt.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))\n\t\t}\n\n\t\tif !opts.force && isTerminal && !tui.BoolPrompt(\"Are you sure you want to remove PERMANENTLY? \") {\n\t\t\treturn errors.New(\"do nothing\")\n\t\t}\n\t\tdoRemove(files)\n\n\t\tif i != len(box.TrashDirs)-1 {\n\t\t\tfmt.Println(\"\")\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/cmd/prune_test.go",
    "content": "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\nfunc newInt(i int64) *int64 {\n\treturn &i\n}\n\nfunc TestGetPruneFiles(t *testing.T) {\n\tt.Run(\"should return prune files\", func(t *testing.T) {\n\t\tgot, deleted, total := getPruneFiles([]trash.File{\n\t\t\t{\n\t\t\t\tName: \"a\",\n\t\t\t\tSize: newInt(20),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"b\",\n\t\t\t\tSize: newInt(30),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"c\",\n\t\t\t\tSize: newInt(50),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"d\",\n\t\t\t\tSize: newInt(100),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"e\",\n\t\t\t\tSize: newInt(150),\n\t\t\t},\n\t\t}, 100)\n\n\t\twant := []trash.File{\n\t\t\t{\n\t\t\t\tName: \"d\",\n\t\t\t\tSize: newInt(100),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"e\",\n\t\t\t\tSize: newInt(150),\n\t\t\t},\n\t\t}\n\n\t\tassert.Equal(t, want, got)\n\t\tassert.EqualValues(t, 250, deleted)\n\t\tassert.EqualValues(t, 350, total)\n\t})\n\n\tt.Run(\"should prune files from larger files\", func(t *testing.T) {\n\t\tgot, deleted, total := getPruneFiles([]trash.File{\n\t\t\t{\n\t\t\t\tName: \"a\",\n\t\t\t\tSize: newInt(20),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"b\",\n\t\t\t\tSize: newInt(30),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"c\",\n\t\t\t\tSize: newInt(50),\n\t\t\t},\n\t\t}, 30)\n\n\t\twant := []trash.File{\n\t\t\t{\n\t\t\t\tName: \"b\",\n\t\t\t\tSize: newInt(30),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"c\",\n\t\t\t\tSize: newInt(50),\n\t\t\t},\n\t\t}\n\n\t\tassert.Equal(t, want, got)\n\t\tassert.EqualValues(t, 80, deleted)\n\t\tassert.EqualValues(t, 100, total)\n\t})\n\n\tt.Run(\"should return nil\", func(t *testing.T) {\n\t\tgot, deleted, total := getPruneFiles([]trash.File{\n\t\t\t{\n\t\t\t\tName: \"a\",\n\t\t\t\tSize: newInt(20),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"b\",\n\t\t\t\tSize: newInt(30),\n\t\t\t},\n\t\t\t{\n\t\t\t\tName: \"c\",\n\t\t\t\tSize: newInt(50),\n\t\t\t},\n\t\t}, 100)\n\n\t\tassert.Nil(t, got)\n\t\tassert.EqualValues(t, 0, deleted)\n\t\tassert.EqualValues(t, 100, total)\n\t})\n\n}\n"
  },
  {
    "path": "internal/cmd/put.go",
    "content": "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 \"github.com/otiai10/copy\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/umlx5h/gtrash/internal/env\"\n\t\"github.com/umlx5h/gtrash/internal/glog\"\n\t\"github.com/umlx5h/gtrash/internal/posix\"\n\t\"github.com/umlx5h/gtrash/internal/tui\"\n\t\"github.com/umlx5h/gtrash/internal/xdg\"\n)\n\ntype putCmd struct {\n\tcmd  *cobra.Command\n\topts putOptions\n}\n\ntype putOptions struct {\n\tprompt     bool\n\tpromptOnce bool\n\tforce      bool\n\tverbose    bool\n\n\trmMode    bool\n\trecursive bool\n\tdir       bool\n\n\thomeFallback bool\n}\n\nfunc newPutCmd() *putCmd {\n\troot := &putCmd{}\n\n\tcmd := &cobra.Command{\n\t\tUse:     \"put PATH...\",\n\t\tAliases: []string{\"p\"},\n\t\tShort:   \"Put files to trash (p)\",\n\t\tLong: `Description:\n  A substitute for 'rm', moving files to the trash.\n  For files in the main file system, they're moved to the following folder:\n      $XDG_DATA_HOME/Trash ($HOME/.local/share/Trash)\n\n  For files in external file systems, they're moved to either of the following locations at the root of the mount point:\n      1. $MOUNTPOINT/.Trash/$uid\n      2. $MOUNTPOINT/.Trash-$uid\n\n  Folder 1 takes precedence but requires pre-creation with a set sticky bit ($uid part is created automatically).\n  Folder 2 is created automatically.\n\n  To identify the folder where files will be moved, use the -v or --debug option.\n  To display the path in the trash can, use --show-trashpath with the find command:\n      $ gtrash find --show-trashpath\n\n  By default, the options -d, -r, -R, and --recursive are ignored.\n  They are unnecessary for file removal but required when using --rm-mode.`,\n\n\t\tExample: `  # -r is unnecessary to delete a folder\n  $ gtrash put file1 file2 dir1/ dir2\n\n  # For files starting with a hyphen, specify the filename after the '--'\n  $ gtrash put -- -foo\n\n  # If expanded in the shell, you can use glob patterns\n  $ gtrash put foo*`,\n\t\tArgs:          cobra.MinimumNArgs(1),\n\t\tSilenceUsage:  true,\n\t\tSilenceErrors: true,\n\t\tRunE: func(_ *cobra.Command, args []string) error {\n\t\t\tif err := putCmdRun(args, root.opts); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif glog.ExitCode() > 0 {\n\t\t\t\treturn errContinue\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\tcmd.Flags().BoolVarP(&root.opts.force, \"force\", \"f\", false, \"ignore nonexistent files and arguments\")\n\tcmd.Flags().BoolVarP(&root.opts.prompt, \"interactive\", \"i\", false, \"prompt before every removal\")\n\t// short only options are not available\n\tcmd.Flags().BoolVarP(&root.opts.promptOnce, \"interactive-once\", \"I\", false, \"prompt once before trashing\")\n\tcmd.Flags().BoolVarP(&root.opts.verbose, \"verbose\", \"v\", false, \"explain what is being done\")\n\n\t// rm mode options if --rm-mode used\n\tcmd.Flags().BoolVar(&root.opts.rmMode, \"rm-mode\", env.PUT_RM_MODE, \"enable rm-like mode (change behavior -r, -R, -d)\")\n\tcmd.Flags().BoolVarP(&root.opts.dir, \"dir\", \"d\", false, `ignored unless --rm-mode set\nremove empty directories (--rm-mode)`)\n\tcmd.Flags().BoolVarP(&root.opts.recursive, \"recursive\", \"r\", false, `ignored unless --rm-mode set\nremove directories and their contents recursively (--rm-mode)`)\n\n\t// TODO: Since short only options are not available, have no choice but to assign a long name.\n\tcmd.Flags().BoolVarP(&root.opts.recursive, \"Recursive\", \"R\", false, \"same as -r\")\n\n\tcmd.Flags().BoolVar(&root.opts.homeFallback, \"home-fallback\", env.HOME_TRASH_FALLBACK_COPY, `Enable fallback to home directory trash\nIf the deletion of a file in an external file system fails, this option may help.`)\n\n\troot.cmd = cmd\n\treturn root\n}\n\nfunc putCmdRun(args []string, opts putOptions) error {\n\tif isDebug {\n\t\topts.verbose = true\n\t}\n\tif opts.force {\n\t\t// If both are specified, force is preferred.\n\t\topts.prompt = false\n\t\topts.promptOnce = false\n\t}\n\n\tslog.Debug(\"starting put\", \"args\", args, \"home-fallback\", opts.homeFallback, \"rm-mode\", opts.rmMode)\n\n\tif (opts.prompt || opts.promptOnce) && !isTerminal {\n\t\treturn errors.New(\"cannot use -i without tty\")\n\t}\n\n\tif opts.promptOnce {\n\t\t// -I confirmation dialog\n\t\tfor _, a := range args {\n\t\t\tfmt.Println(a)\n\t\t}\n\n\t\tfmt.Println(\"\")\n\t\tyes := tui.BoolPrompt(fmt.Sprintf(\"Do you trash above %d items? \", len(args)))\n\t\tif !yes {\n\t\t\treturn errors.New(\"canceled\")\n\t\t}\n\t}\n\n\t// could restore-group to work, reuse deleteTime\n\tvar deleteTime time.Time\n\n\tfor _, arg := range args {\n\t\t// same as rm\n\t\tif slices.Contains([]string{\".\", \"..\"}, filepath.Base(arg)) {\n\t\t\tglog.Errorf(\"refusing to remove '.' or '..' directory: skipping %q\\n\", arg)\n\t\t\tcontinue\n\t\t}\n\n\t\tslog.Debug(\"checking for the existence of files with lstat(2)\", \"file\", arg)\n\t\tst, err := os.Lstat(arg)\n\t\tif err != nil {\n\t\t\tif errors.Is(err, fs.ErrNotExist) {\n\t\t\t\tif !opts.force {\n\t\t\t\t\tglog.Errorf(\"cannot trash %q: No such file or directory\\n\", arg)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tglog.Errorf(\"cannot trash %q: %s\\n\", err)\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\n\t\tif opts.rmMode {\n\t\t\tif st.IsDir() {\n\t\t\t\tif !opts.recursive && !opts.dir {\n\t\t\t\t\tglog.Errorf(\"cannot trash %q: Is a directory\\n\", arg)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif !opts.recursive && opts.dir {\n\t\t\t\t\t// check if directory is empty\n\t\t\t\t\tempty, err := posix.DirEmpty(arg)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tglog.Errorf(\"cannot trash %q: check dir empty: %s\\n\", arg, err)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tif !empty {\n\t\t\t\t\t\tglog.Errorf(\"cannot trash %q: Directory not empty\\n\", arg)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// -i confirmation dialog\n\t\tif opts.prompt {\n\t\t\tprompt := fmt.Sprintf(\"Do you trash %s %q? \", posix.FileType(st), arg)\n\t\t\tchoices := []string{\"yes\", \"no\", \"all-yes\", \"quit\"}\n\t\t\tselected, err := tui.ChoicePrompt(prompt, choices)\n\t\t\tif err != nil {\n\t\t\t\t// canceled\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tswitch selected {\n\t\t\tcase \"no\":\n\t\t\t\tcontinue // skip\n\t\t\tcase \"all-yes\":\n\t\t\t\t// disable prompt later\n\t\t\t\topts.prompt = false\n\t\t\t}\n\t\t}\n\n\t\tpath, err := filepath.Abs(arg)\n\t\tif err != nil {\n\t\t\tglog.Errorf(\"cannot trash %q: get abspath: %s\\n\", arg, err)\n\t\t\tcontinue\n\t\t}\n\n\t\t// for -v logging\n\t\tvar usedDir xdg.TrashDir\n\n\t\tslog.Debug(\"looking up trash_dir\", \"path\", path)\n\n\t\t// TODO: Add integration test\n\t\thomeDir, externalDir, err := xdg.LookupTrashDir(path)\n\n\t\tslog.Debug(\"looked up trash_dir\", \"homeDir\", homeDir, \"externalDir\", externalDir, \"error\", err)\n\n\t\tif err != nil {\n\t\t\tif !opts.homeFallback || (homeDir == nil && externalDir == nil) {\n\t\t\t\tglog.Errorf(\"cannot trash %q: lookup trash directory: %s\\n\", arg, err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// fallback to home trash\n\t\t\tslog.Debug(\"fallback to home trash because external trash is not found\", \"error\", err)\n\t\t}\n\n\t\t// preferred if an external trash can is available.\n\t\tif externalDir != nil {\n\t\t\tslog.Debug(\"will use external trash, will use rename(2) to move\", \"trashDir\", externalDir.Dir)\n\t\t\t// external trash only uses rename, not copy\n\t\t\tif err := trashFile(*externalDir, path, &deleteTime, false); err != nil {\n\t\t\t\tif !opts.homeFallback {\n\t\t\t\t\tglog.Errorf(\"cannot trash %q: %s\\n\", arg, err)\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\t// fallback to home trash\n\t\t\t\tslog.Debug(\"fallback to home trash because moving failed by rename(2)\", \"error\", err)\n\t\t\t} else {\n\t\t\t\tusedDir = *externalDir\n\t\t\t\tgoto SUCCESS\n\t\t\t}\n\t\t}\n\n\t\tif opts.homeFallback || env.ONLY_HOME_TRASH {\n\t\t\tslog.Debug(\"will use home trash, will use rename(2) and copy to move\", \"trashDir\", homeDir.Dir)\n\t\t} else {\n\t\t\tslog.Debug(\"will use home trash, will use rename(2) to move\", \"trashDir\", homeDir.Dir)\n\t\t}\n\t\tif err := trashFile(*homeDir, path, &deleteTime, opts.homeFallback || env.ONLY_HOME_TRASH); err != nil {\n\t\t\tglog.Errorf(\"cannot trash %q: %s\\n\", arg, err)\n\t\t\tcontinue\n\t\t}\n\t\tusedDir = *homeDir\n\n\tSUCCESS:\n\t\tif opts.verbose {\n\t\t\tfmt.Printf(\"trashed %q to %s\\n\", arg, posix.AbsPathToTilde(usedDir.Dir))\n\t\t}\n\t}\n\treturn nil\n}\n\nfunc trashFile(trashDir xdg.TrashDir, path string, deleteTime *time.Time, fallbackCopy bool) error {\n\tif err := trashDir.CreateDir(); err != nil {\n\t\treturn fmt.Errorf(\"create trash directory: %w\\n\", err)\n\t}\n\n\tinfoPath := path\n\tif trashDir.UseRelativePath() {\n\t\t// get relative path from $topDir\n\t\tif p, err := filepath.Rel(trashDir.Root, path); err == nil {\n\t\t\t// it MUST not include a “..” directory, and for files not “under” that directory, absolute pathnames must be used\n\t\t\tif p != \"..\" && !strings.HasPrefix(p, \"..\"+string(os.PathSeparator)) {\n\t\t\t\tinfoPath = p\n\t\t\t}\n\t\t} else {\n\t\t\t// should not come here\n\t\t\tslog.Warn(\"cannot convert absolute to relative path, use absolute path instead\", \"file\", path, \"root\", trashDir.Root, \"error\", err)\n\t\t}\n\t}\n\n\tif deleteTime.IsZero() {\n\t\t*deleteTime = time.Now()\n\t}\n\n\tinfo := xdg.Info{\n\t\tPath:         infoPath,\n\t\tDeletionDate: *deleteTime,\n\t}\n\n\tfilename := filepath.Base(path)\n\t// before rename(2), write .trashinfo metadata atomically\n\tsaveName, deleteFn, err := info.Save(trashDir, filename)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"save trashinfo: %w\\n\", err)\n\t}\n\n\tslog.Debug(\"saved .trashinfo metadata\", \"path\", filepath.Join(trashDir.InfoDir(), saveName+\".trashinfo\"))\n\n\t// move file to trash\n\tdstPath := filepath.Join(trashDir.FilesDir(), saveName)\n\n\tslog.Debug(\"executing rename(2) to move\", \"from\", path, \"to\", dstPath)\n\tif err := os.Rename(path, dstPath); err != nil {\n\t\tif fallbackCopy {\n\t\t\t// rename(2) failed, fallback to copy and delete\n\t\t\tslog.Debug(\"executing copy and delete to move because rename(2) failed\", \"from\", path, \"to\", dstPath, \"error\", err)\n\t\t\t// copy recursively\n\t\t\tif err := cp.Copy(path, dstPath); err != nil {\n\t\t\t\t_ = deleteFn()\n\t\t\t\treturn fmt.Errorf(\"fallback copy: %w\", err)\n\t\t\t}\n\n\t\t\t// if copy success, then remove recursively\n\t\t\tif err = os.RemoveAll(path); err != nil {\n\t\t\t\t_ = deleteFn()\n\t\t\t\treturn fmt.Errorf(\"delete after fallback copy: %w\", err)\n\t\t\t}\n\n\t\t\treturn nil\n\t\t}\n\n\t\t// delete corresponding .trashinfo file\n\t\t_ = deleteFn()\n\n\t\treturn fmt.Errorf(\"move: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/cmd/restore.go",
    "content": "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/xid\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/umlx5h/gtrash/internal/glog\"\n\t\"github.com/umlx5h/gtrash/internal/trash\"\n\t\"github.com/umlx5h/gtrash/internal/tui\"\n)\n\ntype restoreCmd struct {\n\tcmd  *cobra.Command\n\topts restoreOptions\n}\n\ntype restoreOptions struct {\n\tdirectory string\n\tcwd       bool\n\trestoreTo string\n\tforce     bool\n}\n\nfunc newRestoreCmd() *restoreCmd {\n\troot := &restoreCmd{}\n\n\tcmd := &cobra.Command{\n\t\tUse:     \"restore [PATH...]\",\n\t\tAliases: []string{\"r\"},\n\t\tShort:   \"Restore trashed files interactively (r)\",\n\t\tLong: `Description:\n  Use the TUI interface to restore files, enabling multiple file selection.\n  Press the ? key within the TUI interface for usage help.\n\n  When specifying the full path in the command-line argument, restoration is performed without using the TUI interface.`,\n\t\tExample: `  # Restore interactively\n  $ gtrash restore\n\n  # Restore files without TUI\n  # Must specify full paths\n  $ gtrash restore /home/user/file1 /home/user/file2\n\n  # Fuzzy find multiple items and restore them\n  # The -o in xargs is necessary for the confirmation prompt to display.\n  $ gtrash find | fzf --multi | awk -F'\\t' '{print $2}' | xargs -o gtrash restore`,\n\t\tSilenceUsage: true,\n\t\tRunE: func(_ *cobra.Command, args []string) error {\n\t\t\tif err := restoreCmdRun(args, root.opts); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif glog.ExitCode() > 0 {\n\t\t\t\treturn errContinue\n\t\t\t}\n\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.Flags().StringVarP(&root.opts.directory, \"directory\", \"d\", \"\", \"Filter by directory\")\n\tcmd.Flags().BoolVarP(&root.opts.cwd, \"cwd\", \"c\", false, \"Filter by current working directory\")\n\tcmd.Flags().StringVar(&root.opts.restoreTo, \"restore-to\", \"\", \"Restore to this path instead of original path\")\n\tcmd.Flags().BoolVarP(&root.opts.force, \"force\", \"f\", false, `Always execute without confirmation prompt\nThis is not necessary if running outside of a terminal`)\n\n\troot.cmd = cmd\n\treturn root\n}\n\nfunc restoreCmdRun(args []string, opts restoreOptions) (err error) {\n\tif err := checkOptRestoreTo(&opts.restoreTo); err != nil {\n\t\treturn err\n\t}\n\n\tslog.Debug(\"starting restore\", \"args\", args)\n\n\tbox := trash.NewBox(\n\t\ttrash.WithDirectory(opts.directory),\n\t\ttrash.WithCWD(opts.cwd),\n\t\ttrash.WithQueries(args),               // only used when specifying command args\n\t\ttrash.WithQueryMode(trash.ModeByFull), // only support full match\n\t)\n\tif err := box.Open(); err != nil {\n\t\treturn err\n\t}\n\n\tif len(args) == 0 {\n\t\tif !isTerminal {\n\t\t\treturn errors.New(\"cannot use tui interface, please specify restore path to command line args\")\n\t\t}\n\n\t\t// interactive restore when not specifying command line args\n\t\tbox.Files, err = tui.FilesSelect(box.Files)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tlistFiles(box.Files, false, false)\n\n\tfor _, arg := range args {\n\t\tif box.HitByPath(arg) == 0 {\n\t\t\tglog.Errorf(\"cannot restore %q: not found in trashcan\\n\", arg)\n\t\t}\n\t}\n\n\tfmt.Printf(\"\\nSelected %d trashed files\\n\", len(box.Files))\n\n\tif opts.restoreTo != \"\" {\n\t\tfmt.Printf(\"Will restore to %q instead of original path\\n\", opts.restoreTo)\n\t}\n\n\tif !opts.force && isTerminal && !tui.BoolPrompt(\"Are you sure you want to restore? \") {\n\t\treturn errors.New(\"do nothing\")\n\t}\n\n\tif err := doRestore(box.Files, opts.restoreTo, isTerminal && !opts.force); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc checkOptRestoreTo(restoreTo *string) error {\n\tif restoreTo == nil {\n\t\treturn nil\n\t}\n\n\tif *restoreTo != \"\" {\n\t\tfi, err := os.Stat(*restoreTo)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"--restore-to path must be existing directory: %w\", err)\n\t\t}\n\n\t\tif !fi.IsDir() {\n\t\t\treturn fmt.Errorf(\"--restore-to path must be directory\")\n\t\t}\n\n\t\t// convert to absolute path\n\t\tabs, err := filepath.Abs(*restoreTo)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"--restore-to path must be valid directory: %w\", err)\n\t\t}\n\t\t*restoreTo = abs\n\t}\n\n\treturn nil\n}\n\nfunc checkRestoreDup(files []trash.File) error {\n\t// Detect and abort duplicate restore destinations\n\tfileByPath := make(map[string][]trash.File)\n\n\tfor _, f := range files {\n\t\tfileByPath[f.OriginalPath] = append(fileByPath[f.OriginalPath], f)\n\t}\n\n\tvar conflicted bool\n\tfor path, files := range fileByPath {\n\t\tif len(files) >= 2 {\n\t\t\tconflicted = true\n\t\t\tglog.Errorf(\"conflict restore %d files: %q\\n\", len(files), path)\n\t\t}\n\t}\n\n\tif conflicted {\n\t\treturn errors.New(\"canceled: restore conflict detected\")\n\t}\n\n\treturn nil\n}\n\nfunc doRestore(files []trash.File, restoreTo string, prompt bool) error {\n\tif !prompt {\n\t\tif err := checkRestoreDup(files); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tvar (\n\t\tsuccess int\n\t\tfailed  []trash.File\n\t)\n\n\tprintResult := func() {\n\t\tif restoreTo != \"\" {\n\t\t\tfmt.Printf(\"Restored to %q\\n\", restoreTo)\n\t\t}\n\n\t\tfmt.Printf(\"Restored %d/%d trashed files\\n\", success, len(files))\n\t\tif len(failed) > 0 {\n\t\t\tfmt.Printf(\"Following %d files could not be restored.\\n\", len(failed))\n\t\t\tlistFiles(failed, false, true)\n\t\t}\n\t}\n\n\tdefer printResult()\n\n\tvar (\n\t\trepeat     bool\n\t\tselected   string\n\t\tprevSelect string\n\t)\n\n\tfor _, file := range files {\n\t\t// option to change the restore destination.\n\t\trestorePath := file.OriginalPath\n\t\tif restoreTo != \"\" {\n\t\t\trestorePath = filepath.Join(restoreTo, file.OriginalPath)\n\t\t}\n\n\t\t// Check to see if the file already exists in the destination path.\n\t\t// This is necessary because rename(2) overwrites the file.\n\t\tif _, err := os.Lstat(restorePath); err == nil {\n\t\t\tif !prompt {\n\t\t\t\tglog.Errorf(\"cannot restore %q: restore path already exists\\n\", file.OriginalPath)\n\t\t\t\tfailed = append(failed, file)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif !repeat {\n\t\t\t\tchoice := []string{\"new-name\", \"skip\", \"quit\"}\n\t\t\t\tif prevSelect != \"\" {\n\t\t\t\t\tchoice = []string{\"new-name\", \"skip\", \"repeat-prev\", \"quit\"}\n\t\t\t\t}\n\t\t\t\t// TODO: Make the message easy to understand\n\t\t\t\tselected, err = tui.ChoicePrompt(fmt.Sprintf(\"Conflicted restore path %q\\n\\tPlease choose one of the following: \", file.OriginalPath), choice)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\tSWITCH:\n\t\t\tswitch selected {\n\t\t\tcase \"new-name\":\n\t\t\t\t// give a random string to avoid duplicates\n\t\t\t\trestorePath = restorePath + \".\" + xid.New().String()\n\t\t\t\tfmt.Printf(\"Restoring to %q (original: %q)\\n\", restorePath, file.Name)\n\t\t\t\tprevSelect = selected\n\t\t\tcase \"skip\":\n\t\t\t\tprevSelect = selected\n\t\t\t\tcontinue\n\t\t\tcase \"repeat-prev\":\n\t\t\t\trepeat = true\n\t\t\t\tselected = prevSelect\n\t\t\t\tgoto SWITCH\n\t\t\t}\n\t\t}\n\n\t\t// ensure to have directory to restore\n\t\tif err := os.MkdirAll(filepath.Dir(restorePath), 0o777); err != nil {\n\t\t\tglog.Errorf(\"cannot restore %q: mkdir restorePath: %s\\n\", file.OriginalPath, err)\n\t\t\tfailed = append(failed, file)\n\t\t\tcontinue\n\t\t}\n\n\t\t// \"overwrite\" is not an option because it only works when the source and destination files are both files.\n\t\t// old     new\n\t\t// file   file      old overwrites new\n\t\t//  dir   file      error: not a directory\n\t\t// file    dir      error: file exists\n\t\t//  dir    dir      error: file exists\n\n\t\tslog.Debug(\"executing rename(2) to restore\", \"from\", file.TrashPath, \"to\", restorePath)\n\t\tif err := os.Rename(file.TrashPath, restorePath); err != nil {\n\t\t\t// rename(2) failed, fallback to copy and delete\n\t\t\tslog.Debug(\"executing copy and delete to restore because rename(2) failed\", \"from\", file.TrashPath, \"to\", restorePath)\n\n\t\t\t// copy recursively\n\t\t\tif err := cp.Copy(file.TrashPath, restorePath); err != nil {\n\t\t\t\tglog.Errorf(\"cannot restore %q: fallback copy: %s\\n\", file.OriginalPath)\n\t\t\t\tfailed = append(failed, file)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// if copy success, then remove recursively\n\t\t\tif err = os.RemoveAll(file.TrashPath); err != nil {\n\t\t\t\tslog.Warn(\"restored successfully but cannot delete trashed file\", \"trashPath\", file.TrashPath, \"restoreTo\", file.OriginalPath, \"error\", err)\n\t\t\t}\n\t\t}\n\n\t\tif err := file.Delete(); err != nil {\n\t\t\tslog.Warn(\"restored successfully but cannot delete .trashinfo\", \"trashInfoPath\", file.TrashInfoPath, \"restoreTo\", file.OriginalPath, \"error\", err)\n\t\t}\n\n\t\tsuccess++\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/cmd/restoreGroup.go",
    "content": "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/umlx5h/gtrash/internal/trash\"\n\t\"github.com/umlx5h/gtrash/internal/tui\"\n)\n\ntype restoreGroupCmd struct {\n\tcmd  *cobra.Command\n\topts restoreGroupOptions\n}\n\ntype restoreGroupOptions struct{}\n\nfunc newRestoreGroupCmd() *restoreGroupCmd {\n\troot := &restoreGroupCmd{}\n\n\tcmd := &cobra.Command{\n\t\tUse:     \"restore-group\",\n\t\tAliases: []string{\"rg\"},\n\t\tShort:   \"Restore trashed files as a group interactively (rg)\",\n\t\tLong: `Description:\n  Use the TUI interface for file restoration.\n  Unlike the 'restore' command, files deleted simultaneously are grouped together.\n\n  Multiple selections of groups are not allowed.\n\n  Actually, files deleted using 'gtrash put' may not be grouped accurately.\n  Files with deletion times matching in seconds are grouped together.\n\n  Refer below for detailed information.\n  ref: https://github.com/umlx5h/gtrash#how-does-the-restore-group-subcommand-work\n`,\n\t\tSilenceUsage:      true,\n\t\tArgs:              cobra.NoArgs,\n\t\tValidArgsFunction: cobra.NoFileCompletions,\n\t\tRunE: func(_ *cobra.Command, _ []string) error {\n\t\t\tif err := restoreGroupCmdRun(root.opts); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif glog.ExitCode() > 0 {\n\t\t\t\treturn errContinue\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\troot.cmd = cmd\n\treturn root\n}\n\nfunc restoreGroupCmdRun(_ restoreGroupOptions) error {\n\tbox := trash.NewBox()\n\tif err := box.Open(); err != nil {\n\t\treturn err\n\t}\n\n\tgroups := box.ToGroups()\n\n\tgroup, err := tui.GroupSelect(groups)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlistFiles(group.Files, false, false)\n\tfmt.Printf(\"\\nSelected %d trashed files\\n\", len(group.Files))\n\n\tif isTerminal && !tui.BoolPrompt(\"Are you sure you want to restore? \") {\n\t\treturn errors.New(\"do nothing\")\n\t}\n\n\tif err := doRestore(group.Files, \"\", true); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/cmd/rm.go",
    "content": "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/glog\"\n\t\"github.com/umlx5h/gtrash/internal/trash\"\n\t\"github.com/umlx5h/gtrash/internal/tui\"\n)\n\ntype removeCmd struct {\n\tcmd  *cobra.Command\n\topts removeOptions\n}\n\ntype removeOptions struct {\n\tforce bool\n}\n\nfunc newRemoveCmd() *removeCmd {\n\troot := &removeCmd{}\n\tcmd := &cobra.Command{\n\t\tUse:   \"rm PATH...\",\n\t\tShort: \"Remove trashed files PERMANENTLY in the cmd arguments\",\n\t\tLong: `Descricption:\n  Permanently remove the files specified as command-line arguments.\n  Paths must be specified as full paths.\n\n  This command is intended to be used alongside other commands like fzf.\n  Generally, using 'find --rm' is recommended over this command.`,\n\t\tExample: `  # Permanently remove files by providing full paths..\n  $ gtrash rm /home/user/file1 /home/user/file2\n\n  # Fuzzy find multiple items and permanently remove them.\n  # The -o in xargs is necessary for the confirmation prompt to display.\n  $ gtrash find | fzf --multi | awk -F'\\t' '{print $2}' | xargs -o gtrash rm`,\n\t\tSilenceUsage: true,\n\t\tArgs:         cobra.MinimumNArgs(1),\n\t\tRunE: func(_ *cobra.Command, args []string) error {\n\t\t\tif err := removeCmdRun(args, root.opts); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif glog.ExitCode() > 0 {\n\t\t\t\treturn errContinue\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\tcmd.Flags().BoolVarP(&root.opts.force, \"force\", \"f\", false, `Always execute without confirmation prompt\nThis is not necessary if running outside of a terminal`)\n\n\troot.cmd = cmd\n\treturn root\n}\n\nfunc removeCmdRun(args []string, opts removeOptions) error {\n\tbox := trash.NewBox(\n\t\ttrash.WithAscend(true),\n\t\ttrash.WithQueries(args),\n\t\ttrash.WithQueryMode(trash.ModeByFull),\n\t)\n\tif err := box.Open(); err != nil {\n\t\treturn err\n\t}\n\n\tlistFiles(box.Files, false, false)\n\n\tfor _, arg := range args {\n\t\tif box.HitByPath(arg) == 0 {\n\t\t\tglog.Errorf(\"cannot trash %q: not found in trashcan\\n\", arg)\n\t\t}\n\t}\n\tfmt.Printf(\"\\nFound %d trashed files\\n\", len(box.Files))\n\n\tif !opts.force && isTerminal && !tui.BoolPrompt(\"Are you sure you want to remove PERMANENTLY? \") {\n\t\treturn errors.New(\"do nothing\")\n\t}\n\n\tdoRemove(box.Files)\n\n\treturn nil\n}\n\nfunc doRemove(files []trash.File) {\n\tvar failed []trash.File\n\n\tfor _, file := range files {\n\t\tslog.Debug(\"removing a trashed file\", \"path\", file.TrashPath)\n\t\tif err := os.RemoveAll(file.TrashPath); err != nil {\n\t\t\tif !errors.Is(err, os.ErrNotExist) {\n\t\t\t\tglog.Errorf(\"cannot trash %q: remove: %s\\n\", file.TrashPath, err)\n\t\t\t\tfailed = append(failed, file)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tif err := file.Delete(); err != nil {\n\t\t\t// already read, so it is usually not reached\n\t\t\tslog.Warn(\"removed trashed file but cannot delete .trashinfo\", \"deletedFile\", file.TrashPath, \"trashInfoPath\", file.TrashInfoPath, \"error\", err)\n\t\t}\n\t}\n\n\tfmt.Printf(\"Removed %d/%d trashed files\\n\", len(files)-len(failed), len(files))\n\tif len(failed) > 0 {\n\t\tfmt.Printf(\"Following %d files could not be deleted.\\n\", len(failed))\n\t\tlistFiles(failed, false, true)\n\t}\n}\n"
  },
  {
    "path": "internal/cmd/root.go",
    "content": "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/lmittmann/tint\"\n\t\"github.com/spf13/cobra\"\n\t\"github.com/umlx5h/gtrash/internal/env\"\n\t\"golang.org/x/term\"\n)\n\nvar (\n\tprogName    = filepath.Base(os.Args[0])\n\terrContinue = errors.New(\"\")\n\n\tisTerminal bool\n)\n\nfunc init() {\n\tif term.IsTerminal(int(os.Stdout.Fd())) && term.IsTerminal(int(os.Stdin.Fd())) {\n\t\tisTerminal = true\n\t}\n}\n\nfunc Execute(version Version) {\n\terr := newRootCmd(version).cmd.Execute()\n\tif err != nil {\n\t\tif !errors.Is(err, errContinue) {\n\t\t\tfmt.Fprintf(os.Stderr, \"%s: error: %s\\n\", progName, err)\n\t\t}\n\t\tos.Exit(1)\n\t}\n}\n\ntype Version struct {\n\tVersion string\n\tCommit  string\n\tDate    string\n\tBuiltBy string\n}\n\nfunc (v Version) Print() string {\n\tvar s strings.Builder\n\tfmt.Fprintln(&s, \"gtrash: Trash CLI Manager written in Go\")\n\tfmt.Fprintln(&s, \"https://github.com/umlx5h/gtrash\")\n\tfmt.Fprintln(&s, \"\")\n\tfmt.Fprintln(&s, \"version: \"+v.Version)\n\tfmt.Fprintln(&s, \"commit: \"+v.Commit)\n\tfmt.Fprintln(&s, \"buildDate: \"+v.Date)\n\tfmt.Fprintln(&s, \"builtBy: \"+v.BuiltBy)\n\n\treturn s.String()\n}\n\n// global options\nvar (\n\tisDebug bool\n)\n\ntype rootCmd struct {\n\tcmd *cobra.Command\n}\n\nfunc newRootCmd(version Version) *rootCmd {\n\t// if version is not set, probably go install\n\tif version.Version == \"unknown\" {\n\t\tif info, ok := debug.ReadBuildInfo(); ok {\n\t\t\tversion.Version = info.Main.Version\n\t\t}\n\t}\n\n\troot := &rootCmd{}\n\tcmd := &cobra.Command{\n\t\tUse:           progName,\n\t\tSilenceErrors: true,\n\t\tShort:         \"Trash CLI manager written in Go\",\n\t\tLong: `Trash CLI manager written in Go\n  https://github.com/umlx5h/gtrash`,\n\t\tVersion: version.Print(),\n\t\tPersistentPreRun: func(_ *cobra.Command, _ []string) {\n\t\t\t// setup debug log level\n\t\t\tlvl := &slog.LevelVar{}\n\n\t\t\tlvl.Set(slog.LevelWarn)\n\t\t\tif isDebug {\n\t\t\t\tlvl.Set(slog.LevelDebug)\n\t\t\t}\n\t\t\t// colored format\n\t\t\tlogger := slog.New(tint.NewHandler(os.Stderr, &tint.Options{\n\t\t\t\tLevel:      lvl,\n\t\t\t\tTimeFormat: \"15:04:05.000\",\n\t\t\t\tNoColor:    !isTerminal,\n\t\t\t}))\n\n\t\t\tslog.SetDefault(logger)\n\n\t\t\tslog.Debug(\"gtrash version\", \"version\", fmt.Sprintf(\"%+v\", version))\n\t\t\tslog.Debug(\"enviornment variable\",\n\t\t\t\t\"HOME_TRASH_DIR\", env.HOME_TRASH_DIR,\n\t\t\t\t\"ONLY_HOME_TRASH\", env.ONLY_HOME_TRASH,\n\t\t\t)\n\t\t},\n\t}\n\n\tcmd.SetVersionTemplate(\"{{.Version}}\")\n\tcmd.PersistentFlags().BoolVar(&isDebug, \"debug\", false, \"debug mode\")\n\tcmd.PersistentFlags()\n\n\t// disable help subcommand\n\tcmd.SetHelpCommand(&cobra.Command{\n\t\tUse:    \"no-help\",\n\t\tHidden: true,\n\t})\n\n\t// prefix program name\n\tcmd.SetErrPrefix(fmt.Sprintf(\"%s: error:\", progName))\n\n\t// Add subcommands\n\tcmd.AddCommand(\n\t\tnewPutCmd().cmd,\n\t\tnewFindCmd().cmd,\n\t\tnewRestoreCmd().cmd,\n\t\tnewRestoreGroupCmd().cmd,\n\t\tnewRemoveCmd().cmd,\n\t\tnewSummaryCmd().cmd,\n\t\tnewMetafixCmd().cmd,\n\t\tnewPruneCmd().cmd,\n\t)\n\troot.cmd = cmd\n\treturn root\n}\n"
  },
  {
    "path": "internal/cmd/summary.go",
    "content": "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/gtrash/internal/glog\"\n\t\"github.com/umlx5h/gtrash/internal/trash\"\n)\n\ntype summaryCmd struct {\n\tcmd  *cobra.Command\n\topts summaryOptions\n}\n\ntype summaryOptions struct{}\n\nfunc newSummaryCmd() *summaryCmd {\n\troot := &summaryCmd{}\n\tcmd := &cobra.Command{\n\t\tUse:     \"summary\",\n\t\tShort:   \"Show summary of all trash cans (s)\",\n\t\tAliases: []string{\"s\"},\n\t\tLong: `Description:\n  Displays statistics summarizing all trash cans.\n  Shows the count of files (and folders) and their total size.\n  When multiple trash cans are detected, the statistics for each and the total are displayed.`,\n\t\tSilenceUsage:      true,\n\t\tArgs:              cobra.NoArgs,\n\t\tValidArgsFunction: cobra.NoFileCompletions,\n\t\tRunE: func(_ *cobra.Command, _ []string) error {\n\t\t\tif err := summaryCmdRun(root.opts); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tif glog.ExitCode() > 0 {\n\t\t\t\treturn errContinue\n\t\t\t}\n\t\t\treturn nil\n\t\t},\n\t}\n\n\troot.cmd = cmd\n\treturn root\n}\n\nfunc summaryCmdRun(_ summaryOptions) error {\n\tbox := trash.NewBox(\n\t\ttrash.WithGetSize(true),\n\t)\n\n\tif err := box.Open(); err != nil {\n\t\tif !errors.Is(err, trash.ErrNotFound) {\n\t\t\treturn err\n\t\t}\n\t}\n\n\tvar (\n\t\ttotalSize int64\n\t\ttotalItem int\n\t)\n\n\tfor i, trashDir := range box.TrashDirs {\n\t\tvar (\n\t\t\tsize int64\n\t\t\titem int\n\t\t)\n\n\t\tfor _, f := range box.FilesByTrashDir[trashDir] {\n\t\t\titem++\n\t\t\tif f.Size != nil {\n\t\t\t\tsize += *f.Size\n\t\t\t}\n\t\t}\n\n\t\tfmt.Printf(\"[%s]\\n\", trashDir)\n\t\tfmt.Printf(\"item: %d\\n\", item)\n\t\tfmt.Printf(\"size: %s\\n\", humanize.Bytes(uint64(size)))\n\n\t\tif i != len(box.TrashDirs)-1 {\n\t\t\tfmt.Println(\"\")\n\t\t}\n\n\t\ttotalSize += size\n\t\ttotalItem += item\n\t}\n\n\tif len(box.TrashDirs) > 1 {\n\t\tfmt.Printf(\"\\n[total]\\n\")\n\t\tfmt.Printf(\"item: %d\\n\", totalItem)\n\t\tfmt.Printf(\"size: %s\\n\", humanize.Bytes(uint64(totalSize)))\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/env/env.go",
    "content": "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 directory when they cannot be renamed to the external trash can\n\t// Disk usage of the main file system will increase because files are copied across different filesystems, also also take time to copy.\n\t// Automatically enabled if ONLY_HOME_TRASH enabled\n\t// Default: false\n\tHOME_TRASH_FALLBACK_COPY bool\n\n\t// Use only the trash can in the home directory, not the one in the external file system\n\t// Default: false\n\tONLY_HOME_TRASH bool\n\n\t// Specify the directory for home trash can\n\t// Default: $XDG_DATA_HOME/Trash ($HOME/.local/share/Trash)\n\tHOME_TRASH_DIR string\n\n\t// Whether to get as close to rm behavior as possible\n\t// Default: false\n\tPUT_RM_MODE bool\n)\n\nfunc init() {\n\tif e, ok := os.LookupEnv(\"GTRASH_HOME_TRASH_FALLBACK_COPY\"); ok {\n\t\tif strings.ToLower(strings.TrimSpace(e)) == \"true\" {\n\t\t\tHOME_TRASH_FALLBACK_COPY = true\n\t\t}\n\t}\n\n\tif e, ok := os.LookupEnv(\"GTRASH_ONLY_HOME_TRASH\"); ok {\n\t\tif strings.ToLower(strings.TrimSpace(e)) == \"true\" {\n\t\t\tONLY_HOME_TRASH = true\n\t\t\t// Also enable this\n\t\t\tHOME_TRASH_FALLBACK_COPY = true\n\t\t}\n\t}\n\n\tif e, ok := os.LookupEnv(\"GTRASH_PUT_RM_MODE\"); ok {\n\t\tif strings.ToLower(strings.TrimSpace(e)) == \"true\" {\n\t\t\tPUT_RM_MODE = true\n\t\t}\n\t}\n\n\tif e, ok := os.LookupEnv(\"GTRASH_HOME_TRASH_DIR\"); ok {\n\t\tif e != \"\" {\n\t\t\tpath, err := filepath.Abs(e)\n\t\t\tif err != nil {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"ENV $GTRASH_HOME_TRASH_DIR is not valid path: %s\", err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\n\t\t\t// Ensure to have directory in advance\n\t\t\tif err := os.MkdirAll(path, 0o700); err != nil {\n\t\t\t\tfmt.Fprintf(os.Stderr, \"ENV $GTRASH_HOME_TRASH_DIR could not be created: %s\", err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\n\t\t\tHOME_TRASH_DIR = path\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/glog/logger.go",
    "content": "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.Args[0])\n)\n\nvar stderr io.Writer = os.Stderr\n\nfunc Error(msg string) {\n\terrorCalled++\n\tfmt.Fprintln(stderr, progName+\":\", msg)\n}\n\nfunc Errorf(format string, args ...any) {\n\terrorCalled++\n\tfmt.Fprintf(stderr, progName+\": \"+format, args...)\n}\n\nfunc ExitCode() int {\n\tif errorCalled == 0 {\n\t\treturn 0\n\t} else {\n\t\treturn 1\n\t}\n}\n"
  },
  {
    "path": "internal/posix/dir.go",
    "content": "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 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).\nfunc DirSize(path string) (int64, error) {\n\tvar block int64\n\terr := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tsys, ok := info.Sys().(*syscall.Stat_t)\n\t\tif !ok {\n\t\t\treturn errors.New(\"cannot get stat_t\")\n\t\t}\n\n\t\tblock += sys.Blocks\n\t\treturn err\n\t})\n\treturn block * 512, err\n}\n\n// Look at both block-size and apparent-size and choose the larger one.\n// Because there are file systems for which block size cannot be obtained.\n// max(du -sB1, du -sb)\nfunc DirSizeFallback(path string) (int64, error) {\n\tvar size int64\n\terr := filepath.Walk(path, func(_ string, info os.FileInfo, err error) error {\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tsys, ok := info.Sys().(*syscall.Stat_t)\n\t\tif !ok {\n\t\t\treturn errors.New(\"cannot get stat_t\")\n\t\t}\n\n\t\t// stat(2)\n\t\t// blkcnt_t  st_blocks;      /* Number of 512B blocks allocated */\n\t\tsize += max(sys.Size, sys.Blocks*512)\n\t\treturn err\n\t})\n\n\treturn size, err\n}\n\n// check name path is empty directory\nfunc DirEmpty(name string) (bool, error) {\n\tf, err := os.Open(name)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tdefer f.Close()\n\n\t_, err = f.Readdirnames(1)\n\tif err == io.EOF {\n\t\treturn true, nil\n\t}\n\treturn false, err\n}\n"
  },
  {
    "path": "internal/posix/file.go",
    "content": "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.com/umlx5h/go-runewidth\"\n)\n\nfunc IsBinary(content io.ReadSeeker, fileSize int64) (bool, error) {\n\theadSize := min(fileSize, 1024)\n\thead := make([]byte, headSize)\n\tif _, err := content.Read(head); err != nil {\n\t\treturn false, err\n\t}\n\tif _, err := content.Seek(0, io.SeekStart); err != nil {\n\t\treturn false, err\n\t}\n\n\t// ref: https://github.com/file/file/blob/5e33fd6ee7766d40382a084c8e7554c2d43c0b7e/src/encoding.c#L183-L260\n\tfor _, b := range head {\n\t\tif b < 7 || b == 11 || (13 < b && b < 27) || (27 < b && b < 0x20) || b == 0x7f {\n\t\t\treturn true, nil\n\t\t}\n\t}\n\n\treturn false, nil\n}\n\nfunc FileHead(path string, width int, maxLines int) string {\n\tfi, err := os.Lstat(path)\n\tif err != nil {\n\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\treturn \"(error: not found)\"\n\t\t} else {\n\t\t\treturn \"(error: could not stat)\"\n\t\t}\n\t}\n\tcontent := func(isDir bool, lines []string) string {\n\t\tif len(lines) == 0 {\n\t\t\tif isDir {\n\t\t\t\treturn \"(empty directory)\\n\"\n\t\t\t} else {\n\t\t\t\treturn \"(empty file)\\n\"\n\t\t\t}\n\t\t}\n\t\tvar content string\n\t\tvar i int\n\t\tfor _, line := range lines {\n\t\t\ti++\n\t\t\tcontent += fmt.Sprintf(\"  %s\\n\", line)\n\t\t}\n\t\tif isDir {\n\t\t\treturn \"(directory)\" + \"\\n\" + content\n\t\t} else {\n\t\t\treturn \"(text)\" + \"\\n\" + content\n\t\t}\n\t}\n\n\tvar lines []string\n\tvar isDir bool\n\tswitch {\n\tcase fi.Mode().Type() == fs.ModeSymlink:\n\t\treturn \"(symbolic link)\"\n\tcase fi.IsDir():\n\t\tisDir = true\n\t\tdirs, _ := os.ReadDir(path)\n\t\tfor i, dir := range dirs {\n\t\t\tif i == maxLines {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tdinfo, err := dir.Info()\n\t\t\tif err != nil {\n\t\t\t\treturn \"(error: open directory)\"\n\t\t\t}\n\t\t\tname := runewidth.Truncate(dir.Name(), width-15, \"…\")\n\t\t\tif dir.IsDir() {\n\t\t\t\t// folder is blue color\n\t\t\t\tname = lipgloss.NewStyle().Foreground(lipgloss.Color(\"12\")).Render(name)\n\t\t\t}\n\t\t\tl := fmt.Sprintf(\"%s  %s\", dinfo.Mode().Perm().String(), name)\n\t\t\tlines = append(lines, l)\n\t\t}\n\tcase fi.Mode().IsRegular():\n\t\tf, err := os.Open(path)\n\t\tif err != nil {\n\t\t\treturn \"(error: open file)\"\n\t\t}\n\t\tdefer f.Close()\n\n\t\tif binary, err := IsBinary(f, fi.Size()); err != nil {\n\t\t\treturn \"(error: read file)\"\n\t\t} else if binary {\n\t\t\treturn \"(binary file)\"\n\t\t}\n\n\t\t// if file is text, read maxLines lines\n\t\ts := bufio.NewScanner(f)\n\t\tvar n int\n\t\tfor s.Scan() {\n\t\t\tif n == maxLines {\n\t\t\t\tbreak\n\t\t\t}\n\t\t\tt := s.Text()\n\t\t\t// truncate to screen width\n\t\t\tlines = append(lines, runewidth.Truncate(t, width-3, \"…\"))\n\t\t\tn++\n\t\t}\n\tdefault:\n\t\treturn \"(unknown file type)\"\n\t}\n\treturn content(isDir, lines)\n}\n\nfunc FileType(st fs.FileInfo) string {\n\tif st.IsDir() {\n\t\treturn \"directory\"\n\t} else if st.Mode().IsRegular() {\n\t\tif st.Size() == 0 {\n\t\t\treturn \"regular empty file\"\n\t\t} else {\n\t\t\treturn \"regular file\"\n\t\t}\n\t}\n\n\tswitch st.Mode().Type() {\n\tcase fs.ModeSymlink:\n\t\treturn \"symbolic link\"\n\tcase fs.ModeNamedPipe:\n\t\treturn \"fifo\"\n\tcase fs.ModeSocket:\n\t\treturn \"socket\"\n\t}\n\n\treturn \"unknown type file\"\n}\n"
  },
  {
    "path": "internal/posix/path.go",
    "content": "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 AbsPathToTilde(absPath string) string {\n\t// if executed as root, disable\n\tif euid == 0 {\n\t\treturn absPath\n\t}\n\thomeDir, ok := os.LookupEnv(\"HOME\")\n\tif !ok {\n\t\treturn absPath\n\t}\n\n\tif strings.HasPrefix(absPath, homeDir) {\n\t\treturn strings.Replace(absPath, homeDir, \"~\", 1)\n\t}\n\n\treturn absPath\n}\n\n// Check if sub is a subdirectory of parent\nfunc CheckSubPath(parent, sub string) (bool, error) {\n\tup := \"..\" + string(os.PathSeparator)\n\n\trel, err := filepath.Rel(parent, sub)\n\tif err != nil {\n\t\treturn false, err\n\t}\n\tif !strings.HasPrefix(rel, up) && rel != \"..\" {\n\t\treturn true, nil\n\t}\n\treturn false, nil\n}\n"
  },
  {
    "path": "internal/posix/path_test.go",
    "content": "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 := []struct {\n\t\tabsPath      string\n\t\texpectedPath string\n\t}{\n\t\t{home + \"/example/file.txt\", \"~/example/file.txt\"},\n\t\t{\"/home/user/another/file.txt\", \"/home/user/another/file.txt\"},\n\t\t{\"\", \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tresult := AbsPathToTilde(tt.absPath)\n\t\tif result != tt.expectedPath {\n\t\t\tt.Errorf(\"Expected %s, but got %s for path %s\", tt.expectedPath, result, tt.absPath)\n\t\t}\n\t}\n}\n\nfunc TestCheckSubPath(t *testing.T) {\n\ttests := []struct {\n\t\tparentPath string\n\t\tsubPath    string\n\t\texpected   bool\n\t}{\n\t\t{\"/home/user\", \"/home/user/Documents\", true},\n\t\t{\"/home/user\", \"/home/user/Documents/foo\", true},\n\t\t{\"/home/user\", \"/home/user\", true},\n\t\t{\"/home/user\", \"/var/www\", false},\n\t\t{\"/home/user\", \"/home\", false},\n\t\t{\"/\", \"/\", true},\n\t}\n\n\tfor _, tt := range tests {\n\t\tresult, err := CheckSubPath(tt.parentPath, tt.subPath)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error occurred: %s\", err)\n\t\t}\n\n\t\tif result != tt.expected {\n\t\t\tt.Errorf(\"Expected %v, but got %v for parent: %q, sub: %q\", tt.expected, result, tt.parentPath, tt.subPath)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/trash/flag.go",
    "content": "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)\n\nfunc FlagCompletionFunc(allCompletions []string) func(*cobra.Command, []string, string) (\n\t[]string, cobra.ShellCompDirective,\n) {\n\treturn func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {\n\t\tvar completions []string\n\t\tfor _, completion := range allCompletions {\n\t\t\tif strings.HasPrefix(completion, toComplete) {\n\t\t\t\tcompletions = append(completions, completion)\n\t\t\t}\n\t\t}\n\t\treturn completions, cobra.ShellCompDirectiveNoFileComp\n\t}\n}\n\n// --sort, -s\n\nvar (\n\tsortByWellKnownStrings = map[string]SortByType{\n\t\t\"date\": SortByDeletedAt,\n\t\t\"size\": SortBySize,\n\t\t\"name\": SortByName,\n\t}\n\n\tSortByFlagCompletionFunc = FlagCompletionFunc(\n\t\tmaps.Keys(sortByWellKnownStrings),\n\t)\n)\n\nfunc (s *SortByType) Set(str string) error {\n\tif value, ok := sortByWellKnownStrings[strings.ToLower(str)]; ok {\n\t\t*s = value\n\t\treturn nil\n\t}\n\n\treturn fmt.Errorf(\"must be %s\", s.Type())\n}\n\nfunc (s SortByType) String() string {\n\tswitch s {\n\tcase SortByDeletedAt:\n\t\treturn \"date\"\n\tcase SortBySize:\n\t\treturn \"size\"\n\tcase SortByName:\n\t\treturn \"name\"\n\tdefault:\n\t\tpanic(\"invalid SortByType value\")\n\t}\n}\n\nfunc (s SortByType) Type() string {\n\treturn \"date|size|name\"\n}\n\n// --mode, -m\n\nvar _ pflag.Value = (*ModeByType)(nil)\n\ntype ModeByType int\n\nconst (\n\tModeByRegex ModeByType = iota\n\tModeByGlob             // default\n\tModeByLiteral\n\tModeByFull\n)\n\nvar (\n\tmodeByWellKnownStrings = map[string]ModeByType{\n\t\t\"regex\":   ModeByRegex,\n\t\t\"glob\":    ModeByGlob,\n\t\t\"literal\": ModeByLiteral,\n\t\t\"full\":    ModeByFull,\n\t}\n\n\tModeByFlagCompletionFunc = FlagCompletionFunc(\n\t\tmaps.Keys(modeByWellKnownStrings),\n\t)\n)\n\nfunc (s *ModeByType) Set(str string) error {\n\tif value, ok := modeByWellKnownStrings[strings.ToLower(str)]; ok {\n\t\t*s = value\n\t\treturn nil\n\t}\n\n\treturn fmt.Errorf(\"must be %s\", s.Type())\n}\n\nfunc (s ModeByType) String() string {\n\tswitch s {\n\tcase ModeByGlob:\n\t\treturn \"glob\"\n\tcase ModeByRegex:\n\t\treturn \"regex\"\n\tcase ModeByLiteral:\n\t\treturn \"literal\"\n\tcase ModeByFull:\n\t\treturn \"full\"\n\tdefault:\n\t\tpanic(\"invalid ModeByType value\")\n\t}\n}\n\nfunc (s ModeByType) Type() string {\n\treturn \"regex|glob|literal|full\"\n}\n"
  },
  {
    "path": "internal/trash/trash.go",
    "content": "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\"strings\"\n\t\"time\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/dustin/go-humanize\"\n\t\"github.com/gobwas/glob\"\n\t\"github.com/spf13/pflag\"\n\t\"github.com/umlx5h/gtrash/internal/posix\"\n\t\"github.com/umlx5h/gtrash/internal/xdg\"\n)\n\nvar _ pflag.Value = (*SortByType)(nil)\n\ntype SortByType int\n\nconst (\n\tSortByDeletedAt SortByType = iota // default\n\tSortBySize\n\tSortByName\n)\n\ntype Box struct {\n\tFiles           []File\n\tFilesByTrashDir map[string][]File // key: trash_dir, value: array of Files\n\tTrashDirs       []string\n\thitByPath       map[string]int // key: originalPath, value: number of files to hit\n\tOrphanMeta      []File         // .trashinfo exists but there is no real file in the files folder\n\n\t// set by cli flags\n\n\t// sort options\n\tascend bool\n\tsortBy SortByType\n\n\t// filter options\n\tcwd         bool\n\tdirectory   string\n\tqueries     []string\n\tqueriesReg  []*regexp.Regexp\n\tqueriesGlob []glob.Glob\n\tqueryModeBy ModeByType\n\n\t// filter by date\n\tday      int\n\tdayPoint time.Time // --day-new, --day-old\n\tnewer    bool\n\n\t// filter by size\n\tsize       uint64 // byte, convert from sizeHuman\n\tsizeHuman  string // human size (e.g. 10MB)\n\tsizeLarger bool   // if true, filter by size > X\n\n\ttrashDir string // $HOME/.local/share/Trash\n\n\t// Whether to use stat(2) to get size and mode\n\tGetSize       bool\n\tnoFilterApply bool // true if select all trashcan\n\n\tlimitLast int\n}\n\nfunc NewBox(opts ...BoxOption) Box {\n\tb := Box{\n\t\tFilesByTrashDir: make(map[string][]File),\n\t\thitByPath:       make(map[string]int),\n\t}\n\n\tfor _, o := range opts {\n\t\to(&b)\n\t}\n\n\treturn b\n}\n\ntype BoxOption func(*Box)\n\nfunc WithAscend(ascend bool) BoxOption {\n\treturn func(b *Box) {\n\t\tb.ascend = ascend\n\t}\n}\n\nfunc WithTrashDir(trashDir string) BoxOption {\n\treturn func(b *Box) {\n\t\tb.trashDir = trashDir\n\t}\n}\n\nfunc WithSortBy(sortBy SortByType) BoxOption {\n\treturn func(b *Box) {\n\t\tb.sortBy = sortBy\n\t}\n}\n\nfunc WithDirectory(directory string) BoxOption {\n\treturn func(b *Box) {\n\t\tb.directory = directory\n\t}\n}\n\nfunc WithCWD(cwd bool) BoxOption {\n\treturn func(b *Box) {\n\t\tb.cwd = cwd\n\t}\n}\n\nfunc WithQueries(queries []string) BoxOption {\n\treturn func(b *Box) {\n\t\tb.queries = queries\n\t}\n}\n\nfunc WithQueryMode(mode ModeByType) BoxOption {\n\treturn func(b *Box) {\n\t\tb.queryModeBy = mode\n\t}\n}\n\n// TODO: Support for notations other than day?\nfunc WithDay(dayNew int, dayOld int) BoxOption {\n\tday := max(dayNew, dayOld) // either specified\n\tpoint := time.Now().AddDate(0, 0, -day)\n\n\treturn func(b *Box) {\n\t\tb.day = day\n\t\tb.dayPoint = point\n\t\tb.newer = dayNew > 0\n\t}\n}\n\nfunc WithLimitLast(last int) BoxOption {\n\treturn func(b *Box) {\n\t\tb.limitLast = last\n\t}\n}\n\nfunc WithSize(large string, small string) BoxOption {\n\tvar larger bool\n\tsize := small\n\tif large != \"\" {\n\t\tlarger = true\n\t\tsize = large\n\t}\n\n\treturn func(b *Box) {\n\t\tb.sizeHuman = size // either specified\n\t\tb.sizeLarger = larger\n\t}\n}\n\nfunc WithGetSize(get bool) BoxOption {\n\treturn func(b *Box) {\n\t\tb.GetSize = get\n\t}\n}\n\n// validate and adjust options\nfunc (b *Box) checkOptions() error {\n\tvar err error\n\n\t// convert to absolute path\n\tif b.directory != \"\" {\n\t\tif abs, err := filepath.Abs(b.directory); err == nil {\n\t\t\tb.directory = abs\n\t\t}\n\t}\n\n\t// get cwd\n\tif b.cwd {\n\t\tb.directory, err = os.Getwd()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"-c,--cwd get cwd: %w\", err)\n\t\t}\n\t}\n\n\t// compile queries\n\tif len(b.queries) > 0 {\n\t\tswitch b.queryModeBy {\n\t\tcase ModeByRegex:\n\t\t\tregs := make([]*regexp.Regexp, len(b.queries))\n\t\t\tfor i, q := range b.queries {\n\t\t\t\tr, err := regexp.Compile(q)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"regex syntax in query is not valid: %q: %q\", q, err)\n\t\t\t\t}\n\t\t\t\tregs[i] = r\n\t\t\t}\n\n\t\t\tb.queriesReg = regs\n\t\tcase ModeByGlob:\n\t\t\tglobs := make([]glob.Glob, len(b.queries))\n\t\t\tfor i, q := range b.queries {\n\t\t\t\tg, err := glob.Compile(q)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn fmt.Errorf(\"glob syntax in query is not valid: %q: %w\", q, err)\n\t\t\t\t}\n\t\t\t\tglobs[i] = g\n\t\t\t}\n\n\t\t\tb.queriesGlob = globs\n\t\t}\n\t}\n\n\t// compile human-size byte to byte\n\tif b.sizeHuman != \"\" {\n\t\tbyte, err := humanize.ParseBytes(b.sizeHuman)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"--size unit is invalid: %w\", err)\n\t\t}\n\t\tb.size = byte\n\t}\n\n\t// set GetSize true based options\n\tif !b.GetSize {\n\t\tb.GetSize = b.sizeHuman != \"\" || b.sortBy == SortBySize\n\t}\n\n\t// validate as absolute path and normalize and check existence\n\tif b.trashDir != \"\" {\n\t\t// validate\n\t\tif !filepath.IsAbs(b.trashDir) {\n\t\t\treturn fmt.Errorf(\"--trash-dir is not absolute path\")\n\t\t}\n\n\t\t// normalize\n\t\tb.trashDir, _ = filepath.Abs(b.trashDir)\n\n\t\t// check existence\n\t\tif fi, err := os.Stat(b.trashDir); err != nil {\n\t\t\treturn fmt.Errorf(\"--trash-dir must be a existing directory: %w\", err)\n\t\t} else {\n\t\t\tif !fi.IsDir() {\n\t\t\t\treturn fmt.Errorf(\"--trash-dir must be a directory\")\n\t\t\t}\n\t\t}\n\t}\n\n\t// check if select all trashcan\n\tif len(b.queries) == 0 && b.sizeHuman == \"\" && b.day == 0 && b.directory == \"\" {\n\t\tb.noFilterApply = true\n\t}\n\n\treturn nil\n}\n\nvar ErrNotFound = errors.New(\"not found\")\n\nfunc (b *Box) Open() error {\n\t// validation Box options\n\tif err := b.checkOptions(); err != nil {\n\t\treturn err\n\t}\n\n\tvar trashDirs []xdg.TrashDir\n\n\tif b.trashDir == \"\" {\n\t\t// Automatically searches for trash can paths by default\n\t\tslog.Debug(\"scanning trash directories\")\n\t\t// Retrieve trash from all mount points\n\t\ttrashDirs = xdg.ScanTrashDirs()\n\t\tif len(trashDirs) == 0 {\n\t\t\treturn fmt.Errorf(\"%w: trash directories\", ErrNotFound)\n\t\t}\n\t\tslog.Debug(\"found trash directories\", \"number\", len(trashDirs), \"trashDirs\", trashDirs)\n\t} else {\n\t\t// If --trash-dir is specified, it is used as is.\n\t\tslog.Debug(\"using manual trash directory\", \"trashDir\", b.trashDir)\n\t\ttrashDirs = []xdg.TrashDir{xdg.NewTrashDirManual(b.trashDir)}\n\t}\n\n\tfor _, trashDir := range trashDirs {\n\t\tslog.Debug(\"starting to read trashDir\", \"trashDir\", trashDir.Dir)\n\t\t// Scan the files directory to check for the existence of files.\n\t\t// Whether the file is a directory or not can be obtained at this stage.\n\t\tdirents, err := os.ReadDir(trashDir.FilesDir())\n\t\tif err != nil {\n\t\t\tif !errors.Is(err, os.ErrNotExist) {\n\t\t\t\tslog.Warn(\"cannot read files folder in trashDir, skipped\", \"trashDir\", trashDir, \"error\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// files folder not exists in this mountpoint\n\t\t\tslog.Debug(\"not found files folder in trashDir, skipped\", \"trashDir\", trashDir)\n\t\t\tcontinue\n\t\t}\n\t\t// convert to slices to map\n\t\tfileEntries := make(map[string]bool, len(dirents)) // key: filename, value: isDir\n\t\tfor _, ent := range dirents {\n\t\t\tfileEntries[ent.Name()] = ent.IsDir()\n\t\t}\n\n\t\tdirents, err = os.ReadDir(trashDir.InfoDir())\n\t\tif err != nil {\n\t\t\tif !errors.Is(err, os.ErrNotExist) {\n\t\t\t\tslog.Warn(\"cannot read info folder in trashDir, skipped\", \"trashDir\", trashDir, \"error\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tslog.Debug(\"not found info folder in trashDir, skipped\", \"trashDir\", trashDir)\n\t\t\tcontinue\n\t\t}\n\n\t\t// Load directory size cache into map\n\t\t// Not used when nil.\n\t\tvar dirCache xdg.DirCache // key: directory name, value: cache entry\n\n\t\tdirectorySizesPath := filepath.Join(trashDir.Dir, \"directorysizes\")\n\n\t\tif b.GetSize {\n\t\t\t// init map\n\t\t\tdirCache = make(xdg.DirCache)\n\n\t\t\tslog.Debug(\"reading directorysizes cache\", \"path\", directorySizesPath)\n\t\t\tif f, err := os.Open(directorySizesPath); err != nil {\n\t\t\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\t\t\tslog.Debug(\"not found directorysizes cache\", \"path\", directorySizesPath, \"error\", err)\n\t\t\t\t} else {\n\t\t\t\t\tslog.Warn(\"failed to read directorysizes cache\", \"path\", directorySizesPath)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tif c, err := xdg.NewDirCache(f); err != nil {\n\t\t\t\t\tslog.Warn(\"failed to parse directorysizes cache, it will be recreated\", \"path\", directorySizesPath, \"error\", err)\n\t\t\t\t} else {\n\t\t\t\t\t// got cache from file\n\t\t\t\t\tdirCache = c\n\t\t\t\t}\n\t\t\t\tf.Close()\n\t\t\t}\n\t\t}\n\n\t\tslog.Debug(\"starting to read directory entries\", \"file_entries\", len(fileEntries), \"info_entries\", len(dirents))\n\n\t\t// True if the cache expires or an entry is added.\n\t\tvar dirCacheUpdated bool\n\n\t\tfiles := b.getFiles(dirents, fileEntries, trashDir, dirCache, &dirCacheUpdated)\n\t\tslog.Debug(\"found trashed files\", \"number\", len(files), \"trashDir\", trashDir.Dir)\n\n\t\t// save directorysize cache\n\t\tif dirCache != nil && dirCacheUpdated {\n\t\t\tslog.Debug(\"saving directorysizes cache\", \"path\", directorySizesPath, \"isTruncate\", b.noFilterApply)\n\t\t\t// When all selections are made, the cache file is rewritten.\n\t\t\t// (To delete old entries that are no longer needed.)\n\t\t\tif err := dirCache.Save(trashDir.Dir, b.noFilterApply); err != nil { // if\n\t\t\t\tslog.Warn(\"failed to save directorysizes cache\", \"path\", directorySizesPath, \"error\", err)\n\t\t\t}\n\t\t}\n\n\t\tb.TrashDirs = append(b.TrashDirs, trashDir.Dir)\n\t\tif len(files) > 0 {\n\t\t\t// TODO: perf: run only when necessary\n\t\t\tsortFiles(files, b.sortBy, b.ascend)\n\t\t\tb.FilesByTrashDir[trashDir.Dir] = files\n\t\t}\n\t\tb.Files = append(b.Files, files...)\n\t}\n\n\tif len(b.Files) == 0 {\n\t\treturn fmt.Errorf(\"%w: trashed files\", ErrNotFound)\n\t}\n\n\tsortFiles(b.Files, b.sortBy, b.ascend)\n\n\t// truncate to last n items\n\tif b.limitLast > 0 {\n\t\tif len(b.Files) > b.limitLast {\n\t\t\tn := len(b.Files)\n\t\t\tb.Files = b.Files[n-b.limitLast : n]\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (b *Box) getFiles(dirents []fs.DirEntry, fileEntries map[string]bool, trashDir xdg.TrashDir, dirCache xdg.DirCache, dirCacheUpdated *bool) []File {\n\tvar files []File\n\tfor _, ent := range dirents {\n\t\tif ent.Type().IsRegular() && strings.HasSuffix(ent.Name(), \".trashinfo\") {\n\t\t\ttrashInfoPath := filepath.Join(trashDir.InfoDir(), ent.Name())\n\n\t\t\tif strings.HasPrefix(ent.Name(), \"._\") {\n\t\t\t\t// exclude mac resource fork\n\t\t\t\tslog.Debug(\"skipped mac resource fork of .trashinfo\", \"path\", trashInfoPath)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tf, err := os.Open(trashInfoPath)\n\t\t\tif err != nil {\n\t\t\t\tslog.Warn(\"failed to open .trashinfo, skipped\", \"path\", trashInfoPath, \"error\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tinfo, err := xdg.NewInfo(f)\n\n\t\t\t// It is better to close each time from a performance standpoint.\n\t\t\tf.Close()\n\n\t\t\tif err != nil {\n\t\t\t\tslog.Warn(\"failed to parse .trashinfo, skipped\", \"path\", trashInfoPath, \"error\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tif !strings.HasPrefix(info.Path, string(os.PathSeparator)) {\n\t\t\t\t// If it was a relative path, convert it to an absolute path\n\t\t\t\tinfo.Path = filepath.Join(trashDir.Root, info.Path)\n\t\t\t}\n\n\t\t\ttrashFileName := strings.TrimSuffix(ent.Name(), \".trashinfo\")\n\n\t\t\tfile := File{\n\t\t\t\tName:          filepath.Base(info.Path),\n\t\t\t\tOriginalPath:  info.Path,\n\t\t\t\tTrashPath:     filepath.Join(trashDir.FilesDir(), trashFileName),\n\t\t\t\tTrashInfoPath: trashInfoPath,\n\t\t\t\tDeletedAt:     info.DeletionDate,\n\t\t\t\tIsDir:         fileEntries[trashFileName],\n\t\t\t}\n\n\t\t\t// If the corresponding trashed file does not exist, it is assumed to be invalid metadata and skipped\n\t\t\tif _, ok := fileEntries[trashFileName]; !ok {\n\t\t\t\tslog.Debug(\"file in the meta information does not exist, skipped\", \"trashInfoPath\", file.TrashInfoPath, \"trashPath\", file.TrashPath)\n\t\t\t\tb.OrphanMeta = append(b.OrphanMeta, file)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// filter by directory\n\t\t\tif b.directory != \"\" {\n\t\t\t\tsubpath, _ := posix.CheckSubPath(b.directory, file.OriginalPath)\n\t\t\t\tif !subpath {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// filter by original path\n\t\t\tif len(b.queries) > 0 {\n\t\t\t\tswitch b.queryModeBy {\n\t\t\t\tcase ModeByFull:\n\t\t\t\t\tif !slices.Contains(b.queries, file.OriginalPath) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\tcase ModeByLiteral:\n\t\t\t\t\tvar match bool\n\t\t\t\t\tfor _, q := range b.queries {\n\t\t\t\t\t\tif strings.Contains(file.OriginalPath, q) {\n\t\t\t\t\t\t\tmatch = true\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif !match {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\tcase ModeByRegex:\n\t\t\t\t\tvar match bool\n\t\t\t\t\tfor _, reg := range b.queriesReg {\n\t\t\t\t\t\tif reg.MatchString(file.OriginalPath) {\n\t\t\t\t\t\t\tmatch = true\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif !match {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\tcase ModeByGlob:\n\t\t\t\t\tvar match bool\n\t\t\t\t\tfor _, glob := range b.queriesGlob {\n\t\t\t\t\t\tif glob.Match(file.OriginalPath) {\n\t\t\t\t\t\t\tmatch = true\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif !match {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// filter by deletedAt\n\t\t\tif b.day > 0 {\n\t\t\t\tif b.newer {\n\t\t\t\t\tif b.dayPoint.After(info.DeletionDate) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif b.dayPoint.Before(info.DeletionDate) {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// calculate file or directory size\n\t\t\tif b.GetSize {\n\t\t\t\tfi, err := os.Lstat(file.TrashPath)\n\t\t\t\tif err != nil {\n\t\t\t\t\tslog.Warn(\"cannot lstat(2) to the trashed file for getting size\", \"trashPath\", file.TrashPath, \"error\", err)\n\t\t\t\t\tgoto BREAK_GET_SIZE\n\t\t\t\t}\n\n\t\t\t\tfile.Mode = fi.Mode()\n\t\t\t\tfile.IsDir = fi.IsDir()\n\t\t\t\tif !fi.IsDir() {\n\t\t\t\t\t// if regular file\n\n\t\t\t\t\t// Files can be retrieved by stat.\n\t\t\t\t\ts := fi.Size()\n\t\t\t\t\tfile.Size = &s\n\t\t\t\t\tgoto BREAK_GET_SIZE\n\t\t\t\t}\n\n\t\t\t\t// For directory, refer to cache and recursively calculate size if cache misses\n\n\t\t\t\t// Check the update time of the trashinfo file to see if the cache has become stale\n\t\t\t\tfi, err = os.Stat(file.TrashInfoPath)\n\t\t\t\tif err != nil {\n\t\t\t\t\t// Since the file has already been loaded, it is unlikely to reach this point\n\t\t\t\t\tslog.Warn(\"cannot stat(2) to the trashinfo file for calculating directory size\", \"trashInfoPath\", file.TrashInfoPath, \"error\", err)\n\t\t\t\t\tgoto BREAK_GET_SIZE\n\t\t\t\t}\n\n\t\t\t\t// if directory, get size recursively while referring to cache\n\t\t\t\tvar size int64\n\t\t\t\t// check cache entry\n\t\t\t\tif item, ok := dirCache[trashFileName]; ok && item.Item.Mtime.Unix() == fi.ModTime().Unix() {\n\t\t\t\t\t// cache hit and cache is not stale\n\t\t\t\t\tsize = item.Item.Size\n\t\t\t\t\titem.Seen = true\n\t\t\t\t} else {\n\t\t\t\t\tif item == nil {\n\t\t\t\t\t\tslog.Debug(\"calculating directory size\", \"reason\", \"CACHE_NOT_HIT\", \"trashPath\", file.TrashPath)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tslog.Debug(\"calculating directory size\", \"reason\", \"CACHE_STALE\", \"trashPath\", file.TrashPath)\n\t\t\t\t\t}\n\t\t\t\t\t*dirCacheUpdated = true\n\n\t\t\t\t\t// calculate directory size\n\t\t\t\t\ts, err := posix.DirSizeFallback(file.TrashPath)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\t// Even if rename(2) succeeds, the file inside may not be readable depending on the permissions.\n\t\t\t\t\t\tslog.Warn(\"cannot calculate directory size\", \"trashPath\", file.TrashPath, \"error\", err)\n\n\t\t\t\t\t\t// Delete from cache because size could not be retrieved\n\t\t\t\t\t\t// noop when there is no cache\n\t\t\t\t\t\tdelete(dirCache, trashFileName)\n\n\t\t\t\t\t\tgoto BREAK_GET_SIZE\n\t\t\t\t\t}\n\t\t\t\t\tsize = s\n\n\t\t\t\t\t// update cache\n\t\t\t\t\tif item == nil {\n\t\t\t\t\t\t// cache not hit\n\n\t\t\t\t\t\t// add cache entry\n\t\t\t\t\t\tdirCache[trashFileName] = &struct {\n\t\t\t\t\t\t\tItem xdg.DirCacheItem\n\t\t\t\t\t\t\tSeen bool\n\t\t\t\t\t\t}{\n\t\t\t\t\t\t\tItem: xdg.DirCacheItem{\n\t\t\t\t\t\t\t\tSize:    size,\n\t\t\t\t\t\t\t\tMtime:   fi.ModTime(),\n\t\t\t\t\t\t\t\tDirName: trashFileName,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\tSeen: true,\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// cache hit but stale\n\n\t\t\t\t\t\t// update new size and mtime\n\t\t\t\t\t\titem.Item.Size = size\n\t\t\t\t\t\titem.Item.Mtime = fi.ModTime()\n\t\t\t\t\t\titem.Seen = true\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// succeed to get folder size\n\t\t\t\tfile.Size = &size\n\t\t\t}\n\n\t\tBREAK_GET_SIZE:\n\t\t\t// filter by size\n\t\t\tif b.sizeHuman != \"\" { // See sizeHuman to allow filtering even with 0\n\t\t\t\t// If the size is not obtained, it is nil then skipped.\n\t\t\t\tif file.Size == nil {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\n\t\t\t\tif b.sizeLarger {\n\t\t\t\t\tif uint64(*file.Size) < b.size {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tif uint64(*file.Size) > b.size {\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tb.hitByPath[file.OriginalPath]++\n\n\t\t\tfiles = append(files, file)\n\t\t}\n\t}\n\n\treturn files\n}\n\n// TODO: refactor\nfunc sortFiles(files []File, sortBy SortByType, ascend bool) {\n\tswitch sortBy {\n\tcase SortByDeletedAt: // default\n\t\tsort.Slice(files, func(i, j int) bool {\n\t\t\tif !ascend {\n\t\t\t\ti, j = j, i\n\t\t\t}\n\t\t\treturn files[i].DeletedAt.Before(files[j].DeletedAt)\n\t\t})\n\tcase SortBySize:\n\t\tsort.Slice(files, func(i, j int) bool {\n\t\t\tif !ascend {\n\t\t\t\ti, j = j, i\n\t\t\t}\n\n\t\t\t// If size is not available, treat as less than 0\n\t\t\tsi, sj := files[i].Size, files[j].Size\n\n\t\t\tvar minus int64 = -1\n\t\t\tif si == nil {\n\t\t\t\tsi = &minus\n\t\t\t}\n\t\t\tif sj == nil {\n\t\t\t\tsj = &minus\n\t\t\t}\n\n\t\t\treturn *si < *sj\n\t\t})\n\tcase SortByName:\n\t\tsort.Slice(files, func(i, j int) bool {\n\t\t\tif !ascend {\n\t\t\t\ti, j = j, i\n\t\t\t}\n\t\t\treturn files[i].OriginalPath < files[j].OriginalPath\n\t\t})\n\t}\n}\n\nfunc (b *Box) HitByPath(originalPath string) int {\n\treturn b.hitByPath[originalPath]\n}\n\ntype File struct {\n\tName          string    // .vimrc\n\tOriginalPath  string    // ~/.vimrc (Info.Path)\n\tTrashPath     string    // ~/.local/share/Trash/files/.vimrc\n\tTrashInfoPath string    // ~/.local/share/Trash/info/.vimrc.trashinfo\n\tDeletedAt     time.Time // 2023-01-01T00:00:00 (Info.DeletionDate)\n\tIsDir         bool\n\t// optionals below\n\tSize *int64 // nil if could not get, It may not be able to be taken due to permission violation, etc.\n\tMode fs.FileMode\n}\n\ntype Group struct {\n\tDir         string\n\tIsDirCommon bool      // Whether Dir is the same for all files\n\tDeletedAt   time.Time // pick one from Files\n\tFiles       []File\n}\n\nfunc (f *File) OriginalPathFormat(tilde bool, color bool) string {\n\tp := f.OriginalPath\n\tif tilde {\n\t\tp = posix.AbsPathToTilde(p)\n\t}\n\tif color {\n\t\treturn f.pathColor(p)\n\t} else {\n\t\treturn p\n\t}\n}\n\nfunc (f *File) TrashPathColor() string {\n\treturn f.pathColor(f.TrashPath)\n}\n\nfunc (f *File) SizeHuman() string {\n\tif f.Size == nil {\n\t\treturn \"-\"\n\t} else {\n\t\treturn humanize.Bytes(uint64(*f.Size))\n\t}\n}\n\nfunc (f *File) pathColor(s string) string {\n\tvar color lipgloss.Color\n\n\tif f.IsDir {\n\t\tcolor = lipgloss.Color(\"12\") // blue\n\t} else if f.Mode != 0 {\n\t\tswitch {\n\t\tcase f.Mode&0o111 > 0: // may be binary (x flag being set)\n\t\t\tcolor = lipgloss.Color(\"9\") // red\n\t\t}\n\t}\n\n\treturn lipgloss.NewStyle().Foreground(color).Render(s)\n}\n\nfunc (b *Box) ToGroups() []Group {\n\tfiles := b.Files\n\n\t// group by deletedAt\n\tfilesByDeletedAt := make(map[time.Time][]File)\n\tfor _, file := range files {\n\t\tfilesByDeletedAt[file.DeletedAt] = append(filesByDeletedAt[file.DeletedAt], file)\n\t}\n\n\thasMultiDirs := func(files []File) bool {\n\t\tvar dirs []string\n\t\tunique := make(map[string]bool)\n\t\tfor _, file := range files {\n\t\t\tdir := filepath.Dir(file.OriginalPath)\n\t\t\tif !unique[dir] {\n\t\t\t\tunique[dir] = true\n\t\t\t\tdirs = append(dirs, dir)\n\t\t\t}\n\t\t}\n\t\treturn len(dirs) > 1\n\t}\n\n\tvar groups []Group\n\tfor deletedAt, files := range filesByDeletedAt {\n\t\tdir := filepath.Dir(files[0].OriginalPath)\n\t\tisDirCommon := true\n\n\t\tif hasMultiDirs(files) {\n\t\t\tdir = \"(multiple directories)\"\n\t\t\tisDirCommon = false\n\t\t}\n\t\tgroups = append(groups, Group{\n\t\t\tDir:         dir,\n\t\t\tDeletedAt:   deletedAt,\n\t\t\tFiles:       files,\n\t\t\tIsDirCommon: isDirCommon,\n\t\t})\n\t}\n\n\tsort.Slice(groups, func(i, j int) bool {\n\t\treturn groups[i].DeletedAt.After(groups[j].DeletedAt)\n\t})\n\n\treturn groups\n}\n\nfunc (f *File) Delete() error {\n\tslog.Debug(\"removing .trashinfo\", \"trashInfoPath\", f.TrashInfoPath)\n\treturn os.Remove(f.TrashInfoPath)\n}\n"
  },
  {
    "path": "internal/tui/boolInputModel.go",
    "content": "package tui\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n)\n\ntype boolInputModel struct {\n\ttextInput textinput.Model\n\tconfirmed bool\n}\n\nfunc yesno(s string) (bool, string, error) {\n\tif s == \"\" {\n\t\treturn false, \"\", errors.New(\"empty\")\n\t}\n\tswitch strings.ToLower(s[0:1]) {\n\tcase \"y\":\n\t\treturn true, \"Yes\", nil\n\tcase \"n\":\n\t\treturn false, \"No\", nil\n\t}\n\treturn false, \"\", errors.New(\"unknown\")\n}\n\nfunc newBoolInputModel(prompt string) boolInputModel {\n\ttextInput := textinput.New()\n\ttextInput.Prompt = prompt\n\ttextInput.Placeholder = \"(Yes/No)\"\n\ttextInput.Validate = func(value string) error {\n\t\t_, _, err := yesno(value)\n\t\treturn err\n\t}\n\ttextInput.Focus()\n\treturn boolInputModel{\n\t\ttextInput: textInput,\n\t}\n}\n\nfunc (m boolInputModel) Confirmed() bool {\n\treturn m.confirmed\n}\n\nfunc (m boolInputModel) Init() tea.Cmd {\n\treturn textinput.Blink\n}\n\nfunc (m boolInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tif keyMsg, ok := msg.(tea.KeyMsg); ok {\n\t\tswitch keyMsg.Type {\n\t\tcase tea.KeyCtrlC, tea.KeyEsc:\n\t\t\tm.textInput.Blur()\n\t\t\treturn m, tea.Quit\n\t\t}\n\t}\n\tvar cmd tea.Cmd\n\tm.textInput, cmd = m.textInput.Update(msg)\n\tif _, value, err := yesno(m.textInput.Value()); err == nil {\n\t\tm.textInput.Blur()\n\t\tm.textInput.SetValue(value)\n\t\tm.confirmed = true\n\t\treturn m, tea.Quit\n\t}\n\treturn m, cmd\n}\n\nfunc (m boolInputModel) Value() bool {\n\tvalueStr := m.textInput.Value()\n\tv, _, _ := yesno(valueStr)\n\treturn v\n}\n\nfunc (m boolInputModel) View() string {\n\treturn m.textInput.View() + \"\\n\"\n}\n"
  },
  {
    "path": "internal/tui/choiceInputModel.go",
    "content": "package tui\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n)\n\ntype choiceInputModel struct {\n\ttextInput textinput.Model\n\tkeys      map[string]string\n\tconfirmed bool\n}\n\nfunc newChoiceInputModel(prompt string, choices []string) choiceInputModel {\n\ttextInput := textinput.New()\n\ttextInput.Prompt = prompt\n\n\tfor i := range choices {\n\t\tchoices[i] = strings.ToUpper(choices[i][:1]) + choices[i][1:]\n\t}\n\n\ttextInput.Placeholder = \"(\" + strings.Join(choices, \"/\") + \")\"\n\tkeys := make(map[string]string)\n\tfor _, choice := range choices {\n\t\tkeys[strings.ToLower(choice[0:1])] = choice\n\t}\n\ttextInput.Validate = func(s string) error {\n\t\tif s == \"\" {\n\t\t\treturn errors.New(\"empty\")\n\t\t}\n\t\tif _, ok := keys[strings.ToLower(s[0:1])]; ok {\n\t\t\treturn nil\n\t\t}\n\t\treturn errors.New(\"unknown\")\n\t}\n\ttextInput.Focus()\n\treturn choiceInputModel{\n\t\ttextInput: textInput,\n\t\tkeys:      keys,\n\t}\n}\n\nfunc (m choiceInputModel) Confirmed() bool {\n\treturn m.confirmed\n}\n\nfunc (m choiceInputModel) Init() tea.Cmd {\n\treturn textinput.Blink\n}\n\nfunc (m choiceInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tif keyMsg, ok := msg.(tea.KeyMsg); ok {\n\t\tswitch keyMsg.Type {\n\t\tcase tea.KeyCtrlC, tea.KeyEsc:\n\t\t\tm.textInput.Blur()\n\t\t\treturn m, tea.Quit\n\t\t}\n\t}\n\n\tvar cmd tea.Cmd\n\tm.textInput, cmd = m.textInput.Update(msg)\n\tif value, ok := m.keys[strings.ToLower(m.textInput.Value())]; ok {\n\t\tm.textInput.Blur()\n\t\tm.textInput.SetValue(value)\n\t\tm.confirmed = true\n\t\treturn m, tea.Quit\n\t}\n\treturn m, cmd\n}\n\nfunc (m choiceInputModel) Value() string {\n\tvalue := m.textInput.Value()\n\treturn strings.ToLower(value)\n}\n\nfunc (m choiceInputModel) View() string {\n\treturn m.textInput.View() + \"\\n\"\n}\n"
  },
  {
    "path": "internal/tui/multiRestore.go",
    "content": "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\"github.com/charmbracelet/bubbles/key\"\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/dustin/go-humanize\"\n\t\"github.com/umlx5h/gtrash/internal/posix\"\n\t\"github.com/umlx5h/gtrash/internal/trash\"\n\t\"github.com/umlx5h/gtrash/internal/tui/table\"\n\t\"golang.org/x/term\"\n)\n\nconst (\n\tpaddingHeight = 6\n\tshortWidth    = 90\n)\n\nvar notFocusBorderStyle = lipgloss.NewStyle().\n\tBorderStyle(lipgloss.NormalBorder()).\n\tBorderForeground(lipgloss.Color(\"240\"))\n\nvar focusBorderStyle = lipgloss.NewStyle().\n\tBorderStyle(lipgloss.NormalBorder()).\n\tBorderForeground(lipgloss.Color(\"70\"))\n\nvar greyStyle = lipgloss.NewStyle().\n\tForeground(lipgloss.Color(\"246\"))\n\nvar inputCursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(\"70\"))\n\nvar baseHelp = help.New()\n\n// '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.\nvar isKonsole bool\n\nvar (\n\tfocusRowStyle    = table.DefaultStyles()\n\tnotFocusRowStyle = table.DefaultStyles()\n)\n\nfunc init() {\n\tfocusRowStyle.Header = focusRowStyle.Header.\n\t\tBorderStyle(lipgloss.NormalBorder()).\n\t\tBorderForeground(lipgloss.Color(\"70\")).\n\t\tBorderBottom(true).\n\t\tBold(false)\n\tfocusRowStyle.Selected = focusRowStyle.Selected.\n\t\tForeground(lipgloss.Color(\"15\")).\n\t\tBackground(lipgloss.Color(\"240\")).\n\t\tBold(true)\n\n\tnotFocusRowStyle.Header = notFocusRowStyle.Header.\n\t\tBorderStyle(lipgloss.NormalBorder()).\n\t\tBorderForeground(lipgloss.Color(\"240\")).\n\t\tBorderBottom(true).\n\t\tBold(false)\n\tnotFocusRowStyle.Selected = notFocusRowStyle.Selected.\n\t\tForeground(lipgloss.Color(\"246\")).\n\t\tBackground(lipgloss.Color(\"240\")).\n\t\tBold(false)\n\n\t// help text color lighter\n\tbaseHelp.Styles.ShortKey = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{\n\t\tLight: \"#909090\",\n\t\t// Dark:  \"#626262\",\n\t\tDark: \"246\",\n\t})\n\tbaseHelp.Styles.ShortDesc = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{\n\t\tLight: \"#B2B2B2\",\n\t\t// Dark:  \"#4A4A4A\",\n\t\tDark: \"242\",\n\t})\n\tbaseHelp.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{\n\t\tLight: \"#DDDADA\",\n\t\t// Dark:  \"#3C3C3C\",\n\t\tDark: \"239\",\n\t})\n\tbaseHelp.Styles.Ellipsis = baseHelp.Styles.ShortSeparator.Copy()\n\tbaseHelp.Styles.FullKey = baseHelp.Styles.ShortKey.Copy()\n\tbaseHelp.Styles.FullDesc = baseHelp.Styles.ShortDesc.Copy()\n\tbaseHelp.Styles.FullSeparator = baseHelp.Styles.ShortSeparator.Copy()\n\n\tif _, ok := os.LookupEnv(\"KONSOLE_VERSION\"); ok {\n\t\tisKonsole = true\n\t}\n}\n\nvar baseKeymap = keymap{\n\tquit: key.NewBinding(\n\t\tkey.WithKeys(\"q\", \"ctrl+c\"),\n\t\tkey.WithHelp(\"q/CTRL-C\", \"quit\"),\n\t),\n\trunRestore: key.NewBinding(\n\t\tkey.WithKeys(\"enter\"),\n\t\tkey.WithHelp(\"Enter\", \"restore\"),\n\t),\n\tfilter: key.NewBinding(\n\t\tkey.WithKeys(\"/\"),\n\t\tkey.WithHelp(\"/\", \"filter\"),\n\t),\n\tclear: key.NewBinding(\n\t\tkey.WithKeys(\"esc\"),\n\t\tkey.WithHelp(\"ESC\", \"clear filter\"),\n\t),\n\tpageup: key.NewBinding(\n\t\tkey.WithKeys(\"u\", \"pgup\"),\n\t\tkey.WithHelp(\"u/PageUp\", \"page up\"),\n\t),\n\tpagedown: key.NewBinding(\n\t\tkey.WithKeys(\"d\", \"pgdn\"),\n\t\tkey.WithHelp(\"d/PageDown\", \"page down\"),\n\t),\n\ttop: key.NewBinding(\n\t\tkey.WithKeys(\"g\", \"home\"),\n\t\tkey.WithHelp(\"g/Home\", \"go to top\"),\n\t),\n\tbottom: key.NewBinding(\n\t\tkey.WithKeys(\"G\", \"end\"),\n\t\tkey.WithHelp(\"G/End\", \"go to bottom\"),\n\t),\n}\n\ntype keymap struct {\n\thelp, quit, focus, moveRight, moveLeft, runRestore, filter                                      key.Binding\n\tmove, moveRightALL, moveLeftALL, filterCWD, clear, pageup, pagedown, top, bottom, togglePreview key.Binding\n}\n\ntype filterTable struct {\n\ttitle string\n\n\tt     table.Model\n\tinput textinput.Model\n\n\thit, total, hitWidth int // updated when filtering\n}\n\nfunc (t *filterTable) getSelectedIdx() int {\n\tidx, err := strconv.Atoi(t.t.SelectedRow()[0])\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\treturn idx - 1\n}\n\nfunc (t *filterTable) updateInputPrompt(cwd bool) {\n\t// TODO: Set hit to \"-\" when no filter is applied\n\t// (to distinguish between unfiltered and all hits)\n\tif cwd {\n\t\tt.input.Prompt = fmt.Sprintf(\"%s (cwd) %*d/%d > \", t.title, t.hitWidth, t.hit, t.total)\n\t} else {\n\t\tt.input.Prompt = fmt.Sprintf(\"%s %*d/%d > \", t.title, t.hitWidth, t.hit, t.total)\n\t}\n}\n\nfunc (m *multiRestoreModel) updateHit() {\n\tm.trashTable.total = len(m.files) - len(m.selected)\n\tm.trashTable.hit = len(m.trashTable.t.Rows())\n\n\tm.trashTable.updateInputPrompt(m.filterCWD)\n\n\tm.restoreTable.total = len(m.selected)\n\tm.restoreTable.hit = len(m.restoreTable.t.Rows())\n\n\tm.restoreTable.updateInputPrompt(false)\n}\n\n// Convert from rows to an array of indices\nfunc (t *filterTable) getIndices() []int {\n\trows := t.t.Rows()\n\tif len(rows) == 0 {\n\t\treturn nil\n\t}\n\n\tindices := make([]int, len(rows))\n\tfor n, r := range rows {\n\t\tidx, err := strconv.Atoi(r[0])\n\t\tif err != nil {\n\t\t\tpanic(err)\n\t\t}\n\n\t\tindices[n] = idx - 1\n\t}\n\treturn indices\n}\n\nfunc (t filterTable) View(focus bool) string {\n\tvar body strings.Builder\n\n\tbody.WriteString(\" \" + t.input.View() + \"\\n\")\n\tif focus {\n\t\tbody.WriteString(focusBorderStyle.Render(t.t.View()))\n\t} else {\n\t\tbody.WriteString(notFocusBorderStyle.Render(t.t.View()))\n\t}\n\n\treturn body.String()\n}\n\nvar _ tea.Model = multiRestoreModel{}\n\ntype multiRestoreModel struct {\n\twidth       int\n\theight      int\n\tfixedWidth  int\n\ttableHeight int\n\n\twrapStyle lipgloss.Style\n\n\tkeymap keymap\n\thelp   help.Model\n\n\ttrashTable   *filterTable // left table\n\trestoreTable *filterTable // right table\n\n\tfiles []trash.File // table source\n\n\tselected    map[int]struct{} // selected the indices of files\n\trightFocus  bool             // focus to restoreTable\n\tshowHelp    bool\n\tshowPreview bool\n\n\tfilterCWD bool\n\tfilesCWD  map[int]struct{} // Specify the indices of files when filtered by cwd\n\n\tconfirmed    bool         // true when confirmed by pressing Enter\n\trestoreFiles []trash.File // return value\n}\n\nfunc (m *multiRestoreModel) getFocusTable() (focus *filterTable, notFocus *filterTable) {\n\tif !m.rightFocus {\n\t\treturn m.trashTable, m.restoreTable\n\t} else {\n\t\treturn m.restoreTable, m.trashTable\n\t}\n}\n\nfunc (m *multiRestoreModel) getRestoreFiles() []trash.File {\n\tif len(m.selected) == 0 {\n\t\treturn nil\n\t}\n\tfiles := make([]trash.File, len(m.selected))\n\n\tindices := make([]int, len(m.selected))\n\tvar i int\n\tfor idx := range m.selected {\n\t\tindices[i] = idx\n\t\ti++\n\t}\n\tsort.Slice(indices, func(i, j int) bool {\n\t\treturn indices[i] < indices[j]\n\t})\n\n\tfor i, idx := range indices {\n\t\tfiles[i] = m.files[idx]\n\t}\n\n\treturn files\n}\n\nfunc makeFileRow(idx int, f trash.File) table.Row {\n\treturn []string{\n\t\tstrconv.Itoa(idx + 1),\n\t\thumanize.Time(f.DeletedAt),\n\t\t// Prevent color display problems with table records\n\t\tstrings.TrimSuffix(f.OriginalPathFormat(true, true), \"\\033[0m\"),\n\t}\n}\n\nfunc getTermSize() (width int, height int) {\n\tw, h, err := term.GetSize(int(os.Stdout.Fd()))\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\treturn w, h\n}\n\nfunc makeFilterTables(files []trash.File) (left, right filterTable, fixedWidth, tableHeight int) {\n\twidth, height := getTermSize()\n\n\tvar (\n\t\tdateWidth int\n\t\tnoWidth   = len(strconv.Itoa(len(files)))\n\t)\n\tif noWidth <= 1 {\n\t\tnoWidth = 2\n\t}\n\tif isKonsole {\n\t\tnoWidth += 1\n\t}\n\n\trows := make([]table.Row, len(files))\n\tfor i, f := range files {\n\t\tr := makeFileRow(i, f)\n\n\t\t// Only ASCII characters are used, so it should match the character length\n\t\tw := len(r[1])\n\t\tif w > dateWidth {\n\t\t\tdateWidth = w\n\t\t}\n\t\trows[i] = r\n\t}\n\n\tpaddingWidth := 4 * 2 // (columns + 1) * 2\n\n\tfixedWidth = noWidth + dateWidth + paddingWidth\n\n\t// make table shorter\n\tif isKonsole {\n\t\tfixedWidth += 4\n\t}\n\tpathWidth := (width / 2) - fixedWidth\n\n\t// Must be separate instances to prevent data race\n\tgetColumn := func() []table.Column {\n\t\tcolumns := []table.Column{\n\t\t\t{Title: \"No\", Width: noWidth},\n\t\t\t{Title: \"DeletedAt\", Width: dateWidth},\n\t\t\t{Title: \"Path\", Width: pathWidth},\n\t\t}\n\t\treturn columns\n\t}\n\n\ttableHeight = int(float64(height)*0.55) - paddingHeight\n\n\tleftInput := textinput.New()\n\tleftInput.PromptStyle = greyStyle\n\tleftInput.Cursor.Style = inputCursorStyle\n\n\tleft = filterTable{\n\t\ttitle:    \"Trash\",\n\t\ttotal:    len(rows),\n\t\thit:      len(rows),\n\t\thitWidth: len(strconv.Itoa(len(rows))),\n\n\t\tt: table.New(\n\t\t\ttable.WithColumns(getColumn()),\n\t\t\ttable.WithHeight(tableHeight),\n\t\t\ttable.WithRows(rows),\n\t\t\ttable.WithFocused(true),\n\t\t\ttable.WithStyles(focusRowStyle),\n\t\t\ttable.WithShortColumn(1, 2),\n\t\t),\n\t\tinput: leftInput,\n\t}\n\tleft.updateInputPrompt(false)\n\n\trightInput := textinput.New()\n\trightInput.PromptStyle = greyStyle\n\trightInput.Cursor.Style = inputCursorStyle\n\n\tright = filterTable{\n\t\ttitle: \"Restore\",\n\n\t\thit:      0,\n\t\ttotal:    0,\n\t\thitWidth: left.hitWidth,\n\n\t\tt: table.New(\n\t\t\ttable.WithColumns(getColumn()),\n\t\t\ttable.WithHeight(tableHeight),\n\t\t\ttable.WithStyles(notFocusRowStyle),\n\t\t\ttable.WithShortColumn(1, 2),\n\t\t),\n\t\tinput: rightInput,\n\t}\n\tright.t.SetShortMode(true)\n\tright.updateInputPrompt(false)\n\n\treturn left, right, fixedWidth, tableHeight\n}\n\nfunc newMultiRestoreModel(files []trash.File) multiRestoreModel {\n\ttrashTable, restoreTable, fixedWidth, tableHeight := makeFilterTables(files)\n\twidth, height := getTermSize()\n\n\th := baseHelp\n\n\tkm := baseKeymap\n\tkm.help = key.NewBinding(\n\t\tkey.WithKeys(\"?\"),\n\t\tkey.WithHelp(\"?\", \"help\"),\n\t)\n\tkm.focus = key.NewBinding(\n\t\tkey.WithKeys(\"tab\"),\n\t\tkey.WithHelp(\"TAB\", \"focus\"),\n\t)\n\tkm.moveRight = key.NewBinding(\n\t\tkey.WithKeys(\"l\", \"right\"),\n\t\tkey.WithHelp(\"l/→\", \"move right\"),\n\t)\n\tkm.moveLeft = key.NewBinding(\n\t\tkey.WithKeys(\"h\", \"left\"),\n\t\tkey.WithHelp(\"h/←\", \"move left\"),\n\t)\n\tkm.moveRightALL = key.NewBinding(\n\t\tkey.WithKeys(\"L\"),\n\t\tkey.WithHelp(\"L\", \"move right all\"),\n\t)\n\tkm.moveLeftALL = key.NewBinding(\n\t\tkey.WithKeys(\"H\"),\n\t\tkey.WithHelp(\"H\", \"move left all\"),\n\t)\n\tkm.move = key.NewBinding(\n\t\tkey.WithKeys(\" \"),\n\t\tkey.WithHelp(\"Space\", \"move other side\"),\n\t)\n\tkm.filterCWD = key.NewBinding(\n\t\tkey.WithKeys(\"c\"),\n\t\tkey.WithHelp(\"c\", \"filter by cwd\"),\n\t)\n\tkm.togglePreview = key.NewBinding(\n\t\tkey.WithKeys(\"p\"),\n\t\tkey.WithHelp(\"p\", \"toggle preview\"),\n\t)\n\n\tm := multiRestoreModel{\n\t\ttrashTable:   &trashTable,\n\t\trestoreTable: &restoreTable,\n\n\t\twidth:       width,\n\t\theight:      height,\n\t\tfixedWidth:  fixedWidth,\n\t\ttableHeight: tableHeight,\n\n\t\twrapStyle: lipgloss.NewStyle().Width(width).Height(height).MaxWidth(width).MaxHeight(height),\n\n\t\tshowPreview: true,\n\n\t\tfiles:    files,\n\t\thelp:     h,\n\t\tselected: make(map[int]struct{}),\n\t\tkeymap:   km,\n\t}\n\n\treturn m\n}\n\nfunc (m *multiRestoreModel) updateScreenSize() {\n\tm.wrapStyle = m.wrapStyle.Width(m.width).Height(m.height).MaxWidth(m.width).MaxHeight(m.height)\n\tif m.width < shortWidth {\n\t\tm.trashTable.t.SetShortMode(true)\n\t} else {\n\t\tm.trashTable.t.SetShortMode(false)\n\t}\n\t// symmetry\n\t// newWidth := m.width/2 - m.fixedWidth\n\t// m.trashTable.t.SetColWidthLast(newWidth)\n\t// m.restoreTable.t.SetColWidthLast(newWidth)\n\n\t// Make the table on the left a little larger.\n\tm.trashTable.t.SetColWidthLast(int(float64(m.width)*0.6) - m.fixedWidth)\n\tm.restoreTable.t.SetColWidthLast(int(float64(m.width)*0.4) - m.fixedWidth)\n\n\tnewHeight := int(float64(m.height)*0.55 - paddingHeight)\n\tif newHeight < 1 {\n\t\tnewHeight = 0\n\t}\n\tm.trashTable.t.SetHeight(newHeight)\n\tm.restoreTable.t.SetHeight(newHeight)\n\n\tm.tableHeight = newHeight\n}\n\nfunc (m multiRestoreModel) Init() tea.Cmd {\n\treturn nil\n}\n\nfunc (m multiRestoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tvar cmd tea.Cmd\n\n\tft, nft := m.getFocusTable()\n\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\t// when focused to table\n\t\tif ft.t.Focused() {\n\t\t\tswitch {\n\t\t\tcase key.Matches(msg, m.keymap.focus):\n\t\t\t\tft.t.Blur()\n\t\t\t\tft.t.SetStyles(notFocusRowStyle)\n\n\t\t\t\tnft.t.Focus()\n\t\t\t\tnft.t.SetStyles(focusRowStyle)\n\t\t\t\tm.rightFocus = !m.rightFocus\n\n\t\t\tcase key.Matches(msg, m.keymap.moveRight):\n\t\t\t\tif !m.rightFocus {\n\t\t\t\t\tm.moveRow()\n\t\t\t\t}\n\t\t\tcase key.Matches(msg, m.keymap.moveLeft):\n\t\t\t\tif m.rightFocus {\n\t\t\t\t\tm.moveRow()\n\t\t\t\t}\n\t\t\tcase key.Matches(msg, m.keymap.move):\n\t\t\t\tm.moveRow()\n\t\t\tcase key.Matches(msg, m.keymap.moveRightALL):\n\t\t\t\tif !m.rightFocus {\n\t\t\t\t\tm.moveRowALL()\n\t\t\t\t}\n\t\t\tcase key.Matches(msg, m.keymap.moveLeftALL):\n\t\t\t\tif m.rightFocus {\n\t\t\t\t\tm.moveRowALL()\n\t\t\t\t}\n\t\t\tcase key.Matches(msg, m.keymap.quit):\n\t\t\t\treturn m, tea.Quit\n\t\t\tcase key.Matches(msg, m.keymap.filter):\n\t\t\t\tft.t.Blur()\n\t\t\t\tft.input.Focus()\n\t\t\t\treturn m, nil\n\t\t\tcase key.Matches(msg, m.keymap.clear):\n\t\t\t\tif ft.input.Value() != \"\" {\n\t\t\t\t\tft.input.Reset()\n\t\t\t\t\tm.filterApply()\n\t\t\t\t}\n\t\t\t\treturn m, nil\n\t\t\tcase key.Matches(msg, m.keymap.help):\n\t\t\t\tm.showHelp = !m.showHelp\n\t\t\t\treturn m, nil\n\t\t\tcase key.Matches(msg, m.keymap.togglePreview):\n\t\t\t\tm.showPreview = !m.showPreview\n\t\t\t\treturn m, nil\n\t\t\tcase key.Matches(msg, m.keymap.filterCWD):\n\t\t\t\t// only used in left table\n\t\t\t\tif m.rightFocus {\n\t\t\t\t\treturn m, nil\n\t\t\t\t}\n\t\t\t\tm.filterCWD = !m.filterCWD\n\n\t\t\t\tif m.filterCWD && m.filesCWD == nil {\n\t\t\t\t\t// Filter by cwd the first time it is called and cache the results\n\t\t\t\t\tcwd, err := os.Getwd()\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\treturn m, nil\n\t\t\t\t\t}\n\n\t\t\t\t\tfilesCWD := make(map[int]struct{})\n\t\t\t\t\tfor i, f := range m.files {\n\t\t\t\t\t\tsubpath, _ := posix.CheckSubPath(cwd, f.OriginalPath)\n\t\t\t\t\t\tif !subpath {\n\t\t\t\t\t\t\tcontinue\n\t\t\t\t\t\t}\n\t\t\t\t\t\tfilesCWD[i] = struct{}{}\n\t\t\t\t\t}\n\t\t\t\t\tm.filesCWD = filesCWD\n\t\t\t\t}\n\n\t\t\t\tm.trashTable.input.Reset()\n\t\t\t\tif m.filterCWD {\n\t\t\t\t\tm.trashTable.t.SetColumnNameLast(\"Path (cwd)\")\n\t\t\t\t} else {\n\t\t\t\t\tm.trashTable.t.SetColumnNameLast(\"Path\")\n\t\t\t\t}\n\n\t\t\t\tm.filterApply()\n\n\t\t\t\treturn m, nil\n\t\t\tcase key.Matches(msg, m.keymap.runRestore):\n\t\t\t\tfiles := m.getRestoreFiles()\n\t\t\t\t// If the file to be restored is not selected, nothing is done.\n\t\t\t\tif len(files) == 0 {\n\t\t\t\t\treturn m, nil\n\t\t\t\t}\n\n\t\t\t\tm.confirmed = true\n\t\t\t\tm.restoreFiles = files\n\n\t\t\t\treturn m, tea.Quit\n\t\t\t}\n\n\t\t\tft.t, cmd = ft.t.Update(msg)\n\t\t\treturn m, cmd\n\n\t\t} else if ft.input.Focused() {\n\t\t\t// when focused to filter textinput\n\t\t\tswitch msg.String() {\n\t\t\tcase \"enter\", \"esc\":\n\t\t\t\tft.input.Blur()\n\t\t\t\tft.t.Focus()\n\t\t\tcase \"ctrl+c\":\n\t\t\t\tft.input.Blur()\n\t\t\t\tft.t.Focus()\n\t\t\t\treturn m, nil\n\t\t\t}\n\t\t\tft.input, cmd = ft.input.Update(msg)\n\n\t\t\t// Reflecting filter to the table\n\t\t\tm.filterApply()\n\n\t\t\treturn m, cmd\n\t\t}\n\tcase tea.WindowSizeMsg:\n\t\tm.width = msg.Width\n\t\tm.height = msg.Height\n\t\tm.updateScreenSize()\n\t}\n\n\t// ft.t, cmd = ft.t.Update(msg)\n\t// return m, cmd\n\treturn m, nil\n}\n\nfunc deleteRow(rows []table.Row, cursor int) []table.Row {\n\treturn rows[:cursor+copy(rows[cursor:], rows[cursor+1:])]\n}\n\nfunc addRows(rows []table.Row, adds []table.Row) []table.Row {\n\tif len(rows) == 0 {\n\t\treturn adds\n\t}\n\n\t// TODO: perf\n\trows = append(rows, adds...)\n\tsort.Slice(rows, func(i, j int) bool {\n\t\ti, _ = strconv.Atoi(rows[i][0])\n\t\tj, _ = strconv.Atoi(rows[j][0])\n\n\t\treturn i < j\n\t})\n\n\treturn rows\n}\n\nfunc addRow(rows []table.Row, add table.Row) []table.Row {\n\tif len(rows) == 0 {\n\t\treturn []table.Row{add}\n\t}\n\t// TODO: perf\n\trows = append(rows, add)\n\tsort.Slice(rows, func(i, j int) bool {\n\t\ti, _ = strconv.Atoi(rows[i][0])\n\t\tj, _ = strconv.Atoi(rows[j][0])\n\n\t\treturn i < j\n\t})\n\n\treturn rows\n}\n\nfunc (m *multiRestoreModel) moveRow() {\n\tfrom := m.trashTable\n\tto := m.restoreTable\n\n\tif m.rightFocus {\n\t\tfrom = m.restoreTable\n\t\tto = m.trashTable\n\t}\n\n\tif from.t.SelectedRow() == nil {\n\t\treturn\n\t}\n\n\t// apply to selected\n\tidx := from.getSelectedIdx()\n\tif !m.rightFocus {\n\t\tm.selected[idx] = struct{}{}\n\t} else {\n\t\tdelete(m.selected, idx)\n\t}\n\n\t// delete row from focus table\n\trows := from.t.Rows()\n\tselectedRow := from.t.SelectedRow()\n\tcursor := from.t.Cursor()\n\tif len(rows) >= 2 && len(rows) == cursor+1 {\n\t\t// When the last line is selected, shift the focus up one line\n\t\tfrom.t.SetCursor(cursor - 1)\n\t}\n\n\trows = deleteRow(rows, cursor)\n\tfrom.t.SetRows(rows)\n\n\t// add to other side table if filter matches\n\tif to.input.Value() == \"\" || findMatch(selectedRow[len(selectedRow)-1], to.input.Value()) {\n\t\trows = to.t.Rows()\n\t\trows = addRow(rows, selectedRow)\n\n\t\tto.t.SetRows(rows)\n\t}\n\tm.updateHit()\n}\n\nfunc (m *multiRestoreModel) moveRowALL() {\n\tfrom := m.trashTable\n\tto := m.restoreTable\n\n\tif m.rightFocus {\n\t\tfrom = m.restoreTable\n\t\tto = m.trashTable\n\t}\n\n\tif len(from.t.Rows()) == 0 {\n\t\treturn\n\t}\n\n\t// apply to selected\n\tfor _, idx := range from.getIndices() {\n\t\tif !m.rightFocus {\n\t\t\tm.selected[idx] = struct{}{}\n\t\t} else {\n\t\t\tdelete(m.selected, idx)\n\t\t}\n\t}\n\n\t// delete all rows from focus table\n\trows := from.t.Rows()\n\tfrom.t.SetCursor(0)\n\tfrom.t.SetRows(nil)\n\n\t// add to other side table if filter matches\n\tif to.input.Value() == \"\" { // if filter not used, append all\n\t\trows = addRows(to.t.Rows(), rows)\n\t\tto.t.SetRows(rows)\n\t} else {\n\t\tfilterRows := make([]table.Row, 0)\n\n\t\tfor _, r := range rows {\n\t\t\tif findMatch(r[len(r)-1], to.input.Value()) {\n\t\t\t\tfilterRows = append(filterRows, r)\n\t\t\t}\n\t\t}\n\n\t\trows = addRows(to.t.Rows(), filterRows)\n\t\tto.t.SetRows(rows)\n\t}\n\n\tm.updateHit()\n}\n\nfunc (m *multiRestoreModel) filterApply() {\n\tft, _ := m.getFocusTable()\n\n\t// Move the cursor to the top as the record changes\n\tft.t.GotoTop()\n\n\tvar rows []table.Row\n\tfor i, f := range m.files {\n\t\t// Exclude already selected rows from filtering\n\t\tif !m.rightFocus {\n\t\t\tif _, ok := m.selected[i]; ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\t// Apply cwd filtering\n\t\t\tif m.filterCWD {\n\t\t\t\tif _, ok := m.filesCWD[i]; !ok {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t}\n\n\t\t} else {\n\t\t\tif _, ok := m.selected[i]; !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tif ft.input.Value() == \"\" || findMatch(f.OriginalPath, ft.input.Value()) {\n\t\t\trows = append(rows, makeFileRow(i, f))\n\t\t}\n\t}\n\n\tft.t.SetRows(rows)\n\tm.updateHit()\n}\n\nfunc findMatch(text, pattern string) bool {\n\treturn strings.Contains(strings.ToLower(text), strings.ToLower(pattern))\n}\n\nfunc (m multiRestoreModel) View() string {\n\tvar body strings.Builder\n\n\tbody.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, m.trashTable.View(!m.rightFocus), m.restoreTable.View(m.rightFocus)))\n\n\tif m.showHelp {\n\t\thelp := m.help.FullHelpView([][]key.Binding{\n\t\t\t{\n\t\t\t\tm.keymap.moveRight,\n\t\t\t\tm.keymap.moveLeft,\n\t\t\t\tm.keymap.move,\n\t\t\t\tm.keymap.moveRightALL,\n\t\t\t\tm.keymap.moveLeftALL,\n\t\t\t},\n\t\t\t{\n\t\t\t\tm.keymap.focus,\n\t\t\t\tm.keymap.filter,\n\t\t\t\tm.keymap.filterCWD,\n\t\t\t\tm.keymap.clear,\n\t\t\t\tm.keymap.togglePreview,\n\t\t\t},\n\t\t\t{\n\t\t\t\tm.keymap.pageup,\n\t\t\t\tm.keymap.runRestore,\n\t\t\t\tm.keymap.pagedown,\n\t\t\t\tm.keymap.top,\n\t\t\t\tm.keymap.bottom,\n\t\t\t},\n\t\t\t{\n\t\t\t\tm.keymap.quit,\n\t\t\t\tm.keymap.help,\n\t\t\t},\n\t\t})\n\t\tbody.WriteString(\"\\n\" + help)\n\t} else {\n\t\thelp := m.help.ShortHelpView([]key.Binding{\n\t\t\tm.keymap.help,\n\t\t\tm.keymap.quit,\n\t\t\tm.keymap.focus,\n\t\t\tm.keymap.moveRight,\n\t\t\tm.keymap.moveLeft,\n\t\t\tm.keymap.runRestore,\n\t\t\tm.keymap.filter,\n\t\t})\n\t\tbody.WriteString(\"\\n\" + help)\n\n\t\tbody.WriteString(\"\\n\" + m.viewMetadata())\n\t}\n\n\t// Use wrapStyle to prevent buggy table display when moving the screen width repeatedly.\n\treturn m.wrapStyle.Render(body.String())\n}\n\nfunc (m multiRestoreModel) viewMetadata() string {\n\tft, _ := m.getFocusTable()\n\n\tvar body strings.Builder\n\n\tif ft.t.SelectedRow() == nil {\n\t\treturn \"\"\n\t}\n\n\tf := m.files[ft.getSelectedIdx()]\n\n\tbody.WriteString(greyStyle.Render(\"FileName:        \") + f.Name + \"\\n\")\n\tbody.WriteString(greyStyle.Render(\"OriginalPath:    \") + f.OriginalPathFormat(false, true) + \"\\n\")\n\tbody.WriteString(greyStyle.Render(\"TrashPath:       \") + f.TrashPathColor() + \"\\n\")\n\tbody.WriteString(greyStyle.Render(\"DeletedAt:       \") + fmt.Sprintf(\"%s (%s)\", f.DeletedAt.Format(time.DateTime), ft.t.SelectedRow()[1]) + \"\\n\")\n\n\tif m.showPreview {\n\t\tbody.WriteString(greyStyle.Render(\"Preview:         \") + posix.FileHead(f.TrashPath, m.width, m.height-m.tableHeight-paddingHeight-6))\n\t}\n\n\treturn body.String()\n}\n"
  },
  {
    "path": "internal/tui/singleRestore.go",
    "content": "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/charmbracelet/bubbles/key\"\n\t\"github.com/charmbracelet/bubbles/textinput\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/dustin/go-humanize\"\n\t\"github.com/umlx5h/gtrash/internal/posix\"\n\t\"github.com/umlx5h/gtrash/internal/trash\"\n\t\"github.com/umlx5h/gtrash/internal/tui/table\"\n)\n\nvar _ tea.Model = singleRestoreModel{}\n\nvar baseStyle = lipgloss.NewStyle().\n\tBorderStyle(lipgloss.NormalBorder()).\n\tBorderForeground(lipgloss.Color(\"240\"))\n\ntype singleRestoreModel struct {\n\twidth       int\n\theight      int\n\tfixedWidth  int\n\ttableHeight int\n\n\twrapStyle lipgloss.Style\n\n\ttable table.Model\n\tinput textinput.Model // filter\n\n\tgroups []trash.Group // data source\n\n\tkeymap keymap\n\thelp   help.Model\n\n\tconfirmed bool // true when confirmed by pressing Enter\n\tselected  int  // groups index,\n\n\thit, total, hitWidth int\n}\n\nfunc makeGroupRow(idx int, g trash.Group) table.Row {\n\treturn []string{\n\t\tstrconv.Itoa(idx + 1),\n\t\thumanize.Time(g.DeletedAt),\n\t\tstrconv.Itoa(len(g.Files)),\n\t\tposix.AbsPathToTilde(g.Dir),\n\t}\n}\n\nfunc newSingleRestoreModel(groups []trash.Group) singleRestoreModel {\n\twidth, height := getTermSize()\n\n\tvar (\n\t\tnoWidth    = len(strconv.Itoa(len(groups)))\n\t\tdateWidth  int\n\t\tfilesWidth int\n\t)\n\tif noWidth <= 1 {\n\t\tnoWidth = 2\n\t}\n\tif isKonsole {\n\t\tnoWidth += 1\n\t}\n\n\trows := make([]table.Row, len(groups))\n\tfor i, g := range groups {\n\t\tr := makeGroupRow(i, g)\n\n\t\t// Only ASCII characters are used, so it should match the character length\n\t\tw := len(r[1])\n\t\tif w > dateWidth {\n\t\t\tdateWidth = w\n\t\t}\n\t\tw = len(r[2])\n\t\tif w > filesWidth {\n\t\t\tfilesWidth = w\n\t\t}\n\n\t\trows[i] = r\n\t}\n\tfilesWidth = max(filesWidth, len(\"Files\"))\n\n\tpaddingWidth := 5 * 2 // (columns + 1) * 2\n\n\tfixedWidth := noWidth + dateWidth + filesWidth + paddingWidth\n\t// make table shorter\n\tif isKonsole {\n\t\tfixedWidth += 6\n\t}\n\n\tpathWidth := width - fixedWidth\n\n\tcolumns := []table.Column{\n\t\t{Title: \"No\", Width: noWidth},\n\t\t{Title: \"DeletedAt\", Width: dateWidth},\n\t\t{Title: \"Files\", Width: filesWidth},\n\t\t{Title: \"RestoreDir\", Width: pathWidth},\n\t}\n\n\ttableHeight := int(float64(height)*0.55) - paddingHeight\n\n\tt := table.New(\n\t\ttable.WithColumns(columns),\n\t\ttable.WithRows(rows),\n\t\ttable.WithFocused(true),\n\t\ttable.WithHeight(tableHeight),\n\t)\n\n\ts := table.DefaultStyles()\n\ts.Header = s.Header.\n\t\tBorderStyle(lipgloss.NormalBorder()).\n\t\tBorderForeground(lipgloss.Color(\"240\")).\n\t\tBorderBottom(true).\n\t\tBold(false)\n\ts.Selected = s.Selected.\n\t\tForeground(lipgloss.Color(\"15\")).\n\t\tBackground(lipgloss.Color(\"240\")).\n\t\tBold(true)\n\tt.SetStyles(s)\n\n\ti := textinput.New()\n\ti.PromptStyle = greyStyle\n\ti.Cursor.Style = inputCursorStyle\n\n\th := baseHelp\n\tkm := baseKeymap\n\n\tm := singleRestoreModel{\n\t\twidth:       width,\n\t\theight:      height,\n\t\tfixedWidth:  fixedWidth,\n\t\ttableHeight: tableHeight,\n\n\t\twrapStyle: lipgloss.NewStyle().Width(width).Height(height).MaxWidth(width).MaxHeight(height),\n\n\t\ttable:  t,\n\t\tinput:  i,\n\t\tgroups: groups,\n\n\t\tkeymap: km,\n\t\thelp:   h,\n\n\t\ttotal:    len(rows),\n\t\thit:      len(rows),\n\t\thitWidth: len(strconv.Itoa(len(rows))),\n\t}\n\n\tm.updateInputPrompt()\n\n\treturn m\n}\n\nfunc (m singleRestoreModel) Init() tea.Cmd {\n\treturn nil\n}\n\nfunc (m singleRestoreModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {\n\tvar cmd tea.Cmd\n\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\t// when focused to table\n\t\tif m.table.Focused() {\n\t\t\tswitch {\n\t\t\tcase key.Matches(msg, m.keymap.quit):\n\t\t\t\treturn m, tea.Quit\n\t\t\tcase key.Matches(msg, m.keymap.filter):\n\t\t\t\tm.table.Blur()\n\t\t\t\tm.input.Focus()\n\t\t\t\treturn m, nil\n\t\t\tcase key.Matches(msg, m.keymap.clear):\n\t\t\t\tif m.input.Value() != \"\" {\n\t\t\t\t\tm.input.Reset()\n\t\t\t\t\tm.filterApply()\n\t\t\t\t}\n\t\t\t\treturn m, nil\n\t\t\tcase key.Matches(msg, m.keymap.runRestore):\n\t\t\t\tselected := m.table.SelectedRow()\n\t\t\t\tif selected == nil {\n\t\t\t\t\treturn m, nil\n\t\t\t\t}\n\n\t\t\t\tidx, err := strconv.Atoi(selected[0])\n\t\t\t\tif err != nil {\n\t\t\t\t\tpanic(err)\n\t\t\t\t}\n\n\t\t\t\tm.confirmed = true\n\t\t\t\tm.selected = idx - 1\n\n\t\t\t\treturn m, tea.Quit\n\t\t\t}\n\n\t\t\tm.table, cmd = m.table.Update(msg)\n\t\t\treturn m, cmd\n\t\t} else if m.input.Focused() {\n\t\t\t// when focused to filter textinput\n\t\t\tswitch msg.String() {\n\t\t\tcase \"enter\", \"esc\":\n\t\t\t\tm.input.Blur()\n\t\t\t\tm.table.Focus()\n\t\t\tcase \"ctrl+c\":\n\t\t\t\tm.input.Blur()\n\t\t\t\tm.table.Focus()\n\t\t\t\treturn m, nil\n\t\t\t}\n\t\t\tm.input, cmd = m.input.Update(msg)\n\t\t\t// Reflecting filter to the table\n\t\t\tm.filterApply()\n\n\t\t\treturn m, cmd\n\t\t}\n\tcase tea.WindowSizeMsg:\n\t\tm.width = msg.Width\n\t\tm.height = msg.Height\n\t\tm.updateScreenSize()\n\t}\n\n\treturn m, nil\n}\n\nfunc (m *singleRestoreModel) updateScreenSize() {\n\tm.wrapStyle = m.wrapStyle.Width(m.width).Height(m.height).MaxWidth(m.width).MaxHeight(m.height)\n\tm.table.SetColWidthLast(m.width - m.fixedWidth)\n\n\tnewHeight := int(float64(m.height)*0.55 - paddingHeight)\n\tif newHeight < 1 {\n\t\tnewHeight = 0\n\t}\n\tm.table.SetHeight(newHeight)\n\tm.tableHeight = newHeight\n}\n\nfunc (m *singleRestoreModel) updateInputPrompt() {\n\tm.input.Prompt = fmt.Sprintf(\"Trash Group %*d/%d > \", m.hitWidth, m.hit, m.total)\n\n}\n\nfunc (m *singleRestoreModel) updateHit() {\n\tm.total = len(m.groups)\n\tm.hit = len(m.table.Rows())\n\n\tm.updateInputPrompt()\n}\n\nfunc (m *singleRestoreModel) filterApply() {\n\t// Move the cursor to the top as the record changes\n\tm.table.GotoTop()\n\n\tvar rows []table.Row\n\tfor i, g := range m.groups {\n\t\tfor _, f := range g.Files {\n\t\t\t// search by original path\n\t\t\tif m.input.Value() == \"\" || findMatch(f.OriginalPath, m.input.Value()) {\n\n\t\t\t\trows = append(rows, makeGroupRow(i, g))\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tm.table.SetRows(rows)\n\tm.updateHit()\n}\n\nfunc (m singleRestoreModel) View() string {\n\tvar body strings.Builder\n\n\tbody.WriteString(\" \" + m.input.View() + \"\\n\")\n\tbody.WriteString(baseStyle.Render(m.table.View()) + \"\\n\")\n\n\thelp := m.help.ShortHelpView([]key.Binding{\n\t\tm.keymap.quit,\n\t\tm.keymap.runRestore,\n\t\tm.keymap.filter,\n\t\tm.keymap.clear,\n\t\tm.keymap.pageup,\n\t\tm.keymap.pagedown,\n\t})\n\tbody.WriteString(help + \"\\n\")\n\n\tbody.WriteString(m.viewMetadata())\n\n\t// Use wrapStyle to prevent buggy table display when moving the screen width repeatedly.\n\treturn m.wrapStyle.Render(body.String())\n}\n\nfunc (m singleRestoreModel) viewMetadata() string {\n\tvar body strings.Builder\n\n\trow := m.table.SelectedRow()\n\n\tif row == nil {\n\t\treturn \"\"\n\t}\n\n\tselected, _ := strconv.Atoi(row[0])\n\tg := m.groups[selected-1]\n\n\tbody.WriteString(greyStyle.Render(\"DeletedAt:          \") + fmt.Sprintf(\"%s (%s)\", g.DeletedAt.Format(time.DateTime), row[1]) + \"\\n\")\n\tbody.WriteString(greyStyle.Render(\"RestoreDir:         \") + g.Dir + \"\\n\")\n\tbody.WriteString(greyStyle.Render(\"Number of Files:    \") + row[2] + \"\\n\")\n\tbody.WriteString(greyStyle.Render(\"Files:\") + \"\\n\")\n\n\t// TODO: make scrollable\n\tfor i, f := range g.Files {\n\t\t// TODO: calculation correct?\n\t\tif i > m.height-m.tableHeight-11 {\n\t\t\tbreak\n\t\t}\n\t\tbody.WriteString(\"  - \" + f.OriginalPathFormat(false, true) + \"\\n\")\n\t}\n\n\treturn body.String()\n}\n"
  },
  {
    "path": "internal/tui/table/table.go",
    "content": "package table\n\nimport (\n\t\"strings\"\n\n\t\"github.com/charmbracelet/bubbles/key\"\n\t\"github.com/charmbracelet/bubbles/viewport\"\n\ttea \"github.com/charmbracelet/bubbletea\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/umlx5h/go-runewidth\"\n)\n\n// Forked from https://github.com/charmbracelet/bubbles/blob/f36aa3c4b5369f2ecefb4e35dbb2c924906932ca/table/table.go\n// Copyright (c) 2020-2023 Charmbracelet, Inc\n// https://github.com/charmbracelet/bubbles/blob/f36aa3c4b5369f2ecefb4e35dbb2c924906932ca/LICENSE\n\n// MIT License\n//\n// Copyright (c) 2020-2023 Charmbracelet, Inc\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\n// Model defines a state for the table widget.\ntype Model struct {\n\tKeyMap KeyMap\n\n\tcols   []Column\n\trows   []Row\n\tcursor int\n\tfocus  bool\n\tstyles Styles\n\n\t// If true, hide columns in shortColIdx and add width to shortAppendColIdx\n\tshortMode         bool\n\tshortColIdx       int\n\tshortAppendColIdx int\n\n\tviewport viewport.Model\n\tstart    int\n\tend      int\n}\n\n// Row represents one line in the table.\ntype Row []string\n\n// Column defines the table structure.\ntype Column struct {\n\tTitle string\n\tWidth int\n}\n\n// KeyMap defines keybindings. It satisfies to the help.KeyMap interface, which\n// is used to render the menu.\ntype KeyMap struct {\n\tLineUp       key.Binding\n\tLineDown     key.Binding\n\tPageUp       key.Binding\n\tPageDown     key.Binding\n\tHalfPageUp   key.Binding\n\tHalfPageDown key.Binding\n\tGotoTop      key.Binding\n\tGotoBottom   key.Binding\n}\n\n// DefaultKeyMap returns a default set of keybindings.\nfunc DefaultKeyMap() KeyMap {\n\t// const spacebar = \" \"\n\treturn KeyMap{\n\t\tLineUp: key.NewBinding(\n\t\t\tkey.WithKeys(\"up\", \"k\"),\n\t\t\tkey.WithHelp(\"↑/k\", \"up\"),\n\t\t),\n\t\tLineDown: key.NewBinding(\n\t\t\tkey.WithKeys(\"down\", \"j\"),\n\t\t\tkey.WithHelp(\"↓/j\", \"down\"),\n\t\t),\n\t\tPageUp: key.NewBinding(\n\t\t\tkey.WithKeys(\"pgup\", \"ctrl+b\"),\n\t\t\tkey.WithHelp(\"pgup\", \"page up\"),\n\t\t),\n\t\tPageDown: key.NewBinding(\n\t\t\tkey.WithKeys(\"pgdown\", \"ctrl+f\"),\n\t\t\tkey.WithHelp(\"pgdn\", \"page down\"),\n\t\t),\n\t\tHalfPageUp: key.NewBinding(\n\t\t\tkey.WithKeys(\"u\", \"ctrl+u\"),\n\t\t\tkey.WithHelp(\"u\", \"½ page up\"),\n\t\t),\n\t\tHalfPageDown: key.NewBinding(\n\t\t\tkey.WithKeys(\"d\", \"ctrl+d\"),\n\t\t\tkey.WithHelp(\"d\", \"½ page down\"),\n\t\t),\n\t\tGotoTop: key.NewBinding(\n\t\t\tkey.WithKeys(\"home\", \"g\"),\n\t\t\tkey.WithHelp(\"g/home\", \"go to start\"),\n\t\t),\n\t\tGotoBottom: key.NewBinding(\n\t\t\tkey.WithKeys(\"end\", \"G\"),\n\t\t\tkey.WithHelp(\"G/end\", \"go to end\"),\n\t\t),\n\t}\n}\n\n// Styles contains style definitions for this list component. By default, these\n// values are generated by DefaultStyles.\ntype Styles struct {\n\tHeader   lipgloss.Style\n\tCell     lipgloss.Style\n\tSelected lipgloss.Style\n}\n\n// DefaultStyles returns a set of default style definitions for this table.\nfunc DefaultStyles() Styles {\n\treturn Styles{\n\t\tSelected: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(\"212\")),\n\t\tHeader:   lipgloss.NewStyle().Bold(true).Padding(0, 1),\n\t\tCell:     lipgloss.NewStyle().Padding(0, 1),\n\t}\n}\n\n// SetStyles sets the table styles.\nfunc (m *Model) SetStyles(s Styles) {\n\tm.styles = s\n\tm.UpdateViewport()\n}\n\n// Option is used to set options in New. For example:\n//\n//\ttable := New(WithColumns([]Column{{Title: \"ID\", Width: 10}}))\ntype Option func(*Model)\n\n// New creates a new model for the table widget.\nfunc New(opts ...Option) Model {\n\tm := Model{\n\t\tcursor:   0,\n\t\tviewport: viewport.New(0, 20),\n\n\t\tKeyMap: DefaultKeyMap(),\n\t\tstyles: DefaultStyles(),\n\t}\n\n\tfor _, opt := range opts {\n\t\topt(&m)\n\t}\n\n\tm.UpdateViewport()\n\n\treturn m\n}\n\n// WithColumns sets the table columns (headers).\nfunc WithColumns(cols []Column) Option {\n\treturn func(m *Model) {\n\t\tm.cols = cols\n\t}\n}\n\n// WithRows sets the table rows (data).\nfunc WithRows(rows []Row) Option {\n\treturn func(m *Model) {\n\t\tm.rows = rows\n\t}\n}\n\n// WithHeight sets the height of the table.\nfunc WithHeight(h int) Option {\n\treturn func(m *Model) {\n\t\tm.viewport.Height = h\n\t}\n}\n\n// WithWidth sets the width of the table.\nfunc WithWidth(w int) Option {\n\treturn func(m *Model) {\n\t\tm.viewport.Width = w\n\t}\n}\n\n// WithFocused sets the focus state of the table.\nfunc WithFocused(f bool) Option {\n\treturn func(m *Model) {\n\t\tm.focus = f\n\t}\n}\n\n// WithStyles sets the table styles.\nfunc WithStyles(s Styles) Option {\n\treturn func(m *Model) {\n\t\tm.styles = s\n\t}\n}\n\n// WithKeyMap sets the key map.\nfunc WithKeyMap(km KeyMap) Option {\n\treturn func(m *Model) {\n\t\tm.KeyMap = km\n\t}\n}\n\n// WithShortColumn sets the short column options.\nfunc WithShortColumn(shortColIdx, shortAppendColIdx int) Option {\n\treturn func(m *Model) {\n\t\tm.shortColIdx = shortColIdx\n\t\tm.shortAppendColIdx = shortAppendColIdx\n\t}\n}\n\n// Update is the Bubble Tea update loop.\nfunc (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {\n\tif !m.focus {\n\t\treturn m, nil\n\t}\n\n\tswitch msg := msg.(type) {\n\tcase tea.KeyMsg:\n\t\tswitch {\n\t\tcase key.Matches(msg, m.KeyMap.LineUp):\n\t\t\tm.MoveUp(1)\n\t\tcase key.Matches(msg, m.KeyMap.LineDown):\n\t\t\tm.MoveDown(1)\n\t\tcase key.Matches(msg, m.KeyMap.PageUp):\n\t\t\tm.MoveUp(m.viewport.Height)\n\t\tcase key.Matches(msg, m.KeyMap.PageDown):\n\t\t\tm.MoveDown(m.viewport.Height)\n\t\tcase key.Matches(msg, m.KeyMap.HalfPageUp):\n\t\t\tm.MoveUp(m.viewport.Height / 2)\n\t\tcase key.Matches(msg, m.KeyMap.HalfPageDown):\n\t\t\tm.MoveDown(m.viewport.Height / 2)\n\t\tcase key.Matches(msg, m.KeyMap.LineDown):\n\t\t\tm.MoveDown(1)\n\t\tcase key.Matches(msg, m.KeyMap.GotoTop):\n\t\t\tm.GotoTop()\n\t\tcase key.Matches(msg, m.KeyMap.GotoBottom):\n\t\t\tm.GotoBottom()\n\t\t}\n\t}\n\n\treturn m, nil\n}\n\n// Focused returns the focus state of the table.\nfunc (m Model) Focused() bool {\n\treturn m.focus\n}\n\n// Focus focuses the table, allowing the user to move around the rows and\n// interact.\nfunc (m *Model) Focus() {\n\tm.focus = true\n\tm.UpdateViewport()\n}\n\n// Blur blurs the table, preventing selection or movement.\nfunc (m *Model) Blur() {\n\tm.focus = false\n\tm.UpdateViewport()\n}\n\n// View renders the component.\nfunc (m Model) View() string {\n\treturn m.headersView() + \"\\n\" + m.viewport.View()\n}\n\n// UpdateViewport updates the list content based on the previously defined\n// columns and rows.\nfunc (m *Model) UpdateViewport() {\n\trenderedRows := make([]string, 0, len(m.rows))\n\n\t// Render only rows from: m.cursor-m.viewport.Height to: m.cursor+m.viewport.Height\n\t// Constant runtime, independent of number of rows in a table.\n\t// Limits the number of renderedRows to a maximum of 2*m.viewport.Height\n\tif m.cursor >= 0 {\n\t\tm.start = clamp(m.cursor-m.viewport.Height, 0, m.cursor)\n\t} else {\n\t\tm.start = 0\n\t}\n\tm.end = clamp(m.cursor+m.viewport.Height, m.cursor, len(m.rows))\n\tfor i := m.start; i < m.end; i++ {\n\t\trenderedRows = append(renderedRows, m.renderRow(i))\n\t}\n\n\tm.viewport.SetContent(\n\t\tlipgloss.JoinVertical(lipgloss.Left, renderedRows...),\n\t)\n}\n\n// SelectedRow returns the selected row.\n// You can cast it to your own implementation.\nfunc (m Model) SelectedRow() Row {\n\tif m.cursor < 0 || m.cursor >= len(m.rows) {\n\t\treturn nil\n\t}\n\n\treturn m.rows[m.cursor]\n}\n\n// Rows returns the current rows.\nfunc (m Model) Rows() []Row {\n\treturn m.rows\n}\n\n// SetRows sets a new rows state.\nfunc (m *Model) SetRows(r []Row) {\n\tm.rows = r\n\tm.UpdateViewport()\n}\n\n// SetColumns sets a new columns state.\nfunc (m *Model) SetColumns(c []Column) {\n\tm.cols = c\n\tm.UpdateViewport()\n}\n\n// SetColumnNameLast sets new name to last column.\nfunc (m *Model) SetColumnNameLast(n string) {\n\tm.cols[len(m.cols)-1].Title = n\n\tm.UpdateViewport()\n}\n\n// SetWidth sets the width of the viewport of the table.\nfunc (m *Model) SetWidth(w int) {\n\tm.viewport.Width = w\n\tm.UpdateViewport()\n}\n\n// SetShortMode sets the mode of the table.\nfunc (m *Model) SetShortMode(sm bool) {\n\tif m.shortMode != sm {\n\t\tm.shortMode = sm\n\t\tm.UpdateViewport()\n\t}\n}\n\n// Update the width of the rightmost column\nfunc (m *Model) SetColWidthLast(w int) {\n\tm.cols[len(m.cols)-1].Width = w\n\tm.UpdateViewport()\n}\n\n// SetHeight sets the height of the viewport of the table.\nfunc (m *Model) SetHeight(h int) {\n\tm.viewport.Height = h\n\tm.UpdateViewport()\n}\n\n// Height returns the viewport height of the table.\nfunc (m Model) Height() int {\n\treturn m.viewport.Height\n}\n\n// Width returns the viewport width of the table.\nfunc (m Model) Width() int {\n\treturn m.viewport.Width\n}\n\n// Cursor returns the index of the selected row.\nfunc (m Model) Cursor() int {\n\treturn m.cursor\n}\n\n// SetCursor sets the cursor position in the table.\nfunc (m *Model) SetCursor(n int) {\n\tm.cursor = clamp(n, 0, len(m.rows)-1)\n\tm.UpdateViewport()\n}\n\n// MoveUp moves the selection up by any number of rows.\n// It can not go above the first row.\nfunc (m *Model) MoveUp(n int) {\n\tm.cursor = clamp(m.cursor-n, 0, len(m.rows)-1)\n\tswitch {\n\tcase m.start == 0:\n\t\tm.viewport.SetYOffset(clamp(m.viewport.YOffset, 0, m.cursor))\n\tcase m.start < m.viewport.Height:\n\t\tm.viewport.SetYOffset(clamp(m.viewport.YOffset+n, 0, m.cursor))\n\tcase m.viewport.YOffset >= 1:\n\t\tm.viewport.YOffset = clamp(m.viewport.YOffset+n, 1, m.viewport.Height)\n\t}\n\tm.UpdateViewport()\n}\n\n// MoveDown moves the selection down by any number of rows.\n// It can not go below the last row.\nfunc (m *Model) MoveDown(n int) {\n\tm.cursor = clamp(m.cursor+n, 0, len(m.rows)-1)\n\tm.UpdateViewport()\n\n\tswitch {\n\tcase m.end == len(m.rows):\n\t\tm.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.viewport.Height))\n\tcase m.cursor > (m.end-m.start)/2:\n\t\tm.viewport.SetYOffset(clamp(m.viewport.YOffset-n, 1, m.cursor))\n\tcase m.viewport.YOffset > 1:\n\tcase m.cursor > m.viewport.YOffset+m.viewport.Height-1:\n\t\tm.viewport.SetYOffset(clamp(m.viewport.YOffset+1, 0, 1))\n\t}\n}\n\n// GotoTop moves the selection to the first row.\nfunc (m *Model) GotoTop() {\n\tm.MoveUp(m.cursor)\n}\n\n// GotoBottom moves the selection to the last row.\nfunc (m *Model) GotoBottom() {\n\tm.MoveDown(len(m.rows))\n}\n\n// FromValues create the table rows from a simple string. It uses `\\n` by\n// default for getting all the rows and the given separator for the fields on\n// each row.\nfunc (m *Model) FromValues(value, separator string) {\n\trows := []Row{}\n\tfor _, line := range strings.Split(value, \"\\n\") {\n\t\tr := Row{}\n\t\tfor _, field := range strings.Split(line, separator) {\n\t\t\tr = append(r, field)\n\t\t}\n\t\trows = append(rows, r)\n\t}\n\n\tm.SetRows(rows)\n}\n\nfunc (m Model) headersView() string {\n\tvar s = make([]string, 0, len(m.cols))\n\tvar appendWidth int\n\tif m.shortMode {\n\t\t// Width of column to hide\n\t\tappendWidth = m.cols[m.shortColIdx].Width + 2 // columns * 2\n\t}\n\n\tfor i, col := range m.cols {\n\t\tcolWidth := col.Width\n\n\t\tif m.shortMode {\n\t\t\tif m.shortColIdx == i {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif m.shortAppendColIdx == i {\n\t\t\t\tcolWidth += appendWidth\n\t\t\t}\n\t\t}\n\t\tstyle := lipgloss.NewStyle().Width(colWidth).MaxWidth(colWidth).Inline(true)\n\t\trenderedCell := style.Render(runewidth.Truncate(col.Title, colWidth, \"…\"))\n\t\ts = append(s, m.styles.Header.Render(renderedCell))\n\t}\n\treturn lipgloss.JoinHorizontal(lipgloss.Left, s...)\n}\n\nfunc (m *Model) renderRow(rowID int) string {\n\tvar s = make([]string, 0, len(m.cols))\n\tvar appendWidth int\n\tif m.shortMode {\n\t\tappendWidth = m.cols[m.shortColIdx].Width + 2\n\t}\n\n\ttruncate := runewidth.Truncate\n\n\tfor i, value := range m.rows[rowID] {\n\t\t// change truncatePrefix in last column\n\t\tif i == len(m.rows[rowID])-1 {\n\t\t\ttruncate = runewidth.TruncatePrefix\n\t\t}\n\n\t\tcolWidth := m.cols[i].Width\n\t\tif m.shortMode {\n\t\t\tif m.shortColIdx == i {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif m.shortAppendColIdx == i {\n\t\t\t\tcolWidth += appendWidth\n\t\t\t}\n\t\t}\n\t\tstyle := lipgloss.NewStyle().Width(colWidth).MaxWidth(colWidth).Inline(true)\n\t\trenderedCell := m.styles.Cell.Render(style.Render(truncate(value, colWidth, \"…\")))\n\t\ts = append(s, renderedCell)\n\t}\n\n\trow := lipgloss.JoinHorizontal(lipgloss.Left, s...)\n\n\tif rowID == m.cursor {\n\t\treturn m.styles.Selected.Render(row)\n\t}\n\n\treturn row\n}\n\nfunc max(a, b int) int {\n\tif a > b {\n\t\treturn a\n\t}\n\n\treturn b\n}\n\nfunc min(a, b int) int {\n\tif a < b {\n\t\treturn a\n\t}\n\n\treturn b\n}\n\nfunc clamp(v, low, high int) int {\n\treturn min(max(v, low), high)\n}\n"
  },
  {
    "path": "internal/tui/table/table_test.go",
    "content": "package table\n\nimport \"testing\"\n\n// Copied from https://github.com/charmbracelet/bubbles/blob/f36aa3c4b5369f2ecefb4e35dbb2c924906932ca/table/table_test.go\n// https://github.com/charmbracelet/bubbles/blob/f36aa3c4b5369f2ecefb4e35dbb2c924906932ca/LICENSE\n\n// MIT License\n//\n// Copyright (c) 2020-2023 Charmbracelet, Inc\n//\n// Permission is hereby granted, free of charge, to any person obtaining a copy\n// of this software and associated documentation files (the \"Software\"), to deal\n// in the Software without restriction, including without limitation the rights\n// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n// copies of the Software, and to permit persons to whom the Software is\n// furnished to do so, subject to the following conditions:\n//\n// The above copyright notice and this permission notice shall be included in all\n// copies or substantial portions of the Software.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n// SOFTWARE.\n\nfunc TestFromValues(t *testing.T) {\n\tinput := \"foo1,bar1\\nfoo2,bar2\\nfoo3,bar3\"\n\ttable := New(WithColumns([]Column{{Title: \"Foo\"}, {Title: \"Bar\"}}))\n\ttable.FromValues(input, \",\")\n\n\tif len(table.rows) != 3 {\n\t\tt.Fatalf(\"expect table to have 3 rows but it has %d\", len(table.rows))\n\t}\n\n\texpect := []Row{\n\t\t{\"foo1\", \"bar1\"},\n\t\t{\"foo2\", \"bar2\"},\n\t\t{\"foo3\", \"bar3\"},\n\t}\n\tif !deepEqual(table.rows, expect) {\n\t\tt.Fatal(\"table rows is not equals to the input\")\n\t}\n}\n\nfunc TestFromValuesWithTabSeparator(t *testing.T) {\n\tinput := \"foo1.\\tbar1\\nfoo,bar,baz\\tbar,2\"\n\ttable := New(WithColumns([]Column{{Title: \"Foo\"}, {Title: \"Bar\"}}))\n\ttable.FromValues(input, \"\\t\")\n\n\tif len(table.rows) != 2 {\n\t\tt.Fatalf(\"expect table to have 2 rows but it has %d\", len(table.rows))\n\t}\n\n\texpect := []Row{\n\t\t{\"foo1.\", \"bar1\"},\n\t\t{\"foo,bar,baz\", \"bar,2\"},\n\t}\n\tif !deepEqual(table.rows, expect) {\n\t\tt.Fatal(\"table rows is not equals to the input\")\n\t}\n}\n\nfunc deepEqual(a, b []Row) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\tfor i, r := range a {\n\t\tfor j, f := range r {\n\t\t\tif f != b[i][j] {\n\t\t\t\treturn false\n\t\t\t}\n\t\t}\n\t}\n\treturn true\n}\n"
  },
  {
    "path": "internal/tui/tui.go",
    "content": "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/internal/trash\"\n)\n\nfunc FilesSelect(files []trash.File) ([]trash.File, error) {\n\tm := newMultiRestoreModel(files)\n\tresult, err := tea.NewProgram(m, tea.WithAltScreen()).Run()\n\tif err != nil {\n\t\tfmt.Println(\"Error running program:\", err)\n\t\tos.Exit(1)\n\t}\n\n\tif r, ok := result.(multiRestoreModel); ok {\n\t\tif r.confirmed {\n\t\t\treturn r.restoreFiles, nil\n\t\t}\n\t}\n\n\treturn nil, errors.New(\"no selected\")\n}\n\nfunc GroupSelect(groups []trash.Group) (trash.Group, error) {\n\tm := newSingleRestoreModel(groups)\n\tresult, err := tea.NewProgram(m, tea.WithAltScreen()).Run()\n\tif err != nil {\n\t\tfmt.Println(\"Error running program:\", err)\n\t\tos.Exit(1)\n\t}\n\n\tif r, ok := result.(singleRestoreModel); ok {\n\t\tif r.confirmed {\n\t\t\treturn groups[r.selected], nil\n\t\t}\n\t}\n\n\treturn trash.Group{}, errors.New(\"no selected\")\n}\n\nfunc BoolPrompt(prompt string) bool {\n\tm := newBoolInputModel(prompt)\n\n\tresult, err := tea.NewProgram(m).Run()\n\tif err != nil {\n\t\treturn false\n\t}\n\n\tif m, ok := result.(boolInputModel); ok {\n\t\treturn m.Confirmed() && m.Value()\n\t}\n\n\treturn false\n}\n\nfunc ChoicePrompt(prompt string, choices []string) (string, error) {\n\tmodel := newChoiceInputModel(prompt, choices)\n\tresult, err := tea.NewProgram(model).Run()\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif m, ok := result.(choiceInputModel); ok {\n\t\tif !m.Confirmed() || m.Value() == \"quit\" { // hard code quit\n\t\t\treturn \"\", errors.New(\"canceled\")\n\t\t}\n\n\t\treturn m.Value(), err\n\t}\n\treturn \"\", errors.New(\"unexpected error in ChoicePrompt\")\n}\n"
  },
  {
    "path": "internal/xdg/dirsizecache.go",
    "content": "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\"golang.org/x/exp/maps\"\n)\n\ntype DirCache map[string]*struct {\n\tItem DirCacheItem\n\tSeen bool\n}\n\ntype DirCacheItem struct {\n\tSize    int64\n\tMtime   time.Time\n\tDirName string\n}\n\nfunc NewDirCache(r io.Reader) (DirCache, error) {\n\tscan := bufio.NewScanner(r)\n\n\tdirCache := make(DirCache)\n\n\tfor scan.Scan() {\n\t\tline := scan.Text()\n\n\t\tparseErr := fmt.Errorf(\"parse line: %s\", line)\n\n\t\tcols := strings.SplitN(line, \" \", 3)\n\t\tif len(cols) != 3 {\n\t\t\treturn nil, parseErr\n\t\t}\n\n\t\tsize, err := strconv.ParseInt(cols[0], 10, 64)\n\t\tif err != nil {\n\t\t\treturn nil, parseErr\n\t\t}\n\n\t\tts, err := strconv.ParseInt(cols[1], 10, 64)\n\t\tif err != nil {\n\t\t\treturn nil, parseErr\n\t\t}\n\n\t\tfolder, err := url.QueryUnescape(cols[2])\n\t\tif err != nil {\n\t\t\treturn nil, parseErr\n\t\t}\n\n\t\tdirCache[folder] = &struct {\n\t\t\tItem DirCacheItem\n\t\t\tSeen bool\n\t\t}{\n\t\t\tItem: DirCacheItem{\n\t\t\t\tSize:    size,\n\t\t\t\tMtime:   time.Unix(ts, 0),\n\t\t\t\tDirName: folder,\n\t\t\t},\n\t\t}\n\t}\n\n\treturn dirCache, nil\n}\n\nfunc (i DirCacheItem) String() string {\n\treturn fmt.Sprintf(\"%d %d %s\\n\", i.Size, i.Mtime.Unix(), queryEscapePath(i.DirName))\n}\n\nfunc (c DirCache) ToFile(truncate bool) string {\n\tdirs := maps.Keys(c)\n\tsort.Slice(dirs, func(i, j int) bool {\n\t\treturn dirs[i] < dirs[j]\n\t})\n\n\tvar s strings.Builder\n\tfor _, d := range dirs {\n\t\t// remove unseen cache entry\n\t\tif truncate && !c[d].Seen {\n\t\t\tcontinue\n\t\t}\n\t\ts.WriteString(c[d].Item.String())\n\t}\n\n\treturn s.String()\n}\n\nfunc (c DirCache) Save(trashDir string, truncate bool) error {\n\t// xdg ref: To update the directorysizes file, implementations MUST use a temporary\n\t// file followed by an atomic rename() operation, in order to avoid\n\t// corruption due to two implementations writing to the file at the same\n\t// time.\n\tf, err := os.CreateTemp(\"\", \"directorysizes_gtrash_\")\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer f.Close()\n\tdefer os.Remove(f.Name())\n\n\tif _, err = f.WriteString(c.ToFile(truncate)); err != nil {\n\t\treturn err\n\t}\n\n\tcachePath := filepath.Join(trashDir, \"directorysizes\")\n\tif err := os.Rename(f.Name(), cachePath); err != nil {\n\t\t// External trash will definitely cause cross-device link errors.\n\t\t// so copied trash directory, then rename(2)\n\t\ttmpDstPath := filepath.Join(trashDir, filepath.Base(f.Name()))\n\t\tdst, err := os.Create(tmpDstPath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tdefer dst.Close()\n\t\tdefer os.Remove(tmpDstPath)\n\n\t\t// to copy from start, set offset to 0\n\t\tif _, err := f.Seek(0, io.SeekStart); err != nil {\n\t\t\treturn err\n\t\t}\n\t\t// file copy\n\t\tif _, err := io.Copy(dst, f); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t// then rename atomically\n\t\tif err := os.Rename(tmpDstPath, cachePath); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/xdg/dirsizecache_test.go",
    "content": "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/require\"\n)\n\nfunc TestNewDirCache(t *testing.T) {\n\twant := make(DirCache)\n\twant[\"bar\"] = &struct {\n\t\tItem DirCacheItem\n\t\tSeen bool\n\t}{\n\t\tItem: DirCacheItem{\n\t\t\tSize:    10000,\n\t\t\tMtime:   time.Unix(1672531200, 0),\n\t\t\tDirName: \"bar\",\n\t\t},\n\t\tSeen: false,\n\t}\n\n\twant[\"foo\"] = &struct {\n\t\tItem DirCacheItem\n\t\tSeen bool\n\t}{\n\t\tItem: DirCacheItem{\n\t\t\tSize:    20000,\n\t\t\tMtime:   time.Unix(1672531200, 0),\n\t\t\tDirName: \"foo\",\n\t\t},\n\t\tSeen: false,\n\t}\n\n\twant[\"あい うえお\"] = &struct {\n\t\tItem DirCacheItem\n\t\tSeen bool\n\t}{\n\t\tItem: DirCacheItem{\n\t\t\tSize:    40000,\n\t\t\tMtime:   time.Unix(1672531200, 0),\n\t\t\tDirName: \"あい うえお\",\n\t\t},\n\t\tSeen: false,\n\t}\n\n\tfile := `10000 1672531200 bar\n20000 1672531200 foo\n40000 1672531200 %E3%81%82%E3%81%84%20%E3%81%86%E3%81%88%E3%81%8A\n`\n\n\tgot, err := NewDirCache(strings.NewReader(file))\n\trequire.NoError(t, err)\n\tassert.EqualValues(t, want, got, \"parse directorysizes\")\n\n\tassert.Equal(t, file, got.ToFile(false), \"back to directorysizes text\")\n\n\tt.Run(\"skip not seen item when truncate on\", func(t *testing.T) {\n\t\tgot[\"foo\"].Seen = true\n\t\tassert.Equal(t, \"20000 1672531200 foo\\n\", got.ToFile(true))\n\t})\n}\n"
  },
  {
    "path": "internal/xdg/path.go",
    "content": "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\tdirHome string\n\t// $XDG_DATA_HOME\n\tdirDataHome string\n\n\tDirHomeTrash string\n)\n\nfunc init() {\n\tdirHome = os.Getenv(\"HOME\")\n\tif dirHome == \"\" {\n\t\t// fallback to get home dir\n\t\tu, err := user.Current()\n\t\tif err == nil {\n\t\t\tdirHome = u.HomeDir\n\t\t}\n\t}\n\n\tdirDataHome = filepath.Join(dirHome, \".local\", \"share\")\n\tif d, ok := os.LookupEnv(\"XDG_DATA_HOME\"); ok {\n\t\tif abs, err := filepath.Abs(d); err == nil {\n\t\t\tdirDataHome = abs\n\t\t}\n\t}\n\n\t// Can be changed by environment variables\n\tif env.HOME_TRASH_DIR != \"\" {\n\t\tDirHomeTrash = env.HOME_TRASH_DIR\n\t} else {\n\t\tDirHomeTrash = filepath.Join(dirDataHome, \"Trash\")\n\t}\n}\n"
  },
  {
    "path": "internal/xdg/trashdir.go",
    "content": "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\"syscall\"\n\n\t\"github.com/moby/sys/mountinfo\"\n\t\"github.com/umlx5h/gtrash/internal/env\"\n)\n\ntype trashDirType string\n\nconst (\n\ttrashDirTypeHome        trashDirType = \"HOME\"         // $XDG_DATA_HOME/Trash\n\ttrashDirTypeExternal    trashDirType = \"EXTERNAL\"     // $root/.Trash/$uid\n\ttrashDirTypeExternalAlt trashDirType = \"EXTERNAL_ALT\" // $root/.Trash-$uid\n\ttrashDirTypeManual      trashDirType = \"MANUAL\"       // any directory, specify by --trash-dir\n)\n\ntype TrashDir struct {\n\tRoot    string // $XDG_DATA_HOME or $rootDir (used for relative path)\n\tDir     string // $XDG_DATA_HOME/Trash or $rootDir/.Trash/$uid or $rootDir/.Trash-$uid (has info and files directory)\n\tdirType trashDirType\n}\n\nfunc (d TrashDir) InfoDir() string {\n\treturn filepath.Join(d.Dir, \"info\")\n}\n\nfunc (d TrashDir) FilesDir() string {\n\treturn filepath.Join(d.Dir, \"files\")\n}\n\n// Use relative paths for external trash\nfunc (d TrashDir) UseRelativePath() bool {\n\tswitch d.dirType {\n\tcase trashDirTypeHome: // use absolute path\n\t\treturn false\n\tcase trashDirTypeExternal, trashDirTypeExternalAlt: // use relative path\n\t\treturn true\n\tdefault:\n\t\tpanic(\"not reached\")\n\t}\n}\n\nfunc (d TrashDir) CreateDir() error {\n\tif err := os.MkdirAll(d.InfoDir(), 0o700); err != nil {\n\t\treturn err\n\t}\n\n\tif err := os.MkdirAll(d.FilesDir(), 0o700); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc NewTrashDirManual(dir string) TrashDir {\n\treturn TrashDir{\n\t\tRoot:    filepath.Dir(dir),\n\t\tDir:     dir,\n\t\tdirType: trashDirTypeManual,\n\t}\n}\n\n// Scan and returns trash directories from all mountpoints\n// The existence of the 'files' and 'info' directories is not checked\nfunc ScanTrashDirs() []TrashDir {\n\tvar trashDirList []TrashDir\n\n\t// 1. First get the trash can in the home directory\n\tif _, err := os.Stat(DirHomeTrash); err == nil {\n\t\ttrashDirList = append(trashDirList, TrashDir{\n\t\t\tRoot:    dirDataHome,\n\t\t\tDir:     DirHomeTrash,\n\t\t\tdirType: trashDirTypeHome,\n\t\t})\n\t\tslog.Debug(\"found home trash\", \"directory\", DirHomeTrash)\n\t}\n\n\tif env.ONLY_HOME_TRASH {\n\t\treturn trashDirList\n\t}\n\n\t// Get all mount points to get external trash cans\n\tslog.Debug(\"getting all mountpoints\")\n\ttopDirs, err := getAllMountpoints()\n\tif err != nil {\n\t\tslog.Warn(\"failed to get all mountpoints, do not use external trash\", \"error\", err)\n\t\treturn trashDirList\n\t}\n\n\tuid := strconv.Itoa(os.Getuid())\n\n\t// Check to see if the .Trash directory exists\n\tfor _, topDir := range topDirs {\n\t\t// 2. check $topDir/.Trash/$uid\n\t\ttrashDir := filepath.Join(topDir, \".Trash\", uid)\n\n\t\tif _, err := os.Stat(trashDir); err == nil {\n\t\t\ttrashDirList = append(trashDirList, TrashDir{\n\t\t\t\tRoot:    topDir,\n\t\t\t\tDir:     trashDir,\n\t\t\t\tdirType: trashDirTypeExternal,\n\t\t\t})\n\t\t\tslog.Debug(\"found external trash\", \"directory\", trashDir)\n\t\t}\n\n\t\t// 3. check $topDir/Trash-$uid\n\t\ttrashDir = filepath.Join(topDir, fmt.Sprintf(\".Trash-%s\", uid))\n\t\tif _, err = os.Stat(trashDir); err == nil {\n\t\t\ttrashDirList = append(trashDirList, TrashDir{\n\t\t\t\tRoot:    topDir,\n\t\t\t\tDir:     trashDir,\n\t\t\t\tdirType: trashDirTypeExternalAlt,\n\t\t\t})\n\t\t\tslog.Debug(\"found external alternative trash\", \"directory\", trashDir)\n\t\t}\n\t}\n\n\treturn trashDirList\n}\n\n// Returns the trash directory associated with the file\n// Return the home directory for fallback as well.\nfunc LookupTrashDir(path string) (home *TrashDir, external *TrashDir, err error) {\n\thomeTrash := &TrashDir{\n\t\tRoot:    dirDataHome,\n\t\tDir:     DirHomeTrash,\n\t\tdirType: trashDirTypeHome,\n\t}\n\n\t// always using home trash\n\tif env.ONLY_HOME_TRASH {\n\t\t// already create dir in env.go\n\t\treturn homeTrash, nil, nil\n\t}\n\n\t// 1. Determine whether to use the trash can in the home directory\n\t// stat(2) each file and determine that they have the same file system if the device number (st_dev) matches.\n\t// implementation varies by program.\n\tsameFS, err := useHomeTrash(path)\n\tif err != nil {\n\t\t// unexpected error\n\t\treturn nil, nil, fmt.Errorf(\"home_trash: %w\", err)\n\t}\n\n\tif sameFS {\n\t\t// use home_trash\n\t\treturn homeTrash, nil, nil\n\t}\n\n\t// obtain a mount point associated with a file\n\ttopDir, err := getMountpoint(path)\n\tif err != nil {\n\t\treturn homeTrash, nil, fmt.Errorf(\"get mountpoint: %w\", err)\n\t}\n\n\t// 2. Check $topDir/.Trash/$uid available\n\tif trashDir, err := useExternalTrash(topDir); err == nil {\n\t\treturn homeTrash, &TrashDir{\n\t\t\tRoot:    topDir,\n\t\t\tDir:     trashDir,\n\t\t\tdirType: trashDirTypeExternal,\n\t\t}, nil\n\t}\n\n\t// 3. Check $topDir/Trash-$uid available\n\tif trashDir, err := useExternalTrashAlt(topDir); err == nil {\n\t\treturn homeTrash, &TrashDir{\n\t\t\tRoot:    topDir,\n\t\t\tDir:     trashDir,\n\t\t\tdirType: trashDirTypeExternalAlt,\n\t\t}, nil\n\t} else {\n\t\treturn homeTrash, nil, fmt.Errorf(\"external_trash: %w\", err)\n\t}\n}\n\n// Exclude file systems from find that are clearly unnecessary\nvar skipFSType = []string{\n\t\"binfmt_misc\",\n\t\"cgroup\",\n\t\"cgroup2\",\n\t\"debugfs\",\n\t\"devpts\",\n\t\"devtmpfs\",\n\t\"hugetlbfs\",\n\t\"mqueue\",\n\t\"proc\",\n\t\"sysfs\",\n\t\"tracefs\",\n\t\"nsfs\",\n\t\"fusectl\",\n}\n\nfunc getAllMountpoints() ([]string, error) {\n\tinfos, err := mountinfo.GetMounts(func(i *mountinfo.Info) (skip bool, stop bool) {\n\t\tif slices.Contains(skipFSType, i.FSType) {\n\t\t\treturn true, false\n\t\t}\n\n\t\t// Read-only file systems are also excluded.\n\t\tif i.Options == \"ro\" || strings.HasPrefix(i.Options, \"ro,\") {\n\t\t\treturn true, false\n\t\t}\n\n\t\treturn false, false\n\t})\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// sometimes, same mountpoint exists, so must take a unique\n\tmountpoints := make([]string, 0, len(infos))\n\texists := make(map[string]struct{}, len(infos))\n\tfor i := range infos {\n\t\tm := infos[i].Mountpoint\n\n\t\tif _, ok := exists[m]; ok {\n\t\t\t// duplicate entry detected\n\t\t\tslog.Debug(\"duplicated mountpoint is detected\", \"mountpoint\", m)\n\t\t\tcontinue\n\t\t}\n\n\t\tmountpoints = append(mountpoints, m)\n\t\texists[m] = struct{}{}\n\t}\n\n\treturn mountpoints, nil\n}\n\nvar mountinfo_Mounted = mountinfo.Mounted\nvar EvalSymLinks = filepath.EvalSymlinks\n\n// Obtain a mount point associated with a file.\n// Same as df <PATH>\nfunc getMountpoint(path string) (string, error) {\n\n\t// iterate over the real (without symlinks) parents of path until we find a mount point\n\n\tcandidate, err := EvalSymLinks(filepath.Dir(path))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tfor {\n\t\t// root is always mounted\n\t\tif candidate == string(os.PathSeparator) {\n\t\t\tslog.Debug(\"root mountpoint is detected\", \"path\", path)\n\t\t\tbreak\n\t\t}\n\n\t\tif candidate == \".\" {\n\t\t\t// should not reached here\n\t\t\t// check to prevent busy loop\n\t\t\treturn \"\", errors.New(\"mountpoint is '.'\")\n\t\t}\n\n\t\tmounted, err := mountinfo_Mounted(candidate)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\n\t\tif mounted {\n\t\t\tbreak\n\t\t}\n\n\t\tcandidate = filepath.Dir(candidate)\n\t}\n\n\treturn candidate, nil\n}\n\nfunc useHomeTrash(path string) (sameFS bool, err error) {\n\t// do not follow symlink\n\tfi, err := os.Lstat(path)\n\tif err != nil {\n\t\t// must be already checked\n\t\treturn false, err\n\t}\n\n\tti, err := os.Stat(DirHomeTrash)\n\tif err != nil {\n\t\t// if home trash folder do not exist, create it\n\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\tif err := os.MkdirAll(DirHomeTrash, 0o700); err != nil {\n\t\t\t\treturn false, fmt.Errorf(\"create trash_dir: %w\", err)\n\t\t\t}\n\t\t\t// re-execute stat\n\t\t\tti, err = os.Stat(DirHomeTrash)\n\t\t}\n\t}\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"stat(2) trash_dir: %w\", err)\n\t}\n\n\tfromInfo, ok := fi.Sys().(*syscall.Stat_t)\n\tif !ok {\n\t\treturn false, fmt.Errorf(\"get stat(2) dev_ino\")\n\t}\n\n\ttoInfo, ok := ti.Sys().(*syscall.Stat_t)\n\tif !ok {\n\t\treturn false, fmt.Errorf(\"get stat(2) dev_ino from trash_dir\")\n\t}\n\n\t// stat(2) struct stat { dev_t st_dev }\n\tif fromInfo.Dev == toInfo.Dev {\n\t\t// If the device number matches, the home trash can be used because it is the same file system.\n\t\treturn true, nil\n\t}\n\n\t// different file system\n\treturn false, nil\n}\n\nfunc useExternalTrash(topDir string) (string, error) {\n\t// xdg ref: When trashing a file from a non-home partition/device4 , an\n\t// implementation (if it supports trashing in top directories) MUST\n\t// check for the presence of $topdir/.Trash.\n\ttrashDir := filepath.Join(topDir, \".Trash\")\n\tinfo, err := os.Lstat(trashDir)\n\tif err != nil {\n\t\treturn \"\", errors.New(\".Trash not found\")\n\t}\n\tif !info.IsDir() {\n\t\treturn \"\", errors.New(\".Trash is not directory\")\n\t}\n\n\t// xdg ref: The implementation also MUST check that this directory is not a symbolic link.\n\tif info.Mode().Type() == fs.ModeSymlink {\n\t\treturn \"\", errors.New(\".Trash is symlink\")\n\t}\n\n\t// xdg ref: If this directory is present, the implementation MUST, by default, check for the “sticky bit”.\n\tif info.Mode()&os.ModeSticky == 0 {\n\t\treturn \"\", errors.New(\".Trash sticky bit not set\")\n\t}\n\n\ttrashDir = filepath.Join(trashDir, strconv.Itoa(os.Getuid()))\n\n\t// Ensure to have $topDir/$uid directory\n\tif err := os.MkdirAll(trashDir, 0o700); err != nil {\n\t\treturn \"\", fmt.Errorf(\"%q not created: %w\", trashDir, err)\n\t}\n\n\treturn trashDir, nil\n}\n\nfunc useExternalTrashAlt(topDir string) (string, error) {\n\ttrashDir := filepath.Join(topDir, fmt.Sprintf(\".Trash-%d\", os.Getuid()))\n\n\t// Ensure to have $topDir-$uid directory\n\tif err := os.MkdirAll(trashDir, 0o700); err != nil {\n\t\treturn \"\", fmt.Errorf(\"%q not created: %w\", trashDir, err)\n\t}\n\n\treturn trashDir, nil\n}\n"
  },
  {
    "path": "internal/xdg/trashdir_test.go",
    "content": "package xdg\n\nimport (\n\t\"slices\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n)\n\nfunc TestGetMountpoint(t *testing.T) {\n\t// replace to stub\n\tmountinfo_Mounted = func(fpath string) (bool, error) {\n\t\tmounts := []string{\n\t\t\t\"/\",\n\t\t\t\"/foo/bar\",\n\t\t\t\"/foo\",\n\t\t\t\"/fooo/bar\",\n\t\t\t\"/ffoo/bar\",\n\t\t}\n\t\treturn slices.Contains(mounts, fpath), nil\n\t}\n\n\t// not evaluating each component here, just the entire path\n\tsymlinked := map[string]string{\n\t\t// file is a link\n\t\t\"/foo/link.txt\": \"/foo/bar/target.txt\",\n\n\t\t// first component is a link\n\t\t\"/link\": \"/foo/bar\",\n\t}\n\n\tEvalSymLinks = func(path string) (string, error) {\n\t\tif symlink, ok := symlinked[path]; ok {\n\t\t\treturn symlink, nil\n\t\t}\n\t\treturn path, nil\n\t}\n\n\ttestsNormal := []struct {\n\t\tpath string\n\t\twant string\n\t}{\n\t\t{path: \"/a.txt\", want: \"/\"},\n\t\t{path: \"/foo/bar/a.txt\", want: \"/foo/bar\"},\n\t\t{path: \"/foo/bar/aaa/b.txt\", want: \"/foo/bar\"},\n\t\t{path: \"/ffoo/bar/a.txt\", want: \"/ffoo/bar\"},\n\t\t{path: \"/aaa/bbb/ccc/ddd.txt\", want: \"/\"},\n\t\t{path: \"/\", want: \"/\"},\n\n\t\t{path: \"/foo/link.txt\", want: \"/foo\"},\n\t\t{path: \"/link/a.txt\", want: \"/foo/bar\"},\n\t}\n\n\tt.Run(\"normal\", func(t *testing.T) {\n\t\tfor _, tt := range testsNormal {\n\t\t\tgot, err := getMountpoint(tt.path)\n\t\t\trequire.NoError(t, err)\n\t\t\tif got != tt.want {\n\t\t\t\tt.Errorf(\"getMountpoint(%q) = %q, want %q\", tt.path, got, tt.want)\n\t\t\t}\n\t\t}\n\t})\n\n\tt.Run(\"error\", func(t *testing.T) {\n\t\tgot, err := getMountpoint(\"\")\n\t\trequire.Error(t, err, got)\n\t})\n}\n"
  },
  {
    "path": "internal/xdg/trashinfo.go",
    "content": "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\nconst (\n\ttrashHeader = `[Trash Info]`\n\ttimeFormat  = \"2006-01-02T15:04:05\"\n)\n\n// XDG specifications\n// https://specifications.freedesktop.org/trash-spec/latest/\n// https://specifications.freedesktop.org/desktop-entry-spec/latest/basic-format.html\n\ntype Info struct {\n\tPath         string    // $PWD/file.go (url decoded)\n\tDeletionDate time.Time // 2023-01-01T00:00:00\n}\n\nfunc NewInfo(r io.Reader) (Info, error) {\n\tscanner := bufio.NewScanner(r)\n\n\tvar info Info\n\n\tvar (\n\t\tgroupFound bool\n\t\tpathFound  bool\n\t\tdateFound  bool\n\t)\n\tfor scanner.Scan() {\n\t\tline := scanner.Text()\n\n\t\tif line == trashHeader {\n\t\t\tgroupFound = true\n\t\t\tcontinue\n\t\t}\n\t\tif len(line) > 0 && line[0] == '[' {\n\t\t\t// other group found, so exit\n\t\t\tbreak\n\t\t}\n\t\tif strings.Contains(line, \"=\") {\n\t\t\tkv := strings.SplitN(line, \"=\", 2)\n\n\t\t\tswitch strings.TrimSpace(kv[0]) {\n\t\t\tcase \"Path\":\n\t\t\t\tif pathFound {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tu, err := url.QueryUnescape(strings.TrimSpace(kv[1]))\n\t\t\t\tif err != nil {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tinfo.Path = u\n\t\t\t\tpathFound = true\n\t\t\tcase \"DeletionDate\":\n\t\t\t\tif dateFound {\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tparsed, err := time.ParseInLocation(timeFormat, strings.TrimSpace(kv[1]), time.Local)\n\t\t\t\tif err != nil {\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t\tinfo.DeletionDate = parsed\n\t\t\t\tdateFound = true\n\t\t\t}\n\t\t}\n\t}\n\n\tif scanner.Err() != nil {\n\t\treturn Info{}, scanner.Err()\n\t}\n\n\tif !groupFound || !pathFound || !dateFound {\n\t\treturn Info{}, errors.New(\"unable to parse trashinfo\")\n\t}\n\n\treturn info, nil\n}\n\n// represent INI format\nfunc (i Info) String() string {\n\treturn fmt.Sprintf(\"%s\\nPath=%s\\nDeletionDate=%s\\n\", trashHeader, queryEscapePath(i.Path), i.DeletionDate.Format(timeFormat))\n}\n\nfunc (i Info) Save(trashDir TrashDir, filename string) (saveName string, deleteFn func() error, err error) {\n\trevision := 1\n\n\tvar trashinfoFile *os.File\n\tsaveName = filename\n\n\tfor {\n\t\tif revision > 1 {\n\t\t\tsaveName = fmt.Sprintf(\"%s_%d\", filename, revision)\n\t\t}\n\n\t\t// Considering files for which there is no associated trashinfo, check for duplicates under the files directory\n\t\t// Since the trashed file may be overwritten by subsequent rename(2)\n\t\tif _, err := os.Lstat(filepath.Join(trashDir.FilesDir(), saveName)); err == nil {\n\t\t\trevision++\n\t\t\tcontinue\n\t\t}\n\n\t\t// create .trashinfo file atomically using O_EXCL\n\t\tf, err := os.OpenFile(filepath.Join(trashDir.InfoDir(), saveName+\".trashinfo\"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)\n\t\tif err != nil {\n\t\t\t// conflict detected, so change to another name\n\t\t\tif errors.Is(err, fs.ErrExist) {\n\t\t\t\trevision++\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\treturn \"\", nil, fmt.Errorf(\"open failed: %w\", err)\n\t\t\t}\n\t\t}\n\t\tdefer f.Close()\n\n\t\ttrashinfoFile = f\n\t\tbreak\n\t}\n\n\t// Have this called when the file fails to move.\n\tdeleteFn = func() error {\n\t\treturn os.Remove(trashinfoFile.Name())\n\t}\n\n\tif _, err := trashinfoFile.WriteString(i.String()); err != nil {\n\t\t_ = deleteFn()\n\t\treturn \"\", nil, fmt.Errorf(\"write failed: %w\", err)\n\t}\n\n\treturn saveName, deleteFn, nil\n}\n\n// Do not escape '/'\n// Escape ' ' as '%20', not '+'\nfunc queryEscapePath(s string) string {\n\t// do not escape '/'\n\ta := strings.Split(s, \"/\")\n\tfor i := 0; i < len(a); i++ {\n\t\t// escape ' ' as %20 instead of '+'\n\t\tb := strings.Split(a[i], \" \")\n\t\tfor j := 0; j < len(b); j++ {\n\t\t\tb[j] = url.QueryEscape(b[j])\n\t\t}\n\t\ta[i] = strings.Join(b, \"%20\")\n\t}\n\treturn strings.Join(a, \"/\")\n}\n"
  },
  {
    "path": "internal/xdg/trashinfo_test.go",
    "content": "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/stretchr/testify/require\"\n)\n\nfunc TestNewInfoSuccess(t *testing.T) {\n\twantInfo := Info{\n\t\tPath: \"/dummy\",\n\t}\n\tdate, err := time.ParseInLocation(timeFormat, \"2023-01-01T00:00:00\", time.Local)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\twantInfo.DeletionDate = date\n\n\tt.Run(\"normal\", func(t *testing.T) {\n\t\tinfo, err := NewInfo(strings.NewReader(`[Trash Info]\nPath=/dummy\nDeletionDate=2023-01-01T00:00:00\n`))\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, wantInfo, info)\n\t})\n\n\tt.Run(\"ignore_comment_and_blankline\", func(t *testing.T) {\n\t\tinfo, err := NewInfo(strings.NewReader(`# comment 1\n[Trash Info]\n\nPath=/dummy\n\n# comment 2\n\nDeletionDate=2023-01-01T00:00:00\n`))\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, wantInfo, info)\n\t})\n\n\tt.Run(\"contain_space_between_key_value\", func(t *testing.T) {\n\t\tinfo, err := NewInfo(strings.NewReader(`[Trash Info]\nDeletionDate = 2023-01-01T00:00:00\nPath = /dummy`))\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, wantInfo, info)\n\t})\n\n\t// xdg ref: If a string that starts with “Path=” or “DeletionDate=” occurs\n\t// several times, the first occurence is to be used.\n\tt.Run(\"high_priority_to_first_key_pair\", func(t *testing.T) {\n\t\tinfo, err := NewInfo(strings.NewReader(`[Trash Info]\nPath=/dummy\nDeletionDate=2023-01-01T00:00:00\nDeletionDate=2099-01-01T00:00:00\nPath=/notused\n`))\n\t\trequire.NoError(t, err)\n\t\tassert.Equal(t, wantInfo, info)\n\t})\n}\n\nfunc TestNewInfoError(t *testing.T) {\n\tt.Run(\"detect_other_group\", func(t *testing.T) {\n\t\t_, err := NewInfo(strings.NewReader(`[Trash Info]\nPath=/dummy\n[dummy group]\nDeletionDate=2023-01-01T00:00:00\n`))\n\t\trequire.Error(t, err)\n\t})\n}\n\nfunc TestQueryEscape(t *testing.T) {\n\ttests := []struct {\n\t\tinput string\n\t\twant  string\n\t}{\n\t\t{\"/foo/bar\", \"/foo/bar\"},\n\t\t{\"/foo/foo bar\", \"/foo/foo%20bar\"},\n\t\t{\"/foo/b  a  r\", \"/foo/b%20%20a%20%20r\"},\n\t\t{\"/foo/あ い\", \"/foo/%E3%81%82%20%E3%81%84\"},\n\t\t{\"/foo/mycool+blog&about,stuff\", \"/foo/mycool%2Bblog%26about%2Cstuff\"},\n\t}\n\n\tt.Run(\"escape\", func(t *testing.T) {\n\t\tfor _, tt := range tests {\n\t\t\tassert.Equal(t, tt.want, queryEscapePath(tt.input))\n\t\t}\n\t})\n\n\tt.Run(\"unescape\", func(t *testing.T) {\n\t\tfor _, tt := range tests {\n\t\t\te, err := url.QueryUnescape(tt.want)\n\t\t\trequire.NoError(t, err)\n\t\t\tassert.Equal(t, e, tt.input)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "itest/cli_test.go",
    "content": "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) {\n\tif _, err := os.Stat(\"/.dockerenv\"); err != nil {\n\t\tlog.Println(\"please execute on docker enviornment.\")\n\t\tos.Exit(1)\n\t}\n\tret := m.Run()\n\tos.Exit(ret)\n}\n\nfunc checkFileMoved(t *testing.T, from string, to string) {\n\tt.Helper()\n\n\tif _, err := os.Stat(from); err == nil {\n\t\tt.Errorf(\"from still exists. from=%q\", from)\n\t}\n\n\tif _, err := os.Stat(to); err != nil {\n\t\tt.Errorf(\"to not found. to=%q\", to)\n\t}\n}\n\nfunc mustError(t testing.TB, err error, msg ...string) {\n\tt.Helper()\n\n\tif err == nil {\n\t\tif len(msg) == 0 {\n\t\t\tt.Fatalf(\"Received unexpected error: %v\", err)\n\t\t} else {\n\t\t\tt.Fatalf(\"Received unexpected error: %s: %v\", msg[0], err)\n\t\t}\n\t}\n}\n\nfunc mustNoError(t testing.TB, err error, msg ...string) {\n\tt.Helper()\n\n\tif err != nil {\n\t\tif len(msg) == 0 {\n\t\t\tt.Fatalf(\"Received unexpected error: %v\", err)\n\t\t} else {\n\t\t\tt.Fatalf(\"Received unexpected error: %s: %v\", msg[0], err)\n\t\t}\n\t}\n}\n\nfunc assertEmpty(t *testing.T, s string) {\n\tt.Helper()\n\n\tif s != \"\" {\n\t\tt.Errorf(\"Received non empty value: %v\", s)\n\t}\n}\n\nfunc assertContains(t *testing.T, s string, substr string, msg ...string) {\n\tt.Helper()\n\n\tif !strings.Contains(s, substr) {\n\t\tif len(msg) > 0 {\n\t\t\tt.Logf(msg[0])\n\t\t}\n\t\tt.Errorf(\"%q does not contain %q\", s, substr)\n\t}\n}\n\nfunc assertEqual(t *testing.T, got string, want string, msg ...string) {\n\tt.Helper()\n\n\tif want != got {\n\t\tif len(msg) > 0 {\n\t\t\tt.Logf(msg[0])\n\t\t}\n\t\tt.Errorf(\"does not match\\nExpected:\\n\\t%q\\nActual:\\n\\t%q\\n\", want, got)\n\t}\n}\n"
  },
  {
    "path": "itest/put_test.go",
    "content": "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// gtrash: refusing to remove '.' or '..' directory: skipping \".\"\nfunc TestSkipDotEndingPath(t *testing.T) {\n\tpaths := []string{\".\", \"./\", \"..\", \"../\", \"../.\"}\n\n\tfor _, path := range paths {\n\t\tt.Run(fmt.Sprintf(\"skipped path %q\", path), func(t *testing.T) {\n\n\t\t\tcmd := exec.Command(execBinary, \"put\", path)\n\t\t\tout, err := cmd.CombinedOutput()\n\t\t\tmustError(t, err)\n\t\t\tassertContains(t, string(out), \"refusing to remove '.' or '..' directory\")\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "itest/setup.sh",
    "content": "#!/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 /tmp/external\n# mount -t tmpfs external_alt /tmp/external_alt\n#\n# Create .Trash folder beforehand\nmkdir -p \"/external/.Trash\"\nmkdir -p \"/external_alt/.Trash\"\n\nchmod a+rw /external/.Trash /external_alt/.Trash\n\n# sticky bit set only in /external\nchmod +t /external/.Trash\n"
  },
  {
    "path": "itest/trash_test.go",
    "content": "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/.local/share/Trash\"\n\n\tEXTERNAL_ROOT     = \"/external\"\n\tEXTERNAL_ALT_ROOT = \"/external_alt\"\n\n\tEXTERNAL_TRASH     = filepath.Join(EXTERNAL_ROOT, \".Trash\", strconv.Itoa(os.Getuid()))\n\tEXTERNAL_ALT_TRASH = filepath.Join(EXTERNAL_ALT_ROOT, fmt.Sprintf(\".Trash-%d\", os.Getuid()))\n)\n\n// remove all trash\nfunc cleanTrash(t *testing.T) {\n\tt.Helper()\n\n\t// clean home trash\n\terr := os.RemoveAll(HOME_TRASH)\n\tmustNoError(t, err)\n\n\t// clean external trash\n\terr = os.RemoveAll(EXTERNAL_TRASH)\n\tmustNoError(t, err)\n\n\t// clean external_alt trash\n\terr = os.RemoveAll(EXTERNAL_ALT_TRASH)\n\tmustNoError(t, err)\n}\n\nfunc TestTrashAllType(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tfileDir  string\n\t\ttrashDir string\n\t}{\n\t\t{name: \"HOME_TRASH\", fileDir: \"\", trashDir: HOME_TRASH}, // use /tmp\n\t\t{name: \"EXTERNAL_TRASH\", fileDir: EXTERNAL_ROOT, trashDir: EXTERNAL_TRASH},\n\t\t{name: \"EXTERNAL_ALT_TRASH\", fileDir: EXTERNAL_ALT_ROOT, trashDir: EXTERNAL_ALT_TRASH},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tcleanTrash(t)\n\n\t\t\tf, err := os.CreateTemp(tt.fileDir, \"foo\")\n\t\t\tmustNoError(t, err)\n\t\t\ttrashFilePath := filepath.Join(tt.trashDir, \"files\", filepath.Base(f.Name()))\n\n\t\t\t// 1. should be trashed to specific type trashDir\n\t\t\tcmd := exec.Command(execBinary, \"put\", f.Name())\n\t\t\tout, err := cmd.CombinedOutput()\n\t\t\tmustNoError(t, err)\n\t\t\tassertEmpty(t, string(out))\n\n\t\t\tcheckFileMoved(t, f.Name(), trashFilePath)\n\n\t\t\t// 2. should list trashed file\n\t\t\tcmd = exec.Command(execBinary, \"find\")\n\t\t\tout, err = cmd.CombinedOutput()\n\t\t\tmustNoError(t, err, string(out))\n\t\t\tassertContains(t, string(out), f.Name(), \"it should list deleted file\")\n\n\t\t\t// 3. should show summary\n\t\t\tcmd = exec.Command(execBinary, \"summary\")\n\t\t\tout, err = cmd.CombinedOutput()\n\t\t\tmustNoError(t, err, string(out))\n\t\t\tassertEqual(t, string(out), fmt.Sprintf(\"[%s]\\nitem: 1\\nsize: 0 B\\n\", tt.trashDir))\n\n\t\t\t// 4. should be restored to original path\n\t\t\tcmd = exec.Command(execBinary, \"restore\", f.Name())\n\t\t\tout, err = cmd.CombinedOutput()\n\t\t\tmustNoError(t, err, string(out))\n\t\t\tassertContains(t, string(out), \"Restored 1/1 trashed files\")\n\t\t\tcheckFileMoved(t, trashFilePath, f.Name())\n\n\t\t\t// 5. should not list restored file\n\t\t\tcmd = exec.Command(execBinary, \"find\")\n\t\t\tout, err = cmd.CombinedOutput()\n\t\t\tmustError(t, err, string(out))\n\t\t\tassertContains(t, string(out), \"not found: trashed files\", \"should not list deleted file\")\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport (\n\t\"github.com/umlx5h/gtrash/internal/cmd\"\n)\n\n// set by CI\nvar (\n\tversion = \"unknown\"\n\tcommit  = \"unknown\"\n\tdate    = \"unknown\"\n\tbuiltBy = \"unknown\"\n)\n\nfunc main() {\n\tcmd.Execute(cmd.Version{\n\t\tVersion: version,\n\t\tCommit:  commit,\n\t\tDate:    date,\n\t\tBuiltBy: builtBy,\n\t})\n}\n"
  }
]