Files
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

224 lines
5.0 KiB
Go

package components
import (
"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"
)
var defaultStates = []string{"New", "Active", "Resolved", "Closed"}
// StateModal is a modal for changing work item state
type StateModal struct {
visible bool
item *models.WorkItem
states []string
statesByType map[string][]models.WorkItemStateInfo
cursor int
styles theme.Styles
keys theme.KeyMap
width int
height int
}
// NewStateModal creates a new state modal
func NewStateModal(styles theme.Styles, keys theme.KeyMap) StateModal {
return StateModal{
states: defaultStates,
styles: styles,
keys: keys,
}
}
// SetStatesByType sets the available states per work item type
func (m *StateModal) SetStatesByType(statesByType map[string][]models.WorkItemStateInfo) {
m.statesByType = statesByType
}
// Init initializes the modal
func (m StateModal) Init() tea.Cmd {
return nil
}
// Update handles messages
func (m StateModal) Update(msg tea.Msg) (StateModal, tea.Cmd) {
if !m.visible {
return m, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch {
case key.Matches(msg, m.keys.Up):
if m.cursor > 0 {
m.cursor--
}
case key.Matches(msg, m.keys.Down):
if m.cursor < len(m.states)-1 {
m.cursor++
}
case key.Matches(msg, m.keys.Select):
if m.item != nil {
newState := m.states[m.cursor]
return m, func() tea.Msg {
return StateChangeRequestMsg{
Item: *m.item,
NewState: newState,
}
}
}
case key.Matches(msg, m.keys.Back):
m.visible = false
return m, func() tea.Msg { return ModalClosedMsg{} }
}
}
return m, nil
}
// View renders the modal
func (m StateModal) View() string {
if !m.visible {
return ""
}
// Modal dimensions
modalWidth := 40
modalHeight := len(m.states) + 6
// Build content
var b strings.Builder
// Title
title := "Change State"
if m.item != nil {
title = lipgloss.NewStyle().Bold(true).Render("Change State")
itemInfo := lipgloss.NewStyle().
Foreground(lipgloss.Color("#9CA3AF")).
Render("#" + itoa(m.item.ID) + " " + truncateStr(m.item.Title, 25))
b.WriteString(title + "\n")
b.WriteString(itemInfo + "\n\n")
}
// Current state indicator
if m.item != nil {
currentStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#60A5FA"))
b.WriteString(currentStyle.Render("Current: "+string(m.item.State)) + "\n\n")
}
// State options
for i, state := range m.states {
cursor := " "
if i == m.cursor {
cursor = "▸ "
}
style := lipgloss.NewStyle()
if i == m.cursor {
style = style.Bold(true).Foreground(lipgloss.Color("#7C3AED"))
}
// Highlight if this is the current state
if m.item != nil && state == string(m.item.State) {
style = style.Foreground(lipgloss.Color("#10B981"))
}
b.WriteString(cursor + style.Render(state) + "\n")
}
// Help text
b.WriteString("\n")
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280"))
b.WriteString(helpStyle.Render("Enter: confirm Esc: cancel"))
// Modal style
modalStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7C3AED")).
Padding(1, 2).
Width(modalWidth).
Height(modalHeight).
Background(lipgloss.Color("#1F2937"))
modal := modalStyle.Render(b.String())
// Center the modal
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal)
}
// SetVisible sets the visibility
func (m *StateModal) SetVisible(visible bool) {
m.visible = visible
if visible {
m.cursor = 0
// Try to set cursor to current state
if m.item != nil {
for i, state := range m.states {
if state == string(m.item.State) {
m.cursor = i
break
}
}
}
}
}
// IsVisible returns whether the modal is visible
func (m *StateModal) IsVisible() bool {
return m.visible
}
// SetItem sets the work item to modify
func (m *StateModal) SetItem(item *models.WorkItem) {
m.item = item
// Update states based on work item type
if item != nil && m.statesByType != nil {
if states, ok := m.statesByType[string(item.Type)]; ok && len(states) > 0 {
m.states = make([]string, len(states))
for i, s := range states {
m.states[i] = s.Name
}
return
}
}
// Fallback to defaults
m.states = defaultStates
}
// SetSize sets the modal container size
func (m *StateModal) SetSize(width, height int) {
m.width = width
m.height = height
}
// StateChangeRequestMsg is sent when user confirms state change
type StateChangeRequestMsg struct {
Item models.WorkItem
NewState string
}
// StateChangedMsg is sent when state change is complete
type StateChangedMsg struct {
Item models.WorkItem
}
// ModalClosedMsg is sent when a modal is closed
type ModalClosedMsg struct{}
// Helper function
func itoa(n int) string {
if n == 0 {
return "0"
}
var digits []byte
for n > 0 {
digits = append([]byte{byte('0' + n%10)}, digits...)
n /= 10
}
return string(digits)
}