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