In windows opened by netssh multi, prefix S toggles broadcast for all panes and prefix e excludes the current pane (a pane-level synchronize-panes off, shown as [SOLO] in the border) or includes it again. netssh sync --pane does the same from a shell. S and e are unbound in a default tmux (E would be "spread evenly"). netssh writes the bindings to a small tmux config and sources it, binds a key only if the user's tmux.conf does not use it (netssh's own earlier binding is refreshed), and the bindings act only in windows marked @netssh. multi.sync_key and multi.solo_key change the keys or turn them off (none); also in the configurator. zellij syncs whole tabs only, so excluding a pane is tmux-only. testenv: the render test presses the keys through the xterm client (send-keys -K) and checks [SOLO], that broadcast skips the excluded pane, that S toggles, and that other windows are unaffected. The client gets GNU grep: BusyBox grep -oF counted only one match per line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
93 lines
2.9 KiB
Go
93 lines
2.9 KiB
Go
package config
|
||
|
||
import (
|
||
"fmt"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// MultiConfig controls netssh multi.
|
||
type MultiConfig struct {
|
||
// Multiplexer is auto, tmux, or zellij; empty means auto.
|
||
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
|
||
|
||
// ValidateMulti checks groups and the multi settings.
|
||
func (c *Config) ValidateMulti() error {
|
||
switch c.Multi.Multiplexer {
|
||
case "", "auto", "tmux", "zellij":
|
||
default:
|
||
return fmt.Errorf("multi.multiplexer must be auto, tmux, or zellij")
|
||
}
|
||
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)
|
||
}
|
||
sort.Strings(names)
|
||
for _, name := range names {
|
||
if !instanceName.MatchString(name) {
|
||
return fmt.Errorf("groups: name %q must contain only letters, digits, underscores, or hyphens", name)
|
||
}
|
||
if len(c.Groups[name]) == 0 {
|
||
return fmt.Errorf("groups.%s: needs at least one host or selector", name)
|
||
}
|
||
for _, selector := range c.Groups[name] {
|
||
if strings.TrimSpace(selector) == "" || strings.ContainsAny(selector, " \t\n") || strings.HasPrefix(selector, "-") {
|
||
return fmt.Errorf("groups.%s: invalid entry %q", name, selector)
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// PanesPerWindowOrDefault returns the configured value or the default.
|
||
func (m MultiConfig) PanesPerWindowOrDefault() int {
|
||
if m.PanesPerWindow > 0 {
|
||
return m.PanesPerWindow
|
||
}
|
||
return DefaultPanesPerWindow
|
||
}
|