Work>review and tool panes; panes re-sync their size; the Work pane follows the Board again
This commit is contained in:
180
tui/toolpane.go
Normal file
180
tui/toolpane.go
Normal file
@@ -0,0 +1,180 @@
|
||||
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"},
|
||||
{"ocr", "ocr review", "the review of the current change, in the terminal"},
|
||||
}
|
||||
|
||||
// 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"))
|
||||
|
||||
cmd := exec.Command("tuios", "new-window", chosen.name, "-s", session,
|
||||
"--workspace", ws, "--cwd", folder, "--", "/bin/sh", "-c", 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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user