mirror of
https://github.com/github/gh-stack.git
synced 2026-09-14 20:26:28 +08:00
dcb5885053
Detect merge queue status via the GitHub GraphQL API's mergeQueueEntry field and temporarily skip queued branches in push, sync, and submit commands. Unlike merged state (which is persisted permanently), queued state is transient — held in-memory only via a json:"-" tagged field on BranchRef. Each command run re-checks queue status from the API, so if a PR is ejected from the queue it becomes active again on next run. Changes: - Add MergeQueueEntry to PullRequest GraphQL struct and PRDetails - Add IsQueued()/IsSkipped()/QueuedBranches() to stack model - Update ActiveBranches() family to exclude queued branches - Skip queued branches in push, sync (rebase + push), and submit - Add queued icon, style, and QUEUED state label in TUI view - Add comprehensive tests for queued state handling Closes github/pull-requests#24019 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1001 lines
26 KiB
Go
1001 lines
26 KiB
Go
package stackview
|
|
|
|
import (
|
|
"fmt"
|
|
"os/exec"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/bubbles/help"
|
|
"github.com/charmbracelet/bubbles/key"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/github/gh-stack/internal/stack"
|
|
)
|
|
|
|
// 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"),
|
|
),
|
|
}
|
|
|
|
// headerHeight is the total number of lines the header box occupies (top border + 10 art lines + bottom border).
|
|
const headerHeight = 12
|
|
|
|
// minHeightForHeader is the minimum terminal height to show the header.
|
|
const minHeightForHeader = 25
|
|
|
|
// minWidthForShortcuts is the minimum terminal width to show keyboard shortcuts in the header.
|
|
// Below this, the header is shown without the right-side shortcuts column.
|
|
const minWidthForShortcuts = 65
|
|
|
|
// minWidthForHeader is the minimum terminal width to show the header at all.
|
|
const minWidthForHeader = 50
|
|
|
|
// artLines contains the braille ASCII art displayed in the header.
|
|
var artLines = [10]string{
|
|
"⠀⠀⠀⠀⠀⠀⣀⣤⣤⣤⣤⣤⣤⣀⠀⠀⠀⠀⠀⠀",
|
|
"⠀⠀⠀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⠀⠀⠀",
|
|
"⠀⢀⣼⣿⣿⠛⠛⠿⠿⠿⠿⠿⠿⠛⠛⣿⣿⣷⡀⠀",
|
|
"⠀⣾⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣷⡀",
|
|
"⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇",
|
|
"⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇",
|
|
"⠘⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⢀⣤⣿⣿⣿⣿⠇",
|
|
"⠀⠹⣿⣦⡈⠻⢿⠟⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿⠏⠀",
|
|
"⠀⠀⠈⠻⣷⣤⣀⡀⠀⠀⠀⠀⢸⣿⣿⣿⡿⠃⠀⠀",
|
|
"⠀⠀⠀⠀⠈⠙⠻⠇⠀⠀⠀⠀⠸⠟⠛⠁⠀⠀⠀⠀",
|
|
}
|
|
|
|
// artDisplayWidth is the visual column width of each art line.
|
|
const artDisplayWidth = 20
|
|
|
|
// Model is the Bubbletea model for the interactive stack view.
|
|
type Model struct {
|
|
nodes []BranchNode
|
|
trunk stack.BranchRef
|
|
version string
|
|
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.
|
|
func New(nodes []BranchNode, trunk stack.BranchRef, version string) Model {
|
|
h := help.New()
|
|
h.ShowAll = true
|
|
|
|
// Cursor starts at the current branch, or top of stack
|
|
cursor := 0
|
|
for i, n := range nodes {
|
|
if n.IsCurrent {
|
|
cursor = i
|
|
break
|
|
}
|
|
}
|
|
|
|
return Model{
|
|
nodes: nodes,
|
|
trunk: trunk,
|
|
version: version,
|
|
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):
|
|
if m.cursor > 0 {
|
|
m.cursor--
|
|
m.ensureVisible()
|
|
}
|
|
return m, nil
|
|
|
|
case key.Matches(msg, keys.Down):
|
|
if m.cursor < len(m.nodes)-1 {
|
|
m.cursor++
|
|
m.ensureVisible()
|
|
}
|
|
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 != "" {
|
|
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 {
|
|
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
|
|
}
|
|
|
|
// openBrowserInBackground launches the system browser for the given URL.
|
|
func openBrowserInBackground(url string) {
|
|
cmd := browserCmd(url)
|
|
_ = cmd.Start()
|
|
}
|
|
|
|
// handleMouseClick processes a mouse click at the given screen position.
|
|
func (m Model) handleMouseClick(screenX, screenY int) (tea.Model, tea.Cmd) {
|
|
// If header is visible, clicks in the header area are ignored
|
|
yOffset := 0
|
|
if m.showHeader() {
|
|
if screenY < headerHeight {
|
|
return m, nil
|
|
}
|
|
yOffset = headerHeight
|
|
}
|
|
|
|
// Map screen Y to content line, accounting for scroll offset and header
|
|
contentLine := (screenY - yOffset) + m.scrollOffset
|
|
|
|
// Walk through rendered lines to find which node was clicked.
|
|
// Account for the merged/queued separator lines that may appear between nodes.
|
|
line := 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 {
|
|
line++ // separator line
|
|
} else if isQueued && !prevWasQueued && !prevWasMerged && i > 0 {
|
|
line++ // separator line
|
|
}
|
|
prevWasMerged = isMerged
|
|
prevWasQueued = isQueued
|
|
|
|
nodeStart := line
|
|
nodeLines := m.nodeLineCount(i)
|
|
|
|
if contentLine >= nodeStart && contentLine < nodeStart+nodeLines {
|
|
m.cursor = i
|
|
|
|
// Click on PR header line — only open browser if clicking the PR number
|
|
if contentLine == nodeStart && m.nodes[i].PR != nil && m.nodes[i].PR.URL != "" {
|
|
prStartX, prEndX := m.prLabelColumns(i)
|
|
if screenX >= prStartX && screenX < prEndX {
|
|
openBrowserInBackground(m.nodes[i].PR.URL)
|
|
}
|
|
}
|
|
|
|
// Click on files toggle line → toggle expansion
|
|
if len(m.nodes[i].FilesChanged) > 0 {
|
|
filesToggleLine := nodeStart + m.filesToggleLineOffset(i)
|
|
if contentLine == filesToggleLine {
|
|
m.nodes[i].FilesExpanded = !m.nodes[i].FilesExpanded
|
|
m.clampScroll()
|
|
}
|
|
}
|
|
|
|
// Click on commits toggle line → toggle expansion
|
|
if len(m.nodes[i].Commits) > 0 {
|
|
commitToggleLine := nodeStart + m.commitToggleLineOffset(i)
|
|
if contentLine == commitToggleLine {
|
|
m.nodes[i].CommitsExpanded = !m.nodes[i].CommitsExpanded
|
|
m.clampScroll()
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
line += nodeLines
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// nodeLineCount returns how many rendered lines a node occupies.
|
|
func (m Model) nodeLineCount(idx int) int {
|
|
node := m.nodes[idx]
|
|
lines := 1 // header line (PR line or branch line)
|
|
|
|
if node.PR != nil {
|
|
lines++ // branch + diff stats line (below PR header)
|
|
}
|
|
|
|
if len(node.FilesChanged) > 0 {
|
|
lines++ // files toggle line
|
|
if node.FilesExpanded {
|
|
lines += len(node.FilesChanged)
|
|
}
|
|
}
|
|
|
|
if len(node.Commits) > 0 {
|
|
lines++ // commits toggle line
|
|
if node.CommitsExpanded {
|
|
lines += len(node.Commits)
|
|
}
|
|
}
|
|
|
|
lines++ // connector/spacer line
|
|
return lines
|
|
}
|
|
|
|
// commitToggleLineOffset returns the offset from node start to the commits toggle line.
|
|
func (m Model) commitToggleLineOffset(idx int) int {
|
|
node := m.nodes[idx]
|
|
offset := 1 // after header
|
|
if node.PR != nil {
|
|
offset++ // branch + diff line
|
|
}
|
|
if len(node.FilesChanged) > 0 {
|
|
offset++ // files toggle line
|
|
if node.FilesExpanded {
|
|
offset += len(node.FilesChanged)
|
|
}
|
|
}
|
|
return offset
|
|
}
|
|
|
|
// filesToggleLineOffset returns the offset from node start to the files toggle line.
|
|
func (m Model) filesToggleLineOffset(idx int) int {
|
|
node := m.nodes[idx]
|
|
offset := 1 // after header
|
|
if node.PR != nil {
|
|
offset++ // branch + diff line
|
|
}
|
|
return offset
|
|
}
|
|
|
|
// prLabelColumns returns the start and end X columns of the PR number label
|
|
// (e.g. "#123") on the PR header line, for click hit-testing.
|
|
func (m Model) prLabelColumns(idx int) (int, int) {
|
|
node := m.nodes[idx]
|
|
// Layout: "├ " (2) + optional status icon + " " (2) + "#N..."
|
|
col := 2 // bullet + space
|
|
icon := m.statusIcon(node)
|
|
if icon != "" {
|
|
col += 2 // icon (1 visible char) + space
|
|
}
|
|
prLabel := fmt.Sprintf("#%d", node.PR.Number)
|
|
return col, col + len(prLabel)
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Available content height (reserve space for header or help bar)
|
|
viewHeight := m.contentViewHeight()
|
|
if viewHeight < 1 {
|
|
viewHeight = 1
|
|
}
|
|
|
|
if startLine < m.scrollOffset {
|
|
m.scrollOffset = startLine
|
|
}
|
|
if endLine > m.scrollOffset+viewHeight {
|
|
m.scrollOffset = endLine - viewHeight
|
|
}
|
|
}
|
|
|
|
// showHeader returns true if the terminal is large enough for the header.
|
|
func (m Model) showHeader() bool {
|
|
return m.height >= minHeightForHeader && m.width >= minWidthForHeader
|
|
}
|
|
|
|
// showShortcuts returns true if the terminal is wide enough for the shortcuts column in the header.
|
|
func (m Model) showShortcuts() bool {
|
|
return m.width >= minWidthForShortcuts
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// contentViewHeight returns the number of lines available for stack content.
|
|
func (m Model) contentViewHeight() int {
|
|
reserved := 0
|
|
if m.showHeader() {
|
|
reserved = headerHeight
|
|
}
|
|
h := m.height - reserved
|
|
if h < 1 {
|
|
h = 1
|
|
}
|
|
return h
|
|
}
|
|
|
|
// clampScroll ensures scrollOffset doesn't exceed content bounds.
|
|
func (m *Model) clampScroll() {
|
|
maxScroll := m.totalContentLines() - m.contentViewHeight()
|
|
if maxScroll < 0 {
|
|
maxScroll = 0
|
|
}
|
|
if m.scrollOffset > maxScroll {
|
|
m.scrollOffset = maxScroll
|
|
}
|
|
if m.scrollOffset < 0 {
|
|
m.scrollOffset = 0
|
|
}
|
|
}
|
|
|
|
func (m Model) View() string {
|
|
if m.width == 0 {
|
|
return ""
|
|
}
|
|
|
|
var out strings.Builder
|
|
|
|
showHeader := m.showHeader()
|
|
if showHeader {
|
|
m.renderHeader(&out)
|
|
}
|
|
|
|
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 {
|
|
b.WriteString(connectorStyle.Render("────") + dimStyle.Render(" merged ") + connectorStyle.Render("─────") + "\n")
|
|
} else if isQueued && !prevWasQueued && !prevWasMerged && i > 0 {
|
|
b.WriteString(connectorStyle.Render("────") + dimStyle.Render(" queued ") + connectorStyle.Render("─────") + "\n")
|
|
}
|
|
m.renderNode(&b, i)
|
|
prevWasMerged = isMerged
|
|
prevWasQueued = isQueued
|
|
}
|
|
|
|
// Trunk
|
|
b.WriteString(connectorStyle.Render("└ "))
|
|
b.WriteString(trunkStyle.Render(m.trunk.Branch))
|
|
b.WriteString("\n")
|
|
|
|
content := b.String()
|
|
contentLines := strings.Split(content, "\n")
|
|
|
|
// Apply scrolling
|
|
reservedLines := 0
|
|
if showHeader {
|
|
reservedLines = headerHeight
|
|
}
|
|
viewHeight := m.height - reservedLines
|
|
if viewHeight < 1 {
|
|
viewHeight = 1
|
|
}
|
|
|
|
// Clamp scroll offset so we can't scroll past content
|
|
maxScroll := len(contentLines) - viewHeight
|
|
if maxScroll < 0 {
|
|
maxScroll = 0
|
|
}
|
|
start := m.scrollOffset
|
|
if start > maxScroll {
|
|
start = maxScroll
|
|
}
|
|
end := start + viewHeight
|
|
if end > len(contentLines) {
|
|
end = len(contentLines)
|
|
}
|
|
|
|
visibleContent := strings.Join(contentLines[start:end], "\n")
|
|
out.WriteString(visibleContent)
|
|
|
|
return out.String()
|
|
}
|
|
|
|
// renderHeader renders the full-width stylized header box with ASCII art, stack info, and keyboard shortcuts.
|
|
func (m Model) renderHeader(b *strings.Builder) {
|
|
w := m.width
|
|
if w < 2 {
|
|
return
|
|
}
|
|
innerWidth := w - 2 // subtract left and right border chars
|
|
|
|
// Build info lines (placed to the right of art on specific rows)
|
|
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)
|
|
}
|
|
|
|
// Branch progress icon: ○ none merged, ◐ some merged, ● all merged
|
|
branchIcon := "○"
|
|
if mergedCount > 0 && mergedCount < branchCount {
|
|
branchIcon = "◐"
|
|
} else if branchCount > 0 && mergedCount == branchCount {
|
|
branchIcon = "●"
|
|
}
|
|
|
|
// Info text mapped to art row indices (0-based)
|
|
infoByRow := map[int]string{
|
|
2: headerTitleStyle.Render("GitHub Stacks"),
|
|
3: headerInfoLabelStyle.Render("v" + m.version),
|
|
5: headerInfoStyle.Render("✓") + headerInfoLabelStyle.Render(" Stack initialized"),
|
|
6: headerInfoStyle.Render("◆") + headerInfoLabelStyle.Render(" Base: "+m.trunk.Branch),
|
|
7: headerInfoStyle.Render(branchIcon) + headerInfoLabelStyle.Render(" "+branchInfo),
|
|
}
|
|
|
|
showShortcuts := m.showShortcuts()
|
|
|
|
// Build shortcut lines (rendered content + visual widths)
|
|
type shortcutLine struct {
|
|
text string
|
|
visWidth int
|
|
}
|
|
var shortcuts []shortcutLine
|
|
maxShortcutWidth := 0
|
|
rightColWidth := 0
|
|
|
|
if showShortcuts {
|
|
shortcuts = []shortcutLine{
|
|
{headerShortcutKey.Render("↑") + headerShortcutDesc.Render(" up ") +
|
|
headerShortcutKey.Render("↓") + headerShortcutDesc.Render(" down"), 0},
|
|
{headerShortcutKey.Render("c") + headerShortcutDesc.Render(" commits"), 0},
|
|
{headerShortcutKey.Render("f") + headerShortcutDesc.Render(" files"), 0},
|
|
{headerShortcutKey.Render("o") + headerShortcutDesc.Render(" open PR"), 0},
|
|
{headerShortcutKey.Render("↵") + headerShortcutDesc.Render(" checkout"), 0},
|
|
{headerShortcutKey.Render("q") + headerShortcutDesc.Render(" quit"), 0},
|
|
}
|
|
for i := range shortcuts {
|
|
shortcuts[i].visWidth = lipgloss.Width(shortcuts[i].text)
|
|
if shortcuts[i].visWidth > maxShortcutWidth {
|
|
maxShortcutWidth = shortcuts[i].visWidth
|
|
}
|
|
}
|
|
rightColWidth = maxShortcutWidth + 2
|
|
}
|
|
|
|
// Left content base: 1 (margin) + artDisplayWidth
|
|
leftContentBase := 1 + artDisplayWidth
|
|
|
|
// Vertically center shortcuts within the 10 content rows
|
|
scStartRow := 0
|
|
if len(shortcuts) > 0 {
|
|
scStartRow = (10 - len(shortcuts)) / 2
|
|
}
|
|
|
|
// Top border
|
|
b.WriteString(headerBorderStyle.Render("┌" + strings.Repeat("─", innerWidth) + "┐"))
|
|
b.WriteString("\n")
|
|
|
|
// Content rows
|
|
gap := " " // gap between art and info text
|
|
for i := 0; i < 10; i++ {
|
|
art := artLines[i]
|
|
|
|
// Build info segment
|
|
infoText := ""
|
|
infoVisualLen := 0
|
|
if info, ok := infoByRow[i]; ok {
|
|
infoText = gap + info
|
|
infoVisualLen = 2 + lipgloss.Width(info)
|
|
}
|
|
|
|
leftUsed := leftContentBase + infoVisualLen
|
|
|
|
if showShortcuts {
|
|
// Two-column layout: left (art+info) | right (shortcuts)
|
|
shortcutCol := innerWidth - rightColWidth
|
|
midPad := shortcutCol - leftUsed
|
|
if midPad < 0 {
|
|
midPad = 0
|
|
}
|
|
|
|
scIdx := i - scStartRow
|
|
shortcutRendered := ""
|
|
scVisWidth := 0
|
|
if scIdx >= 0 && scIdx < len(shortcuts) {
|
|
shortcutRendered = shortcuts[scIdx].text
|
|
scVisWidth = shortcuts[scIdx].visWidth
|
|
}
|
|
scTrailingPad := rightColWidth - scVisWidth
|
|
if scTrailingPad < 0 {
|
|
scTrailingPad = 0
|
|
}
|
|
|
|
b.WriteString(headerBorderStyle.Render("│"))
|
|
b.WriteString(" ")
|
|
b.WriteString(art)
|
|
b.WriteString(infoText)
|
|
b.WriteString(strings.Repeat(" ", midPad))
|
|
b.WriteString(shortcutRendered)
|
|
b.WriteString(strings.Repeat(" ", scTrailingPad))
|
|
b.WriteString(headerBorderStyle.Render("│"))
|
|
} else {
|
|
// Single-column layout: art + info, padded to fill
|
|
trailingPad := innerWidth - leftUsed
|
|
if trailingPad < 0 {
|
|
trailingPad = 0
|
|
}
|
|
|
|
b.WriteString(headerBorderStyle.Render("│"))
|
|
b.WriteString(" ")
|
|
b.WriteString(art)
|
|
b.WriteString(infoText)
|
|
b.WriteString(strings.Repeat(" ", trailingPad))
|
|
b.WriteString(headerBorderStyle.Render("│"))
|
|
}
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Bottom border
|
|
b.WriteString(headerBorderStyle.Render("└" + strings.Repeat("─", innerWidth) + "┘"))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// renderNode renders a single branch node.
|
|
func (m Model) renderNode(b *strings.Builder, idx int) {
|
|
node := m.nodes[idx]
|
|
isFocused := idx == m.cursor
|
|
|
|
// Determine connector character and style
|
|
connector := "│"
|
|
connStyle := connectorStyle
|
|
isMerged := node.Ref.IsMerged()
|
|
isQueued := node.Ref.IsQueued()
|
|
if !node.IsLinear && !isMerged && !isQueued {
|
|
connector = "┊"
|
|
connStyle = connectorDashedStyle
|
|
}
|
|
// Override style when this node is focused
|
|
if isFocused {
|
|
if node.IsCurrent {
|
|
connStyle = connectorCurrentStyle
|
|
} else if isMerged {
|
|
connStyle = connectorMergedStyle
|
|
} else if isQueued {
|
|
connStyle = connectorQueuedStyle
|
|
} else {
|
|
connStyle = connectorFocusedStyle
|
|
}
|
|
}
|
|
|
|
// Render header: either PR line + branch line, or just branch line
|
|
if node.PR != nil {
|
|
m.renderPRHeader(b, node, isFocused, connStyle)
|
|
m.renderBranchLine(b, node, connector, connStyle)
|
|
} else {
|
|
m.renderBranchHeader(b, node, isFocused, connStyle)
|
|
}
|
|
|
|
// Files changed toggle + expanded file list
|
|
if len(node.FilesChanged) > 0 {
|
|
m.renderFiles(b, node, connector, connStyle)
|
|
}
|
|
|
|
// Commits toggle + expanded commits
|
|
if len(node.Commits) > 0 {
|
|
m.renderCommits(b, node, connector, connStyle)
|
|
}
|
|
|
|
// Connector/spacer
|
|
b.WriteString(connStyle.Render(connector))
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// renderPRHeader renders the top line when a PR exists: bullet + status icon + PR number + state.
|
|
func (m Model) renderPRHeader(b *strings.Builder, node BranchNode, isFocused bool, connStyle lipgloss.Style) {
|
|
bullet := "├"
|
|
if isFocused {
|
|
bullet = "▶"
|
|
}
|
|
|
|
b.WriteString(connStyle.Render(bullet + " "))
|
|
|
|
statusIcon := m.statusIcon(node)
|
|
|
|
if statusIcon != "" {
|
|
b.WriteString(statusIcon + " ")
|
|
}
|
|
|
|
// PR number + state label
|
|
pr := node.PR
|
|
prLabel := fmt.Sprintf("#%d", pr.Number)
|
|
stateLabel := ""
|
|
style := prOpenStyle
|
|
switch {
|
|
case pr.Merged:
|
|
stateLabel = " MERGED"
|
|
style = prMergedStyle
|
|
case pr.IsQueued:
|
|
stateLabel = " QUEUED"
|
|
style = prQueuedStyle
|
|
case pr.State == "CLOSED":
|
|
stateLabel = " CLOSED"
|
|
style = prClosedStyle
|
|
case pr.IsDraft:
|
|
stateLabel = " DRAFT"
|
|
style = prDraftStyle
|
|
default:
|
|
stateLabel = " OPEN"
|
|
}
|
|
b.WriteString(style.Underline(true).Render(prLabel) + style.Render(stateLabel))
|
|
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// renderBranchLine renders the branch name + diff stats below the PR header.
|
|
func (m Model) renderBranchLine(b *strings.Builder, node BranchNode, connector string, connStyle lipgloss.Style) {
|
|
b.WriteString(connStyle.Render(connector))
|
|
b.WriteString(" ")
|
|
|
|
branchName := node.Ref.Branch
|
|
if node.IsCurrent {
|
|
b.WriteString(currentBranchStyle.Render(branchName + " (current)"))
|
|
} else {
|
|
b.WriteString(normalBranchStyle.Render(branchName))
|
|
}
|
|
|
|
m.renderDiffStats(b, node)
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// renderBranchHeader renders the header line when there is no PR: bullet + branch name + diff stats.
|
|
func (m Model) renderBranchHeader(b *strings.Builder, node BranchNode, isFocused bool, connStyle lipgloss.Style) {
|
|
bullet := "├"
|
|
if isFocused {
|
|
bullet = "▶"
|
|
}
|
|
|
|
b.WriteString(connStyle.Render(bullet + " "))
|
|
|
|
// Status indicator
|
|
statusIcon := m.statusIcon(node)
|
|
if statusIcon != "" {
|
|
b.WriteString(statusIcon + " ")
|
|
}
|
|
|
|
// Branch name
|
|
branchName := node.Ref.Branch
|
|
if node.IsCurrent {
|
|
b.WriteString(currentBranchStyle.Render(branchName + " (current)"))
|
|
} else {
|
|
b.WriteString(normalBranchStyle.Render(branchName))
|
|
}
|
|
|
|
m.renderDiffStats(b, node)
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// renderDiffStats appends +N -N diff stats to the current line if available.
|
|
func (m Model) renderDiffStats(b *strings.Builder, node BranchNode) {
|
|
if node.Additions > 0 || node.Deletions > 0 {
|
|
b.WriteString(" ")
|
|
b.WriteString(additionsStyle.Render(fmt.Sprintf("+%d", node.Additions)))
|
|
b.WriteString(" ")
|
|
b.WriteString(deletionsStyle.Render(fmt.Sprintf("-%d", node.Deletions)))
|
|
}
|
|
}
|
|
|
|
// statusIcon returns the appropriate status icon for a branch.
|
|
func (m Model) statusIcon(node BranchNode) string {
|
|
if node.Ref.IsMerged() {
|
|
return mergedIcon
|
|
}
|
|
if node.Ref.IsQueued() {
|
|
return queuedIcon
|
|
}
|
|
if !node.IsLinear {
|
|
return warningIcon
|
|
}
|
|
if node.PR != nil && node.PR.Number != 0 {
|
|
return openIcon
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// renderFiles renders the files changed toggle and optionally the expanded file list.
|
|
func (m Model) renderFiles(b *strings.Builder, node BranchNode, connector string, connStyle lipgloss.Style) {
|
|
b.WriteString(connStyle.Render(connector))
|
|
b.WriteString(" ")
|
|
|
|
icon := collapsedIcon
|
|
if node.FilesExpanded {
|
|
icon = expandedIcon
|
|
}
|
|
fileLabel := "files changed"
|
|
if len(node.FilesChanged) == 1 {
|
|
fileLabel = "file changed"
|
|
}
|
|
b.WriteString(commitTimeStyle.Render(fmt.Sprintf("%s %d %s", icon, len(node.FilesChanged), fileLabel)))
|
|
b.WriteString("\n")
|
|
|
|
if !node.FilesExpanded {
|
|
return
|
|
}
|
|
|
|
for _, f := range node.FilesChanged {
|
|
b.WriteString(connStyle.Render(connector))
|
|
b.WriteString(" ")
|
|
|
|
path := f.Path
|
|
maxLen := m.width - 30
|
|
if maxLen < 20 {
|
|
maxLen = 20
|
|
}
|
|
if len(path) > maxLen {
|
|
path = "…" + path[len(path)-maxLen+1:]
|
|
}
|
|
b.WriteString(normalBranchStyle.Render(path))
|
|
b.WriteString(" ")
|
|
b.WriteString(additionsStyle.Render(fmt.Sprintf("+%d", f.Additions)))
|
|
b.WriteString(" ")
|
|
b.WriteString(deletionsStyle.Render(fmt.Sprintf("-%d", f.Deletions)))
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
// renderCommits renders the commits toggle and optionally the expanded commit list.
|
|
func (m Model) renderCommits(b *strings.Builder, node BranchNode, connector string, connStyle lipgloss.Style) {
|
|
b.WriteString(connStyle.Render(connector))
|
|
b.WriteString(" ")
|
|
|
|
icon := collapsedIcon
|
|
if node.CommitsExpanded {
|
|
icon = expandedIcon
|
|
}
|
|
commitLabel := "commits"
|
|
if len(node.Commits) == 1 {
|
|
commitLabel = "commit"
|
|
}
|
|
b.WriteString(commitTimeStyle.Render(fmt.Sprintf("%s %d %s", icon, len(node.Commits), commitLabel)))
|
|
b.WriteString("\n")
|
|
|
|
if !node.CommitsExpanded {
|
|
return
|
|
}
|
|
|
|
for _, c := range node.Commits {
|
|
b.WriteString(connStyle.Render(connector))
|
|
b.WriteString(" ")
|
|
|
|
sha := c.SHA
|
|
if len(sha) > 7 {
|
|
sha = sha[:7]
|
|
}
|
|
b.WriteString(commitSHAStyle.Render(sha))
|
|
b.WriteString(" ")
|
|
|
|
subject := c.Subject
|
|
maxLen := m.width - 35
|
|
if maxLen < 20 {
|
|
maxLen = 20
|
|
}
|
|
if len(subject) > maxLen {
|
|
subject = subject[:maxLen-1] + "…"
|
|
}
|
|
b.WriteString(commitSubjectStyle.Render(subject))
|
|
b.WriteString(" ")
|
|
b.WriteString(commitTimeStyle.Render(timeAgo(c.Time)))
|
|
b.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
// timeAgo returns a human-readable time-ago string.
|
|
func timeAgo(t time.Time) string {
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < time.Minute:
|
|
secs := int(d.Seconds())
|
|
if secs == 1 {
|
|
return "1 second ago"
|
|
}
|
|
return fmt.Sprintf("%d seconds ago", secs)
|
|
case d < time.Hour:
|
|
mins := int(d.Minutes())
|
|
if mins == 1 {
|
|
return "1 minute ago"
|
|
}
|
|
return fmt.Sprintf("%d minutes ago", mins)
|
|
case d < 24*time.Hour:
|
|
hours := int(d.Hours())
|
|
if hours == 1 {
|
|
return "1 hour ago"
|
|
}
|
|
return fmt.Sprintf("%d hours ago", hours)
|
|
case d < 30*24*time.Hour:
|
|
days := int(d.Hours() / 24)
|
|
if days == 1 {
|
|
return "1 day ago"
|
|
}
|
|
return fmt.Sprintf("%d days ago", days)
|
|
default:
|
|
months := int(d.Hours() / 24 / 30)
|
|
if months <= 1 {
|
|
return "1 month ago"
|
|
}
|
|
return fmt.Sprintf("%d months ago", months)
|
|
}
|
|
}
|
|
|
|
// browserCmd returns an exec.Cmd to open a URL in the default browser.
|
|
func browserCmd(url string) *exec.Cmd {
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
return exec.Command("open", url)
|
|
case "windows":
|
|
return exec.Command("cmd", "/c", "start", url)
|
|
default:
|
|
return exec.Command("xdg-open", url)
|
|
}
|
|
}
|