Files
gastown/internal/tui/convoy/view.go
gastown/crew/jack 40f3a8dfd2 fix(convoy-tui): Address code review issues
- Add convoy ID validation to prevent SQL injection
- Add 5-second timeouts to all subprocess calls
- Batch issue lookups to eliminate N+1 query pattern
- Fix truncate() to handle multi-byte UTF-8 characters

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 13:22:13 -08:00

161 lines
3.2 KiB
Go

package convoy
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/charmbracelet/lipgloss"
)
// Styles for the convoy TUI
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("12"))
selectedStyle = lipgloss.NewStyle().
Background(lipgloss.Color("236")).
Foreground(lipgloss.Color("15"))
convoyStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("15"))
issueOpenStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("11")) // yellow
issueClosedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("10")) // green
progressStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("8")) // gray
helpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("8"))
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("9")) // red
)
// renderView renders the entire view.
func (m Model) renderView() string {
var b strings.Builder
// Title
b.WriteString(titleStyle.Render("Convoys"))
b.WriteString("\n\n")
// Error message
if m.err != nil {
b.WriteString(errorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
b.WriteString("\n\n")
}
// Empty state
if len(m.convoys) == 0 && m.err == nil {
b.WriteString("No convoys found.\n")
b.WriteString("Create a convoy with: gt convoy create <name> [issues...]\n")
}
// Render convoys
pos := 0
for ci, c := range m.convoys {
isSelected := pos == m.cursor
// Convoy row
expandIcon := "▶"
if c.Expanded {
expandIcon = "▼"
}
statusIcon := statusToIcon(c.Status)
line := fmt.Sprintf("%s %d. %s %s: %s %s",
expandIcon,
ci+1,
statusIcon,
c.ID,
c.Title,
progressStyle.Render(fmt.Sprintf("(%s)", c.Progress)),
)
if isSelected {
b.WriteString(selectedStyle.Render(line))
} else {
b.WriteString(convoyStyle.Render(line))
}
b.WriteString("\n")
pos++
// Render issues if expanded
if c.Expanded {
for ii, issue := range c.Issues {
isIssueSelected := pos == m.cursor
// Tree connector
connector := "├─"
if ii == len(c.Issues)-1 {
connector = "└─"
}
issueIcon := "○"
style := issueOpenStyle
if issue.Status == "closed" {
issueIcon = "✓"
style = issueClosedStyle
}
issueLine := fmt.Sprintf(" %s %s %s: %s",
connector,
issueIcon,
issue.ID,
truncate(issue.Title, 50),
)
if isIssueSelected {
b.WriteString(selectedStyle.Render(issueLine))
} else {
b.WriteString(style.Render(issueLine))
}
b.WriteString("\n")
pos++
}
}
}
// Help footer
b.WriteString("\n")
if m.showHelp {
b.WriteString(m.help.View(m.keys))
} else {
b.WriteString(helpStyle.Render("j/k:navigate enter:expand 1-9:jump q:quit ?:help"))
}
return b.String()
}
// statusToIcon converts a status string to an icon.
func statusToIcon(status string) string {
switch status {
case "open":
return "🚚"
case "closed":
return "✓"
case "in_progress":
return "→"
default:
return "●"
}
}
// truncate shortens a string to the given rune length, preserving UTF-8.
func truncate(s string, maxLen int) string {
if utf8.RuneCountInString(s) <= maxLen {
return s
}
runes := []rune(s)
if maxLen <= 3 {
return "..."
}
return string(runes[:maxLen-3]) + "..."
}