Add sorting, user assignment, and UI improvements

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Samuel Enocsson
2025-12-04 22:31:28 +01:00
parent 2555afce19
commit 7c488b2d83
7 changed files with 682 additions and 12 deletions
+125 -10
View File
@@ -2,6 +2,7 @@ package components
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/bubbles/key"
@@ -11,6 +12,23 @@ import (
"github.com/samuelenocsson/devops-tui/internal/ui/theme"
)
// SortField represents the field to sort by
type SortField int
const (
SortByID SortField = iota
SortByState
SortByType
)
// SortDirection represents the sort direction
type SortDirection int
const (
SortAsc SortDirection = iota
SortDesc
)
// Column definitions
type column struct {
title string
@@ -21,15 +39,17 @@ type column struct {
// 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
items []models.WorkItem
cursor int
styles theme.Styles
keys theme.KeyMap
width int
height int
focused bool
offset int // For scrolling
columns []column
sortField SortField
sortDir SortDirection
}
// NewWorkItemsPanel creates a new work items panel
@@ -78,6 +98,12 @@ func (w WorkItemsPanel) Update(msg tea.Msg) (WorkItemsPanel, tea.Cmd) {
if w.SelectedItem() != nil {
return w, func() tea.Msg { return ViewWorkItemMsg{Item: *w.SelectedItem()} }
}
case key.Matches(msg, w.keys.SortByID):
w.toggleSort(SortByID)
case key.Matches(msg, w.keys.SortByState):
w.toggleSort(SortByState)
case key.Matches(msg, w.keys.SortByType):
w.toggleSort(SortByType)
}
}
@@ -172,14 +198,37 @@ func (w *WorkItemsPanel) renderHeader(colWidths []int) string {
Bold(true).
Foreground(lipgloss.Color("#9CA3AF"))
sortedStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7C3AED"))
var parts []string
for i, col := range w.columns {
width := colWidths[i]
title := col.title
// Add sort indicator
isSorted := (col.title == "ID" && w.sortField == SortByID) ||
(col.title == "STATE" && w.sortField == SortByState) ||
(col.title == "TYPE" && w.sortField == SortByType)
if isSorted {
arrow := "▲"
if w.sortDir == SortDesc {
arrow = "▼"
}
title = title + arrow
}
if len(title) > width {
title = title[:width]
}
parts = append(parts, headerStyle.Width(width).Render(title))
if isSorted {
parts = append(parts, sortedStyle.Width(width).Render(title))
} else {
parts = append(parts, headerStyle.Width(width).Render(title))
}
}
return " " + strings.Join(parts, " ")
@@ -305,6 +354,46 @@ func (w *WorkItemsPanel) moveToBottom() {
}
}
func (w *WorkItemsPanel) toggleSort(field SortField) {
if w.sortField == field {
// Toggle direction if same field
if w.sortDir == SortAsc {
w.sortDir = SortDesc
} else {
w.sortDir = SortAsc
}
} else {
w.sortField = field
w.sortDir = SortAsc
}
w.sortItems()
}
func (w *WorkItemsPanel) sortItems() {
if len(w.items) == 0 {
return
}
sort.SliceStable(w.items, func(i, j int) bool {
var less bool
switch w.sortField {
case SortByID:
less = w.items[i].ID < w.items[j].ID
case SortByState:
less = string(w.items[i].State) < string(w.items[j].State)
case SortByType:
less = string(w.items[i].Type) < string(w.items[j].Type)
default:
less = w.items[i].ID < w.items[j].ID
}
if w.sortDir == SortDesc {
return !less
}
return less
})
}
// SetSize sets the size of the work items panel
func (w *WorkItemsPanel) SetSize(width, height int) {
w.width = width
@@ -330,13 +419,30 @@ func (w *WorkItemsPanel) SetFocused(focused bool) {
// SetItems sets the work items
func (w *WorkItemsPanel) SetItems(items []models.WorkItem) {
// Remember currently selected item ID
var selectedID int
if w.cursor >= 0 && w.cursor < len(w.items) {
selectedID = w.items[w.cursor].ID
}
oldLen := len(w.items)
w.items = items
// Re-apply current sort
w.sortItems()
// Only reset position if this is new data (not just a refresh)
if oldLen == 0 && len(items) > 0 {
w.cursor = 0
w.offset = 0
} else if selectedID > 0 {
// Try to restore cursor to previously selected item
for i, item := range w.items {
if item.ID == selectedID {
w.cursor = i
break
}
}
}
// Clamp cursor to valid range
@@ -346,6 +452,15 @@ func (w *WorkItemsPanel) SetItems(items []models.WorkItem) {
if w.cursor < 0 {
w.cursor = 0
}
// Adjust offset to keep cursor visible
visible := w.visibleItemCount()
if w.cursor < w.offset {
w.offset = w.cursor
}
if w.cursor >= w.offset+visible {
w.offset = w.cursor - visible + 1
}
}
// SelectedItem returns the currently selected work item