Files
paperclip-harness/tui/model.go

355 lines
8.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"fmt"
"strings"
"sync"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
zone "github.com/lrstanley/bubblezone"
"paperclip-harness/tui/paperclip"
)
// groupOrder is the order of the four status groups on screen 1.
var groupOrder = []string{"Todo", "In progress", "Blocked", "In review", "Done"}
// Row is one task as the screen needs it: the task itself, who has it, and
// what Paperclip says blocks it.
type Row struct {
Issue paperclip.Issue
Group string
Who string
Blocker *paperclip.BlockerInfo
}
type rowsMsg []Row
type errMsg struct{ err error }
func groupOf(status string) string {
switch status {
case "todo", "backlog":
return "Todo"
case "in_progress":
return "In progress"
case "blocked":
return "Blocked"
case "in_review":
return "In review"
case "done", "cancelled":
return "Done"
}
return "Todo"
}
// loadRows reads the project's tasks from Paperclip, then asks about each
// task's blockers, because the list endpoint never carries them.
func loadRows(c *paperclip.Client, proj paperclip.Project) ([]Row, error) {
issues, err := c.Issues()
if err != nil {
return nil, err
}
mine := make([]paperclip.Issue, 0, len(issues))
for _, is := range issues {
if is.ProjectID == proj.ID {
mine = append(mine, is)
}
}
names := map[string]string{}
if agents, err := c.Agents(); err == nil {
for _, a := range agents {
names[a.ID] = a.Name
}
}
rows := make([]Row, len(mine))
for i, is := range mine {
who := "—"
switch {
case names[is.AssigneeAgent] != "":
who = names[is.AssigneeAgent]
case is.AssigneeAgent != "":
who = "agent"
case is.AssigneeUser != "":
who = "person"
}
rows[i] = Row{Issue: is, Group: groupOf(is.Status), Who: who}
}
// One request per task. Bounded so a large project does not open a
// hundred sockets at once.
var wg sync.WaitGroup
sem := make(chan struct{}, 6)
for i := range rows {
wg.Add(1)
go func(i int) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
info, err := c.Blockers(rows[i].Issue.ID)
if err == nil {
rows[i].Blocker = &info
}
}(i)
}
wg.Wait()
order := map[string]int{}
for i, g := range groupOrder {
order[g] = i
}
// Stable sort by group, then by identifier so the tree does not jump
// around between refreshes.
for i := 1; i < len(rows); i++ {
for j := i; j > 0; j-- {
a, b := rows[j-1], rows[j]
if order[a.Group] > order[b.Group] ||
(order[a.Group] == order[b.Group] && a.Issue.Identifier > b.Issue.Identifier) {
rows[j-1], rows[j] = rows[j], rows[j-1]
continue
}
break
}
}
return rows, nil
}
type model struct {
client *paperclip.Client
project paperclip.Project
agent string
zones *zone.Manager
rows []Row
cursor int
err error
loading bool
width int
height int
}
func newModel(c *paperclip.Client, proj paperclip.Project, agent string) model {
return model{
client: c,
project: proj,
agent: agent,
// The click-zone manager is created once, here, and the whole view is
// wrapped and scanned through it. Wrapping parts separately drifts
// when a pane is resized.
zones: zone.New(),
loading: true,
}
}
func (m model) Init() tea.Cmd {
return m.reload()
}
func (m model) reload() tea.Cmd {
c, proj := m.client, m.project
return func() tea.Msg {
rows, err := loadRows(c, proj)
if err != nil {
return errMsg{err}
}
return rowsMsg(rows)
}
}
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
// Sizes are always taken from the message. Nothing is hardcoded.
m.width, m.height = msg.Width, msg.Height
return m, nil
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "r":
m.loading = true
return m, m.reload()
case "a":
// Assign the selected task to one agent. Paperclip wakes that
// agent as soon as the assignment lands, so this is also the
// "start work" key.
if m.cursor < len(m.rows) && m.rows[m.cursor].Issue.AssigneeAgent == "" {
c, id, agent := m.client, m.rows[m.cursor].Issue.ID, m.agent
return m, func() tea.Msg {
if _, err := c.Assign(id, agent); err != nil {
return errMsg{err}
}
rows, err := loadRows(c, m.project)
if err != nil {
return errMsg{err}
}
return rowsMsg(rows)
}
}
return m, nil
case "up", "shift+tab":
if m.cursor > 0 {
m.cursor--
}
return m, nil
case "down", "tab":
if m.cursor < len(m.rows)-1 {
m.cursor++
}
return m, nil
case "home":
m.cursor = 0
return m, nil
case "end":
if len(m.rows) > 0 {
m.cursor = len(m.rows) - 1
}
return m, nil
case "esc":
// A bare Esc stays free for closing a box. Nothing to close yet.
return m, nil
}
case rowsMsg:
m.rows = msg
m.loading = false
m.err = nil
if m.cursor >= len(m.rows) {
m.cursor = max(0, len(m.rows)-1)
}
return m, nil
case errMsg:
m.err = msg.err
m.loading = false
return m, nil
}
return m, nil
}
var (
titleStyle = lipgloss.NewStyle().Bold(true)
groupStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("12"))
mutedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
blockedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9"))
)
// minWidth and minHeight are the smallest box that still shows something
// honest. Below this the program says so instead of drawing a broken tree.
const (
minWidth = 56
minHeight = 12
)
func (m model) View() string {
if m.width == 0 || m.height == 0 {
return "loading…"
}
if m.width < minWidth || m.height < minHeight {
return m.zones.Scan(lipgloss.NewStyle().
Width(m.width).Height(m.height).
Render(fmt.Sprintf("Window is too small.\nNeed %d×%d, have %d×%d.", minWidth, minHeight, m.width, m.height)))
}
var b strings.Builder
b.WriteString(titleStyle.Render(truncate(m.project.Name+" · task tree", m.width)))
b.WriteString("\n")
if m.err != nil {
b.WriteString(blockedStyle.Render(truncate("error: "+m.err.Error(), m.width)))
b.WriteString("\n")
}
if m.loading && len(m.rows) == 0 {
b.WriteString(mutedStyle.Render("reading tasks from Paperclip…"))
b.WriteString("\n")
}
lines := 0
budget := m.height - 3
for _, g := range groupOrder {
var group []Row
for _, r := range m.rows {
if r.Group == g {
group = append(group, r)
}
}
if len(group) == 0 {
continue
}
if lines >= budget {
break
}
b.WriteString(groupStyle.Render(fmt.Sprintf("%s (%d)", strings.ToUpper(g), len(group))))
b.WriteString("\n")
lines++
for _, r := range group {
if lines >= budget {
break
}
b.WriteString(m.renderRow(r))
b.WriteString("\n")
lines++
}
}
if len(m.rows) == 0 && !m.loading && m.err == nil {
b.WriteString(mutedStyle.Render("no tasks in this project"))
b.WriteString("\n")
}
b.WriteString(mutedStyle.Render(truncate(
"↑/↓ or Tab/Shift-Tab move · a assign to Builder · r refresh · q quit", m.width)))
// One scan for the whole view.
return m.zones.Scan(b.String())
}
func (m model) renderRow(r Row) string {
i := m.indexOf(r.Issue.ID)
marker := " "
if i == m.cursor {
marker = "▸ "
}
who := r.Who
if who == "" {
who = "—"
}
line := fmt.Sprintf("%s%-8s %-11s %-10s %s", marker, r.Issue.Identifier, r.Issue.Status, who, r.Issue.Title)
if r.Blocker != nil && r.Blocker.UnresolvedCount > 0 {
line += " [" + strings.Join(r.Blocker.BlockingIdentities, ", ") + "]"
}
line = truncate(line, m.width)
if i == m.cursor {
return m.zones.Mark("row-"+r.Issue.ID, selectedStyle.Render(line))
}
return m.zones.Mark("row-"+r.Issue.ID, line)
}
func (m model) indexOf(id string) int {
for i, r := range m.rows {
if r.Issue.ID == id {
return i
}
}
return -1
}
func truncate(s string, w int) string {
if w <= 0 {
return ""
}
r := []rune(s)
if len(r) <= w {
return s
}
if w == 1 {
return "…"
}
return string(r[:w-1]) + "…"
}