diff options
| author | Ibrahim Serdar Acikgoz <serdaracikgoz86@gmail.com> | 2019-01-04 18:22:50 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2019-01-04 18:22:50 +0300 |
| commit | 8a7a258de22432c5927ccfd2c9d5c41fea275b19 (patch) | |
| tree | 1bd6738d5016aecbc944f04f0391197541cbd7c3 /gui | |
| parent | Minor checks and changes throughout files (#53) (diff) | |
| parent | cleanup before version increase (diff) | |
| download | gitbatch-8a7a258de22432c5927ccfd2c9d5c41fea275b19.tar.gz | |
Merge pull request #54 from isacikgoz/develop
Develop (0.3.0)
Diffstat (limited to 'gui')
| -rw-r--r-- | gui/authenticationview.go | 188 | ||||
| -rw-r--r-- | gui/commitview.go | 189 | ||||
| -rw-r--r-- | gui/controlsview.go | 36 | ||||
| -rw-r--r-- | gui/diffview.go | 127 | ||||
| -rw-r--r-- | gui/errorview.go | 36 | ||||
| -rw-r--r-- | gui/extensions.go | 198 | ||||
| -rw-r--r-- | gui/gui.go | 245 | ||||
| -rw-r--r-- | gui/keybindings.go | 675 | ||||
| -rw-r--r-- | gui/mainview.go | 302 | ||||
| -rw-r--r-- | gui/sideviews.go | 232 | ||||
| -rw-r--r-- | gui/stagedview.go | 73 | ||||
| -rw-r--r-- | gui/stashview.go | 84 | ||||
| -rw-r--r-- | gui/statusview.go | 162 | ||||
| -rw-r--r-- | gui/text-renderer.go | 191 | ||||
| -rw-r--r-- | gui/unstagedview.go | 66 |
15 files changed, 2804 insertions, 0 deletions
diff --git a/gui/authenticationview.go b/gui/authenticationview.go new file mode 100644 index 0000000..2faa3b7 --- /dev/null +++ b/gui/authenticationview.go @@ -0,0 +1,188 @@ +package gui + +import ( + "fmt" + "regexp" + + "github.com/isacikgoz/gitbatch/core/command" + "github.com/isacikgoz/gitbatch/core/git" + "github.com/isacikgoz/gitbatch/core/job" + "github.com/jroimartin/gocui" + log "github.com/sirupsen/logrus" +) + +var ( + // this is required so we can know where we can return + authenticationReturnView string + + // these views used as a label for git repository address and credential views + authenticationViewFeature = viewFeature{Name: "authentication", Title: " Authentication "} + authUserLabelFeature = viewFeature{Name: "authuserlabel", Title: " User: "} + authPswdLabelViewFeature = viewFeature{Name: "authpasswdlabel", Title: " Password: "} + + // these views used as a input for the credentials + authUserFeature = viewFeature{Name: "authuser", Title: " User "} + authPasswordViewFeature = viewFeature{Name: "authpasswd", Title: " Password "} + + // these are the view groups, so that we can assign common keybindings + authViews = []viewFeature{authUserFeature, authPasswordViewFeature} + authLabels = []viewFeature{authenticationViewFeature, authUserLabelFeature, authPswdLabelViewFeature} + + // we can hold the job that is required to authenticate + jobRequiresAuth *job.Job +) + +// open an auth view to get user credentials +func (gui *Gui) openAuthenticationView(g *gocui.Gui, jobQueue *job.JobQueue, job *job.Job, returnViewName string) error { + maxX, maxY := g.Size() + // lets add this job since it is removed from the queue + // also it is already unsuccessfully finished + if err := jobQueue.AddJob(job); err != nil { + return err + } + jobRequiresAuth = job + if job.Repository.WorkStatus() != git.Fail { + if err := jobQueue.RemoveFromQueue(job.Repository); err != nil { + log.Fatal(err.Error()) + return err + } + } + authenticationReturnView = returnViewName + v, err := g.SetView(authenticationViewFeature.Name, maxX/2-30, maxY/2-2, maxX/2+30, maxY/2+2) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + fmt.Fprintln(v, keySymbol+selectionIndicator+red.Sprint(jobRequiresAuth.Repository.State.Remote.URL[0])) + } + g.Cursor = true + if err := gui.openUserView(g); err != nil { + return err + } + return gui.openPasswordView(g) +} + +// close the opened auth views +func (gui *Gui) closeAuthenticationView(g *gocui.Gui, v *gocui.View) error { + g.Cursor = false + for _, vf := range authLabels { + if err := g.DeleteView(vf.Name); err != nil { + return nil + } + } + for _, vf := range authViews { + if err := g.DeleteView(vf.Name); err != nil { + return nil + } + } + return gui.closeViewCleanup(authenticationReturnView) +} + +// close the opened auth views and submit the credentials +func (gui *Gui) submitAuthenticationView(g *gocui.Gui, v *gocui.View) error { + g.Cursor = false + + // in order to read buffer of the views, first we need to find'em + vUser, err := g.View(authUserFeature.Name) + if err != nil { + log.Errorln("error while retrieving user from view:", err) + return err // should return?? + } + + vPswd, err := g.View(authPasswordViewFeature.Name) + if err != nil { + log.Errorln("error while retrieving password from view:", err) + return err // should return?? + } + + // the return string of the views contain trailing new lines + re := regexp.MustCompile(`\r?\n`) + creduser := re.ReplaceAllString(vUser.ViewBuffer(), "") + credpswd := re.ReplaceAllString(vPswd.ViewBuffer(), "") + + // since the git ops require different types of options we better switch + switch mode := jobRequiresAuth.JobType; mode { + case job.FetchJob: + jobRequiresAuth.Options = command.FetchOptions{ + RemoteName: jobRequiresAuth.Repository.State.Remote.Name, + Credentials: git.Credentials{ + User: creduser, + Password: credpswd, + }, + } + case job.PullJob: + // we handle pull as fetch&merge so same rule applies + jobRequiresAuth.Options = command.PullOptions{ + RemoteName: jobRequiresAuth.Repository.State.Remote.Name, + Credentials: git.Credentials{ + User: creduser, + Password: credpswd, + }, + } + } + jobRequiresAuth.Repository.SetWorkStatus(git.Queued) + + // add this job to the last of the queue + if err := gui.State.Queue.AddJob(jobRequiresAuth); err != nil { + return err + } + + return gui.closeAuthenticationView(g, v) +} + +// open an error view to inform user with a message and a useful note +func (gui *Gui) openUserView(g *gocui.Gui) error { + maxX, maxY := g.Size() + // first, create the label for user + vlabel, err := g.SetView(authUserLabelFeature.Name, maxX/2-30, maxY/2-1, maxX/2-19, maxY/2+1) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + fmt.Fprintln(vlabel, authUserLabelFeature.Title) + vlabel.Frame = false + } + // second, crete the user input + v, err := g.SetView(authUserFeature.Name, maxX/2-18, maxY/2-1, maxX/2+29, maxY/2+1) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = authUserFeature.Title + v.Editable = true + v.Frame = false + } + return gui.focusToView(authUserFeature.Name) +} + +// open an error view to inform user with a message and a useful note +func (gui *Gui) openPasswordView(g *gocui.Gui) error { + maxX, maxY := g.Size() + // first, create the label for password + vlabel, err := g.SetView(authPswdLabelViewFeature.Name, maxX/2-30, maxY/2, maxX/2-19, maxY/2+2) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + fmt.Fprintln(vlabel, authPswdLabelViewFeature.Title) + vlabel.Frame = false + } + // second, crete the masked password input + v, err := g.SetView(authPasswordViewFeature.Name, maxX/2-18, maxY/2, maxX/2+29, maxY/2+2) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = authPasswordViewFeature.Title + v.Editable = true + v.Mask ^= '*' + v.Frame = false + } + return nil +} + +// focus to next view +func (gui *Gui) nextAuthView(g *gocui.Gui, v *gocui.View) error { + err := gui.nextViewOfGroup(g, v, authViews) + return err +} diff --git a/gui/commitview.go b/gui/commitview.go new file mode 100644 index 0000000..7acc5ca --- /dev/null +++ b/gui/commitview.go @@ -0,0 +1,189 @@ +package gui + +import ( + "errors" + "fmt" + "regexp" + + "github.com/isacikgoz/gitbatch/core/command" + "github.com/jroimartin/gocui" +) + +var ( + commitFrameViewFeature = viewFeature{Name: "commitframe", Title: " Frame "} + commitUserNameLabelFeature = viewFeature{Name: "commitusernamelabel", Title: " Name: "} + commitUserEmailLabelViewFeature = viewFeature{Name: "commituseremaillabel", Title: " E-Mail: "} + + // these views used as a input for the credentials + commitMessageViewFeature = viewFeature{Name: "commitmessage", Title: " Commit Mesage "} + commitUserUserViewFeature = viewFeature{Name: "commitusername", Title: " Name "} + commitUserEmailViewFeature = viewFeature{Name: "commituseremail", Title: " E-Mail "} + + commitViews = []viewFeature{commitMessageViewFeature, commitUserUserViewFeature, commitUserEmailViewFeature} + commitLabelViews = []viewFeature{commitFrameViewFeature, commitUserNameLabelFeature, commitUserEmailLabelViewFeature} +) + +// open the commit message views +func (gui *Gui) openCommitMessageView(g *gocui.Gui, v *gocui.View) error { + maxX, maxY := g.Size() + commitMesageReturnView = v.Name() + vFrame, err := g.SetView(commitFrameViewFeature.Name, maxX/2-30, maxY/2-4, maxX/2+30, maxY/2+3) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + vFrame.Frame = true + fmt.Fprintln(vFrame, " Enter your commit message:") + } + v, err = g.SetView(commitMessageViewFeature.Name, maxX/2-29, maxY/2-3, maxX/2+29, maxY/2) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Frame = false + v.Wrap = true + v.Editable = true + v.Editor = gocui.DefaultEditor + g.Cursor = true + } + if err := gui.openCommitUserNameView(g); err != nil { + return err + } + if err := gui.openCommitUserEmailView(g); err != nil { + return err + } + return gui.focusToView(commitMessageViewFeature.Name) +} + +// open an error view to inform user with a message and a useful note +func (gui *Gui) openCommitUserNameView(g *gocui.Gui) error { + r := gui.getSelectedRepository() + maxX, maxY := g.Size() + // first, create the label for user + vlabel, err := g.SetView(commitUserNameLabelFeature.Name, maxX/2-30, maxY/2, maxX/2-19, maxY/2+2) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + fmt.Fprintln(vlabel, commitUserNameLabelFeature.Title) + vlabel.Frame = false + } + // second, crete the user input + v, err := g.SetView(commitUserUserViewFeature.Name, maxX/2-18, maxY/2, maxX/2+29, maxY/2+2) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + name, err := command.Config(r, command.ConfigOptions{ + Section: "user", + Option: "name", + }) + if err != nil { + return err + } + fmt.Fprintln(v, name) + v.Editable = true + v.Frame = false + } + return nil +} + +// open an error view to inform user with a message and a useful note +func (gui *Gui) openCommitUserEmailView(g *gocui.Gui) error { + r := gui.getSelectedRepository() + maxX, maxY := g.Size() + // first, create the label for password + vlabel, err := g.SetView(commitUserEmailLabelViewFeature.Name, maxX/2-30, maxY/2+1, maxX/2-19, maxY/2+3) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + fmt.Fprintln(vlabel, commitUserEmailLabelViewFeature.Title) + vlabel.Frame = false + } + // second, crete the masked password input + v, err := g.SetView(commitUserEmailViewFeature.Name, maxX/2-18, maxY/2+1, maxX/2+29, maxY/2+3) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + email, err := command.Config(r, command.ConfigOptions{ + Section: "user", + Option: "email", + }) + if err != nil { + return err + } + fmt.Fprintln(v, email) + v.Editable = true + v.Frame = false + } + return nil +} + +// close the opened commite mesage view +func (gui *Gui) submitCommitMessageView(g *gocui.Gui, v *gocui.View) error { + r := gui.getSelectedRepository() + + // in order to read buffer of the views, first we need to find'em + vMsg, err := g.View(commitMessageViewFeature.Name) + if err != nil { + return err // should return?? + } + + vName, err := g.View(commitUserUserViewFeature.Name) + if err != nil { + return err // should return?? + } + + vEmail, err := g.View(commitUserEmailViewFeature.Name) + if err != nil { + return err // should return?? + } + + // the return string of the views contain trailing new lines + re := regexp.MustCompile(`\r?\n`) + // TODO: maybe intentionally added new lines? + msg := re.ReplaceAllString(vMsg.ViewBuffer(), "") + name := re.ReplaceAllString(vName.ViewBuffer(), "") + email := re.ReplaceAllString(vEmail.ViewBuffer(), "") + if len(email) <= 0 { + return errors.New("User email needs to be provided") + } + + err = command.CommitCommand(r, command.CommitOptions{ + CommitMsg: msg, + User: name, + Email: email, + }) + if err != nil { + return err + } + + return gui.closeCommitMessageView(g, v) +} + +// focus to next view +func (gui *Gui) nextCommitView(g *gocui.Gui, v *gocui.View) error { + return gui.nextViewOfGroup(g, v, commitViews) +} + +// close the opened commite mesage view +func (gui *Gui) closeCommitMessageView(g *gocui.Gui, v *gocui.View) error { + r := gui.getSelectedRepository() + g.Cursor = false + for _, view := range commitViews { + if err := g.DeleteView(view.Name); err != nil { + return err + } + } + for _, view := range commitLabelViews { + if err := g.DeleteView(view.Name); err != nil { + return err + } + } + if err := refreshAllStatusView(g, r, true); err != nil { + return err + } + return gui.closeViewCleanup(commitMesageReturnView) +} diff --git a/gui/controlsview.go b/gui/controlsview.go new file mode 100644 index 0000000..4b2f97e --- /dev/null +++ b/gui/controlsview.go @@ -0,0 +1,36 @@ +package gui + +import ( + "fmt" + + "github.com/jroimartin/gocui" +) + +// open the application controls +// TODO: view size can handled better for such situations like too small +// application area +func (gui *Gui) openCheatSheetView(g *gocui.Gui, v *gocui.View) error { + maxX, maxY := g.Size() + v, err := g.SetView(cheatSheetViewFeature.Name, maxX/2-25, maxY/2-10, maxX/2+25, maxY/2+10) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = cheatSheetViewFeature.Title + for _, k := range gui.KeyBindings { + if k.View == mainViewFeature.Name || k.View == "" { + binding := " " + cyan.Sprint(k.Display) + ": " + k.Description + fmt.Fprintln(v, binding) + } + } + } + return gui.focusToView(cheatSheetViewFeature.Name) +} + +// close the application controls and do the clean job +func (gui *Gui) closeCheatSheetView(g *gocui.Gui, v *gocui.View) error { + if err := g.DeleteView(v.Name()); err != nil { + return nil + } + return gui.closeViewCleanup(mainViewFeature.Name) +} diff --git a/gui/diffview.go b/gui/diffview.go new file mode 100644 index 0000000..9f49456 --- /dev/null +++ b/gui/diffview.go @@ -0,0 +1,127 @@ +package gui + +import ( + "fmt" + + "github.com/isacikgoz/gitbatch/core/command" + "github.com/jroimartin/gocui" +) + +var diffReturnView string + +// renders the diff view +func (gui *Gui) prepareDiffView(g *gocui.Gui, v *gocui.View, display []string) (out *gocui.View, err error) { + maxX, maxY := g.Size() + diffReturnView = v.Name() + out, err = g.SetView(diffViewFeature.Name, 5, 3, maxX-5, maxY-3) + if err != nil { + if err != gocui.ErrUnknownView { + return out, err + } + } + out.Title = diffViewFeature.Title + out.Overwrite = true + out.Wrap = true + if err = gui.focusToView(diffViewFeature.Name); err != nil { + return out, err + } + for _, line := range display { + fmt.Fprintln(out, line) + } + return out, err +} + +// open diff view for the selcted commit +// called from commitview, so initial view is commitview +func (gui *Gui) openCommitDiffView(g *gocui.Gui, v *gocui.View) (err error) { + r := gui.getSelectedRepository() + commit := r.State.Commit + commitDetail := []string{("Hash: " + cyan.Sprint(commit.Hash) + "\n" + "Author: " + commit.Author + + "\n" + commit.Time + "\n" + "\n" + "\t\t" + commit.Message + "\n")} + diff, err := command.Diff(r, r.State.Commit.Hash) + if err != nil { + return err + } + colorized := colorizeDiff(diff) + commitDetail = append(commitDetail, colorized...) + out, err := gui.prepareDiffView(g, v, commitDetail) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + } + out.Title = " Commit Detail " + return nil +} + +// called from status, so initial view may be stagedview or unstaged view +func (gui *Gui) openFileDiffView(g *gocui.Gui, v *gocui.View) (err error) { + + _, cy := v.Cursor() + _, oy := v.Origin() + var files []*command.File + switch v.Name() { + case unstageViewFeature.Name: + files = unstagedFiles + case stageViewFeature.Name: + files = stagedFiles + } + + if len(files) <= 0 { + return nil + } + output, err := files[cy+oy].Diff() + if err != nil || len(output) <= 0 { + return nil + } + if err != nil { + if err = gui.openErrorView(g, output, + "You should manually resolve this issue", + diffReturnView); err != nil { + return err + } + } + colorized := colorizeDiff(output) + _, err = gui.prepareDiffView(g, v, colorized) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + } + return nil +} + +// called from stashview, so initial view is stashview +func (gui *Gui) showStash(g *gocui.Gui, v *gocui.View) (err error) { + r := gui.getSelectedRepository() + _, oy := v.Origin() + _, cy := v.Cursor() + if len(r.Stasheds) <= 0 { + return nil + } + stashedItem := r.Stasheds[oy+cy] + output, err := stashedItem.Show() + if err != nil { + if err = gui.openErrorView(g, output, + "You should manually resolve this issue", + stashViewFeature.Name); err != nil { + return err + } + } + colorized := colorizeDiff(output) + _, err = gui.prepareDiffView(g, v, colorized) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + } + return nil +} + +// close the opened diff view +func (gui *Gui) closeCommitDiffView(g *gocui.Gui, v *gocui.View) error { + if err := g.DeleteView(v.Name()); err != nil { + return nil + } + return gui.closeViewCleanup(diffReturnView) +} diff --git a/gui/errorview.go b/gui/errorview.go new file mode 100644 index 0000000..f679b54 --- /dev/null +++ b/gui/errorview.go @@ -0,0 +1,36 @@ +package gui + +import ( + "fmt" + + "github.com/jroimartin/gocui" +) + +var errorReturnView string + +// open an error view to inform user with a message and a useful note +func (gui *Gui) openErrorView(g *gocui.Gui, message, note, returnViewName string) error { + maxX, maxY := g.Size() + errorReturnView = returnViewName + v, err := g.SetView(errorViewFeature.Name, maxX/2-30, maxY/2-3, maxX/2+30, maxY/2+3) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = errorViewFeature.Title + v.Wrap = true + ps := red.Sprint("Note:") + " " + note + fmt.Fprintln(v, message) + fmt.Fprintln(v, ps) + } + return gui.focusToView(errorViewFeature.Name) +} + +// close the opened error view +func (gui *Gui) closeErrorView(g *gocui.Gui, v *gocui.View) error { + + if err := g.DeleteView(v.Name()); err != nil { + return nil + } + return gui.closeViewCleanup(errorReturnView) +} diff --git a/gui/extensions.go b/gui/extensions.go new file mode 100644 index 0000000..b25848c --- /dev/null +++ b/gui/extensions.go @@ -0,0 +1,198 @@ +package gui + +import ( + "github.com/jroimartin/gocui" + log "github.com/sirupsen/logrus" +) + +// focus to next view +func (gui *Gui) nextViewOfGroup(g *gocui.Gui, v *gocui.View, group []viewFeature) error { + var focusedViewName string + if v == nil || v.Name() == group[len(group)-1].Name { + focusedViewName = group[0].Name + } else { + for i := range group { + if v.Name() == group[i].Name { + focusedViewName = group[i+1].Name + break + } + if i == len(group)-1 { + return nil + } + } + } + if _, err := g.SetCurrentView(focusedViewName); err != nil { + log.WithFields(log.Fields{ + "view": focusedViewName, + }).Warn("View cannot be focused.") + return nil + } + + return gui.updateKeyBindingsView(g, focusedViewName) +} + +// focus to previous view +func (gui *Gui) previousViewOfGroup(g *gocui.Gui, v *gocui.View, group []viewFeature) error { + var focusedViewName string + if v == nil || v.Name() == group[0].Name { + focusedViewName = group[len(group)-1].Name + } else { + for i := range group { + if v.Name() == group[i].Name { + focusedViewName = group[i-1].Name + break + } + if i == len(group)-1 { + return nil + } + } + } + if _, err := g.SetCurrentView(focusedViewName); err != nil { + log.WithFields(log.Fields{ + "view": focusedViewName, + }).Warn("View cannot be focused.") + return nil + } + + return gui.updateKeyBindingsView(g, focusedViewName) +} + +// siwtch the app's mode to fetch +func (gui *Gui) switchToFetchMode(g *gocui.Gui, v *gocui.View) error { + gui.State.Mode = fetchMode + return gui.updateKeyBindingsView(g, mainViewFeature.Name) +} + +// siwtch the app's mode to pull +func (gui *Gui) switchToPullMode(g *gocui.Gui, v *gocui.View) error { + gui.State.Mode = pullMode + return gui.updateKeyBindingsView(g, mainViewFeature.Name) +} + +// siwtch the app's mode to merge +func (gui *Gui) switchToMergeMode(g *gocui.Gui, v *gocui.View) error { + gui.State.Mode = mergeMode + return gui.updateKeyBindingsView(g, mainViewFeature.Name) +} + +// bring the view on the top by its name +func (gui *Gui) setCurrentViewOnTop(g *gocui.Gui, name string) (*gocui.View, error) { + if _, err := g.SetCurrentView(name); err != nil { + return nil, err + } + return g.SetViewOnTop(name) +} + +// if the cursor down past the last item, move it to the last line +func (gui *Gui) correctCursor(v *gocui.View) error { + cx, cy := v.Cursor() + ox, oy := v.Origin() + width, height := v.Size() + maxY := height - 1 + ly := width - 1 + if oy+cy <= ly { + return nil + } + newCy := min(ly, maxY) + if err := v.SetCursor(cx, newCy); err != nil { + return err + } + err := v.SetOrigin(ox, ly-newCy) + return err +} + +// min finds the minimum value of two int +func min(x, y int) int { + if x < y { + return x + } + return y +} + +// this function handles the iteration of a side view and set its origin point +// so that the selected line can be in the middle of the view +func (gui *Gui) smartAnchorRelativeToLine(v *gocui.View, currentindex, totallines int) error { + _, y := v.Size() + if currentindex >= int(0.5*float32(y)) && totallines-currentindex+int(0.5*float32(y)) >= y { + if err := v.SetOrigin(0, currentindex-int(0.5*float32(y))); err != nil { + return err + } + } else if totallines-currentindex < y && totallines > y { + if err := v.SetOrigin(0, totallines-y); err != nil { + return err + } + } else if totallines-currentindex <= int(0.5*float32(y)) && totallines > y-1 && currentindex > y { + if err := v.SetOrigin(0, currentindex-int(0.5*float32(y))); err != nil { + return err + } + } else { + if err := v.SetOrigin(0, 0); err != nil { + return err + } + } + return nil +} + +// this function writes the given text to rgiht hand side of the view +// cx and cy values are important to get the cursor to its old position +func writeRightHandSide(v *gocui.View, text string, cx, cy int) error { + runes := []rune(text) + tl := len(runes) + lx, _ := v.Size() + v.MoveCursor(lx-tl, cy-1, true) + for i := tl - 1; i >= 0; i-- { + v.EditDelete(true) + v.EditWrite(runes[i]) + } + v.SetCursor(cx, cy) + return nil +} + +// cursor down acts like half-page down for faster scrolling +func (gui *Gui) fastCursorDown(g *gocui.Gui, v *gocui.View) error { + if v != nil { + ox, oy := v.Origin() + _, vy := v.Size() + if len(v.BufferLines())+len(v.ViewBufferLines()) <= vy+oy || len(v.ViewBufferLines()) < vy { + return nil + } + // TODO: do something when it hits bottom + if err := v.SetOrigin(ox, oy+vy/2); err != nil { + return err + } + } + return nil +} + +// cursor up acts like half-page up for faster scrolling +func (gui *Gui) fastCursorUp(g *gocui.Gui, v *gocui.View) error { + if v != nil { + ox, oy := v.Origin() + _, vy := v.Size() + + if oy-vy/2 > 0 { + if err := v.SetOrigin(ox, oy-vy/2); err != nil { + return err + } + } else if oy-vy/2 <= 0 { + if err := v.SetOrigin(0, 0); err != nil { + return err + } + } + } + return nil +} + +// closeViewCleanup both updates the keybidings view and focuses to returning view +func (gui *Gui) closeViewCleanup(returningViewName string) (err error) { + if _, err = gui.g.SetCurrentView(returningViewName); err != nil { + return err + } + err = gui.updateKeyBindingsView(gui.g, returningViewName) + return err +} + +// focus to view same as closeViewCleanup but its just a wrapper for easy reading +func (gui *Gui) focusToView(viewName string) (err error) { + return gui.closeViewCleanup(viewName) +} diff --git a/gui/gui.go b/gui/gui.go new file mode 100644 index 0000000..767bf16 --- /dev/null +++ b/gui/gui.go @@ -0,0 +1,245 @@ +package gui + +import ( + "fmt" + "sort" + "sync" + + "github.com/isacikgoz/gitbatch/core/git" + "github.com/isacikgoz/gitbatch/core/job" + "github.com/isacikgoz/gitbatch/core/load" + "github.com/jroimartin/gocui" + log "github.com/sirupsen/logrus" +) + +// Gui struct hold the gocui struct along with the gui's state, also keybindings +// are tied with this struct in order to render those in different occasions +type Gui struct { + g *gocui.Gui + KeyBindings []*KeyBinding + State guiState + mutex *sync.Mutex +} + +// guiState struct holds the repositories, directiories, mode and queue of the +// gui object. These values are not static +type guiState struct { + Repositories []*git.Repository + Directories []string + Mode mode + Queue *job.JobQueue + FailoverQueue *job.JobQueue +} + +// this struct encapsulates the name and title of a view. the name of a view is +// passed around so much it is added so that I don't need to wirte names again +type viewFeature struct { + Name string + Title string +} + +// mode of the gui +type mode struct { + ModeID ModeID + DisplayString string + CommandString string +} + +// ModeID is the mode indicator for the gui +type ModeID string + +const ( + // FetchMode puts the gui in fetch state + FetchMode ModeID = "fetch" + // PullMode puts the gui in pull state + PullMode ModeID = "pull" + // MergeMode puts the gui in merge state + MergeMode ModeID = "merge" +) + +var ( + mainViewFeature = viewFeature{Name: "main", Title: " Matched Repositories "} + loadingViewFeature = viewFeature{Name: "loading", Title: " Loading in Progress "} + branchViewFeature = viewFeature{Name: "branch", Title: " Local Branches "} + remoteViewFeature = viewFeature{Name: "remotes", Title: " Remotes "} + remoteBranchViewFeature = viewFeature{Name: "remotebranches", Title: " Remote Branches "} + commitViewFeature = viewFeature{Name: "commits", Title: " Commits "} + scheduleViewFeature = viewFeature{Name: "schedule", Title: " Schedule "} + keybindingsViewFeature = viewFeature{Name: "keybindings", Title: " Keybindings "} + diffViewFeature = viewFeature{Name: "diff", Title: " Diff Detail "} + cheatSheetViewFeature = viewFeature{Name: "cheatsheet", Title: " Application Controls "} + errorViewFeature = viewFeature{Name: "error", Title: " Error "} + + fetchMode = mode{ModeID: FetchMode, DisplayString: "Fetch", CommandString: "fetch"} + pullMode = mode{ModeID: PullMode, DisplayString: "Pull", CommandString: "pull"} + mergeMode = mode{ModeID: MergeMode, DisplayString: "Merge", CommandString: "merge"} + + mainViews = []viewFeature{mainViewFeature, remoteViewFeature, remoteBranchViewFeature, branchViewFeature, commitViewFeature} + modes = []mode{fetchMode, pullMode, mergeMode} + + loaded = make(chan bool) +) + +// NewGui creates a Gui opject and fill it's state related entites +func NewGui(mode string, directoies []string) (*Gui, error) { + initialState := guiState{ + Directories: directoies, + Mode: fetchMode, + Queue: job.CreateJobQueue(), + FailoverQueue: job.CreateJobQueue(), + } + gui := &Gui{ + State: initialState, + mutex: &sync.Mutex{}, + } + for _, m := range modes { + if string(m.ModeID) == mode { + gui.State.Mode = m + break + } + } + return gui, nil +} + +// Run function runs the main loop with initial values +func (gui *Gui) Run() error { + g, err := gocui.NewGui(gocui.OutputNormal) + if err != nil { + return err + } + defer g.Close() + + gui.g = g + g.Highlight = true + g.SelFgColor = gocui.ColorGreen + + g.InputEsc = true + g.SetManagerFunc(gui.layout) + + // load repositories in background asynchronously + go load.AsyncLoad(gui.State.Directories, gui.loadRepository, loaded) + + if err := gui.generateKeybindings(); err != nil { + log.Error("Keybindings could not be created.") + return err + } + if err := gui.keybindings(g); err != nil { + log.Error("Keybindings could not be set.") + return err + } + if err := g.MainLoop(); err != nil && err != gocui.ErrQuit { + log.Error("Error in the main loop. " + err.Error()) + return err + } + return nil +} + +func (gui *Gui) loadRepository(r *git.Repository) { + rs := gui.State.Repositories + + // insertion sort implementation + index := sort.Search(len(rs), func(i int) bool { return git.Less(r, rs[i]) }) + rs = append(rs, &git.Repository{}) + copy(rs[index+1:], rs[index:]) + rs[index] = r + // add listener + r.On(git.RepositoryUpdated, gui.repositoryUpdated) + // update gui + gui.repositoryUpdated(nil) + gui.renderTitle() + // take pointer back + gui.State.Repositories = rs + go func() { + if <-loaded { + v, err := gui.g.View(mainViewFeature.Name) + if err != nil { + log.Warn(err.Error()) + return + } + v.Title = mainViewFeature.Title + fmt.Sprintf("(%d) ", len(gui.State.Repositories)) + } + }() +} + +func (gui *Gui) renderTitle() error { + v, err := gui.g.View(mainViewFeature.Name) + if err != nil { + log.Warn(err.Error()) + return err + } + v.Title = mainViewFeature.Title + fmt.Sprintf("(%d/%d) ", len(gui.State.Repositories), len(gui.State.Directories)) + return nil +} + +// set the layout and create views with their default size, name etc. values +// TODO: window sizes can be handled better +func (gui *Gui) layout(g *gocui.Gui) error { + maxX, maxY := g.Size() + if v, err := g.SetView(mainViewFeature.Name, 0, 0, int(0.55*float32(maxX))-1, maxY-2); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = mainViewFeature.Title + v.Overwrite = true + if _, err := gui.setCurrentViewOnTop(g, mainViewFeature.Name); err != nil { + return err + } + } + if v, err := g.SetView(remoteViewFeature.Name, int(0.55*float32(maxX)), 0, maxX-1, int(0.10*float32(maxY))); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = remoteViewFeature.Title + v.Wrap = false + v.Autoscroll = false + } + if v, err := g.SetView(remoteBranchViewFeature.Name, int(0.55*float32(maxX)), int(0.10*float32(maxY))+1, maxX-1, int(0.35*float32(maxY))); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = remoteBranchViewFeature.Title + v.Wrap = false + v.Overwrite = false + } + if v, err := g.SetView(branchViewFeature.Name, int(0.55*float32(maxX)), int(0.35*float32(maxY))+1, maxX-1, int(0.60*float32(maxY))); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = branchViewFeature.Title + v.Wrap = false + v.Autoscroll = false + } + if v, err := g.SetView(commitViewFeature.Name, int(0.55*float32(maxX)), int(0.60*float32(maxY))+1, maxX-1, maxY-2); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = commitViewFeature.Title + v.Wrap = false + v.Autoscroll = false + } + if v, err := g.SetView(keybindingsViewFeature.Name, -1, maxY-2, maxX, maxY); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.BgColor = gocui.ColorWhite + v.FgColor = gocui.ColorBlack + v.Frame = false + gui.updateKeyBindingsView(g, mainViewFeature.Name) + } + return nil +} + +// focus to next view +func (gui *Gui) nextMainView(g *gocui.Gui, v *gocui.View) error { + return gui.nextViewOfGroup(g, v, mainViews) +} + +// focus to previous view +func (gui *Gui) previousMainView(g *gocui.Gui, v *gocui.View) error { + return gui.previousViewOfGroup(g, v, mainViews) +} + +// quit from the gui and end its loop +func (gui *Gui) quit(g *gocui.Gui, v *gocui.View) error { + return gocui.ErrQuit +} diff --git a/gui/keybindings.go b/gui/keybindings.go new file mode 100644 index 0000000..668fe90 --- /dev/null +++ b/gui/keybindings.go @@ -0,0 +1,675 @@ +package gui + +import ( + "fmt" + + "github.com/jroimartin/gocui" +) + +// KeyBinding structs is helpful for not re-writinh the same function over and +// over again. it hold useful values to generate a controls view +type KeyBinding struct { + View string + Handler func(*gocui.Gui, *gocui.View) error + Key interface{} + Modifier gocui.Modifier + Display string + Description string + Vital bool +} + +// generate the gui's controls a.k.a. keybindings +func (gui *Gui) generateKeybindings() error { + // Mainviews common keybindings + for _, view := range mainViews { + mainKeybindings := []*KeyBinding{ + { + View: view.Name, + Key: 'q', + Modifier: gocui.ModNone, + Handler: gui.quit, + Display: "q", + Description: "Quit", + Vital: true, + }, { + View: view.Name, + Key: 'f', + Modifier: gocui.ModNone, + Handler: gui.switchToFetchMode, + Display: "f", + Description: "Fetch mode", + Vital: false, + }, { + View: view.Name, + Key: 'p', + Modifier: gocui.ModNone, + Handler: gui.switchToPullMode, + Display: "p", + Description: "Pull mode", + Vital: false, + }, { + View: view.Name, + Key: 'm', + Modifier: gocui.ModNone, + Handler: gui.switchToMergeMode, + Display: "m", + Description: "Merge mode", + Vital: false, + }, { + View: view.Name, + Key: gocui.KeyTab, + Modifier: gocui.ModNone, + Handler: gui.nextMainView, + Display: "tab", + Description: "Next Panel", + Vital: false, + }, + } + gui.KeyBindings = append(gui.KeyBindings, mainKeybindings...) + } + for _, view := range sideViews { + sideViewKeybindings := []*KeyBinding{ + { + View: view.Name, + Key: gocui.KeyArrowDown, + Modifier: gocui.ModNone, + Handler: gui.sideViewsNextItem, + Display: "↓", + Description: "Iterate over branches", + Vital: false, + }, { + View: view.Name, + Key: gocui.KeyArrowUp, + Modifier: gocui.ModNone, + Handler: gui.sideViewsPreviousItem, + Display: "↑", + Description: "Iterate over branches", + Vital: false, + }, { + View: view.Name, + Key: 'j', + Modifier: gocui.ModNone, + Handler: gui.sideViewsNextItem, + Display: "j", + Description: "Down", + Vital: false, + }, { + View: view.Name, + Key: 'k', + Modifier: gocui.ModNone, + Handler: gui.sideViewsPreviousItem, + Display: "k", + Description: "Up", + Vital: false, + }, + } + gui.KeyBindings = append(gui.KeyBindings, sideViewKeybindings...) + } + // Statusviews common keybindings + for _, view := range statusViews { + statusKeybindings := []*KeyBinding{ + { + View: view.Name, + Key: 'q', + Modifier: gocui.ModNone, + Handler: gui.closeStatusView, + Display: "q", + Description: "Close/Cancel", + Vital: true, + }, { + View: view.Name, + Key: gocui.KeyTab, + Modifier: gocui.ModNone, + Handler: gui.nextStatusView, + Display: "tab", + Description: "Next Panel", + Vital: true, + }, { + View: view.Name, + Key: gocui.KeyArrowUp, + Modifier: gocui.ModNone, + Handler: gui.statusCursorUp, + Display: "↑", + Description: "Up", + Vital: false, + }, { + View: view.Name, + Key: gocui.KeyArrowDown, + Modifier: gocui.ModNone, + Handler: gui.statusCursorDown, + Display: "↓", + Description: "Down", + Vital: false, + }, { + View: view.Name, + Key: 'k', + Modifier: gocui.ModNone, + Handler: gui.statusCursorUp, + Display: "k", + Description: "Up", + Vital: false, + }, { + View: view.Name, + Key: 'j', + Modifier: gocui.ModNone, + Handler: gui.statusCursorDown, + Display: "j", + Description: "Down", + Vital: false, + }, { + View: view.Name, + Key: 't', + Modifier: gocui.ModNone, + Handler: gui.stashChanges, + Display: "t", + Description: "Save to Stash", + Vital: true, + }, { + View: view.Name, + Key: 'm', + Modifier: gocui.ModNone, + Handler: gui.openCommitMessageView, + Display: "m", + Description: "Commit Changes", + Vital: true, + }, + } + gui.KeyBindings = append(gui.KeyBindings, statusKeybindings...) + } + for _, view := range authViews { + authKeybindings := []*KeyBinding{ + { + View: view.Name, + Key: gocui.KeyEsc, + Modifier: gocui.ModNone, + Handler: gui.closeAuthenticationView, + Display: "esc", + Description: "Close/Cancel", + Vital: true, + }, { + View: view.Name, + Key: gocui.KeyTab, + Modifier: gocui.ModNone, + Handler: gui.nextAuthView, + Display: "tab", + Description: "Next Panel", + Vital: true, + }, { + View: view.Name, + Key: gocui.KeyEnter, + Modifier: gocui.ModNone, + Handler: gui.submitAuthenticationView, + Display: "enter", + Description: "Submit", + Vital: true, + }, + } + gui.KeyBindings = append(gui.KeyBindings, authKeybindings...) + } + for _, view := range commitViews { + commitKeybindings := []*KeyBinding{ + { + View: view.Name, + Key: gocui.KeyEsc, + Modifier: gocui.ModNone, + Handler: gui.closeCommitMessageView, + Display: "esc", + Description: "Close/Cancel", + Vital: true, + }, { + View: view.Name, + Key: gocui.KeyTab, + Modifier: gocui.ModNone, + Handler: gui.nextCommitView, + Display: "tab", + Description: "Next Panel", + Vital: true, + }, { + View: view.Name, + Key: gocui.KeyEnter, + Modifier: gocui.ModNone, + Handler: gui.submitCommitMessageView, + Display: "enter", + Description: "Submit", + Vital: true, + }, + } + gui.KeyBindings = append(gui.KeyBindings, commitKeybindings...) + } + individualKeybindings := []*KeyBinding{ + // stash view + { + View: stashViewFeature.Name, + Key: 'p', + Modifier: gocui.ModNone, + Handler: gui.popStash, + Display: "p", + Description: "Pop Item", + Vital: true, + }, { + View: stashViewFeature.Name, + Key: 'd', + Modifier: gocui.ModNone, + Handler: gui.showStash, + Display: "d", + Description: "Show diff", + Vital: true, + }, + // staged view + { + View: stageViewFeature.Name, + Key: 'r', + Modifier: gocui.ModNone, + Handler: gui.resetChanges, + Display: "r", + Description: "Reset Item", + Vital: true, + }, { + View: stageViewFeature.Name, + Key: gocui.KeyCtrlR, + Modifier: gocui.ModNone, + Handler: gui.resetAllChanges, + Display: "ctrl+r", + Description: "Reset All Items", + Vital: true, + }, { + View: stageViewFeature.Name, + Key: 'd', + Modifier: gocui.ModNone, + Handler: gui.openFileDiffView, + Display: "d", + Description: "Show diff", + Vital: true, + }, + // unstaged view + { + View: unstageViewFeature.Name, + Key: 'a', + Modifier: gocui.ModNone, + Handler: gui.addChanges, + Display: "a", + Description: "Add Item", + Vital: true, + }, { + View: unstageViewFeature.Name, + Key: gocui.KeyCtrlA, + Modifier: gocui.ModNone, + Handler: gui.addAllChanges, + Display: "ctrl+a", + Description: "Add All Items", + Vital: true, + }, { + View: unstageViewFeature.Name, + Key: 'd', + Modifier: gocui.ModNone, + Handler: gui.openFileDiffView, + Display: "d", + Description: "Show diff", + Vital: true, + }, + // Main view controls + { + View: mainViewFeature.Name, + Key: 'u', + Modifier: gocui.ModNone, + Handler: gui.submitCredentials, + Display: "u", + Description: "Submit Credentials", + Vital: false, + }, { + View: mainViewFeature.Name, + Key: gocui.KeyArrowUp, + Modifier: gocui.ModNone, + Handler: gui.cursorUp, + Display: "↑", + Description: "Up", + Vital: false, + }, { + View: mainViewFeature.Name, + Key: gocui.KeyPgup, + Modifier: gocui.ModNone, + Handler: gui.pageUp, + Display: "page up", + Description: "Page up", + Vital: false, + }, { + View: mainViewFeature.Name, + Key: gocui.KeyHome, + Modifier: gocui.ModNone, + Handler: gui.cursorTop, + Display: "home", + Description: "Home", + Vital: false, + }, { + View: mainViewFeature.Name, + Key: gocui.KeyPgdn, + Modifier: gocui.ModNone, + Handler: gui.pageDown, + Display: "page down", + Description: "Page Down", + Vital: false, + }, { + View: mainViewFeature.Name, + Key: gocui.KeyEnd, + Modifier: gocui.ModNone, + Handler: gui.cursorEnd, + Display: "end", + Description: "End", + Vital: false, + }, { + View: mainViewFeature.Name, + Key: gocui.KeyArrowDown, + Modifier: gocui.ModNone, + Handler: gui.cursorDown, + Display: "↓", + Description: "Down", + Vital: false, + }, { + View: mainViewFeature.Name, + Key: 'k', + Modifier: gocui.ModNone, + Handler: gui.cursorUp, + Display: "k", + Description: "Up", + Vital: false, + }, { + View: mainViewFeature.Name, + Key: 'j', + Modifier: gocui.ModNone, + Handler: gui.cursorDown, + Display: "j", + Description: "Down", + Vital: false, + }, { + View: mainViewFeature.Name, + Key: gocui.KeySpace, + Modifier: gocui.ModNone, + Handler: gui.markRepository, + Display: "space", + Description: "Select", + Vital: true, + }, { + View: mainViewFeature.Name, + Key: gocui.KeyEnter, + Modifier: gocui.ModNone, + Handler: gui.startQueue, + Display: "enter", + Description: "Start", + Vital: true, + }, { + View: mainViewFeature.Name, + Key: gocui.KeyCtrlSpace, + Modifier: gocui.ModNone, + Handler: gui.markAllRepositories, + Display: "ctrl + space", + Description: "Select All", + Vital: false, + }, { + View: mainViewFeature.Name, + Key: gocui.KeyBackspace2, + Modifier: gocui.ModNone, + Handler: gui.unmarkAllRepositories, + Display: "backspace", + Description: "Deselect All", + Vital: false, + }, { + View: mainViewFeature.Name, + Key: 'c', + Modifier: gocui.ModNone, + Handler: gui.openCheatSheetView, + Display: "c", + Description: "Controls", + Vital: true, + }, { + View: mainViewFeature.Name, + Key: 'n', + Modifier: gocui.ModNone, + Handler: gui.sortByName, + Display: "n", + Description: "Sort repositories by Name", + Vital: false, + }, { + View: mainViewFeature.Name, + Key: 'r', + Modifier: gocui.ModNone, + Handler: gui.sortByMod, + Display: "r", + Description: "Sort repositories by Modification date", + Vital: false, + }, { + View: mainViewFeature.Name, + Key: 's', + Modifier: gocui.ModNone, + Handler: gui.openStatusView, + Display: "s", + Description: "Open Status", + Vital: true, + }, { + View: "", + Key: gocui.KeyCtrlC, + Modifier: gocui.ModNone, + Handler: gui.quit, + Display: "ctrl + c", + Description: "Force application to quit", + Vital: false, + }, { + View: remoteBranchViewFeature.Name, + Key: 's', + Modifier: gocui.ModNone, + Handler: gui.syncRemoteBranch, + Display: "s", + Description: "Synch with Remote", + Vital: true, + }, { + View: branchViewFeature.Name, + Key: 'u', + Modifier: gocui.ModNone, + Handler: gui.setUpstreamToBranch, + Display: "u", + Description: "Set Upstream", + Vital: true, + }, { + View: commitViewFeature.Name, + Key: 'd', + Modifier: gocui.ModNone, + Handler: gui.openCommitDiffView, + Display: "d", + Description: "Show commit diff", + Vital: true, + }, + // upstream confirmation + { + View: confirmationViewFeature.Name, + Key: 'q', + Modifier: gocui.ModNone, + Handler: gui.closeConfirmationView, + Display: "q", + Description: "Close/Cancel", + Vital: true, + }, { + View: confirmationViewFeature.Name, + Key: gocui.KeyEnter, + Modifier: gocui.ModNone, + Handler: gui.confirmSetUpstreamToBranch, + Display: "enter", + Description: "Set Upstream", + Vital: true, + }, + // Diff View Controls + { + View: diffViewFeature.Name, + Key: 'q', + Modifier: gocui.ModNone, + Handler: gui.closeCommitDiffView, + Display: "q", + Description: "Close/Cancel", + Vital: true, + }, { + View: diffViewFeature.Name, + Key: gocui.KeyArrowUp, + Modifier: gocui.ModNone, + Handler: gui.fastCursorUp, + Display: "↑", + Description: "Page up", + Vital: true, + }, { + View: diffViewFeature.Name, + Key: gocui.KeyArrowDown, + Modifier: gocui.ModNone, + Handler: gui.fastCursorDown, + Display: "↓", + Description: "Page down", + Vital: true, + }, { + View: diffViewFeature.Name, + Key: 'k', + Modifier: gocui.ModNone, + Handler: gui.fastCursorUp, + Display: "k", + Description: "Page up", + Vital: false, + }, { + View: diffViewFeature.Name, + Key: 'j', + Modifier: gocui.ModNone, + Handler: gui.fastCursorDown, + Display: "j", + Description: "Page down", + Vital: false, + }, + // Application Controls + { + View: cheatSheetViewFeature.Name, + Key: 'q', + Modifier: gocui.ModNone, + Handler: gui.closeCheatSheetView, + Display: "q", + Description: "Close/Cancel", + Vital: true, + }, { + View: cheatSheetViewFeature.Name, + Key: gocui.KeyArrowUp, + Modifier: gocui.ModNone, + Handler: gui.fastCursorUp, + Display: "↑", + Description: "Up", + Vital: true, + }, { + View: cheatSheetViewFeature.Name, + Key: gocui.KeyArrowDown, + Modifier: gocui.ModNone, + Handler: gui.fastCursorDown, + Display: "↓", + Description: "Down", + Vital: true, + }, { + View: cheatSheetViewFeature.Name, + Key: 'k', + Modifier: gocui.ModNone, + Handler: gui.fastCursorUp, + Display: "k", + Description: "Up", + Vital: false, + }, { + View: cheatSheetViewFeature.Name, + Key: 'j', + Modifier: gocui.ModNone, + Handler: gui.fastCursorDown, + Display: "j", + Description: "Down", + Vital: false, + }, + // Error View + { + View: errorViewFeature.Name, + Key: 'q', + Modifier: gocui.ModNone, + Handler: gui.closeErrorView, + Display: "q", + Description: "Close/Cancel", + Vital: true, + }, { + View: errorViewFeature.Name, + Key: gocui.KeyArrowUp, + Modifier: gocui.ModNone, + Handler: gui.fastCursorUp, + Display: "↑", + Description: "Up", + Vital: true, + }, { + View: errorViewFeature.Name, + Key: gocui.KeyArrowDown, + Modifier: gocui.ModNone, + Handler: gui.fastCursorDown, + Display: "↓", + Description: "Down", + Vital: true, + }, { + View: errorViewFeature.Name, + Key: 'k', + Modifier: gocui.ModNone, + Handler: gui.fastCursorUp, + Display: "k", + Description: "Up", + Vital: false, + }, { + View: errorViewFeature.Name, + Key: 'j', + Modifier: gocui.ModNone, + Handler: gui.fastCursorDown, + Display: "j", + Description: "Down", + Vital: false, + }, + } + gui.KeyBindings = append(gui.KeyBindings, individualKeybindings...) + return nil +} + +// set the guis by iterating over a slice of the gui's keybindings struct +func (gui *Gui) keybindings(g *gocui.Gui) error { + for _, k := range gui.KeyBindings { + if err := g.SetKeybinding(k.View, k.Key, k.Modifier, k.Handler); err != nil { + return err + } + } + return nil +} + +// the bottom line of the gui is mode indicator and keybindings view. Only the +// important controls (marked as vital) are shown +func (gui *Gui) updateKeyBindingsView(g *gocui.Gui, viewName string) error { + v, err := g.View(keybindingsViewFeature.Name) + if err != nil { + return err + } + v.Clear() + v.BgColor = gocui.ColorWhite + v.FgColor = gocui.ColorBlack + v.Frame = false + fmt.Fprint(v, ws) + modeLabel := "" + switch mode := gui.State.Mode.ModeID; mode { + case FetchMode: + v.BgColor = gocui.ColorBlue + modeLabel = fetchSymbol + ws + "FETCH" + case PullMode: + v.BgColor = gocui.ColorMagenta + modeLabel = pullSymbol + ws + "PULL" + case MergeMode: + v.BgColor = gocui.ColorCyan + modeLabel = mergeSymbol + ws + "MERGE" + default: + modeLabel = "No mode selected" + } + + fmt.Fprint(v, ws+modeLabel+ws+modeSeperator) + + for _, k := range gui.KeyBindings { + if k.View == viewName && k.Vital { + binding := keyBindingSeperator + ws + k.Display + ":" + ws + k.Description + ws + fmt.Fprint(v, binding) + } + } + return nil +} diff --git a/gui/mainview.go b/gui/mainview.go new file mode 100644 index 0000000..6cdaf61 --- /dev/null +++ b/gui/mainview.go @@ -0,0 +1,302 @@ +package gui + +import ( + "fmt" + "sort" + + gerr "github.com/isacikgoz/gitbatch/core/errors" + "github.com/isacikgoz/gitbatch/core/git" + "github.com/isacikgoz/gitbatch/core/job" + "github.com/jroimartin/gocui" + log "github.com/sirupsen/logrus" +) + +// refresh the main view and re-render the repository representations +func (gui *Gui) renderMain() error { + gui.mutex.Lock() + defer gui.mutex.Unlock() + + mainView, err := gui.g.View(mainViewFeature.Name) + if err != nil { + return err + } + mainView.Clear() + for _, r := range gui.State.Repositories { + fmt.Fprintln(mainView, gui.repositoryLabel(r)) + } + // while refreshing, refresh sideViews for selected entity, something may + // be changed? + return gui.renderSideViews(gui.getSelectedRepository()) +} + +// listens the event -> "repository.updated" +func (gui *Gui) repositoryUpdated(event *git.RepositoryEvent) error { + gui.g.Update(func(g *gocui.Gui) error { + return gui.renderMain() + }) + return nil +} + +// moves the cursor downwards for the main view and if it goes to bottom it +// prevents from going further +func (gui *Gui) cursorDown(g *gocui.Gui, v *gocui.View) error { + if v != nil { + cx, cy := v.Cursor() + ox, oy := v.Origin() + ly := len(gui.State.Repositories) - 1 + + // if we are at the end we just return + if cy+oy == ly { + return nil + } + if err := v.SetCursor(cx, cy+1); err != nil { + if err := v.SetOrigin(ox, oy+1); err != nil { + return err + } + } + } + return gui.renderMain() +} + +// moves the cursor upwards for the main view +func (gui *Gui) cursorUp(g *gocui.Gui, v *gocui.View) error { + if v != nil { + ox, oy := v.Origin() + cx, cy := v.Cursor() + if err := v.SetCursor(cx, cy-1); err != nil && oy > 0 { + if err := v.SetOrigin(ox, oy-1); err != nil { + return err + } + } + } + return gui.renderMain() +} + +// moves cursor to the top +func (gui *Gui) cursorTop(g *gocui.Gui, v *gocui.View) error { + if v != nil { + ox, _ := v.Origin() + cx, _ := v.Cursor() + if err := v.SetOrigin(ox, 0); err != nil { + return err + } + if err := v.SetCursor(cx, 0); err != nil { + return err + } + } + return gui.renderMain() +} + +// moves cursor to the end +func (gui *Gui) cursorEnd(g *gocui.Gui, v *gocui.View) error { + if v != nil { + ox, _ := v.Origin() + cx, _ := v.Cursor() + _, vy := v.Size() + lr := len(gui.State.Repositories) + if lr <= vy { + if err := v.SetCursor(cx, lr-1); err != nil { + return err + } + return gui.renderMain() + } + if err := v.SetOrigin(ox, lr-vy); err != nil { + return err + } + if err := v.SetCursor(cx, vy-1); err != nil { + return err + } + } + return gui.renderMain() +} + +// moves cursor down for a page size +func (gui *Gui) pageDown(g *gocui.Gui, v *gocui.View) error { + if v != nil { + ox, oy := v.Origin() + cx, _ := v.Cursor() + _, vy := v.Size() + lr := len(gui.State.Repositories) + if lr < vy { + return nil + } + if oy+vy >= lr-vy { + if err := v.SetOrigin(ox, lr-vy); err != nil { + return err + } + } else if err := v.SetOrigin(ox, oy+vy); err != nil { + return err + } + if err := v.SetCursor(cx, 0); err != nil { + return err + } + } + return gui.renderMain() +} + +// moves cursor up for a page size +func (gui *Gui) pageUp(g *gocui.Gui, v *gocui.View) error { + if v != nil { + ox, oy := v.Origin() + cx, cy := v.Cursor() + _, vy := v.Size() + if oy == 0 || oy+cy < vy { + if err := v.SetOrigin(ox, 0); err != nil { + return err + } + } else if oy <= vy { + if err := v.SetOrigin(ox, oy+cy-vy); err != nil { + return err + } + } else if err := v.SetOrigin(ox, oy-vy); err != nil { + return err + } + if err := v.SetCursor(cx, 0); err != nil { + return err + } + } + return gui.renderMain() +} + +// returns the entity at cursors position by taking its position in the gui's +// slice of repositories. Since it is not a %100 percent safe methodology it may +// rrequire a better implementation or the slice's order must be synchronized +// with the views lines +func (gui *Gui) getSelectedRepository() *git.Repository { + if len(gui.State.Repositories) == 0 { + return nil + } + v, _ := gui.g.View(mainViewFeature.Name) + _, oy := v.Origin() + _, cy := v.Cursor() + return gui.State.Repositories[cy+oy] +} + +// adds given entity to job queue +func (gui *Gui) addToQueue(r *git.Repository) error { + var jt job.JobType + switch mode := gui.State.Mode.ModeID; mode { + case FetchMode: + jt = job.FetchJob + case PullMode: + jt = job.PullJob + case MergeMode: + jt = job.MergeJob + default: + return nil + } + err := gui.State.Queue.AddJob(&job.Job{ + JobType: jt, + Repository: r, + }) + if err != nil { + return err + } + r.SetWorkStatus(git.Queued) + return nil +} + +// removes given entity from job queue +func (gui *Gui) removeFromQueue(r *git.Repository) error { + err := gui.State.Queue.RemoveFromQueue(r) + if err != nil { + return err + } + r.SetWorkStatus(git.Available) + return nil +} + +// this function starts the queue and updates the gui with the result of an +// operation +func (gui *Gui) startQueue(g *gocui.Gui, v *gocui.View) error { + go func(gui_go *Gui) { + fails := gui_go.State.Queue.StartJobsAsync() + gui_go.State.Queue = job.CreateJobQueue() + for j, err := range fails { + if err == gerr.ErrAuthenticationRequired { + j.Repository.SetWorkStatus(git.Paused) + gui_go.State.FailoverQueue.AddJob(j) + } + } + }(gui) + return nil +} + +func (gui *Gui) submitCredentials(g *gocui.Gui, v *gocui.View) error { + if is, j := gui.State.FailoverQueue.IsInTheQueue(gui.getSelectedRepository()); is { + if j.Repository.WorkStatus() == git.Paused { + gui.State.FailoverQueue.RemoveFromQueue(j.Repository) + err := gui.openAuthenticationView(g, gui.State.Queue, j, v.Name()) + if err != nil { + log.Warn(err.Error()) + return err + } + if isnt, _ := gui.State.Queue.IsInTheQueue(j.Repository); !isnt { + gui.State.FailoverQueue.AddJob(j) + } + } + } + return nil +} + +// marking repository is simply adding the repostirory into the queue. the +// function does take its current state into account before adding it +func (gui *Gui) markRepository(g *gocui.Gui, v *gocui.View) error { + r := gui.getSelectedRepository() + // maybe, failed entities may be added to queue again + if r.WorkStatus().Ready { + if err := gui.addToQueue(r); err != nil { + return err + } + } else if r.WorkStatus() == git.Queued { + if err := gui.removeFromQueue(r); err != nil { + return err + } + } + return nil +} + +// add all remaining repositories into the queue. the function does take its +// current state into account before adding it +func (gui *Gui) markAllRepositories(g *gocui.Gui, v *gocui.View) error { + for _, r := range gui.State.Repositories { + if r.WorkStatus().Ready { + if err := gui.addToQueue(r); err != nil { + return err + } + } else { + continue + } + } + return nil +} + +// remove all repositories from the queue. the function does take its +// current state into account before removing it +func (gui *Gui) unmarkAllRepositories(g *gocui.Gui, v *gocui.View) error { + for _, r := range gui.State.Repositories { + if r.WorkStatus() == git.Queued { + if err := gui.removeFromQueue(r); err != nil { + return err + } + } else { + continue + } + } + return nil +} + +// sortByName sorts the repositories by A to Z order +func (gui *Gui) sortByName(g *gocui.Gui, v *gocui.View) error { + sort.Sort(git.Alphabetical(gui.State.Repositories)) + gui.renderMain() + return nil +} + +// sortByMod sorts the repositories according to last modifed date +// the top element will be the last modified +func (gui *Gui) sortByMod(g *gocui.Gui, v *gocui.View) error { + sort.Sort(git.LastModified(gui.State.Repositories)) + gui.renderMain() + return nil +} diff --git a/gui/sideviews.go b/gui/sideviews.go new file mode 100644 index 0000000..1a910cf --- /dev/null +++ b/gui/sideviews.go @@ -0,0 +1,232 @@ +package gui + +import ( + "fmt" + + "github.com/isacikgoz/gitbatch/core/command" + "github.com/isacikgoz/gitbatch/core/git" + "github.com/jroimartin/gocui" +) + +var ( + confirmationViewFeature = viewFeature{Name: "confirmation", Title: " Confirmation "} + sideViews = []viewFeature{remoteViewFeature, remoteBranchViewFeature, branchViewFeature, commitViewFeature} +) + +// refreshes the side views of the application for given repository.Repository struct +func (gui *Gui) renderSideViews(r *git.Repository) error { + if r == nil { + return nil + } + + if err := gui.renderRemotes(r); err != nil { + return err + } + if err := gui.renderBranch(r); err != nil { + return err + } + if err := gui.renderRemoteBranches(r); err != nil { + return err + } + if err := gui.renderCommits(r); err != nil { + return err + } + return nil +} + +// updates the remotesview for given entity +func (gui *Gui) renderRemotes(r *git.Repository) error { + var err error + out, err := gui.g.View(remoteViewFeature.Name) + if err != nil { + return err + } + out.Clear() + currentindex := 0 + totalRemotes := len(r.Remotes) + if totalRemotes > 0 { + for i, rm := range r.Remotes { + _, shortURL := trimRemoteURL(rm.URL[0]) + if rm.Name == r.State.Remote.Name { + currentindex = i + fmt.Fprintln(out, selectionIndicator+rm.Name+": "+shortURL) + continue + } + fmt.Fprintln(out, tab+rm.Name+": "+shortURL) + } + if err = gui.smartAnchorRelativeToLine(out, currentindex, totalRemotes); err != nil { + return err + } + } + return nil +} + +// updates the remotebranchview for given entity +func (gui *Gui) renderRemoteBranches(r *git.Repository) error { + var err error + out, err := gui.g.View(remoteBranchViewFeature.Name) + if err != nil { + return err + } + out.Clear() + currentindex := 0 + trb := len(r.State.Remote.Branches) + if trb > 0 { + for i, rm := range r.State.Remote.Branches { + if rm.Name == r.State.Remote.Branch.Name { + currentindex = i + fmt.Fprintln(out, selectionIndicator+rm.Name) + continue + } + fmt.Fprintln(out, tab+rm.Name) + } + if err = gui.smartAnchorRelativeToLine(out, currentindex, trb); err != nil { + return err + } + } + return nil +} + +// updates the branchview for given entity +func (gui *Gui) renderBranch(r *git.Repository) error { + var err error + out, err := gui.g.View(branchViewFeature.Name) + if err != nil { + return err + } + out.Clear() + currentindex := 0 + totalbranches := len(r.Branches) + for i, b := range r.Branches { + if b.Name == r.State.Branch.Name { + currentindex = i + fmt.Fprintln(out, selectionIndicator+b.Name) + continue + } + fmt.Fprintln(out, tab+b.Name) + } + + return gui.smartAnchorRelativeToLine(out, currentindex, totalbranches) +} + +// updates the commitsview for given entity +func (gui *Gui) renderCommits(r *git.Repository) error { + var err error + out, err := gui.g.View(commitViewFeature.Name) + if err != nil { + return err + } + out.Clear() + currentindex := 0 + totalcommits := len(r.Commits) + for i, c := range r.Commits { + if c.Hash == r.State.Commit.Hash { + currentindex = i + fmt.Fprintln(out, selectionIndicator+commitLabel(c)) + continue + } + fmt.Fprintln(out, tab+commitLabel(c)) + } + return gui.smartAnchorRelativeToLine(out, currentindex, totalcommits) +} + +// cursor down variant for sideviews +func (gui *Gui) sideViewsNextItem(g *gocui.Gui, v *gocui.View) error { + var err error + r := gui.getSelectedRepository() + switch viewName := v.Name(); viewName { + case remoteBranchViewFeature.Name: + return r.State.Remote.NextRemoteBranch(r) + case remoteViewFeature.Name: + return r.NextRemote() + case branchViewFeature.Name: + if err = r.Checkout(r.NextBranch()); err != nil { + err = gui.openErrorView(g, err.Error(), + "You should manually resolve this issue", + branchViewFeature.Name) + return err + } + case commitViewFeature.Name: + r.NextCommit() + return gui.renderCommits(r) + } + return err +} + +// cursor up variant for sideviews +func (gui *Gui) sideViewsPreviousItem(g *gocui.Gui, v *gocui.View) error { + var err error + r := gui.getSelectedRepository() + switch viewName := v.Name(); viewName { + case remoteBranchViewFeature.Name: + return r.State.Remote.PreviousRemoteBranch(r) + case remoteViewFeature.Name: + return r.PreviousRemote() + case branchViewFeature.Name: + if err = r.Checkout(r.PreviousBranch()); err != nil { + err = gui.openErrorView(g, err.Error(), + "You should manually resolve this issue", + branchViewFeature.Name) + return err + } + case commitViewFeature.Name: + r.PreviousCommit() + return gui.renderCommits(r) + } + return err +} + +// basically does fetch --prune +func (gui *Gui) syncRemoteBranch(g *gocui.Gui, v *gocui.View) error { + r := gui.getSelectedRepository() + return command.Fetch(r, command.FetchOptions{ + RemoteName: r.State.Remote.Name, + Prune: true, + }) +} + +// opens a confirmation view for setting default merge branch +func (gui *Gui) setUpstreamToBranch(g *gocui.Gui, v *gocui.View) error { + maxX, maxY := g.Size() + + r := gui.getSelectedRepository() + v, err := g.SetView(confirmationViewFeature.Name, maxX/2-30, maxY/2-2, maxX/2+30, maxY/2+2) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + fmt.Fprintln(v, "branch."+r.State.Branch.Name+"."+"remote"+"="+r.State.Remote.Name) + fmt.Fprintln(v, "branch."+r.State.Branch.Name+"."+"merge"+"="+r.State.Branch.Reference.Name().String()) + } + return gui.focusToView(confirmationViewFeature.Name) +} + +// add config for upstream merge +func (gui *Gui) confirmSetUpstreamToBranch(g *gocui.Gui, v *gocui.View) error { + var err error + r := gui.getSelectedRepository() + if err = command.AddConfig(r, command.ConfigOptions{ + Section: "branch." + r.State.Branch.Name, + Option: "remote", + Site: command.ConfigSiteLocal, + }, r.State.Remote.Name); err != nil { + return err + } + if err = command.AddConfig(r, command.ConfigOptions{ + Section: "branch." + r.State.Branch.Name, + Option: "merge", + Site: command.ConfigSiteLocal, + }, r.State.Branch.Reference.Name().String()); err != nil { + return err + } + r.Refresh() + return gui.closeConfirmationView(g, v) +} + +// close confirmation view +func (gui *Gui) closeConfirmationView(g *gocui.Gui, v *gocui.View) error { + if err := g.DeleteView(v.Name()); err != nil { + return err + } + return gui.closeViewCleanup(branchViewFeature.Name) +} diff --git a/gui/stagedview.go b/gui/stagedview.go new file mode 100644 index 0000000..95b474a --- /dev/null +++ b/gui/stagedview.go @@ -0,0 +1,73 @@ +package gui + +import ( + "fmt" + + "github.com/isacikgoz/gitbatch/core/command" + "github.com/jroimartin/gocui" +) + +// staged view +func (gui *Gui) openStageView(g *gocui.Gui) error { + maxX, maxY := g.Size() + + v, err := g.SetView(stageViewFeature.Name, 6, 5, maxX/2-1, int(0.75*float32(maxY))-1) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = stageViewFeature.Title + } + if err := refreshStagedView(g); err != nil { + return err + } + return gui.focusToView(stageViewFeature.Name) +} + +func (gui *Gui) resetChanges(g *gocui.Gui, v *gocui.View) error { + r := gui.getSelectedRepository() + + _, cy := v.Cursor() + _, oy := v.Origin() + if len(stagedFiles) <= 0 || len(stagedFiles) <= cy+oy { + return nil + } + if err := command.Reset(r, stagedFiles[cy+oy], command.ResetOptions{}); err != nil { + return err + } + return refreshAllStatusView(g, r, true) +} + +func (gui *Gui) resetAllChanges(g *gocui.Gui, v *gocui.View) error { + r := gui.getSelectedRepository() + ref, err := r.Repo.Head() + if err != nil { + return err + } + if err := command.ResetAll(r, command.ResetOptions{ + Hash: ref.Hash().String(), + Rtype: command.ResetMixed, + }); err != nil { + return err + } + return refreshAllStatusView(g, r, true) +} + +// refresh the main view and re-render the repository representations +func refreshStagedView(g *gocui.Gui) error { + stageView, err := g.View(stageViewFeature.Name) + if err != nil { + return err + } + stageView.Clear() + _, cy := stageView.Cursor() + _, oy := stageView.Origin() + for i, file := range stagedFiles { + var prefix string + if i == cy+oy { + prefix = prefix + selectionIndicator + } + fmt.Fprintf(stageView, "%s%s%s %s\n", prefix, green.Sprint(string(file.X)), red.Sprint(string(file.Y)), file.Name) + } + return nil +} diff --git a/gui/stashview.go b/gui/stashview.go new file mode 100644 index 0000000..f1ecc48 --- /dev/null +++ b/gui/stashview.go @@ -0,0 +1,84 @@ +package gui + +import ( + "fmt" + + "github.com/isacikgoz/gitbatch/core/git" + "github.com/jroimartin/gocui" +) + +// stash view +func (gui *Gui) openStashView(g *gocui.Gui) error { + maxX, maxY := g.Size() + + v, err := g.SetView(stashViewFeature.Name, 6, int(0.75*float32(maxY)), maxX-6, maxY-3) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = stashViewFeature.Title + } + r := gui.getSelectedRepository() + err = refreshStashView(g, r) + return err +} + +// +func (gui *Gui) stashChanges(g *gocui.Gui, v *gocui.View) error { + r := gui.getSelectedRepository() + output, err := r.Stash() + if err != nil { + if err = gui.openErrorView(g, output, + "You should manually resolve this issue", + stashViewFeature.Name); err != nil { + return err + } + } + err = refreshAllStatusView(g, r, true) + return err +} + +// +func (gui *Gui) popStash(g *gocui.Gui, v *gocui.View) error { + r := gui.getSelectedRepository() + _, oy := v.Origin() + _, cy := v.Cursor() + if len(r.Stasheds) <= 0 { + return nil + } + stashedItem := r.Stasheds[oy+cy] + output, err := stashedItem.Pop() + if err != nil { + if err = gui.openErrorView(g, output, + "You should manually resolve this issue", + stashViewFeature.Name); err != nil { + return err + } + } + // since the pop is a func of stashed item, we need to refresh entity here + if err := r.Refresh(); err != nil { + return err + } + + return refreshAllStatusView(g, r, true) +} + +// refresh the main view and re-render the repository representations +func refreshStashView(g *gocui.Gui, r *git.Repository) error { + stashView, err := g.View(stashViewFeature.Name) + if err != nil { + return err + } + stashView.Clear() + _, cy := stashView.Cursor() + _, oy := stashView.Origin() + stashedItems := r.Stasheds + for i, stashedItem := range stashedItems { + var prefix string + if i == cy+oy { + prefix = prefix + selectionIndicator + } + fmt.Fprintf(stashView, "%s%d %s: %s (%s)\n", prefix, stashedItem.StashID, cyan.Sprint(stashedItem.BranchName), stashedItem.Description, cyan.Sprint(stashedItem.Hash)) + } + return nil +} diff --git a/gui/statusview.go b/gui/statusview.go new file mode 100644 index 0000000..7b92e6a --- /dev/null +++ b/gui/statusview.go @@ -0,0 +1,162 @@ +package gui + +import ( + "fmt" + + "github.com/isacikgoz/gitbatch/core/command" + "github.com/isacikgoz/gitbatch/core/git" + "github.com/jroimartin/gocui" +) + +var ( + statusHeaderViewFeature = viewFeature{Name: "status-header", Title: " Status Header "} + stageViewFeature = viewFeature{Name: "staged", Title: " Staged "} + unstageViewFeature = viewFeature{Name: "unstaged", Title: " Not Staged "} + stashViewFeature = viewFeature{Name: "stash", Title: " Stash "} + + statusViews = []viewFeature{stageViewFeature, unstageViewFeature, stashViewFeature} + + commitMesageReturnView string + stagedFiles []*command.File + unstagedFiles []*command.File +) + +// open the status layout +func (gui *Gui) openStatusView(g *gocui.Gui, v *gocui.View) error { + if err := populateFileLists(gui.getSelectedRepository()); err != nil { + return err + } + gui.openStatusHeaderView(g) + gui.openStageView(g) + gui.openUnStagedView(g) + gui.openStashView(g) + return nil +} + +// focus to next view +func (gui *Gui) nextStatusView(g *gocui.Gui, v *gocui.View) error { + return gui.nextViewOfGroup(g, v, statusViews) +} + +// focus to previous view +func (gui *Gui) previousStatusView(g *gocui.Gui, v *gocui.View) error { + return gui.previousViewOfGroup(g, v, statusViews) +} + +// moves the cursor downwards for the main view and if it goes to bottom it +// prevents from going further +func (gui *Gui) statusCursorDown(g *gocui.Gui, v *gocui.View) error { + if v == nil { + return nil + } + + cx, cy := v.Cursor() + ox, oy := v.Origin() + ly := len(v.BufferLines()) - 2 // why magic number? have no idea + + // if we are at the end we just return + if cy+oy == ly { + return nil + } + if err := v.SetCursor(cx, cy+1); err != nil { + + if err := v.SetOrigin(ox, oy+1); err != nil { + return err + } + } + r := gui.getSelectedRepository() + return refreshStatusView(v.Name(), g, r, false) +} + +// moves the cursor upwards for the main view +func (gui *Gui) statusCursorUp(g *gocui.Gui, v *gocui.View) error { + if v == nil { + return nil + } + + ox, oy := v.Origin() + cx, cy := v.Cursor() + if err := v.SetCursor(cx, cy-1); err != nil && oy > 0 { + if err := v.SetOrigin(ox, oy-1); err != nil { + return err + } + } + r := gui.getSelectedRepository() + return refreshStatusView(v.Name(), g, r, false) +} + +// header og the status layout +func (gui *Gui) openStatusHeaderView(g *gocui.Gui) error { + maxX, _ := g.Size() + r := gui.getSelectedRepository() + v, err := g.SetView(statusHeaderViewFeature.Name, 6, 2, maxX-6, 4) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + fmt.Fprintln(v, r.AbsPath) + // v.Frame = false + v.Wrap = true + } + return nil +} + +// close the opened stat views +func (gui *Gui) closeStatusView(g *gocui.Gui, v *gocui.View) error { + for _, view := range statusViews { + if err := g.DeleteView(view.Name); err != nil { + return err + } + } + if err := g.DeleteView(statusHeaderViewFeature.Name); err != nil { + return err + } + stagedFiles = make([]*command.File, 0) + unstagedFiles = make([]*command.File, 0) + + return gui.closeViewCleanup(mainViewFeature.Name) +} + +// generate file lists by git status command +func populateFileLists(r *git.Repository) error { + files, err := command.Status(r) + if err != nil { + return err + } + stagedFiles = make([]*command.File, 0) + unstagedFiles = make([]*command.File, 0) + for _, file := range files { + if file.X != command.StatusNotupdated && file.X != command.StatusUntracked && file.X != command.StatusIgnored && file.X != command.StatusUpdated { + stagedFiles = append(stagedFiles, file) + } + if file.Y != command.StatusNotupdated { + unstagedFiles = append(unstagedFiles, file) + } + } + return err +} + +func refreshStatusView(viewName string, g *gocui.Gui, r *git.Repository, reload bool) error { + if reload { + populateFileLists(r) + } + var err error + switch viewName { + case stageViewFeature.Name: + err = refreshStagedView(g) + case unstageViewFeature.Name: + err = refreshUnstagedView(g) + case stashViewFeature.Name: + err = refreshStashView(g, r) + } + return err +} + +func refreshAllStatusView(g *gocui.Gui, r *git.Repository, reload bool) error { + for _, v := range statusViews { + if err := refreshStatusView(v.Name, g, r, reload); err != nil { + return err + } + } + return nil +} diff --git a/gui/text-renderer.go b/gui/text-renderer.go new file mode 100644 index 0000000..d699acf --- /dev/null +++ b/gui/text-renderer.go @@ -0,0 +1,191 @@ +package gui + +import ( + "regexp" + "strings" + + "github.com/fatih/color" + "github.com/isacikgoz/gitbatch/core/git" + "github.com/isacikgoz/gitbatch/core/job" +) + +var ( + black = color.New(color.FgBlack) + blue = color.New(color.FgBlue) + green = color.New(color.FgGreen) + red = color.New(color.FgRed) + cyan = color.New(color.FgCyan) + yellow = color.New(color.FgYellow) + white = color.New(color.FgWhite) + magenta = color.New(color.FgMagenta) + + bold = color.New(color.Bold) + + maxBranchLength = 15 + maxRepositoryLength = 20 + hashLength = 7 + + ws = " " + pushable = string(blue.Sprint("↖")) + pullable = string(blue.Sprint("↘")) + dirty = string(yellow.Sprint("✗")) + + queuedSymbol = "•" + workingSymbol = "•" + successSymbol = "✔" + pauseSymbol = "॥" + failSymbol = "✗" + + fetchSymbol = "↓" + pullSymbol = "↓↳" + mergeSymbol = "↳" + + keySymbol = ws + yellow.Sprint("🔑") + ws + + modeSeperator = "" + keyBindingSeperator = "░" + + selectionIndicator = ws + string(green.Sprint("→")) + ws + tab = ws +) + +// this function handles the render and representation of the repository +// TODO: cleanup is required, right now it looks too complicated +func (gui *Gui) repositoryLabel(r *git.Repository) string { + + var prefix string + b := r.State.Branch + if b.Pushables != "?" { + prefix = prefix + pushable + ws + b.Pushables + + ws + pullable + ws + b.Pullables + } else { + prefix = prefix + pushable + ws + yellow.Sprint(b.Pushables) + + ws + pullable + ws + yellow.Sprint(b.Pullables) + } + + var repoName string + sr := gui.getSelectedRepository() + if sr == r { + prefix = prefix + selectionIndicator + repoName = green.Sprint(r.Name) + } else { + prefix = prefix + ws + repoName = r.Name + } + // some branch names can be really long, in that times I hope the first + // characters are important and meaningful + branch := adjustTextLength(b.Name, maxBranchLength) + prefix = prefix + string(cyan.Sprint(branch)) + + if !b.Clean { + prefix = prefix + ws + dirty + ws + } else { + prefix = prefix + ws + } + + var suffix string + // rendering the satus according to repository's state + if r.WorkStatus() == git.Queued { + if inQueue, j := gui.State.Queue.IsInTheQueue(r); inQueue { + switch mode := j.JobType; mode { + case job.FetchJob: + suffix = blue.Sprint(queuedSymbol) + case job.PullJob: + suffix = magenta.Sprint(queuedSymbol) + case job.MergeJob: + suffix = cyan.Sprint(queuedSymbol) + default: + suffix = green.Sprint(queuedSymbol) + } + } + return prefix + repoName + ws + suffix + } else if r.WorkStatus() == git.Working { + // TODO: maybe the type of the job can be written while its working? + return prefix + repoName + ws + green.Sprint(workingSymbol) + } else if r.WorkStatus() == git.Success { + return prefix + repoName + ws + green.Sprint(successSymbol) + } else if r.WorkStatus() == git.Paused { + return prefix + repoName + ws + yellow.Sprint("authentication required (u)") + } else if r.WorkStatus() == git.Fail { + return prefix + repoName + ws + red.Sprint(failSymbol) + ws + red.Sprint(r.State.Message) + } + return prefix + repoName +} + +func commitLabel(c *git.Commit) string { + var body string + switch c.CommitType { + case git.EvenCommit: + body = cyan.Sprint(c.Hash[:hashLength]) + " " + c.Message + case git.LocalCommit: + body = blue.Sprint(c.Hash[:hashLength]) + " " + c.Message + case git.RemoteCommit: + if len(c.Hash) > hashLength { + body = yellow.Sprint(c.Hash[:hashLength]) + " " + c.Message + } else { + body = yellow.Sprint(c.Hash[:len(c.Hash)]) + " " + c.Message + } + default: + body = c.Hash[:hashLength] + " " + c.Message + } + return body +} + +// limit the text length for visual concerns +func adjustTextLength(text string, maxLength int) string { + if len(text) > maxLength { + return text[:maxLength-2] + ".." + } + return text +} + +// colorize the plain diff text collected from system output +// the style is near to original diff command +func colorizeDiff(original string) (colorized []string) { + colorized = strings.Split(original, "\n") + re := regexp.MustCompile(`@@ .+ @@`) + for i, line := range colorized { + if len(line) > 0 { + if line[0] == '-' { + colorized[i] = red.Sprint(line) + } else if line[0] == '+' { + colorized[i] = green.Sprint(line) + } else if re.MatchString(line) { + s := re.FindString(line) + colorized[i] = cyan.Sprint(s) + line[len(s):] + } else { + continue + } + } else { + continue + } + } + return colorized +} + +// the remote link can be too verbose sometimes, so it is good to trim it +func trimRemoteURL(url string) (urltype string, shorturl string) { + // lets trim the unnecessary .git extension of the url + regit := regexp.MustCompile(`.git`) + if regit.MatchString(url[len(url)-4:]) { + url = url[:len(url)-4] + } + + // find out the protocol + ressh := regexp.MustCompile(`git@`) + rehttp := regexp.MustCompile(`http://`) + rehttps := regexp.MustCompile(`https://`) + + // separate the protocol and remote link + if ressh.MatchString(url) { + shorturl = ressh.Split(url, 5)[1] + urltype = "ssh" + } else if rehttp.MatchString(url) { + shorturl = rehttp.Split(url, 5)[1] + urltype = "http" + } else if rehttps.MatchString(url) { + shorturl = rehttps.Split(url, 5)[1] + urltype = "https" + } + return urltype, shorturl +} diff --git a/gui/unstagedview.go b/gui/unstagedview.go new file mode 100644 index 0000000..f5b6e43 --- /dev/null +++ b/gui/unstagedview.go @@ -0,0 +1,66 @@ +package gui + +import ( + "fmt" + + "github.com/isacikgoz/gitbatch/core/command" + "github.com/jroimartin/gocui" +) + +// not staged view +func (gui *Gui) openUnStagedView(g *gocui.Gui) error { + maxX, maxY := g.Size() + + v, err := g.SetView(unstageViewFeature.Name, maxX/2+1, 5, maxX-6, int(0.75*float32(maxY))-1) + if err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = unstageViewFeature.Title + } + + return refreshUnstagedView(g) +} + +func (gui *Gui) addChanges(g *gocui.Gui, v *gocui.View) error { + r := gui.getSelectedRepository() + + _, cy := v.Cursor() + _, oy := v.Origin() + if len(unstagedFiles) <= 0 || len(unstagedFiles) < cy+oy { + return nil + } + if err := command.Add(r, unstagedFiles[cy+oy], command.AddOptions{}); err != nil { + return err + } + + return refreshAllStatusView(g, r, true) +} + +func (gui *Gui) addAllChanges(g *gocui.Gui, v *gocui.View) error { + r := gui.getSelectedRepository() + if err := command.AddAll(r, command.AddOptions{}); err != nil { + return err + } + + return refreshAllStatusView(g, r, true) +} + +// refresh the main view and re-render the repository representations +func refreshUnstagedView(g *gocui.Gui) error { + stageView, err := g.View(unstageViewFeature.Name) + if err != nil { + return err + } + stageView.Clear() + _, cy := stageView.Cursor() + _, oy := stageView.Origin() + for i, file := range unstagedFiles { + var prefix string + if i == cy+oy { + prefix = prefix + selectionIndicator + } + fmt.Fprintf(stageView, "%s%s%s %s\n", prefix, red.Sprint(string(file.X)), red.Sprint(string(file.Y)), file.Name) + } + return nil +} |
