rmux 0.9.0

A local terminal multiplexer with a tmux-style CLI, daemon runtime, Rust SDK, and ratatui integration.
use rmux_client::Connection;
use rmux_proto::{
    encode_internal_pane_exit_probe, PaneId, PaneSnapshotResponse, PaneTarget, PaneTargetRef,
    ResolveTargetType, Response, SessionId, SessionName, Target,
};

use crate::cli_args::TargetSpec;
use crate::cli_response::tmux_cli_error_message;

use super::super::{listed_pane_index_matches_target, ExitFailure};
use super::common::{pane_snapshot, resolve_pane_slot};
use super::pane_exit::PaneExitStatus;

const MAX_RENAME_RETRIES: usize = 8;

#[derive(Debug, Clone)]
pub(super) struct StableWaitTarget {
    session_id: SessionId,
    pane_id: PaneId,
    session_name: SessionName,
}

impl StableWaitTarget {
    pub(super) fn target_ref(&self) -> PaneTargetRef {
        PaneTargetRef::by_id(self.session_name.clone(), self.pane_id)
    }

    pub(super) fn refreshed_target_ref(
        &mut self,
        connection: &mut Connection,
        command_name: &'static str,
    ) -> Result<PaneTargetRef, ExitFailure> {
        match self.refresh_session_name(connection, command_name)? {
            SessionNameRefresh::Unchanged | SessionNameRefresh::Changed => Ok(self.target_ref()),
            SessionNameRefresh::Gone(error) => Err(error),
        }
    }

    fn refresh_session_name(
        &mut self,
        connection: &mut Connection,
        command_name: &'static str,
    ) -> Result<SessionNameRefresh, ExitFailure> {
        let response = connection
            .resolve_target(
                Some(self.session_id.to_string()),
                ResolveTargetType::Session,
                false,
                false,
            )
            .map_err(ExitFailure::from_client)?;
        let session_name = match response {
            Response::ResolveTarget(response) => match response.target {
                Target::Session(session_name) => session_name,
                target => {
                    return Err(ExitFailure::new(
                        1,
                        format!(
                            "protocol error: resolve-target produced a {} target while refreshing a pane wait",
                            target_kind_name(&target)
                        ),
                    ));
                }
            },
            Response::Error(error) => {
                return Ok(SessionNameRefresh::Gone(ExitFailure::new(
                    1,
                    tmux_cli_error_message(command_name, &error.error),
                )));
            }
            other => {
                return Err(ExitFailure::new(
                    1,
                    format!(
                        "protocol error: unexpected '{}' response while refreshing a pane wait",
                        other.command_name()
                    ),
                ));
            }
        };
        if session_name == self.session_name {
            return Ok(SessionNameRefresh::Unchanged);
        }
        self.session_name = session_name;
        Ok(SessionNameRefresh::Changed)
    }
}

pub(super) enum StableWaitProcessState {
    Alive,
    Exited {
        status: PaneExitStatus,
        retained: bool,
    },
    TargetGone,
}

enum SessionNameRefresh {
    Unchanged,
    Changed,
    Gone(ExitFailure),
}

enum ProcessLookup {
    State(StableWaitProcessState),
    TargetUnavailable,
}

pub(super) fn resolve(
    connection: &mut Connection,
    target: Option<&TargetSpec>,
    command_name: &'static str,
) -> Result<StableWaitTarget, ExitFailure> {
    let slot = resolve_pane_slot(connection, target, command_name)?;
    for_slot(connection, &slot, command_name)
}

pub(super) fn for_slot(
    connection: &mut Connection,
    slot: &PaneTarget,
    command_name: &'static str,
) -> Result<StableWaitTarget, ExitFailure> {
    let (session_id, pane_id) = pane_identity_for_slot(connection, slot, command_name)?;
    Ok(StableWaitTarget {
        session_id,
        pane_id,
        session_name: slot.session_name().clone(),
    })
}

pub(super) fn snapshot(
    connection: &mut Connection,
    target: &mut StableWaitTarget,
) -> Result<PaneSnapshotResponse, ExitFailure> {
    for _ in 0..MAX_RENAME_RETRIES {
        match pane_snapshot(connection, target.target_ref()) {
            Ok(snapshot) => return Ok(snapshot),
            Err(error) => match target.refresh_session_name(connection, "pane-snapshot")? {
                SessionNameRefresh::Changed => {}
                SessionNameRefresh::Unchanged => return Err(error),
                SessionNameRefresh::Gone(error) => return Err(error),
            },
        }
    }
    Err(repeated_rename_error())
}

