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.
|
||||
|
||||
17
tui/frame.go
17
tui/frame.go
@@ -272,3 +272,20 @@ func wrap(s string, w int) []string {
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// helpOverlay draws the keys for the panel that asked for it, centred over the
|
||||
// screen. It replaces the whole view rather than floating over it, so nothing
|
||||
// behind it can be misread as still active.
|
||||
func helpOverlay(title string, entries [][2]string, w, h int) string {
|
||||
boxW := min(64, max(30, w-8))
|
||||
boxH := min(len(entries)+4, max(8, h-4))
|
||||
lines := []string{""}
|
||||
for _, e := range entries {
|
||||
key := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Bold(true).
|
||||
Width(16).Render(e[0])
|
||||
lines = append(lines, " "+key+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(e[1]))
|
||||
}
|
||||
lines = append(lines, "", dimStyle().Render(" esc or ? closes this"))
|
||||
p := panel{title: title, width: boxW, height: boxH, focused: true}
|
||||
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, p.render(lines))
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ type projectModel struct {
|
||||
tab int
|
||||
scroll int
|
||||
|
||||
showHelp bool
|
||||
width, height int
|
||||
}
|
||||
|
||||
@@ -140,7 +141,14 @@ func (m projectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(m.load(), tickEvery(10*time.Second))
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.showHelp {
|
||||
m.showHelp = false
|
||||
return m, nil
|
||||
}
|
||||
switch msg.String() {
|
||||
case "?":
|
||||
m.showHelp = true
|
||||
return m, nil
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "r":
|
||||
@@ -194,6 +202,17 @@ func (m projectModel) View() string {
|
||||
fmt.Sprintf("This workspace needs 80 × 24.\nThis panel is %d × %d.", m.width, m.height))
|
||||
}
|
||||
|
||||
if m.showHelp {
|
||||
return helpOverlay("the project", [][2]string{
|
||||
{"[ ] h l", "facts, costs, settings"},
|
||||
{"↑ ↓ j k", "scroll"},
|
||||
{"r", "read again"},
|
||||
{"R", "rebuild every pane"},
|
||||
{"q", "leave"},
|
||||
{"?", "this list"},
|
||||
}, m.width, m.height)
|
||||
}
|
||||
|
||||
head := titleStyle.Render("Project") + mutedStyle2().Render(" · ")
|
||||
if m.project.Name != "" {
|
||||
head += lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(m.project.Name)
|
||||
|
||||
@@ -52,6 +52,7 @@ type repairModel struct {
|
||||
tab int
|
||||
scroll int
|
||||
|
||||
showHelp bool
|
||||
width, height int
|
||||
}
|
||||
|
||||
@@ -196,7 +197,14 @@ func (m repairModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(m.load(), tickEvery(8*time.Second))
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.showHelp {
|
||||
m.showHelp = false
|
||||
return m, nil
|
||||
}
|
||||
switch msg.String() {
|
||||
case "?":
|
||||
m.showHelp = true
|
||||
return m, nil
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "r":
|
||||
@@ -207,6 +215,17 @@ func (m repairModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
return m, tea.Quit
|
||||
case "B":
|
||||
// Add what is missing without replacing what is there. Safe to press
|
||||
// from inside a pane, because it never closes one.
|
||||
out, err := exec.Command(filepath.Join(exeDir(), "board"), "--folder", m.folder).CombinedOutput()
|
||||
if err != nil {
|
||||
m.err = fmt.Errorf("adding the missing panes failed: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
return m, nil
|
||||
}
|
||||
m.err = nil
|
||||
m.tab = 1
|
||||
return m, m.load()
|
||||
case "[", "h":
|
||||
m.tab = (m.tab + len(repairTabs) - 1) % len(repairTabs)
|
||||
m.scroll = 0
|
||||
@@ -244,6 +263,18 @@ func (m repairModel) View() string {
|
||||
fmt.Sprintf("This workspace needs 80 × 24.\nThis panel is %d × %d.", m.width, m.height))
|
||||
}
|
||||
|
||||
if m.showHelp {
|
||||
return helpOverlay("repair", [][2]string{
|
||||
{"[ ] h l", "health, layout, rebuild log"},
|
||||
{"R", "rebuild every pane"},
|
||||
{"B", "add only the missing panes"},
|
||||
{"↑ ↓ j k", "scroll"},
|
||||
{"r", "check again"},
|
||||
{"q", "leave"},
|
||||
{"?", "this list"},
|
||||
}, m.width, m.height)
|
||||
}
|
||||
|
||||
head := titleStyle.Render("Repair") + mutedStyle2().Render(" · layout and health")
|
||||
state := "read " + m.readAt.Format("15:04:05")
|
||||
if m.readAt.IsZero() {
|
||||
|
||||
20
tui/reply.go
20
tui/reply.go
@@ -42,9 +42,10 @@ type replyModel struct {
|
||||
err error
|
||||
readAt time.Time
|
||||
|
||||
scroll int
|
||||
width int
|
||||
height int
|
||||
showHelp bool
|
||||
scroll int
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func newReplyModel(c *paperclip.Client, folder, issueID string) replyModel {
|
||||
@@ -226,6 +227,19 @@ func (m replyModel) View() string {
|
||||
fmt.Sprintf("This workspace needs 80 × 24.\nThis panel is %d × %d.", m.width, m.height))
|
||||
}
|
||||
|
||||
if m.showHelp {
|
||||
return helpOverlay("the conversation", [][2]string{
|
||||
{"enter", "write a reply"},
|
||||
{"alt+enter", "new line inside the box"},
|
||||
{"s", "choose the status to send with it"},
|
||||
{"↑ ↓ j k", "read the thread"},
|
||||
{"r", "read again"},
|
||||
{"R", "rebuild every pane"},
|
||||
{"q", "leave"},
|
||||
{"?", "this list"},
|
||||
}, m.width, m.height)
|
||||
}
|
||||
|
||||
head := titleStyle.Render("Conversation") + mutedStyle2().Render(" · ")
|
||||
if m.task.Identifier != "" {
|
||||
head += lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).
|
||||
|
||||
20
tui/work.go
20
tui/work.go
@@ -46,6 +46,7 @@ type workModel struct {
|
||||
scroll int
|
||||
readAt time.Time
|
||||
|
||||
showHelp bool
|
||||
width, height int
|
||||
}
|
||||
|
||||
@@ -122,7 +123,14 @@ func (m workModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, tea.Batch(m.load(), tickEvery(5*time.Second))
|
||||
|
||||
case tea.KeyMsg:
|
||||
if m.showHelp {
|
||||
m.showHelp = false
|
||||
return m, nil
|
||||
}
|
||||
switch msg.String() {
|
||||
case "?":
|
||||
m.showHelp = true
|
||||
return m, nil
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "r":
|
||||
@@ -188,6 +196,18 @@ func (m workModel) View() string {
|
||||
fmt.Sprintf("This workspace needs 80 × 24.\nThis panel is %d × %d.", m.width, m.height))
|
||||
}
|
||||
|
||||
if m.showHelp {
|
||||
return helpOverlay("the work", [][2]string{
|
||||
{"[ ] h l", "switch between runs, change and facts"},
|
||||
{"↑ ↓ j k", "scroll"},
|
||||
{"g / G", "top / bottom"},
|
||||
{"r", "read again"},
|
||||
{"R", "rebuild every pane"},
|
||||
{"q", "leave"},
|
||||
{"?", "this list"},
|
||||
}, m.width, m.height)
|
||||
}
|
||||
|
||||
head := titleStyle.Render("Work") + mutedStyle2().Render(" · ")
|
||||
if m.task.Identifier != "" {
|
||||
head += lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(m.task.Identifier)
|
||||
|
||||
Reference in New Issue
Block a user