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:
Samuel Enocsson
2025-12-04 09:56:11 +01:00
commit 2555afce19
30 changed files with 4906 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
package api
import (
"sort"
"strings"
"github.com/samuelenocsson/devops-tui/internal/models"
)
// classificationNode represents an area node from the API
type classificationNode struct {
ID int `json:"id"`
Identifier string `json:"identifier"`
Name string `json:"name"`
Path string `json:"path"`
HasChildren bool `json:"hasChildren"`
Children []classificationNode `json:"children"`
}
// GetAreas fetches all areas for the project
func (c *Client) GetAreas() ([]models.Area, error) {
// Use the classification nodes API with depth to get area hierarchy
resp, err := c.get("/wit/classificationnodes/areas?$depth=10")
if err != nil {
return nil, err
}
var rootNode classificationNode
if err := decode(resp, &rootNode); err != nil {
return nil, err
}
// Flatten the tree into a list
areas := flattenAreas(rootNode, "")
// Sort areas by path for consistent ordering
sort.Slice(areas, func(i, j int) bool {
return areas[i].Path < areas[j].Path
})
return areas, nil
}
// flattenAreas recursively flattens the area tree
func flattenAreas(node classificationNode, parentPath string) []models.Area {
var areas []models.Area
// Build the path
path := node.Path
if path == "" {
path = node.Name
}
// Clean up the path:
// 1. Remove leading backslash
// 2. Remove "\Area" from path (API returns \Project\Area\Team but work items use Project\Team)
path = strings.TrimPrefix(path, "\\")
path = strings.TrimSuffix(path, "\\")
// Remove the "Area" segment from the path (e.g., "Project\Area\Team" -> "Project\Team")
parts := strings.Split(path, "\\")
if len(parts) >= 2 && parts[1] == "Area" {
// Remove the "Area" part
newParts := []string{parts[0]}
if len(parts) > 2 {
newParts = append(newParts, parts[2:]...)
}
path = strings.Join(newParts, "\\")
}
// Add this node
areas = append(areas, models.Area{
ID: node.ID,
Name: node.Name,
Path: path,
})
// Recursively add children
for _, child := range node.Children {
childAreas := flattenAreas(child, path)
areas = append(areas, childAreas...)
}
return areas
}
// GetAreaDisplayName returns a shortened display name for an area
func GetAreaDisplayName(path string) string {
parts := strings.Split(path, "\\")
if len(parts) > 1 {
// Return the last part for brevity
return parts[len(parts)-1]
}
return path
}
+179
View File
@@ -0,0 +1,179 @@
package api
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/samuelenocsson/devops-tui/internal/config"
)
const apiVersion = "7.1"
// Client is the Azure DevOps API client
type Client struct {
httpClient *http.Client
baseURL string
teamURL string
webURL string
authHeader string
organization string
project string
team string
}
// NewClient creates a new Azure DevOps API client
func NewClient(cfg *config.Config) *Client {
// Azure DevOps uses Basic auth with empty username and PAT as password
auth := base64.StdEncoding.EncodeToString([]byte(":" + cfg.PAT))
return &Client{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: cfg.BaseURL(),
teamURL: cfg.TeamURL(),
webURL: cfg.WebURL(),
authHeader: "Basic " + auth,
organization: cfg.Organization,
project: cfg.Project,
team: cfg.Team,
}
}
// doRequest performs an HTTP request with authentication
func (c *Client) doRequest(method, url string, body io.Reader) (*http.Response, error) {
return c.doRequestWithContentType(method, url, body, "application/json")
}
// doRequestWithContentType performs an HTTP request with authentication and custom content type
func (c *Client) doRequestWithContentType(method, url string, body io.Reader, contentType string) (*http.Response, error) {
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Authorization", c.authHeader)
req.Header.Set("Content-Type", contentType)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body))
}
return resp, nil
}
// get performs a GET request to base URL
func (c *Client) get(endpoint string) (*http.Response, error) {
return c.getWithBase(c.baseURL, endpoint)
}
// getTeam performs a GET request to team-specific URL
func (c *Client) getTeam(endpoint string) (*http.Response, error) {
return c.getWithBase(c.teamURL, endpoint)
}
// getWithBase performs a GET request with a specific base URL
func (c *Client) getWithBase(baseURL, endpoint string) (*http.Response, error) {
url := fmt.Sprintf("%s%s", baseURL, endpoint)
if endpoint[0] != '/' {
url = fmt.Sprintf("%s/%s", baseURL, endpoint)
}
// Add API version
if len(url) > 0 {
separator := "?"
if len(url) > 0 && url[len(url)-1] != '?' {
for _, c := range url {
if c == '?' {
separator = "&"
break
}
}
}
url = fmt.Sprintf("%s%sapi-version=%s", url, separator, apiVersion)
}
return c.doRequest("GET", url, nil)
}
// post performs a POST request
func (c *Client) post(endpoint string, body io.Reader) (*http.Response, error) {
url := fmt.Sprintf("%s%s", c.baseURL, endpoint)
if endpoint[0] != '/' {
url = fmt.Sprintf("%s/%s", c.baseURL, endpoint)
}
// Add API version
separator := "?"
for _, ch := range url {
if ch == '?' {
separator = "&"
break
}
}
url = fmt.Sprintf("%s%sapi-version=%s", url, separator, apiVersion)
return c.doRequest("POST", url, body)
}
// patch performs a PATCH request (for work item updates)
func (c *Client) patch(endpoint string, body io.Reader) (*http.Response, error) {
url := fmt.Sprintf("%s%s", c.baseURL, endpoint)
if endpoint[0] != '/' {
url = fmt.Sprintf("%s/%s", c.baseURL, endpoint)
}
// Add API version
separator := "?"
for _, ch := range url {
if ch == '?' {
separator = "&"
break
}
}
url = fmt.Sprintf("%s%sapi-version=%s", url, separator, apiVersion)
return c.doRequestWithContentType("PATCH", url, body, "application/json-patch+json")
}
// decode decodes a JSON response into the given target
func decode(resp *http.Response, target interface{}) error {
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
return fmt.Errorf("decoding response: %w", err)
}
return nil
}
// WorkItemWebURL returns the web URL for a work item
func (c *Client) WorkItemWebURL(id int) string {
return fmt.Sprintf("%s/_workitems/edit/%d", c.webURL, id)
}
// Organization returns the organization name
func (c *Client) Organization() string {
return c.organization
}
// Project returns the project name
func (c *Client) Project() string {
return c.project
}
// Team returns the team name
func (c *Client) Team() string {
return c.team
}
+83
View File
@@ -0,0 +1,83 @@
package api
import (
"time"
"github.com/samuelenocsson/devops-tui/internal/models"
)
// iterationsResponse represents the API response for iterations
type iterationsResponse struct {
Count int `json:"count"`
Value []iterationAPIItem `json:"value"`
}
type iterationAPIItem struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Attributes iterationAttrs `json:"attributes"`
URL string `json:"url"`
}
type iterationAttrs struct {
StartDate *time.Time `json:"startDate"`
FinishDate *time.Time `json:"finishDate"`
TimeFrame string `json:"timeFrame"`
}
// GetIterations fetches all iterations (sprints) for the team
func (c *Client) GetIterations() ([]models.Iteration, error) {
resp, err := c.getTeam("/work/teamsettings/iterations")
if err != nil {
return nil, err
}
var apiResp iterationsResponse
if err := decode(resp, &apiResp); err != nil {
return nil, err
}
iterations := make([]models.Iteration, 0, apiResp.Count)
for _, item := range apiResp.Value {
iter := models.Iteration{
ID: item.ID,
Name: item.Name,
Path: item.Path,
TimeFrame: item.Attributes.TimeFrame,
URL: item.URL,
}
if item.Attributes.StartDate != nil {
iter.StartDate = *item.Attributes.StartDate
}
if item.Attributes.FinishDate != nil {
iter.FinishDate = *item.Attributes.FinishDate
}
iterations = append(iterations, iter)
}
return iterations, nil
}
// GetCurrentIteration returns the current iteration
func (c *Client) GetCurrentIteration() (*models.Iteration, error) {
iterations, err := c.GetIterations()
if err != nil {
return nil, err
}
for _, iter := range iterations {
if iter.IsCurrent() {
return &iter, nil
}
}
// Return the first one if no current found
if len(iterations) > 0 {
return &iterations[0], nil
}
return nil, nil
}
+273
View File
@@ -0,0 +1,273 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
"github.com/samuelenocsson/devops-tui/internal/models"
)
// wiqlRequest represents a WIQL query request
type wiqlRequest struct {
Query string `json:"query"`
}
// wiqlResponse represents the response from a WIQL query
type wiqlResponse struct {
WorkItems []struct {
ID int `json:"id"`
URL string `json:"url"`
} `json:"workItems"`
}
// workItemsResponse represents the response for batch work item fetch
type workItemsResponse struct {
Count int `json:"count"`
Value []workItemAPIItem `json:"value"`
}
// workItemAPIItem represents a work item from the API
type workItemAPIItem struct {
ID int `json:"id"`
Rev int `json:"rev"`
Fields workItemFields `json:"fields"`
URL string `json:"url"`
}
type workItemFields struct {
ID int `json:"System.Id"`
Title string `json:"System.Title"`
State string `json:"System.State"`
WorkItemType string `json:"System.WorkItemType"`
AssignedTo *struct {
DisplayName string `json:"displayName"`
UniqueName string `json:"uniqueName"`
} `json:"System.AssignedTo"`
IterationPath string `json:"System.IterationPath"`
AreaPath string `json:"System.AreaPath"`
Description string `json:"System.Description"`
Tags string `json:"System.Tags"`
Parent int `json:"System.Parent"`
Priority int `json:"Microsoft.VSTS.Common.Priority"`
CreatedDate time.Time `json:"System.CreatedDate"`
ChangedDate time.Time `json:"System.ChangedDate"`
}
// escapeWIQL escapes a string value for use in WIQL queries
func escapeWIQL(s string) string {
// Escape single quotes by doubling them
return strings.ReplaceAll(s, "'", "''")
}
// QueryWorkItems queries work items using WIQL
func (c *Client) QueryWorkItems(sprintPath, state, assigned, areaPath string) ([]models.WorkItem, error) {
// Build WIQL query
query := `SELECT [System.Id], [System.Title], [System.State], [System.WorkItemType]
FROM WorkItems
WHERE [System.TeamProject] = @project`
// Add sprint filter
if sprintPath != "" && sprintPath != "all" {
query += fmt.Sprintf(`
AND [System.IterationPath] = '%s'`, escapeWIQL(sprintPath))
}
// Add state filter
if state != "" && state != "all" {
query += fmt.Sprintf(`
AND [System.State] = '%s'`, escapeWIQL(state))
}
// Add assigned filter
if assigned == "me" {
query += `
AND [System.AssignedTo] = @me`
}
// Add area filter
if areaPath != "" && areaPath != "all" {
// Clean up the path
areaPath = strings.TrimPrefix(areaPath, "\\")
areaPath = strings.TrimSuffix(areaPath, "\\")
query += fmt.Sprintf(`
AND [System.AreaPath] UNDER '%s'`, escapeWIQL(areaPath))
}
query += `
ORDER BY [System.ChangedDate] DESC`
// Execute WIQL query
reqBody := wiqlRequest{Query: query}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshaling WIQL request: %w", err)
}
resp, err := c.post("/wit/wiql", bytes.NewReader(bodyBytes))
if err != nil {
return nil, err
}
var wiqlResp wiqlResponse
if err := decode(resp, &wiqlResp); err != nil {
return nil, err
}
if len(wiqlResp.WorkItems) == 0 {
return []models.WorkItem{}, nil
}
// Get the IDs
ids := make([]string, 0, len(wiqlResp.WorkItems))
for _, wi := range wiqlResp.WorkItems {
ids = append(ids, fmt.Sprintf("%d", wi.ID))
}
// Fetch the full work items
return c.GetWorkItems(ids)
}
// GetWorkItems fetches multiple work items by ID
func (c *Client) GetWorkItems(ids []string) ([]models.WorkItem, error) {
if len(ids) == 0 {
return []models.WorkItem{}, nil
}
// API has a limit of 200 items per request
const batchSize = 200
var allItems []models.WorkItem
for i := 0; i < len(ids); i += batchSize {
end := i + batchSize
if end > len(ids) {
end = len(ids)
}
batch := ids[i:end]
fields := "System.Id,System.Title,System.State,System.WorkItemType,System.AssignedTo,System.IterationPath,System.AreaPath,System.Description,System.Tags,System.Parent,Microsoft.VSTS.Common.Priority,System.CreatedDate,System.ChangedDate"
endpoint := fmt.Sprintf("/wit/workitems?ids=%s&fields=%s", strings.Join(batch, ","), fields)
resp, err := c.get(endpoint)
if err != nil {
return nil, err
}
var apiResp workItemsResponse
if err := decode(resp, &apiResp); err != nil {
return nil, err
}
for _, item := range apiResp.Value {
wi := c.convertWorkItem(item)
allItems = append(allItems, wi)
}
}
return allItems, nil
}
// GetWorkItem fetches a single work item by ID
func (c *Client) GetWorkItem(id int) (*models.WorkItem, error) {
fields := "System.Id,System.Title,System.State,System.WorkItemType,System.AssignedTo,System.IterationPath,System.AreaPath,System.Description,System.Tags,System.Parent,Microsoft.VSTS.Common.Priority,System.CreatedDate,System.ChangedDate"
endpoint := fmt.Sprintf("/wit/workitems/%d?fields=%s", id, fields)
resp, err := c.get(endpoint)
if err != nil {
return nil, err
}
var item workItemAPIItem
if err := decode(resp, &item); err != nil {
return nil, err
}
wi := c.convertWorkItem(item)
return &wi, nil
}
// convertWorkItem converts an API work item to our model
func (c *Client) convertWorkItem(item workItemAPIItem) models.WorkItem {
wi := models.WorkItem{
ID: item.ID,
Rev: item.Rev,
Title: item.Fields.Title,
State: models.WorkItemState(item.Fields.State),
Type: models.WorkItemType(item.Fields.WorkItemType),
IterationPath: item.Fields.IterationPath,
AreaPath: item.Fields.AreaPath,
Description: stripHTML(item.Fields.Description),
ParentID: item.Fields.Parent,
Priority: item.Fields.Priority,
CreatedDate: item.Fields.CreatedDate,
ChangedDate: item.Fields.ChangedDate,
URL: item.URL,
WebURL: c.WorkItemWebURL(item.ID),
}
if item.Fields.AssignedTo != nil {
wi.AssignedTo = item.Fields.AssignedTo.DisplayName
}
// Parse tags
if item.Fields.Tags != "" {
tags := strings.Split(item.Fields.Tags, ";")
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag != "" {
wi.Tags = append(wi.Tags, tag)
}
}
}
return wi
}
// UpdateWorkItemState updates a work item's state
func (c *Client) UpdateWorkItemState(id int, newState string) error {
// Azure DevOps uses JSON Patch format
patchDoc := []map[string]interface{}{
{
"op": "add",
"path": "/fields/System.State",
"value": newState,
},
}
bodyBytes, err := json.Marshal(patchDoc)
if err != nil {
return fmt.Errorf("marshaling patch document: %w", err)
}
endpoint := fmt.Sprintf("/wit/workitems/%d", id)
resp, err := c.patch(endpoint, bytes.NewReader(bodyBytes))
if err != nil {
return err
}
resp.Body.Close()
return nil
}
// stripHTML removes HTML tags from a string
func stripHTML(s string) string {
// Simple HTML tag removal
re := regexp.MustCompile(`<[^>]*>`)
s = re.ReplaceAllString(s, "")
// Replace common HTML entities
s = strings.ReplaceAll(s, "&nbsp;", " ")
s = strings.ReplaceAll(s, "&amp;", "&")
s = strings.ReplaceAll(s, "&lt;", "<")
s = strings.ReplaceAll(s, "&gt;", ">")
s = strings.ReplaceAll(s, "&quot;", "\"")
s = strings.ReplaceAll(s, "&#39;", "'")
// Trim whitespace
s = strings.TrimSpace(s)
return s
}
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"fmt"
"github.com/samuelenocsson/devops-tui/internal/models"
)
// workItemTypesResponse represents the API response for work item types
type workItemTypesResponse struct {
Count int `json:"count"`
Value []workItemTypeAPIItem `json:"value"`
}
type workItemTypeAPIItem struct {
Name string `json:"name"`
ReferenceName string `json:"referenceName"`
Description string `json:"description"`
Color string `json:"color"`
Icon struct {
ID string `json:"id"`
URL string `json:"url"`
} `json:"icon"`
}
// statesResponse represents the API response for work item states
type statesResponse struct {
Count int `json:"count"`
Value []stateAPIItem `json:"value"`
}
type stateAPIItem struct {
Name string `json:"name"`
Color string `json:"color"`
Category string `json:"stateCategory"`
}
// GetWorkItemTypes fetches all work item types for the project
func (c *Client) GetWorkItemTypes() ([]string, error) {
resp, err := c.get("/wit/workitemtypes")
if err != nil {
return nil, err
}
var apiResp workItemTypesResponse
if err := decode(resp, &apiResp); err != nil {
return nil, err
}
types := make([]string, 0, apiResp.Count)
for _, item := range apiResp.Value {
types = append(types, item.Name)
}
return types, nil
}
// GetWorkItemTypeStates fetches all states for a specific work item type
func (c *Client) GetWorkItemTypeStates(workItemType string) ([]models.WorkItemStateInfo, error) {
endpoint := fmt.Sprintf("/wit/workitemtypes/%s/states", workItemType)
resp, err := c.get(endpoint)
if err != nil {
return nil, err
}
var apiResp statesResponse
if err := decode(resp, &apiResp); err != nil {
return nil, err
}
states := make([]models.WorkItemStateInfo, 0, apiResp.Count)
for _, item := range apiResp.Value {
states = append(states, models.WorkItemStateInfo{
Name: item.Name,
Color: item.Color,
Category: item.Category,
})
}
return states, nil
}
// GetAllWorkItemTypeStates fetches states for all work item types
func (c *Client) GetAllWorkItemTypeStates() (map[string][]models.WorkItemStateInfo, error) {
types, err := c.GetWorkItemTypes()
if err != nil {
return nil, err
}
statesByType := make(map[string][]models.WorkItemStateInfo)
for _, t := range types {
states, err := c.GetWorkItemTypeStates(t)
if err != nil {
// Skip types that fail (some system types may not have states)
continue
}
statesByType[t] = states
}
return statesByType, nil
}
+142
View File
@@ -0,0 +1,142 @@
package config
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/viper"
)
// Config holds the application configuration
type Config struct {
Organization string `mapstructure:"organization"`
Project string `mapstructure:"project"`
Team string `mapstructure:"team"`
PAT string `mapstructure:"pat"`
Theme string `mapstructure:"theme"`
Defaults Defaults `mapstructure:"defaults"`
}
// Defaults holds default filter settings
type Defaults struct {
Sprint string `mapstructure:"sprint"`
State string `mapstructure:"state"`
Assigned string `mapstructure:"assigned"`
}
// Load loads the configuration from file and environment
func Load() (*Config, error) {
v := viper.New()
// Set config file name and paths
v.SetConfigName("config")
v.SetConfigType("yaml")
// Add config paths
home, err := os.UserHomeDir()
if err == nil {
v.AddConfigPath(filepath.Join(home, ".config", "devops-tui"))
}
v.AddConfigPath(".")
// Set defaults
v.SetDefault("theme", "default")
v.SetDefault("defaults.sprint", "current")
v.SetDefault("defaults.state", "all")
v.SetDefault("defaults.assigned", "me")
// Read config file (ignore if not found)
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("error reading config file: %w", err)
}
}
// Bind environment variables
v.SetEnvPrefix("")
v.AutomaticEnv()
// Map environment variables
v.BindEnv("pat", "AZURE_DEVOPS_PAT")
v.BindEnv("organization", "AZURE_DEVOPS_ORG")
v.BindEnv("project", "AZURE_DEVOPS_PROJECT")
v.BindEnv("team", "AZURE_DEVOPS_TEAM")
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("error unmarshaling config: %w", err)
}
// Validate required fields
if cfg.Organization == "" {
return nil, fmt.Errorf("organization is required (set in config or AZURE_DEVOPS_ORG)")
}
if cfg.Project == "" {
return nil, fmt.Errorf("project is required (set in config or AZURE_DEVOPS_PROJECT)")
}
if cfg.Team == "" {
return nil, fmt.Errorf("team is required (set in config or AZURE_DEVOPS_TEAM)")
}
if cfg.PAT == "" {
return nil, fmt.Errorf("PAT is required (set in config or AZURE_DEVOPS_PAT)")
}
return &cfg, nil
}
// BaseURL returns the Azure DevOps API base URL
func (c *Config) BaseURL() string {
return fmt.Sprintf("https://dev.azure.com/%s/%s/_apis", c.Organization, c.Project)
}
// TeamURL returns the Azure DevOps API URL for team-specific endpoints
func (c *Config) TeamURL() string {
return fmt.Sprintf("https://dev.azure.com/%s/%s/%s/_apis", c.Organization, c.Project, c.Team)
}
// WebURL returns the Azure DevOps web URL for the project
func (c *Config) WebURL() string {
return fmt.Sprintf("https://dev.azure.com/%s/%s", c.Organization, c.Project)
}
// CreateDefaultConfig creates a default config file
func CreateDefaultConfig() error {
home, err := os.UserHomeDir()
if err != nil {
return err
}
configDir := filepath.Join(home, ".config", "devops-tui")
if err := os.MkdirAll(configDir, 0755); err != nil {
return err
}
configPath := filepath.Join(configDir, "config.yaml")
// Don't overwrite existing config
if _, err := os.Stat(configPath); err == nil {
return nil
}
content := `# Azure DevOps connection
organization: "my-organization"
project: "my-project"
team: "my-team"
# Authentication
# PAT can be set here or via environment variable AZURE_DEVOPS_PAT
pat: ""
# UI settings
theme: "default" # default, dark, light
# Default filters at startup
defaults:
sprint: "current" # "current", "all", or specific name
state: "all" # "all", "new", "active", "resolved", "closed"
assigned: "me" # "all", "me"
`
return os.WriteFile(configPath, []byte(content), 0600)
}
+80
View File
@@ -0,0 +1,80 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
)
// FilterState holds the persisted filter selections
type FilterState struct {
Sprint string `json:"sprint"`
State string `json:"state"`
Assigned string `json:"assigned"`
Area string `json:"area"`
}
// getStatePath returns the path to the state file
func getStatePath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "devops-tui", "state.json"), nil
}
// LoadFilterState loads the persisted filter state
func LoadFilterState() (*FilterState, error) {
statePath, err := getStatePath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(statePath)
if err != nil {
if os.IsNotExist(err) {
// Return default state if file doesn't exist
return &FilterState{
Sprint: "current",
State: "all",
Assigned: "me",
Area: "all",
}, nil
}
return nil, err
}
var state FilterState
if err := json.Unmarshal(data, &state); err != nil {
// Return default state if file is corrupted
return &FilterState{
Sprint: "current",
State: "all",
Assigned: "me",
Area: "all",
}, nil
}
return &state, nil
}
// SaveFilterState saves the filter state to disk
func SaveFilterState(state *FilterState) error {
statePath, err := getStatePath()
if err != nil {
return err
}
// Ensure directory exists
dir := filepath.Dir(statePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
return err
}
return os.WriteFile(statePath, data, 0600)
}
+25
View File
@@ -0,0 +1,25 @@
package models
import "strings"
// Area represents an Azure DevOps area
type Area struct {
ID int `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
}
// DisplayName returns a shortened display name
func (a Area) DisplayName() string {
parts := strings.Split(a.Path, "\\")
if len(parts) > 1 {
// Return the last part for brevity
return parts[len(parts)-1]
}
return a.Name
}
// FullPath returns the full path
func (a Area) FullPath() string {
return a.Path
}
+317
View File
@@ -0,0 +1,317 @@
package models
// FilterType represents the type of filter
type FilterType int
const (
FilterTypeSprint FilterType = iota
FilterTypeState
FilterTypeAssigned
FilterTypeArea
)
// FilterOption represents a selectable filter option
type FilterOption struct {
Label string
Value string
Selected bool
}
// FilterGroup represents a group of filter options
type FilterGroup struct {
Type FilterType
Title string
Options []FilterOption
Cursor int
Offset int // Scroll offset for viewing
}
// SelectedOption returns the currently selected option
func (f *FilterGroup) SelectedOption() *FilterOption {
for i := range f.Options {
if f.Options[i].Selected {
return &f.Options[i]
}
}
if len(f.Options) > 0 {
return &f.Options[0]
}
return nil
}
// Select marks the option at the given index as selected
func (f *FilterGroup) Select(index int) {
if index < 0 || index >= len(f.Options) {
return
}
for i := range f.Options {
f.Options[i].Selected = i == index
}
}
// SelectCurrent marks the option at the current cursor as selected
func (f *FilterGroup) SelectCurrent() {
f.Select(f.Cursor)
}
// MoveUp moves the cursor up
func (f *FilterGroup) MoveUp() {
if f.Cursor > 0 {
f.Cursor--
}
}
// MoveDown moves the cursor down
func (f *FilterGroup) MoveDown() {
if f.Cursor < len(f.Options)-1 {
f.Cursor++
}
}
// MoveToTop moves cursor to the first option
func (f *FilterGroup) MoveToTop() {
f.Cursor = 0
}
// MoveToBottom moves cursor to the last option
func (f *FilterGroup) MoveToBottom() {
if len(f.Options) > 0 {
f.Cursor = len(f.Options) - 1
}
}
// FilterState holds the complete filter state
type FilterState struct {
Groups []*FilterGroup
ActiveGroup int
SearchQuery string
}
// NewFilterState creates a new filter state with default groups
func NewFilterState(iterations []Iteration, areas []Area, statesByType map[string][]WorkItemStateInfo) *FilterState {
// Build sprint options from iterations
sprintOptions := []FilterOption{
{Label: "All", Value: "all", Selected: false},
}
for _, iter := range iterations {
selected := iter.IsCurrent()
sprintOptions = append(sprintOptions, FilterOption{
Label: iter.DisplayName(),
Value: iter.Path,
Selected: selected,
})
}
// If no current sprint found, select "All"
hasSelected := false
for _, opt := range sprintOptions {
if opt.Selected {
hasSelected = true
break
}
}
if !hasSelected && len(sprintOptions) > 0 {
sprintOptions[0].Selected = true
}
// Build area options from areas
areaOptions := []FilterOption{
{Label: "All", Value: "all", Selected: true},
}
for _, area := range areas {
areaOptions = append(areaOptions, FilterOption{
Label: area.DisplayName(),
Value: area.Path,
Selected: false,
})
}
// Build state options from all work item types (unique states)
stateOptions := []FilterOption{
{Label: "All", Value: "all", Selected: true},
}
if len(statesByType) > 0 {
// Collect unique states preserving order by category
seenStates := make(map[string]bool)
// Process in category order: Proposed, InProgress, Resolved, Completed
categoryOrder := []string{"Proposed", "InProgress", "Resolved", "Completed", "Removed"}
for _, category := range categoryOrder {
for _, states := range statesByType {
for _, state := range states {
if state.Category == category && !seenStates[state.Name] {
seenStates[state.Name] = true
stateOptions = append(stateOptions, FilterOption{
Label: state.Name,
Value: state.Name,
Selected: false,
})
}
}
}
}
// Also add any states that didn't match known categories
for _, states := range statesByType {
for _, state := range states {
if !seenStates[state.Name] {
seenStates[state.Name] = true
stateOptions = append(stateOptions, FilterOption{
Label: state.Name,
Value: state.Name,
Selected: false,
})
}
}
}
}
// Fallback to default states if we only have "All"
if len(stateOptions) <= 1 {
defaultStates := []string{"New", "Active", "Resolved", "Closed"}
for _, state := range defaultStates {
stateOptions = append(stateOptions, FilterOption{
Label: state,
Value: state,
Selected: false,
})
}
}
return &FilterState{
Groups: []*FilterGroup{
{
Type: FilterTypeSprint,
Title: "Sprint",
Options: sprintOptions,
Cursor: 0,
},
{
Type: FilterTypeState,
Title: "State",
Options: stateOptions,
Cursor: 0,
},
{
Type: FilterTypeAssigned,
Title: "Assigned",
Options: []FilterOption{
{Label: "All", Value: "all", Selected: false},
{Label: "Me", Value: "me", Selected: true},
},
Cursor: 0,
},
{
Type: FilterTypeArea,
Title: "Area",
Options: areaOptions,
Cursor: 0,
},
},
ActiveGroup: 0,
}
}
// ActiveFilterGroup returns the currently active filter group
func (f *FilterState) ActiveFilterGroup() *FilterGroup {
if f.ActiveGroup >= 0 && f.ActiveGroup < len(f.Groups) {
return f.Groups[f.ActiveGroup]
}
return nil
}
// NextGroup moves to the next filter group
func (f *FilterState) NextGroup() {
if f.ActiveGroup < len(f.Groups)-1 {
f.ActiveGroup++
}
}
// PrevGroup moves to the previous filter group
func (f *FilterState) PrevGroup() {
if f.ActiveGroup > 0 {
f.ActiveGroup--
}
}
// GetSelectedSprint returns the selected sprint path
func (f *FilterState) GetSelectedSprint() string {
for _, g := range f.Groups {
if g.Type == FilterTypeSprint {
if opt := g.SelectedOption(); opt != nil {
return opt.Value
}
}
}
return "all"
}
// GetSelectedState returns the selected state filter
func (f *FilterState) GetSelectedState() string {
for _, g := range f.Groups {
if g.Type == FilterTypeState {
if opt := g.SelectedOption(); opt != nil {
return opt.Value
}
}
}
return "all"
}
// GetSelectedAssigned returns the selected assigned filter
func (f *FilterState) GetSelectedAssigned() string {
for _, g := range f.Groups {
if g.Type == FilterTypeAssigned {
if opt := g.SelectedOption(); opt != nil {
return opt.Value
}
}
}
return "all"
}
// GetSelectedArea returns the selected area path
func (f *FilterState) GetSelectedArea() string {
for _, g := range f.Groups {
if g.Type == FilterTypeArea {
if opt := g.SelectedOption(); opt != nil {
return opt.Value
}
}
}
return "all"
}
// ApplySavedSelections applies saved filter selections
func (f *FilterState) ApplySavedSelections(sprint, state, assigned, area string) {
for _, g := range f.Groups {
var targetValue string
switch g.Type {
case FilterTypeSprint:
targetValue = sprint
case FilterTypeState:
targetValue = state
case FilterTypeAssigned:
targetValue = assigned
case FilterTypeArea:
targetValue = area
}
if targetValue == "" {
continue
}
// Find and select the matching option
found := false
for i, opt := range g.Options {
if opt.Value == targetValue {
g.Select(i)
found = true
break
}
}
// If not found and it's sprint with "current", keep the current selection
if !found && g.Type == FilterTypeSprint && targetValue == "current" {
// Already handled in NewFilterState
}
}
}
+37
View File
@@ -0,0 +1,37 @@
package models
import "time"
// Iteration represents an Azure DevOps iteration (sprint)
type Iteration struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
StartDate time.Time `json:"startDate"`
FinishDate time.Time `json:"finishDate"`
TimeFrame string `json:"timeFrame"` // "past", "current", "future"
URL string `json:"url"`
}
// IsCurrent returns true if this is the current iteration
func (i *Iteration) IsCurrent() bool {
return i.TimeFrame == "current"
}
// IsPast returns true if this iteration is in the past
func (i *Iteration) IsPast() bool {
return i.TimeFrame == "past"
}
// IsFuture returns true if this iteration is in the future
func (i *Iteration) IsFuture() bool {
return i.TimeFrame == "future"
}
// DisplayName returns a formatted name for display
func (i *Iteration) DisplayName() string {
if i.IsCurrent() {
return i.Name + " (current)"
}
return i.Name
}
+92
View File
@@ -0,0 +1,92 @@
package models
import "time"
// WorkItemType represents the type of work item
type WorkItemType string
const (
WorkItemTypeStory WorkItemType = "User Story"
WorkItemTypeTask WorkItemType = "Task"
WorkItemTypeBug WorkItemType = "Bug"
WorkItemTypeFeature WorkItemType = "Feature"
WorkItemTypeEpic WorkItemType = "Epic"
)
// WorkItemState represents the state of a work item
type WorkItemState string
const (
WorkItemStateNew WorkItemState = "New"
WorkItemStateActive WorkItemState = "Active"
WorkItemStateResolved WorkItemState = "Resolved"
WorkItemStateClosed WorkItemState = "Closed"
)
// WorkItem represents an Azure DevOps work item
type WorkItem struct {
ID int `json:"id"`
Rev int `json:"rev"`
Title string `json:"title"`
State WorkItemState `json:"state"`
Type WorkItemType `json:"type"`
AssignedTo string `json:"assignedTo"`
IterationPath string `json:"iterationPath"`
AreaPath string `json:"areaPath"`
Description string `json:"description"`
Tags []string `json:"tags"`
ParentID int `json:"parentId"`
ParentTitle string `json:"parentTitle"`
Priority int `json:"priority"`
CreatedDate time.Time `json:"createdDate"`
ChangedDate time.Time `json:"changedDate"`
URL string `json:"url"`
WebURL string `json:"webUrl"`
}
// ShortType returns a short version of the work item type
func (w *WorkItem) ShortType() string {
switch w.Type {
case WorkItemTypeStory:
return "Story"
case WorkItemTypeTask:
return "Task"
case WorkItemTypeBug:
return "Bug"
case WorkItemTypeFeature:
return "Feature"
case WorkItemTypeEpic:
return "Epic"
default:
return string(w.Type)
}
}
// SprintName extracts the sprint name from the iteration path
func (w *WorkItem) SprintName() string {
// IterationPath is like "MyProject\\Sprint 42"
// Return just "Sprint 42"
for i := len(w.IterationPath) - 1; i >= 0; i-- {
if w.IterationPath[i] == '\\' {
return w.IterationPath[i+1:]
}
}
return w.IterationPath
}
// AreaName extracts the area name from the area path
func (w *WorkItem) AreaName() string {
for i := len(w.AreaPath) - 1; i >= 0; i-- {
if w.AreaPath[i] == '\\' {
return w.AreaPath[i+1:]
}
}
return w.AreaPath
}
// WorkItemStateInfo represents state metadata from Azure DevOps
type WorkItemStateInfo struct {
Name string `json:"name"`
Color string `json:"color"`
Category string `json:"category"` // Proposed, InProgress, Resolved, Completed, Removed
}
+532
View File
@@ -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}
}
}
+251
View File
@@ -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
}
+212
View File
@@ -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()
}
+211
View File
@@ -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{}
+223
View File
@@ -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
}
+173
View File
@@ -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, " ")
}
+223
View File
@@ -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)
}
+367
View File
@@ -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
}
+129
View File
@@ -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},
}
}
+234
View File
@@ -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)
},
}
}