From 68be7276b752c2711cc6aac8e29080274b197c47 Mon Sep 17 00:00:00 2001 From: İbrahim Serdar Açıkgöz Date: Fri, 4 Jan 2019 18:07:19 +0300 Subject: cleanup before version increase --- README.md | 9 +-- core/errors/errors.go | 63 +++++++++++++++ core/errors/util-errors.go | 63 --------------- core/job/job-queue.go | 127 ----------------------------- core/job/queue.go | 127 +++++++++++++++++++++++++++++ core/load/load.go | 16 +++- gui/extensions.go | 198 +++++++++++++++++++++++++++++++++++++++++++++ gui/gui.go | 37 ++++++--- gui/text-renderer.go | 191 +++++++++++++++++++++++++++++++++++++++++++ gui/util-common.go | 198 --------------------------------------------- gui/util-textstyle.go | 191 ------------------------------------------- main.go | 2 +- 12 files changed, 624 insertions(+), 598 deletions(-) create mode 100644 core/errors/errors.go delete mode 100644 core/errors/util-errors.go delete mode 100644 core/job/job-queue.go create mode 100644 core/job/queue.go create mode 100644 gui/extensions.go create mode 100644 gui/text-renderer.go delete mode 100644 gui/util-common.go delete mode 100644 gui/util-textstyle.go diff --git a/README.md b/README.md index 12f4cb8..bb19d78 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ [![Build Status](https://travis-ci.com/isacikgoz/gitbatch.svg?branch=master)](https://travis-ci.com/isacikgoz/gitbatch) [![MIT License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](/LICENSE) [![Go Report Card](https://goreportcard.com/badge/github.com/isacikgoz/gitbatch)](https://goreportcard.com/report/github.com/isacikgoz/gitbatch) ## gitbatch -This tool is being built to make your local repositories synchronized with remotes easily. Although the focus is batch jobs, you can still do de facto micro management of your git repositories (e.g *add/reset, stash, commit etc.*) +I like to use polyrepos. I (*was*) often end up walking on many directories and manually pulling updates etc. To make this routine faster, I created a simple tool to handle this job. Although the focus is batch jobs, you can still do de facto micro management of your git repositories (e.g *add/reset, stash, commit etc.*) Here is the screencast of the app: -[![asciicast](https://asciinema.org/a/QQPVDWVxUR3bvJhIZY3c4PTuG.svg)](https://asciinema.org/a/QQPVDWVxUR3bvJhIZY3c4PTuG) +[![asciicast](https://asciinema.org/a/AiH2y2gwr8sLce40epnIQxRAH.svg)](https://asciinema.org/a/AiH2y2gwr8sLce40epnIQxRAH) ## Installation To install with go, run the following command; @@ -19,7 +19,7 @@ run the `gitbatch` command from the parent of your git repositories. For start-u For more information see the [wiki pages](https://github.com/isacikgoz/gitbatch/wiki) ## Further goals -- add testing +- **add testing** - add push - full src-d/go-git integration (*having some performance issues in such cases*) - fetch, config, add, reset, commit, status and diff commands are supported but not fully utilized, still using git sometimes @@ -34,7 +34,6 @@ Please refer to [Known issues page](https://github.com/isacikgoz/gitbatch/wiki/K - [logrus](https://github.com/sirupsen/logrus) for logging - [viper](https://github.com/spf13/viper) for configuration management - [color](https://github.com/fatih/color) for colored text -- [lazygit](https://github.com/jesseduffield/lazygit) as app template and reference +- [lazygit](https://github.com/jesseduffield/lazygit) for inspiration - [kingpin](https://github.com/alecthomas/kingpin) for command-line flag&options -I love [lazygit](https://github.com/jesseduffield/lazygit), with that inspiration, decided to build this project to be even more lazy. The rationale was; my daily work is tied to many repositories and I often end up walking on many directories and manually pulling updates etc. To make this routine faster, I created a simple tool to handle this job. I really enjoy working on this project and I hope it will be a useful tool. diff --git a/core/errors/errors.go b/core/errors/errors.go new file mode 100644 index 0000000..f922617 --- /dev/null +++ b/core/errors/errors.go @@ -0,0 +1,63 @@ +package errors + +import ( + "errors" + "strings" +) + +var ( + // ErrGitCommand is thrown when git command returned an error code + ErrGitCommand = errors.New("git command returned error code") + // ErrAuthenticationRequired is thrown when an authentication required on + // a remote operation + ErrAuthenticationRequired = errors.New("authentication required") + // ErrAuthorizationFailed is thrown when authorization failed while trying + // to authenticate with remote + ErrAuthorizationFailed = errors.New("authorization failed") + // ErrInvalidAuthMethod is thrown when invalid auth method is invoked + ErrInvalidAuthMethod = errors.New("invalid auth method") + // ErrAlreadyUpToDate is thrown when a repository is already up to date + // with its src on merge/fetch/pull + ErrAlreadyUpToDate = errors.New("already up to date") + // ErrCouldNotFindRemoteRef is thrown when trying to fetch/pull cannot + // find suitable remote reference + ErrCouldNotFindRemoteRef = errors.New("could not find remote ref") + // ErrMergeAbortedTryCommit indicates that the repositort is not clean and + // some changes may conflict with the merge + ErrMergeAbortedTryCommit = errors.New("stash/commit changes. aborted") + // ErrRemoteBranchNotSpecified means that default remote branch is not set + // for the current branch. can be setted with "git config --local --add + // branch..remote= " + ErrRemoteBranchNotSpecified = errors.New("upstream not set") + // ErrRemoteNotFound is thrown when the remote is not reachable. It may be + // caused by the deletion of the remote or coneectivty problems + ErrRemoteNotFound = errors.New("remote not found") + // ErrConflictAfterMerge is thrown when a conflict occurs at merging two + // references + ErrConflictAfterMerge = errors.New("conflict while merging") + // ErrUnmergedFiles possibly occurs after a conflict + ErrUnmergedFiles = errors.New("unmerged files detected") + // ErrReferenceBroken thrown when unable to resolve reference + ErrReferenceBroken = errors.New("unable to resolve reference") + // ErrUnclassified is unconsidered error type + ErrUnclassified = errors.New("unclassified error") +) + +// ParseGitError takes git output as an input and tries to find some meaningful +// errors can be used by the app +func ParseGitError(out string, err error) error { + if strings.Contains(out, "error: Your local changes to the following files would be overwritten by merge") { + return ErrMergeAbortedTryCommit + } else if strings.Contains(out, "ERROR: Repository not found") { + return ErrRemoteNotFound + } else if strings.Contains(out, "for your current branch, you must specify a branch on the command line") { + return ErrRemoteBranchNotSpecified + } else if strings.Contains(out, "Automatic merge failed; fix conflicts and then commit the result") { + return ErrConflictAfterMerge + } else if strings.Contains(out, "error: Pulling is not possible because you have unmerged files.") { + return ErrUnmergedFiles + } else if strings.Contains(out, "unable to resolve reference") { + return ErrReferenceBroken + } + return ErrUnclassified +} diff --git a/core/errors/util-errors.go b/core/errors/util-errors.go deleted file mode 100644 index f922617..0000000 --- a/core/errors/util-errors.go +++ /dev/null @@ -1,63 +0,0 @@ -package errors - -import ( - "errors" - "strings" -) - -var ( - // ErrGitCommand is thrown when git command returned an error code - ErrGitCommand = errors.New("git command returned error code") - // ErrAuthenticationRequired is thrown when an authentication required on - // a remote operation - ErrAuthenticationRequired = errors.New("authentication required") - // ErrAuthorizationFailed is thrown when authorization failed while trying - // to authenticate with remote - ErrAuthorizationFailed = errors.New("authorization failed") - // ErrInvalidAuthMethod is thrown when invalid auth method is invoked - ErrInvalidAuthMethod = errors.New("invalid auth method") - // ErrAlreadyUpToDate is thrown when a repository is already up to date - // with its src on merge/fetch/pull - ErrAlreadyUpToDate = errors.New("already up to date") - // ErrCouldNotFindRemoteRef is thrown when trying to fetch/pull cannot - // find suitable remote reference - ErrCouldNotFindRemoteRef = errors.New("could not find remote ref") - // ErrMergeAbortedTryCommit indicates that the repositort is not clean and - // some changes may conflict with the merge - ErrMergeAbortedTryCommit = errors.New("stash/commit changes. aborted") - // ErrRemoteBranchNotSpecified means that default remote branch is not set - // for the current branch. can be setted with "git config --local --add - // branch..remote= " - ErrRemoteBranchNotSpecified = errors.New("upstream not set") - // ErrRemoteNotFound is thrown when the remote is not reachable. It may be - // caused by the deletion of the remote or coneectivty problems - ErrRemoteNotFound = errors.New("remote not found") - // ErrConflictAfterMerge is thrown when a conflict occurs at merging two - // references - ErrConflictAfterMerge = errors.New("conflict while merging") - // ErrUnmergedFiles possibly occurs after a conflict - ErrUnmergedFiles = errors.New("unmerged files detected") - // ErrReferenceBroken thrown when unable to resolve reference - ErrReferenceBroken = errors.New("unable to resolve reference") - // ErrUnclassified is unconsidered error type - ErrUnclassified = errors.New("unclassified error") -) - -// ParseGitError takes git output as an input and tries to find some meaningful -// errors can be used by the app -func ParseGitError(out string, err error) error { - if strings.Contains(out, "error: Your local changes to the following files would be overwritten by merge") { - return ErrMergeAbortedTryCommit - } else if strings.Contains(out, "ERROR: Repository not found") { - return ErrRemoteNotFound - } else if strings.Contains(out, "for your current branch, you must specify a branch on the command line") { - return ErrRemoteBranchNotSpecified - } else if strings.Contains(out, "Automatic merge failed; fix conflicts and then commit the result") { - return ErrConflictAfterMerge - } else if strings.Contains(out, "error: Pulling is not possible because you have unmerged files.") { - return ErrUnmergedFiles - } else if strings.Contains(out, "unable to resolve reference") { - return ErrReferenceBroken - } - return ErrUnclassified -} diff --git a/core/job/job-queue.go b/core/job/job-queue.go deleted file mode 100644 index e337064..0000000 --- a/core/job/job-queue.go +++ /dev/null @@ -1,127 +0,0 @@ -package job - -import ( - "context" - "errors" - "runtime" - "sync" - - "github.com/isacikgoz/gitbatch/core/git" - log "github.com/sirupsen/logrus" - "golang.org/x/sync/semaphore" -) - -// JobQueue holds the slice of Jobs -type JobQueue struct { - series []*Job -} - -// CreateJobQueue creates a jobqueue struct and initialize its slice then return -// its pointer -func CreateJobQueue() (jq *JobQueue) { - s := make([]*Job, 0) - return &JobQueue{ - series: s, - } -} - -// AddJob adds a job to the queue -func (jq *JobQueue) AddJob(j *Job) error { - for _, job := range jq.series { - if job.Repository.RepoID == j.Repository.RepoID && job.JobType == j.JobType { - return errors.New("Same job already is in the queue") - } - } - jq.series = append(jq.series, nil) - copy(jq.series[1:], jq.series[0:]) - jq.series[0] = j - return nil -} - -// StartNext starts the next job in the queue -func (jq *JobQueue) StartNext() (j *Job, finished bool, err error) { - finished = false - if len(jq.series) < 1 { - finished = true - return nil, finished, nil - } - i := len(jq.series) - 1 - lastJob := jq.series[i] - jq.series = jq.series[:i] - if err = lastJob.start(); err != nil { - return lastJob, finished, err - } - return lastJob, finished, nil -} - -// RemoveFromQueue deletes the given entity and its job from the queue -// TODO: it is not safe if the job has been started -func (jq *JobQueue) RemoveFromQueue(r *git.Repository) error { - removed := false - for i, job := range jq.series { - if job.Repository.RepoID == r.RepoID { - jq.series = append(jq.series[:i], jq.series[i+1:]...) - removed = true - } - } - if !removed { - return errors.New("There is no job with given repoID") - } - return nil -} - -// IsInTheQueue function; since the job and entity is not tied with its own -// struct, this function returns true if that entity is in the queue along with -// the jobs type -func (jq *JobQueue) IsInTheQueue(r *git.Repository) (inTheQueue bool, j *Job) { - inTheQueue = false - for _, job := range jq.series { - if job.Repository.RepoID == r.RepoID { - inTheQueue = true - j = job - } - } - return inTheQueue, j -} - -// StartJobsAsync start he jobs in the queue asynchronously -func (jq *JobQueue) StartJobsAsync() map[*Job]error { - - ctx := context.TODO() - - var ( - maxWorkers = runtime.GOMAXPROCS(0) - sem = semaphore.NewWeighted(int64(maxWorkers)) - fails = make(map[*Job]error) - ) - - var mx sync.Mutex - for range jq.series { - - if err := sem.Acquire(ctx, 1); err != nil { - log.Errorf("Failed to acquire semaphore: %v", err) - break - } - - go func() { - - defer sem.Release(1) - j, _, err := jq.StartNext() - if err != nil { - mx.Lock() - fails[j] = err - mx.Unlock() - } - }() - } - - // Acquire all of the tokens to wait for any remaining workers to finish. - // - // If you are already waiting for the workers by some other means (such as an - // errgroup.Group), you can omit this final Acquire call. - if err := sem.Acquire(ctx, int64(maxWorkers)); err != nil { - log.Errorf("Failed to acquire semaphore: %v", err) - } - - return fails -} diff --git a/core/job/queue.go b/core/job/queue.go new file mode 100644 index 0000000..e337064 --- /dev/null +++ b/core/job/queue.go @@ -0,0 +1,127 @@ +package job + +import ( + "context" + "errors" + "runtime" + "sync" + + "github.com/isacikgoz/gitbatch/core/git" + log "github.com/sirupsen/logrus" + "golang.org/x/sync/semaphore" +) + +// JobQueue holds the slice of Jobs +type JobQueue struct { + series []*Job +} + +// CreateJobQueue creates a jobqueue struct and initialize its slice then return +// its pointer +func CreateJobQueue() (jq *JobQueue) { + s := make([]*Job, 0) + return &JobQueue{ + series: s, + } +} + +// AddJob adds a job to the queue +func (jq *JobQueue) AddJob(j *Job) error { + for _, job := range jq.series { + if job.Repository.RepoID == j.Repository.RepoID && job.JobType == j.JobType { + return errors.New("Same job already is in the queue") + } + } + jq.series = append(jq.series, nil) + copy(jq.series[1:], jq.series[0:]) + jq.series[0] = j + return nil +} + +// StartNext starts the next job in the queue +func (jq *JobQueue) StartNext() (j *Job, finished bool, err error) { + finished = false + if len(jq.series) < 1 { + finished = true + return nil, finished, nil + } + i := len(jq.series) - 1 + lastJob := jq.series[i] + jq.series = jq.series[:i] + if err = lastJob.start(); err != nil { + return lastJob, finished, err + } + return lastJob, finished, nil +} + +// RemoveFromQueue deletes the given entity and its job from the queue +// TODO: it is not safe if the job has been started +func (jq *JobQueue) RemoveFromQueue(r *git.Repository) error { + removed := false + for i, job := range jq.series { + if job.Repository.RepoID == r.RepoID { + jq.series = append(jq.series[:i], jq.series[i+1:]...) + removed = true + } + } + if !removed { + return errors.New("There is no job with given repoID") + } + return nil +} + +// IsInTheQueue function; since the job and entity is not tied with its own +// struct, this function returns true if that entity is in the queue along with +// the jobs type +func (jq *JobQueue) IsInTheQueue(r *git.Repository) (inTheQueue bool, j *Job) { + inTheQueue = false + for _, job := range jq.series { + if job.Repository.RepoID == r.RepoID { + inTheQueue = true + j = job + } + } + return inTheQueue, j +} + +// StartJobsAsync start he jobs in the queue asynchronously +func (jq *JobQueue) StartJobsAsync() map[*Job]error { + + ctx := context.TODO() + + var ( + maxWorkers = runtime.GOMAXPROCS(0) + sem = semaphore.NewWeighted(int64(maxWorkers)) + fails = make(map[*Job]error) + ) + + var mx sync.Mutex + for range jq.series { + + if err := sem.Acquire(ctx, 1); err != nil { + log.Errorf("Failed to acquire semaphore: %v", err) + break + } + + go func() { + + defer sem.Release(1) + j, _, err := jq.StartNext() + if err != nil { + mx.Lock() + fails[j] = err + mx.Unlock() + } + }() + } + + // Acquire all of the tokens to wait for any remaining workers to finish. + // + // If you are already waiting for the workers by some other means (such as an + // errgroup.Group), you can omit this final Acquire call. + if err := sem.Acquire(ctx, int64(maxWorkers)); err != nil { + log.Errorf("Failed to acquire semaphore: %v", err) + } + + return fails +} diff --git a/core/load/load.go b/core/load/load.go index c4526ef..c568517 100644 --- a/core/load/load.go +++ b/core/load/load.go @@ -15,10 +15,10 @@ import ( // AsyncAdd is interface to caller type AsyncAdd func(r *git.Repository) -// RepositoryEntities initializes the go-git's repository obejcts with given +// SyncLoad initializes the go-git's repository obejcts with given // slice of paths. since this job is done parallel, the order of the directories // is not kept -func RepositoryEntities(directories []string) (entities []*git.Repository, err error) { +func SyncLoad(directories []string) (entities []*git.Repository, err error) { entities = make([]*git.Repository, 0) var wg sync.WaitGroup @@ -55,8 +55,8 @@ func RepositoryEntities(directories []string) (entities []*git.Repository, err e return entities, nil } -// AddRepositoryEntitiesAsync asynchronously adds to caller -func AddRepositoryEntitiesAsync(directories []string, add AsyncAdd) error { +// AsyncLoad asynchronously adds to AsyncAdd function +func AsyncLoad(directories []string, add AsyncAdd, d chan bool) error { ctx := context.TODO() var ( @@ -65,6 +65,8 @@ func AddRepositoryEntitiesAsync(directories []string, add AsyncAdd) error { ) var mx sync.Mutex + + // Compute the output using up to maxWorkers goroutines at a time. for _, dir := range directories { if err := sem.Acquire(ctx, 1); err != nil { log.Errorf("Failed to acquire semaphore: %v", err) @@ -88,5 +90,11 @@ func AddRepositoryEntitiesAsync(directories []string, add AsyncAdd) error { mx.Unlock() }(dir) } + // Acquire all of the tokens to wait for any remaining workers to finish. + if err := sem.Acquire(ctx, int64(maxWorkers)); err != nil { + return err + } + d <- true + sem = nil return nil } 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 index b88d681..767bf16 100644 --- a/gui/gui.go +++ b/gui/gui.go @@ -1,8 +1,9 @@ package gui import ( - "sync" + "fmt" "sort" + "sync" "github.com/isacikgoz/gitbatch/core/git" "github.com/isacikgoz/gitbatch/core/job" @@ -75,6 +76,8 @@ var ( 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 @@ -110,12 +113,11 @@ func (gui *Gui) Run() error { g.Highlight = true g.SelFgColor = gocui.ColorGreen - // If InputEsc is true, when ESC sequence is in the buffer and it doesn't - // match any known sequence, ESC means KeyEsc. g.InputEsc = true g.SetManagerFunc(gui.layout) - go load.AddRepositoryEntitiesAsync(gui.State.Directories, gui.appendRepo) + // 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.") @@ -132,10 +134,8 @@ func (gui *Gui) Run() error { return nil } -func (gui *Gui) appendRepo(r *git.Repository){ +func (gui *Gui) loadRepository(r *git.Repository) { rs := gui.State.Repositories - // lock mutex to avoid multithreading ambigouty - gui.mutex.Lock() // insertion sort implementation index := sort.Search(len(rs), func(i int) bool { return git.Less(r, rs[i]) }) @@ -146,10 +146,29 @@ func (gui *Gui) appendRepo(r *git.Repository){ r.On(git.RepositoryUpdated, gui.repositoryUpdated) // update gui gui.repositoryUpdated(nil) - gui.mutex.Unlock() - + 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 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/util-common.go b/gui/util-common.go deleted file mode 100644 index b25848c..0000000 --- a/gui/util-common.go +++ /dev/null @@ -1,198 +0,0 @@ -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/util-textstyle.go b/gui/util-textstyle.go deleted file mode 100644 index d699acf..0000000 --- a/gui/util-textstyle.go +++ /dev/null @@ -1,191 +0,0 @@ -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/main.go b/main.go index 92b0330..ebe6583 100644 --- a/main.go +++ b/main.go @@ -17,7 +17,7 @@ var ( ) func main() { - kingpin.Version("gitbatch version 0.2.2") + kingpin.Version("gitbatch version 0.3.0") // parse the command line flag and options kingpin.Parse() -- cgit v1.2.3