diff --git a/.gitignore b/.gitignore index 9dadb843bd..a4fb3479c9 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,7 @@ docs/_static/css/fonts.css **/CLAUDE.local.md **/CLAUDE.*.md **/.claude/settings.local.json +.claude/commands/ + +# Development notes +notes/ diff --git a/CHANGES b/CHANGES index 0330651353..2c3ddf19e9 100644 --- a/CHANGES +++ b/CHANGES @@ -33,11 +33,95 @@ $ pipx install \ ## tmuxp 1.68.0 (Yet to be released) - +### New commands - -_Notes on the upcoming release will go here._ - +#### `tmuxp stop` — kill a tmux session (#1025) +Stop (kill) a running tmux session by name. Runs the `on_project_stop` +lifecycle hook before killing the session, giving your project a chance +to clean up. + +```console +$ tmuxp stop mysession +``` + +#### `tmuxp new` — create a workspace config (#1025) +Create a new workspace configuration file from a minimal template and +open it in `$EDITOR`. + +```console +$ tmuxp new myproject +``` + +#### `tmuxp copy` — copy a workspace config (#1025) +Copy an existing workspace config to a new name. Source is resolved +using the same logic as `tmuxp load`. + +```console +$ tmuxp copy myproject myproject-backup +``` + +#### `tmuxp delete` — delete workspace configs (#1025) +Delete one or more workspace config files. Prompts for confirmation +unless `-y` is passed. + +```console +$ tmuxp delete old-project +``` + +### Lifecycle hooks (#1025) +Workspace configs now support four lifecycle hooks inspired by tmuxinator: + +- `on_project_start` — runs before session build (new session creation only) +- `on_project_restart` — runs when reattaching to an existing session (confirmed attach only) +- `on_project_exit` — runs when the last client detaches (via tmux `client-detached` hook) +- `on_project_stop` — runs before `tmuxp stop` kills the session + +### Config templating (#1025) +Workspace configs now support simple `{{ variable }}` placeholders for variable substitution. +Pass values via `--set KEY=VALUE` on the command line: + +```console +$ tmuxp load --set project=myapp mytemplate.yaml +``` + +### New config keys (#1025) +- **`enable_pane_titles`** / **`pane_title_position`** / **`pane_title_format`** — + session-level keys that enable tmux pane border titles. +- **`title`** — pane-level key to set individual pane titles via + `select-pane -T`. +- **`synchronize`** — window-level shorthand (`before` / `after` / `true`) + that sets `synchronize-panes` without needing `options_after`. +- **`shell_command_after`** — window-level key; commands sent to every pane + after the window is fully built. +- **`clear`** — window-level boolean; sends `clear` to every pane after + commands complete. + +### New `tmuxp load` flags (#1025) +- `--here` — reuse the current tmux window instead of creating a new session. +- `--no-shell-command-before` — skip all `shell_command_before` entries. +- `--debug` — show tmux commands as they execute (disables progress spinner). +- `--set KEY=VALUE` — pass template variables for config templating. + +### Importer improvements (#1025) +#### tmuxinator + +- Map `pre` → `on_project_start`, `pre_window` → `shell_command_before`. +- Parse `cli_args` (`-f`, `-S`, `-L`) into tmuxp equivalents. +- Convert `synchronize` window key. +- Convert `startup_window` / `startup_pane` → `focus: true`. +- Convert named panes (hash-key syntax) → `title` on the pane. + +#### teamocil + +- Support v1.x format (`windows` at top level, `commands` key in panes). +- Convert `focus: true` on windows and panes. +- Pass through window `options`. + +### Bug fixes + +- Only fire `on_project_start` hook when load actually proceeds (not on + cancellation) (#1025) +- Only fire `on_project_restart` after the user confirms reattach (#1025) ## tmuxp 1.67.0 (2026-03-08) diff --git a/conftest.py b/conftest.py index 5ae04a57a3..53307213af 100644 --- a/conftest.py +++ b/conftest.py @@ -100,6 +100,9 @@ def socket_name(request: pytest.FixtureRequest) -> str: # Modules that actually need tmux fixtures in their doctests DOCTEST_NEEDS_TMUX = { + "tmuxp.cli.load", + "tmuxp.cli.stop", + "tmuxp.util", "tmuxp.workspace.builder", } diff --git a/docs/cli/copy.md b/docs/cli/copy.md new file mode 100644 index 0000000000..b84199601e --- /dev/null +++ b/docs/cli/copy.md @@ -0,0 +1,25 @@ +(cli-copy)= + +(cli-copy-reference)= + +# tmuxp copy + +Copy an existing workspace config to a new name. Source is resolved using the same logic as `tmuxp load` (supports names, paths, and extensions). + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: copy +``` + +## Basic usage + +Copy a workspace: + +```console +$ tmuxp copy myproject myproject-backup +``` diff --git a/docs/cli/delete.md b/docs/cli/delete.md new file mode 100644 index 0000000000..49a183d9fa --- /dev/null +++ b/docs/cli/delete.md @@ -0,0 +1,37 @@ +(cli-delete)= + +(cli-delete-reference)= + +# tmuxp delete + +Delete one or more workspace config files. Prompts for confirmation unless `-y` is passed. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: delete +``` + +## Basic usage + +Delete a workspace: + +```console +$ tmuxp delete old-project +``` + +Delete without confirmation: + +```console +$ tmuxp delete -y old-project +``` + +Delete multiple workspaces: + +```console +$ tmuxp delete proj1 proj2 +``` diff --git a/docs/cli/import.md b/docs/cli/import.md index 1e5d191ff8..726a74424c 100644 --- a/docs/cli/import.md +++ b/docs/cli/import.md @@ -38,6 +38,14 @@ $ tmuxp import teamocil /path/to/file.json ```` +### Importer improvements + +The teamocil importer now supports: + +- **v1.x format** — `windows` at top level with `commands` key in panes +- **Focus** — `focus: true` on windows and panes is preserved +- **Window options** — `options` on windows are passed through + (import-tmuxinator)= ## From tmuxinator @@ -71,3 +79,13 @@ $ tmuxp import tmuxinator /path/to/file.json ``` ```` + +### Importer improvements + +The tmuxinator importer now supports: + +- **Hook mapping** — `pre` maps to `on_project_start`, `pre_window` maps to `shell_command_before` +- **CLI args** — `cli_args` values (`-f`, `-S`, `-L`) are parsed into tmuxp config equivalents +- **Synchronize** — `synchronize` window key is converted +- **Startup focus** — `startup_window` / `startup_pane` convert to `focus: true` +- **Named panes** — hash-key pane syntax converts to `title` on the pane diff --git a/docs/cli/index.md b/docs/cli/index.md index fd38b681ea..9ca0611469 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -53,6 +53,7 @@ load shell ls search +stop ``` ```{toctree} @@ -63,6 +64,9 @@ edit import convert freeze +new +copy +delete ``` ```{toctree} diff --git a/docs/cli/load.md b/docs/cli/load.md index 8be9178f29..6a27fca387 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -253,3 +253,67 @@ When progress is disabled, logging flows normally to the terminal and no spinner ### Before-script behavior During `before_script` execution, the progress bar shows a marching animation and a ⏸ status icon, indicating that tmuxp is waiting for the script to finish before continuing with pane creation. + +## Here mode + +The `--here` flag reuses the current tmux window instead of creating a new session. This is similar to teamocil's `--here` flag. + +```console +$ tmuxp load --here . +``` + +When used, tmuxp builds the workspace panes inside the current window rather than spawning a new session. + +`--here` only supports a single workspace file per invocation. + +```{note} +When `--here` needs to provision a directory, environment, or shell, tmuxp uses tmux primitives (`set-environment` and `respawn-pane`) instead of typing `cd` / `export` into the pane. If provisioning is needed, tmux will replace the active pane process before the workspace commands run, so long-running child processes in that pane can be terminated. +``` + +## Skipping shell_command_before + +The `--no-shell-command-before` flag skips all `shell_command_before` entries at every level (session, window, pane). This is useful for quick reloads when the setup commands (virtualenv activation, etc.) are already active. + +```console +$ tmuxp load --no-shell-command-before myproject +``` + +```{note} +This flag is intentionally broader than tmuxinator's `--no-pre-window`, which only disables the window-level `pre_window` chain. tmuxp's flag strips `shell_command_before` at all levels for a clean reload experience. +``` + +## Debug mode + +The `--debug` flag shows tmux commands as they execute. This disables the progress spinner and attaches a debug handler to libtmux's logger, printing each tmux command to stdout. + +```console +$ tmuxp load --debug myproject +``` + +## Config templating + +Workspace configs support simple `{{ variable }}` placeholders for variable substitution. Pass values via `--set KEY=VALUE`: + +```console +$ tmuxp load --set project=myapp mytemplate.yaml +``` + +Multiple variables can be passed: + +```console +$ tmuxp load --set project=myapp --set env=staging mytemplate.yaml +``` + +In the config file, use double-brace syntax: + +```yaml +session_name: "{{ project }}" +windows: + - window_name: "{{ project }}-main" + panes: + - echo "Working on {{ project }}" +``` + +```{note} +Values containing `{{ }}` must be quoted in YAML to avoid parse errors. +``` diff --git a/docs/cli/new.md b/docs/cli/new.md new file mode 100644 index 0000000000..2f34eac25e --- /dev/null +++ b/docs/cli/new.md @@ -0,0 +1,25 @@ +(cli-new)= + +(cli-new-reference)= + +# tmuxp new + +Create a new workspace configuration file from a minimal template and open it in `$EDITOR`. If the workspace already exists, it opens for editing. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: new +``` + +## Basic usage + +Create a new workspace: + +```console +$ tmuxp new myproject +``` diff --git a/docs/cli/stop.md b/docs/cli/stop.md new file mode 100644 index 0000000000..c25757365c --- /dev/null +++ b/docs/cli/stop.md @@ -0,0 +1,37 @@ +(cli-stop)= + +(cli-stop-reference)= + +# tmuxp stop + +Stop (kill) a running tmux session. If `on_project_stop` is defined in the workspace config, that hook runs before the session is killed. + +## Command + +```{eval-rst} +.. argparse:: + :module: tmuxp.cli + :func: create_parser + :prog: tmuxp + :path: stop +``` + +## Basic usage + +Stop a session by name: + +```console +$ tmuxp stop mysession +``` + +Stop the currently attached session: + +```console +$ tmuxp stop +``` + +Use a custom socket: + +```console +$ tmuxp stop -L mysocket mysession +``` diff --git a/docs/comparison.md b/docs/comparison.md new file mode 100644 index 0000000000..51313ec28b --- /dev/null +++ b/docs/comparison.md @@ -0,0 +1,217 @@ +# Feature Comparison: tmuxp vs tmuxinator vs teamocil + +*Last updated: 2026-03-07* + +## Overview + +| | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| **Version** | Next | 3.3.7 | 1.4.2 | +| **Language** | Python | Ruby | Ruby | +| **Min tmux** | 3.2 | 1.5+ (1.5–3.6a tested) | (not specified) | +| **Config formats** | YAML, JSON | YAML (with ERB) | YAML | +| **Architecture** | ORM (libtmux) | Script generation (ERB templates) | Command objects → shell exec | +| **License** | MIT | MIT | MIT | +| **Session building** | API calls via libtmux | Generates bash script, then execs it | Generates tmux command list, renames current session, then `system()` | +| **Plugin system** | Yes (Python classes) | No | No | +| **Shell completion** | Yes | Yes (zsh/bash/fish) | No | + +## Architecture Comparison + +### tmuxp — ORM-Based + +tmuxp uses **libtmux**, an object-relational mapper for tmux. Each tmux entity (server, session, window, pane) has a Python object with methods that issue tmux commands via `tmux(1)`. Configuration is parsed into Python dicts, then the `WorkspaceBuilder` iterates through them, calling libtmux methods. + +**Advantages**: Programmatic control, error recovery mid-build, plugin hooks at each lifecycle stage, Python API for scripting. + +**Disadvantages**: Requires Python runtime, tightly coupled to libtmux API. + +### tmuxinator — Script Generation + +tmuxinator reads YAML (with ERB templating), builds a `Project` object graph, then renders a bash script via ERB templates. The generated script is `exec`'d, replacing the tmuxinator process. + +**Advantages**: Debuggable output (`tmuxinator debug`), wide tmux version support (1.8+), ERB allows config templating with variables. + +**Disadvantages**: No mid-build error recovery (script runs or fails), Ruby dependency. + +### teamocil — Command Objects + +teamocil parses YAML into `Session`/`Window`/`Pane` objects, each producing `Command` objects with `to_s()` methods. Commands are joined with `; ` and executed via `Kernel.system()`. + +**Advantages**: Simple, predictable, debuggable (`--debug`). + +**Disadvantages**: No error recovery, no hooks, no templating, minimal feature set. + +## Configuration Keys + +### Session-Level + +| Key | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Session name | `session_name` | `name` / `project_name` | `name` (auto-generated if omitted) | +| Root directory | `start_directory` | `root` / `project_root` | (none, per-window only) | +| Windows list | `windows` | `windows` / `tabs` | `windows` | +| Socket name | (CLI `-L`) | `socket_name` | (none) | +| Socket path | (CLI `-S`) | `socket_path` | (none) | +| Attach on create | (CLI `-d` to detach) | `attach` (default: true) | (always attaches) | +| Tmux config file | (CLI `-f`) | `tmux_options` / `cli_args` | (none) | +| Tmux command | (none) | `tmux_command` (e.g. `wemux`) | (none) | +| Session options | `options` | (none) | (none) | +| Global options | `global_options` | (none) | (none) | +| Environment vars | `environment` | (none) | (none) | +| Pre-build script | `before_script` | `on_project_first_start` / `pre` (deprecated; see Hooks) | (none) | +| Shell cmd before (all panes) | `shell_command_before` | `pre_window` / `pre_tab` / `rbenv` / `rvm` (all deprecated) | (none) | +| Startup window | (none; use `focus: true` on window) | `startup_window` (name or index) | (none; use `focus: true` on window) | +| Startup pane | (none; use `focus: true` on pane) | `startup_pane` | (none; use `focus: true` on pane) | +| Plugins | `plugins` | (none) | (none) | +| ERB/variable interpolation | `{{ var }}` + `--set KEY=VALUE` | Yes (`key=value` args) | (none) | +| YAML anchors | Yes | Yes (via `YAML.safe_load` `aliases: true`) | Yes | +| Pane titles enable | `enable_pane_titles` | `enable_pane_titles` | (none) | +| Pane title position | `pane_title_position` | `pane_title_position` | (none) | +| Pane title format | `pane_title_format` | `pane_title_format` | (none) | + +### Session Hooks + +| Hook | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Every start invocation | (none) | `on_project_start` | (none) | +| New session creation only | `on_project_start` | `on_project_first_start` | (none) | +| Before first script | `before_script` | (none) | (none) | +| On reattach | `on_project_restart` + Plugin: `reattach()` | `on_project_restart` | (none) | +| On last client detach | `on_project_exit` (guarded `client-detached` hook) | `on_project_exit` | (none) | +| On stop/kill | `on_project_stop` (via `tmuxp stop`) | `on_project_stop` | (none) | +| Before workspace build | Plugin: `before_workspace_builder()` | (none) | (none) | +| On window create | Plugin: `on_window_create()` | (none) | (none) | +| After window done | Plugin: `after_window_finished()` | (none) | (none) | +| Deprecated pre | (none) | `pre` (deprecated → `on_project_start`+`on_project_restart`; runs before session create) | (none) | +| Deprecated post | (none) | `post` (deprecated → `on_project_stop`+`on_project_exit`; runs after attach on every invocation) | (none) | + +tmuxp's lifecycle hook names are intentionally close to tmuxinator's, but `on_project_start` is limited to new-session creation and `on_project_exit` is guarded so teardown only runs when the last client detaches. + +### Window-Level + +| Key | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Window name | `window_name` | hash key | `name` | +| Window index | `window_index` | (auto, sequential) | (auto, sequential) | +| Root directory | `start_directory` | `root` (relative to project root) | `root` | +| Layout | `layout` | `layout` | `layout` | +| Panes list | `panes` | `panes` | `panes` | +| Window options | `options` | (none) | `options` | +| Post-create options | `options_after` | (none) | (none) | +| Shell cmd before | `shell_command_before` | `pre` | (none) | +| Shell for window | `window_shell` | (none) | (none) | +| Environment vars | `environment` | (none) | (none) | +| Suppress history | `suppress_history` | (none) | (none) | +| Focus | `focus` | (none; use `startup_window`) | `focus` | +| Synchronize panes | `synchronize` (`before`/`after`/`true`) | `synchronize` (`true`/`before`/`after`; `true`/`before` deprecated → use `after`) | (none) | +| Filters (before) | (none) | (none) | `filters.before` (v0.x) | +| Filters (after) | (none) | (none) | `filters.after` (v0.x) | + +### Pane-Level + +| Key | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Commands | `shell_command` | (value: string/list) | `commands` | +| Root directory | `start_directory` | (none, inherits) | (none, inherits) | +| Shell | `shell` | (none) | (none) | +| Environment vars | `environment` | (none) | (none) | +| Press enter | `enter` | (always) | (always) | +| Sleep before | `sleep_before` | (none) | (none) | +| Sleep after | `sleep_after` | (none) | (none) | +| Suppress history | `suppress_history` | (none) | (none) | +| Focus | `focus` | (none; use `startup_pane`) | `focus` | +| Shell cmd before | `shell_command_before` | (none; inherits from window/session) | (none) | +| Pane title | `title` | hash key (named pane → `select-pane -T`) | (none) | +| Width | (none) | (none) | `width` (v0.x, horizontal split %) | +| Height | (none) | (none) | `height` (v0.x, vertical split %) | +| Split target | (none) | (none) | `target` (v0.x) | + +### Shorthand Syntax + +| Pattern | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| String pane | `- vim` | `- vim` | `- vim` | +| List of commands | `- [cmd1, cmd2]` | `- [cmd1, cmd2]` | `commands: [cmd1, cmd2]` | +| Empty/blank pane | `- blank` / `- pane` / `- null` | `- ` (nil) | (omit commands) | +| Named pane | (none) | `- name: cmd` | (none) | +| Window as string | (none) | `window_name: cmd` | (none) | +| Window as list | (none) | `window_name: [cmd1, cmd2]` | (none) | + +## CLI Commands + +| Function | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Load/start session | `tmuxp load ` | `tmuxinator start ` | `teamocil ` | +| Load detached | `tmuxp load -d ` | `attach: false` / `tmuxinator start --no-attach` | (none) | +| Load with name override | `tmuxp load -s ` | `tmuxinator start -n ` | (none) | +| Append to session | `tmuxp load --append` | `tmuxinator start --append` | (none) | +| List configs | `tmuxp ls` | `tmuxinator list` | `teamocil --list` | +| Edit config | `tmuxp edit ` | `tmuxinator edit ` | `teamocil --edit ` | +| Show/debug config | `tmuxp load --debug` | `tmuxinator debug ` | `teamocil --show` / `--debug` | +| Create new config | `tmuxp new ` | `tmuxinator new ` | (none) | +| Copy config | `tmuxp copy ` | `tmuxinator copy ` | (none) | +| Delete config | `tmuxp delete ` | `tmuxinator delete ` | (none) | +| Delete all configs | (none) | `tmuxinator implode` | (none) | +| Stop/kill session | `tmuxp stop ` | `tmuxinator stop ` | (none) | +| Stop all sessions | (none) | `tmuxinator stop-all` | (none) | +| Freeze/export session | `tmuxp freeze ` | (none) | (none) | +| Convert format | `tmuxp convert ` | (none) | (none) | +| Import config | `tmuxp import ` | (none) | (none) | +| Search workspaces | `tmuxp search ` | (none) | (none) | +| Python shell | `tmuxp shell` | (none) | (none) | +| Debug/system info | `tmuxp debug-info` | `tmuxinator doctor` | (none) | +| Use here (current window) | `tmuxp load --here` | (none) | `teamocil --here` | +| Skip pre_window | `--no-shell-command-before` | `--no-pre-window` | (none) | +| Pass variables | `--set KEY=VALUE` | `key=value` args | (none) | +| Suppress version warning | (none) | `--suppress-tmux-version-warning` | (none) | +| Custom config path | `tmuxp load /path/to/file` | `-p /path/to/file` | `--layout /path/to/file` | +| Load multiple configs | `tmuxp load f1 f2 ...` (all but last detached) | (none) | (none) | +| Local config | `tmuxp load .` | `tmuxinator local` | (none) | + +```{note} +**`--debug` semantics differ**: `tmuxp load --debug` *executes* the workspace and shows each tmux command on stdout. `tmuxinator debug ` performs a *dry run* and prints the generated bash script without executing it. +``` + +## Config File Discovery + +| Feature | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| Global directory | `~/.tmuxp/` (legacy), `~/.config/tmuxp/` (XDG) | `~/.tmuxinator/`, `~/.config/tmuxinator/` (XDG), `$TMUXINATOR_CONFIG` | `~/.teamocil/` | +| Local config | `.tmuxp.yaml`, `.tmuxp.yml`, `.tmuxp.json` (traverses up to `~`) | `.tmuxinator.yml`, `.tmuxinator.yaml` (current dir only) | (none) | +| Env override | `$TMUXP_CONFIGDIR` | `$TMUXINATOR_CONFIG` | (none) | +| XDG support | Yes (`$XDG_CONFIG_HOME/tmuxp/`) | Yes (`$XDG_CONFIG_HOME/tmuxinator/`) | No | +| Extension search | `.yaml`, `.yml`, `.json` | `.yml`, `.yaml` | `.yml` | +| Recursive search | No | Yes (`Dir.glob("**/*.{yml,yaml}")`) | No | +| Upward traversal | Yes (cwd → `~`) | No | No | + +## Config Format Auto-Detection Heuristics + +If tmuxp were to auto-detect and transparently load tmuxinator/teamocil configs, these heuristics would distinguish the formats: + +| Indicator | tmuxp | tmuxinator | teamocil v0.x | teamocil v1.x | +|---|---|---|---|---| +| `session_name` key | Yes | No | No | No | +| `name` or `project_name` key | No | Yes | Yes (inside `session:`) | Yes | +| `session:` wrapper | No | No | Yes | No | +| `root` / `project_root` key | No | Yes | Yes | No | +| `start_directory` key | Yes | No | No | No | +| `windows` contains hash-key syntax | No | Yes (`- editor: ...`) | No | No | +| `windows` contains `window_name` key | Yes | No | No | No | +| `windows` contains `name` key | No | No | Yes | Yes | +| `splits` key in windows | No | No | Yes | No | +| `panes` with `cmd` key | No | No | Yes | No | +| `panes` with `commands` key | No | No | No | Yes | +| `panes` with `shell_command` key | Yes | No | No | No | +| `tabs` key | No | Yes (deprecated) | No | No | + +**Reliable detection algorithm:** + +1. If `session_name` exists or any window has `window_name` → **tmuxp** format +2. If `session:` wrapper exists → **teamocil v0.x** format +3. If `project_name`, `project_root`, or `tabs` exists → **tmuxinator** format +4. If windows use hash-key syntax (`- editor: {panes: ...}`) → **tmuxinator** format +5. If windows have `name` key and panes use `commands` or string shorthand → **teamocil v1.x** format +6. If `root` exists at top level and windows use hash-key syntax → **tmuxinator** format +7. If windows have `name` key and panes use `cmd` or `splits` → **teamocil v0.x** format (even without `session:` wrapper) +8. Ambiguous → ask user or try tmuxp first diff --git a/docs/configuration/examples.md b/docs/configuration/examples.md index 9651341309..bd67e4d226 100644 --- a/docs/configuration/examples.md +++ b/docs/configuration/examples.md @@ -785,6 +785,58 @@ windows: [poetry]: https://python-poetry.org/ [uv]: https://github.com/astral-sh/uv +## Synchronize panes (shorthand) + +The `synchronize` window-level key provides a shorthand for enabling +`synchronize-panes` without needing `options_after`: + +````{tab} YAML +```{literalinclude} ../../examples/synchronize-shorthand.yaml +:language: yaml + +``` +```` + +## Lifecycle hooks + +Run shell commands at different stages of the session lifecycle: + +````{tab} YAML +```{literalinclude} ../../examples/lifecycle-hooks.yaml +:language: yaml + +``` +```` + +See {ref}`top-level` for full hook documentation. + +## Config templating + +Use `{{ variable }}` placeholders in workspace configs. Pass values via +`--set KEY=VALUE`: + +```console +$ tmuxp load --set project=myapp config-templating.yaml +``` + +````{tab} YAML +```{literalinclude} ../../examples/config-templating.yaml +:language: yaml + +``` +```` + +## Pane titles + +Enable pane border titles to label individual panes: + +````{tab} YAML +```{literalinclude} ../../examples/pane-titles.yaml +:language: yaml + +``` +```` + ## Kung fu :::{note} diff --git a/docs/configuration/top-level.md b/docs/configuration/top-level.md index 72eb24f32f..ba894f5f2d 100644 --- a/docs/configuration/top-level.md +++ b/docs/configuration/top-level.md @@ -40,3 +40,157 @@ Notes: ``` Above: Use `tmux` directly to attach _banana_. + +## Lifecycle hooks + +Workspace configs support four lifecycle hooks that run shell commands at different stages of the session lifecycle: + +```yaml +session_name: myproject +on_project_start: notify-send "Starting myproject" +on_project_restart: notify-send "Reattaching to myproject" +on_project_exit: notify-send "Detached from myproject" +on_project_stop: notify-send "Stopping myproject" +windows: + - window_name: main + panes: + - +``` + +| Hook | When it runs | +|------|-------------| +| `on_project_start` | Before session build (new session creation only) | +| `on_project_restart` | When reattaching to an existing session (confirmed attach only) | +| `on_project_exit` | When the last client detaches (tmux `client-detached` hook) | +| `on_project_stop` | Before `tmuxp stop` kills the session | + +Each hook accepts a string (single command) or a list of strings (multiple commands run sequentially). + +```yaml +on_project_start: + - notify-send "Starting" + - ./setup.sh +``` + +```{note} +These hooks are inspired by tmuxinator's lifecycle hooks but have tmuxp-specific semantics. +`on_project_start` only fires on new session creation (not on reattach, append, or `--here`). +`on_project_restart` only fires when you confirm reattaching to an existing session. +``` + +```{note} +`on_project_exit` uses tmux's `client-detached` hook, but tmuxp guards it with `#{session_attached} == 0` so the command only runs when the **last** client detaches. This avoids repeated teardown in multi-client sessions. Unlike tmuxinator's wrapper-process hook, tmuxp keeps the hook on the session itself for the session lifetime. +``` + +## Pane titles + +Enable pane border titles to display labels on each pane: + +```yaml +session_name: myproject +enable_pane_titles: true +pane_title_position: top +pane_title_format: "#{pane_index}: #{pane_title}" +windows: + - window_name: dev + panes: + - title: editor + shell_command: + - vim + - title: tests + shell_command: + - uv run pytest --watch + - shell_command: + - git status +``` + +| Key | Level | Description | +|-----|-------|-------------| +| `enable_pane_titles` | session | Enable pane border titles (`true`/`false`) | +| `pane_title_position` | session | Position of the title bar (`top`/`bottom`) | +| `pane_title_format` | session | Format string using tmux variables | +| `title` | pane | Title text for an individual pane | + +```{note} +These correspond to tmuxinator's `enable_pane_titles`, `pane_title_position`, `pane_title_format`, and named pane (hash-key) syntax. +``` + +## Config templating + +Workspace configs support `{{ variable }}` placeholders that are rendered before YAML/JSON parsing. Pass values via `--set KEY=VALUE` on the command line: + +```yaml +session_name: "{{ project }}" +start_directory: "~/code/{{ project }}" +windows: + - window_name: main + panes: + - echo "Working on {{ project }}" +``` + +```console +$ tmuxp load --set project=myapp mytemplate.yaml +``` + +```{note} +Values containing `{{ }}` must be quoted in YAML to prevent parse errors. +``` + +See {ref}`cli-load` for full CLI usage. + +## synchronize + +Window-level shorthand for setting `synchronize-panes`. Accepts `before`, `after`, or `true`: + +```yaml +session_name: sync-demo +windows: + - window_name: synced + synchronize: after + panes: + - echo pane0 + - echo pane1 + - window_name: not-synced + panes: + - echo pane0 + - echo pane1 +``` + +| Value | Behavior | +|-------|----------| +| `before` | Enable synchronize-panes before sending pane commands | +| `after` | Enable synchronize-panes after sending pane commands | +| `true` | Same as `before` | + +```{note} +This corresponds to tmuxinator's `synchronize` window key. The `before` and `true` values are accepted for compatibility but `after` is recommended. +``` + +## shell_command_after + +Window-level key. Commands are sent to every pane in the window after all panes have been created and their individual commands executed: + +```yaml +session_name: myproject +windows: + - window_name: servers + shell_command_after: + - echo "All panes ready" + panes: + - ./start-api.sh + - ./start-worker.sh +``` + +## clear + +Window-level boolean. When `true`, sends `clear` to every pane after all commands (including `shell_command_after`) have completed: + +```yaml +session_name: myproject +windows: + - window_name: dev + clear: true + panes: + - cd src + - cd tests +``` diff --git a/docs/index.md b/docs/index.md index 64308bd782..62a04e806f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -102,5 +102,6 @@ history about_tmux migration +Comparison glossary ``` diff --git a/docs/internals/api/cli/copy.md b/docs/internals/api/cli/copy.md new file mode 100644 index 0000000000..9e15404999 --- /dev/null +++ b/docs/internals/api/cli/copy.md @@ -0,0 +1,8 @@ +# tmuxp copy - `tmuxp.cli.copy` + +```{eval-rst} +.. automodule:: tmuxp.cli.copy + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/delete.md b/docs/internals/api/cli/delete.md new file mode 100644 index 0000000000..7873640e95 --- /dev/null +++ b/docs/internals/api/cli/delete.md @@ -0,0 +1,8 @@ +# tmuxp delete - `tmuxp.cli.delete` + +```{eval-rst} +.. automodule:: tmuxp.cli.delete + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/index.md b/docs/internals/api/cli/index.md index 1381fbc90f..f5c5ebcd44 100644 --- a/docs/internals/api/cli/index.md +++ b/docs/internals/api/cli/index.md @@ -19,6 +19,10 @@ ls progress search shell +stop +new +copy +delete utils ``` diff --git a/docs/internals/api/cli/new.md b/docs/internals/api/cli/new.md new file mode 100644 index 0000000000..bec0862ce1 --- /dev/null +++ b/docs/internals/api/cli/new.md @@ -0,0 +1,8 @@ +# tmuxp new - `tmuxp.cli.new` + +```{eval-rst} +.. automodule:: tmuxp.cli.new + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/cli/stop.md b/docs/internals/api/cli/stop.md new file mode 100644 index 0000000000..7f01b8a4d3 --- /dev/null +++ b/docs/internals/api/cli/stop.md @@ -0,0 +1,8 @@ +# tmuxp stop - `tmuxp.cli.stop` + +```{eval-rst} +.. automodule:: tmuxp.cli.stop + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/examples/config-templating.yaml b/examples/config-templating.yaml new file mode 100644 index 0000000000..0578651044 --- /dev/null +++ b/examples/config-templating.yaml @@ -0,0 +1,5 @@ +session_name: "{{ project }}" +windows: + - window_name: "{{ project }}-main" + panes: + - echo "Working on {{ project }}" diff --git a/examples/lifecycle-hooks.yaml b/examples/lifecycle-hooks.yaml new file mode 100644 index 0000000000..5cfd7507e3 --- /dev/null +++ b/examples/lifecycle-hooks.yaml @@ -0,0 +1,7 @@ +session_name: lifecycle hooks +on_project_start: echo "project starting" +on_project_exit: echo "project exiting" +windows: + - window_name: main + panes: + - diff --git a/examples/pane-titles.yaml b/examples/pane-titles.yaml new file mode 100644 index 0000000000..37c5de17fb --- /dev/null +++ b/examples/pane-titles.yaml @@ -0,0 +1,15 @@ +session_name: pane titles +enable_pane_titles: true +pane_title_position: top +pane_title_format: "#{pane_index}: #{pane_title}" +windows: + - window_name: titled + panes: + - title: editor + shell_command: + - echo pane0 + - title: runner + shell_command: + - echo pane1 + - shell_command: + - echo pane2 diff --git a/examples/synchronize-shorthand.yaml b/examples/synchronize-shorthand.yaml new file mode 100644 index 0000000000..7fd507b809 --- /dev/null +++ b/examples/synchronize-shorthand.yaml @@ -0,0 +1,16 @@ +session_name: synchronize shorthand +windows: + - window_name: synced-before + synchronize: before + panes: + - echo 0 + - echo 1 + - window_name: synced-after + synchronize: after + panes: + - echo 0 + - echo 1 + - window_name: not-synced + panes: + - echo 0 + - echo 1 diff --git a/src/tmuxp/_internal/config_reader.py b/src/tmuxp/_internal/config_reader.py index 6da248dea7..e7c86bdea7 100644 --- a/src/tmuxp/_internal/config_reader.py +++ b/src/tmuxp/_internal/config_reader.py @@ -79,9 +79,16 @@ def load(cls, fmt: FormatLiteral, content: str) -> ConfigReader: ) @classmethod - def _from_file(cls, path: pathlib.Path) -> dict[str, t.Any]: + def _from_file( + cls, + path: pathlib.Path, + template_context: dict[str, str] | None = None, + ) -> dict[str, t.Any]: r"""Load data from file path directly to dictionary. + When *template_context* is provided, ``{{ variable }}`` expressions in the + raw file content are replaced before YAML/JSON parsing. + **YAML file** *For demonstration only,* create a YAML file: @@ -107,11 +114,24 @@ def _from_file(cls, path: pathlib.Path) -> dict[str, t.Any]: >>> ConfigReader._from_file(json_file) {'session_name': 'my session'} + + **Template rendering** + + >>> tpl_file = tmp_path / 'tpl.yaml' + >>> tpl_file.write_text('session_name: {{ name }}', encoding='utf-8') + 24 + >>> ConfigReader._from_file(tpl_file, template_context={"name": "rendered"}) + {'session_name': 'rendered'} """ assert isinstance(path, pathlib.Path) logger.debug("loading config", extra={"tmux_config_path": str(path)}) content = path.open(encoding="utf-8").read() + if template_context: + from tmuxp.workspace.loader import render_template + + content = render_template(content, template_context) + if path.suffix in {".yaml", ".yml"}: fmt: FormatLiteral = "yaml" elif path.suffix == ".json": diff --git a/src/tmuxp/cli/__init__.py b/src/tmuxp/cli/__init__.py index 860a9200cb..76a16dfb37 100644 --- a/src/tmuxp/cli/__init__.py +++ b/src/tmuxp/cli/__init__.py @@ -19,12 +19,14 @@ from ._colors import build_description from ._formatter import TmuxpHelpFormatter, create_themed_formatter from .convert import CONVERT_DESCRIPTION, command_convert, create_convert_subparser +from .copy import COPY_DESCRIPTION, command_copy, create_copy_subparser from .debug_info import ( DEBUG_INFO_DESCRIPTION, CLIDebugInfoNamespace, command_debug_info, create_debug_info_subparser, ) +from .delete import DELETE_DESCRIPTION, command_delete, create_delete_subparser from .edit import EDIT_DESCRIPTION, command_edit, create_edit_subparser from .freeze import ( FREEZE_DESCRIPTION, @@ -45,6 +47,7 @@ create_load_subparser, ) from .ls import LS_DESCRIPTION, CLILsNamespace, command_ls, create_ls_subparser +from .new import NEW_DESCRIPTION, command_new, create_new_subparser from .search import ( SEARCH_DESCRIPTION, CLISearchNamespace, @@ -57,6 +60,12 @@ command_shell, create_shell_subparser, ) +from .stop import ( + STOP_DESCRIPTION, + CLIStopNamespace, + command_stop, + create_stop_subparser, +) from .utils import tmuxp_echo logger = logging.getLogger(__name__) @@ -130,6 +139,32 @@ "tmuxp edit myproject", ], ), + ( + "new", + [ + "tmuxp new myproject", + ], + ), + ( + "copy", + [ + "tmuxp copy myproject myproject-backup", + ], + ), + ( + "delete", + [ + "tmuxp delete myproject", + "tmuxp delete -y old-project", + ], + ), + ( + "stop", + [ + "tmuxp stop mysession", + "tmuxp stop -L mysocket mysession", + ], + ), ( "debug-info", [ @@ -151,10 +186,14 @@ "load", "freeze", "convert", + "copy", + "delete", "edit", + "new", "import", "search", "shell", + "stop", "debug-info", ] CLIImportSubparserName: TypeAlias = t.Literal["teamocil", "tmuxinator"] @@ -254,6 +293,30 @@ def create_parser() -> argparse.ArgumentParser: ) create_edit_subparser(edit_parser) + new_parser = subparsers.add_parser( + "new", + help="create a new workspace config from template", + description=NEW_DESCRIPTION, + formatter_class=formatter_class, + ) + create_new_subparser(new_parser) + + copy_parser = subparsers.add_parser( + "copy", + help="copy a workspace config to a new name", + description=COPY_DESCRIPTION, + formatter_class=formatter_class, + ) + create_copy_subparser(copy_parser) + + delete_parser = subparsers.add_parser( + "delete", + help="delete workspace config files", + description=DELETE_DESCRIPTION, + formatter_class=formatter_class, + ) + create_delete_subparser(delete_parser) + freeze_parser = subparsers.add_parser( "freeze", help="freeze a live tmux session to a tmuxp workspace file", @@ -262,6 +325,14 @@ def create_parser() -> argparse.ArgumentParser: ) create_freeze_subparser(freeze_parser) + stop_parser = subparsers.add_parser( + "stop", + help="stop (kill) a tmux session", + description=STOP_DESCRIPTION, + formatter_class=formatter_class, + ) + create_stop_subparser(stop_parser) + return parser @@ -348,11 +419,45 @@ def cli(_args: list[str] | None = None) -> None: parser=parser, color=args.color, ) + elif args.subparser_name == "new": + if not args.workspace_name: + args.print_help() + sys.exit(1) + command_new( + workspace_name=args.workspace_name, + parser=parser, + color=args.color, + ) + elif args.subparser_name == "copy": + if not args.source or not args.destination: + args.print_help() + sys.exit(1) + command_copy( + source=args.source, + destination=args.destination, + parser=parser, + color=args.color, + ) + elif args.subparser_name == "delete": + if not args.workspace_names: + args.print_help() + sys.exit(1) + command_delete( + workspace_names=args.workspace_names, + answer_yes=args.answer_yes, + parser=parser, + color=args.color, + ) elif args.subparser_name == "freeze": command_freeze( args=CLIFreezeNamespace(**vars(args)), parser=parser, ) + elif args.subparser_name == "stop": + command_stop( + args=CLIStopNamespace(**vars(args)), + parser=parser, + ) elif args.subparser_name == "ls": command_ls( args=CLILsNamespace(**vars(args)), diff --git a/src/tmuxp/cli/copy.py b/src/tmuxp/cli/copy.py new file mode 100644 index 0000000000..dc3add31c2 --- /dev/null +++ b/src/tmuxp/cli/copy.py @@ -0,0 +1,145 @@ +"""CLI for ``tmuxp copy`` subcommand.""" + +from __future__ import annotations + +import logging +import os +import shutil +import sys +import typing as t + +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir, is_pure_name + +from ._colors import Colors, build_description, get_color_mode +from .utils import prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) + +COPY_DESCRIPTION = build_description( + """ + Copy an existing workspace config to a new name. + + Source is resolved using the same logic as ``tmuxp load`` (supports + names, paths, and extensions). If destination is a plain name, it + is placed in the workspace directory as ``.yaml``. + """, + ( + ( + None, + [ + "tmuxp copy myproject myproject-backup", + "tmuxp copy dev staging", + ], + ), + ), +) + +if t.TYPE_CHECKING: + import argparse + + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] + + +def create_copy_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``copy`` subcommand. + + Examples + -------- + >>> import argparse + >>> parser = create_copy_subparser(argparse.ArgumentParser()) + >>> args = parser.parse_args(["src", "dst"]) + >>> args.source, args.destination + ('src', 'dst') + + No arguments yields ``None``: + + >>> args = parser.parse_args([]) + >>> args.source is None and args.destination is None + True + """ + parser.add_argument( + dest="source", + metavar="source", + nargs="?", + default=None, + type=str, + help="source workspace name or file path.", + ) + parser.add_argument( + dest="destination", + metavar="destination", + nargs="?", + default=None, + type=str, + help="destination workspace name or file path.", + ) + parser.set_defaults(print_help=parser.print_help) + return parser + + +def command_copy( + source: str, + destination: str, + parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, +) -> None: + r"""Entrypoint for ``tmuxp copy``, copy a workspace config to a new name. + + Examples + -------- + >>> monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + >>> _ = (tmp_path / "src.yaml").write_text( + ... "session_name: s\nwindows:\n - window_name: m\n panes:\n -\n" + ... ) + >>> command_copy("src", "dst", color="never") # doctest: +ELLIPSIS + Copied ...src.yaml ... ...dst.yaml + >>> (tmp_path / "dst.yaml").exists() + True + """ + color_mode = get_color_mode(color) + colors = Colors(color_mode) + + try: + source_path = find_workspace_file(source) + except FileNotFoundError: + tmuxp_echo(colors.error(f"Source not found: {source}")) + sys.exit(1) + + if is_pure_name(destination): + configdir_env = os.environ.get("TMUXP_CONFIGDIR") + workspace_dir = ( + os.path.expanduser(configdir_env) if configdir_env else get_workspace_dir() + ) + os.makedirs(workspace_dir, exist_ok=True) + _, src_ext = os.path.splitext(source_path) + dest_path = os.path.join(workspace_dir, f"{destination}{src_ext or '.yaml'}") + else: + dest_path = os.path.expanduser(destination) + if not os.path.isabs(dest_path): + dest_path = os.path.normpath(os.path.join(os.getcwd(), dest_path)) + + if os.path.realpath(source_path) == os.path.realpath(dest_path): + tmuxp_echo( + colors.error("Source and destination are the same file: ") + + colors.info(str(PrivatePath(source_path))), + ) + sys.exit(1) + + if os.path.exists(dest_path) and not prompt_yes_no( + f"Overwrite {colors.info(str(PrivatePath(dest_path)))}?", + default=False, + color_mode=color_mode, + ): + tmuxp_echo(colors.muted("Aborted.")) + sys.exit(1) + + shutil.copy2(source_path, dest_path) + tmuxp_echo( + colors.success("Copied ") + + colors.info(str(PrivatePath(source_path))) + + colors.muted(" \u2192 ") + + colors.info(str(PrivatePath(dest_path))), + ) diff --git a/src/tmuxp/cli/delete.py b/src/tmuxp/cli/delete.py new file mode 100644 index 0000000000..53e37e97b3 --- /dev/null +++ b/src/tmuxp/cli/delete.py @@ -0,0 +1,127 @@ +"""CLI for ``tmuxp delete`` subcommand.""" + +from __future__ import annotations + +import logging +import os +import sys +import typing as t + +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.finders import find_workspace_file + +from ._colors import Colors, build_description, get_color_mode +from .utils import prompt_yes_no, tmuxp_echo + +logger = logging.getLogger(__name__) + +DELETE_DESCRIPTION = build_description( + """ + Delete workspace config files. + + Resolves workspace names using the same logic as ``tmuxp load``. + Prompts for confirmation unless ``-y`` is passed. + """, + ( + ( + None, + [ + "tmuxp delete myproject", + "tmuxp delete -y old-project", + "tmuxp delete proj1 proj2", + ], + ), + ), +) + +if t.TYPE_CHECKING: + import argparse + + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] + + +def create_delete_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``delete`` subcommand. + + Examples + -------- + >>> import argparse + >>> parser = create_delete_subparser(argparse.ArgumentParser()) + >>> args = parser.parse_args(["proj1", "proj2", "-y"]) + >>> args.workspace_names + ['proj1', 'proj2'] + >>> args.answer_yes + True + + No arguments yields an empty list: + + >>> args = parser.parse_args([]) + >>> args.workspace_names + [] + """ + parser.add_argument( + dest="workspace_names", + metavar="workspace-name", + nargs="*", + type=str, + help="workspace name(s) or file path(s) to delete.", + ) + parser.add_argument( + "--yes", + "-y", + dest="answer_yes", + action="store_true", + help="skip confirmation prompt.", + ) + parser.set_defaults(print_help=parser.print_help) + return parser + + +def command_delete( + workspace_names: list[str], + answer_yes: bool = False, + parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, +) -> None: + r"""Entrypoint for ``tmuxp delete``, remove workspace config files. + + Examples + -------- + >>> monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + >>> _ = (tmp_path / "doomed.yaml").write_text( + ... "session_name: d\nwindows:\n - window_name: m\n panes:\n -\n" + ... ) + >>> command_delete(["doomed"], answer_yes=True, color="never") # doctest: +ELLIPSIS + Deleted ...doomed.yaml + >>> (tmp_path / "doomed.yaml").exists() + False + """ + color_mode = get_color_mode(color) + colors = Colors(color_mode) + + _had_error = False + for name in workspace_names: + try: + workspace_path = find_workspace_file(name) + except FileNotFoundError: + tmuxp_echo(colors.warning(f"Workspace not found: {name}")) + _had_error = True + continue + + if not answer_yes and not prompt_yes_no( + f"Delete {colors.info(str(PrivatePath(workspace_path)))}?", + default=False, + color_mode=color_mode, + ): + tmuxp_echo(colors.muted("Skipped ") + colors.info(name)) + continue + + os.remove(workspace_path) + tmuxp_echo( + colors.success("Deleted ") + colors.info(str(PrivatePath(workspace_path))), + ) + + if _had_error: + sys.exit(1) diff --git a/src/tmuxp/cli/import_config.py b/src/tmuxp/cli/import_config.py index df49c221ba..89dbb5dcd5 100644 --- a/src/tmuxp/cli/import_config.py +++ b/src/tmuxp/cli/import_config.py @@ -9,6 +9,8 @@ import sys import typing as t +from libtmux.common import tmux_cmd + from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace import importers @@ -89,6 +91,59 @@ def _resolve_path_no_overwrite(workspace_file: str) -> str: return str(path) +def _read_tmux_index_option(*args: str) -> int | None: + """Return tmux index option value, or ``None`` when unavailable. + + Examples + -------- + >>> from collections import namedtuple + >>> import tmuxp.cli.import_config as import_config + >>> FakeResponse = namedtuple("FakeResponse", "returncode stdout") + >>> monkeypatch.setattr( + ... import_config, + ... "tmux_cmd", + ... lambda *args: FakeResponse(returncode=0, stdout=["1"]), + ... ) + >>> import_config._read_tmux_index_option("show-options", "-gv", "base-index") + 1 + """ + try: + response = tmux_cmd(*args) + except Exception: + return None + + if response.returncode != 0 or not response.stdout: + return None + + try: + return int(response.stdout[0]) + except ValueError: + return None + + +def _get_tmuxinator_base_indices() -> tuple[int, int]: + """Return tmux base-index and pane-base-index for tmuxinator import. + + Examples + -------- + >>> import tmuxp.cli.import_config as import_config + >>> monkeypatch.setattr( + ... import_config, + ... "_read_tmux_index_option", + ... lambda *args: 1 if args[-1] == "base-index" else 2, + ... ) + >>> import_config._get_tmuxinator_base_indices() + (1, 2) + """ + base_index = _read_tmux_index_option("show-options", "-gv", "base-index") + pane_base_index = _read_tmux_index_option( + "show-window-options", + "-gv", + "pane-base-index", + ) + return (base_index or 0, pane_base_index or 0) + + def command_import( workspace_file: str, print_list: str, @@ -253,12 +308,21 @@ def command_import_tmuxinator( """ color_mode = get_color_mode(color) colors = Colors(color_mode) + base_index, pane_base_index = _get_tmuxinator_base_indices() workspace_file = find_workspace_file( workspace_file, workspace_dir=get_tmuxinator_dir(), ) - import_config(workspace_file, importers.import_tmuxinator, colors=colors) + + def tmuxinator_importer(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: + return importers.import_tmuxinator( + workspace_dict, + base_index=base_index, + pane_base_index=pane_base_index, + ) + + import_config(workspace_file, tmuxinator_importer, colors=colors) def command_import_teamocil( diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 375cdb1b22..b95c568b0a 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -57,6 +57,46 @@ def _silence_stream_handlers(logger_name: str = "tmuxp") -> t.Iterator[None]: h.setLevel(level) +class _TmuxCommandDebugHandler(logging.Handler): + """Logging handler that prints tmux commands from libtmux's structured logs.""" + + def __init__(self, colors: Colors) -> None: + super().__init__() + self._colors = colors + + def emit(self, record: logging.LogRecord) -> None: + """Print tmux command if present in the log record's extra fields. + + Examples + -------- + Handler prints the tmux command when present: + + >>> import logging + >>> from tmuxp.cli.load import _TmuxCommandDebugHandler + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> colors = Colors(ColorMode.NEVER) + >>> handler = _TmuxCommandDebugHandler(colors) + >>> record = logging.LogRecord( + ... name="test", level=logging.DEBUG, + ... pathname="", lineno=0, msg="", args=(), exc_info=None, + ... ) + >>> record.tmux_cmd = "list-sessions" + >>> handler.emit(record) + $ list-sessions + + No output when tmux_cmd is absent: + + >>> record2 = logging.LogRecord( + ... name="test", level=logging.DEBUG, + ... pathname="", lineno=0, msg="", args=(), exc_info=None, + ... ) + >>> handler.emit(record2) + """ + cmd = getattr(record, "tmux_cmd", None) + if cmd is not None: + tmuxp_echo(self._colors.muted("$ ") + self._colors.info(str(cmd))) + + LOAD_DESCRIPTION = build_description( """ Load tmuxp workspace file(s) and create or attach to a tmux session. @@ -105,6 +145,7 @@ class CLILoadNamespace(argparse.Namespace): answer_yes: bool | None detached: bool append: bool | None + here: bool | None colors: CLIColorsLiteral | None color: CLIColorModeLiteral log_file: str | None @@ -112,6 +153,9 @@ class CLILoadNamespace(argparse.Namespace): progress_format: str | None panel_lines: int | None no_progress: bool + no_shell_command_before: bool + debug: bool + set: list[str] def load_plugins( @@ -234,6 +278,7 @@ def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: def _load_attached( builder: WorkspaceBuilder, detached: bool, + pre_build_hook: t.Callable[[], None] | None = None, pre_attach_hook: t.Callable[[], None] | None = None, ) -> None: """ @@ -243,10 +288,15 @@ def _load_attached( ---------- builder: :class:`workspace.builder.WorkspaceBuilder` detached : bool + pre_build_hook : callable, optional + called immediately before ``builder.build()`` for new-session load paths. pre_attach_hook : callable, optional called after build, before attach/switch_client; use to stop the spinner so its cleanup sequences don't appear inside the tmux pane. """ + if pre_build_hook is not None: + pre_build_hook() + builder.build() assert builder.session is not None @@ -267,6 +317,7 @@ def _load_attached( def _load_detached( builder: WorkspaceBuilder, colors: Colors | None = None, + pre_build_hook: t.Callable[[], None] | None = None, pre_output_hook: t.Callable[[], None] | None = None, ) -> None: """ @@ -277,9 +328,14 @@ def _load_detached( builder: :class:`workspace.builder.WorkspaceBuilder` colors : Colors | None Optional Colors instance for styled output. + pre_build_hook : Callable | None + Called immediately before ``builder.build()`` for new-session load paths. pre_output_hook : Callable | None Called after build but before printing, e.g. to stop a spinner. """ + if pre_build_hook is not None: + pre_build_hook() + builder.build() assert builder.session is not None @@ -305,6 +361,36 @@ def _load_append_windows_to_current_session(builder: WorkspaceBuilder) -> None: assert builder.session is not None +def _load_here_in_current_session(builder: WorkspaceBuilder) -> None: + """Load workspace reusing current window for first window. + + Parameters + ---------- + builder: :class:`workspace.builder.WorkspaceBuilder` + + Examples + -------- + Raises when no attached tmux session is available: + + >>> from tmuxp.workspace.builder import WorkspaceBuilder + >>> from tmuxp.cli.load import _load_here_in_current_session + >>> from tmuxp import exc + >>> config = { + ... 'session_name': 'here-doctest', + ... 'windows': [{'window_name': 'main'}], + ... } + >>> builder = WorkspaceBuilder(session_config=config, server=server) + >>> try: + ... _load_here_in_current_session(builder) + ... except exc.ActiveSessionMissingWorkspaceException: + ... print("raised") + raised + """ + current_attached_session = builder.find_current_attached_session() + builder.build(current_attached_session, here=True) + assert builder.session is not None + + def _setup_plugins(builder: WorkspaceBuilder) -> Session: """Execute hooks for plugins running after ``before_script``. @@ -325,6 +411,8 @@ def _dispatch_build( append: bool, answer_yes: bool, cli_colors: Colors, + here: bool = False, + pre_build_hook: t.Callable[[], None] | None = None, pre_attach_hook: t.Callable[[], None] | None = None, on_error_hook: t.Callable[[], None] | None = None, pre_prompt_hook: t.Callable[[], None] | None = None, @@ -347,6 +435,10 @@ def _dispatch_build( Skip interactive prompts. cli_colors : Colors Colors instance for styled output. + here : bool + Use current window for first workspace window. + pre_build_hook : callable, optional + Called before the build only for code paths that create a new session. pre_attach_hook : callable, optional Called before attach/switch_client (e.g. stop spinner). on_error_hook : callable, optional @@ -362,26 +454,81 @@ def _dispatch_build( Examples -------- + Build a minimal workspace in detached mode: + + >>> from tmuxp.workspace.builder import WorkspaceBuilder + >>> from tmuxp.workspace import loader >>> from tmuxp.cli.load import _dispatch_build - >>> callable(_dispatch_build) + >>> from tmuxp.cli._colors import ColorMode, Colors + >>> config = loader.trickle(loader.expand({ + ... 'session_name': 'dispatch-doctest', + ... 'windows': [{'window_name': 'main'}], + ... })) + >>> builder = WorkspaceBuilder(session_config=config, server=server) + >>> colors = Colors(ColorMode.NEVER) + >>> result = _dispatch_build( + ... builder, + ... detached=True, + ... append=False, + ... answer_yes=False, + ... cli_colors=colors, + ... ) + Session created in detached state. + >>> result is not None True + >>> result.kill() """ try: if detached: - _load_detached(builder, cli_colors, pre_output_hook=pre_attach_hook) + _load_detached( + builder, + cli_colors, + pre_build_hook=pre_build_hook, + pre_output_hook=pre_attach_hook, + ) + return _setup_plugins(builder) + + if here: + if "TMUX" in os.environ: # tmuxp ran from inside tmux + _load_here_in_current_session(builder) + else: + logger.warning( + "--here ignored: not inside tmux, falling back to normal attach", + ) + tmuxp_echo( + cli_colors.warning("[Warning]") + + " --here requires running inside tmux; loading normally", + ) + _load_attached( + builder, + detached, + pre_build_hook=pre_build_hook, + pre_attach_hook=pre_attach_hook, + ) + return _setup_plugins(builder) if append: if "TMUX" in os.environ: # tmuxp ran from inside tmux _load_append_windows_to_current_session(builder) else: - _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + _load_attached( + builder, + detached, + pre_build_hook=pre_build_hook, + pre_attach_hook=pre_attach_hook, + ) return _setup_plugins(builder) # append and answer_yes have no meaning if specified together if answer_yes: - _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + _load_attached( + builder, + detached, + pre_build_hook=pre_build_hook, + pre_attach_hook=pre_attach_hook, + ) return _setup_plugins(builder) if "TMUX" in os.environ: # tmuxp ran from inside tmux @@ -395,13 +542,27 @@ def _dispatch_build( choice = prompt_choices(msg, choices=options, color_mode=cli_colors.mode) if choice == "y": - _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + _load_attached( + builder, + detached, + pre_build_hook=pre_build_hook, + pre_attach_hook=pre_attach_hook, + ) elif choice == "a": _load_append_windows_to_current_session(builder) else: - _load_detached(builder, cli_colors) + _load_detached( + builder, + cli_colors, + pre_build_hook=pre_build_hook, + ) else: - _load_attached(builder, detached, pre_attach_hook=pre_attach_hook) + _load_attached( + builder, + detached, + pre_build_hook=pre_build_hook, + pre_attach_hook=pre_attach_hook, + ) except exc.TmuxpException as e: if on_error_hook is not None: @@ -409,13 +570,21 @@ def _dispatch_build( logger.debug("workspace build failed", exc_info=True) tmuxp_echo(cli_colors.error("[Error]") + f" {e}") - choice = prompt_choices( - cli_colors.error("Error loading workspace.") - + " (k)ill, (a)ttach, (d)etach?", - choices=["k", "a", "d"], - default="k", - color_mode=cli_colors.mode, - ) + if here: + choice = prompt_choices( + cli_colors.error("Error loading workspace.") + " (a)ttach, (d)etach?", + choices=["a", "d"], + default="d", + color_mode=cli_colors.mode, + ) + else: + choice = prompt_choices( + cli_colors.error("Error loading workspace.") + + " (k)ill, (a)ttach, (d)etach?", + choices=["k", "a", "d"], + default="k", + color_mode=cli_colors.mode, + ) if choice == "k": if builder.session is not None: @@ -446,10 +615,14 @@ def load_workspace( detached: bool = False, answer_yes: bool = False, append: bool = False, + here: bool = False, cli_colors: Colors | None = None, progress_format: str | None = None, panel_lines: int | None = None, no_progress: bool = False, + no_shell_command_before: bool = False, + debug: bool = False, + template_context: dict[str, str] | None = None, ) -> Session | None: """Entrypoint for ``tmuxp load``, load a tmuxp "workspace" session via config file. @@ -473,6 +646,9 @@ def load_workspace( append : bool Assume current when given prompt to append windows in same session. Default False. + here : bool + Use current window for first workspace window and rename session. + Default False. cli_colors : Colors, optional Colors instance for CLI output formatting. If None, uses AUTO mode. progress_format : str, optional @@ -484,6 +660,15 @@ def load_workspace( no_progress : bool Disable the progress spinner entirely. Default False. Also disabled when ``TMUXP_PROGRESS=0``. + no_shell_command_before : bool + Strip ``shell_command_before`` from all levels (session, window, pane) + before building. Default False. + debug : bool + Show tmux commands as they execute. Implies no_progress. Default False. + template_context : dict, optional + Mapping of variable names to values for ``{{ variable }}`` template + rendering. Applied to raw file content before YAML/JSON parsing. + Typically populated from ``--set KEY=VALUE`` CLI arguments. Notes ----- @@ -544,7 +729,26 @@ def load_workspace( "loading workspace", extra={"tmux_config_path": str(workspace_file)}, ) - _progress_disabled = no_progress or os.getenv("TMUXP_PROGRESS", "1") == "0" + _progress_disabled = no_progress or debug or os.getenv("TMUXP_PROGRESS", "1") == "0" + + # --debug: attach handler to libtmux logger that shows tmux commands + _debug_handler: logging.Handler | None = None + _debug_prev_level: int | None = None + if debug: + _debug_handler = _TmuxCommandDebugHandler(cli_colors) + _debug_handler.setLevel(logging.DEBUG) + _libtmux_logger = logging.getLogger("libtmux.common") + _debug_prev_level = _libtmux_logger.level + _libtmux_logger.setLevel(logging.DEBUG) + _libtmux_logger.addHandler(_debug_handler) + + def _cleanup_debug() -> None: + if _debug_handler is not None: + _ltlog = logging.getLogger("libtmux.common") + _ltlog.removeHandler(_debug_handler) + if _debug_prev_level is not None: + _ltlog.setLevel(_debug_prev_level) + if _progress_disabled: tmuxp_echo( cli_colors.info("[Loading]") @@ -553,22 +757,58 @@ def load_workspace( ) # ConfigReader allows us to open a yaml or json file as a dict - raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} + try: + if template_context: + raw_workspace = ( + config_reader.ConfigReader._from_file( + workspace_file, + template_context=template_context, + ) + or {} + ) + else: + raw_workspace = config_reader.ConfigReader._from_file(workspace_file) or {} - # shapes workspaces relative to config / profile file location - expanded_workspace = loader.expand( - raw_workspace, - cwd=os.path.dirname(workspace_file), - ) + # shapes workspaces relative to config / profile file location + expanded_workspace = loader.expand( + raw_workspace, + cwd=os.path.dirname(workspace_file), + ) + except Exception: + _cleanup_debug() + raise # Overridden session name if new_session_name: expanded_workspace["session_name"] = new_session_name + # Strip shell_command_before at all levels when --no-shell-command-before + if no_shell_command_before: + expanded_workspace.pop("shell_command_before", None) + for window in expanded_workspace.get("windows", []): + window.pop("shell_command_before", None) + for pane in window.get("panes", []): + pane.pop("shell_command_before", None) + + # Use workspace config values as fallbacks for server connection params + # (e.g. from tmuxinator cli_args: "-L socket -f tmux.conf") + if socket_name is None: + socket_name = expanded_workspace.pop("socket_name", None) + else: + expanded_workspace.pop("socket_name", None) + if socket_path is None: + socket_path = expanded_workspace.pop("socket_path", None) + else: + expanded_workspace.pop("socket_path", None) + if tmux_config_file is None: + tmux_config_file = expanded_workspace.pop("config", None) + else: + expanded_workspace.pop("config", None) + # propagate workspace inheritance (e.g. session -> window, window -> pane) expanded_workspace = loader.trickle(expanded_workspace) - t = Server( # create tmux server object + srv = Server( # create tmux server object socket_name=socket_name, socket_path=socket_path, config_file=tmux_config_file, @@ -582,7 +822,7 @@ def load_workspace( builder = WorkspaceBuilder( session_config=expanded_workspace, plugins=load_plugins(expanded_workspace, colors=cli_colors), - server=t, + server=srv, ) except exc.EmptyWorkspaceException: logger.warning( @@ -593,23 +833,42 @@ def load_workspace( cli_colors.warning("[Warning]") + f" {PrivatePath(workspace_file)} is empty or parsed no workspace data", ) + _cleanup_debug() return None session_name = expanded_workspace["session_name"] # Session-exists check — outside spinner so prompt_yes_no is safe - if builder.session_exists(session_name) and not append: - if not detached and ( + if builder.session_exists(session_name) and not append and not here: + _confirmed = not detached and ( answer_yes or prompt_yes_no( f"{cli_colors.highlight(session_name)} is already running. Attach?", default=True, color_mode=cli_colors.mode, ) - ): + ) + # Run on_project_restart hook — only when actually reattaching + if _confirmed: + if "on_project_restart" in expanded_workspace: + _hook_cwd = expanded_workspace.get("start_directory") + util.run_hook_commands( + expanded_workspace["on_project_restart"], + cwd=_hook_cwd, + ) _reattach(builder, cli_colors) + _cleanup_debug() return None + def _run_on_project_start() -> None: + if "on_project_start" not in expanded_workspace: + return + _hook_cwd = expanded_workspace.get("start_directory") + util.run_hook_commands( + expanded_workspace["on_project_start"], + cwd=_hook_cwd, + ) + if _progress_disabled: _private_path = str(PrivatePath(workspace_file)) result = _dispatch_build( @@ -618,6 +877,8 @@ def load_workspace( append, answer_yes, cli_colors, + here=here, + pre_build_hook=_run_on_project_start, ) if result is not None: summary = "" @@ -641,6 +902,7 @@ def load_workspace( tmuxp_echo( f"{checkmark} {SUCCESS_TEMPLATE.format_map(_SafeFormatMap(ctx))}" ) + _cleanup_debug() return result # Spinner wraps only the actual build phase @@ -693,6 +955,8 @@ def _emit_success() -> None: append, answer_yes, cli_colors, + here=here, + pre_build_hook=_run_on_project_start, pre_attach_hook=_emit_success, on_error_hook=spinner.stop, pre_prompt_hook=spinner.stop, @@ -745,19 +1009,36 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP action="store_true", help="always answer yes", ) - parser.add_argument( + load_mode_group = parser.add_mutually_exclusive_group() + load_mode_group.add_argument( "-d", dest="detached", action="store_true", help="load the session without attaching it", ) - parser.add_argument( + load_mode_group.add_argument( "-a", "--append", dest="append", action="store_true", help="load workspace, appending windows to the current session", ) + load_mode_group.add_argument( + "--here", + dest="here", + action="store_true", + help=( + "use the current window for the first workspace window " + "(single workspace only)" + ), + ) + parser.add_argument( + "--no-shell-command-before", + dest="no_shell_command_before", + action="store_true", + default=False, + help="skip shell_command_before at all levels (session, window, pane)", + ) colorsgroup = parser.add_mutually_exclusive_group() colorsgroup.add_argument( @@ -820,6 +1101,25 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP help=("Disable the animated progress spinner. Env: TMUXP_PROGRESS=0"), ) + parser.add_argument( + "--debug", + dest="debug", + action="store_true", + default=False, + help="show tmux commands as they execute (implies --no-progress)", + ) + + parser.add_argument( + "--set", + metavar="KEY=VALUE", + action="append", + default=[], + help=( + "set template variable for {{ variable }} expressions in workspace config " + "(repeatable, e.g. --set project=myapp --set port=8080)" + ), + ) + try: import shtab @@ -873,6 +1173,27 @@ def command_load( sys.exit() return + if args.here and len(args.workspace_files) > 1: + msg = "--here only supports one workspace file" + if parser is not None: + parser.error(msg) + tmuxp_echo(cli_colors.error("[Error]") + f" {msg}") + sys.exit(2) + + # Parse --set KEY=VALUE args into template context + template_context: dict[str, str] | None = None + if args.set: + template_context = {} + for item in args.set: + key, _, value = item.partition("=") + if not key or not _: + tmuxp_echo( + cli_colors.error("[Error]") + + f" Invalid --set format: {item!r} (expected KEY=VALUE)", + ) + sys.exit(1) + template_context[key] = value + last_idx = len(args.workspace_files) - 1 original_detached_option = args.detached original_new_session_name = args.new_session_name @@ -900,8 +1221,12 @@ def command_load( detached=detached, answer_yes=args.answer_yes or False, append=args.append or False, + here=args.here or False, cli_colors=cli_colors, progress_format=args.progress_format, panel_lines=args.panel_lines, no_progress=args.no_progress, + no_shell_command_before=args.no_shell_command_before, + debug=args.debug, + template_context=template_context, ) diff --git a/src/tmuxp/cli/new.py b/src/tmuxp/cli/new.py new file mode 100644 index 0000000000..1491172eec --- /dev/null +++ b/src/tmuxp/cli/new.py @@ -0,0 +1,175 @@ +"""CLI for ``tmuxp new`` subcommand.""" + +from __future__ import annotations + +import logging +import os +import re +import shlex +import subprocess +import sys +import typing as t + +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.finders import get_workspace_dir + +from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) + +WORKSPACE_TEMPLATE = """\ +session_name: '{name}' +windows: + - window_name: main + panes: + - +""" + +_YAML_RESERVED_RE = re.compile( + r"^(true|false|yes|no|on|off|null|~)$", + re.IGNORECASE, +) + + +def _validate_workspace_name(name: str) -> str | None: + """Return error message if name is invalid for a workspace, None if OK. + + Examples + -------- + >>> from tmuxp.cli.new import _validate_workspace_name + >>> _validate_workspace_name("myproject") is None + True + >>> _validate_workspace_name("../escape") is not None + True + >>> _validate_workspace_name("yes") is not None + True + >>> _validate_workspace_name("foo'bar") is not None + True + """ + if os.sep in name or (os.altsep and os.altsep in name): + return f"workspace name must not contain path separators: {name!r}" + if ".." in name: + return f"workspace name must not contain '..': {name!r}" + if _YAML_RESERVED_RE.match(name): + return f"workspace name is a YAML reserved word: {name!r}" + if name.startswith(("#", "*", "&", "!", "|", ">", "'", '"', "%", "@", "`")): + return f"workspace name starts with YAML special character: {name!r}" + if "'" in name or '"' in name: + return f"workspace name must not contain quotes: {name!r}" + return None + + +NEW_DESCRIPTION = build_description( + """ + Create a new workspace config from a minimal template. + + Opens the new file in $EDITOR after creation. If the workspace + already exists, opens it for editing. + """, + ( + ( + None, + [ + "tmuxp new myproject", + "tmuxp new dev-server", + ], + ), + ), +) + +if t.TYPE_CHECKING: + import argparse + + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] + + +def create_new_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``new`` subcommand. + + Examples + -------- + >>> import argparse + >>> parser = create_new_subparser(argparse.ArgumentParser()) + >>> args = parser.parse_args(["myproject"]) + >>> args.workspace_name + 'myproject' + + No arguments yields ``None``: + + >>> args = parser.parse_args([]) + >>> args.workspace_name is None + True + """ + parser.add_argument( + dest="workspace_name", + metavar="workspace-name", + nargs="?", + default=None, + type=str, + help="name for the new workspace config.", + ) + parser.set_defaults(print_help=parser.print_help) + return parser + + +def command_new( + workspace_name: str, + parser: argparse.ArgumentParser | None = None, + color: CLIColorModeLiteral | None = None, +) -> None: + """Entrypoint for ``tmuxp new``, create a new workspace config from template. + + Examples + -------- + >>> monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + >>> monkeypatch.setenv("EDITOR", "true") + >>> command_new("myproject", color="never") # doctest: +ELLIPSIS + Created ...myproject.yaml + >>> (tmp_path / "myproject.yaml").exists() + True + """ + color_mode = get_color_mode(color) + colors = Colors(color_mode) + + err = _validate_workspace_name(workspace_name) + if err: + tmuxp_echo(colors.error(err)) + sys.exit(1) + + # Use TMUXP_CONFIGDIR directly if set, since get_workspace_dir() + # only returns it when the directory already exists. The new command + # needs to create files there even if it doesn't exist yet. + configdir_env = os.environ.get("TMUXP_CONFIGDIR") + workspace_dir = ( + os.path.expanduser(configdir_env) if configdir_env else get_workspace_dir() + ) + os.makedirs(workspace_dir, exist_ok=True) + + workspace_path = os.path.join(workspace_dir, f"{workspace_name}.yaml") + + if os.path.exists(workspace_path): + tmuxp_echo( + colors.info(str(PrivatePath(workspace_path))) + + colors.muted(" already exists, opening in editor."), + ) + else: + content = WORKSPACE_TEMPLATE.format(name=workspace_name) + with open(workspace_path, "w") as f: + f.write(content) + tmuxp_echo( + colors.success("Created ") + colors.info(str(PrivatePath(workspace_path))), + ) + + sys_editor = os.environ.get("EDITOR", "vim") + try: + subprocess.call([*shlex.split(sys_editor), workspace_path]) + except FileNotFoundError: + tmuxp_echo( + colors.error("Editor not found: ") + + colors.info(sys_editor) + + colors.muted(" (set $EDITOR to a valid editor)"), + ) + sys.exit(1) diff --git a/src/tmuxp/cli/stop.py b/src/tmuxp/cli/stop.py new file mode 100644 index 0000000000..aa8f950a03 --- /dev/null +++ b/src/tmuxp/cli/stop.py @@ -0,0 +1,141 @@ +"""CLI for ``tmuxp stop`` subcommand.""" + +from __future__ import annotations + +import argparse +import logging +import os +import sys +import typing as t + +from libtmux.server import Server + +from tmuxp import exc, util +from tmuxp.exc import TmuxpException + +from ._colors import Colors, build_description, get_color_mode +from .utils import tmuxp_echo + +logger = logging.getLogger(__name__) + +STOP_DESCRIPTION = build_description( + """ + Stop (kill) a tmux session. + """, + ( + ( + None, + [ + "tmuxp stop mysession", + "tmuxp stop -L mysocket mysession", + ], + ), + ), +) + +if t.TYPE_CHECKING: + CLIColorModeLiteral: t.TypeAlias = t.Literal["auto", "always", "never"] + + +class CLIStopNamespace(argparse.Namespace): + """Typed :class:`argparse.Namespace` for tmuxp stop command.""" + + color: CLIColorModeLiteral + session_name: str | None + socket_name: str | None + socket_path: str | None + + +def create_stop_subparser( + parser: argparse.ArgumentParser, +) -> argparse.ArgumentParser: + """Augment :class:`argparse.ArgumentParser` with ``stop`` subcommand. + + Examples + -------- + >>> import argparse + >>> parser = create_stop_subparser(argparse.ArgumentParser()) + >>> args = parser.parse_args(["mysession"]) + >>> args.session_name + 'mysession' + """ + parser.add_argument( + dest="session_name", + metavar="session-name", + nargs="?", + action="store", + ) + parser.add_argument( + "-S", + dest="socket_path", + metavar="socket-path", + help="pass-through for tmux -S", + ) + parser.add_argument( + "-L", + dest="socket_name", + metavar="socket-name", + help="pass-through for tmux -L", + ) + parser.set_defaults(print_help=parser.print_help) + return parser + + +def command_stop( + args: CLIStopNamespace, + parser: argparse.ArgumentParser | None = None, +) -> None: + """Entrypoint for ``tmuxp stop``, kill a tmux session. + + Examples + -------- + >>> test_session = server.new_session(session_name="doctest_stop") + >>> args = CLIStopNamespace() + >>> args.session_name = "doctest_stop" + >>> args.color = "never" + >>> args.socket_name = server.socket_name + >>> args.socket_path = None + >>> command_stop(args) # doctest: +ELLIPSIS + Stopped doctest_stop + >>> server.sessions.get(session_name="doctest_stop", default=None) is None + True + """ + color_mode = get_color_mode(args.color) + colors = Colors(color_mode) + + server = Server(socket_name=args.socket_name, socket_path=args.socket_path) + + try: + if args.session_name: + session = server.sessions.get( + session_name=args.session_name, + default=None, + ) + elif os.environ.get("TMUX"): + session = util.get_session(server, require_pane_resolution=True) + else: + tmuxp_echo( + colors.error("No session name given and not inside tmux."), + ) + sys.exit(1) + + if not session: + raise exc.SessionNotFound(args.session_name) + except TmuxpException as e: + tmuxp_echo(colors.error(str(e))) + sys.exit(1) + + session_name = session.name + + # Run on_project_stop hook from session environment + on_stop_cmd = session.getenv("TMUXP_ON_PROJECT_STOP") + if on_stop_cmd and isinstance(on_stop_cmd, str): + start_dir = session.getenv("TMUXP_START_DIRECTORY") + _stop_cwd = str(start_dir) if isinstance(start_dir, str) else None + util.run_hook_commands(on_stop_cmd, cwd=_stop_cwd) + + session.kill() + logger.info("session stopped", extra={"tmux_session": session_name or ""}) + tmuxp_echo( + colors.success("Stopped ") + colors.highlight(session_name or ""), + ) diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 152b1f6c06..48eb23251d 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -105,6 +105,73 @@ def run_before_script( return return_code +def run_hook_commands( + commands: str | list[str], + cwd: pathlib.Path | str | None = None, +) -> None: + """Run lifecycle hook shell commands. + + Unlike :func:`run_before_script`, hooks use ``shell=True`` for full + shell support (pipes, redirects, etc.) and do NOT raise on failure. + + Parameters + ---------- + commands : str or list of str + shell command(s) to run + cwd : pathlib.Path or str, optional + working directory for the commands + + Examples + -------- + Run a single command: + + >>> run_hook_commands("echo hello") + + Run multiple commands: + + >>> run_hook_commands(["echo a", "echo b"]) + + Empty string is a no-op: + + >>> run_hook_commands("") + """ + if isinstance(commands, str): + commands = [commands] + joined = "; ".join(commands) + if not joined.strip(): + return + logger.debug("running hook commands %s", joined) + try: + result = subprocess.run( + joined, + shell=True, + cwd=cwd, + check=False, + capture_output=True, + text=True, + timeout=120, + ) + except subprocess.TimeoutExpired: + logger.warning("hook command timed out after 120s: %s", joined) + return + except OSError: + logger.warning( + "hook command failed (bad cwd or shell): %s", + joined, + ) + return + if result.returncode != 0: + logger.warning( + "hook command failed with exit code %d", + result.returncode, + extra={"tmux_exit_code": result.returncode}, + ) + if result.stdout: + logger.debug("hook stdout: %s", result.stdout.rstrip()) + if result.stderr: + logger.debug("hook stderr: %s", result.stderr.rstrip()) + + def oh_my_zsh_auto_title() -> None: """Give warning and offer to fix ``DISABLE_AUTO_TITLE``. @@ -145,27 +212,57 @@ def get_session( server: Server, session_name: str | None = None, current_pane: Pane | None = None, + require_pane_resolution: bool = False, ) -> Session: - """Get tmux session for server by session name, respects current pane, if passed.""" + """Get tmux session for server by session name, respects current pane, if passed. + + Parameters + ---------- + server : Server + tmux server to search. + session_name : str, optional + Explicit session name to look up. + current_pane : Pane, optional + Pane to infer session from. + require_pane_resolution : bool + If True, raise SessionNotFound when TMUX_PANE cannot be resolved + instead of falling back to server.sessions[0]. Use for destructive + operations like ``tmuxp stop``. + + Examples + -------- + >>> from tmuxp.util import get_session + >>> get_session(server, session_name=session.name) == session + True + """ + session_result: Session | None = None try: if session_name: - session = server.sessions.get(session_name=session_name) + session_result = server.sessions.get(session_name=session_name) elif current_pane is not None: - session = server.sessions.get(session_id=current_pane.session_id) + session_result = server.sessions.get( + session_id=current_pane.session_id, + ) else: current_pane = get_current_pane(server) if current_pane: - session = server.sessions.get(session_id=current_pane.session_id) - else: - session = server.sessions[0] - + session_result = server.sessions.get( + session_id=current_pane.session_id, + ) + elif require_pane_resolution: + pass # session_result stays None → raises below + elif server.sessions: + session_result = server.sessions[0] except Exception as e: if session_name: raise exc.SessionNotFound(session_name) from e raise exc.SessionNotFound from e - assert session is not None - return session + if session_result is None: + if session_name: + raise exc.SessionNotFound(session_name) + raise exc.SessionNotFound + return session_result def get_window( diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 728b477963..99820c07f8 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -4,7 +4,9 @@ import logging import os +import shlex import shutil +import subprocess import time import typing as t @@ -407,7 +409,12 @@ def session_exists(self, session_name: str) -> bool: return False return True - def build(self, session: Session | None = None, append: bool = False) -> None: + def build( + self, + session: Session | None = None, + append: bool = False, + here: bool = False, + ) -> None: """Build tmux workspace in session. Optionally accepts ``session`` to build with only session object. @@ -421,7 +428,11 @@ def build(self, session: Session | None = None, append: bool = False) -> None: session to build workspace in append : bool append windows in current active session + here : bool + reuse current window for first window and rename session """ + session_created = session is None + if not session: if not self.server: msg = ( @@ -486,6 +497,19 @@ def build(self, session: Session | None = None, append: bool = False) -> None: assert isinstance(session, Session) + # Check --here rename conflicts before plugin hooks, before_script, + # or any session/window mutation with user-visible side effects. + if here: + session_name = self.session_config["session_name"] + if session.name != session_name: + existing = self.server.sessions.get( + session_name=session_name, + default=None, + ) + if existing is not None: + msg = f"cannot rename to {session_name!r}: session already exists" + raise exc.TmuxpException(msg) + for plugin in self.plugins: plugin.before_workspace_builder(self.session) @@ -520,12 +544,18 @@ def build(self, session: Session | None = None, append: bool = False) -> None: ), }, ) - self.session.kill() + if session_created: + self.session.kill() raise finally: if self.on_build_event: self.on_build_event({"event": "before_script_done"}) + if here: + session_name = self.session_config["session_name"] + if session.name != session_name: + session.rename_session(session_name) + if "options" in self.session_config: for option, value in self.session_config["options"].items(): self.session.set_option(option, value) @@ -538,7 +568,44 @@ def build(self, session: Session | None = None, append: bool = False) -> None: for option, value in self.session_config["environment"].items(): self.session.set_environment(option, value) - for window, window_config in self.iter_create_windows(session, append): + # Session-scoped lifecycle hooks and metadata belong only to sessions + # created by this build. Reused sessions may already carry unrelated + # hooks or tmuxp metadata from other windows/workspaces. + if session_created and "on_project_exit" in self.session_config: + exit_cmds = self.session_config["on_project_exit"] + if isinstance(exit_cmds, str): + exit_cmds = [exit_cmds] + _joined = "; ".join(exit_cmds) + _start_dir = self.session_config.get("start_directory") + if _start_dir: + _joined = f"cd {shlex.quote(_start_dir)} && {_joined}" + # Guard: only run when last client detaches (safe for multi-client) + _guarded = f"if [ #{{session_attached}} -eq 0 ]; then {_joined}; fi" + self.session.set_hook( + "client-detached", + f"run-shell {shlex.quote(_guarded)}", + ) + + # Store on_project_stop in session environment for tmuxp stop + if session_created and "on_project_stop" in self.session_config: + stop_cmds = self.session_config["on_project_stop"] + if isinstance(stop_cmds, str): + stop_cmds = [stop_cmds] + self.session.set_environment( + "TMUXP_ON_PROJECT_STOP", + "; ".join(stop_cmds), + ) + + # Store start_directory in session environment for hook cwd + if session_created and "start_directory" in self.session_config: + self.session.set_environment( + "TMUXP_START_DIRECTORY", + self.session_config["start_directory"], + ) + + for window, window_config in self.iter_create_windows( + session, append, here=here + ): assert isinstance(window, Window) for plugin in self.plugins: @@ -579,6 +646,7 @@ def iter_create_windows( self, session: Session, append: bool = False, + here: bool = False, ) -> Iterator[t.Any]: """Return :class:`libtmux.Window` iterating through session config dict. @@ -593,6 +661,8 @@ def iter_create_windows( session to create windows in append : bool append windows in current active session + here : bool + reuse current window for first window Returns ------- @@ -617,43 +687,120 @@ def iter_create_windows( } ) - is_first_window_pass = self.first_window_pass( - window_iterator, - session, - append, - ) + if here and window_iterator == 1: + # --here: reuse current window for first window + window = session.active_window + if window_name: + window.rename_window(window_name) + + # Remove extra panes so iter_create_panes starts clean + _active_pane = window.active_pane + for _p in list(window.panes): + if _p != _active_pane: + _p.kill() + + start_directory = window_config.get("start_directory", None) + panes = window_config["panes"] + if panes and "start_directory" in panes[0]: + start_directory = panes[0]["start_directory"] + + environment = window_config.get("environment") + if panes and "environment" in panes[0]: + environment = panes[0]["environment"] + + # Resolve window_shell + window_shell = window_config.get("window_shell") + try: + if panes[0]["shell"] != "": + window_shell = panes[0]["shell"] + except (KeyError, IndexError): + pass + + # Use respawn-pane to provision the reused pane with the + # correct directory, environment, and shell. This avoids + # send_keys entirely — no POSIX shell assumption, no + # typing into foreground programs, no history pollution. + # Matches teamocil's approach of using tmux primitives + # over send_keys for infrastructure setup. + if start_directory or environment or window_shell: + _here_pane = window.active_pane + if _here_pane is not None: + # Warn if the pane has running child processes + # that would be killed by respawn-pane -k + _pane_pid = _here_pane.pane_pid + if _pane_pid: + try: + _children = subprocess.run( + ["pgrep", "-P", _pane_pid], + capture_output=True, + text=True, + ) + if _children.returncode == 0: + logger.warning( + "--here will kill running processes " + "in the active pane (pid %s) to " + "provision directory/environment", + _pane_pid, + ) + except FileNotFoundError: + pass # pgrep not available + + _respawn_args: list[str] = ["respawn-pane", "-k"] + if start_directory: + _respawn_args.extend(["-c", start_directory]) + if environment: + for _ekey, _eval in environment.items(): + _respawn_args.extend( + ["-e", f"{_ekey}={_eval}"], + ) + if window_shell: + _respawn_args.append(window_shell) + _here_pane.cmd(*_respawn_args) + else: + is_first_window_pass = self.first_window_pass( + window_iterator, + session, + append, + ) - w1 = None - if is_first_window_pass: # if first window, use window 1 - w1 = session.active_window - w1.move_window("99") + w1 = None + if is_first_window_pass: # if first window, use window 1 + w1 = session.active_window + w1.move_window("99") - start_directory = window_config.get("start_directory", None) + start_directory = window_config.get("start_directory", None) - # If the first pane specifies a start_directory, use that instead. - panes = window_config["panes"] - if panes and "start_directory" in panes[0]: - start_directory = panes[0]["start_directory"] + # If the first pane specifies a start_directory, use that instead. + panes = window_config["panes"] + if panes and "start_directory" in panes[0]: + start_directory = panes[0]["start_directory"] - window_shell = window_config.get("window_shell", None) + window_shell = window_config.get("window_shell", None) - # If the first pane specifies a shell, use that instead. - try: - if window_config["panes"][0]["shell"] != "": - window_shell = window_config["panes"][0]["shell"] - except (KeyError, IndexError): - pass + # If the first pane specifies a shell, use that instead. + try: + if window_config["panes"][0]["shell"] != "": + window_shell = window_config["panes"][0]["shell"] + except (KeyError, IndexError): + pass - environment = panes[0].get("environment", window_config.get("environment")) + environment = panes[0].get( + "environment", + window_config.get("environment"), + ) + + window = session.new_window( + window_name=window_name, + start_directory=start_directory, + attach=False, # do not move to the new window + window_index=window_config.get("window_index", ""), + window_shell=window_shell, + environment=environment, + ) + + if is_first_window_pass: # if first window, use window 1 + session.active_window.kill() - window = session.new_window( - window_name=window_name, - start_directory=start_directory, - attach=False, # do not move to the new window - window_index=window_config.get("window_index", ""), - window_shell=window_shell, - environment=environment, - ) assert isinstance(window, Window) window_log = TmuxpLoggerAdapter( logger, @@ -664,9 +811,6 @@ def iter_create_windows( ) window_log.debug("window created") - if is_first_window_pass: # if first window, use window 1 - session.active_window.kill() - if "options" in window_config and isinstance( window_config["options"], dict, @@ -813,6 +957,9 @@ def get_pane_shell( if sleep_after is not None: time.sleep(sleep_after) + if pane_config.get("title"): + pane.set_title(pane_config["title"]) + if pane_config.get("focus"): assert pane.pane_id is not None window.select_pane(pane.pane_id) @@ -844,6 +991,18 @@ def config_after_window( for key, val in window_config["options_after"].items(): window.set_option(key, val) + if "shell_command_after" in window_config and isinstance( + window_config["shell_command_after"], + dict, + ): + for cmd in window_config["shell_command_after"].get("shell_command", []): + for pane in window.panes: + pane.send_keys(cmd["cmd"]) + + if window_config.get("clear"): + for pane in window.panes: + pane.send_keys("clear", enter=True) + def find_current_attached_session(self) -> Session: """Return current attached session.""" assert self.server is not None diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 65184d73a4..d653a8a56d 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -3,12 +3,114 @@ from __future__ import annotations import logging +import shlex import typing as t logger = logging.getLogger(__name__) +_TMUXINATOR_UNMAPPED_KEYS: dict[str, str] = { + "tmux_command": "custom tmux binary is not supported; tmuxp always uses 'tmux'", + "attach": "use 'tmuxp load -d' for detached mode instead", + "post": "deprecated in tmuxinator; use on_project_exit instead", +} -def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: + +def _convert_named_panes(panes: list[t.Any]) -> list[t.Any]: + """Convert tmuxinator named pane dicts to tmuxp format. + + Tmuxinator supports ``{pane_name: commands}`` dicts in pane lists, where the + key is the pane title and the value is the command or command list. Convert + these to ``{"shell_command": commands, "title": pane_name}`` so the builder + can call ``pane.set_title()``. + + Parameters + ---------- + panes : list + Raw pane list from a tmuxinator window config. + + Returns + ------- + list + Pane list with named pane dicts converted. + + Examples + -------- + >>> _convert_named_panes(["vim", {"logs": ["tail -f log"]}]) + ['vim', {'shell_command': ['tail -f log'], 'title': 'logs'}] + + >>> _convert_named_panes(["vim", None, "top"]) + ['vim', None, 'top'] + """ + result: list[t.Any] = [] + for pane in panes: + if isinstance(pane, dict) and len(pane) == 1 and "shell_command" not in pane: + pane_name = next(iter(pane)) + commands = pane[pane_name] + if isinstance(commands, str): + commands = [commands] + elif commands is None: + commands = [] + result.append( + { + "shell_command": commands, + "title": str(pane_name), + } + ) + else: + result.append(pane) + return result + + +def _resolve_tmux_list_position( + target: str | int, + *, + base_index: int, + item_count: int, +) -> int | None: + """Resolve a tmux index into a Python list position. + + Parameters + ---------- + target : str or int + tmux index from tmuxinator configuration + base_index : int + tmux base index for the list being resolved + item_count : int + number of items in the generated tmuxp list + + Returns + ------- + int or None + Python list position if the target resolves within bounds + + Examples + -------- + >>> _resolve_tmux_list_position(1, base_index=1, item_count=2) + 0 + + >>> _resolve_tmux_list_position("2", base_index=1, item_count=2) + 1 + + >>> _resolve_tmux_list_position(3, base_index=1, item_count=2) is None + True + """ + try: + list_position = int(target) - base_index + except ValueError: + return None + + if 0 <= list_position < item_count: + return list_position + + return None + + +def import_tmuxinator( + workspace_dict: dict[str, t.Any], + *, + base_index: int = 0, + pane_base_index: int = 0, +) -> dict[str, t.Any]: """Return tmuxp workspace from a `tmuxinator`_ yaml workspace. .. _tmuxinator: https://github.com/aziz/tmuxinator @@ -44,72 +146,193 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: elif "root" in workspace_dict: tmuxp_workspace["start_directory"] = workspace_dict.pop("root") - if "cli_args" in workspace_dict: - tmuxp_workspace["config"] = workspace_dict["cli_args"] + raw_args = workspace_dict.get("cli_args") or workspace_dict.get("tmux_options") + if raw_args: + tokens = shlex.split(raw_args) + flag_map = {"-f": "config", "-L": "socket_name", "-S": "socket_path"} + it = iter(tokens) + for token in it: + if token in flag_map: + # Space-separated: -L mysocket + value = next(it, None) + if value is not None: + tmuxp_workspace[flag_map[token]] = value + else: + # Attached form: -Lmysocket + for prefix, key in flag_map.items(): + if token.startswith(prefix) and len(token) > len(prefix): + tmuxp_workspace[key] = token[len(prefix) :] + break - if "-f" in tmuxp_workspace["config"]: - tmuxp_workspace["config"] = ( - tmuxp_workspace["config"].replace("-f", "").strip() + if "socket_name" in workspace_dict: + explicit_name = workspace_dict["socket_name"] + if ( + "socket_name" in tmuxp_workspace + and tmuxp_workspace["socket_name"] != explicit_name + ): + logger.warning( + "explicit socket_name %s overrides -L %s from cli_args", + explicit_name, + tmuxp_workspace["socket_name"], ) - elif "tmux_options" in workspace_dict: - tmuxp_workspace["config"] = workspace_dict["tmux_options"] + tmuxp_workspace["socket_name"] = explicit_name + + # Passthrough keys supported by both tmuxinator and tmuxp + for _pass_key in ( + "enable_pane_titles", + "pane_title_position", + "pane_title_format", + "on_project_start", + "on_project_restart", + "on_project_exit", + "on_project_stop", + ): + if _pass_key in workspace_dict: + tmuxp_workspace[_pass_key] = workspace_dict[_pass_key] + + if "on_project_first_start" in workspace_dict: + logger.warning( + "on_project_first_start is not yet supported by tmuxp; " + "consider using on_project_start instead", + ) - if "-f" in tmuxp_workspace["config"]: - tmuxp_workspace["config"] = ( - tmuxp_workspace["config"].replace("-f", "").strip() + # Warn on tmuxinator keys that have no tmuxp equivalent + for _ukey, _uhint in _TMUXINATOR_UNMAPPED_KEYS.items(): + if _ukey in workspace_dict: + logger.warning( + "tmuxinator key %r is not supported by tmuxp: %s", + _ukey, + _uhint, ) - if "socket_name" in workspace_dict: - tmuxp_workspace["socket_name"] = workspace_dict["socket_name"] - tmuxp_workspace["windows"] = [] if "tabs" in workspace_dict: workspace_dict["windows"] = workspace_dict.pop("tabs") - if "pre" in workspace_dict and "pre_window" in workspace_dict: - tmuxp_workspace["shell_command"] = workspace_dict["pre"] - - if isinstance(workspace_dict["pre"], str): - tmuxp_workspace["shell_command_before"] = [workspace_dict["pre_window"]] + # Handle pre → on_project_start (independent of pre_window chain) + # tmuxinator's pre is a raw shell command emitted as a line in a bash script. + # on_project_start uses run_hook_commands(shell=True) which handles raw commands. + # before_script requires a file path and would crash on raw commands. + if "pre" in workspace_dict and "on_project_start" not in tmuxp_workspace: + pre_val = workspace_dict["pre"] + if isinstance(pre_val, list): + tmuxp_workspace["on_project_start"] = "; ".join(pre_val) else: - tmuxp_workspace["shell_command_before"] = workspace_dict["pre_window"] - elif "pre" in workspace_dict: - if isinstance(workspace_dict["pre"], str): - tmuxp_workspace["shell_command_before"] = [workspace_dict["pre"]] - else: - tmuxp_workspace["shell_command_before"] = workspace_dict["pre"] + tmuxp_workspace["on_project_start"] = pre_val + # Resolve shell_command_before using tmuxinator's exclusive precedence: + # rbenv > rvm > pre_tab > pre_window (only ONE is selected) + _scb_val: str | None = None if "rbenv" in workspace_dict: - if "shell_command_before" not in tmuxp_workspace: - tmuxp_workspace["shell_command_before"] = [] - tmuxp_workspace["shell_command_before"].append( - "rbenv shell {}".format(workspace_dict["rbenv"]), - ) + _scb_val = "rbenv shell {}".format(workspace_dict["rbenv"]) + elif "rvm" in workspace_dict: + _scb_val = "rvm use {}".format(workspace_dict["rvm"]) + elif "pre_tab" in workspace_dict: + _raw = workspace_dict["pre_tab"] + if isinstance(_raw, list): + _scb_val = "; ".join(_raw) + elif isinstance(_raw, str): + _scb_val = _raw + elif "pre_window" in workspace_dict: + _raw = workspace_dict["pre_window"] + if isinstance(_raw, list): + _scb_val = "; ".join(_raw) + elif isinstance(_raw, str): + _scb_val = _raw + + if _scb_val is not None: + tmuxp_workspace["shell_command_before"] = [_scb_val] + + _startup_window = workspace_dict.get("startup_window") + _startup_pane = workspace_dict.get("startup_pane") for window_dict in workspace_dict["windows"]: for k, v in window_dict.items(): - window_dict = {"window_name": k} + tmuxp_window: dict[str, t.Any] = { + "window_name": str(k) if k is not None else k, + } if isinstance(v, str) or v is None: - window_dict["panes"] = [v] - tmuxp_workspace["windows"].append(window_dict) + tmuxp_window["panes"] = [v] + tmuxp_workspace["windows"].append(tmuxp_window) continue if isinstance(v, list): - window_dict["panes"] = v - tmuxp_workspace["windows"].append(window_dict) + tmuxp_window["panes"] = _convert_named_panes(v) + tmuxp_workspace["windows"].append(tmuxp_window) continue if "pre" in v: - window_dict["shell_command_before"] = v["pre"] + tmuxp_window["shell_command_before"] = v["pre"] if "panes" in v: - window_dict["panes"] = v["panes"] + tmuxp_window["panes"] = _convert_named_panes(v["panes"]) if "root" in v: - window_dict["start_directory"] = v["root"] + tmuxp_window["start_directory"] = v["root"] if "layout" in v: - window_dict["layout"] = v["layout"] - tmuxp_workspace["windows"].append(window_dict) + tmuxp_window["layout"] = v["layout"] + + if "synchronize" in v: + sync = v["synchronize"] + if sync is True or sync == "before": + tmuxp_window.setdefault("options", {})["synchronize-panes"] = "on" + elif sync == "after": + tmuxp_window.setdefault("options_after", {})[ + "synchronize-panes" + ] = "on" + + tmuxp_workspace["windows"].append(tmuxp_window) + + # Post-process startup_window / startup_pane into focus flags + if _startup_window is not None and tmuxp_workspace["windows"]: + _matched = False + for w in tmuxp_workspace["windows"]: + if w.get("window_name") == str(_startup_window): + w["focus"] = True + _matched = True + break + if not _matched: + _idx = _resolve_tmux_list_position( + _startup_window, + base_index=base_index, + item_count=len(tmuxp_workspace["windows"]), + ) + if _idx is not None: + tmuxp_workspace["windows"][_idx]["focus"] = True + else: + logger.warning( + "startup_window %r not found for tmux base-index %d", + _startup_window, + base_index, + ) + + if _startup_pane is not None and tmuxp_workspace["windows"]: + _target = next( + (w for w in tmuxp_workspace["windows"] if w.get("focus")), + tmuxp_workspace["windows"][0], + ) + if "panes" in _target: + _pidx = _resolve_tmux_list_position( + _startup_pane, + base_index=pane_base_index, + item_count=len(_target["panes"]), + ) + if _pidx is not None: + _pane = _target["panes"][_pidx] + if isinstance(_pane, dict): + _pane["focus"] = True + else: + _target["panes"][_pidx] = { + "shell_command": [_pane] if _pane else [], + "focus": True, + } + else: + logger.warning( + "startup_pane %r not found for tmux pane-base-index %d", + _startup_pane, + pane_base_index, + ) + return tmuxp_workspace @@ -122,16 +345,6 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: ---------- workspace_dict : dict python dict for tmuxp workspace - - Notes - ----- - Todos: - - - change 'root' to a cd or start_directory - - width in pane -> main-pain-width - - with_env_var - - clear - - cmd_separator """ _inner = workspace_dict.get("session", workspace_dict) logger.debug( @@ -158,12 +371,10 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: window_dict["clear"] = w["clear"] if "filters" in w: - if "before" in w["filters"]: - for _b in w["filters"]["before"]: - window_dict["shell_command_before"] = w["filters"]["before"] - if "after" in w["filters"]: - for _b in w["filters"]["after"]: - window_dict["shell_command_after"] = w["filters"]["after"] + if w["filters"].get("before"): + window_dict["shell_command_before"] = w["filters"]["before"] + if w["filters"].get("after"): + window_dict["shell_command_after"] = w["filters"]["after"] if "root" in w: window_dict["start_directory"] = w.pop("root") @@ -172,16 +383,57 @@ def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: w["panes"] = w.pop("splits") if "panes" in w: + panes: list[t.Any] = [] for p in w["panes"]: - if "cmd" in p: - p["shell_command"] = p.pop("cmd") - if "width" in p: - # TODO support for height/width - p.pop("width") - window_dict["panes"] = w["panes"] + if p is None: + panes.append({"shell_command": []}) + elif isinstance(p, str): + panes.append({"shell_command": [p]}) + else: + if "cmd" in p: + p["shell_command"] = p.pop("cmd") + elif "commands" in p: + p["shell_command"] = p.pop("commands") + if "width" in p: + logger.warning( + "unsupported pane key %s dropped", + "width", + extra={"tmux_window": w["name"]}, + ) + p.pop("width") + if "height" in p: + logger.warning( + "unsupported pane key %s dropped", + "height", + extra={"tmux_window": w["name"]}, + ) + p.pop("height") + panes.append(p) + window_dict["panes"] = panes if "layout" in w: window_dict["layout"] = w["layout"] + + if w.get("focus"): + window_dict["focus"] = True + + if "options" in w: + window_dict["options"] = w["options"] + + if "with_env_var" in w: + logger.warning( + "unsupported window key %s dropped", + "with_env_var", + extra={"tmux_window": w["name"]}, + ) + + if "cmd_separator" in w: + logger.warning( + "unsupported window key %s dropped", + "cmd_separator", + extra={"tmux_window": w["name"]}, + ) + tmuxp_workspace["windows"].append(window_dict) return tmuxp_workspace diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 9efcd05b52..c0795f94f2 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -5,6 +5,7 @@ import logging import os import pathlib +import re import typing as t logger = logging.getLogger(__name__) @@ -28,6 +29,78 @@ def expandshell(value: str) -> str: return os.path.expandvars(os.path.expanduser(value)) # NOQA: PTH111 +_TEMPLATE_RE = re.compile(r"\{\{\s*(\w+)\s*\}\}") + +_YAML_UNSAFE_RE = re.compile(r"[\n\r:{}\[\]]") + + +def _validate_template_values(context: dict[str, str]) -> None: + """Raise ValueError if any template value could break YAML structure. + + Examples + -------- + >>> _validate_template_values({"key": "simple"}) + + >>> _validate_template_values({"key": "foo: bar"}) # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + ValueError: --set value for 'key' contains YAML-unsafe characters ... + """ + for key, value in context.items(): + if _YAML_UNSAFE_RE.search(value): + msg = ( + f"--set value for {key!r} contains YAML-unsafe characters " + f"(colons, braces, brackets, or newlines): {value!r}" + ) + raise ValueError(msg) + + +def render_template(content: str, context: dict[str, str]) -> str: + """Render ``{{ variable }}`` expressions in raw config content. + + Replaces template expressions with values from *context*. Expressions + referencing keys not in *context* are left unchanged so that + ``$ENV_VAR`` expansion (which runs later, after YAML parsing) is + unaffected. + + Raises :class:`ValueError` if any value contains characters that could + corrupt YAML structure (colons, braces, brackets, newlines). + + Parameters + ---------- + content : str + Raw file content (YAML or JSON) before parsing. + context : dict + Mapping of variable names to replacement values, typically + from ``--set KEY=VALUE`` CLI arguments. + + Returns + ------- + str + Content with matching ``{{ key }}`` expressions replaced. + + Examples + -------- + >>> render_template("root: {{ project }}", {"project": "myapp"}) + 'root: myapp' + + >>> render_template("root: {{ unknown }}", {"project": "myapp"}) + 'root: {{ unknown }}' + + >>> render_template("no templates here", {"key": "val"}) + 'no templates here' + """ + _validate_template_values(context) + + def _replace(match: re.Match[str]) -> str: + key = match.group(1) + if key in context: + return context[key] + return match.group(0) + + return _TEMPLATE_RE.sub(_replace, content) + + def expand_cmd(p: dict[str, t.Any]) -> dict[str, t.Any]: """Resolve shell variables and expand shorthands in a tmuxp config mapping.""" if isinstance(p, str): @@ -112,7 +185,9 @@ def expand( if "session_name" in workspace_dict: workspace_dict["session_name"] = expandshell(workspace_dict["session_name"]) - if "window_name" in workspace_dict: + if "window_name" in workspace_dict and isinstance( + workspace_dict["window_name"], str + ): workspace_dict["window_name"] = expandshell(workspace_dict["window_name"]) if "environment" in workspace_dict: for key in workspace_dict["environment"]: @@ -138,6 +213,14 @@ def expand( val = str(cwd / val) workspace_dict["options"][key] = val + # Desugar synchronize shorthand into options / options_after + if "synchronize" in workspace_dict: + sync = workspace_dict.pop("synchronize") + if sync is True or sync == "before": + workspace_dict.setdefault("options", {})["synchronize-panes"] = "on" + elif sync == "after": + workspace_dict.setdefault("options_after", {})["synchronize-panes"] = "on" + # Any workspace section, session, window, pane that can contain the # 'shell_command' value if "start_directory" in workspace_dict: @@ -164,6 +247,19 @@ def expand( if any(workspace_dict["before_script"].startswith(a) for a in [".", "./"]): workspace_dict["before_script"] = str(cwd / workspace_dict["before_script"]) + for _hook_key in ( + "on_project_start", + "on_project_restart", + "on_project_exit", + "on_project_stop", + ): + if _hook_key in workspace_dict: + _hook_val = workspace_dict[_hook_key] + if isinstance(_hook_val, str): + workspace_dict[_hook_key] = expandshell(_hook_val) + elif isinstance(_hook_val, list): + workspace_dict[_hook_key] = [expandshell(v) for v in _hook_val] + if "shell_command" in workspace_dict and isinstance( workspace_dict["shell_command"], str, @@ -175,6 +271,37 @@ def expand( workspace_dict["shell_command_before"] = expand_cmd(shell_command_before) + if "shell_command_after" in workspace_dict: + shell_command_after = workspace_dict["shell_command_after"] + + workspace_dict["shell_command_after"] = expand_cmd(shell_command_after) + + # Desugar pane title session-level config into per-window options + _VALID_PANE_TITLE_POSITIONS = {"top", "bottom", "off"} + if workspace_dict.get("enable_pane_titles") and "windows" in workspace_dict: + position = workspace_dict.pop("pane_title_position", "top") + if position not in _VALID_PANE_TITLE_POSITIONS: + logger.warning( + "invalid pane_title_position %r, expected one of %s; " + "defaulting to 'top'", + position, + _VALID_PANE_TITLE_POSITIONS, + ) + position = "top" + fmt = workspace_dict.pop( + "pane_title_format", + "#{pane_index}: #{pane_title}", + ) + workspace_dict.pop("enable_pane_titles") + for window in workspace_dict["windows"]: + window.setdefault("options", {}) + window["options"].setdefault("pane-border-status", position) + window["options"].setdefault("pane-border-format", fmt) + elif "enable_pane_titles" in workspace_dict: + workspace_dict.pop("enable_pane_titles") + workspace_dict.pop("pane_title_position", None) + workspace_dict.pop("pane_title_format", None) + # recurse into window and pane workspace items if "windows" in workspace_dict: workspace_dict["windows"] = [ diff --git a/tests/cli/test_copy.py b/tests/cli/test_copy.py new file mode 100644 index 0000000000..5ddf3b46e2 --- /dev/null +++ b/tests/cli/test_copy.py @@ -0,0 +1,292 @@ +"""Test tmuxp copy command.""" + +from __future__ import annotations + +import pathlib +import typing as t + +import pytest + +from tmuxp import cli + + +class CopyTestFixture(t.NamedTuple): + """Test fixture for tmuxp copy command tests.""" + + test_id: str + cli_args: list[str] + source_name: str + dest_name: str + expect_copied: bool + source_exists: bool + + +COPY_TEST_FIXTURES: list[CopyTestFixture] = [ + CopyTestFixture( + test_id="copy-workspace", + cli_args=["copy", "source", "dest"], + source_name="source", + dest_name="dest", + expect_copied=True, + source_exists=True, + ), + CopyTestFixture( + test_id="copy-nonexistent-source", + cli_args=["copy", "nosuch", "dest"], + source_name="nosuch", + dest_name="dest", + expect_copied=False, + source_exists=False, + ), +] + + +@pytest.mark.parametrize( + list(CopyTestFixture._fields), + COPY_TEST_FIXTURES, + ids=[test.test_id for test in COPY_TEST_FIXTURES], +) +def test_copy( + tmp_path: t.Any, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + cli_args: list[str], + source_name: str, + dest_name: str, + expect_copied: bool, + source_exists: bool, +) -> None: + """Test copying a workspace config.""" + config_dir = tmp_path / "tmuxp" + config_dir.mkdir() + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + + source_content = "session_name: source-session\nwindows:\n - window_name: main\n" + if source_exists: + source_path = config_dir / f"{source_name}.yaml" + source_path.write_text(source_content) + + if expect_copied: + cli.cli(cli_args) + + captured = capsys.readouterr() + dest_path = config_dir / f"{dest_name}.yaml" + assert dest_path.exists() + assert dest_path.read_text() == source_content + assert "Copied" in captured.out + else: + with pytest.raises(SystemExit) as exc_info: + cli.cli(cli_args) + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "not found" in captured.out.lower() + + +def test_copy_to_path( + tmp_path: t.Any, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test copying a workspace config to an explicit file path.""" + config_dir = tmp_path / "tmuxp" + config_dir.mkdir() + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + + source_content = "session_name: mysession\n" + source_path = config_dir / "source.yaml" + source_path.write_text(source_content) + + dest_path = tmp_path / "output" / "copied.yaml" + dest_path.parent.mkdir(parents=True) + + cli.cli(["copy", "source", str(dest_path)]) + + assert dest_path.exists() + assert dest_path.read_text() == source_content + + captured = capsys.readouterr() + assert "Copied" in captured.out + + +class CopyConfigdirFixture(t.NamedTuple): + """Fixture for TMUXP_CONFIGDIR handling in copy command.""" + + test_id: str + configdir_exists_before: bool + + +COPY_CONFIGDIR_FIXTURES: list[CopyConfigdirFixture] = [ + CopyConfigdirFixture( + test_id="configdir-exists", + configdir_exists_before=True, + ), + CopyConfigdirFixture( + test_id="configdir-not-exists", + configdir_exists_before=False, + ), +] + + +@pytest.mark.parametrize( + list(CopyConfigdirFixture._fields), + COPY_CONFIGDIR_FIXTURES, + ids=[f.test_id for f in COPY_CONFIGDIR_FIXTURES], +) +def test_copy_respects_tmuxp_configdir( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + configdir_exists_before: bool, +) -> None: + """Copy lands in TMUXP_CONFIGDIR even if it doesn't exist yet.""" + # Source file in a separate directory + source_dir = tmp_path / "source_dir" + source_dir.mkdir() + source_file = source_dir / "orig.yaml" + source_file.write_text("session_name: copied\n") + + # Target configdir — may or may not exist + config_dir = tmp_path / "custom_config" + if configdir_exists_before: + config_dir.mkdir() + + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + + cli.cli(["copy", str(source_file), "myworkspace"]) + + expected = config_dir / "myworkspace.yaml" + assert expected.exists(), f"expected {expected} to exist" + assert expected.read_text() == "session_name: copied\n" + + +class CopyExtensionFixture(t.NamedTuple): + """Test fixture for source extension preservation in copy.""" + + test_id: str + source_ext: str + expected_dest_ext: str + + +COPY_EXTENSION_FIXTURES: list[CopyExtensionFixture] = [ + CopyExtensionFixture( + test_id="yaml_source", + source_ext=".yaml", + expected_dest_ext=".yaml", + ), + CopyExtensionFixture( + test_id="json_source", + source_ext=".json", + expected_dest_ext=".json", + ), + CopyExtensionFixture( + test_id="yml_source", + source_ext=".yml", + expected_dest_ext=".yml", + ), +] + + +@pytest.mark.parametrize( + list(CopyExtensionFixture._fields), + COPY_EXTENSION_FIXTURES, + ids=[f.test_id for f in COPY_EXTENSION_FIXTURES], +) +def test_copy_preserves_source_extension( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + source_ext: str, + expected_dest_ext: str, +) -> None: + """Copy uses the source file extension when destination is a pure name.""" + config_dir = tmp_path / "tmuxp" + config_dir.mkdir() + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + + source_content = '{"session_name": "test"}\n' + source_path = config_dir / f"src{source_ext}" + source_path.write_text(source_content) + + cli.cli(["copy", str(source_path), "dst"]) + + expected = config_dir / f"dst{expected_dest_ext}" + assert expected.exists(), f"expected {expected} to exist" + assert expected.read_text() == source_content + + +class CopyExitCodeFixture(t.NamedTuple): + """Test fixture for tmuxp copy error exit codes.""" + + test_id: str + cli_args: list[str] + expected_exit_code: int + expected_output_fragment: str + + +COPY_EXIT_CODE_FIXTURES: list[CopyExitCodeFixture] = [ + CopyExitCodeFixture( + test_id="missing_source", + cli_args=["copy", "nonexistent", "dst"], + expected_exit_code=1, + expected_output_fragment="Source not found", + ), + CopyExitCodeFixture( + test_id="no_args", + cli_args=["copy"], + expected_exit_code=1, + expected_output_fragment="", + ), + CopyExitCodeFixture( + test_id="missing_destination", + cli_args=["copy", "src"], + expected_exit_code=1, + expected_output_fragment="", + ), +] + + +@pytest.mark.parametrize( + list(CopyExitCodeFixture._fields), + COPY_EXIT_CODE_FIXTURES, + ids=[f.test_id for f in COPY_EXIT_CODE_FIXTURES], +) +def test_copy_error_exits_nonzero( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + cli_args: list[str], + expected_exit_code: int, + expected_output_fragment: str, +) -> None: + """Tmuxp copy exits with code 1 on error conditions.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + + with pytest.raises(SystemExit) as exc_info: + cli.cli(cli_args) + + assert exc_info.value.code == expected_exit_code + if expected_output_fragment: + captured = capsys.readouterr() + assert expected_output_fragment in captured.out + + +def test_copy_self_copy_rejected( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tmuxp copy rejects copying a workspace to itself.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + + workspace_file = tmp_path / "self.yaml" + workspace_file.write_text("session_name: self\n", encoding="utf-8") + + with pytest.raises(SystemExit) as exc_info: + cli.cli(["copy", str(workspace_file), str(workspace_file)]) + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "same file" in captured.out diff --git a/tests/cli/test_delete.py b/tests/cli/test_delete.py new file mode 100644 index 0000000000..12c87075c1 --- /dev/null +++ b/tests/cli/test_delete.py @@ -0,0 +1,150 @@ +"""Test tmuxp delete command.""" + +from __future__ import annotations + +import pathlib +import typing as t + +import pytest + +from tmuxp import cli + + +class DeleteTestFixture(t.NamedTuple): + """Test fixture for tmuxp delete command tests.""" + + test_id: str + cli_args: list[str] + workspace_name: str + expect_deleted: bool + file_exists: bool + + +DELETE_TEST_FIXTURES: list[DeleteTestFixture] = [ + DeleteTestFixture( + test_id="delete-workspace", + cli_args=["delete", "-y", "target"], + workspace_name="target", + expect_deleted=True, + file_exists=True, + ), + DeleteTestFixture( + test_id="delete-nonexistent", + cli_args=["delete", "-y", "nosuch"], + workspace_name="nosuch", + expect_deleted=False, + file_exists=False, + ), +] + + +@pytest.mark.parametrize( + list(DeleteTestFixture._fields), + DELETE_TEST_FIXTURES, + ids=[test.test_id for test in DELETE_TEST_FIXTURES], +) +def test_delete( + tmp_path: t.Any, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + cli_args: list[str], + workspace_name: str, + expect_deleted: bool, + file_exists: bool, +) -> None: + """Test deleting workspace config files.""" + config_dir = tmp_path / "tmuxp" + config_dir.mkdir() + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + + workspace_path = config_dir / f"{workspace_name}.yaml" + if file_exists: + workspace_path.write_text("session_name: target\n") + + if expect_deleted: + cli.cli(cli_args) + + captured = capsys.readouterr() + assert not workspace_path.exists() + assert "Deleted" in captured.out + else: + with pytest.raises(SystemExit) as exc_info: + cli.cli(cli_args) + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "not found" in captured.out.lower() + + +def test_delete_multiple( + tmp_path: t.Any, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test deleting multiple workspace configs at once.""" + config_dir = tmp_path / "tmuxp" + config_dir.mkdir() + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + + for name in ["proj1", "proj2"]: + (config_dir / f"{name}.yaml").write_text(f"session_name: {name}\n") + + cli.cli(["delete", "-y", "proj1", "proj2"]) + + assert not (config_dir / "proj1.yaml").exists() + assert not (config_dir / "proj2.yaml").exists() + + captured = capsys.readouterr() + assert captured.out.count("Deleted") == 2 + + +class DeleteExitCodeFixture(t.NamedTuple): + """Test fixture for tmuxp delete error exit codes.""" + + test_id: str + cli_args: list[str] + expected_exit_code: int + expected_output_fragment: str + + +DELETE_EXIT_CODE_FIXTURES: list[DeleteExitCodeFixture] = [ + DeleteExitCodeFixture( + test_id="nonexistent_workspace", + cli_args=["delete", "-y", "nonexistent"], + expected_exit_code=1, + expected_output_fragment="Workspace not found", + ), + DeleteExitCodeFixture( + test_id="no_args", + cli_args=["delete"], + expected_exit_code=1, + expected_output_fragment="", + ), +] + + +@pytest.mark.parametrize( + list(DeleteExitCodeFixture._fields), + DELETE_EXIT_CODE_FIXTURES, + ids=[f.test_id for f in DELETE_EXIT_CODE_FIXTURES], +) +def test_delete_error_exits_nonzero( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + cli_args: list[str], + expected_exit_code: int, + expected_output_fragment: str, +) -> None: + """Tmuxp delete exits with code 1 on error conditions.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + + with pytest.raises(SystemExit) as exc_info: + cli.cli(cli_args) + + assert exc_info.value.code == expected_exit_code + if expected_output_fragment: + captured = capsys.readouterr() + assert expected_output_fragment in captured.out diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py index 9cbe365db2..6ccf247ef7 100644 --- a/tests/cli/test_help_examples.py +++ b/tests/cli/test_help_examples.py @@ -109,11 +109,15 @@ def test_main_help_examples_are_valid_subcommands() -> None: "shell", "import", "convert", + "copy", "debug-info", + "delete", "ls", "edit", "freeze", + "new", "search", + "stop", } for example in examples: @@ -132,11 +136,15 @@ def test_main_help_examples_are_valid_subcommands() -> None: "shell", "import", "convert", + "copy", "debug-info", + "delete", "ls", "edit", "freeze", + "new", "search", + "stop", ], ) def test_subcommand_help_has_examples(subcommand: str) -> None: @@ -226,6 +234,46 @@ def test_debug_info_subcommand_examples_are_valid() -> None: assert example.startswith("tmuxp debug-info"), f"Bad example format: {example}" +def test_stop_subcommand_examples_are_valid() -> None: + """Stop subcommand examples should have valid flags.""" + help_text = _get_help_text("stop") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp stop"), f"Bad example format: {example}" + + +def test_new_subcommand_examples_are_valid() -> None: + """New subcommand examples should have valid flags.""" + help_text = _get_help_text("new") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp new"), f"Bad example format: {example}" + + +def test_copy_subcommand_examples_are_valid() -> None: + """Copy subcommand examples should have valid flags.""" + help_text = _get_help_text("copy") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp copy"), f"Bad example format: {example}" + + +def test_delete_subcommand_examples_are_valid() -> None: + """Delete subcommand examples should have valid flags.""" + help_text = _get_help_text("delete") + examples = extract_examples_from_help(help_text) + + # Verify each example has valid structure + for example in examples: + assert example.startswith("tmuxp delete"), f"Bad example format: {example}" + + def test_search_subcommand_examples_are_valid() -> None: """Search subcommand examples should have valid flags.""" help_text = _get_help_text("search") @@ -252,6 +300,18 @@ def test_search_no_args_shows_help() -> None: assert result.returncode == 0 +@pytest.mark.parametrize("subcommand", ["new", "copy", "delete"]) +def test_new_commands_no_args_shows_help(subcommand: str) -> None: + """Running new commands with no args shows help and exits 1.""" + result = subprocess.run( + ["tmuxp", subcommand], + capture_output=True, + text=True, + ) + assert f"usage: tmuxp {subcommand}" in result.stdout + assert result.returncode == 1 + + def test_main_help_example_sections_have_examples_suffix() -> None: """Main --help should have section headings ending with 'examples:'.""" help_text = _get_help_text() @@ -271,3 +331,75 @@ def test_main_help_examples_are_colorized(monkeypatch: pytest.MonkeyPatch) -> No # Should contain ANSI escape codes for colorization assert "\033[" in help_text, "Example sections should be colorized" + + +# Commands that can mutate tmux state (kill sessions, create sessions, etc.) +# These must NEVER be called via subprocess without -L . +_DANGEROUS_SUBCOMMANDS = {"stop", "load"} + + +def test_no_dangerous_subprocess_tmuxp_calls() -> None: + """Subprocess calls to tmuxp mutation commands must use -L test socket. + + Catches bugs like the one where ``subprocess.run(["tmuxp", "stop"])`` + killed the user's real tmux session because it ran on the default server + without ``-L``. + """ + import ast + import pathlib + + tests_dir = pathlib.Path(__file__).parent.parent + violations: list[str] = [] + + for py_file in tests_dir.rglob("*.py"): + try: + tree = ast.parse(py_file.read_text(encoding="utf-8"), filename=str(py_file)) + except SyntaxError: + continue + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + # Match subprocess.run(...) or subprocess.call(...) + func = node.func + is_subprocess = False + if ( + isinstance(func, ast.Attribute) + and func.attr in ("run", "call") + and isinstance(func.value, ast.Name) + and func.value.id == "subprocess" + ): + is_subprocess = True + if not is_subprocess: + continue + + # Check first arg is a list literal like ["tmuxp", "stop", ...] + if not node.args or not isinstance(node.args[0], ast.List): + continue + elts = node.args[0].elts + if len(elts) < 2: + continue + if not (isinstance(elts[0], ast.Constant) and elts[0].value == "tmuxp"): + continue + if not isinstance(elts[1], ast.Constant): + continue + + subcmd = str(elts[1].value) + if subcmd not in _DANGEROUS_SUBCOMMANDS: + continue + + # Check if -L appears anywhere in the arg list + has_socket = any( + isinstance(e, ast.Constant) and e.value == "-L" for e in elts + ) + if not has_socket: + rel = py_file.relative_to(tests_dir) + violations.append( + f"{rel}:{node.lineno}: subprocess calls " + f"'tmuxp {subcmd}' without -L test socket" + ) + + assert not violations, ( + "Dangerous subprocess tmuxp calls found (could kill real sessions):\n" + + "\n".join(f" {v}" for v in violations) + ) diff --git a/tests/cli/test_import.py b/tests/cli/test_import.py index 4faad8fe2c..f77c15347f 100644 --- a/tests/cli/test_import.py +++ b/tests/cli/test_import.py @@ -10,6 +10,7 @@ from tests.fixtures import utils as test_utils from tmuxp import cli +from tmuxp.cli import import_config as import_config_module if t.TYPE_CHECKING: import pathlib @@ -173,3 +174,95 @@ def test_import_tmuxinator( new_config_yaml = tmp_path / "la.yaml" assert new_config_yaml.exists() + + +def test_get_tmuxinator_base_indices_reads_live_tmux_options( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxinator import reads tmux base indices from live tmux options.""" + + class FakeTmuxResponse(t.NamedTuple): + """Fake tmux command response.""" + + returncode: int + stdout: list[str] + + def fake_tmux_cmd(*args: str) -> FakeTmuxResponse: + if args == ("show-options", "-gv", "base-index"): + return FakeTmuxResponse(returncode=0, stdout=["1"]) + if args == ("show-window-options", "-gv", "pane-base-index"): + return FakeTmuxResponse(returncode=0, stdout=["2"]) + msg = f"unexpected tmux args: {args!r}" + raise AssertionError(msg) + + monkeypatch.setattr(import_config_module, "tmux_cmd", fake_tmux_cmd) + + assert import_config_module._get_tmuxinator_base_indices() == (1, 2) + + +def test_get_tmuxinator_base_indices_falls_back_when_tmux_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxinator import falls back to tmux defaults when lookup fails.""" + + def raise_tmux_error(*args: str) -> t.NoReturn: + msg = f"tmux unavailable for {args!r}" + raise RuntimeError(msg) + + monkeypatch.setattr(import_config_module, "tmux_cmd", raise_tmux_error) + + assert import_config_module._get_tmuxinator_base_indices() == (0, 0) + + +def test_command_import_tmuxinator_passes_resolved_base_indices( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxinator import command passes resolved tmux indices to the importer.""" + captured: dict[str, t.Any] = {} + + def fake_find_workspace_file( + workspace_file: str, + workspace_dir: t.Any, + ) -> str: + captured["workspace_file"] = workspace_file + captured["workspace_dir"] = workspace_dir + return workspace_file + + def fake_import_config( + workspace_file: str, + importfunc: t.Callable[[dict[str, t.Any]], dict[str, t.Any]], + parser: t.Any = None, + colors: t.Any = None, + ) -> None: + captured["workspace_file"] = workspace_file + captured["parser"] = parser + captured["colors"] = colors + captured["imported"] = importfunc( + { + "name": "sample", + "startup_window": 1, + "startup_pane": 2, + "windows": [{"editor": ["vim", "logs"]}], + } + ) + + monkeypatch.setattr( + import_config_module, + "find_workspace_file", + fake_find_workspace_file, + ) + monkeypatch.setattr(import_config_module, "import_config", fake_import_config) + monkeypatch.setattr( + import_config_module, + "_get_tmuxinator_base_indices", + lambda: (1, 2), + ) + + import_config_module.command_import_tmuxinator("sample.yml") + + imported = captured["imported"] + assert imported["windows"][0]["focus"] is True + assert imported["windows"][0]["panes"][0] == { + "shell_command": ["vim"], + "focus": True, + } diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index ec045dcf3c..704a942527 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -15,11 +15,15 @@ from tests.constants import FIXTURE_PATH from tests.fixtures import utils as test_utils from tmuxp import cli +from tmuxp._internal.colors import ColorMode, Colors from tmuxp._internal.config_reader import ConfigReader from tmuxp._internal.private_path import PrivatePath from tmuxp.cli.load import ( + CLILoadNamespace, + _dispatch_build, _load_append_windows_to_current_session, _load_attached, + command_load, load_plugins, load_workspace, ) @@ -887,6 +891,246 @@ def test_load_workspace_env_progress_disabled( assert session.name == "sample workspace" +class NoShellCommandBeforeFixture(t.NamedTuple): + """Test fixture for --no-shell-command-before flag tests.""" + + test_id: str + no_shell_command_before: bool + expect_before_cmd: bool + + +NO_SHELL_COMMAND_BEFORE_FIXTURES: list[NoShellCommandBeforeFixture] = [ + NoShellCommandBeforeFixture( + test_id="with-shell-command-before", + no_shell_command_before=False, + expect_before_cmd=True, + ), + NoShellCommandBeforeFixture( + test_id="no-shell-command-before", + no_shell_command_before=True, + expect_before_cmd=False, + ), +] + + +@pytest.mark.parametrize( + list(NoShellCommandBeforeFixture._fields), + NO_SHELL_COMMAND_BEFORE_FIXTURES, + ids=[f.test_id for f in NO_SHELL_COMMAND_BEFORE_FIXTURES], +) +def test_load_workspace_no_shell_command_before( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + no_shell_command_before: bool, + expect_before_cmd: bool, +) -> None: + """Test --no-shell-command-before strips shell_command_before from config.""" + monkeypatch.delenv("TMUX", raising=False) + + workspace_file = tmp_path / "test.yaml" + workspace_file.write_text( + """ +session_name: scb_test +shell_command_before: + - echo __BEFORE__ +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + session = load_workspace( + str(workspace_file), + socket_name=server.socket_name, + detached=True, + no_shell_command_before=no_shell_command_before, + ) + + assert isinstance(session, Session) + assert session.name == "scb_test" + + window = session.active_window + assert window is not None + pane = window.active_pane + assert pane is not None + + from libtmux.test.retry import retry_until + + if expect_before_cmd: + assert retry_until( + lambda: "__BEFORE__" in "\n".join(pane.capture_pane()), + seconds=5, + ) + else: + import time + + time.sleep(1) + assert "__BEFORE__" not in "\n".join(pane.capture_pane()) + + +def test_load_no_shell_command_before_strips_all_levels( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --no-shell-command-before strips from session, window, and pane levels.""" + monkeypatch.delenv("TMUX", raising=False) + + workspace_file = tmp_path / "multi_level.yaml" + workspace_file.write_text( + """ +session_name: strip_test +shell_command_before: + - echo session_before +windows: +- window_name: main + shell_command_before: + - echo window_before + panes: + - shell_command: + - echo hello + shell_command_before: + - echo pane_before +""", + encoding="utf-8", + ) + + # Verify the stripping logic via loader functions + raw = ConfigReader._from_file(workspace_file) or {} + expanded = loader.expand(raw, cwd=str(tmp_path)) + + # Before stripping, shell_command_before should be present + assert "shell_command_before" in expanded + assert "shell_command_before" in expanded["windows"][0] + assert "shell_command_before" in expanded["windows"][0]["panes"][0] + + # Simulate the stripping logic from load_workspace + expanded.pop("shell_command_before", None) + for window in expanded.get("windows", []): + window.pop("shell_command_before", None) + for pane in window.get("panes", []): + pane.pop("shell_command_before", None) + + trickled = loader.trickle(expanded) + + # After stripping + trickle, pane commands should not include before cmds + pane_cmds = trickled["windows"][0]["panes"][0]["shell_command"] + cmd_strings = [c["cmd"] for c in pane_cmds] + assert "echo session_before" not in cmd_strings + assert "echo window_before" not in cmd_strings + assert "echo pane_before" not in cmd_strings + assert "echo hello" in cmd_strings + + +class DebugFlagFixture(t.NamedTuple): + """Test fixture for --debug flag tests.""" + + test_id: str + debug: bool + expect_tmux_commands_in_output: bool + + +DEBUG_FLAG_FIXTURES: list[DebugFlagFixture] = [ + DebugFlagFixture( + test_id="debug-off", + debug=False, + expect_tmux_commands_in_output=False, + ), + DebugFlagFixture( + test_id="debug-on", + debug=True, + expect_tmux_commands_in_output=True, + ), +] + + +@pytest.mark.parametrize( + list(DebugFlagFixture._fields), + DEBUG_FLAG_FIXTURES, + ids=[f.test_id for f in DEBUG_FLAG_FIXTURES], +) +def test_load_workspace_debug_flag( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + debug: bool, + expect_tmux_commands_in_output: bool, +) -> None: + """Test --debug shows tmux commands in output.""" + monkeypatch.delenv("TMUX", raising=False) + + workspace_file = tmp_path / "test.yaml" + workspace_file.write_text( + """ +session_name: debug_test +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + session = load_workspace( + str(workspace_file), + socket_name=server.socket_name, + detached=True, + debug=debug, + ) + + assert isinstance(session, Session) + assert session.name == "debug_test" + + captured = capsys.readouterr() + if expect_tmux_commands_in_output: + assert "$ " in captured.out + assert "new-session" in captured.out + else: + # When debug is off, tmux commands should not appear in stdout + assert "new-session" not in captured.out + + +def test_load_debug_cleans_up_handler( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify --debug removes its handler after load completes.""" + import logging + + monkeypatch.delenv("TMUX", raising=False) + + workspace_file = tmp_path / "test.yaml" + workspace_file.write_text( + """ +session_name: debug_cleanup +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + libtmux_logger = logging.getLogger("libtmux.common") + handler_count_before = len(libtmux_logger.handlers) + + load_workspace( + str(workspace_file), + socket_name=server.socket_name, + detached=True, + debug=True, + ) + + assert len(libtmux_logger.handlers) == handler_count_before + + def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> None: """Spinner message should mask home directory via PrivatePath.""" monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) @@ -897,3 +1141,713 @@ def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> assert "~/work/project/.tmuxp.yaml" in message assert "/home/testuser" not in message + + +def test_load_on_project_start_runs_hook( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxp load runs on_project_start hook before session creation.""" + monkeypatch.delenv("TMUX", raising=False) + + marker = tmp_path / "start_hook_ran" + workspace_file = tmp_path / "hook_start.yaml" + workspace_file.write_text( + f"""\ +session_name: hook-start-test +on_project_start: "touch {marker}" +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + session = load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=True, + ) + + assert marker.exists() + assert session is not None + session.kill() + + +class DispatchBuildHookFixture(t.NamedTuple): + """Fixture for on_project_start dispatch behavior.""" + + test_id: str + detached: bool + append: bool + answer_yes: bool + here: bool + inside_tmux: bool + prompt_choice: str | None + expected_loader: str + expect_pre_build_hook: bool + + +DISPATCH_BUILD_HOOK_FIXTURES: list[DispatchBuildHookFixture] = [ + DispatchBuildHookFixture( + test_id="detached-new-session-runs-hook", + detached=True, + append=False, + answer_yes=False, + here=False, + inside_tmux=False, + prompt_choice=None, + expected_loader="detached", + expect_pre_build_hook=True, + ), + DispatchBuildHookFixture( + test_id="interactive-append-skips-hook", + detached=False, + append=False, + answer_yes=False, + here=False, + inside_tmux=True, + prompt_choice="a", + expected_loader="append", + expect_pre_build_hook=False, + ), + DispatchBuildHookFixture( + test_id="interactive-detach-runs-hook", + detached=False, + append=False, + answer_yes=False, + here=False, + inside_tmux=True, + prompt_choice="n", + expected_loader="detached", + expect_pre_build_hook=True, + ), + DispatchBuildHookFixture( + test_id="interactive-attach-runs-hook", + detached=False, + append=False, + answer_yes=False, + here=False, + inside_tmux=True, + prompt_choice="y", + expected_loader="attached", + expect_pre_build_hook=True, + ), + DispatchBuildHookFixture( + test_id="here-inside-tmux-skips-hook", + detached=False, + append=False, + answer_yes=False, + here=True, + inside_tmux=True, + prompt_choice=None, + expected_loader="here", + expect_pre_build_hook=False, + ), + DispatchBuildHookFixture( + test_id="here-outside-tmux-fallback-runs-hook", + detached=False, + append=False, + answer_yes=False, + here=True, + inside_tmux=False, + prompt_choice=None, + expected_loader="attached", + expect_pre_build_hook=True, + ), +] + + +@pytest.mark.parametrize( + list(DispatchBuildHookFixture._fields), + DISPATCH_BUILD_HOOK_FIXTURES, + ids=[f.test_id for f in DISPATCH_BUILD_HOOK_FIXTURES], +) +def test_dispatch_build_on_project_start_only_for_new_session_paths( + monkeypatch: pytest.MonkeyPatch, + test_id: str, + detached: bool, + append: bool, + answer_yes: bool, + here: bool, + inside_tmux: bool, + prompt_choice: str | None, + expected_loader: str, + expect_pre_build_hook: bool, +) -> None: + """_dispatch_build only runs on_project_start on new-session load paths.""" + + class DummyBuilder: + """Minimal builder stub for dispatch tests.""" + + def __init__(self) -> None: + self.session = object() + self.plugins: list[t.Any] = [] + self.on_progress: t.Any = "sentinel" + self.on_before_script: t.Any = "sentinel" + self.on_script_output: t.Any = "sentinel" + self.on_build_event: t.Any = "sentinel" + + builder = t.cast(WorkspaceBuilder, DummyBuilder()) + loader_calls: list[str] = [] + hook_calls: list[str] = [] + + def _pre_build_hook() -> None: + hook_calls.append("hook") + + def _attached_stub( + builder: DummyBuilder, + detached: bool, + pre_build_hook: t.Callable[[], None] | None = None, + pre_attach_hook: t.Callable[[], None] | None = None, + ) -> None: + if pre_build_hook is not None: + pre_build_hook() + loader_calls.append("attached") + + def _detached_stub( + builder: DummyBuilder, + colors: Colors | None = None, + pre_build_hook: t.Callable[[], None] | None = None, + pre_output_hook: t.Callable[[], None] | None = None, + ) -> None: + if pre_build_hook is not None: + pre_build_hook() + loader_calls.append("detached") + + def _append_stub(builder: DummyBuilder) -> None: + loader_calls.append("append") + + def _here_stub(builder: DummyBuilder) -> None: + loader_calls.append("here") + + monkeypatch.setattr("tmuxp.cli.load._load_attached", _attached_stub) + monkeypatch.setattr("tmuxp.cli.load._load_detached", _detached_stub) + monkeypatch.setattr( + "tmuxp.cli.load._load_append_windows_to_current_session", + _append_stub, + ) + monkeypatch.setattr("tmuxp.cli.load._load_here_in_current_session", _here_stub) + monkeypatch.setattr( + "tmuxp.cli.load._setup_plugins", + lambda builder: builder.session, + ) + + if prompt_choice is not None: + monkeypatch.setattr( + "tmuxp.cli.load.prompt_choices", + lambda *a, **kw: prompt_choice, + ) + + if inside_tmux: + monkeypatch.setenv("TMUX", "/tmp/tmux-test/default,12345,0") + else: + monkeypatch.delenv("TMUX", raising=False) + + result = _dispatch_build( + builder=builder, + detached=detached, + append=append, + answer_yes=answer_yes, + cli_colors=Colors(ColorMode.NEVER), + here=here, + pre_build_hook=_pre_build_hook, + ) + + assert result is builder.session + assert loader_calls == [expected_loader], test_id + assert hook_calls == (["hook"] if expect_pre_build_hook else []), test_id + assert builder.on_progress is None + assert builder.on_before_script is None + assert builder.on_script_output is None + assert builder.on_build_event is None + + +def test_load_on_project_restart_runs_hook( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxp load runs on_project_restart hook when session already exists.""" + monkeypatch.delenv("TMUX", raising=False) + + marker = tmp_path / "restart_hook_ran" + workspace_file = tmp_path / "hook_restart.yaml" + workspace_file.write_text( + f"""\ +session_name: hook-restart-test +on_project_restart: "touch {marker}" +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + # First load creates the session + session = load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=True, + ) + assert session is not None + assert not marker.exists() + + # Second detached load does NOT trigger on_project_restart + # (restart hook only fires on confirmed interactive reattach) + load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=True, + ) + assert not marker.exists() + + session.kill() + + +def test_load_on_project_restart_skipped_on_decline( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxp load skips on_project_restart when user declines reattach.""" + monkeypatch.delenv("TMUX", raising=False) + + marker = tmp_path / "restart_hook_ran" + workspace_file = tmp_path / "hook_restart_decline.yaml" + workspace_file.write_text( + f"""\ +session_name: hook-restart-decline +on_project_restart: "touch {marker}" +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + # First load creates the session + session = load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=True, + ) + assert session is not None + assert not marker.exists() + + # Second load: session exists, user declines reattach + monkeypatch.setattr( + "tmuxp.cli.load.prompt_yes_no", + lambda *a, **kw: False, + ) + load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=False, + ) + assert not marker.exists() + + session.kill() + + +def test_load_on_project_start_skipped_on_decline( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxp load skips on_project_start when user declines reattach.""" + monkeypatch.delenv("TMUX", raising=False) + + marker = tmp_path / "start_hook_ran" + workspace_file = tmp_path / "hook_start_decline.yaml" + workspace_file.write_text( + f"""\ +session_name: hook-start-decline +on_project_start: "touch {marker}" +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + # First load creates the session + session = load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=True, + ) + assert session is not None + assert marker.exists() + marker.unlink() + + # Second load: session exists, user declines reattach + monkeypatch.setattr( + "tmuxp.cli.load.prompt_yes_no", + lambda *a, **kw: False, + ) + load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=False, + ) + assert not marker.exists() + + session.kill() + + +class ConfigKeyPrecedenceFixture(t.NamedTuple): + """Test fixture for config key precedence tests.""" + + test_id: str + workspace_extra: dict[str, t.Any] + cli_socket_name: str | None + cli_tmux_config_file: str | None + expect_socket_name: str | None + expect_config_file: str | None + + +CONFIG_KEY_PRECEDENCE_FIXTURES: list[ConfigKeyPrecedenceFixture] = [ + ConfigKeyPrecedenceFixture( + test_id="workspace-socket_name-used-as-fallback", + workspace_extra={"socket_name": "{server_socket}"}, + cli_socket_name=None, + cli_tmux_config_file=None, + expect_socket_name="{server_socket}", + expect_config_file=None, + ), + ConfigKeyPrecedenceFixture( + test_id="workspace-config-used-as-fallback", + workspace_extra={"config": "{tmux_conf}"}, + cli_socket_name="{server_socket}", + cli_tmux_config_file=None, + expect_socket_name="{server_socket}", + expect_config_file="{tmux_conf}", + ), + ConfigKeyPrecedenceFixture( + test_id="cli-overrides-workspace-socket_name", + workspace_extra={"socket_name": "ignored-socket"}, + cli_socket_name="{server_socket}", + cli_tmux_config_file=None, + expect_socket_name="{server_socket}", + expect_config_file=None, + ), + ConfigKeyPrecedenceFixture( + test_id="cli-overrides-workspace-config", + workspace_extra={"config": "/ignored/tmux.conf"}, + cli_socket_name="{server_socket}", + cli_tmux_config_file="{tmux_conf}", + expect_socket_name="{server_socket}", + expect_config_file="{tmux_conf}", + ), +] + + +@pytest.mark.parametrize( + list(ConfigKeyPrecedenceFixture._fields), + CONFIG_KEY_PRECEDENCE_FIXTURES, + ids=[f.test_id for f in CONFIG_KEY_PRECEDENCE_FIXTURES], +) +def test_load_workspace_config_key_precedence( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + workspace_extra: dict[str, t.Any], + cli_socket_name: str | None, + cli_tmux_config_file: str | None, + expect_socket_name: str | None, + expect_config_file: str | None, +) -> None: + """Workspace config keys (socket_name, config) used as Server fallbacks.""" + monkeypatch.delenv("TMUX", raising=False) + + tmux_conf = str(FIXTURE_PATH / "tmux" / "tmux.conf") + server_socket = server.socket_name + + def _resolve(val: str | None) -> str | None: + if val is None: + return None + return val.format(server_socket=server_socket, tmux_conf=tmux_conf) + + resolved_extra = { + k: _resolve(v) if isinstance(v, str) else v for k, v in workspace_extra.items() + } + + extra_lines = "\n".join(f"{k}: {v}" for k, v in resolved_extra.items()) + workspace_file = tmp_path / "test.yaml" + workspace_file.write_text( + f"""\ +session_name: cfg-key-{test_id} +{extra_lines} +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + session = load_workspace( + str(workspace_file), + socket_name=_resolve(cli_socket_name), + tmux_config_file=_resolve(cli_tmux_config_file), + detached=True, + ) + + assert isinstance(session, Session) + + if _resolve(expect_socket_name) is not None: + assert session.server.socket_name == _resolve(expect_socket_name) + if _resolve(expect_config_file) is not None: + assert session.server.config_file == _resolve(expect_config_file) + + session.kill() + + +def test_load_workspace_template_context( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """load_workspace() renders {{ var }} templates before YAML parsing.""" + monkeypatch.delenv("TMUX", raising=False) + + workspace_file = tmp_path / "tpl.yaml" + workspace_file.write_text( + """\ +session_name: {{ project }}-session +windows: +- window_name: {{ window }} + panes: + - echo {{ project }} +""", + encoding="utf-8", + ) + + session = load_workspace( + str(workspace_file), + socket_name=server.socket_name, + detached=True, + template_context={"project": "myapp", "window": "editor"}, + ) + + assert isinstance(session, Session) + assert session.name == "myapp-session" + assert session.windows[0].window_name == "editor" + + +def test_load_workspace_template_no_context( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """load_workspace() without template_context leaves {{ var }} as literals.""" + monkeypatch.delenv("TMUX", raising=False) + + workspace_file = tmp_path / "tpl.yaml" + workspace_file.write_text( + """\ +session_name: plain-session +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + session = load_workspace( + str(workspace_file), + socket_name=server.socket_name, + detached=True, + ) + + assert isinstance(session, Session) + assert session.name == "plain-session" + + +def test_load_here_and_append_mutually_exclusive() -> None: + """--here and --append cannot be used together.""" + from tmuxp.cli import create_parser + + parser = create_parser() + with pytest.raises(SystemExit): + parser.parse_args(["load", "--here", "--append", "myconfig"]) + + +def test_load_here_and_detached_mutually_exclusive() -> None: + """--here and -d cannot be used together.""" + from tmuxp.cli import create_parser + + parser = create_parser() + with pytest.raises(SystemExit): + parser.parse_args(["load", "--here", "-d", "myconfig"]) + + +class MultiWorkspaceHereFixture(t.NamedTuple): + """Fixture for invalid multi-workspace --here invocations.""" + + test_id: str + cli_args: list[str] + expected_exit_code: int + expected_error: str + + +MULTI_WORKSPACE_HERE_FIXTURES: list[MultiWorkspaceHereFixture] = [ + MultiWorkspaceHereFixture( + test_id="rejects-two-workspaces", + cli_args=["load", "--here", "first", "second"], + expected_exit_code=2, + expected_error="--here only supports one workspace file", + ), +] + + +@pytest.mark.parametrize( + list(MultiWorkspaceHereFixture._fields), + MULTI_WORKSPACE_HERE_FIXTURES, + ids=[fixture.test_id for fixture in MULTI_WORKSPACE_HERE_FIXTURES], +) +def test_load_here_rejects_multiple_workspace_files( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + cli_args: list[str], + expected_exit_code: int, + expected_error: str, +) -> None: + """--here exits before load_workspace when multiple files are provided.""" + parser = cli.create_parser() + args = t.cast(CLILoadNamespace, parser.parse_args(cli_args)) + load_calls: list[str] = [] + + monkeypatch.setattr( + "tmuxp.cli.load.util.oh_my_zsh_auto_title", + lambda: None, + ) + monkeypatch.setattr( + "tmuxp.cli.load.load_workspace", + lambda *args, **kwargs: load_calls.append(test_id), + ) + + with pytest.raises(SystemExit) as excinfo: + command_load(args, parser=parser) + + result = capsys.readouterr() + assert excinfo.value.code == expected_exit_code + assert expected_error in result.err + assert load_calls == [] + + +def test_load_append_and_detached_mutually_exclusive() -> None: + """--append and -d cannot be used together.""" + from tmuxp.cli import create_parser + + parser = create_parser() + with pytest.raises(SystemExit): + parser.parse_args(["load", "--append", "-d", "myconfig"]) + + +# --- --here error recovery tests (535ca944) --- + + +class HereErrorRecoveryFixture(t.NamedTuple): + """Fixture for --here error recovery prompt behavior.""" + + test_id: str + here: bool + expected_choices: list[str] + expected_default: str + kill_option_present: bool + + +HERE_ERROR_RECOVERY_FIXTURES: list[HereErrorRecoveryFixture] = [ + HereErrorRecoveryFixture( + test_id="here-mode-no-kill", + here=True, + expected_choices=["a", "d"], + expected_default="d", + kill_option_present=False, + ), + HereErrorRecoveryFixture( + test_id="normal-mode-has-kill", + here=False, + expected_choices=["k", "a", "d"], + expected_default="k", + kill_option_present=True, + ), +] + + +@pytest.mark.parametrize( + list(HereErrorRecoveryFixture._fields), + HERE_ERROR_RECOVERY_FIXTURES, + ids=[f.test_id for f in HERE_ERROR_RECOVERY_FIXTURES], +) +def test_here_error_recovery_prompt( + monkeypatch: pytest.MonkeyPatch, + test_id: str, + here: bool, + expected_choices: list[str], + expected_default: str, + kill_option_present: bool, +) -> None: + """--here error recovery skips (k)ill to protect user's live session.""" + from unittest.mock import MagicMock + + from tmuxp._internal.colors import ColorMode, Colors + from tmuxp.cli.load import _dispatch_build + + captured_kwargs: dict[str, t.Any] = {} + + def _capture_prompt_choices(*args: t.Any, **kwargs: t.Any) -> str: + captured_kwargs.update(kwargs) + captured_kwargs["choices"] = kwargs.get("choices", []) + return "d" # Always detach to exit cleanly + + monkeypatch.setattr( + "tmuxp.cli.load.prompt_choices", + _capture_prompt_choices, + ) + + # Create a mock builder that raises TmuxpException when built + from tmuxp import exc + + mock_builder = MagicMock() + mock_builder.session = None + + # Simulate the here path raising an error + if here: + monkeypatch.setattr( + "tmuxp.cli.load._load_here_in_current_session", + MagicMock(side_effect=exc.TmuxpException("test error")), + ) + monkeypatch.setenv("TMUX", "/tmp/tmux-test/default,12345,0") + else: + monkeypatch.setattr( + "tmuxp.cli.load._load_attached", + MagicMock(side_effect=exc.TmuxpException("test error")), + ) + monkeypatch.delenv("TMUX", raising=False) + + cli_colors = Colors(ColorMode.NEVER) + + with pytest.raises(SystemExit): + _dispatch_build( + builder=mock_builder, + detached=False, + append=False, + answer_yes=not here, # answer_yes triggers _load_attached path + cli_colors=cli_colors, + here=here, + ) + + assert captured_kwargs["choices"] == expected_choices + assert captured_kwargs.get("default") == expected_default + assert ("k" in captured_kwargs["choices"]) == kill_option_present diff --git a/tests/cli/test_new.py b/tests/cli/test_new.py new file mode 100644 index 0000000000..781a28f663 --- /dev/null +++ b/tests/cli/test_new.py @@ -0,0 +1,283 @@ +"""Test tmuxp new command.""" + +from __future__ import annotations + +import pathlib +import typing as t + +import pytest + +from tmuxp import cli +from tmuxp.cli.new import WORKSPACE_TEMPLATE + + +class NewTestFixture(t.NamedTuple): + """Test fixture for tmuxp new command tests.""" + + test_id: str + cli_args: list[str] + workspace_name: str + expect_created: bool + pre_existing: bool + + +NEW_TEST_FIXTURES: list[NewTestFixture] = [ + NewTestFixture( + test_id="new-workspace", + cli_args=["new", "myproject"], + workspace_name="myproject", + expect_created=True, + pre_existing=False, + ), + NewTestFixture( + test_id="new-existing-workspace", + cli_args=["new", "existing"], + workspace_name="existing", + expect_created=False, + pre_existing=True, + ), +] + + +@pytest.mark.parametrize( + list(NewTestFixture._fields), + NEW_TEST_FIXTURES, + ids=[test.test_id for test in NEW_TEST_FIXTURES], +) +def test_new( + tmp_path: t.Any, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + cli_args: list[str], + workspace_name: str, + expect_created: bool, + pre_existing: bool, +) -> None: + """Test creating a new workspace config.""" + config_dir = tmp_path / "tmuxp" + config_dir.mkdir() + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + monkeypatch.setenv("EDITOR", "true") + + workspace_path = config_dir / f"{workspace_name}.yaml" + + if pre_existing: + original_content = "session_name: original\n" + workspace_path.write_text(original_content) + + cli.cli(cli_args) + + captured = capsys.readouterr() + assert workspace_path.exists() + + if expect_created: + expected_content = WORKSPACE_TEMPLATE.format(name=workspace_name) + assert workspace_path.read_text() == expected_content + assert "Created" in captured.out + else: + assert workspace_path.read_text() == original_content + assert "already exists" in captured.out + + +def test_new_creates_workspace_dir( + tmp_path: t.Any, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test that 'new' creates the workspace directory if it doesn't exist.""" + config_dir = tmp_path / "nonexistent" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(config_dir)) + monkeypatch.setenv("EDITOR", "true") + + assert not config_dir.exists() + + cli.cli(["new", "myproject"]) + + assert config_dir.exists() + workspace_path = config_dir / "myproject.yaml" + assert workspace_path.exists() + + +class EditorFixture(t.NamedTuple): + """Fixture for EDITOR environment variable handling.""" + + test_id: str + editor: str + expect_error_output: bool + + +EDITOR_FIXTURES: list[EditorFixture] = [ + EditorFixture( + test_id="valid_editor", + editor="true", + expect_error_output=False, + ), + EditorFixture( + test_id="editor_with_flags", + editor="true --ignored-flag", + expect_error_output=False, + ), +] + + +@pytest.mark.parametrize( + list(EditorFixture._fields), + EDITOR_FIXTURES, + ids=[f.test_id for f in EDITOR_FIXTURES], +) +def test_new_editor_handling( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + editor: str, + expect_error_output: bool, +) -> None: + """Test EDITOR handling: flags and valid editor.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + monkeypatch.setenv("EDITOR", editor) + + cli.cli(["new", f"editortest_{test_id}"]) + + workspace_path = tmp_path / f"editortest_{test_id}.yaml" + assert workspace_path.exists() + + captured = capsys.readouterr() + if expect_error_output: + assert "Editor not found" in captured.out + else: + assert "Editor not found" not in captured.out + + +class NewExitCodeFixture(t.NamedTuple): + """Test fixture for tmuxp new error exit codes.""" + + test_id: str + cli_args: list[str] + editor: str + expected_exit_code: int + expected_output_fragment: str + + +NEW_EXIT_CODE_FIXTURES: list[NewExitCodeFixture] = [ + NewExitCodeFixture( + test_id="no_args", + cli_args=["new"], + editor="true", + expected_exit_code=1, + expected_output_fragment="", + ), + NewExitCodeFixture( + test_id="missing_editor", + cli_args=["new", "editortest_missing"], + editor="nonexistent_editor_binary_xyz", + expected_exit_code=1, + expected_output_fragment="Editor not found", + ), +] + + +@pytest.mark.parametrize( + list(NewExitCodeFixture._fields), + NEW_EXIT_CODE_FIXTURES, + ids=[f.test_id for f in NEW_EXIT_CODE_FIXTURES], +) +def test_new_error_exits_nonzero( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + cli_args: list[str], + editor: str, + expected_exit_code: int, + expected_output_fragment: str, +) -> None: + """Tmuxp new exits with code 1 on error conditions.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + monkeypatch.setenv("EDITOR", editor) + + with pytest.raises(SystemExit) as exc_info: + cli.cli(cli_args) + + assert exc_info.value.code == expected_exit_code + if expected_output_fragment: + captured = capsys.readouterr() + assert expected_output_fragment in captured.out + + +class NewNameValidationFixture(t.NamedTuple): + """Test fixture for workspace name validation.""" + + test_id: str + workspace_name: str + expected_error_fragment: str + + +NEW_NAME_VALIDATION_FIXTURES: list[NewNameValidationFixture] = [ + NewNameValidationFixture( + test_id="path_traversal", + workspace_name="../outside", + expected_error_fragment="path separators", + ), + NewNameValidationFixture( + test_id="yaml_boolean_yes", + workspace_name="yes", + expected_error_fragment="YAML reserved word", + ), + NewNameValidationFixture( + test_id="yaml_boolean_true", + workspace_name="true", + expected_error_fragment="YAML reserved word", + ), + NewNameValidationFixture( + test_id="yaml_comment", + workspace_name="#tmp", + expected_error_fragment="YAML special character", + ), + NewNameValidationFixture( + test_id="yaml_alias", + workspace_name="*alias", + expected_error_fragment="YAML special character", + ), + NewNameValidationFixture( + test_id="path_separator", + workspace_name="a/b", + expected_error_fragment="path separators", + ), + NewNameValidationFixture( + test_id="single_quote", + workspace_name="foo'bar", + expected_error_fragment="quotes", + ), + NewNameValidationFixture( + test_id="double_quote", + workspace_name='foo"bar', + expected_error_fragment="quotes", + ), +] + + +@pytest.mark.parametrize( + list(NewNameValidationFixture._fields), + NEW_NAME_VALIDATION_FIXTURES, + ids=[f.test_id for f in NEW_NAME_VALIDATION_FIXTURES], +) +def test_new_rejects_invalid_workspace_name( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + test_id: str, + workspace_name: str, + expected_error_fragment: str, +) -> None: + """Tmuxp new rejects path traversal and YAML-hostile workspace names.""" + monkeypatch.setenv("TMUXP_CONFIGDIR", str(tmp_path)) + monkeypatch.setenv("EDITOR", "true") + + with pytest.raises(SystemExit) as exc_info: + cli.cli(["new", workspace_name]) + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert expected_error_fragment in captured.out diff --git a/tests/cli/test_stop.py b/tests/cli/test_stop.py new file mode 100644 index 0000000000..c02055ef9c --- /dev/null +++ b/tests/cli/test_stop.py @@ -0,0 +1,173 @@ +"""Test tmuxp stop command.""" + +from __future__ import annotations + +import pathlib +import typing as t + +import pytest + +from tmuxp import cli +from tmuxp.cli.load import load_workspace + +if t.TYPE_CHECKING: + from libtmux.server import Server + + +class StopTestFixture(t.NamedTuple): + """Test fixture for tmuxp stop command tests.""" + + test_id: str + cli_args: list[str] + session_name: str + + +STOP_TEST_FIXTURES: list[StopTestFixture] = [ + StopTestFixture( + test_id="stop-named-session", + cli_args=["stop", "killme"], + session_name="killme", + ), +] + + +@pytest.mark.parametrize( + list(StopTestFixture._fields), + STOP_TEST_FIXTURES, + ids=[test.test_id for test in STOP_TEST_FIXTURES], +) +def test_stop( + server: Server, + test_id: str, + cli_args: list[str], + session_name: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test stopping a tmux session by name.""" + monkeypatch.delenv("TMUX", raising=False) + + server.new_session(session_name=session_name) + assert server.has_session(session_name) + + assert server.socket_name is not None + cli_args = [*cli_args, "-L", server.socket_name] + + cli.cli(cli_args) + + assert not server.has_session(session_name) + + +def test_stop_nonexistent_session( + server: Server, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Test stopping a session that doesn't exist exits with code 1.""" + monkeypatch.delenv("TMUX", raising=False) + + assert server.socket_name is not None + + with pytest.raises(SystemExit) as exc_info: + cli.cli(["stop", "nonexistent", "-L", server.socket_name]) + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "Session not found" in captured.out + + +def test_stop_runs_on_project_stop_hook( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxp stop runs on_project_stop hook from session environment.""" + monkeypatch.delenv("TMUX", raising=False) + + marker = tmp_path / "stop_hook_ran" + workspace_file = tmp_path / "hook_stop.yaml" + workspace_file.write_text( + f"""\ +session_name: hook-stop-test +on_project_stop: "touch {marker}" +windows: +- window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + session = load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=True, + ) + assert session is not None + + # Verify env var was stored + stop_cmd = session.getenv("TMUXP_ON_PROJECT_STOP") + assert stop_cmd is not None + + # Stop the session via CLI + assert server.socket_name is not None + cli.cli(["stop", "hook-stop-test", "-L", server.socket_name]) + + assert marker.exists() + assert not server.has_session("hook-stop-test") + + +def test_stop_no_args_inside_tmux_uses_fallback( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxp stop with no session name inside tmux falls back to current session.""" + sess = server.new_session(session_name="fallback-target") + assert server.has_session("fallback-target") + + # Simulate being inside tmux by setting TMUX and TMUX_PANE + pane = sess.active_window.active_pane + assert pane is not None + monkeypatch.setenv("TMUX", f"/tmp/tmux-test,{sess.session_id},0") + monkeypatch.setenv("TMUX_PANE", pane.pane_id or "") + + assert server.socket_name is not None + cli.cli(["stop", "-L", server.socket_name]) + + assert not server.has_session("fallback-target") + + +def test_stop_no_args_outside_tmux_shows_error( + server: Server, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Tmuxp stop with no session name outside tmux shows error.""" + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.delenv("TMUX_PANE", raising=False) + + server.new_session(session_name="should-survive") + + assert server.socket_name is not None + with pytest.raises(SystemExit) as exc_info: + cli.cli(["stop", "-L", server.socket_name]) + + assert exc_info.value.code == 1 + captured = capsys.readouterr() + assert "not inside tmux" in captured.out + assert server.has_session("should-survive") + + +def test_stop_without_hook( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Tmuxp stop works normally when no on_project_stop hook is set.""" + monkeypatch.delenv("TMUX", raising=False) + + server.new_session(session_name="no-hook-session") + assert server.has_session("no-hook-session") + + assert server.socket_name is not None + cli.cli(["stop", "no-hook-session", "-L", server.socket_name]) + + assert not server.has_session("no-hook-session") diff --git a/tests/docs/examples/__init__.py b/tests/docs/examples/__init__.py new file mode 100644 index 0000000000..4b6f66939d --- /dev/null +++ b/tests/docs/examples/__init__.py @@ -0,0 +1 @@ +"""Tests for example workspace YAML files.""" diff --git a/tests/docs/examples/test_examples.py b/tests/docs/examples/test_examples.py new file mode 100644 index 0000000000..3c2fbbb5e3 --- /dev/null +++ b/tests/docs/examples/test_examples.py @@ -0,0 +1,86 @@ +"""Tests for example workspace YAML files.""" + +from __future__ import annotations + +import functools + +from libtmux.pane import Pane +from libtmux.session import Session +from libtmux.test.retry import retry_until + +from tests.constants import EXAMPLE_PATH +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.workspace import loader +from tmuxp.workspace.builder import WorkspaceBuilder + + +def test_synchronize_shorthand(session: Session) -> None: + """Test synchronize-shorthand.yaml builds and sets synchronize-panes.""" + config = ConfigReader._from_file(EXAMPLE_PATH / "synchronize-shorthand.yaml") + config = loader.expand(config) + builder = WorkspaceBuilder(session_config=config, server=session.server) + builder.build(session=session) + + windows = session.windows + assert len(windows) == 3 + + synced_before = windows[0] + synced_after = windows[1] + not_synced = windows[2] + + assert synced_before.show_option("synchronize-panes") is True + assert synced_after.show_option("synchronize-panes") is True + assert not_synced.show_option("synchronize-panes") is not True + + +def test_lifecycle_hooks(session: Session) -> None: + """Test lifecycle-hooks.yaml loads without error.""" + config = ConfigReader._from_file(EXAMPLE_PATH / "lifecycle-hooks.yaml") + config = loader.expand(config) + builder = WorkspaceBuilder(session_config=config, server=session.server) + builder.build(session=session) + + assert len(session.windows) >= 1 + + +def test_config_templating(session: Session) -> None: + """Test config-templating.yaml renders templates and builds.""" + config = ConfigReader._from_file( + EXAMPLE_PATH / "config-templating.yaml", + template_context={"project": "myapp"}, + ) + config = loader.expand(config) + + assert config["session_name"] == "myapp" + assert config["windows"][0]["window_name"] == "myapp-main" + + builder = WorkspaceBuilder(session_config=config, server=session.server) + builder.build(session=session) + + assert len(session.windows) >= 1 + + +def test_pane_titles(session: Session) -> None: + """Test pane-titles.yaml builds with pane title options.""" + config = ConfigReader._from_file(EXAMPLE_PATH / "pane-titles.yaml") + config = loader.expand(config) + builder = WorkspaceBuilder(session_config=config, server=session.server) + builder.build(session=session) + + window = session.windows[0] + assert window.show_option("pane-border-status") == "top" + assert window.show_option("pane-border-format") == "#{pane_index}: #{pane_title}" + + panes = window.panes + assert len(panes) == 3 + + def check_title(p: Pane, expected: str) -> bool: + p.refresh() + return p.pane_title == expected + + assert retry_until( + functools.partial(check_title, panes[0], "editor"), + ), f"Expected title 'editor', got '{panes[0].pane_title}'" + assert retry_until( + functools.partial(check_title, panes[1], "runner"), + ), f"Expected title 'runner', got '{panes[1].pane_title}'" diff --git a/tests/fixtures/import_teamocil/__init__.py b/tests/fixtures/import_teamocil/__init__.py index 1ec7c59fd5..ac48683e2f 100644 --- a/tests/fixtures/import_teamocil/__init__.py +++ b/tests/fixtures/import_teamocil/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -from . import layouts, test1, test2, test3, test4 +from . import layouts, test1, test2, test3, test4, test5, test6 diff --git a/tests/fixtures/import_teamocil/test5.py b/tests/fixtures/import_teamocil/test5.py new file mode 100644 index 0000000000..c258ab1ded --- /dev/null +++ b/tests/fixtures/import_teamocil/test5.py @@ -0,0 +1,42 @@ +"""Teamocil data fixtures for import_teamocil tests, 5th test (v1.x format).""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/test5.yaml") + +teamocil_dict = { + "windows": [ + { + "name": "v1-string-panes", + "root": "~/Code/legacy", + "layout": "even-horizontal", + "panes": ["echo 'hello'", "echo 'world'", None], + }, + { + "name": "v1-commands-key", + "panes": [{"commands": ["pwd", "ls -la"]}], + }, + ], +} + +expected = { + "session_name": None, + "windows": [ + { + "window_name": "v1-string-panes", + "start_directory": "~/Code/legacy", + "layout": "even-horizontal", + "panes": [ + {"shell_command": ["echo 'hello'"]}, + {"shell_command": ["echo 'world'"]}, + {"shell_command": []}, + ], + }, + { + "window_name": "v1-commands-key", + "panes": [{"shell_command": ["pwd", "ls -la"]}], + }, + ], +} diff --git a/tests/fixtures/import_teamocil/test5.yaml b/tests/fixtures/import_teamocil/test5.yaml new file mode 100644 index 0000000000..d94a2251fa --- /dev/null +++ b/tests/fixtures/import_teamocil/test5.yaml @@ -0,0 +1,13 @@ +windows: +- name: v1-string-panes + root: ~/Code/legacy + layout: even-horizontal + panes: + - echo 'hello' + - echo 'world' + - +- name: v1-commands-key + panes: + - commands: + - pwd + - ls -la diff --git a/tests/fixtures/import_teamocil/test6.py b/tests/fixtures/import_teamocil/test6.py new file mode 100644 index 0000000000..07d957195d --- /dev/null +++ b/tests/fixtures/import_teamocil/test6.py @@ -0,0 +1,48 @@ +"""Teamocil data fixtures for import_teamocil tests, 6th test.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +teamocil_yaml = test_utils.read_workspace_file("import_teamocil/test6.yaml") + +teamocil_dict = { + "windows": [ + { + "name": "focused-window", + "root": "~/Code/app", + "layout": "main-vertical", + "focus": True, + "options": {"synchronize-panes": "on"}, + "panes": [ + {"cmd": "vim"}, + {"cmd": "rails s", "height": 30}, + ], + }, + { + "name": "background-window", + "panes": [{"cmd": "tail -f log/development.log"}], + }, + ], +} + +expected = { + "session_name": None, + "windows": [ + { + "window_name": "focused-window", + "start_directory": "~/Code/app", + "layout": "main-vertical", + "focus": True, + "options": {"synchronize-panes": "on"}, + "panes": [ + {"shell_command": "vim"}, + {"shell_command": "rails s"}, + ], + }, + { + "window_name": "background-window", + "panes": [{"shell_command": "tail -f log/development.log"}], + }, + ], +} diff --git a/tests/fixtures/import_teamocil/test6.yaml b/tests/fixtures/import_teamocil/test6.yaml new file mode 100644 index 0000000000..a682346232 --- /dev/null +++ b/tests/fixtures/import_teamocil/test6.yaml @@ -0,0 +1,14 @@ +windows: +- name: focused-window + root: ~/Code/app + layout: main-vertical + focus: true + options: + synchronize-panes: 'on' + panes: + - cmd: vim + - cmd: rails s + height: 30 +- name: background-window + panes: + - cmd: tail -f log/development.log diff --git a/tests/fixtures/import_tmuxinator/__init__.py b/tests/fixtures/import_tmuxinator/__init__.py index 84508e0405..b778967652 100644 --- a/tests/fixtures/import_tmuxinator/__init__.py +++ b/tests/fixtures/import_tmuxinator/__init__.py @@ -2,4 +2,4 @@ from __future__ import annotations -from . import test1, test2, test3 +from . import test1, test2, test3, test4, test5, test6 diff --git a/tests/fixtures/import_tmuxinator/test2.py b/tests/fixtures/import_tmuxinator/test2.py index 97d923a912..8767443b28 100644 --- a/tests/fixtures/import_tmuxinator/test2.py +++ b/tests/fixtures/import_tmuxinator/test2.py @@ -49,7 +49,8 @@ "socket_name": "foo", "config": "~/.tmux.mac.conf", "start_directory": "~/test", - "shell_command_before": ["sudo /etc/rc.d/mysqld start", "rbenv shell 2.0.0-p247"], + "on_project_start": "sudo /etc/rc.d/mysqld start", + "shell_command_before": ["rbenv shell 2.0.0-p247"], "windows": [ { "window_name": "editor", diff --git a/tests/fixtures/import_tmuxinator/test3.py b/tests/fixtures/import_tmuxinator/test3.py index 86ebd22c16..6a2a6af3e2 100644 --- a/tests/fixtures/import_tmuxinator/test3.py +++ b/tests/fixtures/import_tmuxinator/test3.py @@ -50,7 +50,7 @@ "socket_name": "foo", "start_directory": "~/test", "config": "~/.tmux.mac.conf", - "shell_command": "sudo /etc/rc.d/mysqld start", + "on_project_start": "sudo /etc/rc.d/mysqld start", "shell_command_before": ["rbenv shell 2.0.0-p247"], "windows": [ { diff --git a/tests/fixtures/import_tmuxinator/test4.py b/tests/fixtures/import_tmuxinator/test4.py new file mode 100644 index 0000000000..d318c6bf20 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test4.py @@ -0,0 +1,28 @@ +"""Tmuxinator data fixtures for import_tmuxinator tests, 4th dataset.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file("import_tmuxinator/test4.yaml") + +tmuxinator_dict = { + "name": "multi-flag", + "root": "~/projects/app", + "cli_args": "-f ~/.tmux.mac.conf -L mysocket", + "windows": [ + {"editor": "vim"}, + {"server": "rails s"}, + ], +} + +expected = { + "session_name": "multi-flag", + "start_directory": "~/projects/app", + "config": "~/.tmux.mac.conf", + "socket_name": "mysocket", + "windows": [ + {"window_name": "editor", "panes": ["vim"]}, + {"window_name": "server", "panes": ["rails s"]}, + ], +} diff --git a/tests/fixtures/import_tmuxinator/test4.yaml b/tests/fixtures/import_tmuxinator/test4.yaml new file mode 100644 index 0000000000..5004e1cb65 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test4.yaml @@ -0,0 +1,6 @@ +name: multi-flag +root: ~/projects/app +cli_args: -f ~/.tmux.mac.conf -L mysocket +windows: +- editor: vim +- server: rails s diff --git a/tests/fixtures/import_tmuxinator/test5.py b/tests/fixtures/import_tmuxinator/test5.py new file mode 100644 index 0000000000..194416dcfb --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test5.py @@ -0,0 +1,36 @@ +"""Tmuxinator data fixtures for import_tmuxinator tests, 5th dataset.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file("import_tmuxinator/test5.yaml") + +tmuxinator_dict = { + "name": "ruby-app", + "root": "~/projects/ruby-app", + "rvm": "2.1.1", + "pre": "./scripts/bootstrap.sh", + "pre_tab": "source .env", + "startup_window": "server", + "startup_pane": 0, + "windows": [ + {"editor": "vim"}, + {"server": "rails s"}, + ], +} + +expected = { + "session_name": "ruby-app", + "start_directory": "~/projects/ruby-app", + "on_project_start": "./scripts/bootstrap.sh", + "shell_command_before": ["rvm use 2.1.1"], + "windows": [ + {"window_name": "editor", "panes": ["vim"]}, + { + "window_name": "server", + "focus": True, + "panes": [{"shell_command": ["rails s"], "focus": True}], + }, + ], +} diff --git a/tests/fixtures/import_tmuxinator/test5.yaml b/tests/fixtures/import_tmuxinator/test5.yaml new file mode 100644 index 0000000000..eb4ad0b7c8 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test5.yaml @@ -0,0 +1,10 @@ +name: ruby-app +root: ~/projects/ruby-app +rvm: 2.1.1 +pre: ./scripts/bootstrap.sh +pre_tab: source .env +startup_window: server +startup_pane: 0 +windows: +- editor: vim +- server: rails s diff --git a/tests/fixtures/import_tmuxinator/test6.py b/tests/fixtures/import_tmuxinator/test6.py new file mode 100644 index 0000000000..e581a05586 --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test6.py @@ -0,0 +1,53 @@ +"""Tmuxinator data fixtures for import_tmuxinator tests, 6th dataset.""" + +from __future__ import annotations + +from tests.fixtures import utils as test_utils + +tmuxinator_yaml = test_utils.read_workspace_file("import_tmuxinator/test6.yaml") + +tmuxinator_dict = { + "name": "sync-test", + "root": "~/projects/sync", + "windows": [ + { + "synced": { + "synchronize": True, + "panes": ["echo 'pane1'", "echo 'pane2'"], + }, + }, + { + "synced-after": { + "synchronize": "after", + "panes": ["echo 'pane1'"], + }, + }, + { + "not-synced": { + "synchronize": False, + "panes": ["echo 'pane1'"], + }, + }, + ], +} + +expected = { + "session_name": "sync-test", + "start_directory": "~/projects/sync", + "windows": [ + { + "window_name": "synced", + "options": {"synchronize-panes": "on"}, + "panes": ["echo 'pane1'", "echo 'pane2'"], + }, + { + "window_name": "synced-after", + "options_after": {"synchronize-panes": "on"}, + "panes": ["echo 'pane1'"], + }, + { + "window_name": "not-synced", + "panes": ["echo 'pane1'"], + }, + ], +} diff --git a/tests/fixtures/import_tmuxinator/test6.yaml b/tests/fixtures/import_tmuxinator/test6.yaml new file mode 100644 index 0000000000..c4edc9e71c --- /dev/null +++ b/tests/fixtures/import_tmuxinator/test6.yaml @@ -0,0 +1,16 @@ +name: sync-test +root: ~/projects/sync +windows: +- synced: + synchronize: true + panes: + - echo 'pane1' + - echo 'pane2' +- synced-after: + synchronize: after + panes: + - echo 'pane1' +- not-synced: + synchronize: false + panes: + - echo 'pane1' diff --git a/tests/fixtures/workspace/builder/here_mode.yaml b/tests/fixtures/workspace/builder/here_mode.yaml new file mode 100644 index 0000000000..f31d5ca783 --- /dev/null +++ b/tests/fixtures/workspace/builder/here_mode.yaml @@ -0,0 +1,8 @@ +session_name: here-session +windows: + - window_name: reused + panes: + - echo reused + - window_name: new-win + panes: + - echo new diff --git a/tests/fixtures/workspace/builder/pane_titles.yaml b/tests/fixtures/workspace/builder/pane_titles.yaml new file mode 100644 index 0000000000..09a11cfe11 --- /dev/null +++ b/tests/fixtures/workspace/builder/pane_titles.yaml @@ -0,0 +1,15 @@ +session_name: test pane_titles +enable_pane_titles: true +pane_title_position: top +pane_title_format: "#{pane_index}: #{pane_title}" +windows: + - window_name: titled + panes: + - title: editor + shell_command: + - echo pane0 + - title: runner + shell_command: + - echo pane1 + - shell_command: + - echo pane2 diff --git a/tests/fixtures/workspace/builder/shell_command_after.yaml b/tests/fixtures/workspace/builder/shell_command_after.yaml new file mode 100644 index 0000000000..c63ce1da4b --- /dev/null +++ b/tests/fixtures/workspace/builder/shell_command_after.yaml @@ -0,0 +1,11 @@ +session_name: test shell_command_after +windows: + - window_name: with-after + panes: + - echo pane0 + - echo pane1 + shell_command_after: + - echo __AFTER__ + - window_name: without-after + panes: + - echo normal diff --git a/tests/fixtures/workspace/builder/synchronize.yaml b/tests/fixtures/workspace/builder/synchronize.yaml new file mode 100644 index 0000000000..45837332ea --- /dev/null +++ b/tests/fixtures/workspace/builder/synchronize.yaml @@ -0,0 +1,16 @@ +session_name: test synchronize +windows: + - window_name: synced-before + synchronize: before + panes: + - echo 0 + - echo 1 + - window_name: synced-after + synchronize: after + panes: + - echo 0 + - echo 1 + - window_name: not-synced + panes: + - echo 0 + - echo 1 diff --git a/tests/test_util.py b/tests/test_util.py index 098c8c212b..223824d9b8 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -12,7 +12,13 @@ from tmuxp import exc from tmuxp.exc import BeforeLoadScriptError, BeforeLoadScriptNotExists -from tmuxp.util import get_pane, get_session, oh_my_zsh_auto_title, run_before_script +from tmuxp.util import ( + get_pane, + get_session, + oh_my_zsh_auto_title, + run_before_script, + run_hook_commands, +) from .constants import FIXTURE_PATH @@ -156,12 +162,11 @@ def test_get_session_should_default_to_local_attached_session( assert get_session(server) == second_session -def test_get_session_should_return_first_session_if_no_active_session( +def test_get_session_falls_back_to_first_when_no_pane( server: Server, monkeypatch: pytest.MonkeyPatch, ) -> None: - """get_session() should return first session if no active session.""" - # Clear outer tmux environment to ensure no active pane interferes + """get_session() falls back to first session when TMUX_PANE is unset.""" monkeypatch.delenv("TMUX_PANE", raising=False) monkeypatch.delenv("TMUX", raising=False) @@ -171,6 +176,21 @@ def test_get_session_should_return_first_session_if_no_active_session( assert get_session(server) == first_session +def test_get_session_strict_raises_when_no_active_pane( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """get_session(require_pane_resolution=True) raises when TMUX_PANE unset.""" + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("TMUX", raising=False) + + server.new_session(session_name="myfirstsession") + server.new_session(session_name="mysecondsession") + + with pytest.raises(exc.SessionNotFound): + get_session(server, require_pane_resolution=True) + + def test_get_pane_logs_debug_on_failure( server: Server, monkeypatch: pytest.MonkeyPatch, @@ -234,3 +254,109 @@ def patched_exists(path: str) -> bool: warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] assert len(warning_records) >= 1 assert "DISABLE_AUTO_TITLE" in warning_records[0].message + + +class HookCommandFixture(t.NamedTuple): + """Test fixture for run_hook_commands.""" + + test_id: str + commands: str | list[str] + expect_runs: bool + + +HOOK_COMMAND_FIXTURES: list[HookCommandFixture] = [ + HookCommandFixture( + test_id="string-cmd", + commands="echo hello", + expect_runs=True, + ), + HookCommandFixture( + test_id="list-cmd", + commands=["echo a", "echo b"], + expect_runs=True, + ), + HookCommandFixture( + test_id="empty-string", + commands="", + expect_runs=False, + ), +] + + +@pytest.mark.parametrize( + list(HookCommandFixture._fields), + HOOK_COMMAND_FIXTURES, + ids=[f.test_id for f in HOOK_COMMAND_FIXTURES], +) +def test_run_hook_commands( + tmp_path: pathlib.Path, + test_id: str, + commands: str | list[str], + expect_runs: bool, +) -> None: + """run_hook_commands() executes shell commands without raising.""" + if expect_runs: + marker = tmp_path / "hook_ran" + if isinstance(commands, str): + commands = f"touch {marker}" + else: + commands = [f"touch {marker}"] + run_hook_commands(commands) + assert marker.exists() + else: + # Should not raise + run_hook_commands(commands) + + +def test_run_hook_commands_failure_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """run_hook_commands() logs WARNING on non-zero exit, does not raise.""" + with caplog.at_level(logging.WARNING, logger="tmuxp.util"): + run_hook_commands("exit 1") + + warning_records = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and hasattr(r, "tmux_exit_code") + ] + assert len(warning_records) >= 1 + assert warning_records[0].tmux_exit_code == 1 + + +def test_run_hook_commands_cwd( + tmp_path: pathlib.Path, +) -> None: + """run_hook_commands() respects cwd parameter.""" + run_hook_commands("touch marker_file", cwd=tmp_path) + assert (tmp_path / "marker_file").exists() + + +def test_run_hook_commands_missing_cwd_warns( + tmp_path: pathlib.Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """run_hook_commands() logs warning on nonexistent cwd instead of raising.""" + missing_dir = tmp_path / "does_not_exist" + with caplog.at_level(logging.WARNING, logger="tmuxp.util"): + run_hook_commands("echo hello", cwd=missing_dir) + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) >= 1 + assert "bad cwd or shell" in warning_records[0].message + + +def test_run_hook_commands_failure_logs_output_at_debug( + caplog: pytest.LogCaptureFixture, +) -> None: + """run_hook_commands() logs stdout/stderr at DEBUG when hook fails.""" + with caplog.at_level(logging.DEBUG, logger="tmuxp.util"): + run_hook_commands("echo HOOK_OUT && echo HOOK_ERR >&2 && exit 1") + + debug_records = [r for r in caplog.records if r.levelno == logging.DEBUG] + stdout_records = [r for r in debug_records if "hook stdout" in r.message] + stderr_records = [r for r in debug_records if "hook stderr" in r.message] + assert len(stdout_records) >= 1 + assert "HOOK_OUT" in stdout_records[0].message + assert len(stderr_records) >= 1 + assert "HOOK_ERR" in stderr_records[0].message diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index da95168f46..0f3d0b9b2c 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -359,6 +359,613 @@ def f() -> bool: ), "Synchronized command did not execute properly" +def test_synchronize( + session: Session, +) -> None: + """Test synchronize config key desugars to synchronize-panes option.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/synchronize.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + windows = session.windows + assert len(windows) == 3 + + synced_before = windows[0] + synced_after = windows[1] + not_synced = windows[2] + + assert synced_before.show_option("synchronize-panes") is True + assert synced_after.show_option("synchronize-panes") is True + assert not_synced.show_option("synchronize-panes") is not True + + +def test_shell_command_after( + session: Session, +) -> None: + """Test shell_command_after sends commands to all panes after window creation.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/shell_command_after.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + windows = session.windows + assert len(windows) == 2 + + after_window = windows[0] + no_after_window = windows[1] + + for pane in after_window.panes: + + def check(p: Pane = pane) -> bool: + return "__AFTER__" in "\n".join(p.capture_pane()) + + assert retry_until(check), f"Expected __AFTER__ in pane {pane.pane_id}" + + for pane in no_after_window.panes: + captured = "\n".join(pane.capture_pane()) + assert "__AFTER__" not in captured + + +def test_pane_titles( + session: Session, +) -> None: + """Test pane title config keys set pane-border-status and pane titles.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/pane_titles.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + window = session.windows[0] + assert window.show_option("pane-border-status") == "top" + assert window.show_option("pane-border-format") == "#{pane_index}: #{pane_title}" + + panes = window.panes + assert len(panes) == 3 + + def check_title(p: Pane, expected: str) -> bool: + p.refresh() + return p.pane_title == expected + + assert retry_until( + functools.partial(check_title, panes[0], "editor"), + ), f"Expected title 'editor', got '{panes[0].pane_title}'" + assert retry_until( + functools.partial(check_title, panes[1], "runner"), + ), f"Expected title 'runner', got '{panes[1].pane_title}'" + + +def test_here_mode( + session: Session, +) -> None: + """Test --here mode reuses current window and renames session.""" + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/here_mode.yaml"), + ) + workspace = loader.expand(workspace) + + # Capture original window ID to verify reuse + original_window = session.active_window + original_window_id = original_window.window_id + original_session_name = session.name + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + # Session should be renamed + session.refresh() + assert session.name == "here-session" + assert session.name != original_session_name + + windows = session.windows + assert len(windows) == 2 + + # First window should be the reused original window (same ID) + reused_window = windows[0] + assert reused_window.window_id == original_window_id + assert reused_window.name == "reused" + + # Second window should be newly created + new_window = windows[1] + assert new_window.name == "new-win" + assert new_window.window_id != original_window_id + + +def test_here_mode_start_directory_special_chars( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """Test --here mode with special characters in start_directory.""" + test_dir = tmp_path / "dir with 'quotes' & spaces" + test_dir.mkdir() + + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/here_mode.yaml"), + ) + workspace = loader.expand(workspace) + workspace["start_directory"] = str(test_dir) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + reused_window = session.windows[0] + pane = reused_window.active_pane + assert pane is not None + + expected_path = os.path.realpath(str(test_dir)) + + def check_path() -> bool: + return pane.pane_current_path == expected_path + + assert retry_until(check_path), ( + f"Expected {expected_path}, got {pane.pane_current_path}" + ) + + +def test_here_mode_cleans_existing_panes( + session: Session, +) -> None: + """Test --here mode removes extra panes before rebuilding.""" + # Start with a 2-pane window + original_window = session.active_window + original_window.split() + assert len(original_window.panes) == 2 + + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/here_mode.yaml"), + ) + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + session.refresh() + reused_window = session.windows[0] + # Config has 1 pane in first window — should be exactly 1, not 3 + assert len(reused_window.panes) == 1 + + +class HereDuplicateFixture(t.NamedTuple): + """Fixture for --here duplicate session name detection.""" + + test_id: str + config_session_name: str + expect_error: bool + + +HERE_DUPLICATE_FIXTURES: list[HereDuplicateFixture] = [ + HereDuplicateFixture( + test_id="same-name-no-rename", + config_session_name="__CURRENT__", + expect_error=False, + ), + HereDuplicateFixture( + test_id="different-name-no-conflict", + config_session_name="unique_target", + expect_error=False, + ), + HereDuplicateFixture( + test_id="name-conflict-with-existing", + config_session_name="__EXISTING__", + expect_error=True, + ), +] + + +@pytest.mark.parametrize( + list(HereDuplicateFixture._fields), + HERE_DUPLICATE_FIXTURES, + ids=[f.test_id for f in HERE_DUPLICATE_FIXTURES], +) +def test_here_mode_duplicate_session_name( + session: Session, + test_id: str, + config_session_name: str, + expect_error: bool, +) -> None: + """--here mode detects duplicate session names before renaming.""" + server = session.server + + # Create a second session to conflict with + existing = server.new_session(session_name="existing_blocker") + + # Resolve sentinel values + if config_session_name == "__CURRENT__": + target_name = session.name + elif config_session_name == "__EXISTING__": + target_name = existing.name + else: + target_name = config_session_name + + workspace = ConfigReader._from_file( + test_utils.get_workspace_file("workspace/builder/here_mode.yaml"), + ) + workspace = loader.expand(workspace) + workspace["session_name"] = target_name + + builder = WorkspaceBuilder(session_config=workspace, server=server) + + if expect_error: + with pytest.raises(exc.TmuxpException, match="session already exists"): + builder.build(session=session, here=True) + else: + builder.build(session=session, here=True) + + +def test_here_mode_provisions_environment( + session: Session, +) -> None: + """--here mode provisions the reused pane without mutating session env.""" + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "env-test", + "environment": {"TMUXP_HERE_TEST": "hello_here"}, + "panes": [ + { + "shell_command": [ + "printf '%s' \"${TMUXP_HERE_TEST-unset}\"", + ], + }, + ], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + env = session.show_environment() + assert env.get("TMUXP_HERE_TEST") is None + + pane = session.active_window.active_pane + assert pane is not None + + assert retry_until( + lambda: "hello_here" in "\n".join(pane.capture_pane()), + seconds=5, + ) + + +# --- respawn-pane provisioning tests (f5f490a8, 0504d1b4) --- + + +class HereRespawnFixture(t.NamedTuple): + """Fixture for --here respawn-pane provisioning scenarios.""" + + test_id: str + start_directory: bool + environment: dict[str, str] | None + window_shell: str | None + expect_respawn: bool + + +HERE_RESPAWN_FIXTURES: list[HereRespawnFixture] = [ + HereRespawnFixture( + test_id="dir-only", + start_directory=True, + environment=None, + window_shell=None, + expect_respawn=True, + ), + HereRespawnFixture( + test_id="env-only", + start_directory=False, + environment={"TMUXP_TEST_VAR": "respawn_val"}, + window_shell=None, + expect_respawn=True, + ), + HereRespawnFixture( + test_id="dir-and-env", + start_directory=True, + environment={"TMUXP_DIR_ENV": "combined"}, + window_shell=None, + expect_respawn=True, + ), + HereRespawnFixture( + test_id="nothing-to-provision", + start_directory=False, + environment=None, + window_shell=None, + expect_respawn=False, + ), +] + + +@pytest.mark.parametrize( + list(HereRespawnFixture._fields), + HERE_RESPAWN_FIXTURES, + ids=[f.test_id for f in HERE_RESPAWN_FIXTURES], +) +def test_here_mode_respawn_provisioning( + session: Session, + tmp_path: pathlib.Path, + test_id: str, + start_directory: bool, + environment: dict[str, str] | None, + window_shell: str | None, + expect_respawn: bool, +) -> None: + """--here mode uses respawn-pane for provisioning, not send_keys.""" + test_dir = tmp_path / "here_respawn" + test_dir.mkdir() + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "respawn-test", + "panes": [{"shell_command": []}], + }, + ], + } + if start_directory: + workspace["windows"][0]["start_directory"] = str(test_dir) + if environment: + workspace["windows"][0]["environment"] = environment + + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + original_pane = session.active_window.active_pane + assert original_pane is not None + original_pid = original_pane.pane_pid + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + pane = session.active_window.active_pane + assert pane is not None + + if expect_respawn: + # respawn-pane -k replaces the shell process, so PID changes + assert pane.pane_pid != original_pid, ( + f"Expected new PID after respawn, got same: {pane.pane_pid}" + ) + else: + # No provisioning needed — pane process should be unchanged + assert pane.pane_pid == original_pid + + if start_directory: + expected_path = os.path.realpath(str(test_dir)) + assert retry_until( + lambda: pane.pane_current_path == expected_path, + seconds=5, + ), f"Expected {expected_path}, got {pane.pane_current_path}" + + if environment: + env = session.show_environment() + for key in environment: + assert env.get(key) is None + + +def test_here_mode_does_not_leak_first_pane_environment( + session: Session, +) -> None: + """--here mode keeps first-pane environment out of later windows.""" + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "first-window", + "environment": { + "TMUXP_HERE_ALPHA": "alpha", + "TMUXP_HERE_BRAVO": "bravo", + "TMUXP_HERE_CHARLIE": "charlie", + }, + "panes": [ + { + "shell_command": [ + "printf '%s:%s:%s' " + '"$TMUXP_HERE_ALPHA" ' + '"$TMUXP_HERE_BRAVO" ' + '"$TMUXP_HERE_CHARLIE"', + ], + }, + ], + }, + { + "window_name": "later-window", + "panes": [ + { + "shell_command": [ + "printf '%s' \"${TMUXP_HERE_ALPHA-unset}\"", + ], + }, + ], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + env = session.show_environment() + assert env.get("TMUXP_HERE_ALPHA") is None + assert env.get("TMUXP_HERE_BRAVO") is None + assert env.get("TMUXP_HERE_CHARLIE") is None + + first_window = next( + window for window in session.windows if window.name == "first-window" + ) + later_window = next( + window for window in session.windows if window.name == "later-window" + ) + first_pane = first_window.active_pane + later_pane = later_window.active_pane + assert first_pane is not None + assert later_pane is not None + + assert retry_until( + lambda: "alpha:bravo:charlie" in "\n".join(first_pane.capture_pane()), + seconds=5, + ) + assert retry_until( + lambda: "unset" in "\n".join(later_pane.capture_pane()), + seconds=5, + ) + + +class ReusedSessionMetadataFixture(t.NamedTuple): + """Fixture for reused-session lifecycle metadata scenarios.""" + + test_id: str + build_kwargs: dict[str, bool] + + +REUSED_SESSION_METADATA_FIXTURES: list[ReusedSessionMetadataFixture] = [ + ReusedSessionMetadataFixture( + test_id="append", + build_kwargs={"append": True}, + ), + ReusedSessionMetadataFixture( + test_id="here", + build_kwargs={"here": True}, + ), +] + + +@pytest.mark.parametrize( + list(ReusedSessionMetadataFixture._fields), + REUSED_SESSION_METADATA_FIXTURES, + ids=[fixture.test_id for fixture in REUSED_SESSION_METADATA_FIXTURES], +) +def test_reused_session_keeps_existing_lifecycle_metadata( + session: Session, + tmp_path: pathlib.Path, + test_id: str, + build_kwargs: dict[str, bool], +) -> None: + """Append and --here preserve pre-existing session hook and env metadata.""" + original_hook = "run-shell 'printf %s original-exit >/dev/null'" + session.set_hook("client-detached", original_hook) + session.set_environment("TMUXP_ON_PROJECT_STOP", "original stop") + session.set_environment("TMUXP_START_DIRECTORY", "/original/start") + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "start_directory": str(tmp_path), + "on_project_exit": "printf '%s' new-exit >/dev/null", + "on_project_stop": "printf '%s' new-stop >/dev/null", + "windows": [ + {"window_name": f"{test_id}-window", "panes": [{"shell_command": []}]} + ], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, **build_kwargs) + + hooks = [str(value) for value in session.show_hooks().values()] + assert any("original-exit" in value for value in hooks) + assert all("new-exit" not in value for value in hooks) + assert session.getenv("TMUXP_ON_PROJECT_STOP") == "original stop" + assert session.getenv("TMUXP_START_DIRECTORY") == "/original/start" + + +def test_here_mode_respawn_warns_on_running_processes( + session: Session, + caplog: pytest.LogCaptureFixture, + tmp_path: pathlib.Path, +) -> None: + """--here mode warns when respawn-pane will kill child processes.""" + # Start a background process in the active pane so pgrep finds children + pane = session.active_window.active_pane + assert pane is not None + pane.send_keys("sleep 300 &", enter=True) + + # Give the shell time to fork the background job + assert ( + retry_until( + lambda: ( + "sleep" in (pane.pane_current_command or "") + or retry_until( + lambda: len(pane.capture_pane()) > 1, + seconds=2, + ) + ), + seconds=3, + ) + or True + ) # Best-effort; pgrep check below is the real assertion + + test_dir = tmp_path / "warn_test" + test_dir.mkdir() + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "warn-test", + "start_directory": str(test_dir), + "panes": [{"shell_command": []}], + }, + ], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.builder"): + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + warning_records = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and "kill running processes" in r.message + ] + # pgrep should find the sleep background job and emit a warning + assert len(warning_records) >= 1 + + +def test_here_mode_no_warning_when_pane_idle( + session: Session, + caplog: pytest.LogCaptureFixture, + tmp_path: pathlib.Path, +) -> None: + """--here mode does not warn when pane has no child processes.""" + test_dir = tmp_path / "idle_test" + test_dir.mkdir() + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "idle-test", + "start_directory": str(test_dir), + "panes": [{"shell_command": []}], + }, + ], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.builder"): + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session, here=True) + + warning_records = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and "kill running processes" in r.message + ] + assert len(warning_records) == 0 + + def test_window_shell( session: Session, ) -> None: @@ -714,18 +1321,17 @@ def test_before_script_throw_error_if_retcode_error( builder = WorkspaceBuilder(session_config=workspace, server=server) - with temp_session(server) as sess: - session_name = sess.name - assert session_name is not None + session_name = workspace["session_name"] + assert isinstance(session_name, str) - with ( - caplog.at_level(logging.ERROR, logger="tmuxp.workspace.builder"), - pytest.raises(exc.BeforeLoadScriptError), - ): - builder.build(session=sess) + with ( + caplog.at_level(logging.ERROR, logger="tmuxp.workspace.builder"), + pytest.raises(exc.BeforeLoadScriptError), + ): + builder.build() - result = server.has_session(session_name) - assert not result, "Kills session if before_script exits with errcode" + result = server.has_session(session_name) + assert not result, "Kills created session if before_script exits with errcode" error_records = [r for r in caplog.records if r.levelno == logging.ERROR] assert len(error_records) >= 1 @@ -749,17 +1355,124 @@ def test_before_script_throw_error_if_file_not_exists( builder = WorkspaceBuilder(session_config=workspace, server=server) - with temp_session(server) as session: - session_name = session.name + session_name = workspace["session_name"] + assert isinstance(session_name, str) + with pytest.raises((exc.BeforeLoadScriptNotExists, OSError)): + builder.build() + result = server.has_session(session_name) + assert not result, "Kills created session if before_script doesn't exist" + + +class ReusedSessionBeforeScriptFailureFixture(t.NamedTuple): + """Fixture for before_script failures on reused sessions.""" + + test_id: str + fixture_name: str + build_kwargs: dict[str, bool] + expected_exception: type[BaseException] | tuple[type[BaseException], ...] + + +REUSED_SESSION_BEFORE_SCRIPT_FAILURE_FIXTURES: list[ + ReusedSessionBeforeScriptFailureFixture +] = [ + ReusedSessionBeforeScriptFailureFixture( + test_id="append-retcode-error-keeps-session", + fixture_name="workspace/builder/config_script_fails.yaml", + build_kwargs={"append": True}, + expected_exception=exc.BeforeLoadScriptError, + ), + ReusedSessionBeforeScriptFailureFixture( + test_id="here-retcode-error-keeps-session", + fixture_name="workspace/builder/config_script_fails.yaml", + build_kwargs={"here": True}, + expected_exception=exc.BeforeLoadScriptError, + ), + ReusedSessionBeforeScriptFailureFixture( + test_id="here-missing-script-keeps-session", + fixture_name="workspace/builder/config_script_not_exists.yaml", + build_kwargs={"here": True}, + expected_exception=(exc.BeforeLoadScriptNotExists, OSError), + ), +] + + +@pytest.mark.parametrize( + list(ReusedSessionBeforeScriptFailureFixture._fields), + REUSED_SESSION_BEFORE_SCRIPT_FAILURE_FIXTURES, + ids=[f.test_id for f in REUSED_SESSION_BEFORE_SCRIPT_FAILURE_FIXTURES], +) +def test_before_script_failure_on_reused_session_keeps_session( + server: Server, + test_id: str, + fixture_name: str, + build_kwargs: dict[str, bool], + expected_exception: type[BaseException] | tuple[type[BaseException], ...], +) -> None: + """before_script failures do not kill reused sessions for append or here.""" + fixture_template = test_utils.read_workspace_file(fixture_name) + workspace_yaml = fixture_template.format( + script_failed=FIXTURE_PATH / "script_failed.sh", + script_not_exists=FIXTURE_PATH / "script_not_exists.sh", + ) + workspace = ConfigReader._load(fmt="yaml", content=workspace_yaml) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + + with temp_session(server) as current_session: + session_name = current_session.name assert session_name is not None - temp_session_exists = server.has_session(session_name) - assert temp_session_exists - with pytest.raises((exc.BeforeLoadScriptNotExists, OSError)) as excinfo: - builder.build(session=session) - excinfo.match(r"No such file or directory") - result = server.has_session(session_name) - assert not result, "Kills session if before_script doesn't exist" + workspace["session_name"] = session_name + builder.session_config = workspace + + with pytest.raises(expected_exception): + builder.build(session=current_session, **build_kwargs) + + assert server.has_session(session_name), test_id + + +def test_here_mode_duplicate_session_name_fails_before_startup_hooks( + server: Server, + tmp_path: pathlib.Path, +) -> None: + """--here rename conflicts abort before plugins or before_script run.""" + before_script_marker = tmp_path / "before-script-ran" + plugin_marker = tmp_path / "plugin-ran" + + workspace: dict[str, t.Any] = { + "session_name": "existing-blocker", + "before_script": f"touch {before_script_marker}", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + class PluginProbe: + """Minimal plugin stub for startup-hook ordering tests.""" + + def before_workspace_builder(self, session: Session) -> None: + plugin_marker.touch() + + builder = WorkspaceBuilder( + session_config=workspace, + server=server, + plugins=[PluginProbe()], + ) + + with ( + temp_session(server) as current_session, + temp_session(server) as existing_session, + ): + existing_session.rename_session("existing-blocker") + with pytest.raises(exc.TmuxpException, match="session already exists"): + builder.build(session=current_session, here=True) + + assert current_session.name is not None + assert not before_script_marker.exists() + assert not plugin_marker.exists() + assert server.has_session(current_session.name) def test_before_script_true_if_test_passes( @@ -1768,3 +2481,264 @@ def test_builder_logs_window_and_pane_creation( assert len(cmd_logs) >= 1 builder.session.kill() + + +def test_on_project_exit_sets_hook( + server: Server, +) -> None: + """on_project_exit sets tmux client-detached hook on the session.""" + workspace: dict[str, t.Any] = { + "session_name": "hook-exit-test", + "on_project_exit": "echo goodbye", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + hooks = builder.session.show_hooks() + hook_keys = list(hooks.keys()) + assert any("client-detached" in k for k in hook_keys) + + builder.session.kill() + + +def test_on_project_exit_hook_guards_last_client_detach( + server: Server, +) -> None: + """on_project_exit hook only runs when the last client detaches.""" + workspace: dict[str, t.Any] = { + "session_name": "hook-exit-guard-test", + "on_project_exit": "echo goodbye", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + hooks = builder.session.show_hooks() + hook_values = [str(v) for v in hooks.values()] + matched = [v for v in hook_values if "#{session_attached}" in v] + assert len(matched) >= 1 + + builder.session.kill() + + +def test_on_project_exit_sets_hook_list( + server: Server, +) -> None: + """on_project_exit joins list commands and sets tmux hook.""" + workspace: dict[str, t.Any] = { + "session_name": "hook-exit-list-test", + "on_project_exit": ["echo a", "echo b"], + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + hooks = builder.session.show_hooks() + hook_keys = list(hooks.keys()) + assert any("client-detached" in k for k in hook_keys) + + builder.session.kill() + + +def test_on_project_exit_hook_includes_cwd( + server: Server, +) -> None: + """on_project_exit hook includes cd to start_directory.""" + workspace: dict[str, t.Any] = { + "session_name": "hook-exit-cwd-test", + "start_directory": "/tmp", + "on_project_exit": "echo goodbye", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + hooks = builder.session.show_hooks() + hook_values = list(hooks.values()) + matched = [v for v in hook_values if "cd" in str(v) and "/tmp" in str(v)] + assert len(matched) >= 1 + + builder.session.kill() + + +class OnProjectExitCwdSpecialFixture(t.NamedTuple): + """Test fixture for on_project_exit hook with special cwd characters.""" + + test_id: str + dir_name: str + expected_substring: str + + +ON_PROJECT_EXIT_CWD_SPECIAL_FIXTURES: list[OnProjectExitCwdSpecialFixture] = [ + OnProjectExitCwdSpecialFixture( + test_id="spaces_in_path", + dir_name="my project dir", + expected_substring="my project dir", + ), + OnProjectExitCwdSpecialFixture( + test_id="single_quote_in_path", + dir_name="it's a project", + expected_substring="a project", + ), +] + + +@pytest.mark.parametrize( + list(OnProjectExitCwdSpecialFixture._fields), + ON_PROJECT_EXIT_CWD_SPECIAL_FIXTURES, + ids=[f.test_id for f in ON_PROJECT_EXIT_CWD_SPECIAL_FIXTURES], +) +def test_on_project_exit_hook_cwd_special_chars( + server: Server, + tmp_path: pathlib.Path, + test_id: str, + dir_name: str, + expected_substring: str, +) -> None: + """on_project_exit hook correctly quotes start_directory with special chars.""" + special_dir = tmp_path / dir_name + special_dir.mkdir() + workspace: dict[str, t.Any] = { + "session_name": f"hook-exit-{test_id}", + "start_directory": str(special_dir), + "on_project_exit": "echo goodbye", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + hooks = builder.session.show_hooks() + hook_values = [str(v) for v in hooks.values()] + matched = [v for v in hook_values if expected_substring in v] + assert len(matched) >= 1, ( + f"Expected {expected_substring!r} in hook values, got {hook_values}" + ) + + builder.session.kill() + + +def test_on_project_stop_sets_environment( + server: Server, +) -> None: + """on_project_stop stores commands in session environment.""" + workspace: dict[str, t.Any] = { + "session_name": "hook-stop-env-test", + "on_project_stop": "docker compose down", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + stop_cmd = builder.session.getenv("TMUXP_ON_PROJECT_STOP") + assert stop_cmd == "docker compose down" + + builder.session.kill() + + +def test_on_project_stop_sets_start_directory_env( + server: Server, + tmp_path: pathlib.Path, +) -> None: + """build() stores start_directory in session environment.""" + workspace: dict[str, t.Any] = { + "session_name": "hook-startdir-env-test", + "start_directory": str(tmp_path), + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + start_dir = builder.session.getenv("TMUXP_START_DIRECTORY") + assert start_dir == str(tmp_path) + + builder.session.kill() + + +def test_clear_sends_clear_to_panes( + session: Session, +) -> None: + """clear: true sends clear command to all panes after window creation.""" + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "clear-test", + "clear": True, + "panes": [ + {"shell_command": ["echo BEFORE_CLEAR"]}, + {"shell_command": ["echo BEFORE_CLEAR"]}, + ], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + window = session.windows[0] + assert len(window.panes) == 2 + + for pane in window.panes: + + def check(p: Pane = pane) -> bool: + captured = "\n".join(p.capture_pane()).strip() + return "BEFORE_CLEAR" not in captured + + assert retry_until(check, raises=False), ( + f"Expected BEFORE_CLEAR to be cleared from pane {pane.pane_id}" + ) + + +def test_clear_false_does_not_clear( + session: Session, +) -> None: + """clear: false does not clear pane content.""" + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "no-clear-test", + "clear": False, + "panes": [ + {"shell_command": ["echo SHOULD_REMAIN"]}, + ], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + window = session.windows[0] + pane = window.panes[0] + + def check(p: Pane = pane) -> bool: + return "SHOULD_REMAIN" in "\n".join(p.capture_pane()) + + assert retry_until(check), ( + f"Expected SHOULD_REMAIN to remain in pane {pane.pane_id}" + ) diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index fc6d5ccd5b..5076128111 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -333,6 +333,238 @@ def test_validate_plugins() -> None: assert excinfo.match("only supports list type") +def test_expand_synchronize() -> None: + """Test that expand() desugars synchronize into options/options_after.""" + workspace = { + "session_name": "test", + "windows": [ + { + "window_name": "before", + "synchronize": True, + "panes": [{"shell_command": ["echo hi"]}], + }, + { + "window_name": "after", + "synchronize": "after", + "panes": [{"shell_command": ["echo hi"]}], + }, + { + "window_name": "false", + "synchronize": False, + "panes": [{"shell_command": ["echo hi"]}], + }, + ], + } + result = loader.expand(workspace) + + # synchronize: True → options with synchronize-panes on, key removed + assert "synchronize" not in result["windows"][0] + assert result["windows"][0]["options"]["synchronize-panes"] == "on" + + # synchronize: "after" → options_after with synchronize-panes on, key removed + assert "synchronize" not in result["windows"][1] + assert result["windows"][1]["options_after"]["synchronize-panes"] == "on" + + # synchronize: False → no options added, key removed + assert "synchronize" not in result["windows"][2] + assert "options" not in result["windows"][2] or "synchronize-panes" not in result[ + "windows" + ][2].get("options", {}) + + +def test_expand_shell_command_after() -> None: + """Test that expand() normalizes shell_command_after into expanded form.""" + workspace = { + "session_name": "test", + "windows": [ + { + "window_name": "with-after", + "shell_command_after": ["echo done", "echo bye"], + "panes": [{"shell_command": ["echo hi"]}], + }, + { + "window_name": "string-after", + "shell_command_after": "echo single", + "panes": [{"shell_command": ["echo hi"]}], + }, + { + "window_name": "no-after", + "panes": [{"shell_command": ["echo hi"]}], + }, + ], + } + result = loader.expand(workspace) + + # List form: normalized to {shell_command: [{cmd: "..."}, ...]} + after = result["windows"][0]["shell_command_after"] + assert isinstance(after, dict) + assert len(after["shell_command"]) == 2 + assert after["shell_command"][0]["cmd"] == "echo done" + assert after["shell_command"][1]["cmd"] == "echo bye" + + # String form: normalized the same way + after_str = result["windows"][1]["shell_command_after"] + assert isinstance(after_str, dict) + assert len(after_str["shell_command"]) == 1 + assert after_str["shell_command"][0]["cmd"] == "echo single" + + # No shell_command_after: key absent + assert "shell_command_after" not in result["windows"][2] + + +def test_expand_pane_titles() -> None: + """Test that expand() desugars pane title session keys into window options.""" + workspace = { + "session_name": "test", + "enable_pane_titles": True, + "pane_title_position": "bottom", + "pane_title_format": " #T ", + "windows": [ + { + "window_name": "w1", + "panes": [ + {"title": "editor", "shell_command": ["echo hi"]}, + {"shell_command": ["echo bye"]}, + ], + }, + { + "window_name": "w2", + "options": {"pane-border-status": "off"}, + "panes": [{"shell_command": ["echo hi"]}], + }, + ], + } + result = loader.expand(workspace) + + # Session-level keys removed + assert "enable_pane_titles" not in result + assert "pane_title_position" not in result + assert "pane_title_format" not in result + + # Window 1: options populated from session-level config + assert result["windows"][0]["options"]["pane-border-status"] == "bottom" + assert result["windows"][0]["options"]["pane-border-format"] == " #T " + + # Window 2: per-window override preserved (setdefault doesn't overwrite) + assert result["windows"][1]["options"]["pane-border-status"] == "off" + assert result["windows"][1]["options"]["pane-border-format"] == " #T " + + # Pane title key preserved for builder + assert result["windows"][0]["panes"][0]["title"] == "editor" + assert "title" not in result["windows"][0]["panes"][1] + + +def test_expand_pane_titles_disabled() -> None: + """Test that expand() removes pane title keys when disabled.""" + workspace = { + "session_name": "test", + "enable_pane_titles": False, + "pane_title_position": "top", + "windows": [ + { + "window_name": "w1", + "panes": [{"shell_command": ["echo hi"]}], + }, + ], + } + result = loader.expand(workspace) + + assert "enable_pane_titles" not in result + assert "pane_title_position" not in result + assert "options" not in result["windows"][0] or "pane-border-status" not in result[ + "windows" + ][0].get("options", {}) + + +def test_expand_pane_titles_defaults() -> None: + """Test that expand() uses default position and format when not specified.""" + workspace = { + "session_name": "test", + "enable_pane_titles": True, + "windows": [ + { + "window_name": "w1", + "panes": [{"shell_command": ["echo hi"]}], + }, + ], + } + result = loader.expand(workspace) + + assert result["windows"][0]["options"]["pane-border-status"] == "top" + assert ( + result["windows"][0]["options"]["pane-border-format"] + == "#{pane_index}: #{pane_title}" + ) + + +class PaneTitlePositionFixture(t.NamedTuple): + """Fixture for pane_title_position validation.""" + + test_id: str + position: str + expected_position: str + expect_warning: bool + + +PANE_TITLE_POSITION_FIXTURES: list[PaneTitlePositionFixture] = [ + PaneTitlePositionFixture( + test_id="top", + position="top", + expected_position="top", + expect_warning=False, + ), + PaneTitlePositionFixture( + test_id="bottom", + position="bottom", + expected_position="bottom", + expect_warning=False, + ), + PaneTitlePositionFixture( + test_id="off", + position="off", + expected_position="off", + expect_warning=False, + ), + PaneTitlePositionFixture( + test_id="invalid-falls-back-to-top", + position="invalid_value", + expected_position="top", + expect_warning=True, + ), +] + + +@pytest.mark.parametrize( + list(PaneTitlePositionFixture._fields), + PANE_TITLE_POSITION_FIXTURES, + ids=[f.test_id for f in PANE_TITLE_POSITION_FIXTURES], +) +def test_expand_pane_title_position_validation( + caplog: pytest.LogCaptureFixture, + test_id: str, + position: str, + expected_position: str, + expect_warning: bool, +) -> None: + """Invalid pane_title_position values default to 'top' with a warning.""" + workspace: dict[str, t.Any] = { + "session_name": "pos-test", + "enable_pane_titles": True, + "pane_title_position": position, + "windows": [{"window_name": "main", "panes": [{"shell_command": "echo hi"}]}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.loader"): + result = loader.expand(workspace) + + assert result["windows"][0]["options"]["pane-border-status"] == expected_position + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + if expect_warning: + assert any("pane_title_position" in r.message for r in warning_records) + else: + assert not any("pane_title_position" in r.message for r in warning_records) + + def test_expand_logs_debug( tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, @@ -377,3 +609,146 @@ def test_validate_schema_logs_debug( records = [r for r in caplog.records if r.msg == "validating workspace schema"] assert len(records) >= 1 assert getattr(records[0], "tmux_session", None) == "test_validate" + + +def test_expand_lifecycle_hooks_string( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """expand() expands shell variables in lifecycle hook string values.""" + monkeypatch.setenv("MY_HOOK_CMD", "docker compose up") + + workspace: dict[str, t.Any] = { + "session_name": "test", + "on_project_start": "$MY_HOOK_CMD", + "on_project_stop": "$MY_HOOK_CMD down", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + result = loader.expand(workspace) + + assert result["on_project_start"] == "docker compose up" + assert result["on_project_stop"] == "docker compose up down" + + +def test_expand_lifecycle_hooks_list( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """expand() expands shell variables in lifecycle hook list values.""" + monkeypatch.setenv("MY_CMD", "echo hello") + + workspace: dict[str, t.Any] = { + "session_name": "test", + "on_project_start": ["$MY_CMD", "echo world"], + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + result = loader.expand(workspace) + + assert result["on_project_start"] == ["echo hello", "echo world"] + + +def test_expand_lifecycle_hooks_tilde() -> None: + """expand() expands ~ in lifecycle hook values.""" + workspace: dict[str, t.Any] = { + "session_name": "test", + "on_project_exit": "~/scripts/cleanup.sh", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + result = loader.expand(workspace) + + assert "~" not in result["on_project_exit"] + assert result["on_project_exit"].endswith("/scripts/cleanup.sh") + + +class RenderTemplateFixture(t.NamedTuple): + """Test fixture for render_template tests.""" + + test_id: str + content: str + context: dict[str, str] + expected: str + + +RENDER_TEMPLATE_FIXTURES: list[RenderTemplateFixture] = [ + RenderTemplateFixture( + test_id="simple-replacement", + content="root: {{ project }}", + context={"project": "myapp"}, + expected="root: myapp", + ), + RenderTemplateFixture( + test_id="multiple-vars", + content="name: {{ name }}\nroot: {{ root }}", + context={"name": "dev", "root": "/tmp/dev"}, + expected="name: dev\nroot: /tmp/dev", + ), + RenderTemplateFixture( + test_id="unknown-var-unchanged", + content="root: {{ unknown }}", + context={"project": "myapp"}, + expected="root: {{ unknown }}", + ), + RenderTemplateFixture( + test_id="no-templates", + content="root: /tmp/myapp", + context={"project": "myapp"}, + expected="root: /tmp/myapp", + ), + RenderTemplateFixture( + test_id="env-var-not-affected", + content="root: $HOME/{{ project }}", + context={"project": "myapp"}, + expected="root: $HOME/myapp", + ), + RenderTemplateFixture( + test_id="whitespace-in-braces", + content="root: {{project}}", + context={"project": "myapp"}, + expected="root: myapp", + ), + RenderTemplateFixture( + test_id="extra-whitespace-in-braces", + content="root: {{ project }}", + context={"project": "myapp"}, + expected="root: myapp", + ), + RenderTemplateFixture( + test_id="empty-context", + content="root: {{ project }}", + context={}, + expected="root: {{ project }}", + ), + RenderTemplateFixture( + test_id="same-var-multiple-times", + content="a: {{ x }}\nb: {{ x }}", + context={"x": "val"}, + expected="a: val\nb: val", + ), +] + + +@pytest.mark.parametrize( + list(RenderTemplateFixture._fields), + RENDER_TEMPLATE_FIXTURES, + ids=[f.test_id for f in RENDER_TEMPLATE_FIXTURES], +) +def test_render_template( + test_id: str, + content: str, + context: dict[str, str], + expected: str, +) -> None: + """render_template() replaces {{ var }} expressions with context values.""" + result = loader.render_template(content, context) + assert result == expected + + +def test_render_template_rejects_yaml_unsafe_values() -> None: + """render_template() raises ValueError for YAML-unsafe --set values.""" + with pytest.raises(ValueError, match="YAML-unsafe"): + loader.render_template("cmd: {{ val }}", {"val": "foo: bar"}) + + with pytest.raises(ValueError, match="YAML-unsafe"): + loader.render_template("cmd: {{ val }}", {"val": "line1\nline2"}) + + # Safe values should work fine + result = loader.render_template("cmd: {{ val }}", {"val": "hello-world"}) + assert result == "cmd: hello-world" diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index 0ea457e7c6..547e3207c4 100644 --- a/tests/workspace/test_import_teamocil.py +++ b/tests/workspace/test_import_teamocil.py @@ -46,6 +46,18 @@ class TeamocilConfigTestFixture(t.NamedTuple): teamocil_dict=fixtures.test4.teamocil_dict, tmuxp_dict=fixtures.test4.expected, ), + TeamocilConfigTestFixture( + test_id="v1x_format", + teamocil_yaml=fixtures.test5.teamocil_yaml, + teamocil_dict=fixtures.test5.teamocil_dict, + tmuxp_dict=fixtures.test5.expected, + ), + TeamocilConfigTestFixture( + test_id="focus_options_height", + teamocil_yaml=fixtures.test6.teamocil_yaml, + teamocil_dict=fixtures.test6.teamocil_dict, + tmuxp_dict=fixtures.test6.expected, + ), ] @@ -157,3 +169,48 @@ def test_import_teamocil_logs_debug( records = [r for r in caplog.records if r.msg == "importing teamocil workspace"] assert len(records) >= 1 assert getattr(records[0], "tmux_session", None) == "test" + + +def test_warns_on_width_height_drop( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that importing teamocil config with width/height logs warnings.""" + teamocil_dict = { + "windows": [ + { + "name": "win-with-height", + "panes": [{"cmd": "vim", "height": 30}], + }, + ], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_teamocil(teamocil_dict) + + height_records = [ + r for r in caplog.records if hasattr(r, "tmux_window") and "height" in r.message + ] + assert len(height_records) == 1 + assert height_records[0].tmux_window == "win-with-height" + + +def test_warns_on_with_env_var_and_cmd_separator( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that importing teamocil config with unsupported keys logs warnings.""" + teamocil_dict = { + "windows": [ + { + "name": "custom-opts", + "with_env_var": True, + "cmd_separator": " && ", + "panes": [{"cmd": "echo hello"}], + }, + ], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_teamocil(teamocil_dict) + + env_var_records = [r for r in caplog.records if "with_env_var" in r.message] + cmd_sep_records = [r for r in caplog.records if "cmd_separator" in r.message] + assert len(env_var_records) == 1 + assert len(cmd_sep_records) == 1 diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 457605f2ab..473420a494 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -40,6 +40,24 @@ class TmuxinatorConfigTestFixture(t.NamedTuple): tmuxinator_dict=fixtures.test3.tmuxinator_dict, tmuxp_dict=fixtures.test3.expected, ), + TmuxinatorConfigTestFixture( + test_id="multi_flag_cli_args", + tmuxinator_yaml=fixtures.test4.tmuxinator_yaml, + tmuxinator_dict=fixtures.test4.tmuxinator_dict, + tmuxp_dict=fixtures.test4.expected, + ), + TmuxinatorConfigTestFixture( + test_id="rvm_pre_tab_startup", + tmuxinator_yaml=fixtures.test5.tmuxinator_yaml, + tmuxinator_dict=fixtures.test5.tmuxinator_dict, + tmuxp_dict=fixtures.test5.expected, + ), + TmuxinatorConfigTestFixture( + test_id="synchronize", + tmuxinator_yaml=fixtures.test6.tmuxinator_yaml, + tmuxinator_dict=fixtures.test6.tmuxinator_dict, + tmuxp_dict=fixtures.test6.expected, + ), ] @@ -76,3 +94,794 @@ def test_import_tmuxinator_logs_debug( records = [r for r in caplog.records if r.msg == "importing tmuxinator workspace"] assert len(records) >= 1 assert getattr(records[0], "tmux_session", None) == "test" + + +def test_startup_window_sets_focus_by_name() -> None: + """Startup_window sets focus on the matching window by name.""" + workspace = { + "name": "test", + "startup_window": "logs", + "windows": [ + {"editor": "vim"}, + {"logs": "tail -f log/dev.log"}, + ], + } + result = importers.import_tmuxinator(workspace) + + assert result["windows"][0].get("focus") is None + assert result["windows"][1]["focus"] is True + + +def test_startup_window_sets_focus_by_index() -> None: + """Startup_window resolves numeric values with tmux base-index semantics.""" + workspace = { + "name": "test", + "startup_window": 1, + "windows": [ + {"editor": "vim"}, + {"server": "rails s"}, + ], + } + result = importers.import_tmuxinator(workspace, base_index=1) + + assert result["windows"][0]["focus"] is True + assert result["windows"][1].get("focus") is None + + +def test_startup_pane_sets_focus_on_pane() -> None: + """Startup_pane resolves numeric values with tmux pane-base-index.""" + workspace = { + "name": "test", + "startup_window": "editor", + "startup_pane": 1, + "windows": [ + { + "editor": { + "panes": ["vim", "guard", "top"], + }, + }, + ], + } + result = importers.import_tmuxinator(workspace, pane_base_index=1) + + assert result["windows"][0]["focus"] is True + panes = result["windows"][0]["panes"] + assert panes[0] == {"shell_command": ["vim"], "focus": True} + assert panes[1] == "guard" + assert panes[2] == "top" + + +def test_startup_pane_without_startup_window() -> None: + """Startup_pane targets the first window when no startup_window is set.""" + workspace = { + "name": "test", + "startup_pane": 1, + "windows": [ + { + "editor": { + "panes": ["vim", "guard"], + }, + }, + ], + } + result = importers.import_tmuxinator(workspace, pane_base_index=1) + + panes = result["windows"][0]["panes"] + assert panes[0] == {"shell_command": ["vim"], "focus": True} + assert panes[1] == "guard" + + +def test_startup_window_warns_on_no_match( + caplog: pytest.LogCaptureFixture, +) -> None: + """Startup_window logs WARNING when no matching window is found.""" + workspace = { + "name": "test", + "startup_window": "nonexistent", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + + warn_records = [r for r in caplog.records if "startup_window" in r.message] + assert len(warn_records) == 1 + + +class YamlEdgeCaseFixture(t.NamedTuple): + """Test fixture for YAML edge case tests.""" + + test_id: str + workspace: dict[str, t.Any] + expected_window_names: list[str | None] + + +YAML_EDGE_CASE_FIXTURES: list[YamlEdgeCaseFixture] = [ + YamlEdgeCaseFixture( + test_id="numeric-window-name", + workspace={ + "name": "test", + "windows": [{222: "echo hello"}], + }, + expected_window_names=["222"], + ), + YamlEdgeCaseFixture( + test_id="boolean-true-window-name", + workspace={ + "name": "test", + "windows": [{True: "echo true"}], + }, + expected_window_names=["True"], + ), + YamlEdgeCaseFixture( + test_id="boolean-false-window-name", + workspace={ + "name": "test", + "windows": [{False: "echo false"}], + }, + expected_window_names=["False"], + ), + YamlEdgeCaseFixture( + test_id="float-window-name", + workspace={ + "name": "test", + "windows": [{222.3: "echo float"}], + }, + expected_window_names=["222.3"], + ), + YamlEdgeCaseFixture( + test_id="none-window-name", + workspace={ + "name": "test", + "windows": [{None: "echo none"}], + }, + expected_window_names=[None], + ), + YamlEdgeCaseFixture( + test_id="emoji-window-name", + workspace={ + "name": "test", + "windows": [{"🍩": "echo donut"}], + }, + expected_window_names=["🍩"], + ), + YamlEdgeCaseFixture( + test_id="mixed-type-window-names", + workspace={ + "name": "test", + "windows": [ + {222: "echo int"}, + {True: "echo bool"}, + {"normal": "echo str"}, + ], + }, + expected_window_names=["222", "True", "normal"], + ), +] + + +@pytest.mark.parametrize( + list(YamlEdgeCaseFixture._fields), + YAML_EDGE_CASE_FIXTURES, + ids=[f.test_id for f in YAML_EDGE_CASE_FIXTURES], +) +def test_import_tmuxinator_window_name_coercion( + workspace: dict[str, t.Any], + expected_window_names: list[str | None], + test_id: str, +) -> None: + """Window names are coerced to strings for YAML type-coerced keys.""" + result = importers.import_tmuxinator(workspace) + actual_names = [w["window_name"] for w in result["windows"]] + assert actual_names == expected_window_names + + +def test_import_tmuxinator_numeric_window_survives_expand() -> None: + """Numeric window names don't crash expand() after str coercion.""" + from tmuxp.workspace import loader + + workspace = { + "name": "test", + "windows": [{222: "echo hello"}, {True: "echo bool"}], + } + result = importers.import_tmuxinator(workspace) + expanded = loader.expand(result) + + assert expanded["windows"][0]["window_name"] == "222" + assert expanded["windows"][1]["window_name"] == "True" + + +def test_import_tmuxinator_yaml_aliases() -> None: + """YAML aliases/anchors resolve transparently before import.""" + yaml_content = """\ +defaults: &defaults + pre: + - echo "alias_is_working" + +name: sample_alias +root: ~/test +windows: + - editor: + <<: *defaults + layout: main-vertical + panes: + - vim + - top + - guard: +""" + parsed = ConfigReader._load(fmt="yaml", content=yaml_content) + result = importers.import_tmuxinator(parsed) + + assert result["session_name"] == "sample_alias" + assert result["windows"][0]["window_name"] == "editor" + assert result["windows"][0]["shell_command_before"] == [ + 'echo "alias_is_working"', + ] + assert result["windows"][0]["layout"] == "main-vertical" + assert result["windows"][0]["panes"] == ["vim", "top"] + assert result["windows"][1]["window_name"] == "guard" + + +class NamedPaneFixture(t.NamedTuple): + """Test fixture for named pane conversion tests.""" + + test_id: str + panes_input: list[t.Any] + expected_panes: list[t.Any] + + +NAMED_PANE_FIXTURES: list[NamedPaneFixture] = [ + NamedPaneFixture( + test_id="single-named-pane", + panes_input=[{"git_log": "git log --oneline"}], + expected_panes=[ + {"shell_command": ["git log --oneline"], "title": "git_log"}, + ], + ), + NamedPaneFixture( + test_id="named-pane-with-list-commands", + panes_input=[{"server": ["ssh server", "echo hello"]}], + expected_panes=[ + {"shell_command": ["ssh server", "echo hello"], "title": "server"}, + ], + ), + NamedPaneFixture( + test_id="mixed-named-and-plain-panes", + panes_input=["vim", {"logs": ["tail -f log"]}, "top"], + expected_panes=[ + "vim", + {"shell_command": ["tail -f log"], "title": "logs"}, + "top", + ], + ), + NamedPaneFixture( + test_id="named-pane-with-none-command", + panes_input=[{"empty": None}], + expected_panes=[ + {"shell_command": [], "title": "empty"}, + ], + ), + NamedPaneFixture( + test_id="no-named-panes", + panes_input=["vim", None, "top"], + expected_panes=["vim", None, "top"], + ), +] + + +@pytest.mark.parametrize( + list(NamedPaneFixture._fields), + NAMED_PANE_FIXTURES, + ids=[f.test_id for f in NAMED_PANE_FIXTURES], +) +def test_convert_named_panes( + test_id: str, + panes_input: list[t.Any], + expected_panes: list[t.Any], +) -> None: + """_convert_named_panes() converts {name: commands} dicts to title+shell_command.""" + result = importers._convert_named_panes(panes_input) + assert result == expected_panes + + +def test_import_tmuxinator_named_pane_in_window() -> None: + """Named pane dicts inside window config are converted with title.""" + workspace = { + "name": "test", + "windows": [ + { + "editor": { + "panes": [ + "vim", + {"logs": ["tail -f log/dev.log"]}, + ], + }, + }, + ], + } + result = importers.import_tmuxinator(workspace) + panes = result["windows"][0]["panes"] + assert panes[0] == "vim" + assert panes[1] == {"shell_command": ["tail -f log/dev.log"], "title": "logs"} + + +def test_import_tmuxinator_named_pane_in_list_window() -> None: + """Named pane dicts in list-form windows are converted with title.""" + workspace = { + "name": "test", + "windows": [ + {"editor": ["vim", {"server": "rails s"}, "top"]}, + ], + } + result = importers.import_tmuxinator(workspace) + panes = result["windows"][0]["panes"] + assert panes[0] == "vim" + assert panes[1] == {"shell_command": ["rails s"], "title": "server"} + assert panes[2] == "top" + + +def test_import_tmuxinator_socket_name_conflict_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """Warn when explicit socket_name overrides -L from cli_args.""" + workspace = { + "name": "conflict", + "cli_args": "-L from_cli", + "socket_name": "explicit", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + result = importers.import_tmuxinator(workspace) + + assert result["socket_name"] == "explicit" + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) == 1 + assert "explicit" in warning_records[0].message + assert "from_cli" in warning_records[0].message + + +def test_import_tmuxinator_socket_name_same_no_warning( + caplog: pytest.LogCaptureFixture, +) -> None: + """No warning when cli_args -L and explicit socket_name match.""" + workspace = { + "name": "same", + "cli_args": "-L same_socket", + "socket_name": "same_socket", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + result = importers.import_tmuxinator(workspace) + + assert result["socket_name"] == "same_socket" + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warning_records) == 0 + + +def test_import_tmuxinator_pre_list_joined_for_on_project_start() -> None: + """List pre values are joined with '; ' for on_project_start.""" + workspace = { + "name": "pre-list", + "windows": [{"editor": "vim"}], + "pre": ["echo one", "echo two"], + } + result = importers.import_tmuxinator(workspace) + assert result["on_project_start"] == "echo one; echo two" + + # Verify it survives expand() without TypeError + from tmuxp.workspace import loader + + loader.expand(result) + + +class PreVsPassthroughFixture(t.NamedTuple): + """Test fixture for pre vs on_project_start passthrough precedence.""" + + test_id: str + workspace: dict[str, t.Any] + expected_on_project_start: str + + +PRE_VS_PASSTHROUGH_FIXTURES: list[PreVsPassthroughFixture] = [ + PreVsPassthroughFixture( + test_id="passthrough_wins_over_pre", + workspace={ + "name": "both-keys", + "on_project_start": "echo native-start", + "pre": "echo legacy-pre", + "windows": [{"editor": "vim"}], + }, + expected_on_project_start="echo native-start", + ), + PreVsPassthroughFixture( + test_id="pre_maps_when_no_passthrough", + workspace={ + "name": "pre-only", + "pre": "echo starting", + "windows": [{"editor": "vim"}], + }, + expected_on_project_start="echo starting", + ), +] + + +@pytest.mark.parametrize( + list(PreVsPassthroughFixture._fields), + PRE_VS_PASSTHROUGH_FIXTURES, + ids=[f.test_id for f in PRE_VS_PASSTHROUGH_FIXTURES], +) +def test_import_tmuxinator_pre_vs_passthrough_on_project_start( + test_id: str, + workspace: dict[str, t.Any], + expected_on_project_start: str, +) -> None: + """Passthrough on_project_start takes precedence over legacy pre key.""" + result = importers.import_tmuxinator(workspace) + assert result["on_project_start"] == expected_on_project_start + + +def test_import_tmuxinator_passthrough_pane_titles_and_hooks() -> None: + """Pane title and lifecycle hook keys are copied through to tmuxp config.""" + workspace = { + "name": "passthrough", + "enable_pane_titles": True, + "pane_title_position": "bottom", + "pane_title_format": "#{pane_index}", + "on_project_start": "echo starting", + "on_project_restart": "echo restarting", + "on_project_exit": "echo exiting", + "on_project_stop": "echo stopping", + "windows": [{"editor": "vim"}], + } + result = importers.import_tmuxinator(workspace) + + assert result["enable_pane_titles"] is True + assert result["pane_title_position"] == "bottom" + assert result["pane_title_format"] == "#{pane_index}" + assert result["on_project_start"] == "echo starting" + assert result["on_project_restart"] == "echo restarting" + assert result["on_project_exit"] == "echo exiting" + assert result["on_project_stop"] == "echo stopping" + + +def test_import_tmuxinator_on_project_first_start_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """Warn when on_project_first_start is used (not yet supported by tmuxp).""" + workspace = { + "name": "first-start", + "on_project_first_start": "rake db:create", + "windows": [{"editor": "vim"}], + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + result = importers.import_tmuxinator(workspace) + + assert "on_project_first_start" not in result + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any("on_project_first_start" in r.message for r in warning_records) + + +class UnmappedKeyFixture(t.NamedTuple): + """Fixture for tmuxinator keys with no tmuxp equivalent.""" + + test_id: str + key: str + value: t.Any + + +UNMAPPED_KEY_FIXTURES: list[UnmappedKeyFixture] = [ + UnmappedKeyFixture( + test_id="tmux_command", + key="tmux_command", + value="wemux", + ), + UnmappedKeyFixture( + test_id="attach", + key="attach", + value=False, + ), + UnmappedKeyFixture( + test_id="post", + key="post", + value="echo done", + ), +] + + +@pytest.mark.parametrize( + list(UnmappedKeyFixture._fields), + UNMAPPED_KEY_FIXTURES, + ids=[f.test_id for f in UNMAPPED_KEY_FIXTURES], +) +def test_import_tmuxinator_warns_on_unmapped_key( + caplog: pytest.LogCaptureFixture, + test_id: str, + key: str, + value: t.Any, +) -> None: + """Unmapped tmuxinator keys log a warning instead of being silently dropped.""" + workspace = { + "name": "unmapped-test", + "windows": [{"editor": "vim"}], + key: value, + } + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + importers.import_tmuxinator(workspace) + + warning_records = [r for r in caplog.records if r.levelno == logging.WARNING] + assert any(key in r.message for r in warning_records) + + +class PreWindowStandaloneFixture(t.NamedTuple): + """Fixture for pre_window/pre_tab without pre key.""" + + test_id: str + config_extra: dict[str, t.Any] + expect_shell_command_before: list[str] | None + expect_on_project_start: str | None + + +PRE_WINDOW_STANDALONE_FIXTURES: list[PreWindowStandaloneFixture] = [ + PreWindowStandaloneFixture( + test_id="pre_window-only", + config_extra={"pre_window": "echo PRE"}, + expect_shell_command_before=["echo PRE"], + expect_on_project_start=None, + ), + PreWindowStandaloneFixture( + test_id="pre_tab-only", + config_extra={"pre_tab": "rbenv shell 3.0"}, + expect_shell_command_before=["rbenv shell 3.0"], + expect_on_project_start=None, + ), + PreWindowStandaloneFixture( + test_id="pre_window-list", + config_extra={"pre_window": ["echo a", "echo b"]}, + expect_shell_command_before=["echo a; echo b"], + expect_on_project_start=None, + ), + PreWindowStandaloneFixture( + test_id="pre-and-pre_window", + config_extra={"pre": "sudo start", "pre_window": "echo PRE"}, + expect_shell_command_before=["echo PRE"], + expect_on_project_start="sudo start", + ), + PreWindowStandaloneFixture( + test_id="pre-and-pre_window-list", + config_extra={"pre": "sudo start", "pre_window": ["cd /app", "nvm use 18"]}, + expect_shell_command_before=["cd /app; nvm use 18"], + expect_on_project_start="sudo start", + ), + PreWindowStandaloneFixture( + test_id="pre-only", + config_extra={"pre": "sudo start"}, + expect_shell_command_before=None, + expect_on_project_start="sudo start", + ), +] + + +@pytest.mark.parametrize( + list(PreWindowStandaloneFixture._fields), + PRE_WINDOW_STANDALONE_FIXTURES, + ids=[f.test_id for f in PRE_WINDOW_STANDALONE_FIXTURES], +) +def test_import_tmuxinator_pre_window_standalone( + test_id: str, + config_extra: dict[str, t.Any], + expect_shell_command_before: list[str] | None, + expect_on_project_start: str | None, +) -> None: + """pre_window/pre_tab map to shell_command_before independently of pre.""" + workspace: dict[str, t.Any] = { + "name": "pre-window-test", + "windows": [{"editor": "vim"}], + **config_extra, + } + result = importers.import_tmuxinator(workspace) + + if expect_shell_command_before is not None: + assert result.get("shell_command_before") == expect_shell_command_before + else: + assert "shell_command_before" not in result + + if expect_on_project_start is not None: + assert result.get("on_project_start") == expect_on_project_start + else: + assert "on_project_start" not in result + + +class PreWindowPrecedenceFixture(t.NamedTuple): + """Fixture for rbenv/rvm/pre_tab/pre_window exclusive precedence.""" + + test_id: str + config_extra: dict[str, t.Any] + expect_shell_command_before: list[str] + + +PRE_WINDOW_PRECEDENCE_FIXTURES: list[PreWindowPrecedenceFixture] = [ + PreWindowPrecedenceFixture( + test_id="rbenv-beats-pre_window", + config_extra={"rbenv": "2.7.0", "pre_window": "echo PRE"}, + expect_shell_command_before=["rbenv shell 2.7.0"], + ), + PreWindowPrecedenceFixture( + test_id="rvm-beats-pre_tab", + config_extra={"rvm": "2.1.1", "pre_tab": "source .env"}, + expect_shell_command_before=["rvm use 2.1.1"], + ), + PreWindowPrecedenceFixture( + test_id="rbenv-beats-rvm", + config_extra={"rbenv": "3.2.0", "rvm": "2.1.1"}, + expect_shell_command_before=["rbenv shell 3.2.0"], + ), + PreWindowPrecedenceFixture( + test_id="pre_tab-beats-pre_window", + config_extra={"pre_tab": "nvm use 18", "pre_window": "echo OTHER"}, + expect_shell_command_before=["nvm use 18"], + ), +] + + +@pytest.mark.parametrize( + list(PreWindowPrecedenceFixture._fields), + PRE_WINDOW_PRECEDENCE_FIXTURES, + ids=[f.test_id for f in PRE_WINDOW_PRECEDENCE_FIXTURES], +) +def test_import_tmuxinator_pre_window_precedence( + test_id: str, + config_extra: dict[str, t.Any], + expect_shell_command_before: list[str], +) -> None: + """Tmuxinator uses exclusive rbenv > rvm > pre_tab > pre_window precedence.""" + workspace: dict[str, t.Any] = { + "name": "precedence-test", + "windows": [{"editor": "vim"}], + **config_extra, + } + result = importers.import_tmuxinator(workspace) + assert result.get("shell_command_before") == expect_shell_command_before + + +class StartupIndexFixture(t.NamedTuple): + """Fixture for startup_window/startup_pane numeric index resolution.""" + + test_id: str + startup_window: str | int + base_index: int + window_names: list[str] + expected_focus_index: int | None + expect_warning_log: bool + + +STARTUP_INDEX_FIXTURES: list[StartupIndexFixture] = [ + StartupIndexFixture( + test_id="name-match", + startup_window="editor", + base_index=0, + window_names=["editor", "console"], + expected_focus_index=0, + expect_warning_log=False, + ), + StartupIndexFixture( + test_id="numeric-zero-base-zero", + startup_window=0, + base_index=0, + window_names=["win1", "win2"], + expected_focus_index=0, + expect_warning_log=False, + ), + StartupIndexFixture( + test_id="numeric-one-base-zero", + startup_window=1, + base_index=0, + window_names=["win1", "win2"], + expected_focus_index=1, + expect_warning_log=False, + ), + StartupIndexFixture( + test_id="numeric-one-base-one", + startup_window=1, + base_index=1, + window_names=["win1", "win2"], + expected_focus_index=0, + expect_warning_log=False, + ), + StartupIndexFixture( + test_id="numeric-two-base-one", + startup_window=2, + base_index=1, + window_names=["win1", "win2"], + expected_focus_index=1, + expect_warning_log=False, + ), + StartupIndexFixture( + test_id="out-of-range", + startup_window=5, + base_index=1, + window_names=["win1", "win2"], + expected_focus_index=None, + expect_warning_log=True, + ), + StartupIndexFixture( + test_id="no-match-string", + startup_window="nonexistent", + base_index=0, + window_names=["win1", "win2"], + expected_focus_index=None, + expect_warning_log=True, + ), +] + + +@pytest.mark.parametrize( + list(StartupIndexFixture._fields), + STARTUP_INDEX_FIXTURES, + ids=[f.test_id for f in STARTUP_INDEX_FIXTURES], +) +def test_import_tmuxinator_startup_window_index_resolution( + caplog: pytest.LogCaptureFixture, + test_id: str, + startup_window: str | int, + base_index: int, + window_names: list[str], + expected_focus_index: int | None, + expect_warning_log: bool, +) -> None: + """startup_window resolves by name first, then tmux base-index.""" + workspace: dict[str, t.Any] = { + "name": "startup-test", + "startup_window": startup_window, + "windows": [{wn: "echo hi"} for wn in window_names], + } + with caplog.at_level(logging.DEBUG, logger="tmuxp.workspace.importers"): + result = importers.import_tmuxinator(workspace, base_index=base_index) + + windows = result["windows"] + for i, w in enumerate(windows): + if expected_focus_index is not None and i == expected_focus_index: + assert w.get("focus") is True, f"window {i} should have focus" + else: + assert not w.get("focus"), f"window {i} should not have focus" + + warning_records = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and "startup_window" in r.message + ] + + if expect_warning_log: + assert len(warning_records) >= 1 + else: + assert len(warning_records) == 0 + + +def test_import_tmuxinator_cli_args_attached_flags() -> None: + """Tmuxinator cli_args with attached POSIX flags like -Lmysocket.""" + workspace = { + "name": "attached-flags", + "root": "~/app", + "cli_args": "-f~/.tmux.mac.conf -Lmysocket", + "windows": [{"editor": "vim"}], + } + result = importers.import_tmuxinator(workspace) + + assert result["config"] == "~/.tmux.mac.conf" + assert result["socket_name"] == "mysocket" + + +def test_import_tmuxinator_none_window_name_no_crash() -> None: + """Tmuxinator config with None (null) window key imports without crashing.""" + workspace = { + "name": "null-window", + "windows": [{None: "vim"}], + } + result = importers.import_tmuxinator(workspace) + + assert result["windows"][0]["window_name"] is None + assert result["windows"][0]["panes"] == ["vim"] + + # Verify expand + trickle don't crash on None window_name + from tmuxp.workspace import loader + + expanded = loader.expand(result) + loader.trickle(expanded)