diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 728b477963..2100a95f72 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.options import handle_option_error from libtmux.pane import Pane from libtmux.server import Server from libtmux.session import Session @@ -19,7 +20,7 @@ from tmuxp.util import get_current_pane, run_before_script if t.TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterator, Mapping logger = logging.getLogger(__name__) @@ -27,7 +28,7 @@ def _wait_for_pane_ready( pane: Pane, timeout: float = 2.0, - interval: float = 0.05, + interval: float = 0.01, ) -> bool: """Wait for pane shell to draw its prompt. @@ -407,6 +408,57 @@ def session_exists(self, session_name: str) -> bool: return False return True + def _bulk_set_options( + self, + items: Mapping[str, int | str | bool], + *, + target: str | None, + scope_flag: str, + ) -> None: + """Apply ``set-option`` for each (key, value) pair. + + Mirrors :meth:`libtmux.options.OptionsMixin.set_option`'s + ``True/False -> "on"/"off"`` convention so behaviour matches a + plain loop of ``set_option`` calls. Errors propagate as the + same ``OptionError`` subclasses ``handle_option_error`` + produces. + + Currently issues one ``set-option`` round-trip per item. The + helper's API (mapping + scope flag + optional target) is + deliberately batch-shaped so the body can swap to a single + pipelined dispatch (e.g. ``Server.batch()``) once libtmux + exposes one, without touching the call sites in ``build`` and + ``iter_create_windows``. + + Parameters + ---------- + items : mapping + Option name -> value pairs. + target : str, optional + Target identifier (session_id / window_id) for ``-t``; + pass ``None`` for global options where ``-g`` already + names the scope. + scope_flag : str + ``"-s"``, ``"-g"``, or ``"-w"``. Selects the option scope. + """ + if not items: + return + server = self.server + assert server is not None + for key, raw_val in items.items(): + if raw_val is True: + val: int | str = "on" + elif raw_val is False: + val = "off" + else: + val = raw_val + if target is not None: + cmd = server.cmd("set-option", scope_flag, "-t", target, key, val) + else: + cmd = server.cmd("set-option", scope_flag, key, val) + if cmd.stderr: + handle_option_error(cmd.stderr[0]) + def build(self, session: Session | None = None, append: bool = False) -> None: """Build tmux workspace in session. @@ -527,12 +579,18 @@ def build(self, session: Session | None = None, append: bool = False) -> None: self.on_build_event({"event": "before_script_done"}) if "options" in self.session_config: - for option, value in self.session_config["options"].items(): - self.session.set_option(option, value) + self._bulk_set_options( + self.session_config["options"], + target=self.session.session_id, + scope_flag="-s", + ) if "global_options" in self.session_config: - for option, value in self.session_config["global_options"].items(): - self.session.set_option(option, value, global_=True) + self._bulk_set_options( + self.session_config["global_options"], + target=None, + scope_flag="-g", + ) if "environment" in self.session_config: for option, value in self.session_config["environment"].items(): @@ -671,8 +729,11 @@ def iter_create_windows( window_config["options"], dict, ): - for key, val in window_config["options"].items(): - window.set_option(key, val) + self._bulk_set_options( + window_config["options"], + target=window.window_id, + scope_flag="-w", + ) if window_config.get("focus"): window.select() @@ -841,8 +902,11 @@ def config_after_window( window_config["options_after"], dict, ): - for key, val in window_config["options_after"].items(): - window.set_option(key, val) + self._bulk_set_options( + window_config["options_after"], + target=window.window_id, + scope_flag="-w", + ) def find_current_attached_session(self) -> Session: """Return current attached session.""" diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index da95168f46..94569ca8bb 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -359,6 +359,51 @@ def f() -> bool: ), "Synchronized command did not execute properly" +def test_bulk_set_options_propagates_unknown_option_error( + session: Session, +) -> None: + """Bad options surface as ``OptionError``.""" + workspace = { + "session_name": "bulk-set-options-bad", + "options": {"this-option-does-not-exist": "value"}, + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + with pytest.raises(libtmux.exc.OptionError): + builder.build(session=session) + + +def test_bulk_set_options_applies_session_window_and_options_after( + session: Session, +) -> None: + """Session, window, and options_after loops all land via the helper.""" + workspace = { + "session_name": "bulk-set-options-good", + "options": {"default-shell": "/bin/sh"}, + "global_options": {"repeat-time": 491}, + "windows": [ + { + "window_name": "main", + "options": {"main-pane-height": 7}, + "options_after": {"synchronize-panes": "on"}, + "panes": [{"shell_command": []}, {"shell_command": []}], + }, + ], + } + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + sess = builder.session + win = sess.active_window + default_shell = sess.show_option("default-shell") + assert isinstance(default_shell, str) + assert "/bin/sh" in default_shell + assert sess.show_option("repeat-time", global_=True) == 491 + assert win.show_option("main-pane-height") == 7 + sync = win.cmd("show-option", "-v", "synchronize-panes").stdout + assert sync == ["on"] + + def test_window_shell( session: Session, ) -> None: