From 7b7b6c7821ae575e04151339cdbe5a866c0c92c5 Mon Sep 17 00:00:00 2001 From: ultranity Date: Fri, 19 Jun 2026 14:22:23 +0800 Subject: [PATCH 1/2] feat: confirm before quitting after a long scan Pressing q/Q used to quit gdu immediately, so an accidental key press could discard the results of a scan that took a long time. Now, when a scan took longer than a few seconds and there are results on screen, quitting shows a confirmation dialog ("no" / "yes" / "don't ask me again"), reusing the existing deletion-confirmation modal pattern. The behaviour is enabled by default and can be turned off with the --no-confirm-quit flag or no-confirm-quit: true in the config file. Also guards handleQuit against re-triggering while a confirm dialog is open, which additionally prevents q from quitting the app while the delete-confirmation dialog is shown. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + cmd/gdu/app/app.go | 6 ++++ cmd/gdu/main.go | 1 + configuration.md | 4 +++ gdu.1.md | 2 ++ tui/actions.go | 5 +++ tui/keys.go | 73 ++++++++++++++++++++++++++++++++++++++--- tui/keys_test.go | 82 ++++++++++++++++++++++++++++++++++++++++++++++ tui/show.go | 2 +- tui/tui.go | 9 +++++ 10 files changed, 179 insertions(+), 6 deletions(-) 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..51f352f24 100644 --- a/tui/actions.go +++ b/tui/actions.go @@ -82,6 +82,7 @@ func (ui *UI) AnalyzePath(path string, parentDir fs.Item) error { doneChan := analyzer.GetDone() go ui.updateProgress(analyzer, doneChan) + start := time.Now() go func() { defer debug.FreeOSMemory() currentDir := ui.Analyzer.AnalyzeDir(path, ui.CreateIgnoreFunc(), ui.CreateFileTypeFilter()) @@ -103,6 +104,10 @@ func (ui *UI) AnalyzePath(path string, parentDir fs.Item) error { } ui.app.QueueUpdateDraw(func() { + // remember the longest scan so we can guard against accidental quits + if d := time.Since(start); 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..a40ffead5 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -150,23 +150,86 @@ 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 results from a +// scan that took a noticeable amount of time. +func (ui *UI) shouldConfirmQuit() bool { + return ui.confirmQuit && + 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) { + modal := tview.NewModal(). + SetText( + "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.", + ). + 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..d08fb2ae5 100644 --- a/tui/keys_test.go +++ b/tui/keys_test.go @@ -335,6 +335,88 @@ 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 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..a8cfc7f31 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -98,6 +98,8 @@ type UI struct { browseParentDirs bool showDiskProgressBar bool currentDeviceSize int64 + confirmQuit bool + scanDuration time.Duration } type deleteQueueItem struct { @@ -137,6 +139,7 @@ func CreateUI( screen: screen, output: output, askBeforeDelete: true, + confirmQuit: true, showItemCount: false, remover: remove.ItemFromDir, emptier: remove.EmptyFileFromDir, @@ -343,6 +346,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 From cbf3845e173d25364d34124581599fb33910300c Mon Sep 17 00:00:00 2001 From: ultranity Date: Fri, 19 Jun 2026 14:26:33 +0800 Subject: [PATCH 2/2] feat: also confirm quit while a long scan is still running The previous change only recorded the scan duration after a scan finished, so pressing q during a still-running scan would quit immediately - losing exactly the long scan it was meant to protect. Track the scan start time and the in-progress state, and treat a scan that has been running longer than the threshold the same as a finished long scan: quitting asks for confirmation first. Co-Authored-By: Claude Opus 4.8 (1M context) --- tui/actions.go | 6 ++++-- tui/keys.go | 33 ++++++++++++++++++++++----------- tui/keys_test.go | 28 ++++++++++++++++++++++++++++ tui/tui.go | 2 ++ 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/tui/actions.go b/tui/actions.go index 51f352f24..3e5cafa94 100644 --- a/tui/actions.go +++ b/tui/actions.go @@ -82,7 +82,8 @@ func (ui *UI) AnalyzePath(path string, parentDir fs.Item) error { doneChan := analyzer.GetDone() go ui.updateProgress(analyzer, doneChan) - start := time.Now() + ui.scanStart = time.Now() + ui.scanning = true go func() { defer debug.FreeOSMemory() currentDir := ui.Analyzer.AnalyzeDir(path, ui.CreateIgnoreFunc(), ui.CreateFileTypeFilter()) @@ -104,8 +105,9 @@ 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(start); d > ui.scanDuration { + if d := time.Since(ui.scanStart); d > ui.scanDuration { ui.scanDuration = d } ui.currentDir = currentDir diff --git a/tui/keys.go b/tui/keys.go index a40ffead5..2465a36c2 100644 --- a/tui/keys.go +++ b/tui/keys.go @@ -183,12 +183,17 @@ func (ui *UI) quit(printCurrentDirPath bool) { ui.doQuit(printCurrentDirPath) } -// shouldConfirmQuit returns true when quitting would discard results from a -// scan that took a noticeable amount of time. +// 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 { - return ui.confirmQuit && - ui.currentDir != nil && - ui.scanDuration >= confirmQuitMinScanDuration + 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) { @@ -200,13 +205,19 @@ func (ui *UI) doQuit(printCurrentDirPath bool) { } 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( - "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.", - ). + SetText(text). AddButtons([]string{"no", "yes", "don't ask me again"}). SetDoneFunc(func(buttonIndex int, buttonLabel string) { ui.pages.RemovePage("confirm") diff --git a/tui/keys_test.go b/tui/keys_test.go index d08fb2ae5..9c9d01bd4 100644 --- a/tui/keys_test.go +++ b/tui/keys_test.go @@ -406,6 +406,34 @@ func TestQuitNotRetriggeredWhileConfirmOpen(t *testing.T) { 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() diff --git a/tui/tui.go b/tui/tui.go index a8cfc7f31..50dee1a81 100644 --- a/tui/tui.go +++ b/tui/tui.go @@ -99,6 +99,8 @@ type UI struct { showDiskProgressBar bool currentDeviceSize int64 confirmQuit bool + scanning bool + scanStart time.Time scanDuration time.Duration }