tudiff 0.1.3

A high-performance terminal directory comparison tool written in Rust - bringing Beyond Compare's intuitive interface to the command line.
Documentation
use anyhow::Result;
use crossterm::event::{self, Event};
use ratatui::{backend::Backend, Terminal};
use std::path::Path;
use std::time::Duration;

use crate::app::App;
use crate::compare::{DirectoryComparison, FileStatus};
use crate::ui::draw_ui;

#[derive(Clone)]
pub struct TerminalState;

impl TerminalState {
    pub fn save() -> Result<Self> {
        Ok(Self)
    }

    pub fn restore(&self) -> Result<()> {
        crossterm::terminal::disable_raw_mode().ok();

        crossterm::execute!(std::io::stdout(), crossterm::cursor::Show)?;

        std::process::Command::new("tput")
            .arg("cnorm")
            .status()
            .ok();

        print!("\x1b[?25h");
        print!("\x1b[?12h");
        use std::io::Write;
        std::io::stdout().flush().ok();

        Ok(())
    }
}

pub struct TerminalManager {
    original_state: TerminalState,
}

impl TerminalManager {
    pub fn new() -> Result<Self> {
        let original_state = TerminalState::save()?;

        let restore_state = original_state.clone();
        std::panic::set_hook(Box::new(move |panic_info| {
            let _ = crossterm::terminal::disable_raw_mode();
            let _ = crossterm::execute!(
                std::io::stdout(),
                crossterm::terminal::LeaveAlternateScreen,
                crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
                crossterm::event::DisableMouseCapture
            );
            let _ = restore_state.restore();

            println!("{}", panic_info);
        }));

        crossterm::terminal::enable_raw_mode()?;
        let mut stdout = std::io::stdout();
        crossterm::execute!(
            stdout,
            crossterm::terminal::EnterAlternateScreen,
            crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
            crossterm::cursor::Hide,
            crossterm::event::EnableMouseCapture
        )?;

        print!("\x1b[?12l");
        use std::io::Write;
        stdout.flush()?;

        Ok(Self { original_state })
    }

    pub fn restore(self) -> Result<()> {
        crossterm::terminal::disable_raw_mode()?;
        crossterm::execute!(
            std::io::stdout(),
            crossterm::terminal::LeaveAlternateScreen,
            crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
            crossterm::event::DisableMouseCapture
        )?;

        self.original_state.restore()?;

        let _ = std::panic::take_hook();

        Ok(())
    }
}

pub fn run_tui(dir1: std::path::PathBuf, dir2: std::path::PathBuf) -> Result<()> {
    let comparison = DirectoryComparison::new(dir1, dir2)?;
    let _terminal_manager = TerminalManager::new()?;

    let backend = ratatui::backend::CrosstermBackend::new(std::io::stdout());
    let mut terminal = ratatui::Terminal::new(backend)?;

    terminal.clear()?;

    let result = run_app(&mut terminal, comparison);

    _terminal_manager.restore()?;
    ensure_cursor_visible();

    result
}

fn run_app<B: Backend>(terminal: &mut Terminal<B>, comparison: DirectoryComparison) -> Result<()> {
    let mut app = App::new(comparison);
    let mut need_redraw = true;

    loop {
        app.check_refresh_progress();

        if need_redraw {
            terminal.clear()?;
            need_redraw = false;
        }

        draw_ui(terminal, &mut app)?;

        if event::poll(Duration::from_millis(50))? {
            match event::read()? {
                Event::Key(key) => {
                    if app.handle_key_event(key)? {
                        return Ok(());
                    }

                    if let crossterm::event::KeyCode::Enter = key.code {
                        if key.kind == crossterm::event::KeyEventKind::Press {
                            need_redraw = true;
                        }
                    }
                }
                Event::Mouse(mouse) => {
                    app.handle_mouse_event(mouse);
                }
                _ => {}
            }
        }
    }
}

