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/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"),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue