26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
290 lines
7.2 KiB
Go
290 lines
7.2 KiB
Go
package wizard
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/charmbracelet/lipgloss"
|
||
"github.com/muesli/termenv"
|
||
)
|
||
|
||
// ANSI-only palette, matching internal/tui/theme.go.
|
||
const (
|
||
ansiRed = "1"
|
||
ansiGreen = "2"
|
||
ansiYellow = "3"
|
||
ansiBlue = "4"
|
||
ansiCyan = "6"
|
||
ansiBrightBlack = "8"
|
||
)
|
||
|
||
func init() {
|
||
lipgloss.SetColorProfile(termenv.ANSI)
|
||
}
|
||
|
||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||
|
||
// View renders the wizard.
|
||
func (m Model) View() string {
|
||
width := m.width
|
||
if width < 60 {
|
||
width = 60
|
||
}
|
||
m.width = width
|
||
|
||
var content strings.Builder
|
||
|
||
for i, s := range m.steps {
|
||
content.WriteString(m.renderStep(s))
|
||
content.WriteString("\n")
|
||
if i < len(m.steps)-1 {
|
||
content.WriteString(dimStyle().Render("│"))
|
||
content.WriteString("\n")
|
||
}
|
||
}
|
||
|
||
box := renderBox("Setup", strings.TrimRight(content.String(), "\n"), width)
|
||
|
||
var out strings.Builder
|
||
out.WriteString(setTerminalTitle(m.terminalTitle()))
|
||
out.WriteString(box)
|
||
out.WriteString("\n")
|
||
|
||
if m.waiting {
|
||
out.WriteString("\n")
|
||
out.WriteString(" " + blueStyle().Render(spinnerFrames[m.spinnerFrame%len(spinnerFrames)]) + " " + dimStyle().Render("waiting for run…"))
|
||
out.WriteString("\n")
|
||
}
|
||
|
||
if bar := m.renderActionBar(); bar != "" {
|
||
out.WriteString("\n")
|
||
out.WriteString(bar)
|
||
out.WriteString("\n")
|
||
}
|
||
|
||
out.WriteString("\n")
|
||
out.WriteString(m.renderFooter())
|
||
out.WriteString("\n")
|
||
|
||
if m.err != nil {
|
||
out.WriteString("\n")
|
||
out.WriteString(redStyle().Render("error: " + m.err.Error()))
|
||
out.WriteString("\n")
|
||
}
|
||
|
||
return out.String()
|
||
}
|
||
|
||
func (m Model) renderStep(s *step) string {
|
||
icon, style := stepIconAndStyle(s, m.spinnerFrame)
|
||
header := style.Render(icon) + " " + stepLabel(s.id)
|
||
|
||
switch s.status {
|
||
case statDone:
|
||
return header + " " + dimStyle().Render(s.result)
|
||
|
||
case statSkipped:
|
||
if s.skipReason != "" {
|
||
return header + " " + dimStyle().Render(s.skipReason)
|
||
}
|
||
return header
|
||
|
||
case statFailed:
|
||
lines := []string{header + " " + redStyle().Render(s.errMsg)}
|
||
return strings.Join(lines, "\n")
|
||
|
||
case statInput:
|
||
return header + " " + m.inlineInputView(header)
|
||
|
||
case statAgent:
|
||
return header + " " + dimStyle().Render(agentLabel(s.id))
|
||
|
||
case statRunning:
|
||
return header + " " + dimStyle().Render(runningLabel(s.id, s.result))
|
||
|
||
case statConfirm:
|
||
return header + " " + dimStyle().Render(confirmLabel(m.cfg.GateRemote, m.targetBranch))
|
||
}
|
||
return header
|
||
}
|
||
|
||
func (m Model) inlineInputView(header string) string {
|
||
input := m.input
|
||
contentWidth := m.width - 4
|
||
availableWidth := contentWidth - lipgloss.Width(header) - 2
|
||
promptWidth := lipgloss.Width(input.Prompt)
|
||
fieldWidth := availableWidth - promptWidth - 1
|
||
if fieldWidth < 1 {
|
||
fieldWidth = 1
|
||
}
|
||
input.Width = fieldWidth
|
||
input.SetCursor(input.Position())
|
||
return input.View()
|
||
}
|
||
|
||
func (m Model) renderActionBar() string {
|
||
s := m.activeStep()
|
||
if s == nil {
|
||
return ""
|
||
}
|
||
switch s.status {
|
||
case statInput:
|
||
return " " + boldStyle().Render("⏎") + " submit"
|
||
case statConfirm:
|
||
return " " + boldStyle().Render("y") + " push " + boldStyle().Render("n") + " skip"
|
||
case statFailed:
|
||
return " " + boldStyle().Render("r") + " retry"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func (m Model) renderFooter() string {
|
||
if m.confirmQuit {
|
||
msg := " " + boldStyle().Render("q") + " again to abort " + dimStyle().Render(m.sideEffectsDescription()+" will stay")
|
||
return redStyle().Render(msg)
|
||
}
|
||
if m.hasSideEffects() {
|
||
return " " + boldStyle().Render("q") + " abort " + dimStyle().Render(m.sideEffectsDescription()+" stays")
|
||
}
|
||
return " " + boldStyle().Render("q") + " quit"
|
||
}
|
||
|
||
func (m Model) sideEffectsDescription() string {
|
||
parts := []string{}
|
||
if m.branchCreated {
|
||
parts = append(parts, "branch "+m.targetBranch)
|
||
}
|
||
if m.commitMade {
|
||
parts = append(parts, "commit")
|
||
}
|
||
if len(parts) == 0 {
|
||
return ""
|
||
}
|
||
return strings.Join(parts, " + ")
|
||
}
|
||
|
||
func stepLabel(id stepID) string {
|
||
switch id {
|
||
case stepBranch:
|
||
return "Branch"
|
||
case stepCommit:
|
||
return "Commit"
|
||
case stepPush:
|
||
return "Push"
|
||
}
|
||
return "?"
|
||
}
|
||
|
||
func inputLabel(id stepID) string {
|
||
switch id {
|
||
case stepBranch:
|
||
return "branch name (blank to let the agent suggest):"
|
||
case stepCommit:
|
||
return "commit message (blank to let the agent suggest):"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func agentLabel(id stepID) string {
|
||
switch id {
|
||
case stepBranch:
|
||
return "asking agent for a branch name…"
|
||
case stepCommit:
|
||
return "asking agent for a commit message…"
|
||
}
|
||
return "asking agent…"
|
||
}
|
||
|
||
func runningLabel(id stepID, value string) string {
|
||
switch id {
|
||
case stepBranch:
|
||
return "creating branch " + value + "…"
|
||
case stepCommit:
|
||
return "committing \"" + value + "\"…"
|
||
case stepPush:
|
||
return "pushing to gate…"
|
||
}
|
||
return "working…"
|
||
}
|
||
|
||
func confirmLabel(remote, branch string) string {
|
||
if remote == "" {
|
||
remote = "no-mistakes"
|
||
}
|
||
return fmt.Sprintf("push %s to %s gate?", branch, remote)
|
||
}
|
||
|
||
func stepIconAndStyle(s *step, spinnerFrame int) (string, lipgloss.Style) {
|
||
switch s.status {
|
||
case statDone:
|
||
return "✓", greenStyle()
|
||
case statSkipped:
|
||
return "–", dimStyle()
|
||
case statFailed:
|
||
return "✗", redStyle()
|
||
case statAgent, statRunning:
|
||
if len(spinnerFrames) == 0 {
|
||
return "◉", blueStyle()
|
||
}
|
||
return spinnerFrames[spinnerFrame%len(spinnerFrames)], blueStyle()
|
||
case statInput:
|
||
return "⏸", yellowStyle()
|
||
case statConfirm:
|
||
return "⏸", yellowStyle()
|
||
}
|
||
return "○", dimStyle()
|
||
}
|
||
|
||
// Style helpers — kept as functions so tests can swap the color profile.
|
||
|
||
func dimStyle() lipgloss.Style {
|
||
return lipgloss.NewStyle().Foreground(lipgloss.Color(ansiBrightBlack))
|
||
}
|
||
func greenStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color(ansiGreen)) }
|
||
func redStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color(ansiRed)) }
|
||
func yellowStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color(ansiYellow)) }
|
||
func blueStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color(ansiBlue)) }
|
||
func cyanStyle() lipgloss.Style { return lipgloss.NewStyle().Foreground(lipgloss.Color(ansiCyan)) }
|
||
func boldStyle() lipgloss.Style { return lipgloss.NewStyle().Bold(true) }
|
||
|
||
// renderBox mirrors internal/tui/box.go: a rounded-border box with a cyan
|
||
// bold title embedded in the top border.
|
||
func renderBox(title, content string, width int) string {
|
||
if width < 6 {
|
||
width = 6
|
||
}
|
||
titleStyled := cyanStyle().Bold(true).Render(title)
|
||
borderColor := dimStyle()
|
||
|
||
titleWidth := lipgloss.Width(titleStyled)
|
||
fillWidth := width - 5 - titleWidth
|
||
if fillWidth < 1 {
|
||
fillWidth = 1
|
||
}
|
||
topBorder := borderColor.Render("╭─ ") + titleStyled + " " + borderColor.Render(strings.Repeat("─", fillWidth)+"╮")
|
||
|
||
contentWidth := width - 4
|
||
if contentWidth < 1 {
|
||
contentWidth = 1
|
||
}
|
||
|
||
var lines []string
|
||
for _, cl := range strings.Split(content, "\n") {
|
||
visWidth := lipgloss.Width(cl)
|
||
pad := contentWidth - visWidth
|
||
if pad < 0 {
|
||
pad = 0
|
||
}
|
||
line := borderColor.Render("│") + " " + cl + strings.Repeat(" ", pad) + " " + borderColor.Render("│")
|
||
lines = append(lines, line)
|
||
}
|
||
|
||
fill := width - 2
|
||
if fill < 1 {
|
||
fill = 1
|
||
}
|
||
bottom := borderColor.Render("╰" + strings.Repeat("─", fill) + "╯")
|
||
|
||
return topBorder + "\n" + strings.Join(lines, "\n") + "\n" + bottom
|
||
}
|