diff --git a/.claude/commands/check/audit.md b/.claude/commands/check/audit.md new file mode 100644 index 0000000000..7c36e9cf39 --- /dev/null +++ b/.claude/commands/check/audit.md @@ -0,0 +1,241 @@ +--- +description: Idempotent parity audit — checks deliverables, tmuxinator/teamocil parity, DX, pytest conventions, and test coverage +--- + +# /check:audit — Parity Audit + +Idempotent status check for tmuxinator/teamocil parity work (issue #1016). Launches sub-agents to assess 6 dimensions, then synthesizes a status report with prioritized TODOs. + +## Workflow + +### Batch 1: Launch 3 Explore agents in parallel + +#### Agent 1: Deliverable Check + +Read these files and check each item: + +- `src/tmuxp/workspace/importers.py` +- `src/tmuxp/workspace/builder.py` (search for `shell_command_after`, `synchronize`) +- `src/tmuxp/cli/load.py` (search for `--here`, `here`) +- `src/tmuxp/cli/__init__.py` (search for `stop`, `new`, `copy`, `delete`) + +**Checklist** (mark ✅ done, ❌ missing, 🔧 partially done): + +| ID | Item | What to check | +|----|------|---------------| +| I1 | `pre`/`pre_window` mapping | `pre` → `before_script` (not `shell_command`/`shell_command_before`). `pre_window` → `shell_command_before` | +| I2 | `cli_args`/`tmux_options` parsing | Uses `shlex.split()` token iteration, not `.replace("-f", "")` | +| I3 | Filter loop fix | Direct assignment with truthiness guard, not `for _b in` loop | +| I4 | v1.x teamocil format | String panes and `commands` key handled | +| I5 | Missing tmuxinator keys | `rvm`, `pre_tab`, `startup_window`, `startup_pane` handled | +| I6 | Missing teamocil keys | `focus`, `target`, `options` explicitly copied (not accidental mutation) | +| I7 | Importer TODOs | `with_env_var` → window `environment`, stale TODO docstring removed | +| T1 | `synchronize` desugar | Desugared to `options`/`options_after` in importer | +| T3 | `shell_command_after` | Processed in `config_after_window()` in builder | +| T4 | `--here` CLI flag | Flag exists in load.py, mutually exclusive with `--append` | +| T5 | `tmuxp stop` | Command registered in CLI | +| T10 | `tmuxp new/copy/delete` | Commands registered in CLI | + +#### Agent 2: Tmuxinator Parity + +Read `src/tmuxp/workspace/importers.py` (function `import_tmuxinator`). + +If available, also read tmuxinator Ruby source at `~/study/ruby/tmuxinator/lib/tmuxinator/project.rb`. + +For each tmuxinator config key, check if it's handled in the importer and if a test fixture covers it: + +| Key | Handled? | Mapping correct? | Test fixture? | +|-----|----------|-------------------|---------------| +| `name` | | | | +| `project_name` | | | | +| `root` / `project_root` | | | | +| `pre` (string) | | | | +| `pre` (list) | | | | +| `pre_window` | | | | +| `pre_tab` | | | | +| `rbenv` | | | | +| `rvm` | | | | +| `tmux_options` / `cli_args` | | | | +| `socket_name` | | | | +| `startup_window` | | | | +| `startup_pane` | | | | +| `synchronize` (true/before/after/false) | | | | +| `tabs` (alias for windows) | | | | +| `on_project_start/exit/stop` | | | | +| `enable_pane_titles` | | | | +| `pane_title_position/format` | | | | +| window `pre` | | | | +| window `root` | | | | +| window `layout` | | | | +| window `panes` (list of strings) | | | | +| window `panes` (list of dicts) | | | | + +#### Agent 3: Teamocil Parity + +Read `src/tmuxp/workspace/importers.py` (function `import_teamocil`). + +If available, also read teamocil Ruby source at `~/study/ruby/teamocil/lib/teamocil/tmux/`. + +For each teamocil config key, check if it's handled in the importer and if a test fixture covers it: + +| Key | Handled? | v0.x? | v1.x? | Test fixture? | +|-----|----------|-------|-------|---------------| +| `session.name` | | | | | +| `session.root` | | | | | +| `windows[].name` | | | | | +| `windows[].root` | | | | | +| `windows[].layout` | | | | | +| `windows[].clear` | | | | | +| `windows[].filters.before` | | | | | +| `windows[].filters.after` | | | | | +| `windows[].with_env_var` | | | | | +| `windows[].cmd_separator` | | | | | +| `windows[].focus` | | | | | +| `windows[].options` | | | | | +| `splits` (alias for panes) | | | | | +| `panes[].cmd` (string) | | | | | +| `panes[].cmd` (list) | | | | | +| `panes[].commands` (v1.x) | | | | | +| `panes[]` as string (v1.x) | | | | | +| `panes[]` as None | | | | | +| `panes[].focus` | | | | | +| `panes[].target` | | | | | +| `panes[].width` | | | | | +| `panes[].height` | | | | | + +### Batch 2: Launch 3 more Explore agents in parallel + +#### Agent 4: DX Happiness + +Read: +- `src/tmuxp/cli/load.py` — flags, help strings, error messages +- `src/tmuxp/cli/import_config.py` — import flow warnings +- `src/tmuxp/util.py` — `run_before_script()` behavior + +Check: +- Import CLI warns about manual adjustments after import +- Unsupported keys (width, height) produce log warnings (not silent drops) +- Multi-command `pre` lists warn with actionable guidance +- `--here` outside tmux gives clear error (not cryptic traceback) +- Schema validation catches bad imports before tmux session creation +- `tmuxp stop` has `--yes` flag for scripting + +Report each issue with severity: **blocker** / **warning** / **nice-to-have**. + +#### Agent 5: Pytest Happiness + +Read all test files in: +- `tests/workspace/test_import_tmuxinator.py` +- `tests/workspace/test_import_teamocil.py` +- `tests/workspace/test_builder.py` +- `tests/cli/test_load.py` (if exists) + +And all fixture files in: +- `tests/fixtures/import_tmuxinator/*.py` +- `tests/fixtures/import_teamocil/*.py` + +**Convention checklist** (from CLAUDE.md): + +| Convention | Status | Evidence | +|------------|--------|----------| +| Functional tests only (no `class TestFoo:`) | | | +| `NamedTuple` fixture classes with `test_id` | | | +| `@pytest.mark.parametrize` with `ids=` | | | +| Fixture modules export `*_yaml`, `*_dict`, `expected` | | | +| Tests call `validation.validate_schema()` | | | +| No `unittest.mock` (use `monkeypatch`) | | | +| No `tempfile` (use `tmp_path`) | | | +| `from __future__ import annotations` | | | +| `import typing as t` namespace | | | + +List any violations with file path and line number. + +#### Agent 6: Test Coverage + +Read `src/tmuxp/workspace/importers.py` and enumerate every branch/condition. +Read all test files and fixtures to determine which branches are covered. + +**`import_tmuxinator()` branches**: + +| Branch | Condition | Tested? | Test ID | +|--------|-----------|---------|---------| +| session name | `project_name` present | | | +| session name | `name` present | | | +| session name | neither → `None` | | | +| start dir | `project_root` present | | | +| start dir | `root` present | | | +| cli args | `cli_args` with `-f` | | | +| cli args | `tmux_options` with `-f` | | | +| cli args | multi-flag (`-f -L`) | | | +| socket | `socket_name` present | | | +| pre | `pre` string only | | | +| pre | `pre` list single cmd | | | +| pre | `pre` list multi cmd (warning) | | | +| pre | `pre` + `pre_window` combo | | | +| pre_window | string | | | +| pre_window | list | | | +| pre_tab | alias for pre_window | | | +| rbenv | present | | | +| rvm | present | | | +| tabs | alias for windows | | | +| synchronize | true / "before" | | | +| synchronize | "after" | | | +| synchronize | false | | | +| startup_window | present | | | +| startup_pane | present | | | +| window | string value | | | +| window | None value | | | +| window | list value | | | +| window | dict with pre/panes/root/layout | | | + +**`import_teamocil()` branches**: + +| Branch | Condition | Tested? | Test ID | +|--------|-----------|---------|---------| +| session wrapper | `session` key present | | | +| session name | `name` present / absent | | | +| session root | `root` present | | | +| window clear | `clear` present | | | +| filters before | non-empty list | | | +| filters before | empty list | | | +| filters after | non-empty list | | | +| filters after | empty list | | | +| with_env_var | true | | | +| with_env_var | false | | | +| window root | present | | | +| splits alias | `splits` → `panes` | | | +| pane cmd | string | | | +| pane cmd | list | | | +| pane commands | v1.x key | | | +| pane string | v1.x format | | | +| pane None | blank pane | | | +| pane focus | present | | | +| pane target | present | | | +| pane width | warning | | | +| pane height | warning | | | +| window layout | present | | | +| window focus | present | | | +| window options | present | | | + +### Synthesis + +After all 6 agents complete, synthesize results into: + +**Status Summary** — one line per dimension: +``` +1. Deliverables: X/12 items complete +2. Tmuxinator: X/Y keys handled, X tested +3. Teamocil: X/Y keys handled, X tested +4. DX: X blockers, Y warnings +5. Pytest: X/Y conventions met +6. Coverage: X/Y branches tested +``` + +**Prioritized TODO** — ordered by impact: +1. Blockers (broken behavior, data loss) +2. Missing features (unhandled keys) +3. Test gaps (untested branches) +4. Convention violations +5. Nice-to-have DX improvements + +**Ready to ship?** — Yes / No, with blocking items listed. diff --git a/.claude/commands/check/parity.md b/.claude/commands/check/parity.md new file mode 100644 index 0000000000..533a06eb1e --- /dev/null +++ b/.claude/commands/check/parity.md @@ -0,0 +1,78 @@ +# /check:parity — Feature Parity Analysis + +Deep-dive analysis of tmuxp vs tmuxinator and teamocil. Updates comparison docs and parity notes. + +## Workflow + +1. **Read source code** of all three projects: + - tmuxp: `src/tmuxp/workspace/` (builder.py, loader.py, importers.py), `src/tmuxp/cli/load.py` + - tmuxinator: `~/study/ruby/tmuxinator/lib/tmuxinator/` (project.rb, window.rb, pane.rb, hooks/, assets/template.erb) + - teamocil: `~/study/ruby/teamocil/lib/teamocil/tmux/` (session.rb, window.rb, pane.rb) + +2. **Read existing docs** for baseline: + - `docs/about.md` — tmuxp's own feature description + - `docs/comparison.md` — feature comparison table (create if missing) + - `notes/parity-tmuxinator.md` — tmuxinator parity analysis (create if missing) + - `notes/parity-teamocil.md` — teamocil parity analysis (create if missing) + +3. **Update `docs/comparison.md`** with tabular feature comparison: + - Overview table (language, min tmux, config format, architecture) + - Configuration keys table (every key across all three, with ✓/✗) + - CLI commands table (side-by-side) + - Architecture comparison (ORM vs script generation vs command objects) + - Include version numbers for each project + +4. **Update `notes/parity-tmuxinator.md`** with: + - Features tmuxinator has that tmuxp lacks (with source locations) + - Import behavior analysis (what the current importer handles vs misses) + - WorkspaceBuilder requirements for 100% feature support + - Code quality issues in current importer + +5. **Update `notes/parity-teamocil.md`** with: + - Features teamocil has that tmuxp lacks (with source locations) + - v0.x vs v1.4.2 format differences (current importer targets v0.x only) + - Import behavior analysis + - WorkspaceBuilder requirements for full parity + +6. **Commit each file separately** + +## Key areas to verify + +- Check `importers.py` line-by-line against actual tmuxinator/teamocil config keys +- Verify `load_workspace()` actually reads config keys it claims to support +- Cross-reference CHANGELOGs for version-specific features +- Check test fixtures match real-world configs + +--- + +# Import Behavior + +Study tmuxp, teamocil, and tmuxinator source code. Find any syntax they support that tmuxp's native syntax doesn't. + +Create/update: +- `notes/import-teamocil.md` +- `notes/import-tmuxinator.md` + +## Syntax Level Differences / Limitations + +For each config key and syntax pattern discovered, classify as: + +### Differences (Translatable) + +Syntax that differs but can be automatically converted during import. Document the mapping. + +### Limitations (tmuxp needs to add support) + +Syntax/features that cannot be imported because tmuxp lacks the underlying capability. For each, note: +1. What the feature does in the source tool +2. Why it can't be imported +3. What tmuxp would need to add + +--- + +# WorkspaceBuilder + +Analyze what WorkspaceBuilder needs to: + +1. **Auto-detect config format** — Determine heuristics to identify tmuxinator vs teamocil vs tmuxp configs transparently +2. **100% feature support** — List every feature/behavior needed for complete compatibility, including behavioral idiosyncrasies diff --git a/.claude/commands/check/plan.md b/.claude/commands/check/plan.md new file mode 100644 index 0000000000..037d0febf8 --- /dev/null +++ b/.claude/commands/check/plan.md @@ -0,0 +1,109 @@ +--- +description: Audit parity status and generate a commit-by-commit implementation plan with QA gates +--- + +# /check:plan — Parity Implementation Plan + +Runs the same audit as `/check:audit`, then converts findings into an ordered sequence of atomic commits. Each commit has a mandatory QA gate. Source and test commits are separate. + +## Phase 1: Audit + +Run `/check:audit` inline — launch 6 Explore agents in 2 batches of 3 (see `.claude/commands/check/audit.md` for the full agent definitions). Collect results for all 6 dimensions: + +1. Deliverable Check +2. Tmuxinator Parity +3. Teamocil Parity +4. DX Happiness +5. Pytest Happiness +6. Test Coverage + +## Phase 2: Generate Commit Plan + +Using audit results, generate a **numbered commit sequence**. Group by logical topic, ordered by priority: + +1. **Bug fixes first** (broken behavior, data loss) +2. **Missing features** (unhandled keys, new code paths) +3. **Test coverage** (new fixtures and test cases) +4. **DX improvements** (warnings, validation, error messages) +5. **Future features** (CLI commands, lifecycle hooks) + +### Commit Structure Rules + +Each commit entry must specify: + +``` +### Commit N: + +**Files**: list of files to modify +**Changes**: +- Specific change 1 +- Specific change 2 +**Test fixtures** (if test commit): list of new fixture files/test IDs +**Depends on**: Commit X (if sequential dependency) +``` + +### Commit Pairing Convention + +Source and tests are **separate commits** (per AGENTS.md): + +- Source commit: `fix(importers[import_tmuxinator]): Fix pre mapping to before_script` +- Test commit: `test(importers[import_tmuxinator]): Add pre mapping fixtures` + +### Commit Message Format + +Follow project conventions: +``` +Scope(type[detail]): concise description + +why: Explanation of necessity or impact. +what: +- Specific technical changes made +``` + +## Phase 3: QA Gate + +**Before every commit**, run the full QA suite: + +```bash +uv run ruff check . --fix --show-fixes && uv run ruff format . && uv run mypy && uv run py.test -vvv +``` + +**All four commands must pass.** If `ruff check --fix` modifies files, stage those fixes into the same commit. If `mypy` or `py.test` fails, fix the issue before committing. + +Do NOT use `--no-verify` or skip any step. + +## Phase 4: Execute + +For each commit in the plan: + +1. **Make the changes** described in the commit entry +2. **Run the QA gate** — all 4 commands must pass +3. **Stage specific files** — `git add `, never `git add .` or `git add -A` +4. **Commit** with the specified message (use heredoc for multi-line) +5. **Verify** — `git log --oneline -1` to confirm +6. **Proceed** to next commit + +If a commit fails QA: +- Fix the issue +- Re-run QA +- Create a **new** commit (never amend) + +## Phase 5: Re-audit + +After all commits are done, re-run the audit (Phase 1) to verify progress. Report: + +``` +Before: X/Y items complete +After: X/Y items complete +Remaining: list of items still TODO +``` + +## Reference + +- **Audit dimensions**: `.claude/commands/check/audit.md` +- **Implementation patterns**: `.claude/commands/implement.md` +- **Commit conventions**: `CLAUDE.md` (Git Commit Standards) +- **Test patterns**: `CLAUDE.md` (Testing Guidelines) +- **Primary source**: `src/tmuxp/workspace/importers.py` +- **Test files**: `tests/workspace/test_import_tmuxinator.py`, `tests/workspace/test_import_teamocil.py` +- **Fixture dirs**: `tests/fixtures/import_tmuxinator/`, `tests/fixtures/import_teamocil/` diff --git a/.claude/commands/check/shortcomings.md b/.claude/commands/check/shortcomings.md new file mode 100644 index 0000000000..48a3669da0 --- /dev/null +++ b/.claude/commands/check/shortcomings.md @@ -0,0 +1,51 @@ +# /check:shortcomings — API Limitations Analysis + +Second-step command that reads parity analysis and outputs API blockers to `notes/plan.md`. + +## Input Files (from /check:parity) + +- `notes/parity-tmuxinator.md` +- `notes/parity-teamocil.md` +- `notes/import-tmuxinator.md` +- `notes/import-teamocil.md` + +## Workflow + +1. **Read parity analysis files** to understand feature gaps + +2. **Explore libtmux** at `~/work/python/libtmux/`: + - What APIs are missing? (e.g., no `pane.set_title()`) + - What's hardcoded? (e.g., `shutil.which("tmux")`) + +3. **Explore tmuxp** at `~/work/python/tmuxp/`: + - What config keys are dead data? + - What keys are missing from loader/builder? + - What CLI flags are missing? + +4. **Update `notes/plan.md`** with: + - libtmux limitations (what Server/Pane/Window/Session can't do) + - tmuxp limitations (what WorkspaceBuilder/loader/cli can't do) + - Dead config keys (imported but ignored) + - Required API additions for each gap + - Non-breaking implementation notes + +5. **Commit** `notes/plan.md` + +## Output Structure + +notes/plan.md should follow this format: + +### libtmux Limitations +Per-limitation: +- **Blocker**: What API is missing/hardcoded +- **Blocks**: What parity feature this prevents +- **Required**: What API addition is needed + +### tmuxp Limitations +Per-limitation: +- **Blocker**: What's missing/broken +- **Blocks**: What parity feature this prevents +- **Required**: What change is needed + +### Implementation Notes +Non-breaking approach for each limitation. diff --git a/.claude/commands/implement.md b/.claude/commands/implement.md new file mode 100644 index 0000000000..869bfb9805 --- /dev/null +++ b/.claude/commands/implement.md @@ -0,0 +1,160 @@ +# /implement — Plan and Implement from notes/plan.md + +Orchestrates the full implementation workflow: plan → implement → test → verify → commit → document. + +## Reference Codebases + +- **tmuxinator**: `~/study/ruby/tmuxinator/` +- **teamocil**: `~/study/ruby/teamocil/` +- **tmux**: `~/study/c/tmux/` +- **libtmux**: `~/work/python/libtmux/` +- **tmuxp**: `~/work/python/tmuxp/` + +## Workflow + +### Phase 1: Planning Mode + +1. **Read the plan**: Load `notes/plan.md` to understand what needs to be implemented +2. **Select a task**: Pick the highest priority incomplete item from the plan +3. **Research**: + - Read relevant tmuxinator/teamocil Ruby source for behavior reference + - Read libtmux Python source for available APIs + - Read tmuxp source for integration points + - **Study existing tests** for similar functionality (see Testing Pattern below) +4. **Create implementation plan**: Design the specific changes needed +5. **Exit planning mode** with the finalized approach + +### Phase 2: Implementation + +1. **Make changes**: Edit the necessary files +2. **Follow conventions**: Match existing code style, use type hints, add docstrings + +### Phase 3: Write Tests + +**CRITICAL**: Before running verification, write tests for new functionality. + +1. **Find similar tests**: Search `tests/` for existing tests of similar features +2. **Follow the project test pattern** (see Testing Pattern below) +3. **Add test cases**: Cover normal cases, edge cases, and error conditions + +### Phase 4: Verification + +Run the full QA suite: + +```bash +uv run ruff check . --fix --show-fixes +uv run ruff format . +uv run mypy +uv run py.test --reruns 0 -vvv +``` + +All checks must pass before proceeding. + +### Phase 5: Commit Implementation + +**Source and tests must be in separate commits.** + +1. **Commit source code first**: Implementation changes only (e.g., `fix(cli): Read socket_name/path and config from workspace config`) +2. **Commit tests second**: Test files only (e.g., `tests(cli): Add config key precedence tests for load_workspace`) + +Follow the project's commit conventions (e.g., `feat:`, `fix:`, `refactor:` for source; `tests:` or `tests():` for tests). + +### Phase 6: Update Documentation + +1. **Update `notes/completed.md`**: Add entry for what was implemented + - Date + - What was done + - Files changed + - Any notes or follow-ups + +2. **Update `notes/plan.md`**: Mark the item as complete or remove it + +3. **Commit notes separately**: Use message like `notes: Mark as complete` + +--- + +## Testing Pattern + +This project uses a consistent test pattern. **Always follow this pattern for new tests.** + +### 1. NamedTuple Fixture Class + +```python +import typing as t + +class MyFeatureTestFixture(t.NamedTuple): + """Test fixture for my feature tests.""" + + # pytest (internal): Test fixture name + test_id: str + + # test params + input_value: str + expected_output: str + expected_error: str | None = None +``` + +### 2. Fixture List + +```python +TEST_MY_FEATURE_FIXTURES: list[MyFeatureTestFixture] = [ + MyFeatureTestFixture( + test_id="normal-case", + input_value="foo", + expected_output="bar", + ), + MyFeatureTestFixture( + test_id="edge-case-empty", + input_value="", + expected_output="", + ), + MyFeatureTestFixture( + test_id="error-case", + input_value="bad", + expected_output="", + expected_error="Invalid input", + ), +] +``` + +### 3. Parametrized Test Function + +```python +@pytest.mark.parametrize( + "test", + TEST_MY_FEATURE_FIXTURES, + ids=[test.test_id for test in TEST_MY_FEATURE_FIXTURES], +) +def test_my_feature(test: MyFeatureTestFixture) -> None: + """Test my feature with various inputs.""" + result = my_function(test.input_value) + assert result == test.expected_output + + if test.expected_error: + # check error handling + pass +``` + +### Key Rules + +- **Function tests only** — No `class TestFoo:` groupings (per CLAUDE.md) +- **Use fixtures from `tests/fixtures/`** — Prefer real tmux fixtures over mocks +- **Use `tmp_path`** — Not Python's `tempfile` +- **Use `monkeypatch`** — Not `unittest.mock` + +--- + +## Output + +After completion, report: +- What was implemented +- Files changed (including test files) +- Test results summary +- What remains in the plan + +## Notes + +- If tests fail, fix the issues before committing +- If libtmux changes are needed, note them but don't modify libtmux in this workflow +- One logical change per run — don't implement multiple unrelated items +- **Always write tests** — No implementation is complete without tests diff --git a/CHANGES b/CHANGES index 0330651353..a914139974 100644 --- a/CHANGES +++ b/CHANGES @@ -33,11 +33,96 @@ $ 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, matching tmuxinator's +hook system: + +- `on_project_start` — runs before session build (every invocation) +- `on_project_restart` — runs when reattaching to an existing session +- `on_project_exit` — runs on client detach (via tmux `client-detached` hook) +- `on_project_stop` — runs before `tmuxp stop` kills the session + +### Config templating (#1025) +Workspace configs now support Jinja2-style `{{ variable }}` placeholders. +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..fd7a74a42c 100644 --- a/conftest.py +++ b/conftest.py @@ -100,6 +100,7 @@ def socket_name(request: pytest.FixtureRequest) -> str: # Modules that actually need tmux fixtures in their doctests DOCTEST_NEEDS_TMUX = { + "tmuxp.cli.stop", "tmuxp.workspace.builder", } diff --git a/docs/api/cli/copy.md b/docs/api/cli/copy.md new file mode 100644 index 0000000000..9e15404999 --- /dev/null +++ b/docs/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/api/cli/delete.md b/docs/api/cli/delete.md new file mode 100644 index 0000000000..7873640e95 --- /dev/null +++ b/docs/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/api/cli/index.md b/docs/api/cli/index.md index 1381fbc90f..f5c5ebcd44 100644 --- a/docs/api/cli/index.md +++ b/docs/api/cli/index.md @@ -19,6 +19,10 @@ ls progress search shell +stop +new +copy +delete utils ``` diff --git a/docs/api/cli/new.md b/docs/api/cli/new.md new file mode 100644 index 0000000000..bec0862ce1 --- /dev/null +++ b/docs/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/api/cli/stop.md b/docs/api/cli/stop.md new file mode 100644 index 0000000000..7f01b8a4d3 --- /dev/null +++ b/docs/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/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 cb8bf94b3b..3b86eea5a3 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -12,6 +12,7 @@ load shell ls search +stop ``` ```{toctree} @@ -22,6 +23,9 @@ edit import convert freeze +new +copy +delete ``` ```{toctree} diff --git a/docs/cli/load.md b/docs/cli/load.md index 8be9178f29..8867cf3d39 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -253,3 +253,57 @@ 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. + +## 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 +``` + +## 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 stderr. + +```console +$ tmuxp load --debug myproject +``` + +## Config templating + +Workspace configs support Jinja2-style `{{ variable }}` placeholders. 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..783c9031c6 --- /dev/null +++ b/docs/comparison.md @@ -0,0 +1,210 @@ +# Feature Comparison: tmuxp vs tmuxinator vs teamocil + +*Last updated: 2026-03-07* + +## Overview + +| | tmuxp | tmuxinator | teamocil | +|---|---|---|---| +| **Version** | 1.68.0 | 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 | `on_project_start` | `on_project_start` | (none) | +| First start only | `before_script` | `on_project_first_start` | (none) | +| On reattach | `on_project_restart` + Plugin: `reattach()` | `on_project_restart` | (none) | +| On exit/detach | `on_project_exit` (tmux `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) | + +### 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) | + +## 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..5d132aade7 100644 --- a/docs/configuration/top-level.md +++ b/docs/configuration/top-level.md @@ -40,3 +40,151 @@ 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, every `tmuxp load` invocation | +| `on_project_restart` | When reattaching to an existing session | +| `on_project_exit` | On client detach (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 correspond to tmuxinator's `on_project_start`, `on_project_restart`, `on_project_exit`, and `on_project_stop` keys. +``` + +## 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 fd8a21575f..a88dd2bc45 100644 --- a/docs/index.md +++ b/docs/index.md @@ -37,6 +37,7 @@ api/index history migration about_tmux +Comparison glossary GitHub ``` 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/notes/import-teamocil.md b/notes/import-teamocil.md new file mode 100644 index 0000000000..3b1f4af697 --- /dev/null +++ b/notes/import-teamocil.md @@ -0,0 +1,265 @@ +# Teamocil Import Behavior + +*Last updated: 2026-03-07* +*Importer: `src/tmuxp/workspace/importers.py:import_teamocil`* + +## Format Detection Problem + +Teamocil has two distinct config formats: + +- **v0.x** (pre-1.0): `session:` wrapper, `splits`, `filters`, `cmd` +- **v1.x** (1.0–1.4.2): Flat `windows`, `panes`, `commands`, `focus`, `options` + +The current importer **targets v0.x only**. It handles v0.x-specific constructs (`session:` wrapper, `splits`, `filters.before`, `filters.after`, `cmd`) but does not handle v1.x-specific constructs (`commands`, string pane shorthand, `focus`, window `options`). + +Since teamocil 1.4.2 uses the v1.x format, the importer is outdated for current teamocil configs. + +## Syntax Differences (Translatable) + +### 1. Session Wrapper (v0.x) + +| teamocil v0.x | tmuxp | +|---|---| +| `session:` + `name:` + `windows:` | `session_name:` + `windows:` | + +**Importer status**: ✓ Handled (lines 127-128). Unwraps the `session:` key. + +### 2. Session Name + +| teamocil | tmuxp | +|---|---| +| `name: my-layout` | `session_name: my-layout` | + +**Importer status**: ✓ Handled (line 130) + +### 3. Session Root (v0.x) + +| teamocil v0.x | tmuxp | +|---|---| +| `session.root: ~/project` | `start_directory: ~/project` | + +**Importer status**: ✓ Handled (lines 132-133). Note: v1.x teamocil has no session-level root. + +### 4. Window Name + +| teamocil | tmuxp | +|---|---| +| `name: editor` | `window_name: editor` | + +**Importer status**: ✓ Handled (line 138) + +### 5. Window Root + +| teamocil | tmuxp | +|---|---| +| `root: ~/project` | `start_directory: ~/project` | + +**Importer status**: ✓ Handled (lines 151-152) + +### 6. Window Layout + +| teamocil | tmuxp | +|---|---| +| `layout: main-vertical` | `layout: main-vertical` | + +**Importer status**: ✓ Handled (lines 166-167). Same key name, direct pass-through. + +### 7. Splits → Panes (v0.x) + +| teamocil v0.x | tmuxp | +|---|---| +| `splits:` | `panes:` | + +**Importer status**: ✓ Handled (lines 154-155). Renames key. + +### 8. Pane `cmd` → `shell_command` (v0.x) + +| teamocil v0.x | tmuxp | +|---|---| +| `cmd: vim` | `shell_command: vim` | +| `cmd: [cd /path, vim]` | `shell_command: [cd /path, vim]` | + +**Importer status**: ✓ Handled (lines 159-160). Renames key. + +### 9. Filters Before → Shell Command Before (v0.x) + +| teamocil v0.x | tmuxp | +|---|---| +| `filters: { before: [cmd1, cmd2] }` | `shell_command_before: [cmd1, cmd2]` | + +**Importer status**: ⚠ Handled but with redundant loop (lines 144-146). The `for _b in` loop iterates uselessly — the assignment inside is the same each iteration. Should be a direct assignment. + +### 10. Pane `commands` → `shell_command` (v1.x) + +| teamocil v1.x | tmuxp | +|---|---| +| `commands: [git pull, vim]` | `shell_command: [git pull, vim]` | + +**Importer status**: ✗ Not handled. The v1.x `commands` key is not mapped. Only `cmd` (v0.x) is recognized. + +### 11. String Pane Shorthand (v1.x) + +| teamocil v1.x | tmuxp | +|---|---| +| `- git status` (string in panes list) | `- shell_command: [git status]` | + +**Importer status**: ✗ Not handled. The importer expects each pane to be a dict (tries `p["cmd"]`). String panes will cause a `TypeError`. + +### 12. Window Focus (v1.x) + +| teamocil v1.x | tmuxp | +|---|---| +| `focus: true` (on window) | `focus: true` | + +**Importer status**: ✗ Not handled. The key is not imported. + +### 13. Pane Focus (v0.x and v1.x) + +| teamocil | tmuxp | +|---|---| +| `focus: true` (on pane) | `focus: true` | + +**Importer status**: ✓ Accidentally handled in v0.x. The importer modifies pane dicts in-place (only renaming `cmd` → `shell_command` and dropping `width`), so `focus` survives as an unhandled key that passes through. Test fixture `test3.py` and `layouts.py` confirm this. For v1.x format, pane `focus` would also survive if the pane is a dict (but not if it's a string shorthand). + +### 14. Window Options (v1.x) + +| teamocil v1.x | tmuxp | +|---|---| +| `options: { main-pane-width: '100' }` | `options: { main-pane-width: '100' }` | + +**Importer status**: ✗ Not handled. Same key name in tmuxp, but not imported from teamocil configs. + +## Limitations (tmuxp Needs to Add Support) + +### 1. `--here` Flag (Reuse Current Window) + +**What it does in teamocil**: First window is renamed and reused instead of creating a new one. Root directory applied via `cd` command. + +**Why it can't be imported**: This is a runtime CLI flag, not a config key. + +**What tmuxp would need to add**: `--here` flag on `tmuxp load` that tells WorkspaceBuilder to rename the current window for the first window instead of creating new. + +### 2. Filters After / `shell_command_after` (v0.x) + +**What it does in teamocil**: `filters.after` commands run after pane commands. + +**Why it can't be imported**: The importer maps this to `shell_command_after`, but tmuxp has no support for this key in the WorkspaceBuilder. The key is silently ignored. + +**What tmuxp would need to add**: `shell_command_after` key on windows/panes. Builder would send these commands after all pane `shell_command` entries. + +### 3. Pane Width (v0.x) + +**What it does in teamocil v0.x**: `width` on splits to set pane width. + +**Why it can't be imported**: tmuxp drops this with a TODO comment. tmuxp relies on tmux layouts for pane geometry. + +**What tmuxp would need to add**: Per-pane `width`/`height` keys. Builder would use `resize-pane -x ` or `resize-pane -y ` after split. Alternatively, support custom layout strings. + +### 4. Window Clear (v0.x) + +**What it does in teamocil v0.x**: `clear: true` on windows. + +**Why it can't be imported**: The importer preserves the `clear` key but tmuxp doesn't act on it. + +**What tmuxp would need to add**: `clear` key on windows. Builder would send `clear` (or `send-keys C-l`) after pane creation. + +## Import-Only Fixes (No Builder Changes) + +### 5. `with_env_var` (v0.x only) + +**Verified**: `with_env_var` exists in teamocil's v0.x (`0.4-stable` branch) at `lib/teamocil/layout/window.rb`. When `true` (the default), it exports `TEAMOCIL=1` environment variable in each pane's command chain. Removed in v1.x rewrite. + +tmuxp's `environment` key would be the natural mapping: `environment: { TEAMOCIL: "1" }`. However, since this was a default behavior in v0.x (auto-exported unless disabled), the importer should either: +- Always add `environment: { TEAMOCIL: "1" }` unless `with_env_var: false` +- Or simply drop it, since it's an implementation detail of teamocil + +### 6. `cmd_separator` (v0.x only) + +**Verified**: `cmd_separator` exists in teamocil's v0.x at `lib/teamocil/layout/window.rb`. It's a per-window string (default `"; "`) used to join multiple pane commands before sending via `send-keys`. Removed in v1.x (hardcoded to `"; "`). + +tmuxp sends commands individually (one `send_keys` per command), so this is irrelevant — the importer can safely ignore it. + +## Code Issues in Current Importer + +### Bug: Redundant Filter Loop + +```python +# Lines 143-149 (current) +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"] +``` + +The `for _b in` loops are pointless — they iterate over the list but set the same value each time. Should be: + +```python +if "filters" in w: + if "before" in w["filters"]: + window_dict["shell_command_before"] = w["filters"]["before"] + if "after" in w["filters"]: + window_dict["shell_command_after"] = w["filters"]["after"] +``` + +### Bug: v1.x String Panes Cause TypeError + +```python +# Lines 157-163 (current) +if "panes" in w: + for p in w["panes"]: + if "cmd" in p: # TypeError if p is a string + p["shell_command"] = p.pop("cmd") +``` + +If `p` is a string (v1.x shorthand), `"cmd" in p` will check for substring match in the string, not a dict key. This will either silently pass (if the command doesn't contain "cmd") or incorrectly match. + +### Verified TODOs: `with_env_var` and `cmd_separator` + +Listed in the importer's docstring TODOs (`importers.py:121-123`). Both verified as v0.x features (present in `0.4-stable` branch, removed in v1.x rewrite). `with_env_var` auto-exports `TEAMOCIL=1`; `cmd_separator` controls command joining. Since the importer targets v0.x, these are valid TODOs — but `cmd_separator` is irrelevant since tmuxp sends commands individually. + +### Missing v0.x Features: `height` + +Not mentioned in the importer TODOs but present in v0.x: +- `height` (pane): Percentage for vertical split (`split-window -p `). Like `width`, silently dropped. + +### Accidentally Preserved: `target` and `focus` + +Through in-place dict mutation, these v0.x pane keys survive the import without explicit handling: +- `target` (pane): Preserved in output (see `layouts.py:106-108`), but tmuxp's WorkspaceBuilder ignores it. +- `focus` (pane): Preserved in output (see `layouts.py:109`, `test3.py:39`), and tmuxp's WorkspaceBuilder **does** use `focus` — so pane focus actually works correctly through the v0.x importer by accident. + +### Silent Drops + +- `clear` is preserved but unused by tmuxp +- `width` is dropped with no user warning +- `shell_command_after` is set but unused by tmuxp + +## Summary Table + +| teamocil Feature | Import Status | Classification | +|---|---|---| +| `session:` wrapper (v0.x) | ✓ Handled | Difference | +| `name` → `session_name` | ✓ Handled | Difference | +| `root` → `start_directory` | ✓ Handled | Difference | +| Window `name` → `window_name` | ✓ Handled | Difference | +| Window `root` → `start_directory` | ✓ Handled | Difference | +| Window `layout` | ✓ Handled | Difference | +| `splits` → `panes` (v0.x) | ✓ Handled | Difference | +| Pane `cmd` → `shell_command` (v0.x) | ✓ Handled | Difference | +| `filters.before` → `shell_command_before` (v0.x) | ⚠ Bug (redundant loop) | Difference (needs fix) | +| Pane `commands` → `shell_command` (v1.x) | ✗ Missing | Difference (needs add) | +| String pane shorthand (v1.x) | ✗ Missing (causes error) | Difference (needs add) | +| Window `focus` (v1.x) | ✗ Missing | Difference (needs add) | +| Pane `focus` (v0.x/v1.x) | ✓ Accidentally preserved (v0.x dict passthrough) | Difference (explicit handling needed for v1.x) | +| Window `options` (v1.x) | ✗ Missing | Difference (needs add) | +| `with_env_var` (v0.x) | ✗ Missing | Difference (v0.x only, can map to `environment`) | +| `filters.after` → `shell_command_after` | ⚠ Imported but unused | **Limitation** | +| Pane `width` (v0.x) | ⚠ Dropped silently | **Limitation** | +| Window `clear` (v0.x) | ⚠ Preserved but unused | **Limitation** | +| `cmd_separator` (v0.x) | ✗ Missing | Difference (v0.x only, irrelevant — tmuxp sends individually) | +| `height` (v0.x pane) | ✗ Missing | **Limitation** (like `width`, no per-pane sizing) | +| `target` (v0.x pane) | ✓ Accidentally preserved (but builder ignores it) | **Limitation** (no split targeting) | +| `--here` flag | N/A (runtime flag) | **Limitation** | diff --git a/notes/import-tmuxinator.md b/notes/import-tmuxinator.md new file mode 100644 index 0000000000..d5c6b9e576 --- /dev/null +++ b/notes/import-tmuxinator.md @@ -0,0 +1,257 @@ +# Tmuxinator Import Behavior + +*Last updated: 2026-03-07* +*Importer: `src/tmuxp/workspace/importers.py:import_tmuxinator`* + +## Syntax Differences (Translatable) + +These are config keys/patterns that differ syntactically but can be automatically converted during import. + +### 1. Session Name + +| tmuxinator | tmuxp | +|---|---| +| `name: myproject` | `session_name: myproject` | +| `project_name: myproject` | `session_name: myproject` | + +**Importer status**: ✓ Handled (lines 24-29) + +### 2. Root Directory + +| tmuxinator | tmuxp | +|---|---| +| `root: ~/project` | `start_directory: ~/project` | +| `project_root: ~/project` | `start_directory: ~/project` | + +**Importer status**: ✓ Handled (lines 31-34) + +### 3. Windows List Key + +| tmuxinator | tmuxp | +|---|---| +| `tabs:` | `windows:` | +| `windows:` | `windows:` | + +**Importer status**: ✓ Handled (lines 56-57) + +### 4. Window Name Syntax + +| tmuxinator | tmuxp | +|---|---| +| `- editor:` (hash key) | `- window_name: editor` | + +**Importer status**: ✓ Handled (lines 79-81) + +### 5. Window Root + +| tmuxinator | tmuxp | +|---|---| +| `root: ./src` (under window hash) | `start_directory: ./src` | + +**Importer status**: ✓ Handled (lines 96-97) + +### 6. Window Pre-Commands + +| tmuxinator | tmuxp | +|---|---| +| `pre: "source .env"` (under window hash) | `shell_command_before: ["source .env"]` | + +**Importer status**: ✓ Handled (lines 92-93) + +### 7. Socket Name + +| tmuxinator | tmuxp | +|---|---| +| `socket_name: myapp` | `socket_name: myapp` | + +**Importer status**: ✓ Handled (lines 51-52). Note: tmuxp doesn't use `socket_name` as a config key in `WorkspaceBuilder` — it's a CLI flag. The importer preserves it but it may not be used. + +### 8. CLI Args / Tmux Options → Config File + +| tmuxinator | tmuxp | +|---|---| +| `cli_args: "-f ~/.tmux.special.conf"` | `config: ~/.tmux.special.conf` | +| `tmux_options: "-f ~/.tmux.special.conf"` | `config: ~/.tmux.special.conf` | + +**Importer status**: ⚠ Partially handled (lines 36-49). Only extracts `-f` flag value via `str.replace("-f", "").strip()`, which is fragile — it would also match strings containing `-f` as a substring (e.g. a path like `/opt/foobar`). Other flags like `-L` (socket name) and `-S` (socket path) that may appear in `cli_args`/`tmux_options` are silently included in the `config` value, which is incorrect — `config` should only be a file path. + +In tmuxinator, `cli_args` is deprecated in favor of `tmux_options` (`project.rb:17-19`). The actual tmux command is built as `"#{tmux_command}#{tmux_options}#{socket}"` (`project.rb:196`), where `socket` handles `-L`/`-S` separately from `socket_name`/`socket_path` config keys. + +### 9. Rbenv + +| tmuxinator | tmuxp | +|---|---| +| `rbenv: 2.7.0` | `shell_command_before: ["rbenv shell 2.7.0"]` | + +**Importer status**: ✓ Handled (lines 72-77) + +### 10. Pre / Pre-Window Commands + +| tmuxinator | tmuxp (correct) | tmuxp (current importer) | +|---|---|---| +| `pre: "cmd"` (session-level, alone) | `before_script: "cmd"` | `shell_command_before: ["cmd"]` (wrong scope) | +| `pre_window: "cmd"` | `shell_command_before: ["cmd"]` | ✓ Correct (when alone) | +| `pre: "cmd"` + `pre_window: "cmd2"` | `before_script: "cmd"` + `shell_command_before: ["cmd2"]` | `shell_command: "cmd"` (invalid key, lost) + `shell_command_before: ["cmd2"]` | + +**Importer status**: ⚠ Bug (lines 59-70). Two issues: +1. When both `pre` and `pre_window` exist, the importer sets `shell_command` (not a valid tmuxp session-level key) for `pre`. The `pre` commands are silently lost. +2. When only `pre` exists, the importer maps it to `shell_command_before` — but `pre` runs once before session creation (like `before_script`), not per-pane. This changes the semantics from "run once" to "run in every pane." + +In tmuxinator, `pre` is a deprecated session-level command run once before creating windows (in `template.erb:19`, inside the new-session conditional). Its deprecation message says it's replaced by `on_project_start` + `on_project_restart`. `pre_window` is a per-pane command run before each pane's commands (in `template.erb:71-73`). These are different scopes. + +**Correct mapping**: +- `pre` → `before_script` (runs once before windows are created) +- `pre_window` → `shell_command_before` (runs per pane) + +### 11. Window as String/List + +| tmuxinator | tmuxp | +|---|---| +| `- editor: vim` | `- window_name: editor` + `panes: [vim]` | +| `- editor: [vim, "git status"]` | `- window_name: editor` + `panes: [vim, "git status"]` | + +**Importer status**: ✓ Handled (lines 83-90) + +### 12. `startup_window` → `focus` + +| tmuxinator | tmuxp | +|---|---| +| `startup_window: editor` | Set `focus: true` on the matching window | + +**Importer status**: ✗ Not handled. Could be translated by finding the matching window and adding `focus: true`. + +### 13. `startup_pane` → `focus` + +| tmuxinator | tmuxp | +|---|---| +| `startup_pane: 1` | Set `focus: true` on the matching pane | + +**Importer status**: ✗ Not handled. Could be translated by finding the pane at the given index and adding `focus: true`. + +### 14. `pre_tab` → `shell_command_before` + +| tmuxinator | tmuxp | +|---|---| +| `pre_tab: "source .env"` | `shell_command_before: ["source .env"]` | + +**Importer status**: ✗ Not handled. `pre_tab` is a deprecated predecessor to `pre_window` (not an alias — it was renamed). + +### 15. `rvm` → `shell_command_before` + +| tmuxinator | tmuxp | +|---|---| +| `rvm: ruby-2.7@mygemset` | `shell_command_before: ["rvm use ruby-2.7@mygemset"]` | + +**Importer status**: ✗ Not handled. Only `rbenv` is mapped; `rvm` is ignored. + +### 16. `socket_path` + +| tmuxinator | tmuxp | +|---|---| +| `socket_path: /tmp/my.sock` | (CLI `-S /tmp/my.sock`) | + +**Importer status**: ✗ Not handled. `socket_path` is a tmuxinator config key (takes precedence over `socket_name`) but the importer ignores it. tmuxp takes socket path via CLI `-S` flag only. + +### 17. `attach: false` → CLI Flag + +| tmuxinator | tmuxp | +|---|---| +| `attach: false` | `tmuxp load -d` (detached mode) | + +**Importer status**: ✗ Not handled. Could add a comment or warning suggesting `-d` flag. + +### 18. YAML Aliases/Anchors + +| tmuxinator | tmuxp | +|---|---| +| `defaults: &defaults` + `<<: *defaults` | Same (YAML 1.1 feature) | + +**Importer status**: ✓ Handled transparently. YAML aliases are resolved by the YAML parser before the importer sees the dict. No special handling needed. However, tmuxp's test fixtures have **no coverage** of this pattern — real tmuxinator configs commonly use anchors to DRY up repeated settings (see `tmuxinator/spec/fixtures/sample_alias.yml`). + +### 19. Numeric/Emoji Window Names + +| tmuxinator | tmuxp | +|---|---| +| `- 222:` or `- true:` or `- 🍩:` | `window_name: "222"` or `window_name: "True"` or `window_name: "🍩"` | + +**Importer status**: ⚠ Potentially handled but **untested**. YAML parsers coerce bare `222` to int and `true` to bool. tmuxinator handles this via Ruby's `.to_s` method. The importer iterates `window_dict.items()` (line 80) which will produce `(222, ...)` or `(True, ...)` — the `window_name` will be an int/bool, not a string. tmuxp's builder may or may not handle non-string window names correctly. Needs test coverage. + +## Limitations (tmuxp Needs to Add Support) + +These are features that cannot be imported because tmuxp lacks the underlying capability. + +### 1. Lifecycle Hooks + +**What it does in tmuxinator**: Five project hooks (`on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop`) allow running arbitrary commands at different lifecycle stages. + +**Why it can't be imported**: tmuxp only has `before_script` (partial equivalent to `on_project_first_start`). The exit/stop/restart hooks require tmux `set-hook` integration or signal trapping that tmuxp doesn't support. + +**What tmuxp would need to add**: Session-level `on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop` config keys, plus builder logic to execute them at appropriate points. For exit/stop hooks, tmuxp would need a `stop` command and tmux `set-hook` for `client-detached`. + +### 2. Pane Synchronization + +**What it does in tmuxinator**: `synchronize: true/before/after` on windows enables `synchronize-panes` option, with control over whether sync happens before or after pane commands. + +**Why it can't be imported**: tmuxp has no `synchronize` config key. While users can set `synchronize-panes` via `options`, the before/after timing distinction requires builder support. + +**What tmuxp would need to add**: `synchronize` key on windows with `before`/`after`/`true`/`false` values. Builder should call `set-window-option synchronize-panes on` at the appropriate point. + +### 3. Pane Titles + +**What it does in tmuxinator**: Named pane syntax (`pane_name: command`) sets pane titles via `select-pane -T`. Session-level `enable_pane_titles`, `pane_title_position`, `pane_title_format` control display. + +**Why it can't be imported**: tmuxp has no pane title support. + +**What tmuxp would need to add**: Per-pane `title` key, session-level title configuration. Builder calls `select-pane -T ` after pane creation. + +### 4. ERB Templating + +**What it does in tmuxinator**: Config files are processed through ERB before YAML parsing. Supports `<%= @settings["key"] %>` interpolation and full Ruby expressions. Variables passed via `key=value` CLI args. + +**Why it can't be imported**: ERB is a Ruby templating system. The importer receives already-parsed YAML (ERB would have already been processed in Ruby). When importing a raw tmuxinator config file with ERB syntax, YAML parsing will fail. + +**What tmuxp would need to add**: Either a Jinja2 templating pass, Python string formatting, or environment variable expansion in config values. This is a significant architectural feature. + +### 5. Wemux Support + +**What it does in tmuxinator**: `tmux_command: wemux` uses an alternate template and wemux-specific commands. + +**Why it can't be imported**: tmuxp and libtmux are tightly bound to the `tmux` binary. + +**What tmuxp would need to add**: Configurable tmux binary path in libtmux's `Server` class. + +### 6. `--no-pre-window` Flag + +**What it does in tmuxinator**: Skips all `pre_window` commands when starting a session. Useful for debugging. + +**Why it can't be imported**: This is a runtime behavior, not a config key. + +**What tmuxp would need to add**: `--no-shell-command-before` CLI flag on `tmuxp load`. + +## Summary Table + +| tmuxinator Feature | Import Status | Classification | +|---|---|---| +| `name`/`project_name` → `session_name` | ✓ Handled | Difference | +| `root`/`project_root` → `start_directory` | ✓ Handled | Difference | +| `tabs` → `windows` | ✓ Handled | Difference | +| `socket_name` | ✓ Handled | Difference | +| `cli_args`/`tmux_options` → `config` | ⚠ Partial | Difference (needs fix) | +| `rbenv` → `shell_command_before` | ✓ Handled | Difference | +| `pre` → `before_script` | ⚠ Bug: maps to wrong key (`shell_command_before` alone, `shell_command` with `pre_window`) | Difference (needs fix) | +| Window hash syntax | ✓ Handled | Difference | +| Window `root`/`pre`/`layout`/`panes` | ✓ Handled | Difference | +| `rvm` → `shell_command_before` | ✗ Missing | Difference (needs add) | +| `pre_tab` → `shell_command_before` | ✗ Missing | Difference (needs add) | +| `startup_window` → `focus` | ✗ Missing | Difference (needs add) | +| `startup_pane` → `focus` | ✗ Missing | Difference (needs add) | +| `socket_path` | ✗ Missing | Difference (needs add) | +| `attach: false` | ✗ Missing | Difference (needs add) | +| YAML aliases/anchors | ✓ Transparent (YAML parser resolves) | No action needed | +| Numeric/emoji window names | ⚠ Untested (YAML type coercion risk) | Difference (needs tests) | +| `on_project_*` hooks | ✗ Missing | **Limitation** | +| `synchronize` | ✗ Missing (`true`/`before` deprecated in tmuxinator → `after` recommended) | **Limitation** | +| `enable_pane_titles` / titles | ✗ Missing | **Limitation** | +| ERB templating | ✗ Missing | **Limitation** | +| `tmux_command` (wemux) | ✗ Missing | **Limitation** | +| `--no-pre-window` | N/A (runtime flag) | **Limitation** | diff --git a/notes/parity-teamocil.md b/notes/parity-teamocil.md new file mode 100644 index 0000000000..1d590bdee0 --- /dev/null +++ b/notes/parity-teamocil.md @@ -0,0 +1,258 @@ +# Teamocil Parity Analysis + +*Last updated: 2026-03-07* +*Teamocil version analyzed: 1.4.2* +*tmuxp version: 1.64.0* + +## Version History Context + +Teamocil has had two distinct config formats: + +- **v0.x** (pre-1.0): Wrapped in `session:` key, used `splits` for panes, `filters` for before/after commands, `cmd` for pane commands +- **v1.x** (1.0–1.4.2): Simplified format — top-level `windows`, `panes` with `commands` key, `focus` support, window `options` + +The current tmuxp importer (`importers.py:import_teamocil`) **targets the v0.x format**. It handles the `session:` wrapper, `splits`, `filters`, and `cmd` keys — all of which are v0.x-only constructs. It does **not** handle the v1.x format natively, though v1.x configs may partially work since the `windows`/`panes` structure is similar. + +Note: teamocil v1.x does not create new sessions — it **renames** the current session (`rename-session`) and adds windows to it. This is fundamentally different from tmuxp/tmuxinator which create fresh sessions. + +**v1.0 rewrite context** (from teamocil README): Teamocil 1.0 was a complete rewrite that explicitly dropped several v0.x features: no hook system (pre/post execution scripts), no pane-specific environment variables (`with_env_var`), no inline scripting or complex DSL, and no `cmd_separator` customization. The focus narrowed to core declarative window/pane creation. + +## Features teamocil has that tmuxp lacks + +### 1. Session Rename (Not Create) + +**Source**: `lib/teamocil/tmux/session.rb:18-20` + +teamocil does not create a new session. It **renames** the current session via `rename-session` and adds windows to it. If no `name` is provided, it auto-generates one: `"teamocil-session-#{rand(1_000_000)}"`. + +**Gap**: tmuxp always creates a new session (unless appending with `--append`). There is no way to rename and populate the current session. + +### 2. `--here` Option (Reuse Current Window) + +**Source**: `lib/teamocil/tmux/window.rb`, `lib/teamocil/utils/option_parser.rb` + +```bash +teamocil --here my-layout +``` + +When `--here` is specified: +- First window: **renames** current window (`rename-window`) instead of creating a new one +- First window: sends `cd "<root>"` + `Enter` via `send-keys` to change directory (since no `-c` flag is available on an existing window) +- First window: decrements the window count when calculating base indices for subsequent windows +- Subsequent windows: created normally with `new-window` + +**Gap**: tmuxp always creates new windows. There is no way to populate the current window with a layout. + +**WorkspaceBuilder requirement**: Add `--here` CLI flag. For first window, use `rename-window` + `send-keys cd` instead of `new_window()`. Must also adjust window index calculation. This would require special handling in `WorkspaceBuilder.first_window_pass()`. + +### 3. `--show` Option (Show Raw Config) + +**Source**: `lib/teamocil/layout.rb` + +```bash +teamocil --show my-layout +``` + +Outputs the raw YAML content of the layout file. + +**Gap**: tmuxp has no equivalent. Users can `cat` the file manually. + +### 4. `--debug` Option (Show Commands Without Executing) + +**Source**: `lib/teamocil/layout.rb` + +```bash +teamocil --debug my-layout +``` + +Outputs the tmux commands that would be executed, one per line, without running them. + +**Gap**: tmuxp has no dry-run mode. Since tmuxp uses libtmux API calls rather than generating command strings, implementing this would require a logging/recording mode in the builder. + +Note: teamocil also has `--list` (lists available layouts in `~/.teamocil/`) and `--edit` (opens layout file in `$EDITOR`). Both are available in tmuxp (`tmuxp ls`, `tmuxp edit`). + +### 5. Window-Level `focus` Key + +**Source**: `lib/teamocil/tmux/window.rb` + +```yaml +windows: + - name: editor + focus: true + panes: + - vim +``` + +**Gap**: tmuxp **does** support `focus: true` on windows. **No gap**. + +Note: teamocil handles window focus at the session level in `session.rb:24-25` — after all windows are created, it finds the focused window and issues `select-window`. tmuxp handles this the same way. + +### 6. Pane-Level `focus` Key + +**Source**: `lib/teamocil/tmux/pane.rb` + +```yaml +panes: + - commands: + - vim + focus: true +``` + +**Gap**: tmuxp **does** support `focus: true` on panes. **No gap**. + +### 7. Window-Level `options` Key + +**Source**: `lib/teamocil/tmux/window.rb` + +```yaml +windows: + - name: editor + options: + main-pane-width: '100' +``` + +Maps to `set-window-option -t <window> <key> <value>`. + +**Gap**: tmuxp **does** support `options` on windows. **No gap**. + +### 8. Layout Applied After Each Pane + +**Source**: `lib/teamocil/tmux/pane.rb:9` + +teamocil applies `select-layout` after EACH pane is created (not once at the end). This means the layout is re-applied after every `split-window`, keeping panes evenly distributed as they're added. tmuxp applies layout once after all panes are created (`builder.py:511`). + +**Gap**: This is a behavioral difference. For named layouts (`main-vertical`, `tiled`, etc.) the result is the same. For custom layout strings, the timing matters — tmuxp's approach is correct for custom strings since the string encodes the final geometry. **No action needed** — tmuxp's behavior is actually better for custom layouts. + +### 9. Multiple Commands Joined by Semicolon + +**Source**: `lib/teamocil/tmux/pane.rb` + +Teamocil joins multiple pane commands with `; ` and sends them as a single `send-keys` invocation: + +```ruby +# Pane with commands: ["cd /path", "vim"] +# → send-keys "cd /path; vim" +``` + +**Gap**: tmuxp sends each command separately via individual `pane.send_keys()` calls. This is actually more reliable (each command gets its own Enter press), so this is a **behavioral difference** rather than a gap. + +### 10. Root Path Expansion + +**Source**: `lib/teamocil/tmux/window.rb:8` + +teamocil expands `root` to absolute path via `File.expand_path(root)` at window initialization. This resolves `~` and relative paths before passing to `new-window -c`. + +**Gap**: tmuxp also does this via `expandshell()` in `loader.py` (`os.path.expandvars(os.path.expanduser(value))`). **No gap** — both tools expand paths. + +## v0.x vs v1.x Format Differences + +| Feature | v0.x | v1.x (current) | +|---|---|---| +| Top-level wrapper | `session:` key | None (top-level `windows`) | +| Session name | `session.name` | `name` | +| Session root | `session.root` | (none, per-window only) | +| Panes key | `splits` | `panes` | +| Pane commands | `cmd` (string or list) | `commands` (list) | +| Before commands | `filters.before` (list) | (none) | +| After commands | `filters.after` (list) | (none) | +| Pane width | `width` (number, horizontal split %) | (none) | +| Pane height | `height` (number, vertical split %) | (none) | +| Pane target | `target` (pane to split from) | (none) | +| Window clear | `clear` (boolean) | (none) | +| TEAMOCIL env var | `with_env_var` (default true, exports `TEAMOCIL=1`) | (none) | +| Command separator | `cmd_separator` (default `"; "`) | Hardcoded `"; "` | +| ERB templating | Yes (layouts processed as ERB) | No | +| Pane focus | (none) | `focus` (boolean) | +| Window focus | (none) | `focus` (boolean) | +| Window options | (none) | `options` (hash) | +| Pane string shorthand | (none) | `- command_string` | + +## Import Behavior Analysis + +### Current Importer: `importers.py:import_teamocil` + +**What it handles (v0.x format):** + +| teamocil key | Mapped to | Status | +|---|---|---| +| `session` (wrapper) | Unwrapped | ✓ Correct | +| `session.name` | `session_name` | ✓ Correct | +| `session.root` | `start_directory` | ✓ Correct | +| Window `name` | `window_name` | ✓ Correct | +| Window `root` | `start_directory` | ✓ Correct | +| Window `layout` | `layout` | ✓ Correct | +| Window `clear` | `clear` | ⚠ Preserved but tmuxp doesn't use `clear` | +| Window `filters.before` | `shell_command_before` | ✓ Correct | +| Window `filters.after` | `shell_command_after` | ⚠ tmuxp doesn't support `shell_command_after` | +| `splits` → `panes` | `panes` | ✓ Correct | +| Pane `cmd` | `shell_command` | ✓ Correct | +| Pane `width` | Dropped | ⚠ Silently dropped with TODO comment | + +**What it misses:** + +| Feature | Issue | +|---|---| +| v1.x `commands` key | Not handled — only `cmd` (v0.x) is mapped | +| v1.x pane string shorthand | Not handled — expects dict with `cmd` key | +| v1.x `focus` (window) | Not imported | +| v1.x `focus` (pane) | Not imported | +| v1.x `options` (window) | Not imported | +| Session-level `name` (without `session:` wrapper) | Handled (uses `.get("name")`) | +| v0.x `focus` (pane) | ✓ Accidentally preserved (in-place dict mutation keeps unhandled keys) | +| v0.x `target` (pane) | ✓ Accidentally preserved (same reason) | +| `with_env_var` (v0.x) | Not handled — silently dropped by importer | +| `cmd_separator` (v0.x) | Not handled — silently dropped by importer | + +### Code Quality Issues in Importer + +1. **Lines 144-149**: The `filters.before` and `filters.after` handling has redundant `for _b in` loops that serve no purpose. The inner assignment just reassigns the same value each iteration: + ```python + for _b in w["filters"]["before"]: + window_dict["shell_command_before"] = w["filters"]["before"] + ``` + This iterates N times but sets the same value each time. It should be a direct assignment. + +2. **Lines 140-141**: `clear` is preserved in the config but tmuxp has no handling for it. It will be silently ignored during workspace building. + +3. **Lines 147-149**: `shell_command_after` is set from `filters.after` but is not a tmuxp-supported key. It will be silently ignored during workspace building. + +4. **Lines 161-163**: `width` is silently dropped with a TODO comment. No warning to the user. + +5. **v1.x incompatibility**: The importer assumes v0.x format. A v1.x config with `commands` instead of `cmd`, or string panes, will not import correctly: + - String pane `"git status"` → error (tries to access `p["cmd"]` on a string) + - `commands: [...]` → not mapped to `shell_command` + +6. **No format detection**: The importer doesn't attempt to detect whether the input is v0.x or v1.x format. + +## WorkspaceBuilder Requirements for Full Parity + +### Already Supported (No Changes Needed) + +- Window `focus` — ✓ +- Pane `focus` — ✓ +- Window `options` — ✓ +- Window `layout` — ✓ +- Window `root`/`start_directory` — ✓ +- Pane commands — ✓ + +### Gaps Requiring New Features + +1. **Session rename mode** — teamocil renames the current session rather than creating a new one. tmuxp always creates a fresh session. + +2. **`--here` flag** — Reuse current window for first window of layout. Requires `WorkspaceBuilder` to rename instead of create, and send `cd` for root directory. + +3. **`--debug` / dry-run mode** — Log commands without executing. Architectural challenge since tmuxp uses libtmux API, not command strings. + +4. **`shell_command_after`** — Commands run after pane commands. The importer preserves this from teamocil's `filters.after` but tmuxp has no support for it in the builder. + +### Import-Only Fixes (No Builder Changes) + +5. **v1.x format support** — The importer should handle: + - `commands` key (v1.x) in addition to `cmd` (v0.x) + - String pane shorthand + - `focus` on windows and panes + - `options` on windows + +6. **Redundant loop cleanup** — Fix the `filters` handling code. + +7. **Drop unsupported keys with warnings** — Instead of silently preserving `clear` or dropping `width`, warn the user. diff --git a/notes/parity-tmuxinator.md b/notes/parity-tmuxinator.md new file mode 100644 index 0000000000..bd1f0ce3de --- /dev/null +++ b/notes/parity-tmuxinator.md @@ -0,0 +1,280 @@ +# Tmuxinator Parity Analysis + +*Last updated: 2026-03-07* +*Tmuxinator version analyzed: 3.3.7 (supports tmux 1.5–3.6a)* +*tmuxp version: 1.64.0* + +## Features tmuxinator has that tmuxp lacks + +### 1. Project Hooks (Lifecycle Events) + +**Source**: `lib/tmuxinator/hooks/project.rb`, `assets/template.erb` + +tmuxinator has 5 lifecycle hooks: + +| Hook | Description | tmuxp equivalent | +|---|---|---| +| `on_project_start` | Runs on every `start` invocation | No equivalent | +| `on_project_first_start` | Runs only when session doesn't exist yet | `before_script` (partial — runs before windows, but kills session on failure) | +| `on_project_restart` | Runs when attaching to existing session | Plugin `reattach()` (requires writing a plugin) | +| `on_project_exit` | Runs when detaching from session | No equivalent | +| `on_project_stop` | Runs on `tmuxinator stop` | No equivalent (tmuxp has no `stop` command) | + +**Gap**: tmuxp's `before_script` is a partial equivalent of `on_project_first_start` — it runs before windows are created and kills the session on failure. tmuxp has no equivalent for `on_project_start` (runs every time, including reattach), no hooks for detach/exit/stop events, and no distinction between first start vs. restart. + +**Execution order from `template.erb`**: `cd root` → `on_project_start` → (if new session: `pre` → `on_project_first_start` → `new-session` → create windows → build panes → select startup → attach) OR (if existing: `on_project_restart`) → `post` → `on_project_exit`. Note that `post` and `on_project_exit` run on every invocation (outside the new/existing conditional). + +**WorkspaceBuilder requirement**: Add config keys for `on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop`. The exit/stop hooks require shell integration (trap signals, set-hook in tmux). + +### 2. Stop/Kill Session Command + +**Source**: `lib/tmuxinator/cli.rb` (`stop`, `stop_all`), `assets/template-stop.erb` + +tmuxinator provides: + +```bash +tmuxinator stop <project> # Kill specific session + run on_project_stop hook +tmuxinator stop-all # Kill all tmuxinator-managed sessions +``` + +**Gap**: tmuxp has no `stop` or `kill` command. Users must use `tmux kill-session` directly, which skips any cleanup hooks. + +### 3. Session Name Override at Load Time + +**Source**: `lib/tmuxinator/cli.rb` (`--name` / `-n` option) + +```bash +tmuxinator start myproject --name custom-session-name +``` + +**Gap**: tmuxp has `tmuxp load -s <name>` which provides this. **No gap** — tmuxp already supports this. + +### 4. Startup Window / Startup Pane Selection + +**Source**: `lib/tmuxinator/project.rb` (`startup_window`, `startup_pane`) + +```yaml +startup_window: editor # Select this window after build +startup_pane: 1 # Select this pane within the startup window +``` + +**Gap**: tmuxp supports `focus: true` on windows and panes (boolean), which is equivalent but syntactically different. The `startup_window` key allows referencing by window name or numeric index (rendered as `"#{name}:#{value}"`, defaults to `base_index` if omitted). The `startup_pane` is relative to the startup window (rendered as `"#{startup_window}.#{value}"`, defaults to `pane_base_index`). **Partial parity** — tmuxp can achieve this but uses a different mechanism (`focus` key on individual windows/panes rather than a centralized key). + +### 5. Pane Synchronization + +**Source**: `lib/tmuxinator/window.rb` (`synchronize`) + +```yaml +windows: + - editor: + synchronize: true # or "before" or "after" + panes: + - vim + - vim +``` + +- `synchronize: true` / `synchronize: before` — enable pane sync before running pane commands (**deprecated** in tmuxinator `project.rb:21-29`) +- `synchronize: after` — enable pane sync after running pane commands (recommended) + +Note: tmuxinator deprecates `synchronize: true` and `synchronize: before` in favor of `synchronize: after`. The deprecation message says `before` was the original behavior but `after` is the recommended pattern. Import should still honor the original semantics of each value. + +**Gap**: tmuxp has no `synchronize` config key. Users would need to set `synchronize-panes on` via `options` manually, but this doesn't support the before/after distinction. + +**WorkspaceBuilder requirement**: Add `synchronize` key to window config with `before`/`after`/`true`/`false` values. + +### 6. Pane Titles + +**Source**: `lib/tmuxinator/project.rb`, `lib/tmuxinator/pane.rb` + +```yaml +enable_pane_titles: true +pane_title_position: top # default: "top" +pane_title_format: "#{pane_index}: #{pane_title}" # this is the default format +windows: + - editor: + panes: + - my-editor: vim # "my-editor" becomes the pane title +``` + +**Gap**: tmuxp has no pane title support. Named panes in tmuxinator (hash syntax: `pane_name: command`) set both a title and commands. + +**WorkspaceBuilder requirement**: Add session-level `enable_pane_titles`, `pane_title_position`, `pane_title_format` keys. Add per-pane `title` key. Issue `select-pane -T <title>` after pane creation. + +### 7. ERB Templating / Variable Interpolation + +**Source**: `lib/tmuxinator/project.rb` (`parse_settings`, `render_template`) + +```bash +tmuxinator start myproject env=production port=3000 +``` + +```yaml +# config.yml +root: ~/apps/<%= @settings["app"] %> +windows: + - server: + panes: + - rails server -p <%= @settings["port"] || 3000 %> +``` + +**Gap**: tmuxp has no config templating. Environment variable expansion (`$VAR`) is supported in `start_directory` paths, but not arbitrary variable interpolation in config values. + +**WorkspaceBuilder requirement**: This is an architectural difference. tmuxp could support Jinja2 templating or Python string formatting, but this is a significant feature addition. + +### 8. Wemux Support + +**Source**: `lib/tmuxinator/wemux_support.rb`, `assets/wemux_template.erb` + +```yaml +tmux_command: wemux +``` + +**Gap**: tmuxp has no wemux support. libtmux is tightly bound to the `tmux` command. + +**WorkspaceBuilder requirement**: Allow configurable tmux command binary. Requires libtmux changes. + +### 9. Debug / Dry-Run Output + +**Source**: `lib/tmuxinator/cli.rb` (`debug`) + +```bash +tmuxinator debug myproject +``` + +Outputs the generated shell script without executing it. + +**Gap**: tmuxp has no dry-run mode. Since tmuxp uses API calls rather than script generation, a dry-run would need to log the libtmux calls that *would* be made. + +### 10. Config Management Commands + +**Source**: `lib/tmuxinator/cli.rb` + +| Command | Description | +|---|---| +| `tmuxinator new <name>` | Create new config from template | +| `tmuxinator copy <src> <dst>` | Copy existing config | +| `tmuxinator delete <name>` | Delete config (with confirmation) | +| `tmuxinator implode` | Delete ALL configs | +| `tmuxinator stop <project>` | Stop session + run hooks | +| `tmuxinator stop-all` | Stop all managed sessions | +| `tmuxinator doctor` | Check system setup (tmux installed, version) | +| `tmuxinator completions` | Shell completion helper | + +**Gap**: tmuxp has `edit` but not `new`, `copy`, `delete`, `implode`, `stop`, or `doctor` commands. + +Additional CLI flags on `start`: +- `--append` — append windows to existing session (tmuxp has `--append`) +- `--no-pre-window` — skip pre_window commands (tmuxp lacks this) +- `--project-config` / `-p` — use specific config file (tmuxp uses positional arg) +- `--suppress-tmux-version-warning` — skip tmux version check + +Additional flags on `list`: +- `--active` / `-a` — filter by active sessions (tmuxp lacks this) +- `--newline` / `-n` — one entry per line + +### 11. `--no-pre-window` Flag + +**Source**: `lib/tmuxinator/cli.rb` + +```bash +tmuxinator start myproject --no-pre-window +``` + +Skips `pre_window` commands. Useful for debugging. + +Note: tmuxinator's `pre_window` method has a fallback chain (`project.rb:175-186`): `pre_window` → `pre_tab` → `rbenv` → `rvm` (highest priority first, using Ruby's `||` operator). The `--no-pre-window` flag disables all of these, not just `pre_window`. + +**Gap**: tmuxp has no equivalent flag to skip `shell_command_before`. + +### 12. Create Config from Running Session + +**Source**: `lib/tmuxinator/cli.rb` (`new <name> <session>`) + +```bash +tmuxinator new myproject existing-session-name +``` + +Creates a config file pre-populated from a running tmux session. Note: tmuxinator captures only the window/pane structure and names, not running commands. + +**Gap**: tmuxp has `tmuxp freeze` which exports to YAML/JSON with more detail (captures pane working directories and current commands). Different approach, functionally equivalent. + +## Import Behavior Analysis + +### Current Importer: `importers.py:import_tmuxinator` + +**What it handles:** + +| tmuxinator key | Mapped to | Status | +|---|---|---| +| `project_name` / `name` | `session_name` | ✓ Correct | +| `project_root` / `root` | `start_directory` | ✓ Correct | +| `cli_args` / `tmux_options` | `config` (extracts `-f`) | ⚠ Only handles `-f` flag, ignores `-L`, `-S` | +| `socket_name` | `socket_name` | ✓ Correct | +| `tabs` → `windows` | `windows` | ✓ Correct | +| `pre` + `pre_window` | `shell_command` + `shell_command_before` | ⚠ `shell_command` is not a valid tmuxp session key; should be `before_script` + `shell_command_before` | +| `pre` (alone) | `shell_command_before` | ⚠ Wrong scope: `pre` runs once (like `before_script`), not per-pane | +| `rbenv` | appended to `shell_command_before` | ✓ Correct | +| Window hash key | `window_name` | ✓ Correct | +| Window `pre` | `shell_command_before` | ✓ Correct | +| Window `panes` | `panes` | ✓ Correct | +| Window `root` | `start_directory` | ✓ Correct | +| Window `layout` | `layout` | ✓ Correct | + +**What it misses or handles incorrectly:** + +| tmuxinator key | Issue | +|---|---| +| `attach` | Not imported. tmuxp uses CLI flags instead. | +| `startup_window` | Not imported. tmuxp uses `focus: true` on windows. | +| `startup_pane` | Not imported. tmuxp uses `focus: true` on panes. | +| `tmux_command` | Not imported. tmuxp has no equivalent. | +| `socket_path` | Not imported. tmuxp takes this via CLI. | +| `pre_tab` | Not imported (deprecated predecessor to `pre_window`). | +| `rvm` | Not imported (only `rbenv` is handled). | +| `post` | Not imported. tmuxp has no equivalent. | +| `synchronize` | Not imported. tmuxp has no equivalent. | +| `enable_pane_titles` | Not imported. tmuxp has no equivalent. | +| `pane_title_position` | Not imported. tmuxp has no equivalent. | +| `pane_title_format` | Not imported. tmuxp has no equivalent. | +| `on_project_start` | Not imported. tmuxp has no equivalent. | +| `on_project_first_start` | Not imported. Could map to `before_script`. | +| `on_project_restart` | Not imported. tmuxp has no equivalent. | +| `on_project_exit` | Not imported. tmuxp has no equivalent. | +| `on_project_stop` | Not imported. tmuxp has no equivalent. | +| Named panes (hash syntax) | Not imported. Pane names/titles are lost. | +| ERB templating | Not handled. YAML parsing will fail on ERB syntax. | +| `pre` mapping | Bug: maps to `shell_command_before` (per-pane) instead of `before_script` (once); combo with `pre_window` uses invalid `shell_command` key | + +### Code Quality Issues in Importer + +1. **Lines 59-70 (`pre` handling)**: Two bugs: + - When both `pre` and `pre_window` exist (line 60), the importer sets `tmuxp_workspace["shell_command"]` — but `shell_command` is not a valid session-level tmuxp key. The `pre` commands are silently lost. + - When only `pre` exists (line 68), it maps to `shell_command_before` — but tmuxinator's `pre` runs *once* before session creation (`template.erb:19`), not per-pane. The correct mapping is `before_script`. + +2. **Lines 36-49**: The `cli_args`/`tmux_options` handler only extracts `-f` (config file). It ignores `-L` (socket name) and `-S` (socket path) which could also appear in these fields. + +3. **Line 79-101**: The window iteration uses `for k, v in window_dict.items()` which assumes windows are always dicts with a single key (the window name). This is correct for tmuxinator's format but fragile — if a window dict has multiple keys, only the last one is processed. + +4. **Missing `pre_tab`**: The `pre_tab` deprecated predecessor to `pre_window` is not handled. + +5. **Missing `rvm`**: Only `rbenv` is imported; `rvm` (another deprecated but still functional key) is ignored. In tmuxinator, `rvm` maps to `rvm use #{value}` (`project.rb:181`). + +6. **No validation or warnings**: The importer silently drops unsupported keys with no feedback to the user. + +## WorkspaceBuilder Requirements for 100% Feature Support + +### Must-Have for Parity + +1. **Pane synchronization** (`synchronize` window key) — `set-window-option synchronize-panes on/off` +2. **Pane titles** — `select-pane -T <title>`, `set-option pane-border-status top`, `set-option pane-border-format <fmt>` +3. **Startup window/pane selection** — Already achievable via `focus: true`, but could add `startup_window`/`startup_pane` as aliases +4. **Stop command** — `tmuxp stop <session>` to kill session + +### Nice-to-Have + +5. **Lifecycle hooks** — `on_project_start`, `on_project_first_start`, `on_project_restart`, `on_project_exit`, `on_project_stop` +6. **Config templating** — Jinja2 or Python format string support for config values +7. **Debug/dry-run** — Log tmux commands without executing +8. **Config management** — `tmuxp new`, `tmuxp copy`, `tmuxp delete` commands +9. **`--no-shell-command-before`** flag — Skip `shell_command_before` for debugging +10. **Custom tmux binary** — `tmux_command` key for wemux/byobu support (requires libtmux changes) diff --git a/notes/plan.md b/notes/plan.md new file mode 100644 index 0000000000..a029b35d21 --- /dev/null +++ b/notes/plan.md @@ -0,0 +1,182 @@ +# Parity Implementation Plan + +*Last updated: 2026-03-15* +*Based on: parity-tmuxinator.md, parity-teamocil.md, import-tmuxinator.md, import-teamocil.md* + +## libtmux Limitations + +### L1. No `Pane.set_title()` Method — **RESOLVED in libtmux v0.55.0** + +- **Status**: `Pane.set_title(title)` added at `pane.py:834-859`. Unblocks T2 (pane titles). +- ~~**Blocker**: libtmux has no method wrapping `select-pane -T <title>`.~~ +- ~~**Blocks**: Pane titles (tmuxinator feature: named pane syntax `pane_name: command` → `select-pane -T`).~~ +- ~~**Required**: Add `Pane.set_title(title: str)` method.~~ + +### L2. Hardcoded tmux Binary Path — **RESOLVED in libtmux v0.55.0** + +- **Status**: `Server(tmux_bin=...)` added at `server.py:131-146`. Unblocks tmuxinator `tmux_command` support. +- ~~**Blocker**: `shutil.which("tmux")` is hardcoded in two independent code paths.~~ +- ~~**Blocks**: Wemux support (tmuxinator `tmux_command: wemux`).~~ +- ~~**Required**: Add optional `tmux_bin` parameter to `Server.__init__()`.~~ + +### L3. No Dry-Run / Command Preview Mode — **RESOLVED in libtmux v0.55.0** + +- **Status**: Pre-execution `logger.debug` added at `common.py:263-268`. Unblocks T9 (dry-run mode). +- ~~**Blocker**: `tmux_cmd` always executes commands with no pre-execution logging.~~ +- ~~**Blocks**: `--debug` / dry-run mode (both tmuxinator and teamocil have this).~~ +- ~~**Required**: Add pre-execution logging at DEBUG level.~~ +- **Note**: Since tmuxp uses libtmux API calls (not command strings), a true dry-run would require a recording layer in `WorkspaceBuilder` that logs each API call. This is architecturally different from tmuxinator/teamocil's approach and may not be worth full parity. + +### L4. Available APIs (No Blockers) + +These libtmux APIs already exist and do NOT need changes: + +| API | Location | Supports | +|---|---|---| +| `Session.rename_session(name)` | `session.py:422` | teamocil session rename mode | +| `Window.rename_window(name)` | `window.py:462` | teamocil `--here` flag | +| `Pane.resize(height, width)` | `pane.py:217` | teamocil v0.x pane `width` | +| `Pane.send_keys(cmd, enter)` | `pane.py:423` | All command sending | +| `Pane.select()` | `pane.py:586` | Pane focus | +| `Window.set_option(key, val)` | `options.py:593` (OptionsMixin) | `synchronize-panes`, window options | +| `Session.set_hook(hook, cmd)` | `hooks.py:118` (HooksMixin) | Lifecycle hooks (`client-detached`, etc.) | +| `Session.set_option(key, val)` | `options.py:593` (OptionsMixin) | `pane-border-status`, `pane-border-format` | +| `HooksMixin` on Session/Window/Pane | `session.py:55`, `window.py:56`, `pane.py:51` | All entities inherit hooks | +| `HooksMixin.set_hooks()` (bulk) | `hooks.py:437` | Efficient multi-hook setup (dict/list input) | +| `Session.set_environment(key, val)` | `common.py:63` (EnvironmentMixin) | Session-level env vars (teamocil `with_env_var`) | +| `Pane.clear()` | `pane.py:869` | Sends `reset` to clear pane (teamocil `clear`) | +| `Pane.reset()` | `pane.py:874` | `send-keys -R \; clear-history` (full reset) | +| `Pane.split(target=...)` | `pane.py:634` | Split targeting (teamocil v0.x `target`) | + +## tmuxp Limitations + +### T1. `synchronize` Config Key ✅ Resolved + +Resolved in `feat(loader[expand])` — `expand()` desugars `synchronize: true/before/after` into `options`/`options_after` with `synchronize-panes: on`. The builder's existing `options` and `options_after` handling applies the setting. Tests: `test_synchronize` (builder integration), `test_expand_synchronize` (unit). + +### T2. Pane Title Config Key ✅ Resolved + +Resolved in `feat(loader[expand],builder[iter_create_panes])` — Session-level `enable_pane_titles`/`pane_title_position`/`pane_title_format` desugared in `expand()` into per-window `options` (`pane-border-status`, `pane-border-format`). Pane-level `title` handled in `iter_create_panes()` via `pane.set_title()`. Tests: `test_pane_titles` (builder integration), `test_expand_pane_titles`/`_disabled`/`_defaults` (unit). + +### T3. `shell_command_after` Config Key ✅ Resolved + +Resolved in `feat(builder[config_after_window],loader[expand])` — `expand()` normalizes `shell_command_after` via `expand_cmd()`, then `config_after_window()` sends each command to every pane in the window. Tests: `test_shell_command_after` (builder integration), `test_expand_shell_command_after` (unit). + +### T4. No Session Rename Mode / `--here` CLI Flag ✅ Resolved + +Resolved in `feat(cli[load],builder)` — `--here` flag added to CLI, passed through `load_workspace` → `_dispatch_build` → `build()` → `iter_create_windows()`. In `build()`, renames session to match config. In `iter_create_windows()`, reuses active window for first window (rename + cd) instead of `first_window_pass` trick. Skips session-exists prompt. Tests: `test_here_mode` (builder integration). + +### ~~T5. No `stop` / `kill` CLI Command~~ ✅ + +Resolved in `feat(cli[stop])` — `tmuxp stop <session-name>` command added. Follows `freeze.py` pattern: optional session-name positional arg with current-session fallback via `util.get_session()`, `-L`/`-S` socket pass-through. Kills session via `session.kill()`. Uses `Colors` semantic hierarchy for output (green success + magenta session name). Lifecycle hooks (T6 `on_project_stop`) will layer on top. + +### T6. Lifecycle Hook Config Keys ✅ Resolved + +Resolved in `feat(util,builder,cli[load,stop],loader)` — 4 lifecycle hook config keys added: `on_project_start` (runs on every `tmuxp load`, before session creation), `on_project_restart` (runs when reattaching to existing session), `on_project_exit` (fires on detach via tmux `set-hook client-detached`), `on_project_stop` (runs before `session.kill()` in `tmuxp stop`, stored in session env). `run_hook_commands()` helper uses `shell=True` for full shell support. `on_project_first_start` skipped (covered by `before_script`). Hook values expanded via `expandshell()` in `loader.expand()`. Tests: `test_run_hook_commands*` (unit), `test_on_project_exit_sets_hook*` / `test_on_project_stop_sets_environment` / `test_on_project_stop_sets_start_directory_env` (builder integration), `test_load_on_project_start_runs_hook` / `test_load_on_project_restart_runs_hook` (CLI load), `test_stop_runs_on_project_stop_hook` / `test_stop_without_hook` (CLI stop), `test_expand_lifecycle_hooks_*` (loader expand). + +### T7. No `--no-shell-command-before` CLI Flag ✅ Resolved + +Resolved in `feat(cli[load])` — `--no-shell-command-before` flag added to `tmuxp load`. When set, strips `shell_command_before` from session, window, and pane levels after `expand()` but before `trickle()`. Equivalent to tmuxinator's `--no-pre-window`. + +### T8. Config Templating ✅ Resolved + +Resolved in `feat(loader,cli[load])` — `render_template()` in `loader.py` replaces `{{ variable }}` expressions in raw config content before YAML/JSON parsing. `--set KEY=VALUE` CLI flag (repeatable) passes template context through `load_workspace()` → `ConfigReader._from_file(template_context=...)`. Zero new dependencies (regex-based, no Jinja2). Unknown `{{ var }}` expressions left unchanged. Coexists with existing `$ENV_VAR` expansion (which runs after YAML parsing in `expand()`). Tests: `test_render_template` (9 parametrized unit tests), `test_load_workspace_template_context`/`_no_context` (CLI integration). + +### T9. `--debug` CLI Flag ✅ Resolved + +Resolved in `feat(cli[load])` — `--debug` flag added to `tmuxp load` that shows tmux commands as they execute. Uses a `_TmuxCommandDebugHandler` that attaches to libtmux's `libtmux.common` logger and intercepts structured `tmux_cmd` extra fields. Implies `--no-progress` (spinner disabled). Handler is properly cleaned up on all return paths. Not a true dry-run (tmux commands still execute — required for API-based building), but provides the debugging visibility that tmuxinator `debug` and teamocil `--debug` offer. + +### T10. Missing Config Management Commands ✅ Resolved + +Resolved in `feat(cli[new,copy,delete])` — Three config management commands added: +- `tmuxp new <name>` creates workspace from template + opens in `$EDITOR` +- `tmuxp copy <source> <dest>` duplicates workspace configs (supports names and paths) +- `tmuxp delete <name> [-y]` removes workspace configs with confirmation prompt +All commands follow existing CLI patterns (`edit.py`, `convert.py`), use `Colors` semantic hierarchy, and integrate with `find_workspace_file()`/`get_workspace_dir()`. Skipped `implode` (destructive nuke-all, low value). + +## Dead Config Keys + +Keys produced by importers but silently ignored by the builder: + +| Key | Producer | Builder Handling | Status | +|---|---|---|---| +| ~~`config`~~ | ~~tmuxinator importer~~ | ~~Never read~~ | ✅ Resolved — `load_workspace()` reads as fallback for `-f` CLI flag | +| ~~`socket_name`~~ | ~~tmuxinator importer~~ | ~~Never read~~ | ✅ Resolved — `load_workspace()` reads as fallback for `-L` CLI flag | +| ~~`clear`~~ | ~~teamocil importer~~ | ~~Never read~~ | ✅ Resolved — `config_after_window()` sends `clear` to all panes when `clear: true` | +| ~~`shell_command` (session-level)~~ | ~~tmuxinator importer~~ | ~~Not a valid session key~~ | ✅ Resolved — I1: `pre` now maps to `before_script` | +| ~~`shell_command_after`~~ | ~~teamocil importer~~ | ✅ `config_after_window()` | ✅ Resolved — T3 | +| ~~`height` (pane)~~ | ~~teamocil importer~~ | ~~Dead data~~ | ✅ Resolved — warned + dropped | +| ~~`start_window`/`start_pane`~~ | ~~tmuxinator importer~~ | ~~Dead data~~ | ✅ Resolved — converted to `focus: true` in importer | + +## Importer Fixes — All ✅ Resolved + +### I1. tmuxinator `pre` / `pre_window` Mapping ✅ Resolved + +Resolved — `pre` now correctly maps to `before_script` (session-level, runs once). `pre_window`/`pre_tab` maps to `shell_command_before`. Type check on `pre_window_val` is correct. Multi-command `pre` lists log an info message suggesting split. Tests: `test3` (combo), `test5` (`pre` + `pre_tab`), `test_logs_info_on_multi_command_pre_list`. + +### I2. tmuxinator `cli_args` / `tmux_options` Parsing ✅ Resolved + +Resolved — Uses `shlex.split()` with proper flag-aware iteration. Supports `-f`, `-L`, `-S` flags. Tests: `test3` (single flag), `test4` (multi-flag). + +### I3. teamocil Redundant Filter Loops ✅ Resolved + +Resolved — Direct assignment replaces redundant loops. Tests: existing `test2` (filters fixture). + +### I4. teamocil v1.x Format ✅ Resolved + +Resolved — Handles string panes, `None` panes, `commands` key (v1.x), `cmd` key (v0.x). `width`/`height` warned and dropped. Tests: `test5` (v1.x format), `test6` (focus/options/height). + +### I5. tmuxinator Missing Keys ✅ Resolved + +Resolved — `rvm` → `shell_command_before`, `pre_tab` → alias for `pre_window`, `startup_window` → `focus: true` on matching window, `startup_pane` → `focus: true` on matching pane. Tests: `test5` (rvm/pre_tab/startup), `test_startup_window_*`, `test_startup_pane_*`. + +### I6. teamocil Missing Keys ✅ Resolved + +Resolved — v1.x: `commands` → `shell_command`, string panes handled, window `focus`/`options` pass-through. v0.x: `with_env_var` and `cmd_separator` log warnings. Tests: `test5` (v1.x), `test6` (focus/options), `test_warns_on_with_env_var_and_cmd_separator`. + +### I7. Importer TODOs ✅ Resolved + +Resolved — `with_env_var` logs warning (unsupported), `cmd_separator` logs warning (irrelevant for tmuxp), `width`/`height` warn and drop. Tests: `test_warns_on_width_height_drop`, `test_warns_on_with_env_var_and_cmd_separator`. + +## Remaining Test Coverage Gaps + +### Tier 1: Covered ✅ + +All previously-identified Tier 1 gaps (v1.x string panes, `commands` key, `rvm`, `pre` scope) are now fixed and tested. + +### Tier 2: Edge Cases ✅ Resolved + +- ~~**YAML aliases/anchors**~~: ✅ Tested — aliases resolve transparently via YAML parser before import +- ~~**Numeric/emoji window names**~~: ✅ Fixed + tested — `str(k)` coercion in importer prevents `TypeError` in `expandshell()` +- ~~**Pane title syntax**~~: ✅ Fixed + tested — `_convert_named_panes()` converts `{name: commands}` to `{shell_command, title}` + +## Implementation Priority + +### ~~Phase 1: Import Fixes~~ — **COMPLETE** + +All importer bugs (I1-I7) resolved. Importers handle v1.x format, missing keys, proper `pre`/`pre_window` mapping, flag-aware `cli_args` parsing, `startup_window`/`startup_pane` → `focus: true`, and unsupported key warnings. + +### ~~Phase 2: Builder Additions~~ — **COMPLETE** + +All builder config keys resolved: T1 (`synchronize`), T2 (pane titles), T3 (`shell_command_after`), T4 (`--here`). + +### ~~Phase 3: libtmux Additions~~ — **COMPLETE** (libtmux v0.55.0) + +All libtmux API additions shipped in v0.55.0. + +### ~~Phase 4: New CLI Commands~~ — **COMPLETE** + +T5 (`tmuxp stop`), T10 (`tmuxp new`, `tmuxp copy`, `tmuxp delete`). + +### ~~Phase 5: CLI Flags & Features~~ — **MOSTLY COMPLETE** + +- ~~T7: `--no-shell-command-before` flag~~ ✅ +- ~~T9: `--debug` mode~~ ✅ +- ~~T6: Lifecycle hook config keys~~ ✅ + +### Phase 6: Remaining + +1. ~~**T8**~~: ✅ Resolved — `{{ variable }}` templating with `--set KEY=VALUE` CLI flag. +2. ~~**Dead config keys**~~: ✅ Resolved — `config`, `socket_name`, `socket_path` now read as fallbacks in `load_workspace()`. CLI flags override. +3. ~~**`clear` config key**~~: ✅ Resolved — `config_after_window()` sends `clear` to all panes when `clear: true`. +4. ~~**Edge case test coverage**~~: ✅ Resolved — YAML aliases tested, numeric/emoji window names fixed + tested, pane title syntax fixed + tested. 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..d8963128cf 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() + return + 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() + return + 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() + return + 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..ef09c0c98c --- /dev/null +++ b/src/tmuxp/cli/copy.py @@ -0,0 +1,136 @@ +"""CLI for ``tmuxp copy`` subcommand.""" + +from __future__ import annotations + +import logging +import os +import shutil +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 ``<name>.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}")) + return + + 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) + dest_path = os.path.join(workspace_dir, f"{destination}.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.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.")) + return + + 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..1742c04a8a --- /dev/null +++ b/src/tmuxp/cli/delete.py @@ -0,0 +1,121 @@ +"""CLI for ``tmuxp delete`` subcommand.""" + +from __future__ import annotations + +import logging +import os +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) + + for name in workspace_names: + try: + workspace_path = find_workspace_file(name) + except FileNotFoundError: + tmuxp_echo(colors.warning(f"Workspace not found: {name}")) + 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))), + ) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 375cdb1b22..daaee7cf6c 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -57,6 +57,20 @@ 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.""" + 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 +119,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 +127,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( @@ -305,6 +323,24 @@ 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 + -------- + >>> from tmuxp.cli.load import _load_here_in_current_session + >>> callable(_load_here_in_current_session) + True + """ + 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 +361,7 @@ def _dispatch_build( append: bool, answer_yes: bool, cli_colors: Colors, + here: bool = False, 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 +384,8 @@ def _dispatch_build( Skip interactive prompts. cli_colors : Colors Colors instance for styled output. + here : bool + Use current window for first workspace window. pre_attach_hook : callable, optional Called before attach/switch_client (e.g. stop spinner). on_error_hook : callable, optional @@ -371,6 +410,21 @@ def _dispatch_build( _load_detached(builder, cli_colors, 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_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) @@ -446,10 +500,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 +531,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 +545,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 +614,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,18 +642,54 @@ 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) @@ -593,23 +718,48 @@ 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, ) - ): + ) + if _confirmed or detached: + if "on_project_start" in expanded_workspace: + _hook_cwd = expanded_workspace.get("start_directory") + util.run_hook_commands( + expanded_workspace["on_project_start"], + cwd=_hook_cwd, + ) + # Run on_project_restart hook — fires when reattaching + 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, + ) + if _confirmed: _reattach(builder, cli_colors) + _cleanup_debug() return None + # Run on_project_start hook — fires before new session build + if "on_project_start" in expanded_workspace: + _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 +768,7 @@ def load_workspace( append, answer_yes, cli_colors, + here=here, ) if result is not None: summary = "" @@ -641,6 +792,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 +845,7 @@ def _emit_success() -> None: append, answer_yes, cli_colors, + here=here, pre_attach_hook=_emit_success, on_error_hook=spinner.stop, pre_prompt_hook=spinner.stop, @@ -745,19 +898,33 @@ 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", + ) + 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 +987,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 +1059,20 @@ def command_load( sys.exit() return + # 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 +1100,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..22afbd5da5 --- /dev/null +++ b/src/tmuxp/cli/new.py @@ -0,0 +1,133 @@ +"""CLI for ``tmuxp new`` subcommand.""" + +from __future__ import annotations + +import logging +import os +import shlex +import subprocess +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: + - +""" + +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) + + # 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)"), + ) diff --git a/src/tmuxp/cli/stop.py b/src/tmuxp/cli/stop.py new file mode 100644 index 0000000000..39a364b71c --- /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) + 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..6e6cbc8334 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -105,6 +105,69 @@ 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}, + ) + + def oh_my_zsh_auto_title() -> None: """Give warning and offer to fix ``DISABLE_AUTO_TITLE``. diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 728b477963..2a69bd4540 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -4,6 +4,7 @@ import logging import os +import shlex import shutil import time import typing as t @@ -407,7 +408,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,6 +427,8 @@ 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 """ if not session: if not self.server: @@ -526,6 +534,18 @@ def build(self, session: Session | None = None, append: bool = False) -> None: if self.on_build_event: self.on_build_event({"event": "before_script_done"}) + # Check for rename conflicts early, before any session mutation + 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) + 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 +558,38 @@ 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): + # Set lifecycle tmux hooks + if "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}" + _escaped = _joined.replace("'", "'\\''") + self.session.set_hook("client-detached", f"run-shell '{_escaped}'") + + # Store on_project_stop in session environment for tmuxp stop + if "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 "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 +630,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 +645,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 +671,101 @@ 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"] + + if start_directory: + active_pane = window.active_pane + if active_pane is not None: + active_pane.send_keys( + f"cd {shlex.quote(start_directory)}", + enter=True, + ) + + # Provision environment — no window.set_environment in tmux, + # so export into the active pane's shell + environment = window_config.get("environment") + if panes and "environment" in panes[0]: + environment = panes[0]["environment"] + if environment: + _here_pane = window.active_pane + if _here_pane is not None: + for _ekey, _eval in environment.items(): + _here_pane.send_keys( + f"export {_ekey}={shlex.quote(str(_eval))}", + enter=True, + ) + + # Provision window_shell — send to active pane + window_shell = window_config.get("window_shell") + try: + if panes[0]["shell"] != "": + window_shell = panes[0]["shell"] + except (KeyError, IndexError): + pass + if window_shell: + _here_pane = window.active_pane + if _here_pane is not None: + _here_pane.send_keys(window_shell, enter=True) + 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 +776,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 +922,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 +956,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..e25042f19e 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -3,11 +3,58 @@ from __future__ import annotations import logging +import shlex import typing as t logger = logging.getLogger(__name__) +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 import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: """Return tmuxp workspace from a `tmuxinator`_ yaml workspace. @@ -44,72 +91,203 @@ 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: + value = next(it, None) + if value is not None: + tmuxp_workspace[flag_map[token]] = value - 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 + _TMUXINATOR_UNMAPPED_KEYS = { + "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", + } + 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"]] - 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"]] + # 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: + 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"] + 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} + window_dict = {"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) continue if isinstance(v, list): - window_dict["panes"] = v + window_dict["panes"] = _convert_named_panes(v) tmuxp_workspace["windows"].append(window_dict) continue if "pre" in v: window_dict["shell_command_before"] = v["pre"] if "panes" in v: - window_dict["panes"] = v["panes"] + window_dict["panes"] = _convert_named_panes(v["panes"]) if "root" in v: window_dict["start_directory"] = v["root"] if "layout" in v: window_dict["layout"] = v["layout"] + + if "synchronize" in v: + sync = v["synchronize"] + if sync is True or sync == "before": + window_dict.setdefault("options", {})["synchronize-panes"] = "on" + elif sync == "after": + window_dict.setdefault("options_after", {})["synchronize-panes"] = ( + "on" + ) + tmuxp_workspace["windows"].append(window_dict) + + # 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: + try: + _idx = int(_startup_window) + if 0 <= _idx < len(tmuxp_workspace["windows"]): + tmuxp_workspace["windows"][_idx]["focus"] = True + logger.info( + "startup_window %r resolved as 0-based list index; " + "use window name for unambiguous matching across tools", + _startup_window, + ) + else: + logger.warning( + "startup_window index %d out of range (0-%d)", + _idx, + len(tmuxp_workspace["windows"]) - 1, + ) + except (ValueError, IndexError): + logger.warning( + "startup_window %s not found", + _startup_window, + ) + + 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: + try: + _pidx = int(_startup_pane) + if 0 <= _pidx < len(_target["panes"]): + _pane = _target["panes"][_pidx] + if isinstance(_pane, dict): + _pane["focus"] = True + else: + _target["panes"][_pidx] = { + "shell_command": [_pane] if _pane else [], + "focus": True, + } + logger.info( + "startup_pane %r resolved as 0-based list index; " + "use window name + pane index for clarity", + _startup_pane, + ) + else: + logger.warning( + "startup_pane index %d out of range (0-%d)", + _pidx, + len(_target["panes"]) - 1, + ) + except (ValueError, IndexError): + logger.warning( + "startup_pane %s not found", + _startup_pane, + ) + return tmuxp_workspace @@ -122,16 +300,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 +326,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 +338,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..8f8b7401a8 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,51 @@ def expandshell(value: str) -> str: return os.path.expandvars(os.path.expanduser(value)) # NOQA: PTH111 +_TEMPLATE_RE = re.compile(r"\{\{\s*(\w+)\s*\}\}") + + +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. + + 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' + """ + + 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): @@ -138,6 +184,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 +218,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 +242,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..7fe1817415 --- /dev/null +++ b/tests/cli/test_copy.py @@ -0,0 +1,158 @@ +"""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) + + cli.cli(cli_args) + + captured = capsys.readouterr() + dest_path = config_dir / f"{dest_name}.yaml" + + if expect_copied: + assert dest_path.exists() + assert dest_path.read_text() == source_content + assert "Copied" in captured.out + else: + assert not dest_path.exists() + 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" diff --git a/tests/cli/test_delete.py b/tests/cli/test_delete.py new file mode 100644 index 0000000000..986a19ff0b --- /dev/null +++ b/tests/cli/test_delete.py @@ -0,0 +1,95 @@ +"""Test tmuxp delete command.""" + +from __future__ import annotations + +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") + + cli.cli(cli_args) + + captured = capsys.readouterr() + + if expect_deleted: + assert not workspace_path.exists() + assert "Deleted" in captured.out + else: + assert not workspace_path.exists() + 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 diff --git a/tests/cli/test_help_examples.py b/tests/cli/test_help_examples.py index 9cbe365db2..a0a314a77d 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.""" + result = subprocess.run( + ["tmuxp", subcommand], + capture_output=True, + text=True, + ) + assert f"usage: tmuxp {subcommand}" in result.stdout + assert result.returncode == 0 + + 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 <test_socket>. +_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_load.py b/tests/cli/test_load.py index ec045dcf3c..38e755c781 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -887,6 +887,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 +1137,369 @@ 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() + + +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 load triggers on_project_restart (session already exists) + load_workspace( + workspace_file, + socket_name=server.socket_name, + detached=True, + ) + assert 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"]) + + +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"]) diff --git a/tests/cli/test_new.py b/tests/cli/test_new.py new file mode 100644 index 0000000000..4fcc2db728 --- /dev/null +++ b/tests/cli/test_new.py @@ -0,0 +1,155 @@ +"""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, + ), + EditorFixture( + test_id="missing-editor", + editor="nonexistent_editor_binary_xyz", + expect_error_output=True, + ), +] + + +@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, missing binary, 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 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..fe99990322 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 @@ -234,3 +240,93 @@ 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 diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index da95168f46..96cc5e8236 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -359,6 +359,281 @@ 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 exports environment variables into the active pane.""" + from libtmux.test.retry import retry_until + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "env-test", + "environment": {"TMUXP_HERE_TEST": "hello_here"}, + "panes": [ + {"shell_command": ["echo $TMUXP_HERE_TEST"]}, + ], + }, + ], + } + workspace = loader.expand(workspace) + + 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 + + assert retry_until( + lambda: "hello_here" in "\n".join(pane.capture_pane()), + seconds=5, + ) + + def test_window_shell( session: Session, ) -> None: @@ -1768,3 +2043,182 @@ 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_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() + + +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..2faf7d84db 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,133 @@ 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 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..30b1e169eb 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,709 @@ 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 sets focus by numeric index when name doesn't match.""" + workspace = { + "name": "test", + "startup_window": 1, + "windows": [ + {"editor": "vim"}, + {"server": "rails s"}, + ], + } + result = importers.import_tmuxinator(workspace) + + assert result["windows"][0].get("focus") is None + assert result["windows"][1]["focus"] is True + + +def test_startup_pane_sets_focus_on_pane() -> None: + """Startup_pane converts the target pane to a dict with focus.""" + workspace = { + "name": "test", + "startup_window": "editor", + "startup_pane": 1, + "windows": [ + { + "editor": { + "panes": ["vim", "guard", "top"], + }, + }, + ], + } + result = importers.import_tmuxinator(workspace) + + assert result["windows"][0]["focus"] is True + panes = result["windows"][0]["panes"] + assert panes[0] == "vim" + assert panes[1] == {"shell_command": ["guard"], "focus": True} + 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) + + panes = result["windows"][0]["panes"] + assert panes[1] == {"shell_command": ["guard"], "focus": True} + + +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) + + +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 + window_names: list[str] + expected_focus_index: int | None + expect_info_log: bool + expect_warning_log: bool + + +STARTUP_INDEX_FIXTURES: list[StartupIndexFixture] = [ + StartupIndexFixture( + test_id="name-match", + startup_window="editor", + window_names=["editor", "console"], + expected_focus_index=0, + expect_info_log=False, + expect_warning_log=False, + ), + StartupIndexFixture( + test_id="numeric-zero", + startup_window=0, + window_names=["win1", "win2"], + expected_focus_index=0, + expect_info_log=True, + expect_warning_log=False, + ), + StartupIndexFixture( + test_id="numeric-one", + startup_window=1, + window_names=["win1", "win2"], + expected_focus_index=1, + expect_info_log=True, + expect_warning_log=False, + ), + StartupIndexFixture( + test_id="out-of-range", + startup_window=5, + window_names=["win1", "win2"], + expected_focus_index=None, + expect_info_log=False, + expect_warning_log=True, + ), + StartupIndexFixture( + test_id="no-match-string", + startup_window="nonexistent", + window_names=["win1", "win2"], + expected_focus_index=None, + expect_info_log=False, + 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, + window_names: list[str], + expected_focus_index: int | None, + expect_info_log: bool, + expect_warning_log: bool, +) -> None: + """startup_window resolves by name first, then 0-based index with logging.""" + 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) + + 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" + + info_records = [ + r + for r in caplog.records + if r.levelno == logging.INFO and "startup_window" in r.message + ] + warning_records = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and "startup_window" in r.message + ] + + if expect_info_log: + assert len(info_records) >= 1 + else: + assert len(info_records) == 0 + + if expect_warning_log: + assert len(warning_records) >= 1 + else: + assert len(warning_records) == 0