From b8c9820bbaf76645adaf2172199598853d0569f3 Mon Sep 17 00:00:00 2001 From: Daniel Milde Date: Sat, 30 Nov 2024 22:51:41 +0100 Subject: [PATCH 1/3] feat: show top largest files --- cmd/gdu/app/app.go | 2 ++ cmd/gdu/main.go | 1 + pkg/analyze/top.go | 55 ++++++++++++++++++++++++++++++++ pkg/analyze/top_test.go | 38 ++++++++++++++++++++++ stdout/stdout.go | 39 +++++++++++++++++++++-- stdout/stdout_test.go | 70 +++++++++++++++++++++++++++++++---------- 6 files changed, 186 insertions(+), 19 deletions(-) create mode 100644 pkg/analyze/top.go create mode 100644 pkg/analyze/top_test.go diff --git a/cmd/gdu/app/app.go b/cmd/gdu/app/app.go index 5860453f1..1a27d30a4 100644 --- a/cmd/gdu/app/app.go +++ b/cmd/gdu/app/app.go @@ -72,6 +72,7 @@ type Flags struct { StoragePath string `yaml:"storage-path"` ReadFromStorage bool `yaml:"read-from-storage"` Summarize bool `yaml:"summarize"` + Top int `yaml:"top"` UseSIPrefix bool `yaml:"use-si-prefix"` NoPrefix bool `yaml:"no-prefix"` WriteConfig bool `yaml:"-"` @@ -242,6 +243,7 @@ func (a *App) createUI() (UI, error) { a.Flags.ConstGC, a.Flags.UseSIPrefix, a.Flags.NoPrefix, + a.Flags.Top, ) if a.Flags.NoUnicode { stdoutUI.UseOldProgressRunes() diff --git a/cmd/gdu/main.go b/cmd/gdu/main.go index b7ae04d49..b256f7b29 100644 --- a/cmd/gdu/main.go +++ b/cmd/gdu/main.go @@ -74,6 +74,7 @@ func init() { flags.BoolVarP(&af.NoProgress, "no-progress", "p", false, "Do not show progress in non-interactive mode") flags.BoolVarP(&af.NoUnicode, "no-unicode", "u", false, "Do not use Unicode symbols (for size bar)") flags.BoolVarP(&af.Summarize, "summarize", "s", false, "Show only a total in non-interactive mode") + flags.IntVarP(&af.Top, "top", "t", 0, "Show only top X largest files in non-interactive mode") flags.BoolVar(&af.UseSIPrefix, "si", false, "Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB)") flags.BoolVar(&af.NoPrefix, "no-prefix", false, "Show sizes as raw numbers without any prefixes (SI or binary) in non-interactive mode") flags.BoolVar(&af.NoMouse, "no-mouse", false, "Do not use mouse") diff --git a/pkg/analyze/top.go b/pkg/analyze/top.go new file mode 100644 index 000000000..55814747c --- /dev/null +++ b/pkg/analyze/top.go @@ -0,0 +1,55 @@ +package analyze + +import ( + "sort" + + "github.com/dundee/gdu/v5/pkg/fs" +) + +// TopList is a list of top largest files +type TopList struct { + Count int + Items fs.Files + MinSize int64 +} + +// NewTopList creates new TopList +func NewTopList(count int) *TopList { + return &TopList{Count: count} +} + +// Add adds file to the list +func (tl *TopList) Add(file fs.Item) { + if len(tl.Items) < tl.Count { + tl.Items = append(tl.Items, file) + if file.GetSize() > tl.MinSize { + tl.MinSize = file.GetSize() + } + } else { + if file.GetSize() > tl.MinSize { + tl.Items = append(tl.Items, file) + tl.MinSize = file.GetSize() + if len(tl.Items) > tl.Count { + sort.Sort(fs.ByApparentSize(tl.Items)) + tl.Items = tl.Items[1:] + } + } + } +} + +func CollectTopFiles(dir fs.Item, count int) fs.Files { + topList := NewTopList(count) + walkDir(dir, topList) + sort.Sort(sort.Reverse(fs.ByApparentSize(topList.Items))) + return topList.Items +} + +func walkDir(dir fs.Item, topList *TopList) { + for _, item := range dir.GetFiles() { + if item.IsDir() { + walkDir(item, topList) + } else { + topList.Add(item) + } + } +} diff --git a/pkg/analyze/top_test.go b/pkg/analyze/top_test.go new file mode 100644 index 000000000..7d89cc635 --- /dev/null +++ b/pkg/analyze/top_test.go @@ -0,0 +1,38 @@ +package analyze + +import ( + "testing" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/stretchr/testify/assert" +) + +func TestCollectTopFiles2(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dir := CreateAnalyzer().AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ) + + topFiles := CollectTopFiles(dir, 2) + assert.Equal(t, 2, len(topFiles)) + assert.Equal(t, "file", topFiles[0].GetName()) + assert.Equal(t, int64(5), topFiles[0].GetSize()) + assert.Equal(t, "file2", topFiles[1].GetName()) + assert.Equal(t, int64(2), topFiles[1].GetSize()) +} + +func TestCollectTopFiles1(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + dir := CreateAnalyzer().AnalyzeDir( + "test_dir", func(_, _ string) bool { return false }, false, + ) + + topFiles := CollectTopFiles(dir, 1) + assert.Equal(t, 1, len(topFiles)) + assert.Equal(t, "file", topFiles[0].GetName()) + assert.Equal(t, int64(5), topFiles[0].GetSize()) +} diff --git a/stdout/stdout.go b/stdout/stdout.go index ced65fd53..4d00d3eba 100644 --- a/stdout/stdout.go +++ b/stdout/stdout.go @@ -26,6 +26,7 @@ type UI struct { blue *color.Color summarize bool noPrefix bool + top int } var ( @@ -45,6 +46,7 @@ func CreateStdoutUI( constGC bool, useSIPrefix bool, noPrefix bool, + top int, ) *UI { ui := &UI{ UI: &common.UI{ @@ -59,6 +61,7 @@ func CreateStdoutUI( output: output, summarize: summarize, noPrefix: noPrefix, + top: top, } ui.red = color.New(color.FgRed).Add(color.Bold) @@ -167,7 +170,9 @@ func (ui *UI) AnalyzePath(path string, _ fs.Item) error { wait.Wait() - if ui.summarize { + if ui.top > 0 { + ui.printTopFiles(dir) + } else if ui.summarize { ui.printTotalItem(dir) } else { ui.showDir(dir) @@ -187,7 +192,9 @@ func (ui *UI) ReadFromStorage(storagePath, path string) error { return err } - if ui.summarize { + if ui.top > 0 { + ui.printTopFiles(dir) + } else if ui.summarize { ui.printTotalItem(dir) } else { ui.showDir(dir) @@ -203,6 +210,13 @@ func (ui *UI) showDir(dir fs.Item) { } } +func (ui *UI) printTopFiles(file fs.Item) { + collected := analyze.CollectTopFiles(file, ui.top) + for _, file := range collected { + ui.printItemPath(file) + } +} + func (ui *UI) printTotalItem(file fs.Item) { var lineFormat string if ui.UseColors { @@ -256,6 +270,27 @@ func (ui *UI) printItem(file fs.Item) { } } +func (ui *UI) printItemPath(file fs.Item) { + var lineFormat string + if ui.UseColors { + lineFormat = "%20s %s\n" + } else { + lineFormat = "%9s %s\n" + } + + var size int64 + if ui.ShowApparentSize { + size = file.GetSize() + } else { + size = file.GetUsage() + } + + fmt.Fprintf(ui.output, + lineFormat, + ui.formatSize(size), + file.GetPath()) +} + // ReadAnalysis reads analysis report from JSON file func (ui *UI) ReadAnalysis(input io.Reader) error { var ( diff --git a/stdout/stdout_test.go b/stdout/stdout_test.go index cd76bd92f..95dad4934 100644 --- a/stdout/stdout_test.go +++ b/stdout/stdout_test.go @@ -25,7 +25,7 @@ func TestAnalyzePath(t *testing.T) { buff := make([]byte, 10) output := bytes.NewBuffer(buff) - ui := CreateStdoutUI(output, false, false, false, false, false, true, false, false) + ui := CreateStdoutUI(output, false, false, false, false, false, true, false, false, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) @@ -42,7 +42,7 @@ func TestShowSummary(t *testing.T) { buff := make([]byte, 10) output := bytes.NewBuffer(buff) - ui := CreateStdoutUI(output, true, false, true, false, true, false, false, false) + ui := CreateStdoutUI(output, true, false, true, false, true, false, false, false, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) @@ -59,7 +59,7 @@ func TestShowSummaryBw(t *testing.T) { buff := make([]byte, 10) output := bytes.NewBuffer(buff) - ui := CreateStdoutUI(output, false, false, false, false, true, false, false, false) + ui := CreateStdoutUI(output, false, false, false, false, true, false, false, false, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) assert.Nil(t, err) @@ -69,6 +69,42 @@ func TestShowSummaryBw(t *testing.T) { assert.Contains(t, output.String(), "test_dir") } +func TestShowTop(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, true, false, true, false, true, false, false, false, 2) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "test_dir/nested/subnested/file") + assert.Contains(t, output.String(), "test_dir/nested/file2") +} + +func TestShowTopBw(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := make([]byte, 10) + output := bytes.NewBuffer(buff) + + ui := CreateStdoutUI(output, false, false, false, false, true, false, false, false, 2) + ui.SetIgnoreDirPaths([]string{"/xxx"}) + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + err = ui.StartUILoop() + + assert.Nil(t, err) + assert.Contains(t, output.String(), "test_dir/nested/subnested/file") + assert.Contains(t, output.String(), "test_dir/nested/file2") +} + func TestAnalyzeSubdir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() @@ -76,7 +112,7 @@ func TestAnalyzeSubdir(t *testing.T) { buff := make([]byte, 10) output := bytes.NewBuffer(buff) - ui := CreateStdoutUI(output, false, false, false, false, false, false, false, false) + ui := CreateStdoutUI(output, false, false, false, false, false, false, false, false, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir/nested", nil) assert.Nil(t, err) @@ -93,7 +129,7 @@ func TestAnalyzePathWithColors(t *testing.T) { buff := make([]byte, 10) output := bytes.NewBuffer(buff) - ui := CreateStdoutUI(output, true, false, true, false, false, false, false, false) + ui := CreateStdoutUI(output, true, false, true, false, false, false, false, false, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir/nested", nil) @@ -108,7 +144,7 @@ func TestAnalyzePathWoUnicode(t *testing.T) { buff := make([]byte, 10) output := bytes.NewBuffer(buff) - ui := CreateStdoutUI(output, false, true, true, false, false, false, false, false) + ui := CreateStdoutUI(output, false, true, true, false, false, false, false, false, 0) ui.UseOldProgressRunes() err := ui.AnalyzePath("test_dir/nested", nil) @@ -119,7 +155,7 @@ func TestAnalyzePathWoUnicode(t *testing.T) { func TestItemRows(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) - ui := CreateStdoutUI(output, false, true, false, false, false, false, false, false) + ui := CreateStdoutUI(output, false, true, false, false, false, false, false, false, 0) ui.Analyzer = &testanalyze.MockedAnalyzer{} err := ui.AnalyzePath("test_dir", nil) @@ -133,7 +169,7 @@ func TestAnalyzePathWithProgress(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) - ui := CreateStdoutUI(output, false, true, true, false, false, false, false, false) + ui := CreateStdoutUI(output, false, true, true, false, false, false, false, false, 0) ui.SetIgnoreDirPaths([]string{"/xxx"}) err := ui.AnalyzePath("test_dir", nil) @@ -144,7 +180,7 @@ func TestAnalyzePathWithProgress(t *testing.T) { func TestShowDevices(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) - ui := CreateStdoutUI(output, false, true, false, false, false, false, false, false) + ui := CreateStdoutUI(output, false, true, false, false, false, false, false, false, 0) err := ui.ListDevices(getDevicesInfoMock()) assert.Nil(t, err) @@ -155,7 +191,7 @@ func TestShowDevices(t *testing.T) { func TestShowDevicesWithColor(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) - ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false) + ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false, 0) err := ui.ListDevices(getDevicesInfoMock()) assert.Nil(t, err) @@ -169,7 +205,7 @@ func TestReadAnalysisWithColor(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) - ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false) + ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false, 0) err = ui.ReadAnalysis(input) assert.Nil(t, err) @@ -182,7 +218,7 @@ func TestReadAnalysisBw(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) - ui := CreateStdoutUI(output, false, false, false, false, false, false, false, false) + ui := CreateStdoutUI(output, false, false, false, false, false, false, false, false, 0) err = ui.ReadAnalysis(input) assert.Nil(t, err) @@ -195,7 +231,7 @@ func TestReadAnalysisWithWrongFile(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) - ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false) + ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false, 0) err = ui.ReadAnalysis(input) assert.NotNil(t, err) @@ -207,7 +243,7 @@ func TestReadAnalysisWithSummarize(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) - ui := CreateStdoutUI(output, false, false, false, false, true, false, false, false) + ui := CreateStdoutUI(output, false, false, false, false, true, false, false, false, 0) err = ui.ReadAnalysis(input) assert.Nil(t, err) @@ -222,7 +258,7 @@ func TestMaxInt(t *testing.T) { func TestFormatSize(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) - ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false) + ui := CreateStdoutUI(output, true, true, true, false, false, false, false, false, 0) assert.Contains(t, ui.formatSize(1), "B") assert.Contains(t, ui.formatSize(1<<10+1), "KiB") @@ -236,7 +272,7 @@ func TestFormatSize(t *testing.T) { func TestFormatSizeDec(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) - ui := CreateStdoutUI(output, true, true, true, false, false, false, true, false) + ui := CreateStdoutUI(output, true, true, true, false, false, false, true, false, 0) assert.Contains(t, ui.formatSize(1), "B") assert.Contains(t, ui.formatSize(1<<10+1), "kB") @@ -250,7 +286,7 @@ func TestFormatSizeDec(t *testing.T) { func TestFormatSizeRaw(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) - ui := CreateStdoutUI(output, true, true, true, false, false, false, true, true) + ui := CreateStdoutUI(output, true, true, true, false, false, false, true, true, 0) assert.Equal(t, ui.formatSize(1), "1") assert.Equal(t, ui.formatSize(1<<10+1), "1025") From 16059040252b507ddae198a0a71015b121341282 Mon Sep 17 00:00:00 2001 From: Daniel Milde Date: Sat, 30 Nov 2024 22:56:20 +0100 Subject: [PATCH 2/3] docs: update readme --- README.md | 2 ++ gdu.1.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index 36f847c0b..09aec9de1 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ Flags: --si Show sizes with decimal SI prefixes (kB, MB, GB) instead of binary prefixes (KiB, MiB, GiB) --storage-path string Path to persistent key-value storage directory (default "/tmp/badger") -s, --summarize Show only a total in non-interactive mode + -t, --top int Show only top X largest files in non-interactive mode --use-storage Use persistent key-value storage for analysis data (experimental) -v, --version Print version --write-config Write current configuration to file (default is $HOME/.gdu.yaml) @@ -104,6 +105,7 @@ Basic list of actions in interactive mode (show help modal for more): gdu -n / # only print stats, do not start interactive mode gdu -np / # do not show progress, useful when using its output in a script gdu -nps /some/dir # show only total usage for given dir + gdu -nt 10 / # show top 10 largest files gdu / > file # write stats to file, do not start interactive mode gdu -o- / | gzip -c >report.json.gz # write all info to JSON file for later analysis diff --git a/gdu.1.md b/gdu.1.md index 7a876708a..cf9ecce1c 100644 --- a/gdu.1.md +++ b/gdu.1.md @@ -54,6 +54,8 @@ non-interactive mode **-s**, **\--summarize**\[=false\] Show only a total in non-interactive mode +**-t**, **\--top**\[=0\] Show only top X largest files in non-interactive mode + **-d**, **\--show-disks**\[=false\] Show all mounted disks **-a**, **\--show-apparent-size**\[=false\] Show apparent size From f69bda989e3ecad1178917e42aee23304c05617f Mon Sep 17 00:00:00 2001 From: Daniel Milde Date: Sat, 30 Nov 2024 23:01:49 +0100 Subject: [PATCH 3/3] fix: fmt --- pkg/analyze/top.go | 14 ++++++-------- stdout/stdout.go | 14 ++++++++------ stdout/stdout_linux_test.go | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/pkg/analyze/top.go b/pkg/analyze/top.go index 55814747c..b7e569c7e 100644 --- a/pkg/analyze/top.go +++ b/pkg/analyze/top.go @@ -25,14 +25,12 @@ func (tl *TopList) Add(file fs.Item) { if file.GetSize() > tl.MinSize { tl.MinSize = file.GetSize() } - } else { - if file.GetSize() > tl.MinSize { - tl.Items = append(tl.Items, file) - tl.MinSize = file.GetSize() - if len(tl.Items) > tl.Count { - sort.Sort(fs.ByApparentSize(tl.Items)) - tl.Items = tl.Items[1:] - } + } else if file.GetSize() > tl.MinSize { + tl.Items = append(tl.Items, file) + tl.MinSize = file.GetSize() + if len(tl.Items) > tl.Count { + sort.Sort(fs.ByApparentSize(tl.Items)) + tl.Items = tl.Items[1:] } } } diff --git a/stdout/stdout.go b/stdout/stdout.go index 4d00d3eba..84dfcacef 100644 --- a/stdout/stdout.go +++ b/stdout/stdout.go @@ -170,11 +170,12 @@ func (ui *UI) AnalyzePath(path string, _ fs.Item) error { wait.Wait() - if ui.top > 0 { + switch { + case ui.top > 0: ui.printTopFiles(dir) - } else if ui.summarize { + case ui.summarize: ui.printTotalItem(dir) - } else { + default: ui.showDir(dir) } @@ -192,11 +193,12 @@ func (ui *UI) ReadFromStorage(storagePath, path string) error { return err } - if ui.top > 0 { + switch { + case ui.top > 0: ui.printTopFiles(dir) - } else if ui.summarize { + case ui.summarize: ui.printTotalItem(dir) - } else { + default: ui.showDir(dir) } return nil diff --git a/stdout/stdout_linux_test.go b/stdout/stdout_linux_test.go index 4a47daed2..781d11bb1 100644 --- a/stdout/stdout_linux_test.go +++ b/stdout/stdout_linux_test.go @@ -21,7 +21,7 @@ func TestShowDevicesWithErr(t *testing.T) { output := bytes.NewBuffer(make([]byte, 10)) getter := device.LinuxDevicesInfoGetter{MountsPath: "/xyzxyz"} - ui := CreateStdoutUI(output, false, true, false, false, false, false, false, false) + ui := CreateStdoutUI(output, false, true, false, false, false, false, false, false, 0) err := ui.ListDevices(getter) assert.Contains(t, err.Error(), "no such file")