use std::fmt;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use serde_json::{Value, json};
pub const SOCKET_ENV: &str = "TERMINAL_BROWSER_TERMINAL_SOCKET";
const CALL_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Direction {
Right,
Left,
Up,
Down,
}
impl Direction {
pub fn parse(text: &str) -> Option<Direction> {
Some(match text.to_ascii_lowercase().as_str() {
"right" => Direction::Right,
"left" => Direction::Left,
"up" | "above" | "top" => Direction::Up,
"down" | "below" | "bottom" => Direction::Down,
_ => return None,
})
}
pub fn name(self) -> &'static str {
match self {
Direction::Right => "right",
Direction::Left => "left",
Direction::Up => "up",
Direction::Down => "down",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Pane {
pub id: String,
pub title: String,
pub focused: bool,
}
impl Pane {
pub fn new(id: impl Into<String>) -> Pane {
Pane {
id: id.into(),
title: String::new(),
focused: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Split {
pub direction: Direction,
pub command: Vec<String>,
pub size: Option<u8>,
}
impl Split {
pub fn new(direction: Direction, command: Vec<String>) -> Split {
Split {
direction,
command,
size: None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Method {
GetCurrentPane,
ListPanes,
Neighbor,
Split,
SendText,
PasteKey,
FocusPane,
}
impl Method {
pub const ALL: [Method; 7] = [
Method::GetCurrentPane,
Method::ListPanes,
Method::Neighbor,
Method::Split,
Method::SendText,
Method::PasteKey,
Method::FocusPane,
];
pub fn name(self) -> &'static str {
match self {
Method::GetCurrentPane => "getCurrentPane",
Method::ListPanes => "listPanes",
Method::Neighbor => "neighbor",
Method::Split => "split",
Method::SendText => "sendText",
Method::PasteKey => "pasteKey",
Method::FocusPane => "focusPane",
}
}
}
#[derive(Debug)]
pub enum HostError {
Unsupported(&'static str),
Refused(String),
Io(io::Error),
}
impl fmt::Display for HostError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HostError::Unsupported(method) => write!(f, "the terminal cannot {method}"),
HostError::Refused(why) => write!(f, "the terminal refused: {why}"),
HostError::Io(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for HostError {}
impl From<io::Error> for HostError {
fn from(error: io::Error) -> HostError {
HostError::Io(error)
}
}
pub type Result<T> = std::result::Result<T, HostError>;
pub trait TerminalHost {
fn name(&self) -> &str;
fn supports(&self, method: Method) -> bool;
fn current_pane(&self) -> Result<Pane>;
fn list_panes(&self) -> Result<Vec<Pane>>;
fn neighbor(&self, direction: Direction) -> Result<Option<Pane>>;
fn split(&self, split: &Split) -> Result<Pane>;
fn send_text(&self, pane: &Pane, text: &str) -> Result<()>;
fn focus_pane(&self, pane: &Pane) -> Result<()>;
}
pub fn detect() -> Option<Box<dyn TerminalHost>> {
detect_in(&|name| std::env::var(name).ok())
}
pub fn detect_in(var: &dyn Fn(&str) -> Option<String>) -> Option<Box<dyn TerminalHost>> {
let set = |name: &str| var(name).filter(|v| !v.is_empty());
#[cfg(unix)]
if let Some(path) = set(SOCKET_ENV)
&& let Ok(host) = SocketHost::connect(&path)
{
return Some(Box::new(host));
}
if set("TMUX").is_some() {
return Some(Box::new(Tmux::new(set("TMUX_PANE"))));
}
if set("ZELLIJ").is_some() {
return Some(Box::new(Zellij::new()));
}
if let Some(pane) = set("WEZTERM_PANE") {
return Some(Box::new(WezTerm::new(Some(pane))));
}
if let Some(window) = set("KITTY_WINDOW_ID") {
return Some(Box::new(Kitty::new(Some(window))));
}
None
}
pub fn shell_quote(words: &[String]) -> String {
words
.iter()
.map(|w| format!("'{}'", w.replace('\'', r"'\''")))
.collect::<Vec<_>>()
.join(" ")
}
fn run(program: &Path, args: &[String]) -> Result<String> {
let out = Command::new(program).args(args).output().map_err(|error| {
if error.kind() == io::ErrorKind::NotFound {
HostError::Io(io::Error::new(
error.kind(),
format!("{} not found", program.display()),
))
} else {
HostError::Io(error)
}
})?;
if !out.status.success() {
let said = String::from_utf8_lossy(&out.stderr);
let first = said.lines().next().unwrap_or("").trim();
return Err(HostError::Refused(if first.is_empty() {
format!("{} exited with {}", program.display(), out.status)
} else {
first.to_string()
}));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn strings(words: &[&str]) -> Vec<String> {
words.iter().map(|w| w.to_string()).collect()
}
#[cfg(unix)]
#[derive(Clone, Debug)]
pub struct SocketHost {
path: PathBuf,
name: String,
methods: Vec<String>,
}
#[cfg(unix)]
impl SocketHost {
pub fn connect(path: impl AsRef<Path>) -> Result<SocketHost> {
let path = path.as_ref().to_path_buf();
check_socket(&path)?;
let mut host = SocketHost {
path,
name: "terminal".into(),
methods: Vec::new(),
};
let hello = host.request("hello", json!({}))?;
let methods = hello
.get("methods")
.or_else(|| hello.pointer("/capabilities/methods"))
.and_then(Value::as_array)
.ok_or_else(|| HostError::Refused("hello listed no methods".into()))?;
host.methods = methods
.iter()
.filter_map(|m| m.as_str().map(str::to_string))
.collect();
if let Some(name) = hello
.get("name")
.or_else(|| hello.get("terminal"))
.and_then(Value::as_str)
{
host.name = name.to_string();
}
Ok(host)
}
pub fn methods(&self) -> &[String] {
&self.methods
}
fn request(&self, method: &str, params: Value) -> Result<Value> {
use std::os::unix::net::UnixStream;
let mut stream = UnixStream::connect(&self.path)?;
stream.set_read_timeout(Some(CALL_TIMEOUT))?;
stream.set_write_timeout(Some(CALL_TIMEOUT))?;
let mut line = json!({"id": 1, "method": method, "params": params}).to_string();
line.push('\n');
stream.write_all(line.as_bytes())?;
stream.flush()?;
let mut reply = String::new();
io::BufReader::new(stream.take_limited()).read_line(&mut reply)?;
parse_reply(&reply)
}
fn call(&self, method: Method, params: Value) -> Result<Value> {
if !self.supports(method) {
return Err(HostError::Unsupported(method.name()));
}
self.request(method.name(), params)
}
}
pub fn parse_reply(line: &str) -> Result<Value> {
let reply: Value = serde_json::from_str(line.trim())
.map_err(|e| HostError::Refused(format!("not a JSON reply: {e}")))?;
let error = reply.get("error").filter(|e| !e.is_null());
if let Some(error) = error {
let why = error
.as_str()
.map(str::to_string)
.or_else(|| {
error
.get("message")
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| error.to_string());
return Err(HostError::Refused(why));
}
if reply.get("ok") == Some(&Value::Bool(false)) {
return Err(HostError::Refused("the terminal said no".into()));
}
Ok(reply.get("result").cloned().unwrap_or(reply))
}
pub fn panes_of(result: &Value) -> Result<Vec<Pane>> {
let panes = result
.get("panes")
.unwrap_or(result)
.as_array()
.ok_or_else(|| HostError::Refused("no panes in the reply".into()))?;
Ok(panes.iter().filter_map(pane_of).collect())
}
fn pane_of(value: &Value) -> Option<Pane> {
let value = value.get("pane").unwrap_or(value);
let id = value.get("id").or_else(|| value.get("paneId"))?;
let id = match id {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
_ => return None,
};
Some(Pane {
id,
title: value
.get("title")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
focused: value
.get("focused")
.or_else(|| value.get("active"))
.and_then(Value::as_bool)
.unwrap_or(false),
})
}
#[cfg(unix)]
trait TakeLimited: Sized {
fn take_limited(self) -> io::Take<Self>;
}
#[cfg(unix)]
impl TakeLimited for std::os::unix::net::UnixStream {
fn take_limited(self) -> io::Take<Self> {
io::Read::take(self, 1 << 20)
}
}
#[cfg(unix)]
fn check_socket(path: &Path) -> Result<()> {
use std::os::unix::fs::{FileTypeExt, MetadataExt};
let meta = std::fs::metadata(path)?;
if !meta.file_type().is_socket() {
return Err(HostError::Refused(format!(
"{} is not a socket",
path.display()
)));
}
if Some(meta.uid()) != current_uid() {
return Err(HostError::Refused(format!(
"{} is another user's socket",
path.display()
)));
}
Ok(())
}
#[cfg(unix)]
#[allow(unsafe_code)] fn current_uid() -> Option<u32> {
Some(unsafe { libc::geteuid() })
}
#[cfg(unix)]
impl TerminalHost for SocketHost {
fn name(&self) -> &str {
&self.name
}
fn supports(&self, method: Method) -> bool {
self.methods.iter().any(|m| m == method.name())
}
fn current_pane(&self) -> Result<Pane> {
let reply = self.call(Method::GetCurrentPane, json!({}))?;
pane_of(&reply).ok_or_else(|| HostError::Refused("no pane in the reply".into()))
}
fn list_panes(&self) -> Result<Vec<Pane>> {
let reply = self.call(Method::ListPanes, json!({}))?;
panes_of(&reply)
}
fn neighbor(&self, direction: Direction) -> Result<Option<Pane>> {
let reply = self.call(Method::Neighbor, json!({"direction": direction.name()}))?;
Ok(pane_of(&reply))
}
fn split(&self, split: &Split) -> Result<Pane> {
let mut params = json!({"direction": split.direction.name()});
if !split.command.is_empty() {
params["command"] = json!(split.command);
}
if let Some(size) = split.size {
params["size"] = json!(size);
}
let reply = self.call(Method::Split, params)?;
pane_of(&reply).ok_or_else(|| HostError::Refused("no pane in the reply".into()))
}
fn send_text(&self, pane: &Pane, text: &str) -> Result<()> {
self.call(Method::SendText, json!({"paneId": pane.id, "text": text}))
.map(drop)
}
fn focus_pane(&self, pane: &Pane) -> Result<()> {
self.call(Method::FocusPane, json!({"paneId": pane.id}))
.map(drop)
}
}
#[derive(Clone, Debug)]
pub struct Tmux {
program: PathBuf,
socket: Option<PathBuf>,
pane: Option<String>,
}
impl Tmux {
pub fn new(pane: Option<String>) -> Tmux {
Tmux {
program: PathBuf::from("tmux"),
socket: None,
pane,
}
}
#[must_use]
pub fn socket(mut self, path: impl Into<PathBuf>) -> Tmux {
self.socket = Some(path.into());
self
}
fn args(&self, rest: &[String]) -> Vec<String> {
let mut args = Vec::new();
if let Some(socket) = &self.socket {
args.push("-S".into());
args.push(socket.display().to_string());
}
args.extend(rest.iter().cloned());
args
}
fn target(&self) -> Vec<String> {
match &self.pane {
Some(pane) => vec!["-t".into(), pane.clone()],
None => Vec::new(),
}
}
pub fn split_args(&self, split: &Split) -> Vec<String> {
let mut args = strings(&["split-window", "-P", "-F", "#{pane_id}"]);
args.push(match split.direction {
Direction::Right | Direction::Left => "-h".into(),
Direction::Up | Direction::Down => "-v".into(),
});
if matches!(split.direction, Direction::Left | Direction::Up) {
args.push("-b".into());
}
if let Some(size) = split.size {
args.push("-l".into());
args.push(format!("{size}%"));
}
args.extend(self.target());
if !split.command.is_empty() {
args.push("--".into());
args.push(shell_quote(&split.command));
}
args
}
fn tmux(&self, rest: &[String]) -> Result<String> {
run(&self.program, &self.args(rest))
}
fn pane_at(&self, target: &str) -> Result<Pane> {
let mut args = strings(&["display-message", "-p"]);
args.extend(["-t".into(), target.into()]);
args.push("#{pane_id}|#{pane_active}|#{pane_title}".into());
let out = self.tmux(&args)?;
tmux_pane(&out).ok_or_else(|| HostError::Refused(format!("no pane {target}")))
}
}
pub fn neighbor_of(
id: &str,
places: &[(String, [i64; 4])],
direction: Direction,
) -> Option<String> {
let (_, [x, y, w, h]) = places.iter().find(|(p, _)| p == id)?;
let overlap = |a: i64, al: i64, b: i64, bl: i64| (a + al).min(b + bl) - a.max(b);
places
.iter()
.filter(|(p, _)| p != id)
.filter_map(|(p, [px, py, pw, ph])| {
let (touches, shared) = match direction {
Direction::Right => (*px == x + w + 1, overlap(*y, *h, *py, *ph)),
Direction::Left => (px + pw + 1 == *x, overlap(*y, *h, *py, *ph)),
Direction::Down => (*py == y + h + 1, overlap(*x, *w, *px, *pw)),
Direction::Up => (py + ph + 1 == *y, overlap(*x, *w, *px, *pw)),
};
(touches && shared > 0).then_some((shared, p))
})
.max_by_key(|(shared, _)| *shared)
.map(|(_, p)| p.clone())
}
fn tmux_pane(line: &str) -> Option<Pane> {
let mut parts = line.splitn(3, '|');
let id = parts.next()?.trim();
if !id.starts_with('%') {
return None;
}
Some(Pane {
id: id.to_string(),
focused: parts.next() == Some("1"),
title: parts.next().unwrap_or("").to_string(),
})
}
impl TerminalHost for Tmux {
fn name(&self) -> &str {
"tmux"
}
fn supports(&self, method: Method) -> bool {
method != Method::PasteKey
}
fn current_pane(&self) -> Result<Pane> {
match &self.pane {
Some(pane) => self.pane_at(pane),
None => {
let mut args = strings(&["display-message", "-p"]);
args.push("#{pane_id}|#{pane_active}|#{pane_title}".into());
let out = self.tmux(&args)?;
tmux_pane(&out).ok_or_else(|| HostError::Refused("no current pane".into()))
}
}
}
fn list_panes(&self) -> Result<Vec<Pane>> {
let mut args = strings(&["list-panes"]);
args.extend(self.target());
args.extend(strings(&["-F", "#{pane_id}|#{pane_active}|#{pane_title}"]));
Ok(self.tmux(&args)?.lines().filter_map(tmux_pane).collect())
}
fn neighbor(&self, direction: Direction) -> Result<Option<Pane>> {
let current = self.current_pane()?;
let mut args = strings(&["list-panes"]);
args.extend(self.target());
args.extend(strings(&[
"-F",
"#{pane_id}|#{pane_left}|#{pane_top}|#{pane_width}|#{pane_height}",
]));
let places: Vec<(String, [i64; 4])> = self
.tmux(&args)?
.lines()
.filter_map(|line| {
let mut parts = line.split('|');
let id = parts.next()?.to_string();
let mut at = [0i64; 4];
for slot in &mut at {
*slot = parts.next()?.parse().ok()?;
}
Some((id, at))
})
.collect();
Ok(neighbor_of(¤t.id, &places, direction).map(Pane::new))
}
fn split(&self, split: &Split) -> Result<Pane> {
let id = self.tmux(&self.split_args(split))?;
Ok(Pane::new(id.lines().next().unwrap_or_default()))
}
fn send_text(&self, pane: &Pane, text: &str) -> Result<()> {
let mut args = strings(&["send-keys", "-l", "-t"]);
args.push(pane.id.clone());
args.push("--".into());
args.push(text.into());
self.tmux(&args).map(drop)
}
fn focus_pane(&self, pane: &Pane) -> Result<()> {
let mut args = strings(&["select-pane", "-t"]);
args.push(pane.id.clone());
self.tmux(&args).map(drop)
}
}
#[derive(Clone, Debug)]
pub struct WezTerm {
program: PathBuf,
pane: Option<String>,
}
impl WezTerm {
pub fn new(pane: Option<String>) -> WezTerm {
WezTerm {
program: PathBuf::from("wezterm"),
pane,
}
}
pub fn split_args(&self, split: &Split) -> Vec<String> {
let mut args = strings(&["cli", "split-pane"]);
args.push(
match split.direction {
Direction::Right => "--right",
Direction::Left => "--left",
Direction::Up => "--top",
Direction::Down => "--bottom",
}
.into(),
);
if let Some(size) = split.size {
args.push("--percent".into());
args.push(size.to_string());
}
if let Some(pane) = &self.pane {
args.push("--pane-id".into());
args.push(pane.clone());
}
if !split.command.is_empty() {
args.push("--".into());
args.extend(split.command.iter().cloned());
}
args
}
}
impl TerminalHost for WezTerm {
fn name(&self) -> &str {
"wezterm"
}
fn supports(&self, method: Method) -> bool {
matches!(
method,
Method::GetCurrentPane
| Method::ListPanes
| Method::Split
| Method::SendText
| Method::FocusPane
)
}
fn current_pane(&self) -> Result<Pane> {
self.pane
.clone()
.map(|id| Pane {
focused: true,
..Pane::new(id)
})
.ok_or(HostError::Unsupported(Method::GetCurrentPane.name()))
}
fn list_panes(&self) -> Result<Vec<Pane>> {
let out = run(
&self.program,
&strings(&["cli", "list", "--format", "json"]),
)?;
let list: Value = serde_json::from_str(&out)
.map_err(|e| HostError::Refused(format!("wezterm's list: {e}")))?;
Ok(list
.as_array()
.map(|panes| {
panes
.iter()
.filter_map(|p| {
Some(Pane {
id: p.get("pane_id")?.as_u64()?.to_string(),
title: p
.get("title")
.and_then(Value::as_str)
.unwrap_or_default()
.into(),
focused: p.get("is_active").and_then(Value::as_bool) == Some(true),
})
})
.collect()
})
.unwrap_or_default())
}
fn neighbor(&self, _: Direction) -> Result<Option<Pane>> {
Err(HostError::Unsupported(Method::Neighbor.name()))
}
fn split(&self, split: &Split) -> Result<Pane> {
Ok(Pane::new(run(&self.program, &self.split_args(split))?))
}
fn send_text(&self, pane: &Pane, text: &str) -> Result<()> {
let mut args = strings(&["cli", "send-text", "--pane-id"]);
args.push(pane.id.clone());
args.push("--".into());
args.push(text.into());
run(&self.program, &args).map(drop)
}
fn focus_pane(&self, pane: &Pane) -> Result<()> {
let mut args = strings(&["cli", "activate-pane", "--pane-id"]);
args.push(pane.id.clone());
run(&self.program, &args).map(drop)
}
}
#[derive(Clone, Debug)]
pub struct Kitty {
program: PathBuf,
window: Option<String>,
}
impl Kitty {
pub fn new(window: Option<String>) -> Kitty {
Kitty {
program: PathBuf::from("kitten"),
window,
}
}
pub fn split_args(&self, split: &Split) -> Option<Vec<String>> {
let location = match split.direction {
Direction::Right => "vsplit",
Direction::Down => "hsplit",
Direction::Left | Direction::Up => return None,
};
let mut args = strings(&["@", "launch", "--type=window", "--cwd=current"]);
args.push(format!("--location={location}"));
if let Some(size) = split.size {
args.push(format!("--bias={size}"));
}
if let Some(window) = &self.window {
args.push(format!("--match=id:{window}"));
}
if !split.command.is_empty() {
args.push("--".into());
args.extend(split.command.iter().cloned());
}
Some(args)
}
}
impl TerminalHost for Kitty {
fn name(&self) -> &str {
"kitty"
}
fn supports(&self, method: Method) -> bool {
matches!(
method,
Method::GetCurrentPane | Method::Split | Method::SendText | Method::FocusPane
)
}
fn current_pane(&self) -> Result<Pane> {
self.window
.clone()
.map(|id| Pane {
focused: true,
..Pane::new(id)
})
.ok_or(HostError::Unsupported(Method::GetCurrentPane.name()))
}
fn list_panes(&self) -> Result<Vec<Pane>> {
Err(HostError::Unsupported(Method::ListPanes.name()))
}
fn neighbor(&self, _: Direction) -> Result<Option<Pane>> {
Err(HostError::Unsupported(Method::Neighbor.name()))
}
fn split(&self, split: &Split) -> Result<Pane> {
let args = self
.split_args(split)
.ok_or(HostError::Unsupported("split left or up"))?;
Ok(Pane::new(run(&self.program, &args)?))
}
fn send_text(&self, pane: &Pane, text: &str) -> Result<()> {
let mut args = strings(&["@", "send-text"]);
args.push(format!("--match=id:{}", pane.id));
args.push("--".into());
args.push(text.into());
run(&self.program, &args).map(drop)
}
fn focus_pane(&self, pane: &Pane) -> Result<()> {
let mut args = strings(&["@", "focus-window"]);
args.push(format!("--match=id:{}", pane.id));
run(&self.program, &args).map(drop)
}
}
#[derive(Clone, Debug)]
pub struct Zellij {
program: PathBuf,
}
impl Zellij {
pub fn new() -> Zellij {
Zellij {
program: PathBuf::from("zellij"),
}
}
pub fn split_args(&self, split: &Split) -> Option<Vec<String>> {
let direction = match split.direction {
Direction::Right => "right",
Direction::Down => "down",
Direction::Left | Direction::Up => return None,
};
let mut args = strings(&["action", "new-pane", "--direction", direction]);
if !split.command.is_empty() {
args.push("--".into());
args.extend(split.command.iter().cloned());
}
Some(args)
}
}
impl Default for Zellij {
fn default() -> Zellij {
Zellij::new()
}
}
impl TerminalHost for Zellij {
fn name(&self) -> &str {
"zellij"
}
fn supports(&self, method: Method) -> bool {
method == Method::Split
}
fn current_pane(&self) -> Result<Pane> {
Err(HostError::Unsupported(Method::GetCurrentPane.name()))
}
fn list_panes(&self) -> Result<Vec<Pane>> {
Err(HostError::Unsupported(Method::ListPanes.name()))
}
fn neighbor(&self, _: Direction) -> Result<Option<Pane>> {
Err(HostError::Unsupported(Method::Neighbor.name()))
}
fn split(&self, split: &Split) -> Result<Pane> {
let args = self
.split_args(split)
.ok_or(HostError::Unsupported("split left or up"))?;
run(&self.program, &args)?;
Ok(Pane::new(""))
}
fn send_text(&self, _: &Pane, _: &str) -> Result<()> {
Err(HostError::Unsupported(Method::SendText.name()))
}
fn focus_pane(&self, _: &Pane) -> Result<()> {
Err(HostError::Unsupported(Method::FocusPane.name()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn split(direction: Direction) -> Split {
Split {
direction,
command: strings(&["noctiluca", "https://example.com/?a=1&b='2'"]),
size: Some(40),
}
}
#[test]
fn directions_parse_by_name() {
assert_eq!(Direction::parse("RIGHT"), Some(Direction::Right));
assert_eq!(Direction::parse("below"), Some(Direction::Down));
assert_eq!(Direction::parse("sideways"), None);
for d in [
Direction::Right,
Direction::Left,
Direction::Up,
Direction::Down,
] {
assert_eq!(Direction::parse(d.name()), Some(d));
}
}
#[test]
fn a_quoted_command_runs_as_its_words() {
let line = shell_quote(&strings(&["a b", "$(id)", "it's", ""]));
assert_eq!(line, r"'a b' '$(id)' 'it'\''s' ''");
let out = Command::new("sh")
.args(["-c", &format!("printf '%s|' {line}")])
.output();
if let Ok(out) = out {
assert_eq!(String::from_utf8_lossy(&out.stdout), "a b|$(id)|it's||");
}
}
#[test]
fn replies_and_refusals_are_read() {
assert_eq!(
parse_reply(r#"{"id":1,"result":{"methods":["split"]}}"#).unwrap(),
json!({"methods": ["split"]})
);
assert_eq!(
parse_reply(r#"{"methods":["split"]}"#).unwrap()["methods"][0],
"split"
);
let refused = |line: &str| match parse_reply(line) {
Err(HostError::Refused(why)) => why,
other => panic!("{other:?}"),
};
assert_eq!(
refused(r#"{"id":1,"error":{"message":"no such pane"}}"#),
"no such pane"
);
assert_eq!(refused(r#"{"error":"busy"}"#), "busy");
assert!(refused(r#"{"ok":false}"#).contains("said no"));
assert!(refused("not json").contains("not a JSON reply"));
assert!(parse_reply(r#"{"id":1,"result":null,"error":null}"#).is_ok());
}
#[test]
fn a_list_of_panes_skips_what_is_not_a_pane() {
let panes = panes_of(&json!({"panes": [{"id": "p1"}, 3, {"paneId": 7}]})).unwrap();
let ids: Vec<&str> = panes.iter().map(|p| p.id.as_str()).collect();
assert_eq!(ids, ["p1", "7"]);
assert_eq!(panes_of(&json!([{"id": "a"}])).unwrap().len(), 1);
assert!(panes_of(&json!({"panes": "none"})).is_err());
}
#[test]
fn panes_are_read_from_ids_strings_or_numbers() {
assert_eq!(
pane_of(&json!({"id": 3, "title": "agent", "focused": true})),
Some(Pane {
id: "3".into(),
title: "agent".into(),
focused: true
})
);
assert_eq!(
pane_of(&json!({"pane": {"paneId": "p1"}})).unwrap().id,
"p1"
);
assert_eq!(pane_of(&json!({"title": "no id"})), None);
assert_eq!(
tmux_pane("%3|1|vim"),
Some(Pane {
id: "%3".into(),
focused: true,
title: "vim".into()
})
);
assert_eq!(tmux_pane("no pane"), None);
}
#[test]
fn the_neighbor_is_the_pane_along_that_edge() {
let places = vec![
("%0".to_string(), [0, 0, 40, 24]),
("%1".to_string(), [41, 0, 39, 12]),
("%2".to_string(), [41, 13, 39, 11]),
];
assert_eq!(
neighbor_of("%0", &places, Direction::Right).as_deref(),
Some("%1")
);
assert_eq!(
neighbor_of("%2", &places, Direction::Left).as_deref(),
Some("%0")
);
assert_eq!(
neighbor_of("%1", &places, Direction::Down).as_deref(),
Some("%2")
);
assert_eq!(
neighbor_of("%2", &places, Direction::Up).as_deref(),
Some("%1")
);
assert_eq!(neighbor_of("%0", &places, Direction::Left), None);
assert_eq!(neighbor_of("%9", &places, Direction::Left), None);
}
#[test]
fn tmux_splits_with_its_flags_and_one_quoted_command() {
let tmux = Tmux::new(Some("%1".into()));
let args = tmux.split_args(&split(Direction::Left));
assert_eq!(
args,
[
"split-window",
"-P",
"-F",
"#{pane_id}",
"-h",
"-b",
"-l",
"40%",
"-t",
"%1",
"--",
r"'noctiluca' 'https://example.com/?a=1&b='\''2'\'''"
]
);
let down = tmux.split_args(&Split::new(Direction::Down, Vec::new()));
assert_eq!(
down,
["split-window", "-P", "-F", "#{pane_id}", "-v", "-t", "%1"]
);
let args = Tmux::new(None)
.socket("/tmp/s")
.args(&strings(&["list-panes"]));
assert_eq!(args, ["-S", "/tmp/s", "list-panes"]);
}
#[test]
fn the_adapters_pass_the_command_as_words() {
let wez = WezTerm::new(Some("7".into())).split_args(&split(Direction::Up));
assert_eq!(
wez,
[
"cli",
"split-pane",
"--top",
"--percent",
"40",
"--pane-id",
"7",
"--",
"noctiluca",
"https://example.com/?a=1&b='2'"
]
);
let kitty = Kitty::new(Some("2".into()));
assert_eq!(
kitty.split_args(&split(Direction::Right)).unwrap(),
[
"@",
"launch",
"--type=window",
"--cwd=current",
"--location=vsplit",
"--bias=40",
"--match=id:2",
"--",
"noctiluca",
"https://example.com/?a=1&b='2'"
]
);
assert!(kitty.split_args(&split(Direction::Left)).is_none());
let zellij = Zellij::new().split_args(&split(Direction::Down)).unwrap();
assert_eq!(
zellij,
[
"action",
"new-pane",
"--direction",
"down",
"--",
"noctiluca",
"https://example.com/?a=1&b='2'"
]
);
assert!(matches!(
Zellij::new().send_text(&Pane::new("1"), "x"),
Err(HostError::Unsupported("sendText"))
));
assert!(matches!(
Kitty::new(None).split(&split(Direction::Up)),
Err(HostError::Unsupported(_))
));
}
#[test]
fn the_environment_picks_the_host() {
let env = |pairs: &'static [(&'static str, &'static str)]| {
move |name: &str| {
pairs
.iter()
.find(|(n, _)| *n == name)
.map(|(_, v)| v.to_string())
}
};
let name = |pairs: &'static [(&'static str, &'static str)]| {
detect_in(&env(pairs)).map(|h| h.name().to_string())
};
assert_eq!(name(&[]), None);
assert_eq!(
name(&[("TMUX", "/tmp/tmux-1/default,1,0"), ("TMUX_PANE", "%0")]).as_deref(),
Some("tmux")
);
assert_eq!(name(&[("ZELLIJ", "0")]).as_deref(), Some("zellij"));
assert_eq!(name(&[("WEZTERM_PANE", "3")]).as_deref(), Some("wezterm"));
assert_eq!(name(&[("KITTY_WINDOW_ID", "1")]).as_deref(), Some("kitty"));
assert_eq!(
name(&[("WEZTERM_PANE", "3"), ("TMUX", "x"), ("TMUX_PANE", "%1")]).as_deref(),
Some("tmux")
);
assert_eq!(
name(&[(SOCKET_ENV, "/nonexistent/terminal.sock"), ("ZELLIJ", "0")]).as_deref(),
Some("zellij")
);
assert_eq!(name(&[("TMUX", "")]), None);
}
}