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>
252 lines
5.8 KiB
Go
252 lines
5.8 KiB
Go
package components
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/key"
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/samuelenocsson/devops-tui/internal/models"
|
|
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
|
|
)
|
|
|
|
// BranchModal is a modal for creating a branch linked to a work item
|
|
type BranchModal struct {
|
|
visible bool
|
|
item *models.WorkItem
|
|
textInput textinput.Model
|
|
styles theme.Styles
|
|
keys theme.KeyMap
|
|
width int
|
|
height int
|
|
err error
|
|
}
|
|
|
|
// NewBranchModal creates a new branch modal
|
|
func NewBranchModal(styles theme.Styles, keys theme.KeyMap) BranchModal {
|
|
ti := textinput.New()
|
|
ti.Placeholder = "feature/123-task-name"
|
|
ti.CharLimit = 100
|
|
ti.Width = 35
|
|
|
|
return BranchModal{
|
|
textInput: ti,
|
|
styles: styles,
|
|
keys: keys,
|
|
}
|
|
}
|
|
|
|
// Init initializes the modal
|
|
func (m BranchModal) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
// Update handles messages
|
|
func (m BranchModal) Update(msg tea.Msg) (BranchModal, tea.Cmd) {
|
|
if !m.visible {
|
|
return m, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch {
|
|
case key.Matches(msg, m.keys.Back):
|
|
m.visible = false
|
|
m.err = nil
|
|
return m, func() tea.Msg { return ModalClosedMsg{} }
|
|
case msg.Type == tea.KeyEnter:
|
|
branchName := strings.TrimSpace(m.textInput.Value())
|
|
if branchName == "" {
|
|
m.err = fmt.Errorf("branch name cannot be empty")
|
|
return m, nil
|
|
}
|
|
if !isValidBranchName(branchName) {
|
|
m.err = fmt.Errorf("invalid branch name")
|
|
return m, nil
|
|
}
|
|
m.err = nil
|
|
return m, func() tea.Msg {
|
|
return BranchCreateRequestMsg{
|
|
Item: *m.item,
|
|
BranchName: branchName,
|
|
}
|
|
}
|
|
default:
|
|
m.textInput, cmd = m.textInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
}
|
|
|
|
m.textInput, cmd = m.textInput.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
// View renders the modal
|
|
func (m BranchModal) View() string {
|
|
if !m.visible {
|
|
return ""
|
|
}
|
|
|
|
// Modal dimensions
|
|
modalWidth := 50
|
|
modalHeight := 10
|
|
|
|
// Build content
|
|
var b strings.Builder
|
|
|
|
// Title
|
|
title := lipgloss.NewStyle().Bold(true).Render("Create Branch")
|
|
b.WriteString(title + "\n")
|
|
|
|
// Item info
|
|
if m.item != nil {
|
|
itemInfo := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("#9CA3AF")).
|
|
Render("#" + itoa(m.item.ID) + " " + truncateStr(m.item.Title, 35))
|
|
b.WriteString(itemInfo + "\n\n")
|
|
}
|
|
|
|
// Label
|
|
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#D1D5DB"))
|
|
b.WriteString(labelStyle.Render("Branch name:") + "\n")
|
|
|
|
// Text input
|
|
b.WriteString(m.textInput.View() + "\n")
|
|
|
|
// Error message
|
|
if m.err != nil {
|
|
errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444"))
|
|
b.WriteString(errStyle.Render(m.err.Error()) + "\n")
|
|
} else {
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
// Help text
|
|
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280"))
|
|
b.WriteString(helpStyle.Render("Enter: create 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 *BranchModal) SetVisible(visible bool) {
|
|
m.visible = visible
|
|
m.err = nil
|
|
if visible {
|
|
m.textInput.Focus()
|
|
// Generate suggested branch name from work item
|
|
if m.item != nil {
|
|
suggested := generateBranchName(m.item)
|
|
m.textInput.SetValue(suggested)
|
|
m.textInput.CursorEnd()
|
|
}
|
|
} else {
|
|
m.textInput.Blur()
|
|
}
|
|
}
|
|
|
|
// IsVisible returns whether the modal is visible
|
|
func (m *BranchModal) IsVisible() bool {
|
|
return m.visible
|
|
}
|
|
|
|
// SetItem sets the work item to link
|
|
func (m *BranchModal) SetItem(item *models.WorkItem) {
|
|
m.item = item
|
|
}
|
|
|
|
// SetSize sets the modal container size
|
|
func (m *BranchModal) SetSize(width, height int) {
|
|
m.width = width
|
|
m.height = height
|
|
}
|
|
|
|
// generateBranchName generates a suggested branch name from work item
|
|
func generateBranchName(item *models.WorkItem) string {
|
|
// Get work item type prefix
|
|
prefix := "feature"
|
|
switch item.Type {
|
|
case models.WorkItemTypeBug:
|
|
prefix = "bugfix"
|
|
case models.WorkItemTypeTask:
|
|
prefix = "task"
|
|
case models.WorkItemTypeStory:
|
|
prefix = "feature"
|
|
case models.WorkItemTypeEpic:
|
|
prefix = "epic"
|
|
}
|
|
|
|
// Sanitize title for branch name
|
|
title := strings.ToLower(item.Title)
|
|
// Replace spaces and special chars with hyphens
|
|
re := regexp.MustCompile(`[^a-z0-9]+`)
|
|
title = re.ReplaceAllString(title, "-")
|
|
// Remove leading/trailing hyphens
|
|
title = strings.Trim(title, "-")
|
|
// Truncate to reasonable length
|
|
if len(title) > 40 {
|
|
title = title[:40]
|
|
// Don't cut off in middle of a word if possible
|
|
if lastHyphen := strings.LastIndex(title, "-"); lastHyphen > 20 {
|
|
title = title[:lastHyphen]
|
|
}
|
|
}
|
|
|
|
return fmt.Sprintf("%s/%d-%s", prefix, item.ID, title)
|
|
}
|
|
|
|
// isValidBranchName validates branch name
|
|
func isValidBranchName(name string) bool {
|
|
// Basic validation for git branch names
|
|
if name == "" {
|
|
return false
|
|
}
|
|
// Check for invalid characters
|
|
invalid := regexp.MustCompile(`[\s~^:?*\[\]\\]`)
|
|
if invalid.MatchString(name) {
|
|
return false
|
|
}
|
|
// Can't start or end with /
|
|
if strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") {
|
|
return false
|
|
}
|
|
// Can't have consecutive dots
|
|
if strings.Contains(name, "..") {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// BranchCreateRequestMsg is sent when user confirms branch creation
|
|
type BranchCreateRequestMsg struct {
|
|
Item models.WorkItem
|
|
BranchName string
|
|
}
|
|
|
|
// BranchCreatedMsg is sent when branch creation is complete
|
|
type BranchCreatedMsg struct {
|
|
BranchName string
|
|
}
|
|
|
|
// BranchCreateErrorMsg is sent when branch creation fails
|
|
type BranchCreateErrorMsg struct {
|
|
Err error
|
|
}
|