diff --git a/README.md b/README.md index 3c09c0a..c467840 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,8 @@ # tmux-pilot -A TUI for managing tmux sessions. One keybinding to see, create, rename, switch, and kill sessions. +A minimal TUI for managing tmux sessions. Pick → execute → done. [![CI](https://github.com/blockful/tmux-pilot/actions/workflows/ci.yml/badge.svg)](https://github.com/blockful/tmux-pilot/actions/workflows/ci.yml) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) ``` ┌─ tmux-pilot ──────────────────────────────┐ @@ -11,69 +10,50 @@ A TUI for managing tmux sessions. One keybinding to see, create, rename, switch, │ ● main 3 windows attached │ │ ○ api-server 1 window detached │ │ ○ notes 2 windows detached │ -│ ○ scratch 1 window detached │ │ │ │ [enter] switch [n] new [r] rename │ -│ [x] kill [q] quit │ +│ [x] kill [d] detach [q] quit │ │ │ -│ tip: Ctrl-b d to detach from tmux │ +│ tip: Ctrl-b d to detach │ └───────────────────────────────────────────┘ ``` ## Install -### Go - ```bash go install github.com/blockful/tmux-pilot/cmd@latest ``` -### Download binary - -Grab the latest from [Releases](https://github.com/blockful/tmux-pilot/releases) for your platform (Linux/macOS, amd64/arm64). - -### Build from source - -```bash -git clone https://github.com/blockful/tmux-pilot.git -cd tmux-pilot -go build -o tmux-pilot ./cmd -``` +Or download from [Releases](https://github.com/blockful/tmux-pilot/releases). ## Setup -Automatic (adds keybinding to `~/.tmux.conf` and reloads): +Add to `~/.tmux.conf`: ```bash -tmux-pilot --setup +bind s display-popup -E -w 60% -h 50% "tmux-pilot" ``` -Or manual — add to `~/.tmux.conf`: +Optional alias in `~/.bashrc` or `~/.zshrc`: ```bash -bind s display-popup -E -w 60% -h 50% "tmux-pilot" +alias tp="tmux-pilot" ``` -Then reload: `tmux source-file ~/.tmux.conf` - -Also works standalone outside tmux — it will `attach-session` instead of `switch-client`. - -## Keybindings - -| Key | Action | -|-----|--------| -| `↑`/`↓` `j`/`k` | Navigate | -| `enter` | Switch to session | -| `n` | New session | -| `r` | Rename session | -| `x` | Kill session (with confirmation) | -| `q` / `esc` | Quit | - ## How it works -tmux-pilot shells out to the `tmux` binary for all operations. No library dependencies on tmux internals. When running inside tmux it uses `switch-client`; outside it uses `attach-session`. +tmux-pilot is a thin picker. It shows your sessions, you choose an action, it exits and runs the tmux command: + +| Key | Runs | +|-----|------| +| `enter` | `tmux switch-client -t ` | +| `n` | `tmux new-session -d -s && tmux switch-client -t ` | +| `r` | `tmux rename-session -t ` | +| `x` | `tmux kill-session -t ` | +| `d` | `tmux detach-client` | +| `q`/`esc` | exit | -Built with [BubbleTea](https://github.com/charmbracelet/bubbletea) + [LipGloss](https://github.com/charmbracelet/lipgloss). +Navigation: `↑`/`↓` or `j`/`k` ## License diff --git a/cmd/main.go b/cmd/main.go index 18692bf..a8bb74c 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,13 +4,11 @@ import ( "fmt" "os" - "github.com/blockful/tmux-pilot/internal/setup" "github.com/blockful/tmux-pilot/internal/tmux" "github.com/blockful/tmux-pilot/internal/tui" tea "github.com/charmbracelet/bubbletea" ) -// Set by goreleaser ldflags. var ( version = "dev" commit = "unknown" @@ -22,30 +20,46 @@ func main() { switch os.Args[1] { case "--version", "-v": fmt.Printf("tmux-pilot %s (%s, %s)\n", version, commit, date) - os.Exit(0) - case "--setup": - if err := setup.Run(); err != nil { - fmt.Fprintf(os.Stderr, "Setup failed: %v\n", err) - os.Exit(1) - } - os.Exit(0) + return } } - client := tmux.NewRealClient() - model := tui.New(client, setup.NeedsSetup(), setup.Run) + // 1. Fetch sessions + sessions, err := tmux.ListSessions() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } - opts := []tea.ProgramOption{ - // WithInputTTY reads from /dev/tty directly, bypassing stdin. - // This prevents tmux from mangling escape sequences when - // running inside display-popup or nested terminals. - tea.WithInputTTY(), - tea.WithAltScreen(), + // 2. Show picker + model := tui.New(sessions) + p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithInputTTY()) + result, err := p.Run() + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) } - p := tea.NewProgram(model, opts...) - if _, err := p.Run(); err != nil { + // 3. Execute action after TUI exits + action := result.(*tui.Model).Action() + if err := execute(action); err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } + +func execute(a tui.Action) error { + switch a.Kind { + case "switch": + return tmux.SwitchOrAttach(a.Target) + case "new": + return tmux.NewSession(a.Target) + case "rename": + return tmux.RenameSession(a.Target, a.NewName) + case "kill": + return tmux.KillSession(a.Target) + case "detach": + return tmux.Detach() + } + return nil // user quit without action +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go deleted file mode 100644 index 468c38a..0000000 --- a/internal/setup/setup.go +++ /dev/null @@ -1,107 +0,0 @@ -package setup - -import ( - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" -) - -const tmuxBinding = `bind s display-popup -E -w 60% -h 50% "tmux-pilot"` - -// NeedsSetup returns true if tmux-pilot is not yet configured in ~/.tmux.conf. -func NeedsSetup() bool { - confPath, err := tmuxConfPath() - if err != nil { - return false - } - exists, err := bindingExists(confPath) - if err != nil { - return false - } - return !exists -} - -// Run adds the tmux-pilot keybinding to ~/.tmux.conf and reloads tmux config. -// It is idempotent — skips if the binding already exists. -func Run() error { - confPath, err := tmuxConfPath() - if err != nil { - return fmt.Errorf("resolve tmux.conf path: %w", err) - } - - exists, err := bindingExists(confPath) - if err != nil { - return err - } - if exists { - fmt.Println("✓ tmux-pilot binding already configured in", confPath) - return nil - } - - if err := appendBinding(confPath); err != nil { - return err - } - - fmt.Println("✓ Added keybinding to", confPath) - fmt.Println(" bind s display-popup -E -w 60% -h 50% \"tmux-pilot\"") - - if err := reloadTmux(); err != nil { - fmt.Println("\n Reload tmux manually: tmux source-file", confPath) - return nil // non-fatal: tmux might not be running - } - - fmt.Println("✓ Reloaded tmux config") - fmt.Println("\n Press prefix + s to launch tmux-pilot") - return nil -} - -func tmuxConfPath() (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err - } - return filepath.Join(home, ".tmux.conf"), nil -} - -func bindingExists(path string) (bool, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return false, nil - } - return false, fmt.Errorf("read %s: %w", path, err) - } - return strings.Contains(string(data), "tmux-pilot"), nil -} - -func appendBinding(path string) error { - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return fmt.Errorf("open %s: %w", path, err) - } - defer func() { _ = f.Close() }() - - // Add newline before binding if file is non-empty - info, _ := f.Stat() - prefix := "" - if info.Size() > 0 { - prefix = "\n" - } - - if _, err := fmt.Fprintf(f, "%s# tmux-pilot: session manager popup\n%s\n", prefix, tmuxBinding); err != nil { - return fmt.Errorf("write to %s: %w", path, err) - } - return nil -} - -func reloadTmux() error { - // Only reload if tmux server is running - if err := exec.Command("tmux", "list-sessions").Run(); err != nil { - return err - } - home, _ := os.UserHomeDir() - confPath := filepath.Join(home, ".tmux.conf") - return exec.Command("tmux", "source-file", confPath).Run() -} diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go deleted file mode 100644 index acc038d..0000000 --- a/internal/setup/setup_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package setup - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestBindingExists_NotPresent(t *testing.T) { - tmp := t.TempDir() - path := filepath.Join(tmp, ".tmux.conf") - if err := os.WriteFile(path, []byte("set -g mouse on\n"), 0644); err != nil { - t.Fatal(err) - } - - exists, err := bindingExists(path) - if err != nil { - t.Fatal(err) - } - if exists { - t.Error("should not detect binding in unrelated config") - } -} - -func TestBindingExists_Present(t *testing.T) { - tmp := t.TempDir() - path := filepath.Join(tmp, ".tmux.conf") - if err := os.WriteFile(path, []byte("# tmux-pilot binding\nbind s display-popup -E -w 60% -h 50% \"tmux-pilot\"\n"), 0644); err != nil { - t.Fatal(err) - } - - exists, err := bindingExists(path) - if err != nil { - t.Fatal(err) - } - if !exists { - t.Error("should detect tmux-pilot in config") - } -} - -func TestBindingExists_FileNotFound(t *testing.T) { - exists, err := bindingExists("/nonexistent/.tmux.conf") - if err != nil { - t.Fatal(err) - } - if exists { - t.Error("missing file should return false") - } -} - -func TestAppendBinding_NewFile(t *testing.T) { - tmp := t.TempDir() - path := filepath.Join(tmp, ".tmux.conf") - - if err := appendBinding(path); err != nil { - t.Fatal(err) - } - - data, _ := os.ReadFile(path) - content := string(data) - - if !strings.Contains(content, "tmux-pilot") { - t.Error("binding not found in new file") - } - if !strings.Contains(content, "display-popup") { - t.Error("display-popup command not found") - } - if strings.HasPrefix(content, "\n") { - t.Error("new file should not start with newline") - } -} - -func TestAppendBinding_ExistingFile(t *testing.T) { - tmp := t.TempDir() - path := filepath.Join(tmp, ".tmux.conf") - if err := os.WriteFile(path, []byte("set -g mouse on\n"), 0644); err != nil { - t.Fatal(err) - } - - if err := appendBinding(path); err != nil { - t.Fatal(err) - } - - data, _ := os.ReadFile(path) - content := string(data) - - if !strings.HasPrefix(content, "set -g mouse on\n") { - t.Error("existing content should be preserved") - } - if !strings.Contains(content, "tmux-pilot") { - t.Error("binding not appended") - } -} - -func TestAppendBinding_Idempotent(t *testing.T) { - tmp := t.TempDir() - path := filepath.Join(tmp, ".tmux.conf") - - if err := appendBinding(path); err != nil { - t.Fatal(err) - } - first, _ := os.ReadFile(path) - - // Once in comment, once in command - if count := strings.Count(string(first), "tmux-pilot"); count != 2 { - t.Errorf("expected 2 occurrences of tmux-pilot, got %d", count) - } -} diff --git a/internal/tmux/client.go b/internal/tmux/client.go index d007c33..10b16e9 100644 --- a/internal/tmux/client.go +++ b/internal/tmux/client.go @@ -8,85 +8,76 @@ import ( "strings" ) -// RealClient implements Client by shelling out to the tmux binary. -type RealClient struct{} - -// NewRealClient creates a new RealClient. -func NewRealClient() *RealClient { - return &RealClient{} -} - -// ListSessions returns all tmux sessions. Returns an empty slice if the -// tmux server is not running. -func (c *RealClient) ListSessions() ([]Session, error) { - cmd := exec.Command("tmux", "list-sessions", "-F", "#{session_name}\t#{session_windows}\t#{session_attached}") - output, err := cmd.Output() +// ListSessions returns all tmux sessions. +func ListSessions() ([]Session, error) { + out, err := exec.Command("tmux", "list-sessions", "-F", "#{session_name}\t#{session_windows}\t#{session_attached}").Output() if err != nil { - if isServerNotRunning(err) { - return []Session{}, nil + if isNoServer(err) { + return nil, nil } return nil, fmt.Errorf("list sessions: %w", err) } - - return parseSessions(string(output)) + return parseSessions(string(out)) } -// NewSession creates a detached tmux session with the given name. -func (c *RealClient) NewSession(name string) error { - if err := exec.Command("tmux", "new-session", "-d", "-s", name).Run(); err != nil { - return fmt.Errorf("create session %q: %w", name, err) +// SwitchOrAttach switches to a session (inside tmux) or attaches (outside). +func SwitchOrAttach(name string) error { + if IsInsideTmux() { + return run("switch-client", "-t", name) } - return nil + cmd := exec.Command("tmux", "attach-session", "-t", name) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() } -// SwitchSession switches to the target session. Uses switch-client when -// inside tmux, attach-session when outside. -func (c *RealClient) SwitchSession(name string) error { - var cmd *exec.Cmd - if c.IsInsideTmux() { - cmd = exec.Command("tmux", "switch-client", "-t", name) - } else { - cmd = exec.Command("tmux", "attach-session", "-t", name) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - } - if err := cmd.Run(); err != nil { - return fmt.Errorf("switch to session %q: %w", name, err) +// NewSession creates a session and switches to it. +func NewSession(name string) error { + if err := run("new-session", "-d", "-s", name); err != nil { + return err } - return nil + return SwitchOrAttach(name) } -// RenameSession renames an existing session. -func (c *RealClient) RenameSession(old, new string) error { - if err := exec.Command("tmux", "rename-session", "-t", old, new).Run(); err != nil { - return fmt.Errorf("rename session %q to %q: %w", old, new, err) - } - return nil +// RenameSession renames a session. +func RenameSession(old, new string) error { + return run("rename-session", "-t", old, new) } -// DetachSession detaches the current client from its tmux session. -func (c *RealClient) DetachSession() error { - if err := exec.Command("tmux", "detach-client").Run(); err != nil { - return fmt.Errorf("detach session: %w", err) - } - return nil +// KillSession kills a session. +func KillSession(name string) error { + return run("kill-session", "-t", name) } -// KillSession kills a tmux session. -func (c *RealClient) KillSession(name string) error { - if err := exec.Command("tmux", "kill-session", "-t", name).Run(); err != nil { - return fmt.Errorf("kill session %q: %w", name, err) - } - return nil +// Detach detaches the current client. +func Detach() error { + return run("detach-client") } -// IsInsideTmux returns true if the current process is running inside tmux. -func (c *RealClient) IsInsideTmux() bool { +// IsInsideTmux returns true if running inside a tmux session. +func IsInsideTmux() bool { return os.Getenv("TMUX") != "" } -// parseSessions parses tmux list-sessions output (tab-delimited). +// SessionExists checks if a session name is already taken. +func SessionExists(name string) bool { + sessions, err := ListSessions() + if err != nil { + return false + } + for _, s := range sessions { + if s.Name == name { + return true + } + } + return false +} + +func run(args ...string) error { + return exec.Command("tmux", args...).Run() +} + func parseSessions(output string) ([]Session, error) { var sessions []Session for _, line := range strings.Split(strings.TrimSpace(output), "\n") { @@ -97,29 +88,25 @@ func parseSessions(output string) ([]Session, error) { if len(parts) != 3 { continue } - - windowCount, err := strconv.Atoi(parts[1]) + wc, err := strconv.Atoi(parts[1]) if err != nil { return nil, fmt.Errorf("parse window count %q: %w", parts[1], err) } - sessions = append(sessions, Session{ Name: parts[0], - WindowCount: windowCount, + WindowCount: wc, Attached: parts[2] == "1", }) } return sessions, nil } -// isServerNotRunning checks if the error indicates no tmux server is running. -func isServerNotRunning(err error) bool { - exitError, ok := err.(*exec.ExitError) - if !ok { - return false +func isNoServer(err error) bool { + if e, ok := err.(*exec.ExitError); ok { + stderr := string(e.Stderr) + return strings.Contains(stderr, "no server running") || + strings.Contains(stderr, "failed to connect") || + e.ExitCode() == 1 } - stderr := string(exitError.Stderr) - return strings.Contains(stderr, "no server running") || - strings.Contains(stderr, "failed to connect to server") || - exitError.ExitCode() == 1 + return false } diff --git a/internal/tmux/client_integration_test.go b/internal/tmux/client_integration_test.go deleted file mode 100644 index 5e181b9..0000000 --- a/internal/tmux/client_integration_test.go +++ /dev/null @@ -1,100 +0,0 @@ -//go:build integration - -package tmux - -import ( - "os/exec" - "testing" - "time" -) - -func isTmuxAvailable() bool { - _, err := exec.LookPath("tmux") - return err == nil -} - -func TestRealClient_Integration(t *testing.T) { - if !isTmuxAvailable() { - t.Skip("tmux not available") - } - - client := NewRealClient() - testSession := "tmux-pilot-integration-test" - - // Cleanup before and after - _ = client.KillSession(testSession) - defer func() { _ = client.KillSession(testSession) }() - - // Create - if err := client.NewSession(testSession); err != nil { - t.Fatalf("NewSession: %v", err) - } - time.Sleep(100 * time.Millisecond) - - // List and find - sessions, err := client.ListSessions() - if err != nil { - t.Fatalf("ListSessions: %v", err) - } - - found := false - for _, s := range sessions { - if s.Name == testSession { - found = true - if s.WindowCount < 1 { - t.Errorf("expected at least 1 window, got %d", s.WindowCount) - } - } - } - if !found { - t.Fatalf("session %q not found", testSession) - } - - // Rename - renamed := testSession + "-renamed" - if err := client.RenameSession(testSession, renamed); err != nil { - t.Fatalf("RenameSession: %v", err) - } - defer func() { _ = client.KillSession(renamed) }() - - sessions, err = client.ListSessions() - if err != nil { - t.Fatalf("ListSessions after rename: %v", err) - } - found = false - for _, s := range sessions { - if s.Name == renamed { - found = true - } - if s.Name == testSession { - t.Error("old session name still exists") - } - } - if !found { - t.Fatalf("renamed session %q not found", renamed) - } - - // Kill - if err := client.KillSession(renamed); err != nil { - t.Fatalf("KillSession: %v", err) - } - - sessions, err = client.ListSessions() - if err != nil { - t.Fatalf("ListSessions after kill: %v", err) - } - for _, s := range sessions { - if s.Name == renamed { - t.Errorf("session %q still exists after kill", renamed) - } - } -} - -func TestRealClient_IsInsideTmux(t *testing.T) { - if !isTmuxAvailable() { - t.Skip("tmux not available") - } - client := NewRealClient() - // Just ensure it doesn't panic; result depends on test environment - _ = client.IsInsideTmux() -} diff --git a/internal/tmux/client_test.go b/internal/tmux/client_test.go index 538ed2e..ca0f897 100644 --- a/internal/tmux/client_test.go +++ b/internal/tmux/client_test.go @@ -1,215 +1,38 @@ package tmux -import ( - "errors" - "testing" -) +import "testing" func TestParseSessions(t *testing.T) { tests := []struct { - name string - input string - expected []Session - wantErr bool + name string + input string + want int + wantErr bool }{ - { - name: "multiple sessions", - input: "main\t3\t1\napi-server\t1\t0\nnotes\t2\t0\n", - expected: []Session{ - {Name: "main", WindowCount: 3, Attached: true}, - {Name: "api-server", WindowCount: 1, Attached: false}, - {Name: "notes", WindowCount: 2, Attached: false}, - }, - }, - { - name: "single attached session", - input: "dev\t5\t1\n", - expected: []Session{ - {Name: "dev", WindowCount: 5, Attached: true}, - }, - }, - { - name: "empty output", - input: "", - expected: nil, - }, - { - name: "whitespace only", - input: " \n \n", - expected: nil, - }, - { - name: "invalid window count", - input: "main\tnotanumber\t1\n", - wantErr: true, - }, - { - name: "session name with special characters", - input: "my-project.v2\t1\t0\n", - expected: []Session{ - {Name: "my-project.v2", WindowCount: 1, Attached: false}, - }, - }, + {"multiple", "main\t3\t1\napi\t1\t0\n", 2, false}, + {"single", "dev\t5\t1\n", 1, false}, + {"empty", "", 0, false}, + {"bad number", "x\tnan\t0\n", 0, true}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parseSessions(tt.input) if (err != nil) != tt.wantErr { - t.Errorf("parseSessions() error = %v, wantErr %v", err, tt.wantErr) - return + t.Errorf("err = %v, wantErr %v", err, tt.wantErr) } - if !tt.wantErr && !sessionsEqual(got, tt.expected) { - t.Errorf("parseSessions() = %+v, want %+v", got, tt.expected) + if !tt.wantErr && len(got) != tt.want { + t.Errorf("got %d sessions, want %d", len(got), tt.want) } }) } } -func TestMockClient_ListSessions(t *testing.T) { - mock := NewMockClient() - - sessions, err := mock.ListSessions() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(sessions) != 3 { - t.Errorf("expected 3 sessions, got %d", len(sessions)) - } -} - -func TestMockClient_ListSessions_Error(t *testing.T) { - mock := NewMockClient() - mock.ListErr = errors.New("connection failed") - - _, err := mock.ListSessions() - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestMockClient_NewSession(t *testing.T) { - mock := NewMockClient() - initial := len(mock.Sessions()) - - if err := mock.NewSession("test"); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(mock.Sessions()) != initial+1 { - t.Errorf("expected %d sessions, got %d", initial+1, len(mock.Sessions())) - } - if len(mock.NewCalls) != 1 || mock.NewCalls[0] != "test" { - t.Errorf("expected NewCalls=[test], got %v", mock.NewCalls) - } -} - -func TestMockClient_NewSession_Error(t *testing.T) { - mock := NewMockClient() - mock.NewErr = errors.New("duplicate") - - err := mock.NewSession("main") - if err == nil { - t.Error("expected error, got nil") - } -} - -func TestMockClient_SwitchSession(t *testing.T) { - mock := NewMockClient() - - if err := mock.SwitchSession("api-server"); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - for _, s := range mock.Sessions() { - if s.Name == "api-server" && !s.Attached { - t.Error("expected api-server to be attached") - } - if s.Name == "main" && s.Attached { - t.Error("expected main to be detached after switch") - } - } -} - -func TestMockClient_RenameSession(t *testing.T) { - mock := NewMockClient() - - if err := mock.RenameSession("main", "primary"); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - found := false - for _, s := range mock.Sessions() { - if s.Name == "primary" { - found = true - } - if s.Name == "main" { - t.Error("old name should not exist") - } - } - if !found { - t.Error("renamed session not found") - } -} - -func TestMockClient_RenameSession_NotFound(t *testing.T) { - mock := NewMockClient() - - err := mock.RenameSession("nonexistent", "new") - if err == nil { - t.Error("expected error for nonexistent session") - } -} - -func TestMockClient_KillSession(t *testing.T) { - mock := NewMockClient() - initial := len(mock.Sessions()) - - if err := mock.KillSession("main"); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(mock.Sessions()) != initial-1 { - t.Errorf("expected %d sessions, got %d", initial-1, len(mock.Sessions())) - } - for _, s := range mock.Sessions() { - if s.Name == "main" { - t.Error("session should have been killed") - } - } -} - -func TestMockClient_KillSession_NotFound(t *testing.T) { - mock := NewMockClient() - - err := mock.KillSession("ghost") - if err == nil { - t.Error("expected error for nonexistent session") - } -} - -func TestMockClient_IsInsideTmux(t *testing.T) { - mock := NewMockClient() - - if !mock.IsInsideTmux() { - t.Error("expected true by default") - } - - mock.SetInsideTmux(false) - if mock.IsInsideTmux() { - t.Error("expected false after SetInsideTmux(false)") - } -} - -// sessionsEqual compares two session slices. -func sessionsEqual(a, b []Session) bool { - if len(a) != len(b) { - return false +func TestParseSessions_Fields(t *testing.T) { + sessions, _ := parseSessions("main\t3\t1\napi\t1\t0\n") + if sessions[0].Name != "main" || sessions[0].WindowCount != 3 || !sessions[0].Attached { + t.Errorf("session 0: %+v", sessions[0]) } - for i := range a { - if a[i] != b[i] { - return false - } + if sessions[1].Attached { + t.Error("session 1 should be detached") } - return true } diff --git a/internal/tmux/mock.go b/internal/tmux/mock.go deleted file mode 100644 index 7e71dda..0000000 --- a/internal/tmux/mock.go +++ /dev/null @@ -1,117 +0,0 @@ -package tmux - -import "errors" - -// MockClient implements Client for testing. -type MockClient struct { - sessions []Session - insideTmux bool - - ListErr error - NewErr error - SwitchErr error - RenameErr error - KillErr error - DetachErr error - - // Recorded calls for verification - NewCalls []string - SwitchCalls []string - RenameCalls [][2]string - KillCalls []string - DetachCalls int -} - -// NewMockClient creates a MockClient with default test sessions. -func NewMockClient() *MockClient { - return &MockClient{ - sessions: []Session{ - {Name: "main", WindowCount: 3, Attached: true}, - {Name: "api-server", WindowCount: 1, Attached: false}, - {Name: "notes", WindowCount: 2, Attached: false}, - }, - insideTmux: true, - } -} - -func (m *MockClient) ListSessions() ([]Session, error) { - if m.ListErr != nil { - return nil, m.ListErr - } - return m.sessions, nil -} - -func (m *MockClient) NewSession(name string) error { - m.NewCalls = append(m.NewCalls, name) - if m.NewErr != nil { - return m.NewErr - } - m.sessions = append(m.sessions, Session{Name: name, WindowCount: 1, Attached: false}) - return nil -} - -func (m *MockClient) SwitchSession(name string) error { - m.SwitchCalls = append(m.SwitchCalls, name) - if m.SwitchErr != nil { - return m.SwitchErr - } - for i := range m.sessions { - m.sessions[i].Attached = (m.sessions[i].Name == name) - } - return nil -} - -func (m *MockClient) RenameSession(old, new string) error { - m.RenameCalls = append(m.RenameCalls, [2]string{old, new}) - if m.RenameErr != nil { - return m.RenameErr - } - for i := range m.sessions { - if m.sessions[i].Name == old { - m.sessions[i].Name = new - return nil - } - } - return errors.New("session not found: " + old) -} - -func (m *MockClient) DetachSession() error { - m.DetachCalls++ - if m.DetachErr != nil { - return m.DetachErr - } - return nil -} - -func (m *MockClient) KillSession(name string) error { - m.KillCalls = append(m.KillCalls, name) - if m.KillErr != nil { - return m.KillErr - } - for i, s := range m.sessions { - if s.Name == name { - m.sessions = append(m.sessions[:i], m.sessions[i+1:]...) - return nil - } - } - return errors.New("session not found: " + name) -} - -func (m *MockClient) IsInsideTmux() bool { - return m.insideTmux -} - -// SetSessions replaces the mock's session list. -func (m *MockClient) SetSessions(sessions []Session) { - m.sessions = sessions -} - -// SetInsideTmux sets the IsInsideTmux return value. -func (m *MockClient) SetInsideTmux(inside bool) { - m.insideTmux = inside -} - -// Sessions returns the current session list (for test assertions). -func (m *MockClient) Sessions() []Session { - return m.sessions -} diff --git a/internal/tmux/types.go b/internal/tmux/types.go index a86bfdd..696892f 100644 --- a/internal/tmux/types.go +++ b/internal/tmux/types.go @@ -6,15 +6,3 @@ type Session struct { WindowCount int Attached bool } - -// Client defines the interface for tmux operations. -// All operations shell out to the tmux binary. -type Client interface { - ListSessions() ([]Session, error) - NewSession(name string) error - SwitchSession(name string) error - RenameSession(old, new string) error - KillSession(name string) error - DetachSession() error - IsInsideTmux() bool -} diff --git a/internal/tui/model.go b/internal/tui/model.go index 3504496..5f62cde 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -5,153 +5,78 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -// Mode represents the current UI mode. +// Action is what the TUI tells the caller to do after exit. +type Action struct { + Kind string // "switch", "new", "rename", "kill", "detach", "" + Target string // session name + NewName string // for rename +} + +// Mode is the current UI state. type Mode int const ( - ModeList Mode = iota // Browsing sessions - ModeCreate // Typing a new session name - ModeRename // Typing a new name for existing session - ModeConfirmKill // Confirming session kill - ModeSetup // First-run setup prompt + ModeList Mode = iota + ModeCreate // typing new session name + ModeRename // typing new name + ModeConfirmKill // y/n confirmation ) -// sessionsMsg carries a refreshed session list. -type sessionsMsg struct { - sessions []tmux.Session - err error -} - -// operationMsg signals that an async tmux operation completed. -type operationMsg struct { - err error - switchTo string // if non-empty, quit after switching -} - -// setupDoneMsg signals that the setup operation completed. -type setupDoneMsg struct { - err error -} - -// SetupFunc is the function called to perform setup. -// Injected to keep the model testable without filesystem side effects. -type SetupFunc func() error - -// Model is the BubbleTea model for tmux-pilot. +// Model is the BubbleTea model. It collects user intent and exits. type Model struct { - client tmux.Client - sessions []tmux.Session - cursor int - mode Mode - input string - killName string // session name pending kill confirmation - err error - warning string // non-fatal warning (e.g. duplicate name) - width int - height int - quitting bool - setupFunc SetupFunc + sessions []tmux.Session + cursor int + mode Mode + input string + warning string + width int + height int + action Action // populated on exit } -// New creates a Model wired to the given tmux client. -// If needsSetup is true, the first screen will prompt to configure tmux. -func New(client tmux.Client, needsSetup bool, setupFn SetupFunc) *Model { - mode := ModeList - if needsSetup { - mode = ModeSetup - } +// New creates a new model with the given sessions. +func New(sessions []tmux.Session) *Model { return &Model{ - client: client, - width: 80, - height: 24, - mode: mode, - setupFunc: setupFn, + sessions: sessions, + width: 80, + height: 24, } } -// Init fetches the initial session list. -func (m *Model) Init() tea.Cmd { - return m.fetchSessions() -} +// Action returns the action chosen by the user (check after Run). +func (m *Model) Action() Action { return m.action } -// Update processes messages and returns the updated model + next command. +// Init is a no-op — sessions are passed in, no async needed. +func (m *Model) Init() tea.Cmd { return nil } + +// Update handles input. func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height return m, nil - - case sessionsMsg: - m.sessions = msg.sessions - m.err = msg.err - m.clampCursor() - return m, nil - - case operationMsg: - if msg.err != nil { - m.err = msg.err - } - m.mode = ModeList - m.input = "" - m.killName = "" - m.warning = "" - if msg.switchTo != "" { - m.quitting = true - return m, tea.Quit - } - return m, m.fetchSessions() - - case setupDoneMsg: - if msg.err != nil { - m.err = msg.err - } - m.mode = ModeList - return m, nil - case tea.KeyMsg: return m.handleKey(msg) } - return m, nil } -// handleKey dispatches key presses to the current mode handler. func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Normalize key string. BubbleTea converts both ESC[A and ESCOA - // to "up", but we also handle KeyType for robustness. - key := msg.String() - switch m.mode { - case ModeSetup: - return m.handleSetupKey(key) case ModeList: - return m.handleListKey(msg) + return m.listKey(msg) case ModeCreate: - return m.handleInputKey(key, m.doCreate) + return m.inputKey(msg, "new") case ModeRename: - return m.handleInputKey(key, m.doRename) + return m.inputKey(msg, "rename") case ModeConfirmKill: - return m.handleConfirmKey(key) + return m.confirmKey(msg) } return m, nil } -// handleSetupKey handles keys in the first-run setup prompt. -func (m *Model) handleSetupKey(key string) (tea.Model, tea.Cmd) { - switch key { - case "y", "enter": - return m, m.runSetup() - case "n", "esc", "q": - m.mode = ModeList - return m, nil - } - return m, nil -} - -// handleListKey handles keys in the session list view. -func (m *Model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Use KeyType for special keys (more robust than string matching) +func (m *Model) listKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.Type { case tea.KeyUp: if m.cursor > 0 { @@ -165,19 +90,16 @@ func (m *Model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case tea.KeyEnter: if len(m.sessions) > 0 { - name := m.sessions[m.cursor].Name - return m, m.switchSession(name) + m.action = Action{Kind: "switch", Target: m.sessions[m.cursor].Name} + return m, tea.Quit } return m, nil case tea.KeyEscape: - m.quitting = true return m, tea.Quit } - // Use string for single-char keys switch msg.String() { case "q": - m.quitting = true return m, tea.Quit case "k": if m.cursor > 0 { @@ -190,177 +112,94 @@ func (m *Model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "n": m.mode = ModeCreate m.input = "" - m.err = nil + m.warning = "" case "r": if len(m.sessions) > 0 { m.mode = ModeRename m.input = m.sessions[m.cursor].Name - m.err = nil + m.warning = "" } case "x": if len(m.sessions) > 0 { m.mode = ModeConfirmKill - m.killName = m.sessions[m.cursor].Name - m.err = nil } case "d": - return m, m.detachSession() + m.action = Action{Kind: "detach"} + return m, tea.Quit } return m, nil } -// handleInputKey handles keys in create/rename modes. -// submit is called when enter is pressed with non-empty input. -func (m *Model) handleInputKey(key string, submit func() tea.Cmd) (tea.Model, tea.Cmd) { - // Clear warning on any input change - switch key { - case "esc": +func (m *Model) inputKey(msg tea.KeyMsg, kind string) (tea.Model, tea.Cmd) { + switch msg.Type { + case tea.KeyEscape: m.mode = ModeList m.input = "" m.warning = "" return m, nil - case "enter": - if m.input != "" { - if m.sessionNameExists(m.input) { - m.warning = "Session '" + m.input + "' already exists" - return m, nil - } - m.warning = "" - return m, submit() + case tea.KeyEnter: + if m.input == "" { + return m, nil + } + // Check duplicate + if tmux.SessionExists(m.input) { + m.warning = "'" + m.input + "' already exists" + return m, nil } - return m, nil - case "backspace": m.warning = "" - if len(m.input) > 0 { - m.input = m.input[:len(m.input)-1] + if kind == "new" { + m.action = Action{Kind: "new", Target: m.input} + } else { + m.action = Action{Kind: "rename", Target: m.sessions[m.cursor].Name, NewName: m.input} } - return m, nil - default: + return m, tea.Quit + case tea.KeyBackspace: m.warning = "" - if len(key) == 1 && key[0] >= ' ' && key[0] <= '~' { - m.input += key + if len(m.input) > 0 { + m.input = m.input[:len(m.input)-1] } return m, nil } -} -// sessionNameExists checks if a session name is already in use. -func (m *Model) sessionNameExists(name string) bool { - for _, s := range m.sessions { - if s.Name == name { - return true - } + key := msg.String() + if len(key) == 1 && key[0] >= ' ' && key[0] <= '~' { + m.warning = "" + m.input += key } - return false + return m, nil } -// handleConfirmKey handles keys in the kill confirmation dialog. -func (m *Model) handleConfirmKey(key string) (tea.Model, tea.Cmd) { - switch key { - case "y", "enter": - name := m.killName - return m, m.killSession(name) - case "n", "esc": +func (m *Model) confirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "y": + m.action = Action{Kind: "kill", Target: m.sessions[m.cursor].Name} + return m, tea.Quit + case "n", "q": m.mode = ModeList - m.killName = "" return m, nil } - return m, nil -} - -// clampCursor ensures the cursor stays within bounds. -func (m *Model) clampCursor() { - if len(m.sessions) == 0 { - m.cursor = 0 - } else if m.cursor >= len(m.sessions) { - m.cursor = len(m.sessions) - 1 - } -} - -// --- Async commands --- - -func (m *Model) fetchSessions() tea.Cmd { - return func() tea.Msg { - sessions, err := m.client.ListSessions() - return sessionsMsg{sessions: sessions, err: err} - } -} - -func (m *Model) doCreate() tea.Cmd { - name := m.input - return func() tea.Msg { - if err := m.client.NewSession(name); err != nil { - return operationMsg{err: err} - } - return operationMsg{switchTo: name} - } -} - -func (m *Model) doRename() tea.Cmd { - oldName := m.sessions[m.cursor].Name - newName := m.input - return func() tea.Msg { - err := m.client.RenameSession(oldName, newName) - return operationMsg{err: err} - } -} - -func (m *Model) switchSession(name string) tea.Cmd { - return func() tea.Msg { - err := m.client.SwitchSession(name) - return operationMsg{err: err, switchTo: name} - } -} - -func (m *Model) killSession(name string) tea.Cmd { - return func() tea.Msg { - err := m.client.KillSession(name) - return operationMsg{err: err} + switch msg.Type { + case tea.KeyEnter: + m.action = Action{Kind: "kill", Target: m.sessions[m.cursor].Name} + return m, tea.Quit + case tea.KeyEscape: + m.mode = ModeList + return m, nil } + return m, nil } -func (m *Model) detachSession() tea.Cmd { - return func() tea.Msg { - err := m.client.DetachSession() - return operationMsg{err: err, switchTo: "detach"} - } -} +// --- Getters for view --- -func (m *Model) runSetup() tea.Cmd { - return func() tea.Msg { - if m.setupFunc == nil { - return setupDoneMsg{} - } - err := m.setupFunc() - return setupDoneMsg{err: err} +func (m *Model) Sessions() []tmux.Session { return m.sessions } +func (m *Model) Cursor() int { return m.cursor } +func (m *Model) Mode() Mode { return m.mode } +func (m *Model) Input() string { return m.input } +func (m *Model) Warning() string { return m.warning } +func (m *Model) Width() int { return m.width } +func (m *Model) KillName() string { + if m.mode == ModeConfirmKill && m.cursor < len(m.sessions) { + return m.sessions[m.cursor].Name } + return "" } - -// --- Getters for view and testing --- - -// Mode returns the current UI mode. -func (m *Model) Mode() Mode { return m.mode } - -// Sessions returns the current session list. -func (m *Model) Sessions() []tmux.Session { return m.sessions } - -// Cursor returns the current cursor position. -func (m *Model) Cursor() int { return m.cursor } - -// Input returns the current input buffer. -func (m *Model) Input() string { return m.input } - -// KillName returns the name of the session pending kill confirmation. -func (m *Model) KillName() string { return m.killName } - -// Err returns the current error, if any. -func (m *Model) Err() error { return m.err } - -// Warning returns the current warning message, if any. -func (m *Model) Warning() string { return m.warning } - -// Width returns the terminal width. -func (m *Model) Width() int { return m.width } - -// IsQuitting returns true if the model is in quitting state. -func (m *Model) IsQuitting() bool { return m.quitting } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 889e7f7..a730737 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -1,752 +1,202 @@ package tui import ( - "errors" "testing" "github.com/blockful/tmux-pilot/internal/tmux" tea "github.com/charmbracelet/bubbletea" ) -// helper to create a model pre-loaded with sessions. -func testModel() (*Model, *tmux.MockClient) { - mock := tmux.NewMockClient() - m := New(mock, false, nil) - // Simulate Init() by processing the sessionsMsg directly - m.sessions = []tmux.Session{ - {Name: "main", WindowCount: 3, Attached: true}, - {Name: "api-server", WindowCount: 1, Attached: false}, - {Name: "notes", WindowCount: 2, Attached: false}, - } - return m, mock -} - -func key(k string) tea.KeyMsg { - return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)} -} - -func specialKey(t tea.KeyType) tea.KeyMsg { - return tea.KeyMsg{Type: t} +var testSessions = []tmux.Session{ + {Name: "main", WindowCount: 3, Attached: true}, + {Name: "api", WindowCount: 1, Attached: false}, + {Name: "notes", WindowCount: 2, Attached: false}, } -// --- Initialization --- - -func TestNew(t *testing.T) { - mock := tmux.NewMockClient() - m := New(mock, false, nil) - - if m.Mode() != ModeList { - t.Errorf("expected ModeList, got %d", m.Mode()) - } - if m.Cursor() != 0 { - t.Errorf("expected cursor 0, got %d", m.Cursor()) - } - if m.Width() != 80 { - t.Errorf("expected width 80, got %d", m.Width()) - } -} - -func TestInit_ReturnsCommand(t *testing.T) { - mock := tmux.NewMockClient() - m := New(mock, false, nil) - cmd := m.Init() - if cmd == nil { - t.Error("Init() should return a command") - } -} +func newTestModel() *Model { return New(testSessions) } -// --- Window resize --- - -func TestWindowResize(t *testing.T) { - m, _ := testModel() - result, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) - model := result.(*Model) - - if model.Width() != 120 { - t.Errorf("expected width 120, got %d", model.Width()) - } -} - -// --- Sessions message --- - -func TestSessionsMsg(t *testing.T) { - m, _ := testModel() - sessions := []tmux.Session{ - {Name: "one", WindowCount: 1, Attached: false}, - } - - result, _ := m.Update(sessionsMsg{sessions: sessions}) - model := result.(*Model) - - if len(model.Sessions()) != 1 { - t.Errorf("expected 1 session, got %d", len(model.Sessions())) - } -} - -func TestSessionsMsg_CursorClamp(t *testing.T) { - m, _ := testModel() - m.cursor = 10 - - result, _ := m.Update(sessionsMsg{sessions: []tmux.Session{ - {Name: "only", WindowCount: 1, Attached: false}, - }}) - model := result.(*Model) +func key(k string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)} } +func special(t tea.KeyType) tea.KeyMsg { return tea.KeyMsg{Type: t} } - if model.Cursor() != 0 { - t.Errorf("expected cursor clamped to 0, got %d", model.Cursor()) +func update(m *Model, msgs ...tea.Msg) *Model { + for _, msg := range msgs { + result, _ := m.Update(msg) + m = result.(*Model) } + return m } -func TestSessionsMsg_EmptyList(t *testing.T) { - m, _ := testModel() - m.cursor = 2 - - result, _ := m.Update(sessionsMsg{sessions: nil}) - model := result.(*Model) +// --- Navigation --- - if model.Cursor() != 0 { - t.Errorf("expected cursor 0 for empty list, got %d", model.Cursor()) +func TestNavigation(t *testing.T) { + m := update(newTestModel(), key("j")) + if m.Cursor() != 1 { + t.Errorf("j: want cursor 1, got %d", m.Cursor()) } -} - -func TestSessionsMsg_WithError(t *testing.T) { - m, _ := testModel() - testErr := errors.New("connection refused") - - result, _ := m.Update(sessionsMsg{err: testErr}) - model := result.(*Model) - if model.Err() == nil { - t.Error("expected error to be set") + m = update(m, key("k")) + if m.Cursor() != 0 { + t.Errorf("k: want cursor 0, got %d", m.Cursor()) } -} - -// --- Navigation --- -func TestNavigation_Down(t *testing.T) { - m, _ := testModel() - result, _ := m.Update(key("j")) - model := result.(*Model) - if model.Cursor() != 1 { - t.Errorf("expected cursor 1, got %d", model.Cursor()) + m = update(newTestModel(), special(tea.KeyDown)) + if m.Cursor() != 1 { + t.Errorf("down: want cursor 1, got %d", m.Cursor()) } -} -func TestNavigation_Up(t *testing.T) { - m, _ := testModel() - m.cursor = 2 - result, _ := m.Update(key("k")) - model := result.(*Model) - if model.Cursor() != 1 { - t.Errorf("expected cursor 1, got %d", model.Cursor()) + m = update(newTestModel(), special(tea.KeyUp)) + if m.Cursor() != 0 { + t.Error("up at 0 should stay 0") } } -func TestNavigation_DownArrow(t *testing.T) { - m, _ := testModel() - result, _ := m.Update(specialKey(tea.KeyDown)) - model := result.(*Model) - if model.Cursor() != 1 { - t.Errorf("expected cursor 1, got %d", model.Cursor()) +func TestNavigation_Bounds(t *testing.T) { + m := newTestModel() + m = update(m, key("j"), key("j"), key("j"), key("j")) + if m.Cursor() != 2 { + t.Errorf("should clamp at last index, got %d", m.Cursor()) } } -func TestNavigation_UpArrow(t *testing.T) { - m, _ := testModel() - m.cursor = 1 - result, _ := m.Update(specialKey(tea.KeyUp)) - model := result.(*Model) - if model.Cursor() != 0 { - t.Errorf("expected cursor 0, got %d", model.Cursor()) - } -} +// --- Switch --- -func TestNavigation_BoundsTop(t *testing.T) { - m, _ := testModel() - m.cursor = 0 - result, _ := m.Update(key("k")) +func TestSwitch(t *testing.T) { + m := newTestModel() + result, cmd := m.Update(special(tea.KeyEnter)) model := result.(*Model) - if model.Cursor() != 0 { - t.Errorf("cursor should not go below 0, got %d", model.Cursor()) + if model.Action().Kind != "switch" || model.Action().Target != "main" { + t.Errorf("want switch/main, got %v", model.Action()) } -} - -func TestNavigation_BoundsBottom(t *testing.T) { - m, _ := testModel() - m.cursor = 2 // last index - result, _ := m.Update(key("j")) - model := result.(*Model) - if model.Cursor() != 2 { - t.Errorf("cursor should not exceed last index, got %d", model.Cursor()) + if cmd == nil { + t.Error("should quit") } } // --- Quit --- -func TestQuit_Q(t *testing.T) { - m, _ := testModel() +func TestQuit(t *testing.T) { + m := newTestModel() _, cmd := m.Update(key("q")) if cmd == nil { - t.Error("q should produce quit command") + t.Error("q should quit") + } + if m.Action().Kind != "" { + t.Error("quit should have no action") } } func TestQuit_Esc(t *testing.T) { - m, _ := testModel() - _, cmd := m.Update(specialKey(tea.KeyEscape)) + _, cmd := newTestModel().Update(special(tea.KeyEscape)) if cmd == nil { - t.Error("esc should produce quit command") + t.Error("esc should quit") } } -// --- Mode transitions --- +// --- Create mode --- -func TestEnterCreateMode(t *testing.T) { - m, _ := testModel() - result, _ := m.Update(key("n")) - model := result.(*Model) - if model.Mode() != ModeCreate { - t.Errorf("expected ModeCreate, got %d", model.Mode()) +func TestCreate(t *testing.T) { + m := update(newTestModel(), key("n")) + if m.Mode() != ModeCreate { + t.Error("n should enter create mode") } - if model.Input() != "" { - t.Error("input should be empty on entering create mode") - } -} -func TestEnterRenameMode(t *testing.T) { - m, _ := testModel() - m.cursor = 1 // api-server - result, _ := m.Update(key("r")) - model := result.(*Model) - if model.Mode() != ModeRename { - t.Errorf("expected ModeRename, got %d", model.Mode()) - } - if model.Input() != "api-server" { - t.Errorf("expected input pre-filled with 'api-server', got %q", model.Input()) + m = update(m, key("d"), key("e"), key("v")) + if m.Input() != "dev" { + t.Errorf("want input 'dev', got %q", m.Input()) } -} -func TestEnterRenameMode_EmptySessions(t *testing.T) { - m, _ := testModel() - m.sessions = nil - result, _ := m.Update(key("r")) + result, cmd := m.Update(special(tea.KeyEnter)) model := result.(*Model) - if model.Mode() != ModeList { - t.Error("should stay in list mode with no sessions") + if model.Action().Kind != "new" || model.Action().Target != "dev" { + t.Errorf("want new/dev, got %v", model.Action()) } -} - -func TestEnterConfirmKill(t *testing.T) { - m, _ := testModel() - m.cursor = 1 - result, _ := m.Update(key("x")) - model := result.(*Model) - if model.Mode() != ModeConfirmKill { - t.Errorf("expected ModeConfirmKill, got %d", model.Mode()) - } - if model.KillName() != "api-server" { - t.Errorf("expected killName 'api-server', got %q", model.KillName()) - } -} - -func TestEnterConfirmKill_EmptySessions(t *testing.T) { - m, _ := testModel() - m.sessions = nil - result, _ := m.Update(key("x")) - model := result.(*Model) - if model.Mode() != ModeList { - t.Error("should stay in list mode with no sessions") - } -} - -// --- Input handling --- - -func TestInput_TypeCharacters(t *testing.T) { - m, _ := testModel() - m.mode = ModeCreate - - for _, ch := range "test" { - result, _ := m.Update(key(string(ch))) - m = result.(*Model) - } - - if m.Input() != "test" { - t.Errorf("expected 'test', got %q", m.Input()) - } -} - -func TestInput_Backspace(t *testing.T) { - m, _ := testModel() - m.mode = ModeCreate - m.input = "test" - - result, _ := m.Update(specialKey(tea.KeyBackspace)) - model := result.(*Model) - - if model.Input() != "tes" { - t.Errorf("expected 'tes', got %q", model.Input()) - } -} - -func TestInput_BackspaceEmpty(t *testing.T) { - m, _ := testModel() - m.mode = ModeCreate - m.input = "" - - result, _ := m.Update(specialKey(tea.KeyBackspace)) - model := result.(*Model) - - if model.Input() != "" { - t.Error("backspace on empty should stay empty") - } -} - -func TestInput_EscCancels(t *testing.T) { - m, _ := testModel() - m.mode = ModeCreate - m.input = "partial" - - result, _ := m.Update(specialKey(tea.KeyEscape)) - model := result.(*Model) - - if model.Mode() != ModeList { - t.Error("esc should return to list mode") - } - if model.Input() != "" { - t.Error("input should be cleared on cancel") - } -} - -func TestInput_EnterEmpty(t *testing.T) { - m, _ := testModel() - m.mode = ModeCreate - m.input = "" - - result, cmd := m.Update(specialKey(tea.KeyEnter)) - model := result.(*Model) - - if cmd != nil { - t.Error("enter with empty input should not produce a command") - } - if model.Mode() != ModeCreate { - t.Error("should stay in create mode with empty input") - } -} - -func TestInput_IgnoresControlChars(t *testing.T) { - m, _ := testModel() - m.mode = ModeCreate - m.input = "" - - // Tab and other control characters should be ignored - result, _ := m.Update(specialKey(tea.KeyTab)) - model := result.(*Model) - - if model.Input() != "" { - t.Error("control characters should be ignored") - } -} - -// --- Create mode submit --- - -func TestCreate_Submit(t *testing.T) { - m, _ := testModel() - m.mode = ModeCreate - m.input = "new-session" - - _, cmd := m.Update(specialKey(tea.KeyEnter)) if cmd == nil { - t.Error("enter with input should produce a command") - } - - // Execute the command to get the operationMsg - msg := cmd() - op, ok := msg.(operationMsg) - if !ok { - t.Fatal("expected operationMsg") - } - if op.err != nil { - t.Errorf("unexpected error: %v", op.err) - } - if op.switchTo != "new-session" { - t.Errorf("expected switchTo 'new-session', got %q", op.switchTo) + t.Error("should quit after create") } } -// --- Rename mode submit --- - -func TestRename_Submit(t *testing.T) { - m, _ := testModel() - m.mode = ModeRename - m.input = "renamed" - m.cursor = 0 // "main" - - _, cmd := m.Update(specialKey(tea.KeyEnter)) - if cmd == nil { - t.Error("enter should produce a command") - } - - msg := cmd() - op, ok := msg.(operationMsg) - if !ok { - t.Fatal("expected operationMsg") - } - if op.err != nil { - t.Errorf("unexpected error: %v", op.err) - } -} - -// --- Confirm kill --- - -func TestConfirmKill_Yes(t *testing.T) { - m, mock := testModel() - m.mode = ModeConfirmKill - m.killName = "notes" - - _, cmd := m.Update(key("y")) - if cmd == nil { - t.Error("y should produce kill command") - } - - msg := cmd() - op := msg.(operationMsg) - if op.err != nil { - t.Errorf("unexpected error: %v", op.err) - } - - // Verify the session was killed in the mock - if len(mock.KillCalls) != 1 || mock.KillCalls[0] != "notes" { - t.Errorf("expected KillCalls=[notes], got %v", mock.KillCalls) - } -} - -func TestConfirmKill_Enter(t *testing.T) { - m, _ := testModel() - m.mode = ModeConfirmKill - m.killName = "notes" - - _, cmd := m.Update(specialKey(tea.KeyEnter)) - if cmd == nil { - t.Error("enter should confirm kill") +func TestCreate_Cancel(t *testing.T) { + m := update(newTestModel(), key("n"), key("d")) + m = update(m, special(tea.KeyEscape)) + if m.Mode() != ModeList { + t.Error("esc should return to list") } } -func TestConfirmKill_No(t *testing.T) { - m, _ := testModel() - m.mode = ModeConfirmKill - m.killName = "notes" - - result, _ := m.Update(key("n")) - model := result.(*Model) - - if model.Mode() != ModeList { - t.Error("n should return to list mode") - } - if model.KillName() != "" { - t.Error("killName should be cleared") +func TestCreate_Backspace(t *testing.T) { + m := update(newTestModel(), key("n"), key("a"), key("b")) + m = update(m, special(tea.KeyBackspace)) + if m.Input() != "a" { + t.Errorf("want 'a', got %q", m.Input()) } } -func TestConfirmKill_Esc(t *testing.T) { - m, _ := testModel() - m.mode = ModeConfirmKill - m.killName = "notes" - - result, _ := m.Update(specialKey(tea.KeyEscape)) - model := result.(*Model) - - if model.Mode() != ModeList { - t.Error("esc should return to list mode") +func TestCreate_EmptyEnter(t *testing.T) { + m := update(newTestModel(), key("n")) + _, cmd := m.Update(special(tea.KeyEnter)) + if cmd != nil { + t.Error("empty enter should not quit") } } -// --- Operation message --- - -func TestOperationMsg_Success(t *testing.T) { - m, _ := testModel() - m.mode = ModeCreate - m.input = "something" +// --- Rename --- - result, cmd := m.Update(operationMsg{err: nil}) - model := result.(*Model) - - if model.Mode() != ModeList { - t.Error("should return to list mode") +func TestRename(t *testing.T) { + m := update(newTestModel(), key("r")) + if m.Mode() != ModeRename { + t.Error("r should enter rename mode") } - if model.Input() != "" { - t.Error("input should be cleared") - } - if cmd == nil { - t.Error("should produce refresh command") + if m.Input() != "main" { + t.Errorf("should prefill with 'main', got %q", m.Input()) } } -func TestOperationMsg_WithSwitch(t *testing.T) { - m, _ := testModel() - result, cmd := m.Update(operationMsg{switchTo: "new"}) - model := result.(*Model) +// --- Kill --- - if !model.IsQuitting() { - t.Error("should be quitting after switch") - } - if cmd == nil { - t.Error("should produce quit command") +func TestKill(t *testing.T) { + m := update(newTestModel(), key("x")) + if m.Mode() != ModeConfirmKill { + t.Error("x should enter confirm mode") } -} -func TestOperationMsg_Error(t *testing.T) { - m, _ := testModel() - testErr := errors.New("failed") - - result, _ := m.Update(operationMsg{err: testErr}) + result, cmd := m.Update(key("y")) model := result.(*Model) - - if model.Err() == nil { - t.Error("error should be set") + if model.Action().Kind != "kill" || model.Action().Target != "main" { + t.Errorf("want kill/main, got %v", model.Action()) } - if model.Mode() != ModeList { - t.Error("should return to list mode even on error") - } -} - -// --- Switch session --- - -func TestSwitch_Enter(t *testing.T) { - m, mock := testModel() - m.cursor = 1 // api-server - - _, cmd := m.Update(specialKey(tea.KeyEnter)) if cmd == nil { - t.Error("enter should produce switch command") - } - - msg := cmd() - op := msg.(operationMsg) - if op.switchTo != "api-server" { - t.Errorf("expected switchTo 'api-server', got %q", op.switchTo) - } - if len(mock.SwitchCalls) != 1 || mock.SwitchCalls[0] != "api-server" { - t.Errorf("expected SwitchCalls=[api-server], got %v", mock.SwitchCalls) - } -} - -func TestSwitch_EmptySessions(t *testing.T) { - m, _ := testModel() - m.sessions = nil - - _, cmd := m.Update(specialKey(tea.KeyEnter)) - if cmd != nil { - t.Error("enter with no sessions should not produce a command") - } -} - -// --- Error clearing on mode change --- - -func TestErrorClearedOnModeChange(t *testing.T) { - m, _ := testModel() - m.err = errors.New("stale error") - - result, _ := m.Update(key("n")) - model := result.(*Model) - - if model.Err() != nil { - t.Error("error should be cleared when entering create mode") - } -} - -// --- Setup mode --- - -func TestNew_WithSetup(t *testing.T) { - mock := tmux.NewMockClient() - m := New(mock, true, nil) - if m.Mode() != ModeSetup { - t.Errorf("expected ModeSetup, got %d", m.Mode()) + t.Error("should quit after kill") } } -func TestNew_WithoutSetup(t *testing.T) { - mock := tmux.NewMockClient() - m := New(mock, false, nil) +func TestKill_Cancel(t *testing.T) { + m := update(newTestModel(), key("x")) + m = update(m, key("n")) if m.Mode() != ModeList { - t.Errorf("expected ModeList, got %d", m.Mode()) - } -} - -func TestSetup_AcceptY(t *testing.T) { - mock := tmux.NewMockClient() - called := false - setupFn := func() error { called = true; return nil } - m := New(mock, true, setupFn) - - _, cmd := m.Update(key("y")) - if cmd == nil { - t.Fatal("y should produce setup command") - } - msg := cmd() - if !called { - t.Error("setup function should have been called") - } - result, _ := m.Update(msg) - model := result.(*Model) - if model.Mode() != ModeList { - t.Errorf("expected ModeList after setup, got %d", model.Mode()) - } -} - -func TestSetup_AcceptEnter(t *testing.T) { - mock := tmux.NewMockClient() - setupFn := func() error { return nil } - m := New(mock, true, setupFn) - - _, cmd := m.Update(specialKey(tea.KeyEnter)) - if cmd == nil { - t.Error("enter should produce setup command") - } -} - -func TestSetup_Skip(t *testing.T) { - mock := tmux.NewMockClient() - m := New(mock, true, nil) - - result, _ := m.Update(key("n")) - model := result.(*Model) - if model.Mode() != ModeList { - t.Errorf("expected ModeList after skip, got %d", model.Mode()) - } -} - -func TestSetup_SkipEsc(t *testing.T) { - mock := tmux.NewMockClient() - m := New(mock, true, nil) - - result, _ := m.Update(specialKey(tea.KeyEscape)) - model := result.(*Model) - if model.Mode() != ModeList { - t.Error("esc should skip to list mode") - } -} - -func TestSetup_Error(t *testing.T) { - mock := tmux.NewMockClient() - setupFn := func() error { return errors.New("permission denied") } - m := New(mock, true, setupFn) - - _, cmd := m.Update(key("y")) - msg := cmd() - result, _ := m.Update(msg) - model := result.(*Model) - - if model.Err() == nil { - t.Error("setup error should be set") - } - if model.Mode() != ModeList { - t.Error("should return to list mode even on error") + t.Error("n should cancel kill") } } // --- Detach --- func TestDetach(t *testing.T) { - m, mock := testModel() - - _, cmd := m.Update(key("d")) - if cmd == nil { - t.Fatal("d should produce detach command") - } - - msg := cmd() - op := msg.(operationMsg) - if op.err != nil { - t.Errorf("unexpected error: %v", op.err) - } - if mock.DetachCalls != 1 { - t.Errorf("expected 1 DetachCall, got %d", mock.DetachCalls) - } - - // Should quit after detach - result, _ := m.Update(msg) - model := result.(*Model) - if !model.IsQuitting() { - t.Error("should quit after detach") - } -} - -// --- Rename duplicate warning --- - -func TestRename_DuplicateName(t *testing.T) { - m, _ := testModel() - m.mode = ModeRename - m.input = "api-server" // already exists - m.cursor = 0 // renaming "main" - - result, cmd := m.Update(specialKey(tea.KeyEnter)) + m := newTestModel() + result, cmd := m.Update(key("d")) model := result.(*Model) - - if cmd != nil { - t.Error("should not produce command for duplicate name") + if model.Action().Kind != "detach" { + t.Errorf("want detach, got %v", model.Action()) } - if model.Warning() == "" { - t.Error("should set warning for duplicate name") - } - if model.Mode() != ModeRename { - t.Error("should stay in rename mode") + if cmd == nil { + t.Error("should quit after detach") } } -func TestCreate_DuplicateName(t *testing.T) { - m, _ := testModel() - m.mode = ModeCreate - m.input = "main" // already exists - - result, cmd := m.Update(specialKey(tea.KeyEnter)) - model := result.(*Model) +// --- Empty sessions --- +func TestEmptySessions(t *testing.T) { + m := New(nil) + _, cmd := m.Update(special(tea.KeyEnter)) if cmd != nil { - t.Error("should not produce command for duplicate name") - } - if model.Warning() == "" { - t.Error("should set warning for duplicate name") - } - if model.Mode() != ModeCreate { - t.Error("should stay in create mode") - } -} - -func TestRename_WarningClearsOnType(t *testing.T) { - m, _ := testModel() - m.mode = ModeRename - m.warning = "stale warning" - - result, _ := m.Update(key("a")) - model := result.(*Model) - - if model.Warning() != "" { - t.Error("warning should clear on typing") - } -} - -func TestRename_WarningClearsOnBackspace(t *testing.T) { - m, _ := testModel() - m.mode = ModeRename - m.input = "test" - m.warning = "stale warning" - - result, _ := m.Update(specialKey(tea.KeyBackspace)) - model := result.(*Model) - - if model.Warning() != "" { - t.Error("warning should clear on backspace") - } -} - -func TestRename_UniqueName(t *testing.T) { - m, _ := testModel() - m.mode = ModeRename - m.input = "unique-name" - m.cursor = 0 - - _, cmd := m.Update(specialKey(tea.KeyEnter)) - if cmd == nil { - t.Error("should produce command for unique name") + t.Error("enter with no sessions should not quit") } } diff --git a/internal/tui/view.go b/internal/tui/view.go index 3dc2ec6..c77fa56 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -8,214 +8,106 @@ import ( "github.com/charmbracelet/lipgloss" ) -// --- Color palette --- - var ( - accent = lipgloss.Color("205") // magenta/pink - text = lipgloss.Color("252") // light gray - dim = lipgloss.Color("243") // muted gray - border = lipgloss.Color("240") // border gray - green = lipgloss.Color("46") // attached indicator - red = lipgloss.Color("196") // errors - inputBg = lipgloss.Color("237") // input background -) - -// --- Styles --- - -var ( - frameStyle = lipgloss.NewStyle(). - BorderStyle(lipgloss.RoundedBorder()). - BorderForeground(border). - Padding(0, 1) - - titleStyle = lipgloss.NewStyle(). - Foreground(accent). - Bold(true) - - sessionStyle = lipgloss.NewStyle(). - Foreground(text) - - selectedStyle = lipgloss.NewStyle(). - Foreground(accent). - Background(inputBg). - PaddingLeft(1). - PaddingRight(1) - - attachedDot = lipgloss.NewStyle(). - Foreground(green). - Bold(true) - - detachedDot = lipgloss.NewStyle(). - Foreground(dim) - - helpStyle = lipgloss.NewStyle(). - Foreground(dim) - - tipStyle = lipgloss.NewStyle(). - Foreground(dim). - Italic(true) - - inputStyle = lipgloss.NewStyle(). - Foreground(text). - Background(inputBg). - PaddingLeft(1). - PaddingRight(1) - - errorStyle = lipgloss.NewStyle(). - Foreground(red). - Bold(true) - - warnStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("214")). - Bold(true) - - dimText = lipgloss.NewStyle(). - Foreground(dim) + accent = lipgloss.Color("205") + text = lipgloss.Color("252") + dim = lipgloss.Color("243") + border = lipgloss.Color("240") + green = lipgloss.Color("46") + yellow = lipgloss.Color("214") + inputBg = lipgloss.Color("237") + + frame = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(border).Padding(0, 1) + title = lipgloss.NewStyle().Foreground(accent).Bold(true) + selected = lipgloss.NewStyle().Foreground(accent).Background(inputBg).PaddingLeft(1).PaddingRight(1) + normal = lipgloss.NewStyle().Foreground(text) + dotOn = lipgloss.NewStyle().Foreground(green).Bold(true) + dotOff = lipgloss.NewStyle().Foreground(dim) + dimStyle = lipgloss.NewStyle().Foreground(dim) + warnStyle = lipgloss.NewStyle().Foreground(yellow).Bold(true) + inputShow = lipgloss.NewStyle().Foreground(text).Background(inputBg).PaddingLeft(1).PaddingRight(1) + tipStyle = lipgloss.NewStyle().Foreground(dim).Italic(true) ) -// View renders the current model state. +// View renders the UI. func (m *Model) View() string { - if m.quitting { - return "" - } - var content string switch m.mode { - case ModeSetup: - content = m.viewSetup() case ModeList: content = m.viewList() case ModeCreate: - content = m.viewInput("Create new session:", "[enter] create [esc] cancel") + content = m.viewInput("New session name:") case ModeRename: - label := "Rename session:" - if len(m.sessions) > 0 && m.cursor < len(m.sessions) { - label = fmt.Sprintf("Rename '%s':", m.sessions[m.cursor].Name) - } - content = m.viewInput(label, "[enter] rename [esc] cancel") + content = m.viewInput(fmt.Sprintf("Rename '%s':", m.sessions[m.cursor].Name)) case ModeConfirmKill: content = m.viewConfirm() } - - w := max(50, m.width-4) - return frameStyle.Width(w).Render(content) + return frame.Width(max(50, m.width-4)).Render(content) } -// viewSetup renders the first-run setup prompt. -func (m *Model) viewSetup() string { - var b strings.Builder - - b.WriteString(titleStyle.Render("tmux-pilot")) - b.WriteString("\n\n") - b.WriteString(" Add tmux keybinding? (prefix + s)\n\n") - b.WriteString(dimText.Render(" This adds to ~/.tmux.conf:")) - b.WriteString("\n") - b.WriteString(dimText.Render(` bind s display-popup -E -w 60% -h 50% "tmux-pilot"`)) - b.WriteString("\n\n") - b.WriteString(helpStyle.Render(" [y/enter] yes [n/esc] skip")) - - if m.err != nil { - b.WriteString("\n\n") - b.WriteString(errorStyle.Render(" Error: " + m.err.Error())) - } - - return b.String() -} - -// viewList renders the session list with help bar. func (m *Model) viewList() string { var b strings.Builder - - b.WriteString(titleStyle.Render("tmux-pilot")) + b.WriteString(title.Render("tmux-pilot")) b.WriteString("\n\n") if len(m.sessions) == 0 { - b.WriteString(dimText.Render(" No sessions found")) + b.WriteString(dimStyle.Render(" No sessions")) } else { for i, s := range m.sessions { - b.WriteString(formatSession(s, i == m.cursor)) + b.WriteString(fmtSession(s, i == m.cursor)) b.WriteString("\n") } } b.WriteString("\n") - b.WriteString(helpStyle.Render(" [enter] switch [n] new [r] rename")) + b.WriteString(dimStyle.Render(" [enter] switch [n] new [r] rename")) b.WriteString("\n") - b.WriteString(helpStyle.Render(" [x] kill [d] detach [q] quit")) + b.WriteString(dimStyle.Render(" [x] kill [d] detach [q] quit")) b.WriteString("\n\n") - b.WriteString(tipStyle.Render(" tip: Ctrl-b d to detach from tmux")) - - if m.err != nil { - b.WriteString("\n\n") - b.WriteString(errorStyle.Render(" Error: " + m.err.Error())) - } - + b.WriteString(tipStyle.Render(" tip: Ctrl-b d to detach")) return b.String() } -// viewInput renders create/rename prompts. -func (m *Model) viewInput(label, help string) string { +func (m *Model) viewInput(label string) string { var b strings.Builder - - b.WriteString(titleStyle.Render("tmux-pilot")) + b.WriteString(title.Render("tmux-pilot")) b.WriteString("\n\n") b.WriteString(" " + label + "\n\n") - b.WriteString(" " + inputStyle.Render(m.input+"█")) - + b.WriteString(" " + inputShow.Render(m.input+"█")) if m.warning != "" { b.WriteString("\n\n") b.WriteString(warnStyle.Render(" ⚠ " + m.warning)) } - b.WriteString("\n\n") - b.WriteString(helpStyle.Render(" " + help)) - - if m.err != nil { - b.WriteString("\n\n") - b.WriteString(errorStyle.Render(" Error: " + m.err.Error())) - } - + b.WriteString(dimStyle.Render(" [enter] confirm [esc] cancel")) return b.String() } -// viewConfirm renders the kill confirmation dialog. func (m *Model) viewConfirm() string { var b strings.Builder - - b.WriteString(titleStyle.Render("tmux-pilot")) + b.WriteString(title.Render("tmux-pilot")) b.WriteString("\n\n") - fmt.Fprintf(&b, " Kill session '%s'?\n\n", m.killName) - b.WriteString(helpStyle.Render(" [y/enter] yes [n/esc] no")) - + fmt.Fprintf(&b, " Kill session '%s'?\n\n", m.sessions[m.cursor].Name) + b.WriteString(dimStyle.Render(" [y/enter] yes [n/esc] no")) return b.String() } -// formatSession renders a single session line. -func formatSession(s tmux.Session, selected bool) string { - // Dot indicator - var dot string +func fmtSession(s tmux.Session, sel bool) string { + dot := dotOff.Render("○") if s.Attached { - dot = attachedDot.Render("●") - } else { - dot = detachedDot.Render("○") + dot = dotOn.Render("●") } - - // Window count win := fmt.Sprintf("%d window", s.WindowCount) if s.WindowCount != 1 { win += "s" } - - // Status status := "detached" if s.Attached { status = "attached" } - info := fmt.Sprintf("%-15s %s %s", s.Name, win, status) - - if selected { - return " " + dot + " " + selectedStyle.Render(info) + if sel { + return " " + dot + " " + selected.Render(info) } - return " " + dot + " " + sessionStyle.Render(info) + return " " + dot + " " + normal.Render(info) } diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index 717721a..6f77b95 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -3,179 +3,44 @@ package tui import ( "strings" "testing" - - "github.com/blockful/tmux-pilot/internal/tmux" ) -func TestView_ListMode(t *testing.T) { - m, _ := testModel() - view := m.View() - - checks := []string{ - "tmux-pilot", - "main", - "api-server", - "notes", - "attached", - "detached", - "[enter] switch", - "[n] new", - "[r] rename", - "[x] kill", - "[q] quit", - "Ctrl-b d", - } - for _, check := range checks { - if !strings.Contains(view, check) { - t.Errorf("list view missing %q", check) +func TestView_List(t *testing.T) { + m := newTestModel() + v := m.View() + for _, want := range []string{"tmux-pilot", "main", "api", "notes", "[enter] switch", "[d] detach"} { + if !strings.Contains(v, want) { + t.Errorf("list view missing %q", want) } } } -func TestView_ListMode_Empty(t *testing.T) { - m, _ := testModel() - m.sessions = nil - view := m.View() - - if !strings.Contains(view, "No sessions found") { - t.Error("empty list should show 'No sessions found'") - } -} - -func TestView_ListMode_WindowPluralization(t *testing.T) { - m, _ := testModel() - m.sessions = []tmux.Session{ - {Name: "one", WindowCount: 1, Attached: false}, - {Name: "many", WindowCount: 5, Attached: false}, - } - view := m.View() - - if !strings.Contains(view, "1 window ") { - t.Error("single window should not be pluralized") - } - if !strings.Contains(view, "5 windows") { - t.Error("multiple windows should be pluralized") +func TestView_Empty(t *testing.T) { + m := New(nil) + if !strings.Contains(m.View(), "No sessions") { + t.Error("empty view should say 'No sessions'") } } -func TestView_ListMode_Indicators(t *testing.T) { - m, _ := testModel() - view := m.View() - - if !strings.Contains(view, "●") { - t.Error("attached session should have ● indicator") - } - if !strings.Contains(view, "○") { - t.Error("detached session should have ○ indicator") - } -} - -func TestView_CreateMode(t *testing.T) { - m, _ := testModel() - m.mode = ModeCreate - m.input = "new-session" - view := m.View() - - if !strings.Contains(view, "Create new session:") { - t.Error("create view missing label") - } - if !strings.Contains(view, "new-session") { - t.Error("create view missing input text") - } - if !strings.Contains(view, "[enter] create") { - t.Error("create view missing help") - } -} - -func TestView_RenameMode(t *testing.T) { - m, _ := testModel() - m.mode = ModeRename - m.cursor = 0 - m.input = "primary" - view := m.View() - - if !strings.Contains(view, "Rename 'main':") { - t.Error("rename view missing session name") - } - if !strings.Contains(view, "primary") { - t.Error("rename view missing input text") +func TestView_Create(t *testing.T) { + m := update(newTestModel(), key("n"), key("d"), key("e"), key("v")) + v := m.View() + if !strings.Contains(v, "New session name:") || !strings.Contains(v, "dev") { + t.Error("create view missing prompt or input") } } -func TestView_ConfirmKill(t *testing.T) { - m, _ := testModel() - m.mode = ModeConfirmKill - m.killName = "notes" - view := m.View() - - if !strings.Contains(view, "Kill session 'notes'?") { +func TestView_Confirm(t *testing.T) { + m := update(newTestModel(), key("x")) + if !strings.Contains(m.View(), "Kill session 'main'?") { t.Error("confirm view missing session name") } - if !strings.Contains(view, "[y/enter] yes") { - t.Error("confirm view missing help") - } -} - -func TestView_WithError(t *testing.T) { - m, _ := testModel() - m.err = &testError{"something broke"} - view := m.View() - - if !strings.Contains(view, "Error: something broke") { - t.Error("error should be displayed") - } -} - -func TestView_Quitting(t *testing.T) { - m, _ := testModel() - m.quitting = true - view := m.View() - - if view != "" { - t.Error("quitting should render empty string") - } -} - -type testError struct{ msg string } - -func (e *testError) Error() string { return e.msg } - -func TestView_SetupMode(t *testing.T) { - m, _ := testModel() - m.mode = ModeSetup - view := m.View() - - checks := []string{ - "tmux-pilot", - "Add tmux keybinding", - "prefix + s", - ".tmux.conf", - "[y/enter] yes", - "[n/esc] skip", - } - for _, check := range checks { - if !strings.Contains(view, check) { - t.Errorf("setup view missing %q", check) - } - } -} - -func TestView_ListMode_DetachKey(t *testing.T) { - m, _ := testModel() - view := m.View() - if !strings.Contains(view, "[d] detach") { - t.Error("list view missing detach keybinding") - } } func TestView_Warning(t *testing.T) { - m, _ := testModel() - m.mode = ModeRename - m.input = "main" - m.warning = "Session 'main' already exists" - view := m.View() - - if !strings.Contains(view, "Session 'main' already exists") { - t.Error("warning should be displayed") + m := update(newTestModel(), key("n")) + m.warning = "already exists" + if !strings.Contains(m.View(), "already exists") { + t.Error("warning not shown") } }