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]) + "…" }