diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 845b8ab35..8956e8abc 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -30,3 +30,5 @@ If applicable, add screenshots to help explain your problem. **Additional context** Add any other context about the problem here. + +**Note:** please try updating to the latest version or [manually building](https://github.com/jesseduffield/lazygit/#manual) the latest `master` to see if the issue still occurs. diff --git a/README.md b/README.md index bde1cec41..a7c3ec9e3 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ A simple terminal UI for git commands, written in Go with the [gocui](https://gi

- +

## Elevator Pitch diff --git a/docs/Custom_Command_Keybindings.md b/docs/Custom_Command_Keybindings.md index 47b9d8f97..e3c310023 100644 --- a/docs/Custom_Command_Keybindings.md +++ b/docs/Custom_Command_Keybindings.md @@ -77,6 +77,7 @@ For a given custom command, here are the allowed fields: | loadingText | text to display while waiting for command to finish | no | | description | text to display in the keybindings menu that appears when you press 'x' | no | | stream | whether you want to stream the command's output to the Command Log panel | no | +| showOutput | whether you want to show the command's output in a gui prompt | no | ### Contexts @@ -142,6 +143,7 @@ SelectedLocalCommit SelectedReflogCommit SelectedSubCommit SelectedFile +SelectedPath SelectedLocalBranch SelectedRemoteBranch SelectedRemote @@ -159,7 +161,7 @@ If your custom keybinding collides with an inbuilt keybinding that is defined fo ### Debugging -If you want to verify that your command actually does what you expect, you can wrap it in an 'echo' call and set `subprocess: true` so that it doesn't actually execute the command but you can see how the placeholders were resolved. Alternatively you can run lazygit in debug mode with `lazygit --debug` and in another terminal window run `lazygit --logs` to see which commands are actually run +If you want to verify that your command actually does what you expect, you can wrap it in an 'echo' call and set `showOutput: true` so that it doesn't actually execute the command but you can see how the placeholders were resolved. Alternatively you can run lazygit in debug mode with `lazygit --debug` and in another terminal window run `lazygit --logs` to see which commands are actually run ### More Examples diff --git a/go.mod b/go.mod index 10de81c9d..04613ee04 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/integrii/flaggy v1.4.0 github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68 github.com/jesseduffield/go-git/v5 v5.1.2-0.20201006095850-341962be15a4 - github.com/jesseduffield/gocui v0.3.1-0.20220417002912-bce22fd599f6 + github.com/jesseduffield/gocui v0.3.1-0.20220723050330-1f853fadb335 github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10 github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e github.com/jesseduffield/yaml v2.1.0+incompatible diff --git a/go.sum b/go.sum index 99cb31fd9..a034850f2 100644 --- a/go.sum +++ b/go.sum @@ -72,8 +72,8 @@ github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68 h1:EQP2Tv8T github.com/jesseduffield/generics v0.0.0-20220320043834-727e535cbe68/go.mod h1:+LLj9/WUPAP8LqCchs7P+7X0R98HiFujVFANdNaxhGk= github.com/jesseduffield/go-git/v5 v5.1.2-0.20201006095850-341962be15a4 h1:GOQrmaE8i+KEdB8NzAegKYd4tPn/inM0I1uo0NXFerg= github.com/jesseduffield/go-git/v5 v5.1.2-0.20201006095850-341962be15a4/go.mod h1:nGNEErzf+NRznT+N2SWqmHnDnF9aLgANB1CUNEan09o= -github.com/jesseduffield/gocui v0.3.1-0.20220417002912-bce22fd599f6 h1:Fmay0Lz21taUpXiIbFkjjIIcn0E5GKwp5UFRuXaOiGQ= -github.com/jesseduffield/gocui v0.3.1-0.20220417002912-bce22fd599f6/go.mod h1:znJuCDnF2Ph40YZSlBwdX/4GEofnIoWLGdT4mK5zRAU= +github.com/jesseduffield/gocui v0.3.1-0.20220723050330-1f853fadb335 h1:36XGBaxzg5umrZO99Ir7Y7zpJPlOzOq8rDZUuTUrCvI= +github.com/jesseduffield/gocui v0.3.1-0.20220723050330-1f853fadb335/go.mod h1:znJuCDnF2Ph40YZSlBwdX/4GEofnIoWLGdT4mK5zRAU= github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10 h1:jmpr7KpX2+2GRiE91zTgfq49QvgiqB0nbmlwZ8UnOx0= github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10/go.mod h1:aA97kHeNA+sj2Hbki0pvLslmE4CbDyhBeSSTUUnOuVo= github.com/jesseduffield/minimal/gitignore v0.3.3-0.20211018110810-9cde264e6b1e h1:uw/oo+kg7t/oeMs6sqlAwr85ND/9cpO3up3VxphxY0U= diff --git a/pkg/app/app.go b/pkg/app/app.go index 265b5caaf..8f2ac1ea0 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -148,7 +148,7 @@ func isGitVersionValid(versionStr string) bool { func isDirectoryAGitRepository(dir string) (bool, error) { info, err := os.Stat(filepath.Join(dir, ".git")) - return info != nil && info.IsDir(), err + return info != nil, err } func isBareRepo(osCommand *oscommands.OSCommand) (bool, error) { diff --git a/pkg/commands/loaders/files.go b/pkg/commands/loaders/files.go index 825cad595..db37da935 100644 --- a/pkg/commands/loaders/files.go +++ b/pkg/commands/loaders/files.go @@ -7,7 +7,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/common" - "github.com/samber/lo" ) type FileLoaderConfig interface { @@ -54,28 +53,15 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File self.Log.Warningf("warning when calling git status: %s", status.StatusString) continue } - change := status.Change - stagedChange := change[0:1] - unstagedChange := change[1:2] - untracked := lo.Contains([]string{"??", "A ", "AM"}, change) - hasNoStagedChanges := lo.Contains([]string{" ", "U", "?"}, stagedChange) - hasInlineMergeConflicts := lo.Contains([]string{"UU", "AA"}, change) - hasMergeConflicts := hasInlineMergeConflicts || lo.Contains([]string{"DD", "AU", "UA", "UD", "DU"}, change) file := &models.File{ - Name: status.Name, - PreviousName: status.PreviousName, - DisplayString: status.StatusString, - HasStagedChanges: !hasNoStagedChanges, - HasUnstagedChanges: unstagedChange != " ", - Tracked: !untracked, - Deleted: unstagedChange == "D" || stagedChange == "D", - Added: unstagedChange == "A" || untracked, - HasMergeConflicts: hasMergeConflicts, - HasInlineMergeConflicts: hasInlineMergeConflicts, - Type: self.getFileType(status.Name), - ShortStatus: change, + Name: status.Name, + PreviousName: status.PreviousName, + DisplayString: status.StatusString, + Type: self.getFileType(status.Name), } + + models.SetStatusFields(file, status.Change) files = append(files, file) } diff --git a/pkg/commands/models/file.go b/pkg/commands/models/file.go index bfb40ee53..4589f91fa 100644 --- a/pkg/commands/models/file.go +++ b/pkg/commands/models/file.go @@ -2,6 +2,7 @@ package models import ( "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" ) // File : A file from git status @@ -90,3 +91,48 @@ func (f *File) GetPath() string { func (f *File) GetPreviousPath() string { return f.PreviousName } + +type StatusFields struct { + HasStagedChanges bool + HasUnstagedChanges bool + Tracked bool + Deleted bool + Added bool + HasMergeConflicts bool + HasInlineMergeConflicts bool + ShortStatus string +} + +func SetStatusFields(file *File, shortStatus string) { + derived := deriveStatusFields(shortStatus) + + file.HasStagedChanges = derived.HasStagedChanges + file.HasUnstagedChanges = derived.HasUnstagedChanges + file.Tracked = derived.Tracked + file.Deleted = derived.Deleted + file.Added = derived.Added + file.HasMergeConflicts = derived.HasMergeConflicts + file.HasInlineMergeConflicts = derived.HasInlineMergeConflicts + file.ShortStatus = derived.ShortStatus +} + +// shortStatus is something like '??' or 'A ' +func deriveStatusFields(shortStatus string) StatusFields { + stagedChange := shortStatus[0:1] + unstagedChange := shortStatus[1:2] + tracked := !lo.Contains([]string{"??", "A ", "AM"}, shortStatus) + hasStagedChanges := !lo.Contains([]string{" ", "U", "?"}, stagedChange) + hasInlineMergeConflicts := lo.Contains([]string{"UU", "AA"}, shortStatus) + hasMergeConflicts := hasInlineMergeConflicts || lo.Contains([]string{"DD", "AU", "UA", "UD", "DU"}, shortStatus) + + return StatusFields{ + HasStagedChanges: hasStagedChanges, + HasUnstagedChanges: unstagedChange != " ", + Tracked: tracked, + Deleted: unstagedChange == "D" || stagedChange == "D", + Added: unstagedChange == "A" || !tracked, + HasMergeConflicts: hasMergeConflicts, + HasInlineMergeConflicts: hasInlineMergeConflicts, + ShortStatus: shortStatus, + } +} diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 59bfcc042..804a5bee0 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -310,6 +310,7 @@ type CustomCommand struct { LoadingText string `yaml:"loadingText"` Description string `yaml:"description"` Stream bool `yaml:"stream"` + ShowOutput bool `yaml:"showOutput"` } type CustomCommandPrompt struct { diff --git a/pkg/gui/confirmation_panel.go b/pkg/gui/confirmation_panel.go index 5d8a13bb7..19f8745a8 100644 --- a/pkg/gui/confirmation_panel.go +++ b/pkg/gui/confirmation_panel.go @@ -123,29 +123,24 @@ func (gui *Gui) getConfirmationPanelWidth() int { } func (gui *Gui) prepareConfirmationPanel( - title, - prompt string, - hasLoader bool, - findSuggestionsFunc func(string) []*types.Suggestion, - editable bool, - mask bool, + opts types.ConfirmOpts, ) error { - gui.Views.Confirmation.HasLoader = hasLoader - if hasLoader { + gui.Views.Confirmation.HasLoader = opts.HasLoader + if opts.HasLoader { gui.g.StartTicking() } - gui.Views.Confirmation.Title = title + gui.Views.Confirmation.Title = opts.Title // for now we do not support wrapping in our editor - gui.Views.Confirmation.Wrap = !editable + gui.Views.Confirmation.Wrap = !opts.Editable gui.Views.Confirmation.FgColor = theme.GocuiDefaultTextColor - gui.Views.Confirmation.Mask = runeForMask(mask) + gui.Views.Confirmation.Mask = runeForMask(opts.Mask) - gui.findSuggestions = findSuggestionsFunc - if findSuggestionsFunc != nil { + gui.findSuggestions = opts.FindSuggestionsFunc + if opts.FindSuggestionsFunc != nil { suggestionsView := gui.Views.Suggestions suggestionsView.Wrap = false suggestionsView.FgColor = theme.GocuiDefaultTextColor - gui.setSuggestions(findSuggestionsFunc("")) + gui.setSuggestions(opts.FindSuggestionsFunc("")) suggestionsView.Visible = true suggestionsView.Title = fmt.Sprintf(gui.c.Tr.SuggestionsTitle, gui.c.UserConfig.Keybinding.Universal.TogglePanel) } @@ -177,13 +172,14 @@ func (gui *Gui) createPopupPanel(opts types.CreatePopupPanelOpts) error { gui.clearConfirmationViewKeyBindings() err := gui.prepareConfirmationPanel( - opts.Title, - opts.Prompt, - opts.HasLoader, - opts.FindSuggestionsFunc, - opts.Editable, - opts.Mask, - ) + types.ConfirmOpts{ + Title: opts.Title, + Prompt: opts.Prompt, + HasLoader: opts.HasLoader, + FindSuggestionsFunc: opts.FindSuggestionsFunc, + Editable: opts.Editable, + Mask: opts.Mask, + }) if err != nil { return err } diff --git a/pkg/gui/context.go b/pkg/gui/context.go index b83aa71c9..d2a99d84b 100644 --- a/pkg/gui/context.go +++ b/pkg/gui/context.go @@ -359,7 +359,7 @@ func (gui *Gui) getFocusLayout() func(g *gocui.Gui) error { newView := gui.g.CurrentView() // for now we don't consider losing focus to a popup panel as actually losing focus if newView != previousView && !gui.isPopupPanel(newView.Name()) { - if err := gui.onViewFocusLost(previousView, newView); err != nil { + if err := gui.onViewFocusLost(previousView); err != nil { return err } @@ -369,7 +369,7 @@ func (gui *Gui) getFocusLayout() func(g *gocui.Gui) error { } } -func (gui *Gui) onViewFocusLost(oldView *gocui.View, newView *gocui.View) error { +func (gui *Gui) onViewFocusLost(oldView *gocui.View) error { if oldView == nil { return nil } diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index b705adcee..489f412f0 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -4,7 +4,6 @@ import ( "github.com/jesseduffield/generics/slices" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" - "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -87,7 +86,7 @@ func (self *MenuViewModel) GetDisplayStrings(_startIdx int, _length int) [][]str }) return slices.Map(self.menuItems, func(item *types.MenuItem) []string { - displayStrings := getItemDisplayStrings(item) + displayStrings := item.LabelColumns if showKeys { displayStrings = slices.Prepend(displayStrings, style.FgCyan.Sprint(keybindings.GetKeyDisplay(item.Key))) } @@ -95,18 +94,6 @@ func (self *MenuViewModel) GetDisplayStrings(_startIdx int, _length int) [][]str }) } -func getItemDisplayStrings(item *types.MenuItem) []string { - if item.LabelColumns != nil { - return item.LabelColumns - } - - styledStr := item.Label - if item.OpensMenu { - styledStr = presentation.OpensMenuStyle(styledStr) - } - return []string{styledStr} -} - func (self *MenuContext) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { basicBindings := self.ListContextTrait.GetKeybindings(opts) menuItemsWithKeys := slices.Filter(self.menuItems, func(item *types.MenuItem) bool { diff --git a/pkg/gui/controllers.go b/pkg/gui/controllers.go index 2c851739d..dcb3377cf 100644 --- a/pkg/gui/controllers.go +++ b/pkg/gui/controllers.go @@ -62,6 +62,7 @@ func (gui *Gui) resetControllers() { model, gui.State.Contexts, gui.State.Modes, + &gui.Mutexes, ) syncController := controllers.NewSyncController( diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 20e78997e..5beecba02 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -165,7 +165,7 @@ func (self *CommitFilesController) toggleForPatch(node *filetree.CommitFileNode) // if there is any file that hasn't been fully added we'll fully add everything, // otherwise we'll remove everything - adding := node.AnyFile(func(file *models.CommitFile) bool { + adding := node.SomeFile(func(file *models.CommitFile) bool { return self.git.Patch.PatchManager.GetFileStatus(file.Name, self.context().GetRef().RefName()) != patch.WHOLE }) @@ -203,8 +203,7 @@ func (self *CommitFilesController) toggleForPatch(node *filetree.CommitFileNode) } func (self *CommitFilesController) toggleAllForPatch(_ *filetree.CommitFileNode) error { - // not a fan of type assertions but this will be fixed very soon thanks to generics - root := self.context().CommitFileTreeViewModel.Tree().(*filetree.CommitFileNode) + root := self.context().CommitFileTreeViewModel.GetRoot() return self.toggleForPatch(root) } diff --git a/pkg/gui/controllers/common.go b/pkg/gui/controllers/common.go index 55ba4b176..12a3788fd 100644 --- a/pkg/gui/controllers/common.go +++ b/pkg/gui/controllers/common.go @@ -16,6 +16,7 @@ type controllerCommon struct { model *types.Model contexts *context.ContextTree modes *types.Modes + mutexes *types.Mutexes } func NewControllerCommon( @@ -26,6 +27,7 @@ func NewControllerCommon( model *types.Model, contexts *context.ContextTree, modes *types.Modes, + mutexes *types.Mutexes, ) *controllerCommon { return &controllerCommon{ c: c, @@ -35,5 +37,6 @@ func NewControllerCommon( model: model, contexts: contexts, modes: modes, + mutexes: mutexes, } } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 4aed5cf0a..5304d0d81 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -108,7 +108,7 @@ func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types }, { Key: opts.GetKey(opts.Config.Files.ToggleStagedAll), - Handler: self.stageAll, + Handler: self.toggleStagedAll, Description: self.c.Tr.LcToggleStagedAll, }, { @@ -167,8 +167,87 @@ func (self *FilesController) GetOnClick() func() error { return self.checkSelectedFileNode(self.press) } -func (self *FilesController) press(node *filetree.FileNode) error { - if node.IsLeaf() { +// if we are dealing with a status for which there is no key in this map, +// then we won't optimistically render: we'll just let `git status` tell +// us what the new status is. +// There are no doubt more entries that could be added to these two maps. +var stageStatusMap = map[string]string{ + "??": "A ", + " M": "M ", + "MM": "M ", + " D": "D ", + " A": "A ", + "AM": "A ", + "MD": "D ", +} + +var unstageStatusMap = map[string]string{ + "A ": "??", + "M ": " M", + "D ": " D", +} + +func (self *FilesController) optimisticStage(file *models.File) bool { + newShortStatus, ok := stageStatusMap[file.ShortStatus] + if !ok { + return false + } + + models.SetStatusFields(file, newShortStatus) + return true +} + +func (self *FilesController) optimisticUnstage(file *models.File) bool { + newShortStatus, ok := unstageStatusMap[file.ShortStatus] + if !ok { + return false + } + + models.SetStatusFields(file, newShortStatus) + return true +} + +// Running a git add command followed by a git status command can take some time (e.g. 200ms). +// Given how often users stage/unstage files in Lazygit, we're adding some +// optimistic rendering to make things feel faster. When we go to stage +// a file, we'll first update that file's status in-memory, then re-render +// the files panel. Then we'll immediately do a proper git status call +// so that if the optimistic rendering got something wrong, it's quickly +// corrected. +func (self *FilesController) optimisticChange(node *filetree.FileNode, optimisticChangeFn func(*models.File) bool) error { + rerender := false + err := node.ForEachFile(func(f *models.File) error { + // can't act on the file itself: we need to update the original model file + for _, modelFile := range self.model.Files { + if modelFile.Name == f.Name { + if optimisticChangeFn(modelFile) { + rerender = true + } + break + } + } + + return nil + }) + if err != nil { + return err + } + if rerender { + if err := self.c.PostRefreshUpdate(self.contexts.Files); err != nil { + return err + } + } + + return nil +} + +func (self *FilesController) pressWithLock(node *filetree.FileNode) error { + // Obtaining this lock because optimistic rendering requires us to mutate + // the files in our model. + self.mutexes.RefreshingFilesMutex.Lock() + defer self.mutexes.RefreshingFilesMutex.Unlock() + + if node.IsFile() { file := node.File if file.HasInlineMergeConflicts { @@ -177,11 +256,21 @@ func (self *FilesController) press(node *filetree.FileNode) error { if file.HasUnstagedChanges { self.c.LogAction(self.c.Tr.Actions.StageFile) + + if err := self.optimisticChange(node, self.optimisticStage); err != nil { + return err + } + if err := self.git.WorkingTree.StageFile(file.Name); err != nil { return self.c.Error(err) } } else { self.c.LogAction(self.c.Tr.Actions.UnstageFile) + + if err := self.optimisticChange(node, self.optimisticUnstage); err != nil { + return err + } + if err := self.git.WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil { return self.c.Error(err) } @@ -195,19 +284,37 @@ func (self *FilesController) press(node *filetree.FileNode) error { if node.GetHasUnstagedChanges() { self.c.LogAction(self.c.Tr.Actions.StageFile) + + if err := self.optimisticChange(node, self.optimisticStage); err != nil { + return err + } + if err := self.git.WorkingTree.StageFile(node.Path); err != nil { return self.c.Error(err) } } else { - // pretty sure it doesn't matter that we're always passing true here self.c.LogAction(self.c.Tr.Actions.UnstageFile) + + if err := self.optimisticChange(node, self.optimisticUnstage); err != nil { + return err + } + + // pretty sure it doesn't matter that we're always passing true here if err := self.git.WorkingTree.UnStageFile([]string{node.Path}, true); err != nil { return self.c.Error(err) } } } - if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}); err != nil { + return nil +} + +func (self *FilesController) press(node *filetree.FileNode) error { + if err := self.pressWithLock(node); err != nil { + return err + } + + if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}); err != nil { return err } @@ -273,33 +380,53 @@ func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { return self.c.PushContext(self.contexts.Staging, opts) } -func (self *FilesController) allFilesStaged() bool { - for _, file := range self.model.Files { - if file.HasUnstagedChanges { - return false - } - } - return true -} - -func (self *FilesController) stageAll() error { - var err error - if self.allFilesStaged() { - self.c.LogAction(self.c.Tr.Actions.UnstageAllFiles) - err = self.git.WorkingTree.UnstageAll() - } else { - self.c.LogAction(self.c.Tr.Actions.StageAllFiles) - err = self.git.WorkingTree.StageAll() - } - if err != nil { - _ = self.c.Error(err) - } - - if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}); err != nil { +func (self *FilesController) toggleStagedAll() error { + if err := self.toggleStagedAllWithLock(); err != nil { return err } - return self.contexts.Files.HandleFocus() + if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}); err != nil { + return err + } + + return self.context().HandleFocus() +} + +func (self *FilesController) toggleStagedAllWithLock() error { + self.mutexes.RefreshingFilesMutex.Lock() + defer self.mutexes.RefreshingFilesMutex.Unlock() + + root := self.context().FileTreeViewModel.GetRoot() + + // if any files within have inline merge conflicts we can't stage or unstage, + // or it'll end up with those >>>>>> lines actually staged + if root.GetHasInlineMergeConflicts() { + return self.c.ErrorMsg(self.c.Tr.ErrStageDirWithInlineMergeConflicts) + } + + if root.GetHasUnstagedChanges() { + self.c.LogAction(self.c.Tr.Actions.StageAllFiles) + + if err := self.optimisticChange(root, self.optimisticStage); err != nil { + return err + } + + if err := self.git.WorkingTree.StageAll(); err != nil { + return self.c.Error(err) + } + } else { + self.c.LogAction(self.c.Tr.Actions.UnstageAllFiles) + + if err := self.optimisticChange(root, self.optimisticUnstage); err != nil { + return err + } + + if err := self.git.WorkingTree.UnstageAll(); err != nil { + return self.c.Error(err) + } + } + + return nil } func (self *FilesController) unstageFiles(node *filetree.FileNode) error { diff --git a/pkg/gui/controllers/helpers/upstream_helper.go b/pkg/gui/controllers/helpers/upstream_helper.go index a3ece704e..a2d8e8ae2 100644 --- a/pkg/gui/controllers/helpers/upstream_helper.go +++ b/pkg/gui/controllers/helpers/upstream_helper.go @@ -49,7 +49,7 @@ func (self *UpstreamHelper) ParseUpstream(upstream string) (string, string, erro return upstreamRemote, upstreamBranch, nil } -func (self *UpstreamHelper) promptForUpstream(currentBranch *models.Branch, initialContent string, onConfirm func(string) error) error { +func (self *UpstreamHelper) promptForUpstream(initialContent string, onConfirm func(string) error) error { return self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.EnterUpstream, InitialContent: initialContent, @@ -62,11 +62,11 @@ func (self *UpstreamHelper) PromptForUpstreamWithInitialContent(currentBranch *m suggestedRemote := self.GetSuggestedRemote() initialContent := suggestedRemote + " " + currentBranch.Name - return self.promptForUpstream(currentBranch, initialContent, onConfirm) + return self.promptForUpstream(initialContent, onConfirm) } -func (self *UpstreamHelper) PromptForUpstreamWithoutInitialContent(currentBranch *models.Branch, onConfirm func(string) error) error { - return self.promptForUpstream(currentBranch, "", onConfirm) +func (self *UpstreamHelper) PromptForUpstreamWithoutInitialContent(_ *models.Branch, onConfirm func(string) error) error { + return self.promptForUpstream("", onConfirm) } func (self *UpstreamHelper) GetSuggestedRemote() string { diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 1d1420a97..6539a13af 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -227,11 +227,11 @@ func (self *LocalCommitsController) reword(commit *models.Commit) error { } func (self *LocalCommitsController) rewordEditor(commit *models.Commit) error { - applied, err := self.handleMidRebaseCommand("reword", commit) + midRebase, err := self.handleMidRebaseCommand("reword", commit) if err != nil { return err } - if applied { + if midRebase { return nil } @@ -240,6 +240,11 @@ func (self *LocalCommitsController) rewordEditor(commit *models.Commit) error { Prompt: self.c.Tr.RewordInEditorPrompt, HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.RewordCommit) + + if self.context().GetSelectedLineIdx() == 0 { + return self.c.RunSubprocessAndRefresh(self.os.Cmd.New("git commit --allow-empty --amend --only")) + } + subProcess, err := self.git.Rebase.RewordCommitInEditor( self.model.Commits, self.context().GetSelectedLineIdx(), ) diff --git a/pkg/gui/filetree/build_tree.go b/pkg/gui/filetree/build_tree.go index 36034d02d..c7c465e28 100644 --- a/pkg/gui/filetree/build_tree.go +++ b/pkg/gui/filetree/build_tree.go @@ -7,10 +7,10 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" ) -func BuildTreeFromFiles(files []*models.File) *FileNode { - root := &FileNode{} +func BuildTreeFromFiles(files []*models.File) *Node[models.File] { + root := &Node[models.File]{} - var curr *FileNode + var curr *Node[models.File] for _, file := range files { splitPath := split(file.Name) curr = root @@ -30,7 +30,7 @@ func BuildTreeFromFiles(files []*models.File) *FileNode { } } - newChild := &FileNode{ + newChild := &Node[models.File]{ Path: path, File: setFile, } @@ -46,17 +46,17 @@ func BuildTreeFromFiles(files []*models.File) *FileNode { return root } -func BuildFlatTreeFromCommitFiles(files []*models.CommitFile) *CommitFileNode { +func BuildFlatTreeFromCommitFiles(files []*models.CommitFile) *Node[models.CommitFile] { rootAux := BuildTreeFromCommitFiles(files) sortedFiles := rootAux.GetLeaves() - return &CommitFileNode{Children: sortedFiles} + return &Node[models.CommitFile]{Children: sortedFiles} } -func BuildTreeFromCommitFiles(files []*models.CommitFile) *CommitFileNode { - root := &CommitFileNode{} +func BuildTreeFromCommitFiles(files []*models.CommitFile) *Node[models.CommitFile] { + root := &Node[models.CommitFile]{} - var curr *CommitFileNode + var curr *Node[models.CommitFile] for _, file := range files { splitPath := split(file.Name) curr = root @@ -77,7 +77,7 @@ func BuildTreeFromCommitFiles(files []*models.CommitFile) *CommitFileNode { } } - newChild := &CommitFileNode{ + newChild := &Node[models.CommitFile]{ Path: path, File: setFile, } @@ -93,7 +93,7 @@ func BuildTreeFromCommitFiles(files []*models.CommitFile) *CommitFileNode { return root } -func BuildFlatTreeFromFiles(files []*models.File) *FileNode { +func BuildFlatTreeFromFiles(files []*models.File) *Node[models.File] { rootAux := BuildTreeFromFiles(files) sortedFiles := rootAux.GetLeaves() @@ -128,7 +128,7 @@ func BuildFlatTreeFromFiles(files []*models.File) *FileNode { return false }) - return &FileNode{Children: sortedFiles} + return &Node[models.File]{Children: sortedFiles} } func split(str string) []string { diff --git a/pkg/gui/filetree/build_tree_test.go b/pkg/gui/filetree/build_tree_test.go index c486ddfa5..ac36be9af 100644 --- a/pkg/gui/filetree/build_tree_test.go +++ b/pkg/gui/filetree/build_tree_test.go @@ -11,14 +11,14 @@ func TestBuildTreeFromFiles(t *testing.T) { scenarios := []struct { name string files []*models.File - expected *FileNode + expected *Node[models.File] }{ { name: "no files", files: []*models.File{}, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{}, + Children: nil, }, }, { @@ -31,12 +31,12 @@ func TestBuildTreeFromFiles(t *testing.T) { Name: "dir1/b", }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { Path: "dir1", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "dir1/a"}, Path: "dir1/a", @@ -60,12 +60,12 @@ func TestBuildTreeFromFiles(t *testing.T) { Name: "dir2/dir4/b", }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { Path: "dir1/dir3", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "dir1/dir3/a"}, Path: "dir1/dir3/a", @@ -75,7 +75,7 @@ func TestBuildTreeFromFiles(t *testing.T) { }, { Path: "dir2/dir4", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "dir2/dir4/b"}, Path: "dir2/dir4/b", @@ -96,9 +96,9 @@ func TestBuildTreeFromFiles(t *testing.T) { Name: "a", }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "a"}, Path: "a", @@ -124,11 +124,11 @@ func TestBuildTreeFromFiles(t *testing.T) { Name: "a", }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", // it is a little strange that we're not bubbling up our merge conflict // here but we are technically still in in tree mode and that's the rule - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "a"}, Path: "a", @@ -159,14 +159,14 @@ func TestBuildFlatTreeFromFiles(t *testing.T) { scenarios := []struct { name string files []*models.File - expected *FileNode + expected *Node[models.File] }{ { name: "no files", files: []*models.File{}, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{}, + Children: []*Node[models.File]{}, }, }, { @@ -179,9 +179,9 @@ func TestBuildFlatTreeFromFiles(t *testing.T) { Name: "dir1/b", }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "dir1/a"}, Path: "dir1/a", @@ -205,9 +205,9 @@ func TestBuildFlatTreeFromFiles(t *testing.T) { Name: "dir2/b", }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "dir1/a"}, Path: "dir1/a", @@ -231,9 +231,9 @@ func TestBuildFlatTreeFromFiles(t *testing.T) { Name: "a", }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "a"}, Path: "a", @@ -273,9 +273,9 @@ func TestBuildFlatTreeFromFiles(t *testing.T) { Tracked: true, }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "c1", HasMergeConflicts: true}, Path: "c1", @@ -318,14 +318,14 @@ func TestBuildTreeFromCommitFiles(t *testing.T) { scenarios := []struct { name string files []*models.CommitFile - expected *CommitFileNode + expected *Node[models.CommitFile] }{ { name: "no files", files: []*models.CommitFile{}, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{}, + Children: nil, }, }, { @@ -338,12 +338,12 @@ func TestBuildTreeFromCommitFiles(t *testing.T) { Name: "dir1/b", }, }, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { Path: "dir1", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Name: "dir1/a"}, Path: "dir1/a", @@ -367,12 +367,12 @@ func TestBuildTreeFromCommitFiles(t *testing.T) { Name: "dir2/dir4/b", }, }, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { Path: "dir1/dir3", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Name: "dir1/dir3/a"}, Path: "dir1/dir3/a", @@ -382,7 +382,7 @@ func TestBuildTreeFromCommitFiles(t *testing.T) { }, { Path: "dir2/dir4", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Name: "dir2/dir4/b"}, Path: "dir2/dir4/b", @@ -403,9 +403,9 @@ func TestBuildTreeFromCommitFiles(t *testing.T) { Name: "a", }, }, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Name: "a"}, Path: "a", @@ -432,14 +432,14 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) { scenarios := []struct { name string files []*models.CommitFile - expected *CommitFileNode + expected *Node[models.CommitFile] }{ { name: "no files", files: []*models.CommitFile{}, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{}, + Children: []*Node[models.CommitFile]{}, }, }, { @@ -452,9 +452,9 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) { Name: "dir1/b", }, }, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Name: "dir1/a"}, Path: "dir1/a", @@ -478,9 +478,9 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) { Name: "dir2/b", }, }, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Name: "dir1/a"}, Path: "dir1/a", @@ -504,9 +504,9 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) { Name: "a", }, }, - expected: &CommitFileNode{ + expected: &Node[models.CommitFile]{ Path: "", - Children: []*CommitFileNode{ + Children: []*Node[models.CommitFile]{ { File: &models.CommitFile{Name: "a"}, Path: "a", diff --git a/pkg/gui/filetree/commit_file_node.go b/pkg/gui/filetree/commit_file_node.go index ad794c0c2..067eee682 100644 --- a/pkg/gui/filetree/commit_file_node.go +++ b/pkg/gui/filetree/commit_file_node.go @@ -1,166 +1,21 @@ package filetree -import ( - "github.com/jesseduffield/generics/slices" - "github.com/jesseduffield/lazygit/pkg/commands/models" - "github.com/jesseduffield/lazygit/pkg/gui/types" -) +import "github.com/jesseduffield/lazygit/pkg/commands/models" +// CommitFileNode wraps a node and provides some commit-file-specific methods for it. type CommitFileNode struct { - Children []*CommitFileNode - File *models.CommitFile - Path string // e.g. '/path/to/mydir' - CompressionLevel int // equal to the number of forward slashes you'll see in the path when it's rendered in tree mode + *Node[models.CommitFile] } -var ( - _ INode = &CommitFileNode{} - _ types.ListItem = &CommitFileNode{} -) - -func (s *CommitFileNode) ID() string { - return s.GetPath() -} - -func (s *CommitFileNode) Description() string { - return s.GetPath() -} - -// methods satisfying INode interface - -func (s *CommitFileNode) IsNil() bool { - return s == nil -} - -func (s *CommitFileNode) IsLeaf() bool { - return s.File != nil -} - -func (s *CommitFileNode) GetPath() string { - return s.Path -} - -func (s *CommitFileNode) GetChildren() []INode { - return slices.Map(s.Children, func(child *CommitFileNode) INode { - return child - }) -} - -func (s *CommitFileNode) SetChildren(children []INode) { - castChildren := slices.Map(children, func(child INode) *CommitFileNode { - return child.(*CommitFileNode) - }) - - s.Children = castChildren -} - -func (s *CommitFileNode) GetCompressionLevel() int { - return s.CompressionLevel -} - -func (s *CommitFileNode) SetCompressionLevel(level int) { - s.CompressionLevel = level -} - -// methods utilising generic functions for INodes - -func (s *CommitFileNode) Sort() { - sortNode(s) -} - -func (s *CommitFileNode) ForEachFile(cb func(*models.CommitFile) error) error { - return forEachLeaf(s, func(n INode) error { - castNode := n.(*CommitFileNode) - return cb(castNode.File) - }) -} - -func (s *CommitFileNode) Any(test func(node *CommitFileNode) bool) bool { - return any(s, func(n INode) bool { - castNode := n.(*CommitFileNode) - return test(castNode) - }) -} - -func (s *CommitFileNode) Every(test func(node *CommitFileNode) bool) bool { - return every(s, func(n INode) bool { - castNode := n.(*CommitFileNode) - return test(castNode) - }) -} - -func (s *CommitFileNode) EveryFile(test func(file *models.CommitFile) bool) bool { - return every(s, func(n INode) bool { - castNode := n.(*CommitFileNode) - - return castNode.File == nil || test(castNode.File) - }) -} - -func (n *CommitFileNode) Flatten(collapsedPaths *CollapsedPaths) []*CommitFileNode { - results := flatten(n, collapsedPaths) - - return slices.Map(results, func(result INode) *CommitFileNode { - return result.(*CommitFileNode) - }) -} - -func (node *CommitFileNode) GetNodeAtIndex(index int, collapsedPaths *CollapsedPaths) *CommitFileNode { +func NewCommitFileNode(node *Node[models.CommitFile]) *CommitFileNode { if node == nil { return nil } - result := getNodeAtIndex(node, index, collapsedPaths) - if result == nil { - // not sure how this can be nil: we probably are missing a mutex somewhere - return nil - } - - return result.(*CommitFileNode) + return &CommitFileNode{Node: node} } -func (node *CommitFileNode) GetIndexForPath(path string, collapsedPaths *CollapsedPaths) (int, bool) { - return getIndexForPath(node, path, collapsedPaths) -} - -func (node *CommitFileNode) Size(collapsedPaths *CollapsedPaths) int { - if node == nil { - return 0 - } - - return size(node, collapsedPaths) -} - -func (s *CommitFileNode) Compress() { - // with these functions I try to only have type conversion code on the actual struct, - // but comparing interface values to nil is fraught with danger so I'm duplicating - // that code here. - if s == nil { - return - } - - compressAux(s) -} - -func (s *CommitFileNode) GetLeaves() []*CommitFileNode { - leaves := getLeaves(s) - - return slices.Map(leaves, func(leaf INode) *CommitFileNode { - return leaf.(*CommitFileNode) - }) -} - -// extra methods - -func (s *CommitFileNode) AnyFile(test func(file *models.CommitFile) bool) bool { - return s.Any(func(node *CommitFileNode) bool { - return node.IsLeaf() && test(node.File) - }) -} - -func (s *CommitFileNode) NameAtDepth(depth int) string { - splitName := split(s.Path) - name := join(splitName[depth:]) - - return name +// returns the underlying node, without any commit-file-specific methods attached +func (self *CommitFileNode) Raw() *Node[models.CommitFile] { + return self.Node } diff --git a/pkg/gui/filetree/commit_file_tree.go b/pkg/gui/filetree/commit_file_tree.go index e539c9dea..862db26f1 100644 --- a/pkg/gui/filetree/commit_file_tree.go +++ b/pkg/gui/filetree/commit_file_tree.go @@ -1,22 +1,24 @@ package filetree import ( + "github.com/jesseduffield/generics/slices" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/sirupsen/logrus" ) type ICommitFileTree interface { - ITree + ITree[models.CommitFile] Get(index int) *CommitFileNode GetFile(path string) *models.CommitFile GetAllItems() []*CommitFileNode GetAllFiles() []*models.CommitFile + GetRoot() *CommitFileNode } type CommitFileTree struct { getFiles func() []*models.CommitFile - tree *CommitFileNode + tree *Node[models.CommitFile] showTree bool log *logrus.Entry collapsedPaths *CollapsedPaths @@ -44,7 +46,7 @@ func (self *CommitFileTree) ToggleShowTree() { func (self *CommitFileTree) Get(index int) *CommitFileNode { // need to traverse the three depth first until we get to the index. - return self.tree.GetNodeAtIndex(index+1, self.collapsedPaths) // ignoring root + return NewCommitFileNode(self.tree.GetNodeAtIndex(index+1, self.collapsedPaths)) // ignoring root } func (self *CommitFileTree) GetIndexForPath(path string) (int, bool) { @@ -57,7 +59,10 @@ func (self *CommitFileTree) GetAllItems() []*CommitFileNode { return nil } - return self.tree.Flatten(self.collapsedPaths)[1:] // ignoring root + // ignoring root + return slices.Map(self.tree.Flatten(self.collapsedPaths)[1:], func(node *Node[models.CommitFile]) *CommitFileNode { + return NewCommitFileNode(node) + }) } func (self *CommitFileTree) Len() int { @@ -84,8 +89,8 @@ func (self *CommitFileTree) ToggleCollapsed(path string) { self.collapsedPaths.ToggleCollapsed(path) } -func (self *CommitFileTree) Tree() INode { - return self.tree +func (self *CommitFileTree) GetRoot() *CommitFileNode { + return NewCommitFileNode(self.tree) } func (self *CommitFileTree) CollapsedPaths() *CollapsedPaths { diff --git a/pkg/gui/filetree/file_node.go b/pkg/gui/filetree/file_node.go index 53f87c71b..092841b52 100644 --- a/pkg/gui/filetree/file_node.go +++ b/pkg/gui/filetree/file_node.go @@ -1,199 +1,47 @@ package filetree -import ( - "github.com/jesseduffield/generics/slices" - "github.com/jesseduffield/lazygit/pkg/commands/models" - "github.com/jesseduffield/lazygit/pkg/gui/types" -) +import "github.com/jesseduffield/lazygit/pkg/commands/models" +// FileNode wraps a node and provides some file-specific methods for it. type FileNode struct { - Children []*FileNode - File *models.File - Path string // e.g. '/path/to/mydir' - CompressionLevel int // equal to the number of forward slashes you'll see in the path when it's rendered in tree mode + *Node[models.File] } -var ( - _ INode = &FileNode{} - _ types.ListItem = &FileNode{} -) +var _ models.IFile = &FileNode{} -func (s *FileNode) ID() string { - return s.GetPath() -} - -func (s *FileNode) Description() string { - return s.GetPath() -} - -// methods satisfying INode interface - -// interfaces values whose concrete value is nil are not themselves nil -// hence the existence of this method -func (s *FileNode) IsNil() bool { - return s == nil -} - -func (s *FileNode) IsLeaf() bool { - return s.File != nil -} - -func (s *FileNode) GetPath() string { - return s.Path -} - -func (s *FileNode) GetPreviousPath() string { - if s.File != nil { - return s.File.GetPreviousPath() - } - return "" -} - -func (s *FileNode) GetChildren() []INode { - return slices.Map(s.Children, func(child *FileNode) INode { - return child - }) -} - -func (s *FileNode) SetChildren(children []INode) { - castChildren := slices.Map(children, func(child INode) *FileNode { - return child.(*FileNode) - }) - - s.Children = castChildren -} - -func (s *FileNode) GetCompressionLevel() int { - return s.CompressionLevel -} - -func (s *FileNode) SetCompressionLevel(level int) { - s.CompressionLevel = level -} - -// methods utilising generic functions for INodes - -func (s *FileNode) Sort() { - sortNode(s) -} - -func (s *FileNode) ForEachFile(cb func(*models.File) error) error { - return forEachLeaf(s, func(n INode) error { - castNode := n.(*FileNode) - return cb(castNode.File) - }) -} - -func (s *FileNode) Any(test func(node *FileNode) bool) bool { - return any(s, func(n INode) bool { - castNode := n.(*FileNode) - return test(castNode) - }) -} - -func (n *FileNode) Flatten(collapsedPaths *CollapsedPaths) []*FileNode { - results := flatten(n, collapsedPaths) - return slices.Map(results, func(result INode) *FileNode { - return result.(*FileNode) - }) -} - -func (node *FileNode) GetNodeAtIndex(index int, collapsedPaths *CollapsedPaths) *FileNode { +func NewFileNode(node *Node[models.File]) *FileNode { if node == nil { return nil } - result := getNodeAtIndex(node, index, collapsedPaths) - if result == nil { - // not sure how this can be nil: we probably are missing a mutex somewhere - return nil + return &FileNode{Node: node} +} + +// returns the underlying node, without any file-specific methods attached +func (self *FileNode) Raw() *Node[models.File] { + return self.Node +} + +func (self *FileNode) GetHasUnstagedChanges() bool { + return self.SomeFile(func(file *models.File) bool { return file.HasUnstagedChanges }) +} + +func (self *FileNode) GetHasStagedChanges() bool { + return self.SomeFile(func(file *models.File) bool { return file.HasStagedChanges }) +} + +func (self *FileNode) GetHasInlineMergeConflicts() bool { + return self.SomeFile(func(file *models.File) bool { return file.HasInlineMergeConflicts }) +} + +func (self *FileNode) GetIsTracked() bool { + return self.SomeFile(func(file *models.File) bool { return file.Tracked }) +} + +func (self *FileNode) GetPreviousPath() string { + if self.File == nil { + return "" } - return result.(*FileNode) -} - -func (node *FileNode) GetIndexForPath(path string, collapsedPaths *CollapsedPaths) (int, bool) { - return getIndexForPath(node, path, collapsedPaths) -} - -func (node *FileNode) Size(collapsedPaths *CollapsedPaths) int { - if node == nil { - return 0 - } - - return size(node, collapsedPaths) -} - -func (s *FileNode) Compress() { - // with these functions I try to only have type conversion code on the actual struct, - // but comparing interface values to nil is fraught with danger so I'm duplicating - // that code here. - if s == nil { - return - } - - compressAux(s) -} - -func (node *FileNode) GetFilePathsMatching(test func(*models.File) bool) []string { - return getPathsMatching(node, func(n INode) bool { - castNode := n.(*FileNode) - if castNode.File == nil { - return false - } - return test(castNode.File) - }) -} - -func (s *FileNode) GetLeaves() []*FileNode { - leaves := getLeaves(s) - - return slices.Map(leaves, func(leaf INode) *FileNode { - return leaf.(*FileNode) - }) -} - -// extra methods - -func (s *FileNode) GetHasUnstagedChanges() bool { - return s.AnyFile(func(file *models.File) bool { return file.HasUnstagedChanges }) -} - -func (s *FileNode) GetHasStagedChanges() bool { - return s.AnyFile(func(file *models.File) bool { return file.HasStagedChanges }) -} - -func (s *FileNode) GetHasInlineMergeConflicts() bool { - return s.AnyFile(func(file *models.File) bool { return file.HasInlineMergeConflicts }) -} - -func (s *FileNode) GetIsTracked() bool { - return s.AnyFile(func(file *models.File) bool { return file.Tracked }) -} - -func (s *FileNode) AnyFile(test func(file *models.File) bool) bool { - return s.Any(func(node *FileNode) bool { - return node.IsLeaf() && test(node.File) - }) -} - -func (s *FileNode) NameAtDepth(depth int) string { - splitName := split(s.Path) - name := join(splitName[depth:]) - - if s.File != nil && s.File.IsRename() { - splitPrevName := split(s.File.PreviousName) - - prevName := s.File.PreviousName - // if the file has just been renamed inside the same directory, we can shave off - // the prefix for the previous path too. Otherwise we'll keep it unchanged - sameParentDir := len(splitName) == len(splitPrevName) && join(splitName[0:depth]) == join(splitPrevName[0:depth]) - if sameParentDir { - prevName = join(splitPrevName[depth:]) - } - - return prevName + " → " + name - } - - return name + return self.File.PreviousName } diff --git a/pkg/gui/filetree/file_node_test.go b/pkg/gui/filetree/file_node_test.go index c7649bd16..a3b2b9aee 100644 --- a/pkg/gui/filetree/file_node_test.go +++ b/pkg/gui/filetree/file_node_test.go @@ -10,8 +10,8 @@ import ( func TestCompress(t *testing.T) { scenarios := []struct { name string - root *FileNode - expected *FileNode + root *Node[models.File] + expected *Node[models.File] }{ { name: "nil node", @@ -20,27 +20,27 @@ func TestCompress(t *testing.T) { }, { name: "leaf node", - root: &FileNode{ + root: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ {File: &models.File{Name: "test", ShortStatus: " M", HasStagedChanges: true}, Path: "test"}, }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ {File: &models.File{Name: "test", ShortStatus: " M", HasStagedChanges: true}, Path: "test"}, }, }, }, { name: "big example", - root: &FileNode{ + root: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { Path: "dir1", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "file2", ShortStatus: "M ", HasUnstagedChanges: true}, Path: "dir1/file2", @@ -49,7 +49,7 @@ func TestCompress(t *testing.T) { }, { Path: "dir2", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "file3", ShortStatus: " M", HasStagedChanges: true}, Path: "dir2/file3", @@ -62,10 +62,10 @@ func TestCompress(t *testing.T) { }, { Path: "dir3", - Children: []*FileNode{ + Children: []*Node[models.File]{ { Path: "dir3/dir3-1", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "file5", ShortStatus: "M ", HasUnstagedChanges: true}, Path: "dir3/dir3-1/file5", @@ -80,12 +80,12 @@ func TestCompress(t *testing.T) { }, }, }, - expected: &FileNode{ + expected: &Node[models.File]{ Path: "", - Children: []*FileNode{ + Children: []*Node[models.File]{ { Path: "dir1", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "file2", ShortStatus: "M ", HasUnstagedChanges: true}, Path: "dir1/file2", @@ -94,7 +94,7 @@ func TestCompress(t *testing.T) { }, { Path: "dir2", - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "file3", ShortStatus: " M", HasStagedChanges: true}, Path: "dir2/file3", @@ -108,7 +108,7 @@ func TestCompress(t *testing.T) { { Path: "dir3/dir3-1", CompressionLevel: 1, - Children: []*FileNode{ + Children: []*Node[models.File]{ { File: &models.File{Name: "file5", ShortStatus: "M ", HasUnstagedChanges: true}, Path: "dir3/dir3-1/file5", diff --git a/pkg/gui/filetree/file_tree.go b/pkg/gui/filetree/file_tree.go index d4bb8e596..950bf24be 100644 --- a/pkg/gui/filetree/file_tree.go +++ b/pkg/gui/filetree/file_tree.go @@ -18,7 +18,7 @@ const ( DisplayConflicted ) -type ITree interface { +type ITree[T any] interface { InTreeMode() bool ExpandToPath(path string) ToggleShowTree() @@ -27,12 +27,11 @@ type ITree interface { SetTree() IsCollapsed(path string) bool ToggleCollapsed(path string) - Tree() INode CollapsedPaths() *CollapsedPaths } type IFileTree interface { - ITree + ITree[models.File] FilterFiles(test func(*models.File) bool) []*models.File SetFilter(filter FileTreeDisplayFilter) @@ -41,17 +40,20 @@ type IFileTree interface { GetAllItems() []*FileNode GetAllFiles() []*models.File GetFilter() FileTreeDisplayFilter + GetRoot() *FileNode } type FileTree struct { getFiles func() []*models.File - tree *FileNode + tree *Node[models.File] showTree bool log *logrus.Entry filter FileTreeDisplayFilter collapsedPaths *CollapsedPaths } +var _ IFileTree = &FileTree{} + func NewFileTree(getFiles func() []*models.File, log *logrus.Entry, showTree bool) *FileTree { return &FileTree{ getFiles: getFiles, @@ -101,7 +103,7 @@ func (self *FileTree) ToggleShowTree() { func (self *FileTree) Get(index int) *FileNode { // need to traverse the three depth first until we get to the index. - return self.tree.GetNodeAtIndex(index+1, self.collapsedPaths) // ignoring root + return NewFileNode(self.tree.GetNodeAtIndex(index+1, self.collapsedPaths)) // ignoring root } func (self *FileTree) GetFile(path string) *models.File { @@ -127,7 +129,10 @@ func (self *FileTree) GetAllItems() []*FileNode { return nil } - return self.tree.Flatten(self.collapsedPaths)[1:] // ignoring root + // ignoring root + return slices.Map(self.tree.Flatten(self.collapsedPaths)[1:], func(node *Node[models.File]) *FileNode { + return NewFileNode(node) + }) } func (self *FileTree) Len() int { @@ -155,8 +160,12 @@ func (self *FileTree) ToggleCollapsed(path string) { self.collapsedPaths.ToggleCollapsed(path) } -func (self *FileTree) Tree() INode { - return self.tree +func (self *FileTree) Tree() *FileNode { + return NewFileNode(self.tree) +} + +func (self *FileTree) GetRoot() *FileNode { + return NewFileNode(self.tree) } func (self *FileTree) CollapsedPaths() *CollapsedPaths { diff --git a/pkg/gui/filetree/inode.go b/pkg/gui/filetree/inode.go deleted file mode 100644 index d59315b28..000000000 --- a/pkg/gui/filetree/inode.go +++ /dev/null @@ -1,206 +0,0 @@ -package filetree - -import "github.com/jesseduffield/generics/slices" - -type INode interface { - IsNil() bool - IsLeaf() bool - GetPath() string - GetChildren() []INode - SetChildren([]INode) - GetCompressionLevel() int - SetCompressionLevel(int) -} - -func sortNode(node INode) { - sortChildren(node) - - for _, child := range node.GetChildren() { - sortNode(child) - } -} - -func sortChildren(node INode) { - if node.IsLeaf() { - return - } - - sortedChildren := slices.Clone(node.GetChildren()) - - slices.SortFunc(sortedChildren, func(a, b INode) bool { - if !a.IsLeaf() && b.IsLeaf() { - return true - } - if a.IsLeaf() && !b.IsLeaf() { - return false - } - - return a.GetPath() < b.GetPath() - }) - - // TODO: think about making this in-place - node.SetChildren(sortedChildren) -} - -func forEachLeaf(node INode, cb func(INode) error) error { - if node.IsLeaf() { - if err := cb(node); err != nil { - return err - } - } - - for _, child := range node.GetChildren() { - if err := forEachLeaf(child, cb); err != nil { - return err - } - } - - return nil -} - -func any(node INode, test func(INode) bool) bool { - if test(node) { - return true - } - - for _, child := range node.GetChildren() { - if any(child, test) { - return true - } - } - - return false -} - -func every(node INode, test func(INode) bool) bool { - if !test(node) { - return false - } - - for _, child := range node.GetChildren() { - if !every(child, test) { - return false - } - } - - return true -} - -func flatten(node INode, collapsedPaths *CollapsedPaths) []INode { - result := []INode{} - result = append(result, node) - - if !collapsedPaths.IsCollapsed(node.GetPath()) { - for _, child := range node.GetChildren() { - result = append(result, flatten(child, collapsedPaths)...) - } - } - - return result -} - -func getNodeAtIndex(node INode, index int, collapsedPaths *CollapsedPaths) INode { - foundNode, _ := getNodeAtIndexAux(node, index, collapsedPaths) - - return foundNode -} - -func getNodeAtIndexAux(node INode, index int, collapsedPaths *CollapsedPaths) (INode, int) { - offset := 1 - - if index == 0 { - return node, offset - } - - if !collapsedPaths.IsCollapsed(node.GetPath()) { - for _, child := range node.GetChildren() { - foundNode, offsetChange := getNodeAtIndexAux(child, index-offset, collapsedPaths) - offset += offsetChange - if foundNode != nil { - return foundNode, offset - } - } - } - - return nil, offset -} - -func getIndexForPath(node INode, path string, collapsedPaths *CollapsedPaths) (int, bool) { - offset := 0 - - if node.GetPath() == path { - return offset, true - } - - if !collapsedPaths.IsCollapsed(node.GetPath()) { - for _, child := range node.GetChildren() { - offsetChange, found := getIndexForPath(child, path, collapsedPaths) - offset += offsetChange + 1 - if found { - return offset, true - } - } - } - - return offset, false -} - -func size(node INode, collapsedPaths *CollapsedPaths) int { - output := 1 - - if !collapsedPaths.IsCollapsed(node.GetPath()) { - for _, child := range node.GetChildren() { - output += size(child, collapsedPaths) - } - } - - return output -} - -func compressAux(node INode) INode { - if node.IsLeaf() { - return node - } - - children := node.GetChildren() - for i := range children { - grandchildren := children[i].GetChildren() - for len(grandchildren) == 1 && !grandchildren[0].IsLeaf() { - grandchildren[0].SetCompressionLevel(children[i].GetCompressionLevel() + 1) - children[i] = grandchildren[0] - grandchildren = children[i].GetChildren() - } - } - - for i := range children { - children[i] = compressAux(children[i]) - } - - node.SetChildren(children) - - return node -} - -func getPathsMatching(node INode, test func(INode) bool) []string { - paths := []string{} - - if test(node) { - paths = append(paths, node.GetPath()) - } - - for _, child := range node.GetChildren() { - paths = append(paths, getPathsMatching(child, test)...) - } - - return paths -} - -func getLeaves(node INode) []INode { - if node.IsLeaf() { - return []INode{node} - } - - return slices.FlatMap(node.GetChildren(), func(child INode) []INode { - return getLeaves(child) - }) -} diff --git a/pkg/gui/filetree/node.go b/pkg/gui/filetree/node.go new file mode 100644 index 000000000..8de655b37 --- /dev/null +++ b/pkg/gui/filetree/node.go @@ -0,0 +1,301 @@ +package filetree + +import ( + "github.com/jesseduffield/generics/slices" + "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/gui/types" +) + +// Represents a file or directory in a file tree. +type Node[T any] struct { + // File will be nil if the node is a directory. + File *T + + // If the node is a directory, Children contains the contents of the directory, + // otherwise it's nil. + Children []*Node[T] + + // path of the file/directory + Path string + + // rather than render a tree as: + // a/ + // b/ + // file.blah + // + // we instead render it as: + // a/b/ + // file.blah + // This saves vertical space. The CompressionLevel of a node is equal to the + // number of times a 'compression' like the above has happened, where two + // nodes are squished into one. + CompressionLevel int +} + +var _ types.ListItem = &Node[models.File]{} + +func (self *Node[T]) IsFile() bool { + return self.File != nil +} + +func (self *Node[T]) GetPath() string { + return self.Path +} + +func (self *Node[T]) Sort() { + self.SortChildren() + + for _, child := range self.Children { + child.Sort() + } +} + +func (self *Node[T]) ForEachFile(cb func(*T) error) error { + if self.IsFile() { + if err := cb(self.File); err != nil { + return err + } + } + + for _, child := range self.Children { + if err := child.ForEachFile(cb); err != nil { + return err + } + } + + return nil +} + +func (self *Node[T]) SortChildren() { + if self.IsFile() { + return + } + + children := slices.Clone(self.Children) + + slices.SortFunc(children, func(a, b *Node[T]) bool { + if !a.IsFile() && b.IsFile() { + return true + } + if a.IsFile() && !b.IsFile() { + return false + } + + return a.GetPath() < b.GetPath() + }) + + // TODO: think about making this in-place + self.Children = children +} + +func (self *Node[T]) Some(test func(*Node[T]) bool) bool { + if test(self) { + return true + } + + for _, child := range self.Children { + if child.Some(test) { + return true + } + } + + return false +} + +func (self *Node[T]) SomeFile(test func(*T) bool) bool { + if self.IsFile() { + if test(self.File) { + return true + } + } else { + for _, child := range self.Children { + if child.SomeFile(test) { + return true + } + } + } + + return false +} + +func (self *Node[T]) Every(test func(*Node[T]) bool) bool { + if !test(self) { + return false + } + + for _, child := range self.Children { + if !child.Every(test) { + return false + } + } + + return true +} + +func (self *Node[T]) EveryFile(test func(*T) bool) bool { + if self.IsFile() { + if !test(self.File) { + return false + } + } else { + for _, child := range self.Children { + if !child.EveryFile(test) { + return false + } + } + } + + return true +} + +func (self *Node[T]) Flatten(collapsedPaths *CollapsedPaths) []*Node[T] { + result := []*Node[T]{self} + + if len(self.Children) > 0 && !collapsedPaths.IsCollapsed(self.GetPath()) { + result = append(result, slices.FlatMap(self.Children, func(child *Node[T]) []*Node[T] { + return child.Flatten(collapsedPaths) + })...) + } + + return result +} + +func (self *Node[T]) GetNodeAtIndex(index int, collapsedPaths *CollapsedPaths) *Node[T] { + if self == nil { + return nil + } + + node, _ := self.getNodeAtIndexAux(index, collapsedPaths) + + return node +} + +func (self *Node[T]) getNodeAtIndexAux(index int, collapsedPaths *CollapsedPaths) (*Node[T], int) { + offset := 1 + + if index == 0 { + return self, offset + } + + if !collapsedPaths.IsCollapsed(self.GetPath()) { + for _, child := range self.Children { + foundNode, offsetChange := child.getNodeAtIndexAux(index-offset, collapsedPaths) + offset += offsetChange + if foundNode != nil { + return foundNode, offset + } + } + } + + return nil, offset +} + +func (self *Node[T]) GetIndexForPath(path string, collapsedPaths *CollapsedPaths) (int, bool) { + offset := 0 + + if self.GetPath() == path { + return offset, true + } + + if !collapsedPaths.IsCollapsed(self.GetPath()) { + for _, child := range self.Children { + offsetChange, found := child.GetIndexForPath(path, collapsedPaths) + offset += offsetChange + 1 + if found { + return offset, true + } + } + } + + return offset, false +} + +func (self *Node[T]) Size(collapsedPaths *CollapsedPaths) int { + if self == nil { + return 0 + } + + output := 1 + + if !collapsedPaths.IsCollapsed(self.GetPath()) { + for _, child := range self.Children { + output += child.Size(collapsedPaths) + } + } + + return output +} + +func (self *Node[T]) Compress() { + if self == nil { + return + } + + self.compressAux() +} + +func (self *Node[T]) compressAux() *Node[T] { + if self.IsFile() { + return self + } + + children := self.Children + for i := range children { + grandchildren := children[i].Children + for len(grandchildren) == 1 && !grandchildren[0].IsFile() { + grandchildren[0].CompressionLevel = children[i].CompressionLevel + 1 + children[i] = grandchildren[0] + grandchildren = children[i].Children + } + } + + for i := range children { + children[i] = children[i].compressAux() + } + + self.Children = children + + return self +} + +func (self *Node[T]) GetPathsMatching(test func(*Node[T]) bool) []string { + paths := []string{} + + if test(self) { + paths = append(paths, self.GetPath()) + } + + for _, child := range self.Children { + paths = append(paths, child.GetPathsMatching(test)...) + } + + return paths +} + +func (self *Node[T]) GetFilePathsMatching(test func(*T) bool) []string { + matchingFileNodes := slices.Filter(self.GetLeaves(), func(node *Node[T]) bool { + return test(node.File) + }) + + return slices.Map(matchingFileNodes, func(node *Node[T]) string { + return node.GetPath() + }) +} + +func (self *Node[T]) GetLeaves() []*Node[T] { + if self.IsFile() { + return []*Node[T]{self} + } + + return slices.FlatMap(self.Children, func(child *Node[T]) []*Node[T] { + return child.GetLeaves() + }) +} + +func (self *Node[T]) ID() string { + return self.GetPath() +} + +func (self *Node[T]) Description() string { + return self.GetPath() +} diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 531a43035..04b53e074 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -97,7 +97,7 @@ type Gui struct { // recent repo with the recent repos popup showing showRecentRepos bool - Mutexes guiMutexes + Mutexes types.Mutexes // findSuggestions will take a string that the user has typed into a prompt // and return a slice of suggestions which match that string. @@ -237,18 +237,6 @@ const ( COMPLETE ) -// if you add a new mutex here be sure to instantiate it. We're using pointers to -// mutexes so that we can pass the mutexes to controllers. -type guiMutexes struct { - RefreshingFilesMutex *sync.Mutex - RefreshingStatusMutex *sync.Mutex - SyncMutex *sync.Mutex - LocalCommitsMutex *sync.Mutex - LineByLinePanelMutex *sync.Mutex - SubprocessMutex *sync.Mutex - PopupMutex *sync.Mutex -} - func (gui *Gui) onNewRepo(startArgs types.StartArgs, reuseState bool) error { var err error gui.git, err = commands.NewGitCommand( @@ -418,7 +406,7 @@ func NewGui( // but now we do it via state. So we need to still support the config for the // sake of backwards compatibility. We're making use of short circuiting here ShowExtrasWindow: cmn.UserConfig.Gui.ShowCommandLog && !config.GetAppState().HideCommandLog, - Mutexes: guiMutexes{ + Mutexes: types.Mutexes{ RefreshingFilesMutex: &sync.Mutex{}, RefreshingStatusMutex: &sync.Mutex{}, SyncMutex: &sync.Mutex{}, diff --git a/pkg/gui/menu_panel.go b/pkg/gui/menu_panel.go index 59dd6b3a5..34dd1c615 100644 --- a/pkg/gui/menu_panel.go +++ b/pkg/gui/menu_panel.go @@ -1,11 +1,12 @@ package gui import ( - "errors" "fmt" + "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/theme" + "github.com/jesseduffield/lazygit/pkg/utils" ) func (gui *Gui) getMenuOptions() map[string]string { @@ -30,9 +31,25 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error { }) } + maxColumnSize := 1 + for _, item := range opts.Items { - if item.OpensMenu && item.LabelColumns != nil { - return errors.New("Message for the developer of this app: you've set opensMenu with displaystrings on the menu panel. Bad developer!. Apologies, user") + if item.LabelColumns == nil { + item.LabelColumns = []string{item.Label} + } + + if item.OpensMenu { + item.LabelColumns[0] = presentation.OpensMenuStyle(item.LabelColumns[0]) + } + + maxColumnSize = utils.Max(maxColumnSize, len(item.LabelColumns)) + } + + for _, item := range opts.Items { + if len(item.LabelColumns) < maxColumnSize { + // we require that each item has the same number of columns so we're padding out with blank strings + // if this item has too few + item.LabelColumns = append(item.LabelColumns, make([]string, maxColumnSize-len(item.LabelColumns))...) } } diff --git a/pkg/gui/options_menu_panel.go b/pkg/gui/options_menu_panel.go index aee88d866..ee07f2f08 100644 --- a/pkg/gui/options_menu_panel.go +++ b/pkg/gui/options_menu_panel.go @@ -5,7 +5,6 @@ import ( "github.com/jesseduffield/generics/slices" "github.com/jesseduffield/lazygit/pkg/gui/keybindings" - "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" ) @@ -50,21 +49,14 @@ func uniqueBindings(bindings []*types.Binding) []*types.Binding { }) } -func (gui *Gui) displayDescription(binding *types.Binding) string { - if binding.OpensMenu { - return presentation.OpensMenuStyle(binding.Description) - } - - return binding.Description -} - func (gui *Gui) handleCreateOptionsMenu() error { context := gui.currentContext() bindings := gui.getBindings(context) menuItems := slices.Map(bindings, func(binding *types.Binding) *types.MenuItem { return &types.MenuItem{ - Label: gui.displayDescription(binding), + OpensMenu: binding.OpensMenu, + Label: binding.Description, OnPress: func() error { if binding.Key == nil { return nil diff --git a/pkg/gui/presentation/files.go b/pkg/gui/presentation/files.go index cb4d19cec..394b39f73 100644 --- a/pkg/gui/presentation/files.go +++ b/pkg/gui/presentation/files.go @@ -30,9 +30,10 @@ func RenderFileTree( diffName string, submoduleConfigs []*models.SubmoduleConfig, ) []string { - return renderAux(tree.Tree(), tree.CollapsedPaths(), "", -1, func(n filetree.INode, depth int) string { - castN := n.(*filetree.FileNode) - return getFileLine(castN.GetHasUnstagedChanges(), castN.GetHasStagedChanges(), castN.NameAtDepth(depth), diffName, submoduleConfigs, castN.File) + return renderAux(tree.GetRoot().Raw(), tree.CollapsedPaths(), "", -1, func(node *filetree.Node[models.File], depth int) string { + fileNode := filetree.NewFileNode(node) + + return getFileLine(fileNode.GetHasUnstagedChanges(), fileNode.GetHasStagedChanges(), fileNameAtDepth(node, depth), diffName, submoduleConfigs, node.File) }) } @@ -41,19 +42,17 @@ func RenderCommitFileTree( diffName string, patchManager *patch.PatchManager, ) []string { - return renderAux(tree.Tree(), tree.CollapsedPaths(), "", -1, func(n filetree.INode, depth int) string { - castN := n.(*filetree.CommitFileNode) - + return renderAux(tree.GetRoot().Raw(), tree.CollapsedPaths(), "", -1, func(node *filetree.Node[models.CommitFile], depth int) string { // This is a little convoluted because we're dealing with either a leaf or a non-leaf. // But this code actually applies to both. If it's a leaf, the status will just // be whatever status it is, but if it's a non-leaf it will determine its status // based on the leaves of that subtree var status patch.PatchStatus - if castN.EveryFile(func(file *models.CommitFile) bool { + if node.EveryFile(func(file *models.CommitFile) bool { return patchManager.GetFileStatus(file.Name, tree.GetRef().RefName()) == patch.WHOLE }) { status = patch.WHOLE - } else if castN.EveryFile(func(file *models.CommitFile) bool { + } else if node.EveryFile(func(file *models.CommitFile) bool { return patchManager.GetFileStatus(file.Name, tree.GetRef().RefName()) == patch.UNSELECTED }) { status = patch.UNSELECTED @@ -61,37 +60,37 @@ func RenderCommitFileTree( status = patch.PART } - return getCommitFileLine(castN.NameAtDepth(depth), diffName, castN.File, status) + return getCommitFileLine(commitFileNameAtDepth(node, depth), diffName, node.File, status) }) } -func renderAux( - s filetree.INode, +func renderAux[T any]( + node *filetree.Node[T], collapsedPaths *filetree.CollapsedPaths, prefix string, depth int, - renderLine func(filetree.INode, int) string, + renderLine func(*filetree.Node[T], int) string, ) []string { - if s == nil || s.IsNil() { + if node == nil { return []string{} } isRoot := depth == -1 - if s.IsLeaf() { + if node.IsFile() { if isRoot { return []string{} } - return []string{prefix + renderLine(s, depth)} + return []string{prefix + renderLine(node, depth)} } - if collapsedPaths.IsCollapsed(s.GetPath()) { - return []string{prefix + COLLAPSED_ARROW + " " + renderLine(s, depth)} + if collapsedPaths.IsCollapsed(node.GetPath()) { + return []string{prefix + COLLAPSED_ARROW + " " + renderLine(node, depth)} } arr := []string{} if !isRoot { - arr = append(arr, prefix+EXPANDED_ARROW+" "+renderLine(s, depth)) + arr = append(arr, prefix+EXPANDED_ARROW+" "+renderLine(node, depth)) } newPrefix := prefix @@ -101,8 +100,8 @@ func renderAux( newPrefix = strings.TrimSuffix(prefix, INNER_ITEM) + NESTED } - for i, child := range s.GetChildren() { - isLast := i == len(s.GetChildren())-1 + for i, child := range node.Children { + isLast := i == len(node.Children)-1 var childPrefix string if isRoot { @@ -113,7 +112,7 @@ func renderAux( childPrefix = newPrefix + INNER_ITEM } - arr = append(arr, renderAux(child, collapsedPaths, childPrefix, depth+1+s.GetCompressionLevel(), renderLine)...) + arr = append(arr, renderAux(child, collapsedPaths, childPrefix, depth+1+node.CompressionLevel, renderLine)...) } return arr @@ -220,3 +219,39 @@ func getColorForChangeStatus(changeStatus string) style.TextStyle { return theme.DefaultTextColor } } + +func fileNameAtDepth(node *filetree.Node[models.File], depth int) string { + splitName := split(node.Path) + name := join(splitName[depth:]) + + if node.File != nil && node.File.IsRename() { + splitPrevName := split(node.File.PreviousName) + + prevName := node.File.PreviousName + // if the file has just been renamed inside the same directory, we can shave off + // the prefix for the previous path too. Otherwise we'll keep it unchanged + sameParentDir := len(splitName) == len(splitPrevName) && join(splitName[0:depth]) == join(splitPrevName[0:depth]) + if sameParentDir { + prevName = join(splitPrevName[depth:]) + } + + return prevName + " → " + name + } + + return name +} + +func commitFileNameAtDepth(node *filetree.Node[models.CommitFile], depth int) string { + splitName := split(node.Path) + name := join(splitName[depth:]) + + return name +} + +func split(str string) []string { + return strings.Split(str, "/") +} + +func join(strs []string) string { + return strings.Join(strs, "/") +} diff --git a/pkg/gui/presentation/reflog_commits.go b/pkg/gui/presentation/reflog_commits.go index 15cd2cb74..5a49f0374 100644 --- a/pkg/gui/presentation/reflog_commits.go +++ b/pkg/gui/presentation/reflog_commits.go @@ -11,7 +11,7 @@ import ( ) func GetReflogCommitListDisplayStrings(commits []*models.Commit, fullDescription bool, cherryPickedCommitShaSet *set.Set[string], diffName string, timeFormat string, parseEmoji bool) [][]string { - var displayFunc func(*models.Commit, string, bool, bool, bool) []string + var displayFunc func(*models.Commit, reflogCommitDisplayAttributes) []string if fullDescription { displayFunc = getFullDescriptionDisplayStringsForReflogCommit } else { @@ -21,7 +21,13 @@ func GetReflogCommitListDisplayStrings(commits []*models.Commit, fullDescription return slices.Map(commits, func(commit *models.Commit) []string { diffed := commit.Sha == diffName cherryPicked := cherryPickedCommitShaSet.Includes(commit.Sha) - return displayFunc(commit, timeFormat, cherryPicked, diffed, parseEmoji) + return displayFunc(commit, + reflogCommitDisplayAttributes{ + cherryPicked: cherryPicked, + diffed: diffed, + parseEmoji: parseEmoji, + timeFormat: timeFormat, + }) }) } @@ -38,27 +44,34 @@ func reflogShaColor(cherryPicked, diffed bool) style.TextStyle { return shaColor } -func getFullDescriptionDisplayStringsForReflogCommit(c *models.Commit, timeFormat string, cherryPicked, diffed, parseEmoji bool) []string { +type reflogCommitDisplayAttributes struct { + cherryPicked bool + diffed bool + parseEmoji bool + timeFormat string +} + +func getFullDescriptionDisplayStringsForReflogCommit(c *models.Commit, attrs reflogCommitDisplayAttributes) []string { name := c.Name - if parseEmoji { + if attrs.parseEmoji { name = emoji.Sprint(name) } return []string{ - reflogShaColor(cherryPicked, diffed).Sprint(c.ShortSha()), - style.FgMagenta.Sprint(utils.UnixToDate(c.UnixTimestamp, timeFormat)), + reflogShaColor(attrs.cherryPicked, attrs.diffed).Sprint(c.ShortSha()), + style.FgMagenta.Sprint(utils.UnixToDate(c.UnixTimestamp, attrs.timeFormat)), theme.DefaultTextColor.Sprint(name), } } -func getDisplayStringsForReflogCommit(c *models.Commit, timeFormat string, cherryPicked, diffed, parseEmoji bool) []string { +func getDisplayStringsForReflogCommit(c *models.Commit, attrs reflogCommitDisplayAttributes) []string { name := c.Name - if parseEmoji { + if attrs.parseEmoji { name = emoji.Sprint(name) } return []string{ - reflogShaColor(cherryPicked, diffed).Sprint(c.ShortSha()), + reflogShaColor(attrs.cherryPicked, attrs.diffed).Sprint(c.ShortSha()), theme.DefaultTextColor.Sprint(name), } } diff --git a/pkg/gui/recent_repos_panel.go b/pkg/gui/recent_repos_panel.go index 605ae79d3..3b817fbca 100644 --- a/pkg/gui/recent_repos_panel.go +++ b/pkg/gui/recent_repos_panel.go @@ -1,24 +1,92 @@ package gui import ( + "fmt" + "io/ioutil" "os" "path/filepath" + "strings" + "sync" "github.com/jesseduffield/generics/slices" "github.com/jesseduffield/lazygit/pkg/commands" "github.com/jesseduffield/lazygit/pkg/env" + "github.com/jesseduffield/lazygit/pkg/gui/presentation/icons" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/jesseduffield/lazygit/pkg/utils" ) -func (gui *Gui) handleCreateRecentReposMenu() error { - recentRepoPaths := gui.c.GetAppState().RecentRepos +func (gui *Gui) getCurrentBranch(path string) string { + readHeadFile := func(path string) (string, error) { + headFile, err := ioutil.ReadFile(filepath.Join(path, "HEAD")) + if err == nil { + content := strings.TrimSpace(string(headFile)) + refsPrefix := "ref: refs/heads/" + branchDisplay := "" + if strings.HasPrefix(content, refsPrefix) { + // is a branch + branchDisplay = strings.TrimPrefix(content, refsPrefix) + } else { + // detached HEAD state, displaying short SHA + branchDisplay = utils.ShortSha(content) + } + return branchDisplay, nil + } + return "", err + } + + gitDirPath := filepath.Join(path, ".git") + + if gitDir, err := os.Stat(gitDirPath); err == nil { + if gitDir.IsDir() { + // ordinary repo + if branch, err := readHeadFile(gitDirPath); err == nil { + return branch + } + } else { + // worktree + if worktreeGitDir, err := ioutil.ReadFile(gitDirPath); err == nil { + content := strings.TrimSpace(string(worktreeGitDir)) + worktreePath := strings.TrimPrefix(content, "gitdir: ") + if branch, err := readHeadFile(worktreePath); err == nil { + return branch + } + } + } + } + + return gui.c.Tr.LcBranchUnknown +} + +func (gui *Gui) handleCreateRecentReposMenu() error { + // we skip the first one because we're currently in it + recentRepoPaths := gui.c.GetAppState().RecentRepos[1:] + + currentBranches := sync.Map{} + + wg := sync.WaitGroup{} + wg.Add(len(recentRepoPaths)) + + for _, path := range recentRepoPaths { + go func(path string) { + defer wg.Done() + currentBranches.Store(path, gui.getCurrentBranch(path)) + }(path) + } + + wg.Wait() + + menuItems := slices.Map(recentRepoPaths, func(path string) *types.MenuItem { + branchName, _ := currentBranches.Load(path) + if icons.IsIconEnabled() { + branchName = icons.BRANCH_ICON + " " + fmt.Sprintf("%v", branchName) + } - // we won't show the current repo hence the -1 - menuItems := slices.Map(recentRepoPaths[1:], func(path string) *types.MenuItem { return &types.MenuItem{ LabelColumns: []string{ filepath.Base(path), + style.FgCyan.Sprint(branchName), style.FgMagenta.Sprint(path), }, OnPress: func() error { @@ -110,7 +178,7 @@ func newRecentReposList(recentRepos []string, currentRepo string) (bool, []strin newRepos := []string{currentRepo} for _, repo := range recentRepos { if repo != currentRepo { - if _, err := os.Stat(repo); err != nil { + if _, err := os.Stat(filepath.Join(repo, ".git")); err != nil { continue } newRepos = append(newRepos, repo) diff --git a/pkg/gui/services/custom_commands/handler_creator.go b/pkg/gui/services/custom_commands/handler_creator.go index 3dd9a0517..6ac9fb733 100644 --- a/pkg/gui/services/custom_commands/handler_creator.go +++ b/pkg/gui/services/custom_commands/handler_creator.go @@ -1,6 +1,8 @@ package custom_commands import ( + "strings" + "github.com/jesseduffield/generics/slices" "github.com/jesseduffield/lazygit/pkg/commands" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" @@ -187,10 +189,20 @@ func (self *HandlerCreator) finalHandler(customCommand config.CustomCommand, ses if customCommand.Stream { cmdObj.StreamOutput() } - err := cmdObj.Run() + output, err := cmdObj.RunWithOutput() if err != nil { return self.c.Error(err) } + + if customCommand.ShowOutput { + if strings.TrimSpace(output) == "" { + output = self.c.Tr.EmptyOutput + } + if err = self.c.Alert(cmdStr, output); err != nil { + return self.c.Error(err) + } + return self.c.Refresh(types.RefreshOptions{}) + } return self.c.Refresh(types.RefreshOptions{}) }) } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 9272317f9..21808705a 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -1,6 +1,8 @@ package types import ( + "sync" + "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/oscommands" @@ -96,6 +98,10 @@ type ConfirmOpts struct { HandleConfirm func() error HandleClose func() error HandlersManageFocus bool + HasLoader bool + FindSuggestionsFunc func(string) []*Suggestion + Editable bool + Mask bool } type PromptOpts struct { @@ -152,3 +158,15 @@ type Model struct { // for displaying suggestions while typing in a file name FilesTrie *patricia.Trie } + +// if you add a new mutex here be sure to instantiate it. We're using pointers to +// mutexes so that we can pass the mutexes to controllers. +type Mutexes struct { + RefreshingFilesMutex *sync.Mutex + RefreshingStatusMutex *sync.Mutex + SyncMutex *sync.Mutex + LocalCommitsMutex *sync.Mutex + LineByLinePanelMutex *sync.Mutex + SubprocessMutex *sync.Mutex + PopupMutex *sync.Mutex +} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index af5a8a99b..b57c1aad8 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -409,6 +409,7 @@ type TranslationSet struct { NoFilesStagedPrompt string BranchNotFoundTitle string BranchNotFoundPrompt string + LcBranchUnknown string UnstageLinesTitle string UnstageLinesPrompt string LcCreateNewBranchFromCommit string @@ -501,6 +502,7 @@ type TranslationSet struct { UpstreamGone string NukeDescription string DiscardStagedChangesDescription string + EmptyOutput string Actions Actions Bisect Bisect } @@ -1045,6 +1047,7 @@ func EnglishTranslationSet() TranslationSet { NoFilesStagedPrompt: "You have not staged any files. Commit all files?", BranchNotFoundTitle: "Branch not found", BranchNotFoundPrompt: "Branch not found. Create a new branch named", + LcBranchUnknown: "branch unknown", UnstageLinesTitle: "Unstage lines", UnstageLinesPrompt: "Are you sure you want to delete the selected lines (git reset)? It is irreversible.\nTo disable this dialogue set the config key of 'gui.skipUnstageLineWarning' to true", LcCreateNewBranchFromCommit: "create new branch off of commit", @@ -1136,6 +1139,7 @@ func EnglishTranslationSet() TranslationSet { UpstreamGone: "(upstream gone)", NukeDescription: "If you want to make all the changes in the worktree go away, this is the way to do it. If there are dirty submodule changes this will stash those changes in the submodule(s).", DiscardStagedChangesDescription: "This will create a new stash entry containing only staged files and then drop it, so that the working tree is left with only unstaged changes", + EmptyOutput: "", Actions: Actions{ // TODO: combine this with the original keybinding descriptions (those are all in lowercase atm) CheckoutCommit: "Checkout commit", diff --git a/vendor/github.com/jesseduffield/gocui/gui.go b/vendor/github.com/jesseduffield/gocui/gui.go index 17150c477..563d18dcc 100644 --- a/vendor/github.com/jesseduffield/gocui/gui.go +++ b/vendor/github.com/jesseduffield/gocui/gui.go @@ -1158,9 +1158,11 @@ func (g *Gui) onKey(ev *GocuiEvent) error { if len(v.Tabs) > 0 { tabIndex := v.GetClickedTabIndex(mx - v.x0) - for _, binding := range g.tabClickBindings { - if binding.viewName == v.Name() { - return binding.handler(tabIndex) + if tabIndex >= 0 { + for _, binding := range g.tabClickBindings { + if binding.viewName == v.Name() { + return binding.handler(tabIndex) + } } } } diff --git a/vendor/github.com/jesseduffield/gocui/view.go b/vendor/github.com/jesseduffield/gocui/view.go index 8051cce72..5735c2fc4 100644 --- a/vendor/github.com/jesseduffield/gocui/view.go +++ b/vendor/github.com/jesseduffield/gocui/view.go @@ -1214,15 +1214,22 @@ func (v *View) GetClickedTabIndex(x int) int { return 0 } - charIndex := 0 + charX := 1 + if x <= charX { + return -1 + } for i, tab := range v.Tabs { - charIndex += len(tab + " - ") - if x < charIndex { + charX += runewidth.StringWidth(tab) + if x <= charX { return i } + charX += runewidth.StringWidth(" - ") + if x <= charX { + return -1 + } } - return 0 + return -1 } func (v *View) SelectedLineIdx() int { diff --git a/vendor/modules.txt b/vendor/modules.txt index 07d49b414..96043b723 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -172,7 +172,7 @@ github.com/jesseduffield/go-git/v5/utils/merkletrie/filesystem github.com/jesseduffield/go-git/v5/utils/merkletrie/index github.com/jesseduffield/go-git/v5/utils/merkletrie/internal/frame github.com/jesseduffield/go-git/v5/utils/merkletrie/noder -# github.com/jesseduffield/gocui v0.3.1-0.20220417002912-bce22fd599f6 +# github.com/jesseduffield/gocui v0.3.1-0.20220723050330-1f853fadb335 ## explicit; go 1.12 github.com/jesseduffield/gocui # github.com/jesseduffield/kill v0.0.0-20220618033138-bfbe04675d10