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.
447 lines
12 KiB
Go
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 ""
|
|
}
|
|
}
|