use std::borrow::Cow;
use std::env;
use std::fmt::Write;
use std::fs::write;
use std::process::{self, Command};
use anyhow::{Context, Result};
use shell_escape::escape;
use tempfile::NamedTempFile;
use crate::tmux::session::{Pane, Session, Window};
const TMUX_FIELD_SEPARATOR: &str = "\x1f";
const TMUX_LINE_SEPARATOR: &str = "\n";
pub fn get_session(session_name: Option<&str>) -> Result<Session> {
let name = if let Some(name) = session_name {
name.to_string()
} else {
get_session_name()?
};
let path = get_session_path(&name)
.with_context(|| format!("Failed to get working directory for session '{name}'"))?;
let windows = get_windows(&name)
.with_context(|| format!("Failed to get windows for session '{name}'"))?;
Ok(Session {
name,
work_dir: path,
windows,
})
}
pub fn restore_session(session: &Session) -> Result<()> {
if session.windows.is_empty() {
anyhow::bail!("Cannot restore session without windows");
}
let temp_session_name = format!("tsman-temp-{}", process::id());
let mut script_str = String::new();
writeln!(
script_str,
"tmux new-session -d -s {} -c {}",
temp_session_name,
escape(Cow::from(&session.work_dir))
)?;
let first_window = &session.windows[0];
script_str += &get_window_config_cmd(&temp_session_name, session, first_window)?;
for window in session.windows.iter().skip(1) {
writeln!(
script_str,
"tmux new-window -d -t {} -c {}",
temp_session_name,
escape(Cow::from(&session.work_dir))
)?;
script_str += &get_window_config_cmd(&temp_session_name, session, window)?;
}
writeln!(
script_str,
"tmux rename-session -t {} {}",
temp_session_name, session.name
)?;
let script = NamedTempFile::new()?;
write(script.path(), script_str)?;
Command::new("sh")
.arg(script.path())
.status()
.context("Failed to reconstruct session")?;
attach_to_session(&session.name)
}
pub fn is_active_session(session_name: &str) -> Result<bool> {
let output = Command::new("tmux")
.arg("list-session")
.args(["-F", "#{session_name}"])
.output()
.context("Failed to get sessions")?;
let output_str = String::from_utf8(output.stdout)?;
let session_names = output_str.split(TMUX_LINE_SEPARATOR).collect::<Vec<&str>>();
Ok(session_names.contains(&session_name))
}
pub fn attach_to_session(session_name: &str) -> Result<()> {
let is_attached = env::var("TMUX").is_ok();
let attach_cmd = if is_attached {
"switch-client"
} else {
"attach-session"
};
Command::new("tmux")
.arg(attach_cmd)
.args(["-t", session_name])
.status()
.context("Failed to attach session")?;
Ok(())
}
pub fn attach_to_window(session_name: &str, window_index: &str) -> Result<()> {
let window_target = format!("{session_name}:{window_index}");
let is_attached = env::var("TMUX").is_ok();
let status = if is_attached {
Command::new("tmux")
.args(["switch-client", "-t", session_name])
.args([";", "select-window", "-t", &window_target])
.status()
.context("Failed to switch to target window")?
} else {
Command::new("tmux")
.args(["attach-session", "-t", session_name])
.args([";", "select-window", "-t", &window_target])
.status()
.context("Failed to attach to target window")?
};
if !status.success() {
anyhow::bail!("tmux failed to attach/select window {window_target}");
}
Ok(())
}
pub fn capture_preview(session_name: &str, window_index: Option<&str>) -> Result<String> {
let target = match window_index {
Some(index) => format!("{session_name}:{index}"),
None => session_name.to_string(),
};
let pane_target = resolve_preview_pane(&target)?;
let output = Command::new("tmux")
.args(["capture-pane", "-p", "-S", "-200", "-t", &pane_target])
.output()
.with_context(|| format!("Failed to capture pane output for target {pane_target}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux capture-pane failed for {pane_target}: {stderr}");
}
let output = String::from_utf8(output.stdout)
.context("Failed to convert tmux capture-pane output to UTF-8")?;
Ok(output.trim_end().to_string())
}
fn resolve_preview_pane(target: &str) -> Result<String> {
let output = Command::new("tmux")
.args(["list-panes", "-t", target, "-F", "#{pane_id}"])
.output()
.with_context(|| format!("Failed to list panes for target {target}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("tmux list-panes failed for {target}: {stderr}");
}
let output = String::from_utf8(output.stdout)
.context("Failed to convert tmux list-panes output to UTF-8")?;
let pane_id = output
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.ok_or_else(|| anyhow::anyhow!("No panes found for target {target}"))?;
Ok(pane_id.to_string())
}
pub fn create_session(session_name: &str) -> Result<()> {
let status = Command::new("tmux")
.args(["new-session", "-d", "-s", session_name])
.status()
.context("Failed to create session")?;
if !status.success() {
anyhow::bail!("tmux failed to create session {session_name}");
}
Ok(())
}
pub fn create_window(session_name: &str, window_name: &str) -> Result<()> {
let status = Command::new("tmux")
.args(["new-window", "-t", session_name, "-n", window_name])
.status()
.context("Failed to create window")?;
if !status.success() {
anyhow::bail!("tmux failed to create window {window_name} in {session_name}");
}
Ok(())
}
pub fn rename_session(session_name: &str, new_name: &str) -> Result<()> {
Command::new("tmux")
.arg("rename-session")
.args(["-t", session_name])
.arg(new_name)
.status()
.context("Failed to rename session")?;
Ok(())
}
pub fn rename_window(session_name: &str, window_index: &str, new_name: &str) -> Result<()> {
let window_target = format!("{session_name}:{window_index}");
let status = Command::new("tmux")
.arg("rename-window")
.args(["-t", &window_target])
.arg(new_name)
.status()
.context("Failed to rename window")?;
if !status.success() {
anyhow::bail!("tmux failed to rename window {window_target}");
}
Ok(())
}
pub fn close_session(session_name: &str) -> Result<()> {
Command::new("tmux")
.arg("kill-session")
.args(["-t", session_name])
.status()
.context("Failed to kill session")?;
Ok(())
}
pub fn close_window(session_name: &str, window_index: &str) -> Result<()> {
let window_target = format!("{session_name}:{window_index}");
let status = Command::new("tmux")
.arg("kill-window")
.args(["-t", &window_target])
.status()
.context("Failed to kill window")?;
if !status.success() {
anyhow::bail!("tmux failed to kill window {window_target}");
}
Ok(())
}
pub fn get_session_name() -> Result<String> {
let output = Command::new("tmux")
.arg("display-message")
.arg("-p")
.args(["-F", "#{session_name}"])
.output()
.context("Failed to execute 'tmux display-message'")?;
let string_output = String::from_utf8(output.stdout)
.context("Failed to convert tmux output to UTF-8 string")?;
Ok(string_output.trim().to_string())
}
pub fn list_active_sessions() -> Result<Vec<String>> {
let output = Command::new("tmux")
.arg("list-sessions")
.args(["-F", "#{session_name}"])
.output()
.context("Failed to get active sessions")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("no server running") {
return Ok(Vec::new());
}
anyhow::bail!("tmux list-sessions failed: {stderr}");
}
let string_output = String::from_utf8(output.stdout)
.context("Failed to convert tmux output to UTF-8 string")?;
let parts: Vec<String> = string_output
.split(TMUX_LINE_SEPARATOR)
.filter(|line| !line.trim().is_empty())
.map(|s| s.trim().to_string())
.collect();
Ok(parts)
}
fn get_session_path(session_name: &str) -> Result<String> {
let output = Command::new("tmux")
.arg("display-message")
.arg("-p")
.args(["-t", session_name])
.args(["-F", "#{session_path}"])
.output()
.context("Failed to execute 'tmux display-message'")?;
let string_output = String::from_utf8(output.stdout)
.context("Failed to convert tmux output to UTF-8 string")?;
Ok(string_output.trim().to_string())
}
fn get_windows(session_name: &str) -> Result<Vec<Window>> {
let output = Command::new("tmux")
.arg("list-windows")
.args(["-t", session_name])
.args([
"-F",
"#{window_index}\x1f#{window_name}\x1f#{window_layout}",
])
.output()
.context("Failed to execute 'tmux list-windows'")?;
let string_output = String::from_utf8(output.stdout)
.context("Failed to convert tmux output to UTF-8 string")?;
string_output
.split(TMUX_LINE_SEPARATOR)
.filter(|window| !window.trim().is_empty())
.map(|window| parse_window_string(window, session_name))
.collect()
}
fn parse_window_string(window: &str, session_name: &str) -> Result<Window> {
let mut parts = window.splitn(3, TMUX_FIELD_SEPARATOR);
match (parts.next(), parts.next(), parts.next()) {
(Some(index), Some(name), Some(layout)) => {
let index = index.to_string();
let window_target = format!("{session_name}:{index}");
let panes = get_panes(&window_target)?;
Ok(Window {
index,
name: name.to_string(),
layout: layout.to_string(),
panes,
})
}
_ => {
anyhow::bail!("Failed to parse window string: {window}")
}
}
}
fn get_panes(window_target: &str) -> Result<Vec<Pane>> {
let output = Command::new("tmux")
.arg("list-panes")
.args(["-t", window_target])
.args(["-F", "#{pane_index}\x1f#{pane_pid}\x1f#{pane_current_path}"])
.output()
.with_context(|| {
format!("Failed to execute 'tmux list-panes' for window {window_target}",)
})?;
let string_output = String::from_utf8(output.stdout)
.context("Failed to convert tmux output to UTF-8 string")?;
string_output
.split(TMUX_LINE_SEPARATOR)
.filter(|pane| !pane.trim().is_empty())
.map(parse_pane_string)
.collect()
}
fn parse_pane_string(pane: &str) -> Result<Pane> {
let mut parts = pane.splitn(3, TMUX_FIELD_SEPARATOR);
match (parts.next(), parts.next(), parts.next()) {
(Some(index), Some(pid), Some(work_dir_str)) => {
let process = get_foreground_process(pid)?;
let current_command = match process {
Some((cmd_pid, cmdline)) if process::id() != cmd_pid => Some(cmdline),
_ => None,
};
Ok(Pane {
index: index.to_string(),
current_command,
work_dir: work_dir_str.to_string(),
})
}
_ => anyhow::bail!("Failed to parse pane string: {pane}"),
}
}
fn get_foreground_process(shell_pid: &str) -> Result<Option<(u32, String)>> {
Ok(get_process_children(shell_pid)?.into_iter().next())
}
fn get_process_children(shell_pid: &str) -> Result<Vec<(u32, String)>> {
let output = Command::new("ps")
.args(["-o", "pid=,args="])
.args(["--ppid", shell_pid])
.output()
.with_context(|| format!("Failed to get children of process #{shell_pid}"))?;
let output_str = String::from_utf8(output.stdout)?;
let mut children = Vec::new();
for line in output_str.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some((pid_str, cmdline)) = trimmed.split_once(' ')
&& let Ok(pid) = pid_str.trim().parse::<u32>()
{
children.push((pid, cmdline.trim().to_string()));
}
}
Ok(children)
}
fn get_window_config_cmd(
temp_session_name: &str,
session: &Session,
window: &Window,
) -> Result<String> {
if window.panes.is_empty() {
anyhow::bail!("Cannot restore window '{}' without panes", window.name);
}
let window_target = format!("{}:{}", temp_session_name, window.index);
let mut cmd = String::new();
writeln!(
cmd,
"tmux rename-window -t {} {}",
window_target, window.name
)?;
for _ in window.panes.iter().skip(1) {
writeln!(
cmd,
"tmux split-window -d -t {} -c {}",
window_target,
escape(Cow::from(&session.work_dir))
)?;
}
writeln!(
cmd,
"tmux select-layout -t {} {}",
window_target,
escape(Cow::from(&window.layout))
)?;
for pane in &window.panes {
let pane_target = format!("{}.{}", window_target, pane.index);
if pane.work_dir != session.work_dir {
writeln!(
cmd,
"tmux send-keys -t {} {} C-m",
pane_target,
escape(format!("cd {}; clear", escape(Cow::from(&pane.work_dir))).into()),
)?;
}
if let Some(pane_cmd) = &pane.current_command {
writeln!(
cmd,
"tmux send-keys -t {} {} C-m",
pane_target,
escape(pane_cmd.into())
)?;
}
}
Ok(cmd)
}