diff --git a/tui/actions.go b/tui/actions.go index c9b39d603..7f60f6a67 100644 --- a/tui/actions.go +++ b/tui/actions.go @@ -2,13 +2,16 @@ package tui import ( "bufio" + "bytes" "fmt" "io" "os" "os/exec" "runtime" "runtime/debug" + "strconv" "strings" + "time" "github.com/dundee/gdu/v5/build" "github.com/dundee/gdu/v5/pkg/analyze" @@ -380,3 +383,78 @@ func (ui *UI) openItem() { ui.showErr("Error opening", err) } } + +func (ui *UI) confirmExport() *tview.Form { + form := tview.NewForm(). + AddInputField("File name", "export.json", 30, nil, func(v string) { + ui.exportName = v + }). + AddButton("Export", ui.exportAnalysis). + SetButtonsAlign(tview.AlignCenter) + form.SetBorder(true). + SetTitle(" Export data to JSON "). + SetInputCapture(func(key *tcell.EventKey) *tcell.EventKey { + if key.Key() == tcell.KeyEsc { + ui.pages.RemovePage("export") + ui.app.SetFocus(ui.table) + return nil + } + return key + }) + flex := modal(form, 50, 7) + ui.pages.AddPage("export", flex, true, true) + ui.app.SetFocus(form) + return form +} + +func (ui *UI) exportAnalysis() { + ui.pages.RemovePage("export") + + text := tview.NewTextView().SetText("Export in progress...").SetTextAlign(tview.AlignCenter) + text.SetBorder(true).SetTitle(" Export data to JSON ") + flex := modal(text, 50, 3) + ui.pages.AddPage("exporting", flex, true, true) + + go func() { + var err error + defer ui.app.QueueUpdateDraw(func() { + ui.pages.RemovePage("exporting") + if err == nil { + ui.app.SetFocus(ui.table) + } + }) + if ui.done != nil { + defer func() { + ui.done <- struct{}{} + }() + } + + var buff bytes.Buffer + + buff.Write([]byte(`[1,2,{"progname":"gdu","progver":"`)) + buff.Write([]byte(build.Version)) + buff.Write([]byte(`","timestamp":`)) + buff.Write([]byte(strconv.FormatInt(time.Now().Unix(), 10))) + buff.Write([]byte("},\n")) + + file, err := os.Create(ui.exportName) + if err != nil { + ui.showErrFromGo("Error creating file", err) + return + } + + if err = ui.topDir.EncodeJSON(&buff, true); err != nil { + ui.showErrFromGo("Error encoding JSON", err) + return + } + + if _, err = buff.Write([]byte("]\n")); err != nil { + ui.showErrFromGo("Error writting to buffer", err) + return + } + if _, err = buff.WriteTo(file); err != nil { + ui.showErrFromGo("Error writting to file", err) + return + } + }() +} diff --git a/tui/export_test.go b/tui/export_test.go new file mode 100644 index 000000000..52fd22cc7 --- /dev/null +++ b/tui/export_test.go @@ -0,0 +1,203 @@ +package tui + +import ( + "bytes" + "os" + "testing" + + "github.com/dundee/gdu/v5/internal/testanalyze" + "github.com/dundee/gdu/v5/internal/testapp" + "github.com/dundee/gdu/v5/pkg/analyze" + "github.com/dundee/gdu/v5/pkg/fs" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/stretchr/testify/assert" +) + +func TestConfirmExport(t *testing.T) { + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'E', 0)) + + assert.True(t, ui.pages.HasPage("export")) + + ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'n', 0)) + ui.keyPressed(tcell.NewEventKey(tcell.KeyEnter, 0, 0)) + + assert.True(t, ui.pages.HasPage("export")) +} + +func TestExportAnalysis(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + + ui.exportAnalysis() + + assert.True(t, ui.pages.HasPage("exporting")) + + <-ui.done + + assert.FileExists(t, "export.json") + err := os.Remove("export.json") + assert.NoError(t, err) + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } +} + +func TestExportAnalysisEsc(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + + form := ui.confirmExport() + formInputFn := form.GetInputCapture() + + assert.True(t, ui.pages.HasPage("export")) + + formInputFn(tcell.NewEventKey(tcell.KeyEsc, 0, 0)) + + assert.False(t, ui.pages.HasPage("export")) +} + +func TestExportAnalysisWithName(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + + form := ui.confirmExport() + // formInputFn := form.GetInputCapture() + item := form.GetFormItemByLabel("File name") + inputFn := item.(*tview.InputField).InputHandler() + + // send 'n' to input + inputFn(tcell.NewEventKey(tcell.KeyRune, 'n', 0), nil) + assert.Equal(t, "export.jsonn", ui.exportName) + + assert.True(t, ui.pages.HasPage("export")) + + form.GetButton(0).InputHandler()(tcell.NewEventKey(tcell.KeyEnter, 0, 0), nil) + + assert.True(t, ui.pages.HasPage("exporting")) + + <-ui.done + + assert.FileExists(t, "export.jsonn") + err := os.Remove("export.jsonn") + assert.NoError(t, err) + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } +} + +func TestExportAnalysisWithoutRights(t *testing.T) { + parentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "parent", + }, + Files: make([]fs.Item, 0, 1), + } + currentDir := &analyze.Dir{ + File: &analyze.File{ + Name: "sub", + Parent: parentDir, + }, + } + + _, err := os.Create("export.json") + assert.NoError(t, err) + err = os.Chmod("export.json", 0) + assert.NoError(t, err) + defer func() { + err = os.Chmod("export.json", 0755) + assert.Nil(t, err) + err = os.Remove("export.json") + assert.NoError(t, err) + }() + + simScreen := testapp.CreateSimScreen() + defer simScreen.Fini() + + app := testapp.CreateMockedApp(true) + ui := CreateUI(app, simScreen, &bytes.Buffer{}, false, true, false, false, false) + ui.done = make(chan struct{}) + ui.Analyzer = &testanalyze.MockedAnalyzer{} + ui.currentDir = currentDir + ui.topDir = parentDir + + ui.exportAnalysis() + + <-ui.done + + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + + assert.True(t, ui.pages.HasPage("error")) +} diff --git a/tui/keys.go b/tui/keys.go index 76629dcbf..1bb475bfa 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -13,7 +13,7 @@ func (ui *UI) keyPressed(key *tcell.EventKey) *tcell.EventKey { return nil } - if ui.pages.HasPage("file") { + if ui.pages.HasPage("file") || ui.pages.HasPage("export") { return key // send event to primitive } if ui.filtering { @@ -231,6 +231,9 @@ func (ui *UI) handleMainActions(key *tcell.EventKey) *tcell.EventKey { if ui.currentDir != nil { ui.rescanDir() } + case 'E': + ui.confirmExport() + return nil case 's': ui.setSorting("size") case 'C': diff --git a/tui/show.go b/tui/show.go index 765a6a67a..abf35cab9 100644 --- a/tui/show.go +++ b/tui/show.go @@ -17,6 +17,7 @@ const helpText = ` [::b]up/down, k/j [white:black:-]Move cursor up/down [::b]left, h [white:black:-]Go to parent directory [::b]r [white:black:-]Rescan current directory + [::b]E [white:black:-]Export analysis data to file as JSON [::b]/ [white:black:-]Search items by name [::b]a [white:black:-]Toggle between showing disk usage and apparent size [::b]B [white:black:-]Toggle bar alignment to biggest file or directory @@ -233,6 +234,13 @@ func (ui *UI) showErr(msg string, err error) { } ui.pages.AddPage("error", modal, true, true) + ui.app.SetFocus(modal) +} + +func (ui *UI) showErrFromGo(msg string, err error) { + ui.app.QueueUpdateDraw(func() { + ui.showErr(msg, err) + }) } func (ui *UI) showHelp() { diff --git a/tui/tui.go b/tui/tui.go index baaee7348..dec7eb750 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -54,6 +54,7 @@ type UI struct { defaultSortBy string defaultSortOrder string markedRows map[int]struct{} + exportName string } // Option is optional function customizing the bahaviour of UI @@ -95,6 +96,7 @@ func CreateUI( defaultSortBy: "size", defaultSortOrder: "desc", markedRows: make(map[int]struct{}), + exportName: "export.json", } for _, o := range opts { o(ui) diff --git a/tui/tui_test.go b/tui/tui_test.go index db2c5ce06..43c89089d 100644 --- a/tui/tui_test.go +++ b/tui/tui_test.go @@ -93,7 +93,7 @@ func TestHelp(t *testing.T) { b, _, _ := simScreen.GetContents() - cells := b[406 : 406+9] + cells := b[456 : 456+9] text := []byte("directory") for i, r := range cells { @@ -114,7 +114,7 @@ func TestHelpBw(t *testing.T) { b, _, _ := simScreen.GetContents() - cells := b[406 : 406+9] + cells := b[456 : 456+9] text := []byte("directory") for i, r := range cells { diff --git a/tui/utils.go b/tui/utils.go index f38aa3342..351f679c9 100644 --- a/tui/utils.go +++ b/tui/utils.go @@ -2,6 +2,7 @@ package tui import ( "github.com/dundee/gdu/v5/pkg/device" + "github.com/rivo/tview" ) var ( @@ -65,3 +66,13 @@ func min(a, b int) int { } return b } + +func modal(p tview.Primitive, width, height int) tview.Primitive { + return tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(p, height, 1, true). + AddItem(nil, 0, 1, false), width, 1, true). + AddItem(nil, 0, 1, false) +}