diff --git a/README.md b/README.md index fe4aaac..a8825a8 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,9 @@ $ gh sub-pattern -r main --tags v | jq -R . | jq -s . Without any target flag and with a terminal attached, the extension lists the repository's environments, branches, tags, `pull_request` and derived prefix globs (e.g. `release/*`) in -[fzf](https://github.com/junegunn/fzf). Pick one, or type your own ref/glob and press enter. -Without fzf it falls back to gh's built-in selector (no free-text input). +[fzf](https://github.com/junegunn/fzf). Mark several candidates with Tab, or type your own +ref/glob; Enter prints one pattern per selection. Without fzf it falls back to gh's built-in +multi-select (no free-text input). ## Subject format diff --git a/main.go b/main.go index d34f7c1..76f24fd 100644 --- a/main.go +++ b/main.go @@ -82,15 +82,17 @@ func run(cli CLI) error { if !term.IsTerminal(os.Stdin) { return errors.New("no --environment, --ref(s) or --tag(s) given and stdin is not a terminal") } - choice, err := pick(repo.Host, fullName) + choices, err := pick(repo.Host, fullName) if err != nil { return err } - suffix, err := parseChoice(choice) - if err != nil { - return err + for _, c := range choices { + suffix, err := parseChoice(c) + if err != nil { + return err + } + subs = append(subs, suffix) } - subs = []string{suffix} } for _, s := range subs { fmt.Fprintln(stdout, "repo:"+repoSegment(info, immutable)+":"+s) @@ -183,7 +185,7 @@ func candidates(envs, branches, tags []string) []string { return out } -func pick(host, fullName string) (string, error) { +func pick(host, fullName string) ([]string, error) { list := func(path, jq string) []string { out, _, err := gh.Exec("api", "--hostname", host, "--paginate", "repos/"+fullName+"/"+path, "--jq", jq) if err != nil { @@ -200,16 +202,23 @@ func pick(host, fullName string) (string, error) { return fzf(items) } // shortcut: no free-text glob without fzf; add prompter.Input if non-fzf users need it. - idx, err := prompter.New(os.Stdin, os.Stderr, os.Stderr).Select("sub for "+fullName, "", items) + idxs, err := prompter.New(os.Stdin, os.Stderr, os.Stderr).MultiSelect("sub for "+fullName, nil, items) if err != nil { - return "", err + return nil, err + } + if len(idxs) == 0 { + return nil, errors.New("nothing selected") + } + picked := make([]string, len(idxs)) + for i, idx := range idxs { + picked[i] = items[idx] } - return items[idx], nil + return picked, nil } -func fzf(items []string) (string, error) { - cmd := exec.Command("fzf", "--print-query", "--exact", "--no-multi", "--prompt", "sub> ", - "--header", "Pick a candidate or type a ref/glob (e.g. release/*) and press enter") +func fzf(items []string) ([]string, error) { + cmd := exec.Command("fzf", "--print-query", "--exact", "--multi", "--prompt", "sub> ", + "--header", "Tab marks several candidates; or type a ref/glob (e.g. release/*). Enter to finish") cmd.Stdin = strings.NewReader(strings.Join(items, "\n")) cmd.Stderr = os.Stderr out, err := cmd.Output() @@ -218,11 +227,11 @@ func fzf(items []string) (string, error) { noMatch := errors.As(err, &exit) && exit.ExitCode() == 1 switch { case err == nil && len(lines) > 1: - return lines[1], nil + return lines[1:], nil case (err == nil || noMatch) && lines[0] != "": - return lines[0], nil // free-text query + return lines[:1], nil // free-text query default: - return "", errors.New("selection cancelled") + return nil, errors.New("selection cancelled") } } diff --git a/run_test.go b/run_test.go index 319ce0c..1c3190e 100644 --- a/run_test.go +++ b/run_test.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "path/filepath" + "slices" "strings" "testing" @@ -127,17 +128,19 @@ func TestResolveRepoCurrent(t *testing.T) { func TestFzf(t *testing.T) { for name, tc := range map[string]struct { - script, want string - wantErr bool + script string + want []string + wantErr bool }{ - "selection": {`printf 'ma\nref:refs/heads/main\n'`, "ref:refs/heads/main", false}, - "free text": {`printf 'release/2.x/*\n'; exit 1`, "release/2.x/*", false}, - "cancelled": {`exit 130`, "", true}, + "selection": {`printf 'ma\nref:refs/heads/main\n'`, []string{"ref:refs/heads/main"}, false}, + "multi": {`printf '\npull_request\nref:refs/tags/*\n'`, []string{"pull_request", "ref:refs/tags/*"}, false}, + "free text": {`printf 'release/2.x/*\n'; exit 1`, []string{"release/2.x/*"}, false}, + "cancelled": {`exit 130`, nil, true}, } { t.Run(name, func(t *testing.T) { fakeBin(t, "fzf", "cat >/dev/null\n"+tc.script) got, err := fzf([]string{"ref:refs/heads/main"}) - if (err != nil) != tc.wantErr || got != tc.want { + if (err != nil) != tc.wantErr || !slices.Equal(got, tc.want) { t.Errorf("got %q, %v", got, err) } }) @@ -154,7 +157,7 @@ esac`)) fakeBin(t, "fzf", `cat > "`+cands+`"; printf '\nref:refs/heads/release/*\n'`) got, err := pick("github.com", "octo-org/octo-repo") - if err != nil || got != "ref:refs/heads/release/*" { + if err != nil || !slices.Equal(got, []string{"ref:refs/heads/release/*"}) { t.Fatalf("got %q, %v", got, err) } listed, _ := os.ReadFile(cands)