diff --git a/doc/changes/browse-shortcut.md b/doc/changes/browse-shortcut.md new file mode 100644 index 0000000..cad2716 --- /dev/null +++ b/doc/changes/browse-shortcut.md @@ -0,0 +1,20 @@ +# Browse Shortcut + +新增 `browse` Shortcut 组,在浏览器中打开仓库的各类页面,对标 `gh browse`: + +- `browse +repo` +- `browse +issue --number ` +- `browse +pr --number ` +- `browse +commit --sha ` +- `browse +branch [--name ]` +- `browse +file --path [--ref ]` +- `browse +releases` +- `browse +wiki` + +说明: + +- Web 地址由配置的 `base_url` 推导(去掉 `/api` 后缀),因此自建实例同样适用。 +- `-n/--no-browser` 只打印地址而不打开浏览器,便于脚本取用与 CI 环境。 +- 浏览器启动优先使用 `$BROWSER`,否则回退到平台默认(Windows/macOS/Linux)。 + +同时补充了 `internal/browser` 跨平台启动器与 URL 构造的单元测试。 diff --git a/internal/browser/browser.go b/internal/browser/browser.go new file mode 100644 index 0000000..71ed6be --- /dev/null +++ b/internal/browser/browser.go @@ -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() +} diff --git a/internal/browser/browser_test.go b/internal/browser/browser_test.go new file mode 100644 index 0000000..46edf18 --- /dev/null +++ b/internal/browser/browser_test.go @@ -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) + } +} diff --git a/shortcuts/browse/browse.go b/shortcuts/browse/browse.go new file mode 100644 index 0000000..01037d6 --- /dev/null +++ b/shortcuts/browse/browse.go @@ -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)) + } +} diff --git a/shortcuts/browse/browse_test.go b/shortcuts/browse/browse_test.go new file mode 100644 index 0000000..d662366 --- /dev/null +++ b/shortcuts/browse/browse_test.go @@ -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) + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 1fedc7e..e00cffa 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -5,6 +5,7 @@ import ( "github.com/gitlink-org/gitlink-cli/internal/i18n" "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/common" "github.com/gitlink-org/gitlink-cli/shortcuts/compare" @@ -47,6 +48,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "profile": profile.Shortcuts(tr), "release": release.Shortcuts(tr), "branch": branch.Shortcuts(tr), + "browse": browse.Shortcuts(), "org": org.Shortcuts(tr), "user": user.Shortcuts(tr), "search": search.Shortcuts(tr), @@ -72,6 +74,7 @@ func RegisterAll(root *cobra.Command, translators ...*i18n.Translator) { "profile": tr.T("cmd.profile.short"), "release": tr.T("cmd.release.short"), "branch": tr.T("cmd.branch.short"), + "browse": "Open repository pages in a browser", "org": tr.T("cmd.org.short"), "user": tr.T("cmd.user.short"), "search": tr.T("cmd.search.short"), diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 00f4c57..94259e7 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -12,7 +12,7 @@ func TestRegisterAll(t *testing.T) { expectedGroups := []string{ "repo", "issue", "label", "license", "pr", "profile", "release", "branch", - "org", "user", "search", "ci", "workflow", + "browse", "org", "user", "search", "ci", "workflow", "compare", "member", "milestone", "pipeline", "webhook", "dataset", "health", "ignore", "wiki", }