Skip to content

Commit 1a82b3a

Browse files
committed
Tmuxinator(fix[startup_targets]): Respect tmux base indices during import
why: tmuxinator numeric startup_window and startup_pane values are tmux indices, not Python list offsets. Importing them as list positions changes which window or pane receives focus and breaks compatibility with existing configs, especially when base-index or pane-base-index are nonzero. what: - resolve numeric startup targets against tmux base-index and pane-base-index - read live tmux index settings in the tmuxinator import CLI path - add importer and CLI coverage for base-index aware conversion and fallback
1 parent 77b7ddf commit 1a82b3a

4 files changed

Lines changed: 274 additions & 77 deletions

File tree

src/tmuxp/cli/import_config.py

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import sys
1010
import typing as t
1111

12+
from libtmux.common import tmux_cmd
13+
1214
from tmuxp._internal.config_reader import ConfigReader
1315
from tmuxp._internal.private_path import PrivatePath
1416
from tmuxp.workspace import importers
@@ -89,6 +91,59 @@ def _resolve_path_no_overwrite(workspace_file: str) -> str:
8991
return str(path)
9092

9193

94+
def _read_tmux_index_option(*args: str) -> int | None:
95+
"""Return tmux index option value, or ``None`` when unavailable.
96+
97+
Examples
98+
--------
99+
>>> from collections import namedtuple
100+
>>> import tmuxp.cli.import_config as import_config
101+
>>> FakeResponse = namedtuple("FakeResponse", "returncode stdout")
102+
>>> monkeypatch.setattr(
103+
... import_config,
104+
... "tmux_cmd",
105+
... lambda *args: FakeResponse(returncode=0, stdout=["1"]),
106+
... )
107+
>>> import_config._read_tmux_index_option("show-options", "-gv", "base-index")
108+
1
109+
"""
110+
try:
111+
response = tmux_cmd(*args)
112+
except Exception:
113+
return None
114+
115+
if response.returncode != 0 or not response.stdout:
116+
return None
117+
118+
try:
119+
return int(response.stdout[0])
120+
except ValueError:
121+
return None
122+
123+
124+
def _get_tmuxinator_base_indices() -> tuple[int, int]:
125+
"""Return tmux base-index and pane-base-index for tmuxinator import.
126+
127+
Examples
128+
--------
129+
>>> import tmuxp.cli.import_config as import_config
130+
>>> monkeypatch.setattr(
131+
... import_config,
132+
... "_read_tmux_index_option",
133+
... lambda *args: 1 if args[-1] == "base-index" else 2,
134+
... )
135+
>>> import_config._get_tmuxinator_base_indices()
136+
(1, 2)
137+
"""
138+
base_index = _read_tmux_index_option("show-options", "-gv", "base-index")
139+
pane_base_index = _read_tmux_index_option(
140+
"show-window-options",
141+
"-gv",
142+
"pane-base-index",
143+
)
144+
return (base_index or 0, pane_base_index or 0)
145+
146+
92147
def command_import(
93148
workspace_file: str,
94149
print_list: str,
@@ -253,12 +308,21 @@ def command_import_tmuxinator(
253308
"""
254309
color_mode = get_color_mode(color)
255310
colors = Colors(color_mode)
311+
base_index, pane_base_index = _get_tmuxinator_base_indices()
256312

257313
workspace_file = find_workspace_file(
258314
workspace_file,
259315
workspace_dir=get_tmuxinator_dir(),
260316
)
261-
import_config(workspace_file, importers.import_tmuxinator, colors=colors)
317+
318+
def tmuxinator_importer(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]:
319+
return importers.import_tmuxinator(
320+
workspace_dict,
321+
base_index=base_index,
322+
pane_base_index=pane_base_index,
323+
)
324+
325+
import_config(workspace_file, tmuxinator_importer, colors=colors)
262326

263327

264328
def command_import_teamocil(

src/tmuxp/workspace/importers.py

Lines changed: 76 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,56 @@ def _convert_named_panes(panes: list[t.Any]) -> list[t.Any]:
6161
return result
6262

6363

64-
def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]:
64+
def _resolve_tmux_list_position(
65+
target: str | int,
66+
*,
67+
base_index: int,
68+
item_count: int,
69+
) -> int | None:
70+
"""Resolve a tmux index into a Python list position.
71+
72+
Parameters
73+
----------
74+
target : str or int
75+
tmux index from tmuxinator configuration
76+
base_index : int
77+
tmux base index for the list being resolved
78+
item_count : int
79+
number of items in the generated tmuxp list
80+
81+
Returns
82+
-------
83+
int or None
84+
Python list position if the target resolves within bounds
85+
86+
Examples
87+
--------
88+
>>> _resolve_tmux_list_position(1, base_index=1, item_count=2)
89+
0
90+
91+
>>> _resolve_tmux_list_position("2", base_index=1, item_count=2)
92+
1
93+
94+
>>> _resolve_tmux_list_position(3, base_index=1, item_count=2) is None
95+
True
96+
"""
97+
try:
98+
list_position = int(target) - base_index
99+
except ValueError:
100+
return None
101+
102+
if 0 <= list_position < item_count:
103+
return list_position
104+
105+
return None
106+
107+
108+
def import_tmuxinator(
109+
workspace_dict: dict[str, t.Any],
110+
*,
111+
base_index: int = 0,
112+
pane_base_index: int = 0,
113+
) -> dict[str, t.Any]:
65114
"""Return tmuxp workspace from a `tmuxinator`_ yaml workspace.
66115
67116
.. _tmuxinator: https://github.com/aziz/tmuxinator
@@ -243,26 +292,18 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]:
243292
_matched = True
244293
break
245294
if not _matched:
246-
try:
247-
_idx = int(_startup_window)
248-
if 0 <= _idx < len(tmuxp_workspace["windows"]):
249-
tmuxp_workspace["windows"][_idx]["focus"] = True
250-
logger.warning(
251-
"startup_window %r resolved as 0-based list index, "
252-
"which may differ from tmuxinator's tmux base-index "
253-
"semantics; use window name for reliable matching",
254-
_startup_window,
255-
)
256-
else:
257-
logger.warning(
258-
"startup_window index %d out of range (0-%d)",
259-
_idx,
260-
len(tmuxp_workspace["windows"]) - 1,
261-
)
262-
except (ValueError, IndexError):
295+
_idx = _resolve_tmux_list_position(
296+
_startup_window,
297+
base_index=base_index,
298+
item_count=len(tmuxp_workspace["windows"]),
299+
)
300+
if _idx is not None:
301+
tmuxp_workspace["windows"][_idx]["focus"] = True
302+
else:
263303
logger.warning(
264-
"startup_window %s not found",
304+
"startup_window %r not found for tmux base-index %d",
265305
_startup_window,
306+
base_index,
266307
)
267308

268309
if _startup_pane is not None and tmuxp_workspace["windows"]:
@@ -271,33 +312,25 @@ def import_tmuxinator(workspace_dict: dict[str, t.Any]) -> dict[str, t.Any]:
271312
tmuxp_workspace["windows"][0],
272313
)
273314
if "panes" in _target:
274-
try:
275-
_pidx = int(_startup_pane)
276-
if 0 <= _pidx < len(_target["panes"]):
277-
_pane = _target["panes"][_pidx]
278-
if isinstance(_pane, dict):
279-
_pane["focus"] = True
280-
else:
281-
_target["panes"][_pidx] = {
282-
"shell_command": [_pane] if _pane else [],
283-
"focus": True,
284-
}
285-
logger.warning(
286-
"startup_pane %r resolved as 0-based list index, "
287-
"which may differ from tmuxinator's tmux "
288-
"pane-base-index semantics",
289-
_startup_pane,
290-
)
315+
_pidx = _resolve_tmux_list_position(
316+
_startup_pane,
317+
base_index=pane_base_index,
318+
item_count=len(_target["panes"]),
319+
)
320+
if _pidx is not None:
321+
_pane = _target["panes"][_pidx]
322+
if isinstance(_pane, dict):
323+
_pane["focus"] = True
291324
else:
292-
logger.warning(
293-
"startup_pane index %d out of range (0-%d)",
294-
_pidx,
295-
len(_target["panes"]) - 1,
296-
)
297-
except (ValueError, IndexError):
325+
_target["panes"][_pidx] = {
326+
"shell_command": [_pane] if _pane else [],
327+
"focus": True,
328+
}
329+
else:
298330
logger.warning(
299-
"startup_pane %s not found",
331+
"startup_pane %r not found for tmux pane-base-index %d",
300332
_startup_pane,
333+
pane_base_index,
301334
)
302335

