netssh multi: mehrere Server in einem Fenster (tmux/zellij) #37

Merged
Sebi merged 3 commits from feat/multi into main 2026-11-14 12:37:29 +00:00
14 changed files with 342 additions and 31 deletions
Showing only changes of commit 396d489187 - Show all commits

No files matched your search

+2 -1
View File
@@ -6,9 +6,10 @@
- Fix `netssh sftp` and `netssh scp` with instance-qualified hosts (`prod/web01:/path`), which were taken for local paths.
- Fix shell aliases of instance-qualified hosts ignoring anchored rewrite rules (`fsn_fsn1-pve1` instead of `fsn_fsnpve1`).
- Add `netssh multi` to open several hosts side by side in tmux or zellij: host lists, `tag:`, NetBox virtualization clusters (`cluster:`, devices by default, `--kind vm|all`), and groups from the config (`@name`). `--sync` sends typing to all panes; `netssh sync` toggles it later. In the TUI, `Space` marks hosts and `Enter` opens them together. Configurable in `netssh configure` (multiplexer, panes per window, groups).
- tmux keys for broadcast in `netssh multi` windows: prefix `S` toggles all panes, prefix `e` excludes the current pane (border `[SOLO]`) or includes it again; also `netssh sync --pane`. Both keys are unbound in a default tmux, are not bound if your tmux.conf uses them, act only in netssh windows, and can be changed or turned off (`multi.sync_key`, `multi.solo_key`).
- `netssh multi` widens the status bar of its own tmux session, which tmux otherwise cuts to `[netssh-pv`.
- Cache entries remember the NetBox cluster of a host, for `cluster:` completion and offline use.
- Add `testenv/`, a local Docker environment with two NetBox instances, SSH targets, a jump host, tmux, zellij, and bash/zsh/fish, and 178 end-to-end checks of all features, including `netssh multi` with real tmux and zellij and screenshot checks of what a real terminal (xterm) renders (`testenv/up.sh`, `testenv/test.sh`). Not part of CI/CD.
- Add `testenv/`, a local Docker environment with two NetBox instances, SSH targets, a jump host, tmux, zellij, and bash/zsh/fish, and 185 end-to-end checks of all features, including `netssh multi` with real tmux and zellij and screenshot checks of what a real terminal (xterm) renders (`testenv/up.sh`, `testenv/test.sh`). Not part of CI/CD.
## v0.0.23
+13 -6
View File
@@ -150,7 +150,7 @@ A menu then gives access to every other setting; open only the sections you want
| SSH options | `proxy_jump`, `host_key_checking`, `target`, `address_family`, `extra_args` (one per line), `options` (`Key=Value` per line), `forwards` (`NAME L\|R\|D SPEC` per line) |
| SSH rules | add, edit, remove `ssh.rules` — match by tag, instance, kind, name pattern, each with its own SSH options |
| Static hosts | add, edit, remove hosts that are not in NetBox, each with user, port, tags, and SSH options |
| Multi-session | multiplexer (auto, tmux, zellij), panes per window, groups (`NAME: sel1, sel2` per line) |
| Multi-session | multiplexer (auto, tmux, zellij), panes per window, tmux keys for broadcast and excluding a pane, groups (`NAME: sel1, sel2` per line) |
| Resolver & cache | strategies, management subnets, interface name, hostname domain, cache TTL, cache file |
| Shortcuts & command alias | domain suffixes, hyphen stripping, rewrite rules (`REGEX => REPLACEMENT` per line), `shell.alias` |
| Completion | `completion.max_results` |
@@ -237,6 +237,7 @@ Every key, its default, and the section that explains it. Keys marked *layered*
| `groups` | map of lists | `{}` | Named host lists for `netssh multi @NAME`; entries are hosts or selectors. |
| `multi.multiplexer` | `auto` \| `tmux` \| `zellij` | `auto` | Which multiplexer `netssh multi` uses. |
| `multi.panes_per_window` | int | `9` | More hosts open in further tmux windows or zellij tabs. |
| `multi.sync_key`, `multi.solo_key` | tmux key \| `none` | `S`, `e` | Keys after the tmux prefix: broadcast to all panes, exclude/include the current pane. |
### Environment variables
@@ -366,7 +367,7 @@ A hint is shown during `netssh configure` if a legacy v1 token is entered.
| `netssh tunnel [netssh flags] [ssh options] HOST FORWARD...` | Port forwards without a shell — see [Port forwarding](#port-forwarding). |
| `netssh hosts [list] \| add NAME ADDRESS [--user U] [--port P] [--tag T]... [--replace] \| remove NAME` | Manage static hosts — see [Static hosts](#static-hosts). |
| `netssh multi [--sync] [--kind K] [--tmux\|--zellij] [-l U] [--jump H\|--no-jump] HOST\|tag:T\|cluster:C\|@GROUP...` | Several hosts side by side — see [Multiple servers](#multiple-servers-in-one-window). |
| `netssh sync` | Toggle typing into all panes of the current tmux window or zellij tab. |
| `netssh sync [--pane]` | Toggle typing into all panes of the current tmux window or zellij tab; `--pane` excludes/includes the current tmux pane. |
| `netssh configure` | Setup wizard. |
| `netssh cache list \| refresh [--tag T] [--kind device\|vm] [--instance I] \| clear` | Inspect and manage the host cache. |
| `netssh search QUERY` | Print matching cached hostnames, one per line. |
@@ -581,6 +582,8 @@ groups:
multi:
multiplexer: auto # auto | tmux | zellij
panes_per_window: 9 # more hosts open in further windows/tabs
sync_key: S # tmux: prefix S = broadcast on/off (none = no key)
solo_key: e # tmux: prefix e = exclude/include this pane
```
**tmux and zellij.** With `auto`, netssh uses the multiplexer you are in; otherwise tmux, then zellij, whichever is installed (`--tmux` / `--zellij` choose explicitly).
@@ -590,7 +593,11 @@ multi:
| Inside a session | new window in the current session | new tab in the current session |
| Outside | new session `netssh-NAME`, attached | new session from a generated layout |
| Layout | tiled; pane borders show the host | grid; pane titles show the host |
| Broadcast | `--sync`, or `netssh sync` / `Ctrl+b :setw synchronize-panes` to toggle; borders show `[SYNC]` | `--sync` inside zellij, or `Ctrl+t` then `s` (also `netssh sync`) |
| Broadcast to all panes | `--sync`; toggle with `Ctrl+b S` or `netssh sync` | `--sync` inside zellij; toggle with `Ctrl+t` then `s` or `netssh sync` |
| Exclude one pane | `Ctrl+b e` (again: include) or `netssh sync --pane` | not possible — zellij syncs whole tabs |
| Shown in the border | `[SYNC]` receives broadcast, `[SOLO]` excluded | `(SYNC)` in the tab name |
**Keys in tmux.** `S` and `e` are free in a default tmux (`E` would be "spread panes evenly"). netssh binds them after your prefix when it opens panes, but only if your own `tmux.conf` does not already use them — then it keeps your binding and says so. They act only in windows opened by `netssh multi`; elsewhere they just show a message. Change or turn them off with `multi.sync_key` / `multi.solo_key` (`none`), e.g. `sync_key: C-s` for `prefix Ctrl+s`.
**In the TUI**, mark hosts with `Space` (or all listed hosts with `Ctrl+A`) and press `Enter` to open them together.
@@ -882,8 +889,8 @@ netssh fsnbx1 # also works as shell alias: fsnbx1
```sh
netssh multi --sync cluster:pve-fsn1 # every node in one window, typing goes to all
# … run apt upgrade on all nodes, then turn broadcast off for per-node work:
netssh sync # from another pane, or Ctrl+b :setw synchronize-panes
# … run apt upgrade on all nodes; Ctrl+b e on a node that needs something
# different excludes just that one, Ctrl+b S turns broadcast off for all
```
**Deploy a file and restart a service**
@@ -1122,7 +1129,7 @@ End-to-end tests run against real services in a local Docker environment — two
```sh
testenv/up.sh # build and start (first start ~6 min)
testenv/test.sh # 178 checks: connect, strategies, instances, shortcuts, rules, sftp/scp, tunnels, notes, shells, TUI, configurator, multi with tmux and zellij, and what xterm renders (screenshots + OCR)
testenv/test.sh # 185 checks: connect, strategies, instances, shortcuts, rules, sftp/scp, tunnels, notes, shells, TUI, configurator, multi with tmux and zellij, and what xterm renders (screenshots + OCR)
testenv/down.sh
```
+16 -4
View File
@@ -83,12 +83,19 @@ netssh sync. Inside tmux or zellij the panes open in the current session.`,
}
func syncCmd() *cobra.Command {
return &cobra.Command{
Use: "sync",
var pane bool
cmd := &cobra.Command{
Use: "sync [--pane]",
Short: "Toggle typing into all panes of the current tmux window or zellij tab",
Args: cobra.NoArgs,
Long: `Toggle broadcast in the current tmux window or zellij tab. With --pane,
exclude the current tmux pane from broadcast, or include it again.
In windows opened by netssh multi, tmux also has keys for this:
prefix S (all panes) and prefix e (this pane); see multi.sync_key and
multi.solo_key. In zellij, use Ctrl+t then s.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
state, err := multiplex.ToggleSync(multiplex.System{})
state, err := multiplex.ToggleSync(multiplex.System{}, pane)
if err != nil {
return err
}
@@ -96,6 +103,8 @@ func syncCmd() *cobra.Command {
return nil
},
}
cmd.Flags().BoolVar(&pane, "pane", false, "Exclude the current tmux pane from broadcast, or include it again")
return cmd
}
// runMulti expands selectors and opens one pane per host.
@@ -147,7 +156,10 @@ func runMulti(cfg *config.Config, selectors []string, f multiFlags) error {
if err := os.MkdirAll(layoutDir, 0o700); err != nil {
return err
}
syncKey, soloKey := cfg.Multi.Keys()
return multiplex.Open(kind, panes, multiplex.Options{
SyncKey: syncKey,
SoloKey: soloKey,
Label: multiLabel(selectors),
Sync: f.sync,
PerWindow: cfg.Multi.PanesPerWindowOrDefault(),
+35
View File
@@ -2,6 +2,7 @@ package config
import (
"fmt"
"regexp"
"sort"
"strings"
)
@@ -12,8 +13,34 @@ type MultiConfig struct {
Multiplexer string `mapstructure:"multiplexer"`
// PanesPerWindow splits larger host lists into more windows or tabs; 0 = 9.
PanesPerWindow int `mapstructure:"panes_per_window"`
// SyncKey and SoloKey are tmux prefix keys for broadcast to all panes and
// for excluding the current pane; empty = default (S, e), "none" = off.
SyncKey string `mapstructure:"sync_key"`
SoloKey string `mapstructure:"solo_key"`
}
// Default tmux keys: unbound in a stock tmux (E would be "spread evenly").
const (
DefaultSyncKey = "S"
DefaultSoloKey = "e"
)
// Keys returns the tmux keys to bind; "" for a disabled one.
func (m MultiConfig) Keys() (sync, solo string) {
pick := func(key, fallback string) string {
switch key {
case "":
return fallback
case "none":
return ""
}
return key
}
return pick(m.SyncKey, DefaultSyncKey), pick(m.SoloKey, DefaultSoloKey)
}
var tmuxKey = regexp.MustCompile(`^([CMS]-)*([A-Za-z0-9]|F[0-9]{1,2}|[-_=+!@#$%^&*,./?<>\[\]]|Tab|Space|Enter|BSpace|Up|Down|Left|Right|Home|End|PPage|NPage)$`)
// DefaultPanesPerWindow keeps panes readable on a normal screen (3×3).
const DefaultPanesPerWindow = 9
@@ -27,6 +54,14 @@ func (c *Config) ValidateMulti() error {
if c.Multi.PanesPerWindow < 0 || c.Multi.PanesPerWindow > 64 {
return fmt.Errorf("multi.panes_per_window must be between 1 and 64")
}
for name, key := range map[string]string{"sync_key": c.Multi.SyncKey, "solo_key": c.Multi.SoloKey} {
if key != "" && key != "none" && !tmuxKey.MatchString(key) {
return fmt.Errorf("multi.%s %q is not a tmux key (e.g. S, e, C-s, F5) or none", name, key)
}
}
if sync, solo := c.Multi.Keys(); sync != "" && sync == solo {
return fmt.Errorf("multi.sync_key and multi.solo_key must differ")
}
names := make([]string, 0, len(c.Groups))
for name := range c.Groups {
names = append(names, name)
+9 -1
View File
@@ -196,7 +196,15 @@ func TestMultiConfig(t *testing.T) {
if err != nil || len(cfg.Groups["k8s"]) != 2 || cfg.Multi.Multiplexer != "zellij" || cfg.Multi.PanesPerWindowOrDefault() != 9 {
t.Fatalf("%+v %v", cfg, err)
}
for _, bad := range []string{"multi: {multiplexer: screen}", "multi: {panes_per_window: 100}", "groups: {web: []}", "groups: {web: [\"a b\"]}"} {
if sync, solo := cfg.Multi.Keys(); sync != "S" || solo != "e" {
t.Fatalf("default keys %q %q", sync, solo)
}
custom, err := loadYAML(t, "multi: {sync_key: C-s, solo_key: none}\n")
if sync, solo := custom.Multi.Keys(); err != nil || sync != "C-s" || solo != "" {
t.Fatalf("custom keys %q %q %v", sync, solo, err)
}
for _, bad := range []string{"multi: {multiplexer: screen}", "multi: {panes_per_window: 100}", "groups: {web: []}", "groups: {web: [\"a b\"]}",
"multi: {sync_key: \"x y\"}", "multi: {sync_key: \"{\"}", "multi: {sync_key: e}"} {
if _, err := loadYAML(t, bad+"\n"); err == nil {
t.Errorf("%s accepted", bad)
}
+28 -3
View File
@@ -22,7 +22,10 @@ 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
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)
}
@@ -98,8 +101,30 @@ func Open(kind string, panes []Pane, opts Options, r Runner) error {
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) {
// 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")
+75 -6
View File
@@ -16,6 +16,8 @@ type fake struct {
exec []string
files map[string]string
syncState string
keys map[string]string // existing prefix bindings by key
solo string
n int
}
@@ -42,6 +44,10 @@ func (f *fake) Output(argv ...string) (string, error) {
return "", errors.New("can't find session")
case "show-window-options":
return f.syncState, nil
case "list-keys":
return f.keys[argv[4]], nil
case "display-message":
return f.solo, nil
}
return "", nil
}
@@ -94,7 +100,7 @@ func TestDetect(t *testing.T) {
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 {
if err := Open(Tmux, panes("a", "b", "c"), Options{Label: "pve", Sync: true, SyncKey: "S", SoloKey: "e", LayoutDir: "/tmp/l"}, f); err != nil {
t.Fatal(err)
}
script := "sh -c " + paneScript + " netssh-pane /usr/bin/netssh "
@@ -107,9 +113,14 @@ func TestTmuxInside(t *testing.T) {
"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 @netssh 1",
"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 pane-border-format #{@netssh_host}#{?pane_synchronized, [SYNC],#{?@netssh_solo, [SOLO],}} ",
"tmux set-window-option -t @1 synchronize-panes on",
"tmux show-options -gv prefix",
"tmux list-keys -T prefix S",
"tmux list-keys -T prefix e",
"tmux source-file /tmp/l/netssh-multi.tmux.conf",
"tmux select-window -t @1",
}
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
@@ -195,18 +206,76 @@ func TestZellijInsideAddsTabs(t *testing.T) {
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" {
if state, err := ToggleSync(f, false); 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" {
if state, _ := ToggleSync(f, false); 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" {
if _, err := ToggleSync(z, false); 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 {
if _, err := ToggleSync(newFake(nil), false); err == nil {
t.Fatal("outside a multiplexer accepted")
}
}
func TestTmuxKeyBindings(t *testing.T) {
f := newFake(map[string]string{"TMUX": "x"}, "tmux")
var hint string
if err := Open(Tmux, panes("a"), Options{SyncKey: "S", SoloKey: "e", LayoutDir: "/tmp/l", Hint: func(s string) { hint = s }}, f); err != nil {
t.Fatal(err)
}
conf := f.files["/tmp/l/netssh-multi.tmux.conf"]
for _, want := range []string{"bind-key -T prefix S if-shell -F '#{@netssh}'", "bind-key -T prefix e if-shell", "set-option -p synchronize-panes off"} {
if !strings.Contains(conf, want) {
t.Errorf("conf lacks %q:\n%s", want, conf)
}
}
if hint != "keys: C-b S broadcast to all panes, C-b e exclude/include this pane" {
t.Errorf("hint %q", hint)
}
// A key bound in the user's tmux.conf is left alone; netssh's own is replaced.
f = newFake(map[string]string{"TMUX": "x"}, "tmux")
f.keys = map[string]string{"S": "bind-key -T prefix S kill-server", "e": "bind-key -T prefix e if-shell -F #{@netssh} …"}
if err := Open(Tmux, panes("a"), Options{SyncKey: "S", SoloKey: "e", LayoutDir: "/tmp/l", Hint: func(s string) { hint = s }}, f); err != nil {
t.Fatal(err)
}
conf = f.files["/tmp/l/netssh-multi.tmux.conf"]
if strings.Contains(conf, "prefix S ") || !strings.Contains(conf, "prefix e ") {
t.Errorf("user binding overwritten or own binding not refreshed:\n%s", conf)
}
if !strings.Contains(hint, "C-b S kept your own binding") {
t.Errorf("hint %q", hint)
}
// No keys configured: nothing is bound.
f = newFake(map[string]string{"TMUX": "x"}, "tmux")
if err := Open(Tmux, panes("a"), Options{LayoutDir: "/tmp/l"}, f); err != nil {
t.Fatal(err)
}
if len(f.files) != 0 || strings.Contains(strings.Join(f.calls, "\n"), "source-file") {
t.Errorf("bound keys although disabled: %v", f.calls)
}
}
func TestToggleSyncPane(t *testing.T) {
f := newFake(map[string]string{"TMUX": "x"})
if state, err := ToggleSync(f, true); err != nil || state != "pane excluded from broadcast" {
t.Fatalf("%q %v", state, err)
}
if got := strings.Join(f.calls[1:], "; "); got != "tmux set-option -p synchronize-panes off; tmux set-option -p @netssh_solo 1" {
t.Fatalf("exclude: %s", got)
}
f = newFake(map[string]string{"TMUX": "x"})
f.solo = "1"
if state, _ := ToggleSync(f, true); state != "pane included in broadcast" || f.calls[1] != "tmux set-option -pu synchronize-panes" {
t.Fatalf("include: %q %v", state, f.calls)
}
if _, err := ToggleSync(newFake(map[string]string{"ZELLIJ": "0"}), true); err == nil || !strings.Contains(err.Error(), "whole tab") {
t.Fatalf("zellij: %v", err)
}
}
+94 -2
View File
@@ -1,6 +1,8 @@
package multiplex
import (
"fmt"
"path/filepath"
"strconv"
"strings"
)
@@ -70,8 +72,9 @@ func openTmux(panes []Pane, opts Options, r Runner) error {
_, _ = r.Output("tmux", "set-option", "-p", "-t", id, "@netssh_host", group[j].Title)
}
windowOptions := [][]string{
{"@netssh", "1"}, // the key bindings act only in these windows
{"pane-border-status", "top"},
{"pane-border-format", " #{@netssh_host}#{?pane_synchronized, [SYNC],} "},
{"pane-border-format", " #{@netssh_host}#{?pane_synchronized, [SYNC],#{?@netssh_solo, [SOLO],}} "},
}
if opts.Sync {
windowOptions = append(windowOptions, []string{"synchronize-panes", "on"})
@@ -82,14 +85,103 @@ func openTmux(panes []Pane, opts Options, r Runner) error {
}
}
}
keys, err := bindKeys(r, opts)
if err != nil {
return err
}
if keys != "" {
opts.Hint(keys)
}
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)
}
// soloToggle excludes the current pane from broadcast (a pane-level
// synchronize-panes off) or includes it again by removing that override.
func soloToggle(solo bool) [][]string {
if solo {
return [][]string{
{"tmux", "set-option", "-pu", "synchronize-panes"},
{"tmux", "set-option", "-pu", "@netssh_solo"},
}
}
return [][]string{
{"tmux", "set-option", "-p", "synchronize-panes", "off"},
{"tmux", "set-option", "-p", "@netssh_solo", "1"},
}
}
// keyBindings toggle broadcast in netssh multi windows; elsewhere they only
// show a message, so they do not change what the keys mean to other windows.
const syncBinding = `bind-key -T prefix %s if-shell -F '#{@netssh}' {
set-window-option synchronize-panes
display-message 'netssh: broadcast #{?synchronize-panes,on,off}'
} {
display-message 'netssh: not a netssh multi window'
}
`
const soloBinding = `bind-key -T prefix %s if-shell -F '#{@netssh}' {
if-shell -F '#{@netssh_solo}' {
set-option -pu synchronize-panes
set-option -pu @netssh_solo
display-message 'netssh: pane included in broadcast'
} {
set-option -p synchronize-panes off
set-option -p @netssh_solo 1
display-message 'netssh: pane excluded from broadcast'
}
} {
display-message 'netssh: not a netssh multi window'
}
`
// bindKeys installs the broadcast keys in the tmux server unless the user's
// own configuration already uses them, and returns a hint naming the keys.
func bindKeys(r Runner, opts Options) (string, error) {
prefix, err := r.Output("tmux", "show-options", "-gv", "prefix")
if err != nil || prefix == "" {
prefix = "C-b"
}
var conf strings.Builder
var hints, taken []string
for _, key := range []struct{ key, binding, what string }{
{opts.SyncKey, syncBinding, "broadcast to all panes"},
{opts.SoloKey, soloBinding, "exclude/include this pane"},
} {
if key.key == "" {
continue
}
// An existing binding is replaced only if netssh made it.
if existing, _ := r.Output("tmux", "list-keys", "-T", "prefix", key.key); existing != "" && !strings.Contains(existing, "@netssh") {
taken = append(taken, prefix+" "+key.key)
continue
}
fmt.Fprintf(&conf, key.binding, key.key)
hints = append(hints, prefix+" "+key.key+" "+key.what)
}
if conf.Len() > 0 {
path := filepath.Join(opts.LayoutDir, "netssh-multi.tmux.conf")
if err := r.WriteFile(path, []byte(conf.String())); err != nil {
return "", err
}
if _, err := r.Output("tmux", "source-file", path); err != nil {
return "", err
}
}
hint := "keys: " + strings.Join(hints, ", ")
if len(hints) == 0 {
hint = "toggle broadcast with: netssh sync, netssh sync --pane"
}
if len(taken) > 0 {
hint += "; " + strings.Join(taken, " and ") + " kept your own binding (use netssh sync)"
}
return hint, nil
}
// freeSession returns name, or name-2, name-3, … if a session already uses it.
func freeSession(r Runner, name string) string {
candidate := name
+26
View File
@@ -22,6 +22,18 @@ func editMulti(cfg *config.Config) error {
perWindow = strconv.Itoa(cfg.Multi.PanesPerWindow)
}
groups := formatGroups(cfg.Groups)
syncKey, soloKey := cfg.Multi.SyncKey, cfg.Multi.SoloKey
validKey := func(field string) func(string) error {
return func(s string) error {
candidate := config.Config{Multi: cfg.Multi}
if field == "sync" {
candidate.Multi.SyncKey = strings.TrimSpace(s)
} else {
candidate.Multi.SoloKey = strings.TrimSpace(s)
}
return candidate.ValidateMulti()
}
}
err := huh.NewForm(huh.NewGroup(
huh.NewSelect[string]().
Title("Multiplexer").
@@ -41,6 +53,18 @@ func editMulti(cfg *config.Config) error {
}
return nil
}),
huh.NewInput().
Title("tmux key: broadcast to all panes").
Description(fmt.Sprintf("Pressed after the tmux prefix. Empty = %s, none = no key.", config.DefaultSyncKey)).
Placeholder(config.DefaultSyncKey).
Value(&syncKey).
Validate(validKey("sync")),
huh.NewInput().
Title("tmux key: exclude/include this pane").
Description(fmt.Sprintf("Pressed after the tmux prefix. Empty = %s, none = no key.", config.DefaultSoloKey)).
Placeholder(config.DefaultSoloKey).
Value(&soloKey).
Validate(validKey("solo")),
huh.NewText().
Title("Groups").
Description("One per line: NAME: host, tag:TAG, cluster:NAME, @other — open with netssh multi @NAME.").
@@ -59,6 +83,8 @@ func editMulti(cfg *config.Config) error {
cfg.Multi.Multiplexer = ""
}
cfg.Multi.PanesPerWindow, _ = strconv.Atoi(strings.TrimSpace(perWindow))
cfg.Multi.SyncKey = strings.TrimSpace(syncKey)
cfg.Multi.SoloKey = strings.TrimSpace(soloKey)
cfg.Groups, _ = parseGroups(groups)
return nil
}
+6 -1
View File
@@ -482,7 +482,7 @@ func save(cfg config.Config) error {
fmt.Fprintf(&b, " %s: [%s]\n", name, strings.Join(quoted, ", "))
}
}
if cfg.Multi.Multiplexer != "" || cfg.Multi.PanesPerWindow > 0 {
if cfg.Multi != (config.MultiConfig{}) {
b.WriteString("\nmulti:\n")
if cfg.Multi.Multiplexer != "" {
fmt.Fprintf(&b, " multiplexer: %s\n", cfg.Multi.Multiplexer)
@@ -490,6 +490,11 @@ func save(cfg config.Config) error {
if cfg.Multi.PanesPerWindow > 0 {
fmt.Fprintf(&b, " panes_per_window: %d\n", cfg.Multi.PanesPerWindow)
}
for _, key := range [][2]string{{"sync_key", cfg.Multi.SyncKey}, {"solo_key", cfg.Multi.SoloKey}} {
if key[1] != "" {
fmt.Fprintf(&b, " %s: %q\n", key[0], key[1])
}
}
}
if cfg.Completion.MaxResults > 0 {
+5 -1
View File
@@ -90,7 +90,11 @@ xterm on a virtual X server (Xvfb) — takes screenshots, and reads them back
with OCR (tesseract). It checks what is actually drawn: remote prompts in all
panes, borders and titles with the host names, `[SYNC]` appearing and
disappearing, panes side by side, broadcast output in every pane, input in one
pane only without broadcast, the status bar, and the TUI marks.
pane only without broadcast, the status bar, and the TUI marks. The broadcast
keys are pressed as a user would: `tmux send-keys -K` delivers them to the
xterm client, so they pass through the prefix and key tables (`prefix e` →
`[SOLO]` and the pane drops out of the broadcast; `prefix S` toggles all panes;
other windows stay unaffected).
```sh
testenv/test.sh render # then look at the screenshots:
+1 -1
View File
@@ -14,7 +14,7 @@ FROM alpine:3.23
# xvfb/xterm/imagemagick/tesseract: render panes in a real terminal emulator,
# take screenshots, and read them back with OCR (tests/16-render.sh).
RUN apk add --no-cache openssh-client-default tmux zellij bash zsh fish bash-completion \
curl python3 jq ncurses coreutils \
curl python3 jq ncurses coreutils grep \
xvfb xterm imagemagick tesseract-ocr tesseract-ocr-data-eng font-dejavu \
&& adduser -D -s /bin/bash tester
COPY --from=build /out/netssh /usr/local/bin/netssh
+28 -1
View File
@@ -32,13 +32,34 @@ visible "tmux: borders show host and [SYNC]" tmux-1 "$p1 [SYNC]" "$p2 [SYNC]" "$
side_by_side tmux-1 "$p1" "$p2" && ok "tmux: tiled, first two panes side by side" || bad "tmux: tiled, first two panes side by side" "$(screen_text tmux-1)"
tmux send-keys -t netssh-pve "echo typed-once-\$(hostname)" Enter
visible "tmux: broadcast output in every pane" tmux-2 "typed-once-fsn1-pve1" "typed-once-fsn1-pve2" "typed-once-fsn1-pve3"
tmux set-window-option -t netssh-pve synchronize-panes off
# Keys as typed in xterm (send-keys -K goes through the client's prefix and
# key tables): prefix e excludes the active pane, prefix S toggles all panes.
client=$(tmux list-clients -t netssh-pve -F '#{client_name}' | head -1)
press() { tmux send-keys -K -c "$client" C-b "$1"; sleep 0.5; }
panes=($(tmux list-panes -t netssh-pve -F '#{pane_id}'))
tmux select-pane -t "${panes[2]}"
press e
visible "tmux: prefix e marks the pane [SOLO]" tmux-solo "$p3 [SOLO]" "$p1 [SYNC]" "$p2 [SYNC]"
tmux send-keys -t "${panes[2]}" "echo solo-only" Enter
tmux send-keys -t "${panes[0]}" "echo two-panes" Enter
sleep 1
shot tmux-solo-2
[ "$(screen_count tmux-solo-2 "solo-only")" = 2 ] && ok "tmux: the excluded pane types alone" || bad "tmux: the excluded pane types alone" "$(screen_text tmux-solo-2)"
[ "$(screen_count tmux-solo-2 "two-panes")" = 4 ] && ok "tmux: broadcast skips the excluded pane" || bad "tmux: broadcast skips the excluded pane" "$(screen_text tmux-solo-2)"
press e
visible "tmux: prefix e again includes it" tmux-solo-3 "$p3 [SYNC]"
press S
sync_is() { [ "$(tmux show-window-options -t netssh-pve -v synchronize-panes 2>/dev/null)" = "$1" ]; }
wait_for 5 sync_is off && ok "tmux: prefix S turns broadcast off" || bad "tmux: prefix S turns broadcast off" "$(tmux show-window-options -t netssh-pve)"
tmux send-keys -t netssh-pve "echo just-this-pane" Enter
sleep 1
shot tmux-3
[ "$(screen_count tmux-3 "just-this-pane")" = 2 ] && ok "tmux: without broadcast only one pane gets input" || bad "tmux: without broadcast only one pane gets input" "$(screen_text tmux-3)"
shows_sync() { screen_shows tmux-3 "[SYNC]"; }
shows_sync && bad "tmux: [SYNC] disappears when broadcast is off" "$(screen_text tmux-3)" || ok "tmux: [SYNC] disappears when broadcast is off"
press S
wait_for 5 sync_is on && ok "tmux: prefix S turns it on again" || bad "tmux: prefix S turns it on again" "$(tmux show-window-options -t netssh-pve)"
tmux kill-server
# --- more hosts than panes_per_window: further windows ---
@@ -64,6 +85,12 @@ zellij kill-all-sessions --yes >/dev/null 2>&1
# --- TUI: mark hosts, open them together ---
term tmux new-session -s tui netssh
visible "TUI: search screen" tui-1 "Search hostname"
# The netssh keys do nothing outside netssh multi windows.
tmux source-file "$XDG_CACHE_HOME/netssh/netssh-multi.tmux.conf"
client=$(tmux list-clients -t tui -F '#{client_name}' | head -1)
tmux send-keys -K -c "$client" C-b S
sleep 0.5
[ "$(tmux show-window-options -t tui -v synchronize-panes 2>/dev/null)" != on ] && ok "tmux: prefix S leaves other windows alone" || bad "tmux: prefix S leaves other windows alone" "synchronize-panes turned on in a non-netssh window"
tmux send-keys -t tui "pve"
visible "TUI: results listed" tui-2 "$p1" "$p2" "$p3"
tmux send-keys -t tui C-a
+4 -4
View File
@@ -124,10 +124,10 @@ screen_text() {
for i in "${!OCR_VARIANTS[@]}"; do ocr_variant "$1" "$i"; done
}
# ocr_norm maps characters OCR confuses in DejaVu Sans Mono (1/l/I/|, C/( , 0/O)
# to one form; expected text gets the same mapping, so comparisons stay strict
# about everything else.
ocr_norm() { tr 'lI|(O' '111C0'; }
# ocr_norm maps characters OCR confuses in DejaVu Sans Mono (1/l/I/|, C/( , 0/O,
# typographic dashes for "-") to one form; expected text gets the same mapping,
# so comparisons stay strict about everything else.
ocr_norm() { sed 's/[‐‑‒–—―−]/-/g' | tr 'lI|(O' '111C0'; }
# screen_shows NAME TEXT... — take a screenshot and check that OCR finds every TEXT.
screen_shows() {