use std::{
ffi::OsStr,
path::{Path, PathBuf},
process::{Command, Stdio},
sync::atomic::{AtomicU64, Ordering},
time::Duration,
};
use gpui::{App, Context, Entity, IntoElement, Render, Window};
use gpui_ghostty::{Terminal, TerminalOptions};
use wait_timeout::ChildExt as _;
static NEXT_SOCKET_ID: AtomicU64 = AtomicU64::new(1);
const DEFAULT_REMOTE_TIMEOUT: Duration = Duration::from_secs(1);
pub struct NvimOptions {
pub project: PathBuf,
pub initial_file: PathBuf,
pub initial_line: Option<u64>,
pub executable: PathBuf,
pub remote_timeout: Duration,
}
impl NvimOptions {
pub fn new(project: impl Into<PathBuf>, initial_file: impl Into<PathBuf>) -> Self {
Self {
project: project.into(),
initial_file: initial_file.into(),
initial_line: None,
executable: std::env::var_os("GPUI_NVIM")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("nvim")),
remote_timeout: DEFAULT_REMOTE_TIMEOUT,
}
}
}
pub struct NvimEditor {
project: PathBuf,
path: PathBuf,
socket: PathBuf,
executable: PathBuf,
remote_timeout: Duration,
terminal: Entity<Terminal>,
}
impl NvimEditor {
pub fn spawn<T: 'static>(
options: NvimOptions,
window: &mut Window,
cx: &mut Context<T>,
) -> Result<Self, String> {
let socket = socket_path();
let command = nvim_command(
&options.executable,
&socket,
&options.initial_file,
options.initial_line,
);
let terminal = Terminal::spawn(
TerminalOptions::new(command, options.project.clone()),
window,
cx,
)?;
Ok(Self {
project: options.project,
path: options.initial_file,
socket,
executable: options.executable,
remote_timeout: options.remote_timeout,
terminal,
})
}
pub fn project(&self) -> &Path {
&self.project
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn is_alive(&self, cx: &App) -> bool {
self.terminal.read(cx).is_alive()
}
pub fn focus<T>(&mut self, window: &mut Window, cx: &mut Context<T>) {
self.terminal
.update(cx, |terminal, cx| terminal.focus(window, cx));
}
pub fn set_visible(&mut self, visible: bool, cx: &mut Context<Self>) {
self.terminal
.update(cx, |terminal, _| terminal.set_visible(visible));
}
pub fn open_file(&mut self, path: PathBuf, cx: &mut Context<Self>) -> Result<(), String> {
self.open_file_at_line(path, None, cx)
}
pub fn open_file_at_line(
&mut self,
path: PathBuf,
line: Option<u64>,
cx: &mut Context<Self>,
) -> Result<(), String> {
if !self.terminal.read(cx).is_alive() {
return Err("the embedded Neovim process has exited".to_owned());
}
self.run_remote("--remote", path.as_os_str())?;
if let Some(line) = line {
self.run_remote("--remote-expr", format!("cursor({line}, 1)"))?;
}
self.path = path;
Ok(())
}
fn run_remote(&self, operation: &str, argument: impl AsRef<OsStr>) -> Result<(), String> {
let mut remote = remote_command(
&self.executable,
&self.project,
&self.socket,
operation,
argument,
)
.spawn()
.map_err(|error| format!("contact embedded Neovim: {error}"))?;
let status = remote
.wait_timeout(self.remote_timeout)
.map_err(|error| format!("wait for embedded Neovim: {error}"))?;
let Some(status) = status else {
let _ = remote.kill();
let _ = remote.wait();
return Err(format!(
"Neovim did not respond within {:?}",
self.remote_timeout
));
};
if !status.success() {
return Err(format!("Neovim remote command exited with {status}"));
}
Ok(())
}
}
fn remote_command(
executable: &Path,
project: &Path,
socket: &Path,
operation: &str,
argument: impl AsRef<OsStr>,
) -> Command {
let mut command = Command::new(executable);
command
.current_dir(project)
.arg("--server")
.arg(socket)
.arg(operation)
.arg(argument)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
command
}
impl Render for NvimEditor {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
self.terminal.clone()
}
}
fn nvim_command(executable: &Path, socket: &Path, path: &Path, line: Option<u64>) -> String {
let line = line.map_or_else(String::new, |line| format!(" +{line}"));
format!(
"{} --listen {}{} -- {}",
shell_quote(executable),
shell_quote(socket),
line,
shell_quote(path)
)
}
fn shell_quote(path: &Path) -> String {
format!("'{}'", path.to_string_lossy().replace('\'', "'\\''"))
}
fn socket_path() -> PathBuf {
let id = NEXT_SOCKET_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("gpui-neovim-{}-{id}.sock", std::process::id()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_quotes_paths_at_the_ghostty_boundary() {
assert_eq!(
nvim_command(
Path::new("/tmp/my nvim"),
Path::new("/tmp/editor.sock"),
Path::new("/tmp/it's.rs"),
Some(42),
),
"'/tmp/my nvim' --listen '/tmp/editor.sock' +42 -- '/tmp/it'\\''s.rs'"
);
}
#[test]
fn remote_commands_keep_the_operation_and_argument_separate() {
let command = remote_command(
Path::new("/tmp/my nvim"),
Path::new("/tmp/project"),
Path::new("/tmp/editor.sock"),
"--remote-expr",
"cursor(42, 1)",
);
assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/project")));
assert_eq!(
command.get_args().collect::<Vec<_>>(),
[
"--server",
"/tmp/editor.sock",
"--remote-expr",
"cursor(42, 1)"
]
);
}
#[test]
fn sockets_are_unique_within_the_process() {
assert_ne!(socket_path(), socket_path());
}
}