Help on every panel, task filter, unblock key, mouse zones, safe add-only rebuild

This commit is contained in:
Sam
2026-11-10 15:59:42 +11:00
parent 972d4c0184
commit 09e32da046
13 changed files with 587 additions and 16 deletions

View File

@@ -17,6 +17,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
zone "github.com/lrstanley/bubblezone"
"paperclip-harness/tui/paperclip"
)
@@ -48,6 +49,11 @@ type boardModel struct {
activity []paperclip.Activity
needsTab int
zones *zone.Manager
filter string
typing bool
showHelp bool
width, height int
}
@@ -59,7 +65,9 @@ const (
)
func newBoardModel(c *paperclip.Client, proj paperclip.Project, folder string) boardModel {
return boardModel{client: c, project: proj, folder: folder, loading: true}
// One click-zone manager for the whole program, created once. Wrapping each
// panel separately drifts as soon as a pane is resized.
return boardModel{client: c, project: proj, folder: folder, loading: true, zones: zone.New()}
}
func (m boardModel) Init() tea.Cmd {
@@ -94,16 +102,36 @@ func (m boardModel) loadDetail(id string) tea.Cmd {
}
}
// visible is the list after the filter. The cursor moves over this, so what is
// highlighted is always what would be acted on.
func (m boardModel) visible() []Row {
if m.filter == "" {
return m.rows
}
needle := strings.ToLower(m.filter)
var out []Row
for _, r := range m.rows {
if strings.Contains(strings.ToLower(r.Issue.Identifier), needle) ||
strings.Contains(strings.ToLower(r.Issue.Title), needle) ||
strings.Contains(strings.ToLower(r.Who), needle) {
out = append(out, r)
}
}
return out
}
func (m boardModel) selectedID() string {
if m.cursor >= 0 && m.cursor < len(m.rows) {
return m.rows[m.cursor].Issue.ID
vis := m.visible()
if m.cursor >= 0 && m.cursor < len(vis) {
return vis[m.cursor].Issue.ID
}
return ""
}
func (m boardModel) selectedTask() *Row {
if m.cursor >= 0 && m.cursor < len(m.rows) {
return &m.rows[m.cursor]
vis := m.visible()
if m.cursor >= 0 && m.cursor < len(vis) {
return &vis[m.cursor]
}
return nil
}
@@ -117,10 +145,73 @@ func (m boardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tickMsg:
return m, tea.Batch(m.reload(), tickEvery(5*time.Second))
case tea.MouseMsg:
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
for i, r := range m.visible() {
if z := m.zones.Get("row-" + r.Issue.ID); z != nil && z.InBounds(msg) {
m.cursor = i
return m, m.loadDetail(r.Issue.ID)
}
}
}
return m, nil
case tea.KeyMsg:
if m.showHelp {
m.showHelp = false
return m, nil
}
if m.typing {
switch msg.Type {
case tea.KeyEsc:
m.typing = false
m.filter = ""
return m, nil
case tea.KeyEnter, tea.KeyTab:
m.typing = false
return m, nil
case tea.KeyBackspace:
r := []rune(m.filter)
if len(r) > 0 {
m.filter = string(r[:len(r)-1])
}
return m, nil
case tea.KeySpace:
m.filter += " "
return m, nil
case tea.KeyRunes:
m.filter += string(msg.Runes)
return m, nil
}
return m, nil
}
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "?":
m.showHelp = true
return m, nil
case "/":
m.typing = true
return m, nil
case "u":
// A task whose blocker finished but which still says blocked is the
// one thing a person unblocks without reading anything else.
if r := m.selectedTask(); r != nil && isReadyButBlocked(*r) {
c, id := m.client, r.Issue.ID
return m, func() tea.Msg {
if _, err := c.SetStatus(id, "todo", "Unblocked: its blocker finished."); err != nil {
return errMsg{err}
}
rows, err := loadRows(c, m.project)
if err != nil {
return errMsg{err}
}
return rowsMsg(rows)
}
}
return m, nil
case "tab":
m.focus = (m.focus + 1) % focusCount
return m, nil
@@ -143,7 +234,7 @@ func (m boardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case "down", "j":
if m.cursor < len(m.rows)-1 {
if m.cursor < len(m.visible())-1 {
m.cursor++
return m, m.loadDetail(m.selectedID())
}
@@ -192,8 +283,8 @@ func (m boardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.loading = false
m.readAt = time.Now()
m.err = nil
if m.cursor >= len(m.rows) {
m.cursor = max(0, len(m.rows)-1)
if m.cursor >= len(m.visible()) {
m.cursor = max(0, len(m.visible())-1)
}
if id := m.selectedID(); id != "" && id != m.detailID {
return m, m.loadDetail(id)
@@ -232,6 +323,19 @@ func (m boardModel) View() string {
m.width, m.height))
}
if m.showHelp {
return helpOverlay("keys on this panel", [][2]string{
{"↑ ↓ j k", "choose a task"},
{"enter", "show that task in every workspace"},
{"a", "assign it to the configured agent"},
{"u", "unblock it, when its blocker has finished"},
{"/", "filter the list by identifier, title or name"},
{"tab", "move to the next panel"},
{"r / R", "read again / rebuild every pane"},
{"q", "leave this panel"},
}, m.width, m.height)
}
header := m.headerLine()
footer := m.footerLine()
bodyHeight := m.height - 2
@@ -257,7 +361,7 @@ func (m boardModel) View() string {
left.render(m.treeLines(treeW-3)), " ",
mid.render(m.summaryLines(sumW-3)), " ",
rightP.render(body))
return strings.Join([]string{header, page, footer}, "\n")
return m.zones.Scan(strings.Join([]string{header, page, footer}, "\n"))
}
leftWidth := max(34, m.width*40/100)
@@ -288,7 +392,7 @@ func (m boardModel) View() string {
)
page := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right)
return strings.Join([]string{header, page, footer}, "\n")
return m.zones.Scan(strings.Join([]string{header, page, footer}, "\n"))
}
func (m boardModel) headerLine() string {
@@ -319,6 +423,9 @@ func (m boardModel) footerLine() string {
keyHint("↑↓ or j k", "choose a task"),
keyHint("enter", "show it everywhere"),
keyHint("a", "assign"),
keyHint("u", "unblock"),
keyHint("/", "filter"),
keyHint("?", "keys"),
keyHint("tab", "next panel"),
keyHint("r", "read again"),
keyHint("R", "rebuild every pane"),
@@ -345,13 +452,17 @@ func (m boardModel) footerLine() string {
// treeLines draws the grouped task list.
func (m boardModel) treeLines(width int) []string {
var out []string
if len(m.rows) == 0 && !m.loading {
vis := m.visible()
if len(vis) == 0 && !m.loading {
if m.filter != "" {
return []string{mutedStyle2().Render(" nothing matches " + m.filter)}
}
return []string{mutedStyle2().Render(" no tasks in this project")}
}
index := 0
for _, g := range groupOrder {
var group []Row
for _, r := range m.rows {
for _, r := range vis {
if r.Group == g {
group = append(group, r)
}
@@ -414,7 +525,10 @@ func (m boardModel) taskLine(r Row, index, width int) []string {
if chosen {
first, second = padTo(first, width), padTo(second, width)
}
return []string{firstStyle.Render(first), secondStyle.Render(second)}
return []string{
m.zones.Mark("row-"+r.Issue.ID, firstStyle.Render(first)),
secondStyle.Render(second),
}
}
// shortStatus is the one word a row has room for.