mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
f880f0d469
* 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
184 lines
5.3 KiB
Go
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
|
|
}
|