use std::process::{Command, Stdio};
use std::env;
use std::sync::Arc;
use std::time::{Duration, Instant};
use crate::error::TmuxFzfError;
const TMUX_ENV_VAR: &str = "TMUX";
const DEFAULT_WINDOWS: &str = "1";
const UNKNOWN_TIME: &str = "unknown";
#[derive(Debug, Clone, PartialEq)]
pub struct TmuxSession {
pub name: Arc<str>,
pub windows: Arc<str>,
#[allow(dead_code)]
pub created: Arc<str>,
#[allow(dead_code)]
pub raw_line: Arc<str>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TmuxWindow {
pub index: u32,
pub name: Arc<str>,
pub pane_count: u32,
pub is_active: bool,
}
impl TmuxWindow {
pub fn from_line(line: &str) -> Option<Self> {
let parts: Vec<&str> = line.splitn(4, ':').collect();
if parts.len() < 4 {
return None;
}
let index = parts[0].parse().ok()?;
let name: Arc<str> = parts[1].into();
let pane_count = parts[2].parse().ok()?;
let is_active = parts[3] == "1";
Some(Self {
index,
name,
pane_count,
is_active,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TmuxPane {
pub index: u32,
pub pane_id: Arc<str>,
pub command: Arc<str>,
pub is_active: bool,
pub current_path: Arc<str>,
}
impl TmuxPane {
pub fn from_line(line: &str) -> Option<Self> {
let parts: Vec<&str> = line.splitn(5, ':').collect();
if parts.len() < 5 {
return None;
}
let index = parts[0].parse().ok()?;
let pane_id: Arc<str> = parts[1].into();
let command: Arc<str> = parts[2].into();
let is_active = parts[3] == "1";
let current_path: Arc<str> = parts[4].into();
Some(Self {
index,
pane_id,
command,
is_active,
current_path,
})
}
}
impl TmuxSession {
pub fn from_line(line: &str) -> Self {
let mut parts = line.splitn(2, ':');
let name: Arc<str> = parts.next().unwrap_or("").into();
let info = parts.next().unwrap_or("");
let windows: Arc<str> = if let Some(windows_start) = info.find(" windows") {
info[..windows_start].trim().into()
} else if let Some(window_start) = info.find(" window") {
info[..window_start].trim().into()
} else if !info.trim().is_empty() {
info.trim().into()
} else {
DEFAULT_WINDOWS.into()
};
let created: Arc<str> = if let Some(pos) = info.find(" (created ") {
info[pos..].into()
} else {
UNKNOWN_TIME.into()
};
Self {
name,
windows,
created,
raw_line: line.into(),
}
}
pub fn is_attached(&self) -> bool {
self.raw_line.contains("(attached)")
}
}
pub struct TmuxClient {
session_cache: Option<(Vec<TmuxSession>, Instant)>,
}
impl TmuxClient {
const CACHE_DURATION: Duration = Duration::from_millis(500);
pub fn new() -> Self {
Self {
session_cache: None,
}
}
pub fn list_sessions(&mut self) -> Result<Vec<TmuxSession>, TmuxFzfError> {
if let Some((ref cached_sessions, cached_time)) = self.session_cache {
if cached_time.elapsed() < Self::CACHE_DURATION {
return Ok(cached_sessions.clone());
}
}
let output = Command::new("tmux")
.arg("list-sessions")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| TmuxFzfError::TmuxError(format!("Failed to execute tmux command: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("no sessions") || stderr.contains("no server running") {
return Err(TmuxFzfError::NoSessions);
}
return Err(TmuxFzfError::TmuxError(format!("tmux list-sessions failed: {}", stderr)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let sessions: Vec<TmuxSession> = stdout
.lines()
.filter(|line| !line.is_empty())
.map(TmuxSession::from_line)
.collect();
self.session_cache = Some((sessions.clone(), Instant::now()));
Ok(sessions)
}
pub fn attach_session(&self, session_name: &str) -> Result<(), TmuxFzfError> {
self.attach_target(session_name)
}
pub fn attach_window(&self, session_name: &str, window_index: u32) -> Result<(), TmuxFzfError> {
let target = format!("{}:{}", session_name, window_index);
self.attach_target(&target)
}
pub fn attach_pane(&self, session_name: &str, window_index: u32, pane_index: u32) -> Result<(), TmuxFzfError> {
let target = format!("{}:{}.{}", session_name, window_index, pane_index);
self.attach_target(&target)
}
pub fn attach_target(&self, target: &str) -> Result<(), TmuxFzfError> {
let (command, action) = if self.is_inside_tmux() {
("switch-client", "switch to")
} else {
("attach-session", "attach to")
};
let status = Command::new("tmux")
.arg(command)
.arg("-t")
.arg(target)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.map_err(|e| TmuxFzfError::TmuxError(format!("Failed to execute tmux {}: {}", command, e)))?;
if status.success() {
Ok(())
} else {
let error_msg = match status.code() {
Some(code) => {
format!("Failed to {} target '{}' (exit code: {})", action, target, code)
},
None => {
format!("tmux process was terminated while trying to {} target '{}'", action, target)
}
};
Err(TmuxFzfError::TmuxError(error_msg))
}
}
pub fn kill_session(&mut self, session_name: &str) -> Result<(), TmuxFzfError> {
let status = Command::new("tmux")
.arg("kill-session")
.arg("-t")
.arg(session_name)
.status()
.map_err(|e| TmuxFzfError::TmuxError(format!("Failed to execute tmux kill-session: {}", e)))?;
if status.success() {
self.session_cache = None;
Ok(())
} else {
Err(TmuxFzfError::TmuxError(format!(
"Failed to kill session '{}' (exit code: {})",
session_name, status.code().unwrap_or(-1)
)))
}
}
pub fn kill_window(&mut self, session_name: &str, window_index: u32) -> Result<(), TmuxFzfError> {
let target = format!("{}:{}", session_name, window_index);
let status = Command::new("tmux")
.arg("kill-window")
.arg("-t")
.arg(&target)
.status()
.map_err(|e| TmuxFzfError::TmuxError(format!("Failed to execute tmux kill-window: {}", e)))?;
if status.success() {
self.session_cache = None;
Ok(())
} else {
Err(TmuxFzfError::TmuxError(format!(
"Failed to kill window '{}' (exit code: {})",
target, status.code().unwrap_or(-1)
)))
}
}
pub fn kill_pane(&mut self, session_name: &str, window_index: u32, pane_index: u32) -> Result<(), TmuxFzfError> {
let target = format!("{}:{}.{}", session_name, window_index, pane_index);
let status = Command::new("tmux")
.arg("kill-pane")
.arg("-t")
.arg(&target)
.status()
.map_err(|e| TmuxFzfError::TmuxError(format!("Failed to execute tmux kill-pane: {}", e)))?;
if status.success() {
self.session_cache = None;
Ok(())
} else {
Err(TmuxFzfError::TmuxError(format!(
"Failed to kill pane '{}' (exit code: {})",
target, status.code().unwrap_or(-1)
)))
}
}
pub fn rename_session(&mut self, old_name: &str, new_name: &str) -> Result<(), TmuxFzfError> {
if new_name.is_empty() {
return Err(TmuxFzfError::InvalidSessionName("Name cannot be empty".to_string()));
}
if new_name.contains(':') {
return Err(TmuxFzfError::InvalidSessionName("Name cannot contain ':'".to_string()));
}
let status = Command::new("tmux")
.arg("rename-session")
.arg("-t")
.arg(old_name)
.arg(new_name)
.status()
.map_err(|e| TmuxFzfError::TmuxError(format!("Failed to execute tmux rename-session: {}", e)))?;
if status.success() {
self.session_cache = None;
Ok(())
} else {
Err(TmuxFzfError::TmuxError(format!(
"Failed to rename session '{}' to '{}' (exit code: {})",
old_name, new_name, status.code().unwrap_or(-1)
)))
}
}
pub fn rename_window(&mut self, session_name: &str, window_index: u32, new_name: &str) -> Result<(), TmuxFzfError> {
if new_name.is_empty() {
return Err(TmuxFzfError::InvalidSessionName("Name cannot be empty".to_string()));
}
let target = format!("{}:{}", session_name, window_index);
let status = Command::new("tmux")
.arg("rename-window")
.arg("-t")
.arg(&target)
.arg(new_name)
.status()
.map_err(|e| TmuxFzfError::TmuxError(format!("Failed to execute tmux rename-window: {}", e)))?;
if status.success() {
self.session_cache = None;
Ok(())
} else {
Err(TmuxFzfError::TmuxError(format!(
"Failed to rename window '{}' to '{}' (exit code: {})",
target, new_name, status.code().unwrap_or(-1)
)))
}
}
pub fn new_session(&mut self, session_name: Option<&str>) -> Result<String, TmuxFzfError> {
self.new_session_with_attach(session_name, true)
}
fn new_session_with_attach(&mut self, session_name: Option<&str>, should_attach: bool) -> Result<String, TmuxFzfError> {
if let Some(name) = session_name {
if name.is_empty() {
return Err(TmuxFzfError::InvalidSessionName("Session name cannot be empty".to_string()));
}
if name.contains(':') {
return Err(TmuxFzfError::InvalidSessionName("Session name cannot contain ':'".to_string()));
}
}
let mut cmd = Command::new("tmux");
cmd.arg("new-session").arg("-d");
if let Some(name) = session_name {
cmd.arg("-s").arg(name);
}
if let Ok(current_dir) = std::env::current_dir() {
cmd.arg("-c").arg(current_dir);
}
let output = cmd
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| TmuxFzfError::TmuxError(format!("Failed to execute tmux new-session: {}", e)))?;
if output.status.success() {
let created_name = if let Some(name) = session_name {
name.to_string()
} else {
self.get_latest_session_name()?
};
if should_attach && !self.is_inside_tmux() {
self.attach_session(&created_name)?;
}
self.session_cache = None; Ok(created_name)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(TmuxFzfError::TmuxError(format!(
"Failed to create session: {}", stderr
)))
}
}
pub fn is_inside_tmux(&self) -> bool {
env::var(TMUX_ENV_VAR).is_ok()
}
fn get_latest_session_name(&mut self) -> Result<String, TmuxFzfError> {
let sessions = self.list_sessions()?;
sessions
.last()
.map(|s| s.name.as_ref().to_string())
.ok_or(TmuxFzfError::NoSessions)
}
#[allow(dead_code)]
pub fn has_sessions(&mut self) -> bool {
self.list_sessions().is_ok()
}
pub fn clear_cache(&mut self) {
self.session_cache = None;
}
pub fn capture_pane(&self, target: &str) -> Result<String, TmuxFzfError> {
let output = Command::new("tmux")
.arg("capture-pane")
.arg("-p")
.arg("-e") .arg("-t")
.arg(target)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| TmuxFzfError::TmuxError(format!("Failed to execute tmux capture-pane: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TmuxFzfError::TmuxError(format!(
"tmux capture-pane failed for '{}': {}", target, stderr
)));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn capture_pane_plain(&self, target: &str) -> Result<String, TmuxFzfError> {
let output = Command::new("tmux")
.arg("capture-pane")
.arg("-p")
.arg("-t")
.arg(target)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| TmuxFzfError::TmuxError(format!("Failed to execute tmux capture-pane: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TmuxFzfError::TmuxError(format!(
"tmux capture-pane failed for '{}': {}", target, stderr
)));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
pub fn get_active_pane_target(&self, session_name: &str) -> Result<String, TmuxFzfError> {
let output = Command::new("tmux")
.arg("display-message")
.arg("-t")
.arg(session_name)
.arg("-p")
.arg("#{pane_id}")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| TmuxFzfError::TmuxError(format!("Failed to execute tmux display-message: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TmuxFzfError::TmuxError(format!(
"tmux display-message failed for session '{}': {}", session_name, stderr
)));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn get_window_active_pane_target(&self, session_name: &str, window_index: u32) -> Result<String, TmuxFzfError> {
let target = format!("{}:{}", session_name, window_index);
let output = Command::new("tmux")
.arg("display-message")
.arg("-t")
.arg(&target)
.arg("-p")
.arg("#{pane_id}")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| TmuxFzfError::TmuxError(format!("Failed to execute tmux display-message: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TmuxFzfError::TmuxError(format!(
"tmux display-message failed for window '{}': {}", target, stderr
)));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn list_windows(&self, session_name: &str) -> Result<Vec<TmuxWindow>, TmuxFzfError> {
let output = Command::new("tmux")
.arg("list-windows")
.arg("-t")
.arg(session_name)
.arg("-F")
.arg("#{window_index}:#{window_name}:#{window_panes}:#{window_active}")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| TmuxFzfError::TmuxError(format!("Failed to execute tmux list-windows: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TmuxFzfError::TmuxError(format!(
"tmux list-windows failed for session '{}': {}", session_name, stderr
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let windows: Vec<TmuxWindow> = stdout
.lines()
.filter(|line| !line.is_empty())
.filter_map(TmuxWindow::from_line)
.collect();
Ok(windows)
}
pub fn list_panes(&self, session_name: &str, window_index: u32) -> Result<Vec<TmuxPane>, TmuxFzfError> {
let target = format!("{}:{}", session_name, window_index);
let output = Command::new("tmux")
.arg("list-panes")
.arg("-t")
.arg(&target)
.arg("-F")
.arg("#{pane_index}:#{pane_id}:#{pane_current_command}:#{pane_active}:#{pane_current_path}")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| TmuxFzfError::TmuxError(format!("Failed to execute tmux list-panes: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TmuxFzfError::TmuxError(format!(
"tmux list-panes failed for '{}': {}", target, stderr
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let panes: Vec<TmuxPane> = stdout
.lines()
.filter(|line| !line.is_empty())
.filter_map(TmuxPane::from_line)
.collect();
Ok(panes)
}
pub fn list_all_panes_info(&self) -> Result<Vec<PaneInfo>, TmuxFzfError> {
let output = Command::new("tmux")
.arg("list-panes")
.arg("-a")
.arg("-F")
.arg("#{pane_id}\t#{session_name}\t#{window_index}\t#{pane_current_command}")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| TmuxFzfError::TmuxError(format!("Failed to execute tmux list-panes -a: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(TmuxFzfError::TmuxError(format!(
"tmux list-panes -a failed: {}", stderr
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let panes = stdout
.lines()
.filter(|line| !line.is_empty())
.filter_map(PaneInfo::from_line)
.collect();
Ok(panes)
}
}
#[derive(Debug, Clone)]
pub struct PaneInfo {
pub pane_id: String,
pub session_name: String,
pub window_index: u32,
pub command: String,
}
impl PaneInfo {
fn from_line(line: &str) -> Option<Self> {
let parts: Vec<&str> = line.splitn(4, '\t').collect();
if parts.len() < 4 {
return None;
}
Some(Self {
pane_id: parts[0].to_string(),
session_name: parts[1].to_string(),
window_index: parts[2].parse().ok()?,
command: parts[3].to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tmux_window_from_line_valid() {
let line = "0:main:2:1";
let window = TmuxWindow::from_line(line).expect("Should parse valid window line");
assert_eq!(window.index, 0);
assert_eq!(window.name.as_ref(), "main");
assert_eq!(window.pane_count, 2);
assert!(window.is_active);
}
#[test]
fn test_tmux_window_from_line_inactive() {
let line = "1:editor:1:0";
let window = TmuxWindow::from_line(line).expect("Should parse valid window line");
assert_eq!(window.index, 1);
assert_eq!(window.name.as_ref(), "editor");
assert_eq!(window.pane_count, 1);
assert!(!window.is_active);
}
#[test]
fn test_tmux_window_from_line_special_name() {
let line = "2:server-main:3:0";
let window = TmuxWindow::from_line(line).expect("Should parse window with special name");
assert_eq!(window.index, 2);
assert_eq!(window.name.as_ref(), "server-main");
assert_eq!(window.pane_count, 3);
assert!(!window.is_active);
}
#[test]
fn test_tmux_window_from_line_invalid_too_few_parts() {
let line = "0:main:2";
assert!(TmuxWindow::from_line(line).is_none(), "Should fail with too few parts");
}
#[test]
fn test_tmux_window_from_line_invalid_index() {
let line = "abc:main:2:1";
assert!(TmuxWindow::from_line(line).is_none(), "Should fail with non-numeric index");
}
#[test]
fn test_tmux_window_from_line_invalid_pane_count() {
let line = "0:main:abc:1";
assert!(TmuxWindow::from_line(line).is_none(), "Should fail with non-numeric pane count");
}
#[test]
fn test_tmux_pane_from_line_valid() {
let line = "0:%0:bash:1:/home/user/projects";
let pane = TmuxPane::from_line(line).expect("Should parse valid pane line");
assert_eq!(pane.index, 0);
assert_eq!(pane.pane_id.as_ref(), "%0");
assert_eq!(pane.command.as_ref(), "bash");
assert!(pane.is_active);
assert_eq!(pane.current_path.as_ref(), "/home/user/projects");
}
#[test]
fn test_tmux_pane_from_line_inactive() {
let line = "1:%1:nvim:0:/tmp";
let pane = TmuxPane::from_line(line).expect("Should parse valid pane line");
assert_eq!(pane.index, 1);
assert_eq!(pane.pane_id.as_ref(), "%1");
assert_eq!(pane.command.as_ref(), "nvim");
assert!(!pane.is_active);
assert_eq!(pane.current_path.as_ref(), "/tmp");
}
#[test]
fn test_tmux_pane_from_line_with_special_command() {
let line = "0:%0:node-server:1:/var/www/app";
let pane = TmuxPane::from_line(line).expect("Should parse pane with special command");
assert_eq!(pane.index, 0);
assert_eq!(pane.pane_id.as_ref(), "%0");
assert_eq!(pane.command.as_ref(), "node-server");
assert!(pane.is_active);
assert_eq!(pane.current_path.as_ref(), "/var/www/app");
}
#[test]
fn test_tmux_pane_from_line_with_path_containing_colons() {
let line = "0:%0:bash:1:/home/user/path:with:colons";
let pane = TmuxPane::from_line(line).expect("Should parse pane with colons in path");
assert_eq!(pane.index, 0);
assert_eq!(pane.pane_id.as_ref(), "%0");
assert_eq!(pane.command.as_ref(), "bash");
assert!(pane.is_active);
assert_eq!(pane.current_path.as_ref(), "/home/user/path:with:colons");
}
#[test]
fn test_tmux_pane_from_line_invalid_too_few_parts() {
let line = "0:%0:bash:1";
assert!(TmuxPane::from_line(line).is_none(), "Should fail with too few parts (missing path)");
}
#[test]
fn test_tmux_pane_from_line_invalid_index() {
let line = "abc:%0:bash:1:/home";
assert!(TmuxPane::from_line(line).is_none(), "Should fail with non-numeric index");
}
#[test]
fn test_tmux_window_clone() {
let window = TmuxWindow {
index: 0,
name: "test".into(),
pane_count: 1,
is_active: true,
};
let cloned = window.clone();
assert_eq!(window, cloned);
}
#[test]
fn test_tmux_pane_clone() {
let pane = TmuxPane {
index: 0,
pane_id: "%0".into(),
command: "bash".into(),
is_active: true,
current_path: "/home/user".into(),
};
let cloned = pane.clone();
assert_eq!(pane, cloned);
}
#[test]
fn test_tmux_session_from_line() {
let line = "dev: 3 windows (created Mon Jan 27 10:00:00 2025)";
let session = TmuxSession::from_line(line);
assert_eq!(session.name.as_ref(), "dev");
assert_eq!(session.windows.as_ref(), "3");
assert!(!session.is_attached());
}
#[test]
fn test_tmux_session_attached() {
let line = "main: 2 windows (created Mon Jan 27 10:00:00 2025) (attached)";
let session = TmuxSession::from_line(line);
assert_eq!(session.name.as_ref(), "main");
assert!(session.is_attached());
}
#[test]
fn test_tmux_session_single_window() {
let line = "single: 1 window (created Mon Jan 27 10:00:00 2025)";
let session = TmuxSession::from_line(line);
assert_eq!(session.name.as_ref(), "single");
assert_eq!(session.windows.as_ref(), "1");
}
#[test]
fn test_tmux_client_new() {
let client = TmuxClient::new();
assert!(client.session_cache.is_none(), "New client should have no cache");
}
#[test]
fn test_tmux_client_clear_cache() {
let mut client = TmuxClient::new();
client.clear_cache();
assert!(client.session_cache.is_none(), "Cache should be cleared");
}
#[test]
fn test_capture_pane_invalid_target_returns_error() {
let client = TmuxClient::new();
let result = client.capture_pane("nonexistent-session-12345:0.0");
assert!(result.is_err(), "capture_pane with invalid target should return error");
}
#[test]
fn test_get_active_pane_target_returns_result() {
let client = TmuxClient::new();
let _result = client.get_active_pane_target("test-session");
}
#[test]
fn test_get_window_active_pane_target_returns_result() {
let client = TmuxClient::new();
let _result = client.get_window_active_pane_target("test-session", 0);
}
}