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>
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user