Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
workspace(feat[builder]) add _bulk_set_options helper for set-option …
…loops

why: tmuxp's workspace builder fires N round-trips per workspace
load applying session/window/options_after metadata, one
``set-option`` per loop iteration. Centralising those calls
behind a single helper means we have one place to swap in a
pipelined dispatch (e.g. ``Server.batch()``) the moment libtmux
exposes one. The helper lands first without call-site changes
so behaviour parity can be verified in isolation.

The original draft of this work (``libtmux-protocol`` branch,
``f4c95faa``) used ``Server.batch()`` directly. That API doesn't
exist on libtmux 0.56.0 (it ships with the unmerged
libtmux-protocol-engines work), and even when present its perf
gain is documented as "no-op cost on subprocess and imsg" — so
batching only pays off on the unshipped control_mode engine. To
land the API shape against current libtmux without committing to
unshipped upstream, this commit issues one ``set-option`` per
item via ``server.cmd``. The helper API (mapping, target,
scope_flag) is deliberately batch-shaped so the body can swap
back to a single batched dispatch later without touching the
call sites.

what:
- Add ``WorkspaceBuilder._bulk_set_options(items, *, target,
  scope_flag)`` mirroring ``OptionsMixin.set_option``'s
  bool -> "on"/"off" normalisation (libtmux/options.py:712) so
  loop and helper paths produce identical tmux commands.
- Reuse libtmux's ``handle_option_error`` to preserve the
  ``OptionError`` subclasses callers already catch — the same
  propagation ``OptionsMixin.set_option`` does inside its own
  body (libtmux/options.py:712).
- Empty-mapping guard avoids a zero-iteration loop.
- Add two regression tests in tests/workspace/test_builder.py:
  ``test_bulk_set_options_propagates_unknown_option_error`` proves
  bad options still raise ``OptionError`` from the helper;
  ``test_bulk_set_options_applies_session_window_and_options_after``
  exercises the helper across all three scopes (-s, -g, -w) and
  verifies each option lands on tmux via ``show_option`` /
  ``show-option -v``.

Salvaged from libtmux-protocol@f4c95faa with the body rewritten
to drop the unshipped ``Server.batch()`` dependency. Test
docstrings updated to match the loop-based reality.
  • Loading branch information
tony committed Jul 4, 2026
commit 3e7df3dd7a0e9be30ffbb20f94db59ea4da09ca3
54 changes: 53 additions & 1 deletion src/tmuxp/workspace/builder/classic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,7 +26,7 @@
)

if t.TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import Iterator, Mapping

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -425,6 +426,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.

Expand Down
45 changes: 45 additions & 0 deletions tests/workspace/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,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:
Expand Down