diff --git a/.tool-versions b/.tool-versions index 3eaf48ee7..6bc74c519 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -golang 1.21.5 +golang 1.22.1 diff --git a/cmd/gdu/app/app.go b/cmd/gdu/app/app.go index 8b55571a1..4ac669665 100644 --- a/cmd/gdu/app/app.go +++ b/cmd/gdu/app/app.go @@ -75,6 +75,7 @@ type Flags struct { NoPrefix bool `yaml:"no-prefix"` WriteConfig bool `yaml:"-"` ChangeCwd bool `yaml:"change-cwd"` + DeleteInBackground bool `yaml:"delete-in-background"` Style Style `yaml:"style"` Sorting Sorting `yaml:"sorting"` } @@ -280,6 +281,11 @@ func (a *App) createUI() (UI, error) { ui.SetNoDelete() }) } + if a.Flags.DeleteInBackground { + opts = append(opts, func(ui *tui.UI) { + ui.SetDeleteInBackground() + }) + } ui = tui.CreateUI( a.TermApp, diff --git a/cmd/gdu/app/app_test.go b/cmd/gdu/app/app_test.go index 10240a951..7fa34e080 100644 --- a/cmd/gdu/app/app_test.go +++ b/cmd/gdu/app/app_test.go @@ -149,6 +149,21 @@ func TestAnalyzePathWithGui(t *testing.T) { assert.Nil(t, err) } +func TestAnalyzePathWithGuiBackgroundDeletion(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + out, err := runApp( + &Flags{LogFile: "/dev/null", DeleteInBackground: true}, + []string{"test_dir"}, + true, + testdev.DevicesInfoGetterMock{}, + ) + + assert.Empty(t, out) + assert.Nil(t, err) +} + func TestAnalyzePathWithDefaultSorting(t *testing.T) { fin := testdir.CreateTestDir() defer fin() diff --git a/internal/testanalyze/analyze.go b/internal/testanalyze/analyze.go index 89ca4cfc1..9911a982d 100644 --- a/internal/testanalyze/analyze.go +++ b/internal/testanalyze/analyze.go @@ -87,3 +87,15 @@ func (a *MockedAnalyzer) SetFollowSymlinks(v bool) {} func RemoveItemFromDirWithErr(dir fs.Item, file fs.Item) error { return errors.New("Failed") } + +// RemoveItemFromDirWithSleep returns error +func RemoveItemFromDirWithSleep(dir fs.Item, file fs.Item) error { + time.Sleep(time.Millisecond * 600) + return analyze.RemoveItemFromDir(dir, file) +} + +// RemoveItemFromDirWithSleepAndErr returns error +func RemoveItemFromDirWithSleepAndErr(dir fs.Item, file fs.Item) error { + time.Sleep(time.Millisecond * 600) + return errors.New("Failed") +} diff --git a/tui/actions.go b/tui/actions.go index 613cfe809..5ca16ee75 100644 --- a/tui/actions.go +++ b/tui/actions.go @@ -169,6 +169,11 @@ func (ui *UI) deleteSelected(shouldEmpty bool) { row, column := ui.table.GetSelection() selectedItem := ui.table.GetCell(row, column).GetReference().(fs.Item) + if ui.deleteInBackground { + ui.queueForDeletion([]fs.Item{selectedItem}, shouldEmpty) + return + } + var action, acting string if shouldEmpty { action = "empty " diff --git a/tui/background.go b/tui/background.go new file mode 100644 index 000000000..813c3b902 --- /dev/null +++ b/tui/background.go @@ -0,0 +1,93 @@ +package tui + +import ( + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/rivo/tview" +) + +func (ui *UI) queueForDeletion(items []fs.Item, shouldEmpty bool) { + go func() { + for _, item := range items { + ui.deleteQueue <- deleteQueueItem{item: item, shouldEmpty: shouldEmpty} + } + }() + + ui.markedRows = make(map[int]struct{}) +} + +func (ui *UI) deleteWorker() { + for item := range ui.deleteQueue { + ui.deleteItem(item.item, item.shouldEmpty) + } +} + +func (ui *UI) deleteItem(item fs.Item, shouldEmpty bool) { + ui.increaseActiveWorkers() + defer ui.decreaseActiveWorkers() + + var action, acting string + if shouldEmpty { + action = "empty " + } else { + action = "delete " + } + + var deleteFun func(fs.Item, fs.Item) error + if shouldEmpty && !item.IsDir() { + deleteFun = ui.emptier + } else { + deleteFun = ui.remover + } + + var parentDir fs.Item + var deleteItems []fs.Item + if shouldEmpty && item.IsDir() { + parentDir = item.(*analyze.Dir) + for _, file := range item.GetFiles() { + deleteItems = append(deleteItems, file) + } + } else { + parentDir = item.GetParent() + deleteItems = append(deleteItems, item) + } + + for _, toDelete := range deleteItems { + if err := deleteFun(parentDir, toDelete); err != nil { + msg := "Can't " + action + tview.Escape(toDelete.GetName()) + ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage(acting) + ui.showErr(msg, err) + }) + if ui.done != nil { + ui.done <- struct{}{} + } + return + } + } + + if item.GetParent() == ui.currentDir { + ui.app.QueueUpdateDraw(func() { + row, _ := ui.table.GetSelection() + x, y := ui.table.GetOffset() + ui.showDir() + ui.table.Select(min(row, ui.table.GetRowCount()-1), 0) + ui.table.SetOffset(min(x, ui.table.GetRowCount()-1), y) + }) + } + if ui.done != nil { + ui.done <- struct{}{} + } +} + +func (ui *UI) increaseActiveWorkers() { + ui.workersMut.Lock() + defer ui.workersMut.Unlock() + ui.activeWorkers++ +} + +func (ui *UI) decreaseActiveWorkers() { + ui.workersMut.Lock() + defer ui.workersMut.Unlock() + ui.activeWorkers-- +} diff --git a/tui/marked.go b/tui/marked.go index f41ebc6bc..fb5ac91ad 100644 --- a/tui/marked.go +++ b/tui/marked.go @@ -31,9 +31,6 @@ func (ui *UI) deleteMarked(shouldEmpty bool) { acting = "deleting" } - modal := tview.NewModal() - ui.pages.AddPage(acting, modal, true, true) - var currentDir fs.Item var markedItems []fs.Item for row := range ui.markedRows { @@ -41,6 +38,14 @@ func (ui *UI) deleteMarked(shouldEmpty bool) { markedItems = append(markedItems, item) } + if ui.deleteInBackground { + ui.queueForDeletion(markedItems, shouldEmpty) + return + } + + modal := tview.NewModal() + ui.pages.AddPage(acting, modal, true, true) + currentRow, _ := ui.table.GetSelection() var deleteFun func(fs.Item, fs.Item) error diff --git a/tui/status.go b/tui/status.go new file mode 100644 index 000000000..ff4c06219 --- /dev/null +++ b/tui/status.go @@ -0,0 +1,84 @@ +package tui + +import ( + "fmt" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (ui *UI) toggleStatusBar(show bool) { + var textColor, textBgColor tcell.Color + if ui.UseColors { + textColor = tcell.NewRGBColor(0, 0, 0) + textBgColor = tcell.NewRGBColor(36, 121, 208) + } else { + textColor = tcell.NewRGBColor(0, 0, 0) + textBgColor = tcell.NewRGBColor(255, 255, 255) + } + + ui.grid.Clear() + + ui.statusMut.Lock() + defer ui.statusMut.Unlock() + + if show { + ui.status = tview.NewTextView().SetDynamicColors(true) + ui.status.SetTextColor(textColor) + ui.status.SetBackgroundColor(textBgColor) + + ui.grid.SetRows(1, 1, 0, 1, 1) + ui.grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). + AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false). + AddItem(ui.table, 2, 0, 1, 1, 0, 0, true). + AddItem(ui.status, 3, 0, 1, 1, 0, 0, false). + AddItem(ui.footer, 4, 0, 1, 1, 0, 0, false) + return + } + ui.status = nil + ui.grid.SetRows(1, 1, 0, 1) + ui.grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). + AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false). + AddItem(ui.table, 2, 0, 1, 1, 0, 0, true). + AddItem(ui.footer, 3, 0, 1, 1, 0, 0, false) +} + +func (ui *UI) updateStatusWorker() { + for { + ui.updateStatus() + time.Sleep(500 * time.Millisecond) + } +} + +func (ui *UI) updateStatus() { + ui.workersMut.Lock() + cnt := ui.activeWorkers + ui.workersMut.Unlock() + + ui.statusMut.RLock() + status := ui.status + ui.statusMut.RUnlock() + + if cnt == 0 && status == nil { + return + } + + if cnt > 0 && status == nil { + ui.app.QueueUpdateDraw(func() { + ui.toggleStatusBar(true) + }) + } else if cnt == 0 { + ui.app.QueueUpdateDraw(func() { + ui.toggleStatusBar(false) + }) + return + } + + ui.app.QueueUpdateDraw(func() { + msg := fmt.Sprintf(" Active background deletions: %d", cnt) + ui.statusMut.RLock() + ui.status.SetText(msg) + ui.statusMut.RUnlock() + }) +} diff --git a/tui/tui.go b/tui/tui.go index e912696ac..a0facec9f 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -2,6 +2,8 @@ package tui import ( "io" + "runtime" + "sync" "time" "golang.org/x/exp/slices" @@ -22,12 +24,14 @@ type UI struct { app common.TermApplication screen tcell.Screen output io.Writer + grid *tview.Grid header *tview.TextView footer *tview.Flex footerLabel *tview.TextView currentDirLabel *tview.TextView pages *tview.Pages progress *tview.TextView + status *tview.TextView help *tview.Flex table *tview.Table filteringInput *tview.InputField @@ -59,6 +63,16 @@ type UI struct { markedRows map[int]struct{} exportName string noDelete bool + deleteInBackground bool + deleteQueue chan deleteQueueItem + activeWorkers int + workersMut sync.Mutex + statusMut sync.RWMutex +} + +type deleteQueueItem struct { + item fs.Item + shouldEmpty bool } // Option is optional function customizing the bahaviour of UI @@ -102,6 +116,7 @@ func CreateUI( markedRows: make(map[int]struct{}), exportName: "export.json", noDelete: false, + deleteQueue: make(chan deleteQueueItem, 1000), } for _, o := range opts { o(ui) @@ -157,14 +172,14 @@ func CreateUI( ui.footer = tview.NewFlex() ui.footer.AddItem(ui.footerLabel, 0, 1, false) - grid := tview.NewGrid().SetRows(1, 1, 0, 1).SetColumns(0) - grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). + ui.grid = tview.NewGrid().SetRows(1, 1, 0, 1).SetColumns(0) + ui.grid.AddItem(ui.header, 0, 0, 1, 1, 0, 0, false). AddItem(ui.currentDirLabel, 1, 0, 1, 1, 0, 0, false). AddItem(ui.table, 2, 0, 1, 1, 0, 0, true). AddItem(ui.footer, 3, 0, 1, 1, 0, 0, false) ui.pages = tview.NewPages(). - AddPage("background", grid, true, true) + AddPage("background", ui.grid, true, true) ui.pages.SetBackgroundColor(tcell.ColorDefault) ui.app.SetRoot(ui.pages, true) @@ -204,14 +219,26 @@ func (ui *UI) StartUILoop() error { return ui.app.Run() } +// SetShowItemCount sets the flag to show number of items in directory func (ui *UI) SetShowItemCount() { ui.showItemCount = true } +// SetNoDelete disables all write operations func (ui *UI) SetNoDelete() { ui.noDelete = true } +// SetDeleteInBackground sets the flag to delete files in background +func (ui *UI) SetDeleteInBackground() { + ui.deleteInBackground = true + + for i := 0; i < 3*runtime.GOMAXPROCS(0); i++ { + go ui.deleteWorker() + } + go ui.updateStatusWorker() +} + func (ui *UI) resetSorting() { ui.sortBy = ui.defaultSortBy ui.sortOrder = ui.defaultSortOrder diff --git a/tui/tui_test.go b/tui/tui_test.go index 21765866f..ecd0c8f57 100644 --- a/tui/tui_test.go +++ b/tui/tui_test.go @@ -4,7 +4,9 @@ import ( "bytes" "errors" "fmt" + "os" "testing" + "time" log "github.com/sirupsen/logrus" @@ -328,6 +330,108 @@ func TestDeleteSelected(t *testing.T) { assert.NoDirExists(t, "test_dir/nested") } +func TestDeleteSelectedInBackground(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, true, false) + ui.remover = testanalyze.RemoveItemFromDirWithSleep + ui.done = make(chan struct{}) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.deleteSelected(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestDeleteSelectedInBackgroundBW(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.done = make(chan struct{}) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.deleteSelected(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + +func TestEmptyDirInBackground(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, true, false) + ui.done = make(chan struct{}) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.deleteSelected(true) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.NoDirExists(t, "test_dir/nested/subnested") +} + +func TestEmptyFileInBackground(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, true, true, false) + ui.done = make(chan struct{}) + ui.SetDeleteInBackground() + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.fileItemSelected(0, 0) // nested + ui.table.Select(2, 0) + + ui.deleteSelected(true) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.FileExists(t, "test_dir/nested/file2") + + f, err := os.Open("test_dir/nested/file2") + assert.Nil(t, err) + info, err := f.Stat() + assert.Nil(t, err) + assert.Equal(t, int64(0), info.Size()) +} + func TestDeleteSelectedWithErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() @@ -351,6 +455,38 @@ func TestDeleteSelectedWithErr(t *testing.T) { assert.DirExists(t, "test_dir/nested") } +func TestDeleteSelectedInBackgroundWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.SetDeleteInBackground() + ui.remover = testanalyze.RemoveItemFromDirWithSleepAndErr + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.delete(false) + + <-ui.done + + // change the status + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + // wait for status to be removed + time.Sleep(500 * time.Millisecond) + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.True(t, ui.pages.HasPage("error")) + assert.DirExists(t, "test_dir/nested") +} + func TestDeleteMarkedWithErr(t *testing.T) { fin := testdir.CreateTestDir() defer fin() @@ -375,6 +511,31 @@ func TestDeleteMarkedWithErr(t *testing.T) { assert.DirExists(t, "test_dir/nested") } +func TestDeleteMarkedInBackgroundWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.SetDeleteInBackground() + ui.remover = testanalyze.RemoveItemFromDirWithErr + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + ui.markedRows[0] = struct{}{} + + ui.deleteMarked(false) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.True(t, ui.pages.HasPage("error")) + assert.DirExists(t, "test_dir/nested") +} + func TestShowErr(t *testing.T) { simScreen := testapp.CreateSimScreen() defer simScreen.Fini()