188 lines
5.1 KiB
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 ""
|
|
}
|