mirror of
https://github.com/jesseduffield/lazygit.git
synced 2024-12-12 11:15:00 +02:00
1066 lines
28 KiB
Go
1066 lines
28 KiB
Go
package gui
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/loaders"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/types/enums"
|
|
"github.com/jesseduffield/lazygit/pkg/config"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/filetree"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
)
|
|
|
|
// list panel functions
|
|
|
|
func (gui *Gui) getSelectedFileNode() *filetree.FileNode {
|
|
selectedLine := gui.State.Panels.Files.SelectedLineIdx
|
|
if selectedLine == -1 {
|
|
return nil
|
|
}
|
|
|
|
return gui.State.FileTreeViewModel.GetItemAtIndex(selectedLine)
|
|
}
|
|
|
|
func (gui *Gui) getSelectedFile() *models.File {
|
|
node := gui.getSelectedFileNode()
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
return node.File
|
|
}
|
|
|
|
func (gui *Gui) getSelectedPath() string {
|
|
node := gui.getSelectedFileNode()
|
|
if node == nil {
|
|
return ""
|
|
}
|
|
|
|
return node.GetPath()
|
|
}
|
|
|
|
func (gui *Gui) filesRenderToMain() error {
|
|
node := gui.getSelectedFileNode()
|
|
|
|
if node == nil {
|
|
return gui.refreshMainViews(refreshMainOpts{
|
|
main: &viewUpdateOpts{
|
|
title: "",
|
|
task: NewRenderStringTask(gui.Tr.NoChangedFiles),
|
|
},
|
|
})
|
|
}
|
|
|
|
if node.File != nil && node.File.HasInlineMergeConflicts {
|
|
ok, err := gui.setConflictsAndRenderWithLock(node.GetPath(), false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if ok {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
gui.resetMergeStateWithLock()
|
|
|
|
cmdObj := gui.Git.WorkingTree.WorktreeFileDiffCmdObj(node, false, !node.GetHasUnstagedChanges() && node.GetHasStagedChanges(), gui.State.IgnoreWhitespaceInDiffView)
|
|
|
|
refreshOpts := refreshMainOpts{main: &viewUpdateOpts{
|
|
title: gui.Tr.UnstagedChanges,
|
|
task: NewRunPtyTask(cmdObj.GetCmd()),
|
|
}}
|
|
|
|
if node.GetHasUnstagedChanges() {
|
|
if node.GetHasStagedChanges() {
|
|
cmdObj := gui.Git.WorkingTree.WorktreeFileDiffCmdObj(node, false, true, gui.State.IgnoreWhitespaceInDiffView)
|
|
|
|
refreshOpts.secondary = &viewUpdateOpts{
|
|
title: gui.Tr.StagedChanges,
|
|
task: NewRunPtyTask(cmdObj.GetCmd()),
|
|
}
|
|
}
|
|
} else {
|
|
refreshOpts.main.title = gui.Tr.StagedChanges
|
|
}
|
|
|
|
return gui.refreshMainViews(refreshOpts)
|
|
}
|
|
|
|
func (gui *Gui) refreshFilesAndSubmodules() error {
|
|
gui.Mutexes.RefreshingFilesMutex.Lock()
|
|
gui.State.IsRefreshingFiles = true
|
|
defer func() {
|
|
gui.State.IsRefreshingFiles = false
|
|
gui.Mutexes.RefreshingFilesMutex.Unlock()
|
|
}()
|
|
|
|
prevSelectedPath := gui.getSelectedPath()
|
|
|
|
if err := gui.refreshStateSubmoduleConfigs(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := gui.refreshMergeState(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := gui.refreshStateFiles(); err != nil {
|
|
return err
|
|
}
|
|
|
|
gui.OnUIThread(func() error {
|
|
if err := gui.postRefreshUpdate(gui.State.Contexts.Submodules); err != nil {
|
|
gui.Log.Error(err)
|
|
}
|
|
|
|
if ContextKey(gui.Views.Files.Context) == FILES_CONTEXT_KEY {
|
|
// doing this a little custom (as opposed to using gui.postRefreshUpdate) because we handle selecting the file explicitly below
|
|
if err := gui.State.Contexts.Files.HandleRender(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if gui.currentContext().GetKey() == FILES_CONTEXT_KEY {
|
|
currentSelectedPath := gui.getSelectedPath()
|
|
alreadySelected := prevSelectedPath != "" && currentSelectedPath == prevSelectedPath
|
|
if !alreadySelected {
|
|
gui.takeOverMergeConflictScrolling()
|
|
}
|
|
|
|
gui.Views.Files.FocusPoint(0, gui.State.Panels.Files.SelectedLineIdx)
|
|
return gui.filesRenderToMain()
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// specific functions
|
|
|
|
func (gui *Gui) stagedFiles() []*models.File {
|
|
files := gui.State.FileTreeViewModel.GetAllFiles()
|
|
result := make([]*models.File, 0)
|
|
for _, file := range files {
|
|
if file.HasStagedChanges {
|
|
result = append(result, file)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (gui *Gui) trackedFiles() []*models.File {
|
|
files := gui.State.FileTreeViewModel.GetAllFiles()
|
|
result := make([]*models.File, 0, len(files))
|
|
for _, file := range files {
|
|
if file.Tracked {
|
|
result = append(result, file)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (gui *Gui) handleEnterFile() error {
|
|
return gui.enterFile(OnFocusOpts{ClickedViewName: "", ClickedViewLineIdx: -1})
|
|
}
|
|
|
|
func (gui *Gui) enterFile(opts OnFocusOpts) error {
|
|
node := gui.getSelectedFileNode()
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
|
|
if node.File == nil {
|
|
return gui.handleToggleDirCollapsed()
|
|
}
|
|
|
|
file := node.File
|
|
|
|
submoduleConfigs := gui.State.Submodules
|
|
if file.IsSubmodule(submoduleConfigs) {
|
|
submoduleConfig := file.SubmoduleConfig(submoduleConfigs)
|
|
return gui.enterSubmodule(submoduleConfig)
|
|
}
|
|
|
|
if file.HasInlineMergeConflicts {
|
|
return gui.switchToMerge()
|
|
}
|
|
if file.HasMergeConflicts {
|
|
return gui.createErrorPanel(gui.Tr.FileStagingRequirements)
|
|
}
|
|
|
|
return gui.pushContext(gui.State.Contexts.Staging, opts)
|
|
}
|
|
|
|
func (gui *Gui) handleFilePress() error {
|
|
node := gui.getSelectedFileNode()
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
|
|
if node.IsLeaf() {
|
|
file := node.File
|
|
|
|
if file.HasInlineMergeConflicts {
|
|
return gui.switchToMerge()
|
|
}
|
|
|
|
if file.HasUnstagedChanges {
|
|
gui.logAction(gui.Tr.Actions.StageFile)
|
|
if err := gui.Git.WorkingTree.StageFile(file.Name); err != nil {
|
|
return gui.surfaceError(err)
|
|
}
|
|
} else {
|
|
gui.logAction(gui.Tr.Actions.UnstageFile)
|
|
if err := gui.Git.WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil {
|
|
return gui.surfaceError(err)
|
|
}
|
|
}
|
|
} else {
|
|
// 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 gui.createErrorPanel(gui.Tr.ErrStageDirWithInlineMergeConflicts)
|
|
}
|
|
|
|
if node.GetHasUnstagedChanges() {
|
|
gui.logAction(gui.Tr.Actions.StageFile)
|
|
if err := gui.Git.WorkingTree.StageFile(node.Path); err != nil {
|
|
return gui.surfaceError(err)
|
|
}
|
|
} else {
|
|
// pretty sure it doesn't matter that we're always passing true here
|
|
gui.logAction(gui.Tr.Actions.UnstageFile)
|
|
if err := gui.Git.WorkingTree.UnStageFile([]string{node.Path}, true); err != nil {
|
|
return gui.surfaceError(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}}); err != nil {
|
|
return err
|
|
}
|
|
|
|
return gui.State.Contexts.Files.HandleFocus()
|
|
}
|
|
|
|
func (gui *Gui) allFilesStaged() bool {
|
|
for _, file := range gui.State.FileTreeViewModel.GetAllFiles() {
|
|
if file.HasUnstagedChanges {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (gui *Gui) onFocusFile() error {
|
|
gui.takeOverMergeConflictScrolling()
|
|
return nil
|
|
}
|
|
|
|
func (gui *Gui) handleStageAll() error {
|
|
var err error
|
|
if gui.allFilesStaged() {
|
|
gui.logAction(gui.Tr.Actions.UnstageAllFiles)
|
|
err = gui.Git.WorkingTree.UnstageAll()
|
|
} else {
|
|
gui.logAction(gui.Tr.Actions.StageAllFiles)
|
|
err = gui.Git.WorkingTree.StageAll()
|
|
}
|
|
if err != nil {
|
|
_ = gui.surfaceError(err)
|
|
}
|
|
|
|
if err := gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}}); err != nil {
|
|
return err
|
|
}
|
|
|
|
return gui.State.Contexts.Files.HandleFocus()
|
|
}
|
|
|
|
func (gui *Gui) handleIgnoreFile() error {
|
|
node := gui.getSelectedFileNode()
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
|
|
if node.GetPath() == ".gitignore" {
|
|
return gui.createErrorPanel("Cannot ignore .gitignore")
|
|
}
|
|
|
|
unstageFiles := func() error {
|
|
return node.ForEachFile(func(file *models.File) error {
|
|
if file.HasStagedChanges {
|
|
if err := gui.Git.WorkingTree.UnStageFile(file.Names(), file.Tracked); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
}
|
|
|
|
if node.GetIsTracked() {
|
|
return gui.ask(askOpts{
|
|
title: gui.Tr.IgnoreTracked,
|
|
prompt: gui.Tr.IgnoreTrackedPrompt,
|
|
handleConfirm: func() error {
|
|
gui.logAction(gui.Tr.Actions.IgnoreFile)
|
|
// not 100% sure if this is necessary but I'll assume it is
|
|
if err := unstageFiles(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := gui.Git.WorkingTree.RemoveTrackedFiles(node.GetPath()); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := gui.Git.WorkingTree.Ignore(node.GetPath()); err != nil {
|
|
return err
|
|
}
|
|
return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}})
|
|
},
|
|
})
|
|
}
|
|
|
|
gui.logAction(gui.Tr.Actions.IgnoreFile)
|
|
|
|
if err := unstageFiles(); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := gui.Git.WorkingTree.Ignore(node.GetPath()); err != nil {
|
|
return gui.surfaceError(err)
|
|
}
|
|
|
|
return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}})
|
|
}
|
|
|
|
func (gui *Gui) handleWIPCommitPress() error {
|
|
skipHookPrefix := gui.UserConfig.Git.SkipHookPrefix
|
|
if skipHookPrefix == "" {
|
|
return gui.createErrorPanel(gui.Tr.SkipHookPrefixNotConfigured)
|
|
}
|
|
|
|
textArea := gui.Views.CommitMessage.TextArea
|
|
textArea.Clear()
|
|
textArea.TypeString(skipHookPrefix)
|
|
gui.Views.CommitMessage.RenderTextArea()
|
|
|
|
return gui.handleCommitPress()
|
|
}
|
|
|
|
func (gui *Gui) commitPrefixConfigForRepo() *config.CommitPrefixConfig {
|
|
cfg, ok := gui.UserConfig.Git.CommitPrefixes[utils.GetCurrentRepoName()]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
return &cfg
|
|
}
|
|
|
|
func (gui *Gui) prepareFilesForCommit() error {
|
|
noStagedFiles := len(gui.stagedFiles()) == 0
|
|
if noStagedFiles && gui.UserConfig.Gui.SkipNoStagedFilesWarning {
|
|
gui.logAction(gui.Tr.Actions.StageAllFiles)
|
|
err := gui.Git.WorkingTree.StageAll()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return gui.refreshFilesAndSubmodules()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (gui *Gui) handleCommitPress() error {
|
|
if err := gui.prepareFilesForCommit(); err != nil {
|
|
return gui.surfaceError(err)
|
|
}
|
|
|
|
if gui.State.FileTreeViewModel.GetItemsLength() == 0 {
|
|
return gui.createErrorPanel(gui.Tr.NoFilesStagedTitle)
|
|
}
|
|
|
|
if len(gui.stagedFiles()) == 0 {
|
|
return gui.promptToStageAllAndRetry(gui.handleCommitPress)
|
|
}
|
|
|
|
if len(gui.State.failedCommitMessage) > 0 {
|
|
gui.Views.CommitMessage.ClearTextArea()
|
|
gui.Views.CommitMessage.TextArea.TypeString(gui.State.failedCommitMessage)
|
|
gui.Views.CommitMessage.RenderTextArea()
|
|
} else {
|
|
commitPrefixConfig := gui.commitPrefixConfigForRepo()
|
|
if commitPrefixConfig != nil {
|
|
prefixPattern := commitPrefixConfig.Pattern
|
|
prefixReplace := commitPrefixConfig.Replace
|
|
rgx, err := regexp.Compile(prefixPattern)
|
|
if err != nil {
|
|
return gui.createErrorPanel(fmt.Sprintf("%s: %s", gui.Tr.LcCommitPrefixPatternError, err.Error()))
|
|
}
|
|
prefix := rgx.ReplaceAllString(gui.getCheckedOutBranch().Name, prefixReplace)
|
|
gui.Views.CommitMessage.ClearTextArea()
|
|
gui.Views.CommitMessage.TextArea.TypeString(prefix)
|
|
gui.Views.CommitMessage.RenderTextArea()
|
|
}
|
|
}
|
|
|
|
if err := gui.pushContext(gui.State.Contexts.CommitMessage); err != nil {
|
|
return err
|
|
}
|
|
|
|
gui.RenderCommitLength()
|
|
return nil
|
|
}
|
|
|
|
func (gui *Gui) promptToStageAllAndRetry(retry func() error) error {
|
|
return gui.ask(askOpts{
|
|
title: gui.Tr.NoFilesStagedTitle,
|
|
prompt: gui.Tr.NoFilesStagedPrompt,
|
|
handleConfirm: func() error {
|
|
gui.logAction(gui.Tr.Actions.StageAllFiles)
|
|
if err := gui.Git.WorkingTree.StageAll(); err != nil {
|
|
return gui.surfaceError(err)
|
|
}
|
|
if err := gui.refreshFilesAndSubmodules(); err != nil {
|
|
return gui.surfaceError(err)
|
|
}
|
|
|
|
return retry()
|
|
},
|
|
})
|
|
}
|
|
|
|
func (gui *Gui) handleAmendCommitPress() error {
|
|
if gui.State.FileTreeViewModel.GetItemsLength() == 0 {
|
|
return gui.createErrorPanel(gui.Tr.NoFilesStagedTitle)
|
|
}
|
|
|
|
if len(gui.stagedFiles()) == 0 {
|
|
return gui.promptToStageAllAndRetry(gui.handleAmendCommitPress)
|
|
}
|
|
|
|
if len(gui.State.Commits) == 0 {
|
|
return gui.createErrorPanel(gui.Tr.NoCommitToAmend)
|
|
}
|
|
|
|
return gui.ask(askOpts{
|
|
title: strings.Title(gui.Tr.AmendLastCommit),
|
|
prompt: gui.Tr.SureToAmend,
|
|
handleConfirm: func() error {
|
|
cmdObj := gui.Git.Commit.AmendHeadCmdObj()
|
|
gui.logAction(gui.Tr.Actions.AmendCommit)
|
|
return gui.withGpgHandling(cmdObj, gui.Tr.AmendingStatus, nil)
|
|
},
|
|
})
|
|
}
|
|
|
|
// handleCommitEditorPress - handle when the user wants to commit changes via
|
|
// their editor rather than via the popup panel
|
|
func (gui *Gui) handleCommitEditorPress() error {
|
|
if gui.State.FileTreeViewModel.GetItemsLength() == 0 {
|
|
return gui.createErrorPanel(gui.Tr.NoFilesStagedTitle)
|
|
}
|
|
|
|
if len(gui.stagedFiles()) == 0 {
|
|
return gui.promptToStageAllAndRetry(gui.handleCommitEditorPress)
|
|
}
|
|
|
|
gui.logAction(gui.Tr.Actions.Commit)
|
|
return gui.runSubprocessWithSuspenseAndRefresh(
|
|
gui.Git.Commit.CommitEditorCmdObj(),
|
|
)
|
|
}
|
|
|
|
func (gui *Gui) handleStatusFilterPressed() error {
|
|
menuItems := []*menuItem{
|
|
{
|
|
displayString: gui.Tr.FilterStagedFiles,
|
|
onPress: func() error {
|
|
return gui.setStatusFiltering(filetree.DisplayStaged)
|
|
},
|
|
},
|
|
{
|
|
displayString: gui.Tr.FilterUnstagedFiles,
|
|
onPress: func() error {
|
|
return gui.setStatusFiltering(filetree.DisplayUnstaged)
|
|
},
|
|
},
|
|
{
|
|
displayString: gui.Tr.ResetCommitFilterState,
|
|
onPress: func() error {
|
|
return gui.setStatusFiltering(filetree.DisplayAll)
|
|
},
|
|
},
|
|
}
|
|
|
|
return gui.createMenu(gui.Tr.FilteringMenuTitle, menuItems, createMenuOptions{showCancel: true})
|
|
}
|
|
|
|
func (gui *Gui) setStatusFiltering(filter filetree.FileTreeDisplayFilter) error {
|
|
state := gui.State
|
|
state.FileTreeViewModel.SetFilter(filter)
|
|
return gui.handleRefreshFiles()
|
|
}
|
|
|
|
func (gui *Gui) editFile(filename string) error {
|
|
return gui.editFileAtLine(filename, 1)
|
|
}
|
|
|
|
func (gui *Gui) editFileAtLine(filename string, lineNumber int) error {
|
|
cmdStr, err := gui.Git.File.GetEditCmdStr(filename, lineNumber)
|
|
if err != nil {
|
|
return gui.surfaceError(err)
|
|
}
|
|
|
|
gui.logAction(gui.Tr.Actions.EditFile)
|
|
return gui.runSubprocessWithSuspenseAndRefresh(
|
|
gui.OSCommand.Cmd.NewShell(cmdStr),
|
|
)
|
|
}
|
|
|
|
func (gui *Gui) handleFileEdit() error {
|
|
node := gui.getSelectedFileNode()
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
|
|
if node.File == nil {
|
|
return gui.createErrorPanel(gui.Tr.ErrCannotEditDirectory)
|
|
}
|
|
|
|
return gui.editFile(node.GetPath())
|
|
}
|
|
|
|
func (gui *Gui) handleFileOpen() error {
|
|
node := gui.getSelectedFileNode()
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
|
|
return gui.openFile(node.GetPath())
|
|
}
|
|
|
|
func (gui *Gui) handleRefreshFiles() error {
|
|
return gui.refreshSidePanels(refreshOptions{scope: []RefreshableView{FILES}})
|
|
}
|
|
|
|
func (gui *Gui) refreshStateFiles() error {
|
|
state := gui.State
|
|
|
|
// keep track of where the cursor is currently and the current file names
|
|
// when we refresh, go looking for a matching name
|
|
// move the cursor to there.
|
|
|
|
selectedNode := gui.getSelectedFileNode()
|
|
|
|
prevNodes := gui.State.FileTreeViewModel.GetAllItems()
|
|
prevSelectedLineIdx := gui.State.Panels.Files.SelectedLineIdx
|
|
|
|
// If git thinks any of our files have inline merge conflicts, but they actually don't,
|
|
// we stage them.
|
|
// Note that if files with merge conflicts have both arisen and have been resolved
|
|
// between refreshes, we won't stage them here. This is super unlikely though,
|
|
// and this approach spares us from having to call `git status` twice in a row.
|
|
// Although this also means that at startup we won't be staging anything until
|
|
// we call git status again.
|
|
pathsToStage := []string{}
|
|
prevConflictFileCount := 0
|
|
for _, file := range state.FileTreeViewModel.GetAllFiles() {
|
|
if file.HasMergeConflicts {
|
|
prevConflictFileCount++
|
|
}
|
|
if file.HasInlineMergeConflicts {
|
|
hasConflicts, err := mergeconflicts.FileHasConflictMarkers(file.Name)
|
|
if err != nil {
|
|
gui.Log.Error(err)
|
|
} else if !hasConflicts {
|
|
pathsToStage = append(pathsToStage, file.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(pathsToStage) > 0 {
|
|
gui.logAction(gui.Tr.Actions.StageResolvedFiles)
|
|
if err := gui.Git.WorkingTree.StageFiles(pathsToStage); err != nil {
|
|
return gui.surfaceError(err)
|
|
}
|
|
}
|
|
|
|
files := gui.Git.Loaders.Files.
|
|
GetStatusFiles(loaders.GetStatusFileOptions{})
|
|
|
|
conflictFileCount := 0
|
|
for _, file := range files {
|
|
if file.HasMergeConflicts {
|
|
conflictFileCount++
|
|
}
|
|
}
|
|
|
|
if gui.Git.Status.WorkingTreeState() != enums.REBASE_MODE_NONE && conflictFileCount == 0 && prevConflictFileCount > 0 {
|
|
gui.OnUIThread(func() error { return gui.promptToContinueRebase() })
|
|
}
|
|
|
|
// for when you stage the old file of a rename and the new file is in a collapsed dir
|
|
state.FileTreeViewModel.RWMutex.Lock()
|
|
for _, file := range files {
|
|
if selectedNode != nil && selectedNode.Path != "" && file.PreviousName == selectedNode.Path {
|
|
state.FileTreeViewModel.ExpandToPath(file.Name)
|
|
}
|
|
}
|
|
|
|
// only taking over the filter if it hasn't already been set by the user.
|
|
// Though this does make it impossible for the user to actually say they want to display all if
|
|
// conflicts are currently being shown. Hmm. Worth it I reckon. If we need to add some
|
|
// extra state here to see if the user's set the filter themselves we can do that, but
|
|
// I'd prefer to maintain as little state as possible.
|
|
if conflictFileCount > 0 {
|
|
if state.FileTreeViewModel.GetFilter() == filetree.DisplayAll {
|
|
state.FileTreeViewModel.SetFilter(filetree.DisplayConflicted)
|
|
}
|
|
} else if state.FileTreeViewModel.GetFilter() == filetree.DisplayConflicted {
|
|
state.FileTreeViewModel.SetFilter(filetree.DisplayAll)
|
|
}
|
|
|
|
state.FileTreeViewModel.SetFiles(files)
|
|
state.FileTreeViewModel.RWMutex.Unlock()
|
|
|
|
if err := gui.fileWatcher.addFilesToFileWatcher(files); err != nil {
|
|
return err
|
|
}
|
|
|
|
if selectedNode != nil {
|
|
newIdx := gui.findNewSelectedIdx(prevNodes[prevSelectedLineIdx:], state.FileTreeViewModel.GetAllItems())
|
|
if newIdx != -1 && newIdx != prevSelectedLineIdx {
|
|
newNode := state.FileTreeViewModel.GetItemAtIndex(newIdx)
|
|
// when not in tree mode, we show merge conflict files at the top, so you
|
|
// can work through them one by one without having to sift through a large
|
|
// set of files. If you have just fixed the merge conflicts of a file, we
|
|
// actually don't want to jump to that file's new position, because that
|
|
// file will now be ages away amidst the other files without merge
|
|
// conflicts: the user in this case would rather work on the next file
|
|
// with merge conflicts, which will have moved up to fill the gap left by
|
|
// the last file, meaning the cursor doesn't need to move at all.
|
|
leaveCursor := !state.FileTreeViewModel.InTreeMode() && newNode != nil &&
|
|
selectedNode.File != nil && selectedNode.File.HasMergeConflicts &&
|
|
newNode.File != nil && !newNode.File.HasMergeConflicts
|
|
|
|
if !leaveCursor {
|
|
state.Panels.Files.SelectedLineIdx = newIdx
|
|
}
|
|
}
|
|
}
|
|
|
|
gui.refreshSelectedLine(state.Panels.Files, state.FileTreeViewModel.GetItemsLength())
|
|
return nil
|
|
}
|
|
|
|
// promptToContinueRebase asks the user if they want to continue the rebase/merge that's in progress
|
|
func (gui *Gui) promptToContinueRebase() error {
|
|
gui.takeOverMergeConflictScrolling()
|
|
|
|
return gui.ask(askOpts{
|
|
title: "continue",
|
|
prompt: gui.Tr.ConflictsResolved,
|
|
handleConfirm: func() error {
|
|
return gui.genericMergeCommand(REBASE_OPTION_CONTINUE)
|
|
},
|
|
})
|
|
}
|
|
|
|
// Let's try to find our file again and move the cursor to that.
|
|
// If we can't find our file, it was probably just removed by the user. In that
|
|
// case, we go looking for where the next file has been moved to. Given that the
|
|
// user could have removed a whole directory, we continue iterating through the old
|
|
// nodes until we find one that exists in the new set of nodes, then move the cursor
|
|
// to that.
|
|
// prevNodes starts from our previously selected node because we don't need to consider anything above that
|
|
func (gui *Gui) findNewSelectedIdx(prevNodes []*filetree.FileNode, currNodes []*filetree.FileNode) int {
|
|
getPaths := func(node *filetree.FileNode) []string {
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
if node.File != nil && node.File.IsRename() {
|
|
return node.File.Names()
|
|
} else {
|
|
return []string{node.Path}
|
|
}
|
|
}
|
|
|
|
for _, prevNode := range prevNodes {
|
|
selectedPaths := getPaths(prevNode)
|
|
|
|
for idx, node := range currNodes {
|
|
paths := getPaths(node)
|
|
|
|
// If you started off with a rename selected, and now it's broken in two, we want you to jump to the new file, not the old file.
|
|
// This is because the new should be in the same position as the rename was meaning less cursor jumping
|
|
foundOldFileInRename := prevNode.File != nil && prevNode.File.IsRename() && node.Path == prevNode.File.PreviousName
|
|
foundNode := utils.StringArraysOverlap(paths, selectedPaths) && !foundOldFileInRename
|
|
if foundNode {
|
|
return idx
|
|
}
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
func (gui *Gui) handlePullFiles() error {
|
|
if gui.popupPanelFocused() {
|
|
return nil
|
|
}
|
|
|
|
action := gui.Tr.Actions.Pull
|
|
|
|
currentBranch := gui.currentBranch()
|
|
if currentBranch == nil {
|
|
// need to wait for branches to refresh
|
|
return nil
|
|
}
|
|
|
|
// if we have no upstream branch we need to set that first
|
|
if !currentBranch.IsTrackingRemote() {
|
|
suggestedRemote := getSuggestedRemote(gui.State.Remotes)
|
|
|
|
return gui.prompt(promptOpts{
|
|
title: gui.Tr.EnterUpstream,
|
|
initialContent: suggestedRemote + " " + currentBranch.Name,
|
|
findSuggestionsFunc: gui.getRemoteBranchesSuggestionsFunc(" "),
|
|
handleConfirm: func(upstream string) error {
|
|
var upstreamBranch, upstreamRemote string
|
|
split := strings.Split(upstream, " ")
|
|
if len(split) != 2 {
|
|
return gui.createErrorPanel(gui.Tr.InvalidUpstream)
|
|
}
|
|
|
|
upstreamRemote = split[0]
|
|
upstreamBranch = split[1]
|
|
|
|
if err := gui.Git.Branch.SetCurrentBranchUpstream(upstreamRemote, upstreamBranch); err != nil {
|
|
errorMessage := err.Error()
|
|
if strings.Contains(errorMessage, "does not exist") {
|
|
errorMessage = fmt.Sprintf("upstream branch %s not found.\nIf you expect it to exist, you should fetch (with 'f').\nOtherwise, you should push (with 'shift+P')", upstream)
|
|
}
|
|
return gui.createErrorPanel(errorMessage)
|
|
}
|
|
return gui.pullFiles(PullFilesOptions{UpstreamRemote: upstreamRemote, UpstreamBranch: upstreamBranch, action: action})
|
|
},
|
|
})
|
|
}
|
|
|
|
return gui.pullFiles(PullFilesOptions{UpstreamRemote: currentBranch.UpstreamRemote, UpstreamBranch: currentBranch.UpstreamBranch, action: action})
|
|
}
|
|
|
|
type PullFilesOptions struct {
|
|
UpstreamRemote string
|
|
UpstreamBranch string
|
|
FastForwardOnly bool
|
|
action string
|
|
}
|
|
|
|
func (gui *Gui) pullFiles(opts PullFilesOptions) error {
|
|
if err := gui.createLoaderPanel(gui.Tr.PullWait); err != nil {
|
|
return err
|
|
}
|
|
|
|
// TODO: this doesn't look like a good idea. Why the goroutine?
|
|
go utils.Safe(func() { _ = gui.pullWithLock(opts) })
|
|
|
|
return nil
|
|
}
|
|
|
|
func (gui *Gui) pullWithLock(opts PullFilesOptions) error {
|
|
gui.Mutexes.FetchMutex.Lock()
|
|
defer gui.Mutexes.FetchMutex.Unlock()
|
|
|
|
gui.logAction(opts.action)
|
|
|
|
err := gui.Git.Sync.Pull(
|
|
git_commands.PullOptions{
|
|
RemoteName: opts.UpstreamRemote,
|
|
BranchName: opts.UpstreamBranch,
|
|
FastForwardOnly: opts.FastForwardOnly,
|
|
},
|
|
)
|
|
if err == nil {
|
|
_ = gui.closeConfirmationPrompt(false)
|
|
}
|
|
return gui.handleGenericMergeCommandResult(err)
|
|
}
|
|
|
|
type pushOpts struct {
|
|
force bool
|
|
upstreamRemote string
|
|
upstreamBranch string
|
|
setUpstream bool
|
|
}
|
|
|
|
func (gui *Gui) push(opts pushOpts) error {
|
|
if err := gui.createLoaderPanel(gui.Tr.PushWait); err != nil {
|
|
return err
|
|
}
|
|
go utils.Safe(func() {
|
|
gui.logAction(gui.Tr.Actions.Push)
|
|
err := gui.Git.Sync.Push(git_commands.PushOpts{
|
|
Force: opts.force,
|
|
UpstreamRemote: opts.upstreamRemote,
|
|
UpstreamBranch: opts.upstreamBranch,
|
|
SetUpstream: opts.setUpstream,
|
|
})
|
|
|
|
if err != nil && !opts.force && strings.Contains(err.Error(), "Updates were rejected") {
|
|
forcePushDisabled := gui.UserConfig.Git.DisableForcePushing
|
|
if forcePushDisabled {
|
|
_ = gui.createErrorPanel(gui.Tr.UpdatesRejectedAndForcePushDisabled)
|
|
return
|
|
}
|
|
_ = gui.ask(askOpts{
|
|
title: gui.Tr.ForcePush,
|
|
prompt: gui.Tr.ForcePushPrompt,
|
|
handleConfirm: func() error {
|
|
newOpts := opts
|
|
newOpts.force = true
|
|
|
|
return gui.push(newOpts)
|
|
},
|
|
})
|
|
return
|
|
}
|
|
gui.handleCredentialsPopup(err)
|
|
_ = gui.refreshSidePanels(refreshOptions{mode: ASYNC})
|
|
})
|
|
return nil
|
|
}
|
|
|
|
func (gui *Gui) pushFiles() error {
|
|
if gui.popupPanelFocused() {
|
|
return nil
|
|
}
|
|
|
|
// if we have pullables we'll ask if the user wants to force push
|
|
currentBranch := gui.currentBranch()
|
|
if currentBranch == nil {
|
|
// need to wait for branches to refresh
|
|
return nil
|
|
}
|
|
|
|
if currentBranch.IsTrackingRemote() {
|
|
opts := pushOpts{
|
|
force: false,
|
|
upstreamRemote: currentBranch.UpstreamRemote,
|
|
upstreamBranch: currentBranch.UpstreamBranch,
|
|
}
|
|
if currentBranch.HasCommitsToPull() {
|
|
opts.force = true
|
|
return gui.requestToForcePush(opts)
|
|
} else {
|
|
return gui.push(opts)
|
|
}
|
|
} else {
|
|
suggestedRemote := getSuggestedRemote(gui.State.Remotes)
|
|
|
|
if gui.Git.Config.GetPushToCurrent() {
|
|
return gui.push(pushOpts{setUpstream: true})
|
|
} else {
|
|
return gui.prompt(promptOpts{
|
|
title: gui.Tr.EnterUpstream,
|
|
initialContent: suggestedRemote + " " + currentBranch.Name,
|
|
findSuggestionsFunc: gui.getRemoteBranchesSuggestionsFunc(" "),
|
|
handleConfirm: func(upstream string) error {
|
|
var upstreamBranch, upstreamRemote string
|
|
split := strings.Split(upstream, " ")
|
|
if len(split) == 2 {
|
|
upstreamRemote = split[0]
|
|
upstreamBranch = split[1]
|
|
} else {
|
|
upstreamRemote = upstream
|
|
upstreamBranch = ""
|
|
}
|
|
|
|
return gui.push(pushOpts{
|
|
force: false,
|
|
upstreamRemote: upstreamRemote,
|
|
upstreamBranch: upstreamBranch,
|
|
setUpstream: true,
|
|
})
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func getSuggestedRemote(remotes []*models.Remote) string {
|
|
if len(remotes) == 0 {
|
|
return "origin"
|
|
}
|
|
|
|
for _, remote := range remotes {
|
|
if remote.Name == "origin" {
|
|
return remote.Name
|
|
}
|
|
}
|
|
|
|
return remotes[0].Name
|
|
}
|
|
|
|
func (gui *Gui) requestToForcePush(opts pushOpts) error {
|
|
forcePushDisabled := gui.UserConfig.Git.DisableForcePushing
|
|
if forcePushDisabled {
|
|
return gui.createErrorPanel(gui.Tr.ForcePushDisabled)
|
|
}
|
|
|
|
return gui.ask(askOpts{
|
|
title: gui.Tr.ForcePush,
|
|
prompt: gui.Tr.ForcePushPrompt,
|
|
handleConfirm: func() error {
|
|
return gui.push(opts)
|
|
},
|
|
})
|
|
}
|
|
|
|
func (gui *Gui) switchToMerge() error {
|
|
file := gui.getSelectedFile()
|
|
if file == nil {
|
|
return nil
|
|
}
|
|
|
|
gui.takeOverMergeConflictScrolling()
|
|
|
|
if gui.State.Panels.Merging.GetPath() != file.Name {
|
|
hasConflicts, err := gui.setMergeStateWithLock(file.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !hasConflicts {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return gui.pushContext(gui.State.Contexts.Merging)
|
|
}
|
|
|
|
func (gui *Gui) openFile(filename string) error {
|
|
gui.logAction(gui.Tr.Actions.OpenFile)
|
|
if err := gui.OSCommand.OpenFile(filename); err != nil {
|
|
return gui.surfaceError(err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (gui *Gui) handleCustomCommand() error {
|
|
return gui.prompt(promptOpts{
|
|
title: gui.Tr.CustomCommand,
|
|
findSuggestionsFunc: gui.getCustomCommandsHistorySuggestionsFunc(),
|
|
handleConfirm: func(command string) error {
|
|
gui.Config.GetAppState().CustomCommandsHistory = utils.Limit(
|
|
utils.Uniq(
|
|
append(gui.Config.GetAppState().CustomCommandsHistory, command),
|
|
),
|
|
1000,
|
|
)
|
|
|
|
err := gui.Config.SaveAppState()
|
|
if err != nil {
|
|
gui.Log.Error(err)
|
|
}
|
|
|
|
gui.logAction(gui.Tr.Actions.CustomCommand)
|
|
return gui.runSubprocessWithSuspenseAndRefresh(
|
|
gui.OSCommand.Cmd.NewShell(command),
|
|
)
|
|
},
|
|
})
|
|
}
|
|
|
|
func (gui *Gui) handleCreateStashMenu() error {
|
|
menuItems := []*menuItem{
|
|
{
|
|
displayString: gui.Tr.LcStashAllChanges,
|
|
onPress: func() error {
|
|
gui.logAction(gui.Tr.Actions.StashAllChanges)
|
|
return gui.handleStashSave(gui.Git.Stash.Save)
|
|
},
|
|
},
|
|
{
|
|
displayString: gui.Tr.LcStashStagedChanges,
|
|
onPress: func() error {
|
|
gui.logAction(gui.Tr.Actions.StashStagedChanges)
|
|
return gui.handleStashSave(gui.Git.Stash.SaveStagedChanges)
|
|
},
|
|
},
|
|
}
|
|
|
|
return gui.createMenu(gui.Tr.LcStashOptions, menuItems, createMenuOptions{showCancel: true})
|
|
}
|
|
|
|
func (gui *Gui) handleStashChanges() error {
|
|
return gui.handleStashSave(gui.Git.Stash.Save)
|
|
}
|
|
|
|
func (gui *Gui) handleCreateResetToUpstreamMenu() error {
|
|
return gui.createResetMenu("@{upstream}")
|
|
}
|
|
|
|
func (gui *Gui) handleToggleDirCollapsed() error {
|
|
node := gui.getSelectedFileNode()
|
|
if node == nil {
|
|
return nil
|
|
}
|
|
|
|
gui.State.FileTreeViewModel.ToggleCollapsed(node.GetPath())
|
|
|
|
if err := gui.postRefreshUpdate(gui.State.Contexts.Files); err != nil {
|
|
gui.Log.Error(err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (gui *Gui) handleToggleFileTreeView() error {
|
|
// get path of currently selected file
|
|
path := gui.getSelectedPath()
|
|
|
|
gui.State.FileTreeViewModel.ToggleShowTree()
|
|
|
|
// find that same node in the new format and move the cursor to it
|
|
if path != "" {
|
|
gui.State.FileTreeViewModel.ExpandToPath(path)
|
|
index, found := gui.State.FileTreeViewModel.GetIndexForPath(path)
|
|
if found {
|
|
gui.filesListContext().GetPanelState().SetSelectedLineIdx(index)
|
|
}
|
|
}
|
|
|
|
if ContextKey(gui.Views.Files.Context) == FILES_CONTEXT_KEY {
|
|
if err := gui.State.Contexts.Files.HandleRender(); err != nil {
|
|
return err
|
|
}
|
|
if err := gui.State.Contexts.Files.HandleFocus(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (gui *Gui) handleOpenMergeTool() error {
|
|
return gui.ask(askOpts{
|
|
title: gui.Tr.MergeToolTitle,
|
|
prompt: gui.Tr.MergeToolPrompt,
|
|
handleConfirm: func() error {
|
|
gui.logAction(gui.Tr.Actions.OpenMergeTool)
|
|
return gui.runSubprocessWithSuspenseAndRefresh(
|
|
gui.Git.WorkingTree.OpenMergeToolCmdObj(),
|
|
)
|
|
},
|
|
})
|
|
}
|