feat(browse): add browse shortcut group to open repo pages in a browser
Signed-off-by: 林晨 (Leo Cheng) <chengkelfan@qq.com>
This commit is contained in:
parent
9749a4c832
commit
50bcf5bf68
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Browse Shortcut
|
||||||
|
|
||||||
|
新增 `browse` Shortcut 组,在浏览器中打开仓库的各类页面,对标 `gh browse`:
|
||||||
|
|
||||||
|
- `browse +repo`
|
||||||
|
- `browse +issue --number <n>`
|
||||||
|
- `browse +pr --number <n>`
|
||||||
|
- `browse +commit --sha <sha>`
|
||||||
|
- `browse +branch [--name <branch>]`
|
||||||
|
- `browse +file --path <path> [--ref <branch>]`
|
||||||
|
- `browse +releases`
|
||||||
|
- `browse +wiki`
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
- Web 地址由配置的 `base_url` 推导(去掉 `/api` 后缀),因此自建实例同样适用。
|
||||||
|
- `-n/--no-browser` 只打印地址而不打开浏览器,便于脚本取用与 CI 环境。
|
||||||
|
- 浏览器启动优先使用 `$BROWSER`,否则回退到平台默认(Windows/macOS/Linux)。
|
||||||
|
|
||||||
|
同时补充了 `internal/browser` 跨平台启动器与 URL 构造的单元测试。
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
// Package browser opens URLs in the user's default web browser.
|
||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Command resolves the executable and arguments used to open url on goos.
|
||||||
|
// A non-empty browserEnv (the $BROWSER value) overrides the platform default,
|
||||||
|
// so users on headless or non-standard setups can point at their own launcher.
|
||||||
|
func Command(goos, browserEnv, url string) (string, []string) {
|
||||||
|
if fields := strings.Fields(browserEnv); len(fields) > 0 {
|
||||||
|
return fields[0], append(fields[1:], url)
|
||||||
|
}
|
||||||
|
switch goos {
|
||||||
|
case "windows":
|
||||||
|
return "rundll32", []string{"url.dll,FileProtocolHandler", url}
|
||||||
|
case "darwin":
|
||||||
|
return "open", []string{url}
|
||||||
|
default:
|
||||||
|
return "xdg-open", []string{url}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open launches the default browser pointed at url without blocking on it.
|
||||||
|
func Open(url string) error {
|
||||||
|
name, args := Command(runtime.GOOS, os.Getenv("BROWSER"), url)
|
||||||
|
return exec.Command(name, args...).Start()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCommandPlatformDefaults(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
goos string
|
||||||
|
wantName string
|
||||||
|
wantArgs []string
|
||||||
|
}{
|
||||||
|
{"windows", "rundll32", []string{"url.dll,FileProtocolHandler", "https://x"}},
|
||||||
|
{"darwin", "open", []string{"https://x"}},
|
||||||
|
{"linux", "xdg-open", []string{"https://x"}},
|
||||||
|
{"freebsd", "xdg-open", []string{"https://x"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
name, args := Command(c.goos, "", "https://x")
|
||||||
|
if name != c.wantName || !reflect.DeepEqual(args, c.wantArgs) {
|
||||||
|
t.Errorf("Command(%q) = %q %v, want %q %v", c.goos, name, args, c.wantName, c.wantArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandBrowserEnvOverridesPlatform(t *testing.T) {
|
||||||
|
name, args := Command("linux", "firefox", "https://x")
|
||||||
|
if name != "firefox" || !reflect.DeepEqual(args, []string{"https://x"}) {
|
||||||
|
t.Fatalf("got %q %v", name, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandBrowserEnvWithFlags(t *testing.T) {
|
||||||
|
name, args := Command("windows", "chrome --incognito", "https://x")
|
||||||
|
if name != "chrome" || !reflect.DeepEqual(args, []string{"--incognito", "https://x"}) {
|
||||||
|
t.Fatalf("got %q %v", name, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommandBlankBrowserEnvFallsBack(t *testing.T) {
|
||||||
|
name, _ := Command("darwin", " ", "https://x")
|
||||||
|
if name != "open" {
|
||||||
|
t.Fatalf("blank BROWSER should fall back to platform default, got %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
package browse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gitlink-org/gitlink-cli/internal/browser"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Shortcuts() []*common.Shortcut {
|
||||||
|
return []*common.Shortcut{
|
||||||
|
{
|
||||||
|
Name: "repo",
|
||||||
|
Description: "Open the repository home page in a browser",
|
||||||
|
Flags: []common.Flag{noBrowserFlag},
|
||||||
|
Run: page(func(r repoRef) string {
|
||||||
|
return r.url()
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "issue",
|
||||||
|
Description: "Open an issue in a browser",
|
||||||
|
Flags: []common.Flag{numberFlag, noBrowserFlag},
|
||||||
|
Run: pageWithArg("number", func(r repoRef, n string) string {
|
||||||
|
return r.url("issues", n)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "pr",
|
||||||
|
Description: "Open a pull request in a browser",
|
||||||
|
Flags: []common.Flag{numberFlag, noBrowserFlag},
|
||||||
|
Run: pageWithArg("number", func(r repoRef, n string) string {
|
||||||
|
return r.url("pulls", n)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "commit",
|
||||||
|
Description: "Open a commit in a browser",
|
||||||
|
Flags: []common.Flag{{Name: "sha", Usage: "Commit SHA", Required: true}, noBrowserFlag},
|
||||||
|
Run: pageWithArg("sha", func(r repoRef, sha string) string {
|
||||||
|
return r.url("commits", sha)
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "branch",
|
||||||
|
Description: "Open a branch's code tree, or the branch list when omitted",
|
||||||
|
Flags: []common.Flag{{Name: "name", Usage: "Branch name"}, noBrowserFlag},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
r, err := resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if name := ctx.Arg("name"); name != "" {
|
||||||
|
return emit(ctx, r.url("tree", name))
|
||||||
|
}
|
||||||
|
return emit(ctx, r.url("branches"))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "file",
|
||||||
|
Description: "Open a file or directory in a browser",
|
||||||
|
Flags: []common.Flag{
|
||||||
|
{Name: "path", Usage: "File or directory path", Required: true},
|
||||||
|
{Name: "ref", Usage: "Branch, tag, or commit", Default: "master"},
|
||||||
|
noBrowserFlag,
|
||||||
|
},
|
||||||
|
Run: func(ctx *common.RuntimeContext) error {
|
||||||
|
r, err := resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path, err := ctx.RequireArg("path")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return emit(ctx, r.url("tree", ctx.Arg("ref"), path))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "releases",
|
||||||
|
Description: "Open the releases page in a browser",
|
||||||
|
Flags: []common.Flag{noBrowserFlag},
|
||||||
|
Run: page(func(r repoRef) string { return r.url("releases") }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "wiki",
|
||||||
|
Description: "Open the wiki in a browser",
|
||||||
|
Flags: []common.Flag{noBrowserFlag},
|
||||||
|
Run: page(func(r repoRef) string { return r.url("wiki") }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
noBrowserFlag = common.Flag{Name: "no-browser", Short: "n", Usage: "Print the URL instead of opening a browser", Bool: true}
|
||||||
|
numberFlag = common.Flag{Name: "number", Short: "N", Usage: "Issue or pull request number", Required: true}
|
||||||
|
)
|
||||||
|
|
||||||
|
// repoRef builds web URLs for a resolved owner/repo against a web host.
|
||||||
|
type repoRef struct {
|
||||||
|
host string
|
||||||
|
owner string
|
||||||
|
repo string
|
||||||
|
}
|
||||||
|
|
||||||
|
// url joins the repo web root with url-escaped path segments. Multi-segment
|
||||||
|
// inputs (a file path or a branch like "feat/x") keep their slashes.
|
||||||
|
func (r repoRef) url(segs ...string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(r.host)
|
||||||
|
for _, seg := range append([]string{r.owner, r.repo}, segs...) {
|
||||||
|
for _, part := range strings.Split(strings.Trim(seg, "/"), "/") {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteByte('/')
|
||||||
|
b.WriteString(url.PathEscape(part))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolve(ctx *common.RuntimeContext) (repoRef, error) {
|
||||||
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||||
|
return repoRef{}, err
|
||||||
|
}
|
||||||
|
return repoRef{host: webRoot(ctx.Client.BaseURL), owner: ctx.Owner, repo: ctx.Repo}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// webRoot turns an API base URL into the web host it fronts.
|
||||||
|
func webRoot(baseURL string) string {
|
||||||
|
return strings.TrimSuffix(strings.TrimRight(baseURL, "/"), "/api")
|
||||||
|
}
|
||||||
|
|
||||||
|
func emit(ctx *common.RuntimeContext, target string) error {
|
||||||
|
if ctx.Arg("no-browser") != "true" {
|
||||||
|
// A launch failure (headless host, no browser) is not fatal: the URL is
|
||||||
|
// still printed below so the caller can open it themselves.
|
||||||
|
_ = browser.Open(target)
|
||||||
|
}
|
||||||
|
return ctx.OutputData(map[string]interface{}{"url": target})
|
||||||
|
}
|
||||||
|
|
||||||
|
func page(build func(repoRef) string) func(*common.RuntimeContext) error {
|
||||||
|
return func(ctx *common.RuntimeContext) error {
|
||||||
|
r, err := resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return emit(ctx, build(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageWithArg(flag string, build func(repoRef, string) string) func(*common.RuntimeContext) error {
|
||||||
|
return func(ctx *common.RuntimeContext) error {
|
||||||
|
r, err := resolve(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v, err := ctx.RequireArg(flag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return emit(ctx, build(r, v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package browse
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestWebRoot(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"https://www.gitlink.org.cn/api": "https://www.gitlink.org.cn",
|
||||||
|
"https://www.gitlink.org.cn/api/": "https://www.gitlink.org.cn",
|
||||||
|
"http://localhost:3000/api": "http://localhost:3000",
|
||||||
|
"https://example.com": "https://example.com",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := webRoot(in); got != want {
|
||||||
|
t.Errorf("webRoot(%q) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoRefURL(t *testing.T) {
|
||||||
|
r := repoRef{host: "https://www.gitlink.org.cn", owner: "Gitlink", repo: "gitlink-cli"}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
got string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"home", r.url(), "https://www.gitlink.org.cn/Gitlink/gitlink-cli"},
|
||||||
|
{"issue", r.url("issues", "42"), "https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues/42"},
|
||||||
|
{"pr", r.url("pulls", "262"), "https://www.gitlink.org.cn/Gitlink/gitlink-cli/pulls/262"},
|
||||||
|
{"commit", r.url("commits", "abc123"), "https://www.gitlink.org.cn/Gitlink/gitlink-cli/commits/abc123"},
|
||||||
|
{"branch tree", r.url("tree", "master"), "https://www.gitlink.org.cn/Gitlink/gitlink-cli/tree/master"},
|
||||||
|
{"releases", r.url("releases"), "https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if c.got != c.want {
|
||||||
|
t.Errorf("%s: got %q, want %q", c.name, c.got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoRefURLMultiSegmentAndEscaping(t *testing.T) {
|
||||||
|
r := repoRef{host: "https://www.gitlink.org.cn", owner: "Gitlink", repo: "gitlink-cli"}
|
||||||
|
|
||||||
|
// A file path keeps its slashes across segments.
|
||||||
|
if got, want := r.url("tree", "master", "skills/README.md"),
|
||||||
|
"https://www.gitlink.org.cn/Gitlink/gitlink-cli/tree/master/skills/README.md"; got != want {
|
||||||
|
t.Errorf("file path: got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A branch name with a slash is preserved, not collapsed.
|
||||||
|
if got, want := r.url("tree", "feat/browse"),
|
||||||
|
"https://www.gitlink.org.cn/Gitlink/gitlink-cli/tree/feat/browse"; got != want {
|
||||||
|
t.Errorf("slash branch: got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A path segment with a space is percent-escaped.
|
||||||
|
if got, want := r.url("tree", "master", "my dir/file.txt"),
|
||||||
|
"https://www.gitlink.org.cn/Gitlink/gitlink-cli/tree/master/my%20dir/file.txt"; got != want {
|
||||||
|
t.Errorf("escaping: got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
|
|
||||||
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
"github.com/gitlink-org/gitlink-cli/internal/i18n"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
|
||||||
|
"github.com/gitlink-org/gitlink-cli/shortcuts/browse"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||||
"github.com/gitlink-org/gitlink-cli/shortcuts/compare"
|
"github.com/gitlink-org/gitlink-cli/shortcuts/compare"
|
||||||
|
|
@ -47,6 +48,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
|
||||||
"profile": profile.Shortcuts(tr),
|
"profile": profile.Shortcuts(tr),
|
||||||
"release": release.Shortcuts(tr),
|
"release": release.Shortcuts(tr),
|
||||||
"branch": branch.Shortcuts(tr),
|
"branch": branch.Shortcuts(tr),
|
||||||
|
"browse": browse.Shortcuts(),
|
||||||
"org": org.Shortcuts(tr),
|
"org": org.Shortcuts(tr),
|
||||||
"user": user.Shortcuts(tr),
|
"user": user.Shortcuts(tr),
|
||||||
"search": search.Shortcuts(tr),
|
"search": search.Shortcuts(tr),
|
||||||
|
|
@ -72,6 +74,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) {
|
||||||
"profile": tr.T("cmd.profile.short"),
|
"profile": tr.T("cmd.profile.short"),
|
||||||
"release": tr.T("cmd.release.short"),
|
"release": tr.T("cmd.release.short"),
|
||||||
"branch": tr.T("cmd.branch.short"),
|
"branch": tr.T("cmd.branch.short"),
|
||||||
|
"browse": "Open repository pages in a browser",
|
||||||
"org": tr.T("cmd.org.short"),
|
"org": tr.T("cmd.org.short"),
|
||||||
"user": tr.T("cmd.user.short"),
|
"user": tr.T("cmd.user.short"),
|
||||||
"search": tr.T("cmd.search.short"),
|
"search": tr.T("cmd.search.short"),
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ func TestRegisterAll(t *testing.T) {
|
||||||
|
|
||||||
expectedGroups := []string{
|
expectedGroups := []string{
|
||||||
"repo", "issue", "label", "license", "pr", "profile", "release", "branch",
|
"repo", "issue", "label", "license", "pr", "profile", "release", "branch",
|
||||||
"org", "user", "search", "ci", "workflow",
|
"browse", "org", "user", "search", "ci", "workflow",
|
||||||
"compare", "member", "milestone", "pipeline", "webhook",
|
"compare", "member", "milestone", "pipeline", "webhook",
|
||||||
"dataset", "health", "ignore", "wiki",
|
"dataset", "health", "ignore", "wiki",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue