diff --git a/.gitignore b/.gitignore index dac0b46b6..3d59e16c5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,4 @@ extra/lgit.rb notes/go.notes TODO.notes TODO.md -test/testrepo/ -test/repos/repo \ No newline at end of file +test/repos/repo diff --git a/Gopkg.lock b/Gopkg.lock index af729a2cb..7b167c7d8 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -1,6 +1,14 @@ # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. +[[projects]] + digest = "1:b2339e83ce9b5c4f79405f949429a7f68a9a904fed903c672aac1e7ceb7f5f02" + name = "github.com/Sirupsen/logrus" + packages = ["."] + pruneopts = "NUT" + revision = "3e01752db0189b9157070a0e1668a620f9a85da2" + version = "v1.0.6" + [[projects]] digest = "1:a2c1d0e43bd3baaa071d1b9ed72c27d78169b2b269f71c105ac4ba34b1be4a39" name = "github.com/davecgh/go-spew" @@ -50,11 +58,11 @@ [[projects]] branch = "master" - digest = "1:e9b2b07a20f19d886267876b72ba15f2cbdeeeadd18030a4ce174b864e97c39e" + digest = "1:c9a848b0484a72da2dae28957b4f67501fe27fa38bc73f4713e454353c0a4a60" name = "github.com/jesseduffield/gocui" packages = ["."] pruneopts = "NUT" - revision = "8cecad864fb0b099a5f55bf1c97fbc1daca103e0" + revision = "432b7f6215f81ef1aaa1b2d9b69887822923cf79" [[projects]] digest = "1:8021af4dcbd531ae89433c8c3a6520e51064114aaf8eb1724c3cf911c497c9ba" @@ -88,6 +96,14 @@ revision = "9e777a8366cce605130a531d2cd6363d07ad7317" version = "v0.0.2" +[[projects]] + digest = "1:a25c9a6b41e100f4ce164db80260f2b687095ba9d8b46a1d6072d3686cc020db" + name = "github.com/mgutz/str" + packages = ["."] + pruneopts = "NUT" + revision = "968bf66e3da857419e4f6e71b2d5c9ae95682dc4" + version = "v1.2.0" + [[projects]] branch = "master" digest = "1:a4df73029d2c42fabcb6b41e327d2f87e685284ec03edf76921c267d9cfc9c23" @@ -151,7 +167,7 @@ [[projects]] branch = "master" - digest = "1:c76f8b24a4d9b99b502fb7b61ad769125075cb570efff9b9b73e6c428629532d" + digest = "1:dfcb1b2db354cafa48fc3cdafe4905a08bec4a9757919ab07155db0ca23855b4" name = "golang.org/x/crypto" packages = [ "cast5", @@ -170,6 +186,7 @@ "ssh", "ssh/agent", "ssh/knownhosts", + "ssh/terminal", ] pruneopts = "NUT" revision = "de0752318171da717af4ce24d0a2e8626afaeb11" @@ -282,10 +299,12 @@ analyzer-name = "dep" analyzer-version = 1 input-imports = [ + "github.com/Sirupsen/logrus", "github.com/davecgh/go-spew/spew", "github.com/fatih/color", "github.com/golang-collections/collections/stack", "github.com/jesseduffield/gocui", + "github.com/mgutz/str", "github.com/tcnksm/go-gitconfig", "gopkg.in/src-d/go-git.v4", "gopkg.in/src-d/go-git.v4/plumbing", diff --git a/Gopkg.toml b/Gopkg.toml index 16fd76083..a47fe5e93 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -39,4 +39,4 @@ [[constraint]] name = "gopkg.in/src-d/go-git.v4" - revision = "43d17e14b714665ab5bc2ecc220b6740779d733f" \ No newline at end of file + revision = "43d17e14b714665ab5bc2ecc220b6740779d733f" diff --git a/VERSION b/VERSION index 388e9d45b..bcb7cc884 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.1.55 \ No newline at end of file +v0.1.58 \ No newline at end of file diff --git a/branches_panel.go b/branches_panel.go deleted file mode 100644 index a73d28bb3..000000000 --- a/branches_panel.go +++ /dev/null @@ -1,135 +0,0 @@ -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 -} diff --git a/commit_message_panel.go b/commit_message_panel.go deleted file mode 100644 index baef870cf..000000000 --- a/commit_message_panel.go +++ /dev/null @@ -1,45 +0,0 @@ -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") -} diff --git a/commits_panel.go b/commits_panel.go deleted file mode 100644 index 1a9976ffe..000000000 --- a/commits_panel.go +++ /dev/null @@ -1,176 +0,0 @@ -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 -} diff --git a/docs/Keybindings.md b/docs/Keybindings.md index 8ef749277..2b9f9d952 100644 --- a/docs/Keybindings.md +++ b/docs/Keybindings.md @@ -2,11 +2,12 @@ ## Global:
- ←→↑↓/hjkl: navigate - PgUp/PgDn: scroll diff panel (use fn+up/fn+down on osx) - q: quit - p: pull - shift+P: push + ←→↑↓/hjkl: navigate + PgUp/PgDn or ctrl+u/ctrl+d: scroll diff panel + (for PgUp and PgDn, use fn+up/fn+down on osx) + q: quit + p: pull + shift+P: push## Files Panel: diff --git a/files_panel.go b/files_panel.go deleted file mode 100644 index 0957f6f71..000000000 --- a/files_panel.go +++ /dev/null @@ -1,362 +0,0 @@ -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{ - "← → ↑ ↓": ShortLocalize("navigate", "navigate"), - "S": ShortLocalize("stashFiles", "stash files"), - "c": ShortLocalize("commitChanges", "commit changes"), - "o": ShortLocalize("open", "open"), - "i": ShortLocalize("ignore", "ignore"), - "d": ShortLocalize("delete", "delete"), - "space": ShortLocalize("toggleStaged", "toggle staged"), - "R": ShortLocalize("refresh", "refresh"), - "t": ShortLocalize("addPatch", "add patch"), - "e": ShortLocalize("edit", "edit"), - "PgUp/PgDn": ShortLocalize("scroll", "scroll"), - } - if state.HasMergeConflicts { - optionsMap["a"] = ShortLocalize("abortMerge", "abort merge") - optionsMap["m"] = ShortLocalize("resolveMergeConflicts", "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) -} diff --git a/gitcommands.go b/gitcommands.go deleted file mode 100644 index c65841e99..000000000 --- a/gitcommands.go +++ /dev/null @@ -1,529 +0,0 @@ -package main - -import ( - - // "log" - "errors" - "fmt" - "os" - "os/exec" - "strings" - - "github.com/jesseduffield/gocui" - gitconfig "github.com/tcnksm/go-gitconfig" - git "gopkg.in/src-d/go-git.v4" -) - -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") -) - -// GitFile : A staged/unstaged file -// TODO: decide whether to give all of these the Git prefix -type GitFile 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 -} - -// 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 - } - return runDirectCommand("git commit -m \"" + message + "\"") -} - -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}) -} diff --git a/main.go b/main.go index 28ecb9ec6..c7fec1ae1 100644 --- a/main.go +++ b/main.go @@ -1,36 +1,25 @@ package main import ( - "errors" "flag" "fmt" "io/ioutil" "log" "os" - "os/exec" "os/user" "path/filepath" - "github.com/davecgh/go-spew/spew" - - "github.com/jesseduffield/gocui" - git "gopkg.in/src-d/go-git.v4" + "github.com/jesseduffield/lazygit/pkg/app" + "github.com/jesseduffield/lazygit/pkg/config" ) -// ErrSubProcess is raised when we are running a subprocess var ( - ErrSubprocess = errors.New("running subprocess") - subprocess *exec.Cmd - commit string version = "unversioned" + date string - date string debuggingFlag = flag.Bool("debug", false, "a boolean") versionFlag = flag.Bool("v", false, "Print the current version") - - w *git.Worktree - r *git.Repository ) func homeDirectory() string { @@ -46,46 +35,6 @@ func projectPath(path string) string { return filepath.FromSlash(gopath + "/src/github.com/jesseduffield/lazygit/" + path) } -func devLog(objects ...interface{}) { - localLog("development.log", objects...) -} - -func objectLog(object interface{}) { - if !*debuggingFlag { - return - } - str := spew.Sdump(object) - localLog("development.log", str) -} - -func commandLog(objects ...interface{}) { - localLog("commands.log", objects...) -} - -func localLog(path string, objects ...interface{}) { - if !*debuggingFlag { - return - } - f, err := os.OpenFile(projectPath(path), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) - if err != nil { - panic(err.Error()) - } - defer f.Close() - log.SetOutput(f) - for _, object := range objects { - log.Println(fmt.Sprint(object)) - } -} - -func navigateToRepoRootDirectory() { - _, err := os.Stat(".git") - for os.IsNotExist(err) { - devLog("going up a directory to find the root") - os.Chdir("..") - _, err = os.Stat(".git") - } -} - // when building the binary, `version` is set as a compile-time variable, along // with `date` and `commit`. If this program has been opened directly via go, // we will populate the `version` with VERSION in the lazygit root directory @@ -98,21 +47,7 @@ func fallbackVersion() string { return string(byteVersion) } -func setupWorktree() { - var err error - r, err = git.PlainOpen(".") - if err != nil { - panic(err) - } - - w, err = r.Worktree() - if err != nil { - panic(err) - } -} - func main() { - devLog("\n\n\n\n\n\n\n\n\n\n") flag.Parse() if version == "unversioned" { version = fallbackVersion() @@ -121,18 +56,15 @@ func main() { fmt.Printf("commit=%s, build date=%s, version=%s\n", commit, date, version) os.Exit(0) } - verifyInGitRepo() - navigateToRepoRootDirectory() - setupWorktree() - for { - if err := run(); err != nil { - if err == gocui.ErrQuit { - break - } else if err == ErrSubprocess { - subprocess.Run() - } else { - log.Panicln(err) - } - } + appConfig := &config.AppConfig{ + Name: "lazygit", + Version: version, + Commit: commit, + BuildDate: date, + Debug: *debuggingFlag, } + app, err := app.NewApp(appConfig) + app.Log.Info(err) + app.GitCommand.SetupGit() + app.Gui.RunWithSubprocesses() } diff --git a/merge_panel.go b/merge_panel.go deleted file mode 100644 index f5ca12a23..000000000 --- a/merge_panel.go +++ /dev/null @@ -1,263 +0,0 @@ -// 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) -} diff --git a/pkg/app/app.go b/pkg/app/app.go new file mode 100644 index 000000000..d558ed250 --- /dev/null +++ b/pkg/app/app.go @@ -0,0 +1,71 @@ +package app + +import ( + "io" + "io/ioutil" + "os" + + "github.com/Sirupsen/logrus" + "github.com/jesseduffield/lazygit/pkg/commands" + "github.com/jesseduffield/lazygit/pkg/config" + "github.com/jesseduffield/lazygit/pkg/gui" +) + +// App struct +type App struct { + closers []io.Closer + + Config config.AppConfigurer + Log *logrus.Logger + OSCommand *commands.OSCommand + GitCommand *commands.GitCommand + Gui *gui.Gui +} + +func newLogger(config config.AppConfigurer) *logrus.Logger { + log := logrus.New() + if !config.GetDebug() { + log.Out = ioutil.Discard + return log + } + file, err := os.OpenFile("development.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + panic("unable to log to file") // TODO: don't panic (also, remove this call to the `panic` function) + } + log.SetOutput(file) + return log +} + +// NewApp retruns a new applications +func NewApp(config config.AppConfigurer) (*App, error) { + app := &App{ + closers: []io.Closer{}, + Config: config, + } + var err error + app.Log = newLogger(config) + 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 + } + app.Gui, err = gui.NewGui(app.Log, app.GitCommand, app.OSCommand, config.GetVersion()) + 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 +} diff --git a/branch.go b/pkg/commands/branch.go similarity index 56% rename from branch.go rename to pkg/commands/branch.go index 78c2e55aa..13c26e766 100644 --- a/branch.go +++ b/pkg/commands/branch.go @@ -1,22 +1,26 @@ -package main +package commands import ( "strings" "github.com/fatih/color" + "github.com/jesseduffield/lazygit/pkg/utils" ) // Branch : A git branch +// duplicating this for now type Branch struct { Name string Recency string } -func (b *Branch) getDisplayString() string { - return withPadding(b.Recency, 4) + coloredString(b.Name, b.getColor()) +// GetDisplayString returns the dispaly string of branch +func (b *Branch) GetDisplayString() string { + return utils.WithPadding(b.Recency, 4) + utils.ColoredString(b.Name, b.GetColor()) } -func (b *Branch) getColor() color.Attribute { +// GetColor branch color +func (b *Branch) GetColor() color.Attribute { switch b.getType() { case "feature": return color.FgGreen diff --git a/pkg/commands/git.go b/pkg/commands/git.go new file mode 100644 index 000000000..44fd57f1c --- /dev/null +++ b/pkg/commands/git.go @@ -0,0 +1,497 @@ +package commands + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/Sirupsen/logrus" + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazygit/pkg/utils" + gitconfig "github.com/tcnksm/go-gitconfig" + gogit "gopkg.in/src-d/go-git.v4" +) + +// GitCommand is our main git interface +type GitCommand struct { + Log *logrus.Logger + OSCommand *OSCommand + Worktree *gogit.Worktree + Repo *gogit.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() +} + +// GetStashEntries stash entryies +func (c *GitCommand) GetStashEntries() []StashEntry { + stashEntries := make([]StashEntry, 0) + rawString, _ := c.OSCommand.RunCommandWithOutput("git stash list --pretty='%gs'") + for i, line := range utils.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, + } +} + +// GetStashEntryDiff stash diff +func (c *GitCommand) GetStashEntryDiff(index int) (string, error) { + return c.OSCommand.RunCommandWithOutput("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 +} + +// GetStatusFiles git status files +func (c *GitCommand) GetStatusFiles() []File { + statusOutput, _ := c.GitStatus() + statusStrings := utils.SplitLines(statusOutput) + files := make([]File, 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) + file := File{ + Name: filename, + DisplayString: statusString, + HasStagedChanges: !includes([]string{" ", "U", "?"}, stagedChange), + HasUnstagedChanges: unstagedChange != " ", + Tracked: tracked, + Deleted: unstagedChange == "D" || stagedChange == "D", + HasMergeConflicts: change == "UU", + } + files = append(files, file) + } + c.Log.Info(files) // TODO: use a dumper-esque log here + return files +} + +// StashDo modify stash +func (c *GitCommand) StashDo(index int, method string) error { + return c.OSCommand.RunCommand("git stash " + method + " stash@{" + fmt.Sprint(index) + "}") +} + +// StashSave save stash +// TODO: before calling this, check if there is anything to save +func (c *GitCommand) StashSave(message string) error { + return c.OSCommand.RunCommand("git stash save " + c.OSCommand.Quote(message)) +} + +// MergeStatusFiles merge status files +func (c *GitCommand) MergeStatusFiles(oldFiles, newFiles []File) []File { + if len(oldFiles) == 0 { + return newFiles + } + + appendedIndexes := make([]int, 0) + + // retain position of files we already could see + result := make([]File, 0) + for _, oldFile := range oldFiles { + for newIndex, newFile := range newFiles { + if oldFile.Name == newFile.Name { + result = append(result, newFile) + appendedIndexes = append(appendedIndexes, newIndex) + break + } + } + } + + // append any new files to the end + for index, newFile := range newFiles { + if !includesInt(appendedIndexes, index) { + result = append(result, newFile) + } + } + + return result +} + +func (c *GitCommand) verifyInGitRepo() { + if output, err := c.OSCommand.RunCommandWithOutput("git status"); err != nil { + fmt.Println(output) + os.Exit(1) + } +} + +// GetBranchName branch name +func (c *GitCommand) GetBranchName() (string, error) { + return c.OSCommand.RunCommandWithOutput("git symbolic-ref --short HEAD") +} + +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() { + r, err := gogit.PlainOpen(".") + if err != nil { + panic(err) + } + c.Repo = r + + w, err := r.Worktree() + if err != nil { + panic(err) + } + c.Worktree = w +} + +// ResetHard does the equivalent of `git reset --hard HEAD` +func (c *GitCommand) ResetHard() error { + return c.Worktree.Reset(&gogit.ResetOptions{Mode: gogit.HardReset}) +} + +// UpstreamDifferenceCount checks how many pushables/pullables there are for the +// current branch +func (c *GitCommand) UpstreamDifferenceCount() (string, string) { + pushableCount, err := c.OSCommand.RunCommandWithOutput("git rev-list @{u}..head --count") + if err != nil { + return "?", "?" + } + pullableCount, err := c.OSCommand.RunCommandWithOutput("git rev-list head..@{u} --count") + if err != nil { + return "?", "?" + } + return strings.TrimSpace(pushableCount), strings.TrimSpace(pullableCount) +} + +// GetCommitsToPush Returns the sha's of the commits that have not yet been pushed +// to the remote branch of the current branch +func (c *GitCommand) GetCommitsToPush() []string { + pushables, err := c.OSCommand.RunCommandWithOutput("git rev-list @{u}..head --abbrev-commit") + if err != nil { + return make([]string, 0) + } + return utils.SplitLines(pushables) +} + +// RenameCommit renames the topmost commit with the given name +func (c *GitCommand) RenameCommit(name string) error { + return c.OSCommand.RunCommand("git commit --allow-empty --amend -m " + c.OSCommand.Quote(name)) +} + +// Fetch fetch git repo +func (c *GitCommand) Fetch() error { + return c.OSCommand.RunCommand("git fetch") +} + +// ResetToCommit reset to commit +func (c *GitCommand) ResetToCommit(sha string) error { + return c.OSCommand.RunCommand("git reset " + sha) +} + +// NewBranch create new branch +func (c *GitCommand) NewBranch(name string) error { + return c.OSCommand.RunCommand("git checkout -b " + name) +} + +// DeleteBranch delete branch +func (c *GitCommand) DeleteBranch(branch string) error { + return c.OSCommand.RunCommand("git branch -d " + branch) +} + +// ListStash list stash +func (c *GitCommand) ListStash() (string, error) { + return c.OSCommand.RunCommandWithOutput("git stash list") +} + +// Merge merge +func (c *GitCommand) Merge(branchName string) error { + return c.OSCommand.RunCommand("git merge --no-edit " + branchName) +} + +// AbortMerge abort merge +func (c *GitCommand) AbortMerge() error { + return c.OSCommand.RunCommand("git merge --abort") +} + +// UsingGpg tells us whether the user has gpg enabled so that we can know +// whether we need to run a subprocess to allow them to enter their password +func (c *GitCommand) UsingGpg() bool { + gpgsign, _ := gitconfig.Global("commit.gpgsign") + if gpgsign == "" { + gpgsign, _ = gitconfig.Local("commit.gpgsign") + } + if gpgsign == "" { + return false + } + return true +} + +// Commit commit to git +func (c *GitCommand) Commit(g *gocui.Gui, message string) (*exec.Cmd, error) { + command := "git commit -m " + c.OSCommand.Quote(message) + if c.UsingGpg() { + return c.OSCommand.PrepareSubProcess(c.OSCommand.Platform.shell, c.OSCommand.Platform.shellArg, command) + } + return nil, c.OSCommand.RunCommand(command) +} + +// Pull pull from repo +func (c *GitCommand) Pull() error { + return c.OSCommand.RunCommand("git pull --no-edit") +} + +// Push push to a branch +func (c *GitCommand) Push(branchName string) error { + return c.OSCommand.RunCommand("git push -u origin " + branchName) +} + +// SquashPreviousTwoCommits squashes a commit down to the one below it +// retaining the message of the higher commit +func (c *GitCommand) SquashPreviousTwoCommits(message string) error { + // TODO: test this + err := c.OSCommand.RunCommand("git reset --soft HEAD^") + if err != nil { + return err + } + // TODO: if password is required, we need to return a subprocess + return c.OSCommand.RunCommand("git commit --amend -m " + c.OSCommand.Quote(message)) +} + +// SquashFixupCommit squashes a 'FIXUP' commit into the commit beneath it, +// retaining the commit message of the lower commit +func (c *GitCommand) SquashFixupCommit(branchName string, shaValue 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 { + c.Log.Info(command) + output, err := c.OSCommand.RunCommandWithOutput(command) + ret += output + if err != nil { + c.Log.Info(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 + output, _ := c.OSCommand.RunCommandWithOutput("git branch -d " + shaValue) + ret += output + output, _ = c.OSCommand.RunCommandWithOutput("git checkout " + branchName) + ret += output + } + if err != nil { + return errors.New(ret) + } + return nil +} + +// CatFile obtain the contents of a file +func (c *GitCommand) CatFile(file string) (string, error) { + return c.OSCommand.RunCommandWithOutput("cat " + file) +} + +// StageFile stages a file +func (c *GitCommand) StageFile(file string) error { + return c.OSCommand.RunCommand("git add " + c.OSCommand.Quote(file)) +} + +// UnStageFile unstages a file +func (c *GitCommand) UnStageFile(file string, tracked bool) error { + var command string + if tracked { + command = "git reset HEAD " + } else { + command = "git rm --cached " + } + return c.OSCommand.RunCommand(command + file) +} + +// GitStatus returns the plaintext short status of the repo +func (c *GitCommand) GitStatus() (string, error) { + return c.OSCommand.RunCommandWithOutput("git status --untracked-files=all --short") +} + +// IsInMergeState states whether we are still mid-merge +func (c *GitCommand) IsInMergeState() (bool, error) { + output, err := c.OSCommand.RunCommandWithOutput("git status --untracked-files=all") + if err != nil { + return false, err + } + return strings.Contains(output, "conclude merge") || strings.Contains(output, "unmerged paths"), nil +} + +// RemoveFile directly +func (c *GitCommand) RemoveFile(file File) error { + // if the file isn't tracked, we assume you want to delete it + if !file.Tracked { + return c.OSCommand.RunCommand("rm -rf ./" + file.Name) + } + // if the file is tracked, we assume you want to just check it out + return c.OSCommand.RunCommand("git checkout " + file.Name) +} + +// Checkout checks out a branch, with --force if you set the force arg to true +func (c *GitCommand) Checkout(branch string, force bool) error { + forceArg := "" + if force { + forceArg = "--force " + } + return c.OSCommand.RunCommand("git checkout " + forceArg + branch) +} + +// AddPatch prepares a subprocess for adding a patch by patch +// this will eventually be swapped out for a better solution inside the Gui +func (c *GitCommand) AddPatch(filename string) (*exec.Cmd, error) { + return c.OSCommand.PrepareSubProcess("git", "add", "--patch", filename) +} + +// PrepareCommitSubProcess prepares a subprocess for `git commit` +func (c *GitCommand) PrepareCommitSubProcess() (*exec.Cmd, error) { + return c.OSCommand.PrepareSubProcess("git", "commit") +} + +// GetBranchGraph gets the color-formatted graph of the log for the given branch +// Currently it limits the result to 100 commits, but when we get async stuff +// working we can do lazy loading +func (c *GitCommand) GetBranchGraph(branchName string) (string, error) { + return c.OSCommand.RunCommandWithOutput("git log --graph --color --abbrev-commit --decorate --date=relative --pretty=medium -100 " + branchName) +} + +// 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 +} + +// GetCommits obtains the commits of the current branch +func (c *GitCommand) GetCommits() []Commit { + pushables := c.GetCommitsToPush() + log := c.GetLog() + commits := make([]Commit, 0) + // now we can split it up and turn it into commits + lines := utils.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 +} + +// GetLog gets the git log (currently limited to 30 commits for performance +// until we work out lazy loading +func (c *GitCommand) GetLog() string { + // currently limiting to 30 for performance reasons + // TODO: add lazyloading when you scroll down + result, err := c.OSCommand.RunCommandWithOutput("git log --oneline -30") + if err != nil { + // assume if there is an error there are no commits yet for this branch + return "" + } + return result +} + +// Ignore adds a file to the gitignore for the repo +func (c *GitCommand) Ignore(filename string) { + if _, err := c.OSCommand.RunDirectCommand("echo '" + filename + "' >> .gitignore"); err != nil { + panic(err) + } +} + +// Show shows the diff of a commit +func (c *GitCommand) Show(sha string) string { + result, err := c.OSCommand.RunCommandWithOutput("git show --color " + sha) + if err != nil { + panic(err) + } + return result +} + +// Diff returns the diff of a file +func (c *GitCommand) Diff(file File) string { + cachedArg := "" + fileName := file.Name + if file.HasStagedChanges && !file.HasUnstagedChanges { + cachedArg = "--cached" + } else { + // if the file is staged and has spaces in it, it comes pre-quoted + fileName = c.OSCommand.Quote(fileName) + } + deletedArg := "" + if file.Deleted { + deletedArg = "--" + } + trackedArg := "" + if !file.Tracked && !file.HasStagedChanges { + trackedArg = "--no-index /dev/null" + } + command := fmt.Sprintf("%s %s %s %s %s", "git diff --color ", cachedArg, deletedArg, trackedArg, fileName) + + // for now we assume an error means the file was deleted + s, _ := c.OSCommand.RunCommandWithOutput(command) + return s +} diff --git a/pkg/commands/git_structs.go b/pkg/commands/git_structs.go new file mode 100644 index 000000000..6b10b18bb --- /dev/null +++ b/pkg/commands/git_structs.go @@ -0,0 +1,36 @@ +package commands + +// 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 +} + +// Conflict : A git conflict with a start middle and end corresponding to line +// numbers in the file where the conflict bars appear +type Conflict struct { + Start int + Middle int + End int +} diff --git a/pkg/commands/os.go b/pkg/commands/os.go new file mode 100644 index 000000000..9f9819a5a --- /dev/null +++ b/pkg/commands/os.go @@ -0,0 +1,174 @@ +package commands + +import ( + "errors" + "os" + "os/exec" + "runtime" + + "github.com/davecgh/go-spew/spew" + + "github.com/mgutz/str" + + "github.com/Sirupsen/logrus" + gitconfig "github.com/tcnksm/go-gitconfig" +) + +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") + // ErrNoEditorDefined : When we can't find an editor to edit a file + ErrNoEditorDefined = errors.New("No editor defined in $VISUAL, $EDITOR, or git config") +) + +// 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 +} + +// RunCommandWithOutput wrapper around commands returning their output and error +func (c *OSCommand) RunCommandWithOutput(command string) (string, error) { + c.Log.WithField("command", command).Info("RunCommand") + splitCmd := str.ToArgv(command) + c.Log.Info(splitCmd) + cmdOut, err := exec.Command(splitCmd[0], splitCmd[1:]...).CombinedOutput() + return sanitisedCommandOutput(cmdOut, err) +} + +// RunCommand runs a command and just returns the error +func (c *OSCommand) RunCommand(command string) error { + _, err := c.RunCommandWithOutput(command) + return err +} + +// RunDirectCommand wrapper around direct commands +func (c *OSCommand) RunDirectCommand(command string) (string, error) { + c.Log.WithField("command", command).Info("RunDirectCommand") + args := str.ToArgv(c.Platform.shellArg + " " + command) + c.Log.Info(spew.Sdump(args)) + + cmdOut, err := exec. + Command(c.Platform.shell, args...). + CombinedOutput() + return sanitisedCommandOutput(cmdOut, err) +} + +func sanitisedCommandOutput(output []byte, err error) (string, error) { + outputString := string(output) + if err != nil { + // errors like 'exit status 1' are not very useful so we'll create an error + // from the combined output + return outputString, errors.New(outputString) + } + return outputString, nil +} + +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: "\"", + } + } +} + +// GetOpenCommand get open command +func (c *OSCommand) 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 err := c.RunCommand("which " + name); err == nil { + return name, trail, nil + } + } + return "", "", ErrNoOpenCommand +} + +// VsCodeOpenFile opens the file in code, with the -r flag to open in the +// current window +// each of these open files needs to have the same function signature because +// they're being passed as arguments into another function, +// but only editFile actually returns a *exec.Cmd +func (c *OSCommand) VsCodeOpenFile(filename string) (*exec.Cmd, error) { + return nil, c.RunCommand("code -r " + filename) +} + +// SublimeOpenFile opens the filein sublime +// may be deprecated in the future +func (c *OSCommand) SublimeOpenFile(filename string) (*exec.Cmd, error) { + return nil, c.RunCommand("subl " + filename) +} + +// OpenFile opens a file with the given +func (c *OSCommand) OpenFile(filename string) (*exec.Cmd, error) { + cmdName, cmdTrail, err := c.GetOpenCommand() + if err != nil { + return nil, err + } + err = c.RunCommand(cmdName + " " + filename + cmdTrail) // TODO: test on linux + return nil, err +} + +// EditFile opens a file in a subprocess using whatever editor is available, +// falling back to core.editor, VISUAL, EDITOR, then vi +func (c *OSCommand) EditFile(filename string) (*exec.Cmd, error) { + editor, _ := gitconfig.Global("core.editor") + if editor == "" { + editor = os.Getenv("VISUAL") + } + if editor == "" { + editor = os.Getenv("EDITOR") + } + if editor == "" { + if err := c.RunCommand("which vi"); err == nil { + editor = "vi" + } + } + if editor == "" { + return nil, ErrNoEditorDefined + } + return c.PrepareSubProcess(editor, filename) +} + +// PrepareSubProcess iniPrepareSubProcessrocess then tells the Gui to switch to it +func (c *OSCommand) PrepareSubProcess(cmdName string, commandArgs ...string) (*exec.Cmd, error) { + subprocess := exec.Command(cmdName, commandArgs...) + return subprocess, nil +} + +// Quote wraps a message in platform-specific quotation marks +func (c *OSCommand) Quote(message string) string { + return c.Platform.escapedQuote + message + c.Platform.escapedQuote +} diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go new file mode 100644 index 000000000..98e56dea2 --- /dev/null +++ b/pkg/config/app_config.go @@ -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 +} diff --git a/branch_list_builder.go b/pkg/git/branch_list_builder.go similarity index 54% rename from branch_list_builder.go rename to pkg/git/branch_list_builder.go index 1d4dc338d..41e59c093 100644 --- a/branch_list_builder.go +++ b/pkg/git/branch_list_builder.go @@ -1,9 +1,14 @@ -package main +package git import ( "regexp" "strings" + "github.com/jesseduffield/lazygit/pkg/commands" + "github.com/jesseduffield/lazygit/pkg/utils" + + "github.com/Sirupsen/logrus" + "gopkg.in/src-d/go-git.v4/plumbing" ) @@ -15,53 +20,64 @@ import ( // our safe branches, then add the remaining safe branches, ensuring uniqueness // along the way -type branchListBuilder struct{} - -func newBranchListBuilder() *branchListBuilder { - return &branchListBuilder{} +// BranchListBuilder returns a list of Branch objects for the current repo +type BranchListBuilder struct { + Log *logrus.Logger + GitCommand *commands.GitCommand } -func (b *branchListBuilder) obtainCurrentBranch() Branch { +// NewBranchListBuilder builds a new branch list builder +func NewBranchListBuilder(log *logrus.Logger, gitCommand *commands.GitCommand) (*BranchListBuilder, error) { + return &BranchListBuilder{ + Log: log, + GitCommand: gitCommand, + }, nil +} + +func (b *BranchListBuilder) obtainCurrentBranch() commands.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: " *"} + branchName, err := b.GitCommand.OSCommand.RunCommandWithOutput("git symbolic-ref --short HEAD") + if err != nil { + panic(err.Error()) + } + return commands.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") +func (b *BranchListBuilder) obtainReflogBranches() []commands.Branch { + branches := make([]commands.Branch, 0) + rawString, err := b.GitCommand.OSCommand.RunCommandWithOutput("git reflog -n100 --pretty='%cr|%gs' --grep-reflog='checkout: moving' HEAD") if err != nil { return branches } - branchLines := splitLines(rawString) + branchLines := utils.SplitLines(rawString) for _, line := range branchLines { timeNumber, timeUnit, branchName := branchInfoFromLine(line) timeUnit = abbreviatedTimeUnit(timeUnit) - branch := Branch{Name: branchName, Recency: timeNumber + timeUnit} + branch := commands.Branch{Name: branchName, Recency: timeNumber + timeUnit} branches = append(branches, branch) } return branches } -func (b *branchListBuilder) obtainSafeBranches() []Branch { - branches := make([]Branch, 0) +func (b *BranchListBuilder) obtainSafeBranches() []commands.Branch { + branches := make([]commands.Branch, 0) - bIter, err := r.Branches() + bIter, err := b.GitCommand.Repo.Branches() if err != nil { panic(err) } err = bIter.ForEach(func(b *plumbing.Reference) error { name := b.Name().Short() - branches = append(branches, Branch{Name: name}) + branches = append(branches, commands.Branch{Name: name}) return nil }) return branches } -func (b *branchListBuilder) appendNewBranches(finalBranches, newBranches, existingBranches []Branch, included bool) []Branch { +func (b *BranchListBuilder) appendNewBranches(finalBranches, newBranches, existingBranches []commands.Branch, included bool) []commands.Branch { for _, newBranch := range newBranches { if included == branchIncluded(newBranch.Name, existingBranches) { finalBranches = append(finalBranches, newBranch) @@ -70,7 +86,7 @@ func (b *branchListBuilder) appendNewBranches(finalBranches, newBranches, existi return finalBranches } -func sanitisedReflogName(reflogBranch Branch, safeBranches []Branch) string { +func sanitisedReflogName(reflogBranch commands.Branch, safeBranches []commands.Branch) string { for _, safeBranch := range safeBranches { if strings.ToLower(safeBranch.Name) == strings.ToLower(reflogBranch.Name) { return safeBranch.Name @@ -79,15 +95,16 @@ func sanitisedReflogName(reflogBranch Branch, safeBranches []Branch) string { return reflogBranch.Name } -func (b *branchListBuilder) build() []Branch { - branches := make([]Branch, 0) +// Build the list of branches for the current repo +func (b *BranchListBuilder) Build() []commands.Branch { + branches := make([]commands.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...)) + reflogBranches = uniqueByName(append([]commands.Branch{head}, reflogBranches...)) for i, reflogBranch := range reflogBranches { reflogBranches[i].Name = sanitisedReflogName(reflogBranch, safeBranches) } @@ -98,8 +115,17 @@ func (b *branchListBuilder) build() []Branch { return branches } -func uniqueByName(branches []Branch) []Branch { - finalBranches := make([]Branch, 0) +func branchIncluded(branchName string, branches []commands.Branch) bool { + for _, existingBranch := range branches { + if strings.ToLower(existingBranch.Name) == strings.ToLower(branchName) { + return true + } + } + return false +} + +func uniqueByName(branches []commands.Branch) []commands.Branch { + finalBranches := make([]commands.Branch, 0) for _, branch := range branches { if branchIncluded(branch.Name, finalBranches) { continue diff --git a/pkg/gui/branches_panel.go b/pkg/gui/branches_panel.go new file mode 100644 index 000000000..c4786d39f --- /dev/null +++ b/pkg/gui/branches_panel.go @@ -0,0 +1,141 @@ +package gui + +import ( + "fmt" + "strings" + + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazygit/pkg/commands" + "github.com/jesseduffield/lazygit/pkg/git" +) + +func (gui *Gui) handleBranchPress(g *gocui.Gui, v *gocui.View) error { + index := gui.getItemPosition(v) + if index == 0 { + return gui.createErrorPanel(g, "You have already checked out this branch") + } + branch := gui.getSelectedBranch(v) + if err := gui.GitCommand.Checkout(branch.Name, false); err != nil { + gui.createErrorPanel(g, err.Error()) + } + return gui.refreshSidePanels(g) +} + +func (gui *Gui) handleForceCheckout(g *gocui.Gui, v *gocui.View) error { + branch := gui.getSelectedBranch(v) + return gui.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 err := gui.GitCommand.Checkout(branch.Name, true); err != nil { + gui.createErrorPanel(g, err.Error()) + } + return gui.refreshSidePanels(g) + }, nil) +} + +func (gui *Gui) handleCheckoutByName(g *gocui.Gui, v *gocui.View) error { + gui.createPromptPanel(g, v, "Branch Name:", func(g *gocui.Gui, v *gocui.View) error { + if err := gui.GitCommand.Checkout(gui.trimmedContent(v), false); err != nil { + return gui.createErrorPanel(g, err.Error()) + } + return gui.refreshSidePanels(g) + }) + return nil +} + +func (gui *Gui) handleNewBranch(g *gocui.Gui, v *gocui.View) error { + branch := gui.State.Branches[0] + gui.createPromptPanel(g, v, "New Branch Name (Branch is off of "+branch.Name+")", func(g *gocui.Gui, v *gocui.View) error { + if err := gui.GitCommand.NewBranch(gui.trimmedContent(v)); err != nil { + return gui.createErrorPanel(g, err.Error()) + } + gui.refreshSidePanels(g) + return gui.handleBranchSelect(g, v) + }) + return nil +} + +func (gui *Gui) handleDeleteBranch(g *gocui.Gui, v *gocui.View) error { + checkedOutBranch := gui.State.Branches[0] + selectedBranch := gui.getSelectedBranch(v) + if checkedOutBranch.Name == selectedBranch.Name { + return gui.createErrorPanel(g, "You cannot delete the checked out branch!") + } + return gui.createConfirmationPanel(g, v, "Delete Branch", "Are you sure you want delete the branch "+selectedBranch.Name+" ?", func(g *gocui.Gui, v *gocui.View) error { + if err := gui.GitCommand.DeleteBranch(selectedBranch.Name); err != nil { + return gui.createErrorPanel(g, err.Error()) + } + return gui.refreshSidePanels(g) + }, nil) +} + +func (gui *Gui) handleMerge(g *gocui.Gui, v *gocui.View) error { + checkedOutBranch := gui.State.Branches[0] + selectedBranch := gui.getSelectedBranch(v) + defer gui.refreshSidePanels(g) + if checkedOutBranch.Name == selectedBranch.Name { + return gui.createErrorPanel(g, "You cannot merge a branch into itself") + } + if err := gui.GitCommand.Merge(selectedBranch.Name); err != nil { + return gui.createErrorPanel(g, err.Error()) + } + return nil +} + +func (gui *Gui) getSelectedBranch(v *gocui.View) commands.Branch { + lineNumber := gui.getItemPosition(v) + return gui.State.Branches[lineNumber] +} + +func (gui *Gui) renderBranchesOptions(g *gocui.Gui) error { + return gui.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 (gui *Gui) handleBranchSelect(g *gocui.Gui, v *gocui.View) error { + if err := gui.renderBranchesOptions(g); err != nil { + return err + } + // This really shouldn't happen: there should always be a master branch + if len(gui.State.Branches) == 0 { + return gui.renderString(g, "main", "No branches for this repo") + } + go func() { + branch := gui.getSelectedBranch(v) + diff, err := gui.GitCommand.GetBranchGraph(branch.Name) + if err != nil && strings.HasPrefix(diff, "fatal: ambiguous argument") { + diff = "There is no tracking for this branch" + } + gui.renderString(g, "main", diff) + }() + return nil +} + +// gui.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 (gui *Gui) refreshBranches(g *gocui.Gui) error { + g.Update(func(g *gocui.Gui) error { + v, err := g.View("branches") + if err != nil { + panic(err) + } + builder, err := git.NewBranchListBuilder(gui.Log, gui.GitCommand) + if err != nil { + return err + } + gui.State.Branches = builder.Build() + v.Clear() + for _, branch := range gui.State.Branches { + fmt.Fprintln(v, branch.GetDisplayString()) + } + gui.resetOrigin(v) + return gui.refreshStatus(g) + }) + return nil +} diff --git a/pkg/gui/commit_message_panel.go b/pkg/gui/commit_message_panel.go new file mode 100644 index 000000000..f765ab308 --- /dev/null +++ b/pkg/gui/commit_message_panel.go @@ -0,0 +1,50 @@ +package gui + +import "github.com/jesseduffield/gocui" + +func (gui *Gui) handleCommitConfirm(g *gocui.Gui, v *gocui.View) error { + message := gui.trimmedContent(v) + if message == "" { + return gui.createErrorPanel(g, "You cannot commit without a commit message") + } + sub, err := gui.GitCommand.Commit(g, message) + if err != nil { + // TODO need to find a way to send through this error + if err != ErrSubProcess { + return gui.createErrorPanel(g, err.Error()) + } + } + if sub != nil { + gui.SubProcess = sub + return ErrSubProcess + } + gui.refreshFiles(g) + v.Clear() + v.SetCursor(0, 0) + g.SetViewOnBottom("commitMessage") + gui.switchFocus(g, v, gui.getFilesView(g)) + return gui.refreshCommits(g) +} + +func (gui *Gui) handleCommitClose(g *gocui.Gui, v *gocui.View) error { + g.SetViewOnBottom("commitMessage") + return gui.switchFocus(g, v, gui.getFilesView(g)) +} + +func (gui *Gui) 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 := gui.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 (gui *Gui) handleCommitFocused(g *gocui.Gui, v *gocui.View) error { + return gui.renderString(g, "options", "esc: close, enter: confirm") +} diff --git a/pkg/gui/commits_panel.go b/pkg/gui/commits_panel.go new file mode 100644 index 000000000..60ae1c315 --- /dev/null +++ b/pkg/gui/commits_panel.go @@ -0,0 +1,176 @@ +package gui + +import ( + "errors" + + "github.com/fatih/color" + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazygit/pkg/commands" +) + +var ( + // ErrNoCommits : When no commits are found for the branch + ErrNoCommits = errors.New("No commits for this branch") +) + +func (gui *Gui) refreshCommits(g *gocui.Gui) error { + g.Update(func(*gocui.Gui) error { + gui.State.Commits = gui.GitCommand.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 gui.State.Commits { + if commit.Pushed { + shaColor = red + } else { + shaColor = yellow + } + shaColor.Fprint(v, commit.Sha+" ") + white.Fprintln(v, commit.Name) + } + gui.refreshStatus(g) + if g.CurrentView().Name() == "commits" { + gui.handleCommitSelect(g, v) + } + return nil + }) + return nil +} + +func (gui *Gui) handleResetToCommit(g *gocui.Gui, commitView *gocui.View) error { + return gui.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 := gui.getSelectedCommit(g) + if err != nil { + panic(err) + } + if err := gui.GitCommand.ResetToCommit(commit.Sha); err != nil { + return gui.createErrorPanel(g, err.Error()) + } + if err := gui.refreshCommits(g); err != nil { + panic(err) + } + if err := gui.refreshFiles(g); err != nil { + panic(err) + } + gui.resetOrigin(commitView) + return gui.handleCommitSelect(g, nil) + }, nil) +} + +func (gui *Gui) renderCommitsOptions(g *gocui.Gui) error { + return gui.renderOptionsMap(g, map[string]string{ + "s": "squash down", + "r": "rename", + "g": "reset to this commit", + "f": "fixup commit", + "← → ↑ ↓": "navigate", + }) +} + +func (gui *Gui) handleCommitSelect(g *gocui.Gui, v *gocui.View) error { + if err := gui.renderCommitsOptions(g); err != nil { + return err + } + commit, err := gui.getSelectedCommit(g) + if err != nil { + if err != ErrNoCommits { + return err + } + return gui.renderString(g, "main", "No commits for this branch") + } + commitText := gui.GitCommand.Show(commit.Sha) + return gui.renderString(g, "main", commitText) +} + +func (gui *Gui) handleCommitSquashDown(g *gocui.Gui, v *gocui.View) error { + if gui.getItemPosition(v) != 0 { + return gui.createErrorPanel(g, "Can only squash topmost commit") + } + if len(gui.State.Commits) == 1 { + return gui.createErrorPanel(g, "You have no commits to squash with") + } + commit, err := gui.getSelectedCommit(g) + if err != nil { + return err + } + if err := gui.GitCommand.SquashPreviousTwoCommits(commit.Name); err != nil { + return gui.createErrorPanel(g, err.Error()) + } + if err := gui.refreshCommits(g); err != nil { + panic(err) + } + gui.refreshStatus(g) + return gui.handleCommitSelect(g, v) +} + +// TODO: move to files panel +func (gui *Gui) anyUnStagedChanges(files []commands.File) bool { + for _, file := range files { + if file.Tracked && file.HasUnstagedChanges { + return true + } + } + return false +} + +func (gui *Gui) handleCommitFixup(g *gocui.Gui, v *gocui.View) error { + if len(gui.State.Commits) == 1 { + return gui.createErrorPanel(g, "You have no commits to squash with") + } + if gui.anyUnStagedChanges(gui.State.Files) { + return gui.createErrorPanel(g, "Can't fixup while there are unstaged changes") + } + branch := gui.State.Branches[0] + commit, err := gui.getSelectedCommit(g) + if err != nil { + return err + } + gui.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 err := gui.GitCommand.SquashFixupCommit(branch.Name, commit.Sha); err != nil { + return gui.createErrorPanel(g, err.Error()) + } + if err := gui.refreshCommits(g); err != nil { + panic(err) + } + return gui.refreshStatus(g) + }, nil) + return nil +} + +func (gui *Gui) handleRenameCommit(g *gocui.Gui, v *gocui.View) error { + if gui.getItemPosition(v) != 0 { + return gui.createErrorPanel(g, "Can only rename topmost commit") + } + gui.createPromptPanel(g, v, "Rename Commit", func(g *gocui.Gui, v *gocui.View) error { + if err := gui.GitCommand.RenameCommit(v.Buffer()); err != nil { + return gui.createErrorPanel(g, err.Error()) + } + if err := gui.refreshCommits(g); err != nil { + panic(err) + } + return gui.handleCommitSelect(g, v) + }) + return nil +} + +func (gui *Gui) getSelectedCommit(g *gocui.Gui) (commands.Commit, error) { + v, err := g.View("commits") + if err != nil { + panic(err) + } + if len(gui.State.Commits) == 0 { + return commands.Commit{}, ErrNoCommits + } + lineNumber := gui.getItemPosition(v) + if lineNumber > len(gui.State.Commits)-1 { + gui.Log.Info("potential error in getSelected Commit (mismatched ui and state)", gui.State.Commits, lineNumber) + return gui.State.Commits[len(gui.State.Commits)-1], nil + } + return gui.State.Commits[lineNumber], nil +} diff --git a/confirmation_panel.go b/pkg/gui/confirmation_panel.go similarity index 53% rename from confirmation_panel.go rename to pkg/gui/confirmation_panel.go index a8719d237..3bec18419 100644 --- a/confirmation_panel.go +++ b/pkg/gui/confirmation_panel.go @@ -4,39 +4,40 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package main +package gui import ( "strings" "github.com/fatih/color" "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazygit/pkg/utils" ) -func wrappedConfirmationFunction(function func(*gocui.Gui, *gocui.View) error) func(*gocui.Gui, *gocui.View) error { +func (gui *Gui) 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) + return gui.closeConfirmationPrompt(g) } } -func closeConfirmationPrompt(g *gocui.Gui) error { +func (gui *Gui) closeConfirmationPrompt(g *gocui.Gui) error { view, err := g.View("confirmation") if err != nil { panic(err) } - if err := returnFocus(g, view); err != nil { + if err := gui.returnFocus(g, view); err != nil { panic(err) } g.DeleteKeybindings("confirmation") return g.DeleteView("confirmation") } -func getMessageHeight(message string, width int) int { +func (gui *Gui) getMessageHeight(message string, width int) int { lines := strings.Split(message, "\n") lineCount := 0 for _, line := range lines { @@ -45,20 +46,20 @@ func getMessageHeight(message string, width int) int { return lineCount } -func getConfirmationPanelDimensions(g *gocui.Gui, prompt string) (int, int, int, int) { +func (gui *Gui) getConfirmationPanelDimensions(g *gocui.Gui, prompt string) (int, int, int, int) { width, height := g.Size() panelWidth := width / 2 - panelHeight := getMessageHeight(prompt, panelWidth) + panelHeight := gui.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 { +func (gui *Gui) 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, "") + x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(g, "") if confirmationView, err := g.SetView("confirmation", x0, y0, x1, y1, 0); err != nil { if err != gocui.ErrUnknownView { return err @@ -66,41 +67,41 @@ func createPromptPanel(g *gocui.Gui, currentView *gocui.View, title string, hand confirmationView.Editable = true confirmationView.Title = title - switchFocus(g, currentView, confirmationView) - return setKeyBindings(g, handleConfirm, nil) + gui.switchFocus(g, currentView, confirmationView) + return gui.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 { +func (gui *Gui) 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 { + if err := gui.closeConfirmationPrompt(g); err != nil { panic(err) } } - x0, y0, x1, y1 := getConfirmationPanelDimensions(g, prompt) + x0, y0, x1, y1 := gui.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) + gui.renderString(g, "confirmation", prompt) + gui.switchFocus(g, currentView, confirmationView) + return gui.setKeyBindings(g, handleConfirm, handleClose) } return nil }) return nil } -func handleNewline(g *gocui.Gui, v *gocui.View) error { +func (gui *Gui) 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()) + x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(g, v.Buffer()) if _, err := g.SetView("confirmation", x0, y0, x1, y1+1, 0); err != nil { if err != gocui.ErrUnknownView { return err @@ -111,45 +112,38 @@ func handleNewline(g *gocui.Gui, v *gocui.View) error { 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 { +func (gui *Gui) setKeyBindings(g *gocui.Gui, handleConfirm, handleClose func(*gocui.Gui, *gocui.View) error) error { + gui.renderString(g, "options", "esc: close, enter: confirm") + if err := g.SetKeybinding("confirmation", gocui.KeyEnter, gocui.ModNone, gui.wrappedConfirmationFunction(handleConfirm)); err != nil { return err } - if err := g.SetKeybinding("confirmation", gocui.KeyTab, gocui.ModNone, handleNewline); err != nil { + if err := g.SetKeybinding("confirmation", gocui.KeyTab, gocui.ModNone, gui.handleNewline); err != nil { return err } - return g.SetKeybinding("confirmation", gocui.KeyEsc, gocui.ModNone, wrappedConfirmationFunction(handleClose)) + return g.SetKeybinding("confirmation", gocui.KeyEsc, gocui.ModNone, gui.wrappedConfirmationFunction(handleClose)) } -func createMessagePanel(g *gocui.Gui, currentView *gocui.View, title, prompt string) error { - return createConfirmationPanel(g, currentView, title, prompt, nil, nil) +func (gui *Gui) createMessagePanel(g *gocui.Gui, currentView *gocui.View, title, prompt string) error { + return gui.createConfirmationPanel(g, currentView, title, prompt, nil, nil) } -func createErrorPanel(g *gocui.Gui, message string) error { +func (gui *Gui) 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) + return gui.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 { +func (gui *Gui) 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) + content := utils.TrimTrailingNewline(v.Buffer()) + x0, y0, x1, y1 := gui.getConfirmationPanelDimensions(g, content) vx0, vy0, vx1, vy1 := v.Dimensions() if vx0 == x0 && vy0 == y0 && vx1 == x1 && vy1 == y1 { return nil } - devLog("resizing popup panel") + gui.Log.Info("resizing popup panel") _, err := g.SetView(v.Name(), x0, y0, x1, y1, 0) return err } diff --git a/pkg/gui/files_panel.go b/pkg/gui/files_panel.go new file mode 100644 index 000000000..a7e9683d2 --- /dev/null +++ b/pkg/gui/files_panel.go @@ -0,0 +1,400 @@ +package gui + +import ( + + // "io" + // "io/ioutil" + + // "strings" + + "errors" + "os/exec" + "strings" + + "github.com/fatih/color" + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazygit/pkg/commands" +) + +var ( + errNoFiles = errors.New("No changed files") + errNoUsername = errors.New(`No username set. Please do: git config --global user.name "Your Name"`) +) + +func (gui *Gui) stagedFiles() []commands.File { + files := gui.State.Files + result := make([]commands.File, 0) + for _, file := range files { + if file.HasStagedChanges { + result = append(result, file) + } + } + return result +} + +func (gui *Gui) trackedFiles() []commands.File { + files := gui.State.Files + result := make([]commands.File, 0) + for _, file := range files { + if file.Tracked { + result = append(result, file) + } + } + return result +} + +func (gui *Gui) stageSelectedFile(g *gocui.Gui) error { + file, err := gui.getSelectedFile(g) + if err != nil { + return err + } + return gui.GitCommand.StageFile(file.Name) +} + +func (gui *Gui) handleFilePress(g *gocui.Gui, v *gocui.View) error { + file, err := gui.getSelectedFile(g) + if err != nil { + if err == errNoFiles { + return nil + } + return err + } + + if file.HasMergeConflicts { + return gui.handleSwitchToMerge(g, v) + } + + if file.HasUnstagedChanges { + gui.GitCommand.StageFile(file.Name) + } else { + gui.GitCommand.UnStageFile(file.Name, file.Tracked) + } + + if err := gui.refreshFiles(g); err != nil { + return err + } + + return gui.handleFileSelect(g, v) +} + +func (gui *Gui) handleAddPatch(g *gocui.Gui, v *gocui.View) error { + file, err := gui.getSelectedFile(g) + if err != nil { + if err == errNoFiles { + return nil + } + return err + } + if !file.HasUnstagedChanges { + return gui.createErrorPanel(g, "File has no unstaged changes to add") + } + if !file.Tracked { + return gui.createErrorPanel(g, "Cannot git add --patch untracked files") + } + sub, err := gui.GitCommand.AddPatch(file.Name) + if err != nil { + return err + } + gui.SubProcess = sub + return ErrSubProcess +} + +func (gui *Gui) getSelectedFile(g *gocui.Gui) (commands.File, error) { + if len(gui.State.Files) == 0 { + return commands.File{}, errNoFiles + } + filesView, err := g.View("files") + if err != nil { + panic(err) + } + lineNumber := gui.getItemPosition(filesView) + return gui.State.Files[lineNumber], nil +} + +func (gui *Gui) handleFileRemove(g *gocui.Gui, v *gocui.View) error { + file, err := gui.getSelectedFile(g) + if err != nil { + if err == errNoFiles { + return nil + } + return err + } + var deleteVerb string + if file.Tracked { + deleteVerb = "checkout" + } else { + deleteVerb = "delete" + } + return gui.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 := gui.GitCommand.RemoveFile(file); err != nil { + panic(err) + } + return gui.refreshFiles(g) + }, nil) +} + +func (gui *Gui) handleIgnoreFile(g *gocui.Gui, v *gocui.View) error { + file, err := gui.getSelectedFile(g) + if err != nil { + return gui.createErrorPanel(g, err.Error()) + } + if file.Tracked { + return gui.createErrorPanel(g, "Cannot ignore tracked files") + } + gui.GitCommand.Ignore(file.Name) + return gui.refreshFiles(g) +} + +func (gui *Gui) renderfilesOptions(g *gocui.Gui, file *commands.File) 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 gui.State.HasMergeConflicts { + optionsMap["a"] = "abort merge" + optionsMap["m"] = "resolve merge conflicts" + } + if file == nil { + return gui.renderOptionsMap(g, optionsMap) + } + if file.Tracked { + optionsMap["d"] = "checkout" + } + return gui.renderOptionsMap(g, optionsMap) +} + +func (gui *Gui) handleFileSelect(g *gocui.Gui, v *gocui.View) error { + file, err := gui.getSelectedFile(g) + if err != nil { + if err != errNoFiles { + return err + } + gui.renderString(g, "main", "No changed files") + return gui.renderfilesOptions(g, nil) + } + gui.renderfilesOptions(g, &file) + var content string + if file.HasMergeConflicts { + return gui.refreshMergePanel(g) + } + + content = gui.GitCommand.Diff(file) + return gui.renderString(g, "main", content) +} + +func (gui *Gui) handleCommitPress(g *gocui.Gui, filesView *gocui.View) error { + if len(gui.stagedFiles()) == 0 && !gui.State.HasMergeConflicts { + return gui.createErrorPanel(g, "There are no staged files to commit") + } + commitMessageView := gui.getCommitMessageView(g) + g.Update(func(g *gocui.Gui) error { + g.SetViewOnTop("commitMessage") + gui.switchFocus(g, filesView, commitMessageView) + return nil + }) + return nil +} + +// handleCommitEditorPress - handle when the user wants to commit changes via +// their editor rather than via the popup panel +func (gui *Gui) handleCommitEditorPress(g *gocui.Gui, filesView *gocui.View) error { + if len(gui.stagedFiles()) == 0 && !gui.State.HasMergeConflicts { + return gui.createErrorPanel(g, "There are no staged files to commit") + } + gui.PrepareSubProcess(g, "git", "commit") + return nil +} + +// PrepareSubProcess - prepare a subprocess for execution and tell the gui to switch to it +func (gui *Gui) PrepareSubProcess(g *gocui.Gui, commands ...string) error { + sub, err := gui.GitCommand.PrepareCommitSubProcess() + if err != nil { + return err + } + gui.SubProcess = sub + g.Update(func(g *gocui.Gui) error { + return ErrSubProcess + }) + return nil +} + +func (gui *Gui) genericFileOpen(g *gocui.Gui, v *gocui.View, open func(string) (*exec.Cmd, error)) error { + file, err := gui.getSelectedFile(g) + if err != nil { + if err != errNoFiles { + return err + } + return nil + } + sub, err := open(file.Name) + if err != nil { + return gui.createErrorPanel(g, err.Error()) + } + if sub != nil { + gui.SubProcess = sub + return ErrSubProcess + } + return nil +} + +func (gui *Gui) handleFileEdit(g *gocui.Gui, v *gocui.View) error { + return gui.genericFileOpen(g, v, gui.OSCommand.EditFile) +} + +func (gui *Gui) handleFileOpen(g *gocui.Gui, v *gocui.View) error { + return gui.genericFileOpen(g, v, gui.OSCommand.OpenFile) +} + +func (gui *Gui) handleSublimeFileOpen(g *gocui.Gui, v *gocui.View) error { + return gui.genericFileOpen(g, v, gui.OSCommand.SublimeOpenFile) +} + +func (gui *Gui) handleVsCodeFileOpen(g *gocui.Gui, v *gocui.View) error { + return gui.genericFileOpen(g, v, gui.OSCommand.VsCodeOpenFile) +} + +func (gui *Gui) handleRefreshFiles(g *gocui.Gui, v *gocui.View) error { + return gui.refreshFiles(g) +} + +func (gui *Gui) refreshStateFiles() { + // get files to stage + files := gui.GitCommand.GetStatusFiles() + gui.State.Files = gui.GitCommand.MergeStatusFiles(gui.State.Files, files) + gui.updateHasMergeConflictStatus() +} + +func (gui *Gui) updateHasMergeConflictStatus() error { + merging, err := gui.GitCommand.IsInMergeState() + if err != nil { + return err + } + gui.State.HasMergeConflicts = merging + return nil +} + +func (gui *Gui) renderFile(file commands.File, 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 !file.Tracked && !file.HasStagedChanges { + red.Fprintln(filesView, file.DisplayString) + return + } + green.Fprint(filesView, file.DisplayString[0:1]) + red.Fprint(filesView, file.DisplayString[1:3]) + if file.HasUnstagedChanges { + red.Fprintln(filesView, file.Name) + } else { + green.Fprintln(filesView, file.Name) + } +} + +func (gui *Gui) catSelectedFile(g *gocui.Gui) (string, error) { + item, err := gui.getSelectedFile(g) + if err != nil { + if err != errNoFiles { + return "", err + } + return "", gui.renderString(g, "main", "No file to display") + } + cat, err := gui.GitCommand.CatFile(item.Name) + if err != nil { + panic(err) + } + return cat, nil +} + +func (gui *Gui) refreshFiles(g *gocui.Gui) error { + filesView, err := g.View("files") + if err != nil { + return err + } + gui.refreshStateFiles() + filesView.Clear() + for _, file := range gui.State.Files { + gui.renderFile(file, filesView) + } + gui.correctCursor(filesView) + if filesView == g.CurrentView() { + gui.handleFileSelect(g, filesView) + } + return nil +} + +func (gui *Gui) pullFiles(g *gocui.Gui, v *gocui.View) error { + gui.createMessagePanel(g, v, "", "Pulling...") + go func() { + if err := gui.GitCommand.Pull(); err != nil { + gui.createErrorPanel(g, err.Error()) + } else { + gui.closeConfirmationPrompt(g) + gui.refreshCommits(g) + gui.refreshStatus(g) + } + gui.refreshFiles(g) + }() + return nil +} + +func (gui *Gui) pushFiles(g *gocui.Gui, v *gocui.View) error { + gui.createMessagePanel(g, v, "", "Pushing...") + go func() { + branchName := gui.State.Branches[0].Name + if err := gui.GitCommand.Push(branchName); err != nil { + gui.createErrorPanel(g, err.Error()) + } else { + gui.closeConfirmationPrompt(g) + gui.refreshCommits(g) + gui.refreshStatus(g) + } + }() + return nil +} + +func (gui *Gui) handleSwitchToMerge(g *gocui.Gui, v *gocui.View) error { + mergeView, err := g.View("main") + if err != nil { + return err + } + file, err := gui.getSelectedFile(g) + if err != nil { + if err != errNoFiles { + return err + } + return nil + } + if !file.HasMergeConflicts { + return gui.createErrorPanel(g, "This file has no merge conflicts") + } + gui.switchFocus(g, v, mergeView) + return gui.refreshMergePanel(g) +} + +func (gui *Gui) handleAbortMerge(g *gocui.Gui, v *gocui.View) error { + if err := gui.GitCommand.AbortMerge(); err != nil { + return gui.createErrorPanel(g, err.Error()) + } + gui.createMessagePanel(g, v, "", "Merge aborted") + gui.refreshStatus(g) + return gui.refreshFiles(g) +} + +func (gui *Gui) handleResetHard(g *gocui.Gui, v *gocui.View) error { + return gui.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 := gui.GitCommand.ResetHard(); err != nil { + gui.createErrorPanel(g, err.Error()) + } + return gui.refreshFiles(g) + }, nil) +} diff --git a/gui.go b/pkg/gui/gui.go similarity index 51% rename from gui.go rename to pkg/gui/gui.go index f41f2891b..7fd396f05 100644 --- a/gui.go +++ b/pkg/gui/gui.go @@ -1,82 +1,86 @@ -package main +package gui import ( // "io" // "io/ioutil" - "runtime" + "errors" + "io/ioutil" + "log" + "os" + "os/exec" "strings" "time" // "strings" + "github.com/Sirupsen/logrus" "github.com/golang-collections/collections/stack" "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazygit/pkg/commands" ) // OverlappingEdges determines if panel edges overlap var OverlappingEdges = false -type stateType struct { - GitFiles []GitFile - Branches []Branch - Commits []Commit - StashEntries []StashEntry +// ErrSubProcess tells us we're switching to a subprocess so we need to +// close the Gui until it is finished +var ( + ErrSubProcess = errors.New("running subprocess") +) + +// Gui wraps the gocui Gui object which handles rendering and events +type Gui struct { + g *gocui.Gui + Log *logrus.Logger + GitCommand *commands.GitCommand + OSCommand *commands.OSCommand + Version string + SubProcess *exec.Cmd + State guiState +} + +type guiState struct { + Files []commands.File + Branches []commands.Branch + Commits []commands.Commit + StashEntries []commands.StashEntry PreviousView string HasMergeConflicts bool ConflictIndex int ConflictTop bool - Conflicts []conflict + Conflicts []commands.Conflict EditHistory *stack.Stack - Platform platform + Platform commands.Platform + Version string } -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: "\"", - } +// NewGui builds a new gui handler +func NewGui(log *logrus.Logger, gitCommand *commands.GitCommand, oSCommand *commands.OSCommand, version string) (*Gui, error) { + initialState := guiState{ + Files: make([]commands.File, 0), + PreviousView: "files", + Commits: make([]commands.Commit, 0), + StashEntries: make([]commands.StashEntry, 0), + ConflictIndex: 0, + ConflictTop: true, + Conflicts: make([]commands.Conflict, 0), + EditHistory: stack.New(), + Platform: *oSCommand.Platform, + Version: "test version", // TODO: send version in } + + return &Gui{ + Log: log, + GitCommand: gitCommand, + OSCommand: oSCommand, + Version: version, + State: initialState, + }, nil } -func scrollUpMain(g *gocui.Gui, v *gocui.View) error { +func (gui *Gui) scrollUpMain(g *gocui.Gui, v *gocui.View) error { mainView, _ := g.View("main") ox, oy := mainView.Origin() if oy >= 1 { @@ -85,7 +89,7 @@ func scrollUpMain(g *gocui.Gui, v *gocui.View) error { return nil } -func scrollDownMain(g *gocui.Gui, v *gocui.View) error { +func (gui *Gui) scrollDownMain(g *gocui.Gui, v *gocui.View) error { mainView, _ := g.View("main") ox, oy := mainView.Origin() if oy < len(mainView.BufferLines()) { @@ -94,8 +98,8 @@ func scrollDownMain(g *gocui.Gui, v *gocui.View) error { return nil } -func handleRefresh(g *gocui.Gui, v *gocui.View) error { - return refreshSidePanels(g) +func (gui *Gui) handleRefresh(g *gocui.Gui, v *gocui.View) error { + return gui.refreshSidePanels(g) } func max(a, b int) int { @@ -106,7 +110,7 @@ func max(a, b int) int { } // layout is called for every screen re-render e.g. when the screen is resized -func layout(g *gocui.Gui) error { +func (gui *Gui) layout(g *gocui.Gui) error { g.Highlight = true g.SelFgColor = gocui.ColorWhite | gocui.AttrBold width, height := g.Size() @@ -157,7 +161,7 @@ func layout(g *gocui.Gui) error { if err != gocui.ErrUnknownView { return err } - v.Title = ShortLocalize("StatusTitle", "Status") + v.Title = "Status" v.FgColor = gocui.ColorWhite } @@ -167,7 +171,7 @@ func layout(g *gocui.Gui) error { return err } filesView.Highlight = true - filesView.Title = ShortLocalize("FilesTitle", "Files") + filesView.Title = "Files" v.FgColor = gocui.ColorWhite } @@ -175,7 +179,7 @@ func layout(g *gocui.Gui) error { if err != gocui.ErrUnknownView { return err } - v.Title = ShortLocalize("BranchesTitle", "Branches") + v.Title = "Branches" v.FgColor = gocui.ColorWhite } @@ -183,7 +187,7 @@ func layout(g *gocui.Gui) error { if err != gocui.ErrUnknownView { return err } - v.Title = ShortLocalize("CommitsTitle", "Commits") + v.Title = "Commits" v.FgColor = gocui.ColorWhite } @@ -191,11 +195,11 @@ func layout(g *gocui.Gui) error { if err != gocui.ErrUnknownView { return err } - v.Title = ShortLocalize("StashTitle", "Stash") + 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 v, err := g.SetView("options", -1, optionsTop, width-len(gui.Version)-2, optionsTop+2, 0); err != nil { if err != gocui.ErrUnknownView { return err } @@ -203,60 +207,60 @@ func layout(g *gocui.Gui) error { v.Frame = false } - if getCommitMessageView(g) == nil { + if gui.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 = ShortLocalize("CommitMessage", "Commit message") + 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 v, err := g.SetView("version", width-len(gui.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) + gui.renderString(g, "version", gui.Version) // these are only called once - handleFileSelect(g, filesView) - refreshFiles(g) - refreshBranches(g) - refreshCommits(g) - refreshStashEntries(g) - nextView(g, nil) + gui.handleFileSelect(g, filesView) + gui.refreshFiles(g) + gui.refreshBranches(g) + gui.refreshCommits(g) + gui.refreshStashEntries(g) + gui.nextView(g, nil) } - resizePopupPanels(g) + gui.resizePopupPanels(g) return nil } -func fetch(g *gocui.Gui) error { - gitFetch() - refreshStatus(g) +func (gui *Gui) fetch(g *gocui.Gui) error { + gui.GitCommand.Fetch() + gui.refreshStatus(g) return nil } -func updateLoader(g *gocui.Gui) error { +func (gui *Gui) updateLoader(g *gocui.Gui) error { if confirmationView, _ := g.View("confirmation"); confirmationView != nil { - content := trimmedContent(confirmationView) + content := gui.trimmedContent(confirmationView) if strings.Contains(content, "...") { staticContent := strings.Split(content, "...")[0] + "..." - renderString(g, "confirmation", staticContent+" "+loader()) + gui.renderString(g, "confirmation", staticContent+" "+gui.loader()) } } return nil } -func goEvery(g *gocui.Gui, interval time.Duration, function func(*gocui.Gui) error) { +func (gui *Gui) goEvery(g *gocui.Gui, interval time.Duration, function func(*gocui.Gui) error) { go func() { for range time.Tick(interval) { function(g) @@ -264,37 +268,64 @@ func goEvery(g *gocui.Gui, interval time.Duration, function func(*gocui.Gui) err }() } -func resizePopupPanels(g *gocui.Gui) error { +func (gui *Gui) resizePopupPanels(g *gocui.Gui) error { v := g.CurrentView() if v.Name() == "commitMessage" || v.Name() == "confirmation" { - return resizePopupPanel(g, v) + return gui.resizePopupPanel(g, v) } return nil } -func run() (err error) { +// Run setup the gui with keybindings and start the mainloop +func (gui *Gui) Run() error { g, err := gocui.NewGui(gocui.OutputNormal, OverlappingEdges) if err != nil { - return + return err } defer g.Close() + gui.g = g // TODO: always use gui.g rather than passing g around everywhere + g.FgColor = gocui.ColorDefault - goEvery(g, time.Second*60, fetch) - goEvery(g, time.Second*10, refreshFiles) - goEvery(g, time.Millisecond*10, updateLoader) + gui.goEvery(g, time.Second*60, gui.fetch) + gui.goEvery(g, time.Second*10, gui.refreshFiles) + gui.goEvery(g, time.Millisecond*10, gui.updateLoader) - g.SetManagerFunc(layout) + g.SetManagerFunc(gui.layout) - if err = keybindings(g); err != nil { - return + if err = gui.keybindings(g); err != nil { + return err } err = g.MainLoop() - return + return err } -func quit(g *gocui.Gui, v *gocui.View) error { +// RunWithSubprocesses loops, instantiating a new gocui.Gui with each iteration +// if the error returned from a run is a ErrSubProcess, it runs the subprocess +// otherwise it handles the error, possibly by quitting the application +func (gui *Gui) RunWithSubprocesses() { + for { + if err := gui.Run(); err != nil { + if err == gocui.ErrQuit { + break + } else if err == ErrSubProcess { + gui.SubProcess.Stdin = os.Stdin + gui.SubProcess.Stdout = os.Stdout + gui.SubProcess.Stderr = os.Stderr + gui.SubProcess.Run() + gui.SubProcess.Stdout = ioutil.Discard + gui.SubProcess.Stderr = ioutil.Discard + gui.SubProcess.Stdin = nil + gui.SubProcess = nil + } else { + log.Panicln(err) + } + } + } +} + +func (gui *Gui) quit(g *gocui.Gui, v *gocui.View) error { return gocui.ErrQuit } diff --git a/keybindings.go b/pkg/gui/keybindings.go similarity index 66% rename from keybindings.go rename to pkg/gui/keybindings.go index afaa09527..b4f2bdc57 100644 --- a/keybindings.go +++ b/pkg/gui/keybindings.go @@ -1,4 +1,4 @@ -package main +package gui import "github.com/jesseduffield/gocui" @@ -12,73 +12,75 @@ type Binding struct { Modifier gocui.Modifier } -func keybindings(g *gocui.Gui) error { +func (gui *Gui) 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}, + {ViewName: "", Key: 'q', Modifier: gocui.ModNone, Handler: gui.quit}, + {ViewName: "", Key: gocui.KeyCtrlC, Modifier: gocui.ModNone, Handler: gui.quit}, + {ViewName: "", Key: gocui.KeyPgup, Modifier: gocui.ModNone, Handler: gui.scrollUpMain}, + {ViewName: "", Key: gocui.KeyPgdn, Modifier: gocui.ModNone, Handler: gui.scrollDownMain}, + {ViewName: "", Key: gocui.KeyCtrlU, Modifier: gocui.ModNone, Handler: gui.scrollUpMain}, + {ViewName: "", Key: gocui.KeyCtrlD, Modifier: gocui.ModNone, Handler: gui.scrollDownMain}, + {ViewName: "", Key: 'P', Modifier: gocui.ModNone, Handler: gui.pushFiles}, + {ViewName: "", Key: 'p', Modifier: gocui.ModNone, Handler: gui.pullFiles}, + {ViewName: "", Key: 'R', Modifier: gocui.ModNone, Handler: gui.handleRefresh}, + {ViewName: "files", Key: 'c', Modifier: gocui.ModNone, Handler: gui.handleCommitPress}, + {ViewName: "files", Key: 'C', Modifier: gocui.ModNone, Handler: gui.handleCommitEditorPress}, + {ViewName: "files", Key: gocui.KeySpace, Modifier: gocui.ModNone, Handler: gui.handleFilePress}, + {ViewName: "files", Key: 'd', Modifier: gocui.ModNone, Handler: gui.handleFileRemove}, + {ViewName: "files", Key: 'm', Modifier: gocui.ModNone, Handler: gui.handleSwitchToMerge}, + {ViewName: "files", Key: 'e', Modifier: gocui.ModNone, Handler: gui.handleFileEdit}, + {ViewName: "files", Key: 'o', Modifier: gocui.ModNone, Handler: gui.handleFileOpen}, + {ViewName: "files", Key: 's', Modifier: gocui.ModNone, Handler: gui.handleSublimeFileOpen}, + {ViewName: "files", Key: 'v', Modifier: gocui.ModNone, Handler: gui.handleVsCodeFileOpen}, + {ViewName: "files", Key: 'i', Modifier: gocui.ModNone, Handler: gui.handleIgnoreFile}, + {ViewName: "files", Key: 'r', Modifier: gocui.ModNone, Handler: gui.handleRefreshFiles}, + {ViewName: "files", Key: 'S', Modifier: gocui.ModNone, Handler: gui.handleStashSave}, + {ViewName: "files", Key: 'a', Modifier: gocui.ModNone, Handler: gui.handleAbortMerge}, + {ViewName: "files", Key: 't', Modifier: gocui.ModNone, Handler: gui.handleAddPatch}, + {ViewName: "files", Key: 'D', Modifier: gocui.ModNone, Handler: gui.handleResetHard}, + {ViewName: "main", Key: gocui.KeyEsc, Modifier: gocui.ModNone, Handler: gui.handleEscapeMerge}, + {ViewName: "main", Key: gocui.KeySpace, Modifier: gocui.ModNone, Handler: gui.handlePickHunk}, + {ViewName: "main", Key: 'b', Modifier: gocui.ModNone, Handler: gui.handlePickBothHunks}, + {ViewName: "main", Key: gocui.KeyArrowLeft, Modifier: gocui.ModNone, Handler: gui.handleSelectPrevConflict}, + {ViewName: "main", Key: gocui.KeyArrowRight, Modifier: gocui.ModNone, Handler: gui.handleSelectNextConflict}, + {ViewName: "main", Key: gocui.KeyArrowUp, Modifier: gocui.ModNone, Handler: gui.handleSelectTop}, + {ViewName: "main", Key: gocui.KeyArrowDown, Modifier: gocui.ModNone, Handler: gui.handleSelectBottom}, + {ViewName: "main", Key: 'h', Modifier: gocui.ModNone, Handler: gui.handleSelectPrevConflict}, + {ViewName: "main", Key: 'l', Modifier: gocui.ModNone, Handler: gui.handleSelectNextConflict}, + {ViewName: "main", Key: 'k', Modifier: gocui.ModNone, Handler: gui.handleSelectTop}, + {ViewName: "main", Key: 'j', Modifier: gocui.ModNone, Handler: gui.handleSelectBottom}, + {ViewName: "main", Key: 'z', Modifier: gocui.ModNone, Handler: gui.handlePopFileSnapshot}, + {ViewName: "branches", Key: gocui.KeySpace, Modifier: gocui.ModNone, Handler: gui.handleBranchPress}, + {ViewName: "branches", Key: 'c', Modifier: gocui.ModNone, Handler: gui.handleCheckoutByName}, + {ViewName: "branches", Key: 'F', Modifier: gocui.ModNone, Handler: gui.handleForceCheckout}, + {ViewName: "branches", Key: 'n', Modifier: gocui.ModNone, Handler: gui.handleNewBranch}, + {ViewName: "branches", Key: 'd', Modifier: gocui.ModNone, Handler: gui.handleDeleteBranch}, + {ViewName: "branches", Key: 'm', Modifier: gocui.ModNone, Handler: gui.handleMerge}, + {ViewName: "commits", Key: 's', Modifier: gocui.ModNone, Handler: gui.handleCommitSquashDown}, + {ViewName: "commits", Key: 'r', Modifier: gocui.ModNone, Handler: gui.handleRenameCommit}, + {ViewName: "commits", Key: 'g', Modifier: gocui.ModNone, Handler: gui.handleResetToCommit}, + {ViewName: "commits", Key: 'f', Modifier: gocui.ModNone, Handler: gui.handleCommitFixup}, + {ViewName: "stash", Key: gocui.KeySpace, Modifier: gocui.ModNone, Handler: gui.handleStashApply}, + {ViewName: "stash", Key: 'g', Modifier: gocui.ModNone, Handler: gui.handleStashPop}, + {ViewName: "stash", Key: 'd', Modifier: gocui.ModNone, Handler: gui.handleStashDrop}, + {ViewName: "commitMessage", Key: gocui.KeyEnter, Modifier: gocui.ModNone, Handler: gui.handleCommitConfirm}, + {ViewName: "commitMessage", Key: gocui.KeyEsc, Modifier: gocui.ModNone, Handler: gui.handleCommitClose}, + {ViewName: "commitMessage", Key: gocui.KeyTab, Modifier: gocui.ModNone, Handler: gui.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}, + {ViewName: viewName, Key: gocui.KeyTab, Modifier: gocui.ModNone, Handler: gui.nextView}, + {ViewName: viewName, Key: gocui.KeyArrowLeft, Modifier: gocui.ModNone, Handler: gui.previousView}, + {ViewName: viewName, Key: gocui.KeyArrowRight, Modifier: gocui.ModNone, Handler: gui.nextView}, + {ViewName: viewName, Key: gocui.KeyArrowUp, Modifier: gocui.ModNone, Handler: gui.cursorUp}, + {ViewName: viewName, Key: gocui.KeyArrowDown, Modifier: gocui.ModNone, Handler: gui.cursorDown}, + {ViewName: viewName, Key: 'h', Modifier: gocui.ModNone, Handler: gui.previousView}, + {ViewName: viewName, Key: 'l', Modifier: gocui.ModNone, Handler: gui.nextView}, + {ViewName: viewName, Key: 'k', Modifier: gocui.ModNone, Handler: gui.cursorUp}, + {ViewName: viewName, Key: 'j', Modifier: gocui.ModNone, Handler: gui.cursorDown}, }...) } diff --git a/pkg/gui/merge_panel.go b/pkg/gui/merge_panel.go new file mode 100644 index 000000000..81e37f593 --- /dev/null +++ b/pkg/gui/merge_panel.go @@ -0,0 +1,260 @@ +// though this panel is called the merge panel, it's really going to use the main panel. This may change in the future + +package gui + +import ( + "bufio" + "bytes" + "io/ioutil" + "math" + "os" + "strings" + + "github.com/fatih/color" + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazygit/pkg/commands" + "github.com/jesseduffield/lazygit/pkg/utils" +) + +func (gui *Gui) findConflicts(content string) ([]commands.Conflict, error) { + conflicts := make([]commands.Conflict, 0) + var newConflict commands.Conflict + for i, line := range utils.SplitLines(content) { + if line == "<<<<<<< HEAD" || line == "<<<<<<< MERGE_HEAD" || line == "<<<<<<< Updated upstream" { + newConflict = commands.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 (gui *Gui) shiftConflict(conflicts []commands.Conflict) (commands.Conflict, []commands.Conflict) { + return conflicts[0], conflicts[1:] +} + +func (gui *Gui) shouldHighlightLine(index int, conflict commands.Conflict, top bool) bool { + return (index >= conflict.Start && index <= conflict.Middle && top) || (index >= conflict.Middle && index <= conflict.End && !top) +} + +func (gui *Gui) coloredConflictFile(content string, conflicts []commands.Conflict, conflictIndex int, conflictTop, hasFocus bool) (string, error) { + if len(conflicts) == 0 { + return content, nil + } + conflict, remainingConflicts := gui.shiftConflict(conflicts) + var outputBuffer bytes.Buffer + for i, line := range utils.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 && gui.shouldHighlightLine(i, conflict, conflictTop) { + colour.Add(color.Bold) + } + if i == conflict.End && len(remainingConflicts) > 0 { + conflict, remainingConflicts = gui.shiftConflict(remainingConflicts) + } + outputBuffer.WriteString(utils.ColoredStringDirect(line, colour) + "\n") + } + return outputBuffer.String(), nil +} + +func (gui *Gui) handleSelectTop(g *gocui.Gui, v *gocui.View) error { + gui.State.ConflictTop = true + return gui.refreshMergePanel(g) +} + +func (gui *Gui) handleSelectBottom(g *gocui.Gui, v *gocui.View) error { + gui.State.ConflictTop = false + return gui.refreshMergePanel(g) +} + +func (gui *Gui) handleSelectNextConflict(g *gocui.Gui, v *gocui.View) error { + if gui.State.ConflictIndex >= len(gui.State.Conflicts)-1 { + return nil + } + gui.State.ConflictIndex++ + return gui.refreshMergePanel(g) +} + +func (gui *Gui) handleSelectPrevConflict(g *gocui.Gui, v *gocui.View) error { + if gui.State.ConflictIndex <= 0 { + return nil + } + gui.State.ConflictIndex-- + return gui.refreshMergePanel(g) +} + +func (gui *Gui) isIndexToDelete(i int, conflict commands.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 (gui *Gui) resolveConflict(g *gocui.Gui, conflict commands.Conflict, pick string) error { + gitFile, err := gui.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 !gui.isIndexToDelete(i, conflict, pick) { + output += line + } + } + gui.Log.Info(output) + return ioutil.WriteFile(gitFile.Name, []byte(output), 0644) +} + +func (gui *Gui) pushFileSnapshot(g *gocui.Gui) error { + gitFile, err := gui.getSelectedFile(g) + if err != nil { + return err + } + content, err := gui.GitCommand.CatFile(gitFile.Name) + if err != nil { + return err + } + gui.State.EditHistory.Push(content) + return nil +} + +func (gui *Gui) handlePopFileSnapshot(g *gocui.Gui, v *gocui.View) error { + if gui.State.EditHistory.Len() == 0 { + return nil + } + prevContent := gui.State.EditHistory.Pop().(string) + gitFile, err := gui.getSelectedFile(g) + if err != nil { + return err + } + ioutil.WriteFile(gitFile.Name, []byte(prevContent), 0644) + return gui.refreshMergePanel(g) +} + +func (gui *Gui) handlePickHunk(g *gocui.Gui, v *gocui.View) error { + conflict := gui.State.Conflicts[gui.State.ConflictIndex] + gui.pushFileSnapshot(g) + pick := "bottom" + if gui.State.ConflictTop { + pick = "top" + } + err := gui.resolveConflict(g, conflict, pick) + if err != nil { + panic(err) + } + gui.refreshMergePanel(g) + return nil +} + +func (gui *Gui) handlePickBothHunks(g *gocui.Gui, v *gocui.View) error { + conflict := gui.State.Conflicts[gui.State.ConflictIndex] + gui.pushFileSnapshot(g) + err := gui.resolveConflict(g, conflict, "both") + if err != nil { + panic(err) + } + return gui.refreshMergePanel(g) +} + +func (gui *Gui) refreshMergePanel(g *gocui.Gui) error { + cat, err := gui.catSelectedFile(g) + if err != nil { + return err + } + gui.State.Conflicts, err = gui.findConflicts(cat) + if err != nil { + return err + } + + if len(gui.State.Conflicts) == 0 { + return gui.handleCompleteMerge(g) + } else if gui.State.ConflictIndex > len(gui.State.Conflicts)-1 { + gui.State.ConflictIndex = len(gui.State.Conflicts) - 1 + } + hasFocus := gui.currentViewName(g) == "main" + if hasFocus { + gui.renderMergeOptions(g) + } + content, err := gui.coloredConflictFile(cat, gui.State.Conflicts, gui.State.ConflictIndex, gui.State.ConflictTop, hasFocus) + if err != nil { + return err + } + if err := gui.scrollToConflict(g); err != nil { + return err + } + return gui.renderString(g, "main", content) +} + +func (gui *Gui) scrollToConflict(g *gocui.Gui) error { + mainView, err := g.View("main") + if err != nil { + return err + } + if len(gui.State.Conflicts) == 0 { + return nil + } + conflict := gui.State.Conflicts[gui.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 (gui *Gui) switchToMerging(g *gocui.Gui) error { + gui.State.ConflictIndex = 0 + gui.State.ConflictTop = true + _, err := g.SetCurrentView("main") + if err != nil { + return err + } + return gui.refreshMergePanel(g) +} + +func (gui *Gui) renderMergeOptions(g *gocui.Gui) error { + return gui.renderOptionsMap(g, map[string]string{ + "↑ ↓": "select hunk", + "← →": "navigate conflicts", + "space": "pick hunk", + "b": "pick both hunks", + "z": "undo", + }) +} + +func (gui *Gui) handleEscapeMerge(g *gocui.Gui, v *gocui.View) error { + filesView, err := g.View("files") + if err != nil { + return err + } + gui.refreshFiles(g) + return gui.switchFocus(g, v, filesView) +} + +func (gui *Gui) handleCompleteMerge(g *gocui.Gui) error { + filesView, err := g.View("files") + if err != nil { + return err + } + gui.stageSelectedFile(g) + gui.refreshFiles(g) + return gui.switchFocus(g, nil, filesView) +} diff --git a/pkg/gui/stash_panel.go b/pkg/gui/stash_panel.go new file mode 100644 index 000000000..b4fb11902 --- /dev/null +++ b/pkg/gui/stash_panel.go @@ -0,0 +1,97 @@ +package gui + +import ( + "fmt" + + "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazygit/pkg/commands" +) + +func (gui *Gui) refreshStashEntries(g *gocui.Gui) error { + g.Update(func(g *gocui.Gui) error { + v, err := g.View("stash") + if err != nil { + panic(err) + } + gui.State.StashEntries = gui.GitCommand.GetStashEntries() + v.Clear() + for _, stashEntry := range gui.State.StashEntries { + fmt.Fprintln(v, stashEntry.DisplayString) + } + return gui.resetOrigin(v) + }) + return nil +} + +func (gui *Gui) getSelectedStashEntry(v *gocui.View) *commands.StashEntry { + if len(gui.State.StashEntries) == 0 { + return nil + } + lineNumber := gui.getItemPosition(v) + return &gui.State.StashEntries[lineNumber] +} + +func (gui *Gui) renderStashOptions(g *gocui.Gui) error { + return gui.renderOptionsMap(g, map[string]string{ + "space": "apply", + "g": "pop", + "d": "drop", + "← → ↑ ↓": "navigate", + }) +} + +func (gui *Gui) handleStashEntrySelect(g *gocui.Gui, v *gocui.View) error { + if err := gui.renderStashOptions(g); err != nil { + return err + } + go func() { + stashEntry := gui.getSelectedStashEntry(v) + if stashEntry == nil { + gui.renderString(g, "main", "No stash entries") + return + } + diff, _ := gui.GitCommand.GetStashEntryDiff(stashEntry.Index) + gui.renderString(g, "main", diff) + }() + return nil +} + +func (gui *Gui) handleStashApply(g *gocui.Gui, v *gocui.View) error { + return gui.stashDo(g, v, "apply") +} + +func (gui *Gui) handleStashPop(g *gocui.Gui, v *gocui.View) error { + return gui.stashDo(g, v, "pop") +} + +func (gui *Gui) handleStashDrop(g *gocui.Gui, v *gocui.View) error { + return gui.createConfirmationPanel(g, v, "Stash drop", "Are you sure you want to drop this stash entry?", func(g *gocui.Gui, v *gocui.View) error { + return gui.stashDo(g, v, "drop") + }, nil) +} + +func (gui *Gui) stashDo(g *gocui.Gui, v *gocui.View, method string) error { + stashEntry := gui.getSelectedStashEntry(v) + if stashEntry == nil { + return gui.createErrorPanel(g, "No stash to "+method) + } + if err := gui.GitCommand.StashDo(stashEntry.Index, method); err != nil { + gui.createErrorPanel(g, err.Error()) + } + gui.refreshStashEntries(g) + return gui.refreshFiles(g) +} + +func (gui *Gui) handleStashSave(g *gocui.Gui, filesView *gocui.View) error { + if len(gui.trackedFiles()) == 0 && len(gui.stagedFiles()) == 0 { + return gui.createErrorPanel(g, "You have no tracked/staged files to stash") + } + gui.createPromptPanel(g, filesView, "Stash changes", func(g *gocui.Gui, v *gocui.View) error { + if err := gui.GitCommand.StashSave(gui.trimmedContent(v)); err != nil { + gui.createErrorPanel(g, err.Error()) + } + gui.refreshStashEntries(g) + return gui.refreshFiles(g) + }) + return nil +} diff --git a/status_panel.go b/pkg/gui/status_panel.go similarity index 54% rename from status_panel.go rename to pkg/gui/status_panel.go index f3fcb8078..67f133738 100644 --- a/status_panel.go +++ b/pkg/gui/status_panel.go @@ -1,13 +1,14 @@ -package main +package gui import ( "fmt" "github.com/fatih/color" "github.com/jesseduffield/gocui" + "github.com/jesseduffield/lazygit/pkg/utils" ) -func refreshStatus(g *gocui.Gui) error { +func (gui *Gui) refreshStatus(g *gocui.Gui) error { v, err := g.View("status") if err != nil { panic(err) @@ -17,22 +18,22 @@ func refreshStatus(g *gocui.Gui) error { // contents end up cleared g.Update(func(*gocui.Gui) error { v.Clear() - pushables, pullables := gitUpstreamDifferenceCount() + pushables, pullables := gui.GitCommand.UpstreamDifferenceCount() fmt.Fprint(v, "↑"+pushables+"↓"+pullables) - branches := state.Branches - if err := updateHasMergeConflictStatus(); err != nil { + branches := gui.State.Branches + if err := gui.updateHasMergeConflictStatus(); err != nil { return err } - if state.HasMergeConflicts { - fmt.Fprint(v, coloredString(" (merging)", color.FgYellow)) + if gui.State.HasMergeConflicts { + fmt.Fprint(v, utils.ColoredString(" (merging)", color.FgYellow)) } if len(branches) == 0 { return nil } branch := branches[0] - name := coloredString(branch.Name, branch.getColor()) - repo := getCurrentProject() + name := utils.ColoredString(branch.Name, branch.GetColor()) + repo := utils.GetCurrentRepoName() fmt.Fprint(v, " "+repo+" → "+name) return nil }) diff --git a/view_helpers.go b/pkg/gui/view_helpers.go similarity index 60% rename from view_helpers.go rename to pkg/gui/view_helpers.go index b28e84efb..331e27975 100644 --- a/view_helpers.go +++ b/pkg/gui/view_helpers.go @@ -1,4 +1,4 @@ -package main +package gui import ( "fmt" @@ -11,14 +11,14 @@ import ( var cyclableViews = []string{"files", "branches", "commits", "stash"} -func refreshSidePanels(g *gocui.Gui) error { - refreshBranches(g) - refreshFiles(g) - refreshCommits(g) +func (gui *Gui) refreshSidePanels(g *gocui.Gui) error { + gui.refreshBranches(g) + gui.refreshFiles(g) + gui.refreshCommits(g) return nil } -func nextView(g *gocui.Gui, v *gocui.View) error { +func (gui *Gui) nextView(g *gocui.Gui, v *gocui.View) error { var focusedViewName string if v == nil || v.Name() == cyclableViews[len(cyclableViews)-1] { focusedViewName = cyclableViews[0] @@ -29,7 +29,7 @@ func nextView(g *gocui.Gui, v *gocui.View) error { break } if i == len(cyclableViews)-1 { - devLog(v.Name() + " is not in the list of views") + gui.Log.Info(v.Name() + " is not in the list of views") return nil } } @@ -38,10 +38,10 @@ func nextView(g *gocui.Gui, v *gocui.View) error { if err != nil { panic(err) } - return switchFocus(g, v, focusedView) + return gui.switchFocus(g, v, focusedView) } -func previousView(g *gocui.Gui, v *gocui.View) error { +func (gui *Gui) previousView(g *gocui.Gui, v *gocui.View) error { var focusedViewName string if v == nil || v.Name() == cyclableViews[0] { focusedViewName = cyclableViews[len(cyclableViews)-1] @@ -52,7 +52,7 @@ func previousView(g *gocui.Gui, v *gocui.View) error { break } if i == len(cyclableViews)-1 { - devLog(v.Name() + " is not in the list of views") + gui.Log.Info(v.Name() + " is not in the list of views") return nil } } @@ -61,69 +61,70 @@ func previousView(g *gocui.Gui, v *gocui.View) error { if err != nil { panic(err) } - return switchFocus(g, v, focusedView) + return gui.switchFocus(g, v, focusedView) } -func newLineFocused(g *gocui.Gui, v *gocui.View) error { +func (gui *Gui) 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) + return gui.handleFileSelect(g, v) case "branches": - return handleBranchSelect(g, v) + return gui.handleBranchSelect(g, v) case "confirmation": return nil case "commitMessage": - return handleCommitFocused(g, v) + return gui.handleCommitFocused(g, v) case "main": // TODO: pull this out into a 'view focused' function - refreshMergePanel(g) + gui.refreshMergePanel(g) v.Highlight = false return nil case "commits": - return handleCommitSelect(g, v) + return gui.handleCommitSelect(g, v) case "stash": - return handleStashEntrySelect(g, v) + return gui.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) +func (gui *Gui) returnFocus(g *gocui.Gui, v *gocui.View) error { + previousView, err := g.View(gui.State.PreviousView) if err != nil { panic(err) } - return switchFocus(g, v, previousView) + return gui.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 { +func (gui *Gui) 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() + gui.Log.Info("setting previous view to:", oldView.Name()) + gui.State.PreviousView = oldView.Name() } newView.Highlight = true - devLog("new focused view is " + newView.Name()) + gui.Log.Info("new focused view is " + newView.Name()) if _, err := g.SetCurrentView(newView.Name()); err != nil { return err } g.Cursor = newView.Editable - return newLineFocused(g, newView) + return gui.newLineFocused(g, newView) } -func getItemPosition(v *gocui.View) int { +func (gui *Gui) getItemPosition(v *gocui.View) int { + gui.correctCursor(v) _, cy := v.Cursor() _, oy := v.Origin() return oy + cy } -func cursorUp(g *gocui.Gui, v *gocui.View) error { +func (gui *Gui) cursorUp(g *gocui.Gui, v *gocui.View) error { // swallowing cursor movements in main // TODO: pull this out if v == nil || v.Name() == "main" { @@ -138,11 +139,11 @@ func cursorUp(g *gocui.Gui, v *gocui.View) error { } } - newLineFocused(g, v) + gui.newLineFocused(g, v) return nil } -func cursorDown(g *gocui.Gui, v *gocui.View) error { +func (gui *Gui) cursorDown(g *gocui.Gui, v *gocui.View) error { // swallowing cursor movements in main // TODO: pull this out if v == nil || v.Name() == "main" { @@ -159,19 +160,19 @@ func cursorDown(g *gocui.Gui, v *gocui.View) error { } } - newLineFocused(g, v) + gui.newLineFocused(g, v) return nil } -func resetOrigin(v *gocui.View) error { +func (gui *Gui) 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 { +// if the cursor down past the last item, move it to the last line +func (gui *Gui) correctCursor(v *gocui.View) error { cx, cy := v.Cursor() _, oy := v.Origin() lineCount := len(v.BufferLines()) - 2 @@ -181,7 +182,7 @@ func correctCursor(v *gocui.View) error { return nil } -func renderString(g *gocui.Gui, viewName, s string) error { +func (gui *Gui) 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 @@ -197,7 +198,7 @@ func renderString(g *gocui.Gui, viewName, s string) error { return nil } -func optionsMapToString(optionsMap map[string]string) string { +func (gui *Gui) optionsMapToString(optionsMap map[string]string) string { optionsArray := make([]string, 0) for key, description := range optionsMap { optionsArray = append(optionsArray, key+": "+description) @@ -206,11 +207,11 @@ func optionsMapToString(optionsMap map[string]string) string { return strings.Join(optionsArray, ", ") } -func renderOptionsMap(g *gocui.Gui, optionsMap map[string]string) error { - return renderString(g, "options", optionsMapToString(optionsMap)) +func (gui *Gui) renderOptionsMap(g *gocui.Gui, optionsMap map[string]string) error { + return gui.renderString(g, "options", gui.optionsMapToString(optionsMap)) } -func loader() string { +func (gui *Gui) loader() string { characters := "|/-\\" now := time.Now() nanos := now.UnixNano() @@ -219,17 +220,26 @@ func loader() string { } // TODO: refactor properly -func getFilesView(g *gocui.Gui) *gocui.View { +func (gui *Gui) getFilesView(g *gocui.Gui) *gocui.View { v, _ := g.View("files") return v } -func getCommitsView(g *gocui.Gui) *gocui.View { +func (gui *Gui) getCommitsView(g *gocui.Gui) *gocui.View { v, _ := g.View("commits") return v } -func getCommitMessageView(g *gocui.Gui) *gocui.View { +func (gui *Gui) getCommitMessageView(g *gocui.Gui) *gocui.View { v, _ := g.View("commitMessage") return v } + +func (gui *Gui) trimmedContent(v *gocui.View) string { + return strings.TrimSpace(v.Buffer()) +} + +func (gui *Gui) currentViewName(g *gocui.Gui) string { + currentView := g.CurrentView() + return currentView.Name() +} diff --git a/i18n.go b/pkg/i18n/i18n.go similarity index 100% rename from i18n.go rename to pkg/i18n/i18n.go diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go new file mode 100644 index 000000000..68438246a --- /dev/null +++ b/pkg/utils/utils.go @@ -0,0 +1,65 @@ +package utils + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/fatih/color" +) + +// SplitLines takes a multiline string and splits it on newlines +// currently we are also stripping \r's which may have adverse effects for +// windows users (but no issues have been raised yet) +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 +} + +// WithPadding pads a string as much as you want +func WithPadding(str string, padding int) string { + if padding-len(str) < 0 { + return str + } + return str + strings.Repeat(" ", padding-len(str)) +} + +// ColoredString takes a string and a colour attribute and returns a colored +// string with that attribute +func ColoredString(str string, colorAttribute color.Attribute) string { + colour := color.New(colorAttribute) + return ColoredStringDirect(str, colour) +} + +// ColoredStringDirect 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)) +} + +// GetCurrentRepoName gets the repo's base name +func GetCurrentRepoName() string { + pwd, err := os.Getwd() + if err != nil { + log.Fatalln(err.Error()) + } + return filepath.Base(pwd) +} + +// TrimTrailingNewline - Trims the trailing newline +// TODO: replace with `chomp` after refactor +func TrimTrailingNewline(str string) string { + if strings.HasSuffix(str, "\n") { + return str[:len(str)-1] + } + return str +} diff --git a/stash_panel.go b/stash_panel.go deleted file mode 100644 index 33c7e297b..000000000 --- a/stash_panel.go +++ /dev/null @@ -1,93 +0,0 @@ -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 -} diff --git a/test/repos/gpg.sh b/test/repos/gpg.sh new file mode 100755 index 000000000..94e0742e5 --- /dev/null +++ b/test/repos/gpg.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -ex; rm -rf repo; mkdir repo; cd repo + +git init + +git config gpg.program $(which gpg) +git config user.signingkey E304229F # test key +git config commit.gpgsign true + +touch foo +git add foo + +touch bar +git add bar \ No newline at end of file diff --git a/test/repos/pre_commit_hook.sh b/test/repos/pre_commit_hook.sh index 8857f4145..1c24bf19f 100755 --- a/test/repos/pre_commit_hook.sh +++ b/test/repos/pre_commit_hook.sh @@ -2,7 +2,7 @@ set -ex; rm -rf repo; mkdir repo; cd repo git init -cp ../pre-commit .git/hooks/pre-commit +cp ../extras/pre-commit .git/hooks/pre-commit chmod +x .git/hooks/pre-commit echo "file" > file diff --git a/utils.go b/utils.go deleted file mode 100644 index e2de46233..000000000 --- a/utils.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "fmt" - "log" - "os" - "path/filepath" - "strings" - - "github.com/fatih/color" - "github.com/jesseduffield/gocui" -) - -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 trimmedContent(v *gocui.View) string { - return strings.TrimSpace(v.Buffer()) -} - -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) -} diff --git a/vendor/github.com/Sirupsen/logrus/LICENSE b/vendor/github.com/Sirupsen/logrus/LICENSE new file mode 100644 index 000000000..f090cb42f --- /dev/null +++ b/vendor/github.com/Sirupsen/logrus/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Simon Eskildsen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/vendor/github.com/Sirupsen/logrus/alt_exit.go b/vendor/github.com/Sirupsen/logrus/alt_exit.go new file mode 100644 index 000000000..8af90637a --- /dev/null +++ b/vendor/github.com/Sirupsen/logrus/alt_exit.go @@ -0,0 +1,64 @@ +package logrus + +// The following code was sourced and modified from the +// https://github.com/tebeka/atexit package governed by the following license: +// +// Copyright (c) 2012 Miki Tebeka