tmuxido/src/session.rs
cinco euzebio a592c99375 🐛 fix: target tmux windows by name instead of numeric index
Removes base-index detection which was unreliable and defaulted to 0
when tmux's actual base-index was 1, causing "index in use" and
"can't find window" errors on session creation.
2026-03-01 03:17:28 -03:00

278 lines
8.3 KiB
Rust

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Window {
pub name: String,
#[serde(default)]
pub panes: Vec<String>,
#[serde(default)]
pub layout: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SessionConfig {
#[serde(default)]
pub windows: Vec<Window>,
}
impl SessionConfig {
pub fn load_from_project(project_path: &Path) -> Result<Option<Self>> {
let config_path = project_path.join(".tmuxido.toml");
if !config_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read session config: {}", config_path.display()))?;
let config: SessionConfig = toml::from_str(&content).with_context(|| {
format!("Failed to parse session config: {}", config_path.display())
})?;
Ok(Some(config))
}
}
pub struct TmuxSession {
pub(crate) session_name: String,
project_path: String,
}
impl TmuxSession {
pub fn new(project_path: &Path) -> Self {
let session_name = project_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project")
.replace('.', "_")
.replace(' ', "-");
Self {
session_name,
project_path: project_path.display().to_string(),
}
}
pub fn create(&self, config: &SessionConfig) -> Result<()> {
// Check if we're already inside a tmux session
let inside_tmux = std::env::var("TMUX").is_ok();
// Check if session already exists
let session_exists = Command::new("tmux")
.args(["has-session", "-t", &self.session_name])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if session_exists {
// Session exists, just switch to it
if inside_tmux {
Command::new("tmux")
.args(["switch-client", "-t", &self.session_name])
.status()
.context("Failed to switch to existing session")?;
} else {
Command::new("tmux")
.args(["attach-session", "-t", &self.session_name])
.status()
.context("Failed to attach to existing session")?;
}
return Ok(());
}
// Create new session
if config.windows.is_empty() {
// Create simple session with one window
self.create_simple_session()?;
} else {
// Create session with custom windows
self.create_custom_session(config)?;
}
// Attach or switch to the session
if inside_tmux {
Command::new("tmux")
.args(["switch-client", "-t", &self.session_name])
.status()
.context("Failed to switch to new session")?;
} else {
Command::new("tmux")
.args(["attach-session", "-t", &self.session_name])
.status()
.context("Failed to attach to new session")?;
}
Ok(())
}
fn create_simple_session(&self) -> Result<()> {
// Create a detached session with one window
Command::new("tmux")
.args([
"new-session",
"-d",
"-s",
&self.session_name,
"-c",
&self.project_path,
])
.status()
.context("Failed to create tmux session")?;
Ok(())
}
fn create_custom_session(&self, config: &SessionConfig) -> Result<()> {
// Create session with first window
let first_window = &config.windows[0];
Command::new("tmux")
.args([
"new-session",
"-d",
"-s",
&self.session_name,
"-n",
&first_window.name,
"-c",
&self.project_path,
])
.status()
.context("Failed to create tmux session")?;
let first_target = format!("{}:{}", self.session_name, first_window.name);
if !first_window.panes.is_empty() {
self.create_panes(&first_target, &first_window.panes)?;
}
if let Some(layout) = &first_window.layout {
self.apply_layout(&first_target, layout)?;
}
// Create additional windows, targeting by session name so tmux auto-assigns the index
for window in config.windows.iter().skip(1) {
Command::new("tmux")
.args([
"new-window",
"-t",
&self.session_name,
"-n",
&window.name,
"-c",
&self.project_path,
])
.status()
.with_context(|| format!("Failed to create window: {}", window.name))?;
let target = format!("{}:{}", self.session_name, window.name);
if !window.panes.is_empty() {
self.create_panes(&target, &window.panes)?;
}
if let Some(layout) = &window.layout {
self.apply_layout(&target, layout)?;
}
}
// Select the first window by name
Command::new("tmux")
.args(["select-window", "-t", &first_target])
.status()
.context("Failed to select first window")?;
Ok(())
}
fn create_panes(&self, window_target: &str, panes: &[String]) -> Result<()> {
for (pane_index, command) in panes.iter().enumerate() {
// First pane already exists (created with the window), skip split
if pane_index > 0 {
Command::new("tmux")
.args([
"split-window",
"-t",
window_target,
"-c",
&self.project_path,
])
.status()
.context("Failed to split pane")?;
}
if !command.is_empty() {
let pane_target = format!("{}.{}", window_target, pane_index);
Command::new("tmux")
.args(["send-keys", "-t", &pane_target, command, "Enter"])
.status()
.context("Failed to send keys to pane")?;
}
}
Ok(())
}
fn apply_layout(&self, window_target: &str, layout: &str) -> Result<()> {
Command::new("tmux")
.args(["select-layout", "-t", window_target, layout])
.status()
.with_context(|| format!("Failed to apply layout: {}", layout))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn should_replace_dots_with_underscores_in_session_name() {
let session = TmuxSession::new(Path::new("/home/user/my.project"));
assert_eq!(session.session_name, "my_project");
}
#[test]
fn should_replace_spaces_with_dashes_in_session_name() {
let session = TmuxSession::new(Path::new("/home/user/my project"));
assert_eq!(session.session_name, "my-project");
}
#[test]
fn should_use_project_fallback_when_path_has_no_filename() {
let session = TmuxSession::new(Path::new("/"));
assert_eq!(session.session_name, "project");
}
#[test]
fn should_parse_window_from_toml() {
let toml_str = r#"
[[windows]]
name = "editor"
panes = ["nvim ."]
"#;
let config: SessionConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.windows[0].name, "editor");
assert_eq!(config.windows[0].panes, vec!["nvim ."]);
}
#[test]
fn should_parse_session_config_with_layout() {
let toml_str = r#"
[[windows]]
name = "main"
layout = "tiled"
panes = ["vim", "bash"]
"#;
let config: SessionConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.windows[0].layout, Some("tiled".to_string()));
assert_eq!(config.windows[0].panes.len(), 2);
}
}