Files
paperclip-harness/tui/logdiff.go

295 lines
7.3 KiB
Go
Raw Permalink 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
// Screen 3: what the task is doing, and what it changed.
//
// The log half comes from Paperclip: the task's runs and its activity. The diff
// half comes from the task's own copy of the code, which Paperclip made as a git
// worktree; the program only reads it with git, in a command, never while
// drawing.
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"paperclip-harness/tui/paperclip"
)
type logMsg struct {
task paperclip.Issue
runs []paperclip.Run
activity []paperclip.Activity
diff string
diffErr error
}
type tickMsg time.Time
type logModel struct {
client *paperclip.Client
issue string
task paperclip.Issue
runs []paperclip.Run
activity []paperclip.Activity
diff string
diffErr error
err error
scroll int
width, height int
}
func newLogModel(c *paperclip.Client, issueID string) logModel {
return logModel{client: c, issue: issueID}
}
// The screen re-reads on a timer so it is live while an agent works. The read
// happens in a command, never in Update or View.
func (m logModel) Init() tea.Cmd { return tea.Batch(m.load(), m.tick()) }
func (m logModel) load() tea.Cmd {
c, id := m.client, m.issue
return func() tea.Msg {
task, err := c.Issue(id)
if err != nil {
return errMsg{err}
}
runs, _ := c.Runs(id)
act, _ := c.Activity(id)
msg := logMsg{task: task, runs: runs, activity: act}
if task.Workspace == nil || task.Workspace.CWD == "" {
msg.diffErr = fmt.Errorf("this task has no worktree, so there is nothing to compare")
return msg
}
msg.diff, msg.diffErr = worktreeDiff(task.Workspace.CWD, task.Workspace.BaseRef)
return msg
}
}
// worktreeDiff reads what the task's branch changed. It runs git, so it is only
// ever called from a command.
func worktreeDiff(dir, base string) (string, error) {
if base == "" {
base = "main"
}
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
return "", fmt.Errorf("the task's folder %s no longer exists, so there is nothing to compare", dir)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
run := func(args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
return string(out), fmt.Errorf("git %s: %v", strings.Join(args, " "), err)
}
return string(out), nil
}
var b strings.Builder
head, err := run("rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return "", err
}
b.WriteString("worktree: " + dir + "\n")
b.WriteString("branch: " + strings.TrimSpace(head) + " base: " + base + "\n\n")
commits, err := run("log", "--oneline", base+"..HEAD")
if err != nil {
commits = ""
}
if strings.TrimSpace(commits) == "" {
b.WriteString("no commits on this branch yet\n")
} else {
b.WriteString("commits\n")
b.WriteString(indent(commits, " ", 20))
b.WriteString("\n")
}
stat, err := run("diff", "--stat", base+"...HEAD")
if err == nil && strings.TrimSpace(stat) != "" {
b.WriteString("changed\n")
b.WriteString(indent(stat, " ", 40))
b.WriteString("\n")
}
d, err := run("diff", base+"...HEAD")
if err == nil && strings.TrimSpace(d) != "" {
b.WriteString("diff\n")
b.WriteString(indent(d, " ", 400))
}
// Work the agent has not committed is worth more than the commit log.
dirty, _ := run("status", "--porcelain")
if strings.TrimSpace(dirty) != "" {
b.WriteString("\nuncommitted\n")
b.WriteString(indent(dirty, " ", 40))
ud, _ := run("diff")
if strings.TrimSpace(ud) != "" {
b.WriteString(indent(ud, " ", 200))
}
}
return b.String(), nil
}
// indent prefixes lines and stops after max, saying how many were left out.
func indent(s, prefix string, max int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
var b strings.Builder
for i, ln := range lines {
if i >= max {
fmt.Fprintf(&b, "%s… %d more lines\n", prefix, len(lines)-max)
break
}
b.WriteString(prefix + ln + "\n")
}
return b.String()
}
func (m logModel) tick() tea.Cmd {
return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
}
func (m logModel) 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 tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
return m, tea.Quit
case "r":
return m, m.load()
case "up":
m.scroll--
return m, nil
case "down":
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 tickMsg:
return m, tea.Batch(m.load(), m.tick())
case logMsg:
m.task = msg.task
m.runs = msg.runs
m.activity = msg.activity
m.diff = msg.diff
m.diffErr = msg.diffErr
m.err = nil
return m, nil
case errMsg:
m.err = msg.err
return m, m.tick()
}
return m, nil
}
func (m logModel) running() bool {
for _, r := range m.runs {
if r.Status == "running" {
return true
}
}
return false
}
func (m logModel) View() string {
if m.width == 0 || m.height == 0 {
return "loading…"
}
if m.width < minWidth || m.height < minHeight {
return fmt.Sprintf("Window is too small.\nNeed %d×%d, have %d×%d.", minWidth, minHeight, m.width, m.height)
}
var lines []string
head := fmt.Sprintf("%s %s · log and diff", m.task.Identifier, m.task.Status)
lines = append(lines, titleStyle.Render(truncate(head, m.width)))
if m.err != nil {
lines = append(lines, blockedStyle.Render(truncate("error: "+m.err.Error(), m.width)))
}
lines = append(lines, "")
lines = append(lines, groupStyle.Render("runs"))
if len(m.runs) == 0 {
lines = append(lines, mutedStyle.Render(" never run"))
}
for _, r := range m.runs {
when := r.StartedAt
if r.FinishedAt != "" {
when += " → " + r.FinishedAt
}
line := fmt.Sprintf(" %-10s %-9s %s", r.Status, r.Source, when)
if r.ErrorCode != "" {
line += " " + r.ErrorCode
}
lines = append(lines, truncate(line, m.width))
}
lines = append(lines, "")
lines = append(lines, groupStyle.Render("activity"))
if len(m.activity) == 0 {
lines = append(lines, mutedStyle.Render(" nothing recorded"))
}
for i, a := range m.activity {
if i >= 12 {
lines = append(lines, mutedStyle.Render(fmt.Sprintf(" … %d more", len(m.activity)-12)))
break
}
lines = append(lines, truncate(fmt.Sprintf(" %s %-16s %s", a.CreatedAt, a.ActorType, a.Action), m.width))
}
lines = append(lines, "")
lines = append(lines, groupStyle.Render("diff and worktree"))
switch {
case m.diffErr != nil:
lines = append(lines, mutedStyle.Render(" "+truncate(m.diffErr.Error(), m.width-2)))
case m.diff == "":
lines = append(lines, mutedStyle.Render(" nothing yet"))
default:
for _, ln := range strings.Split(strings.TrimRight(m.diff, "\n"), "\n") {
lines = append(lines, truncate(ln, m.width))
}
}
// Scrolling, with the cursor pinned to the bottom until moved up.
visible := max(4, m.height-1)
maxScroll := max(0, len(lines)-visible)
if m.scroll > maxScroll {
m.scroll = maxScroll
}
if m.scroll < 0 {
m.scroll = 0
}
from := m.scroll
to := min(len(lines), from+visible+1)
var b strings.Builder
for _, ln := range lines[from:to] {
b.WriteString(ln)
b.WriteString("\n")
}
return b.String()
}