wtui 0.1.1

A terminal UI and CLI for managing Git worktrees across repositories
use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::Path;

use crossterm::cursor::{Hide, Show};
use crossterm::execute;
use crossterm::terminal::{
    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;

pub type InteractiveTerminal = TerminalGuard<Box<dyn Write>>;

pub struct TerminalGuard<W: Write> {
    terminal: Terminal<CrosstermBackend<W>>,
    raw_enabled: bool,
    restored: bool,
}

impl InteractiveTerminal {
    pub fn open() -> io::Result<Self> {
        TerminalGuard::new(terminal_writer(), true)
    }
}

impl<W: Write> TerminalGuard<W> {
    pub fn new(writer: W, raw_enabled: bool) -> io::Result<Self> {
        if raw_enabled {
            enable_raw_mode()?;
        }
        let mut terminal = match Terminal::new(CrosstermBackend::new(writer)) {
            Ok(terminal) => terminal,
            Err(error) => {
                if raw_enabled {
                    let _ = disable_raw_mode();
                }
                return Err(error);
            }
        };
        if let Err(error) = execute!(terminal.backend_mut(), EnterAlternateScreen, Hide) {
            if raw_enabled {
                let _ = disable_raw_mode();
            }
            return Err(error);
        }
        Ok(Self {
            terminal,
            raw_enabled,
            restored: false,
        })
    }

    pub fn terminal_mut(&mut self) -> &mut Terminal<CrosstermBackend<W>> {
        &mut self.terminal
    }

    pub fn restore(&mut self) -> io::Result<()> {
        if self.restored {
            return Ok(());
        }
        let mut first_error = None;
        if let Err(error) = self.terminal.show_cursor() {
            first_error = Some(error);
        }
        if let Err(error) = execute!(self.terminal.backend_mut(), LeaveAlternateScreen, Show) {
            first_error.get_or_insert(error);
        }
        if self.raw_enabled
            && let Err(error) = disable_raw_mode()
        {
            first_error.get_or_insert(error);
        }
        if let Some(error) = first_error {
            Err(error)
        } else {
            self.restored = true;
            Ok(())
        }
    }
}

impl<W: Write> Drop for TerminalGuard<W> {
    fn drop(&mut self) {
        let _ = self.restore();
    }
}

type PanicHook = dyn Fn(&std::panic::PanicHookInfo<'_>) + Sync + Send + 'static;

pub struct PanicHookGuard {
    previous: Option<Box<PanicHook>>,
}

impl PanicHookGuard {
    pub fn install() -> Self {
        let previous = std::panic::take_hook();
        std::panic::set_hook(Box::new(|information| {
            emergency_restore();
            eprintln!("{information}");
        }));
        Self {
            previous: Some(previous),
        }
    }
}

impl Drop for PanicHookGuard {
    fn drop(&mut self) {
        if let Some(previous) = self.previous.take()
            && !std::thread::panicking()
        {
            std::panic::set_hook(previous);
        }
    }
}

pub fn emergency_restore() {
    let _ = disable_raw_mode();
    let mut writer = terminal_writer();
    let _ = execute!(writer, LeaveAlternateScreen, Show);
}

pub fn write_selection(mut writer: impl Write, selection: Option<&Path>) -> io::Result<()> {
    let Some(path) = selection else {
        return Ok(());
    };
    if !path.is_absolute() {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "selected worktree path is not absolute",
        ));
    }
    write_path(&mut writer, path)?;
    writer.write_all(b"\n")?;
    writer.flush()
}

fn terminal_writer() -> Box<dyn Write> {
    #[cfg(unix)]
    if let Ok(terminal) = OpenOptions::new().read(true).write(true).open("/dev/tty") {
        return Box::new(terminal);
    }
    Box::new(io::stderr())
}

#[cfg(unix)]
fn write_path(writer: &mut impl Write, path: &Path) -> io::Result<()> {
    use std::os::unix::ffi::OsStrExt;
    writer.write_all(path.as_os_str().as_bytes())
}

#[cfg(not(unix))]
fn write_path(writer: &mut impl Write, path: &Path) -> io::Result<()> {
    writer.write_all(path.to_string_lossy().as_bytes())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::sync::{Arc, Mutex};

    #[derive(Clone, Default)]
    struct SharedWriter(Arc<Mutex<Vec<u8>>>);

    impl Write for SharedWriter {
        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
            self.0.lock().unwrap().extend_from_slice(bytes);
            Ok(bytes.len())
        }

        fn flush(&mut self) -> io::Result<()> {
            Ok(())
        }
    }

    #[test]
    fn guard_restores_alternate_screen_and_cursor_on_drop() {
        let writer = SharedWriter::default();
        let captured = writer.0.clone();
        {
            let _guard = TerminalGuard::new(writer, false).unwrap();
        }
        let output = captured.lock().unwrap().clone();
        assert!(output.windows(8).any(|window| window == b"\x1b[?1049h"));
        assert!(output.windows(8).any(|window| window == b"\x1b[?1049l"));
        assert!(output.windows(6).any(|window| window == b"\x1b[?25h"));
    }

    #[test]
    fn selection_protocol_is_exact_and_cancellation_is_empty() {
        let mut output = Vec::new();
        write_selection(&mut output, Some(Path::new("/tmp/a path"))).unwrap();
        assert_eq!(output, b"/tmp/a path\n");
        output.clear();
        write_selection(&mut output, None).unwrap();
        assert!(output.is_empty());
        assert!(write_selection(Vec::new(), Some(Path::new("relative"))).is_err());
    }

    #[test]
    fn guard_restores_during_unwind() {
        let writer = SharedWriter::default();
        let captured = writer.0.clone();
        let result = std::panic::catch_unwind(|| {
            let _guard = TerminalGuard::new(writer, false).unwrap();
            panic!("simulated panic");
        });
        assert!(result.is_err());
        let output = captured.lock().unwrap().clone();
        assert!(output.windows(8).any(|window| window == b"\x1b[?1049l"));
        assert!(output.windows(6).any(|window| window == b"\x1b[?25h"));
    }
}