Skip to content

Commit 2f428cb

Browse files
committed
load(feat[append-multi]): Coalesce multi-file --append into one session
why: ``tmuxp load f1 f2 f3 --append`` previously failed on the second file because each file was loaded against its own config's ``session_name``, and the second file's ``new_session`` would collide with the session the first file just created. Multi-file ``--append`` should land every workspace in a single shared target session. The fix has two parts because the old behavior was wrong on two layers: 1. Pre-resolve the target session name once, before the load loop. ``--new-session-name`` wins; otherwise fall back to the first workspace file's ``session_name`` config key. Apply the resolved name to every iteration's ``new_session_name`` so all loads target the same session. 2. Make ``_dispatch_build`` honor ``append`` even when the target session already exists in detached mode. Previously the ``if detached:`` branch unconditionally called ``builder.build()`` (no append arg), which re-tried session creation and raised ``TmuxSessionExists``. New hoisted check: when ``append=True`` and the named session already exists, build windows directly onto the existing session via ``builder.build(existing, append=True)``, honoring ``detached`` for whether to attach afterwards. what: - src/tmuxp/cli/load.py: pre-resolve append_target_session_name in command_load when args.append and len(workspace_files) > 1; force every loop iteration to use it as new_session_name. - src/tmuxp/cli/load.py: add session_name kwarg to _dispatch_build; hoist an append-to-existing-session branch above the detached/ attached split so detached + append + session-exists is honored. - tests/cli/test_load.py: two functional tests covering (a) ``load f1 f2 --append`` coalescing into f1's session_name, and (b) ``--new-session-name`` overriding the first file's session_name. Re-ports PR #839 (originally a single 2022 WIP commit). Drops the WIP's broken in-loop scoping of original_session_name and its type mismatch between ConfigReader._from_file (returns dict) and new_session_name (str).
1 parent 6edda37 commit 2f428cb

2 files changed

Lines changed: 162 additions & 0 deletions

File tree

