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>
212 lines
5.4 KiB
Go
212 lines
5.4 KiB
Go
package components
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/key"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/samuelenocsson/devops-tui/internal/models"
|
|
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
|
|
)
|
|
|
|
// DetailView is the fullscreen detail view component
|
|
type DetailView struct {
|
|
item *models.WorkItem
|
|
styles theme.Styles
|
|
keys theme.KeyMap
|
|
width int
|
|
height int
|
|
scrollOffset int
|
|
maxScroll int
|
|
}
|
|
|
|
// NewDetailView creates a new detail view
|
|
func NewDetailView(styles theme.Styles, keys theme.KeyMap) DetailView {
|
|
return DetailView{
|
|
styles: styles,
|
|
keys: keys,
|
|
}
|
|
}
|
|
|
|
// Init initializes the detail view
|
|
func (d DetailView) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
// Update handles messages for the detail view
|
|
func (d DetailView) Update(msg tea.Msg) (DetailView, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch {
|
|
case key.Matches(msg, d.keys.Back):
|
|
return d, func() tea.Msg { return CloseDetailViewMsg{} }
|
|
case key.Matches(msg, d.keys.Quit) && msg.String() == "q":
|
|
return d, func() tea.Msg { return CloseDetailViewMsg{} }
|
|
case key.Matches(msg, d.keys.Open):
|
|
if d.item != nil {
|
|
return d, func() tea.Msg { return OpenWorkItemMsg{Item: *d.item} }
|
|
}
|
|
case key.Matches(msg, d.keys.Up):
|
|
if d.scrollOffset > 0 {
|
|
d.scrollOffset--
|
|
}
|
|
case key.Matches(msg, d.keys.Down):
|
|
if d.scrollOffset < d.maxScroll {
|
|
d.scrollOffset++
|
|
}
|
|
}
|
|
}
|
|
|
|
return d, nil
|
|
}
|
|
|
|
// View renders the detail view
|
|
func (d DetailView) View() string {
|
|
if d.item == nil {
|
|
return ""
|
|
}
|
|
|
|
var sections []string
|
|
|
|
// Title bar
|
|
title := fmt.Sprintf("#%d %s", d.item.ID, d.item.Title)
|
|
titleBar := lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color("#F9FAFB")).
|
|
Background(lipgloss.Color("#7C3AED")).
|
|
Padding(0, 1).
|
|
Width(d.width - 2).
|
|
Render(title)
|
|
sections = append(sections, titleBar)
|
|
|
|
// Metadata section
|
|
metadataContent := d.renderMetadata()
|
|
metadataSection := d.styles.DetailSection.
|
|
Width(d.width - 6).
|
|
Render("METADATA\n" + metadataContent)
|
|
sections = append(sections, metadataSection)
|
|
|
|
// Parent section (if exists)
|
|
if d.item.ParentID > 0 {
|
|
parentContent := fmt.Sprintf("#%d", d.item.ParentID)
|
|
if d.item.ParentTitle != "" {
|
|
parentContent += " " + d.item.ParentTitle
|
|
}
|
|
parentSection := d.styles.DetailSection.
|
|
Width(d.width - 6).
|
|
Render("PARENT\n" + parentContent)
|
|
sections = append(sections, parentSection)
|
|
}
|
|
|
|
// Description section
|
|
if d.item.Description != "" {
|
|
desc := wordWrap(d.item.Description, d.width-10)
|
|
descSection := d.styles.DetailSection.
|
|
Width(d.width - 6).
|
|
Render("DESCRIPTION\n" + desc)
|
|
sections = append(sections, descSection)
|
|
}
|
|
|
|
// Tags section
|
|
if len(d.item.Tags) > 0 {
|
|
var tagStrings []string
|
|
for _, tag := range d.item.Tags {
|
|
tagStrings = append(tagStrings, d.styles.DetailTag.Render(tag))
|
|
}
|
|
tagsSection := d.styles.DetailSection.
|
|
Width(d.width - 6).
|
|
Render("TAGS\n" + strings.Join(tagStrings, " "))
|
|
sections = append(sections, tagsSection)
|
|
}
|
|
|
|
// Join all sections
|
|
content := strings.Join(sections, "\n\n")
|
|
|
|
// Calculate scrolling
|
|
contentLines := strings.Split(content, "\n")
|
|
viewableHeight := d.height - 4
|
|
d.maxScroll = len(contentLines) - viewableHeight
|
|
if d.maxScroll < 0 {
|
|
d.maxScroll = 0
|
|
}
|
|
|
|
// Apply scrolling
|
|
if d.scrollOffset > 0 && d.scrollOffset < len(contentLines) {
|
|
contentLines = contentLines[d.scrollOffset:]
|
|
}
|
|
if len(contentLines) > viewableHeight {
|
|
contentLines = contentLines[:viewableHeight]
|
|
}
|
|
|
|
scrolledContent := strings.Join(contentLines, "\n")
|
|
|
|
// Status bar
|
|
statusBar := d.renderStatusBar()
|
|
|
|
// Build final view
|
|
mainContent := d.styles.PanelActive.
|
|
Width(d.width).
|
|
Height(d.height - 2).
|
|
Render(scrolledContent)
|
|
|
|
return lipgloss.JoinVertical(
|
|
lipgloss.Left,
|
|
mainContent,
|
|
statusBar,
|
|
)
|
|
}
|
|
|
|
func (d *DetailView) renderMetadata() string {
|
|
typeStyle := d.styles.TypeBadge(string(d.item.Type))
|
|
stateStyle := d.styles.StateBadge(string(d.item.State))
|
|
|
|
labelWidth := 12
|
|
valueWidth := 20
|
|
|
|
label := func(s string) string {
|
|
return d.styles.DetailLabel.Width(labelWidth).Render(s)
|
|
}
|
|
value := func(s string) string {
|
|
return d.styles.DetailValue.Width(valueWidth).Render(s)
|
|
}
|
|
|
|
rows := []string{
|
|
label("Type:") + typeStyle.Render(d.item.ShortType()) + " " + label("ID:") + value(fmt.Sprintf("#%d", d.item.ID)),
|
|
label("State:") + stateStyle.Render(string(d.item.State)) + " " + label("Created:") + value(d.item.CreatedDate.Format("2006-01-02")),
|
|
}
|
|
|
|
assignedTo := d.item.AssignedTo
|
|
if assignedTo == "" {
|
|
assignedTo = "Unassigned"
|
|
}
|
|
rows = append(rows, label("Assigned:")+value(assignedTo)+" "+label("Updated:")+value(d.item.ChangedDate.Format("2006-01-02")))
|
|
rows = append(rows, label("Sprint:")+value(d.item.SprintName())+" "+label("Priority:")+value(fmt.Sprintf("%d", d.item.Priority)))
|
|
rows = append(rows, label("Area:")+value(d.item.AreaName()))
|
|
|
|
return strings.Join(rows, "\n")
|
|
}
|
|
|
|
func (d *DetailView) renderStatusBar() string {
|
|
help := "Esc Back Enter Open in browser j/k Scroll"
|
|
return d.styles.StatusBar.
|
|
Width(d.width).
|
|
Render(help)
|
|
}
|
|
|
|
// SetItem sets the work item to display
|
|
func (d *DetailView) SetItem(item *models.WorkItem) {
|
|
d.item = item
|
|
d.scrollOffset = 0
|
|
}
|
|
|
|
// SetSize sets the size of the detail view
|
|
func (d *DetailView) SetSize(width, height int) {
|
|
d.width = width
|
|
d.height = height
|
|
}
|
|
|
|
// CloseDetailViewMsg is sent when the detail view should be closed
|
|
type CloseDetailViewMsg struct{}
|