pax_global_header00006660000000000000000000000064151744745550014533gustar00rootroot0000000000000052 comment=8d64b4f1ef50bf6694cd17e060f9fd80aed3e297 gdu-5.36.1/000077500000000000000000000000001517447455500124065ustar00rootroot00000000000000gdu-5.36.1/.github/000077500000000000000000000000001517447455500137465ustar00rootroot00000000000000gdu-5.36.1/.github/ISSUE_TEMPLATE/000077500000000000000000000000001517447455500161315ustar00rootroot00000000000000gdu-5.36.1/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000012371517447455500206260ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: '' assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **System (please complete the following information):** - OS: [e.g. ArchLinux] - Terminal [e.g. xTerm, Guake] - Version [e.g. 5.22] **Additional context** Add any other context about the problem here. gdu-5.36.1/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000011231517447455500216530ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project title: '' labels: '' assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. gdu-5.36.1/.github/dependabot.yml000066400000000000000000000003151517447455500165750ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" - package-ecosystem: "gomod" directory: "/" schedule: interval: "daily" gdu-5.36.1/.github/workflows/000077500000000000000000000000001517447455500160035ustar00rootroot00000000000000gdu-5.36.1/.github/workflows/codeql-analysis.yml000066400000000000000000000044641517447455500216260ustar00rootroot00000000000000# For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ master ] pull_request: # The branches below must be a subset of the branches above branches: [ master ] schedule: - cron: '21 0 * * 3' jobs: analyze: name: Analyze runs-on: ubuntu-latest strategy: fail-fast: false matrix: language: [ 'go' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] # Learn more: # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed steps: - name: Checkout repository uses: actions/checkout@v6 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 gdu-5.36.1/.github/workflows/docker.yml000066400000000000000000000016071517447455500200010ustar00rootroot00000000000000name: Docker on: workflow_dispatch: push: branches: - 'master' tags: - 'v*' env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: docker: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - name: Login to registry uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker meta id: meta uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - name: Build and push uses: docker/build-push-action@v7 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} gdu-5.36.1/.github/workflows/release.yml000066400000000000000000000024201517447455500201440ustar00rootroot00000000000000name: Release on: push: tags: - 'v*' jobs: release: runs-on: ubuntu-latest steps: - name: Install Go uses: actions/setup-go@v6 with: go-version: 1.26.x - name: Checkout uses: actions/checkout@v6 - name: Run tests run: go test -v -covermode=count ./... - name: Install tooling run: go install github.com/mitchellh/gox@latest - name: Import GPG key run: | echo "$GPG_PRIVATE_KEY" | gpg --batch --import echo "allow-loopback-pinentry" >> ~/.gnupg/gpg-agent.conf echo "use-agent" >> ~/.gnupg/gpg.conf env: GPG_PRIVATE_KEY: ${{ secrets.PRIVATE_GPG_KEY }} - name: Build run: | make clean make tarball make build-all make man make clean-uncompressed-dist cd dist sha256sum * > sha256sums.txt gpg --yes --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" --sign --armor --detach-sign sha256sums.txt env: GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - name: Release uses: softprops/action-gh-release@v3 with: draft: true files: | dist/* generate_release_notes: truegdu-5.36.1/.github/workflows/test.yml000066400000000000000000000026621517447455500175130ustar00rootroot00000000000000on: push: branches: - master pull_request: branches: - master name: run tests jobs: lint: runs-on: ubuntu-latest steps: - name: Install Go uses: actions/setup-go@v6 with: go-version: 1.26.x - name: Checkout code uses: actions/checkout@v6 - name: Run linters uses: golangci/golangci-lint-action@v9 with: version: v2.11.2 test: strategy: matrix: go-version: [1.25.x, 1.26.x] platform: [ubuntu-latest] include: - go-version: 1.26.x platform: macos-latest runs-on: ${{ matrix.platform }} steps: - name: Install Go if: success() uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} - name: Checkout code uses: actions/checkout@v6 - name: Run tests run: go test -v -covermode=count ./... coverage: runs-on: ubuntu-latest steps: - name: Install Go if: success() uses: actions/setup-go@v6 with: go-version: 1.26.x - name: Checkout code uses: actions/checkout@v6 - name: Calc coverage run: | go test -v -race -covermode=atomic -coverprofile=coverage.out ./... - name: Upload coverage report uses: codecov/codecov-action@v6 with: files: ./coverage.out fail_ci_if_error: true verbose: true token: ${{ secrets.CODECOV_TOKEN }} gdu-5.36.1/.github/workflows/winget.yml000066400000000000000000000004671517447455500200320ustar00rootroot00000000000000name: Publish to Winget on: release: types: [released] jobs: publish: runs-on: ubuntu-latest steps: - uses: vedantmgoyal2009/winget-releaser@v2 with: identifier: dundee.gdu installers-regex: '_windows_[\w.]+\.zip$' token: ${{ secrets.WINGET_TOKEN }} gdu-5.36.1/.gitignore000066400000000000000000000001411517447455500143720ustar00rootroot00000000000000/.vscode /.idea /coverage.txt /coverage.out /coverage.html /dist /test_dir /tui/test_dir /vendor gdu-5.36.1/.golangci.yml000066400000000000000000000057311517447455500150000ustar00rootroot00000000000000version: "2" output: formats: text: path: stdout linters: default: none enable: - bodyclose - copyloopvar - dogsled - errcheck - errorlint - exhaustive - funlen - goconst - gocritic - gocyclo - govet - ineffassign - lll - nakedret - revive - staticcheck - unparam - unused - whitespace settings: dupl: threshold: 100 errcheck: check-blank: true funlen: lines: 500 statements: 50 goconst: min-len: 3 min-occurrences: 3 gocritic: disabled-checks: - whyNoLint enabled-tags: - diagnostic - experimental - opinionated - performance - style gocyclo: min-complexity: 25 govet: enable: - shadow lll: line-length: 160 revive: rules: - name: blank-imports - name: context-as-argument - name: context-keys-type - name: dot-imports - name: error-return - name: error-strings - name: error-naming - name: exported - name: increment-decrement - name: var-naming - name: var-declaration - name: package-comments - name: range - name: receiver-naming - name: time-naming - name: unexported-return - name: indent-error-flow - name: errorf - name: empty-block - name: superfluous-else - name: unreachable-code - name: redefines-builtin-id # While we agree with this rule, right now it would break too many # projects. So, we disable it by default. - name: unused-parameter disabled: true exclusions: generated: lax presets: - comments - common-false-positives - legacy - std-error-handling rules: - linters: - errcheck - funlen - gochecknoglobals # Globals in test files are tolerated. - goconst # Repeated consts in test files are tolerated. - gocritic - gocyclo - gosec path: _test\.go # This rule is buggy and breaks on our `///Block` lines. Disable for now. - linters: - gocritic text: 'commentFormatting: put a space' # This rule incorrectly flags nil references after assert.Assert(t, x != nil) - linters: - staticcheck path: _test\.go text: SA5011 - linters: - lll source: '^//go:generate ' - linters: - gocritic - lll path: \.resolvers\.go source: '^func \(r \*[a-zA-Z]+Resolvers\) ' - path: (.+)\.go$ # We allow error shadowing text: declaration of "err" shadows declaration at paths: - third_party$ - builtin$ - examples$ formatters: enable: - gofmt - goimports exclusions: generated: lax paths: - third_party$ - builtin$ - examples$ gdu-5.36.1/.tito/000077500000000000000000000000001517447455500134435ustar00rootroot00000000000000gdu-5.36.1/.tito/packages/000077500000000000000000000000001517447455500152215ustar00rootroot00000000000000gdu-5.36.1/.tito/packages/.readme000066400000000000000000000002371517447455500164610ustar00rootroot00000000000000the .tito/packages directory contains metadata files named after their packages. Each file has the latest tagged version and the project's relative directory. gdu-5.36.1/.tito/packages/gdu000066400000000000000000000000141517447455500157160ustar00rootroot000000000000005.25.0-1 ./ gdu-5.36.1/.tito/tito.props000066400000000000000000000002231517447455500155040ustar00rootroot00000000000000[buildconfig] builder = tito.builder.Builder tagger = tito.tagger.VersionTagger changelog_do_not_remove_cherrypick = 0 changelog_format = %s (%ae) gdu-5.36.1/.tool-versions000066400000000000000000000000161517447455500152270ustar00rootroot00000000000000golang 1.26.1 gdu-5.36.1/Dockerfile000066400000000000000000000003341517447455500144000ustar00rootroot00000000000000FROM docker.io/library/golang:1.26.1 as builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN make build-static FROM scratch COPY --from=builder /app/dist/gdu /opt/gdu ENTRYPOINT ["/opt/gdu"] gdu-5.36.1/INSTALL.md000066400000000000000000000102101517447455500140300ustar00rootroot00000000000000# Installation [Arch Linux](https://archlinux.org/packages/extra/x86_64/gdu/): pacman -S gdu [Conda Forge](https://github.com/conda-forge/gdu-feedstock) conda install gdu [Debian](https://packages.debian.org/bullseye/gdu): apt install gdu [Ubuntu](https://launchpad.net/~daniel-milde/+archive/ubuntu/gdu) add-apt-repository ppa:daniel-milde/gdu apt-get update apt-get install gdu [NixOS](https://search.nixos.org/packages?channel=unstable&show=gdu&query=gdu): nix-env -iA nixos.gdu [Homebrew](https://formulae.brew.sh/formula/gdu): brew install -f gdu # gdu will be installed as `gdu-go` to avoid conflicts with coreutils gdu-go [Mise](https://github.com/jdx/mise): mise use -g gdu@latest [Snap](https://snapcraft.io/gdu-disk-usage-analyzer): snap install gdu-disk-usage-analyzer snap connect gdu-disk-usage-analyzer:mount-observe :mount-observe snap connect gdu-disk-usage-analyzer:system-backup :system-backup snap alias gdu-disk-usage-analyzer.gdu gdu [Binenv](https://github.com/devops-works/binenv) binenv install gdu [Go](https://pkg.go.dev/github.com/dundee/gdu): go install github.com/dundee/gdu/v5/cmd/gdu@latest [Winget](https://github.com/microsoft/winget-pkgs/tree/master/manifests/d/dundee/gdu) (for Windows users): winget install gdu You can either run it as `gdu_windows_amd64.exe` or * add an alias with `Doskey`. * add `alias gdu="gdu_windows_amd64.exe"` to your `~/.bashrc` file if using Git Bash to run it as `gdu`. You might need to restart your terminal. [Scoop](https://github.com/ScoopInstaller/Main/blob/master/bucket/gdu.json): scoop install gdu [X-cmd](https://www.x-cmd.com/start/) x env use gdu ## [COPR builds](https://copr.fedorainfracloud.org/coprs/faramirza/gdu/) COPR Builds exist for the the following Linux Distros. [How to enable a CORP Repo](https://docs.pagure.org/copr.copr/how_to_enable_repo.html) Amazon Linux 2023: ``` [copr:copr.fedorainfracloud.org:faramirza:gdu] name=Copr repo for gdu owned by faramirza baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/amazonlinux-2023-$basearch/ type=rpm-md skip_if_unavailable=True gpgcheck=1 gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg repo_gpgcheck=0 enabled=1 enabled_metadata=1 ``` EPEL 7: ``` [copr:copr.fedorainfracloud.org:faramirza:gdu] name=Copr repo for gdu owned by faramirza baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/epel-7-$basearch/ type=rpm-md skip_if_unavailable=True gpgcheck=1 gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg repo_gpgcheck=0 enabled=1 enabled_metadata=1 ``` EPEL 8: ``` [copr:copr.fedorainfracloud.org:faramirza:gdu] name=Copr repo for gdu owned by faramirza baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/epel-8-$basearch/ type=rpm-md skip_if_unavailable=True gpgcheck=1 gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg repo_gpgcheck=0 enabled=1 enabled_metadata=1 ``` EPEL 9: ``` [copr:copr.fedorainfracloud.org:faramirza:gdu] name=Copr repo for gdu owned by faramirza baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/epel-9-$basearch/ type=rpm-md skip_if_unavailable=True gpgcheck=1 gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg repo_gpgcheck=0 enabled=1 enabled_metadata=1 ``` Fedora 38: ``` [copr:copr.fedorainfracloud.org:faramirza:gdu] name=Copr repo for gdu owned by faramirza baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/fedora-$releasever-$basearch/ type=rpm-md skip_if_unavailable=True gpgcheck=1 gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg repo_gpgcheck=0 enabled=1 enabled_metadata=1 ``` Fedora 39: ``` [copr:copr.fedorainfracloud.org:faramirza:gdu] name=Copr repo for gdu owned by faramirza baseurl=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/fedora-$releasever-$basearch/ type=rpm-md skip_if_unavailable=True gpgcheck=1 gpgkey=https://download.copr.fedorainfracloud.org/results/faramirza/gdu/pubkey.gpg repo_gpgcheck=0 enabled=1 enabled_metadata=1 ``` gdu-5.36.1/LICENSE.md000066400000000000000000000020641517447455500140140ustar00rootroot00000000000000Copyright 2020-2021 Daniel Milde Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. gdu-5.36.1/Makefile000066400000000000000000000146661517447455500140630ustar00rootroot00000000000000NAME := gdu MAJOR_VER := v5 PACKAGE := github.com/dundee/$(NAME)/$(MAJOR_VER) CMD_GDU := cmd/gdu VERSION := $(shell git describe --tags 2>/dev/null) NAMEVER := $(NAME)-$(subst v,,$(VERSION)) DATE := $(shell date +'%Y-%m-%d') GOBIN := go GOFLAGS ?= -buildmode=pie -trimpath -mod=readonly -modcacherw -pgo=default.pgo GOFLAGS_STATIC ?= -trimpath -mod=readonly -modcacherw -pgo=default.pgo LDFLAGS := -s -w -extldflags '-static' \ -X '$(PACKAGE)/build.Version=$(VERSION)' \ -X '$(PACKAGE)/build.User=$(shell id -u -n)' \ -X '$(PACKAGE)/build.Time=$(shell LC_ALL=en_US.UTF-8 date)' TAR := tar ifeq ($(shell uname -s),Darwin) TAR := gtar # brew install gnu-tar endif all: clean tarball build-all build-docker man clean-uncompressed-dist shasums run: go run $(PACKAGE)/$(CMD_GDU) vendor: go.mod go.sum go mod vendor tarball: vendor -mkdir dist $(TAR) czf dist/$(NAMEVER).tgz --transform "s,^,$(NAMEVER)/," --exclude dist --exclude test_dir --exclude coverage.txt * build: @echo "Version: " $(VERSION) mkdir -p dist GOFLAGS="$(GOFLAGS)" CGO_ENABLED=0 $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/$(NAME) $(PACKAGE)/$(CMD_GDU) build-static: @echo "Version: " $(VERSION) mkdir -p dist GOFLAGS="$(GOFLAGS_STATIC)" CGO_ENABLED=0 $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/$(NAME) $(PACKAGE)/$(CMD_GDU) build-docker: @echo "Version: " $(VERSION) docker build . --tag ghcr.io/dundee/gdu:$(VERSION) build-all: @echo "Version: " $(VERSION) -mkdir dist CGO_ENABLED=0 gox \ -os="darwin" \ -arch="amd64 arm64" \ -output="dist/gdu_{{.OS}}_{{.Arch}}" \ -ldflags="$(LDFLAGS)" \ $(PACKAGE)/$(CMD_GDU) CGO_ENABLED=0 gox \ -os="windows" \ -arch="amd64" \ -output="dist/gdu_{{.OS}}_{{.Arch}}" \ -ldflags="$(LDFLAGS)" \ $(PACKAGE)/$(CMD_GDU) CGO_ENABLED=0 gox \ -os="linux freebsd netbsd openbsd" \ -output="dist/gdu_{{.OS}}_{{.Arch}}" \ -ldflags="$(LDFLAGS)" \ $(PACKAGE)/$(CMD_GDU) GOFLAGS="$(GOFLAGS)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_amd64 $(PACKAGE)/$(CMD_GDU) GOFLAGS="$(GOFLAGS_STATIC)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_amd64_static $(PACKAGE)/$(CMD_GDU) CGO_ENABLED=0 GOOS=linux GOARM=5 GOARCH=arm $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_armv5l $(PACKAGE)/$(CMD_GDU) CGO_ENABLED=0 GOOS=linux GOARM=6 GOARCH=arm $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_armv6l $(PACKAGE)/$(CMD_GDU) CGO_ENABLED=0 GOOS=linux GOARM=7 GOARCH=arm $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_armv7l $(PACKAGE)/$(CMD_GDU) CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/gdu_linux_arm64 $(PACKAGE)/$(CMD_GDU) CGO_ENABLED=0 GOOS=android GOARCH=arm64 $(GOBIN) build -ldflags="$(LDFLAGS)" -o dist/gdu_android_arm64 $(PACKAGE)/$(CMD_GDU) cd dist; for file in gdu_linux_* gdu_darwin_* gdu_netbsd_* gdu_openbsd_* gdu_freebsd_* gdu_android_*; do tar czf $$file.tgz $$file; done cd dist; for file in gdu_windows_*; do zip $$file.zip $$file; done gdu.1: gdu.1.md sed 's/{{date}}/$(DATE)/g' gdu.1.md > gdu.1.date.md pandoc gdu.1.date.md -s -t man > gdu.1 rm -f gdu.1.date.md man: gdu.1 cp gdu.1 dist cd dist; tar czf gdu.1.tgz gdu.1 show-man: sed 's/{{date}}/$(DATE)/g' gdu.1.md > gdu.1.date.md pandoc gdu.1.date.md -s -t man | man -l - test: gotestsum coverage: gotestsum -- -race -coverprofile=coverage.txt -covermode=atomic ./... coverage-html: coverage $(GOBIN) tool cover -html=coverage.txt gobench: $(GOBIN) test -bench=. $(PACKAGE)/pkg/analyze heap-profile: $(GOBIN) tool pprof -web http://localhost:6060/debug/pprof/heap pgo: wget -O cpu.pprof http://localhost:6060/debug/pprof/profile?seconds=30 $(GOBIN) tool pprof -proto cpu.pprof default.pgo > merged.pprof mv merged.pprof default.pgo trace: wget -O trace.out http://localhost:6060/debug/pprof/trace?seconds=30 gotraceui ./trace.out profile: wget -O cpu.pprof http://localhost:6060/debug/pprof/profile?seconds=30 $(GOBIN) tool pprof -web cpu.pprof benchmark: sudo cpupower frequency-set -g performance hyperfine --export-markdown=docs/benchmarks/bench-cold.md \ --prepare 'sync; echo 3 | sudo tee /proc/sys/vm/drop_caches' \ --ignore-failure \ 'dua ~' 'duc index ~' 'ncdu -0 -o /dev/null ~' \ 'diskus ~' 'du -hs ~' 'dust -d0 ~' 'pdu ~' \ 'gdu -npc ~' \ 'gdu -npc --db=tmp.badger ~' \ 'gdu -npc --db=tmp.db ~' \ 'GOMAXPROCS=80 gdu -npc ~' hyperfine --export-markdown=docs/benchmarks/bench-warm.md \ --warmup 5 \ --ignore-failure \ 'dua ~' 'duc index ~' 'ncdu -0 -o /dev/null ~' \ 'diskus ~' 'du -hs ~' 'dust -d0 ~' 'pdu ~' \ 'gdu -npc ~' \ 'gdu -npc --db=tmp.badger ~' \ 'gdu -npc --db=tmp.db ~' \ 'GOMAXPROCS=100 gdu -npc ~' hyperfine -M 1 --export-markdown=docs/benchmarks/bench-cold-big.md \ --prepare 'sync; echo 3 | sudo tee /proc/sys/vm/drop_caches' \ --ignore-failure \ 'dua /' \ 'diskus /' 'dust -d0 /' \ 'gdu -npc /' 'GOMAXPROCS=5 gdu -npc /' sudo cpupower frequency-set -g schedutil benchmark-parameter-scan: sudo cpupower frequency-set -g performance hyperfine --export-markdown=docs/benchmarks/bench-cold-param-scan.md \ --prepare 'sync; echo 3 | sudo tee /proc/sys/vm/drop_caches' \ --ignore-failure \ -P procs 1 120 -D 10 \ 'GOMAXPROCS={procs} gdu -npc ~' hyperfine --export-markdown=docs/benchmarks/bench-warm-param-scan.md \ --warmup 5 \ --ignore-failure \ -P procs 90 200 -D 10 \ 'GOMAXPROCS={procs} gdu -npc ~' hyperfine -M 1 --export-markdown=docs/benchmarks/bench-cold-big-param-scan.md \ --prepare 'sync; echo 3 | sudo tee /proc/sys/vm/drop_caches' \ --ignore-failure \ -P procs 1 10 \ 'GOMAXPROCS={procs} gdu -npc /' sudo cpupower frequency-set -g schedutil lint: golangci-lint run -c .golangci.yml clean: $(GOBIN) mod tidy -rm coverage.txt -rm -r test_dir -rm -r vendor -rm -r dist clean-uncompressed-dist: find dist -type f -not -name '*.tgz' -not -name '*.zip' -delete shasums: cd dist; sha256sum * > sha256sums.txt cd dist; gpg --sign --armor --detach-sign sha256sums.txt release: gh release create -t "gdu $(VERSION)" $(VERSION) ./dist/* install-dev-dependencies: $(GOBIN) install gotest.tools/gotestsum@latest $(GOBIN) install github.com/mitchellh/gox@latest $(GOBIN) install honnef.co/go/gotraceui/cmd/gotraceui@latest $(GOBIN) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.2 .PHONY: run build build-static build-all test gobench benchmark coverage coverage-html clean clean-uncompressed-dist man show-man release dev-build gdu-5.36.1/README.md000066400000000000000000000354151517447455500136750ustar00rootroot00000000000000# go DiskUsage() Gdu [![Codecov](https://codecov.io/gh/dundee/gdu/branch/master/graph/badge.svg)](https://codecov.io/gh/dundee/gdu) [![Go Report Card](https://goreportcard.com/badge/github.com/dundee/gdu)](https://goreportcard.com/report/github.com/dundee/gdu) [![Maintainability](https://api.codeclimate.com/v1/badges/30d793274607f599e658/maintainability)](https://codeclimate.com/github/dundee/gdu/maintainability) [![CodeScene Code Health](https://codescene.io/projects/13129/status-badges/code-health)](https://codescene.io/projects/13129) Pretty fast disk usage analyzer written in Go. Gdu is intended primarily for SSD disks where it can fully utilize parallel processing. However HDDs work as well, but the performance gain is not so huge. [![asciicast](https://asciinema.org/a/382738.svg)](https://asciinema.org/a/382738) Packaging status ## Installation Head for the [releases page](https://github.com/dundee/gdu/releases) and download the binary for your system. Using curl: curl -L https://github.com/dundee/gdu/releases/latest/download/gdu_linux_amd64.tgz | tar xz chmod +x gdu_linux_amd64 mv gdu_linux_amd64 /usr/bin/gdu See the [installation page](./INSTALL.md) for other ways how to install Gdu to your system. Or you can use Gdu directly via Docker: docker run --rm --init --interactive --tty --privileged --volume /:/mnt/root ghcr.io/dundee/gdu /mnt/root ## Usage ``` gdu [flags] [directory_to_scan] Flags: --archive-browsing Enable browsing of zip/jar/tar archives (tar, tar.gz, tar.bz2, tar.xz) --collapse-path Collapse single-child directory chains --config-file string Read config from file (default is $HOME/.gdu.yaml) -D, --db string Store analysis in database (*.sqlite for SQLite, *.badger for BadgerDB) --depth int Show directory structure up to specified depth in non-interactive mode (0 means the flag is ignored) --enable-profiling Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/ -E, --exclude-type strings File types to exclude (e.g., --exclude-type yaml,json) -L, --follow-symlinks Follow symlinks for files, i.e. show the size of the file to which symlink points to (symlinks to directories are not followed) -h, --help help for gdu -i, --ignore-dirs strings Paths to ignore (separated by comma). Can be absolute or relative to current directory (default [/proc,/dev,/sys,/run]) -I, --ignore-dirs-pattern strings Path patterns to ignore (separated by comma) -X, --ignore-from string Read path patterns to ignore from file -f, --input-file string Import analysis from JSON file --interactive Force interactive mode even when output is not a TTY -l, --log-file string Path to a logfile (default "/dev/null") --max-age string Include files with mtime no older than DURATION (e.g., 7d, 2h30m, 1y2mo) -m, --max-cores int Set max cores that Gdu will use. 8 cores available (default 8) --min-age string Include files with mtime at least DURATION old (e.g., 30d, 1w) --mouse Use mouse -c, --no-color Do not use colorized output -x, --no-cross Do not cross filesystem boundaries --no-delete Do not allow deletions -H, --no-hidden Ignore hidden directories (beginning with dot) --no-prefix Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode -p, --no-progress Do not show progress in non-interactive mode --no-spawn-shell Do not allow spawning shell -u, --no-unicode Do not use Unicode symbols (for size bar) --no-view-file Do not allow viewing file contents -n, --non-interactive Do not run in interactive mode -o, --output-file string Export all info into file as JSON -r, --read-from-storage Use existing database instead of re-scanning --reverse-sort Reverse sorting order (smallest to largest) in non-interactive mode --sequential Use sequential scanning (intended for rotating HDDs) -A, --show-annexed-size Use apparent size of git-annex'ed files in case files are not present locally (real usage is zero) -a, --show-apparent-size Show apparent size -d, --show-disks Show all mounted disks -k, --show-in-kib Show sizes in KiB (or kB with --si) in non-interactive mode -C, --show-item-count Show number of items in directory -M, --show-mtime Show latest mtime of items in directory -B, --show-relative-size Show relative size --si Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB) --since string Include files with mtime >= WHEN. WHEN accepts RFC3339 timestamp (e.g., 2025-08-11T01:00:00-07:00) or date only YYYY-MM-DD (calendar-day compare; includes the whole day) -s, --summarize Show only a total in non-interactive mode -t, --top int Show only top X largest files in non-interactive mode -T, --type strings File types to include (e.g., --type yaml,json) --until string Include files with mtime <= WHEN. WHEN accepts RFC3339 timestamp or date only YYYY-MM-DD -v, --version Print version --write-config Write current configuration to file (default is $HOME/.gdu.yaml) Basic list of actions in interactive mode (show help modal for more): ↑ or k Move cursor up ↓ or j Move cursor down → or Enter or l Go to highlighted directory ← or h Go to parent directory d Delete the selected file or directory e Empty the selected directory n Sort by name s Sort by size c Show number of items in directory ? Show help modal ``` ## Examples gdu # analyze current dir gdu -a # show apparent size instead of disk usage gdu --no-delete # prevent write operations gdu --no-view-file # prevent viewing file contents gdu # analyze given dir gdu -d # show all mounted disks gdu -l ./gdu.log # write errors to log file gdu -i /sys,/proc / # ignore some paths gdu -I '.*[abc]+' # ignore paths by regular pattern gdu -X ignore_file / # ignore paths by regular patterns from file gdu -c / # use only white/gray/black colors gdu -n / # only print stats, do not start interactive mode gdu --interactive / | tee out.txt # force interactive mode even when stdout is piped gdu -p / # do not show progress, useful when using its output in a script gdu -ps /some/dir # show only total usage for given dir gdu -t 10 / # show top 10 largest files gdu --reverse-sort -n / # show files sorted from smallest to largest in non-interactive mode gdu / > file # write stats to file, do not start interactive mode gdu -o- / | gzip -c >report.json.gz # write all info to JSON file for later analysis zcat report.json.gz | gdu -f- # read analysis from file gdu --db=tmp.badger / # use persistent key-value storage for saving analysis data gdu --db=tmp.db / # use persistent SQLite storage for saving analysis data gdu -r / # read saved analysis data from persistent key-value storage ## Modes Gdu has three modes: interactive (default), non-interactive and export. Non-interactive mode is started automatically when TTY is not detected (using [go-isatty](https://github.com/mattn/go-isatty)), for example if the output is being piped to a file, or it can be started explicitly by using a flag. Use `--interactive` to disable this automatic fallback and force interactive mode. In non-interactive mode (and without `--top` and `--depth` flags), gdu uses a memory-efficient analyzer that only tracks top-level directory totals. This means memory usage stays constant regardless of how large the scanned directory tree is. When `--top` or `--depth` flags are used, the full directory tree is built in memory as in interactive mode. Export mode (flag `-o`) outputs all usage data as JSON, which can be later opened using the `-f` flag. Hard links are counted only once. ## File flags Files and directories may be prefixed by a one-character flag with following meaning: * `!` An error occurred while reading this directory. * `.` An error occurred while reading a subdirectory, size may be not correct. * `@` File is symlink or socket. * `H` Same file was already counted (hard link). * `e` Directory is empty. ## Configuration file Gdu can read (and write) YAML configuration file. `$HOME/.config/gdu/gdu.yaml` and `$HOME/.gdu.yaml` are checked for the presence of the config file by default. See the [full list of all configuration options](configuration.md). ### Examples * To configure gdu to permanently run in gray-scale color mode: ``` echo "no-color: true" >> ~/.gdu.yaml ``` * To set default sorting in configuration file: ``` sorting: by: name // size, name, itemCount, mtime order: desc ``` * To configure gdu to set CWD variable when browsing directories: ``` echo "change-cwd: true" >> ~/.gdu.yaml ``` * To save the current configuration ``` gdu --write-config ``` ## Styling There are wide options for how terminals can be colored. Some gdu primitives (like basic text) adapt to different color schemas, but the selected/highlighted row does not. If the default look is not sufficient, it can be changed in configuration file, e.g.: ``` style: selected-row: text-color: black background-color: "#ff0000" ``` ## Deletion in background and in parallel (experimental) Gdu can delete items in the background, thus not blocking the UI for additional work. To enable: ``` echo "delete-in-background: true" >> ~/.gdu.yaml ``` Directory items can be also deleted in parallel, which might increase the speed of deletion. To enable: ``` echo "delete-in-parallel: true" >> ~/.gdu.yaml ``` ## Saving analysis data to database Gdu can store the analysis data to a database file instead of just memory. This allows you to save and reload analysis results later. Both SQLite and BadgerDB are supported. ``` gdu --db analysis.sqlite / # saves analysis data to SQLite database gdu --db analysis.badger / # saves analysis data to BadgerDB gdu -r --db analysis.sqlite / # reads saved data, does not run analysis again ``` ## Running tests make install-dev-dependencies make test ## Profiling Gdu can collect profiling data when the `--enable-profiling` flag is set. The data are provided via embedded http server on URL `http://localhost:6060/debug/pprof/`. You can then use e.g. `go tool pprof -web http://localhost:6060/debug/pprof/heap` to open the heap profile as SVG image in your web browser. ## Benchmarks Benchmarks were performed on 90G directory (100k directories, 400k files) on 500 GB SSD using [hyperfine](https://github.com/sharkdp/hyperfine). See `benchmark` target in [Makefile](Makefile) for more info. ### Cold cache Filesystem cache was cleared using `sync; echo 3 | sudo tee /proc/sys/vm/drop_caches`. | Command | Mean [s] | Min [s] | Max [s] | Relative | |:---|---:|---:|---:|---:| | `diskus ~` | 4.489 ± 0.020 | 4.449 | 4.516 | 1.00 | | `gdu -npc ~` | 4.716 ± 0.342 | 4.109 | 5.337 | 1.05 ± 0.08 | | `GOMAXPROCS=80 gdu -npc ~` | 4.901 ± 1.953 | 3.627 | 9.993 | 1.09 ± 0.44 | | `pdu ~` | 5.969 ± 0.492 | 5.567 | 6.640 | 1.33 ± 0.11 | | `dua ~` | 6.030 ± 0.249 | 5.878 | 6.597 | 1.34 ± 0.06 | | `dust -d0 ~` | 6.181 ± 0.311 | 6.043 | 7.053 | 1.38 ± 0.07 | | `gdu -npc --db=tmp.badger ~` | 27.479 ± 3.015 | 25.048 | 32.777 | 6.12 ± 0.67 | | `du -hs ~` | 30.608 ± 0.221 | 30.136 | 30.794 | 6.82 ± 0.06 | | `duc index ~` | 32.897 ± 3.168 | 31.524 | 41.865 | 7.33 ± 0.71 | | `ncdu -0 -o /dev/null ~` | 33.163 ± 3.482 | 31.476 | 42.979 | 7.39 ± 0.78 | | `gdu -npc --db=tmp.db ~` | 44.989 ± 0.270 | 44.622 | 45.414 | 10.02 ± 0.07 | ### Warm cache | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | |:---|---:|---:|---:|---:| | `diskus ~` | 270.8 ± 8.1 | 262.4 | 291.5 | 1.00 | | `pdu ~` | 299.1 ± 4.1 | 292.1 | 305.0 | 1.10 ± 0.04 | | `GOMAXPROCS=100 gdu -npc ~` | 459.1 ± 14.2 | 446.7 | 490.3 | 1.69 ± 0.07 | | `gdu -npc ~` | 466.1 ± 27.9 | 421.4 | 495.3 | 1.72 ± 0.12 | | `dua ~` | 590.6 ± 5.9 | 580.5 | 599.7 | 2.18 ± 0.07 | | `dust -d0 ~` | 578.7 ± 3.7 | 572.2 | 586.3 | 2.14 ± 0.07 | | `du -hs ~` | 1255.2 ± 7.4 | 1245.1 | 1273.4 | 4.63 ± 0.14 | | `duc index ~` | 1450.5 ± 6.2 | 1440.6 | 1460.4 | 5.36 ± 0.16 | | `ncdu -0 -o /dev/null ~` | 2222.4 ± 5.6 | 2215.6 | 2231.0 | 8.21 ± 0.25 | | `gdu -npc --db=tmp.db ~` | 8246.7 ± 30.9 | 8181.9 | 8288.7 | 30.45 ± 0.92 | | `gdu -npc --db=tmp.badger ~` | 15608.0 ± 3215.8 | 13960.3 | 22448.0 | 57.63 ± 12.00 | ## Alternatives * [ncdu](https://dev.yorhel.nl/ncdu) - NCurses based tool written in pure `C` (LTS) or `zig` (Stable) * [godu](https://github.com/viktomas/godu) - Analyzer with a carousel like user interface * [dua](https://github.com/Byron/dua-cli) - Tool written in `Rust` with interface similar to gdu (and ncdu) * [diskus](https://github.com/sharkdp/diskus) - Very simple but very fast tool written in `Rust` * [duc](https://duc.zevv.nl/) - Collection of tools with many possibilities for inspecting and visualising disk usage * [dust](https://github.com/bootandy/dust) - Tool written in `Rust` showing tree like structures of disk usage * [pdu](https://github.com/KSXGitHub/parallel-disk-usage) - Tool written in `Rust` showing tree like structures of disk usage ## Notes [HDD icon created by Nikita Golubev - Flaticon](https://www.flaticon.com/free-icons/hdd) gdu-5.36.1/build/000077500000000000000000000000001517447455500135055ustar00rootroot00000000000000gdu-5.36.1/build/build.go000066400000000000000000000004551517447455500151370ustar00rootroot00000000000000package build // Version stores the current version of the app var Version = "development" // Time of the build var Time string // User who built it var User string // RootPathPrefix stores path to be prepended to given absolute path // e.g. /var/lib/snapd/hostfs for snap var RootPathPrefix = "" gdu-5.36.1/cmd/000077500000000000000000000000001517447455500131515ustar00rootroot00000000000000gdu-5.36.1/cmd/gdu/000077500000000000000000000000001517447455500137305ustar00rootroot00000000000000gdu-5.36.1/cmd/gdu/app/000077500000000000000000000000001517447455500145105ustar00rootroot00000000000000gdu-5.36.1/cmd/gdu/app/app.go000066400000000000000000000400451517447455500156220ustar00rootroot00000000000000package app import ( "fmt" "io" "io/fs" "net/http" "net/http/pprof" "os" "path/filepath" "runtime" "strings" "time" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" log "github.com/sirupsen/logrus" "github.com/dundee/gdu/v5/build" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/device" gfs "github.com/dundee/gdu/v5/pkg/fs" "github.com/dundee/gdu/v5/pkg/timefilter" "github.com/dundee/gdu/v5/report" "github.com/dundee/gdu/v5/stdout" "github.com/dundee/gdu/v5/tui" ) // UI is common interface for both terminal UI and text output type UI interface { ListDevices(getter device.DevicesInfoGetter) error AnalyzePath(path string, parentDir gfs.Item) error ReadAnalysis(input io.Reader) error ReadFromStorage(storagePath, path string) error SetIgnoreTypes(types []string) SetIgnoreDirPaths(paths []string) SetIgnoreDirPatterns(paths []string) error SetIgnoreFromFile(ignoreFile string) error SetIgnoreHidden(value bool) SetIncludeTypes(types []string) SetFollowSymlinks(value bool) SetShowAnnexedSize(value bool) SetAnalyzer(analyzer common.Analyzer) SetTimeFilter(timeFilter common.TimeFilter) SetArchiveBrowsing(value bool) SetCollapsePath(value bool) StartUILoop() error } // Flags define flags accepted by Run type Flags struct { Style Style `yaml:"style"` Sorting Sorting `yaml:"sorting"` CfgFile string `yaml:"-"` LogFile string `yaml:"log-file"` InputFile string `yaml:"input-file"` OutputFile string `yaml:"output-file"` IgnoreFromFile string `yaml:"ignore-from-file"` IgnoreDirs []string `yaml:"ignore-dirs"` IgnoreDirPatterns []string `yaml:"ignore-dir-patterns"` TypeFilter []string `yaml:"type"` ExcludeTypeFilter []string `yaml:"exclude-type"` MaxCores int `yaml:"max-cores"` Top int `yaml:"top"` Depth int `yaml:"depth"` SequentialScanning bool `yaml:"sequential-scanning"` ShowDisks bool `yaml:"-"` ShowApparentSize bool `yaml:"show-apparent-size"` ShowRelativeSize bool `yaml:"show-relative-size"` ShowAnnexedSize bool `yaml:"show-annexed-size"` ShowVersion bool `yaml:"-"` ShowItemCount bool `yaml:"show-item-count"` ShowMTime bool `yaml:"show-mtime"` NoColor bool `yaml:"no-color"` Mouse bool `yaml:"mouse"` NonInteractive bool `yaml:"non-interactive"` Interactive bool `yaml:"interactive"` NoProgress bool `yaml:"no-progress"` NoUnicode bool `yaml:"no-unicode"` NoCross bool `yaml:"no-cross"` NoHidden bool `yaml:"no-hidden"` NoDelete bool `yaml:"no-delete"` NoViewFile bool `yaml:"no-view-file"` NoSpawnShell bool `yaml:"no-spawn-shell"` FollowSymlinks bool `yaml:"follow-symlinks"` Profiling bool `yaml:"profiling"` ReadFromStorage bool `yaml:"read-from-storage"` DbPath string `yaml:"db"` Summarize bool `yaml:"summarize"` UseSIPrefix bool `yaml:"use-si-prefix"` NoPrefix bool `yaml:"no-prefix"` ShowInKiB bool `yaml:"show-in-kib"` WriteConfig bool `yaml:"-"` ReverseSort bool `yaml:"reverse-sort"` ChangeCwd bool `yaml:"change-cwd"` DeleteInBackground bool `yaml:"delete-in-background"` DeleteInParallel bool `yaml:"delete-in-parallel"` Since string `yaml:"since"` Until string `yaml:"until"` MaxAge string `yaml:"max-age"` MinAge string `yaml:"min-age"` ArchiveBrowsing bool `yaml:"archive-browsing"` CollapsePath bool `yaml:"collapse-path"` BrowseParentDirs bool `yaml:"browse-parent-dirs"` } // ShouldRunInNonInteractiveMode checks if the application should run in non-interactive mode // based on the flags set. func (f *Flags) ShouldRunInNonInteractiveMode(istty bool) bool { if f.NonInteractive { return true } if f.Interactive { return f.ShowVersion || f.OutputFile != "" || f.NoPrefix || f.NoProgress || f.Summarize || f.Top > 0 } return !istty || f.ShowVersion || f.OutputFile != "" || f.NoPrefix || f.NoProgress || f.Summarize || f.Top > 0 } // Style define style config type Style struct { Footer FooterColorStyle `yaml:"footer"` SelectedRow ColorStyle `yaml:"selected-row"` ResultRow ResultRowColorStyle `yaml:"result-row"` Header HeaderColorStyle `yaml:"header"` ProgressModal ProgressModalOpts `yaml:"progress-modal"` UseOldSizeBar bool `yaml:"use-old-size-bar"` } // ProgressModalOpts defines options for progress modal type ProgressModalOpts struct { CurrentItemNameMaxLen int `yaml:"current-item-path-max-len"` ShowDiskProgressBar bool `yaml:"show-disk-progress-bar"` } // ColorStyle defines styling of some item type ColorStyle struct { TextColor string `yaml:"text-color"` BackgroundColor string `yaml:"background-color"` } // FooterColorStyle defines styling of footer type FooterColorStyle struct { TextColor string `yaml:"text-color"` BackgroundColor string `yaml:"background-color"` NumberColor string `yaml:"number-color"` } // HeaderColorStyle defines styling of header type HeaderColorStyle struct { TextColor string `yaml:"text-color"` BackgroundColor string `yaml:"background-color"` Hidden bool `yaml:"hidden"` } // ResultRowColorStyle defines styling of result row type ResultRowColorStyle struct { NumberColor string `yaml:"number-color"` DirectoryColor string `yaml:"directory-color"` } // Sorting defines default sorting of items type Sorting struct { By string `yaml:"by"` Order string `yaml:"order"` } // App defines the main application type App struct { Writer io.Writer TermApp common.TermApplication Screen tcell.Screen Getter device.DevicesInfoGetter Flags *Flags PathChecker func(string) (fs.FileInfo, error) Args []string Istty bool } func init() { http.DefaultServeMux = http.NewServeMux() } // Run starts gdu main logic // //nolint:gocyclo,funlen // App function is a suite of if statements func (a *App) Run() error { var ui UI if a.Flags.ShowVersion { fmt.Fprintln(a.Writer, "Version:\t", build.Version) fmt.Fprintln(a.Writer, "Built time:\t", build.Time) fmt.Fprintln(a.Writer, "Built user:\t", build.User) return nil } log.Printf("Runtime flags: %+v", *a.Flags) if a.Flags.NoPrefix && a.Flags.UseSIPrefix { return fmt.Errorf("--no-prefix and --si cannot be used at once") } if a.Flags.NonInteractive && a.Flags.Interactive { return fmt.Errorf("--interactive and --non-interactive cannot be used at once") } path := a.getPath() path, err := filepath.Abs(path) if err != nil { return err } ui, err = a.createUI() if err != nil { return err } if a.Flags.DbPath != "" { if !a.Flags.ReadFromStorage { // Remove existing db before re-scan if strings.HasSuffix(a.Flags.DbPath, ".badger") { os.RemoveAll(a.Flags.DbPath) } else { os.Remove(a.Flags.DbPath) } } if strings.HasSuffix(a.Flags.DbPath, ".badger") { ui.SetAnalyzer(analyze.CreateStoredAnalyzer(a.Flags.DbPath)) } else { sqliteAnalyzer, err := analyze.CreateSqliteAnalyzer(a.Flags.DbPath) if err != nil { return fmt.Errorf("creating sqlite analyzer: %w", err) } ui.SetAnalyzer(sqliteAnalyzer) } } if a.Flags.SequentialScanning { ui.SetAnalyzer(analyze.CreateSeqAnalyzer()) } if a.Flags.FollowSymlinks { ui.SetFollowSymlinks(true) } if a.Flags.ShowAnnexedSize { ui.SetShowAnnexedSize(true) } if a.Flags.ArchiveBrowsing { ui.SetArchiveBrowsing(true) } if a.Flags.CollapsePath { ui.SetCollapsePath(true) } // Set up time filter if any time flags are provided if a.Flags.Since != "" || a.Flags.Until != "" || a.Flags.MaxAge != "" || a.Flags.MinAge != "" { if err := a.setTimeFilters(ui); err != nil { return err } } if err := a.setNoCross(path); err != nil { return err } // Process type filters if len(a.Flags.TypeFilter) > 0 { ui.SetIncludeTypes(a.Flags.TypeFilter) } if len(a.Flags.ExcludeTypeFilter) > 0 { ui.SetIgnoreTypes(a.Flags.ExcludeTypeFilter) } ui.SetIgnoreDirPaths(a.Flags.IgnoreDirs) if len(a.Flags.IgnoreDirPatterns) > 0 { if err := ui.SetIgnoreDirPatterns(a.Flags.IgnoreDirPatterns); err != nil { return err } } if a.Flags.IgnoreFromFile != "" { if err := ui.SetIgnoreFromFile(a.Flags.IgnoreFromFile); err != nil { return err } } if a.Flags.NoHidden { ui.SetIgnoreHidden(true) } a.setMaxProcs() if err := a.runAction(ui, path); err != nil { return err } return ui.StartUILoop() } func (a *App) getPath() string { if len(a.Args) == 1 { return a.Args[0] } return "." } func (a *App) setMaxProcs() { if a.Flags.MaxCores < 1 || a.Flags.MaxCores > runtime.NumCPU() { return } runtime.GOMAXPROCS(a.Flags.MaxCores) // runtime.GOMAXPROCS(n) with n < 1 doesn't change current setting so we use it to check current value log.Printf("Max cores set to %d", runtime.GOMAXPROCS(0)) } func (a *App) setTimeFilters(ui UI) error { loc := time.Local now := time.Now() timeFilter, err := timefilter.NewTimeFilter( a.Flags.Since, a.Flags.Until, a.Flags.MaxAge, a.Flags.MinAge, now, loc, ) if err != nil { return fmt.Errorf("invalid time filter: %w", err) } if !timeFilter.IsEmpty() { timeFilterFunc := func(mtime time.Time) bool { return timeFilter.IncludeByTimeFilter(mtime, loc) } ui.SetTimeFilter(timeFilterFunc) // If this is a TUI, also set the filter info for display if tuiUI, ok := ui.(*tui.UI); ok { tuiUI.SetTimeFilterWithInfo(timeFilter, loc) } } return nil } func (a *App) createUI() (UI, error) { var ui UI var err error switch { case a.Flags.OutputFile != "": var output io.Writer if a.Flags.OutputFile == "-" { output = os.Stdout } else { output, err = os.OpenFile(a.Flags.OutputFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return nil, fmt.Errorf("opening output file: %w", err) } } ui = report.CreateExportUI( a.Writer, output, !a.Flags.NoColor && a.Istty, !a.Flags.NoProgress && a.Istty, a.Flags.UseSIPrefix, ) case a.Flags.ShouldRunInNonInteractiveMode(a.Istty): fixedUnit := "" if a.Flags.ShowInKiB { fixedUnit = "k" } stdoutUI := stdout.CreateStdoutUI( a.Writer, !a.Flags.NoColor && a.Istty, !a.Flags.NoProgress && a.Istty, a.Flags.ShowApparentSize, a.Flags.ShowRelativeSize, a.Flags.Summarize, a.Flags.UseSIPrefix, a.Flags.NoPrefix, fixedUnit, a.Flags.Top, a.Flags.ReverseSort, a.Flags.Depth, ) if a.Flags.NoUnicode { stdoutUI.UseOldProgressRunes() } if a.Flags.ShowItemCount { stdoutUI.SetShowItemCount() } ui = stdoutUI default: opts := a.getOptions() ui = tui.CreateUI( a.TermApp, a.Screen, os.Stdout, !a.Flags.NoColor, a.Flags.ShowApparentSize, a.Flags.ShowRelativeSize, a.Flags.UseSIPrefix, opts..., ) if !a.Flags.NoColor { tview.Styles.TitleColor = tcell.NewRGBColor(27, 161, 227) } else { tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(150, 150, 150) } tview.Styles.BorderColor = tcell.ColorDefault } return ui, nil } func (a *App) getOptions() []tui.Option { var opts []tui.Option if a.Flags.Style.SelectedRow.TextColor != "" { opts = append(opts, func(ui *tui.UI) { ui.SetSelectedTextColor(tcell.GetColor(a.Flags.Style.SelectedRow.TextColor)) }) } if a.Flags.Style.SelectedRow.BackgroundColor != "" { opts = append(opts, func(ui *tui.UI) { ui.SetSelectedBackgroundColor(tcell.GetColor(a.Flags.Style.SelectedRow.BackgroundColor)) }) } if a.Flags.Style.Footer.TextColor != "" { opts = append(opts, func(ui *tui.UI) { ui.SetFooterTextColor(a.Flags.Style.Footer.TextColor) }) } if a.Flags.Style.Footer.BackgroundColor != "" { opts = append(opts, func(ui *tui.UI) { ui.SetFooterBackgroundColor(a.Flags.Style.Footer.BackgroundColor) }) } if a.Flags.Style.Footer.NumberColor != "" { opts = append(opts, func(ui *tui.UI) { ui.SetFooterNumberColor(a.Flags.Style.Footer.NumberColor) }) } if a.Flags.Style.Header.TextColor != "" { opts = append(opts, func(ui *tui.UI) { ui.SetHeaderTextColor(a.Flags.Style.Header.TextColor) }) } if a.Flags.Style.Header.BackgroundColor != "" { opts = append(opts, func(ui *tui.UI) { ui.SetHeaderBackgroundColor(a.Flags.Style.Header.BackgroundColor) }) } if a.Flags.Style.Header.Hidden { opts = append(opts, func(ui *tui.UI) { ui.SetHeaderHidden() }) } if a.Flags.Style.ResultRow.NumberColor != "" { opts = append(opts, func(ui *tui.UI) { ui.SetResultRowNumberColor(a.Flags.Style.ResultRow.NumberColor) }) } if a.Flags.Style.ResultRow.DirectoryColor != "" { opts = append(opts, func(ui *tui.UI) { ui.SetResultRowDirectoryColor(a.Flags.Style.ResultRow.DirectoryColor) }) } if a.Flags.Style.ProgressModal.CurrentItemNameMaxLen > 0 { opts = append(opts, func(ui *tui.UI) { ui.SetCurrentItemNameMaxLen(a.Flags.Style.ProgressModal.CurrentItemNameMaxLen) }) } if a.Flags.Style.UseOldSizeBar || a.Flags.NoUnicode { opts = append(opts, func(ui *tui.UI) { ui.UseOldSizeBar() }) } if a.Flags.Sorting.Order != "" || a.Flags.Sorting.By != "" { opts = append(opts, func(ui *tui.UI) { ui.SetDefaultSorting(a.Flags.Sorting.By, a.Flags.Sorting.Order) }) } if a.Flags.ChangeCwd { opts = append(opts, func(ui *tui.UI) { ui.SetChangeCwdFn(os.Chdir) }) } if a.Flags.ShowItemCount { opts = append(opts, func(ui *tui.UI) { ui.SetShowItemCount() }) } if a.Flags.ShowMTime { opts = append(opts, func(ui *tui.UI) { ui.SetShowMTime() }) } if a.Flags.NoDelete { opts = append(opts, func(ui *tui.UI) { ui.SetNoDelete() }) } if a.Flags.NoViewFile { opts = append(opts, func(ui *tui.UI) { ui.SetNoViewFile() }) } if a.Flags.NoSpawnShell { opts = append(opts, func(ui *tui.UI) { ui.SetNoSpawnShell() }) } if a.Flags.DeleteInBackground { opts = append(opts, func(ui *tui.UI) { ui.SetDeleteInBackground() }) } if a.Flags.DeleteInParallel { opts = append(opts, func(ui *tui.UI) { ui.SetDeleteInParallel() }) } if a.Flags.BrowseParentDirs { opts = append(opts, func(ui *tui.UI) { ui.SetBrowseParentDirs() }) } opts = append(opts, func(ui *tui.UI) { ui.SetShowDiskProgressBar(a.Flags.Style.ProgressModal.ShowDiskProgressBar) }) return opts } func (a *App) setNoCross(path string) error { if a.Flags.NoCross { mounts, err := a.Getter.GetMounts() if err != nil { return fmt.Errorf("loading mount points: %w", err) } paths := device.GetNestedMountpointsPaths(path, mounts) log.Printf("Ignoring mount points: %s", strings.Join(paths, ", ")) a.Flags.IgnoreDirs = append(a.Flags.IgnoreDirs, paths...) } return nil } func (a *App) runAction(ui UI, path string) error { if a.Flags.Profiling { go func() { http.HandleFunc("/debug/pprof/", pprof.Index) http.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) http.HandleFunc("/debug/pprof/profile", pprof.Profile) http.HandleFunc("/debug/pprof/symbol", pprof.Symbol) http.HandleFunc("/debug/pprof/trace", pprof.Trace) log.Println(http.ListenAndServe("localhost:6060", nil)) }() } switch { case a.Flags.ShowDisks: if err := ui.ListDevices(a.Getter); err != nil { return fmt.Errorf("loading mount points: %w", err) } case a.Flags.InputFile != "": var input io.Reader var err error if a.Flags.InputFile == "-" { input = os.Stdin } else { input, err = os.OpenFile(a.Flags.InputFile, os.O_RDONLY, 0o600) if err != nil { return fmt.Errorf("opening input file: %w", err) } } if err := ui.ReadAnalysis(input); err != nil { return fmt.Errorf("reading analysis: %w", err) } default: if build.RootPathPrefix != "" { path = build.RootPathPrefix + path } _, err := a.PathChecker(path) if err != nil { return err } log.Printf("Analyzing path: %s", path) if err := ui.AnalyzePath(path, nil); err != nil { return fmt.Errorf("scanning dir: %w", err) } } return nil } gdu-5.36.1/cmd/gdu/app/app_linux_test.go000066400000000000000000000064271517447455500201060ustar00rootroot00000000000000//go:build linux package app import ( "os" "path/filepath" "testing" "github.com/dundee/gdu/v5/internal/testdev" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/device" "github.com/stretchr/testify/assert" ) func TestNoCrossWithErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", NoCross: true}, []string{"test_dir"}, false, device.LinuxDevicesInfoGetter{MountsPath: "/xxxyyy"}, ) assert.Equal(t, "loading mount points: open /xxxyyy: no such file or directory", err.Error()) assert.Empty(t, out) } func TestListDevicesWithErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() _, err := runApp( &Flags{LogFile: "/dev/null", ShowDisks: true}, []string{}, false, device.LinuxDevicesInfoGetter{MountsPath: "/xxxyyy"}, ) assert.Equal(t, "loading mount points: open /xxxyyy: no such file or directory", err.Error()) } func TestOutputFileError(t *testing.T) { out, err := runApp( &Flags{LogFile: "/dev/null", OutputFile: "/xyzxyz"}, []string{}, false, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.Contains(t, err.Error(), "permission denied") } func TestUseStorage(t *testing.T) { fin := testdir.CreateTestDir() defer fin() const storagePath = "/tmp/badger-test.badger" defer func() { err := os.RemoveAll(storagePath) if err != nil { panic(err) } }() out, err := runApp( &Flags{LogFile: "/dev/null", DbPath: storagePath}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Contains(t, out, "nested") assert.Nil(t, err) } func TestReadFromStorage(t *testing.T) { fin := testdir.CreateTestDir() defer fin() storagePath := "/tmp/badger-test4.badger" defer func() { err := os.RemoveAll(storagePath) if err != nil { panic(err) } }() out, err := runApp( &Flags{LogFile: "/dev/null", DbPath: storagePath}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Contains(t, out, "nested") assert.Nil(t, err) out, err = runApp( &Flags{LogFile: "/dev/null", ReadFromStorage: true, DbPath: storagePath}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Contains(t, out, "nested") assert.Nil(t, err) } func TestAnalyzePathWithSqliteStorage(t *testing.T) { fin := testdir.CreateTestDir() defer fin() dbPath := filepath.Join(t.TempDir(), "db", "test.sqlite") out, err := runApp( &Flags{LogFile: "/dev/null", DbPath: dbPath}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Contains(t, out, "nested") assert.Nil(t, err) out, err = runApp( &Flags{LogFile: "/dev/null", DbPath: dbPath, ReadFromStorage: true}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Contains(t, out, "nested") assert.Nil(t, err) } func TestAnalyzePathWithSqliteStorageError(t *testing.T) { fin := testdir.CreateTestDir() defer fin() parentFile := filepath.Join(t.TempDir(), "parent-file") err := os.WriteFile(parentFile, []byte("x"), 0o600) assert.Nil(t, err) out, err := runApp( &Flags{LogFile: "/dev/null", DbPath: filepath.Join(parentFile, "db.sqlite")}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.ErrorContains(t, err, "creating sqlite analyzer") } gdu-5.36.1/cmd/gdu/app/app_test.go000066400000000000000000000355701517447455500166700ustar00rootroot00000000000000package app import ( "bytes" "io" "os" "regexp" "runtime" "strings" "testing" "time" log "github.com/sirupsen/logrus" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/internal/testapp" "github.com/dundee/gdu/v5/internal/testdev" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/device" gfs "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func init() { log.SetLevel(log.WarnLevel) } func TestVersion(t *testing.T) { out, err := runApp( &Flags{ShowVersion: true}, []string{}, false, testdev.DevicesInfoGetterMock{}, ) assert.Contains(t, out, "Version:\t development") assert.Nil(t, err) } func TestShouldRunInNonInteractiveModeInteractiveOverridesNoTTY(t *testing.T) { flags := &Flags{Interactive: true} assert.False(t, flags.ShouldRunInNonInteractiveMode(false)) } func TestShouldRunInNonInteractiveMode(t *testing.T) { flags := &Flags{NonInteractive: true} assert.True(t, flags.ShouldRunInNonInteractiveMode(false)) } func TestShouldRunInNonInteractiveModeInteractiveKeepsNonInteractiveOnlyFlags(t *testing.T) { flags := &Flags{Interactive: true, Summarize: true} assert.True(t, flags.ShouldRunInNonInteractiveMode(false)) } func TestInteractiveAndNonInteractiveConflict(t *testing.T) { out, err := runApp( &Flags{Interactive: true, NonInteractive: true}, []string{"."}, true, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.ErrorContains(t, err, "--interactive and --non-interactive cannot be used at once") } func TestAnalyzePath(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null"}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Contains(t, out, "nested") assert.Nil(t, err) } func TestAnalyzePathWithShowItemCountNonInteractive(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", ShowItemCount: true}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Nil(t, err) assert.Regexp(t, regexp.MustCompile(`(?m)\s+\d+\s+/nested$`), out) } func TestSequentialScanning(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", SequentialScanning: true}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Contains(t, out, "nested") assert.Nil(t, err) } func TestFollowSymlinks(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", FollowSymlinks: true}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Contains(t, out, "nested") assert.Nil(t, err) } func TestShowAnnexedSize(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", ShowAnnexedSize: true}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Contains(t, out, "nested") assert.Nil(t, err) } func TestAnalyzePathProfiling(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", Profiling: true}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Contains(t, out, "nested") assert.Nil(t, err) } func TestAnalyzePathWithIgnoring(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{ LogFile: "/dev/null", IgnoreDirPatterns: []string{"/(abc)+"}, NoHidden: true, }, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Contains(t, out, "nested") assert.Nil(t, err) } func TestAnalyzePathWithIgnoringPatternError(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{ LogFile: "/dev/null", IgnoreDirPatterns: []string{"[[["}, NoHidden: true, }, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Equal(t, out, "") assert.NotNil(t, err) } func TestAnalyzePathWithIgnoringFromNotExistingFile(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{ LogFile: "/dev/null", IgnoreFromFile: "file", NoHidden: true, }, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Equal(t, out, "") assert.NotNil(t, err) } func TestAnalyzePathWithGui(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null"}, []string{"test_dir"}, true, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.Nil(t, err) } func TestAnalyzePathWithGuiNoColor(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", NoColor: true}, []string{"test_dir"}, true, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.Nil(t, err) } func TestGuiShowMTimeAndItemCount(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", ShowItemCount: true, ShowMTime: true}, []string{"test_dir"}, true, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.Nil(t, err) } func TestGuiNoDelete(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", NoDelete: true}, []string{"test_dir"}, true, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.Nil(t, err) } func TestGuiNoViewFile(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", NoViewFile: true}, []string{"test_dir"}, true, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.Nil(t, err) } func TestGuiNoSpawnShell(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", NoSpawnShell: true}, []string{"test_dir"}, true, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.Nil(t, err) } func TestGuiDeleteInParallel(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", DeleteInParallel: true}, []string{"test_dir"}, true, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.Nil(t, err) } func TestAnalyzePathWithGuiBackgroundDeletion(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", DeleteInBackground: true}, []string{"test_dir"}, true, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.Nil(t, err) } func TestAnalyzePathWithDefaultSorting(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{ LogFile: "/dev/null", Sorting: Sorting{ By: "name", Order: "asc", }, }, []string{"test_dir"}, true, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.Nil(t, err) } func TestAnalyzePathWithStyle(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{ LogFile: "/dev/null", Style: Style{ SelectedRow: ColorStyle{ TextColor: "black", BackgroundColor: "red", }, ProgressModal: ProgressModalOpts{ CurrentItemNameMaxLen: 10, }, Footer: FooterColorStyle{ TextColor: "black", BackgroundColor: "red", NumberColor: "white", }, Header: HeaderColorStyle{ TextColor: "black", BackgroundColor: "red", Hidden: true, }, ResultRow: ResultRowColorStyle{ NumberColor: "orange", DirectoryColor: "blue", }, UseOldSizeBar: true, }, }, []string{"test_dir"}, true, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.Nil(t, err) } func TestAnalyzePathNoUnicode(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{ LogFile: "/dev/null", NoUnicode: true, }, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Contains(t, out, "nested") assert.Nil(t, err) } func TestAnalyzePathWithExport(t *testing.T) { fin := testdir.CreateTestDir() defer fin() defer func() { os.Remove("output.json") }() out, err := runApp( &Flags{LogFile: "/dev/null", OutputFile: "output.json"}, []string{"test_dir"}, true, testdev.DevicesInfoGetterMock{}, ) assert.NotEmpty(t, out) assert.Nil(t, err) } func TestAnalyzePathWithChdir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{ LogFile: "/dev/null", ChangeCwd: true, }, []string{"test_dir"}, true, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.Nil(t, err) } func TestReadAnalysisFromFile(t *testing.T) { out, err := runApp( &Flags{LogFile: "/dev/null", InputFile: "../../../internal/testdata/test.json"}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.NotEmpty(t, out) assert.Contains(t, out, "main.go") assert.Nil(t, err) } func TestReadWrongAnalysisFromFile(t *testing.T) { out, err := runApp( &Flags{LogFile: "/dev/null", InputFile: "../../../internal/testdata/wrong.json"}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.Contains(t, err.Error(), "array of maps not found") } func TestWrongCombinationOfPrefixes(t *testing.T) { out, err := runApp( &Flags{NoPrefix: true, UseSIPrefix: true}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.Contains(t, err.Error(), "cannot be used at once") } func TestReadWrongAnalysisFromNotExistingFile(t *testing.T) { out, err := runApp( &Flags{LogFile: "/dev/null", InputFile: "xxx.json"}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Empty(t, out) assert.Contains(t, err.Error(), "no such file or directory") } func TestAnalyzePathWithErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := bytes.NewBufferString("") app := App{ Flags: &Flags{LogFile: "/dev/null"}, Args: []string{"xxx"}, Istty: false, Writer: buff, TermApp: testapp.CreateMockedApp(false), Getter: testdev.DevicesInfoGetterMock{}, PathChecker: os.Stat, } err := app.Run() assert.Equal(t, "", strings.TrimSpace(buff.String())) assert.Contains(t, err.Error(), "no such file or directory") } func TestNoCross(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", NoCross: true}, []string{"test_dir"}, false, testdev.DevicesInfoGetterMock{}, ) assert.Contains(t, out, "nested") assert.Nil(t, err) } func TestListDevices(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", ShowDisks: true}, []string{}, false, testdev.DevicesInfoGetterMock{}, ) assert.Contains(t, out, "Device") assert.Nil(t, err) } func TestListDevicesToFile(t *testing.T) { fin := testdir.CreateTestDir() defer fin() defer func() { os.Remove("output.json") }() out, err := runApp( &Flags{LogFile: "/dev/null", ShowDisks: true, OutputFile: "output.json"}, []string{}, false, testdev.DevicesInfoGetterMock{}, ) assert.Equal(t, "", out) assert.Contains(t, err.Error(), "not supported") } func TestListDevicesWithGui(t *testing.T) { fin := testdir.CreateTestDir() defer fin() out, err := runApp( &Flags{LogFile: "/dev/null", ShowDisks: true}, []string{}, true, testdev.DevicesInfoGetterMock{}, ) assert.Nil(t, err) assert.Empty(t, out) } func TestMaxCores(t *testing.T) { _, err := runApp( &Flags{LogFile: "/dev/null", MaxCores: 1}, []string{}, true, testdev.DevicesInfoGetterMock{}, ) assert.Equal(t, 1, runtime.GOMAXPROCS(0)) assert.Nil(t, err) } func TestMaxCoresHighEdge(t *testing.T) { if runtime.NumCPU() < 2 { t.Skip("Skipping on a single core CPU") } out, err := runApp( &Flags{LogFile: "/dev/null", MaxCores: runtime.NumCPU() + 1}, []string{}, true, testdev.DevicesInfoGetterMock{}, ) assert.NotEqual(t, runtime.NumCPU(), runtime.GOMAXPROCS(0)) assert.Empty(t, out) assert.Nil(t, err) } func TestMaxCoresLowEdge(t *testing.T) { if runtime.NumCPU() < 2 { t.Skip("Skipping on a single core CPU") } out, err := runApp( &Flags{LogFile: "/dev/null", MaxCores: -100}, []string{}, true, testdev.DevicesInfoGetterMock{}, ) assert.NotEqual(t, runtime.NumCPU(), runtime.GOMAXPROCS(0)) assert.Empty(t, out) assert.Nil(t, err) } type uiTimeFilterMock struct { timeFilter common.TimeFilter } func (m *uiTimeFilterMock) ListDevices(getter device.DevicesInfoGetter) error { return nil } func (m *uiTimeFilterMock) AnalyzePath(path string, parentDir gfs.Item) error { return nil } func (m *uiTimeFilterMock) ReadAnalysis(input io.Reader) error { return nil } func (m *uiTimeFilterMock) ReadFromStorage(storagePath, path string) error { return nil } func (m *uiTimeFilterMock) SetIgnoreTypes(types []string) {} func (m *uiTimeFilterMock) SetIgnoreDirPaths(paths []string) {} func (m *uiTimeFilterMock) SetIgnoreDirPatterns(paths []string) error { return nil } func (m *uiTimeFilterMock) SetIgnoreFromFile(ignoreFile string) error { return nil } func (m *uiTimeFilterMock) SetIgnoreHidden(value bool) {} func (m *uiTimeFilterMock) SetIncludeTypes(types []string) {} func (m *uiTimeFilterMock) SetFollowSymlinks(value bool) {} func (m *uiTimeFilterMock) SetShowAnnexedSize(value bool) {} func (m *uiTimeFilterMock) SetAnalyzer(analyzer common.Analyzer) {} func (m *uiTimeFilterMock) SetTimeFilter(timeFilter common.TimeFilter) { m.timeFilter = timeFilter } func (m *uiTimeFilterMock) SetArchiveBrowsing(value bool) {} func (m *uiTimeFilterMock) SetCollapsePath(value bool) {} func (m *uiTimeFilterMock) StartUILoop() error { return nil } func TestSetTimeFiltersInvalid(t *testing.T) { a := &App{Flags: &Flags{Since: "not-a-date"}} ui := &uiTimeFilterMock{} err := a.setTimeFilters(ui) assert.ErrorContains(t, err, "invalid time filter") } func TestSetTimeFiltersSetsFilter(t *testing.T) { futureDate := time.Now().Add(48 * time.Hour).Format("2006-01-02") a := &App{Flags: &Flags{Since: futureDate}} ui := &uiTimeFilterMock{} err := a.setTimeFilters(ui) assert.Nil(t, err) if assert.NotNil(t, ui.timeFilter) { assert.False(t, ui.timeFilter(time.Now())) assert.True(t, ui.timeFilter(time.Now().Add(72*time.Hour))) } } // nolint: unparam // Why: it's used in linux tests func runApp(flags *Flags, args []string, istty bool, getter device.DevicesInfoGetter) (output string, err error) { buff := bytes.NewBufferString("") app := App{ Flags: flags, Args: args, Istty: istty, Writer: buff, TermApp: testapp.CreateMockedApp(false), Getter: getter, PathChecker: testdir.MockedPathChecker, } err = app.Run() return strings.TrimSpace(buff.String()), err } gdu-5.36.1/cmd/gdu/main.go000066400000000000000000000230321517447455500152030ustar00rootroot00000000000000package main import ( "fmt" "os" "path/filepath" "regexp" "runtime" "strings" "github.com/gdamore/tcell/v2" "github.com/mattn/go-isatty" "github.com/rivo/tview" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "gopkg.in/yaml.v3" "github.com/dundee/gdu/v5/cmd/gdu/app" "github.com/dundee/gdu/v5/pkg/device" ) var ( af *app.Flags configErr error ) var rootCmd = &cobra.Command{ Use: "gdu [directory_to_scan]", Short: "Pretty fast disk usage analyzer written in Go", Long: `Pretty fast disk usage analyzer written in Go. Gdu is intended primarily for SSD disks where it can fully utilize parallel processing. However HDDs work as well, but the performance gain is not so huge. `, Args: cobra.MaximumNArgs(1), SilenceUsage: true, RunE: runE, } // nolint:funlen // a lot of flags to initialize func init() { af = &app.Flags{Style: app.Style{ProgressModal: app.ProgressModalOpts{ShowDiskProgressBar: true}}} flags := rootCmd.Flags() flags.StringVar(&af.CfgFile, "config-file", "", "Read config from file (default is $HOME/.gdu.yaml)") flags.StringVarP(&af.LogFile, "log-file", "l", "/dev/null", "Path to a logfile") flags.StringVarP(&af.OutputFile, "output-file", "o", "", "Export all info into file as JSON") flags.StringVarP(&af.InputFile, "input-file", "f", "", "Import analysis from JSON file") flags.IntVarP(&af.MaxCores, "max-cores", "m", runtime.NumCPU(), fmt.Sprintf("Set max cores that Gdu will use. %d cores available", runtime.NumCPU())) flags.BoolVar(&af.SequentialScanning, "sequential", false, "Use sequential scanning (intended for rotating HDDs)") flags.BoolVarP(&af.ShowVersion, "version", "v", false, "Print version") flags.StringSliceVarP(&af.TypeFilter, "type", "T", []string{}, "File types to include (e.g., --type yaml,json)") flags.StringSliceVarP(&af.ExcludeTypeFilter, "exclude-type", "E", []string{}, "File types to exclude (e.g., --exclude-type yaml,json)") flags.StringSliceVarP(&af.IgnoreDirs, "ignore-dirs", "i", []string{"/proc", "/dev", "/sys", "/run"}, "Paths to ignore (separated by comma). Can be absolute or relative to current directory") flags.StringSliceVarP(&af.IgnoreDirPatterns, "ignore-dirs-pattern", "I", []string{}, "Path patterns to ignore (separated by comma)") flags.StringVarP(&af.IgnoreFromFile, "ignore-from", "X", "", "Read path patterns to ignore from file") flags.BoolVarP(&af.NoHidden, "no-hidden", "H", false, "Ignore hidden directories (beginning with dot)") flags.BoolVarP( &af.FollowSymlinks, "follow-symlinks", "L", false, "Follow symlinks for files, i.e. show the size of the file to which symlink points to (symlinks to directories are not followed)", ) flags.BoolVarP( &af.ShowAnnexedSize, "show-annexed-size", "A", false, "Use apparent size of git-annex'ed files in case files are not present locally (real usage is zero)", ) flags.BoolVarP(&af.NoCross, "no-cross", "x", false, "Do not cross filesystem boundaries") flags.BoolVar(&af.Profiling, "enable-profiling", false, "Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/") flags.StringVarP(&af.DbPath, "db", "D", "", "Store analysis in database (*.sqlite for SQLite, *.badger for BadgerDB)") flags.BoolVarP(&af.ReadFromStorage, "read-from-storage", "r", false, "Use existing database instead of re-scanning") flags.BoolVar(&af.ArchiveBrowsing, "archive-browsing", false, "Enable browsing of zip/jar/tar archives (tar, tar.gz, tar.bz2, tar.xz)") flags.BoolVar(&af.CollapsePath, "collapse-path", false, "Collapse single-child directory chains") flags.BoolVarP(&af.ShowDisks, "show-disks", "d", false, "Show all mounted disks") flags.BoolVarP(&af.ShowApparentSize, "show-apparent-size", "a", false, "Show apparent size") flags.BoolVarP(&af.ShowRelativeSize, "show-relative-size", "B", false, "Show relative size") flags.BoolVarP(&af.NoColor, "no-color", "c", false, "Do not use colorized output") flags.BoolVarP(&af.ShowItemCount, "show-item-count", "C", false, "Show number of items in directory") flags.BoolVarP(&af.ShowMTime, "show-mtime", "M", false, "Show latest mtime of items in directory") flags.BoolVarP(&af.NonInteractive, "non-interactive", "n", false, "Do not run in interactive mode") flags.BoolVar(&af.Interactive, "interactive", false, "Force interactive mode even when output is not a TTY") flags.BoolVarP(&af.NoProgress, "no-progress", "p", false, "Do not show progress in non-interactive mode") flags.BoolVarP(&af.NoUnicode, "no-unicode", "u", false, "Do not use Unicode symbols (for size bar)") flags.BoolVarP(&af.Summarize, "summarize", "s", false, "Show only a total in non-interactive mode") flags.IntVarP(&af.Top, "top", "t", 0, "Show only top X largest files in non-interactive mode") flags.IntVar(&af.Depth, "depth", 0, "Show directory structure up to specified depth in non-interactive mode (0 means the flag is ignored)") flags.BoolVar(&af.UseSIPrefix, "si", false, "Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB)") flags.BoolVar(&af.NoPrefix, "no-prefix", false, "Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode") flags.BoolVarP(&af.ShowInKiB, "show-in-kib", "k", false, "Show sizes in KiB (or kB with --si) in non-interactive mode") flags.BoolVar(&af.ReverseSort, "reverse-sort", false, "Reverse sorting order (smallest to largest) in non-interactive mode") flags.BoolVar(&af.Mouse, "mouse", false, "Use mouse") flags.BoolVar(&af.NoDelete, "no-delete", false, "Do not allow deletions") flags.BoolVar(&af.NoViewFile, "no-view-file", false, "Do not allow viewing file contents") flags.BoolVar(&af.NoSpawnShell, "no-spawn-shell", false, "Do not allow spawning shell") flags.BoolVar(&af.WriteConfig, "write-config", false, "Write current configuration to file (default is $HOME/.gdu.yaml)") flags.StringVar( &af.Since, "since", "", "Include files with mtime >= WHEN. WHEN accepts RFC3339 timestamp (e.g., 2025-08-11T01:00:00-07:00) "+ "or date only YYYY-MM-DD (calendar-day compare; includes the whole day)", ) flags.StringVar(&af.Until, "until", "", "Include files with mtime <= WHEN. WHEN accepts RFC3339 timestamp or date only YYYY-MM-DD") flags.StringVar(&af.MaxAge, "max-age", "", "Include files with mtime no older than DURATION (e.g., 7d, 2h30m, 1y2mo)") flags.StringVar(&af.MinAge, "min-age", "", "Include files with mtime at least DURATION old (e.g., 30d, 1w)") initConfig() setDefaults() } func initConfig() { setConfigFilePath() data, err := os.ReadFile(af.CfgFile) if err != nil { configErr = err return // config file does not exist, return } configErr = yaml.Unmarshal(data, &af) } func setDefaults() { if af.Style.Footer.BackgroundColor == "" { af.Style.Footer.BackgroundColor = "#2479D0" } if af.Style.Footer.TextColor == "" { af.Style.Footer.TextColor = "#000000" } if af.Style.Footer.NumberColor == "" { af.Style.Footer.NumberColor = "#FFFFFF" } if af.Style.Header.BackgroundColor == "" { af.Style.Header.BackgroundColor = "#2479D0" } if af.Style.Header.TextColor == "" { af.Style.Header.TextColor = "#000000" } if af.Style.ResultRow.NumberColor == "" { af.Style.ResultRow.NumberColor = "#e67100" } if af.Style.ResultRow.DirectoryColor == "" { af.Style.ResultRow.DirectoryColor = "#3498db" } } func setConfigFilePath() { command := strings.Join(os.Args, " ") if strings.Contains(command, "--config-file") { re := regexp.MustCompile("--config-file[= ]([^ ]+)") parts := re.FindStringSubmatch(command) if len(parts) > 1 { af.CfgFile = parts[1] return } } setDefaultConfigFilePath() } func setDefaultConfigFilePath() { home, err := os.UserHomeDir() if err != nil { configErr = err return } path := filepath.Join(home, ".config", "gdu", "gdu.yaml") if _, err := os.Stat(path); err == nil { af.CfgFile = path return } af.CfgFile = filepath.Join(home, ".gdu.yaml") } func runE(command *cobra.Command, args []string) error { var ( termApp *tview.Application screen tcell.Screen err error ) if af.WriteConfig { data, err := yaml.Marshal(af) if err != nil { return fmt.Errorf("error marshaling config file: %w", err) } if af.CfgFile == "" { setDefaultConfigFilePath() } err = os.WriteFile(af.CfgFile, data, 0o600) if err != nil { return fmt.Errorf("error writing config file %s: %w", af.CfgFile, err) } } if runtime.GOOS == "windows" && af.LogFile == "/dev/null" { af.LogFile = "nul" } var f *os.File if af.LogFile == "-" { f = os.Stdout } else { f, err = os.OpenFile(af.LogFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return fmt.Errorf("opening log file: %w", err) } defer func() { cerr := f.Close() if cerr != nil { panic(cerr) } }() } log.SetOutput(f) if configErr != nil { log.Printf("Error reading config file: %s", configErr.Error()) } istty := isatty.IsTerminal(os.Stdout.Fd()) // we are not able to analyze disk usage on Windows and Plan9 if runtime.GOOS == "windows" || runtime.GOOS == "plan9" { af.ShowApparentSize = true } if !af.ShouldRunInNonInteractiveMode(istty) { screen, err = tcell.NewScreen() if err != nil { return fmt.Errorf("error creating screen: %w", err) } defer screen.Clear() defer screen.Fini() termApp = tview.NewApplication() termApp.SetScreen(screen) if af.Mouse { termApp.EnableMouse(true) } } a := app.App{ Flags: af, Args: args, Istty: istty, Writer: os.Stdout, TermApp: termApp, Screen: screen, Getter: device.Getter, PathChecker: os.Stat, } return a.Run() } func main() { if err := rootCmd.Execute(); err != nil { os.Exit(1) } } gdu-5.36.1/cmd/gdu/main_test.go000066400000000000000000000021141517447455500162400ustar00rootroot00000000000000package main import "testing" func TestNoViewFileFlagRegistered(t *testing.T) { flag := rootCmd.Flags().Lookup("no-view-file") if flag == nil { t.Fatal("expected no-view-file flag to be registered") } } func TestNoViewFileFlagCanBeSet(t *testing.T) { t.Cleanup(func() { _ = rootCmd.Flags().Set("no-view-file", "false") }) err := rootCmd.Flags().Set("no-view-file", "true") if err != nil { t.Fatalf("expected setting no-view-file flag to succeed: %v", err) } if !af.NoViewFile { t.Fatal("expected NoViewFile to be true after setting flag") } } func TestInteractiveFlagRegistered(t *testing.T) { flag := rootCmd.Flags().Lookup("interactive") if flag == nil { t.Fatal("expected interactive flag to be registered") } } func TestInteractiveFlagCanBeSet(t *testing.T) { t.Cleanup(func() { _ = rootCmd.Flags().Set("interactive", "false") }) err := rootCmd.Flags().Set("interactive", "true") if err != nil { t.Fatalf("expected setting interactive flag to succeed: %v", err) } if !af.Interactive { t.Fatal("expected Interactive to be true after setting flag") } } gdu-5.36.1/codecov.yml000066400000000000000000000002541517447455500145540ustar00rootroot00000000000000coverage: status: project: default: target: auto threshold: 2% informational: true patch: default: informational: truegdu-5.36.1/configuration.md000066400000000000000000000101441517447455500155770ustar00rootroot00000000000000# YAML file configuration options Gdu provides an additional set of configuration options to the usual command line options. You can get the full list of all possible options by running: ``` gdu --write-config ``` This will create file `$HOME/.gdu.yaml` with all the options set to default values. Let's go through them one by one: #### `log-file` Path to a logfile (default "/dev/null") #### `input-file` Import analysis from JSON file #### `output-file` Export all info into file as JSON #### `ignore-dirs` Paths to ignore (separated by comma). Can be absolute (like `/proc`) or relative to the current working directory (like `node_modules`). Default values are [/proc,/dev,/sys,/run]. #### `ignore-dir-patterns` Path patterns to ignore (separated by comma). Patterns can be absolute or relative to the current working directory. #### `ignore-from-file` Read path patterns to ignore from file. Patterns can be absolute or relative to the current working directory. #### `max-cores` Set max cores that Gdu will use. #### `sequential-scanning` Use sequential scanning (intended for rotating HDDs) #### `show-apparent-size` Show apparent size #### `show-relative-size` Show relative size #### `show-item-count` Show number of items in directory #### `no-color` Do not use colorized output #### `mouse` Use mouse #### `non-interactive` Do not run in interactive mode #### `interactive` Force interactive mode even when output is not a TTY #### `no-progress` Do not show progress in non-interactive mode #### `no-cross` Do not cross filesystem boundaries #### `no-hidden` Ignore hidden directories (beginning with dot) #### `no-delete` Do not allow deletions #### `no-view-file` Do not allow viewing file contents #### `follow-symlinks` Follow symlinks for files, i.e. show the size of the file to which symlink points to (symlinks to directories are not followed) #### `profiling` Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/ #### `read-from-storage` Read analysis data from persistent key-value storage #### `summarize` Show only a total in non-interactive mode #### `use-si-prefix` Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB) #### `no-prefix` Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode #### `reverse-sort` Reverse sorting order (smallest to largest) in non-interactive mode #### `change-cwd` Set CWD variable when browsing directories #### `delete-in-background` Delete items in the background, not blocking the UI from work #### `delete-in-parallel` Delete items in parallel, which might increase the speed of deletion #### `browse-parent-dirs` Allow navigating above the launch directory by pressing the left arrow key. When enabled, pressing left at the top-level directory will rescan and open its parent directory. Disabled by default. #### `style.selected-row.text-color` Color of text for the selected row #### `style.selected-row.background-color` Background color for the selected row #### `style.progress-modal.current-item-path-max-len` Maximum length of file path for the current item in progress bar. When the length is reached, the path is shortened with "/.../". #### `style.use-old-size-bar` Show size bar without Unicode symbols. #### `style.footer.text-color` Color of text for footer bar #### `style.footer.background-color` Background color for footer bar #### `style.footer.number-color` Color of numbers displayed in the footer #### `style.header.text-color` Color of text for header bar #### `style.header.background-color` Background color for header bar #### `style.header.hidden` Hide the header bar #### `style.result-row.number-color` Color of numbers in result rows #### `style.result-row.directory-color` Color of directory names in result rows #### `sorting.by` Sort items. Possible values: * name - name of the item * size - usage or apparent size * itemCount - number of items in the folder tree * mtime - modification time #### `sorting.order` Set sorting order. Possible values: * asc - ascending order * desc - descending order gdu-5.36.1/default.pgo000066400000000000000000001727421517447455500145560ustar00rootroot00000000000000wǕ\u3fB%$Q$ Z^oVO6־]ɲW"_H$H䜈H$"ID"uOtUu_Jӡ󝯠PB*ɊJ%8PnB{Os D+2md1'pXCM*yQ? |8uŒJUڸO.n+uZ|dEm_.v;/~j<_/'icKpʴ3|ԥ7i$S>^!O{x"F}dKf~?Fkn 6}R7vFO|^ G}wS%>R__ IK HCä{ MKɹ#ܹm[A(i4FzK_6NXmi4^b}P9ڸl2Q"'$( m4E*T5k4Mj?]!͔Βϧ"3J'͗H_Yw%a nCXS <_$UK5'^,*~%{Riĭo0VH?kPWJ]n~ҿ#OSg\%x1OZ#6kp"KhƗ06J{-V}:8pߤo~\x|]!hK-}("m])h_s>vPfHq)z,IRl.@'l%'I#RO$T}pL:.| cD|J43 b|R:%!v$SOh$T 7Zu*ڣYxX$/|.}!]X[w0]}?@.K՗fE:DĴ-P6nWƳC Kv̢mG7$)ݒXs|[*G0OtWz{}vA`7]nrw F̸e ;:ic3\W2d~rdQWE$ˬ/: c\Y/! ˵TNxf?T}F>+vrVޅz.|ieю; \`E].` geAqP*ۉ/½&g;_z4 0RN!7G*½)ߪa}mFpGJ+ߓ3q܈ ⍗c/?I.Tqw=J7ҾSwmz) =`7)}R06K+#J|Rڸ{ʏû T"\0 5@--}684CJz( )]BpM#%.}|? o@cF+]Rw[Xme2^u@LoS/stZϚec&w2SQą MgJ^=?$#rFɆCgWH9W'I}\P.֬hwIWJ:+Ur]RTn)a3} sWJ-8vxrO* }k'6+ux+jhK:hJ`fu:H>`ULhC7ڲh1T C0GHԟȦp5#惼PG:)R/dGlaJT]RǪ!*~[MG.q*cg?>E,GaV|K^dbh'o)߈BFsuPJ.8FY|]e`*ZTCMNh .8Biܭ~rɞ<=41TKp/5(U%ubމGB>Ȍ!fsD}G~P/yP=rOLaVTLeu$D=@OS'ԓj :SNGOU@P&eԙ:QL=,REO|.rqzɅv^ez.%I| 2z' ")]P/VakڸXxI^$qUBp'U42Vgt]eݻM }HPmyjpcWz[MB!qW*awJv*ISb"*8YtxSjبgsv 6Ƽ}w-fUb [9VdIK ?{2*5%lT{kLhҸbV=Ѓ$ղ <~ZRqHҲJ+.%_pFX_>B9̤jVIF+6с]TZCh`8T Hoj#4fFi3$(JUtP;ZB@hB.QMKL0jxW S6.xRѩ'^-&kaA&j|Ҩ{6Y/N3qyKui/ɺ(}VsVe.8)Z*ÚM(T4!2AsH%L\$AL,mIiŚPX-i%{Z5\RT[1~˵J"ĐcP:d*-(Z %xkJF=ںyklbA`L6jg^'~*kl%ހPï6#D?` kAԙu!-J0R96_gT2%xJ6DZъãq-]7'R<]yX̺ZfK>>hg5! DM@k%c_h\u:\ԾD6-eԫZk&|Mk>ANYL9G]:,|5]qߛ(ʃ``Ӈ[#MCa Cc SxStLP>J%x@h=rq)JDc8=;tv338 'Wk$en ^%؜W IqΙ+)Y\oԩzys4]EyL}*0gӜgMi-_]I,I-j'yjd׹}.&A&]=*I '~bXH tZx Wg#||Dg:~gRO&J1!^0g\W<F^3.&+uºU:_?BfM=tY3{^vWݠv1VK&f-٬oS"ܨÇKԪH8UOTKe'c0 '%uCߩU_;gk{ǚ^~/OɂHen\(sIΓbt]9.1ғ.0nLM'=~ k)dibq]B>6Yu1q 9/d=0.zCNS:@mLo]' s8@]R`8&`ՅsrN'+?׿/ o:Ë%rջ \ѯLF5}XfJ;p&҃~]g K6JF [:O򾭇@+r!&JgDpA6*g"9Ѓ~O~ĴM,>*wkB䁾Ilcqԯ5 F/k19#\Tgo28`o6Ibl5>0*6m6ΌuinQvm 5.Ѕ]1B&6>^#J=vjIڍҤ^iEѩ}į>qz`AtJ m7|7d08hMB=ԌM/ע*w2p]dGF i?<)R~7WD1BXmTN|qȠ`6zg|jTF>3B#I>}x4 +8LN;gܱ8PAfP:8q5?@EwxPRI%#r:׊@ě11O,R#K3;UC6Fp͸nD펩T3͛Dؕx7qCqx+۸c|ed\5=9h$ѳŹЕ,?C  IV}Ftӓd{nLr@o"GFFF1k$ly۴4Y!%M\Ha\h}IN d3#$o|F\W̴` BYth.#(Xd\HF Iu E^pcz !BZ;PW+Y pa@U4ڍ xh`4QD0D详>·"SD(MƑ=`ReG xR -oye> cSV60lIDXUh7IЦ^ eR\VK:ڦpli͇ WaDKѯ k%~=3 14k~"|#t6CxNTTjJGh:'y/. Jf }|!o2`c(DL.Pj WZ }1YBPIVd$T]hK.CK7oX2qPZNؙB[A+W. t?DP)A_CXv[s-/q;iQy)>[Bg $w׉&>k=,&$^$(OeKG]Kh: UZ踑af6d J> B[}G[.o#o$5'5Av]d7!9?ʆn.xt#zB\yTc0R"Hrikׂ4IbfRwsps KEGiU4KL4"-23(yXT&Q9SaTt[i?bWUɸlq,s-Q"uda '&bDgunJ1 n7\ˉ@4*TŌ a' i33L1<,r9S}O77OQ*) ͇Z/t|ng4s[-xl=*I <M0 S&!_ oa)h4y[&3ISGYOf)L~y1aLC4lnV+J_7uh SM5F#8`~7`{",`$Bm1P:M_}0 n}(U„E?xԄy~;JBF,kRb2˺6 .[3Q6iRwRcuwxbR*71#TG!3oE))MVhpB't,2 `wV%E2'ћϢCEu<&T;Ls*S  fjMoG]hnXnsi cg"f!JWssdT'UF6LB8r:8vLaR&siMWR8(Ex=bpt<|Rjպ&Gl~B P:wNuWKAﻢYK"XBBh yA?L?1G9ؖŨ"%k| 0h+ dyQ 76 l$VK sp!6YL"э! 'b'*tkyX5 !L8`p @636"#XmN &$kwS3S=*Kd[8d{V?l[TK `nCZTurt;GP+'.ɀ(% hw3">OIٍG-+.rB>OR6͹^ا>|'WL8p>qgH|#-"OBHq!:?ON/$"2 N]h'YP]W)zKN/δ9S Ig^ zY*L<Ae2vB?IbDqNY1؝aBPƔ`_;.115%sl"~eqWKW5E53= !} %4]g%z:;YUm$UBA*zmcY̯ 0XE񜃡E˵{8C +d& ,zonN\IĘFV7&YF0zXb%#Z>HO/ڶF7_SqZ2 ^ ]E-VFPTofu=XJ'pYYeEjXLã>`#Tg`O2f$sl$|ݚFDm©@[!aV+8m2ⲝ3-35˺!v?'.-9U=#Ͷvb|e4jͳ*$ 5b䤂ٲš mWzh&&Z(e)nb=bK(Yb1Y:8\:EP7xy`K-޸T&E@]Ƭ[Lad nӵr ++'90i,>#s$DYP[4|k W[鎊K Ф}_йaJQ+3 &)¾hXo᮷" V3BE#qtW)H6[L/\-a˭spY3naaNKhOkK>)Vi-PvGϝ|IZnke4~Ͼ+lE=e_?q{/:SQ7OQ}J[EK>-:8V":Q]T8m}';tbq"j5-ޔBEG[-݉ pm*KaUnlݲRykh$ݱ(\we[^ű]kR `PqAzi|vByYw }K+}xO7;j 6up E32Cz؄S iEeB>6Sjuu~v{ͮ4 y ;``yX[Um c-6w k%ϛडv"נ\f9`E赡0޴{-NN qlD_K1v&-Xb%#)UJǼtb k p (n_gPxcϷذJ4 ׹v7yR{-DdslJ۠0bF]{3c٩m `=Kzm׷t&O^f6-? 3B4+R֫ r+,WgaSISIu08{,BÚg)@Lb*]h{7-P%vJ;koSFAv.9`!&/~yl)҃fB_hښ>Qn're?[mAzձkd"}lG6g WcWovM%y phl5=YD;L|W'6GQ~$}WTL=賤h}$},iIf]h/rX>>e4VO<f&Oڃ/C)+&<SiExzJ9F.?gVbٗu019zgsys -q^_Jt9 ݲ-Hv PW{ݎiEfEa|is}> VP7.B-;4Y)xwN;ʾkʳOepYi依22 AcWsĕNI\,ilcrP'AB9 RD|_a1.EkrvNzˏ}.nb/$&򴾃N~J|(XJq%@d@V_gZ?̔ϩ,GeC|L:8"po{;C- l C#MvZJ PM(\0I;D!4+f*#؅5҉ip#7;P8|IaO nHEr%u$ 았LAkk9jo;INp{A(^>0gs GBq';Up)W`DLs*P  uR-aܷP;iF/JfۙGY{.%L/bJW` 2Ivb%duy8h\'k:P9\~¥ rJ;In6^m_ vbᣁnNӃR냜2@9 <"-#!^pv%{N2'ӇT܂(<}IYtUr*INR5 ۣnKvp2k"q8 & t68|F( p +)Wr^c&Co!J x%6;\Yh#:]z:iFOYj 6Onw&t}S_f m1Z=ػ&_i4:,Hǝd>tq"Jwt3CN9VHGTCnjFۏ}׹f9kR?!Vm[(n(3dsx[yCe\hg2ta_ 9 ÂA_8W!>}u]|Irera/2ۣKӻw\u_s}o#Ydt}Pg\V,Wn:\3qmŸ̅uu_#9$i3!gFtNt DupV|Ԯ.]AVy˭¤oى~DEwwRMs>pyתBx. 0 L!zyz-62K%@ at <"Azˮ E 릔#xeeJ..5m`V)ɣA.2Mm:-d.K]S?9 2%AvZ c11/bye$Ֆ.{re5W^a.vyG{ہvǸokx|Н*jX7m0yjEU "nVUs,wm|3{Яw7k' Fw6_-Yh.y'o:U2ze`:mv*"n@ M-v ̓e?~G?tχd|Nv}jR}nf f's%{]>Cx?]-h?w+EG!iUƃ.e-= 9= B;d0,!? reejXӑ9`O17ѼPw͊AwIӳ8ҝ紻Kg+ĔK\" Lgn_/tV:OI i92Ɋ <҂!k9(Xa^-> w\ϖ7Y͖ pJ]#pĦnn+TtYTE6/>L=6GKR3SKnB"6˖Huq5exq|HF¸c< Z/]>$I"pvݿ2y—AGʁzre˛"{n3|.SVH=pzSEe!7تl`Z4z]lOSN7e'tk,ڛ❚B!̻+Oa78:xn// Uzn(:8H#/4u*ZzVo%5*j"5jhӅA{)Nh.E^f |>-5˲! {D{#4+k' i c'm$-/}z-I .㼀W\!5%ZV w쩦$&dLL;'L])?S,s@j)qIQ=,>"0zBӒ mg{ly<1ë#Zb\"lxf:B!06qJNM :%7z֙˜cު`-&=.YP3d5O!s= qDqy^?./?E'O{-| z`!G `n`+n{ t4OJi-0n{4UJ/+kJ*aR/mFH kHN'a~oJʮȘ2|]r ]h=~M"6 Vl7>LU.i٨m~K䓢 O=yb?Ml Ma׽㋋ԝ(*-|谫+26xo;Vı `F Ǎ_h~M ߭oQjFo(!&EzaA!OL5x<1rΪ%wJJwVOT>?qZ#cāzLr91ȶ <$>eu#aKcSPn:QS:8]Olg^ WRWnz[.7S>MoY7JόGyJk~BVPwH|̅\xi 7.Us eE,JwŻꥷZ.Wm)}úZ gLWI}!1!4xz7n Y67fMoyu+͸vWEf\Clf|Txm4ŭ9z-,kٗbQƻsׅo}/K"%z#=f)֕2NxVj-X7'MR7:Wki.y\OںS<UGZzPV)%Z$+W;ѓ&^PMh~3XCy31[;vzNN'N )z*;tΪa""/%(`N,rn @HX|?6LydCoQ Ʃ\mb+7XYd . 6`Յ_ر^Ygajo$[EW1LK9Q4#\CE[h"0"X Kߦ(ivlb묗ճXkZwR!M| :S;9OM$2t J[)UXFY*ty̠\vңʷKϤ=<?6$VY@й4` %.CKU~TN˜+l!eQE#L.V m KQ~""?OSХ4SEe ?wE`KbpDdjf 0[t 򥰼UYVNJS6YM3ZhRA|HoϤR9`]04MfGi%/"mDڳ[V*_Uζ`k:sjAy0m26]P^$o٢֣Lxb-@Ia}dZyXݜTlݴW)LCTo⤤0PZ]#6ѫҀ$~*G8qW}<@cH[EtUb!dTzHc:Ǣn"< YHRPqryXit3GHB{O:J#!@;LCNa=UE25F+EB T؅Ci#hO)GYfuW0P!7րst2.JaV x)HT0){xe7)Ev& >% .7Lv:iuӂaqVoەJtA|"+i&K6yЯR6sVrb"Me_=_Li&[EgQ =D)2ZnPcݤYe] mZl;Yw(_+v~E&w)adn܄=0e(Tz@+$_ { ba+hj5rW?+Yp}Dvz=f6$gJg|x~zDr'w/oȖS+ )鋾g-~Nf()E'qnZp~?_%eB!|r̨Jz<1`Ӏq5^xeSk"u X Xt6Y[shߺlS &oƸ ׯ|B=՜2d/aӢ3;qB7SƛOSyqALܪqxb?-ڣ[}Z <Ws.3ΈP")?rE*iSYPnt91m!EYVch7닸2MUWvS,SتUez-njfՕwem__w3^k zl.8@C%^B5b)m5L+bf7XdZG.>O2;HɒZ᧜{JE*bJ-;THiE!Ne!}T00%ŷ(-˒?Zἧv(l|F!<}!xkJ;P7ܚ3w~}Q Pl7-[6ïcmzl 2| m'lOl*HN"b0gjgPOmPlqͬ mvDyRF0N@޴C&V#_0{m<o }3#gCl~3->_1CY~a?U5k\1kQ  w+Vpg)GcqofS˧}桅HWت%%D,.%k;J@AX,ofw{@4NsD a-ײdzk)g.<+⊌=衁pIB;zLQF/籤)x%xQw] / 0+xzM)>x&xWs]J؏r6Jq7th4I7YF-59 3!fQhWI4NCDݗXZ3m"}MvV_PC0Ӣ]R%YB1!n0E*qcq\)iB8Q]RZ\0/`^P/~@Q=13`K^̓?'zUԣ0!}`GaG0̡Q/hƘx$6 6pcD&], RQ* 7w %qBa ЀyX" Q;YORup /\{Nςư`+[}O Fɣ M6u#P(-b:*!>Ho%ܘ×I%Y*tʄ6IF06X e1"_ (]Ͱ{G;29K[A|Fz&”%{*Ko3Ku80!~Tw|VJr; R91`>9)˜< .)BmrjYj=*d& ӂTw!t+3\tMȕQ3N?3|YAXMLD.X*U:Ad:. uB5STKDp5js+3, ]hs جM"@bȟ ߻쿋pAXN8[mJ`rƢ) xjIX2p "gFX‡qUHkz-,G{y!Gae3tY@ʀkZ$ 5`J> _Ǿkz>1/ERT[9cBalĻOhx+1`Vv;l|mXnds{K0bV@k- ^v%כBe>Lal8J@b FI5uH;!My)ID6Ma @seW0t Dl1gT^7yX- ^}(cys+ r(|6Q́I2ZLlY9G=_Gc`$Sy'rdS3ƹ"t>hihM?ĝG9L ֬s@.Ieos̴dBW9=\Uj6p^9A$irLZZ=(Na\ V &̯x5M,] `_ vv]5Z0ҡ*t VyYtAZV<,2StD9{ǽfJ,uA˳%u~ɼ N VXyGC;' ܅-SN+l|ApY Nck.EA7NjC>a*&r}9s E[Mc78j:(kͥyv;UL3\V\j GX!tzDp+t8 ㉑-Tk[ ? XFev:oTNXwtNռ8vƩFe;)19QS\,Z# ygz/ϨnyzVh“6ੱJ'ni;N᎒"l;8˭f!^fvWS MKnSci>>QnVW \&RIwrDܩԦPc3otzA䘉rf>mvM45 E>ȴ\J}szN 0ev\ #gjtZqr- Ck[~ŶQg暝rifCf^朜 Úa^T_jeli)@Kxj٬hs 8X5I*w龛㺳y٫7,̉d-'%q(WO--ɵ_oRn~ȄPҹ9k5YѦڈ"/ͱֿ{Y.]vnROB[㹦+r?ؘS͉/* ndu[es "Q)ksIv<2}Y ,&Eo6X|r|qxMYWs[r[s!ߞۑۙDrqTHۆeHt`2%2JoFGd:]J~݇G}h4gJ?PѨT{\VF({DRiN_*{ĠReJJ[J_ O!4WֈIo*N/=U $RiŕrGeQtpѫ\GZSt7me҆J)gV'ϗ5Ҟ~%Uҁ~h}HRtNahTnKQ}Ǐ'yN,5$TCSRJ}WEO4JN L'Fsj)Hёokf?zo+r@Ri C=Է_שT8psxKtRV˴S4Pv4ܯ&93T*Wou,{J7 Or|#\B|&JkDmJߚj yJ7K{DODɷ;HLKT*>=yJ+5DG~@/Lws zT)ȟP4|7? G{/??åyGe T'>gHxοm IׄK*4EtR8J+#EB45:C:S*kU$7#ÏpJW[ gs* !YF3rFG&^NY%iG:Stakt3]#:ms*>?)qt%%ΔC۱]H-JXG^R9΁?_rxOeJJeoGw1 EҴ%7,k?c^Uw݉9"m;koN8LKv2v۱LHH @#MB DsG}=߿zlߗa= s_fNhÊm`мfߢ2Ah<&HӖ q'y[м CJ?C>wɟͻXoįK7}K|Uҿ}c-XG#_񣏬oO]п .AT;d*`м6|ChLka@/&:V?1wl!xߊ_n0ANH$E>_;m"D{nlwjҠ(ν3Q<* ?{niP SK/tiP,C¨8@;xIh21.iP]xD@"Sh CnJT[A)VqR]C1?E.8G"MJ& `Eġe&L+AĪKz( ݌Ek51،H9<rr;O^X|'[1qK?g,V7DfB~k*"`1ֶ4ȢRJb?JxviJ~)Ԡ2!c,5S ^F5qMcӠ/T Y@{_^tijPR[20ɚgoMvII5ZKDKqRJvKbLF:D+V|khKykur~:JJD8^^^8MT끅DA@] EO,fk.Ra^8K4Kdս+èOҀoa_0jRqۭ=_ğZA ?[4ҠS6_!,{yJ4X';Z`#èyTAgAs8TTߑx)Z?ʏQ742F3 O: ޲I"6]S}) MkOoU֛P[[ :©2(4kٔݣ܄]sC@|\D03{5KY4zFe;=~ Ġ.w05}l9['Riˀ:J肘zIلNWyE͍s@ml5Џ\[;:ZfzMH [XT4Ib@ZuXg C,vM#&Na|(%*tŨMrFMon]hW& ;t ,%zu6H͚(m^˼G4orl): -}J,tN|#"d %!238b ~t'E!Nr鼵֕~:gÏjD3̃9O7֪b~xZ%饇#;g~z[Wl11K*Gɠ'G\hL@Os&=(B}rA\ 'K,Tkub"{tm,A]i vzSz9&XHq41 y\0_2XWF XsY.8/"R Pmܝonl` D[@G¾d1h41Y@pn^v۞AѠLֲV:(Pc&*m r2]|dEK>o Q`r`_O`׮DH!`{g"+ei %`#Fa@w%Z/oxP'g]^}-\,tep>iwUPz.u^h" R V{CX!59rk`덲<-a,\`<صEn.٠"uрc9k;!ɺN l545mQx7V?O9Ds-I"e> rp6K33KAS,CwZhPK%ʀ&PIA3l1wYU[oZi4~<fF}32cK@m|)2D 1XŪ=mwtѪPz~ͶOQz1۵w 2i~WOgo.rWkX3]k䧧y6t6vK3{>/AZ(ۢXgL࿂eb]U sQӚy1 Kt|=I_ ɺź>hT:#45uTW^e pGMb}"@ȏR$\.&ߖ!0)O#=q`j.C[D.V`1W lX`Mķ>}C 5E.`v3t"f`3 IԅHx@# leʹjGrT^8?<,O#P/0A=¿ pu2]|ѹYgeftR4(՚L;riP5|@cJyTJU%>. *7iEn5zMKX^4]T@E  Vf h +J\>+vuae aX.:S91?U;do2S@x+vZ2T|((~h16h/Ct.,j@yt.m1%.2a'c ED6ZXC1[`:`L5PmvOkv4 \kNvJT3 D7HAyw/#✛?"ϳ9>e 7h@X tA@Glدx2|gAP1O,-CCiVt|$ӌ- I.f:tu_7D9up/k^v#z]E=:Gs._Zd9:,t޼&FbB.Yz>D9x(y`W2XbS@vR QK֡ZdhN vZ?%=j"]܁\g," Za+@:m93`fz5 zϑL硗 guQmVZmt5Wԭ3oץapм2֭"U^*"5?N5UIt1 >B4(P{x]@teڂA:lJHcHR"Wq)(Ku+Ҡ J:q9G뽺LmJOaPr:(@l"<鞴OwR? ҴHx\R5 Avy BAhs:6vGtE'C5چ-Oy!L..Y4,KEf \S6thX?l{l;=w&gQ9^8Uz%6puQ"=`.j#;`y7'2[lc.-:`'C1*OYw8KE%MFlik~Ws1eX,~;JB?dǑ$} ^X SW\fx?d΂Vyzb/k]̿-tX(cy"1:J~pȡHu RP+d Pm^/P:|bԏN0SQL0G;kSNvRoJ?u{@%D,mt KvFN@΀6.aɥσ+aoHy + :RE\`*y,L|:zE1\2BfXٔlJ/xJdÁy4BGiunat5ֆXXpf:{9L"Hxat YAìTQgA#wҽ{ߗEYPWxJ:e'^?]ch&FqL-v+@PO&q P LDyPc۞a/q`q *m4635g$3ua]XܛՑFپ\Wm*!/6fI41. }~縃XaqJK;7Je;E!mC{Yldz})/ $"!/h'.1}G TKb,TsOe>a[2`Rg1fO-a/(^$-oUEmh]"mz@"Ϝ⣋K,7 =#e+,{ iCz|8KkU-I/`  ľuĠWl9HDI.Y=qh.~Jhǜ/"՘yGf#0ΑlP4V)^ !u2Uz(؄tsDxPK@I>`>JY6;C).75)) sd9B.шOmi (jHv8XsZUl36~ؿ22@L%~CL@v_z,Mv[P8E6L5(XDX43\"!D)Xc`K.H2.=*r$sªx$v\.E,J¿fįIJc.(R@95%~_ * ]T-:P Jud6t}= [/о uFU 7[)ΩDT͜X\avJ`Jء|E-o6;Ҡ}/=4hHM bvr87߲7٦ ڸBK|Dz"CuJqoK,$^i0Z(#fɁ.V6|G qY1\Šk^F /H:#FAJfs#~]zs}'M4v1DCh9fIK4`7 QM"cUBsL\fX9sʙsoDg< }Y8I~-W0V2X.qbsAAJvV|XR߂uU2=9 7BkYη=l6_/h093rm0⁥.[ev顇b.bB׽.1  icƆ-{`jhZV~-{}}.3gO z Zn).?ou8}g1 Zfv+~A9?z[ʚoNAXKfl@UXbيNakfK!h9P"`PfqzEÏ3.\WN'(zdEwQ4\f^!t%a"L _a/KQ, ]<ҫs|npAFt4$|SDPZ-#6ivGIF\Knb- ?k{S H7ktp jnvlT23KtlLVlq )7Y/}l4P`K+ RԆGX( k$(g[  A@䂐Hh7f1qeonBjv3܎_*槨Ӱ[`݀:^T“jt+1ERu s2nt8bam~T0Q;TVt%h6.h*6D+X%&4B՛* _ }T saM{Ah`)z8hc>)Й{VI[nLߒ^hl0(1X$bO}K.O0>L4 ϱ9i?~ /fK\ӥK?cxځ|w%ߒjLqv`%BGP {tFlУMO*O8l֙aKv6"Ay$.9Z2ˀɋIwAW_> (H*>.Ҡ{,tЕF( 8`>oHnQP1@ *a:q~V9` `WUh<ܨI@*sO/Q3emnu,лFhI (vzm|:6Kw]e23Jxtx&!Xەt;B|WnH@?85 %U>Uh VZGu))X{%Zܦ _b̥=j! @fAH#U NrU6SnD@y+ 37GI^ C[%4LO s6eMֱzK#Ҁ5MӁ==<@{I]_߼*tLitQ2yv&TSPX`yj)؅2xpZN CaH/tdcIAŠp4>naxK/;q}i|\Tqݫ>*nf ʉ[ȭSFޗaTR܆D}UpfٺMUR}2'[KKlL3Ѹx'*9o~%C}vaDD֚J.]ʹ.\p, هUK#zf6UBd(r2ֿeձ7cXWccn&f>#o=љР# U썝O-RcN{(hv&vF JV}j_ gs%~Y58iQjÀ`1&G5Nce7pQg;UkwN:ɬum.{2 < ==llh!дK<{cWNMKr2 v;]~v9/m9j {bi5Ay.U#sJt*}m'n= sz?3XcPld(=a1oM<+q3c#%>#%=eLuq.Gn3¢mlzE|%gT_gA,NtYTpƟ7_E/xh\2.qS,IĐf" !Aݱ3?" Z%[dBEh3PuY (nn]l'Emz E2ݚ8 7#N%-m@%oqN,Y8@GPV|x` :y(=\Iq"v+쀭,(i{Ҡ-R)9*! JYiMS<) =tJF?'83pF62Mn|^jn-fhL n ôS.zG@PNޓyB[ #v0TESKCJrE$NڥGӠ>.CKfχfgχ3qft\Ӑ,vߋx̾ .mk6II];Ed`- 2[VoucL0CٍpD +Lna+^~` Qx+^On$RA0rQydcmE֪θ%`c8: ԑmIaF#0Y[-I rr;O3hf^svAW0:K\wSktB_ VcgidMz =1. JQ4 vKɿa4Rcv}$%&@&g[fYLD_ `7) C#̇]buw;ͦ1 SlȽp,%}C3 Emuuвe_龜͞ץBCs)tI]H)MB>%"/]c`e\b0T>Z;y@ vѱOOY f9}. s*cgv-=|e"H?-԰XS=>Gg؉ Za}zFyZ#CIPzOc{ /Yx@z&J}F6^W‡VR٤IA[!Rԍߐ™iP$5Ezn% h w#[Ijj v 1:DTkRRxݑ6ٗu.D9_lIUX~n d`MyN! 7[?fV }Az"?e༲ gv9LІIt,Wn4(I/* H/(Nʪ~[|y}8?c`]R *-S]]iP!5O([ }OPE+`e>sB}^ۊ2-P8TLjz^;y&X_t"*@뻯ʺng0Vo8U N{1&=`bX3T9)/0BB(-J]Z9' p^D EϠ}8~f`whGקKM g7 @/Z;`k~`A @K.r?>JrZJv=$ b\|F`^U*.U1ߗkڹ[K,wezhPInS.tt12,G KW-  i}Z:9@rUQ3g԰OP3qiCHhQ8yH'Qڧ/֟ {hyߜYᣧ9kj/R:>Wxh`Y:Hn/r+Tby+ڢXUk$Q{L8#=M5^xW. נ߰]g.FV$,REvQضAow?7in3;1P7PLwnl(`/rmE|%օ+X@B$ŁPKR1b`@1gt;-!r'%v$x#Cpt0b^U.0%;̧-PG6Tviº ·A^'noS&#*x8AY~Y"kP6$tNQ]iP.RdM <@B(gi/7,秅 T(~QY:+ [[ Ғ6*Xn}-Ң>Q)Q !p;ܭX`. `}G| 'A< AOH11`tvʙQZf6w.U/h(UAhG;tԎBKMKogLAPDŽA@Zr^{~дZK5a6G71R?5J v^F͠%Xdo/~*2yY5Q [mAt 8KOm@$o*zYu'&ԣ^?kOI: 6lۘm(0> S@= ;4.%¯BaE fif uo8Z.Mr]vJ^Vxart$eMrܻ5Hc7פWcBXE; 6h"zQ]]xSU!J]/Ně KLcކYh흃UƄnDs\uJU۟saUPv C@z,$=tw}c(\.=I nTcMT;0pq$32&lBJO~~g3_'-o[Ԃan'%XWM ,iNѝ7>vUܙBAwytGϯOKE@1.udRͷiH=-\s! 6+5녁Wۂ:ѷVrR1TF)L2s_bE?@^i\4ڗ-K^qV!g,-:(~QAL?!B%h:gf6kL+`WnVX6_γDrP!0OjS]b6r:G^"/ qEߪSR̛<#vbJ: ܢ[9X}d x`gWyU~EBȶ[ZLG=T"8+'=c+b 4MUJ!顔7Yw!T։oK? 1(&u .[C̜&elUTg?l|ObeYTg|jyG5׍l8+UwԀNjD~)`W:`7o p SQz 5&ְbj)O~ Vς]Wh|)D/e"#"@53})tXlƚ4Z@[@X}SrV wo5= Zy}Ѣ0jXehݲe5>;,Π3nkgi7zpgBG9~ 4bf4joXR?Î$3 MҾl|uXOG JTXmF]n^RF҂z@kt2P/]=5N cUYnh߭4Eǐd-4hw{'~@\Te B,RKr\\=fC?T%`e޿K 00"( 2HGXrt:[vȒ5Y ƪ<>ׁv4 -f7@qjZe7jGMRc((RZ&n(HďO1b47 J}W}n r<Κv줧n!xuD#vh˥?>Zd$uӠ7 2]P>ր&}̺v`l&V }}X,&bߖ-dG7Zfg{ym dZ[*aF@J7ħ} B}Y1?Eɠh.X{7K?żtx(Gm=_WJps&Q'"-?%ݯCI*ƛ+m1`\ D/Hּ) n1{]1[žӨ:0V$E1 xlPtfc>L.=u3('ڣ*4 x]KyԽun1Ck ?H-Hg=\#i7h{@'U@A%o0o^}3׫K `#QhJF5}OzhJU֗>Qi/Am%UqۛxUgO Z Yg}̗N~ сd Ґٴ}TlD$S=ʚ{sA6T!Zԧm]^nlw2[x.A:Ujv ٕCh\ D#=c xmZϯrCn3(W.;67d[D- 쒝}֙LJs-=BNH˸eʸ.Az$YMnqðyX_nS OU'D : &B*@UJ@?\'7^,߁i 2mZgP7ojiÌ :#饾;ӆ"<&' vǑ`r6 ^H]C^ɹү@ RYlD\ʆ_[ /W/+K v k%~QzhH\,#\vӾ[^eaG;טwׁmeoKWm/ Ьn|4l *8HrF4ВI?'[6ovﰒB4Z-.;HvCiYS)ۧ,w}ȳ*Gb&DJFY¬.[@ s(g>d9G5<!C*(BϵS»쐇g6RӖ.vGOO_TVZnJA j]G\/Ee7.4.6A3 7M QiYVvy#:^;2lk=I/Yavjq5C3+S* 5[v(9 #u=~TF[. "{ua2J p$ U*.R4Xu\|b@9|\bnhۧ% uץ殾k.`MS艀7&P$/>WR2pҠT ݤKz) `OAf2pUgj|MS 8fi8xmihsfq{-7 O:1!(_~*wvM;Cuol(؞EuO_K?U*xaޢ "|%h.6Z7@>QzS(UcXr߲] | MwښDP1'j]W A3k:9\-2~:qo>95`A:(r®m`9l?A-ZF/5":"<(OE{~y`w$ỤA,®>(Qf[`[[Z= ,C5W~>:ƻdIGt\ N1:o>(3ME7nhO'j+tx|lf"[{Cty(Ğp: Ӿ3`tᠺO~ f-:'NZuU Bn0[,yNv-lvۡ~ԏEzős{v-~_nN7'TTh Ao??묿   Ok*6v͡)"+j-}t H~n~ì!6֕aU΁> HxҺ oPuzހwAX9klWaGskG7YrۍB1n2;8 vgv]/MgW&>|/TaU8vUA@:Q6 1)U;h +A59iqGTS'.؟3,롻xaβbsX^msv}}&9`)O?`2!8zۄ#@Kv`2pAkZ<4h)Δ<Zڞ)9N\?<V.EAI1m,YVYs]& zݲ|K/*-Z[ oK-ܢunUf5l[D>zNmEjCV{ Ǖx(cnff~Šx`*H(nu4`x)`@Dz->,̯iw5 TZY 3\ SnO㹳Ϯ 13&S8$R H `g؅<5c0u])s|o6W br> Sp[x<`S1󁭆 Zk;a_XOEV^󬾘y\XǬޔJxw6y?xuxmH?4{Oz[𥟕 g=P|YrCU:ek4[WUiP te>j;4֭J!8v?j0:l£~NpxWFu ԫ:UVmP=;td[4ߗ>:ҙ|& vgWvJ~pGmVjTyAt0jX B20֜:ʟn]%4g5}Q $69Om-x> !7|?n0C'x"~&!RR;TOI0OԘ ͼּxCyNCR' ,`O3MZ+~:l7>hҽ:y ɒ U`% aԾwC7z@Jop^@A832( 4)P0on`rf`L=q z7\A}u`}ܑ@_>bER黎S_2?Km0JǙvaj^q]7c2s9nZ!Ȯ.pOe =P>JU~~Bz& e3D7;Ӎ,m "q@}Yqx7儲^CiLp2S*)L[n5@XؑgPllEγeA3Z7_ngWgYg;X cE-Ag ֻ%昋6+Fx7\LiCfQG.c7 f[gki)>L"VSM%-5:عCl`i#s }=]ڠe`m/XmK H[|؂s\ujЋw ĪdJWC_1l;5|,4Ka7,6>#YxLeUjPBEzu]n,;bMh`lb5 dn .q/_>"HY%4t D`*H[v/ %^~R J[ X*i2Rx. x'z_u-ް /?g2ASo2p[6l}"}TüiǸ Y ;Zxh3EKCuO\mU&A~׬< CNYРk40W jzB7KޣW~qm񊇚AP\MtDo'S  VvSt7yrdИ2uچ%H#3A I&*Ӛh EIdiN;? Zab<'U?/ iPEjů?iVƝtnð*D*;Z݊gY[n!`(0t|/s&;Nu~@Qhw!D|* ڗtLdqRTY苭MIթra[H6N7شhi;0QQIvef@93= , **( sNYYC{{{L }׳-$3F]$`aӷ >Mo:c$/W`ې ڭ+݆OV?HC^,?Iϩp ߕl;Ibd~jQS{߀xSl'|] |}ۍyrBIؘd_ mB?1P>:mt_@wݑvڋ7[}ęW/!99~iK:$ؼ' C9!C{};׎*^ Vp >`G0oر)vW!HCQߏRoEZ{z zdi_}z_;A|Ƶk#TF5$A"73*)Z0L &`{=. d4Ovsg1׹XZs-,rj:&_@#Q{V7ƜBڞoӗ,>{ *덴_? Pg![ĉ҇Oq lF_fw~H?%#'\o6/dp>b^s /aٞħ{e#mI;S`C' Ә}&4ׂ Ew¬;9{>Bzuz~jf}yUHcJ,fD/Ib+`dH؆O]{u gU4LZKBv[үef$~Nʨ*z*U 4 po$I8'MHT)=_|We*{HY/{}NS _%ۊapηdμv$ݠ{$GDuC$-ELwnq)B1I3 -|^ 6_g 29ן"mk(O 0IqiF]!Sط2= sϦc*n=5`%Ҏpp z%NȰ{f}*}'RWo%h 63D 'A|l/Nᮗ`ʠ}H{y"iT[WR<m&IVI˕S2|.]vvus |e-Jϭ"9 vU$3gW#/$#;E/:;y볿Uٜ}O@]sv>g@](qbݥ KM;} siziGc ҜeJ ׀O;Œoܗٯ;)O"B\z#>/]ՈJzf~I{컒Eڴd0cO:~"#zӯИ3o D- =L5l0riuw=GW!=:|{3fl(R.td2' ?_+nF䋤S"wGʈ:JQH: zZ_%O+HcQH4og4>hkd 琶? inFwK{ѷI:_ 6I3H0>)>{Ikܟ6o듎e-˞^&"i)nP+$,pd$}+ܿ1E̙0"_+T$ lx\!}(Z_ 6EP{c{ykGx 36s鐴CF: 6fl/z e=lBl.eb|Oy?'>m>_l<9=/<9$BണC|} ׂJ[c^S_#TIג>eǖbkX{ꇢ ^cW 5pВe|Ԉ}eXB? s^=Ujح9o"moW6|h $yyi [E$syCD5b?O|:{iyk+,}2tF PU}l- :yg]i S?` zڦٻlṞ{6)slWt >sfaq>?T-XfJ!m̶th ]ۊY Dh Mu~J; ^|}H#Yci~C>3d]I}L8 dGs 1W:| S:y`?%{`Č.tO"ݫŻsq2Bis2L8!l AڢX_PޫKM;q^$=h#{Ѯ#>4Ђ}diiӦ{kYRM/gZvbn<Ή>mg6g!eMyΕdGIt<=<N7h3. v&sd^F M<=I:s{%Jv2}.З*ih$]Sc׹yU3ޢӂY}-Y}^(nH{n&1卖dvta"<-7^epl^6r_y)z!iE/'U+Yo,Sm+Y}-+SŌ¦aڴa?I}| @ތLJ6sg1mХt2JC2)C3PZ Aڂ EJcsW"eO&J ep C^yۻWco8=K_8eܓv[}-KOL͆LWQmEVw=Ck DS+sXt116qԪc4T8䖛#iϭJ68dY:4Hyn >JSĜ gS:e 5g'`qW*d '}Hb: CU0ܓma7SiӃ T6 I~=KiVc> b?]fbκhf!i=軞Ƽ VXdd淭AC cbɚy})K}gH"|_< 5lƌL[(">g2kH&3?^=&en U8VKcýׂ-Aһ\ϖbz)h~Jps5i88u$̆_@ʋO3׏`hGֺR[BZK@}F{$M2tZ5L@YSn\aU餝D[9lFڗ?Ooɜ6gAjil9[K;Xp(:b_qoR6@IZCB6Hc T~ `GIPrcV`ǕyvXԛ;Qޔ?hRs7ok/\IU/e??a_PhMٯ?vޔ !jP`rS jb7PkՇMR[km'vjX+KY[+&Ƿ:^F3!ʣjD{H/e@TK{ټy\}H{ޔ Uw њMYGHhwMv'.z꯵M8׫7kNBu~_&{8Vh#ޔuE !D0/_-Aoƪ{ -PQo^-|D;uތz 7&ܬ)?%}Klߠbfߡ jADo>@P fO0H5m,js% A}Zz30TCt,3|J0v<+g)~E ׎;f,#>ԛ (Lnä >ۯқZxN AFoB0VcyAMoƺ8d;g+ jm4&|N2I$~ތEa' ꋂB/c1"):UІ(z3f4Os ԙvތ=/ %ʽ]/c6,A-h^_9 ǁ+m0_u#e"W(}FXwX()e,"A}Uը_,Kތ=KTP_d/x] D/c ҇e`Sz7+/)7q= z9)[KPW &E/gX%UD['X-D]-A;/)| Jz#ZEg}q% X/^DoAz(Fo $ME^ Y``zi{~Cx_ W8g*^&m^| >,׳|(1G?p^F ]1=է?O2-r SA)h[K0z+nݹy|ޥ]q|&&9x>r_ KA+hb'}8& Ի$ |?&#LP_ k"pP\ތM jv^Φ pXM#Gz36Co-~]z)1= % ޠUel'~BPK ME2O $m9%Sqi pZ)7<V/[yg~VPԛW8's87el~^P҆"-/`soثyOQm}^C/+'ȟUG{ [*@o+~MD`uOq!@_jLϡ~DT^V 0TC)ψy2D>Lĵ^0\ñ%z9[+"VD昃g#D>BTGZfz9{WQ"E8hQ}H^6ȟ1X"GqIEelE>^T_?elD>A'DOk 0I䓈uE>YT__ehM\q>`ȧbz>6D>MTqG " ^ζ 0S31$?*0K䳈V!l՗Em2ǧ{usE>܌`D>|%D=,"/LWD>r E ̽D/c_HD8/xU04^|K5'/եvW&׈~^D!7Xk| 3`ȗ Q oMQ}KtaV|%5eD {7jQ}[#kD?`׊:Qۋ8&"WT׋q6|^B 6|#^Dwl&I\lf" D~?%oE~?-{"OTz^"*D_2vFDz9;+"PT?8.DSqNE~"jQT^)򝢺KVaw%Dm5."|.i#[/DLoƞK9|/ aGOD+QG ~s m "S_"AlopH0=`0"ȏ76i(-zȿ%rD=ψpLD9 &p\Ǒ^@2\"?czYw8!JO)QlE~ZTI8D8#34hΊsDA?9Q0F"?/"!/jOI$Kx8DXs%ޛKQ SJRH26A+wk^(B?8+=I/$*MaJgj/0H$p0X%u5U%6&NaćJ3i" 0)g0\1 JYez9{IAf0R#%gz$>JRGKZQI9r6G1Cr\J|,ྜྷ'C0_qG@OM^@]HX(OP DOp+WU&I|՘50YⓩZd%"(q&,aħH(p_aħMʯ0M!D.E[M.agHj'!D)\!,ϒPZ~S-/KZ^aH\I Ry'*K||Nq $+qo_ԅ;",""|wJU Z",b"\'/8]J|)qs5&KDxCoH2IEX.8k1+$BR>F,›SRߒlaWJ-WcJ⫐VaW ~oKm"|{HI]#i} Z! &>aI}$»}\"zI i"a7J&I'"lf"z!o!B_$|*{:9 S%> cD*$H aķIk2G$~(irI#Ja.vo'|l >ǒ R!Jel;%". T%[RHڿelI3A>@Ŀ s}P/%%u/dC"^ O">IO_I+LF/$fkMi(ߊpPXa nD a&nXEA#?Bpww"|#o0 'E8*D,zJo%>-E:-1]3"q"}F?!gI;L/A?)!$~ ݳ\/cE8-ӘL/cD^ߓ@tO H {8$8+. I ~S 8HpN9:e) K:8>\I)kCaJKd$mO'e^_2-ތ )?%<(A+$+2;HOd'me_VȄ`ʸpއJ0HdT{F2,lCd>DV_͸#K̟ա\ <#gPAa2&e^FJ̟q;H0B#d>sHYe9 F| sZA+qތ=/XջelywCz/8e>^V_r6A 2@7Q2HIL$`5d &||qE ^2$")2ȉS%*Ds^ΦI0Md_ ?e> 8C2Ml3e>SV_ 8ȗ$%YDX-̒`g˲v5 |Ε]gd>m26G2/ d$xEPz.0O2_HY| |AWeRg xE2_3}B |.=8q$xM벶 J T|%X&ejkz9["r/UAYT2_!oʈk%󷈰 +exCU2_EkveZVoAe6b#wh[M |$X+P,`ɨhVIߕQOm@Z2_Omߖ`7ЦC FoM2 lejl&JE[d=YۊZ'2a,[eUF6 X/6o#z >4'PmC(7ip*6I?IPteSrcEYB {_2!AYʭ|*O8mNB .Y-kJG{hnI$g2qO%\ˈD 2#>K)y^vHW{qQ|*>]vI_eZm YZv}e~PV׎ ~&!ջ[2HpXeu-_HpDGdY]/g_JpTGe[Y{ad~ fle~%NG}% !!|'h/C I1V%8%SXA N4:{b)$8#3v^ƎHpVgeYko$8's4GQmNҷ\jr ~1 z*v$^&B'; z+>^.g'%> %(GOIW}nm!6qZ~ w@{ +6^Ek>@Qo΢:<+@ 8$A o`c!K0DCTS[.HŸVԡv\OQ3[ތa‡)40\PY?^O"haGP! 2THS8 }eQ љH'hVwaN 0VcQ y? ݍ9Hq tVe hw<-DOTI"P&+|S_TЪy'{ S>EQ*2LS470]q ag((hAag*6dQ20hf)|K0[ᳱz9# YAl5j s>GAŽy*|.;Ny G ezDFT /PPe QOBUOa" $"/RWFMa#OQ(/ކ-Na—b>_SLé& ]QP83eXer6C _Nd +:5^Mo)Z@/gdX ;lV)|vL,j&w"G̑mMbٹ2wu͓aa!˰N0 Wx *]dhg"z':|P ߀j2lTFE+ho#*&oRTK먗2lVf+ȰE[EH/=zM>ɘY˰U[e2lS62>PF/geP*hV?RLoʰ]ۉ +cEDўAawPQCdT*hdgaw*hh KA&;2VnZ=)kdأ= z[+g x>W /¿%z[/× }$*|/u^6ʰOLo+5,~krE ?_+[#z9.1O ~tD ?)N*RQRpZ{EۈkS3 ?m]2UYE#hC3w lB2S9X?WD ~[|*}!C޳׾6 Jxл.Xd OJP~J>%O 92-}K~%i%(㨾a@ Pwz9;(>s à>Ynn2 .჉p CJ >D}DM/cGeV‡6ط2 /KgK|L%|gad IN0"L:at MN\ D[{ cJ! cK7{ƕq%js;#>DKB j}tb!Ä>q0O,2t^I%| KvJT"N/gR§T~R%|*@lJ[O)0O/QH@<(0ϠB%|fRvbfY%(in>(r gQT`N CH>Pj V`^ W%(B%|>wP`A _@5Ɩ?Ḡ_ Xɘ4fĬbV]*MUwˮk &[!NƜpԬ ǂ'c1&bkLح"V!UJj@UCZ" &#,Mv]Y: b-h#뎪41sL;fDZ%X.Ӱ 5 ǒ=26O܊D3Ž~_LkJbsX]_UkԙXt@W3PH8jډlsF,aƂK`; w ɚMN;[B¬ΆmD"fK[V!D]UZ\iU0la{fA]HWtfI[DNׄ,efHl͘&P̲ñ=x/x`d,L6!Mо#Zkj%plYeF06o%p3{뤺7/\7b 廄cjmx(2%TO:Yv̈́c[٭a,Tm17KGX,%ͭ[*Xm>+dD. ρhU]V­JxztEzJjc.{ zK0_  4c̹߲&nuhDΚv7̸0NJǝʌK'3JT_&.f㿾(WGpIxxIYU bk4UñH8f>m3aES쿱!c {cN80k@]vfJg#JOKzǤC J|Ŗ {Ix؞e,0_N,4Id"'f!Sۋ27>lwNeGf \q;뢬P[oő$9JԇH߲8K49dv$1_N.IX+mX9@:gp7-lvGfUm9hbVUv[mUmw~[onEMw_e+bS#Bvbw6Bf"ӻcb#ခeUG> #W5f|44''ȹ'cI4ю-q_Σ﷬_-9d*Z@4H;wENT#d51ojlxm[ۨvV4jĂ-3&~ۭuϫnkpv3TWRY7b"mDMJ &P 3p̈0,6ij GT?G:zgk2(\z/ \I3 Hbi.JLCyn+0@=lg @W#fnUE٩xZd t!KuTB0Nm4)HY*#ap 9rݫ B3Y|Ѵbm^v+nӖvq\cv5*SqݣU[Fcbm-+*u3yvYjйG6taxsB ީ"]0AP1o>?D5y|A0sbVƴpAmX>2Jad٦+O˵5=b%K6C0lwZrYvi]A#x\aیg> Yt~vBNQZcIy\EZOV01cw H‰jrbkV LeV BضewL* ZLKtL<`9'yF(=l"f]=bv O8h ~!DLFH1edV(c>r4aF}EPML eo٦F"ź uGq+jSe__䱖ݍmvV2h ^Q_dae݊{*tyE_gnpcqPoV 覢{,M0hbk׀K9*h(5F |MB'î{̲Lc0bfǡ@P6/%o(lt;lW4̊1>E fYukX`W&¡8U5f(_Y\Uiv2bI#7Vj:nY) 1h8FC(̴'f|Uh5T"7d)hfkBn;Mӄ{eUЬIhVw2]07oʶ v2si4=UiYOk.q͆, {<)bh M#hV;*a@;6Nwl˪D25İD5aPly-$X6sl#0`фr*j`B^vMmtVH$+ҨXF`p9~4LGhȫڈv5dI\@>pd,0CUqۊS_mvBVU7t_1/vrm۪UٰUG:E ɘX8tmݱ2MQ3"r2옶9X{NNpᘃHS1W1j?:zYNtŞ>4(IxZ2dތ8G˘h,#=,8{ϑTl[!LxF]9+Щ ‰OK1{8r*3 ꆄSG>U!!hDeĬffW/Ưc^lRgg&HKɾH8v#Z(e$ eGdΊXEIц_u# Ydf?34+!bvS13!hTq6ʇD}kմ2F |Ip9z3qE\xj= >Yŵyf*뎖UmAO3#F8BeU8Tol?u{n3Fr0ed Uϩv#a̻`dcmqz[/:m&fߦh̦XѨKvoۚC1 ZƒjP]b%}m\MDBJ =g=e*z=z1ztŠB:o9dV{1>yeF:fQإ9Qg&[+e8GƍhzW!mjjqQV_%qy #Oz66 y>{7<^!y@tp,c.ʾ>KQ/1AkpZVp2fv[UܶB!xMS]f`Df:`]b4НX-yAlqxy^ ts0)]5rÄM!֞ adDl\8 D1720kԠ{>nBvΟNoQh 5]mtClȦlՍ]i'*I\#mqhU;uq27ߌ+V~#{M32i6!+ zUC7{/udx^r "zŻUJD.ڶ?"a4{٦ᘏvLR:H)31/1u=kk'k#?O?zhtspz8^8n&ތ%6;rShT# )"?=F>ڹ}@<\Z7ng͋\͘]wmǬ{lKT.sQ"5kF0X0ם`qqz"jv8VƻgsIW4 Iz< _5|%=3wmx/stϣe~Dln8&*3t3c1]ʕ`#^ejXN=2g`GcgE^F̘Gu {éwDU;mfEC<!өNED=[UP&p=|Qm"z˪b陙̔0{-J/PѺ =݃Ô=l$*9b.U!s`mm`0(jF,u` ׆cF>uu[O^k:[8p&CeU=T+jf STNG4Jɰd8h´@]d'kx,Ag~"2 sVG\:v❪QyfڵX0R^-FZ#PS glPlkfa6̰CxƷR:+ɚ-8Sdž7b;MEWJ6<3*b?3qrBpPs>vg?f!MWՒ5n8'|nz5Yu5t p5k?|̲#A:0NR!']Je"7/<̉ޚ"fLtwxp o& MfE]tJף_ zr>]]պu'&ͽXzfr)dƊ$kYX"p(fD:y6hT<ѭ}ߪAM'kT՘H8᤮>:'y"o[[mH LP3#@E`mN0gn>\cJf{Md- n${MM6JKY(@4-y){83>`u~AJq b{wy$KҤB+ MkfG[}Y,]jbfKC޽щf D3b0Q0]<vIt;\'hdx 5mт);imԩ@WkN+jlզN@+±x2/MQȴMyLfpM2V9}͋<=/(~AQں#"Fo<\eه̈́t2l$Zmx,yov6929.vvж!?jY0&UmjΉ8Gq&x^$9u/cFͤϬqgG֌iyAhyj3Jj/=2ZUt= WHEN. WHEN accepts RFC3339 timestamp (e.g., 2025\-08\-11T01:00:00\-07:00) or date only YYYY\-MM\-DD (calendar\-day compare; includes the whole day) .PP \f[B]\-\-until\f[R] Include files with mtime <= WHEN. WHEN accepts RFC3339 timestamp or date only YYYY\-MM\-DD .PP \f[B]\-l\f[R], \f[B]\-\-log\-file\f[R]=\[dq]/dev/null\[dq] Path to a logfile .PP \f[B]\-m\f[R], \f[B]\-\-max\-cores\f[R] Set max cores that Gdu will use. .PP \f[B]\-c\f[R], \f[B]\-\-no\-color\f[R][=false] Do not use colorized output .PP \f[B]\-x\f[R], \f[B]\-\-no\-cross\f[R][=false] Do not cross filesystem boundaries .PP \f[B]\-H\f[R], \f[B]\-\-no\-hidden\f[R][=false] Ignore hidden directories (beginning with dot) .PP \f[B]\-L\f[R], \f[B]\-\-follow\-symlinks\f[R][=false] Follow symlinks for files, i.e.\ show the size of the file to which symlink points to (symlinks to directories are not followed) .PP \f[B]\-n\f[R], \f[B]\-\-non\-interactive\f[R][=false] Do not run in interactive mode .PP \f[B]\-\-interactive\f[R][=false] Force interactive mode even when output is not a TTY .PP \f[B]\-p\f[R], \f[B]\-\-no\-progress\f[R][=false] Do not show progress in non\-interactive mode .PP \f[B]\-u\f[R], \f[B]\-\-no\-unicode\f[R][=false] Do not use Unicode symbols (for size bar) .PP \f[B]\-s\f[R], \f[B]\-\-summarize\f[R][=false] Show only a total in non\-interactive mode .PP \f[B]\-t\f[R], \f[B]\-\-top\f[R][=0] Show only top X largest files in non\-interactive mode .PP \f[B]\-d\f[R], \f[B]\-\-show\-disks\f[R][=false] Show all mounted disks .PP \f[B]\-a\f[R], \f[B]\-\-show\-apparent\-size\f[R][=false] Show apparent size .PP \f[B]\-C\f[R], \f[B]\-\-show\-item\-count\f[R][=false] Show number of items in directory .PP \f[B]\-k\f[R], \f[B]\-\-show\-in\-kib\f[R][=false] Show sizes in KiB (or kB with \[en]si) in non\-interactive mode .PP \f[B]\-M\f[R], \f[B]\-\-show\-mtime\f[R][=false] Show latest mtime of items in directory .PP \f[B]\-\-archive\-browsing\f[R][=false] Enable browsing of zip/jar/tar archives (tar, tar.gz, tar.bz2, tar.xz) .PP \f[B]\-\-depth\f[R][=0] Show directory structure up to specified depth in non\-interactive mode (0 means the flag is ignored) .PP \f[B]\-\-collapse\-path\f[R][=false] Collapse single\-child directory chains .PP \f[B]\-\-mouse\f[R][=false] Use mouse .PP \f[B]\-\-si\f[R][=false] Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB) .PP \f[B]\-\-no\-prefix\f[R][=false] Show sizes as raw numbers without any prefixes (SI or binary) in non\-interactive mode .PP \f[B]\-\-no\-spawn\-shell\f[R][=false] Do not allow spawning shell .PP \f[B]\-\-no\-delete\f[R][=false] Do not allow deletions .PP \f[B]\-\-no\-view\-file\f[R][=false] Do not allow viewing file contents .PP \f[B]\-f\f[R], \f[B]\-\-input\-file\f[R] Import analysis from JSON file. If the file is \[dq]\-\[dq], read from standard input. .PP \f[B]\-o\f[R], \f[B]\-\-output\-file\f[R] Export all info into file as JSON. If the file is \[dq]\-\[dq], write to standard output. .PP \f[B]\-\-config\-file\f[R]=\[dq]$HOME/.gdu.yaml\[dq] Read config from file .PP \f[B]\-\-write\-config\f[R][=false] Write current configuration to file (default is $HOME/.gdu.yaml) .PP \f[B]\-\-enable\-profiling\f[R][=false] Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/ .PP \f[B]\-D\f[R], \f[B]\-\-db\f[R] Store analysis in database (\f[I].sqlite for SQLite, \f[R].badger for BadgerDB) .PP \f[B]\-r\f[R], \f[B]\-\-read\-from\-storage\f[R][=false] Use existing database instead of re\-scanning .PP \f[B]\-v\f[R], \f[B]\-\-version\f[R][=false] Print version .SH FILE FLAGS Files and directories may be prefixed by a one\-character flag with following meaning: .TP \f[B]!\f[R] An error occurred while reading this directory. .TP \f[B].\f[R] An error occurred while reading a subdirectory, size may be not correct. .TP \f[B]\[at]\f[R] File is symlink or socket. .TP \f[B]H\f[R] Same file was already counted (hard link). .TP \f[B]e\f[R] Directory is empty. gdu-5.36.1/gdu.1.md000066400000000000000000000110531517447455500136460ustar00rootroot00000000000000--- date: {{date}} section: 1 title: gdu --- # NAME gdu - Pretty fast disk usage analyzer written in Go # SYNOPSIS **gdu \[flags\] \[directory_to_scan\]** # DESCRIPTION Pretty fast disk usage analyzer written in Go. Gdu is intended primarily for SSD disks where it can fully utilize parallel processing. However HDDs work as well, but the performance gain is not so huge. # OPTIONS **-h**, **\--help**\[=false\] help for gdu **-i**, **\--ignore-dirs**=\[/proc,/dev,/sys,/run\] Paths to ignore (separated by comma). Supports both absolute and relative paths. **-I**, **\--ignore-dirs-pattern** Path patterns to ignore (separated by comma). Supports both absolute and relative path patterns. **-X**, **\--ignore-from** Read path patterns to ignore from file. Supports both absolute and relative path patterns. **-T**, **\--type** File types to include (e.g., --type yaml,json) **-E**, **\--exclude-type** File types to exclude (e.g., --exclude-type yaml,json) **\--max-age** Include files with mtime no older than DURATION (e.g., 7d, 2h30m, 1y2mo) **\--min-age** Include files with mtime at least DURATION old (e.g., 30d, 1w) **\--since** Include files with mtime >= WHEN. WHEN accepts RFC3339 timestamp (e.g., 2025-08-11T01:00:00-07:00) or date only YYYY-MM-DD (calendar-day compare; includes the whole day) **\--until** Include files with mtime <= WHEN. WHEN accepts RFC3339 timestamp or date only YYYY-MM-DD **-l**, **\--log-file**=\"/dev/null\" Path to a logfile **-m**, **\--max-cores** Set max cores that Gdu will use. **-c**, **\--no-color**\[=false\] Do not use colorized output **-x**, **\--no-cross**\[=false\] Do not cross filesystem boundaries **-H**, **\--no-hidden**\[=false\] Ignore hidden directories (beginning with dot) **-L**, **\--follow-symlinks**\[=false\] Follow symlinks for files, i.e. show the size of the file to which symlink points to (symlinks to directories are not followed) **-n**, **\--non-interactive**\[=false\] Do not run in interactive mode **\--interactive**\[=false\] Force interactive mode even when output is not a TTY **-p**, **\--no-progress**\[=false\] Do not show progress in non-interactive mode **-u**, **\--no-unicode**\[=false\] Do not use Unicode symbols (for size bar) **-s**, **\--summarize**\[=false\] Show only a total in non-interactive mode **-t**, **\--top**\[=0\] Show only top X largest files in non-interactive mode **-d**, **\--show-disks**\[=false\] Show all mounted disks **-a**, **\--show-apparent-size**\[=false\] Show apparent size **-C**, **\--show-item-count**\[=false\] Show number of items in directory **-k**, **\--show-in-kib**\[=false\] Show sizes in KiB (or kB with --si) in non-interactive mode **-M**, **\--show-mtime**\[=false\] Show latest mtime of items in directory **\--archive-browsing**\[=false\] Enable browsing of zip/jar/tar archives (tar, tar.gz, tar.bz2, tar.xz) **\--depth**\[=0\] Show directory structure up to specified depth in non-interactive mode (0 means the flag is ignored) **\--collapse-path**\[=false\] Collapse single-child directory chains **\--mouse**\[=false\] Use mouse **\--si**\[=false\] Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB) **\--no-prefix**\[=false\] Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode **\--no-spawn-shell**\[=false\] Do not allow spawning shell **\--no-delete**\[=false\] Do not allow deletions **\--no-view-file**\[=false\] Do not allow viewing file contents **-f**, **\--input-file** Import analysis from JSON file. If the file is \"-\", read from standard input. **-o**, **\--output-file** Export all info into file as JSON. If the file is \"-\", write to standard output. **\--config-file**=\"$HOME/.gdu.yaml\" Read config from file **\--write-config**\[=false\] Write current configuration to file (default is $HOME/.gdu.yaml) **\--enable-profiling**\[=false\] Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/ **-D**, **\--db** Store analysis in database (*.sqlite for SQLite, *.badger for BadgerDB) **-r**, **\--read-from-storage**\[=false\] Use existing database instead of re-scanning **-v**, **\--version**\[=false\] Print version # FILE FLAGS Files and directories may be prefixed by a one-character flag with following meaning: **!** : An error occurred while reading this directory. **.** : An error occurred while reading a subdirectory, size may be not correct. **\@** : File is symlink or socket. **H** : Same file was already counted (hard link). **e** : Directory is empty. gdu-5.36.1/gdu.png000066400000000000000000004011111517447455500136710ustar00rootroot00000000000000PNG  IHDR @IxzTXtRaw profile type exifxuQ[ =8mޠ/(3.`83\( [ʱjRP Ck;]pޮkP{ Um"*5Q udDՉzŧ.(nCzX~[7S@;cz;I/"Gy R$@2F[ Wc&1v[{i׋-<Lu,iCCPICC profilex}=H@_JE*QP"kP! :\MGbYWWAqvpRtZzp܏wwQandBV+Bc2$)>EyVs~5o1'Ǚaijy8JJ|N .abKGD pHYs  tIME"!Wt IDATxwfU}?k]v4WgCA F%^#Qz`1EƒDHj,1FXx ("023g}wYk?~Sfmy眷{_x<x<x<} x<a&Iɣ^ Sln+0oc_Kp KO x<^px^ v^Rx<9  )c4dz'~x<ہqeq=gRx<g8x'.j uy~ TO x^8x y\ |7@x<Ͼ/:b$\xYt?+RyE R1 xV6@_ӹ&9B p3!R1R {iۭZkH%ֶ۟7ƠF)1{mAA{1=grV_.BqD ZFTZR.& C(F csٓWfx2^|Ch/@<\ཅjfI__CCCر[f6o̦Mc۶m}vh^F RbqQbgό&@EDQDAiӈrLL>'2g&MiS2e SLA rK !xx<g/ {q%$Ixɲ c Rts RYf [la͚5lݺz͛7eDK Tފ"4VMRaL: 0}t/^Lww7ӦM{~[-1s<ЯB\v/@<xlllGe,[m۶cm6x≶=>it2q Q=yTzzz={6'Ofܹ̟?ŋ3|fΜQi6J%4mɓ%Eq NZ6O}6+DŽ}/ 񼚅^ cn%|V[k-I)K.eퟫWhhZ8nϭLJC6MM+l{mZb5p,vۏ?iӦqaqpQG2uT:::\.f ^qaYCO3g0Xkxz^,n~5xx<ϫCxԀ?W`f#Tرc'+Wcժ}ݬ^]v"B`KE\S8.‰ R` QN-RrC!7\JXcر-Դl hTRqđG)'̒{^5Hk-J*ڳRy ¹,%Fd-|euB{x<JދƄ4MMt[Uvڵbٲe\<ìXa(4`@P:~-3m!Y!*jZg7j`„T:;MDX)Q(JJ%38+ CW7I0f:Y 2Hs:4 h4 H0iFIھ.FꎱuRc 1)}BV i%ʸu YUH::2e o:dN9dNxL~9s?> 9uMkE3FX),(0(UϽRԯ BXHjqގeUo}+Qy;[B| x qk{x,dYR!uGGu߯ndƍ j tƢXQ4CJehFP:o6Z£3a4&̜r3l`r !]\1b`H2#_ڧ7=&_qiL@FKblnFĖCi8%u!D#pi҂Ԗ;x`^6}f5<%!栭 S#3WlE|JcBM}o0/@<ye_:w  *Pc F֚nG\}ujD ('Y &q Ee~Z#9is`P2C4)4(Ehc{D] ף=N0<y.bfo Va*Fq/SyhuU8&P0koXϪ{ne}-h@D( "âD. .?܏?zs',/@<kXGW\ ;c [n[wygUUU ]Vs JuG#{"&Ua&sR!0RO ;> UDZm։Ш,oF]eUwE2 .<ZU>ù y׻ޅ !^x<dz 2ᄅm۶qWo|+W!iR*4K)W#4+DXh1yѯ࣏c~tNmH\G<#CJ*I %@j*ZSH@Bh ] KQiGW[~Ol BFKPJ&Al6#ތ+c{G*EQ5;{׿u֮] sȢ`VcxxU*סZJX|,~)tYvCQhi*!Z!WE ^Td-8qe r}fY̴*Οe+!,$}}Y|>wv:DE9'> .\HH)"w`GW$!=]-:ooHcBKx<g)Kؾ⣕l |K/eŊ"22Zdj!v9`̘7'$#u19f>(okňd_ #剅Pm#r1 ,U$jxK[};]z(PJܣQiʬY 8¨5RgY<%^FأHйXr]'=% yBx<ϫU|,UIemF\z\tEZ !]j֚aLTF['O;3gMn4BZ19JEU*1R{V80)q `~!X{yĝQ< }FV6T5QyƵʟBvbİv\0Q%? vJu<#1kl[<\i&֯_ʕ+پ};6l`ɓ5k .d֬Y̝3% ^x<L5C ӟnZKdYFGTeU $4kɱo9ǿ4$Ѡ& CCtK£!,6ivgݪ8 1V= fVzֶ  2\dYF,],[ P˲,JP뀞.ٜxL6F-hjrR IHk"dD\V~>.%@ M2ey8.x|+M^nacD Y oqe2@Q% *41Of``fI\i{z(W+=ݨ =d"9E=jJvc2ʼnh{R ֆ|+~X^x<{Y1[]dP/}~4M4E)E\ Ɋ6E2zrw gc j' ɳ]^ V a]kXa02E,r6l\[7aZz7lM$08Y:I2j%(܏)sg! >{^3pFJ/# y'X$@b͛D=Bnĕ߾=!S))Wbɱ{|{cΜ9lݺKع}A9Fk-\!i4(W̺ .bmH;d+ d)q%TPAC7/}K,Zhsc7Xrxx<}]|a{?|goo/+Vgڵ-[sʈN4ZƬY={6Gusa̙qL]]; IDAT(Fjw(R)Bu$I½G?Q|$! C2CCCk4z;o8m,:z [H@KUn\Xmrttm+I|TVhcZE*ZȲ4M1 ++EH(ŒFB^Xly*S/OF=Z KS0\Hat`10CClyx9;Y"\mjQmJk*"ȅ`$. 6c>^sY _˔P0l4UFq#>\ooE m-ENBY)"mXz\Q@M*G hߗs9׿fŊnhy El!fsW[CBy+` !HuKPR91rh4iǹ U_Q⹊g!x< /z¹31)6l77zUVQ*h4mC!,8ۯCeŜr)l)QaAkNK?IEHŤˆ42$' 3_w,pޟ0gb6aTȌn DYPF5#( (ϵ0%22@*k1ޗR$Lk,olw @ EPʒ!vnw7<{AY,E"qƺUI ]ηt-D!>d1 `iVՁ ԪP2urr#%cUR| t9%ʥxj%Pr`O oULB}RԾO V#n4vB~3o ^sA9`aDR"y iɭWlW 5!҆ponW=}!M@L SN,^cu8rp5pƘFar-[]@ UYla-z (V٩e\Z)%R!1R0*FBk8ȣd:~~R-R `2KV瀖.?s=x * ɢGa,An)!iˆ,3:; ;7ch[/wqj6^ǦUY`_/d9"cD8ekE";P0y~L=Q *"2(JDMBXiL,Ljgڨ e!,129Aip(lfA{Krsڶ߀"eJ%6]P+A咍qBADnB7,J=;--UC99KR*#ɨgհ!Zy@Za2G.$;W#@3#5h@j]LGz"]S cI# 'yA (cabCKrO~֛n0Y r7^#j/&ͦ;?!^X^x<g_ _~"ګӟ ?h \-Zh7Bb~ "(⤄[ś+ADS cm.a4LRG-\cNBPhBv>gILr.mw .5Az;Yl!l@K"EĐ u.ƱfUb%GW,#b!G0u<lyb;='?-J'y¾91Clb0J&|Rb$wokr9;W> P[hp=c څ Ν˂ 6m ,`ԩ̜9rLgg'JF:[la˖-Y[f֬Y?4Ƹ vH ԚR +aKxLJ>Ģg( LXl!˙;m"Fњrƒ_YXdRA('D9Z ƌbHlKTMx)HscWPXJCC9} z .AӅkҠT8ۋ${ylB<xϤRΒ$26};Eo AaLƤ}` J("% eF5#,Qk= #@D (cy>RTNMۏ9q'k=)6R:oNBb\s=aFrAɽLhiȥFrk^%L@ tczXJ^0<8HN8>h:;;;wnyY.:vA%&7S2x2#IB%.՛DA@5۶|"6r34 AK F4s.cKPz!xx.]\U\&IV>{.k֬4)W+$FcVuFZJ@F5g> :2E=twwSըT*lp?C}ytlZ"%o4b#[Ґ$OAX("49 dLUO?wş5o.A %JvY]# n6`u+ȤĊ+$|!-@ C\>4HG-w*apZDKBJNgiܱIȒa^y7޻ `s ZvP2hik7k r fV.HgPA՚j!0. Ru6@`CH,Lt'ljL_'H)i4STиQ\n ND ʯ Eu(Ä ]lNSgL~g}6'x+w2c3Y"KJ2l'_g!#tNXQRo-W]]JJPJ`uv1xn,P֐2`ҤIL>iӧRTV9zغe ;m'sCäYT`uЂ,D@\3r9gN @Uj.goƕH3.Exbx:'.E,C2RZXA+1'aeYTr k-F5aH\{u| {A]5 A-L)9-0c,%#"uB-+{HF91E6`ѿ,)HÓyAJ R4r4‰Vj# .HYgj]??W FWM+_ G0Q :y8x^#~yo^_LRb2ۮ+\JKNNy &Sh6b \ܾ}OBS)E1VPdb\f~U E(# i0"@,Nx>#Lf0twHMLI.9Y"Ӝ@Plz>Ywͮjuu4Pr!L GpjΜ90/> pQG3m &N6Bfh۶ey֭^Ýw`ӖMH*fhcU=V!HEǡr9K]HJO Z+#r v)BvbZO~1}F1ς$kK*@t^GbhK/ٰ1rC' P.4**CGb'c$MAn\ U^Yv9fV`ǮT)W~,i &a'v0yɉ+$񨌛Ȩ{]("FKv;Z90$~ _"d)Ja@8#QQ|YBkx<鍠2K O|Rhasg$H?!z$AZAX]fU ڽVPHTRl610{6s7|o`<%4.%K4 =}Tak3a(K)*$pk8d艭˹a`RM mrcOtY(:d :JQl+qKgzQI)wa,K1(׳$ -_ϯ%|0TXCꚨR!3P *U&`( 2 M~xB ng0I"䖟&V12(1i1y>>O3c Z[wHI8_Rl6 НK\U(!R){W]T#-t]ƒ 4UĻ?Qq,']TWk0,,Ar)(IM%ʯKsWشJC=SF) ǒ = RNH ´ϥ2Hh*IAiUswk c0 q{=7pqBo ! xXL{i-hij}} eplEg)&ɀ%9+n! )9e 5ɝ A, 16/= J!~eaRBps]_@=(eݪXrBfBuM 3ᾥ|dB#sC X•hwn ;8s,= ð "Kh\..11WFѸE5/QQs5.hFA\ qYdasNU=3, x|O:gBf=b#F`-6fKamB`8+q)!ɱ+Dq ~҅ 4i'yXx1Z}bkYW9`gκR2[oβؐ`"֚&tlUt)ր8pWڧ(J RBlb6x"2lk+`QT \wm(*~D8"@kO@z]ڛoY\y12Sι]hL5߻W|!,,0Vr.#d#!pM߾z᭭amF&Es4]zy,2K -a빲aAX_2L,2E+JM9S6-۬k w]s5S/`ЩRV@N~?U6"]q^fRATPAhFWwn'mO'x?b܌dJT'TzN<' =jy/Et@iÃ$3kjϴFܺ X9>%%݂^헜 >:ߒ0!\1gE2@WvrY2\w|a[ ڭ[k) D)gAf<ܐ 5' #!%b-6AqD-͚~;-Y '] i"H\~1NP{q՗g.D& Y껲 FK*KZDUA|[k-۶A l9sEG ~../uُ+PWA@qӷּҋDc\6 V)Zd+;,KrvjGt20s/!Jum U,QԄ؃Gymaen&;z9X?)dY.*cꎮ*M{m. /ƏaGN>*,Ңā9/~4{:"qAML;/LT[P&dl_w>".˭j2Csp|^((k|Lsd#Db[ogPcф^@Dc\w/٪TVi:._vdM5b0c˛7sH>(}ݷ<g-Zk_Z{vӍl<.9|S;/*"tfOf$oYZTH;UL" ?h<}-x$` 8梟0o4Id&2P++ki Q, 3w cO' Mb*ƛ=jw3UU%QѵK ۞{xK(ȃDE kndoP-agY1s (hC<|I>Ħ@bc')%{sE~Br~}͠,~MHD^LbP1̟? e HAJ< NK K"zs"b3fP,2>{ !}!УGuV^E]!xk<^,p`gcGe'a4xԈ,#piqzDI2(E+.PStUJIh JF3W<:|>07+`TNU8Y`<]Fv-dhhIbKri9Mlbg\fGW_ż X< 8*IM3f ? $:F-rU#h($Fr7zc(H,6 \~\SE,H3 4kHdFZyj3b#+b"K1;=Gzrayth_ݨo<v:FU.8?߻H&6DW]"m m:+f@D MCw6/CaB!dcrU J#g+.&2mʲ(.-mwV?V`DQBVZ?];O~_$"aO!UW]W_^5BRAT#[NƕQ$ se̙|ᇼ_>:tl }O>x+, blծ{("\6lgF) )U._Cy5X@INFزO`Ȳqss35*#)Fxa܏S~?wqBbbJ,abdB[V$CN=c>ʠ VH?HA!ΣU¦ ȋtL.ZK&a%l6w}1twe}X'ºE40+/n@zd3$!L@,ۗŕ=rJ@u"k ֙ "" ?|;Ͽf͢*$EgXtׯcǎ_~ȕheyc#t SLA)A_E9KbC[YE! 3=yu:t+Fا%™");B:!ne#䧣eWMUve܂{!iAGKm.qŸtmAY8fbرTWWwLvB4l`UPA|־| \Y" +vm ygn{9l8z%?G-vq'Ln^x ^ s`vkmk2 WP(( l6V$W] /<8[Œ<\ #Z;geHMńDed6GV2:j"U AQD(Σ=jrdt˯ag`b=Bk԰rigPP"A~I=.]|?ȋ?@@LD,[~nÓO=Ş{N^ˑ5ݵ+C(}e" 1̘6K`|+[pK XI2z XAcͤ|1=o,s.ZBUlRWMJœ?K\-(a,u[nٗ_ (ROA=J2DF>z |VTAUUhfxoo߾c dYn&N9e$MMMmpD,)eL'>o&dt¤^ Qk- Xp?X,}Xf>貌e繿?F5t$Yz<‚ Yh ՋHT9_(fZaвAq_ǩ\ేJ~y ~lvAڐ[lI'`L1if"o0xTPATp.-1όŗkAO/9s zgB,T+)_=3L0LFbH--E5{Z|+9/K|yiA#}6_z_OԐ'iaam5Tg~@3;6Z^ꟼ@Y7&02k7~p%{P$d $iIVT1(Ćm]RIDscl֋w}U"ar뭷2yd>'ۖY?qed- r!$ȳn>]/AZo $AK"mzqO/,]_EsIpu /܌b 76"%^v,ӳCKeZ|xru5|3:h(" :s'o3D,Lm fȑruĞ8  *H~ \ t㘷z{ͱ\wÍ\wÍ|#839f9Lk-BWz;F`{%IdHFVC !#m[h顭!I B:֐+KQTIIM1?7~LlFtzOx3!l- qbDfЀ؈}nGx(1m|i 9%<&j յu ҶD>VWFɏhtCF#L}~,?9ͭd]jĠ#)Gz$L֧аn,yh,H ɭ{>~&sed;`u)%Og̘1c!*$0_/0pYGH_)]ò㼬\# -6& &1=,|ιFc)S#)3 !]?Dkx Zgp0Z{WUv5tRꫯ"ȩ# d:(LMww~ќ$AVr+y㞻  1d!0 9!XXn#OaA4E\u$-i_ʺ217 2YU]vek$b='_ܿI[ IgMK{+[ϋWLG-L[`/h^E?!e_ 8 G$q>: Izy>*CUmՒT֡4 I,Ij8TZlzo(g=LIq[yB8u1 f!ȆԖx\3K4"!aϞQtǬYNѮ&nRAT&{L8N::<78ߜ8p"|0/R}Ak3׆^pJill, Co]l2b뺵_:aKy3u(Ȗr m2RAk$'v뢾@(vg|M5?D-Ɨ$r#3:UO=zg| j G%\b!Z*HR o QRIŅ^Ġw&AM3y25}$IF!$L{oAǓ)ö{y>}g`ZxRr0h ;8b2nL23f34/ȡ ۴X9TB,$}ӿƊ FJIڰd|t\$(<Lگ>zN#M{oZ3&UJ!`4448+Y! TPA__1ܹs袋sa< !+⭉~|gʔ)xZ{ב$Ig}(JsZqБ , M"VYST)' "M& DjKHl&LgfGb1&1C*S.i7O?xbfan5DV drYz) J Їey)O$:|)g ")♘W{ƙ ["KAct#[d>V5uRrꩧy+g0ŗXx!ai0> nNLIBYpy{,[Q0m]!(I.a,|eLaVy,u GKcҲ=n6aTPA;YkwgE<#lu?ns<ȣ +wygII(Zֱj$w])AP5o+-2Hc`h+jClԄl֧}1EeT+@Zfq^bBb؊W(R- IfÜh4җ+ E~@NX{d  [W0pkюqQ|a4 Wɰ,h"H +p E2"O`"|eC[ *`݂>)`W>Gn3ӧuuuՋ !}X):]F/^ٳ3w.1f^Ӌx衇1 8(`$!Ą1}:ٳg3sLL`N%F*Z[]-JH=$<ʿ]@hF2rml$H3D{!'kDjl(M A@PfOg֛ @ }^xa"^ݹ|<3P2i z|z9co2OrQyK6 s{]h\>LP!?5u]au[JIh}87sM1%}ذaLv7["} 7Kњb9u$}{ 2Y$.)[(3DHZ3?6%Q'lΚO(f*l!4bWLRAT!O>f?w}atM޽s|3 ,`Μ9{<_ mw~w睜~w}7H? IDAT G>“pWq 8SH'ꔠ$.@)gbD918OX2!QӒ$q2\qT2 y58s=8qb1q |&$bŰpM|45qx <iKM}'pZOE444٬ P477dˤwwK1.'1T*_7'2bM eQ==1e2 #SG/bLGDܦy^j[ZZ1# RrͿqҰt v< 4(=6tSg3 }sc.]oct9pd p7avv~)ޮdk,mjc2," (Uj='<>h*zl綼0H4'V0 ZcA'KśQҰ`.S'I6 Rr-BPNx9s[yc/ߒCX#Fd>Y|{+0pδ89S~q)0tPg: k/;0qgZh̗1|iӦtkmg޷9sfu af{I) Wlb*"Mغƫ+Δ >CO=L|bmIu^#!*muݺxOߟ N# nJyVb&jΓ8g3aX>#GR__OEaHw? $NŚSFXAϮ=xW 50p)'d#1ƭ  ߰i8xᇙ2e }>SNeܸqu]p >g}7",7]r)ABn8!M;r B+'c 2%.nx)5гgO RL\陸?D[`m@4s?8Ac$Zʱ X!(-ej~2#F/fek?#<ѣG ֯ͼl(BiZΦD#xdi߇l|(-()>yG(Rtu'Y|9XKT(ZK׮];cXRATf,&NdC;ncG_c0` N׬g_pq쟒=ZY.\a<8kU554+dߞt4))I\iz)?WD+IvJ)Qm f~Q֠NIˤ}ኡ0xJt>~o"d]M caMz|mJb!#,-L0Ng9,_@mm-[ݚߞ!C oĵ>3 Z晳33GKPzMR#cde˙3uh b)N D&o?h  Z{=z! аbw'b#t9Ysd7JE*VY+ . O*[Phlߐe<+>3h XXi&rQYøp2sT*%mHc mYAhPUɀTPA!8q"{=le?ӦrYg}G{ݛ/9ghsȓyGJI(zg666y^9+=1ʕP$Ԋ%fSd,z`S:֔ɋ+^M_6 *Yl e 5yXK s}Jp 4EA||{,Nb,Y_.vCsr52;gᇱ뮻r}|cHc9z$5*r~ɹ|-VB5/g^T(ZˠAJ0 Jb>3F|2}0 a<| X,Osyfk-յ\2V! TPA_1=sa봟#Ƅkr-7ʱ~ϧmȮ봏O=mErWy FJP%SK3q2\X6J7$*5Tm2`Hg2:[es 2#qSvٓ )ޔdI&MLjf>|;A%esB'w}7?r+g l +GncS>fO>A8|ѓ^z;b%)5>#Jbh3֢!+͚&J%ڰ{z*6k-]3f̜xa6)p֒C5䄿&06M6۔-􁤭|R ;C1:-ohV7E 6h4aΣEh =ٚh :F K.y=jl6 =f)o%ı&)&T| )8]!;*B VW! TPA&?.Wv;n硇bȐ!xAēO>e?dIc=VuvM#:)ե-?)} ڲڹ0vvm]6 + ׏QzZB3(?Dm{j+yk_ŝw޹4w[o5_9g{?>8G}[fvy~{2L9Q D&Z4 |S#1 r7:56-RPrQtsDбmѾ$,2U"́_A,KUo A6%B$l;2h%RoŨQ:ŬtKv!*="= 7ԩS) <'66 8.]8BXZVs?'ximEd0ZV^Izu =ָ1O+ʛb QϬYSOmV}:)7 ~yK_'W٣ame0gOP' MQڞQ//lj:A&ELRQwRUUh9ׇ1B  *jp0Ø1c՛\r%o5hvmrzߛoM+X,l%oy331` ^Z]<,L6a!2 Fz%2HVDn|g?aC"2Fu}Cb1[1CXtU]M΍]rPkMU]-.50MP,\3 |#5$?G>1!9c_=L)6P# Ct!H-ﭭ-`t nYkfsHJଵ,__z<%I#+_.H ؀pZ2;"$qL-zàAP]M1) 3s*Gs&mBeA]u","'F'U)2={rǣN+~B@* Cg}F^kq '7sEkn}=_^/蔼R޽{)R[h^@9XxUZԂXDtz+ν=PbmMI!M><Ͷ݁r+*7 !hnXڀе[7껮uܫx]ͺ+{ٲ+}y'1cN{Wjz1X8q,;\-q[ﵵ"({E!5kV0$c!}}wߔ{X|l/1,Vu紿NHr]{gSRBC1q(dM8 .bK@*TSNk׮_RATGwӧO^?wI/δ .d„ _aq5/L]s<`J Z4|~B ePưx.Z%! RBklTLj,5h".^L(uRI|>VBGDH fk cR,¼c=_%ZHB-L~KX.NaCa5-۫_owyx9! $H+9Hef5ok qN>.:Yd @nCxidcr=8wlT Ɇ.^Io +q)θ܂XR%H2G };`YaG %!" }pcRQzeĽ_Ă*rH޹D K.X9 #\Fu!u!VG[JH<7ӧo= ZtŊa`5K~dHZm6Jjd…yLJW΍Ս3K~3~rUu/uu۷zR{$60ʊm}N]>l>=f%ʤn#2~<$R&)X<ΰuX*C[G+QWG>?]Żw"$,(Wd<B8o3jHY#e5$! $H~Cnv|-5{o~d>VABN9N>5󟧞+`=v6},a󼨬&Ra#yWH AeOAę(;Zj[27@p ؑPΟݓss.A"*].Cr\1kD/YZ3ݯt:حР#dkJr2̆b#6[!9a.+uעո.NZ`GqI'E{~MT,{ $XO,"]yhuo'$dԩ|y\z˟XkzQ> 3a„.,nDoX&6iu`d?3ğtҍWHI~vlNtU8$U#x.FsC}nk,Y~+~h)xJ%JR5hj8هDm e J2jkBIHE ZK߾}#ՀJQ*E}#C‘k{.g RP(q\&}1޻E.B 6kf|'e02n E#AIXx7wT:pk+=QS_ZFoyA{\<ϩqѿ_1U%<׆8_ I ''c~_ռ~I'SZ]s0r9+e 9=lk(>RI C3aOc8/Θ'#8>:$>Oz8E:.!q"4HCB vyvPNtRq4 t:Àb-V)!zTC]%P=]lW9,@ߏH{{{-%_ߥ5ۍN1z(2}%y:#T BcvYИ>ה|cm5)hr=X?)7R6)cC2]W?x*.d#nmѥ0*'+k?=8k ~l3a7 ! M sW4\ͶKLt>b,*Al]Xb! uTe|՗[KL>ѣG>>mmmUMHYNyiP#}(l.Gss Pg_xh1i9r$cݖ#{S"(dK\"ϓE-LqEtn*8̞=c(KE j+rl䡲 YU)L+@EZAgh>)EJvRlfH IDATg靫 M%} *8C:u*"d]wOlJBU*pm* #b2L& I~;`!C eٲeXR rLBRd#DY8 H6敖%Ə]yo%K3gNg((]pׯRE\OJ)Hʡ!~} 9 t_@ZȤ('H`3/1F*)GN(;1eʔd~8NXӶ[Z뮻*/Y1 :Ҩ?e]}mCJWr9Z} 1"=lH? QD%{aɟ,\BtkhlNЁրmY+4hP(IR`ܸqvҤIRL&FC BZ.\7]SR,G2cnO!zT*qNí>W^qK,!eҥ{2HwdҤIXkׅG_zrLx0x`' *mƨmŖ;Rd(wHTd̺F< yq\/E{{VIb(Rh$XGPҺnBWB@$HIķ j~vI?Yxq/o1%\B:\*E*~),3oy lu A %,>WKmFQ7l Fݞ@Jt\t!VL< $|#H@J6D)BgU>}H]իJBѧO}2x`F^b{yx Z5e: mB h!cY_K!5kVdB3eRz*=k CO\Zo_><˗a!':Ӷ%g`ԗ;yp.~\28e)X?7p{}z/t:çµY AcZ'[~_GLZ`„ L=u|Yd%[:ȂxǮgB Q[.u]0;lP$I L>*xvP EWkVl5~7p흋FdѰ6_q,MPkX De=BՁVK(wTACXĊA]ER0{!a, ۥMΛ7rL:fwuM6}7r@)Zc(ݿj/ C:#I{t~ \b@W__NԳ+-XkWSJ%\"=M3~_I/ԓNSO< RdĉrIU@S>t:;:p-x"-0پ ZtocU"L| ǰ1[(M` Z,jZ_|'pShyQj%0%GZY?ϻ{&FƩ+Fђ $XZo~vix/&3pGּ%\BXp}An^c O?/~K`,8,S^R7'U4e.C m̝=x-H52a=K:N 4&W0DH27V I~HP(V=o@h%O>J%cMz}"RץȔG? Հlxđ61m}ه=²OP@k]YXYL&uAqb=‹<㕗]ͱR0⚫bʔ%<#с̙~^9zӴ 4ճp\<dd,` )qG cbB(G&F%^ o DN`xC=47oOKK =J):[qsu9 BFK)ԖAZJUQƃ)F(E\?D:fi[+A F 12"Cz) %3lxl6#ss/ÿ#<—t Jy8A"qPU*LUCMKH >I8੧icg 6Wy7|=iӦ1z e?e2BJAiڃ[oIf  o6 @9\00SdSOYQnào1clץ&j.K׿uA 5t Nuj j ƌہ@my(l4ɽ=m1k,.R8/{~q)p 70}t."1c ƠRY&;.|!מ{}bJyJu_"1 AO>l2~kaIɬG| i'T2u{ ATFUFe7FT455qmrI QT9 h~>Adh~ } ZBD>}W^iGN7VU!!89~G!;^јtT{ &.O)q60\Ύ6=>`\3ns./DR+~oSCꫯf̙c<QT 2«˹|e.]et-#D\.؋B!.ZW2!ц.bĐ> V!R ZWӷ8.HH OXk O?S[ξLz8~a5m{=TlZ~{]_wv{/1!t!:8 # -sOA6 f5dr@Pf-f]zT+\F[[RܗQ L%W_}53f3`vdrY<ϣC;sc/_R ͷbڴi+Ɨed:Ea`1,EJŔmD$:yL6 (JBIRt~RPQd2L:c |>~-(Zńq&46*[% fW(R2`p(ke,(Ra^z+Q)x}c8 I 'h5Դ;.Rv7V,YRyNJ?BV *+~B|ꩧOsa(Jԇ%moKS+H ,r0L?c̘> < ӧO`ĈH)6k._@pdj i+ eB0y٠js!C0bu$ӧva)cE)~hpc޼ydOgE*e8chkkCA{{;ah@}`4h tϞD#MX Vilzm@lrF)J "<`|Eֱpu 8Dqr> AOvxwhy#C}}}2kÆ cִkU]dBuq\]U >|8^{-3g裏fh +&c?,B.c.+ZJIӡ >LDBc u/~ \ƕT"4 #`„ˮ0n{F QIuPb4v΁oF.R-5ٚm'¢EK}xf2` %>oI*0o:(Y)@H>;d s@(d<*UkImq i28Nl'q'x{G~w7e/ңkYӥǑGDTVOcc"Z[:ldg4]LA54Jcu1!dued<'j)3yvA`+\@hR矻K[kt`VjtgڢC__N:X,#mkmܷ#d H@|GX7!u#8p3&z<~w}O`)ᡬSZ Tt AO:vhiii-2 ߾yʗ}#WA,p:8!CT*ՁW[@  ^Uw/&n464 ~PX]@P}tQJrW8rG9 cܧ&Q4e`DJZy!"+yq<〰1$sΏOE("\Y䦌Rɏa1F_5[T*EV*oK=tS?JZbh|M ȋ<{n:A[#! $DZ9xjgm@ :~{? X|x{}#3gx51ۍ%PuτֶGLX{M60&UQ/.>7V\?H^ueO!}tkp%_2jK|Ǎ? +hi5 sVDד0}P cbaprS.יf/[9 )7tGy$S,qr2R C:6ig!\.l%zAF #)kc= I [Dkn|Q%N'Q+BhQ|l|i}* /P̀@d2`H|TaTƺF(@ HA,FvtP /gEq!{ IP9ygiFq#|$@TG%+"Wh(|+"c'Mb?R:.>hiG$D!cV/C4tGPdW̾_?NcqT\:@Uɇ@:8;uٳgWZˀ )gF$H ,iq%3ԓOi{5c25qs=GGg;0V@Glɒ we=ehW0n^ 0HKH+/8{GcsJO%KW}㇉eUĶ6s4Nh8p?ZUZJ~ eSd:ـ6.P-4R\ORqKȸGkS)Rk )$hd{CցVc)m[ }pUulZ(![ IDAT ?S"dŒ\H8rp̱,K+ z?k16VcƬ0,~nph+ -~A+5Kjmտ0(Ǫ5 A;XtiM6,[ouM۵V]#ax.TTRAp9 PhƁ\N,* Qso e Gꀾs4=2"v*{y_|eP|lRXGcJEe84_Ѩ7|AJ;|#?emz$:Q-^̵^5ƠTG)QqYG H#oKk0dhT6'FB,xecЫ}ηC | :~G~մ_fd!ڽ[m1kiiiwE9 B>bxuYF.v=IJ D6B/bNFnq^q :@8Uo /?< !$R(G->B 6vrubPM C;0#*^^IՄRbϾ믾;sb˯ mmBA p\&Ro`LH#hjO[Y{09.;$d2T -B#8y7e˦SXB~_relRJNru K:D64 ۻE;˛e@r :ǖn˜GuR{yF#ݵy큧 A-F{ ٘Yb5mץd|Xk?KT}ĺ8f%R GyQɇ4#ClІ#=ַu$6X0Oo淵2q~F-R \(=\rup\_]"gy&Y_Ƒ m%r8Sэ(Aynt. BzӼ:Μ0z/JlBBp"],t.Y@'%-/1C݌۟q.S0phzNb.=GF"jz76Y;Wp޹~PWWa֟|:,^Kx*}<7hEӨ-ډ'kL8|M8)\$<6ĚdBy򕯬mf1P "K`BmK%V׵djн6SR. ^p0 0ApGg 7@rJ)4ovqc"(m8񪫨kL`teg ~/,{ihmCxB ppe 4x92 =P|F~wat3%FNo%· #Sttf|P=P|nR6TT*Tǯ-N:T&MČ3pS\C:.M!&vBOi=\GnABR}uYHݥc#Y c)m uh@Z"UQΚxt>gvO0$$Aw 5 -2̚5y/!59fmiiA9a٢_}{ +\**1,L+ׯO<YrXKcRQ*9q%9^}?MG e8\ Sb}.(1w\2AV=X I AP zBak氖eȋ,s̉jQTr !ߧ7X)RF뉀2"aZHʞǸɻOUkV#t9kywYց!JFxad3@sW1ydywΏg6wj>F$#K6B~Pt7T"*{Š#z oMk{ Q#I 'dP{LGZ}(|?]_֭[n ϟղ)"{ESS3B \%"JXA7gv+Ðv w_Z[}W6RZJA&0O_ /.c;|1hVo ["wy_WʁrSN aZɇ13 _b:ݖa^}$NAP숂Nb+=v95miga4Mϝ38i- b&"S.mm8 .K/媫b>{fmFjŲu| TrJ 3f]<:s.䱮aْQFD=ZJt! i'>8IX 8<$ݏFZ |fAy(%cQk,̝;rm\ğo'$A >bpP_LG ϣzWZ^aɒ%!(|p l>rZ Ĭ(O+*!DZveR%bBAup ;%r'cʾS8SOc}VuJz#ɊrIEǧ}\pOX<ݨ;Fb(L>4Գgh-q@qCIӹ`̛/ A*4AaUhA.&V.\=*LJES3("RgoQ#ġSOgrQ| Z XȺ r.@/p?; .`;g0v7\CcLTm\5Ja% jLQuc- !(,mm/=wŋh] aAJm"GX.!KwBc8:n%h#.H+$`ݓnMaʥ>b+ M,lb7!/"í]_B@$HD-+ϡ`w{tEtƾH-P"Z+$x=SXJT^VjAs/QF-\\v>,fLSO;]'M"MFY#*t e&En_R^}5IT.GmBX $C9g0`Yx`@ l. h]{O?A\ѤE4ݤ_Om+rCH h]ο=? 4nNW{ӕ[aq r u4)^0{HՠnwQv{חk̙S]RbQM4`j?=>t b'p!ҥ 4{z +"X#a!%͜~>_Ɣ|b'L2:hug~ :E%Ȼ2gs3| Uؠ̤).|ヌ~PJ%}ҷOaS3lOFǨ ֵX r%MQ 5K![oq׽ę(ѯ_?2 |L&#<}*KىR 5ZG)%!AhH[_=/Fe hUX_ )V-k})6ӥ"HSf׾AtN!䎿Ȼ? S GE"u )CY`A7RҢN*qPTU*j H- : (:mGWgvQ}Xf o|:&g/-\c"Wt) &\{ռ@_`^B@$Hvʼa$bu6].K/T%NFtޙUy{ι{:¾& " (8θ(~dD}tWzs?JwJw's?K9<';l{ ]- Y;FlQ`p*0ù[Q)&xA_J|0:?=03ɻ;O~7' &(mA@UsR`) m4-fѢE┎㐰 R,tSNB#Ͼ|OAi(׶qr)+kxkr֭\&@ Rpl">*Vzj=r,ɭ^EL &ITS;Y0v^}AIe( ` сF>JYh D$/cG"%%F\)J"-E/{vq(!v|./*H^:nA2hߠ%G^_;,k 2nlibt\soVXƚoܰűP1#F]dm ]]]7.n=ܳ<˵]]]tuu! h;~&Y{`8a$h'\%~+GkV$ABHDRb/k̙3c9ߟyaYqG 4P-VFP2/fѢE<3<#l|=r/xP-~ Jy>mQTXx1/[oE 2ew}2e &L D&rLPT*y\N^y-[[oxW J)R$I\E)U#!&Ze*.$3Nxh2n@_o +BH )IMvլxe kW-登$͙qȣ`1bٔ(<"߿7ÏuY<;?F:-1bA犥s<׬s} 5Rj5ūHz{DD1a(@}s!91}Z 9L:A PMnZ&q& 1:} &7T:WJ8R^xO<ֶC1#:C:;;2eJj;˗/k՗a@Ѫl'I-ktjԌ]B[Q P+L?h&g 'obOcb$ϓmjį5An֬Y?^k#"yZ)-xKRw5Bom0X>R*RJx1T<l[JHDᏚ{iy)8d}źPlhW#ҁkx~ε"9R '+J:F.f. K2`h|)^&KsMϑsI&1HP2db?v&maseet_eŒא^^EKݛ0)ޒA 1tLOfL SęS0mOdTfN(445Q(1RNgT*51!ݷv4 $"00c2'p RJ `ҥQ^1#F]ƴյʕ+?~j;oQ_ߖ%øܐЫCkk6 ]a'6uJQjE͌ܖ:rt%]oڀ2iJ8t6g? ^y x.86e r%LJV"7BP3 zzsV kU F`K#R@Ie#)@ /5:I$B `T&p\3^=` ' S`) WeEYL9kLøּE/Ggg'}}}r9"\RDX"NH5v-MtlD1K\&8 ByGC/#- -Tj%VFltp(6 ^!6:ٌ5yeH\.'|+L jB3Ĉ펕muW^y%n]?`]ul:MH$Ig[q}Fsܺd6 Q<-1Ojg@(yxxAat(n]0LƤ KZ*cо wˤi{o<̚iͦ߯o< |AS!aIA}{~s+ݹ1 o$lIh7˨ ɶ~Ei-RK*^d2A6&L{GDu~w>}f"#eB%( %%B7CIf $֑h6)$R!e1\ < hn'3v;:,(JY֊  "h# H1b|ar8[|j;</a\rX BV\ƀZ '#'̤ȃ>ڒђ}N:'ª%/Ē瞥8U2Q9 BU$`E9*8ip,g͜ y} rEU^t Wo$p=h~լuDD[X /%ZVA[A*/Za#e$@n RVW`PaJKHMXܺ|)'s.sO;1o IDATK\a!E [A  P!RA*9)Ix}][ZDS)"qB PB6jǴe 6,_^&NXDĈ#.5cLHϝ;w?~zZZZIX9c~3?o߾4$[/2Y L6KpJ<{x4Xє)i<}9:\͒e/v Xn@Pv 4Z(C`MSf0a^7@&ΜA۔ɤZ)iͪ)GA21XʡŨF(1yQF&f75&m_v1,e#F-^x3?yӧQHeB{>h8}!$m!BY)| BD*E2D>UX FOl/ȐH*}dC0Zc+,*W4/ &B/ȫ=b#FwW_gq$u߾V_v !Vr衼=ϣwPH v2 }Ơ@ hnM kl[1C$D"h :d5m*ss͵vї[C\J%󒂰d#1c4fi3v&MLd& iך{eIRmE'2,h Wᅇ? zZ2J J1B$$=$SYG2}~<ЃqݼLߓH`':SHڡg /dcdu@`IķZNOl) 2)" xn|$RH1ᱵqm!C'S8V<O5aXº7*{Bf(y x,& 1bĈkpԩ?̳1ى{o욪p!øRTKl RBm DMHbƍX@>G%R47cV{׉芋W,EexGP$HJH8)|lr aY2a1¢g >2<)wzo. LN(A+ѬҒ>@{hpIGs3~47λ?eD$QZ c2 4vB48p}3\I{IHegp)qڛP*ѐl"pð?e|qtn=rgq!D:ZG2f7p=Dae]i BJ~zBrf͚€1#;8q"cUNnw\x_Q'g?kӧi$,DݐRytwwBA@cc#V7/jAkZck+ _œBedx5IiPD7a )˵ d_)H0Vɴj ?@(kWŝ/ \RS{* Ks[\| #P(hl'c WL^iMo缏t,& (EmJ?I\ ^JKa|*#uTgW2g:>xgP*FZvZo 5ck0(P <:$ \oyK.y=Tv1bux,Ͽ/nu+:3f̈[ngks>oL3"Zk2 (7 ΠEU$)Lf6bWA0 #BSTܮ E4jTu5׭T-D"m[K!  RdXU{`Z^%5( $SY0x߲rDBHQF2z8g#tr.QhdYCs駞Gj l1ӣW&G`jG3v(Ξ=Còviiҳn_+̲VqZm4:_>t.1x"P2 #RH@fjOg[}bѽK01mXÂ0m͚5I|@ ׻$;w2VŋyK.E~r6- @" K0exϘys6m2 Ge4FC ]m X,GfM 7^vIk ޏ-'21r@90Aps>|7Aa=WF>$ıx3dnEΙ+?{Oט! IߪsMB]*a0`6lfMr6uW0n4xň㝄_uK^1gy|}:˻a^zp{(V6Ql>BHjhH 3p#{G*1++8$B =)qE)cra9RIPNiaM "chTm2 vAh&o ,'d3R "W]L_Wo-,ip8JEՏ>ڧ%#%}}}hm#}CWƻRN(X8Gնnoxc#F466z]W[mkk?'{R3',1k$x-YKPFc %l/n9fQ.АJ nl!TTX162 +ZT|1 c"C X̄1ϲǔ6|7$h]Tw'/{wihn6|jv29Fe$FEׁN9唺_ #{뮿}⋫/ALI$Ȩ`v: !VV.^]SJHeR*r~ % Rl0r𶹿 9G -0O|o3N7k+Q%i{;{"$R&-Iheٙ0axhoobGjaL@pG !!Wu_rYȉxۙ[.Gc+!B8;b1~Ǚ;nx"!xǷ4qzhգ7l"@%(ێVc&RBSA@ "t>u1Nls#ifimz@aMmPXdŶ4ϙm~ x *1#.,OZɅpo,\w{L]]qx0ǗӓO>WB5#` Xl DЬ m$(rx~˲64P@ܽq[miM.*v"n!'vҐ@#DkR 5n`yK;Rq ٬I~*X1v}d$1vs$Gc{϶m?~q/{:|g?V3ۣ% ֺ c6~>@F:t $.C6fVi KKhT׭uD7ܪe2cmm* B "NF#$H@֭KM H6 4J`hx8yϩ%[o=1<3|]* ح6ď؝o-l%o0n7ڌDc6ZZR}+ !T** |B.B2:᳓ƨune$cÍocx8> 88*"wuww8$]mgEcŘA__3Y~htmC&JիWoF7"!1vҙ4}}B{k׬e͚8LfWoNC"i[)(%C(H`7f T*A[pH/HST<2Eոxr-JhmjU+ĶmrZkR()d`"„rrZyQ_r]]=õ@hk+7D/19 o'c\pB>?ܶOs9`;X\~_±J~e6l&} T*tu#DL:p}˄f8 kBC% ` *:)RR4K%"pmL@bW#;U+xI)gs\pѧ5krH o[?ߩ{K/|#TDqN4')bKnWhe9ѧNʳ () 8YL@9W$`h|1$h<Jx ^~%JBPy FƧYhK7p~s>7g&M4JTP-reYdi~S(PI6Fe1}LP ?'@Y/#m; eSýTr4ڈ1ٽ19T Fq=p*ecg{Lu9=ݧsϲ{Ľ<|c{/rЂBQg#&a /$Mf*dIL_?=?}C$3Tܪ >Qn7VOԳyW6h@aY 1& -)qQ^gǒg^Wf=ǑG-dV&N@P&-_vT|ߞC:-ضF_*1ۧWFV|#H0Kٰl̼CB"UM3e<񻛙Ial.p},8fLI&pM!4BHxGTq--Co̲yO #(; X؎4nLZa+S B%˭+fOنF$aҲW<,)MٗdlK;7v' А!TVd5}fΦ?xqnNONXȦ6 9`,o&3:gbcĠ>8!$l3ItL =v"V0|?u){ʥǁS{!~KcdWպO?JrJ?W P>6jl\tmhpGn}HOXVv_'9QlX PII@Sz{{yWyE<|9r}elDV]%ƽ |a$"$g4DyDσx^~&sS8C3goRB"I(^ 26n]|A4 VLQ_]io's1v\= ;ndʠH.:6v6˦P0 qz`Bd1_d;\zʹt,<ƩpG]B_.[O> vET91pOuc.~7}t~_sΪ؛n۶+6N$<| Cb({ޘ̥Z0En!`^{B]$2:'Mvwo~]Xbo,_K/Uk0*I`B$HX~TPtD~z >ӄCƎ1a8UXH] g hll/ҢXh1g,8wp CsS--YCxW~GqH"\j+Ô2Ѓ(Ú$:v4J) lT S) ƉtIKz3f32& 11cƈƒOg\BӴ*v}к\&q2k/5s1c*mƘkz|կW]q]/wl.]|u+^|# /[cH19QkX/Q B" |H"݀1> ҩY 'юJ`ȗ=l;``zLD@ &L(ǠAP}F@BcN&(}"?hobS5s* ۡ\y'a%34d)T<Q G> CP7 (<(%}!DUxcR|>-z0v_ IDATwR1C0 I407"Eӌ}ǫ+1d WaRWn $Z8DѠϧ$>RRhyJI\Ò[xG٭.B),[s`℉,YG}_^U(KR.R_L*&_ʣCSH+>CKyא!QB h ,۶JS`L+'H$Iq4'ȃIC(& 1< Fn)kފ^:la[dR11,htdҩbs/ih/nDsιJ_"M[Y2Dh9x!{e7^9XAm;uvWߞ+XC#AP:!nbܾs8霏o4x9Y%(u:tH 0")j$>RLs>hN;4,?plT0ct)}rJE.q,-I2 c J\e% [x9`ΑGU`0*rթw=gLm#̈́yG3T,ͷr7qƙ粒\.5 '_QৰHb$A* OipiK\b$v}9c`hƣgV\2M&E??c -JiGJ?$BZuE:&l}E|nz?@L84!1v,؞^g]zB$pZ![4??g}xG9÷Bz)/-B,YE؛n#<'By^ZĵTh+ANXp_~s_| HPQ`b@\KMgZl.<?C=L*HVXӹ.KNضM\D'Dӄ!) AL89[o[&t )|< HMr6$*+ˢe4Nٓ|4g>yT0|-76#(#qQgd6p`=&E£b0WѾϲ?2_YvoK#xh+>CBH:MrYTea?6|f HX Ƶ G8٠k-m̳җ.7t3vI1I$X` ұ)R?)398)3w Qa (Xkx``!0ii]#Lyar:RӷQ[Y"c,u [ϾLmCtͷ=[K|̄=8h2ƈ!DS̊H6Wl$ YSs:̧?Ìw.|׾{㬳jN=qrcPjJBM7;Cv4h! @H T8ӳn~+H+,? (%ja%vA8x1AYd2c'`0XM6a]alQ$qHHcY #ƶw0C0s/1>Wi_Te/b|H!ƑD"< =!>b!$o cӍq1a1kǒެWۧ%7_MÐxpƟPam?R*W֎B|* 2 Ws}G3fQ[b?яw7st =' !;=j i(JT*(Z<Lj DcsįZwl)P)ʀ{_fҤIXc (}Y &mQThmn! 4N2A˘6V^ERTJe܂ ؉Sz}WH,Kb!BvT:KoZCRh&NJl,,L:Mte@CV=GM8 }Bu(tfZ\ɿqkQ o t$+#(L't?q^{-k֬}tR.2kX㡿?0@Nihh 6]/T*A<@B|2R %eHaKc 0Px~P#[{7S5d2$m,t6۶)tsh{.i~X^,/Go$$^c>28N(](KX$73@b{Ah')6 ۞ xqkQ wIeR"WF {ͭ‡zх'&']v-. uy'}Yү}}:h|^xy>nNEe8Wh"RhG)ZZ1;c"Rhc'|\ lE`V*оP p{Ph uGt.oتaYLq0F 6y6mqDž+h,p%MX" l*:ISI5deY5u!j D"$RfI$!)~;W/w^U% |Vqޤ5Af`SG$]e (VNdA=6qS ^ 0(r9!6ZR@#IkniِY(4x֗Jut>Zh4>pF$ xAfHt]۶@P1Mk,! rD!;ӧ#֎w ~I}c.DxhĈVH]Ƙ9B9r K/K_m>yva瞴J[[[]R>֮]ڵkYt)_v9h|~xU̝;G~3ƘABBQ%ch1 Jmdh!Fb,m@ 6\.oR 5TjΛICȫPUus\'Rjbn4>ƀc, [#& 1i,ߥcM,m biqCň߶eƘC+Mu]{^>zGZ?:~~u>QO;PHG};=wwhllzO~᠁x8uT1zU_LKKn T TRi.#z0R*w܋(޲[J|ބ`~Pܮ" KQ$%'ZB@NV!t'ݓ$''P`Z&*m1\|_ᶎZ|htkR$88*7_})'*s|0}>O<ǃ裡 Œ(o!"k1^k "q\x ^G9`0cJ[Ԯ\j X, թ^/ʱ<}VV >p$DSz< -qVͣ{F4  =Ceʔ) ?y]|ӿ#FD"̜9O<7xz!JކњYSN!BdbW$۶$ EMӤ)B!ܘfRn ,$Pgw%lxpkJAzF:HYg|I1,DIɨ{Tu&[ݬn5xsjm=۶駟9s9~_y~kooƗT}EQS!OHH0%LkCj[K@Z*c㯬B9,Y"AxD"88xEq3bmf$: ~B'mxm;e_K1 Zh4-PRrclҮ781Rb-r!@0 z7La~Ӧ~5W]٬ܳ?`uL0XB!~G9>>&/jUJuo.Xc@xB j&f"2t'mT>cKQ^wQ#aDLX [`¦MD" ))// *a4-dzH@ġvKiv:CJPR[T*+!PHE)r"UU؁*pǎDW*tTw~gyga;W^} z*۷ގHI%k[ Ej&OV4 N{ܕxMAq`ӆ /B'& zdy(!%>TRR}s8Q+CijG+0M&e"X^/X+*mu2HJkDՆkհ~*Au+)c)*+!RZ(AƙZL<IH >߇NJ]Af!QJ\\Ӯ];СCٰak֮_~aϷ}enq4qxx M3grgKQnCܾ ;vUp*R.-gkym2$kYY#,?/̞ `i*B9zON(L$n=^%e%HSD8(,bҥ|ᇌ3ێDnmձnH(")!0\+eJ0mHIc#v,n.Dn5V+!0Dtڢew0+NhZ88 WQQ|-pʋMJ<<@٢@i$fw}σ8X;e?Lo[IYZO)8 88IJ,uFn8fP^~e (,,$f***XjU:J):vXc󑓓Czz*1|,5Mz]#^ .Yӧsi\zPQ֌? ##˲jҤ`W8N^>h GuTadK^{8sk-[u9b22mx|TTTzG0x!( D`+EIi)svtDHԕO`xI܋{GC6^G^XBJaHqPK !\^J4c*[@x*N SOeI>e͇ٗGydoZ0nb $UO|MtUnr{`=Dh0Cǹc]1_s?_]#$7f+@RRR0 c"AB*7UT5?{ q& $(l@)8\v٥/y(˯y1-|Hێ^;57$&&|^z5~:> MZZB̟G~ȿ/6[^%͋'-<g7%_ TH@IL¶c˄ Zh4CgСC) #z:fr_P+MNg,JI`3kǬ;/d(}"+K(= Ǭf75FB, IDAT=n|FױS/^\ftYi;x<թyHG;dTozEMT( (ݍ1׏` AVW"v۞0Pz|  p#Mzz: ix^,k=1- 2QJl3e7܄Ǜ%}&R:Lɷj'hvoYϪŬ;ư[Zw4[ӡjB?uUh={kׁ[cOfr=U@iC`YiiiQl6@RFP uąI 95*u$cx2!! e/ 0p 0P;$1gèoCeRZ\2 laW,a1-H # PU{AU85dӴDZjc4ErزnuGhiCI[6W.Wlj3+1>uWH'B^zi}WXti"Q` $$l)AD*AVm=1B 0lb#p潏@JLanR;'cZ$ȻsƵ7PLAX%l0h\x"1$a!RS`(P)ld({e0۹H݇p%FA20~筓]mhA pcJ)w #Tۢv,ӠYc+,%,٭s=eIrJ ecԚUr***mJ)))vw^\__z_~@%aXɩ3ii}2ꌑ|'18ydW)5T1c RAxMu0LcTbtƺ`) !-ҰMÏf02_>x C)н'p?*m ˜z"wۋ/'>#RZ WLdR E9ء:bI6CHDJ|$-  µ<TU5ƟHDK)m 2@EBM ӤSv';u"++=zһwڵkGjj*l mMV9UJR\\B~***())aٲb9yyylټ[6#&pR KXl:$w zkhʻ0ƍW/+K.mSѠzER```ObSQmVB$ !$R$2[8j٬]U+ 2lON |(% [&UA?xB"Ji~r 5%3gQf5![BE9sgz ĀGtܙ* !0LRS8@hWQlkٞD%@l?V84J)s]e1LD9P5_Laobmn|adeeӥkW >#8tŲ,(>YYYdeeռN:7mfj6mȏ?9Ynyy[\,(Z2}ϸn'oWS/C9SO>Ӿ'O[o8Z)5H1w_RJGbZ&III &Nj{{ZGmDÔNJ։.Se{Z4$v!,o)GEqe #<(]@H !pZ6P(xli$Pa*;nT )`?2XG $$(]GrP:AK՘ \E8 Fܔ݆* hi 7X5uTh]2x! 1Dǎ10W>ث7w,Y̙͆Uj?v}9Yib}RwsPym'L4B)\%x]Ny  %Qa˻\ T-8G5E8 $tS q(ANfHȐD 2j5kI0qP(@ZI mԪ[Q}ͨ8n] =ߗ(7Q2[gdп5~&7wת2b:吝݉c;իWh>C.OYii|yʷ'Q|]uAڔq>wwr9 >y'뀔B:vR(!{:rf^Ggq P^^U#A?Y.~6%X` AmHf)F* mm@taȭSLaS£\bԣ F℃lg,37:p8oןĖ]{p@wN;}-wf)N(?m^GKnazuO*p1ǐJyE;nܚ};w.ӧOg/~ԙgĉdgg<.AAԸCT[@pD]V6vUBHW Q V1PUS$H1EQY(6j:_F A ozp 99s{P?7]80` K/bimip፫XmYƒ![wNͮx>==]<7YR=r_'ׯw~1cCRCw' 9ȨÑU+))`8R[_ TkٞϮMZ"+* NʋZ.ބ)]aʭ6Pp T(l#tv؏ufϪHR(\O!"B)=fz-[jOkg Q2oF\Y 13GM^x4Zh4opO~&*oPB=Ogg5n_L)H)N'e1`@~y&>?>}jpK~& Zhv,.%Ƚa9\~? ׯnPܠk )%^ @`UU8>h4]WiɺǯXC7nP;f1梋9yo&T-. nٲJJǡҲ2ʦsѳ'ڵcsjj*#Fyw[$?~e_oߵsIS'w dL8rwk9J1k7]B(P=$&&ҹsg6lPS4 aQ\\K0ETӡ o>b+?FAGwgAMb^(gʕl-P\\DU=ϗĐr˭~{,atҕn!G>?̪&y HU]2 άMMʴ^{?v,t$i4 Fe#Avl'tXh$ZhT|m3DW\IvvFK)b?Xnv:˖-eݺ ilڸW^{}o>ؖ! {*o j)c E6kr4ɓ's뭷:\n80POeZ aD0 rpA<)([8f[6j4P+nLѪmZh8ntm 1D_㜓RB!V,_>7V8.]믿#>Sj^:pѳ'}k'DxvT<'N8!}>999v+Tq_!P`hMmh4-#ltBRǯXJŇ!GW^笳na6+W}c/10gl =|?S\\lcY+qԐmIr:MЁLҥ w3>OPJο :u@BBJ_DD_dh4mGGfSDPHSm +>pO,zz|+I/үq۶ҥKk7w_Ϧ}1M^.Ǭw襇)XNѫ^, ,uYqu󭷑_\޹.$RRRjHLG m\WFiVnAخմqGѴVA%zgz|˭z۝dgw4"  GIϳfݖ.7558eqɺ[Գb|dp/+U@RӪxo>8eHj?82a2BUVoM=!nB[D46ҪۚƢbv<տd~:>q.Yo.M>ܹsO\{8>ulp.;`2P(onꏻ RbC*eդRLVhv05o5Bp ^E۷?%\_Rm^K//{{lU\m+))K^΃?Jf:r|p-qA`A _%W^~> VVݥ+w"L^c$'~y37pAh^>o).V psV5Z( JQQQR ?h4uix5qdwj h߾=>Z=֭[K/N?ISϗDjZ*  ^^/?x'|2}m8{x c:zeؙCeUJ%w\M6 /`RQ!įMtE4Mvv60Mr,v**P Vt<.WFq(c *d}vz&cxrc,\у-ˢSNg:wL}ׁٿ{wHNI!1!D|I>x!ZizTVVRUU XgH֝釡mpIse_)B{5n&:R H%##׋anѰhUtp!K 4ͮ^wF=E,ym +c>rwq95P_yy9S&Ǥ#??QCAVv6?~8r(rruL0u ,><Äᘎ o\W㐻_ C~((B_R3#GK|V1J Md< ##+ 6 mLBm4,¡0QtVmc"*VhAD d8Ib>4-x3c.1u15y]Lz͈gpXb-zsTVT0gpؾy378[1 h< eҋ/5d g*^!iiiu_Ii,D7Ӝk fGhsWS (67x=? ͚izG'ֿq71p [{U}IOOn!G.C~7ضy v)q5tec)..xOo\@Z@JR  P8h4Nufc\ cu{wsqz8-\РBУgO|;\zt-";wsݺ|Lo Y7󏦭PdYW^ue\ !ϫ?6(|VJՌB~?B1Va 7F'Yuzň4%͘]m۷~Y/oaŊ :7G!'vԃ}[n#11$AJPb(B?21Cw眸ڻ)--xGr~[c% !REh4grnMLJV2valn6B.rQYcڵ>O49ay= L#Fpey L rRF>גxرc1mSQVeZضR4ϨV~ge:l+g}1q$_yz… x{$>2ڵ^#>.*8򨘏ɟ~uEѴ9X 6,nF+ȧ[oqEN.) XYW*F#jS-@41ز$bPbugܮz'q5ג^<4ٝ;٫JNfŻ/زQymYYY}cztZ6s=zkKVP)HXzt$YYY`&ۮD#0LdM!XRjUkO]-kг5)tkWĴk{7lf*dz[;kf51ޛk.*+7(&B| pA#&|GI4@zz[Ĵ+6N~j4mN@)Tc?~PIdQ>WēNa'lPJ3酉pKJ{kwqCM a'rI'Ǩ^>-k NEK7T 5j!τiHJJ" " !& $ V 2RH)QfD_ʆ~9k4md2;eR5F!AyyvFJ5v=ivQ%yÿrYLgegs5ב ӂ IDATXv ONx{^7\:$}v#\uudegǴ/lΈvy(x<\uq7O|F8?hpjQE`0T !Bj4XZ aRPP-rzDpI+8y>b$//#4-x3c.8fW MrA>"b%Y*._\DQ!s1̌sΈuRjF U =X)E$an!14bmhb8%ʐ6VX)TsL-@4;z7{*[YYٜ9ϕR̘|Q0l匽*m؃Xř&++6+HKț;^=Vs'n'$w)8SV$Tc! BwFjjrrP6R vqBAL{bLzI'ӫWſ@UUeǜvpwh;Xf{ BuD p q7gm l*}>@J0ݔ6R,,Đ,VbgR2Fi(j6EXH†R(R~JmSEG8v)[ e+Ǵo8sc8}͍}ۻ>^gj&x<>\wmWRVˋ999}p&T[A/$q  1MS@i4s85&?_eK՗_ƼCr cvj*8%lڴJ)))Fe$--rr:.34[Jg~3vVd ɻ@W_ͻO9wq' MH0 fB Fܚ0Pit1ŬN54PyZhʱ4c**a9j1m6LLAAl5?efrulGDG8V_SZZâeю:vdOWtHbb"gݥHEl1Dzi{/W(8p yY9|>_2p R%$$([RDZ@BFyGRP4XH.Rjnbki6eެݻKW?8I_aSc<ۭv$E,>` .$//J޶mSXX@aaO YYYԯ?:#<3wuwM*`ҕr?Hr>akfM1ߣG\xnsRJQRRSpEc/%>v8믾k⒋.)S())i51#2x!1޴5WEQ0dȐFiO=E87dNJ R_sg 6\HDw$֐ݘ1ܜRm4fX"83 v`0SL1pR[iZhp$SY#c.:apWѭ[yǼysvb߱mϛ-7qZ~uz1H]&w3f2kVfT6£RZĨXigz1MMHl)Q ^_bu[٤rl풐@ 8x H "q$J9u89T)oaZhv)\OtAd TJhB֮]S=ű[^~?ʱ|;kc|;kr,oo= ?{L.~m!rsSO=Qڜ4iH+#< NK0-wu2==m, ;ؑFS^5I,v6j"3R+R*ׯ$y]LyQt[QP(}#ݩɯ ?>CGA~n y{7&;v#iuT_͓]v喛n>Ɯٳ?ީ"۩I+ߵtD_ JFbMm+;wFj1D!)`*PEtםuR8-@4MRTmوSZNc}̱5b_YxQLM^C)ŪU+kj=B8'qYje]5 3'v}ߜlDFh!fF8']']:LcyHOO'9y^]Nq3hv27ۺBIlJ۔RVS\FV?ߘ&YYtɉJJ?DaAALq樳ҥk^իi|>7%RJ>7իW#sNYY19EIy„rr܍M+fo21M3Z@lt*,Fft&~k4m!Y"$@͛6Ghێ%D$O(iit`EǴo]wbje2衘f=H3ч;gvsf£Xw}Kc*m> LdܸqO?MdֆZ aD"5$h4,Fjŀ( ;DSRghiT*׭$%Aƴʕļ߫woy`]^i70xrr}bw@ b+-202(t-\@ VaAnө3gΜ'@Ai$~j4-Ljǀ,Ylw# 9J1]P@ x׊49#cR(ذ~=E.4,`3H*b#86rܮDB} {Qt<oj:t3ﯬHe2`Y OHH)y՗ᢋ/lAhxS:ń*y%7 /8ziLs@)/)O۝, a8^h4SA+ExJ) xL M|]J7+Q ;9IS`uVv6Ssayi7pKfxӢ<5?wp=)e>%Gٱp8s災8[FNNg:ewb )T5 @).0v '4JE ~nCYYYn!BE$D**1Lj(uM #vIRa 0RB,8!lRxD4$e,iN[:J-}?:'P ~pdz޴v,LoZ;=xo8JKK[TWꖛKvR(D,@ybcJP Oh4;UOʊ~?a`Ya`gw"fk-@4(ܸ6}bvmXr%Wr7˄Ro3ABy+t>4<)+xR|i+dz|ڙ1|W4I,ظVc pg6Jl*smax=ޝhMtFXH]`(׺j )W_O?eTUUD"{J!ZhD`yl+{yAa>%1MZ9&~U\T[x3 Be ҺuG4Biݺ3g2Ecۼ7).*n1}IA=c7T^JT?p< ]( N4 iZ)D(;ypE.*hb[Q3=[| >SN9ݻss2af̘AQQ&GEHF}1 [Ŵo}bӐR˲e1N9锓$?bѢ1yqxM:4~%v_NxMTmYOME㏳8-"0 z䭴@2aGRW1c0ŗnsʇq7r衇B;5n Hƕʱ]b,[׳yfV\ɖ-[bрQ#SO.캀*M[ڃ2MPHR(e"4a0 7oaeȐ!/aذaknwv_9-@4"\Y*]v1[@KLstO_W8̇Lk_o]`"us+rg㤨|l~p4H ?cTb#XF 5K-XcMkT*Evmoocs"|>ٙ|yc޼yL6={rs '{ѶbK8Ek⑰;&йgi*/8tGXdI},W>Y3Y?2 );`}0 vZ*f~ȼO?4Tqq1P +m.xafΚ1Ok(!DK)%g5#F0f&MSO=Euu5Th4J$!nKg˱t;՚ԘBa"D SCɼXƢ˹馛8C~믿I&⣙[1VTTPխ{˖USSS]v-x]5sΡ:T=ۨبnGѡWWW3wN]խ;6-_l p7ϐ]丷OڪϺ";ƨ`ڴ;/@ ,!KĉD"A]i /ƒ)Au?Gh0I '-"w= II6(-@۔:QB!L /q!p 'nܬ]e6 NQQΟp 4}^ypHǜ(־u'Q̀cNGDڿK=PTT(* 6PGăß%)=G}J(,+WnYg͌w n":([d,9#͑PNHiG`hLLRPս9UHAă|6 C6E6!L %&)<O>Ƹq4iZk2 \5BƂrpmm!H75L6l],'jVj;!{+EV Pt{* AŐ݉ WSsdBNKah Q < pwuW+K<>… q"QH1XiHlG"FIEPF25\#ȑnGxZku&맞}Z.8&q;ViPuh(B"b8+Wk;}Zk\-\* B7 $qAJP+%šW߄.KCtZ"|(*D\59?㳥+ź 12^˱Qm $B(,41℣vW$5)7w̝kyO>h(T=D"r3f`رy睌?%xCۓƆPX ah)LA",n{UY|YғV ,o>7s?` %B9܎U[[C24T{K%M4M}iy s7x#Lvgu-I7eq]l: Cilx z*ge9Gx*ݻ]t!Fb%׬f&`0lp$ym`;5eЩzdtcr)|oa?a,rϽ_:s>[ B4(!FSuuuq\ &~*bhc]"R=Yv#.ha֚PweQ)nqi*/=X=,yI*A#OEkqJEu+-SnNc7xVVcۏ'+/9w$%[?]C\Srx~4i?z%- 6i.t\3BB hd|t(AĖP^FEW??[n ]lZHE1qB*V6,#@ H+mP__ZCKm=6KLCYYLT^*I.VG"]{l|ڃD>[:;5m W,1Кѣ V)9ra%djk6f9-"H\qxs|,)v*kDd2ؖA maׂt. *p*6DN[H q"XXX,#>RhRIq!3D~ͧsj|Dq)N\FL䰵FjoB!L ,.L`0lt"C<^ IDATC Hr A`$"mOhjFAj"WR}Wпg T*HxcɅ(PC1l+ TccK:tz߯.%%%dنpDHIGc!Ңأ=PzTd6%%%B^C 5v@` yAnrs .pmIC\kk@|XUW!KRRR`02압Z章kTt:behV/6(JQV_DCpē pԼx9s/@#@  Y#jjq ;qi?e]P٥ ;ԏ;[nN<֏fNzknICϿ=+{`W 1x] Z,{(++/TfoJ|v&{.-؇{bґ5ggjMl%F$JAڇd2P%y 6Hx,f` 6B"(ze4x\c\=*[۠h'幔vmE$Sza{q-,R2mڴ#&Zw51`Gef3L~suYj%YLiY)zqͺ߳g/VUb򍶭M7㖔:[RF#O![]%z%B[> (Yoj۵={u{*54l_XGL ?N?t DX\oqZ%@ ,F>+V!$XdX5&z[_u?`0l54t%2/ ٘7ZiVc8 29E2c ڱ+}tCY.=Gi!J)||WN%VVjyZߍmNpfw%*`6}5Us?=)؉bJJ]hͪj^ SQZPE [dm3lsذaq <@G=2rnX:7BXhhh\L/8 ˒X!H|Ɇ`1DBjMY|_n/9393''Ia{@?;kkx,^jTGX+  H)TdUj:IG8&?F[;\c=1 Wn6wBjUMFb SJ !EC'!C]$։i,k嫯6 -ERǗ茏#$ie<,%(-Xl9Z\㣵ߒ8"Jղ] `0lhdI".|*]Lc.䔍 DbQ.ZZxrZP*@.m!Q ,f滟{sq 5} w"F6H$o.娩4\\9̺*V,lԴbI ޶mBVZSSSC.^'Qb:])ƌS𡇂{2V]c!Yy052X1StKbr2޺)0< `X/JELDžؚI$e6P"FVK"8#u1Ka;]K>|4` ज़ОD*7FJH$eYH):t(Æ o OԋKMIBg_F=>{{f!M+{ob{gĈg};R9s>)$֥ρ ᓮ:X!BYnq;VB}Zsqn׬"Xz86SA1 &`0,X,XDcQH5EDţ=igχ -,r(&\/?=t%"2Hn㥄ǧ)ۄm(KWgt"iaĺ$RVO?T+*+ɩa5_?}/b꿜W__O:ۖeӨ4߷֚y~m 'd60nܸ>hx9"ė_HrR6n+QK/qؑs3p9FD UdP\Ld2 m"#V,'Ɗzٜxڹ(; @ċ|:!$nYdط  QՊx>MD4I'AĸjFCq>VWWwf;dA3zt;\6fջ/Kֵ+Eou|x zvg1=`<WK7BVXA!1)5?Pxb͉?ȵ6ŤE.I,@kLՅkKV&as/W ENmtgnʒp-Jm'9y tC#j"]2Y=XvZ*Mh]#@ m<%CeYd _.XЩ.Oתv>n"\}K}l}MװWxsຉc>ZUΧsY\K¹\7ڻqWX ҃4,X\ǩg]kx"'lruE;ba9˞plLѥB %q(xq6 .r2~UW ċ"Rds[E?_ }]Kj篟2d~J8w,Yŋa.tQFqEs+ەNN{YV2/ǾT5Hi[E.YGe3^co|t&u] /QFuI߰d0? E}xa[ D\=[rUxj()nNxVµ#x4B)whFDcQ@+M>`0^$HN =CH?$% ;;EƎޗO>[dd>dYsk!ERߪnр(++kA>BTbh{[\U\_vy10eXx1zsu&Ej,7݁,*E&Ex$CM>O)%g}?>,suO?oV"Hqx| |_[;qbV/+V_R>x1Qư(BkB[Ujq hF;I;PGXupQ@"EPC A^,pd=MƦ G]Zkg_gĐ;滌>` ̙/sٯ|V'h:BVkD>]{дfbNuxΚ/>kW)~  |6Κ@<tP6jއ>M3 Hj?jw^{ɌGk+cc;Ah"TǕxJk Y'#AQ/)*$ɁgɄe0lWhFET$RA,RXRd]RD"Zj a >t3>B< EE()rVeQK:>=`z[oNQ j…,Ny]ȽFmu6rQ׿iw‚/> C<;0ϰ6owX,y[ГxӧWLM$#"eIL^=zpeӥ+ t%(k1 5F )B6 FR7ǁXHqxYL\hԥiIA>l<%\|M=Kse!#D%QGɀ-#@ !n|n{H`k|xqJۚvMq? H)ù鯷N:wŋ^fT5Ń>'qW7rI%s1ƛ{ϠuG4%1 Ɣ6 a5+r5 DzpmxC:C:D:y+AJ||O|R`[RHMIQ^Q?PBDBd1,.hSU; 9ۢU]圉1?Я_N)G)?Ol<*J{|ĮWڻSee%{]AO+Zk6e<K>F%q]Ā =@>BX1(Q=O?͔ۦ0t02 <-PJaI )u\6| kյmrbr;`؎HiFIċ)hxb7ѱ}Rm#,X3Bu{ ^bfZ6>.mwC/\yN;4?u>8;h!Z)NE>a a=.V˱n'<^h6446pxXK˰Np;?yQ_ $@4my=1AKwۋE!Ȣ2k 8hX:K?|a̛)o./"gelp߶mZUŀ9 +vuͬpw]zf#Bk? 8uY gM8#8.]5u_^lYK,%Rd-4erDQGkZg<0`^)JNW0H$°a:tG#jVboH&SSڠiiYPTTDϞ(mNt&NOoN^̰c cƌs='+9g ZĀ+a3x/P Vԧhqu1:t{F6nX3g~Y3{l߈ضMתlUZu&M0kLf TN;S6pbwW@Y9̳ v?=LƍGee7a yzX[D-A`D+ f}("b0l7$i;خ RBHԠ<PW[%e{w2a݉bj[W[zl6}4!Rmo#.o;wnI`E,^8ndubM"Gk0 m\Bqhx<E+!|#@ [6;|Ȣpŗ_~f2_vGg/hʩBZ3 rˇW^\sդĩ?=+ן]rѢE 9o[Ri|Os#-`u^ivNiq)ДJaY;i(_ +W՛@}K<,);ϰ^|בڮ\}\.g\.c>+B/4~Cgh7wG<뼮&LwqqmM6H`6|Tqk&\c Êq܏!c9N;wӟ.(caaGc3ly= :O<1kֺ^󨫫[' 5WG7 )5J)E@dDcH ( -0!&kO| ElWW/'+x>ևypLjرc73N>x ܰj]] j{H5>E41B A6AjJ"Bjf0 D"PƋ[붐є/~:bЦoU@!o; }KF鏑죍Z*7L&3Os1OP*(TV٥ '| 'rݻoIZkfx/'(;}SxаEn IDATVk:0Xo޽{~%^Zp?Cy䑸fsY'ң ZcL5 ú(z7 R+@H|ñl455mO_^£bE[/v>nvB_|9z,Y]~_/q/~'ft㟸 JmײdxX<ܤvt÷%Df!vW\޶`Ųj=؍~\5EEEH!BW6c1~Kzs_:oK;ˮhQRcfo-Zk}|óra[;};>UJ}2 ߶Y \  pWs3sL͛G.O>Ӈ~ѿd;c"y 2^I@O`2a h0 !K@hA NW_栃*y{v+{0sŦZyrpe <`|rtB1P>X(cC`cVe w D0ؤr\D,B&")lEk&.*a?&J^:GxºZc*P⣙m>=nzm`GN|ᦣ1 ! !, 9bd$>o:SnPS[38gxB~|sSWWǔ[o7^-8OgK^ɗi0V4gRV-^ $ ?8\ꮭzR6u'lC?`WNl6ˣ'_Ǫ+۵oxXntCg%~z%e:8v`\QZ#'X[moFx}{P#@ he7Ow/%Y+WFN:Vq=J)>*+4K/g[ J޿ow:{LWV\y%-|VK"+?pȒ%= mŀFmr`8/wگ]w SܲU <_ g+/wX|XŹ_a*R2喿]nqdJ47a]CCZkH Ǵ9N4AHĀ P4ZVZ ab@ ۊ|Tn^ݥ}l'oъ =..>N>f<'٢?֚%K/7\;S!;=vd lއa[kG2ﺨH 86=Vz8a0GII E+-@? @z7#DJ}5]Ǵ;&2k懨o1C֚o-^uLbժ9{-?s=wcVYWFs%]w!̰ahiܟ rq]g~ Ml8mw g䵚a3]%2Zn!q΄3yH6*5C;ނeݻW*E&{~6,zvOdg UZ,ŷd2A9T:ݲ,ku־a6s}66;#Ruw$ۦ!㑎[_}ע5o㤟;^vP6)5_}%<' ^CqO kd(XkЃ<}X|ؑ\]ˇa#B]H&%q 4^*4 uD )x&ɐilG.T %mQxY2 am`Z$qVtVbfv- }e˪OseAS\j޻rݵWWk:l8Wt[xLr s|Awa ṀaaХ%QJQ__OcccPX)|?1!vItAXBJg[4 D"h#:xTچzKq䴇#]̹,%1ThoiV=3;2E#Zѕ7'fsqȡr9>|Dht:?|?YXխՕ?[=cYLr+/Ll;붦hɔfO90c 6Cj?{-/0f$?=[ :f|4{=ӟ;7Nd1t @iG;1 Z7V`Jc![ܪ׷):a 9{od9W}k/ww_Ǫ^}ó>Áq'n#vvl^5&:|fG ~F|nx;qv>ZsXb>0/L{?>65 y0W*YDy,Xf~ȓO<̙PW[[NR ?Wݰ]I+Wx_MsP@Q=Sl#C P>￉X*r췚vUXCG2[{X]tcY[[K/Nos///˯`.C *:Yl͞O3c 68ƟɀO'ڥ #URJU'}_mS\\Y Kku,"f!`0=vl>F#@ 1b]7҃+X 9b*y:d Ƕ9‹8C;T$dY|9,^yz~͂Ġ| ,7bn|(P Kf>8)u2dĀ  y$T [τdz?#XXouߓwǟpR* Z,^ ox.zf +{Sĺ4Y ;;tׯ+2| |58b06Nqe۵<$^{\w_}7^^󎳳6=N TB&kCQVW(kgUW,Uе"*6TPQHH' )6>s}9?;))3wd!&{|η}TCT#XKiz^0'=}\Ol];wNss3ergW1U3?)nYwXC\O.{bOssԹqċ%3mT4{ džtEي A)OzZa?rKds,Zz2^jhu' :;kjMf ;9?fW]’}=d!#Ho˶6}7(2L{]aV͞m0j,.DuTUײpI!--M_mG[*u2rO`e_/_DxhGF5Y&\ !*2UaE"2Akì[|]V S3oR֮|;X\ >RLap6-Xku"ꎊZ]‘'T]X;apxc4ƔRžDirqȏ黠K,A$I4mmm4F)K%kkf;`{c+2DȌYG6_58'k?I깋iēcv/{:=5q&s|xѫr>zn:#)uP 'yQRgZR*롰Xj).gJ*uW& .z<Mtm5GPaˆ!m[0|ÜW3iiXa\⹻~ʛFܴc"^0G33y[_U#.VP>9M0P tuuh Gk*BJD 8.cSRPnWV%Y{mH {"8bg!zv~R~8([w?FAY89 ߠaLL4Nd2gSLy:XSgJ  (ڇl&v4ژ~IuE\P޺,}+;1Si]`"$hAk_F!f:6oG.+֚׾߸xillLZ)ܴT̚QF' h BOQCNz~"DD0^B[๘b<% 􈬻+!(;j7n_Aέ{ûI֏#?WZ Vul6~#+_w+7 +P-t`hd&CWL"UUC9Tǐb\t.qkADQ4 gd"fY!EHwXa5H`,ʧ"@D0oq`Ƕx'ߠuH2d cvΟcڛ:ǑGW\x>[lO_m[^WMK>OGA[ܶBb8(!&1)L7[WQd& U]"S? & UЇ eVB$ f,DMb@VTbHԨZ@UE+Ot + \En$>E)U"^ qv{__XF\k6VzykYVzW_s-v:M|s9o^>0ZG^9JbC0Jp)3 o2Y0ܷ$6dvPFĆ 塁P!>)E&JD7 ~*żK6ȲwY#CguдICl\̍7 _xQ}oP Gb<JiqP*Ao G!ă(Хqw=p'u1^y< Ě[zY( )(LxD\9B, B_8\egXi'4|Eo8 {'+n2 e}皚>OwZ8׿%_ǃwo^B\|IRi]a{ #!F|=s5&gvwFS3 t lGohݰEogbYfݏY |{Yu\xyy>Z{Qj:::g~ԙr'.aٙz5RDaRrD6K[E us-v,CL?󕴬[t~>z{c̙=ɖky20Kyk$ L tttIT}cYuyruA @Cl)uSĖD!JQ7i ǟBV-=MxKq)tl~h3eT>_xkcr-?hϓ-ˆaHG0yâQ R fD0b1 aTSN IDATQYq'5+ٰh="-(;x>󲗽{c;W\M _w^_U+XC!ap$`LRpdc@aP3Y/>cvnZs_M?1x+˗s?Gs Sfq"7c\XAZ $`m ʀ2tt6@{5=D41yTVװfStu< ҙ |di^߶mW\q9V9a;?ƤN ,JJj)5XlDdN "XP-ūΙ5k@2, KKWE]N< 6_YfO]\O߆5G_>f(-ق sZnA+Ɩ, -cJeFz"@HgrxjjYzQXs|)\}5qY8@E? IL>{ûp\@AwHxI cBdў&C1u#Kevqfq$'zU5#r/n1gI*1q{ U7Y.  % UzXKG=1c` &6XcTdrR) 0QJQYUʹG޺lwʪ*.aҤI}ڵkg>͎;/ljr1rA"BC+E%6 q~q*ր= 񕦲ӒrFð"4s- "@a?m3+4{)՟]v>SO>9x,|L9l :  r>-|WinS. `-d^(tѠ4q\p_< "DJ=fsٰ晤QJqY/ \ˉ'z2 |;~?`sG9t.mؽRSS8q?+ִE1Ƙ! q4"rp=(D;uu'i"Ilyn=֬(yo};e`֬Y_sC}Ԟb_~LN. 64Aic @|Wm"}Ą]ȃKC[YQv=)uxʢy׭*K|L$o}۩:y=4E. #58h`Ib4 q rn8lm ]yTdL>m$;w+D2AȺˉ㡻R/^>YyիT56m+.D{v;W33[t)T<PZK< Cq=A/AB~0X8Ao QDC$3J}}=(;$D }GfZ ֚\kYhQOՁhjj W7>пhѼ=Jf j]B gǯ&3e&n"ٱa" HRd$H{SJ 8xmI@I\e#ai"m˦!?K>3&~x?~u56@|PS= Lm=Gͧfj'.N:įVRƻc䫪[ aTNY3ԆbT*E&)M6{g0'ojM"/,$KV}}H|{EC|g?cL>yo&\01vWr@=ۉl'4=zwЙ t'[e28~ qR3xUu,x%#eD!V߳T:sN=(1p !4LRXĀۅى’l\to_(kKJo-L&*J9Köm%&ym"zqܹ`/+(Ct D;Zvm6蔻 ./~}Kbg<2󴶶 A('35иg6~3qKc2h̑YKyqm/c2ZkQJ.Tj'YaFQ1Mqd"W5 a.D⼥qPo \K_A{tuV[vtk\bױw?ĊoXS_ Y/}>- 1$U-@ݰzWJrikk!v C_.vؙĤjXA3fR]]T\1Ġ]$AF!a0z__:$u_ wu;Hշ@׎ͣ =ذZBmI}t1`);P& a$ A)@w(i!MReNFD0hbaP1J)^y>wE'7so~XưaߗߴF]{Ac 0R 'XB;Z[rJANi L\Ii47AF8s1؈RٮOH&}ߧzcėEp87ZKsI1aU?a*~h:c3(jHp~Zb p q7*(5} ZM6[Foa^ NFtKիV|ݻ&p%'N:ݻӼg7A1?q4p1HYY55wR(tRËa1J+74`E)I&=k,sŘq} saLyZ;Vk-> /+Z|ǎ|Ob!-?ISg!mԸ6 p+„\M6ݷPC %},0q ڡ @"@1TԲ{Gg5~:z@6Nn|I Tsq?t/ <ǫL,A$ N:XqaP0i.akiuu׌ՕǑՂ qn;vwCb֬Yd~'D8,(2m&wllȥ,XٳP(ذY~-bq~*Â'JgHҤR^rѶXc HlݼƝ dW9(q'O11^WuO0#=/¾C㎝tڍr֘yŋSYQdPfM@aQYYzDUYkyxϻɔS:e*aeچ'PQY=4~ʁt6G:dinMNނ0NS11NIDt[L58 :V^7*=h A&"[! ‚%Bgͤ7gykGC ^ VT2#ٺiq74-Sg * A(k21sL!( 6aA[Q8bqI}(Cb>abl}n51(VAs1<4a#f/Ӧ9G AANɭC}}=\c J%&܆kD`0ȵiپT#&)d+*XhH|J ˜%dr( *j8zb1^pPQEed R__Omm-Z(0<wmGZT Y[P6U(B+:P_Wd &,;IN"]G>i3g?Xx d(CL'1\ϼhh:[^b}w lBH[18n§Ud|kb%d-HgTVnc젭eψ??aPU]+k5Ӷq5;WTJpH(a"Ξ=[=I*q,Te,XloH?";01q 4nJa&%ZE%SNq]8N3J ̺imnمSJQ][ϼQUS`ܫҙ,KN8є~*â'S];I.׸,kfM_cca|r&bcT +JZ[G4iӦ%ҿY{7li#;s:`a(S0$ ;A{I܇Z k|.tl % g7oZ뤀hp<(iٹ A-F=zvkZy\DE|N:$ '."@&Z6=b~bMϮ律{b?L&OpPT:˱Nd'SQY-c<q_|g 8o{STHwtbBښ߭{Ň U`Ks@1֢EѰY0-h td++Xx#> t E3z{wŽdnhi 4APcه8Kmd͜MuM~*-cSlmbS}zbk)iɳ4_ u](O@[ȷyF9FnaNa\Je:(GY5 E)Kg{3[V>aMsa֬Y6Ri;ZP||qx qRw It.&O~ sÀv8" yR~:8Ri2JO`Er]5I߹¦wn(@~G(;ٳ{j>E4D!V#B&6`Y@8"kkQR&;(.^rcqTQP.M g,<8v<XS OQ[~bOZfIg=ĝ2kY,~'|YGu4V`l fϞ8(QJ-80a;:N\Oǭ Lh2nfC1ȓIymIR8}R)5$dl Zp4#쳘SJ3k\kصc+MIILvUյ*u/,u O~m'AeU22IgGox.Gw1 O?ҴIyKPov>r/Mެ~4Vư/^B[SaݳVz֭X_k^W= ò<8D^q{Ѳi&M!2TQU]h&%0}ۙooM(du9SC㩙g'}w6ÚA1JcEjrO0{l<# P*2Kq M  s ˜FdnPBOa,9Zς0DH)XYwa5`bZx1.ɜϱQVPX<9lu"bl֤x4F"8ɋq9裱v(vosQ+^頭!buDM8)+@.=uzFl" IŚA`ٲi<͈_0ڱqDgX?[1X+Jc1"婲R'ٛѪ6ls ezPYЃ҂zwyn)ѽQ*FOb@V)uim״{;772sL2jG.qXF\?Q!Oۦ5|^6D L+nkll- '3WXk;wnZZXeX(hܾW9xZ$v Z7yJp,.7ҥambQuK#=Z{CVcs \t~8 ټa [7#ΑUdq]o8cqp7'o ppuӵkڲ\Ω4 0JtILTOKS+Z)T)4s}f%m{p'M"Zc0nŇцL:CAe&5oXʤq5gy&G1$Zi l"@8Z?cC]令Tp[vIGn( ayC^R֚iӦYfV6hgۖtE)?bZ;ZHYL*Ų|;nJ0"Wdsi6ucf6&>(n gƄ REl 3@cpLRPSrܱ u6(wՆQ p]&^Cl千bH׼H?#"@6[w;+ "]! #@ OAR I6 ʳe3T9߷ m5>>tcf:wJ,3i7o;{-6ڤ7 F𡊊Ǥ+a0 tG;˿ uJNV&6`cw,..UQɑR qp 今Ex I(k'~G_Z7ۭG V"!YᲚ2-ɶT`pƌ̚3"1ȁc!Һy=a[X@a#.G:ٴIZV|E=7N;0vFsqĞ={,I0ko!.Yv_$B[I N>=)ήz<#.&EaabfRa#@G;Q[ V1\p!)'="duݏ/~K1Nٮ~I "%~l^]Rk`Y烵$Yfڌr(b1M}>>Zǂ0QP_c͓ܲ J>] IDAT&֢N8N< ɦV-O8`ikҒ uTJo vBp/J=+A`/j] Lb;LI5a9~Z垿^z 0\3:ǂneA{2׾8td9fq, @)P xt¨ZKHmArϦn$1p&ҸI:vl#]YG!Ra(`5Fuo/ذ}JI=cѥsdʍk }&6_AA+@$m<)Ys)',(cХ޿u= (ݳk*!t8p,Os ҆ 8"tɢ3T_qjX/ `x^WvoBcgaG> "0--d3i\kiݽU=#\#JKgHRwy#E+=&DA`YR7L%!k~62G=aN$S,1O< W81Z.; .Ҽy ' 0K ZGg=D(h jkFcjbbPJ XExyp]7::*eR#p" }3۸gMozbe 2x~t/B@h hKtACUlͫf!0qLl <\x#5~1LnAAax9 |'ٳZp\/6cc$FEH ^A"$92XmЦ=wy_J?go" cT!f*#q3sL7EXu5WLΤD6q:sװBT@`/:;!ň)\+"~9K  e|( RhGr'rgS(:'X foIRI3AƤ5:QҼb#zD(G'>v)J%c> e59zX< DA0%7?ٶy;@60+g\NV;2;  U\O܊Βe$EE̛7Wx"bF5SA0ausxbZ: &tR[Y lI067.RiaqWOA 0QO|=QIj*  =+8[1s_;* r"0PڢEc]8NàDQ1},\pFq!Aq¥_LHoxk_Z0m7$;~>]ĸ!AHLX|.m.Ih5V[|eVs׏ny.qlZZN?t>K( I=c\X?D V=OXD)/Q]],l@QxDLQT|BQ. "!k-r4YC5o&2P,R)q*KUЇ͸~AhMP}(f޼y|c#V\U+2KV|ՐNAGҝq`":KדccBRa%\9CPZ'!E|uX?D  q>?OrgP, ڒoosYQ6壬 uÛz\,GG!DAv! >ŏbQs[@9DA0 p\7ͷo~Ŵ Qv1Z, P$>FDWaKecJQ@UGn5o 06&6 9D"/zA">1b"2y]_4dJ+ydY|G7Ĥj:E  >D p5pw£A"BN;4n<kp FC8d] &jKB9IjQ+ ,>bFc(877/w\Mplc1KWG -?9GyLoxXk+DAk"d -x|С\?Wӯ bb"\.B(D1:Pzcƿ/B ("˸j!z=.1{P "LzeAvv+9C93MxMs-9QeN#YAStOIvZ Pm0`aՄO?ϢEm E70Idd{ݡ!py$) ŰFf=47!eCJćօH'`!.1<ikmCI'{C&L duc"izEsk$^[naŵ+B?_g{9[ci mh:>g oY(w*%cw_ˊc(QR+1Y¤_6H{G~<~ؗx$ "ι/^vtt@ggWJŹ8aIl9_BI1ڃVNw3vtՈF)UU7cz, "`}nlw89Ӧ%+yQg҂Ju9$,4Nυ^Ȍw#د?rI{9@xu> V]&C1cC;ohHiru4RbEJIL:sΞ?D:X3}\B#J"!pI 7/|:X8meaYLd 8+uDES^ڋgiWP$J$s$_ hma[38{⯘,G|pQ:{ ># n?v0<$?!s%ߐ#1! #vJjGJ][edێm{QGXv H2I!L yJBcý=,^<@XEcQ.A%|'g|֖$ UJY Eđ80p@z!j+r !nHFFFFFƿ98{}nZN9.RRk |-N"li5`8p߱$.OaX8H %+C73cY$UcEZRH'O׾*ł`Ӹes/Šv{NHh"gWr*$QOXgy;)Y !abe<=k=B˦`eddddd]?@E̜9N:n BB EDeΕ)s֙_/@W+Xk#KT_`;;xŗyWִ) hpΡX,H!V yvk4y&NA#Pis lH+'ŃF RΥ"\A@}9 FkIlc4ɓO>ɡJSSS}Sj5L0addddddbx>gux9ZkT8P(Ta.I;0l g<-$x9tVoBGG"ZZ)M`>o},IAOA^; sũAckap$+O;Cۑ~6܀/*$q[q9p)b\| _NP"EkMߟ)%&1(kK1J$ˀdddddds9_X_ۓR='p ,y_P C|>\xH t._V6gwSYY" AH `\`֛#j!"\cbz+8֦$#cJ%˓|>rwNx(Jk=ў&17{)&Lۆp HFFFFF?s33+HWq/b&Lpַ!F E;ǐ6͏-:|TR% v|496А'y]w=NW'G91PI{=,@R|#"_tUBI"$N 1&<$D:Eh%aG}~+B [HGLҘ :KqLP5Uq _(i"CJtu.% @a*&2DCVI\BPS_Eg!G5 Q*GՐ8ps@4ve9snmK_>+3!@ۉÐ> .B$ b>H {~vc.p&>k<%qqTkO6',Fp߽pE\=*BJ59_#%!MG׏Ո| p6D:&j"ٵ&0G#,1FD%PU}S[PBuzLqP,hQV*xyE\n{?AsYB|0&F8DG I,"~wٵw$4 GrJ;Æ o)>BB|{A?kYGT8̥^JHbSZ)'Y?^B (D+w׿# HP,0w|~qU+,OR.ԜUeaB%1>+4h:>Rݞl8&RI/a.g|d~(S g-KˮV`)rBRGQM= \[ NMjH-4l_8hܮƒcZG2e*ډb ",A IDATyiY>{po=,^RWRhcI-,>%/vTu; PEmW352S vg#T<4#Ӆpp.@VSORw$ h|wqsri>AE ("X[\ZVFo6'f>̞6RFߣ"  F\an;}kg(gBLՅdS"깖%@Rޟ_?],X/| +Xztg,Qbc\wl6Ie)RDX5hݯG|}B{BD tCzEuw\C,-GJ5WiMXҕ桛p1泻0bԧY^ (AhMk\U]J2!S:2>4yOMLsS]7^|<3~C>$8gP<%H5 V ㈃>ǹg!qe(wt VHe:G @a2MF/hiƉ_=FRbd: B0`O}u= !~ W8h]!?ɏW'isbe cܔpa#mW!%X4xJ )\ӆ,= _m݃mvݓ]vەDΦ-BjR}0 Fs9Y 6 Z' UFY&%eSc͜(gyst^~Qx~@5JS*ơ9MyCI*.(J(U 6Nrm|~\~Y D,J- "jO-*'xwb ,i'C!JH /=WVJcs8qPM{ͤIUOꊾ0 xu4SBtKH35 5'AqݠΉZX+V0gdy/`dݘTKO!@>5TA[cŒw㥿/OaΔW\g]ٓR׼ y::V}ȃ,Xk ~D {)nbZ%' # V(=B$sxT$V_2@V,O\=d毈2e [lź=O~R7re*>H)6m7|#}:r**ݯc} ^ytO+sӠLɿO%^O8 AKtg8p!+fVhE>_y7&+\uw>21m%#50}N 㢰[,]ڝ%a9I5$HFFFFFƪٌh>X&̙#͛G^ZHFn>=wCCE%1R#CӲfʹbZ< l8z2N@"$Fy@,K/[SA$q"CzPd}IƸ8R!l̜9$КrT!QRUBz>/M'Bsu)LzuPl嶌m?mmCa| 4q$YXT 1BJVJh0ѳǹJ|0$N.!4)M7@lXp1K{IO<{^g Ahh%"[p a@#x؄Dk}N;tvck/VݒH.szk!֦wg?X.HOei;|Qq.t.{O=b6}EP1pwbȀ~LU8BʕyE(D*־- ,HFFFFFƆV_"@Xb?İ>ҿ'l_@o;?ᮇY|_$! | $qbc3l%?o}% AڒL!&v*/<f{n͛Obj¢:s~(ˀ$Hʝ]hϢHAR2{x^(63h&l4r3l6oᛠik1-i7񞹵ӣQ_őq&w.5X|Asy~w3S`5gp7Q^I!t/[PX+6! BFxnsk#D ɓ9s& <$wHak~@'5;)q(-_s|b@?F`ȰMh47ے|~P,҂8NpLC"X8@!)X Z5rv8c(64ZXg2W{_!B`Cg8ԺEڀ/QR~:'&,[eN:::;xZRYKBG \PR(3QuܲJkTxZE&!lfN|g=vg69^:9rTHϓ|Z>.)צlwUq< e|aJSx꩗(GB(AGHr;^YVK&f3"222222Vܿ.W>sV+c˘Ooϸ  $("b~/s GbgVᅱdPrDІ[x8'1cB"+1Zu@V`3dĉH)T*5t!E=}z?ʥ=-^੧gƌttttg:1@{4JD/XKROA} M~-4hniFF 444sr9VA~0FJJ- !^)9?ugz:5q!E)pq$QD\\ꤴ|R'sgCĊ+(:Xh+Z ! йb!ꙞTXz V)@H%'1惙zao{6yKr*&wo=DQ," Ge _TPD(kM91yB\MPxaB3a AtnT*1zhޝ= jS(= ޞ\.UjC<6]J^Ex\'0L˃ҕsK"ʑm$wu2?ytT-j9W?h <@VkGadUB/&1(܋/(^z%&MokZWԟ'k\j ڪaD޻Rt^!4':FtF "qy_B>OZ& ˖}%~_haRI]TWU2qX-^ 6NM0ΏBR2)R=%66`JH9 61cg}Ʋ뮻[#@+8kekz/YM/ &LGJ0"I8ݹ2%|b eҡ^P.:~~ѭ8 J1X"-NjM?PVwqr͎>wZ !;ذvY|_ jcOBI㚏^$..aQ᫨XnWK誖)4&W^s;\r RRM"""vm7.rFhML2g}{3gvfA@GNJjzqck=^]ޟaJR԰YԄ襞V>G&lr}ƾ|q0z5 M]Ln#vD ,$&]^QRbŘlp>yay AfB|+_ᦛn<$aivޜo3d@Ҋv5"%/[.K;:K3H=2C]% ">y f$#####à 3>kk+zm?ni`(BɀD%/Ypa ?I8=YOGo!M-*IDATMMe -W-?+nIJ1\2u_?K/`c BJ%(JL:g}W_}7xR2XDQmt$a]Vuݽ 3juُzCDG="9+S{ KLsLiiݙK)[lȑ#qarsd4}7RVvƍC=D i[D9v!q/d̨xq2I )s?=OTG$(㬟řgԚ5qOLdddddd> >m]H{{;~ D UL' ETf"$p_9|k"dMUmY]V1C2dm-V6mݖÇ3|r` kP*D`urJ8 4rLTbg鵓S!`F88Oks鬂A99ֈ2|0pmK$BHFFFFFƆ)@nkԶt YAѫjW GۑJN;T.GX*Ŏ|Cʉ7knN_~,f͚2yTBIX AثF& U:9U׿qb,]*Y!D Wn'K_+:) T*]iP8|' g)'Q'Q @(D0{~s2Q(/_!~ѣke̘ $ 񱩻+@>|U͛GWWK.9sRR`(ZER87Ƥ=QV&y2]JyH)) ݍ|&<#Fl 0\.Gss3 dMtnxZRc }`bܚ I vߵqsW:O|G&@222222| ݺn'"&OwM]WZM4$QDs^p׎爃dP&JYorͷ1Ih/5݁M$va\}hB0 1&u_! zBI_(x{ 8T*_BVJ%,Yvذa~oll%@}[ ?/K(> tq=>~_[UkO~9s+i z뭹3z{k= ! [&[Xۛ3g?yYlN(g!QGkeP[+6vLm*!4iD:5z`sgӟV*~(>cM$II{jOo kNj>^CԳ5RՈ/R$袋x뭷jꙏc˯~+ DIߣܮۑ8\ؠp=3s쳏C*细N76;rR (\ )*p;s"s˷49pbm{Yk{}eq(g]_=9{s}}_'?/Z}tvX) .ϻ \)nws.Əf".,f (x뭷gXp!^R'IjhW+^eR N_ߋb}Ox[YJ?= ~u>ԇ =.z+&Mbʔ),]BqaÆc{9#$55!9 TN꿾ɓ'sGh8)B"E`~I I?5_LrH 2 '!rKԿ;PZub,Z9s|r `! X)"""xP +ڧȡda*{^c_ߒa> 63) == 1 gdu-5.36.1/internal/common/ignore.go000066400000000000000000000156751517447455500173420ustar00rootroot00000000000000// Package common contains commong logic and interfaces used across Gdu // nolint: revive //Why: this is common package package common import ( "bufio" "os" "path/filepath" "regexp" "strings" log "github.com/sirupsen/logrus" ) // CreateIgnorePattern creates one pattern from all path patterns func CreateIgnorePattern(paths []string) (compiled *regexp.Regexp, err error) { for i, path := range paths { if _, err = regexp.Compile(path); err != nil { return nil, err } if !filepath.IsAbs(path) { absPath, err := filepath.Abs(path) if err == nil { paths = append(paths, absPath) } } else { relPath, err := filepath.Rel("/", path) if err == nil { paths = append(paths, relPath) } } paths[i] = "(" + path + ")" } ignore := `^` + strings.Join(paths, "|") + `$` return regexp.Compile(ignore) } // SetIgnoreDirPaths sets paths to ignore func (ui *UI) SetIgnoreDirPaths(paths []string) { log.Printf("Ignoring dirs %s", strings.Join(paths, ", ")) ui.IgnoreDirPaths = make(map[string]struct{}, len(paths)*2) for _, path := range paths { ui.IgnoreDirPaths[path] = struct{}{} if !filepath.IsAbs(path) { if absPath, err := filepath.Abs(path); err == nil { ui.IgnoreDirPaths[absPath] = struct{}{} } } else { if relPath, err := filepath.Rel("/", path); err == nil { ui.IgnoreDirPaths[relPath] = struct{}{} } } } } // SetIgnoreDirPatterns sets regular patterns of dirs to ignore func (ui *UI) SetIgnoreDirPatterns(paths []string) error { var err error log.Printf("Ignoring dir patterns %s", strings.Join(paths, ", ")) ui.IgnoreDirPathPatterns, err = CreateIgnorePattern(paths) return err } // SetIgnoreFromFile sets regular patterns of dirs to ignore func (ui *UI) SetIgnoreFromFile(ignoreFile string) error { var err error var paths []string log.Printf("Reading ignoring dir patterns from file '%s'", ignoreFile) file, err := os.Open(ignoreFile) if err != nil { return err } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { paths = append(paths, scanner.Text()) } if err := scanner.Err(); err != nil { return err } ui.IgnoreDirPathPatterns, err = CreateIgnorePattern(paths) return err } // SetIgnoreTypes sets file types to ignore func (ui *UI) SetIgnoreTypes(types []string) { log.Printf("Ignoring file types: %s", strings.Join(types, ", ")) ui.IgnoreTypes = types } // SetIncludeTypes sets file types to include (whitelist) func (ui *UI) SetIncludeTypes(types []string) { log.Printf("Including only file types: %s", strings.Join(types, ", ")) ui.IncludeTypes = types } // SetIgnoreHidden sets flags if hidden dirs should be ignored func (ui *UI) SetIgnoreHidden(value bool) { log.Printf("Ignoring hidden dirs") ui.IgnoreHidden = value } // ShouldDirBeIgnored returns true if given path should be ignored func (ui *UI) ShouldDirBeIgnored(name, path string) bool { _, shouldIgnore := ui.IgnoreDirPaths[path] if shouldIgnore { log.Printf("Directory %s ignored", path) } return shouldIgnore } // ShouldDirBeIgnoredUsingPattern returns true if given path should be ignored func (ui *UI) ShouldDirBeIgnoredUsingPattern(name, path string) bool { shouldIgnore := ui.IgnoreDirPathPatterns.MatchString(path) if shouldIgnore { log.Printf("Directory %s ignored", path) } return shouldIgnore } // IsHiddenDir returns if the dir name begins with dot func (ui *UI) IsHiddenDir(name, path string) bool { shouldIgnore := name[0] == '.' if shouldIgnore { log.Printf("Directory %s ignored", path) } return shouldIgnore } // ShouldFileBeIgnoredByType returns true if file should be ignored based on its extension func (ui *UI) ShouldFileBeIgnoredByType(name string) bool { if len(ui.IgnoreTypes) == 0 { return false } ext := strings.ToLower(filepath.Ext(name)) if ext == "" { return false // No extension, don't ignore } // Remove leading dot from extension ext = strings.TrimPrefix(ext, ".") for _, ignoreType := range ui.IgnoreTypes { // Remove leading dot from ignoreType cleanIgnoreType := strings.TrimPrefix(strings.ToLower(ignoreType), ".") if cleanIgnoreType == ext { log.Printf("File %s ignored by type", name) return true } } return false } // ShouldFileBeIncludedByType returns true if file should be included based on its extension func (ui *UI) ShouldFileBeIncludedByType(name string) bool { if len(ui.IncludeTypes) == 0 { return true // No include filter, include all } ext := strings.ToLower(filepath.Ext(name)) if ext == "" { return false // No extension, don't include if we have include filter } // Remove leading dot from extension ext = strings.TrimPrefix(ext, ".") for _, includeType := range ui.IncludeTypes { // Remove leading dot from includeType cleanIncludeType := strings.TrimPrefix(strings.ToLower(includeType), ".") if cleanIncludeType == ext { return true } } log.Printf("File %s excluded by type filter", name) return false } // CreateIgnoreFunc returns function for detecting if dir should be ignored // nolint: gocyclo // Why: This function is a switch statement that is not too complex func (ui *UI) CreateIgnoreFunc() ShouldDirBeIgnored { switch { case len(ui.IgnoreDirPaths) > 0 && ui.IgnoreDirPathPatterns == nil && !ui.IgnoreHidden: return ui.ShouldDirBeIgnored case len(ui.IgnoreDirPaths) > 0 && ui.IgnoreDirPathPatterns != nil && !ui.IgnoreHidden: return func(name, path string) bool { return ui.ShouldDirBeIgnored(name, path) || ui.ShouldDirBeIgnoredUsingPattern(name, path) } case len(ui.IgnoreDirPaths) > 0 && ui.IgnoreDirPathPatterns != nil && ui.IgnoreHidden: return func(name, path string) bool { return ui.ShouldDirBeIgnored(name, path) || ui.ShouldDirBeIgnoredUsingPattern(name, path) || ui.IsHiddenDir(name, path) } case len(ui.IgnoreDirPaths) == 0 && ui.IgnoreDirPathPatterns != nil && ui.IgnoreHidden: return func(name, path string) bool { return ui.ShouldDirBeIgnoredUsingPattern(name, path) || ui.IsHiddenDir(name, path) } case len(ui.IgnoreDirPaths) == 0 && ui.IgnoreDirPathPatterns != nil && !ui.IgnoreHidden: return ui.ShouldDirBeIgnoredUsingPattern case len(ui.IgnoreDirPaths) == 0 && ui.IgnoreDirPathPatterns == nil && ui.IgnoreHidden: return ui.IsHiddenDir case len(ui.IgnoreDirPaths) > 0 && ui.IgnoreDirPathPatterns == nil && ui.IgnoreHidden: return func(name, path string) bool { return ui.ShouldDirBeIgnored(name, path) || ui.IsHiddenDir(name, path) } default: return func(name, path string) bool { return false } } } // CreateFileTypeFilter returns function for detecting if file should be ignored based on type func (ui *UI) CreateFileTypeFilter() ShouldFileBeIgnored { // If we have include types, use whitelist mode if len(ui.IncludeTypes) > 0 { return func(name string) bool { return !ui.ShouldFileBeIncludedByType(name) } } // If we have ignore types, use blacklist mode if len(ui.IgnoreTypes) > 0 { return func(name string) bool { return ui.ShouldFileBeIgnoredByType(name) } } // No type filtering - return nil to indicate no filtering is needed return nil } gdu-5.36.1/internal/common/ignore_scanner_test.go000066400000000000000000000017161517447455500221010ustar00rootroot00000000000000package common_test import ( "os" "path/filepath" "testing" "github.com/dundee/gdu/v5/internal/common" "github.com/stretchr/testify/assert" ) func TestSetIgnoreFromEmptyFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "ignore") err := os.WriteFile(path, []byte(""), 0o600) assert.Nil(t, err) ui := &common.UI{} err = ui.SetIgnoreFromFile(path) assert.Nil(t, err) shouldIgnore := ui.CreateIgnoreFunc() assert.False(t, shouldIgnore("anything", "/anything")) } func TestSetIgnoreFromFileWithBlankLines(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "ignore") content := "/valid\n/other\n" err := os.WriteFile(path, []byte(content), 0o600) assert.Nil(t, err) ui := &common.UI{} err = ui.SetIgnoreFromFile(path) assert.Nil(t, err) shouldIgnore := ui.CreateIgnoreFunc() assert.True(t, shouldIgnore("valid", "/valid")) assert.True(t, shouldIgnore("other", "/other")) assert.False(t, shouldIgnore("xxx", "/xxx")) } gdu-5.36.1/internal/common/ignore_test.go000066400000000000000000000303451517447455500203700ustar00rootroot00000000000000package common_test import ( "os" "path/filepath" "testing" log "github.com/sirupsen/logrus" "github.com/dundee/gdu/v5/internal/common" "github.com/stretchr/testify/assert" ) func init() { log.SetLevel(log.WarnLevel) } func TestCreateIgnorePattern(t *testing.T) { re, err := common.CreateIgnorePattern([]string{"[abc]+"}) assert.Nil(t, err) assert.True(t, re.MatchString("aa")) } func TestCreateIgnorePatternWithErr(t *testing.T) { re, err := common.CreateIgnorePattern([]string{"[[["}) assert.NotNil(t, err) assert.Nil(t, re) } func TestEmptyIgnore(t *testing.T) { ui := &common.UI{} shouldBeIgnored := ui.CreateIgnoreFunc() assert.False(t, shouldBeIgnored("abc", "/abc")) assert.False(t, shouldBeIgnored("xxx", "/xxx")) } func TestIgnoreByAbsPath(t *testing.T) { ui := &common.UI{} ui.SetIgnoreDirPaths([]string{"/abc"}) shouldBeIgnored := ui.CreateIgnoreFunc() assert.True(t, shouldBeIgnored("abc", "/abc")) assert.False(t, shouldBeIgnored("xxx", "/xxx")) } func TestIgnoreByPattern(t *testing.T) { ui := &common.UI{} err := ui.SetIgnoreDirPatterns([]string{"/[abc]+"}) assert.Nil(t, err) shouldBeIgnored := ui.CreateIgnoreFunc() assert.True(t, shouldBeIgnored("aaa", "/aaa")) assert.True(t, shouldBeIgnored("aaa", "/aaabc")) assert.False(t, shouldBeIgnored("xxx", "/xxx")) } func TestIgnoreFromFile(t *testing.T) { file, err := os.OpenFile("ignore", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { panic(err) } defer file.Close() if _, err := file.WriteString("/aaa\n"); err != nil { panic(err) } if _, err := file.WriteString("/aaabc\n"); err != nil { panic(err) } if _, err := file.WriteString("/[abd]+\n"); err != nil { panic(err) } ui := &common.UI{} err = ui.SetIgnoreFromFile("ignore") assert.Nil(t, err) shouldBeIgnored := ui.CreateIgnoreFunc() assert.True(t, shouldBeIgnored("aaa", "/aaa")) assert.True(t, shouldBeIgnored("aaabc", "/aaabc")) assert.True(t, shouldBeIgnored("aaabd", "/aaabd")) assert.False(t, shouldBeIgnored("xxx", "/xxx")) } func TestIgnoreFromNotExistingFile(t *testing.T) { ui := &common.UI{} err := ui.SetIgnoreFromFile("xxx") assert.NotNil(t, err) } func TestIgnoreHidden(t *testing.T) { ui := &common.UI{} ui.SetIgnoreHidden(true) shouldBeIgnored := ui.CreateIgnoreFunc() assert.True(t, shouldBeIgnored(".git", "/aaa/.git")) assert.True(t, shouldBeIgnored(".bbb", "/aaa/.bbb")) assert.False(t, shouldBeIgnored("xxx", "/xxx")) } func TestIgnoreByAbsPathAndHidden(t *testing.T) { ui := &common.UI{} ui.SetIgnoreDirPaths([]string{"/abc"}) ui.SetIgnoreHidden(true) shouldBeIgnored := ui.CreateIgnoreFunc() assert.True(t, shouldBeIgnored("abc", "/abc")) assert.True(t, shouldBeIgnored(".git", "/aaa/.git")) assert.True(t, shouldBeIgnored(".bbb", "/aaa/.bbb")) assert.False(t, shouldBeIgnored("xxx", "/xxx")) } func TestIgnoreByAbsPathAndPattern(t *testing.T) { ui := &common.UI{} ui.SetIgnoreDirPaths([]string{"/abc"}) err := ui.SetIgnoreDirPatterns([]string{"/[abc]+"}) assert.Nil(t, err) shouldBeIgnored := ui.CreateIgnoreFunc() assert.True(t, shouldBeIgnored("abc", "/abc")) assert.True(t, shouldBeIgnored("aabc", "/aabc")) assert.True(t, shouldBeIgnored("ccc", "/ccc")) assert.False(t, shouldBeIgnored("xxx", "/xxx")) } func TestIgnoreByPatternAndHidden(t *testing.T) { ui := &common.UI{} err := ui.SetIgnoreDirPatterns([]string{"/[abc]+"}) assert.Nil(t, err) ui.SetIgnoreHidden(true) shouldBeIgnored := ui.CreateIgnoreFunc() assert.True(t, shouldBeIgnored("abbc", "/abbc")) assert.True(t, shouldBeIgnored(".git", "/aaa/.git")) assert.True(t, shouldBeIgnored(".bbb", "/aaa/.bbb")) assert.False(t, shouldBeIgnored("xxx", "/xxx")) } func TestIgnoreByAll(t *testing.T) { ui := &common.UI{} ui.SetIgnoreDirPaths([]string{"/abc"}) err := ui.SetIgnoreDirPatterns([]string{"/[abc]+"}) assert.Nil(t, err) ui.SetIgnoreHidden(true) shouldBeIgnored := ui.CreateIgnoreFunc() assert.True(t, shouldBeIgnored("abc", "/abc")) assert.True(t, shouldBeIgnored("aabc", "/aabc")) assert.True(t, shouldBeIgnored(".git", "/aaa/.git")) assert.True(t, shouldBeIgnored(".bbb", "/aaa/.bbb")) assert.False(t, shouldBeIgnored("xxx", "/xxx")) } func TestIgnoreByRelativePath(t *testing.T) { ui := &common.UI{} ui.SetIgnoreDirPaths([]string{"test_dir/abc"}) shouldBeIgnored := ui.CreateIgnoreFunc() assert.True(t, shouldBeIgnored("abc", "test_dir/abc")) absPath, err := filepath.Abs("test_dir/abc") assert.Nil(t, err) assert.True(t, shouldBeIgnored("abc", absPath)) assert.False(t, shouldBeIgnored("xxx", "test_dir/xxx")) } func TestIgnoreByRelativePattern(t *testing.T) { ui := &common.UI{} err := ui.SetIgnoreDirPatterns([]string{"test_dir/[abc]+"}) assert.Nil(t, err) shouldBeIgnored := ui.CreateIgnoreFunc() assert.True(t, shouldBeIgnored("abc", "test_dir/abc")) absPath, err := filepath.Abs("test_dir/abc") assert.Nil(t, err) assert.True(t, shouldBeIgnored("abc", absPath)) assert.False(t, shouldBeIgnored("xxx", "test_dir/xxx")) } func TestIgnoreFromFileWithRelativePaths(t *testing.T) { file, err := os.OpenFile("ignore", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { panic(err) } defer file.Close() defer os.Remove("ignore") if _, err := file.WriteString("test_dir/aaa\n"); err != nil { panic(err) } if _, err := file.WriteString("node_modules/[^/]+\n"); err != nil { panic(err) } ui := &common.UI{} err = ui.SetIgnoreFromFile("ignore") assert.Nil(t, err) shouldBeIgnored := ui.CreateIgnoreFunc() assert.True(t, shouldBeIgnored("aaa", "test_dir/aaa")) absPath, err := filepath.Abs("test_dir/aaa") assert.Nil(t, err) assert.True(t, shouldBeIgnored("aaa", absPath)) assert.False(t, shouldBeIgnored("xxx", "test_dir/xxx")) } func TestShouldFileBeIgnoredByType(t *testing.T) { tests := []struct { name string ignoreTypes []string filename string expectedIgnored bool }{ { name: "no ignore types", ignoreTypes: []string{}, filename: "test.yaml", expectedIgnored: false, }, { name: "ignore yaml", ignoreTypes: []string{"yaml"}, filename: "test.yaml", expectedIgnored: true, }, { name: "ignore json", ignoreTypes: []string{"json"}, filename: "test.json", expectedIgnored: true, }, { name: "ignore multiple types", ignoreTypes: []string{"yaml", "json"}, filename: "test.yaml", expectedIgnored: true, }, { name: "ignore multiple types - not matched", ignoreTypes: []string{"yaml", "json"}, filename: "test.txt", expectedIgnored: false, }, { name: "ignore with uppercase", ignoreTypes: []string{"YAML"}, filename: "test.yaml", expectedIgnored: true, }, { name: "ignore file without extension", ignoreTypes: []string{"yaml"}, filename: "test", expectedIgnored: false, }, { name: "ignore with dot in extension", ignoreTypes: []string{".yaml"}, filename: "test.yaml", expectedIgnored: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ui := &common.UI{} ui.SetIgnoreTypes(tt.ignoreTypes) actual := ui.ShouldFileBeIgnoredByType(tt.filename) assert.Equal(t, tt.expectedIgnored, actual) }) } } func TestShouldFileBeIncludedByType(t *testing.T) { tests := []struct { name string includeTypes []string filename string expectedIncluded bool }{ { name: "no include types", includeTypes: []string{}, filename: "test.yaml", expectedIncluded: true, }, { name: "include yaml", includeTypes: []string{"yaml"}, filename: "test.yaml", expectedIncluded: true, }, { name: "include json", includeTypes: []string{"json"}, filename: "test.json", expectedIncluded: true, }, { name: "include multiple types", includeTypes: []string{"yaml", "json"}, filename: "test.yaml", expectedIncluded: true, }, { name: "include multiple types - not matched", includeTypes: []string{"yaml", "json"}, filename: "test.txt", expectedIncluded: false, }, { name: "include with uppercase", includeTypes: []string{"YAML"}, filename: "test.yaml", expectedIncluded: true, }, { name: "include file without extension", includeTypes: []string{"yaml"}, filename: "test", expectedIncluded: false, }, { name: "include with dot in extension", includeTypes: []string{".yaml"}, filename: "test.yaml", expectedIncluded: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ui := &common.UI{} ui.SetIncludeTypes(tt.includeTypes) actual := ui.ShouldFileBeIncludedByType(tt.filename) assert.Equal(t, tt.expectedIncluded, actual) }) } } func TestCreateFileTypeFilter(t *testing.T) { tests := []struct { name string includeTypes []string ignoreTypes []string filename string expectedFiltered bool }{ { name: "no filters", includeTypes: []string{}, ignoreTypes: []string{}, filename: "test.yaml", expectedFiltered: false, }, { name: "include filter - matched", includeTypes: []string{"yaml"}, ignoreTypes: []string{}, filename: "test.yaml", expectedFiltered: false, }, { name: "include filter - not matched", includeTypes: []string{"json"}, ignoreTypes: []string{}, filename: "test.yaml", expectedFiltered: true, }, { name: "ignore filter - matched", includeTypes: []string{}, ignoreTypes: []string{"yaml"}, filename: "test.yaml", expectedFiltered: true, }, { name: "ignore filter - not matched", includeTypes: []string{}, ignoreTypes: []string{"json"}, filename: "test.yaml", expectedFiltered: false, }, { name: "include filter takes precedence", includeTypes: []string{"yaml"}, ignoreTypes: []string{"yaml"}, filename: "test.yaml", expectedFiltered: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ui := &common.UI{} ui.SetIncludeTypes(tt.includeTypes) ui.SetIgnoreTypes(tt.ignoreTypes) filter := ui.CreateFileTypeFilter() var actual bool if filter == nil { // When filter is nil, no filtering is applied, so file should not be filtered actual = false } else { actual = filter(tt.filename) } assert.Equal(t, tt.expectedFiltered, actual) }) } } func TestFileTypeFilterWithRealFiles(t *testing.T) { // Create a temporary directory with test files tmpDir := t.TempDir() // Create test files testFiles := []struct { name string content string expected bool // expected to be included }{ {"test.yaml", "key: value", true}, {"test.json", "{\"key\": \"value\"}", true}, {"test.txt", "plain text", false}, {"test.go", "package main", false}, {"noextension", "no extension", false}, } for _, tf := range testFiles { filePath := filepath.Join(tmpDir, tf.name) err := os.WriteFile(filePath, []byte(tf.content), 0644) assert.NoError(t, err) } // Test include filter ui := &common.UI{} ui.SetIncludeTypes([]string{"yaml", "json"}) filter := ui.CreateFileTypeFilter() for _, tf := range testFiles { actual := filter(tf.name) expected := !tf.expected // filter returns true if file should be filtered out assert.Equal(t, expected, actual, "Failed for file: %s", tf.name) } // Test ignore filter ui2 := &common.UI{} ui2.SetIgnoreTypes([]string{"txt", "go"}) filter2 := ui2.CreateFileTypeFilter() for _, tf := range testFiles { actual := filter2(tf.name) // For ignore filter, yaml and json should not be filtered, txt and go should be filtered expected := tf.name == "test.txt" || tf.name == "test.go" assert.Equal(t, expected, actual, "Failed for file: %s", tf.name) } } func TestCreateFileTypeFilterReturnsNilWhenNoFiltering(t *testing.T) { ui := &common.UI{} // No include or ignore types set filter := ui.CreateFileTypeFilter() assert.Nil(t, filter, "CreateFileTypeFilter should return nil when no filtering is configured") } gdu-5.36.1/internal/common/signal.go000066400000000000000000000003751517447455500173230ustar00rootroot00000000000000// Package common contains commong logic and interfaces used across Gdu // nolint: revive //Why: this is common package package common type SignalGroup chan struct{} func (s SignalGroup) Wait() { <-s } func (s SignalGroup) Broadcast() { close(s) } gdu-5.36.1/internal/common/signal_test.go000066400000000000000000000021251517447455500203550ustar00rootroot00000000000000package common import ( "testing" "time" "github.com/stretchr/testify/assert" ) func TestSignalGroupWait(t *testing.T) { sg := make(SignalGroup) done := make(chan struct{}) go func() { sg.Wait() done <- struct{}{} }() // Give the goroutine time to block on Wait() time.Sleep(10 * time.Millisecond) // Broadcast should unblock the Wait sg.Broadcast() // Verify goroutine exited select { case <-done: // Success case <-time.After(100 * time.Millisecond): t.Fatal("Wait() did not unblock after Broadcast()") } } func TestSignalGroupBroadcast(t *testing.T) { sg := make(SignalGroup) // Broadcast should not panic on a fresh channel assert.NotPanics(t, func() { sg.Broadcast() }) } func TestSignalGroupWaitAfterBroadcast(t *testing.T) { sg := make(SignalGroup) sg.Broadcast() // Wait on already-closed channel should return immediately done := make(chan struct{}) go func() { sg.Wait() done <- struct{}{} }() select { case <-done: // Success case <-time.After(100 * time.Millisecond): t.Fatal("Wait() did not return after already-broadcast channel") } } gdu-5.36.1/internal/common/ui.go000066400000000000000000000036001517447455500164550ustar00rootroot00000000000000// Package common contains commong logic and interfaces used across Gdu // nolint: revive //Why: this is common package package common import ( "regexp" "strconv" ) // UI struct type UI struct { Analyzer Analyzer IgnoreDirPaths map[string]struct{} IgnoreDirPathPatterns *regexp.Regexp IgnoreHidden bool IgnoreTypes []string IncludeTypes []string UseColors bool UseSIPrefix bool ShowProgress bool ShowApparentSize bool ShowRelativeSize bool } // SetAnalyzer sets analyzer instance func (ui *UI) SetAnalyzer(a Analyzer) { ui.Analyzer = a } // SetFollowSymlinks sets whether symlinks to files should be followed func (ui *UI) SetFollowSymlinks(v bool) { ui.Analyzer.SetFollowSymlinks(v) } // SetShowAnnexedSize sets whether to use annexed size of git-annex files func (ui *UI) SetShowAnnexedSize(v bool) { ui.Analyzer.SetShowAnnexedSize(v) } // SetTimeFilter sets the time filter function for file inclusion func (ui *UI) SetTimeFilter(timeFilter TimeFilter) { ui.Analyzer.SetTimeFilter(timeFilter) } // SetArchiveBrowsing sets whether browsing of zip/jar archives is enabled func (ui *UI) SetArchiveBrowsing(v bool) { ui.Analyzer.SetArchiveBrowsing(v) } // binary multiplies prefixes (IEC) const ( _ float64 = 1 << (10 * iota) Ki Mi Gi Ti Pi Ei ) // SI prefixes const ( K float64 = 1e3 M float64 = 1e6 G float64 = 1e9 T float64 = 1e12 P float64 = 1e15 E float64 = 1e18 ) // FormatNumber returns number as a string with thousands separator func FormatNumber(n int64) string { in := []byte(strconv.FormatInt(n, 10)) var out []byte if i := len(in) % 3; i != 0 { if out, in = append(out, in[:i]...), in[i:]; len(in) > 0 { out = append(out, ',') } } for len(in) > 0 { if out, in = append(out, in[:3]...), in[3:]; len(in) > 0 { out = append(out, ',') } } return string(out) } gdu-5.36.1/internal/common/ui_test.go000066400000000000000000000046271517447455500175260ustar00rootroot00000000000000// Package common contains commong logic and interfaces used across Gdu // nolint: revive //Why: this is common package package common import ( "testing" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func TestFormatNumber(t *testing.T) { res := FormatNumber(1234567890) assert.Equal(t, "1,234,567,890", res) } func TestSetFollowSymlinks(t *testing.T) { ui := UI{ Analyzer: &MockedAnalyzer{}, } ui.SetFollowSymlinks(true) assert.Equal(t, true, ui.Analyzer.(*MockedAnalyzer).FollowSymlinks) } func TestSetShowAnnexedSize(t *testing.T) { ui := UI{ Analyzer: &MockedAnalyzer{}, } ui.SetShowAnnexedSize(true) assert.Equal(t, true, ui.Analyzer.(*MockedAnalyzer).ShowAnnexedSize) } func TestSetEnableArchiveBrowsing(t *testing.T) { ui := UI{ Analyzer: &MockedAnalyzer{}, } ui.SetArchiveBrowsing(true) assert.Equal(t, true, ui.Analyzer.(*MockedAnalyzer).ArchiveBrowsing) } func TestSetAnalyzer(t *testing.T) { ui := UI{} a := &MockedAnalyzer{} ui.SetAnalyzer(a) assert.Equal(t, a, ui.Analyzer) } func TestSetTimeFilter(t *testing.T) { ui := UI{Analyzer: &MockedAnalyzer{}} assert.NotPanics(t, func() { ui.SetTimeFilter(nil) }) } type MockedAnalyzer struct { FollowSymlinks bool ShowAnnexedSize bool ArchiveBrowsing bool } // SetFileTypeFilter sets the file type filter function func (a *MockedAnalyzer) SetFileTypeFilter(filter ShouldFileBeIgnored) { // Mock implementation - do nothing } // AnalyzeDir returns dir with files with different size exponents func (a *MockedAnalyzer) AnalyzeDir( path string, ignore ShouldDirBeIgnored, fileTypeFilter ShouldFileBeIgnored, ) fs.Item { return nil } // GetProgress returns empty progress func (a *MockedAnalyzer) GetProgress() CurrentProgress { return CurrentProgress{} } // GetDone returns always Done func (a *MockedAnalyzer) GetDone() SignalGroup { c := make(SignalGroup) defer c.Broadcast() return c } // ResetProgress does nothing func (a *MockedAnalyzer) ResetProgress() {} // SetFollowSymlinks does nothing func (a *MockedAnalyzer) SetFollowSymlinks(v bool) { a.FollowSymlinks = v } // SetShowAnnexedSize does nothing func (a *MockedAnalyzer) SetShowAnnexedSize(v bool) { a.ShowAnnexedSize = v } // SetTimeFilter does nothing func (a *MockedAnalyzer) SetTimeFilter(timeFilter TimeFilter) {} // SetArchiveBrowsing sets EnableArchiveBrowsing func (a *MockedAnalyzer) SetArchiveBrowsing(v bool) { a.ArchiveBrowsing = v } gdu-5.36.1/internal/testanalyze/000077500000000000000000000000001517447455500165655ustar00rootroot00000000000000gdu-5.36.1/internal/testanalyze/analyze.go000066400000000000000000000054171517447455500205660ustar00rootroot00000000000000package testanalyze import ( "errors" "time" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/fs" "github.com/dundee/gdu/v5/pkg/remove" ) // MockedAnalyzer returns dir with files with different size exponents type MockedAnalyzer struct{} // AnalyzeDir returns dir with files with different size exponents func (a *MockedAnalyzer) AnalyzeDir( path string, ignore common.ShouldDirBeIgnored, fileTypeFilter common.ShouldFileBeIgnored, ) fs.Item { dir := &analyze.Dir{ File: &analyze.File{ Name: "test_dir", Usage: 1e12 + 1, Size: 1e12 + 2, Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), }, BasePath: ".", ItemCount: 12, } dir2 := &analyze.Dir{ File: &analyze.File{ Name: "aaa", Usage: 1e12 + 1, Size: 1e12 + 2, Mtime: time.Date(2021, 8, 27, 22, 23, 27, 0, time.UTC), Parent: dir, }, } dir3 := &analyze.Dir{ File: &analyze.File{ Name: "bbb", Usage: 1e9 + 1, Size: 1e9 + 2, Mtime: time.Date(2021, 8, 27, 22, 23, 26, 0, time.UTC), Parent: dir, }, } dir4 := &analyze.Dir{ File: &analyze.File{ Name: "ccc", Usage: 1e6 + 1, Size: 1e6 + 2, Mtime: time.Date(2021, 8, 27, 22, 23, 25, 0, time.UTC), Parent: dir, }, } file := &analyze.File{ Name: "ddd", Usage: 1e3 + 1, Size: 1e3 + 2, Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), Parent: dir, } dir.Files = fs.Files{dir2, dir3, dir4, file} return dir } // GetProgress returns empty progress func (a *MockedAnalyzer) GetProgress() common.CurrentProgress { return common.CurrentProgress{} } // GetDone returns always Done func (a *MockedAnalyzer) GetDone() common.SignalGroup { c := make(common.SignalGroup) defer c.Broadcast() return c } // ResetProgress does nothing func (a *MockedAnalyzer) ResetProgress() {} // SetFollowSymlinks does nothing func (a *MockedAnalyzer) SetFollowSymlinks(v bool) {} // SetShowAnnexedSize does nothing func (a *MockedAnalyzer) SetShowAnnexedSize(v bool) {} // SetTimeFilter does nothing func (a *MockedAnalyzer) SetTimeFilter(timeFilter common.TimeFilter) {} // SetArchiveBrowsing does nothing func (a *MockedAnalyzer) SetArchiveBrowsing(v bool) {} // SetFileTypeFilter does nothing func (a *MockedAnalyzer) SetFileTypeFilter(fileTypeFilter common.ShouldFileBeIgnored) {} // ItemFromDirWithErr returns error func ItemFromDirWithErr(dir, file fs.Item) error { return errors.New("Failed") } // ItemFromDirWithSleep returns error func ItemFromDirWithSleep(dir, file fs.Item) error { time.Sleep(time.Millisecond * 600) return remove.ItemFromDir(dir, file) } // ItemFromDirWithSleepAndErr returns error func ItemFromDirWithSleepAndErr(dir, file fs.Item) error { time.Sleep(time.Millisecond * 600) return errors.New("Failed") } gdu-5.36.1/internal/testanalyze/analyze_test.go000066400000000000000000000110131517447455500216120ustar00rootroot00000000000000package testanalyze import ( "testing" "time" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) // Compile-time check that MockedAnalyzer implements common.Analyzer var _ common.Analyzer = (*MockedAnalyzer)(nil) func TestAnalyzeDir(t *testing.T) { a := &MockedAnalyzer{} result := a.AnalyzeDir(".", nil, nil) assert.NotNil(t, result) assert.True(t, result.IsDir()) assert.Equal(t, "test_dir", result.GetName()) assert.Equal(t, int64(1e12+1), result.GetUsage()) assert.Equal(t, int64(1e12+2), result.GetSize()) assert.Equal(t, int64(12), result.GetItemCount()) assert.Equal(t, time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), result.GetMtime(), ) dir := result.(*analyze.Dir) assert.Equal(t, ".", dir.BasePath) assert.Len(t, dir.Files, 4) // Verify children names and types names := make([]string, len(dir.Files)) for i, f := range dir.Files { names[i] = f.GetName() } assert.Equal(t, []string{"aaa", "bbb", "ccc", "ddd"}, names) // Verify "aaa" is a dir with TB-range size aaa := dir.Files[0] assert.True(t, aaa.IsDir()) assert.Equal(t, int64(1e12+1), aaa.GetUsage()) assert.Equal(t, int64(1e12+2), aaa.GetSize()) assert.Equal(t, result, aaa.GetParent()) assert.Equal(t, time.Date(2021, 8, 27, 22, 23, 27, 0, time.UTC), aaa.GetMtime(), ) // Verify "bbb" is a dir with GB-range size bbb := dir.Files[1] assert.True(t, bbb.IsDir()) assert.Equal(t, int64(1e9+1), bbb.GetUsage()) assert.Equal(t, int64(1e9+2), bbb.GetSize()) assert.Equal(t, result, bbb.GetParent()) // Verify "ccc" is a dir with MB-range size ccc := dir.Files[2] assert.True(t, ccc.IsDir()) assert.Equal(t, int64(1e6+1), ccc.GetUsage()) assert.Equal(t, int64(1e6+2), ccc.GetSize()) assert.Equal(t, result, ccc.GetParent()) // Verify "ddd" is a file with KB-range size ddd := dir.Files[3] assert.False(t, ddd.IsDir()) assert.Equal(t, int64(1e3+1), ddd.GetUsage()) assert.Equal(t, int64(1e3+2), ddd.GetSize()) assert.Equal(t, result, ddd.GetParent()) } func TestGetProgress(t *testing.T) { a := &MockedAnalyzer{} progress := a.GetProgress() assert.Equal(t, int64(0), progress.ItemCount) } func TestGetDone(t *testing.T) { a := &MockedAnalyzer{} done := a.GetDone() assert.NotNil(t, done) // The channel should be already closed (broadcast), so receiving should not block select { case <-done: // expected: channel was broadcast case <-time.After(time.Second): t.Fatal("GetDone channel was not broadcast") } } func TestResetProgress(t *testing.T) { a := &MockedAnalyzer{} assert.NotPanics(t, func() { a.ResetProgress() }) } func TestSetFollowSymlinks(t *testing.T) { a := &MockedAnalyzer{} assert.NotPanics(t, func() { a.SetFollowSymlinks(true) a.SetFollowSymlinks(false) }) } func TestSetShowAnnexedSize(t *testing.T) { a := &MockedAnalyzer{} assert.NotPanics(t, func() { a.SetShowAnnexedSize(true) a.SetShowAnnexedSize(false) }) } func TestSetTimeFilter(t *testing.T) { a := &MockedAnalyzer{} assert.NotPanics(t, func() { a.SetTimeFilter(func(mtime time.Time) bool { return true }) a.SetTimeFilter(nil) }) } func TestSetArchiveBrowsing(t *testing.T) { a := &MockedAnalyzer{} assert.NotPanics(t, func() { a.SetArchiveBrowsing(true) a.SetArchiveBrowsing(false) }) } func TestSetFileTypeFilter(t *testing.T) { a := &MockedAnalyzer{} assert.NotPanics(t, func() { a.SetFileTypeFilter(func(name string) bool { return false }) a.SetFileTypeFilter(nil) }) } func TestItemFromDirWithErr(t *testing.T) { err := ItemFromDirWithErr(nil, nil) assert.NotNil(t, err) assert.Equal(t, "Failed", err.Error()) } func TestItemFromDirWithSleep(t *testing.T) { // Create a minimal dir + file structure for remove.ItemFromDir dir := &analyze.Dir{ File: &analyze.File{ Name: "parent", Usage: 5000, Size: 5000, }, BasePath: t.TempDir(), } file := &analyze.File{ Name: "child", Usage: 1000, Size: 1000, Parent: dir, } dir.Files = fs.Files{file} start := time.Now() err := ItemFromDirWithSleep(dir, file) elapsed := time.Since(start) // Should take at least 500ms due to sleep assert.GreaterOrEqual(t, elapsed.Milliseconds(), int64(500)) // os.RemoveAll returns nil for non-existent paths assert.NoError(t, err) } func TestItemFromDirWithSleepAndErr(t *testing.T) { start := time.Now() err := ItemFromDirWithSleepAndErr(nil, nil) elapsed := time.Since(start) assert.NotNil(t, err) assert.Equal(t, "Failed", err.Error()) assert.GreaterOrEqual(t, elapsed.Milliseconds(), int64(500)) } gdu-5.36.1/internal/testapp/000077500000000000000000000000001517447455500157025ustar00rootroot00000000000000gdu-5.36.1/internal/testapp/app.go000066400000000000000000000050041517447455500170100ustar00rootroot00000000000000package testapp import ( "errors" "sync" "github.com/dundee/gdu/v5/internal/common" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) // CreateSimScreen returns tcell.SimulationScreen func CreateSimScreen() tcell.SimulationScreen { screen := tcell.NewSimulationScreen("UTF-8") return screen } // CreateTestAppWithSimScreen returns app with simulation screen for tests func CreateTestAppWithSimScreen(width, height int) (app *tview.Application, screen tcell.SimulationScreen) { app = tview.NewApplication() screen = CreateSimScreen() app.SetScreen(screen) screen.SetSize(width, height) return app, screen } // MockedApp is tview.Application with mocked methods type MockedApp struct { mutex *sync.Mutex updateDraws []func() BeforeDraws []func(screen tcell.Screen) bool FailRun bool } // CreateMockedApp returns app with simulation screen for tests func CreateMockedApp(failRun bool) common.TermApplication { app := &MockedApp{ FailRun: failRun, updateDraws: make([]func(), 0, 1), BeforeDraws: make([]func(screen tcell.Screen) bool, 0, 1), mutex: &sync.Mutex{}, } return app } // Run does nothing func (app *MockedApp) Run() error { if app.FailRun { return errors.New("Fail") } return nil } // Stop does nothing func (app *MockedApp) Stop() {} // Suspend runs given function func (app *MockedApp) Suspend(f func()) bool { f() return true } // SetRoot does nothing func (app *MockedApp) SetRoot(root tview.Primitive, fullscreen bool) *tview.Application { return nil } // SetFocus does nothing func (app *MockedApp) SetFocus(p tview.Primitive) *tview.Application { return nil } // SetInputCapture does nothing func (app *MockedApp) SetInputCapture(capture func(event *tcell.EventKey) *tcell.EventKey) *tview.Application { return nil } // SetMouseCapture does nothing func (app *MockedApp) SetMouseCapture( capture func(event *tcell.EventMouse, action tview.MouseAction) (*tcell.EventMouse, tview.MouseAction), ) *tview.Application { return nil } // QueueUpdateDraw does nothing func (app *MockedApp) QueueUpdateDraw(f func()) *tview.Application { app.mutex.Lock() app.updateDraws = append(app.updateDraws, f) app.mutex.Unlock() return nil } // QueueUpdateDraw does nothing func (app *MockedApp) GetUpdateDraws() []func() { app.mutex.Lock() defer app.mutex.Unlock() return app.updateDraws } // SetBeforeDrawFunc does nothing func (app *MockedApp) SetBeforeDrawFunc(f func(screen tcell.Screen) bool) *tview.Application { app.BeforeDraws = append(app.BeforeDraws, f) return nil } gdu-5.36.1/internal/testapp/app_test.go000066400000000000000000000061131517447455500200510ustar00rootroot00000000000000package testapp import ( "sync" "testing" "github.com/dundee/gdu/v5/internal/common" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/stretchr/testify/assert" ) // Compile-time check that MockedApp implements common.TermApplication var _ common.TermApplication = (*MockedApp)(nil) func TestCreateSimScreen(t *testing.T) { screen := CreateSimScreen() assert.NotNil(t, screen) } func TestCreateTestAppWithSimScreen(t *testing.T) { app, screen := CreateTestAppWithSimScreen(120, 40) assert.NotNil(t, app) assert.NotNil(t, screen) w, h := screen.Size() assert.Equal(t, 120, w) assert.Equal(t, 40, h) } func TestCreateMockedApp(t *testing.T) { app := CreateMockedApp(false) assert.NotNil(t, app) } func TestMockedAppRunSuccess(t *testing.T) { app := CreateMockedApp(false) err := app.Run() assert.NoError(t, err) } func TestMockedAppRunFail(t *testing.T) { app := CreateMockedApp(true) err := app.Run() assert.Error(t, err) assert.Equal(t, "Fail", err.Error()) } func TestMockedAppStop(t *testing.T) { app := CreateMockedApp(false) assert.NotPanics(t, func() { app.Stop() }) } func TestMockedAppSuspend(t *testing.T) { app := CreateMockedApp(false) called := false result := app.Suspend(func() { called = true }) assert.True(t, called) assert.True(t, result) } func TestMockedAppSetRoot(t *testing.T) { app := CreateMockedApp(false) result := app.SetRoot(nil, true) assert.Nil(t, result) } func TestMockedAppSetFocus(t *testing.T) { app := CreateMockedApp(false) result := app.SetFocus(nil) assert.Nil(t, result) } func TestMockedAppSetInputCapture(t *testing.T) { app := CreateMockedApp(false) result := app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { return event }) assert.Nil(t, result) } func TestMockedAppSetMouseCapture(t *testing.T) { app := CreateMockedApp(false) result := app.SetMouseCapture( func(event *tcell.EventMouse, action tview.MouseAction) (*tcell.EventMouse, tview.MouseAction) { return event, action }, ) assert.Nil(t, result) } func TestMockedAppQueueUpdateDraw(t *testing.T) { app := CreateMockedApp(false).(*MockedApp) counter := 0 f1 := func() { counter++ } f2 := func() { counter += 10 } app.QueueUpdateDraw(f1) app.QueueUpdateDraw(f2) draws := app.GetUpdateDraws() assert.Len(t, draws, 2) // Execute the queued functions and verify they work for _, f := range draws { f() } assert.Equal(t, 11, counter) } func TestMockedAppSetBeforeDrawFunc(t *testing.T) { app := CreateMockedApp(false).(*MockedApp) assert.Empty(t, app.BeforeDraws) app.SetBeforeDrawFunc(func(screen tcell.Screen) bool { return true }) assert.Len(t, app.BeforeDraws, 1) app.SetBeforeDrawFunc(func(screen tcell.Screen) bool { return false }) assert.Len(t, app.BeforeDraws, 2) } func TestMockedAppConcurrentQueueUpdateDraw(t *testing.T) { app := CreateMockedApp(false).(*MockedApp) var wg sync.WaitGroup n := 100 wg.Add(n) for i := 0; i < n; i++ { go func() { defer wg.Done() app.QueueUpdateDraw(func() {}) }() } wg.Wait() draws := app.GetUpdateDraws() assert.Len(t, draws, n) } gdu-5.36.1/internal/testdata/000077500000000000000000000000001517447455500160335ustar00rootroot00000000000000gdu-5.36.1/internal/testdata/test.json000066400000000000000000000004671517447455500177140ustar00rootroot00000000000000[1,2,{"progname":"gdu","progver":"development","timestamp":1626807263}, [{"name":"/home/gdu"}, [{"name":"app"}, {"name":"app.go","asize":4638,"dsize":8192}, {"name":"app_linux_test.go","asize":1410,"dsize":4096}, {"name":"app_test.go","asize":4974,"dsize":8192}], {"name":"main.go","asize":3205,"dsize":4096}]] gdu-5.36.1/internal/testdata/wrong.json000066400000000000000000000000121517447455500200530ustar00rootroot00000000000000[1,2,3,4] gdu-5.36.1/internal/testdev/000077500000000000000000000000001517447455500157005ustar00rootroot00000000000000gdu-5.36.1/internal/testdev/dev.go000066400000000000000000000007661517447455500170160ustar00rootroot00000000000000package testdev import "github.com/dundee/gdu/v5/pkg/device" // DevicesInfoGetterMock is mock of DevicesInfoGetter type DevicesInfoGetterMock struct { Devices device.Devices } // GetDevicesInfo returns mocked devices func (t DevicesInfoGetterMock) GetDevicesInfo() (devices device.Devices, err error) { return t.Devices, nil } // GetMounts returns all mounted filesystems from /proc/mounts func (t DevicesInfoGetterMock) GetMounts() (devices device.Devices, err error) { return t.Devices, nil } gdu-5.36.1/internal/testdev/dev_test.go000066400000000000000000000026711517447455500200520ustar00rootroot00000000000000package testdev import ( "testing" "github.com/dundee/gdu/v5/pkg/device" "github.com/stretchr/testify/assert" ) // Compile-time check that DevicesInfoGetterMock implements device.DevicesInfoGetter var _ device.DevicesInfoGetter = DevicesInfoGetterMock{} func TestGetDevicesInfo(t *testing.T) { devices := device.Devices{ { Name: "/dev/sda1", MountPoint: "/", Size: 1e12, Free: 5e11, }, { Name: "/dev/sda2", MountPoint: "/home", Size: 2e12, Free: 1e12, }, } mock := DevicesInfoGetterMock{Devices: devices} result, err := mock.GetDevicesInfo() assert.NoError(t, err) assert.Equal(t, devices, result) assert.Len(t, result, 2) assert.Equal(t, "/dev/sda1", result[0].Name) assert.Equal(t, "/home", result[1].MountPoint) } func TestGetMounts(t *testing.T) { devices := device.Devices{ { Name: "/dev/sda1", MountPoint: "/", Size: 1e12, Free: 5e11, }, } mock := DevicesInfoGetterMock{Devices: devices} result, err := mock.GetMounts() assert.NoError(t, err) assert.Equal(t, devices, result) assert.Len(t, result, 1) } func TestGetDevicesInfoEmpty(t *testing.T) { mock := DevicesInfoGetterMock{} result, err := mock.GetDevicesInfo() assert.NoError(t, err) assert.Nil(t, result) } func TestGetMountsEmpty(t *testing.T) { mock := DevicesInfoGetterMock{} result, err := mock.GetMounts() assert.NoError(t, err) assert.Nil(t, result) } gdu-5.36.1/internal/testdir/000077500000000000000000000000001517447455500157005ustar00rootroot00000000000000gdu-5.36.1/internal/testdir/test_dir.go000066400000000000000000000012301517447455500200400ustar00rootroot00000000000000package testdir import ( "io/fs" "os" ) // CreateTestDir creates test dir structure func CreateTestDir() func() { if err := os.MkdirAll("test_dir/nested/subnested", os.ModePerm); err != nil { panic(err) } if err := os.WriteFile("test_dir/nested/subnested/file", []byte("hello"), 0o600); err != nil { panic(err) } if err := os.WriteFile("test_dir/nested/file2", []byte("go"), 0o600); err != nil { panic(err) } return func() { err := os.RemoveAll("test_dir") if err != nil { panic(err) } } } // MockedPathChecker is mocked os.Stat, returns (nil, nil) func MockedPathChecker(path string) (info fs.FileInfo, err error) { return nil, nil } gdu-5.36.1/internal/testdir/test_dir_test.go000066400000000000000000000025071517447455500211070ustar00rootroot00000000000000package testdir import ( "os" "testing" "github.com/stretchr/testify/assert" ) func TestCreateTestDir(t *testing.T) { cleanup := CreateTestDir() defer cleanup() // Verify directory structure exists info, err := os.Stat("test_dir") assert.NoError(t, err) assert.True(t, info.IsDir()) info, err = os.Stat("test_dir/nested") assert.NoError(t, err) assert.True(t, info.IsDir()) info, err = os.Stat("test_dir/nested/subnested") assert.NoError(t, err) assert.True(t, info.IsDir()) // Verify file contents content, err := os.ReadFile("test_dir/nested/subnested/file") assert.NoError(t, err) assert.Equal(t, "hello", string(content)) content, err = os.ReadFile("test_dir/nested/file2") assert.NoError(t, err) assert.Equal(t, "go", string(content)) } func TestCreateTestDirCleanup(t *testing.T) { cleanup := CreateTestDir() // Verify it exists _, err := os.Stat("test_dir") assert.NoError(t, err) // Run cleanup cleanup() // Verify it's gone _, err = os.Stat("test_dir") assert.True(t, os.IsNotExist(err)) } func TestMockedPathChecker(t *testing.T) { info, err := MockedPathChecker("/any/path") assert.Nil(t, info) assert.Nil(t, err) info, err = MockedPathChecker("") assert.Nil(t, info) assert.Nil(t, err) info, err = MockedPathChecker("/another/different/path") assert.Nil(t, info) assert.Nil(t, err) } gdu-5.36.1/pkg/000077500000000000000000000000001517447455500131675ustar00rootroot00000000000000gdu-5.36.1/pkg/analyze/000077500000000000000000000000001517447455500146325ustar00rootroot00000000000000gdu-5.36.1/pkg/analyze/analyzer.go000066400000000000000000000052521517447455500170120ustar00rootroot00000000000000package analyze import ( "sync/atomic" "time" "github.com/dundee/gdu/v5/internal/common" ) // BaseAnalyzer provides common logic for all analyzers type BaseAnalyzer struct { progressOutChan chan common.CurrentProgress progressDoneChan chan struct{} progressItemCount atomic.Int64 progressTotalUsage atomic.Int64 progressCurrentItemName atomic.Value doneChan common.SignalGroup wait *WaitGroup ignoreDir common.ShouldDirBeIgnored ignoreFileType common.ShouldFileBeIgnored followSymlinks bool gitAnnexedSize bool matchesTimeFilterFn common.TimeFilter archiveBrowsing bool progressTicker *time.Ticker } // Init initializes the BaseAnalyzer func (a *BaseAnalyzer) Init() { a.progressOutChan = make(chan common.CurrentProgress, 1) a.progressDoneChan = make(chan struct{}) a.doneChan = make(common.SignalGroup) a.wait = (&WaitGroup{}).Init() a.progressItemCount.Store(0) a.progressTotalUsage.Store(0) a.progressCurrentItemName.Store("") a.progressTicker = time.NewTicker(50 * time.Millisecond) } // SetFollowSymlinks sets whether symlink to files should be followed func (a *BaseAnalyzer) SetFollowSymlinks(v bool) { a.followSymlinks = v } // SetShowAnnexedSize sets whether to use annexed size of git-annex files func (a *BaseAnalyzer) SetShowAnnexedSize(v bool) { a.gitAnnexedSize = v } // SetTimeFilter sets the time filter function for file inclusion func (a *BaseAnalyzer) SetTimeFilter(matchesTimeFilterFn common.TimeFilter) { a.matchesTimeFilterFn = matchesTimeFilterFn } // SetArchiveBrowsing sets whether browsing of zip/jar/tar archives is enabled func (a *BaseAnalyzer) SetArchiveBrowsing(v bool) { a.archiveBrowsing = v } // SetFileTypeFilter sets the file type filter function func (a *BaseAnalyzer) SetFileTypeFilter(filter common.ShouldFileBeIgnored) { a.ignoreFileType = filter } // GetDone returns channel for checking when analysis is done func (a *BaseAnalyzer) GetDone() common.SignalGroup { return a.doneChan } // ResetProgress resets the analyzer state func (a *BaseAnalyzer) ResetProgress() { a.Init() } func (a *BaseAnalyzer) GetProgress() common.CurrentProgress { return common.CurrentProgress{ CurrentItemName: a.progressCurrentItemName.Load().(string), ItemCount: a.progressItemCount.Load(), TotalUsage: a.progressTotalUsage.Load(), } } // UpdateProgress updates progress func (a *BaseAnalyzer) UpdateProgress() { ticker := a.progressTicker defer ticker.Stop() for { select { case <-a.progressDoneChan: return case <-ticker.C: select { case a.progressOutChan <- a.GetProgress(): default: } } } } gdu-5.36.1/pkg/analyze/dir_linux-openbsd.go000066400000000000000000000022121517447455500206030ustar00rootroot00000000000000//go:build linux || openbsd package analyze import ( "os" "syscall" "time" ) const devBSize = 512 func getPlatformSpecificUsageAndMli(info os.FileInfo) (usage int64, ino uint64) { if stat, ok := info.Sys().(*syscall.Stat_t); ok { if stat.Nlink > 1 { ino = stat.Ino } return stat.Blocks * devBSize, ino } return 0, 0 } func setPlatformSpecificAttrs(file *File, f os.FileInfo) { if stat, ok := f.Sys().(*syscall.Stat_t); ok { file.Usage = stat.Blocks * devBSize file.Mtime = time.Unix(int64(stat.Mtim.Sec), int64(stat.Mtim.Nsec)) if stat.Nlink > 1 { file.Mli = stat.Ino } } } func setDirPlatformSpecificAttrs(dir *Dir, path string) { var stat syscall.Stat_t if err := syscall.Stat(path, &stat); err != nil { return } dir.Mtime = time.Unix(int64(stat.Mtim.Sec), int64(stat.Mtim.Nsec)) } // getSyscallStats extracts usage and inode info from os.FileInfo using syscall func getSyscallStats(info os.FileInfo) (usage int64, mli uint64) { if stat, ok := info.Sys().(*syscall.Stat_t); ok { usage = stat.Blocks * 512 // 512-byte blocks if stat.Nlink > 1 { mli = stat.Ino } } else { usage = info.Size() } return } gdu-5.36.1/pkg/analyze/dir_linux_test.go000066400000000000000000000030061517447455500202140ustar00rootroot00000000000000//go:build linux package analyze import ( "os" "testing" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func TestErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Chmod("test_dir/nested", 0) assert.Nil(t, err) defer func() { err = os.Chmod("test_dir/nested", 0o755) assert.Nil(t, err) }() analyzer := CreateAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*Dir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) assert.Equal(t, "test_dir", dir.GetName()) assert.Equal(t, int64(2), dir.ItemCount) assert.Equal(t, '.', dir.GetFlag()) assert.Equal(t, "nested", dir.Files[0].GetName()) assert.Equal(t, '!', dir.Files[0].GetFlag()) } func TestSeqErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Chmod("test_dir/nested", 0) assert.Nil(t, err) defer func() { err = os.Chmod("test_dir/nested", 0o755) assert.Nil(t, err) }() analyzer := CreateSeqAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*Dir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) assert.Equal(t, "test_dir", dir.GetName()) assert.Equal(t, int64(2), dir.ItemCount) assert.Equal(t, '.', dir.GetFlag()) assert.Equal(t, "nested", dir.Files[0].GetName()) assert.Equal(t, '!', dir.Files[0].GetFlag()) } gdu-5.36.1/pkg/analyze/dir_other.go000066400000000000000000000014461517447455500171450ustar00rootroot00000000000000//go:build windows || plan9 package analyze import ( "os" "syscall" "time" ) func getPlatformSpecificUsageAndMli(info os.FileInfo) (usage int64, ino uint64) { return info.Size(), 0 // No block info on Windows, use apparent size } func setPlatformSpecificAttrs(file *File, f os.FileInfo) { stat := f.Sys().(*syscall.Win32FileAttributeData) file.Mtime = time.Unix(0, stat.LastWriteTime.Nanoseconds()) file.Usage = f.Size() // No block info on Windows, use apparent size } func setDirPlatformSpecificAttrs(dir *Dir, path string) { stat, err := os.Stat(path) if err != nil { return } dir.Mtime = stat.ModTime() } // getSyscallStats extracts usage and inode info from os.FileInfo using syscall func getSyscallStats(info os.FileInfo) (usage int64, mli uint64) { usage = info.Size() return } gdu-5.36.1/pkg/analyze/dir_test.go000066400000000000000000000237061517447455500170060ustar00rootroot00000000000000package analyze import ( "os" "sort" "testing" log "github.com/sirupsen/logrus" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func init() { log.SetLevel(log.WarnLevel) } func TestAnalyzeDir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() analyzer := CreateAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*Dir) progress := analyzer.GetProgress() assert.GreaterOrEqual(t, progress.TotalUsage, int64(0)) analyzer.GetDone().Wait() analyzer.ResetProgress() dir.UpdateStats(make(fs.HardLinkedItems)) // test dir info assert.Equal(t, "test_dir", dir.Name) assert.Equal(t, int64(7+4096*3), dir.Size) assert.Equal(t, int64(5), dir.ItemCount) assert.True(t, dir.IsDir()) // test dir tree assert.Equal(t, "nested", dir.Files[0].GetName()) assert.Equal(t, "subnested", dir.Files[0].(*Dir).Files[1].GetName()) // test file assert.Equal(t, "file2", dir.Files[0].(*Dir).Files[0].GetName()) assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[0].GetSize()) assert.Equal( t, "file", dir.Files[0].(*Dir).Files[1].(*Dir).Files[0].GetName(), ) assert.Equal( t, int64(5), dir.Files[0].(*Dir).Files[1].(*Dir).Files[0].GetSize(), ) // test parent link assert.Equal( t, "test_dir", dir.Files[0].(*Dir). Files[1].(*Dir). Files[0]. GetParent(). GetParent(). GetParent(). GetName(), ) } func TestIgnoreDir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() dir := CreateAnalyzer().AnalyzeDir( "test_dir", func(_, _ string) bool { return true }, func(_ string) bool { return false }, ).(*Dir) assert.Equal(t, "test_dir", dir.Name) assert.Equal(t, int64(1), dir.ItemCount) } func TestFlags(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Mkdir("test_dir/empty", 0o644) assert.Nil(t, err) err = os.Symlink("test_dir/nested/file2", "test_dir/nested/file3") assert.Nil(t, err) analyzer := CreateAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*Dir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) sort.Sort(sort.Reverse(dir.Files)) assert.Equal(t, int64(28+4096*4), dir.Size) assert.Equal(t, int64(7), dir.ItemCount) // test file3 assert.Equal(t, "nested", dir.Files[0].GetName()) assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) assert.Equal(t, int64(21), dir.Files[0].(*Dir).Files[1].GetSize()) assert.Equal(t, '@', dir.Files[0].(*Dir).Files[1].GetFlag()) assert.Equal(t, 'e', dir.Files[1].GetFlag()) } func TestHardlink(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Link("test_dir/nested/file2", "test_dir/nested/file3") assert.Nil(t, err) analyzer := CreateAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*Dir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) assert.Equal(t, int64(7+4096*3), dir.Size) // file2 and file3 are counted just once for size assert.Equal(t, int64(6), dir.ItemCount) // but twice for item count // test file3 assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[1].GetSize()) assert.Equal(t, 'H', dir.Files[0].(*Dir).Files[1].GetFlag()) } func TestFollowSymlink(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Mkdir("test_dir/empty", 0o644) assert.Nil(t, err) err = os.Symlink("./file2", "test_dir/nested/file3") assert.Nil(t, err) analyzer := CreateAnalyzer() analyzer.SetFollowSymlinks(true) dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*Dir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) sort.Sort(sort.Reverse(dir.Files)) assert.Equal(t, int64(9+4096*4), dir.Size) assert.Equal(t, int64(7), dir.ItemCount) // test file3 assert.Equal(t, "nested", dir.Files[0].GetName()) assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[1].GetSize()) assert.Equal(t, ' ', dir.Files[0].(*Dir).Files[1].GetFlag()) assert.Equal(t, 'e', dir.Files[1].GetFlag()) } func TestGitAnnexSymlink(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Mkdir("test_dir/empty", 0o644) assert.Nil(t, err) err = os.Symlink( ".git/annex/objects/qx/qX/SHA256E-s967858083--"+ "3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4/SHA256E-s967858083--"+ "3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4", "test_dir/nested/file3") assert.Nil(t, err) analyzer := CreateAnalyzer() analyzer.SetFollowSymlinks(true) analyzer.SetShowAnnexedSize(true) dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*Dir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) sort.Sort(sort.Reverse(dir.Files)) assert.Equal(t, int64(967858083+7+4096*4), dir.Size) assert.Equal(t, int64(7), dir.ItemCount) // test file3 assert.Equal(t, "nested", dir.Files[0].GetName()) assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) assert.Equal(t, int64(967858083), dir.Files[0].(*Dir).Files[1].GetSize()) assert.Equal(t, '@', dir.Files[0].(*Dir).Files[1].GetFlag()) assert.Equal(t, 'e', dir.Files[1].GetFlag()) } func TestBrokenSymlinkSkipped(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Mkdir("test_dir/empty", 0o644) assert.Nil(t, err) err = os.Symlink("xxx", "test_dir/nested/file3") assert.Nil(t, err) analyzer := CreateAnalyzer() analyzer.SetFollowSymlinks(true) dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*Dir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) sort.Sort(sort.Reverse(dir.Files)) assert.Equal(t, int64(7+4096*4), dir.Size) assert.Equal(t, int64(6), dir.ItemCount) assert.Equal(t, '!', dir.Files[0].GetFlag()) } func BenchmarkAnalyzeDir(b *testing.B) { fin := testdir.CreateTestDir() defer fin() b.ResetTimer() analyzer := CreateAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) } func TestParallelStableOrderAnalyzerDeterminism(t *testing.T) { fin := testdir.CreateTestDir() defer fin() // Run parallel analyzer multiple times and verify results are identical var results [][]string for i := 0; i < 5; i++ { analyzer := CreateStableOrderAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) names := getFileNames(dir) results = append(results, names) } // All runs should produce identical results for i := 1; i < len(results); i++ { assert.Equal(t, results[0], results[i], "Parallel analyzer run %d produced different results than run 0", i) } } func TestParallelVsSequentialConsistency(t *testing.T) { fin := testdir.CreateTestDir() defer fin() // Run sequential analyzer seqAnalyzer := CreateSeqAnalyzer() seqDir := seqAnalyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) seqAnalyzer.GetDone().Wait() seqDir.UpdateStats(make(fs.HardLinkedItems)) seqNames := getFileNames(seqDir) // Run parallel analyzer parAnalyzer := CreateStableOrderAnalyzer() parDir := parAnalyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) parAnalyzer.GetDone().Wait() parDir.UpdateStats(make(fs.HardLinkedItems)) parNames := getFileNames(parDir) // Results should match assert.Equal(t, seqNames, parNames, "Parallel and sequential analyzers produced different results") } func TestFileDirectoryInterleaving(t *testing.T) { // Create test directory with interleaved files and directories err := os.MkdirAll("test_interleave/aaa_dir", 0755) assert.NoError(t, err) err = os.WriteFile("test_interleave/bbb_file", []byte("content"), 0644) assert.NoError(t, err) err = os.MkdirAll("test_interleave/ccc_dir", 0755) assert.NoError(t, err) err = os.WriteFile("test_interleave/ddd_file", []byte("content"), 0644) assert.NoError(t, err) defer os.RemoveAll("test_interleave") // Run sequential analyzer seqAnalyzer := CreateSeqAnalyzer() seqDir := seqAnalyzer.AnalyzeDir( "test_interleave", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*Dir) seqAnalyzer.GetDone().Wait() // Run parallel analyzer parAnalyzer := CreateStableOrderAnalyzer() parDir := parAnalyzer.AnalyzeDir( "test_interleave", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*Dir) parAnalyzer.GetDone().Wait() // Extract file/dir names in order seqOrder := make([]string, len(seqDir.Files)) for i, item := range seqDir.Files { seqOrder[i] = item.GetName() } parOrder := make([]string, len(parDir.Files)) for i, item := range parDir.Files { parOrder[i] = item.GetName() } // The order must be identical: [aaa_dir, bbb_file, ccc_dir, ddd_file] assert.Equal(t, seqOrder, parOrder, "Parallel analyzer did not preserve file/directory interleaving") // Verify the expected order (alphabetical from os.ReadDir) assert.Equal(t, "aaa_dir", seqOrder[0]) assert.Equal(t, "bbb_file", seqOrder[1]) assert.Equal(t, "ccc_dir", seqOrder[2]) assert.Equal(t, "ddd_file", seqOrder[3]) } // getFileNames recursively collects file names from a directory tree func getFileNames(item fs.Item) []string { names := []string{item.GetName()} if item.IsDir() { for child := range item.GetFiles(fs.SortByName, fs.SortAsc) { names = append(names, getFileNames(child)...) } } return names } gdu-5.36.1/pkg/analyze/dir_unix.go000066400000000000000000000022501517447455500170010ustar00rootroot00000000000000//go:build darwin || netbsd || freebsd package analyze import ( "os" "syscall" "time" ) const devBSize = 512 func getPlatformSpecificUsageAndMli(info os.FileInfo) (usage int64, ino uint64) { if stat, ok := info.Sys().(*syscall.Stat_t); ok { if stat.Nlink > 1 { ino = stat.Ino } return stat.Blocks * devBSize, ino } return 0, 0 } func setPlatformSpecificAttrs(file *File, f os.FileInfo) { if stat, ok := f.Sys().(*syscall.Stat_t); ok { file.Usage = stat.Blocks * devBSize file.Mtime = time.Unix(int64(stat.Mtimespec.Sec), int64(stat.Mtimespec.Nsec)) if stat.Nlink > 1 { file.Mli = stat.Ino } } } func setDirPlatformSpecificAttrs(dir *Dir, path string) { var stat syscall.Stat_t if err := syscall.Stat(path, &stat); err != nil { return } dir.Mtime = time.Unix(int64(stat.Mtimespec.Sec), int64(stat.Mtimespec.Nsec)) } // getSyscallStats extracts usage and inode info from os.FileInfo using syscall func getSyscallStats(info os.FileInfo) (usage int64, mli uint64) { if stat, ok := info.Sys().(*syscall.Stat_t); ok { usage = stat.Blocks * 512 // 512-byte blocks if stat.Nlink > 1 { mli = stat.Ino } } else { usage = info.Size() } return } gdu-5.36.1/pkg/analyze/encode.go000066400000000000000000000042241517447455500164200ustar00rootroot00000000000000package analyze import ( "encoding/json" "io" "strconv" ) // EncodeJSON writes JSON representation of dir func (f *Dir) EncodeJSON(writer io.Writer, topLevel bool) error { buff := make([]byte, 0, 20) buff = append(buff, []byte(`[{"name":`)...) if topLevel { if err := addString(&buff, f.GetPath()); err != nil { return err } } else { if err := addString(&buff, f.GetName()); err != nil { return err } } if !f.GetMtime().IsZero() { buff = append(buff, []byte(`,"mtime":`)...) buff = append(buff, []byte(strconv.FormatInt(f.GetMtime().Unix(), 10))...) } buff = append(buff, '}') if f.Files.Len() > 0 { buff = append(buff, ',') } buff = append(buff, '\n') if _, err := writer.Write(buff); err != nil { return err } for i, item := range f.Files { if i > 0 { if _, err := writer.Write([]byte(",\n")); err != nil { return err } } err := item.EncodeJSON(writer, false) if err != nil { return err } } if _, err := writer.Write([]byte("]")); err != nil { return err } return nil } // EncodeJSON writes JSON representation of file func (f *File) EncodeJSON(writer io.Writer, topLevel bool) error { buff := make([]byte, 0, 20) buff = append(buff, []byte(`{"name":`)...) if err := addString(&buff, f.GetName()); err != nil { return err } if f.GetSize() > 0 { buff = append(buff, []byte(`,"asize":`)...) buff = append(buff, []byte(strconv.FormatInt(f.GetSize(), 10))...) } if f.GetUsage() > 0 { buff = append(buff, []byte(`,"dsize":`)...) buff = append(buff, []byte(strconv.FormatInt(f.GetUsage(), 10))...) } if !f.GetMtime().IsZero() { buff = append(buff, []byte(`,"mtime":`)...) buff = append(buff, []byte(strconv.FormatInt(f.GetMtime().Unix(), 10))...) } if f.Flag == '@' { buff = append(buff, []byte(`,"notreg":true`)...) } if f.Flag == 'H' { buff = append(buff, []byte(`,"ino":`+strconv.FormatUint(f.Mli, 10)+`,"hlnkc":true`)...) } buff = append(buff, '}') if _, err := writer.Write(buff); err != nil { return err } return nil } func addString(buff *[]byte, val string) error { b, err := json.Marshal(val) if err != nil { return err } *buff = append(*buff, b...) return err } gdu-5.36.1/pkg/analyze/encode_test.go000066400000000000000000000022751517447455500174630ustar00rootroot00000000000000package analyze import ( "bytes" "testing" "time" "github.com/dundee/gdu/v5/pkg/fs" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) func init() { log.SetLevel(log.WarnLevel) } func TestEncode(t *testing.T) { dir := &Dir{ File: &File{ Name: "test_dir", Size: 10, Usage: 18, Mtime: time.Date(2021, 8, 19, 0, 40, 0, 0, time.UTC), }, ItemCount: 4, BasePath: ".", } subdir := &Dir{ File: &File{ Name: "nested", Size: 9, Usage: 14, Parent: dir, }, ItemCount: 3, } file := &File{ Name: "file2", Size: 3, Usage: 4, Parent: subdir, } file2 := &File{ Name: "file", Size: 5, Usage: 6, Parent: subdir, Flag: '@', Mtime: time.Date(2021, 8, 19, 0, 40, 0, 0, time.UTC), } file3 := &File{ Name: "file3", Mli: 1234, Flag: 'H', } dir.Files = fs.Files{subdir} subdir.Files = fs.Files{file, file2, file3} var buff bytes.Buffer err := dir.EncodeJSON(&buff, true) assert.Nil(t, err) assert.Contains(t, buff.String(), `"name":"nested"`) assert.Contains(t, buff.String(), `"mtime":1629333600`) assert.Contains(t, buff.String(), `"ino":1234`) assert.Contains(t, buff.String(), `"hlnkc":true`) } gdu-5.36.1/pkg/analyze/file.go000066400000000000000000000153621517447455500161070ustar00rootroot00000000000000package analyze import ( "iter" "path/filepath" "sort" "sync" "time" "github.com/dundee/gdu/v5/pkg/fs" ) // File struct type File struct { Mtime time.Time Parent fs.Item Name string Size int64 Usage int64 Mli uint64 Flag rune } // GetName returns name of dir func (f *File) GetName() string { return f.Name } // IsDir returns false for file func (f *File) IsDir() bool { return false } // GetParent returns parent dir func (f *File) GetParent() fs.Item { return f.Parent } // SetParent sets parent dir func (f *File) SetParent(parent fs.Item) { f.Parent = parent } // GetPath returns absolute Get of the file func (f *File) GetPath() string { return filepath.Join(f.Parent.GetPath(), f.Name) } // GetFlag returns flag of the file func (f *File) GetFlag() rune { return f.Flag } // GetSize returns size of the file func (f *File) GetSize() int64 { return f.Size } // GetUsage returns usage of the file func (f *File) GetUsage() int64 { return f.Usage } // GetMtime returns mtime of the file func (f *File) GetMtime() time.Time { return f.Mtime } // GetType returns name type of item func (f *File) GetType() string { if f.Flag == '@' { return "Other" } return "File" } // GetItemCount returns 1 for file func (f *File) GetItemCount() int64 { return 1 } // GetMultiLinkedInode returns inode number of multilinked file func (f *File) GetMultiLinkedInode() uint64 { return f.Mli } func (f *File) alreadyCounted(linkedItems fs.HardLinkedItems) bool { mli := f.Mli counted := false if mli > 0 { f.Flag = 'H' if _, ok := linkedItems[mli]; ok { counted = true } linkedItems[mli] = append(linkedItems[mli], f) } return counted } // GetItemStats returns 1 as count of items, apparent usage and real usage of this file func (f *File) GetItemStats(linkedItems fs.HardLinkedItems) (itemCount, size, usage int64) { if f.alreadyCounted(linkedItems) { return 1, 0, 0 } return 1, f.GetSize(), f.GetUsage() } // UpdateStats does nothing on file func (f *File) UpdateStats(linkedItems fs.HardLinkedItems) {} // GetFiles returns all files in directory func (f *File) GetFiles(sortBy fs.SortBy, order fs.SortOrder) iter.Seq[fs.Item] { return func(yield func(fs.Item) bool) {} } // GetFilesLocked returns all files in directory func (f *File) GetFilesLocked(sortBy fs.SortBy, order fs.SortOrder) iter.Seq[fs.Item] { return f.GetFiles(sortBy, order) } // RLock panics on file func (f *File) RLock() func() { panic("RLock should not be called on file") } // AddFile panics on file func (f *File) AddFile(item fs.Item) { panic("AddFile should not be called on file") } // RemoveFile panics on file func (f *File) RemoveFile(item fs.Item) { panic("RemoveFile should not be called on file") } // RemoveFileByName panics on file func (f *File) RemoveFileByName(name string) { panic("RemoveFileByName should not be called on file") } // Dir struct type Dir struct { *File BasePath string Files fs.Files ItemCount int64 m sync.RWMutex } // AddFile add item to files func (f *Dir) AddFile(item fs.Item) { f.Files = append(f.Files, item) } // GetFiles returns all files in directory as a sorted iterator func (f *Dir) GetFiles(sortBy fs.SortBy, order fs.SortOrder) iter.Seq[fs.Item] { return func(yield func(fs.Item) bool) { // Make a copy to avoid modifying the original slice sorted := make(fs.Files, len(f.Files)) copy(sorted, f.Files) sortFiles(sorted, sortBy, order) for _, item := range sorted { if !yield(item) { return } } } } // GetFilesLocked returns all files in directory as a sorted iterator // It is safe to call this function from multiple goroutines func (f *Dir) GetFilesLocked(sortBy fs.SortBy, order fs.SortOrder) iter.Seq[fs.Item] { return func(yield func(fs.Item) bool) { f.m.RLock() defer f.m.RUnlock() // Make a copy to avoid modifying the original slice sorted := make(fs.Files, len(f.Files)) copy(sorted, f.Files) sortFiles(sorted, sortBy, order) for _, item := range sorted { if !yield(item) { return } } } } // GetType returns name type of item func (f *Dir) GetType() string { return "Directory" } // GetItemCount returns number of files in dir func (f *Dir) GetItemCount() int64 { f.m.RLock() defer f.m.RUnlock() return f.ItemCount } // IsDir returns true for dir func (f *Dir) IsDir() bool { return true } // GetPath returns absolute path of the file func (f *Dir) GetPath() string { if f.BasePath != "" { return filepath.Join(f.BasePath, f.Name) } if f.Parent != nil { return filepath.Join(f.Parent.GetPath(), f.Name) } return f.Name } // GetItemStats returns item count, apparent usage and real usage of this dir func (f *Dir) GetItemStats(linkedItems fs.HardLinkedItems) (itemCount, size, usage int64) { f.UpdateStats(linkedItems) return f.ItemCount, f.GetSize(), f.GetUsage() } // UpdateStats recursively updates size and item count func (f *Dir) UpdateStats(linkedItems fs.HardLinkedItems) { totalSize := int64(4096) totalUsage := int64(4096) var itemCount int64 for _, entry := range f.Files { count, size, usage := entry.GetItemStats(linkedItems) totalSize += size totalUsage += usage itemCount += count if entry.GetMtime().After(f.Mtime) { f.Mtime = entry.GetMtime() } switch entry.GetFlag() { case '!', '.': if f.Flag != '!' { f.Flag = '.' } } } f.ItemCount = itemCount + 1 f.Size = totalSize f.Usage = totalUsage } // RemoveFile removes item from dir, updates size and item count func (f *Dir) RemoveFile(item fs.Item) { f.m.Lock() defer f.m.Unlock() f.Files = f.Files.Remove(item) cur := f for { cur.ItemCount -= item.GetItemCount() cur.Size -= item.GetSize() cur.Usage -= item.GetUsage() if cur.Parent == nil { break } cur = cur.Parent.(*Dir) } } // sortFiles sorts files in place according to sortBy and order func sortFiles(files fs.Files, sortBy fs.SortBy, order fs.SortOrder) { var sorter sort.Interface switch sortBy { case fs.SortByName: sorter = fs.ByName(files) case fs.SortByItemCount: sorter = fs.ByItemCount(files) case fs.SortByMtime: sorter = fs.ByMtime(files) case fs.SortByApparentSize: sorter = fs.ByApparentSize(files) case fs.SortBySize: sorter = files } if order == fs.SortDesc { sort.Sort(sort.Reverse(sorter)) } else { sort.Sort(sorter) } } // RLock read locks dir func (f *Dir) RLock() func() { f.m.RLock() return f.m.RUnlock } // RemoveFileByName removes item by name from dir func (f *Dir) RemoveFileByName(name string) { f.m.Lock() defer f.m.Unlock() idx, ok := f.Files.FindByName(name) if !ok { return } item := f.Files[idx] f.Files = append(f.Files[:idx], f.Files[idx+1:]...) cur := f for { cur.ItemCount -= item.GetItemCount() cur.Size -= item.GetSize() cur.Usage -= item.GetUsage() if cur.Parent == nil { break } cur = cur.Parent.(*Dir) } } gdu-5.36.1/pkg/analyze/file_test.go000066400000000000000000000122571517447455500171460ustar00rootroot00000000000000package analyze import ( "slices" "testing" "time" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func TestIsDir(t *testing.T) { dir := Dir{ File: &File{ Name: "xxx", Size: 5, }, ItemCount: 2, } file := &File{ Name: "yyy", Size: 2, Parent: &dir, } dir.Files = fs.Files{file} assert.True(t, dir.IsDir()) assert.False(t, file.IsDir()) } func TestGetType(t *testing.T) { dir := Dir{ File: &File{ Name: "xxx", Size: 5, }, ItemCount: 2, } file := &File{ Name: "yyy", Size: 2, Parent: &dir, Flag: ' ', } file2 := &File{ Name: "yyy", Size: 2, Parent: &dir, Flag: '@', } dir.Files = fs.Files{file, file2} assert.Equal(t, "Directory", dir.GetType()) assert.Equal(t, "File", file.GetType()) assert.Equal(t, "Other", file2.GetType()) } func TestFind(t *testing.T) { dir := Dir{ File: &File{ Name: "xxx", Size: 5, }, ItemCount: 2, } file := &File{ Name: "yyy", Size: 2, Parent: &dir, } file2 := &File{ Name: "zzz", Size: 3, Parent: &dir, } dir.Files = fs.Files{file, file2} i, _ := dir.Files.IndexOf(file) assert.Equal(t, 0, i) i, _ = dir.Files.IndexOf(file2) assert.Equal(t, 1, i) } func TestRemove(t *testing.T) { dir := Dir{ File: &File{ Name: "xxx", Size: 5, }, ItemCount: 2, } file := &File{ Name: "yyy", Size: 2, Parent: &dir, } file2 := &File{ Name: "zzz", Size: 3, Parent: &dir, } dir.Files = fs.Files{file, file2} dir.Files = dir.Files.Remove(file) assert.Equal(t, 1, len(dir.Files)) assert.Equal(t, file2, dir.Files[0]) } func TestRemoveByName(t *testing.T) { dir := Dir{ File: &File{ Name: "xxx", Size: 5, Usage: 8, }, ItemCount: 2, } file := &File{ Name: "yyy", Size: 2, Usage: 4, Parent: &dir, } file2 := &File{ Name: "zzz", Size: 3, Usage: 4, Parent: &dir, } dir.Files = fs.Files{file, file2} dir.Files = dir.Files.RemoveByName("yyy") assert.Equal(t, 1, len(dir.Files)) assert.Equal(t, file2, dir.Files[0]) } func TestRemoveNotInDir(t *testing.T) { dir := Dir{ File: &File{ Name: "xxx", Size: 5, Usage: 8, }, ItemCount: 2, } file := &File{ Name: "yyy", Size: 2, Usage: 4, Parent: &dir, } file2 := &File{ Name: "zzz", Size: 3, Usage: 4, } dir.Files = fs.Files{file} _, ok := dir.Files.IndexOf(file2) assert.Equal(t, false, ok) dir.Files = dir.Files.Remove(file2) assert.Equal(t, 1, len(dir.Files)) } func TestRemoveByNameNotInDir(t *testing.T) { dir := Dir{ File: &File{ Name: "xxx", Size: 5, Usage: 8, }, ItemCount: 2, } file := &File{ Name: "yyy", Size: 2, Usage: 4, Parent: &dir, } file2 := &File{ Name: "zzz", Size: 3, Usage: 4, } dir.Files = fs.Files{file} _, ok := dir.Files.IndexOf(file2) assert.Equal(t, false, ok) dir.Files = dir.Files.RemoveByName("zzz") assert.Equal(t, 1, len(dir.Files)) } func TestUpdateStats(t *testing.T) { dir := Dir{ File: &File{ Name: "xxx", Size: 1, Mtime: time.Date(2021, 8, 19, 0, 40, 0, 0, time.UTC), }, ItemCount: 1, } file := &File{ Name: "yyy", Size: 2, Mtime: time.Date(2021, 8, 19, 0, 41, 0, 0, time.UTC), Parent: &dir, } file2 := &File{ Name: "zzz", Size: 3, Mtime: time.Date(2021, 8, 19, 0, 42, 0, 0, time.UTC), Parent: &dir, } dir.Files = fs.Files{file, file2} dir.UpdateStats(nil) assert.Equal(t, int64(4096+5), dir.Size) assert.Equal(t, 42, dir.GetMtime().Minute()) } func TestGetMultiLinkedInode(t *testing.T) { file := &File{ Name: "xxx", Mli: 5, } assert.Equal(t, uint64(5), file.GetMultiLinkedInode()) } func TestGetPathWithoutLeadingSlash(t *testing.T) { dir := &Dir{ File: &File{ Name: "C:\\", Size: 5, Usage: 12, }, ItemCount: 3, BasePath: "", } assert.Equal(t, "C:\\", dir.GetPath()) } func TestSetParent(t *testing.T) { dir := &Dir{ File: &File{ Name: "root", Size: 5, Usage: 12, }, ItemCount: 3, BasePath: "/", } file := &File{ Name: "xxx", Mli: 5, } file.SetParent(dir) assert.Equal(t, "root", file.GetParent().GetName()) } func TestGetFiles(t *testing.T) { file := &File{ Name: "xxx", Mli: 5, } dir := &Dir{ File: &File{ Name: "root", Size: 5, Usage: 12, }, ItemCount: 3, BasePath: "/", Files: fs.Files{file}, } dirFiles := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc)) assert.Equal(t, file.Name, dirFiles[0].GetName()) fileFiles := slices.Collect(file.GetFiles(fs.SortByName, fs.SortAsc)) assert.Equal(t, 0, len(fileFiles)) } func TestGetFilesLocked(t *testing.T) { file := &File{ Name: "xxx", Mli: 5, } dir := &Dir{ File: &File{ Name: "root", Size: 5, Usage: 12, }, ItemCount: 3, BasePath: "/", Files: fs.Files{file}, } unlock := dir.RLock() defer unlock() files := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc)) locked := slices.Collect(dir.GetFilesLocked(fs.SortByName, fs.SortAsc)) assert.Equal(t, len(files), len(locked)) assert.Equal(t, files[0].GetName(), locked[0].GetName()) } func TestAddFilePanicsOnFile(t *testing.T) { file := &File{ Name: "xxx", Mli: 5, } assert.Panics(t, func() { file.AddFile(file) }) } gdu-5.36.1/pkg/analyze/parallel.go000066400000000000000000000103151517447455500167550ustar00rootroot00000000000000package analyze import ( "os" "path/filepath" "runtime" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/fs" log "github.com/sirupsen/logrus" ) var concurrencyLimit = make(chan struct{}, 2*runtime.GOMAXPROCS(0)) var _ common.Analyzer = (*ParallelAnalyzer)(nil) // ParallelAnalyzer implements Analyzer type ParallelAnalyzer struct { BaseAnalyzer } // CreateAnalyzer returns Analyzer func CreateAnalyzer() *ParallelAnalyzer { a := &ParallelAnalyzer{} a.Init() return a } // AnalyzeDir analyzes given path func (a *ParallelAnalyzer) AnalyzeDir( path string, ignore common.ShouldDirBeIgnored, fileTypeFilter common.ShouldFileBeIgnored, ) fs.Item { a.ignoreDir = ignore a.ignoreFileType = fileTypeFilter go a.UpdateProgress() dir := a.processDir(path) dir.BasePath = filepath.Dir(path) a.wait.Wait() a.progressDoneChan <- struct{}{} a.doneChan.Broadcast() return dir } func (a *ParallelAnalyzer) processDir(path string) *Dir { var ( file fs.Item err error totalUsage int64 info os.FileInfo subDirChan = make(chan *Dir) dirCount int ) a.wait.Add(1) files, err := os.ReadDir(path) if err != nil { log.Print(err.Error()) } dir := &Dir{ File: &File{ Name: filepath.Base(path), Flag: getDirFlag(err, len(files)), }, ItemCount: 1, Files: make(fs.Files, 0, len(files)), } setDirPlatformSpecificAttrs(dir, path) for _, f := range files { name := f.Name() entryPath := filepath.Join(path, name) if f.IsDir() { if a.ignoreDir(name, entryPath) { continue } dirCount++ go func(entryPath string) { concurrencyLimit <- struct{}{} subdir := a.processDir(entryPath) subdir.Parent = dir subDirChan <- subdir <-concurrencyLimit }(entryPath) } else { // Apply file type filter if set if a.ignoreFileType != nil && a.ignoreFileType(name) { continue // Skip this file } info, err = f.Info() if err != nil { log.Print(err.Error()) dir.Flag = '!' continue } if a.followSymlinks && info.Mode()&os.ModeSymlink != 0 { infoF, err := followSymlink(entryPath, a.gitAnnexedSize) if err != nil { log.Print(err.Error()) dir.Flag = '!' continue } if infoF != nil { info = infoF } } // Apply time filter if set if a.matchesTimeFilterFn != nil && !a.matchesTimeFilterFn(info.ModTime()) { continue // Skip this file } switch { case a.archiveBrowsing && isZipFile(name): zipDir, err := processZipFile(entryPath, info) if err != nil { log.Printf("Failed to process zip file %s: %v", entryPath, err) file = &File{ Name: name, Flag: getFlag(info), Size: info.Size(), Parent: dir, } } else { uncompressedSize, compressedSize, err := getZipFileSize(entryPath) if err == nil { zipDir.Size = uncompressedSize zipDir.Usage = compressedSize } zipDir.Parent = dir file = zipDir } case a.archiveBrowsing && isTarFile(name): tarDir, err := processTarFile(entryPath, info) if err != nil { log.Printf("Failed to process tar file %s: %v", entryPath, err) file = &File{ Name: name, Flag: getFlag(info), Size: info.Size(), Parent: dir, } } else { tarDir.Parent = dir file = tarDir } default: file = &File{ Name: name, Flag: getFlag(info), Size: info.Size(), Parent: dir, } } if file != nil { // Only set platform-specific attributes for regular files if regularFile, ok := file.(*File); ok { setPlatformSpecificAttrs(regularFile, info) } totalUsage += file.GetUsage() dir.AddFile(file) } } } go func() { var sub *Dir for i := 0; i < dirCount; i++ { sub = <-subDirChan dir.AddFile(sub) } a.wait.Done() }() a.progressCurrentItemName.Store(path) a.progressItemCount.Add(int64(len(files))) a.progressTotalUsage.Add(totalUsage) return dir } func getDirFlag(err error, items int) rune { switch { case err != nil: return '!' case items == 0: return 'e' default: return ' ' } } func getFlag(f os.FileInfo) rune { if f.Mode()&os.ModeSymlink != 0 || f.Mode()&os.ModeSocket != 0 { return '@' } return ' ' } gdu-5.36.1/pkg/analyze/parallel_coverage_test.go000066400000000000000000000063271517447455500216770ustar00rootroot00000000000000package analyze import ( "os" "testing" "time" "github.com/dundee/gdu/v5/internal/testdir" "github.com/stretchr/testify/assert" ) func TestParallelAnalyzerSetFollowSymlinks(t *testing.T) { analyzer := CreateAnalyzer() analyzer.SetFollowSymlinks(true) assert.True(t, analyzer.followSymlinks) analyzer.SetFollowSymlinks(false) assert.False(t, analyzer.followSymlinks) } func TestParallelAnalyzerSetShowAnnexedSize(t *testing.T) { analyzer := CreateAnalyzer() analyzer.SetShowAnnexedSize(true) assert.True(t, analyzer.gitAnnexedSize) analyzer.SetShowAnnexedSize(false) assert.False(t, analyzer.gitAnnexedSize) } func TestGetDirFlagWithError(t *testing.T) { flag := getDirFlag(os.ErrNotExist, 5) assert.Equal(t, '!', flag) } func TestGetDirFlagWithEmptyDir(t *testing.T) { flag := getDirFlag(nil, 0) assert.Equal(t, 'e', flag) } func TestGetDirFlagWithNormalDir(t *testing.T) { flag := getDirFlag(nil, 5) assert.Equal(t, ' ', flag) } func TestGetFlagWithSymlink(t *testing.T) { // Create a temporary symlink symlinkPath := "/tmp/test_symlink" defer os.Remove(symlinkPath) err := os.Symlink("/tmp", symlinkPath) assert.NoError(t, err) info, err := os.Lstat(symlinkPath) assert.NoError(t, err) flag := getFlag(info) assert.Equal(t, '@', flag) } func TestGetFlagWithRegularFile(t *testing.T) { fin := testdir.CreateTestDir() defer fin() info, err := os.Stat("test_dir/nested/file2") assert.NoError(t, err) flag := getFlag(info) assert.Equal(t, ' ', flag) } func TestParallelAnalyzerUpdateProgress(t *testing.T) { analyzer := CreateAnalyzer() // Start the progress updater go analyzer.UpdateProgress() // Set some progress via atomics analyzer.progressCurrentItemName.Store("test") analyzer.progressItemCount.Add(5) analyzer.progressTotalUsage.Add(100) // Wait a bit for the progress to be processed time.Sleep(100 * time.Millisecond) // Send done signal analyzer.progressDoneChan <- struct{}{} // Wait for the updater to finish time.Sleep(10 * time.Millisecond) } func TestParallelAnalyzerUpdateProgressWithDefaultCase(t *testing.T) { analyzer := CreateAnalyzer() // Start the progress updater go analyzer.UpdateProgress() // Set some progress via atomics analyzer.progressCurrentItemName.Store("test") analyzer.progressItemCount.Add(5) analyzer.progressTotalUsage.Add(100) // Wait a bit for the progress to be processed time.Sleep(100 * time.Millisecond) // Update progress again analyzer.progressCurrentItemName.Store("test2") analyzer.progressItemCount.Add(3) analyzer.progressTotalUsage.Store(50) // Wait a bit for the progress to be processed time.Sleep(100 * time.Millisecond) // Send done signal analyzer.progressDoneChan <- struct{}{} // Wait for the updater to finish time.Sleep(10 * time.Millisecond) } func TestParallelAnalyzerAnalyzeDirWithIgnoreDir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() analyzer := CreateAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(name, _ string) bool { return name == "nested" }, func(_ string) bool { return false }, ).(*Dir) analyzer.GetDone().Wait() assert.NotNil(t, dir) assert.Equal(t, "test_dir", dir.Name) // Should have fewer items since nested directory was ignored assert.Less(t, dir.ItemCount, int64(5)) } gdu-5.36.1/pkg/analyze/parallel_stable.go000066400000000000000000000065701517447455500203170ustar00rootroot00000000000000package analyze import ( "os" "path/filepath" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/fs" log "github.com/sirupsen/logrus" ) // ParallelStableOrderAnalyzer implements Analyzer type ParallelStableOrderAnalyzer struct { BaseAnalyzer } // CreateStableOrderAnalyzer returns parallel Analyzer which keeps stable order of files func CreateStableOrderAnalyzer() *ParallelStableOrderAnalyzer { a := &ParallelStableOrderAnalyzer{} a.Init() return a } // AnalyzeDir analyzes given path func (a *ParallelStableOrderAnalyzer) AnalyzeDir( path string, ignore common.ShouldDirBeIgnored, fileTypeFilter common.ShouldFileBeIgnored, ) fs.Item { a.ignoreDir = ignore a.ignoreFileType = fileTypeFilter go a.UpdateProgress() dir := a.processDir(path) dir.BasePath = filepath.Dir(path) a.wait.Wait() a.progressDoneChan <- struct{}{} a.doneChan.Broadcast() return dir } func (a *ParallelStableOrderAnalyzer) processDir(path string) *Dir { type indexedItem struct { index int item fs.Item } var ( file *File err error totalSize int64 info os.FileInfo itemCount int dirCount int ) a.wait.Add(1) files, err := os.ReadDir(path) if err != nil { log.Print(err.Error()) } dir := &Dir{ File: &File{ Name: filepath.Base(path), Flag: getDirFlag(err, len(files)), }, ItemCount: 1, Files: make(fs.Files, 0, len(files)), } setDirPlatformSpecificAttrs(dir, path) // Buffer channel to prevent deadlock when sending files synchronously itemChan := make(chan indexedItem, len(files)) for _, f := range files { name := f.Name() entryPath := filepath.Join(path, name) if f.IsDir() { if a.ignoreDir(name, entryPath) { continue } currentIndex := itemCount itemCount++ dirCount++ go func(entryPath string, idx int) { concurrencyLimit <- struct{}{} subdir := a.processDir(entryPath) subdir.Parent = dir itemChan <- indexedItem{idx, subdir} <-concurrencyLimit }(entryPath, currentIndex) } else { // Apply file type filter if set if a.ignoreFileType != nil && a.ignoreFileType(name) { continue // Skip this file } info, err = f.Info() if err != nil { log.Print(err.Error()) dir.Flag = '!' continue } if a.followSymlinks && info.Mode()&os.ModeSymlink != 0 { infoF, err := followSymlink(entryPath, a.gitAnnexedSize) if err != nil { log.Print(err.Error()) dir.Flag = '!' continue } if infoF != nil { info = infoF } } // Apply time filter if set if a.matchesTimeFilterFn != nil && !a.matchesTimeFilterFn(info.ModTime()) { continue // Skip this file } file = &File{ Name: name, Flag: getFlag(info), Size: info.Size(), Parent: dir, } setPlatformSpecificAttrs(file, info) totalSize += file.Usage // Send file to channel with its index itemChan <- indexedItem{itemCount, file} itemCount++ } } go func() { items := make([]indexedItem, itemCount) // Collect all items (both files and subdirs) for i := 0; i < itemCount; i++ { indexed := <-itemChan items[indexed.index] = indexed } // Add all items in their original order for i := 0; i < itemCount; i++ { dir.AddFile(items[i].item) } a.wait.Done() }() a.progressCurrentItemName.Store(path) a.progressItemCount.Add(int64(len(files))) a.progressTotalUsage.Add(totalSize) return dir } gdu-5.36.1/pkg/analyze/parallel_top_dir.go000066400000000000000000000114501517447455500204760ustar00rootroot00000000000000package analyze import ( "os" "path/filepath" "sync" "time" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/fs" log "github.com/sirupsen/logrus" ) var pathSep = string(os.PathSeparator) var _ common.Analyzer = (*TopDirAnalyzer)(nil) // TopDirAnalyzer implements Analyzer // It doesn't return the full directory structure, only the top level directory, // thus is suitable only for non-interactive mode. // It tries to use only stack for storing state and results. type TopDirAnalyzer struct { BaseAnalyzer linkedItems sync.Map } // CreateTopDirAnalyzer returns Analyzer func CreateTopDirAnalyzer() *TopDirAnalyzer { a := &TopDirAnalyzer{ BaseAnalyzer: BaseAnalyzer{ ignoreFileType: func(string) bool { return false }, matchesTimeFilterFn: func(time.Time) bool { return true }, }, } a.Init() return a } // AnalyzeDir analyzes given path func (a *TopDirAnalyzer) AnalyzeDir( path string, ignore common.ShouldDirBeIgnored, fileTypeFilter common.ShouldFileBeIgnored, ) fs.Item { a.ignoreDir = ignore if fileTypeFilter != nil { a.ignoreFileType = fileTypeFilter } var subDirChan = make(chan struct{}) go a.UpdateProgress() files, err := os.ReadDir(path) if err != nil { log.Print(err.Error()) } dir := SimpleDir{ SimpleFile: SimpleFile{ Name: filepath.Base(path), Flag: getDirFlag(err, len(files)), IsDir: true, ItemCount: 1, }, Files: make([]SimpleFile, 0, len(files)), } var topDirs []*TopDir for _, f := range files { name := f.Name() entryPath := path + pathSep + name if f.IsDir() { if a.ignoreDir(name, entryPath) { continue } topDir := &TopDir{ Name: name, Flag: ' ', } topDirs = append(topDirs, topDir) go func(entryPath string) { a.processSubDir(entryPath, topDir) subDirChan <- struct{}{} }(entryPath) } else { var info os.FileInfo // Apply file type filter if set if a.ignoreFileType(name) { continue // Skip this file } info, err = f.Info() if err != nil { log.Print(err.Error()) dir.Flag = '!' continue } // Apply time filter if set if !a.matchesTimeFilterFn(info.ModTime()) { continue // Skip this file } if a.followSymlinks && info.Mode()&os.ModeSymlink != 0 { infoF, err := followSymlink(entryPath, a.gitAnnexedSize) if err != nil { log.Print(err.Error()) dir.Flag = '!' continue } if infoF != nil { info = infoF } } file := SimpleFile{ Name: name, Flag: getFlag(info), Size: info.Size(), } usage, mli := getPlatformSpecificUsageAndMli(info) file.Usage = usage if mli > 0 { file.Flag = 'H' } dir.Files = append(dir.Files, file) } } for i := 0; i < len(topDirs); i++ { <-subDirChan } a.wait.Wait() for _, topDir := range topDirs { size, usage, itemCount := topDir.GetUsage() dir.Files = append(dir.Files, SimpleFile{ Name: topDir.Name, Flag: topDir.Flag, Size: size, Usage: usage, ItemCount: itemCount, IsDir: true, }) } dir.BasePath = filepath.Dir(path) a.progressDoneChan <- struct{}{} a.doneChan.Broadcast() return &dir } func (a *TopDirAnalyzer) processSubDir(path string, topDir *TopDir) { var ( err error totalSize int64 = 4096 totalUsage int64 = 4096 totalCount int64 info os.FileInfo ) files, err := os.ReadDir(path) if err != nil { log.Print(err.Error()) topDir.SetFlag('.') } for _, f := range files { name := f.Name() entryPath := path + pathSep + name if f.IsDir() { if a.ignoreDir(name, entryPath) { continue } select { case concurrencyLimit <- struct{}{}: a.wait.Add(1) go func(entryPath string) { a.processSubDir(entryPath, topDir) <-concurrencyLimit a.wait.Done() }(entryPath) default: a.processSubDir(entryPath, topDir) } } else { // Apply file type filter if set if a.ignoreFileType(name) { continue // Skip this file } totalCount++ info, err = f.Info() if err != nil { log.Print(err.Error()) topDir.SetFlag('.') continue } // Apply time filter if set if !a.matchesTimeFilterFn(info.ModTime()) { continue // Skip this file } if a.followSymlinks && info.Mode()&os.ModeSymlink != 0 { infoF, err := followSymlink(entryPath, a.gitAnnexedSize) if err != nil { log.Print(err.Error()) topDir.SetFlag('.') continue } if infoF != nil { info = infoF } } usage, mli := getPlatformSpecificUsageAndMli(info) if mli > 0 { if _, loaded := a.linkedItems.LoadOrStore(mli, struct{}{}); loaded { continue } } totalUsage += usage totalSize += info.Size() } } a.progressItemCount.Add(totalCount) a.progressTotalUsage.Add(totalUsage) topDir.AddUsage(totalSize, totalUsage, totalCount+1) } gdu-5.36.1/pkg/analyze/parallel_top_dir_test.go000066400000000000000000000202461517447455500215400ustar00rootroot00000000000000package analyze import ( "os" "testing" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func TestTopDirAnalyzeDir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() analyzer := CreateTopDirAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) analyzer.GetDone().Wait() assert.Equal(t, "test_dir", dir.GetName()) simpleDir := dir.(*SimpleDir) simpleDir.UpdateStats(make(fs.HardLinkedItems)) // Should have one top-level entry: "nested" directory assert.Equal(t, 1, len(simpleDir.Files)) assert.True(t, simpleDir.Files[0].IsDir) assert.Equal(t, "nested", simpleDir.Files[0].Name) // nested dir contains: file2 (2 bytes) + subnested/file (5 bytes) + dir overhead assert.Greater(t, simpleDir.Files[0].Size, int64(0)) assert.Greater(t, simpleDir.Files[0].ItemCount, int64(0)) } func TestTopDirAnalyzeDirWithFiles(t *testing.T) { tmpDir := t.TempDir() err := os.WriteFile(tmpDir+"/file_a", []byte("hello"), 0o644) assert.Nil(t, err) err = os.Mkdir(tmpDir+"/subdir", 0o755) assert.Nil(t, err) err = os.WriteFile(tmpDir+"/subdir/file_b", []byte("world!"), 0o644) assert.Nil(t, err) analyzer := CreateTopDirAnalyzer() dir := analyzer.AnalyzeDir( tmpDir, func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) analyzer.GetDone().Wait() simpleDir := dir.(*SimpleDir) simpleDir.UpdateStats(make(fs.HardLinkedItems)) // Should have 2 entries: file_a and subdir assert.Equal(t, 2, len(simpleDir.Files)) // Verify total size includes both assert.Greater(t, simpleDir.GetSize(), int64(0)) } func TestTopDirAnalyzeDirIgnoreDir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() analyzer := CreateTopDirAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(name, _ string) bool { return name == "nested" }, func(_ string) bool { return false }, ) analyzer.GetDone().Wait() simpleDir := dir.(*SimpleDir) // "nested" directory should be ignored, no entries assert.Equal(t, 0, len(simpleDir.Files)) } func TestTopDirAnalyzeDirIgnoreFileType(t *testing.T) { tmpDir := t.TempDir() err := os.WriteFile(tmpDir+"/keep.txt", []byte("keep"), 0o644) assert.Nil(t, err) err = os.WriteFile(tmpDir+"/skip.log", []byte("skip"), 0o644) assert.Nil(t, err) analyzer := CreateTopDirAnalyzer() dir := analyzer.AnalyzeDir( tmpDir, func(_, _ string) bool { return false }, func(name string) bool { return len(name) > 4 && name[len(name)-4:] == ".log" }, ) analyzer.GetDone().Wait() simpleDir := dir.(*SimpleDir) assert.Equal(t, 1, len(simpleDir.Files)) assert.Equal(t, "keep.txt", simpleDir.Files[0].Name) } func TestTopDirAnalyzeDirIgnoreFileTypeInSubDir(t *testing.T) { tmpDir := t.TempDir() err := os.Mkdir(tmpDir+"/sub", 0o755) assert.Nil(t, err) err = os.WriteFile(tmpDir+"/sub/keep.txt", []byte("keep"), 0o644) assert.Nil(t, err) err = os.WriteFile(tmpDir+"/sub/skip.log", []byte("skip"), 0o644) assert.Nil(t, err) analyzer := CreateTopDirAnalyzer() dir := analyzer.AnalyzeDir( tmpDir, func(_, _ string) bool { return false }, func(name string) bool { return len(name) > 4 && name[len(name)-4:] == ".log" }, ) analyzer.GetDone().Wait() simpleDir := dir.(*SimpleDir) // sub directory should exist but only count keep.txt assert.Equal(t, 1, len(simpleDir.Files)) assert.Equal(t, "sub", simpleDir.Files[0].Name) // ItemCount should reflect only the kept file + the dir itself assert.Equal(t, int64(2), simpleDir.Files[0].ItemCount) } func TestTopDirAnalyzeDirProgress(t *testing.T) { fin := testdir.CreateTestDir() defer fin() analyzer := CreateTopDirAnalyzer() _ = analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) analyzer.GetDone().Wait() // Just verify the progress channel is accessible and was used assert.NotNil(t, analyzer.GetProgress()) } func TestTopDirAnalyzeDirResetProgress(t *testing.T) { fin := testdir.CreateTestDir() defer fin() analyzer := CreateTopDirAnalyzer() _ = analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) progress := analyzer.GetProgress() assert.GreaterOrEqual(t, progress.TotalUsage, int64(0)) analyzer.GetDone().Wait() analyzer.ResetProgress() // Analyze again after reset dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) analyzer.GetDone().Wait() assert.Equal(t, "test_dir", dir.GetName()) } func TestTopDirAnalyzeDirNonExistent(t *testing.T) { analyzer := CreateTopDirAnalyzer() dir := analyzer.AnalyzeDir( "/nonexistent_path_xyz", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) analyzer.GetDone().Wait() simpleDir := dir.(*SimpleDir) assert.Equal(t, "nonexistent_path_xyz", simpleDir.Name) assert.Equal(t, 0, len(simpleDir.Files)) } func TestTopDirAnalyzerSetters(t *testing.T) { analyzer := CreateTopDirAnalyzer() analyzer.SetFollowSymlinks(true) assert.True(t, analyzer.followSymlinks) analyzer.SetShowAnnexedSize(true) assert.True(t, analyzer.gitAnnexedSize) analyzer.SetArchiveBrowsing(true) assert.True(t, analyzer.archiveBrowsing) called := false filter := func(name string) bool { called = true; return false } analyzer.SetFileTypeFilter(filter) analyzer.ignoreFileType("test") assert.True(t, called) } func TestTopDirAnalyzeDirIgnoreSubDir(t *testing.T) { tmpDir := t.TempDir() err := os.MkdirAll(tmpDir+"/top/ignored", 0o755) assert.Nil(t, err) err = os.MkdirAll(tmpDir+"/top/kept", 0o755) assert.Nil(t, err) err = os.WriteFile(tmpDir+"/top/ignored/file", []byte("data"), 0o644) assert.Nil(t, err) err = os.WriteFile(tmpDir+"/top/kept/file", []byte("data"), 0o644) assert.Nil(t, err) analyzer := CreateTopDirAnalyzer() dir := analyzer.AnalyzeDir( tmpDir, func(name, _ string) bool { return name == "ignored" }, func(_ string) bool { return false }, ) analyzer.GetDone().Wait() simpleDir := dir.(*SimpleDir) assert.Equal(t, 1, len(simpleDir.Files)) assert.Equal(t, "top", simpleDir.Files[0].Name) } func TestTopDirGetFiles(t *testing.T) { fin := testdir.CreateTestDir() defer fin() analyzer := CreateTopDirAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) analyzer.GetDone().Wait() simpleDir := dir.(*SimpleDir) simpleDir.UpdateStats(make(fs.HardLinkedItems)) count := 0 for range simpleDir.GetFiles(fs.SortBySize, fs.SortAsc) { count++ } assert.Equal(t, len(simpleDir.Files), count) } func TestTopDirFollowSymlink(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Mkdir("test_dir/empty", 0o644) assert.Nil(t, err) err = os.Symlink("nested/subnested/file", "./test_dir/file2") assert.Nil(t, err) analyzer := CreateTopDirAnalyzer() analyzer.SetFollowSymlinks(true) dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*SimpleDir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) var files []fs.Item for file := range dir.GetFiles(fs.SortBySize, fs.SortDesc) { files = append(files, file) } assert.Equal(t, int64(12+4096*4), dir.Size) assert.Equal(t, int64(6), dir.ItemCount) // test file3 assert.Equal(t, "file2", files[1].GetName()) assert.Equal(t, int64(5), files[1].GetSize()) assert.Equal(t, ' ', files[1].GetFlag()) } func TestTopDirFollowNestedSymlink(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Mkdir("test_dir/empty", 0o644) assert.Nil(t, err) err = os.Symlink("subnested/file", "./test_dir/nested/file3") assert.Nil(t, err) analyzer := CreateTopDirAnalyzer() analyzer.SetFollowSymlinks(true) dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*SimpleDir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) var files []fs.Item for file := range dir.GetFiles(fs.SortBySize, fs.SortDesc) { files = append(files, file) } assert.Equal(t, int64(12+4096*4), dir.Size) assert.Equal(t, int64(7), dir.ItemCount) } gdu-5.36.1/pkg/analyze/sequential.go000066400000000000000000000070201517447455500173320ustar00rootroot00000000000000package analyze import ( "os" "path/filepath" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/fs" log "github.com/sirupsen/logrus" ) // SequentialAnalyzer implements Analyzer type SequentialAnalyzer struct { BaseAnalyzer } // CreateSeqAnalyzer returns Analyzer func CreateSeqAnalyzer() *SequentialAnalyzer { a := &SequentialAnalyzer{} a.Init() return a } // AnalyzeDir analyzes given path func (a *SequentialAnalyzer) AnalyzeDir( path string, ignore common.ShouldDirBeIgnored, fileTypeFilter common.ShouldFileBeIgnored, ) fs.Item { a.ignoreDir = ignore a.ignoreFileType = fileTypeFilter go a.UpdateProgress() dir := a.processDir(path) dir.BasePath = filepath.Dir(path) a.progressDoneChan <- struct{}{} a.doneChan.Broadcast() return dir } func (a *SequentialAnalyzer) processDir(path string) *Dir { var ( file fs.Item err error totalSize int64 info os.FileInfo dirCount int ) files, err := os.ReadDir(path) if err != nil { log.Print(err.Error()) } dir := &Dir{ File: &File{ Name: filepath.Base(path), Flag: getDirFlag(err, len(files)), }, ItemCount: 1, Files: make(fs.Files, 0, len(files)), } setDirPlatformSpecificAttrs(dir, path) for _, f := range files { name := f.Name() entryPath := filepath.Join(path, name) if f.IsDir() { if a.ignoreDir(name, entryPath) { continue } dirCount++ subdir := a.processDir(entryPath) subdir.Parent = dir dir.AddFile(subdir) } else { // Apply file type filter if set if a.ignoreFileType != nil && a.ignoreFileType(name) { continue // Skip this file } info, err = f.Info() if err != nil { log.Print(err.Error()) dir.Flag = '!' continue } if a.followSymlinks && info.Mode()&os.ModeSymlink != 0 { infoF, err := followSymlink(entryPath, a.gitAnnexedSize) if err != nil { log.Print(err.Error()) dir.Flag = '!' continue } if infoF != nil { info = infoF } } // Apply time filter if set if a.matchesTimeFilterFn != nil && !a.matchesTimeFilterFn(info.ModTime()) { continue // Skip this file } switch { case a.archiveBrowsing && isZipFile(name): zipDir, err := processZipFile(entryPath, info) if err != nil { log.Printf("Failed to process zip file %s: %v", entryPath, err) file = &File{ Name: name, Flag: getFlag(info), Size: info.Size(), Parent: dir, } } else { uncompressedSize, compressedSize, err := getZipFileSize(entryPath) if err == nil { zipDir.Size = uncompressedSize zipDir.Usage = compressedSize } zipDir.Parent = dir file = zipDir } case a.archiveBrowsing && isTarFile(name): tarDir, err := processTarFile(entryPath, info) if err != nil { log.Printf("Failed to process tar file %s: %v", entryPath, err) file = &File{ Name: name, Flag: getFlag(info), Size: info.Size(), Parent: dir, } } else { tarDir.Parent = dir file = tarDir } default: file = &File{ Name: name, Flag: getFlag(info), Size: info.Size(), Parent: dir, } } if file != nil { // Only set platform-specific attributes for regular files if regularFile, ok := file.(*File); ok { setPlatformSpecificAttrs(regularFile, info) } totalSize += file.GetUsage() dir.AddFile(file) } } } a.progressCurrentItemName.Store(path) a.progressItemCount.Add(int64(len(files))) a.progressTotalUsage.Add(totalSize) return dir } gdu-5.36.1/pkg/analyze/sequential_coverage_test.go000066400000000000000000000046021517447455500222470ustar00rootroot00000000000000package analyze import ( "testing" "time" "github.com/dundee/gdu/v5/internal/testdir" "github.com/stretchr/testify/assert" ) func TestSequentialAnalyzerSetFollowSymlinks(t *testing.T) { analyzer := CreateSeqAnalyzer() analyzer.SetFollowSymlinks(true) assert.True(t, analyzer.followSymlinks) analyzer.SetFollowSymlinks(false) assert.False(t, analyzer.followSymlinks) } func TestSequentialAnalyzerSetShowAnnexedSize(t *testing.T) { analyzer := CreateSeqAnalyzer() analyzer.SetShowAnnexedSize(true) assert.True(t, analyzer.gitAnnexedSize) analyzer.SetShowAnnexedSize(false) assert.False(t, analyzer.gitAnnexedSize) } func TestSequentialAnalyzerUpdateProgress(t *testing.T) { analyzer := CreateSeqAnalyzer() // Start the progress updater go analyzer.UpdateProgress() // Set some progress via atomics analyzer.progressCurrentItemName.Store("test") analyzer.progressItemCount.Add(5) analyzer.progressTotalUsage.Add(100) // Wait a bit for the progress to be processed time.Sleep(100 * time.Millisecond) // Send done signal analyzer.progressDoneChan <- struct{}{} // Wait for the updater to finish time.Sleep(10 * time.Millisecond) } func TestSequentialAnalyzerUpdateProgressWithDefaultCase(t *testing.T) { analyzer := CreateSeqAnalyzer() // Start the progress updater go analyzer.UpdateProgress() // Set some progress via atomics analyzer.progressCurrentItemName.Store("test") analyzer.progressItemCount.Add(5) analyzer.progressTotalUsage.Add(100) // Wait a bit for the progress to be processed time.Sleep(100 * time.Millisecond) // Update progress again analyzer.progressCurrentItemName.Store("test2") analyzer.progressItemCount.Add(3) analyzer.progressTotalUsage.Store(50) // Wait a bit for the progress to be processed time.Sleep(100 * time.Millisecond) // Send done signal analyzer.progressDoneChan <- struct{}{} // Wait for the updater to finish time.Sleep(10 * time.Millisecond) } func TestSequentialAnalyzerAnalyzeDirWithIgnoreDir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() analyzer := CreateSeqAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(name, _ string) bool { return name == "nested" }, func(_ string) bool { return false }, ).(*Dir) analyzer.GetDone().Wait() assert.NotNil(t, dir) assert.Equal(t, "test_dir", dir.Name) // Should have fewer items since nested directory was ignored assert.Less(t, dir.ItemCount, int64(5)) } gdu-5.36.1/pkg/analyze/sequential_test.go000066400000000000000000000125171517447455500204000ustar00rootroot00000000000000package analyze import ( "os" "sort" "testing" log "github.com/sirupsen/logrus" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func init() { log.SetLevel(log.WarnLevel) } func TestAnalyzeDirSeq(t *testing.T) { fin := testdir.CreateTestDir() defer fin() analyzer := CreateSeqAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*Dir) progress := analyzer.GetProgress() assert.GreaterOrEqual(t, progress.TotalUsage, int64(0)) analyzer.GetDone().Wait() analyzer.ResetProgress() dir.UpdateStats(make(fs.HardLinkedItems)) // test dir info assert.Equal(t, "test_dir", dir.Name) assert.Equal(t, int64(7+4096*3), dir.Size) assert.Equal(t, int64(5), dir.ItemCount) assert.True(t, dir.IsDir()) // test dir tree assert.Equal(t, "nested", dir.Files[0].GetName()) assert.Equal(t, "subnested", dir.Files[0].(*Dir).Files[1].GetName()) // test file assert.Equal(t, "file2", dir.Files[0].(*Dir).Files[0].GetName()) assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[0].GetSize()) assert.Equal( t, "file", dir.Files[0].(*Dir).Files[1].(*Dir).Files[0].GetName(), ) assert.Equal( t, int64(5), dir.Files[0].(*Dir).Files[1].(*Dir).Files[0].GetSize(), ) // test parent link assert.Equal( t, "test_dir", dir.Files[0].(*Dir). Files[1].(*Dir). Files[0]. GetParent(). GetParent(). GetParent(). GetName(), ) } func TestIgnoreDirSeq(t *testing.T) { fin := testdir.CreateTestDir() defer fin() dir := CreateSeqAnalyzer().AnalyzeDir( "test_dir", func(_, _ string) bool { return true }, func(_ string) bool { return false }, ).(*Dir) assert.Equal(t, "test_dir", dir.Name) assert.Equal(t, int64(1), dir.ItemCount) } func TestFlagsSeq(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Mkdir("test_dir/empty", 0o644) assert.Nil(t, err) err = os.Symlink("test_dir/nested/file2", "test_dir/nested/file3") assert.Nil(t, err) analyzer := CreateSeqAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*Dir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) sort.Sort(sort.Reverse(dir.Files)) assert.Equal(t, int64(28+4096*4), dir.Size) assert.Equal(t, int64(7), dir.ItemCount) // test file3 assert.Equal(t, "nested", dir.Files[0].GetName()) assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) assert.Equal(t, int64(21), dir.Files[0].(*Dir).Files[1].GetSize()) assert.Equal(t, '@', dir.Files[0].(*Dir).Files[1].GetFlag()) assert.Equal(t, 'e', dir.Files[1].GetFlag()) } func TestHardlinkSeq(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Link("test_dir/nested/file2", "test_dir/nested/file3") assert.Nil(t, err) analyzer := CreateSeqAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*Dir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) assert.Equal(t, int64(7+4096*3), dir.Size) // file2 and file3 are counted just once for size assert.Equal(t, int64(6), dir.ItemCount) // but twice for item count // test file3 assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[1].GetSize()) assert.Equal(t, 'H', dir.Files[0].(*Dir).Files[1].GetFlag()) } func TestFollowSymlinkSeq(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Mkdir("test_dir/empty", 0o644) assert.Nil(t, err) err = os.Symlink("./file2", "test_dir/nested/file3") assert.Nil(t, err) analyzer := CreateSeqAnalyzer() analyzer.SetFollowSymlinks(true) dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*Dir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) sort.Sort(sort.Reverse(dir.Files)) assert.Equal(t, int64(9+4096*4), dir.Size) assert.Equal(t, int64(7), dir.ItemCount) // test file3 assert.Equal(t, "nested", dir.Files[0].GetName()) assert.Equal(t, "file3", dir.Files[0].(*Dir).Files[1].GetName()) assert.Equal(t, int64(2), dir.Files[0].(*Dir).Files[1].GetSize()) assert.Equal(t, ' ', dir.Files[0].(*Dir).Files[1].GetFlag()) assert.Equal(t, 'e', dir.Files[1].GetFlag()) } func TestBrokenSymlinkSkippedSeq(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Mkdir("test_dir/empty", 0o644) assert.Nil(t, err) err = os.Symlink("xxx", "test_dir/nested/file3") assert.Nil(t, err) analyzer := CreateSeqAnalyzer() analyzer.SetFollowSymlinks(true) dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*Dir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) sort.Sort(sort.Reverse(dir.Files)) assert.Equal(t, int64(7+4096*4), dir.Size) assert.Equal(t, int64(6), dir.ItemCount) assert.Equal(t, '!', dir.Files[0].GetFlag()) } func BenchmarkAnalyzeDirSeq(b *testing.B) { fin := testdir.CreateTestDir() defer fin() b.ResetTimer() analyzer := CreateSeqAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) } gdu-5.36.1/pkg/analyze/sort_test.go000066400000000000000000000063101517447455500172070ustar00rootroot00000000000000package analyze import ( "sort" "testing" "time" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func TestSortByUsage(t *testing.T) { files := fs.Files{ &File{ Usage: 1, }, &File{ Usage: 2, }, &File{ Usage: 3, }, } sort.Sort(sort.Reverse(files)) assert.Equal(t, int64(3), files[0].GetUsage()) assert.Equal(t, int64(2), files[1].GetUsage()) assert.Equal(t, int64(1), files[2].GetUsage()) } func TestStableSortByUsage(t *testing.T) { files := fs.Files{ &File{ Name: "aaa", Usage: 1, }, &File{ Name: "bbb", Usage: 1, }, &File{ Name: "ccc", Usage: 3, }, } sort.Sort(sort.Reverse(files)) assert.Equal(t, "ccc", files[0].GetName()) assert.Equal(t, "bbb", files[1].GetName()) assert.Equal(t, "aaa", files[2].GetName()) } func TestSortByUsageAsc(t *testing.T) { files := fs.Files{ &File{ Size: 1, }, &File{ Size: 2, }, &File{ Size: 3, }, } sort.Sort(files) assert.Equal(t, int64(1), files[0].GetSize()) assert.Equal(t, int64(2), files[1].GetSize()) assert.Equal(t, int64(3), files[2].GetSize()) } func TestSortBySize(t *testing.T) { files := fs.Files{ &File{ Size: 1, }, &File{ Size: 2, }, &File{ Size: 3, }, } sort.Sort(sort.Reverse(fs.ByApparentSize(files))) assert.Equal(t, int64(3), files[0].GetSize()) assert.Equal(t, int64(2), files[1].GetSize()) assert.Equal(t, int64(1), files[2].GetSize()) } func TestSortBySizeAsc(t *testing.T) { files := fs.Files{ &File{ Size: 1, }, &File{ Size: 2, }, &File{ Size: 3, }, } sort.Sort(fs.ByApparentSize(files)) assert.Equal(t, int64(1), files[0].GetSize()) assert.Equal(t, int64(2), files[1].GetSize()) assert.Equal(t, int64(3), files[2].GetSize()) } func TestSortByItemCount(t *testing.T) { files := fs.Files{ &Dir{ ItemCount: 1, }, &Dir{ ItemCount: 2, }, &Dir{ ItemCount: 3, }, } sort.Sort(sort.Reverse(fs.ByItemCount(files))) assert.Equal(t, int64(3), files[0].GetItemCount()) assert.Equal(t, int64(2), files[1].GetItemCount()) assert.Equal(t, int64(1), files[2].GetItemCount()) } func TestSortByName(t *testing.T) { files := fs.Files{ &File{ Name: "aa", }, &File{ Name: "bb", }, &File{ Name: "cc", }, } sort.Sort(sort.Reverse(fs.ByName(files))) assert.Equal(t, "cc", files[0].GetName()) assert.Equal(t, "bb", files[1].GetName()) assert.Equal(t, "aa", files[2].GetName()) } func TestNaturalSortByNameAsc(t *testing.T) { files := fs.Files{ &File{ Name: "aa3", }, &File{ Name: "aa20", }, &File{ Name: "aa100", }, } sort.Sort(fs.ByName(files)) assert.Equal(t, "aa3", files[0].GetName()) assert.Equal(t, "aa20", files[1].GetName()) assert.Equal(t, "aa100", files[2].GetName()) } func TestSortByMtime(t *testing.T) { files := fs.Files{ &File{ Mtime: time.Date(2021, 8, 19, 0, 40, 0, 0, time.UTC), }, &File{ Mtime: time.Date(2021, 8, 19, 0, 41, 0, 0, time.UTC), }, &File{ Mtime: time.Date(2021, 8, 19, 0, 42, 0, 0, time.UTC), }, } sort.Sort(sort.Reverse(fs.ByMtime(files))) assert.Equal(t, 42, files[0].GetMtime().Minute()) assert.Equal(t, 41, files[1].GetMtime().Minute()) assert.Equal(t, 40, files[2].GetMtime().Minute()) } gdu-5.36.1/pkg/analyze/sqlite.go000066400000000000000000000662551517447455500165000ustar00rootroot00000000000000package analyze import ( "database/sql" "encoding/json" "io" "iter" "os" "path/filepath" "strconv" "sync" "time" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/fs" "github.com/pkg/errors" log "github.com/sirupsen/logrus" ) // SqliteStorage represents SQLite database storage type SqliteStorage struct { db *sql.DB dbPath string m sync.RWMutex tx *sql.Tx insertStmt *sql.Stmt updateStmt *sql.Stmt hasInodeStmt *sql.Stmt } // NewSqliteStorage creates a new SQLite storage and initializes the schema func NewSqliteStorage(dbPath string) (*SqliteStorage, error) { parentDir := filepath.Dir(dbPath) if err := os.MkdirAll(parentDir, 0o755); err != nil { return nil, errors.Wrap(err, "failed to create parent directory for SQLite database") } db, err := sql.Open("sqlite", dbPath) if err != nil { return nil, err } storage := &SqliteStorage{ db: db, dbPath: dbPath, } if err := storage.createTables(); err != nil { db.Close() return nil, err } return storage, nil } // createTables creates the database schema if it doesn't exist func (s *SqliteStorage) createTables() error { // Optimize for insertion speed pragmas := ` PRAGMA synchronous = OFF; PRAGMA journal_mode = MEMORY; PRAGMA cache_size = -64000; PRAGMA temp_store = MEMORY; ` if _, err := s.db.Exec(pragmas); err != nil { return err } schema := ` CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES items(id), name TEXT NOT NULL, is_dir INTEGER NOT NULL, size INTEGER NOT NULL, usage INTEGER NOT NULL, mtime INTEGER NOT NULL, item_count INTEGER NOT NULL DEFAULT 1, mli INTEGER NOT NULL DEFAULT 0, flag TEXT NOT NULL DEFAULT ' ' ); CREATE INDEX IF NOT EXISTS idx_items_parent_id ON items(parent_id); CREATE INDEX IF NOT EXISTS idx_items_parent_name ON items(parent_id, name); CREATE INDEX IF NOT EXISTS idx_items_mli ON items(mli) WHERE mli != 0; CREATE TABLE IF NOT EXISTS metadata ( key TEXT PRIMARY KEY, value TEXT ); ` _, err := s.db.Exec(schema) return err } // Close closes the database connection func (s *SqliteStorage) Close() error { s.m.Lock() defer s.m.Unlock() if s.db != nil { return s.db.Close() } return nil } // ClearItems removes all items from the database func (s *SqliteStorage) ClearItems() error { _, err := s.db.Exec("DELETE FROM items") return err } // BeginBulkInsert starts a transaction and prepares statements for bulk insertion func (s *SqliteStorage) BeginBulkInsert() error { s.m.Lock() defer s.m.Unlock() tx, err := s.db.Begin() if err != nil { return err } s.tx = tx s.insertStmt, err = tx.Prepare( `INSERT INTO items (parent_id, name, is_dir, size, usage, mtime, item_count, mli, flag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) if err != nil { rollbackErr := tx.Rollback() if rollbackErr != nil { log.Errorf("failed to rollback transaction: %v", rollbackErr) } return err } s.updateStmt, err = tx.Prepare( `UPDATE items SET size = ?, usage = ?, item_count = ? WHERE id = ?`, ) if err != nil { s.insertStmt.Close() rollbackErr := tx.Rollback() if rollbackErr != nil { log.Errorf("failed to rollback transaction: %v", rollbackErr) } return err } s.hasInodeStmt, err = tx.Prepare( `SELECT 1 FROM items WHERE mli = ? LIMIT 1`, ) if err != nil { s.insertStmt.Close() s.updateStmt.Close() rollbackErr := tx.Rollback() if rollbackErr != nil { log.Errorf("failed to rollback transaction: %v", rollbackErr) } return err } return nil } // EndBulkInsert commits the transaction and closes prepared statements func (s *SqliteStorage) EndBulkInsert() error { s.m.Lock() defer s.m.Unlock() if s.insertStmt != nil { s.insertStmt.Close() s.insertStmt = nil } if s.updateStmt != nil { s.updateStmt.Close() s.updateStmt = nil } if s.hasInodeStmt != nil { s.hasInodeStmt.Close() s.hasInodeStmt = nil } if s.tx != nil { err := s.tx.Commit() s.tx = nil return err } return nil } // HasData returns true if the database contains analysis data func (s *SqliteStorage) HasData() bool { s.m.RLock() defer s.m.RUnlock() var rowid int err := s.db.QueryRow("SELECT MAX(rowid) FROM items").Scan(&rowid) if err != nil { return false } return rowid > 0 } // HasInode returns true if a file with the given inode already exists in the database func (s *SqliteStorage) HasInode(mli uint64) bool { var exists int var err error if s.hasInodeStmt != nil { err = s.hasInodeStmt.QueryRow(mli).Scan(&exists) } else { s.m.RLock() err = s.db.QueryRow(`SELECT 1 FROM items WHERE mli = ? LIMIT 1`, mli).Scan(&exists) s.m.RUnlock() } return err == nil } // GetRootItem returns the root item (item with no parent) func (s *SqliteStorage) GetRootItem() (*SqliteItem, error) { s.m.RLock() defer s.m.RUnlock() item := &SqliteItem{storage: s} var parentID sql.NullInt64 var isDirInt int var mtimeUnix int64 var flag string err := s.db.QueryRow( `SELECT id, parent_id, name, is_dir, size, usage, mtime, item_count, mli, flag FROM items WHERE parent_id IS NULL LIMIT 1`, ).Scan( &item.id, &parentID, &item.name, &isDirInt, &item.size, &item.usage, &mtimeUnix, &item.itemCount, &item.mli, &flag, ) if err != nil { return nil, err } item.isDir = isDirInt == 1 item.mtime = time.Unix(mtimeUnix, 0) if flag != "" { item.flag = rune(flag[0]) } else { item.flag = ' ' } return item, nil } // InsertItem inserts a file/directory item into the database func (s *SqliteStorage) InsertItem( parentID *int64, name string, isDir bool, size, usage int64, mtime time.Time, itemCount int64, mli uint64, flag rune, ) (int64, error) { isDirInt := 0 if isDir { isDirInt = 1 } var result sql.Result var err error // Use prepared statement if in bulk mode, otherwise use direct exec if s.insertStmt != nil { result, err = s.insertStmt.Exec(parentID, name, isDirInt, size, usage, mtime.Unix(), itemCount, mli, string(flag)) } else { s.m.Lock() result, err = s.db.Exec( `INSERT INTO items (parent_id, name, is_dir, size, usage, mtime, item_count, mli, flag) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, parentID, name, isDirInt, size, usage, mtime.Unix(), itemCount, mli, string(flag), ) s.m.Unlock() } if err != nil { return 0, err } return result.LastInsertId() } // UpdateItem updates an existing item's stats func (s *SqliteStorage) UpdateItem(id, size, usage, itemCount int64) error { var err error // Use prepared statement if in bulk mode, otherwise use direct exec if s.updateStmt != nil { _, err = s.updateStmt.Exec(size, usage, itemCount, id) } else { s.m.Lock() _, err = s.db.Exec( `UPDATE items SET size = ?, usage = ?, item_count = ? WHERE id = ?`, size, usage, itemCount, id, ) s.m.Unlock() } return err } // deleteItemTree removes an item and all its descendants from the database within the given transaction. func (s *SqliteStorage) deleteItemTree(tx *sql.Tx, id int64) error { query := ` WITH RECURSIVE to_delete(id) AS ( SELECT ? UNION ALL SELECT items.id FROM items JOIN to_delete ON items.parent_id = to_delete.id ) DELETE FROM items WHERE id IN (SELECT id FROM to_delete) ` _, err := tx.Exec(query, id) return err } // RemoveItemAndUpdateStats removes an item and updates all its ancestors' stats in the database func (s *SqliteStorage) RemoveItemAndUpdateStats(id, size, usage, itemCount int64) error { s.m.Lock() defer s.m.Unlock() tx, err := s.db.Begin() if err != nil { return err } // 1. Update all ancestors using recursive CTE updateAncestorsQuery := ` WITH RECURSIVE ancestors(id, parent_id) AS ( SELECT parent_id, NULL FROM items WHERE id = ? UNION ALL SELECT i.parent_id, NULL FROM items i JOIN ancestors a ON i.id = a.id WHERE i.parent_id IS NOT NULL ) UPDATE items SET size = size - ?, usage = usage - ?, item_count = item_count - ? WHERE id IN (SELECT id FROM ancestors) ` _, err = tx.Exec(updateAncestorsQuery, id, size, usage, itemCount) if err != nil { rollbackErr := tx.Rollback() if rollbackErr != nil { log.Errorf("failed to rollback transaction: %v", rollbackErr) } return err } // 2. Delete the item and its descendants if err = s.deleteItemTree(tx, id); err != nil { rollbackErr := tx.Rollback() if rollbackErr != nil { log.Errorf("failed to rollback transaction: %v", rollbackErr) } return err } return tx.Commit() } // GetChildByName returns a child item by its name and parent ID func (s *SqliteStorage) GetChildByName(parentID int64, name string) (*SqliteItem, error) { s.m.RLock() defer s.m.RUnlock() item := &SqliteItem{storage: s} var pID sql.NullInt64 var isDirInt int var mtimeUnix int64 var flag string err := s.db.QueryRow( `SELECT id, parent_id, name, is_dir, size, usage, mtime, item_count, mli, flag FROM items WHERE parent_id = ? AND name = ? LIMIT 1`, parentID, name, ).Scan( &item.id, &pID, &item.name, &isDirInt, &item.size, &item.usage, &mtimeUnix, &item.itemCount, &item.mli, &flag, ) if err != nil { return nil, err } if pID.Valid { item.parentID = &pID.Int64 } item.isDir = isDirInt == 1 item.mtime = time.Unix(mtimeUnix, 0) if flag != "" { item.flag = rune(flag[0]) } else { item.flag = ' ' } return item, nil } // GetChildren returns all children of a given parent ID func (s *SqliteStorage) GetChildren(parentID int64) ([]*SqliteItem, error) { s.m.RLock() defer s.m.RUnlock() rows, err := s.db.Query( `SELECT id, parent_id, name, is_dir, size, usage, mtime, item_count, mli, flag FROM items WHERE parent_id = ?`, parentID, ) if err != nil { return nil, err } defer rows.Close() var items []*SqliteItem for rows.Next() { item := &SqliteItem{storage: s} var parentID sql.NullInt64 var isDirInt int var mtimeUnix int64 var flag string err := rows.Scan( &item.id, &parentID, &item.name, &isDirInt, &item.size, &item.usage, &mtimeUnix, &item.itemCount, &item.mli, &flag, ) if err != nil { return nil, err } if parentID.Valid { item.parentID = &parentID.Int64 } item.isDir = isDirInt == 1 item.mtime = time.Unix(mtimeUnix, 0) if flag != "" { item.flag = rune(flag[0]) } else { item.flag = ' ' } items = append(items, item) } return items, rows.Err() } // GetItemByID returns an item by its ID func (s *SqliteStorage) GetItemByID(id int64) (*SqliteItem, error) { s.m.RLock() defer s.m.RUnlock() item := &SqliteItem{storage: s} var parentID sql.NullInt64 var isDirInt int var mtimeUnix int64 var flag string err := s.db.QueryRow( `SELECT id, parent_id, name, is_dir, size, usage, mtime, item_count, mli, flag FROM items WHERE id = ?`, id, ).Scan( &item.id, &parentID, &item.name, &isDirInt, &item.size, &item.usage, &mtimeUnix, &item.itemCount, &item.mli, &flag, ) if err != nil { return nil, err } if parentID.Valid { item.parentID = &parentID.Int64 } item.isDir = isDirInt == 1 item.mtime = time.Unix(mtimeUnix, 0) if flag != "" { item.flag = rune(flag[0]) } else { item.flag = ' ' } return item, nil } // SetMetadata stores a metadata key-value pair func (s *SqliteStorage) SetMetadata(key, value string) error { s.m.Lock() defer s.m.Unlock() _, err := s.db.Exec( `INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)`, key, value, ) return err } // GetMetadata retrieves a metadata value by key func (s *SqliteStorage) GetMetadata(key string) (string, error) { s.m.RLock() defer s.m.RUnlock() var value string err := s.db.QueryRow(`SELECT value FROM metadata WHERE key = ?`, key).Scan(&value) return value, err } // SqliteItem represents a file or directory stored in SQLite type SqliteItem struct { storage *SqliteStorage id int64 parentID *int64 name string isDir bool size int64 usage int64 mtime time.Time itemCount int64 mli uint64 flag rune parent fs.Item m sync.RWMutex } // GetPath returns the full path of the item func (i *SqliteItem) GetPath() string { parent := i.GetParent() if parent != nil { return filepath.Join(parent.GetPath(), i.name) } // For root item, get basePath from metadata basePath, err := i.storage.GetMetadata("top_dir_path") if err != nil { return i.name } return filepath.Join(filepath.Dir(basePath), i.name) } // GetName returns the name of the item func (i *SqliteItem) GetName() string { return i.name } // GetFlag returns the flag of the item func (i *SqliteItem) GetFlag() rune { return i.flag } // IsDir returns true if the item is a directory func (i *SqliteItem) IsDir() bool { return i.isDir } // GetSize returns the apparent size func (i *SqliteItem) GetSize() int64 { i.m.RLock() defer i.m.RUnlock() return i.size } // GetType returns the type of the item func (i *SqliteItem) GetType() string { if i.isDir { return "Directory" } if i.flag == '@' { return "Other" } return "File" } // GetUsage returns the disk usage func (i *SqliteItem) GetUsage() int64 { i.m.RLock() defer i.m.RUnlock() return i.usage } // GetMtime returns the modification time func (i *SqliteItem) GetMtime() time.Time { i.m.RLock() defer i.m.RUnlock() return i.mtime } // GetItemCount returns the item count func (i *SqliteItem) GetItemCount() int64 { i.m.RLock() defer i.m.RUnlock() return i.itemCount } // GetParent returns the parent item func (i *SqliteItem) GetParent() fs.Item { i.m.RLock() if i.parent != nil { defer i.m.RUnlock() return i.parent } if i.parentID == nil { defer i.m.RUnlock() return nil } i.m.RUnlock() parent, err := i.storage.GetItemByID(*i.parentID) if err != nil { log.Print(err.Error()) return nil } i.m.Lock() defer i.m.Unlock() if i.parent == nil { i.parent = parent } return i.parent } // SetParent sets the parent item func (i *SqliteItem) SetParent(parent fs.Item) { i.parent = parent } // GetParentLocked returns the in-memory parent without hitting the database. // Used inside RemoveFile where storage.m is already write-locked. func (i *SqliteItem) GetParentLocked() *SqliteItem { if i.parent != nil { return i.parent.(*SqliteItem) } return nil } // GetMultiLinkedInode returns the multi-linked inode number func (i *SqliteItem) GetMultiLinkedInode() uint64 { return i.mli } // EncodeJSON encodes the item to JSON func (i *SqliteItem) EncodeJSON(writer io.Writer, topLevel bool) error { if i.isDir { return i.encodeDirJSON(writer, topLevel) } return i.encodeFileJSON(writer) } func (i *SqliteItem) encodeDirJSON(writer io.Writer, topLevel bool) error { buff := make([]byte, 0, 128) buff = append(buff, []byte(`[{"name":`)...) name := i.GetName() if topLevel { name = i.GetPath() } if err := addSqliteString(&buff, name); err != nil { return err } if i.GetSize() > 0 { buff = append(buff, []byte(`,"asize":`)...) buff = append(buff, []byte(strconv.FormatInt(i.GetSize(), 10))...) } if i.GetUsage() > 0 { buff = append(buff, []byte(`,"dsize":`)...) buff = append(buff, []byte(strconv.FormatInt(i.GetUsage(), 10))...) } if !i.GetMtime().IsZero() { buff = append(buff, []byte(`,"mtime":`)...) buff = append(buff, []byte(strconv.FormatInt(i.GetMtime().Unix(), 10))...) } buff = append(buff, '}') children, err := i.storage.GetChildren(i.id) if err != nil { return err } if len(children) > 0 { buff = append(buff, ',') } buff = append(buff, '\n') if _, err := writer.Write(buff); err != nil { return err } for idx, child := range children { if idx > 0 { if _, err := writer.Write([]byte(",\n")); err != nil { return err } } child.parent = i if err := child.EncodeJSON(writer, false); err != nil { return err } } if _, err := writer.Write([]byte("]")); err != nil { return err } return nil } func (i *SqliteItem) encodeFileJSON(writer io.Writer) error { buff := make([]byte, 0, 128) buff = append(buff, []byte(`{"name":`)...) if err := addSqliteString(&buff, i.GetName()); err != nil { return err } if i.GetSize() > 0 { buff = append(buff, []byte(`,"asize":`)...) buff = append(buff, []byte(strconv.FormatInt(i.GetSize(), 10))...) } if i.GetUsage() > 0 { buff = append(buff, []byte(`,"dsize":`)...) buff = append(buff, []byte(strconv.FormatInt(i.GetUsage(), 10))...) } if !i.GetMtime().IsZero() { buff = append(buff, []byte(`,"mtime":`)...) buff = append(buff, []byte(strconv.FormatInt(i.GetMtime().Unix(), 10))...) } if i.flag == '@' { buff = append(buff, []byte(`,"notreg":true`)...) } if i.flag == 'H' { buff = append(buff, []byte(`,"ino":`+strconv.FormatUint(i.mli, 10)+`,"hlnkc":true`)...) } buff = append(buff, '}') if _, err := writer.Write(buff); err != nil { return err } return nil } func addSqliteString(buff *[]byte, val string) error { b, err := json.Marshal(val) if err != nil { return err } *buff = append(*buff, b...) return nil } // GetItemStats returns item statistics - hard links already handled during scan func (i *SqliteItem) GetItemStats(linkedItems fs.HardLinkedItems) (itemCount, size, usage int64) { return i.itemCount, i.size, i.usage } // UpdateStats is a no-op for SqliteItem - hard links are handled during scan func (i *SqliteItem) UpdateStats(linkedItems fs.HardLinkedItems) { } // AddFile adds a child file (no-op for SQLite items - children are in DB) func (i *SqliteItem) AddFile(item fs.Item) { // Children are stored in database via parent_id relationship } // GetFiles returns children as a sorted iterator func (i *SqliteItem) GetFiles(sortBy fs.SortBy, order fs.SortOrder) iter.Seq[fs.Item] { return func(yield func(fs.Item) bool) { children, err := i.storage.GetChildren(i.id) if err != nil { log.Print(err.Error()) return } // Convert to fs.Files for sorting files := make(fs.Files, len(children)) for idx, child := range children { child.parent = i files[idx] = child } sortFiles(files, sortBy, order) for _, item := range files { if !yield(item) { return } } } } // GetFilesLocked returns children with locking func (i *SqliteItem) GetFilesLocked(sortBy fs.SortBy, order fs.SortOrder) iter.Seq[fs.Item] { return i.GetFiles(sortBy, order) } // RemoveFile removes a child file, updating both in-memory stats and the database in one pass. func (i *SqliteItem) RemoveFile(item fs.Item) { sqliteItem := item.(*SqliteItem) size := item.GetSize() usage := item.GetUsage() itemCount := item.GetItemCount() // Apply in-memory stats updates for loaded ancestors first, // to avoid double-counting if ancestors are loaded from DB after it's updated. cur := i for { cur.m.Lock() cur.size -= size cur.usage -= usage cur.itemCount -= itemCount cur.m.Unlock() parent := cur.GetParentLocked() if parent == nil { break } cur = parent } if err := i.storage.RemoveItemAndUpdateStats(sqliteItem.id, size, usage, itemCount); err != nil { log.Errorf("Error removing item and updating stats: %v", err) return } } // RemoveFileByName removes a child by name func (i *SqliteItem) RemoveFileByName(name string) { child, err := i.storage.GetChildByName(i.id, name) if err != nil { log.Errorf("Error getting child from database: %v", err) return } i.RemoveFile(child) } // RLock returns a no-op unlock function func (i *SqliteItem) RLock() func() { i.m.RLock() return i.m.RUnlock } // SqliteAnalyzer implements Analyzer using SQLite storage type SqliteAnalyzer struct { BaseAnalyzer storage *SqliteStorage } // CreateSqliteAnalyzer creates a new SQLite analyzer func CreateSqliteAnalyzer(dbPath string) (*SqliteAnalyzer, error) { if err := checkAvailable(); err != nil { return nil, err } storage, err := NewSqliteStorage(dbPath) if err != nil { return nil, err } a := &SqliteAnalyzer{ storage: storage, } a.Init() return a, nil } // AnalyzeDir analyzes the given path and stores results in SQLite. // If the database already contains data, it loads from the database instead of re-scanning. func (a *SqliteAnalyzer) AnalyzeDir( path string, ignore common.ShouldDirBeIgnored, fileTypeFilter common.ShouldFileBeIgnored, ) fs.Item { // Check if database already has data if a.storage.HasData() { log.Printf("Loading analysis from existing SQLite database") rootItem, err := a.storage.GetRootItem() if err != nil { log.Printf("Error loading from database, will re-scan: %v", err) } else { // Signal that we're done immediately a.doneChan.Broadcast() return rootItem } } a.ignoreDir = ignore a.ignoreFileType = fileTypeFilter // Clear existing data and store metadata err := a.storage.ClearItems() if err != nil { log.Printf("Error clearing items: %v", err) } err = a.storage.SetMetadata("top_dir_path", path) if err != nil { log.Printf("Error setting metadata: %v", err) } // Start bulk insert transaction if err := a.storage.BeginBulkInsert(); err != nil { log.Printf("Error starting bulk insert: %v", err) } go a.UpdateProgress() // Process directory and get the root item rootItem := a.processDir(path, nil) a.wait.Wait() // Commit bulk insert transaction if err := a.storage.EndBulkInsert(); err != nil { log.Printf("Error committing bulk insert: %v", err) } a.progressDoneChan <- struct{}{} a.doneChan.Broadcast() return rootItem } // fileStat holds stats for a single file entry computed by processFile. type fileStat struct { size int64 usage int64 itemCount int64 mli uint64 flag rune // archiveDir is non-nil when the file is an archive that was expanded. archiveDir *Dir } // processArchiveEntry tries to expand name/entryPath as a zip or tar archive. // Returns the archive *Dir on success, or nil on failure (in which case err is set). func (a *SqliteAnalyzer) processArchiveEntry(entryPath, name string, info os.FileInfo) (*Dir, error) { if isZipFile(name) { archiveDirZip, errZip := processZipFile(entryPath, info) if errZip != nil { return nil, errZip } uncompressedSize, compressedSize, errSize := getZipFileSize(entryPath) if errSize == nil { archiveDirZip.Size = uncompressedSize archiveDirZip.Usage = compressedSize } return archiveDirZip.Dir, nil } archiveDirTar, errTar := processTarFile(entryPath, info) if errTar != nil { return nil, errTar } return archiveDirTar.Dir, nil } // processFile resolves a single non-directory entry and returns its stats. // It returns nil when the file should be skipped entirely. func (a *SqliteAnalyzer) processFile(entryPath, name string, f os.DirEntry) (stat fileStat) { if a.ignoreFileType != nil && a.ignoreFileType(name) { return stat } info, err := f.Info() if err != nil { log.Print(err.Error()) return stat } if a.followSymlinks && info.Mode()&os.ModeSymlink != 0 { infoF, err := followSymlink(entryPath, a.gitAnnexedSize) if err != nil { log.Print(err.Error()) return stat } if infoF != nil { info = infoF } } if a.matchesTimeFilterFn != nil && !a.matchesTimeFilterFn(info.ModTime()) { return stat } if a.archiveBrowsing && (isZipFile(name) || isTarFile(name)) { archiveDir, err := a.processArchiveEntry(entryPath, name, info) if err != nil { log.Printf("Failed to process archive %s: %v", entryPath, err) } else { return fileStat{ size: archiveDir.Size, usage: archiveDir.Usage, itemCount: archiveDir.ItemCount, flag: archiveDir.Flag, archiveDir: archiveDir, } } } fileSize := info.Size() fileUsage, fileMli := getSyscallStats(info) fileFlag := getFlag(info) if fileMli != 0 && a.storage.HasInode(fileMli) { fileSize = 0 fileUsage = 0 fileFlag = 'H' } return fileStat{ size: fileSize, usage: fileUsage, mli: fileMli, flag: fileFlag, } } // nolint:funlen func (a *SqliteAnalyzer) processDir(path string, parentID *int64) *SqliteItem { // Start with 4096 for directory's own size/usage, matching Dir.UpdateStats behavior var ( totalSize int64 = 4096 totalUsage int64 = 4096 filesSize int64 // only files in this directory, for progress reporting itemCount int64 = 1 ) a.wait.Add(1) defer a.wait.Done() files, err := os.ReadDir(path) if err != nil { log.Print(err.Error()) } // Get directory info for mtime dirInfo, err := os.Stat(path) var dirMtime time.Time if err == nil { dirMtime = dirInfo.ModTime() } // Insert directory into database (size/usage will be updated later) dirID, err := a.storage.InsertItem( parentID, filepath.Base(path), true, 0, // size will be updated later 0, // usage will be updated later dirMtime, 1, // item_count will be updated later 0, getDirFlag(err, len(files)), ) if err != nil { log.Print(err.Error()) return nil } // Process children for _, f := range files { name := f.Name() entryPath := filepath.Join(path, name) if f.IsDir() { if a.ignoreDir(name, entryPath) { continue } // Process subdirectory recursively subItem := a.processDir(entryPath, &dirID) if subItem != nil { totalSize += subItem.size totalUsage += subItem.usage itemCount += subItem.itemCount } continue } info, err := f.Info() if err != nil { log.Print(err.Error()) continue } stat := a.processFile(entryPath, name, f) if stat == (fileStat{}) { continue } if stat.archiveDir != nil { archiveID, err := a.storage.InsertItem( &dirID, name, true, stat.archiveDir.Size, stat.archiveDir.Usage, info.ModTime(), stat.archiveDir.ItemCount, 0, stat.archiveDir.Flag, ) if err != nil { log.Print(err.Error()) continue } a.persistArchive(stat.archiveDir, archiveID) totalSize += stat.size totalUsage += stat.usage filesSize += stat.usage itemCount += stat.itemCount continue } _, err = a.storage.InsertItem( &dirID, name, false, stat.size, stat.usage, info.ModTime(), 1, stat.mli, stat.flag, ) if err != nil { log.Print(err.Error()) continue } totalSize += stat.size totalUsage += stat.usage filesSize += stat.usage itemCount++ } // Update directory with computed stats err = a.storage.UpdateItem(dirID, totalSize, totalUsage, itemCount) if err != nil { log.Printf("Error updating item: %v", err) } // Report progress (only files in this dir, subdirs already reported themselves) a.progressCurrentItemName.Store(path) a.progressItemCount.Add(int64(len(files))) a.progressTotalUsage.Add(filesSize) // Return SqliteItem for the directory return &SqliteItem{ storage: a.storage, id: dirID, parentID: parentID, name: filepath.Base(path), isDir: true, size: totalSize, usage: totalUsage, mtime: dirMtime, itemCount: itemCount, flag: getDirFlag(err, len(files)), } } func (a *SqliteAnalyzer) persistArchive(archiveDir *Dir, parentID int64) { if archiveDir == nil { return } for _, f := range archiveDir.Files { if f.IsDir() { var subDir *Dir switch v := f.(type) { case *ZipDir: subDir = v.Dir case *TarDir: subDir = v.Dir } if subDir == nil { continue } id, err := a.storage.InsertItem( &parentID, f.GetName(), true, f.GetSize(), f.GetUsage(), f.GetMtime(), f.GetItemCount(), 0, f.GetFlag(), ) if err != nil { log.Print(err.Error()) continue } a.persistArchive(subDir, id) } else { _, err := a.storage.InsertItem( &parentID, f.GetName(), false, f.GetSize(), f.GetUsage(), f.GetMtime(), 1, f.GetMultiLinkedInode(), f.GetFlag(), ) if err != nil { log.Print(err.Error()) continue } } } } gdu-5.36.1/pkg/analyze/sqlite_modernc.go000066400000000000000000000006361517447455500201760ustar00rootroot00000000000000//go:build (linux && !mips64 && !mipsle && !mips && !mips64le && !ppc64) || darwin || windows || (freebsd && !arm && !386) || (openbsd && !386) || (netbsd && !arm && !386 && !amd64) package analyze import ( // nolint:revive // Why: importing SQLite driver for side effects _ "modernc.org/sqlite" ) // checkAvailable checks if the modernc SQLite driver is available func checkAvailable() error { return nil } gdu-5.36.1/pkg/analyze/sqlite_other.go000066400000000000000000000006101517447455500176600ustar00rootroot00000000000000//go:build (linux && (mips64 || mipsle || mips || mips64le || ppc64)) || (freebsd && (arm || 386)) || (openbsd && 386) || (netbsd && (arm || 386 || amd64)) package analyze import "errors" // checkAvailable reports that the modernc SQLite driver is not available on this platform func checkAvailable() error { return errors.New("modernc SQLite driver is not available on this platform") } gdu-5.36.1/pkg/analyze/sqlite_test.go000066400000000000000000000727611517447455500175360ustar00rootroot00000000000000//go:build (linux && !mips64 && !mipsle && !mips && !mips64le && !ppc64) || darwin || windows || (freebsd && !arm && !386) || (openbsd && !386) || (netbsd && !arm && !386 && !amd64) package analyze import ( "bytes" "os" "os/exec" "path/filepath" "slices" "testing" "time" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func TestNewSqliteStorage(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) assert.NotNil(t, storage) defer storage.Close() // Test that the database is created _, err = os.Stat(dbPath) assert.NoError(t, err) } func TestNewSqliteStorageNestedDir(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "nested", "dir", "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) assert.NotNil(t, storage) defer storage.Close() // Test that the database is created _, err = os.Stat(dbPath) assert.NoError(t, err) } func TestSqliteStorageClose(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) err = storage.Close() assert.NoError(t, err) // Closing again should not error err = storage.Close() assert.NoError(t, err) } func TestSqliteStorageHasData(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() // Initially no data assert.False(t, storage.HasData()) // Insert an item _, err = storage.InsertItem(nil, "root", true, 100, 100, time.Now(), 1, 0, ' ') assert.NoError(t, err) // Now has data assert.True(t, storage.HasData()) } func TestSqliteStorageClearItems(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() // Insert an item _, err = storage.InsertItem(nil, "root", true, 100, 100, time.Now(), 1, 0, ' ') assert.NoError(t, err) assert.True(t, storage.HasData()) // Clear items err = storage.ClearItems() assert.NoError(t, err) assert.False(t, storage.HasData()) } func TestSqliteStorageMetadata(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() // Set metadata err = storage.SetMetadata("key1", "value1") assert.NoError(t, err) // Get metadata value, err := storage.GetMetadata("key1") assert.NoError(t, err) assert.Equal(t, "value1", value) // Update metadata err = storage.SetMetadata("key1", "value2") assert.NoError(t, err) value, err = storage.GetMetadata("key1") assert.NoError(t, err) assert.Equal(t, "value2", value) // Get non-existent metadata _, err = storage.GetMetadata("nonexistent") assert.Error(t, err) } func TestSqliteStorageInsertAndGetItem(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() mtime := time.Now().Truncate(time.Second) // Insert root directory rootID, err := storage.InsertItem(nil, "root", true, 1000, 2000, mtime, 5, 0, ' ') assert.NoError(t, err) assert.Greater(t, rootID, int64(0)) // Get root item root, err := storage.GetRootItem() assert.NoError(t, err) assert.Equal(t, "root", root.GetName()) assert.True(t, root.IsDir()) assert.Equal(t, int64(1000), root.GetSize()) assert.Equal(t, int64(2000), root.GetUsage()) assert.Equal(t, int64(5), root.GetItemCount()) assert.Equal(t, ' ', root.GetFlag()) assert.Equal(t, mtime, root.GetMtime()) } func TestSqliteStorageInsertAndGetChildren(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() mtime := time.Now().Truncate(time.Second) // Insert root rootID, err := storage.InsertItem(nil, "root", true, 0, 0, mtime, 1, 0, ' ') assert.NoError(t, err) // Insert children _, err = storage.InsertItem(&rootID, "file1.txt", false, 100, 4096, mtime, 1, 0, ' ') assert.NoError(t, err) _, err = storage.InsertItem(&rootID, "file2.txt", false, 200, 4096, mtime, 1, 12345, 'H') assert.NoError(t, err) _, err = storage.InsertItem(&rootID, "subdir", true, 500, 8192, mtime, 3, 0, ' ') assert.NoError(t, err) // Get children children, err := storage.GetChildren(rootID) assert.NoError(t, err) assert.Len(t, children, 3) // Verify children names names := make([]string, len(children)) for i, child := range children { names[i] = child.GetName() } assert.Contains(t, names, "file1.txt") assert.Contains(t, names, "file2.txt") assert.Contains(t, names, "subdir") } func TestSqliteStorageUpdateItem(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() // Insert item id, err := storage.InsertItem(nil, "dir", true, 100, 200, time.Now(), 1, 0, ' ') assert.NoError(t, err) // Update item err = storage.UpdateItem(id, 500, 1000, 10) assert.NoError(t, err) // Verify update item, err := storage.GetItemByID(id) assert.NoError(t, err) assert.Equal(t, int64(500), item.GetSize()) assert.Equal(t, int64(1000), item.GetUsage()) assert.Equal(t, int64(10), item.GetItemCount()) } func TestSqliteStorageBulkInsert(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() // Begin bulk insert err = storage.BeginBulkInsert() assert.NoError(t, err) // Insert many items rootID, err := storage.InsertItem(nil, "root", true, 0, 0, time.Now(), 1, 0, ' ') assert.NoError(t, err) for i := 0; i < 100; i++ { _, err = storage.InsertItem(&rootID, "file", false, 100, 4096, time.Now(), 1, 0, ' ') assert.NoError(t, err) } // Update during bulk mode err = storage.UpdateItem(rootID, 10000, 20000, 101) assert.NoError(t, err) // End bulk insert err = storage.EndBulkInsert() assert.NoError(t, err) // Verify children, err := storage.GetChildren(rootID) assert.NoError(t, err) assert.Len(t, children, 100) } func TestSqliteStorageHasInode(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() // No inode initially assert.False(t, storage.HasInode(12345)) // Insert item with inode _, err = storage.InsertItem(nil, "file", false, 100, 4096, time.Now(), 1, 12345, 'H') assert.NoError(t, err) // Now inode exists assert.True(t, storage.HasInode(12345)) assert.False(t, storage.HasInode(99999)) } func TestSqliteStorageHasInodeBulkMode(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() err = storage.BeginBulkInsert() assert.NoError(t, err) // Insert item with inode in bulk mode _, err = storage.InsertItem(nil, "file", false, 100, 4096, time.Now(), 1, 12345, 'H') assert.NoError(t, err) // Check inode during bulk mode (uses prepared statement) assert.True(t, storage.HasInode(12345)) assert.False(t, storage.HasInode(99999)) err = storage.EndBulkInsert() assert.NoError(t, err) } func TestSqliteItemGetPath(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() // Set up metadata for path resolution err = storage.SetMetadata("top_dir_path", "/home/user/testdir") assert.NoError(t, err) // Insert root rootID, err := storage.InsertItem(nil, "testdir", true, 0, 0, time.Now(), 1, 0, ' ') assert.NoError(t, err) // Insert child childID, err := storage.InsertItem(&rootID, "file.txt", false, 100, 4096, time.Now(), 1, 0, ' ') assert.NoError(t, err) // Get root item root, err := storage.GetItemByID(rootID) assert.NoError(t, err) assert.Equal(t, "/home/user/testdir", root.GetPath()) // Get child and set parent child, err := storage.GetItemByID(childID) assert.NoError(t, err) child.SetParent(root) assert.Equal(t, "/home/user/testdir/file.txt", child.GetPath()) } func TestSqliteItemGetPathLazy(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() // Set up metadata for path resolution err = storage.SetMetadata("top_dir_path", "/home/user/testdir") assert.NoError(t, err) // Insert root rootID, err := storage.InsertItem(nil, "testdir", true, 0, 0, time.Now(), 1, 0, ' ') assert.NoError(t, err) // Insert child childID, err := storage.InsertItem(&rootID, "file.txt", false, 100, 4096, time.Now(), 1, 0, ' ') assert.NoError(t, err) // Get child without explicitly loading parent child, err := storage.GetItemByID(childID) assert.NoError(t, err) // GetPath should lazy load parent and reconstruct correct path assert.Equal(t, "/home/user/testdir/file.txt", child.GetPath()) } func TestSqliteItemGetType(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() // Directory dirID, err := storage.InsertItem(nil, "dir", true, 0, 0, time.Now(), 1, 0, ' ') assert.NoError(t, err) dir, _ := storage.GetItemByID(dirID) assert.Equal(t, "Directory", dir.GetType()) // File fileID, err := storage.InsertItem(nil, "file", false, 100, 4096, time.Now(), 1, 0, ' ') assert.NoError(t, err) file, _ := storage.GetItemByID(fileID) assert.Equal(t, "File", file.GetType()) // Other (symlink flag) otherID, err := storage.InsertItem(nil, "symlink", false, 100, 4096, time.Now(), 1, 0, '@') assert.NoError(t, err) other, _ := storage.GetItemByID(otherID) assert.Equal(t, "Other", other.GetType()) } func TestSqliteItemGetParent(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() // Insert root and child rootID, err := storage.InsertItem(nil, "root", true, 0, 0, time.Now(), 1, 0, ' ') assert.NoError(t, err) childID, err := storage.InsertItem(&rootID, "child", false, 100, 4096, time.Now(), 1, 0, ' ') assert.NoError(t, err) // Get child child, err := storage.GetItemByID(childID) assert.NoError(t, err) // Get parent (lazy loaded) parent := child.GetParent() assert.NotNil(t, parent) assert.Equal(t, "root", parent.GetName()) // Second call should use cached parent parent2 := child.GetParent() assert.Equal(t, parent, parent2) // Root item has no parent root, err := storage.GetItemByID(rootID) assert.NoError(t, err) assert.Nil(t, root.GetParent()) } func TestSqliteItemGetMultiLinkedInode(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() // Insert item with inode id, err := storage.InsertItem(nil, "file", false, 100, 4096, time.Now(), 1, 12345, 'H') assert.NoError(t, err) item, err := storage.GetItemByID(id) assert.NoError(t, err) assert.Equal(t, uint64(12345), item.GetMultiLinkedInode()) } func TestSqliteItemGetFiles(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() // Insert root and children with different usages (SortBySize sorts by usage) rootID, err := storage.InsertItem(nil, "root", true, 0, 0, time.Now(), 1, 0, ' ') assert.NoError(t, err) _, err = storage.InsertItem(&rootID, "small.txt", false, 100, 1000, time.Now(), 1, 0, ' ') assert.NoError(t, err) _, err = storage.InsertItem(&rootID, "large.txt", false, 1000, 9000, time.Now(), 1, 0, ' ') assert.NoError(t, err) _, err = storage.InsertItem(&rootID, "medium.txt", false, 500, 5000, time.Now(), 1, 0, ' ') assert.NoError(t, err) root, err := storage.GetItemByID(rootID) assert.NoError(t, err) // Sort by name ascending (alphabetical) files := slices.Collect(root.GetFiles(fs.SortByName, fs.SortAsc)) assert.Len(t, files, 3) assert.Equal(t, "large.txt", files[0].GetName()) assert.Equal(t, "medium.txt", files[1].GetName()) assert.Equal(t, "small.txt", files[2].GetName()) // Sort by size descending (largest usage first) files = slices.Collect(root.GetFiles(fs.SortBySize, fs.SortDesc)) assert.Len(t, files, 3) assert.Equal(t, "large.txt", files[0].GetName()) assert.Equal(t, "medium.txt", files[1].GetName()) assert.Equal(t, "small.txt", files[2].GetName()) } func TestSqliteItemGetFilesLocked(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() rootID, err := storage.InsertItem(nil, "root", true, 0, 0, time.Now(), 1, 0, ' ') assert.NoError(t, err) _, err = storage.InsertItem(&rootID, "file.txt", false, 100, 4096, time.Now(), 1, 0, ' ') assert.NoError(t, err) root, err := storage.GetItemByID(rootID) assert.NoError(t, err) // GetFilesLocked should work same as GetFiles files := slices.Collect(root.GetFilesLocked(fs.SortByName, fs.SortAsc)) assert.Len(t, files, 1) assert.Equal(t, "file.txt", files[0].GetName()) } func TestSqliteItemRLock(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() id, err := storage.InsertItem(nil, "root", true, 0, 0, time.Now(), 1, 0, ' ') assert.NoError(t, err) item, err := storage.GetItemByID(id) assert.NoError(t, err) // RLock should return unlock function unlock := item.RLock() assert.NotNil(t, unlock) unlock() } func TestSqliteItemGetItemStats(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() id, err := storage.InsertItem(nil, "dir", true, 1000, 2000, time.Now(), 5, 0, ' ') assert.NoError(t, err) item, err := storage.GetItemByID(id) assert.NoError(t, err) count, size, usage := item.GetItemStats(make(fs.HardLinkedItems)) assert.Equal(t, int64(5), count) assert.Equal(t, int64(1000), size) assert.Equal(t, int64(2000), usage) } func TestSqliteItemUpdateStats(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() id, err := storage.InsertItem(nil, "dir", true, 1000, 2000, time.Now(), 5, 0, ' ') assert.NoError(t, err) item, err := storage.GetItemByID(id) assert.NoError(t, err) // UpdateStats is a no-op for SqliteItem item.UpdateStats(make(fs.HardLinkedItems)) // Just verify it doesn't panic } func TestSqliteItemAddFile(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() id, err := storage.InsertItem(nil, "dir", true, 0, 0, time.Now(), 1, 0, ' ') assert.NoError(t, err) item, err := storage.GetItemByID(id) assert.NoError(t, err) // AddFile is a no-op for SqliteItem item.AddFile(nil) // Just verify it doesn't panic } func TestSqliteItemRemoveFile(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() // root -> subdir -> file rootID, _ := storage.InsertItem(nil, "root", true, 5000, 10000, time.Now(), 3, 0, ' ') subdirID, _ := storage.InsertItem(&rootID, "subdir", true, 2000, 5000, time.Now(), 2, 0, ' ') fileID, _ := storage.InsertItem(&subdirID, "file.txt", false, 1000, 4000, time.Now(), 1, 0, ' ') root, _ := storage.GetItemByID(rootID) subdir, _ := storage.GetItemByID(subdirID) file, _ := storage.GetItemByID(fileID) subdir.SetParent(root) file.SetParent(subdir) // Remove file from subdir subdir.RemoveFile(file) // Check local stats for subdir assert.Equal(t, int64(1000), subdir.GetSize()) assert.Equal(t, int64(1000), subdir.GetUsage()) assert.Equal(t, int64(1), subdir.GetItemCount()) // Check memory-state propagation for root assert.Equal(t, int64(4000), root.GetSize()) assert.Equal(t, int64(6000), root.GetUsage()) assert.Equal(t, int64(2), root.GetItemCount()) // Check database stats for root rootFromDB, _ := storage.GetItemByID(rootID) assert.Equal(t, int64(4000), rootFromDB.GetSize()) assert.Equal(t, int64(6000), rootFromDB.GetUsage()) assert.Equal(t, int64(2), rootFromDB.GetItemCount()) // Check that file is gone from DB _, err = storage.GetItemByID(fileID) assert.Error(t, err) } func TestSqliteItemRemoveFileByName(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() rootID, _ := storage.InsertItem(nil, "root", true, 100, 200, time.Now(), 2, 0, ' ') storage.InsertItem(&rootID, "file.txt", false, 10, 20, time.Now(), 1, 0, ' ') root, _ := storage.GetItemByID(rootID) root.RemoveFileByName("file.txt") assert.Equal(t, int64(90), root.GetSize()) assert.Equal(t, int64(180), root.GetUsage()) assert.Equal(t, int64(1), root.GetItemCount()) children, _ := storage.GetChildren(rootID) assert.Empty(t, children) } func TestSqliteItemEncodeJSON(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") storage, err := NewSqliteStorage(dbPath) assert.NoError(t, err) defer storage.Close() mtime := time.Unix(1600000000, 0) rootID, _ := storage.InsertItem(nil, "root", true, 100, 200, mtime, 2, 0, ' ') storage.InsertItem(&rootID, "file.txt", false, 10, 20, mtime, 1, 0, ' ') root, _ := storage.GetItemByID(rootID) var buf bytes.Buffer err = root.EncodeJSON(&buf, false) assert.NoError(t, err) expected := `[{"name":"root","asize":100,"dsize":200,"mtime":1600000000}, {"name":"file.txt","asize":10,"dsize":20,"mtime":1600000000}]` assert.Equal(t, expected, buf.String()) } func TestCreateSqliteAnalyzer(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) assert.NotNil(t, analyzer) defer analyzer.storage.Close() } func TestSqliteAnalyzerSetFollowSymlinks(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() analyzer.SetFollowSymlinks(true) assert.True(t, analyzer.followSymlinks) analyzer.SetFollowSymlinks(false) assert.False(t, analyzer.followSymlinks) } func TestSqliteAnalyzerSetShowAnnexedSize(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() analyzer.SetShowAnnexedSize(true) assert.True(t, analyzer.gitAnnexedSize) analyzer.SetShowAnnexedSize(false) assert.False(t, analyzer.gitAnnexedSize) } func TestSqliteAnalyzerSetArchiveBrowsing(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() analyzer.SetArchiveBrowsing(true) assert.True(t, analyzer.archiveBrowsing) analyzer.SetArchiveBrowsing(false) assert.False(t, analyzer.archiveBrowsing) } func TestSqliteAnalyzerSetTimeFilter(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() filter := func(mtime time.Time) bool { return true } analyzer.SetTimeFilter(filter) assert.NotNil(t, analyzer.matchesTimeFilterFn) } func TestSqliteAnalyzerSetFileTypeFilter(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() filter := func(name string) bool { return false } analyzer.SetFileTypeFilter(filter) assert.NotNil(t, analyzer.ignoreFileType) } func TestSqliteAnalyzerGetProgress(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() progress := analyzer.GetProgress() assert.Equal(t, int64(0), progress.ItemCount) } func TestSqliteAnalyzerGetDone(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() doneChan := analyzer.GetDone() assert.NotNil(t, doneChan) } func TestSqliteAnalyzerArchiveBrowsing(t *testing.T) { fin := testdir.CreateTestDir() defer fin() // Create a zip file err := runInBash("zip -r test_dir/test.zip test_dir/nested") if err != nil { t.Skip("zip command not available") } dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() analyzer.SetArchiveBrowsing(true) dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*SqliteItem) analyzer.GetDone().Wait() // Find the zip file in the results files := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc)) var zipItem *SqliteItem for _, f := range files { if f.GetName() == "test.zip" { zipItem = f.(*SqliteItem) break } } assert.NotNil(t, zipItem) assert.True(t, zipItem.IsDir()) // It should be treated as a directory // Check zip contents zipFiles := slices.Collect(zipItem.GetFiles(fs.SortByName, fs.SortAsc)) assert.NotEmpty(t, zipFiles) } func runInBash(cmd string) error { const shell = "/bin/bash" return exec.Command(shell, "-c", cmd).Run() } func TestSqliteAnalyzerResetProgress(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() analyzer.ResetProgress() assert.NotNil(t, analyzer.progressOutChan) assert.NotNil(t, analyzer.doneChan) } func TestSqliteAnalyzerAnalyzeDir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*SqliteItem) analyzer.GetDone().Wait() // Test dir info assert.Equal(t, "test_dir", dir.GetName()) assert.True(t, dir.IsDir()) assert.Equal(t, int64(5), dir.GetItemCount()) // Size should include directory overhead + file sizes: 4096*3 + 7 bytes assert.Equal(t, int64(7+4096*3), dir.GetSize()) // Test dir tree files := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc)) assert.Equal(t, 1, len(files)) assert.Equal(t, "nested", files[0].GetName()) nested := files[0].(*SqliteItem) nestedFiles := slices.Collect(nested.GetFiles(fs.SortByName, fs.SortAsc)) assert.Equal(t, 2, len(nestedFiles)) assert.Equal(t, "file2", nestedFiles[0].GetName()) assert.Equal(t, "subnested", nestedFiles[1].GetName()) // Test file assert.Equal(t, int64(2), nestedFiles[0].GetSize()) subnested := nestedFiles[1].(*SqliteItem) subnestedFiles := slices.Collect(subnested.GetFiles(fs.SortByName, fs.SortAsc)) assert.Equal(t, "file", subnestedFiles[0].GetName()) assert.Equal(t, int64(5), subnestedFiles[0].GetSize()) } func TestSqliteAnalyzerIgnoreDir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return true }, func(_ string) bool { return false }, ).(*SqliteItem) analyzer.GetDone().Wait() assert.Equal(t, "test_dir", dir.GetName()) assert.Equal(t, int64(1), dir.GetItemCount()) } func TestSqliteAnalyzerIgnoreFileType(t *testing.T) { fin := testdir.CreateTestDir() defer fin() dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() // Ignore all files dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return true }, ).(*SqliteItem) analyzer.GetDone().Wait() // Only directories should remain assert.Equal(t, "test_dir", dir.GetName()) assert.Equal(t, int64(3), dir.GetItemCount()) // test_dir, nested, subnested } func TestSqliteAnalyzerHardlinks(t *testing.T) { fin := testdir.CreateTestDir() defer fin() // Create hard link err := os.Link("test_dir/nested/file2", "test_dir/nested/file3") assert.NoError(t, err) dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*SqliteItem) analyzer.GetDone().Wait() // file2 and file3 are counted just once for size but twice for item count assert.Equal(t, int64(7+4096*3), dir.GetSize()) assert.Equal(t, int64(6), dir.GetItemCount()) // Check hard link flag nested := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc))[0].(*SqliteItem) nestedFiles := slices.Collect(nested.GetFiles(fs.SortByName, fs.SortAsc)) var file3 *SqliteItem for _, f := range nestedFiles { if f.GetName() == "file3" { file3 = f.(*SqliteItem) break } } assert.NotNil(t, file3) assert.Equal(t, 'H', file3.GetFlag()) } func TestSqliteAnalyzerSymlink(t *testing.T) { fin := testdir.CreateTestDir() defer fin() // Create symlink err := os.Symlink("test_dir/nested/file2", "test_dir/nested/file3") assert.NoError(t, err) dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*SqliteItem) analyzer.GetDone().Wait() // Check symlink flag nested := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc))[0].(*SqliteItem) nestedFiles := slices.Collect(nested.GetFiles(fs.SortByName, fs.SortAsc)) var file3 *SqliteItem for _, f := range nestedFiles { if f.GetName() == "file3" { file3 = f.(*SqliteItem) break } } assert.NotNil(t, file3) assert.Equal(t, '@', file3.GetFlag()) } func TestSqliteAnalyzerFollowSymlink(t *testing.T) { fin := testdir.CreateTestDir() defer fin() // Create symlink to file2 err := os.Symlink("./file2", "test_dir/nested/file3") assert.NoError(t, err) dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() analyzer.SetFollowSymlinks(true) dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*SqliteItem) analyzer.GetDone().Wait() // With followSymlinks, file3 should have same size as file2 nested := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc))[0].(*SqliteItem) nestedFiles := slices.Collect(nested.GetFiles(fs.SortByName, fs.SortAsc)) var file3 *SqliteItem for _, f := range nestedFiles { if f.GetName() == "file3" { file3 = f.(*SqliteItem) break } } assert.NotNil(t, file3) assert.Equal(t, int64(2), file3.GetSize()) assert.Equal(t, ' ', file3.GetFlag()) // Not a symlink flag when followed } func TestSqliteAnalyzerTimeFilter(t *testing.T) { fin := testdir.CreateTestDir() defer fin() dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() // Filter out all files (mtime filter that always returns false) analyzer.SetTimeFilter(func(mtime time.Time) bool { return false }) dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*SqliteItem) analyzer.GetDone().Wait() // Only directories should remain assert.Equal(t, int64(3), dir.GetItemCount()) // test_dir, nested, subnested } func TestSqliteAnalyzerLoadFromExisting(t *testing.T) { fin := testdir.CreateTestDir() defer fin() dbPath := filepath.Join(t.TempDir(), "test.db") // First analysis analyzer1, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) dir1 := analyzer1.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*SqliteItem) analyzer1.GetDone().Wait() assert.Equal(t, "test_dir", dir1.GetName()) assert.Equal(t, int64(5), dir1.GetItemCount()) analyzer1.storage.Close() // Second analysis should load from existing data analyzer2, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer2.storage.Close() dir2 := analyzer2.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*SqliteItem) analyzer2.GetDone().Wait() assert.Equal(t, "test_dir", dir2.GetName()) assert.Equal(t, int64(5), dir2.GetItemCount()) } func TestSqliteAnalyzerProgress(t *testing.T) { fin := testdir.CreateTestDir() defer fin() dbPath := filepath.Join(t.TempDir(), "test.db") analyzer, err := CreateSqliteAnalyzer(dbPath) assert.NoError(t, err) defer analyzer.storage.Close() // Start analysis in goroutine go func() { analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) }() // Wait for analysis to make some progress time.Sleep(200 * time.Millisecond) progress := analyzer.GetProgress() assert.GreaterOrEqual(t, progress.TotalUsage, int64(0)) analyzer.GetDone().Wait() } func BenchmarkSqliteAnalyzeDir(b *testing.B) { fin := testdir.CreateTestDir() defer fin() for i := 0; i < b.N; i++ { dbPath := filepath.Join(b.TempDir(), "test.db") analyzer, _ := CreateSqliteAnalyzer(dbPath) analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) analyzer.GetDone().Wait() analyzer.storage.Close() } } gdu-5.36.1/pkg/analyze/storage.go000066400000000000000000000057711517447455500166370ustar00rootroot00000000000000package analyze import ( "bytes" "encoding/gob" "path/filepath" "sync" "github.com/dgraph-io/badger/v4" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/fs" "github.com/pkg/errors" ) func init() { gob.RegisterName("analyze.StoredDir", &StoredDir{}) gob.RegisterName("analyze.Dir", &Dir{}) gob.RegisterName("analyze.File", &File{}) gob.RegisterName("analyze.ParentDir", &ParentDir{}) } // DefaultStorage is a default instance of badger storage var DefaultStorage *Storage // Storage represents a badger storage type Storage struct { db *badger.DB storagePath string topDir string m sync.RWMutex counter int counterM sync.Mutex } // NewStorage returns new instance of badger storage func NewStorage(storagePath, topDir string) *Storage { st := &Storage{ storagePath: storagePath, topDir: topDir, } DefaultStorage = st return st } // GetTopDir returns top directory func (s *Storage) GetTopDir() string { return s.topDir } // IsOpen returns true if badger DB is open func (s *Storage) IsOpen() bool { s.m.RLock() defer s.m.RUnlock() return s.db != nil } // Open opens badger DB func (s *Storage) Open() func() { options := badger.DefaultOptions(s.storagePath) options.Logger = nil if !common.Is64Bit { // For 32-bit systems, we need to set ValueLogFileSize to a smaller value to // avoid "cannot allocate memory while mmapping" error options.ValueLogFileSize = (1<<30 - 1) / 2 } db, err := badger.Open(options) if err != nil { panic(err) } s.db = db return func() { s.db.Close() s.db = nil } } // StoreDir saves item info into badger DB func (s *Storage) StoreDir(dir fs.Item) error { s.checkCount() s.m.RLock() defer s.m.RUnlock() return s.db.Update(func(txn *badger.Txn) error { b := &bytes.Buffer{} enc := gob.NewEncoder(b) err := enc.Encode(dir) if err != nil { return errors.Wrap(err, "encoding dir value") } return txn.Set([]byte(dir.GetPath()), b.Bytes()) }) } // LoadDir saves item info into badger DB func (s *Storage) LoadDir(dir fs.Item) error { s.checkCount() s.m.RLock() defer s.m.RUnlock() return s.db.View(func(txn *badger.Txn) error { path := dir.GetPath() item, err := txn.Get([]byte(path)) if err != nil { return errors.Wrap(err, "reading stored value for path: "+path) } return item.Value(func(val []byte) error { b := bytes.NewBuffer(val) dec := gob.NewDecoder(b) return dec.Decode(dir) }) }) } // GetDirForPath returns Dir for given path func (s *Storage) GetDirForPath(path string) (item fs.Item, err error) { dirPath := filepath.Dir(path) name := filepath.Base(path) dir := &StoredDir{ &Dir{ File: &File{ Name: name, }, BasePath: dirPath, }, nil, sync.Mutex{}, } err = s.LoadDir(dir) if err != nil { return nil, err } return dir, nil } func (s *Storage) checkCount() { s.counterM.Lock() defer s.counterM.Unlock() s.counter++ if s.counter >= 10000 { s.m.Lock() defer s.m.Unlock() s.counter = 0 s.db.Close() s.Open() } } gdu-5.36.1/pkg/analyze/stored.go000066400000000000000000000247151517447455500164720ustar00rootroot00000000000000package analyze import ( "io" "iter" "os" "path/filepath" "sync" "time" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/fs" log "github.com/sirupsen/logrus" ) // StoredAnalyzer implements Analyzer type StoredAnalyzer struct { BaseAnalyzer storage *Storage storagePath string } // CreateStoredAnalyzer returns Analyzer func CreateStoredAnalyzer(storagePath string) *StoredAnalyzer { a := &StoredAnalyzer{ storagePath: storagePath, } a.Init() return a } // AnalyzeDir analyzes given path func (a *StoredAnalyzer) AnalyzeDir( path string, ignore common.ShouldDirBeIgnored, fileTypeFilter common.ShouldFileBeIgnored, ) fs.Item { a.ignoreDir = ignore a.ignoreFileType = fileTypeFilter a.storage = NewStorage(a.storagePath, path) closeFn := a.storage.Open() defer func() { // nasty hack to close storage after all goroutines are done // Wait returns immediately if value is 0 // few last goroutines might still start after that time.Sleep(1 * time.Second) closeFn() }() a.ignoreDir = ignore go a.UpdateProgress() dir := a.processDir(path) a.wait.Wait() a.progressDoneChan <- struct{}{} a.doneChan.Broadcast() return dir } func (a *StoredAnalyzer) processDir(path string) *StoredDir { var ( file fs.Item err error totalUsage int64 info os.FileInfo dirCount int ) a.wait.Add(1) files, err := os.ReadDir(path) if err != nil { log.Print(err.Error()) } dir := &StoredDir{ Dir: &Dir{ File: &File{ Name: filepath.Base(path), Flag: getDirFlag(err, len(files)), }, BasePath: filepath.Dir(path), ItemCount: 1, Files: make(fs.Files, 0, len(files)), }, } parent := &ParentDir{Path: path} setDirPlatformSpecificAttrs(dir.Dir, path) for _, f := range files { name := f.Name() entryPath := filepath.Join(path, name) if f.IsDir() { if a.ignoreDir(name, entryPath) { continue } dirCount++ subdir := &StoredDir{ &Dir{ File: &File{ Name: name, }, BasePath: path, }, nil, sync.Mutex{}, } dir.AddFile(subdir) go func(entryPath string) { concurrencyLimit <- struct{}{} a.processDir(entryPath) <-concurrencyLimit }(entryPath) } else { // Apply file type filter if set if a.ignoreFileType != nil && a.ignoreFileType(name) { continue // Skip this file } info, err = f.Info() if err != nil { log.Print(err.Error()) continue } if a.followSymlinks && info.Mode()&os.ModeSymlink != 0 { infoF, err := followSymlink(entryPath, a.gitAnnexedSize) if err != nil { log.Print(err.Error()) continue } if infoF != nil { info = infoF } } // Apply time filter if set if a.matchesTimeFilterFn != nil && !a.matchesTimeFilterFn(info.ModTime()) { continue // Skip this file } // Check if it's a zip or jar file if a.archiveBrowsing && isZipFile(name) { zipDir, err := processZipFile(entryPath, info) if err != nil { // If unable to process zip file, treat as regular file log.Printf("Failed to process zip file %s: %v", entryPath, err) file = &File{ Name: name, Flag: getFlag(info), Size: info.Size(), Parent: parent, } } else { // Successfully processed zip file, use zip content size uncompressedSize, compressedSize, err := getZipFileSize(entryPath) if err == nil { zipDir.Size = uncompressedSize zipDir.Usage = compressedSize } zipDir.Parent = parent file = zipDir } } else { file = &File{ Name: name, Flag: getFlag(info), Size: info.Size(), Parent: parent, } } if file != nil { // Only set platform-specific attributes for regular files if regularFile, ok := file.(*File); ok { setPlatformSpecificAttrs(regularFile, info) } totalUsage += file.GetUsage() dir.AddFile(file) } } } err = a.storage.StoreDir(dir) if err != nil { log.Print(err.Error()) } a.wait.Done() a.progressCurrentItemName.Store(path) a.progressItemCount.Add(int64(len(files))) a.progressTotalUsage.Add(totalUsage) return dir } // StoredDir implements Dir item stored on disk type StoredDir struct { *Dir cachedFiles fs.Files dbLock sync.Mutex } // GetParent returns parent dir func (f *StoredDir) GetParent() fs.Item { if DefaultStorage.GetTopDir() == f.GetPath() { return nil } if !DefaultStorage.IsOpen() { closeFn := DefaultStorage.Open() defer closeFn() } dir, err := DefaultStorage.GetDirForPath(f.BasePath) if err != nil { log.Print(err.Error()) } return dir } // GetFiles returns files in directory as a sorted iterator // If files are already cached, use them // Otherwise load them from storage func (f *StoredDir) GetFiles(sortBy fs.SortBy, order fs.SortOrder) iter.Seq[fs.Item] { return func(yield func(fs.Item) bool) { files := f.loadFiles() sortFiles(files, sortBy, order) for _, item := range files { if !yield(item) { return } } } } // loadFiles loads files from storage or returns cached files func (f *StoredDir) loadFiles() fs.Files { if f.cachedFiles != nil { // Return a copy to avoid modifying cached slice result := make(fs.Files, len(f.cachedFiles)) copy(result, f.cachedFiles) return result } if !DefaultStorage.IsOpen() { f.dbLock.Lock() defer f.dbLock.Unlock() closeFn := DefaultStorage.Open() defer closeFn() } var files fs.Files for _, file := range f.Files { if file.IsDir() { dir := &StoredDir{ &Dir{ File: &File{ Name: file.GetName(), }, BasePath: f.GetPath(), }, nil, sync.Mutex{}, } err := DefaultStorage.LoadDir(dir) if err != nil { log.Print(err.Error()) } files = append(files, dir) } else { files = append(files, file) } } f.cachedFiles = files // Return a copy to avoid modifying cached slice result := make(fs.Files, len(files)) copy(result, files) return result } // RemoveFile removes file from stored directory // It also updates size and item count of parent directories func (f *StoredDir) RemoveFile(item fs.Item) { if !DefaultStorage.IsOpen() { f.dbLock.Lock() defer f.dbLock.Unlock() closeFn := DefaultStorage.Open() defer closeFn() } f.Files = f.Files.Remove(item) f.cachedFiles = nil cur := f for { cur.ItemCount -= item.GetItemCount() cur.Size -= item.GetSize() cur.Usage -= item.GetUsage() err := DefaultStorage.StoreDir(cur) if err != nil { log.Print(err.Error()) } parent := cur.GetParent() if parent == nil { break } cur = parent.(*StoredDir) } } // GetItemStats returns item count, apparent usage and real usage of this dir func (f *StoredDir) GetItemStats(linkedItems fs.HardLinkedItems) (itemCount, size, usage int64) { f.UpdateStats(linkedItems) return f.ItemCount, f.GetSize(), f.GetUsage() } // UpdateStats recursively updates size and item count func (f *StoredDir) UpdateStats(linkedItems fs.HardLinkedItems) { if !DefaultStorage.IsOpen() { closeFn := DefaultStorage.Open() defer closeFn() } totalSize := int64(4096) totalUsage := int64(4096) var itemCount int64 f.cachedFiles = nil files := f.loadFiles() for _, entry := range files { count, size, usage := entry.GetItemStats(linkedItems) totalSize += size totalUsage += usage itemCount += count if entry.GetMtime().After(f.Mtime) { f.Mtime = entry.GetMtime() } switch entry.GetFlag() { case '!', '.': if f.Flag != '!' { f.Flag = '.' } } } f.cachedFiles = nil f.ItemCount = itemCount + 1 f.Size = totalSize f.Usage = totalUsage err := DefaultStorage.StoreDir(f) if err != nil { log.Print(err.Error()) } } // RemoveFileByName removes file by name from stored directory func (f *StoredDir) RemoveFileByName(name string) { if !DefaultStorage.IsOpen() { f.dbLock.Lock() defer f.dbLock.Unlock() closeFn := DefaultStorage.Open() defer closeFn() } idx, ok := f.Files.FindByName(name) if !ok { return } item := f.Files[idx] f.Files = append(f.Files[:idx], f.Files[idx+1:]...) f.cachedFiles = nil cur := f for { cur.ItemCount -= item.GetItemCount() cur.Size -= item.GetSize() cur.Usage -= item.GetUsage() err := DefaultStorage.StoreDir(cur) if err != nil { log.Print(err.Error()) } parent := cur.GetParent() if parent == nil { break } cur = parent.(*StoredDir) } } // ParentDir represents parent directory of single file // It is used to get path to parent directory of a file type ParentDir struct { Path string } func (p *ParentDir) GetPath() string { return p.Path } func (p *ParentDir) GetName() string { panic("must not be called") } func (p *ParentDir) GetFlag() rune { panic("must not be called") } func (p *ParentDir) IsDir() bool { panic("must not be called") } func (p *ParentDir) GetSize() int64 { panic("must not be called") } func (p *ParentDir) GetType() string { panic("must not be called") } func (p *ParentDir) GetUsage() int64 { panic("must not be called") } func (p *ParentDir) GetMtime() time.Time { panic("must not be called") } func (p *ParentDir) GetItemCount() int64 { panic("must not be called") } func (p *ParentDir) GetParent() fs.Item { panic("must not be called") } func (p *ParentDir) SetParent(fs.Item) { panic("must not be called") } func (p *ParentDir) GetMultiLinkedInode() uint64 { panic("must not be called") } func (p *ParentDir) EncodeJSON(writer io.Writer, topLevel bool) error { panic("must not be called") } func (p *ParentDir) UpdateStats(linkedItems fs.HardLinkedItems) { panic("must not be called") } func (p *ParentDir) AddFile(fs.Item) { panic("must not be called") } func (p *ParentDir) GetFiles(fs.SortBy, fs.SortOrder) iter.Seq[fs.Item] { panic("must not be called") } func (p *ParentDir) GetFilesLocked(fs.SortBy, fs.SortOrder) iter.Seq[fs.Item] { panic("must not be called") } func (p *ParentDir) RLock() func() { panic("must not be called") } func (p *ParentDir) RemoveFile(item fs.Item) { panic("must not be called") } func (p *ParentDir) RemoveFileByName(name string) { panic("must not be called") } func (p *ParentDir) GetItemStats( linkedItems fs.HardLinkedItems, ) (itemCount, size, usage int64) { panic("must not be called") } gdu-5.36.1/pkg/analyze/stored_coverage_test.go000066400000000000000000000113551517447455500214000ustar00rootroot00000000000000package analyze import ( "os" "slices" "testing" "time" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func TestStoredAnalyzerGetProgress(t *testing.T) { analyzer := CreateStoredAnalyzer("/tmp/test") progress := analyzer.GetProgress() assert.Equal(t, int64(0), progress.ItemCount) } func TestStoredAnalyzerSetFollowSymlinks(t *testing.T) { analyzer := CreateStoredAnalyzer("/tmp/test") analyzer.SetFollowSymlinks(true) assert.True(t, analyzer.followSymlinks) analyzer.SetFollowSymlinks(false) assert.False(t, analyzer.followSymlinks) } func TestStoredAnalyzerSetShowAnnexedSize(t *testing.T) { analyzer := CreateStoredAnalyzer("/tmp/test") analyzer.SetShowAnnexedSize(true) assert.True(t, analyzer.gitAnnexedSize) analyzer.SetShowAnnexedSize(false) assert.False(t, analyzer.gitAnnexedSize) } func TestStoredDirGetFilesCached(t *testing.T) { // Test when files are already cached files := make(fs.Files, 0) dir := &StoredDir{ Dir: &Dir{ File: &File{ Name: "test", }, BasePath: "/test", }, cachedFiles: files, } result := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc)) assert.Equal(t, len(files), len(result)) } func TestStoredDirRemoveFile(t *testing.T) { // Test RemoveFile functionality fin := testdir.CreateTestDir() defer fin() analyzer := CreateStoredAnalyzer("/tmp/test") dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*StoredDir) analyzer.GetDone().Wait() // Remove a file files := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc)) if len(files) > 0 { dir.RemoveFile(files[0]) } } func TestStoredDirUpdateStats(t *testing.T) { // Test UpdateStats functionality fin := testdir.CreateTestDir() defer fin() analyzer := CreateStoredAnalyzer("/tmp/test") dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*StoredDir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) } func TestStoredDirUpdateStatsWithMtimeUpdate(t *testing.T) { // Test UpdateStats with mtime updates fin := testdir.CreateTestDir() defer fin() analyzer := CreateStoredAnalyzer("/tmp/test") dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*StoredDir) analyzer.GetDone().Wait() // Create a file with newer mtime file := &File{ Name: "newfile", Mtime: time.Now().Add(time.Hour), } dir.AddFile(file) dir.UpdateStats(make(fs.HardLinkedItems)) } func TestStoredDirUpdateStatsWithFlagUpdate(t *testing.T) { // Test UpdateStats with flag updates fin := testdir.CreateTestDir() defer fin() analyzer := CreateStoredAnalyzer("/tmp/test") dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*StoredDir) analyzer.GetDone().Wait() // Create a file with error flag file := &File{ Name: "errorfile", Flag: '!', } dir.AddFile(file) dir.UpdateStats(make(fs.HardLinkedItems)) // Just test that UpdateStats runs without error // The flag behavior depends on the specific implementation } func TestStoredDirUpdateStatsWithDotFlag(t *testing.T) { // Test UpdateStats with dot flag fin := testdir.CreateTestDir() defer fin() analyzer := CreateStoredAnalyzer("/tmp/test") dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*StoredDir) analyzer.GetDone().Wait() // Create a file with dot flag file := &File{ Name: "dotfile", Flag: '.', } dir.AddFile(file) dir.UpdateStats(make(fs.HardLinkedItems)) assert.Equal(t, '.', dir.Flag) } func TestStoredAnalyzerWithZip(t *testing.T) { fin := testdir.CreateTestDir() defer fin() // Create valid zip createTestZipFile(t, "test_dir/valid.zip") // Create invalid zip f, err := os.Create("test_dir/invalid.zip") assert.NoError(t, err) _, err = f.WriteString("this is not a zip file") assert.NoError(t, err) f.Close() analyzer := CreateStoredAnalyzer("/tmp/test") analyzer.SetArchiveBrowsing(true) dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*StoredDir) analyzer.GetDone().Wait() // Check valid.zip var validZip fs.Item var invalidZip fs.Item for _, file := range dir.Files { if file.GetName() == "valid.zip" { validZip = file } if file.GetName() == "invalid.zip" { invalidZip = file } } assert.NotNil(t, validZip) assert.True(t, validZip.IsDir()) assert.Greater(t, validZip.GetSize(), int64(0)) assert.NotNil(t, invalidZip) assert.False(t, invalidZip.IsDir()) assert.Equal(t, int64(22), invalidZip.GetSize()) } gdu-5.36.1/pkg/analyze/stored_test.go000066400000000000000000000156621517447455500175320ustar00rootroot00000000000000package analyze import ( "bytes" "encoding/gob" "fmt" "slices" "testing" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func TestEncDec(t *testing.T) { var d fs.Item = &StoredDir{ Dir: &Dir{ File: &File{ Name: "xxx", }, BasePath: "/yyy", }, } b := &bytes.Buffer{} enc := gob.NewEncoder(b) err := enc.Encode(d) assert.NoError(t, err) var x fs.Item = &StoredDir{} dec := gob.NewDecoder(b) err = dec.Decode(x) assert.NoError(t, err) fmt.Println(d, x) assert.Equal(t, d.GetName(), x.GetName()) } func TestStoredAnalyzer(t *testing.T) { fin := testdir.CreateTestDir() defer fin() a := CreateStoredAnalyzer("/tmp/badger") dir := a.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*StoredDir) a.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) // test dir info assert.Equal(t, "test_dir", dir.Name) assert.Equal(t, int64(7+4096*3), dir.Size) assert.Equal(t, int64(5), dir.ItemCount) assert.True(t, dir.IsDir()) // test dir tree files := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc)) assert.Equal(t, "nested", files[0].GetName()) nested := files[0].(*StoredDir) nestedFiles := slices.Collect(nested.GetFiles(fs.SortByName, fs.SortAsc)) assert.Equal(t, "subnested", nestedFiles[1].GetName()) // test file assert.Equal(t, "file2", nestedFiles[0].GetName()) assert.Equal(t, int64(2), nestedFiles[0].GetSize()) assert.True(t, int64(4096) <= nestedFiles[0].GetUsage()) subnested := nestedFiles[1].(*StoredDir) subnestedFiles := slices.Collect(subnested.GetFiles(fs.SortByName, fs.SortAsc)) assert.Equal(t, "file", subnestedFiles[0].GetName()) assert.Equal(t, int64(5), subnestedFiles[0].GetSize()) } func TestRemoveStoredFile(t *testing.T) { fin := testdir.CreateTestDir() defer fin() a := CreateStoredAnalyzer("/tmp/badger") dir := a.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*StoredDir) a.GetDone().Wait() a.ResetProgress() dir.UpdateStats(make(fs.HardLinkedItems)) // test dir info assert.Equal(t, "test_dir", dir.Name) assert.Equal(t, int64(7+4096*3), dir.Size) assert.Equal(t, int64(5), dir.ItemCount) assert.True(t, dir.IsDir()) dirFiles := slices.Collect(dir.GetFiles(fs.SortByName, fs.SortAsc)) subdir := dirFiles[0].(*StoredDir) subdirFiles := slices.Collect(subdir.GetFiles(fs.SortByName, fs.SortAsc)) subdir.RemoveFile(subdirFiles[0]) closeFn := DefaultStorage.Open() defer closeFn() stored, err := DefaultStorage.GetDirForPath("test_dir") assert.NoError(t, err) assert.Equal(t, int64(4), stored.GetItemCount()) assert.Equal(t, int64(5+4096*3), stored.GetSize()) storedFiles := slices.Collect(stored.GetFiles(fs.SortByName, fs.SortAsc)) storedNested := storedFiles[0].(*StoredDir) storedNestedFiles := slices.Collect(storedNested.GetFiles(fs.SortByName, fs.SortAsc)) storedSubnested := storedNestedFiles[0].(*StoredDir) storedSubnestedFiles := slices.Collect(storedSubnested.GetFiles(fs.SortByName, fs.SortAsc)) file := storedSubnestedFiles[0] assert.Equal(t, false, file.IsDir()) assert.Equal(t, "file", file.GetName()) assert.Equal(t, "test_dir/nested/subnested", file.GetParent().GetPath()) } func TestParentDirGetNamePanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.GetName() } func TestParentDirGetFlagPanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.GetFlag() } func TestParentDirIsDirPanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.IsDir() } func TestParentDirGetSizePanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.GetSize() } func TestParentDirGetTypePanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.GetType() } func TestParentDirGetUsagePanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.GetUsage() } func TestParentDirGetMtimePanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.GetMtime() } func TestParentDirGetItemCountPanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.GetItemCount() } func TestParentDirGetParentPanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.GetParent() } func TestParentDirSetParentPanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.SetParent(nil) } func TestParentDirGetMultiLinkedInodePanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.GetMultiLinkedInode() } func TestParentDirEncodeJSONPanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} err := dir.EncodeJSON(nil, false) assert.NoError(t, err) } func TestParentDirUpdateStatsPanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.UpdateStats(nil) } func TestParentDirAddFilePanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.AddFile(nil) } func TestParentDirGetFilesPanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.GetFiles(fs.SortByName, fs.SortAsc) } func TestParentDirGetFilesLockedPanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.GetFilesLocked(fs.SortByName, fs.SortAsc) } func TestParentDirRLockPanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.RLock() } func TestParentDirRemoveFilePanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.RemoveFile(nil) } func TestParentDirGetItemStatsPanics(t *testing.T) { defer func() { if r := recover(); r != nil { assert.Equal(t, "must not be called", r) } }() dir := &ParentDir{} dir.GetItemStats(nil) } gdu-5.36.1/pkg/analyze/symlink.go000066400000000000000000000013111517447455500166430ustar00rootroot00000000000000package analyze import ( "os" "path/filepath" "strings" "github.com/dundee/gdu/v5/pkg/annex" ) func followSymlink(path string, gitAnnexedSize bool) (tInfo os.FileInfo, err error) { target, err := filepath.EvalSymlinks(path) if err != nil { target, err = os.Readlink(path) if err != nil { return nil, err } if gitAnnexedSize && strings.Contains(target, ".git/annex/objects") { tInfo, err = os.Lstat(path) if err != nil { return nil, err } name := filepath.Base(target) tInfo = annex.AnnexedFileInfo(tInfo, name) return tInfo, nil } } tInfo, err = os.Lstat(target) if err != nil { return nil, err } if tInfo.IsDir() { return nil, nil } return tInfo, nil } gdu-5.36.1/pkg/analyze/symlink_test.go000066400000000000000000000020371517447455500177100ustar00rootroot00000000000000package analyze import ( "os" "testing" "github.com/dundee/gdu/v5/internal/testdir" "github.com/stretchr/testify/assert" ) func TestFollowSymlinkErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Mkdir("test_dir/empty", 0o644) assert.Nil(t, err) err = os.Symlink( ".git/annex/objects/qx/qX/SHA256E-s967858083--"+ "3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4/SHA256E-s967858083--"+ "3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4", "test_dir/nested/file3") assert.Nil(t, err) err = os.Symlink( "test_dir/nested", "test_dir/some_dir") assert.Nil(t, err) _, err = followSymlink("xxx", false) assert.ErrorContains(t, err, "no such file or directory") _, err = followSymlink("test_dir/nested/file3", false) assert.ErrorContains(t, err, "no such file or directory") _, err = followSymlink("test_dir/nested/file3", true) assert.NoError(t, err) res, err := followSymlink("test_dir/some_dir", true) assert.Equal(t, nil, res) assert.NoError(t, err) } gdu-5.36.1/pkg/analyze/tardir.go000066400000000000000000000135161517447455500164540ustar00rootroot00000000000000package analyze import ( "archive/tar" "compress/bzip2" "compress/gzip" "io" "os" "path/filepath" "strings" "time" "github.com/dundee/gdu/v5/pkg/fs" "github.com/ulikunitz/xz" ) // TarDir represents a directory structure inside a tar archive type TarDir struct { *Dir tarPath string // path to the original tar file } // TarFile represents a file inside a tar archive type TarFile struct { *File tarPath string inTarPath string // path inside the tar archive } // GetPath returns the virtual path for tar file func (tf *TarFile) GetPath() string { return tf.tarPath + "/" + tf.inTarPath } // GetType returns type of tar file func (tf *TarFile) GetType() string { return "TarFile" } // EncodeJSON encodes tar file to JSON func (tf *TarFile) EncodeJSON(writer io.Writer, topLevel bool) error { return tf.File.EncodeJSON(writer, topLevel) } // GetType returns type of tar directory func (td *TarDir) GetType() string { return "TarDirectory" } // IsDir returns true for TarDir func (td *TarDir) IsDir() bool { return true } // EncodeJSON encodes tar directory to JSON func (td *TarDir) EncodeJSON(writer io.Writer, topLevel bool) error { return td.Dir.EncodeJSON(writer, topLevel) } // GetPath returns the virtual path for tar directory func (td *TarDir) GetPath() string { if td.Parent != nil { return filepath.Join(td.Parent.GetPath(), td.Name) } return td.tarPath } // isTarFile checks if a file is a tar archive (tar, tar.gz, tgz, tar.bz2, tbz2, tar.xz, txz) func isTarFile(filename string) bool { lower := strings.ToLower(filename) return strings.HasSuffix(lower, ".tar") || strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") || strings.HasSuffix(lower, ".tar.bz2") || strings.HasSuffix(lower, ".tbz2") || strings.HasSuffix(lower, ".tar.xz") || strings.HasSuffix(lower, ".txz") } // multiCloser closes multiple io.Closer instances in sequence type multiCloser struct { closers []io.Closer } func (mc *multiCloser) Close() error { var firstErr error for _, c := range mc.closers { if err := c.Close(); err != nil && firstErr == nil { firstErr = err } } return firstErr } // openTarReader opens a tar archive and returns a tar.Reader and a Closer for cleanup. // It automatically wraps the reader with the appropriate decompressor based on file extension. func openTarReader(tarPath string) (*tar.Reader, io.Closer, error) { f, err := os.Open(tarPath) if err != nil { return nil, nil, err } lower := strings.ToLower(tarPath) switch { case strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz"): gr, err := gzip.NewReader(f) if err != nil { f.Close() return nil, nil, err } return tar.NewReader(gr), &multiCloser{closers: []io.Closer{gr, f}}, nil case strings.HasSuffix(lower, ".tar.bz2") || strings.HasSuffix(lower, ".tbz2"): br := bzip2.NewReader(f) return tar.NewReader(br), f, nil case strings.HasSuffix(lower, ".tar.xz") || strings.HasSuffix(lower, ".txz"): xr, err := xz.NewReader(f) if err != nil { f.Close() return nil, nil, err } return tar.NewReader(xr), f, nil default: // plain .tar return tar.NewReader(f), f, nil } } // processTarFile reads a tar archive and returns a TarDir representing its contents. // TarDir.Size is set to the total uncompressed content size; TarDir.Usage is the // size of the archive file on disk. func processTarFile(tarPath string, info os.FileInfo) (*TarDir, error) { tr, closer, err := openTarReader(tarPath) if err != nil { return nil, err } defer closer.Close() tarDir := &TarDir{ Dir: &Dir{ File: &File{ Name: filepath.Base(tarPath), Flag: 'T', Size: info.Size(), Usage: info.Size(), Mtime: info.ModTime(), }, ItemCount: 1, Files: make(fs.Files, 0), }, tarPath: tarPath, } dirMap := make(map[string]*TarDir) dirMap[""] = tarDir var totalUncompressed int64 for { header, err := tr.Next() if err == io.EOF { break } if err != nil { return nil, err } // Normalize path: strip leading ./ and trailing / name := strings.TrimPrefix(header.Name, "./") name = strings.TrimSuffix(name, "/") switch header.Typeflag { case tar.TypeDir: if name != "" { ensureTarDirExists(dirMap, name, tarPath, tarDir) } case tar.TypeReg, tar.TypeLink, tar.TypeSymlink: dirPath := filepath.Dir(name) if dirPath == "." { dirPath = "" } ensureTarDirExists(dirMap, dirPath, tarPath, tarDir) parentDir := dirMap[dirPath] totalUncompressed += header.Size tarFile := &TarFile{ File: &File{ Name: filepath.Base(name), Flag: ' ', Size: header.Size, Usage: header.Size, Mtime: header.ModTime, Parent: parentDir, }, tarPath: tarPath, inTarPath: name, } parentDir.AddFile(tarFile) } // Other types (device files, fifos, etc.) are silently skipped } // Size = total uncompressed content; Usage = compressed archive on disk tarDir.Size = totalUncompressed tarDir.Usage = info.Size() return tarDir, nil } // ensureTarDirExists ensures all directories in the specified path exist within dirMap func ensureTarDirExists(dirMap map[string]*TarDir, path, tarPath string, rootDir *TarDir) { if path == "" || path == "." { return } if _, exists := dirMap[path]; exists { return } // Ensure parent directory exists first parentPath := filepath.Dir(path) if parentPath != "." && parentPath != "" { ensureTarDirExists(dirMap, parentPath, tarPath, rootDir) } var parent *TarDir if parentPath == "" || parentPath == "." { parent = rootDir } else { parent = dirMap[parentPath] } newDir := &TarDir{ Dir: &Dir{ File: &File{ Name: filepath.Base(path), Flag: 'T', Size: 4096, Usage: 4096, Mtime: time.Now(), Parent: parent, }, ItemCount: 1, Files: make(fs.Files, 0), }, tarPath: tarPath, } dirMap[path] = newDir parent.AddFile(newDir) } gdu-5.36.1/pkg/analyze/tardir_test.go000066400000000000000000000436441517447455500175200ustar00rootroot00000000000000package analyze import ( "archive/tar" "bytes" "compress/bzip2" "compress/gzip" "io" "os" "os/exec" "path/filepath" "slices" "testing" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/ulikunitz/xz" ) // --------------------------------------------------------------------------- // Helpers to create test archives // --------------------------------------------------------------------------- // writeTarEntries writes a tiny but realistic directory structure into w. func writeTarEntries(t *testing.T, w io.Writer) { t.Helper() tw := tar.NewWriter(w) entries := []struct { hdr tar.Header content []byte }{ {tar.Header{Typeflag: tar.TypeDir, Name: "subdir/", Mode: 0o755}, nil}, {tar.Header{Typeflag: tar.TypeReg, Name: "test.txt", Size: 11, Mode: 0o644}, []byte("hello world")}, {tar.Header{Typeflag: tar.TypeReg, Name: "subdir/nested.txt", Size: 6, Mode: 0o644}, []byte("nested")}, } for _, e := range entries { require.NoError(t, tw.WriteHeader(&e.hdr)) if e.content != nil { _, err := tw.Write(e.content) require.NoError(t, err) } } require.NoError(t, tw.Close()) } func createTestTarFile(t *testing.T, path string) { t.Helper() f, err := os.Create(path) require.NoError(t, err) defer f.Close() writeTarEntries(t, f) } func createTestTarGzFile(t *testing.T, path string) { t.Helper() f, err := os.Create(path) require.NoError(t, err) defer f.Close() gw := gzip.NewWriter(f) writeTarEntries(t, gw) require.NoError(t, gw.Close()) } func createTestTarBz2File(t *testing.T, path string) { t.Helper() // compress/bzip2 only provides a reader; use a pre-recorded bzip2-compressed // tar so we can test the reading path without an external encoder. // We build the plain tar in memory, then bzip2-compress it via a known // approach: write raw tar bytes, then BZ-compress them with a pure-Go writer. // Go stdlib has no bzip2 writer, so we create a minimal bzip2 stream by // piping through the `bzip2` command if available, otherwise skip. var buf bytes.Buffer writeTarEntries(t, &buf) // Try to use bzip2 command-line tool tmp := t.TempDir() rawTar := filepath.Join(tmp, "tmp.tar") require.NoError(t, os.WriteFile(rawTar, buf.Bytes(), 0o600)) out, err := os.Create(path) require.NoError(t, err) defer out.Close() // Write a real bzip2 stream using a simple pure-Go BZ2 encoder shim. // Because the stdlib only provides a reader, we vendor a minimal BZ2 writer // here via dsnet/compress or simply write the file using pgzip. Instead, we // use a known-good approach: encode via Go's exec. If exec is unavailable we // create the file using a bzip2 block that wraps our raw bytes. // // Simplest portable approach: use compress/flate at the tar layer instead. // But since we need a real bzip2 file, we skip if bzip2 is unavailable. bzip2Cmd, lookErr := findBzip2Cmd() if lookErr != nil { t.Skip("bzip2 command not available, skipping .tar.bz2 test") } rawData, err := os.ReadFile(rawTar) require.NoError(t, err) compressed, err := bzip2Cmd(rawData) require.NoError(t, err) _, err = out.Write(compressed) require.NoError(t, err) } func createTestTarXzFile(t *testing.T, path string) { t.Helper() f, err := os.Create(path) require.NoError(t, err) defer f.Close() xw, err := xz.NewWriter(f) require.NoError(t, err) writeTarEntries(t, xw) require.NoError(t, xw.Close()) } // findBzip2Cmd returns a function that compresses data with bzip2, or an error // if bzip2 is not available on the system. func findBzip2Cmd() (func([]byte) ([]byte, error), error) { // We use os/exec but need to import it. To avoid adding an import only used // in tests, use a direct syscall approach via os/exec in a sub-function. return bzip2CompressFunc() } // --------------------------------------------------------------------------- // isTarFile // --------------------------------------------------------------------------- func TestIsTarFile(t *testing.T) { tests := []struct { filename string expected bool }{ {"archive.tar", true}, {"archive.tar.gz", true}, {"archive.tgz", true}, {"archive.tar.bz2", true}, {"archive.tbz2", true}, {"archive.tar.xz", true}, {"archive.txz", true}, {"ARCHIVE.TAR", true}, {"ARCHIVE.TAR.GZ", true}, {"ARCHIVE.TGZ", true}, {"ARCHIVE.TAR.BZ2", true}, {"ARCHIVE.TBZZ2", false}, {"ARCHIVE.TAR.XZ", true}, {"ARCHIVE.TXZ", true}, {"archive.zip", false}, {"archive.jar", false}, {"archive.gz", false}, // plain gzip, not a tarball {"archive.bz2", false}, // plain bzip2, not a tarball {"archive.xz", false}, {"archive.txt", false}, {"archive", false}, {"", false}, } for _, tc := range tests { result := isTarFile(tc.filename) assert.Equal(t, tc.expected, result, "filename: %s", tc.filename) } } // --------------------------------------------------------------------------- // processTarFile – plain .tar // --------------------------------------------------------------------------- func TestProcessTarFile(t *testing.T) { tmpDir := t.TempDir() tarPath := filepath.Join(tmpDir, "test.tar") createTestTarFile(t, tarPath) info, err := os.Stat(tarPath) require.NoError(t, err) td, err := processTarFile(tarPath, info) require.NoError(t, err) require.NotNil(t, td) assert.Equal(t, "test.tar", td.GetName()) assert.Equal(t, rune('T'), td.GetFlag()) assert.True(t, td.IsDir()) assert.Equal(t, "TarDirectory", td.GetType()) // Usage should equal the on-disk size assert.Equal(t, info.Size(), td.Usage) files := slices.Collect(td.GetFiles(fs.SortByName, fs.SortAsc)) assert.Greater(t, len(files), 0) foundText := false foundSubdir := false for _, f := range files { if f.GetName() == "test.txt" { foundText = true assert.False(t, f.IsDir()) assert.Equal(t, "TarFile", f.GetType()) } if f.GetName() == "subdir" { foundSubdir = true assert.True(t, f.IsDir()) assert.Equal(t, "TarDirectory", f.GetType()) } } assert.True(t, foundText, "should find test.txt") assert.True(t, foundSubdir, "should find subdir") } // --------------------------------------------------------------------------- // processTarFile – .tar.gz // --------------------------------------------------------------------------- func TestProcessTarGzFile(t *testing.T) { tmpDir := t.TempDir() tarPath := filepath.Join(tmpDir, "test.tar.gz") createTestTarGzFile(t, tarPath) info, err := os.Stat(tarPath) require.NoError(t, err) td, err := processTarFile(tarPath, info) require.NoError(t, err) require.NotNil(t, td) assert.Equal(t, "test.tar.gz", td.GetName()) assert.True(t, td.IsDir()) assert.Equal(t, "TarDirectory", td.GetType()) assert.Equal(t, info.Size(), td.Usage) files := slices.Collect(td.GetFiles(fs.SortByName, fs.SortAsc)) assert.Greater(t, len(files), 0) } // --------------------------------------------------------------------------- // processTarFile – .tgz alias // --------------------------------------------------------------------------- func TestProcessTgzFile(t *testing.T) { tmpDir := t.TempDir() tarPath := filepath.Join(tmpDir, "test.tgz") createTestTarGzFile(t, tarPath) info, err := os.Stat(tarPath) require.NoError(t, err) td, err := processTarFile(tarPath, info) require.NoError(t, err) require.NotNil(t, td) assert.Equal(t, "test.tgz", td.GetName()) } // --------------------------------------------------------------------------- // processTarFile – .tar.bz2 // --------------------------------------------------------------------------- func TestProcessTarBz2File(t *testing.T) { tmpDir := t.TempDir() tarPath := filepath.Join(tmpDir, "test.tar.bz2") createTestTarBz2File(t, tarPath) // skips if bzip2 unavailable info, err := os.Stat(tarPath) require.NoError(t, err) td, err := processTarFile(tarPath, info) require.NoError(t, err) require.NotNil(t, td) assert.Equal(t, "test.tar.bz2", td.GetName()) assert.True(t, td.IsDir()) assert.Equal(t, "TarDirectory", td.GetType()) } // --------------------------------------------------------------------------- // processTarFile – .tar.xz // --------------------------------------------------------------------------- func TestProcessTarXzFile(t *testing.T) { tmpDir := t.TempDir() tarPath := filepath.Join(tmpDir, "test.tar.xz") createTestTarXzFile(t, tarPath) info, err := os.Stat(tarPath) require.NoError(t, err) td, err := processTarFile(tarPath, info) require.NoError(t, err) require.NotNil(t, td) assert.Equal(t, "test.tar.xz", td.GetName()) assert.True(t, td.IsDir()) assert.Equal(t, "TarDirectory", td.GetType()) files := slices.Collect(td.GetFiles(fs.SortByName, fs.SortAsc)) assert.Greater(t, len(files), 0) } // --------------------------------------------------------------------------- // processTarFile – .txz alias // --------------------------------------------------------------------------- func TestProcessTxzFile(t *testing.T) { tmpDir := t.TempDir() tarPath := filepath.Join(tmpDir, "test.txz") createTestTarXzFile(t, tarPath) info, err := os.Stat(tarPath) require.NoError(t, err) td, err := processTarFile(tarPath, info) require.NoError(t, err) require.NotNil(t, td) assert.Equal(t, "test.txz", td.GetName()) } // --------------------------------------------------------------------------- // ensureTarDirExists // --------------------------------------------------------------------------- func TestEnsureTarDirExists(t *testing.T) { tarPath := "/fake/archive.tar" rootDir := &TarDir{ Dir: &Dir{ File: &File{Name: "archive.tar", Flag: 'T'}, Files: make(fs.Files, 0), }, tarPath: tarPath, } dirMap := make(map[string]*TarDir) dirMap[""] = rootDir ensureTarDirExists(dirMap, "a/b/c", tarPath, rootDir) assert.Contains(t, dirMap, "a") assert.Contains(t, dirMap, "a/b") assert.Contains(t, dirMap, "a/b/c") assert.Equal(t, rootDir, dirMap["a"].GetParent()) assert.Equal(t, dirMap["a"], dirMap["a/b"].GetParent()) assert.Equal(t, dirMap["a/b"], dirMap["a/b/c"].GetParent()) } func TestEnsureTarDirExistsIdempotent(t *testing.T) { tarPath := "/fake/archive.tar" rootDir := &TarDir{ Dir: &Dir{File: &File{Name: "archive.tar", Flag: 'T'}, Files: make(fs.Files, 0)}, tarPath: tarPath, } dirMap := map[string]*TarDir{"": rootDir} ensureTarDirExists(dirMap, "sub", tarPath, rootDir) first := dirMap["sub"] ensureTarDirExists(dirMap, "sub", tarPath, rootDir) // must be idempotent assert.Same(t, first, dirMap["sub"]) } // --------------------------------------------------------------------------- // TarFile / TarDir method coverage // --------------------------------------------------------------------------- func TestTarFileGetPath(t *testing.T) { tf := &TarFile{ File: &File{Name: "file.txt"}, tarPath: "/path/to/archive.tar", inTarPath: "dir/file.txt", } assert.Equal(t, "/path/to/archive.tar/dir/file.txt", tf.GetPath()) } func TestTarFileGetType(t *testing.T) { tf := &TarFile{File: &File{Name: "x"}} assert.Equal(t, "TarFile", tf.GetType()) } func TestTarFileEncodeJSON(t *testing.T) { tf := &TarFile{ File: &File{Name: "x.txt", Size: 42}, tarPath: "/a.tar", inTarPath: "x.txt", } var buf bytes.Buffer assert.NoError(t, tf.EncodeJSON(&buf, false)) assert.NotEmpty(t, buf.String()) } func TestTarDirGetType(t *testing.T) { td := &TarDir{Dir: &Dir{File: &File{Name: "sub"}}} assert.Equal(t, "TarDirectory", td.GetType()) } func TestTarDirIsDir(t *testing.T) { td := &TarDir{Dir: &Dir{File: &File{Name: "sub"}}} assert.True(t, td.IsDir()) } func TestTarDirEncodeJSON(t *testing.T) { td := &TarDir{ Dir: &Dir{File: &File{Name: "sub"}, Files: make(fs.Files, 0)}, tarPath: "/a.tar", } var buf bytes.Buffer assert.NoError(t, td.EncodeJSON(&buf, true)) assert.NotEmpty(t, buf.String()) } func TestTarDirGetPathWithParent(t *testing.T) { parent := &TarDir{ Dir: &Dir{File: &File{Name: "parent"}}, tarPath: "/a.tar", } child := &TarDir{ Dir: &Dir{File: &File{Name: "child"}}, tarPath: "/a.tar", } child.Parent = parent assert.Equal(t, filepath.Join(parent.GetPath(), "child"), child.GetPath()) } func TestTarDirGetPathWithoutParent(t *testing.T) { td := &TarDir{ Dir: &Dir{File: &File{Name: "root"}}, tarPath: "/path/to/archive.tar", } assert.Equal(t, "/path/to/archive.tar", td.GetPath()) } // --------------------------------------------------------------------------- // Integration: SequentialAnalyzer with tar files // --------------------------------------------------------------------------- func TestSequentialAnalyzerWithTarFile(t *testing.T) { tmpDir := t.TempDir() createTestTarFile(t, filepath.Join(tmpDir, "test.tar")) a := CreateSeqAnalyzer() a.SetArchiveBrowsing(true) result := a.AnalyzeDir(tmpDir, func(string, string) bool { return false }, func(string) bool { return false }, ) require.NotNil(t, result) var tarItem fs.Item for f := range result.GetFiles(fs.SortByName, fs.SortAsc) { if f.GetName() == "test.tar" { tarItem = f break } } require.NotNil(t, tarItem, "should find test.tar as a browsable directory") assert.True(t, tarItem.IsDir()) assert.Equal(t, "TarDirectory", tarItem.GetType()) count := 0 for range tarItem.GetFiles(fs.SortByName, fs.SortAsc) { count++ } assert.Greater(t, count, 0, "tar archive should contain browsable content") } func TestSequentialAnalyzerWithTarGzFile(t *testing.T) { tmpDir := t.TempDir() createTestTarGzFile(t, filepath.Join(tmpDir, "test.tar.gz")) a := CreateSeqAnalyzer() a.SetArchiveBrowsing(true) result := a.AnalyzeDir(tmpDir, func(string, string) bool { return false }, func(string) bool { return false }, ) require.NotNil(t, result) var tarItem fs.Item for f := range result.GetFiles(fs.SortByName, fs.SortAsc) { if f.GetName() == "test.tar.gz" { tarItem = f break } } require.NotNil(t, tarItem, "should find test.tar.gz as a browsable directory") assert.True(t, tarItem.IsDir()) } // --------------------------------------------------------------------------- // Integration: ParallelAnalyzer with tar files // --------------------------------------------------------------------------- func TestParallelAnalyzerWithTarFile(t *testing.T) { tmpDir := t.TempDir() createTestTarFile(t, filepath.Join(tmpDir, "archive.tar")) a := CreateAnalyzer() a.SetArchiveBrowsing(true) result := a.AnalyzeDir(tmpDir, func(string, string) bool { return false }, func(string) bool { return false }, ) require.NotNil(t, result) var tarItem fs.Item for f := range result.GetFiles(fs.SortByName, fs.SortAsc) { if f.GetName() == "archive.tar" { tarItem = f break } } require.NotNil(t, tarItem, "should find archive.tar") assert.True(t, tarItem.IsDir()) assert.Equal(t, "TarDirectory", tarItem.GetType()) } func TestParallelAnalyzerWithTarXzFile(t *testing.T) { tmpDir := t.TempDir() createTestTarXzFile(t, filepath.Join(tmpDir, "archive.tar.xz")) a := CreateAnalyzer() a.SetArchiveBrowsing(true) result := a.AnalyzeDir(tmpDir, func(string, string) bool { return false }, func(string) bool { return false }, ) require.NotNil(t, result) var tarItem fs.Item for f := range result.GetFiles(fs.SortByName, fs.SortAsc) { if f.GetName() == "archive.tar.xz" { tarItem = f break } } require.NotNil(t, tarItem, "should find archive.tar.xz") assert.True(t, tarItem.IsDir()) } // --------------------------------------------------------------------------- // Error handling: non-existent / corrupt archive // --------------------------------------------------------------------------- func TestProcessTarFileNotFound(t *testing.T) { _, err := processTarFile("/no/such/file.tar", nil) assert.Error(t, err) } func TestProcessTarFileCorrupt(t *testing.T) { tmpDir := t.TempDir() p := filepath.Join(tmpDir, "bad.tar") require.NoError(t, os.WriteFile(p, []byte("not a tar file at all"), 0o600)) info, err := os.Stat(p) require.NoError(t, err) // A corrupt plain-tar should still open (it's a file), but Next() will fail. // processTarFile should propagate the error. _, err = processTarFile(p, info) assert.Error(t, err) } func TestProcessTarGzFileCorrupt(t *testing.T) { tmpDir := t.TempDir() p := filepath.Join(tmpDir, "bad.tar.gz") require.NoError(t, os.WriteFile(p, []byte("not gzip"), 0o600)) info, err := os.Stat(p) require.NoError(t, err) _, err = processTarFile(p, info) assert.Error(t, err) } func TestProcessTarXzFileCorrupt(t *testing.T) { tmpDir := t.TempDir() p := filepath.Join(tmpDir, "bad.tar.xz") require.NoError(t, os.WriteFile(p, []byte("not xz"), 0o600)) info, err := os.Stat(p) require.NoError(t, err) _, err = processTarFile(p, info) assert.Error(t, err) } // --------------------------------------------------------------------------- // Archive browsing disabled – tar file treated as regular file // --------------------------------------------------------------------------- func TestAnalyzerTarBrowsingDisabled(t *testing.T) { tmpDir := t.TempDir() createTestTarFile(t, filepath.Join(tmpDir, "archive.tar")) a := CreateSeqAnalyzer() // archiveBrowsing is false by default result := a.AnalyzeDir(tmpDir, func(string, string) bool { return false }, func(string) bool { return false }, ) require.NotNil(t, result) var tarItem fs.Item for f := range result.GetFiles(fs.SortByName, fs.SortAsc) { if f.GetName() == "archive.tar" { tarItem = f break } } require.NotNil(t, tarItem) assert.False(t, tarItem.IsDir(), "when browsing disabled, tar is a plain file") } // --------------------------------------------------------------------------- // bzip2 compression helper (uses os/exec "bzip2") // --------------------------------------------------------------------------- func bzip2CompressFunc() (func([]byte) ([]byte, error), error) { return bzip2CompressWithCmd() } func bzip2CompressWithCmd() (func([]byte) ([]byte, error), error) { bzip2Path, err := exec.LookPath("bzip2") if err != nil { return nil, err } return func(data []byte) ([]byte, error) { cmd := exec.Command(bzip2Path, "--compress", "--stdout") cmd.Stdin = bytes.NewReader(data) return cmd.Output() }, nil } // Ensure compress/bzip2 package is used (for reading .tar.bz2) var _ = bzip2.NewReader gdu-5.36.1/pkg/analyze/top.go000066400000000000000000000017411517447455500157660ustar00rootroot00000000000000package analyze import ( "sort" "github.com/dundee/gdu/v5/pkg/fs" ) // TopList is a list of top largest files type TopList struct { Items fs.Files Count int MinSize int64 } // NewTopList creates new TopList func NewTopList(count int) *TopList { return &TopList{Count: count} } // Add adds file to the list func (tl *TopList) Add(file fs.Item) { if file.GetSize() > tl.MinSize || len(tl.Items) < tl.Count { tl.Items = append(tl.Items, file) sort.Sort(fs.ByApparentSize(tl.Items)) if len(tl.Items) > tl.Count { tl.Items = tl.Items[1:] } tl.MinSize = tl.Items[0].GetSize() } } func CollectTopFiles(dir fs.Item, count int) fs.Files { topList := NewTopList(count) walkDir(dir, topList) sort.Sort(sort.Reverse(fs.ByApparentSize(topList.Items))) return topList.Items } func walkDir(dir fs.Item, topList *TopList) { for item := range dir.GetFiles(fs.SortBySize, fs.SortDesc) { if item.IsDir() { walkDir(item, topList) } else { topList.Add(item) } } } gdu-5.36.1/pkg/analyze/top_dir.go000066400000000000000000000070251517447455500166250ustar00rootroot00000000000000package analyze import ( "io" "iter" "sync" "sync/atomic" "time" "github.com/dundee/gdu/v5/pkg/fs" ) var _ fs.Item = (*SimpleDir)(nil) type TopDir struct { Name string Size atomic.Int64 Usage atomic.Int64 ItemCount atomic.Int64 Flag rune m sync.Mutex } func (d *TopDir) AddUsage(size, usage, itemCount int64) { d.Size.Add(size) d.Usage.Add(usage) d.ItemCount.Add(itemCount) } func (d *TopDir) GetUsage() (size, usage, itemCount int64) { return d.Size.Load(), d.Usage.Load(), d.ItemCount.Load() } func (d *TopDir) SetFlag(flag rune) { d.m.Lock() d.Flag = flag d.m.Unlock() } type SimpleFile struct { Name string Flag rune Size int64 Usage int64 ItemCount int64 IsDir bool } type SimpleDir struct { SimpleFile Files []SimpleFile BasePath string } func (d *SimpleDir) GetName() string { return d.Name } func (d *SimpleDir) GetUsage() int64 { return d.Usage } func (d *SimpleDir) GetSize() int64 { return d.Size } func (d *SimpleDir) GetPath() string { panic("not implemented") } func (d *SimpleDir) GetFlag() rune { panic("not implemented") } func (d *SimpleDir) IsDir() bool { panic("not implemented") } func (d *SimpleDir) GetType() string { panic("not implemented") } func (d *SimpleDir) GetMtime() time.Time { panic("not implemented") } func (d *SimpleDir) GetItemCount() int64 { panic("not implemented") } func (d *SimpleDir) GetParent() fs.Item { panic("not implemented") } func (d *SimpleDir) SetParent(parent fs.Item) { panic("not implemented") } func (d *SimpleDir) GetMultiLinkedInode() uint64 { panic("not implemented") } func (d *SimpleDir) EncodeJSON(writer io.Writer, topLevel bool) error { panic("not implemented") } func (d *SimpleDir) GetItemStats(linkedItems fs.HardLinkedItems) (itemCount, size, usage int64) { panic("not implemented") } func (d *SimpleDir) UpdateStats(linkedItems fs.HardLinkedItems) { totalSize := int64(4096) totalUsage := int64(4096) var itemCount int64 for _, entry := range d.Files { totalSize += entry.Size totalUsage += entry.Usage itemCount += entry.ItemCount switch entry.Flag { case '!', '.': if d.Flag != '!' { d.Flag = '.' } } } d.ItemCount = itemCount + 1 d.Size = totalSize d.Usage = totalUsage } func (d *SimpleDir) AddFile(fs.Item) { panic("not implemented") } func (d *SimpleDir) GetFiles(sortBy fs.SortBy, order fs.SortOrder) iter.Seq[fs.Item] { return func(yield func(fs.Item) bool) { // Make a copy to avoid modifying the original slice sorted := make(fs.Files, 0, len(d.Files)) for _, file := range d.Files { f := &File{ Name: file.Name, Flag: file.Flag, Size: file.Size, Usage: file.Usage, } if file.IsDir { sorted = append(sorted, &Dir{ File: f, ItemCount: file.ItemCount, }) } else { sorted = append(sorted, f) } } sortFiles(sorted, sortBy, order) for _, item := range sorted { if !yield(item) { return } } } } func (d *SimpleDir) GetFilesLocked(fs.SortBy, fs.SortOrder) iter.Seq[fs.Item] { panic("not implemented") } func (d *SimpleDir) RemoveFile(fs.Item) { panic("not implemented") } func (d *SimpleDir) RemoveFileByName(name string) { panic("not implemented") } func (d *SimpleDir) RLock() func() { panic("not implemented") } gdu-5.36.1/pkg/analyze/top_dir_test.go000066400000000000000000000106711517447455500176650ustar00rootroot00000000000000package analyze import ( "testing" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func TestTopDirAddUsage(t *testing.T) { td := &TopDir{Name: "test"} td.AddUsage(100, 200, 5) assert.Equal(t, int64(100), td.Size.Load()) assert.Equal(t, int64(200), td.Usage.Load()) assert.Equal(t, int64(5), td.ItemCount.Load()) td.AddUsage(50, 80, 3) assert.Equal(t, int64(150), td.Size.Load()) assert.Equal(t, int64(280), td.Usage.Load()) assert.Equal(t, int64(8), td.ItemCount.Load()) } func TestTopDirSetFlag(t *testing.T) { td := &TopDir{Name: "test", Flag: ' '} td.SetFlag('!') assert.Equal(t, '!', td.Flag) } func TestSimpleDirGetName(t *testing.T) { d := &SimpleDir{SimpleFile: SimpleFile{Name: "mydir"}} assert.Equal(t, "mydir", d.GetName()) } func TestSimpleDirGetSize(t *testing.T) { d := &SimpleDir{SimpleFile: SimpleFile{Size: 1234}} assert.Equal(t, int64(1234), d.GetSize()) } func TestSimpleDirGetUsage(t *testing.T) { d := &SimpleDir{SimpleFile: SimpleFile{Usage: 5678}} assert.Equal(t, int64(5678), d.GetUsage()) } func TestSimpleDirUpdateStats(t *testing.T) { d := &SimpleDir{ SimpleFile: SimpleFile{Name: "root", IsDir: true}, Files: []SimpleFile{ {Name: "file1", Size: 100, Usage: 200, ItemCount: 1}, {Name: "file2", Size: 50, Usage: 80, ItemCount: 1}, {Name: "subdir", Size: 300, Usage: 400, ItemCount: 5, IsDir: true}, }, } d.UpdateStats(make(fs.HardLinkedItems)) assert.Equal(t, int64(100+50+300+4096), d.Size) assert.Equal(t, int64(200+80+400+4096), d.Usage) assert.Equal(t, int64(1+1+5+1), d.ItemCount) } func TestSimpleDirUpdateStatsErrorFlag(t *testing.T) { d := &SimpleDir{ SimpleFile: SimpleFile{Name: "root", IsDir: true}, Files: []SimpleFile{ {Name: "sub", Size: 100, Usage: 200, ItemCount: 1, Flag: '!'}, }, } d.UpdateStats(make(fs.HardLinkedItems)) assert.Equal(t, '.', d.Flag) } func TestSimpleDirUpdateStatsErrorFlagPreserved(t *testing.T) { d := &SimpleDir{ SimpleFile: SimpleFile{Name: "root", IsDir: true, Flag: '!'}, Files: []SimpleFile{ {Name: "sub", Size: 100, Usage: 200, ItemCount: 1, Flag: '!'}, }, } d.UpdateStats(make(fs.HardLinkedItems)) // '!' flag on dir should be preserved (not downgraded to '.') assert.Equal(t, '!', d.Flag) } func TestSimpleDirUpdateStatsDotFlag(t *testing.T) { d := &SimpleDir{ SimpleFile: SimpleFile{Name: "root", IsDir: true}, Files: []SimpleFile{ {Name: "sub", Size: 100, Usage: 200, ItemCount: 1, Flag: '.'}, }, } d.UpdateStats(make(fs.HardLinkedItems)) assert.Equal(t, '.', d.Flag) } func TestSimpleDirGetFilesSort(t *testing.T) { d := &SimpleDir{ SimpleFile: SimpleFile{Name: "root", IsDir: true}, Files: []SimpleFile{ {Name: "small", Size: 10, Usage: 10}, {Name: "big", Size: 100, Usage: 100}, {Name: "medium", Size: 50, Usage: 50}, }, } // Collect files sorted by size descending (default sort is desc for size) var names []string for item := range d.GetFiles(fs.SortBySize, fs.SortAsc) { names = append(names, item.GetName()) } assert.Equal(t, 3, len(names)) } func TestSimpleDirGetFilesDirVsFile(t *testing.T) { d := &SimpleDir{ SimpleFile: SimpleFile{Name: "root", IsDir: true}, Files: []SimpleFile{ {Name: "afile", Size: 10, Usage: 10, IsDir: false}, {Name: "adir", Size: 100, Usage: 100, IsDir: true, ItemCount: 5}, }, } var items []fs.Item for item := range d.GetFiles(fs.SortBySize, fs.SortAsc) { items = append(items, item) } assert.Equal(t, 2, len(items)) // The dir entry should be a *Dir with ItemCount for _, item := range items { if item.GetName() == "adir" { assert.True(t, item.IsDir()) assert.Equal(t, int64(5), item.GetItemCount()) } } } func TestSimpleDirPanics(t *testing.T) { d := &SimpleDir{} assert.Panics(t, func() { d.GetPath() }) assert.Panics(t, func() { d.GetFlag() }) assert.Panics(t, func() { d.IsDir() }) assert.Panics(t, func() { d.GetType() }) assert.Panics(t, func() { d.GetMtime() }) assert.Panics(t, func() { d.GetItemCount() }) assert.Panics(t, func() { d.GetParent() }) assert.Panics(t, func() { d.SetParent(nil) }) assert.Panics(t, func() { d.GetMultiLinkedInode() }) assert.Panics(t, func() { d.EncodeJSON(nil, false) }) assert.Panics(t, func() { d.GetItemStats(nil) }) assert.Panics(t, func() { d.AddFile(nil) }) assert.Panics(t, func() { d.GetFilesLocked(fs.SortBySize, fs.SortAsc) }) assert.Panics(t, func() { d.RemoveFile(nil) }) assert.Panics(t, func() { d.RemoveFileByName("x") }) assert.Panics(t, func() { d.RLock() }) } gdu-5.36.1/pkg/analyze/top_test.go000066400000000000000000000037151517447455500170300ustar00rootroot00000000000000package analyze import ( "sort" "testing" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func TestCollectTopFiles2(t *testing.T) { fin := testdir.CreateTestDir() defer fin() dir := CreateAnalyzer().AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) topFiles := CollectTopFiles(dir, 2) assert.Equal(t, 2, len(topFiles)) assert.Equal(t, "file", topFiles[0].GetName()) assert.Equal(t, int64(5), topFiles[0].GetSize()) assert.Equal(t, "file2", topFiles[1].GetName()) assert.Equal(t, int64(2), topFiles[1].GetSize()) } func TestCollectTopFiles1(t *testing.T) { fin := testdir.CreateTestDir() defer fin() dir := CreateAnalyzer().AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ) topFiles := CollectTopFiles(dir, 1) assert.Equal(t, 1, len(topFiles)) assert.Equal(t, "file", topFiles[0].GetName()) assert.Equal(t, int64(5), topFiles[0].GetSize()) } func TestAdd2(t *testing.T) { topList := NewTopList(2) topList.Add(&File{Size: 1, Name: "file1"}) topList.Add(&File{Size: 5, Name: "file5"}) topList.Add(&File{Size: 2, Name: "file2"}) sort.Sort(sort.Reverse(fs.ByApparentSize(topList.Items))) assert.Equal(t, 2, len(topList.Items)) assert.Equal(t, "file5", topList.Items[0].GetName()) assert.Equal(t, "file2", topList.Items[1].GetName()) } func TestAdd3(t *testing.T) { topList := NewTopList(3) topList.Add(&File{Size: 5, Name: "file5"}) topList.Add(&File{Size: 1, Name: "file1"}) topList.Add(&File{Size: 2, Name: "file2"}) topList.Add(&File{Size: 4, Name: "file4"}) topList.Add(&File{Size: 3, Name: "file3"}) sort.Sort(sort.Reverse(fs.ByApparentSize(topList.Items))) assert.Equal(t, 3, len(topList.Items)) assert.Equal(t, "file5", topList.Items[0].GetName()) assert.Equal(t, "file4", topList.Items[1].GetName()) assert.Equal(t, "file3", topList.Items[2].GetName()) } gdu-5.36.1/pkg/analyze/wait.go000066400000000000000000000015711517447455500161310ustar00rootroot00000000000000package analyze import "sync" // A WaitGroup waits for a collection of goroutines to finish. // In contrast to sync.WaitGroup Add method can be called from a goroutine. type WaitGroup struct { wait sync.Mutex value int access sync.Mutex } // Init prepares the WaitGroup for usage, locks func (s *WaitGroup) Init() *WaitGroup { s.wait.Lock() return s } // Add increments value func (s *WaitGroup) Add(value int) { s.access.Lock() s.value += value s.access.Unlock() } // Done decrements the value by one, if value is 0, lock is released func (s *WaitGroup) Done() { s.access.Lock() s.value-- s.check() s.access.Unlock() } // Wait blocks until value is 0 func (s *WaitGroup) Wait() { s.access.Lock() isValue := s.value > 0 s.access.Unlock() if isValue { s.wait.Lock() } } func (s *WaitGroup) check() { if s.value == 0 { s.wait.TryLock() s.wait.Unlock() } } gdu-5.36.1/pkg/analyze/zipdir.go000066400000000000000000000107321517447455500164650ustar00rootroot00000000000000package analyze import ( "archive/zip" "io" "os" "path/filepath" "strings" "time" "github.com/dundee/gdu/v5/pkg/fs" ) // ZipDir represents a directory structure inside a zip file type ZipDir struct { *Dir zipPath string // path to the original zip file } // ZipFile represents a file inside a zip archive type ZipFile struct { *File zipPath string inZipPath string // path inside the zip file } // GetPath returns the virtual path for zip file func (zf *ZipFile) GetPath() string { return zf.zipPath + "/" + zf.inZipPath } // GetType returns type of zip file func (zf *ZipFile) GetType() string { return "ZipFile" } // EncodeJSON encodes zip file to JSON func (zf *ZipFile) EncodeJSON(writer io.Writer, topLevel bool) error { // Use the embedded File's EncodeJSON method return zf.File.EncodeJSON(writer, topLevel) } // GetType returns type of zip directory func (zd *ZipDir) GetType() string { return "ZipDirectory" } // IsDir returns true for ZipDir func (zd *ZipDir) IsDir() bool { return true } // EncodeJSON encodes zip directory to JSON func (zd *ZipDir) EncodeJSON(writer io.Writer, topLevel bool) error { // Use the embedded Dir's EncodeJSON method return zd.Dir.EncodeJSON(writer, topLevel) } // GetPath returns the virtual path for zip directory func (zd *ZipDir) GetPath() string { if zd.Parent != nil { return filepath.Join(zd.Parent.GetPath(), zd.Name) } return zd.zipPath } // isZipFile checks if a file is a zip or jar file func isZipFile(filename string) bool { ext := strings.ToLower(filepath.Ext(filename)) return ext == ".zip" || ext == ".jar" } // processZipFile processes a zip file and returns a ZipDir representing its contents func processZipFile(zipPath string, info os.FileInfo) (zipDir *ZipDir, err error) { reader, err := zip.OpenReader(zipPath) if err != nil { return nil, err } defer reader.Close() // Create root directory zipDir = &ZipDir{ Dir: &Dir{ File: &File{ Name: filepath.Base(zipPath), Flag: 'Z', // Use 'Z' to identify zip files Size: info.Size(), Usage: info.Size(), Mtime: info.ModTime(), }, ItemCount: 1, Files: make(fs.Files, 0), }, zipPath: zipPath, } // Use map to store directory structure dirMap := make(map[string]*ZipDir) dirMap[""] = zipDir // root directory for _, f := range reader.File { if f.FileInfo().IsDir() { continue // Skip directory entries, we'll create them automatically based on file paths } // Parse file path and ensure all parent directories exist dirPath := filepath.Dir(f.Name) if dirPath == "." { dirPath = "" // root directory } ensureZipDirExists(dirMap, dirPath, zipPath, zipDir) // Create file item parentDir := dirMap[dirPath] zipFile := &ZipFile{ File: &File{ Name: filepath.Base(f.Name), Flag: ' ', Size: int64(f.UncompressedSize64), Usage: int64(f.CompressedSize64), Mtime: f.FileInfo().ModTime(), Parent: parentDir, }, zipPath: zipPath, inZipPath: f.Name, } parentDir.AddFile(zipFile) } return zipDir, nil } // ensureZipDirExists ensures all directories in the specified path exist func ensureZipDirExists(dirMap map[string]*ZipDir, path, zipPath string, rootDir *ZipDir) { if path == "" || path == "." { return } // If directory already exists, return directly if _, exists := dirMap[path]; exists { return } // Ensure parent directory exists parentPath := filepath.Dir(path) if parentPath != "." && parentPath != "" { ensureZipDirExists(dirMap, parentPath, zipPath, rootDir) } // Create current directory var parent *ZipDir if parentPath == "" || parentPath == "." { parent = rootDir } else { parent = dirMap[parentPath] } newDir := &ZipDir{ Dir: &Dir{ File: &File{ Name: filepath.Base(path), Flag: 'Z', Size: 4096, // virtual directory size Usage: 4096, Mtime: time.Now(), Parent: parent, }, ItemCount: 1, Files: make(fs.Files, 0), }, zipPath: zipPath, } dirMap[path] = newDir parent.AddFile(newDir) } // getZipFileSize gets the total uncompressed size of a zip file func getZipFileSize(zipPath string) (uncompressed, compressed int64, err error) { reader, err := zip.OpenReader(zipPath) if err != nil { return 0, 0, err } defer reader.Close() var uncompressedSize, compressedSize int64 for _, f := range reader.File { if !f.FileInfo().IsDir() { uncompressedSize += int64(f.UncompressedSize64) compressedSize += int64(f.CompressedSize64) } } return uncompressedSize, compressedSize, nil } gdu-5.36.1/pkg/analyze/zipdir_coverage_test.go000066400000000000000000000211301517447455500213710ustar00rootroot00000000000000package analyze import ( "archive/zip" "bytes" "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" ) func TestZipFileGetPath(t *testing.T) { zipFile := &ZipFile{ zipPath: "/path/to/archive.zip", inZipPath: "folder/file.txt", } path := zipFile.GetPath() assert.Equal(t, "/path/to/archive.zip/folder/file.txt", path) } func TestZipFileEncodeJSON(t *testing.T) { zipFile := &ZipFile{ File: &File{ Name: "test.txt", Size: 100, }, zipPath: "/path/to/archive.zip", inZipPath: "test.txt", } var buf bytes.Buffer err := zipFile.EncodeJSON(&buf, false) assert.NoError(t, err) assert.NotEmpty(t, buf.String()) } func TestZipDirEncodeJSON(t *testing.T) { zipDir := &ZipDir{ Dir: &Dir{ File: &File{ Name: "folder", }, }, zipPath: "/path/to/archive.zip", } var buf bytes.Buffer err := zipDir.EncodeJSON(&buf, false) assert.NoError(t, err) assert.NotEmpty(t, buf.String()) } func TestZipDirGetPathWithParent(t *testing.T) { parent := &ZipDir{ Dir: &Dir{ File: &File{ Name: "parent", }, }, zipPath: "/path/to/archive.zip", } zipDir := &ZipDir{ Dir: &Dir{ File: &File{ Name: "child", }, }, zipPath: "/path/to/archive.zip", } zipDir.Parent = parent path := zipDir.GetPath() assert.Equal(t, filepath.Join(parent.GetPath(), "child"), path) } func TestZipDirGetPathWithoutParent(t *testing.T) { zipDir := &ZipDir{ Dir: &Dir{ File: &File{ Name: "root", }, }, zipPath: "/path/to/archive.zip", } path := zipDir.GetPath() assert.Equal(t, "/path/to/archive.zip", path) } func TestProcessZipFileWithEmptyZip(t *testing.T) { // Create a temporary zip file zipPath := "/tmp/empty.zip" defer os.Remove(zipPath) // Create an empty zip file file, err := os.Create(zipPath) assert.NoError(t, err) file.Close() // Create a zip file with no entries zipFile, err := os.Create(zipPath) assert.NoError(t, err) writer := zip.NewWriter(zipFile) writer.Close() zipFile.Close() info, err := os.Stat(zipPath) assert.NoError(t, err) zipDir, err := processZipFile(zipPath, info) assert.NoError(t, err) assert.NotNil(t, zipDir) assert.Equal(t, "empty.zip", zipDir.Name) assert.Equal(t, 'Z', zipDir.Flag) } func TestProcessZipFileWithDirectoryEntries(t *testing.T) { // Create a temporary zip file zipPath := "/tmp/dir_entries.zip" defer os.Remove(zipPath) // Create a zip file with directory entries zipFile, err := os.Create(zipPath) assert.NoError(t, err) writer := zip.NewWriter(zipFile) // Add a directory entry _, err = writer.Create("folder/") assert.NoError(t, err) // Add a file in the directory fileWriter, err := writer.Create("folder/file.txt") assert.NoError(t, err) fileWriter.Write([]byte("test content")) writer.Close() zipFile.Close() info, err := os.Stat(zipPath) assert.NoError(t, err) zipDir, err := processZipFile(zipPath, info) assert.NoError(t, err) assert.NotNil(t, zipDir) assert.Equal(t, "dir_entries.zip", zipDir.Name) } func TestProcessZipFileWithNestedDirectories(t *testing.T) { // Create a temporary zip file zipPath := "/tmp/nested.zip" defer os.Remove(zipPath) // Create a zip file with nested directories zipFile, err := os.Create(zipPath) assert.NoError(t, err) writer := zip.NewWriter(zipFile) // Add files in nested directories fileWriter, err := writer.Create("level1/level2/file.txt") assert.NoError(t, err) fileWriter.Write([]byte("nested content")) writer.Close() zipFile.Close() info, err := os.Stat(zipPath) assert.NoError(t, err) zipDir, err := processZipFile(zipPath, info) assert.NoError(t, err) assert.NotNil(t, zipDir) assert.Equal(t, "nested.zip", zipDir.Name) } func TestProcessZipFileWithRootFiles(t *testing.T) { // Create a temporary zip file zipPath := "/tmp/root_files.zip" defer os.Remove(zipPath) // Create a zip file with files in root zipFile, err := os.Create(zipPath) assert.NoError(t, err) writer := zip.NewWriter(zipFile) // Add files in root directory fileWriter, err := writer.Create("file1.txt") assert.NoError(t, err) fileWriter.Write([]byte("file1 content")) fileWriter, err = writer.Create("file2.txt") assert.NoError(t, err) fileWriter.Write([]byte("file2 content")) writer.Close() zipFile.Close() info, err := os.Stat(zipPath) assert.NoError(t, err) zipDir, err := processZipFile(zipPath, info) assert.NoError(t, err) assert.NotNil(t, zipDir) assert.Equal(t, "root_files.zip", zipDir.Name) } func TestProcessZipFileError(t *testing.T) { // Test with non-existent file zipDir, err := processZipFile("/non/existent/file.zip", nil) assert.Error(t, err) assert.Nil(t, zipDir) } func TestGetZipFileSizeWithEmptyZip(t *testing.T) { // Create a temporary zip file zipPath := "/tmp/empty_size.zip" defer os.Remove(zipPath) // Create an empty zip file zipFile, err := os.Create(zipPath) assert.NoError(t, err) writer := zip.NewWriter(zipFile) writer.Close() zipFile.Close() uncompressed, compressed, err := getZipFileSize(zipPath) assert.NoError(t, err) assert.Equal(t, int64(0), uncompressed) assert.Equal(t, int64(0), compressed) } func TestGetZipFileSizeWithFiles(t *testing.T) { // Create a temporary zip file zipPath := "/tmp/size_test.zip" defer os.Remove(zipPath) // Create a zip file with files zipFile, err := os.Create(zipPath) assert.NoError(t, err) writer := zip.NewWriter(zipFile) // Add a file fileWriter, err := writer.Create("test.txt") assert.NoError(t, err) fileWriter.Write([]byte("test content")) writer.Close() zipFile.Close() uncompressed, compressed, err := getZipFileSize(zipPath) assert.NoError(t, err) assert.Greater(t, uncompressed, int64(0)) assert.Greater(t, compressed, int64(0)) } func TestGetZipFileSizeWithDirectories(t *testing.T) { // Create a temporary zip file zipPath := "/tmp/dir_size.zip" defer os.Remove(zipPath) // Create a zip file with directories zipFile, err := os.Create(zipPath) assert.NoError(t, err) writer := zip.NewWriter(zipFile) // Add a directory entry (should be ignored) _, err = writer.Create("folder/") assert.NoError(t, err) // Add a file fileWriter, err := writer.Create("file.txt") assert.NoError(t, err) fileWriter.Write([]byte("test content")) writer.Close() zipFile.Close() uncompressed, compressed, err := getZipFileSize(zipPath) assert.NoError(t, err) assert.Greater(t, uncompressed, int64(0)) assert.Greater(t, compressed, int64(0)) } func TestGetZipFileSizeError(t *testing.T) { // Test with non-existent file uncompressed, compressed, err := getZipFileSize("/non/existent/file.zip") assert.Error(t, err) assert.Equal(t, int64(0), uncompressed) assert.Equal(t, int64(0), compressed) } func TestEnsureZipDirExistsWithEmptyPath(t *testing.T) { dirMap := make(map[string]*ZipDir) rootDir := &ZipDir{ Dir: &Dir{ File: &File{ Name: "root", }, }, zipPath: "/test.zip", } ensureZipDirExists(dirMap, "", "/test.zip", rootDir) // Should not create any new directories for empty path assert.Len(t, dirMap, 0) } func TestEnsureZipDirExistsWithDotPath(t *testing.T) { dirMap := make(map[string]*ZipDir) rootDir := &ZipDir{ Dir: &Dir{ File: &File{ Name: "root", }, }, zipPath: "/test.zip", } ensureZipDirExists(dirMap, ".", "/test.zip", rootDir) // Should not create any new directories for dot path assert.Len(t, dirMap, 0) } func TestEnsureZipDirExistsWithExistingPath(t *testing.T) { dirMap := make(map[string]*ZipDir) existingDir := &ZipDir{ Dir: &Dir{ File: &File{ Name: "existing", }, }, zipPath: "/test.zip", } dirMap["existing"] = existingDir rootDir := &ZipDir{ Dir: &Dir{ File: &File{ Name: "root", }, }, zipPath: "/test.zip", } ensureZipDirExists(dirMap, "existing", "/test.zip", rootDir) // Should not create a new directory for existing path assert.Len(t, dirMap, 1) assert.Equal(t, existingDir, dirMap["existing"]) } func TestEnsureZipDirExistsWithNestedPath(t *testing.T) { dirMap := make(map[string]*ZipDir) rootDir := &ZipDir{ Dir: &Dir{ File: &File{ Name: "root", }, }, zipPath: "/test.zip", } dirMap[""] = rootDir ensureZipDirExists(dirMap, "level1/level2", "/test.zip", rootDir) // Should create both level1 and level1/level2 directories assert.Contains(t, dirMap, "level1") assert.Contains(t, dirMap, "level1/level2") assert.Equal(t, "level1", dirMap["level1"].Name) assert.Equal(t, "level2", dirMap["level1/level2"].Name) } func TestIsZipFileFunction(t *testing.T) { assert.True(t, isZipFile("test.zip")) assert.True(t, isZipFile("test.ZIP")) assert.True(t, isZipFile("test.jar")) assert.True(t, isZipFile("test.JAR")) assert.False(t, isZipFile("test.txt")) assert.False(t, isZipFile("test.tar")) assert.False(t, isZipFile("test.gz")) } gdu-5.36.1/pkg/analyze/zipdir_integration_test.go000066400000000000000000000112571517447455500221320ustar00rootroot00000000000000package analyze import ( "archive/zip" "os" "path/filepath" "testing" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func TestSequentialAnalyzerWithZipFile(t *testing.T) { // Create temporary directory and zip file tempDir := t.TempDir() zipPath := filepath.Join(tempDir, "test.zip") // Create test zip file createTestZipFile(t, zipPath) // Create analyzer analyzer := CreateSeqAnalyzer() analyzer.SetArchiveBrowsing(true) // Analyze directory (containing zip file) result := analyzer.AnalyzeDir(tempDir, func(string, string) bool { return false }, func(string) bool { return false }) // Verify result assert.NotNil(t, result) assert.True(t, result.IsDir()) // Find zip file var zipItem fs.Item for file := range result.GetFiles(fs.SortByName, fs.SortAsc) { if file.GetName() == "test.zip" { zipItem = file break } } assert.NotNil(t, zipItem, "should find zip file") assert.True(t, zipItem.IsDir(), "zip file should be treated as directory") // Verify zip file content zipFilesCount := 0 foundTextFile := false for file := range zipItem.GetFiles(fs.SortByName, fs.SortAsc) { zipFilesCount++ if file.GetName() == "test.txt" { foundTextFile = true assert.False(t, file.IsDir()) } } assert.Greater(t, zipFilesCount, 0, "zip file should contain content") assert.True(t, foundTextFile, "should find test.txt in zip file") } func TestParallelAnalyzerWithZipFile(t *testing.T) { // Create temporary directory and zip file tempDir := t.TempDir() zipPath := filepath.Join(tempDir, "test.jar") // test jar file // Create test jar file (actually a zip file) createTestZipFile(t, zipPath) // Create parallel analyzer analyzer := CreateAnalyzer() analyzer.SetArchiveBrowsing(true) // Analyze directory result := analyzer.AnalyzeDir(tempDir, func(string, string) bool { return false }, func(string) bool { return false }) // Verify result assert.NotNil(t, result) assert.True(t, result.IsDir()) // Find jar file var jarItem fs.Item for file := range result.GetFiles(fs.SortByName, fs.SortAsc) { if file.GetName() == "test.jar" { jarItem = file break } } assert.NotNil(t, jarItem, "should find jar file") assert.True(t, jarItem.IsDir(), "jar file should be treated as directory") // Verify jar file content jarFilesCount := 0 for range jarItem.GetFiles(fs.SortByName, fs.SortAsc) { jarFilesCount++ } assert.Greater(t, jarFilesCount, 0, "jar file should contain content") } func TestZipFileWithNestedStructure(t *testing.T) { // Create temporary directory tempDir := t.TempDir() zipPath := filepath.Join(tempDir, "nested.zip") // Create zip file with complex nested structure createComplexZipFile(t, zipPath) // Create analyzer analyzer := CreateSeqAnalyzer() analyzer.SetArchiveBrowsing(true) // Analyze directory result := analyzer.AnalyzeDir(tempDir, func(string, string) bool { return false }, func(string) bool { return false }) // Find zip file var zipItem fs.Item for file := range result.GetFiles(fs.SortByName, fs.SortAsc) { if file.GetName() == "nested.zip" { zipItem = file break } } assert.NotNil(t, zipItem) // Find deeply nested directory var level1Dir fs.Item for file := range zipItem.GetFiles(fs.SortByName, fs.SortAsc) { if file.GetName() == "level1" && file.IsDir() { level1Dir = file break } } assert.NotNil(t, level1Dir, "should find level1 directory") // Find level2 directory var level2Dir fs.Item for file := range level1Dir.GetFiles(fs.SortByName, fs.SortAsc) { if file.GetName() == "level2" && file.IsDir() { level2Dir = file break } } assert.NotNil(t, level2Dir, "should find level2 directory") // Find deepest nested file foundDeepFile := false for file := range level2Dir.GetFiles(fs.SortByName, fs.SortAsc) { if file.GetName() == "deep.txt" { foundDeepFile = true break } } assert.True(t, foundDeepFile, "should find deeply nested file") } // createComplexZipFile creates a zip file with complex nested structure func createComplexZipFile(t *testing.T, zipPath string) { file, err := os.Create(zipPath) assert.NoError(t, err) defer file.Close() zipWriter := zip.NewWriter(file) defer zipWriter.Close() // Create multi-level nested structure files := []struct { name string content string }{ {"root.txt", "Root level file"}, {"level1/file1.txt", "Level 1 file"}, {"level1/level2/file2.txt", "Level 2 file"}, {"level1/level2/deep.txt", "Deep nested file"}, {"level1/level2/level3/file3.txt", "Level 3 file"}, {"another/path/file.txt", "Another path file"}, } for _, f := range files { writer, err := zipWriter.Create(f.name) assert.NoError(t, err) _, err = writer.Write([]byte(f.content)) assert.NoError(t, err) } } gdu-5.36.1/pkg/analyze/zipdir_test.go000066400000000000000000000104241517447455500175220ustar00rootroot00000000000000package analyze import ( "archive/zip" "os" "path/filepath" "slices" "testing" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func TestIsZipFile(t *testing.T) { tests := []struct { filename string expected bool }{ {"test.zip", true}, {"test.jar", true}, {"TEST.ZIP", true}, {"TEST.JAR", true}, {"test.txt", false}, {"test.tar.gz", false}, {"test", false}, {"", false}, } for _, test := range tests { result := isZipFile(test.filename) assert.Equal(t, test.expected, result, "filename: %s", test.filename) } } func TestProcessZipFile(t *testing.T) { // Create temporary zip file tempDir := t.TempDir() zipPath := filepath.Join(tempDir, "test.zip") // Create zip file createTestZipFile(t, zipPath) // Get file info info, err := os.Stat(zipPath) assert.NoError(t, err) // Process zip file zipDir, err := processZipFile(zipPath, info) assert.NoError(t, err) assert.NotNil(t, zipDir) // Verify zip directory properties assert.Equal(t, "test.zip", zipDir.GetName(), "Name must include extension") assert.Equal(t, rune('Z'), zipDir.GetFlag()) assert.True(t, zipDir.IsDir()) assert.Equal(t, "ZipDirectory", zipDir.GetType()) // Verify file structure files := slices.Collect(zipDir.GetFiles(fs.SortByName, fs.SortAsc)) assert.Greater(t, len(files), 0) // Debug: print all files t.Logf("Found %d files in zip:", len(files)) for _, file := range files { t.Logf(" - %s (isDir: %t, type: %s)", file.GetName(), file.IsDir(), file.GetType()) } // Find files foundTextFile := false foundSubdir := false for _, file := range files { if file.GetName() == "test.txt" { foundTextFile = true assert.False(t, file.IsDir()) assert.Equal(t, "ZipFile", file.GetType()) } if file.GetName() == "subdir" { foundSubdir = true assert.True(t, file.IsDir()) assert.Equal(t, "ZipDirectory", file.GetType()) } } assert.True(t, foundTextFile, "should find test.txt file") assert.True(t, foundSubdir, "should find subdir directory") } func TestGetZipFileSize(t *testing.T) { // Create temporary zip file tempDir := t.TempDir() zipPath := filepath.Join(tempDir, "test.zip") // Create zip file createTestZipFile(t, zipPath) // Get size uncompressed, compressed, err := getZipFileSize(zipPath) assert.NoError(t, err) assert.Greater(t, uncompressed, int64(0)) assert.Greater(t, compressed, int64(0)) // Note: for small files, compressed size might be larger t.Logf("Uncompressed size: %d, Compressed size: %d", uncompressed, compressed) } func TestEnsureZipDirExists(t *testing.T) { tempDir := t.TempDir() zipPath := filepath.Join(tempDir, "test.zip") // Create root directory rootDir := &ZipDir{ Dir: &Dir{ File: &File{ Name: "test.zip", Flag: 'Z', }, Files: make(fs.Files, 0), }, zipPath: zipPath, } dirMap := make(map[string]*ZipDir) dirMap[""] = rootDir // Ensure nested directory structure is created ensureZipDirExists(dirMap, "dir1/dir2/dir3", zipPath, rootDir) // Verify directory structure assert.Contains(t, dirMap, "dir1") assert.Contains(t, dirMap, "dir1/dir2") assert.Contains(t, dirMap, "dir1/dir2/dir3") // Verify parent-child relationships dir1 := dirMap["dir1"] assert.Equal(t, rootDir, dir1.GetParent()) dir2 := dirMap["dir1/dir2"] assert.Equal(t, dir1, dir2.GetParent()) dir3 := dirMap["dir1/dir2/dir3"] assert.Equal(t, dir2, dir3.GetParent()) } // createTestZipFile creates a test zip file func createTestZipFile(t *testing.T, zipPath string) { file, err := os.Create(zipPath) assert.NoError(t, err) defer file.Close() zipWriter := zip.NewWriter(file) defer zipWriter.Close() // Add root directory file writer, err := zipWriter.Create("test.txt") assert.NoError(t, err) _, err = writer.Write([]byte("Hello, this is a test file!")) assert.NoError(t, err) // Add subdirectory files // We don't need to use the writer for the directory entry, avoid SA4006 _, err = zipWriter.Create("subdir/") assert.NoError(t, err) writer, err = zipWriter.Create("subdir/nested.txt") assert.NoError(t, err) _, err = writer.Write([]byte("This is a nested file.")) assert.NoError(t, err) // Add deeper directory structure writer, err = zipWriter.Create("dir1/dir2/deep.txt") assert.NoError(t, err) _, err = writer.Write([]byte("Deep nested file content.")) assert.NoError(t, err) } gdu-5.36.1/pkg/annex/000077500000000000000000000000001517447455500143005ustar00rootroot00000000000000gdu-5.36.1/pkg/annex/annex.go000066400000000000000000000023671517447455500157500ustar00rootroot00000000000000package annex import ( "fmt" "io/fs" "log" "strconv" "strings" ) // SizeFromKey returns size from git-annex key. func SizeFromKey(name string) (size int64, err error) { nameParts := strings.SplitN(name, "--", 2) backendKVs := nameParts[0] backendKVParts := strings.Split(backendKVs, "-") if len(backendKVParts) < 2 { return 0, fmt.Errorf("key is is missing backend") } for _, p := range backendKVParts[1:] { if p == "" || p[0] != 's' { continue } size, err = strconv.ParseInt(p[1:], 10, 64) if err != nil { return 0, fmt.Errorf("failed to parse size: %w", err) } return size, nil } return 0, fmt.Errorf("size not found in key") } // AnnexedFileInfo returns a new FileInfo with size from git-annex key. func AnnexedFileInfo(fi fs.FileInfo, name string) *FileInfo { size, err := SizeFromKey(name) if err != nil { log.Print(err.Error()) return &FileInfo{FileInfo: fi} } afi := &FileInfo{ FileInfo: fi, size: size, } return afi } var _ fs.FileInfo = (*FileInfo)(nil) // FileInfo is a wrapper around fs.FileInfo to overwrite the size. type FileInfo struct { fs.FileInfo size int64 } // Length in bytes for regular files; system-dependent for others func (fi *FileInfo) Size() int64 { return int64(fi.size) } gdu-5.36.1/pkg/annex/annex_test.go000066400000000000000000000021761517447455500170050ustar00rootroot00000000000000package annex import ( "testing" "github.com/stretchr/testify/assert" ) func TestAnnexedFileInfo(t *testing.T) { fi := &FileInfo{} fi = AnnexedFileInfo(fi, "SHA256E-s967858083--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4") assert.Equal(t, int64(967858083), fi.Size()) } func TestAnnexedFileInfoErr(t *testing.T) { fi := &FileInfo{} fi = AnnexedFileInfo(fi, "xxx") assert.Equal(t, int64(0), fi.Size()) } func TestSizeFromKeyErr(t *testing.T) { _, err := SizeFromKey("xxx") assert.Error(t, err) assert.ErrorContains(t, err, "key is is missing backend") _, err = SizeFromKey("SHA256E-sXXX--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4") assert.Error(t, err) assert.ErrorContains(t, err, "failed to parse size") _, err = SizeFromKey("SHA256E-s--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4") assert.Error(t, err) assert.ErrorContains(t, err, "failed to parse size") _, err = SizeFromKey("SHA256E-a-b-c--3e54803fded8dc3a9ea68b106f7b51e04e33c79b4a7b32a860f0b22d89af5c65.mp4") assert.Error(t, err) assert.ErrorContains(t, err, "size not found in key") } gdu-5.36.1/pkg/device/000077500000000000000000000000001517447455500144265ustar00rootroot00000000000000gdu-5.36.1/pkg/device/dev.go000066400000000000000000000025101517447455500155310ustar00rootroot00000000000000package device import "strings" // Device struct type Device struct { Name string MountPoint string Fstype string Size int64 Free int64 } // GetUsage returns used size of device func (d Device) GetUsage() int64 { return d.Size - d.Free } // DevicesInfoGetter is type for GetDevicesInfo function type DevicesInfoGetter interface { GetMounts() (Devices, error) GetDevicesInfo() (Devices, error) } // Devices if slice of Device items type Devices []*Device // ByUsedSize sorts devices by used size type ByUsedSize Devices func (f ByUsedSize) Len() int { return len(f) } func (f ByUsedSize) Swap(i, j int) { f[i], f[j] = f[j], f[i] } func (f ByUsedSize) Less(i, j int) bool { return f[i].GetUsage() < f[j].GetUsage() } // ByName sorts devices by device name type ByName Devices func (f ByName) Len() int { return len(f) } func (f ByName) Swap(i, j int) { f[i], f[j] = f[j], f[i] } func (f ByName) Less(i, j int) bool { return f[i].Name < f[j].Name } // GetNestedMountpointsPaths returns paths of nested mount points func GetNestedMountpointsPaths(path string, mounts Devices) []string { paths := make([]string, 0, len(mounts)) for _, mount := range mounts { if strings.HasPrefix(mount.MountPoint, path) && mount.MountPoint != path { paths = append(paths, mount.MountPoint) } } return paths } gdu-5.36.1/pkg/device/dev_bsd.go000066400000000000000000000030731517447455500163660ustar00rootroot00000000000000//go:build netbsd || openbsd package device import ( "bufio" "bytes" "errors" "io" "os/exec" "regexp" "strings" ) // BSDDevicesInfoGetter returns info for Darwin devices type BSDDevicesInfoGetter struct { MountCmd string } // Getter is current instance of DevicesInfoGetter var Getter DevicesInfoGetter = BSDDevicesInfoGetter{MountCmd: "/sbin/mount"} // GetMounts returns all mounted filesystems from output of /sbin/mount func (t BSDDevicesInfoGetter) GetMounts() (devices Devices, err error) { out, err := exec.Command(t.MountCmd).Output() if err != nil { return nil, err } rdr := bytes.NewReader(out) return readMountOutput(rdr) } // GetDevicesInfo returns result of GetMounts with usage info about mounted devices (by calling Statfs syscall) func (t BSDDevicesInfoGetter) GetDevicesInfo() (devices Devices, err error) { mounts, err := t.GetMounts() if err != nil { return nil, err } return processMounts(mounts, false) } func readMountOutput(rdr io.Reader) (mounts Devices, err error) { scanner := bufio.NewScanner(rdr) for scanner.Scan() { line := scanner.Text() re := regexp.MustCompile("^(.*) on (/.*) type (.*) \\(([^)]+)\\)$") parts := re.FindAllStringSubmatch(line, -1) if len(parts) < 1 { return nil, errors.New("Cannot parse mount output") } fstype := strings.TrimSpace(strings.Split(parts[0][3], ",")[0]) device := &Device{ Name: parts[0][1], MountPoint: parts[0][2], Fstype: fstype, } mounts = append(mounts, device) } if err := scanner.Err(); err != nil { return nil, err } return mounts, nil } gdu-5.36.1/pkg/device/dev_bsd_test.go000066400000000000000000000010201517447455500174130ustar00rootroot00000000000000//go:build freebsd || openbsd || netbsd || darwin package device import ( "testing" "github.com/stretchr/testify/assert" ) func TestGetDevicesInfo(t *testing.T) { getter := BSDDevicesInfoGetter{MountCmd: "/sbin/mount"} devices, _ := getter.GetDevicesInfo() assert.IsType(t, Devices{}, devices) } func TestGetDevicesInfoFail(t *testing.T) { getter := BSDDevicesInfoGetter{MountCmd: "/nonexistent"} _, err := getter.GetDevicesInfo() assert.Equal(t, "fork/exec /nonexistent: no such file or directory", err.Error()) } gdu-5.36.1/pkg/device/dev_freebsd_darwin_other.go000066400000000000000000000041471517447455500220000ustar00rootroot00000000000000//go:build freebsd || darwin package device import ( "bufio" "bytes" "errors" "io" "os/exec" "regexp" "strings" "golang.org/x/sys/unix" ) // BSDDevicesInfoGetter returns info for Darwin devices type BSDDevicesInfoGetter struct { MountCmd string } // Getter is current instance of DevicesInfoGetter var Getter DevicesInfoGetter = BSDDevicesInfoGetter{MountCmd: "/sbin/mount"} // GetMounts returns all mounted filesystems from output of /sbin/mount func (t BSDDevicesInfoGetter) GetMounts() (devices Devices, err error) { var out []byte out, err = exec.Command(t.MountCmd).Output() if err != nil { return nil, err } rdr := bytes.NewReader(out) return readMountOutput(rdr) } // GetDevicesInfo returns result of GetMounts with usage info about mounted devices (by calling Statfs syscall) func (t BSDDevicesInfoGetter) GetDevicesInfo() (devices Devices, err error) { var mounts Devices mounts, err = t.GetMounts() if err != nil { return nil, err } return processMounts(mounts, false) } func readMountOutput(rdr io.Reader) (mounts Devices, err error) { scanner := bufio.NewScanner(rdr) for scanner.Scan() { line := scanner.Text() re := regexp.MustCompile(`^(.*) on (/.*) \(([^)]+)\)$`) parts := re.FindAllStringSubmatch(line, -1) if len(parts) < 1 { return nil, errors.New("cannot parse mount output") } fstype := strings.TrimSpace(strings.Split(parts[0][3], ",")[0]) device := &Device{ Name: parts[0][1], MountPoint: parts[0][2], Fstype: fstype, } mounts = append(mounts, device) } if err := scanner.Err(); err != nil { return nil, err } return mounts, nil } func processMounts(mounts Devices, ignoreErrors bool) (devices Devices, err error) { for _, mount := range mounts { if !strings.HasPrefix(mount.Name, "/dev") && mount.Fstype != "zfs" { continue } info := &unix.Statfs_t{} err := unix.Statfs(mount.MountPoint, info) if err != nil && !ignoreErrors { return nil, err } mount.Size = int64(info.Bsize) * int64(info.Blocks) mount.Free = int64(info.Bsize) * int64(info.Bavail) devices = append(devices, mount) } return devices, nil } gdu-5.36.1/pkg/device/dev_freebsd_darwin_test.go000066400000000000000000000024521517447455500216330ustar00rootroot00000000000000//go:build freebsd || darwin package device import ( "strings" "testing" "github.com/stretchr/testify/assert" ) func TestZfsMountsShown(t *testing.T) { mounts, _ := readMountOutput(strings.NewReader(`/dev/ada0p2 on / (ufs, local, soft-updates) devfs on /dev (devfs) tmpfs on /tmp (tmpfs, local) fdescfs on /dev/fd (fdescfs) procfs on /proc (procfs, local) t on /t (zfs, local, nfsv4acls) t/db on /t/db (zfs, local, nfsv4acls) t/vm on /t/vm (zfs, local, nfsv4acls) t/log/pflog on /var/log/pflog (zfs, local, nfsv4acls) t/log on /t/log (zfs, local, nfsv4acls) devfs on /compat/linux/dev (devfs) fdescfs on /compat/linux/dev/fd (fdescfs) tmpfs on /compat/linux/dev/shm (tmpfs, local) map -hosts on /net (autofs) argon:/usr/src on /usr/src (nfs) argon:/usr/obj on /usr/obj (nfs)`)) devices, err := processMounts(mounts, true) assert.Len(t, devices, 6) assert.Nil(t, err) } func TestMountsWithSpace(t *testing.T) { mounts, err := readMountOutput(strings.NewReader( `//inglor@vault.lan/volatile on /Users/inglor/Mountpoints/volatile (vault.lan) (smbfs, nodev, nosuid, mounted by inglor)`, )) assert.Equal(t, "//inglor@vault.lan/volatile", mounts[0].Name) assert.Equal(t, "/Users/inglor/Mountpoints/volatile (vault.lan)", mounts[0].MountPoint) assert.Equal(t, "smbfs", mounts[0].Fstype) assert.Nil(t, err) } gdu-5.36.1/pkg/device/dev_linux.go000066400000000000000000000043701517447455500167560ustar00rootroot00000000000000package device import ( "bufio" "fmt" "io" "os" "strings" "golang.org/x/sys/unix" ) // LinuxDevicesInfoGetter returns info for Linux devices type LinuxDevicesInfoGetter struct { MountsPath string } // Getter is current instance of DevicesInfoGetter var Getter DevicesInfoGetter = LinuxDevicesInfoGetter{MountsPath: "/proc/mounts"} // GetMounts returns all mounted filesystems from /proc/mounts func (t LinuxDevicesInfoGetter) GetMounts() (devices Devices, err error) { file, err := os.Open(t.MountsPath) if err != nil { return nil, err } devices, err = readMountsFile(file) if err != nil { if cerr := file.Close(); cerr != nil { return nil, fmt.Errorf("%w; %s", err, cerr.Error()) } return nil, err } if err := file.Close(); err != nil { return nil, err } return devices, nil } // GetDevicesInfo returns result of GetMounts with usage info about mounted devices (by calling Statfs syscall) func (t LinuxDevicesInfoGetter) GetDevicesInfo() (devices Devices, err error) { mounts, err := t.GetMounts() if err != nil { return nil, err } return processMounts(mounts, false) } func readMountsFile(file io.Reader) (mounts Devices, err error) { mounts = Devices{} scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() parts := strings.Fields(line) device := &Device{ Name: parts[0], MountPoint: unescapeString(parts[1]), Fstype: parts[2], } mounts = append(mounts, device) } if err := scanner.Err(); err != nil { return nil, err } return mounts, nil } func processMounts(mounts Devices, ignoreErrors bool) (devices Devices, err error) { devices = Devices{} for _, mount := range mounts { if strings.Contains(mount.MountPoint, "/snap/") { continue } if strings.HasPrefix(mount.Name, "/dev") || mount.Fstype == "zfs" || mount.Fstype == "nfs" || mount.Fstype == "nfs4" { info := &unix.Statfs_t{} err = unix.Statfs(mount.MountPoint, info) if err != nil && !ignoreErrors { return nil, err } mount.Size = int64(info.Bsize) * int64(info.Blocks) mount.Free = int64(info.Bsize) * int64(info.Bavail) devices = append(devices, mount) } } return devices, nil } func unescapeString(str string) string { return strings.ReplaceAll(str, "\\040", " ") } gdu-5.36.1/pkg/device/dev_linux_test.go000066400000000000000000000064161517447455500200200ustar00rootroot00000000000000//go:build linux package device import ( "strings" "testing" "github.com/stretchr/testify/assert" ) func TestGetDevicesInfo(t *testing.T) { getter := LinuxDevicesInfoGetter{MountsPath: "/proc/mounts"} devices, _ := getter.GetDevicesInfo() assert.IsType(t, Devices{}, devices) } func TestGetDevicesInfoFail(t *testing.T) { getter := LinuxDevicesInfoGetter{MountsPath: "/xxxyyy"} _, err := getter.GetDevicesInfo() assert.Equal(t, "open /xxxyyy: no such file or directory", err.Error()) } func TestSnapMountsNotShown(t *testing.T) { mounts, _ := readMountsFile(strings.NewReader(`/dev/loop4 /var/lib/snapd/snap/core18/1944 squashfs ro,nodev,relatime 0 0 /dev/loop3 /var/lib/snapd/snap/core20/904 squashfs ro,nodev,relatime 0 0 /dev/nvme0n1p1 /boot vfat rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=ascii,shortname=mixed,utf8,errors=remount-ro 0 0`)) devices, err := processMounts(mounts, true) assert.Len(t, devices, 1) assert.Nil(t, err) } func TestZfsMountsShown(t *testing.T) { mounts, _ := readMountsFile(strings.NewReader(`rootpool/opt /opt zfs rw,nodev,relatime,xattr,posixacl 0 0 rootpool/usr/local /usr/local zfs rw,nodev,relatime,xattr,posixacl 0 0 rootpool/home/root /root zfs rw,nodev,relatime,xattr,posixacl 0 0 rootpool/usr/games /usr/games zfs rw,nodev,relatime,xattr,posixacl 0 0 rootpool/home /home zfs rw,nodev,relatime,xattr,posixacl 0 0 /dev/loop4 /var/lib/snapd/snap/core18/1944 squashfs ro,nodev,relatime 0 0 /dev/loop3 /var/lib/snapd/snap/core20/904 squashfs ro,nodev,relatime 0 0 /dev/nvme0n1p1 /boot vfat rw,relatime,fmask=0022,dmask=0022,codepage=437,iocharset=ascii,shortname=mixed,utf8,errors=remount-ro 0 0`)) devices, err := processMounts(mounts, true) assert.Len(t, devices, 6) assert.Nil(t, err) } func TestNfsMountsShown(t *testing.T) { // nolint: lll // Why: Test data mounts, _ := readMountsFile(strings.NewReader(`host1:/dir1/ /mnt/dir1 nfs4 rw,nosuid,nodev,noatime,nodiratime,vers=4.2,rsize=1048576,wsize=1048576,namlen=255,hard,proto=tcp,timeo=600,retrans=2,sec=sys,clientaddr=192.168.1.1,fsc,local_lock=none,addr=192.168.1.2 0 0 host2:/dir2/ /mnt/dir2 nfs rw,relatime,vers=3,rsize=524288,wsize=524288,namlen=255,hard,proto=tcp,timeo=600,retrans=2,sec=sys,mountaddr=192.168.1.3,mountvers=3,mountport=38081,mountproto=udp,fsc,local_lock=none,addr=192.168.1.4 0 0`)) devices, err := processMounts(mounts, true) assert.Len(t, devices, 2) assert.Equal(t, "host1:/dir1/", devices[0].Name) assert.Equal(t, "/mnt/dir1", devices[0].MountPoint) assert.Nil(t, err) } func TestMountsWithSpaces(t *testing.T) { // nolint: lll // Why: Test data mounts, _ := readMountsFile(strings.NewReader(`host1:/dir1/ /mnt/dir\040with\040spaces nfs4 rw,nosuid,nodev,noatime,nodiratime,vers=4.2,rsize=1048576,wsize=1048576,namlen=255,hard,proto=tcp,timeo=600,retrans=2,sec=sys,clientaddr=192.168.1.1,fsc,local_lock=none,addr=192.168.1.2 0 0 host2:/dir2/ /mnt/dir2 nfs rw,relatime,vers=3,rsize=524288,wsize=524288,namlen=255,hard,proto=tcp,timeo=600,retrans=2,sec=sys,mountaddr=192.168.1.3,mountvers=3,mountport=38081,mountproto=udp,fsc,local_lock=none,addr=192.168.1.4 0 0`)) devices, err := processMounts(mounts, true) assert.Len(t, devices, 2) assert.Equal(t, "host1:/dir1/", devices[0].Name) assert.Equal(t, "/mnt/dir with spaces", devices[0].MountPoint) assert.Nil(t, err) } gdu-5.36.1/pkg/device/dev_netbsd.go000066400000000000000000000011121517447455500170650ustar00rootroot00000000000000//go:build netbsd package device import ( "strings" "golang.org/x/sys/unix" ) func processMounts(mounts Devices, ignoreErrors bool) (devices Devices, err error) { for _, mount := range mounts { if strings.HasPrefix(mount.Name, "/dev") || mount.Fstype == "zfs" { info := &unix.Statvfs_t{} err = unix.Statvfs(mount.MountPoint, info) if err != nil && !ignoreErrors { return nil, err } mount.Size = int64(info.Bsize) * int64(info.Blocks) mount.Free = int64(info.Bsize) * int64(info.Bavail) devices = append(devices, mount) } } return devices, nil } gdu-5.36.1/pkg/device/dev_openbsd.go000066400000000000000000000012431517447455500172450ustar00rootroot00000000000000//go:build openbsd package device import ( "fmt" "strings" "golang.org/x/sys/unix" ) func processMounts(mounts Devices, ignoreErrors bool) (devices Devices, err error) { for _, mount := range mounts { if strings.HasPrefix(mount.Name, "/dev") || mount.Fstype == "zfs" { info := &unix.Statfs_t{} err = unix.Statfs(mount.MountPoint, info) if err != nil && !ignoreErrors { return nil, fmt.Errorf("getting stats for mount point: \"%s\", %w", mount.MountPoint, err) } mount.Size = int64(info.F_bsize) * int64(info.F_blocks) mount.Free = int64(info.F_bsize) * int64(info.F_bavail) devices = append(devices, mount) } } return devices, nil } gdu-5.36.1/pkg/device/dev_other.go000066400000000000000000000013171517447455500167360ustar00rootroot00000000000000//go:build windows || plan9 package device import "errors" // OtherDevicesInfoGetter returns info for other devices type OtherDevicesInfoGetter struct{} // Getter is current instance of DevicesInfoGetter var Getter DevicesInfoGetter = OtherDevicesInfoGetter{} // GetDevicesInfo returns result of GetMounts with usage info about mounted devices func (t OtherDevicesInfoGetter) GetDevicesInfo() (devices Devices, err error) { return nil, errors.New("Only Linux platform is supported for listing devices") } // GetMounts returns all mounted filesystems func (t OtherDevicesInfoGetter) GetMounts() (devices Devices, err error) { return nil, errors.New("Only Linux platform is supported for listing mount points") } gdu-5.36.1/pkg/device/dev_test.go000066400000000000000000000024001517447455500165660ustar00rootroot00000000000000package device import ( "sort" "testing" "github.com/stretchr/testify/assert" ) func TestNested(t *testing.T) { item := &Device{ MountPoint: "/xxx", } nested := &Device{ MountPoint: "/xxx/yyy", } notNested := &Device{ MountPoint: "/zzz/yyy", } mounts := Devices{item, nested, notNested} mountsNested := GetNestedMountpointsPaths("/xxx", mounts) assert.Len(t, mountsNested, 1) assert.Equal(t, "/xxx/yyy", mountsNested[0]) } func TestSortByName(t *testing.T) { item := &Device{ Name: "/xxx", } nested := &Device{ Name: "/xxx/yyy", } notNested := &Device{ Name: "/zzz/yyy", } devices := Devices{item, nested, notNested} sort.Sort(sort.Reverse(ByName(devices))) assert.Equal(t, "/zzz/yyy", devices[0].Name) assert.Equal(t, "/xxx/yyy", devices[1].Name) assert.Equal(t, "/xxx", devices[2].Name) } func TestSortByUsedSize(t *testing.T) { item := &Device{ Name: "xxx", Size: 1e12, Free: 1e3, } nested := &Device{ Name: "yyy", Size: 1e12, Free: 1e6, } notNested := &Device{ Name: "zzz", Size: 1e12, Free: 1e12, } devices := Devices{item, nested, notNested} sort.Sort(ByUsedSize(devices)) assert.Equal(t, "zzz", devices[0].Name) assert.Equal(t, "yyy", devices[1].Name) assert.Equal(t, "xxx", devices[2].Name) } gdu-5.36.1/pkg/fs/000077500000000000000000000000001517447455500135775ustar00rootroot00000000000000gdu-5.36.1/pkg/fs/file.go000066400000000000000000000101231517447455500150420ustar00rootroot00000000000000package fs import ( "io" "iter" "time" "github.com/maruel/natural" ) // SortBy represents the field to sort files by type SortBy int const ( SortBySize SortBy = iota SortByName SortByItemCount SortByMtime SortByApparentSize ) // SortOrder represents the sort direction type SortOrder int const ( SortAsc SortOrder = iota SortDesc ) // Item is a FS item (file or dir) type Item interface { GetPath() string GetName() string GetFlag() rune IsDir() bool GetSize() int64 GetType() string GetUsage() int64 GetMtime() time.Time GetItemCount() int64 GetParent() Item SetParent(Item) GetMultiLinkedInode() uint64 EncodeJSON(writer io.Writer, topLevel bool) error GetItemStats(linkedItems HardLinkedItems) (itemCount int64, size, usage int64) UpdateStats(linkedItems HardLinkedItems) AddFile(Item) GetFiles(SortBy, SortOrder) iter.Seq[Item] GetFilesLocked(SortBy, SortOrder) iter.Seq[Item] RemoveFile(Item) RemoveFileByName(name string) RLock() func() } // Files - slice of pointers to File type Files []Item // HardLinkedItems maps inode number to array of all hard linked items type HardLinkedItems map[uint64]Files // IndexOf searches File in Files and returns its index func (f Files) IndexOf(file Item) (int, bool) { for i, item := range f { if item == file { return i, true } } return 0, false } // FindByName searches name in Files and returns its index func (f Files) FindByName(name string) (int, bool) { for i, item := range f { if item.GetName() == name { return i, true } } return 0, false } // Remove removes File from Files func (f Files) Remove(file Item) Files { index, ok := f.IndexOf(file) if !ok { return f } return append(f[:index], f[index+1:]...) } // RemoveByName removes File from Files func (f Files) RemoveByName(name string) Files { index, ok := f.FindByName(name) if !ok { return f } return append(f[:index], f[index+1:]...) } func (f Files) Len() int { return len(f) } func (f Files) Swap(i, j int) { f[i], f[j] = f[j], f[i] } func (f Files) Less(i, j int) bool { if f[i].GetUsage() != f[j].GetUsage() { return f[i].GetUsage() < f[j].GetUsage() } // if usage is the same, sort by name return natural.Less(f[i].GetName(), f[j].GetName()) } // ByApparentSize sorts files by apparent size type ByApparentSize Files func (f ByApparentSize) Len() int { return len(f) } func (f ByApparentSize) Swap(i, j int) { f[i], f[j] = f[j], f[i] } func (f ByApparentSize) Less(i, j int) bool { if f[i].GetSize() != f[j].GetSize() { return f[i].GetSize() < f[j].GetSize() } // if size is the same, sort by name return natural.Less(f[i].GetName(), f[j].GetName()) } // ByItemCount sorts files by item count type ByItemCount Files func (f ByItemCount) Len() int { return len(f) } func (f ByItemCount) Swap(i, j int) { f[i], f[j] = f[j], f[i] } func (f ByItemCount) Less(i, j int) bool { if f[i].GetItemCount() != f[j].GetItemCount() { return f[i].GetItemCount() < f[j].GetItemCount() } // if item count is the same, sort by name return natural.Less(f[i].GetName(), f[j].GetName()) } // ByName sorts files by name type ByName Files func (f ByName) Len() int { return len(f) } func (f ByName) Swap(i, j int) { f[i], f[j] = f[j], f[i] } func (f ByName) Less(i, j int) bool { return natural.Less(f[i].GetName(), f[j].GetName()) } // ByMtime sorts files by name type ByMtime Files func (f ByMtime) Len() int { return len(f) } func (f ByMtime) Swap(i, j int) { f[i], f[j] = f[j], f[i] } func (f ByMtime) Less(i, j int) bool { if !f[i].GetMtime().Equal(f[j].GetMtime()) { return f[i].GetMtime().Before(f[j].GetMtime()) } // if item count is the same, sort by name return natural.Less(f[i].GetName(), f[j].GetName()) } // ParseSortBy converts a string to SortBy func ParseSortBy(s string) SortBy { switch s { case "name": return SortByName case "size": return SortBySize case "itemCount": return SortByItemCount case "mtime": return SortByMtime default: return SortBySize } } // ParseSortOrder converts a string to SortOrder func ParseSortOrder(s string) SortOrder { if s == "asc" { return SortAsc } return SortDesc } gdu-5.36.1/pkg/path/000077500000000000000000000000001517447455500141235ustar00rootroot00000000000000gdu-5.36.1/pkg/path/path.go000066400000000000000000000007751517447455500154170ustar00rootroot00000000000000package path import "strings" // ShortenPath removes the last but one path components to fit into maxLen func ShortenPath(path string, maxLen int) string { if len(path) <= maxLen { return path } res := "" parts := strings.SplitAfter(path, "/") curLen := len(parts[len(parts)-1]) // count length of last part for start for _, part := range parts[:len(parts)-1] { curLen += len(part) if curLen > maxLen { res += ".../" break } res += part } res += parts[len(parts)-1] return res } gdu-5.36.1/pkg/path/path_test.go000066400000000000000000000007451517447455500164530ustar00rootroot00000000000000package path import ( "testing" "github.com/stretchr/testify/assert" ) func TestShortenPath(t *testing.T) { assert.Equal(t, "/root", ShortenPath("/root", 10)) assert.Equal(t, "/home/.../foo", ShortenPath("/home/dundee/foo", 10)) assert.Equal(t, "/home/dundee/foo", ShortenPath("/home/dundee/foo", 50)) assert.Equal(t, "/home/dundee/.../bar.txt", ShortenPath("/home/dundee/foo/bar.txt", 20)) assert.Equal(t, "/home/.../bar.txt", ShortenPath("/home/dundee/foo/bar.txt", 15)) } gdu-5.36.1/pkg/remove/000077500000000000000000000000001517447455500144645ustar00rootroot00000000000000gdu-5.36.1/pkg/remove/parallel.go000066400000000000000000000021671517447455500166150ustar00rootroot00000000000000package remove import ( "os" "runtime" "sync" "github.com/dundee/gdu/v5/pkg/fs" ) var concurrencyLimit = make(chan struct{}, 3*runtime.GOMAXPROCS(0)) // ItemFromDirParallel removes item from dir func ItemFromDirParallel(dir, item fs.Item) error { if !item.IsDir() { return ItemFromDir(dir, item) } errChan := make(chan error, 1) // we show only first error var wait sync.WaitGroup // remove all files in the directory in parallel for file := range item.GetFilesLocked(fs.SortBySize, fs.SortDesc) { if !file.IsDir() { continue } wait.Add(1) go func(itemPath string) { concurrencyLimit <- struct{}{} defer func() { <-concurrencyLimit }() err := os.RemoveAll(itemPath) if err != nil { select { // write error to channel if it's empty case errChan <- err: default: } } wait.Done() }(file.GetPath()) } wait.Wait() // check if there was an error select { case err := <-errChan: return err default: } // remove the directory itself err := os.RemoveAll(item.GetPath()) if err != nil { return err } // update parent directory dir.RemoveFile(item) return nil } gdu-5.36.1/pkg/remove/parallel_linux_test.go000066400000000000000000000025651517447455500210750ustar00rootroot00000000000000//go:build linux package remove import ( "os" "testing" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func TestItemFromDirParallelWithErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Chmod("test_dir/nested", 0) assert.Nil(t, err) defer func() { err = os.Chmod("test_dir/nested", 0o755) assert.Nil(t, err) }() dir := &analyze.Dir{ File: &analyze.File{ Name: "test_dir", }, BasePath: ".", } subdir := &analyze.Dir{ File: &analyze.File{ Name: "nested", Parent: dir, }, } err = ItemFromDirParallel(dir, subdir) assert.Contains(t, err.Error(), "permission denied") } func TestItemFromDirParallelWithErr2(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Chmod("test_dir/nested/subnested", 0) assert.Nil(t, err) defer func() { err = os.Chmod("test_dir/nested/subnested", 0o755) assert.Nil(t, err) }() analyzer := analyze.CreateAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*analyze.Dir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) subdir := dir.Files[0].(*analyze.Dir) err = ItemFromDirParallel(dir, subdir) assert.Contains(t, err.Error(), "permission denied") } gdu-5.36.1/pkg/remove/parallel_test.go000066400000000000000000000026461517447455500176560ustar00rootroot00000000000000package remove import ( "testing" "github.com/stretchr/testify/assert" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/fs" ) func TestRemoveFileParallel(t *testing.T) { dir := &analyze.Dir{ File: &analyze.File{ Name: "xxx", Size: 5, Usage: 12, }, ItemCount: 3, BasePath: ".", } subdir := &analyze.Dir{ File: &analyze.File{ Name: "yyy", Size: 4, Usage: 8, Parent: dir, }, ItemCount: 2, } file := &analyze.File{ Name: "zzz", Size: 3, Usage: 4, Parent: subdir, } dir.Files = fs.Files{subdir} subdir.Files = fs.Files{file} err := ItemFromDirParallel(subdir, file) assert.Nil(t, err) assert.Equal(t, 0, len(subdir.Files)) assert.Equal(t, int64(1), subdir.ItemCount) assert.Equal(t, int64(1), subdir.Size) assert.Equal(t, int64(4), subdir.Usage) assert.Equal(t, 1, len(dir.Files)) assert.Equal(t, int64(2), dir.ItemCount) assert.Equal(t, int64(2), dir.Size) } func TestRemoveDirParallel(t *testing.T) { fin := testdir.CreateTestDir() defer fin() analyzer := analyze.CreateAnalyzer() dir := analyzer.AnalyzeDir( "test_dir", func(_, _ string) bool { return false }, func(_ string) bool { return false }, ).(*analyze.Dir) analyzer.GetDone().Wait() dir.UpdateStats(make(fs.HardLinkedItems)) subdir := dir.Files[0].(*analyze.Dir) err := ItemFromDirParallel(dir, subdir) assert.Nil(t, err) } gdu-5.36.1/pkg/remove/remove.go000066400000000000000000000013061517447455500163100ustar00rootroot00000000000000package remove import ( "os" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/fs" ) // ItemFromDir removes item from dir func ItemFromDir(dir, item fs.Item) error { err := os.RemoveAll(item.GetPath()) if err != nil { return err } dir.RemoveFile(item) return nil } // EmptyFileFromDir empties file from dir (truncates to 0 bytes) func EmptyFileFromDir(dir, file fs.Item) error { err := os.Truncate(file.GetPath(), 0) if err != nil { return err } // Remove old file and add zero-sized one dir.RemoveFile(file) newFile := &analyze.File{ Name: file.GetName(), Flag: file.GetFlag(), Size: 0, Usage: 0, Parent: dir, } dir.AddFile(newFile) return nil } gdu-5.36.1/pkg/remove/remove_linux_test.go000066400000000000000000000012671517447455500205740ustar00rootroot00000000000000//go:build linux package remove import ( "os" "testing" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/stretchr/testify/assert" ) func TestRemoveFileWithErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() err := os.Chmod("test_dir/nested", 0) assert.Nil(t, err) defer func() { err = os.Chmod("test_dir/nested", 0o755) assert.Nil(t, err) }() dir := &analyze.Dir{ File: &analyze.File{ Name: "test_dir", }, BasePath: ".", } subdir := &analyze.Dir{ File: &analyze.File{ Name: "nested", Parent: dir, }, } err = ItemFromDir(dir, subdir) assert.Contains(t, err.Error(), "permission denied") } gdu-5.36.1/pkg/remove/remove_test.go000066400000000000000000000047171517447455500173600ustar00rootroot00000000000000package remove import ( "testing" "github.com/stretchr/testify/assert" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/fs" ) func TestTruncateFile(t *testing.T) { fin := testdir.CreateTestDir() defer fin() dir := &analyze.Dir{ File: &analyze.File{ Name: "test_dir", Size: 5, Usage: 12, }, ItemCount: 3, BasePath: ".", } subdir := &analyze.Dir{ File: &analyze.File{ Name: "nested", Size: 4, Usage: 8, Parent: dir, }, ItemCount: 2, } file := &analyze.File{ Name: "file2", Size: 3, Usage: 4, Parent: subdir, } dir.Files = fs.Files{subdir} subdir.Files = fs.Files{file} err := EmptyFileFromDir(subdir, file) assert.Nil(t, err) assert.Equal(t, 1, len(subdir.Files)) assert.Equal(t, int64(1), subdir.ItemCount) // RemoveFile decrements, AddFile doesn't increment assert.Equal(t, int64(1), subdir.Size) assert.Equal(t, int64(4), subdir.Usage) assert.Equal(t, 1, len(dir.Files)) assert.Equal(t, int64(2), dir.ItemCount) // RemoveFile decrements, AddFile doesn't increment assert.Equal(t, int64(2), dir.Size) } func TestRemoveFile(t *testing.T) { dir := &analyze.Dir{ File: &analyze.File{ Name: "xxx", Size: 5, Usage: 12, }, ItemCount: 3, BasePath: ".", } subdir := &analyze.Dir{ File: &analyze.File{ Name: "yyy", Size: 4, Usage: 8, Parent: dir, }, ItemCount: 2, } file := &analyze.File{ Name: "zzz", Size: 3, Usage: 4, Parent: subdir, } dir.Files = fs.Files{subdir} subdir.Files = fs.Files{file} err := ItemFromDir(subdir, file) assert.Nil(t, err) assert.Equal(t, 0, len(subdir.Files)) assert.Equal(t, int64(1), subdir.ItemCount) assert.Equal(t, int64(1), subdir.Size) assert.Equal(t, int64(4), subdir.Usage) assert.Equal(t, 1, len(dir.Files)) assert.Equal(t, int64(2), dir.ItemCount) assert.Equal(t, int64(2), dir.Size) } func TestTruncateFileWithErr(t *testing.T) { dir := &analyze.Dir{ File: &analyze.File{ Name: "xxx", Size: 5, Usage: 12, }, ItemCount: 3, BasePath: ".", } subdir := &analyze.Dir{ File: &analyze.File{ Name: "yyy", Size: 4, Usage: 8, Parent: dir, }, ItemCount: 2, } file := &analyze.File{ Name: "zzz", Size: 3, Usage: 4, Parent: subdir, } dir.Files = fs.Files{subdir} subdir.Files = fs.Files{file} err := EmptyFileFromDir(subdir, file) assert.Contains(t, err.Error(), "no such file or directory") } gdu-5.36.1/pkg/timefilter/000077500000000000000000000000001517447455500153335ustar00rootroot00000000000000gdu-5.36.1/pkg/timefilter/timefilter.go000066400000000000000000000156261517447455500200400ustar00rootroot00000000000000package timefilter import ( "fmt" "regexp" "strconv" "strings" "time" ) // TimeBound represents a parsed time filter value that can be either an instant or a date-only value type TimeBound struct { instant *time.Time // absolute instant (UTC) dateOnly *time.Time // at local midnight; only YYYY-MM-DD will set this } // IsEmpty returns true if the TimeBound has no filter criteria func (tb TimeBound) IsEmpty() bool { return tb.instant == nil && tb.dateOnly == nil } // TimeFilter represents multiple time filtering criteria type TimeFilter struct { since []*TimeBound until []*TimeBound } // NewTimeFilter creates a new TimeFilter with the given parameters func NewTimeFilter(since, until, maxAge, minAge string, now time.Time, loc *time.Location) (*TimeFilter, error) { tf := &TimeFilter{} // Parse since if since != "" { sinceBound, err := parseTimeValue(since, loc) if err != nil { return nil, fmt.Errorf("invalid --since value: %w", err) } if !sinceBound.IsEmpty() { tf.since = append(tf.since, &sinceBound) } } // Parse until if until != "" { untilBound, err := parseTimeValue(until, loc) if err != nil { return nil, fmt.Errorf("invalid --until value: %w", err) } if !untilBound.IsEmpty() { tf.until = append(tf.until, &untilBound) } } // Parse max-age (convert to since) if maxAge != "" { duration, err := parseDuration(maxAge) if err != nil { return nil, fmt.Errorf("invalid --max-age value: %w", err) } sinceTime := now.Add(-duration).UTC() tf.since = append(tf.since, &TimeBound{instant: &sinceTime}) } // Parse min-age (convert to until) if minAge != "" { duration, err := parseDuration(minAge) if err != nil { return nil, fmt.Errorf("invalid --min-age value: %w", err) } untilTime := now.Add(-duration).UTC() tf.until = append(tf.until, &TimeBound{instant: &untilTime}) } return tf, nil } // IncludeByTimeFilter determines if a file should be included based on the complete time filter func (tf *TimeFilter) IncludeByTimeFilter(mtime time.Time, loc *time.Location) bool { // Check since bound for _, since := range tf.since { if !includeByTimeBound(mtime, *since, loc, false) { return false } } // Check until bound for _, until := range tf.until { if !includeByTimeBound(mtime, *until, loc, true) { return false } } return true } // IsEmpty returns true if the TimeFilter has no filter criteria func (tf *TimeFilter) IsEmpty() bool { return tf.since == nil && tf.until == nil } // FormatForDisplay returns a formatted string showing the active time filters // This shows what the program actually parsed and is acting on func (tf *TimeFilter) FormatForDisplay(loc *time.Location) string { if tf.IsEmpty() { return "" } var parts []string for _, since := range tf.since { if since.instant != nil { parts = append(parts, "since="+since.instant.In(loc).Format(time.RFC3339)) } else if since.dateOnly != nil { parts = append(parts, "since="+since.dateOnly.Format("2006-01-02")+" (date-only)") } } for _, until := range tf.until { if until.instant != nil { parts = append(parts, "until=", until.instant.In(loc).Format(time.RFC3339)) } else if until.dateOnly != nil { parts = append(parts, "until=", until.dateOnly.Format("2006-01-02")+" (date-only)") } } if len(parts) == 0 { return "" } return " Filtered by: time=mtime; " + strings.Join(parts, "; ") } // includeByTimeBound determines if a file should be included based on its mtime and the time bound func includeByTimeBound(mtime time.Time, tb TimeBound, loc *time.Location, isUntil bool) bool { if tb.instant == nil && tb.dateOnly == nil { return true // no filter applied } if tb.instant != nil { if isUntil { return !mtime.After(*tb.instant) // inclusive (<=) } return !mtime.Before(*tb.instant) // inclusive (>=) } if tb.dateOnly != nil { // For date-only comparisons, adjust the bound to cover the whole day. boundDate := tb.dateOnly.In(loc) if isUntil { // For `until`, we want to include the entire day. // So the upper bound is the beginning of the *next* day. upperBound := time.Date(boundDate.Year(), boundDate.Month(), boundDate.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, 1) return mtime.Before(upperBound) } // For `since`, we want to include the entire day. // So the lower bound is the beginning of that day. lowerBound := time.Date(boundDate.Year(), boundDate.Month(), boundDate.Day(), 0, 0, 0, 0, loc) return !mtime.Before(lowerBound) // inclusive (>=) } return true } // parseDuration parses a duration string with support for extended units // Supports: s, m, h, d (=24h), w (=7d), mo (=30d), y (=365d) // Examples: "90m", "2h30m", "7d", "6w", "1y2mo" func parseDuration(input string) (time.Duration, error) { if input == "" { return 0, fmt.Errorf("empty duration") } // Remove whitespace and convert to lowercase input = strings.ToLower(strings.ReplaceAll(input, " ", "")) // Regex to match number+unit pairs (mo must come before m to avoid greedy matching) re := regexp.MustCompile(`(\d+)(mo|s|m|h|d|w|y)`) matches := re.FindAllStringSubmatch(input, -1) if len(matches) == 0 { return 0, fmt.Errorf("invalid duration format %q. Use combinations like 7d, 2h30m, 1y2mo", input) } // Check if the entire input was consumed by matches consumed := "" for _, match := range matches { consumed += match[0] } if consumed != input { return 0, fmt.Errorf("invalid duration format %q. Use combinations like 7d, 2h30m, 1y2mo", input) } var total time.Duration for _, match := range matches { value, err := strconv.Atoi(match[1]) if err != nil { return 0, fmt.Errorf("invalid number in duration: %s", match[1]) } unit := match[2] var duration time.Duration switch unit { case "s": duration = time.Duration(value) * time.Second case "m": duration = time.Duration(value) * time.Minute case "h": duration = time.Duration(value) * time.Hour case "d": duration = time.Duration(value) * 24 * time.Hour case "w": duration = time.Duration(value) * 7 * 24 * time.Hour case "mo": duration = time.Duration(value) * 30 * 24 * time.Hour case "y": duration = time.Duration(value) * 365 * 24 * time.Hour default: return 0, fmt.Errorf("unsupported duration unit: %s", unit) } total += duration } return total, nil } // parseTimeValue parses a time value into either a timestamp instant or a date-only value func parseTimeValue(arg string, loc *time.Location) (TimeBound, error) { if arg == "" { return TimeBound{}, nil } // 1) Try RFC3339 instant if t, err := time.Parse(time.RFC3339, arg); err == nil { u := t.UTC() return TimeBound{instant: &u}, nil } // 2) Try strict YYYY-MM-DD if len(arg) == 10 { if d, err := time.ParseInLocation("2006-01-02", arg, loc); err == nil { // dateOnly uses local date; we will compare date parts only return TimeBound{dateOnly: &d}, nil } } return TimeBound{}, fmt.Errorf("invalid time value %q. Use RFC3339 timestamp or YYYY-MM-DD", arg) } gdu-5.36.1/pkg/timefilter/timefilter_test.go000066400000000000000000000427521517447455500210770ustar00rootroot00000000000000package timefilter import ( "testing" "time" ) func TestParseSince(t *testing.T) { // Use America/Vancouver timezone for testing (UTC-7 or UTC-8 depending on DST) loc, err := time.LoadLocation("America/Vancouver") if err != nil { t.Fatalf("Failed to load timezone: %v", err) } tests := []struct { name string input string expectError bool expectType string // "instant", "dateOnly", or "empty" }{ { name: "empty string", input: "", expectError: false, expectType: "empty", }, { name: "RFC3339 with timezone", input: "2025-08-11T01:00:00-07:00", expectError: false, expectType: "instant", }, { name: "RFC3339 UTC", input: "2025-08-11T08:00:00Z", expectError: false, expectType: "instant", }, { name: "RFC3339 with nanoseconds", input: "2025-08-11T01:00:00.123456789-07:00", expectError: false, expectType: "instant", }, { name: "date only YYYY-MM-DD", input: "2025-08-11", expectError: false, expectType: "dateOnly", }, { name: "invalid format", input: "2025/08/11", expectError: true, expectType: "", }, { name: "invalid date", input: "2025-13-01", expectError: true, expectType: "", }, { name: "too short date", input: "2025-8-1", expectError: true, expectType: "", }, { name: "too long date", input: "2025-08-011", expectError: true, expectType: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := parseTimeValue(tt.input, loc) if tt.expectError { if err == nil { t.Errorf("Expected error but got none") } return } if err != nil { t.Errorf("Unexpected error: %v", err) return } switch tt.expectType { case "empty": if !result.IsEmpty() { t.Errorf("Expected empty result") } case "instant": if result.instant == nil { t.Errorf("Expected instant to be set") } if result.dateOnly != nil { t.Errorf("Expected dateOnly to be nil") } case "dateOnly": if result.dateOnly == nil { t.Errorf("Expected dateOnly to be set") } if result.instant != nil { t.Errorf("Expected instant to be nil") } } }) } } func TestIncludeBySince(t *testing.T) { // Use America/Vancouver timezone for testing (UTC-7 or UTC-8 depending on DST) loc, err := time.LoadLocation("America/Vancouver") if err != nil { t.Fatalf("Failed to load timezone: %v", err) } // Test cases from the MVP document tests := []struct { name string fileMtime string // local time sinceArg string expectInclude bool }{ { name: "file before date boundary", fileMtime: "2025-08-10T23:59:00-07:00", sinceArg: "2025-08-11", expectInclude: false, }, { name: "file at start of date", fileMtime: "2025-08-11T00:00:00-07:00", sinceArg: "2025-08-11", expectInclude: true, }, { name: "file during date", fileMtime: "2025-08-11T01:00:00-07:00", sinceArg: "2025-08-11", expectInclude: true, }, { name: "file at end of date", fileMtime: "2025-08-11T23:59:00-07:00", sinceArg: "2025-08-11", expectInclude: true, }, { name: "file after date", fileMtime: "2025-08-12T00:00:00-07:00", sinceArg: "2025-08-11", expectInclude: true, }, { name: "instant mode - file before", fileMtime: "2025-08-11T01:00:00-07:00", sinceArg: "2025-08-11T02:00:00-07:00", expectInclude: false, }, { name: "instant mode - file after", fileMtime: "2025-08-11T03:00:00-07:00", sinceArg: "2025-08-11T02:00:00-07:00", expectInclude: true, }, { name: "instant mode - file exactly at boundary", fileMtime: "2025-08-11T02:00:00-07:00", sinceArg: "2025-08-11T02:00:00-07:00", expectInclude: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Parse file mtime fileMtime, err := time.Parse(time.RFC3339, tt.fileMtime) if err != nil { t.Fatalf("Failed to parse file mtime: %v", err) } // Parse since bound sinceBound, err := parseTimeValue(tt.sinceArg, loc) if err != nil { t.Fatalf("Failed to parse since arg: %v", err) } // Test inclusion result := includeByTimeBound(fileMtime, sinceBound, loc, false) if result != tt.expectInclude { t.Errorf("Expected include=%v, got include=%v", tt.expectInclude, result) } }) } } func TestIncludeBySinceEmpty(t *testing.T) { loc, err := time.LoadLocation("America/Vancouver") if err != nil { t.Fatalf("Failed to load timezone: %v", err) } // Test with empty since bound (no filter) emptySince := TimeBound{} testTime := time.Now() result := includeByTimeBound(testTime, emptySince, loc, false) if !result { t.Errorf("Expected true for empty since bound, got false") } } func TestTimeBoundIsEmpty(t *testing.T) { tests := []struct { name string bound TimeBound expected bool }{ { name: "empty bound", bound: TimeBound{}, expected: true, }, { name: "instant bound", bound: TimeBound{instant: &time.Time{}}, expected: false, }, { name: "dateOnly bound", bound: TimeBound{dateOnly: &time.Time{}}, expected: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tt.bound.IsEmpty() if result != tt.expected { t.Errorf("Expected %v, got %v", tt.expected, result) } }) } } func TestParseDuration(t *testing.T) { tests := []struct { name string input string expected time.Duration expectError bool }{ { name: "empty string", input: "", expectError: true, }, { name: "seconds", input: "30s", expected: 30 * time.Second, }, { name: "minutes", input: "45m", expected: 45 * time.Minute, }, { name: "hours", input: "2h", expected: 2 * time.Hour, }, { name: "days", input: "7d", expected: 7 * 24 * time.Hour, }, { name: "weeks", input: "2w", expected: 2 * 7 * 24 * time.Hour, }, { name: "months", input: "3mo", expected: 3 * 30 * 24 * time.Hour, }, { name: "years", input: "1y", expected: 365 * 24 * time.Hour, }, { name: "combined hours and minutes", input: "2h30m", expected: 2*time.Hour + 30*time.Minute, }, { name: "combined with spaces", input: "2 h 30 m", expected: 2*time.Hour + 30*time.Minute, }, { name: "complex combination", input: "1y2mo3w4d5h6m7s", expected: 365*24*time.Hour + 2*30*24*time.Hour + 3*7*24*time.Hour + 4*24*time.Hour + 5*time.Hour + 6*time.Minute + 7*time.Second, }, { name: "uppercase", input: "2H30M", expected: 2*time.Hour + 30*time.Minute, }, { name: "invalid format", input: "2x", expectError: true, }, { name: "no number", input: "h", expectError: true, }, { name: "partial match", input: "2h30", expectError: true, }, { name: "invalid number", input: "abch", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := parseDuration(tt.input) if tt.expectError { if err == nil { t.Errorf("Expected error but got none") } return } if err != nil { t.Errorf("Unexpected error: %v", err) return } if result != tt.expected { t.Errorf("Expected %v, got %v", tt.expected, result) } }) } } func TestNewTimeFilter(t *testing.T) { loc, err := time.LoadLocation("America/Vancouver") if err != nil { t.Fatalf("Failed to load timezone: %v", err) } now := time.Date(2025, 8, 11, 12, 0, 0, 0, loc) tests := []struct { name string since string until string maxAge string minAge string expectError bool expectEmpty bool }{ { name: "empty filter", expectEmpty: true, }, { name: "since only", since: "2025-08-10", }, { name: "until only", until: "2025-08-12", }, { name: "max-age only", maxAge: "7d", }, { name: "min-age only", minAge: "30d", }, { name: "since and until", since: "2025-08-01", until: "2025-08-15", }, { name: "max-age and min-age", maxAge: "7d", minAge: "1d", }, { name: "all filters", since: "2025-08-01", until: "2025-08-15", maxAge: "30d", minAge: "1d", }, { name: "invalid since", since: "invalid", expectError: true, }, { name: "invalid until", until: "invalid", expectError: true, }, { name: "invalid max-age", maxAge: "invalid", expectError: true, }, { name: "invalid min-age", minAge: "invalid", expectError: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { filter, err := NewTimeFilter(tt.since, tt.until, tt.maxAge, tt.minAge, now, loc) if tt.expectError { if err == nil { t.Errorf("Expected error but got none") } return } if err != nil { t.Errorf("Unexpected error: %v", err) return } if tt.expectEmpty { if !filter.IsEmpty() { t.Errorf("Expected empty filter") } } else { if filter.IsEmpty() { t.Errorf("Expected non-empty filter") } } }) } } func TestTimeFilterIncludeByTimeFilter(t *testing.T) { loc, err := time.LoadLocation("America/Vancouver") if err != nil { t.Fatalf("Failed to load timezone: %v", err) } now := time.Date(2025, 8, 11, 12, 0, 0, 0, loc) tests := []struct { name string since string until string maxAge string minAge string fileMtime string expectInclude bool }{ { name: "since filter - file after", since: "2025-08-10", fileMtime: "2025-08-11T10:00:00-07:00", expectInclude: true, }, { name: "since filter - file before", since: "2025-08-10", fileMtime: "2025-08-09T10:00:00-07:00", expectInclude: false, }, { name: "until filter - file before", until: "2025-08-12", fileMtime: "2025-08-11T10:00:00-07:00", expectInclude: true, }, { name: "until filter - file after", until: "2025-08-12", fileMtime: "2025-08-13T10:00:00-07:00", expectInclude: false, }, { name: "max-age filter - file recent", maxAge: "7d", fileMtime: "2025-08-10T12:00:00-07:00", // 1 day ago expectInclude: true, }, { name: "max-age filter - file old", maxAge: "7d", fileMtime: "2025-08-01T12:00:00-07:00", // 10 days ago expectInclude: false, }, { name: "min-age filter - file old", minAge: "7d", fileMtime: "2025-08-01T12:00:00-07:00", // 10 days ago expectInclude: true, }, { name: "min-age filter - file recent", minAge: "7d", fileMtime: "2025-08-10T12:00:00-07:00", // 1 day ago expectInclude: false, }, { name: "combined filters - all pass", since: "2025-08-01", until: "2025-08-15", maxAge: "30d", minAge: "1d", fileMtime: "2025-08-05T12:00:00-07:00", // 6 days ago expectInclude: true, }, { name: "combined filters - since fails", since: "2025-08-10", until: "2025-08-15", maxAge: "30d", minAge: "1d", fileMtime: "2025-08-05T12:00:00-07:00", // 6 days ago expectInclude: false, }, { name: "combined filters - until fails", since: "2025-08-01", until: "2025-08-10", maxAge: "30d", minAge: "1d", fileMtime: "2025-08-12T12:00:00-07:00", // future expectInclude: false, }, { name: "combined filters - max-age fails", since: "2025-08-01", until: "2025-08-15", maxAge: "5d", minAge: "1d", fileMtime: "2025-08-01T12:00:00-07:00", // 10 days ago expectInclude: false, }, { name: "combined filters - min-age fails", since: "2025-08-01", until: "2025-08-15", maxAge: "30d", minAge: "5d", fileMtime: "2025-08-10T12:00:00-07:00", // 1 day ago expectInclude: false, }, { name: "date-only since and max-age - fail", since: "2025-08-10", maxAge: "3d", fileMtime: "2025-08-09T12:00:00-07:00", // 2 days old, but before since date expectInclude: false, }, { name: "date-only since and max-age - pass", since: "2025-08-10", maxAge: "3d", fileMtime: "2025-08-10T12:00:00-07:00", // 1 day old, and on since date expectInclude: true, }, { name: "date-only until and min-age - fail", until: "2025-08-10", minAge: "1d", fileMtime: "2025-08-10T12:00:00-07:00", // 1 day old, but not old enough to be excluded by until expectInclude: true, }, { name: "date-only until and min-age - pass", until: "2025-08-10", minAge: "2d", fileMtime: "2025-08-08T12:00:00-07:00", // 3 days old, and before until date expectInclude: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Parse file mtime fileMtime, err := time.Parse(time.RFC3339, tt.fileMtime) if err != nil { t.Fatalf("Failed to parse file mtime: %v", err) } // Create time filter filter, err := NewTimeFilter(tt.since, tt.until, tt.maxAge, tt.minAge, now, loc) if err != nil { t.Fatalf("Failed to create time filter: %v", err) } // Test inclusion result := filter.IncludeByTimeFilter(fileMtime, loc) if result != tt.expectInclude { t.Errorf("Expected include=%v, got include=%v", tt.expectInclude, result) } }) } } func TestIncludeByTimeBound(t *testing.T) { loc, err := time.LoadLocation("America/Vancouver") if err != nil { t.Fatalf("Failed to load timezone: %v", err) } tests := []struct { name string boundArg string fileMtime string isUntil bool expectInclude bool }{ { name: "since instant - file after", boundArg: "2025-08-11T10:00:00-07:00", fileMtime: "2025-08-11T11:00:00-07:00", isUntil: false, expectInclude: true, }, { name: "since instant - file before", boundArg: "2025-08-11T10:00:00-07:00", fileMtime: "2025-08-11T09:00:00-07:00", isUntil: false, expectInclude: false, }, { name: "since instant - file exactly at boundary", boundArg: "2025-08-11T10:00:00-07:00", fileMtime: "2025-08-11T10:00:00-07:00", isUntil: false, expectInclude: true, }, { name: "until instant - file before", boundArg: "2025-08-11T10:00:00-07:00", fileMtime: "2025-08-11T09:00:00-07:00", isUntil: true, expectInclude: true, }, { name: "until instant - file after", boundArg: "2025-08-11T10:00:00-07:00", fileMtime: "2025-08-11T11:00:00-07:00", isUntil: true, expectInclude: false, }, { name: "until instant - file exactly at boundary", boundArg: "2025-08-11T10:00:00-07:00", fileMtime: "2025-08-11T10:00:00-07:00", isUntil: true, expectInclude: true, }, { name: "since date - file just before day", boundArg: "2025-08-11", fileMtime: "2025-08-10T23:59:59-07:00", isUntil: false, expectInclude: false, }, { name: "since date - file at start of day", boundArg: "2025-08-11", fileMtime: "2025-08-11T00:00:00-07:00", isUntil: false, expectInclude: true, }, { name: "since date - file at end of day", boundArg: "2025-08-11", fileMtime: "2025-08-11T23:59:59-07:00", isUntil: false, expectInclude: true, }, { name: "since date - file on next day", boundArg: "2025-08-11", fileMtime: "2025-08-12T00:00:00-07:00", isUntil: false, expectInclude: true, }, { name: "until date - file on previous day", boundArg: "2025-08-11", fileMtime: "2025-08-10T23:59:59-07:00", isUntil: true, expectInclude: true, }, { name: "until date - file at start of day", boundArg: "2025-08-11", fileMtime: "2025-08-11T00:00:00-07:00", isUntil: true, expectInclude: true, }, { name: "until date - file at end of day", boundArg: "2025-08-11", fileMtime: "2025-08-11T23:59:59-07:00", isUntil: true, expectInclude: true, }, { name: "until date - file just after day", boundArg: "2025-08-11", fileMtime: "2025-08-12T00:00:00-07:00", isUntil: true, expectInclude: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Parse time bound bound, err := parseTimeValue(tt.boundArg, loc) if err != nil { t.Fatalf("Failed to parse time bound: %v", err) } // Parse file mtime fileMtime, err := time.Parse(time.RFC3339, tt.fileMtime) if err != nil { t.Fatalf("Failed to parse file mtime: %v", err) } // Test inclusion result := includeByTimeBound(fileMtime, bound, loc, tt.isUntil) if result != tt.expectInclude { t.Errorf("Expected include=%v, got include=%v", tt.expectInclude, result) } }) } } gdu-5.36.1/report/000077500000000000000000000000001517447455500137215ustar00rootroot00000000000000gdu-5.36.1/report/export.go000066400000000000000000000133761517447455500156030ustar00rootroot00000000000000package report import ( "bytes" "errors" "fmt" "io" "math" "os" "strconv" "sync" "time" "github.com/dundee/gdu/v5/build" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/device" "github.com/dundee/gdu/v5/pkg/fs" "github.com/fatih/color" ) // UI struct type UI struct { *common.UI output io.Writer exportOutput io.Writer red *color.Color orange *color.Color writtenChan chan struct{} } // CreateExportUI creates UI for stdout func CreateExportUI( output io.Writer, exportOutput io.Writer, useColors bool, showProgress bool, useSIPrefix bool, ) *UI { ui := &UI{ UI: &common.UI{ ShowProgress: showProgress, Analyzer: analyze.CreateAnalyzer(), UseSIPrefix: useSIPrefix, }, output: output, exportOutput: exportOutput, writtenChan: make(chan struct{}), } ui.red = color.New(color.FgRed).Add(color.Bold) ui.orange = color.New(color.FgYellow).Add(color.Bold) if !useColors { color.NoColor = true } return ui } // StartUILoop stub func (ui *UI) StartUILoop() error { return nil } // SetCollapsePath sets the flag to collapse paths func (ui *UI) SetCollapsePath(value bool) { } // ListDevices lists mounted devices and shows their disk usage func (ui *UI) ListDevices(getter device.DevicesInfoGetter) error { return errors.New("exporting devices list is not supported") } // ReadAnalysis reads analysis report from JSON file func (ui *UI) ReadAnalysis(input io.Reader) error { return errors.New("reading analysis is not possible while exporting") } // ReadFromStorage reads analysis data from persistent key-value storage func (ui *UI) ReadFromStorage(storagePath, path string) error { storage := analyze.NewStorage(storagePath, path) closeFn := storage.Open() defer closeFn() dir, err := storage.GetDirForPath(path) if err != nil { return err } var waitWritten sync.WaitGroup if ui.ShowProgress { waitWritten.Add(1) go func() { defer waitWritten.Done() ui.updateProgress() }() } return ui.exportDir(dir, &waitWritten) } // AnalyzePath analyzes recursively disk usage in given path func (ui *UI) AnalyzePath(path string, _ fs.Item) error { var ( dir fs.Item wait sync.WaitGroup waitWritten sync.WaitGroup ) if ui.ShowProgress { waitWritten.Add(1) go func() { defer waitWritten.Done() ui.updateProgress() }() } wait.Add(1) go func() { defer wait.Done() dir = ui.Analyzer.AnalyzeDir(path, ui.CreateIgnoreFunc(), ui.CreateFileTypeFilter()) dir.UpdateStats(make(fs.HardLinkedItems, 10)) }() wait.Wait() return ui.exportDir(dir, &waitWritten) } func (ui *UI) exportDir(dir fs.Item, waitWritten *sync.WaitGroup) error { // Sorting is now handled by GetFiles with sort parameters var ( buff bytes.Buffer err error ) buff.Write([]byte(`[1,2,{"progname":"gdu","progver":"`)) buff.Write([]byte(build.Version)) buff.Write([]byte(`","timestamp":`)) buff.Write([]byte(strconv.FormatInt(time.Now().Unix(), 10))) buff.Write([]byte("},\n")) if err := dir.EncodeJSON(&buff, true); err != nil { return err } if _, err = buff.Write([]byte("]\n")); err != nil { return err } if _, err = buff.WriteTo(ui.exportOutput); err != nil { return err } if f, ok := ui.exportOutput.(*os.File); ok { err = f.Close() if err != nil { return err } } if ui.ShowProgress { ui.writtenChan <- struct{}{} waitWritten.Wait() } return nil } func (ui *UI) updateProgress() { waitingForWrite := false emptyRow := "\r" for j := 0; j < 100; j++ { emptyRow += " " } progressRunes := []rune(`⠇⠏⠋⠙⠹⠸⠼⠴⠦⠧`) doneChan := ui.Analyzer.GetDone() i := 0 for { fmt.Fprint(ui.output, emptyRow) progress := ui.Analyzer.GetProgress() select { case <-doneChan: fmt.Fprint(ui.output, "\r") waitingForWrite = true case <-ui.writtenChan: fmt.Fprint(ui.output, "\r") return default: } fmt.Fprintf(ui.output, "\r %s ", string(progressRunes[i])) if waitingForWrite { fmt.Fprint(ui.output, "Writing output file...") } else { fmt.Fprint(ui.output, "Scanning... Total items: "+ ui.red.Sprint(common.FormatNumber(int64(progress.ItemCount)))+ " size: "+ ui.formatSize(progress.TotalUsage)) } time.Sleep(100 * time.Millisecond) i++ i %= 10 } } func (ui *UI) formatSize(size int64) string { if ui.UseSIPrefix { return ui.formatWithDecPrefix(size) } return ui.formatWithBinPrefix(size) } func (ui *UI) formatWithBinPrefix(size int64) string { fsize := float64(size) asize := math.Abs(fsize) switch { case asize >= common.Ei: return ui.orange.Sprintf("%.1f", fsize/common.Ei) + " EiB" case asize >= common.Pi: return ui.orange.Sprintf("%.1f", fsize/common.Pi) + " PiB" case asize >= common.Ti: return ui.orange.Sprintf("%.1f", fsize/common.Ti) + " TiB" case asize >= common.Gi: return ui.orange.Sprintf("%.1f", fsize/common.Gi) + " GiB" case asize >= common.Mi: return ui.orange.Sprintf("%.1f", fsize/common.Mi) + " MiB" case asize >= common.Ki: return ui.orange.Sprintf("%.1f", fsize/common.Ki) + " KiB" default: return ui.orange.Sprintf("%d", size) + " B" } } func (ui *UI) formatWithDecPrefix(size int64) string { fsize := float64(size) asize := math.Abs(fsize) switch { case asize >= common.E: return ui.orange.Sprintf("%.1f", fsize/common.E) + " EB" case asize >= common.P: return ui.orange.Sprintf("%.1f", fsize/common.P) + " PB" case asize >= common.T: return ui.orange.Sprintf("%.1f", fsize/common.T) + " TB" case asize >= common.G: return ui.orange.Sprintf("%.1f", fsize/common.G) + " GB" case asize >= common.M: return ui.orange.Sprintf("%.1f", fsize/common.M) + " MB" case asize >= common.K: return ui.orange.Sprintf("%.1f", fsize/common.K) + " kB" default: return ui.orange.Sprintf("%d", size) + " B" } } gdu-5.36.1/report/export_linux_test.go000066400000000000000000000024421517447455500200510ustar00rootroot00000000000000//go:build linux package report import ( "bytes" "os" "testing" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/stretchr/testify/assert" ) func TestReadFromStorage(t *testing.T) { fin := testdir.CreateTestDir() defer fin() const storagePath = "/tmp/badger-test2" defer func() { err := os.RemoveAll(storagePath) if err != nil { panic(err) } }() output := bytes.NewBuffer(make([]byte, 10)) reportOutput := bytes.NewBuffer(make([]byte, 10)) ui := CreateExportUI(output, reportOutput, false, true, false) ui.SetIgnoreDirPaths([]string{"/xxx"}) ui.SetAnalyzer(analyze.CreateStoredAnalyzer(storagePath)) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.ReadFromStorage(storagePath, "test_dir") assert.Nil(t, err) assert.Contains(t, reportOutput.String(), `"name":"nested"`) } func TestReadFromStorageWithErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() const storagePath = "/tmp/badger-test3" output := bytes.NewBuffer(make([]byte, 10)) reportOutput := bytes.NewBuffer(make([]byte, 10)) ui := CreateExportUI(output, reportOutput, false, false, false) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.ReadFromStorage(storagePath, "test_dir") assert.ErrorContains(t, err, "Key not found") } gdu-5.36.1/report/export_test.go000066400000000000000000000072561517447455500166420ustar00rootroot00000000000000package report import ( "bytes" "os" "testing" log "github.com/sirupsen/logrus" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/device" "github.com/stretchr/testify/assert" ) func init() { log.SetLevel(log.WarnLevel) } func TestAnalyzePath(t *testing.T) { fin := testdir.CreateTestDir() defer fin() output := bytes.NewBuffer(make([]byte, 10)) reportOutput := bytes.NewBuffer(make([]byte, 10)) ui := CreateExportUI(output, reportOutput, false, false, false) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) assert.Contains(t, reportOutput.String(), `"name":"nested"`) } func TestAnalyzePathWithProgress(t *testing.T) { fin := testdir.CreateTestDir() defer fin() output := bytes.NewBuffer(make([]byte, 10)) reportOutput := bytes.NewBuffer(make([]byte, 10)) ui := CreateExportUI(output, reportOutput, true, true, true) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) assert.Contains(t, reportOutput.String(), `"name":"nested"`) } func TestShowDevices(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) reportOutput := bytes.NewBuffer(make([]byte, 10)) ui := CreateExportUI(output, reportOutput, false, true, false) err := ui.ListDevices(device.Getter) assert.Contains(t, err.Error(), "not supported") } func TestReadAnalysisWhileExporting(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) reportOutput := bytes.NewBuffer(make([]byte, 10)) ui := CreateExportUI(output, reportOutput, false, true, false) err := ui.ReadAnalysis(output) assert.Contains(t, err.Error(), "not possible while exporting") } func TestExportToFile(t *testing.T) { fin := testdir.CreateTestDir() defer fin() reportOutput, err := os.OpenFile("output.json", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) assert.Nil(t, err) defer func() { os.Remove("output.json") }() output := bytes.NewBuffer(make([]byte, 10)) ui := CreateExportUI(output, reportOutput, false, true, false) ui.SetIgnoreDirPaths([]string{"/xxx"}) err = ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) reportOutput, err = os.OpenFile("output.json", os.O_RDONLY, 0o644) assert.Nil(t, err) _, err = reportOutput.Seek(0, 0) assert.Nil(t, err) buff := make([]byte, 200) _, err = reportOutput.Read(buff) assert.Nil(t, err) assert.Contains(t, string(buff), `"name":"nested"`) } func TestFormatSize(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) reportOutput := bytes.NewBuffer(make([]byte, 10)) ui := CreateExportUI(output, reportOutput, false, true, false) assert.Contains(t, ui.formatSize(1), "B") assert.Contains(t, ui.formatSize(1<<10+1), "KiB") assert.Contains(t, ui.formatSize(1<<20+1), "MiB") assert.Contains(t, ui.formatSize(1<<30+1), "GiB") assert.Contains(t, ui.formatSize(1<<40+1), "TiB") assert.Contains(t, ui.formatSize(1<<50+1), "PiB") assert.Contains(t, ui.formatSize(1<<60+1), "EiB") assert.Contains(t, ui.formatSize(-1<<10-1), "KiB") } func TestFormatSizeDec(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) reportOutput := bytes.NewBuffer(make([]byte, 10)) ui := CreateExportUI(output, reportOutput, false, true, true) assert.Contains(t, ui.formatSize(1), "B") assert.Contains(t, ui.formatSize(1<<10+1), "kB") assert.Contains(t, ui.formatSize(1<<20+1), "MB") assert.Contains(t, ui.formatSize(1<<30+1), "GB") assert.Contains(t, ui.formatSize(1<<40+1), "TB") assert.Contains(t, ui.formatSize(1<<50+1), "PB") assert.Contains(t, ui.formatSize(1<<60+1), "EB") assert.Contains(t, ui.formatSize(-1<<10-1), "kB") } gdu-5.36.1/report/import.go000066400000000000000000000045051517447455500155660ustar00rootroot00000000000000package report import ( "bytes" "encoding/json" "errors" "io" "strings" "time" "github.com/dundee/gdu/v5/pkg/analyze" ) // ReadAnalysis reads analysis report from JSON file and returns directory item func ReadAnalysis(input io.Reader) (dir *analyze.Dir, err error) { var data interface{} var buff bytes.Buffer if _, err = buff.ReadFrom(input); err != nil { return nil, err } if err := json.Unmarshal(buff.Bytes(), &data); err != nil { return nil, err } dataArray, ok := data.([]interface{}) if !ok { return nil, errors.New("JSON file does not contain top level array") } if len(dataArray) < 4 { return nil, errors.New("top level array must have at least 4 items") } items, ok := dataArray[3].([]interface{}) if !ok { return nil, errors.New("array of maps not found in the top level array on 4th position") } return processDir(items) } func processDir(items []interface{}) (dir *analyze.Dir, err error) { dir = &analyze.Dir{ File: &analyze.File{ Flag: ' ', }, } dirMap, ok := items[0].(map[string]interface{}) if !ok { return nil, errors.New("directory item is not a map") } name, ok := dirMap["name"].(string) if !ok { return nil, errors.New("directory name is not a string") } if mtime, ok := dirMap["mtime"].(float64); ok { dir.Mtime = time.Unix(int64(mtime), 0) } slashPos := strings.LastIndex(name, "/") if slashPos > -1 { dir.Name = name[slashPos+1:] dir.BasePath = name[:slashPos+1] } else { dir.Name = name } for _, v := range items[1:] { switch item := v.(type) { case map[string]interface{}: file := &analyze.File{} file.Name = item["name"].(string) if asize, ok := item["asize"].(float64); ok { file.Size = int64(asize) } if dsize, ok := item["dsize"].(float64); ok { file.Usage = int64(dsize) } if mtime, ok := item["mtime"].(float64); ok { file.Mtime = time.Unix(int64(mtime), 0) } if _, ok := item["notreg"].(bool); ok { file.Flag = '@' } else { file.Flag = ' ' } if mli, ok := item["ino"].(float64); ok { file.Mli = uint64(mli) } if _, ok := item["hlnkc"].(bool); ok { file.Flag = 'H' } file.Parent = dir dir.AddFile(file) case []interface{}: subdir, err := processDir(item) if err != nil { return nil, err } subdir.Parent = dir dir.AddFile(subdir) } } return dir, nil } gdu-5.36.1/report/import_test.go000066400000000000000000000056231517447455500166270ustar00rootroot00000000000000package report import ( "bytes" "errors" "testing" "github.com/dundee/gdu/v5/pkg/analyze" log "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) func init() { log.SetLevel(log.WarnLevel) } func TestReadAnalysis(t *testing.T) { buff := bytes.NewBuffer([]byte(` [1,2,{"progname":"gdu","progver":"development","timestamp":1626806293}, [{"name":"/home/xxx","mtime":1629333600}, {"name":"gdu.json","asize":33805233,"dsize":33808384}, {"name":"sock","notreg":true}, [{"name":"app"}, {"name":"app.go","asize":4638,"dsize":8192}, {"name":"app_linux_test.go","asize":1410,"dsize":4096}, {"name":"app_linux_test2.go","ino":1234,"hlnkc":true,"asize":1410,"dsize":4096}, {"name":"app_test.go","asize":4974,"dsize":8192}], {"name":"main.go","asize":3205,"dsize":4096,"mtime":1629333600}]] `)) dir, err := ReadAnalysis(buff) assert.Nil(t, err) assert.Equal(t, "xxx", dir.GetName()) assert.Equal(t, "/home/xxx", dir.GetPath()) assert.Equal(t, 2021, dir.GetMtime().Year()) assert.Equal(t, 2021, dir.Files[3].GetMtime().Year()) alt2 := dir.Files[2].(*analyze.Dir).Files[2].(*analyze.File) assert.Equal(t, "app_linux_test2.go", alt2.Name) assert.Equal(t, uint64(1234), alt2.Mli) assert.Equal(t, 'H', alt2.Flag) } func TestReadAnalysisWithEmptyInput(t *testing.T) { buff := bytes.NewBuffer([]byte(``)) _, err := ReadAnalysis(buff) assert.Equal(t, "unexpected end of JSON input", err.Error()) } func TestReadAnalysisWithEmptyDict(t *testing.T) { buff := bytes.NewBuffer([]byte(`{}`)) _, err := ReadAnalysis(buff) assert.Equal(t, "JSON file does not contain top level array", err.Error()) } func TestReadFromBrokenInput(t *testing.T) { _, err := ReadAnalysis(&BrokenInput{}) assert.Equal(t, "IO error", err.Error()) } func TestReadAnalysisWithEmptyArray(t *testing.T) { buff := bytes.NewBuffer([]byte(`[]`)) _, err := ReadAnalysis(buff) assert.Equal(t, "top level array must have at least 4 items", err.Error()) } func TestReadAnalysisWithWrongContent(t *testing.T) { buff := bytes.NewBuffer([]byte(`[1,2,3,4]`)) _, err := ReadAnalysis(buff) assert.Equal(t, "array of maps not found in the top level array on 4th position", err.Error()) } func TestReadAnalysisWithEmptyDirContent(t *testing.T) { buff := bytes.NewBuffer([]byte(`[1,2,3,[{}]]`)) _, err := ReadAnalysis(buff) assert.Equal(t, "directory name is not a string", err.Error()) } func TestReadAnalysisWithWrongDirItem(t *testing.T) { buff := bytes.NewBuffer([]byte(`[1,2,3,[1, 2, 3]]`)) _, err := ReadAnalysis(buff) assert.Equal(t, "directory item is not a map", err.Error()) } func TestReadAnalysisWithWrongSubdirItem(t *testing.T) { buff := bytes.NewBuffer([]byte(`[1,2,3,[{"name":"xxx"}, [1,2,3]]]`)) _, err := ReadAnalysis(buff) assert.Equal(t, "directory item is not a map", err.Error()) } type BrokenInput struct{} func (i *BrokenInput) Read(p []byte) (n int, err error) { return 0, errors.New("IO error") } gdu-5.36.1/snapcraft.yaml000066400000000000000000000022511517447455500152530ustar00rootroot00000000000000name: gdu-disk-usage-analyzer version: git summary: Pretty fast disk usage analyzer written in Go. description: | Gdu is intended primarily for SSD disks where it can fully utilize parallel processing. However HDDs work as well, but the performance gain is not so huge. confinement: strict base: core24 platforms: amd64: arm64: armhf: ppc64el: riscv64: s390x: parts: gdu: plugin: go build-snaps: [go/latest/stable] source: . override-build: | GO111MODULE=on CGO_ENABLED=0 go build \ -trimpath -mod=readonly -modcacherw -pgo=default.pgo \ -ldflags \ "-s -w -extldflags '-static' \ -X 'github.com/dundee/gdu/v5/build.Version=$(git describe)' \ -X 'github.com/dundee/gdu/v5/build.User=$(id -u -n)' \ -X 'github.com/dundee/gdu/v5/build.Time=$(LC_ALL=en_US.UTF-8 date)' \ -X 'github.com/dundee/gdu/v5/build.RootPathPrefix=/var/lib/snapd/hostfs'" \ -o $SNAPCRAFT_PART_INSTALL/gdu \ github.com/dundee/gdu/v5/cmd/gdu $SNAPCRAFT_PART_INSTALL/gdu -v apps: gdu: command: gdu plugs: - mount-observe - system-backup gdu-5.36.1/stdout/000077500000000000000000000000001517447455500137305ustar00rootroot00000000000000gdu-5.36.1/stdout/stdout.go000066400000000000000000000303641517447455500156070ustar00rootroot00000000000000package stdout import ( "fmt" "io" "math" "runtime" "sync" "time" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/device" "github.com/dundee/gdu/v5/pkg/fs" "github.com/dundee/gdu/v5/report" "github.com/fatih/color" ) // UI struct type UI struct { output io.Writer *common.UI red *color.Color orange *color.Color blue *color.Color showItemCnt bool top int depth int summarize bool noPrefix bool fixedBase float64 fixedSuffix string reverseSort bool } var ( progressRunes = []rune(`⠇⠏⠋⠙⠹⠸⠼⠴⠦⠧`) progressRunesOld = []rune(`-\\|/`) progressRunesCount = len(progressRunes) ) // CreateStdoutUI creates UI for stdout func CreateStdoutUI( output io.Writer, useColors bool, showProgress bool, showApparentSize bool, showRelativeSize bool, summarize bool, useSIPrefix bool, noPrefix bool, fixedUnit string, top int, reverseSort bool, depth int, ) *UI { ui := &UI{ UI: &common.UI{ UseColors: useColors, ShowProgress: showProgress, ShowApparentSize: showApparentSize, ShowRelativeSize: showRelativeSize, Analyzer: analyze.CreateTopDirAnalyzer(), UseSIPrefix: useSIPrefix, }, output: output, summarize: summarize, noPrefix: noPrefix, top: top, reverseSort: reverseSort, depth: depth, } if fixedUnit != "" { ui.SetFixedUnit(fixedUnit) } ui.red = color.New(color.FgRed).Add(color.Bold) ui.orange = color.New(color.FgYellow).Add(color.Bold) ui.blue = color.New(color.FgBlue).Add(color.Bold) if ui.top > 0 || ui.depth > 0 { ui.Analyzer = analyze.CreateAnalyzer() } if !useColors { color.NoColor = true } return ui } func (ui *UI) SetFixedUnit(unitChar string) { k, m, g := common.Ki, common.Mi, common.Gi suffixMap := map[string]string{"k": " KiB", "m": " MiB", "g": " GiB"} if ui.UseSIPrefix { k, m, g = common.K, common.M, common.G suffixMap = map[string]string{"k": " kB", "m": " MB", "g": " GB"} } switch unitChar { case "k": ui.fixedBase = k ui.fixedSuffix = suffixMap["k"] case "m": ui.fixedBase = m ui.fixedSuffix = suffixMap["m"] case "g": ui.fixedBase = g ui.fixedSuffix = suffixMap["g"] } } func (ui *UI) SetShowItemCount() { ui.showItemCnt = true } func (ui *UI) UseOldProgressRunes() { progressRunes = progressRunesOld progressRunesCount = len(progressRunes) } // StartUILoop stub func (ui *UI) StartUILoop() error { return nil } // SetCollapsePath sets the flag to collapse paths func (ui *UI) SetCollapsePath(value bool) { } // ListDevices lists mounted devices and shows their disk usage func (ui *UI) ListDevices(getter device.DevicesInfoGetter) error { devices, err := getter.GetDevicesInfo() if err != nil { return err } maxDeviceNameLength := maxInt(maxLength( devices, func(device *device.Device) string { return device.Name }, ), len("Devices")) var sizeLength, percentLength int if ui.UseColors { sizeLength = 20 percentLength = 16 } else { sizeLength = 9 percentLength = 5 } lineFormat := fmt.Sprintf( "%%%ds %%%ds %%%ds %%%ds %%%ds %%s\n", maxDeviceNameLength, sizeLength, sizeLength, sizeLength, percentLength, ) fmt.Fprintf( ui.output, fmt.Sprintf("%%%ds %%9s %%9s %%9s %%5s %%s\n", maxDeviceNameLength), "Device", "Size", "Used", "Free", "Used%", "Mount point", ) for _, device := range devices { usedPercent := math.Round(float64(device.Size-device.Free) / float64(device.Size) * 100) fmt.Fprintf( ui.output, lineFormat, device.Name, ui.formatSize(device.Size), ui.formatSize(device.Size-device.Free), ui.formatSize(device.Free), ui.red.Sprintf("%.f%%", usedPercent), device.MountPoint) } return nil } // AnalyzePath analyzes recursively disk usage in given path func (ui *UI) AnalyzePath(path string, _ fs.Item) error { var ( dir fs.Item wait sync.WaitGroup updateStatsDone chan struct{} ) updateStatsDone = make(chan struct{}, 1) if ui.ShowProgress { wait.Add(1) go func() { defer wait.Done() ui.updateProgress(updateStatsDone) }() } wait.Add(1) go func() { defer wait.Done() dir = ui.Analyzer.AnalyzeDir(path, ui.CreateIgnoreFunc(), ui.CreateFileTypeFilter()) dir.UpdateStats(make(fs.HardLinkedItems, 10)) updateStatsDone <- struct{}{} }() wait.Wait() switch { case ui.top > 0: ui.printTopFiles(dir) case ui.depth > 0: ui.printDirWithDepth(dir, 0) case ui.summarize: ui.printTotalItem(dir) default: ui.showDir(dir) } return nil } // ReadFromStorage reads analysis data from persistent key-value storage func (ui *UI) ReadFromStorage(storagePath, path string) error { storage := analyze.NewStorage(storagePath, path) closeFn := storage.Open() defer closeFn() dir, err := storage.GetDirForPath(path) if err != nil { return err } switch { case ui.top > 0: ui.printTopFiles(dir) case ui.summarize: ui.printTotalItem(dir) default: ui.showDir(dir) } return nil } func (ui *UI) showDir(dir fs.Item) { sortOrder := fs.SortDesc if ui.reverseSort { sortOrder = fs.SortAsc } for file := range dir.GetFiles(fs.SortBySize, sortOrder) { ui.printItem(file) } } func (ui *UI) printTopFiles(file fs.Item) { collected := analyze.CollectTopFiles(file, ui.top) for _, file := range collected { ui.printItemPath(file) } } func (ui *UI) printTotalItem(file fs.Item) { var lineFormat string if ui.UseColors { lineFormat = "%20s %s\n" } else { lineFormat = "%9s %s\n" } var size int64 if ui.ShowApparentSize { size = file.GetSize() } else { size = file.GetUsage() } fmt.Fprintf( ui.output, lineFormat, ui.formatSize(size), file.GetName(), ) } func (ui *UI) printItem(file fs.Item) { var lineFormat string if ui.showItemCnt { if ui.UseColors { lineFormat = "%s %23s %25s %s\n" } else { lineFormat = "%s %9s %11s %s\n" } } else { if ui.UseColors { lineFormat = "%s %23s %s\n" } else { lineFormat = "%s %9s %s\n" } } var size int64 if ui.ShowApparentSize { size = file.GetSize() } else { size = file.GetUsage() } countToDisplay := file.GetItemCount() if file.IsDir() { countToDisplay-- } name := file.GetName() if file.IsDir() { name = ui.blue.Sprint("/" + file.GetName()) } if ui.showItemCnt { fmt.Fprintf( ui.output, lineFormat, string(file.GetFlag()), ui.formatSize(size), ui.formatCount(countToDisplay), name, ) return } fmt.Fprintf( ui.output, lineFormat, string(file.GetFlag()), ui.formatSize(size), name, ) } func (ui *UI) printItemPath(file fs.Item) { var lineFormat string if ui.UseColors { lineFormat = "%20s %s\n" } else { lineFormat = "%9s %s\n" } var size int64 if ui.ShowApparentSize { size = file.GetSize() } else { size = file.GetUsage() } if file.IsDir() { fmt.Fprintf(ui.output, lineFormat, ui.formatSize(size), ui.blue.Sprint(file.GetPath())) } else { fmt.Fprintf(ui.output, lineFormat, ui.formatSize(size), file.GetPath()) } } func (ui *UI) printDirWithDepth(dir fs.Item, currentDepth int) { // Print current directory ui.printItemPath(dir) // If we haven't reached the max depth, print contents if currentDepth < ui.depth && dir.IsDir() { sortOrder := fs.SortDesc if ui.reverseSort { sortOrder = fs.SortAsc } files := dir.GetFiles(fs.SortBySize, sortOrder) // Print all files at this depth level for file := range files { if file.IsDir() { // Recurse into subdirectories ui.printDirWithDepth(file, currentDepth+1) } else { // Print regular files ui.printItemPath(file) } } } } // ReadAnalysis reads analysis report from JSON file func (ui *UI) ReadAnalysis(input io.Reader) error { var ( dir fs.Item wait sync.WaitGroup err error doneChan chan struct{} ) if ui.ShowProgress { wait.Add(1) doneChan = make(chan struct{}) go func() { defer wait.Done() ui.showReadingProgress(doneChan) }() } wait.Add(1) go func() { defer wait.Done() dir, err = report.ReadAnalysis(input) if err != nil { if ui.ShowProgress { doneChan <- struct{}{} } return } runtime.GC() dir.UpdateStats(make(fs.HardLinkedItems, 10)) if ui.ShowProgress { doneChan <- struct{}{} } }() wait.Wait() if err != nil { return err } if ui.summarize { ui.printTotalItem(dir) } else { ui.showDir(dir) } return nil } func (ui *UI) showReadingProgress(doneChan chan struct{}) { emptyRow := "\r" for j := 0; j < 40; j++ { emptyRow += " " } i := 0 for { fmt.Fprint(ui.output, emptyRow) select { case <-doneChan: fmt.Fprint(ui.output, "\r") return default: } fmt.Fprintf(ui.output, "\r %s ", string(progressRunes[i])) fmt.Fprint(ui.output, "Reading analysis from file...") time.Sleep(100 * time.Millisecond) i++ i %= progressRunesCount } } func (ui *UI) updateProgress(updateStatsDone <-chan struct{}) { emptyRow := "\r" for j := 0; j < 100; j++ { emptyRow += " " } analysisDoneChan := ui.Analyzer.GetDone() ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() i := 0 for { select { case <-ticker.C: progress := ui.Analyzer.GetProgress() fmt.Fprint(ui.output, emptyRow) fmt.Fprintf(ui.output, "\r %s ", string(progressRunes[i])) fmt.Fprint(ui.output, "Scanning... Total items: "+ ui.red.Sprint(common.FormatNumber(int64(progress.ItemCount)))+ " size: "+ ui.formatSize(progress.TotalUsage)) i++ i %= progressRunesCount case <-analysisDoneChan: ticker.Stop() fmt.Fprint(ui.output, emptyRow) for { fmt.Fprint(ui.output, emptyRow) fmt.Fprintf(ui.output, "\r %s ", string(progressRunes[i])) fmt.Fprint(ui.output, "Calculating disk usage...") time.Sleep(100 * time.Millisecond) i++ i %= progressRunesCount select { case <-updateStatsDone: fmt.Fprint(ui.output, emptyRow) fmt.Fprint(ui.output, "\r") return default: } } } } } func (ui *UI) formatCount(count int64) string { count64 := float64(count) switch { case count64 >= common.G: return ui.red.Sprintf("%.1f", float64(count)/float64(common.G)) + "G" case count64 >= common.M: return ui.red.Sprintf("%.1f", float64(count)/float64(common.M)) + "M" case count64 >= common.K: return ui.red.Sprintf("%.1f", float64(count)/float64(common.K)) + "k" default: return ui.red.Sprintf("%d", count) } } func (ui *UI) formatSize(size int64) string { if ui.noPrefix { return ui.orange.Sprintf("%d", size) } if ui.fixedBase > 0 { val := float64(size) / ui.fixedBase return ui.orange.Sprintf("%.1f", val) + ui.fixedSuffix } if ui.UseSIPrefix { return ui.formatWithDecPrefix(size) } return ui.formatWithBinPrefix(size) } func (ui *UI) formatWithBinPrefix(size int64) string { fsize := float64(size) asize := math.Abs(fsize) switch { case asize >= common.Ei: return ui.orange.Sprintf("%.1f", fsize/common.Ei) + " EiB" case asize >= common.Pi: return ui.orange.Sprintf("%.1f", fsize/common.Pi) + " PiB" case asize >= common.Ti: return ui.orange.Sprintf("%.1f", fsize/common.Ti) + " TiB" case asize >= common.Gi: return ui.orange.Sprintf("%.1f", fsize/common.Gi) + " GiB" case asize >= common.Mi: return ui.orange.Sprintf("%.1f", fsize/common.Mi) + " MiB" case asize >= common.Ki: return ui.orange.Sprintf("%.1f", fsize/common.Ki) + " KiB" default: return ui.orange.Sprintf("%d", size) + " B" } } func (ui *UI) formatWithDecPrefix(size int64) string { fsize := float64(size) asize := math.Abs(fsize) switch { case asize >= common.E: return ui.orange.Sprintf("%.1f", fsize/common.E) + " EB" case asize >= common.P: return ui.orange.Sprintf("%.1f", fsize/common.P) + " PB" case asize >= common.T: return ui.orange.Sprintf("%.1f", fsize/common.T) + " TB" case asize >= common.G: return ui.orange.Sprintf("%.1f", fsize/common.G) + " GB" case asize >= common.M: return ui.orange.Sprintf("%.1f", fsize/common.M) + " MB" case asize >= common.K: return ui.orange.Sprintf("%.1f", fsize/common.K) + " kB" default: return ui.orange.Sprintf("%d", size) + " B" } } func maxLength(list []*device.Device, keyGetter func(*device.Device) string) int { maxLen := 0 var s string for _, item := range list { s = keyGetter(item) if len(s) > maxLen { maxLen = len(s) } } return maxLen } func maxInt(x, y int) int { if x > y { return x } return y } gdu-5.36.1/stdout/stdout_linux_test.go000066400000000000000000000010551517447455500200600ustar00rootroot00000000000000//go:build linux package stdout import ( "bytes" "testing" log "github.com/sirupsen/logrus" "github.com/dundee/gdu/v5/pkg/device" "github.com/stretchr/testify/assert" ) func init() { log.SetLevel(log.WarnLevel) } func TestShowDevicesWithErr(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) getter := device.LinuxDevicesInfoGetter{MountsPath: "/xyzxyz"} ui := CreateStdoutUI(output, false, true, false, false, false, false, false, "", 0, false, 0) err := ui.ListDevices(getter) assert.Contains(t, err.Error(), "no such file") } gdu-5.36.1/stdout/stdout_test.go000066400000000000000000000436621517447455500166530ustar00rootroot00000000000000package stdout import ( "bytes" "os" "path/filepath" "regexp" "strings" "testing" log "github.com/sirupsen/logrus" "github.com/dundee/gdu/v5/internal/testanalyze" "github.com/dundee/gdu/v5/internal/testdev" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/device" "github.com/stretchr/testify/assert" ) func init() { log.SetLevel(log.WarnLevel) } func TestAnalyzePath(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, false, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) assert.Contains(t, output.String(), "nested") } func TestShowItemCountInNonInteractiveMode(t *testing.T) { tmpDir := t.TempDir() for dirName, fileCount := range map[string]int{"a": 5, "b": 10, "c": 15} { dirPath := filepath.Join(tmpDir, dirName) err := os.Mkdir(dirPath, 0o755) assert.Nil(t, err) for i := 0; i < fileCount; i++ { filePath := filepath.Join(dirPath, "f"+string(rune('a'+i))) err = os.WriteFile(filePath, []byte("x"), 0o644) assert.Nil(t, err) } } output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, false, 0) ui.SetShowItemCount() err := ui.AnalyzePath(tmpDir, nil) assert.Nil(t, err) out := output.String() assert.Regexp(t, regexp.MustCompile(`(?m)\s+5\s+/a$`), out) assert.Regexp(t, regexp.MustCompile(`(?m)\s+10\s+/b$`), out) assert.Regexp(t, regexp.MustCompile(`(?m)\s+15\s+/c$`), out) } func TestShowItemCountInNonInteractiveModeWithColorsAndFile(t *testing.T) { tmpDir := t.TempDir() filePath := filepath.Join(tmpDir, "single") err := os.WriteFile(filePath, []byte("x"), 0o644) assert.Nil(t, err) output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, true, false, false, false, false, false, false, "", 0, false, 0) ui.SetShowItemCount() err = ui.AnalyzePath(tmpDir, nil) assert.Nil(t, err) out := output.String() assert.Regexp(t, regexp.MustCompile(`(?m)\s+1\s+single$`), out) } func TestShowSummary(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, true, false, true, false, true, false, false, "", 0, false, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) assert.Contains(t, output.String(), "test_dir") } func TestShowSummaryBw(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, false, false, false, false, true, false, false, "", 0, false, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) assert.Contains(t, output.String(), "test_dir") } func TestShowTop(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, true, false, true, false, true, false, false, "", 2, false, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) assert.Contains(t, output.String(), "test_dir/nested/subnested/file") assert.Contains(t, output.String(), "test_dir/nested/file2") } func TestShowTopBw(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, false, false, false, false, true, false, false, "", 2, false, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) assert.Contains(t, output.String(), "test_dir/nested/subnested/file") assert.Contains(t, output.String(), "test_dir/nested/file2") } func TestShowDepth(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, false, 2) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) assert.Contains(t, output.String(), "test_dir") assert.Contains(t, output.String(), "test_dir/nested") assert.Contains(t, output.String(), "test_dir/nested/subnested") } func TestShowDepthWithColors(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, true, false, false, false, false, false, false, "", 0, false, 2) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) assert.Contains(t, output.String(), "test_dir") assert.Contains(t, output.String(), "test_dir/nested") } func TestShowDepthWithReverseSort(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, true, 2) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) outputStr := output.String() assert.Contains(t, outputStr, "test_dir") assert.Contains(t, outputStr, "test_dir/nested") assert.Contains(t, outputStr, "test_dir/nested/subnested") } func TestAnalyzeSubdir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, false, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir/nested", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) assert.Contains(t, output.String(), "file2") } func TestAnalyzePathWithColors(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, true, false, true, false, false, false, false, "", 0, false, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir/nested", nil) assert.Nil(t, err) assert.Contains(t, output.String(), "subnested") } func TestAnalyzePathWoUnicode(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, false, true, true, false, false, false, false, "", 0, false, 0) ui.UseOldProgressRunes() err := ui.AnalyzePath("test_dir/nested", nil) assert.Nil(t, err) assert.Contains(t, output.String(), "subnested") } func TestItemRows(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, false, true, false, false, false, false, false, "", 0, false, 0) ui.Analyzer = &testanalyze.MockedAnalyzer{} err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) assert.Contains(t, output.String(), "KiB") } func TestAnalyzePathWithProgress(t *testing.T) { fin := testdir.CreateTestDir() defer fin() output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, false, true, true, false, false, false, false, "", 0, false, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) assert.Contains(t, output.String(), "nested") } func TestShowDevices(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, false, true, false, false, false, false, false, "", 0, false, 0) err := ui.ListDevices(getDevicesInfoMock()) assert.Nil(t, err) assert.Contains(t, output.String(), "Device") assert.Contains(t, output.String(), "xxx") } func TestShowDevicesWithColor(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, true, true, true, false, false, false, false, "", 0, false, 0) err := ui.ListDevices(getDevicesInfoMock()) assert.Nil(t, err) assert.Contains(t, output.String(), "Device") assert.Contains(t, output.String(), "xxx") } func TestReadAnalysisWithColor(t *testing.T) { input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0o644) assert.Nil(t, err) output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, true, true, true, false, false, false, false, "", 0, false, 0) err = ui.ReadAnalysis(input) assert.Nil(t, err) assert.Contains(t, output.String(), "main.go") } func TestReadAnalysisBw(t *testing.T) { input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0o644) assert.Nil(t, err) output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, false, 0) err = ui.ReadAnalysis(input) assert.Nil(t, err) assert.Contains(t, output.String(), "main.go") } func TestReadAnalysisWithWrongFile(t *testing.T) { input, err := os.OpenFile("../internal/testdata/wrong.json", os.O_RDONLY, 0o644) assert.Nil(t, err) output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, true, true, true, false, false, false, false, "", 0, false, 0) err = ui.ReadAnalysis(input) assert.NotNil(t, err) } func TestReadAnalysisWithSummarize(t *testing.T) { input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0o644) assert.Nil(t, err) output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, false, false, false, false, true, false, false, "", 0, false, 0) err = ui.ReadAnalysis(input) assert.Nil(t, err) assert.Contains(t, output.String(), " gdu\n") } func TestMaxInt(t *testing.T) { assert.Equal(t, 5, maxInt(2, 5)) assert.Equal(t, 4, maxInt(4, 2)) } func TestFormatSize(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, true, true, true, false, false, false, false, "", 0, false, 0) assert.Contains(t, ui.formatSize(1), "B") assert.Contains(t, ui.formatSize(1<<10+1), "KiB") assert.Contains(t, ui.formatSize(1<<20+1), "MiB") assert.Contains(t, ui.formatSize(1<<30+1), "GiB") assert.Contains(t, ui.formatSize(1<<40+1), "TiB") assert.Contains(t, ui.formatSize(1<<50+1), "PiB") assert.Contains(t, ui.formatSize(1<<60+1), "EiB") } func TestFormatSizeDec(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, true, true, true, false, false, true, false, "", 0, false, 0) assert.Contains(t, ui.formatSize(1), "B") assert.Contains(t, ui.formatSize(1<<10+1), "kB") assert.Contains(t, ui.formatSize(1<<20+1), "MB") assert.Contains(t, ui.formatSize(1<<30+1), "GB") assert.Contains(t, ui.formatSize(1<<40+1), "TB") assert.Contains(t, ui.formatSize(1<<50+1), "PB") assert.Contains(t, ui.formatSize(1<<60+1), "EB") } func TestFormatCount(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, true, true, true, false, false, true, false, "", 0, false, 0) assert.Equal(t, "42", ui.formatCount(42)) assert.Equal(t, "1.5k", ui.formatCount(1500)) assert.Equal(t, "2.5M", ui.formatCount(2500000)) assert.Equal(t, "3.5G", ui.formatCount(3500000000)) } func TestFormatSizeRaw(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, true, true, true, false, false, true, true, "", 0, false, 0) assert.Equal(t, ui.formatSize(1), "1") assert.Equal(t, ui.formatSize(1<<10+1), "1025") assert.Equal(t, ui.formatSize(1<<20+1), "1048577") assert.Equal(t, ui.formatSize(1<<30+1), "1073741825") assert.Equal(t, ui.formatSize(1<<40+1), "1099511627777") assert.Equal(t, ui.formatSize(1<<50+1), "1125899906842625") assert.Equal(t, ui.formatSize(1<<60+1), "1152921504606846977") } func TestFormatSizeFixedUnitBinary(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "k", 0, false, 0) assert.Equal(t, "0.1 KiB", ui.formatSize(100)) assert.Equal(t, "1500.0 KiB", ui.formatSize(1536000)) ui = CreateStdoutUI(output, false, false, false, false, false, false, false, "m", 0, false, 0) assert.Equal(t, "0.1 MiB", ui.formatSize(100*1024)) assert.Equal(t, "1500.0 MiB", ui.formatSize(1536000*1024)) ui = CreateStdoutUI(output, false, false, false, false, false, false, false, "g", 0, false, 0) assert.Equal(t, "0.1 GiB", ui.formatSize(100*1024*1024)) assert.Equal(t, "1500.0 GiB", ui.formatSize(1536000*1024*1024)) } func TestFormatSizeFixedUnitSI(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) // -k --si ui := CreateStdoutUI(output, false, false, false, false, false, true, false, "k", 0, false, 0) assert.Equal(t, "0.1 kB", ui.formatSize(100)) assert.Equal(t, "1500.0 kB", ui.formatSize(15e+5)) ui = CreateStdoutUI(output, false, false, false, false, false, true, false, "m", 0, false, 0) assert.Equal(t, "0.1 MB", ui.formatSize(1e+5)) assert.Equal(t, "1500.0 MB", ui.formatSize(1.5e+9)) ui = CreateStdoutUI(output, false, false, false, false, false, true, false, "g", 0, false, 0) assert.Equal(t, "0.1 GB", ui.formatSize(1e+8)) assert.Equal(t, "1500.0 GB", ui.formatSize(1.5e+12)) } // func printBuffer(buff *bytes.Buffer) { // for i, x := range buff.String() { // println(i, string(x)) // } // } func getDevicesInfoMock() device.DevicesInfoGetter { item := &device.Device{ Name: "xxx", } mock := testdev.DevicesInfoGetterMock{} mock.Devices = []*device.Device{item} return mock } // New tests for reverse sort functionality func TestAnalyzePathWithReverseSort(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, true, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) assert.Contains(t, output.String(), "nested") // Verify that smaller items appear first when reverse sort is enabled outputStr := output.String() lines := strings.Split(outputStr, "\n") // Filter out empty lines and progress lines var fileLines []string for _, line := range lines { if strings.Contains(line, " ") && !strings.Contains(line, "Scanning") { fileLines = append(fileLines, line) } } // With reverse sort, smaller files should appear before larger ones assert.True(t, len(fileLines) > 0, "Should have file entries in output") } func TestAnalyzePathWithoutReverseSort(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, false, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) assert.Contains(t, output.String(), "nested") } func TestReverseSortWithColors(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, true, false, true, false, false, false, false, "", 0, true, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir/nested", nil) assert.Nil(t, err) assert.Contains(t, output.String(), "subnested") } func TestReverseSortWithSummarize(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, false, false, false, false, true, false, false, "", 0, true, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) assert.Contains(t, output.String(), "test_dir") } func TestReverseSortWithTop(t *testing.T) { fin := testdir.CreateTestDir() defer fin() buff := make([]byte, 10) output := bytes.NewBuffer(buff) ui := CreateStdoutUI(output, true, false, true, false, true, false, false, "", 2, true, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) assert.Contains(t, output.String(), "test_dir/nested/subnested/file") assert.Contains(t, output.String(), "test_dir/nested/file2") } func TestReverseSortFromAnalysisFile(t *testing.T) { input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0o644) assert.Nil(t, err) output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, true, true, true, false, false, false, false, "", 0, true, 0) err = ui.ReadAnalysis(input) assert.Nil(t, err) assert.Contains(t, output.String(), "main.go") } func TestDefaultAnalyzerIsTopDir(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, false, 0) _, ok := ui.Analyzer.(*analyze.TopDirAnalyzer) assert.True(t, ok, "default analyzer should be TopDirAnalyzer") } func TestAnalyzerWithTopIsParallel(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 5, false, 0) _, ok := ui.Analyzer.(*analyze.ParallelAnalyzer) assert.True(t, ok, "analyzer with top > 0 should be ParallelAnalyzer") } func TestAnalyzerWithDepthIsParallel(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, false, 3) _, ok := ui.Analyzer.(*analyze.ParallelAnalyzer) assert.True(t, ok, "analyzer with depth > 0 should be ParallelAnalyzer") } func TestAnalyzePathWithTopDirAnalyzer(t *testing.T) { fin := testdir.CreateTestDir() defer fin() output := bytes.NewBuffer(make([]byte, 10)) ui := CreateStdoutUI(output, false, false, false, false, false, false, false, "", 0, false, 0) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) err = ui.StartUILoop() assert.Nil(t, err) assert.Contains(t, output.String(), "nested") } gdu-5.36.1/tui/000077500000000000000000000000001517447455500132075ustar00rootroot00000000000000gdu-5.36.1/tui/actions.go000066400000000000000000000243731517447455500152070ustar00rootroot00000000000000package tui import ( "bytes" "fmt" "io" "os" "os/exec" "runtime" "runtime/debug" "strconv" "strings" "time" "golang.org/x/text/cases" "golang.org/x/text/language" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/dundee/gdu/v5/build" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/device" "github.com/dundee/gdu/v5/pkg/fs" "github.com/dundee/gdu/v5/report" ) const ( defaultLinesCount = 500 linesThreshold = 20 actionEmpty = "empty" actionDelete = "delete" actingEmpty = "emptying" actingDelete = "deleting" ) // ListDevices lists mounted devices and shows their disk usage func (ui *UI) ListDevices(getter device.DevicesInfoGetter) error { var err error ui.getter = getter ui.devices, err = getter.GetDevicesInfo() if err != nil { return err } ui.showDevices() return nil } // AnalyzePath analyzes recursively disk usage for given path func (ui *UI) AnalyzePath(path string, parentDir fs.Item) error { ui.progress = tview.NewTextView().SetText("Scanning...") ui.progress.SetBorder(true).SetBorderPadding(2, 2, 2, 2) ui.progress.SetTitle(" Scanning... ") ui.progress.SetDynamicColors(true) innerFlex := tview.NewFlex().SetDirection(tview.FlexRow). AddItem(nil, 0, 1, false). AddItem(ui.progress, 8, 1, false) if ui.currentDeviceSize > 0 && ui.showDiskProgressBar { ui.progressBar = NewProgressBar() ui.progressBar.SetBorder(true) ui.progressBar.SetTitle(" Progress ") ui.progressBar.SetUseColor(ui.UseColors) innerFlex.AddItem(ui.progressBar, 3, 1, false) } innerFlex.AddItem(nil, 0, 1, false) flex := tview.NewFlex(). AddItem(nil, 0, 1, false). AddItem(innerFlex, 0, 50, false). AddItem(nil, 0, 1, false) ui.pages.AddPage("progress", flex, true, true) analyzer := ui.Analyzer doneChan := analyzer.GetDone() go ui.updateProgress(analyzer, doneChan) go func() { defer debug.FreeOSMemory() currentDir := ui.Analyzer.AnalyzeDir(path, ui.CreateIgnoreFunc(), ui.CreateFileTypeFilter()) if parentDir != nil { currentDir.SetParent(parentDir) // Remove old entry with the same name and add new one parentDir.RemoveFileByName(currentDir.GetName()) parentDir.AddFile(currentDir) } else { ui.topDirPath = path ui.topDir = currentDir } ui.topDir.UpdateStats(ui.linkedItems) ui.app.QueueUpdateDraw(func() { ui.currentDir = currentDir ui.showDir() ui.pages.RemovePage("progress") }) if ui.done != nil { ui.done <- struct{}{} } }() return nil } // ReadAnalysis reads analysis report from JSON file func (ui *UI) ReadAnalysis(input io.Reader) error { ui.progress = tview.NewTextView().SetText("Reading analysis from file...") ui.progress.SetBorder(true).SetBorderPadding(2, 2, 2, 2) ui.progress.SetTitle(" Reading... ") ui.progress.SetDynamicColors(true) flex := tview.NewFlex(). AddItem(nil, 0, 1, false). AddItem(tview.NewFlex().SetDirection(tview.FlexRow). AddItem(nil, 10, 1, false). AddItem(ui.progress, 8, 1, false). AddItem(nil, 10, 1, false), 0, 50, false). AddItem(nil, 0, 1, false) ui.pages.AddPage("progress", flex, true, true) go func() { var err error ui.currentDir, err = report.ReadAnalysis(input) if err != nil { ui.app.QueueUpdateDraw(func() { ui.pages.RemovePage("progress") ui.showErr("Error reading file", err) }) if ui.done != nil { ui.done <- struct{}{} } return } runtime.GC() ui.topDirPath = ui.currentDir.GetPath() ui.topDir = ui.currentDir links := make(fs.HardLinkedItems, 10) ui.topDir.UpdateStats(links) ui.app.QueueUpdateDraw(func() { ui.showDir() ui.pages.RemovePage("progress") }) if ui.done != nil { ui.done <- struct{}{} } }() return nil } // ReadFromStorage reads analysis data from persistent key-value storage func (ui *UI) ReadFromStorage(storagePath, path string) error { storage := analyze.NewStorage(storagePath, path) closeFn := storage.Open() defer closeFn() dir, err := storage.GetDirForPath(path) if err != nil { return err } ui.currentDir = dir ui.topDirPath = ui.currentDir.GetPath() ui.topDir = ui.currentDir ui.showDir() return nil } func (ui *UI) delete(shouldEmpty bool) { if len(ui.markedRows) > 0 { ui.deleteMarked(shouldEmpty) } else { ui.deleteSelected(shouldEmpty) } } func (ui *UI) deleteSelected(shouldEmpty bool) { row, column := ui.table.GetSelection() selectedItem := ui.table.GetCell(row, column).GetReference().(fs.Item) if ui.deleteInBackground { ui.queueForDeletion([]fs.Item{selectedItem}, shouldEmpty) return } var action, acting string if shouldEmpty { action = actionEmpty acting = actingEmpty } else { action = actionDelete acting = actingDelete } modal := tview.NewModal().SetText( cases.Title(language.English).String(acting) + " " + tview.Escape(selectedItem.GetName()) + "...", ) ui.pages.AddPage(acting, modal, true, true) var currentDir fs.Item var deleteItems []fs.Item if shouldEmpty && selectedItem.IsDir() { currentDir = selectedItem for file := range currentDir.GetFiles(fs.SortBySize, fs.SortDesc) { deleteItems = append(deleteItems, file) } } else { currentDir = ui.currentDir deleteItems = append(deleteItems, selectedItem) } var deleteFun func(fs.Item, fs.Item) error if shouldEmpty && !selectedItem.IsDir() { deleteFun = ui.emptier } else { deleteFun = ui.remover } go func() { for _, item := range deleteItems { if err := deleteFun(currentDir, item); err != nil { msg := "Can't " + action + " " + tview.Escape(selectedItem.GetName()) ui.app.QueueUpdateDraw(func() { ui.pages.RemovePage(acting) ui.showErr(msg, err) }) if ui.done != nil { ui.done <- struct{}{} } return } } ui.app.QueueUpdateDraw(func() { ui.pages.RemovePage(acting) x, y := ui.table.GetOffset() ui.showDir() ui.table.Select(min(row, ui.table.GetRowCount()-1), 0) ui.table.SetOffset(min(x, ui.table.GetRowCount()-1), y) }) if ui.done != nil { ui.done <- struct{}{} } }() } func (ui *UI) showInfo() { if ui.currentDir == nil { return } var content, numberColor string row, column := ui.table.GetSelection() selectedFile := ui.table.GetCell(row, column).GetReference().(fs.Item) if ui.UseColors { numberColor = fmt.Sprintf( "[%s::b]", ui.resultRow.NumberColor, ) } else { numberColor = defaultColorBold } linesCount := 12 text := tview.NewTextView().SetDynamicColors(true) text.SetBorder(true).SetBorderPadding(2, 2, 2, 2) text.SetBorderColor(tcell.ColorDefault) text.SetTitle(" Item info ") content += "[::b]Name:[::-] " content += tview.Escape(selectedFile.GetName()) + "\n" content += "[::b]Path:[::-] " content += tview.Escape( strings.TrimPrefix(selectedFile.GetPath(), build.RootPathPrefix), ) + "\n" content += "[::b]Type:[::-] " + selectedFile.GetType() + "\n\n" content += " [::b]Disk usage:[::-] " content += numberColor + ui.formatSize(selectedFile.GetUsage(), false, true) content += fmt.Sprintf(" (%s%d[-::] B)", numberColor, selectedFile.GetUsage()) + "\n" content += "[::b]Apparent size:[::-] " content += numberColor + ui.formatSize(selectedFile.GetSize(), false, true) content += fmt.Sprintf(" (%s%d[-::] B)", numberColor, selectedFile.GetSize()) + "\n" if selectedFile.GetMultiLinkedInode() > 0 { linkedItems := ui.linkedItems[selectedFile.GetMultiLinkedInode()] linesCount += 2 + len(linkedItems) content += "\nHard-linked files:\n" for _, linkedItem := range linkedItems { content += "\t" + linkedItem.GetPath() + "\n" } } text.SetText(content) flex := tview.NewFlex(). AddItem(nil, 0, 1, false). AddItem(tview.NewFlex().SetDirection(tview.FlexRow). AddItem(nil, 0, 1, false). AddItem(text, linesCount, 1, false). AddItem(nil, 0, 1, false), 80, 1, false). AddItem(nil, 0, 1, false) ui.pages.AddPage("info", flex, true, true) } func (ui *UI) openItem() { row, column := ui.table.GetSelection() selectedFile, ok := ui.table.GetCell(row, column).GetReference().(fs.Item) if !ok || selectedFile == ui.currentDir.GetParent() { return } openBinary := "xdg-open" switch runtime.GOOS { case "darwin": openBinary = "open" case "windows": openBinary = "explorer" } cmd := exec.Command(openBinary, selectedFile.GetPath()) err := cmd.Start() if err != nil { ui.showErr("Error opening", err) } } func (ui *UI) confirmExport() *tview.Form { form := tview.NewForm(). AddInputField("File name", "export.json", 30, nil, func(v string) { ui.exportName = v }). AddButton("Export", ui.exportAnalysis). SetButtonsAlign(tview.AlignCenter) form.SetBorder(true). SetTitle(" Export data to JSON "). SetInputCapture(func(key *tcell.EventKey) *tcell.EventKey { if key.Key() == tcell.KeyEsc { ui.pages.RemovePage("export") ui.app.SetFocus(ui.table) return nil } return key }) flex := modal(form, 50, 7) ui.pages.AddPage("export", flex, true, true) ui.app.SetFocus(form) return form } func (ui *UI) exportAnalysis() { ui.pages.RemovePage("export") text := tview.NewTextView().SetText("Export in progress...").SetTextAlign(tview.AlignCenter) text.SetBorder(true).SetTitle(" Export data to JSON ") flex := modal(text, 50, 3) ui.pages.AddPage("exporting", flex, true, true) go func() { var err error defer ui.app.QueueUpdateDraw(func() { ui.pages.RemovePage("exporting") if err == nil { ui.app.SetFocus(ui.table) } }) if ui.done != nil { defer func() { ui.done <- struct{}{} }() } var buff bytes.Buffer buff.Write([]byte(`[1,2,{"progname":"gdu","progver":"`)) buff.Write([]byte(build.Version)) buff.Write([]byte(`","timestamp":`)) buff.Write([]byte(strconv.FormatInt(time.Now().Unix(), 10))) buff.Write([]byte("},\n")) file, err := os.Create(ui.exportName) if err != nil { ui.showErrFromGo("Error creating file", err) return } if err = ui.topDir.EncodeJSON(&buff, true); err != nil { ui.showErrFromGo("Error encoding JSON", err) return } if _, err = buff.Write([]byte("]\n")); err != nil { ui.showErrFromGo("Error writing to buffer", err) return } if _, err = buff.WriteTo(file); err != nil { ui.showErrFromGo("Error writing to file", err) return } }() } func (ui *UI) isInArchive() bool { if ui.currentDir == nil { return false } if _, ok := ui.currentDir.(*analyze.ZipDir); ok { return true } _, ok := ui.currentDir.(*analyze.TarDir) return ok } gdu-5.36.1/tui/actions_linux_test.go000066400000000000000000000010401517447455500174470ustar00rootroot00000000000000//go:build linux package tui import ( "bytes" "testing" "github.com/dundee/gdu/v5/internal/testapp" "github.com/dundee/gdu/v5/pkg/device" "github.com/stretchr/testify/assert" ) func TestShowDevicesWithError(t *testing.T) { app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) defer simScreen.Fini() getter := device.LinuxDevicesInfoGetter{MountsPath: "/xyzxyz"} ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) err := ui.ListDevices(getter) assert.Contains(t, err.Error(), "no such file") } gdu-5.36.1/tui/actions_test.go000066400000000000000000000347361517447455500162520ustar00rootroot00000000000000package tui import ( "bytes" "errors" "os" "slices" "testing" "github.com/dundee/gdu/v5/internal/testanalyze" "github.com/dundee/gdu/v5/internal/testapp" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/fs" "github.com/gdamore/tcell/v2" "github.com/stretchr/testify/assert" ) func TestShowDevices(t *testing.T) { app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) defer simScreen.Fini() ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) err := ui.ListDevices(getDevicesInfoMock()) assert.Nil(t, err) ui.table.Draw(simScreen) simScreen.Show() b, _, _ := simScreen.GetContents() text := []byte("Device name") for i, r := range b[0:11] { assert.Equal(t, text[i], r.Bytes[0]) } } func TestShowDevicesBW(t *testing.T) { app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) defer simScreen.Fini() ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) err := ui.ListDevices(getDevicesInfoMock()) assert.Nil(t, err) ui.table.Draw(simScreen) simScreen.Show() b, _, _ := simScreen.GetContents() text := []byte("Device name") for i, r := range b[0:11] { assert.Equal(t, text[i], r.Bytes[0]) } } func TestDeviceSelected(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) ui.UseOldSizeBar() ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.ListDevices(getDevicesInfoMock()) assert.Nil(t, err) assert.Equal(t, 3, ui.table.GetRowCount()) ui.deviceItemSelected(1, 0) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.Equal(t, 4, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") } func TestNilDeviceSelected(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) ui.UseOldSizeBar() ui.SetIgnoreDirPaths([]string{"/xxx"}) ui.deviceItemSelected(1, 0) assert.Equal(t, 0, ui.table.GetRowCount()) } func TestAnalyzePath(t *testing.T) { ui := getAnalyzedPathMockedApp(t, true, true, true) assert.Equal(t, 4, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") } func TestAnalyzePathBW(t *testing.T) { ui := getAnalyzedPathMockedApp(t, false, true, true) assert.Equal(t, 4, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") } func TestAnalyzePathWithParentDir(t *testing.T) { parentDir := &analyze.Dir{ File: &analyze.File{ Name: "parent", }, Files: make([]fs.Item, 0, 1), } simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, true, true) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.topDir = parentDir ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", parentDir) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.Equal(t, parentDir, ui.currentDir.GetParent()) assert.Equal(t, 5, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "/..") assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") } func TestReadAnalysis(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0o644) assert.Nil(t, err) app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false) ui.done = make(chan struct{}) err = ui.ReadAnalysis(input) assert.Nil(t, err) <-ui.done // wait for reading for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "gdu", ui.currentDir.GetName()) } func TestReadAnalysisWithWrongFile(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() input, err := os.OpenFile("../internal/testdata/wrong.json", os.O_RDONLY, 0o644) assert.Nil(t, err) app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.done = make(chan struct{}) err = ui.ReadAnalysis(input) assert.Nil(t, err) <-ui.done // wait for reading for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.True(t, ui.pages.HasPage("error")) } func TestViewDirContents(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) res := ui.showFile() // selected item is dir, do nothing assert.Nil(t, res) } func TestViewFileWithoutCurrentDir(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) res := ui.showFile() // no current directory assert.Nil(t, res) } func TestViewContentsOfNotExistingFile(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.table.Select(3, 0) selectedFile := ui.table.GetCell(3, 0).GetReference().(fs.Item) assert.Equal(t, "ddd", selectedFile.GetName()) res := ui.showFile() assert.Nil(t, res) } func TestViewFile(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.table.Select(2, 0) file := ui.showFile() assert.True(t, ui.pages.HasPage("file")) event := file.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, 'j', 0)) assert.Equal(t, 'j', event.Rune()) } func TestChangeCwd(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() cwd := "" opt := func(ui *UI) { ui.SetChangeCwdFn(func(p string) error { cwd = p return nil }) } app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, opt) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.table.Select(1, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) assert.Equal(t, cwd, "test_dir/nested/subnested") } func TestChangeCwdWithErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() cwd := "" opt := func(ui *UI) { ui.SetChangeCwdFn(func(p string) error { cwd = p return errors.New("failed") }) } app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, opt) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.table.Select(1, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) assert.Equal(t, cwd, "test_dir/nested/subnested") } func TestShowInfo(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) assert.True(t, ui.pages.HasPage("info")) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) assert.False(t, ui.pages.HasPage("info")) } func TestShowInfoBW(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, false, false, false) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) assert.True(t, ui.pages.HasPage("info")) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) assert.False(t, ui.pages.HasPage("info")) } func TestShowInfoWithHardlinks(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } files := slices.Collect(ui.currentDir.GetFiles(fs.SortByName, fs.SortAsc)) nested := files[0].(*analyze.Dir) subnested := nested.Files[1].(*analyze.Dir) file := subnested.Files[0].(*analyze.File) file2 := nested.Files[0].(*analyze.File) file.Mli = 1 file2.Mli = 1 ui.currentDir.UpdateStats(ui.linkedItems) assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.table.Select(1, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.table.Select(1, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) assert.True(t, ui.pages.HasPage("info")) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) assert.False(t, ui.pages.HasPage("info")) } func TestShowInfoWithoutCurrentDir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) // pressing `i` will do nothing ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) assert.False(t, ui.pages.HasPage("info")) } func TestExitViewFile(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.table.Select(2, 0) file := ui.showFile() assert.True(t, ui.pages.HasPage("file")) file.GetInputCapture()(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) assert.False(t, ui.pages.HasPage("file")) } func TestAnalyzePathCreatesProgressBar(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) ui.currentDeviceSize = 1e6 ui.showDiskProgressBar = true err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) assert.NotNil(t, ui.progressBar) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } } func TestAnalyzePathNoProgressBarWhenDisabled(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) ui.currentDeviceSize = 1e6 ui.showDiskProgressBar = false err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) assert.Nil(t, ui.progressBar) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } } func TestAnalyzePathNoProgressBarWhenNoDeviceSize(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) ui.currentDeviceSize = 0 ui.showDiskProgressBar = true err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) assert.Nil(t, ui.progressBar) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } } gdu-5.36.1/tui/background.go000066400000000000000000000041151517447455500156560ustar00rootroot00000000000000package tui import ( "github.com/dundee/gdu/v5/pkg/fs" "github.com/rivo/tview" ) func (ui *UI) queueForDeletion(items []fs.Item, shouldEmpty bool) { go func() { for _, item := range items { ui.deleteQueue <- deleteQueueItem{item: item, shouldEmpty: shouldEmpty} } }() ui.markedRows = make(map[int]struct{}) } func (ui *UI) deleteWorker() { defer func() { if r := recover(); r != nil { ui.app.Stop() panic(r) } }() for item := range ui.deleteQueue { ui.deleteItem(item.item, item.shouldEmpty) } } func (ui *UI) deleteItem(item fs.Item, shouldEmpty bool) { ui.increaseActiveWorkers() defer ui.decreaseActiveWorkers() var action, acting string if shouldEmpty { action = actionEmpty } else { action = actionDelete } var deleteFun func(fs.Item, fs.Item) error if shouldEmpty && !item.IsDir() { deleteFun = ui.emptier } else { deleteFun = ui.remover } var parentDir fs.Item var deleteItems []fs.Item if shouldEmpty && item.IsDir() { parentDir = item for file := range item.GetFilesLocked(fs.SortBySize, fs.SortDesc) { deleteItems = append(deleteItems, file) } } else { parentDir = ui.currentDir deleteItems = append(deleteItems, item) } for _, toDelete := range deleteItems { if err := deleteFun(parentDir, toDelete); err != nil { msg := "Can't " + action + " " + tview.Escape(toDelete.GetName()) ui.app.QueueUpdateDraw(func() { ui.pages.RemovePage(acting) ui.showErr(msg, err) }) if ui.done != nil { ui.done <- struct{}{} } return } } if item.GetParent().GetPath() == ui.currentDir.GetPath() { ui.app.QueueUpdateDraw(func() { row, _ := ui.table.GetSelection() x, y := ui.table.GetOffset() ui.showDir() ui.table.Select(min(row, ui.table.GetRowCount()-1), 0) ui.table.SetOffset(min(x, ui.table.GetRowCount()-1), y) }) } if ui.done != nil { ui.done <- struct{}{} } } func (ui *UI) increaseActiveWorkers() { ui.workersMut.Lock() defer ui.workersMut.Unlock() ui.activeWorkers++ } func (ui *UI) decreaseActiveWorkers() { ui.workersMut.Lock() defer ui.workersMut.Unlock() ui.activeWorkers-- } gdu-5.36.1/tui/collapse_minimal_test.go000066400000000000000000000014361517447455500201110ustar00rootroot00000000000000package tui import ( "testing" "github.com/stretchr/testify/assert" ) func TestCollapsedPathStruct(t *testing.T) { // Test CollapsedPath struct creation and fields cp := &CollapsedPath{ DisplayName: "test/path", DeepestDir: nil, Segments: []string{"test", "path"}, } assert.Equal(t, "test/path", cp.DisplayName) assert.Nil(t, cp.DeepestDir) assert.Equal(t, []string{"test", "path"}, cp.Segments) } func TestFindCollapsedParentNilCases(t *testing.T) { // Test nil input result := findCollapsedParent(nil) assert.Nil(t, result) } // Test that our new functions exist and don't panic with basic inputs func TestFunctionExistence(t *testing.T) { // Test that findCollapsiblePath exists and handles nil gracefully result := findCollapsiblePath(nil) assert.Nil(t, result) } gdu-5.36.1/tui/collapse_test.go000066400000000000000000000152741517447455500164100ustar00rootroot00000000000000package tui import ( "bytes" "testing" "github.com/dundee/gdu/v5/internal/testapp" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func TestFindCollapsiblePath(t *testing.T) { // Test case 1: Non-directory item should return nil file := &analyze.File{ Name: "test.txt", } result := findCollapsiblePath(file) assert.Nil(t, result) // Test case 2: Directory with files and subdirectories should not collapse dirWithFiles := &analyze.Dir{ File: &analyze.File{ Name: "mixed", }, Files: []fs.Item{ &analyze.Dir{ File: &analyze.File{ Name: "subdir", }, Files: []fs.Item{}, }, &analyze.File{ Name: "file.txt", }, }, } result = findCollapsiblePath(dirWithFiles) assert.Nil(t, result) // Test case 3: Directory with multiple subdirectories should not collapse dirWithMultiSubs := &analyze.Dir{ File: &analyze.File{ Name: "multi", }, Files: []fs.Item{ &analyze.Dir{ File: &analyze.File{ Name: "subdir1", }, Files: []fs.Item{}, }, &analyze.Dir{ File: &analyze.File{ Name: "subdir2", }, Files: []fs.Item{}, }, }, } result = findCollapsiblePath(dirWithMultiSubs) assert.Nil(t, result) // Test case 4: Single subdirectory chain should collapse deepestDir := &analyze.Dir{ File: &analyze.File{ Name: "deep", }, Files: []fs.Item{ &analyze.File{ Name: "finalfile.txt", }, }, } middleDir := &analyze.Dir{ File: &analyze.File{ Name: "middle", }, Files: []fs.Item{deepestDir}, } rootDir := &analyze.Dir{ File: &analyze.File{ Name: "root", }, Files: []fs.Item{middleDir}, } result = findCollapsiblePath(rootDir) assert.NotNil(t, result) assert.Equal(t, "root/middle/deep", result.DisplayName) assert.Equal(t, deepestDir, result.DeepestDir) assert.Equal(t, []string{"middle", "deep"}, result.Segments) // Test case 5: Directory with no subdirectories should not collapse emptyDir := &analyze.Dir{ File: &analyze.File{ Name: "empty", }, Files: []fs.Item{}, } result = findCollapsiblePath(emptyDir) assert.Nil(t, result) } func TestFindCollapsedParent(t *testing.T) { // Test case 1: Nil current directory result := findCollapsedParent(nil) assert.Nil(t, result) // Test case 2: Directory without parent rootDir := &analyze.Dir{ File: &analyze.File{ Name: "root", }, Files: []fs.Item{}, } result = findCollapsedParent(rootDir) assert.Nil(t, result) // Test case 3: Directory in a collapsed chain otherDir := &analyze.Dir{ File: &analyze.File{ Name: "other", }, Files: []fs.Item{}, } grandParent := &analyze.Dir{ File: &analyze.File{ Name: "grandparent", }, Files: []fs.Item{otherDir}, } otherDir.SetParent(grandParent) parent := &analyze.Dir{ File: &analyze.File{ Name: "parent", }, Files: []fs.Item{}, } parent.SetParent(grandParent) grandParent.AddFile(parent) child := &analyze.Dir{ File: &analyze.File{ Name: "child", }, Files: []fs.Item{}, } child.SetParent(parent) parent.AddFile(child) result = findCollapsedParent(child) assert.Equal(t, grandParent, result) // Test case 4: Directory not in a collapsed chain normalParent := &analyze.Dir{ File: &analyze.File{ Name: "normalparent", }, Files: []fs.Item{ &analyze.File{ Name: "file.txt", }, }, } normalChild := &analyze.Dir{ File: &analyze.File{ Name: "normalchild", }, Files: []fs.Item{}, } normalChild.SetParent(normalParent) normalParent.AddFile(normalChild) result = findCollapsedParent(normalChild) assert.Equal(t, normalParent, result) } func TestFormatCollapsedRow(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, false, false, false) // Create a test collapsed path deepDir := &analyze.Dir{ File: &analyze.File{ Name: "deep", Size: 1000, Usage: 800, }, Files: []fs.Item{}, } collapsedPath := &CollapsedPath{ DisplayName: "level1/level2/deep", DeepestDir: deepDir, Segments: []string{"level1", "level2", "deep"}, } // Test normal formatting result := ui.formatCollapsedRow(collapsedPath, 1000, 1000, false, false) assert.Contains(t, result, "level1/level2/deep") assert.Contains(t, result, "/") // Should have directory indicator // Test with marked flag ui.markedRows = map[int]struct{}{0: {}} result = ui.formatCollapsedRow(collapsedPath, 1000, 1000, true, false) assert.Contains(t, result, "✓") // Should have marked indicator // Test with ignored flag result = ui.formatCollapsedRow(collapsedPath, 1000, 1000, false, true) assert.Contains(t, result, "level1/level2/deep") // Test with ShowApparentSize ui.ShowApparentSize = true result = ui.formatCollapsedRow(collapsedPath, 1000, 1000, false, false) assert.Contains(t, result, "level1/level2/deep") // Test with showItemCount ui.showItemCount = true result = ui.formatCollapsedRow(collapsedPath, 1000, 1000, false, false) assert.Contains(t, result, "level1/level2/deep") // Test with showMtime ui.showMtime = true result = ui.formatCollapsedRow(collapsedPath, 1000, 1000, false, false) assert.Contains(t, result, "level1/level2/deep") // Test without colors ui2 := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) result = ui2.formatCollapsedRow(collapsedPath, 1000, 1000, false, false) assert.Contains(t, result, "level1/level2/deep") } func TestCollapsedPathIntegration(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, false, false, false) // Create a directory structure that should be collapsed deepestDir := &analyze.Dir{ File: &analyze.File{ Name: "deepest", Size: 100, Usage: 80, }, Files: []fs.Item{ &analyze.File{ Name: "file.txt", Size: 50, Usage: 40, }, }, } middleDir := &analyze.Dir{ File: &analyze.File{ Name: "middle", Size: 100, Usage: 80, }, Files: []fs.Item{deepestDir}, } topDir := &analyze.Dir{ File: &analyze.File{ Name: "top", Size: 100, Usage: 80, }, Files: []fs.Item{middleDir}, } deepestDir.SetParent(middleDir) middleDir.SetParent(topDir) ui.currentDir = topDir ui.topDir = topDir ui.topDirPath = "/test" ui.currentDirPath = "/test" ui.SetCollapsePath(true) // Test that showDir properly handles collapsed paths ui.showDir() // Test navigation into collapsed path ui.table.Select(1, 0) // Select the collapsed entry cell := ui.table.GetCell(1, 0) assert.NotNil(t, cell) ref := cell.GetReference() assert.NotNil(t, ref) assert.Equal(t, deepestDir, ref) // Should reference the deepest directory } gdu-5.36.1/tui/exec.go000066400000000000000000000004501517447455500144610ustar00rootroot00000000000000package tui import ( "os" "os/exec" ) // Execute runs given bin path via exec.Command call func Execute(argv0 string, argv, envv []string) error { cmd := exec.Command(argv0, argv...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin cmd.Env = envv return cmd.Run() } gdu-5.36.1/tui/exec_other.go000066400000000000000000000014231517447455500156630ustar00rootroot00000000000000//go:build !windows package tui import ( "os" "os/signal" "syscall" ) func getShellBin() string { shellbin, ok := os.LookupEnv("SHELL") if !ok { shellbin = "/bin/bash" } return shellbin } func (ui *UI) spawnShell() { if ui.currentDir == nil { return } ui.app.Suspend(func() { if err := os.Chdir(ui.currentDirPath); err != nil { ui.showErr("Error changing directory", err) return } if err := ui.exec(getShellBin(), nil, os.Environ()); err != nil { ui.showErr("Error executing shell", err) } }) } func stopProcess() error { // chan for signal sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGCONT) defer signal.Stop(sigChan) err := syscall.Kill(syscall.Getpid(), syscall.SIGTSTP) // wait continue <-sigChan return err } gdu-5.36.1/tui/exec_test.go000066400000000000000000000002631517447455500155220ustar00rootroot00000000000000package tui import ( "testing" "github.com/stretchr/testify/assert" ) func TestExecute(t *testing.T) { err := Execute("true", []string{}, []string{}) assert.Nil(t, err) } gdu-5.36.1/tui/exec_windows.go000066400000000000000000000010241517447455500162310ustar00rootroot00000000000000package tui import ( "os" ) func getShellBin() string { shellbin, ok := os.LookupEnv("COMSPEC") if !ok { shellbin = "C:\\WINDOWS\\System32\\cmd.exe" } return shellbin } func (ui *UI) spawnShell() { if ui.currentDir == nil { return } ui.app.Stop() if err := os.Chdir(ui.currentDirPath); err != nil { ui.showErr("Error changing directory", err) return } if err := ui.exec(getShellBin(), nil, os.Environ()); err != nil { ui.showErr("Error executing shell", err) } } func stopProcess() error { return nil } gdu-5.36.1/tui/export_test.go000066400000000000000000000110751517447455500161220ustar00rootroot00000000000000package tui import ( "bytes" "os" "testing" "github.com/dundee/gdu/v5/internal/testanalyze" "github.com/dundee/gdu/v5/internal/testapp" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/fs" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/stretchr/testify/assert" ) func TestConfirmExport(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'E', 0)) assert.True(t, ui.pages.HasPage("export")) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'n', 0)) ui.keyPressed(tcell.NewEventKey(tcell.KeyEnter, 0, 0)) assert.True(t, ui.pages.HasPage("export")) } func TestExportAnalysis(t *testing.T) { parentDir := &analyze.Dir{ File: &analyze.File{ Name: "parent", }, Files: make([]fs.Item, 0, 1), } currentDir := &analyze.Dir{ File: &analyze.File{ Name: "sub", Parent: parentDir, }, } simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.currentDir = currentDir ui.topDir = parentDir ui.exportAnalysis() assert.True(t, ui.pages.HasPage("exporting")) <-ui.done assert.FileExists(t, "export.json") err := os.Remove("export.json") assert.NoError(t, err) for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } } func TestExportAnalysisEsc(t *testing.T) { parentDir := &analyze.Dir{ File: &analyze.File{ Name: "parent", }, Files: make([]fs.Item, 0, 1), } currentDir := &analyze.Dir{ File: &analyze.File{ Name: "sub", Parent: parentDir, }, } simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.currentDir = currentDir ui.topDir = parentDir form := ui.confirmExport() formInputFn := form.GetInputCapture() assert.True(t, ui.pages.HasPage("export")) formInputFn(tcell.NewEventKey(tcell.KeyEsc, 0, 0)) assert.False(t, ui.pages.HasPage("export")) } func TestExportAnalysisWithName(t *testing.T) { parentDir := &analyze.Dir{ File: &analyze.File{ Name: "parent", }, Files: make([]fs.Item, 0, 1), } currentDir := &analyze.Dir{ File: &analyze.File{ Name: "sub", Parent: parentDir, }, } simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.currentDir = currentDir ui.topDir = parentDir form := ui.confirmExport() // formInputFn := form.GetInputCapture() item := form.GetFormItemByLabel("File name") inputFn := item.(*tview.InputField).InputHandler() // send 'n' to input inputFn(tcell.NewEventKey(tcell.KeyRune, 'n', 0), nil) assert.Equal(t, "export.jsonn", ui.exportName) assert.True(t, ui.pages.HasPage("export")) form.GetButton(0).InputHandler()(tcell.NewEventKey(tcell.KeyEnter, 0, 0), nil) assert.True(t, ui.pages.HasPage("exporting")) <-ui.done assert.FileExists(t, "export.jsonn") err := os.Remove("export.jsonn") assert.NoError(t, err) for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } } func TestExportAnalysisWithoutRights(t *testing.T) { parentDir := &analyze.Dir{ File: &analyze.File{ Name: "parent", }, Files: make([]fs.Item, 0, 1), } currentDir := &analyze.Dir{ File: &analyze.File{ Name: "sub", Parent: parentDir, }, } _, err := os.Create("export.json") assert.NoError(t, err) err = os.Chmod("export.json", 0) assert.NoError(t, err) defer func() { err = os.Chmod("export.json", 0o755) assert.Nil(t, err) err = os.Remove("export.json") assert.NoError(t, err) }() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.currentDir = currentDir ui.topDir = parentDir ui.exportAnalysis() <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.True(t, ui.pages.HasPage("error")) } gdu-5.36.1/tui/filter.go000066400000000000000000000057611517447455500150340ustar00rootroot00000000000000package tui import ( "path/filepath" "strings" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) func (ui *UI) rebuildFooter() { ui.footer.Clear() if ui.filteringInput != nil { ui.footer.AddItem(ui.filteringInput, 0, 1, ui.filtering) } if ui.typeFilteringInput != nil { ui.footer.AddItem(ui.typeFilteringInput, 0, 1, ui.typeFiltering) } ui.footer.AddItem(ui.footerLabel, 0, 5, false) } func (ui *UI) hideFilterInput() { ui.filterValue = "" ui.filteringInput = nil ui.filtering = false ui.rebuildFooter() ui.app.SetFocus(ui.table) } func (ui *UI) showFilterInput() { if ui.currentDir == nil { return } if ui.filteringInput == nil { ui.markedRows = make(map[int]struct{}) ui.filteringInput = tview.NewInputField() ui.filteringInput.SetLabel("Name: ") if !ui.UseColors { ui.filteringInput.SetFieldBackgroundColor( tcell.NewRGBColor(100, 100, 100), ) ui.filteringInput.SetFieldTextColor( tcell.NewRGBColor(255, 255, 255), ) } ui.filteringInput.SetChangedFunc(func(text string) { ui.filterValue = text ui.showDir() }) ui.filteringInput.SetDoneFunc(func(key tcell.Key) { if key == tcell.KeyESC { ui.hideFilterInput() ui.showDir() } else { ui.app.SetFocus(ui.table) ui.filtering = false } }) ui.rebuildFooter() } ui.app.SetFocus(ui.filteringInput) ui.filtering = true } func (ui *UI) hideTypeFilterInput() { ui.typeFilterValue = "" ui.typeFilteringInput = nil ui.typeFiltering = false ui.rebuildFooter() ui.app.SetFocus(ui.table) } func (ui *UI) showTypeFilterInput() { if ui.currentDir == nil { return } if ui.typeFilteringInput == nil { ui.markedRows = make(map[int]struct{}) ui.typeFilteringInput = tview.NewInputField() ui.typeFilteringInput.SetLabel("Type: ") if !ui.UseColors { ui.typeFilteringInput.SetFieldBackgroundColor( tcell.NewRGBColor(100, 100, 100), ) ui.typeFilteringInput.SetFieldTextColor( tcell.NewRGBColor(255, 255, 255), ) } ui.typeFilteringInput.SetChangedFunc(func(text string) { ui.typeFilterValue = text ui.showDir() }) ui.typeFilteringInput.SetDoneFunc(func(key tcell.Key) { if key == tcell.KeyESC { ui.hideTypeFilterInput() ui.showDir() } else { ui.app.SetFocus(ui.table) ui.typeFiltering = false } }) ui.rebuildFooter() } ui.app.SetFocus(ui.typeFilteringInput) ui.typeFiltering = true } // matchesTypeFilter returns true if the file name matches the type filter. // Directories always match. Files are matched by extension against the // comma-separated list in typeFilterValue. func (ui *UI) matchesTypeFilter(name string, isDir bool) bool { if ui.typeFilterValue == "" { return true } if isDir { return true } ext := strings.ToLower(filepath.Ext(name)) if ext == "" { return false } ext = strings.TrimPrefix(ext, ".") for _, t := range strings.Split(ui.typeFilterValue, ",") { t = strings.TrimSpace(strings.TrimPrefix(strings.ToLower(t), ".")) if t != "" && t == ext { return true } } return false } gdu-5.36.1/tui/filter_test.go000066400000000000000000000317751517447455500160770ustar00rootroot00000000000000package tui import ( "bytes" "strings" "testing" "time" "github.com/dundee/gdu/v5/internal/testanalyze" "github.com/dundee/gdu/v5/internal/testapp" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/fs" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/stretchr/testify/assert" ) func TestFiltering(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } // mark the item for deletion ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) assert.Equal(t, 1, len(ui.markedRows)) ui.showFilterInput() ui.filterValue = "" ui.showDir() assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") // nothing is filtered // marking should be dropped after sorting assert.Equal(t, 0, len(ui.markedRows)) ui.filterValue = "aa" ui.showDir() assert.Contains(t, ui.table.GetCell(0, 0).Text, "aaa") // shows only cccc ui.hideFilterInput() ui.showDir() assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") // filtering reset } func TestFilteringWithoutCurrentDir(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) ui.showFilterInput() assert.False(t, ui.filtering) } func TestSwitchToTable(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '/', 0)) // open filtering input handler := ui.filteringInput.InputHandler() handler(tcell.NewEventKey(tcell.KeyRune, 'n', 0), func(p tview.Primitive) {}) handler(tcell.NewEventKey(tcell.KeyRune, 'e', 0), func(p tview.Primitive) {}) handler(tcell.NewEventKey(tcell.KeyRune, 's', 0), func(p tview.Primitive) {}) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // we are filtering, should do nothing assert.Contains(t, ui.table.GetCell(0, 0).Text, "nested") handler( tcell.NewEventKey(tcell.KeyTAB, ' ', 0), func(p tview.Primitive) {}, ) // switch focus to table ui.keyPressed(tcell.NewEventKey(tcell.KeyTAB, ' ', 0)) // switch back to input handler( tcell.NewEventKey(tcell.KeyEnter, ' ', 0), func(p tview.Primitive) {}, ) // switch back to table ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // open nested dir assert.Contains(t, ui.table.GetCell(1, 0).Text, "subnested") assert.Empty(t, ui.filterValue) // filtering reset } func TestExitFiltering(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '/', 0)) // open filtering input handler := ui.filteringInput.InputHandler() ui.filterValue = "xxx" ui.showDir() assert.Equal(t, ui.table.GetCell(0, 0).Text, "") // nothing is filtered handler( tcell.NewEventKey(tcell.KeyEsc, ' ', 0), func(p tview.Primitive) {}, ) // exit filtering assert.Contains(t, ui.table.GetCell(0, 0).Text, "nested") assert.Empty(t, ui.filterValue) // filtering reset } func createDirWithExtensions() *analyze.Dir { dir := &analyze.Dir{ File: &analyze.File{ Name: "test_dir", Usage: 1e9, Size: 1e9, Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), }, BasePath: ".", ItemCount: 6, } subdir := &analyze.Dir{ File: &analyze.File{ Name: "subdir", Usage: 1e6, Size: 1e6, Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), Parent: dir, }, } goFile := &analyze.File{ Name: "main.go", Usage: 1e6, Size: 1e6, Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), Parent: dir, } yamlFile := &analyze.File{ Name: "config.yaml", Usage: 1e3, Size: 1e3, Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), Parent: dir, } jsonFile := &analyze.File{ Name: "data.json", Usage: 1e4, Size: 1e4, Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), Parent: dir, } noExtFile := &analyze.File{ Name: "Makefile", Usage: 500, Size: 500, Mtime: time.Date(2021, 8, 27, 22, 23, 24, 0, time.UTC), Parent: dir, } dir.Files = fs.Files{subdir, goFile, yamlFile, jsonFile, noExtFile} return dir } func TestTypeFiltering(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } dir := createDirWithExtensions() ui.currentDir = dir ui.topDir = dir ui.topDirPath = dir.GetPath() ui.showDir() rowCount := ui.table.GetRowCount() assert.Equal(t, 5, rowCount) // subdir + main.go + config.yaml + data.json + Makefile // activate type filter for "go" files ui.showTypeFilterInput() assert.True(t, ui.typeFiltering) ui.typeFilterValue = "go" ui.showDir() // should show: subdir (dirs always shown) + main.go assert.True(t, tableContains(ui, "subdir")) assert.True(t, tableContains(ui, "main.go")) assert.False(t, tableContains(ui, "config.yaml")) assert.False(t, tableContains(ui, "data.json")) assert.False(t, tableContains(ui, "Makefile")) ui.typeFilterValue = "go,yaml" ui.showDir() assert.True(t, tableContains(ui, "subdir")) assert.True(t, tableContains(ui, "main.go")) assert.True(t, tableContains(ui, "config.yaml")) assert.False(t, tableContains(ui, "data.json")) // hide type filter resets it ui.hideTypeFilterInput() ui.showDir() assert.True(t, tableContains(ui, "main.go")) assert.True(t, tableContains(ui, "config.yaml")) assert.True(t, tableContains(ui, "data.json")) assert.True(t, tableContains(ui, "Makefile")) assert.Empty(t, ui.typeFilterValue) } func TestTypeFilteringWithoutCurrentDir(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.showTypeFilterInput() assert.False(t, ui.typeFiltering) } func TestTypeFilteringKeyBinding(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'T', 0)) assert.True(t, ui.typeFiltering) assert.NotNil(t, ui.typeFilteringInput) } func TestExitTypeFiltering(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'T', 0)) handler := ui.typeFilteringInput.InputHandler() ui.typeFilterValue = "go" ui.showDir() handler( tcell.NewEventKey(tcell.KeyEsc, ' ', 0), func(p tview.Primitive) {}, ) assert.Empty(t, ui.typeFilterValue) assert.Nil(t, ui.typeFilteringInput) assert.False(t, ui.typeFiltering) } func TestTypeFilterTabSwitch(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } // open type filter, confirm with Enter, then TAB should switch back ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'T', 0)) assert.True(t, ui.typeFiltering) handler := ui.typeFilteringInput.InputHandler() handler( tcell.NewEventKey(tcell.KeyEnter, ' ', 0), func(p tview.Primitive) {}, ) assert.False(t, ui.typeFiltering) // focus returned to table ui.keyPressed(tcell.NewEventKey(tcell.KeyTAB, ' ', 0)) assert.True(t, ui.typeFiltering) // TAB should switch back to type filter } func TestBothFiltersCoexist(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } dir := createDirWithExtensions() ui.currentDir = dir ui.topDir = dir ui.topDirPath = dir.GetPath() // activate both filters ui.showFilterInput() ui.filterValue = "main" ui.showTypeFilterInput() ui.typeFilterValue = "go" ui.showDir() assert.True(t, tableContains(ui, "main.go")) // matches both name "main" and type "go" assert.False(t, tableContains(ui, "subdir")) // dir name doesn't contain "main" assert.False(t, tableContains(ui, "data.json")) // doesn't match name or type } func TestMatchesTypeFilter(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.typeFilterValue = "go" assert.True(t, ui.matchesTypeFilter("main.go", false)) assert.False(t, ui.matchesTypeFilter("config.yaml", false)) assert.True(t, ui.matchesTypeFilter("subdir", true)) // dirs always match assert.False(t, ui.matchesTypeFilter("Makefile", false)) // no extension ui.typeFilterValue = "go,yaml" assert.True(t, ui.matchesTypeFilter("main.go", false)) assert.True(t, ui.matchesTypeFilter("config.yaml", false)) assert.False(t, ui.matchesTypeFilter("data.json", false)) ui.typeFilterValue = ".go" // with leading dot assert.True(t, ui.matchesTypeFilter("main.go", false)) ui.typeFilterValue = "GO" // case insensitive assert.True(t, ui.matchesTypeFilter("main.go", false)) ui.typeFilterValue = "" // empty filter matches all assert.True(t, ui.matchesTypeFilter("anything", false)) } func TestTypeFilterInputNoColorAndChangedCallback(t *testing.T) { app, simScreen := testapp.CreateTestAppWithSimScreen(80, 30) defer simScreen.Fini() ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) dir := createDirWithExtensions() ui.currentDir = dir ui.topDir = dir ui.topDirPath = dir.GetPath() ui.showDir() ui.showTypeFilterInput() assert.NotNil(t, ui.typeFilteringInput) handler := ui.typeFilteringInput.InputHandler() handler(tcell.NewEventKey(tcell.KeyRune, 'g', 0), func(p tview.Primitive) {}) handler(tcell.NewEventKey(tcell.KeyRune, 'o', 0), func(p tview.Primitive) {}) assert.Equal(t, "go", ui.typeFilterValue) assert.True(t, tableContains(ui, "main.go")) assert.False(t, tableContains(ui, "config.yaml")) } func TestTypeFilterShowAgainKeepsExistingInput(t *testing.T) { app, simScreen := testapp.CreateTestAppWithSimScreen(80, 30) defer simScreen.Fini() ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) dir := createDirWithExtensions() ui.currentDir = dir ui.topDir = dir ui.topDirPath = dir.GetPath() ui.showDir() ui.showTypeFilterInput() original := ui.typeFilteringInput ui.showTypeFilterInput() assert.Equal(t, original, ui.typeFilteringInput) assert.True(t, ui.typeFiltering) } func collectTableTexts(ui *UI) []string { var texts []string for i := 0; i < ui.table.GetRowCount(); i++ { cell := ui.table.GetCell(i, 0) if cell != nil { texts = append(texts, cell.Text) } } return texts } func tableContains(ui *UI, name string) bool { for _, text := range collectTableTexts(ui) { if strings.Contains(text, name) { return true } } return false } gdu-5.36.1/tui/format.go000066400000000000000000000143441517447455500150340ustar00rootroot00000000000000package tui import ( "fmt" "math" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/fs" "github.com/rivo/tview" ) const ( blackOnWhite = "[black:white:-]" whiteOnBlack = "[white:black:-]" defaultColor = "[-::]" defaultColorBold = "[::b]" ) func (ui *UI) formatFileRow(item fs.Item, maxUsage, maxSize int64, marked, ignored bool) string { part := 0 if !ignored { if ui.ShowApparentSize { if size := item.GetSize(); size > 0 { part = int(float64(size) / float64(maxSize) * 100.0) } } else { if usage := item.GetUsage(); usage > 0 { part = int(float64(usage) / float64(maxUsage) * 100.0) } } } row := string(item.GetFlag()) numberColor := fmt.Sprintf( "[%s::b]", ui.resultRow.NumberColor, ) if ui.UseColors && !marked && !ignored { row += numberColor } else { row += defaultColorBold } if ui.ShowApparentSize { row += fmt.Sprintf("%15s", ui.formatSize(item.GetSize(), false, true)) } else { row += fmt.Sprintf("%15s", ui.formatSize(item.GetUsage(), false, true)) } if ui.useOldSizeBar { row += " " + getUsageGraphOld(part) + " " } else { row += getUsageGraph(part) } if ui.showItemCount { if ui.UseColors && !marked && !ignored { row += numberColor } else { row += defaultColorBold } countToDisplay := item.GetItemCount() if item.IsDir() { countToDisplay-- } row += fmt.Sprintf("%11s ", ui.formatCount(countToDisplay)) } if ui.showMtime { if ui.UseColors && !marked && !ignored { row += numberColor } else { row += defaultColorBold } row += fmt.Sprintf( "%s "+defaultColor, item.GetMtime().Format("2006-01-02 15:04:05"), ) } if len(ui.markedRows) > 0 { if marked { row += string('✓') } else { row += " " } row += " " } if item.IsDir() { if ui.UseColors && !marked && !ignored { row += fmt.Sprintf("[%s::b]/", ui.resultRow.DirectoryColor) } else { row += defaultColorBold + "/" } } row += tview.Escape(item.GetName()) return row } // formatCollapsedRow formats a collapsed directory path for display func (ui *UI) formatCollapsedRow(collapsedPath *CollapsedPath, maxUsage, maxSize int64, marked, ignored bool) string { // Use the deepest directory's stats for display item := collapsedPath.DeepestDir part := 0 if !ignored { if ui.ShowApparentSize { if size := item.GetSize(); size > 0 { part = int(float64(size) / float64(maxSize) * 100.0) } } else { if usage := item.GetUsage(); usage > 0 { part = int(float64(usage) / float64(maxUsage) * 100.0) } } } row := string(item.GetFlag()) numberColor := fmt.Sprintf( "[%s::b]", ui.resultRow.NumberColor, ) if ui.UseColors && !marked && !ignored { row += numberColor } else { row += defaultColorBold } if ui.ShowApparentSize { row += fmt.Sprintf("%15s", ui.formatSize(item.GetSize(), false, true)) } else { row += fmt.Sprintf("%15s", ui.formatSize(item.GetUsage(), false, true)) } if ui.useOldSizeBar { row += " " + getUsageGraphOld(part) + " " } else { row += getUsageGraph(part) } if ui.showItemCount { if ui.UseColors && !marked && !ignored { row += numberColor } else { row += defaultColorBold } countToDisplay := item.GetItemCount() if item.IsDir() { countToDisplay-- } row += fmt.Sprintf("%11s ", ui.formatCount(countToDisplay)) } if ui.showMtime { if ui.UseColors && !marked && !ignored { row += numberColor } else { row += defaultColorBold } row += fmt.Sprintf( "%s "+defaultColor, item.GetMtime().Format("2006-01-02 15:04:05"), ) } if len(ui.markedRows) > 0 { if marked { row += string('✓') } else { row += " " } row += " " } // Always display as directory with special formatting for collapsed path if ui.UseColors && !marked && !ignored { row += fmt.Sprintf("[%s::b]/", ui.resultRow.DirectoryColor) } else { row += defaultColorBold + "/" } // Display the collapsed path (e.g., "a/b/c") row += tview.Escape(collapsedPath.DisplayName) return row } func (ui *UI) formatSize(size int64, reverseColor, transparentBg bool) string { var color string if reverseColor { if ui.UseColors { color = fmt.Sprintf( "[%s:%s:-]", ui.footerTextColor, ui.footerBackgroundColor, ) } else { color = blackOnWhite } } else { if transparentBg { color = defaultColor } else { color = whiteOnBlack } } if ui.UseSIPrefix { return formatWithDecPrefix(size, color) } return formatWithBinPrefix(float64(size), color) } func (ui *UI) formatCount(count int64) string { row := "" color := defaultColor count64 := float64(count) switch { case count64 >= common.G: row += fmt.Sprintf("%.1f%sG", float64(count)/float64(common.G), color) case count64 >= common.M: row += fmt.Sprintf("%.1f%sM", float64(count)/float64(common.M), color) case count64 >= common.K: row += fmt.Sprintf("%.1f%sk", float64(count)/float64(common.K), color) default: row += fmt.Sprintf("%d%s", count, color) } return row } func formatWithBinPrefix(fsize float64, color string) string { asize := math.Abs(fsize) switch { case asize >= common.Ei: return fmt.Sprintf("%.1f%s EiB", fsize/common.Ei, color) case asize >= common.Pi: return fmt.Sprintf("%.1f%s PiB", fsize/common.Pi, color) case asize >= common.Ti: return fmt.Sprintf("%.1f%s TiB", fsize/common.Ti, color) case asize >= common.Gi: return fmt.Sprintf("%.1f%s GiB", fsize/common.Gi, color) case asize >= common.Mi: return fmt.Sprintf("%.1f%s MiB", fsize/common.Mi, color) case asize >= common.Ki: return fmt.Sprintf("%.1f%s KiB", fsize/common.Ki, color) default: return fmt.Sprintf("%d%s B", int64(fsize), color) } } func formatWithDecPrefix(size int64, color string) string { fsize := float64(size) asize := math.Abs(fsize) switch { case asize >= common.E: return fmt.Sprintf("%.1f%s EB", fsize/common.E, color) case asize >= common.P: return fmt.Sprintf("%.1f%s PB", fsize/common.P, color) case asize >= common.T: return fmt.Sprintf("%.1f%s TB", fsize/common.T, color) case asize >= common.G: return fmt.Sprintf("%.1f%s GB", fsize/common.G, color) case asize >= common.M: return fmt.Sprintf("%.1f%s MB", fsize/common.M, color) case asize >= common.K: return fmt.Sprintf("%.1f%s kB", fsize/common.K, color) default: return fmt.Sprintf("%d%s B", size, color) } } gdu-5.36.1/tui/format_test.go000066400000000000000000000116661517447455500160770ustar00rootroot00000000000000package tui import ( "bytes" "testing" "github.com/dundee/gdu/v5/internal/testapp" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/stretchr/testify/assert" ) func TestFormatSize(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) assert.Equal(t, "1[white:black:-] B", ui.formatSize(1, false, false)) assert.Equal(t, "1.0[white:black:-] KiB", ui.formatSize(1<<10, false, false)) assert.Equal(t, "1.0[white:black:-] MiB", ui.formatSize(1<<20, false, false)) assert.Equal(t, "1.0[white:black:-] GiB", ui.formatSize(1<<30, false, false)) assert.Equal(t, "1.0[white:black:-] TiB", ui.formatSize(1<<40, false, false)) assert.Equal(t, "1.0[white:black:-] PiB", ui.formatSize(1<<50, false, false)) assert.Equal(t, "1.0[white:black:-] EiB", ui.formatSize(1<<60, false, false)) assert.Equal(t, "-1.0[white:black:-] KiB", ui.formatSize(-1<<10, false, false)) } func TestFormatSizeDec(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, true) assert.Equal(t, "1[white:black:-] B", ui.formatSize(1, false, false)) assert.Equal(t, "1.0[white:black:-] kB", ui.formatSize(1<<10, false, false)) assert.Equal(t, "1.0[white:black:-] MB", ui.formatSize(1<<20, false, false)) assert.Equal(t, "1.1[white:black:-] GB", ui.formatSize(1<<30, false, false)) assert.Equal(t, "1.1[white:black:-] TB", ui.formatSize(1<<40, false, false)) assert.Equal(t, "1.1[white:black:-] PB", ui.formatSize(1<<50, false, false)) assert.Equal(t, "1.2[white:black:-] EB", ui.formatSize(1<<60, false, false)) assert.Equal(t, "-1.0[white:black:-] kB", ui.formatSize(-1<<10, false, false)) } func TestFormatCount(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) assert.Equal(t, "1[-::]", ui.formatCount(1)) assert.Equal(t, "1.0[-::]k", ui.formatCount(1<<10)) assert.Equal(t, "1.0[-::]M", ui.formatCount(1<<20)) assert.Equal(t, "1.1[-::]G", ui.formatCount(1<<30)) } func TestEscapeName(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) dir := &analyze.Dir{ File: &analyze.File{ Usage: 10, }, } file := &analyze.File{ Name: "Aaa [red] bbb", Parent: dir, Usage: 10, } assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false, false), "Aaa [red[] bbb") } func TestMarked(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) ui.markedRows[0] = struct{}{} ui.useOldSizeBar = true dir := &analyze.Dir{ File: &analyze.File{ Usage: 10, }, } file := &analyze.File{ Name: "Aaa", Parent: dir, Usage: 10, } assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), true, false), "✓ Aaa") assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false, false), "[##########] Aaa") } func TestIgnored(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) ui.ignoredRows[0] = struct{}{} ui.useOldSizeBar = true dir := &analyze.Dir{ File: &analyze.File{ Usage: 10, }, } file := &analyze.File{ Name: "Aaa", Parent: dir, Usage: 10, } assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false, true), "[ ] Aaa") assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false, false), "[##########] Aaa") } func TestSizeBar(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) dir := &analyze.Dir{ File: &analyze.File{ Usage: 10, }, } file := &analyze.File{ Name: "Aaa", Parent: dir, Usage: 10, } assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false, false), "██████████▏Aaa") } func TestOldSizeBar(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) ui.markedRows[0] = struct{}{} ui.useOldSizeBar = true dir := &analyze.Dir{ File: &analyze.File{ Usage: 20, }, } file := &analyze.File{ Name: "Aaa", Parent: dir, Usage: 10, } assert.Contains(t, ui.formatFileRow(file, dir.GetUsage(), dir.GetSize(), false, false), "[##### ] Aaa") } gdu-5.36.1/tui/keys.go000066400000000000000000000221331517447455500145120ustar00rootroot00000000000000package tui import ( "fmt" "path/filepath" "time" "github.com/dundee/gdu/v5/pkg/fs" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) var analyzeParentPath = func(ui *UI, path string, parentDir fs.Item) error { return ui.AnalyzePath(path, parentDir) } func (ui *UI) keyPressed(key *tcell.EventKey) *tcell.EventKey { if ui.handleCtrlZ(key) == nil { return nil } if ui.pages.HasPage("file") || ui.pages.HasPage("export") { return key // send event to primitive } if ui.filtering || ui.typeFiltering { return key } key = ui.handleClosingModals(key) if key == nil { return nil } key = ui.handleInfoPageEvents(key) if key == nil { return nil } key = ui.handleQuit(key) if key == nil { return nil } if ui.pages.HasPage("confirm") { return ui.handleConfirmation(key) } if ui.pages.HasPage("progress") || ui.pages.HasPage("deleting") || ui.pages.HasPage("emptying") { return key } key = ui.handleHelp(key) if key == nil { return nil } if ui.pages.HasPage("help") { return key } key = ui.handleShell(key) if key == nil { return nil } key = ui.handleLeftRight(key) if key == nil { return nil } key = ui.handleFiltering(key) if key == nil { return nil } return ui.handleMainActions(key) } func (ui *UI) handleClosingModals(key *tcell.EventKey) *tcell.EventKey { if key.Key() == tcell.KeyEsc || key.Rune() == 'q' { if ui.pages.HasPage("help") { ui.pages.RemovePage("help") ui.app.SetFocus(ui.table) return nil } if ui.pages.HasPage("info") { ui.pages.RemovePage("info") ui.app.SetFocus(ui.table) return nil } } return key } func (ui *UI) handleConfirmation(key *tcell.EventKey) *tcell.EventKey { if key.Rune() == 'h' { return tcell.NewEventKey(tcell.KeyLeft, 0, 0) } if key.Rune() == 'l' { return tcell.NewEventKey(tcell.KeyRight, 0, 0) } return key } func (ui *UI) handleInfoPageEvents(key *tcell.EventKey) *tcell.EventKey { if ui.pages.HasPage("info") { switch key.Rune() { case 'i': ui.pages.RemovePage("info") ui.app.SetFocus(ui.table) return nil case '?': return nil } if key.Key() == tcell.KeyUp || key.Key() == tcell.KeyDown || key.Rune() == 'j' || key.Rune() == 'k' { row, column := ui.table.GetSelection() if (key.Key() == tcell.KeyUp || key.Rune() == 'k') && row > 0 { row-- } else if (key.Key() == tcell.KeyDown || key.Rune() == 'j') && row+1 < ui.table.GetRowCount() { row++ } ui.table.Select(row, column) } ui.showInfo() // refresh file info after any change } return key } // handle ctrl+z job control func (ui *UI) handleCtrlZ(key *tcell.EventKey) *tcell.EventKey { if key.Key() == tcell.KeyCtrlZ { ui.app.Suspend(func() { termApp := ui.app.(*tview.Application) termApp.Lock() defer termApp.Unlock() err := stopProcess() if err != nil { ui.showErr("Error sending STOP signal", err) } }) return nil } return key } func (ui *UI) handleQuit(key *tcell.EventKey) *tcell.EventKey { clearTerminalProgress() switch key.Rune() { case 'Q': ui.app.Stop() ui.printMarkedPaths() fmt.Fprintf(ui.output, "%s\n", ui.currentDirPath) return nil case 'q': ui.app.Stop() ui.printMarkedPaths() return nil } return key } func (ui *UI) handleHelp(key *tcell.EventKey) *tcell.EventKey { if key.Rune() == '?' { if ui.pages.HasPage("help") { ui.pages.RemovePage("help") ui.app.SetFocus(ui.table) return nil } ui.showHelp() return nil } return key } func (ui *UI) handleShell(key *tcell.EventKey) *tcell.EventKey { if key.Rune() == 'b' { if ui.isInArchive() { ui.showErr("Spawning shell is not supported in archives", nil) return nil } if ui.noSpawnShell { previousHeaderText := ui.header.GetText(false) // show feedback to user ui.header.SetText(" Shell spawning is disabled!") go func() { time.Sleep(2 * time.Second) ui.app.QueueUpdateDraw(func() { ui.header.Clear() ui.header.SetText(previousHeaderText) }) }() return nil } ui.spawnShell() return nil } return key } func (ui *UI) handleLeftRight(key *tcell.EventKey) *tcell.EventKey { if key.Rune() == 'h' || key.Key() == tcell.KeyLeft { ui.handleLeft() return nil } if key.Rune() == 'l' || key.Key() == tcell.KeyRight { ui.handleRight() return nil } return key } func (ui *UI) handleFiltering(key *tcell.EventKey) *tcell.EventKey { if key.Key() != tcell.KeyTab { return key } if ui.filteringInput != nil { ui.filtering = true ui.app.SetFocus(ui.filteringInput) return nil } if ui.typeFilteringInput != nil { ui.typeFiltering = true ui.app.SetFocus(ui.typeFilteringInput) return nil } return key } // nolint: funlen // Why: there's a lot of options to handle func (ui *UI) handleMainActions(key *tcell.EventKey) *tcell.EventKey { switch key.Rune() { case 'd': if ui.isInArchive() { ui.showErr("Deletion is not supported in archives", nil) return nil } ui.handleDelete(false) case 'e': if ui.isInArchive() { ui.showErr("Deletion is not supported in archives", nil) return nil } ui.handleDelete(true) case 'v': if ui.isInArchive() { ui.showErr("Viewing content is not supported in archives", nil) return nil } if ui.noViewFile { previousHeaderText := ui.header.GetText(false) ui.header.SetText(" Viewing files is disabled!") go func() { time.Sleep(2 * time.Second) ui.app.QueueUpdateDraw(func() { ui.header.Clear() ui.header.SetText(previousHeaderText) }) }() return nil } ui.showFile() case 'o': if ui.noSpawnShell { previousHeaderText := ui.header.GetText(false) // show feedback to user ui.header.SetText(" Opening items is disabled!") go func() { time.Sleep(2 * time.Second) ui.app.QueueUpdateDraw(func() { ui.header.Clear() ui.header.SetText(previousHeaderText) }) }() return nil } ui.openItem() case 'i': ui.showInfo() case 'a', 'B', 'c', 'm': ui.handleToggles(key) case 'r': if ui.currentDir != nil { ui.rescanDir() } case 'E': ui.confirmExport() return nil case 's', 'C', 'n', 'M': ui.handleSorting(key) case '/': ui.showFilterInput() return nil case 'T': ui.showTypeFilterInput() return nil case ' ': ui.handleMark() case 'p': ui.printMarked() return nil case 'I': ui.ignoreItem() } return key } func (ui *UI) handleToggles(key *tcell.EventKey) { switch key.Rune() { case 'a': ui.ShowApparentSize = !ui.ShowApparentSize case 'B': ui.ShowRelativeSize = !ui.ShowRelativeSize case 'c': ui.showItemCount = !ui.showItemCount case 'm': ui.showMtime = !ui.showMtime } if ui.currentDir != nil { row, column := ui.table.GetSelection() ui.showDir() ui.table.Select(row, column) } } func (ui *UI) handleSorting(key *tcell.EventKey) { switch key.Rune() { case 's': ui.setSorting("size") case 'C': ui.setSorting("itemCount") case 'n': ui.setSorting("name") case 'M': ui.setSorting("mtime") } } func (ui *UI) handleLeft() { if ui.currentDirPath == ui.topDirPath { if ui.devices != nil { ui.currentDir = nil err := ui.ListDevices(ui.getter) if err != nil { ui.showErr("Error listing devices", err) } } else if ui.browseParentDirs { ui.analyzeParentOfTopDir() } return } if ui.currentDir != nil { ui.fileItemSelected(0, 0) } } func (ui *UI) analyzeParentOfTopDir() { if ui.currentDir == nil || ui.isInArchive() { return } currentPath := ui.currentDir.GetPath() parentPath := filepath.Dir(currentPath) if parentPath == currentPath { return } ui.Analyzer.ResetProgress() ui.linkedItems = make(fs.HardLinkedItems) if err := analyzeParentPath(ui, parentPath, nil); err != nil { ui.showErr("Error analyzing parent directory", err) } } func (ui *UI) handleRight() { row, column := ui.table.GetSelection() if ui.currentDirPath != ui.topDirPath && row == 0 { // do not select /.. return } if ui.currentDir != nil { ui.fileItemSelected(row, column) } else { ui.deviceItemSelected(row, column) } } func (ui *UI) handleDelete(shouldEmpty bool) { if ui.currentDir == nil { return } // do not allow deleting parent dir row, column := ui.table.GetSelection() selectedFile, ok := ui.table.GetCell(row, column).GetReference().(fs.Item) if !ok || selectedFile == ui.currentDir.GetParent() { return } if ui.askBeforeDelete { ui.confirmDeletion(shouldEmpty) } else { ui.delete(shouldEmpty) } } func (ui *UI) handleMark() { if ui.currentDir == nil { return } // do not allow deleting parent dir row, column := ui.table.GetSelection() selectedFile, ok := ui.table.GetCell(row, column).GetReference().(fs.Item) if !ok || selectedFile == ui.currentDir.GetParent() { return } ui.fileItemMarked(row) } func (ui *UI) ignoreItem() { if ui.currentDir == nil { return } // do not allow ignoring parent dir row, column := ui.table.GetSelection() selectedFile, ok := ui.table.GetCell(row, column).GetReference().(fs.Item) if !ok || selectedFile == ui.currentDir.GetParent() { return } if _, ok := ui.ignoredRows[row]; ok { delete(ui.ignoredRows, row) } else { ui.ignoredRows[row] = struct{}{} } ui.showDir() // select next row if possible ui.table.Select(min(row+1, ui.table.GetRowCount()-1), 0) } gdu-5.36.1/tui/keys_test.go000066400000000000000000001050541517447455500155550ustar00rootroot00000000000000package tui import ( "bytes" "errors" "fmt" "os" "path/filepath" "testing" "time" "github.com/dundee/gdu/v5/internal/testanalyze" "github.com/dundee/gdu/v5/internal/testapp" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/device" "github.com/dundee/gdu/v5/pkg/fs" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/stretchr/testify/assert" ) type devicesInfoGetterErrMock struct{} func (m devicesInfoGetterErrMock) GetDevicesInfo() (device.Devices, error) { return nil, fmt.Errorf("failed getting devices") } func (m devicesInfoGetterErrMock) GetMounts() (device.Devices, error) { return nil, fmt.Errorf("failed getting mounts") } func TestShowHelp(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '?', 0)) assert.True(t, ui.pages.HasPage("help")) } func TestCloseHelp(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.showHelp() assert.True(t, ui.pages.HasPage("help")) ui.keyPressed(tcell.NewEventKey(tcell.KeyEsc, 'q', 0)) assert.False(t, ui.pages.HasPage("help")) } func TestCloseHelpWithQuestionMark(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.showHelp() assert.True(t, ui.pages.HasPage("help")) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '?', 0)) assert.False(t, ui.pages.HasPage("help")) } func TestKeyWhileDeleting(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) modal := tview.NewModal().SetText("Deleting...") ui.pages.AddPage("deleting", modal, true, true) key := ui.keyPressed(tcell.NewEventKey(tcell.KeyEnter, ' ', 0)) assert.Equal(t, tcell.KeyEnter, key.Key()) } func TestLeftRightKeyWhileConfirm(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) modal := tview.NewModal().SetText("Really?") ui.pages.AddPage("confirm", modal, true, true) key := ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 0, 0)) assert.Equal(t, tcell.KeyLeft, key.Key()) key = ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 0, 0)) assert.Equal(t, tcell.KeyRight, key.Key()) key = ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'h', 0)) assert.Equal(t, tcell.KeyLeft, key.Key()) key = ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'l', 0)) assert.Equal(t, tcell.KeyRight, key.Key()) } func TestMoveLeftRight(t *testing.T) { origWD, err := os.Getwd() assert.Nil(t, err) err = os.Chdir(t.TempDir()) assert.Nil(t, err) defer func() { err := os.Chdir(origWD) assert.Nil(t, err) }() fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.done = make(chan struct{}) ui.browseParentDirs = true err = ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) assert.Equal(t, "nested", ui.currentDir.GetName()) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // try /.. first assert.Equal(t, "nested", ui.currentDir.GetName()) ui.table.Select(1, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) assert.Equal(t, "subnested", ui.currentDir.GetName()) ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 'h', 0)) assert.Equal(t, "nested", ui.currentDir.GetName()) ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 'h', 0)) assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 'h', 0)) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, filepath.Dir("test_dir"), ui.currentDirPath) } func TestMoveRightOnDevice(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) ui.SetIgnoreDirPaths([]string{}) err := ui.ListDevices(getDevicesInfoMock()) assert.Nil(t, err) ui.table.Select(1, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) // go back to list of devices ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 'h', 0)) assert.Nil(t, ui.currentDir) assert.Equal(t, "/dev/root", ui.table.GetCell(1, 0).GetReference().(*device.Device).Name) } func TestHandleLeftShowsErrorWhenListDevicesFails(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.currentDirPath = "test_dir" ui.topDirPath = "test_dir" ui.devices = device.Devices{&device.Device{Name: "x"}} ui.getter = devicesInfoGetterErrMock{} ui.handleLeft() assert.True(t, ui.pages.HasPage("error")) } func TestHandleLeftAtTopDirDoesNothingWhenBrowseParentDirsDisabled(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.currentDirPath = "test_dir" ui.topDirPath = "test_dir" ui.currentDir = &analyze.Dir{ File: &analyze.File{Name: "test_dir"}, BasePath: ".", } ui.handleLeft() assert.False(t, ui.pages.HasPage("error")) assert.Equal(t, "test_dir", ui.currentDirPath) } func TestAnalyzeParentOfTopDirNilCurrentDir(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.currentDir = nil ui.analyzeParentOfTopDir() assert.False(t, ui.pages.HasPage("error")) } func TestAnalyzeParentOfTopDirInArchive(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.currentDir = &analyze.ZipDir{Dir: &analyze.Dir{}} ui.analyzeParentOfTopDir() assert.False(t, ui.pages.HasPage("error")) } func TestAnalyzeParentOfTopDirAtFilesystemRoot(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.currentDir = &analyze.Dir{File: &analyze.File{Name: "/"}} ui.analyzeParentOfTopDir() assert.False(t, ui.pages.HasPage("error")) } func TestAnalyzeParentOfTopDirShowsErrorWhenAnalyzeFails(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.currentDir = &analyze.Dir{ File: &analyze.File{Name: "test_dir"}, BasePath: ".", } origAnalyzeParentPath := analyzeParentPath t.Cleanup(func() { analyzeParentPath = origAnalyzeParentPath }) analyzeParentPath = func(ui *UI, path string, parentDir fs.Item) error { return errors.New("boom") } ui.analyzeParentOfTopDir() assert.True(t, ui.pages.HasPage("error")) } func TestStop(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) assert.Nil(t, key) } func TestStopWithPrintingPath(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) buff := &bytes.Buffer{} ui := CreateUI(app, simScreen, buff, true, true, false, false) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'Q', 0)) assert.Nil(t, key) assert.Equal(t, "test_dir\n", buff.String()) } func TestSpawnShell(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) buff := &bytes.Buffer{} ui := CreateUI(app, simScreen, buff, true, true, false, false) called := false ui.exec = func(argv0 string, argv, envv []string) error { called = true return nil } ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) assert.Nil(t, key) assert.True(t, called) } func TestSpawnShellWithoutDir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) buff := &bytes.Buffer{} ui := CreateUI(app, simScreen, buff, true, true, false, false) called := false ui.exec = func(argv0 string, argv, envv []string) error { called = true return nil } ui.done = make(chan struct{}) key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) assert.Nil(t, key) assert.False(t, called) } func TestSpawnShellWithWrongDir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) buff := &bytes.Buffer{} ui := CreateUI(app, simScreen, buff, true, true, false, false) called := false ui.exec = func(argv0 string, argv, envv []string) error { called = true return nil } ui.done = make(chan struct{}) ui.currentDir = &analyze.Dir{} ui.currentDirPath = "/xxxxx" key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) assert.Nil(t, key) assert.False(t, called) assert.True(t, ui.pages.HasPage("error")) } func TestSpawnShellWithError(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) buff := &bytes.Buffer{} ui := CreateUI(app, simScreen, buff, true, true, false, false) called := false ui.exec = func(argv0 string, argv, envv []string) error { called = true return errors.New("wrong shell") } ui.done = make(chan struct{}) ui.currentDir = &analyze.Dir{} ui.currentDirPath = "." key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) assert.Nil(t, key) assert.True(t, called) assert.True(t, ui.pages.HasPage("error")) } func TestSpawnShellWithNoSpawnShell(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) buff := &bytes.Buffer{} ui := CreateUI(app, simScreen, buff, true, true, false, false) called := false ui.exec = func(argv0 string, argv, envv []string) error { called = true return nil } ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } ui.SetNoSpawnShell() key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) assert.Nil(t, key) assert.False(t, called) } func TestOpenItemWithNoSpawnShell(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) buff := &bytes.Buffer{} ui := CreateUI(app, simScreen, buff, true, true, false, false) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } ui.SetNoSpawnShell() key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'o', 0)) assert.Nil(t, key) } func TestShowConfirm(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.table.Select(1, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) assert.True(t, ui.pages.HasPage("confirm")) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '?', 0)) assert.False(t, ui.pages.HasPage("help")) } func TestDeleteEmpty(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) assert.NotNil(t, key) } func TestMarkEmpty(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) assert.NotNil(t, key) } func TestIgnoreEmpty(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'I', 0)) assert.NotNil(t, key) } func TestDelete(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.askBeforeDelete = false err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.NoDirExists(t, "test_dir/nested") } func TestDeleteWithNoDelete(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.SetNoDelete() ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) assert.DirExists(t, "test_dir/nested") } func TestDeleteMarked(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.askBeforeDelete = false err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.NoDirExists(t, "test_dir/nested") } func TestDeleteParent(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.askBeforeDelete = false err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) assert.DirExists(t, "test_dir/nested") } func TestMarkParent(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.askBeforeDelete = false err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) assert.Equal(t, len(ui.markedRows), 0) } func TestIgnoreParent(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.askBeforeDelete = false err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'I', 0)) assert.Equal(t, len(ui.ignoredRows), 0) } func TestEmptyDir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.askBeforeDelete = false err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.DirExists(t, "test_dir/nested") assert.NoDirExists(t, "test_dir/nested/subnested") } func TestMarkedEmptyDir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.askBeforeDelete = false err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.DirExists(t, "test_dir/nested") assert.NoDirExists(t, "test_dir/nested/subnested") } func TestIgnoreDir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.askBeforeDelete = false err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // into nested assert.Equal(t, 3, ui.table.GetRowCount()) ui.table.Select(1, 0) // subnested ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'I', 0)) // ignore subnested row, _ := ui.table.GetSelection() assert.Equal(t, 2, row) // selection moves to next row ui.table.Select(1, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'I', 0)) // unignore subnested } func TestEmptyFile(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.askBeforeDelete = false err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // into nested ui.table.Select(2, 0) // file2 ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.DirExists(t, "test_dir/nested") assert.DirExists(t, "test_dir/nested/subnested") } func TestMarkedEmptyFile(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.askBeforeDelete = false err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // into nested ui.table.Select(2, 0) // file2 ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.DirExists(t, "test_dir/nested") assert.DirExists(t, "test_dir/nested/subnested") } func TestSortByApparentSize(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'a', 0)) assert.True(t, ui.ShowApparentSize) } func TestShowFileCount(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, false, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'c', 0)) assert.True(t, ui.showItemCount) } func TestShowFileCountBW(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'c', 0)) assert.True(t, ui.showItemCount) } func TestShowMtime(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, false, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'm', 0)) assert.True(t, ui.showMtime) } func TestShowMtimeBW(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'm', 0)) assert.True(t, ui.showMtime) } func TestShowRelativeBar(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.False(t, ui.ShowRelativeSize) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'B', 0)) assert.True(t, ui.ShowRelativeSize) } func TestRescan(t *testing.T) { parentDir := &analyze.Dir{ File: &analyze.File{ Name: "parent", }, Files: make([]fs.Item, 0, 1), } currentDir := &analyze.Dir{ File: &analyze.File{ Name: "sub", Parent: parentDir, }, } simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.currentDir = currentDir ui.topDir = parentDir ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'r', 0)) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.Equal(t, parentDir, ui.currentDir.GetParent()) assert.Equal(t, 5, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "/..") assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") } func TestSorting(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.table.Select(1, 0) // mark the item for deletion ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) assert.Equal(t, 1, len(ui.markedRows)) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 's', 0)) assert.Equal(t, "size", ui.sortBy) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'C', 0)) assert.Equal(t, "itemCount", ui.sortBy) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'n', 0)) assert.Equal(t, "name", ui.sortBy) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'M', 0)) assert.Equal(t, "mtime", ui.sortBy) // marking should be dropped after sorting assert.Equal(t, 0, len(ui.markedRows)) } func TestShowFile(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.table.Select(2, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'v', 0)) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) } func TestShowFileWithNoViewFile(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } ui.SetNoViewFile() ui.table.Select(0, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.table.Select(2, 0) previousHeaderText := ui.header.GetText(false) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'v', 0)) assert.False(t, ui.pages.HasPage("file")) assert.Equal(t, " Viewing files is disabled!", ui.header.GetText(false)) time.Sleep(2100 * time.Millisecond) for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, previousHeaderText, ui.header.GetText(false)) } func TestShowInfoAndMoveAround(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'i', 0)) assert.True(t, ui.pages.HasPage("info")) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'k', 0)) // move up ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'j', 0)) // move down ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'k', 0)) // move up ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '?', 0)) // does nothing assert.True(t, ui.pages.HasPage("info")) // we can still see info page ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) assert.False(t, ui.pages.HasPage("info")) } func TestBlockedActionsInArchive(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) // Simulate being in a zip dir zipDir := &analyze.ZipDir{ Dir: &analyze.Dir{ File: &analyze.File{ Name: "test.zip", Flag: 'Z', }, }, } ui.currentDir = zipDir // Test 'd' (delete) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) assert.True(t, ui.pages.HasPage("error")) ui.pages.RemovePage("error") // Test 'e' (empty) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) assert.True(t, ui.pages.HasPage("error")) ui.pages.RemovePage("error") // Test 'v' (view) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'v', 0)) assert.True(t, ui.pages.HasPage("error")) ui.pages.RemovePage("error") // Test 'b' (shell) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'b', 0)) assert.True(t, ui.pages.HasPage("error")) ui.pages.RemovePage("error") } func TestPrintMarkedEmpty(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) buff := &bytes.Buffer{} ui := CreateUI(app, simScreen, buff, false, true, false, false) ui.done = make(chan struct{}) key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'p', 0)) assert.Nil(t, key) assert.Empty(t, ui.markedPaths) } func TestPrintMarked(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) buff := &bytes.Buffer{} ui := CreateUI(app, simScreen, buff, false, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.table.Select(1, 0) ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) // mark item assert.Equal(t, 1, len(ui.markedRows)) // pressing 'p' saves paths but does not quit key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'p', 0)) assert.Nil(t, key) assert.Equal(t, 1, len(ui.markedPaths)) assert.Empty(t, buff.String()) // nothing written yet // quitting with 'q' flushes the saved paths to output ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) assert.NotEmpty(t, buff.String()) } gdu-5.36.1/tui/marked.go000066400000000000000000000070531517447455500150060ustar00rootroot00000000000000package tui import ( "strconv" "golang.org/x/text/cases" "golang.org/x/text/language" "github.com/dundee/gdu/v5/pkg/fs" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) func (ui *UI) fileItemMarked(row int) { if _, ok := ui.markedRows[row]; ok { delete(ui.markedRows, row) } else { ui.markedRows[row] = struct{}{} } ui.showDir() // select next row if possible ui.table.Select(min(row+1, ui.table.GetRowCount()-1), 0) } func (ui *UI) deleteMarked(shouldEmpty bool) { var action, acting string if shouldEmpty { action = actionEmpty acting = actingEmpty } else { action = actionDelete acting = actingDelete } var currentDir fs.Item var markedItems []fs.Item for row := range ui.markedRows { item := ui.table.GetCell(row, 0).GetReference().(fs.Item) markedItems = append(markedItems, item) } if ui.deleteInBackground { ui.queueForDeletion(markedItems, shouldEmpty) return } modal := tview.NewModal() ui.pages.AddPage(acting, modal, true, true) currentRow, _ := ui.table.GetSelection() var deleteFun func(fs.Item, fs.Item) error go func() { for _, one := range markedItems { ui.app.QueueUpdateDraw(func() { modal.SetText( cases.Title(language.English).String(acting) + " " + tview.Escape(one.GetName()) + "...", ) }) if shouldEmpty && !one.IsDir() { deleteFun = ui.emptier } else { deleteFun = ui.remover } var deleteItems []fs.Item if shouldEmpty && one.IsDir() { currentDir = one for file := range currentDir.GetFiles(fs.SortBySize, fs.SortDesc) { deleteItems = append(deleteItems, file) } } else { currentDir = ui.currentDir deleteItems = append(deleteItems, one) } for _, item := range deleteItems { if err := deleteFun(currentDir, item); err != nil { msg := "Can't " + action + " " + tview.Escape(one.GetName()) ui.app.QueueUpdateDraw(func() { ui.pages.RemovePage(acting) ui.showErr(msg, err) }) if ui.done != nil { ui.done <- struct{}{} } return } } } ui.app.QueueUpdateDraw(func() { ui.pages.RemovePage(acting) ui.pages.RemovePage(acting) ui.markedRows = make(map[int]struct{}) x, y := ui.table.GetOffset() ui.showDir() ui.table.Select(min(currentRow, ui.table.GetRowCount()-1), 0) ui.table.SetOffset(min(x, ui.table.GetRowCount()-1), y) }) if ui.done != nil { ui.done <- struct{}{} } }() } func (ui *UI) confirmDeletionMarked(shouldEmpty bool) { var action string if shouldEmpty { action = actionEmpty } else { action = actionDelete } modal := tview.NewModal(). SetText( "Are you sure you want to " + action + " [::b]" + strconv.Itoa(len(ui.markedRows)) + "[::-] items?", ). AddButtons([]string{"no", "yes", "don't ask me again"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { switch buttonIndex { case 2: ui.askBeforeDelete = false fallthrough case 1: ui.deleteMarked(shouldEmpty) } ui.pages.RemovePage("confirm") }) if !ui.UseColors { modal.SetBackgroundColor(tcell.ColorGray) } else { modal.SetBackgroundColor(tcell.ColorBlack) } modal.SetBorderColor(tcell.ColorDefault) ui.pages.AddPage("confirm", modal, true, true) } func (ui *UI) printMarked() { if len(ui.markedRows) == 0 { return } for row := range ui.markedRows { item := ui.table.GetCell(row, 0).GetReference().(fs.Item) ui.markedPaths = append(ui.markedPaths, item.GetPath()) } ui.markedRows = make(map[int]struct{}) selectRow, _ := ui.table.GetSelection() ui.showDir() ui.table.Select(selectRow, 0) } gdu-5.36.1/tui/marked_test.go000066400000000000000000000006751517447455500160500ustar00rootroot00000000000000package tui import ( "testing" "github.com/dundee/gdu/v5/internal/testdir" "github.com/stretchr/testify/assert" ) func TestItemMarked(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) ui.done = make(chan struct{}) ui.fileItemMarked(1) assert.Equal(t, ui.markedRows, map[int]struct{}{1: {}}) ui.fileItemMarked(1) assert.Equal(t, ui.markedRows, map[int]struct{}{}) } gdu-5.36.1/tui/mouse.go000066400000000000000000000030361517447455500146700ustar00rootroot00000000000000package tui import ( "time" "github.com/dundee/gdu/v5/pkg/fs" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) func (ui *UI) onMouse(event *tcell.EventMouse, action tview.MouseAction) (*tcell.EventMouse, tview.MouseAction) { if event == nil { return nil, action } if ui.pages.HasPage("confirm") || ui.pages.HasPage("progress") || ui.pages.HasPage("deleting") || ui.pages.HasPage("emptying") || ui.pages.HasPage("help") { return nil, action } // nolint: exhaustive // Why: we don't need to handle all mouse events switch action { case tview.MouseLeftDoubleClick: row, column := ui.table.GetSelection() if ui.currentDirPath != ui.topDirPath && row == 0 { ui.handleLeft() } else { selectedFile := ui.table.GetCell(row, column).GetReference().(fs.Item) if selectedFile.IsDir() { ui.handleRight() } else { if ui.noViewFile { previousHeaderText := ui.header.GetText(false) ui.header.SetText(" Viewing files is disabled!") go func() { time.Sleep(2 * time.Second) ui.app.QueueUpdateDraw(func() { ui.header.Clear() ui.header.SetText(previousHeaderText) }) }() return nil, action } ui.showFile() } } return nil, action case tview.MouseScrollUp, tview.MouseScrollDown: row, column := ui.table.GetSelection() if action == tview.MouseScrollUp && row > 0 { row-- } else if action == tview.MouseScrollDown && row+1 < ui.table.GetRowCount() { row++ } ui.table.Select(row, column) return nil, action } return event, action } gdu-5.36.1/tui/mouse_test.go000066400000000000000000000107371517447455500157350ustar00rootroot00000000000000package tui import ( "bytes" "testing" "time" "github.com/dundee/gdu/v5/internal/testanalyze" "github.com/dundee/gdu/v5/internal/testapp" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/fs" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/stretchr/testify/assert" ) func TestDoubleClick(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } ui.table.Select(0, 0) assert.Equal(t, "test_dir", ui.currentDir.GetName()) ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseLeftDoubleClick) assert.Equal(t, "nested", ui.currentDir.GetName()) ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseLeftDoubleClick) assert.Equal(t, "test_dir", ui.currentDir.GetName()) // show file content ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.table.Select(2, 0) selectedFile := ui.table.GetCell(2, 0).GetReference().(fs.Item) assert.Equal(t, selectedFile.GetName(), "file2") ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseLeftDoubleClick) assert.True(t, ui.pages.HasPage("file")) } func TestDoubleClickNoViewFile(t *testing.T) { fin := testdir.CreateTestDir() defer fin() simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) ui.table.Select(2, 0) ui.SetNoViewFile() previousHeaderText := ui.header.GetText(false) ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseLeftDoubleClick) assert.False(t, ui.pages.HasPage("file")) assert.Equal(t, " Viewing files is disabled!", ui.header.GetText(false)) time.Sleep(2100 * time.Millisecond) for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, previousHeaderText, ui.header.GetText(false)) } func TestScroll(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseScrollDown) row, _ := ui.table.GetSelection() assert.Equal(t, row, 1) ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseScrollUp) row, _ = ui.table.GetSelection() assert.Equal(t, row, 0) } func TestScrollWhenPageOpened(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } // open confirm dialog ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseScrollDown) row, _ := ui.table.GetSelection() // scrolling does nothing assert.Equal(t, 0, row) } func TestEmptyEvent(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) event, action := ui.onMouse(nil, tview.MouseMove) assert.True(t, event == nil) assert.Equal(t, action, tview.MouseMove) } func TestMouseMove(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) event, action := ui.onMouse(tcell.NewEventMouse(0, 0, 0, 0), tview.MouseMove) assert.True(t, event != nil) assert.Equal(t, action, tview.MouseMove) } gdu-5.36.1/tui/progress.go000066400000000000000000000042621517447455500154060ustar00rootroot00000000000000package tui import ( "fmt" "os" "time" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/path" ) func (ui *UI) updateProgress(analyzer common.Analyzer, doneChan common.SignalGroup) { color := "[white:black:b]" if ui.UseColors { color = "[red:black:b]" } deviceSize := ui.currentDeviceSize showBar := ui.showDiskProgressBar start := time.Now() ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { select { case <-doneChan: if deviceSize > 0 && showBar { clearTerminalProgress() ui.currentDeviceSize = 0 } ui.app.QueueUpdateDraw(func() { ui.progress.SetTitle(" Finalizing... ") ui.progress.SetText("Calculating disk usage...") }) return case <-ticker.C: } progress := analyzer.GetProgress() func(itemCount int64, totalUsage int64, currentItem string) { delta := time.Since(start).Round(time.Second) if deviceSize > 0 && showBar { percent := int(totalUsage * 100 / deviceSize) writeTerminalProgress(percent) if ui.progressBar != nil { ui.progressBar.SetProgress(percent) } } ui.app.QueueUpdateDraw(func() { ui.progress.SetText("Total items: " + color + common.FormatNumber(int64(itemCount)) + "[white:black:-], size: " + color + ui.formatSize(totalUsage, false, false) + "[white:black:-], elapsed time: " + color + delta.String() + "[white:black:-]\nCurrent item: [white:black:b]" + path.ShortenPath(currentItem, ui.currentItemNameMaxLen)) }) }(progress.ItemCount, progress.TotalUsage, progress.CurrentItemName) } } // writeTerminalProgress emits an OSC 9;4 sequence to update the terminal // tab/taskbar progress indicator. percent must be in the range [0, 100]. // This sequence is supported by Windows Terminal, ConEmu, and compatible // terminals. Writing to stderr ensures it reaches the terminal even when // the TUI has taken over stdout/stdin via tcell. func writeTerminalProgress(percent int) { fmt.Fprintf(os.Stderr, "\x1b]9;4;1;%d\x1b\\", percent) } // clearTerminalProgress removes the terminal tab/taskbar progress indicator. func clearTerminalProgress() { fmt.Fprintf(os.Stderr, "\x1b]9;4;0;0\x1b\\") } gdu-5.36.1/tui/progressbar.go000066400000000000000000000046231517447455500160740ustar00rootroot00000000000000package tui import ( "fmt" "sync" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) // ProgressBar is a tview primitive that renders a horizontal progress bar. // It embeds tview.Box so it participates in layout and can optionally have // a border and title. type ProgressBar struct { *tview.Box progress int // 0–100 useColor bool mu sync.RWMutex } // NewProgressBar returns a new ProgressBar with default settings. func NewProgressBar() *ProgressBar { return &ProgressBar{ Box: tview.NewBox(), } } // SetUseColor controls whether the filled segment is highlighted with colour. func (p *ProgressBar) SetUseColor(use bool) *ProgressBar { p.mu.Lock() defer p.mu.Unlock() p.useColor = use return p } // SetProgress sets the current progress in the range [0, 100]. func (p *ProgressBar) SetProgress(progress int) { p.mu.Lock() defer p.mu.Unlock() if progress < 0 { progress = 0 } else if progress > 100 { progress = 100 } p.progress = progress } // GetProgress returns the current progress in the range [0, 100]. func (p *ProgressBar) GetProgress() int { p.mu.RLock() defer p.mu.RUnlock() return p.progress } // Draw implements tview.Primitive. It draws the border (via Box.Draw) and // then fills the inner rect with a segmented progress bar. func (p *ProgressBar) Draw(screen tcell.Screen) { p.Box.Draw(screen) p.mu.RLock() progress := p.progress useColor := p.useColor p.mu.RUnlock() x, y, width, height := p.GetInnerRect() if width <= 0 || height <= 0 { return } percentStr := fmt.Sprintf(" %3d%% ", progress) barWidth := width - len(percentStr) if barWidth < 0 { barWidth = 0 } filled := 0 if barWidth > 0 && progress > 0 { filled = barWidth * progress / 100 } filledStyle := tcell.StyleDefault.Foreground(tcell.ColorDefault) if useColor { filledStyle = tcell.StyleDefault.Foreground(tcell.ColorGreen) } emptyStyle := tcell.StyleDefault.Foreground(tcell.ColorGray) textStyle := tcell.StyleDefault.Foreground(tcell.ColorDefault) // Render only the middle row; if height > 1 the box padding handles spacing. row := (height - 1) / 2 for col := 0; col < barWidth; col++ { ch := '░' style := emptyStyle if col < filled { ch = '█' style = filledStyle } screen.SetContent(x+col, y+row, ch, nil, style) } for i, ch := range []rune(percentStr) { if barWidth+i >= width { break } screen.SetContent(x+barWidth+i, y+row, ch, nil, textStyle) } } gdu-5.36.1/tui/progressbar_test.go000066400000000000000000000052321517447455500171300ustar00rootroot00000000000000package tui import ( "testing" "time" "github.com/dundee/gdu/v5/internal/testapp" "github.com/stretchr/testify/assert" ) func TestProgressBarSetGetProgress(t *testing.T) { pb := NewProgressBar() assert.Equal(t, 0, pb.GetProgress()) pb.SetProgress(50) assert.Equal(t, 50, pb.GetProgress()) pb.SetProgress(100) assert.Equal(t, 100, pb.GetProgress()) } func TestProgressBarClampsProgress(t *testing.T) { pb := NewProgressBar() pb.SetProgress(-10) assert.Equal(t, 0, pb.GetProgress()) pb.SetProgress(150) assert.Equal(t, 100, pb.GetProgress()) } func TestProgressBarDraw(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() pb := NewProgressBar() pb.SetBorder(true) pb.SetProgress(42) pb.SetRect(0, 0, 40, 3) pb.Draw(simScreen) simScreen.Show() // If Draw completed without panic, the test passes. assert.Equal(t, 42, pb.GetProgress()) } func TestProgressBarDrawWithColor(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() pb := NewProgressBar() pb.SetBorder(true) pb.SetUseColor(true) pb.SetProgress(75) pb.SetRect(0, 0, 40, 3) pb.Draw(simScreen) simScreen.Show() assert.Equal(t, 75, pb.GetProgress()) } func TestUpdateProgressWithDeviceSize(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, nil, false, false, false, false) ui.currentDeviceSize = 1000 ui.showDiskProgressBar = true done := ui.Analyzer.GetDone() done.Broadcast() ui.updateProgress(ui.Analyzer, done) // After updateProgress returns, currentDeviceSize must be cleared. assert.Equal(t, int64(0), ui.currentDeviceSize) } func TestUpdateProgressBarDisabled(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, nil, false, false, false, false) ui.currentDeviceSize = 1000 ui.showDiskProgressBar = false done := ui.Analyzer.GetDone() done.Broadcast() ui.updateProgress(ui.Analyzer, done) // showDiskProgressBar is false, so currentDeviceSize must NOT be cleared. assert.Equal(t, int64(1000), ui.currentDeviceSize) } func TestUpdateProgressUpdatesBar(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, nil, false, false, false, false) ui.currentDeviceSize = 1000 ui.showDiskProgressBar = true ui.progressBar = NewProgressBar() doneChan := ui.Analyzer.GetDone() go func() { // Wait for updateProgress to start polling time.Sleep(150 * time.Millisecond) doneChan.Broadcast() }() ui.updateProgress(ui.Analyzer, doneChan) } gdu-5.36.1/tui/show.go000066400000000000000000000262651517447455500145310ustar00rootroot00000000000000package tui import ( "fmt" "strconv" "strings" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" log "github.com/sirupsen/logrus" "github.com/dundee/gdu/v5/build" "github.com/dundee/gdu/v5/pkg/fs" ) var ( helpDisabledSuffix = " (disabled)" helpText = ` [::b]up/down, k/j [white:black:-]Move cursor up/down [::b]pgup/pgdn, g/G [white:black:-]Move cursor top/bottom [::b]enter, right, l [white:black:-]Go to directory/device [::b]left, h [white:black:-]Go to parent directory [::b]r [white:black:-]Rescan current directory [::b]E [white:black:-]Export analysis data to file as JSON [::b]/ [white:black:-]Search items by name [::b]T [white:black:-]Filter items by file type (extension) [::b]a [white:black:-]Toggle between showing disk usage and apparent size [::b]B [white:black:-]Toggle bar alignment to biggest file or directory [::b]c [white:black:-]Show/hide file count [::b]m [white:black:-]Show/hide latest mtime [::b]b [white:black:-]Spawn shell in current directory [::b]q [white:black:-]Quit gdu [::b]Q [white:black:-]Quit gdu and print current directory path Item under cursor: [::b]d [white:black:-]Delete file or directory [::b]e [white:black:-]Empty file or directory [::b]space [white:black:-]Mark file or directory for deletion [::b]p [white:black:-]Print marked items paths to stdout after quitting [::b]I [white:black:-]Ignore file or directory [::b]v [white:black:-]Show content of file [::b]o [white:black:-]Open file or directory in external program [::b]i [white:black:-]Show info about item Sort by (twice toggles asc/desc): [::b]n [white:black:-]Sort by name (asc/desc) [::b]s [white:black:-]Sort by size (asc/desc) [::b]C [white:black:-]Sort by file count (asc/desc) [::b]M [white:black:-]Sort by mtime (asc/desc)` ) // nolint: funlen // Why: complex function func (ui *UI) showDir() { var ( totalUsage int64 totalSize int64 maxUsage int64 maxSize int64 itemCount int64 ) ui.currentDirPath = ui.currentDir.GetPath() if ui.changeCwdFn != nil { err := ui.changeCwdFn(ui.currentDirPath) if err != nil { log.Printf("error setting cwd: %s", err.Error()) } log.Printf("changing cwd to %s", ui.currentDirPath) } ui.currentDirLabel.SetText("[::b] --- " + tview.Escape( strings.TrimPrefix(ui.currentDirPath, build.RootPathPrefix), ) + " ---").SetDynamicColors(true) ui.table.Clear() rowIndex := 0 if ui.currentDirPath != ui.topDirPath { prefix := " " if len(ui.markedRows) > 0 { prefix += " " } cell := tview.NewTableCell(prefix + "[::b]/..") // Use the collapsed parent logic to handle navigation back through collapsed paths var collapsedParent fs.Item if ui.collapsePath { collapsedParent = findCollapsedParent(ui.currentDir) } else { collapsedParent = ui.currentDir.GetParent() } cell.SetReference(collapsedParent) cell.SetStyle(tcell.Style{}.Foreground(tcell.ColorDefault)) ui.table.SetCell(0, 0, cell) rowIndex++ } sortBy, sortOrder := ui.getSortParams() unlock := ui.currentDir.RLock() defer unlock() i := rowIndex maxUsage = 0 maxSize = 0 for item := range ui.currentDir.GetFiles(sortBy, sortOrder) { if _, ignored := ui.ignoredRows[i]; ignored { i++ continue } if ui.ShowRelativeSize { if item.GetUsage() > maxUsage { maxUsage = item.GetUsage() } if item.GetSize() > maxSize { maxSize = item.GetSize() } } else { maxSize += item.GetSize() maxUsage += item.GetUsage() } i++ } for item := range ui.currentDir.GetFiles(sortBy, sortOrder) { if ui.filterValue != "" && !strings.Contains( strings.ToLower(item.GetName()), strings.ToLower(ui.filterValue), ) { continue } if !ui.matchesTypeFilter(item.GetName(), item.IsDir()) { continue } _, ignored := ui.ignoredRows[rowIndex] if !ignored { totalUsage += item.GetUsage() totalSize += item.GetSize() itemCount += item.GetItemCount() } _, marked := ui.markedRows[rowIndex] var cell *tview.TableCell var reference fs.Item // Check if this directory can be collapsed if item.IsDir() { var collapsedPath *CollapsedPath if ui.collapsePath { collapsedPath = findCollapsiblePath(item) } if collapsedPath != nil { // Format as collapsed path cell = tview.NewTableCell(ui.formatCollapsedRow(collapsedPath, maxUsage, maxSize, marked, ignored)) // Reference should point to the deepest directory for navigation reference = collapsedPath.DeepestDir } else { // Regular directory formatting cell = tview.NewTableCell(ui.formatFileRow(item, maxUsage, maxSize, marked, ignored)) reference = item } } else { // Regular file formatting cell = tview.NewTableCell(ui.formatFileRow(item, maxUsage, maxSize, marked, ignored)) reference = item } cell.SetReference(reference) switch { case ignored: cell.SetStyle(tcell.Style{}.Foreground(tview.Styles.SecondaryTextColor)) case marked: cell.SetStyle(tcell.Style{}.Foreground(tview.Styles.PrimaryTextColor)) cell.SetBackgroundColor(tview.Styles.ContrastBackgroundColor) default: cell.SetStyle(tcell.Style{}.Foreground(tcell.ColorDefault)) } ui.table.SetCell(rowIndex, 0, cell) rowIndex++ } var footerNumberColor, footerTextColor string if ui.UseColors { footerNumberColor = fmt.Sprintf( "[%s:%s:b]", ui.footerNumberColor, ui.footerBackgroundColor, ) footerTextColor = fmt.Sprintf( "[%s:%s:-]", ui.footerTextColor, ui.footerBackgroundColor, ) } else { footerNumberColor = "[black:white:b]" footerTextColor = blackOnWhite } selected := "" if len(ui.markedRows) > 0 { selected = " Selected items: " + footerNumberColor + strconv.Itoa(len(ui.markedRows)) + footerTextColor } timeFilterText := ui.formatTimeFilterInfo() typeFilterText := ui.formatTypeFilterInfo(footerNumberColor, footerTextColor) ui.footerLabel.SetText( selected + footerTextColor + " Total disk usage: " + footerNumberColor + ui.formatSize(totalUsage, true, false) + " Apparent size: " + footerNumberColor + ui.formatSize(totalSize, true, false) + " Items: " + footerNumberColor + fmt.Sprintf("%d", itemCount) + footerTextColor + " Sorting by: " + ui.sortBy + " " + ui.sortOrder + typeFilterText + timeFilterText) ui.table.Select(0, 0) ui.table.ScrollToBeginning() if !ui.filtering && !ui.typeFiltering { ui.app.SetFocus(ui.table) } } func (ui *UI) showDevices() { var totalUsage int64 ui.table.Clear() ui.table.SetCell(0, 0, tview.NewTableCell("Device name").SetSelectable(false)) ui.table.SetCell(0, 1, tview.NewTableCell("Size").SetSelectable(false)) ui.table.SetCell(0, 2, tview.NewTableCell("Used").SetSelectable(false)) ui.table.SetCell(0, 3, tview.NewTableCell("Used part").SetSelectable(false)) ui.table.SetCell(0, 4, tview.NewTableCell("Free").SetSelectable(false)) ui.table.SetCell(0, 5, tview.NewTableCell("Mount point").SetSelectable(false)) var textColor, sizeColor string if ui.UseColors { textColor = "[#3498db:-:b]" sizeColor = "[#edb20a:-:b]" } else { textColor = "[white:-:b]" sizeColor = "[white:-:b]" } ui.sortDevices() for i, device := range ui.devices { totalUsage += device.GetUsage() ui.table.SetCell(i+1, 0, tview.NewTableCell(textColor+device.Name).SetReference(ui.devices[i])) ui.table.SetCell(i+1, 1, tview.NewTableCell(ui.formatSize(device.Size, false, true))) ui.table.SetCell(i+1, 2, tview.NewTableCell(sizeColor+ui.formatSize(device.Size-device.Free, false, true))) ui.table.SetCell(i+1, 3, tview.NewTableCell(getDeviceUsagePart(device, ui.useOldSizeBar))) ui.table.SetCell(i+1, 4, tview.NewTableCell(ui.formatSize(device.Free, false, true))) ui.table.SetCell(i+1, 5, tview.NewTableCell(textColor+device.MountPoint).SetReference(ui.devices[i])) } var footerNumberColor, footerTextColor string if ui.UseColors { footerNumberColor = fmt.Sprintf( "[%s:%s:b]", ui.footerNumberColor, ui.footerBackgroundColor, ) footerTextColor = fmt.Sprintf( "[%s:%s:-]", ui.footerTextColor, ui.footerBackgroundColor, ) } else { footerNumberColor = "[black:white:b]" footerTextColor = blackOnWhite } ui.footerLabel.SetText( " Total usage: " + footerNumberColor + ui.formatSize(totalUsage, true, false) + footerTextColor + " Sorting by: " + ui.sortBy + " " + ui.sortOrder) ui.table.Select(1, 0) ui.table.SetSelectedFunc(ui.deviceItemSelected) if ui.topDirPath != "" { for i, device := range ui.devices { if device.MountPoint == ui.topDirPath { ui.table.Select(i+1, 0) break } } } } func (ui *UI) showErr(msg string, err error) { text := msg if err != nil { text += ": " + err.Error() } modal := tview.NewModal(). SetText(text). AddButtons([]string{"ok"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { ui.pages.RemovePage("error") }) if !ui.UseColors { modal.SetBackgroundColor(tcell.ColorGray) } ui.pages.AddPage("error", modal, true, true) ui.app.SetFocus(modal) } func (ui *UI) showErrFromGo(msg string, err error) { ui.app.QueueUpdateDraw(func() { ui.showErr(msg, err) }) } func (ui *UI) showHelp() { text := tview.NewTextView().SetDynamicColors(true) text.SetBorder(true).SetBorderPadding(2, 2, 2, 2) text.SetBorderColor(tcell.ColorDefault) text.SetTitle(" gdu help ") text.SetScrollable(true) formattedHelpText := ui.formatHelpTextFor() text.SetText(formattedHelpText) maxHeight := strings.Count(formattedHelpText, "\n") + 7 _, height := ui.screen.Size() if height > maxHeight { height = maxHeight } flex := tview.NewFlex(). AddItem(nil, 0, 1, false). AddItem(tview.NewFlex().SetDirection(tview.FlexRow). AddItem(nil, 0, 1, false). AddItem(text, height, 1, false). AddItem(nil, 0, 1, false), 80, 1, false). AddItem(nil, 0, 1, false) ui.help = flex ui.pages.AddPage("help", flex, true, true) ui.app.SetFocus(text) } func (ui *UI) formatHelpTextFor() string { lines := strings.Split(helpText, "\n") for i, line := range lines { if ui.UseColors { lines[i] = strings.ReplaceAll( strings.ReplaceAll(line, defaultColorBold, "[red]"), whiteOnBlack, "[white]", ) } isFound := (strings.Contains(line, "Empty file or directory") || strings.Contains(line, "Delete file or directory")) if ui.noDelete && isFound { lines[i] += helpDisabledSuffix } else if ui.noDeleteWithFilter && isFound { lines[i] += " (disabled/filter)" } if ui.noSpawnShell && (strings.Contains(line, "Spawn shell in current directory") || strings.Contains(line, "Open file or directory in external program")) { lines[i] += helpDisabledSuffix } if ui.noViewFile && strings.Contains(line, "Show content of file") { lines[i] += helpDisabledSuffix } } return strings.Join(lines, "\n") } func (ui *UI) formatTypeFilterInfo(numberColor, textColor string) string { if ui.typeFilterValue == "" { return "" } return " Type filter: " + numberColor + ui.typeFilterValue + textColor } gdu-5.36.1/tui/show_file.go000066400000000000000000000071671517447455500155300ustar00rootroot00000000000000package tui import ( "bufio" "compress/bzip2" "compress/gzip" "io" "os" "strings" "github.com/gdamore/tcell/v2" "github.com/h2non/filetype" "github.com/h2non/filetype/matchers" "github.com/pkg/errors" "github.com/rivo/tview" "github.com/ulikunitz/xz" "github.com/dundee/gdu/v5/build" "github.com/dundee/gdu/v5/pkg/fs" ) func (ui *UI) showFile() *tview.TextView { if ui.currentDir == nil { return nil } row, column := ui.table.GetSelection() cell := ui.table.GetCell(row, column) if cell == nil || cell.GetReference() == nil { return nil } selectedFile, ok := cell.GetReference().(fs.Item) if !ok || selectedFile == nil || selectedFile.IsDir() { return nil } path := selectedFile.GetPath() f, err := os.Open(path) if err != nil { ui.showErr("Error opening file", err) return nil } scanner, err := getScanner(f) if err != nil { ui.showErr("Error reading file", err) return nil } totalLines := 0 file := tview.NewTextView() ui.currentDirLabel.SetText("[::b] --- " + strings.TrimPrefix(path, build.RootPathPrefix) + " ---").SetDynamicColors(true) readNextPart := func(linesCount int) int { var err error readLines := 0 for scanner.Scan() && readLines <= linesCount { _, err = file.Write(scanner.Bytes()) if err != nil { ui.showErr("Error reading file", err) return 0 } _, err = file.Write([]byte("\n")) if err != nil { ui.showErr("Error reading file", err) return 0 } readLines++ } return readLines } totalLines += readNextPart(defaultLinesCount) file.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { if event.Rune() == 'q' || event.Key() == tcell.KeyESC { err = f.Close() if err != nil { ui.showErr("Error closing file", err) return event } ui.currentDirLabel.SetText("[::b] --- " + strings.TrimPrefix(ui.currentDirPath, build.RootPathPrefix) + " ---").SetDynamicColors(true) ui.pages.RemovePage("file") ui.app.SetFocus(ui.table) return event } if event.Rune() == 'j' || event.Rune() == 'G' || event.Key() == tcell.KeyDown || event.Key() == tcell.KeyPgDn { _, _, _, height := file.GetInnerRect() row, _ := file.GetScrollOffset() if height+row > totalLines-linesThreshold { totalLines += readNextPart(defaultLinesCount) } } return event }) grid := tview.NewGrid().SetRows(1, 1, 0, 1).SetColumns(0) grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false). AddItem(file, 2, 0, 1, 1, 0, 0, true). AddItem(ui.footerLabel, 3, 0, 1, 1, 0, 0, false) ui.pages.HidePage("background") ui.pages.AddPage("file", grid, true, true) return file } func getScanner(f io.ReadSeeker) (scanner *bufio.Scanner, err error) { // We only have to pass the file header = first 261 bytes head := make([]byte, 261) if _, err = f.Read(head); err != nil { return nil, errors.Wrap(err, "error reading file header") } if pos, err := f.Seek(0, 0); pos != 0 || err != nil { return nil, errors.Wrap(err, "error seeking file") } scanner = bufio.NewScanner(f) typ, err := filetype.Match(head) if err != nil { return nil, errors.Wrap(err, "error matching file type") } switch typ.MIME.Value { case matchers.TypeGz.MIME.Value: r, err := gzip.NewReader(f) if err != nil { return nil, errors.Wrap(err, "error creating gzip reader") } scanner = bufio.NewScanner(r) case matchers.TypeBz2.MIME.Value: r := bzip2.NewReader(f) scanner = bufio.NewScanner(r) case matchers.TypeXz.MIME.Value: r, err := xz.NewReader(f) if err != nil { return nil, errors.Wrap(err, "error creating xz reader") } scanner = bufio.NewScanner(r) } return scanner, nil } gdu-5.36.1/tui/show_file_test.go000066400000000000000000000035231517447455500165570ustar00rootroot00000000000000package tui import ( "bytes" "compress/gzip" "testing" "github.com/stretchr/testify/assert" "github.com/ulikunitz/xz" ) func TestGetScannerForEmptyString(t *testing.T) { r := bytes.NewReader([]byte{}) _, err := getScanner(r) assert.ErrorContains(t, err, "EOF") } func TestGetScannerForPlainString(t *testing.T) { r := bytes.NewReader([]byte("hello")) s, err := getScanner(r) assert.Nil(t, err) assert.Equal(t, true, s.Scan()) assert.Equal(t, "hello", s.Text()) assert.Equal(t, nil, s.Err()) } func TestGetScannerForGzipped(t *testing.T) { b := bytes.NewBuffer([]byte{}) w := gzip.NewWriter(b) _, err := w.Write([]byte("hello world")) assert.Nil(t, err) err = w.Close() assert.Nil(t, err) r := bytes.NewReader(b.Bytes()) s, err := getScanner(r) assert.Nil(t, err) assert.Equal(t, true, s.Scan()) assert.Equal(t, "hello world", s.Text()) assert.Equal(t, nil, s.Err()) } func TestGetScannerForBzipped(t *testing.T) { r := bytes.NewReader([]byte{ // bzip2 header 0x42, 0x5A, 0x68, 0x39, // bzip2 compressed data: "hello" 0x31, 0x41, 0x59, 0x26, 0x53, 0x59, 0xC1, 0xC0, 0x80, 0xE2, 0x00, 0x00, 0x01, 0x41, 0x00, 0x00, 0x10, 0x02, 0x44, 0xA0, 0x00, 0x30, 0xCD, 0x00, 0xC3, 0x46, 0x29, 0x97, 0x17, 0x72, 0x45, 0x38, 0x50, 0x90, 0xC1, 0xC0, 0x80, 0xE2, }) s, err := getScanner(r) assert.Nil(t, err) assert.Equal(t, true, s.Scan()) assert.Equal(t, "hello", s.Text()) assert.Equal(t, nil, s.Err()) } func TestGetScannerForXzipped(t *testing.T) { b := bytes.NewBuffer([]byte{}) w, err := xz.NewWriter(b) assert.Nil(t, err) _, err = w.Write([]byte("hello world")) assert.Nil(t, err) err = w.Close() assert.Nil(t, err) r := bytes.NewReader(b.Bytes()) s, err := getScanner(r) assert.Nil(t, err) assert.Equal(t, true, s.Scan()) assert.Equal(t, "hello world", s.Text()) assert.Equal(t, nil, s.Err()) } gdu-5.36.1/tui/show_test.go000066400000000000000000000041051517447455500155550ustar00rootroot00000000000000package tui import ( "bytes" "strings" "testing" "github.com/dundee/gdu/v5/internal/testapp" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/fs" "github.com/stretchr/testify/assert" ) func TestHelpNoSpawnShell(t *testing.T) { app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) defer simScreen.Fini() ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.SetNoDelete() ui.SetNoSpawnShell() ui.SetNoViewFile() ui.showHelp() assert.True(t, ui.pages.HasPage("help")) helpText := ui.formatHelpTextFor() assert.True(t, strings.Contains(helpText, "Delete file or directory (disabled)")) assert.True(t, strings.Contains(helpText, "Empty file or directory (disabled)")) assert.True(t, strings.Contains(helpText, "Spawn shell in current directory (disabled)")) assert.True(t, strings.Contains(helpText, "Open file or directory in external program (disabled)")) assert.True(t, strings.Contains(helpText, "Show content of file (disabled)")) } func TestCollapsePathFlag(t *testing.T) { app := testapp.CreateMockedApp(true) simScreen := testapp.CreateSimScreen() defer simScreen.Fini() ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) // Create a collapsible structure deepestDir := &analyze.Dir{ File: &analyze.File{ Name: "deepest", Usage: 100, Size: 100, }, Files: []fs.Item{}, } middleDir := &analyze.Dir{ File: &analyze.File{ Name: "middle", Usage: 100, Size: 100, }, Files: []fs.Item{deepestDir}, } topDir := &analyze.Dir{ File: &analyze.File{ Name: "top", }, Files: []fs.Item{middleDir}, } deepestDir.SetParent(middleDir) middleDir.SetParent(topDir) ui.currentDir = topDir ui.topDir = topDir ui.topDirPath = "top" // Default (flag false) -> Should NOT collapse ui.showDir() cell := ui.table.GetCell(0, 0) assert.Contains(t, cell.Text, "middle") assert.NotContains(t, cell.Text, "deepest") // Enable flag -> Should collapse ui.SetCollapsePath(true) ui.showDir() cell = ui.table.GetCell(0, 0) assert.Contains(t, cell.Text, "middle/deepest") } gdu-5.36.1/tui/sort.go000066400000000000000000000036021517447455500145260ustar00rootroot00000000000000package tui import ( "sort" "github.com/dundee/gdu/v5/pkg/device" "github.com/dundee/gdu/v5/pkg/fs" ) const ( nameSortKey = "name" sizeSortKey = "size" itemCountSortKey = "itemCount" mtimeSortKey = "mtime" ascOrder = "asc" descOrder = "desc" ) // SetDefaultSorting sets the default sorting func (ui *UI) SetDefaultSorting(by, order string) { if by != "" { ui.defaultSortBy = by } if order == ascOrder || order == descOrder { ui.defaultSortOrder = order } } func (ui *UI) setSorting(newOrder string) { ui.markedRows = make(map[int]struct{}) if newOrder == ui.sortBy { if ui.sortOrder == ascOrder { ui.sortOrder = descOrder } else { ui.sortOrder = ascOrder } } else { ui.sortBy = newOrder ui.sortOrder = ascOrder } if ui.currentDir != nil { ui.showDir() } else if ui.devices != nil && (newOrder == sizeSortKey || newOrder == nameSortKey) { ui.showDevices() } } // getSortParams returns the current sort parameters as fs.SortBy and fs.SortOrder func (ui *UI) getSortParams() (fs.SortBy, fs.SortOrder) { var sortBy fs.SortBy switch ui.sortBy { case nameSortKey: sortBy = fs.SortByName case itemCountSortKey: sortBy = fs.SortByItemCount case mtimeSortKey: sortBy = fs.SortByMtime case sizeSortKey: if ui.ShowApparentSize { sortBy = fs.SortByApparentSize } else { sortBy = fs.SortBySize } default: sortBy = fs.SortBySize } sortOrder := fs.SortAsc if ui.sortOrder == descOrder { sortOrder = fs.SortDesc } return sortBy, sortOrder } func (ui *UI) sortDevices() { if ui.sortBy == sizeSortKey { if ui.sortOrder == descOrder { sort.Sort(sort.Reverse(device.ByUsedSize(ui.devices))) } else { sort.Sort(device.ByUsedSize(ui.devices)) } } if ui.sortBy == nameSortKey { if ui.sortOrder == descOrder { sort.Sort(sort.Reverse(device.ByName(ui.devices))) } else { sort.Sort(device.ByName(ui.devices)) } } } gdu-5.36.1/tui/sort_test.go000066400000000000000000000145351517447455500155740ustar00rootroot00000000000000package tui import ( "bytes" "testing" "github.com/dundee/gdu/v5/internal/testanalyze" "github.com/dundee/gdu/v5/internal/testapp" "github.com/stretchr/testify/assert" ) func TestAnalyzeByApparentSize(t *testing.T) { ui := getAnalyzedPathWithSorting("size", "desc", true) assert.Equal(t, 4, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") assert.Contains(t, ui.table.GetCell(2, 0).Text, "aaa") assert.Contains(t, ui.table.GetCell(3, 0).Text, "ddd") } func TestSortByApparentSizeAsc(t *testing.T) { ui := getAnalyzedPathWithSorting("size", "asc", true) assert.Equal(t, 4, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "ddd") assert.Contains(t, ui.table.GetCell(1, 0).Text, "aaa") assert.Contains(t, ui.table.GetCell(2, 0).Text, "bbb") assert.Contains(t, ui.table.GetCell(3, 0).Text, "ccc") } func TestAnalyzeBySize(t *testing.T) { ui := getAnalyzedPathWithSorting("size", "desc", false) assert.Equal(t, 4, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") assert.Contains(t, ui.table.GetCell(2, 0).Text, "aaa") assert.Contains(t, ui.table.GetCell(3, 0).Text, "ddd") } func TestSortBySizeAsc(t *testing.T) { ui := getAnalyzedPathWithSorting("size", "asc", false) assert.Equal(t, 4, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "ddd") assert.Contains(t, ui.table.GetCell(1, 0).Text, "aaa") assert.Contains(t, ui.table.GetCell(2, 0).Text, "bbb") assert.Contains(t, ui.table.GetCell(3, 0).Text, "ccc") } func TestAnalyzeByName(t *testing.T) { ui := getAnalyzedPathWithSorting("name", "desc", false) assert.Equal(t, 4, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "ddd") assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") assert.Contains(t, ui.table.GetCell(2, 0).Text, "bbb") assert.Contains(t, ui.table.GetCell(3, 0).Text, "aaa") } func TestAnalyzeByNameAsc(t *testing.T) { ui := getAnalyzedPathWithSorting("name", "asc", false) assert.Equal(t, 4, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "aaa") assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") assert.Contains(t, ui.table.GetCell(2, 0).Text, "ccc") assert.Contains(t, ui.table.GetCell(3, 0).Text, "ddd") } func TestAnalyzeByItemCount(t *testing.T) { ui := getAnalyzedPathWithSorting("itemCount", "desc", false) assert.Equal(t, 4, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "ddd") assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") assert.Contains(t, ui.table.GetCell(2, 0).Text, "bbb") assert.Contains(t, ui.table.GetCell(3, 0).Text, "aaa") } func TestAnalyzeByItemCountAsc(t *testing.T) { ui := getAnalyzedPathWithSorting("itemCount", "asc", false) assert.Equal(t, 4, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "aaa") assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") assert.Contains(t, ui.table.GetCell(2, 0).Text, "ccc") assert.Contains(t, ui.table.GetCell(3, 0).Text, "ddd") } func TestAnalyzeByMtime(t *testing.T) { ui := getAnalyzedPathWithSorting("mtime", "desc", false) assert.Equal(t, 4, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "aaa") assert.Contains(t, ui.table.GetCell(1, 0).Text, "bbb") assert.Contains(t, ui.table.GetCell(2, 0).Text, "ccc") assert.Contains(t, ui.table.GetCell(3, 0).Text, "ddd") } func TestAnalyzeByMtimeAsc(t *testing.T) { ui := getAnalyzedPathWithSorting("mtime", "asc", false) assert.Equal(t, 4, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "ddd") assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") assert.Contains(t, ui.table.GetCell(2, 0).Text, "bbb") assert.Contains(t, ui.table.GetCell(3, 0).Text, "aaa") } func TestSetSorting(t *testing.T) { ui := getAnalyzedPathWithSorting("itemCount", "asc", false) ui.setSorting("name") assert.Equal(t, "name", ui.sortBy) assert.Equal(t, "asc", ui.sortOrder) ui.setSorting("name") assert.Equal(t, "name", ui.sortBy) assert.Equal(t, "desc", ui.sortOrder) ui.setSorting("name") assert.Equal(t, "name", ui.sortBy) assert.Equal(t, "asc", ui.sortOrder) } func TestSetDEfaultSorting(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() var opts []Option opts = append(opts, func(ui *UI) { ui.SetDefaultSorting("name", "asc") }) app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, opts...) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) if err := ui.AnalyzePath("test_dir", nil); err != nil { panic(err) } <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "name", ui.sortBy) assert.Equal(t, "asc", ui.sortOrder) } func TestSortDevicesByName(t *testing.T) { app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) defer simScreen.Fini() ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false) err := ui.ListDevices(getDevicesInfoMock()) assert.Nil(t, err) ui.setSorting("name") // sort by name asc assert.Equal(t, "/dev/boot", ui.devices[0].Name) ui.setSorting("name") // sort by name desc assert.Equal(t, "/dev/root", ui.devices[0].Name) } func TestSortDevicesByUsedSize(t *testing.T) { app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) defer simScreen.Fini() ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false) err := ui.ListDevices(getDevicesInfoMock()) assert.Nil(t, err) ui.setSorting("size") // sort by used size asc assert.Equal(t, "/dev/boot", ui.devices[0].Name) ui.setSorting("size") // sort by used size desc assert.Equal(t, "/dev/root", ui.devices[0].Name) } func getAnalyzedPathWithSorting(sortBy string, sortOrder string, apparentSize bool) *UI { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, apparentSize, false, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) ui.sortBy = sortBy ui.sortOrder = sortOrder if err := ui.AnalyzePath("test_dir", nil); err != nil { panic(err) } <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } return ui } gdu-5.36.1/tui/status.go000066400000000000000000000035001517447455500150570ustar00rootroot00000000000000package tui import ( "fmt" "time" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) func (ui *UI) toggleStatusBar(show bool) { var textColor, textBgColor tcell.Color if ui.UseColors { textColor = tcell.NewRGBColor(0, 0, 0) textBgColor = tcell.NewRGBColor(36, 121, 208) } else { textColor = tcell.NewRGBColor(0, 0, 0) textBgColor = tcell.NewRGBColor(255, 255, 255) } ui.grid.Clear() ui.statusMut.Lock() defer ui.statusMut.Unlock() if show { ui.status = tview.NewTextView().SetDynamicColors(true) ui.status.SetTextColor(textColor) ui.status.SetBackgroundColor(textBgColor) ui.grid.SetRows(1, 1, 0, 1, 1) ui.grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false). AddItem(ui.table, 2, 0, 1, 1, 0, 0, true). AddItem(ui.status, 3, 0, 1, 1, 0, 0, false). AddItem(ui.footer, 4, 0, 1, 1, 0, 0, false) return } ui.status = nil ui.grid.SetRows(1, 1, 0, 1) ui.grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false). AddItem(ui.table, 2, 0, 1, 1, 0, 0, true). AddItem(ui.footer, 3, 0, 1, 1, 0, 0, false) } func (ui *UI) updateStatusWorker() { for { ui.updateStatus() time.Sleep(500 * time.Millisecond) } } func (ui *UI) updateStatus() { ui.workersMut.Lock() cnt := ui.activeWorkers ui.workersMut.Unlock() ui.statusMut.RLock() status := ui.status ui.statusMut.RUnlock() if cnt == 0 && status == nil { return } if cnt > 0 && status == nil { ui.app.QueueUpdateDraw(func() { ui.toggleStatusBar(true) }) } else if cnt == 0 { ui.app.QueueUpdateDraw(func() { ui.toggleStatusBar(false) }) return } ui.app.QueueUpdateDraw(func() { msg := fmt.Sprintf(" Active background deletions: %d", cnt) ui.statusMut.RLock() ui.status.SetText(msg) ui.statusMut.RUnlock() }) } gdu-5.36.1/tui/tui.go000066400000000000000000000402231517447455500143400ustar00rootroot00000000000000package tui import ( "fmt" "io" "os" "os/signal" "runtime" "sync" "syscall" "time" log "github.com/sirupsen/logrus" "github.com/dundee/gdu/v5/internal/common" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/device" "github.com/dundee/gdu/v5/pkg/fs" "github.com/dundee/gdu/v5/pkg/remove" "github.com/dundee/gdu/v5/pkg/timefilter" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) // UI struct type UI struct { app common.TermApplication screen tcell.Screen output io.Writer currentDir fs.Item topDir fs.Item getter device.DevicesInfoGetter *common.UI grid *tview.Grid header *tview.TextView footer *tview.Flex footerLabel *tview.TextView currentDirLabel *tview.TextView pages *tview.Pages progress *tview.TextView progressBar *ProgressBar status *tview.TextView help *tview.Flex table *tview.Table filteringInput *tview.InputField typeFilteringInput *tview.InputField done chan struct{} remover func(fs.Item, fs.Item) error emptier func(fs.Item, fs.Item) error exec func(argv0 string, argv []string, envv []string) error changeCwdFn func(string) error linkedItems fs.HardLinkedItems ignoredRows map[int]struct{} markedRows map[int]struct{} markedPaths []string deleteQueue chan deleteQueueItem resultRow ResultRow topDirPath string currentDirPath string filterValue string typeFilterValue string sortBy string sortOrder string footerTextColor string footerBackgroundColor string footerNumberColor string headerTextColor string headerBackgroundColor string defaultSortBy string defaultSortOrder string exportName string devices []*device.Device selectedTextColor tcell.Color selectedBackgroundColor tcell.Color currentItemNameMaxLen int activeWorkers int deleteWorkersCount int statusMut sync.RWMutex workersMut sync.Mutex askBeforeDelete bool showItemCount bool showMtime bool filtering bool typeFiltering bool headerHidden bool useOldSizeBar bool noDelete bool noViewFile bool noSpawnShell bool deleteInBackground bool timeFilter *timefilter.TimeFilter timeFilterLoc *time.Location noDeleteWithFilter bool collapsePath bool browseParentDirs bool showDiskProgressBar bool currentDeviceSize int64 } type deleteQueueItem struct { item fs.Item shouldEmpty bool } // ResultRow is a struct for a row in the result table type ResultRow struct { NumberColor string DirectoryColor string } // Option is optional function customizing the behaviour of UI type Option func(ui *UI) // CreateUI creates the whole UI app func CreateUI( app common.TermApplication, screen tcell.Screen, output io.Writer, useColors bool, showApparentSize bool, showRelativeSize bool, useSIPrefix bool, opts ...Option, ) *UI { ui := &UI{ UI: &common.UI{ UseColors: useColors, ShowApparentSize: showApparentSize, ShowRelativeSize: showRelativeSize, Analyzer: analyze.CreateAnalyzer(), UseSIPrefix: useSIPrefix, }, app: app, screen: screen, output: output, askBeforeDelete: true, showItemCount: false, remover: remove.ItemFromDir, emptier: remove.EmptyFileFromDir, exec: Execute, linkedItems: make(fs.HardLinkedItems, 10), selectedTextColor: tview.Styles.TitleColor, selectedBackgroundColor: tview.Styles.MoreContrastBackgroundColor, currentItemNameMaxLen: 70, defaultSortBy: "size", defaultSortOrder: "desc", ignoredRows: make(map[int]struct{}), markedRows: make(map[int]struct{}), exportName: "export.json", noDelete: false, noViewFile: false, noSpawnShell: false, deleteQueue: make(chan deleteQueueItem, 1000), deleteWorkersCount: 3 * runtime.GOMAXPROCS(0), } for _, o := range opts { o(ui) } ui.resetSorting() app.SetBeforeDrawFunc(func(screen tcell.Screen) bool { screen.Clear() return false }) ui.app.SetInputCapture(ui.keyPressed) ui.app.SetMouseCapture(ui.onMouse) ui.header = tview.NewTextView() ui.header.SetText(" gdu ~ Use arrow keys to navigate, press ? for help ") ui.header.SetTextColor(tcell.GetColor(ui.headerTextColor)) ui.header.SetBackgroundColor(tcell.GetColor(ui.headerBackgroundColor)) ui.currentDirLabel = tview.NewTextView() ui.currentDirLabel.SetTextColor(tcell.ColorDefault) ui.currentDirLabel.SetBackgroundColor(tcell.ColorDefault) ui.table = tview.NewTable().SetSelectable(true, false) ui.table.SetBackgroundColor(tcell.ColorDefault) ui.table.SetSelectedFunc(ui.fileItemSelected) if ui.UseColors { ui.table.SetSelectedStyle(tcell.Style{}. Foreground(ui.selectedTextColor). Background(ui.selectedBackgroundColor).Bold(true)) } else { ui.table.SetSelectedStyle(tcell.Style{}. Foreground(tcell.ColorWhite). Background(tcell.ColorGray).Bold(true)) } ui.footerLabel = tview.NewTextView().SetDynamicColors(true) ui.footerLabel.SetTextColor(tcell.GetColor(ui.footerTextColor)) ui.footerLabel.SetBackgroundColor(tcell.GetColor(ui.footerBackgroundColor)) ui.footerLabel.SetText(" No items to display. ") ui.footer = tview.NewFlex() ui.footer.AddItem(ui.footerLabel, 0, 1, false) ui.createGrid() ui.pages = tview.NewPages(). AddPage("background", ui.grid, true, true) ui.pages.SetBackgroundColor(tcell.ColorDefault) ui.app.SetRoot(ui.pages, true) return ui } // createGrid creates the main grid layout func (ui *UI) createGrid() { if ui.headerHidden { ui.grid = tview.NewGrid().SetRows(1, 0, 1).SetColumns(0) ui.grid.AddItem(ui.currentDirLabel, 0, 0, 1, 1, 0, 0, false). AddItem(ui.table, 1, 0, 1, 1, 0, 0, true). AddItem(ui.footer, 2, 0, 1, 1, 0, 0, false) } else { ui.grid = tview.NewGrid().SetRows(1, 1, 0, 1).SetColumns(0) ui.grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false). AddItem(ui.table, 2, 0, 1, 1, 0, 0, true). AddItem(ui.footer, 3, 0, 1, 1, 0, 0, false) } } // SetSelectedTextColor sets the color for the highlighted selected text func (ui *UI) SetSelectedTextColor(color tcell.Color) { ui.selectedTextColor = color } // SetSelectedBackgroundColor sets the color for the highlighted selected text func (ui *UI) SetSelectedBackgroundColor(color tcell.Color) { ui.selectedBackgroundColor = color } // SetFooterTextColor sets the color for the footer text func (ui *UI) SetFooterTextColor(color string) { ui.footerTextColor = color } // SetFooterBackgroundColor sets the color for the footer background func (ui *UI) SetFooterBackgroundColor(color string) { ui.footerBackgroundColor = color } // SetFooterNumberColor sets the color for the footer number func (ui *UI) SetFooterNumberColor(color string) { ui.footerNumberColor = color } // SetHeaderTextColor sets the color for the header text func (ui *UI) SetHeaderTextColor(color string) { ui.headerTextColor = color } // SetHeaderBackgroundColor sets the color for the header background func (ui *UI) SetHeaderBackgroundColor(color string) { ui.headerBackgroundColor = color } // SetHeaderHidden sets the flag to hide the header func (ui *UI) SetHeaderHidden() { ui.headerHidden = true } // SetResultRowDirectoryColor sets the color for the result row directory func (ui *UI) SetResultRowDirectoryColor(color string) { ui.resultRow.DirectoryColor = color } // SetResultRowNumberColor sets the color for the result row number func (ui *UI) SetResultRowNumberColor(color string) { ui.resultRow.NumberColor = color } // SetCurrentItemNameMaxLen sets the maximum length of the path of the currently processed item // to be shown in the progress modal func (ui *UI) SetCurrentItemNameMaxLen(maxLen int) { ui.currentItemNameMaxLen = maxLen } // UseOldSizeBar uses the old size bar (# chars) instead of the new one (unicode block elements) func (ui *UI) UseOldSizeBar() { ui.useOldSizeBar = true } // SetChangeCwdFn sets function that can be used to change current working dir // during dir browsing func (ui *UI) SetChangeCwdFn(fn func(string) error) { ui.changeCwdFn = fn } // SetDeleteInParallel sets the flag to delete files in parallel func (ui *UI) SetDeleteInParallel() { ui.remover = remove.ItemFromDirParallel } // StartUILoop starts tview application func (ui *UI) StartUILoop() error { go func() { c := make(chan os.Signal, 1) signal.Notify( c, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGILL, syscall.SIGTRAP, syscall.SIGABRT, syscall.SIGPIPE, syscall.SIGTERM, ) s := <-c log.Printf("Got signal: %s", s) ui.app.QueueUpdateDraw(func() { ui.printMarkedPaths() ui.app.Stop() }) }() return ui.app.Run() } // SetShowItemCount sets the flag to show number of items in directory func (ui *UI) SetShowItemCount() { ui.showItemCount = true } // SetShowMTime sets the flag to show last modification time of items in directory func (ui *UI) SetShowMTime() { ui.showMtime = true } // SetNoDelete disables all write operations func (ui *UI) SetNoDelete() { ui.noDelete = true } // SetNoSpawnShell disables shell spawning func (ui *UI) SetNoSpawnShell() { ui.noSpawnShell = true } func (ui *UI) SetNoViewFile() { ui.noViewFile = true } // SetNoDelete disables delete when time filters are active func (ui *UI) SetNoDeleteWithFilter() { ui.noDeleteWithFilter = true } // SetBrowseParentDirs enables navigating above the launch directory func (ui *UI) SetBrowseParentDirs() { ui.browseParentDirs = true } // SetCollapsePath sets the flag to collapse paths func (ui *UI) SetCollapsePath(value bool) { ui.collapsePath = value } // SetShowDiskProgressBar sets whether to show a progress bar when scanning a whole disk func (ui *UI) SetShowDiskProgressBar(value bool) { ui.showDiskProgressBar = value } // SetDeleteInBackground sets the flag to delete files in background func (ui *UI) SetDeleteInBackground() { ui.deleteInBackground = true for i := 0; i < ui.deleteWorkersCount; i++ { go ui.deleteWorker() } go ui.updateStatusWorker() } func (ui *UI) resetSorting() { ui.sortBy = ui.defaultSortBy ui.sortOrder = ui.defaultSortOrder } func (ui *UI) rescanDir() { ui.Analyzer.ResetProgress() ui.linkedItems = make(fs.HardLinkedItems) err := ui.AnalyzePath(ui.currentDirPath, ui.currentDir.GetParent()) if err != nil { ui.showErr("Error rescanning path", err) } } func (ui *UI) fileItemSelected(row, column int) { if ui.currentDir == nil { return // Add this check to handle nil case } selectedDirCell := ui.table.GetCell(row, column) // Check if the selectedDirCell is nil before using it if selectedDirCell == nil || selectedDirCell.GetReference() == nil { return } selectedDir := selectedDirCell.GetReference().(fs.Item) if selectedDir == nil || !selectedDir.IsDir() { return } origDir := ui.currentDir ui.currentDir = selectedDir ui.hideFilterInput() ui.hideTypeFilterInput() ui.markedRows = make(map[int]struct{}) ui.ignoredRows = make(map[int]struct{}) ui.showDir() if row != 0 || origDir.GetPath() == ui.topDir.GetPath() { return } // we are going up in the directory tree, select the last visited directory if origDir.GetParent() != nil { nestedDir := origDir for nestedDir.GetParent() != nil { if selectedDir.GetName() == nestedDir.GetParent().GetName() { sortBy, sortOrder := ui.getSortParams() index := -1 i := 0 for item := range ui.currentDir.GetFiles(sortBy, sortOrder) { if item.GetName() == nestedDir.GetName() { index = i break } i++ } if index >= 0 { if ui.currentDir.GetPath() != ui.topDir.GetPath() { index++ } ui.table.Select(index, 0) } break } nestedDir = nestedDir.GetParent() } } } func (ui *UI) deviceItemSelected(row, column int) { var err error selectedDevice, ok := ui.table.GetCell(row, column).GetReference().(*device.Device) if !ok { return } paths := device.GetNestedMountpointsPaths(selectedDevice.MountPoint, ui.devices) ui.IgnoreDirPathPatterns, err = common.CreateIgnorePattern(paths) if err != nil { log.Printf("Creating path patterns for other devices failed: %s", paths) } ui.resetSorting() ui.currentDeviceSize = selectedDevice.Size ui.Analyzer.ResetProgress() ui.linkedItems = make(fs.HardLinkedItems) err = ui.AnalyzePath(selectedDevice.MountPoint, nil) if err != nil { ui.showErr("Error analyzing device", err) } } func (ui *UI) confirmDeletion(shouldEmpty bool) { if ui.noDelete { previousHeaderText := ui.header.GetText(false) // show feedback to user ui.header.SetText(" Deletion is disabled!") go func() { time.Sleep(2 * time.Second) ui.app.QueueUpdateDraw(func() { ui.header.Clear() ui.header.SetText(previousHeaderText) }) }() return } // Check if deletion is allowed with active time filters if ui.noDeleteWithFilter { modal := tview.NewModal(). SetText("Deletion is disabled when a time filter is active.\n\n" + "To override, set GDU_ALLOW_DELETE_WITH_FILTER=1"). AddButtons([]string{"OK"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { ui.pages.RemovePage("confirm") }) if !ui.UseColors { modal.SetBackgroundColor(tcell.ColorGray) } ui.pages.AddPage("confirm", modal, true, true) return } if len(ui.markedRows) > 0 { ui.confirmDeletionMarked(shouldEmpty) } else { ui.confirmDeletionSelected(shouldEmpty) } } func (ui *UI) confirmDeletionSelected(shouldEmpty bool) { row, column := ui.table.GetSelection() selectedFile := ui.table.GetCell(row, column).GetReference().(fs.Item) var action string if shouldEmpty { action = "empty" } else { action = "delete" } modal := tview.NewModal(). SetText( "Are you sure you want to " + action + " \"" + tview.Escape(selectedFile.GetName()) + "\"?", ). AddButtons([]string{"no", "yes", "don't ask me again"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { switch buttonIndex { case 2: ui.askBeforeDelete = false fallthrough case 1: ui.deleteSelected(shouldEmpty) } ui.pages.RemovePage("confirm") }) if !ui.UseColors { modal.SetBackgroundColor(tcell.ColorGray) } else { modal.SetBackgroundColor(tcell.ColorBlack) } modal.SetBorderColor(tcell.ColorDefault) ui.pages.AddPage("confirm", modal, true, true) } // SetTimeFilterWithInfo sets both the time filter function and stores the filter info for display func (ui *UI) SetTimeFilterWithInfo(tf *timefilter.TimeFilter, loc *time.Location) { ui.timeFilter = tf ui.timeFilterLoc = loc if tf != nil && !tf.IsEmpty() { timeFilterFunc := func(mtime time.Time) bool { return tf.IncludeByTimeFilter(mtime, loc) } ui.SetTimeFilter(timeFilterFunc) if !ui.isDeleteAllowedWithFilter() { ui.SetNoDeleteWithFilter() } } } // hasActiveTimeFilter returns true if any time filter is active func (ui *UI) hasActiveTimeFilter() bool { return ui.timeFilter != nil && !ui.timeFilter.IsEmpty() } // formatTimeFilterInfo formats the time filter information for display func (ui *UI) formatTimeFilterInfo() string { if !ui.hasActiveTimeFilter() { return "" } return ui.timeFilter.FormatForDisplay(ui.timeFilterLoc) } // isDeleteAllowedWithFilter checks if deletion is allowed when filters are active func (ui *UI) isDeleteAllowedWithFilter() bool { if !ui.hasActiveTimeFilter() { return true } // Check environment variable override if os.Getenv("GDU_ALLOW_DELETE_WITH_FILTER") == "1" { return true } return false } // printMarkedPaths prints the paths of the marked items to the output func (ui *UI) printMarkedPaths() { for _, path := range ui.markedPaths { fmt.Fprintf(ui.output, "%s\n", path) } } gdu-5.36.1/tui/tui_test.go000066400000000000000000000602551517447455500154060ustar00rootroot00000000000000package tui import ( "bytes" "errors" "fmt" "os" "testing" "time" log "github.com/sirupsen/logrus" "github.com/dundee/gdu/v5/internal/testanalyze" "github.com/dundee/gdu/v5/internal/testapp" "github.com/dundee/gdu/v5/internal/testdev" "github.com/dundee/gdu/v5/internal/testdir" "github.com/dundee/gdu/v5/pkg/analyze" "github.com/dundee/gdu/v5/pkg/device" "github.com/dundee/gdu/v5/pkg/fs" "github.com/gdamore/tcell/v2" "github.com/stretchr/testify/assert" ) func init() { log.SetLevel(log.WarnLevel) } func TestFooter(t *testing.T) { app, simScreen := testapp.CreateTestAppWithSimScreen(15, 15) defer simScreen.Fini() ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) dir := &analyze.Dir{ File: &analyze.File{ Name: "xxx", Size: 5, Usage: 4096, }, BasePath: ".", ItemCount: 2, } file := &analyze.File{ Name: "yyy", Size: 2, Usage: 4096, Parent: dir, } dir.Files = fs.Files{file} ui.currentDir = dir ui.showDir() ui.pages.HidePage("progress") ui.footerLabel.Draw(simScreen) simScreen.Show() b, _, _ := simScreen.GetContents() // printScreen(simScreen) text := []byte(" Total disk usage: 4.0 KiB Apparent size: 2 B Items: 1") for i, r := range b { if i >= len(text) { break } assert.Equal(t, string(text[i]), string(r.Bytes[0]), fmt.Sprintf("Index: %d", i)) } } func TestUpdateProgress(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) done := ui.Analyzer.GetDone() done.Broadcast() ui.updateProgress(ui.Analyzer, done) } func TestSetShowDiskProgressBar(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false) ui.SetShowDiskProgressBar(true) assert.True(t, ui.showDiskProgressBar) ui.SetShowDiskProgressBar(false) assert.False(t, ui.showDiskProgressBar) } func TestDeviceSelectedSetsCurrentDeviceSize(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.done = make(chan struct{}) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.ListDevices(getDevicesInfoMock()) assert.Nil(t, err) ui.deviceItemSelected(1, 0) // currentDeviceSize must be set to the selected device's Size before analysis starts. assert.Equal(t, int64(1e12), ui.currentDeviceSize) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } } func TestHelp(t *testing.T) { app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) defer simScreen.Fini() ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.showHelp() assert.True(t, ui.pages.HasPage("help")) ui.help.Draw(simScreen) simScreen.Show() // printScreen(simScreen) b, _, _ := simScreen.GetContents() cells := b[557 : 557+9] text := []byte("directory") for i, r := range cells { assert.Equal(t, text[i], r.Bytes[0]) } } func TestHelpBw(t *testing.T) { app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50) defer simScreen.Fini() ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.showHelp() ui.help.Draw(simScreen) simScreen.Show() // printScreen(simScreen) b, _, _ := simScreen.GetContents() cells := b[557 : 557+9] text := []byte("directory") for i, r := range cells { assert.Equal(t, text[i], r.Bytes[0]) } } func TestAppRun(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(false) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) err := ui.StartUILoop() assert.Nil(t, err) } func TestAppRunWithErr(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) err := ui.StartUILoop() assert.Equal(t, "Fail", err.Error()) } func TestRescanDir(t *testing.T) { parentDir := &analyze.Dir{ File: &analyze.File{ Name: "parent", }, Files: make([]fs.Item, 0, 1), } currentDir := &analyze.Dir{ File: &analyze.File{ Name: "sub", Parent: parentDir, }, } simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.done = make(chan struct{}) ui.Analyzer = &testanalyze.MockedAnalyzer{} ui.currentDir = currentDir ui.topDir = parentDir ui.rescanDir() <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) assert.Equal(t, parentDir, ui.currentDir.GetParent()) assert.Equal(t, 5, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "/..") assert.Contains(t, ui.table.GetCell(1, 0).Text, "ccc") } func TestDirSelected(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, true, false, false) ui.done = make(chan struct{}) ui.fileItemSelected(0, 0) assert.Equal(t, 3, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "/..") assert.Contains(t, ui.table.GetCell(1, 0).Text, "subnested") } func TestFileSelected(t *testing.T) { ui := getAnalyzedPathMockedApp(t, true, true, true) ui.fileItemSelected(3, 0) assert.Equal(t, 4, ui.table.GetRowCount()) assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") } func TestSelectedWithoutCurrentDir(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.fileItemSelected(1, 0) assert.Nil(t, ui.currentDir) } func TestBeforeDraw(t *testing.T) { screen := tcell.NewSimulationScreen("UTF-8") err := screen.Init() assert.Nil(t, err) app := testapp.CreateMockedApp(true) ui := CreateUI(app, screen, &bytes.Buffer{}, false, true, false, false) for _, f := range ui.app.(*testapp.MockedApp).BeforeDraws { assert.False(t, f(screen)) } } func TestIgnorePaths(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.SetIgnoreDirPaths([]string{"/aaa", "/bbb"}) assert.True(t, ui.ShouldDirBeIgnored("aaa", "/aaa")) assert.True(t, ui.ShouldDirBeIgnored("bbb", "/bbb")) assert.False(t, ui.ShouldDirBeIgnored("ccc", "/ccc")) } func TestConfirmDeletion(t *testing.T) { ui := getAnalyzedPathMockedApp(t, true, true, true) ui.table.Select(1, 0) ui.confirmDeletion(false) assert.True(t, ui.pages.HasPage("confirm")) } func TestConfirmDeletionBW(t *testing.T) { ui := getAnalyzedPathMockedApp(t, false, true, true) ui.table.Select(1, 0) ui.confirmDeletion(false) assert.True(t, ui.pages.HasPage("confirm")) } func TestConfirmEmpty(t *testing.T) { ui := getAnalyzedPathMockedApp(t, false, true, true) ui.table.Select(1, 0) ui.confirmDeletion(true) assert.True(t, ui.pages.HasPage("confirm")) } func TestConfirmEmptyMarked(t *testing.T) { ui := getAnalyzedPathMockedApp(t, false, true, true) ui.table.Select(1, 0) ui.markedRows[1] = struct{}{} ui.confirmDeletion(true) assert.True(t, ui.pages.HasPage("confirm")) } func TestConfirmDeletionMarked(t *testing.T) { ui := getAnalyzedPathMockedApp(t, true, true, true) ui.table.Select(1, 0) ui.markedRows[1] = struct{}{} ui.confirmDeletion(false) assert.True(t, ui.pages.HasPage("confirm")) } func TestConfirmDeletionMarkedBW(t *testing.T) { ui := getAnalyzedPathMockedApp(t, false, true, true) ui.table.Select(1, 0) ui.markedRows[1] = struct{}{} ui.confirmDeletion(false) assert.True(t, ui.pages.HasPage("confirm")) } func TestDeleteSelected(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) ui.done = make(chan struct{}) assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.deleteSelected(false) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.NoDirExists(t, "test_dir/nested") } func TestDeleteSelectedInParallel(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) ui.done = make(chan struct{}) ui.SetDeleteInParallel() assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.deleteSelected(false) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.NoDirExists(t, "test_dir/nested") } func TestDeleteSelectedInBackground(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, true, true, false) ui.remover = testanalyze.ItemFromDirWithSleep ui.done = make(chan struct{}) ui.SetDeleteInBackground() assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.deleteSelected(false) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.NoDirExists(t, "test_dir/nested") } func TestDeleteSelectedInBackgroundAndParallel(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, true, true, false) ui.remover = testanalyze.ItemFromDirWithSleep ui.done = make(chan struct{}) ui.SetDeleteInBackground() ui.SetDeleteInParallel() assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.deleteSelected(false) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.NoDirExists(t, "test_dir/nested") } func TestDeleteSelectedInBackgroundBW(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) ui.done = make(chan struct{}) ui.SetDeleteInBackground() assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.deleteSelected(false) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.NoDirExists(t, "test_dir/nested") } func TestEmptyDirInBackground(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, true, true, false) ui.done = make(chan struct{}) ui.SetDeleteInBackground() assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.deleteSelected(true) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.DirExists(t, "test_dir/nested") assert.NoDirExists(t, "test_dir/nested/subnested") } func TestEmptyFileInBackground(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, true, true, false) ui.done = make(chan struct{}) ui.SetDeleteInBackground() assert.Equal(t, 1, ui.table.GetRowCount()) ui.fileItemSelected(0, 0) // nested ui.table.Select(2, 0) ui.deleteSelected(true) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.DirExists(t, "test_dir/nested") assert.FileExists(t, "test_dir/nested/file2") f, err := os.Open("test_dir/nested/file2") assert.Nil(t, err) info, err := f.Stat() assert.Nil(t, err) assert.Equal(t, int64(0), info.Size()) } func TestDeleteSelectedWithErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) ui.remover = testanalyze.ItemFromDirWithErr assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.delete(false) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.True(t, ui.pages.HasPage("error")) assert.DirExists(t, "test_dir/nested") } func TestDeleteSelectedInBackgroundWithErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) ui.SetDeleteInBackground() ui.remover = testanalyze.ItemFromDirWithSleepAndErr assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.delete(false) <-ui.done // change the status for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } // wait for status to be removed time.Sleep(500 * time.Millisecond) for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.True(t, ui.pages.HasPage("error")) assert.DirExists(t, "test_dir/nested") } func TestDeleteMarkedWithErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) ui.remover = testanalyze.ItemFromDirWithErr assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.markedRows[0] = struct{}{} ui.deleteMarked(false) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.True(t, ui.pages.HasPage("error")) assert.DirExists(t, "test_dir/nested") } func TestDeleteMarkedInBackground(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) ui.SetDeleteInBackground() assert.Equal(t, 1, ui.table.GetRowCount()) ui.fileItemSelected(0, 0) // nested ui.markedRows[1] = struct{}{} // subnested ui.markedRows[2] = struct{}{} // file2 ui.deleteMarked(false) <-ui.done // wait for deletion of subnested <-ui.done // wait for deletion of file2 for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.DirExists(t, "test_dir/nested") assert.NoDirExists(t, "test_dir/nested/subnested") assert.NoFileExists(t, "test_dir/nested/file2") } func TestDeleteMarkedInBackgroundWithStorage(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) ui.SetAnalyzer(analyze.CreateStoredAnalyzer("/tmp/badger")) ui.SetDeleteInBackground() assert.Equal(t, 1, ui.table.GetRowCount()) ui.fileItemSelected(0, 0) // nested ui.markedRows[1] = struct{}{} // subnested ui.markedRows[2] = struct{}{} // file2 ui.deleteMarked(false) <-ui.done // wait for deletion of subnested <-ui.done // wait for deletion of file2 for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.DirExists(t, "test_dir/nested") assert.NoDirExists(t, "test_dir/nested/subnested") assert.NoFileExists(t, "test_dir/nested/file2") } func TestDeleteMarkedInBackgroundWithStorageAndParallel(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) ui.SetAnalyzer(analyze.CreateStoredAnalyzer("/tmp/badger")) ui.SetDeleteInBackground() ui.SetDeleteInParallel() assert.Equal(t, 1, ui.table.GetRowCount()) ui.fileItemSelected(0, 0) // nested ui.markedRows[1] = struct{}{} // subnested ui.markedRows[2] = struct{}{} // file2 ui.deleteMarked(false) <-ui.done // wait for deletion of subnested <-ui.done // wait for deletion of file2 for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.DirExists(t, "test_dir/nested") assert.NoDirExists(t, "test_dir/nested/subnested") assert.NoFileExists(t, "test_dir/nested/file2") } func TestDeleteMarkedInBackgroundWithErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) ui.SetDeleteInBackground() ui.remover = testanalyze.ItemFromDirWithErr assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) ui.markedRows[0] = struct{}{} ui.deleteMarked(false) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.True(t, ui.pages.HasPage("error")) assert.DirExists(t, "test_dir/nested") } func TestShowErr(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false) ui.showErr("Something went wrong", errors.New("error")) assert.True(t, ui.pages.HasPage("error")) } func TestShowErrBW(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.showErr("Something went wrong", errors.New("error")) assert.True(t, ui.pages.HasPage("error")) } func TestMin(t *testing.T) { assert.Equal(t, 2, min(2, 5)) assert.Equal(t, 3, min(4, 3)) } func TestSetStyles(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() opts := []Option{} opts = append(opts, func(ui *UI) { ui.SetHeaderHidden() }) app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, opts...) ui.SetSelectedBackgroundColor(tcell.ColorRed) ui.SetSelectedTextColor(tcell.ColorRed) ui.SetFooterTextColor("red") ui.SetFooterBackgroundColor("red") ui.SetFooterNumberColor("red") ui.SetHeaderTextColor("red") ui.SetHeaderBackgroundColor("red") ui.SetResultRowDirectoryColor("red") ui.SetResultRowNumberColor("red") assert.Equal(t, ui.selectedBackgroundColor, tcell.ColorRed) assert.Equal(t, ui.selectedTextColor, tcell.ColorRed) assert.Equal(t, ui.footerTextColor, "red") assert.Equal(t, ui.footerBackgroundColor, "red") assert.Equal(t, ui.footerNumberColor, "red") assert.Equal(t, ui.headerTextColor, "red") assert.Equal(t, ui.headerBackgroundColor, "red") assert.Equal(t, ui.headerHidden, true) assert.Equal(t, ui.resultRow.DirectoryColor, "red") assert.Equal(t, ui.resultRow.NumberColor, "red") } func TestSetCurrentItemNameMaxLen(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.SetCurrentItemNameMaxLen(5) assert.Equal(t, ui.currentItemNameMaxLen, 5) } func TestUseOldSizeBar(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.UseOldSizeBar() assert.Equal(t, ui.useOldSizeBar, true) } func TestSetShowItemCount(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.SetShowItemCount() assert.Equal(t, ui.showItemCount, true) } func TestSetShowMTime(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.SetShowMTime() assert.Equal(t, ui.showMtime, true) } func TestNoDelete(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.SetNoDelete() assert.Equal(t, ui.noDelete, true) } func TestNoSpawnShell(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.SetNoSpawnShell() assert.Equal(t, ui.noSpawnShell, true) } func TestNoViewFile(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false) ui.SetNoViewFile() assert.Equal(t, ui.noViewFile, true) } // nolint: unused // Why: for debugging func printScreen(simScreen tcell.SimulationScreen) { b, _, _ := simScreen.GetContents() for i, r := range b { if string(r.Bytes) != " " { println(i, string(r.Bytes)) } } } func getDevicesInfoMock() device.DevicesInfoGetter { item := &device.Device{ Name: "/dev/root", MountPoint: "test_dir", Size: 1e12, Free: 1e6, } item2 := &device.Device{ Name: "/dev/boot", MountPoint: "/boot", Size: 1e6, Free: 1e3, } mock := testdev.DevicesInfoGetterMock{} mock.Devices = []*device.Device{item, item2} return mock } func getAnalyzedPathMockedApp(t *testing.T, useColors, apparentSize, mockedAnalyzer bool) *UI { simScreen := testapp.CreateSimScreen() defer simScreen.Fini() app := testapp.CreateMockedApp(true) ui := CreateUI(app, simScreen, &bytes.Buffer{}, useColors, apparentSize, false, false) if mockedAnalyzer { ui.Analyzer = &testanalyze.MockedAnalyzer{} } ui.done = make(chan struct{}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) <-ui.done // wait for analyzer for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.Equal(t, "test_dir", ui.currentDir.GetName()) return ui } func TestConfirmDeletionSelectedButtonOrder(t *testing.T) { ui := getAnalyzedPathMockedApp(t, true, true, true) ui.table.Select(1, 0) ui.confirmDeletionSelected(false) // Verify confirmation page is created assert.True(t, ui.pages.HasPage("confirm")) } func TestConfirmDeletionSelectedSafeDefault(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) ui.done = make(chan struct{}) assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) // Create confirmation dialog ui.confirmDeletionSelected(false) // Verify that the confirmation dialog exists with safer defaults assert.DirExists(t, "test_dir/nested") assert.True(t, ui.pages.HasPage("confirm")) } func TestConfirmDeletionButtonIndexMapping(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) ui.done = make(chan struct{}) ui.askBeforeDelete = false // Skip confirmation for direct testing assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) // Test that deletion still works when explicitly called ui.deleteSelected(false) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.NoDirExists(t, "test_dir/nested") } func TestConfirmEmptySelectedSafeDefault(t *testing.T) { ui := getAnalyzedPathMockedApp(t, true, true, true) ui.table.Select(1, 0) ui.confirmDeletionSelected(true) // Verify empty confirmation dialog is created safely assert.True(t, ui.pages.HasPage("confirm")) } func TestConfirmDeletionMarkedSafeDefault(t *testing.T) { ui := getAnalyzedPathMockedApp(t, true, true, true) ui.table.Select(1, 0) ui.markedRows[1] = struct{}{} ui.confirmDeletionMarked(false) // Verify marked deletion confirmation dialog is created safely assert.True(t, ui.pages.HasPage("confirm")) } func TestConfirmEmptyMarkedSafeDefault(t *testing.T) { ui := getAnalyzedPathMockedApp(t, false, true, true) ui.table.Select(1, 0) ui.markedRows[1] = struct{}{} ui.confirmDeletionMarked(true) // Verify marked empty confirmation dialog is created safely assert.True(t, ui.pages.HasPage("confirm")) } func TestSaferConfirmationPreventDataLoss(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) // Test that creating confirmation dialog doesn't accidentally trigger deletion ui.confirmDeletionSelected(false) ui.confirmDeletionSelected(true) // empty // Directory should still exist - no accidental deletion assert.DirExists(t, "test_dir/nested") assert.True(t, ui.pages.HasPage("confirm")) } func TestConfirmDeletionSelectedCase1(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) ui.done = make(chan struct{}) assert.Equal(t, 1, ui.table.GetRowCount()) ui.table.Select(0, 0) // Test case 1 branch (yes button at index 1) by directly calling deleteSelected ui.deleteSelected(false) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.NoDirExists(t, "test_dir/nested") } func TestConfirmDeletionMarkedCase1(t *testing.T) { fin := testdir.CreateTestDir() defer fin() ui := getAnalyzedPathMockedApp(t, false, true, false) ui.done = make(chan struct{}) ui.fileItemSelected(0, 0) // nested ui.markedRows[1] = struct{}{} // subnested // Test case 1 branch (yes button at index 1) by directly calling deleteMarked ui.deleteMarked(false) <-ui.done for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { f() } assert.NoDirExists(t, "test_dir/nested/subnested") } gdu-5.36.1/tui/utils.go000066400000000000000000000102351517447455500146770ustar00rootroot00000000000000package tui import ( "path/filepath" "slices" "github.com/dundee/gdu/v5/pkg/device" "github.com/dundee/gdu/v5/pkg/fs" "github.com/rivo/tview" ) var ( barFullRune = "\u2588" barPartRunes = map[int]string{ 0: " ", 1: "\u258F", 2: "\u258E", 3: "\u258D", 4: "\u258C", 5: "\u258B", 6: "\u258A", 7: "\u2589", } ) func getDeviceUsagePart(item *device.Device, useOld bool) string { part := int(float64(item.Size-item.Free) / float64(item.Size) * 100.0) if useOld { return getUsageGraphOld(part) } return getUsageGraph(part) } func getUsageGraph(part int) string { graph := " " whole := part / 10 for i := 0; i < whole; i++ { graph += barFullRune } partWidth := (part % 10) * 8 / 10 if part < 100 { graph += barPartRunes[partWidth] } for i := 0; i < 10-whole-1; i++ { graph += " " } graph += "\u258F" return graph } func getUsageGraphOld(part int) string { part /= 10 graph := "[" for i := 0; i < 10; i++ { if part > i { graph += "#" } else { graph += " " } } graph += "]" return graph } func modal(p tview.Primitive, width, height int) tview.Primitive { return tview.NewFlex(). AddItem(nil, 0, 1, false). AddItem(tview.NewFlex().SetDirection(tview.FlexRow). AddItem(nil, 0, 1, false). AddItem(p, height, 1, true). AddItem(nil, 0, 1, false), width, 1, true). AddItem(nil, 0, 1, false) } // CollapsedPath represents a directory chain that can be collapsed into a single display entry. // For example, if directory "a" contains only directory "b", and "b" contains only "c", // this represents the collapsed path "a/b/c" that allows direct navigation to the deepest directory. type CollapsedPath struct { DisplayName string // The display name shown in the UI (e.g., "a/b/c") DeepestDir fs.Item // The actual deepest directory item Segments []string // Individual path segments of the collapsed chain } // findCollapsiblePath checks if the given directory item has a single subdirectory chain // and returns a CollapsedPath if it can be collapsed func findCollapsiblePath(item fs.Item) *CollapsedPath { if item == nil || !item.IsDir() { return nil } var segments []string current := item for { // Collect files to check count and types var files []fs.Item for file := range current.GetFiles(fs.SortByName, fs.SortAsc) { files = append(files, file) } if len(files) > 1 { break } // Count directories and files separately var subdirs []fs.Item var fileCount int for _, file := range files { if file.IsDir() { subdirs = append(subdirs, file) } else { fileCount++ } } // Only collapse if there's exactly one subdirectory AND no files if len(subdirs) != 1 || fileCount > 0 { break } // Add this segment to the path // nolint:staticcheck // the result is used segments = append(segments, subdirs[0].GetName()) current = subdirs[0] } // Only create collapsed path if we have at least one collapsible segment if len(segments) == 0 { return nil } return &CollapsedPath{ DisplayName: filepath.Join(slices.Concat([]string{item.GetName()}, segments)...), DeepestDir: current, Segments: segments, } } // findCollapsedParent checks if the current directory is the deepest directory // in a collapsed path, and returns the appropriate parent to navigate to func findCollapsedParent(currentDir fs.Item) fs.Item { if currentDir == nil { return nil } if currentDir.GetParent() == nil { return nil } // Check if current directory is part of a single-child chain going up current := currentDir var chainParent fs.Item // Walk up the parent chain for current.GetParent() != nil { parent := current.GetParent() // Count files in parent fileCount := 0 for range parent.GetFiles(fs.SortByName, fs.SortAsc) { fileCount++ if fileCount > 1 { break } } // If parent has more than one item, this is where the collapsed chain starts if fileCount > 1 { chainParent = parent break } // Move up the chain current = parent } // If we found a chain parent (meaning current dir is part of a collapsed path), // return it, otherwise return the normal parent if chainParent != nil { return chainParent } return currentDir.GetParent() } gdu-5.36.1/tui/utils_test.go000066400000000000000000000025711517447455500157420ustar00rootroot00000000000000package tui import ( "testing" "github.com/stretchr/testify/assert" ) func TestGetUsageGraph(t *testing.T) { assert.Equal(t, " \u258F", getUsageGraph(0)) assert.Equal(t, " █ \u258F", getUsageGraph(10)) assert.Equal(t, " ██ \u258F", getUsageGraph(20)) assert.Equal(t, " ███ \u258F", getUsageGraph(30)) assert.Equal(t, " ████ \u258F", getUsageGraph(40)) assert.Equal(t, " █████ \u258F", getUsageGraph(50)) assert.Equal(t, " ██████ \u258F", getUsageGraph(60)) assert.Equal(t, " ███████ \u258F", getUsageGraph(70)) assert.Equal(t, " ████████ \u258F", getUsageGraph(80)) assert.Equal(t, " █████████ \u258F", getUsageGraph(90)) assert.Equal(t, " ██████████\u258F", getUsageGraph(100)) assert.Equal(t, " █ \u258F", getUsageGraph(11)) assert.Equal(t, " █▏ \u258F", getUsageGraph(12)) assert.Equal(t, " █▎ \u258F", getUsageGraph(13)) assert.Equal(t, " █▍ \u258F", getUsageGraph(14)) assert.Equal(t, " █▌ \u258F", getUsageGraph(15)) assert.Equal(t, " █▌ \u258F", getUsageGraph(16)) assert.Equal(t, " █▋ \u258F", getUsageGraph(17)) assert.Equal(t, " █▊ \u258F", getUsageGraph(18)) assert.Equal(t, " █▉ \u258F", getUsageGraph(19)) }