mirror of
https://github.com/jesseduffield/lazygit.git
synced 2024-12-02 09:21:40 +02:00
add optimistic rendering for staging and unstaging files
This commit is contained in:
parent
c26650258d
commit
7077ea428f
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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]
|
||||
untracked := lo.Contains([]string{"??", "A ", "AM"}, shortStatus)
|
||||
hasNoStagedChanges := 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: !hasNoStagedChanges,
|
||||
HasUnstagedChanges: unstagedChange != " ",
|
||||
Tracked: !untracked,
|
||||
Deleted: unstagedChange == "D" || stagedChange == "D",
|
||||
Added: unstagedChange == "A" || untracked,
|
||||
HasMergeConflicts: hasMergeConflicts,
|
||||
HasInlineMergeConflicts: hasInlineMergeConflicts,
|
||||
ShortStatus: shortStatus,
|
||||
}
|
||||
}
|
||||
|
@ -62,6 +62,7 @@ func (gui *Gui) resetControllers() {
|
||||
model,
|
||||
gui.State.Contexts,
|
||||
gui.State.Modes,
|
||||
&gui.Mutexes,
|
||||
)
|
||||
|
||||
syncController := controllers.NewSyncController(
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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,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
|
||||
|
||||
@ -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 {
|
||||
|
@ -41,6 +41,7 @@ type IFileTree interface {
|
||||
GetAllItems() []*FileNode
|
||||
GetAllFiles() []*models.File
|
||||
GetFilter() FileTreeDisplayFilter
|
||||
GetRoot() *FileNode
|
||||
}
|
||||
|
||||
type FileTree struct {
|
||||
@ -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
|
||||
}
|
||||
|
@ -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{},
|
||||
|
@ -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"
|
||||
@ -156,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
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user