diff --git a/docs/cli.rst b/docs/cli.rst index 87a67a089b6..703ad8cdcb9 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -169,6 +169,23 @@ directory may be loaded with: $ tmuxp load . +If you try to load a config file from within a tmux session, it will ask you +if you want to load and attach to the new session, or just load detached. +You can also load a config file and append the windows to the current active session. + +:: + + Already inside TMUX, switch to session? yes/no + Or (a)ppend windows in the current active session? + [y/n/a]: + +All of these options can be preselected to skip the prompt: + +.. code-block:: bash + $ tmuxp load -y config # load attached + $ tmuxp load -d config # load detached + $ tmuxp load -a config # append windows + Multiple sessions can be loaded at once. The first ones will be created without being attached. The last one will be attached if there is no ``-d`` flag on the command line. diff --git a/docs/quickstart.rst b/docs/quickstart.rst index aebac4812fb..a575582a8bc 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -55,7 +55,8 @@ Load multiple tmux sessions at once: $ tmuxp load example.yaml anothersession.yaml tmuxp will offer to ``switch-client`` for you if you're already in a -session. +session. You can also load a configuration, and append the windows to +the current active session. You can also have a custom tmuxp config directory by setting the ``TMUX_CONFIGDIR`` in your environment variables. diff --git a/tests/fixtures/workspacebuilder/three_windows.yaml b/tests/fixtures/workspacebuilder/three_windows.yaml new file mode 100644 index 00000000000..e0c8286f851 --- /dev/null +++ b/tests/fixtures/workspacebuilder/three_windows.yaml @@ -0,0 +1,14 @@ +session_name: sample_three_windows +windows: +- window_name: first + panes: + - shell_command: + - echo 'first window' +- window_name: second + panes: + - shell_command: + - echo 'second window' +- window_name: third + panes: + - shell_command: + - echo 'third window' diff --git a/tests/fixtures/workspacebuilder/two_windows.yaml b/tests/fixtures/workspacebuilder/two_windows.yaml new file mode 100644 index 00000000000..0f1d40f34a9 --- /dev/null +++ b/tests/fixtures/workspacebuilder/two_windows.yaml @@ -0,0 +1,10 @@ +session_name: sample_two_windows +windows: +- window_name: first + panes: + - shell_command: + - echo 'first window' +- window_name: second + panes: + - shell_command: + - echo 'second window' diff --git a/tests/test_cli.py b/tests/test_cli.py index 2bf16dd368b..e5a7f93ed8a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,6 +5,11 @@ import json import os +try: + from unittest.mock import MagicMock +except ImportError: + from mock import MagicMock + import pytest import click @@ -18,6 +23,8 @@ from tmuxp import cli, config, exc from tmuxp.cli import ( _reattach, + _load_attached, + _load_append_windows_to_current_session, command_debug_info, command_ls, get_config_dir, @@ -1088,6 +1095,107 @@ def test_reattach_plugins(server): assert proc.stdout[0] == "'plugin_test_r'" +def test_load_attached(server, monkeypatch): + # Load a session and attach from outside tmux + monkeypatch.delenv('TMUX', raising=False) + + attach_session_mock = MagicMock() + attach_session_mock.return_value.stderr = None + + monkeypatch.setattr("libtmux.session.Session.attach_session", attach_session_mock) + + yaml_config = loadfixture("workspacebuilder/two_pane.yaml") + sconfig = kaptan.Kaptan(handler='yaml') + sconfig = sconfig.import_config(yaml_config).get() + + builder = WorkspaceBuilder(sconf=sconfig, server=server) + + _load_attached(builder, False) + + assert builder.session.attach_session.call_count == 1 + + +def test_load_attached_detached(server, monkeypatch): + # Load a session but don't attach + monkeypatch.delenv('TMUX', raising=False) + + attach_session_mock = MagicMock() + attach_session_mock.return_value.stderr = None + + monkeypatch.setattr("libtmux.session.Session.attach_session", attach_session_mock) + + yaml_config = loadfixture("workspacebuilder/two_pane.yaml") + sconfig = kaptan.Kaptan(handler='yaml') + sconfig = sconfig.import_config(yaml_config).get() + + builder = WorkspaceBuilder(sconf=sconfig, server=server) + + _load_attached(builder, True) + + assert builder.session.attach_session.call_count == 0 + + +def test_load_attached_within_tmux(server, monkeypatch): + # Load a session and attach from within tmux + monkeypatch.setenv('TMUX', "/tmp/tmux-1234/default,123,0") + + switch_client_mock = MagicMock() + switch_client_mock.return_value.stderr = None + + monkeypatch.setattr("libtmux.session.Session.switch_client", switch_client_mock) + + yaml_config = loadfixture("workspacebuilder/two_pane.yaml") + sconfig = kaptan.Kaptan(handler='yaml') + sconfig = sconfig.import_config(yaml_config).get() + + builder = WorkspaceBuilder(sconf=sconfig, server=server) + + _load_attached(builder, False) + + assert builder.session.switch_client.call_count == 1 + + +def test_load_attached_within_tmux_detached(server, monkeypatch): + # Load a session and attach from within tmux + monkeypatch.setenv('TMUX', "/tmp/tmux-1234/default,123,0") + + switch_client_mock = MagicMock() + switch_client_mock.return_value.stderr = None + + monkeypatch.setattr("libtmux.session.Session.switch_client", switch_client_mock) + + yaml_config = loadfixture("workspacebuilder/two_pane.yaml") + sconfig = kaptan.Kaptan(handler='yaml') + sconfig = sconfig.import_config(yaml_config).get() + + builder = WorkspaceBuilder(sconf=sconfig, server=server) + + _load_attached(builder, True) + + assert builder.session.switch_client.call_count == 1 + +def test_load_append_windows_to_current_session(server, monkeypatch): + yaml_config = loadfixture("workspacebuilder/two_pane.yaml") + sconfig = kaptan.Kaptan(handler='yaml') + sconfig = sconfig.import_config(yaml_config).get() + + builder = WorkspaceBuilder(sconf=sconfig, server=server) + builder.build() + + assert len(server.list_sessions()) == 1 + assert len(server._list_windows()) == 3 + + # Assign an active pane to the session + monkeypatch.setenv("TMUX_PANE", server._list_panes()[0]["pane_id"]) + + builder = WorkspaceBuilder(sconf=sconfig, server=server) + _load_append_windows_to_current_session(builder) + + assert len(server.list_sessions()) == 1 + assert len(server._list_windows()) == 6 + + + def test_debug_info_cli(monkeypatch, tmpdir): monkeypatch.setenv('SHELL', '/bin/bash') diff --git a/tests/test_workspacebuilder.py b/tests/test_workspacebuilder.py index 3783d0d8e4c..ce3584dc430 100644 --- a/tests/test_workspacebuilder.py +++ b/tests/test_workspacebuilder.py @@ -779,3 +779,90 @@ def test_plugin_system_multiple_plugins(session): # override methods are currently written proc = session.cmd('display-message', '-p', "'#W'") assert proc.stdout[0] == "'mp_test_awf'" + + +def test_load_configs_same_session(server): + yaml_config = loadfixture("workspacebuilder/three_windows.yaml") + sconfig = kaptan.Kaptan(handler='yaml') + sconfig = sconfig.import_config(yaml_config).get() + + builder = WorkspaceBuilder(sconf=sconfig, server=server) + builder.build() + + assert len(server.sessions) == 1 + assert len(server.sessions[0]._windows) == 3 + + yaml_config = loadfixture("workspacebuilder/two_windows.yaml") + + sconfig = kaptan.Kaptan(handler='yaml') + sconfig = sconfig.import_config(yaml_config).get() + + builder = WorkspaceBuilder(sconf=sconfig, server=server) + builder.build() + assert len(server.sessions) == 2 + assert len(server.sessions[1]._windows) == 2 + + yaml_config = loadfixture("workspacebuilder/two_windows.yaml") + + sconfig = kaptan.Kaptan(handler='yaml') + sconfig = sconfig.import_config(yaml_config).get() + + builder = WorkspaceBuilder(sconf=sconfig, server=server) + builder.build(server.sessions[1], True) + + assert len(server.sessions) == 2 + assert len(server.sessions[1]._windows) == 4 + + +def test_load_configs_separate_sessions(server): + yaml_config = loadfixture("workspacebuilder/three_windows.yaml") + sconfig = kaptan.Kaptan(handler='yaml') + sconfig = sconfig.import_config(yaml_config).get() + + builder = WorkspaceBuilder(sconf=sconfig, server=server) + builder.build() + + assert len(server.sessions) == 1 + assert len(server.sessions[0]._windows) == 3 + + yaml_config = loadfixture("workspacebuilder/two_windows.yaml") + + sconfig = kaptan.Kaptan(handler='yaml') + sconfig = sconfig.import_config(yaml_config).get() + + builder = WorkspaceBuilder(sconf=sconfig, server=server) + builder.build() + + assert len(server.sessions) == 2 + assert len(server.sessions[0]._windows) == 3 + assert len(server.sessions[1]._windows) == 2 + + +def test_find_current_active_pane(server, monkeypatch): + yaml_config = loadfixture("workspacebuilder/three_windows.yaml") + sconfig = kaptan.Kaptan(handler='yaml') + sconfig = sconfig.import_config(yaml_config).get() + + builder = WorkspaceBuilder(sconf=sconfig, server=server) + builder.build() + + yaml_config = loadfixture("workspacebuilder/two_windows.yaml") + + sconfig = kaptan.Kaptan(handler='yaml') + sconfig = sconfig.import_config(yaml_config).get() + + builder = WorkspaceBuilder(sconf=sconfig, server=server) + builder.build() + + assert len(server.list_sessions()) == 2 + + # Assign an active pane to the session + second_session = server.list_sessions()[1] + first_pane_on_second_session_id = ( + second_session.list_windows()[0].list_panes()[0]["pane_id"] + ) + monkeypatch.setenv("TMUX_PANE", first_pane_on_second_session_id) + + builder = WorkspaceBuilder(sconf=sconfig, server=server) + + assert builder.find_current_attached_session() == second_session diff --git a/tmuxp/cli.py b/tmuxp/cli.py index b95297211ea..f2202457494 100644 --- a/tmuxp/cli.py +++ b/tmuxp/cli.py @@ -448,7 +448,6 @@ def _reattach(builder): If not, ``tmux attach-session`` loads the client to the target session. """ - for plugin in builder.plugins: plugin.reattach(builder.session) proc = builder.session.cmd('display-message', '-p', "'#S'") @@ -462,6 +461,85 @@ def _reattach(builder): builder.session.attach_session() +def _load_attached(builder, detached): + """ + Load config in new session + + Parameters + ---------- + builder: :class:`workspacebuilder.WorkspaceBuilder` + detached : bool + """ + builder.build() + + if 'TMUX' in os.environ: # tmuxp ran from inside tmux + # unset TMUX, save it, e.g. '/tmp/tmux-1000/default,30668,0' + tmux_env = os.environ.pop('TMUX') + + if has_gte_version('2.6'): + set_layout_hook(builder.session, 'client-session-changed') + + builder.session.switch_client() # switch client to new session + + os.environ['TMUX'] = tmux_env # set TMUX back again + else: + if has_gte_version('2.6'): + # if attaching for first time + set_layout_hook(builder.session, 'client-attached') + + # for cases where user switches client for first time + set_layout_hook(builder.session, 'client-session-changed') + + if not detached: + builder.session.attach_session() + + +def _load_detached(builder): + """ + Load config in new session but don't attach + + Parameters + ---------- + builder: :class:`workspacebuilder.WorkspaceBuilder` + """ + builder.build() + + if has_gte_version('2.6'): # prepare for both cases + set_layout_hook(builder.session, 'client-attached') + set_layout_hook(builder.session, 'client-session-changed') + + print('Session created in detached state.') + + +def _load_append_windows_to_current_session(builder): + """ + Load config as new windows in current session + + Parameters + ---------- + builder: :class:`workspacebuilder.WorkspaceBuilder` + """ + current_attached_session = builder.find_current_attached_session() + builder.build(current_attached_session, append=True) + if has_gte_version('2.6'): # prepare for both cases + set_layout_hook(builder.session, 'client-attached') + set_layout_hook(builder.session, 'client-session-changed') + + +def _setup_plugins(builder): + """ + Runs after before_script + + Parameters + ---------- + builder: :class:`workspacebuilder.WorkspaceBuilder` + """ + for plugin in builder.plugins: + plugin.before_script(builder.session) + + return builder.session + + def load_workspace( config_file, socket_name=None, @@ -470,6 +548,7 @@ def load_workspace( colors=None, detached=False, answer_yes=False, + append=False, ): """ Load a tmux "workspace" session via tmuxp file. @@ -490,7 +569,11 @@ def load_workspace( detached : bool Force detached state. default False. answer_yes : bool - Assume yes when given prompt. default False. + Assume yes when given prompt to attach in new session. + Default False. + append : bool + Assume current when given prompt to append windows in same session. + Default False. Notes ----- @@ -595,9 +678,8 @@ def load_workspace( session_name = sconfig['session_name'] - # if the session already exists, prompt the user to attach. tmuxp doesn't - # support incremental session building or appending (yet, PR's welcome!) - if builder.session_exists(session_name): + # if the session already exists, prompt the user to attach + if builder.session_exists(session_name) and not append: if not detached and ( answer_yes or click.confirm( @@ -610,38 +692,37 @@ def load_workspace( return try: - builder.build() # load tmux session via workspace builder - - if 'TMUX' in os.environ: # tmuxp ran from inside tmux - if not detached and ( - answer_yes or click.confirm('Already inside TMUX, switch to session?') - ): - # unset TMUX, save it, e.g. '/tmp/tmux-1000/default,30668,0' - tmux_env = os.environ.pop('TMUX') - - if has_gte_version('2.6'): - set_layout_hook(builder.session, 'client-session-changed') - - builder.session.switch_client() # switch client to new session + if detached: + _load_detached(builder) + return _setup_plugins(builder) - os.environ['TMUX'] = tmux_env # set TMUX back again - return builder.session - else: # session created in the background, from within tmux - if has_gte_version('2.6'): # prepare for both cases - set_layout_hook(builder.session, 'client-attached') - set_layout_hook(builder.session, 'client-session-changed') + if append: + if 'TMUX' in os.environ: # tmuxp ran from inside tmux + _load_append_windows_to_current_session(builder) + else: + _load_attached(builder, detached) - sys.exit('Session created in detached state.') - else: # tmuxp ran from inside tmux - if has_gte_version('2.6'): - # if attaching for first time - set_layout_hook(builder.session, 'client-attached') + return _setup_plugins(builder) - # for cases where user switches client for first time - set_layout_hook(builder.session, 'client-session-changed') + # append and answer_yes have no meaning if specified together + elif answer_yes: + _load_attached(builder, detached) + return _setup_plugins(builder) - if not detached: - builder.session.attach_session() + if 'TMUX' in os.environ: # tmuxp ran from inside tmux + msg = "Already inside TMUX, switch to session? yes/no\n"\ + "Or (a)ppend windows in the current active session?\n[y/n/a]" + options = ['y', 'n', 'a'] + choice = click.prompt(msg, value_proc=_validate_choices(options)) + + if choice == 'y': + _load_attached(builder, detached) + elif choice == 'a': + _load_append_windows_to_current_session(builder) + else: + _load_detached(builder) + else: + _load_attached(builder, detached) except exc.TmuxpException as e: import traceback @@ -663,11 +744,8 @@ def load_workspace( else: sys.exit() - # Runs after before_script - for plugin in builder.plugins: - plugin.before_script(builder.session) + return _setup_plugins(builder) - return builder.session @click.group(context_settings={'obj': {}}) @@ -923,6 +1001,12 @@ def command_freeze(session_name, socket_name, socket_path, force): @click.option( '-d', 'detached', help='Load the session without attaching it', is_flag=True ) +@click.option( + '-a', + 'append', + help='Load configuration, appending windows to the current session', + is_flag=True +) @click.option( 'colors', '-2', @@ -945,6 +1029,7 @@ def command_load( new_session_name, answer_yes, detached, + append, colors, log_file, ): @@ -983,6 +1068,7 @@ def command_load( 'answer_yes': answer_yes, 'colors': colors, 'detached': detached, + 'append': append, } if not config: diff --git a/tmuxp/workspacebuilder.py b/tmuxp/workspacebuilder.py index d313438d83e..45992cb21fd 100644 --- a/tmuxp/workspacebuilder.py +++ b/tmuxp/workspacebuilder.py @@ -17,7 +17,7 @@ from libtmux.window import Window from . import exc -from .util import run_before_script +from .util import run_before_script, get_current_pane logger = logging.getLogger(__name__) @@ -111,7 +111,7 @@ def session_exists(self, session_name=None): self.session = self.server.find_where({'session_name': session_name}) return True - def build(self, session=None): + def build(self, session=None, append=False): """ Build tmux workspace in session. @@ -125,6 +125,8 @@ def build(self, session=None): ---------- session : :class:`libtmux.Session` session to build workspace in + append : bool + append windows in current active session """ if not session: @@ -191,7 +193,7 @@ def build(self, session=None): for option, value in self.sconf['environment'].items(): self.session.set_environment(option, value) - for w, wconf in self.iter_create_windows(session): + for w, wconf in self.iter_create_windows(session, append): assert isinstance(w, Window) for plugin in self.plugins: @@ -222,7 +224,7 @@ def build(self, session=None): if focus: focus.select_window() - def iter_create_windows(self, s): + def iter_create_windows(self, session, append=False): """ Return :class:`libtmux.Window` iterating through session config dict. @@ -235,6 +237,8 @@ def iter_create_windows(self, s): ---------- session : :class:`libtmux.Session` session to create windows in + append : bool + append windows in current active session Returns ------- @@ -248,11 +252,12 @@ def iter_create_windows(self, s): else: window_name = wconf['window_name'] + is_first_window_pass = self.first_window_pass(i, session, append) + w1 = None - if i == int(1): # if first window, use window 1 - w1 = s.attached_window + if is_first_window_pass: # if first window, use window 1 + w1 = session.attached_window w1.move_window(99) - pass if 'start_directory' in wconf: sd = wconf['start_directory'] @@ -264,7 +269,7 @@ def iter_create_windows(self, s): else: ws = None - w = s.new_window( + w = session.new_window( window_name=window_name, start_directory=sd, attach=False, # do not move to the new window @@ -272,10 +277,11 @@ def iter_create_windows(self, s): window_shell=ws, ) - if i == int(1) and w1: # if first window, use window 1 - w1.kill_window() + if is_first_window_pass: # if first window, use window 1 + session.attached_window.kill_window() + assert isinstance(w, Window) - s.server._update_windows() + session.server._update_windows() if 'options' in wconf and isinstance(wconf['options'], dict): for key, val in wconf['options'].items(): w.set_window_option(key, val) @@ -283,7 +289,7 @@ def iter_create_windows(self, s): if 'focus' in wconf and wconf['focus']: w.select_window() - s.server._update_windows() + session.server._update_windows() yield w, wconf @@ -369,6 +375,24 @@ def config_after_window(self, w, wconf): for key, val in wconf['options_after'].items(): w.set_window_option(key, val) + def find_current_attached_session(self): + current_active_pane = get_current_pane(self.server) + + if not current_active_pane: + raise exc.TmuxpException("No session active.") + + return next( + ( + s + for s in self.server.list_sessions() + if s["session_id"] == current_active_pane["session_id"] + ), + None, + ) + + def first_window_pass(self, i, session, append): + return len(session.windows) == 1 and i == 1 and not append + def freeze(session): """