Files
github__gh-stack/internal/tui/stackview/model.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

483 lines
13 KiB
Go

package stackview
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/github/gh-stack/internal/stack"
"github.com/github/gh-stack/internal/tui/shared"
)
// keyMap defines the key bindings for the stack view.
type keyMap struct {
Up key.Binding
Down key.Binding
ToggleCommits key.Binding
ToggleFiles key.Binding
OpenPR key.Binding
Checkout key.Binding
Quit key.Binding
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Up, k.Down, k.ToggleCommits, k.ToggleFiles, k.OpenPR, k.Checkout, k.Quit}
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{k.ShortHelp()}
}
var keys = keyMap{
Up: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("↑", "up"),
),
Down: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("↓", "down"),
),
ToggleCommits: key.NewBinding(
key.WithKeys("c"),
key.WithHelp("c", "commits"),
),
ToggleFiles: key.NewBinding(
key.WithKeys("f"),
key.WithHelp("f", "files"),
),
OpenPR: key.NewBinding(
key.WithKeys("o"),
key.WithHelp("o", "open PR"),
),
Checkout: key.NewBinding(
key.WithKeys("enter"),
key.WithHelp("enter", "checkout"),
),
Quit: key.NewBinding(
key.WithKeys("q", "esc", "ctrl+c"),
key.WithHelp("q", "quit"),
),
}
// Model is the Bubbletea model for the interactive stack view.
type Model struct {
nodes []BranchNode
trunk stack.BranchRef
version string
stackNumber int
cursor int // index into nodes (displayed top-down, so 0 = top of stack)
help help.Model
width int
height int
// scrollOffset tracks vertical scroll position for tall stacks.
scrollOffset int
// checkoutBranch is set when the user wants to checkout a branch after quitting.
checkoutBranch string
}
// New creates a new stack view model. stackNumber is the human-facing stack
// number shown in the header; pass 0 when it is not known.
func New(nodes []BranchNode, trunk stack.BranchRef, version string, stackNumber int) Model {
h := help.New()
h.ShowAll = true
// Cursor starts at the current branch, or the first non-merged branch.
// When every branch is merged there is nothing selectable, so the cursor
// is hidden (-1) and the cursor-dependent shortcuts are disabled.
cursor := -1
for i, n := range nodes {
if n.IsCurrent && !n.Ref.IsMerged() {
cursor = i
break
}
}
if cursor < 0 {
for i, n := range nodes {
if !n.Ref.IsMerged() {
cursor = i
break
}
}
}
return Model{
nodes: nodes,
trunk: trunk,
version: version,
stackNumber: stackNumber,
cursor: cursor,
help: h,
}
}
// CheckoutBranch returns the branch to checkout after the TUI exits, if any.
func (m Model) CheckoutBranch() string {
return m.checkoutBranch
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.help.Width = msg.Width
return m, nil
case tea.KeyMsg:
switch {
case key.Matches(msg, keys.Quit):
return m, tea.Quit
case key.Matches(msg, keys.Up):
m.moveCursor(-1)
return m, nil
case key.Matches(msg, keys.Down):
m.moveCursor(1)
return m, nil
case key.Matches(msg, keys.ToggleCommits):
if m.cursor >= 0 && m.cursor < len(m.nodes) {
m.nodes[m.cursor].CommitsExpanded = !m.nodes[m.cursor].CommitsExpanded
m.clampScroll()
m.ensureVisible()
}
return m, nil
case key.Matches(msg, keys.ToggleFiles):
if m.cursor >= 0 && m.cursor < len(m.nodes) {
m.nodes[m.cursor].FilesExpanded = !m.nodes[m.cursor].FilesExpanded
m.clampScroll()
m.ensureVisible()
}
return m, nil
case key.Matches(msg, keys.OpenPR):
if m.cursor >= 0 && m.cursor < len(m.nodes) {
node := m.nodes[m.cursor]
if node.PR != nil && node.PR.URL != "" {
shared.OpenBrowserInBackground(node.PR.URL)
}
}
return m, nil
case key.Matches(msg, keys.Checkout):
if m.cursor >= 0 && m.cursor < len(m.nodes) {
node := m.nodes[m.cursor]
if !node.IsCurrent && !node.Ref.IsMerged() {
m.checkoutBranch = node.Ref.Branch
return m, tea.Quit
}
}
return m, nil
}
case tea.MouseMsg:
switch msg.Action {
case tea.MouseActionPress:
if msg.Button == tea.MouseButtonLeft {
return m.handleMouseClick(msg.X, msg.Y)
}
if msg.Button == tea.MouseButtonWheelUp {
if m.scrollOffset > 0 {
m.scrollOffset--
}
return m, nil
}
if msg.Button == tea.MouseButtonWheelDown {
m.scrollOffset++
m.clampScroll()
return m, nil
}
}
}
return m, nil
}
// toBranchNodeData converts a BranchNode to shared.BranchNodeData.
func toBranchNodeData(node BranchNode) shared.BranchNodeData {
return shared.BranchNodeData{
Ref: node.Ref,
IsCurrent: node.IsCurrent,
IsLinear: node.IsLinear,
BaseBranch: node.BaseBranch,
Commits: node.Commits,
FilesChanged: node.FilesChanged,
PR: node.PR,
Additions: node.Additions,
Deletions: node.Deletions,
CommitsExpanded: node.CommitsExpanded,
FilesExpanded: node.FilesExpanded,
ShowCurrentLabel: true,
}
}
// handleMouseClick processes a mouse click at the given screen position.
func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) {
nodes := make([]shared.BranchNodeData, len(m.nodes))
for i, n := range m.nodes {
nodes[i] = toBranchNodeData(n)
}
result := shared.HandleClick(screenX, screenY, nodes, m.width, m.height, m.scrollOffset, m.headerHeight(), true)
if result.NodeIndex < 0 {
return m, nil
}
// Don't allow selecting merged branches.
if m.nodes[result.NodeIndex].Ref.IsMerged() {
return m, nil
}
m.cursor = result.NodeIndex
if result.OpenURL != "" {
shared.OpenBrowserInBackground(result.OpenURL)
}
if result.ToggleFiles {
m.nodes[result.NodeIndex].FilesExpanded = !m.nodes[result.NodeIndex].FilesExpanded
m.clampScroll()
}
if result.ToggleCommits {
m.nodes[result.NodeIndex].CommitsExpanded = !m.nodes[result.NodeIndex].CommitsExpanded
m.clampScroll()
}
return m, nil
}
// nodeLineCount returns how many rendered lines a node occupies.
func (m Model) nodeLineCount(idx int) int {
return shared.NodeLineCount(toBranchNodeData(m.nodes[idx]))
}
// moveCursor moves the cursor by delta, skipping merged branches.
func (m *Model) moveCursor(delta int) {
next := m.cursor + delta
for next >= 0 && next < len(m.nodes) {
if !m.nodes[next].Ref.IsMerged() {
m.cursor = next
m.ensureVisible()
return
}
next += delta
}
}
// ensureVisible adjusts scroll offset so the cursor is visible.
func (m *Model) ensureVisible() {
if m.height == 0 {
return
}
// Calculate the line range for the cursor node, accounting for separator lines
startLine := 0
prevWasMerged := false
prevWasQueued := false
for i := 0; i < m.cursor; i++ {
isMerged := m.nodes[i].Ref.IsMerged()
isQueued := m.nodes[i].Ref.IsQueued()
if isMerged && !prevWasMerged && i > 0 {
startLine++ // separator line
} else if isQueued && !prevWasQueued && !prevWasMerged && i > 0 {
startLine++ // separator line
}
prevWasMerged = isMerged
prevWasQueued = isQueued
startLine += m.nodeLineCount(i)
}
// Check if the cursor node itself is preceded by a separator
if m.cursor < len(m.nodes) {
isMerged := m.nodes[m.cursor].Ref.IsMerged()
isQueued := m.nodes[m.cursor].Ref.IsQueued()
if isMerged && !prevWasMerged && m.cursor > 0 {
startLine++
} else if isQueued && !prevWasQueued && !prevWasMerged && m.cursor > 0 {
startLine++
}
}
endLine := startLine + m.nodeLineCount(m.cursor)
viewHeight := m.contentViewHeight()
m.scrollOffset = shared.EnsureVisible(startLine, endLine, m.scrollOffset, viewHeight)
}
// totalContentLines returns the total number of rendered content lines (excluding header).
func (m Model) totalContentLines() int {
lines := 0
prevWasMerged := false
prevWasQueued := false
for i := 0; i < len(m.nodes); i++ {
isMerged := m.nodes[i].Ref.IsMerged()
isQueued := m.nodes[i].Ref.IsQueued()
if isMerged && !prevWasMerged && i > 0 {
lines++ // separator line
} else if isQueued && !prevWasQueued && !prevWasMerged && i > 0 {
lines++ // separator line
}
prevWasMerged = isMerged
prevWasQueued = isQueued
lines += m.nodeLineCount(i)
}
lines++ // trunk line
return lines
}
// headerHeight returns the number of rows the header occupies for this model's
// config, or 0 when the header is hidden.
func (m Model) headerHeight() int {
if shared.ShouldShowHeader(m.width, m.height) {
return shared.HeaderHeightFor(m.buildHeaderConfig())
}
return 0
}
// contentViewHeight returns the number of lines available for stack content.
func (m Model) contentViewHeight() int {
h := m.height - m.headerHeight()
if h < 1 {
h = 1
}
return h
}
// clampScroll ensures scrollOffset doesn't exceed content bounds.
func (m *Model) clampScroll() {
m.scrollOffset = shared.ClampScroll(m.totalContentLines(), m.contentViewHeight(), m.scrollOffset)
}
func (m Model) View() string {
if m.width == 0 {
return ""
}
var out strings.Builder
showHeader := shared.ShouldShowHeader(m.width, m.height)
reservedLines := 0
if showHeader {
// Build the header config once and reuse it for both rendering and the
// height reservation, so View does not rebuild it twice per frame.
cfg := m.buildHeaderConfig()
shared.RenderHeader(&out, cfg, m.width, m.height)
reservedLines = shared.HeaderHeightFor(cfg)
} else {
// The header (and its inline-image logo) is hidden; clear any logo that
// was previously drawn so it does not linger in the graphics layer.
out.WriteString(shared.ClearLogo())
}
var b strings.Builder
// Render nodes in order (index 0 = top of stack, displayed first)
prevWasMerged := false
prevWasQueued := false
for i := 0; i < len(m.nodes); i++ {
isMerged := m.nodes[i].Ref.IsMerged()
isQueued := m.nodes[i].Ref.IsQueued()
if isMerged && !prevWasMerged && i > 0 {
shared.RenderMergedSeparator(&b)
} else if isQueued && !prevWasQueued && !prevWasMerged && i > 0 {
shared.RenderQueuedSeparator(&b)
}
m.renderNode(&b, i)
prevWasMerged = isMerged
prevWasQueued = isQueued
}
// Trunk
shared.RenderTrunk(&b, m.trunk.Branch)
content := b.String()
// Apply scrolling
viewHeight := m.height - reservedLines
if viewHeight < 1 {
viewHeight = 1
}
out.WriteString(shared.ApplyScrollToContent(content, m.scrollOffset, viewHeight))
return out.String()
}
// buildHeaderConfig produces the header configuration for the stack view.
func (m Model) buildHeaderConfig() shared.HeaderConfig {
mergedCount := 0
queuedCount := 0
for _, n := range m.nodes {
if n.Ref.IsMerged() {
mergedCount++
}
if n.Ref.IsQueued() {
queuedCount++
}
}
branchCount := len(m.nodes)
branchInfo := fmt.Sprintf("%d branches", branchCount)
if branchCount == 1 {
branchInfo = "1 branch"
}
if mergedCount > 0 {
branchInfo += fmt.Sprintf(" (%d merged)", mergedCount)
}
if queuedCount > 0 {
branchInfo += fmt.Sprintf(" (%d queued)", queuedCount)
}
branchIcon := "○"
if mergedCount > 0 && mergedCount < branchCount {
branchIcon = "◐"
} else if branchCount > 0 && mergedCount == branchCount {
branchIcon = "●"
}
// When every branch is merged there is no selectable branch, so the cursor
// is hidden and the actions that depend on it are dimmed; only quit works.
allMerged := branchCount > 0 && mergedCount == branchCount
infoLines := make([]shared.HeaderInfoLine, 0, 3)
if m.stackNumber > 0 {
infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "◆", Label: fmt.Sprintf("Stack #%d", m.stackNumber)})
} else {
infoLines = append(infoLines, shared.HeaderInfoLine{Icon: "✓", Label: "Stack initialized"})
}
infoLines = append(infoLines,
shared.HeaderInfoLine{Icon: "◼", Label: "Base: " + m.trunk.Branch},
shared.HeaderInfoLine{Icon: branchIcon, Label: branchInfo},
)
return shared.HeaderConfig{
ShowArt: true,
Title: "View Stack",
Subtitle: "v" + m.version,
InfoLines: infoLines,
ShortcutColumns: 1,
Shortcuts: []shared.ShortcutEntry{
{Key: "↑↓", Desc: "navigate", Disabled: allMerged},
{Key: "c", Desc: "commits", Disabled: allMerged},
{Key: "f", Desc: "files", Disabled: allMerged},
{Key: "o", Desc: "open PR", Disabled: allMerged},
{Key: "↵", Desc: "checkout", Disabled: allMerged},
{Key: "q", Desc: "quit"},
},
}
}
// renderNode renders a single branch node.
func (m Model) renderNode(b *strings.Builder, idx int) {
node := m.nodes[idx]
isFocused := idx == m.cursor
shared.RenderNode(b, toBranchNodeData(node), isFocused, m.width, nil)
}