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

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

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

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

483 lines
11 KiB
Go

package components
import (
"fmt"
"sort"
"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"
)
// 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
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
sortField SortField
sortDir SortDirection
}
// 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()} }
}
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)
}
}
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"))
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]
}
if isSorted {
parts = append(parts, sortedStyle.Width(width).Render(title))
} else {
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
}
}
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
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) {
// 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
if w.cursor >= len(items) {
w.cursor = len(items) - 1
}
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
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
}