use anyhow::{Context, Result};
use std::path::Path;
use crate::sessions::{PaneMoveOp, Tmux};
use crate::{frontmatter, sessions};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Split {
Horizontal,
Vertical,
}
impl Split {
fn tmux_flag(&self) -> &str {
match self {
Split::Horizontal => "-h",
Split::Vertical => "-v",
}
}
}
pub fn run(files: &[&Path], split: Split, pane: Option<&str>, window: Option<&str>) -> Result<()> {
run_with_tmux(files, split, pane, window, &Tmux::default_server())
}
pub fn run_with_tmux(files: &[&Path], split: Split, pane: Option<&str>, window: Option<&str>, tmux: &Tmux) -> Result<()> {
tracing::debug!(file_count = files.len(), split = ?split, window, "layout::run start");
if files.is_empty() {
anyhow::bail!("at least one file required");
}
if files.len() == 1 {
return crate::focus::run_with_tmux(files[0], pane, tmux);
}
let mut pane_files: Vec<(String, String)> = Vec::new(); for file in files {
if !file.exists() {
anyhow::bail!("file not found: {}", file.display());
}
let content = std::fs::read_to_string(file)
.with_context(|| format!("failed to read {}", file.display()))?;
let (_updated, session_id) = frontmatter::ensure_session(&content)?;
let pane = sessions::lookup(&session_id)?;
match pane {
Some(pane_id) if tmux.pane_alive(&pane_id) => {
pane_files.push((pane_id, file.display().to_string()));
}
Some(pane_id) => {
eprintln!(
"warning: pane {} is dead for {}, skipping",
pane_id,
file.display()
);
}
None => {
eprintln!(
"warning: no pane registered for {}, skipping",
file.display()
);
}
}
}
if let Some(win) = window {
let window_panes_list = tmux.list_window_panes(win).unwrap_or_default();
let window_pane_set: std::collections::HashSet<&str> =
window_panes_list.iter().map(|s| s.as_str()).collect();
let before = pane_files.len();
pane_files.retain(|(pane_id, _)| window_pane_set.contains(pane_id.as_str()));
if pane_files.len() < before {
eprintln!(
"Filtered {} panes outside window {}",
before - pane_files.len(),
win
);
}
}
if pane_files.len() < 2 {
if let Some(first_file) = files.first() {
let first_display = first_file.display().to_string();
for (pane_id, display) in &pane_files {
if *display == first_display {
tmux.select_pane(pane_id)?;
break;
}
}
}
return Ok(());
}
let mut seen = std::collections::HashSet::new();
pane_files.retain(|(pane_id, _)| seen.insert(pane_id.clone()));
if pane_files.len() < 2 {
anyhow::bail!("all files share the same pane — nothing to arrange");
}
let wanted: std::collections::HashSet<&str> =
pane_files.iter().map(|(id, _)| id.as_str()).collect();
let mut best_window = String::new();
let mut best_wanted = 0usize;
let mut best_total = 0usize;
let mut anchor_pane = pane_files[0].0.clone(); for (pane_id, _) in &pane_files {
let window = tmux.pane_window(pane_id)?;
let window_panes = tmux.list_window_panes(&window)?;
let wanted_count = window_panes
.iter()
.filter(|p| wanted.contains(p.as_str()))
.count();
let total = window_panes.len();
if wanted_count > best_wanted || (wanted_count == best_wanted && total > best_total) {
best_wanted = wanted_count;
best_total = total;
best_window = window;
anchor_pane = pane_id.clone();
}
}
let target_window = best_window;
let registry = sessions::load().unwrap_or_default();
let session_panes: std::collections::HashSet<String> =
registry.values().map(|e| e.pane.clone()).collect();
let window_panes = tmux.list_window_panes(&target_window)?;
for existing_pane in &window_panes {
if !wanted.contains(existing_pane.as_str())
&& session_panes.contains(existing_pane)
&& window_panes.len() > 1
{
if is_pane_busy(tmux, existing_pane) {
eprintln!("Skipped busy pane {} in window {}", existing_pane, target_window);
continue;
}
tmux.break_pane(existing_pane)?;
eprintln!("Broke out pane {} from window {}", existing_pane, target_window);
}
}
for (pane_id, file_display) in &pane_files {
let pane_window = tmux.pane_window(pane_id)?;
if pane_window == target_window {
continue;
}
PaneMoveOp::new(tmux, pane_id, &anchor_pane).join(split.tmux_flag())?;
eprintln!("Joined {} (pane {}) into window {}", file_display, pane_id, target_window);
}
let (focus_pane, _) = &pane_files[0];
tmux.select_pane(focus_pane)?;
eprintln!(
"Layout: {} panes arranged {}",
pane_files.len(),
match split {
Split::Horizontal => "side-by-side",
Split::Vertical => "stacked",
}
);
Ok(())
}
fn is_pane_busy(tmux: &Tmux, pane_id: &str) -> bool {
let output = tmux.cmd()
.args(["display-message", "-t", pane_id, "-p", "#{pane_pid}"])
.output();
let pid_str = match output {
Ok(ref o) if o.status.success() => {
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
_ => return false,
};
if pid_str.is_empty() {
return false;
}
if pid_is_agent_session(&pid_str) {
return true;
}
if let Ok(children) = std::process::Command::new("pgrep")
.args(["-P", &pid_str])
.output()
{
for child_pid in String::from_utf8_lossy(&children.stdout).lines() {
let child_pid = child_pid.trim();
if !child_pid.is_empty() && pid_is_agent_session(child_pid) {
return true;
}
}
}
false
}
fn pid_is_agent_session(pid: &str) -> bool {
let output = match std::process::Command::new("ps")
.args(["-p", pid, "-o", "command="])
.output()
{
Ok(o) if o.status.success() => o,
_ => return false,
};
let cmdline = String::from_utf8_lossy(&output.stdout);
cmdline.contains("agent-doc") || cmdline.contains("claude")
}