diff --git a/README.md b/README.md index 3e053050a..496e92738 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ Flags: --min-age string Include files with mtime at least DURATION old (e.g., 30d, 1w) --mouse Use mouse -c, --no-color Do not use colorized output + --no-confirm-quit Do not ask for confirmation before quitting after a long scan -x, --no-cross Do not cross filesystem boundaries --no-delete Do not allow deletions -H, --no-hidden Ignore hidden directories (beginning with dot) diff --git a/cmd/gdu/app/app.go b/cmd/gdu/app/app.go index 6a67accc2..c709232a2 100644 --- a/cmd/gdu/app/app.go +++ b/cmd/gdu/app/app.go @@ -83,6 +83,7 @@ type Flags struct { NoDelete bool `yaml:"no-delete"` NoViewFile bool `yaml:"no-view-file"` NoSpawnShell bool `yaml:"no-spawn-shell"` + NoConfirmQuit bool `yaml:"no-confirm-quit"` FollowSymlinks bool `yaml:"follow-symlinks"` Profiling bool `yaml:"profiling"` ReadFromStorage bool `yaml:"read-from-storage"` @@ -546,6 +547,11 @@ func (a *App) getOptions() []tui.Option { ui.SetNoSpawnShell() }) } + if a.Flags.NoConfirmQuit { + opts = append(opts, func(ui *tui.UI) { + ui.SetConfirmQuit(false) + }) + } if a.Flags.DeleteInBackground { opts = append(opts, func(ui *tui.UI) { ui.SetDeleteInBackground() diff --git a/cmd/gdu/main.go b/cmd/gdu/main.go index 0d4772cda..f1c9454d2 100644 --- a/cmd/gdu/main.go +++ b/cmd/gdu/main.go @@ -100,6 +100,7 @@ func init() { flags.BoolVar(&af.NoDelete, "no-delete", false, "Do not allow deletions") flags.BoolVar(&af.NoViewFile, "no-view-file", false, "Do not allow viewing file contents") flags.BoolVar(&af.NoSpawnShell, "no-spawn-shell", false, "Do not allow spawning shell") + flags.BoolVar(&af.NoConfirmQuit, "no-confirm-quit", false, "Do not ask for confirmation before quitting after a long scan") flags.BoolVar(&af.WriteConfig, "write-config", false, "Write current configuration to file (default is $HOME/.gdu.yaml)") flags.StringVar( &af.Since, "since", "", diff --git a/configuration.md b/configuration.md index 24105678a..9bc733b0d 100644 --- a/configuration.md +++ b/configuration.md @@ -92,6 +92,10 @@ Do not allow deletions Do not allow viewing file contents +#### `no-confirm-quit` + +Do not ask for confirmation before quitting after a long scan. By default, pressing `q`/`Q` after a scan that took more than a few seconds shows a confirmation dialog so that results are not lost by an accidental key press. + #### `follow-symlinks` Follow symlinks for files, i.e. show the size of the file to which symlink points to (symlinks to directories are not followed) diff --git a/gdu.1.md b/gdu.1.md index e2a073e20..301f583b9 100644 --- a/gdu.1.md +++ b/gdu.1.md @@ -98,6 +98,8 @@ non-interactive mode **\--no-spawn-shell**\[=false\] Do not allow spawning shell +**\--no-confirm-quit**\[=false\] Do not ask for confirmation before quitting after a long scan + **\--no-delete**\[=false\] Do not allow deletions **\--no-view-file**\[=false\] Do not allow viewing file contents diff --git a/tui/actions.go b/tui/actions.go index 892bd0945..3e5cafa94 100644 --- a/tui/actions.go +++ b/tui/actions.go @@ -82,6 +82,8 @@ func (ui *UI) AnalyzePath(path string, parentDir fs.Item) error { doneChan := analyzer.GetDone() go ui.updateProgress(analyzer, doneChan) + ui.scanStart = time.Now() + ui.scanning = true go func() { defer debug.FreeOSMemory() currentDir := ui.Analyzer.AnalyzeDir(path, ui.CreateIgnoreFunc(), ui.CreateFileTypeFilter()) @@ -103,6 +105,11 @@ func (ui *UI) AnalyzePath(path string, parentDir fs.Item) error { } ui.app.QueueUpdateDraw(func() { + ui.scanning = false + // remember the longest scan so we can guard against accidental quits + if d := time.Since(ui.scanStart); d > ui.scanDuration { + ui.scanDuration = d + } ui.currentDir = currentDir ui.showDir() ui.pages.RemovePage("progress") diff --git a/tui/keys.go b/tui/keys.go index 13764d4b6..2465a36c2 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -150,23 +150,97 @@ func (ui *UI) handleCtrlZ(key *tcell.EventKey) *tcell.EventKey { return key } +// confirmQuitMinScanDuration is the scan time above which quitting asks for +// confirmation, so a long scan is not lost by an accidental key press. +const confirmQuitMinScanDuration = 3 * time.Second + func (ui *UI) handleQuit(key *tcell.EventKey) *tcell.EventKey { clearTerminalProgress() + // do not re-trigger quitting while a confirmation dialog is open + if ui.pages.HasPage("confirm") { + return key + } + switch key.Rune() { case 'Q': - ui.app.Stop() - ui.printMarkedPaths() - fmt.Fprintf(ui.output, "%s\n", ui.currentDirPath) + ui.quit(true) return nil case 'q': - ui.app.Stop() - ui.printMarkedPaths() + ui.quit(false) return nil } return key } +// quit asks for confirmation when there are scan results worth protecting, +// otherwise it quits immediately. +func (ui *UI) quit(printCurrentDirPath bool) { + if ui.shouldConfirmQuit() { + ui.confirmQuitDialog(printCurrentDirPath) + return + } + ui.doQuit(printCurrentDirPath) +} + +// shouldConfirmQuit returns true when quitting would discard the work of a scan +// that took a noticeable amount of time, whether it is still running or already +// finished. +func (ui *UI) shouldConfirmQuit() bool { + if !ui.confirmQuit { + return false + } + if ui.scanning { + return time.Since(ui.scanStart) >= confirmQuitMinScanDuration + } + return ui.currentDir != nil && ui.scanDuration >= confirmQuitMinScanDuration +} + +func (ui *UI) doQuit(printCurrentDirPath bool) { + ui.app.Stop() + ui.printMarkedPaths() + if printCurrentDirPath { + fmt.Fprintf(ui.output, "%s\n", ui.currentDirPath) + } +} + +func (ui *UI) confirmQuitDialog(printCurrentDirPath bool) { + var text string + if ui.scanning { + text = "A scan has been running for " + + time.Since(ui.scanStart).Round(time.Second).String() + ".\n\n" + + "Do you really want to quit gdu and abandon it?" + } else { + text = "Do you really want to quit gdu?\n\n" + + "This scan took " + ui.scanDuration.Round(time.Second).String() + + " and the results are not saved.\n" + + "Choose \"no\" and press E to export them first." + } + modal := tview.NewModal(). + SetText(text). + AddButtons([]string{"no", "yes", "don't ask me again"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + ui.pages.RemovePage("confirm") + ui.app.SetFocus(ui.table) + switch buttonIndex { + case 2: + ui.confirmQuit = false + fallthrough + case 1: + ui.doQuit(printCurrentDirPath) + } + }) + + if !ui.UseColors { + modal.SetBackgroundColor(tcell.ColorGray) + } else { + modal.SetBackgroundColor(tcell.ColorBlack) + } + modal.SetBorderColor(tcell.ColorDefault) + + ui.pages.AddPage("confirm", modal, true, true) +} + func (ui *UI) handleHelp(key *tcell.EventKey) *tcell.EventKey { if key.Rune() == '?' { if ui.pages.HasPage("help") { diff --git a/tui/keys_test.go b/tui/keys_test.go index b957798bf..9c9d01bd4 100644 --- a/tui/keys_test.go +++ b/tui/keys_test.go @@ -335,6 +335,116 @@ func TestStopWithPrintingPath(t *testing.T) { assert.Equal(t, "test_dir\n", buff.String()) } +// analyzedUIForQuit returns a UI with a finished scan so quit behaviour can be tested. +func analyzedUIForQuit(t *testing.T, buff *bytes.Buffer) *UI { + t.Helper() + simScreen := testapp.CreateSimScreen() + t.Cleanup(simScreen.Fini) + + app := testapp.CreateMockedApp(false) + ui := CreateUI(app, simScreen, buff, true, true, false, false) + + ui.done = make(chan struct{}) + assert.Nil(t, ui.AnalyzePath("test_dir", nil)) + <-ui.done // wait for analyzer + for _, f := range ui.app.(*testapp.MockedApp).GetUpdateDraws() { + f() + } + return ui +} + +func TestQuitConfirmsAfterLongScan(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := analyzedUIForQuit(t, &bytes.Buffer{}) + ui.scanDuration = 10 * time.Second // simulate a long scan + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + assert.Nil(t, key) + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestQuitImmediateAfterShortScan(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := analyzedUIForQuit(t, &bytes.Buffer{}) + ui.scanDuration = time.Second // below the confirmation threshold + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + assert.Nil(t, key) + assert.False(t, ui.pages.HasPage("confirm")) +} + +func TestQuitImmediateWhenConfirmDisabled(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := analyzedUIForQuit(t, &bytes.Buffer{}) + ui.SetConfirmQuit(false) + ui.scanDuration = 10 * time.Second + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + assert.Nil(t, key) + assert.False(t, ui.pages.HasPage("confirm")) +} + +func TestQuitNotRetriggeredWhileConfirmOpen(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := analyzedUIForQuit(t, &bytes.Buffer{}) + ui.scanDuration = 10 * time.Second + + assert.Nil(t, ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0))) + assert.True(t, ui.pages.HasPage("confirm")) + + // pressing q again must not stack dialogs nor quit; the key passes through + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + assert.NotNil(t, key) + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestQuitConfirmsDuringLongRunningScan(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := analyzedUIForQuit(t, &bytes.Buffer{}) + // simulate a scan that is still running and started long ago + ui.scanning = true + ui.scanStart = time.Now().Add(-10 * time.Second) + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + assert.Nil(t, key) + assert.True(t, ui.pages.HasPage("confirm")) +} + +func TestQuitImmediateDuringShortRunningScan(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + ui := analyzedUIForQuit(t, &bytes.Buffer{}) + // a scan that just started should not block quitting + ui.scanning = true + ui.scanStart = time.Now() + + key := ui.keyPressed(tcell.NewEventKey(tcell.KeyRune, 'q', 0)) + assert.Nil(t, key) + assert.False(t, ui.pages.HasPage("confirm")) +} + +func TestDoQuitPrintsCurrentDirPath(t *testing.T) { + fin := testdir.CreateTestDir() + defer fin() + + buff := &bytes.Buffer{} + ui := analyzedUIForQuit(t, buff) + + ui.doQuit(true) + assert.Equal(t, "test_dir\n", buff.String()) +} + func TestSpawnShell(t *testing.T) { fin := testdir.CreateTestDir() defer fin() diff --git a/tui/show.go b/tui/show.go index 9525c28a8..19b4c583f 100644 --- a/tui/show.go +++ b/tui/show.go @@ -30,7 +30,7 @@ var ( [::b]c [white:black:-]Show/hide file count [::b]m [white:black:-]Show/hide latest mtime [::b]b [white:black:-]Spawn shell in current directory - [::b]q [white:black:-]Quit gdu + [::b]q [white:black:-]Quit gdu (asks to confirm after a long scan) [::b]Q [white:black:-]Quit gdu and print current directory path Item under cursor: diff --git a/tui/tui.go b/tui/tui.go index 4bf29eef1..50dee1a81 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -98,6 +98,10 @@ type UI struct { browseParentDirs bool showDiskProgressBar bool currentDeviceSize int64 + confirmQuit bool + scanning bool + scanStart time.Time + scanDuration time.Duration } type deleteQueueItem struct { @@ -137,6 +141,7 @@ func CreateUI( screen: screen, output: output, askBeforeDelete: true, + confirmQuit: true, showItemCount: false, remover: remove.ItemFromDir, emptier: remove.EmptyFileFromDir, @@ -343,6 +348,12 @@ func (ui *UI) StartUILoop() error { return ui.app.Run() } +// SetConfirmQuit sets whether gdu asks for confirmation before quitting +// after a scan that took a noticeable amount of time +func (ui *UI) SetConfirmQuit(value bool) { + ui.confirmQuit = value +} + // SetShowItemCount sets the flag to show number of items in directory func (ui *UI) SetShowItemCount() { ui.showItemCount = true