303336
return tmuxp_workspace

tests/cli/test_import.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from tests.fixtures import utils as test_utils
1212
from tmuxp import cli
13+
from tmuxp.cli import import_config as import_config_module
1314

1415
if t.TYPE_CHECKING:
1516
import pathlib
@@ -173,3 +174,95 @@ def test_import_tmuxinator(
173174

174175
new_config_yaml = tmp_path / "la.yaml"
175176
assert new_config_yaml.exists()
177+
178+
179+
def test_get_tmuxinator_base_indices_reads_live_tmux_options(
180+
monkeypatch: pytest.MonkeyPatch,
181+
) -> None:
182+
"""Tmuxinator import reads tmux base indices from live tmux options."""
183+
184+
class FakeTmuxResponse(t.NamedTuple):
185+
"""Fake tmux command response."""
186+
187+
returncode: int
188+
stdout: list[str]
189+
190+
def fake_tmux_cmd(*args: str) -> FakeTmuxResponse:
191+
if args == ("show-options", "-gv", "base-index"):
192+
return FakeTmuxResponse(returncode=0, stdout=["1"])
193+
if args == ("show-window-options", "-gv", "pane-base-index"):
194+
return FakeTmuxResponse(returncode=0, stdout=["2"])
195+
msg = f"unexpected tmux args: {args!r}"
196+
raise AssertionError(msg)
197+
198+
monkeypatch.setattr(import_config_module, "tmux_cmd", fake_tmux_cmd)
199+
200+
assert import_config_module._get_tmuxinator_base_indices() == (1, 2)
201+
202+
203+
def test_get_tmuxinator_base_indices_falls_back_when_tmux_unavailable(
204+
monkeypatch: pytest.MonkeyPatch,
205+
) -> None:
206+
"""Tmuxinator import falls back to tmux defaults when lookup fails."""
207+
208+
def raise_tmux_error(*args: str) -> t.NoReturn:
209+
msg = f"tmux unavailable for {args!r}"
210+
raise RuntimeError(msg)
211+
212+
monkeypatch.setattr(import_config_module, "tmux_cmd", raise_tmux_error)
213+
214+
assert import_config_module._get_tmuxinator_base_indices() == (0, 0)
215+
216+
217+
def test_command_import_tmuxinator_passes_resolved_base_indices(
218+
monkeypatch: pytest.MonkeyPatch,
219+
) -> None:
220+
"""Tmuxinator import command passes resolved tmux indices to the importer."""
221+
captured: dict[str, t.Any] = {}
222+
223+
def fake_find_workspace_file(
224+
workspace_file: str,
225+
workspace_dir: t.Any,
226+
) -> str:
227+
captured["workspace_file"] = workspace_file
228+
captured["workspace_dir"] = workspace_dir
229+
return workspace_file
230+
231+
def fake_import_config(
232+
workspace_file: str,
233+
importfunc: t.Callable[[dict[str, t.Any]], dict[str, t.Any]],
234+
parser: t.Any = None,
235+
colors: t.Any = None,
236+
) -> None:
237+
captured["workspace_file"] = workspace_file
238+
captured["parser"] = parser
239+
captured["colors"] = colors
240+
captured["imported"] = importfunc(
241+
{
242+
"name": "sample",
243+
"startup_window": 1,
244+
"startup_pane": 2,
245+
"windows": [{"editor": ["vim", "logs"]}],
246+
}
247+
)
248+
249+
monkeypatch.setattr(
250+
import_config_module,
251+
"find_workspace_file",
252+
fake_find_workspace_file,
253+
)
254+
monkeypatch.setattr(import_config_module, "import_config", fake_import_config)
255+
monkeypatch.setattr(
256+
import_config_module,
257+
"_get_tmuxinator_base_indices",
258+
lambda: (1, 2),
259+
)
260+
261+
import_config_module.command_import_tmuxinator("sample.yml")
262+
263+
imported = captured["imported"]
264+
assert imported["windows"][0]["focus"] is True
265+
assert imported["windows"][0]["panes"][0] == {
266+
"shell_command": ["vim"],
267+
"focus": True,
268+
}

0 commit comments

Comments
 (0)