Files
paperclip-harness/tui/toolpane.go

188 lines
5.1 KiB
Go

package main
// Tool panes: any terminal program this interface does not cover, opened beside
// what you were reading and closed when you are done with it.
//
// Nothing is rebuilt. Lazygit, Hunk, Yazi, Neovim, fzf and ocr are the tools
// people already use, and a pane is the correct place for them: they keep their
// own keys, their own colours and their own way of working.
//
// A tool pane is not part of the layout. It is created on demand in the
// workspace you are in and it disappears when the program exits, so the eight
// workspaces stay as they are.
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
)
type tool struct {
name string
command string
about string
}
// tools are offered in this order: the shell first because it can reach
// anything, then the three that read code, then the review.
var tools = []tool{
{"shell", "exec zsh -i", "a prompt in the project folder"},
{"lazygit", "lazygit", "branches, commits, staging, history"},
{"hunk", "hunk", "a change read as a review"},
{"yazi", "yazi", "files"},
{"nvim", "nvim", "editing one file"},
{"fzf", "fzf", "picking anything"},
// A review that finds nothing exits in a second, which looks like a broken
// pane. It is given a last line so the output can be read before closing.
{"ocr", "ocr review --color never; printf '\\npress enter to close\\n'; read -r _", "the review of the current change"},
}
// currentWorkspace finds the workspace this program is drawn in. TUIOS knows;
// the environment names the pane, and the pane's window says which workspace.
func currentWorkspace() (string, string, error) {
session := os.Getenv("TUIOS_SESSION")
pane := os.Getenv("TUIOS_PANE_ID")
if session == "" || pane == "" {
return "", "", fmt.Errorf("this is not running inside a TUIOS pane")
}
out, err := exec.Command("tuios", "list-windows", "-s", session, "--json").Output()
if err != nil {
return "", "", fmt.Errorf("cannot ask TUIOS where this pane is: %w", err)
}
ws := workspaceOf(string(out), pane)
if ws == "" {
return "", "", fmt.Errorf("this pane is not in the layout TUIOS reports")
}
return session, ws, nil
}
// workspaceOf finds the workspace number of one window in the JSON TUIOS
// prints. It decodes properly rather than scanning text: a pane id appears in
// more than one place, and the first match is not always the window.
func workspaceOf(raw, paneID string) string {
var doc struct {
Windows []struct {
WindowID string `json:"window_id"`
Workspace int `json:"workspace"`
} `json:"windows"`
}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return ""
}
for _, w := range doc.Windows {
if w.WindowID == paneID {
return strconv.Itoa(w.Workspace)
}
}
return ""
}
// openTool starts a program as a new pane in the current workspace.
func openTool(folder, name string) error {
var chosen *tool
for i := range tools {
if tools[i].name == name {
chosen = &tools[i]
}
}
if chosen == nil {
return fmt.Errorf("no tool called %q", name)
}
session, ws, err := currentWorkspace()
if err != nil {
return err
}
log, _ := os.Create(filepath.Join(os.TempDir(), "paperclip-harness-tool.log"))
// A pane started this way gets a smaller PATH than an interactive shell, so
// the places a person's tools actually live are put back before the tool
// runs. Without this, ocr is "command not found" inside its own pane.
prelude := `export PATH="$HOME/.local/bin:$HOME/.nix-profile/bin:` +
`/run/current-system/sw/bin:/etc/profiles/per-user/$USER/bin:$PATH"; `
cmd := exec.Command("tuios", "new-window", chosen.name, "-s", session,
"--workspace", ws, "--cwd", folder, "--", "/bin/sh", "-c", prelude+chosen.command)
cmd.Stdout = log
cmd.Stderr = log
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
return fmt.Errorf("cannot open %s: %w", chosen.name, err)
}
go func() {
cmd.Wait()
if log != nil {
log.Close()
}
}()
return nil
}
// --- the smallest JSON reader that does this one job -------------------------
type scanner struct {
s string
pos int
}
func newScanner(s string) *scanner { return &scanner{s: s} }
// nextObject returns the next {...} block, or "" at the end.
func (sc *scanner) nextObject() string {
for sc.pos < len(sc.s) && sc.s[sc.pos] != '{' {
sc.pos++
}
if sc.pos >= len(sc.s) {
return ""
}
depth := 0
start := sc.pos
for sc.pos < len(sc.s) {
switch sc.s[sc.pos] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
sc.pos++
return sc.s[start:sc.pos]
}
}
sc.pos++
}
return ""
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
// fieldNumber reads "name": 12 out of an object.
func fieldNumber(obj, name string) string {
key := `"` + name + `":`
for i := 0; i+len(key) <= len(obj); i++ {
if obj[i:i+len(key)] != key {
continue
}
j := i + len(key)
for j < len(obj) && (obj[j] == ' ' || obj[j] == '"') {
j++
}
start := j
for j < len(obj) && obj[j] >= '0' && obj[j] <= '9' {
j++
}
if _, err := strconv.Atoi(obj[start:j]); err == nil {
return obj[start:j]
}
}
return ""
}