package controllers import ( "strings" "github.com/jesseduffield/gocui" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context" "github.com/jesseduffield/lazygit/pkg/gui/filetree" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" ) type FilesController struct { baseController // nolint: unused *ListControllerTrait[*filetree.FileNode] c *ControllerCommon } var _ types.IController = &FilesController{} func NewFilesController( c *ControllerCommon, ) *FilesController { return &FilesController{ c: c, ListControllerTrait: NewListControllerTrait[*filetree.FileNode]( c, c.Contexts().Files, c.Contexts().Files.GetSelected, c.Contexts().Files.GetSelectedItems, ), } } func (self *FilesController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { return []*types.Binding{ { Key: opts.GetKey(opts.Config.Universal.Select), Handler: self.withItems(self.press), GetDisabledReason: self.require(self.itemsSelected()), Description: self.c.Tr.Stage, Tooltip: self.c.Tr.StageTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Files.OpenStatusFilter), Handler: self.handleStatusFilterPressed, Description: self.c.Tr.FileFilter, }, { Key: opts.GetKey(opts.Config.Files.CopyFileInfoToClipboard), Handler: self.openCopyMenu, Description: self.c.Tr.CopyToClipboardMenu, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Files.CommitChanges), Handler: self.c.Helpers().WorkingTree.HandleCommitPress, Description: self.c.Tr.Commit, Tooltip: self.c.Tr.CommitTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Files.CommitChangesWithoutHook), Handler: self.c.Helpers().WorkingTree.HandleWIPCommitPress, Description: self.c.Tr.CommitChangesWithoutHook, }, { Key: opts.GetKey(opts.Config.Files.AmendLastCommit), Handler: self.handleAmendCommitPress, Description: self.c.Tr.AmendLastCommit, }, { Key: opts.GetKey(opts.Config.Files.CommitChangesWithEditor), Handler: self.c.Helpers().WorkingTree.HandleCommitEditorPress, Description: self.c.Tr.CommitChangesWithEditor, }, { Key: opts.GetKey(opts.Config.Files.FindBaseCommitForFixup), Handler: self.c.Helpers().FixupHelper.HandleFindBaseCommitForFixupPress, Description: self.c.Tr.FindBaseCommitForFixup, Tooltip: self.c.Tr.FindBaseCommitForFixupTooltip, }, { Key: opts.GetKey(opts.Config.Universal.Edit), Handler: self.withItems(self.edit), GetDisabledReason: self.require(self.itemsSelected(self.canEditFiles)), Description: self.c.Tr.Edit, Tooltip: self.c.Tr.EditFileTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Universal.OpenFile), Handler: self.Open, GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenFile, Tooltip: self.c.Tr.OpenFileTooltip, }, { Key: opts.GetKey(opts.Config.Files.IgnoreFile), Handler: self.withItem(self.ignoreOrExcludeMenu), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.Actions.IgnoreExcludeFile, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Files.RefreshFiles), Handler: self.refresh, Description: self.c.Tr.RefreshFiles, }, { Key: opts.GetKey(opts.Config.Files.StashAllChanges), Handler: self.stash, Description: self.c.Tr.Stash, Tooltip: self.c.Tr.StashTooltip, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Files.ViewStashOptions), Handler: self.createStashMenu, Description: self.c.Tr.ViewStashOptions, Tooltip: self.c.Tr.ViewStashOptionsTooltip, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Files.ToggleStagedAll), Handler: self.toggleStagedAll, Description: self.c.Tr.ToggleStagedAll, Tooltip: self.c.Tr.ToggleStagedAllTooltip, }, { Key: opts.GetKey(opts.Config.Universal.GoInto), Handler: self.enter, GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.FileEnter, Tooltip: self.c.Tr.FileEnterTooltip, }, { Key: opts.GetKey(opts.Config.Universal.Remove), Handler: self.withItems(self.remove), GetDisabledReason: self.require(self.itemsSelected(self.canRemove)), Description: self.c.Tr.Discard, Tooltip: self.c.Tr.DiscardFileChangesTooltip, OpensMenu: true, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Commits.ViewResetOptions), Handler: self.createResetToUpstreamMenu, Description: self.c.Tr.ViewResetToUpstreamOptions, OpensMenu: true, }, { Key: opts.GetKey(opts.Config.Files.ViewResetOptions), Handler: self.createResetMenu, Description: self.c.Tr.Reset, Tooltip: self.c.Tr.FileResetOptionsTooltip, OpensMenu: true, DisplayOnScreen: true, }, { Key: opts.GetKey(opts.Config.Files.ToggleTreeView), Handler: self.toggleTreeView, Description: self.c.Tr.ToggleTreeView, Tooltip: self.c.Tr.ToggleTreeViewTooltip, }, { Key: opts.GetKey(opts.Config.Universal.OpenDiffTool), Handler: self.withItem(self.openDiffTool), GetDisabledReason: self.require(self.singleItemSelected()), Description: self.c.Tr.OpenDiffTool, }, { Key: opts.GetKey(opts.Config.Files.OpenMergeTool), Handler: self.c.Helpers().WorkingTree.OpenMergeTool, Description: self.c.Tr.OpenMergeTool, Tooltip: self.c.Tr.OpenMergeToolTooltip, }, { Key: opts.GetKey(opts.Config.Files.Fetch), Handler: self.fetch, Description: self.c.Tr.Fetch, Tooltip: self.c.Tr.FetchTooltip, }, } } func (self *FilesController) GetMouseKeybindings(opts types.KeybindingsOpts) []*gocui.ViewMouseBinding { return []*gocui.ViewMouseBinding{ { ViewName: "main", Key: gocui.MouseLeft, Handler: self.onClickMain, FocusedView: self.context().GetViewName(), }, { ViewName: "patchBuilding", Key: gocui.MouseLeft, Handler: self.onClickMain, FocusedView: self.context().GetViewName(), }, { ViewName: "mergeConflicts", Key: gocui.MouseLeft, Handler: self.onClickMain, FocusedView: self.context().GetViewName(), }, { ViewName: "secondary", Key: gocui.MouseLeft, Handler: self.onClickSecondary, FocusedView: self.context().GetViewName(), }, { ViewName: "patchBuildingSecondary", Key: gocui.MouseLeft, Handler: self.onClickSecondary, FocusedView: self.context().GetViewName(), }, } } func (self *FilesController) GetOnRenderToMain() func() error { return func() error { return self.c.Helpers().Diff.WithDiffModeCheck(func() error { node := self.context().GetSelected() if node == nil { return self.c.RenderToMainViews(types.RefreshMainOpts{ Pair: self.c.MainViewPairs().Normal, Main: &types.ViewUpdateOpts{ Title: self.c.Tr.DiffTitle, SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), Task: types.NewRenderStringTask(self.c.Tr.NoChangedFiles), }, }) } if node.File != nil && node.File.HasInlineMergeConflicts { hasConflicts, err := self.c.Helpers().MergeConflicts.SetMergeState(node.GetPath()) if err != nil { return err } if hasConflicts { return self.c.Helpers().MergeConflicts.Render() } } self.c.Helpers().MergeConflicts.ResetMergeState() pair := self.c.MainViewPairs().Normal if node.File != nil { pair = self.c.MainViewPairs().Staging } split := self.c.UserConfig.Gui.SplitDiff == "always" || (node.GetHasUnstagedChanges() && node.GetHasStagedChanges()) mainShowsStaged := !split && node.GetHasStagedChanges() cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, mainShowsStaged) title := self.c.Tr.UnstagedChanges if mainShowsStaged { title = self.c.Tr.StagedChanges } refreshOpts := types.RefreshMainOpts{ Pair: pair, Main: &types.ViewUpdateOpts{ Task: types.NewRunPtyTask(cmdObj.GetCmd()), SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), Title: title, }, } if split { cmdObj := self.c.Git().WorkingTree.WorktreeFileDiffCmdObj(node, false, true) title := self.c.Tr.StagedChanges if mainShowsStaged { title = self.c.Tr.UnstagedChanges } refreshOpts.Secondary = &types.ViewUpdateOpts{ Title: title, SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(), Task: types.NewRunPtyTask(cmdObj.GetCmd()), } } return self.c.RenderToMainViews(refreshOpts) }) } } func (self *FilesController) GetOnClick() func() error { return self.withItemGraceful(func(node *filetree.FileNode) error { return self.press([]*filetree.FileNode{node}) }) } // 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(nodes []*filetree.FileNode, optimisticChangeFn func(*models.File) bool) error { rerender := false for _, node := range nodes { 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.c.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.c.Contexts().Files); err != nil { return err } } return nil } func (self *FilesController) pressWithLock(selectedNodes []*filetree.FileNode) error { // Obtaining this lock because optimistic rendering requires us to mutate // the files in our model. self.c.Mutexes().RefreshingFilesMutex.Lock() defer self.c.Mutexes().RefreshingFilesMutex.Unlock() for _, node := range selectedNodes { // 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 node.GetHasInlineMergeConflicts() { return self.c.ErrorMsg(self.c.Tr.ErrStageDirWithInlineMergeConflicts) } } toPaths := func(nodes []*filetree.FileNode) []string { return lo.Map(nodes, func(node *filetree.FileNode, _ int) string { return node.Path }) } selectedNodes = normalisedSelectedNodes(selectedNodes) // If any node has unstaged changes, we'll stage all the selected nodes. Otherwise, // we unstage all the selected nodes. if someNodesHaveUnstagedChanges(selectedNodes) { self.c.LogAction(self.c.Tr.Actions.StageFile) if err := self.optimisticChange(selectedNodes, self.optimisticStage); err != nil { return err } if err := self.c.Git().WorkingTree.StageFiles(toPaths(selectedNodes)); err != nil { return self.c.Error(err) } } else { self.c.LogAction(self.c.Tr.Actions.UnstageFile) if err := self.optimisticChange(selectedNodes, self.optimisticUnstage); err != nil { return err } // need to partition the paths into tracked and untracked (where we assume directories are tracked). Then we'll run the commands separately. trackedNodes, untrackedNodes := utils.Partition(selectedNodes, func(node *filetree.FileNode) bool { // We treat all directories as tracked. I'm not actually sure why we do this but // it's been the existing behaviour for a while and nobody has complained return !node.IsFile() || node.GetIsTracked() }) if len(untrackedNodes) > 0 { if err := self.c.Git().WorkingTree.UnstageUntrackedFiles(toPaths(untrackedNodes)); err != nil { return self.c.Error(err) } } if len(trackedNodes) > 0 { if err := self.c.Git().WorkingTree.UnstageTrackedFiles(toPaths(trackedNodes)); err != nil { return self.c.Error(err) } } } return nil } func (self *FilesController) press(nodes []*filetree.FileNode) error { if err := self.pressWithLock(nodes); err != nil { return err } if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}); err != nil { return err } return self.context().HandleFocus(types.OnFocusOpts{}) } func (self *FilesController) Context() types.Context { return self.context() } func (self *FilesController) context() *context.WorkingTreeContext { return self.c.Contexts().Files } func (self *FilesController) getSelectedFile() *models.File { node := self.context().GetSelected() if node == nil { return nil } return node.File } func (self *FilesController) enter() error { return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "", ClickedViewLineIdx: -1}) } func (self *FilesController) EnterFile(opts types.OnFocusOpts) error { node := self.context().GetSelected() if node == nil { return nil } if node.File == nil { return self.handleToggleDirCollapsed() } file := node.File submoduleConfigs := self.c.Model().Submodules if file.IsSubmodule(submoduleConfigs) { submoduleConfig := file.SubmoduleConfig(submoduleConfigs) return self.c.Helpers().Repos.EnterSubmodule(submoduleConfig) } if file.HasInlineMergeConflicts { return self.switchToMerge() } if file.HasMergeConflicts { return self.c.ErrorMsg(self.c.Tr.FileStagingRequirements) } return self.c.PushContext(self.c.Contexts().Staging, opts) } func (self *FilesController) toggleStagedAll() error { if err := self.toggleStagedAllWithLock(); err != nil { return err } if err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}); err != nil { return err } return self.context().HandleFocus(types.OnFocusOpts{}) } func (self *FilesController) toggleStagedAllWithLock() error { self.c.Mutexes().RefreshingFilesMutex.Lock() defer self.c.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([]*filetree.FileNode{root}, self.optimisticStage); err != nil { return err } if err := self.c.Git().WorkingTree.StageAll(); err != nil { return self.c.Error(err) } } else { self.c.LogAction(self.c.Tr.Actions.UnstageAllFiles) if err := self.optimisticChange([]*filetree.FileNode{root}, self.optimisticUnstage); err != nil { return err } if err := self.c.Git().WorkingTree.UnstageAll(); err != nil { return self.c.Error(err) } } return nil } func (self *FilesController) unstageFiles(node *filetree.FileNode) error { return node.ForEachFile(func(file *models.File) error { if file.HasStagedChanges { if err := self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil { return err } } return nil }) } func (self *FilesController) ignoreOrExcludeTracked(node *filetree.FileNode, trAction string, f func(string) error) error { self.c.LogAction(trAction) // not 100% sure if this is necessary but I'll assume it is if err := self.unstageFiles(node); err != nil { return err } if err := self.c.Git().WorkingTree.RemoveTrackedFiles(node.GetPath()); err != nil { return err } if err := f(node.GetPath()); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) } func (self *FilesController) ignoreOrExcludeUntracked(node *filetree.FileNode, trAction string, f func(string) error) error { self.c.LogAction(trAction) if err := f(node.GetPath()); err != nil { return err } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) } func (self *FilesController) ignoreOrExcludeFile(node *filetree.FileNode, trText string, trPrompt string, trAction string, f func(string) error) error { if node.GetIsTracked() { return self.c.Confirm(types.ConfirmOpts{ Title: trText, Prompt: trPrompt, HandleConfirm: func() error { return self.ignoreOrExcludeTracked(node, trAction, f) }, }) } return self.ignoreOrExcludeUntracked(node, trAction, f) } func (self *FilesController) ignore(node *filetree.FileNode) error { if node.GetPath() == ".gitignore" { return self.c.ErrorMsg(self.c.Tr.Actions.IgnoreFileErr) } err := self.ignoreOrExcludeFile(node, self.c.Tr.IgnoreTracked, self.c.Tr.IgnoreTrackedPrompt, self.c.Tr.Actions.IgnoreExcludeFile, self.c.Git().WorkingTree.Ignore) if err != nil { return err } return nil } func (self *FilesController) exclude(node *filetree.FileNode) error { if node.GetPath() == ".git/info/exclude" { return self.c.ErrorMsg(self.c.Tr.Actions.ExcludeFileErr) } if node.GetPath() == ".gitignore" { return self.c.ErrorMsg(self.c.Tr.Actions.ExcludeGitIgnoreErr) } err := self.ignoreOrExcludeFile(node, self.c.Tr.ExcludeTracked, self.c.Tr.ExcludeTrackedPrompt, self.c.Tr.Actions.ExcludeFile, self.c.Git().WorkingTree.Exclude) if err != nil { return err } return nil } func (self *FilesController) ignoreOrExcludeMenu(node *filetree.FileNode) error { return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.Actions.IgnoreExcludeFile, Items: []*types.MenuItem{ { LabelColumns: []string{self.c.Tr.IgnoreFile}, OnPress: func() error { if err := self.ignore(node); err != nil { return self.c.Error(err) } return nil }, Key: 'i', }, { LabelColumns: []string{self.c.Tr.ExcludeFile}, OnPress: func() error { if err := self.exclude(node); err != nil { return self.c.Error(err) } return nil }, Key: 'e', }, }, }) } func (self *FilesController) refresh() error { return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}) } func (self *FilesController) handleAmendCommitPress() error { return self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.AmendLastCommitTitle, Prompt: self.c.Tr.SureToAmend, HandleConfirm: func() error { return self.c.Helpers().WorkingTree.WithEnsureCommitableFiles(func() error { if len(self.c.Model().Commits) == 0 { return self.c.ErrorMsg(self.c.Tr.NoCommitToAmend) } return self.c.Helpers().AmendHelper.AmendHead() }) }, }) } func (self *FilesController) handleStatusFilterPressed() error { return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.FilteringMenuTitle, Items: []*types.MenuItem{ { Label: self.c.Tr.FilterStagedFiles, OnPress: func() error { return self.setStatusFiltering(filetree.DisplayStaged) }, Key: 's', }, { Label: self.c.Tr.FilterUnstagedFiles, OnPress: func() error { return self.setStatusFiltering(filetree.DisplayUnstaged) }, Key: 'u', }, { Label: self.c.Tr.ResetFilter, OnPress: func() error { return self.setStatusFiltering(filetree.DisplayAll) }, Key: 'r', }, }, }) } func (self *FilesController) setStatusFiltering(filter filetree.FileTreeDisplayFilter) error { self.context().FileTreeViewModel.SetStatusFilter(filter) return self.c.PostRefreshUpdate(self.context()) } func (self *FilesController) edit(nodes []*filetree.FileNode) error { return self.c.Helpers().Files.EditFiles(lo.FilterMap(nodes, func(node *filetree.FileNode, _ int) (string, bool) { return node.GetPath(), node.IsFile() })) } func (self *FilesController) canEditFiles(nodes []*filetree.FileNode) *types.DisabledReason { if lo.NoneBy(nodes, func(node *filetree.FileNode) bool { return node.IsFile() }) { return &types.DisabledReason{ Text: self.c.Tr.ErrCannotEditDirectory, ShowErrorInPanel: true, } } return nil } func (self *FilesController) Open() error { node := self.context().GetSelected() if node == nil { return nil } return self.c.Helpers().Files.OpenFile(node.GetPath()) } func (self *FilesController) openDiffTool(node *filetree.FileNode) error { fromCommit := "" reverse := false if self.c.Modes().Diffing.Active() { fromCommit = self.c.Modes().Diffing.Ref reverse = self.c.Modes().Diffing.Reverse } return self.c.RunSubprocessAndRefresh( self.c.Git().Diff.OpenDiffToolCmdObj( git_commands.DiffToolCmdOptions{ Filepath: node.Path, FromCommit: fromCommit, ToCommit: "", Reverse: reverse, IsDirectory: !node.IsFile(), Staged: !node.GetHasUnstagedChanges(), }), ) } func (self *FilesController) switchToMerge() error { file := self.getSelectedFile() if file == nil { return nil } return self.c.Helpers().MergeConflicts.SwitchToMerge(file.Name) } func (self *FilesController) createStashMenu() error { return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.StashOptions, Items: []*types.MenuItem{ { Label: self.c.Tr.StashAllChanges, OnPress: func() error { if !self.c.Helpers().WorkingTree.IsWorkingTreeDirty() { return self.c.ErrorMsg(self.c.Tr.NoFilesToStash) } return self.handleStashSave(self.c.Git().Stash.Push, self.c.Tr.Actions.StashAllChanges) }, Key: 'a', }, { Label: self.c.Tr.StashAllChangesKeepIndex, OnPress: func() error { if !self.c.Helpers().WorkingTree.IsWorkingTreeDirty() { return self.c.ErrorMsg(self.c.Tr.NoFilesToStash) } // if there are no staged files it behaves the same as Stash.Save return self.handleStashSave(self.c.Git().Stash.StashAndKeepIndex, self.c.Tr.Actions.StashAllChangesKeepIndex) }, Key: 'i', }, { Label: self.c.Tr.StashIncludeUntrackedChanges, OnPress: func() error { return self.handleStashSave(self.c.Git().Stash.StashIncludeUntrackedChanges, self.c.Tr.Actions.StashIncludeUntrackedChanges) }, Key: 'U', }, { Label: self.c.Tr.StashStagedChanges, OnPress: func() error { // there must be something in staging otherwise the current implementation mucks the stash up if !self.c.Helpers().WorkingTree.AnyStagedFiles() { return self.c.ErrorMsg(self.c.Tr.NoTrackedStagedFilesStash) } return self.handleStashSave(self.c.Git().Stash.SaveStagedChanges, self.c.Tr.Actions.StashStagedChanges) }, Key: 's', }, { Label: self.c.Tr.StashUnstagedChanges, OnPress: func() error { if !self.c.Helpers().WorkingTree.IsWorkingTreeDirty() { return self.c.ErrorMsg(self.c.Tr.NoFilesToStash) } if self.c.Helpers().WorkingTree.AnyStagedFiles() { return self.handleStashSave(self.c.Git().Stash.StashUnstagedChanges, self.c.Tr.Actions.StashUnstagedChanges) } // ordinary stash return self.handleStashSave(self.c.Git().Stash.Push, self.c.Tr.Actions.StashUnstagedChanges) }, Key: 'u', }, }, }) } func (self *FilesController) openCopyMenu() error { node := self.context().GetSelected() copyNameItem := &types.MenuItem{ Label: self.c.Tr.CopyFileName, OnPress: func() error { if err := self.c.OS().CopyToClipboard(node.Name()); err != nil { return self.c.Error(err) } self.c.Toast(self.c.Tr.FileNameCopiedToast) return nil }, DisabledReason: self.require(self.singleItemSelected())(), Key: 'n', } copyPathItem := &types.MenuItem{ Label: self.c.Tr.CopyFilePath, OnPress: func() error { if err := self.c.OS().CopyToClipboard(node.Path); err != nil { return self.c.Error(err) } self.c.Toast(self.c.Tr.FilePathCopiedToast) return nil }, DisabledReason: self.require(self.singleItemSelected())(), Key: 'p', } copyFileDiffItem := &types.MenuItem{ Label: self.c.Tr.CopySelectedDiff, Tooltip: self.c.Tr.CopyFileDiffTooltip, OnPress: func() error { path := self.context().GetSelectedPath() hasStaged := self.hasPathStagedChanges(node) diff, err := self.c.Git().Diff.GetPathDiff(path, hasStaged) if err != nil { return self.c.Error(err) } if err := self.c.OS().CopyToClipboard(diff); err != nil { return self.c.Error(err) } self.c.Toast(self.c.Tr.FileDiffCopiedToast) return nil }, DisabledReason: self.require(self.singleItemSelected( func(file *filetree.FileNode) *types.DisabledReason { if !node.GetHasStagedOrTrackedChanges() { return &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError} } return nil }, ))(), Key: 's', } copyAllDiff := &types.MenuItem{ Label: self.c.Tr.CopyAllFilesDiff, Tooltip: self.c.Tr.CopyFileDiffTooltip, OnPress: func() error { hasStaged := self.c.Helpers().WorkingTree.AnyStagedFiles() diff, err := self.c.Git().Diff.GetAllDiff(hasStaged) if err != nil { return self.c.Error(err) } if err := self.c.OS().CopyToClipboard(diff); err != nil { return self.c.Error(err) } self.c.Toast(self.c.Tr.AllFilesDiffCopiedToast) return nil }, DisabledReason: self.require( func() *types.DisabledReason { if !self.anyStagedOrTrackedFile() { return &types.DisabledReason{Text: self.c.Tr.NoContentToCopyError} } return nil }, )(), Key: 'a', } return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.CopyToClipboardMenu, Items: []*types.MenuItem{ copyNameItem, copyPathItem, copyFileDiffItem, copyAllDiff, }, }) } func (self *FilesController) anyStagedOrTrackedFile() bool { if !self.c.Helpers().WorkingTree.AnyStagedFiles() { return self.c.Helpers().WorkingTree.AnyTrackedFiles() } return true } func (self *FilesController) hasPathStagedChanges(node *filetree.FileNode) bool { return node.SomeFile(func(t *models.File) bool { return t.HasStagedChanges }) } func (self *FilesController) stash() error { return self.handleStashSave(self.c.Git().Stash.Push, self.c.Tr.Actions.StashAllChanges) } func (self *FilesController) createResetToUpstreamMenu() error { return self.c.Helpers().Refs.CreateGitResetMenu("@{upstream}") } func (self *FilesController) handleToggleDirCollapsed() error { node := self.context().GetSelected() if node == nil { return nil } self.context().FileTreeViewModel.ToggleCollapsed(node.GetPath()) if err := self.c.PostRefreshUpdate(self.c.Contexts().Files); err != nil { self.c.Log.Error(err) } return nil } func (self *FilesController) toggleTreeView() error { self.context().FileTreeViewModel.ToggleShowTree() return self.c.PostRefreshUpdate(self.context()) } func (self *FilesController) handleStashSave(stashFunc func(message string) error, action string) error { return self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.StashChanges, HandleConfirm: func(stashComment string) error { self.c.LogAction(action) if err := stashFunc(stashComment); err != nil { return self.c.Error(err) } return self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}}) }, }) } func (self *FilesController) onClickMain(opts gocui.ViewMouseBindingOpts) error { return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "main", ClickedViewLineIdx: opts.Y}) } func (self *FilesController) onClickSecondary(opts gocui.ViewMouseBindingOpts) error { return self.EnterFile(types.OnFocusOpts{ClickedWindowName: "secondary", ClickedViewLineIdx: opts.Y}) } func (self *FilesController) fetch() error { return self.c.WithWaitingStatus(self.c.Tr.FetchingStatus, func(task gocui.Task) error { if err := self.fetchAux(task); err != nil { _ = self.c.Error(err) } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC}) }) } func (self *FilesController) fetchAux(task gocui.Task) (err error) { self.c.LogAction("Fetch") err = self.c.Git().Sync.Fetch(task) if err != nil && strings.Contains(err.Error(), "exit status 128") { _ = self.c.ErrorMsg(self.c.Tr.PassUnameWrong) } _ = self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.BRANCHES, types.COMMITS, types.REMOTES, types.TAGS}, Mode: types.ASYNC}) return err } // Couldn't think of a better term than 'normalised'. Alas. // The idea is that when you select a range of nodes, you will often have both // a node and its parent node selected. If we are trying to discard changes to the // selected nodes, we'll get an error if we try to discard the child after the parent. // So we just need to filter out any nodes from the selection that are descendants // of other nodes func normalisedSelectedNodes(selectedNodes []*filetree.FileNode) []*filetree.FileNode { return lo.Filter(selectedNodes, func(node *filetree.FileNode, _ int) bool { return !isDescendentOfSelectedNodes(node, selectedNodes) }) } func isDescendentOfSelectedNodes(node *filetree.FileNode, selectedNodes []*filetree.FileNode) bool { for _, selectedNode := range selectedNodes { selectedNodePath := selectedNode.GetPath() nodePath := node.GetPath() if strings.HasPrefix(nodePath, selectedNodePath) && nodePath != selectedNodePath { return true } } return false } func someNodesHaveUnstagedChanges(nodes []*filetree.FileNode) bool { return lo.SomeBy(nodes, (*filetree.FileNode).GetHasUnstagedChanges) } func someNodesHaveStagedChanges(nodes []*filetree.FileNode) bool { return lo.SomeBy(nodes, (*filetree.FileNode).GetHasStagedChanges) } func (self *FilesController) canRemove(selectedNodes []*filetree.FileNode) *types.DisabledReason { submodules := self.c.Model().Submodules submoduleCount := lo.CountBy(selectedNodes, func(node *filetree.FileNode) bool { return node.File != nil && node.File.IsSubmodule(submodules) }) if submoduleCount > 0 && len(selectedNodes) > 1 { return &types.DisabledReason{Text: self.c.Tr.RangeSelectNotSupportedForSubmodules} } return nil } func (self *FilesController) remove(selectedNodes []*filetree.FileNode) error { submodules := self.c.Model().Submodules // If we have one submodule then we must only have one submodule or `canRemove` would have // returned an error firstNode := selectedNodes[0] if firstNode.File != nil && firstNode.File.IsSubmodule(submodules) { submodule := firstNode.File.SubmoduleConfig(submodules) menuItems := []*types.MenuItem{ { Label: self.c.Tr.SubmoduleStashAndReset, OnPress: func() error { return self.ResetSubmodule(submodule) }, }, } return self.c.Menu(types.CreateMenuOptions{Title: firstNode.GetPath(), Items: menuItems}) } selectedNodes = normalisedSelectedNodes(selectedNodes) menuItems := []*types.MenuItem{ { Label: self.c.Tr.DiscardAllChanges, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.DiscardAllChangesInFile) if self.context().IsSelectingRange() { defer self.context().CancelRangeSelect() } for _, node := range selectedNodes { if err := self.c.Git().WorkingTree.DiscardAllDirChanges(node); err != nil { return self.c.Error(err) } } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) }, Key: self.c.KeybindingsOpts().GetKey(self.c.UserConfig.Keybinding.Files.ConfirmDiscard), Tooltip: utils.ResolvePlaceholderString( self.c.Tr.DiscardAllTooltip, map[string]string{ "path": self.formattedPaths(selectedNodes), }, ), }, } if someNodesHaveStagedChanges(selectedNodes) && someNodesHaveUnstagedChanges(selectedNodes) { menuItems = append(menuItems, &types.MenuItem{ Label: self.c.Tr.DiscardUnstagedChanges, OnPress: func() error { self.c.LogAction(self.c.Tr.Actions.DiscardAllUnstagedChangesInFile) if self.context().IsSelectingRange() { defer self.context().CancelRangeSelect() } for _, node := range selectedNodes { if err := self.c.Git().WorkingTree.DiscardUnstagedDirChanges(node); err != nil { return self.c.Error(err) } } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.WORKTREES}}) }, Key: 'u', Tooltip: utils.ResolvePlaceholderString( self.c.Tr.DiscardUnstagedTooltip, map[string]string{ "path": self.formattedPaths(selectedNodes), }, ), }) } return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.DiscardChangesTitle, Items: menuItems}) } func (self *FilesController) ResetSubmodule(submodule *models.SubmoduleConfig) error { return self.c.WithWaitingStatus(self.c.Tr.ResettingSubmoduleStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.ResetSubmodule) file := self.c.Helpers().WorkingTree.FileForSubmodule(submodule) if file != nil { if err := self.c.Git().WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil { return self.c.Error(err) } } if err := self.c.Git().Submodule.Stash(submodule); err != nil { return self.c.Error(err) } if err := self.c.Git().Submodule.Reset(submodule); err != nil { return self.c.Error(err) } return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.FILES, types.SUBMODULES}}) }) } func (self *FilesController) formattedPaths(nodes []*filetree.FileNode) string { return utils.FormatPaths(lo.Map(nodes, func(node *filetree.FileNode, _ int) string { return node.GetPath() })) }