From 7653ff9a2462482d00283576eb68cbf0c5cbb596 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Sat, 9 Jun 2018 19:06:33 +1000 Subject: [PATCH] add support for merging --- branches_panel.go | 32 ++++++- commits_panel.go | 12 ++- confirmation_panel.go | 3 +- files_panel.go | 163 ++++++++++++++++++++++++-------- gitcommands.go | 40 +++++--- gui.go | 123 ++++++++++++++---------- merge_panel.go | 213 ++++++++++++++++++++++++++++++++++++++++++ stash_panel.go | 12 ++- status_panel.go | 7 ++ view_helpers.go | 66 ++++++++----- 10 files changed, 540 insertions(+), 131 deletions(-) create mode 100644 merge_panel.go diff --git a/branches_panel.go b/branches_panel.go index 58620f049..8000da2a9 100644 --- a/branches_panel.go +++ b/branches_panel.go @@ -11,7 +11,7 @@ func handleBranchPress(g *gocui.Gui, v *gocui.View) error { if output, err := gitCheckout(branch.Name, false); err != nil { createErrorPanel(g, output) } - return refreshSidePanels(g, v) + return refreshSidePanels(g) } func handleForceCheckout(g *gocui.Gui, v *gocui.View) error { @@ -20,7 +20,7 @@ func handleForceCheckout(g *gocui.Gui, v *gocui.View) error { if output, err := gitCheckout(branch.Name, true); err != nil { createErrorPanel(g, output) } - return refreshSidePanels(g, v) + return refreshSidePanels(g) }, nil) } @@ -30,20 +30,44 @@ func handleNewBranch(g *gocui.Gui, v *gocui.View) error { if output, err := gitNewBranch(trimmedContent(v)); err != nil { return createErrorPanel(g, output) } - refreshSidePanels(g, v) + refreshSidePanels(g) return handleCommitSelect(g, v) }) return 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", + }) +} + // may want to standardise how these select methods work func handleBranchSelect(g *gocui.Gui, v *gocui.View) error { - renderString(g, "options", "space: checkout, f: force checkout") + 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") } diff --git a/commits_panel.go b/commits_panel.go index 76083374e..05ca7eac5 100644 --- a/commits_panel.go +++ b/commits_panel.go @@ -60,8 +60,18 @@ func handleResetToCommit(g *gocui.Gui, commitView *gocui.View) error { }, nil) } +func renderCommitsOptions(g *gocui.Gui) error { + return renderOptionsMap(g, map[string]string{ + "s": "squash down", + "r": "rename", + "g": "reset to this commit", + }) +} + func handleCommitSelect(g *gocui.Gui, v *gocui.View) error { - renderString(g, "options", "s: squash down, r: rename, g: reset to this commit") + if err := renderCommitsOptions(g); err != nil { + return err + } commit, err := getSelectedCommit(g) if err != nil { if err != ErrNoCommits { diff --git a/confirmation_panel.go b/confirmation_panel.go index e86972eea..6bbdbbf90 100644 --- a/confirmation_panel.go +++ b/confirmation_panel.go @@ -53,7 +53,6 @@ func getMessageHeight(message string, width int) int { func getConfirmationPanelDimensions(g *gocui.Gui, prompt string) (int, int, int, int) { width, height := g.Size() panelWidth := 60 - // panelHeight := int(math.Ceil(float64(len(prompt)) / float64(panelWidth))) panelHeight := getMessageHeight(prompt, panelWidth) return width/2 - panelWidth/2, height/2 - panelHeight/2 - panelHeight%2 - 1, @@ -113,7 +112,7 @@ func setKeyBindings(g *gocui.Gui, handleYes, handleNo func(*gocui.Gui, *gocui.Vi return nil } -func createSimpleConfirmationPanel(g *gocui.Gui, currentView *gocui.View, title, prompt string) error { +func createMessagePanel(g *gocui.Gui, currentView *gocui.View, title, prompt string) error { return createConfirmationPanel(g, currentView, title, prompt, nil, nil) } diff --git a/files_panel.go b/files_panel.go index 674cae387..8e718da7f 100644 --- a/files_panel.go +++ b/files_panel.go @@ -30,7 +30,7 @@ func stagedFiles(files []GitFile) []GitFile { } func handleFilePress(g *gocui.Gui, v *gocui.View) error { - file, err := getSelectedFile(v) + file, err := getSelectedFile(g) if err != nil { return err } @@ -51,16 +51,20 @@ func handleFilePress(g *gocui.Gui, v *gocui.View) error { return nil } -func getSelectedFile(v *gocui.View) (GitFile, error) { +func getSelectedFile(g *gocui.Gui) (GitFile, error) { if len(state.GitFiles) == 0 { return GitFile{}, ErrNoFiles } - lineNumber := getItemPosition(v) + 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(v) + file, err := getSelectedFile(g) if err != nil { return err } @@ -79,7 +83,7 @@ func handleFileRemove(g *gocui.Gui, v *gocui.View) error { } func handleIgnoreFile(g *gocui.Gui, v *gocui.View) error { - file, err := getSelectedFile(v) + file, err := getSelectedFile(g) if err != nil { return err } @@ -90,30 +94,52 @@ func handleIgnoreFile(g *gocui.Gui, v *gocui.View) error { return refreshFiles(g) } +func renderfilesOptions(g *gocui.Gui, gitFile *GitFile) error { + optionsMap := map[string]string{ + "tab": "next panel", + "S": "stash files", + "c": "commit changes", + "o": "open", + "s": "open in sublime", + "i": "ignore", + "d": "delete", + "space": "toggle staged", + } + if state.HasMergeConflicts { + optionsMap["a"] = "abort merge" + optionsMap["m"] = "resolve merge conflicts" + } + if gitFile == nil { + return renderOptionsMap(g, optionsMap) + } + if gitFile.Tracked { + optionsMap["d"] = "checkout" + } + return renderOptionsMap(g, optionsMap) +} + func handleFileSelect(g *gocui.Gui, v *gocui.View) error { - baseString := "tab: next panel, S: stash files, space: toggle staged, c: commit changes, o: open, s: open in sublime, i: ignore" - item, err := getSelectedFile(v) + gitFile, err := getSelectedFile(g) if err != nil { if err != ErrNoFiles { return err } renderString(g, "main", "No changed files") colorLog(color.FgRed, "error") - return renderString(g, "options", baseString) + return renderfilesOptions(g, nil) } - var optionsString string - if item.Tracked { - optionsString = baseString + ", d: checkout" - } else { - optionsString = baseString + ", d: delete" + renderfilesOptions(g, &gitFile) + var content string + if gitFile.HasMergeConflicts { + return refreshMergePanel(g) } - renderString(g, "options", optionsString) - diff := getDiff(item) - return renderString(g, "main", diff) + + content = getDiff(gitFile) + return renderString(g, "main", content) } func handleCommitPress(g *gocui.Gui, filesView *gocui.View) error { - if len(stagedFiles(state.GitFiles)) == 0 { + if len(stagedFiles(state.GitFiles)) == 0 && !state.HasMergeConflicts { return createErrorPanel(g, "There are no staged files to commit") } createPromptPanel(g, filesView, "Commit message", func(g *gocui.Gui, v *gocui.View) error { @@ -131,7 +157,7 @@ func handleCommitPress(g *gocui.Gui, filesView *gocui.View) error { } func genericFileOpen(g *gocui.Gui, v *gocui.View, open func(string) (string, error)) error { - file, err := getSelectedFile(v) + file, err := getSelectedFile(g) if err != nil { return err } @@ -146,31 +172,64 @@ func handleSublimeFileOpen(g *gocui.Gui, v *gocui.View) error { return genericFileOpen(g, v, sublimeOpenFile) } +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 { + 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 } - - // get files to stage - gitFiles := getGitStatusFiles() - state.GitFiles = mergeGitStatusFiles(state.GitFiles, gitFiles) - + refreshStateGitFiles() filesView.Clear() - red := color.New(color.FgRed) - green := color.New(color.FgGreen) for _, gitFile := range state.GitFiles { - if !gitFile.Tracked { - red.Fprintln(filesView, gitFile.DisplayString) - continue - } - 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) - } + renderGitFile(gitFile, filesView) } correctCursor(filesView) if filesView == g.CurrentView() { @@ -181,7 +240,7 @@ func refreshFiles(g *gocui.Gui) error { func pullFiles(g *gocui.Gui, v *gocui.View) error { devLog("pulling...") - createSimpleConfirmationPanel(g, v, "", "Pulling...") + createMessagePanel(g, v, "", "Pulling...") go func() { if output, err := gitPull(); err != nil { createErrorPanel(g, output) @@ -198,7 +257,7 @@ func pullFiles(g *gocui.Gui, v *gocui.View) error { func pushFiles(g *gocui.Gui, v *gocui.View) error { devLog("pushing...") - createSimpleConfirmationPanel(g, v, "", "Pushing...") + createMessagePanel(g, v, "", "Pushing...") go func() { if output, err := gitPush(); err != nil { createErrorPanel(g, output) @@ -211,3 +270,31 @@ func pushFiles(g *gocui.Gui, v *gocui.View) error { }() 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 nil + } + 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") + return refreshFiles(g) +} diff --git a/gitcommands.go b/gitcommands.go index 7df29170a..6c01aa889 100644 --- a/gitcommands.go +++ b/gitcommands.go @@ -21,6 +21,7 @@ type GitFile struct { HasUnstagedChanges bool Tracked bool Deleted bool + HasMergeConflicts bool DisplayString string } @@ -30,7 +31,6 @@ type Branch struct { Type string BaseBranch string DisplayString string - DisplayColor color.Attribute } // Commit : A git commit @@ -133,8 +133,8 @@ func branchPropertiesFromName(name string) (string, string, color.Attribute) { return "other", name, color.FgWhite } -func coloredString(str string, colour color.Attribute) string { - return color.New(colour).SprintFunc()(fmt.Sprint(str)) +func coloredString(str string, colour *color.Color) string { + return colour.SprintFunc()(fmt.Sprint(str)) } func withPadding(str string, padding int) string { @@ -143,17 +143,17 @@ func withPadding(str string, padding int) string { func branchFromLine(line string, index int) Branch { recency, name := branchStringParts(line) - branchType, branchBase, colour := branchPropertiesFromName(name) + branchType, branchBase, colourAttr := branchPropertiesFromName(name) if index == 0 { recency = " *" } + colour := color.New(colourAttr) displayString := withPadding(recency, 4) + coloredString(name, colour) return Branch{ Name: name, Type: branchType, BaseBranch: branchBase, DisplayString: displayString, - DisplayColor: colour, } } @@ -206,10 +206,11 @@ func getGitStatusFiles() []GitFile { gitFile := GitFile{ Name: filename, DisplayString: statusString, - HasStagedChanges: tracked && stagedChange != " ", + HasStagedChanges: tracked && stagedChange != " " && stagedChange != "U", HasUnstagedChanges: !tracked || unstagedChange != " ", Tracked: tracked, Deleted: unstagedChange == "D" || stagedChange == "D", + HasMergeConflicts: statusString[0:2] == "UU", } gitFiles = append(gitFiles, gitFile) } @@ -328,14 +329,15 @@ func getDiff(file GitFile) string { trackedArg = "--no-index /dev/null " } command := "git diff --color " + cachedArg + deletedArg + trackedArg + file.Name - s, err := runCommand(command) - if err != nil { - // for now we assume an error means the file was deleted - return s - } + // 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 @@ -350,6 +352,14 @@ 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 { @@ -398,6 +408,14 @@ 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 { diff --git a/gui.go b/gui.go index 530e35e94..314bf3960 100644 --- a/gui.go +++ b/gui.go @@ -8,22 +8,38 @@ import ( "log" "time" // "strings" + "github.com/golang-collections/collections/stack" "github.com/jesseduffield/gocui" ) type stateType struct { - GitFiles []GitFile - Branches []Branch - Commits []Commit - StashEntries []StashEntry - PreviousView string + GitFiles []GitFile + Branches []Branch + Commits []Commit + StashEntries []StashEntry + PreviousView string + HasMergeConflicts bool + ConflictIndex int + ConflictTop bool + Conflicts []conflict + EditHistory *stack.Stack +} + +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), + 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(), } func scrollUpMain(g *gocui.Gui, v *gocui.View) error { @@ -44,6 +60,10 @@ func scrollDownMain(g *gocui.Gui, v *gocui.View) error { return nil } +func handleRefresh(g *gocui.Gui, v *gocui.View) error { + return refreshSidePanels(g) +} + func keybindings(g *gocui.Gui) error { if err := g.SetKeybinding("", gocui.KeyTab, gocui.ModNone, nextView); err != nil { return err @@ -66,6 +86,15 @@ func keybindings(g *gocui.Gui) error { if err := g.SetKeybinding("", gocui.KeyPgdn, gocui.ModNone, scrollDownMain); err != nil { return err } + if err := g.SetKeybinding("", 'P', gocui.ModNone, pushFiles); err != nil { + return err + } + if err := g.SetKeybinding("", 'p', gocui.ModNone, pullFiles); err != nil { + return err + } + if err := g.SetKeybinding("", 'R', gocui.ModNone, handleRefresh); err != nil { + return err + } if err := g.SetKeybinding("files", 'c', gocui.ModNone, handleCommitPress); err != nil { return err } @@ -75,24 +104,45 @@ func keybindings(g *gocui.Gui) error { if err := g.SetKeybinding("files", 'd', gocui.ModNone, handleFileRemove); err != nil { return err } + if err := g.SetKeybinding("files", 'm', gocui.ModNone, handleSwitchToMerge); err != nil { + return err + } if err := g.SetKeybinding("files", 'o', gocui.ModNone, handleFileOpen); err != nil { return err } if err := g.SetKeybinding("files", 's', gocui.ModNone, handleSublimeFileOpen); err != nil { return err } - if err := g.SetKeybinding("", 'P', gocui.ModNone, pushFiles); err != nil { - return err - } - if err := g.SetKeybinding("", 'p', gocui.ModNone, pullFiles); err != nil { - return err - } if err := g.SetKeybinding("files", 'i', gocui.ModNone, handleIgnoreFile); err != nil { return err } if err := g.SetKeybinding("files", 'S', gocui.ModNone, handleStashSave); err != nil { return err } + if err := g.SetKeybinding("files", 'a', gocui.ModNone, handleAbortMerge); err != nil { + return err + } + if err := g.SetKeybinding("main", gocui.KeyArrowUp, gocui.ModNone, handleSelectTop); err != nil { + return err + } + if err := g.SetKeybinding("main", gocui.KeyEsc, gocui.ModNone, handleEscapeMerge); err != nil { + return err + } + if err := g.SetKeybinding("main", gocui.KeyArrowDown, gocui.ModNone, handleSelectBottom); err != nil { + return err + } + if err := g.SetKeybinding("main", gocui.KeySpace, gocui.ModNone, handlePickConflict); err != nil { + return err + } + if err := g.SetKeybinding("main", gocui.KeyArrowLeft, gocui.ModNone, handleSelectPrevConflict); err != nil { + return err + } + if err := g.SetKeybinding("main", gocui.KeyArrowRight, gocui.ModNone, handleSelectNextConflict); err != nil { + return err + } + if err := g.SetKeybinding("main", 'z', gocui.ModNone, handlePopFileSnapshot); err != nil { + return err + } if err := g.SetKeybinding("branches", gocui.KeySpace, gocui.ModNone, handleBranchPress); err != nil { return err } @@ -102,6 +152,9 @@ func keybindings(g *gocui.Gui) error { if err := g.SetKeybinding("branches", 'n', gocui.ModNone, handleNewBranch); err != nil { return err } + if err := g.SetKeybinding("branches", 'm', gocui.ModNone, handleMerge); err != nil { + return err + } if err := g.SetKeybinding("commits", 's', gocui.ModNone, handleCommitSquashDown); err != nil { return err } @@ -127,12 +180,14 @@ func keybindings(g *gocui.Gui) error { } func layout(g *gocui.Gui) error { + g.Highlight = true + g.SelFgColor = gocui.AttrBold width, height := g.Size() leftSideWidth := width / 3 statusFilesBoundary := 2 - filesBranchesBoundary := height - 20 - commitsBranchesBoundary := height - 10 - commitsStashBoundary := height - 5 + filesBranchesBoundary := 2 * height / 5 // height - 20 + commitsBranchesBoundary := 3 * height / 5 // height - 10 + commitsStashBoundary := height - 5 // height - 5 optionsTop := height - 2 // hiding options if there's not enough space @@ -241,35 +296,3 @@ func run() { func quit(g *gocui.Gui, v *gocui.View) error { return gocui.ErrQuit } - -// const mcRide = " -// `.-::-` -// -/o+oossys+:. -// `+o++++++osssyys/` -// ://-:+.` .::-. . `++oyyo/-/+oooosyhy. -// `-+sy::-:::/+o+yss+-... /s++ss/:/:+osyoosydh` -// `-:/+o/:/+:/+-s/:s/o+`/++s++/:--/+shds+++yd: -// `y+/+soy:+/-o++y+yhyyyo/---/oyhddo/::od- -// .+o-``-+syysy//o:-oo+oyyyo+oyhyddds/oshy -// `:o++o+/-....-:/+oooyyh+:ooshhhhhhdddssyyy` -// .:o+/++ooosso//:::+yo.::hs+++:yhhhhdddhoyhh: -// `-/+so///+osyso-.:://++-` `:hhhdsohddhhhdddssh+ -// -+oso++ssoyys:.` ydddddddddddhho+yd+ -// `:sysssssssydh:` `-:::-..-...`ydddddddddyso++shds -// `/syyysssyyhhdd+``..://+ooo/++ssssoyddddddhho/:::oyhdhs-` -// -syyyysssyhhddhyo++++/::+/+/-:::///+sddddhs//+o+/ososyhhs+/.` -// `+hhyyyyyyyhddhs+///://///+ooo/::+o++osyhyyys+--+//o//oosyys++++:..`` -// .sddhyhyyyhddyso++/::://////+syo/:osssssyhsssoooosoo//+ossssyssooooo+++:. -// .hdhhhhhhhhhysssssysssssssyyyhddso+soyhhhsssooosyyssso+syysoososoo/++osyo/ -// -syyyyyyyyyyyyyyyyyyo/::----:shdsyo+yysyyyssssosyysos+/+++/+ooo++:/+/ooss/ -// `........----..`` odhyyyhhsysoss++oysso++s/++++syys++/:::/:+sy- -// `ydyssyysyoyyo+sysyys++s+++++ooo+osss+/+++syy -// /dysyssoyyoo+oyyshss//:---:/++++oshhysooosyh` -// .dhhhyysyyys++yyyyss+--:::/:///oshddhhyo+osy` -// yddhhyyssy+//ssyyso/-:://+ooosyhddhsoo+/+so -// +ddhhyysss+osyyysss:::/oyyhhyhddddds+///oy/ -// /dddhhyyyssysssssss+++ooyhdddddddhdyo///yyo -// /dddhyyyyyysssoo+/:-/oshhdddddddssdds+//sys -// +ddhhyyhhy/oo+/:::::+syhddddddds -hdyo++ohh` -// sddhhysyysoys/:::::osyhdddddddy` sdhsosohh: -// `dddddhhhhhhhyo:-/ossoshddddhhd- .ddyssohh/" diff --git a/merge_panel.go b/merge_panel.go new file mode 100644 index 000000000..6cf516a04 --- /dev/null +++ b/merge_panel.go @@ -0,0 +1,213 @@ +// 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" + "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" { + 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(coloredString(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, top bool) bool { + return i == conflict.middle || + i == conflict.start || + i == conflict.end || + (!top && i > conflict.start && i < conflict.middle) || + (top && i > conflict.middle && i < conflict.end) +} + +func resolveConflict(filename string, conflict conflict, top bool) error { + file, err := os.Open(filename) + 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, top) { + output += line + } + } + devLog(output) + return ioutil.WriteFile(filename, []byte(output), 0644) +} + +func pushFileSnapshot(filename string) error { + content, err := catFile(filename) + if err != nil { + return err + } + state.EditHistory.Push(content) + return nil +} + +func handlePopFileSnapshot(g *gocui.Gui, v *gocui.View) error { + colorLog(color.FgCyan, "IM HERE") + 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 handlePickConflict(g *gocui.Gui, v *gocui.View) error { + conflict := state.Conflicts[state.ConflictIndex] + gitFile, err := getSelectedFile(g) + if err != nil { + return err + } + pushFileSnapshot(gitFile.Name) + err = resolveConflict(gitFile.Name, conflict, state.ConflictTop) + 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 { + state.ConflictIndex = 0 + } 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 + } + return renderString(g, "main", content) +} + +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{ + "up/down": "pick hunk", + "left/right": "previous/next commit", + "space": "pick hunk", + "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) +} diff --git a/stash_panel.go b/stash_panel.go index ea0d9127c..5b39793c2 100644 --- a/stash_panel.go +++ b/stash_panel.go @@ -30,8 +30,18 @@ func getSelectedStashEntry(v *gocui.View) *StashEntry { return &state.StashEntries[lineNumber] } +func renderStashOptions(g *gocui.Gui) error { + return renderOptionsMap(g, map[string]string{ + "space": "apply", + "k": "pop", + "d": "drop", + }) +} + func handleStashEntrySelect(g *gocui.Gui, v *gocui.View) error { - renderString(g, "options", "space: apply, k: pop, d: drop") + if err := renderStashOptions(g); err != nil { + return err + } go func() { stashEntry := getSelectedStashEntry(v) if stashEntry == nil { diff --git a/status_panel.go b/status_panel.go index fded5995d..cbc6956a8 100644 --- a/status_panel.go +++ b/status_panel.go @@ -21,6 +21,13 @@ func refreshStatus(g *gocui.Gui) error { pushables, pullables := gitUpstreamDifferenceCount() fmt.Fprint(v, "↑"+pushables+"↓"+pullables) branches := state.Branches + if err := updateHasMergeConflictStatus(); err != nil { + return err + } + if state.HasMergeConflicts { + colour := color.New(color.FgYellow) + fmt.Fprint(v, coloredString(" (merging)", colour)) + } if len(branches) == 0 { return nil } diff --git a/view_helpers.go b/view_helpers.go index 1a312fe92..294ab93ca 100644 --- a/view_helpers.go +++ b/view_helpers.go @@ -2,16 +2,15 @@ package main import ( "fmt" + "sort" "strings" - "time" - "github.com/fatih/color" "github.com/jesseduffield/gocui" ) var cyclableViews = []string{"files", "branches", "commits", "stash"} -func refreshSidePanels(g *gocui.Gui, v *gocui.View) error { +func refreshSidePanels(g *gocui.Gui) error { refreshBranches(g) refreshFiles(g) refreshCommits(g) @@ -54,6 +53,9 @@ func newLineFocused(g *gocui.Gui, v *gocui.View) error { case "confirmation": return nil case "main": + // TODO: pull this out into a 'view focused' function + refreshMergePanel(g) + v.Highlight = false return nil case "commits": return handleCommitSelect(g, v) @@ -72,6 +74,7 @@ func returnFocus(g *gocui.Gui, v *gocui.View) error { return switchFocus(g, v, previousView) } +// pass in oldView = nil if you don't want to be able to return to your old view func switchFocus(g *gocui.Gui, oldView, newView *gocui.View) error { // we assume we'll never want to return focus to a confirmation panel i.e. // we should never stack confirmation panels @@ -100,7 +103,9 @@ func trimmedContent(v *gocui.View) string { } func cursorUp(g *gocui.Gui, v *gocui.View) error { - if v == nil { + // swallowing cursor movements in main + // TODO: pull this out + if v == nil || v.Name() == "main" { return nil } @@ -116,24 +121,20 @@ func cursorUp(g *gocui.Gui, v *gocui.View) error { return nil } -func resetOrigin(v *gocui.View) error { - if err := v.SetCursor(0, 0); err != nil { - return err - } - return v.SetOrigin(0, 0) -} - func cursorDown(g *gocui.Gui, v *gocui.View) error { - if v != nil { - cx, cy := v.Cursor() - ox, oy := v.Origin() - if cy+oy >= len(v.BufferLines())-2 { - return nil - } - if err := v.SetCursor(cx, cy+1); err != nil { - if err := v.SetOrigin(ox, oy+1); err != nil { - return err - } + // swallowing cursor movements in main + // TODO: pull this out + if v == nil || v.Name() == "main" { + return nil + } + cx, cy := v.Cursor() + ox, oy := v.Origin() + if cy+oy >= len(v.BufferLines())-2 { + return nil + } + if err := v.SetCursor(cx, cy+1); err != nil { + if err := v.SetOrigin(ox, oy+1); err != nil { + return err } } @@ -141,6 +142,13 @@ func cursorDown(g *gocui.Gui, v *gocui.View) error { return nil } +func resetOrigin(v *gocui.View) error { + if err := v.SetCursor(0, 0); err != nil { + return err + } + return v.SetOrigin(0, 0) +} + // if the cursor down past the last item, move it up one func correctCursor(v *gocui.View) error { cx, cy := v.Cursor() @@ -154,8 +162,6 @@ func correctCursor(v *gocui.View) error { func renderString(g *gocui.Gui, viewName, s string) error { g.Update(func(*gocui.Gui) error { - timeStart := time.Now() - colorLog(color.FgRed, viewName) v, err := g.View(viewName) if err != nil { panic(err) @@ -163,7 +169,6 @@ func renderString(g *gocui.Gui, viewName, s string) error { v.Clear() fmt.Fprint(v, s) v.Wrap = true - devLog("render time: ", time.Now().Sub(timeStart)) return nil }) return nil @@ -179,3 +184,16 @@ func splitLines(multilineString string) []string { } return lines } + +func optionsMapToString(optionsMap map[string]string) string { + optionsArray := make([]string, 0) + for key, description := range optionsMap { + optionsArray = append(optionsArray, key+": "+description) + } + sort.Strings(optionsArray) + return strings.Join(optionsArray, ", ") +} + +func renderOptionsMap(g *gocui.Gui, optionsMap map[string]string) error { + return renderString(g, "options", optionsMapToString(optionsMap)) +}