use crate::cli::{Cli, Color};
use crate::config::Config;
use crate::project::RustProject;
use crate::scanner::ProjectScanner;
use crate::utils;
use anyhow::Result;
use colored::Colorize;
use crossterm::{
cursor,
event::{self, Event, KeyCode, KeyEvent},
execute,
terminal::{self, ClearType},
};
use std::io::{self, Write};
use std::process;
#[derive(Debug)]
pub struct InteractiveUI {
cli: Cli,
config: Config,
projects: Vec<RustProject>,
selected_index: usize,
total_deleted_size: u64,
deleted_count: usize,
}
impl InteractiveUI {
pub fn new(cli: Cli, config: Config) -> Self {
Self {
cli,
config,
projects: Vec::new(),
selected_index: 0,
total_deleted_size: 0,
deleted_count: 0,
}
}
pub async fn run(&mut self) -> Result<()> {
terminal::enable_raw_mode()?;
execute!(
io::stdout(),
terminal::Clear(ClearType::All),
cursor::Hide
)?;
let result = self.run_interactive_loop().await;
execute!(io::stdout(), cursor::Show)?;
terminal::disable_raw_mode()?;
result
}
async fn run_interactive_loop(&mut self) -> Result<()> {
let scanner = ProjectScanner::new(self.cli.clone(), self.config.clone());
self.projects = scanner.scan().await?;
if self.projects.is_empty() {
println!("🚫 No Rust projects found.");
return Ok(());
}
loop {
self.display_interface()?;
if let Event::Key(key_event) = event::read()? {
match self.handle_key_event(key_event).await? {
ControlFlow::Exit => break,
ControlFlow::Continue => continue,
}
}
}
Ok(())
}
fn display_interface(&self) -> Result<()> {
execute!(io::stdout(), terminal::Clear(ClearType::All), cursor::MoveTo(0, 0))?;
println!("{}", "🦀 RSKILL - Rust Project Cleaner".cyan().bold());
println!("{}", "─".repeat(80));
println!();
println!("📋 {} | {} | {} | {} | {}",
"↑↓ Navigate".blue(),
"Space/Del Delete".red(),
"o Open".green(),
"r Refresh".yellow(),
"q Quit".magenta()
);
println!();
let start_row = 6;
let max_visible = 15;
let start_index = if self.selected_index >= max_visible {
self.selected_index - max_visible + 1
} else {
0
};
for (i, project) in self.projects
.iter()
.skip(start_index)
.take(max_visible)
.enumerate()
{
let actual_index = start_index + i;
let is_selected = actual_index == self.selected_index;
self.display_project_row(project, is_selected, start_row + i)?;
}
self.display_footer()?;
io::stdout().flush()?;
Ok(())
}
fn display_project_row(&self, project: &RustProject, is_selected: bool, row: usize) -> Result<()> {
execute!(io::stdout(), cursor::MoveTo(0, row as u16))?;
let size_str = project.format_size(self.cli.gb);
let path_str = utils::get_relative_path(&project.path);
let path_display = utils::truncate_string(&path_str, 35);
let last_mod = project
.days_since_modified()
.map(|days| {
if days == 0 {
"Today".to_string()
} else if days == 1 {
"1 day ago".to_string()
} else {
format!("{} days ago", days)
}
})
.unwrap_or_else(|| "Unknown".to_string());
let status_color = if project.is_likely_active() {
"green"
} else {
"yellow"
};
let warning_indicator = if project.target_dir.is_none() {
" (no target)".red().to_string()
} else if !project.is_likely_active() && project.total_cleanable_size() > 1024 * 1024 * 100 { " ⚠️".to_string()
} else {
"".to_string()
};
if is_selected {
let _highlight_color = self.get_highlight_color();
print!(
"{} {:<25} {:<12} {:<35} {:<15}{}",
"►".bold(),
project.name.bold(),
size_str,
path_display,
last_mod,
warning_indicator
);
} else {
let last_mod_colored = if status_color == "green" {
last_mod.green()
} else {
last_mod.yellow()
};
print!(
" {:<25} {:<12} {:<35} {:<15}{}",
project.name,
size_str.cyan(),
path_display.bright_black(),
last_mod_colored,
warning_indicator
);
}
Ok(())
}
fn display_footer(&self) -> Result<()> {
let footer_row = 23;
execute!(io::stdout(), cursor::MoveTo(0, footer_row))?;
println!("{}", "─".repeat(80));
let total_projects = self.projects.len();
let total_size: u64 = self.projects.iter().map(|p| p.total_cleanable_size()).sum();
let total_size_str = utils::format_size(total_size, self.cli.gb);
println!(
"📊 {} projects | 💾 {} cleanable | 🗑️ {} deleted ({})",
total_projects.to_string().bold(),
total_size_str.bold().green(),
self.deleted_count.to_string().bold(),
utils::format_size(self.total_deleted_size, self.cli.gb).bold().red()
);
if let Some(selected_project) = self.projects.get(self.selected_index) {
println!();
println!("📁 {}", selected_project.path.display().to_string().bright_blue());
if let Some(target_dir) = &selected_project.target_dir {
println!("🎯 Target: {}", target_dir.display().to_string().bright_black());
}
if selected_project.workspace_root {
println!("🏗️ Workspace root");
}
if selected_project.dependencies_count > 0 {
println!("📦 {} dependencies", selected_project.dependencies_count);
}
}
Ok(())
}
async fn handle_key_event(&mut self, key_event: KeyEvent) -> Result<ControlFlow> {
match key_event.code {
KeyCode::Char('q') | KeyCode::Esc => Ok(ControlFlow::Exit),
KeyCode::Up | KeyCode::Char('k') => {
if self.selected_index > 0 {
self.selected_index -= 1;
}
Ok(ControlFlow::Continue)
}
KeyCode::Down | KeyCode::Char('j') => {
if self.selected_index < self.projects.len().saturating_sub(1) {
self.selected_index += 1;
}
Ok(ControlFlow::Continue)
}
KeyCode::Delete | KeyCode::Char(' ') => {
self.delete_selected_project().await?;
Ok(ControlFlow::Continue)
}
KeyCode::Char('o') => {
self.open_selected_project()?;
Ok(ControlFlow::Continue)
}
KeyCode::Char('r') => {
self.refresh_projects().await?;
Ok(ControlFlow::Continue)
}
KeyCode::Char('a') => {
self.delete_all_projects().await?;
Ok(ControlFlow::Continue)
}
_ => Ok(ControlFlow::Continue),
}
}
async fn delete_selected_project(&mut self) -> Result<()> {
if let Some(project) = self.projects.get(self.selected_index) {
if let Some(target_dir) = &project.target_dir {
let size_before = project.total_cleanable_size();
if !self.cli.delete_all && (project.is_likely_active() || size_before > 1024 * 1024 * 500) {
}
utils::remove_directory(target_dir, self.cli.dry_run)?;
if !self.cli.dry_run {
self.total_deleted_size += size_before;
self.deleted_count += 1;
if let Some(project_mut) = self.projects.get_mut(self.selected_index) {
project_mut.target_dir = None;
project_mut.target_size = 0;
project_mut.build_artifacts.clear();
}
}
}
}
Ok(())
}
async fn delete_all_projects(&mut self) -> Result<()> {
let mut total_deleted = 0u64;
let mut count_deleted = 0;
for project in &mut self.projects {
if let Some(target_dir) = &project.target_dir {
let size_before = project.target_size;
utils::remove_directory(target_dir, self.cli.dry_run)?;
if !self.cli.dry_run {
total_deleted += size_before;
count_deleted += 1;
project.target_dir = None;
project.target_size = 0;
project.build_artifacts.clear();
}
}
}
self.total_deleted_size += total_deleted;
self.deleted_count += count_deleted;
Ok(())
}
fn open_selected_project(&self) -> Result<()> {
if let Some(project) = self.projects.get(self.selected_index) {
let path = &project.path;
#[cfg(target_os = "macos")]
{
process::Command::new("open").arg(path).spawn()?;
}
#[cfg(target_os = "linux")]
{
process::Command::new("xdg-open").arg(path).spawn()?;
}
#[cfg(target_os = "windows")]
{
process::Command::new("explorer").arg(path).spawn()?;
}
}
Ok(())
}
async fn refresh_projects(&mut self) -> Result<()> {
let scanner = ProjectScanner::new(self.cli.clone(), self.config.clone());
self.projects = scanner.scan().await?;
self.selected_index = 0;
Ok(())
}
fn get_highlight_color(&self) -> &'static str {
match self.cli.get_color() {
Color::Blue => "blue",
Color::Cyan => "cyan",
Color::Magenta => "magenta",
Color::White => "white",
Color::Red => "red",
Color::Yellow => "yellow",
}
}
}
enum ControlFlow {
Continue,
Exit,
}