diff --git a/tui/actions.go b/tui/actions.go index 008e7a702..e88a63b43 100644 --- a/tui/actions.go +++ b/tui/actions.go @@ -136,6 +136,14 @@ func (ui *UI) ReadAnalysis(input io.Reader) error { return nil } +func (ui *UI) delete(shouldEmpty bool) { + if len(ui.markedRows) > 0 { + ui.deleteMarked(shouldEmpty) + } else { + ui.deleteSelected(shouldEmpty) + } +} + func (ui *UI) deleteSelected(shouldEmpty bool) { row, column := ui.table.GetSelection() selectedItem := ui.table.GetCell(row, column).GetReference().(fs.Item) diff --git a/tui/format.go b/tui/format.go index e67a9f065..ce82b2ee4 100644 --- a/tui/format.go +++ b/tui/format.go @@ -9,7 +9,7 @@ import ( "github.com/rivo/tview" ) -func (ui *UI) formatFileRow(item fs.Item, maxUsage int64, maxSize int64) string { +func (ui *UI) formatFileRow(item fs.Item, maxUsage int64, maxSize int64, marked bool) string { var part int if ui.ShowApparentSize { @@ -20,7 +20,7 @@ func (ui *UI) formatFileRow(item fs.Item, maxUsage int64, maxSize int64) string row := string(item.GetFlag()) - if ui.UseColors { + if ui.UseColors && !marked { row += "[#e67100::b]" } else { row += "[::b]" @@ -35,7 +35,7 @@ func (ui *UI) formatFileRow(item fs.Item, maxUsage int64, maxSize int64) string row += getUsageGraph(part) if ui.showItemCount { - if ui.UseColors { + if ui.UseColors && !marked { row += "[#e67100::b]" } else { row += "[::b]" @@ -44,7 +44,7 @@ func (ui *UI) formatFileRow(item fs.Item, maxUsage int64, maxSize int64) string } if ui.showMtime { - if ui.UseColors { + if ui.UseColors && !marked { row += "[#e67100::b]" } else { row += "[::b]" @@ -55,8 +55,17 @@ func (ui *UI) formatFileRow(item fs.Item, maxUsage int64, maxSize int64) string ) } + if len(ui.markedRows) > 0 { + if marked { + row += string('✓') + } else { + row += " " + } + row += " " + } + if item.IsDir() { - if ui.UseColors { + if ui.UseColors && !marked { row += "[#3498db::b]/" } else { row += "[::b]/" diff --git a/tui/format_test.go b/tui/format_test.go index 94d44adf6..729cd5b9f 100644 --- a/tui/format_test.go +++ b/tui/format_test.go @@ -75,5 +75,29 @@ func TestEscapeName(t *testing.T) { Usage: 10, } - assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize()), "Aaa [red[] bbb") + assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false), "Aaa [red[] bbb") +} + +func TestMarked(t *testing.T) { + simScreen := testapp.CreateSimScreen(50, 50) + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, false, false, false, false) + ui.markedRows[0] = struct{}{} + + dir := &analyze.Dir{ + File: &analyze.File{ + Usage: 10, + }, + } + + file := &analyze.File{ + Name: "Aaa", + Parent: dir, + Usage: 10, + } + + assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), true), "✓ Aaa") + assert.Contains(t, ui.formatFileRow(file, file.GetUsage(), file.GetSize(), false), "[##########] Aaa") } diff --git a/tui/keys.go b/tui/keys.go index 2909b573e..945d1abbc 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -186,6 +186,8 @@ func (ui *UI) handleMainActions(key *tcell.EventKey) *tcell.EventKey { case '/': ui.showFilterInput() return nil + case ' ': + ui.handleMark() } return key } @@ -233,6 +235,20 @@ func (ui *UI) handleDelete(shouldEmpty bool) { if ui.askBeforeDelete { ui.confirmDeletion(shouldEmpty) } else { - ui.deleteSelected(shouldEmpty) + ui.delete(shouldEmpty) } } + +func (ui *UI) handleMark() { + if ui.currentDir == nil { + return + } + // do not allow deleting parent dir + row, column := ui.table.GetSelection() + selectedFile, ok := ui.table.GetCell(row, column).GetReference().(fs.Item) + if !ok || selectedFile == ui.currentDir.GetParent() { + return + } + + ui.fileItemMarked(row) +} diff --git a/tui/keys_test.go b/tui/keys_test.go index 0907d1588..0b2904343 100644 --- a/tui/keys_test.go +++ b/tui/keys_test.go @@ -344,6 +344,18 @@ func TestDeleteEmpty(t *testing.T) { assert.NotNil(t, key) } +func TestMarkEmpty(t *testing.T) { + simScreen := testapp.CreateSimScreen(50, 50) + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + assert.NotNil(t, key) +} + func TestDelete(t *testing.T) { fin := testdir.CreateTestDir() defer fin() @@ -380,6 +392,44 @@ func TestDelete(t *testing.T) { assert.NoDirExists(t, "test_dir/nested") } +func TestDeleteMarked(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen(50, 50) + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'd', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.NoDirExists(t, "test_dir/nested") +} + func TestDeleteParent(t *testing.T) { fin := testdir.CreateTestDir() defer fin() @@ -410,6 +460,36 @@ func TestDeleteParent(t *testing.T) { assert.DirExists(t, "test_dir/nested") } +func TestMarkParent(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen(50, 50) + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + + assert.Equal(t, len(ui.markedRows), 0) +} + func TestEmptyDir(t *testing.T) { fin := testdir.CreateTestDir() defer fin() @@ -447,6 +527,45 @@ func TestEmptyDir(t *testing.T) { assert.NoDirExists(t, "test_dir/nested/subnested") } +func TestMarkedEmptyDir(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen(50, 50) + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.NoDirExists(t, "test_dir/nested/subnested") +} + func TestEmptyFile(t *testing.T) { fin := testdir.CreateTestDir() defer fin() @@ -488,6 +607,49 @@ func TestEmptyFile(t *testing.T) { assert.DirExists(t, "test_dir/nested/subnested") } +func TestMarkedEmptyFile(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + simScreen := testapp.CreateSimScreen(50, 50) + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.askBeforeDelete = false + err := ui.AnalyzePath("test_dir", nil) + assert.Nil(t, err) + + <-ui.done // wait for analyzer + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.Equal(t, "test_dir", ui.currentDir.GetName()) + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRight, 'l', 0)) // into nested + + ui.table.Select(2, 0) // file2 + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, ' ', 0)) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0)) + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.DirExists(t, "test_dir/nested") + assert.DirExists(t, "test_dir/nested/subnested") +} + func TestSortByApparentSize(t *testing.T) { simScreen := testapp.CreateSimScreen(50, 50) defer simScreen.Fini() diff --git a/tui/marked.go b/tui/marked.go new file mode 100644 index 000000000..c2dcf9e6d --- /dev/null +++ b/tui/marked.go @@ -0,0 +1,139 @@ +package tui + +import ( + "strconv" + "strings" + + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func (ui *UI) fileItemMarked(row int) { + if _, ok := ui.markedRows[row]; ok { + delete(ui.markedRows, row) + } else { + ui.markedRows[row] = struct{}{} + } + ui.showDir() + ui.table.Select(row, 0) +} + +func (ui *UI) deleteMarked(shouldEmpty bool) { + var action, acting string + if shouldEmpty { + action = "empty " + acting = "emptying" + } else { + action = "delete " + 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 { + item := ui.table.GetCell(row, 0).GetReference().(fs.Item) + markedItems = append(markedItems, item) + } + + currentRow, _ := ui.table.GetSelection() + + var deleteFun func(fs.Item, fs.Item) error + + go func() { + for _, one := range markedItems { + + ui.app.QueueUpdateDraw(func() { + modal.SetText( + strings.Title(acting) + + " " + + tview.Escape(one.GetName()) + + "...", + ) + }) + + if shouldEmpty && !one.IsDir() { + deleteFun = ui.emptier + } else { + deleteFun = ui.remover + } + + var deleteItems []fs.Item + if shouldEmpty && one.IsDir() { + currentDir = one.(*analyze.Dir) + for _, file := range currentDir.GetFiles() { + deleteItems = append(deleteItems, file) + } + } else { + currentDir = ui.currentDir + deleteItems = append(deleteItems, one) + } + + for _, item := range deleteItems { + if err := deleteFun(currentDir, item); err != nil { + msg := "Can't " + action + tview.Escape(one.GetName()) + ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage(acting) + ui.showErr(msg, err) + }) + if ui.done != nil { + ui.done <- struct{}{} + } + return + } + } + } + + ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage(acting) + ui.markedRows = make(map[int]struct{}) + ui.showDir() + ui.table.Select(min(currentRow, ui.table.GetRowCount()-1), 0) + }) + + if ui.done != nil { + ui.done <- struct{}{} + } + }() +} + +func (ui *UI) confirmDeletionMarked(shouldEmpty bool) { + var action string + if shouldEmpty { + action = "empty" + } else { + action = "delete" + } + + modal := tview.NewModal(). + SetText( + "Are you sure you want to " + + action + " [::b]" + + strconv.Itoa(len(ui.markedRows)) + + "[::-] items?", + ). + AddButtons([]string{"yes", "no", "don't ask me again"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + switch buttonIndex { + case 2: + ui.askBeforeDelete = false + fallthrough + case 0: + ui.deleteMarked(shouldEmpty) + } + ui.pages.RemovePage("confirm") + }) + + if !ui.UseColors { + modal.SetBackgroundColor(tcell.ColorGray) + } else { + modal.SetBackgroundColor(tcell.ColorBlack) + } + modal.SetBorderColor(tcell.ColorDefault) + + ui.pages.AddPage("confirm", modal, true, true) +} diff --git a/tui/marked_test.go b/tui/marked_test.go new file mode 100644 index 000000000..41d5e16e9 --- /dev/null +++ b/tui/marked_test.go @@ -0,0 +1,22 @@ +package tui + +import ( + "testing" + + "github.com/dundee/gdu/v5/internal/testdir" + "github.com/stretchr/testify/assert" +) + +func TestItemMarked(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.done = make(chan struct{}) + + ui.fileItemMarked(1) + assert.Equal(t, ui.markedRows, map[int]struct{}{1: {}}) + + ui.fileItemMarked(1) + assert.Equal(t, ui.markedRows, map[int]struct{}{}) +} diff --git a/tui/show.go b/tui/show.go index d071b2b56..5edd6b0b5 100644 --- a/tui/show.go +++ b/tui/show.go @@ -64,10 +64,15 @@ func (ui *UI) showDir() { totalSize += item.GetSize() itemCount += item.GetItemCount() - cell := tview.NewTableCell(ui.formatFileRow(item, maxUsage, maxSize)) + _, marked := ui.markedRows[rowIndex] + cell := tview.NewTableCell(ui.formatFileRow(item, maxUsage, maxSize, marked)) cell.SetStyle(tcell.Style{}.Foreground(tcell.ColorDefault)) cell.SetReference(ui.currentDir.GetFiles()[i]) + if marked { + cell.SetBackgroundColor(tview.Styles.ContrastBackgroundColor) + } + ui.table.SetCell(rowIndex, 0, cell) rowIndex++ } @@ -81,8 +86,15 @@ func (ui *UI) showDir() { footerTextColor = "[black:white:-]" } + selected := "" + if len(ui.markedRows) > 0 { + selected = " Selected items: " + footerNumberColor + + strconv.Itoa(len(ui.markedRows)) + footerTextColor + } + ui.footerLabel.SetText( - " Total disk usage: " + + selected + + " Total disk usage: " + footerNumberColor + ui.formatSize(totalUsage, true, false) + " Apparent size: " + diff --git a/tui/tui.go b/tui/tui.go index 6e88a33c3..562b4c5e3 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -79,6 +79,7 @@ type UI struct { currentItemNameMaxLen int defaultSortBy string defaultSortOrder string + markedRows map[int]struct{} } // Option is optional function customizing the bahaviour of UI @@ -119,6 +120,7 @@ func CreateUI( currentItemNameMaxLen: 70, defaultSortBy: "size", defaultSortOrder: "desc", + markedRows: make(map[int]struct{}), } for _, o := range opts { o(ui) @@ -237,6 +239,7 @@ func (ui *UI) fileItemSelected(row, column int) { ui.currentDir = selectedDir.(*analyze.Dir) ui.hideFilterInput() + ui.markedRows = make(map[int]struct{}) ui.showDir() if selectedDir == origDir.GetParent() { @@ -270,6 +273,14 @@ func (ui *UI) deviceItemSelected(row, column int) { } func (ui *UI) confirmDeletion(shouldEmpty bool) { + if len(ui.markedRows) > 0 { + ui.confirmDeletionMarked(shouldEmpty) + } else { + ui.confirmDeletionSelected(shouldEmpty) + } +} + +func (ui *UI) confirmDeletionSelected(shouldEmpty bool) { row, column := ui.table.GetSelection() selectedFile := ui.table.GetCell(row, column).GetReference().(fs.Item) var action string diff --git a/tui/tui_test.go b/tui/tui_test.go index aab92f999..eeeb530f0 100644 --- a/tui/tui_test.go +++ b/tui/tui_test.go @@ -272,6 +272,36 @@ func TestConfirmEmpty(t *testing.T) { assert.True(t, ui.pages.HasPage("confirm")) } +func TestConfirmEmptyMarked(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, false, true, true) + + ui.table.Select(1, 0) + ui.markedRows[1] = struct{}{} + ui.confirmDeletion(true) + + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmDeletionMarked(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, true, true, true) + + ui.table.Select(1, 0) + ui.markedRows[1] = struct{}{} + ui.confirmDeletion(false) + + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestConfirmDeletionMarkedBW(t *testing.T) { + ui := getAnalyzedPathMockedApp(t, false, true, true) + + ui.table.Select(1, 0) + ui.markedRows[1] = struct{}{} + ui.confirmDeletion(false) + + assert.True(t, ui.pages.HasPage("confirm")) +} + func TestDeleteSelected(t *testing.T) { fin := testdir.CreateTestDir() defer fin() @@ -305,7 +335,31 @@ func TestDeleteSelectedWithErr(t *testing.T) { ui.table.Select(0, 0) - ui.deleteSelected(false) + ui.delete(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 TestDeleteMarkedWithErr(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := getAnalyzedPathMockedApp(t, false, true, false) + ui.remover = testanalyze.RemoveItemFromDirWithErr + + assert.Equal(t, 1, ui.table.GetRowCount()) + + ui.table.Select(0, 0) + ui.markedRows[0] = struct{}{} + + ui.deleteMarked(false) <-ui.done @@ -346,13 +400,52 @@ func TestMin(t *testing.T) { assert.Equal(t, 3, min(4, 3)) } -// func printScreen(simScreen tcell.SimulationScreen) { -// b, _, _ := simScreen.GetContents() +func TestSetSelectedBackgroundColor(t *testing.T) { + simScreen := testapp.CreateSimScreen(50, 50) + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + + ui.SetSelectedBackgroundColor(tcell.ColorRed) + + assert.Equal(t, ui.selectedBackgroundColor, tcell.ColorRed) +} + +func TestSetSelectedTextColor(t *testing.T) { + simScreen := testapp.CreateSimScreen(50, 50) + defer simScreen.Fini() -// for i, r := range b { -// println(i, string(r.Bytes)) -// } -// } + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + + ui.SetSelectedTextColor(tcell.ColorRed) + + assert.Equal(t, ui.selectedTextColor, tcell.ColorRed) +} + +func TestSetCurrentItemNameMaxLen(t *testing.T) { + simScreen := testapp.CreateSimScreen(50, 50) + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + + ui.SetCurrentItemNameMaxLen(5) + + assert.Equal(t, ui.currentItemNameMaxLen, 5) +} + +// nolint: unused // Why: for debugging +func printScreen(simScreen tcell.SimulationScreen) { + b, _, _ := simScreen.GetContents() + + for i, r := range b { + if string(r.Bytes) != " " { + println(i, string(r.Bytes)) + } + } +} func getDevicesInfoMock() device.DevicesInfoGetter { item := &device.Device{