├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yaml ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── commands ├── command.go ├── command_test.go ├── processor.go └── processor_test.go ├── docs └── release.md ├── files ├── const.go ├── file_walker.go ├── file_walker_test.go ├── test_utils.go ├── test_utils_test.go ├── tree_manipulation.go └── tree_manipulation_test.go ├── go.mod ├── go.sum ├── godu.go ├── godu_architecture.drawio ├── godu_architecture.png ├── ignore.go ├── ignore_test.go ├── interactive ├── printer.go ├── printer_test.go ├── reporter.go └── reporter_test.go ├── parser.go ├── state_model.go ├── state_model_test.go ├── state_presenter.go └── testCover.sh /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | - [ ] I've read [Contribution guide](https://github.com/viktomas/godu/blob/master/CONTRIBUTING.md) 2 | - [ ] I've tested everything that doesn't relate to tcell.Screen API 3 | 4 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # taken from https://github.com/goreleaser/goreleaser-action#usage 2 | name: goreleaser 3 | 4 | on: 5 | pull_request: 6 | push: 7 | 8 | jobs: 9 | goreleaser: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 0 16 | - name: Set up Go 17 | uses: actions/setup-go@v3 18 | - name: Run GoReleaser 19 | uses: goreleaser/goreleaser-action@v3 20 | with: 21 | distribution: goreleaser 22 | version: v1.26.1 23 | args: release --rm-dist 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # taken from https://github.com/mvdan/github-actions-golang/blob/master/.github/workflows/test.yml 2 | on: [push, pull_request] 3 | name: Test 4 | jobs: 5 | test: 6 | strategy: 7 | matrix: 8 | go-version: [1.18.x, 1.19.x] 9 | os: [ubuntu-latest, macos-latest, windows-latest] 10 | runs-on: ${{ matrix.os }} 11 | steps: 12 | - uses: actions/setup-go@v3 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - uses: actions/checkout@v3 16 | - run: go test ./... 17 | 18 | test-cache: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/setup-go@v3 22 | with: 23 | go-version: 1.19.x 24 | - uses: actions/checkout@v3 25 | - uses: actions/cache@v3 26 | with: 27 | # In order: 28 | # * Module download cache 29 | # * Build cache (Linux) 30 | # * Build cache (Mac) 31 | # * Build cache (Windows) 32 | path: | 33 | ~/go/pkg/mod 34 | ~/.cache/go-build 35 | ~/Library/Caches/go-build 36 | ~\AppData\Local\go-build 37 | key: ${{ runner.os }}-go-${{ matrix.go-version }}-${{ hashFiles('**/go.sum') }} 38 | restore-keys: | 39 | ${{ runner.os }}-go-${{ matrix.go-version }}- 40 | - run: go test ./... 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage.txt 2 | godu 3 | 4 | dist/ 5 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - linux 9 | - windows 10 | - darwin 11 | - freebsd 12 | checksum: 13 | name_template: 'checksums.txt' 14 | snapshot: 15 | name_template: "{{ incpatch .Version }}-next" 16 | changelog: 17 | sort: asc 18 | filters: 19 | exclude: 20 | - '^docs:' 21 | - '^test:' 22 | brews: 23 | - tap: 24 | owner: viktomas 25 | name: homebrew-taps 26 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 27 | 28 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 29 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 30 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | # - 1.11.x 5 | # - 1.12.x 6 | - tip 7 | 8 | env: 9 | - GO111MODULE=on 10 | script: 11 | - ./testCover.sh 12 | 13 | after_success: 14 | - bash <(curl -s https://codecov.io/bash) 15 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guide 2 | So you decided to help godu out hey? Perfect. Thank you already even for entertaining the idea. 3 | 4 | ## Setting up the project 5 | Why for nomral use it is ok to call `go get` without go modules, if you are going to make changes to the codebase, please make sure you have Go 1.11 or later intalled and you have got Go modules enabled (`GO111MODULE=on`) [Extensive Info](https://github.com/golang/go/wiki/Modules) 6 | 7 | ## Godu architecture 8 | ![godu architecture](godu_architecture.png) 9 | 10 | Main things to consider when implementing new functionality is packages, there are 3: 11 | * `files` - godu's file system representation and algorythms for making and changing the file tree structure 12 | * `commands` - application state representation and functions to change it, all based on the command design pattern 13 | * `interactive` - this is functionality connected to UI of the app 14 | * `main` - this contains main wiring + tcell.Screen related functionality, **this is the only package that is not expected to have 100% test coverage** 15 | 16 | #### Before doing significant changes to `files` and `commands` packages (specially changing the main structures like File and State, please consult the change in form of Github Issue 17 | 18 | ## Main ideas 19 | ### State is immutable 20 | - Except for `folder *File` property the state should be immutable. I would make `folder` immutable as well but that would mean referencing it as a value and potentially copying 200M of memory every user interaction 21 | - Please make sure that you are never using pointers(and arrays) from `oldState` when creating `newState` in a `Executer` (a.k.a. command) 22 | 23 | ### the root folder structure should only represent the file system 24 | You would notice that in `godu.go` we are walking through the whole folder using 25 | ``` 26 | rootFolder := files.WalkFolder(rootFolderName, ioutil.ReadDir, getIgnoredFolders()) 27 | ``` 28 | The expectation is that the `files.File` structure contains only representation of the file system and it is not going to change after calling `WalkFolder` 29 | 30 | ### Everything that commands do should be represented in State 31 | When implementing new command please make sure that it's result is captured in a state and that the state is appropriately displayed using `InteractiveFolder` (e.g. highlighting selected line in file column) 32 | 33 | ## 100% test coverage 34 | Expectation is that `files`, `commands` and `interactive` packages will have 100% test coverage. I'm not a testing nazi but I won't have time to checkout every PR and manually retest it and neither will you. We need to be confident that `godu` still works after merging your (and any subsequent) PR. 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present Tomas Vik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # godu 2 | 3 | [![Build Status](https://travis-ci.org/viktomas/godu.svg?branch=master)](https://travis-ci.org/viktomas/godu) 4 | [![codecov](https://codecov.io/gh/viktomas/godu/branch/master/graph/badge.svg)](https://codecov.io/gh/viktomas/godu) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/viktomas/godu)](https://goreportcard.com/report/github.com/viktomas/godu) 6 | [![Gitter chat](https://badges.gitter.im/viktomas-godu.png)](https://gitter.im/viktomas-godu) 7 | 8 | Find the files that are taking up your space. 9 | 10 | 11 | 12 | Tired of looking like a noob with [Disk Inventory X](http://www.derlien.com/), [Daisy Disk](https://daisydiskapp.com/) or SpaceMonger? Do you want something that: 13 | 14 | * can do the job 15 | * scans your drive blazingly fast 16 | * works in terminal 17 | * makes you look cool 18 | * is written in Golang 19 | * you can contribute to 20 | 21 | ?? 22 | 23 | Well then **look no more** and try out the godu. 24 | 25 | ## Installation 26 | 27 | With `homebrew`: 28 | 29 | ```sh 30 | brew tap viktomas/taps 31 | brew install godu 32 | ``` 33 | 34 | With `go`: 35 | 36 | ```sh 37 | go install github.com/viktomas/godu@latest 38 | ``` 39 | 40 | Or grab a [Released binary](https://github.com/viktomas/godu/releases/latest) for your OS and architecture, extract it and put it on your `$PATH` e.g. `/usr/local/bin`. 41 | 42 | ## Configuration 43 | 44 | You can specify names of ignored folders in `.goduignore` in your home directory: 45 | 46 | ```sh 47 | > cat ~/.goduignore 48 | node_modules 49 | > 50 | ``` 51 | 52 | I found that I could reduce time it took to crawl through the whole drive to 25% when I started ignoring all `node_modules` which cumulatively contain gigabytes of small text files. 53 | 54 | The `.goduignore` is currently only supporting whole folder names. PR that will make it work like `.gitignore` is welcomed. 55 | 56 | ## Usage 57 | 58 | ```sh 59 | godu ~ 60 | godu -l 100 / # walks the whole root but shows only files larger than 100MB 61 | # godu -print0 ~ | xargs -0 rm # use with caution! Will delete all marked files! 62 | ``` 63 | 64 | ## Favourite aliases 65 | 66 | ```bash 67 | # After you exit godu, all selected files get deleted 68 | alias gd="godu -print0 | xargs -0 rm -rf" 69 | # Usage gm ~/destination/folder 70 | # After you exit godu, all selected files get moved to ~/destination/folder 71 | alias gm="godu -print0 | xargs -0 -I _ mv _ " 72 | ``` 73 | 74 | The currently selected file/folder can be marked/unmarked with the space key. Upon exiting, godu prints all marked files/folders to stdout. You can further process them with commands like xargs. 75 | 76 | Mind you `-l ` option is not speeding up the walking process, it just allows you to filter small files you are not interested in from the output. **The default limit is 10MB**. 77 | 78 | Use arrows (or `hjkl`) to move around, space to select a file / folder, `ESC`, `q` or `CTRL+C` to quit 79 | -------------------------------------------------------------------------------- /commands/command.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/viktomas/godu/files" 7 | ) 8 | 9 | // State represents system configuration after processing user input 10 | type State struct { 11 | Folder *files.File 12 | Selected int 13 | history map[*files.File]int // last cursor location in each folder 14 | MarkedFiles map[*files.File]struct{} 15 | } 16 | 17 | // Executer represents a user action triggered on a State 18 | type Executer interface { 19 | Execute(State) (State, error) 20 | } 21 | 22 | // Enter is an action opening selected directory 23 | type Enter struct{} 24 | 25 | // GoBack is an action returning to parent directory 26 | type GoBack struct{} 27 | 28 | // Down is an action selecting next file in the list 29 | type Down struct{} 30 | 31 | // Up is an action selecting previous file in the list 32 | type Up struct{} 33 | 34 | // Mark is an action that saves current directory for later use 35 | type Mark struct{} 36 | 37 | func copyState(state State) State { 38 | return State{ 39 | Folder: state.Folder, 40 | history: state.history, 41 | Selected: state.Selected, 42 | MarkedFiles: state.MarkedFiles, 43 | } 44 | } 45 | 46 | func (d Down) Execute(oldState State) (State, error) { 47 | if oldState.Selected+2 > len(oldState.Folder.Files) { 48 | return oldState, errors.New("trying to go down below last file") 49 | } 50 | newState := copyState(oldState) 51 | newState.Selected = oldState.Selected + 1 52 | return newState, nil 53 | } 54 | 55 | func (u Up) Execute(oldState State) (State, error) { 56 | if oldState.Selected == 0 { 57 | return oldState, errors.New("trying to go above first file") 58 | } 59 | newState := copyState(oldState) 60 | newState.Selected = oldState.Selected - 1 61 | return newState, nil 62 | } 63 | 64 | func (e Enter) Execute(oldState State) (State, error) { 65 | newFolder := oldState.Folder.Files[oldState.Selected] 66 | if len(newFolder.Files) == 0 { 67 | return oldState, errors.New("Trying to enter empty file") 68 | } 69 | newHistory := map[*files.File]int{} 70 | for fp, selected := range oldState.history { 71 | newHistory[fp] = selected 72 | } 73 | newHistory[oldState.Folder] = oldState.Selected 74 | return State{ 75 | Folder: newFolder, 76 | history: newHistory, 77 | Selected: newHistory[newFolder], 78 | MarkedFiles: oldState.MarkedFiles, 79 | }, nil 80 | } 81 | 82 | func (GoBack) Execute(oldState State) (State, error) { 83 | parentFolder := oldState.Folder.Parent 84 | if parentFolder == nil { 85 | return oldState, errors.New("Trying to go back on root") 86 | } 87 | newHistory := map[*files.File]int{} 88 | for fp, selected := range oldState.history { 89 | newHistory[fp] = selected 90 | } 91 | newHistory[oldState.Folder] = oldState.Selected 92 | return State{ 93 | Folder: parentFolder, 94 | history: newHistory, 95 | Selected: newHistory[parentFolder], 96 | MarkedFiles: oldState.MarkedFiles, 97 | }, nil 98 | } 99 | 100 | func (m Mark) Execute(oldState State) (State, error) { 101 | newState := copyState(oldState) 102 | selectedFile := newState.Folder.Files[newState.Selected] 103 | if _, exists := newState.MarkedFiles[selectedFile]; exists { 104 | delete(newState.MarkedFiles, selectedFile) 105 | } else { 106 | newState.MarkedFiles[selectedFile] = struct{}{} 107 | } 108 | return newState, nil 109 | } 110 | -------------------------------------------------------------------------------- /commands/command_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/viktomas/godu/files" 8 | ) 9 | 10 | func TestDownCommand(t *testing.T) { 11 | folder := files.NewTestFolder("a", files.NewTestFile("b", 50), files.NewTestFile("c", 50)) 12 | initialState := State{ 13 | Folder: folder, 14 | } 15 | newState, _ := Down{}.Execute(initialState) 16 | assert.Equal(t, 1, newState.Selected, "Down command didn't change Selected index") 17 | 18 | } 19 | 20 | func TestDownCommandFails(t *testing.T) { 21 | folder := files.NewTestFolder("a", files.NewTestFile("b", 50), files.NewTestFile("c", 50)) 22 | initialState := State{ 23 | Folder: folder, 24 | Selected: 1, 25 | } 26 | newState, err := Down{}.Execute(initialState) 27 | assert.NotNil(t, err, "Down command didn't fail") 28 | assert.Equal(t, initialState, newState, "State mutated when performing Down") 29 | } 30 | 31 | func TestUpCommand(t *testing.T) { 32 | folder := files.NewTestFolder("a", files.NewTestFile("b", 50), files.NewTestFile("c", 50)) 33 | initialState := State{ 34 | Folder: folder, 35 | Selected: 1, 36 | } 37 | newState, _ := Up{}.Execute(initialState) 38 | assert.Equal(t, 0, newState.Selected, "Up command didn't change Selected index") 39 | } 40 | 41 | func TestUpCommandFails(t *testing.T) { 42 | folder := files.NewTestFolder("a", files.NewTestFile("b", 50), files.NewTestFile("c", 50)) 43 | initialState := State{ 44 | Folder: folder, 45 | Selected: 0, 46 | } 47 | newState, err := Up{}.Execute(initialState) 48 | assert.NotNil(t, err, "Up command didn't fail") 49 | assert.Equal(t, initialState, newState, "State mutated when performing Up") 50 | } 51 | 52 | func TestEnterCommand(t *testing.T) { 53 | folder := files.NewTestFolder("a", files.NewTestFile("b", 50), files.NewTestFolder("c", files.NewTestFile("d", 50), files.NewTestFile("e", 50))) 54 | subFolder := folder.Files[1] 55 | marked := make(map[*files.File]struct{}) 56 | initialState := State{ 57 | Folder: folder, 58 | history: map[*files.File]int{subFolder: 1}, 59 | Selected: 1, 60 | MarkedFiles: marked, 61 | } 62 | command := Enter{} 63 | newState, _ := command.Execute(initialState) 64 | expectedState := State{ 65 | Folder: subFolder, 66 | history: map[*files.File]int{folder: 1, subFolder: 1}, 67 | Selected: 1, 68 | MarkedFiles: marked, 69 | } 70 | assert.Equal(t, expectedState, newState) 71 | } 72 | 73 | func TestEnterCommandFails(t *testing.T) { 74 | folder := files.NewTestFolder("a", files.NewTestFile("b", 50)) 75 | initialState := State{ 76 | Folder: folder, 77 | } 78 | command := Enter{} 79 | _, err := command.Execute(initialState) 80 | assert.NotNil(t, err, "Command Enter entered a file") 81 | } 82 | 83 | func TestGoBackCommand(t *testing.T) { 84 | folder := files.NewTestFolder("a", files.NewTestFile("b", 50), files.NewTestFolder("c", files.NewTestFile("d", 50), files.NewTestFile("e", 50))) 85 | subFolder := folder.Files[1] 86 | marked := make(map[*files.File]struct{}) 87 | initialState := State{ 88 | Folder: subFolder, 89 | history: map[*files.File]int{folder: 1}, 90 | Selected: 1, 91 | MarkedFiles: marked, 92 | } 93 | command := GoBack{} 94 | newState, _ := command.Execute(initialState) 95 | expectedState := State{ 96 | Folder: folder, 97 | history: map[*files.File]int{folder: 1, subFolder: 1}, 98 | Selected: 1, 99 | MarkedFiles: marked, 100 | } 101 | assert.Equal(t, expectedState, newState) 102 | } 103 | 104 | func TestGoBackOnRoot(t *testing.T) { 105 | folder := files.NewTestFolder("a", files.NewTestFile("b", 50)) 106 | initialState := State{ 107 | Folder: folder, 108 | } 109 | command := GoBack{} 110 | _, err := command.Execute(initialState) 111 | assert.NotNil(t, err, "GoBack should fail on root") 112 | } 113 | 114 | func TestMarkFile(t *testing.T) { 115 | folder := files.NewTestFolder("a", files.NewTestFile("b", 50)) 116 | initialState := State{ 117 | Folder: folder, 118 | Selected: 0, 119 | MarkedFiles: make(map[*files.File]struct{}), 120 | } 121 | command := Mark{} 122 | newState, _ := command.Execute(initialState) 123 | _, marked := newState.MarkedFiles[folder.Files[0]] 124 | assert.True(t, marked) 125 | newState, _ = command.Execute(newState) 126 | _, marked = newState.MarkedFiles[folder.Files[0]] 127 | assert.False(t, marked) 128 | } 129 | -------------------------------------------------------------------------------- /commands/processor.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | "github.com/viktomas/godu/files" 8 | ) 9 | 10 | // ProcessFolder removes small files and sorts folder content based on accumulated size 11 | func ProcessFolder(folder *files.File, limit int64) error { 12 | files.PruneSmallFiles(folder, limit) 13 | if len(folder.Files) == 0 { 14 | return fmt.Errorf("the folder '%s' doesn't contain any files bigger than %dMB", folder.Name, limit/files.MEGABYTE) 15 | } 16 | files.SortDesc(folder) 17 | return nil 18 | } 19 | 20 | // StartProcessing reads user commands and applies them to state 21 | func StartProcessing( 22 | folder *files.File, 23 | commands <-chan Executer, 24 | states chan<- State, 25 | lastStateChan chan<- *State, 26 | wg *sync.WaitGroup, 27 | ) { 28 | defer wg.Done() 29 | state := State{ 30 | Folder: folder, 31 | MarkedFiles: make(map[*files.File]struct{}), 32 | } 33 | states <- state 34 | for { 35 | command, more := <-commands 36 | if !more { 37 | close(states) 38 | break 39 | } 40 | if newState, err := command.Execute(state); err == nil { 41 | state = newState 42 | states <- state 43 | } 44 | } 45 | lastStateChan <- &state 46 | } 47 | -------------------------------------------------------------------------------- /commands/processor_test.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/viktomas/godu/files" 9 | ) 10 | 11 | func TestProcessFolder(t *testing.T) { 12 | folder := files.NewTestFolder("a", files.NewTestFile("b", 10), files.NewTestFile("c", 50), files.NewTestFile("d", 70)) 13 | ProcessFolder(folder, 30) 14 | c := &files.File{"c", nil, 50, false, []*files.File{}} 15 | d := &files.File{"d", nil, 70, false, []*files.File{}} 16 | a := &files.File{"a", nil, 130, true, []*files.File{d, c}} 17 | d.Parent = a 18 | c.Parent = a 19 | assert.Equal(t, a, folder, "ProcessFoler didn't prune and sort folder") 20 | } 21 | 22 | func TestProcessFolderShouldFailWithSmallFiles(t *testing.T) { 23 | folder := files.NewTestFolder("a", files.NewTestFile("b", 70)) 24 | err := ProcessFolder(folder, 80) 25 | assert.NotNil(t, err, "ProcessFolder didn't result in error when run on folder with too small files") 26 | } 27 | 28 | func TestStartProcessing(t *testing.T) { 29 | commands := make(chan Executer) 30 | states := make(chan State, 2) 31 | lastStateChan := make(chan<- *State, 1) 32 | folder := files.NewTestFolder("a", files.NewTestFile("b", 10), files.NewTestFile("c", 50)) 33 | var wg sync.WaitGroup 34 | wg.Add(1) 35 | go StartProcessing(folder, commands, states, lastStateChan, &wg) 36 | commands <- Down{} 37 | close(commands) 38 | state := <-states 39 | state = <-states 40 | wg.Wait() 41 | assert.Equal(t, 1, state.Selected, "StartProcessing didn't process command") 42 | state, ok := <-states 43 | assert.False(t, ok, "forgot to close states channel") 44 | } 45 | 46 | func TestDoesntProcessInvalidCommand(t *testing.T) { 47 | commands := make(chan Executer) 48 | states := make(chan State) 49 | lastStateChan := make(chan<- *State, 1) 50 | var wg sync.WaitGroup 51 | wg.Add(1) 52 | folder := files.NewTestFolder("a", files.NewTestFile("b", 10), files.NewTestFile("c", 50)) 53 | go StartProcessing(folder, commands, states, lastStateChan, &wg) 54 | <-states 55 | commands <- Enter{} 56 | close(commands) 57 | wg.Wait() //would block if StartProcessing adds second state to the channel 58 | } 59 | -------------------------------------------------------------------------------- /docs/release.md: -------------------------------------------------------------------------------- 1 | # Releasing `godu` 2 | 3 | `godu` is released using a GitHub action and `goreleaser`. 4 | 5 | 1. tag a new version `git tag v1.5.1` 6 | 1. push the tag `git push origin v1.5.1` 7 | 8 | If everything goes well, that's all you have to do. 9 | 10 | ## Generate homebrew taps token 11 | Once a year, you need to [generate a new token](https://github.com/settings/tokens?type=beta) for the [homebrew-taps](https://github.com/viktomas/homebrew-taps) repository. So that the `goreleaser` job can write into it. 12 | -------------------------------------------------------------------------------- /files/const.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | const ( 4 | _ = iota 5 | KILOBYTE = 1 << (10 * iota) 6 | MEGABYTE 7 | GIGABYTE 8 | TERABYTE 9 | PETABYTE 10 | ) 11 | -------------------------------------------------------------------------------- /files/file_walker.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "sync" 9 | ) 10 | 11 | // File structure representing files and folders with their accumulated sizes 12 | type File struct { 13 | Name string 14 | Parent *File 15 | Size int64 16 | IsDir bool 17 | Files []*File 18 | } 19 | 20 | // Path builds a file system location for given file 21 | func (f *File) Path() string { 22 | if f.Parent == nil { 23 | return f.Name 24 | } 25 | return filepath.Join(f.Parent.Path(), f.Name) 26 | } 27 | 28 | // UpdateSize goes through subfiles and subfolders and accumulates their size 29 | func (f *File) UpdateSize() { 30 | if !f.IsDir { 31 | return 32 | } 33 | var size int64 34 | for _, child := range f.Files { 35 | child.UpdateSize() 36 | size += child.Size 37 | } 38 | f.Size = size 39 | } 40 | 41 | // ReadDir function can return list of files for given folder path 42 | type ReadDir func(dirname string) ([]os.FileInfo, error) 43 | 44 | // ShouldIgnoreFolder function decides whether a folder should be ignored 45 | type ShouldIgnoreFolder func(absolutePath string) bool 46 | 47 | func ignoringReadDir(shouldIgnore ShouldIgnoreFolder, originalReadDir ReadDir) ReadDir { 48 | return func(path string) ([]os.FileInfo, error) { 49 | if shouldIgnore(path) { 50 | return []os.FileInfo{}, nil 51 | } 52 | return originalReadDir(path) 53 | } 54 | } 55 | 56 | // WalkFolder will go through a given folder and subfolders and produces file structure 57 | // with aggregated file sizes 58 | func WalkFolder( 59 | path string, 60 | readDir ReadDir, 61 | ignoreFunction ShouldIgnoreFolder, 62 | progress chan<- int, 63 | ) *File { 64 | var wg sync.WaitGroup 65 | c := make(chan bool, 2*runtime.NumCPU()) 66 | root := walkSubFolderConcurrently(path, nil, ignoringReadDir(ignoreFunction, readDir), c, &wg, progress) 67 | wg.Wait() 68 | close(progress) 69 | root.UpdateSize() 70 | return root 71 | } 72 | 73 | func walkSubFolderConcurrently( 74 | path string, 75 | parent *File, 76 | readDir ReadDir, 77 | c chan bool, 78 | wg *sync.WaitGroup, 79 | progress chan<- int, 80 | ) *File { 81 | result := &File{} 82 | entries, err := readDir(path) 83 | if err != nil { 84 | log.Println(err) 85 | return result 86 | } 87 | dirName, name := filepath.Split(path) 88 | result.Files = make([]*File, 0, len(entries)) 89 | numSubFolders := 0 90 | defer updateProgress(progress, &numSubFolders) 91 | var mutex sync.Mutex 92 | for _, entry := range entries { 93 | if entry.IsDir() { 94 | numSubFolders++ 95 | subFolderPath := filepath.Join(path, entry.Name()) 96 | wg.Add(1) 97 | go func() { 98 | c <- true 99 | subFolder := walkSubFolderConcurrently(subFolderPath, result, readDir, c, wg, progress) 100 | mutex.Lock() 101 | result.Files = append(result.Files, subFolder) 102 | mutex.Unlock() 103 | <-c 104 | wg.Done() 105 | }() 106 | } else { 107 | size := entry.Size() 108 | file := &File{ 109 | entry.Name(), 110 | result, 111 | size, 112 | false, 113 | []*File{}, 114 | } 115 | mutex.Lock() 116 | result.Files = append(result.Files, file) 117 | mutex.Unlock() 118 | } 119 | } 120 | if parent != nil { 121 | result.Name = name 122 | result.Parent = parent 123 | } else { 124 | // Root dir 125 | // TODO unit test this Join 126 | result.Name = filepath.Join(dirName, name) 127 | } 128 | result.IsDir = true 129 | return result 130 | } 131 | 132 | func updateProgress(progress chan<- int, count *int) { 133 | if *count > 0 { 134 | progress <- *count 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /files/file_walker_test.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | type fakeFile struct { 16 | fileName string 17 | fileSize int64 18 | fakeFiles []fakeFile 19 | } 20 | 21 | func (f fakeFile) Name() string { return f.fileName } 22 | func (f fakeFile) Size() int64 { return f.fileSize } 23 | func (f fakeFile) Mode() os.FileMode { return 0 } 24 | func (f fakeFile) ModTime() time.Time { return time.Now() } 25 | func (f fakeFile) IsDir() bool { return len(f.fakeFiles) > 0 } 26 | func (f fakeFile) Sys() interface{} { return nil } 27 | 28 | func createReadDir(ff fakeFile) ReadDir { 29 | return func(path string) ([]os.FileInfo, error) { 30 | names := strings.Split(filepath.ToSlash(path), "/") // oh windows, I'm looking at you you silly bugger 31 | fakeFolder := ff 32 | var found bool 33 | for _, name := range names { 34 | found = false 35 | for _, testFile := range fakeFolder.fakeFiles { 36 | if testFile.fileName == name { 37 | fakeFolder = testFile 38 | found = true 39 | break 40 | } 41 | } 42 | if !found { 43 | return []os.FileInfo{}, fmt.Errorf("file not found") 44 | } 45 | 46 | } 47 | result := make([]os.FileInfo, len(fakeFolder.fakeFiles)) 48 | for i, resultFile := range fakeFolder.fakeFiles { 49 | result[i] = resultFile 50 | } 51 | return result, nil 52 | } 53 | } 54 | 55 | func TestFilePath(t *testing.T) { 56 | root := NewTestFolder("root", 57 | NewTestFolder("folder1", 58 | NewTestFile("file1", 0), 59 | ), 60 | ) 61 | want := filepath.Join("root", "folder1", "file1") 62 | file1 := FindTestFile(root, "file1") 63 | assert.Equal(t, want, file1.Path()) 64 | } 65 | 66 | func TestWalkFolderOnSimpleDir(t *testing.T) { 67 | testStructure := fakeFile{"a", 0, []fakeFile{ 68 | {"b", 0, []fakeFile{ 69 | {"c", 100, []fakeFile{}}, 70 | {"d", 0, []fakeFile{ 71 | {"e", 50, []fakeFile{}}, 72 | {"f", 30, []fakeFile{}}, 73 | {"g", 70, []fakeFile{ //thisfolder should get ignored 74 | {"h", 10, []fakeFile{}}, 75 | {"i", 20, []fakeFile{}}, 76 | }}, 77 | }}, 78 | }}, 79 | }} 80 | dummyIgnoreFunction := func(p string) bool { return p == filepath.Join("b", "d", "g") } 81 | progress := make(chan int, 3) 82 | result := WalkFolder("b", createReadDir(testStructure), dummyIgnoreFunction, progress) 83 | buildExpected := func() *File { 84 | b := &File{"b", nil, 180, true, []*File{}} 85 | c := &File{"c", b, 100, false, []*File{}} 86 | d := &File{"d", b, 80, true, []*File{}} 87 | b.Files = []*File{c, d} 88 | 89 | e := &File{"e", nil, 50, false, []*File{}} 90 | e.Parent = d 91 | f := &File{"f", nil, 30, false, []*File{}} 92 | g := &File{"g", nil, 0, true, []*File{}} 93 | f.Parent = d 94 | g.Parent = d 95 | d.Files = []*File{e, f, g} 96 | 97 | return b 98 | } 99 | expected := buildExpected() 100 | assert.Equal(t, expected, result) 101 | resultProgress := 0 102 | resultProgress += <-progress 103 | resultProgress += <-progress 104 | _, more := <-progress 105 | assert.Equal(t, 2, resultProgress) 106 | assert.False(t, more, "the progress channel should be closed") 107 | } 108 | 109 | func TestWalkFolderHandlesError(t *testing.T) { 110 | failing := func(path string) ([]os.FileInfo, error) { 111 | return []os.FileInfo{}, errors.New("Not found") 112 | } 113 | progress := make(chan int, 2) 114 | result := WalkFolder("xyz", failing, func(string) bool { return false }, progress) 115 | assert.Equal(t, File{}, *result, "WalkFolder didn't return empty file on ReadDir failure") 116 | } 117 | -------------------------------------------------------------------------------- /files/test_utils.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | // NewTestFolder is providing easy interface to create folders for automated tests 4 | // Never use in production code! 5 | func NewTestFolder(name string, files ...*File) *File { 6 | folder := &File{name, nil, 0, true, []*File{}} 7 | if files == nil { 8 | return folder 9 | } 10 | for _, file := range files { 11 | file.Parent = folder 12 | } 13 | folder.Files = files 14 | folder.UpdateSize() 15 | return folder 16 | } 17 | 18 | // NewTestFile provides easy interface to create files for automated tests 19 | // Never use in production code! 20 | func NewTestFile(name string, size int64) *File { 21 | return &File{name, nil, size, false, []*File{}} 22 | } 23 | 24 | // FindTestFile helps testing by returning first occurrence of file with given name. 25 | // Never use in production code! 26 | func FindTestFile(folder *File, name string) *File { 27 | if folder.Name == name { 28 | return folder 29 | } 30 | for _, file := range folder.Files { 31 | result := FindTestFile(file, name) 32 | if result != nil { 33 | return result 34 | } 35 | } 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /files/test_utils_test.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestBuildFile(t *testing.T) { 10 | a := &File{"a", nil, 100, false, []*File{}} 11 | build := NewTestFile("a", 100) 12 | assert.Equal(t, a, build) 13 | } 14 | 15 | func TestBuildFolder(t *testing.T) { 16 | a := &File{"a", nil, 0, true, []*File{}} 17 | build := NewTestFolder("a") 18 | assert.Equal(t, a, build) 19 | } 20 | 21 | func TestBuildFolderWithFile(t *testing.T) { 22 | e := &File{"e", nil, 100, false, []*File{}} 23 | d := &File{"d", nil, 100, true, []*File{e}} 24 | e.Parent = d 25 | build := NewTestFolder("d", NewTestFile("e", 100)) 26 | assert.Equal(t, d, build) 27 | } 28 | 29 | func TestBuildComplexFolder(t *testing.T) { 30 | e := &File{"e", nil, 100, false, []*File{}} 31 | d := &File{"d", nil, 100, true, []*File{e}} 32 | e.Parent = d 33 | b := &File{"b", nil, 50, false, []*File{}} 34 | c := &File{"c", nil, 100, false, []*File{}} 35 | a := &File{"a", nil, 250, true, []*File{b, c, d}} 36 | b.Parent = a 37 | c.Parent = a 38 | d.Parent = a 39 | build := NewTestFolder("a", NewTestFile("b", 50), NewTestFile("c", 100), NewTestFolder("d", NewTestFile("e", 100))) 40 | assert.Equal(t, a, build) 41 | } 42 | 43 | func TestFindTestFile(t *testing.T) { 44 | folder := NewTestFolder("a", 45 | NewTestFolder("b", 46 | NewTestFile("c", 10), 47 | NewTestFile("d", 100), 48 | ), 49 | ) 50 | expected := folder.Files[0].Files[1] 51 | foundFile := FindTestFile(folder, "d") 52 | assert.Equal(t, expected, foundFile) 53 | } 54 | -------------------------------------------------------------------------------- /files/tree_manipulation.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "sort" 5 | ) 6 | 7 | // SortDesc sorts folder content by size from largest to smallest 8 | func SortDesc(folder *File) { 9 | sort.Slice(folder.Files, 10 | func(i, j int) bool { 11 | return folder.Files[i].Size > folder.Files[j].Size 12 | }) 13 | for _, file := range folder.Files { 14 | SortDesc(file) 15 | } 16 | } 17 | 18 | func PruneSmallFiles(folder *File, limit int64) { 19 | prunedFiles := []*File{} 20 | for _, file := range folder.Files { 21 | if file.Size >= limit { 22 | PruneSmallFiles(file, limit) 23 | prunedFiles = append(prunedFiles, file) 24 | } 25 | } 26 | folder.Files = prunedFiles 27 | 28 | } 29 | -------------------------------------------------------------------------------- /files/tree_manipulation_test.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestSortFolder(t *testing.T) { 10 | folder := NewTestFolder("b", 11 | NewTestFile("c", 100), 12 | NewTestFolder("d", 13 | NewTestFile("e", 50), 14 | NewTestFile("f", 30), 15 | NewTestFolder("g", 16 | NewTestFile("i", 30), 17 | NewTestFile("j", 50), 18 | ), 19 | ), 20 | ) 21 | expected := NewTestFolder("b", 22 | NewTestFolder("d", 23 | NewTestFolder("g", 24 | NewTestFile("j", 50), 25 | NewTestFile("i", 30), 26 | ), 27 | NewTestFile("e", 50), 28 | NewTestFile("f", 30), 29 | ), 30 | NewTestFile("c", 100), 31 | ) 32 | 33 | SortDesc(folder) 34 | assert.Equal(t, expected, folder) 35 | } 36 | 37 | func TestPruneFolder(t *testing.T) { 38 | folder := &File{"b", nil, 260, true, []*File{ 39 | {"c", nil, 100, false, []*File{}}, 40 | {"d", nil, 160, true, []*File{ 41 | {"e", nil, 50, false, []*File{}}, 42 | {"f", nil, 30, false, []*File{}}, 43 | {"g", nil, 80, true, []*File{ 44 | {"i", nil, 50, false, []*File{}}, 45 | {"j", nil, 30, false, []*File{}}, 46 | }}, 47 | }}, 48 | }} 49 | expected := &File{"b", nil, 260, true, []*File{ 50 | {"c", nil, 100, false, []*File{}}, 51 | {"d", nil, 160, true, []*File{ 52 | {"g", nil, 80, true, []*File{}}, 53 | }}, 54 | }} 55 | PruneSmallFiles(folder, 60) 56 | assert.Equal(t, expected, folder) 57 | } 58 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/viktomas/godu 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/gdamore/tcell/v2 v2.5.3 7 | github.com/gosuri/uilive v0.0.4 8 | github.com/stretchr/testify v1.7.1 9 | ) 10 | 11 | require ( 12 | github.com/davecgh/go-spew v1.1.1 // indirect 13 | github.com/gdamore/encoding v1.0.0 // indirect 14 | github.com/kr/pretty v0.3.0 // indirect 15 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 16 | github.com/mattn/go-isatty v0.0.14 // indirect 17 | github.com/mattn/go-runewidth v0.0.15 // indirect 18 | github.com/pmezard/go-difflib v1.0.0 // indirect 19 | github.com/rivo/uniseg v0.2.0 // indirect 20 | github.com/rogpeppe/go-internal v1.8.1 // indirect 21 | golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f // indirect 22 | golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf // indirect 23 | golang.org/x/text v0.3.7 // indirect 24 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 25 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= 6 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 7 | github.com/gdamore/tcell/v2 v2.5.3 h1:b9XQrT6QGbgI7JvZOJXFNczOQeIYbo8BfeSMzt2sAV0= 8 | github.com/gdamore/tcell/v2 v2.5.3/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo= 9 | github.com/gosuri/uilive v0.0.4 h1:hUEBpQDj8D8jXgtCdBu7sWsy5sbW/5GhuO8KBwJ2jyY= 10 | github.com/gosuri/uilive v0.0.4/go.mod h1:V/epo5LjjlDE5RJUcqx8dbw+zc93y5Ya3yg8tfZ74VI= 11 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 12 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 13 | github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= 14 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 15 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 16 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 17 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 18 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 19 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 20 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 21 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= 22 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 23 | github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= 24 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 25 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 26 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 27 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 28 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 29 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 30 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 31 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 32 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 33 | github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= 34 | github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= 35 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 36 | github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= 37 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 38 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 39 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 | golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f h1:8w7RhxzTVgUzw/AH/9mUV5q0vMgy40SQRursCcfmkCw= 42 | golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 | golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= 44 | golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 45 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 46 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 47 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 48 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 49 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 50 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 51 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 52 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 53 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 54 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 55 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= 56 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 57 | -------------------------------------------------------------------------------- /godu.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "sync" 11 | "time" 12 | 13 | "github.com/gdamore/tcell/v2" 14 | "github.com/gosuri/uilive" 15 | "github.com/viktomas/godu/commands" 16 | "github.com/viktomas/godu/files" 17 | 18 | "github.com/viktomas/godu/interactive" 19 | ) 20 | 21 | // the correct version is injected by `go build` command in release.sh script 22 | var goduVersion = "master" 23 | 24 | func main() { 25 | limit := flag.Int64("l", 10, "show only files larger than limit (in MB)") 26 | nullTerminate := flag.Bool("print0", false, "print null-terminated strings") 27 | version := flag.Bool("v", false, "show version") 28 | flag.Usage = func() { 29 | fmt.Fprintf(os.Stderr, "Usage: godu [OPTION]... [DIRECTORY]\nShow disk usage under DIRECTORY (. by default) interactively.\n\nOptions:\n") 30 | flag.PrintDefaults() 31 | fmt.Fprintf(os.Stderr, "\nThe currently selected file/folder can be marked/unmarked with the space key. Upon exiting, godu prints all marked files/folders to stdout. You can further process them with commands like xargs.\n\nFor example:\n\n# Show information of selected files\ngodu -print0 | xargs -0 ls -l\n\n# Delete selected files\ngodu -print0 | xargs -0 rm -rf\n\n# Move selected files to 'tmp' directory\ngodu -print0 | xargs -0 -I _ mv _ tmp\n") 32 | } 33 | flag.Parse() 34 | if *version { 35 | fmt.Printf("godu %s\n", goduVersion) 36 | os.Exit(0) 37 | } 38 | args := flag.Args() 39 | rootFolderName := "." 40 | if len(args) > 0 { 41 | rootFolderName = args[0] 42 | } 43 | rootFolderName, err := filepath.Abs(rootFolderName) 44 | if err != nil { 45 | log.Fatalln(err.Error()) 46 | } 47 | progress := make(chan int) 48 | go reportProgress(progress) 49 | rootFolder := files.WalkFolder(rootFolderName, ioutil.ReadDir, ignoreBasedOnIgnoreFile(readIgnoreFile()), progress) 50 | rootFolder.Name = rootFolderName 51 | err = commands.ProcessFolder(rootFolder, *limit*files.MEGABYTE) 52 | if err != nil { 53 | log.Fatalln(err.Error()) 54 | } 55 | s := initScreen() 56 | commandsChan := make(chan commands.Executer) 57 | states := make(chan commands.State) 58 | lastStateChan := make(chan *commands.State, 1) 59 | var wg sync.WaitGroup 60 | wg.Add(3) 61 | go commands.StartProcessing(rootFolder, commandsChan, states, lastStateChan, &wg) 62 | go interactiveFolder(s, states, &wg) 63 | go parseCommand(s, commandsChan, &wg) 64 | wg.Wait() 65 | s.Fini() 66 | lastState := <-lastStateChan 67 | printMarkedFiles(lastState, *nullTerminate) 68 | } 69 | 70 | func reportProgress(progress <-chan int) { 71 | const interval = 50 * time.Millisecond 72 | writer := uilive.New() 73 | writer.Out = os.Stderr 74 | writer.Start() 75 | defer writer.Stop() 76 | total := 0 77 | ticker := time.NewTicker(interval) 78 | for { 79 | select { 80 | case c, ok := <-progress: 81 | if !ok { 82 | return 83 | } 84 | total += c 85 | case <-ticker.C: 86 | fmt.Fprintf(writer, "Walked through %d folders\n", total) 87 | } 88 | } 89 | } 90 | 91 | func printMarkedFiles(lastState *commands.State, nullTerminate bool) { 92 | markedFiles := interactive.FilesAsSlice(lastState.MarkedFiles) 93 | var printFunc func(string) 94 | if nullTerminate { 95 | printFunc = func(s string) { 96 | fmt.Printf("%s\x00", s) 97 | } 98 | } else { 99 | printFunc = func(s string) { 100 | fmt.Println(s) 101 | } 102 | } 103 | for _, f := range markedFiles { 104 | printFunc(f) 105 | } 106 | } 107 | 108 | func initScreen() tcell.Screen { 109 | tcell.SetEncodingFallback(tcell.EncodingFallbackASCII) 110 | s, e := tcell.NewScreen() 111 | if e != nil { 112 | log.Printf("%v\n", e) 113 | os.Exit(1) 114 | } 115 | if e = s.Init(); e != nil { 116 | log.Printf("%v\n", e) 117 | os.Exit(1) 118 | } 119 | s.Clear() 120 | return s 121 | } 122 | -------------------------------------------------------------------------------- /godu_architecture.drawio: -------------------------------------------------------------------------------- 1 | 1VnZctowFP0az7QPYbxglscCCV1n0tKZLC8dYV8vjWwxsgiQr++1LWMbuZSkTgAeEt8ryUjn3HMkG80aR+spJ4vgG3OBaqburjVropmmYeg9/JdmNjKj63ae8XnoylyZmIVPUHSU2WXoQlLrKBijIlzUkw6LY3BELUc4Z6t6N4/R+rcuiA9KYuYQqmZvQlcEeXZg9sv8Rwj9oPhmozfMWyJSdJYrSQLislUlZV1q1pgzJvKraD0GmqJX4JKPu/pL63ZiHGJxyID74ff7wXhC+4tpf/OD/Jp+vh1dSHoeCV3KBcvJik2BAGfL2IX0JrpmjVZBKGC2IE7aukLSMReIiGJk4KUXUjpmlHGMYxZjp5E6Tzn1R+AC1pWUnPcUWASCb7BLWUf5kLKK8nhVUmJ3ZS6o0GEVSSLLwN/eu0QKLyRYzwDOUIECFytHhnLtdewYFwHzWUzoV8YWErHfIMRG1j1ZClbHE9ahuE2Hd2wZ3VVaJmt55yzYFEGMC6wMSsO7als5LIuKcYng7AEK9jTT0rPPPgoTtuQO7INJSpZwH8S/6zCFcG9BcKBEhI91cbZPrqIKrGtIOjeEPlyhgwB/9749nSDSLoGB5zRx0HMGMPfakZFl7spIUZFhNqio91oiGigg/odEjIpASrm0K5E6x68hGPNAwVinJRhLUcxMEIEb9y7BWKqizh+HJHwi86xDit2ChbHIJmiPNHuCGUJDP8aEg2gCQj1KSz7EPfqDbIhC103HjyiZAx0R58HPmKpQ42WfRmr2laYiru1JQ864tpk3ie5C7+jGcFgXXh4dzI28+XWKTKUL87wEi2SXvO0cXs6nqdDpsCgisZt0kFcurjlzkLbYP08f1Hd80Gwwwt5bGuHw9A8TRs0nS9t8w8OEdaA32qfljV1FTBNOViieszXHYYvmiA9MxnmZo7rXhSn0xEmnmx8Pz9AUu4O6KZrdY58ODfUYPpa70BHtUj/MLvXj22X3QLs0T8ouVbfEtScgmT9DYW1PEqcjLF2B8VX108KDmXEE/dgH6qd7UvoxbEVAX2Bzvg9iRbG28ySmn9uTmMqmcHBYZ+ZwgLhVO/Q86DmNduj2h/P9Ynr5Syird2w77CsQfyrPc/lyU66Zh3985i7bkRLaZZI7p1FRFgVPNOhKpJY7SpBJfGz4mfkv2k4729PuS8Gmt4JN79bN5/OBYfl7R66Q8mcj6/IP -------------------------------------------------------------------------------- /godu_architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/viktomas/godu/54765d14f9b60603d7e3b32e686945e5ae543c5f/godu_architecture.png -------------------------------------------------------------------------------- /ignore.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "log" 6 | "os" 7 | "os/user" 8 | "path/filepath" 9 | 10 | "github.com/viktomas/godu/files" 11 | ) 12 | 13 | func readIgnoreFile() []string { 14 | usr, err := user.Current() 15 | if err != nil { 16 | log.Println("Wasn't able to retrieve current user at runtime") 17 | return []string{} 18 | } 19 | ignoreFileName := filepath.Join(usr.HomeDir, ".goduignore") 20 | if _, err := os.Stat(ignoreFileName); os.IsNotExist(err) { 21 | return []string{} 22 | } 23 | ignoreFile, err := os.Open(ignoreFileName) 24 | if err != nil { 25 | log.Printf("Failed to read ingorefile because %s\n", err.Error()) 26 | return []string{} 27 | } 28 | defer ignoreFile.Close() 29 | scanner := bufio.NewScanner(ignoreFile) 30 | lines := []string{} 31 | for scanner.Scan() { 32 | lines = append(lines, scanner.Text()) 33 | } 34 | return lines 35 | } 36 | 37 | func ignoreBasedOnIgnoreFile(ignoreFile []string) files.ShouldIgnoreFolder { 38 | ignoredFolders := map[string]struct{}{} 39 | for _, line := range ignoreFile { 40 | ignoredFolders[line] = struct{}{} 41 | } 42 | return func(absolutePath string) bool { 43 | _, name := filepath.Split(absolutePath) 44 | _, ignored := ignoredFolders[name] 45 | return ignored 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /ignore_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestIgnoreBasedOnIgnoreFile(t *testing.T) { 10 | ignored := []string{"node_modules"} 11 | ignoreFunction := ignoreBasedOnIgnoreFile(ignored) 12 | assert.True(t, ignoreFunction("something/node_modules")) 13 | assert.False(t, ignoreFunction("something/notIgnored")) 14 | } 15 | -------------------------------------------------------------------------------- /interactive/printer.go: -------------------------------------------------------------------------------- 1 | package interactive 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/viktomas/godu/files" 7 | ) 8 | 9 | type byLength []string 10 | 11 | func (l byLength) Len() int { return len(l) } 12 | func (l byLength) Swap(i, j int) { l[i], l[j] = l[j], l[i] } 13 | func (l byLength) Less(i, j int) bool { return len(l[i]) > len(l[j]) } 14 | 15 | // FilesAsSlice takes files from the map and returns a sorted slice of file paths. 16 | func FilesAsSlice(in map[*files.File]struct{}) []string { 17 | out := make([]string, 0, len(in)) 18 | for file := range in { 19 | p := file.Path() 20 | out = append(out, p) 21 | } 22 | // sorting length of the path (assuming that we want to delete files in subdirs first) 23 | // alphabetical sorting added for determinism (map keys doesn't guarantee order) 24 | sort.Sort(sort.StringSlice(out)) 25 | sort.Sort(byLength(out)) 26 | return out 27 | } 28 | -------------------------------------------------------------------------------- /interactive/printer_test.go: -------------------------------------------------------------------------------- 1 | package interactive 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/viktomas/godu/files" 9 | ) 10 | 11 | func TestFilesAsSliceEmptyMap(t *testing.T) { 12 | marked := make(map[*files.File]struct{}) 13 | result := FilesAsSlice(marked) 14 | assert.Equal(t, 0, len(result)) 15 | } 16 | 17 | func TestFilesAsSlice(t *testing.T) { 18 | root := files.NewTestFolder(".", 19 | files.NewTestFile("'single''quotes'", 0), 20 | files.NewTestFolder("d1", 21 | files.NewTestFile("f1", 0), 22 | files.NewTestFolder("d3", 23 | files.NewTestFile("f2", 0), 24 | ), 25 | ), 26 | files.NewTestFolder("d2"), 27 | files.NewTestFile("f3", 0), 28 | ) 29 | marked := make(map[*files.File]struct{}) 30 | marked[files.FindTestFile(root, "d1")] = struct{}{} 31 | marked[files.FindTestFile(root, "d2")] = struct{}{} 32 | marked[files.FindTestFile(root, "f1")] = struct{}{} 33 | marked[files.FindTestFile(root, "f2")] = struct{}{} 34 | marked[files.FindTestFile(root, "'single''quotes'")] = struct{}{} 35 | 36 | want := []string{"'single''quotes'", filepath.Join("d1", "d3", "f2"), filepath.Join("d1", "f1"), "d1", "d2"} 37 | got := FilesAsSlice(marked) 38 | assert.Equal(t, want, got) 39 | } 40 | -------------------------------------------------------------------------------- /interactive/reporter.go: -------------------------------------------------------------------------------- 1 | package interactive 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/viktomas/godu/files" 7 | ) 8 | 9 | // Line represents row of text in folder UI contains info about subfile 10 | type Line struct { 11 | Text []rune 12 | IsMarked bool 13 | } 14 | 15 | // Status contain info about size of all files in current godu instance 16 | // and size of the files marked by user 17 | type Status struct { 18 | Total string 19 | Selected string 20 | } 21 | 22 | // ReportStatus reads through the folder structure and produces Status 23 | func ReportStatus(file *files.File, markedFiles *map[*files.File]struct{}) Status { 24 | parent := file 25 | for parent.Parent != nil { 26 | parent = parent.Parent 27 | } 28 | var selected int64 29 | for f := range *markedFiles { 30 | if !parentMarked(f, markedFiles) { 31 | selected += f.Size 32 | } 33 | } 34 | return Status{ 35 | Total: fmt.Sprintf("Total size: %s", formatBytes(parent.Size)), 36 | Selected: fmt.Sprintf("Selected size: %s", formatBytes(selected)), 37 | } 38 | } 39 | 40 | func parentMarked(file *files.File, markedFiles *map[*files.File]struct{}) bool { 41 | parent := file 42 | for parent.Parent != nil { 43 | _, found := (*markedFiles)[parent.Parent] 44 | if found { 45 | return true 46 | } 47 | parent = parent.Parent 48 | } 49 | return false 50 | } 51 | 52 | // ReportFolder converts all subfiles into UI lines 53 | func ReportFolder(folder *files.File, markedFiles map[*files.File]struct{}) []Line { 54 | report := make([]Line, len(folder.Files)) 55 | for index, file := range folder.Files { 56 | name := file.Name 57 | if file.IsDir { 58 | name = name + "/" 59 | } 60 | marking := " " 61 | _, isMarked := markedFiles[file] 62 | if isMarked { 63 | marking = "*" 64 | } 65 | report[index] = Line{ 66 | Text: []rune(fmt.Sprintf("%s%s %s", marking, formatBytes(file.Size), name)), 67 | IsMarked: isMarked, 68 | } 69 | } 70 | return report 71 | } 72 | 73 | func formatBytes(bytesInt int64) string { 74 | bytes := float32(bytesInt) 75 | var unit string 76 | var amount float32 77 | switch { 78 | case files.PETABYTE <= bytes: 79 | unit = "P" 80 | amount = bytes / files.PETABYTE 81 | case files.TERABYTE <= bytes: 82 | unit = "T" 83 | amount = bytes / files.TERABYTE 84 | case files.GIGABYTE <= bytes: 85 | unit = "G" 86 | amount = bytes / files.GIGABYTE 87 | case files.MEGABYTE <= bytes: 88 | unit = "M" 89 | amount = bytes / files.MEGABYTE 90 | case files.KILOBYTE <= bytes: 91 | unit = "K" 92 | amount = bytes / files.KILOBYTE 93 | default: 94 | unit = "B" 95 | amount = bytes 96 | 97 | } 98 | return fmt.Sprintf("%4.0f%s", amount, unit) 99 | } 100 | -------------------------------------------------------------------------------- /interactive/reporter_test.go: -------------------------------------------------------------------------------- 1 | package interactive 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/viktomas/godu/files" 8 | ) 9 | 10 | func TestReportFolder(t *testing.T) { 11 | marked := make(map[*files.File]struct{}) 12 | folder := files.NewTestFolder("b", 13 | files.NewTestFile("c", 100), 14 | files.NewTestFolder("d", 15 | files.NewTestFile("e", 50), 16 | files.NewTestFile("f", 30), 17 | ), 18 | ) 19 | marked[folder.Files[0]] = struct{}{} 20 | expected := []Line{ 21 | {Text: []rune("* 100B c"), IsMarked: true}, 22 | {Text: []rune(" 80B d/")}, 23 | } 24 | testFolderAgainstOutput(folder, marked, expected, t) 25 | } 26 | 27 | func TestPrintsEmptyDir(t *testing.T) { 28 | marked := make(map[*files.File]struct{}) 29 | folder := files.NewTestFolder("", files.NewTestFolder("a")) 30 | expected := []Line{ 31 | {Text: []rune(" 0B a/")}, 32 | } 33 | testFolderAgainstOutput(folder, marked, expected, t) 34 | } 35 | 36 | func TestFiveCharSize(t *testing.T) { 37 | marked := make(map[*files.File]struct{}) 38 | folder := files.NewTestFolder("X", 39 | files.NewTestFile("o", 1), 40 | files.NewTestFile("on", 10), 41 | files.NewTestFile("one", 100), 42 | files.NewTestFile("one1", 1000), 43 | ) 44 | ex := []Line{ 45 | {Text: []rune(" 1B o")}, 46 | {Text: []rune(" 10B on")}, 47 | {Text: []rune(" 100B one")}, 48 | {Text: []rune(" 1000B one1")}, 49 | } 50 | testFolderAgainstOutput(folder, marked, ex, t) 51 | } 52 | 53 | func TestReportingUnits(t *testing.T) { 54 | marked := make(map[*files.File]struct{}) 55 | folder := files.NewTestFolder("X", 56 | files.NewTestFile("B", 1<<0), 57 | files.NewTestFile("K", 1<<10), 58 | files.NewTestFile("M", 1048576), 59 | files.NewTestFile("G", 1073741824), 60 | files.NewTestFile("T", 1099511627776), 61 | files.NewTestFile("P", 1125899906842624), 62 | ) 63 | ex := []Line{ 64 | {Text: []rune(" 1B B")}, 65 | {Text: []rune(" 1K K")}, 66 | {Text: []rune(" 1M M")}, 67 | {Text: []rune(" 1G G")}, 68 | {Text: []rune(" 1T T")}, 69 | {Text: []rune(" 1P P")}, 70 | } 71 | testFolderAgainstOutput(folder, marked, ex, t) 72 | } 73 | 74 | func testFolderAgainstOutput(folder *files.File, marked map[*files.File]struct{}, expected []Line, t *testing.T) { 75 | result := ReportFolder(folder, marked) 76 | assert.Equal(t, expected, result) 77 | } 78 | 79 | func TestReportStatusTotalSize(t *testing.T) { 80 | folder := files.NewTestFolder("root", 81 | files.NewTestFile("a", 10), 82 | files.NewTestFile("b", 20), 83 | files.NewTestFile("c", 30), 84 | ) 85 | marked := make(map[*files.File]struct{}) 86 | status := ReportStatus(files.FindTestFile(folder, "b"), &marked) 87 | assert.Equal(t, "Total size: 60B", status.Total) 88 | } 89 | 90 | func TestReportStatusSelectedSize(t *testing.T) { 91 | folder := files.NewTestFolder("root", 92 | files.NewTestFile("a", 30), 93 | files.NewTestFile("b", 40), 94 | ) 95 | marked := make(map[*files.File]struct{}) 96 | marked[files.FindTestFile(folder, "b")] = struct{}{} 97 | status := ReportStatus(folder, &marked) 98 | assert.Equal(t, "Selected size: 40B", status.Selected) 99 | } 100 | 101 | func TestReportStatusSelectedSizeWithParent(t *testing.T) { 102 | folder := files.NewTestFolder("root", 103 | files.NewTestFolder("f1", 104 | files.NewTestFile("a", 10), 105 | files.NewTestFile("b", 20), 106 | ), 107 | files.NewTestFile("c", 30), 108 | files.NewTestFile("d", 40), 109 | ) 110 | marked := make(map[*files.File]struct{}) 111 | marked[files.FindTestFile(folder, "f1")] = struct{}{} 112 | marked[files.FindTestFile(folder, "a")] = struct{}{} 113 | status := ReportStatus(folder, &marked) 114 | assert.Equal(t, "Selected size: 30B", status.Selected) 115 | } 116 | -------------------------------------------------------------------------------- /parser.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/gdamore/tcell/v2" 7 | "github.com/viktomas/godu/commands" 8 | ) 9 | 10 | func parseCommand(s tcell.Screen, commandsChan chan commands.Executer, wg *sync.WaitGroup) { 11 | defer wg.Done() 12 | for { 13 | ev := s.PollEvent() 14 | switch ev := ev.(type) { 15 | case *tcell.EventKey: 16 | switch ev.Key() { 17 | case tcell.KeyEscape, tcell.KeyCtrlC: 18 | close(commandsChan) 19 | return 20 | case tcell.KeyEnter, tcell.KeyRight: 21 | commandsChan <- commands.Enter{} 22 | case tcell.KeyDown: 23 | commandsChan <- commands.Down{} 24 | case tcell.KeyUp: 25 | commandsChan <- commands.Up{} 26 | case tcell.KeyBackspace, tcell.KeyLeft: 27 | commandsChan <- commands.GoBack{} 28 | case tcell.KeyCtrlL: 29 | s.Sync() 30 | case tcell.KeyRune: 31 | switch ev.Rune() { 32 | case ' ': 33 | commandsChan <- commands.Mark{} 34 | case 'q': 35 | close(commandsChan) 36 | return 37 | case 'h': 38 | commandsChan <- commands.GoBack{} 39 | case 'j': 40 | commandsChan <- commands.Down{} 41 | case 'k': 42 | commandsChan <- commands.Up{} 43 | case 'l': 44 | commandsChan <- commands.Enter{} 45 | } 46 | 47 | } 48 | case *tcell.EventResize: 49 | s.Sync() 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /state_model.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gdamore/tcell/v2" 5 | "github.com/mattn/go-runewidth" 6 | "github.com/viktomas/godu/commands" 7 | "github.com/viktomas/godu/interactive" 8 | ) 9 | 10 | type visualState struct { 11 | folders []interactive.Line 12 | selected int 13 | xbound, ybound int 14 | screenHeight int 15 | } 16 | 17 | func newVisualState(state commands.State, screenHeight int) visualState { 18 | lines := interactive.ReportFolder(state.Folder, state.MarkedFiles) 19 | xbound := 0 20 | ybound := len(lines) 21 | for index, line := range lines { 22 | if len(line.Text)-1 > xbound { 23 | xbound = len(line.Text) - 1 24 | } 25 | lines[index] = line 26 | } 27 | return visualState{lines, state.Selected, xbound, ybound, screenHeight} 28 | } 29 | 30 | func (vs visualState) GetCell(x, y int) (rune, tcell.Style, []rune, int) { 31 | style := tcell.StyleDefault 32 | // return empty cell if we are asking for a line that doesn't exist 33 | if y >= len(vs.folders) { 34 | return ' ', style, nil, 1 35 | } 36 | // For some reason tcell is asking for cells below the viewport, we will return empty cell 37 | if y > vs.screenHeight { 38 | return ' ', style, nil, 1 39 | } 40 | shiftedIndex := y 41 | if vs.selected > vs.screenHeight { 42 | // shifting the index enables displaying selected folders that would be otherwise hidden bellow the screen 43 | shiftedIndex += vs.selected - vs.screenHeight 44 | } 45 | if shiftedIndex == vs.selected { 46 | style = style.Reverse(true) 47 | } 48 | line := vs.folders[shiftedIndex] 49 | if line.IsMarked { 50 | style = style.Foreground(tcell.ColorGreen) 51 | } 52 | 53 | visualIndex := 0 54 | for _, r := range line.Text { 55 | width := runewidth.RuneWidth(r) 56 | if visualIndex == x { 57 | return r, style, nil, width 58 | } 59 | visualIndex += width 60 | if visualIndex > x { 61 | break 62 | } 63 | } 64 | return ' ', style, nil, 1 65 | } 66 | func (vs visualState) GetBounds() (int, int) { 67 | return vs.xbound, vs.ybound 68 | } 69 | func (visualState) SetCursor(int, int) { 70 | } 71 | 72 | func (visualState) GetCursor() (int, int, bool, bool) { 73 | return 0, 0, false, false 74 | } 75 | func (visualState) MoveCursor(offx, offy int) { 76 | 77 | } 78 | -------------------------------------------------------------------------------- /state_model_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/gdamore/tcell/v2" 7 | "github.com/viktomas/godu/interactive" 8 | ) 9 | 10 | func TestGetCell(t *testing.T) { 11 | vs := visualState{ 12 | folders: []interactive.Line{ 13 | {Text: []rune("Hello"), IsMarked: false}, 14 | {Text: []rune("你好"), IsMarked: true}, 15 | }, 16 | selected: 1, 17 | screenHeight: 5, 18 | } 19 | 20 | tests := []struct { 21 | x, y int 22 | expectedRune rune 23 | expectedStyle tcell.Style 24 | expectedWidth int 25 | }{ 26 | {0, 0, 'H', tcell.StyleDefault, 1}, 27 | {1, 0, 'e', tcell.StyleDefault, 1}, 28 | {0, 1, '你', tcell.StyleDefault.Reverse(true).Foreground(tcell.ColorGreen), 2}, 29 | {1, 1, ' ', tcell.StyleDefault.Reverse(true).Foreground(tcell.ColorGreen), 1}, 30 | {2, 1, '好', tcell.StyleDefault.Reverse(true).Foreground(tcell.ColorGreen), 2}, 31 | {0, 2, ' ', tcell.StyleDefault, 1}, // out of bounds y 32 | {6, 0, ' ', tcell.StyleDefault, 1}, // out of bounds x 33 | } 34 | 35 | for _, tt := range tests { 36 | r, style, _, width := vs.GetCell(tt.x, tt.y) 37 | if r != tt.expectedRune { 38 | t.Errorf("GetCell(%d, %d) = %c; want %c", tt.x, tt.y, r, tt.expectedRune) 39 | } 40 | if style != tt.expectedStyle { 41 | t.Errorf("GetCell(%d, %d) style = %+v; want %+v", tt.x, tt.y, style, tt.expectedStyle) 42 | } 43 | if width != tt.expectedWidth { 44 | t.Errorf("GetCell(%d, %d) width = %d; want %d", tt.x, tt.y, width, tt.expectedWidth) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /state_presenter.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/gdamore/tcell/v2" 7 | "github.com/gdamore/tcell/v2/views" 8 | "github.com/viktomas/godu/commands" 9 | "github.com/viktomas/godu/interactive" 10 | ) 11 | 12 | func interactiveFolder(s tcell.Screen, states chan commands.State, wg *sync.WaitGroup) { 13 | defer wg.Done() 14 | for { 15 | state, more := <-states 16 | if !more { 17 | break 18 | } 19 | printOptions(state, s) 20 | } 21 | } 22 | 23 | func printOptions(state commands.State, s tcell.Screen) { 24 | s.Clear() 25 | outer := views.NewBoxLayout(views.Vertical) 26 | inner := views.NewBoxLayout(views.Horizontal) 27 | var back views.Widget 28 | var forth views.Widget 29 | 30 | // Subtract a row from screen height for the status bar 31 | _, screenHeight := s.Size() 32 | innerWidgetHeight := screenHeight - 2 33 | 34 | middle := views.NewCellView() 35 | 36 | middle.SetModel(newVisualState(state, innerWidgetHeight)) 37 | backState, err := commands.GoBack{}.Execute(state) 38 | if err == nil { 39 | backCell := views.NewCellView() 40 | backCell.SetModel(newVisualState(backState, innerWidgetHeight)) 41 | back = backCell 42 | } else { 43 | back = views.NewText() 44 | } 45 | forthState, err := commands.Enter{}.Execute(state) 46 | if err == nil { 47 | forthCell := views.NewCellView() 48 | forthCell.SetModel(newVisualState(forthState, innerWidgetHeight)) 49 | forth = forthCell 50 | } else { 51 | forth = views.NewText() 52 | } 53 | 54 | statusBar := views.NewSimpleStyledTextBar() 55 | status := interactive.ReportStatus(state.Folder, &state.MarkedFiles) 56 | 57 | statusBar.SetLeft(status.Total) 58 | statusBar.SetRight(status.Selected) 59 | 60 | outer.SetView(s) 61 | outer.AddWidget(inner, 1.0) 62 | outer.AddWidget(statusBar, 0.0) 63 | inner.AddWidget(back, 0.33) 64 | inner.AddWidget(middle, 0.33) 65 | inner.AddWidget(forth, 0.33) 66 | outer.Draw() 67 | s.Show() 68 | } 69 | -------------------------------------------------------------------------------- /testCover.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | echo "" > coverage.txt 5 | 6 | for d in $(go list ./... ); do 7 | if [[ "$d" == */godu ]]; then 8 | go test $d 9 | else 10 | go test -v -race -coverprofile=profile.out -covermode=atomic $d 11 | if [ -f profile.out ]; then 12 | cat profile.out >> coverage.txt 13 | rm profile.out 14 | fi 15 | fi 16 | done 17 | 18 | go test . 19 | --------------------------------------------------------------------------------