Repository: dundee/gdu
Pull request #63 (2021-07-02 11:13:44)

Feature: Empty selected file or directory
PR for #62.

dundee: Could you also fix the tests and add some more? Or should I do it?
zhangsean: Please help me for the tests.

Patch:
diff --git a/pkg/analyze/file.go b/pkg/analyze/file.go
index 481944f..7a46874 100644
--- a/pkg/analyze/file.go
+++ b/pkg/analyze/file.go
@@ -255,3 +255,33 @@ func RemoveItemFromDir(dir *Dir, item Item) error {
 	}
 	return nil
 }
+
+// EmptyFileFromDir empty file from dir
+func EmptyFileFromDir(dir *Dir, file Item) error {
+	err := os.Truncate(file.GetPath(), 0)
+	if err != nil {
+		return err
+	}
+
+	cur := dir
+	for {
+		cur.Size -= file.GetSize()
+		cur.Usage -= file.GetUsage()
+
+		if cur.Parent == nil {
+			break
+		}
+		cur = cur.Parent
+	}
+
+	dir.Files = dir.Files.Remove(file)
+	newFile := &File{
+		Name:   file.GetName(),
+		Flag:   file.GetFlag(),
+		Size:   0,
+		Parent: dir,
+	}
+	dir.Files.Append(newFile)
+
+	return nil
+}
diff --git a/tui/actions.go b/tui/actions.go
index a5663d3..06131e7 100644
--- a/tui/actions.go
+++ b/tui/actions.go
@@ -6,6 +6,7 @@ import (
 	"os"
 	"path/filepath"
 	"runtime"
+	"strings"
 
 	"github.com/dundee/gdu/v5/pkg/analyze"
 	"github.com/dundee/gdu/v5/pkg/device"
@@ -112,30 +113,56 @@ func (ui *UI) AnalyzePath(path string, parentDir *analyze.Dir) error {
 	return nil
 }
 
-func (ui *UI) deleteSelected() {
+func (ui *UI) deleteSelected(shouldEmpty bool) {
 	row, column := ui.table.GetSelection()
-	selectedFile := ui.table.GetCell(row, column).GetReference().(analyze.Item)
-
-	modal := tview.NewModal().SetText("Deleting " + selectedFile.GetName() + "...")
-	ui.pages.AddPage("deleting", modal, true, true)
+	selectedItem := ui.table.GetCell(row, column).GetReference().(analyze.Item)
 
-	currentDir := ui.currentDir
+	var action, acting string
+	if shouldEmpty {
+		action = "empty "
+		acting = "emptying"
+	} else {
+		action = "delete "
+		acting = "deleting"
+	}
+	modal := tview.NewModal().SetText(strings.Title(acting) + " " + selectedItem.GetName() + "...")
+	ui.pages.AddPage(acting, modal, true, true)
+
+	var currentDir *analyze.Dir
+	var deleteItems []analyze.Item
+	if shouldEmpty && selectedItem.IsDir() {
+		currentDir = selectedItem.(*analyze.Dir)
+		for _, file := range currentDir.Files {
+			deleteItems = append(deleteItems, file)
+		}
+	} else {
+		currentDir = ui.currentDir
+		deleteItems = append(deleteItems, selectedItem)
+	}
 
+	var deleteFun func(*analyze.Dir, analyze.Item) error
+	if shouldEmpty && !selectedItem.IsDir() {
+		deleteFun = ui.emptier
+	} else {
+		deleteFun = ui.remover
+	}
 	go func() {
-		if err := ui.remover(currentDir, selectedFile); err != nil {
-			msg := "Can't delete " + selectedFile.GetName()
-			ui.app.QueueUpdateDraw(func() {
-				ui.pages.RemovePage("deleting")
-				ui.showErr(msg, err)
-			})
-			if ui.done != nil {
-				ui.done <- struct{}{}
+		for _, item := range deleteItems {
+			if err := deleteFun(currentDir, item); err != nil {
+				msg := "Can't " + action + selectedItem.GetName()
+				ui.app.QueueUpdateDraw(func() {
+					ui.pages.RemovePage(acting)
+					ui.showErr(msg, err)
+				})
+				if ui.done != nil {
+					ui.done <- struct{}{}
+				}
+				return
 			}
-			return
 		}
 
 		ui.app.QueueUpdateDraw(func() {
-			ui.pages.RemovePage("deleting")
+			ui.pages.RemovePage(acting)
 			ui.showDir()
 			ui.table.Select(min(row, ui.table.GetRowCount()-1), 0)
 		})
diff --git a/tui/keys.go b/tui/keys.go
index 8c2b592..93a0520 100644
--- a/tui/keys.go
+++ b/tui/keys.go
@@ -36,6 +36,7 @@ func (ui *UI) keyPressed(key *tcell.EventKey) *tcell.EventKey {
 	if ui.pages.HasPage("confirm") ||
 		ui.pages.HasPage("progress") ||
 		ui.pages.HasPage("deleting") ||
+		ui.pages.HasPage("emptying") ||
 		ui.pages.HasPage("info") {
 		return key
 	}
@@ -52,7 +53,9 @@ func (ui *UI) keyPressed(key *tcell.EventKey) *tcell.EventKey {
 
 	switch key.Rune() {
 	case 'd':
-		ui.handleDelete()
+		ui.handleDelete(false)
+	case 'e':
+		ui.handleDelete(true)
 	case 'v':
 		ui.showFile()
 	case 'i':
@@ -109,11 +112,10 @@ func (ui *UI) handleRight() {
 	}
 }
 
-func (ui *UI) handleDelete() {
+func (ui *UI) handleDelete(shouldEmpty bool) {
 	if ui.currentDir == nil {
 		return
 	}
-
 	// do not allow deleting parent dir
 	row, column := ui.table.GetSelection()
 	selectedFile := ui.table.GetCell(row, column).GetReference().(analyze.Item)
@@ -122,8 +124,8 @@ func (ui *UI) handleDelete() {
 	}
 
 	if ui.askBeforeDelete {
-		ui.confirmDeletion()
+		ui.confirmDeletion(shouldEmpty)
 	} else {
-		ui.deleteSelected()
+		ui.deleteSelected(shouldEmpty)
 	}
 }
diff --git a/tui/tui.go b/tui/tui.go
index d0f753c..9e0c87c 100644
--- a/tui/tui.go
+++ b/tui/tui.go
@@ -21,6 +21,7 @@ const helpTextColorized = `
  [red]enter, right, l    [white]Select directory/device
          [red]left, h    [white]Go to parent directory
 			   [red]d    [white]Delete selected file or directory
+			   [red]e    [white]Empty selected file or directory
 			   [red]v    [white]Show content of selected file
 			   [red]i    [white]Show info about selected item
 			   [red]r    [white]Rescan current directory
@@ -37,6 +38,7 @@ const helpText = `
  [::b]enter, right, l    [white:black:-]Select directory/device
          [::b]left, h    [white:black:-]Go to parent directory
 			   [::b]d    [white:black:-]Delete selected file or directory
+			   [::b]e    [white:black:-]Empty selected file or directory
 			   [::b]v    [white:black:-]Show content of selected file
 			   [::b]i    [white:black:-]Show info about selected item
 			   [::b]r    [white:black:-]Rescan current directory
@@ -70,6 +72,7 @@ type UI struct {
 	sortOrder       string
 	done            chan struct{}
 	remover         func(*analyze.Dir, analyze.Item) error
+	emptier         func(*analyze.Dir, analyze.Item) error
 }
 
 // CreateUI creates the whole UI app
@@ -86,6 +89,7 @@ func CreateUI(app common.TermApplication, useColors bool, showApparentSize bool)
 		sortBy:          "size",
 		sortOrder:       "desc",
 		remover:         analyze.RemoveItemFromDir,
+		emptier:         analyze.EmptyFileFromDir,
 	}
 
 	app.SetBeforeDrawFunc(func(screen tcell.Screen) bool {
@@ -273,11 +277,17 @@ func (ui *UI) deviceItemSelected(row, column int) {
 	ui.AnalyzePath(selectedDevice.MountPoint, nil)
 }
 
-func (ui *UI) confirmDeletion() {
+func (ui *UI) confirmDeletion(shouldEmpty bool) {
 	row, column := ui.table.GetSelection()
 	selectedFile := ui.table.GetCell(row, column).GetReference().(analyze.Item)
+	var action string
+	if shouldEmpty {
+		action = "empty"
+	} else {
+		action = "delete"
+	}
 	modal := tview.NewModal().
-		SetText("Are you sure you want to delete \"" + selectedFile.GetName() + "\"?").
+		SetText("Are you sure you want to " + action + " \"" + selectedFile.GetName() + "\"?").
 		AddButtons([]string{"yes", "no", "don't ask me again"}).
 		SetDoneFunc(func(buttonIndex int, buttonLabel string) {
 			switch buttonIndex {
@@ -285,7 +295,7 @@ func (ui *UI) confirmDeletion() {
 				ui.askBeforeDelete = false
 				fallthrough
 			case 0:
-				ui.deleteSelected()
+				ui.deleteSelected(shouldEmpty)
 			}
 			ui.pages.RemovePage("confirm")
 		})


Test patch:
diff --git a/pkg/analyze/file_test.go b/pkg/analyze/file_test.go
index fbd9162..8091b84 100644
--- a/pkg/analyze/file_test.go
+++ b/pkg/analyze/file_test.go
@@ -265,6 +265,84 @@ func TestRemoveFileWithErr(t *testing.T) {
 	assert.Contains(t, err.Error(), "permission denied")
 }
 
+func TestTruncateFile(t *testing.T) {
+	fin := testdir.CreateTestDir()
+	defer fin()
+
+	dir := &Dir{
+		File: &File{
+			Name:  "test_dir",
+			Size:  5,
+			Usage: 12,
+		},
+		ItemCount: 3,
+		BasePath:  ".",
+	}
+
+	subdir := &Dir{
+		File: &File{
+			Name:   "nested",
+			Size:   4,
+			Usage:  8,
+			Parent: dir,
+		},
+		ItemCount: 2,
+	}
+	file := &File{
+		Name:   "file2",
+		Size:   3,
+		Usage:  4,
+		Parent: subdir,
+	}
+	dir.Files = Files{subdir}
+	subdir.Files = Files{file}
+
+	err := EmptyFileFromDir(subdir, file)
+
+	assert.Nil(t, err)
+	assert.Equal(t, 1, len(subdir.Files))
+	assert.Equal(t, 2, subdir.ItemCount)
+	assert.Equal(t, int64(1), subdir.Size)
+	assert.Equal(t, int64(4), subdir.Usage)
+	assert.Equal(t, 1, len(dir.Files))
+	assert.Equal(t, 3, dir.ItemCount)
+	assert.Equal(t, int64(2), dir.Size)
+}
+
+func TestTruncateFileWithErr(t *testing.T) {
+	dir := &Dir{
+		File: &File{
+			Name:  "xxx",
+			Size:  5,
+			Usage: 12,
+		},
+		ItemCount: 3,
+		BasePath:  ".",
+	}
+
+	subdir := &Dir{
+		File: &File{
+			Name:   "yyy",
+			Size:   4,
+			Usage:  8,
+			Parent: dir,
+		},
+		ItemCount: 2,
+	}
+	file := &File{
+		Name:   "zzz",
+		Size:   3,
+		Usage:  4,
+		Parent: subdir,
+	}
+	dir.Files = Files{subdir}
+	subdir.Files = Files{file}
+
+	err := EmptyFileFromDir(subdir, file)
+
+	assert.Contains(t, err.Error(), "no such file or directory")
+}
+
 func TestUpdateStats(t *testing.T) {
 	dir := Dir{
 		File: &File{
diff --git a/tui/keys_test.go b/tui/keys_test.go
index bf2a79b..37888a0 100644
--- a/tui/keys_test.go
+++ b/tui/keys_test.go
@@ -221,6 +221,78 @@ func TestDeleteParent(t *testing.T) {
 	assert.DirExists(t, "test_dir/nested")
 }
 
+func TestEmptyDir(t *testing.T) {
+	fin := testdir.CreateTestDir()
+	defer fin()
+
+	app := testapp.CreateMockedApp(true)
+	ui := CreateUI(app, false, true)
+	ui.done = make(chan struct{})
+	ui.askBeforeDelete = false
+	ui.AnalyzePath("test_dir", nil)
+
+	<-ui.done // wait for analyzer
+
+	assert.Equal(t, "test_dir", ui.currentDir.Name)
+
+	for _, f := range ui.app.(*testapp.MockedApp).UpdateDraws {
+		f()
+	}
+
+	assert.Equal(t, 1, ui.table.GetRowCount())
+
+	ui.table.Select(0, 0)
+
+	ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'e', 0))
+
+	<-ui.done
+
+	for _, f := range ui.app.(*testapp.MockedApp).UpdateDraws {
+		f()
+	}
+
+	assert.DirExists(t, "test_dir/nested")
+	assert.NoDirExists(t, "test_dir/nested/subnested")
+}
+
+func TestEmptyFile(t *testing.T) {
+	fin := testdir.CreateTestDir()
+	defer fin()
+
+	app := testapp.CreateMockedApp(true)
+	ui := CreateUI(app, false, true)
+	ui.done = make(chan struct{})
+	ui.askBeforeDelete = false
+	ui.AnalyzePath("test_dir", nil)
+
+	<-ui.done // wait for analyzer
+
+	assert.Equal(t, "test_dir", ui.currentDir.Name)
+
+	for _, f := range ui.app.(*testapp.MockedApp).UpdateDraws {
+		f()
+	}
+
+	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, 'e', 0))
+
+	<-ui.done
+
+	for _, f := range ui.app.(*testapp.MockedApp).UpdateDraws {
+		f()
+	}
+
+	assert.DirExists(t, "test_dir/nested")
+	assert.DirExists(t, "test_dir/nested/subnested")
+}
+
 func TestSortByApparentSize(t *testing.T) {
 	app := testapp.CreateMockedApp(true)
 	ui := CreateUI(app, false, false)
diff --git a/tui/tui_test.go b/tui/tui_test.go
index 26dc5bc..e768e92 100644
--- a/tui/tui_test.go
+++ b/tui/tui_test.go
@@ -87,7 +87,7 @@ func TestHelp(t *testing.T) {
 
 	b, _, _ := simScreen.GetContents()
 
-	cells := b[306 : 306+9]
+	cells := b[356 : 356+9]
 
 	text := []byte("directory")
 	for i, r := range cells {
@@ -106,7 +106,7 @@ func TestHelpBw(t *testing.T) {
 
 	b, _, _ := simScreen.GetContents()
 
-	cells := b[306 : 306+9]
+	cells := b[356 : 356+9]
 
 	text := []byte("directory")
 	for i, r := range cells {
@@ -218,7 +218,7 @@ func TestConfirmDeletion(t *testing.T) {
 	ui := getAnalyzedPathMockedApp(t, true, true, true)
 
 	ui.table.Select(1, 0)
-	ui.confirmDeletion()
+	ui.confirmDeletion(false)
 
 	assert.True(t, ui.pages.HasPage("confirm"))
 }
@@ -227,7 +227,16 @@ func TestConfirmDeletionBW(t *testing.T) {
 	ui := getAnalyzedPathMockedApp(t, false, true, true)
 
 	ui.table.Select(1, 0)
-	ui.confirmDeletion()
+	ui.confirmDeletion(false)
+
+	assert.True(t, ui.pages.HasPage("confirm"))
+}
+
+func TestConfirmEmpty(t *testing.T) {
+	ui := getAnalyzedPathMockedApp(t, false, true, true)
+
+	ui.table.Select(1, 0)
+	ui.confirmDeletion(true)
 
 	assert.True(t, ui.pages.HasPage("confirm"))
 }
@@ -243,7 +252,7 @@ func TestDeleteSelected(t *testing.T) {
 
 	ui.table.Select(0, 0)
 
-	ui.deleteSelected()
+	ui.deleteSelected(false)
 
 	<-ui.done
 
@@ -265,7 +274,7 @@ func TestDeleteSelectedWithErr(t *testing.T) {
 
 	ui.table.Select(0, 0)
 
-	ui.deleteSelected()
+	ui.deleteSelected(false)
 
 	<-ui.done