Files
paperclip-harness/tui/work.go

359 lines
9.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// The Work workspace: what the chosen task is doing, and what it changed.
//
// One panel with three tabs, because the three things are read one at a time:
// the runs, the change, and the facts about the copy of the code the task is
// working in. The change is read with git in the task's own worktree, in a
// command, never while drawing.
import (
"fmt"
"os"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"paperclip-harness/tui/paperclip"
)
type workMsg struct {
task paperclip.Issue
runs []paperclip.Run
activity []paperclip.Activity
cost paperclip.Cost
diff string
diffErr error
}
type workModel struct {
client *paperclip.Client
folder string
issue string
explicit bool // the task was named on the command line, so do not follow the file
task paperclip.Issue
runs []paperclip.Run
activity []paperclip.Activity
cost paperclip.Cost
diff string
diffErr error
err error
tab int
scroll int
readAt time.Time
width, height int
}
var workTabs = []string{"runs", "change", "facts"}
func newWorkModel(c *paperclip.Client, folder, issueID string) workModel {
return workModel{client: c, folder: folder, issue: issueID, explicit: issueID != ""}
}
func (m workModel) Init() tea.Cmd {
return tea.Batch(m.load(), tickEvery(5*time.Second))
}
func (m workModel) load() tea.Cmd {
c, folder := m.client, m.folder
fixed, explicit := m.issue, m.explicit
return func() tea.Msg {
// Unless a task was named on the command line, follow the choice made
// on the Board: it is written in the project's own file, so this panel
// needs no message from any other program.
id := fixed
if !explicit {
id = watchedTask(folder)
}
if id == "" {
return errMsg{fmt.Errorf("no task chosen: pick one on the Board, then press Enter")}
}
task, err := c.Issue(id)
if err != nil {
return errMsg{err}
}
runs, _ := c.Runs(id)
act, _ := c.Activity(id)
cost, _ := c.CostSummary(id)
msg := workMsg{task: task, runs: runs, activity: act, cost: cost}
if ws := task.Workspace; ws != nil && ws.CWD != "" {
msg.diff, msg.diffErr = worktreeDiff(ws.CWD, ws.BaseRef)
} else {
msg.diffErr = fmt.Errorf("this task has no worktree on this machine, so there is nothing to compare")
}
return msg
}
}
// watchedTask reads the chosen task from the project's own harness file.
func watchedTask(folder string) string {
b, err := os.ReadFile(projectFilePath(folder))
if err != nil {
return ""
}
for _, line := range strings.Split(string(b), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "watching") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
return strings.Trim(strings.TrimSpace(parts[1]), `"`)
}
}
}
return ""
}
func projectFilePath(folder string) string {
return folder + "/.harness/harness.toml"
}
func (m workModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
return m, nil
case tickMsg:
return m, tea.Batch(m.load(), tickEvery(5*time.Second))
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "r":
return m, m.load()
case "R":
if err := startRebuild(m.folder); err != nil {
m.err = err
return m, nil
}
return m, tea.Quit
case "[", "h":
if m.tab > 0 {
m.tab--
m.scroll = 0
}
return m, nil
case "]", "l", "tab":
if m.tab < len(workTabs)-1 {
m.tab++
m.scroll = 0
}
return m, nil
case "up", "k":
m.scroll--
return m, nil
case "down", "j":
m.scroll++
return m, nil
case "g":
m.scroll = 0
return m, nil
case "G":
m.scroll = 1 << 30
return m, nil
case "esc":
return m, nil
}
case workMsg:
m.task = msg.task
m.runs = msg.runs
m.activity = msg.activity
m.cost = msg.cost
m.diff = msg.diff
m.diffErr = msg.diffErr
m.err = nil
m.readAt = time.Now()
return m, nil
case errMsg:
m.err = msg.err
return m, nil
}
return m, nil
}
func (m workModel) View() string {
if m.width == 0 || m.height == 0 {
return "reading…"
}
if m.width < 80 || m.height < 24 {
return lipgloss.NewStyle().Width(m.width).Height(m.height).Render(
fmt.Sprintf("This workspace needs 80 × 24.\nThis panel is %d × %d.", 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)
head += mutedStyle2().Render(" " + truncateVisible(m.task.Title, max(10, m.width-50)))
}
state := "read " + m.readAt.Format("15:04:05")
if m.readAt.IsZero() {
state = "reading…"
}
gap := max(2, m.width-lipgloss.Width(head)-lipgloss.Width(state)-1)
var body []string
switch m.tab {
case 0:
body = m.runsLines(m.width - 4)
case 1:
body = m.diffLines(m.width - 4)
default:
body = m.factsLines(m.width - 4)
}
p := panel{
title: "the work", width: m.width, height: m.height - 2,
tabs: workTabs, active: m.tab, focused: true,
}
// Scroll inside the panel.
visible := p.bodyHeight()
maxScroll := max(0, len(body)-visible)
if m.scroll > maxScroll {
m.scroll = maxScroll
}
if m.scroll < 0 {
m.scroll = 0
}
from := m.scroll
to := min(len(body), from+visible)
footer := hintLine([]string{
keyHint("[ ]", "switch tab"),
keyHint("↑↓ or j k", "scroll"),
keyHint("r", "read again"),
keyHint("R", "rebuild every pane"),
keyHint("q", "leave"),
}, m.width)
return strings.Join([]string{
head + strings.Repeat(" ", gap) + mutedStyle2().Render(state),
p.render(body[from:to]),
footer,
}, "\n")
}
func (m workModel) runsLines(width int) []string {
if m.err != nil {
return []string{lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("error: " + m.err.Error())}
}
var out []string
out = append(out, "")
if len(m.runs) == 0 {
out = append(out, mutedStyle2().Render("this task has never run"))
} else {
out = append(out, dimStyle().Render(fmt.Sprintf("%-18s %-12s %-10s %-10s %s",
"state", "woken by", "started", "ended", "took")))
for _, r := range m.runs {
started, took := "-", "-"
if t, err := time.Parse(time.RFC3339, r.StartedAt); err == nil {
started = t.Local().Format("15:04:05")
}
if r.FinishedAt != "" {
if s, err := time.Parse(time.RFC3339, r.StartedAt); err == nil {
if f, err := time.Parse(time.RFC3339, r.FinishedAt); err == nil {
took = f.Sub(s).Round(time.Second).String()
}
}
}
ended := "-"
if t, err := time.Parse(time.RFC3339, r.FinishedAt); err == nil {
ended = t.Local().Format("15:04:05")
}
colour := active.Text
switch r.Status {
case "running":
colour = active.Info
case "succeeded":
colour = active.Success
case "failed":
colour = active.Error
}
// Pad the plain text first, then colour it: styling first would
// make the escape codes count as characters and break the columns.
out = append(out, fmt.Sprintf("%-18s %-12s %-10s %-10s %s",
lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(fmt.Sprintf("%-18s", r.Status)),
fmt.Sprintf("%-12s", r.Source), started, ended, took))
}
}
out = append(out, "")
out = append(out, groupHeading("activity", len(m.activity), width))
for i, a := range m.activity {
if i >= 20 {
out = append(out, dimStyle().Render(fmt.Sprintf("… %d more", len(m.activity)-20)))
break
}
when := a.CreatedAt
if t, err := time.Parse(time.RFC3339, a.CreatedAt); err == nil {
when = t.Local().Format("15:04")
}
who := a.ActorType
if who == "user" {
who = "you"
}
out = append(out, fmt.Sprintf("%s %-7s %s", dimStyle().Render(when), mutedStyle2().Render(who),
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(a.Action)))
}
return out
}
func (m workModel) diffLines(width int) []string {
if m.diffErr != nil {
return []string{"", mutedStyle2().Render(m.diffErr.Error())}
}
if m.diff == "" {
return []string{"", mutedStyle2().Render("nothing to compare yet")}
}
var out []string
for _, ln := range strings.Split(strings.TrimRight(m.diff, "\n"), "\n") {
style := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text))
switch {
case strings.HasPrefix(ln, "+++"), strings.HasPrefix(ln, "---"):
style = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Muted))
case strings.HasPrefix(ln, "+"):
style = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success))
case strings.HasPrefix(ln, "-"):
style = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error))
case strings.HasPrefix(ln, "@@"):
style = lipgloss.NewStyle().Foreground(lipgloss.Color(active.Info))
case strings.HasPrefix(ln, "diff "), strings.HasPrefix(ln, "commit "), strings.HasPrefix(ln, "+"):
style = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(active.Text))
}
if lipgloss.Width(ln) > width {
ln = truncateVisible(ln, width)
}
out = append(out, style.Render(ln))
}
return out
}
func (m workModel) factsLines(width int) []string {
var out []string
out = append(out, "")
row := func(label, value string) {
out = append(out, mutedStyle2().Render(fmt.Sprintf("%-14s", label))+
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(value))
}
if m.task.Identifier != "" {
row("task", m.task.Identifier+" "+m.task.Title)
row("status", m.task.Status)
}
if m.task.Workspace != nil {
row("worktree", m.task.Workspace.CWD)
row("branch", m.task.Workspace.Branch)
row("cut from", m.task.Workspace.BaseRef)
} else {
row("worktree", "none: this task has no copy of the code yet")
}
row("cost", fmt.Sprintf("%d cent(s), %d run(s), %d tokens out", m.cost.CostCents, m.cost.RunCount, m.cost.OutputTokens))
return out
}