├── .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 | 
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 | [](https://travis-ci.org/viktomas/godu)
4 | [](https://codecov.io/gh/viktomas/godu)
5 | [](https://goreportcard.com/report/github.com/viktomas/godu)
6 | [](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 |
--------------------------------------------------------------------------------