563 lines
16 KiB
Go
563 lines
16 KiB
Go
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
|
||
}
|