pub(super) fn process_state(
    connection: &mut Connection,
    target: &mut StableWaitTarget,
) -> Result<StableWaitProcessState, ExitFailure> {
    for _ in 0..MAX_RENAME_RETRIES {
        match query_process_state(connection, target)? {
            ProcessLookup::State(state) => return Ok(state),
            ProcessLookup::TargetUnavailable => {
                match target.refresh_session_name(connection, "wait-pane")? {
                    SessionNameRefresh::Changed => {}
                    SessionNameRefresh::Unchanged => {
                        return Ok(StableWaitProcessState::Exited {
                            status: PaneExitStatus::stale(),
                            retained: false,
                        });
                    }
                    SessionNameRefresh::Gone(_) => {
                        return Ok(StableWaitProcessState::TargetGone);
                    }
                }
            }
        }
    }
    Err(repeated_rename_error())
}

fn pane_identity_for_slot(
    connection: &mut Connection,
    target: &PaneTarget,
    command_name: &'static str,
) -> Result<(SessionId, PaneId), ExitFailure> {
    let response = connection
        .list_panes_in_window(
            target.session_name().clone(),
            Some(target.window_index()),
            Some("#{pane_index}\t#{pane-base-index}\t#{pane_id}\t#{session_id}\n".to_owned()),
        )
        .map_err(ExitFailure::from_client)?;
    let output = match response {
        Response::ListPanes(response) => response.output,
        Response::Error(error) => {
            return Err(ExitFailure::new(
                1,
                tmux_cli_error_message(command_name, &error.error),
            ));
        }
        other => return Err(unexpected_response(&other, "resolving pane wait identity")),
    };
    let text = String::from_utf8_lossy(output.stdout());
    for line in text.lines() {
        let mut fields = line.split('\t');
        if !listed_pane_index_matches_target(
            target,
            fields.next().unwrap_or_default(),
            fields.next().unwrap_or_default(),
        ) {
            continue;
        }
        if let Some((pane_id, session_id)) = fields
            .next()
            .and_then(parse_pane_id)
            .zip(fields.next().and_then(parse_session_id))
        {
            return Ok((session_id, pane_id));
        }
        break;
    }
    Err(ExitFailure::new(
        1,
        format!("unable to resolve stable pane identity for target {target}"),
    ))
}

fn query_process_state(
    connection: &mut Connection,
    target: &StableWaitTarget,
) -> Result<ProcessLookup, ExitFailure> {
    let response = connection
        .list_panes_in_window(
            target.session_name.clone(),
            None,
            Some(encode_internal_pane_exit_probe(
                target.session_id,
                target.pane_id,
            )),
        )
        .map_err(ExitFailure::from_client)?;
    let output = match response {
        Response::ListPanes(response) => response.output,
        Response::Error(_) => return Ok(ProcessLookup::TargetUnavailable),
        other => return Err(unexpected_response(&other, "reading pane process state")),
    };
    for line in String::from_utf8_lossy(output.stdout()).lines() {
        let mut fields = line.split('\t');
        if fields.next().and_then(parse_pane_id) != Some(target.pane_id) {
            continue;
        }
        let dead = fields.next() == Some("1");
        if !dead {
            return Ok(ProcessLookup::State(StableWaitProcessState::Alive));
        }
        return Ok(ProcessLookup::State(StableWaitProcessState::Exited {
            status: PaneExitStatus::known(
                parse_i32_field(fields.next()),
                parse_i32_field(fields.next()),
            ),
            retained: fields.next() == Some("1"),
        }));
    }
    Ok(ProcessLookup::TargetUnavailable)
}

fn parse_pane_id(value: &str) -> Option<PaneId> {
    value
        .strip_prefix('%')?
        .parse::<u32>()
        .ok()
        .map(PaneId::new)
}

fn parse_session_id(value: &str) -> Option<SessionId> {
    value
        .strip_prefix('$')?
        .parse::<u32>()
        .ok()
        .map(SessionId::new)
}

fn parse_i32_field(value: Option<&str>) -> Option<i32> {
    value
        .filter(|value| !value.is_empty())
        .and_then(|value| value.parse::<i32>().ok())
}

fn target_kind_name(target: &Target) -> &'static str {
    match target {
        Target::Session(_) => "session",
        Target::Window(_) => "window",
        Target::Pane(_) => "pane",
    }
}

fn unexpected_response(response: &Response, context: &str) -> ExitFailure {
    ExitFailure::new(
        1,
        format!(
            "protocol error: unexpected '{}' response while {context}",
            response.command_name()
        ),
    )
}

fn repeated_rename_error() -> ExitFailure {
    ExitFailure::new(1, "pane target session changed repeatedly while waiting")
}