Files
github__gh-stack/internal/tui/shared/header.go
Sameen Karim 7a268fc380 modify command (#72)
* git primitives for modify cmd

* extract reusable TUI parts

* modify cmd

* recreate stack after modify

* add checks to prevent other modifications while modify is applying

* modify continue for resuming after resolving conflicts

* fix bug with duplicate stack entries after modifying

* reuse conflict resolution help msg from rebase

* additional confirmation before overwriting stack on remote

* fix recreate order of operations

Co-authored-by: Copilot <copilot@github.com>

* move base commit instead of cherry picking for fold up

* check to ensure we aren't left with zero branches

* unify and dedupe across view and modify tui

* more detailed help instructions

Co-authored-by: Copilot <copilot@github.com>

* only recommend submit if stack exists on remote

Co-authored-by: Copilot <copilot@github.com>

* tests for modify tui, apply modifications, submit modifications

* refactor submit for regular and pending modifications

* rename recover to abort

Co-authored-by: Copilot <copilot@github.com>

* docs for modify cmd

* tui styling updates

* updated tui screenshot

* addressing review comments

* Fix 4 bugs from code review

Bug 1: Move RevParseMap error check before using originalRefs.
The error from git.RevParseMap() was deferred past iteration of
originalRefs, which could panic on a nil map.

Bug 2: Differentiate cherry-pick vs rebase conflicts in modify.
Cherry-pick conflicts don't save state as 'conflict' phase, so
--continue won't work. Now prints --abort-only instructions for
cherry-pick conflicts.

Bug 3: Unwind now cleans up branches created by renames.
After restoring snapshot branches, Unwind deletes renamed branch
names that don't belong to the original snapshot.

Bug 4: Simplify push message in submit command.
Changed from 'Pushing N branches to remote...' to 'Pushing to
remote...' since individual branches may fail.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix 7 nit issues from code review

11: Add named constants for phase strings (PhaseApplying, PhaseConflict,
PhasePendingSubmit) in state.go; replace remaining raw literals in
state.go CheckStateGuard.

14: Fix bottomLines comment mismatch — listed 3 items but value is 2.

15: Extract magic number 88 to MinWidthForArt constant in header.go.

16: Remove unused stackview import anchor in model.go — the import
is used via types.go where BranchNode is embedded.

17: Simplify CheckStackLinearity parent resolution — ActiveBaseBranch
already handles skipping merged branches.

18: Fix rename undo matching any rename — add NewName check so only
the specific rename being undone is matched.

20: Add TestUndoRename and TestUndoRename_DoesNotAffectOtherRenames
to validate rename undo behavior.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Make cherry-pick conflicts recoverable via --continue

Previously, cherry-pick conflicts during fold-down operations could only
be resolved with --abort. Now they save full conflict state (phase,
conflict type, fold branch/target, remaining branches) to the state file,
enabling recovery via 'gh stack modify --continue'.

Changes:
- Add ConflictType field to StateFile (rebase or cherry_pick)
- Add FoldBranch/FoldTarget fields for cherry-pick context
- Add CherryPickContinue to git package (cherry-pick --continue)
- Save cherry-pick conflict state in ApplyPlan with remaining branches
- ContinueApply handles both rebase and cherry-pick conflicts
- Unified conflict messaging in cmd/modify.go (both types show --continue)
- Updated test to verify cherry-pick conflict state is saved correctly

* Apply suggestions from code review

Co-authored-by: Luke Ghenco <lukeghenco@github.com>
Co-authored-by: Sameen Karim <skarim@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Luke Ghenco <lukeghenco@github.com>
2026-05-04 22:34:42 -04:00

320 lines
9.3 KiB
Go

package shared
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// HeaderHeight is the total number of lines the header occupies.
const HeaderHeight = 12
// MinHeightForHeader is the minimum terminal height to show the header.
const MinHeightForHeader = 25
// MinWidthForShortcuts is the minimum width to show keyboard shortcuts.
const MinWidthForShortcuts = 65
// MinWidthForHeader is the minimum width to show the header at all.
const MinWidthForHeader = 50
// MinWidthForArt is the minimum width to show ASCII art in the header.
const MinWidthForArt = 88
// ShortcutEntry represents a keyboard shortcut for the header.
type ShortcutEntry struct {
Key string
Desc string
Disabled bool // when true, rendered in gray (dimmed)
}
// HeaderInfoLine represents an info line in the header (icon + label).
type HeaderInfoLine struct {
Icon string
Label string
IconStyle *lipgloss.Style // optional override; nil uses default HeaderInfoStyle (cyan)
}
// ArtLines is the braille ASCII art for the View header.
var ArtLines = [10]string{
"⠀⠀⠀⠀⠀⠀⣀⣤⣤⣤⣤⣤⣤⣀⠀⠀⠀⠀⠀⠀",
"⠀⠀⠀⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⠀⠀⠀",
"⠀⢀⣼⣿⣿⠛⠛⠿⠿⠿⠿⠿⠿⠛⠛⣿⣿⣷⡀⠀",
"⠀⣾⣿⣿⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⣿⣷⡀",
"⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇",
"⢸⣿⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⡇",
"⠘⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⢀⣤⣿⣿⣿⣿⠇",
"⠀⠹⣿⣦⡈⠻⢿⠟⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿⠏⠀",
"⠀⠀⠈⠻⣷⣤⣀⡀⠀⠀⠀⠀⢸⣿⣿⣿⡿⠃⠀⠀",
"⠀⠀⠀⠀⠈⠙⠻⠇⠀⠀⠀⠀⠸⠟⠛⠁⠀⠀⠀⠀",
}
// ArtDisplayWidth is the visual column width of each art line.
const ArtDisplayWidth = 20
// HeaderConfig controls what the header displays.
type HeaderConfig struct {
ShowArt bool // whether to display GitHub logo
Title string // heading next to logo art
Subtitle string // version string, or empty
InfoLines []HeaderInfoLine // info rows (stack info)
Shortcuts []ShortcutEntry // keyboard shortcuts
ShortcutColumns int // number of columns for shortcuts (default 1; set 2 for side-by-side)
}
// ShouldShowHeader returns whether the header should be displayed.
func ShouldShowHeader(width, height int) bool {
return height >= MinHeightForHeader && width >= MinWidthForHeader
}
// ShouldShowShortcuts returns whether shortcuts should be displayed.
func ShouldShowShortcuts(width int) bool {
return width >= MinWidthForShortcuts
}
// RenderHeader renders the full-width header box.
// Progressive disclosure as width narrows: first hides the art, then the
// info text, keeping keyboard shortcuts always visible.
func RenderHeader(b *strings.Builder, cfg HeaderConfig, width, height int) {
if width < 2 {
return
}
innerWidth := width - 2
// Always build shortcut lines
type shortcutLine struct {
text string
visWidth int
}
var shortcuts []shortcutLine
maxShortcutWidth := 0
rightColWidth := 0
cols := cfg.ShortcutColumns
if cols < 1 {
cols = 1
}
if len(cfg.Shortcuts) > 0 {
if cols >= 2 {
// Two-column layout with aligned keys and descriptions.
// First pass: compute max visual key width per column.
maxKeyLeft := 0
maxKeyRight := 0
for i := 0; i < len(cfg.Shortcuts); i += 2 {
kw := lipgloss.Width(cfg.Shortcuts[i].Key)
if kw > maxKeyLeft {
maxKeyLeft = kw
}
if i+1 < len(cfg.Shortcuts) {
kw = lipgloss.Width(cfg.Shortcuts[i+1].Key)
if kw > maxKeyRight {
maxKeyRight = kw
}
}
}
// Second pass: compute max visual width of the left column
// so the right column starts at a consistent position.
maxLeftWidth := 0
for i := 0; i < len(cfg.Shortcuts); i += 2 {
left := renderShortcutEntryPadded(cfg.Shortcuts[i], maxKeyLeft)
lw := lipgloss.Width(left)
if lw > maxLeftWidth {
maxLeftWidth = lw
}
}
colGap := " "
colGapWidth := lipgloss.Width(colGap)
for i := 0; i < len(cfg.Shortcuts); i += 2 {
left := renderShortcutEntryPadded(cfg.Shortcuts[i], maxKeyLeft)
// Pad left entry to maxLeftWidth for consistent right column start
leftPad := maxLeftWidth - lipgloss.Width(left)
if leftPad < 0 {
leftPad = 0
}
line := left + strings.Repeat(" ", leftPad)
if i+1 < len(cfg.Shortcuts) {
right := renderShortcutEntryPadded(cfg.Shortcuts[i+1], maxKeyRight)
line = line + colGap + right
}
visW := lipgloss.Width(line)
// Account for column gap width in case right column is missing
if i+1 >= len(cfg.Shortcuts) {
visW = maxLeftWidth + colGapWidth + maxKeyRight + 10 // approximate
}
shortcuts = append(shortcuts, shortcutLine{text: line, visWidth: visW})
if visW > maxShortcutWidth {
maxShortcutWidth = visW
}
}
} else {
// Single-column layout with aligned keys.
maxKeyW := 0
for _, sc := range cfg.Shortcuts {
kw := lipgloss.Width(sc.Key)
if kw > maxKeyW {
maxKeyW = kw
}
}
for _, sc := range cfg.Shortcuts {
rendered := renderShortcutEntryPadded(sc, maxKeyW)
visW := lipgloss.Width(rendered)
shortcuts = append(shortcuts, shortcutLine{text: rendered, visWidth: visW})
if visW > maxShortcutWidth {
maxShortcutWidth = visW
}
}
}
rightColWidth = maxShortcutWidth + 2
}
// Determine what fits: shortcuts always shown, art and info are progressive.
// Hide art first (below 88 cols), then info text, as width narrows.
showArt := cfg.ShowArt
showInfo := true
// Hide art when viewport is too narrow for art + info + shortcuts
if showArt && width < MinWidthForArt {
showArt = false
}
// If info + shortcuts don't fit, hide info
infoMinWidth := 20 // rough minimum for title/info text
if innerWidth < rightColWidth+infoMinWidth+4 {
showInfo = false
}
// Map info lines to row indices
infoByRow := make(map[int]string)
if showInfo {
infoByRow[2] = HeaderTitleStyle.Render(cfg.Title)
if cfg.Subtitle != "" {
infoByRow[3] = HeaderInfoLabelStyle.Render(cfg.Subtitle)
}
for i, info := range cfg.InfoLines {
row := 5 + i
if row > 9 {
break
}
iconStyle := HeaderInfoStyle
if info.IconStyle != nil {
iconStyle = *info.IconStyle
}
infoByRow[row] = iconStyle.Render(info.Icon) + HeaderInfoLabelStyle.Render(" "+info.Label)
}
}
// Left content base width
leftContentBase := 1 // margin
if showArt {
leftContentBase += ArtDisplayWidth
}
// Vertically center shortcuts
scStartRow := 0
if len(shortcuts) > 0 {
scStartRow = (10 - len(shortcuts)) / 2
}
gap := " "
// Top border
b.WriteString(HeaderBorderStyle.Render("┌" + strings.Repeat("─", innerWidth) + "┐"))
b.WriteString("\n")
// Content rows
for i := 0; i < 10; i++ {
// Left column: art (optional) + info
artText := ""
if showArt {
artText = ArtLines[i]
}
infoText := ""
infoVisualLen := 0
if info, ok := infoByRow[i]; ok {
infoText = gap + info
infoVisualLen = 2 + lipgloss.Width(info)
}
leftUsed := leftContentBase + infoVisualLen
if len(shortcuts) > 0 {
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(" ")
if showArt {
b.WriteString(artText)
}
b.WriteString(infoText)
b.WriteString(strings.Repeat(" ", midPad))
b.WriteString(shortcutRendered)
b.WriteString(strings.Repeat(" ", scTrailingPad))
b.WriteString(HeaderBorderStyle.Render("│"))
} else {
trailingPad := innerWidth - leftUsed
if trailingPad < 0 {
trailingPad = 0
}
b.WriteString(HeaderBorderStyle.Render("│"))
b.WriteString(" ")
if showArt {
b.WriteString(artText)
}
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")
}
// disabledShortcutStyle renders both key and desc in dim gray.
var disabledShortcutStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
// renderShortcutEntry renders a single shortcut, dimmed if disabled.
func renderShortcutEntry(sc ShortcutEntry) string {
if sc.Disabled {
return disabledShortcutStyle.Render(sc.Key + " " + sc.Desc)
}
return HeaderShortcutKey.Render(sc.Key) + HeaderShortcutDesc.Render(" "+sc.Desc)
}
// renderShortcutEntryPadded renders a shortcut with the key right-padded
// to keyWidth visual columns so descriptions align across rows.
func renderShortcutEntryPadded(sc ShortcutEntry, keyWidth int) string {
keyVisWidth := lipgloss.Width(sc.Key)
pad := ""
if keyVisWidth < keyWidth {
pad = strings.Repeat(" ", keyWidth-keyVisWidth)
}
if sc.Disabled {
return disabledShortcutStyle.Render(sc.Key) + pad + disabledShortcutStyle.Render(" "+sc.Desc)
}
return HeaderShortcutKey.Render(sc.Key) + pad + HeaderShortcutDesc.Render(" "+sc.Desc)
}