Files
ssh-netbox-wrapper/internal/config/multi.go
T
Sebastian UnterschützandClaude Opus 5.5 396d489187 feat(multi): tmux keys to toggle broadcast and exclude single panes
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>
2026-11-14 13:32:32 +01:00

93 lines
2.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}