2555afce19
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>
213 lines
5.2 KiB
Go
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()
|
|
}
|