Go module with the task tree screen; worktrees on and measured; Go installed
This commit is contained in:
334
tui/model.go
Normal file
334
tui/model.go
Normal file
@@ -0,0 +1,334 @@
|
||||
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
|
||||
zones *zone.Manager
|
||||
|
||||
rows []Row
|
||||
cursor int
|
||||
err error
|
||||
loading bool
|
||||
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func newModel(c *paperclip.Client, proj paperclip.Project) model {
|
||||
return model{
|
||||
client: c,
|
||||
project: proj,
|
||||
// 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 "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 · 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]) + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user