#[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>),
Inline(String),
Ask,
Error(String),
}
pub(crate) fn shell_single_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn substitute_argv(template: &str, dir: &str) -> Vec<String> {
template
.split_whitespace()
.map(|tok| tok.replace("{path}", dir))
.collect()
}
fn substitute_shell(template: &str, dir: &str, base: Option<&str>) -> String {
let mut s = template.replace("{path}", &shell_single_quote(dir));
if let Some(b) = base {
s = s.replace("{base}", &shell_single_quote(b));
}
s
}
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>,
in_tmux: bool,
) -> LaunchPlan {
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, dir)),
None if in_tmux => LaunchPlan::Spawn(vec![
"tmux".to_string(),
"split-window".to_string(),
"-c".to_string(),
dir.to_string(),
]),
None => LaunchPlan::Error("set a command or run gitpane inside tmux".to_string()),
},
Placement::Tmux(flags) => {
let shell = cmd.map(|c| substitute_shell(c, dir, base));
if in_tmux {
LaunchPlan::Spawn(build_tmux_argv(&flags, dir, shell.as_deref()))
} else if let Some(s) = shell {
LaunchPlan::Inline(s)
} else {
LaunchPlan::Error("run gitpane inside tmux for this placement".to_string())
}
}
Placement::Inline => match cmd {
Some(c) => LaunchPlan::Inline(substitute_shell(c, dir, base)),
None => LaunchPlan::Error("inline placement needs a command".to_string()),
},
Placement::Ask => {
if in_tmux {
LaunchPlan::Ask
} else if let Some(c) = cmd {
LaunchPlan::Inline(substitute_shell(c, dir, base))
} else {
LaunchPlan::Error("run gitpane inside tmux for this placement".to_string())
}
}
}
}
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
}
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(parts: &[&str]) -> LaunchPlan {
LaunchPlan::Spawn(parts.iter().map(|s| s.to_string()).collect())
}
#[test]
fn goto_placement_infers_tab_or_window() {
assert_eq!(
goto_placement("wezterm cli spawn -- tmux attach -t {session}"),
Some("new tab")
);
assert_eq!(
goto_placement("kitten @ launch --type=tab tmux attach -t {session}"),
Some("new tab")
);
assert_eq!(
goto_placement("open -na Ghostty --args -e tmux attach -t {session}"),
Some("new window")
);
assert_eq!(goto_placement("tmux switch-client -t {session}"), None);
}
#[test]
fn goto_argv_substitutes_session() {
assert_eq!(
build_goto_argv("tmux switch-client -t {session}", "fairtrail"),
vec!["tmux", "switch-client", "-t", "fairtrail"]
);
assert_eq!(
build_goto_argv("wezterm cli spawn -- tmux attach -t {session}", "ft-rec"),
vec![
"wezterm", "cli", "spawn", "--", "tmux", "attach", "-t", "ft-rec"
]
);
}
#[test]
fn parse_keywords_and_tmux_and_invalid() {
assert_eq!(parse_placement("command"), Ok(Placement::Command));
assert_eq!(parse_placement("inline"), Ok(Placement::Inline));
assert_eq!(parse_placement("ask"), Ok(Placement::Ask));
assert_eq!(
parse_placement("split-window -h -t agents"),
Ok(Placement::Tmux(
["split-window", "-h", "-t", "agents"]
.iter()
.map(|s| s.to_string())
.collect()
))
);
assert!(parse_placement("kill-server").is_err());
assert!(parse_placement("-t other").is_err());
assert!(parse_placement("split-window -h ; kill-server").is_err());
assert!(parse_placement("new-window;kill-server").is_err());
}
#[test]
fn command_mode_runs_detached_argv() {
assert_eq!(
plan(Some("cursor {path}"), "command", "/w t/app", None, false),
argv(&["cursor", "/w t/app"])
);
}
#[test]
fn command_mode_empty_opens_tmux_pane_in_tmux() {
assert_eq!(
plan(None, "command", "/app", None, true),
argv(&["tmux", "split-window", "-c", "/app"])
);
}
#[test]
fn command_mode_empty_without_tmux_errors() {
assert!(matches!(
plan(None, "command", "/app", None, false),
LaunchPlan::Error(_)
));
}
#[test]
fn tmux_placement_wraps_in_sh_c() {
assert_eq!(
plan(
Some("git diff {base}...HEAD"),
"new-window",
"/app",
Some("origin/main"),
true
),
argv(&[
"tmux",
"new-window",
"-c",
"/app",
"sh",
"-c",
"git diff 'origin/main'...HEAD"
])
);
}
#[test]
fn tmux_placement_passes_flags_through() {
assert_eq!(
plan(
Some("lazygit"),
"split-window -h -t agents",
"/app",
None,
true
),
argv(&[
"tmux",
"split-window",
"-h",
"-t",
"agents",
"-c",
"/app",
"sh",
"-c",
"lazygit"
])
);
}
#[test]
fn tmux_placement_without_tmux_falls_back_to_inline() {
assert_eq!(
plan(
Some("git diff {base}...HEAD | delta"),
"new-window",
"/app",
Some("main"),
false
),
LaunchPlan::Inline("git diff 'main'...HEAD | delta".to_string())
);
}
#[test]
fn base_with_metacharacters_is_quoted() {
assert_eq!(
plan(
Some("git diff {base}...HEAD"),
"inline",
"/app",
Some("a;rm -rf b"),
false
),
LaunchPlan::Inline("git diff 'a;rm -rf b'...HEAD".to_string())
);
}
#[test]
fn ask_is_a_picker_in_tmux_and_inline_without() {
assert_eq!(plan(Some("x"), "ask", "/app", None, true), LaunchPlan::Ask);
assert_eq!(
plan(Some("x"), "ask", "/app", None, false),
LaunchPlan::Inline("x".to_string())
);
}
#[test]
fn command_mode_expands_embedded_path_token() {
assert_eq!(
plan(
Some("wezterm cli spawn --cwd={path}"),
"command",
"/w t/x",
None,
false
),
argv(&["wezterm", "cli", "spawn", "--cwd=/w t/x"])
);
}
#[test]
fn command_mode_blank_command_opens_tmux_pane() {
assert_eq!(
plan(Some(" "), "command", "/repo", None, true),
argv(&["tmux", "split-window", "-c", "/repo"])
);
}
#[test]
fn shell_mode_quotes_path_token() {
assert_eq!(
plan(
Some("cd {path} && git diff"),
"inline",
"/w t/x",
None,
false
),
LaunchPlan::Inline("cd '/w t/x' && git diff".to_string())
);
}
#[test]
fn invalid_placement_is_an_error_plan() {
assert!(matches!(
plan(Some("x"), "frobnicate", "/app", None, true),
LaunchPlan::Error(_)
));
}
#[test]
fn parse_tmux_windows_skips_malformed_lines() {
let out = "@0\tmain:0 editor\n@1\t\nno-tab-here\n@2\twork:2 logs\n";
assert_eq!(
parse_tmux_windows(out),
vec![
("main:0 editor".to_string(), "@0".to_string()),
("@1".to_string(), "@1".to_string()), ("work:2 logs".to_string(), "@2".to_string()),
]
);
}
#[test]
fn placement_choices_use_space_free_window_id_target() {
let windows = vec![("my session:0 editor".to_string(), "@7".to_string())];
assert_eq!(
placement_choices(&windows),
vec![
("New window".to_string(), "new-window".to_string()),
(
"Right of my session:0 editor".to_string(),
"split-window -h -t @7".to_string()
),
(
"Below my session:0 editor".to_string(),
"split-window -v -t @7".to_string()
),
]
);
}
}