kunchenguid--no-mistakes
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
454 行
13 KiB
Go
454 行
13 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/kunchenguid/no-mistakes/internal/ipc"
|
|
)
|
|
|
|
func (m Model) View() string {
|
|
if m.quitting {
|
|
return ""
|
|
}
|
|
|
|
showSelectionActions, allowFix, selectedCount, totalCount := m.awaitingActionState()
|
|
hasCI := isCIActive(m.steps)
|
|
compact := m.height > 0 && m.height < 24
|
|
sectionGap := "\n\n"
|
|
sectionGapHeight := 2
|
|
if compact {
|
|
sectionGap = "\n"
|
|
sectionGapHeight = 1
|
|
}
|
|
|
|
useResponsiveLayout := shouldUseResponsiveLayout(m.width, hasResponsiveSidebarContent(m))
|
|
leftWidth := m.width
|
|
rightWidth := m.width
|
|
if useResponsiveLayout {
|
|
leftWidth, rightWidth = responsiveColumnWidths(m.width)
|
|
}
|
|
|
|
// Pipeline progress view.
|
|
// Compute elapsed times for running steps so they display live durations.
|
|
pipelineSteps := m.stepsWithRunningElapsed()
|
|
pipelineHeight := m.height
|
|
if !useResponsiveLayout && (m.showHelp || hasCI) && (pipelineHeight == 0 || pipelineHeight >= 30) {
|
|
pipelineHeight = cappedPipelineHeight
|
|
}
|
|
pipelineView := renderPipelineView(m.run, pipelineSteps, leftWidth, m.spinnerFrame, pipelineHeight)
|
|
banner := renderOutcomeBanner(m.run, m.steps)
|
|
|
|
// Action bar between pipeline box and findings/diff per DESIGN.md.
|
|
hasDiff := false
|
|
if step := awaitingStep(m.steps); step != nil {
|
|
raw, ok := m.stepDiffs[step.StepName]
|
|
hasDiff = ok && raw != ""
|
|
}
|
|
actionBar := renderActionBar(m.steps, showSelectionActions, allowFix, m.showDiff, selectedCount, totalCount, m.confirmAbort, hasDiff)
|
|
|
|
footer := renderFooter(m.done, m.showHelp, m.confirmAbort, m.yoloMode, m.run, m.latestVersion, m.width)
|
|
contentBudget := -1
|
|
if m.height > 0 {
|
|
baseSections := []string{}
|
|
if useResponsiveLayout {
|
|
if actionBar != "" {
|
|
baseSections = append(baseSections, actionBar)
|
|
}
|
|
} else {
|
|
baseSections = append(baseSections, pipelineView)
|
|
if banner != "" {
|
|
baseSections = append(baseSections, banner)
|
|
}
|
|
if actionBar != "" {
|
|
baseSections = append(baseSections, actionBar)
|
|
}
|
|
}
|
|
contentBudget = m.height - sectionsHeight(baseSections, sectionGapHeight)
|
|
contentBudget -= sectionGapHeight + lipgloss.Height(footer)
|
|
if contentBudget < 0 {
|
|
contentBudget = 0
|
|
}
|
|
}
|
|
|
|
var extraSections []string
|
|
appendExtraSection := func(section string) bool {
|
|
if section == "" {
|
|
return false
|
|
}
|
|
if contentBudget >= 0 {
|
|
needed := lipgloss.Height(section)
|
|
if len(extraSections) > 0 {
|
|
needed += sectionGapHeight
|
|
}
|
|
if needed > contentBudget {
|
|
return false
|
|
}
|
|
contentBudget -= needed
|
|
}
|
|
extraSections = append(extraSections, section)
|
|
return true
|
|
}
|
|
|
|
if m.err != nil {
|
|
appendExtraSection(renderErrorBox(m.err, rightWidth))
|
|
}
|
|
|
|
// Modal editor takes priority over findings/logs so it always renders
|
|
// when active. Bypass the content budget so it never gets dropped on
|
|
// cramped terminals; the editor is user-driven and must stay visible.
|
|
if m.editorActive() {
|
|
boxWidth := rightWidth
|
|
if boxWidth < 40 {
|
|
boxWidth = 80
|
|
}
|
|
var section string
|
|
switch m.editor.kind {
|
|
case editorInstruction:
|
|
section = m.renderInstructionEditor(boxWidth)
|
|
case editorAddFinding:
|
|
section = m.renderAddFindingEditor(boxWidth)
|
|
}
|
|
if section != "" {
|
|
extraSections = append(extraSections, section)
|
|
if contentBudget > 0 {
|
|
contentBudget -= lipgloss.Height(section)
|
|
if contentBudget < 0 {
|
|
contentBudget = 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// CI-specific view when CI step is active.
|
|
if !m.showHelp && !m.editorActive() && hasCI {
|
|
findings := ""
|
|
cursor := 0
|
|
var selected map[string]bool
|
|
if step := awaitingStep(m.steps); step != nil {
|
|
findings = m.stepFindings[step.StepName]
|
|
cursor = m.findingCursor[step.StepName]
|
|
selected = m.findingSelections[step.StepName]
|
|
}
|
|
ciHeight := -1
|
|
if m.height > 0 {
|
|
ciHeight = m.height
|
|
}
|
|
if contentBudget >= 0 {
|
|
ciHeight = contentBudget
|
|
}
|
|
appendExtraSection(renderCIViewWithSelection(m.run, m.steps, findings, m.logs, rightWidth, ciHeight, cursor, selected))
|
|
} else if !m.showHelp && !m.editorActive() {
|
|
if step := awaitingStep(m.steps); step != nil {
|
|
// Generic findings or diff for non-CI steps awaiting approval.
|
|
label := stepLabel(step.StepName)
|
|
if m.showDiff {
|
|
if raw, ok := m.stepDiffs[step.StepName]; ok && raw != "" {
|
|
// Build finding context for diff view header.
|
|
findingCtx := ""
|
|
if items := m.findingItems(step.StepName); len(items) > 0 {
|
|
cur := m.findingCursor[step.StepName]
|
|
if cur >= 0 && cur < len(items) {
|
|
item := items[cur]
|
|
ref := item.File
|
|
if item.Line > 0 {
|
|
ref = fmt.Sprintf("%s:%d", item.File, item.Line)
|
|
}
|
|
findingCtx = fmt.Sprintf("%s %s %s (%d/%d)", severityIcon(item.Severity), ref, item.Description, cur+1, len(items))
|
|
}
|
|
}
|
|
viewHeight := m.height - 15
|
|
if contentBudget >= 0 {
|
|
fixedLines := 4
|
|
if findingCtx != "" {
|
|
fixedLines++
|
|
}
|
|
viewHeight = contentBudget - fixedLines
|
|
}
|
|
if viewHeight > 0 {
|
|
appendExtraSection(renderDiff(raw, rightWidth, viewHeight, m.diffOffset, label, findingCtx))
|
|
}
|
|
}
|
|
} else if _, ok := m.stepFindings[step.StepName]; ok || len(m.addedFindings[step.StepName]) > 0 {
|
|
cursor := m.findingCursor[step.StepName]
|
|
boxHeight := m.height
|
|
if contentBudget >= 0 {
|
|
boxHeight = contentBudget
|
|
}
|
|
raw := m.combinedFindingsJSON(step.StepName)
|
|
appendExtraSection(renderFindingsBoxForHeight(raw, rightWidth, cursor, m.findingSelections[step.StepName], boxHeight))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Log tail in a box - adaptive line count based on terminal height.
|
|
// In responsive layout with no other right-column content, expand to
|
|
// fill the remaining vertical budget so the log panel matches the
|
|
// pipeline panel height. In stacked layout, use the remaining content
|
|
// budget so the log box can consume the available terminal height.
|
|
// Also hidden when CI is active (log context integrated into CI box).
|
|
logLines := 5
|
|
if m.editorActive() {
|
|
logLines = 0
|
|
}
|
|
if !m.showHelp && contentBudget > 0 {
|
|
if useResponsiveLayout {
|
|
if len(extraSections) == 0 {
|
|
logLines = contentBudget - 2 // subtract box borders
|
|
if actionBar != "" {
|
|
logLines -= sectionGapHeight
|
|
}
|
|
}
|
|
} else {
|
|
logLines = contentBudget - 2 // subtract box borders
|
|
}
|
|
} else if m.height > 0 && m.height < 30 {
|
|
logLines = 3
|
|
}
|
|
if m.height > 0 && m.height < 20 {
|
|
logLines = 0
|
|
}
|
|
if len(m.logs) > 0 && logLines > 0 && !hasCI {
|
|
appendExtraSection(renderLogBox(m.logs, rightWidth, logLines, contentBudget))
|
|
}
|
|
|
|
if m.showHelp {
|
|
boxWidth := rightWidth
|
|
if boxWidth < 20 {
|
|
boxWidth = 80
|
|
}
|
|
// The help overlay is user-invoked, so render it even when the content
|
|
// budget is exhausted rather than silently dropping it.
|
|
overlay := renderHelpOverlay(boxWidth, m.run, awaitingStep(m.steps) != nil, m.showDiff, hasDiff, m.done, m.yoloMode)
|
|
if overlay != "" {
|
|
extraSections = append(extraSections, overlay)
|
|
if contentBudget > 0 {
|
|
contentBudget -= lipgloss.Height(overlay)
|
|
if contentBudget < 0 {
|
|
contentBudget = 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if useResponsiveLayout {
|
|
leftSections := []string{pipelineView}
|
|
if banner != "" {
|
|
leftSections = append(leftSections, banner)
|
|
}
|
|
rightSections := make([]string, 0, len(extraSections)+1)
|
|
if actionBar != "" {
|
|
rightSections = append(rightSections, actionBar)
|
|
}
|
|
rightSections = append(rightSections, extraSections...)
|
|
columns := renderResponsiveColumns(joinSections(leftSections, sectionGap), joinSections(rightSections, sectionGap), leftWidth, rightWidth, responsiveLayoutGap)
|
|
return setTerminalTitle(m.terminalTitle()) + joinSections([]string{columns, footer}, sectionGap)
|
|
}
|
|
|
|
sections := []string{pipelineView}
|
|
if banner != "" {
|
|
sections = append(sections, banner)
|
|
}
|
|
if actionBar != "" {
|
|
sections = append(sections, actionBar)
|
|
}
|
|
sections = append(sections, extraSections...)
|
|
sections = append(sections, footer)
|
|
return setTerminalTitle(m.terminalTitle()) + joinSections(sections, sectionGap)
|
|
}
|
|
|
|
func renderFindingsBoxForHeight(raw string, width int, cursor int, selected map[string]bool, boxHeight int) string {
|
|
if boxHeight > 0 && boxHeight < 3 {
|
|
return ""
|
|
}
|
|
boxWidth := width
|
|
if boxWidth < 20 {
|
|
boxWidth = 80
|
|
}
|
|
contentHeight := 0
|
|
if boxHeight > 0 {
|
|
contentHeight = boxHeight - 2
|
|
}
|
|
|
|
f, err := parseFindings(raw)
|
|
if err != nil || f == nil {
|
|
return ""
|
|
}
|
|
|
|
// Build styled title: "Findings - E 2 W 2 I 2" with colorized severity counts.
|
|
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(ansiCyan))
|
|
styledTitle := titleStyle.Render("Findings")
|
|
counts := map[string]int{}
|
|
for _, item := range f.Items {
|
|
counts[item.Severity]++
|
|
}
|
|
if len(counts) > 0 {
|
|
styledTitle += titleStyle.Render(" -")
|
|
for _, sev := range []string{"error", "warning", "info"} {
|
|
if c, ok := counts[sev]; ok {
|
|
styledTitle += " " + severityStyle(sev).Render(fmt.Sprintf("%s %d", severityIcon(sev), c))
|
|
}
|
|
}
|
|
}
|
|
|
|
contentWidth := boxWidth - 4
|
|
if contentWidth < 1 {
|
|
contentWidth = 1
|
|
}
|
|
var rendered string
|
|
var scrollFooter string
|
|
if contentHeight > 0 {
|
|
rendered, scrollFooter = renderParsedFindingsHeight(f, contentWidth, cursor, selected, contentHeight)
|
|
} else {
|
|
rendered, scrollFooter = renderParsedFindingsViewport(f, contentWidth, cursor, selected, 0)
|
|
}
|
|
if rendered == "" {
|
|
return ""
|
|
}
|
|
return renderBoxWithStyledTitle(styledTitle, rendered, boxWidth, scrollFooter)
|
|
}
|
|
|
|
func renderLogBox(logs []string, width int, logLines int, remainingBudget int) string {
|
|
if len(logs) == 0 || logLines <= 0 {
|
|
return ""
|
|
}
|
|
boxWidth := width
|
|
if boxWidth < 20 {
|
|
boxWidth = 80
|
|
}
|
|
if remainingBudget >= 0 {
|
|
maxLogLines := remainingBudget - 2
|
|
if maxLogLines <= 0 {
|
|
return ""
|
|
}
|
|
if logLines > maxLogLines {
|
|
logLines = maxLogLines
|
|
}
|
|
}
|
|
contentWidth := boxWidth - 4
|
|
renderedLines := renderLogTail(logs, contentWidth, logLines)
|
|
if len(renderedLines) == 0 {
|
|
return ""
|
|
}
|
|
var logContent strings.Builder
|
|
logContent.WriteString(strings.Join(renderedLines, "\n"))
|
|
return renderBox("Log", logContent.String(), boxWidth)
|
|
}
|
|
|
|
func renderErrorBox(err error, width int) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
boxWidth := width
|
|
if boxWidth < 20 {
|
|
boxWidth = 80
|
|
}
|
|
contentWidth := boxWidth - 4 // 2 border + 2 padding
|
|
errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ansiRed))
|
|
errLines := strings.Split(err.Error(), "\n")
|
|
var errContent strings.Builder
|
|
for i, line := range errLines {
|
|
if i > 0 {
|
|
errContent.WriteString("\n")
|
|
}
|
|
line, _ = cutText(line, contentWidth)
|
|
errContent.WriteString(errStyle.Render(line))
|
|
}
|
|
return renderBox("Error", errContent.String(), boxWidth)
|
|
}
|
|
|
|
func renderFooter(done bool, showHelp bool, confirmAbort bool, yolo bool, run *ipc.RunInfo, latestVersion string, width int) string {
|
|
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ansiBrightBlack))
|
|
warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ansiYellow))
|
|
boldKey := lipgloss.NewStyle().Bold(true)
|
|
qLabel := "detach"
|
|
if done {
|
|
qLabel = "quit"
|
|
}
|
|
helpLabel := "help"
|
|
if showHelp {
|
|
helpLabel = "close"
|
|
}
|
|
left := " " + boldKey.Render("q") + " " + dimStyle.Render(qLabel)
|
|
if !done {
|
|
xLabel := "abort"
|
|
if confirmAbort {
|
|
xLabel = "again to abort"
|
|
}
|
|
left += " " + boldKey.Render("x") + " " + dimStyle.Render(xLabel)
|
|
}
|
|
left += " " + boldKey.Render("?") + " " + dimStyle.Render(helpLabel)
|
|
if yolo {
|
|
left += " " + boldKey.Render("y") + " " + warnStyle.Render("end yolo")
|
|
} else {
|
|
left += " " + boldKey.Render("y") + " " + dimStyle.Render("yolo")
|
|
}
|
|
if canRerun(run) {
|
|
left += " " + boldKey.Render("r") + " " + dimStyle.Render("rerun")
|
|
}
|
|
|
|
var prURL *string
|
|
if run != nil {
|
|
prURL = run.PRURL
|
|
}
|
|
|
|
rightParts := []string{}
|
|
if latestVersion != "" {
|
|
rightParts = append(rightParts, warnStyle.Render(latestVersion+" available"))
|
|
}
|
|
if prURL == nil || *prURL == "" {
|
|
if len(rightParts) == 0 {
|
|
return left
|
|
}
|
|
right := strings.Join(rightParts, " ")
|
|
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
|
|
if gap < 2 {
|
|
return left
|
|
}
|
|
return left + strings.Repeat(" ", gap) + right
|
|
}
|
|
|
|
left += " " + boldKey.Render("o") + " " + dimStyle.Render("open PR")
|
|
leftWidth := lipgloss.Width(left)
|
|
reservedRightWidth := 0
|
|
if len(rightParts) > 0 {
|
|
reservedRightWidth = lipgloss.Width(strings.Join(rightParts, " ")) + 2
|
|
}
|
|
available := width - leftWidth - reservedRightWidth - 2
|
|
prText := *prURL
|
|
if available < lipgloss.Width(prText) {
|
|
prText = shortPRLabel(*prURL)
|
|
}
|
|
if available >= lipgloss.Width(prText) {
|
|
rightParts = append([]string{dimStyle.Render(prText)}, rightParts...)
|
|
}
|
|
if len(rightParts) == 0 {
|
|
return left
|
|
}
|
|
right := strings.Join(rightParts, " ")
|
|
gap := width - leftWidth - lipgloss.Width(right)
|
|
if gap < 2 {
|
|
return left
|
|
}
|
|
return left + strings.Repeat(" ", gap) + right
|
|
}
|
|
|
|
// shortPRLabel extracts a compact label like "PR #42" from a PR URL.
|
|
func shortPRLabel(url string) string {
|
|
// GitHub: .../pull/42, GitLab: .../merge_requests/42, Bitbucket: .../pull-requests/42
|
|
for _, prefix := range []string{"/pull/", "/merge_requests/", "/pull-requests/"} {
|
|
if idx := strings.LastIndex(url, prefix); idx >= 0 {
|
|
num := url[idx+len(prefix):]
|
|
if num != "" {
|
|
label := "PR"
|
|
if prefix == "/merge_requests/" {
|
|
label = "MR"
|
|
}
|
|
return label + " #" + num
|
|
}
|
|
}
|
|
}
|
|
return url
|
|
}
|