1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2024-12-12 11:15:00 +02:00
lazygit/pkg/gui/files_panel.go
Jesse Duffield 1dd7307fde start moving commit panel handlers into controller
more

and more

move rebase commit refreshing into existing abstraction

and more

and more

WIP

and more

handling clicks

properly fix merge conflicts

update cheatsheet

lots more preparation to start moving things into controllers

WIP

better typing

expand on remotes controller

moving more code into controllers
2022-03-17 19:13:40 +11:00

326 lines
10 KiB
Go

package gui
import (
"github.com/jesseduffield/gocui"
"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/gui/filetree"
"github.com/jesseduffield/lazygit/pkg/gui/mergeconflicts"
"github.com/jesseduffield/lazygit/pkg/gui/popup"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"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.c.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.IgnoreWhitespaceInDiffView)
refreshOpts := refreshMainOpts{main: &viewUpdateOpts{
title: gui.c.Tr.UnstagedChanges,
task: NewRunPtyTask(cmdObj.GetCmd()),
}}
if node.GetHasUnstagedChanges() {
if node.GetHasStagedChanges() {
cmdObj := gui.git.WorkingTree.WorktreeFileDiffCmdObj(node, false, true, gui.IgnoreWhitespaceInDiffView)
refreshOpts.secondary = &viewUpdateOpts{
title: gui.c.Tr.StagedChanges,
task: NewRunPtyTask(cmdObj.GetCmd()),
}
}
} else {
refreshOpts.main.title = gui.c.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.c.PostRefreshUpdate(gui.State.Contexts.Submodules); err != nil {
gui.c.Log.Error(err)
}
if types.ContextKey(gui.Views.Files.Context) == FILES_CONTEXT_KEY {
// doing this a little custom (as opposed to using gui.c.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
}
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.c.LogAction(gui.Tr.Actions.StageResolvedFiles)
if err := gui.git.WorkingTree.StageFiles(pathsToStage); err != nil {
return gui.c.Error(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.PopupHandler.Ask(popup.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) onFocusFile() error {
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
}
}
// TODO: this can't be right.
return gui.pushContext(gui.State.Contexts.Merging)
}
func (gui *Gui) getSetTextareaTextFn(view *gocui.View) func(string) {
return func(text string) {
view.ClearTextArea()
view.TextArea.TypeString(text)
view.RenderTextArea()
}
}