--- /dev/null
+# 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@v2
+
+ # Initializes the CodeQL tools for scanning.
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@v1
+ 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@v1
+
+ # âšī¸ 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@v1
--- /dev/null
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ branches:
+ - master
+
+name: run tests
+jobs:
+ lint:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Install Go
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.18.x
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Run linters
+ uses: golangci/golangci-lint-action@v3
+ with:
+ version: v1.45
+
+ test:
+ strategy:
+ matrix:
+ go-version: [1.18.x, 1.19.x]
+ platform: [ubuntu-latest, macos-latest]
+ runs-on: ${{ matrix.platform }}
+ steps:
+ - name: Install Go
+ if: success()
+ uses: actions/setup-go@v2
+ with:
+ go-version: ${{ matrix.go-version }}
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Run tests
+ run: go test -v -covermode=count ./...
+
+ coverage:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Install Go
+ if: success()
+ uses: actions/setup-go@v2
+ with:
+ go-version: 1.18.x
+ - name: Checkout code
+ uses: actions/checkout@v2
+ - name: Calc coverage
+ run: |
+ go test -v -race -covermode=atomic -coverprofile=coverage.out ./...
+ - name: Upload coverage report
+ uses: codecov/codecov-action@v2
+ with:
+ files: ./coverage.out
+ fail_ci_if_error: true
+ verbose: true
+ - name: Convert coverage.out to coverage.lcov
+ uses: jandelgado/gcov2lcov-action@v1.0.6
+ - name: Coveralls
+ uses: coverallsapp/github-action@v1.1.2
+ with:
+ github-token: ${{ secrets.github_token }}
+ path-to-lcov: coverage.lcov
--- /dev/null
+/.vscode
+/.idea
+/coverage.txt
+/dist
+/test_dir
+/vendor
\ No newline at end of file
--- /dev/null
+golang 1.18
--- /dev/null
+Copyright 2020-2021 Daniel Milde <daniel@milde.cz>
+
+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.
+
--- /dev/null
+NAME := 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')
+GOFLAGS ?= -buildmode=pie -trimpath -mod=readonly -modcacherw
+GOFLAGS_STATIC ?= -trimpath -mod=readonly -modcacherw
+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)'
+
+all: clean tarball build-all 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 go build -ldflags="$(LDFLAGS)" -o dist/$(NAME) $(PACKAGE)/$(CMD_GDU)
+
+build-static:
+ @echo "Version: " $(VERSION)
+ mkdir -p dist
+ GOFLAGS="$(GOFLAGS_STATIC)" CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o dist/$(NAME) $(PACKAGE)/$(CMD_GDU)
+
+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)
+
+ cd dist; GOFLAGS="$(GOFLAGS)" CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o gdu_linux_amd64 $(PACKAGE)/$(CMD_GDU)
+ cd dist; GOFLAGS="$(GOFLAGS_STATIC)" CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o gdu_linux_amd64_static $(PACKAGE)/$(CMD_GDU)
+
+ cd dist; CGO_ENABLED=0 GOOS=linux GOARM=5 GOARCH=arm go build -ldflags="$(LDFLAGS)" -o gdu_linux_armv5l $(PACKAGE)/$(CMD_GDU)
+ cd dist; CGO_ENABLED=0 GOOS=linux GOARM=6 GOARCH=arm go build -ldflags="$(LDFLAGS)" -o gdu_linux_armv6l $(PACKAGE)/$(CMD_GDU)
+ cd dist; CGO_ENABLED=0 GOOS=linux GOARM=7 GOARCH=arm go build -ldflags="$(LDFLAGS)" -o gdu_linux_armv7l $(PACKAGE)/$(CMD_GDU)
+ cd dist; CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o gdu_linux_arm64 $(PACKAGE)/$(CMD_GDU)
+ cd dist; CGO_ENABLED=0 GOOS=android GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o 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
+ go tool cover -html=coverage.txt
+
+gobench:
+ go test -bench=. $(PACKAGE)/pkg/analyze
+
+benchmark:
+ hyperfine --export-markdown=bench-cold.md \
+ --prepare 'sync; echo 3 | sudo tee /proc/sys/vm/drop_caches' \
+ 'gdu -npc ~' 'gdu -gnpc ~' 'dua ~' 'duc index ~' 'ncdu -0 -o /dev/null ~' \
+ 'diskus ~' 'du -hs ~' 'dust -d0 ~'
+ hyperfine --export-markdown=bench-warm.md \
+ --warmup 5 \
+ 'gdu -npc ~' 'gdu -gnpc ~' 'dua ~' 'duc index ~' 'ncdu -0 -o /dev/null ~' \
+ 'diskus ~' 'du -hs ~' 'dust -d0 ~'
+
+clean:
+ go 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:
+ go install gotest.tools/gotestsum@latest
+
+.PHONY: run build build-static build-all test gobench benchmark coverage coverage-html clean clean-uncompressed-dist man show-man release
--- /dev/null
+# go DiskUsage()
+
+[](https://codecov.io/gh/dundee/gdu)
+[](https://goreportcard.com/report/github.com/dundee/gdu)
+[](https://codeclimate.com/github/dundee/gdu/maintainability)
+[](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.
+
+[](https://asciinema.org/a/382738)
+
+<a href="https://repology.org/project/gdu/versions">
+ <img src="https://repology.org/badge/vertical-allrepos/gdu.svg" alt="Packaging status" align="right">
+</a>
+
+## 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
+
+[Arch Linux](https://aur.archlinux.org/packages/gdu/):
+
+ pacman -S 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
+ brew link --overwrite gdu # if you have coreutils installed as well
+
+[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
+
+## Usage
+
+```
+ gdu [flags] [directory_to_scan]
+
+Flags:
+ --config-file string Read config from file (default is $HOME/.gdu.yaml)
+ -g, --const-gc Enable memory garbage collection during analysis with constant level set by GOGC
+ --enable-profiling Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/
+ -h, --help help for gdu
+ -i, --ignore-dirs strings Absolute paths to ignore (separated by comma) (default [/proc,/dev,/sys,/run])
+ -I, --ignore-dirs-pattern strings Absolute path patterns to ignore (separated by comma)
+ -X, --ignore-from string Read absolute path patterns to ignore from file
+ -f, --input-file string Import analysis from JSON file
+ -l, --log-file string Path to a logfile (default "/dev/null")
+ -m, --max-cores int Set max cores that GDU will use. 8 cores available (default 8)
+ -c, --no-color Do not use colorized output
+ -x, --no-cross Do not cross filesystem boundaries
+ -H, --no-hidden Ignore hidden directories (beginning with dot)
+ --no-mouse Do not use mouse
+ --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
+ -n, --non-interactive Do not run in interactive mode
+ -o, --output-file string Export all info into file as JSON
+ -a, --show-apparent-size Show apparent size
+ -d, --show-disks Show all mounted disks
+ -B, --show-relative-size Show relative size
+ --si Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB)
+ -s, --summarize Show only a total in non-interactive mode
+ -v, --version Print version
+ --write-config Write current configuration to file (default is $HOME/.gdu.yaml)
+```
+
+## Examples
+
+ gdu # analyze current dir
+ gdu -a # show apparent size instead of disk usage
+ gdu <some_dir_to_analyze> # analyze given dir
+ gdu -d # show all mounted disks
+ gdu -l ./gdu.log <some_dir> # 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 -np / # do not show progress, useful when using its output in a script
+ gdu -nps /some/dir # show only total usage for given dir
+ 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
+
+## 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.
+
+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 presense of the config file by default.
+
+### 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 save the current configuration
+
+```
+gdu --write-config
+```
+
+## Styling
+
+There are wast ways how terminals can be colored.
+Some gdu primitives (like basic text) addapt 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"
+```
+
+## Memory usage
+
+### Automatic balancing
+
+Gdu tries to balance performance and memory usage.
+
+When less memory is used by gdu than the total free memory of the host,
+then Garbage Collection is disabled during the analysis phase completely to gain maximum speed.
+
+Otherwise GC is enabled.
+The more memory is used and the less memory is free, the more often will the GC happen.
+
+### Manual memory usage control
+
+If you want manual control over Garbage Collection, you can use `--const-gc` / `-g` flag.
+It will run Garbage Collection during the analysis phase with constant level of aggressiveness.
+As a result, the analysis will be about 25% slower and will consume about 30% less memory.
+To change the level, you can set the `GOGC` environment variable to specify how often the garbage collection will happen.
+Lower value (than 100) means GC will run more often. Higher means less often. Negative number will stop GC.
+
+Example running gdu with constant GC, but not so aggressive as default:
+
+```
+GOGC=200 gdu -g /
+```
+
+## Running tests
+
+ make install-dev-dependencies
+ make test
+
+## Benchmarks
+
+Benchmarks were performed on 50G 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.
+
+## 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.
+
+### 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 |
+|:---|---:|---:|---:|---:|
+| `gdu -npc ~` | 5.390 Âą 0.094 | 5.303 | 5.644 | 1.00 Âą 0.02 |
+| `gdu -gnpc ~` | 6.275 Âą 2.406 | 5.379 | 13.097 | 1.17 Âą 0.45 |
+| `dua ~` | 6.727 Âą 0.019 | 6.689 | 6.748 | 1.25 Âą 0.01 |
+| `duc index ~` | 31.377 Âą 0.176 | 31.085 | 31.701 | 5.83 Âą 0.06 |
+| `ncdu -0 -o /dev/null ~` | 31.311 Âą 0.100 | 31.170 | 31.507 | 5.82 Âą 0.05 |
+| `diskus ~` | 5.383 Âą 0.044 | 5.287 | 5.440 | 1.00 |
+| `du -hs ~` | 30.333 Âą 0.408 | 29.865 | 31.086 | 5.63 Âą 0.09 |
+| `dust -d0 ~` | 6.889 Âą 0.354 | 6.738 | 7.889 | 1.28 Âą 0.07 |
+
+### Warm cache
+
+| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
+|:---|---:|---:|---:|---:|
+| `gdu -npc ~` | 840.3 Âą 13.4 | 817.7 | 867.8 | 1.74 Âą 0.06 |
+| `gdu -gnpc ~` | 1038.4 Âą 9.7 | 1021.3 | 1054.1 | 2.15 Âą 0.07 |
+| `dua ~` | 635.0 Âą 20.6 | 602.6 | 669.9 | 1.32 Âą 0.06 |
+| `duc index ~` | 1879.5 Âą 18.5 | 1853.5 | 1922.1 | 3.90 Âą 0.13 |
+| `ncdu -0 -o /dev/null ~` | 2618.5 Âą 10.0 | 2607.9 | 2634.8 | 5.43 Âą 0.18 |
+| `diskus ~` | 482.4 Âą 15.6 | 456.5 | 516.9 | 1.00 |
+| `du -hs ~` | 1508.7 Âą 8.2 | 1501.1 | 1524.3 | 3.13 Âą 0.10 |
+| `dust -d0 ~` | 832.5 Âą 27.0 | 797.3 | 895.5 | 1.73 Âą 0.08 |
+
+## Alternatives
+
+* [ncdu](https://dev.yorhel.nl/ncdu) - NCurses based tool written in pure C
+* [godu](https://github.com/viktomas/godu) - Analyzer with 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
--- /dev/null
+package 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 = ""
--- /dev/null
+package app
+
+import (
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+
+ "net/http"
+ "net/http/pprof"
+
+ log "github.com/sirupsen/logrus"
+
+ "github.com/dundee/gdu/v5/build"
+ "github.com/dundee/gdu/v5/internal/common"
+ "github.com/dundee/gdu/v5/pkg/device"
+ gfs "github.com/dundee/gdu/v5/pkg/fs"
+ "github.com/dundee/gdu/v5/report"
+ "github.com/dundee/gdu/v5/stdout"
+ "github.com/dundee/gdu/v5/tui"
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+// 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
+ SetIgnoreDirPaths(paths []string)
+ SetIgnoreDirPatterns(paths []string) error
+ SetIgnoreFromFile(ignoreFile string) error
+ SetIgnoreHidden(value bool)
+ StartUILoop() error
+}
+
+// Flags define flags accepted by Run
+type Flags struct {
+ CfgFile string `yaml:"-"`
+ LogFile string `yaml:"log-file"`
+ InputFile string `yaml:"input-file"`
+ OutputFile string `yaml:"output-file"`
+ IgnoreDirs []string `yaml:"ignore-dirs"`
+ IgnoreDirPatterns []string `yaml:"ignore-dir-patterns"`
+ IgnoreFromFile string `yaml:"ignore-from-file"`
+ MaxCores int `yaml:"max-cores"`
+ ShowDisks bool `yaml:"-"`
+ ShowApparentSize bool `yaml:"show-apparent-size"`
+ ShowRelativeSize bool `yaml:"show-relative-size"`
+ ShowVersion bool `yaml:"-"`
+ NoColor bool `yaml:"no-color"`
+ NoMouse bool `yaml:"no-mouse"`
+ NonInteractive bool `yaml:"non-interactive"`
+ NoProgress bool `yaml:"no-progress"`
+ NoCross bool `yaml:"no-cross"`
+ NoHidden bool `yaml:"no-hidden"`
+ Profiling bool `yaml:"profiling"`
+ ConstGC bool `yaml:"const-gc"`
+ Summarize bool `yaml:"summarize"`
+ UseSIPrefix bool `yaml:"use-si-prefix"`
+ NoPrefix bool `yaml:"no-prefix"`
+ WriteConfig bool `yaml:"-"`
+ Style Style `yaml:"style"`
+ Sorting Sorting `yaml:"sorting"`
+}
+
+// Style define style config
+type Style struct {
+ SelectedRow ColorStyle `yaml:"selected-row"`
+ ProgressModal ProgressModalOpts `yaml:"progress-modal"`
+}
+
+// ProgressModalOpts defines options for progress modal
+type ProgressModalOpts struct {
+ CurrentItemNameMaxLen int `yaml:"current-item-path-max-len"`
+}
+
+// ColorStyle defines styling of some item
+type ColorStyle struct {
+ TextColor string `yaml:"text-color"`
+ BackgroundColor string `yaml:"background-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 {
+ Args []string
+ Flags *Flags
+ Istty bool
+ Writer io.Writer
+ TermApp common.TermApplication
+ Screen tcell.Screen
+ Getter device.DevicesInfoGetter
+ PathChecker func(string) (fs.FileInfo, error)
+}
+
+func init() {
+ http.DefaultServeMux = http.NewServeMux()
+}
+
+// Run starts gdu main logic
+func (a *App) Run() (err 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
+ }
+
+ 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")
+ }
+
+ path := a.getPath()
+ path, _ = filepath.Abs(path)
+
+ ui, err = a.createUI()
+ if err != nil {
+ return
+ }
+
+ if err = a.setNoCross(path); err != nil {
+ return
+ }
+
+ ui.SetIgnoreDirPaths(a.Flags.IgnoreDirs)
+
+ if len(a.Flags.IgnoreDirPatterns) > 0 {
+ if err = ui.SetIgnoreDirPatterns(a.Flags.IgnoreDirPatterns); err != nil {
+ return
+ }
+ }
+
+ if a.Flags.IgnoreFromFile != "" {
+ if err = ui.SetIgnoreFromFile(a.Flags.IgnoreFromFile); err != nil {
+ return
+ }
+ }
+
+ if a.Flags.NoHidden {
+ ui.SetIgnoreHidden(true)
+ }
+
+ a.setMaxProcs()
+
+ if err = a.runAction(ui, path); err != nil {
+ return
+ }
+
+ err = ui.StartUILoop()
+ return
+}
+
+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) createUI() (UI, error) {
+ var ui UI
+
+ if a.Flags.OutputFile != "" {
+ var output io.Writer
+ var err error
+ if a.Flags.OutputFile == "-" {
+ output = os.Stdout
+ } else {
+ output, err = os.OpenFile(a.Flags.OutputFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
+ 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.ConstGC,
+ a.Flags.UseSIPrefix,
+ )
+ return ui, nil
+ }
+
+ if a.Flags.NonInteractive || !a.Istty {
+ ui = 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.ConstGC,
+ a.Flags.UseSIPrefix,
+ a.Flags.NoPrefix,
+ )
+ } else {
+ 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.ProgressModal.CurrentItemNameMaxLen > 0 {
+ opts = append(opts, func(ui *tui.UI) {
+ ui.SetCurrentItemNameMaxLen(a.Flags.Style.ProgressModal.CurrentItemNameMaxLen)
+ })
+ }
+ 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)
+ })
+ }
+
+ ui = tui.CreateUI(
+ a.TermApp,
+ a.Screen,
+ os.Stdout,
+ !a.Flags.NoColor,
+ a.Flags.ShowApparentSize,
+ a.Flags.ShowRelativeSize,
+ a.Flags.ConstGC,
+ a.Flags.UseSIPrefix,
+ opts...,
+ )
+
+ if !a.Flags.NoColor {
+ tview.Styles.TitleColor = tcell.NewRGBColor(27, 161, 227)
+ }
+ tview.Styles.BorderColor = tcell.ColorDefault
+ }
+ return ui, nil
+}
+
+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))
+ }()
+ }
+
+ if a.Flags.ShowDisks {
+ if err := ui.ListDevices(a.Getter); err != nil {
+ return fmt.Errorf("loading mount points: %w", err)
+ }
+ } else if 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, 0600)
+ 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)
+ }
+ } else {
+ 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
+}
--- /dev/null
+//go:build linux
+// +build linux
+
+package app
+
+import (
+ "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")
+}
--- /dev/null
+package app
+
+import (
+ "bytes"
+ "os"
+ "runtime"
+ "strings"
+ "testing"
+
+ log "github.com/sirupsen/logrus"
+
+ "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"
+ "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 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 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 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,
+ },
+ },
+ },
+ []string{"test_dir"},
+ true,
+ testdev.DevicesInfoGetterMock{},
+ )
+
+ assert.Empty(t, out)
+ 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 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)
+}
+
+func runApp(flags *Flags, args []string, istty bool, getter device.DevicesInfoGetter) (string, 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
+}
--- /dev/null
+package 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
+var 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,
+}
+
+func init() {
+ af = &app.Flags{}
+ 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.BoolVarP(&af.ShowVersion, "version", "v", false, "Print version")
+
+ flags.StringSliceVarP(&af.IgnoreDirs, "ignore-dirs", "i", []string{"/proc", "/dev", "/sys", "/run"}, "Absolute paths to ignore (separated by comma)")
+ flags.StringSliceVarP(&af.IgnoreDirPatterns, "ignore-dirs-pattern", "I", []string{}, "Absolute path patterns to ignore (separated by comma)")
+ flags.StringVarP(&af.IgnoreFromFile, "ignore-from", "X", "", "Read absolute path patterns to ignore from file")
+ flags.BoolVarP(&af.NoHidden, "no-hidden", "H", false, "Ignore hidden directories (beginning with dot)")
+ flags.BoolVarP(&af.NoCross, "no-cross", "x", false, "Do not cross filesystem boundaries")
+ flags.BoolVarP(&af.ConstGC, "const-gc", "g", false, "Enable memory garbage collection during analysis with constant level set by GOGC")
+ flags.BoolVar(&af.Profiling, "enable-profiling", false, "Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/")
+
+ 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.NonInteractive, "non-interactive", "n", false, "Do not run in interactive mode")
+ flags.BoolVarP(&af.NoProgress, "no-progress", "p", false, "Do not show progress in non-interactive mode")
+ flags.BoolVarP(&af.Summarize, "summarize", "s", false, "Show only a total in non-interactive mode")
+ 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.BoolVar(&af.NoMouse, "no-mouse", false, "Do not use mouse")
+ flags.BoolVar(&af.WriteConfig, "write-config", false, "Write current configuration to file (default is $HOME/.gdu.yaml)")
+
+ initConfig()
+}
+
+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 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, 0600)
+ 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, 0600)
+ 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.ShowVersion && !af.NonInteractive && istty && af.OutputFile == "" {
+ screen, err = tcell.NewScreen()
+ if err != nil {
+ return fmt.Errorf("Error creating screen: %w", err)
+ }
+ err = screen.Init()
+ if err != nil {
+ return fmt.Errorf("Error initializing screen: %w", err)
+ }
+ defer screen.Clear()
+ defer screen.Fini()
+
+ termApp = tview.NewApplication()
+ termApp.SetScreen(screen)
+
+ if !af.NoMouse {
+ 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)
+ }
+}
--- /dev/null
+coverage:
+ status:
+ project:
+ default:
+ target: auto
+ threshold: 2%
+ informational: true
+ patch:
+ default:
+ informational: true
\ No newline at end of file
--- /dev/null
+.\" Automatically generated by Pandoc 2.18
+.\"
+.\" Define V font for inline verbatim, using C font in formats
+.\" that render this, and otherwise B font.
+.ie "\f[CB]x\f[]"x" \{\
+. ftr V B
+. ftr VI BI
+. ftr VB B
+. ftr VBI BI
+.\}
+.el \{\
+. ftr V CR
+. ftr VI CI
+. ftr VB CB
+. ftr VBI CBI
+.\}
+.TH "gdu" "1" "2022-09-07" "" ""
+.hy
+.SH NAME
+.PP
+gdu - Pretty fast disk usage analyzer written in Go
+.SH SYNOPSIS
+.PP
+\f[B]gdu [flags] [directory_to_scan]\f[R]
+.SH DESCRIPTION
+.PP
+Pretty fast disk usage analyzer written in Go.
+.PP
+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.
+.SH OPTIONS
+.PP
+\f[B]-h\f[R], \f[B]--help\f[R][=false] help for gdu
+.PP
+\f[B]-i\f[R], \f[B]--ignore-dirs\f[R]=[/proc,/dev,/sys,/run] Absolute
+paths to ignore (separated by comma)
+.PP
+\f[B]-I\f[R], \f[B]--ignore-dirs-pattern\f[R] Absolute path patterns to
+ignore (separated by comma)
+.PP
+\f[B]-X\f[R], \f[B]--ignore-from\f[R] Read absolute path patterns to
+ignore from file
+.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]-n\f[R], \f[B]--non-interactive\f[R][=false] Do not run in
+interactive mode
+.PP
+\f[B]-p\f[R], \f[B]--no-progress\f[R][=false] Do not show progress in
+non-interactive mode
+.PP
+\f[B]-s\f[R], \f[B]--summarize\f[R][=false] Show only a total 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]--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-mouse\f[R][=false] Do not use mouse
+.PP
+\f[B]-f\f[R], \f[B]-\[em]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]-\[em]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]-g\f[R], \f[B]--const-gc\f[R][=false] Enable memory garbage
+collection during analysis with constant level set by GOGC
+.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]-v\f[R], \f[B]--version\f[R][=false] Print version
+.SH FILE FLAGS
+.PP
+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.
--- /dev/null
+---
+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\] Absolute paths to
+ignore (separated by comma)
+
+**-I**, **\--ignore-dirs-pattern** Absolute path patterns to
+ignore (separated by comma)
+
+**-X**, **\--ignore-from** Read absolute path patterns to ignore from file
+
+**-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)
+
+**-n**, **\--non-interactive**\[=false\] Do not run in interactive mode
+
+**-p**, **\--no-progress**\[=false\] Do not show progress in
+non-interactive mode
+
+**-s**, **\--summarize**\[=false\] Show only a total in non-interactive mode
+
+**-d**, **\--show-disks**\[=false\] Show all mounted disks
+
+**-a**, **\--show-apparent-size**\[=false\] Show apparent size
+
+**\--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-mouse**\[=false\] Do not use mouse
+
+**-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)
+
+**-g**, **\--const-gc**\[=false\] Enable memory garbage collection during analysis with constant level set by GOGC
+
+**\--enable-profiling**\[=false\] Enable collection of profiling data and provide it on http://localhost:6060/debug/pprof/
+
+**-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.
--- /dev/null
+module github.com/dundee/gdu/v5
+
+go 1.18
+
+require (
+ github.com/fatih/color v1.13.0
+ github.com/gdamore/tcell/v2 v2.5.3
+ github.com/maruel/natural v1.1.0
+ github.com/mattn/go-isatty v0.0.16
+ github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58
+ github.com/rivo/tview v0.0.0-20220916081518-2e69b7385a37
+ github.com/sirupsen/logrus v1.9.0
+ github.com/spf13/cobra v1.5.0
+ github.com/stretchr/testify v1.8.0
+ golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41
+ gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/gdamore/encoding v1.0.0 // indirect
+ github.com/inconshreveable/mousetrap v1.0.1 // indirect
+ github.com/kr/pretty v0.3.0 // indirect
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-runewidth v0.0.13 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/rivo/uniseg v0.4.2 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 // indirect
+ golang.org/x/text v0.3.7 // indirect
+ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
+)
--- /dev/null
+github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
+github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
+github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
+github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
+github.com/gdamore/tcell/v2 v2.5.3 h1:b9XQrT6QGbgI7JvZOJXFNczOQeIYbo8BfeSMzt2sAV0=
+github.com/gdamore/tcell/v2 v2.5.3/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo=
+github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
+github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
+github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/maruel/natural v1.1.0 h1:2z1NgP/Vae+gYrtC0VuvrTJ6U35OuyUqDdfluLqMWuQ=
+github.com/maruel/natural v1.1.0/go.mod h1:eFVhYCcUOfZFxXoDZam8Ktya72wa79fNC3lc/leA0DQ=
+github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
+github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 h1:onHthvaw9LFnH4t2DcNVpwGmV9E1BkGknEliJkfwQj0=
+github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhMYhSNPKjeNKa5WY9YCIEBRbNzFFPJbWO6Y=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rivo/tview v0.0.0-20220916081518-2e69b7385a37 h1:cTzFg1FfTXwXuODi7Doz70hsW+dAye1OBwAFWHCqmww=
+github.com/rivo/tview v0.0.0-20220916081518-2e69b7385a37/go.mod h1:YX2wUZOcJGOIycErz2s9KvDaP0jnWwRCirQMPLPpQ+Y=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8=
+github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
+github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
+github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
+github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41 h1:ohgcoMbSofXygzo6AD2I1kz3BFmW1QArPYTtwEM3UXc=
+golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 h1:Q5284mrmYTpACcm+eAKjKJH48BBwSyfJqmmGDTtT8Vc=
+golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
+gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
--- /dev/null
+package common
+
+import "github.com/dundee/gdu/v5/pkg/fs"
+
+// CurrentProgress struct
+type CurrentProgress struct {
+ CurrentItemName string
+ ItemCount int
+ TotalSize int64
+}
+
+// ShouldDirBeIgnored whether path should be ignored
+type ShouldDirBeIgnored func(name, path string) bool
+
+// Analyzer is type for dir analyzing function
+type Analyzer interface {
+ AnalyzeDir(path string, ignore ShouldDirBeIgnored, constGC bool) fs.Item
+ GetProgressChan() chan CurrentProgress
+ GetDone() SignalGroup
+ ResetProgress()
+}
--- /dev/null
+package common
+
+import (
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+// TermApplication is interface for the terminal UI app
+type TermApplication interface {
+ Run() error
+ Stop()
+ Suspend(f func()) bool
+ SetRoot(root tview.Primitive, fullscreen bool) *tview.Application
+ SetFocus(p tview.Primitive) *tview.Application
+ SetInputCapture(capture func(event *tcell.EventKey) *tcell.EventKey) *tview.Application
+ SetMouseCapture(
+ capture func(event *tcell.EventMouse, action tview.MouseAction) (*tcell.EventMouse, tview.MouseAction),
+ ) *tview.Application
+ QueueUpdateDraw(f func()) *tview.Application
+ SetBeforeDrawFunc(func(screen tcell.Screen) bool) *tview.Application
+}
--- /dev/null
+package common
+
+import (
+ "bufio"
+ "os"
+ "regexp"
+ "strings"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// CreateIgnorePattern creates one pattern from all path patterns
+func CreateIgnorePattern(paths []string) (*regexp.Regexp, error) {
+ var err error
+
+ for i, path := range paths {
+ if _, err = regexp.Compile(path); err != nil {
+ return nil, err
+ }
+ 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))
+ for _, path := range paths {
+ ui.IgnoreDirPaths[path] = struct{}{}
+ }
+}
+
+// SetIgnoreDirPatterns sets regular patters 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 patters 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
+}
+
+// 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
+}
+
+// CreateIgnoreFunc returns function for detecting if dir should be ignored
+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 }
+ }
+}
--- /dev/null
+package common_test
+
+import (
+ "os"
+ "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.Match([]byte("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, 0600)
+ if err != nil {
+ panic(err)
+ }
+ defer file.Close()
+
+ if _, err := file.Write([]byte("/aaa\n")); err != nil {
+ panic(err)
+ }
+ if _, err := file.Write([]byte("/aaabc\n")); err != nil {
+ panic(err)
+ }
+ if _, err := file.Write([]byte("/[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"))
+}
--- /dev/null
+package common
+
+type SignalGroup chan struct{}
+
+func (s SignalGroup) Wait() {
+ <-s
+}
+
+func (s SignalGroup) Broadcast() {
+ close(s)
+}
--- /dev/null
+package common
+
+import (
+ "regexp"
+ "strconv"
+)
+
+// UI struct
+type UI struct {
+ Analyzer Analyzer
+ IgnoreDirPaths map[string]struct{}
+ IgnoreDirPathPatterns *regexp.Regexp
+ IgnoreHidden bool
+ UseColors bool
+ UseSIPrefix bool
+ ShowProgress bool
+ ShowApparentSize bool
+ ShowRelativeSize bool
+ ConstGC bool
+}
+
+// binary multiplies prefixes (IEC)
+const (
+ _ = iota
+ Ki float64 = 1 << (10 * iota)
+ 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)
+}
--- /dev/null
+package common
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFormatNumber(t *testing.T) {
+ res := FormatNumber(1234567890)
+ assert.Equal(t, "1,234,567,890", res)
+}
--- /dev/null
+package 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"
+)
+
+// 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, enableGC bool,
+) 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
+}
+
+// GetProgressChan returns always Done
+func (a *MockedAnalyzer) GetProgressChan() chan common.CurrentProgress {
+ return make(chan common.CurrentProgress)
+}
+
+// GetDoneChan 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() {}
+
+// RemoveItemFromDirWithErr returns error
+func RemoveItemFromDirWithErr(dir fs.Item, file fs.Item) error {
+ return errors.New("Failed")
+}
--- /dev/null
+package 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(width, height int) tcell.SimulationScreen {
+ screen := tcell.NewSimulationScreen("UTF-8")
+ err := screen.Init()
+ if err != nil {
+ panic(err)
+ }
+ screen.SetSize(width, height)
+
+ return screen
+}
+
+// CreateTestAppWithSimScreen returns app with simulation screen for tests
+func CreateTestAppWithSimScreen(width, height int) (*tview.Application, tcell.SimulationScreen) {
+ app := tview.NewApplication()
+ screen := CreateSimScreen(width, height)
+ app.SetScreen(screen)
+ return app, screen
+}
+
+// MockedApp is tview.Application with mocked methods
+type MockedApp struct {
+ FailRun bool
+ updateDraws []func()
+ BeforeDraws []func(screen tcell.Screen) bool
+ mutex *sync.Mutex
+}
+
+// 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
+}
--- /dev/null
+[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}]]
--- /dev/null
+package 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() (device.Devices, error) {
+ return t.Devices, nil
+}
+
+// GetMounts returns all mounted filesystems from /proc/mounts
+func (t DevicesInfoGetterMock) GetMounts() (device.Devices, error) {
+ return t.Devices, nil
+}
--- /dev/null
+package 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"), 0600); err != nil {
+ panic(err)
+ }
+ if err := os.WriteFile("test_dir/nested/file2", []byte("go"), 0600); 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) (fs.FileInfo, error) {
+ return nil, nil
+}
--- /dev/null
+package analyze
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "runtime/debug"
+
+ "github.com/dundee/gdu/v5/internal/common"
+ "github.com/dundee/gdu/v5/pkg/fs"
+ log "github.com/sirupsen/logrus"
+)
+
+var concurrencyLimit = make(chan struct{}, 3*runtime.GOMAXPROCS(0))
+
+// ParallelAnalyzer implements Analyzer
+type ParallelAnalyzer struct {
+ progress *common.CurrentProgress
+ progressChan chan common.CurrentProgress
+ progressOutChan chan common.CurrentProgress
+ progressDoneChan chan struct{}
+ doneChan common.SignalGroup
+ wait *WaitGroup
+ ignoreDir common.ShouldDirBeIgnored
+}
+
+// CreateAnalyzer returns Analyzer
+func CreateAnalyzer() *ParallelAnalyzer {
+ return &ParallelAnalyzer{
+ progress: &common.CurrentProgress{
+ ItemCount: 0,
+ TotalSize: int64(0),
+ },
+ progressChan: make(chan common.CurrentProgress, 1),
+ progressOutChan: make(chan common.CurrentProgress, 1),
+ progressDoneChan: make(chan struct{}),
+ doneChan: make(common.SignalGroup),
+ wait: (&WaitGroup{}).Init(),
+ }
+}
+
+// GetProgressChan returns channel for getting progress
+func (a *ParallelAnalyzer) GetProgressChan() chan common.CurrentProgress {
+ return a.progressOutChan
+}
+
+// GetDoneChan returns channel for checking when analysis is done
+func (a *ParallelAnalyzer) GetDone() common.SignalGroup {
+ return a.doneChan
+}
+
+// ResetProgress returns progress
+func (a *ParallelAnalyzer) ResetProgress() {
+ a.progress = &common.CurrentProgress{}
+ a.progressChan = make(chan common.CurrentProgress, 1)
+ a.progressOutChan = make(chan common.CurrentProgress, 1)
+ a.progressDoneChan = make(chan struct{})
+ a.doneChan = make(common.SignalGroup)
+ a.wait = (&WaitGroup{}).Init()
+}
+
+// AnalyzeDir analyzes given path
+func (a *ParallelAnalyzer) AnalyzeDir(
+ path string, ignore common.ShouldDirBeIgnored, constGC bool,
+) fs.Item {
+ if !constGC {
+ defer debug.SetGCPercent(debug.SetGCPercent(-1))
+ go manageMemoryUsage(a.doneChan)
+ }
+
+ a.ignoreDir = ignore
+
+ 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 *File
+ err error
+ totalSize 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 {
+ info, err = f.Info()
+ if err != nil {
+ log.Print(err.Error())
+ continue
+ }
+ file = &File{
+ Name: name,
+ Flag: getFlag(info),
+ Size: info.Size(),
+ Parent: dir,
+ }
+ setPlatformSpecificAttrs(file, info)
+
+ totalSize += info.Size()
+
+ dir.AddFile(file)
+ }
+ }
+
+ go func() {
+ var sub *Dir
+
+ for i := 0; i < dirCount; i++ {
+ sub = <-subDirChan
+ dir.AddFile(sub)
+ }
+
+ a.wait.Done()
+ }()
+
+ a.progressChan <- common.CurrentProgress{
+ CurrentItemName: path,
+ ItemCount: len(files),
+ TotalSize: totalSize,
+ }
+ return dir
+}
+
+func (a *ParallelAnalyzer) updateProgress() {
+ for {
+ select {
+ case <-a.progressDoneChan:
+ return
+ case progress := <-a.progressChan:
+ a.progress.CurrentItemName = progress.CurrentItemName
+ a.progress.ItemCount += progress.ItemCount
+ a.progress.TotalSize += progress.TotalSize
+ }
+
+ select {
+ case a.progressOutChan <- *a.progress:
+ default:
+ }
+ }
+}
+
+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 {
+ switch {
+ case f.Mode()&os.ModeSymlink != 0:
+ fallthrough
+ case f.Mode()&os.ModeSocket != 0:
+ return '@'
+ default:
+ return ' '
+ }
+}
--- /dev/null
+//go:build linux || openbsd
+// +build linux openbsd
+
+package analyze
+
+import (
+ "os"
+ "syscall"
+ "time"
+)
+
+const devBSize = 512
+
+func setPlatformSpecificAttrs(file *File, f os.FileInfo) {
+ switch stat := f.Sys().(type) {
+ case *syscall.Stat_t:
+ 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))
+}
--- /dev/null
+//go:build linux
+// +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", 0755)
+ assert.Nil(t, err)
+ }()
+
+ analyzer := CreateAnalyzer()
+ dir := analyzer.AnalyzeDir(
+ "test_dir", func(_, _ string) bool { return false }, false,
+ ).(*Dir)
+ analyzer.GetDone().Wait()
+ dir.UpdateStats(make(fs.HardLinkedItems))
+
+ assert.Equal(t, "test_dir", dir.GetName())
+ assert.Equal(t, 2, dir.ItemCount)
+ assert.Equal(t, '.', dir.GetFlag())
+
+ assert.Equal(t, "nested", dir.Files[0].GetName())
+ assert.Equal(t, '!', dir.Files[0].GetFlag())
+}
--- /dev/null
+//go:build windows || plan9
+// +build windows plan9
+
+package analyze
+
+import (
+ "os"
+ "syscall"
+ "time"
+)
+
+func setPlatformSpecificAttrs(file *File, f os.FileInfo) {
+ stat := f.Sys().(*syscall.Win32FileAttributeData)
+ file.Mtime = time.Unix(0, stat.LastWriteTime.Nanoseconds())
+}
+
+func setDirPlatformSpecificAttrs(dir *Dir, path string) {
+ stat, err := os.Stat(path)
+ if err != nil {
+ return
+ }
+ dir.Mtime = stat.ModTime()
+}
--- /dev/null
+package 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 }, false,
+ ).(*Dir)
+
+ progress := <-analyzer.GetProgressChan()
+ assert.GreaterOrEqual(t, progress.TotalSize, 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, 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 }, false,
+ ).(*Dir)
+
+ assert.Equal(t, "test_dir", dir.Name)
+ assert.Equal(t, 1, dir.ItemCount)
+}
+
+func TestFlags(t *testing.T) {
+ fin := testdir.CreateTestDir()
+ defer fin()
+
+ err := os.Mkdir("test_dir/empty", 0644)
+ 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 }, 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, 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 }, 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, 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 BenchmarkAnalyzeDir(b *testing.B) {
+ fin := testdir.CreateTestDir()
+ defer fin()
+
+ b.ResetTimer()
+
+ analyzer := CreateAnalyzer()
+ dir := analyzer.AnalyzeDir(
+ "test_dir", func(_, _ string) bool { return false }, false,
+ )
+ analyzer.GetDone().Wait()
+ dir.UpdateStats(make(fs.HardLinkedItems))
+}
--- /dev/null
+//go:build darwin || netbsd || freebsd
+// +build darwin netbsd freebsd
+
+package analyze
+
+import (
+ "os"
+ "syscall"
+ "time"
+)
+
+const devBSize = 512
+
+func setPlatformSpecificAttrs(file *File, f os.FileInfo) {
+ switch stat := f.Sys().(type) {
+ case *syscall.Stat_t:
+ 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))
+}
--- /dev/null
+package 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
+}
--- /dev/null
+package 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`)
+}
--- /dev/null
+package analyze
+
+import (
+ "os"
+ "path/filepath"
+ "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 {
+ switch f.Flag {
+ case '@':
+ return "Other"
+ }
+ return "File"
+}
+
+// GetItemCount returns 1 for file
+func (f *File) GetItemCount() int {
+ 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 {
+ if _, ok := linkedItems[mli]; ok {
+ f.Flag = 'H'
+ 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) (int, int64, 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() fs.Files {
+ return fs.Files{}
+}
+
+// SetFiles panics on file
+func (f *File) SetFiles(files fs.Files) {
+ panic("SetFiles 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")
+}
+
+// Dir struct
+type Dir struct {
+ *File
+ BasePath string
+ Files fs.Files
+ ItemCount int
+}
+
+// AddFile add item fo files
+func (f *Dir) AddFile(item fs.Item) {
+ f.Files = append(f.Files, item)
+}
+
+// GetFiles returns all files in directory
+func (f *Dir) GetFiles() fs.Files {
+ return f.Files
+}
+
+// SetFiles sets files in directory
+func (f *Dir) SetFiles(files fs.Files) {
+ f.Files = files
+}
+
+// GetType returns name type of item
+func (f *Dir) GetType() string {
+ return "Directory"
+}
+
+// GetItemCount returns number of files in dir
+func (f *Dir) GetItemCount() int {
+ 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) (int, int64, 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 int
+ 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
+}
+
+// RemoveItemFromDir removes item from dir
+func RemoveItemFromDir(dir fs.Item, item fs.Item) error {
+ err := os.RemoveAll(item.GetPath())
+ if err != nil {
+ return err
+ }
+
+ dir.SetFiles(dir.GetFiles().Remove(item))
+
+ cur := dir.(*Dir)
+ for {
+ cur.ItemCount -= item.GetItemCount()
+ cur.Size -= item.GetSize()
+ cur.Usage -= item.GetUsage()
+
+ if cur.Parent == nil {
+ break
+ }
+ cur = cur.Parent.(*Dir)
+ }
+ return nil
+}
+
+// EmptyFileFromDir empty file from dir
+func EmptyFileFromDir(dir fs.Item, file fs.Item) error {
+ err := os.Truncate(file.GetPath(), 0)
+ if err != nil {
+ return err
+ }
+
+ cur := dir.(*Dir)
+ for {
+ cur.Size -= file.GetSize()
+ cur.Usage -= file.GetUsage()
+
+ if cur.Parent == nil {
+ break
+ }
+ cur = cur.Parent.(*Dir)
+ }
+
+ dir.SetFiles(dir.GetFiles().Remove(file))
+ newFile := &File{
+ Name: file.GetName(),
+ Flag: file.GetFlag(),
+ Size: 0,
+ Parent: dir,
+ }
+ dir.AddFile(newFile)
+
+ return nil
+}
--- /dev/null
+//go:build linux
+// +build linux
+
+package analyze
+
+import (
+ "os"
+ "testing"
+
+ "github.com/dundee/gdu/v5/internal/testdir"
+ "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", 0755)
+ assert.Nil(t, err)
+ }()
+
+ dir := &Dir{
+ File: &File{
+ Name: "test_dir",
+ },
+ BasePath: ".",
+ }
+
+ subdir := &Dir{
+ File: &File{
+ Name: "nested",
+ Parent: dir,
+ },
+ }
+
+ err = RemoveItemFromDir(dir, subdir)
+ assert.Contains(t, err.Error(), "permission denied")
+}
--- /dev/null
+package analyze
+
+import (
+ "testing"
+ "time"
+
+ "github.com/dundee/gdu/v5/internal/testdir"
+ "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 TestRemoveFile(t *testing.T) {
+ dir := &Dir{
+ File: &File{
+ Name: "xxx",
+ Size: 5,
+ Usage: 12,
+ },
+ ItemCount: 3,
+ BasePath: ".",
+ }
+
+ subdir := &Dir{
+ File: &File{
+ Name: "yyy",
+ Size: 4,
+ Usage: 8,
+ Parent: dir,
+ },
+ ItemCount: 2,
+ }
+ file := &File{
+ Name: "zzz",
+ Size: 3,
+ Usage: 4,
+ Parent: subdir,
+ }
+ dir.Files = fs.Files{subdir}
+ subdir.Files = fs.Files{file}
+
+ err := RemoveItemFromDir(subdir, file)
+ assert.Nil(t, err)
+
+ assert.Equal(t, 0, len(subdir.Files))
+ assert.Equal(t, 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, 2, dir.ItemCount)
+ assert.Equal(t, int64(2), dir.Size)
+}
+
+func TestTruncateFile(t *testing.T) {
+ fin := testdir.CreateTestDir()
+ defer fin()
+
+ dir := &Dir{
+ File: &File{
+ Name: "test_dir",
+ Size: 5,
+ Usage: 12,
+ },
+ ItemCount: 3,
+ BasePath: ".",
+ }
+
+ subdir := &Dir{
+ File: &File{
+ Name: "nested",
+ Size: 4,
+ Usage: 8,
+ Parent: dir,
+ },
+ ItemCount: 2,
+ }
+ file := &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, 2, 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, 3, dir.ItemCount)
+ assert.Equal(t, int64(2), dir.Size)
+}
+
+func TestTruncateFileWithErr(t *testing.T) {
+ dir := &Dir{
+ File: &File{
+ Name: "xxx",
+ Size: 5,
+ Usage: 12,
+ },
+ ItemCount: 3,
+ BasePath: ".",
+ }
+
+ subdir := &Dir{
+ File: &File{
+ Name: "yyy",
+ Size: 4,
+ Usage: 8,
+ Parent: dir,
+ },
+ ItemCount: 2,
+ }
+ file := &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")
+}
+
+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},
+ }
+
+ assert.Equal(t, file.Name, dir.GetFiles()[0].GetName())
+ assert.Equal(t, fs.Files{}, file.GetFiles())
+}
+
+func TestSetFilesPanicsOnFile(t *testing.T) {
+ file := &File{
+ Name: "xxx",
+ Mli: 5,
+ }
+ assert.Panics(t, func() {
+ file.SetFiles(fs.Files{file})
+ })
+}
+
+func TestAddFilePanicsOnFile(t *testing.T) {
+ file := &File{
+ Name: "xxx",
+ Mli: 5,
+ }
+ assert.Panics(t, func() {
+ file.AddFile(file)
+ })
+}
--- /dev/null
+package analyze
+
+import (
+ "runtime"
+ "runtime/debug"
+ "time"
+
+ "github.com/pbnjay/memory"
+ log "github.com/sirupsen/logrus"
+)
+
+// set GC percentage according to memory usage and system free memory
+func manageMemoryUsage(c <-chan struct{}) {
+ disabledGC := true
+
+ for {
+ select {
+ case <-c:
+ return
+ default:
+ }
+
+ time.Sleep(time.Second)
+
+ rebalanceGC(&disabledGC)
+ }
+}
+
+/*
+ Try to balance performance and memory consumption.
+
+ When less memory is used by gdu than the total free memory of the host,
+ Garbage Collection is disabled during the analysis phase at all.
+
+ Otherwise GC is enabled.
+ The more memory is used and the less memory is free,
+ the more often will the GC happen.
+*/
+func rebalanceGC(disabledGC *bool) {
+ memStats := runtime.MemStats{}
+ runtime.ReadMemStats(&memStats)
+ free := memory.FreeMemory()
+
+ // we use less memory than is free, disable GC
+ if memStats.Alloc < free {
+ if !*disabledGC {
+ log.Printf(
+ "disabling GC, alloc: %d, free: %d", memStats.Alloc, free,
+ )
+ debug.SetGCPercent(-1)
+ *disabledGC = true
+ }
+ } else {
+ // the more memory we use and the less memory is free, the more aggressive the GC will be
+ gcPercent := int(100 / float64(memStats.Alloc) * float64(free))
+ log.Printf(
+ "setting GC percent to %d, alloc: %d, free: %d",
+ gcPercent, memStats.Alloc, free,
+ )
+ debug.SetGCPercent(gcPercent)
+ *disabledGC = false
+ }
+}
--- /dev/null
+package analyze
+
+import (
+ "runtime"
+ "runtime/debug"
+ "testing"
+
+ "github.com/pbnjay/memory"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRebalanceGC(t *testing.T) {
+ memStats := runtime.MemStats{}
+ runtime.ReadMemStats(&memStats)
+ free := memory.FreeMemory()
+
+ disabledGC := false
+ rebalanceGC(&disabledGC)
+
+ if free > memStats.Alloc {
+ assert.True(t, disabledGC)
+ assert.Equal(t, -1, debug.SetGCPercent(100))
+ } else {
+ assert.False(t, disabledGC)
+ assert.Greater(t, 0, debug.SetGCPercent(-1))
+ }
+}
--- /dev/null
+package 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, 3, files[0].GetItemCount())
+ assert.Equal(t, 2, files[1].GetItemCount())
+ assert.Equal(t, 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())
+}
--- /dev/null
+package 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 = 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.Unlock()
+ }
+}
--- /dev/null
+package 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
+}
--- /dev/null
+//go:build netbsd || openbsd
+// +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, 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, error) {
+ mounts, err := t.GetMounts()
+ if err != nil {
+ return nil, err
+ }
+
+ return processMounts(mounts, false)
+}
+
+func readMountOutput(rdr io.Reader) (Devices, error) {
+ mounts := Devices{}
+
+ 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
+}
--- /dev/null
+//go:build freebsd || openbsd || netbsd || darwin
+// +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())
+}
--- /dev/null
+//go:build freebsd || darwin
+// +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, 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, error) {
+ mounts, err := t.GetMounts()
+ if err != nil {
+ return nil, err
+ }
+
+ return processMounts(mounts, false)
+}
+
+func readMountOutput(rdr io.Reader) (Devices, error) {
+ mounts := Devices{}
+
+ 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, error) {
+ devices := Devices{}
+
+ 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, err
+ }
+
+ mount.Size = int64(info.Bsize) * int64(info.Blocks)
+ mount.Free = int64(info.Bsize) * int64(info.Bavail)
+
+ devices = append(devices, mount)
+ }
+ }
+
+ return devices, nil
+}
--- /dev/null
+//go:build freebsd || darwin
+// +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)
+}
--- /dev/null
+package 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, 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)
+ }
+ 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, error) {
+ mounts, err := t.GetMounts()
+ if err != nil {
+ return nil, err
+ }
+
+ return processMounts(mounts, false)
+}
+
+func readMountsFile(file io.Reader) (Devices, 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, 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", " ")
+}
--- /dev/null
+//go:build linux
+// +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) {
+ 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) {
+ 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)
+}
--- /dev/null
+//go:build netbsd
+// +build netbsd
+
+package device
+
+import (
+ "strings"
+
+ "golang.org/x/sys/unix"
+)
+
+func processMounts(mounts Devices, ignoreErrors bool) (Devices, error) {
+ devices := Devices{}
+
+ 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
+}
--- /dev/null
+//go:build openbsd
+// +build openbsd
+
+package device
+
+import (
+ "fmt"
+ "strings"
+
+ "golang.org/x/sys/unix"
+)
+
+func processMounts(mounts Devices, ignoreErrors bool) (Devices, error) {
+ devices := Devices{}
+
+ 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
+}
--- /dev/null
+//go:build windows || plan9
+// +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, error) {
+ return nil, errors.New("Only Linux platform is supported for listing devices")
+}
+
+// GetMounts returns all mounted filesystems
+func (t OtherDevicesInfoGetter) GetMounts() (Devices, error) {
+ return nil, errors.New("Only Linux platform is supported for listing mount points")
+}
--- /dev/null
+package 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)
+}
--- /dev/null
+package fs
+
+import (
+ "io"
+ "time"
+
+ "github.com/maruel/natural"
+)
+
+// 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() int
+ GetParent() Item
+ SetParent(Item)
+ GetMultiLinkedInode() uint64
+ EncodeJSON(writer io.Writer, topLevel bool) error
+ GetItemStats(linkedItems HardLinkedItems) (int, int64, int64)
+ UpdateStats(linkedItems HardLinkedItems)
+ AddFile(Item)
+ GetFiles() Files
+ SetFiles(Files)
+}
+
+// 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())
+}
--- /dev/null
+package 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 lenght 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
+}
--- /dev/null
+package 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))
+}
--- /dev/null
+package report
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "os"
+ "sort"
+ "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,
+ constGC bool,
+ useSIPrefix bool,
+) *UI {
+ ui := &UI{
+ UI: &common.UI{
+ ShowProgress: showProgress,
+ Analyzer: analyze.CreateAnalyzer(),
+ ConstGC: constGC,
+ 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
+}
+
+// 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")
+}
+
+// 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
+ err error
+ )
+
+ 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.ConstGC)
+ dir.UpdateStats(make(fs.HardLinkedItems, 10))
+ }()
+
+ wait.Wait()
+
+ sort.Sort(sort.Reverse(dir.GetFiles()))
+
+ 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"))
+
+ 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
+ }
+
+ switch f := ui.exportOutput.(type) {
+ case *os.File:
+ 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(`â â â â â šâ ¸â ŧâ ´â Ļâ §`)
+
+ progressChan := ui.Analyzer.GetProgressChan()
+ doneChan := ui.Analyzer.GetDone()
+
+ var progress common.CurrentProgress
+
+ i := 0
+ for {
+ fmt.Fprint(ui.output, emptyRow)
+
+ select {
+ case progress = <-progressChan:
+ 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.TotalSize))
+ }
+
+ 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"
+ }
+}
--- /dev/null
+package 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, 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, 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, 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, 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, 0644)
+ assert.Nil(t, err)
+ defer func() {
+ os.Remove("output.json")
+ }()
+
+ output := bytes.NewBuffer(make([]byte, 10))
+
+ ui := CreateExportUI(output, reportOutput, false, true, false, 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, 0644)
+ 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, 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, false, 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")
+}
--- /dev/null
+package 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) (*analyze.Dir, 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{}) (*analyze.Dir, 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
+}
--- /dev/null
+package 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")
+}
--- /dev/null
+name: 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: core20
+parts:
+ gdu:
+ plugin: go
+ source: .
+ override-build: |
+ GO111MODULE=on CGO_ENABLED=0 go build \
+ -buildmode=pie -trimpath -mod=readonly -modcacherw \
+ -ldflags \
+ "-s -w \
+ -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
--- /dev/null
+package stdout
+
+import (
+ "fmt"
+ "io"
+ "math"
+ "runtime"
+ "sort"
+ "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 {
+ *common.UI
+ output io.Writer
+ red *color.Color
+ orange *color.Color
+ blue *color.Color
+ summarize bool
+ noPrefix bool
+}
+
+var progressRunes = []rune(`â â â â â šâ ¸â ŧâ ´â Ļâ §`)
+
+// CreateStdoutUI creates UI for stdout
+func CreateStdoutUI(
+ output io.Writer,
+ useColors bool,
+ showProgress bool,
+ showApparentSize bool,
+ showRelativeSize bool,
+ summarize bool,
+ constGC bool,
+ useSIPrefix bool,
+ noPrefix bool,
+) *UI {
+ ui := &UI{
+ UI: &common.UI{
+ UseColors: useColors,
+ ShowProgress: showProgress,
+ ShowApparentSize: showApparentSize,
+ ShowRelativeSize: showRelativeSize,
+ Analyzer: analyze.CreateAnalyzer(),
+ ConstGC: constGC,
+ UseSIPrefix: useSIPrefix,
+ },
+ output: output,
+ summarize: summarize,
+ noPrefix: noPrefix,
+ }
+
+ 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 !useColors {
+ color.NoColor = true
+ }
+
+ return ui
+}
+
+// StartUILoop stub
+func (ui *UI) StartUILoop() error {
+ return nil
+}
+
+// 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
+ }
+
+ maxDeviceNameLenght := 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",
+ maxDeviceNameLenght,
+ sizeLength,
+ sizeLength,
+ sizeLength,
+ percentLength,
+ )
+
+ fmt.Fprintf(
+ ui.output,
+ fmt.Sprintf("%%%ds %%9s %%9s %%9s %%5s %%s\n", maxDeviceNameLenght),
+ "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
+ )
+
+ if ui.ShowProgress {
+ wait.Add(1)
+ go func() {
+ defer wait.Done()
+ ui.updateProgress()
+ }()
+ }
+
+ wait.Add(1)
+ go func() {
+ defer wait.Done()
+ dir = ui.Analyzer.AnalyzeDir(path, ui.CreateIgnoreFunc(), ui.ConstGC)
+ dir.UpdateStats(make(fs.HardLinkedItems, 10))
+ }()
+
+ wait.Wait()
+
+ if ui.summarize {
+ ui.printTotalItem(dir)
+ } else {
+ ui.showDir(dir)
+ }
+
+ return nil
+}
+
+func (ui *UI) showDir(dir fs.Item) {
+ sort.Sort(sort.Reverse(dir.GetFiles()))
+
+ for _, file := range dir.GetFiles() {
+ ui.printItem(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.UseColors {
+ lineFormat = "%s %20s %s\n"
+ } else {
+ lineFormat = "%s %9s %s\n"
+ }
+
+ var size int64
+ if ui.ShowApparentSize {
+ size = file.GetSize()
+ } else {
+ size = file.GetUsage()
+ }
+
+ if file.IsDir() {
+ fmt.Fprintf(ui.output,
+ lineFormat,
+ string(file.GetFlag()),
+ ui.formatSize(size),
+ ui.blue.Sprintf("/"+file.GetName()))
+ } else {
+ fmt.Fprintf(ui.output,
+ lineFormat,
+ string(file.GetFlag()),
+ ui.formatSize(size),
+ file.GetName())
+ }
+}
+
+// ReadAnalysis reads analysis report from JSON file
+func (ui *UI) ReadAnalysis(input io.Reader) error {
+ var (
+ dir *analyze.Dir
+ 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 %= 10
+ }
+}
+
+func (ui *UI) updateProgress() {
+ emptyRow := "\r"
+ for j := 0; j < 100; j++ {
+ emptyRow += " "
+ }
+
+ progressChan := ui.Analyzer.GetProgressChan()
+ doneChan := ui.Analyzer.GetDone()
+
+ var progress common.CurrentProgress
+
+ i := 0
+ for {
+ fmt.Fprint(ui.output, emptyRow)
+
+ select {
+ case progress = <-progressChan:
+ case <-doneChan:
+ fmt.Fprint(ui.output, "\r")
+ return
+ }
+
+ 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.TotalSize))
+
+ time.Sleep(100 * time.Millisecond)
+ i++
+ i %= 10
+ }
+}
+
+func (ui *UI) formatSize(size int64) string {
+ if ui.noPrefix {
+ return ui.orange.Sprintf("%d", size)
+ }
+ 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 int, y int) int {
+ if x > y {
+ return x
+ }
+ return y
+}
--- /dev/null
+//go:build linux
+// +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, false)
+ err := ui.ListDevices(getter)
+
+ assert.Contains(t, err.Error(), "no such file")
+}
--- /dev/null
+package stdout
+
+import (
+ "bytes"
+ "os"
+ "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/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, true, 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, output.String(), "nested")
+}
+
+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, 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, 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, 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, output.String(), "test_dir")
+}
+
+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, false)
+ 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, false)
+ ui.SetIgnoreDirPaths([]string{"/xxx"})
+ 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, false)
+ 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, false)
+ 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, false)
+ 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, false)
+ 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, 0644)
+ assert.Nil(t, err)
+
+ output := bytes.NewBuffer(make([]byte, 10))
+
+ ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false)
+ 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, 0644)
+ assert.Nil(t, err)
+
+ output := bytes.NewBuffer(make([]byte, 10))
+
+ ui := CreateStdoutUI(output, false, false, false, false, false, false, false, false)
+ 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, 0644)
+ assert.Nil(t, err)
+
+ output := bytes.NewBuffer(make([]byte, 10))
+
+ ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false)
+ err = ui.ReadAnalysis(input)
+
+ assert.NotNil(t, err)
+}
+
+func TestReadAnalysisWithSummarize(t *testing.T) {
+ input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0644)
+ assert.Nil(t, err)
+
+ output := bytes.NewBuffer(make([]byte, 10))
+
+ ui := CreateStdoutUI(output, false, false, false, false, true, false, false, false)
+ 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, 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")
+}
+
+func TestFormatSizeDec(t *testing.T) {
+ output := bytes.NewBuffer(make([]byte, 10))
+
+ ui := CreateStdoutUI(output, true, true, true, false, false, false, true, false)
+
+ 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 TestFormatSizeRaw(t *testing.T) {
+ output := bytes.NewBuffer(make([]byte, 10))
+
+ ui := CreateStdoutUI(output, true, true, true, false, false, false, true, true)
+
+ 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 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
+}
--- /dev/null
+package tui
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "runtime"
+ "runtime/debug"
+ "strings"
+
+ "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"
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+const defaultLinesCount = 500
+const linesTreshold = 20
+
+// 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)
+
+ flex := tview.NewFlex().
+ AddItem(nil, 0, 1, false).
+ AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
+ AddItem(nil, 0, 1, false).
+ AddItem(ui.progress, 8, 1, false).
+ AddItem(nil, 0, 1, false), 0, 50, false).
+ AddItem(nil, 0, 1, false)
+
+ ui.pages.AddPage("progress", flex, true, true)
+ ui.table.SetSelectedFunc(ui.fileItemSelected)
+
+ go ui.updateProgress()
+
+ go func() {
+ defer debug.FreeOSMemory()
+ currentDir := ui.Analyzer.AnalyzeDir(path, ui.CreateIgnoreFunc(), ui.ConstGC)
+
+ if parentDir != nil {
+ currentDir.SetParent(parentDir)
+ parentDir.SetFiles(parentDir.GetFiles().RemoveByName(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
+}
+
+func (ui *UI) deleteSelected(shouldEmpty bool) {
+ row, column := ui.table.GetSelection()
+ selectedItem := ui.table.GetCell(row, column).GetReference().(fs.Item)
+
+ var action, acting string
+ if shouldEmpty {
+ action = "empty "
+ acting = "emptying"
+ } else {
+ action = "delete "
+ acting = "deleting"
+ }
+ modal := tview.NewModal().SetText(
+ strings.Title(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.(*analyze.Dir)
+ for _, file := range currentDir.GetFiles() {
+ 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)
+ ui.showDir()
+ ui.table.Select(min(row, ui.table.GetRowCount()-1), 0)
+ })
+
+ if ui.done != nil {
+ ui.done <- struct{}{}
+ }
+ }()
+}
+
+func (ui *UI) showFile() *tview.TextView {
+ if ui.currentDir == nil {
+ return nil
+ }
+
+ row, column := ui.table.GetSelection()
+ selectedFile := ui.table.GetCell(row, column).GetReference().(fs.Item)
+ if selectedFile.IsDir() {
+ return nil
+ }
+
+ f, err := os.Open(selectedFile.GetPath())
+ if err != nil {
+ ui.showErr("Error opening file", err)
+ return nil
+ }
+
+ totalLines := 0
+ scanner := bufio.NewScanner(f)
+
+ file := tview.NewTextView()
+ ui.currentDirLabel.SetText("[::b] --- " +
+ strings.TrimPrefix(selectedFile.GetPath(), 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
+ }
+
+ switch {
+ case event.Rune() == 'j':
+ fallthrough
+ case event.Rune() == 'G':
+ fallthrough
+ case event.Key() == tcell.KeyDown:
+ fallthrough
+ case event.Key() == tcell.KeyPgDn:
+ _, _, _, height := file.GetInnerRect()
+ row, _ := file.GetScrollOffset()
+ if height+row > totalLines-linesTreshold {
+ 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 (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 = "[#e67100::b]"
+ } else {
+ numberColor = "[::b]"
+ }
+
+ 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 = "Invoke-Item"
+ }
+
+ cmd := exec.Command(openBinary, selectedFile.GetPath())
+ err := cmd.Start()
+ if err != nil {
+ ui.showErr("Error opening", err)
+ }
+}
--- /dev/null
+//go:build linux
+// +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, false)
+ err := ui.ListDevices(getter)
+
+ assert.Contains(t, err.Error(), "no such file")
+}
--- /dev/null
+package tui
+
+import (
+ "bytes"
+ "os"
+ "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, 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, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, true, false, false)
+ ui.Analyzer = &testanalyze.MockedAnalyzer{}
+ ui.done = make(chan struct{})
+ 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 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, true, true, false)
+ 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(50, 50)
+ defer simScreen.Fini()
+
+ input, err := os.OpenFile("../internal/testdata/test.json", os.O_RDONLY, 0644)
+ assert.Nil(t, err)
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, 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.Equal(t, "gdu", ui.currentDir.GetName())
+}
+
+func TestReadAnalysisWithWrongFile(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ input, err := os.OpenFile("../internal/testdata/wrong.json", os.O_RDONLY, 0644)
+ assert.Nil(t, err)
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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())
+
+ res := ui.showFile() // selected item is dir, do nothing
+ assert.Nil(t, res)
+}
+
+func TestViewFileWithoutCurrentDir(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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.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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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.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 TestShowInfo(t *testing.T) {
+ fin := testdir.CreateTestDir()
+ defer fin()
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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, 'q', 0))
+
+ assert.False(t, ui.pages.HasPage("info"))
+}
+
+func TestShowInfoBW(t *testing.T) {
+ fin := testdir.CreateTestDir()
+ defer fin()
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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()
+ }
+
+ nested := ui.currentDir.GetFiles()[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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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.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"))
+}
--- /dev/null
+package tui
+
+import (
+ "os"
+ "os/exec"
+)
+
+// Execute runs given bin path via exec.Command call
+func Execute(argv0 string, argv []string, 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()
+}
--- /dev/null
+//go:build !windows\r
+// +build !windows\r
+\r
+package tui\r
+\r
+import (\r
+ "os"\r
+)\r
+\r
+func getShellBin() string {\r
+ shellbin, ok := os.LookupEnv("SHELL")\r
+ if !ok {\r
+ shellbin = "/bin/bash"\r
+ }\r
+ return shellbin\r
+}\r
+\r
+func (ui *UI) spawnShell() {\r
+ if ui.currentDir == nil {\r
+ return\r
+ }\r
+\r
+ ui.app.Suspend(func() {\r
+ if err := os.Chdir(ui.currentDirPath); err != nil {\r
+ ui.showErr("Error changing directory", err)\r
+ return\r
+ }\r
+\r
+ if err := ui.exec(getShellBin(), nil, os.Environ()); err != nil {\r
+ ui.showErr("Error executing shell", err)\r
+ }\r
+ })\r
+}\r
--- /dev/null
+package tui
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestExecute(t *testing.T) {
+ err := Execute("true", []string{}, []string{})
+
+ assert.Nil(t, err)
+}
--- /dev/null
+package tui\r
+\r
+import (\r
+ "os"\r
+)\r
+\r
+func getShellBin() string {\r
+ shellbin, ok := os.LookupEnv("COMSPEC")\r
+ if !ok {\r
+ shellbin = "C:\\WINDOWS\\System32\\cmd.exe"\r
+ }\r
+ return shellbin\r
+}\r
+\r
+func (ui *UI) spawnShell() {\r
+ if ui.currentDir == nil {\r
+ return\r
+ }\r
+\r
+ ui.app.Stop()\r
+\r
+ if err := os.Chdir(ui.currentDirPath); err != nil {\r
+ ui.showErr("Error changing directory", err)\r
+ return\r
+ }\r
+ if err := ui.exec(getShellBin(), nil, os.Environ()); err != nil {\r
+ ui.showErr("Error executing shell", err)\r
+ }\r
+}\r
--- /dev/null
+package tui
+
+import (
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+func (ui *UI) hideFilterInput() {
+ ui.filterValue = ""
+ ui.footer.Clear()
+ ui.footer.AddItem(ui.footerLabel, 0, 1, false)
+ ui.app.SetFocus(ui.table)
+ ui.filteringInput = nil
+ ui.filtering = false
+}
+
+func (ui *UI) showFilterInput() {
+ if ui.currentDir == nil {
+ return
+ }
+
+ if ui.filteringInput == nil {
+ ui.filteringInput = tview.NewInputField()
+
+ 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.footer.Clear()
+ ui.footer.AddItem(ui.filteringInput, 0, 1, true)
+ ui.footer.AddItem(ui.footerLabel, 0, 5, false)
+ }
+ ui.app.SetFocus(ui.filteringInput)
+ ui.filtering = true
+}
--- /dev/null
+package tui
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/dundee/gdu/v5/internal/testanalyze"
+ "github.com/dundee/gdu/v5/internal/testapp"
+ "github.com/dundee/gdu/v5/internal/testdir"
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFiltering(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, 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()
+ }
+
+ ui.showFilterInput()
+ ui.filterValue = ""
+ ui.showDir()
+
+ assert.Contains(t, ui.table.GetCell(0, 0).Text, "ccc") // nothing is filtered
+
+ 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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()
+ }
+
+ 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, 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()
+ }
+
+ 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
+}
--- /dev/null
+package tui
+
+import (
+ "fmt"
+ "math"
+
+ "github.com/dundee/gdu/v5/internal/common"
+ "github.com/dundee/gdu/v5/pkg/fs"
+ "github.com/rivo/tview"
+)
+
+func (ui *UI) formatFileRow(item fs.Item, maxUsage int64, maxSize int64) string {
+ var part int
+
+ if ui.ShowApparentSize {
+ part = int(float64(item.GetSize()) / float64(maxSize) * 10.0)
+ } else {
+ part = int(float64(item.GetUsage()) / float64(maxUsage) * 10.0)
+ }
+
+ row := string(item.GetFlag())
+
+ if ui.UseColors {
+ row += "[#e67100::b]"
+ } else {
+ row += "[::b]"
+ }
+
+ if ui.ShowApparentSize {
+ row += fmt.Sprintf("%15s", ui.formatSize(item.GetSize(), false, true))
+ } else {
+ row += fmt.Sprintf("%15s", ui.formatSize(item.GetUsage(), false, true))
+ }
+
+ row += getUsageGraph(part)
+
+ if ui.showItemCount {
+ if ui.UseColors {
+ row += "[#e67100::b]"
+ } else {
+ row += "[::b]"
+ }
+ row += fmt.Sprintf("%11s ", ui.formatCount(item.GetItemCount()))
+ }
+
+ if ui.showMtime {
+ if ui.UseColors {
+ row += "[#e67100::b]"
+ } else {
+ row += "[::b]"
+ }
+ row += fmt.Sprintf(
+ "%s [-::]",
+ item.GetMtime().Format("2006-01-02 15:04:05"),
+ )
+ }
+
+ if item.IsDir() {
+ if ui.UseColors {
+ row += "[#3498db::b]/"
+ } else {
+ row += "[::b]/"
+ }
+ }
+ row += tview.Escape(item.GetName())
+ return row
+}
+
+func (ui *UI) formatSize(size int64, reverseColor bool, transparentBg bool) string {
+ var color string
+ if reverseColor {
+ if ui.UseColors {
+ color = "[black:#2479d0:-]"
+ } else {
+ color = "[black:white:-]"
+ }
+ } else {
+ if transparentBg {
+ color = "[-::]"
+ } else {
+ color = "[white:black:-]"
+ }
+ }
+
+ if ui.UseSIPrefix {
+ return formatWithDecPrefix(size, color)
+ }
+ return formatWithBinPrefix(float64(size), color)
+}
+
+func (ui *UI) formatCount(count int) string {
+ row := ""
+ color := "[-::]"
+ 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)
+ }
+}
--- /dev/null
+package 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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()), "Aaa [red[] bbb")
+}
--- /dev/null
+package tui
+
+import (
+ "fmt"
+
+ "github.com/dundee/gdu/v5/pkg/fs"
+ "github.com/gdamore/tcell/v2"
+)
+
+func (ui *UI) keyPressed(key *tcell.EventKey) *tcell.EventKey {
+ if ui.pages.HasPage("file") {
+ return key // send event to primitive
+ }
+ if ui.filtering {
+ return key
+ }
+
+ key = ui.handleClosingModals(key)
+ if key == nil {
+ return nil
+ }
+ key = ui.handleInfoPageEvents(key)
+ if key == nil {
+ return nil
+ }
+ key = ui.handleBreakingActions(key)
+ if key == nil {
+ return nil
+ }
+
+ if ui.pages.HasPage("confirm") ||
+ ui.pages.HasPage("progress") ||
+ ui.pages.HasPage("deleting") ||
+ ui.pages.HasPage("emptying") ||
+ ui.pages.HasPage("help") {
+ return key
+ }
+
+ key = ui.handleLeftRight(key)
+ if key == nil {
+ return nil
+ }
+
+ if key.Key() == tcell.KeyTab && ui.filteringInput != nil {
+ ui.filtering = true
+ ui.app.SetFocus(ui.filteringInput)
+ 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) 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
+}
+
+func (ui *UI) handleBreakingActions(key *tcell.EventKey) *tcell.EventKey {
+ switch key.Rune() {
+ case 'Q':
+ ui.app.Stop()
+ fmt.Fprintf(ui.output, "%s\n", ui.currentDirPath)
+ return nil
+ case 'q':
+ ui.app.Stop()
+ return nil
+ case 'b':
+ ui.spawnShell()
+ return nil
+ case '?':
+ if ui.pages.HasPage("help") {
+ ui.pages.RemovePage("help")
+ ui.app.SetFocus(ui.table)
+ return nil
+ }
+ ui.showHelp()
+ }
+ 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) handleMainActions(key *tcell.EventKey) *tcell.EventKey {
+ switch key.Rune() {
+ case 'd':
+ ui.handleDelete(false)
+ case 'e':
+ ui.handleDelete(true)
+ case 'v':
+ ui.showFile()
+ case 'o':
+ ui.openItem()
+ case 'i':
+ ui.showInfo()
+ case 'a':
+ ui.ShowApparentSize = !ui.ShowApparentSize
+ if ui.currentDir != nil {
+ row, column := ui.table.GetSelection()
+ ui.showDir()
+ ui.table.Select(row, column)
+ }
+ case 'B':
+ ui.ShowRelativeSize = !ui.ShowRelativeSize
+ if ui.currentDir != nil {
+ row, column := ui.table.GetSelection()
+ ui.showDir()
+ ui.table.Select(row, column)
+ }
+ case 'c':
+ ui.showItemCount = !ui.showItemCount
+ if ui.currentDir != nil {
+ row, column := ui.table.GetSelection()
+ ui.showDir()
+ ui.table.Select(row, column)
+ }
+ case 'm':
+ ui.showMtime = !ui.showMtime
+ if ui.currentDir != nil {
+ row, column := ui.table.GetSelection()
+ ui.showDir()
+ ui.table.Select(row, column)
+ }
+ case 'r':
+ if ui.currentDir != nil {
+ ui.rescanDir()
+ }
+ case 's':
+ ui.setSorting("size")
+ case 'C':
+ ui.setSorting("itemCount")
+ case 'n':
+ ui.setSorting("name")
+ case 'M':
+ ui.setSorting("mtime")
+ case '/':
+ ui.showFilterInput()
+ return nil
+ }
+ return key
+}
+
+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)
+ }
+ }
+ return
+ }
+ if ui.currentDir != nil {
+ ui.fileItemSelected(0, 0)
+ }
+}
+
+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.deleteSelected(shouldEmpty)
+ }
+}
--- /dev/null
+package tui
+
+import (
+ "bytes"
+ "errors"
+ "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/device"
+ "github.com/dundee/gdu/v5/pkg/fs"
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestShowHelp(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false)
+
+ ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, '?', 0))
+
+ assert.True(t, ui.pages.HasPage("help"))
+}
+
+func TestCloseHelp(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false)
+
+ modal := tview.NewModal().SetText("Really?")
+ ui.pages.AddPage("confirm", modal, true, true)
+
+ key := ui.keyPressed(tcell.NewEventKey(tcell.KeyLeft, 'h', 0))
+ assert.Equal(t, tcell.KeyLeft, key.Key())
+ key = ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0))
+ assert.Equal(t, tcell.KeyRight, key.Key())
+}
+
+func TestMoveLeftRight(t *testing.T) {
+ fin := testdir.CreateTestDir()
+ defer fin()
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, 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()
+ }
+
+ 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))
+
+ assert.Equal(t, "test_dir", ui.currentDir.GetName())
+}
+
+func TestMoveRightOnDevice(t *testing.T) {
+ fin := testdir.CreateTestDir()
+ defer fin()
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, 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 TestStop(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ buff := &bytes.Buffer{}
+ ui := CreateUI(app, simScreen, buff, true, 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()
+ }
+
+ 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ buff := &bytes.Buffer{}
+ ui := CreateUI(app, simScreen, buff, true, true, false, false, false)
+ var 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ buff := &bytes.Buffer{}
+ ui := CreateUI(app, simScreen, buff, true, true, false, false, false)
+ var 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ buff := &bytes.Buffer{}
+ ui := CreateUI(app, simScreen, buff, true, true, false, false, false)
+ var 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ buff := &bytes.Buffer{}
+ ui := CreateUI(app, simScreen, buff, true, true, false, false, false)
+ var 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 TestShowConfirm(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, 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.table.Select(1, 0)
+
+ ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0))
+
+ assert.True(t, ui.pages.HasPage("confirm"))
+}
+
+func TestDeleteEmpty(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false)
+ ui.done = make(chan struct{})
+
+ key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0))
+ assert.NotNil(t, key)
+}
+
+func TestDelete(t *testing.T) {
+ fin := testdir.CreateTestDir()
+ defer fin()
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, 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 TestDeleteParent(t *testing.T) {
+ fin := testdir.CreateTestDir()
+ defer fin()
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, 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 TestEmptyDir(t *testing.T) {
+ fin := testdir.CreateTestDir()
+ defer fin()
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, 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 TestEmptyFile(t *testing.T) {
+ fin := testdir.CreateTestDir()
+ defer fin()
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, 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 TestSortByApparentSize(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, 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 TestShowFileCountBW(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, 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 TestShowMtimeBW(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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, '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)
+}
+
+func TestShowFile(t *testing.T) {
+ fin := testdir.CreateTestDir()
+ defer fin()
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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.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 TestShowInfoAndMoveAround(t *testing.T) {
+ fin := testdir.CreateTestDir()
+ defer fin()
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, 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, '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"))
+}
--- /dev/null
+package tui
+
+import (
+ "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
+ }
+
+ 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 {
+ ui.showFile()
+ }
+ }
+ return nil, action
+ case tview.MouseScrollUp:
+ fallthrough
+ case 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
+}
--- /dev/null
+package tui
+
+import (
+ "bytes"
+ "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/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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, 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()
+ }
+
+ 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 TestScroll(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, 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()
+ }
+
+ 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 TestEmptyEvent(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, 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)
+}
--- /dev/null
+package tui
+
+import (
+ "time"
+
+ "github.com/dundee/gdu/v5/internal/common"
+ "github.com/dundee/gdu/v5/pkg/path"
+)
+
+func (ui *UI) updateProgress() {
+ color := "[white:black:b]"
+ if ui.UseColors {
+ color = "[red:black:b]"
+ }
+
+ progressChan := ui.Analyzer.GetProgressChan()
+ doneChan := ui.Analyzer.GetDone()
+
+ var progress common.CurrentProgress
+ start := time.Now()
+
+ for {
+ select {
+ case progress = <-progressChan:
+ case <-doneChan:
+ return
+ }
+
+ func(itemCount int, totalSize int64, currentItem string) {
+ delta := time.Since(start).Round(time.Second)
+
+ ui.app.QueueUpdateDraw(func() {
+ ui.progress.SetText("Total items: " +
+ color +
+ common.FormatNumber(int64(itemCount)) +
+ "[white:black:-], size: " +
+ color +
+ ui.formatSize(totalSize, false, false) +
+ "[white:black:-], elapsed time: " +
+ color +
+ delta.String() +
+ "[white:black:-]\nCurrent item: [white:black:b]" +
+ path.ShortenPath(currentItem, ui.currentItemNameMaxLen))
+ })
+ }(progress.ItemCount, progress.TotalSize, progress.CurrentItemName)
+
+ time.Sleep(100 * time.Millisecond)
+ }
+}
--- /dev/null
+package tui
+
+import (
+ "strconv"
+ "strings"
+
+ "github.com/dundee/gdu/v5/build"
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+func (ui *UI) showDir() {
+ var (
+ totalUsage int64
+ totalSize int64
+ maxUsage int64
+ maxSize int64
+ itemCount int
+ )
+
+ ui.currentDirPath = ui.currentDir.GetPath()
+ ui.currentDirLabel.SetText("[::b] --- " +
+ tview.Escape(
+ strings.TrimPrefix(ui.currentDirPath, build.RootPathPrefix),
+ ) +
+ " ---").SetDynamicColors(true)
+
+ ui.table.Clear()
+
+ rowIndex := 0
+ if ui.currentDirPath != ui.topDirPath {
+ cell := tview.NewTableCell(" [::b]/..")
+ cell.SetReference(ui.currentDir.GetParent())
+ cell.SetStyle(tcell.Style{}.Foreground(tcell.ColorDefault))
+ ui.table.SetCell(0, 0, cell)
+ rowIndex++
+ }
+
+ ui.sortItems()
+
+ if ui.ShowRelativeSize {
+ for _, item := range ui.currentDir.GetFiles() {
+ if item.GetUsage() > maxUsage {
+ maxUsage = item.GetUsage()
+ }
+ if item.GetSize() > maxSize {
+ maxSize = item.GetSize()
+ }
+ }
+ } else {
+ maxUsage = ui.currentDir.GetUsage()
+ maxSize = ui.currentDir.GetSize()
+ }
+
+ for i, item := range ui.currentDir.GetFiles() {
+ if ui.filterValue != "" && !strings.Contains(
+ strings.ToLower(item.GetName()),
+ strings.ToLower(ui.filterValue),
+ ) {
+ continue
+ }
+
+ totalUsage += item.GetUsage()
+ totalSize += item.GetSize()
+ itemCount += item.GetItemCount()
+
+ cell := tview.NewTableCell(ui.formatFileRow(item, maxUsage, maxSize))
+ cell.SetStyle(tcell.Style{}.Foreground(tcell.ColorDefault))
+ cell.SetReference(ui.currentDir.GetFiles()[i])
+
+ ui.table.SetCell(rowIndex, 0, cell)
+ rowIndex++
+ }
+
+ var footerNumberColor, footerTextColor string
+ if ui.UseColors {
+ footerNumberColor = "[#ffffff:#2479d0:b]"
+ footerTextColor = "[black:#2479d0:-]"
+ } else {
+ footerNumberColor = "[black:white:b]"
+ footerTextColor = "[black:white:-]"
+ }
+
+ ui.footerLabel.SetText(
+ " Total disk usage: " +
+ footerNumberColor +
+ ui.formatSize(totalUsage, true, false) +
+ " Apparent size: " +
+ footerNumberColor +
+ ui.formatSize(totalSize, true, false) +
+ " Items: " + footerNumberColor + strconv.Itoa(itemCount) +
+ footerTextColor +
+ " Sorting by: " + ui.sortBy + " " + ui.sortOrder)
+
+ ui.table.Select(0, 0)
+ ui.table.ScrollToBeginning()
+
+ if !ui.filtering {
+ 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.table.SetCell(i+1, 4, tview.NewTableCell(ui.formatSize(device.Free, false, true)))
+ ui.table.SetCell(i+1, 5, tview.NewTableCell(textColor+device.MountPoint))
+ }
+
+ var footerNumberColor, footerTextColor string
+ if ui.UseColors {
+ footerNumberColor = "[#ffffff:#2479d0:b]"
+ footerTextColor = "[black:#2479d0:-]"
+ } else {
+ footerNumberColor = "[black:white:b]"
+ footerTextColor = "[black:white:-]"
+ }
+
+ 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) {
+ modal := tview.NewModal().
+ SetText(msg + ": " + err.Error()).
+ 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)
+}
+
+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)
+
+ if ui.UseColors {
+ text.SetText(
+ strings.ReplaceAll(
+ strings.ReplaceAll(helpText, "[::b]", "[red]"),
+ "[white:black:-]",
+ "[white]",
+ ),
+ )
+ } else {
+ text.SetText(helpText)
+ }
+
+ maxHeight := strings.Count(helpText, "\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)
+}
--- /dev/null
+package tui
+
+import (
+ "sort"
+
+ "github.com/dundee/gdu/v5/pkg/device"
+ "github.com/dundee/gdu/v5/pkg/fs"
+)
+
+// SetDefaultSorting sets the default sorting
+func (ui *UI) SetDefaultSorting(by, order string) {
+ if by != "" {
+ ui.defaultSortBy = by
+ }
+ if order == "asc" || order == "desc" {
+ ui.defaultSortOrder = order
+ }
+}
+
+func (ui *UI) setSorting(newOrder string) {
+ if newOrder == ui.sortBy {
+ if ui.sortOrder == "asc" {
+ ui.sortOrder = "desc"
+ } else {
+ ui.sortOrder = "asc"
+ }
+ } else {
+ ui.sortBy = newOrder
+ ui.sortOrder = "asc"
+ }
+
+ if ui.currentDir != nil {
+ ui.showDir()
+ } else if ui.devices != nil && (newOrder == "size" || newOrder == "name") {
+ ui.showDevices()
+ }
+}
+
+func (ui *UI) sortItems() {
+ if ui.sortBy == "size" {
+ if ui.ShowApparentSize {
+ if ui.sortOrder == "desc" {
+ sort.Sort(sort.Reverse(fs.ByApparentSize(ui.currentDir.GetFiles())))
+ } else {
+ sort.Sort(fs.ByApparentSize(ui.currentDir.GetFiles()))
+ }
+ } else {
+ if ui.sortOrder == "desc" {
+ sort.Sort(sort.Reverse(ui.currentDir.GetFiles()))
+ } else {
+ sort.Sort(ui.currentDir.GetFiles())
+ }
+ }
+ }
+ if ui.sortBy == "itemCount" {
+ if ui.sortOrder == "desc" {
+ sort.Sort(sort.Reverse(fs.ByItemCount(ui.currentDir.GetFiles())))
+ } else {
+ sort.Sort(fs.ByItemCount(ui.currentDir.GetFiles()))
+ }
+ }
+ if ui.sortBy == "name" {
+ if ui.sortOrder == "desc" {
+ sort.Sort(sort.Reverse(fs.ByName(ui.currentDir.GetFiles())))
+ } else {
+ sort.Sort(fs.ByName(ui.currentDir.GetFiles()))
+ }
+ }
+ if ui.sortBy == "mtime" {
+ if ui.sortOrder == "desc" {
+ sort.Sort(sort.Reverse(fs.ByMtime(ui.currentDir.GetFiles())))
+ } else {
+ sort.Sort(fs.ByMtime(ui.currentDir.GetFiles()))
+ }
+ }
+}
+
+func (ui *UI) sortDevices() {
+ if ui.sortBy == "size" {
+ if ui.sortOrder == "desc" {
+ sort.Sort(sort.Reverse(device.ByUsedSize(ui.devices)))
+ } else {
+ sort.Sort(device.ByUsedSize(ui.devices))
+ }
+ }
+ if ui.sortBy == "name" {
+ if ui.sortOrder == "desc" {
+ sort.Sort(sort.Reverse(device.ByName(ui.devices)))
+ } else {
+ sort.Sort(device.ByName(ui.devices))
+ }
+ }
+}
--- /dev/null
+package 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(50, 50)
+ 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, 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, 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, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, apparentSize, false, 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
+}
--- /dev/null
+package tui
+
+import (
+ "io"
+
+ 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/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+const 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]/ [white:black:-]Search items by name
+ [::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]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)`
+
+// UI struct
+type UI struct {
+ *common.UI
+ app common.TermApplication
+ screen tcell.Screen
+ output io.Writer
+ header *tview.TextView
+ footer *tview.Flex
+ footerLabel *tview.TextView
+ currentDirLabel *tview.TextView
+ pages *tview.Pages
+ progress *tview.TextView
+ help *tview.Flex
+ table *tview.Table
+ filteringInput *tview.InputField
+ currentDir fs.Item
+ devices []*device.Device
+ topDir fs.Item
+ topDirPath string
+ currentDirPath string
+ askBeforeDelete bool
+ showItemCount bool
+ showMtime bool
+ filtering bool
+ filterValue string
+ sortBy string
+ sortOrder string
+ done chan struct{}
+ remover func(fs.Item, fs.Item) error
+ emptier func(fs.Item, fs.Item) error
+ getter device.DevicesInfoGetter
+ exec func(argv0 string, argv []string, envv []string) error
+ linkedItems fs.HardLinkedItems
+ selectedTextColor tcell.Color
+ selectedBackgroundColor tcell.Color
+ currentItemNameMaxLen int
+ defaultSortBy string
+ defaultSortOrder string
+}
+
+// Option is optional function customizing the bahaviour 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,
+ constGC bool,
+ useSIPrefix bool,
+ opts ...Option,
+) *UI {
+ ui := &UI{
+ UI: &common.UI{
+ UseColors: useColors,
+ ShowApparentSize: showApparentSize,
+ ShowRelativeSize: showRelativeSize,
+ Analyzer: analyze.CreateAnalyzer(),
+ ConstGC: constGC,
+ UseSIPrefix: useSIPrefix,
+ },
+ app: app,
+ screen: screen,
+ output: output,
+ askBeforeDelete: true,
+ showItemCount: false,
+ remover: analyze.RemoveItemFromDir,
+ emptier: analyze.EmptyFileFromDir,
+ exec: Execute,
+ linkedItems: make(fs.HardLinkedItems, 10),
+ selectedTextColor: tview.Styles.TitleColor,
+ selectedBackgroundColor: tview.Styles.MoreContrastBackgroundColor,
+ currentItemNameMaxLen: 70,
+ defaultSortBy: "size",
+ defaultSortOrder: "desc",
+ }
+ 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)
+
+ 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.header = tview.NewTextView()
+ ui.header.SetText(" gdu ~ Use arrow keys to navigate, press ? for help ")
+ ui.header.SetTextColor(textColor)
+ ui.header.SetBackgroundColor(textBgColor)
+
+ 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)
+
+ 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(textColor)
+ ui.footerLabel.SetBackgroundColor(textBgColor)
+ ui.footerLabel.SetText(" No items to display. ")
+
+ ui.footer = tview.NewFlex()
+ ui.footer.AddItem(ui.footerLabel, 0, 1, false)
+
+ 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(ui.table, 2, 0, 1, 1, 0, 0, true).
+ AddItem(ui.footer, 3, 0, 1, 1, 0, 0, false)
+
+ ui.pages = tview.NewPages().
+ AddPage("background", grid, true, true)
+ ui.pages.SetBackgroundColor(tcell.ColorDefault)
+
+ ui.app.SetRoot(ui.pages, true)
+
+ return ui
+}
+
+// SetSelectedTextColor sets the color for the highighted selected text
+func (ui *UI) SetSelectedTextColor(color tcell.Color) {
+ ui.selectedTextColor = color
+}
+
+// SetSelectedBackgroundColor sets the color for the highighted selected text
+func (ui *UI) SetSelectedBackgroundColor(color tcell.Color) {
+ ui.selectedBackgroundColor = 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(len int) {
+ ui.currentItemNameMaxLen = len
+}
+
+// StartUILoop starts tview application
+func (ui *UI) StartUILoop() error {
+ return ui.app.Run()
+}
+
+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
+ }
+
+ origDir := ui.currentDir
+ selectedDir := ui.table.GetCell(row, column).GetReference().(fs.Item)
+ if !selectedDir.IsDir() {
+ return
+ }
+
+ ui.currentDir = selectedDir.(*analyze.Dir)
+ ui.hideFilterInput()
+ ui.showDir()
+
+ if selectedDir == origDir.GetParent() {
+ index, _ := ui.currentDir.GetFiles().IndexOf(origDir)
+ if ui.currentDir != ui.topDir {
+ index++
+ }
+ ui.table.Select(index, 0)
+ }
+}
+
+func (ui *UI) deviceItemSelected(row, column int) {
+ var err error
+ selectedDevice := ui.table.GetCell(row, column).GetReference().(*device.Device)
+
+ 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.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) {
+ 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{"yes", "no", "don't ask me again"}).
+ SetDoneFunc(func(buttonIndex int, buttonLabel string) {
+ switch buttonIndex {
+ case 2:
+ ui.askBeforeDelete = false
+ fallthrough
+ case 0:
+ 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)
+}
--- /dev/null
+package tui
+
+import (
+ "bytes"
+ "errors"
+ "testing"
+
+ 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, 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()
+
+ 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]))
+ }
+}
+
+func TestUpdateProgress(t *testing.T) {
+ app, simScreen := testapp.CreateTestAppWithSimScreen(15, 15)
+ defer simScreen.Fini()
+
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false)
+ done := ui.Analyzer.GetDone()
+ done.Broadcast()
+ ui.updateProgress()
+ assert.True(t, true)
+}
+
+func TestHelp(t *testing.T) {
+ app, simScreen := testapp.CreateTestAppWithSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, false, false)
+ ui.showHelp()
+
+ assert.True(t, ui.pages.HasPage("help"))
+
+ ui.help.Draw(simScreen)
+ simScreen.Show()
+
+ // printScreen(simScreen)
+
+ b, _, _ := simScreen.GetContents()
+
+ cells := b[406 : 406+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, false)
+ ui.showHelp()
+ ui.help.Draw(simScreen)
+ simScreen.Show()
+
+ // printScreen(simScreen)
+
+ b, _, _ := simScreen.GetContents()
+
+ cells := b[406 : 406+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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(false)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false)
+
+ err := ui.StartUILoop()
+
+ assert.Nil(t, err)
+}
+
+func TestAppRunWithErr(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, 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(50, 50)
+ defer simScreen.Fini()
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, 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, true, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, 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, false)
+
+ for _, f := range ui.app.(*testapp.MockedApp).BeforeDraws {
+ assert.False(t, f(screen))
+ }
+}
+
+func TestIgnorePaths(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, 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 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 TestDeleteSelectedWithErr(t *testing.T) {
+ fin := testdir.CreateTestDir()
+ defer fin()
+
+ ui := getAnalyzedPathMockedApp(t, false, true, false)
+ ui.remover = testanalyze.RemoveItemFromDirWithErr
+
+ 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.True(t, ui.pages.HasPage("error"))
+ assert.DirExists(t, "test_dir/nested")
+}
+
+func TestShowErr(t *testing.T) {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, true, true, false, 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(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, 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 printScreen(simScreen tcell.SimulationScreen) {
+// b, _, _ := simScreen.GetContents()
+
+// for i, r := range b {
+// 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 bool, mockedAnalyzer bool) *UI {
+ simScreen := testapp.CreateSimScreen(50, 50)
+ defer simScreen.Fini()
+
+ app := testapp.CreateMockedApp(true)
+ ui := CreateUI(app, simScreen, &bytes.Buffer{}, useColors, apparentSize, false, 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
+}
--- /dev/null
+package tui
+
+import (
+ "github.com/dundee/gdu/v5/pkg/device"
+)
+
+func getDeviceUsagePart(item *device.Device) string {
+ part := int(float64(item.Size-item.Free) / float64(item.Size) * 10.0)
+ row := "["
+ for i := 0; i < 10; i++ {
+ if part > i {
+ row += "#"
+ } else {
+ row += " "
+ }
+ }
+ row += "]"
+ return row
+}
+
+func getUsageGraph(part int) string {
+ graph := " ["
+ for i := 0; i < 10; i++ {
+ if part > i {
+ graph += "#"
+ } else {
+ graph += " "
+ }
+ }
+ graph += "] "
+ return graph
+}
+
+func min(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}