Files
paperclip-harness/tui/repair.go

563 lines
16 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 Repair workspace: is the layout sound, and what is broken.
//
// Every line is a check that was run, with three outcomes and never two: it
// passed, it failed with a reason, or it has not been run. "Not run" is not a
// pass, and a screen that says otherwise is how an afternoon disappears.
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"paperclip-harness/tui/paperclip"
)
type checkResult struct {
name string
state string // pass, fail, not run
detail string
}
type repairMsg struct {
sessions []sessionRow
checks []checkResult
workspaces []layoutRow
runs int
err error
}
// sessionRow is one TUIOS session as the cleanup tab sees it.
type sessionRow struct {
name string
panes int
lastUsed time.Time
attached bool
protected bool // this project's own session, or nothing to clean
}
type layoutRow struct {
number int
name string
panes int
}
type repairModel struct {
client *paperclip.Client
folder string
checks []checkResult
workspaces []layoutRow
runs int
readAt time.Time
err error
tab int
scroll int
showHelp bool
width, height int
sessions []sessionRow
sessAt int
ticked map[string]bool
confirmKill bool
killNote string
}
var repairTabs = []string{"health", "layout", "rebuild log", "cleanup"}
func newRepairModel(c *paperclip.Client, folder string) repairModel {
return repairModel{client: c, folder: folder}
}
func (m repairModel) Init() tea.Cmd {
return tea.Batch(m.load(), tickEvery(8*time.Second), tea.WindowSize())
}
func (m repairModel) load() tea.Cmd {
c, folder := m.client, m.folder
return func() tea.Msg {
var checks []checkResult
add := func(name, state, detail string) {
checks = append(checks, checkResult{name, state, detail})
}
if err := c.Health(); err != nil {
add("Paperclip answers", "fail", err.Error())
} else {
add("Paperclip answers", "pass", "the control plane replied")
}
session := "paperclip-harness"
cfgPath := filepath.Join(folder, ".harness", "harness.toml")
if _, err := os.Stat(cfgPath); err != nil {
add("project file", "fail", "no .harness/harness.toml: run bin/office")
} else if cfg, err := readHarnessFile(folder); err != nil {
add("project file", "fail", err.Error())
} else if cfg["paperclip.project"] == "" {
add("project file", "fail", "no paperclip project recorded")
} else {
add("project file", "pass", cfg["paperclip.project"])
if cfg["session"] != "" {
session = cfg["session"]
}
}
bin, _ := os.Executable()
if _, err := os.Stat(bin); err != nil {
add("harness binary", "fail", "not found")
} else {
add("harness binary", "pass", bin)
}
for _, tool := range []string{"git", "go", "ocr", "tuios", "lazygit", "hunk"} {
if path, ok := findTool(tool); ok {
add(tool, "pass", path)
} else {
add(tool, "fail", "not found on the path or in the usual places")
}
}
// Fonts: the icons are drawn from a Nerd Font, so say whether it is here.
out, _ := exec.Command("fc-list").Output()
nerd := strings.Count(strings.ToLower(string(out)), "nerd")
if nerd == 0 {
add("Nerd Font", "fail", "no Nerd Font installed; icons will be boxes")
} else {
add("Nerd Font", "pass", fmt.Sprintf("%d font files", nerd))
}
ws, layoutErr := layoutRows(session)
sess := listSessions(session)
res := repairMsg{checks: checks, workspaces: ws, sessions: sess}
if layoutErr != nil {
res.err = layoutErr
}
return res
}
}
// findTool looks where a person's tools actually live. A pane started by TUIOS
// gets a smaller PATH than an interactive shell, so a tool that works in the
// shell can look missing from in here.
func findTool(name string) (string, bool) {
if p, err := exec.LookPath(name); err == nil {
return p, true
}
home, _ := os.UserHomeDir()
for _, dir := range []string{
filepath.Join(home, ".local", "bin"),
filepath.Join(home, ".nix-profile", "bin"),
"/run/current-system/sw/bin",
"/etc/profiles/per-user/" + os.Getenv("USER") + "/bin",
} {
p := filepath.Join(dir, name)
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p, true
}
}
return "", false
}
// layoutRows reads what TUIOS actually has, not what we think it has.
func layoutRows(session string) ([]layoutRow, error) {
out, err := exec.Command("tuios", "list-workspaces", "-s", session, "--json").Output()
if err != nil {
return nil, err
}
return parseWorkspaces(string(out)), nil
}
func parseWorkspaces(raw string) []layoutRow {
var rows []layoutRow
for _, chunk := range strings.Split(raw, "{") {
if !strings.Contains(chunk, "\"workspace\"") {
continue
}
var number, panes int
var name string
fmt.Sscanf(strings.TrimSpace(chunk), "%d", &number)
for _, field := range strings.Split(chunk, ",") {
field = strings.TrimSpace(field)
if strings.HasPrefix(field, "\"workspace\":") {
fmt.Sscanf(strings.TrimPrefix(field, "\"workspace\":"), "%d", &number)
}
if strings.HasPrefix(field, "\"window_count\":") {
fmt.Sscanf(strings.TrimPrefix(field, "\"window_count\":"), "%d", &panes)
}
if strings.HasPrefix(field, "\"name\":") {
name = strings.Trim(strings.TrimPrefix(field, "\"name\":"), `" `)
}
}
if number > 0 {
rows = append(rows, layoutRow{number, name, panes})
}
}
return rows
}
func (m repairModel) 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(8*time.Second), tea.WindowSize())
case tea.KeyMsg:
if m.confirmKill {
switch msg.String() {
case "y":
m.confirmKill = false
killed := 0
for _, row := range m.sessions {
if !m.ticked[row.name] || row.protected || row.attached {
continue
}
out, err := exec.Command("tuios", "kill-session", row.name).CombinedOutput()
if err != nil {
m.err = fmt.Errorf("could not kill %q: %v %s", row.name, err, strings.TrimSpace(string(out)))
continue
}
killed++
}
m.ticked = map[string]bool{}
m.killNote = fmt.Sprintf("%d session(s) closed", killed)
return m, m.load()
case "esc", "n", "q":
m.confirmKill = false
}
return m, nil
}
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":
return m, m.load()
case "R":
if err := startRebuild(m.folder); err != nil {
m.err = err
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
return m, nil
case ")", "]", "l", "tab":
m.tab = (m.tab + 1) % len(repairTabs)
m.scroll = 0
return m, nil
case " ":
if m.tab == 3 && len(m.sessions) > 0 {
row := m.sessions[max(0, min(m.sessAt, len(m.sessions)-1))]
if row.protected || row.attached {
m.killNote = row.name + " is attached or is this project's session; it is not touched"
return m, nil
}
if m.ticked == nil {
m.ticked = map[string]bool{}
}
m.ticked[row.name] = !m.ticked[row.name]
return m, nil
}
return m, nil
case "x":
if m.tab == 3 && len(m.ticked) > 0 {
m.confirmKill = true
}
return m, nil
case "up", "k":
if m.tab == 3 {
if m.sessAt > 0 {
m.sessAt--
}
m.scroll = max(0, min(m.scroll, m.sessAt-2))
return m, nil
}
m.scroll--
return m, nil
case "down", "j":
if m.tab == 3 {
if m.sessAt < len(m.sessions)-1 {
m.sessAt++
}
visible := max(3, m.height-6)
if m.sessAt > m.scroll+visible-3 {
m.scroll = m.sessAt - visible + 3
}
return m, nil
}
m.scroll++
return m, nil
case "esc":
return m, nil
}
case repairMsg:
if msg.sessions != nil {
m.sessions = msg.sessions
}
m.checks = msg.checks
m.workspaces = msg.workspaces
m.err = msg.err
m.readAt = time.Now()
return m, nil
}
return m, nil
}
func (m repairModel) 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))
}
if m.confirmKill {
var names []string
for name := range m.ticked {
names = append(names, name)
}
sort.Strings(names)
entries := [][2]string{
{"what happens", "these TUIOS sessions are closed and their panes end"},
{"", "nothing in ~/.pi/paperclips is deleted; that is your Pi history"},
{"", "this project's session and attached sessions are never touched"},
{"y", "close them"},
{"esc or n", "do nothing"},
}
for _, n := range names {
entries = append(entries, [2]string{"", n})
}
return helpOverlay(fmt.Sprintf("close %s?", fmtCount(len(names), "session", "sessions")), entries, 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() {
state = "checking…"
}
body := m.healthLines()
switch m.tab {
case 1:
body = m.layoutLines()
case 2:
body = m.logLines()
case 3:
body = m.cleanupLines(m.width - 3)
}
p := panel{title: "checks", width: m.width, height: m.height - 2, tabs: repairTabs, active: m.tab, focused: true}
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("( )", "tab"),
keyHint("R", "rebuild"),
keyHint("↑↓", "scroll"),
keyHint("r", "recheck"),
keyHint("q", "quit"),
}, m.width)
gap := max(2, m.width-lipgloss.Width(head)-lipgloss.Width(state)-1)
return strings.Join([]string{
head + strings.Repeat(" ", gap) + mutedStyle2().Render(state),
p.render(body[from:to]),
footer,
}, "\n")
}
func (m repairModel) healthLines() []string {
out := []string{""}
if m.err != nil {
out = append(out, lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("layout: "+m.err.Error()))
out = append(out, "")
}
failed := 0
for _, c := range m.checks {
mark, colour := icons.Done, active.Success
switch c.state {
case "fail":
mark, colour = icons.Cancelled, active.Error
failed++
case "not run":
mark, colour = icons.Todo, active.Warning
}
out = append(out, fmt.Sprintf(" %s %-20s %s",
lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(mark),
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(c.name),
mutedStyle2().Render(truncateVisible(c.detail, max(10, m.width-30)))))
}
out = append(out, "")
if failed == 0 {
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Success)).Render("everything checked passes"))
} else {
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render(
fmt.Sprintf("%d check(s) failing", failed)))
}
return out
}
// logLines shows what the last rebuild did. It is read from the log the rebuild
// writes, so the record of a repair survives the panes it replaced.
func (m repairModel) logLines() []string {
out := []string{"", dimStyle().Render(" " + rebuildLogPath()), ""}
for _, ln := range rebuildLogTail(m.height - 6) {
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(ln))
}
out = append(out, "")
out = append(out, dimStyle().Render(" press R on any screen to rebuild every pane"))
return out
}
func (m repairModel) layoutLines() []string {
out := []string{""}
out = append(out, dimStyle().Render(fmt.Sprintf(" %-9s %-14s %s", "workspace", "name", "panes")))
for _, w := range m.workspaces {
name := w.name
if name == "" {
name = "(unnamed)"
}
if w.panes == 0 {
continue
}
out = append(out, fmt.Sprintf(" %-9d %-14s %d", w.number, name, w.panes))
}
out = append(out, "")
out = append(out, dimStyle().Render(" a workspace with no panes is removed by TUIOS and the rest renumber,"))
out = append(out, dimStyle().Render(" so every panel is found by name, never by number."))
return out
}
// listSessions reads every TUIOS session on this machine, newest activity first.
// It never reads or touches the Pi conversation files: those are separate and
// are the person's history.
func listSessions(current string) []sessionRow {
out, err := exec.Command("tuios", "ls", "--all-hosts", "--json").Output()
if err != nil {
return nil
}
var doc struct {
Hosts []struct {
Host string `json:"host"`
Sessions []struct {
Name string `json:"name"`
Windows int `json:"window_count"`
LastActive int64 `json:"last_active"`
Attached bool `json:"attached"`
} `json:"sessions"`
} `json:"hosts"`
}
if json.Unmarshal(out, &doc) != nil {
return nil
}
var rows []sessionRow
for _, host := range doc.Hosts {
for _, s := range host.Sessions {
// A session on another machine is listed but never touched from here.
row := sessionRow{
name: s.Name,
panes: s.Windows,
lastUsed: time.Unix(s.LastActive, 0),
attached: s.Attached || host.Host != "local",
}
if s.Name == current {
row.protected = true
}
rows = append(rows, row)
}
}
sort.Slice(rows, func(i, j int) bool { return rows[i].lastUsed.After(rows[j].lastUsed) })
return rows
}
// cleanupLines lists TUIOS sessions, marks what is protected, and lets a person
// tick the ones to close. It never touches ~/.pi/paperclips: closing a TUIOS
// session ends panes, and the Pi conversations are files that stay.
func (m repairModel) cleanupLines(width int) []string {
out := []string{"", dimStyle().Render(fmt.Sprintf(" %-34s %6s %-12s %s", "session", "panes", "last used", "state"))}
for i, r := range m.sessions {
state := ""
switch {
case r.protected:
state = "this project's session"
case r.attached:
state = "attached, or on another machine"
case m.ticked[r.name]:
state = "ticked for closing"
}
line := fmt.Sprintf(" %-34s %6d %-12s %s", truncateVisible(r.name, 34), r.panes,
r.lastUsed.Format("2 Jan 15:04"), state)
mark := " "
if m.ticked[r.name] {
mark = "* "
}
if i == m.sessAt {
out = append(out, selectedStyle2().Render(padTo("▸"+line[1:], width)))
continue
}
out = append(out, mark+line)
}
out = append(out, "")
if m.killNote != "" {
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Warning)).Render(m.killNote))
out = append(out, "")
}
out = append(out, dimStyle().Render(" space ticks a session, x closes the ticked ones after asking."))
out = append(out, dimStyle().Render(" closing a TUIOS session ends its panes. It does not delete anything in"))
out = append(out, dimStyle().Render(" ~/.pi/paperclips, where every Pi conversation is kept."))
return out
}