pub fn launch_external_editor(status: &FileStatus, left_path: &Path, right_path: &Path) -> Result<()> {
    let _ = crossterm::terminal::disable_raw_mode();
    let _ = crossterm::execute!(
        std::io::stdout(),
        crossterm::terminal::LeaveAlternateScreen
    );

    print!("\x1b[2J\x1b[H");
    use std::io::Write;
    let _ = std::io::stdout().flush();

    match status {
        FileStatus::LeftOnly => {
            if left_path.exists() {
                let editors = ["vim", "vi", "nano"];
                let mut success = false;

                for editor in &editors {
                    let result = std::process::Command::new(editor)
                        .arg(left_path)
                        .status();
                    if result.is_ok() {
                        success = true;
                        break;
                    }
                }

                if !success {
                    eprintln!("No editor found. Displaying file content with cat...\n");
                    let _ = std::process::Command::new("cat")
                        .arg(left_path)
                        .status();
                    eprintln!("\n\nPress Enter to continue...");
                    let _ = std::io::stdin().read_line(&mut String::new());
                }
            }
        }
        FileStatus::RightOnly => {
            if right_path.exists() {
                let editors = ["vim", "vi", "nano"];
                let mut success = false;

                for editor in &editors {
                    let result = std::process::Command::new(editor)
                        .arg(right_path)
                        .status();
                    if result.is_ok() {
                        success = true;
                        break;
                    }
                }

                if !success {
                    eprintln!("No editor found. Displaying file content with cat...\n");
                    let _ = std::process::Command::new("cat")
                        .arg(right_path)
                        .status();
                    eprintln!("\n\nPress Enter to continue...");
                    let _ = std::io::stdin().read_line(&mut String::new());
                }
            }
        }
        _ => {
            let mut success = false;

            let result = std::process::Command::new("vimdiff")
                .arg(left_path)
                .arg(right_path)
                .status();

            if result.is_ok() {
                success = true;
            } else {
                let result = std::process::Command::new("vim")
                    .arg("-d")
                    .arg(left_path)
                    .arg(right_path)
                    .status();

                if result.is_ok() {
                    success = true;
                }
            }

            if !success {
                eprintln!("No visual diff tool found. Using diff command...\n");
                let _ = std::process::Command::new("diff")
                    .arg("-u")
                    .arg("--color=always")
                    .arg(left_path)
                    .arg(right_path)
                    .status();
                eprintln!("\n\nPress Enter to continue...");
                let _ = std::io::stdin().read_line(&mut String::new());
            }
        }
    }

    std::thread::sleep(Duration::from_millis(200));

    if let Err(e) = crossterm::terminal::enable_raw_mode() {
        eprintln!("Failed to enable raw mode: {}", e);
    }
    if let Err(e) = crossterm::execute!(
        std::io::stdout(),
        crossterm::terminal::EnterAlternateScreen
    ) {
        eprintln!("Failed to enter alternate screen: {}", e);
    }

    if let Err(e) = crossterm::execute!(
        std::io::stdout(),
        crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
        crossterm::terminal::Clear(crossterm::terminal::ClearType::Purge),
        crossterm::cursor::MoveTo(0, 0),
        crossterm::cursor::Hide
    ) {
        eprintln!("Failed to clear terminal: {}", e);
    }

    print!("\x1b[?12l");
    let _ = std::io::stdout().flush();

    Ok(())
}

pub fn ensure_cursor_visible() {
    let _ = crossterm::execute!(std::io::stdout(), crossterm::cursor::Show);
    let _ = std::process::Command::new("tput").arg("cnorm").status();
    print!("\x1b[?25h");
    print!("\x1b[?12h");
    use std::io::Write;
    let _ = std::io::stdout().flush();
}

pub fn simple_compare(dir1: std::path::PathBuf, dir2: std::path::PathBuf) -> Result<()> {
    let comparison = DirectoryComparison::new(dir1, dir2)?;

    crossterm::execute!(std::io::stdout(), crossterm::cursor::Show).ok();

    println!("Directory Comparison Results:");
    println!("Left:  {}", comparison.left_dir.display());
    println!("Right: {}", comparison.right_dir.display());
    println!();

    fn print_tree(node: &crate::compare::FileNode, depth: usize) {
        let indent = "  ".repeat(depth);

        if node.name.is_empty() {
            println!("{}", indent);
        } else {
            let icon = if node.is_dir { "📁" } else { "📄" };
            let status_char = match node.status {
                FileStatus::Same => "=",
                FileStatus::Different => "",
                FileStatus::LeftOnly => "L",
                FileStatus::RightOnly => "R",
            };

            println!("{}{} {} [{}]", indent, icon, node.name, status_char);
        }

        if node.is_dir && !node.name.is_empty() {
            for child in &node.children {
                print_tree(child, depth + 1);
            }
        }
    }

    println!("Legend: [=] Same, [≠] Different, [L] Left only, [R] Right only");
    println!();

    println!("=== LEFT PANEL ===");
    print_tree(&comparison.left_tree, 0);
    println!();

    println!("=== RIGHT PANEL ===");
    print_tree(&comparison.right_tree, 0);

    Ok(())
}