diff --git a/gitcommands.go b/gitcommands.go index 0a7e9c5a5..a7bc62172 100644 --- a/gitcommands.go +++ b/gitcommands.go @@ -3,6 +3,7 @@ package main import ( // "log" + "errors" "fmt" "os" "os/exec" @@ -13,6 +14,7 @@ import ( ) // GitFile : A staged/unstaged file +// TODO: decide whether to give all of these the Git prefix type GitFile struct { Name string HasStagedChanges bool @@ -39,6 +41,13 @@ type Commit struct { DisplayString string } +// StashEntry : A git stash entry +type StashEntry struct { + Index int + Name string + DisplayString string +} + func devLog(objects ...interface{}) { localLog(color.FgWhite, "/Users/jesseduffieldduffield/go/src/github.com/jesseduffield/gitgot/development.log", objects...) } @@ -178,13 +187,34 @@ func getGitBranches() []Branch { return branches } rawString, _ := runDirectCommand(getBranchesCommand) - branchLines := splitLines(rawString) - for i, line := range branchLines { + for i, line := range splitLines(rawString) { branches = append(branches, branchFromLine(line, i)) } return branches } +// 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 getGitStatusFiles() []GitFile { statusOutput, _ := getGitStatus() statusStrings := splitLines(statusOutput) @@ -208,6 +238,23 @@ func getGitStatusFiles() []GitFile { 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 { @@ -369,6 +416,10 @@ func gitNewBranch(name string) (string, error) { return runDirectCommand("git checkout -b " + name) } +func gitListStash() (string, error) { + return runDirectCommand("git stash list") +} + func gitUpstreamDifferenceCount() (string, string) { pushableCount, err := runDirectCommand("git rev-list @{u}..head --count") if err != nil { @@ -378,7 +429,7 @@ func gitUpstreamDifferenceCount() (string, string) { if err != nil { return "?", "?" } - return strings.Trim(pushableCount, " \n"), strings.Trim(pullableCount, " \n") + return strings.TrimSpace(pushableCount), strings.TrimSpace(pullableCount) } func gitCommitsToPush() []string { diff --git a/gui.go b/gui.go index ef3e910ba..90ad0a16e 100644 --- a/gui.go +++ b/gui.go @@ -15,6 +15,7 @@ type stateType struct { GitFiles []GitFile Branches []Branch Commits []Commit + StashEntries []StashEntry PreviousView string } @@ -22,9 +23,10 @@ var state = stateType{ GitFiles: make([]GitFile, 0), PreviousView: "files", Commits: make([]Commit, 0), + StashEntries: make([]StashEntry, 0), } -var cyclableViews = []string{"files", "branches", "commits"} +var cyclableViews = []string{"files", "branches", "commits", "stash"} func refreshSidePanels(g *gocui.Gui, v *gocui.View) error { refreshBranches(g) @@ -66,14 +68,14 @@ func newLineFocused(g *gocui.Gui, v *gocui.View) error { return handleFileSelect(g, v) case "branches": return handleBranchSelect(g, v) - case "commit": - return handleCommitPromptFocus(g, v) case "confirmation": return nil case "main": return nil case "commits": return handleCommitSelect(g, v) + case "stash": + return handleStashEntrySelect(g, v) default: panic("No view matching newLineFocused switch statement") } @@ -125,7 +127,7 @@ func keybindings(g *gocui.Gui) error { if err := g.SetKeybinding("files", gocui.KeySpace, gocui.ModNone, handleFilePress); err != nil { return err } - if err := g.SetKeybinding("files", 'r', gocui.ModNone, handleFileRemove); err != nil { + if err := g.SetKeybinding("files", 'd', gocui.ModNone, handleFileRemove); err != nil { return err } if err := g.SetKeybinding("files", 'o', gocui.ModNone, handleFileOpen); err != nil { @@ -143,10 +145,7 @@ func keybindings(g *gocui.Gui) error { if err := g.SetKeybinding("files", 'i', gocui.ModNone, handleIgnoreFile); err != nil { return err } - if err := g.SetKeybinding("commit", gocui.KeyEsc, gocui.ModNone, closeCommitPrompt); err != nil { - return err - } - if err := g.SetKeybinding("commit", gocui.KeyEnter, gocui.ModNone, handleCommitSubmit); err != nil { + if err := g.SetKeybinding("files", 'S', gocui.ModNone, handleStashSave); err != nil { return err } if err := g.SetKeybinding("branches", gocui.KeySpace, gocui.ModNone, handleBranchPress); err != nil { @@ -167,9 +166,21 @@ func keybindings(g *gocui.Gui) error { if err := g.SetKeybinding("commits", 'g', gocui.ModNone, handleResetToCommit); err != nil { return err } - if err := g.SetKeybinding("", 'S', gocui.ModNone, genericTest); err != nil { + if err := g.SetKeybinding("stash", gocui.KeySpace, gocui.ModNone, handleStashApply); err != nil { return err } + // TODO: come up with a better keybinding (p/P used for pushing/pulling which + // I'd like to be global. Perhaps all global keybindings should use a modifier + // like command? But then there's gonna be hotkey conflicts with the terminal + if err := g.SetKeybinding("stash", 'k', gocui.ModNone, handleStashPop); err != nil { + return err + } + if err := g.SetKeybinding("stash", 'd', gocui.ModNone, handleStashDrop); err != nil { + return err + } + // if err := g.SetKeybinding("", 'S', gocui.ModNone, genericTest); err != nil { + // return err + // } return nil } @@ -181,9 +192,10 @@ func genericTest(g *gocui.Gui, v *gocui.View) error { func layout(g *gocui.Gui) error { width, height := g.Size() leftSideWidth := width / 3 - logsBranchesBoundary := height - 10 - filesBranchesBoundary := height - 20 statusFilesBoundary := 2 + filesBranchesBoundary := height - 20 + commitsBranchesBoundary := height - 10 + commitsStashBoundary := height - 5 optionsTop := height - 2 // hiding options if there's not enough space @@ -191,14 +203,13 @@ func layout(g *gocui.Gui) error { optionsTop = height - 1 } - sideView, err := g.SetView("files", 0, statusFilesBoundary+1, leftSideWidth, filesBranchesBoundary-1) + filesView, err := g.SetView("files", 0, statusFilesBoundary+1, leftSideWidth, filesBranchesBoundary-1) if err != nil { if err != gocui.ErrUnknownView { return err } - sideView.Highlight = true - sideView.Title = "Files" - refreshFiles(g) + filesView.Highlight = true + filesView.Title = "Files" } if v, err := g.SetView("status", 0, statusFilesBoundary-2, leftSideWidth, statusFilesBoundary); err != nil { @@ -208,35 +219,36 @@ func layout(g *gocui.Gui) error { v.Title = "Status" } - if v, err := g.SetView("main", leftSideWidth+1, 0, width-1, optionsTop); err != nil { + mainView, err := g.SetView("main", leftSideWidth+1, 0, width-1, optionsTop) + if err != nil { if err != gocui.ErrUnknownView { return err } - v.Title = "Diff" - v.Wrap = true - switchFocus(g, nil, v) - handleFileSelect(g, sideView) + mainView.Title = "Diff" + mainView.Wrap = true } - if v, err := g.SetView("branches", 0, filesBranchesBoundary, leftSideWidth, logsBranchesBoundary-1); err != nil { + if v, err := g.SetView("branches", 0, filesBranchesBoundary, leftSideWidth, commitsBranchesBoundary-1); err != nil { if err != gocui.ErrUnknownView { return err } v.Title = "Branches" - // these are only called once - refreshBranches(g) - nextView(g, nil) } - if v, err := g.SetView("commits", 0, logsBranchesBoundary, leftSideWidth, optionsTop); err != nil { + if v, err := g.SetView("commits", 0, commitsBranchesBoundary, leftSideWidth, commitsStashBoundary-1); err != nil { if err != gocui.ErrUnknownView { return err } v.Title = "Commits" - // these are only called once - refreshCommits(g) + } + + if v, err := g.SetView("stash", 0, commitsStashBoundary, leftSideWidth, optionsTop); err != nil { + if err != gocui.ErrUnknownView { + return err + } + v.Title = "Stash" } if v, err := g.SetView("options", -1, optionsTop, width, optionsTop+2); err != nil { @@ -246,6 +258,14 @@ func layout(g *gocui.Gui) error { v.BgColor = gocui.ColorBlue v.Frame = false v.Title = "Options" + + // these are only called once + handleFileSelect(g, filesView) + refreshFiles(g) + refreshBranches(g) + refreshCommits(g) + refreshStashEntries(g) + nextView(g, nil) } return nil diff --git a/stash_panel.go b/stash_panel.go new file mode 100644 index 000000000..ea0d9127c --- /dev/null +++ b/stash_panel.go @@ -0,0 +1,83 @@ +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 handleStashEntrySelect(g *gocui.Gui, v *gocui.View) error { + renderString(g, "options", "space: apply, k: pop, d: drop") + 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? (y/n)", func(g *gocui.Gui, v *gocui.View) error { + return stashDo(g, v, "drop") + }, nil) + return 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/view_helpers.go b/view_helpers.go index aa89e9254..c17506b54 100644 --- a/view_helpers.go +++ b/view_helpers.go @@ -18,7 +18,9 @@ func returnFocus(g *gocui.Gui, v *gocui.View) error { } func switchFocus(g *gocui.Gui, oldView, newView *gocui.View) error { - if oldView != nil { + // 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() @@ -38,6 +40,10 @@ func getItemPosition(v *gocui.View) int { return oy + cy } +func trimmedContent(v *gocui.View) string { + return strings.TrimSpace(v.Buffer()) +} + func cursorUp(g *gocui.Gui, v *gocui.View) error { if v == nil { return nil @@ -94,6 +100,7 @@ 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)