diff --git a/command/issue.go b/command/issue.go index 6a5820309..315ce6aac 100644 --- a/command/issue.go +++ b/command/issue.go @@ -3,6 +3,7 @@ package command import ( "fmt" "io" + "net/url" "regexp" "strconv" "strings" @@ -308,6 +309,8 @@ func issueCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("the '%s/%s' repository has disabled issues", baseRepo.RepoOwner(), baseRepo.RepoName()) } + action := SubmitAction + title, err := cmd.Flags().GetString("title") if err != nil { return errors.Wrap(err, "could not parse title") @@ -325,8 +328,11 @@ func issueCreate(cmd *cobra.Command, args []string) error { return errors.Wrap(err, "could not collect title and/or body") } - if tb == nil { - // editing was canceled, we can just leave + action = tb.Action + + if tb.Action == CancelAction { + fmt.Fprintln(cmd.ErrOrStderr(), "Discarding.") + return nil } @@ -337,17 +343,38 @@ func issueCreate(cmd *cobra.Command, args []string) error { body = tb.Body } } - params := map[string]interface{}{ - "title": title, - "body": body, + + if action == PreviewAction { + openURL := fmt.Sprintf( + "https://github.com/%s/%s/issues/new/?title=%s&body=%s", + baseRepo.RepoOwner(), + baseRepo.RepoName(), + url.QueryEscape(title), + url.QueryEscape(body), + ) + // TODO could exceed max url length for explorer + url, err := url.Parse(openURL) + if err != nil { + return err + } + fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s%s in your browser.\n", url.Host, url.Path) + return utils.OpenInBrowser(openURL) + } else if action == SubmitAction { + params := map[string]interface{}{ + "title": title, + "body": body, + } + + newIssue, err := api.IssueCreate(apiClient, repo, params) + if err != nil { + return err + } + + fmt.Fprintln(cmd.OutOrStdout(), newIssue.URL) + } else { + panic("Unreachable state") } - newIssue, err := api.IssueCreate(apiClient, repo, params) - if err != nil { - return err - } - - fmt.Fprintln(cmd.OutOrStdout(), newIssue.URL) return nil } diff --git a/command/pr_create.go b/command/pr_create.go index 6cacaa451..a72dff7ff 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -2,8 +2,7 @@ package command import ( "fmt" - "os" - "runtime" + "net/url" "github.com/github/gh-cli/api" "github.com/github/gh-cli/context" @@ -22,13 +21,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { return err } if ucc > 0 { - noun := "change" - if ucc > 1 { - // TODO: use pluralize helper - noun = noun + "s" - } - - fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %d uncommitted %s\n", ucc, noun) + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change")) } repo, err := ctx.BaseRepo() @@ -51,6 +44,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { return err } if target == "" { + // TODO use default branch target = "master" } @@ -79,6 +73,8 @@ func prCreate(cmd *cobra.Command, _ []string) error { return errors.Wrap(err, "could not parse body") } + action := SubmitAction + interactive := title == "" || body == "" if interactive { @@ -93,8 +89,10 @@ func prCreate(cmd *cobra.Command, _ []string) error { return errors.Wrap(err, "could not collect title and/or body") } - if tb == nil { - // editing was canceled, we can just leave + action = tb.Action + + if action == CancelAction { + fmt.Fprintln(cmd.ErrOrStderr(), "Discarding.") return nil } @@ -125,21 +123,44 @@ func prCreate(cmd *cobra.Command, _ []string) error { return errors.Wrap(err, "could not parse draft") } - params := map[string]interface{}{ - "title": title, - "body": body, - "draft": isDraft, - "baseRefName": base, - "headRefName": head, + if action == SubmitAction { + params := map[string]interface{}{ + "title": title, + "body": body, + "draft": isDraft, + "baseRefName": base, + "headRefName": head, + } + + pr, err := api.CreatePullRequest(client, repo, params) + if err != nil { + return errors.Wrap(err, "failed to create pull request") + } + + fmt.Fprintln(cmd.OutOrStdout(), pr.URL) + } else if action == PreviewAction { + openURL := fmt.Sprintf( + "https://github.com/%s/%s/compare/%s...%s?expand=1&title=%s&body=%s", + repo.RepoOwner(), + repo.RepoName(), + target, + head, + url.QueryEscape(title), + url.QueryEscape(body), + ) + // TODO could exceed max url length for explorer + url, err := url.Parse(openURL) + if err != nil { + return err + } + fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s%s in your browser.\n", url.Host, url.Path) + return utils.OpenInBrowser(openURL) + } else { + panic("Unreachable state") } - pr, err := api.CreatePullRequest(client, repo, params) - if err != nil { - return errors.Wrap(err, "failed to create pull request") - } - - fmt.Fprintln(cmd.OutOrStdout(), pr.URL) return nil + } func guessRemote(ctx context.Context) (string, error) { @@ -158,19 +179,6 @@ func guessRemote(ctx context.Context) (string, error) { return remote.Name, nil } -func determineEditor() string { - if runtime.GOOS == "windows" { - return "notepad" - } - if v := os.Getenv("VISUAL"); v != "" { - return v - } - if e := os.Getenv("EDITOR"); e != "" { - return e - } - return "nano" -} - var prCreateCmd = &cobra.Command{ Use: "create", Short: "Create a pull request", diff --git a/command/title_body_survey.go b/command/title_body_survey.go index 180fdd11f..2b11c326f 100644 --- a/command/title_body_survey.go +++ b/command/title_body_survey.go @@ -1,26 +1,28 @@ package command import ( - "fmt" - "github.com/AlecAivazis/survey/v2" "github.com/github/gh-cli/pkg/githubtemplate" + "github.com/github/gh-cli/pkg/surveyext" "github.com/pkg/errors" "github.com/spf13/cobra" ) +type Action int + type titleBody struct { - Body string - Title string + Body string + Title string + Action Action } const ( - _confirmed = iota - _unconfirmed = iota - _cancel = iota + PreviewAction Action = iota + SubmitAction + CancelAction ) -func confirm() (int, error) { +func confirm() (Action, error) { confirmAnswers := struct { Confirmation int }{} @@ -28,10 +30,10 @@ func confirm() (int, error) { { Name: "confirmation", Prompt: &survey.Select{ - Message: "Submit?", + Message: "What's next?", Options: []string{ - "Yes", - "Edit", + "Preview in browser", + "Submit", "Cancel", }, }, @@ -43,7 +45,7 @@ func confirm() (int, error) { return -1, errors.Wrap(err, "could not prompt") } - return confirmAnswers.Confirmation, nil + return Action(confirmAnswers.Confirmation), nil } func selectTemplate(templatePaths []string) (string, error) { @@ -85,59 +87,45 @@ func titleBodySurvey(cmd *cobra.Command, providedTitle string, providedBody stri inProgress.Body = templateContents } - confirmed := false - editor := determineEditor() - - for !confirmed { - titleQuestion := &survey.Question{ - Name: "title", - Prompt: &survey.Input{ - Message: "Title", - Default: inProgress.Title, - }, - } - bodyQuestion := &survey.Question{ - Name: "body", - Prompt: &survey.Editor{ - Message: fmt.Sprintf("Body (%s)", editor), + titleQuestion := &survey.Question{ + Name: "title", + Prompt: &survey.Input{ + Message: "Title", + Default: inProgress.Title, + }, + } + bodyQuestion := &survey.Question{ + Name: "body", + Prompt: &surveyext.GhEditor{ + Editor: &survey.Editor{ + Message: "Body", FileName: "*.md", Default: inProgress.Body, HideDefault: true, AppendDefault: true, - Editor: editor, }, - } - - qs := []*survey.Question{} - if providedTitle == "" { - qs = append(qs, titleQuestion) - } - if providedBody == "" { - qs = append(qs, bodyQuestion) - } - - err := survey.Ask(qs, &inProgress) - if err != nil { - return nil, errors.Wrap(err, "could not prompt") - } - - confirmA, err := confirm() - if err != nil { - return nil, errors.Wrap(err, "unable to confirm") - } - switch confirmA { - case _confirmed: - confirmed = true - case _unconfirmed: - continue - case _cancel: - fmt.Fprintln(cmd.ErrOrStderr(), "Discarding.") - return nil, nil - default: - panic("reached unreachable case") - } - + }, } + qs := []*survey.Question{} + if providedTitle == "" { + qs = append(qs, titleQuestion) + } + if providedBody == "" { + qs = append(qs, bodyQuestion) + } + + err := survey.Ask(qs, &inProgress) + if err != nil { + return nil, errors.Wrap(err, "could not prompt") + } + + confirmA, err := confirm() + if err != nil { + return nil, errors.Wrap(err, "unable to confirm") + } + + inProgress.Action = confirmA + return &inProgress, nil } diff --git a/pkg/surveyext/editor.go b/pkg/surveyext/editor.go new file mode 100644 index 000000000..a0c3d29a3 --- /dev/null +++ b/pkg/surveyext/editor.go @@ -0,0 +1,198 @@ +package surveyext + +// This file extends survey.Editor to give it more flexible behavior. For more context, read +// https://github.com/github/gh-cli/issues/70 +// To see what we extended, search through for EXTENDED comments. + +import ( + "bytes" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/AlecAivazis/survey/v2" + "github.com/AlecAivazis/survey/v2/terminal" + shellquote "github.com/kballard/go-shellquote" +) + +var ( + bom = []byte{0xef, 0xbb, 0xbf} + editor = "nano" // EXTENDED to switch from vim as a default editor +) + +func init() { + if runtime.GOOS == "windows" { + editor = "notepad" + } + if v := os.Getenv("VISUAL"); v != "" { + editor = v + } + if e := os.Getenv("EDITOR"); e != "" { + editor = e + } +} + +// EXTENDED to enable different prompting behavior +type GhEditor struct { + *survey.Editor +} + +// EXTENDED to change prompt text +var EditorQuestionTemplate = ` +{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}} +{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}} +{{- color "default+hb"}}{{ .Message }} {{color "reset"}} +{{- if .ShowAnswer}} + {{- color "cyan"}}{{.Answer}}{{color "reset"}}{{"\n"}} +{{- else }} + {{- if and .Help (not .ShowHelp)}}{{color "cyan"}}[{{ .Config.HelpInput }} for help]{{color "reset"}} {{end}} + {{- if and .Default (not .HideDefault)}}{{color "white"}}({{.Default}}) {{color "reset"}}{{end}} + {{- color "cyan"}}[(e) to launch {{ .EditorName }}, enter to skip] {{color "reset"}} +{{- end}}` + +// EXTENDED to pass editor name (to use in prompt) +type EditorTemplateData struct { + survey.Editor + EditorName string + Answer string + ShowAnswer bool + ShowHelp bool + Config *survey.PromptConfig +} + +// EXTENDED to augment prompt text and keypress handling +func (e *GhEditor) prompt(initialValue string, config *survey.PromptConfig) (interface{}, error) { + err := e.Render( + EditorQuestionTemplate, + // EXTENDED to support printing editor in prompt + EditorTemplateData{ + Editor: *e.Editor, + EditorName: filepath.Base(editor), + Config: config, + }, + ) + if err != nil { + return "", err + } + + // start reading runes from the standard in + rr := e.NewRuneReader() + rr.SetTermMode() + defer rr.RestoreTermMode() + + cursor := e.NewCursor() + cursor.Hide() + defer cursor.Show() + + for { + // EXTENDED to handle the e to edit / enter to skip behavior + r, _, err := rr.ReadRune() + if err != nil { + return "", err + } + if r == 'e' { + break + } + if r == '\r' || r == '\n' { + return "", nil + } + if r == terminal.KeyInterrupt { + return "", terminal.InterruptErr + } + if r == terminal.KeyEndTransmission { + break + } + if string(r) == config.HelpInput && e.Help != "" { + err = e.Render( + EditorQuestionTemplate, + EditorTemplateData{ + // EXTENDED to support printing editor in prompt + Editor: *e.Editor, + EditorName: filepath.Base(editor), + ShowHelp: true, + Config: config, + }, + ) + if err != nil { + return "", err + } + } + continue + } + + // prepare the temp file + pattern := e.FileName + if pattern == "" { + pattern = "survey*.txt" + } + f, err := ioutil.TempFile("", pattern) + if err != nil { + return "", err + } + defer os.Remove(f.Name()) + + // write utf8 BOM header + // The reason why we do this is because notepad.exe on Windows determines the + // encoding of an "empty" text file by the locale, for example, GBK in China, + // while golang string only handles utf8 well. However, a text file with utf8 + // BOM header is not considered "empty" on Windows, and the encoding will then + // be determined utf8 by notepad.exe, instead of GBK or other encodings. + if _, err := f.Write(bom); err != nil { + return "", err + } + + // write initial value + if _, err := f.WriteString(initialValue); err != nil { + return "", err + } + + // close the fd to prevent the editor unable to save file + if err := f.Close(); err != nil { + return "", err + } + + stdio := e.Stdio() + + args, err := shellquote.Split(editor) + if err != nil { + return "", err + } + args = append(args, f.Name()) + + // open the editor + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdin = stdio.In + cmd.Stdout = stdio.Out + cmd.Stderr = stdio.Err + cursor.Show() + if err := cmd.Run(); err != nil { + return "", err + } + + // raw is a BOM-unstripped UTF8 byte slice + raw, err := ioutil.ReadFile(f.Name()) + if err != nil { + return "", err + } + + // strip BOM header + text := string(bytes.TrimPrefix(raw, bom)) + + // check length, return default value on empty + if len(text) == 0 && !e.AppendDefault { + return e.Default, nil + } + + return text, nil +} + +// EXTENDED This is straight copypasta from survey to get our overriden prompt called.; +func (e *GhEditor) Prompt(config *survey.PromptConfig) (interface{}, error) { + initialValue := "" + if e.Default != "" && e.AppendDefault { + initialValue = e.Default + } + return e.prompt(initialValue, config) +}