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,532 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/samuelenocsson/devops-tui/internal/api"
|
||||
"github.com/samuelenocsson/devops-tui/internal/config"
|
||||
"github.com/samuelenocsson/devops-tui/internal/models"
|
||||
"github.com/samuelenocsson/devops-tui/internal/ui/components"
|
||||
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
|
||||
"github.com/samuelenocsson/devops-tui/pkg/browser"
|
||||
"github.com/samuelenocsson/devops-tui/pkg/git"
|
||||
)
|
||||
|
||||
// Panel represents the active panel
|
||||
type Panel int
|
||||
|
||||
const (
|
||||
PanelFilter Panel = iota
|
||||
PanelWorkItems
|
||||
)
|
||||
|
||||
// ViewMode represents the current view mode
|
||||
type ViewMode int
|
||||
|
||||
const (
|
||||
ViewMain ViewMode = iota
|
||||
ViewDetail
|
||||
)
|
||||
|
||||
// App is the main application model
|
||||
type App struct {
|
||||
// Components
|
||||
filterPanel components.FilterPanel
|
||||
workItemsPanel components.WorkItemsPanel
|
||||
detailsPanel components.DetailsPanel
|
||||
detailView components.DetailView
|
||||
helpPanel components.HelpPanel
|
||||
stateModal components.StateModal
|
||||
branchModal components.BranchModal
|
||||
|
||||
// State
|
||||
activePanel Panel
|
||||
viewMode ViewMode
|
||||
loading bool
|
||||
err error
|
||||
statusMsg string // Temporary status message
|
||||
|
||||
// Data
|
||||
iterations []models.Iteration
|
||||
areas []models.Area
|
||||
workItems []models.WorkItem
|
||||
statesByType map[string][]models.WorkItemStateInfo
|
||||
|
||||
// Services
|
||||
client *api.Client
|
||||
|
||||
// Config
|
||||
styles theme.Styles
|
||||
keys theme.KeyMap
|
||||
|
||||
// Dimensions
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// NewApp creates a new application
|
||||
func NewApp(client *api.Client) App {
|
||||
styles := theme.DefaultStyles()
|
||||
keys := theme.DefaultKeyMap()
|
||||
|
||||
// Create empty filter state (will be populated after loading data)
|
||||
filterState := models.NewFilterState(nil, nil, nil)
|
||||
|
||||
return App{
|
||||
filterPanel: components.NewFilterPanel(filterState, styles, keys),
|
||||
workItemsPanel: components.NewWorkItemsPanel(styles, keys),
|
||||
detailsPanel: components.NewDetailsPanel(styles),
|
||||
detailView: components.NewDetailView(styles, keys),
|
||||
helpPanel: components.NewHelpPanel(keys, styles),
|
||||
stateModal: components.NewStateModal(styles, keys),
|
||||
branchModal: components.NewBranchModal(styles, keys),
|
||||
activePanel: PanelWorkItems,
|
||||
viewMode: ViewMain,
|
||||
loading: true,
|
||||
client: client,
|
||||
styles: styles,
|
||||
keys: keys,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the application
|
||||
func (a App) Init() tea.Cmd {
|
||||
return tea.Batch(
|
||||
loadDataCmd(a.client),
|
||||
)
|
||||
}
|
||||
|
||||
// Update handles messages
|
||||
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmds []tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
a.width = msg.Width
|
||||
a.height = msg.Height
|
||||
a.updateSizes()
|
||||
|
||||
case tea.KeyMsg:
|
||||
// Handle modals first (they capture all input when visible)
|
||||
if a.stateModal.IsVisible() {
|
||||
newModal, cmd := a.stateModal.Update(msg)
|
||||
a.stateModal = newModal
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
if a.branchModal.IsVisible() {
|
||||
newModal, cmd := a.branchModal.Update(msg)
|
||||
a.branchModal = newModal
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// Global keys
|
||||
if key.Matches(msg, a.keys.Quit) && !a.helpPanel.IsVisible() && a.viewMode == ViewMain {
|
||||
return a, tea.Quit
|
||||
}
|
||||
|
||||
if key.Matches(msg, a.keys.Help) {
|
||||
a.helpPanel.Toggle()
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Close help with any key if visible
|
||||
if a.helpPanel.IsVisible() {
|
||||
if key.Matches(msg, a.keys.Back) || key.Matches(msg, a.keys.Help) {
|
||||
a.helpPanel.SetVisible(false)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Handle detail view mode
|
||||
if a.viewMode == ViewDetail {
|
||||
newDetailView, cmd := a.detailView.Update(msg)
|
||||
a.detailView = newDetailView
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// Panel switching
|
||||
if key.Matches(msg, a.keys.NextPanel) {
|
||||
a.nextPanel()
|
||||
a.updateFocus()
|
||||
}
|
||||
if key.Matches(msg, a.keys.PrevPanel) {
|
||||
a.prevPanel()
|
||||
a.updateFocus()
|
||||
}
|
||||
|
||||
// Refresh
|
||||
if key.Matches(msg, a.keys.Refresh) {
|
||||
a.loading = true
|
||||
a.statusMsg = ""
|
||||
return a, loadWorkItemsCmd(a.client, a.filterPanel.FilterState())
|
||||
}
|
||||
|
||||
// Open state change modal (only when work items panel is active)
|
||||
if key.Matches(msg, a.keys.ChangeState) && a.activePanel == PanelWorkItems {
|
||||
if item := a.workItemsPanel.SelectedItem(); item != nil {
|
||||
a.stateModal.SetItem(item)
|
||||
a.stateModal.SetSize(a.width, a.height)
|
||||
a.stateModal.SetVisible(true)
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Open branch modal (only when work items panel is active)
|
||||
if key.Matches(msg, a.keys.CreateBranch) && a.activePanel == PanelWorkItems {
|
||||
if item := a.workItemsPanel.SelectedItem(); item != nil {
|
||||
a.branchModal.SetItem(item)
|
||||
a.branchModal.SetSize(a.width, a.height)
|
||||
a.branchModal.SetVisible(true)
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Update active panel
|
||||
switch a.activePanel {
|
||||
case PanelFilter:
|
||||
newFilter, cmd := a.filterPanel.Update(msg)
|
||||
a.filterPanel = newFilter
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
case PanelWorkItems:
|
||||
newWorkItems, cmd := a.workItemsPanel.Update(msg)
|
||||
a.workItemsPanel = newWorkItems
|
||||
if cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
case dataLoadedMsg:
|
||||
a.iterations = msg.iterations
|
||||
a.areas = msg.areas
|
||||
a.statesByType = msg.statesByType
|
||||
a.stateModal.SetStatesByType(a.statesByType)
|
||||
filterState := models.NewFilterState(a.iterations, a.areas, a.statesByType)
|
||||
|
||||
// Apply saved filter selections
|
||||
if savedState, err := config.LoadFilterState(); err == nil {
|
||||
filterState.ApplySavedSelections(savedState.Sprint, savedState.State, savedState.Assigned, savedState.Area)
|
||||
}
|
||||
|
||||
a.filterPanel.SetFilterState(filterState)
|
||||
// Load work items with initial filters
|
||||
return a, loadWorkItemsCmd(a.client, filterState)
|
||||
|
||||
case workItemsLoadedMsg:
|
||||
a.loading = false
|
||||
a.workItems = msg.items
|
||||
a.workItemsPanel.SetItems(msg.items)
|
||||
a.updateSelectedItem()
|
||||
|
||||
case components.FilterChangedMsg:
|
||||
a.loading = true
|
||||
fs := a.filterPanel.FilterState()
|
||||
|
||||
// Save filter selections for next startup
|
||||
_ = config.SaveFilterState(&config.FilterState{
|
||||
Sprint: fs.GetSelectedSprint(),
|
||||
State: fs.GetSelectedState(),
|
||||
Assigned: fs.GetSelectedAssigned(),
|
||||
Area: fs.GetSelectedArea(),
|
||||
})
|
||||
|
||||
return a, loadWorkItemsCmd(a.client, fs)
|
||||
|
||||
case components.OpenWorkItemMsg:
|
||||
if err := browser.Open(msg.Item.WebURL); err != nil {
|
||||
a.err = err
|
||||
}
|
||||
|
||||
case components.ViewWorkItemMsg:
|
||||
a.viewMode = ViewDetail
|
||||
a.detailView.SetItem(&msg.Item)
|
||||
a.updateSizes()
|
||||
|
||||
case components.CloseDetailViewMsg:
|
||||
a.viewMode = ViewMain
|
||||
|
||||
case errMsg:
|
||||
a.loading = false
|
||||
a.err = msg.err
|
||||
|
||||
case components.ModalClosedMsg:
|
||||
// Modal was closed, nothing special to do
|
||||
a.stateModal.SetVisible(false)
|
||||
a.branchModal.SetVisible(false)
|
||||
|
||||
case components.StateChangeRequestMsg:
|
||||
a.stateModal.SetVisible(false)
|
||||
a.loading = true
|
||||
return a, updateWorkItemStateCmd(a.client, msg.Item.ID, msg.NewState, a.filterPanel.FilterState())
|
||||
|
||||
case stateChangedMsg:
|
||||
a.loading = false
|
||||
a.statusMsg = fmt.Sprintf("State changed to %s", msg.newState)
|
||||
// Refresh work items to show updated state
|
||||
return a, loadWorkItemsCmd(a.client, a.filterPanel.FilterState())
|
||||
|
||||
case components.BranchCreateRequestMsg:
|
||||
a.branchModal.SetVisible(false)
|
||||
return a, createBranchCmd(msg.BranchName)
|
||||
|
||||
case components.BranchCreatedMsg:
|
||||
a.statusMsg = fmt.Sprintf("Branch created: %s", msg.BranchName)
|
||||
|
||||
case components.BranchCreateErrorMsg:
|
||||
a.err = msg.Err
|
||||
}
|
||||
|
||||
// Update selected item in details panel
|
||||
a.updateSelectedItem()
|
||||
|
||||
return a, tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// View renders the application
|
||||
func (a App) View() string {
|
||||
if a.width == 0 || a.height == 0 {
|
||||
return "Loading..."
|
||||
}
|
||||
|
||||
// Render state modal if visible
|
||||
if a.stateModal.IsVisible() {
|
||||
return a.stateModal.View()
|
||||
}
|
||||
|
||||
// Render branch modal if visible
|
||||
if a.branchModal.IsVisible() {
|
||||
return a.branchModal.View()
|
||||
}
|
||||
|
||||
// Render help overlay if visible
|
||||
if a.helpPanel.IsVisible() {
|
||||
_ = a.renderMainView()
|
||||
help := a.helpPanel.View()
|
||||
return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, help)
|
||||
}
|
||||
|
||||
// Render detail view if in detail mode
|
||||
if a.viewMode == ViewDetail {
|
||||
return a.detailView.View()
|
||||
}
|
||||
|
||||
return a.renderMainView()
|
||||
}
|
||||
|
||||
func (a *App) renderMainView() string {
|
||||
// Calculate dimensions
|
||||
// Borders add 2 chars per panel (1 left + 1 right)
|
||||
// Two panels side by side = 4 total border overhead
|
||||
filterWidth := int(float64(a.width) * 0.20)
|
||||
if filterWidth < 20 {
|
||||
filterWidth = 20
|
||||
}
|
||||
contentWidth := a.width - filterWidth - 4
|
||||
|
||||
// Available height: total - title bar (1) - status bar (1) = a.height - 2
|
||||
// Filter panel: content height + border (2) = available height
|
||||
availableHeight := a.height - 2
|
||||
filterContentHeight := availableHeight - 2
|
||||
|
||||
// Right side has two panels stacked, each with border (2 each = 4 total)
|
||||
rightContentHeight := availableHeight - 4
|
||||
workItemsHeight := int(float64(rightContentHeight) * 0.55)
|
||||
if workItemsHeight < 8 {
|
||||
workItemsHeight = 8
|
||||
}
|
||||
detailsHeight := rightContentHeight - workItemsHeight
|
||||
|
||||
// Title bar
|
||||
title := a.styles.Title.Render("devops-tui")
|
||||
projectInfo := a.styles.Subtitle.Render(fmt.Sprintf("%s/%s", a.client.Organization(), a.client.Project()))
|
||||
titleBar := lipgloss.JoinHorizontal(lipgloss.Left, title, " ", projectInfo)
|
||||
|
||||
// Loading indicator
|
||||
if a.loading {
|
||||
titleBar += " " + a.styles.Subtitle.Render("Loading...")
|
||||
}
|
||||
|
||||
// Error display
|
||||
if a.err != nil {
|
||||
errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444"))
|
||||
titleBar += " " + errStyle.Render(a.err.Error())
|
||||
}
|
||||
|
||||
// Status message
|
||||
if a.statusMsg != "" {
|
||||
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981"))
|
||||
titleBar += " " + statusStyle.Render(a.statusMsg)
|
||||
}
|
||||
|
||||
// Set panel sizes (content dimensions, borders added by styles)
|
||||
a.filterPanel.SetSize(filterWidth, filterContentHeight)
|
||||
a.workItemsPanel.SetSize(contentWidth, workItemsHeight)
|
||||
a.detailsPanel.SetSize(contentWidth, detailsHeight)
|
||||
|
||||
// Render panels
|
||||
filterView := a.filterPanel.View()
|
||||
workItemsView := a.workItemsPanel.View()
|
||||
detailsView := a.detailsPanel.View()
|
||||
|
||||
// Right side (work items + details)
|
||||
rightSide := lipgloss.JoinVertical(lipgloss.Left, workItemsView, detailsView)
|
||||
|
||||
// Main content
|
||||
mainContent := lipgloss.JoinHorizontal(lipgloss.Top, filterView, rightSide)
|
||||
|
||||
// Status bar
|
||||
statusBar := a.renderStatusBar()
|
||||
|
||||
// Combine all
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
titleBar,
|
||||
mainContent,
|
||||
statusBar,
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) renderStatusBar() string {
|
||||
var parts []string
|
||||
|
||||
// Panel indicator
|
||||
panelName := "Filter"
|
||||
if a.activePanel == PanelWorkItems {
|
||||
panelName = "Work Items"
|
||||
}
|
||||
parts = append(parts, a.styles.HelpKey.Render("Panel")+": "+panelName)
|
||||
|
||||
// Short help
|
||||
help := components.ShortHelp(a.keys, a.styles)
|
||||
parts = append(parts, help)
|
||||
|
||||
return a.styles.StatusBar.Width(a.width).Render(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func (a *App) nextPanel() {
|
||||
if a.activePanel == PanelFilter {
|
||||
a.activePanel = PanelWorkItems
|
||||
} else {
|
||||
a.activePanel = PanelFilter
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) prevPanel() {
|
||||
if a.activePanel == PanelWorkItems {
|
||||
a.activePanel = PanelFilter
|
||||
} else {
|
||||
a.activePanel = PanelWorkItems
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) updateFocus() {
|
||||
a.filterPanel.SetFocused(a.activePanel == PanelFilter)
|
||||
a.workItemsPanel.SetFocused(a.activePanel == PanelWorkItems)
|
||||
}
|
||||
|
||||
func (a *App) updateSizes() {
|
||||
a.helpPanel.SetSize(a.width, a.height)
|
||||
a.detailView.SetSize(a.width, a.height)
|
||||
a.updateFocus()
|
||||
}
|
||||
|
||||
func (a *App) updateSelectedItem() {
|
||||
item := a.workItemsPanel.SelectedItem()
|
||||
a.detailsPanel.SetItem(item)
|
||||
}
|
||||
|
||||
// Message types
|
||||
|
||||
type dataLoadedMsg struct {
|
||||
iterations []models.Iteration
|
||||
areas []models.Area
|
||||
statesByType map[string][]models.WorkItemStateInfo
|
||||
}
|
||||
|
||||
type workItemsLoadedMsg struct {
|
||||
items []models.WorkItem
|
||||
}
|
||||
|
||||
type errMsg struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type stateChangedMsg struct {
|
||||
newState string
|
||||
}
|
||||
|
||||
// Commands
|
||||
|
||||
func loadDataCmd(client *api.Client) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
iterations, err := client.GetIterations()
|
||||
if err != nil {
|
||||
return errMsg{err: err}
|
||||
}
|
||||
areas, err := client.GetAreas()
|
||||
if err != nil {
|
||||
return errMsg{err: err}
|
||||
}
|
||||
statesByType, err := client.GetAllWorkItemTypeStates()
|
||||
if err != nil {
|
||||
// Non-fatal - we can still work with hardcoded states
|
||||
statesByType = make(map[string][]models.WorkItemStateInfo)
|
||||
}
|
||||
return dataLoadedMsg{iterations: iterations, areas: areas, statesByType: statesByType}
|
||||
}
|
||||
}
|
||||
|
||||
func loadWorkItemsCmd(client *api.Client, filterState *models.FilterState) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
sprint := filterState.GetSelectedSprint()
|
||||
state := filterState.GetSelectedState()
|
||||
assigned := filterState.GetSelectedAssigned()
|
||||
area := filterState.GetSelectedArea()
|
||||
|
||||
items, err := client.QueryWorkItems(sprint, state, assigned, area)
|
||||
if err != nil {
|
||||
return errMsg{err: err}
|
||||
}
|
||||
return workItemsLoadedMsg{items: items}
|
||||
}
|
||||
}
|
||||
|
||||
func updateWorkItemStateCmd(client *api.Client, itemID int, newState string, filterState *models.FilterState) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
err := client.UpdateWorkItemState(itemID, newState)
|
||||
if err != nil {
|
||||
return errMsg{err: err}
|
||||
}
|
||||
return stateChangedMsg{newState: newState}
|
||||
}
|
||||
}
|
||||
|
||||
func createBranchCmd(branchName string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if !git.IsGitRepo() {
|
||||
return components.BranchCreateErrorMsg{Err: fmt.Errorf("not a git repository")}
|
||||
}
|
||||
if git.HasUncommittedChanges() {
|
||||
return components.BranchCreateErrorMsg{Err: fmt.Errorf("uncommitted changes exist")}
|
||||
}
|
||||
err := git.CreateBranch(branchName, true)
|
||||
if err != nil {
|
||||
return components.BranchCreateErrorMsg{Err: err}
|
||||
}
|
||||
return components.BranchCreatedMsg{BranchName: branchName}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/glamour"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/samuelenocsson/devops-tui/internal/models"
|
||||
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
|
||||
)
|
||||
|
||||
// DetailsPanel shows details for a selected work item
|
||||
type DetailsPanel struct {
|
||||
item *models.WorkItem
|
||||
styles theme.Styles
|
||||
width int
|
||||
height int
|
||||
renderedDesc string
|
||||
renderedDescWidth int
|
||||
}
|
||||
|
||||
// NewDetailsPanel creates a new details panel
|
||||
func NewDetailsPanel(styles theme.Styles) DetailsPanel {
|
||||
return DetailsPanel{
|
||||
styles: styles,
|
||||
}
|
||||
}
|
||||
|
||||
// View renders the details panel
|
||||
func (d DetailsPanel) View() string {
|
||||
if d.item == nil {
|
||||
content := d.styles.Subtitle.Render("Select a work item to view details")
|
||||
return d.styles.PanelInactive.
|
||||
Width(d.width).
|
||||
Height(d.height).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Title
|
||||
title := fmt.Sprintf("#%d %s", d.item.ID, d.item.Title)
|
||||
if len(title) > d.width-6 {
|
||||
title = title[:d.width-9] + "..."
|
||||
}
|
||||
b.WriteString(d.styles.DetailTitle.Render(title))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Metadata section with aligned columns
|
||||
typeStyle := d.styles.TypeBadge(string(d.item.Type))
|
||||
stateStyle := d.styles.StateBadge(string(d.item.State))
|
||||
|
||||
labelWidth := 10
|
||||
valueWidth := 12
|
||||
|
||||
// Type and State row
|
||||
b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Type:"))
|
||||
b.WriteString(typeStyle.Width(valueWidth).Render(d.item.ShortType()))
|
||||
b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("State:"))
|
||||
b.WriteString(stateStyle.Render(string(d.item.State)))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Assigned and Sprint row
|
||||
assignedTo := d.item.AssignedTo
|
||||
if assignedTo == "" {
|
||||
assignedTo = "Unassigned"
|
||||
}
|
||||
b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Assigned:"))
|
||||
b.WriteString(d.styles.DetailValue.Width(valueWidth).Render(truncate(assignedTo, valueWidth)))
|
||||
b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Sprint:"))
|
||||
b.WriteString(d.styles.DetailValue.Render(d.item.SprintName()))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Area row
|
||||
b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Area:"))
|
||||
b.WriteString(d.styles.DetailValue.Render(d.item.AreaName()))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Parent (if exists)
|
||||
if d.item.ParentID > 0 {
|
||||
b.WriteString("\n")
|
||||
parentLabel := fmt.Sprintf("Parent: #%d", d.item.ParentID)
|
||||
if d.item.ParentTitle != "" {
|
||||
parentLabel += " " + d.item.ParentTitle
|
||||
}
|
||||
b.WriteString(d.styles.Subtitle.Render(parentLabel))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Description section
|
||||
if d.item.Description != "" {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(d.styles.DetailSectionTitle.Render("─── Description ───"))
|
||||
b.WriteString("\n")
|
||||
|
||||
maxWidth := d.width - 8
|
||||
if maxWidth < 20 {
|
||||
maxWidth = 20
|
||||
}
|
||||
|
||||
// Use cached rendered description if available and width matches
|
||||
desc := d.renderedDesc
|
||||
if desc == "" || d.renderedDescWidth != maxWidth {
|
||||
desc = d.renderMarkdown(d.item.Description, maxWidth)
|
||||
}
|
||||
|
||||
// Limit description height
|
||||
lines := strings.Split(desc, "\n")
|
||||
maxLines := d.height - 12
|
||||
if maxLines < 3 {
|
||||
maxLines = 3
|
||||
}
|
||||
if len(lines) > maxLines {
|
||||
lines = lines[:maxLines]
|
||||
lines = append(lines, "...")
|
||||
}
|
||||
|
||||
b.WriteString(strings.Join(lines, "\n"))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Tags section
|
||||
if len(d.item.Tags) > 0 {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(d.styles.DetailSectionTitle.Render("─── Tags ───"))
|
||||
b.WriteString("\n")
|
||||
|
||||
var tagStrings []string
|
||||
for _, tag := range d.item.Tags {
|
||||
tagStrings = append(tagStrings, d.styles.DetailTag.Render(tag))
|
||||
}
|
||||
b.WriteString(strings.Join(tagStrings, " "))
|
||||
}
|
||||
|
||||
content := b.String()
|
||||
return d.styles.PanelInactive.
|
||||
Width(d.width).
|
||||
Height(d.height).
|
||||
BorderForeground(lipgloss.Color("#374151")).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
// SetItem sets the work item to display
|
||||
func (d *DetailsPanel) SetItem(item *models.WorkItem) {
|
||||
d.item = item
|
||||
d.renderedDesc = ""
|
||||
d.renderedDescWidth = 0
|
||||
}
|
||||
|
||||
// renderMarkdown renders markdown content and caches the result
|
||||
func (d *DetailsPanel) renderMarkdown(content string, width int) string {
|
||||
renderer, err := glamour.NewTermRenderer(
|
||||
glamour.WithStylePath("dark"),
|
||||
glamour.WithWordWrap(width),
|
||||
)
|
||||
|
||||
var result string
|
||||
if err == nil {
|
||||
rendered, renderErr := renderer.Render(content)
|
||||
if renderErr == nil {
|
||||
result = strings.TrimSpace(rendered)
|
||||
} else {
|
||||
result = wordWrap(content, width)
|
||||
}
|
||||
} else {
|
||||
result = wordWrap(content, width)
|
||||
}
|
||||
|
||||
d.renderedDesc = result
|
||||
d.renderedDescWidth = width
|
||||
return result
|
||||
}
|
||||
|
||||
// SetSize sets the size of the details panel
|
||||
func (d *DetailsPanel) SetSize(width, height int) {
|
||||
d.width = width
|
||||
d.height = height
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
}
|
||||
|
||||
func wordWrap(text string, width int) string {
|
||||
if width <= 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
words := strings.Fields(text)
|
||||
currentLineLength := 0
|
||||
|
||||
for i, word := range words {
|
||||
if currentLineLength+len(word)+1 > width {
|
||||
result.WriteString("\n")
|
||||
currentLineLength = 0
|
||||
} else if i > 0 {
|
||||
result.WriteString(" ")
|
||||
currentLineLength++
|
||||
}
|
||||
result.WriteString(word)
|
||||
currentLineLength += len(word)
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
@@ -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{}
|
||||
@@ -0,0 +1,223 @@
|
||||
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"
|
||||
)
|
||||
|
||||
const maxVisibleOptions = 6 // Max visible options per filter group
|
||||
|
||||
// FilterPanel is the filter panel component
|
||||
type FilterPanel struct {
|
||||
filterState *models.FilterState
|
||||
styles theme.Styles
|
||||
keys theme.KeyMap
|
||||
width int
|
||||
height int
|
||||
focused bool
|
||||
}
|
||||
|
||||
// NewFilterPanel creates a new filter panel
|
||||
func NewFilterPanel(filterState *models.FilterState, styles theme.Styles, keys theme.KeyMap) FilterPanel {
|
||||
return FilterPanel{
|
||||
filterState: filterState,
|
||||
styles: styles,
|
||||
keys: keys,
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the filter panel
|
||||
func (f FilterPanel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update handles messages for the filter panel
|
||||
func (f FilterPanel) Update(msg tea.Msg) (FilterPanel, tea.Cmd) {
|
||||
if !f.focused {
|
||||
return f, nil
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, f.keys.Up):
|
||||
if group := f.filterState.ActiveFilterGroup(); group != nil {
|
||||
group.MoveUp()
|
||||
f.adjustOffset(group)
|
||||
}
|
||||
case key.Matches(msg, f.keys.Down):
|
||||
if group := f.filterState.ActiveFilterGroup(); group != nil {
|
||||
group.MoveDown()
|
||||
f.adjustOffset(group)
|
||||
}
|
||||
case key.Matches(msg, f.keys.Top):
|
||||
if group := f.filterState.ActiveFilterGroup(); group != nil {
|
||||
group.MoveToTop()
|
||||
group.Offset = 0
|
||||
}
|
||||
case key.Matches(msg, f.keys.Bottom):
|
||||
if group := f.filterState.ActiveFilterGroup(); group != nil {
|
||||
group.MoveToBottom()
|
||||
f.adjustOffset(group)
|
||||
}
|
||||
case key.Matches(msg, f.keys.Select):
|
||||
if group := f.filterState.ActiveFilterGroup(); group != nil {
|
||||
group.SelectCurrent()
|
||||
}
|
||||
return f, func() tea.Msg { return FilterChangedMsg{} }
|
||||
case key.Matches(msg, f.keys.Left):
|
||||
f.filterState.PrevGroup()
|
||||
case key.Matches(msg, f.keys.Right):
|
||||
f.filterState.NextGroup()
|
||||
}
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// adjustOffset ensures the cursor is visible within the scroll window
|
||||
func (f *FilterPanel) adjustOffset(group *models.FilterGroup) {
|
||||
if group.Cursor < group.Offset {
|
||||
group.Offset = group.Cursor
|
||||
}
|
||||
if group.Cursor >= group.Offset+maxVisibleOptions {
|
||||
group.Offset = group.Cursor - maxVisibleOptions + 1
|
||||
}
|
||||
}
|
||||
|
||||
// View renders the filter panel
|
||||
func (f FilterPanel) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
for i, group := range f.filterState.Groups {
|
||||
isActiveGroup := i == f.filterState.ActiveGroup && f.focused
|
||||
|
||||
// Group title with count if scrollable
|
||||
titleStyle := f.styles.FilterGroupTitle
|
||||
if isActiveGroup {
|
||||
titleStyle = titleStyle.Foreground(lipgloss.Color("#7C3AED"))
|
||||
}
|
||||
title := group.Title
|
||||
if len(group.Options) > maxVisibleOptions {
|
||||
countStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280"))
|
||||
title += countStyle.Render(" (" + itoa(len(group.Options)) + ")")
|
||||
}
|
||||
b.WriteString(titleStyle.Render(title))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Separator
|
||||
sep := strings.Repeat("─", min(f.width-4, 15))
|
||||
b.WriteString(f.styles.Subtitle.Render(sep))
|
||||
b.WriteString("\n")
|
||||
|
||||
// Scroll up indicator
|
||||
if group.Offset > 0 {
|
||||
scrollStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280"))
|
||||
b.WriteString(scrollStyle.Render(" ▲ more"))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Calculate visible range
|
||||
startIdx := group.Offset
|
||||
endIdx := startIdx + maxVisibleOptions
|
||||
if endIdx > len(group.Options) {
|
||||
endIdx = len(group.Options)
|
||||
}
|
||||
|
||||
// Options (only visible ones)
|
||||
for j := startIdx; j < endIdx; j++ {
|
||||
opt := group.Options[j]
|
||||
isCursor := j == group.Cursor && isActiveGroup
|
||||
|
||||
// Selection indicator
|
||||
var indicator string
|
||||
if opt.Selected {
|
||||
indicator = "●"
|
||||
} else {
|
||||
indicator = "○"
|
||||
}
|
||||
|
||||
// Cursor indicator
|
||||
var cursor string
|
||||
if isCursor {
|
||||
cursor = "▸"
|
||||
} else {
|
||||
cursor = " "
|
||||
}
|
||||
|
||||
// Style the option
|
||||
optionStyle := f.styles.FilterOption
|
||||
if opt.Selected {
|
||||
optionStyle = f.styles.FilterSelected
|
||||
}
|
||||
if isCursor {
|
||||
optionStyle = optionStyle.Bold(true).Foreground(lipgloss.Color("#7C3AED"))
|
||||
}
|
||||
|
||||
line := cursor + " " + indicator + " " + opt.Label
|
||||
b.WriteString(optionStyle.Render(line))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Scroll down indicator
|
||||
if endIdx < len(group.Options) {
|
||||
scrollStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280"))
|
||||
b.WriteString(scrollStyle.Render(" ▼ more"))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Add spacing between groups
|
||||
if i < len(f.filterState.Groups)-1 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Apply panel styling
|
||||
content := b.String()
|
||||
if f.focused {
|
||||
return f.styles.PanelActive.
|
||||
Width(f.width).
|
||||
Height(f.height).
|
||||
Render(content)
|
||||
}
|
||||
return f.styles.PanelInactive.
|
||||
Width(f.width).
|
||||
Height(f.height).
|
||||
Render(content)
|
||||
}
|
||||
|
||||
// SetSize sets the size of the filter panel
|
||||
func (f *FilterPanel) SetSize(width, height int) {
|
||||
f.width = width
|
||||
f.height = height
|
||||
}
|
||||
|
||||
// SetFocused sets whether the panel is focused
|
||||
func (f *FilterPanel) SetFocused(focused bool) {
|
||||
f.focused = focused
|
||||
}
|
||||
|
||||
// FilterState returns the current filter state
|
||||
func (f *FilterPanel) FilterState() *models.FilterState {
|
||||
return f.filterState
|
||||
}
|
||||
|
||||
// SetFilterState updates the filter state
|
||||
func (f *FilterPanel) SetFilterState(state *models.FilterState) {
|
||||
f.filterState = state
|
||||
}
|
||||
|
||||
// FilterChangedMsg is sent when a filter selection changes
|
||||
type FilterChangedMsg struct{}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
|
||||
)
|
||||
|
||||
// HelpPanel displays keyboard shortcuts
|
||||
type HelpPanel struct {
|
||||
keys theme.KeyMap
|
||||
styles theme.Styles
|
||||
visible bool
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
// NewHelpPanel creates a new help panel
|
||||
func NewHelpPanel(keys theme.KeyMap, styles theme.Styles) HelpPanel {
|
||||
return HelpPanel{
|
||||
keys: keys,
|
||||
styles: styles,
|
||||
}
|
||||
}
|
||||
|
||||
// View renders the help panel
|
||||
func (h HelpPanel) View() string {
|
||||
if !h.visible {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
title := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#F9FAFB")).
|
||||
MarginBottom(1).
|
||||
Render("Keyboard Shortcuts")
|
||||
|
||||
b.WriteString(title)
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Group shortcuts by category
|
||||
sections := []struct {
|
||||
title string
|
||||
bindings []key.Binding
|
||||
}{
|
||||
{
|
||||
title: "Navigation",
|
||||
bindings: []key.Binding{
|
||||
h.keys.Up,
|
||||
h.keys.Down,
|
||||
h.keys.Top,
|
||||
h.keys.Bottom,
|
||||
h.keys.NextPanel,
|
||||
h.keys.PrevPanel,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Actions",
|
||||
bindings: []key.Binding{
|
||||
h.keys.Select,
|
||||
h.keys.Open,
|
||||
h.keys.View,
|
||||
h.keys.Search,
|
||||
h.keys.Refresh,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "General",
|
||||
bindings: []key.Binding{
|
||||
h.keys.Help,
|
||||
h.keys.Back,
|
||||
h.keys.Quit,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, section := range sections {
|
||||
// Section title
|
||||
sectionTitle := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#7C3AED")).
|
||||
Render(section.title)
|
||||
b.WriteString(sectionTitle)
|
||||
b.WriteString("\n")
|
||||
|
||||
// Key bindings
|
||||
for _, binding := range section.bindings {
|
||||
keyStyle := h.styles.HelpKey.Width(12)
|
||||
descStyle := h.styles.HelpDesc
|
||||
|
||||
help := binding.Help()
|
||||
line := keyStyle.Render(help.Key) + descStyle.Render(help.Desc)
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Add spacing between sections
|
||||
if i < len(sections)-1 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Footer
|
||||
b.WriteString("\n")
|
||||
footer := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#6B7280")).
|
||||
Italic(true).
|
||||
Render("Press ? or Esc to close")
|
||||
b.WriteString(footer)
|
||||
|
||||
content := b.String()
|
||||
|
||||
// Center the help panel
|
||||
helpWidth := 40
|
||||
helpHeight := 25
|
||||
|
||||
panel := h.styles.HelpPanel.
|
||||
Width(helpWidth).
|
||||
Height(helpHeight).
|
||||
Background(lipgloss.Color("#1F2937")).
|
||||
Render(content)
|
||||
|
||||
// Create overlay positioning
|
||||
return lipgloss.Place(
|
||||
h.width,
|
||||
h.height,
|
||||
lipgloss.Center,
|
||||
lipgloss.Center,
|
||||
panel,
|
||||
lipgloss.WithWhitespaceChars(" "),
|
||||
lipgloss.WithWhitespaceForeground(lipgloss.Color("#000000")),
|
||||
)
|
||||
}
|
||||
|
||||
// SetVisible sets whether the help panel is visible
|
||||
func (h *HelpPanel) SetVisible(visible bool) {
|
||||
h.visible = visible
|
||||
}
|
||||
|
||||
// IsVisible returns whether the help panel is visible
|
||||
func (h *HelpPanel) IsVisible() bool {
|
||||
return h.visible
|
||||
}
|
||||
|
||||
// Toggle toggles the visibility of the help panel
|
||||
func (h *HelpPanel) Toggle() {
|
||||
h.visible = !h.visible
|
||||
}
|
||||
|
||||
// SetSize sets the size of the help panel
|
||||
func (h *HelpPanel) SetSize(width, height int) {
|
||||
h.width = width
|
||||
h.height = height
|
||||
}
|
||||
|
||||
// ShortHelp returns a short help string for the status bar
|
||||
func ShortHelp(keys theme.KeyMap, styles theme.Styles) string {
|
||||
bindings := keys.ShortHelp()
|
||||
var parts []string
|
||||
|
||||
for _, b := range bindings {
|
||||
help := b.Help()
|
||||
key := styles.HelpKey.Render(help.Key)
|
||||
desc := styles.HelpDesc.Render(help.Desc)
|
||||
parts = append(parts, key+" "+desc)
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package theme
|
||||
|
||||
import "github.com/charmbracelet/bubbles/key"
|
||||
|
||||
// KeyMap defines all keyboard shortcuts
|
||||
type KeyMap struct {
|
||||
// Navigation
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Left key.Binding
|
||||
Right key.Binding
|
||||
Top key.Binding
|
||||
Bottom key.Binding
|
||||
NextPanel key.Binding
|
||||
PrevPanel key.Binding
|
||||
|
||||
// Actions
|
||||
Select key.Binding
|
||||
Open key.Binding
|
||||
View key.Binding
|
||||
Search key.Binding
|
||||
Refresh key.Binding
|
||||
Help key.Binding
|
||||
Back key.Binding
|
||||
Quit key.Binding
|
||||
ChangeState key.Binding
|
||||
CreateBranch key.Binding
|
||||
}
|
||||
|
||||
// DefaultKeyMap returns the default key bindings
|
||||
func DefaultKeyMap() KeyMap {
|
||||
return KeyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up", "k"),
|
||||
key.WithHelp("↑/k", "up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down", "j"),
|
||||
key.WithHelp("↓/j", "down"),
|
||||
),
|
||||
Left: key.NewBinding(
|
||||
key.WithKeys("left", "h"),
|
||||
key.WithHelp("←/h", "left"),
|
||||
),
|
||||
Right: key.NewBinding(
|
||||
key.WithKeys("right", "l"),
|
||||
key.WithHelp("→/l", "right"),
|
||||
),
|
||||
Top: key.NewBinding(
|
||||
key.WithKeys("g"),
|
||||
key.WithHelp("g", "top"),
|
||||
),
|
||||
Bottom: key.NewBinding(
|
||||
key.WithKeys("G"),
|
||||
key.WithHelp("G", "bottom"),
|
||||
),
|
||||
NextPanel: key.NewBinding(
|
||||
key.WithKeys("tab"),
|
||||
key.WithHelp("Tab", "next panel"),
|
||||
),
|
||||
PrevPanel: key.NewBinding(
|
||||
key.WithKeys("shift+tab"),
|
||||
key.WithHelp("Shift+Tab", "prev panel"),
|
||||
),
|
||||
Select: key.NewBinding(
|
||||
key.WithKeys("enter", " "),
|
||||
key.WithHelp("Enter/Space", "select"),
|
||||
),
|
||||
Open: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("Enter", "open in browser"),
|
||||
),
|
||||
View: key.NewBinding(
|
||||
key.WithKeys("v"),
|
||||
key.WithHelp("v", "view details"),
|
||||
),
|
||||
Search: key.NewBinding(
|
||||
key.WithKeys("/"),
|
||||
key.WithHelp("/", "search"),
|
||||
),
|
||||
Refresh: key.NewBinding(
|
||||
key.WithKeys("ctrl+r"),
|
||||
key.WithHelp("Ctrl+r", "refresh"),
|
||||
),
|
||||
Help: key.NewBinding(
|
||||
key.WithKeys("?"),
|
||||
key.WithHelp("?", "help"),
|
||||
),
|
||||
Back: key.NewBinding(
|
||||
key.WithKeys("esc"),
|
||||
key.WithHelp("Esc", "back"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("q", "ctrl+c"),
|
||||
key.WithHelp("q", "quit"),
|
||||
),
|
||||
ChangeState: key.NewBinding(
|
||||
key.WithKeys("s"),
|
||||
key.WithHelp("s", "change state"),
|
||||
),
|
||||
CreateBranch: key.NewBinding(
|
||||
key.WithKeys("b"),
|
||||
key.WithHelp("b", "create branch"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ShortHelp returns a short help text for the status bar
|
||||
func (k KeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{
|
||||
k.NextPanel,
|
||||
k.Up, k.Down,
|
||||
k.Top, k.Bottom,
|
||||
k.Open,
|
||||
k.Help,
|
||||
}
|
||||
}
|
||||
|
||||
// FullHelp returns all key bindings for the help panel
|
||||
func (k KeyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{k.Up, k.Down, k.Top, k.Bottom},
|
||||
{k.NextPanel, k.PrevPanel},
|
||||
{k.Select, k.Open, k.View},
|
||||
{k.ChangeState, k.CreateBranch},
|
||||
{k.Search, k.Refresh},
|
||||
{k.Help, k.Back, k.Quit},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package theme
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Colors
|
||||
var (
|
||||
colorPrimary = lipgloss.Color("#7C3AED") // Purple
|
||||
colorSecondary = lipgloss.Color("#06B6D4") // Cyan
|
||||
colorSuccess = lipgloss.Color("#10B981") // Green
|
||||
colorWarning = lipgloss.Color("#F59E0B") // Yellow
|
||||
colorError = lipgloss.Color("#EF4444") // Red
|
||||
colorMuted = lipgloss.Color("#6B7280") // Gray
|
||||
colorBorder = lipgloss.Color("#374151") // Dark gray
|
||||
colorHighlight = lipgloss.Color("#1F2937") // Very dark gray
|
||||
colorText = lipgloss.Color("#F9FAFB") // White
|
||||
colorTextMuted = lipgloss.Color("#9CA3AF") // Light gray
|
||||
)
|
||||
|
||||
// Work item type colors
|
||||
var typeColors = map[string]lipgloss.Color{
|
||||
"User Story": lipgloss.Color("#3B82F6"), // Blue
|
||||
"Story": lipgloss.Color("#3B82F6"),
|
||||
"Task": lipgloss.Color("#F59E0B"), // Yellow
|
||||
"Bug": lipgloss.Color("#EF4444"), // Red
|
||||
"Feature": lipgloss.Color("#8B5CF6"), // Purple
|
||||
"Epic": lipgloss.Color("#EC4899"), // Pink
|
||||
}
|
||||
|
||||
// Work item state colors
|
||||
var stateColors = map[string]lipgloss.Color{
|
||||
// Common states
|
||||
"New": lipgloss.Color("#6B7280"), // Gray
|
||||
"Active": lipgloss.Color("#3B82F6"), // Blue
|
||||
"Resolved": lipgloss.Color("#10B981"), // Green
|
||||
"Closed": lipgloss.Color("#6B7280"), // Gray
|
||||
// Agile states
|
||||
"To Do": lipgloss.Color("#F97316"), // Orange
|
||||
"In Progress": lipgloss.Color("#8B5CF6"), // Purple
|
||||
"Done": lipgloss.Color("#10B981"), // Green
|
||||
"Testing": lipgloss.Color("#FBBF24"), // Yellow
|
||||
// Additional states
|
||||
"Removed": lipgloss.Color("#6B7280"), // Gray
|
||||
"Approved": lipgloss.Color("#10B981"), // Green
|
||||
}
|
||||
|
||||
// Styles defines all UI styles
|
||||
type Styles struct {
|
||||
// Base styles
|
||||
App lipgloss.Style
|
||||
Title lipgloss.Style
|
||||
Subtitle lipgloss.Style
|
||||
StatusBar lipgloss.Style
|
||||
|
||||
// Panel styles
|
||||
PanelActive lipgloss.Style
|
||||
PanelInactive lipgloss.Style
|
||||
PanelTitle lipgloss.Style
|
||||
|
||||
// Filter styles
|
||||
FilterGroup lipgloss.Style
|
||||
FilterGroupTitle lipgloss.Style
|
||||
FilterOption lipgloss.Style
|
||||
FilterSelected lipgloss.Style
|
||||
FilterCursor lipgloss.Style
|
||||
|
||||
// Work items list styles
|
||||
ListHeader lipgloss.Style
|
||||
ListItem lipgloss.Style
|
||||
ListItemSelected lipgloss.Style
|
||||
ListItemCursor lipgloss.Style
|
||||
|
||||
// Detail view styles
|
||||
DetailTitle lipgloss.Style
|
||||
DetailSection lipgloss.Style
|
||||
DetailSectionTitle lipgloss.Style
|
||||
DetailLabel lipgloss.Style
|
||||
DetailValue lipgloss.Style
|
||||
DetailDescription lipgloss.Style
|
||||
DetailTag lipgloss.Style
|
||||
|
||||
// Help styles
|
||||
HelpKey lipgloss.Style
|
||||
HelpDesc lipgloss.Style
|
||||
HelpPanel lipgloss.Style
|
||||
|
||||
// Type and state badges
|
||||
TypeBadge func(string) lipgloss.Style
|
||||
StateBadge func(string) lipgloss.Style
|
||||
}
|
||||
|
||||
// DefaultStyles returns the default styles
|
||||
func DefaultStyles() Styles {
|
||||
return Styles{
|
||||
// Base styles
|
||||
App: lipgloss.NewStyle().
|
||||
Background(lipgloss.Color("#111827")),
|
||||
|
||||
Title: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(colorText).
|
||||
Padding(0, 1),
|
||||
|
||||
Subtitle: lipgloss.NewStyle().
|
||||
Foreground(colorTextMuted),
|
||||
|
||||
StatusBar: lipgloss.NewStyle().
|
||||
Foreground(colorTextMuted).
|
||||
Background(lipgloss.Color("#1F2937")).
|
||||
Padding(0, 1),
|
||||
|
||||
// Panel styles
|
||||
PanelActive: lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorPrimary).
|
||||
Padding(0, 1),
|
||||
|
||||
PanelInactive: lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorBorder).
|
||||
Padding(0, 1),
|
||||
|
||||
PanelTitle: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(colorText).
|
||||
Background(colorHighlight).
|
||||
Padding(0, 1),
|
||||
|
||||
// Filter styles
|
||||
FilterGroup: lipgloss.NewStyle().
|
||||
MarginBottom(1),
|
||||
|
||||
FilterGroupTitle: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(colorText).
|
||||
MarginBottom(0),
|
||||
|
||||
FilterOption: lipgloss.NewStyle().
|
||||
Foreground(colorTextMuted).
|
||||
PaddingLeft(1),
|
||||
|
||||
FilterSelected: lipgloss.NewStyle().
|
||||
Foreground(colorSuccess).
|
||||
PaddingLeft(1),
|
||||
|
||||
FilterCursor: lipgloss.NewStyle().
|
||||
Foreground(colorPrimary).
|
||||
Bold(true),
|
||||
|
||||
// Work items list styles
|
||||
ListHeader: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(colorTextMuted).
|
||||
BorderBottom(true).
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(colorBorder),
|
||||
|
||||
ListItem: lipgloss.NewStyle().
|
||||
Foreground(colorText),
|
||||
|
||||
ListItemSelected: lipgloss.NewStyle().
|
||||
Foreground(colorText).
|
||||
Background(colorHighlight),
|
||||
|
||||
ListItemCursor: lipgloss.NewStyle().
|
||||
Foreground(colorPrimary).
|
||||
Bold(true),
|
||||
|
||||
// Detail view styles
|
||||
DetailTitle: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(colorText).
|
||||
MarginBottom(1),
|
||||
|
||||
DetailSection: lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorBorder).
|
||||
Padding(0, 1).
|
||||
MarginBottom(1),
|
||||
|
||||
DetailSectionTitle: lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(colorTextMuted).
|
||||
MarginBottom(0),
|
||||
|
||||
DetailLabel: lipgloss.NewStyle().
|
||||
Foreground(colorTextMuted).
|
||||
Width(12),
|
||||
|
||||
DetailValue: lipgloss.NewStyle().
|
||||
Foreground(colorText),
|
||||
|
||||
DetailDescription: lipgloss.NewStyle().
|
||||
Foreground(colorText),
|
||||
|
||||
DetailTag: lipgloss.NewStyle().
|
||||
Foreground(colorSecondary).
|
||||
Background(lipgloss.Color("#1E3A5F")).
|
||||
Padding(0, 1).
|
||||
MarginRight(1),
|
||||
|
||||
// Help styles
|
||||
HelpKey: lipgloss.NewStyle().
|
||||
Foreground(colorPrimary).
|
||||
Bold(true),
|
||||
|
||||
HelpDesc: lipgloss.NewStyle().
|
||||
Foreground(colorTextMuted),
|
||||
|
||||
HelpPanel: lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorBorder).
|
||||
Padding(1, 2),
|
||||
|
||||
// Type and state badge generators
|
||||
TypeBadge: func(t string) lipgloss.Style {
|
||||
color := typeColors[t]
|
||||
if color == "" {
|
||||
color = colorMuted
|
||||
}
|
||||
return lipgloss.NewStyle().
|
||||
Foreground(color).
|
||||
Bold(true)
|
||||
},
|
||||
|
||||
StateBadge: func(s string) lipgloss.Style {
|
||||
color := stateColors[s]
|
||||
if color == "" {
|
||||
color = colorMuted
|
||||
}
|
||||
return lipgloss.NewStyle().
|
||||
Foreground(color)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user