Files
devops-tui/internal/ui/components/detailview.go
T
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

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{}