use crate::session::env::Multiplexer;
#[derive(Clone, Debug, PartialEq, Eq)]
enum Placement {
Command,
Tmux(Vec<String>),
Inline,
Ask,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum LaunchPlan {
Spawn(Vec<String>),
Herdr {
create: Vec<String>,
command: Option<String>,
},
Inline(String),
Ask,
Error(String),
}
pub(crate) fn shell_single_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn substitute_placeholders(template: &str, dir: &str, base: Option<&str>) -> String {
let mut output = String::new();
let mut cursor = 0;
for (offset, _) in template.match_indices('{') {
let replacement = if template[offset..].starts_with("{path}") {
Some(("{path}", dir))
} else if template[offset..].starts_with("{base}") {
base.map(|b| ("{base}", b))
} else {
None
};
if let Some((token, value)) = replacement {
output.push_str(&template[cursor..offset]);
output.push_str(value);
cursor = offset + token.len();
}
}
output.push_str(&template[cursor..]);
output
}
fn substitute_argv(template: &str, dir: &str, base: Option<&str>) -> Vec<String> {
template
.split_whitespace()
.map(|tok| substitute_placeholders(tok, dir, base))
.collect()
}
fn substitute_shell(template: &str, dir: &str, base: Option<&str>) -> String {
let base = base.map(shell_single_quote);
substitute_placeholders(template, &shell_single_quote(dir), base.as_deref())
}
fn parse_placement(s: &str) -> Result<Placement, String> {
let s = s.trim();
match s {
"command" => Ok(Placement::Command),
"inline" => Ok(Placement::Inline),
"ask" => Ok(Placement::Ask),
_ => {
let tokens: Vec<String> = s.split_whitespace().map(String::from).collect();
match tokens.first().map(String::as_str) {
Some("split-window") | Some("new-window") => {
if tokens.iter().any(|t| t.contains(';')) {
Err(format!(
"placement '{s}' may not contain ';' (a tmux command separator)"
))
} else {
Ok(Placement::Tmux(tokens))
}
}
_ => Err(format!(
"invalid placement '{s}'; use command, inline, ask, or split-window/new-window with flags"
)),
}
}
}
}
fn build_tmux_argv(flags: &[String], dir: &str, cmd: Option<&str>) -> Vec<String> {
let mut argv = vec!["tmux".to_string()];
argv.extend(flags.iter().cloned());
argv.push("-c".to_string());
argv.push(dir.to_string());
if let Some(c) = cmd {
argv.push("sh".to_string());
argv.push("-c".to_string());
argv.push(c.to_string());
}
argv
}
pub(crate) fn plan(
command: Option<&str>,
placement: &str,
dir: &str,
base: Option<&str>,
mux: Multiplexer,
) -> LaunchPlan {
plan_with_target(command, placement, dir, dir, base, mux)
}
pub(crate) fn plan_with_target(
command: Option<&str>,
placement: &str,
dir: &str,
target: &str,
base: Option<&str>,
mux: Multiplexer,
) -> LaunchPlan {
let dir = crate::repo_id::boundary_path(std::path::Path::new(dir));
let target = crate::repo_id::boundary_path(std::path::Path::new(target));
let dir = dir.to_string_lossy();
let target = target.to_string_lossy();
let (dir, target) = (dir.as_ref(), target.as_ref());
let placement = match parse_placement(placement) {
Ok(p) => p,
Err(e) => return LaunchPlan::Error(e),
};
let cmd = command.filter(|c| !c.trim().is_empty());
match placement {
Placement::Command => match cmd {
Some(c) => LaunchPlan::Spawn(substitute_argv(c, target, base)),
None => match mux {
Multiplexer::Tmux => LaunchPlan::Spawn(vec![
"tmux".to_string(),
"split-window".to_string(),
"-c".to_string(),
dir.to_string(),
]),
Multiplexer::Herdr => LaunchPlan::Herdr {
create: herdr_split_argv("right", dir, None),
command: None,
},
Multiplexer::None => {
LaunchPlan::Error("set a command or run gitpane inside tmux or herdr".into())
}
},
},
Placement::Tmux(flags) => {
let shell = cmd.map(|c| substitute_shell(c, target, base));
match mux {
Multiplexer::Tmux => {
LaunchPlan::Spawn(build_tmux_argv(&flags, dir, shell.as_deref()))
}
Multiplexer::Herdr => match herdr_create_argv(&flags, dir) {
Ok(create) => LaunchPlan::Herdr {
create,
command: shell,
},
Err(e) => LaunchPlan::Error(e),
},
Multiplexer::None => {
if let Some(s) = shell {
LaunchPlan::Inline(s)
} else {
LaunchPlan::Error(
"run gitpane inside tmux or herdr for this placement".into(),
)
}
}
}
}
Placement::Inline => match cmd {
Some(c) => LaunchPlan::Inline(substitute_shell(c, target, base)),
None => LaunchPlan::Error("inline placement needs a command".to_string()),
},
Placement::Ask => match mux {
Multiplexer::Tmux | Multiplexer::Herdr => LaunchPlan::Ask,
Multiplexer::None => {
if let Some(c) = cmd {
LaunchPlan::Inline(substitute_shell(c, target, base))
} else {
LaunchPlan::Error("run gitpane inside tmux or herdr for this placement".into())
}
}
},
}
}
pub(crate) fn parse_tmux_windows(output: &str) -> Vec<(String, String)> {
output
.lines()
.filter_map(|line| {
let (target, label) = line.split_once('\t')?;
let target = target.trim();
if target.is_empty() {
return None;
}
let label = label.trim();
let label = if label.is_empty() {
target.to_string()
} else {
label.to_string()
};
Some((label, target.to_string()))
})
.collect()
}
pub(crate) fn tmux_windows() -> Vec<(String, String)> {
let output = std::process::Command::new("tmux")
.args([
"list-windows",
"-a",
"-F",
"#{window_id}\t#{session_name}:#{window_index} #{window_name}",
])
.output();
match output {
Ok(o) if o.status.success() => parse_tmux_windows(&String::from_utf8_lossy(&o.stdout)),
_ => Vec::new(),
}
}
pub(crate) fn placement_choices(windows: &[(String, String)]) -> Vec<(String, String)> {
let mut out = vec![("New window".to_string(), "new-window".to_string())];
for (label, target) in windows {
out.push((
format!("Right of {label}"),
format!("split-window -h -t {target}"),
));
out.push((
format!("Below {label}"),
format!("split-window -v -t {target}"),
));
}
out
}
pub(crate) fn build_goto_argv(template: &str, session: &str) -> Vec<String> {
template
.split_whitespace()
.map(|tok| tok.replace("{session}", session))
.collect()
}
pub(crate) fn goto_placement(command: &str) -> Option<&'static str> {
if command.contains("cli spawn") || command.contains("--type=tab") || command.contains("new-tab") || command.contains("--tab")
{
Some("new tab")
} else if command.contains("new-window")
|| command.contains("-na ") || command.starts_with("ghostty ") || command.contains("create-window")
{
Some("new window")
} else {
None
}
}
fn herdr_split_argv(direction: &str, dir: &str, target: Option<&str>) -> Vec<String> {
let mut argv = vec!["herdr".to_string(), "pane".to_string(), "split".to_string()];
match target {
Some(t) => {
argv.push("--pane".to_string());
argv.push(t.to_string());
}
None => argv.push("--current".to_string()),
}
argv.push("--direction".to_string());
argv.push(direction.to_string());
argv.push("--cwd".to_string());
argv.push(dir.to_string());
argv.push("--no-focus".to_string());
argv.push("--right-click".to_string());
argv.push("pane".to_string());
argv
}
fn herdr_tab_argv(dir: &str) -> Vec<String> {
vec![
"herdr".to_string(),
"tab".to_string(),
"create".to_string(),
"--cwd".to_string(),
dir.to_string(),
"--no-focus".to_string(),
]
}
fn herdr_create_argv(flags: &[String], dir: &str) -> Result<Vec<String>, String> {
let mut rest = flags.iter();
let Some(head) = rest.next() else {
return Err("empty herdr placement".to_string());
};
match head.as_str() {
"split-window" => {
let mut direction = "right";
let mut target = None;
let mut extra = Vec::new();
while let Some(tok) = rest.next() {
match tok.as_str() {
"-h" => direction = "right",
"-v" => direction = "down",
"-t" => {
let t = rest.next().ok_or_else(|| {
"placement '-t' needs a target under herdr".to_string()
})?;
target = Some(t.clone());
}
other => extra.push(other.to_string()),
}
}
if !extra.is_empty() {
return Err(format!(
"placement flags {extra:?} are not supported under herdr (use -h, -v, -t <pane-id>)"
));
}
Ok(herdr_split_argv(direction, dir, target.as_deref()))
}
"new-window" if flags.len() == 1 => Ok(herdr_tab_argv(dir)),
"new-window" => Err("placement 'new-window' takes no flags under herdr".to_string()),
other => Err(format!("invalid placement '{other}' under herdr")),
}
}
pub(crate) fn herdr_placement_choices() -> Vec<(String, String)> {
vec![
("New tab".to_string(), "new-window".to_string()),
(
"Right of current pane".to_string(),
"split-window -h".to_string(),
),
(
"Below current pane".to_string(),
"split-window -v".to_string(),
),
]
}
pub(crate) fn parse_herdr_pane_id(output: &str) -> Option<String> {
#[derive(serde::Deserialize)]
struct PaneId {
#[serde(default)]
pane_id: Option<String>,
}
#[derive(serde::Deserialize)]
struct Payload {
#[serde(default)]
pane: Option<PaneId>,
#[serde(default)]
root_pane: Option<PaneId>,
}
#[derive(serde::Deserialize)]
struct Envelope {
#[serde(default)]
result: Option<Payload>,
}
let Ok(env) = serde_json::from_str::<Envelope>(output) else {
return None;
};
let result = env.result?;
if let Some(id) = result.pane.and_then(|p| p.pane_id) {
return Some(id);
}
result.root_pane?.pane_id
}
pub(crate) fn forward_right_click_in_herdr() {
let reachable = std::env::var_os("HERDR_ENV").is_some()
|| std::env::var_os("HERDR_PANE_ID").is_some()
|| std::env::var_os("HERDR_TAB_ID").is_some()
|| std::env::var_os("HERDR_WORKSPACE_ID").is_some();
if !reachable {
return;
}
let status = std::process::Command::new("herdr")
.args(["pane", "input", "--current", "--right-click", "pane"])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
if let Err(e) = status {
tracing::debug!("could not forward right-click to herdr: {e}");
}
}
#[cfg(test)]
mod tests;