Help on every panel, task filter, unblock key, mouse zones, safe add-only rebuild
This commit is contained in:
140
tui/board.go
140
tui/board.go
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user