use super::*;
pub(super) struct HxPane {
pub(super) pane: String,
}
pub static MOUSE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
pub fn init_terminal() -> DefaultTerminal {
static HOOKED: std::sync::Once = std::sync::Once::new();
HOOKED.call_once(hook_panics);
use ratatui::crossterm::{execute, terminal};
terminal::enable_raw_mode().expect("raw mode");
_ = execute!(std::io::stdout(), terminal::EnterAlternateScreen);
let term = ratatui::Terminal::new(ratatui::backend::CrosstermBackend::new(std::io::stdout())).expect("a terminal");
if MOUSE.load(std::sync::atomic::Ordering::Relaxed) {
_ = ratatui::crossterm::execute!(std::io::stdout(), ratatui::crossterm::event::EnableMouseCapture);
}
term
}
fn hook_panics() {
let hook = std::panic::take_hook();
let log = crate::claude::dv_dir().map(|d| d.join("panic.log")).ok().filter(|p| p.parent().is_some_and(|d| std::fs::create_dir_all(d).is_ok()));
std::panic::set_hook(Box::new(move |info| match std::thread::current().name() {
Some("main") => {
restore_terminal();
hook(info)
}
_ => {
use std::io::Write;
let Some(path) = &log else { return };
if std::fs::metadata(path).is_ok_and(|m| m.len() > 1 << 20) {
_ = std::fs::rename(path, path.with_extension("log.old"));
}
let log = std::fs::OpenOptions::new().create(true).append(true).open(path);
_ = log.map(|mut f| f.write_all(format!("dv {}: {info}\n", std::process::id()).as_bytes()));
}
}));
}
pub fn restore_terminal() {
if MOUSE.load(std::sync::atomic::Ordering::Relaxed) {
_ = ratatui::crossterm::execute!(std::io::stdout(), ratatui::crossterm::event::DisableMouseCapture);
}
ratatui::restore();
}
pub(crate) fn in_zellij() -> bool {
std::env::var_os("ZELLIJ").is_some()
}
pub(super) fn zellij_run() -> Vec<&'static str> {
let floating = || {
let me = std::env::var("ZELLIJ_PANE_ID").ok()?.parse::<u64>().ok()?;
crate::pane::find(&crate::pane::list(&[])?, me)?["is_floating"].as_bool()
};
match floating() {
Some(true) => vec!["run"],
_ => vec!["run", "--direction", "down"],
}
}
pub(super) fn interactive(prog: &str, args: &[String], name: &str) -> std::io::Result<std::process::ExitStatus> {
if !in_zellij() {
return std::process::Command::new(prog).args(args).status();
}
let st = zellij_pane(prog, args, name, &std::env::current_dir()?, true).map(|(st, _)| st);
focus_dv();
st
}
pub(super) fn zellij_pane(prog: &str, args: &[String], name: &str, cwd: &Path, wait: bool) -> std::io::Result<(std::process::ExitStatus, Option<String>)> {
let out = std::process::Command::new("zellij")
.args(zellij_run())
.args(["--close-on-exit", "--name", name])
.args(wait.then_some("--block-until-exit"))
.arg("--cwd")
.arg(cwd)
.arg("--")
.arg(prog)
.args(args)
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()?;
if wait {
return Ok((out.status, None));
}
let said = String::from_utf8_lossy(&out.stdout);
let id = said.lines().map(str::trim).find(|l| l.starts_with("terminal_")).map(str::to_owned).or_else(|| {
let id = crate::pane::list(&[])?.iter().filter(|p| p["title"] == name && p["is_plugin"] != true).filter_map(|p| p["id"].as_u64()).max()?;
Some(format!("terminal_{id}"))
});
Ok((out.status, id))
}
pub(crate) fn focus_dv() {
if let Some(id) = std::env::var("ZELLIJ_PANE_ID").ok().filter(|_| in_zellij()) {
_ = crate::pane::action(&["focus-pane-id", &format!("terminal_{id}")]);
}
}
#[derive(Debug)]
pub(crate) struct Writing {
path: PathBuf,
done: PathBuf,
pid: PathBuf,
looked: std::time::Instant,
allow_empty: bool,
prefill: String,
untouched_ok: bool,
pub(super) kept: Option<PathBuf>,
}
impl std::fmt::Display for Writing {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("writing in the editor's pane")
}
}
impl std::error::Error for Writing {}
impl Writing {
pub(super) fn done(&mut self) -> Option<Option<String>> {
let status = match std::fs::read_to_string(&self.done) {
Ok(s) => s,
Err(_) if self.looked.elapsed() > std::time::Duration::from_secs(3) => {
self.looked = std::time::Instant::now();
let pid = std::fs::read_to_string(&self.pid).unwrap_or_default();
let alive = |p: &str| std::process::Command::new("kill").args(["-0", p.trim()]).stderr(std::process::Stdio::null()).status().is_ok_and(|s| s.success());
if pid.trim().is_empty() || alive(&pid) {
return None;
}
std::fs::read_to_string(&self.done).unwrap_or_default()
}
Err(_) => return None,
};
let text = std::fs::read_to_string(&self.path).unwrap_or_default();
_ = std::fs::remove_file(&self.done);
_ = std::fs::remove_file(&self.pid);
let closed = status.is_empty();
if closed && finish(&text, true, false, &self.prefill, false).is_some() {
self.kept = Some(unsent_path());
if self.kept.as_ref().is_some_and(|k| std::fs::rename(&self.path, k).is_ok()) {
return Some(None);
}
self.kept = None;
}
_ = std::fs::remove_file(&self.path);
Some(finish(&text, status.trim() == "0", self.allow_empty, &self.prefill, self.untouched_ok))
}
}
thread_local! {
static BESIDE: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
static GIVEN: std::cell::RefCell<Option<Option<String>>> = const { std::cell::RefCell::new(None) };
}
pub(super) fn writing_beside<T>(busy: bool, given: Option<Option<String>>, f: impl FnOnce() -> T) -> (T, Option<Option<String>>) {
BESIDE.set(in_zellij().then_some(busy));
GIVEN.set(given);
let out = f();
BESIDE.set(None);
(out, GIVEN.take())
}
pub(super) fn unsent_path() -> PathBuf {
temp_file("dv-unsent")
}
fn temp_file(prefix: &str) -> PathBuf {
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
std::env::temp_dir().join(format!("{prefix}-{}-{n}.md", std::process::id()))
}
fn finish(text: &str, ok: bool, allow_empty: bool, prefill: &str, untouched_ok: bool) -> Option<String> {
let body = clean_body(text, HINT, if untouched_ok { "" } else { prefill });
(ok && (allow_empty || !body.is_empty())).then_some(body)
}
pub(crate) fn compose_with(what: &str, allow_empty: bool, prefill: &str, untouched_ok: bool) -> Result<Option<String>> {
if let Some(given) = GIVEN.take() {
return Ok(given);
}
let beside = BESIDE.get();
if beside == Some(true) {
anyhow::bail!("you're writing in the editor's pane already: save and quit there first (:cq cancels)");
}
let path = temp_file("dv-comment");
let hint = if allow_empty { "leave empty for none; :cq to cancel" } else { "leave empty to cancel" };
std::fs::write(&path, format!("{prefill}\n{HINT} {what}. Save and quit when done; {hint}. -->\n"))?;
let editor = std::env::var("VISUAL").or_else(|_| std::env::var("EDITOR")).unwrap_or_else(|_| "hx".into());
let mut parts = editor.split_whitespace();
let prog = parts.next().unwrap_or("hx");
let line = prefill.lines().position(str::is_empty).unwrap_or(prefill.lines().count()) + 1;
let arg = match Path::new(prog).file_name().is_some_and(|n| n == "hx") {
true => format!("{}:{line}", path.display()),
false => path.display().to_string(),
};
let args: Vec<String> = parts.map(str::to_owned).chain([arg]).collect();
if beside.is_some() {
let (done, pid) = (path.with_extension("done"), path.with_extension("pid"));
_ = std::fs::remove_file(&done);
_ = std::fs::remove_file(&pid);
let q = |p: &Path| format!("'{}'", p.to_string_lossy().replace('\'', "'\\''"));
let script = format!("echo $$ > {pid}; \"$0\" \"$@\"; echo $? > {tmp} && mv {tmp} {done}", pid = q(&pid), tmp = q(&path.with_extension("done.tmp")), done = q(&done));
let sh: Vec<String> = ["-c".to_owned(), script, prog.to_owned()].into_iter().chain(args).collect();
let (st, _) = zellij_pane("sh", &sh, &format!("dv: {what}"), &std::env::current_dir()?, false)?;
anyhow::ensure!(st.success(), "zellij run failed ({st})");
let looked = std::time::Instant::now();
let prefill = prefill.to_owned();
return Err(Writing { path, done, pid, looked, allow_empty, prefill, untouched_ok, kept: None }.into());
}
let st = interactive(prog, &args, &format!("dv: {what}"))?;
let text = std::fs::read_to_string(&path).unwrap_or_default();
_ = std::fs::remove_file(&path);
Ok(finish(&text, st.success(), allow_empty, prefill, untouched_ok))
}
pub fn clipboard(t: &str) {
let mut out = std::io::stdout();
let _ = write!(out, "\x1b]52;c;{}\x07", base64(t.as_bytes()));
let _ = out.flush();
}
pub(super) fn base64(b: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut o = String::new();
for c in b.chunks(3) {
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
for i in 0..4 {
o.push(if i <= c.len() { T[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' });
}
}
o
}
#[test]
fn written_beside_then_done_again() {
let dir = std::env::temp_dir().join(format!("dv-beside-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (path, done) = (dir.join("c.md"), dir.join("c.done"));
std::fs::write(&path, format!("> quote\n\nmy reply\n{HINT} reply. Save and quit when done -->\n")).unwrap();
let looked = std::time::Instant::now();
let mut w = Writing { path: path.clone(), done: done.clone(), pid: path.with_extension("pid"), looked, allow_empty: false, prefill: "> quote".into(), untouched_ok: false, kept: None };
assert_eq!(w.done(), None); std::fs::write(&done, "0\n").unwrap();
let text = w.done().unwrap();
assert!(!path.exists() && text.as_deref() == Some("> quote\n\nmy reply"));
let (got, left) = writing_beside(false, Some(text.clone()), || compose_with("reply", false, "", false).unwrap());
assert_eq!((got, left), (text.clone(), None));
let (_, left) = writing_beside(false, Some(text.clone()), || ());
assert_eq!(left, Some(text));
std::fs::write(&path, "> quote\n\nhalf a reply\n").unwrap();
let mut gone = std::process::Command::new("true").spawn().unwrap();
gone.wait().unwrap();
std::fs::write(path.with_extension("pid"), gone.id().to_string()).unwrap();
w.looked = std::time::Instant::now() - std::time::Duration::from_secs(4);
assert_eq!(w.done(), Some(None));
let kept = w.kept.take().unwrap();
assert_eq!(std::fs::read_to_string(&kept).unwrap(), "> quote\n\nhalf a reply\n");
_ = std::fs::remove_file(kept);
std::fs::write(&path, "x\n").unwrap();
std::fs::write(&done, "1\n").unwrap();
assert_eq!(w.done(), Some(None));
std::fs::write(&path, "> quote\n").unwrap();
std::fs::write(&done, "0\n").unwrap();
assert_eq!(w.done(), Some(None));
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(test)]
pub(super) fn test_writing() -> Writing {
let path = temp_file("dv-test-writing");
let (done, looked) = (path.with_extension("done"), std::time::Instant::now());
let pid = path.with_extension("pid");
Writing { path, done, pid, looked, allow_empty: false, prefill: String::new(), untouched_ok: false, kept: None }
}