Skip to content

Commit

Permalink
Merge pull request #2072 from jesseduffield/optimistic-file-rendering
Browse files Browse the repository at this point in the history
  • Loading branch information
jesseduffield authored Jul 31, 2022
2 parents 5f4c29d + a905165 commit 2ca2aca
Show file tree
Hide file tree
Showing 8 changed files with 228 additions and 58 deletions.
26 changes: 6 additions & 20 deletions pkg/commands/loaders/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}

Expand Down
46 changes: 46 additions & 0 deletions pkg/commands/models/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package models

import (
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/samber/lo"
)

// File : A file from git status
Expand Down Expand Up @@ -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,
}
}
1 change: 1 addition & 0 deletions pkg/gui/controllers.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ func (gui *Gui) resetControllers() {
model,
gui.State.Contexts,
gui.State.Modes,
&gui.Mutexes,
)

syncController := controllers.NewSyncController(
Expand Down
3 changes: 3 additions & 0 deletions pkg/gui/controllers/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type controllerCommon struct {
model *types.Model
contexts *context.ContextTree
modes *types.Modes
mutexes *types.Mutexes
}

func NewControllerCommon(
Expand All @@ -26,6 +27,7 @@ func NewControllerCommon(
model *types.Model,
contexts *context.ContextTree,
modes *types.Modes,
mutexes *types.Mutexes,
) *controllerCommon {
return &controllerCommon{
c: c,
Expand All @@ -35,5 +37,6 @@ func NewControllerCommon(
model: model,
contexts: contexts,
modes: modes,
mutexes: mutexes,
}
}
175 changes: 151 additions & 24 deletions pkg/gui/controllers/files_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
{
Expand Down Expand Up @@ -167,7 +167,86 @@ func (self *FilesController) GetOnClick() func() error {
return self.checkSelectedFileNode(self.press)
}

func (self *FilesController) press(node *filetree.FileNode) error {
// 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.IsLeaf() {
file := node.File

Expand All @@ -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)
}
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
func (self *FilesController) toggleStagedAll() error {
if err := self.toggleStagedAllWithLock(); err != nil {
return err
}
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 := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}, Mode: types.ASYNC}); err != nil {
return err
}
if err != nil {
_ = self.c.Error(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 err := self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.FILES}}); err != nil {
return err
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 self.contexts.Files.HandleFocus()
return nil
}

func (self *FilesController) unstageFiles(node *filetree.FileNode) error {
Expand Down
5 changes: 5 additions & 0 deletions pkg/gui/filetree/file_tree.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type IFileTree interface {
GetAllItems() []*FileNode
GetAllFiles() []*models.File
GetFilter() FileTreeDisplayFilter
GetRoot() *FileNode
}

type FileTree struct {
Expand Down Expand Up @@ -159,6 +160,10 @@ func (self *FileTree) Tree() INode {
return self.tree
}

func (self *FileTree) GetRoot() *FileNode {
return self.tree
}

func (self *FileTree) CollapsedPaths() *CollapsedPaths {
return self.collapsedPaths
}
Expand Down
Loading

0 comments on commit 2ca2aca

Please sign in to comment.