Files
LazyPM/pkg/tui/components/issue_list.go
Robin Olsen d900a5fcd3 Merge pull request #75 from LazyBachelor/LPM-140
LPM-140 The Sprinting Update
Adds sprint support across the web UI (board + issue detail), TUI kanban, CLI/REPL, and the Beads-backed storage layer.

Changes:

Introduces sprint entities in storage and exposes sprint operations via IssueService.
Updates web board to support backlog vs. sprint columns, sprint selection, and sprint creation routes.
Updates TUI kanban to show backlog + sprint columns and adds sprint selection/creation actions.
2026-03-23 19:26:04 +01:00

447 lines
12 KiB
Go

package components
import (
"context"
"fmt"
"io"
"sort"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/LazyBachelor/LazyPM/internal/app"
"github.com/LazyBachelor/LazyPM/internal/models"
"github.com/LazyBachelor/LazyPM/internal/style"
"github.com/muesli/reflow/truncate"
)
type IssueList struct {
list list.Model
app *app.App
width int
height int
highlightSelected bool
}
type ListIssue struct {
models.Issue
}
func (l ListIssue) Title() string { return l.Issue.Title }
func (l ListIssue) Description() string { return l.Issue.Description }
func (l ListIssue) FilterValue() string { return l.Issue.ID + " " + l.Issue.Title }
type tableColumn struct {
width uint
label string
key string
}
func getTableColumns(width int) []tableColumn {
switch {
case width < 20:
return []tableColumn{
{width: 10, label: "ID", key: "id"},
}
case width < 30:
return []tableColumn{
{width: 10, label: "ID", key: "id"},
{width: 10, label: "TITLE", key: "title"},
}
case width < 45:
return []tableColumn{
{width: 10, label: "ID", key: "id"},
{width: 20, label: "TITLE", key: "title"},
}
case width < 60:
return []tableColumn{
{width: 10, label: "ID", key: "id"},
{width: 22, label: "TITLE", key: "title"},
{width: 15, label: "STATUS", key: "status"},
}
case width < 75:
return []tableColumn{
{width: 10, label: "ID", key: "id"},
{width: 25, label: "TITLE", key: "title"},
{width: 15, label: "STATUS", key: "status"},
{width: 10, label: "TYPE", key: "type"},
}
case width < 95:
return []tableColumn{
{width: 12, label: "ID", key: "id"},
{width: 25, label: "TITLE", key: "title"},
{width: 15, label: "STATUS", key: "status"},
{width: 10, label: "TYPE", key: "type"},
{width: 15, label: "PRIORITY", key: "priority"},
}
case width < 120:
return []tableColumn{
{width: 12, label: "ID", key: "id"},
{width: 30, label: "TITLE", key: "title"},
{width: 15, label: "STATUS", key: "status"},
{width: 11, label: "TYPE", key: "type"},
{width: 14, label: "PRIORITY", key: "priority"},
{width: 12, label: "ASSIGNEE", key: "assignee"},
}
default:
return []tableColumn{
{width: 12, label: "ID", key: "id"},
{width: 40, label: "TITLE", key: "title"},
{width: 15, label: "STATUS", key: "status"},
{width: 11, label: "TYPE", key: "type"},
{width: 14, label: "PRIORITY", key: "priority"},
{width: 12, label: "ASSIGNEE", key: "assignee"},
}
}
}
// PriorityCodeName returns the human-readable name for a priority code.
// Exported for use by IssueDetail.
func PriorityCodeName(priority int) string {
if name, ok := priorityCodeNames[priority]; ok {
return name
}
return fmt.Sprintf("%d", priority)
}
var priorityCodeNames = map[int]string{
0: "irrelevant",
1: "low",
2: "normal",
3: "high",
4: "critical",
}
func renderHeaders(cols []tableColumn) string {
var parts []string
headerStyle := lipgloss.NewStyle().Foreground(style.FaintText).Bold(true)
for _, col := range cols {
colWidth := col.width
if colWidth == 0 {
colWidth = 1
}
style := lipgloss.NewStyle().Width(int(colWidth))
headerText := headerStyle.Render(truncate.StringWithTail(col.label, colWidth, "..."))
parts = append(parts, style.Render(headerText))
}
return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
}
// IssueInputs bundles the common text inputs used for issue title, creation, description, and assignee.
type IssueInputs struct {
Title textinput.Model
CreateTitle textinput.Model
Description textarea.Model
Assignee textinput.Model
}
// NewIssueInputs creates initialized inputs for issue title, new issue title, and description.
func NewIssueInputs() IssueInputs {
ti := textinput.New()
ti.Placeholder = "Issue title ..."
ti.CharLimit = 256
createTi := textinput.New()
createTi.Placeholder = "New issue title ..."
createTi.CharLimit = 256
descTa := textarea.New()
descTa.Placeholder = "Issue description..."
descTa.SetWidth(56)
descTa.SetHeight(8)
assigneeTi := textinput.New()
assigneeTi.Placeholder = "Assignee name..."
assigneeTi.CharLimit = 64
return IssueInputs{
Title: ti,
CreateTitle: createTi,
Description: descTa,
Assignee: assigneeTi,
}
}
// ValidationFeedbackMsg is a shared message carrying validation results from the app.
type ValidationFeedbackMsg struct {
Feedback models.ValidationFeedback
}
// ListenForValidation returns a command that waits for a validation feedback message
// on the given channel and wraps it in a ValidationFeedbackMsg.
func ListenForValidation(ch chan models.ValidationFeedback) tea.Cmd {
return func() tea.Msg {
feedback := <-ch
return ValidationFeedbackMsg{Feedback: feedback}
}
}
func NewIssueList(app *app.App, width, height int) IssueList {
// NewIssueList creates an IssueList populated from the app.
issues, err := app.Issues.SearchIssues(context.Background(), "", models.IssueFilter{})
if err != nil {
return IssueList{}
}
listIssues := []ListIssue{}
for _, issue := range issues {
listIssues = append(listIssues, ListIssue{Issue: *issue})
}
items := make([]list.Item, len(listIssues))
for i, issue := range listIssues {
items[i] = issue
}
l := list.New(items, newIssueListDelegate(width), width, height)
l.SetShowTitle(false)
l.SetShowHelp(false)
l.SetShowStatusBar(false)
l.SetFilteringEnabled(true)
return IssueList{
list: l,
app: app,
width: width,
height: height,
highlightSelected: true,
}
}
// SetHighlightSelected controls whether the selected row is visually highlighted.
// Use false for lists that are not currently focused (e.g. non-focused Kanban columns).
func (l *IssueList) SetHighlightSelected(show bool) {
l.highlightSelected = show
}
func NewIssueListFromIssues(app *app.App, issues []*models.Issue, width, height int) IssueList {
// NewIssueListFromIssues creates an IssueList from a slice of issues.
listIssues := make([]list.Item, len(issues))
for i, issue := range issues {
listIssues[i] = ListIssue{Issue: *issue}
}
l := list.New(listIssues, newIssueListDelegate(width), width, height)
l.SetShowTitle(false)
l.SetShowHelp(false)
l.SetShowStatusBar(false)
l.SetFilteringEnabled(true)
return IssueList{
list: l,
app: app,
width: width,
height: height,
highlightSelected: true,
}
}
func SortedIssues(issues []*models.Issue) []*models.Issue {
sortByClosedThenPriorityDesc(issues)
return issues
}
// StatusOnly returns issues that exactly match the given status, sorted by priority.
func StatusOnly(issues []*models.Issue, status models.Status) []*models.Issue {
out := make([]*models.Issue, 0, len(issues))
for _, issue := range issues {
if issue == nil {
continue
}
if issue.Status == status {
out = append(out, issue)
}
}
sortByClosedThenPriorityDesc(out)
return out
}
func sortByClosedThenPriorityDesc(issues []*models.Issue) {
sort.SliceStable(issues, func(i, j int) bool {
iClosed := issues[i].Status == models.StatusClosed
jClosed := issues[j].Status == models.StatusClosed
if iClosed != jClosed {
return !iClosed
}
if issues[i].Priority != issues[j].Priority {
return issues[i].Priority > issues[j].Priority
}
return issues[i].ID < issues[j].ID
})
}
func (l *IssueList) Update(msg tea.Msg) (tea.Cmd, bool) {
var cmd tea.Cmd
oldIndex := l.list.Index()
l.list, cmd = l.list.Update(msg)
changed := l.list.Index() != oldIndex
return cmd, changed
}
func (l *IssueList) SetSize(width, height int) {
l.width = width
l.height = height
l.list.SetSize(width, height)
l.list.SetDelegate(newIssueListDelegate(width))
}
func (l IssueList) View() string {
// renders the list
return l.renderResponsive()
}
func (l IssueList) renderResponsive() string {
cols := getTableColumns(l.width)
header := renderHeaders(cols)
var content []string
if l.list.FilterState() == list.Filtering {
filterText := l.list.FilterInput.Value()
filterView := style.FilterStyle.Render("🔍 " + filterText)
content = append(content, filterView)
}
itemsView := l.renderFilteredItems()
content = append(content, header, itemsView)
return style.ContainerStyle.
Width(l.width).
MaxWidth(l.width).
MaxHeight(l.height).
Render(lipgloss.JoinVertical(lipgloss.Left, content...))
}
func (l IssueList) renderFilteredItems() string {
var items []string
var visibleItems []list.Item
if l.list.FilterState() == list.Filtering || l.list.FilterState() == list.FilterApplied {
visibleItems = l.list.VisibleItems()
} else {
visibleItems = l.list.Items()
}
start, end := l.list.Paginator.GetSliceBounds(len(visibleItems))
cursor := l.list.Index()
for i := start; i < end && i < len(visibleItems); i++ {
isSelected := l.highlightSelected && i == cursor
if issue, ok := visibleItems[i].(ListIssue); ok {
cols := getTableColumns(l.width)
row := renderRow(issue, isSelected, cols)
items = append(items, row)
}
}
return lipgloss.JoinVertical(lipgloss.Left, items...)
}
// selectedItem returns the currently selected issue.
func (l IssueList) SelectedItem() ListIssue {
if item, ok := l.list.SelectedItem().(ListIssue); ok {
return item
}
return ListIssue{}
}
func (l IssueList) Index() int {
return l.list.Index()
}
func (l IssueList) FilterState() list.FilterState {
return l.list.FilterState()
}
func (l *IssueList) SetIssues(issues []*models.Issue) tea.Cmd {
listIssues := make([]list.Item, len(issues))
for i, issue := range issues {
listIssues[i] = ListIssue{Issue: *issue}
}
return l.list.SetItems(listIssues)
}
func (l *IssueList) SelectIssueID(issueID string) {
items := l.list.Items()
for i := range items {
if item, ok := items[i].(ListIssue); ok && item.ID == issueID {
l.list.Select(i)
return
}
}
}
type issueListDelegate struct {
width int
}
func newIssueListDelegate(width int) issueListDelegate {
return issueListDelegate{width: width}
}
func (d issueListDelegate) Height() int { return 1 }
func (d issueListDelegate) Spacing() int { return 0 }
func (d issueListDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
func (d issueListDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
issue, ok := listItem.(ListIssue)
if !ok {
return
}
isSelected := index == m.Index()
cols := getTableColumns(d.width)
row := renderRow(issue, isSelected, cols)
io.WriteString(w, row)
}
func renderRow(issue ListIssue, isSelected bool, cols []tableColumn) string {
var parts []string
for _, col := range cols {
value := getColumnValue(col, issue)
colWidth := col.width
if colWidth == 0 {
colWidth = 1
}
cellStyle := lipgloss.NewStyle().Width(int(colWidth))
if isSelected {
cellStyle = cellStyle.Background(style.SelectedBackground).Bold(true)
}
if issue.Status == models.StatusClosed {
cellStyle = cellStyle.Strikethrough(true).Foreground(style.FaintText)
}
truncated := truncate.StringWithTail(value, colWidth, "...")
parts = append(parts, cellStyle.Render(truncated))
}
return lipgloss.JoinHorizontal(lipgloss.Left, parts...)
}
func getColumnValue(col tableColumn, issue ListIssue) string {
switch col.key {
case "id":
return issue.ID
case "title":
return issue.Title()
case "status":
return string(issue.Issue.Status)
case "type":
return string(issue.Issue.IssueType)
case "priority":
return PriorityCodeName(issue.Issue.Priority)
case "assignee":
return issue.Assignee
default:
return ""
}
}