Repository: jesseduffield/lazygit (language: go)

Issue:
Add command to remove command history
**Is your feature request related to a problem? Please describe.**
 I'm always frustrated when I typed the wrong command, and it's not easy to remove that entry from the command history

**Describe the solution you'd like**
I want to have a command to remove the entry when I am selecting that entry.



Discussion / hints:

Fix patch:
diff --git a/pkg/gui/context/suggestions_context.go b/pkg/gui/context/suggestions_context.go
index 59908fe5eb7..c741cc769a9 100644
--- a/pkg/gui/context/suggestions_context.go
+++ b/pkg/gui/context/suggestions_context.go
@@ -14,10 +14,13 @@ type SuggestionsContext struct {
 }
 
 type SuggestionsContextState struct {
-	Suggestions  []*types.Suggestion
-	OnConfirm    func() error
-	OnClose      func() error
-	AsyncHandler *tasks.AsyncHandler
+	Suggestions        []*types.Suggestion
+	OnConfirm          func() error
+	OnClose            func() error
+	OnDeleteSuggestion func() error
+	AsyncHandler       *tasks.AsyncHandler
+
+	AllowEditSuggestion bool
 
 	// FindSuggestions will take a string that the user has typed into a prompt
 	// and return a slice of suggestions which match that string.
diff --git a/pkg/gui/controllers/confirmation_controller.go b/pkg/gui/controllers/confirmation_controller.go
index aa5617fa81d..45bd16a456e 100644
--- a/pkg/gui/controllers/confirmation_controller.go
+++ b/pkg/gui/controllers/confirmation_controller.go
@@ -1,6 +1,8 @@
 package controllers
 
 import (
+	"fmt"
+
 	"github.com/jesseduffield/lazygit/pkg/gui/context"
 	"github.com/jesseduffield/lazygit/pkg/gui/types"
 )
@@ -39,6 +41,14 @@ func (self *ConfirmationController) GetKeybindings(opts types.KeybindingsOpts) [
 			Key: opts.GetKey(opts.Config.Universal.TogglePanel),
 			Handler: func() error {
 				if len(self.c.Contexts().Suggestions.State.Suggestions) > 0 {
+					subtitle := ""
+					if self.c.State().GetRepoState().GetCurrentPopupOpts().HandleDeleteSuggestion != nil {
+						// We assume that whenever things are deletable, they
+						// are also editable, so we show both keybindings
+						subtitle = fmt.Sprintf(self.c.Tr.SuggestionsSubtitle,
+							self.c.UserConfig.Keybinding.Universal.Remove, self.c.UserConfig.Keybinding.Universal.Edit)
+					}
+					self.c.Views().Suggestions.Subtitle = subtitle
 					return self.c.ReplaceContext(self.c.Contexts().Suggestions)
 				}
 				return nil
diff --git a/pkg/gui/controllers/custom_command_action.go b/pkg/gui/controllers/custom_command_action.go
index f4de3218eb9..39777e70a0d 100644
--- a/pkg/gui/controllers/custom_command_action.go
+++ b/pkg/gui/controllers/custom_command_action.go
@@ -1,6 +1,7 @@
 package controllers
 
 import (
+	"slices"
 	"strings"
 
 	"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
@@ -17,6 +18,7 @@ func (self *CustomCommandAction) Call() error {
 	return self.c.Prompt(types.PromptOpts{
 		Title:               self.c.Tr.CustomCommand,
 		FindSuggestionsFunc: self.GetCustomCommandsHistorySuggestionsFunc(),
+		AllowEditSuggestion: true,
 		HandleConfirm: func(command string) error {
 			if self.shouldSaveCommand(command) {
 				self.c.GetAppState().CustomCommandsHistory = utils.Limit(
@@ -32,13 +34,34 @@ func (self *CustomCommandAction) Call() error {
 				self.c.OS().Cmd.NewShell(command),
 			)
 		},
+		HandleDeleteSuggestion: func(index int) error {
+			// index is the index in the _filtered_ list of suggestions, so we
+			// need to map it back to the full list. There's no really good way
+			// to do this, but fortunately we keep the items in the
+			// CustomCommandsHistory unique, which allows us to simply search
+			// for it by string.
+			item := self.c.Contexts().Suggestions.GetItems()[index].Value
+			fullIndex := lo.IndexOf(self.c.GetAppState().CustomCommandsHistory, item)
+			if fullIndex == -1 {
+				// Should never happen, but better be safe
+				return nil
+			}
+
+			self.c.GetAppState().CustomCommandsHistory = slices.Delete(
+				self.c.GetAppState().CustomCommandsHistory, fullIndex, fullIndex+1)
+			self.c.SaveAppStateAndLogError()
+			self.c.Contexts().Suggestions.RefreshSuggestions()
+			return nil
+		},
 	})
 }
 
 func (self *CustomCommandAction) GetCustomCommandsHistorySuggestionsFunc() func(string) []*types.Suggestion {
-	history := self.c.GetAppState().CustomCommandsHistory
+	return func(input string) []*types.Suggestion {
+		history := self.c.GetAppState().CustomCommandsHistory
 
-	return helpers.FilterFunc(history, self.c.UserConfig.Gui.UseFuzzySearch())
+		return helpers.FilterFunc(history, self.c.UserConfig.Gui.UseFuzzySearch())(input)
+	}
 }
 
 // this mimics the shell functionality `ignorespace`
diff --git a/pkg/gui/controllers/helpers/confirmation_helper.go b/pkg/gui/controllers/helpers/confirmation_helper.go
index 8c1265c15cf..b5a337fbb05 100644
--- a/pkg/gui/controllers/helpers/confirmation_helper.go
+++ b/pkg/gui/controllers/helpers/confirmation_helper.go
@@ -159,6 +159,7 @@ func (self *ConfirmationHelper) prepareConfirmationPanel(
 		suggestionsContext.SetSuggestions(opts.FindSuggestionsFunc(""))
 		suggestionsView.Visible = true
 		suggestionsView.Title = fmt.Sprintf(self.c.Tr.SuggestionsTitle, self.c.UserConfig.Keybinding.Universal.TogglePanel)
+		suggestionsView.Subtitle = ""
 	}
 
 	self.ResizeConfirmationPanel()
@@ -223,6 +224,8 @@ func (self *ConfirmationHelper) CreatePopupPanel(ctx goContext.Context, opts typ
 		return err
 	}
 
+	self.c.Contexts().Suggestions.State.AllowEditSuggestion = opts.AllowEditSuggestion
+
 	self.c.State().GetRepoState().SetCurrentPopupOpts(&opts)
 
 	return self.c.PushContext(self.c.Contexts().Confirmation)
@@ -270,10 +273,20 @@ func (self *ConfirmationHelper) setKeyBindings(cancel goContext.CancelFunc, opts
 
 	onClose := self.wrappedConfirmationFunction(cancel, opts.HandleClose)
 
+	onDeleteSuggestion := func() error {
+		if opts.HandleDeleteSuggestion == nil {
+			return nil
+		}
+
+		idx := self.c.Contexts().Suggestions.GetSelectedLineIdx()
+		return opts.HandleDeleteSuggestion(idx)
+	}
+
 	self.c.Contexts().Confirmation.State.OnConfirm = onConfirm
 	self.c.Contexts().Confirmation.State.OnClose = onClose
 	self.c.Contexts().Suggestions.State.OnConfirm = onSuggestionConfirm
 	self.c.Contexts().Suggestions.State.OnClose = onClose
+	self.c.Contexts().Suggestions.State.OnDeleteSuggestion = onDeleteSuggestion
 
 	return nil
 }
@@ -284,6 +297,7 @@ func (self *ConfirmationHelper) clearConfirmationViewKeyBindings() {
 	self.c.Contexts().Confirmation.State.OnClose = noop
 	self.c.Contexts().Suggestions.State.OnConfirm = noop
 	self.c.Contexts().Suggestions.State.OnClose = noop
+	self.c.Contexts().Suggestions.State.OnDeleteSuggestion = noop
 }
 
 func (self *ConfirmationHelper) getSelectedSuggestionValue() string {
diff --git a/pkg/gui/controllers/suggestions_controller.go b/pkg/gui/controllers/suggestions_controller.go
index d6e6151ff4e..857952d9bf5 100644
--- a/pkg/gui/controllers/suggestions_controller.go
+++ b/pkg/gui/controllers/suggestions_controller.go
@@ -40,8 +40,32 @@ func (self *SuggestionsController) GetKeybindings(opts types.KeybindingsOpts) []
 			Handler: func() error { return self.context().State.OnClose() },
 		},
 		{
-			Key:     opts.GetKey(opts.Config.Universal.TogglePanel),
-			Handler: func() error { return self.c.ReplaceContext(self.c.Contexts().Confirmation) },
+			Key: opts.GetKey(opts.Config.Universal.TogglePanel),
+			Handler: func() error {
+				self.c.Views().Suggestions.Subtitle = ""
+				return self.c.ReplaceContext(self.c.Contexts().Confirmation)
+			},
+		},
+		{
+			Key: opts.GetKey(opts.Config.Universal.Remove),
+			Handler: func() error {
+				return self.context().State.OnDeleteSuggestion()
+			},
+		},
+		{
+			Key: opts.GetKey(opts.Config.Universal.Edit),
+			Handler: func() error {
+				if self.context().State.AllowEditSuggestion {
+					if selectedItem := self.c.Contexts().Suggestions.GetSelected(); selectedItem != nil {
+						self.c.Contexts().Confirmation.GetView().TextArea.Clear()
+						self.c.Contexts().Confirmation.GetView().TextArea.TypeString(selectedItem.Value)
+						self.c.Contexts().Confirmation.GetView().RenderTextArea()
+						self.c.Contexts().Suggestions.RefreshSuggestions()
+						return self.c.ReplaceContext(self.c.Contexts().Confirmation)
+					}
+				}
+				return nil
+			},
 		},
 	}
 
diff --git a/pkg/gui/popup/popup_handler.go b/pkg/gui/popup/popup_handler.go
index 1ca9cc3eefb..3f71644d301 100644
--- a/pkg/gui/popup/popup_handler.go
+++ b/pkg/gui/popup/popup_handler.go
@@ -104,13 +104,15 @@ func (self *PopupHandler) Confirm(opts types.ConfirmOpts) error {
 
 func (self *PopupHandler) Prompt(opts types.PromptOpts) error {
 	return self.createPopupPanelFn(context.Background(), types.CreatePopupPanelOpts{
-		Title:               opts.Title,
-		Prompt:              opts.InitialContent,
-		Editable:            true,
-		HandleConfirmPrompt: opts.HandleConfirm,
-		HandleClose:         opts.HandleClose,
-		FindSuggestionsFunc: opts.FindSuggestionsFunc,
-		Mask:                opts.Mask,
+		Title:                  opts.Title,
+		Prompt:                 opts.InitialContent,
+		Editable:               true,
+		HandleConfirmPrompt:    opts.HandleConfirm,
+		HandleClose:            opts.HandleClose,
+		HandleDeleteSuggestion: opts.HandleDeleteSuggestion,
+		FindSuggestionsFunc:    opts.FindSuggestionsFunc,
+		AllowEditSuggestion:    opts.AllowEditSuggestion,
+		Mask:                   opts.Mask,
 	})
 }
 
diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go
index 44f07c1e305..d5017307820 100644
--- a/pkg/gui/types/common.go
+++ b/pkg/gui/types/common.go
@@ -165,16 +165,18 @@ type CreateMenuOptions struct {
 }
 
 type CreatePopupPanelOpts struct {
-	HasLoader           bool
-	Editable            bool
-	Title               string
-	Prompt              string
-	HandleConfirm       func() error
-	HandleConfirmPrompt func(string) error
-	HandleClose         func() error
+	HasLoader              bool
+	Editable               bool
+	Title                  string
+	Prompt                 string
+	HandleConfirm          func() error
+	HandleConfirmPrompt    func(string) error
+	HandleClose            func() error
+	HandleDeleteSuggestion func(int) error
 
 	FindSuggestionsFunc func(string) []*Suggestion
 	Mask                bool
+	AllowEditSuggestion bool
 }
 
 type ConfirmOpts struct {
@@ -192,9 +194,11 @@ type PromptOpts struct {
 	InitialContent      string
 	FindSuggestionsFunc func(string) []*Suggestion
 	HandleConfirm       func(string) error
+	AllowEditSuggestion bool
 	// CAPTURE THIS
-	HandleClose func() error
-	Mask        bool
+	HandleClose            func() error
+	HandleDeleteSuggestion func(int) error
+	Mask                   bool
 }
 
 type MenuSection struct {
diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go
index 27905cf9bb1..caa920de1ff 100644
--- a/pkg/i18n/english.go
+++ b/pkg/i18n/english.go
@@ -631,6 +631,7 @@ type TranslationSet struct {
 	SuggestionsCheatsheetTitle            string
 	// Unlike the cheatsheet title above, the real suggestions title has a little message saying press tab to focus
 	SuggestionsTitle                     string
+	SuggestionsSubtitle                  string
 	ExtrasTitle                          string
 	PushingTagStatus                     string
 	PullRequestURLCopiedToClipboard      string
@@ -1593,6 +1594,7 @@ func EnglishTranslationSet() TranslationSet {
 		NavigationTitle:                       "List panel navigation",
 		SuggestionsCheatsheetTitle:            "Suggestions",
 		SuggestionsTitle:                      "Suggestions (press %s to focus)",
+		SuggestionsSubtitle:                   "(press %s to delete, %s to edit)",
 		ExtrasTitle:                           "Command log",
 		PushingTagStatus:                      "Pushing tag",
 		PullRequestURLCopiedToClipboard:       "Pull request URL copied to clipboard",
diff --git a/pkg/integration/components/prompt_driver.go b/pkg/integration/components/prompt_driver.go
index 023c2f4389c..a19c29aa4c8 100644
--- a/pkg/integration/components/prompt_driver.go
+++ b/pkg/integration/components/prompt_driver.go
@@ -82,3 +82,21 @@ func (self *PromptDriver) ConfirmSuggestion(matcher *TextMatcher) {
 		NavigateToLine(matcher).
 		PressEnter()
 }
+
+func (self *PromptDriver) DeleteSuggestion(matcher *TextMatcher) *PromptDriver {
+	self.t.press(self.t.keys.Universal.TogglePanel)
+	self.t.Views().Suggestions().
+		IsFocused().
+		NavigateToLine(matcher)
+	self.t.press(self.t.keys.Universal.Remove)
+	return self
+}
+
+func (self *PromptDriver) EditSuggestion(matcher *TextMatcher) *PromptDriver {
+	self.t.press(self.t.keys.Universal.TogglePanel)
+	self.t.Views().Suggestions().
+		IsFocused().
+		NavigateToLine(matcher)
+	self.t.press(self.t.keys.Universal.Edit)
+	return self
+}


Test patch:
diff --git a/pkg/integration/tests/custom_commands/delete_from_history.go b/pkg/integration/tests/custom_commands/delete_from_history.go
new file mode 100644
index 00000000000..e90e6c3c6ee
--- /dev/null
+++ b/pkg/integration/tests/custom_commands/delete_from_history.go
@@ -0,0 +1,41 @@
+package custom_commands
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var DeleteFromHistory = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Delete an entry from the custom commands history",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupRepo:    func(shell *Shell) {},
+	SetupConfig:  func(cfg *config.AppConfig) {},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		createCustomCommand := func(command string) {
+			t.GlobalPress(keys.Universal.ExecuteCustomCommand)
+			t.ExpectPopup().Prompt().
+				Title(Equals("Custom command:")).
+				Type(command).
+				Confirm()
+		}
+
+		createCustomCommand("echo 1")
+		createCustomCommand("echo 2")
+		createCustomCommand("echo 3")
+
+		t.GlobalPress(keys.Universal.ExecuteCustomCommand)
+		t.ExpectPopup().Prompt().
+			Title(Equals("Custom command:")).
+			SuggestionLines(
+				Contains("3"),
+				Contains("2"),
+				Contains("1"),
+			).
+			DeleteSuggestion(Contains("2")).
+			SuggestionLines(
+				Contains("3"),
+				Contains("1"),
+			)
+	},
+})
diff --git a/pkg/integration/tests/custom_commands/edit_history.go b/pkg/integration/tests/custom_commands/edit_history.go
new file mode 100644
index 00000000000..c6999b1f2bb
--- /dev/null
+++ b/pkg/integration/tests/custom_commands/edit_history.go
@@ -0,0 +1,31 @@
+package custom_commands
+
+import (
+	"github.com/jesseduffield/lazygit/pkg/config"
+	. "github.com/jesseduffield/lazygit/pkg/integration/components"
+)
+
+var EditHistory = NewIntegrationTest(NewIntegrationTestArgs{
+	Description:  "Edit an entry from the custom commands history",
+	ExtraCmdArgs: []string{},
+	Skip:         false,
+	SetupRepo:    func(shell *Shell) {},
+	SetupConfig:  func(cfg *config.AppConfig) {},
+	Run: func(t *TestDriver, keys config.KeybindingConfig) {
+		t.GlobalPress(keys.Universal.ExecuteCustomCommand)
+		t.ExpectPopup().Prompt().
+			Title(Equals("Custom command:")).
+			Type("echo x").
+			Confirm()
+
+		t.GlobalPress(keys.Universal.ExecuteCustomCommand)
+		t.ExpectPopup().Prompt().
+			Title(Equals("Custom command:")).
+			Type("ec").
+			SuggestionLines(
+				Equals("echo x"),
+			).
+			EditSuggestion(Equals("echo x")).
+			InitialText(Equals("echo x"))
+	},
+})
diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go
index 2548702e9a0..7084fd99b3c 100644
--- a/pkg/integration/tests/test_list.go
+++ b/pkg/integration/tests/test_list.go
@@ -105,6 +105,8 @@ var tests = []*components.IntegrationTest{
 	custom_commands.BasicCmdFromConfig,
 	custom_commands.CheckForConflicts,
 	custom_commands.ComplexCmdAtRuntime,
+	custom_commands.DeleteFromHistory,
+	custom_commands.EditHistory,
 	custom_commands.FormPrompts,
 	custom_commands.History,
 	custom_commands.MenuFromCommand,