From ea2084fba7f3b0e5cee14f653a106ceb70740f1a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 05:53:12 -0500 Subject: [PATCH 1/4] Workspace(feat[importers]): Add tmuxinator and teamocil parity fallbacks why: Preserve common tmuxinator and teamocil configuration semantics after import without bundling unrelated CLI commands into the parity split. what: - Parse tmuxinator tmux flags into workspace server fallbacks and resolve config paths from the workspace file - Map tmuxinator hooks, pre-window commands, startup focus, synchronize, and named panes to tmuxp keys - Expand teamocil pane normalization and focus/options passthrough - Update existing import fixtures whose expected output encodes the new pre-to-on_project_start mapping --- src/tmuxp/cli/load.py | 40 +++ src/tmuxp/workspace/importers.py | 394 ++++++++++++++++++---- tests/fixtures/import_tmuxinator/test2.py | 3 +- tests/fixtures/import_tmuxinator/test3.py | 2 +- 4 files changed, 377 insertions(+), 62 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index e7f478c179..0663689c8a 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -195,6 +195,29 @@ def load_plugins( return plugins +def _resolve_workspace_config_file( + config_file: str, + workspace_file: pathlib.Path, +) -> str: + """Resolve a workspace-level tmux config path. + + Relative paths are interpreted from the workspace file directory. + + Examples + -------- + >>> from tmuxp.cli.load import _resolve_workspace_config_file + >>> _resolve_workspace_config_file( + ... "./tmux.conf", + ... pathlib.Path("/tmp/project/session.yaml"), + ... ) + '/tmp/project/tmux.conf' + """ + config_path = pathlib.Path(config_file).expanduser() + if not config_path.is_absolute(): + config_path = workspace_file.parent / config_path + return str(config_path.resolve(strict=False)) + + def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: """ Reattach session (depending on env being inside tmux already or not). @@ -609,6 +632,23 @@ def load_workspace( if new_session_name: expanded_workspace["session_name"] = new_session_name + 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) + + workspace_config_file = expanded_workspace.pop("config", None) + if tmux_config_file is None and workspace_config_file is not None: + tmux_config_file = _resolve_workspace_config_file( + str(workspace_config_file), + workspace_file, + ) + # propagate workspace inheritance (e.g. session -> window, window -> pane) expanded_workspace = loader.trickle(expanded_workspace) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index 65184d73a4..b8e4e086c8 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -3,24 +3,234 @@ from __future__ import annotations import logging +import shlex import typing as t logger = logging.getLogger(__name__) -def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: - """Return tmuxp workspace from a `tmuxinator`_ yaml workspace. +def _join_tmuxinator_parameters(parameters: t.Any) -> str | None: + """Return tmuxinator's command-parameter string. - .. _tmuxinator: https://github.com/aziz/tmuxinator + Tmuxinator joins array-valued project parameters with a semicolon-space + separator before sending them to tmux. + + Examples + -------- + >>> _join_tmuxinator_parameters(["one", "two"]) + 'one; two' + >>> _join_tmuxinator_parameters("one") + 'one' + >>> _join_tmuxinator_parameters(None) is None + True + """ + if parameters is None: + return None + if isinstance(parameters, list): + return "; ".join(str(parameter) for parameter in parameters) + return str(parameters) + + +def _parse_tmux_options(raw_args: str) -> dict[str, str]: + """Parse tmux pass-through flags from tmuxinator args. + + Examples + -------- + >>> _parse_tmux_options("-f ~/.tmux.conf -L mysocket") + {'config': '~/.tmux.conf', 'socket_name': 'mysocket'} + >>> _parse_tmux_options("-f./tmux.conf -S/tmp/tmux.sock") + {'config': './tmux.conf', 'socket_path': '/tmp/tmux.sock'} + """ + flag_map = {"-f": "config", "-L": "socket_name", "-S": "socket_path"} + result: dict[str, str] = {} + tokens = shlex.split(raw_args) + token_index = 0 + + while token_index < len(tokens): + token = tokens[token_index] + if token in flag_map: + token_index += 1 + if token_index < len(tokens): + result[flag_map[token]] = tokens[token_index] + else: + for prefix, key in flag_map.items(): + if token.startswith(prefix) and len(token) > len(prefix): + result[key] = token[len(prefix) :] + break + token_index += 1 + + return result + + +def _convert_named_panes(panes: list[t.Any]) -> list[t.Any]: + """Convert tmuxinator named pane dictionaries to tmuxp pane titles. + + Examples + -------- + >>> _convert_named_panes(["vim", {"logs": ["tail -f log"]}]) + ['vim', {'shell_command': ['tail -f log'], 'title': 'logs'}] + >>> _convert_named_panes([{"empty": None}]) + [{'shell_command': [], 'title': 'empty'}] + """ + 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): + shell_command: list[t.Any] = [commands] + elif commands is None: + shell_command = [] + elif isinstance(commands, list): + shell_command = commands + else: + shell_command = [commands] + result.append( + { + "shell_command": shell_command, + "title": str(pane_name), + } + ) + else: + result.append(pane) + return result + + +def _resolve_tmux_list_position( + target: str | int, + *, + base_index: int, + item_count: int, +) -> int | None: + """Resolve a tmux index to a Python list position. + + Examples + -------- + >>> _resolve_tmux_list_position(1, base_index=1, item_count=2) + 0 + >>> _resolve_tmux_list_position("2", base_index=1, item_count=2) + 1 + >>> _resolve_tmux_list_position(3, base_index=1, item_count=2) is None + True + """ + try: + list_position = int(target) - base_index + except ValueError: + return None + + if 0 <= list_position < item_count: + return list_position + return None + + +def _focus_tmuxinator_startup_target( + tmuxp_workspace: dict[str, t.Any], + startup_window: str | int | None, + startup_pane: str | int | None, + *, + base_index: int, + pane_base_index: int, +) -> None: + """Apply tmuxinator startup focus keys to a tmuxp workspace. + + Examples + -------- + >>> workspace = {"windows": [{"window_name": "main", "panes": ["vim"]}]} + >>> _focus_tmuxinator_startup_target( + ... workspace, "main", 0, base_index=0, pane_base_index=0 + ... ) + >>> workspace["windows"][0]["focus"] + True + >>> workspace["windows"][0]["panes"][0] + {'shell_command': ['vim'], 'focus': True} + """ + windows = tmuxp_workspace.get("windows", []) + if not windows: + return + + target_window = windows[0] + if startup_window is not None: + target_window = {} + for window in windows: + if window.get("window_name") == str(startup_window): + target_window = window + break + if not target_window: + window_index = _resolve_tmux_list_position( + startup_window, + base_index=base_index, + item_count=len(windows), + ) + if window_index is None: + logger.warning( + "startup_window %r not found for tmux base-index %d", + startup_window, + base_index, + ) + return + target_window = windows[window_index] + + target_window["focus"] = True + elif startup_pane is not None: + target_window["focus"] = True + + if startup_pane is None or "panes" not in target_window: + return + + panes = target_window["panes"] + pane_index = _resolve_tmux_list_position( + startup_pane, + base_index=pane_base_index, + item_count=len(panes), + ) + if pane_index is None: + logger.warning( + "startup_pane %r not found for tmux pane-base-index %d", + startup_pane, + pane_base_index, + ) + return + + pane = panes[pane_index] + if isinstance(pane, dict): + pane["focus"] = True + else: + panes[pane_index] = { + "shell_command": [pane] if pane else [], + "focus": True, + } + + +def import_tmuxinator( + workspace_dict: dict[str, t.Any], + *, + base_index: int = 0, + pane_base_index: int = 0, +) -> dict[str, t.Any]: + """Return tmuxp workspace from a ``tmuxinator`` yaml workspace. Parameters ---------- workspace_dict : dict python dict for tmuxp workspace. + base_index : int + tmux ``base-index`` used to resolve numeric ``startup_window``. + pane_base_index : int + tmux ``pane-base-index`` used to resolve numeric ``startup_pane``. Returns ------- dict + + Examples + -------- + >>> result = import_tmuxinator( + ... {"name": "demo", "windows": [{"editor": {"panes": ["vim"]}}]} + ... ) + >>> result["session_name"] + 'demo' + >>> result["windows"][0]["window_name"] + 'editor' """ logger.debug( "importing tmuxinator workspace", @@ -44,94 +254,132 @@ 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"] - - if "-f" in tmuxp_workspace["config"]: - tmuxp_workspace["config"] = ( - tmuxp_workspace["config"].replace("-f", "").strip() - ) - elif "tmux_options" in workspace_dict: - tmuxp_workspace["config"] = workspace_dict["tmux_options"] + raw_tmux_options = workspace_dict.get("cli_args") or workspace_dict.get( + "tmux_options", + ) + if raw_tmux_options: + tmuxp_workspace.update(_parse_tmux_options(str(raw_tmux_options))) + + for socket_key in ("socket_name", "socket_path"): + if socket_key in workspace_dict: + explicit_value = workspace_dict[socket_key] + if ( + socket_key in tmuxp_workspace + and tmuxp_workspace[socket_key] != explicit_value + ): + logger.warning( + "explicit %s %s overrides tmux option value %s", + socket_key, + explicit_value, + tmuxp_workspace[socket_key], + ) + tmuxp_workspace[socket_key] = explicit_value + + 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 "pre" in workspace_dict and "on_project_start" not in tmuxp_workspace: + pre_command = _join_tmuxinator_parameters(workspace_dict["pre"]) + if pre_command is not None: + tmuxp_workspace["on_project_start"] = pre_command + + pre_window = None + if "rbenv" in workspace_dict: + pre_window = "rbenv shell {}".format(workspace_dict["rbenv"]) + elif "rvm" in workspace_dict: + pre_window = "rvm use {}".format(workspace_dict["rvm"]) + elif "pre_tab" in workspace_dict: + pre_window = _join_tmuxinator_parameters(workspace_dict["pre_tab"]) + elif "pre_window" in workspace_dict: + pre_window = _join_tmuxinator_parameters(workspace_dict["pre_window"]) + + if pre_window is not None: + tmuxp_workspace["shell_command_before"] = [pre_window] + + 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() + for unsupported_key, hint in { + "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 or on_project_stop", + }.items(): + if unsupported_key in workspace_dict: + logger.warning( + "tmuxinator key %r is not supported by tmuxp: %s", + unsupported_key, + hint, ) - 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"]] - else: - tmuxp_workspace["shell_command_before"] = workspace_dict["pre"] - - 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"]), - ) - 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 and v["synchronize"] in (True, "before", "after"): + window_dict["synchronize"] = v["synchronize"] tmuxp_workspace["windows"].append(window_dict) + + _focus_tmuxinator_startup_target( + tmuxp_workspace, + workspace_dict.get("startup_window"), + workspace_dict.get("startup_pane"), + base_index=base_index, + pane_base_index=pane_base_index, + ) return tmuxp_workspace def import_teamocil(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]: - """Return tmuxp workspace from a `teamocil`_ yaml workspace. - - .. _teamocil: https://github.com/remiprev/teamocil + """Return tmuxp workspace from a ``teamocil`` yaml workspace. Parameters ---------- workspace_dict : dict python dict for tmuxp workspace - Notes - ----- - Todos: + Examples + -------- + >>> result = import_teamocil( + ... {"windows": [{"name": "dev", "panes": [{"cmd": "ls"}]}]} + ... ) + >>> result["windows"][0]["panes"] + [{'shell_command': 'ls'}] - - 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 +406,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 +418,44 @@ 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 p is None: + panes.append({"shell_command": []}) + continue + if isinstance(p, str): + panes.append({"shell_command": [p]}) + continue + if not isinstance(p, dict): + panes.append({"shell_command": [str(p)]}) + continue if "cmd" in p: p["shell_command"] = p.pop("cmd") + elif "commands" in p: + p["shell_command"] = p.pop("commands") if "width" in p: - # TODO support for height/width + logger.warning( + "unsupported pane key %s dropped", + "width", + extra={"tmux_window": w["name"]}, + ) p.pop("width") - window_dict["panes"] = w["panes"] + 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"] tmuxp_workspace["windows"].append(window_dict) return tmuxp_workspace 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": [ { From e77f391d50e3a2f13f8ccf5a58fc48467db3818f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 05:54:42 -0500 Subject: [PATCH 2/4] test(importers): Cover tmuxinator and teamocil parity fallbacks why: Pin flag parsing, hook and pane mapping, and teamocil v1.x normalization behavior. what: - Importer tests for cli_args parsing, startup focus, synchronize, named panes, and teamocil focus/options passthrough - New fixture workspaces for edge cases (numeric names, aliases, named panes, v1.x format) - Load CLI tests for workspace server fallbacks --- tests/cli/test_load.py | 124 +++++++++ tests/fixtures/import_teamocil/__init__.py | 2 +- tests/fixtures/import_teamocil/test5.py | 42 +++ tests/fixtures/import_teamocil/test5.yaml | 13 + tests/fixtures/import_teamocil/test6.py | 48 ++++ tests/fixtures/import_teamocil/test6.yaml | 14 + tests/fixtures/import_tmuxinator/__init__.py | 2 +- tests/fixtures/import_tmuxinator/test4.py | 28 ++ tests/fixtures/import_tmuxinator/test4.yaml | 6 + tests/fixtures/import_tmuxinator/test5.py | 36 +++ tests/fixtures/import_tmuxinator/test5.yaml | 10 + tests/fixtures/import_tmuxinator/test6.py | 53 ++++ tests/fixtures/import_tmuxinator/test6.yaml | 16 ++ tests/workspace/test_import_teamocil.py | 85 ++++++ tests/workspace/test_import_tmuxinator.py | 256 +++++++++++++++++++ 15 files changed, 733 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/import_teamocil/test5.py create mode 100644 tests/fixtures/import_teamocil/test5.yaml create mode 100644 tests/fixtures/import_teamocil/test6.py create mode 100644 tests/fixtures/import_teamocil/test6.yaml create mode 100644 tests/fixtures/import_tmuxinator/test4.py create mode 100644 tests/fixtures/import_tmuxinator/test4.yaml create mode 100644 tests/fixtures/import_tmuxinator/test5.py create mode 100644 tests/fixtures/import_tmuxinator/test5.yaml create mode 100644 tests/fixtures/import_tmuxinator/test6.py create mode 100644 tests/fixtures/import_tmuxinator/test6.yaml diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index a924e92bf3..1cc7021a17 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -21,6 +21,7 @@ from tmuxp.cli.load import ( _load_append_windows_to_current_session, _load_attached, + _resolve_workspace_config_file, load_plugins, load_workspace, ) @@ -74,6 +75,129 @@ def test_load_workspace_passes_tmux_config( assert session.server.config_file == str(FIXTURE_PATH / "tmux" / "tmux.conf") +class WorkspaceConfigResolutionFixture(t.NamedTuple): + """Fixture for workspace-level tmux config path resolution.""" + + test_id: str + config_value: str + expected_kind: str + + +WORKSPACE_CONFIG_RESOLUTION_FIXTURES: list[WorkspaceConfigResolutionFixture] = [ + WorkspaceConfigResolutionFixture( + test_id="relative", + config_value="./tmux.conf", + expected_kind="workspace", + ), + WorkspaceConfigResolutionFixture( + test_id="home", + config_value="~/.tmux.conf", + expected_kind="home", + ), + WorkspaceConfigResolutionFixture( + test_id="absolute", + config_value="/tmp/tmux.conf", + expected_kind="absolute", + ), +] + + +@pytest.mark.parametrize( + list(WorkspaceConfigResolutionFixture._fields), + WORKSPACE_CONFIG_RESOLUTION_FIXTURES, + ids=[fixture.test_id for fixture in WORKSPACE_CONFIG_RESOLUTION_FIXTURES], +) +def test_resolve_workspace_config_file( + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + config_value: str, + expected_kind: str, +) -> None: + """Workspace config paths resolve relative to the workspace file.""" + home_dir = tmp_path / "home" + home_dir.mkdir() + monkeypatch.setenv("HOME", str(home_dir)) + workspace_file = tmp_path / "project" / "session.yaml" + workspace_file.parent.mkdir() + + if expected_kind == "workspace": + expected = workspace_file.parent / "tmux.conf" + elif expected_kind == "home": + expected = home_dir / ".tmux.conf" + else: + expected = pathlib.Path(config_value) + + assert _resolve_workspace_config_file(config_value, workspace_file) == str( + expected.resolve(strict=False), + ) + + +def test_load_workspace_uses_workspace_server_fallbacks( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Workspace socket/config keys are Server fallbacks.""" + monkeypatch.delenv("TMUX", raising=False) + tmux_conf = tmp_path / "tmux.conf" + tmux_conf.write_text("", encoding="utf-8") + workspace_file = tmp_path / "fallbacks.yaml" + workspace_file.write_text( + f"""\ +session_name: fallback-test +socket_name: {server.socket_name} +config: ./tmux.conf +windows: + - window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + session = load_workspace(workspace_file, detached=True) + + assert isinstance(session, Session) + assert session.server.socket_name == server.socket_name + assert session.server.config_file == str(tmux_conf.resolve(strict=False)) + + +def test_load_workspace_cli_server_args_override_workspace_fallbacks( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """CLI socket/config values take precedence over workspace fallback keys.""" + monkeypatch.delenv("TMUX", raising=False) + cli_tmux_conf = tmp_path / "cli.tmux.conf" + cli_tmux_conf.write_text("", encoding="utf-8") + workspace_file = tmp_path / "fallbacks.yaml" + workspace_file.write_text( + """\ +session_name: fallback-override-test +socket_name: ignored-socket +config: ./ignored.tmux.conf +windows: + - window_name: main + panes: + - echo hello +""", + encoding="utf-8", + ) + + session = load_workspace( + workspace_file, + socket_name=server.socket_name, + tmux_config_file=str(cli_tmux_conf), + detached=True, + ) + + assert isinstance(session, Session) + assert session.server.socket_name == server.socket_name + assert session.server.config_file == str(cli_tmux_conf) + + def test_load_workspace_named_session( server: Server, monkeypatch: pytest.MonkeyPatch, 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..1c68e90940 --- /dev/null +++ b/tests/fixtures/import_teamocil/test5.py @@ -0,0 +1,42 @@ +"""Teamocil data fixtures for import_teamocil tests, 5th test.""" + +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/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..4f8984932b --- /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", + "synchronize": True, + "panes": ["echo 'pane1'", "echo 'pane2'"], + }, + { + "window_name": "synced-after", + "synchronize": "after", + "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/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index 0ea457e7c6..90a72ede38 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="v1_string_panes", + teamocil_yaml=fixtures.test5.teamocil_yaml, + teamocil_dict=fixtures.test5.teamocil_dict, + tmuxp_dict=fixtures.test5.expected, + ), + TeamocilConfigTestFixture( + test_id="focus_and_options", + teamocil_yaml=fixtures.test6.teamocil_yaml, + teamocil_dict=fixtures.test6.teamocil_dict, + tmuxp_dict=fixtures.test6.expected, + ), ] @@ -157,3 +169,76 @@ 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" + + +class TeamocilPaneConversionFixture(t.NamedTuple): + """Test fixture for teamocil pane conversion.""" + + test_id: str + pane_config: t.Any + expected_pane: dict[str, t.Any] + + +TEAMOCIL_PANE_CONVERSION_FIXTURES: list[TeamocilPaneConversionFixture] = [ + TeamocilPaneConversionFixture( + test_id="string-pane", + pane_config="echo hi", + expected_pane={"shell_command": ["echo hi"]}, + ), + TeamocilPaneConversionFixture( + test_id="blank-pane", + pane_config=None, + expected_pane={"shell_command": []}, + ), + TeamocilPaneConversionFixture( + test_id="commands-key", + pane_config={"commands": ["pwd", "ls"]}, + expected_pane={"shell_command": ["pwd", "ls"]}, + ), +] + + +@pytest.mark.parametrize( + list(TeamocilPaneConversionFixture._fields), + TEAMOCIL_PANE_CONVERSION_FIXTURES, + ids=[test.test_id for test in TEAMOCIL_PANE_CONVERSION_FIXTURES], +) +def test_import_teamocil_pane_conversion( + test_id: str, + pane_config: t.Any, + expected_pane: dict[str, t.Any], +) -> None: + """Teamocil panes normalize to tmuxp pane dictionaries.""" + workspace = { + "windows": [ + { + "name": "main", + "panes": [pane_config], + } + ], + } + + result = importers.import_teamocil(workspace) + + assert result["windows"][0]["panes"] == [expected_pane] + + +def test_import_teamocil_warns_and_drops_pane_dimensions( + caplog: pytest.LogCaptureFixture, +) -> None: + """Unsupported width/height pane keys are dropped with warnings.""" + workspace = { + "windows": [ + { + "name": "main", + "panes": [{"cmd": "vim", "width": 30, "height": 20}], + } + ], + } + + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.importers"): + result = importers.import_teamocil(workspace) + + assert result["windows"][0]["panes"] == [{"shell_command": "vim"}] + assert any("width" in record.message for record in caplog.records) + assert any("height" in record.message for record in caplog.records) diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 457605f2ab..25f5fafc7b 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_config", + tmuxinator_yaml=fixtures.test4.tmuxinator_yaml, + tmuxinator_dict=fixtures.test4.tmuxinator_dict, + tmuxp_dict=fixtures.test4.expected, + ), + TmuxinatorConfigTestFixture( + test_id="startup_focus_config", + tmuxinator_yaml=fixtures.test5.tmuxinator_yaml, + tmuxinator_dict=fixtures.test5.tmuxinator_dict, + tmuxp_dict=fixtures.test5.expected, + ), + TmuxinatorConfigTestFixture( + test_id="synchronize_config", + tmuxinator_yaml=fixtures.test6.tmuxinator_yaml, + tmuxinator_dict=fixtures.test6.tmuxinator_dict, + tmuxp_dict=fixtures.test6.expected, + ), ] @@ -76,3 +94,241 @@ 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" + + +class TmuxinatorNamedPaneFixture(t.NamedTuple): + """Test fixture for tmuxinator named pane conversion.""" + + test_id: str + panes_input: list[t.Any] + expected_panes: list[t.Any] + + +TMUXINATOR_NAMED_PANE_FIXTURES: list[TmuxinatorNamedPaneFixture] = [ + TmuxinatorNamedPaneFixture( + test_id="single-named-pane", + panes_input=[{"git_log": "git log --oneline"}], + expected_panes=[ + {"shell_command": ["git log --oneline"], "title": "git_log"}, + ], + ), + TmuxinatorNamedPaneFixture( + test_id="named-pane-list-commands", + panes_input=[{"server": ["ssh server", "echo hello"]}], + expected_panes=[ + {"shell_command": ["ssh server", "echo hello"], "title": "server"}, + ], + ), + TmuxinatorNamedPaneFixture( + test_id="plain-panes-unchanged", + panes_input=["vim", None, "top"], + expected_panes=["vim", None, "top"], + ), +] + + +@pytest.mark.parametrize( + list(TmuxinatorNamedPaneFixture._fields), + TMUXINATOR_NAMED_PANE_FIXTURES, + ids=[test.test_id for test in TMUXINATOR_NAMED_PANE_FIXTURES], +) +def test_convert_named_panes( + test_id: str, + panes_input: list[t.Any], + expected_panes: list[t.Any], +) -> None: + """Named tmuxinator panes convert to tmuxp pane titles.""" + assert importers._convert_named_panes(panes_input) == expected_panes + + +def test_import_tmuxinator_named_pane_in_window() -> None: + """Named pane dictionaries inside window configs are converted.""" + workspace = { + "name": "test", + "windows": [ + { + "editor": { + "panes": [ + "vim", + {"logs": ["tail -f log/dev.log"]}, + ], + }, + }, + ], + } + + result = importers.import_tmuxinator(workspace) + + assert result["windows"][0]["panes"] == [ + "vim", + {"shell_command": ["tail -f log/dev.log"], "title": "logs"}, + ] + + +def test_import_tmuxinator_startup_pane_focuses_default_window() -> None: + """startup_pane alone focuses the default startup window.""" + workspace = { + "name": "startup-pane", + "startup_pane": 0, + "windows": [ + {"editor": ["vim", "top"]}, + {"server": "rails s"}, + ], + } + + result = importers.import_tmuxinator(workspace) + + assert result["windows"][0]["focus"] is True + assert result["windows"][0]["panes"][0] == { + "shell_command": ["vim"], + "focus": True, + } + assert "focus" not in result["windows"][1] + + +class TmuxinatorPreWindowFixture(t.NamedTuple): + """Test fixture for tmuxinator pre-window mapping.""" + + test_id: str + config_extra: dict[str, t.Any] + expected_shell_command_before: list[str] | None + expected_on_project_start: str | None + + +TMUXINATOR_PRE_WINDOW_FIXTURES: list[TmuxinatorPreWindowFixture] = [ + TmuxinatorPreWindowFixture( + test_id="pre-window-only", + config_extra={"pre_window": "echo PRE"}, + expected_shell_command_before=["echo PRE"], + expected_on_project_start=None, + ), + TmuxinatorPreWindowFixture( + test_id="pre-list", + config_extra={"pre": ["echo one", "echo two"]}, + expected_shell_command_before=None, + expected_on_project_start="echo one; echo two", + ), + TmuxinatorPreWindowFixture( + test_id="pre-and-pre-window", + config_extra={"pre": "sudo start", "pre_window": ["cd /app", "nvm use"]}, + expected_shell_command_before=["cd /app; nvm use"], + expected_on_project_start="sudo start", + ), + TmuxinatorPreWindowFixture( + test_id="rbenv-precedence", + config_extra={"rbenv": "3.2.0", "pre_window": "echo PRE"}, + expected_shell_command_before=["rbenv shell 3.2.0"], + expected_on_project_start=None, + ), + TmuxinatorPreWindowFixture( + test_id="rvm-precedence", + config_extra={"rvm": "2.1.1", "pre_tab": "source .env"}, + expected_shell_command_before=["rvm use 2.1.1"], + expected_on_project_start=None, + ), +] + + +@pytest.mark.parametrize( + list(TmuxinatorPreWindowFixture._fields), + TMUXINATOR_PRE_WINDOW_FIXTURES, + ids=[test.test_id for test in TMUXINATOR_PRE_WINDOW_FIXTURES], +) +def test_import_tmuxinator_pre_window_mapping( + test_id: str, + config_extra: dict[str, t.Any], + expected_shell_command_before: list[str] | None, + expected_on_project_start: str | None, +) -> None: + """Pre maps to project start; pre-window keys map to shell_command_before.""" + workspace: dict[str, t.Any] = { + "name": "pre-test", + "windows": [{"editor": "vim"}], + **config_extra, + } + + result = importers.import_tmuxinator(workspace) + + if expected_shell_command_before is None: + assert "shell_command_before" not in result + else: + assert result["shell_command_before"] == expected_shell_command_before + + if expected_on_project_start is None: + assert "on_project_start" not in result + else: + assert result["on_project_start"] == expected_on_project_start + + +def test_import_tmuxinator_socket_name_conflict_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + """Explicit socket_name warns when it overrides cli_args -L.""" + 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" + assert any("explicit" in record.message for record in caplog.records) + assert any("from_cli" in record.message for record in caplog.records) + + +def test_import_tmuxinator_attached_tmux_flags() -> None: + """Attached tmux flags like -Lsocket are parsed.""" + workspace = { + "name": "attached-flags", + "cli_args": "-f./tmux.conf -Lmysocket -S/tmp/tmux.sock", + "windows": [{"editor": "vim"}], + } + + result = importers.import_tmuxinator(workspace) + + assert result["config"] == "./tmux.conf" + assert result["socket_name"] == "mysocket" + assert result["socket_path"] == "/tmp/tmux.sock" + + +def test_import_tmuxinator_passthrough_pane_titles_and_hooks() -> None: + """Native tmuxp pane title and lifecycle hook keys pass through.""" + 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_numeric_window_names_expand() -> None: + """Numeric YAML window keys become strings before loader expansion.""" + 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" From 015a9127fc17ba89ee29c7d8b37db5d7737e4eb6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 05:56:11 -0500 Subject: [PATCH 3/4] docs(cli[import]): Document tmuxinator and teamocil fallback mappings why: Make the importer's key mappings discoverable for users migrating configs. what: - Note the fallback conversions on the import page --- docs/cli/import.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/cli/import.md b/docs/cli/import.md index 1e5d191ff8..4530da69b8 100644 --- a/docs/cli/import.md +++ b/docs/cli/import.md @@ -38,6 +38,14 @@ $ tmuxp import teamocil /path/to/file.json ```` +### Supported teamocil fields + +The teamocil importer preserves top-level and `session`-wrapped configs, window +`root`, `layout`, `clear`, `focus`, `options`, and `filters` entries. Pane +entries using `cmd`, `commands`, string panes, and blank panes are converted to +tmuxp pane dictionaries. Unsupported pane `width` and `height` values are +dropped with a warning. + (import-tmuxinator)= ## From tmuxinator @@ -64,6 +72,18 @@ $ tmuxp import tmuxinator /path/to/file.yaml ```` +### Supported tmuxinator fields + +The tmuxinator importer maps `project_name`/`name`, `project_root`/`root`, +legacy `tabs`, `cli_args`/`tmux_options` values for `-f`, `-L`, and `-S`, +`socket_name`, `socket_path`, `pre`, `pre_window`, `pre_tab`, `rbenv`, `rvm`, +`startup_window`, `startup_pane`, `synchronize`, pane-title keys, and lifecycle +hook keys. + +Named pane entries such as `{logs: tail -f log/development.log}` become tmuxp +pane `title` values. Imported `config` values are resolved relative to the saved +workspace file when the workspace is loaded. + ````{tab} JSON ```console From b7026c86bf694432046dbff29dbfd7a367686005 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 08:17:42 -0500 Subject: [PATCH 4/4] Workspace(fix[importers]): Assert importer warnings on schema fields why: The socket-override warning carried no structured fields and the import tests matched message substrings against the logging standards; the override message also used present tense for an event. what: - Add tmux_session extra to the socket-override warning and change "overrides" to "overrode" - Assert the override and dropped-pane-key warnings via record extras and args instead of message text --- src/tmuxp/workspace/importers.py | 7 ++++++- tests/workspace/test_import_teamocil.py | 14 ++++++++++++-- tests/workspace/test_import_tmuxinator.py | 10 ++++++++-- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/tmuxp/workspace/importers.py b/src/tmuxp/workspace/importers.py index b8e4e086c8..edb2c5a763 100644 --- a/src/tmuxp/workspace/importers.py +++ b/src/tmuxp/workspace/importers.py @@ -268,10 +268,15 @@ def import_tmuxinator( and tmuxp_workspace[socket_key] != explicit_value ): logger.warning( - "explicit %s %s overrides tmux option value %s", + "explicit %s %s overrode tmux option value %s", socket_key, explicit_value, tmuxp_workspace[socket_key], + extra={ + "tmux_session": str( + tmuxp_workspace.get("session_name") or "", + ), + }, ) tmuxp_workspace[socket_key] = explicit_value diff --git a/tests/workspace/test_import_teamocil.py b/tests/workspace/test_import_teamocil.py index 90a72ede38..1de6af9415 100644 --- a/tests/workspace/test_import_teamocil.py +++ b/tests/workspace/test_import_teamocil.py @@ -240,5 +240,15 @@ def test_import_teamocil_warns_and_drops_pane_dimensions( result = importers.import_teamocil(workspace) assert result["windows"][0]["panes"] == [{"shell_command": "vim"}] - assert any("width" in record.message for record in caplog.records) - assert any("height" in record.message for record in caplog.records) + dropped = [ + record + for record in caplog.records + if record.levelno == logging.WARNING and hasattr(record, "tmux_window") + ] + dropped_keys = sorted( + str(record.args[0]) + for record in dropped + if isinstance(record.args, tuple) and record.args + ) + assert dropped_keys == ["height", "width"] + assert all(record.tmux_window == "main" for record in dropped) diff --git a/tests/workspace/test_import_tmuxinator.py b/tests/workspace/test_import_tmuxinator.py index 25f5fafc7b..9373eeeafc 100644 --- a/tests/workspace/test_import_tmuxinator.py +++ b/tests/workspace/test_import_tmuxinator.py @@ -274,8 +274,14 @@ def test_import_tmuxinator_socket_name_conflict_warns( result = importers.import_tmuxinator(workspace) assert result["socket_name"] == "explicit" - assert any("explicit" in record.message for record in caplog.records) - assert any("from_cli" in record.message for record in caplog.records) + conflict_warnings = [ + record + for record in caplog.records + if record.levelno == logging.WARNING and hasattr(record, "tmux_session") + ] + assert len(conflict_warnings) == 1 + assert conflict_warnings[0].tmux_session == "conflict" + assert conflict_warnings[0].args == ("socket_name", "explicit", "from_cli") def test_import_tmuxinator_attached_tmux_flags() -> None: