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