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:
林晨 (Leo Cheng) 2026-07-08 03:06:54 +08:00
parent 9749a4c832
commit 50bcf5bf68
No known key found for this signature in database
GPG Key ID: 24FCF87A069356B9
7 changed files with 329 additions and 1 deletions

View File

@ -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 构造的单元测试。

View File

@ -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()
}

View File

@ -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)
}
}

167
shortcuts/browse/browse.go Normal file
View File

@ -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))
}
}

View File

@ -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)
}
}

View File

@ -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"),

View File

@ -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",
}