src/tmuxp/cli/load.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ def _dispatch_build(
328328
pre_attach_hook: t.Callable[[], None] | None = None,
329329
on_error_hook: t.Callable[[], None] | None = None,
330330
pre_prompt_hook: t.Callable[[], None] | None = None,
331+
session_name: str | None = None,
331332
) -> Session | None:
332333
"""Dispatch the build to the correct load path and handle errors.
333334
@@ -367,6 +368,26 @@ def _dispatch_build(
367368
True
368369
"""
369370
try:
371+
# When append is requested and the target session already exists,
372+
# build windows onto it directly. This applies whether running
373+
# detached or attached, and is what makes ``tmuxp load f1 f2 --append``
374+
# land all files in the same session instead of failing on the
375+
# second file's create-session call.
376+
if append and session_name is not None and builder.session_exists(session_name):
377+
existing = builder.server.sessions.get(session_name=session_name)
378+
builder.build(existing, append=True)
379+
assert builder.session is not None
380+
if pre_attach_hook is not None:
381+
pre_attach_hook()
382+
if not detached:
383+
if "TMUX" in os.environ:
384+
tmux_env = os.environ.pop("TMUX")
385+
builder.session.switch_client()
386+
os.environ["TMUX"] = tmux_env
387+
else:
388+
builder.session.attach()
389+
return _setup_plugins(builder)
390+
370391
if detached:
371392
_load_detached(builder, cli_colors, pre_output_hook=pre_attach_hook)
372393
return _setup_plugins(builder)
@@ -618,6 +639,7 @@ def load_workspace(
618639
append,
619640
answer_yes,
620641
cli_colors,
642+
session_name=session_name,
621643
)
622644
if result is not None:
623645
summary = ""
@@ -696,6 +718,7 @@ def _emit_success() -> None:
696718
pre_attach_hook=_emit_success,
697719
on_error_hook=spinner.stop,
698720
pre_prompt_hook=spinner.stop,
721+
session_name=session_name,
699722
)
700723
if result is not None:
701724
_emit_success()
@@ -877,6 +900,33 @@ def command_load(
877900
original_detached_option = args.detached
878901
original_new_session_name = args.new_session_name
879902

903+
# When --append spans multiple files, every load must target the same
904+
# session — otherwise each file would be appended to whatever its own
905+
# config names, defeating the point. Resolve the target once: explicit
906+
# --new-session-name wins; otherwise fall back to the first file's
907+
# `session_name` config key.
908+
append_target_session_name: str | None = None
909+
if args.append and last_idx > 0:
910+
if original_new_session_name:
911+
append_target_session_name = original_new_session_name
912+
else:
913+
try:
914+
first_workspace_file = find_workspace_file(
915+
args.workspace_files[0],
916+
workspace_dir=get_workspace_dir(),
917+
)
918+
first_config = config_reader.ConfigReader._from_file(
919+
pathlib.Path(first_workspace_file),
920+
)
921+
resolved = first_config.get("session_name")
922+
if isinstance(resolved, str):
923+
append_target_session_name = resolved
924+
except Exception:
925+
logger.debug(
926+
"could not pre-resolve session_name for multi-file --append",
927+
exc_info=True,
928+
)
929+
880930
for idx, workspace_file in enumerate(args.workspace_files):
881931
workspace_file = find_workspace_file(
882932
workspace_file,
@@ -890,6 +940,9 @@ def command_load(
890940
detached = True
891941
new_session_name = None
892942

943+
if append_target_session_name is not None:
944+
new_session_name = append_target_session_name
945+
893946
load_workspace(
894947
workspace_file,
895948
socket_name=args.socket_name,

tests/cli/test_load.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,115 @@ def test_regression_00132_session_name_with_dots(
356356
cli.cli(["load", *cli_args])
357357

358358

359+
@pytest.mark.usefixtures("tmuxp_configdir_default")
360+
def test_load_multi_file_append_coalesces_into_first_sessions_name(
361+
tmp_path: pathlib.Path,
362+
tmuxp_configdir: pathlib.Path,
363+
server: Server,
364+
session: Session,
365+
capsys: pytest.CaptureFixture[str],
366+
monkeypatch: pytest.MonkeyPatch,
367+
) -> None:
368+
"""``tmuxp load f1 f2 --append`` lands every file in f1's session_name."""
369+
assert server.socket_name is not None
370+
monkeypatch.chdir(tmp_path)
371+
372+
(tmuxp_configdir / "first_cfg.yaml").write_text(
373+
"session_name: shared_target\n"
374+
"windows:\n"
375+
" - window_name: from_first\n"
376+
" panes:\n"
377+
" -\n",
378+
encoding="utf-8",
379+
)
380+
(tmuxp_configdir / "second_cfg.yaml").write_text(
381+
"session_name: should_not_appear\n"
382+
"windows:\n"
383+
" - window_name: from_second\n"
384+
" panes:\n"
385+
" -\n",
386+
encoding="utf-8",
387+
)
388+
389+
with contextlib.suppress(SystemExit):
390+
cli.cli(
391+
[
392+
"load",
393+
"first_cfg",
394+
"second_cfg",
395+
"--append",
396+
"-d",
397+
"-L",
398+
server.socket_name,
399+
"-y",
400+
],
401+
)
402+
403+
assert server.has_session("shared_target")
404+
assert not server.has_session("should_not_appear")
405+
target = server.sessions.get(session_name="shared_target")
406+
assert target is not None
407+
window_names = {w.name for w in target.windows}
408+
assert "from_first" in window_names
409+
assert "from_second" in window_names
410+
411+
412+
@pytest.mark.usefixtures("tmuxp_configdir_default")
413+
def test_load_multi_file_append_with_new_session_name_overrides_first_cfg(
414+
tmp_path: pathlib.Path,
415+
tmuxp_configdir: pathlib.Path,
416+
server: Server,
417+
session: Session,
418+
capsys: pytest.CaptureFixture[str],
419+
monkeypatch: pytest.MonkeyPatch,
420+
) -> None:
421+
"""``--append --new-session-name=foo`` wins over the first file's name."""
422+
assert server.socket_name is not None
423+
monkeypatch.chdir(tmp_path)
424+
425+
(tmuxp_configdir / "first_cfg.yaml").write_text(
426+
"session_name: from_first_cfg\n"
427+
"windows:\n"
428+
" - window_name: from_first\n"
429+
" panes:\n"
430+
" -\n",
431+
encoding="utf-8",
432+
)
433+
(tmuxp_configdir / "second_cfg.yaml").write_text(
434+
"session_name: from_second_cfg\n"
435+
"windows:\n"
436+
" - window_name: from_second\n"
437+
" panes:\n"
438+
" -\n",
439+
encoding="utf-8",
440+
)
441+
442+
with contextlib.suppress(SystemExit):
443+
cli.cli(
444+
[
445+
"load",
446+
"first_cfg",
447+
"second_cfg",
448+
"--append",
449+
"-s",
450+
"explicit_target",
451+
"-d",
452+
"-L",
453+
server.socket_name,
454+
"-y",
455+
],
456+
)
457+
458+
assert server.has_session("explicit_target")
459+
assert not server.has_session("from_first_cfg")
460+
assert not server.has_session("from_second_cfg")
461+
target = server.sessions.get(session_name="explicit_target")
462+
assert target is not None
463+
window_names = {w.name for w in target.windows}
464+
assert "from_first" in window_names
465+
assert "from_second" in window_names
466+
467+
359468
class ZshAutotitleTestFixture(t.NamedTuple):
360469
"""Test fixture for zsh auto title warning tests."""
361470

0 commit comments

Comments
 (0)