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} } }