diff --git a/CHANGES b/CHANGES index b198181ac6..963f35c75c 100644 --- a/CHANGES +++ b/CHANGES @@ -44,6 +44,24 @@ $ tmuxp@next load yoursession _Notes on the upcoming release will go here._ +### Breaking changes + +#### `before_script` output now goes to the terminal by default (#1056) + +A workspace's `before_script` now runs attached to your terminal, so interactive prompts and TTY-aware tools behave normally. Previously its output was captured into the load progress panel (up to three lines) by default. To restore panel capture, pass `--progress-lines` (or set `TMUXP_PROGRESS_LINES`): + +```console +$ tmuxp load --progress-lines 3 myworkspace +``` + +### What's new + +#### Faster workspace loads (#1056) + +Loading a workspace is faster: {ref}`tmuxp-load` now prepares panes in a window together when config-order dependencies allow it, rather than waiting on every pane one at a time. Workspaces with multiple panes per window — particularly with a slow interactive shell startup — open noticeably quicker, with no changes to your configuration. + +See {ref}`loading-workspaces` for an overview of how a load proceeds. + ## tmuxp 1.71.0 (2026-06-27) tmuxp 1.71.0 bumps libtmux to 0.59.0, adding support for tmux 3.7. diff --git a/docs/cli/load.md b/docs/cli/load.md index 8be9178f29..3b70f03085 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -157,13 +157,16 @@ $ tmuxp --log-level [LEVEL] load [filename] --log-file [log_filename] When loading a workspace, tmuxp shows an animated spinner with build progress. The spinner updates as windows and panes are created, giving real-time feedback during session builds. +See {ref}`loading-workspaces` for how tmuxp creates windows and panes during +load and how to read the default progress line. + ### Presets Five built-in presets control the spinner format: | Preset | Format | |--------|--------| -| `default` | `Loading workspace: {session} {bar} {progress} {window}` | +| `default` | `Loading workspace: {session} {bar} {build_progress} {window}` | | `minimal` | `Loading workspace: {session} [{window_progress}]` | | `window` | `Loading workspace: {session} {window_bar} {window_progress_rel}` | | `pane` | `Loading workspace: {session} {pane_bar} {session_pane_progress}` | @@ -191,7 +194,9 @@ Use a custom format string with any of the available tokens: | `{window}` | Current window name | | `{window_index}` | Current window number (1-based) | | `{window_total}` | Total number of windows | +| `{windows_created}` | Number of windows created so far | | `{window_progress}` | Window fraction (e.g. `1/3`) | +| `{window_progress_created}` | Created windows fraction (e.g. `2/3`) | | `{window_progress_rel}` | Completed windows fraction (e.g. `1/3`) | | `{windows_done}` | Number of completed windows | | `{windows_remaining}` | Number of remaining windows | @@ -199,9 +204,10 @@ Use a custom format string with any of the available tokens: | `{pane_total}` | Total panes in the current window | | `{pane_progress}` | Pane fraction (e.g. `2/4`) | | `{progress}` | Combined progress (e.g. `1/3 win · 2/4 pane`) | +| `{build_progress}` | Finished windows over created windows (e.g. `1/3 win · pane 2/4`) | | `{session_pane_progress}` | Panes completed across the session (e.g. `5/10`) | | `{overall_percent}` | Pane-based completion percentage (0–100) | -| `{bar}` | Composite progress bar | +| `{bar}` | Build progress bar: finished, created, and empty window segments | | `{pane_bar}` | Pane-based progress bar | | `{window_bar}` | Window-based progress bar | | `{status_icon}` | Status icon (⏸ during before_script) | @@ -214,21 +220,21 @@ $ tmuxp load --progress-format "{session} {bar} {overall_percent}%" myproject ### Panel lines -The spinner shows script output in a panel below the spinner line. Control the panel height with `--progress-lines`: +By default, `before_script` runs with its normal terminal output before the spinner appears. Use `--progress-lines` to capture that output in a panel below the spinner line: -Hide the panel entirely (script output goes to stdout): +Keep native script output and hide the panel: ```console $ tmuxp load --progress-lines 0 myproject ``` -Show unlimited lines (capped to terminal height): +Capture unlimited lines (capped to terminal height): ```console $ tmuxp load --progress-lines -1 myproject ``` -Set a custom height (default is 3): +Capture five lines: ```console $ tmuxp load --progress-lines 5 myproject diff --git a/docs/configuration/environmental-variables.md b/docs/configuration/environmental-variables.md index 73c0d1e373..52782e5b10 100644 --- a/docs/configuration/environmental-variables.md +++ b/docs/configuration/environmental-variables.md @@ -60,15 +60,15 @@ Equivalent to the `--progress-format` CLI flag. ## `TMUXP_PROGRESS_LINES` -Number of script-output lines shown in the spinner panel. Defaults to `3`. +Capture `before_script` output in the spinner panel with this many lines. By default, scripts keep their normal terminal output. -Set to `0` to hide the panel entirely (script output goes to stdout): +Set to `0` to keep native script output and hide the panel: ```console $ TMUXP_PROGRESS_LINES=0 tmuxp load myproject ``` -Set to `-1` for unlimited lines (capped to terminal height): +Set to `-1` to capture unlimited lines (capped to terminal height): ```console $ TMUXP_PROGRESS_LINES=-1 tmuxp load myproject diff --git a/docs/topics/index.md b/docs/topics/index.md index 050ddb9d58..c89086b2aa 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -7,6 +7,12 @@ Conceptual guides and workflow documentation. ::::{grid} 1 1 2 2 :gutter: 2 2 3 3 +:::{grid-item-card} Loading workspaces +:link: loading-workspaces +:link-type: doc +How tmuxp creates windows, panes, and progress output. +::: + :::{grid-item-card} Workflows :link: workflows :link-type: doc @@ -46,6 +52,7 @@ instead of raw shell commands, supports JSON and YAML, and can :hidden: workflows +loading-workspaces plugins library-vs-cli troubleshooting diff --git a/docs/topics/loading-workspaces.md b/docs/topics/loading-workspaces.md new file mode 100644 index 0000000000..da7f37a8b7 --- /dev/null +++ b/docs/topics/loading-workspaces.md @@ -0,0 +1,58 @@ +(loading-workspaces)= + +# Loading workspaces + +When you run `tmuxp load`, tmuxp creates the tmux session described by your +workspace file, waits for panes to be ready, then finishes each window by +applying layouts and sending commands. + +## What happens during load + +tmuxp builds each window in config order. + +For each window, tmuxp creates the window and prepares its panes. When later +pane `start_directory` values already exist, panes can start their shells +together and tmuxp waits for them together. + +Once the panes are ready, tmuxp applies layout, sends configured commands, +runs window-level configuration, and fires +{meth}`~tmuxp.plugin.TmuxpPlugin.after_window_finished` plugin hooks. + +This means {meth}`~tmuxp.plugin.TmuxpPlugin.on_window_create` runs while the +window is created, while +{meth}`~tmuxp.plugin.TmuxpPlugin.after_window_finished` runs later, after that +window has been laid out and configured. + +## Reading the progress line + +The default progress line reports the same build in a compact form: + +```text +Loading workspace: study ▓░░░░░░░░░ 0/1 win · pane 3/4 learning-asyncio +``` + +The fraction before `win` is finished windows over windows created so far. In +the example above, one window has been created and has not finished yet. + +The `pane` fraction describes the current window's pane creation progress. When +a new window starts, this can briefly show `pane 0/N` before tmuxp creates the +first pane for that window. + +The progress bar shows the whole workspace: + +- `█` means a window is finished. +- `▓` means a window has been created but is not finished yet. +- `░` means a window has not been created yet. + +Once tmuxp enters the finish phase, the finished-window count rises: + +```text +Loading workspace: study █░░░░░░░░░ 1/1 win learning-asyncio +``` + +## Why tmuxp loads this way + +Preparing a window's panes before layout lets shell startup happen in parallel +when config-order dependencies allow it. Layout and commands then run after the +panes are ready, which avoids resizing panes while shells are still drawing +their first prompts. diff --git a/src/tmuxp/cli/_progress.py b/src/tmuxp/cli/_progress.py index 01d31d7272..2da325f5e1 100644 --- a/src/tmuxp/cli/_progress.py +++ b/src/tmuxp/cli/_progress.py @@ -11,6 +11,7 @@ import dataclasses import itertools import logging +import math import shutil import sys import threading @@ -124,7 +125,7 @@ def _truncate_visible(text: str, max_visible: int, suffix: str = "...") -> str: SUCCESS_TEMPLATE = "Loaded workspace: {session} ({workspace_path}) {summary}" PROGRESS_PRESETS: dict[str, str] = { - "default": "Loading workspace: {session} {bar} {progress} {window}", + "default": "Loading workspace: {session} {bar} {build_progress} {window}", "minimal": "Loading workspace: {session} [{window_progress}]", "window": "Loading workspace: {session} {window_bar} {window_progress_rel}", "pane": "Loading workspace: {session} {pane_bar} {session_pane_progress}", @@ -172,6 +173,63 @@ def render_bar(done: int, total: int, width: int = BAR_WIDTH) -> str: return "█" * filled + "░" * (width - filled) +def _progress_cells(done: int, total: int, width: int = BAR_WIDTH) -> int: + """Return visible cells for nonzero progress without completing early. + + Examples + -------- + >>> _progress_cells(0, 11) + 0 + >>> _progress_cells(1, 11) + 1 + >>> _progress_cells(10, 11) + 9 + >>> _progress_cells(11, 11) + 10 + """ + if done <= 0 or total <= 0 or width <= 0: + return 0 + if done >= total: + return width + return min(width - 1, max(1, math.ceil(done / total * width))) + + +def render_build_bar( + windows_done: int, + windows_created: int, + total: int, + width: int = BAR_WIDTH, +) -> str: + """Render a two-phase window build bar. + + ``windows_created`` is the total number of windows created so far, + including completed windows. The rendered bar uses ``█`` for finished + windows, ``▓`` for created-but-unfinished windows, and ``░`` for windows + not created yet. + + Examples + -------- + >>> render_build_bar(0, 0, 11) + '░░░░░░░░░░' + >>> render_build_bar(0, 5, 11) + '▓▓▓▓▓░░░░░' + >>> render_build_bar(1, 5, 11) + '█▓▓▓▓░░░░░' + >>> render_build_bar(11, 11, 11) + '██████████' + """ + if total <= 0 or width <= 0: + return "" + + done = max(0, min(windows_done, total)) + created = max(done, min(windows_created, total)) + done_cells = _progress_cells(done, total, width) + created_cells = _progress_cells(created, total, width) + created_only_cells = max(0, created_cells - done_cells) + empty_cells = max(0, width - created_cells) + return "█" * done_cells + "▓" * created_only_cells + "░" * empty_cells + + class _SafeFormatMap(dict): # type: ignore[type-arg] """dict subclass that returns ``{key}`` for missing keys in format_map.""" @@ -249,12 +307,24 @@ class BuildTree: - — - — - — + * - ``{windows_created}`` + - ``0`` + - ``0`` + - increments + - — + - — * - ``{window_progress}`` - ``""`` - ``""`` - ``"N/M"`` when > 0 - — - — + * - ``{window_progress_created}`` + - ``""`` + - ``"0/M"`` + - increments + - — + - — * - ``{windows_done}`` - ``0`` - ``0`` @@ -315,6 +385,12 @@ class BuildTree: - ``"N/M win"`` - ``"N/M win · P/Q pane"`` - — + * - ``{build_progress}`` + - ``"0/0 win"`` + - ``"0/0 win"`` + - denominator increments + - pane progress appended + - numerator increments * - ``{session_pane_total}`` - ``0`` - total @@ -354,9 +430,9 @@ class BuildTree: * - ``{bar}`` (spinner) - ``[░░…]`` - ``[░░…]`` - - starts filling - - fractional - - jumps + - created segment fills + - — + - done segment fills * - ``{pane_bar}`` (spinner) - ``""`` - ``[░░…]`` @@ -432,6 +508,7 @@ def __init__(self, workspace_path: str = "") -> None: self.session_pane_total: int | None = None self.session_panes_done: int = 0 self.windows_done: int = 0 + self._current_window: _WindowStatus | None = None self._before_script_event: threading.Event = threading.Event() def on_event(self, event: dict[str, t.Any]) -> None: @@ -465,26 +542,71 @@ def on_event(self, event: dict[str, t.Any]) -> None: elif kind == "before_script_done": self._before_script_event.clear() elif kind == "window_started": - self.windows.append( - _WindowStatus(name=event["name"], pane_total=event["pane_total"]) + self._current_window = _WindowStatus( + name=event["name"], + pane_total=event["pane_total"], ) + self.windows.append(self._current_window) elif kind == "pane_creating": if self.windows: - w = self.windows[-1] + w = self._current_window or self.windows[-1] w.pane_num = event["pane_num"] w.pane_total = event["pane_total"] elif kind == "window_done": - if self.windows: - w = self.windows[-1] - w.done = True - w.pane_num = None - w.pane_done = w.pane_total or 0 - self.session_panes_done += w.pane_done + target_window = self._window_for_done_event(event) + if target_window is not None: + target_window.done = True + target_window.pane_num = None + target_window.pane_done = target_window.pane_total or 0 + self.session_panes_done += target_window.pane_done self.windows_done += 1 + self._current_window = target_window elif kind == "workspace_built": for w in self.windows: w.done = True + def _window_for_done_event(self, event: dict[str, t.Any]) -> _WindowStatus | None: + """Return the unfinished window targeted by a window_done event. + + Examples + -------- + >>> tree = BuildTree() + >>> tree.on_event({"event": "window_started", "name": "one", "pane_total": 1}) + >>> tree.on_event({"event": "window_started", "name": "two", "pane_total": 1}) + >>> tree._window_for_done_event({"event": "window_done", "name": "one"}).name + 'one' + >>> tree._window_for_done_event({"event": "window_done", "name": "missing"}) + """ + name = event.get("name") + if isinstance(name, str): + for window in self.windows: + if not window.done and window.name == name: + return window + return None + + for window in self.windows: + if not window.done: + return window + return None + + def _current_window_index(self) -> int: + """Return the 1-based index for the current window. + + Examples + -------- + >>> tree = BuildTree() + >>> tree._current_window_index() + 0 + >>> tree.on_event({"event": "window_started", "name": "one", "pane_total": 1}) + >>> tree.on_event({"event": "window_started", "name": "two", "pane_total": 1}) + >>> tree._current_window_index() + 2 + """ + for index, window in enumerate(self.windows, start=1): + if window is self._current_window: + return index + return 0 + def render(self, colors: Colors, width: int) -> list[str]: """Render the current tree state to a list of display strings. @@ -540,8 +662,12 @@ def _context(self) -> dict[str, t.Any]: 5 >>> ctx["window_index"] 0 + >>> ctx["windows_created"] + 0 >>> ctx["progress"] '' + >>> ctx["build_progress"] + '0/0 win' >>> ctx["windows_done"] 0 >>> ctx["windows_remaining"] @@ -566,9 +692,10 @@ def _context(self) -> dict[str, t.Any]: >>> tree._context()["summary"] '[2 win, 8 panes]' """ - w = self.windows[-1] if self.windows else None - window_idx = len(self.windows) + w = self._current_window if self._current_window else None + window_idx = self._current_window_index() win_tot = self.window_total or 0 + windows_created = len(self.windows) pane_idx = (w.pane_num or 0) if w else 0 pane_tot = (w.pane_total or 0) if w else 0 @@ -582,10 +709,18 @@ def _context(self) -> dict[str, t.Any]: win_done = self.windows_done win_progress_rel = f"{win_done}/{win_tot}" if win_tot else "" - pane_done_cur = ( (w.pane_num or 0) if w and not w.done else (w.pane_done if w else 0) ) + build_pane_progress = ( + f"{pane_done_cur}/{pane_tot}" if w and not w.done and pane_tot else "" + ) + build_progress_parts = [ + f"{win_done}/{windows_created} win", + f"pane {build_pane_progress}" if build_pane_progress else "", + ] + build_progress = " · ".join(p for p in build_progress_parts if p) + pane_remaining = max(0, pane_tot - pane_done_cur) pane_progress_rel = f"{pane_done_cur}/{pane_tot}" if pane_tot else "" @@ -606,11 +741,16 @@ def _context(self) -> dict[str, t.Any]: "window": w.name if w else "", "window_index": window_idx, "window_total": win_tot, + "windows_created": windows_created, "window_progress": win_progress, + "window_progress_created": ( + f"{windows_created}/{win_tot}" if win_tot else "" + ), "pane_index": pane_idx, "pane_total": pane_tot, "pane_progress": pane_progress, "progress": progress, + "build_progress": build_progress, "windows_done": win_done, "windows_remaining": max(0, win_tot - win_done), "window_progress_rel": win_progress_rel, @@ -945,16 +1085,11 @@ def _build_extra(self) -> dict[str, t.Any]: win_tot = tree.window_total or 0 spt = tree.session_pane_total or 0 - # Composite fraction: (windows_done + pane_frac) / window_total - if win_tot > 0: - cw = tree.windows[-1] if tree.windows else None - pane_frac = 0.0 - if cw and not cw.done and cw.pane_total: - pane_frac = (cw.pane_num or 0) / cw.pane_total - composite_done = tree.windows_done + pane_frac - composite_bar = render_bar(int(composite_done * 100), win_tot * 100) - else: - composite_bar = render_bar(0, 0) + build_bar = render_build_bar( + windows_done=tree.windows_done, + windows_created=len(tree.windows), + total=win_tot, + ) pane_bar = render_bar(tree.session_panes_done, spt) window_bar = render_bar(tree.windows_done, win_tot) @@ -966,9 +1101,21 @@ def _color_bar(plain: str) -> str: empty = plain.count("░") return self.colors.success("█" * filled) + self.colors.muted("░" * empty) + def _color_build_bar(plain: str) -> str: + if not plain: + return plain + return "".join( + { + "█": self.colors.success("█"), + "▓": self.colors.info("▓"), + "░": self.colors.muted("░"), + }.get(char, char) + for char in plain + ) + return { "session": self.colors.highlight(tree.session_name or ""), - "bar": _color_bar(composite_bar), + "bar": _color_build_bar(build_bar), "pane_bar": _color_bar(pane_bar), "window_bar": _color_bar(window_bar), "status_icon": "", diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 375cdb1b22..68aac5f5bb 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -478,9 +478,9 @@ def load_workspace( progress_format : str, optional Spinner format preset name or custom format string with tokens. panel_lines : int, optional - Number of script-output lines shown in the spinner panel. - Defaults to the :class:`~tmuxp.cli._progress.Spinner` default (3). - Override via ``TMUXP_PROGRESS_LINES`` environment variable. + Nonzero values capture ``before_script`` output in the spinner panel. + By default, scripts keep their normal terminal output. Override via + ``TMUXP_PROGRESS_LINES`` environment variable. no_progress : bool Disable the progress spinner entirely. Default False. Also disabled when ``TMUXP_PROGRESS=0``. @@ -568,7 +568,7 @@ def load_workspace( # propagate workspace inheritance (e.g. session -> window, window -> pane) expanded_workspace = loader.trickle(expanded_workspace) - t = Server( # create tmux server object + tmux_server = Server( # create tmux server object socket_name=socket_name, socket_path=socket_path, config_file=tmux_config_file, @@ -582,7 +582,7 @@ def load_workspace( builder = WorkspaceBuilder( session_config=expanded_workspace, plugins=load_plugins(expanded_workspace, colors=cli_colors), - server=t, + server=tmux_server, ) except exc.EmptyWorkspaceException: logger.warning( @@ -658,6 +658,7 @@ def load_workspace( else: _panel_lines_env_int = None _panel_lines = panel_lines if panel_lines is not None else _panel_lines_env_int + _panel_lines_explicit = panel_lines is not None or _panel_lines_env_int is not None _private_path = str(PrivatePath(workspace_file)) _spinner = Spinner( message=( @@ -669,6 +670,10 @@ def load_workspace( workspace_path=_private_path, ) _success_emitted = False + _has_before_script = "before_script" in expanded_workspace + _capture_script_output = ( + _has_before_script and _panel_lines_explicit and _panel_lines != 0 + ) def _emit_success() -> None: nonlocal _success_emitted @@ -677,29 +682,34 @@ def _emit_success() -> None: _success_emitted = True _spinner.success() - with ( - _silence_stream_handlers(), - _spinner as spinner, - ): - builder.on_build_event = spinner.on_build_event - _resolved_panel = ( - _panel_lines if _panel_lines is not None else DEFAULT_OUTPUT_LINES - ) - if _resolved_panel != 0: + def _on_build_event(event: dict[str, t.Any]) -> None: + spinner.on_build_event(event) + if event.get("event") == "before_script_done" and not _capture_script_output: + spinner.start() + + spinner = _spinner + with _silence_stream_handlers(): + if _capture_script_output: builder.on_script_output = spinner.add_output_line - result = _dispatch_build( - builder, - detached, - append, - answer_yes, - cli_colors, - pre_attach_hook=_emit_success, - on_error_hook=spinner.stop, - pre_prompt_hook=spinner.stop, - ) - if result is not None: - _emit_success() - return result + if not _has_before_script or _capture_script_output: + spinner.start() + builder.on_build_event = _on_build_event + try: + result = _dispatch_build( + builder, + detached, + append, + answer_yes, + cli_colors, + pre_attach_hook=_emit_success, + on_error_hook=spinner.stop, + pre_prompt_hook=spinner.stop, + ) + if result is not None: + _emit_success() + return result + finally: + spinner.stop() def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: @@ -805,8 +815,8 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP type=int, default=None, help=( - "Number of script-output lines shown in the spinner panel (default: 3). " - "0 hides the panel entirely (script output goes to stdout). " + "Capture before_script output in the spinner panel with N lines. " + "0 keeps native script output and hides the panel. " "-1 shows unlimited lines (capped to terminal height). " "Env: TMUXP_PROGRESS_LINES" ), diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 152b1f6c06..c6a4d00c86 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -32,12 +32,31 @@ def run_before_script( ) -> int: """Execute shell script, streaming output to callback or terminal (if TTY). - Output is buffered and optionally forwarded via the ``on_line`` callback. + Output is forwarded line-by-line via ``on_line`` when given; otherwise it + is inherited by the terminal. """ script_cmd = shlex.split(str(script_file)) + if on_line is None: + try: + stream_proc: subprocess.Popen[bytes] = subprocess.Popen(script_cmd, cwd=cwd) + except FileNotFoundError as e: + raise exc.BeforeLoadScriptNotExists( + e, + os.path.abspath(script_file), # NOQA: PTH100 + ) from e + + stream_return_code = stream_proc.wait() + if stream_return_code != 0: + raise exc.BeforeLoadScriptError( + stream_return_code, + os.path.abspath(script_file), # NOQA: PTH100 + None, + ) + return stream_return_code + try: - proc = subprocess.Popen( + captured_proc: subprocess.Popen[str] = subprocess.Popen( script_cmd, cwd=cwd, stdout=subprocess.PIPE, @@ -51,8 +70,8 @@ def run_before_script( os.path.abspath(script_file), # NOQA: PTH100 ) from e - out_buffer = [] - err_buffer = [] + out_buffer: list[str] = [] + err_buffer: list[str] = [] # While process is running, read lines from stdout/stderr # and write them to this process's stdout/stderr if isatty @@ -62,13 +81,13 @@ def run_before_script( # You can do a simple loop reading in real-time: while True: # Use .poll() to check if the child has exited - return_code = proc.poll() + captured_return_code = captured_proc.poll() # Read one line from stdout, if available - line_out = proc.stdout.readline() if proc.stdout else "" + line_out = captured_proc.stdout.readline() if captured_proc.stdout else "" # Read one line from stderr, if available - line_err = proc.stderr.readline() if proc.stderr else "" + line_err = captured_proc.stderr.readline() if captured_proc.stderr else "" if line_out and line_out.strip(): out_buffer.append(line_out) @@ -87,11 +106,11 @@ def run_before_script( sys.stderr.flush() # If no more data from pipes and process ended, break - if not line_out and not line_err and return_code is not None: + if not line_out and not line_err and captured_return_code is not None: break # At this point, the process has finished - return_code = proc.wait() + return_code = captured_proc.wait() if return_code != 0: # Join captured stderr lines for your exception diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 728b477963..947cfb4274 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -4,11 +4,14 @@ import logging import os +import pathlib import shutil import time import typing as t from libtmux._internal.query_list import ObjectDoesNotExist +from libtmux.constants import OptionScope +from libtmux.exc import LibTmuxException, OptionError from libtmux.pane import Pane from libtmux.server import Server from libtmux.session import Session @@ -24,15 +27,112 @@ logger = logging.getLogger(__name__) +def _pane_has_drawn_prompt(pane: Pane) -> bool: + """Return whether a pane's shell has drawn its prompt. + + The cursor leaving the origin ``(0, 0)`` is the signal that the shell has + finished initializing and rendered its first prompt. Resizing a pane before + this point races zsh's prompt redraw and surfaces its partial-line ``%`` + marker (issue #365), so the build waits for this to become true before + applying layouts or sending keys. + + Parameters + ---------- + pane : :class:`libtmux.Pane` + pane whose freshly refreshed cursor position to inspect + + Returns + ------- + bool + True once the cursor has moved away from ``(0, 0)`` + + Examples + -------- + >>> pane = session.active_window.active_pane + >>> _ = _wait_for_panes_ready([pane], timeout=5.0) + >>> _pane_has_drawn_prompt(pane) + True + """ + return pane.cursor_x != "0" or pane.cursor_y != "0" + + +def _wait_for_panes_ready( + panes: list[Pane], + timeout: float = 2.0, + interval: float = 0.05, +) -> dict[str, bool]: + """Wait for many panes to draw their prompts, sharing one timeout budget. + + tmux spawns each pane's shell the moment the pane is created, so the shells + initialize concurrently. Polling every pane in a single loop — rather than + blocking on each one to completion before starting the next — observes that + concurrency, collapsing the worst case from ``len(panes) * timeout`` of + serial waiting into a single shared ``timeout`` window. A slow prompt that + exceeds the timeout continues without blocking the rest of the workspace. + + Parameters + ---------- + panes : list of :class:`libtmux.Pane` + panes to wait for; panes without an id are ignored + timeout : float + maximum seconds to wait for the whole set before giving up + interval : float + seconds between polling sweeps + + Returns + ------- + dict of str to bool + maps each pane id to whether it became ready before the timeout + + Examples + -------- + >>> pane = session.active_window.active_pane + >>> _wait_for_panes_ready([pane], timeout=5.0) # doctest: +ELLIPSIS + {'%...': True} + """ + pending = {p.pane_id: p for p in panes if p.pane_id is not None} + ready: dict[str, bool] = {} + start = time.monotonic() + while pending and time.monotonic() - start < timeout: + for pane_id, pane in list(pending.items()): + try: + pane.refresh() + except Exception: + logger.debug( + "pane refresh failed during readiness check", + exc_info=True, + extra={"tmux_pane": str(pane_id)}, + ) + ready[pane_id] = False + del pending[pane_id] + continue + if _pane_has_drawn_prompt(pane): + logger.debug( + "pane ready, cursor moved from origin", + extra={"tmux_pane": str(pane_id)}, + ) + ready[pane_id] = True + del pending[pane_id] + if pending: + time.sleep(interval) + for pane_id in pending: + logger.debug( + "pane readiness check timed out after %.1f seconds", + timeout, + extra={"tmux_pane": str(pane_id)}, + ) + ready[pane_id] = False + return ready + + def _wait_for_pane_ready( pane: Pane, timeout: float = 2.0, interval: float = 0.05, ) -> bool: - """Wait for pane shell to draw its prompt. + """Wait for a single pane's shell to draw its prompt. - Polls the pane's cursor position until it moves from origin (0, 0), - indicating the shell has finished initializing and drawn its prompt. + Convenience wrapper over :func:`_wait_for_panes_ready` for the one-pane case. Parameters ---------- @@ -57,30 +157,10 @@ def _wait_for_pane_ready( >>> _wait_for_pane_ready(pane, timeout=5.0) True """ - start = time.monotonic() - while time.monotonic() - start < timeout: - try: - pane.refresh() - except Exception: - logger.debug( - "pane refresh failed during readiness check", - exc_info=True, - extra={"tmux_pane": str(pane.pane_id)}, - ) - return False - if pane.cursor_x != "0" or pane.cursor_y != "0": - logger.debug( - "pane ready, cursor moved from origin", - extra={"tmux_pane": str(pane.pane_id)}, - ) - return True - time.sleep(interval) - logger.debug( - "pane readiness check timed out after %.1f seconds", - timeout, - extra={"tmux_pane": str(pane.pane_id)}, - ) - return False + if pane.pane_id is None: + return False + results = _wait_for_panes_ready([pane], timeout=timeout, interval=interval) + return results.get(pane.pane_id, False) COLUMNS_FALLBACK = 80 @@ -101,6 +181,19 @@ def get_default_rows() -> int: return int(os.getenv("TMUXP_DEFAULT_ROWS", os.getenv("ROWS", ROWS_FALLBACK))) +class _PaneEntry(t.NamedTuple): + """A created pane awaiting the readiness barrier, layout, and its commands. + + Carries the bits the second build phase needs once every shell is ready: + the pane, its config section, and the resolved custom ``shell`` (``None`` for + a default-shell pane, which is the only kind whose prompt the build waits on). + """ + + pane: Pane + config: dict[str, t.Any] + shell: str | None + + class WorkspaceBuilder: """Load workspace from workspace :py:obj:`dict` object. @@ -415,6 +508,10 @@ def build(self, session: Session | None = None, append: bool = False) -> None: Without ``session``, it will use :class:`libmtux.Server` at ``self.server`` passed in on initialization to create a new Session object. + Plugin hooks fire in config order. For each window, ``on_window_create`` + runs after creation; ``after_window_finished`` runs after layout, + configuration, and pane command dispatch. + Parameters ---------- session : :class:`libtmux.Session` @@ -538,16 +635,35 @@ def build(self, session: Session | None = None, append: bool = False) -> None: for option, value in self.session_config["environment"].items(): self.session.set_environment(option, value) - for window, window_config in self.iter_create_windows(session, append): + # Build each window in config order. Pane shells still warm up together + # inside a window, but earlier window commands run before later windows + # are created. + for window_index, (window, window_config) in enumerate( + self.iter_create_windows(session, append), + start=1, + ): assert isinstance(window, Window) for plugin in self.plugins: plugin.on_window_create(window) focus_pane = None - for pane, pane_config in self.iter_create_panes(window, window_config): + if self._window_needs_sequential_pane_setup(window_config): + pane_iter = self._iter_create_panes_sequentially( + window, + window_config, + ) + else: + entries = self._create_window_panes(window, window_config) + self._wait_for_workspace_ready([(window, window_config, entries)]) + pane_iter = self._dispatch_window_commands( + window, + window_config, + entries, + ) + + for pane, pane_config in pane_iter: assert isinstance(pane, Pane) - pane = pane if pane_config.get("focus"): focus_pane = pane @@ -564,7 +680,14 @@ def build(self, session: Session | None = None, append: bool = False) -> None: focus_pane.select() if self.on_build_event: - self.on_build_event({"event": "window_done"}) + window_name = window_config.get("window_name") or str(window_index) + self.on_build_event( + { + "event": "window_done", + "name": window_name, + "pane_total": len(window_config["panes"]), + } + ) if focus: focus.select() @@ -679,14 +802,69 @@ def iter_create_windows( yield window, window_config - def iter_create_panes( + def _window_needs_sequential_pane_setup( + self, + window_config: dict[str, t.Any], + ) -> bool: + """Return whether later pane splits depend on missing directories. + + Examples + -------- + >>> builder = WorkspaceBuilder( + ... session_config={'session_name': 'x', 'windows': []}, + ... server=server, + ... ) + >>> builder._window_needs_sequential_pane_setup({ + ... 'panes': [ + ... {'shell_command': []}, + ... {'shell_command': [], 'start_directory': '/'}, + ... ], + ... }) + False + >>> builder._window_needs_sequential_pane_setup({ + ... 'panes': [ + ... {'shell_command': []}, + ... { + ... 'shell_command': [], + ... 'start_directory': '/__tmuxp_missing_start_directory__', + ... }, + ... ], + ... }) + True + """ + for pane_config in window_config["panes"][1:]: + start_directory = pane_config.get( + "start_directory", + window_config.get("start_directory"), + ) + if ( + start_directory is not None + and not pathlib.Path( + os.fspath(start_directory), + ) + .expanduser() + .is_dir() + ): + return True + return False + + def _create_window_panes( self, window: Window, window_config: dict[str, t.Any], - ) -> Iterator[t.Any]: - """Return :class:`libtmux.Pane` iterating through window config dict. + ) -> list[_PaneEntry]: + """Create a window's panes without waiting, laying out, or sending keys. - Run ``shell_command`` with ``$ tmux send-keys``. + This is the first build phase. Splitting the panes back-to-back lets + their shells initialize concurrently while the build moves on; readiness, + layout, and commands are deferred to later phases. + + Each split resizes the pane it targets, and the build only ever splits + the newest pane — one created microseconds earlier, still sourcing its rc + and therefore safe to resize. The exception is running out of room: when + a split fails for space, the existing panes are first waited on (so they + are past their prompt and safe to resize), then ``select_layout`` + reclaims space, and the split is retried. Parameters ---------- @@ -697,16 +875,160 @@ def iter_create_panes( Returns ------- - tuple of (:class:`libtmux.Pane`, ``pane_config``) - Newly created pane, and the section from the tmuxp configuration - that was used to create the pane. + list of :class:`_PaneEntry` + one entry per created pane, in config order + + Examples + -------- + >>> session = server.new_session('create-window-panes') + >>> builder = WorkspaceBuilder( + ... session_config={'session_name': 'x', 'windows': []}, + ... server=server, + ... ) + >>> entries = builder._create_window_panes( + ... session.active_window, + ... {'window_name': 'main', 'panes': [{'shell_command': []}, + ... {'shell_command': []}]}, + ... ) + >>> len(entries) + 2 + >>> entries[0].shell is None + True + """ + assert isinstance(window, Window) + + pane_base_index = window.show_option("pane-base-index", global_=True) + assert pane_base_index is not None + + layout = window_config.get("layout") + pane = None + entries: list[_PaneEntry] = [] + + for pane_index, pane_config in enumerate( + window_config["panes"], + start=pane_base_index, + ): + if self.on_progress: + self.on_progress(f"Creating pane: {pane_index}") + if self.on_build_event: + self.on_build_event( + { + "event": "pane_creating", + "pane_num": pane_index - int(pane_base_index) + 1, + "pane_total": len(window_config["panes"]), + } + ) + + if pane_index == int(pane_base_index): + pane = window.active_pane + else: + + def get_pane_start_directory( + pane_config: dict[str, str], + window_config: dict[str, str], + ) -> str | None: + if "start_directory" in pane_config: + return pane_config["start_directory"] + if "start_directory" in window_config: + return window_config["start_directory"] + return None + + def get_pane_shell( + pane_config: dict[str, str], + window_config: dict[str, str], + ) -> str | None: + if "shell" in pane_config: + return pane_config["shell"] + if "window_shell" in window_config: + return window_config["window_shell"] + return None + + environment = pane_config.get( + "environment", + window_config.get("environment"), + ) + + assert pane is not None + + split_kwargs: dict[str, t.Any] = { + "attach": True, + "start_directory": get_pane_start_directory( + pane_config=pane_config, + window_config=window_config, + ), + "shell": get_pane_shell( + pane_config=pane_config, + window_config=window_config, + ), + "environment": environment, + } + + # Splitting resizes the target, but we only ever split the + # freshest pane (pre-prompt, still sourcing its rc), so no + # readiness wait is needed here; the post-creation barrier + # guards layout and commands. (Residual #365 risk if a slow + # on_window_create plugin lets pane 0 prompt first.) + pane = self._split_pane_reclaiming_space( + window, + pane, + split_kwargs, + layout, + entries, + ) + + assert isinstance(pane, Pane) + pane_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": window.session.name or "", + "tmux_window": window.name or "", + "tmux_pane": pane.pane_id or "", + }, + ) + pane_log.debug("pane created") + + # A pane with a custom shell/command launcher (e.g. "top", "sleep + # 999") replaces the default shell and never draws an interactive + # prompt, so it is recorded but excluded from the readiness barrier. + pane_shell = pane_config.get("shell", window_config.get("window_shell")) + entries.append(_PaneEntry(pane=pane, config=pane_config, shell=pane_shell)) + + return entries + + def _iter_create_panes_sequentially( + self, + window: Window, + window_config: dict[str, t.Any], + ) -> Iterator[t.Any]: + """Create, wait, and dispatch a window's panes one at a time. + + This compatibility path preserves configs where an earlier pane command + prepares a later pane's split-time ``start_directory``. + + Examples + -------- + >>> session = server.new_session('create-panes-sequentially') + >>> builder = WorkspaceBuilder( + ... session_config={'session_name': 'x', 'windows': []}, + ... server=server, + ... ) + >>> panes = list( + ... builder._iter_create_panes_sequentially( + ... session.active_window, + ... {'window_name': 'main', 'panes': [{'shell_command': []}]}, + ... ), + ... ) + >>> len(panes) + 1 """ assert isinstance(window, Window) pane_base_index = window.show_option("pane-base-index", global_=True) assert pane_base_index is not None + layout = window_config.get("layout") pane = None + entries: list[_PaneEntry] = [] for pane_index, pane_config in enumerate( window_config["panes"], @@ -754,17 +1076,25 @@ def get_pane_shell( assert pane is not None - pane = pane.split( - attach=True, - start_directory=get_pane_start_directory( + split_kwargs: dict[str, t.Any] = { + "attach": True, + "start_directory": get_pane_start_directory( pane_config=pane_config, window_config=window_config, ), - shell=get_pane_shell( + "shell": get_pane_shell( pane_config=pane_config, window_config=window_config, ), - environment=environment, + "environment": environment, + } + + pane = self._split_pane_reclaiming_space( + window, + pane, + split_kwargs, + layout, + entries, ) assert isinstance(pane, Pane) @@ -778,46 +1108,307 @@ def get_pane_shell( ) pane_log.debug("pane created") - # Skip readiness wait when a custom shell/command launcher is set. - # The shell/window_shell key runs a command (e.g. "top", "sleep 999") - # that replaces the default shell — the pane exits when the command - # exits, so there is no interactive prompt to wait for. pane_shell = pane_config.get("shell", window_config.get("window_shell")) - if pane_shell is None: - _wait_for_pane_ready(pane) + entry = _PaneEntry(pane=pane, config=pane_config, shell=pane_shell) + entries.append(entry) + + self._wait_for_workspace_ready([(window, window_config, [entry])]) + yield from self._dispatch_window_commands(window, window_config, [entry]) + + def _split_pane_reclaiming_space( + self, + window: Window, + pane: Pane, + split_kwargs: dict[str, t.Any], + layout: str | None, + entries: list[_PaneEntry], + ) -> Pane: + """Split ``pane``; if it fails for space and a layout is set, retry. + + Without an intermediate ``select_layout`` after every split, a window + with many panes eventually has no room for the next split. Reclaiming + space resizes created panes, so default-shell panes are waited on first. + With no layout to redistribute with, the split failure propagates. + + Parameters + ---------- + window : :class:`libtmux.Window` + window the panes belong to + pane : :class:`libtmux.Pane` + pane to split + split_kwargs : dict + keyword arguments forwarded to :meth:`libtmux.Pane.split` + layout : str or None + window layout used to reclaim space, if configured + entries : list of :class:`_PaneEntry` + panes created so far, waited on before reclaiming space + + Returns + ------- + :class:`libtmux.Pane` + the newly split pane + + Examples + -------- + >>> session = server.new_session('reclaim-space') + >>> builder = WorkspaceBuilder( + ... session_config={'session_name': 'x', 'windows': []}, + ... server=server, + ... ) + >>> first = session.active_window.active_pane + >>> second = builder._split_pane_reclaiming_space( + ... session.active_window, first, {'attach': True}, 'tiled', [], + ... ) + >>> second is not None + True + """ + # libtmux resolves ``pane.window`` during split; refresh keeps the + # target pane's window id current without waiting for a shell prompt. + pane.refresh() + try: + return pane.split(**split_kwargs) + except LibTmuxException as error: + if layout is None or "no space for" not in str(error): + raise + _wait_for_panes_ready([e.pane for e in entries if e.shell is None]) + window.select_layout(layout) + return pane.split(**split_kwargs) + + def _wait_for_workspace_ready( + self, + window_layout: list[tuple[Window, dict[str, t.Any], list[_PaneEntry]]], + ) -> dict[str, bool]: + """Wait for default-shell panes in the provided windows, concurrently. + + Collects the panes that draw an interactive prompt from each window and + waits for them in a single shared barrier (:func:`_wait_for_panes_ready`). + + Parameters + ---------- + window_layout : list of tuple + ``(window, window_config, entries)`` triples for created panes + Returns + ------- + dict of str to bool + maps each waited pane id to whether it became ready + + Examples + -------- + >>> session = server.new_session('workspace-ready') + >>> builder = WorkspaceBuilder( + ... session_config={'session_name': 'x', 'windows': []}, + ... server=server, + ... ) + >>> window_config = {'window_name': 'main', 'panes': [{'shell_command': []}]} + >>> entries = builder._create_window_panes( + ... session.active_window, window_config, + ... ) + >>> sorted( + ... builder._wait_for_workspace_ready( + ... [(session.active_window, window_config, entries)], + ... ).values(), + ... ) + [True] + """ + panes = [ + entry.pane + for (_window, _window_config, entries) in window_layout + for entry in entries + if entry.shell is None + ] + return _wait_for_panes_ready(panes) + + def _dispatch_window_commands( + self, + window: Window, + window_config: dict[str, t.Any], + entries: list[_PaneEntry], + ) -> Iterator[t.Any]: + """Lay the window out, then send each pane its ``shell_command``. + + The final build phase, run only after the readiness barrier. Applying the + layout here is a single resize with every shell already past its prompt, + which is what keeps zsh from printing its partial-line ``%`` marker + (issue #365). + + Parameters + ---------- + window : :class:`libtmux.Window` + window to finish + window_config : dict + config section for window + entries : list of :class:`_PaneEntry` + panes created for this window + + Yields + ------ + tuple of (:class:`libtmux.Pane`, ``pane_config``) + each pane and the config section used to create it + + Examples + -------- + >>> session = server.new_session('dispatch-window') + >>> builder = WorkspaceBuilder( + ... session_config={'session_name': 'x', 'windows': []}, + ... server=server, + ... ) + >>> window_config = { + ... 'window_name': 'main', 'layout': 'tiled', + ... 'panes': [{'shell_command': []}], + ... } + >>> entries = builder._create_window_panes( + ... session.active_window, window_config, + ... ) + >>> _ = builder._wait_for_workspace_ready( + ... [(session.active_window, window_config, entries)], + ... ) + >>> dispatched = list( + ... builder._dispatch_window_commands( + ... session.active_window, window_config, entries, + ... ), + ... ) + >>> len(dispatched) + 1 + """ + sync_option = "synchronize-panes" + window_sync = window.show_option(sync_option, scope=OptionScope.Window) + + def target_missing(error: OptionError) -> bool: + message = str(error) + return "no such pane" in message or "no such window" in message + + def show_pane_sync(pane: Pane) -> t.Any | None: + try: + return pane.show_option(sync_option, scope=OptionScope.Pane) + except OptionError as e: + if not target_missing(e): + raise + return None + + def set_pane_sync_off(pane: Pane) -> None: + try: + pane.set_option(sync_option, "off", scope=OptionScope.Pane) + except OptionError as e: + if not target_missing(e): + raise + + pane_sync = [(entry.pane, show_pane_sync(entry.pane)) for entry in entries] + + def restore_sync(target: Window | Pane, value: t.Any | None) -> None: + try: + if value is None: + target.unset_option(sync_option) + return + target.set_option( + sync_option, + "on" if value is True or value == "on" else "off", + ) + except OptionError as e: + if not target_missing(e): + raise + + window.set_option(sync_option, "off", scope=OptionScope.Window) + for pane, _value in pane_sync: + set_pane_sync_off(pane) + + try: if "layout" in window_config: window.select_layout(window_config["layout"]) - if "suppress_history" in pane_config: - suppress = pane_config["suppress_history"] - elif "suppress_history" in window_config: - suppress = window_config["suppress_history"] - else: - suppress = True + for pane, pane_config, _pane_shell in entries: + pane_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": window.session.name or "", + "tmux_window": window.name or "", + "tmux_pane": pane.pane_id or "", + }, + ) + + if "suppress_history" in pane_config: + suppress = pane_config["suppress_history"] + elif "suppress_history" in window_config: + suppress = window_config["suppress_history"] + else: + suppress = True + + enter = pane_config.get("enter", True) + sleep_before = pane_config.get("sleep_before", None) + sleep_after = pane_config.get("sleep_after", None) + for cmd in pane_config["shell_command"]: + enter = cmd.get("enter", enter) + sleep_before = cmd.get("sleep_before", sleep_before) + sleep_after = cmd.get("sleep_after", sleep_after) + + if sleep_before is not None: + time.sleep(sleep_before) + + pane.send_keys(cmd["cmd"], suppress_history=suppress, enter=enter) + pane_log.debug("sent command %s", cmd["cmd"]) + + if sleep_after is not None: + time.sleep(sleep_after) + + if pane_config.get("focus"): + assert pane.pane_id is not None + window.select_pane(pane.pane_id) + + yield pane, pane_config + finally: + restore_sync(window, window_sync) + for pane, value in pane_sync: + restore_sync(pane, value) + + def iter_create_panes( + self, + window: Window, + window_config: dict[str, t.Any], + ) -> Iterator[t.Any]: + """Return :class:`libtmux.Pane` iterating through window config dict. - enter = pane_config.get("enter", True) - sleep_before = pane_config.get("sleep_before", None) - sleep_after = pane_config.get("sleep_after", None) - for cmd in pane_config["shell_command"]: - enter = cmd.get("enter", enter) - sleep_before = cmd.get("sleep_before", sleep_before) - sleep_after = cmd.get("sleep_after", sleep_after) + Run ``shell_command`` with ``$ tmux send-keys``. - if sleep_before is not None: - time.sleep(sleep_before) + Creates the window's panes, waits for their shells concurrently, then + lays out and sends commands. :meth:`build` uses the same per-window + boundary so config-order command effects are visible to later windows. - pane.send_keys(cmd["cmd"], suppress_history=suppress, enter=enter) - pane_log.debug("sent command %s", cmd["cmd"]) + Parameters + ---------- + window : :class:`libtmux.Window` + window to create panes for + window_config : dict + config section for window - if sleep_after is not None: - time.sleep(sleep_after) + Returns + ------- + tuple of (:class:`libtmux.Pane`, ``pane_config``) + Newly created pane, and the section from the tmuxp configuration + that was used to create the pane. - if pane_config.get("focus"): - assert pane.pane_id is not None - window.select_pane(pane.pane_id) + Examples + -------- + >>> session = server.new_session('iter-create-panes') + >>> builder = WorkspaceBuilder( + ... session_config={'session_name': 'x', 'windows': []}, + ... server=server, + ... ) + >>> panes = list( + ... builder.iter_create_panes( + ... session.active_window, + ... {'window_name': 'main', 'panes': [{'shell_command': []}]}, + ... ), + ... ) + >>> len(panes) + 1 + """ + if self._window_needs_sequential_pane_setup(window_config): + yield from self._iter_create_panes_sequentially(window, window_config) + return - yield pane, pane_config + entries = self._create_window_panes(window, window_config) + _wait_for_panes_ready([e.pane for e in entries if e.shell is None]) + yield from self._dispatch_window_commands(window, window_config, entries) def config_after_window( self, diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index ec045dcf3c..87977e4832 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -887,6 +887,219 @@ def test_load_workspace_env_progress_disabled( assert session.name == "sample workspace" +def test_load_workspace_pauses_spinner_for_before_script( + server: Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """before_script workspaces do not render progress before the script exits.""" + import yaml + + from tmuxp.cli._colors import ColorMode, Colors + + calls: list[str] = [] + captured_script_callback: list[t.Callable[[str], None] | None] = [] + + class FakeSpinner: + def __init__(self, *_args: t.Any, **_kwargs: t.Any) -> None: + pass + + def __enter__(self) -> FakeSpinner: + self.start() + return self + + def __exit__(self, *_args: object) -> t.Literal[False]: + self.stop() + return False + + def start(self) -> None: + calls.append("start") + + def stop(self) -> None: + calls.append("stop") + + def success(self) -> None: + calls.append("success") + + def add_output_line(self, _line: str) -> None: + calls.append("add_output_line") + + def on_build_event(self, event: dict[str, t.Any]) -> None: + calls.append(str(event["event"])) + + def fake_dispatch_build( + builder: WorkspaceBuilder, + *_args: t.Any, + **_kwargs: t.Any, + ) -> None: + captured_script_callback.append(builder.on_script_output) + assert builder.on_before_script is None + assert builder.on_build_event is not None + + builder.on_build_event({"event": "before_script_started"}) + builder.on_build_event({"event": "before_script_done"}) + + config = { + "session_name": "before-script-progress", + "before_script": "echo before", + "windows": [{"window_name": "main"}], + } + config_file = tmp_path / "before-script.yaml" + config_file.write_text(yaml.dump(config)) + + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.setattr("tmuxp.cli.load.Spinner", FakeSpinner) + monkeypatch.setattr("tmuxp.cli.load._dispatch_build", fake_dispatch_build) + + result = load_workspace( + str(config_file), + socket_name=server.socket_name, + cli_colors=Colors(ColorMode.NEVER), + ) + + assert result is None + assert captured_script_callback == [None] + assert calls == [ + "before_script_started", + "before_script_done", + "start", + "stop", + ] + + +class ProgressLinesCaptureFixture(t.NamedTuple): + """Test fixture for explicit progress-lines capture mode.""" + + test_id: str + panel_lines: int | None + env_value: str | None + expected_capture: bool + + +PROGRESS_LINES_CAPTURE_FIXTURES: list[ProgressLinesCaptureFixture] = [ + ProgressLinesCaptureFixture( + test_id="cli_panel_lines", + panel_lines=5, + env_value=None, + expected_capture=True, + ), + ProgressLinesCaptureFixture( + test_id="env_panel_lines", + panel_lines=None, + env_value="4", + expected_capture=True, + ), + ProgressLinesCaptureFixture( + test_id="cli_zero_lines", + panel_lines=0, + env_value=None, + expected_capture=False, + ), + ProgressLinesCaptureFixture( + test_id="env_zero_lines", + panel_lines=None, + env_value="0", + expected_capture=False, + ), +] + + +@pytest.mark.parametrize( + list(ProgressLinesCaptureFixture._fields), + PROGRESS_LINES_CAPTURE_FIXTURES, + ids=[f.test_id for f in PROGRESS_LINES_CAPTURE_FIXTURES], +) +def test_load_workspace_handles_explicit_before_script_progress_lines( + server: Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + panel_lines: int | None, + env_value: str | None, + expected_capture: bool, +) -> None: + """Explicit progress-lines controls before_script output capture.""" + import yaml + + from tmuxp.cli._colors import ColorMode, Colors + + calls: list[str] = [] + captured_script_callback: list[t.Callable[[str], None] | None] = [] + + class FakeSpinner: + def __init__(self, *_args: t.Any, **_kwargs: t.Any) -> None: + pass + + def start(self) -> None: + calls.append("start") + + def stop(self) -> None: + calls.append("stop") + + def success(self) -> None: + calls.append("success") + + def add_output_line(self, line: str) -> None: + calls.append(f"add_output_line:{line}") + + def on_build_event(self, event: dict[str, t.Any]) -> None: + calls.append(str(event["event"])) + + def fake_dispatch_build( + builder: WorkspaceBuilder, + *_args: t.Any, + **_kwargs: t.Any, + ) -> None: + captured_script_callback.append(builder.on_script_output) + assert builder.on_build_event is not None + + builder.on_build_event({"event": "before_script_started"}) + if builder.on_script_output is not None: + builder.on_script_output("before line") + builder.on_build_event({"event": "before_script_done"}) + + config = { + "session_name": f"before-script-progress-{test_id}", + "before_script": "echo before", + "windows": [{"window_name": "main"}], + } + config_file = tmp_path / f"{test_id}.yaml" + config_file.write_text(yaml.dump(config)) + + monkeypatch.delenv("TMUX", raising=False) + if env_value is None: + monkeypatch.delenv("TMUXP_PROGRESS_LINES", raising=False) + else: + monkeypatch.setenv("TMUXP_PROGRESS_LINES", env_value) + monkeypatch.setattr("tmuxp.cli.load.Spinner", FakeSpinner) + monkeypatch.setattr("tmuxp.cli.load._dispatch_build", fake_dispatch_build) + + result = load_workspace( + str(config_file), + socket_name=server.socket_name, + cli_colors=Colors(ColorMode.NEVER), + panel_lines=panel_lines, + ) + + assert result is None + assert (captured_script_callback[0] is not None) is expected_capture + if expected_capture: + assert calls == [ + "start", + "before_script_started", + "add_output_line:before line", + "before_script_done", + "stop", + ] + else: + assert calls == [ + "before_script_started", + "before_script_done", + "start", + "stop", + ] + + def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> None: """Spinner message should mask home directory via PrivatePath.""" monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) diff --git a/tests/cli/test_progress.py b/tests/cli/test_progress.py index 8ace187b65..8f27f07dd2 100644 --- a/tests/cli/test_progress.py +++ b/tests/cli/test_progress.py @@ -24,6 +24,7 @@ _truncate_visible, _visible_len, render_bar, + render_build_bar, resolve_progress_format, ) @@ -46,6 +47,89 @@ class SpinnerEnablementFixture(t.NamedTuple): ] +class TwoPhaseWindowDoneFixture(t.NamedTuple): + """Test fixture for non-interleaved window completion events.""" + + test_id: str + done_events: tuple[dict[str, t.Any], ...] + expected_done_names: tuple[str, ...] + expected_window: str + expected_progress: str + expected_session_pane_progress: str + + +TWO_PHASE_WINDOW_DONE_FIXTURES: list[TwoPhaseWindowDoneFixture] = [ + TwoPhaseWindowDoneFixture( + test_id="first_window_done", + done_events=({"event": "window_done", "name": "w1"},), + expected_done_names=("w1",), + expected_window="w1", + expected_progress="1/3 win", + expected_session_pane_progress="2/6", + ), + TwoPhaseWindowDoneFixture( + test_id="first_two_windows_done", + done_events=( + {"event": "window_done", "name": "w1"}, + {"event": "window_done", "name": "w2"}, + ), + expected_done_names=("w1", "w2"), + expected_window="w2", + expected_progress="2/3 win", + expected_session_pane_progress="3/6", + ), +] + + +class BuildBarFixture(t.NamedTuple): + """Test fixture for the two-phase build bar renderer.""" + + test_id: str + windows_done: int + windows_created: int + total: int + expected: str + + +BUILD_BAR_FIXTURES: list[BuildBarFixture] = [ + BuildBarFixture( + test_id="none_created", + windows_done=0, + windows_created=0, + total=11, + expected="░░░░░░░░░░", + ), + BuildBarFixture( + test_id="created_before_done", + windows_done=0, + windows_created=5, + total=11, + expected="▓▓▓▓▓░░░░░", + ), + BuildBarFixture( + test_id="done_and_created", + windows_done=1, + windows_created=5, + total=11, + expected="█▓▓▓▓░░░░░", + ), + BuildBarFixture( + test_id="done_does_not_complete_early", + windows_done=10, + windows_created=11, + total=11, + expected="█████████▓", + ), + BuildBarFixture( + test_id="all_done", + windows_done=11, + windows_created=11, + total=11, + expected="██████████", + ), +] + + @pytest.mark.parametrize( list(SpinnerEnablementFixture._fields), SPINNER_ENABLEMENT_FIXTURES, @@ -247,6 +331,53 @@ def test_build_tree_window_done_shows_checkmark() -> None: assert lines[1] == "- ✓ editor" +def _build_two_phase_progress_tree() -> BuildTree: + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "my-session", + "window_total": 3, + "session_pane_total": 6, + } + ) + for name, pane_total in (("w1", 2), ("w2", 1), ("w3", 3)): + tree.on_event( + { + "event": "window_started", + "name": name, + "pane_total": pane_total, + } + ) + return tree + + +@pytest.mark.parametrize( + list(TwoPhaseWindowDoneFixture._fields), + TWO_PHASE_WINDOW_DONE_FIXTURES, + ids=[f.test_id for f in TWO_PHASE_WINDOW_DONE_FIXTURES], +) +def test_build_tree_named_window_done_updates_completed_window( + test_id: str, + done_events: tuple[dict[str, t.Any], ...], + expected_done_names: tuple[str, ...], + expected_window: str, + expected_progress: str, + expected_session_pane_progress: str, +) -> None: + """Named window_done events update the completed window, not the last one.""" + tree = _build_two_phase_progress_tree() + + for event in done_events: + tree.on_event(event) + + context = tree._context() + assert tuple(w.name for w in tree.windows if w.done) == expected_done_names + assert context["window"] == expected_window + assert context["progress"] == expected_progress + assert context["session_pane_progress"] == expected_session_pane_progress + + def test_build_tree_workspace_built_marks_all_done() -> None: """workspace_built marks all windows as done.""" from tmuxp.cli._colors import Colors @@ -543,6 +674,22 @@ def test_render_bar_width_constant() -> None: assert len(bar) == BAR_WIDTH +@pytest.mark.parametrize( + list(BuildBarFixture._fields), + BUILD_BAR_FIXTURES, + ids=[f.test_id for f in BUILD_BAR_FIXTURES], +) +def test_render_build_bar( + test_id: str, + windows_done: int, + windows_created: int, + total: int, + expected: str, +) -> None: + """render_build_bar shows done, created, and empty segments.""" + assert render_build_bar(windows_done, windows_created, total) == expected + + # BuildTree new token tests @@ -735,6 +882,90 @@ def test_spinner_pane_bar_preset() -> None: assert "░" in spinner.message or "█" in spinner.message +def test_spinner_default_progress_tracks_started_window_in_phase_one() -> None: + """The default preset shows started-window progress while panes are created.""" + stream = io.StringIO() + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + for event in ( + {"event": "session_created", "name": "s", "window_total": 3}, + {"event": "window_started", "name": "w1", "pane_total": 2}, + {"event": "window_started", "name": "w2", "pane_total": 1}, + ): + spinner.on_build_event(event) + + assert "0/2 win · pane 0/1" in spinner.message + assert render_build_bar(0, 2, 3) in spinner.message + assert spinner.message.endswith("w2") + + +def test_spinner_default_bar_tracks_created_windows_before_completion() -> None: + """The default bar shows created windows before any window_done events.""" + stream = io.StringIO() + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + spinner.on_build_event( + { + "event": "session_created", + "name": "study", + "window_total": 11, + "session_pane_total": 44, + } + ) + for index in range(1, 6): + spinner.on_build_event( + { + "event": "window_started", + "name": f"w{index}", + "pane_total": 4, + } + ) + spinner.on_build_event( + {"event": "pane_creating", "pane_num": 4, "pane_total": 4} + ) + + assert "0/5 win · pane 4/4" in spinner.message + assert "▓" in spinner.message + assert render_bar(0, 11) not in spinner.message + + +def test_spinner_default_progress_tracks_completed_window_in_phase_two() -> None: + """The default preset changes current window when it completes.""" + stream = io.StringIO() + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + for event in ( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + }, + {"event": "window_started", "name": "w1", "pane_total": 2}, + {"event": "window_started", "name": "w2", "pane_total": 1}, + {"event": "window_started", "name": "w3", "pane_total": 3}, + {"event": "pane_creating", "pane_num": 3, "pane_total": 3}, + {"event": "window_done", "name": "w1"}, + ): + spinner.on_build_event(event) + + assert "1/3 win" in spinner.message + assert render_build_bar(1, 3, 3) in spinner.message + assert spinner.message.endswith("w1") + + def test_spinner_before_script_event_via_events() -> None: """before_script_started / before_script_done toggle the BuildTree Event flag.""" stream = io.StringIO() diff --git a/tests/test_util.py b/tests/test_util.py index 098c8c212b..7677f2ce61 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -5,6 +5,7 @@ import logging import os import pathlib +import subprocess import sys import typing as t @@ -41,6 +42,40 @@ def test_run_before_script_raise_BeforeLoadScriptError_if_retcode() -> None: run_before_script(script_file) +def test_run_before_script_inherits_stdio_without_observer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """run_before_script() leaves stdio attached unless an observer is needed.""" + captured_kwargs: dict[str, t.Any] = {} + + class EmptyStream: + def readline(self) -> str: + return "" + + class FakeProcess: + returncode = 0 + stdout = EmptyStream() + stderr = EmptyStream() + + def poll(self) -> int: + return self.returncode + + def wait(self) -> int: + return self.returncode + + def fake_popen(_args: list[str], **kwargs: t.Any) -> FakeProcess: + captured_kwargs.update(kwargs) + return FakeProcess() + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + + returncode = run_before_script("echo ok") + + assert returncode == 0 + assert captured_kwargs.get("stdout") is None + assert captured_kwargs.get("stderr") is None + + @pytest.fixture def temp_script(tmp_path: pathlib.Path) -> pathlib.Path: """Fixture of an example script that prints "Hello, world!".""" @@ -56,7 +91,7 @@ def temp_script(tmp_path: pathlib.Path) -> pathlib.Path: class TTYTestFixture(t.NamedTuple): - """Test fixture for isatty behavior verification.""" + """Test fixture for inherited subprocess stdio verification.""" test_id: str isatty_value: bool @@ -70,9 +105,9 @@ class TTYTestFixture(t.NamedTuple): expected_output="Hello, World!", ), TTYTestFixture( - test_id="tty_disabled_suppresses_output", + test_id="tty_disabled_still_streams_output", isatty_value=False, - expected_output="", + expected_output="Hello, World!", ), ] @@ -82,32 +117,28 @@ class TTYTestFixture(t.NamedTuple): TTY_TEST_FIXTURES, ids=[test.test_id for test in TTY_TEST_FIXTURES], ) -def test_run_before_script_isatty( +def test_run_before_script_inherits_stdio_regardless_of_parent_isatty( temp_script: pathlib.Path, monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], + capfd: pytest.CaptureFixture[str], test_id: str, isatty_value: bool, expected_output: str, ) -> None: - """Verify behavior of ``isatty()``, which we mock in `run_before_script()`.""" - # Mock sys.stdout.isatty() to return the desired value. + """run_before_script() lets the child process own its stdout behavior.""" monkeypatch.setattr(sys.stdout, "isatty", lambda: isatty_value) - # Run the script. returncode = run_before_script(temp_script) - # Assert that the script ran successfully. assert returncode == 0 - out, _err = capsys.readouterr() + out, _err = capfd.readouterr() - # In TTY mode, we expect the output; in non-TTY mode, we expect it to be suppressed. assert expected_output in out def test_return_stdout_if_ok( - capsys: pytest.CaptureFixture[str], + capfd: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: """run_before_script() returns stdout if script succeeds.""" @@ -118,7 +149,7 @@ def test_return_stdout_if_ok( script_file = FIXTURE_PATH / "script_complete.sh" run_before_script(script_file) - out, _err = capsys.readouterr() + out, _err = capfd.readouterr() assert "hello" in out diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 007a489c16..d560cb3159 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -6,6 +6,7 @@ import logging import os import pathlib +import shlex import textwrap import time import typing as t @@ -13,6 +14,7 @@ import libtmux import pytest from libtmux._internal.query_list import ObjectDoesNotExist +from libtmux.constants import OptionScope from libtmux.exc import LibTmuxException from libtmux.pane import Pane from libtmux.session import Session @@ -25,8 +27,13 @@ from tmuxp import exc from tmuxp._internal.config_reader import ConfigReader from tmuxp.cli.load import load_plugins +from tmuxp.plugin import TmuxpPlugin from tmuxp.workspace import builder as builder_module, loader -from tmuxp.workspace.builder import WorkspaceBuilder, _wait_for_pane_ready +from tmuxp.workspace.builder import ( + WorkspaceBuilder, + _wait_for_pane_ready, + _wait_for_panes_ready, +) if t.TYPE_CHECKING: from libtmux.server import Server @@ -359,6 +366,185 @@ def f() -> bool: ), "Synchronized command did not execute properly" +class SynchronizePanesFixture(t.NamedTuple): + """Synchronize-panes command isolation fixture.""" + + test_id: str + yaml: str + enable_global_sync: bool + expected_local_sync: bool | None + + +SYNCHRONIZE_PANES_FIXTURES: list[SynchronizePanesFixture] = [ + SynchronizePanesFixture( + test_id="window_option", + yaml=textwrap.dedent( + """\ +session_name: sync-command-test +windows: +- window_name: sync + options: + synchronize-panes: on + panes: + - shell_command: + - cmd: echo tmuxp-left-sync-marker + - shell_command: + - cmd: echo tmuxp-right-sync-marker +""", + ), + enable_global_sync=False, + expected_local_sync=True, + ), + SynchronizePanesFixture( + test_id="global_default", + yaml=textwrap.dedent( + """\ +session_name: sync-command-test +windows: +- window_name: sync + panes: + - shell_command: + - cmd: echo tmuxp-left-sync-marker + - shell_command: + - cmd: echo tmuxp-right-sync-marker +""", + ), + enable_global_sync=True, + expected_local_sync=None, + ), +] + + +class SynchronizePanesExitFixture(t.NamedTuple): + """Synchronize-panes fixture with a pane that exits during setup.""" + + test_id: str + yaml: str + + +SYNCHRONIZE_PANES_EXIT_FIXTURES: list[SynchronizePanesExitFixture] = [ + SynchronizePanesExitFixture( + test_id="pane_exit", + yaml=textwrap.dedent( + """\ +session_name: sync-exit-test +windows: +- window_name: sync-exit + options: + synchronize-panes: on + panes: + - shell_command: + - cmd: printf tmuxp-survivor-marker; sleep 1 + - shell_command: + - cmd: exit + sleep_after: 0.3 +""", + ), + ), +] + + +@pytest.mark.parametrize( + list(SynchronizePanesFixture._fields), + SYNCHRONIZE_PANES_FIXTURES, + ids=[t.test_id for t in SYNCHRONIZE_PANES_FIXTURES], +) +def test_synchronize_panes_disabled_during_pane_commands( + tmp_path: pathlib.Path, + session: Session, + test_id: str, + yaml: str, + enable_global_sync: bool, + expected_local_sync: bool | None, +) -> None: + """Per-pane shell commands do not broadcast when synchronize-panes is on.""" + yaml_workspace = tmp_path / f"{test_id}.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + if enable_global_sync: + session.active_window.set_option( + "synchronize-panes", + "on", + global_=True, + scope=OptionScope.Window, + ) + + try: + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + window = session.active_window + left, right = window.panes + + def capture(pane: Pane) -> str: + return "\n".join(pane.cmd("capture-pane", "-p", "-J").stdout) + + def commands_stayed_per_pane() -> bool: + left_text = capture(left) + right_text = capture(right) + return ( + "tmuxp-left-sync-marker" in left_text + and "tmuxp-right-sync-marker" not in left_text + and "tmuxp-right-sync-marker" in right_text + and "tmuxp-left-sync-marker" not in right_text + ) + + assert retry_until(commands_stayed_per_pane, 5, interval=0.1), ( + capture(left), + capture(right), + ) + assert ( + window.show_option("synchronize-panes", scope=OptionScope.Window) + is expected_local_sync + ) + assert ( + window.show_option( + "synchronize-panes", + scope=OptionScope.Window, + include_inherited=True, + ) + is True + ) + finally: + if enable_global_sync: + session.active_window.set_option( + "synchronize-panes", + "off", + global_=True, + scope=OptionScope.Window, + ) + + +@pytest.mark.parametrize( + list(SynchronizePanesExitFixture._fields), + SYNCHRONIZE_PANES_EXIT_FIXTURES, + ids=[t.test_id for t in SYNCHRONIZE_PANES_EXIT_FIXTURES], +) +def test_synchronize_panes_ignores_exited_targets( + tmp_path: pathlib.Path, + session: Session, + test_id: str, + yaml: str, +) -> None: + """Exiting startup panes do not break temporary sync restoration.""" + yaml_workspace = tmp_path / f"{test_id}.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + window = session.active_window + + def pane_count() -> bool: + return len(window.panes) == 1 + + assert retry_until(pane_count, 5, interval=0.1) + + def test_window_shell( session: Session, ) -> None: @@ -543,6 +729,104 @@ def f(path: str, p: Pane) -> bool: assert retry_until(f_) +def test_build_dispatches_window_commands_before_later_start_directory( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """Earlier window commands can prepare a later window's start_directory.""" + late_dir = tmp_path / "late-dir" + yaml_config = textwrap.dedent( + f"""\ +session_name: window-command-order +windows: +- window_name: bootstrap + panes: + - shell_command: + - cmd: mkdir -p {shlex.quote(str(late_dir))} + sleep_after: 0.2 +- window_name: dependent + start_directory: {late_dir!s} + panes: + - shell_command: [] +""", + ) + workspace = ConfigReader._load(fmt="yaml", content=yaml_config) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + dependent = session.windows.get(window_name="dependent") + assert dependent is not None + pane = dependent.active_pane + assert pane is not None + + def has_late_dir() -> bool: + return pane.pane_current_path == str(late_dir) + + assert retry_until(has_late_dir) + + +class SameWindowStartDirectoryFixture(t.NamedTuple): + """Same-window start_directory dependency fixture.""" + + test_id: str + later_pane_yaml: str + + +SAME_WINDOW_START_DIRECTORY_FIXTURES: list[SameWindowStartDirectoryFixture] = [ + SameWindowStartDirectoryFixture( + test_id="pane_start_directory", + later_pane_yaml=" - shell_command: []\n start_directory: {late_dir!s}\n", + ), +] + + +@pytest.mark.parametrize( + list(SameWindowStartDirectoryFixture._fields), + SAME_WINDOW_START_DIRECTORY_FIXTURES, + ids=[t.test_id for t in SAME_WINDOW_START_DIRECTORY_FIXTURES], +) +def test_build_dispatches_same_window_commands_before_later_start_directory( + session: Session, + tmp_path: pathlib.Path, + test_id: str, + later_pane_yaml: str, +) -> None: + """Earlier pane commands can prepare a later pane's start_directory.""" + late_dir = tmp_path / test_id / "late-dir" + yaml_config = textwrap.dedent( + f"""\ +session_name: same-window-command-order +windows: +- window_name: dependent + panes: + - shell_command: + - cmd: mkdir -p {shlex.quote(str(late_dir))} + sleep_after: 0.2 +{later_pane_yaml.format(late_dir=late_dir)} +""", + ) + workspace = ConfigReader._load(fmt="yaml", content=yaml_config) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + dependent = session.windows.get(window_name="dependent") + assert dependent is not None + panes = dependent.panes + assert len(panes) == 2 + pane = panes[1] + + def has_late_dir() -> bool: + return pane.pane_current_path == str(late_dir) + + assert retry_until(has_late_dir) + + def test_start_directory_relative(session: Session, tmp_path: pathlib.Path) -> None: """Test workspace builder setting start_directory relative to project file. @@ -1549,6 +1833,35 @@ def test_wait_for_pane_ready_timeout(session: Session) -> None: assert result is False +def test_wait_for_panes_ready_all_ready(session: Session) -> None: + """The shared barrier reports every default-shell pane ready.""" + window = session.active_window + first = window.active_pane + assert first is not None + second = first.split() + assert second is not None + + result = _wait_for_panes_ready([first, second], timeout=5.0) + + assert result == {first.pane_id: True, second.pane_id: True} + + +def test_wait_for_panes_ready_mixed(session: Session) -> None: + """The shared barrier times out only the pane that never draws a prompt.""" + window = session.active_window + first = window.active_pane + assert first is not None + sleeper = first.split(shell="sleep 999") + assert sleeper is not None + assert first.pane_id is not None + assert sleeper.pane_id is not None + + result = _wait_for_panes_ready([first, sleeper], timeout=0.5) + + assert result[first.pane_id] is True + assert result[sleeper.pane_id] is False + + class PaneReadinessFixture(t.NamedTuple): """Test fixture for pane readiness call count verification.""" @@ -1625,7 +1938,7 @@ class PaneReadinessFixture(t.NamedTuple): PANE_READINESS_FIXTURES, ids=[t.test_id for t in PANE_READINESS_FIXTURES], ) -def test_pane_readiness_call_count( +def test_pane_readiness_waits_for_default_shell_panes( tmp_path: pathlib.Path, server: Server, monkeypatch: pytest.MonkeyPatch, @@ -1633,20 +1946,19 @@ def test_pane_readiness_call_count( yaml: str, expected_wait_count: int, ) -> None: - """Verify _wait_for_pane_ready is called only for appropriate panes.""" - call_count = 0 - original = builder_module._wait_for_pane_ready + """Only default-shell panes are submitted to the readiness barrier.""" + waited: list[str] = [] + original = builder_module._wait_for_panes_ready - def counting_wait( - pane: Pane, + def recording_barrier( + panes: list[Pane], timeout: float = 2.0, interval: float = 0.05, - ) -> bool: - nonlocal call_count - call_count += 1 - return original(pane, timeout=timeout, interval=interval) + ) -> dict[str, bool]: + waited.extend(p.pane_id for p in panes if p.pane_id is not None) + return original(panes, timeout=timeout, interval=interval) - monkeypatch.setattr(builder_module, "_wait_for_pane_ready", counting_wait) + monkeypatch.setattr(builder_module, "_wait_for_panes_ready", recording_barrier) yaml_workspace = tmp_path / "readiness.yaml" yaml_workspace.write_text(yaml, encoding="utf-8") @@ -1656,15 +1968,15 @@ def counting_wait( builder = WorkspaceBuilder(session_config=workspace, server=server) builder.build() - assert call_count == expected_wait_count + assert len(waited) == expected_wait_count -def test_select_layout_not_called_after_yield( +def test_select_layout_called_once_per_window( tmp_path: pathlib.Path, server: Server, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Verify select_layout is called once per pane, not duplicated in build().""" + """select_layout runs once per window, after the readiness barrier.""" call_count = 0 original_select_layout = Window.select_layout @@ -1695,8 +2007,334 @@ def counting_layout(self: Window, layout: str | None = None) -> Window: builder = WorkspaceBuilder(session_config=workspace, server=server) builder.build() - # 3 panes = 3 layout calls (one per pane in iter_create_panes), not 6 - assert call_count == 3 + # One window, one layout pass — no per-pane and no duplicate build() pass. + assert call_count == 1 + + +def test_split_target_refreshes_without_readiness_wait( + tmp_path: pathlib.Path, + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Normal pane splitting refreshes target state without prompt waiting.""" + events: list[str] = [] + original_refresh = Pane.refresh + original_split = Pane.split + + def recording_refresh(self: Pane) -> None: + events.append("refresh") + return original_refresh(self) + + def recording_split(self: Pane, *args: t.Any, **kwargs: t.Any) -> Pane: + events.append("split") + return original_split(self, *args, **kwargs) + + def recording_barrier( + panes: list[Pane], + timeout: float = 2.0, + interval: float = 0.05, + ) -> dict[str, bool]: + events.append("wait") + return {pane.pane_id: True for pane in panes if pane.pane_id is not None} + + monkeypatch.setattr(Pane, "refresh", recording_refresh) + monkeypatch.setattr(Pane, "split", recording_split) + monkeypatch.setattr(builder_module, "_wait_for_panes_ready", recording_barrier) + + yaml_config = textwrap.dedent( + """\ +session_name: split-refresh-test +windows: +- panes: + - shell_command: [] + - shell_command: [] +""", + ) + yaml_workspace = tmp_path / "split_refresh.yaml" + yaml_workspace.write_text(yaml_config, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder._create_window_panes(session.active_window, workspace["windows"][0]) + + assert "wait" not in events + assert events[:2] == ["refresh", "split"] + + +class ReclaimSpaceFixture(t.NamedTuple): + """Reclaim-retry exception-matching fixture.""" + + test_id: str + error_message: str + expect_reclaim: bool + + +RECLAIM_SPACE_FIXTURES: list[ReclaimSpaceFixture] = [ + ReclaimSpaceFixture( + test_id="no_space_reclaims", + error_message="no space for new pane", + expect_reclaim=True, + ), + ReclaimSpaceFixture( + test_id="other_error_propagates", + error_message="some other tmux failure", + expect_reclaim=False, + ), +] + + +@pytest.mark.parametrize( + list(ReclaimSpaceFixture._fields), + RECLAIM_SPACE_FIXTURES, + ids=[c.test_id for c in RECLAIM_SPACE_FIXTURES], +) +def test_split_reclaims_only_on_no_space( + session: Session, + test_id: str, + error_message: str, + expect_reclaim: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reclaim retry runs only when a split fails for lack of space.""" + builder = WorkspaceBuilder( + session_config={"session_name": "x", "windows": []}, + server=session.server, + ) + window = session.active_window + pane = window.active_pane + assert pane is not None + + layout_calls: list[str | None] = [] + monkeypatch.setattr( + Window, + "select_layout", + lambda self, layout=None: layout_calls.append(layout), + ) + + def failing_split(self: Pane, *args: t.Any, **kwargs: t.Any) -> Pane: + raise LibTmuxException(error_message) + + monkeypatch.setattr(Pane, "split", failing_split) + + with pytest.raises(LibTmuxException, match=error_message): + builder._split_pane_reclaiming_space( + window, + pane, + {"attach": True}, + "tiled", + [], + ) + + assert bool(layout_calls) == expect_reclaim + + +def test_build_waits_for_each_window_before_dispatch( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """build() waits per window so earlier commands run before later windows.""" + barrier_sizes: list[int] = [] + original = builder_module._wait_for_panes_ready + + def recording_barrier( + panes: list[Pane], + timeout: float = 2.0, + interval: float = 0.05, + ) -> dict[str, bool]: + barrier_sizes.append(len(panes)) + return original(panes, timeout=timeout, interval=interval) + + monkeypatch.setattr(builder_module, "_wait_for_panes_ready", recording_barrier) + + yaml_config = textwrap.dedent( + """\ +session_name: barrier-test +windows: +- window_name: one + layout: tiled + panes: + - shell_command: [] + - shell_command: [] +- window_name: two + layout: tiled + panes: + - shell_command: [] + - shell_command: [] +""", + ) + yaml_workspace = tmp_path / "barrier.yaml" + yaml_workspace.write_text(yaml_config, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + assert barrier_sizes == [2, 2] + + +def test_layout_runs_after_readiness_barrier( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Each window is laid out after that window's readiness barrier. + + This is the issue #365 safety invariant: a pane must draw its prompt before + it is resized, so every ``select_layout`` must follow a barrier. + """ + events: list[str] = [] + original_barrier = builder_module._wait_for_panes_ready + original_select_layout = Window.select_layout + + def traced_barrier( + panes: list[Pane], + timeout: float = 2.0, + interval: float = 0.05, + ) -> dict[str, bool]: + result = original_barrier(panes, timeout=timeout, interval=interval) + events.append("barrier") + return result + + def traced_layout(self: Window, layout: str | None = None) -> Window: + events.append("layout") + return original_select_layout(self, layout) + + monkeypatch.setattr(builder_module, "_wait_for_panes_ready", traced_barrier) + monkeypatch.setattr(Window, "select_layout", traced_layout) + + yaml_config = textwrap.dedent( + """\ +session_name: ordering-test +windows: +- window_name: one + layout: tiled + panes: + - shell_command: [] + - shell_command: [] +- window_name: two + layout: tiled + panes: + - shell_command: [] +""", + ) + yaml_workspace = tmp_path / "ordering.yaml" + yaml_workspace.write_text(yaml_config, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + assert "barrier" in events + assert "layout" in events + assert events == ["barrier", "layout", "barrier", "layout"] + + +class _HookOrderRecorder(TmuxpPlugin): + """Plugin that records the order its lifecycle hooks fire.""" + + def __init__(self, calls: list[str]) -> None: + self.calls = calls + + def before_workspace_builder(self, session: Session) -> None: + """Record the session-level pre-build hook.""" + self.calls.append("before_workspace_builder") + + def on_window_create(self, window: Window) -> None: + """Record the per-window creation hook.""" + self.calls.append(f"on_window_create:{window.name}") + + def after_window_finished(self, window: Window) -> None: + """Record the per-window completion hook.""" + self.calls.append(f"after_window_finished:{window.name}") + + +class PluginHookOrderFixture(t.NamedTuple): + """Expected plugin hook firing order for a workspace config.""" + + test_id: str + yaml: str + expected_order: list[str] + + +PLUGIN_HOOK_ORDER_FIXTURES: list[PluginHookOrderFixture] = [ + PluginHookOrderFixture( + test_id="single_window", + yaml=textwrap.dedent( + """\ +session_name: hook-order +windows: +- window_name: one + panes: + - shell_command: [] +""", + ), + expected_order=[ + "before_workspace_builder", + "on_window_create:one", + "after_window_finished:one", + ], + ), + PluginHookOrderFixture( + test_id="interleaves_create_and_finish", + yaml=textwrap.dedent( + """\ +session_name: hook-order +windows: +- window_name: one + panes: + - shell_command: [] +- window_name: two + panes: + - shell_command: [] +""", + ), + expected_order=[ + "before_workspace_builder", + "on_window_create:one", + "after_window_finished:one", + "on_window_create:two", + "after_window_finished:two", + ], + ), +] + + +@pytest.mark.parametrize( + list(PluginHookOrderFixture._fields), + PLUGIN_HOOK_ORDER_FIXTURES, + ids=[f.test_id for f in PLUGIN_HOOK_ORDER_FIXTURES], +) +def test_plugin_hook_order( + tmp_path: pathlib.Path, + server: Server, + test_id: str, + yaml: str, + expected_order: list[str], +) -> None: + """Per-window create and finish hooks follow config order.""" + calls: list[str] = [] + + yaml_workspace = tmp_path / "hook_order.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + plugins=[_HookOrderRecorder(calls)], + server=server, + ) + builder.build() + + assert calls == expected_order def test_builder_logs_session_created( diff --git a/tests/workspace/test_progress.py b/tests/workspace/test_progress.py index 336fa9dafd..bce5849a0e 100644 --- a/tests/workspace/test_progress.py +++ b/tests/workspace/test_progress.py @@ -116,6 +116,38 @@ def test_builder_on_build_event_sequence( assert created["session_pane_total"] == 3 # 2 panes + 1 pane +def test_builder_window_done_events_include_window_identity( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """window_done events identify the completed window.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "window-done-identity-test", + "windows": [ + { + "window_name": "editor", + "panes": [{"shell_command": []}, {"shell_command": []}], + }, + {"window_name": "logs", "panes": [{"shell_command": []}]}, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + done_events = [e for e in events if e["event"] == "window_done"] + assert [(e["name"], e["pane_total"]) for e in done_events] == [ + ("editor", 2), + ("logs", 1), + ] + + def test_builder_on_build_event_session_name( server: Server, monkeypatch: pytest.MonkeyPatch,