Charm-based CLI with JSON persistence, status/list filters, external editor editing, command-history undo/redo, and glamour markdown without AutoStyle OSC hangs. Co-authored-by: Cursor <cursoragent@cursor.com>
244 lines
5.4 KiB
Go
244 lines
5.4 KiB
Go
package views
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"task/internal/ticket"
|
|
)
|
|
|
|
// CollapsedCardHeight is the fixed row height for non-expanded cards
|
|
// (single content line; left accent border adds no vertical space).
|
|
const CollapsedCardHeight = 1
|
|
|
|
// TicketListStyles holds styles for card rendering.
|
|
type TicketListStyles struct {
|
|
Card lipgloss.Style
|
|
CardSelected lipgloss.Style
|
|
StatusOPEN lipgloss.Style
|
|
StatusWIP lipgloss.Style
|
|
StatusDONE lipgloss.Style
|
|
Headline lipgloss.Style
|
|
}
|
|
|
|
// TicketList is a custom viewport list of ticket cards.
|
|
type TicketList struct {
|
|
Items []ticket.Ticket
|
|
Cursor int
|
|
Offset int
|
|
Width int
|
|
Height int
|
|
Styles TicketListStyles
|
|
}
|
|
|
|
// SetSize updates the viewport dimensions.
|
|
func (l *TicketList) SetSize(width, height int) {
|
|
l.Width = width
|
|
l.Height = height
|
|
l.ensureVisible()
|
|
}
|
|
|
|
// SetItems replaces the ticket list, clamping the cursor.
|
|
func (l *TicketList) SetItems(items []ticket.Ticket) {
|
|
l.Items = items
|
|
if l.Cursor >= len(l.Items) {
|
|
l.Cursor = max(0, len(l.Items)-1)
|
|
}
|
|
l.Offset = 0
|
|
l.ensureVisible()
|
|
}
|
|
|
|
// Selected returns the currently selected ticket and whether one exists.
|
|
func (l *TicketList) Selected() (ticket.Ticket, bool) {
|
|
if len(l.Items) == 0 || l.Cursor < 0 || l.Cursor >= len(l.Items) {
|
|
return ticket.Ticket{}, false
|
|
}
|
|
return l.Items[l.Cursor], true
|
|
}
|
|
|
|
// CursorIndex returns the current cursor.
|
|
func (l *TicketList) CursorIndex() int { return l.Cursor }
|
|
|
|
// Len returns the number of tickets.
|
|
func (l *TicketList) Len() int { return len(l.Items) }
|
|
|
|
// MoveDown moves the cursor down.
|
|
func (l *TicketList) MoveDown() {
|
|
if l.Cursor < len(l.Items)-1 {
|
|
l.Cursor++
|
|
l.ensureVisible()
|
|
}
|
|
}
|
|
|
|
// MoveUp moves the cursor up.
|
|
func (l *TicketList) MoveUp() {
|
|
if l.Cursor > 0 {
|
|
l.Cursor--
|
|
l.ensureVisible()
|
|
}
|
|
}
|
|
|
|
// Select sets the cursor to idx if in range.
|
|
func (l *TicketList) Select(idx int) {
|
|
if idx < 0 || idx >= len(l.Items) {
|
|
return
|
|
}
|
|
l.Cursor = idx
|
|
l.ensureVisible()
|
|
}
|
|
|
|
// View renders only the visible ticket cards.
|
|
func (l *TicketList) View() string {
|
|
if l.Height < 1 {
|
|
return ""
|
|
}
|
|
if len(l.Items) == 0 {
|
|
empty := lipgloss.NewStyle().Faint(true).Render("No tickets — press n to create one")
|
|
return lipgloss.NewStyle().Width(l.Width).Height(l.Height).Render(empty)
|
|
}
|
|
|
|
var visible []string
|
|
used := 0
|
|
for i := l.Offset; i < len(l.Items); i++ {
|
|
card := l.renderCard(l.Items[i], i == l.Cursor)
|
|
h := lipgloss.Height(card)
|
|
if used > 0 && used+h > l.Height {
|
|
break
|
|
}
|
|
visible = append(visible, card)
|
|
used += h
|
|
}
|
|
|
|
body := lipgloss.JoinVertical(lipgloss.Left, visible...)
|
|
return lipgloss.NewStyle().Width(l.Width).Height(l.Height).MaxHeight(l.Height).Render(body)
|
|
}
|
|
|
|
func (l *TicketList) renderCard(t ticket.Ticket, selected bool) string {
|
|
innerWidth := max(10, l.Width-4)
|
|
|
|
badge := l.statusBadge(t.Status)
|
|
headline := truncateOneLine(plainHeadline(t.Title), max(8, innerWidth-lipgloss.Width(badge)-2))
|
|
headerLine := badge + " " + l.Styles.Headline.Render(headline)
|
|
|
|
var content string
|
|
if selected {
|
|
// Expanded: headline + glamour-rendered description (fixed dark style).
|
|
desc := strings.TrimSpace(t.Description)
|
|
if desc != "" {
|
|
content = headerLine + "\n" + RenderMarkdown(desc, innerWidth)
|
|
} else {
|
|
content = headerLine
|
|
}
|
|
} else {
|
|
content = headerLine
|
|
}
|
|
|
|
style := l.Styles.Card
|
|
if selected {
|
|
style = l.Styles.CardSelected
|
|
}
|
|
return style.Width(max(4, l.Width-2)).Render(content)
|
|
}
|
|
|
|
func (l *TicketList) statusBadge(s ticket.Status) string {
|
|
label := fmt.Sprintf(" %s ", s.Label())
|
|
switch s {
|
|
case ticket.StatusInProgress:
|
|
return l.Styles.StatusWIP.Render(label)
|
|
case ticket.StatusDone:
|
|
return l.Styles.StatusDONE.Render(label)
|
|
default:
|
|
return l.Styles.StatusOPEN.Render(label)
|
|
}
|
|
}
|
|
|
|
func (l *TicketList) ensureVisible() {
|
|
if len(l.Items) == 0 {
|
|
l.Offset = 0
|
|
return
|
|
}
|
|
if l.Cursor < l.Offset {
|
|
l.Offset = l.Cursor
|
|
return
|
|
}
|
|
|
|
for {
|
|
used := 0
|
|
fits := false
|
|
for i := l.Offset; i < len(l.Items); i++ {
|
|
h := l.estimateHeight(i)
|
|
if used > 0 && used+h > l.Height {
|
|
break
|
|
}
|
|
used += h
|
|
if i == l.Cursor {
|
|
fits = true
|
|
break
|
|
}
|
|
}
|
|
if fits || l.Offset >= l.Cursor {
|
|
break
|
|
}
|
|
l.Offset++
|
|
}
|
|
}
|
|
|
|
// estimateHeight avoids full glamour for collapsed cards.
|
|
func (l *TicketList) estimateHeight(index int) int {
|
|
if index < 0 || index >= len(l.Items) {
|
|
return CollapsedCardHeight
|
|
}
|
|
if index == l.Cursor && strings.TrimSpace(l.Items[index].Description) != "" {
|
|
return lipgloss.Height(l.renderCard(l.Items[index], true))
|
|
}
|
|
return CollapsedCardHeight
|
|
}
|
|
|
|
// FilterTickets returns tickets matching query in title or description.
|
|
func FilterTickets(tickets []ticket.Ticket, query string) []ticket.Ticket {
|
|
q := strings.ToLower(strings.TrimSpace(query))
|
|
if q == "" {
|
|
return tickets
|
|
}
|
|
var out []ticket.Ticket
|
|
for _, t := range tickets {
|
|
if strings.Contains(strings.ToLower(t.Title), q) ||
|
|
strings.Contains(strings.ToLower(t.Description), q) {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func plainHeadline(title string) string {
|
|
title = strings.TrimSpace(title)
|
|
title = strings.TrimLeft(title, "#")
|
|
return strings.TrimSpace(title)
|
|
}
|
|
|
|
func truncateOneLine(s string, maxWidth int) string {
|
|
if maxWidth <= 0 {
|
|
return ""
|
|
}
|
|
if lipgloss.Width(s) <= maxWidth {
|
|
return s
|
|
}
|
|
runes := []rune(s)
|
|
for len(runes) > 0 {
|
|
runes = runes[:len(runes)-1]
|
|
if lipgloss.Width(string(runes)) <= maxWidth-1 {
|
|
return string(runes) + "…"
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|