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,211 @@
|
||||
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{}
|
||||
Reference in New Issue
Block a user