Project and Repair workspaces; adoption keeps the person's own pane; truncation fix
This commit is contained in:
323
tui/repair.go
Normal file
323
tui/repair.go
Normal file
@@ -0,0 +1,323 @@
|
||||
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 (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"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 {
|
||||
checks []checkResult
|
||||
workspaces []layoutRow
|
||||
runs int
|
||||
err error
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
width, height int
|
||||
}
|
||||
|
||||
var repairTabs = []string{"health", "layout"}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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)
|
||||
res := repairMsg{checks: checks, workspaces: ws}
|
||||
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))
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "r":
|
||||
return m, m.load()
|
||||
case "[", "h", "]", "l", "tab":
|
||||
m.tab = (m.tab + 1) % len(repairTabs)
|
||||
m.scroll = 0
|
||||
return m, nil
|
||||
case "up", "k":
|
||||
m.scroll--
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
m.scroll++
|
||||
return m, nil
|
||||
case "esc":
|
||||
return m, nil
|
||||
}
|
||||
|
||||
case repairMsg:
|
||||
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))
|
||||
}
|
||||
|
||||
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()
|
||||
if m.tab == 1 {
|
||||
body = m.layoutLines()
|
||||
}
|
||||
|
||||
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("[ ]", "switch tab"),
|
||||
keyHint("↑↓ or j k", "scroll"),
|
||||
keyHint("r", "check again"),
|
||||
keyHint("q", "leave"),
|
||||
}, 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user