737 lines
22 KiB
Go
737 lines
22 KiB
Go
package main
|
||
|
||
// The Project workspace: which folder, which machine, which session, what it
|
||
// has cost. Read-only on purpose. A screen that can change a project's settings
|
||
// is a screen that can break it while nobody is looking; changing them belongs
|
||
// to a command with a name, not a keystroke.
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
"github.com/charmbracelet/lipgloss"
|
||
|
||
"paperclip-harness/tui/paperclip"
|
||
)
|
||
|
||
type projectMsg struct {
|
||
git gitState
|
||
wts []worktreeRow
|
||
dash paperclip.Dashboard
|
||
project paperclip.Project
|
||
cfg map[string]string
|
||
agents []paperclip.Agent
|
||
costs []taskCost
|
||
verified string
|
||
err error
|
||
}
|
||
|
||
type taskCost struct {
|
||
Identifier string
|
||
Status string
|
||
Cents int
|
||
Runs int
|
||
}
|
||
|
||
type projectModel struct {
|
||
client *paperclip.Client
|
||
folder string
|
||
|
||
dash paperclip.Dashboard
|
||
project paperclip.Project
|
||
cfg map[string]string
|
||
agents []paperclip.Agent
|
||
costs []taskCost
|
||
verified string
|
||
err error
|
||
readAt time.Time
|
||
|
||
git gitState
|
||
wts []worktreeRow
|
||
|
||
tab int
|
||
scroll int
|
||
regAt int
|
||
regs []registryRow
|
||
confirmSwitch bool
|
||
|
||
showHelp bool
|
||
width, height int
|
||
}
|
||
|
||
// Tab order is also the reading order, and the numbers are named because they
|
||
// are used in the key handling: a bare 4 broke once already.
|
||
const (
|
||
tabOverview = iota
|
||
tabGit
|
||
tabFacts
|
||
tabCosts
|
||
tabSettings
|
||
tabProjects
|
||
)
|
||
|
||
var projectTabs = []string{"overview", "git", "facts", "costs", "settings", "projects"}
|
||
|
||
func newProjectModel(c *paperclip.Client, folder string) projectModel {
|
||
return projectModel{client: c, folder: folder}
|
||
}
|
||
|
||
func (m projectModel) Init() tea.Cmd {
|
||
return tea.Batch(m.load(), tickEvery(10*time.Second), tea.WindowSize())
|
||
}
|
||
|
||
func (m projectModel) load() tea.Cmd {
|
||
c, folder := m.client, m.folder
|
||
return func() tea.Msg {
|
||
cfg, err := readHarnessFile(folder)
|
||
if err != nil {
|
||
return projectMsg{err: err}
|
||
}
|
||
proj, err := c.ResolveProject(cfg["paperclip.project"])
|
||
if err != nil {
|
||
return projectMsg{cfg: cfg, err: err}
|
||
}
|
||
agents, _ := c.Agents()
|
||
issues, err := c.Issues()
|
||
if err != nil {
|
||
return projectMsg{cfg: cfg, project: proj, err: err}
|
||
}
|
||
var costs []taskCost
|
||
// The most recent tasks only: a cost per task is one request each, and
|
||
// a whole project's history would be hundreds.
|
||
var mine []paperclip.Issue
|
||
for _, i := range issues {
|
||
if i.ProjectID == proj.ID {
|
||
mine = append(mine, i)
|
||
}
|
||
}
|
||
for i := len(mine) - 1; i >= 0 && len(costs) < 12; i-- {
|
||
t := mine[i]
|
||
cost, err := c.CostSummary(t.ID)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
costs = append(costs, taskCost{t.Identifier, t.Status, cost.CostCents, cost.RunCount})
|
||
}
|
||
dash, _ := c.DashboardSummary()
|
||
git := readGitState(folder)
|
||
wts, _ := readWorktrees(c, proj.ID, 8)
|
||
return projectMsg{project: proj, cfg: cfg, agents: agents, costs: costs,
|
||
dash: dash, git: git, wts: wts}
|
||
}
|
||
}
|
||
|
||
// readHarnessFile reads the one file this project keeps for itself.
|
||
func readHarnessFile(folder string) (map[string]string, error) {
|
||
b, err := os.ReadFile(projectFilePath(folder))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("no .harness/harness.toml in %s: run bin/office first", folder)
|
||
}
|
||
out := map[string]string{}
|
||
section := ""
|
||
for _, line := range strings.Split(string(b), "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" || strings.HasPrefix(line, "#") {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(line, "[") {
|
||
section = strings.Trim(line, "[]")
|
||
continue
|
||
}
|
||
parts := strings.SplitN(line, "=", 2)
|
||
if len(parts) != 2 {
|
||
continue
|
||
}
|
||
key := strings.TrimSpace(parts[0])
|
||
val := strings.Trim(strings.TrimSpace(parts[1]), `"'`)
|
||
if section != "" {
|
||
key = section + "." + key
|
||
}
|
||
out[key] = val
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (m projectModel) 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(10*time.Second))
|
||
|
||
case tea.KeyMsg:
|
||
if m.confirmSwitch {
|
||
switch msg.String() {
|
||
case "y":
|
||
m.confirmSwitch = false
|
||
if m.regAt < len(m.regs) {
|
||
r := m.regs[m.regAt]
|
||
params := map[string]any{"name": r.session}
|
||
me, _ := os.Hostname()
|
||
if r.host != "" && r.host != me && r.host != strings.Split(me, ".")[0] {
|
||
params["host"] = r.host
|
||
}
|
||
body, _ := json.Marshal(params)
|
||
out, err := exec.Command("tuios", "call", "switch-session", string(body)).CombinedOutput()
|
||
if err != nil {
|
||
m.err = fmt.Errorf("could not switch to %q: %v %s", r.session, err, strings.TrimSpace(string(out)))
|
||
}
|
||
}
|
||
return m, nil
|
||
case "esc", "n", "q":
|
||
m.confirmSwitch = 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 "t":
|
||
// lazygit, in the project folder: it is the tool that can stage,
|
||
// exclude and commit. Nothing here reimplements it.
|
||
if err := openTool(m.folder, "lazygit"); err != nil {
|
||
m.err = err
|
||
}
|
||
return m, nil
|
||
case "R":
|
||
if err := startRebuild(m.folder); err != nil {
|
||
m.err = err
|
||
return m, nil
|
||
}
|
||
return m, tea.Quit
|
||
case "enter":
|
||
if m.tab == tabProjects {
|
||
m.regs = readRegistry()
|
||
if len(m.regs) > 0 && m.regAt < len(m.regs) {
|
||
m.confirmSwitch = true
|
||
}
|
||
}
|
||
return m, nil
|
||
case "(", "[", "h":
|
||
if m.tab > 0 {
|
||
m.tab--
|
||
m.scroll = 0
|
||
}
|
||
return m, nil
|
||
case ")", "]", "l", "tab":
|
||
if m.tab < len(projectTabs)-1 {
|
||
m.tab++
|
||
m.scroll = 0
|
||
}
|
||
return m, nil
|
||
case "up", "k":
|
||
if m.tab == tabProjects && m.regAt > 0 {
|
||
m.regAt--
|
||
}
|
||
m.scroll--
|
||
return m, nil
|
||
case "down", "j":
|
||
if m.tab == tabProjects && m.regAt < len(m.regs)-1 {
|
||
m.regAt++
|
||
}
|
||
m.scroll++
|
||
return m, nil
|
||
case "esc":
|
||
return m, nil
|
||
}
|
||
|
||
case projectMsg:
|
||
m.regs = readRegistry()
|
||
m.regAt = min(m.regAt, max(0, len(m.regs)-1))
|
||
m.dash = msg.dash
|
||
m.project = msg.project
|
||
m.cfg = msg.cfg
|
||
m.agents = msg.agents
|
||
m.costs = msg.costs
|
||
m.git = msg.git
|
||
m.wts = msg.wts
|
||
m.err = msg.err
|
||
m.readAt = time.Now()
|
||
return m, nil
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
func (m projectModel) 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.confirmSwitch && m.regAt < len(m.regs) {
|
||
r := m.regs[m.regAt]
|
||
return helpOverlay("switch to "+r.session+"?", [][2]string{
|
||
{"what happens", "your TUIOS client moves to that session"},
|
||
{"session", r.session},
|
||
{"machine", r.host},
|
||
{"folder", r.folder},
|
||
{"y", "switch"},
|
||
{"esc or n", "stay here"},
|
||
}, m.width, m.height)
|
||
}
|
||
if m.showHelp {
|
||
return helpOverlay("the project", [][2]string{
|
||
{"( )", "overview, git, facts, costs, settings, projects"},
|
||
{"t", "open lazygit in this folder"},
|
||
{"[ ] h l", "previous and next tab"},
|
||
{"↑ ↓ j k", "scroll"},
|
||
{"r", "read again"},
|
||
{"R", "rebuild every pane"},
|
||
{"q", "leave"},
|
||
{"?", "this list"},
|
||
}, m.width, m.height)
|
||
}
|
||
|
||
head := titleStyle.Render("Project") + mutedStyle2().Render(" · ")
|
||
if m.project.Name != "" {
|
||
head += lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(m.project.Name)
|
||
}
|
||
state := "read " + m.readAt.Format("15:04:05")
|
||
if m.readAt.IsZero() {
|
||
state = "reading…"
|
||
}
|
||
|
||
var body []string
|
||
switch m.tab {
|
||
case tabOverview:
|
||
body = m.overviewLines(m.width - 4)
|
||
case tabGit:
|
||
body = m.gitLines(m.width - 4)
|
||
case tabFacts:
|
||
body = m.factLines()
|
||
case tabCosts:
|
||
body = m.costLines()
|
||
case tabProjects:
|
||
body = m.projectListLines(m.width - 4)
|
||
default:
|
||
body = m.settingLines()
|
||
}
|
||
if m.err != nil {
|
||
body = append([]string{lipgloss.NewStyle().Foreground(lipgloss.Color(active.Error)).Render("error: " + m.err.Error()), ""}, body...)
|
||
}
|
||
|
||
p := panel{title: "the project", width: m.width, height: m.height - 2, tabs: projectTabs, 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("t", "lazygit"),
|
||
keyHint("↑↓", "scroll"),
|
||
keyHint("r", "refresh"),
|
||
keyHint("R", "rebuild"),
|
||
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 projectModel) row(label, value string) string {
|
||
return mutedStyle2().Render(fmt.Sprintf("%-16s", label)) +
|
||
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(value)
|
||
}
|
||
|
||
// overviewLines is the company dashboard, drawn as numbers a person can act on
|
||
// and a sparkline of the last two weeks of runs. Everything here comes from
|
||
// GET /api/companies/{id}/dashboard in one call.
|
||
func (m projectModel) overviewLines(width int) []string {
|
||
d := m.dash
|
||
out := []string{"", groupHeading("agents", -1, width), ""}
|
||
out = append(out, " "+stat("active", d.Agents.Active, active.Text)+
|
||
" "+stat("running", d.Agents.Running, active.Info)+
|
||
" "+stat("paused", d.Agents.Paused, active.Warning)+
|
||
" "+stat("error", d.Agents.Error, active.Error))
|
||
out = append(out, "", groupHeading("tasks", -1, width), "")
|
||
out = append(out, " "+stat("open", d.Tasks.Open, active.Text)+
|
||
" "+stat("in progress", d.Tasks.InProgress, active.Info)+
|
||
" "+stat("blocked", d.Tasks.Blocked, active.Error)+
|
||
" "+stat("done", d.Tasks.Done, active.Success))
|
||
out = append(out, "", groupHeading("money, this month", -1, width), "")
|
||
budget := "no company budget set"
|
||
if d.Costs.MonthBudgetCents > 0 {
|
||
budget = fmt.Sprintf("%.0f%% of %d cents", d.Costs.MonthUtilizationPercent, d.Costs.MonthBudgetCents)
|
||
}
|
||
out = append(out, " "+stat("cents spent", d.Costs.MonthSpendCents, active.Text)+" "+
|
||
mutedStyle2().Render(budget))
|
||
out = append(out, " "+stat("pending approvals", d.PendingApprovals, active.Warning))
|
||
if d.Budgets.ActiveIncidents > 0 || d.Budgets.PausedAgents > 0 || d.Budgets.PausedProjects > 0 {
|
||
out = append(out, " "+stat("budget incidents", d.Budgets.ActiveIncidents, active.Error)+
|
||
mutedStyle2().Render(fmt.Sprintf(" paused: %d agent(s), %d project(s)",
|
||
d.Budgets.PausedAgents, d.Budgets.PausedProjects)))
|
||
}
|
||
out = append(out, "", groupHeading("runs, last two weeks", -1, width), "")
|
||
out = append(out, " "+sparkline(d.RunActivity))
|
||
if n := len(d.RunActivity); n > 0 {
|
||
first, last := d.RunActivity[0].Date, d.RunActivity[n-1].Date
|
||
out = append(out, dimStyle().Render(" "+first+" → "+last+" one bar per day, taller is more runs"))
|
||
}
|
||
out = append(out, "")
|
||
out = append(out, dimStyle().Render(" read from Paperclip's own dashboard, not counted here."))
|
||
return out
|
||
}
|
||
|
||
// stat is a label and a number, the number first because it is what you look at.
|
||
func stat(label string, value int, colour string) string {
|
||
return lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Bold(true).Render(fmt.Sprintf("%d", value)) +
|
||
" " + mutedStyle2().Render(label)
|
||
}
|
||
|
||
// sparkline draws one bar per day using block characters, scaled so the busiest
|
||
// day fills the height. No chart library, no second language: the data is in
|
||
// memory and the terminal already knows how to draw blocks.
|
||
func sparkline(points []struct {
|
||
Date string `json:"date"`
|
||
Total int `json:"total"`
|
||
Succeeded int `json:"succeeded"`
|
||
Failed int `json:"failed"`
|
||
}) string {
|
||
bars := []rune("▁▂▃▄▅▆▇█")
|
||
peak := 0
|
||
for _, p := range points {
|
||
if p.Total > peak {
|
||
peak = p.Total
|
||
}
|
||
}
|
||
if peak == 0 {
|
||
return mutedStyle2().Render("no runs in the last two weeks")
|
||
}
|
||
var b strings.Builder
|
||
for _, p := range points {
|
||
idx := int(float64(p.Total) / float64(peak) * float64(len(bars)-1))
|
||
colour := active.Success
|
||
if p.Failed > 0 {
|
||
colour = active.Warning
|
||
}
|
||
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colour)).Render(string(bars[idx])))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
func (m projectModel) factLines() []string {
|
||
out := []string{""}
|
||
if m.project.Name != "" {
|
||
out = append(out, m.row("project", m.project.Name))
|
||
out = append(out, m.row("project id", m.project.ID))
|
||
out = append(out, m.row("status", m.project.Status))
|
||
}
|
||
out = append(out, m.row("folder", m.folder))
|
||
out = append(out, m.row("machine", m.cfg["machine.host"]))
|
||
out = append(out, m.row("session", m.cfg["session"]))
|
||
out = append(out, "")
|
||
out = append(out, groupHeading("agents", len(m.agents), m.width-4))
|
||
for _, a := range m.agents {
|
||
mark := icons.Todo
|
||
if a.Status == "idle" {
|
||
mark = icons.Done
|
||
}
|
||
out = append(out, fmt.Sprintf(" %s %-16s %s", mark,
|
||
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(a.Name),
|
||
mutedStyle2().Render(a.Status)))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (m projectModel) costLines() []string {
|
||
if len(m.costs) == 0 {
|
||
return []string{"", mutedStyle2().Render("no recorded spend for recent tasks")}
|
||
}
|
||
total := 0
|
||
runs := 0
|
||
peak := 1
|
||
for _, c := range m.costs {
|
||
if c.Cents > peak {
|
||
peak = c.Cents
|
||
}
|
||
}
|
||
out := []string{"", dimStyle().Render(fmt.Sprintf("%-10s %-10s %6s %5s %s", "task", "state", "cents", "runs", "share of the largest"))}
|
||
for _, c := range m.costs {
|
||
total += c.Cents
|
||
runs += c.Runs
|
||
bars := int(float64(c.Cents) / float64(peak) * 24)
|
||
bar := strings.Repeat("█", bars)
|
||
if c.Cents > 0 && bars == 0 {
|
||
bar = "▏"
|
||
}
|
||
out = append(out, fmt.Sprintf("%-10s %-10s %6d %5d %s", c.Identifier, c.Status, c.Cents, c.Runs,
|
||
lipgloss.NewStyle().Foreground(lipgloss.Color(active.Accent)).Render(bar)))
|
||
}
|
||
out = append(out, "")
|
||
out = append(out, m.row("total, recent", fmt.Sprintf("%d cent(s) over %d run(s)", total, runs)))
|
||
out = append(out, dimStyle().Render(" costs come from Paperclip; nothing is added up twice"))
|
||
return out
|
||
}
|
||
|
||
func (m projectModel) settingLines() []string {
|
||
out := []string{"", groupHeading("this project's own file", 6, m.width-4)}
|
||
out = append(out, m.row("session", m.cfg["session"]))
|
||
out = append(out, m.row("machine", m.cfg["machine.host"]))
|
||
out = append(out, m.row("folder", m.cfg["machine.folder"]))
|
||
out = append(out, m.row("paperclip", m.cfg["paperclip.project"]))
|
||
out = append(out, m.row("watching", m.cfg["watching"]))
|
||
out = append(out, m.row("verify command", m.verifyCommand()))
|
||
out = append(out, "")
|
||
out = append(out, dimStyle().Render(" .harness/harness.toml is the only thing this project keeps for itself."))
|
||
out = append(out, dimStyle().Render(" Paperclip owns tasks, blockers, comments, runs, cost and worktrees."))
|
||
out = append(out, "")
|
||
out = append(out, groupHeading("limits held by Paperclip", len(m.agents), m.width-4))
|
||
for _, a := range m.agents {
|
||
out = append(out, m.row(a.Name, fmt.Sprintf("%s · monthly budget %d cents · spent %d",
|
||
a.Status, a.BudgetCents, a.SpentCents)))
|
||
}
|
||
out = append(out, "", dimStyle().Render(" change a budget in Paperclip, not here: it is their fact, and a second"))
|
||
out = append(out, dimStyle().Render(" copy of it is how two screens start disagreeing."))
|
||
return out
|
||
}
|
||
|
||
func (m projectModel) verifyCommand() string {
|
||
issues, err := m.client.Issues()
|
||
if err == nil {
|
||
for _, i := range issues {
|
||
if i.ProjectID == m.project.ID {
|
||
if cmd := verifyFromDescription(i.Description); cmd != "" {
|
||
return cmd + " (from " + i.Identifier + ")"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
path := m.folder + "/.harness/verify.sh"
|
||
if _, err := os.Stat(path); err == nil {
|
||
return ".harness/verify.sh"
|
||
}
|
||
return "none agreed: this would pass on an agent's word"
|
||
}
|
||
|
||
var _ = exec.Command
|
||
|
||
// registryRow is one adopted project, from the file bin/office writes.
|
||
type registryRow struct {
|
||
name, folder, host, session, id string
|
||
}
|
||
|
||
// readRegistry lists adopted projects. It is the only thing this program keeps
|
||
// besides the project file: where a folder is, and on which machine.
|
||
func readRegistry() []registryRow {
|
||
home, _ := os.UserHomeDir()
|
||
path := filepath.Join(home, ".config", "paperclip-harness", "projects.toml")
|
||
b, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
var rows []registryRow
|
||
cur := map[string]string{}
|
||
id := ""
|
||
flush := func() {
|
||
if id != "" && cur["folder"] != "" {
|
||
rows = append(rows, registryRow{
|
||
name: cur["name"], folder: cur["folder"], host: cur["host"],
|
||
session: cur["session"], id: id,
|
||
})
|
||
}
|
||
cur = map[string]string{}
|
||
}
|
||
for _, line := range strings.Split(string(b), "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if strings.HasPrefix(line, "[projects.") {
|
||
flush()
|
||
id = strings.Trim(strings.TrimPrefix(line, "[projects."), "]'\"")
|
||
continue
|
||
}
|
||
parts := strings.SplitN(line, "=", 2)
|
||
if len(parts) != 2 {
|
||
continue
|
||
}
|
||
cur[strings.TrimSpace(parts[0])] = strings.Trim(strings.TrimSpace(parts[1]), "\"'")
|
||
}
|
||
flush()
|
||
return rows
|
||
}
|
||
|
||
// projectListLines shows every adopted project and where it lives.
|
||
func (m projectModel) projectListLines(width int) []string {
|
||
rows := readRegistry()
|
||
out := []string{"", dimStyle().Render(fmt.Sprintf(" %-26s %-12s %-14s %s", "project", "machine", "session", "folder"))}
|
||
me, _ := os.Hostname()
|
||
for i, r := range rows {
|
||
here := ""
|
||
if r.host == me || r.host == strings.Split(me, ".")[0] {
|
||
here = " (this machine)"
|
||
}
|
||
line := fmt.Sprintf(" %-26s %-12s %-14s %s%s",
|
||
truncateVisible(r.name, 26), r.host, truncateVisible(r.session, 14),
|
||
truncateVisible(r.folder, max(10, width-60)), here)
|
||
if i == m.regAt {
|
||
out = append(out, selectedStyle2().Render(padTo("▸"+line[1:], width)))
|
||
continue
|
||
}
|
||
out = append(out, line)
|
||
}
|
||
if len(rows) == 0 {
|
||
out = append(out, " "+mutedStyle2().Render("none adopted yet: bin/office <folder> adds one"))
|
||
}
|
||
out = append(out, "")
|
||
out = append(out, dimStyle().Render(" enter switches your TUIOS client to that project's session."))
|
||
out = append(out, dimStyle().Render(" a session on another machine switches only when that machine is in your"))
|
||
out = append(out, dimStyle().Render(" ~/.config/tuios hosts table; otherwise attach to it by hand."))
|
||
return out
|
||
}
|
||
|
||
// gitLines is the git tab: the folder read locally, then what Paperclip knows
|
||
// about each task's worktree. The two halves answer different questions and are
|
||
// not merged into one verdict.
|
||
func (m projectModel) gitLines(width int) []string {
|
||
var out []string
|
||
g := m.git
|
||
|
||
report := func(label, value string, bad bool) {
|
||
line := " " + mutedStyle2().Render(fmt.Sprintf("%-14s", label))
|
||
style := lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text))
|
||
if bad {
|
||
style = style.Foreground(lipgloss.Color(active.Warning))
|
||
}
|
||
out = append(out, line+style.Render(value))
|
||
}
|
||
|
||
// --- the folder ---
|
||
out = append(out, mutedStyle2().Render(" THIS FOLDER"))
|
||
if !g.IsRepo {
|
||
report("repository", "no — this folder is not a git repository", true)
|
||
} else {
|
||
branch := g.Branch
|
||
if branch == "" {
|
||
branch = "(no branch yet)"
|
||
}
|
||
report("repository", "yes · branch "+branch, false)
|
||
report("commits", fmt.Sprintf("%d", g.Commits), !g.HasCommit)
|
||
if g.Commits == 0 {
|
||
out = append(out, " no commit, so nothing to cut a worktree from")
|
||
}
|
||
remotes := "none — not needed for worktrees"
|
||
if len(g.Remotes) > 0 {
|
||
remotes = strings.Join(g.Remotes, ", ")
|
||
}
|
||
report("remote", remotes, false)
|
||
report("changed", fmt.Sprintf("%d staged · %d unstaged · %d untracked",
|
||
g.Staged, g.Unstaged, g.Untracked), false)
|
||
if g.HasIgnore {
|
||
shown := strings.Join(g.Ignore, " ")
|
||
if len(shown) > width-24 {
|
||
shown = shown[:width-25] + "…"
|
||
}
|
||
if shown == "" {
|
||
shown = "(no entries)"
|
||
}
|
||
report(".gitignore", shown, !g.ignoresPaperclipWorktrees())
|
||
} else {
|
||
report(".gitignore", "missing — .paperclip/ must be in it", true)
|
||
}
|
||
}
|
||
out = append(out, "")
|
||
|
||
// --- what Paperclip will do with each task ---
|
||
policy := "not set"
|
||
if p := m.project.ExecutionWorkspacePolicy; p != nil && p.Enabled {
|
||
policy = p.DefaultMode
|
||
if p.Strategy.Type != "" {
|
||
policy += " · " + p.Strategy.Type
|
||
}
|
||
if p.Strategy.Base != "" {
|
||
policy += " · base " + p.Strategy.Base
|
||
}
|
||
}
|
||
report("worktrees", policy, m.project.ExecutionWorkspacePolicy == nil ||
|
||
(m.project.ExecutionWorkspacePolicy != nil && m.project.ExecutionWorkspacePolicy.Strategy.Type != "git_worktree"))
|
||
out = append(out, "")
|
||
|
||
// --- what to do, when something is wrong ---
|
||
if !g.ready() || !g.ignoresPaperclipWorktrees() {
|
||
out = append(out, mutedStyle2().Render(" WHAT TO DO"))
|
||
folder := m.folder
|
||
if !g.ignoresPaperclipWorktrees() {
|
||
out = append(out, " .paperclip/ is not ignored: a commit would swallow every worktree.")
|
||
out = append(out, " "+dimStyle().Render("t lazygit · select .gitignore · e to edit · add .paperclip/"))
|
||
out = append(out, "")
|
||
}
|
||
if !g.IsRepo {
|
||
out = append(out, " This folder is not a repository, so no task can have its own branch.")
|
||
out = append(out, " "+dimStyle().Render("cd "+folder+" && git init -b main"))
|
||
out = append(out, " "+dimStyle().Render("then commit something — in lazygit: t, then c"))
|
||
} else if !g.HasCommit {
|
||
out = append(out, " This repository has no commit, so there is nothing to cut a worktree from.")
|
||
out = append(out, " "+dimStyle().Render("t lazygit · space to stage · c to commit"))
|
||
}
|
||
out = append(out, "")
|
||
out = append(out, " Then run "+keyHint("R", "rebuild")+" so every pane is built against the new state.")
|
||
out = append(out, "")
|
||
}
|
||
|
||
// --- Paperclip's own scan of the task worktrees ---
|
||
out = append(out, mutedStyle2().Render(" WORKTREES, AS PAPERCLIP SEES THEM"))
|
||
if len(m.wts) == 0 {
|
||
out = append(out, dimStyle().Render(" none yet — one is made when a task runs"))
|
||
}
|
||
for _, w := range m.wts {
|
||
state := "clean"
|
||
if w.Dirty > 0 {
|
||
state = fmt.Sprintf("%d changed", w.Dirty)
|
||
}
|
||
extra := ""
|
||
if w.Ahead > 0 {
|
||
extra += fmt.Sprintf(" · %d ahead", w.Ahead)
|
||
}
|
||
if w.Behind > 0 {
|
||
extra += fmt.Sprintf(" · %d behind", w.Behind)
|
||
}
|
||
if w.Merged {
|
||
extra += " · merged into base"
|
||
}
|
||
branch := w.Branch
|
||
if branch == "" {
|
||
branch = "(no branch)"
|
||
}
|
||
if len(branch) > width-34 {
|
||
branch = branch[:width-35] + "…"
|
||
}
|
||
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Text)).Render(branch)+
|
||
dimStyle().Render(" "+state+extra))
|
||
if w.Warning != "" {
|
||
out = append(out, " "+lipgloss.NewStyle().Foreground(lipgloss.Color(active.Warning)).Render(w.Warning))
|
||
}
|
||
}
|
||
out = append(out, "")
|
||
out = append(out, dimStyle().Render(" read from git in this folder, and from Paperclip's scan of each worktree"))
|
||
return out
|
||
}
|