Files
Samuel Enocsson 7c488b2d83 Add sorting, user assignment, and UI improvements
Features:
- Add work item sorting by ID, Type, State (keys 1, 2, 3)
- Add user assignment modal with team member filtering (key a)
- Add parent work item title display in details panel
- Preserve sort order and selection on panel reload

UI improvements:
- Remove zebra striping from work items list
- Remove priority column from list and details
- Align metadata fields in details panel
- Add markdown rendering for descriptions (using glamour)
- Add state colors: To Do (orange), In Progress (purple), Done (green), Testing (yellow)

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 22:31:28 +01:00

594 lines
15 KiB
Go

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
assignModal components.AssignModal
// 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
teamMembers []models.TeamMember
// 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),
assignModal: components.NewAssignModal(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...)
}
if a.assignModal.IsVisible() {
newModal, cmd := a.assignModal.Update(msg)
a.assignModal = 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
}
}
// Open assign modal (only when work items panel is active)
if key.Matches(msg, a.keys.Assign) && a.activePanel == PanelWorkItems {
if item := a.workItemsPanel.SelectedItem(); item != nil {
a.assignModal.SetItem(item)
a.assignModal.SetMembers(a.teamMembers)
a.assignModal.SetSize(a.width, a.height)
a.assignModal.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.teamMembers = msg.teamMembers
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)
a.assignModal.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
case components.AssignRequestMsg:
a.assignModal.SetVisible(false)
a.loading = true
return a, assignWorkItemCmd(a.client, msg.Item.ID, msg.UserEmail, msg.UserName, a.filterPanel.FilterState())
case assignedMsg:
a.loading = false
a.statusMsg = fmt.Sprintf("Assigned to %s", msg.userName)
// Refresh work items to show updated assignment
return a, loadWorkItemsCmd(a.client, a.filterPanel.FilterState())
}
// 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 assign modal if visible
if a.assignModal.IsVisible() {
return a.assignModal.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
teamMembers []models.TeamMember
}
type workItemsLoadedMsg struct {
items []models.WorkItem
}
type errMsg struct {
err error
}
type stateChangedMsg struct {
newState string
}
type assignedMsg struct {
userName 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)
}
teamMembers, err := client.GetTeamMembers()
if err != nil {
// Non-fatal - we can still work without team members
teamMembers = []models.TeamMember{}
}
return dataLoadedMsg{iterations: iterations, areas: areas, statesByType: statesByType, teamMembers: teamMembers}
}
}
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}
}
}
func assignWorkItemCmd(client *api.Client, itemID int, userEmail, userName string, filterState *models.FilterState) tea.Cmd {
return func() tea.Msg {
err := client.AssignWorkItem(itemID, userEmail)
if err != nil {
return errMsg{err: err}
}
return assignedMsg{userName: userName}
}
}