Files
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

129 lines
3.9 KiB
Go

package setup
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"charm.land/huh/v2"
"git.zb-server.de/Sebi/ssh-netbox-wrapper/internal/config"
)
func editMulti(cfg *config.Config) error {
multiplexer := cfg.Multi.Multiplexer
if multiplexer == "" {
multiplexer = "auto"
}
perWindow := ""
if cfg.Multi.PanesPerWindow > 0 {
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").
Description("netssh multi opens hosts side by side. auto uses the one you are in, else tmux, else zellij.").
Options(huh.NewOption("auto", "auto"), huh.NewOption("tmux", "tmux"), huh.NewOption("zellij", "zellij")).
Value(&multiplexer),
huh.NewInput().
Title("Panes per window").
Description(fmt.Sprintf("More hosts open in further windows or tabs. Leave empty for %d.", config.DefaultPanesPerWindow)).
Value(&perWindow).
Validate(func(s string) error {
if s = strings.TrimSpace(s); s == "" {
return nil
}
if n, err := strconv.Atoi(s); err != nil || n < 1 || n > 64 {
return errors.New("must be a number between 1 and 64")
}
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.").
Lines(4).
Value(&groups).
Validate(func(s string) error {
_, err := parseGroups(s)
return err
}),
).Title("Multi-session")).Run()
if err != nil {
return err
}
cfg.Multi.Multiplexer = multiplexer
if multiplexer == "auto" {
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
}
// parseGroups reads "NAME: sel1, sel2" lines into groups:.
func parseGroups(s string) (map[string][]string, error) {
var out map[string][]string
for _, line := range parseLines(s) {
name, members, ok := strings.Cut(line, ":")
name = strings.ToLower(strings.TrimSpace(name))
// "tag:x" alone has no group name before the first colon worth keeping.
if !ok || name == "" || name == "tag" || name == "cluster" {
return nil, fmt.Errorf("%q: expected NAME: host, tag:TAG, …", line)
}
var selectors []string
for _, field := range strings.FieldsFunc(members, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' }) {
selectors = append(selectors, field)
}
if out == nil {
out = make(map[string][]string)
}
out[name] = append(out[name], selectors...)
}
if err := (&config.Config{Groups: out}).ValidateMulti(); err != nil {
return nil, err
}
return out, nil
}
func formatGroups(groups map[string][]string) string {
names := make([]string, 0, len(groups))
for name := range groups {
names = append(names, name)
}
sort.Strings(names)
lines := make([]string, 0, len(names))
for _, name := range names {
lines = append(lines, name+": "+strings.Join(groups[name], ", "))
}
return strings.Join(lines, "\n")
}