use std::path::{Path, PathBuf};
pub(crate) fn tmux_pane_sessions() -> Vec<(String, PathBuf)> {
let output = std::process::Command::new("tmux")
.args([
"list-panes",
"-a",
"-F",
"#{pane_current_path}\t#{session_name}",
])
.output();
match output {
Ok(o) if o.status.success() => parse_pane_sessions(&String::from_utf8_lossy(&o.stdout))
.into_iter()
.map(|(s, p)| {
let canon = p.canonicalize().unwrap_or(p);
(s, canon)
})
.collect(),
Ok(o) => {
rate_limited_debug(|| {
format!(
"tmux list-panes failed (status {:?}): {}",
o.status.code(),
String::from_utf8_lossy(&o.stderr).trim()
)
});
Vec::new()
}
Err(e) => {
rate_limited_debug(|| format!("tmux list-panes spawn failed: {e}"));
Vec::new()
}
}
}
fn rate_limited_debug(msg: impl FnOnce() -> String) {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static LAST: AtomicU64 = AtomicU64::new(0);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let last = LAST.load(Ordering::Relaxed);
if now.saturating_sub(last) >= 60
&& LAST
.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
tracing::debug!("{}", msg());
}
}
fn parse_pane_sessions(output: &str) -> Vec<(String, PathBuf)> {
output
.lines()
.filter_map(|line| {
let (path, session) = line.split_once('\t')?;
let path = path.trim();
let session = session.trim();
if session.is_empty() || path.is_empty() {
return None;
}
Some((session.to_string(), PathBuf::from(path)))
})
.collect()
}
pub(crate) fn herdr_live_panes() -> Vec<(String, PathBuf)> {
let output = std::process::Command::new("herdr")
.args(["pane", "list"])
.output();
match output {
Ok(o) if o.status.success() => {
match parse_herdr_panes(&String::from_utf8_lossy(&o.stdout)) {
Ok(panes) => panes
.into_iter()
.map(|(s, p)| {
let canon = p.canonicalize().unwrap_or(p);
(s, canon)
})
.collect(),
Err(e) => {
rate_limited_debug(|| format!("herdr pane list parse failed: {e}"));
Vec::new()
}
}
}
Ok(o) => {
rate_limited_debug(|| {
format!(
"herdr pane list failed (status {:?}): {}",
o.status.code(),
String::from_utf8_lossy(&o.stderr).trim()
)
});
Vec::new()
}
Err(e) => {
rate_limited_debug(|| format!("herdr pane list spawn failed: {e}"));
Vec::new()
}
}
}
fn parse_herdr_panes(output: &str) -> Result<Vec<(String, PathBuf)>, String> {
#[derive(serde::Deserialize)]
struct Pane {
#[serde(default)]
tab_id: Option<String>,
#[serde(default)]
cwd: Option<String>,
#[serde(default)]
foreground_cwd: Option<String>,
}
#[derive(serde::Deserialize)]
struct Panes {
panes: Vec<Pane>,
}
#[derive(serde::Deserialize)]
struct Envelope {
result: Panes,
}
let parsed: Envelope = serde_json::from_str(output).map_err(|e| e.to_string())?;
Ok(parsed
.result
.panes
.into_iter()
.filter_map(|p| {
let cwd = p
.foreground_cwd
.filter(|c| !c.is_empty())
.or_else(|| p.cwd.filter(|c| !c.is_empty()))?;
let tab = p.tab_id.filter(|t| !t.is_empty())?;
Some((tab, PathBuf::from(cwd)))
})
.collect())
}
pub(crate) fn live_sessions(path: &Path, panes: &[(String, PathBuf)]) -> Vec<String> {
let mut names: Vec<String> = panes
.iter()
.filter(|(_, p)| p.starts_with(path))
.map(|(s, _)| s.clone())
.collect();
names.sort();
names.dedup();
names
}
pub(crate) fn is_live(path: &Path, panes: &[(String, PathBuf)]) -> bool {
panes.iter().any(|(_, p)| p.starts_with(path))
}
#[cfg(test)]
mod tests {
use super::*;
fn panes(ps: &[(&str, &str)]) -> Vec<(String, PathBuf)> {
ps.iter()
.map(|(s, p)| (s.to_string(), PathBuf::from(p)))
.collect()
}
#[test]
fn parse_skips_malformed_and_empty() {
let out = "/code/app\tmain\nno-tab\n/code/x\t\n\twork\n/code/app/src\tside\n";
assert_eq!(
parse_pane_sessions(out),
vec![
("main".to_string(), PathBuf::from("/code/app")),
("side".to_string(), PathBuf::from("/code/app/src")),
]
);
}
#[test]
fn parse_keeps_path_intact_when_session_name_has_tab() {
assert_eq!(
parse_pane_sessions("/code/app\tweird\tname\n"),
vec![("weird\tname".to_string(), PathBuf::from("/code/app"))]
);
}
#[test]
fn live_sessions_are_prefix_matched_unique_and_sorted() {
let p = panes(&[
("ternu", "/code/app/src"),
("fairtrail", "/code/app"),
("ternu", "/code/app"), ("other", "/code/app-sibling"), ]);
assert_eq!(
live_sessions(Path::new("/code/app"), &p),
vec!["fairtrail".to_string(), "ternu".to_string()]
);
}
#[test]
fn live_sessions_empty_when_none_inside() {
let p = panes(&[("x", "/elsewhere")]);
assert!(live_sessions(Path::new("/code/app"), &p).is_empty());
}
#[test]
fn is_live_matches_a_pane_inside_the_path() {
let p = panes(&[("s", "/code/app/src")]);
assert!(is_live(Path::new("/code/app"), &p));
assert!(!is_live(Path::new("/code/app-sibling"), &p));
assert!(!is_live(Path::new("/code/app"), &[]));
}
#[test]
fn parse_herdr_panes_prefers_foreground_cwd_and_drops_null_tab() {
let out = "{\"result\":{\"panes\":[
{\"pane_id\":\"w1:p1\",\"tab_id\":\"w1:t1\",\"cwd\":\"/code/app\"},
{\"pane_id\":\"w1:p2\",\"tab_id\":\"w1:t1\",\"cwd\":\"/code/app\",
\"foreground_cwd\":\"/code/app/src\"},
{\"pane_id\":\"w1:p3\",\"tab_id\":\"w1:t2\",\"cwd\":\"/elsewhere\"},
{\"pane_id\":\"w1:p4\",\"tab_id\":null,\"cwd\":\"/code/app\"}
]}}";
assert_eq!(
parse_herdr_panes(out).unwrap(),
vec![
("w1:t1".to_string(), PathBuf::from("/code/app")),
("w1:t1".to_string(), PathBuf::from("/code/app/src")),
("w1:t2".to_string(), PathBuf::from("/elsewhere")),
]
);
}
#[test]
fn parse_herdr_panes_skips_non_json_and_empty() {
assert!(parse_herdr_panes("not json").is_err());
assert!(parse_herdr_panes("{}").is_err());
assert!(parse_herdr_panes("{\"result\":{}}").is_err());
assert!(
parse_herdr_panes("{\"result\":{\"panes\":[]}}")
.unwrap()
.is_empty()
);
let out = "{\"result\":{\"panes\":[{\"pane_id\":\"w1:p1\",\"cwd\":\"/code\"}]}}";
assert!(parse_herdr_panes(out).unwrap().is_empty());
}
#[test]
fn parse_herdr_panes_falls_back_from_empty_foreground_cwd() {
let out = "{\"result\":{\"panes\":[{\"pane_id\":\"w1:p1\",\"tab_id\":\"w1:t1\",
\"cwd\":\"/code/app\",\"foreground_cwd\":\"\"}]}}";
assert_eq!(
parse_herdr_panes(out).unwrap(),
vec![("w1:t1".to_string(), PathBuf::from("/code/app"))]
);
}
}