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:
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# Binaries
|
||||||
|
devops-tui
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of go coverage
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Go workspace
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Config with secrets
|
||||||
|
.env
|
||||||
|
config.yaml
|
||||||
|
|
||||||
|
# Serena
|
||||||
|
.serena/
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# devops-tui
|
||||||
|
|
||||||
|
A terminal user interface (TUI) for Azure DevOps Boards, inspired by [jira-cli](https://github.com/ankitpokhrel/jira-cli) and [JiraTUI](https://jiratui.sh/).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- View Azure DevOps work items in a clean terminal interface
|
||||||
|
- Filter by Sprint, State, and Assigned To
|
||||||
|
- Vim-style navigation (j/k/g/G)
|
||||||
|
- Fullscreen detail view
|
||||||
|
- Open work items in browser
|
||||||
|
- Cross-platform (Windows, macOS, Linux)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Build from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build -o devops-tui .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Move to PATH
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mv devops-tui /usr/local/bin/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Create a config file at `~/.config/devops-tui/config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Azure DevOps connection
|
||||||
|
organization: "my-organization"
|
||||||
|
project: "my-project"
|
||||||
|
team: "my-team"
|
||||||
|
|
||||||
|
# Authentication (or use AZURE_DEVOPS_PAT env variable)
|
||||||
|
pat: "your-personal-access-token"
|
||||||
|
|
||||||
|
# UI settings
|
||||||
|
theme: "default"
|
||||||
|
|
||||||
|
# Default filters at startup
|
||||||
|
defaults:
|
||||||
|
sprint: "current"
|
||||||
|
state: "all"
|
||||||
|
assigned: "me"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `AZURE_DEVOPS_PAT` | Personal Access Token (recommended) |
|
||||||
|
| `AZURE_DEVOPS_ORG` | Organization (overrides config) |
|
||||||
|
| `AZURE_DEVOPS_PROJECT` | Project (overrides config) |
|
||||||
|
| `AZURE_DEVOPS_TEAM` | Team (overrides config) |
|
||||||
|
|
||||||
|
### PAT Permissions
|
||||||
|
|
||||||
|
Your Personal Access Token needs these scopes:
|
||||||
|
- `Work Items (Read)` - Read work items
|
||||||
|
- `Project and Team (Read)` - List sprints/iterations
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
### Global
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `Tab` | Switch to next panel |
|
||||||
|
| `Shift+Tab` | Switch to previous panel |
|
||||||
|
| `?` | Show/hide help |
|
||||||
|
| `Ctrl+r` | Reload data |
|
||||||
|
| `q` / `Ctrl+c` | Quit |
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `j` / `↓` | Move down |
|
||||||
|
| `k` / `↑` | Move up |
|
||||||
|
| `g` | Go to first item |
|
||||||
|
| `G` | Go to last item |
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `Enter` / `Space` | Select filter / Open in browser |
|
||||||
|
| `v` | View fullscreen details |
|
||||||
|
|
||||||
|
### Detail View
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `Esc` / `q` | Back to main view |
|
||||||
|
| `Enter` | Open in browser |
|
||||||
|
| `j` / `k` | Scroll description |
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- [Bubble Tea](https://github.com/charmbracelet/bubbletea) - TUI framework
|
||||||
|
- [Bubbles](https://github.com/charmbracelet/bubbles) - UI components
|
||||||
|
- [Lip Gloss](https://github.com/charmbracelet/lipgloss) - Styling
|
||||||
|
- [Viper](https://github.com/spf13/viper) - Configuration
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
# devops-tui - MVP Specifikation
|
||||||
|
|
||||||
|
> En terminal user interface (TUI) för Azure DevOps Boards, inspirerad av [jira-cli](https://github.com/ankitpokhrel/jira-cli) och [JiraTUI](https://jiratui.sh/).
|
||||||
|
|
||||||
|
## Översikt
|
||||||
|
|
||||||
|
**devops-tui** är ett terminalbaserat verktyg för att navigera och granska Azure DevOps work items direkt från kommandoraden. MVP:n fokuserar på read-only boards-funktionalitet med en panel-baserad layout inspirerad av Lazygit.
|
||||||
|
|
||||||
|
### Mål
|
||||||
|
|
||||||
|
- Snabb överblick av work items utan att lämna terminalen
|
||||||
|
- Vim-inspirerad navigation för effektivt arbetsflöde
|
||||||
|
- Enkel installation via single binary (Go)
|
||||||
|
|
||||||
|
### Avgränsningar (MVP)
|
||||||
|
|
||||||
|
| Inkluderat | Exkluderat |
|
||||||
|
|------------|------------|
|
||||||
|
| Boards (work items) | Pipelines/Builds |
|
||||||
|
| Read-only visning | Skapa/redigera items |
|
||||||
|
| Fasta filter (Sprint/State/Assigned) | WIQL-queries |
|
||||||
|
| Ett konfigurerat projekt | Projekt-switcher |
|
||||||
|
| Personal Access Token auth | OAuth/SSO |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ devops-tui │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ UI Layer │
|
||||||
|
│ ├─ Bubble Tea (TUI framework) │
|
||||||
|
│ ├─ Bubbles (komponenter) │
|
||||||
|
│ └─ Lip Gloss (styling) │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Data Layer │
|
||||||
|
│ ├─ Azure DevOps REST API v7.1 │
|
||||||
|
│ └─ HTTP client (net/http) │
|
||||||
|
├─────────────────────────────────────────┤
|
||||||
|
│ Config │
|
||||||
|
│ ├─ Viper (konfiguration) │
|
||||||
|
│ └─ YAML config file │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Varför Go + Bubble Tea?
|
||||||
|
|
||||||
|
1. **Single binary** - Enkel distribution, inga runtime-beroenden
|
||||||
|
2. **Cross-platform** - Windows, macOS, Linux utan extra arbete
|
||||||
|
3. **Beprövat** - Används av GitHub CLI, lazygit, och jira-cli
|
||||||
|
4. **Bra ekosystem** - Charm.sh har kompletterande bibliotek
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
```go
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/bubbletea // TUI framework
|
||||||
|
github.com/charmbracelet/bubbles // UI komponenter
|
||||||
|
github.com/charmbracelet/lipgloss // Styling
|
||||||
|
github.com/spf13/viper // Konfiguration
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Design
|
||||||
|
|
||||||
|
### Layout (3-panel)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ devops-tui ──────────────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ ┌─ FILTER ──────────┐ ┌─ WORK ITEMS ────────────────────────┐ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ Sprint │ │ ID TYPE STATE TITLE │ │
|
||||||
|
│ │ ───────────── │ │ ───────────────────────────────── │ │
|
||||||
|
│ │ ▸ Sprint 42 │ │ #1234 Story Active Implement │ │
|
||||||
|
│ │ Sprint 41 │ │▸ #1235 Task Active Create lo │ │
|
||||||
|
│ │ Sprint 40 │ │ #1236 Task New Add JWT m │ │
|
||||||
|
│ │ Backlog │ │ #1237 Bug Active Fix token │ │
|
||||||
|
│ │ │ │ #1238 Task Resolved Setup CI │ │
|
||||||
|
│ ├───────────────────┤ │ │ │
|
||||||
|
│ │ State │ │ │ │
|
||||||
|
│ │ ───────────── │ │ │ │
|
||||||
|
│ │ ● All │ │ │ │
|
||||||
|
│ │ ○ New │ └─────────────────────────────────────┘ │
|
||||||
|
│ │ ○ Active │ ┌─ DETAILS ───────────────────────────┐ │
|
||||||
|
│ │ ○ Resolved │ │ │ │
|
||||||
|
│ │ ○ Closed │ │ #1235 Create login component │ │
|
||||||
|
│ ├───────────────────┤ │ │ │
|
||||||
|
│ │ Assigned │ │ Type: Task State: Active │ │
|
||||||
|
│ │ ───────────── │ │ Assigned: Samuel Sprint: 42 │ │
|
||||||
|
│ │ ● All │ │ Area: Frontend Priority: 2 │ │
|
||||||
|
│ │ ○ Me │ │ │ │
|
||||||
|
│ │ │ │ Parent: #1234 Implement auth flow │ │
|
||||||
|
│ └───────────────────┘ │ │ │
|
||||||
|
│ │ ─── Description ─── │ │
|
||||||
|
│ │ Create a React login component │ │
|
||||||
|
│ │ with email/password fields... │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ─── Tags ─── │ │
|
||||||
|
│ │ frontend · react · auth │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
├───────────────────────────────────────────────────────────────┤
|
||||||
|
│ Tab Panel j/k Navigate g/G Top/Bottom Enter Open ? Help │
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Paneler
|
||||||
|
|
||||||
|
| Panel | Bredd | Innehåll | Interaktion |
|
||||||
|
|-------|-------|----------|-------------|
|
||||||
|
| **Filter** | ~20% | Sprint, State, Assigned filter | Välj filter med Enter/Space |
|
||||||
|
| **Work Items** | ~80% (topp) | Tabell med work items | Navigera med j/k, öppna med Enter |
|
||||||
|
| **Details** | ~80% (botten) | Detaljer för valt item | Automatiskt uppdaterad |
|
||||||
|
|
||||||
|
### Fullskärms-detaljvy
|
||||||
|
|
||||||
|
När användaren trycker `v` på ett work item:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ #1235 Create login component ────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ ┌─ METADATA ───────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Type: Task ID: #1235 │ │
|
||||||
|
│ │ State: Active Created: 2024-01-15 │ │
|
||||||
|
│ │ Assigned: Samuel Enocsson Updated: 2024-01-18 │ │
|
||||||
|
│ │ Sprint: Sprint 42 Priority: 2 │ │
|
||||||
|
│ │ Area: Frontend │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ PARENT ─────────────────────────────────────────────────┐ │
|
||||||
|
│ │ #1234 User Story: Implement authentication flow │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ DESCRIPTION ────────────────────────────────────────────┐ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ Create a React login component with the following: │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ - Email input field with validation │ │
|
||||||
|
│ │ - Password input field │ │
|
||||||
|
│ │ - "Remember me" checkbox │ │
|
||||||
|
│ │ - Submit button with loading state │ │
|
||||||
|
│ │ - Error message display │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ The component should follow our design system and │ │
|
||||||
|
│ │ integrate with the existing auth context. │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ TAGS ───────────────────────────────────────────────────┐ │
|
||||||
|
│ │ frontend · react · auth · ui │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
├───────────────────────────────────────────────────────────────┤
|
||||||
|
│ Esc Back Enter Open in browser j/k Scroll │
|
||||||
|
└───────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
|
### Globala
|
||||||
|
|
||||||
|
| Key | Beskrivning |
|
||||||
|
|-----|-------------|
|
||||||
|
| `Tab` | Byt till nästa panel |
|
||||||
|
| `Shift+Tab` | Byt till föregående panel |
|
||||||
|
| `?` | Visa/dölj hjälp |
|
||||||
|
| `Ctrl+r` | Ladda om data |
|
||||||
|
| `q` / `Ctrl+c` | Avsluta |
|
||||||
|
|
||||||
|
### Filter-panel
|
||||||
|
|
||||||
|
| Key | Beskrivning |
|
||||||
|
|-----|-------------|
|
||||||
|
| `j` / `↓` | Nästa filter/val |
|
||||||
|
| `k` / `↑` | Föregående filter/val |
|
||||||
|
| `Enter` / `Space` | Välj filter |
|
||||||
|
| `g` | Gå till första |
|
||||||
|
| `G` | Gå till sista |
|
||||||
|
|
||||||
|
### Work Items-panel
|
||||||
|
|
||||||
|
| Key | Beskrivning |
|
||||||
|
|-----|-------------|
|
||||||
|
| `j` / `↓` | Nästa work item |
|
||||||
|
| `k` / `↑` | Föregående work item |
|
||||||
|
| `g` | Gå till första |
|
||||||
|
| `G` | Gå till sista |
|
||||||
|
| `Enter` | Öppna i webbläsare |
|
||||||
|
| `v` | Visa fullskärms-detaljer |
|
||||||
|
| `/` | Sök (filter by text) |
|
||||||
|
|
||||||
|
### Detaljvy (fullskärm)
|
||||||
|
|
||||||
|
| Key | Beskrivning |
|
||||||
|
|-----|-------------|
|
||||||
|
| `Esc` / `q` | Tillbaka till huvudvy |
|
||||||
|
| `Enter` | Öppna i webbläsare |
|
||||||
|
| `j` / `k` | Scrolla beskrivning |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### Config-fil
|
||||||
|
|
||||||
|
Placering: `~/.config/devops-tui/config.yaml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Azure DevOps-anslutning
|
||||||
|
organization: "my-organization"
|
||||||
|
project: "my-project"
|
||||||
|
|
||||||
|
# Autentisering
|
||||||
|
# PAT kan anges här eller via miljövariabel AZURE_DEVOPS_PAT
|
||||||
|
pat: ""
|
||||||
|
|
||||||
|
# UI-inställningar
|
||||||
|
theme: "default" # default, dark, light
|
||||||
|
|
||||||
|
# Standardfilter vid uppstart
|
||||||
|
defaults:
|
||||||
|
sprint: "current" # "current", "all", eller specifikt namn
|
||||||
|
state: "all" # "all", "new", "active", "resolved", "closed"
|
||||||
|
assigned: "me" # "all", "me"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Miljövariabler
|
||||||
|
|
||||||
|
| Variabel | Beskrivning |
|
||||||
|
|----------|-------------|
|
||||||
|
| `AZURE_DEVOPS_PAT` | Personal Access Token (rekommenderat) |
|
||||||
|
| `AZURE_DEVOPS_ORG` | Organisation (override config) |
|
||||||
|
| `AZURE_DEVOPS_PROJECT` | Projekt (override config) |
|
||||||
|
|
||||||
|
### PAT-behörigheter
|
||||||
|
|
||||||
|
Personal Access Token behöver följande scope:
|
||||||
|
- `Work Items (Read)` - Läsa work items
|
||||||
|
- `Project and Team (Read)` - Lista sprints/iterationer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Azure DevOps API
|
||||||
|
|
||||||
|
### Endpoints som används
|
||||||
|
|
||||||
|
```
|
||||||
|
Base URL: https://dev.azure.com/{organization}/{project}/_apis
|
||||||
|
|
||||||
|
Work Items:
|
||||||
|
GET /wit/wiql # Kör WIQL-query
|
||||||
|
GET /wit/workitems/{id} # Hämta enskilt work item
|
||||||
|
GET /wit/workitems?ids={ids} # Hämta flera work items
|
||||||
|
|
||||||
|
Iterationer (Sprints):
|
||||||
|
GET /work/teamsettings/iterations # Lista iterationer
|
||||||
|
|
||||||
|
Team:
|
||||||
|
GET /teams # Lista teams
|
||||||
|
```
|
||||||
|
|
||||||
|
### WIQL-queries
|
||||||
|
|
||||||
|
För att hämta work items använder vi WIQL (Work Item Query Language):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Alla items i current sprint, tilldelade mig
|
||||||
|
SELECT [System.Id], [System.Title], [System.State], [System.WorkItemType]
|
||||||
|
FROM WorkItems
|
||||||
|
WHERE [System.TeamProject] = @project
|
||||||
|
AND [System.IterationPath] = @currentIteration
|
||||||
|
AND [System.AssignedTo] = @me
|
||||||
|
ORDER BY [System.ChangedDate] DESC
|
||||||
|
|
||||||
|
-- Alla aktiva items
|
||||||
|
SELECT [System.Id], [System.Title], [System.State], [System.WorkItemType]
|
||||||
|
FROM WorkItems
|
||||||
|
WHERE [System.TeamProject] = @project
|
||||||
|
AND [System.State] = 'Active'
|
||||||
|
ORDER BY [System.ChangedDate] DESC
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response-struktur (Work Item)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1235,
|
||||||
|
"rev": 5,
|
||||||
|
"fields": {
|
||||||
|
"System.Id": 1235,
|
||||||
|
"System.Title": "Create login component",
|
||||||
|
"System.State": "Active",
|
||||||
|
"System.WorkItemType": "Task",
|
||||||
|
"System.AssignedTo": {
|
||||||
|
"displayName": "Samuel Enocsson",
|
||||||
|
"uniqueName": "samuel@example.com"
|
||||||
|
},
|
||||||
|
"System.IterationPath": "MyProject\\Sprint 42",
|
||||||
|
"System.AreaPath": "MyProject\\Frontend",
|
||||||
|
"System.Description": "<div>Create a React login...</div>",
|
||||||
|
"System.Tags": "frontend; react; auth",
|
||||||
|
"System.Parent": 1234,
|
||||||
|
"Microsoft.VSTS.Common.Priority": 2,
|
||||||
|
"System.CreatedDate": "2024-01-15T10:00:00Z",
|
||||||
|
"System.ChangedDate": "2024-01-18T14:30:00Z"
|
||||||
|
},
|
||||||
|
"url": "https://dev.azure.com/org/project/_apis/wit/workItems/1235"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
devops-tui/
|
||||||
|
├── main.go # Entry point
|
||||||
|
├── go.mod
|
||||||
|
├── go.sum
|
||||||
|
├── README.md
|
||||||
|
├── SPEC.md # Denna fil
|
||||||
|
│
|
||||||
|
├── cmd/
|
||||||
|
│ └── root.go # CLI setup (cobra om vi vill ha subcommands)
|
||||||
|
│
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── config.go # Viper config loading
|
||||||
|
│ │
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── client.go # Azure DevOps HTTP client
|
||||||
|
│ │ ├── workitems.go # Work item queries
|
||||||
|
│ │ └── iterations.go # Sprint/iteration queries
|
||||||
|
│ │
|
||||||
|
│ ├── ui/
|
||||||
|
│ │ ├── app.go # Bubble Tea main model
|
||||||
|
│ │ ├── styles.go # Lip Gloss styles
|
||||||
|
│ │ ├── keys.go # Keyboard bindings
|
||||||
|
│ │ │
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ │ ├── filter.go # Filter panel
|
||||||
|
│ │ │ ├── workitems.go # Work items list
|
||||||
|
│ │ │ ├── details.go # Details panel
|
||||||
|
│ │ │ ├── detailview.go # Fullscreen detail view
|
||||||
|
│ │ │ └── help.go # Help overlay
|
||||||
|
│ │ │
|
||||||
|
│ │ └── views/
|
||||||
|
│ │ ├── main.go # Main 3-panel view
|
||||||
|
│ │ └── detail.go # Fullscreen detail view
|
||||||
|
│ │
|
||||||
|
│ └── models/
|
||||||
|
│ ├── workitem.go # Work item domain model
|
||||||
|
│ ├── iteration.go # Sprint/iteration model
|
||||||
|
│ └── filter.go # Filter state model
|
||||||
|
│
|
||||||
|
└── pkg/
|
||||||
|
└── browser/
|
||||||
|
└── open.go # Cross-platform browser open
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation - Milstolpar
|
||||||
|
|
||||||
|
### Fas 1: Grundstruktur
|
||||||
|
- [ ] Initiera Go-modul
|
||||||
|
- [ ] Sätt upp projektstruktur
|
||||||
|
- [ ] Konfigurera Bubble Tea scaffold
|
||||||
|
- [ ] Implementera config-laddning (Viper)
|
||||||
|
|
||||||
|
### Fas 2: Azure DevOps API
|
||||||
|
- [ ] HTTP-klient med PAT-auth
|
||||||
|
- [ ] Hämta iterationer (sprints)
|
||||||
|
- [ ] Kör WIQL-queries
|
||||||
|
- [ ] Hämta work items med detaljer
|
||||||
|
|
||||||
|
### Fas 3: UI - Paneler
|
||||||
|
- [ ] Filter-panel (sprint, state, assigned)
|
||||||
|
- [ ] Work items-lista med tabell
|
||||||
|
- [ ] Details-panel med preview
|
||||||
|
- [ ] Panel-navigation (Tab)
|
||||||
|
|
||||||
|
### Fas 4: UI - Interaktion
|
||||||
|
- [ ] Vim-navigation (j/k/g/G)
|
||||||
|
- [ ] Filter-val påverkar work items
|
||||||
|
- [ ] Öppna i browser (Enter)
|
||||||
|
- [ ] Fullskärms-detaljvy (v)
|
||||||
|
|
||||||
|
### Fas 5: Polish
|
||||||
|
- [ ] Hjälp-overlay (?)
|
||||||
|
- [ ] Refresh (Ctrl+r)
|
||||||
|
- [ ] Felhantering och loading states
|
||||||
|
- [ ] Färgtema och styling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Framtida features (post-MVP)
|
||||||
|
|
||||||
|
Dessa features är medvetet exkluderade från MVP men kan läggas till senare:
|
||||||
|
|
||||||
|
### Prioritet 1 (nästa iteration)
|
||||||
|
- [ ] Pipelines/Builds-vy
|
||||||
|
- [ ] Pull Requests-vy
|
||||||
|
- [ ] Projekt-switcher
|
||||||
|
|
||||||
|
### Prioritet 2
|
||||||
|
- [ ] Skapa work items
|
||||||
|
- [ ] Redigera work items (state, assigned)
|
||||||
|
- [ ] WIQL query editor
|
||||||
|
- [ ] Kommentarer
|
||||||
|
|
||||||
|
### Prioritet 3
|
||||||
|
- [ ] Kanban board-vy
|
||||||
|
- [ ] Notifications
|
||||||
|
- [ ] Offline cache
|
||||||
|
- [ ] Themes (dark/light/custom)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Referenser
|
||||||
|
|
||||||
|
### Inspiration
|
||||||
|
- [jira-cli](https://github.com/ankitpokhrel/jira-cli) - Go/Bubble Tea, tabellbaserad
|
||||||
|
- [JiraTUI](https://jiratui.sh/) - Python/Textual, panel-baserad
|
||||||
|
- [lazygit](https://github.com/jesseduffield/lazygit) - Go/gocui, panel-layout
|
||||||
|
- [k9s](https://github.com/derailed/k9s) - Go/tview, Kubernetes TUI
|
||||||
|
|
||||||
|
### Dokumentation
|
||||||
|
- [Bubble Tea](https://github.com/charmbracelet/bubbletea)
|
||||||
|
- [Bubbles](https://github.com/charmbracelet/bubbles)
|
||||||
|
- [Lip Gloss](https://github.com/charmbracelet/lipgloss)
|
||||||
|
- [Azure DevOps REST API](https://learn.microsoft.com/en-us/rest/api/azure/devops/)
|
||||||
|
- [WIQL Syntax](https://learn.microsoft.com/en-us/azure/devops/boards/queries/wiql-syntax)
|
||||||
|
|
||||||
|
### Azure DevOps Go Libraries
|
||||||
|
- [microsoft/azure-devops-go-api](https://github.com/microsoft/azure-devops-go-api) - Officiellt men begränsat
|
||||||
|
- REST API direkt rekommenderas för enklare implementation
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/api"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/config"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/ui"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Execute runs the application
|
||||||
|
func Execute() error {
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
// If config not found, try to create default
|
||||||
|
if err := config.CreateDefaultConfig(); err == nil {
|
||||||
|
fmt.Println("Created default config file at ~/.config/devops-tui/config.yaml")
|
||||||
|
fmt.Println("Please edit the config file with your Azure DevOps settings.")
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("configuration error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create API client
|
||||||
|
client := api.NewClient(cfg)
|
||||||
|
|
||||||
|
// Create and run the TUI
|
||||||
|
app := ui.NewApp(client)
|
||||||
|
|
||||||
|
p := tea.NewProgram(
|
||||||
|
app,
|
||||||
|
tea.WithAltScreen(),
|
||||||
|
tea.WithMouseCellMotion(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if _, err := p.Run(); err != nil {
|
||||||
|
return fmt.Errorf("error running application: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
module github.com/samuelenocsson/devops-tui
|
||||||
|
|
||||||
|
go 1.25.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
|
||||||
|
github.com/spf13/viper v1.21.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/glamour v0.10.0 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/reflow v0.3.0 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.5 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/net v0.33.0 // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
golang.org/x/term v0.31.0 // indirect
|
||||||
|
golang.org/x/text v0.28.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||||
|
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
|
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
|
||||||
|
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
|
||||||
|
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||||
|
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||||
|
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
|
||||||
|
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
|
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
||||||
|
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||||
|
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||||
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, " ", " ")
|
||||||
|
s = strings.ReplaceAll(s, "&", "&")
|
||||||
|
s = strings.ReplaceAll(s, "<", "<")
|
||||||
|
s = strings.ReplaceAll(s, ">", ">")
|
||||||
|
s = strings.ReplaceAll(s, """, "\"")
|
||||||
|
s = strings.ReplaceAll(s, "'", "'")
|
||||||
|
|
||||||
|
// Trim whitespace
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/api"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/config"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/models"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/ui/components"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
|
||||||
|
"github.com/samuelenocsson/devops-tui/pkg/browser"
|
||||||
|
"github.com/samuelenocsson/devops-tui/pkg/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Panel represents the active panel
|
||||||
|
type Panel int
|
||||||
|
|
||||||
|
const (
|
||||||
|
PanelFilter Panel = iota
|
||||||
|
PanelWorkItems
|
||||||
|
)
|
||||||
|
|
||||||
|
// ViewMode represents the current view mode
|
||||||
|
type ViewMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ViewMain ViewMode = iota
|
||||||
|
ViewDetail
|
||||||
|
)
|
||||||
|
|
||||||
|
// App is the main application model
|
||||||
|
type App struct {
|
||||||
|
// Components
|
||||||
|
filterPanel components.FilterPanel
|
||||||
|
workItemsPanel components.WorkItemsPanel
|
||||||
|
detailsPanel components.DetailsPanel
|
||||||
|
detailView components.DetailView
|
||||||
|
helpPanel components.HelpPanel
|
||||||
|
stateModal components.StateModal
|
||||||
|
branchModal components.BranchModal
|
||||||
|
|
||||||
|
// State
|
||||||
|
activePanel Panel
|
||||||
|
viewMode ViewMode
|
||||||
|
loading bool
|
||||||
|
err error
|
||||||
|
statusMsg string // Temporary status message
|
||||||
|
|
||||||
|
// Data
|
||||||
|
iterations []models.Iteration
|
||||||
|
areas []models.Area
|
||||||
|
workItems []models.WorkItem
|
||||||
|
statesByType map[string][]models.WorkItemStateInfo
|
||||||
|
|
||||||
|
// Services
|
||||||
|
client *api.Client
|
||||||
|
|
||||||
|
// Config
|
||||||
|
styles theme.Styles
|
||||||
|
keys theme.KeyMap
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewApp creates a new application
|
||||||
|
func NewApp(client *api.Client) App {
|
||||||
|
styles := theme.DefaultStyles()
|
||||||
|
keys := theme.DefaultKeyMap()
|
||||||
|
|
||||||
|
// Create empty filter state (will be populated after loading data)
|
||||||
|
filterState := models.NewFilterState(nil, nil, nil)
|
||||||
|
|
||||||
|
return App{
|
||||||
|
filterPanel: components.NewFilterPanel(filterState, styles, keys),
|
||||||
|
workItemsPanel: components.NewWorkItemsPanel(styles, keys),
|
||||||
|
detailsPanel: components.NewDetailsPanel(styles),
|
||||||
|
detailView: components.NewDetailView(styles, keys),
|
||||||
|
helpPanel: components.NewHelpPanel(keys, styles),
|
||||||
|
stateModal: components.NewStateModal(styles, keys),
|
||||||
|
branchModal: components.NewBranchModal(styles, keys),
|
||||||
|
activePanel: PanelWorkItems,
|
||||||
|
viewMode: ViewMain,
|
||||||
|
loading: true,
|
||||||
|
client: client,
|
||||||
|
styles: styles,
|
||||||
|
keys: keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the application
|
||||||
|
func (a App) Init() tea.Cmd {
|
||||||
|
return tea.Batch(
|
||||||
|
loadDataCmd(a.client),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages
|
||||||
|
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
var cmds []tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
a.width = msg.Width
|
||||||
|
a.height = msg.Height
|
||||||
|
a.updateSizes()
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
// Handle modals first (they capture all input when visible)
|
||||||
|
if a.stateModal.IsVisible() {
|
||||||
|
newModal, cmd := a.stateModal.Update(msg)
|
||||||
|
a.stateModal = newModal
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
return a, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.branchModal.IsVisible() {
|
||||||
|
newModal, cmd := a.branchModal.Update(msg)
|
||||||
|
a.branchModal = newModal
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
return a, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global keys
|
||||||
|
if key.Matches(msg, a.keys.Quit) && !a.helpPanel.IsVisible() && a.viewMode == ViewMain {
|
||||||
|
return a, tea.Quit
|
||||||
|
}
|
||||||
|
|
||||||
|
if key.Matches(msg, a.keys.Help) {
|
||||||
|
a.helpPanel.Toggle()
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close help with any key if visible
|
||||||
|
if a.helpPanel.IsVisible() {
|
||||||
|
if key.Matches(msg, a.keys.Back) || key.Matches(msg, a.keys.Help) {
|
||||||
|
a.helpPanel.SetVisible(false)
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle detail view mode
|
||||||
|
if a.viewMode == ViewDetail {
|
||||||
|
newDetailView, cmd := a.detailView.Update(msg)
|
||||||
|
a.detailView = newDetailView
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
return a, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panel switching
|
||||||
|
if key.Matches(msg, a.keys.NextPanel) {
|
||||||
|
a.nextPanel()
|
||||||
|
a.updateFocus()
|
||||||
|
}
|
||||||
|
if key.Matches(msg, a.keys.PrevPanel) {
|
||||||
|
a.prevPanel()
|
||||||
|
a.updateFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh
|
||||||
|
if key.Matches(msg, a.keys.Refresh) {
|
||||||
|
a.loading = true
|
||||||
|
a.statusMsg = ""
|
||||||
|
return a, loadWorkItemsCmd(a.client, a.filterPanel.FilterState())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open state change modal (only when work items panel is active)
|
||||||
|
if key.Matches(msg, a.keys.ChangeState) && a.activePanel == PanelWorkItems {
|
||||||
|
if item := a.workItemsPanel.SelectedItem(); item != nil {
|
||||||
|
a.stateModal.SetItem(item)
|
||||||
|
a.stateModal.SetSize(a.width, a.height)
|
||||||
|
a.stateModal.SetVisible(true)
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open branch modal (only when work items panel is active)
|
||||||
|
if key.Matches(msg, a.keys.CreateBranch) && a.activePanel == PanelWorkItems {
|
||||||
|
if item := a.workItemsPanel.SelectedItem(); item != nil {
|
||||||
|
a.branchModal.SetItem(item)
|
||||||
|
a.branchModal.SetSize(a.width, a.height)
|
||||||
|
a.branchModal.SetVisible(true)
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update active panel
|
||||||
|
switch a.activePanel {
|
||||||
|
case PanelFilter:
|
||||||
|
newFilter, cmd := a.filterPanel.Update(msg)
|
||||||
|
a.filterPanel = newFilter
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
case PanelWorkItems:
|
||||||
|
newWorkItems, cmd := a.workItemsPanel.Update(msg)
|
||||||
|
a.workItemsPanel = newWorkItems
|
||||||
|
if cmd != nil {
|
||||||
|
cmds = append(cmds, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case dataLoadedMsg:
|
||||||
|
a.iterations = msg.iterations
|
||||||
|
a.areas = msg.areas
|
||||||
|
a.statesByType = msg.statesByType
|
||||||
|
a.stateModal.SetStatesByType(a.statesByType)
|
||||||
|
filterState := models.NewFilterState(a.iterations, a.areas, a.statesByType)
|
||||||
|
|
||||||
|
// Apply saved filter selections
|
||||||
|
if savedState, err := config.LoadFilterState(); err == nil {
|
||||||
|
filterState.ApplySavedSelections(savedState.Sprint, savedState.State, savedState.Assigned, savedState.Area)
|
||||||
|
}
|
||||||
|
|
||||||
|
a.filterPanel.SetFilterState(filterState)
|
||||||
|
// Load work items with initial filters
|
||||||
|
return a, loadWorkItemsCmd(a.client, filterState)
|
||||||
|
|
||||||
|
case workItemsLoadedMsg:
|
||||||
|
a.loading = false
|
||||||
|
a.workItems = msg.items
|
||||||
|
a.workItemsPanel.SetItems(msg.items)
|
||||||
|
a.updateSelectedItem()
|
||||||
|
|
||||||
|
case components.FilterChangedMsg:
|
||||||
|
a.loading = true
|
||||||
|
fs := a.filterPanel.FilterState()
|
||||||
|
|
||||||
|
// Save filter selections for next startup
|
||||||
|
_ = config.SaveFilterState(&config.FilterState{
|
||||||
|
Sprint: fs.GetSelectedSprint(),
|
||||||
|
State: fs.GetSelectedState(),
|
||||||
|
Assigned: fs.GetSelectedAssigned(),
|
||||||
|
Area: fs.GetSelectedArea(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return a, loadWorkItemsCmd(a.client, fs)
|
||||||
|
|
||||||
|
case components.OpenWorkItemMsg:
|
||||||
|
if err := browser.Open(msg.Item.WebURL); err != nil {
|
||||||
|
a.err = err
|
||||||
|
}
|
||||||
|
|
||||||
|
case components.ViewWorkItemMsg:
|
||||||
|
a.viewMode = ViewDetail
|
||||||
|
a.detailView.SetItem(&msg.Item)
|
||||||
|
a.updateSizes()
|
||||||
|
|
||||||
|
case components.CloseDetailViewMsg:
|
||||||
|
a.viewMode = ViewMain
|
||||||
|
|
||||||
|
case errMsg:
|
||||||
|
a.loading = false
|
||||||
|
a.err = msg.err
|
||||||
|
|
||||||
|
case components.ModalClosedMsg:
|
||||||
|
// Modal was closed, nothing special to do
|
||||||
|
a.stateModal.SetVisible(false)
|
||||||
|
a.branchModal.SetVisible(false)
|
||||||
|
|
||||||
|
case components.StateChangeRequestMsg:
|
||||||
|
a.stateModal.SetVisible(false)
|
||||||
|
a.loading = true
|
||||||
|
return a, updateWorkItemStateCmd(a.client, msg.Item.ID, msg.NewState, a.filterPanel.FilterState())
|
||||||
|
|
||||||
|
case stateChangedMsg:
|
||||||
|
a.loading = false
|
||||||
|
a.statusMsg = fmt.Sprintf("State changed to %s", msg.newState)
|
||||||
|
// Refresh work items to show updated state
|
||||||
|
return a, loadWorkItemsCmd(a.client, a.filterPanel.FilterState())
|
||||||
|
|
||||||
|
case components.BranchCreateRequestMsg:
|
||||||
|
a.branchModal.SetVisible(false)
|
||||||
|
return a, createBranchCmd(msg.BranchName)
|
||||||
|
|
||||||
|
case components.BranchCreatedMsg:
|
||||||
|
a.statusMsg = fmt.Sprintf("Branch created: %s", msg.BranchName)
|
||||||
|
|
||||||
|
case components.BranchCreateErrorMsg:
|
||||||
|
a.err = msg.Err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update selected item in details panel
|
||||||
|
a.updateSelectedItem()
|
||||||
|
|
||||||
|
return a, tea.Batch(cmds...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the application
|
||||||
|
func (a App) View() string {
|
||||||
|
if a.width == 0 || a.height == 0 {
|
||||||
|
return "Loading..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render state modal if visible
|
||||||
|
if a.stateModal.IsVisible() {
|
||||||
|
return a.stateModal.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render branch modal if visible
|
||||||
|
if a.branchModal.IsVisible() {
|
||||||
|
return a.branchModal.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render help overlay if visible
|
||||||
|
if a.helpPanel.IsVisible() {
|
||||||
|
_ = a.renderMainView()
|
||||||
|
help := a.helpPanel.View()
|
||||||
|
return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, help)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render detail view if in detail mode
|
||||||
|
if a.viewMode == ViewDetail {
|
||||||
|
return a.detailView.View()
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.renderMainView()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) renderMainView() string {
|
||||||
|
// Calculate dimensions
|
||||||
|
// Borders add 2 chars per panel (1 left + 1 right)
|
||||||
|
// Two panels side by side = 4 total border overhead
|
||||||
|
filterWidth := int(float64(a.width) * 0.20)
|
||||||
|
if filterWidth < 20 {
|
||||||
|
filterWidth = 20
|
||||||
|
}
|
||||||
|
contentWidth := a.width - filterWidth - 4
|
||||||
|
|
||||||
|
// Available height: total - title bar (1) - status bar (1) = a.height - 2
|
||||||
|
// Filter panel: content height + border (2) = available height
|
||||||
|
availableHeight := a.height - 2
|
||||||
|
filterContentHeight := availableHeight - 2
|
||||||
|
|
||||||
|
// Right side has two panels stacked, each with border (2 each = 4 total)
|
||||||
|
rightContentHeight := availableHeight - 4
|
||||||
|
workItemsHeight := int(float64(rightContentHeight) * 0.55)
|
||||||
|
if workItemsHeight < 8 {
|
||||||
|
workItemsHeight = 8
|
||||||
|
}
|
||||||
|
detailsHeight := rightContentHeight - workItemsHeight
|
||||||
|
|
||||||
|
// Title bar
|
||||||
|
title := a.styles.Title.Render("devops-tui")
|
||||||
|
projectInfo := a.styles.Subtitle.Render(fmt.Sprintf("%s/%s", a.client.Organization(), a.client.Project()))
|
||||||
|
titleBar := lipgloss.JoinHorizontal(lipgloss.Left, title, " ", projectInfo)
|
||||||
|
|
||||||
|
// Loading indicator
|
||||||
|
if a.loading {
|
||||||
|
titleBar += " " + a.styles.Subtitle.Render("Loading...")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error display
|
||||||
|
if a.err != nil {
|
||||||
|
errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444"))
|
||||||
|
titleBar += " " + errStyle.Render(a.err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status message
|
||||||
|
if a.statusMsg != "" {
|
||||||
|
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981"))
|
||||||
|
titleBar += " " + statusStyle.Render(a.statusMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set panel sizes (content dimensions, borders added by styles)
|
||||||
|
a.filterPanel.SetSize(filterWidth, filterContentHeight)
|
||||||
|
a.workItemsPanel.SetSize(contentWidth, workItemsHeight)
|
||||||
|
a.detailsPanel.SetSize(contentWidth, detailsHeight)
|
||||||
|
|
||||||
|
// Render panels
|
||||||
|
filterView := a.filterPanel.View()
|
||||||
|
workItemsView := a.workItemsPanel.View()
|
||||||
|
detailsView := a.detailsPanel.View()
|
||||||
|
|
||||||
|
// Right side (work items + details)
|
||||||
|
rightSide := lipgloss.JoinVertical(lipgloss.Left, workItemsView, detailsView)
|
||||||
|
|
||||||
|
// Main content
|
||||||
|
mainContent := lipgloss.JoinHorizontal(lipgloss.Top, filterView, rightSide)
|
||||||
|
|
||||||
|
// Status bar
|
||||||
|
statusBar := a.renderStatusBar()
|
||||||
|
|
||||||
|
// Combine all
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left,
|
||||||
|
titleBar,
|
||||||
|
mainContent,
|
||||||
|
statusBar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) renderStatusBar() string {
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
// Panel indicator
|
||||||
|
panelName := "Filter"
|
||||||
|
if a.activePanel == PanelWorkItems {
|
||||||
|
panelName = "Work Items"
|
||||||
|
}
|
||||||
|
parts = append(parts, a.styles.HelpKey.Render("Panel")+": "+panelName)
|
||||||
|
|
||||||
|
// Short help
|
||||||
|
help := components.ShortHelp(a.keys, a.styles)
|
||||||
|
parts = append(parts, help)
|
||||||
|
|
||||||
|
return a.styles.StatusBar.Width(a.width).Render(strings.Join(parts, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) nextPanel() {
|
||||||
|
if a.activePanel == PanelFilter {
|
||||||
|
a.activePanel = PanelWorkItems
|
||||||
|
} else {
|
||||||
|
a.activePanel = PanelFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) prevPanel() {
|
||||||
|
if a.activePanel == PanelWorkItems {
|
||||||
|
a.activePanel = PanelFilter
|
||||||
|
} else {
|
||||||
|
a.activePanel = PanelWorkItems
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) updateFocus() {
|
||||||
|
a.filterPanel.SetFocused(a.activePanel == PanelFilter)
|
||||||
|
a.workItemsPanel.SetFocused(a.activePanel == PanelWorkItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) updateSizes() {
|
||||||
|
a.helpPanel.SetSize(a.width, a.height)
|
||||||
|
a.detailView.SetSize(a.width, a.height)
|
||||||
|
a.updateFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) updateSelectedItem() {
|
||||||
|
item := a.workItemsPanel.SelectedItem()
|
||||||
|
a.detailsPanel.SetItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message types
|
||||||
|
|
||||||
|
type dataLoadedMsg struct {
|
||||||
|
iterations []models.Iteration
|
||||||
|
areas []models.Area
|
||||||
|
statesByType map[string][]models.WorkItemStateInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type workItemsLoadedMsg struct {
|
||||||
|
items []models.WorkItem
|
||||||
|
}
|
||||||
|
|
||||||
|
type errMsg struct {
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type stateChangedMsg struct {
|
||||||
|
newState string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands
|
||||||
|
|
||||||
|
func loadDataCmd(client *api.Client) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
iterations, err := client.GetIterations()
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err: err}
|
||||||
|
}
|
||||||
|
areas, err := client.GetAreas()
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err: err}
|
||||||
|
}
|
||||||
|
statesByType, err := client.GetAllWorkItemTypeStates()
|
||||||
|
if err != nil {
|
||||||
|
// Non-fatal - we can still work with hardcoded states
|
||||||
|
statesByType = make(map[string][]models.WorkItemStateInfo)
|
||||||
|
}
|
||||||
|
return dataLoadedMsg{iterations: iterations, areas: areas, statesByType: statesByType}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadWorkItemsCmd(client *api.Client, filterState *models.FilterState) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
sprint := filterState.GetSelectedSprint()
|
||||||
|
state := filterState.GetSelectedState()
|
||||||
|
assigned := filterState.GetSelectedAssigned()
|
||||||
|
area := filterState.GetSelectedArea()
|
||||||
|
|
||||||
|
items, err := client.QueryWorkItems(sprint, state, assigned, area)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err: err}
|
||||||
|
}
|
||||||
|
return workItemsLoadedMsg{items: items}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateWorkItemStateCmd(client *api.Client, itemID int, newState string, filterState *models.FilterState) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
err := client.UpdateWorkItemState(itemID, newState)
|
||||||
|
if err != nil {
|
||||||
|
return errMsg{err: err}
|
||||||
|
}
|
||||||
|
return stateChangedMsg{newState: newState}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createBranchCmd(branchName string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if !git.IsGitRepo() {
|
||||||
|
return components.BranchCreateErrorMsg{Err: fmt.Errorf("not a git repository")}
|
||||||
|
}
|
||||||
|
if git.HasUncommittedChanges() {
|
||||||
|
return components.BranchCreateErrorMsg{Err: fmt.Errorf("uncommitted changes exist")}
|
||||||
|
}
|
||||||
|
err := git.CreateBranch(branchName, true)
|
||||||
|
if err != nil {
|
||||||
|
return components.BranchCreateErrorMsg{Err: err}
|
||||||
|
}
|
||||||
|
return components.BranchCreatedMsg{BranchName: branchName}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/models"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BranchModal is a modal for creating a branch linked to a work item
|
||||||
|
type BranchModal struct {
|
||||||
|
visible bool
|
||||||
|
item *models.WorkItem
|
||||||
|
textInput textinput.Model
|
||||||
|
styles theme.Styles
|
||||||
|
keys theme.KeyMap
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBranchModal creates a new branch modal
|
||||||
|
func NewBranchModal(styles theme.Styles, keys theme.KeyMap) BranchModal {
|
||||||
|
ti := textinput.New()
|
||||||
|
ti.Placeholder = "feature/123-task-name"
|
||||||
|
ti.CharLimit = 100
|
||||||
|
ti.Width = 35
|
||||||
|
|
||||||
|
return BranchModal{
|
||||||
|
textInput: ti,
|
||||||
|
styles: styles,
|
||||||
|
keys: keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the modal
|
||||||
|
func (m BranchModal) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages
|
||||||
|
func (m BranchModal) Update(msg tea.Msg) (BranchModal, tea.Cmd) {
|
||||||
|
if !m.visible {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd tea.Cmd
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Back):
|
||||||
|
m.visible = false
|
||||||
|
m.err = nil
|
||||||
|
return m, func() tea.Msg { return ModalClosedMsg{} }
|
||||||
|
case msg.Type == tea.KeyEnter:
|
||||||
|
branchName := strings.TrimSpace(m.textInput.Value())
|
||||||
|
if branchName == "" {
|
||||||
|
m.err = fmt.Errorf("branch name cannot be empty")
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if !isValidBranchName(branchName) {
|
||||||
|
m.err = fmt.Errorf("invalid branch name")
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.err = nil
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
return BranchCreateRequestMsg{
|
||||||
|
Item: *m.item,
|
||||||
|
BranchName: branchName,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
m.textInput, cmd = m.textInput.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.textInput, cmd = m.textInput.Update(msg)
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the modal
|
||||||
|
func (m BranchModal) View() string {
|
||||||
|
if !m.visible {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal dimensions
|
||||||
|
modalWidth := 50
|
||||||
|
modalHeight := 10
|
||||||
|
|
||||||
|
// Build content
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Title
|
||||||
|
title := lipgloss.NewStyle().Bold(true).Render("Create Branch")
|
||||||
|
b.WriteString(title + "\n")
|
||||||
|
|
||||||
|
// Item info
|
||||||
|
if m.item != nil {
|
||||||
|
itemInfo := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#9CA3AF")).
|
||||||
|
Render("#" + itoa(m.item.ID) + " " + truncateStr(m.item.Title, 35))
|
||||||
|
b.WriteString(itemInfo + "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label
|
||||||
|
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#D1D5DB"))
|
||||||
|
b.WriteString(labelStyle.Render("Branch name:") + "\n")
|
||||||
|
|
||||||
|
// Text input
|
||||||
|
b.WriteString(m.textInput.View() + "\n")
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if m.err != nil {
|
||||||
|
errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444"))
|
||||||
|
b.WriteString(errStyle.Render(m.err.Error()) + "\n")
|
||||||
|
} else {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Help text
|
||||||
|
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280"))
|
||||||
|
b.WriteString(helpStyle.Render("Enter: create Esc: cancel"))
|
||||||
|
|
||||||
|
// Modal style
|
||||||
|
modalStyle := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("#7C3AED")).
|
||||||
|
Padding(1, 2).
|
||||||
|
Width(modalWidth).
|
||||||
|
Height(modalHeight).
|
||||||
|
Background(lipgloss.Color("#1F2937"))
|
||||||
|
|
||||||
|
modal := modalStyle.Render(b.String())
|
||||||
|
|
||||||
|
// Center the modal
|
||||||
|
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVisible sets the visibility
|
||||||
|
func (m *BranchModal) SetVisible(visible bool) {
|
||||||
|
m.visible = visible
|
||||||
|
m.err = nil
|
||||||
|
if visible {
|
||||||
|
m.textInput.Focus()
|
||||||
|
// Generate suggested branch name from work item
|
||||||
|
if m.item != nil {
|
||||||
|
suggested := generateBranchName(m.item)
|
||||||
|
m.textInput.SetValue(suggested)
|
||||||
|
m.textInput.CursorEnd()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
m.textInput.Blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsVisible returns whether the modal is visible
|
||||||
|
func (m *BranchModal) IsVisible() bool {
|
||||||
|
return m.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItem sets the work item to link
|
||||||
|
func (m *BranchModal) SetItem(item *models.WorkItem) {
|
||||||
|
m.item = item
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSize sets the modal container size
|
||||||
|
func (m *BranchModal) SetSize(width, height int) {
|
||||||
|
m.width = width
|
||||||
|
m.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateBranchName generates a suggested branch name from work item
|
||||||
|
func generateBranchName(item *models.WorkItem) string {
|
||||||
|
// Get work item type prefix
|
||||||
|
prefix := "feature"
|
||||||
|
switch item.Type {
|
||||||
|
case models.WorkItemTypeBug:
|
||||||
|
prefix = "bugfix"
|
||||||
|
case models.WorkItemTypeTask:
|
||||||
|
prefix = "task"
|
||||||
|
case models.WorkItemTypeStory:
|
||||||
|
prefix = "feature"
|
||||||
|
case models.WorkItemTypeEpic:
|
||||||
|
prefix = "epic"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize title for branch name
|
||||||
|
title := strings.ToLower(item.Title)
|
||||||
|
// Replace spaces and special chars with hyphens
|
||||||
|
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
title = re.ReplaceAllString(title, "-")
|
||||||
|
// Remove leading/trailing hyphens
|
||||||
|
title = strings.Trim(title, "-")
|
||||||
|
// Truncate to reasonable length
|
||||||
|
if len(title) > 40 {
|
||||||
|
title = title[:40]
|
||||||
|
// Don't cut off in middle of a word if possible
|
||||||
|
if lastHyphen := strings.LastIndex(title, "-"); lastHyphen > 20 {
|
||||||
|
title = title[:lastHyphen]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s/%d-%s", prefix, item.ID, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidBranchName validates branch name
|
||||||
|
func isValidBranchName(name string) bool {
|
||||||
|
// Basic validation for git branch names
|
||||||
|
if name == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Check for invalid characters
|
||||||
|
invalid := regexp.MustCompile(`[\s~^:?*\[\]\\]`)
|
||||||
|
if invalid.MatchString(name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Can't start or end with /
|
||||||
|
if strings.HasPrefix(name, "/") || strings.HasSuffix(name, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Can't have consecutive dots
|
||||||
|
if strings.Contains(name, "..") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// BranchCreateRequestMsg is sent when user confirms branch creation
|
||||||
|
type BranchCreateRequestMsg struct {
|
||||||
|
Item models.WorkItem
|
||||||
|
BranchName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BranchCreatedMsg is sent when branch creation is complete
|
||||||
|
type BranchCreatedMsg struct {
|
||||||
|
BranchName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// BranchCreateErrorMsg is sent when branch creation fails
|
||||||
|
type BranchCreateErrorMsg struct {
|
||||||
|
Err error
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/glamour"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/models"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DetailsPanel shows details for a selected work item
|
||||||
|
type DetailsPanel struct {
|
||||||
|
item *models.WorkItem
|
||||||
|
styles theme.Styles
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
renderedDesc string
|
||||||
|
renderedDescWidth int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDetailsPanel creates a new details panel
|
||||||
|
func NewDetailsPanel(styles theme.Styles) DetailsPanel {
|
||||||
|
return DetailsPanel{
|
||||||
|
styles: styles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the details panel
|
||||||
|
func (d DetailsPanel) View() string {
|
||||||
|
if d.item == nil {
|
||||||
|
content := d.styles.Subtitle.Render("Select a work item to view details")
|
||||||
|
return d.styles.PanelInactive.
|
||||||
|
Width(d.width).
|
||||||
|
Height(d.height).
|
||||||
|
Render(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Title
|
||||||
|
title := fmt.Sprintf("#%d %s", d.item.ID, d.item.Title)
|
||||||
|
if len(title) > d.width-6 {
|
||||||
|
title = title[:d.width-9] + "..."
|
||||||
|
}
|
||||||
|
b.WriteString(d.styles.DetailTitle.Render(title))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Metadata section with aligned columns
|
||||||
|
typeStyle := d.styles.TypeBadge(string(d.item.Type))
|
||||||
|
stateStyle := d.styles.StateBadge(string(d.item.State))
|
||||||
|
|
||||||
|
labelWidth := 10
|
||||||
|
valueWidth := 12
|
||||||
|
|
||||||
|
// Type and State row
|
||||||
|
b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Type:"))
|
||||||
|
b.WriteString(typeStyle.Width(valueWidth).Render(d.item.ShortType()))
|
||||||
|
b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("State:"))
|
||||||
|
b.WriteString(stateStyle.Render(string(d.item.State)))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Assigned and Sprint row
|
||||||
|
assignedTo := d.item.AssignedTo
|
||||||
|
if assignedTo == "" {
|
||||||
|
assignedTo = "Unassigned"
|
||||||
|
}
|
||||||
|
b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Assigned:"))
|
||||||
|
b.WriteString(d.styles.DetailValue.Width(valueWidth).Render(truncate(assignedTo, valueWidth)))
|
||||||
|
b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Sprint:"))
|
||||||
|
b.WriteString(d.styles.DetailValue.Render(d.item.SprintName()))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Area row
|
||||||
|
b.WriteString(d.styles.DetailLabel.Width(labelWidth).Render("Area:"))
|
||||||
|
b.WriteString(d.styles.DetailValue.Render(d.item.AreaName()))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Parent (if exists)
|
||||||
|
if d.item.ParentID > 0 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
parentLabel := fmt.Sprintf("Parent: #%d", d.item.ParentID)
|
||||||
|
if d.item.ParentTitle != "" {
|
||||||
|
parentLabel += " " + d.item.ParentTitle
|
||||||
|
}
|
||||||
|
b.WriteString(d.styles.Subtitle.Render(parentLabel))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description section
|
||||||
|
if d.item.Description != "" {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(d.styles.DetailSectionTitle.Render("─── Description ───"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
maxWidth := d.width - 8
|
||||||
|
if maxWidth < 20 {
|
||||||
|
maxWidth = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use cached rendered description if available and width matches
|
||||||
|
desc := d.renderedDesc
|
||||||
|
if desc == "" || d.renderedDescWidth != maxWidth {
|
||||||
|
desc = d.renderMarkdown(d.item.Description, maxWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit description height
|
||||||
|
lines := strings.Split(desc, "\n")
|
||||||
|
maxLines := d.height - 12
|
||||||
|
if maxLines < 3 {
|
||||||
|
maxLines = 3
|
||||||
|
}
|
||||||
|
if len(lines) > maxLines {
|
||||||
|
lines = lines[:maxLines]
|
||||||
|
lines = append(lines, "...")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(strings.Join(lines, "\n"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags section
|
||||||
|
if len(d.item.Tags) > 0 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(d.styles.DetailSectionTitle.Render("─── Tags ───"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
var tagStrings []string
|
||||||
|
for _, tag := range d.item.Tags {
|
||||||
|
tagStrings = append(tagStrings, d.styles.DetailTag.Render(tag))
|
||||||
|
}
|
||||||
|
b.WriteString(strings.Join(tagStrings, " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
content := b.String()
|
||||||
|
return d.styles.PanelInactive.
|
||||||
|
Width(d.width).
|
||||||
|
Height(d.height).
|
||||||
|
BorderForeground(lipgloss.Color("#374151")).
|
||||||
|
Render(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItem sets the work item to display
|
||||||
|
func (d *DetailsPanel) SetItem(item *models.WorkItem) {
|
||||||
|
d.item = item
|
||||||
|
d.renderedDesc = ""
|
||||||
|
d.renderedDescWidth = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderMarkdown renders markdown content and caches the result
|
||||||
|
func (d *DetailsPanel) renderMarkdown(content string, width int) string {
|
||||||
|
renderer, err := glamour.NewTermRenderer(
|
||||||
|
glamour.WithStylePath("dark"),
|
||||||
|
glamour.WithWordWrap(width),
|
||||||
|
)
|
||||||
|
|
||||||
|
var result string
|
||||||
|
if err == nil {
|
||||||
|
rendered, renderErr := renderer.Render(content)
|
||||||
|
if renderErr == nil {
|
||||||
|
result = strings.TrimSpace(rendered)
|
||||||
|
} else {
|
||||||
|
result = wordWrap(content, width)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result = wordWrap(content, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.renderedDesc = result
|
||||||
|
d.renderedDescWidth = width
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSize sets the size of the details panel
|
||||||
|
func (d *DetailsPanel) SetSize(width, height int) {
|
||||||
|
d.width = width
|
||||||
|
d.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
|
||||||
|
func truncate(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen-3] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func wordWrap(text string, width int) string {
|
||||||
|
if width <= 0 {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
var result strings.Builder
|
||||||
|
words := strings.Fields(text)
|
||||||
|
currentLineLength := 0
|
||||||
|
|
||||||
|
for i, word := range words {
|
||||||
|
if currentLineLength+len(word)+1 > width {
|
||||||
|
result.WriteString("\n")
|
||||||
|
currentLineLength = 0
|
||||||
|
} else if i > 0 {
|
||||||
|
result.WriteString(" ")
|
||||||
|
currentLineLength++
|
||||||
|
}
|
||||||
|
result.WriteString(word)
|
||||||
|
currentLineLength += len(word)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/models"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DetailView is the fullscreen detail view component
|
||||||
|
type DetailView struct {
|
||||||
|
item *models.WorkItem
|
||||||
|
styles theme.Styles
|
||||||
|
keys theme.KeyMap
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
scrollOffset int
|
||||||
|
maxScroll int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDetailView creates a new detail view
|
||||||
|
func NewDetailView(styles theme.Styles, keys theme.KeyMap) DetailView {
|
||||||
|
return DetailView{
|
||||||
|
styles: styles,
|
||||||
|
keys: keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the detail view
|
||||||
|
func (d DetailView) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the detail view
|
||||||
|
func (d DetailView) Update(msg tea.Msg) (DetailView, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, d.keys.Back):
|
||||||
|
return d, func() tea.Msg { return CloseDetailViewMsg{} }
|
||||||
|
case key.Matches(msg, d.keys.Quit) && msg.String() == "q":
|
||||||
|
return d, func() tea.Msg { return CloseDetailViewMsg{} }
|
||||||
|
case key.Matches(msg, d.keys.Open):
|
||||||
|
if d.item != nil {
|
||||||
|
return d, func() tea.Msg { return OpenWorkItemMsg{Item: *d.item} }
|
||||||
|
}
|
||||||
|
case key.Matches(msg, d.keys.Up):
|
||||||
|
if d.scrollOffset > 0 {
|
||||||
|
d.scrollOffset--
|
||||||
|
}
|
||||||
|
case key.Matches(msg, d.keys.Down):
|
||||||
|
if d.scrollOffset < d.maxScroll {
|
||||||
|
d.scrollOffset++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the detail view
|
||||||
|
func (d DetailView) View() string {
|
||||||
|
if d.item == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var sections []string
|
||||||
|
|
||||||
|
// Title bar
|
||||||
|
title := fmt.Sprintf("#%d %s", d.item.ID, d.item.Title)
|
||||||
|
titleBar := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("#F9FAFB")).
|
||||||
|
Background(lipgloss.Color("#7C3AED")).
|
||||||
|
Padding(0, 1).
|
||||||
|
Width(d.width - 2).
|
||||||
|
Render(title)
|
||||||
|
sections = append(sections, titleBar)
|
||||||
|
|
||||||
|
// Metadata section
|
||||||
|
metadataContent := d.renderMetadata()
|
||||||
|
metadataSection := d.styles.DetailSection.
|
||||||
|
Width(d.width - 6).
|
||||||
|
Render("METADATA\n" + metadataContent)
|
||||||
|
sections = append(sections, metadataSection)
|
||||||
|
|
||||||
|
// Parent section (if exists)
|
||||||
|
if d.item.ParentID > 0 {
|
||||||
|
parentContent := fmt.Sprintf("#%d", d.item.ParentID)
|
||||||
|
if d.item.ParentTitle != "" {
|
||||||
|
parentContent += " " + d.item.ParentTitle
|
||||||
|
}
|
||||||
|
parentSection := d.styles.DetailSection.
|
||||||
|
Width(d.width - 6).
|
||||||
|
Render("PARENT\n" + parentContent)
|
||||||
|
sections = append(sections, parentSection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description section
|
||||||
|
if d.item.Description != "" {
|
||||||
|
desc := wordWrap(d.item.Description, d.width-10)
|
||||||
|
descSection := d.styles.DetailSection.
|
||||||
|
Width(d.width - 6).
|
||||||
|
Render("DESCRIPTION\n" + desc)
|
||||||
|
sections = append(sections, descSection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags section
|
||||||
|
if len(d.item.Tags) > 0 {
|
||||||
|
var tagStrings []string
|
||||||
|
for _, tag := range d.item.Tags {
|
||||||
|
tagStrings = append(tagStrings, d.styles.DetailTag.Render(tag))
|
||||||
|
}
|
||||||
|
tagsSection := d.styles.DetailSection.
|
||||||
|
Width(d.width - 6).
|
||||||
|
Render("TAGS\n" + strings.Join(tagStrings, " "))
|
||||||
|
sections = append(sections, tagsSection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join all sections
|
||||||
|
content := strings.Join(sections, "\n\n")
|
||||||
|
|
||||||
|
// Calculate scrolling
|
||||||
|
contentLines := strings.Split(content, "\n")
|
||||||
|
viewableHeight := d.height - 4
|
||||||
|
d.maxScroll = len(contentLines) - viewableHeight
|
||||||
|
if d.maxScroll < 0 {
|
||||||
|
d.maxScroll = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply scrolling
|
||||||
|
if d.scrollOffset > 0 && d.scrollOffset < len(contentLines) {
|
||||||
|
contentLines = contentLines[d.scrollOffset:]
|
||||||
|
}
|
||||||
|
if len(contentLines) > viewableHeight {
|
||||||
|
contentLines = contentLines[:viewableHeight]
|
||||||
|
}
|
||||||
|
|
||||||
|
scrolledContent := strings.Join(contentLines, "\n")
|
||||||
|
|
||||||
|
// Status bar
|
||||||
|
statusBar := d.renderStatusBar()
|
||||||
|
|
||||||
|
// Build final view
|
||||||
|
mainContent := d.styles.PanelActive.
|
||||||
|
Width(d.width).
|
||||||
|
Height(d.height - 2).
|
||||||
|
Render(scrolledContent)
|
||||||
|
|
||||||
|
return lipgloss.JoinVertical(
|
||||||
|
lipgloss.Left,
|
||||||
|
mainContent,
|
||||||
|
statusBar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailView) renderMetadata() string {
|
||||||
|
typeStyle := d.styles.TypeBadge(string(d.item.Type))
|
||||||
|
stateStyle := d.styles.StateBadge(string(d.item.State))
|
||||||
|
|
||||||
|
labelWidth := 12
|
||||||
|
valueWidth := 20
|
||||||
|
|
||||||
|
label := func(s string) string {
|
||||||
|
return d.styles.DetailLabel.Width(labelWidth).Render(s)
|
||||||
|
}
|
||||||
|
value := func(s string) string {
|
||||||
|
return d.styles.DetailValue.Width(valueWidth).Render(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := []string{
|
||||||
|
label("Type:") + typeStyle.Render(d.item.ShortType()) + " " + label("ID:") + value(fmt.Sprintf("#%d", d.item.ID)),
|
||||||
|
label("State:") + stateStyle.Render(string(d.item.State)) + " " + label("Created:") + value(d.item.CreatedDate.Format("2006-01-02")),
|
||||||
|
}
|
||||||
|
|
||||||
|
assignedTo := d.item.AssignedTo
|
||||||
|
if assignedTo == "" {
|
||||||
|
assignedTo = "Unassigned"
|
||||||
|
}
|
||||||
|
rows = append(rows, label("Assigned:")+value(assignedTo)+" "+label("Updated:")+value(d.item.ChangedDate.Format("2006-01-02")))
|
||||||
|
rows = append(rows, label("Sprint:")+value(d.item.SprintName())+" "+label("Priority:")+value(fmt.Sprintf("%d", d.item.Priority)))
|
||||||
|
rows = append(rows, label("Area:")+value(d.item.AreaName()))
|
||||||
|
|
||||||
|
return strings.Join(rows, "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DetailView) renderStatusBar() string {
|
||||||
|
help := "Esc Back Enter Open in browser j/k Scroll"
|
||||||
|
return d.styles.StatusBar.
|
||||||
|
Width(d.width).
|
||||||
|
Render(help)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItem sets the work item to display
|
||||||
|
func (d *DetailView) SetItem(item *models.WorkItem) {
|
||||||
|
d.item = item
|
||||||
|
d.scrollOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSize sets the size of the detail view
|
||||||
|
func (d *DetailView) SetSize(width, height int) {
|
||||||
|
d.width = width
|
||||||
|
d.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseDetailViewMsg is sent when the detail view should be closed
|
||||||
|
type CloseDetailViewMsg struct{}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/models"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxVisibleOptions = 6 // Max visible options per filter group
|
||||||
|
|
||||||
|
// FilterPanel is the filter panel component
|
||||||
|
type FilterPanel struct {
|
||||||
|
filterState *models.FilterState
|
||||||
|
styles theme.Styles
|
||||||
|
keys theme.KeyMap
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
focused bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFilterPanel creates a new filter panel
|
||||||
|
func NewFilterPanel(filterState *models.FilterState, styles theme.Styles, keys theme.KeyMap) FilterPanel {
|
||||||
|
return FilterPanel{
|
||||||
|
filterState: filterState,
|
||||||
|
styles: styles,
|
||||||
|
keys: keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the filter panel
|
||||||
|
func (f FilterPanel) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the filter panel
|
||||||
|
func (f FilterPanel) Update(msg tea.Msg) (FilterPanel, tea.Cmd) {
|
||||||
|
if !f.focused {
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, f.keys.Up):
|
||||||
|
if group := f.filterState.ActiveFilterGroup(); group != nil {
|
||||||
|
group.MoveUp()
|
||||||
|
f.adjustOffset(group)
|
||||||
|
}
|
||||||
|
case key.Matches(msg, f.keys.Down):
|
||||||
|
if group := f.filterState.ActiveFilterGroup(); group != nil {
|
||||||
|
group.MoveDown()
|
||||||
|
f.adjustOffset(group)
|
||||||
|
}
|
||||||
|
case key.Matches(msg, f.keys.Top):
|
||||||
|
if group := f.filterState.ActiveFilterGroup(); group != nil {
|
||||||
|
group.MoveToTop()
|
||||||
|
group.Offset = 0
|
||||||
|
}
|
||||||
|
case key.Matches(msg, f.keys.Bottom):
|
||||||
|
if group := f.filterState.ActiveFilterGroup(); group != nil {
|
||||||
|
group.MoveToBottom()
|
||||||
|
f.adjustOffset(group)
|
||||||
|
}
|
||||||
|
case key.Matches(msg, f.keys.Select):
|
||||||
|
if group := f.filterState.ActiveFilterGroup(); group != nil {
|
||||||
|
group.SelectCurrent()
|
||||||
|
}
|
||||||
|
return f, func() tea.Msg { return FilterChangedMsg{} }
|
||||||
|
case key.Matches(msg, f.keys.Left):
|
||||||
|
f.filterState.PrevGroup()
|
||||||
|
case key.Matches(msg, f.keys.Right):
|
||||||
|
f.filterState.NextGroup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// adjustOffset ensures the cursor is visible within the scroll window
|
||||||
|
func (f *FilterPanel) adjustOffset(group *models.FilterGroup) {
|
||||||
|
if group.Cursor < group.Offset {
|
||||||
|
group.Offset = group.Cursor
|
||||||
|
}
|
||||||
|
if group.Cursor >= group.Offset+maxVisibleOptions {
|
||||||
|
group.Offset = group.Cursor - maxVisibleOptions + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the filter panel
|
||||||
|
func (f FilterPanel) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
for i, group := range f.filterState.Groups {
|
||||||
|
isActiveGroup := i == f.filterState.ActiveGroup && f.focused
|
||||||
|
|
||||||
|
// Group title with count if scrollable
|
||||||
|
titleStyle := f.styles.FilterGroupTitle
|
||||||
|
if isActiveGroup {
|
||||||
|
titleStyle = titleStyle.Foreground(lipgloss.Color("#7C3AED"))
|
||||||
|
}
|
||||||
|
title := group.Title
|
||||||
|
if len(group.Options) > maxVisibleOptions {
|
||||||
|
countStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280"))
|
||||||
|
title += countStyle.Render(" (" + itoa(len(group.Options)) + ")")
|
||||||
|
}
|
||||||
|
b.WriteString(titleStyle.Render(title))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
sep := strings.Repeat("─", min(f.width-4, 15))
|
||||||
|
b.WriteString(f.styles.Subtitle.Render(sep))
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Scroll up indicator
|
||||||
|
if group.Offset > 0 {
|
||||||
|
scrollStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280"))
|
||||||
|
b.WriteString(scrollStyle.Render(" ▲ more"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate visible range
|
||||||
|
startIdx := group.Offset
|
||||||
|
endIdx := startIdx + maxVisibleOptions
|
||||||
|
if endIdx > len(group.Options) {
|
||||||
|
endIdx = len(group.Options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options (only visible ones)
|
||||||
|
for j := startIdx; j < endIdx; j++ {
|
||||||
|
opt := group.Options[j]
|
||||||
|
isCursor := j == group.Cursor && isActiveGroup
|
||||||
|
|
||||||
|
// Selection indicator
|
||||||
|
var indicator string
|
||||||
|
if opt.Selected {
|
||||||
|
indicator = "●"
|
||||||
|
} else {
|
||||||
|
indicator = "○"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor indicator
|
||||||
|
var cursor string
|
||||||
|
if isCursor {
|
||||||
|
cursor = "▸"
|
||||||
|
} else {
|
||||||
|
cursor = " "
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style the option
|
||||||
|
optionStyle := f.styles.FilterOption
|
||||||
|
if opt.Selected {
|
||||||
|
optionStyle = f.styles.FilterSelected
|
||||||
|
}
|
||||||
|
if isCursor {
|
||||||
|
optionStyle = optionStyle.Bold(true).Foreground(lipgloss.Color("#7C3AED"))
|
||||||
|
}
|
||||||
|
|
||||||
|
line := cursor + " " + indicator + " " + opt.Label
|
||||||
|
b.WriteString(optionStyle.Render(line))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll down indicator
|
||||||
|
if endIdx < len(group.Options) {
|
||||||
|
scrollStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280"))
|
||||||
|
b.WriteString(scrollStyle.Render(" ▼ more"))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add spacing between groups
|
||||||
|
if i < len(f.filterState.Groups)-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply panel styling
|
||||||
|
content := b.String()
|
||||||
|
if f.focused {
|
||||||
|
return f.styles.PanelActive.
|
||||||
|
Width(f.width).
|
||||||
|
Height(f.height).
|
||||||
|
Render(content)
|
||||||
|
}
|
||||||
|
return f.styles.PanelInactive.
|
||||||
|
Width(f.width).
|
||||||
|
Height(f.height).
|
||||||
|
Render(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSize sets the size of the filter panel
|
||||||
|
func (f *FilterPanel) SetSize(width, height int) {
|
||||||
|
f.width = width
|
||||||
|
f.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFocused sets whether the panel is focused
|
||||||
|
func (f *FilterPanel) SetFocused(focused bool) {
|
||||||
|
f.focused = focused
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterState returns the current filter state
|
||||||
|
func (f *FilterPanel) FilterState() *models.FilterState {
|
||||||
|
return f.filterState
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFilterState updates the filter state
|
||||||
|
func (f *FilterPanel) SetFilterState(state *models.FilterState) {
|
||||||
|
f.filterState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterChangedMsg is sent when a filter selection changes
|
||||||
|
type FilterChangedMsg struct{}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
if a < b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HelpPanel displays keyboard shortcuts
|
||||||
|
type HelpPanel struct {
|
||||||
|
keys theme.KeyMap
|
||||||
|
styles theme.Styles
|
||||||
|
visible bool
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHelpPanel creates a new help panel
|
||||||
|
func NewHelpPanel(keys theme.KeyMap, styles theme.Styles) HelpPanel {
|
||||||
|
return HelpPanel{
|
||||||
|
keys: keys,
|
||||||
|
styles: styles,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the help panel
|
||||||
|
func (h HelpPanel) View() string {
|
||||||
|
if !h.visible {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
title := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("#F9FAFB")).
|
||||||
|
MarginBottom(1).
|
||||||
|
Render("Keyboard Shortcuts")
|
||||||
|
|
||||||
|
b.WriteString(title)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
// Group shortcuts by category
|
||||||
|
sections := []struct {
|
||||||
|
title string
|
||||||
|
bindings []key.Binding
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
title: "Navigation",
|
||||||
|
bindings: []key.Binding{
|
||||||
|
h.keys.Up,
|
||||||
|
h.keys.Down,
|
||||||
|
h.keys.Top,
|
||||||
|
h.keys.Bottom,
|
||||||
|
h.keys.NextPanel,
|
||||||
|
h.keys.PrevPanel,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Actions",
|
||||||
|
bindings: []key.Binding{
|
||||||
|
h.keys.Select,
|
||||||
|
h.keys.Open,
|
||||||
|
h.keys.View,
|
||||||
|
h.keys.Search,
|
||||||
|
h.keys.Refresh,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "General",
|
||||||
|
bindings: []key.Binding{
|
||||||
|
h.keys.Help,
|
||||||
|
h.keys.Back,
|
||||||
|
h.keys.Quit,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, section := range sections {
|
||||||
|
// Section title
|
||||||
|
sectionTitle := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("#7C3AED")).
|
||||||
|
Render(section.title)
|
||||||
|
b.WriteString(sectionTitle)
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Key bindings
|
||||||
|
for _, binding := range section.bindings {
|
||||||
|
keyStyle := h.styles.HelpKey.Width(12)
|
||||||
|
descStyle := h.styles.HelpDesc
|
||||||
|
|
||||||
|
help := binding.Help()
|
||||||
|
line := keyStyle.Render(help.Key) + descStyle.Render(help.Desc)
|
||||||
|
b.WriteString(line)
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add spacing between sections
|
||||||
|
if i < len(sections)-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
b.WriteString("\n")
|
||||||
|
footer := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#6B7280")).
|
||||||
|
Italic(true).
|
||||||
|
Render("Press ? or Esc to close")
|
||||||
|
b.WriteString(footer)
|
||||||
|
|
||||||
|
content := b.String()
|
||||||
|
|
||||||
|
// Center the help panel
|
||||||
|
helpWidth := 40
|
||||||
|
helpHeight := 25
|
||||||
|
|
||||||
|
panel := h.styles.HelpPanel.
|
||||||
|
Width(helpWidth).
|
||||||
|
Height(helpHeight).
|
||||||
|
Background(lipgloss.Color("#1F2937")).
|
||||||
|
Render(content)
|
||||||
|
|
||||||
|
// Create overlay positioning
|
||||||
|
return lipgloss.Place(
|
||||||
|
h.width,
|
||||||
|
h.height,
|
||||||
|
lipgloss.Center,
|
||||||
|
lipgloss.Center,
|
||||||
|
panel,
|
||||||
|
lipgloss.WithWhitespaceChars(" "),
|
||||||
|
lipgloss.WithWhitespaceForeground(lipgloss.Color("#000000")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVisible sets whether the help panel is visible
|
||||||
|
func (h *HelpPanel) SetVisible(visible bool) {
|
||||||
|
h.visible = visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsVisible returns whether the help panel is visible
|
||||||
|
func (h *HelpPanel) IsVisible() bool {
|
||||||
|
return h.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle toggles the visibility of the help panel
|
||||||
|
func (h *HelpPanel) Toggle() {
|
||||||
|
h.visible = !h.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSize sets the size of the help panel
|
||||||
|
func (h *HelpPanel) SetSize(width, height int) {
|
||||||
|
h.width = width
|
||||||
|
h.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortHelp returns a short help string for the status bar
|
||||||
|
func ShortHelp(keys theme.KeyMap, styles theme.Styles) string {
|
||||||
|
bindings := keys.ShortHelp()
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
for _, b := range bindings {
|
||||||
|
help := b.Help()
|
||||||
|
key := styles.HelpKey.Render(help.Key)
|
||||||
|
desc := styles.HelpDesc.Render(help.Desc)
|
||||||
|
parts = append(parts, key+" "+desc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/models"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultStates = []string{"New", "Active", "Resolved", "Closed"}
|
||||||
|
|
||||||
|
// StateModal is a modal for changing work item state
|
||||||
|
type StateModal struct {
|
||||||
|
visible bool
|
||||||
|
item *models.WorkItem
|
||||||
|
states []string
|
||||||
|
statesByType map[string][]models.WorkItemStateInfo
|
||||||
|
cursor int
|
||||||
|
styles theme.Styles
|
||||||
|
keys theme.KeyMap
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStateModal creates a new state modal
|
||||||
|
func NewStateModal(styles theme.Styles, keys theme.KeyMap) StateModal {
|
||||||
|
return StateModal{
|
||||||
|
states: defaultStates,
|
||||||
|
styles: styles,
|
||||||
|
keys: keys,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetStatesByType sets the available states per work item type
|
||||||
|
func (m *StateModal) SetStatesByType(statesByType map[string][]models.WorkItemStateInfo) {
|
||||||
|
m.statesByType = statesByType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the modal
|
||||||
|
func (m StateModal) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages
|
||||||
|
func (m StateModal) Update(msg tea.Msg) (StateModal, tea.Cmd) {
|
||||||
|
if !m.visible {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, m.keys.Up):
|
||||||
|
if m.cursor > 0 {
|
||||||
|
m.cursor--
|
||||||
|
}
|
||||||
|
case key.Matches(msg, m.keys.Down):
|
||||||
|
if m.cursor < len(m.states)-1 {
|
||||||
|
m.cursor++
|
||||||
|
}
|
||||||
|
case key.Matches(msg, m.keys.Select):
|
||||||
|
if m.item != nil {
|
||||||
|
newState := m.states[m.cursor]
|
||||||
|
return m, func() tea.Msg {
|
||||||
|
return StateChangeRequestMsg{
|
||||||
|
Item: *m.item,
|
||||||
|
NewState: newState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case key.Matches(msg, m.keys.Back):
|
||||||
|
m.visible = false
|
||||||
|
return m, func() tea.Msg { return ModalClosedMsg{} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the modal
|
||||||
|
func (m StateModal) View() string {
|
||||||
|
if !m.visible {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal dimensions
|
||||||
|
modalWidth := 40
|
||||||
|
modalHeight := len(m.states) + 6
|
||||||
|
|
||||||
|
// Build content
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Title
|
||||||
|
title := "Change State"
|
||||||
|
if m.item != nil {
|
||||||
|
title = lipgloss.NewStyle().Bold(true).Render("Change State")
|
||||||
|
itemInfo := lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#9CA3AF")).
|
||||||
|
Render("#" + itoa(m.item.ID) + " " + truncateStr(m.item.Title, 25))
|
||||||
|
b.WriteString(title + "\n")
|
||||||
|
b.WriteString(itemInfo + "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current state indicator
|
||||||
|
if m.item != nil {
|
||||||
|
currentStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#60A5FA"))
|
||||||
|
b.WriteString(currentStyle.Render("Current: "+string(m.item.State)) + "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// State options
|
||||||
|
for i, state := range m.states {
|
||||||
|
cursor := " "
|
||||||
|
if i == m.cursor {
|
||||||
|
cursor = "▸ "
|
||||||
|
}
|
||||||
|
|
||||||
|
style := lipgloss.NewStyle()
|
||||||
|
if i == m.cursor {
|
||||||
|
style = style.Bold(true).Foreground(lipgloss.Color("#7C3AED"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight if this is the current state
|
||||||
|
if m.item != nil && state == string(m.item.State) {
|
||||||
|
style = style.Foreground(lipgloss.Color("#10B981"))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(cursor + style.Render(state) + "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Help text
|
||||||
|
b.WriteString("\n")
|
||||||
|
helpStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#6B7280"))
|
||||||
|
b.WriteString(helpStyle.Render("Enter: confirm Esc: cancel"))
|
||||||
|
|
||||||
|
// Modal style
|
||||||
|
modalStyle := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("#7C3AED")).
|
||||||
|
Padding(1, 2).
|
||||||
|
Width(modalWidth).
|
||||||
|
Height(modalHeight).
|
||||||
|
Background(lipgloss.Color("#1F2937"))
|
||||||
|
|
||||||
|
modal := modalStyle.Render(b.String())
|
||||||
|
|
||||||
|
// Center the modal
|
||||||
|
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, modal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVisible sets the visibility
|
||||||
|
func (m *StateModal) SetVisible(visible bool) {
|
||||||
|
m.visible = visible
|
||||||
|
if visible {
|
||||||
|
m.cursor = 0
|
||||||
|
// Try to set cursor to current state
|
||||||
|
if m.item != nil {
|
||||||
|
for i, state := range m.states {
|
||||||
|
if state == string(m.item.State) {
|
||||||
|
m.cursor = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsVisible returns whether the modal is visible
|
||||||
|
func (m *StateModal) IsVisible() bool {
|
||||||
|
return m.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItem sets the work item to modify
|
||||||
|
func (m *StateModal) SetItem(item *models.WorkItem) {
|
||||||
|
m.item = item
|
||||||
|
// Update states based on work item type
|
||||||
|
if item != nil && m.statesByType != nil {
|
||||||
|
if states, ok := m.statesByType[string(item.Type)]; ok && len(states) > 0 {
|
||||||
|
m.states = make([]string, len(states))
|
||||||
|
for i, s := range states {
|
||||||
|
m.states[i] = s.Name
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to defaults
|
||||||
|
m.states = defaultStates
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSize sets the modal container size
|
||||||
|
func (m *StateModal) SetSize(width, height int) {
|
||||||
|
m.width = width
|
||||||
|
m.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateChangeRequestMsg is sent when user confirms state change
|
||||||
|
type StateChangeRequestMsg struct {
|
||||||
|
Item models.WorkItem
|
||||||
|
NewState string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StateChangedMsg is sent when state change is complete
|
||||||
|
type StateChangedMsg struct {
|
||||||
|
Item models.WorkItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModalClosedMsg is sent when a modal is closed
|
||||||
|
type ModalClosedMsg struct{}
|
||||||
|
|
||||||
|
// Helper function
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var digits []byte
|
||||||
|
for n > 0 {
|
||||||
|
digits = append([]byte{byte('0' + n%10)}, digits...)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(digits)
|
||||||
|
}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/models"
|
||||||
|
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Column definitions
|
||||||
|
type column struct {
|
||||||
|
title string
|
||||||
|
width int
|
||||||
|
minWidth int
|
||||||
|
flex bool // If true, this column takes remaining space
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkItemsPanel is the work items list component
|
||||||
|
type WorkItemsPanel struct {
|
||||||
|
items []models.WorkItem
|
||||||
|
cursor int
|
||||||
|
styles theme.Styles
|
||||||
|
keys theme.KeyMap
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
focused bool
|
||||||
|
offset int // For scrolling
|
||||||
|
columns []column
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorkItemsPanel creates a new work items panel
|
||||||
|
func NewWorkItemsPanel(styles theme.Styles, keys theme.KeyMap) WorkItemsPanel {
|
||||||
|
return WorkItemsPanel{
|
||||||
|
items: []models.WorkItem{},
|
||||||
|
styles: styles,
|
||||||
|
keys: keys,
|
||||||
|
columns: []column{
|
||||||
|
{title: "ID", width: 7, minWidth: 6},
|
||||||
|
{title: "TYPE", width: 8, minWidth: 6},
|
||||||
|
{title: "STATE", width: 12, minWidth: 8},
|
||||||
|
{title: "ASSIGNED", width: 14, minWidth: 10},
|
||||||
|
{title: "TITLE", flex: true, minWidth: 20},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the work items panel
|
||||||
|
func (w WorkItemsPanel) Init() tea.Cmd {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update handles messages for the work items panel
|
||||||
|
func (w WorkItemsPanel) Update(msg tea.Msg) (WorkItemsPanel, tea.Cmd) {
|
||||||
|
if !w.focused {
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch {
|
||||||
|
case key.Matches(msg, w.keys.Up):
|
||||||
|
w.moveUp()
|
||||||
|
case key.Matches(msg, w.keys.Down):
|
||||||
|
w.moveDown()
|
||||||
|
case key.Matches(msg, w.keys.Top):
|
||||||
|
w.moveToTop()
|
||||||
|
case key.Matches(msg, w.keys.Bottom):
|
||||||
|
w.moveToBottom()
|
||||||
|
case key.Matches(msg, w.keys.Open):
|
||||||
|
if w.SelectedItem() != nil {
|
||||||
|
return w, func() tea.Msg { return OpenWorkItemMsg{Item: *w.SelectedItem()} }
|
||||||
|
}
|
||||||
|
case key.Matches(msg, w.keys.View):
|
||||||
|
if w.SelectedItem() != nil {
|
||||||
|
return w, func() tea.Msg { return ViewWorkItemMsg{Item: *w.SelectedItem()} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the work items panel
|
||||||
|
func (w WorkItemsPanel) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Calculate column widths
|
||||||
|
colWidths := w.calculateColumnWidths()
|
||||||
|
|
||||||
|
// Header
|
||||||
|
header := w.renderHeader(colWidths)
|
||||||
|
b.WriteString(header)
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Separator line
|
||||||
|
separator := w.renderSeparator(colWidths)
|
||||||
|
b.WriteString(separator)
|
||||||
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
// Items
|
||||||
|
if len(w.items) == 0 {
|
||||||
|
emptyMsg := w.styles.Subtitle.Render(" No work items found")
|
||||||
|
b.WriteString(emptyMsg)
|
||||||
|
} else {
|
||||||
|
visibleItems := w.visibleItemCount()
|
||||||
|
|
||||||
|
// Render visible items
|
||||||
|
for i := w.offset; i < len(w.items) && i < w.offset+visibleItems; i++ {
|
||||||
|
item := w.items[i]
|
||||||
|
isCursor := i == w.cursor
|
||||||
|
line := w.renderItem(item, isCursor, colWidths)
|
||||||
|
b.WriteString(line)
|
||||||
|
if i < len(w.items)-1 && i < w.offset+visibleItems-1 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply panel styling
|
||||||
|
content := b.String()
|
||||||
|
if w.focused {
|
||||||
|
return w.styles.PanelActive.
|
||||||
|
Width(w.width).
|
||||||
|
Height(w.height).
|
||||||
|
Render(content)
|
||||||
|
}
|
||||||
|
return w.styles.PanelInactive.
|
||||||
|
Width(w.width).
|
||||||
|
Height(w.height).
|
||||||
|
Render(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WorkItemsPanel) calculateColumnWidths() []int {
|
||||||
|
availableWidth := w.width - 6 // Account for borders and padding
|
||||||
|
|
||||||
|
// Calculate fixed columns total width
|
||||||
|
fixedWidth := 0
|
||||||
|
flexCount := 0
|
||||||
|
for _, col := range w.columns {
|
||||||
|
if col.flex {
|
||||||
|
flexCount++
|
||||||
|
} else {
|
||||||
|
fixedWidth += col.width + 1 // +1 for separator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate flex column width
|
||||||
|
flexWidth := availableWidth - fixedWidth
|
||||||
|
if flexWidth < 20 {
|
||||||
|
flexWidth = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build widths array
|
||||||
|
widths := make([]int, len(w.columns))
|
||||||
|
for i, col := range w.columns {
|
||||||
|
if col.flex {
|
||||||
|
widths[i] = flexWidth / flexCount
|
||||||
|
} else {
|
||||||
|
widths[i] = col.width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return widths
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WorkItemsPanel) renderHeader(colWidths []int) string {
|
||||||
|
headerStyle := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("#9CA3AF"))
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
for i, col := range w.columns {
|
||||||
|
width := colWidths[i]
|
||||||
|
title := col.title
|
||||||
|
if len(title) > width {
|
||||||
|
title = title[:width]
|
||||||
|
}
|
||||||
|
parts = append(parts, headerStyle.Width(width).Render(title))
|
||||||
|
}
|
||||||
|
|
||||||
|
return " " + strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WorkItemsPanel) renderSeparator(colWidths []int) string {
|
||||||
|
sepStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#374151"))
|
||||||
|
|
||||||
|
// Use content width (accounting for panel padding)
|
||||||
|
contentWidth := w.width - 4
|
||||||
|
if contentWidth < 10 {
|
||||||
|
contentWidth = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
return sepStyle.Render(strings.Repeat("─", contentWidth))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WorkItemsPanel) renderItem(item models.WorkItem, isCursor bool, colWidths []int) string {
|
||||||
|
// Cursor indicator
|
||||||
|
cursor := " "
|
||||||
|
if isCursor {
|
||||||
|
cursor = "▸ "
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format values
|
||||||
|
id := fmt.Sprintf("#%d", item.ID)
|
||||||
|
typeStr := item.ShortType()
|
||||||
|
stateStr := string(item.State)
|
||||||
|
assigned := truncateStr(item.AssignedTo, colWidths[3])
|
||||||
|
if assigned == "" {
|
||||||
|
assigned = "-"
|
||||||
|
}
|
||||||
|
title := truncateStr(item.Title, colWidths[4])
|
||||||
|
|
||||||
|
// For cursor row, use plain text with unified background
|
||||||
|
if isCursor {
|
||||||
|
rowStyle := lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("#F9FAFB")).
|
||||||
|
Background(lipgloss.Color("#7C3AED")). // Purple highlight
|
||||||
|
Width(w.width - 4)
|
||||||
|
|
||||||
|
// Build plain text cells (no individual colors)
|
||||||
|
cells := []string{
|
||||||
|
padRight(truncateStr(id, colWidths[0]), colWidths[0]),
|
||||||
|
padRight(truncateStr(typeStr, colWidths[1]), colWidths[1]),
|
||||||
|
padRight(truncateStr(stateStr, colWidths[2]), colWidths[2]),
|
||||||
|
padRight(assigned, colWidths[3]),
|
||||||
|
padRight(title, colWidths[4]),
|
||||||
|
}
|
||||||
|
row := cursor + strings.Join(cells, " ")
|
||||||
|
return rowStyle.Render(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-cursor rows with individual cell colors
|
||||||
|
idStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#60A5FA"))
|
||||||
|
typeStyle := w.styles.TypeBadge(string(item.Type))
|
||||||
|
stateStyle := w.styles.StateBadge(string(item.State))
|
||||||
|
assignedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#9CA3AF"))
|
||||||
|
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#F9FAFB"))
|
||||||
|
|
||||||
|
// Build cells with proper width and colors
|
||||||
|
cells := []string{
|
||||||
|
idStyle.Width(colWidths[0]).Render(truncateStr(id, colWidths[0])),
|
||||||
|
typeStyle.Width(colWidths[1]).Render(truncateStr(typeStr, colWidths[1])),
|
||||||
|
stateStyle.Width(colWidths[2]).Render(truncateStr(stateStr, colWidths[2])),
|
||||||
|
assignedStyle.Width(colWidths[3]).Render(assigned),
|
||||||
|
titleStyle.Width(colWidths[4]).Render(title),
|
||||||
|
}
|
||||||
|
|
||||||
|
row := cursor + strings.Join(cells, " ")
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
func padRight(s string, width int) string {
|
||||||
|
if len(s) >= width {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s + strings.Repeat(" ", width-len(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateStr(s string, maxLen int) string {
|
||||||
|
if maxLen <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if maxLen <= 3 {
|
||||||
|
return s[:maxLen]
|
||||||
|
}
|
||||||
|
return s[:maxLen-3] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WorkItemsPanel) visibleItemCount() int {
|
||||||
|
visible := w.height - 5 // header, separator, borders
|
||||||
|
if visible < 1 {
|
||||||
|
visible = 1
|
||||||
|
}
|
||||||
|
return visible
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WorkItemsPanel) moveUp() {
|
||||||
|
if w.cursor > 0 {
|
||||||
|
w.cursor--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WorkItemsPanel) moveDown() {
|
||||||
|
if w.cursor < len(w.items)-1 {
|
||||||
|
w.cursor++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WorkItemsPanel) moveToTop() {
|
||||||
|
w.cursor = 0
|
||||||
|
w.offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *WorkItemsPanel) moveToBottom() {
|
||||||
|
if len(w.items) > 0 {
|
||||||
|
w.cursor = len(w.items) - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSize sets the size of the work items panel
|
||||||
|
func (w *WorkItemsPanel) SetSize(width, height int) {
|
||||||
|
w.width = width
|
||||||
|
w.height = height
|
||||||
|
|
||||||
|
// Adjust offset to keep cursor visible (now that we have correct height)
|
||||||
|
visible := w.visibleItemCount()
|
||||||
|
if w.cursor < w.offset {
|
||||||
|
w.offset = w.cursor
|
||||||
|
}
|
||||||
|
if w.cursor >= w.offset+visible {
|
||||||
|
w.offset = w.cursor - visible + 1
|
||||||
|
}
|
||||||
|
if w.offset < 0 {
|
||||||
|
w.offset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFocused sets whether the panel is focused
|
||||||
|
func (w *WorkItemsPanel) SetFocused(focused bool) {
|
||||||
|
w.focused = focused
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetItems sets the work items
|
||||||
|
func (w *WorkItemsPanel) SetItems(items []models.WorkItem) {
|
||||||
|
oldLen := len(w.items)
|
||||||
|
w.items = items
|
||||||
|
|
||||||
|
// Only reset position if this is new data (not just a refresh)
|
||||||
|
if oldLen == 0 && len(items) > 0 {
|
||||||
|
w.cursor = 0
|
||||||
|
w.offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp cursor to valid range
|
||||||
|
if w.cursor >= len(items) {
|
||||||
|
w.cursor = len(items) - 1
|
||||||
|
}
|
||||||
|
if w.cursor < 0 {
|
||||||
|
w.cursor = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelectedItem returns the currently selected work item
|
||||||
|
func (w *WorkItemsPanel) SelectedItem() *models.WorkItem {
|
||||||
|
if w.cursor >= 0 && w.cursor < len(w.items) {
|
||||||
|
return &w.items[w.cursor]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenWorkItemMsg is sent when a work item should be opened in browser
|
||||||
|
type OpenWorkItemMsg struct {
|
||||||
|
Item models.WorkItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewWorkItemMsg is sent when a work item should be viewed in detail
|
||||||
|
type ViewWorkItemMsg struct {
|
||||||
|
Item models.WorkItem
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/bubbles/key"
|
||||||
|
|
||||||
|
// KeyMap defines all keyboard shortcuts
|
||||||
|
type KeyMap struct {
|
||||||
|
// Navigation
|
||||||
|
Up key.Binding
|
||||||
|
Down key.Binding
|
||||||
|
Left key.Binding
|
||||||
|
Right key.Binding
|
||||||
|
Top key.Binding
|
||||||
|
Bottom key.Binding
|
||||||
|
NextPanel key.Binding
|
||||||
|
PrevPanel key.Binding
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
Select key.Binding
|
||||||
|
Open key.Binding
|
||||||
|
View key.Binding
|
||||||
|
Search key.Binding
|
||||||
|
Refresh key.Binding
|
||||||
|
Help key.Binding
|
||||||
|
Back key.Binding
|
||||||
|
Quit key.Binding
|
||||||
|
ChangeState key.Binding
|
||||||
|
CreateBranch key.Binding
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultKeyMap returns the default key bindings
|
||||||
|
func DefaultKeyMap() KeyMap {
|
||||||
|
return KeyMap{
|
||||||
|
Up: key.NewBinding(
|
||||||
|
key.WithKeys("up", "k"),
|
||||||
|
key.WithHelp("↑/k", "up"),
|
||||||
|
),
|
||||||
|
Down: key.NewBinding(
|
||||||
|
key.WithKeys("down", "j"),
|
||||||
|
key.WithHelp("↓/j", "down"),
|
||||||
|
),
|
||||||
|
Left: key.NewBinding(
|
||||||
|
key.WithKeys("left", "h"),
|
||||||
|
key.WithHelp("←/h", "left"),
|
||||||
|
),
|
||||||
|
Right: key.NewBinding(
|
||||||
|
key.WithKeys("right", "l"),
|
||||||
|
key.WithHelp("→/l", "right"),
|
||||||
|
),
|
||||||
|
Top: key.NewBinding(
|
||||||
|
key.WithKeys("g"),
|
||||||
|
key.WithHelp("g", "top"),
|
||||||
|
),
|
||||||
|
Bottom: key.NewBinding(
|
||||||
|
key.WithKeys("G"),
|
||||||
|
key.WithHelp("G", "bottom"),
|
||||||
|
),
|
||||||
|
NextPanel: key.NewBinding(
|
||||||
|
key.WithKeys("tab"),
|
||||||
|
key.WithHelp("Tab", "next panel"),
|
||||||
|
),
|
||||||
|
PrevPanel: key.NewBinding(
|
||||||
|
key.WithKeys("shift+tab"),
|
||||||
|
key.WithHelp("Shift+Tab", "prev panel"),
|
||||||
|
),
|
||||||
|
Select: key.NewBinding(
|
||||||
|
key.WithKeys("enter", " "),
|
||||||
|
key.WithHelp("Enter/Space", "select"),
|
||||||
|
),
|
||||||
|
Open: key.NewBinding(
|
||||||
|
key.WithKeys("enter"),
|
||||||
|
key.WithHelp("Enter", "open in browser"),
|
||||||
|
),
|
||||||
|
View: key.NewBinding(
|
||||||
|
key.WithKeys("v"),
|
||||||
|
key.WithHelp("v", "view details"),
|
||||||
|
),
|
||||||
|
Search: key.NewBinding(
|
||||||
|
key.WithKeys("/"),
|
||||||
|
key.WithHelp("/", "search"),
|
||||||
|
),
|
||||||
|
Refresh: key.NewBinding(
|
||||||
|
key.WithKeys("ctrl+r"),
|
||||||
|
key.WithHelp("Ctrl+r", "refresh"),
|
||||||
|
),
|
||||||
|
Help: key.NewBinding(
|
||||||
|
key.WithKeys("?"),
|
||||||
|
key.WithHelp("?", "help"),
|
||||||
|
),
|
||||||
|
Back: key.NewBinding(
|
||||||
|
key.WithKeys("esc"),
|
||||||
|
key.WithHelp("Esc", "back"),
|
||||||
|
),
|
||||||
|
Quit: key.NewBinding(
|
||||||
|
key.WithKeys("q", "ctrl+c"),
|
||||||
|
key.WithHelp("q", "quit"),
|
||||||
|
),
|
||||||
|
ChangeState: key.NewBinding(
|
||||||
|
key.WithKeys("s"),
|
||||||
|
key.WithHelp("s", "change state"),
|
||||||
|
),
|
||||||
|
CreateBranch: key.NewBinding(
|
||||||
|
key.WithKeys("b"),
|
||||||
|
key.WithHelp("b", "create branch"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortHelp returns a short help text for the status bar
|
||||||
|
func (k KeyMap) ShortHelp() []key.Binding {
|
||||||
|
return []key.Binding{
|
||||||
|
k.NextPanel,
|
||||||
|
k.Up, k.Down,
|
||||||
|
k.Top, k.Bottom,
|
||||||
|
k.Open,
|
||||||
|
k.Help,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FullHelp returns all key bindings for the help panel
|
||||||
|
func (k KeyMap) FullHelp() [][]key.Binding {
|
||||||
|
return [][]key.Binding{
|
||||||
|
{k.Up, k.Down, k.Top, k.Bottom},
|
||||||
|
{k.NextPanel, k.PrevPanel},
|
||||||
|
{k.Select, k.Open, k.View},
|
||||||
|
{k.ChangeState, k.CreateBranch},
|
||||||
|
{k.Search, k.Refresh},
|
||||||
|
{k.Help, k.Back, k.Quit},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
import "github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
// Colors
|
||||||
|
var (
|
||||||
|
colorPrimary = lipgloss.Color("#7C3AED") // Purple
|
||||||
|
colorSecondary = lipgloss.Color("#06B6D4") // Cyan
|
||||||
|
colorSuccess = lipgloss.Color("#10B981") // Green
|
||||||
|
colorWarning = lipgloss.Color("#F59E0B") // Yellow
|
||||||
|
colorError = lipgloss.Color("#EF4444") // Red
|
||||||
|
colorMuted = lipgloss.Color("#6B7280") // Gray
|
||||||
|
colorBorder = lipgloss.Color("#374151") // Dark gray
|
||||||
|
colorHighlight = lipgloss.Color("#1F2937") // Very dark gray
|
||||||
|
colorText = lipgloss.Color("#F9FAFB") // White
|
||||||
|
colorTextMuted = lipgloss.Color("#9CA3AF") // Light gray
|
||||||
|
)
|
||||||
|
|
||||||
|
// Work item type colors
|
||||||
|
var typeColors = map[string]lipgloss.Color{
|
||||||
|
"User Story": lipgloss.Color("#3B82F6"), // Blue
|
||||||
|
"Story": lipgloss.Color("#3B82F6"),
|
||||||
|
"Task": lipgloss.Color("#F59E0B"), // Yellow
|
||||||
|
"Bug": lipgloss.Color("#EF4444"), // Red
|
||||||
|
"Feature": lipgloss.Color("#8B5CF6"), // Purple
|
||||||
|
"Epic": lipgloss.Color("#EC4899"), // Pink
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work item state colors
|
||||||
|
var stateColors = map[string]lipgloss.Color{
|
||||||
|
// Common states
|
||||||
|
"New": lipgloss.Color("#6B7280"), // Gray
|
||||||
|
"Active": lipgloss.Color("#3B82F6"), // Blue
|
||||||
|
"Resolved": lipgloss.Color("#10B981"), // Green
|
||||||
|
"Closed": lipgloss.Color("#6B7280"), // Gray
|
||||||
|
// Agile states
|
||||||
|
"To Do": lipgloss.Color("#F97316"), // Orange
|
||||||
|
"In Progress": lipgloss.Color("#8B5CF6"), // Purple
|
||||||
|
"Done": lipgloss.Color("#10B981"), // Green
|
||||||
|
"Testing": lipgloss.Color("#FBBF24"), // Yellow
|
||||||
|
// Additional states
|
||||||
|
"Removed": lipgloss.Color("#6B7280"), // Gray
|
||||||
|
"Approved": lipgloss.Color("#10B981"), // Green
|
||||||
|
}
|
||||||
|
|
||||||
|
// Styles defines all UI styles
|
||||||
|
type Styles struct {
|
||||||
|
// Base styles
|
||||||
|
App lipgloss.Style
|
||||||
|
Title lipgloss.Style
|
||||||
|
Subtitle lipgloss.Style
|
||||||
|
StatusBar lipgloss.Style
|
||||||
|
|
||||||
|
// Panel styles
|
||||||
|
PanelActive lipgloss.Style
|
||||||
|
PanelInactive lipgloss.Style
|
||||||
|
PanelTitle lipgloss.Style
|
||||||
|
|
||||||
|
// Filter styles
|
||||||
|
FilterGroup lipgloss.Style
|
||||||
|
FilterGroupTitle lipgloss.Style
|
||||||
|
FilterOption lipgloss.Style
|
||||||
|
FilterSelected lipgloss.Style
|
||||||
|
FilterCursor lipgloss.Style
|
||||||
|
|
||||||
|
// Work items list styles
|
||||||
|
ListHeader lipgloss.Style
|
||||||
|
ListItem lipgloss.Style
|
||||||
|
ListItemSelected lipgloss.Style
|
||||||
|
ListItemCursor lipgloss.Style
|
||||||
|
|
||||||
|
// Detail view styles
|
||||||
|
DetailTitle lipgloss.Style
|
||||||
|
DetailSection lipgloss.Style
|
||||||
|
DetailSectionTitle lipgloss.Style
|
||||||
|
DetailLabel lipgloss.Style
|
||||||
|
DetailValue lipgloss.Style
|
||||||
|
DetailDescription lipgloss.Style
|
||||||
|
DetailTag lipgloss.Style
|
||||||
|
|
||||||
|
// Help styles
|
||||||
|
HelpKey lipgloss.Style
|
||||||
|
HelpDesc lipgloss.Style
|
||||||
|
HelpPanel lipgloss.Style
|
||||||
|
|
||||||
|
// Type and state badges
|
||||||
|
TypeBadge func(string) lipgloss.Style
|
||||||
|
StateBadge func(string) lipgloss.Style
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultStyles returns the default styles
|
||||||
|
func DefaultStyles() Styles {
|
||||||
|
return Styles{
|
||||||
|
// Base styles
|
||||||
|
App: lipgloss.NewStyle().
|
||||||
|
Background(lipgloss.Color("#111827")),
|
||||||
|
|
||||||
|
Title: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(colorText).
|
||||||
|
Padding(0, 1),
|
||||||
|
|
||||||
|
Subtitle: lipgloss.NewStyle().
|
||||||
|
Foreground(colorTextMuted),
|
||||||
|
|
||||||
|
StatusBar: lipgloss.NewStyle().
|
||||||
|
Foreground(colorTextMuted).
|
||||||
|
Background(lipgloss.Color("#1F2937")).
|
||||||
|
Padding(0, 1),
|
||||||
|
|
||||||
|
// Panel styles
|
||||||
|
PanelActive: lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(colorPrimary).
|
||||||
|
Padding(0, 1),
|
||||||
|
|
||||||
|
PanelInactive: lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(colorBorder).
|
||||||
|
Padding(0, 1),
|
||||||
|
|
||||||
|
PanelTitle: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(colorText).
|
||||||
|
Background(colorHighlight).
|
||||||
|
Padding(0, 1),
|
||||||
|
|
||||||
|
// Filter styles
|
||||||
|
FilterGroup: lipgloss.NewStyle().
|
||||||
|
MarginBottom(1),
|
||||||
|
|
||||||
|
FilterGroupTitle: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(colorText).
|
||||||
|
MarginBottom(0),
|
||||||
|
|
||||||
|
FilterOption: lipgloss.NewStyle().
|
||||||
|
Foreground(colorTextMuted).
|
||||||
|
PaddingLeft(1),
|
||||||
|
|
||||||
|
FilterSelected: lipgloss.NewStyle().
|
||||||
|
Foreground(colorSuccess).
|
||||||
|
PaddingLeft(1),
|
||||||
|
|
||||||
|
FilterCursor: lipgloss.NewStyle().
|
||||||
|
Foreground(colorPrimary).
|
||||||
|
Bold(true),
|
||||||
|
|
||||||
|
// Work items list styles
|
||||||
|
ListHeader: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(colorTextMuted).
|
||||||
|
BorderBottom(true).
|
||||||
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(colorBorder),
|
||||||
|
|
||||||
|
ListItem: lipgloss.NewStyle().
|
||||||
|
Foreground(colorText),
|
||||||
|
|
||||||
|
ListItemSelected: lipgloss.NewStyle().
|
||||||
|
Foreground(colorText).
|
||||||
|
Background(colorHighlight),
|
||||||
|
|
||||||
|
ListItemCursor: lipgloss.NewStyle().
|
||||||
|
Foreground(colorPrimary).
|
||||||
|
Bold(true),
|
||||||
|
|
||||||
|
// Detail view styles
|
||||||
|
DetailTitle: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(colorText).
|
||||||
|
MarginBottom(1),
|
||||||
|
|
||||||
|
DetailSection: lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(colorBorder).
|
||||||
|
Padding(0, 1).
|
||||||
|
MarginBottom(1),
|
||||||
|
|
||||||
|
DetailSectionTitle: lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(colorTextMuted).
|
||||||
|
MarginBottom(0),
|
||||||
|
|
||||||
|
DetailLabel: lipgloss.NewStyle().
|
||||||
|
Foreground(colorTextMuted).
|
||||||
|
Width(12),
|
||||||
|
|
||||||
|
DetailValue: lipgloss.NewStyle().
|
||||||
|
Foreground(colorText),
|
||||||
|
|
||||||
|
DetailDescription: lipgloss.NewStyle().
|
||||||
|
Foreground(colorText),
|
||||||
|
|
||||||
|
DetailTag: lipgloss.NewStyle().
|
||||||
|
Foreground(colorSecondary).
|
||||||
|
Background(lipgloss.Color("#1E3A5F")).
|
||||||
|
Padding(0, 1).
|
||||||
|
MarginRight(1),
|
||||||
|
|
||||||
|
// Help styles
|
||||||
|
HelpKey: lipgloss.NewStyle().
|
||||||
|
Foreground(colorPrimary).
|
||||||
|
Bold(true),
|
||||||
|
|
||||||
|
HelpDesc: lipgloss.NewStyle().
|
||||||
|
Foreground(colorTextMuted),
|
||||||
|
|
||||||
|
HelpPanel: lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(colorBorder).
|
||||||
|
Padding(1, 2),
|
||||||
|
|
||||||
|
// Type and state badge generators
|
||||||
|
TypeBadge: func(t string) lipgloss.Style {
|
||||||
|
color := typeColors[t]
|
||||||
|
if color == "" {
|
||||||
|
color = colorMuted
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Foreground(color).
|
||||||
|
Bold(true)
|
||||||
|
},
|
||||||
|
|
||||||
|
StateBadge: func(s string) lipgloss.Style {
|
||||||
|
color := stateColors[s]
|
||||||
|
if color == "" {
|
||||||
|
color = colorMuted
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
Foreground(color)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/samuelenocsson/devops-tui/cmd"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package browser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Open opens the specified URL in the default browser
|
||||||
|
func Open(url string) error {
|
||||||
|
var cmd string
|
||||||
|
var args []string
|
||||||
|
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "windows":
|
||||||
|
cmd = "rundll32"
|
||||||
|
args = []string{"url.dll,FileProtocolHandler", url}
|
||||||
|
case "darwin":
|
||||||
|
cmd = "open"
|
||||||
|
args = []string{url}
|
||||||
|
default: // linux and others
|
||||||
|
cmd = "xdg-open"
|
||||||
|
args = []string{url}
|
||||||
|
}
|
||||||
|
|
||||||
|
return exec.Command(cmd, args...).Start()
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsGitRepo checks if the current directory is a git repository
|
||||||
|
func IsGitRepo() bool {
|
||||||
|
cmd := exec.Command("git", "rev-parse", "--git-dir")
|
||||||
|
err := cmd.Run()
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentBranch returns the current branch name
|
||||||
|
func GetCurrentBranch() (string, error) {
|
||||||
|
cmd := exec.Command("git", "branch", "--show-current")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("getting current branch: %w", err)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(output)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BranchExists checks if a branch exists
|
||||||
|
func BranchExists(name string) bool {
|
||||||
|
cmd := exec.Command("git", "show-ref", "--verify", "--quiet", "refs/heads/"+name)
|
||||||
|
err := cmd.Run()
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBranch creates a new branch and optionally checks it out
|
||||||
|
func CreateBranch(name string, checkout bool) error {
|
||||||
|
if BranchExists(name) {
|
||||||
|
return fmt.Errorf("branch '%s' already exists", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkout {
|
||||||
|
// Create and checkout
|
||||||
|
cmd := exec.Command("git", "checkout", "-b", name)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating branch: %s", strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Just create
|
||||||
|
cmd := exec.Command("git", "branch", name)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating branch: %s", strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckoutBranch checks out an existing branch
|
||||||
|
func CheckoutBranch(name string) error {
|
||||||
|
cmd := exec.Command("git", "checkout", name)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking out branch: %s", strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasUncommittedChanges checks if there are uncommitted changes
|
||||||
|
func HasUncommittedChanges() bool {
|
||||||
|
cmd := exec.Command("git", "status", "--porcelain")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(strings.TrimSpace(string(output))) > 0
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user