Files
Samuel Enocsson 2555afce19 Initial commit: Azure DevOps TUI client
A terminal-based user interface for browsing and managing Azure DevOps
work items. Features include:

- Browse work items with filtering by area, iteration, state, and type
- View work item details with markdown rendering
- Open work items in browser
- Create git branches from work items
- Update work item state
- Keyboard-driven navigation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 09:56:11 +01:00

213 lines
5.2 KiB
Go

package components
import (
"fmt"
"strings"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/lipgloss"
"github.com/samuelenocsson/devops-tui/internal/models"
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
)
// DetailsPanel shows details for a selected work item
type DetailsPanel struct {
item *models.WorkItem
styles theme.Styles
width int
height int
renderedDesc string
renderedDescWidth int
}
// NewDetailsPanel creates a new details panel
func NewDetailsPanel(styles theme.Styles) DetailsPanel {
return DetailsPanel{
styles: styles,
}
}
// View renders the details panel
func (d DetailsPanel) View() string {
if d.item == nil {
content := d.styles.Subtitle.Render("Select a work item to view details")
return d.styles.PanelInactive.
Width(d.width).
Height(d.height).
Render(content)
}
var b strings.Builder
// Title
title := fmt.Sprintf("#%d %s", d.item.ID, d.item.Title)
if len(title) > d.width-6 {
title = title[:d.width-9] + "..."
}
b.WriteString(d.styles.DetailTitle.Render(title))
b.WriteString("\n\n")
// Metadata section with aligned columns
typeStyle := d.styles.TypeBadge(string(d.item.Type))
stateStyle := d.styles.StateBadge(string(d.item.State))
labelWidth := 10
valueWidth := 12
// Type and State row
b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Type:"))
b.WriteString(typeStyle.Width(valueWidth).Render(d.item.ShortType()))
b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("State:"))
b.WriteString(stateStyle.Render(string(d.item.State)))
b.WriteString("\n")
// Assigned and Sprint row
assignedTo := d.item.AssignedTo
if assignedTo == "" {
assignedTo = "Unassigned"
}
b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Assigned:"))
b.WriteString(d.styles.DetailValue.Width(valueWidth).Render(truncate(assignedTo, valueWidth)))
b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Sprint:"))
b.WriteString(d.styles.DetailValue.Render(d.item.SprintName()))
b.WriteString("\n")
// Area row
b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Area:"))
b.WriteString(d.styles.DetailValue.Render(d.item.AreaName()))
b.WriteString("\n")
// Parent (if exists)
if d.item.ParentID > 0 {
b.WriteString("\n")
parentLabel := fmt.Sprintf("Parent: #%d", d.item.ParentID)
if d.item.ParentTitle != "" {
parentLabel += " " + d.item.ParentTitle
}
b.WriteString(d.styles.Subtitle.Render(parentLabel))
b.WriteString("\n")
}
// Description section
if d.item.Description != "" {
b.WriteString("\n")
b.WriteString(d.styles.DetailSectionTitle.Render("─── Description ───"))
b.WriteString("\n")
maxWidth := d.width - 8
if maxWidth < 20 {
maxWidth = 20
}
// Use cached rendered description if available and width matches
desc := d.renderedDesc
if desc == "" || d.renderedDescWidth != maxWidth {
desc = d.renderMarkdown(d.item.Description, maxWidth)
}
// Limit description height
lines := strings.Split(desc, "\n")
maxLines := d.height - 12
if maxLines < 3 {
maxLines = 3
}
if len(lines) > maxLines {
lines = lines[:maxLines]
lines = append(lines, "...")
}
b.WriteString(strings.Join(lines, "\n"))
b.WriteString("\n")
}
// Tags section
if len(d.item.Tags) > 0 {
b.WriteString("\n")
b.WriteString(d.styles.DetailSectionTitle.Render("─── Tags ───"))
b.WriteString("\n")
var tagStrings []string
for _, tag := range d.item.Tags {
tagStrings = append(tagStrings, d.styles.DetailTag.Render(tag))
}
b.WriteString(strings.Join(tagStrings, " "))
}
content := b.String()
return d.styles.PanelInactive.
Width(d.width).
Height(d.height).
BorderForeground(lipgloss.Color("#374151")).
Render(content)
}
// SetItem sets the work item to display
func (d *DetailsPanel) SetItem(item *models.WorkItem) {
d.item = item
d.renderedDesc = ""
d.renderedDescWidth = 0
}
// renderMarkdown renders markdown content and caches the result
func (d *DetailsPanel) renderMarkdown(content string, width int) string {
renderer, err := glamour.NewTermRenderer(
glamour.WithStylePath("dark"),
glamour.WithWordWrap(width),
)
var result string
if err == nil {
rendered, renderErr := renderer.Render(content)
if renderErr == nil {
result = strings.TrimSpace(rendered)
} else {
result = wordWrap(content, width)
}
} else {
result = wordWrap(content, width)
}
d.renderedDesc = result
d.renderedDescWidth = width
return result
}
// SetSize sets the size of the details panel
func (d *DetailsPanel) SetSize(width, height int) {
d.width = width
d.height = height
}
// Helper functions
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
func wordWrap(text string, width int) string {
if width <= 0 {
return text
}
var result strings.Builder
words := strings.Fields(text)
currentLineLength := 0
for i, word := range words {
if currentLineLength+len(word)+1 > width {
result.WriteString("\n")
currentLineLength = 0
} else if i > 0 {
result.WriteString(" ")
currentLineLength++
}
result.WriteString(word)
currentLineLength += len(word)
}
return result.String()
}