Files
github__gh-stack/internal/tui/submitview/render.go
Sameen Karim f880f0d469 Stack number as primary identifier (#178)
* Support addressing a stack by its stack number

checkout now interprets a bare integer as a stack number first (the
identifier shown in the github.com stack UI), falling back to a locally
tracked PR number, then a PR number discovered from GitHub, then a branch
name. A new checkoutStackByNumber resolves the stack via GetStack and
checks out its top-most unmerged branch; the reconcile/import logic is
shared with the PR-number path.

unstack gains an optional <stack-number> positional argument to unstack a
specific locally tracked stack instead of the current one.

Copilot-Session: 03673c26-a245-42da-93ed-dfcebc92a740

* Surface the stack number in output and TUIs

Show the human-facing stack number wherever it is known:
- Append a "(stack #N)" label to submit, link, checkout, and unstack
  success messages.
- Add a "Stack #N" header line to the view command (short and static)
  and the stackview TUI header.
- Add a "Stack #N" info line to the submit TUI header when submitting
  an already-created stack.

Copilot-Session: 03673c26-a245-42da-93ed-dfcebc92a740

* Update docs and agent instructions for the new API

- cli.md: document checkout/unstack by stack number and drop the
  "PATs are not supported" note (any gh-authenticated user can now run
  stack operations).
- quick-start.md: drop the PAT-not-supported note.
- AGENTS.md / copilot-instructions.md: ClientOps is now 13 methods over
  the public Stacks REST API; remove the TokenForHostFn test hook; note
  the stack file's id/number identity.
- SKILL.md: add checkout/unstack-by-stack-number quick references.

Copilot-Session: 03673c26-a245-42da-93ed-dfcebc92a740

* address review comments
2026-07-15 12:07:44 -04:00

184 lines
5.3 KiB
Go

package submitview
import (
"fmt"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/github/gh-stack/internal/tui/shared"
)
// Chrome styles shared across the submit views.
var (
stackInfoStyle = lipgloss.NewStyle().Foreground(shared.ColorTextMuted)
)
// headerHeight returns the number of screen rows the shared header occupies, or
// 0 when the terminal is too small to show it.
func (m Model) headerHeight() int {
if shared.ShouldShowHeader(m.width, m.height) {
return shared.HeaderHeightFor(m.buildHeaderConfig())
}
return 0
}
// renderHeader renders the shared gh-stack header (GitHub art, title, stack
// info, and keyboard shortcuts), matching `gh stack view` and `gh stack
// modify`. It returns "" when the terminal is too small for the header.
func (m Model) renderHeader() string {
if !shared.ShouldShowHeader(m.width, m.height) {
return ""
}
var b strings.Builder
shared.RenderHeader(&b, m.buildHeaderConfig(), m.width, m.height)
return strings.TrimSuffix(b.String(), "\n")
}
// buildHeaderConfig assembles the shared header configuration: title, stack
// info lines (including the consequence summary), and keyboard shortcuts.
func (m Model) buildHeaderConfig() shared.HeaderConfig {
repo := m.repoLabel
if repo == "" {
repo = "unknown"
}
infoLines := make([]shared.HeaderInfoLine, 0, 3)
if m.stackNumber > 0 {
infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "◆", Label: fmt.Sprintf("Stack #%d • %s", m.stackNumber, repo)})
} else {
infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "◆", Label: "Repo: " + repo})
}
infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "○", Label: "Base: " + m.trunk.Branch})
// Third line mirrors the modify header's pending line: a solid yellow square
// with the count when PRs will be created, or an empty square otherwise.
newCount := 0
for _, n := range m.nodes {
if n.State == StateNew && n.Included {
newCount++
}
}
if newCount > 0 {
yellowStyle := lipgloss.NewStyle().Foreground(shared.ColorYellow)
prWord := "PRs"
if newCount == 1 {
prWord = "PR"
}
infoLines = append(infoLines, shared.HeaderInfoLine{
Icon: "■",
Label: fmt.Sprintf("Creating %d %s", newCount, prWord),
IconStyle: &yellowStyle,
})
} else {
infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "□", Label: "No pending PRs"})
}
return shared.HeaderConfig{
ShowArt: true,
Title: "Submit Stack",
Subtitle: "v" + m.version,
InfoLines: infoLines,
ShortcutColumns: 1,
Shortcuts: m.headerShortcuts(),
}
}
// headerShortcuts returns the six primary single-screen keyboard shortcuts shown
// in the header (the help overlay lists the full set). When the STACK action is
// offered (no stack yet and every new PR deselected), the submit hint is swapped
// for the "^b stack PRs" action, which is the relevant control in that state.
func (m Model) headerShortcuts() []shared.ShortcutEntry {
primary := shared.ShortcutEntry{Key: "^s", Desc: "submit PRs"}
if m.canStackExistingPRs() {
primary = shared.ShortcutEntry{Key: "^b", Desc: "stack PRs"}
}
return []shared.ShortcutEntry{
{Key: "↑↓", Desc: "select branch"},
{Key: "tab", Desc: "cycle field"},
{Key: "^x", Desc: "skip/include"},
primary,
{Key: "^h", Desc: "help"},
{Key: "esc", Desc: "quit"},
}
}
// renderClosedBanner renders a slim full-width red banner directly under the
// header when the stack contains a closed PR, or "" otherwise.
func (m Model) renderClosedBanner() string {
closed := ClosedBranches(m.nodes)
if len(closed) == 0 {
return ""
}
verb := "branch has"
if len(closed) > 1 {
verb = "branches have"
}
msg := fmt.Sprintf(" %s %d %s a closed PR (%s) — reopen it, or unstack and recreate.",
RenderDot(StateClosed), len(closed), verb, strings.Join(closed, ", "))
if lipgloss.Width(msg) > m.width {
msg = truncateVisible(msg, m.width)
}
return calloutErrorStyle.Render(msg)
}
// composeLR places left and right content on one line of the given width with
// the right content flush to the right edge.
func composeLR(left, right string, width int) string {
lw := lipgloss.Width(left)
rw := lipgloss.Width(right)
gap := width - lw - rw
if gap < 1 {
gap = 1
// Truncate the left side so the right stays visible.
if lw > width-rw-1 {
left = truncateVisible(left, width-rw-1)
}
}
return left + strings.Repeat(" ", gap) + right
}
// truncateVisible truncates a possibly-styled string to at most maxWidth
// visible columns, appending an ellipsis when it had to cut. It resets styling
// at the cut so trailing ANSI does not leak.
func truncateVisible(s string, maxWidth int) string {
if maxWidth <= 0 {
return ""
}
if lipgloss.Width(s) <= maxWidth {
return s
}
var b strings.Builder
width := 0
inEscape := false
for _, r := range s {
if r == '\x1b' {
inEscape = true
}
if inEscape {
b.WriteRune(r)
if r == 'm' {
inEscape = false
}
continue
}
if width >= maxWidth-1 {
b.WriteString("…")
b.WriteString("\x1b[0m")
break
}
b.WriteRune(r)
width++
}
return b.String()
}
// contentHeight returns the number of lines available for the two panels,
// between the header and the reserved status line.
func (m Model) contentHeight() int {
h := m.height - m.headerHeight() - 1 // status line
if h < 1 {
h = 1
}
return h
}