diff --git a/docs/configuration/examples.md b/docs/configuration/examples.md index 010b039c17..8fd8091a87 100644 --- a/docs/configuration/examples.md +++ b/docs/configuration/examples.md @@ -287,6 +287,41 @@ newer and tmux 3.0 or newer. ```` +## `if` conditions + +tmuxp enables one to optionally open windows / panes based on conditions. The `if` conditions can appears in the configuration for window or pane. + +````{tab} YAML + +```{literalinclude} ../../examples/if-conditions.yaml +:language: yaml + +``` +```` + +````{tab} JSON + +```{literalinclude} ../../examples/if-conditions.json +:language: json + +``` + +```` + +In the example, running the example + +```console +$ tmuxp load examples/if-conditions.yaml +``` + +should produce **only** a window with upper and lower split panes (others should have `if` conditions that evaluates to false). This example allows for on-demand pane showing, where + +```console +$ show_htop=false tmuxp load examples/if-conditions.yaml +``` + +will instead suppress the `htop` command pane and resulting in a different behaviour. + ## Focusing tmuxp allows `focus: true` for assuring windows and panes are attached / diff --git a/examples/if-conditions-test.yaml b/examples/if-conditions-test.yaml new file mode 100644 index 0000000000..345f95a74a --- /dev/null +++ b/examples/if-conditions-test.yaml @@ -0,0 +1,53 @@ +session_name: if conditions test conditions +environment: + foo: 'false' + bar: '1' + F: '0' + MY_VAR: myfoobar +windows: + - window_name: window all false + panes: + - if: ${foo} + shell_command: + - echo pane 1 + - if: + shell: '[ 1 -gt 2 ]' + shell_command: + - echo pane 2 + - if: + shell_var: ${F} + - window_name: window 2 of 3 true + panes: + - if: + shell: '[ "foo" = "bar" ]' + shell_command: + - echo pane 3 + - if: + shell: '[ "hello" != "byte" ]' + shell_command: + - echo pane 4 + - if: + python: '2**4 == 16' + shell_command: + - echo pane 5 + - window_name: window 2 of 4 true + panes: + - if: + shell_var: 'FALSE' + shell_command: + - echo pane 6 + - if: + shell_var: ${bar} + shell_command: + - echo pane 7 + - if: + python: import os; not os.path.isdir('/a/very/random/path') + shell_command: + - echo pane 8 + - if: ${non_existing_var} + shell_command: + - echo pane 9 + - if: + shell: echo ${MY_VAR} | grep -q foo + shell_command: + - echo pane 10 diff --git a/examples/if-conditions.json b/examples/if-conditions.json new file mode 100644 index 0000000000..815bd192c3 --- /dev/null +++ b/examples/if-conditions.json @@ -0,0 +1,50 @@ +{ + "session_name": "if conditions test", + "environment": { + "Foo": "false", + "show_htop": "true" + }, + "windows": [ + { + "window_name": "window 1 ${ha} $Foo", + "if": "${Foo}", + "panes": [ + { + "shell_command": [ + "echo \"this shouldn't show up\"" + ] + }, + "echo neither should this $Foo" + ] + }, + { + "window_name": "window 2", + "panes": [ + { + "if": { + "shell": "[ 5 -lt 4 ]" + }, + "shell_command": [ + "echo the above is a false statement" + ] + }, + { + "if": { + "python": "import os; os.path.isdir('${PWD}')" + }, + "shell_command": [ + "echo \"checking for PWD (${PWD}) is a directory in python\"", + "python -m http.server" + ] + }, + { + "if": "${show_htop}", + "shell_command": [ + "echo \"the above is a true statement (by default), but can be disabled on-demand\"", + "htop" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/examples/if-conditions.yaml b/examples/if-conditions.yaml new file mode 100644 index 0000000000..b11e884d69 --- /dev/null +++ b/examples/if-conditions.yaml @@ -0,0 +1,30 @@ +session_name: if conditions test +environment: + Foo: 'false' + show_htop: 'true' +windows: + # the following would not show up as it evaluates to false + - window_name: window 1 ${ha} $Foo + if: ${Foo} + panes: + - shell_command: + - echo "this shouldn't show up" + - echo neither should this $Foo + - window_name: window 2 + panes: + # shell expression condition; should not show up + - if: + shell: '[ 5 -lt 4 ]' + shell_command: + - echo the above is a false statement + # python condition + - if: + python: import os; os.path.isdir(os.path.expandvars('${PWD}')) + shell_command: + - echo "checking for PWD (${PWD}) is a directory in python" + - python -m http.server + # display by default, but can be disabled by running `show_htop=false tmuxp load .....` + - if: ${show_htop} + shell_command: + - echo "the above is a true statement (by default), but can be disabled on-demand" + - htop diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 58da0ee91a..b8d35b8b31 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -3,11 +3,76 @@ import logging import os import pathlib +import subprocess import typing as t logger = logging.getLogger(__name__) +def optional_windows_and_pane( + workspace_dict: t.Dict[str, t.Any], +) -> bool: + """Determine if a window or pane should be included based on `if` conditions. + + The function evaluates the 'if' condition specified in `workspace_dict` to determine inclusion: + - If 'if' key is not present, it defaults to True. + - If 'if' is a string or boolean, it's treated as a shell variable. + - 'if' can be a dictionary containing 'shell', 'shell_var' or 'python' keys with valid expressions. + - 'shell_var' expressions are expanded and checked against true values ('y', 'yes', '1', 'on', 'true', 't'). + - 'shell' expressions are evaluated using subprocess + - 'python' expressions are evaluated using `exec()` + + Parameters + ---------- + workspace_dict : Dict + A dictionary containing pane/window configuration data. + + Returns + ------- + bool + True if the window or pane should be included, False otherwise. + """ + if "if" not in workspace_dict: + return True + if_cond = workspace_dict["if"] + if isinstance(if_cond, (str, bool)): + # treat this as shell variable + if_cond = {"shell_var": if_cond} + if not isinstance(if_cond, dict) or not any( + predicate in if_cond for predicate in ("python", "shell", "shell_var") + ): + msg = f"if conditions does not contains valid expression: {if_cond}" + raise ValueError(msg) + if "shell_var" in if_cond: + if expandshell(str(if_cond["shell_var"])).lower() not in { + "y", + "yes", + "1", + "on", + "true", + "t", + }: + return False + if "shell" in if_cond and ( + subprocess.run( + expandshell(if_cond["shell"]), + shell=True, + check=False, + ).returncode + != 0 + ): + return False + if "python" in if_cond: + # assign the result of the last statement from the python snippet + py_statements = if_cond["python"].split(";") + py_statements[-1] = f"ret={py_statements[-1]}" + locals = {} + exec(";".join(py_statements), {}, locals) + if not locals["ret"]: + return False + return True + + def expandshell(value: str) -> str: """Resolve shell variables based on user's ``$HOME`` and ``env``. @@ -114,6 +179,9 @@ def expand( if any(val.startswith(a) for a in [".", "./"]): val = str(cwd / val) workspace_dict["environment"][key] = val + if key not in os.environ: + # using user provided environment variable as default vars + os.environ[key] = val if "global_options" in workspace_dict: for key in workspace_dict["global_options"]: val = workspace_dict["global_options"][key] @@ -170,18 +238,21 @@ def expand( # recurse into window and pane workspace items if "windows" in workspace_dict: - workspace_dict["windows"] = [ - expand(window, parent=workspace_dict) - for window in workspace_dict["windows"] - ] + window_dicts = workspace_dict["windows"] + window_dicts = filter(optional_windows_and_pane, window_dicts) + window_dicts = (expand(x, parent=workspace_dict) for x in window_dicts) + # remove windows that has no panels (e.g. due to if conditions) + window_dicts = filter(lambda x: len(x["panes"]), window_dicts) + workspace_dict["windows"] = list(window_dicts) + elif "panes" in workspace_dict: pane_dicts = workspace_dict["panes"] for pane_idx, pane_dict in enumerate(pane_dicts): pane_dicts[pane_idx] = {} pane_dicts[pane_idx].update(expand_cmd(pane_dict)) - workspace_dict["panes"] = [ - expand(pane, parent=workspace_dict) for pane in pane_dicts - ] + pane_dicts = filter(optional_windows_and_pane, pane_dicts) + pane_dicts = (expand(x, parent=workspace_dict) for x in pane_dicts) + workspace_dict["panes"] = list(pane_dicts) return workspace_dict diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 801b462d5a..cf5d6d05e2 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -563,6 +563,32 @@ def test_blank_pane_spawn( assert len(window4.panes) == 2 +def test_if_conditions( + session: Session, +) -> None: + """Test various ways of spawning panes with conditions from a tmuxp configuration.""" + yaml_workspace_file = EXAMPLE_PATH / "if-conditions-test.yaml" + test_config = ConfigReader._from_file(yaml_workspace_file) + + test_config = loader.expand(test_config) + builder = WorkspaceBuilder(session_config=test_config, server=session.server) + builder.build(session=session) + + assert session == builder.session + + with pytest.raises(ObjectDoesNotExist): + window1 = session.windows.get(window_name="window all false") + assert window1 is None + + window2 = session.windows.get(window_name="window 2 of 3 true") + assert window2 is not None + assert len(window2.panes) == 2 + + window3 = session.windows.get(window_name="window 2 of 4 true") + assert window3 is not None + assert len(window3.panes) == 3 + + def test_start_directory(session: Session, tmp_path: pathlib.Path) -> None: """Test workspace builder setting start_directory relative to current directory.""" test_dir = tmp_path / "foo bar"