mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-02-25 18:55:28 -06:00
Restrucure project in a way where it is more modular
This commit is contained in:
49
pkg/app/app.go
Normal file
49
pkg/app/app.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/Sirupsen/logrus"
|
||||
"github.com/jesseduffield/lazygit/pkg/commands"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
)
|
||||
|
||||
// App struct
|
||||
type App struct {
|
||||
closers []io.Closer
|
||||
|
||||
Config config.AppConfigurer
|
||||
Log *logrus.Logger
|
||||
OSCommand *commands.OSCommand
|
||||
GitCommand *commands.GitCommand
|
||||
}
|
||||
|
||||
// NewApp retruns a new applications
|
||||
func NewApp(config config.AppConfigurer) (*App, error) {
|
||||
app := &App{
|
||||
closers: []io.Closer{},
|
||||
Config: config,
|
||||
}
|
||||
var err error
|
||||
app.Log = logrus.New()
|
||||
app.OSCommand, err = commands.NewOSCommand(app.Log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app.GitCommand, err = commands.NewGitCommand(app.Log, app.OSCommand)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// Close closes any resources
|
||||
func (app *App) Close() error {
|
||||
for _, closer := range app.closers {
|
||||
err := closer.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
71
pkg/commands/git.go
Normal file
71
pkg/commands/git.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/Sirupsen/logrus"
|
||||
git "gopkg.in/src-d/go-git.v4"
|
||||
)
|
||||
|
||||
// GitCommand is our main git interface
|
||||
type GitCommand struct {
|
||||
Log *logrus.Logger
|
||||
OSCommand *OSCommand
|
||||
Worktree *git.Worktree
|
||||
Repo *git.Repository
|
||||
}
|
||||
|
||||
// NewGitCommand it runs git commands
|
||||
func NewGitCommand(log *logrus.Logger, osCommand *OSCommand) (*GitCommand, error) {
|
||||
gitCommand := &GitCommand{
|
||||
Log: log,
|
||||
OSCommand: osCommand,
|
||||
}
|
||||
return gitCommand, nil
|
||||
}
|
||||
|
||||
// SetupGit sets git repo up
|
||||
func (c *GitCommand) SetupGit() {
|
||||
c.verifyInGitRepo()
|
||||
c.navigateToRepoRootDirectory()
|
||||
c.setupWorktree()
|
||||
}
|
||||
|
||||
func (c *GitCommand) GitIgnore(filename string) {
|
||||
if _, err := c.OSCommand.RunDirectCommand("echo '" + filename + "' >> .gitignore"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GitCommand) verifyInGitRepo() {
|
||||
if output, err := c.OSCommand.RunCommand("git status"); err != nil {
|
||||
fmt.Println(output)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GitCommand) navigateToRepoRootDirectory() {
|
||||
_, err := os.Stat(".git")
|
||||
for os.IsNotExist(err) {
|
||||
c.Log.Debug("going up a directory to find the root")
|
||||
os.Chdir("..")
|
||||
_, err = os.Stat(".git")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GitCommand) setupWorktree() {
|
||||
var err error
|
||||
r, err := git.PlainOpen(".")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
c.Repo = r
|
||||
|
||||
w, err := r.Worktree()
|
||||
c.Worktree = w
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
c.Worktree = w
|
||||
}
|
||||
519
pkg/commands/gitcommands.go
Normal file
519
pkg/commands/gitcommands.go
Normal file
@@ -0,0 +1,519 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
// "log"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/gocui"
|
||||
gitconfig "github.com/tcnksm/go-gitconfig"
|
||||
git "gopkg.in/src-d/go-git.v4"
|
||||
"gopkg.in/src-d/go-git.v4/plumbing/object"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoOpenCommand : When we don't know which command to use to open a file
|
||||
ErrNoOpenCommand = errors.New("Unsure what command to use to open this file")
|
||||
)
|
||||
|
||||
// Map (from https://gobyexample.com/collection-functions)
|
||||
func Map(vs []string, f func(string) string) []string {
|
||||
vsm := make([]string, len(vs))
|
||||
for i, v := range vs {
|
||||
vsm[i] = f(v)
|
||||
}
|
||||
return vsm
|
||||
}
|
||||
|
||||
func includesString(list []string, a string) bool {
|
||||
for _, b := range list {
|
||||
if b == a {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// not sure how to genericise this because []interface{} doesn't accept e.g.
|
||||
// []int arguments
|
||||
func includesInt(list []int, a int) bool {
|
||||
for _, b := range list {
|
||||
if b == a {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mergeGitStatusFiles(oldGitFiles, newGitFiles []GitFile) []GitFile {
|
||||
if len(oldGitFiles) == 0 {
|
||||
return newGitFiles
|
||||
}
|
||||
|
||||
appendedIndexes := make([]int, 0)
|
||||
|
||||
// retain position of files we already could see
|
||||
result := make([]GitFile, 0)
|
||||
for _, oldGitFile := range oldGitFiles {
|
||||
for newIndex, newGitFile := range newGitFiles {
|
||||
if oldGitFile.Name == newGitFile.Name {
|
||||
result = append(result, newGitFile)
|
||||
appendedIndexes = append(appendedIndexes, newIndex)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// append any new files to the end
|
||||
for index, newGitFile := range newGitFiles {
|
||||
if !includesInt(appendedIndexes, index) {
|
||||
result = append(result, newGitFile)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// only to be used when you're already in an error state
|
||||
func runDirectCommandIgnoringError(command string) string {
|
||||
output, _ := runDirectCommand(command)
|
||||
return output
|
||||
}
|
||||
|
||||
func runDirectCommand(command string) (string, error) {
|
||||
commandLog(command)
|
||||
|
||||
cmdOut, err := exec.
|
||||
Command(state.Platform.shell, state.Platform.shellArg, command).
|
||||
CombinedOutput()
|
||||
return sanitisedCommandOutput(cmdOut, err)
|
||||
}
|
||||
|
||||
func branchStringParts(branchString string) (string, string) {
|
||||
// expect string to be something like '4w master`
|
||||
splitBranchName := strings.Split(branchString, "\t")
|
||||
// if we have no \t then we have no recency, so just output that as blank
|
||||
if len(splitBranchName) == 1 {
|
||||
return "", branchString
|
||||
}
|
||||
return splitBranchName[0], splitBranchName[1]
|
||||
}
|
||||
|
||||
// TODO: DRY up this function and getGitBranches
|
||||
func getGitStashEntries() []StashEntry {
|
||||
stashEntries := make([]StashEntry, 0)
|
||||
rawString, _ := runDirectCommand("git stash list --pretty='%gs'")
|
||||
for i, line := range splitLines(rawString) {
|
||||
stashEntries = append(stashEntries, stashEntryFromLine(line, i))
|
||||
}
|
||||
return stashEntries
|
||||
}
|
||||
|
||||
func stashEntryFromLine(line string, index int) StashEntry {
|
||||
return StashEntry{
|
||||
Name: line,
|
||||
Index: index,
|
||||
DisplayString: line,
|
||||
}
|
||||
}
|
||||
|
||||
func getStashEntryDiff(index int) (string, error) {
|
||||
return runCommand("git stash show -p --color stash@{" + fmt.Sprint(index) + "}")
|
||||
}
|
||||
|
||||
func includes(array []string, str string) bool {
|
||||
for _, arrayStr := range array {
|
||||
if arrayStr == str {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getGitStatusFiles() []GitFile {
|
||||
statusOutput, _ := getGitStatus()
|
||||
statusStrings := splitLines(statusOutput)
|
||||
gitFiles := make([]GitFile, 0)
|
||||
|
||||
for _, statusString := range statusStrings {
|
||||
change := statusString[0:2]
|
||||
stagedChange := change[0:1]
|
||||
unstagedChange := statusString[1:2]
|
||||
filename := statusString[3:]
|
||||
tracked := !includes([]string{"??", "A "}, change)
|
||||
gitFile := GitFile{
|
||||
Name: filename,
|
||||
DisplayString: statusString,
|
||||
HasStagedChanges: !includes([]string{" ", "U", "?"}, stagedChange),
|
||||
HasUnstagedChanges: unstagedChange != " ",
|
||||
Tracked: tracked,
|
||||
Deleted: unstagedChange == "D" || stagedChange == "D",
|
||||
HasMergeConflicts: change == "UU",
|
||||
}
|
||||
gitFiles = append(gitFiles, gitFile)
|
||||
}
|
||||
objectLog(gitFiles)
|
||||
return gitFiles
|
||||
}
|
||||
|
||||
func gitStashDo(index int, method string) (string, error) {
|
||||
return runCommand("git stash " + method + " stash@{" + fmt.Sprint(index) + "}")
|
||||
}
|
||||
|
||||
func gitStashSave(message string) (string, error) {
|
||||
output, err := runCommand("git stash save \"" + message + "\"")
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
// if there are no local changes to save, the exit code is 0, but we want
|
||||
// to raise an error
|
||||
if output == "No local changes to save\n" {
|
||||
return output, errors.New(output)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func gitCheckout(branch string, force bool) (string, error) {
|
||||
forceArg := ""
|
||||
if force {
|
||||
forceArg = "--force "
|
||||
}
|
||||
return runCommand("git checkout " + forceArg + branch)
|
||||
}
|
||||
|
||||
func sanitisedCommandOutput(output []byte, err error) (string, error) {
|
||||
outputString := string(output)
|
||||
if outputString == "" && err != nil {
|
||||
return err.Error(), err
|
||||
}
|
||||
return outputString, err
|
||||
}
|
||||
|
||||
func runCommand(command string) (string, error) {
|
||||
commandLog(command)
|
||||
splitCmd := strings.Split(command, " ")
|
||||
cmdOut, err := exec.Command(splitCmd[0], splitCmd[1:]...).CombinedOutput()
|
||||
return sanitisedCommandOutput(cmdOut, err)
|
||||
}
|
||||
|
||||
func vsCodeOpenFile(g *gocui.Gui, filename string) (string, error) {
|
||||
return runCommand("code -r " + filename)
|
||||
}
|
||||
|
||||
func sublimeOpenFile(g *gocui.Gui, filename string) (string, error) {
|
||||
return runCommand("subl " + filename)
|
||||
}
|
||||
|
||||
func openFile(g *gocui.Gui, filename string) (string, error) {
|
||||
cmdName, cmdTrail, err := getOpenCommand()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return runCommand(cmdName + " " + filename + cmdTrail)
|
||||
}
|
||||
|
||||
func getOpenCommand() (string, string, error) {
|
||||
//NextStep open equivalents: xdg-open (linux), cygstart (cygwin), open (OSX)
|
||||
trailMap := map[string]string{
|
||||
"xdg-open": " &>/dev/null &",
|
||||
"cygstart": "",
|
||||
"open": "",
|
||||
}
|
||||
for name, trail := range trailMap {
|
||||
if out, _ := runCommand("which " + name); out != "exit status 1" {
|
||||
return name, trail, nil
|
||||
}
|
||||
}
|
||||
return "", "", ErrNoOpenCommand
|
||||
}
|
||||
|
||||
func gitAddPatch(g *gocui.Gui, filename string) {
|
||||
runSubProcess(g, "git", "add", "--patch", filename)
|
||||
}
|
||||
|
||||
func editFile(g *gocui.Gui, filename string) (string, error) {
|
||||
editor, _ := gitconfig.Global("core.editor")
|
||||
if editor == "" {
|
||||
editor = os.Getenv("VISUAL")
|
||||
}
|
||||
if editor == "" {
|
||||
editor = os.Getenv("EDITOR")
|
||||
}
|
||||
if editor == "" {
|
||||
if _, err := runCommand("which vi"); err == nil {
|
||||
editor = "vi"
|
||||
}
|
||||
}
|
||||
if editor == "" {
|
||||
return "", createErrorPanel(g, "No editor defined in $VISUAL, $EDITOR, or git config.")
|
||||
}
|
||||
runSubProcess(g, editor, filename)
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func runSubProcess(g *gocui.Gui, cmdName string, commandArgs ...string) {
|
||||
subprocess = exec.Command(cmdName, commandArgs...)
|
||||
subprocess.Stdin = os.Stdin
|
||||
subprocess.Stdout = os.Stdout
|
||||
subprocess.Stderr = os.Stderr
|
||||
|
||||
g.Update(func(g *gocui.Gui) error {
|
||||
return ErrSubprocess
|
||||
})
|
||||
}
|
||||
|
||||
func getBranchGraph(branch string) (string, error) {
|
||||
return runCommand("git log --graph --color --abbrev-commit --decorate --date=relative --pretty=medium -100 " + branch)
|
||||
}
|
||||
|
||||
func verifyInGitRepo() {
|
||||
if output, err := runCommand("git status"); err != nil {
|
||||
fmt.Println(output)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func getCommits() []Commit {
|
||||
pushables := gitCommitsToPush()
|
||||
log := getLog()
|
||||
commits := make([]Commit, 0)
|
||||
// now we can split it up and turn it into commits
|
||||
lines := splitLines(log)
|
||||
for _, line := range lines {
|
||||
splitLine := strings.Split(line, " ")
|
||||
sha := splitLine[0]
|
||||
pushed := includesString(pushables, sha)
|
||||
commits = append(commits, Commit{
|
||||
Sha: sha,
|
||||
Name: strings.Join(splitLine[1:], " "),
|
||||
Pushed: pushed,
|
||||
DisplayString: strings.Join(splitLine, " "),
|
||||
})
|
||||
}
|
||||
return commits
|
||||
}
|
||||
|
||||
func getLog() string {
|
||||
// currently limiting to 30 for performance reasons
|
||||
// TODO: add lazyloading when you scroll down
|
||||
result, err := runDirectCommand("git log --oneline -30")
|
||||
if err != nil {
|
||||
// assume if there is an error there are no commits yet for this branch
|
||||
return ""
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func gitIgnore(filename string) {
|
||||
if _, err := runDirectCommand("echo '" + filename + "' >> .gitignore"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func gitShow(sha string) string {
|
||||
result, err := runDirectCommand("git show --color " + sha)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func getDiff(file GitFile) string {
|
||||
cachedArg := ""
|
||||
if file.HasStagedChanges && !file.HasUnstagedChanges {
|
||||
cachedArg = "--cached "
|
||||
}
|
||||
deletedArg := ""
|
||||
if file.Deleted {
|
||||
deletedArg = "-- "
|
||||
}
|
||||
trackedArg := ""
|
||||
if !file.Tracked && !file.HasStagedChanges {
|
||||
trackedArg = "--no-index /dev/null "
|
||||
}
|
||||
command := "git diff --color " + cachedArg + deletedArg + trackedArg + file.Name
|
||||
// for now we assume an error means the file was deleted
|
||||
s, _ := runCommand(command)
|
||||
return s
|
||||
}
|
||||
|
||||
func catFile(file string) (string, error) {
|
||||
return runDirectCommand("cat " + file)
|
||||
}
|
||||
|
||||
func stageFile(file string) error {
|
||||
_, err := runCommand("git add " + file)
|
||||
return err
|
||||
}
|
||||
|
||||
func unStageFile(file string, tracked bool) error {
|
||||
var command string
|
||||
if tracked {
|
||||
command = "git reset HEAD "
|
||||
} else {
|
||||
command = "git rm --cached "
|
||||
}
|
||||
_, err := runCommand(command + file)
|
||||
return err
|
||||
}
|
||||
|
||||
func getGitStatus() (string, error) {
|
||||
return runCommand("git status --untracked-files=all --short")
|
||||
}
|
||||
|
||||
func isInMergeState() (bool, error) {
|
||||
output, err := runCommand("git status --untracked-files=all")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.Contains(output, "conclude merge") || strings.Contains(output, "unmerged paths"), nil
|
||||
}
|
||||
|
||||
func removeFile(file GitFile) error {
|
||||
// if the file isn't tracked, we assume you want to delete it
|
||||
if !file.Tracked {
|
||||
_, err := runCommand("rm -rf ./" + file.Name)
|
||||
return err
|
||||
}
|
||||
// if the file is tracked, we assume you want to just check it out
|
||||
_, err := runCommand("git checkout " + file.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
func gitCommit(g *gocui.Gui, message string) (string, error) {
|
||||
gpgsign, _ := gitconfig.Global("commit.gpgsign")
|
||||
if gpgsign != "" {
|
||||
runSubProcess(g, "git", "commit")
|
||||
return "", nil
|
||||
}
|
||||
userName, err := gitconfig.Username()
|
||||
if userName == "" {
|
||||
return "", errNoUsername
|
||||
}
|
||||
userEmail, err := gitconfig.Email()
|
||||
_, err = w.Commit(message, &git.CommitOptions{
|
||||
Author: &object.Signature{
|
||||
Name: userName,
|
||||
Email: userEmail,
|
||||
When: time.Now(),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err.Error(), err
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func gitPull() (string, error) {
|
||||
return runDirectCommand("git pull --no-edit")
|
||||
}
|
||||
|
||||
func gitPush() (string, error) {
|
||||
return runDirectCommand("git push -u origin " + state.Branches[0].Name)
|
||||
}
|
||||
|
||||
func gitSquashPreviousTwoCommits(message string) (string, error) {
|
||||
return runDirectCommand("git reset --soft HEAD^ && git commit --amend -m \"" + message + "\"")
|
||||
}
|
||||
|
||||
func gitSquashFixupCommit(branchName string, shaValue string) (string, error) {
|
||||
var err error
|
||||
commands := []string{
|
||||
"git checkout -q " + shaValue,
|
||||
"git reset --soft " + shaValue + "^",
|
||||
"git commit --amend -C " + shaValue + "^",
|
||||
"git rebase --onto HEAD " + shaValue + " " + branchName,
|
||||
}
|
||||
ret := ""
|
||||
for _, command := range commands {
|
||||
devLog(command)
|
||||
output, err := runDirectCommand(command)
|
||||
ret += output
|
||||
if err != nil {
|
||||
devLog(ret)
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
// We are already in an error state here so we're just going to append
|
||||
// the output of these commands
|
||||
ret += runDirectCommandIgnoringError("git branch -d " + shaValue)
|
||||
ret += runDirectCommandIgnoringError("git checkout " + branchName)
|
||||
}
|
||||
return ret, err
|
||||
}
|
||||
|
||||
func gitRenameCommit(message string) (string, error) {
|
||||
return runDirectCommand("git commit --allow-empty --amend -m \"" + message + "\"")
|
||||
}
|
||||
|
||||
func gitFetch() (string, error) {
|
||||
return runDirectCommand("git fetch")
|
||||
}
|
||||
|
||||
func gitResetToCommit(sha string) (string, error) {
|
||||
return runDirectCommand("git reset " + sha)
|
||||
}
|
||||
|
||||
func gitNewBranch(name string) (string, error) {
|
||||
return runDirectCommand("git checkout -b " + name)
|
||||
}
|
||||
|
||||
func gitDeleteBranch(branch string) (string, error) {
|
||||
return runCommand("git branch -d " + branch)
|
||||
}
|
||||
|
||||
func gitListStash() (string, error) {
|
||||
return runDirectCommand("git stash list")
|
||||
}
|
||||
|
||||
func gitMerge(branchName string) (string, error) {
|
||||
return runDirectCommand("git merge --no-edit " + branchName)
|
||||
}
|
||||
|
||||
func gitAbortMerge() (string, error) {
|
||||
return runDirectCommand("git merge --abort")
|
||||
}
|
||||
|
||||
func gitUpstreamDifferenceCount() (string, string) {
|
||||
pushableCount, err := runDirectCommand("git rev-list @{u}..head --count")
|
||||
if err != nil {
|
||||
return "?", "?"
|
||||
}
|
||||
pullableCount, err := runDirectCommand("git rev-list head..@{u} --count")
|
||||
if err != nil {
|
||||
return "?", "?"
|
||||
}
|
||||
return strings.TrimSpace(pushableCount), strings.TrimSpace(pullableCount)
|
||||
}
|
||||
|
||||
func gitCommitsToPush() []string {
|
||||
pushables, err := runDirectCommand("git rev-list @{u}..head --abbrev-commit")
|
||||
if err != nil {
|
||||
return make([]string, 0)
|
||||
}
|
||||
return splitLines(pushables)
|
||||
}
|
||||
|
||||
func getGitBranches() []Branch {
|
||||
builder := newBranchListBuilder()
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
func branchIncluded(branchName string, branches []Branch) bool {
|
||||
for _, existingBranch := range branches {
|
||||
if strings.ToLower(existingBranch.Name) == strings.ToLower(branchName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gitResetHard() error {
|
||||
return w.Reset(&git.ResetOptions{Mode: git.HardReset})
|
||||
}
|
||||
77
pkg/commands/os.go
Normal file
77
pkg/commands/os.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/Sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Platform stores the os state
|
||||
type platform struct {
|
||||
os string
|
||||
shell string
|
||||
shellArg string
|
||||
escapedQuote string
|
||||
}
|
||||
|
||||
// OSCommand holds all the os commands
|
||||
type OSCommand struct {
|
||||
Log *logrus.Logger
|
||||
Platform platform
|
||||
}
|
||||
|
||||
// NewOSCommand os command runner
|
||||
func NewOSCommand(log *logrus.Logger) (*OSCommand, error) {
|
||||
osCommand := &OSCommand{
|
||||
Log: log,
|
||||
Platform: getPlatform(),
|
||||
}
|
||||
return osCommand, nil
|
||||
}
|
||||
|
||||
// RunCommand wrapper around commands
|
||||
func (c *OSCommand) RunCommand(command string) (string, error) {
|
||||
c.Log.WithField("command", command).Info("RunCommand")
|
||||
splitCmd := strings.Split(command, " ")
|
||||
cmdOut, err := exec.Command(splitCmd[0], splitCmd[1:]...).CombinedOutput()
|
||||
return sanitisedCommandOutput(cmdOut, err)
|
||||
}
|
||||
|
||||
// RunDirectCommand wrapper around direct commands
|
||||
func (c *OSCommand) RunDirectCommand(command string) (string, error) {
|
||||
c.Log.WithField("command", command).Info("RunDirectCommand")
|
||||
|
||||
cmdOut, err := exec.
|
||||
Command(c.Platform.shell, c.Platform.shellArg, command).
|
||||
CombinedOutput()
|
||||
return sanitisedCommandOutput(cmdOut, err)
|
||||
}
|
||||
|
||||
func sanitisedCommandOutput(output []byte, err error) (string, error) {
|
||||
outputString := string(output)
|
||||
if outputString == "" && err != nil {
|
||||
return err.Error(), err
|
||||
}
|
||||
return outputString, err
|
||||
}
|
||||
|
||||
func getPlatform() platform {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return platform{
|
||||
os: "windows",
|
||||
shell: "cmd",
|
||||
shellArg: "/c",
|
||||
escapedQuote: "\\\"",
|
||||
}
|
||||
default:
|
||||
return platform{
|
||||
os: runtime.GOOS,
|
||||
shell: "bash",
|
||||
shellArg: "-c",
|
||||
escapedQuote: "\"",
|
||||
}
|
||||
}
|
||||
}
|
||||
45
pkg/config/app_config.go
Normal file
45
pkg/config/app_config.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package config
|
||||
|
||||
// AppConfig contains the base configuration fields required for lazygit.
|
||||
type AppConfig struct {
|
||||
Debug bool `long:"debug" env:"DEBUG" default:"false"`
|
||||
Version string `long:"version" env:"VERSION" default:"unversioned"`
|
||||
Commit string `long:"commit" env:"COMMIT"`
|
||||
BuildDate string `long:"build-date" env:"BUILD_DATE"`
|
||||
Name string `long:"name" env:"NAME" default:"lazygit"`
|
||||
}
|
||||
|
||||
// AppConfigurer interface allows individual app config structs to inherit Fields
|
||||
// from AppConfig and still be used by lazygit.
|
||||
type AppConfigurer interface {
|
||||
GetDebug() bool
|
||||
GetVersion() string
|
||||
GetCommit() string
|
||||
GetBuildDate() string
|
||||
GetName() string
|
||||
}
|
||||
|
||||
// GetDebug returns debug flag
|
||||
func (c *AppConfig) GetDebug() bool {
|
||||
return c.Debug
|
||||
}
|
||||
|
||||
// GetVersion returns debug flag
|
||||
func (c *AppConfig) GetVersion() string {
|
||||
return c.Version
|
||||
}
|
||||
|
||||
// GetCommit returns debug flag
|
||||
func (c *AppConfig) GetCommit() string {
|
||||
return c.Commit
|
||||
}
|
||||
|
||||
// GetBuildDate returns debug flag
|
||||
func (c *AppConfig) GetBuildDate() string {
|
||||
return c.BuildDate
|
||||
}
|
||||
|
||||
// GetName returns debug flag
|
||||
func (c *AppConfig) GetName() string {
|
||||
return c.Name
|
||||
}
|
||||
37
pkg/git/branch.go
Normal file
37
pkg/git/branch.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
// Branch : A git branch
|
||||
type Branch struct {
|
||||
Name string
|
||||
Recency string
|
||||
}
|
||||
|
||||
// GetDisplayString returns the dispaly string of branch
|
||||
// func (b *Branch) GetDisplayString() string {
|
||||
// return gui.withPadding(b.Recency, 4) + gui.coloredString(b.Name, b.getColor())
|
||||
// }
|
||||
|
||||
// GetColor branch color
|
||||
func (b *Branch) GetColor() color.Attribute {
|
||||
switch b.getType() {
|
||||
case "feature":
|
||||
return color.FgGreen
|
||||
case "bugfix":
|
||||
return color.FgYellow
|
||||
case "hotfix":
|
||||
return color.FgRed
|
||||
default:
|
||||
return color.FgWhite
|
||||
}
|
||||
}
|
||||
|
||||
// expected to return feature/bugfix/hotfix or blank string
|
||||
func (b *Branch) getType() string {
|
||||
return strings.Split(b.Name, "/")[0]
|
||||
}
|
||||
134
pkg/git/branch_list_builder.go
Normal file
134
pkg/git/branch_list_builder.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/src-d/go-git.v4/plumbing"
|
||||
)
|
||||
|
||||
// context:
|
||||
// we want to only show 'safe' branches (ones that haven't e.g. been deleted)
|
||||
// which `git branch -a` gives us, but we also want the recency data that
|
||||
// git reflog gives us.
|
||||
// So we get the HEAD, then append get the reflog branches that intersect with
|
||||
// our safe branches, then add the remaining safe branches, ensuring uniqueness
|
||||
// along the way
|
||||
|
||||
type branchListBuilder struct{}
|
||||
|
||||
func newBranchListBuilder() *branchListBuilder {
|
||||
return &branchListBuilder{}
|
||||
}
|
||||
|
||||
func (b *branchListBuilder) obtainCurrentBranch() Branch {
|
||||
// I used go-git for this, but that breaks if you've just done a git init,
|
||||
// even though you're on 'master'
|
||||
branchName, _ := runDirectCommand("git symbolic-ref --short HEAD")
|
||||
return Branch{Name: strings.TrimSpace(branchName), Recency: " *"}
|
||||
}
|
||||
|
||||
func (*branchListBuilder) obtainReflogBranches() []Branch {
|
||||
branches := make([]Branch, 0)
|
||||
rawString, err := runDirectCommand("git reflog -n100 --pretty='%cr|%gs' --grep-reflog='checkout: moving' HEAD")
|
||||
if err != nil {
|
||||
return branches
|
||||
}
|
||||
|
||||
branchLines := splitLines(rawString)
|
||||
for _, line := range branchLines {
|
||||
timeNumber, timeUnit, branchName := branchInfoFromLine(line)
|
||||
timeUnit = abbreviatedTimeUnit(timeUnit)
|
||||
branch := Branch{Name: branchName, Recency: timeNumber + timeUnit}
|
||||
branches = append(branches, branch)
|
||||
}
|
||||
return branches
|
||||
}
|
||||
|
||||
func (b *branchListBuilder) obtainSafeBranches() []Branch {
|
||||
branches := make([]Branch, 0)
|
||||
|
||||
bIter, err := r.Branches()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = bIter.ForEach(func(b *plumbing.Reference) error {
|
||||
name := b.Name().Short()
|
||||
branches = append(branches, Branch{Name: name})
|
||||
return nil
|
||||
})
|
||||
|
||||
return branches
|
||||
}
|
||||
|
||||
func (b *branchListBuilder) appendNewBranches(finalBranches, newBranches, existingBranches []Branch, included bool) []Branch {
|
||||
for _, newBranch := range newBranches {
|
||||
if included == branchIncluded(newBranch.Name, existingBranches) {
|
||||
finalBranches = append(finalBranches, newBranch)
|
||||
}
|
||||
}
|
||||
return finalBranches
|
||||
}
|
||||
|
||||
func sanitisedReflogName(reflogBranch Branch, safeBranches []Branch) string {
|
||||
for _, safeBranch := range safeBranches {
|
||||
if strings.ToLower(safeBranch.Name) == strings.ToLower(reflogBranch.Name) {
|
||||
return safeBranch.Name
|
||||
}
|
||||
}
|
||||
return reflogBranch.Name
|
||||
}
|
||||
|
||||
func (b *branchListBuilder) build() []Branch {
|
||||
branches := make([]Branch, 0)
|
||||
head := b.obtainCurrentBranch()
|
||||
safeBranches := b.obtainSafeBranches()
|
||||
if len(safeBranches) == 0 {
|
||||
return append(branches, head)
|
||||
}
|
||||
reflogBranches := b.obtainReflogBranches()
|
||||
reflogBranches = uniqueByName(append([]Branch{head}, reflogBranches...))
|
||||
for i, reflogBranch := range reflogBranches {
|
||||
reflogBranches[i].Name = sanitisedReflogName(reflogBranch, safeBranches)
|
||||
}
|
||||
|
||||
branches = b.appendNewBranches(branches, reflogBranches, safeBranches, true)
|
||||
branches = b.appendNewBranches(branches, safeBranches, branches, false)
|
||||
|
||||
return branches
|
||||
}
|
||||
|
||||
func uniqueByName(branches []Branch) []Branch {
|
||||
finalBranches := make([]Branch, 0)
|
||||
for _, branch := range branches {
|
||||
if branchIncluded(branch.Name, finalBranches) {
|
||||
continue
|
||||
}
|
||||
finalBranches = append(finalBranches, branch)
|
||||
}
|
||||
return finalBranches
|
||||
}
|
||||
|
||||
// A line will have the form '10 days ago master' so we need to strip out the
|
||||
// useful information from that into timeNumber, timeUnit, and branchName
|
||||
func branchInfoFromLine(line string) (string, string, string) {
|
||||
r := regexp.MustCompile("\\|.*\\s")
|
||||
line = r.ReplaceAllString(line, " ")
|
||||
words := strings.Split(line, " ")
|
||||
return words[0], words[1], words[3]
|
||||
}
|
||||
|
||||
func abbreviatedTimeUnit(timeUnit string) string {
|
||||
r := regexp.MustCompile("s$")
|
||||
timeUnit = r.ReplaceAllString(timeUnit, "")
|
||||
timeUnitMap := map[string]string{
|
||||
"hour": "h",
|
||||
"minute": "m",
|
||||
"second": "s",
|
||||
"week": "w",
|
||||
"year": "y",
|
||||
"day": "d",
|
||||
"month": "m",
|
||||
}
|
||||
return timeUnitMap[timeUnit]
|
||||
}
|
||||
28
pkg/git/git_structs.go
Normal file
28
pkg/git/git_structs.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package git
|
||||
|
||||
// File : A staged/unstaged file
|
||||
// TODO: decide whether to give all of these the Git prefix
|
||||
type File struct {
|
||||
Name string
|
||||
HasStagedChanges bool
|
||||
HasUnstagedChanges bool
|
||||
Tracked bool
|
||||
Deleted bool
|
||||
HasMergeConflicts bool
|
||||
DisplayString string
|
||||
}
|
||||
|
||||
// Commit : A git commit
|
||||
type Commit struct {
|
||||
Sha string
|
||||
Name string
|
||||
Pushed bool
|
||||
DisplayString string
|
||||
}
|
||||
|
||||
// StashEntry : A git stash entry
|
||||
type StashEntry struct {
|
||||
Index int
|
||||
Name string
|
||||
DisplayString string
|
||||
}
|
||||
301
pkg/gui/gui.go
Normal file
301
pkg/gui/gui.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
|
||||
// "io"
|
||||
// "io/ioutil"
|
||||
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
// "strings"
|
||||
|
||||
"github.com/golang-collections/collections/stack"
|
||||
"github.com/jesseduffield/gocui"
|
||||
"github.com/jesseduffield/lazygit/pkg/git"
|
||||
)
|
||||
|
||||
// OverlappingEdges determines if panel edges overlap
|
||||
var OverlappingEdges = false
|
||||
|
||||
type stateType struct {
|
||||
GitFiles []git.File
|
||||
Branches []git.Branch
|
||||
Commits []git.Commit
|
||||
StashEntries []git.StashEntry
|
||||
PreviousView string
|
||||
HasMergeConflicts bool
|
||||
ConflictIndex int
|
||||
ConflictTop bool
|
||||
Conflicts []conflict
|
||||
EditHistory *stack.Stack
|
||||
Platform platform
|
||||
}
|
||||
|
||||
type conflict struct {
|
||||
start int
|
||||
middle int
|
||||
end int
|
||||
}
|
||||
|
||||
var state = stateType{
|
||||
GitFiles: make([]GitFile, 0),
|
||||
PreviousView: "files",
|
||||
Commits: make([]Commit, 0),
|
||||
StashEntries: make([]StashEntry, 0),
|
||||
ConflictIndex: 0,
|
||||
ConflictTop: true,
|
||||
Conflicts: make([]conflict, 0),
|
||||
EditHistory: stack.New(),
|
||||
Platform: getPlatform(),
|
||||
}
|
||||
|
||||
type platform struct {
|
||||
os string
|
||||
shell string
|
||||
shellArg string
|
||||
escapedQuote string
|
||||
}
|
||||
|
||||
func getPlatform() platform {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return platform{
|
||||
os: "windows",
|
||||
shell: "cmd",
|
||||
shellArg: "/c",
|
||||
escapedQuote: "\\\"",
|
||||
}
|
||||
default:
|
||||
return platform{
|
||||
os: runtime.GOOS,
|
||||
shell: "bash",
|
||||
shellArg: "-c",
|
||||
escapedQuote: "\"",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scrollUpMain(g *gocui.Gui, v *gocui.View) error {
|
||||
mainView, _ := g.View("main")
|
||||
ox, oy := mainView.Origin()
|
||||
if oy >= 1 {
|
||||
return mainView.SetOrigin(ox, oy-1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scrollDownMain(g *gocui.Gui, v *gocui.View) error {
|
||||
mainView, _ := g.View("main")
|
||||
ox, oy := mainView.Origin()
|
||||
if oy < len(mainView.BufferLines()) {
|
||||
return mainView.SetOrigin(ox, oy+1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleRefresh(g *gocui.Gui, v *gocui.View) error {
|
||||
return refreshSidePanels(g)
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// layout is called for every screen re-render e.g. when the screen is resized
|
||||
func layout(g *gocui.Gui) error {
|
||||
g.Highlight = true
|
||||
g.SelFgColor = gocui.ColorWhite | gocui.AttrBold
|
||||
width, height := g.Size()
|
||||
leftSideWidth := width / 3
|
||||
statusFilesBoundary := 2
|
||||
filesBranchesBoundary := 2 * height / 5 // height - 20
|
||||
commitsBranchesBoundary := 3 * height / 5 // height - 10
|
||||
commitsStashBoundary := height - 5 // height - 5
|
||||
minimumHeight := 16
|
||||
minimumWidth := 10
|
||||
|
||||
panelSpacing := 1
|
||||
if OverlappingEdges {
|
||||
panelSpacing = 0
|
||||
}
|
||||
|
||||
if height < minimumHeight || width < minimumWidth {
|
||||
v, err := g.SetView("limit", 0, 0, max(width-1, 2), max(height-1, 2), 0)
|
||||
if err != nil {
|
||||
if err != gocui.ErrUnknownView {
|
||||
return err
|
||||
}
|
||||
v.Title = "Not enough space to render panels"
|
||||
v.Wrap = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
g.DeleteView("limit")
|
||||
|
||||
optionsTop := height - 2
|
||||
// hiding options if there's not enough space
|
||||
if height < 30 {
|
||||
optionsTop = height - 1
|
||||
}
|
||||
|
||||
v, err := g.SetView("main", leftSideWidth+panelSpacing, 0, width-1, optionsTop, gocui.LEFT)
|
||||
if err != nil {
|
||||
if err != gocui.ErrUnknownView {
|
||||
return err
|
||||
}
|
||||
v.Title = "Diff"
|
||||
v.Wrap = true
|
||||
v.FgColor = gocui.ColorWhite
|
||||
}
|
||||
|
||||
if v, err := g.SetView("status", 0, 0, leftSideWidth, statusFilesBoundary, gocui.BOTTOM|gocui.RIGHT); err != nil {
|
||||
if err != gocui.ErrUnknownView {
|
||||
return err
|
||||
}
|
||||
v.Title = "Status"
|
||||
v.FgColor = gocui.ColorWhite
|
||||
}
|
||||
|
||||
filesView, err := g.SetView("files", 0, statusFilesBoundary+panelSpacing, leftSideWidth, filesBranchesBoundary, gocui.TOP|gocui.BOTTOM)
|
||||
if err != nil {
|
||||
if err != gocui.ErrUnknownView {
|
||||
return err
|
||||
}
|
||||
filesView.Highlight = true
|
||||
filesView.Title = "Files"
|
||||
v.FgColor = gocui.ColorWhite
|
||||
}
|
||||
|
||||
if v, err := g.SetView("branches", 0, filesBranchesBoundary+panelSpacing, leftSideWidth, commitsBranchesBoundary, gocui.TOP|gocui.BOTTOM); err != nil {
|
||||
if err != gocui.ErrUnknownView {
|
||||
return err
|
||||
}
|
||||
v.Title = "Branches"
|
||||
v.FgColor = gocui.ColorWhite
|
||||
}
|
||||
|
||||
if v, err := g.SetView("commits", 0, commitsBranchesBoundary+panelSpacing, leftSideWidth, commitsStashBoundary, gocui.TOP|gocui.BOTTOM); err != nil {
|
||||
if err != gocui.ErrUnknownView {
|
||||
return err
|
||||
}
|
||||
v.Title = "Commits"
|
||||
v.FgColor = gocui.ColorWhite
|
||||
}
|
||||
|
||||
if v, err := g.SetView("stash", 0, commitsStashBoundary+panelSpacing, leftSideWidth, optionsTop, gocui.TOP|gocui.RIGHT); err != nil {
|
||||
if err != gocui.ErrUnknownView {
|
||||
return err
|
||||
}
|
||||
v.Title = "Stash"
|
||||
v.FgColor = gocui.ColorWhite
|
||||
}
|
||||
|
||||
if v, err := g.SetView("options", -1, optionsTop, width-len(version)-2, optionsTop+2, 0); err != nil {
|
||||
if err != gocui.ErrUnknownView {
|
||||
return err
|
||||
}
|
||||
v.FgColor = gocui.ColorBlue
|
||||
v.Frame = false
|
||||
}
|
||||
|
||||
if getCommitMessageView(g) == nil {
|
||||
// doesn't matter where this view starts because it will be hidden
|
||||
if commitMessageView, err := g.SetView("commitMessage", 0, 0, width, height, 0); err != nil {
|
||||
if err != gocui.ErrUnknownView {
|
||||
return err
|
||||
}
|
||||
g.SetViewOnBottom("commitMessage")
|
||||
commitMessageView.Title = "Commit message"
|
||||
commitMessageView.FgColor = gocui.ColorWhite
|
||||
commitMessageView.Editable = true
|
||||
}
|
||||
}
|
||||
|
||||
if v, err := g.SetView("version", width-len(version)-1, optionsTop, width, optionsTop+2, 0); err != nil {
|
||||
if err != gocui.ErrUnknownView {
|
||||
return err
|
||||
}
|
||||
v.BgColor = gocui.ColorDefault
|
||||
v.FgColor = gocui.ColorGreen
|
||||
v.Frame = false
|
||||
renderString(g, "version", version)
|
||||
|
||||
// these are only called once
|
||||
handleFileSelect(g, filesView)
|
||||
refreshFiles(g)
|
||||
refreshBranches(g)
|
||||
refreshCommits(g)
|
||||
refreshStashEntries(g)
|
||||
nextView(g, nil)
|
||||
}
|
||||
|
||||
resizePopupPanels(g)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetch(g *gocui.Gui) error {
|
||||
gitFetch()
|
||||
refreshStatus(g)
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateLoader(g *gocui.Gui) error {
|
||||
if confirmationView, _ := g.View("confirmation"); confirmationView != nil {
|
||||
content := trimmedContent(confirmationView)
|
||||
if strings.Contains(content, "...") {
|
||||
staticContent := strings.Split(content, "...")[0] + "..."
|
||||
renderString(g, "confirmation", staticContent+" "+loader())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func goEvery(g *gocui.Gui, interval time.Duration, function func(*gocui.Gui) error) {
|
||||
go func() {
|
||||
for range time.Tick(interval) {
|
||||
function(g)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func resizePopupPanels(g *gocui.Gui) error {
|
||||
v := g.CurrentView()
|
||||
if v.Name() == "commitMessage" || v.Name() == "confirmation" {
|
||||
return resizePopupPanel(g, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func run() (err error) {
|
||||
g, err := gocui.NewGui(gocui.OutputNormal, OverlappingEdges)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer g.Close()
|
||||
|
||||
g.FgColor = gocui.ColorDefault
|
||||
|
||||
goEvery(g, time.Second*60, fetch)
|
||||
goEvery(g, time.Second*10, refreshFiles)
|
||||
goEvery(g, time.Millisecond*10, updateLoader)
|
||||
|
||||
g.SetManagerFunc(layout)
|
||||
|
||||
if err = keybindings(g); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = g.MainLoop()
|
||||
return
|
||||
}
|
||||
|
||||
func quit(g *gocui.Gui, v *gocui.View) error {
|
||||
return gocui.ErrQuit
|
||||
}
|
||||
91
pkg/gui/keybindings.go
Normal file
91
pkg/gui/keybindings.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package gui
|
||||
|
||||
import "github.com/jesseduffield/gocui"
|
||||
|
||||
// Binding - a keybinding mapping a key and modifier to a handler. The keypress
|
||||
// is only handled if the given view has focus, or handled globally if the view
|
||||
// is ""
|
||||
type Binding struct {
|
||||
ViewName string
|
||||
Handler func(*gocui.Gui, *gocui.View) error
|
||||
Key interface{} // FIXME: find out how to get `gocui.Key | rune`
|
||||
Modifier gocui.Modifier
|
||||
}
|
||||
|
||||
func keybindings(g *gocui.Gui) error {
|
||||
bindings := []Binding{
|
||||
{ViewName: "", Key: 'q', Modifier: gocui.ModNone, Handler: quit},
|
||||
{ViewName: "", Key: gocui.KeyCtrlC, Modifier: gocui.ModNone, Handler: quit},
|
||||
{ViewName: "", Key: gocui.KeyPgup, Modifier: gocui.ModNone, Handler: scrollUpMain},
|
||||
{ViewName: "", Key: gocui.KeyPgdn, Modifier: gocui.ModNone, Handler: scrollDownMain},
|
||||
{ViewName: "", Key: 'P', Modifier: gocui.ModNone, Handler: pushFiles},
|
||||
{ViewName: "", Key: 'p', Modifier: gocui.ModNone, Handler: pullFiles},
|
||||
{ViewName: "", Key: 'R', Modifier: gocui.ModNone, Handler: handleRefresh},
|
||||
{ViewName: "files", Key: 'c', Modifier: gocui.ModNone, Handler: handleCommitPress},
|
||||
{ViewName: "files", Key: 'C', Modifier: gocui.ModNone, Handler: handleCommitEditorPress},
|
||||
{ViewName: "files", Key: gocui.KeySpace, Modifier: gocui.ModNone, Handler: handleFilePress},
|
||||
{ViewName: "files", Key: 'd', Modifier: gocui.ModNone, Handler: handleFileRemove},
|
||||
{ViewName: "files", Key: 'm', Modifier: gocui.ModNone, Handler: handleSwitchToMerge},
|
||||
{ViewName: "files", Key: 'e', Modifier: gocui.ModNone, Handler: handleFileEdit},
|
||||
{ViewName: "files", Key: 'o', Modifier: gocui.ModNone, Handler: handleFileOpen},
|
||||
{ViewName: "files", Key: 's', Modifier: gocui.ModNone, Handler: handleSublimeFileOpen},
|
||||
{ViewName: "files", Key: 'v', Modifier: gocui.ModNone, Handler: handleVsCodeFileOpen},
|
||||
{ViewName: "files", Key: 'i', Modifier: gocui.ModNone, Handler: handleIgnoreFile},
|
||||
{ViewName: "files", Key: 'r', Modifier: gocui.ModNone, Handler: handleRefreshFiles},
|
||||
{ViewName: "files", Key: 'S', Modifier: gocui.ModNone, Handler: handleStashSave},
|
||||
{ViewName: "files", Key: 'a', Modifier: gocui.ModNone, Handler: handleAbortMerge},
|
||||
{ViewName: "files", Key: 't', Modifier: gocui.ModNone, Handler: handleAddPatch},
|
||||
{ViewName: "files", Key: 'D', Modifier: gocui.ModNone, Handler: handleResetHard},
|
||||
{ViewName: "main", Key: gocui.KeyEsc, Modifier: gocui.ModNone, Handler: handleEscapeMerge},
|
||||
{ViewName: "main", Key: gocui.KeySpace, Modifier: gocui.ModNone, Handler: handlePickHunk},
|
||||
{ViewName: "main", Key: 'b', Modifier: gocui.ModNone, Handler: handlePickBothHunks},
|
||||
{ViewName: "main", Key: gocui.KeyArrowLeft, Modifier: gocui.ModNone, Handler: handleSelectPrevConflict},
|
||||
{ViewName: "main", Key: gocui.KeyArrowRight, Modifier: gocui.ModNone, Handler: handleSelectNextConflict},
|
||||
{ViewName: "main", Key: gocui.KeyArrowUp, Modifier: gocui.ModNone, Handler: handleSelectTop},
|
||||
{ViewName: "main", Key: gocui.KeyArrowDown, Modifier: gocui.ModNone, Handler: handleSelectBottom},
|
||||
{ViewName: "main", Key: 'h', Modifier: gocui.ModNone, Handler: handleSelectPrevConflict},
|
||||
{ViewName: "main", Key: 'l', Modifier: gocui.ModNone, Handler: handleSelectNextConflict},
|
||||
{ViewName: "main", Key: 'k', Modifier: gocui.ModNone, Handler: handleSelectTop},
|
||||
{ViewName: "main", Key: 'j', Modifier: gocui.ModNone, Handler: handleSelectBottom},
|
||||
{ViewName: "main", Key: 'z', Modifier: gocui.ModNone, Handler: handlePopFileSnapshot},
|
||||
{ViewName: "branches", Key: gocui.KeySpace, Modifier: gocui.ModNone, Handler: handleBranchPress},
|
||||
{ViewName: "branches", Key: 'c', Modifier: gocui.ModNone, Handler: handleCheckoutByName},
|
||||
{ViewName: "branches", Key: 'F', Modifier: gocui.ModNone, Handler: handleForceCheckout},
|
||||
{ViewName: "branches", Key: 'n', Modifier: gocui.ModNone, Handler: handleNewBranch},
|
||||
{ViewName: "branches", Key: 'd', Modifier: gocui.ModNone, Handler: handleDeleteBranch},
|
||||
{ViewName: "branches", Key: 'm', Modifier: gocui.ModNone, Handler: handleMerge},
|
||||
{ViewName: "commits", Key: 's', Modifier: gocui.ModNone, Handler: handleCommitSquashDown},
|
||||
{ViewName: "commits", Key: 'r', Modifier: gocui.ModNone, Handler: handleRenameCommit},
|
||||
{ViewName: "commits", Key: 'g', Modifier: gocui.ModNone, Handler: handleResetToCommit},
|
||||
{ViewName: "commits", Key: 'f', Modifier: gocui.ModNone, Handler: handleCommitFixup},
|
||||
{ViewName: "stash", Key: gocui.KeySpace, Modifier: gocui.ModNone, Handler: handleStashApply},
|
||||
{ViewName: "stash", Key: 'g', Modifier: gocui.ModNone, Handler: handleStashPop},
|
||||
{ViewName: "stash", Key: 'd', Modifier: gocui.ModNone, Handler: handleStashDrop},
|
||||
{ViewName: "commitMessage", Key: gocui.KeyEnter, Modifier: gocui.ModNone, Handler: handleCommitConfirm},
|
||||
{ViewName: "commitMessage", Key: gocui.KeyEsc, Modifier: gocui.ModNone, Handler: handleCommitClose},
|
||||
{ViewName: "commitMessage", Key: gocui.KeyTab, Modifier: gocui.ModNone, Handler: handleNewlineCommitMessage},
|
||||
}
|
||||
|
||||
// Would make these keybindings global but that interferes with editing
|
||||
// input in the confirmation panel
|
||||
for _, viewName := range []string{"files", "branches", "commits", "stash"} {
|
||||
bindings = append(bindings, []Binding{
|
||||
{ViewName: viewName, Key: gocui.KeyTab, Modifier: gocui.ModNone, Handler: nextView},
|
||||
{ViewName: viewName, Key: gocui.KeyArrowLeft, Modifier: gocui.ModNone, Handler: previousView},
|
||||
{ViewName: viewName, Key: gocui.KeyArrowRight, Modifier: gocui.ModNone, Handler: nextView},
|
||||
{ViewName: viewName, Key: gocui.KeyArrowUp, Modifier: gocui.ModNone, Handler: cursorUp},
|
||||
{ViewName: viewName, Key: gocui.KeyArrowDown, Modifier: gocui.ModNone, Handler: cursorDown},
|
||||
{ViewName: viewName, Key: 'h', Modifier: gocui.ModNone, Handler: previousView},
|
||||
{ViewName: viewName, Key: 'l', Modifier: gocui.ModNone, Handler: nextView},
|
||||
{ViewName: viewName, Key: 'k', Modifier: gocui.ModNone, Handler: cursorUp},
|
||||
{ViewName: viewName, Key: 'j', Modifier: gocui.ModNone, Handler: cursorDown},
|
||||
}...)
|
||||
}
|
||||
|
||||
for _, binding := range bindings {
|
||||
if err := g.SetKeybinding(binding.ViewName, binding.Key, binding.Modifier, binding.Handler); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
135
pkg/gui/panels/branches_panel.go
Normal file
135
pkg/gui/panels/branches_panel.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jesseduffield/gocui"
|
||||
)
|
||||
|
||||
func handleBranchPress(g *gocui.Gui, v *gocui.View) error {
|
||||
index := getItemPosition(v)
|
||||
if index == 0 {
|
||||
return createErrorPanel(g, "You have already checked out this branch")
|
||||
}
|
||||
branch := getSelectedBranch(v)
|
||||
if output, err := gitCheckout(branch.Name, false); err != nil {
|
||||
createErrorPanel(g, output)
|
||||
}
|
||||
return refreshSidePanels(g)
|
||||
}
|
||||
|
||||
func handleForceCheckout(g *gocui.Gui, v *gocui.View) error {
|
||||
branch := getSelectedBranch(v)
|
||||
return createConfirmationPanel(g, v, "Force Checkout Branch", "Are you sure you want force checkout? You will lose all local changes", func(g *gocui.Gui, v *gocui.View) error {
|
||||
if output, err := gitCheckout(branch.Name, true); err != nil {
|
||||
createErrorPanel(g, output)
|
||||
}
|
||||
return refreshSidePanels(g)
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func handleCheckoutByName(g *gocui.Gui, v *gocui.View) error {
|
||||
createPromptPanel(g, v, "Branch Name:", func(g *gocui.Gui, v *gocui.View) error {
|
||||
if output, err := gitCheckout(trimmedContent(v), false); err != nil {
|
||||
return createErrorPanel(g, output)
|
||||
}
|
||||
return refreshSidePanels(g)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleNewBranch(g *gocui.Gui, v *gocui.View) error {
|
||||
branch := state.Branches[0]
|
||||
createPromptPanel(g, v, "New Branch Name (Branch is off of "+branch.Name+")", func(g *gocui.Gui, v *gocui.View) error {
|
||||
if output, err := gitNewBranch(trimmedContent(v)); err != nil {
|
||||
return createErrorPanel(g, output)
|
||||
}
|
||||
refreshSidePanels(g)
|
||||
return handleBranchSelect(g, v)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleDeleteBranch(g *gocui.Gui, v *gocui.View) error {
|
||||
checkedOutBranch := state.Branches[0]
|
||||
selectedBranch := getSelectedBranch(v)
|
||||
if checkedOutBranch.Name == selectedBranch.Name {
|
||||
return createErrorPanel(g, "You cannot delete the checked out branch!")
|
||||
}
|
||||
return createConfirmationPanel(g, v, "Delete Branch", "Are you sure you want delete the branch "+selectedBranch.Name+" ?", func(g *gocui.Gui, v *gocui.View) error {
|
||||
if output, err := gitDeleteBranch(selectedBranch.Name); err != nil {
|
||||
return createErrorPanel(g, output)
|
||||
}
|
||||
return refreshSidePanels(g)
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func handleMerge(g *gocui.Gui, v *gocui.View) error {
|
||||
checkedOutBranch := state.Branches[0]
|
||||
selectedBranch := getSelectedBranch(v)
|
||||
defer refreshSidePanels(g)
|
||||
if checkedOutBranch.Name == selectedBranch.Name {
|
||||
return createErrorPanel(g, "You cannot merge a branch into itself")
|
||||
}
|
||||
if output, err := gitMerge(selectedBranch.Name); err != nil {
|
||||
return createErrorPanel(g, output)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSelectedBranch(v *gocui.View) Branch {
|
||||
lineNumber := getItemPosition(v)
|
||||
return state.Branches[lineNumber]
|
||||
}
|
||||
|
||||
func renderBranchesOptions(g *gocui.Gui) error {
|
||||
return renderOptionsMap(g, map[string]string{
|
||||
"space": "checkout",
|
||||
"f": "force checkout",
|
||||
"m": "merge",
|
||||
"c": "checkout by name",
|
||||
"n": "new branch",
|
||||
"d": "delete branch",
|
||||
"← → ↑ ↓": "navigate",
|
||||
})
|
||||
}
|
||||
|
||||
// may want to standardise how these select methods work
|
||||
func handleBranchSelect(g *gocui.Gui, v *gocui.View) error {
|
||||
if err := renderBranchesOptions(g); err != nil {
|
||||
return err
|
||||
}
|
||||
// This really shouldn't happen: there should always be a master branch
|
||||
if len(state.Branches) == 0 {
|
||||
return renderString(g, "main", "No branches for this repo")
|
||||
}
|
||||
go func() {
|
||||
branch := getSelectedBranch(v)
|
||||
diff, err := getBranchGraph(branch.Name)
|
||||
if err != nil && strings.HasPrefix(diff, "fatal: ambiguous argument") {
|
||||
diff = "There is no tracking for this branch"
|
||||
}
|
||||
renderString(g, "main", diff)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// refreshStatus is called at the end of this because that's when we can
|
||||
// be sure there is a state.Branches array to pick the current branch from
|
||||
func refreshBranches(g *gocui.Gui) error {
|
||||
g.Update(func(g *gocui.Gui) error {
|
||||
v, err := g.View("branches")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
state.Branches = getGitBranches()
|
||||
v.Clear()
|
||||
for _, branch := range state.Branches {
|
||||
fmt.Fprintln(v, branch.getDisplayString())
|
||||
}
|
||||
resetOrigin(v)
|
||||
return refreshStatus(g)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
45
pkg/gui/panels/commit_message_panel.go
Normal file
45
pkg/gui/panels/commit_message_panel.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import "github.com/jesseduffield/gocui"
|
||||
|
||||
func handleCommitConfirm(g *gocui.Gui, v *gocui.View) error {
|
||||
message := trimmedContent(v)
|
||||
if message == "" {
|
||||
return createErrorPanel(g, "You cannot commit without a commit message")
|
||||
}
|
||||
if output, err := gitCommit(g, message); err != nil {
|
||||
if err == errNoUsername {
|
||||
return createErrorPanel(g, err.Error())
|
||||
}
|
||||
return createErrorPanel(g, output)
|
||||
}
|
||||
refreshFiles(g)
|
||||
v.Clear()
|
||||
v.SetCursor(0, 0)
|
||||
g.SetViewOnBottom("commitMessage")
|
||||
switchFocus(g, v, getFilesView(g))
|
||||
return refreshCommits(g)
|
||||
}
|
||||
|
||||
func handleCommitClose(g *gocui.Gui, v *gocui.View) error {
|
||||
g.SetViewOnBottom("commitMessage")
|
||||
return switchFocus(g, v, getFilesView(g))
|
||||
}
|
||||
|
||||
func handleNewlineCommitMessage(g *gocui.Gui, v *gocui.View) error {
|
||||
// resising ahead of time so that the top line doesn't get hidden to make
|
||||
// room for the cursor on the second line
|
||||
x0, y0, x1, y1 := getConfirmationPanelDimensions(g, v.Buffer())
|
||||
if _, err := g.SetView("commitMessage", x0, y0, x1, y1+1, 0); err != nil {
|
||||
if err != gocui.ErrUnknownView {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
v.EditNewLine()
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleCommitFocused(g *gocui.Gui, v *gocui.View) error {
|
||||
return renderString(g, "options", "esc: close, enter: confirm")
|
||||
}
|
||||
176
pkg/gui/panels/commits_panel.go
Normal file
176
pkg/gui/panels/commits_panel.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/jesseduffield/gocui"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoCommits : When no commits are found for the branch
|
||||
ErrNoCommits = errors.New("No commits for this branch")
|
||||
)
|
||||
|
||||
func refreshCommits(g *gocui.Gui) error {
|
||||
g.Update(func(*gocui.Gui) error {
|
||||
state.Commits = getCommits()
|
||||
v, err := g.View("commits")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
v.Clear()
|
||||
red := color.New(color.FgRed)
|
||||
yellow := color.New(color.FgYellow)
|
||||
white := color.New(color.FgWhite)
|
||||
shaColor := white
|
||||
for _, commit := range state.Commits {
|
||||
if commit.Pushed {
|
||||
shaColor = red
|
||||
} else {
|
||||
shaColor = yellow
|
||||
}
|
||||
shaColor.Fprint(v, commit.Sha+" ")
|
||||
white.Fprintln(v, commit.Name)
|
||||
}
|
||||
refreshStatus(g)
|
||||
if g.CurrentView().Name() == "commits" {
|
||||
handleCommitSelect(g, v)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleResetToCommit(g *gocui.Gui, commitView *gocui.View) error {
|
||||
return createConfirmationPanel(g, commitView, "Reset To Commit", "Are you sure you want to reset to this commit?", func(g *gocui.Gui, v *gocui.View) error {
|
||||
commit, err := getSelectedCommit(g)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if output, err := gitResetToCommit(commit.Sha); err != nil {
|
||||
return createErrorPanel(g, output)
|
||||
}
|
||||
if err := refreshCommits(g); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := refreshFiles(g); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
resetOrigin(commitView)
|
||||
return handleCommitSelect(g, nil)
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func renderCommitsOptions(g *gocui.Gui) error {
|
||||
return renderOptionsMap(g, map[string]string{
|
||||
"s": "squash down",
|
||||
"r": "rename",
|
||||
"g": "reset to this commit",
|
||||
"f": "fixup commit",
|
||||
"← → ↑ ↓": "navigate",
|
||||
})
|
||||
}
|
||||
|
||||
func handleCommitSelect(g *gocui.Gui, v *gocui.View) error {
|
||||
if err := renderCommitsOptions(g); err != nil {
|
||||
return err
|
||||
}
|
||||
commit, err := getSelectedCommit(g)
|
||||
if err != nil {
|
||||
if err != ErrNoCommits {
|
||||
return err
|
||||
}
|
||||
return renderString(g, "main", "No commits for this branch")
|
||||
}
|
||||
commitText := gitShow(commit.Sha)
|
||||
return renderString(g, "main", commitText)
|
||||
}
|
||||
|
||||
func handleCommitSquashDown(g *gocui.Gui, v *gocui.View) error {
|
||||
if getItemPosition(v) != 0 {
|
||||
return createErrorPanel(g, "Can only squash topmost commit")
|
||||
}
|
||||
if len(state.Commits) == 1 {
|
||||
return createErrorPanel(g, "You have no commits to squash with")
|
||||
}
|
||||
commit, err := getSelectedCommit(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if output, err := gitSquashPreviousTwoCommits(commit.Name); err != nil {
|
||||
return createErrorPanel(g, output)
|
||||
}
|
||||
if err := refreshCommits(g); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
refreshStatus(g)
|
||||
return handleCommitSelect(g, v)
|
||||
}
|
||||
|
||||
// TODO: move to files panel
|
||||
func anyUnStagedChanges(files []GitFile) bool {
|
||||
for _, file := range files {
|
||||
if file.Tracked && file.HasUnstagedChanges {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func handleCommitFixup(g *gocui.Gui, v *gocui.View) error {
|
||||
if len(state.Commits) == 1 {
|
||||
return createErrorPanel(g, "You have no commits to squash with")
|
||||
}
|
||||
objectLog(state.GitFiles)
|
||||
if anyUnStagedChanges(state.GitFiles) {
|
||||
return createErrorPanel(g, "Can't fixup while there are unstaged changes")
|
||||
}
|
||||
branch := state.Branches[0]
|
||||
commit, err := getSelectedCommit(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
createConfirmationPanel(g, v, "Fixup", "Are you sure you want to fixup this commit? The commit beneath will be squashed up into this one", func(g *gocui.Gui, v *gocui.View) error {
|
||||
if output, err := gitSquashFixupCommit(branch.Name, commit.Sha); err != nil {
|
||||
return createErrorPanel(g, output)
|
||||
}
|
||||
if err := refreshCommits(g); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return refreshStatus(g)
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleRenameCommit(g *gocui.Gui, v *gocui.View) error {
|
||||
if getItemPosition(v) != 0 {
|
||||
return createErrorPanel(g, "Can only rename topmost commit")
|
||||
}
|
||||
createPromptPanel(g, v, "Rename Commit", func(g *gocui.Gui, v *gocui.View) error {
|
||||
if output, err := gitRenameCommit(v.Buffer()); err != nil {
|
||||
return createErrorPanel(g, output)
|
||||
}
|
||||
if err := refreshCommits(g); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return handleCommitSelect(g, v)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSelectedCommit(g *gocui.Gui) (Commit, error) {
|
||||
v, err := g.View("commits")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if len(state.Commits) == 0 {
|
||||
return Commit{}, ErrNoCommits
|
||||
}
|
||||
lineNumber := getItemPosition(v)
|
||||
if lineNumber > len(state.Commits)-1 {
|
||||
devLog("potential error in getSelected Commit (mismatched ui and state)", state.Commits, lineNumber)
|
||||
return state.Commits[len(state.Commits)-1], nil
|
||||
}
|
||||
return state.Commits[lineNumber], nil
|
||||
}
|
||||
155
pkg/gui/panels/confirmation_panel.go
Normal file
155
pkg/gui/panels/confirmation_panel.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// lots of this has been directly ported from one of the example files, will brush up later
|
||||
|
||||
// Copyright 2014 The gocui Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/jesseduffield/gocui"
|
||||
)
|
||||
|
||||
func wrappedConfirmationFunction(function func(*gocui.Gui, *gocui.View) error) func(*gocui.Gui, *gocui.View) error {
|
||||
return func(g *gocui.Gui, v *gocui.View) error {
|
||||
if function != nil {
|
||||
if err := function(g, v); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
return closeConfirmationPrompt(g)
|
||||
}
|
||||
}
|
||||
|
||||
func closeConfirmationPrompt(g *gocui.Gui) error {
|
||||
view, err := g.View("confirmation")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := returnFocus(g, view); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
g.DeleteKeybindings("confirmation")
|
||||
return g.DeleteView("confirmation")
|
||||
}
|
||||
|
||||
func getMessageHeight(message string, width int) int {
|
||||
lines := strings.Split(message, "\n")
|
||||
lineCount := 0
|
||||
for _, line := range lines {
|
||||
lineCount += len(line)/width + 1
|
||||
}
|
||||
return lineCount
|
||||
}
|
||||
|
||||
func getConfirmationPanelDimensions(g *gocui.Gui, prompt string) (int, int, int, int) {
|
||||
width, height := g.Size()
|
||||
panelWidth := width / 2
|
||||
panelHeight := getMessageHeight(prompt, panelWidth)
|
||||
return width/2 - panelWidth/2,
|
||||
height/2 - panelHeight/2 - panelHeight%2 - 1,
|
||||
width/2 + panelWidth/2,
|
||||
height/2 + panelHeight/2
|
||||
}
|
||||
|
||||
func createPromptPanel(g *gocui.Gui, currentView *gocui.View, title string, handleConfirm func(*gocui.Gui, *gocui.View) error) error {
|
||||
g.SetViewOnBottom("commitMessage")
|
||||
// only need to fit one line
|
||||
x0, y0, x1, y1 := getConfirmationPanelDimensions(g, "")
|
||||
if confirmationView, err := g.SetView("confirmation", x0, y0, x1, y1, 0); err != nil {
|
||||
if err != gocui.ErrUnknownView {
|
||||
return err
|
||||
}
|
||||
|
||||
confirmationView.Editable = true
|
||||
confirmationView.Title = title
|
||||
switchFocus(g, currentView, confirmationView)
|
||||
return setKeyBindings(g, handleConfirm, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createConfirmationPanel(g *gocui.Gui, currentView *gocui.View, title, prompt string, handleConfirm, handleClose func(*gocui.Gui, *gocui.View) error) error {
|
||||
g.SetViewOnBottom("commitMessage")
|
||||
g.Update(func(g *gocui.Gui) error {
|
||||
// delete the existing confirmation panel if it exists
|
||||
if view, _ := g.View("confirmation"); view != nil {
|
||||
if err := closeConfirmationPrompt(g); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
x0, y0, x1, y1 := getConfirmationPanelDimensions(g, prompt)
|
||||
if confirmationView, err := g.SetView("confirmation", x0, y0, x1, y1, 0); err != nil {
|
||||
if err != gocui.ErrUnknownView {
|
||||
return err
|
||||
}
|
||||
confirmationView.Title = title
|
||||
confirmationView.FgColor = gocui.ColorWhite
|
||||
renderString(g, "confirmation", prompt)
|
||||
switchFocus(g, currentView, confirmationView)
|
||||
return setKeyBindings(g, handleConfirm, handleClose)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleNewline(g *gocui.Gui, v *gocui.View) error {
|
||||
// resising ahead of time so that the top line doesn't get hidden to make
|
||||
// room for the cursor on the second line
|
||||
x0, y0, x1, y1 := getConfirmationPanelDimensions(g, v.Buffer())
|
||||
if _, err := g.SetView("confirmation", x0, y0, x1, y1+1, 0); err != nil {
|
||||
if err != gocui.ErrUnknownView {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
v.EditNewLine()
|
||||
return nil
|
||||
}
|
||||
|
||||
func setKeyBindings(g *gocui.Gui, handleConfirm, handleClose func(*gocui.Gui, *gocui.View) error) error {
|
||||
renderString(g, "options", "esc: close, enter: confirm")
|
||||
if err := g.SetKeybinding("confirmation", gocui.KeyEnter, gocui.ModNone, wrappedConfirmationFunction(handleConfirm)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := g.SetKeybinding("confirmation", gocui.KeyTab, gocui.ModNone, handleNewline); err != nil {
|
||||
return err
|
||||
}
|
||||
return g.SetKeybinding("confirmation", gocui.KeyEsc, gocui.ModNone, wrappedConfirmationFunction(handleClose))
|
||||
}
|
||||
|
||||
func createMessagePanel(g *gocui.Gui, currentView *gocui.View, title, prompt string) error {
|
||||
return createConfirmationPanel(g, currentView, title, prompt, nil, nil)
|
||||
}
|
||||
|
||||
func createErrorPanel(g *gocui.Gui, message string) error {
|
||||
currentView := g.CurrentView()
|
||||
colorFunction := color.New(color.FgRed).SprintFunc()
|
||||
coloredMessage := colorFunction(strings.TrimSpace(message))
|
||||
return createConfirmationPanel(g, currentView, "Error", coloredMessage, nil, nil)
|
||||
}
|
||||
|
||||
func trimTrailingNewline(str string) string {
|
||||
if strings.HasSuffix(str, "\n") {
|
||||
return str[:len(str)-1]
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
func resizePopupPanel(g *gocui.Gui, v *gocui.View) error {
|
||||
// If the confirmation panel is already displayed, just resize the width,
|
||||
// otherwise continue
|
||||
content := trimTrailingNewline(v.Buffer())
|
||||
x0, y0, x1, y1 := getConfirmationPanelDimensions(g, content)
|
||||
vx0, vy0, vx1, vy1 := v.Dimensions()
|
||||
if vx0 == x0 && vy0 == y0 && vx1 == x1 && vy1 == y1 {
|
||||
return nil
|
||||
}
|
||||
devLog("resizing popup panel")
|
||||
_, err := g.SetView(v.Name(), x0, y0, x1, y1, 0)
|
||||
return err
|
||||
}
|
||||
362
pkg/gui/panels/files_panel.go
Normal file
362
pkg/gui/panels/files_panel.go
Normal file
@@ -0,0 +1,362 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
// "io"
|
||||
// "io/ioutil"
|
||||
|
||||
// "strings"
|
||||
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/jesseduffield/gocui"
|
||||
)
|
||||
|
||||
var (
|
||||
errNoFiles = errors.New("No changed files")
|
||||
errNoUsername = errors.New(`No username set. Please do: git config --global user.name "Your Name"`)
|
||||
)
|
||||
|
||||
func stagedFiles(files []GitFile) []GitFile {
|
||||
result := make([]GitFile, 0)
|
||||
for _, file := range files {
|
||||
if file.HasStagedChanges {
|
||||
result = append(result, file)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func stageSelectedFile(g *gocui.Gui) error {
|
||||
file, err := getSelectedFile(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return stageFile(file.Name)
|
||||
}
|
||||
|
||||
func handleFilePress(g *gocui.Gui, v *gocui.View) error {
|
||||
file, err := getSelectedFile(g)
|
||||
if err != nil {
|
||||
if err == errNoFiles {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if file.HasMergeConflicts {
|
||||
return handleSwitchToMerge(g, v)
|
||||
}
|
||||
|
||||
if file.HasUnstagedChanges {
|
||||
stageFile(file.Name)
|
||||
} else {
|
||||
unStageFile(file.Name, file.Tracked)
|
||||
}
|
||||
|
||||
if err := refreshFiles(g); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return handleFileSelect(g, v)
|
||||
}
|
||||
|
||||
func handleAddPatch(g *gocui.Gui, v *gocui.View) error {
|
||||
file, err := getSelectedFile(g)
|
||||
if err != nil {
|
||||
if err == errNoFiles {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !file.HasUnstagedChanges {
|
||||
return createErrorPanel(g, "File has no unstaged changes to add")
|
||||
}
|
||||
if !file.Tracked {
|
||||
return createErrorPanel(g, "Cannot git add --patch untracked files")
|
||||
}
|
||||
gitAddPatch(g, file.Name)
|
||||
return err
|
||||
}
|
||||
|
||||
func getSelectedFile(g *gocui.Gui) (GitFile, error) {
|
||||
if len(state.GitFiles) == 0 {
|
||||
return GitFile{}, errNoFiles
|
||||
}
|
||||
filesView, err := g.View("files")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
lineNumber := getItemPosition(filesView)
|
||||
return state.GitFiles[lineNumber], nil
|
||||
}
|
||||
|
||||
func handleFileRemove(g *gocui.Gui, v *gocui.View) error {
|
||||
file, err := getSelectedFile(g)
|
||||
if err != nil {
|
||||
if err == errNoFiles {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
var deleteVerb string
|
||||
if file.Tracked {
|
||||
deleteVerb = "checkout"
|
||||
} else {
|
||||
deleteVerb = "delete"
|
||||
}
|
||||
return createConfirmationPanel(g, v, strings.Title(deleteVerb)+" file", "Are you sure you want to "+deleteVerb+" "+file.Name+" (you will lose your changes)?", func(g *gocui.Gui, v *gocui.View) error {
|
||||
if err := removeFile(file); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return refreshFiles(g)
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func handleIgnoreFile(g *gocui.Gui, v *gocui.View) error {
|
||||
file, err := getSelectedFile(g)
|
||||
if err != nil {
|
||||
return createErrorPanel(g, err.Error())
|
||||
}
|
||||
if file.Tracked {
|
||||
return createErrorPanel(g, "Cannot ignore tracked files")
|
||||
}
|
||||
gitIgnore(file.Name)
|
||||
return refreshFiles(g)
|
||||
}
|
||||
|
||||
func renderfilesOptions(g *gocui.Gui, gitFile *GitFile) error {
|
||||
optionsMap := map[string]string{
|
||||
"← → ↑ ↓": "navigate",
|
||||
"S": "stash files",
|
||||
"c": "commit changes",
|
||||
"o": "open",
|
||||
"i": "ignore",
|
||||
"d": "delete",
|
||||
"space": "toggle staged",
|
||||
"R": "refresh",
|
||||
"t": "add patch",
|
||||
"e": "edit",
|
||||
"PgUp/PgDn": "scroll",
|
||||
}
|
||||
if state.HasMergeConflicts {
|
||||
optionsMap["a"] = "abort merge"
|
||||
optionsMap["m"] = "resolve merge conflicts"
|
||||
}
|
||||
if gitFile == nil {
|
||||
return renderOptionsMap(g, optionsMap)
|
||||
}
|
||||
if gitFile.Tracked {
|
||||
optionsMap["d"] = "checkout"
|
||||
}
|
||||
return renderOptionsMap(g, optionsMap)
|
||||
}
|
||||
|
||||
func handleFileSelect(g *gocui.Gui, v *gocui.View) error {
|
||||
gitFile, err := getSelectedFile(g)
|
||||
if err != nil {
|
||||
if err != errNoFiles {
|
||||
return err
|
||||
}
|
||||
renderString(g, "main", "No changed files")
|
||||
return renderfilesOptions(g, nil)
|
||||
}
|
||||
renderfilesOptions(g, &gitFile)
|
||||
var content string
|
||||
if gitFile.HasMergeConflicts {
|
||||
return refreshMergePanel(g)
|
||||
}
|
||||
|
||||
content = getDiff(gitFile)
|
||||
return renderString(g, "main", content)
|
||||
}
|
||||
|
||||
func handleCommitPress(g *gocui.Gui, filesView *gocui.View) error {
|
||||
if len(stagedFiles(state.GitFiles)) == 0 && !state.HasMergeConflicts {
|
||||
return createErrorPanel(g, "There are no staged files to commit")
|
||||
}
|
||||
commitMessageView := getCommitMessageView(g)
|
||||
g.Update(func(g *gocui.Gui) error {
|
||||
g.SetViewOnTop("commitMessage")
|
||||
switchFocus(g, filesView, commitMessageView)
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleCommitEditorPress(g *gocui.Gui, filesView *gocui.View) error {
|
||||
if len(stagedFiles(state.GitFiles)) == 0 && !state.HasMergeConflicts {
|
||||
return createErrorPanel(g, "There are no staged files to commit")
|
||||
}
|
||||
runSubProcess(g, "git", "commit")
|
||||
return nil
|
||||
}
|
||||
|
||||
func genericFileOpen(g *gocui.Gui, v *gocui.View, open func(*gocui.Gui, string) (string, error)) error {
|
||||
file, err := getSelectedFile(g)
|
||||
if err != nil {
|
||||
if err != errNoFiles {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, err := open(g, file.Name); err != nil {
|
||||
return createErrorPanel(g, err.Error())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleFileEdit(g *gocui.Gui, v *gocui.View) error {
|
||||
return genericFileOpen(g, v, editFile)
|
||||
}
|
||||
|
||||
func handleFileOpen(g *gocui.Gui, v *gocui.View) error {
|
||||
return genericFileOpen(g, v, openFile)
|
||||
}
|
||||
|
||||
func handleSublimeFileOpen(g *gocui.Gui, v *gocui.View) error {
|
||||
return genericFileOpen(g, v, sublimeOpenFile)
|
||||
}
|
||||
|
||||
func handleVsCodeFileOpen(g *gocui.Gui, v *gocui.View) error {
|
||||
return genericFileOpen(g, v, vsCodeOpenFile)
|
||||
}
|
||||
|
||||
func handleRefreshFiles(g *gocui.Gui, v *gocui.View) error {
|
||||
return refreshFiles(g)
|
||||
}
|
||||
|
||||
func refreshStateGitFiles() {
|
||||
// get files to stage
|
||||
gitFiles := getGitStatusFiles()
|
||||
state.GitFiles = mergeGitStatusFiles(state.GitFiles, gitFiles)
|
||||
updateHasMergeConflictStatus()
|
||||
}
|
||||
|
||||
func updateHasMergeConflictStatus() error {
|
||||
merging, err := isInMergeState()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.HasMergeConflicts = merging
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderGitFile(gitFile GitFile, filesView *gocui.View) {
|
||||
// potentially inefficient to be instantiating these color
|
||||
// objects with each render
|
||||
red := color.New(color.FgRed)
|
||||
green := color.New(color.FgGreen)
|
||||
if !gitFile.Tracked && !gitFile.HasStagedChanges {
|
||||
red.Fprintln(filesView, gitFile.DisplayString)
|
||||
return
|
||||
}
|
||||
green.Fprint(filesView, gitFile.DisplayString[0:1])
|
||||
red.Fprint(filesView, gitFile.DisplayString[1:3])
|
||||
if gitFile.HasUnstagedChanges {
|
||||
red.Fprintln(filesView, gitFile.Name)
|
||||
} else {
|
||||
green.Fprintln(filesView, gitFile.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func catSelectedFile(g *gocui.Gui) (string, error) {
|
||||
item, err := getSelectedFile(g)
|
||||
if err != nil {
|
||||
if err != errNoFiles {
|
||||
return "", err
|
||||
}
|
||||
return "", renderString(g, "main", "No file to display")
|
||||
}
|
||||
cat, err := catFile(item.Name)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return cat, nil
|
||||
}
|
||||
|
||||
func refreshFiles(g *gocui.Gui) error {
|
||||
filesView, err := g.View("files")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refreshStateGitFiles()
|
||||
filesView.Clear()
|
||||
for _, gitFile := range state.GitFiles {
|
||||
renderGitFile(gitFile, filesView)
|
||||
}
|
||||
correctCursor(filesView)
|
||||
if filesView == g.CurrentView() {
|
||||
handleFileSelect(g, filesView)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pullFiles(g *gocui.Gui, v *gocui.View) error {
|
||||
createMessagePanel(g, v, "", "Pulling...")
|
||||
go func() {
|
||||
if output, err := gitPull(); err != nil {
|
||||
createErrorPanel(g, output)
|
||||
} else {
|
||||
closeConfirmationPrompt(g)
|
||||
refreshCommits(g)
|
||||
refreshStatus(g)
|
||||
}
|
||||
refreshFiles(g)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func pushFiles(g *gocui.Gui, v *gocui.View) error {
|
||||
createMessagePanel(g, v, "", "Pushing...")
|
||||
go func() {
|
||||
if output, err := gitPush(); err != nil {
|
||||
createErrorPanel(g, output)
|
||||
} else {
|
||||
closeConfirmationPrompt(g)
|
||||
refreshCommits(g)
|
||||
refreshStatus(g)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleSwitchToMerge(g *gocui.Gui, v *gocui.View) error {
|
||||
mergeView, err := g.View("main")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := getSelectedFile(g)
|
||||
if err != nil {
|
||||
if err != errNoFiles {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !file.HasMergeConflicts {
|
||||
return createErrorPanel(g, "This file has no merge conflicts")
|
||||
}
|
||||
switchFocus(g, v, mergeView)
|
||||
return refreshMergePanel(g)
|
||||
}
|
||||
|
||||
func handleAbortMerge(g *gocui.Gui, v *gocui.View) error {
|
||||
output, err := gitAbortMerge()
|
||||
if err != nil {
|
||||
return createErrorPanel(g, output)
|
||||
}
|
||||
createMessagePanel(g, v, "", "Merge aborted")
|
||||
refreshStatus(g)
|
||||
return refreshFiles(g)
|
||||
}
|
||||
|
||||
func handleResetHard(g *gocui.Gui, v *gocui.View) error {
|
||||
return createConfirmationPanel(g, v, "Clear file panel", "Are you sure you want `reset --hard HEAD`? You may lose changes", func(g *gocui.Gui, v *gocui.View) error {
|
||||
if err := gitResetHard(); err != nil {
|
||||
createErrorPanel(g, err.Error())
|
||||
}
|
||||
return refreshFiles(g)
|
||||
}, nil)
|
||||
}
|
||||
263
pkg/gui/panels/merge_panel.go
Normal file
263
pkg/gui/panels/merge_panel.go
Normal file
@@ -0,0 +1,263 @@
|
||||
// though this panel is called the merge panel, it's really going to use the main panel. This may change in the future
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/jesseduffield/gocui"
|
||||
)
|
||||
|
||||
func findConflicts(content string) ([]conflict, error) {
|
||||
conflicts := make([]conflict, 0)
|
||||
var newConflict conflict
|
||||
for i, line := range splitLines(content) {
|
||||
if line == "<<<<<<< HEAD" || line == "<<<<<<< MERGE_HEAD" || line == "<<<<<<< Updated upstream" {
|
||||
newConflict = conflict{start: i}
|
||||
} else if line == "=======" {
|
||||
newConflict.middle = i
|
||||
} else if strings.HasPrefix(line, ">>>>>>> ") {
|
||||
newConflict.end = i
|
||||
conflicts = append(conflicts, newConflict)
|
||||
}
|
||||
}
|
||||
return conflicts, nil
|
||||
}
|
||||
|
||||
func shiftConflict(conflicts []conflict) (conflict, []conflict) {
|
||||
return conflicts[0], conflicts[1:]
|
||||
}
|
||||
|
||||
func shouldHighlightLine(index int, conflict conflict, top bool) bool {
|
||||
return (index >= conflict.start && index <= conflict.middle && top) || (index >= conflict.middle && index <= conflict.end && !top)
|
||||
}
|
||||
|
||||
func coloredConflictFile(content string, conflicts []conflict, conflictIndex int, conflictTop, hasFocus bool) (string, error) {
|
||||
if len(conflicts) == 0 {
|
||||
return content, nil
|
||||
}
|
||||
conflict, remainingConflicts := shiftConflict(conflicts)
|
||||
var outputBuffer bytes.Buffer
|
||||
for i, line := range splitLines(content) {
|
||||
colourAttr := color.FgWhite
|
||||
if i == conflict.start || i == conflict.middle || i == conflict.end {
|
||||
colourAttr = color.FgRed
|
||||
}
|
||||
colour := color.New(colourAttr)
|
||||
if hasFocus && conflictIndex < len(conflicts) && conflicts[conflictIndex] == conflict && shouldHighlightLine(i, conflict, conflictTop) {
|
||||
colour.Add(color.Bold)
|
||||
}
|
||||
if i == conflict.end && len(remainingConflicts) > 0 {
|
||||
conflict, remainingConflicts = shiftConflict(remainingConflicts)
|
||||
}
|
||||
outputBuffer.WriteString(coloredStringDirect(line, colour) + "\n")
|
||||
}
|
||||
return outputBuffer.String(), nil
|
||||
}
|
||||
|
||||
func handleSelectTop(g *gocui.Gui, v *gocui.View) error {
|
||||
state.ConflictTop = true
|
||||
return refreshMergePanel(g)
|
||||
}
|
||||
|
||||
func handleSelectBottom(g *gocui.Gui, v *gocui.View) error {
|
||||
state.ConflictTop = false
|
||||
return refreshMergePanel(g)
|
||||
}
|
||||
|
||||
func handleSelectNextConflict(g *gocui.Gui, v *gocui.View) error {
|
||||
if state.ConflictIndex >= len(state.Conflicts)-1 {
|
||||
return nil
|
||||
}
|
||||
state.ConflictIndex++
|
||||
return refreshMergePanel(g)
|
||||
}
|
||||
|
||||
func handleSelectPrevConflict(g *gocui.Gui, v *gocui.View) error {
|
||||
if state.ConflictIndex <= 0 {
|
||||
return nil
|
||||
}
|
||||
state.ConflictIndex--
|
||||
return refreshMergePanel(g)
|
||||
}
|
||||
|
||||
func isIndexToDelete(i int, conflict conflict, pick string) bool {
|
||||
return i == conflict.middle ||
|
||||
i == conflict.start ||
|
||||
i == conflict.end ||
|
||||
pick != "both" &&
|
||||
(pick == "bottom" && i > conflict.start && i < conflict.middle) ||
|
||||
(pick == "top" && i > conflict.middle && i < conflict.end)
|
||||
}
|
||||
|
||||
func resolveConflict(g *gocui.Gui, conflict conflict, pick string) error {
|
||||
gitFile, err := getSelectedFile(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Open(gitFile.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := bufio.NewReader(file)
|
||||
output := ""
|
||||
for i := 0; true; i++ {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if !isIndexToDelete(i, conflict, pick) {
|
||||
output += line
|
||||
}
|
||||
}
|
||||
devLog(output)
|
||||
return ioutil.WriteFile(gitFile.Name, []byte(output), 0644)
|
||||
}
|
||||
|
||||
func pushFileSnapshot(g *gocui.Gui) error {
|
||||
gitFile, err := getSelectedFile(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content, err := catFile(gitFile.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.EditHistory.Push(content)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handlePopFileSnapshot(g *gocui.Gui, v *gocui.View) error {
|
||||
if state.EditHistory.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
prevContent := state.EditHistory.Pop().(string)
|
||||
gitFile, err := getSelectedFile(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ioutil.WriteFile(gitFile.Name, []byte(prevContent), 0644)
|
||||
return refreshMergePanel(g)
|
||||
}
|
||||
|
||||
func handlePickHunk(g *gocui.Gui, v *gocui.View) error {
|
||||
conflict := state.Conflicts[state.ConflictIndex]
|
||||
pushFileSnapshot(g)
|
||||
pick := "bottom"
|
||||
if state.ConflictTop {
|
||||
pick = "top"
|
||||
}
|
||||
err := resolveConflict(g, conflict, pick)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
refreshMergePanel(g)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handlePickBothHunks(g *gocui.Gui, v *gocui.View) error {
|
||||
conflict := state.Conflicts[state.ConflictIndex]
|
||||
pushFileSnapshot(g)
|
||||
err := resolveConflict(g, conflict, "both")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return refreshMergePanel(g)
|
||||
}
|
||||
|
||||
func currentViewName(g *gocui.Gui) string {
|
||||
currentView := g.CurrentView()
|
||||
return currentView.Name()
|
||||
}
|
||||
|
||||
func refreshMergePanel(g *gocui.Gui) error {
|
||||
cat, err := catSelectedFile(g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state.Conflicts, err = findConflicts(cat)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(state.Conflicts) == 0 {
|
||||
return handleCompleteMerge(g)
|
||||
} else if state.ConflictIndex > len(state.Conflicts)-1 {
|
||||
state.ConflictIndex = len(state.Conflicts) - 1
|
||||
}
|
||||
hasFocus := currentViewName(g) == "main"
|
||||
if hasFocus {
|
||||
renderMergeOptions(g)
|
||||
}
|
||||
content, err := coloredConflictFile(cat, state.Conflicts, state.ConflictIndex, state.ConflictTop, hasFocus)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := scrollToConflict(g); err != nil {
|
||||
return err
|
||||
}
|
||||
return renderString(g, "main", content)
|
||||
}
|
||||
|
||||
func scrollToConflict(g *gocui.Gui) error {
|
||||
mainView, err := g.View("main")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(state.Conflicts) == 0 {
|
||||
return nil
|
||||
}
|
||||
conflict := state.Conflicts[state.ConflictIndex]
|
||||
ox, _ := mainView.Origin()
|
||||
_, height := mainView.Size()
|
||||
conflictMiddle := (conflict.end + conflict.start) / 2
|
||||
newOriginY := int(math.Max(0, float64(conflictMiddle-(height/2))))
|
||||
return mainView.SetOrigin(ox, newOriginY)
|
||||
}
|
||||
|
||||
func switchToMerging(g *gocui.Gui) error {
|
||||
state.ConflictIndex = 0
|
||||
state.ConflictTop = true
|
||||
_, err := g.SetCurrentView("main")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return refreshMergePanel(g)
|
||||
}
|
||||
|
||||
func renderMergeOptions(g *gocui.Gui) error {
|
||||
return renderOptionsMap(g, map[string]string{
|
||||
"↑ ↓": "select hunk",
|
||||
"← →": "navigate conflicts",
|
||||
"space": "pick hunk",
|
||||
"b": "pick both hunks",
|
||||
"z": "undo",
|
||||
})
|
||||
}
|
||||
|
||||
func handleEscapeMerge(g *gocui.Gui, v *gocui.View) error {
|
||||
filesView, err := g.View("files")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refreshFiles(g)
|
||||
return switchFocus(g, v, filesView)
|
||||
}
|
||||
|
||||
func handleCompleteMerge(g *gocui.Gui) error {
|
||||
filesView, err := g.View("files")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stageSelectedFile(g)
|
||||
refreshFiles(g)
|
||||
return switchFocus(g, nil, filesView)
|
||||
}
|
||||
93
pkg/gui/panels/stash_panel.go
Normal file
93
pkg/gui/panels/stash_panel.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jesseduffield/gocui"
|
||||
)
|
||||
|
||||
func refreshStashEntries(g *gocui.Gui) error {
|
||||
g.Update(func(g *gocui.Gui) error {
|
||||
v, err := g.View("stash")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
state.StashEntries = getGitStashEntries()
|
||||
v.Clear()
|
||||
for _, stashEntry := range state.StashEntries {
|
||||
fmt.Fprintln(v, stashEntry.DisplayString)
|
||||
}
|
||||
return resetOrigin(v)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSelectedStashEntry(v *gocui.View) *StashEntry {
|
||||
if len(state.StashEntries) == 0 {
|
||||
return nil
|
||||
}
|
||||
lineNumber := getItemPosition(v)
|
||||
return &state.StashEntries[lineNumber]
|
||||
}
|
||||
|
||||
func renderStashOptions(g *gocui.Gui) error {
|
||||
return renderOptionsMap(g, map[string]string{
|
||||
"space": "apply",
|
||||
"g": "pop",
|
||||
"d": "drop",
|
||||
"← → ↑ ↓": "navigate",
|
||||
})
|
||||
}
|
||||
|
||||
func handleStashEntrySelect(g *gocui.Gui, v *gocui.View) error {
|
||||
if err := renderStashOptions(g); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() {
|
||||
stashEntry := getSelectedStashEntry(v)
|
||||
if stashEntry == nil {
|
||||
renderString(g, "main", "No stash entries")
|
||||
return
|
||||
}
|
||||
diff, _ := getStashEntryDiff(stashEntry.Index)
|
||||
renderString(g, "main", diff)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleStashApply(g *gocui.Gui, v *gocui.View) error {
|
||||
return stashDo(g, v, "apply")
|
||||
}
|
||||
|
||||
func handleStashPop(g *gocui.Gui, v *gocui.View) error {
|
||||
return stashDo(g, v, "pop")
|
||||
}
|
||||
|
||||
func handleStashDrop(g *gocui.Gui, v *gocui.View) error {
|
||||
return createConfirmationPanel(g, v, "Stash drop", "Are you sure you want to drop this stash entry?", func(g *gocui.Gui, v *gocui.View) error {
|
||||
return stashDo(g, v, "drop")
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func stashDo(g *gocui.Gui, v *gocui.View, method string) error {
|
||||
stashEntry := getSelectedStashEntry(v)
|
||||
if stashEntry == nil {
|
||||
return createErrorPanel(g, "No stash to "+method)
|
||||
}
|
||||
if output, err := gitStashDo(stashEntry.Index, method); err != nil {
|
||||
createErrorPanel(g, output)
|
||||
}
|
||||
refreshStashEntries(g)
|
||||
return refreshFiles(g)
|
||||
}
|
||||
|
||||
func handleStashSave(g *gocui.Gui, filesView *gocui.View) error {
|
||||
createPromptPanel(g, filesView, "Stash changes", func(g *gocui.Gui, v *gocui.View) error {
|
||||
if output, err := gitStashSave(trimmedContent(v)); err != nil {
|
||||
createErrorPanel(g, output)
|
||||
}
|
||||
refreshStashEntries(g)
|
||||
return refreshFiles(g)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
41
pkg/gui/panels/status_panel.go
Normal file
41
pkg/gui/panels/status_panel.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/jesseduffield/gocui"
|
||||
)
|
||||
|
||||
func refreshStatus(g *gocui.Gui) error {
|
||||
v, err := g.View("status")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// for some reason if this isn't wrapped in an update the clear seems to
|
||||
// be applied after the other things or something like that; the panel's
|
||||
// contents end up cleared
|
||||
g.Update(func(*gocui.Gui) error {
|
||||
v.Clear()
|
||||
pushables, pullables := gitUpstreamDifferenceCount()
|
||||
fmt.Fprint(v, "↑"+pushables+"↓"+pullables)
|
||||
branches := state.Branches
|
||||
if err := updateHasMergeConflictStatus(); err != nil {
|
||||
return err
|
||||
}
|
||||
if state.HasMergeConflicts {
|
||||
fmt.Fprint(v, coloredString(" (merging)", color.FgYellow))
|
||||
}
|
||||
|
||||
if len(branches) == 0 {
|
||||
return nil
|
||||
}
|
||||
branch := branches[0]
|
||||
name := coloredString(branch.Name, branch.getColor())
|
||||
repo := getCurrentProject()
|
||||
fmt.Fprint(v, " "+repo+" → "+name)
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
239
pkg/gui/view_helpers.go
Normal file
239
pkg/gui/view_helpers.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jesseduffield/gocui"
|
||||
)
|
||||
|
||||
var cyclableViews = []string{"files", "branches", "commits", "stash"}
|
||||
|
||||
func refreshSidePanels(g *gocui.Gui) error {
|
||||
refreshBranches(g)
|
||||
refreshFiles(g)
|
||||
refreshCommits(g)
|
||||
return nil
|
||||
}
|
||||
|
||||
func nextView(g *gocui.Gui, v *gocui.View) error {
|
||||
var focusedViewName string
|
||||
if v == nil || v.Name() == cyclableViews[len(cyclableViews)-1] {
|
||||
focusedViewName = cyclableViews[0]
|
||||
} else {
|
||||
for i := range cyclableViews {
|
||||
if v.Name() == cyclableViews[i] {
|
||||
focusedViewName = cyclableViews[i+1]
|
||||
break
|
||||
}
|
||||
if i == len(cyclableViews)-1 {
|
||||
devLog(v.Name() + " is not in the list of views")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
focusedView, err := g.View(focusedViewName)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return switchFocus(g, v, focusedView)
|
||||
}
|
||||
|
||||
func previousView(g *gocui.Gui, v *gocui.View) error {
|
||||
var focusedViewName string
|
||||
if v == nil || v.Name() == cyclableViews[0] {
|
||||
focusedViewName = cyclableViews[len(cyclableViews)-1]
|
||||
} else {
|
||||
for i := range cyclableViews {
|
||||
if v.Name() == cyclableViews[i] {
|
||||
focusedViewName = cyclableViews[i-1] // TODO: make this work properly
|
||||
break
|
||||
}
|
||||
if i == len(cyclableViews)-1 {
|
||||
devLog(v.Name() + " is not in the list of views")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
focusedView, err := g.View(focusedViewName)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return switchFocus(g, v, focusedView)
|
||||
}
|
||||
|
||||
func newLineFocused(g *gocui.Gui, v *gocui.View) error {
|
||||
mainView, _ := g.View("main")
|
||||
mainView.SetOrigin(0, 0)
|
||||
|
||||
switch v.Name() {
|
||||
case "files":
|
||||
return handleFileSelect(g, v)
|
||||
case "branches":
|
||||
return handleBranchSelect(g, v)
|
||||
case "confirmation":
|
||||
return nil
|
||||
case "commitMessage":
|
||||
return handleCommitFocused(g, v)
|
||||
case "main":
|
||||
// TODO: pull this out into a 'view focused' function
|
||||
refreshMergePanel(g)
|
||||
v.Highlight = false
|
||||
return nil
|
||||
case "commits":
|
||||
return handleCommitSelect(g, v)
|
||||
case "stash":
|
||||
return handleStashEntrySelect(g, v)
|
||||
default:
|
||||
panic("No view matching newLineFocused switch statement")
|
||||
}
|
||||
}
|
||||
|
||||
func returnFocus(g *gocui.Gui, v *gocui.View) error {
|
||||
previousView, err := g.View(state.PreviousView)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return switchFocus(g, v, previousView)
|
||||
}
|
||||
|
||||
// pass in oldView = nil if you don't want to be able to return to your old view
|
||||
func switchFocus(g *gocui.Gui, oldView, newView *gocui.View) error {
|
||||
// we assume we'll never want to return focus to a confirmation panel i.e.
|
||||
// we should never stack confirmation panels
|
||||
if oldView != nil && oldView.Name() != "confirmation" {
|
||||
oldView.Highlight = false
|
||||
devLog("setting previous view to:", oldView.Name())
|
||||
state.PreviousView = oldView.Name()
|
||||
}
|
||||
newView.Highlight = true
|
||||
devLog("new focused view is " + newView.Name())
|
||||
if _, err := g.SetCurrentView(newView.Name()); err != nil {
|
||||
return err
|
||||
}
|
||||
g.Cursor = newView.Editable
|
||||
return newLineFocused(g, newView)
|
||||
}
|
||||
|
||||
func getItemPosition(v *gocui.View) int {
|
||||
_, cy := v.Cursor()
|
||||
_, oy := v.Origin()
|
||||
return oy + cy
|
||||
}
|
||||
|
||||
func cursorUp(g *gocui.Gui, v *gocui.View) error {
|
||||
// swallowing cursor movements in main
|
||||
// TODO: pull this out
|
||||
if v == nil || v.Name() == "main" {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
newLineFocused(g, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cursorDown(g *gocui.Gui, v *gocui.View) error {
|
||||
// swallowing cursor movements in main
|
||||
// TODO: pull this out
|
||||
if v == nil || v.Name() == "main" {
|
||||
return nil
|
||||
}
|
||||
cx, cy := v.Cursor()
|
||||
ox, oy := v.Origin()
|
||||
if cy+oy >= len(v.BufferLines())-2 {
|
||||
return nil
|
||||
}
|
||||
if err := v.SetCursor(cx, cy+1); err != nil {
|
||||
if err := v.SetOrigin(ox, oy+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
newLineFocused(g, v)
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetOrigin(v *gocui.View) error {
|
||||
if err := v.SetCursor(0, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
return v.SetOrigin(0, 0)
|
||||
}
|
||||
|
||||
// if the cursor down past the last item, move it up one
|
||||
func correctCursor(v *gocui.View) error {
|
||||
cx, cy := v.Cursor()
|
||||
_, oy := v.Origin()
|
||||
lineCount := len(v.BufferLines()) - 2
|
||||
if cy >= lineCount-oy {
|
||||
return v.SetCursor(cx, lineCount-oy)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderString(g *gocui.Gui, viewName, s string) error {
|
||||
g.Update(func(*gocui.Gui) error {
|
||||
v, err := g.View(viewName)
|
||||
// just in case the view disappeared as this function was called, we'll
|
||||
// silently return if it's not found
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
v.Clear()
|
||||
fmt.Fprint(v, s)
|
||||
v.Wrap = true
|
||||
return nil
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func optionsMapToString(optionsMap map[string]string) string {
|
||||
optionsArray := make([]string, 0)
|
||||
for key, description := range optionsMap {
|
||||
optionsArray = append(optionsArray, key+": "+description)
|
||||
}
|
||||
sort.Strings(optionsArray)
|
||||
return strings.Join(optionsArray, ", ")
|
||||
}
|
||||
|
||||
func renderOptionsMap(g *gocui.Gui, optionsMap map[string]string) error {
|
||||
return renderString(g, "options", optionsMapToString(optionsMap))
|
||||
}
|
||||
|
||||
func loader() string {
|
||||
characters := "|/-\\"
|
||||
now := time.Now()
|
||||
nanos := now.UnixNano()
|
||||
index := nanos / 50000000 % int64(len(characters))
|
||||
return characters[index : index+1]
|
||||
}
|
||||
|
||||
// TODO: refactor properly
|
||||
func getFilesView(g *gocui.Gui) *gocui.View {
|
||||
v, _ := g.View("files")
|
||||
return v
|
||||
}
|
||||
|
||||
func getCommitsView(g *gocui.Gui) *gocui.View {
|
||||
v, _ := g.View("commits")
|
||||
return v
|
||||
}
|
||||
|
||||
func getCommitMessageView(g *gocui.Gui) *gocui.View {
|
||||
v, _ := g.View("commitMessage")
|
||||
return v
|
||||
}
|
||||
|
||||
func trimmedContent(v *gocui.View) string {
|
||||
return strings.TrimSpace(v.Buffer())
|
||||
}
|
||||
49
pkg/utils/utils.go
Normal file
49
pkg/utils/utils.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
func splitLines(multilineString string) []string {
|
||||
multilineString = strings.Replace(multilineString, "\r", "", -1)
|
||||
if multilineString == "" || multilineString == "\n" {
|
||||
return make([]string, 0)
|
||||
}
|
||||
lines := strings.Split(multilineString, "\n")
|
||||
if lines[len(lines)-1] == "" {
|
||||
return lines[:len(lines)-1]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func withPadding(str string, padding int) string {
|
||||
if padding-len(str) < 0 {
|
||||
return str
|
||||
}
|
||||
return str + strings.Repeat(" ", padding-len(str))
|
||||
}
|
||||
|
||||
func coloredString(str string, colorAttribute color.Attribute) string {
|
||||
colour := color.New(colorAttribute)
|
||||
return coloredStringDirect(str, colour)
|
||||
}
|
||||
|
||||
// used for aggregating a few color attributes rather than just sending a single one
|
||||
func coloredStringDirect(str string, colour *color.Color) string {
|
||||
return colour.SprintFunc()(fmt.Sprint(str))
|
||||
}
|
||||
|
||||
// used to get the project name
|
||||
func getCurrentProject() string {
|
||||
pwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatalln(err.Error())
|
||||
}
|
||||
return filepath.Base(pwd)
|
||||
}
|
||||
Reference in New Issue
Block a user