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:
Sebastian UnterschützandClaude Opus 5.5 committed 2026-11-14 12:45:05 +01:00
1 parent dbb99bdded
commit a31aab4110
24 files changed
+1754 -40

No files matched your search

+1
View File
@@ -21,6 +21,7 @@ type Entry struct {
IP string `json:"ip"`
Kind string `json:"kind"`
Tags []string `json:"tags,omitempty"`
Cluster string `json:"cluster,omitempty"`
CachedAt time.Time `json:"cached_at"`
LastUsed time.Time `json:"last_used,omitempty"`
// Static marks a host from the config's hosts: list. It never expires;
+7 -1
View File
@@ -20,6 +20,9 @@ type Config struct {
Shortcuts ShortcutsConfig `mapstructure:"shortcuts"`
Completion CompletionConfig `mapstructure:"completion"`
Hosts []StaticHost `mapstructure:"hosts"`
// Groups name lists of hosts and selectors for netssh multi (@name).
Groups map[string][]string `mapstructure:"groups"`
Multi MultiConfig `mapstructure:"multi"`
}
type ShellConfig struct {
@@ -117,7 +120,7 @@ func Load() (*Config, error) {
v.SetEnvPrefix("NETSSH")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "resolver.hostname_domain", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results"} {
for _, key := range []string{"netbox.url", "netbox.token", "netbox.token_version", "resolver.strategies", "resolver.management_subnets", "resolver.interface_name", "resolver.hostname_domain", "cache.ttl", "cache.path", "ssh.default_user", "ssh.default_port", "shortcuts.domains", "shortcuts.strip_hyphens", "shell.alias", "ssh.proxy_jump", "ssh.host_key_checking", "ssh.target", "ssh.address_family", "completion.max_results", "multi.multiplexer", "multi.panes_per_window"} {
if err := v.BindEnv(key); err != nil {
return nil, err
}
@@ -163,6 +166,9 @@ func Load() (*Config, error) {
if err := cfg.ValidateHosts(); err != nil {
return nil, err
}
if err := cfg.ValidateMulti(); err != nil {
return nil, err
}
for i := range cfg.NetBoxes {
source := &cfg.NetBoxes[i]
source.Name = strings.ToLower(source.Name)
+57
View File
@@ -0,0 +1,57 @@
package config
import (
"fmt"
"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"`
}
// 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")
}
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
}
+12
View File
@@ -190,3 +190,15 @@ func TestStaticHostsValidation(t *testing.T) {
}
}
}
func TestMultiConfig(t *testing.T) {
cfg, err := loadYAML(t, "groups:\n K8s: [master1, \"tag:worker\"]\nmulti:\n multiplexer: zellij\n")
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 _, err := loadYAML(t, bad+"\n"); err == nil {
t.Errorf("%s accepted", bad)
}
}
}
+226
View File
@@ -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, " ")
}
+211
View File
@@ -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")
}
}
+95
View File
@@ -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)
}
}
+101
View File
@@ -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) + `"`
}
+43 -12
View File
@@ -105,6 +105,22 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
if opts.Kind != "" && opts.Kind != "device" && opts.Kind != "vm" {
return nil, fmt.Errorf("invalid host kind %q", opts.Kind)
}
filters := ""
if opts.Tag != "" {
filters += "&tag=" + url.QueryEscape(opts.Tag)
}
if opts.Cluster != "" {
ids, err := c.clusterIDs(ctx, opts.Cluster)
if err != nil {
return nil, fmt.Errorf("clusters: %w", err)
}
if len(ids) == 0 {
return nil, nil // no such cluster in this NetBox
}
for _, id := range ids {
filters += fmt.Sprintf("&cluster_id=%d", id)
}
}
var (
mu sync.Mutex
results []HostEntry
@@ -116,7 +132,7 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
wg.Add(1)
go func() {
defer wg.Done()
devices, err := c.fetchAllDevices(ctx, query, opts.Tag)
devices, err := c.fetchAllDevices(ctx, query, filters)
mu.Lock()
defer mu.Unlock()
if err != nil {
@@ -131,7 +147,7 @@ func (c *Client) SearchAll(ctx context.Context, query string, opts SearchOptions
wg.Add(1)
go func() {
defer wg.Done()
vms, err := c.fetchAllVMs(ctx, query, opts.Tag)
vms, err := c.fetchAllVMs(ctx, query, filters)
mu.Lock()
defer mu.Unlock()
if err != nil {
@@ -231,15 +247,27 @@ func (c *Client) searchVMs(ctx context.Context, query, tag string) ([]HostEntry,
return entries, nil
}
func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]HostEntry, error) {
// clusterIDs returns the IDs of the clusters named name (case-insensitive).
func (c *Client) clusterIDs(ctx context.Context, name string) ([]int, error) {
var resp netboxListResponse[netboxCluster]
apiURL := fmt.Sprintf("%s/api/virtualization/clusters/?name__ie=%s&limit=50", c.baseURL, url.QueryEscape(name))
if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err
}
ids := make([]int, 0, len(resp.Results))
for _, cluster := range resp.Results {
ids = append(ids, cluster.ID)
}
return ids, nil
}
// fetchAllDevices pages through devices; filters are extra "&key=value" parameters.
func (c *Client) fetchAllDevices(ctx context.Context, query, filters string) ([]HostEntry, error) {
const pageSize = 50
var all []HostEntry
for offset := 0; ; offset += pageSize {
apiURL := fmt.Sprintf("%s/api/dcim/devices/?name__ic=%s&limit=%d&offset=%d",
c.baseURL, url.QueryEscape(query), pageSize, offset)
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
c.baseURL, url.QueryEscape(query), pageSize, offset) + filters
var resp netboxListResponse[netboxDevice]
if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err
@@ -254,15 +282,12 @@ func (c *Client) fetchAllDevices(ctx context.Context, query, tag string) ([]Host
return all, nil
}
func (c *Client) fetchAllVMs(ctx context.Context, query, tag string) ([]HostEntry, error) {
func (c *Client) fetchAllVMs(ctx context.Context, query, filters string) ([]HostEntry, error) {
const pageSize = 50
var all []HostEntry
for offset := 0; ; offset += pageSize {
apiURL := fmt.Sprintf("%s/api/virtualization/virtual-machines/?name__ic=%s&limit=%d&offset=%d",
c.baseURL, url.QueryEscape(query), pageSize, offset)
if tag != "" {
apiURL += "&tag=" + url.QueryEscape(tag)
}
c.baseURL, url.QueryEscape(query), pageSize, offset) + filters
var resp netboxListResponse[netboxVM]
if err := c.get(ctx, apiURL, &resp); err != nil {
return nil, err
@@ -343,6 +368,9 @@ func deviceToEntry(d netboxDevice) HostEntry {
for _, t := range d.Tags {
e.Tags = append(e.Tags, t.Name)
}
if d.Cluster != nil {
e.Cluster = d.Cluster.Name
}
return e
}
@@ -357,6 +385,9 @@ func vmToEntry(v netboxVM) HostEntry {
for _, t := range v.Tags {
e.Tags = append(e.Tags, t.Name)
}
if v.Cluster != nil {
e.Cluster = v.Cluster.Name
}
return e
}
+51
View File
@@ -0,0 +1,51 @@
package netbox
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestSearchAllByCluster(t *testing.T) {
var deviceQuery, vmQuery string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/virtualization/clusters/":
if r.URL.Query().Get("name__ie") == "PVE" {
fmt.Fprint(w, `{"count":2,"results":[{"id":3,"name":"pve"},{"id":4,"name":"pve"}]}`)
return
}
fmt.Fprint(w, `{"count":0,"results":[]}`)
case "/api/dcim/devices/":
deviceQuery = r.URL.RawQuery
fmt.Fprint(w, `{"count":1,"results":[{"id":1,"name":"node1","cluster":{"id":3,"name":"pve"}}]}`)
case "/api/virtualization/virtual-machines/":
vmQuery = r.URL.RawQuery
fmt.Fprint(w, `{"count":1,"results":[{"id":2,"name":"vm1","cluster":{"id":4,"name":"pve"}}]}`)
}
}))
defer srv.Close()
c := NewClient(srv.URL, "t", 1)
hosts, err := c.SearchAll(context.Background(), "", SearchOptions{Cluster: "PVE", Kind: "device"})
if err != nil || len(hosts) != 1 || hosts[0].Name != "node1" || hosts[0].Cluster != "pve" {
t.Fatalf("got %+v %v", hosts, err)
}
if want := "name__ic=&limit=50&offset=0&cluster_id=3&cluster_id=4"; deviceQuery != want {
t.Errorf("device query %q, want %q", deviceQuery, want)
}
if vmQuery != "" {
t.Error("VMs queried for kind device")
}
all, err := c.SearchAll(context.Background(), "", SearchOptions{Cluster: "PVE"})
if err != nil || len(all) != 2 {
t.Fatalf("all kinds: %+v %v", all, err)
}
none, err := c.SearchAll(context.Background(), "", SearchOptions{Cluster: "missing"})
if err != nil || len(none) != 0 {
t.Fatalf("unknown cluster: %+v %v", none, err)
}
}
+15 -4
View File
@@ -7,6 +7,8 @@ import "strings"
type SearchOptions struct {
Tag string // filter by tag slug; empty = no filter
Kind string // "device" | "vm" | "" (both)
// Cluster filters SearchAll by virtualization cluster name (case-insensitive).
Cluster string
}
// HostEntry is a unified model for both devices and virtual machines from NetBox.
@@ -18,6 +20,13 @@ type HostEntry struct {
PrimaryIP4 string // e.g. "10.0.1.5" (prefix length stripped)
PrimaryIP6 string
Tags []string
Cluster string // virtualization cluster name, if assigned
}
// netboxCluster is the nested cluster of a device or virtual machine.
type netboxCluster struct {
ID int `json:"id"`
Name string `json:"name"`
}
// netboxIP represents an IP address as returned by the NetBox API.
@@ -32,8 +41,9 @@ type netboxDevice struct {
Tags []struct {
Name string `json:"name"`
} `json:"tags"`
PrimaryIP4 *netboxIP `json:"primary_ip4"`
PrimaryIP6 *netboxIP `json:"primary_ip6"`
PrimaryIP4 *netboxIP `json:"primary_ip4"`
PrimaryIP6 *netboxIP `json:"primary_ip6"`
Cluster *netboxCluster `json:"cluster"`
}
// netboxVM matches the relevant fields of the NetBox /virtualization/virtual-machines/ response.
@@ -43,8 +53,9 @@ type netboxVM struct {
Tags []struct {
Name string `json:"name"`
} `json:"tags"`
PrimaryIP4 *netboxIP `json:"primary_ip4"`
PrimaryIP6 *netboxIP `json:"primary_ip6"`
PrimaryIP4 *netboxIP `json:"primary_ip4"`
PrimaryIP6 *netboxIP `json:"primary_ip6"`
Cluster *netboxCluster `json:"cluster"`
}
type netboxListResponse[T any] struct {
+102
View File
@@ -0,0 +1,102 @@
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)
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.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.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")
}
+31
View File
@@ -91,6 +91,7 @@ func runMenu(cfg *config.Config) error {
huh.NewOption("SSH options — jump host, host keys, target, -o, forwards", "ssh"),
huh.NewOption(fmt.Sprintf("SSH rules — per tag, instance, kind, name (%d)", len(cfg.SSH.Rules)), "rules"),
huh.NewOption(fmt.Sprintf("Static hosts — hosts not in NetBox (%d)", len(cfg.Hosts)), "hosts"),
huh.NewOption(fmt.Sprintf("Multi-session — groups, tmux/zellij (%d groups)", len(cfg.Groups)), "multi"),
huh.NewOption("Resolver & cache", "resolver"),
huh.NewOption("Shortcuts & command alias", "shortcuts"),
huh.NewOption("Completion", "completion"),
@@ -115,6 +116,8 @@ func runMenu(cfg *config.Config) error {
if hosts, err = configureHosts(cfg.Hosts); err == nil {
cfg.Hosts = hosts
}
case "multi":
err = editMulti(cfg)
case "resolver":
err = editResolverCache(cfg)
case "shortcuts":
@@ -369,6 +372,9 @@ func save(cfg config.Config) error {
if err := cfg.ValidateHosts(); err != nil {
return err
}
if err := cfg.ValidateMulti(); err != nil {
return err
}
path := config.Path()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("creating config dir: %w", err)
@@ -461,6 +467,31 @@ func save(cfg config.Config) error {
}
}
if len(cfg.Groups) > 0 {
b.WriteString("\ngroups:\n")
names := make([]string, 0, len(cfg.Groups))
for name := range cfg.Groups {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
quoted := make([]string, 0, len(cfg.Groups[name]))
for _, selector := range cfg.Groups[name] {
quoted = append(quoted, strconv.Quote(selector))
}
fmt.Fprintf(&b, " %s: [%s]\n", name, strings.Join(quoted, ", "))
}
}
if cfg.Multi.Multiplexer != "" || cfg.Multi.PanesPerWindow > 0 {
b.WriteString("\nmulti:\n")
if cfg.Multi.Multiplexer != "" {
fmt.Fprintf(&b, " multiplexer: %s\n", cfg.Multi.Multiplexer)
}
if cfg.Multi.PanesPerWindow > 0 {
fmt.Fprintf(&b, " panes_per_window: %d\n", cfg.Multi.PanesPerWindow)
}
}
if cfg.Completion.MaxResults > 0 {
fmt.Fprintf(&b, "\ncompletion:\n max_results: %d\n", cfg.Completion.MaxResults)
}
+34
View File
@@ -617,3 +617,37 @@ func TestValidateHostIn(t *testing.T) {
t.Fatal("invalid address accepted")
}
}
func TestParseGroupsAndSaveMulti(t *testing.T) {
groups, err := parseGroups("K8s: master1, tag:k8s-worker\npve: cluster:pve-fsn1 @k8s\n")
want := map[string][]string{"k8s": {"master1", "tag:k8s-worker"}, "pve": {"cluster:pve-fsn1", "@k8s"}}
if err != nil || !reflect.DeepEqual(groups, want) {
t.Fatalf("got %v %v", groups, err)
}
if again, _ := parseGroups(formatGroups(groups)); !reflect.DeepEqual(again, want) {
t.Fatalf("roundtrip: %v", again)
}
for _, bad := range []string{"no colon", "tag:x", "bad name: a", "empty:"} {
if _, err := parseGroups(bad); err == nil {
t.Errorf("%q accepted", bad)
}
}
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
cfg := config.Config{
Hosts: []config.StaticHost{{Name: "a", Address: "10.0.0.1"}},
Groups: groups,
Multi: config.MultiConfig{Multiplexer: "zellij", PanesPerWindow: 4},
}
if err := save(cfg); err != nil {
t.Fatal(err)
}
loaded, err := config.Load()
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(loaded.Groups, want) || loaded.Multi != cfg.Multi {
t.Fatalf("got %v %+v", loaded.Groups, loaded.Multi)
}
}
+74 -9
View File
@@ -27,8 +27,9 @@ type SelectedHost struct {
IP string
User string // empty = use config default
Port string // empty = use default port
Action string // ActionSSH, ActionSFTP, or ActionTunnel
Action string // ActionSSH, ActionSFTP, ActionTunnel, or ActionMulti
Forwards []string // forward names or specs for ActionTunnel
Targets []string // marked hosts for ActionMulti
}
// Actions the user can start on a host.
@@ -36,6 +37,7 @@ const (
ActionSSH = ""
ActionSFTP = "sftp"
ActionTunnel = "tunnel"
ActionMulti = "multi"
)
// --- bubbletea messages ---
@@ -74,7 +76,8 @@ func (h hostItem) FilterValue() string { return h.name }
// --- compact list delegate ---
type compactDelegate struct{}
// compactDelegate shares the model's marks so marked hosts show a dot.
type compactDelegate struct{ marked map[string]bool }
func (d compactDelegate) Height() int { return 1 }
func (d compactDelegate) Spacing() int { return 0 }
@@ -84,7 +87,11 @@ func (d compactDelegate) Render(w io.Writer, m list.Model, index int, item list.
if !ok {
return
}
line := fmt.Sprintf(" %s %s", h.name, lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(h.ip))
mark := " "
if d.marked[h.key()] {
mark = "● "
}
line := fmt.Sprintf(" %s%s %s", mark, h.name, lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(h.ip))
if index == m.Index() {
line = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("86")).Render("> " + strings.TrimPrefix(line, " "))
}
@@ -169,10 +176,12 @@ type Model struct {
selected *SelectedHost
width int
height int
recentMode bool // true when showing recent hosts (empty search, initial state)
filterOpen bool // Ctrl+F toggles
editMode bool // Ctrl+E on the selected item
forwardMode bool // Ctrl+T on the selected item
recentMode bool // true when showing recent hosts (empty search, initial state)
filterOpen bool // Ctrl+F toggles
editMode bool // Ctrl+E on the selected item
forwardMode bool // Ctrl+T on the selected item
marked map[string]bool // hostItem.key() of hosts marked with space
markOrder []string // targets in the order they were marked
}
func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts ...int) *Model {
@@ -191,7 +200,8 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
fwd.Prompt = ""
fwd.Placeholder = "web, 8080:localhost:80"
l := list.New(nil, compactDelegate{}, 0, 0)
marked := make(map[string]bool)
l := list.New(nil, compactDelegate{marked: marked}, 0, 0)
l.SetShowHelp(false)
l.SetShowTitle(false)
l.SetShowStatusBar(false)
@@ -212,6 +222,7 @@ func New(client *netbox.Client, c *cache.Cache, defaultUser string, defaultPorts
filterInput: fi,
editInput: ei,
forwardInput: fwd,
marked: marked,
list: l,
client: client,
cache: c,
@@ -386,11 +397,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case "enter":
if len(m.markOrder) > 0 {
m.selected = &SelectedHost{Action: ActionMulti, Targets: append([]string(nil), m.markOrder...)}
return m, tea.Quit
}
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.selected = &SelectedHost{Name: item.name, IP: item.ip, Kind: item.kind}
return m, tea.Quit
}
case "space":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.toggleMark(item)
m.list.CursorDown()
return m, nil
}
case "ctrl+a":
m.toggleAllVisible()
return m, nil
case "ctrl+e":
if item, ok := m.list.SelectedItem().(hostItem); ok {
m.openEdit(item)
@@ -584,7 +610,10 @@ func (m *Model) viewContent() string {
sb.WriteString(m.editInput.View() + "\n")
sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(" enter connect esc cancel") + "\n")
} else {
hint := "ctrl+c quit enter connect ctrl+o sftp ctrl+t forward ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
hint := "ctrl+c quit enter connect space mark ctrl+o sftp ctrl+t forward ctrl+e edit ctrl+f filter ctrl+n notes ctrl+b NetBox"
if n := len(m.markOrder); n > 0 {
hint = fmt.Sprintf("%d marked — enter open side by side space unmark ctrl+a all esc quit", n)
}
sb.WriteString("\n" + lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render(hint) + "\n")
}
@@ -724,6 +753,42 @@ func (m *Model) buildSelected(item hostItem) *SelectedHost {
func (m *Model) SetNotes(service *notes.Service) { m.noteService = service }
func (h hostItem) key() string { return h.name + "\x00" + h.kind }
// toggleMark marks or unmarks a host for opening several side by side.
func (m *Model) toggleMark(item hostItem) {
key := item.key()
if m.marked[key] {
delete(m.marked, key)
for i, target := range m.markOrder {
if target == item.name {
m.markOrder = append(m.markOrder[:i], m.markOrder[i+1:]...)
break
}
}
return
}
m.marked[key] = true
m.markOrder = append(m.markOrder, item.name)
}
// toggleAllVisible marks every listed host, or unmarks them if all are marked.
func (m *Model) toggleAllVisible() {
var items []hostItem
allMarked := true
for _, it := range m.list.Items() {
if item, ok := it.(hostItem); ok {
items = append(items, item)
allMarked = allMarked && m.marked[item.key()]
}
}
for _, item := range items {
if m.marked[item.key()] == allMarked {
m.toggleMark(item)
}
}
}
// SetForwards provides the forward names configured for a host (ctrl+t).
func (m *Model) SetForwards(forwardsFor func(name, kind string, tags []string) []string) {
m.forwardsFor = forwardsFor
+35
View File
@@ -122,3 +122,38 @@ func TestForwardInputEscCancels(t *testing.T) {
t.Fatal("esc did not cancel")
}
}
func TestSpaceMarksHostsAndEnterOpensThem(t *testing.T) {
m := New(nil, nil, "")
m.updateListItems([]netbox.HostEntry{{Name: "a", Kind: "device"}, {Name: "b", Kind: "device"}, {Name: "c", Kind: "vm"}})
m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
if len(m.markOrder) != 1 || m.markOrder[0] != "a" || m.list.Index() != 1 {
t.Fatalf("space: marks %v, cursor %d", m.markOrder, m.list.Index())
}
if m.input.Value() != "" {
t.Fatal("space went into the search field")
}
m.list.Select(2)
m.Update(tea.KeyPressMsg{Code: tea.KeySpace, Text: " "})
if !strings.Contains(m.viewContent(), "2 marked") {
t.Fatalf("no marked count:\n%s", m.viewContent())
}
m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
if m.selected == nil || m.selected.Action != ActionMulti || !reflect.DeepEqual(m.selected.Targets, []string{"a", "c"}) {
t.Fatalf("got %+v", m.selected)
}
}
func TestCtrlATogglesAllVisible(t *testing.T) {
m := New(nil, nil, "")
m.updateListItems([]netbox.HostEntry{{Name: "a"}, {Name: "b"}})
m.toggleMark(hostItem{name: "a"})
m.Update(tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl})
if !reflect.DeepEqual(m.markOrder, []string{"a", "b"}) {
t.Fatalf("mark all: %v", m.markOrder)
}
m.Update(tea.KeyPressMsg{Code: 'a', Mod: tea.ModCtrl})
if len(m.markOrder) != 0 {
t.Fatalf("unmark all: %v", m.markOrder)
}
}