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,367 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// Column definitions
|
||||
type column struct {
|
||||
title string
|
||||
width int
|
||||
minWidth int
|
||||
flex bool // If true, this column takes remaining space
|
||||
}
|
||||
|
||||
// WorkItemsPanel is the work items list component
|
||||
type WorkItemsPanel struct {
|
||||
items []models.WorkItem
|
||||
cursor int
|
||||
styles theme.Styles
|
||||
keys theme.KeyMap
|
||||
width int
|
||||
height int
|
||||
focused bool
|
||||
offset int // For scrolling
|
||||
columns []column
|
||||
}
|
||||
|
||||
// NewWorkItemsPanel creates a new work items panel
|
||||
func NewWorkItemsPanel(styles theme.Styles, keys theme.KeyMap) WorkItemsPanel {
|
||||
return WorkItemsPanel{
|
||||
items: []models.WorkItem{},
|
||||
styles: styles,
|
||||
keys: keys,
|
||||
columns: []column{
|
||||
{title: "ID", width: 7, minWidth: 6},
|
||||
{title: "TYPE", width: 8, minWidth: 6},
|
||||
{title: "STATE", width: 12, minWidth: 8},
|
||||
{title: "ASSIGNED", width: 14, minWidth: 10},
|
||||
{title: "TITLE", flex: true, minWidth: 20},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the work items panel
|
||||
func (w WorkItemsPanel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages for the work items panel
|
||||
func (w WorkItemsPanel) Update(msg tea.Msg) (WorkItemsPanel, tea.Cmd) {
|
||||
if !w.focused {
|
||||
return w, nil
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, w.keys.Up):
|
||||
w.moveUp()
|
||||
case key.Matches(msg, w.keys.Down):
|
||||
w.moveDown()
|
||||
case key.Matches(msg, w.keys.Top):
|
||||
w.moveToTop()
|
||||
case key.Matches(msg, w.keys.Bottom):
|
||||
w.moveToBottom()
|
||||
case key.Matches(msg, w.keys.Open):
|
||||
if w.SelectedItem() != nil {
|
||||
return w, func() tea.Msg { return OpenWorkItemMsg{Item: *w.SelectedItem()} }
|
||||
}
|
||||
case key.Matches(msg, w.keys.View):
|
||||
if w.SelectedItem() != nil {
|
||||
return w, func() tea.Msg { return ViewWorkItemMsg{Item: *w.SelectedItem()} }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// View renders the work items panel
|
||||
func (w WorkItemsPanel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
// Calculate column widths
|
||||
colWidths := w.calculateColumnWidths()
|
||||
|
||||
// Header
|
||||
header := w.renderHeader(colWidths)
|
||||
b.WriteString(header)
|
||||
b.WriteString("\n")
|
||||
|
||||
// Separator line
|
||||
separator := w.renderSeparator(colWidths)
|
||||
b.WriteString(separator)
|
||||
b.WriteString("\n")
|
||||
|
||||
// Items
|
||||
if len(w.items) == 0 {
|
||||
emptyMsg := w.styles.Subtitle.Render(" No work items found")
|
||||
b.WriteString(emptyMsg)
|
||||
} else {
|
||||
visibleItems := w.visibleItemCount()
|
||||
|
||||
// Render visible items
|
||||
for i := w.offset; i < len(w.items) && i < w.offset+visibleItems; i++ {
|
||||
item := w.items[i]
|
||||
isCursor := i == w.cursor
|
||||
line := w.renderItem(item, isCursor, colWidths)
|
||||
b.WriteString(line)
|
||||
if i < len(w.items)-1 && i < w.offset+visibleItems-1 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply panel styling
|
||||
content := b.String()
|
||||
if w.focused {
|
||||
return w.styles.PanelActive.
|
||||
Width(w.width).
|
||||
Height(w.height).
|
||||
Render(content)
|
||||
}
|
||||
return w.styles.PanelInactive.
|
||||
Width(w.width).
|
||||
Height(w.height).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
func (w *WorkItemsPanel) calculateColumnWidths() []int {
|
||||
availableWidth := w.width - 6 // Account for borders and padding
|
||||
|
||||
// Calculate fixed columns total width
|
||||
fixedWidth := 0
|
||||
flexCount := 0
|
||||
for _, col := range w.columns {
|
||||
if col.flex {
|
||||
flexCount++
|
||||
} else {
|
||||
fixedWidth += col.width + 1 // +1 for separator
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate flex column width
|
||||
flexWidth := availableWidth - fixedWidth
|
||||
if flexWidth < 20 {
|
||||
flexWidth = 20
|
||||
}
|
||||
|
||||
// Build widths array
|
||||
widths := make([]int, len(w.columns))
|
||||
for i, col := range w.columns {
|
||||
if col.flex {
|
||||
widths[i] = flexWidth / flexCount
|
||||
} else {
|
||||
widths[i] = col.width
|
||||
}
|
||||
}
|
||||
|
||||
return widths
|
||||
}
|
||||
|
||||
func (w *WorkItemsPanel) renderHeader(colWidths []int) string {
|
||||
headerStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#9CA3AF"))
|
||||
|
||||
var parts []string
|
||||
for i, col := range w.columns {
|
||||
width := colWidths[i]
|
||||
title := col.title
|
||||
if len(title) > width {
|
||||
title = title[:width]
|
||||
}
|
||||
parts = append(parts, headerStyle.Width(width).Render(title))
|
||||
}
|
||||
|
||||
return " " + strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func (w *WorkItemsPanel) renderSeparator(colWidths []int) string {
|
||||
sepStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#374151"))
|
||||
|
||||
// Use content width (accounting for panel padding)
|
||||
contentWidth := w.width - 4
|
||||
if contentWidth < 10 {
|
||||
contentWidth = 10
|
||||
}
|
||||
|
||||
return sepStyle.Render(strings.Repeat("─", contentWidth))
|
||||
}
|
||||
|
||||
func (w *WorkItemsPanel) renderItem(item models.WorkItem, isCursor bool, colWidths []int) string {
|
||||
// Cursor indicator
|
||||
cursor := " "
|
||||
if isCursor {
|
||||
cursor = "▸ "
|
||||
}
|
||||
|
||||
// Format values
|
||||
id := fmt.Sprintf("#%d", item.ID)
|
||||
typeStr := item.ShortType()
|
||||
stateStr := string(item.State)
|
||||
assigned := truncateStr(item.AssignedTo, colWidths[3])
|
||||
if assigned == "" {
|
||||
assigned = "-"
|
||||
}
|
||||
title := truncateStr(item.Title, colWidths[4])
|
||||
|
||||
// For cursor row, use plain text with unified background
|
||||
if isCursor {
|
||||
rowStyle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#F9FAFB")).
|
||||
Background(lipgloss.Color("#7C3AED")). // Purple highlight
|
||||
Width(w.width - 4)
|
||||
|
||||
// Build plain text cells (no individual colors)
|
||||
cells := []string{
|
||||
padRight(truncateStr(id, colWidths[0]), colWidths[0]),
|
||||
padRight(truncateStr(typeStr, colWidths[1]), colWidths[1]),
|
||||
padRight(truncateStr(stateStr, colWidths[2]), colWidths[2]),
|
||||
padRight(assigned, colWidths[3]),
|
||||
padRight(title, colWidths[4]),
|
||||
}
|
||||
row := cursor + strings.Join(cells, " ")
|
||||
return rowStyle.Render(row)
|
||||
}
|
||||
|
||||
// Non-cursor rows with individual cell colors
|
||||
idStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#60A5FA"))
|
||||
typeStyle := w.styles.TypeBadge(string(item.Type))
|
||||
stateStyle := w.styles.StateBadge(string(item.State))
|
||||
assignedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#9CA3AF"))
|
||||
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#F9FAFB"))
|
||||
|
||||
// Build cells with proper width and colors
|
||||
cells := []string{
|
||||
idStyle.Width(colWidths[0]).Render(truncateStr(id, colWidths[0])),
|
||||
typeStyle.Width(colWidths[1]).Render(truncateStr(typeStr, colWidths[1])),
|
||||
stateStyle.Width(colWidths[2]).Render(truncateStr(stateStr, colWidths[2])),
|
||||
assignedStyle.Width(colWidths[3]).Render(assigned),
|
||||
titleStyle.Width(colWidths[4]).Render(title),
|
||||
}
|
||||
|
||||
row := cursor + strings.Join(cells, " ")
|
||||
return row
|
||||
}
|
||||
|
||||
func padRight(s string, width int) string {
|
||||
if len(s) >= width {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", width-len(s))
|
||||
}
|
||||
|
||||
func truncateStr(s string, maxLen int) string {
|
||||
if maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
if maxLen <= 3 {
|
||||
return s[:maxLen]
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
func (w *WorkItemsPanel) visibleItemCount() int {
|
||||
visible := w.height - 5 // header, separator, borders
|
||||
if visible < 1 {
|
||||
visible = 1
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
func (w *WorkItemsPanel) moveUp() {
|
||||
if w.cursor > 0 {
|
||||
w.cursor--
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WorkItemsPanel) moveDown() {
|
||||
if w.cursor < len(w.items)-1 {
|
||||
w.cursor++
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WorkItemsPanel) moveToTop() {
|
||||
w.cursor = 0
|
||||
w.offset = 0
|
||||
}
|
||||
|
||||
func (w *WorkItemsPanel) moveToBottom() {
|
||||
if len(w.items) > 0 {
|
||||
w.cursor = len(w.items) - 1
|
||||
}
|
||||
}
|
||||
|
||||
// SetSize sets the size of the work items panel
|
||||
func (w *WorkItemsPanel) SetSize(width, height int) {
|
||||
w.width = width
|
||||
w.height = height
|
||||
|
||||
// Adjust offset to keep cursor visible (now that we have correct height)
|
||||
visible := w.visibleItemCount()
|
||||
if w.cursor < w.offset {
|
||||
w.offset = w.cursor
|
||||
}
|
||||
if w.cursor >= w.offset+visible {
|
||||
w.offset = w.cursor - visible + 1
|
||||
}
|
||||
if w.offset < 0 {
|
||||
w.offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
// SetFocused sets whether the panel is focused
|
||||
func (w *WorkItemsPanel) SetFocused(focused bool) {
|
||||
w.focused = focused
|
||||
}
|
||||
|
||||
// SetItems sets the work items
|
||||
func (w *WorkItemsPanel) SetItems(items []models.WorkItem) {
|
||||
oldLen := len(w.items)
|
||||
w.items = items
|
||||
|
||||
// Only reset position if this is new data (not just a refresh)
|
||||
if oldLen == 0 && len(items) > 0 {
|
||||
w.cursor = 0
|
||||
w.offset = 0
|
||||
}
|
||||
|
||||
// Clamp cursor to valid range
|
||||
if w.cursor >= len(items) {
|
||||
w.cursor = len(items) - 1
|
||||
}
|
||||
if w.cursor < 0 {
|
||||
w.cursor = 0
|
||||
}
|
||||
}
|
||||
|
||||
// SelectedItem returns the currently selected work item
|
||||
func (w *WorkItemsPanel) SelectedItem() *models.WorkItem {
|
||||
if w.cursor >= 0 && w.cursor < len(w.items) {
|
||||
return &w.items[w.cursor]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenWorkItemMsg is sent when a work item should be opened in browser
|
||||
type OpenWorkItemMsg struct {
|
||||
Item models.WorkItem
|
||||
}
|
||||
|
||||
// ViewWorkItemMsg is sent when a work item should be viewed in detail
|
||||
type ViewWorkItemMsg struct {
|
||||
Item models.WorkItem
|
||||
}
|
||||
Reference in New Issue
Block a user