diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index d41a9c202..74f675576 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -311,6 +311,9 @@ type AppState struct { LastUpdateCheck int64 RecentRepos []string StartupPopupVersion int + + // these are for custom commands typed in directly, not for custom commands in the lazygit config + CustomCommandsHistory []string } func getDefaultAppState() *AppState { diff --git a/pkg/gui/branches_panel.go b/pkg/gui/branches_panel.go index e85e9fd05..e913f6f8d 100644 --- a/pkg/gui/branches_panel.go +++ b/pkg/gui/branches_panel.go @@ -218,7 +218,7 @@ func (gui *Gui) handleCheckoutRef(ref string, options handleCheckoutRefOptions) func (gui *Gui) handleCheckoutByName() error { return gui.prompt(promptOpts{ title: gui.Tr.BranchName + ":", - findSuggestionsFunc: gui.getBranchNameSuggestionsFunc(), + findSuggestionsFunc: gui.getRefsSuggestionsFunc(), handleConfirm: func(response string) error { return gui.handleCheckoutRef(response, handleCheckoutRefOptions{ span: "Checkout branch", diff --git a/pkg/gui/confirmation_panel.go b/pkg/gui/confirmation_panel.go index 6664f765d..e1b78bc3a 100644 --- a/pkg/gui/confirmation_panel.go +++ b/pkg/gui/confirmation_panel.go @@ -3,6 +3,7 @@ package gui import ( + "fmt" "strings" "github.com/jesseduffield/gocui" @@ -33,7 +34,6 @@ type askOpts struct { handleConfirm func() error handleClose func() error handlersManageFocus bool - findSuggestionsFunc func(string) []*types.Suggestion } type promptOpts struct { @@ -50,7 +50,6 @@ func (gui *Gui) ask(opts askOpts) error { handleConfirm: opts.handleConfirm, handleClose: opts.handleClose, handlersManageFocus: opts.handlersManageFocus, - findSuggestionsFunc: opts.findSuggestionsFunc, }) } @@ -103,13 +102,6 @@ func (gui *Gui) wrappedPromptConfirmationFunction(handlersManageFocus bool, func } } -func (gui *Gui) clearConfirmationViewKeyBindings() { - keybindingConfig := gui.Config.GetUserConfig().Keybinding - _ = gui.g.DeleteKeybinding("confirmation", gui.getKey(keybindingConfig.Universal.Confirm), gocui.ModNone) - _ = gui.g.DeleteKeybinding("confirmation", gui.getKey(keybindingConfig.Universal.ConfirmAlt1), gocui.ModNone) - _ = gui.g.DeleteKeybinding("confirmation", gui.getKey(keybindingConfig.Universal.Return), gocui.ModNone) -} - func (gui *Gui) closeConfirmationPrompt(handlersManageFocus bool) error { // we've already closed it so we can just return if !gui.Views.Confirmation.Visible { @@ -165,7 +157,13 @@ func (gui *Gui) getConfirmationPanelDimensions(wrap bool, prompt string) (int, i height/2 + panelHeight/2 } -func (gui *Gui) prepareConfirmationPanel(title, prompt string, hasLoader bool, findSuggestionsFunc func(string) []*types.Suggestion, editable bool) error { +func (gui *Gui) prepareConfirmationPanel( + title, + prompt string, + hasLoader bool, + findSuggestionsFunc func(string) []*types.Suggestion, + editable bool, +) error { x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(true, prompt) // calling SetView on an existing view returns the same view, so I'm not bothering // to reassign to gui.Views.Confirmation @@ -185,14 +183,15 @@ func (gui *Gui) prepareConfirmationPanel(title, prompt string, hasLoader bool, f gui.findSuggestions = findSuggestionsFunc if findSuggestionsFunc != nil { suggestionsViewHeight := 11 - suggestionsView, err := gui.g.SetView("suggestions", x0, y1, x1, y1+suggestionsViewHeight, 0) + suggestionsView, err := gui.g.SetView("suggestions", x0, y1+1, x1, y1+suggestionsViewHeight, 0) if err != nil { return err } suggestionsView.Wrap = false suggestionsView.FgColor = theme.GocuiDefaultTextColor - gui.setSuggestions([]*types.Suggestion{}) + gui.setSuggestions(findSuggestionsFunc("")) suggestionsView.Visible = true + suggestionsView.Title = fmt.Sprintf(gui.Tr.SuggestionsTitle, gui.Config.GetUserConfig().Keybinding.Universal.TogglePanel) } gui.g.Update(func(g *gocui.Gui) error { @@ -248,7 +247,7 @@ func (gui *Gui) setKeyBindings(opts createPopupPanelOpts) error { gui.renderString(gui.Views.Options, actions) var onConfirm func() error if opts.handleConfirmPrompt != nil { - onConfirm = gui.wrappedPromptConfirmationFunction(opts.handlersManageFocus, opts.handleConfirmPrompt, func() string { return gui.Views.Confirmation.Buffer() }) + onConfirm = gui.wrappedPromptConfirmationFunction(opts.handlersManageFocus, opts.handleConfirmPrompt, func() string { return gui.Views.Confirmation.TextArea.GetContent() }) } else { onConfirm = gui.wrappedConfirmationFunction(opts.handlersManageFocus, opts.handleConfirm) } @@ -260,7 +259,11 @@ func (gui *Gui) setKeyBindings(opts createPopupPanelOpts) error { } keybindingConfig := gui.Config.GetUserConfig().Keybinding - onSuggestionConfirm := gui.wrappedPromptConfirmationFunction(opts.handlersManageFocus, opts.handleConfirmPrompt, func() string { return gui.getSelectedSuggestionValue() }) + onSuggestionConfirm := gui.wrappedPromptConfirmationFunction( + opts.handlersManageFocus, + opts.handleConfirmPrompt, + gui.getSelectedSuggestionValue, + ) confirmationKeybindings := []confirmationKeybinding{ { @@ -319,6 +322,16 @@ func (gui *Gui) setKeyBindings(opts createPopupPanelOpts) error { return nil } +func (gui *Gui) clearConfirmationViewKeyBindings() { + keybindingConfig := gui.Config.GetUserConfig().Keybinding + _ = gui.g.DeleteKeybinding("confirmation", gui.getKey(keybindingConfig.Universal.Confirm), gocui.ModNone) + _ = gui.g.DeleteKeybinding("confirmation", gui.getKey(keybindingConfig.Universal.ConfirmAlt1), gocui.ModNone) + _ = gui.g.DeleteKeybinding("confirmation", gui.getKey(keybindingConfig.Universal.Return), gocui.ModNone) + _ = gui.g.DeleteKeybinding("suggestions", gui.getKey(keybindingConfig.Universal.Confirm), gocui.ModNone) + _ = gui.g.DeleteKeybinding("suggestions", gui.getKey(keybindingConfig.Universal.ConfirmAlt1), gocui.ModNone) + _ = gui.g.DeleteKeybinding("suggestions", gui.getKey(keybindingConfig.Universal.Return), gocui.ModNone) +} + func (gui *Gui) wrappedHandler(f func() error) func(g *gocui.Gui, v *gocui.View) error { return func(g *gocui.Gui, v *gocui.View) error { return f() diff --git a/pkg/gui/diffing.go b/pkg/gui/diffing.go index ef87b40da..e1f3afa88 100644 --- a/pkg/gui/diffing.go +++ b/pkg/gui/diffing.go @@ -125,7 +125,8 @@ func (gui *Gui) handleCreateDiffingMenuPanel() error { displayString: gui.Tr.LcEnterRefToDiff, onPress: func() error { return gui.prompt(promptOpts{ - title: gui.Tr.LcEnteRefName, + title: gui.Tr.LcEnteRefName, + findSuggestionsFunc: gui.getRefsSuggestionsFunc(), handleConfirm: func(response string) error { gui.State.Modes.Diffing.Ref = strings.TrimSpace(response) return gui.refreshSidePanels(refreshOptions{mode: ASYNC}) diff --git a/pkg/gui/files_panel.go b/pkg/gui/files_panel.go index 41ac25fb8..0f9bbd65d 100644 --- a/pkg/gui/files_panel.go +++ b/pkg/gui/files_panel.go @@ -659,8 +659,9 @@ func (gui *Gui) handlePullFiles() error { } return gui.prompt(promptOpts{ - title: gui.Tr.EnterUpstream, - initialContent: "origin/" + currentBranch.Name, + title: gui.Tr.EnterUpstream, + initialContent: "origin/" + currentBranch.Name, + findSuggestionsFunc: gui.getRemoteBranchesSuggestionsFunc("/"), handleConfirm: func(upstream string) error { if err := gui.GitCommand.SetUpstreamBranch(upstream); err != nil { errorMessage := err.Error() @@ -798,8 +799,9 @@ func (gui *Gui) pushFiles() error { return gui.push(pushOpts{setUpstream: true}) } else { return gui.prompt(promptOpts{ - title: gui.Tr.EnterUpstream, - initialContent: "origin " + currentBranch.Name, + title: gui.Tr.EnterUpstream, + initialContent: "origin " + currentBranch.Name, + findSuggestionsFunc: gui.getRemoteBranchesSuggestionsFunc(" "), handleConfirm: func(upstream string) error { var upstreamBranch, upstreamRemote string split := strings.Split(upstream, " ") @@ -884,8 +886,21 @@ func (gui *Gui) anyFilesWithMergeConflicts() bool { func (gui *Gui) handleCustomCommand() error { return gui.prompt(promptOpts{ - title: gui.Tr.CustomCommand, + title: gui.Tr.CustomCommand, + findSuggestionsFunc: gui.getCustomCommandsHistorySuggestionsFunc(), handleConfirm: func(command string) error { + gui.Config.GetAppState().CustomCommandsHistory = utils.Limit( + utils.Uniq( + append(gui.Config.GetAppState().CustomCommandsHistory, command), + ), + 1000, + ) + + err := gui.Config.SaveAppState() + if err != nil { + gui.Log.Error(err) + } + gui.OnRunCommand(oscommands.NewCmdLogEntry(command, gui.Tr.Spans.CustomCommand, true)) return gui.runSubprocessWithSuspenseAndRefresh( gui.OSCommand.PrepareShellSubProcess(command), diff --git a/pkg/gui/filtering_menu_panel.go b/pkg/gui/filtering_menu_panel.go index c354f0996..2955f6e8d 100644 --- a/pkg/gui/filtering_menu_panel.go +++ b/pkg/gui/filtering_menu_panel.go @@ -36,7 +36,7 @@ func (gui *Gui) handleCreateFilteringMenuPanel() error { onPress: func() error { return gui.prompt(promptOpts{ findSuggestionsFunc: gui.getFilePathSuggestionsFunc(), - title: gui.Tr.LcEnterFileName, + title: gui.Tr.EnterFileName, handleConfirm: func(response string) error { return gui.setFiltering(strings.TrimSpace(response)) }, diff --git a/pkg/gui/find_suggestions.go b/pkg/gui/find_suggestions.go index b5ee0ecf2..343e087e8 100644 --- a/pkg/gui/find_suggestions.go +++ b/pkg/gui/find_suggestions.go @@ -1,6 +1,7 @@ package gui import ( + "fmt" "os" "github.com/jesseduffield/lazygit/pkg/gui/presentation" @@ -42,11 +43,7 @@ func matchesToSuggestions(matches []string) []*types.Suggestion { func (gui *Gui) getRemoteSuggestionsFunc() func(string) []*types.Suggestion { remoteNames := gui.getRemoteNames() - return func(input string) []*types.Suggestion { - return matchesToSuggestions( - utils.FuzzySearch(input, remoteNames), - ) - } + return fuzzySearchFunc(remoteNames) } func (gui *Gui) getBranchNames() []string { @@ -61,7 +58,12 @@ func (gui *Gui) getBranchNameSuggestionsFunc() func(string) []*types.Suggestion branchNames := gui.getBranchNames() return func(input string) []*types.Suggestion { - matchingBranchNames := utils.FuzzySearch(sanitizedBranchName(input), branchNames) + var matchingBranchNames []string + if input == "" { + matchingBranchNames = branchNames + } else { + matchingBranchNames = utils.FuzzySearch(input, branchNames) + } suggestions := make([]*types.Suggestion, len(matchingBranchNames)) for i, branchName := range matchingBranchNames { @@ -79,6 +81,8 @@ func (gui *Gui) getBranchNameSuggestionsFunc() func(string) []*types.Suggestion // gui.State.FilesTrie. On the main thread we'll be doing a fuzzy search via // gui.State.FilesTrie. So if we've looked for a file previously, we'll start with // the old trie and eventually it'll be swapped out for the new one. +// Notably, unlike other suggestion functions we're not showing all the options +// if nothing has been typed because there'll be too much to display efficiently func (gui *Gui) getFilePathSuggestionsFunc() func(string) []*types.Suggestion { _ = gui.WithWaitingStatus(gui.Tr.LcLoadingFileSuggestions, func() error { trie := patricia.NewTrie() @@ -131,3 +135,56 @@ func (gui *Gui) getFilePathSuggestionsFunc() func(string) []*types.Suggestion { return suggestions } } + +func (gui *Gui) getRemoteBranchNames(separator string) []string { + result := []string{} + for _, remote := range gui.State.Remotes { + for _, branch := range remote.Branches { + result = append(result, fmt.Sprintf("%s%s%s", remote.Name, separator, branch.Name)) + } + } + return result +} + +func (gui *Gui) getRemoteBranchesSuggestionsFunc(separator string) func(string) []*types.Suggestion { + return fuzzySearchFunc(gui.getRemoteBranchNames(separator)) +} + +func (gui *Gui) getTagNames() []string { + result := make([]string, len(gui.State.Tags)) + for i, tag := range gui.State.Tags { + result[i] = tag.Name + } + return result +} + +func (gui *Gui) getRefsSuggestionsFunc() func(string) []*types.Suggestion { + remoteBranchNames := gui.getRemoteBranchNames("/") + localBranchNames := gui.getBranchNames() + tagNames := gui.getTagNames() + additionalRefNames := []string{"HEAD", "FETCH_HEAD", "MERGE_HEAD", "ORIG_HEAD"} + + refNames := append(append(append(remoteBranchNames, localBranchNames...), tagNames...), additionalRefNames...) + + return fuzzySearchFunc(refNames) +} + +func (gui *Gui) getCustomCommandsHistorySuggestionsFunc() func(string) []*types.Suggestion { + // reversing so that we display the latest command first + history := utils.Reverse(gui.Config.GetAppState().CustomCommandsHistory) + + return fuzzySearchFunc(history) +} + +func fuzzySearchFunc(options []string) func(string) []*types.Suggestion { + return func(input string) []*types.Suggestion { + var matches []string + if input == "" { + matches = options + } else { + matches = utils.FuzzySearch(input, options) + } + + return matchesToSuggestions(matches) + } +} diff --git a/pkg/gui/layout.go b/pkg/gui/layout.go index c15ba340e..a7f308b7b 100644 --- a/pkg/gui/layout.go +++ b/pkg/gui/layout.go @@ -111,6 +111,7 @@ func (gui *Gui) createAllViews() error { gui.Views.Credentials.Editable = true gui.Views.Suggestions.Visible = false + gui.Views.Suggestions.ContainsList = true gui.Views.Menu.Visible = false diff --git a/pkg/i18n/chinese.go b/pkg/i18n/chinese.go index 5a5d885cd..f54f8d657 100644 --- a/pkg/i18n/chinese.go +++ b/pkg/i18n/chinese.go @@ -358,7 +358,7 @@ func chineseTranslationSet() TranslationSet { LcFilterBy: "过滤", LcExitFilterMode: "停止按路径过滤", LcFilterPathOption: "输入要过滤的路径", - LcEnterFileName: "输入路径:", + EnterFileName: "输入路径:", FilteringMenuTitle: "正在过滤", MustExitFilterModeTitle: "命令不可用", MustExitFilterModePrompt: "命令在过滤模式下不可用。退出过滤模式?", @@ -419,7 +419,7 @@ func chineseTranslationSet() TranslationSet { SubCommitsTitle: "子提交", SubmodulesTitle: "子模块", NavigationTitle: "列表面板导航", - SuggestionsTitle: "意见建议", + SuggestionsCheatsheetTitle: "意见建议", PushingTagStatus: "推送标签", PullRequestURLCopiedToClipboard: "抓取请求网址已复制到剪贴板", CommitMessageCopiedToClipboard: "提交消息复制到剪贴板", diff --git a/pkg/i18n/dutch.go b/pkg/i18n/dutch.go index f3d34d94c..fff0f7b31 100644 --- a/pkg/i18n/dutch.go +++ b/pkg/i18n/dutch.go @@ -327,7 +327,7 @@ func dutchTranslationSet() TranslationSet { LcFilterBy: "filter bij", LcExitFilterMode: "stop met filteren bij pad", LcFilterPathOption: "vulin pad om op te filteren", - LcEnterFileName: "vulin path:", + EnterFileName: "Vulin path:", FilteringMenuTitle: "Filteren", MustExitFilterModeTitle: "Command niet beschikbaar", MustExitFilterModePrompt: "Command niet beschikbaar in filter modus. Sluit filter modus?", diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 50508d63a..a0a485c60 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -342,7 +342,7 @@ type TranslationSet struct { LcFilterBy string LcExitFilterMode string LcFilterPathOption string - LcEnterFileName string + EnterFileName string FilteringMenuTitle string MustExitFilterModeTitle string MustExitFilterModePrompt string @@ -404,6 +404,8 @@ type TranslationSet struct { SubCommitsTitle string SubmodulesTitle string NavigationTitle string + SuggestionsCheatsheetTitle string + // Unlike the cheatsheet title above, the real suggestions title has a little message saying press tab to focus SuggestionsTitle string ExtrasTitle string PushingTagStatus string @@ -867,7 +869,7 @@ func englishTranslationSet() TranslationSet { LcFilterBy: "filter by", LcExitFilterMode: "stop filtering by path", LcFilterPathOption: "enter path to filter by", - LcEnterFileName: "enter path:", + EnterFileName: "Enter path:", FilteringMenuTitle: "Filtering", MustExitFilterModeTitle: "Command not available", MustExitFilterModePrompt: "Command not available in filtered mode. Exit filtered mode?", @@ -930,7 +932,8 @@ func englishTranslationSet() TranslationSet { SubCommitsTitle: "Sub-commits", SubmodulesTitle: "Submodules", NavigationTitle: "List Panel Navigation", - SuggestionsTitle: "Suggestions", + SuggestionsCheatsheetTitle: "Suggestions", + SuggestionsTitle: "Suggestions (press %s to focus)", ExtrasTitle: "Extras", PushingTagStatus: "pushing tag", PullRequestURLCopiedToClipboard: "Pull request URL copied to clipboard", diff --git a/pkg/utils/slice.go b/pkg/utils/slice.go index f536ea056..48acdbd2d 100644 --- a/pkg/utils/slice.go +++ b/pkg/utils/slice.go @@ -116,3 +116,31 @@ func StringArraysOverlap(strArrA []string, strArrB []string) bool { return false } + +func Uniq(values []string) []string { + added := make(map[string]bool) + result := make([]string, 0, len(values)) + for _, value := range values { + if added[value] { + continue + } + added[value] = true + result = append(result, value) + } + return result +} + +func Limit(values []string, limit int) []string { + if len(values) > limit { + return values[:limit] + } + return values +} + +func Reverse(values []string) []string { + result := make([]string, len(values)) + for i, val := range values { + result[len(values)-i-1] = val + } + return result +} diff --git a/pkg/utils/slice_test.go b/pkg/utils/slice_test.go index 491968cb4..3636f44cb 100644 --- a/pkg/utils/slice_test.go +++ b/pkg/utils/slice_test.go @@ -165,3 +165,86 @@ func TestEscapeSpecialChars(t *testing.T) { }) } } + +func TestUniq(t *testing.T) { + for _, test := range []struct { + values []string + want []string + }{ + { + values: []string{"a", "b", "c"}, + want: []string{"a", "b", "c"}, + }, + { + values: []string{"a", "b", "a", "b", "c"}, + want: []string{"a", "b", "c"}, + }, + } { + if got := Uniq(test.values); !assert.EqualValues(t, got, test.want) { + t.Errorf("Uniq(%v) = %v; want %v", test.values, got, test.want) + } + } +} + +func TestLimit(t *testing.T) { + for _, test := range []struct { + values []string + limit int + want []string + }{ + { + values: []string{"a", "b", "c"}, + limit: 3, + want: []string{"a", "b", "c"}, + }, + { + values: []string{"a", "b", "c"}, + limit: 4, + want: []string{"a", "b", "c"}, + }, + { + values: []string{"a", "b", "c"}, + limit: 2, + want: []string{"a", "b"}, + }, + { + values: []string{"a", "b", "c"}, + limit: 1, + want: []string{"a"}, + }, + { + values: []string{"a", "b", "c"}, + limit: 0, + want: []string{}, + }, + { + values: []string{}, + limit: 0, + want: []string{}, + }, + } { + if got := Limit(test.values, test.limit); !assert.EqualValues(t, got, test.want) { + t.Errorf("Limit(%v, %d) = %v; want %v", test.values, test.limit, got, test.want) + } + } +} + +func TestReverse(t *testing.T) { + for _, test := range []struct { + values []string + want []string + }{ + { + values: []string{"a", "b", "c"}, + want: []string{"c", "b", "a"}, + }, + { + values: []string{}, + want: []string{}, + }, + } { + if got := Reverse(test.values); !assert.EqualValues(t, got, test.want) { + t.Errorf("Reverse(%v) = %v; want %v", test.values, got, test.want) + } + } +} diff --git a/scripts/generate_cheatsheet.go b/scripts/generate_cheatsheet.go index e49156377..e2200b694 100644 --- a/scripts/generate_cheatsheet.go +++ b/scripts/generate_cheatsheet.go @@ -81,7 +81,7 @@ func localisedTitle(mApp *app.App, str string) string { "search": tr.SearchTitle, "secondary": tr.SecondaryTitle, "stash": tr.StashTitle, - "suggestions": tr.SuggestionsTitle, + "suggestions": tr.SuggestionsCheatsheetTitle, "extras": tr.ExtrasTitle, }