// Package multiplex opens several commands side by side in tmux or zellij. package multiplex import ( "errors" "fmt" "io" "os" "os/exec" "strings" "syscall" ) // Pane is one command shown in its own pane. type Pane struct { Title string Argv []string } // Options control how panes are laid out. type Options struct { Label string // window, session, and tab name Sync bool // type into all panes of a window at once PerWindow int // panes per tmux window or zellij tab LayoutDir string // where zellij layout files and tmux key bindings are written // SyncKey and SoloKey are tmux prefix keys that toggle broadcast for the // window and exclude the current pane from it; empty means no binding. SyncKey, SoloKey string // Hint receives notes for the user before the process is replaced. Hint func(string) } // Runner executes multiplexer commands; tests and --dry-run replace it. type Runner interface { // Output runs argv and returns its trimmed standard output. Output(argv ...string) (string, error) // Exec replaces the current process with argv. Exec(argv ...string) error LookPath(name string) (string, error) Getenv(key string) string WriteFile(path string, data []byte) error } // Supported multiplexers. const ( Tmux = "tmux" Zellij = "zellij" ) // ErrNone means neither tmux nor zellij is available. var ErrNone = errors.New("netssh multi needs tmux or zellij; install one of them (e.g. apt install tmux)") // Detect picks the multiplexer: the requested one, else the one netssh runs // in, else tmux, else zellij. func Detect(requested string, r Runner) (string, error) { switch requested { case Tmux, Zellij: if _, err := r.LookPath(requested); err != nil { return "", fmt.Errorf("%s is not installed", requested) } return requested, nil case "", "auto": default: return "", fmt.Errorf("unknown multiplexer %q", requested) } if r.Getenv("TMUX") != "" { return Tmux, nil } if r.Getenv("ZELLIJ") != "" { return Zellij, nil } for _, name := range []string{Tmux, Zellij} { if _, err := r.LookPath(name); err == nil { return name, nil } } return "", ErrNone } // Open shows panes in the multiplexer. Outside of it, Open replaces the // process with the multiplexer client and only returns on error. func Open(kind string, panes []Pane, opts Options, r Runner) error { if len(panes) == 0 { return errors.New("no hosts to open") } if opts.Hint == nil { opts.Hint = func(string) {} } if opts.PerWindow <= 0 { opts.PerWindow = 9 } if opts.Label == "" { opts.Label = "netssh" } switch kind { case Tmux: return openTmux(panes, opts, r) case Zellij: return openZellij(panes, opts, r) } return fmt.Errorf("unknown multiplexer %q", kind) } // ToggleSync switches broadcast input in the current tmux window or zellij // tab; with pane it excludes the current tmux pane from it or includes it again. func ToggleSync(r Runner, pane bool) (string, error) { if pane { if r.Getenv("TMUX") == "" { if r.Getenv("ZELLIJ") != "" { return "", errors.New("zellij broadcasts to a whole tab; single panes cannot be excluded") } return "", errors.New("netssh sync --pane works inside tmux") } solo, err := r.Output("tmux", "display-message", "-p", "#{@netssh_solo}") if err != nil { return "", err } for _, argv := range soloToggle(solo == "1") { if _, err := r.Output(argv...); err != nil { return "", err } } if solo == "1" { return "pane included in broadcast", nil } return "pane excluded from broadcast", nil } switch { case r.Getenv("TMUX") != "": current, err := r.Output("tmux", "show-window-options", "-v", "synchronize-panes") if err != nil { return "", err } next := "on" if current == "on" { next = "off" } if _, err := r.Output("tmux", "set-window-option", "synchronize-panes", next); err != nil { return "", err } return "broadcast " + next, nil case r.Getenv("ZELLIJ") != "": if _, err := r.Output("zellij", "action", "toggle-active-sync-tab"); err != nil { return "", err } return "broadcast toggled", nil } return "", errors.New("netssh sync works inside tmux or zellij") } // chunks splits panes into groups of at most n. func chunks(panes []Pane, n int) [][]Pane { var out [][]Pane for len(panes) > n { out = append(out, panes[:n]) panes = panes[n:] } return append(out, panes) } // windowLabel numbers windows when the panes need more than one. func windowLabel(label string, i, total int) string { if total == 1 { return label } return fmt.Sprintf("%s-%d", label, i+1) } // System runs real commands. type System struct{} func (System) Output(argv ...string) (string, error) { cmd := exec.Command(argv[0], argv[1:]...) var stderr strings.Builder cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { if msg := strings.TrimSpace(stderr.String()); msg != "" { return "", fmt.Errorf("%s: %s", argv[0], msg) } return "", fmt.Errorf("%s: %w", argv[0], err) } return strings.TrimSpace(string(out)), nil } func (System) Exec(argv ...string) error { path, err := exec.LookPath(argv[0]) if err != nil { return err } return syscall.Exec(path, argv, os.Environ()) } func (System) LookPath(name string) (string, error) { return exec.LookPath(name) } func (System) Getenv(key string) string { return os.Getenv(key) } func (System) WriteFile(path string, data []byte) error { return os.WriteFile(path, data, 0o600) } // DryRun prints commands instead of running them; Output returns fake IDs. type DryRun struct { W io.Writer Env map[string]string // values for Getenv, e.g. TMUX n int } func (d *DryRun) Output(argv ...string) (string, error) { fmt.Fprintln(d.W, shellJoin(argv)) d.n++ switch { case len(argv) > 1 && (argv[1] == "new-window" || argv[1] == "new-session"): return fmt.Sprintf("@%d %%%d", d.n, d.n), nil case len(argv) > 1 && argv[1] == "split-window": return fmt.Sprintf("%%%d", d.n), nil case len(argv) > 1 && argv[1] == "has-session": return "", errors.New("no session") } return "", nil } func (d *DryRun) Exec(argv ...string) error { fmt.Fprintln(d.W, shellJoin(argv)) return nil } // LookPath pretends every multiplexer is installed, so --dry-run shows the // commands on machines without tmux or zellij. func (d *DryRun) LookPath(name string) (string, error) { return name, nil } func (d *DryRun) Getenv(key string) string { if v, ok := d.Env[key]; ok { return v } return os.Getenv(key) } func (d *DryRun) WriteFile(path string, data []byte) error { fmt.Fprintf(d.W, "# %s\n%s", path, data) return nil } // shellJoin quotes argv for display. func shellJoin(argv []string) string { quoted := make([]string, len(argv)) for i, arg := range argv { if arg != "" && strings.Trim(arg, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@%+=:,./_-") == "" { quoted[i] = arg continue } quoted[i] = "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" } return strings.Join(quoted, " ") }