feat: netssh multi — several hosts side by side in tmux or zellij
netssh multi opens one pane per host in a tmux window or zellij tab. Hosts come from names, tag:TAG (NetBox and static hosts), cluster:NAME (NetBox virtualization clusters; devices by default, --kind vm|all), and groups from the config (@name, nested, with cycle detection). Every pane runs netssh HOST, so lookup, rules, and jump hosts apply. - tmux: a new window inside tmux, else a new session that is attached; tiled layout, pane borders with the host and a [SYNC] marker. - zellij: a generated grid layout; a new tab inside zellij, else a new session. - --sync broadcasts typing to all panes; netssh sync toggles it in the current tmux window or zellij tab. - TUI: Space marks hosts, Ctrl+A marks all, Enter opens them together. - Configurator: multiplexer, panes per window, groups. NetBox SearchAll gains a cluster filter (name to cluster IDs per instance), and cache entries keep the host's cluster for completion and an offline fallback. Panes skip the 24-hour background refresh (checked once by multi) and get HOME/XDG paths but no NETSSH_* values, which would expose tokens in the process list. testenv/tests/15-multi.sh checks real tmux and zellij sessions, including a broadcast command arriving on all three cluster nodes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dbb99bdded
commit
a31aab4110
24 files changed
+1754
-40
No files matched your search
@@ -0,0 +1,226 @@
|
||||
// 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 are written
|
||||
// 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.
|
||||
func ToggleSync(r Runner) (string, error) {
|
||||
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, " ")
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package multiplex
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fake records commands and answers like tmux and zellij would.
|
||||
type fake struct {
|
||||
env map[string]string
|
||||
installed map[string]bool
|
||||
sessions map[string]bool
|
||||
calls []string
|
||||
exec []string
|
||||
files map[string]string
|
||||
syncState string
|
||||
n int
|
||||
}
|
||||
|
||||
func newFake(env map[string]string, installed ...string) *fake {
|
||||
f := &fake{env: env, installed: map[string]bool{}, sessions: map[string]bool{}, files: map[string]string{}, syncState: "off"}
|
||||
for _, name := range installed {
|
||||
f.installed[name] = true
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) Output(argv ...string) (string, error) {
|
||||
f.calls = append(f.calls, strings.Join(argv, " "))
|
||||
f.n++
|
||||
switch argv[1] {
|
||||
case "new-window", "new-session":
|
||||
return fmt.Sprintf("@%d %%%d", f.n, f.n), nil
|
||||
case "split-window":
|
||||
return fmt.Sprintf("%%%d", f.n), nil
|
||||
case "has-session":
|
||||
if f.sessions[strings.TrimPrefix(argv[3], "=")] {
|
||||
return "", nil
|
||||
}
|
||||
return "", errors.New("can't find session")
|
||||
case "show-window-options":
|
||||
return f.syncState, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
func (f *fake) Exec(argv ...string) error { f.exec = argv; return nil }
|
||||
func (f *fake) LookPath(name string) (string, error) {
|
||||
if f.installed[name] {
|
||||
return "/usr/bin/" + name, nil
|
||||
}
|
||||
return "", errors.New("not found")
|
||||
}
|
||||
func (f *fake) Getenv(key string) string { return f.env[key] }
|
||||
func (f *fake) WriteFile(path string, data []byte) error {
|
||||
f.files[path] = string(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
func panes(names ...string) []Pane {
|
||||
out := make([]Pane, 0, len(names))
|
||||
for _, name := range names {
|
||||
out = append(out, Pane{Title: name, Argv: []string{"/usr/bin/netssh", name}})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestDetect(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
requested string
|
||||
env map[string]string
|
||||
installed []string
|
||||
want string
|
||||
}{
|
||||
{"", map[string]string{"TMUX": "/tmp/tmux"}, []string{"tmux", "zellij"}, Tmux},
|
||||
{"auto", map[string]string{"ZELLIJ": "0"}, []string{"tmux", "zellij"}, Zellij},
|
||||
{"", nil, []string{"tmux", "zellij"}, Tmux},
|
||||
{"", nil, []string{"zellij"}, Zellij},
|
||||
{"zellij", map[string]string{"TMUX": "x"}, []string{"tmux", "zellij"}, Zellij},
|
||||
} {
|
||||
got, err := Detect(tc.requested, newFake(tc.env, tc.installed...))
|
||||
if err != nil || got != tc.want {
|
||||
t.Errorf("%+v: got %q %v", tc, got, err)
|
||||
}
|
||||
}
|
||||
if _, err := Detect("", newFake(nil)); !errors.Is(err, ErrNone) {
|
||||
t.Errorf("nothing installed: %v", err)
|
||||
}
|
||||
if _, err := Detect("tmux", newFake(nil, "zellij")); err == nil {
|
||||
t.Error("missing requested multiplexer accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxInside(t *testing.T) {
|
||||
f := newFake(map[string]string{"TMUX": "/tmp/tmux-1/default"}, "tmux")
|
||||
if err := Open(Tmux, panes("a", "b", "c"), Options{Label: "pve", Sync: true}, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := "sh -c " + paneScript + " netssh-pane /usr/bin/netssh "
|
||||
want := []string{
|
||||
"tmux new-window -P -F #{window_id} #{pane_id} -n pve " + script + "a",
|
||||
"tmux split-window -t @1 -P -F #{pane_id} " + script + "b",
|
||||
"tmux select-layout -t @1 tiled",
|
||||
"tmux split-window -t @1 -P -F #{pane_id} " + script + "c",
|
||||
"tmux select-layout -t @1 tiled",
|
||||
"tmux set-option -p -t %1 @netssh_host a",
|
||||
"tmux set-option -p -t %2 @netssh_host b",
|
||||
"tmux set-option -p -t %4 @netssh_host c",
|
||||
"tmux set-window-option -t @1 pane-border-status top",
|
||||
"tmux set-window-option -t @1 pane-border-format #{@netssh_host}#{?pane_synchronized, [SYNC],} ",
|
||||
"tmux set-window-option -t @1 synchronize-panes on",
|
||||
"tmux select-window -t @1",
|
||||
}
|
||||
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("calls:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
if f.exec != nil {
|
||||
t.Fatal("inside tmux must not attach")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxOutsideSplitsWindowsAndAvoidsSessionNames(t *testing.T) {
|
||||
f := newFake(nil, "tmux")
|
||||
f.sessions["netssh-pve"] = true
|
||||
var hints []string
|
||||
err := Open(Tmux, panes("a", "b", "c"), Options{Label: "pve", PerWindow: 2, Hint: func(s string) { hints = append(hints, s) }}, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joined := strings.Join(f.calls, "\n")
|
||||
for _, want := range []string{
|
||||
"tmux has-session -t =netssh-pve-2",
|
||||
"tmux new-session -d -x 240 -y 70 -s netssh-pve-2 -P -F #{window_id} #{pane_id} -n pve-1 ",
|
||||
"tmux new-window -t netssh-pve-2: -P -F #{window_id} #{pane_id} -n pve-2 ",
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("missing %q in\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
if strings.Contains(joined, "synchronize-panes") {
|
||||
t.Error("sync set without --sync")
|
||||
}
|
||||
if strings.Join(f.exec, " ") != "tmux attach-session -t netssh-pve-2" || len(hints) != 1 {
|
||||
t.Fatalf("exec %v hints %v", f.exec, hints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZellijOutsideWritesGridLayout(t *testing.T) {
|
||||
f := newFake(nil, "zellij")
|
||||
if err := Open(Zellij, panes("a", "b", "c", `q"x`), Options{Label: "pve", LayoutDir: "/tmp/l"}, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layout := f.files["/tmp/l/netssh-multi.kdl"]
|
||||
for _, want := range []string{
|
||||
`plugin location="zellij:tab-bar"`,
|
||||
`tab name="pve" focus=true {`,
|
||||
`pane split_direction="vertical" {`,
|
||||
`pane name="a" command="/usr/bin/netssh" {`,
|
||||
`args "a"`,
|
||||
`pane name="q\"x" command="/usr/bin/netssh" {`,
|
||||
} {
|
||||
if !strings.Contains(layout, want) {
|
||||
t.Errorf("layout lacks %q:\n%s", want, layout)
|
||||
}
|
||||
}
|
||||
// 4 panes → 2 columns × 2 rows.
|
||||
if n := strings.Count(layout, `split_direction="vertical"`); n != 2 {
|
||||
t.Errorf("%d rows, want 2:\n%s", n, layout)
|
||||
}
|
||||
if strings.Join(f.exec, " ") != "zellij --layout /tmp/l/netssh-multi.kdl" {
|
||||
t.Fatalf("exec %v", f.exec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZellijInsideAddsTabs(t *testing.T) {
|
||||
f := newFake(map[string]string{"ZELLIJ": "0"}, "zellij")
|
||||
if err := Open(Zellij, panes("a", "b", "c"), Options{Label: "pve", PerWindow: 2, Sync: true, LayoutDir: "/tmp/l"}, f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
"zellij action new-tab --layout /tmp/l/netssh-multi-1.kdl",
|
||||
"zellij action toggle-active-sync-tab",
|
||||
"zellij action new-tab --layout /tmp/l/netssh-multi-2.kdl",
|
||||
"zellij action toggle-active-sync-tab",
|
||||
}
|
||||
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("calls:\n%s", strings.Join(f.calls, "\n"))
|
||||
}
|
||||
if !strings.Contains(f.files["/tmp/l/netssh-multi-2.kdl"], `tab name="pve-2"`) {
|
||||
t.Fatalf("second tab: %s", f.files["/tmp/l/netssh-multi-2.kdl"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleSync(t *testing.T) {
|
||||
f := newFake(map[string]string{"TMUX": "x"})
|
||||
if state, err := ToggleSync(f); err != nil || state != "broadcast on" || f.calls[1] != "tmux set-window-option synchronize-panes on" {
|
||||
t.Fatalf("%q %v %v", state, err, f.calls)
|
||||
}
|
||||
f.syncState = "on"
|
||||
if state, _ := ToggleSync(f); state != "broadcast off" {
|
||||
t.Fatalf("%q", state)
|
||||
}
|
||||
z := newFake(map[string]string{"ZELLIJ": "0"})
|
||||
if _, err := ToggleSync(z); err != nil || z.calls[0] != "zellij action toggle-active-sync-tab" {
|
||||
t.Fatalf("%v %v", err, z.calls)
|
||||
}
|
||||
if _, err := ToggleSync(newFake(nil)); err == nil {
|
||||
t.Fatal("outside a multiplexer accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package multiplex
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// paneScript runs the pane's command and keeps a failed one on screen, so an
|
||||
// unreachable host shows its error instead of the pane vanishing.
|
||||
const paneScript = `"$@"; status=$?; if [ "$status" -ne 0 ]; then echo; echo "netssh exited with status $status, press Enter to close"; read -r _; fi`
|
||||
|
||||
// tmuxArgv runs the pane through sh with argv as arguments: no quoting, and
|
||||
// independent of the user's default-shell (which may be fish).
|
||||
func tmuxArgv(p Pane) []string {
|
||||
return append([]string{"sh", "-c", paneScript, "netssh-pane"}, p.Argv...)
|
||||
}
|
||||
|
||||
// openTmux adds a window per chunk of panes: to the current session inside
|
||||
// tmux, else to a new detached session that is attached at the end.
|
||||
func openTmux(panes []Pane, opts Options, r Runner) error {
|
||||
inside := r.Getenv("TMUX") != ""
|
||||
session := ""
|
||||
if !inside {
|
||||
session = freeSession(r, "netssh-"+opts.Label)
|
||||
}
|
||||
groups := chunks(panes, opts.PerWindow)
|
||||
first := ""
|
||||
for i, group := range groups {
|
||||
name := windowLabel(opts.Label, i, len(groups))
|
||||
format := []string{"-P", "-F", "#{window_id} #{pane_id}", "-n", name}
|
||||
var create []string
|
||||
switch {
|
||||
case inside:
|
||||
create = append([]string{"tmux", "new-window"}, format...)
|
||||
case i == 0:
|
||||
// A large virtual size leaves room for the splits; attaching resizes it.
|
||||
create = append([]string{"tmux", "new-session", "-d", "-x", "240", "-y", "70", "-s", session}, format...)
|
||||
default:
|
||||
create = append([]string{"tmux", "new-window", "-t", session + ":"}, format...)
|
||||
}
|
||||
out, err := r.Output(append(create, tmuxArgv(group[0])...)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
window, pane, _ := strings.Cut(out, " ")
|
||||
if first == "" {
|
||||
first = window
|
||||
}
|
||||
paneIDs := []string{pane}
|
||||
for _, p := range group[1:] {
|
||||
id, err := r.Output(append([]string{"tmux", "split-window", "-t", window, "-P", "-F", "#{pane_id}"}, tmuxArgv(p)...)...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
paneIDs = append(paneIDs, id)
|
||||
// Re-tile after every split so the next one still has room.
|
||||
if _, err := r.Output("tmux", "select-layout", "-t", window, "tiled"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for j, id := range paneIDs {
|
||||
// A pane may already be gone (host unreachable and closed); not fatal.
|
||||
_, _ = r.Output("tmux", "set-option", "-p", "-t", id, "@netssh_host", group[j].Title)
|
||||
}
|
||||
windowOptions := [][]string{
|
||||
{"pane-border-status", "top"},
|
||||
{"pane-border-format", " #{@netssh_host}#{?pane_synchronized, [SYNC],} "},
|
||||
}
|
||||
if opts.Sync {
|
||||
windowOptions = append(windowOptions, []string{"synchronize-panes", "on"})
|
||||
}
|
||||
for _, option := range windowOptions {
|
||||
if _, err := r.Output("tmux", "set-window-option", "-t", window, option[0], option[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if inside {
|
||||
_, err := r.Output("tmux", "select-window", "-t", first)
|
||||
return err
|
||||
}
|
||||
opts.Hint("toggle broadcast with: netssh sync (or Ctrl+b :setw synchronize-panes)")
|
||||
return r.Exec("tmux", "attach-session", "-t", session)
|
||||
}
|
||||
|
||||
// freeSession returns name, or name-2, name-3, … if a session already uses it.
|
||||
func freeSession(r Runner, name string) string {
|
||||
candidate := name
|
||||
for n := 2; ; n++ {
|
||||
if _, err := r.Output("tmux", "has-session", "-t", "="+candidate); err != nil {
|
||||
return candidate
|
||||
}
|
||||
candidate = name + "-" + strconv.Itoa(n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package multiplex
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// openZellij writes a layout with one tab per chunk of panes. Inside zellij
|
||||
// each tab is added to the current session; outside, a new session starts.
|
||||
func openZellij(panes []Pane, opts Options, r Runner) error {
|
||||
groups := chunks(panes, opts.PerWindow)
|
||||
if r.Getenv("ZELLIJ") != "" {
|
||||
for i, group := range groups {
|
||||
path := filepath.Join(opts.LayoutDir, fmt.Sprintf("netssh-multi-%d.kdl", i+1))
|
||||
tab := zellijTab(windowLabel(opts.Label, i, len(groups)), group, true)
|
||||
if err := r.WriteFile(path, []byte(zellijLayout(tab))); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.Output("zellij", "action", "new-tab", "--layout", path); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Sync {
|
||||
if _, err := r.Output("zellij", "action", "toggle-active-sync-tab"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var tabs strings.Builder
|
||||
for i, group := range groups {
|
||||
tabs.WriteString(zellijTab(windowLabel(opts.Label, i, len(groups)), group, i == 0))
|
||||
}
|
||||
path := filepath.Join(opts.LayoutDir, "netssh-multi.kdl")
|
||||
if err := r.WriteFile(path, []byte(zellijLayout(tabs.String()))); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Sync {
|
||||
opts.Hint("zellij starts without broadcast: press Ctrl+t, then s, to type into all panes")
|
||||
} else {
|
||||
opts.Hint("toggle broadcast with Ctrl+t, then s (or netssh sync)")
|
||||
}
|
||||
return r.Exec("zellij", "--layout", path)
|
||||
}
|
||||
|
||||
// zellijLayout wraps tabs with zellij's tab and status bars.
|
||||
func zellijLayout(tabs string) string {
|
||||
return `layout {
|
||||
default_tab_template {
|
||||
pane size=1 borderless=true {
|
||||
plugin location="zellij:tab-bar"
|
||||
}
|
||||
children
|
||||
pane size=2 borderless=true {
|
||||
plugin location="zellij:status-bar"
|
||||
}
|
||||
}
|
||||
` + tabs + "}\n"
|
||||
}
|
||||
|
||||
// zellijTab arranges panes in a grid: rows of ceil(sqrt(n)) columns.
|
||||
func zellijTab(name string, panes []Pane, focus bool) string {
|
||||
cols := int(math.Ceil(math.Sqrt(float64(len(panes)))))
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, " tab name=%s", kdlString(name))
|
||||
if focus {
|
||||
b.WriteString(" focus=true")
|
||||
}
|
||||
b.WriteString(" {\n")
|
||||
for start := 0; start < len(panes); start += cols {
|
||||
row := panes[start:min(start+cols, len(panes))]
|
||||
indent := " "
|
||||
if len(row) > 1 {
|
||||
b.WriteString(indent + "pane split_direction=\"vertical\" {\n")
|
||||
indent += " "
|
||||
}
|
||||
for _, p := range row {
|
||||
fmt.Fprintf(&b, "%spane name=%s command=%s {\n", indent, kdlString(p.Title), kdlString(p.Argv[0]))
|
||||
if len(p.Argv) > 1 {
|
||||
args := make([]string, 0, len(p.Argv)-1)
|
||||
for _, arg := range p.Argv[1:] {
|
||||
args = append(args, kdlString(arg))
|
||||
}
|
||||
fmt.Fprintf(&b, "%s args %s\n", indent, strings.Join(args, " "))
|
||||
}
|
||||
b.WriteString(indent + "}\n")
|
||||
}
|
||||
if len(row) > 1 {
|
||||
b.WriteString(" }\n")
|
||||
}
|
||||
}
|
||||
b.WriteString(" }\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// kdlString quotes s as a KDL string.
|
||||
func kdlString(s string) string {
|
||||
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`).Replace(s) + `"`
|
||||
}
|
||||
Reference in new issue
Block a user