diff --git a/CHANGES b/CHANGES index 35e90e910a..c508c75a44 100644 --- a/CHANGES +++ b/CHANGES @@ -44,6 +44,23 @@ $ tmuxp@next load yoursession _Notes on the upcoming release will go here._ +### What's new + +#### Pane titles (#1047) + +Workspace configs can label panes natively: session-level +`enable_pane_titles`, `pane_title_position`, and `pane_title_format` +turn on tmux's pane border titles, and a pane-level `title` names +individual panes — replacing the escape-sequence workaround previously +required. See {ref}`top-level` for examples. + +#### New window keys: `synchronize`, `shell_command_after`, `clear` (#1047) + +`synchronize` sets the final pane synchronization state while tmuxp keeps +setup and post-build commands isolated per pane; `shell_command_after` runs +commands in every pane after the window is built; `clear: true` clears each +pane once its commands complete. See {ref}`top-level`. + ## tmuxp 1.70.0 (2026-05-23) tmuxp 1.70.0 bumps libtmux to 0.58.0, fixing session and window listing on systems whose locale is not UTF-8. diff --git a/docs/configuration/examples.md b/docs/configuration/examples.md index 9651341309..f99caee2b9 100644 --- a/docs/configuration/examples.md +++ b/docs/configuration/examples.md @@ -533,9 +533,10 @@ Including `automatic-rename`, `default-shell`, ## Set window options after pane creation -Apply window options after panes have been created. Useful for -`synchronize-panes` option after executing individual commands in each -pane during creation. +Apply window options after panes have been created. When +`synchronize-panes` appears in `options` or `options_after`, tmuxp keeps it +disabled while sending configured commands and restores the requested final +state after the window is ready. ````{tab} YAML ```{literalinclude} ../../examples/2-pane-synchronized.yaml @@ -785,6 +786,29 @@ windows: [poetry]: https://python-poetry.org/ [uv]: https://github.com/astral-sh/uv +## Synchronize Panes Shorthand + +The `synchronize` window key provides a shorthand for the final +`synchronize-panes` state without spelling out tmux options directly: + +````{tab} YAML +```{literalinclude} ../../examples/synchronize-shorthand.yaml +:language: yaml + +``` +```` + +## Pane Titles + +Pane title keys turn on tmux pane border titles and 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..de54d912e9 100644 --- a/docs/configuration/top-level.md +++ b/docs/configuration/top-level.md @@ -40,3 +40,112 @@ Notes: ``` Above: Use `tmux` directly to attach _banana_. + +## 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` or `false`). | +| `pane_title_position` | session | Position of the title bar (`top`, `bottom`, or `off`). | +| `pane_title_format` | session | Format string using tmux variables. | +| `title` | pane | Title text for an individual pane. | + +```{note} +tmux ignores empty pane titles — `title: ""` logs a warning and keeps the +default label. Use a single space (`title: " "`) to visually blank one. +``` + +## synchronize + +Window-level shorthand for the final `synchronize-panes` state. tmuxp keeps +pane synchronization disabled while it builds panes and sends configured +commands, then restores the requested synchronized state after the window is +ready. + +```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 | +|-------|----------| +| `after` | Synchronize panes after tmuxp finishes building the window. | +| `before` | Compatibility alias for the same final synchronized state. | +| `true` | Compatibility alias for the same final synchronized state. | +| `false` | Force the final window state to unsynchronized. | + +## shell_command_after + +Window-level commands sent to every pane 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 +``` + +tmuxp keeps `synchronize-panes` disabled while `shell_command_after` runs, then +restores the final synchronized state afterward. This prevents tmux from +duplicating post-build commands across panes. + +Entries accept the same command mappings as `shell_command` — `enter`, +`sleep_before`, and `sleep_after` apply per command (sleeps run once per +command, before and after it is sent to every pane): + +```yaml +shell_command_after: + - cmd: ./healthcheck.sh + sleep_before: 2 + - cmd: tail -f app.log + enter: false +``` + +## 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/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..e4d0345544 --- /dev/null +++ b/examples/synchronize-shorthand.yaml @@ -0,0 +1,17 @@ +session_name: synchronize shorthand +windows: + - window_name: synced-after + synchronize: after + panes: + - echo 0 + - echo 1 + - window_name: synced-compat-before + synchronize: before + panes: + - echo 0 + - echo 1 + - window_name: not-synced + synchronize: false + panes: + - echo 0 + - echo 1 diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 728b477963..bba40570b6 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -9,6 +9,7 @@ import typing as t from libtmux._internal.query_list import ObjectDoesNotExist +from libtmux.constants import OptionScope from libtmux.pane import Pane from libtmux.server import Server from libtmux.session import Session @@ -23,6 +24,13 @@ logger = logging.getLogger(__name__) +SYNCHRONIZE_PANES_OPTION = "synchronize-panes" +SYNCHRONIZE_PANES_FINAL_OPTION = "_synchronize_panes" +SYNCHRONIZE_PANES_FORCED_OFF_OPTION = "_synchronize_panes_forced_off" +SYNCHRONIZE_PANES_PANE_RESTORE_OPTION = "_synchronize_panes_pane_restore" +SYNCHRONIZE_PANES_RESTORE_OPTION = "_synchronize_panes_restore" +_MISSING = object() + def _wait_for_pane_ready( pane: Pane, @@ -83,6 +91,27 @@ def _wait_for_pane_ready( return False +def _get_window_option_value( + window_config: dict[str, t.Any], + section: str, + option: str, +) -> t.Any: + """Return a window option value or ``_MISSING``. + + Examples + -------- + >>> cfg = {"options": {"synchronize-panes": "on"}} + >>> _get_window_option_value(cfg, "options", "synchronize-panes") + 'on' + >>> _get_window_option_value(cfg, "options_after", "synchronize-panes") is _MISSING + True + """ + options = window_config.get(section) + if isinstance(options, dict) and option in options: + return options[option] + return _MISSING + + COLUMNS_FALLBACK = 80 @@ -541,21 +570,36 @@ def build(self, session: Session | None = None, append: bool = False) -> None: for window, window_config in self.iter_create_windows(session, append): assert isinstance(window, Window) + if SYNCHRONIZE_PANES_FINAL_OPTION in window_config: + window.set_option( + SYNCHRONIZE_PANES_OPTION, + window_config[SYNCHRONIZE_PANES_FINAL_OPTION], + ) + for plugin in self.plugins: plugin.on_window_create(window) + self.prepare_window_synchronize_panes(window, window_config) + focus_pane = None - for pane, pane_config in self.iter_create_panes(window, window_config): - assert isinstance(pane, Pane) - pane = pane + try: + for pane, pane_config in self.iter_create_panes(window, window_config): + assert isinstance(pane, Pane) + pane = pane - if pane_config.get("focus"): - focus_pane = pane + if pane_config.get("focus"): + focus_pane = pane - if window_config.get("focus"): - focus = window + if window_config.get("focus"): + focus = window - self.config_after_window(window, window_config) + self.config_after_window(window, window_config) + finally: + self.restore_window_synchronize_panes( + window, + window_config, + apply_options_after=False, + ) for plugin in self.plugins: plugin.after_window_finished(window) @@ -679,6 +723,127 @@ def iter_create_windows( yield window, window_config + def prepare_window_synchronize_panes( + self, + window: Window, + window_config: dict[str, t.Any], + ) -> None: + """Disable tmux pane synchronization while tmuxp sends setup keys. + + Examples + -------- + >>> cfg = {} + >>> builder = WorkspaceBuilder( + ... session_config={"session_name": session.name, "windows": []}, + ... server=session.server, + ... ) + >>> scratch = session.new_window(window_name="sync-prepare") + >>> _ = scratch.set_option("synchronize-panes", True) + >>> builder.prepare_window_synchronize_panes(scratch, cfg) + >>> scratch.show_option("synchronize-panes") + False + >>> builder.restore_window_synchronize_panes(scratch, cfg) + >>> scratch.show_option("synchronize-panes") + True + >>> _ = scratch.kill() + """ + local_sync = window.show_option(SYNCHRONIZE_PANES_OPTION) + effective_sync = window.show_option( + SYNCHRONIZE_PANES_OPTION, + include_inherited=True, + ) + + if effective_sync is True: + window_config[SYNCHRONIZE_PANES_RESTORE_OPTION] = local_sync + window.set_option(SYNCHRONIZE_PANES_OPTION, False) + window_config[SYNCHRONIZE_PANES_FORCED_OFF_OPTION] = True + + pane_restores = [] + for pane in window.panes: + local_pane_sync = pane.show_option( + SYNCHRONIZE_PANES_OPTION, + scope=OptionScope.Pane, + ) + if local_pane_sync is True: + pane_restores.append((pane, local_pane_sync)) + pane.set_option( + SYNCHRONIZE_PANES_OPTION, + False, + scope=OptionScope.Pane, + ignore_errors=True, + ) + + if pane_restores: + window_config[SYNCHRONIZE_PANES_PANE_RESTORE_OPTION] = pane_restores + window_config[SYNCHRONIZE_PANES_FORCED_OFF_OPTION] = True + + def restore_window_synchronize_panes( + self, + window: Window, + window_config: dict[str, t.Any], + *, + apply_options_after: bool = True, + ) -> None: + """Restore the configured final tmux pane synchronization state. + + Examples + -------- + >>> cfg = {"options_after": {"synchronize-panes": "off"}} + >>> builder = WorkspaceBuilder( + ... session_config={"session_name": session.name, "windows": []}, + ... server=session.server, + ... ) + >>> scratch = session.new_window(window_name="sync-restore") + >>> _ = scratch.set_option("synchronize-panes", True) + >>> builder.restore_window_synchronize_panes(scratch, cfg) + >>> scratch.show_option("synchronize-panes") + False + >>> _ = scratch.kill() + """ + after_sync = _MISSING + if apply_options_after: + after_sync = _get_window_option_value( + window_config, + "options_after", + SYNCHRONIZE_PANES_OPTION, + ) + + pane_restores = window_config.pop( + SYNCHRONIZE_PANES_PANE_RESTORE_OPTION, + [], + ) + forced_off = window_config.pop(SYNCHRONIZE_PANES_FORCED_OFF_OPTION, False) + restore_sync = window_config.pop( + SYNCHRONIZE_PANES_RESTORE_OPTION, + _MISSING, + ) + + if after_sync is not _MISSING: + window.set_option(SYNCHRONIZE_PANES_OPTION, t.cast(int | str, after_sync)) + elif forced_off and restore_sync is not _MISSING: + if restore_sync is None: + window.unset_option(SYNCHRONIZE_PANES_OPTION, ignore_errors=True) + else: + window.set_option( + SYNCHRONIZE_PANES_OPTION, + t.cast(int | str, restore_sync), + ) + + for pane, restore_pane_sync in pane_restores: + if restore_pane_sync is _MISSING or restore_pane_sync is None: + pane.unset_option( + SYNCHRONIZE_PANES_OPTION, + scope=OptionScope.Pane, + ignore_errors=True, + ) + else: + pane.set_option( + SYNCHRONIZE_PANES_OPTION, + t.cast(int | str, restore_pane_sync), + scope=OptionScope.Pane, + ignore_errors=True, + ) + def iter_create_panes( self, window: Window, @@ -813,6 +978,17 @@ def get_pane_shell( if sleep_after is not None: time.sleep(sleep_after) + title = pane_config.get("title") + if title: + pane.set_title(title) + elif title is not None: + # tmux discards `select-pane -T ""`; an empty pane title + # cannot be applied. + pane_log.warning( + "tmux ignores empty pane titles; use a single space " + "to blank the label", + ) + if pane_config.get("focus"): assert pane.pane_id is not None window.select_pane(pane.pane_id) @@ -837,12 +1013,46 @@ def config_after_window( window_config : dict config section for window """ - if "options_after" in window_config and isinstance( - window_config["options_after"], - dict, - ): - for key, val in window_config["options_after"].items(): - window.set_option(key, val) + suppress = window_config.get("suppress_history", True) + + try: + 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", + [], + ): + enter = cmd.get("enter", True) + sleep_before = cmd.get("sleep_before") + sleep_after = cmd.get("sleep_after") + # Sleeps apply once per command wave, not once per pane. + if sleep_before is not None: + time.sleep(sleep_before) + for pane in window.panes: + pane.send_keys( + cmd["cmd"], + suppress_history=suppress, + enter=enter, + ) + if sleep_after is not None: + time.sleep(sleep_after) + + if window_config.get("clear"): + for pane in window.panes: + pane.send_keys("clear", enter=True, suppress_history=suppress) + + if "options_after" in window_config and isinstance( + window_config["options_after"], + dict, + ): + for key, val in window_config["options_after"].items(): + if key == SYNCHRONIZE_PANES_OPTION: + continue + window.set_option(key, val) + finally: + self.restore_window_synchronize_panes(window, window_config) def find_current_attached_session(self) -> Session: """Return current attached session.""" diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 9efcd05b52..49f28f72b7 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -9,6 +9,8 @@ logger = logging.getLogger(__name__) +SYNCHRONIZE_PANES_FINAL_OPTION = "_synchronize_panes" + def expandshell(value: str) -> str: """Resolve shell variables based on user's ``$HOME`` and ``env``. @@ -138,6 +140,26 @@ def expand( val = str(cwd / val) workspace_dict["options"][key] = val + if "synchronize" in workspace_dict: + sync = workspace_dict.pop("synchronize") + if sync is True or sync in ["before", "after"]: + workspace_dict[SYNCHRONIZE_PANES_FINAL_OPTION] = True + elif sync is False: + workspace_dict[SYNCHRONIZE_PANES_FINAL_OPTION] = False + else: + session_name = workspace_dict.get("session_name") + if session_name is None and isinstance(parent, dict): + session_name = parent.get("session_name") + logger.warning( + "invalid synchronize value %r, expected true, false, " + "'before', or 'after'", + sync, + extra={ + "tmux_session": str(session_name or ""), + "tmux_window": str(workspace_dict.get("window_name") or ""), + }, + ) + # Any workspace section, session, window, pane that can contain the # 'shell_command' value if "start_directory" in workspace_dict: @@ -175,6 +197,39 @@ 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) + + if workspace_dict.get("enable_pane_titles") and "windows" in workspace_dict: + valid_positions = {"top", "bottom", "off"} + position = workspace_dict.pop("pane_title_position", "top") + if position not in valid_positions: + logger.warning( + "invalid pane_title_position %r, expected one of %s; " + "defaulting to 'top'", + position, + valid_positions, + extra={ + "tmux_session": str(workspace_dict.get("session_name") or ""), + }, + ) + position = "top" + pane_title_format = 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", pane_title_format) + 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/workspace/test_builder.py b/tests/workspace/test_builder.py index da95168f46..da681f585f 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -13,6 +13,7 @@ import libtmux import pytest from libtmux._internal.query_list import ObjectDoesNotExist +from libtmux.constants import OptionScope from libtmux.exc import LibTmuxException from libtmux.pane import Pane from libtmux.session import Session @@ -359,6 +360,955 @@ def f() -> bool: ), "Synchronized command did not execute properly" +class SynchronizeBuilderFixture(t.NamedTuple): + """Fixture for synchronize shorthand builder behavior.""" + + test_id: str + synchronize: bool | str + expected_synchronized: bool | None + + +SYNCHRONIZE_BUILDER_FIXTURES: list[SynchronizeBuilderFixture] = [ + SynchronizeBuilderFixture( + test_id="true", + synchronize=True, + expected_synchronized=True, + ), + SynchronizeBuilderFixture( + test_id="before", + synchronize="before", + expected_synchronized=True, + ), + SynchronizeBuilderFixture( + test_id="after", + synchronize="after", + expected_synchronized=True, + ), + SynchronizeBuilderFixture( + test_id="false", + synchronize=False, + expected_synchronized=False, + ), +] + + +@pytest.mark.parametrize( + list(SynchronizeBuilderFixture._fields), + SYNCHRONIZE_BUILDER_FIXTURES, + ids=[fixture.test_id for fixture in SYNCHRONIZE_BUILDER_FIXTURES], +) +def test_synchronize_builder_options( + session: Session, + test_id: str, + synchronize: bool | str, + expected_synchronized: bool | None, +) -> None: + """Synchronize shorthand sets synchronize-panes on the built window.""" + workspace: dict[str, t.Any] = { + "session_name": f"sync-builder-{test_id}", + "windows": [ + { + "window_name": "main", + "synchronize": synchronize, + "panes": ["echo pane0", "echo pane1"], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + assert session.windows[0].show_option("synchronize-panes") is expected_synchronized + + +class IteratorSynchronizeOptionFixture(t.NamedTuple): + """Fixture for direct iterator synchronize-panes option behavior.""" + + test_id: str + option_value: str + expected_synchronized: bool + + +ITERATOR_SYNCHRONIZE_OPTION_FIXTURES: list[IteratorSynchronizeOptionFixture] = [ + IteratorSynchronizeOptionFixture( + test_id="on", + option_value="on", + expected_synchronized=True, + ), + IteratorSynchronizeOptionFixture( + test_id="off", + option_value="off", + expected_synchronized=False, + ), +] + + +@pytest.mark.parametrize( + list(IteratorSynchronizeOptionFixture._fields), + ITERATOR_SYNCHRONIZE_OPTION_FIXTURES, + ids=[fixture.test_id for fixture in ITERATOR_SYNCHRONIZE_OPTION_FIXTURES], +) +def test_iter_create_windows_preserves_raw_synchronize_panes_option( + session: Session, + test_id: str, + option_value: str, + expected_synchronized: bool, +) -> None: + """Direct iter_create_windows() calls apply raw synchronize-panes options.""" + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": f"iterator-sync-{test_id}", + "options": {"synchronize-panes": option_value}, + "panes": ["echo pane0"], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + window, _window_config = next(builder.iter_create_windows(session=session)) + + assert window.show_option("synchronize-panes") is expected_synchronized + + +class SyncIsolationFixture(t.NamedTuple): + """Fixture for build-time synchronize-panes isolation.""" + + test_id: str + window_extra: dict[str, t.Any] + global_synchronize: bool + expected_local_sync: bool | None + expected_effective_sync: bool + + +SYNC_ISOLATION_FIXTURES: list[SyncIsolationFixture] = [ + SyncIsolationFixture( + test_id="sync_before", + window_extra={"synchronize": "before"}, + global_synchronize=False, + expected_local_sync=True, + expected_effective_sync=True, + ), + SyncIsolationFixture( + test_id="sync_true", + window_extra={"synchronize": True}, + global_synchronize=False, + expected_local_sync=True, + expected_effective_sync=True, + ), + SyncIsolationFixture( + test_id="sync_after", + window_extra={"synchronize": "after"}, + global_synchronize=False, + expected_local_sync=True, + expected_effective_sync=True, + ), + SyncIsolationFixture( + test_id="explicit_options_on", + window_extra={"options": {"synchronize-panes": "on"}}, + global_synchronize=False, + expected_local_sync=True, + expected_effective_sync=True, + ), + SyncIsolationFixture( + test_id="explicit_options_after_on", + window_extra={"options_after": {"synchronize-panes": "on"}}, + global_synchronize=False, + expected_local_sync=True, + expected_effective_sync=True, + ), + SyncIsolationFixture( + test_id="inherits_global_on", + window_extra={}, + global_synchronize=True, + expected_local_sync=None, + expected_effective_sync=True, + ), + SyncIsolationFixture( + test_id="sync_false_overrides_global_on", + window_extra={"synchronize": False}, + global_synchronize=True, + expected_local_sync=False, + expected_effective_sync=False, + ), +] + + +@pytest.mark.parametrize( + list(SyncIsolationFixture._fields), + SYNC_ISOLATION_FIXTURES, + ids=[fixture.test_id for fixture in SYNC_ISOLATION_FIXTURES], +) +def test_synchronize_keeps_setup_commands_isolated( + session: Session, + test_id: str, + window_extra: dict[str, t.Any], + global_synchronize: bool, + expected_local_sync: bool | None, + expected_effective_sync: bool, +) -> None: + """Pane setup commands are never broadcast while a window is building.""" + if global_synchronize: + session.server.cmd("set-window-option", "-g", "synchronize-panes", "on") + + window_config: dict[str, t.Any] = { + "window_name": f"sync-isolated-{test_id}", + "panes": [ + "printf '__PANE0__\\n'", + "printf '__PANE1__\\n'", + ], + } + window_config.update(window_extra) + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [window_config], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + window = session.windows[0] + panes = window.panes + + def output_lines(pane: Pane, marker: str) -> int: + return sum(1 for line in pane.capture_pane() if line.strip() == marker) + + def setup_complete() -> bool: + return ( + output_lines(panes[0], "__PANE0__") >= 1 + and output_lines(panes[1], "__PANE1__") >= 1 + ) + + assert retry_until(setup_complete), "Expected setup markers in their own panes" + assert output_lines(panes[0], "__PANE1__") == 0 + assert output_lines(panes[1], "__PANE0__") == 0 + assert window.show_option("synchronize-panes") is expected_local_sync + assert ( + window.show_option("synchronize-panes", include_inherited=True) + is expected_effective_sync + ) + + +class SyncSetupAbortFixture(t.NamedTuple): + """Fixture for synchronize-panes cleanup after pane setup aborts.""" + + test_id: str + window_extra: dict[str, t.Any] + global_synchronize: bool + plugin_synchronize: bool | None + expected_local_sync: bool | None + expected_effective_sync: bool + + +SYNC_SETUP_ABORT_FIXTURES: list[SyncSetupAbortFixture] = [ + SyncSetupAbortFixture( + test_id="sync_true", + window_extra={"synchronize": True}, + global_synchronize=False, + plugin_synchronize=None, + expected_local_sync=True, + expected_effective_sync=True, + ), + SyncSetupAbortFixture( + test_id="raw_option_on", + window_extra={"options": {"synchronize-panes": "on"}}, + global_synchronize=False, + plugin_synchronize=None, + expected_local_sync=True, + expected_effective_sync=True, + ), + SyncSetupAbortFixture( + test_id="inherits_global_on", + window_extra={}, + global_synchronize=True, + plugin_synchronize=None, + expected_local_sync=None, + expected_effective_sync=True, + ), + SyncSetupAbortFixture( + test_id="options_after_off_keeps_inherited_global_on", + window_extra={"options_after": {"synchronize-panes": "off"}}, + global_synchronize=True, + plugin_synchronize=None, + expected_local_sync=None, + expected_effective_sync=True, + ), + SyncSetupAbortFixture( + test_id="options_after_on_does_not_run_on_setup_abort", + window_extra={"options_after": {"synchronize-panes": "on"}}, + global_synchronize=False, + plugin_synchronize=None, + expected_local_sync=None, + expected_effective_sync=False, + ), + SyncSetupAbortFixture( + test_id="plugin_on", + window_extra={}, + global_synchronize=False, + plugin_synchronize=True, + expected_local_sync=True, + expected_effective_sync=True, + ), +] + + +@pytest.mark.parametrize( + list(SyncSetupAbortFixture._fields), + SYNC_SETUP_ABORT_FIXTURES, + ids=[fixture.test_id for fixture in SYNC_SETUP_ABORT_FIXTURES], +) +def test_synchronize_restores_state_when_pane_setup_aborts( + session: Session, + test_id: str, + window_extra: dict[str, t.Any], + global_synchronize: bool, + plugin_synchronize: bool | None, + expected_local_sync: bool | None, + expected_effective_sync: bool, +) -> None: + """Pane setup failures restore temporarily disabled synchronize-panes state.""" + + class SyncOnWindowCreatePlugin: + """Plugin that optionally sets the sync state before pane setup.""" + + def before_workspace_builder(self, session: Session) -> None: + """No-op workspace hook.""" + + def on_window_create(self, window: Window) -> None: + """Set the synchronized state before pane setup starts.""" + if plugin_synchronize is not None: + window.set_option("synchronize-panes", plugin_synchronize) + + def after_window_finished(self, window: Window) -> None: + """No-op window hook.""" + + try: + if global_synchronize: + session.server.cmd("set-window-option", "-g", "synchronize-panes", "on") + + window_config: dict[str, t.Any] = { + "window_name": f"sync-abort-{test_id}", + "layout": "not-a-layout", + "panes": [ + "printf '__PANE0__\\n'", + "printf '__PANE1__\\n'", + ], + } + window_config.update(window_extra) + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [window_config], + } + workspace = loader.expand(workspace) + + plugins: list[t.Any] = [] + if plugin_synchronize is not None: + plugins.append(SyncOnWindowCreatePlugin()) + builder = WorkspaceBuilder( + session_config=workspace, + server=session.server, + plugins=plugins, + ) + + with pytest.raises(LibTmuxException, match="invalid layout"): + builder.build(session=session) + + window = session.windows[0] + assert window.show_option("synchronize-panes") is expected_local_sync + assert ( + window.show_option("synchronize-panes", include_inherited=True) + is expected_effective_sync + ) + finally: + session.server.cmd("set-window-option", "-gu", "synchronize-panes") + + +class SyncPaneLocalPluginFixture(t.NamedTuple): + """Fixture for plugin-created pane-local synchronize-panes state.""" + + test_id: str + window_extra: dict[str, t.Any] + expected_window_sync: bool | None + + +SYNC_PANE_LOCAL_PLUGIN_FIXTURES: list[SyncPaneLocalPluginFixture] = [ + SyncPaneLocalPluginFixture( + test_id="window_option_on", + window_extra={"options": {"synchronize-panes": "on"}}, + expected_window_sync=True, + ), + SyncPaneLocalPluginFixture( + test_id="synchronize_false", + window_extra={"synchronize": False}, + expected_window_sync=False, + ), +] + + +@pytest.mark.parametrize( + list(SyncPaneLocalPluginFixture._fields), + SYNC_PANE_LOCAL_PLUGIN_FIXTURES, + ids=[fixture.test_id for fixture in SYNC_PANE_LOCAL_PLUGIN_FIXTURES], +) +def test_synchronize_disables_plugin_pane_local_state_during_setup( + session: Session, + test_id: str, + window_extra: dict[str, t.Any], + expected_window_sync: bool | None, +) -> None: + """Pane-local plugin sync state does not broadcast tmuxp setup commands.""" + + class PaneLocalSyncPlugin: + """Plugin that creates panes with pane-local sync before tmuxp setup.""" + + def before_workspace_builder(self, session: Session) -> None: + """No-op workspace hook.""" + + def on_window_create(self, window: Window) -> None: + """Create pane-local sync state before tmuxp sends setup keys.""" + first = window.active_pane + assert isinstance(first, Pane) + second = first.split(attach=True) + assert isinstance(second, Pane) + first.set_option( + "synchronize-panes", + True, + scope=OptionScope.Pane, + ) + second.set_option( + "synchronize-panes", + True, + scope=OptionScope.Pane, + ) + + def after_window_finished(self, window: Window) -> None: + """No-op window hook.""" + + window_config: dict[str, t.Any] = { + "window_name": f"sync-pane-local-{test_id}", + "panes": [ + "printf '__PANE0__\\n'", + "printf '__PANE1__\\n'", + ], + } + window_config.update(window_extra) + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [window_config], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + server=session.server, + plugins=[PaneLocalSyncPlugin()], + ) + builder.build(session=session) + + window = session.windows[0] + panes = window.panes + + def output_lines(pane: Pane, marker: str) -> int: + return sum(1 for line in pane.capture_pane() if line.strip() == marker) + + def setup_complete() -> bool: + return sum(output_lines(pane, "__PANE1__") for pane in panes) == 1 + + assert retry_until(setup_complete), "Expected plugin-pane setup to finish" + assert sum(output_lines(pane, "__PANE0__") for pane in panes) == 1 + assert sum(output_lines(pane, "__PANE1__") for pane in panes) == 1 + assert panes[0].show_option("synchronize-panes") is True + assert panes[1].show_option("synchronize-panes") is True + assert window.show_option("synchronize-panes") is expected_window_sync + + +class SyncPluginOverrideFixture(t.NamedTuple): + """Fixture for plugin synchronize-panes override behavior.""" + + test_id: str + window_extra: dict[str, t.Any] + plugin_synchronize: bool + expected_synchronized: bool + + +SYNC_PLUGIN_OVERRIDE_FIXTURES: list[SyncPluginOverrideFixture] = [ + SyncPluginOverrideFixture( + test_id="no_config_plugin_on", + window_extra={}, + plugin_synchronize=True, + expected_synchronized=True, + ), + SyncPluginOverrideFixture( + test_id="options_on_plugin_off", + window_extra={"options": {"synchronize-panes": "on"}}, + plugin_synchronize=False, + expected_synchronized=False, + ), + SyncPluginOverrideFixture( + test_id="options_off_plugin_on", + window_extra={"options": {"synchronize-panes": "off"}}, + plugin_synchronize=True, + expected_synchronized=True, + ), + SyncPluginOverrideFixture( + test_id="synchronize_true_plugin_off", + window_extra={"synchronize": True}, + plugin_synchronize=False, + expected_synchronized=False, + ), + SyncPluginOverrideFixture( + test_id="synchronize_false_plugin_on", + window_extra={"synchronize": False}, + plugin_synchronize=True, + expected_synchronized=True, + ), +] + + +def _assert_synchronize_setup_isolated(window: Window) -> None: + """Assert pane setup commands did not broadcast across synchronized panes.""" + panes = window.panes + + def output_lines(pane: Pane, marker: str) -> int: + return sum(1 for line in pane.capture_pane() if line.strip() == marker) + + def setup_complete() -> bool: + return ( + output_lines(panes[0], "__PANE0__") >= 1 + and output_lines(panes[1], "__PANE1__") >= 1 + ) + + assert retry_until(setup_complete), "Expected setup markers in their own panes" + assert output_lines(panes[0], "__PANE1__") == 0 + assert output_lines(panes[1], "__PANE0__") == 0 + + +def _build_synchronize_plugin_workspace( + session: Session, + test_id: str, + window_extra: dict[str, t.Any], + plugins: list[t.Any], +) -> Window: + """Build a two-pane workspace for synchronize-panes plugin tests.""" + window_config: dict[str, t.Any] = { + "window_name": f"sync-plugin-{test_id}", + "panes": [ + "printf '__PANE0__\\n'", + "printf '__PANE1__\\n'", + ], + } + window_config.update(window_extra) + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [window_config], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + server=session.server, + plugins=plugins, + ) + builder.build(session=session) + + window = session.windows[0] + _assert_synchronize_setup_isolated(window) + return window + + +@pytest.mark.parametrize( + list(SyncPluginOverrideFixture._fields), + SYNC_PLUGIN_OVERRIDE_FIXTURES, + ids=[fixture.test_id for fixture in SYNC_PLUGIN_OVERRIDE_FIXTURES], +) +def test_synchronize_preserves_on_window_create_plugin_override( + session: Session, + test_id: str, + window_extra: dict[str, t.Any], + plugin_synchronize: bool, + expected_synchronized: bool, +) -> None: + """on_window_create can override configured initial synchronize-panes state.""" + + class SyncOnWindowCreatePlugin: + """Plugin that chooses the final sync state before pane setup.""" + + def before_workspace_builder(self, session: Session) -> None: + """No-op workspace hook.""" + + def on_window_create(self, window: Window) -> None: + """Set the final synchronized state before pane setup starts.""" + window.set_option("synchronize-panes", plugin_synchronize) + + def after_window_finished(self, window: Window) -> None: + """No-op window hook.""" + + window = _build_synchronize_plugin_workspace( + session=session, + test_id=test_id, + window_extra=window_extra, + plugins=[SyncOnWindowCreatePlugin()], + ) + + assert window.show_option("synchronize-panes") is expected_synchronized + + +class SyncPluginPrecedenceFixture(t.NamedTuple): + """Fixture for synchronize-panes plugin/config precedence.""" + + test_id: str + window_extra: dict[str, t.Any] + on_window_create_synchronize: bool + after_window_finished_synchronize: bool | None + expected_synchronized: bool + + +SYNC_PLUGIN_PRECEDENCE_FIXTURES: list[SyncPluginPrecedenceFixture] = [ + SyncPluginPrecedenceFixture( + test_id="options_after_on_overrides_on_window_create_off", + window_extra={"options_after": {"synchronize-panes": "on"}}, + on_window_create_synchronize=False, + after_window_finished_synchronize=None, + expected_synchronized=True, + ), + SyncPluginPrecedenceFixture( + test_id="after_window_finished_overrides_options_after", + window_extra={"options_after": {"synchronize-panes": "on"}}, + on_window_create_synchronize=False, + after_window_finished_synchronize=False, + expected_synchronized=False, + ), +] + + +@pytest.mark.parametrize( + list(SyncPluginPrecedenceFixture._fields), + SYNC_PLUGIN_PRECEDENCE_FIXTURES, + ids=[fixture.test_id for fixture in SYNC_PLUGIN_PRECEDENCE_FIXTURES], +) +def test_synchronize_options_after_and_plugin_precedence( + session: Session, + test_id: str, + window_extra: dict[str, t.Any], + on_window_create_synchronize: bool, + after_window_finished_synchronize: bool | None, + expected_synchronized: bool, +) -> None: + """options_after and after_window_finished keep their usual late precedence.""" + + class SyncPrecedencePlugin: + """Plugin that mutates sync state at both window hook boundaries.""" + + def before_workspace_builder(self, session: Session) -> None: + """No-op workspace hook.""" + + def on_window_create(self, window: Window) -> None: + """Set the synchronized state before pane setup starts.""" + window.set_option("synchronize-panes", on_window_create_synchronize) + + def after_window_finished(self, window: Window) -> None: + """Optionally set the synchronized state after options_after.""" + if after_window_finished_synchronize is not None: + window.set_option( + "synchronize-panes", + after_window_finished_synchronize, + ) + + window = _build_synchronize_plugin_workspace( + session=session, + test_id=test_id, + window_extra=window_extra, + plugins=[SyncPrecedencePlugin()], + ) + + assert window.show_option("synchronize-panes") is expected_synchronized + + +class SyncFanoutFixture(t.NamedTuple): + """Fixture for shell_command_after under synchronize-panes modes.""" + + test_id: str + window_extra: dict[str, t.Any] + + +SYNC_FANOUT_FIXTURES: list[SyncFanoutFixture] = [ + SyncFanoutFixture(test_id="sync_after", window_extra={"synchronize": "after"}), + SyncFanoutFixture(test_id="sync_before", window_extra={"synchronize": "before"}), + SyncFanoutFixture(test_id="sync_true", window_extra={"synchronize": True}), + SyncFanoutFixture( + test_id="explicit_option", + window_extra={"options": {"synchronize-panes": "on"}}, + ), +] + + +@pytest.mark.parametrize( + list(SyncFanoutFixture._fields), + SYNC_FANOUT_FIXTURES, + ids=[fixture.test_id for fixture in SYNC_FANOUT_FIXTURES], +) +def test_shell_command_after_runs_once_per_pane_when_synchronized( + session: Session, + test_id: str, + window_extra: dict[str, t.Any], +) -> None: + """shell_command_after runs exactly once per pane in every sync mode. + + tmuxp keeps synchronize-panes off while it sends post-build commands, + then restores the final configured synchronize-panes state afterward. + """ + window_config: dict[str, t.Any] = { + "window_name": f"sync-cmds-{test_id}", + "shell_command_after": [ + "echo __SYNC_AF''TER__", + "echo __SYNC_DO''NE__", + ], + "panes": ["echo pane0", "echo pane1"], + } + window_config.update(window_extra) + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [window_config], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + window = session.windows[0] + assert window.show_option("synchronize-panes") is True + + def output_lines(pane: Pane, marker: str) -> int: + return sum(1 for line in pane.capture_pane() if line.strip() == marker) + + for pane in window.panes: + + def done(p: Pane = pane) -> bool: + return output_lines(p, "__SYNC_DONE__") >= 1 + + assert retry_until(done), f"Expected __SYNC_DONE__ in pane {pane.pane_id}" + count = output_lines(pane, "__SYNC_AFTER__") + assert count == 1, ( + f"Pane {pane.pane_id} ran shell_command_after {count} times; " + "synchronize-panes was enabled before the fan-out" + ) + + +def test_pane_titles( + session: Session, + caplog: pytest.LogCaptureFixture, +) -> None: + """Pane title config sets pane-border options and pane titles.""" + workspace: dict[str, t.Any] = { + "session_name": session.name, + "enable_pane_titles": True, + "windows": [ + { + "window_name": "titled", + "panes": [ + {"title": "editor", "shell_command": ["echo pane0"]}, + {"title": "runner", "shell_command": ["echo pane1"]}, + {"title": "", "shell_command": ["echo pane2"]}, + {"shell_command": ["echo pane3"]}, + ], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.builder"): + 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) == 4 + + def check_title(pane: Pane, expected: str) -> bool: + pane.refresh() + return pane.pane_title == expected + + assert retry_until(functools.partial(check_title, panes[0], "editor")), ( + f"Expected title 'editor', got {panes[0].pane_title!r}" + ) + assert retry_until(functools.partial(check_title, panes[1], "runner")), ( + f"Expected title 'runner', got {panes[1].pane_title!r}" + ) + + # tmux discards empty titles; an explicit title: "" warns instead + blank_warnings = [ + record + for record in caplog.records + if record.levelno == logging.WARNING and hasattr(record, "tmux_pane") + ] + assert len(blank_warnings) == 1 + assert blank_warnings[0].tmux_pane == panes[2].pane_id + + +class ClearBuilderFixture(t.NamedTuple): + """Fixture for clear window behavior.""" + + test_id: str + clear: bool + marker_should_remain: bool + + +CLEAR_BUILDER_FIXTURES: list[ClearBuilderFixture] = [ + ClearBuilderFixture( + test_id="clear-true", + clear=True, + marker_should_remain=False, + ), + ClearBuilderFixture( + test_id="clear-false", + clear=False, + marker_should_remain=True, + ), +] + + +@pytest.mark.parametrize( + list(ClearBuilderFixture._fields), + CLEAR_BUILDER_FIXTURES, + ids=[fixture.test_id for fixture in CLEAR_BUILDER_FIXTURES], +) +def test_clear_window( + session: Session, + test_id: str, + clear: bool, + marker_should_remain: bool, +) -> None: + """Clear sends clear to panes only when enabled.""" + marker = f"__{test_id.upper().replace('-', '_')}__" + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "clear-test", + "clear": clear, + "panes": [{"shell_command": [f"echo {marker}"]}], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + pane = session.windows[0].panes[0] + + def marker_visible() -> bool: + return marker in "\n".join(pane.capture_pane()) + + if marker_should_remain: + assert retry_until(marker_visible) + else: + + def marker_cleared() -> bool: + return not marker_visible() + + assert retry_until(marker_cleared, raises=False) + + +class PostBuildSuppressHistoryFixture(t.NamedTuple): + """Fixture for post-build suppress_history behavior.""" + + test_id: str + suppress_history: bool | None + expected_suppress_history: bool + + +POST_BUILD_SUPPRESS_HISTORY_FIXTURES: list[PostBuildSuppressHistoryFixture] = [ + PostBuildSuppressHistoryFixture( + test_id="default", + suppress_history=None, + expected_suppress_history=True, + ), + PostBuildSuppressHistoryFixture( + test_id="explicit-false", + suppress_history=False, + expected_suppress_history=False, + ), +] + + +@pytest.mark.parametrize( + list(PostBuildSuppressHistoryFixture._fields), + POST_BUILD_SUPPRESS_HISTORY_FIXTURES, + ids=[fixture.test_id for fixture in POST_BUILD_SUPPRESS_HISTORY_FIXTURES], +) +def test_post_build_commands_honor_suppress_history( + session: Session, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + suppress_history: bool | None, + expected_suppress_history: bool, +) -> None: + """Post-build commands use the window suppress_history setting.""" + sent: list[tuple[str | None, bool | None]] = [] + original_send_keys = Pane.send_keys + + def spy_send_keys( + self: Pane, + cmd: str | None = None, + enter: bool | None = True, + suppress_history: bool | None = False, + literal: bool | None = False, + reset: bool | None = None, + copy_mode_cmd: str | None = None, + repeat: int | None = None, + expand_formats: bool | None = None, + hex_keys: bool | None = None, + target_client: str | None = None, + key_name: bool | None = None, + ) -> None: + sent.append((cmd, suppress_history)) + original_send_keys( + self, + cmd=cmd, + enter=enter, + suppress_history=suppress_history, + literal=literal, + reset=reset, + copy_mode_cmd=copy_mode_cmd, + repeat=repeat, + expand_formats=expand_formats, + hex_keys=hex_keys, + target_client=target_client, + key_name=key_name, + ) + + monkeypatch.setattr(Pane, "send_keys", spy_send_keys) + + window_config: dict[str, t.Any] = { + "window_name": f"post-build-history-{test_id}", + "shell_command_after": ["echo __POST_BUILD_AFTER__"], + "clear": True, + "panes": ["echo pane"], + } + if suppress_history is not None: + window_config["suppress_history"] = suppress_history + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [window_config], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + assert ("echo __POST_BUILD_AFTER__", expected_suppress_history) in sent + assert ("clear", expected_suppress_history) in sent + + def test_window_shell( session: Session, ) -> None: @@ -1768,3 +2718,119 @@ def test_builder_logs_window_and_pane_creation( assert len(cmd_logs) >= 1 builder.session.kill() + + +class AfterCommandOptionsFixture(t.NamedTuple): + """Fixture for per-command options in shell_command_after mappings.""" + + test_id: str + command: dict[str, t.Any] + expected_enter: bool + expected_sleeps: list[float] + + +AFTER_COMMAND_OPTIONS_FIXTURES: list[AfterCommandOptionsFixture] = [ + AfterCommandOptionsFixture( + test_id="enter_false_respected", + command={"cmd": "echo __AFTER_OPT__", "enter": False}, + expected_enter=False, + expected_sleeps=[], + ), + AfterCommandOptionsFixture( + test_id="sleeps_once_per_wave", + command={ + "cmd": "echo __AFTER_OPT__", + "sleep_before": 0.21, + "sleep_after": 0.31, + }, + expected_enter=True, + expected_sleeps=[0.21, 0.31], + ), +] + + +@pytest.mark.parametrize( + list(AfterCommandOptionsFixture._fields), + AFTER_COMMAND_OPTIONS_FIXTURES, + ids=[fixture.test_id for fixture in AFTER_COMMAND_OPTIONS_FIXTURES], +) +def test_shell_command_after_honors_command_options( + session: Session, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + command: dict[str, t.Any], + expected_enter: bool, + expected_sleeps: list[float], +) -> None: + """shell_command_after mappings keep their enter and sleep options. + + The pane command loop honors per-command enter/sleep_before/ + sleep_after; the post-build fan-out accepts the same mapping syntax + and must behave the same. Sleeps apply once per command wave, not + once per pane. + """ + sent: list[tuple[str | None, bool | None]] = [] + original_send_keys = Pane.send_keys + + def spy_send_keys( + self: Pane, + cmd: str | None = None, + enter: bool | None = True, + suppress_history: bool | None = False, + literal: bool | None = False, + reset: bool | None = None, + copy_mode_cmd: str | None = None, + repeat: int | None = None, + expand_formats: bool | None = None, + hex_keys: bool | None = None, + target_client: str | None = None, + key_name: bool | None = None, + ) -> None: + sent.append((cmd, enter)) + original_send_keys( + self, + cmd=cmd, + enter=enter, + suppress_history=suppress_history, + literal=literal, + reset=reset, + copy_mode_cmd=copy_mode_cmd, + repeat=repeat, + expand_formats=expand_formats, + hex_keys=hex_keys, + target_client=target_client, + key_name=key_name, + ) + + monkeypatch.setattr(Pane, "send_keys", spy_send_keys) + + slept: list[float] = [] + original_sleep = time.sleep + + def spy_sleep(seconds: float) -> None: + slept.append(seconds) + original_sleep(0) + + monkeypatch.setattr("tmuxp.workspace.builder.time.sleep", spy_sleep) + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": f"after-options-{test_id}", + "shell_command_after": [command], + "panes": ["echo pane0", "echo pane1"], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + after_sends = [entry for entry in sent if entry[0] == "echo __AFTER_OPT__"] + assert len(after_sends) == 2, "after-command should reach both panes" + assert all(enter is expected_enter for _, enter in after_sends) + + option_sleeps = [s for s in slept if s in (0.21, 0.31)] + assert sorted(option_sleeps) == sorted(expected_sleeps) diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index fc6d5ccd5b..dfbe29942d 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -333,6 +333,245 @@ def test_validate_plugins() -> None: assert excinfo.match("only supports list type") +class SynchronizeFixture(t.NamedTuple): + """Fixture for synchronize shorthand expansion.""" + + test_id: str + synchronize: bool | str + expected_final_sync: bool | None + expect_warning: bool + + +SYNCHRONIZE_FIXTURES: list[SynchronizeFixture] = [ + SynchronizeFixture( + test_id="true-enables-before", + synchronize=True, + expected_final_sync=True, + expect_warning=False, + ), + SynchronizeFixture( + test_id="before-enables-before", + synchronize="before", + expected_final_sync=True, + expect_warning=False, + ), + SynchronizeFixture( + test_id="after-enables-after", + synchronize="after", + expected_final_sync=True, + expect_warning=False, + ), + SynchronizeFixture( + test_id="false-disables-final-sync", + synchronize=False, + expected_final_sync=False, + expect_warning=False, + ), + SynchronizeFixture( + test_id="invalid-warns-and-removes-key", + synchronize="during", + expected_final_sync=None, + expect_warning=True, + ), +] + + +@pytest.mark.parametrize( + list(SynchronizeFixture._fields), + SYNCHRONIZE_FIXTURES, + ids=[fixture.test_id for fixture in SYNCHRONIZE_FIXTURES], +) +def test_expand_synchronize( + caplog: pytest.LogCaptureFixture, + test_id: str, + synchronize: bool | str, + expected_final_sync: bool | None, + expect_warning: bool, +) -> None: + """expand() normalizes synchronize without enabling tmux during build.""" + workspace: dict[str, t.Any] = { + "session_name": f"sync-{test_id}", + "windows": [ + { + "window_name": "main", + "synchronize": synchronize, + "panes": [{"shell_command": ["echo hi"]}], + }, + ], + } + + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.loader"): + result = loader.expand(workspace) + window = result["windows"][0] + + assert "synchronize" not in window + assert "synchronize-panes" not in window.get("options", {}) + assert "synchronize-panes" not in window.get("options_after", {}) + if expected_final_sync is None: + assert "_synchronize_panes" not in window + else: + assert window["_synchronize_panes"] is expected_final_sync + + synchronize_warnings = [ + record + for record in caplog.records + if record.levelno == logging.WARNING and hasattr(record, "tmux_window") + ] + assert bool(synchronize_warnings) is expect_warning + if expect_warning: + warning = t.cast(t.Any, synchronize_warnings[0]) + assert warning.tmux_session == f"sync-{test_id}" + assert warning.tmux_window == "main" + + +class ShellCommandAfterFixture(t.NamedTuple): + """Fixture for shell_command_after expansion.""" + + test_id: str + shell_command_after: str | list[str] + expected_commands: list[str] + + +SHELL_COMMAND_AFTER_FIXTURES: list[ShellCommandAfterFixture] = [ + ShellCommandAfterFixture( + test_id="string-command", + shell_command_after="echo done", + expected_commands=["echo done"], + ), + ShellCommandAfterFixture( + test_id="list-commands", + shell_command_after=["echo done", "echo bye"], + expected_commands=["echo done", "echo bye"], + ), +] + + +@pytest.mark.parametrize( + list(ShellCommandAfterFixture._fields), + SHELL_COMMAND_AFTER_FIXTURES, + ids=[fixture.test_id for fixture in SHELL_COMMAND_AFTER_FIXTURES], +) +def test_expand_shell_command_after( + test_id: str, + shell_command_after: str | list[str], + expected_commands: list[str], +) -> None: + """expand() normalizes shell_command_after like shell_command_before.""" + workspace: dict[str, t.Any] = { + "session_name": f"after-{test_id}", + "windows": [ + { + "window_name": "main", + "shell_command_after": shell_command_after, + "panes": [{"shell_command": ["echo hi"]}], + }, + ], + } + + result = loader.expand(workspace) + after = result["windows"][0]["shell_command_after"] + + assert [cmd["cmd"] for cmd in after["shell_command"]] == expected_commands + + +class PaneTitleFixture(t.NamedTuple): + """Fixture for pane title option expansion.""" + + test_id: str + enabled: bool + position: str | None + expected_position: str | None + expect_warning: bool + + +PANE_TITLE_FIXTURES: list[PaneTitleFixture] = [ + PaneTitleFixture( + test_id="enabled-defaults", + enabled=True, + position=None, + expected_position="top", + expect_warning=False, + ), + PaneTitleFixture( + test_id="enabled-bottom", + enabled=True, + position="bottom", + expected_position="bottom", + expect_warning=False, + ), + PaneTitleFixture( + test_id="enabled-invalid-falls-back", + enabled=True, + position="invalid", + expected_position="top", + expect_warning=True, + ), + PaneTitleFixture( + test_id="disabled-removes-session-keys", + enabled=False, + position="bottom", + expected_position=None, + expect_warning=False, + ), +] + + +@pytest.mark.parametrize( + list(PaneTitleFixture._fields), + PANE_TITLE_FIXTURES, + ids=[fixture.test_id for fixture in PANE_TITLE_FIXTURES], +) +def test_expand_pane_titles( + caplog: pytest.LogCaptureFixture, + test_id: str, + enabled: bool, + position: str | None, + expected_position: str | None, + expect_warning: bool, +) -> None: + """expand() turns session pane title keys into tmux window options.""" + workspace: dict[str, t.Any] = { + "session_name": f"title-{test_id}", + "enable_pane_titles": enabled, + "pane_title_format": " #T ", + "windows": [ + { + "window_name": "main", + "panes": [ + {"title": "editor", "shell_command": ["echo hi"]}, + {"shell_command": ["echo bye"]}, + ], + }, + ], + } + if position is not None: + workspace["pane_title_position"] = position + + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.loader"): + result = loader.expand(workspace) + + window = result["windows"][0] + assert "enable_pane_titles" not in result + assert "pane_title_position" not in result + assert "pane_title_format" not in result + assert window["panes"][0]["title"] == "editor" + + if expected_position is None: + assert "pane-border-status" not in window.get("options", {}) + else: + assert window["options"]["pane-border-status"] == expected_position + assert window["options"]["pane-border-format"] == " #T " + + position_warnings = [ + record + for record in caplog.records + if record.levelno == logging.WARNING and hasattr(record, "tmux_session") + ] + assert bool(position_warnings) is expect_warning + if expect_warning: + assert position_warnings[0].tmux_session == f"title-{test_id}" + + def test_expand_logs_debug( tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture,