mirror of
				https://github.com/jesseduffield/lazygit.git
				synced 2025-10-30 23:57:43 +02:00 
			
		
		
		
	add support for merging
This commit is contained in:
		| @@ -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") | ||||
|   } | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										163
									
								
								files_panel.go
									
									
									
									
									
								
							
							
						
						
									
										163
									
								
								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) | ||||
| } | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
							
								
								
									
										123
									
								
								gui.go
									
									
									
									
									
								
							
							
						
						
									
										123
									
								
								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/" | ||||
|   | ||||
							
								
								
									
										213
									
								
								merge_panel.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										213
									
								
								merge_panel.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||
| } | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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 | ||||
|     } | ||||
|   | ||||
| @@ -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)) | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user