use std::io::{self, Stdout};
use std::panic;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crossterm::{
cursor,
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::prelude::*;
use thiserror::Error;
use super::app::{Action, App, AppMode};
use super::events::EventHandler;
use super::ui::render;
use crate::actions::delete::{delete_batch, validate_preserves_copy, DeleteConfig};
use crate::actions::preview::preview_file_simple;
const FRAME_DURATION: Duration = Duration::from_millis(16);
const POLL_TIMEOUT: Duration = Duration::from_millis(16);
#[derive(Debug, Error)]
pub enum TuiError {
#[error("terminal I/O error: {0}")]
Io(#[from] io::Error),
#[error("event error: {0}")]
Event(#[from] super::events::EventError),
#[error("interrupted by shutdown signal")]
Interrupted,
#[error("deletion error: {0}")]
DeleteError(String),
}
pub type TuiResult<T> = Result<T, TuiError>;
type Terminal = ratatui::Terminal<CrosstermBackend<Stdout>>;
pub fn run_tui(app: &mut App, shutdown_flag: Option<Arc<AtomicBool>>) -> TuiResult<()> {
let original_hook = panic::take_hook();
panic::set_hook(Box::new(move |panic_info| {
let _ = restore_terminal();
original_hook(panic_info);
}));
let result = run_tui_inner(app, shutdown_flag);
let _ = panic::take_hook();
result
}
fn run_tui_inner(app: &mut App, shutdown_flag: Option<Arc<AtomicBool>>) -> TuiResult<()> {
let mut terminal = setup_terminal()?;
let event_handler = EventHandler::new();
let mut last_render = Instant::now();
loop {
if let Some(ref flag) = shutdown_flag {
if flag.load(Ordering::SeqCst) {
log::info!("Shutdown signal received, exiting TUI");
break;
}
}
if app.should_quit() {
log::debug!("App requested quit");
break;
}
terminal.draw(|frame| render(frame, app))?;
if let Some(action) = event_handler.poll(POLL_TIMEOUT)? {
handle_action(app, action, &shutdown_flag)?;
}
let elapsed = last_render.elapsed();
if elapsed < FRAME_DURATION {
std::thread::sleep(FRAME_DURATION - elapsed);
}
last_render = Instant::now();
}
restore_terminal()?;
log::info!("TUI exited normally");
Ok(())
}
fn handle_action(
app: &mut App,
action: Action,
_shutdown_flag: &Option<Arc<AtomicBool>>,
) -> TuiResult<()> {
let was_handled = app.handle_action(action);
match action {
Action::Confirm => {
if app.mode() == AppMode::Confirming {
let result = perform_deletion(app);
match result {
Ok(deleted_count) => {
log::info!("Deleted {} files", deleted_count);
app.set_mode(AppMode::Reviewing);
}
Err(e) => {
app.set_error(&format!("Deletion failed: {}", e));
app.set_mode(AppMode::Reviewing);
}
}
}
}
Action::Preview => {
if app.mode() == AppMode::Previewing {
if let Some(path) = app.current_file() {
let content = preview_file_simple(path);
app.set_preview(content);
}
}
}
Action::Cancel => {
if app.error_message().is_some() {
app.clear_error();
}
}
_ => {
if !was_handled {
log::trace!("Action not handled: {:?}", action);
}
}
}
Ok(())
}
fn perform_deletion(app: &mut App) -> Result<usize, TuiError> {
let selected_files = app.selected_files_vec();
if selected_files.is_empty() {
return Ok(0);
}
for group in app.groups() {
let group_paths = group.paths();
if let Err(_e) = validate_preserves_copy(&selected_files, &group_paths) {
return Err(TuiError::DeleteError(
"Cannot delete all copies - at least one file must be preserved".to_string(),
));
}
}
let config = DeleteConfig::trash();
let result = delete_batch(&selected_files, &config, None::<&NoOpProgress>);
let deleted_paths: Vec<_> = result.successes.iter().map(|r| r.path.clone()).collect();
app.remove_deleted_files(&deleted_paths);
if !result.failures.is_empty() {
let (failed_path, error_msg) = &result.failures[0];
log::warn!(
"Some files failed to delete: {} - {}",
failed_path.display(),
error_msg
);
if result.successes.is_empty() {
return Err(TuiError::DeleteError(format!(
"Failed to delete files: {}",
error_msg
)));
}
}
Ok(result.success_count())
}
struct NoOpProgress;
impl crate::actions::delete::DeleteProgressCallback for NoOpProgress {
fn on_before_delete(&self, _path: &std::path::Path, _index: usize, _total: usize) {}
fn on_delete_success(&self, _path: &std::path::Path, _size: u64) {}
fn on_delete_failure(&self, _path: &std::path::Path, _error: &str) {}
fn on_complete(&self, _result: &crate::actions::delete::BatchDeleteResult) {}
}
fn setup_terminal() -> TuiResult<Terminal> {
log::debug!("Setting up terminal for TUI");
terminal::enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(
stdout,
EnterAlternateScreen,
EnableMouseCapture,
cursor::Hide
)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
log::debug!("Terminal setup complete");
Ok(terminal)
}
fn restore_terminal() -> TuiResult<()> {
log::debug!("Restoring terminal");
let _ = terminal::disable_raw_mode();
let mut stdout = io::stdout();
let _ = execute!(
stdout,
LeaveAlternateScreen,
DisableMouseCapture,
cursor::Show
);
log::debug!("Terminal restored");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_tui_error_display() {
let io_err = io::Error::other("test error");
let tui_err = TuiError::Io(io_err);
assert!(format!("{}", tui_err).contains("terminal I/O error"));
let interrupted = TuiError::Interrupted;
assert!(format!("{}", interrupted).contains("interrupted"));
}
#[test]
fn test_preview_file_simple_nonexistent() {
let content = preview_file_simple(std::path::Path::new("/nonexistent/file.txt"));
assert!(content.to_lowercase().contains("error") || content.contains("not found"));
}
#[test]
fn test_preview_file_simple_with_temp_file() {
use std::io::Write;
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join("rustdupe_test_preview.txt");
{
let mut file = std::fs::File::create(&temp_path).unwrap();
writeln!(file, "Line 1").unwrap();
writeln!(file, "Line 2").unwrap();
writeln!(file, "Line 3").unwrap();
}
let content = preview_file_simple(&temp_path);
assert!(content.contains("Line 1"));
assert!(content.contains("Line 2"));
assert!(content.contains("Line 3"));
let _ = std::fs::remove_file(&temp_path);
}
#[test]
fn test_preview_file_simple_empty_file() {
use std::fs::File;
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join("rustdupe_test_empty.txt");
File::create(&temp_path).unwrap();
let content = preview_file_simple(&temp_path);
assert!(content.contains("empty"));
let _ = std::fs::remove_file(&temp_path);
}
#[test]
fn test_frame_duration() {
assert_eq!(FRAME_DURATION.as_millis(), 16);
}
#[test]
fn test_poll_timeout() {
assert_eq!(POLL_TIMEOUT.as_millis(), 16);
}
#[test]
fn test_noop_progress_callback() {
use crate::actions::delete::DeleteProgressCallback;
let progress = NoOpProgress;
progress.on_before_delete(std::path::Path::new("/test"), 0, 1);
progress.on_delete_success(std::path::Path::new("/test"), 100);
progress.on_delete_failure(std::path::Path::new("/test"), "error");
let result = crate::actions::delete::BatchDeleteResult {
successes: vec![],
failures: vec![],
bytes_freed: 0,
};
progress.on_complete(&result);
}
mod perform_deletion_tests {
use super::*;
use crate::duplicates::DuplicateGroup;
use crate::tui::App;
fn make_group(size: u64, paths: Vec<&str>) -> DuplicateGroup {
DuplicateGroup::new(
[0u8; 32],
size,
paths
.into_iter()
.map(|p| {
crate::scanner::FileEntry::new(
PathBuf::from(p),
size,
std::time::SystemTime::now(),
)
})
.collect(),
Vec::new(),
)
}
#[test]
fn test_perform_deletion_empty_selection() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt"])];
let mut app = App::with_groups(groups);
let result = perform_deletion(&mut app);
assert!(result.is_ok());
assert_eq!(result.unwrap(), 0);
}
#[test]
fn test_perform_deletion_prevents_deleting_all_copies() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt"])];
let mut app = App::with_groups(groups);
app.select(PathBuf::from("/a.txt"));
app.select(PathBuf::from("/b.txt"));
let result = perform_deletion(&mut app);
assert!(result.is_err());
}
}
}