mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
print full urls when terminal doesn't support osc 8 hyperlinks (#479)
This commit is contained in:
@@ -489,7 +489,7 @@ Shows all branches in the stack, their ordering, PR links, and the most recent c
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-s, --short` | Compact output (branch names only) |
|
||||
| `-s, --short` | Compact one-line-per-branch output |
|
||||
| `--json` | Output stack data as JSON |
|
||||
|
||||
**Examples:**
|
||||
@@ -500,6 +500,10 @@ gh stack view --short
|
||||
gh stack view --json
|
||||
```
|
||||
|
||||
`gh stack view --short` uses OSC 8 hyperlinks for PR numbers when the terminal
|
||||
supports them. Otherwise, the full URL is shown for copy/paste. Set
|
||||
`GH_STACK_HYPERLINKS=1` or `GH_STACK_HYPERLINKS=0` to override terminal detection.
|
||||
|
||||
### `gh stack unstack`
|
||||
|
||||
Remove a stack from local tracking and unstack it on GitHub. Also available as `gh stack delete`.
|
||||
|
||||
@@ -245,6 +245,27 @@ func TestViewShort_ActiveStack(t *testing.T) {
|
||||
assert.Contains(t, output, "main")
|
||||
}
|
||||
|
||||
func TestShortPRSuffix_PlainURLFallback(t *testing.T) {
|
||||
t.Setenv("GH_STACK_HYPERLINKS", "0")
|
||||
|
||||
cfg, outR, errR := config.NewTestConfig()
|
||||
defer cfg.Out.Close()
|
||||
defer cfg.Err.Close()
|
||||
defer outR.Close()
|
||||
defer errR.Close()
|
||||
|
||||
b := stack.BranchRef{
|
||||
PullRequest: &stack.PullRequestRef{
|
||||
Number: 42,
|
||||
URL: "https://github.com/o/r/pull/42",
|
||||
},
|
||||
}
|
||||
|
||||
suffix := shortPRSuffix(cfg, b, "", "", "")
|
||||
assert.Equal(t, " #42 (https://github.com/o/r/pull/42)", suffix)
|
||||
assert.NotContains(t, suffix, "\x1b]8")
|
||||
}
|
||||
|
||||
// TestViewShort_FullyMergedStack verifies that --short output shows merged
|
||||
// branches correctly when all branches in the stack are merged.
|
||||
func TestViewShort_FullyMergedStack(t *testing.T) {
|
||||
|
||||
@@ -110,7 +110,7 @@ gh stack view [flags]
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `-s, --short` | Compact output (branch names only) |
|
||||
| `-s, --short` | Compact one-line-per-branch output |
|
||||
| `--json` | Output stack data as JSON |
|
||||
|
||||
Shows all branches in the stack, their ordering, PR links, and the most recent commit with a relative timestamp. Output is piped through a pager (respects `GIT_PAGER`, `PAGER`, or defaults to `less -R`).
|
||||
@@ -123,6 +123,10 @@ gh stack view --short
|
||||
gh stack view --json
|
||||
```
|
||||
|
||||
`gh stack view --short` uses OSC 8 hyperlinks for PR numbers when the terminal
|
||||
supports them. Otherwise, the full URL is shown for copy/paste. Set
|
||||
`GH_STACK_HYPERLINKS=1` or `GH_STACK_HYPERLINKS=0` to override terminal detection.
|
||||
|
||||
### `gh stack checkout`
|
||||
|
||||
Check out a stack by its stack number, a pull request number, a PR URL, or a branch name.
|
||||
@@ -659,6 +663,7 @@ gh stack feedback "Support for reordering branches"
|
||||
| Variable | Values | Description |
|
||||
|----------|--------|-------------|
|
||||
| `GH_STACK_THEME` | `auto` (default), `light`, `dark` | Controls the color palette of the interactive screens (`submit`, `modify`, `view`) and all colored command output. Colors adapt to your terminal background automatically; set this to force the light or dark palette when a terminal doesn't report its background (some SSH or `tmux` setups). |
|
||||
| `GH_STACK_HYPERLINKS` | `0`, `1` | Disables or enables OSC 8 hyperlinks when terminal detection is incorrect. Unsupported terminals show the full URL by default. |
|
||||
|
||||
```sh
|
||||
# Force the light palette for one command
|
||||
|
||||
@@ -3,6 +3,8 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/go-gh/v2/pkg/repository"
|
||||
"github.com/cli/go-gh/v2/pkg/term"
|
||||
@@ -87,6 +89,47 @@ func New() *Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
func supportsHyperlinks(isTTY bool) bool {
|
||||
switch strings.ToLower(os.Getenv("GH_STACK_HYPERLINKS")) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
}
|
||||
if !isTTY {
|
||||
return false
|
||||
}
|
||||
|
||||
termName := strings.ToLower(os.Getenv("TERM"))
|
||||
if termName == "dumb" ||
|
||||
os.Getenv("TMUX") != "" ||
|
||||
os.Getenv("STY") != "" ||
|
||||
strings.HasPrefix(termName, "screen") ||
|
||||
strings.HasPrefix(termName, "tmux") {
|
||||
return false
|
||||
}
|
||||
|
||||
switch strings.ToLower(os.Getenv("TERM_PROGRAM")) {
|
||||
case "alacritty", "ghostty", "hyper", "iterm.app", "mintty", "rio", "tabby", "vscode", "warpterminal", "wezterm":
|
||||
return true
|
||||
}
|
||||
|
||||
if version, err := strconv.Atoi(os.Getenv("VTE_VERSION")); err == nil && version >= 5000 {
|
||||
return true
|
||||
}
|
||||
if os.Getenv("WT_SESSION") != "" ||
|
||||
os.Getenv("KITTY_WINDOW_ID") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, supported := range []string{"alacritty", "contour", "foot", "ghostty", "kitty", "wezterm"} {
|
||||
if strings.Contains(termName, supported) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Config) Successf(format string, args ...any) {
|
||||
fmt.Fprintf(c.Err, "%s %s\n", c.ColorSuccess("\u2713"), fmt.Sprintf(format, args...))
|
||||
}
|
||||
@@ -111,16 +154,18 @@ func (c *Config) Outf(format string, args ...any) {
|
||||
fmt.Fprintf(c.Out, format, args...)
|
||||
}
|
||||
|
||||
// PRLink formats a PR number as a clickable, underlined terminal hyperlink.
|
||||
// Falls back to plain "#N" when color is disabled.
|
||||
// PRLink formats a PR number as a clickable terminal hyperlink when supported,
|
||||
// or includes the full URL as a copyable fallback.
|
||||
func (c *Config) PRLink(number int, url string) string {
|
||||
hyperlinksEnabled := supportsHyperlinks(c.Terminal.IsTerminalOutput())
|
||||
label := fmt.Sprintf("#%d", number)
|
||||
if c.Terminal.IsColorEnabled() {
|
||||
if url != "" {
|
||||
// OSC 8 hyperlink
|
||||
label = fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, label)
|
||||
if url != "" {
|
||||
if !hyperlinksEnabled {
|
||||
return fmt.Sprintf("%s (%s)", label, url)
|
||||
}
|
||||
// Underline
|
||||
label = fmt.Sprintf("\033]8;;%s\033\\%s\033]8;;\033\\", url, label)
|
||||
}
|
||||
if c.Terminal.IsColorEnabled() {
|
||||
label = fmt.Sprintf("\033[4m%s\033[24m", label)
|
||||
}
|
||||
return label
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSupportsHyperlinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isTTY bool
|
||||
env map[string]string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "force enabled without TTY",
|
||||
env: map[string]string{"GH_STACK_HYPERLINKS": "1"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "force disabled in supported terminal",
|
||||
isTTY: true,
|
||||
env: map[string]string{"GH_STACK_HYPERLINKS": "0", "TERM_PROGRAM": "iTerm.app"},
|
||||
},
|
||||
{
|
||||
name: "supported terminal without TTY",
|
||||
env: map[string]string{"TERM_PROGRAM": "iTerm.app"},
|
||||
},
|
||||
{
|
||||
name: "tmux overrides outer terminal",
|
||||
isTTY: true,
|
||||
env: map[string]string{"TMUX": "/tmp/tmux-501/default,1,0", "TERM_PROGRAM": "iTerm.app"},
|
||||
},
|
||||
{
|
||||
name: "Apple Terminal unsupported",
|
||||
isTTY: true,
|
||||
env: map[string]string{"TERM_PROGRAM": "Apple_Terminal"},
|
||||
},
|
||||
{
|
||||
name: "unknown terminal unsupported",
|
||||
isTTY: true,
|
||||
env: map[string]string{"TERM": "xterm-256color"},
|
||||
},
|
||||
{
|
||||
name: "Konsole defaults unsupported",
|
||||
isTTY: true,
|
||||
env: map[string]string{"KONSOLE_VERSION": "210401"},
|
||||
},
|
||||
{
|
||||
name: "iTerm supported",
|
||||
isTTY: true,
|
||||
env: map[string]string{"TERM_PROGRAM": "iTerm.app"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "VTE 0.50 supported",
|
||||
isTTY: true,
|
||||
env: map[string]string{"VTE_VERSION": "5000"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Windows Terminal supported",
|
||||
isTTY: true,
|
||||
env: map[string]string{"WT_SESSION": "session-id"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "kitty supported",
|
||||
isTTY: true,
|
||||
env: map[string]string{"TERM": "xterm-kitty"},
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
envVars := []string{
|
||||
"GH_STACK_HYPERLINKS",
|
||||
"KITTY_WINDOW_ID",
|
||||
"KONSOLE_VERSION",
|
||||
"STY",
|
||||
"TERM",
|
||||
"TERM_PROGRAM",
|
||||
"TMUX",
|
||||
"VTE_VERSION",
|
||||
"WT_SESSION",
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
for _, name := range envVars {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
for name, value := range tt.env {
|
||||
t.Setenv(name, value)
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.want, supportsHyperlinks(tt.isTTY))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRLinkFormatting(t *testing.T) {
|
||||
const url = "https://github.com/o/r/pull/42"
|
||||
tests := []struct {
|
||||
name string
|
||||
forceHyperlink string
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "OSC 8 hyperlink",
|
||||
forceHyperlink: "1",
|
||||
url: url,
|
||||
want: "\x1b]8;;https://github.com/o/r/pull/42\x1b\\#42\x1b]8;;\x1b\\",
|
||||
},
|
||||
{
|
||||
name: "plain URL fallback",
|
||||
forceHyperlink: "0",
|
||||
url: url,
|
||||
want: "#42 (https://github.com/o/r/pull/42)",
|
||||
},
|
||||
{
|
||||
name: "missing URL",
|
||||
forceHyperlink: "0",
|
||||
want: "#42",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("GH_STACK_HYPERLINKS", tt.forceHyperlink)
|
||||
cfg := &Config{}
|
||||
assert.Equal(t, tt.want, cfg.PRLink(42, tt.url))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user