diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..99893ce --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,545 @@ +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + terminal::{disable_raw_mode, enable_raw_mode, Clear}, + ExecutableCommand, +}; +use ratatui::{ + backend::CrosstermBackend, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style, Stylize}, + text::{Line, Span, Text}, + widgets::{ + Block, Borders, Cell, Gauge, List, ListItem, Paragraph, Row, Table, Tabs, Widget, + }, + Frame, Terminal, +}; +use std::io::Stdout; +use std::path::PathBuf; + +use crate::models::{AnalysisSummary, ByLanguage, ByPriority, Priority, TechDebtItem}; + +mod app; +pub use app::TuiApp; + +#[derive(Debug, Clone, PartialEq)] +pub enum View { + Dashboard, + List, + Detail, + Export, +} + +#[derive(Debug, Clone, PartialEq)] +enum SortOrder { + Priority, + File, + Line, + Keyword, +} + +pub struct TuiState { + pub items: Vec, + pub current_view: View, + pub selected_index: usize, + pub filter_text: String, + pub filter_priority: Option, + pub sort_order: SortOrder, + pub show_help: bool, + pub path: PathBuf, +} + +impl TuiState { + pub fn new(items: Vec, path: PathBuf) -> Self { + Self { + items, + current_view: View::Dashboard, + selected_index: 0, + filter_text: String::new(), + filter_priority: None, + sort_order: SortOrder::Priority, + show_help: false, + path, + } + } + + pub fn filtered_items(&self) -> Vec<&TechDebtItem> { + let mut items: Vec<&TechDebtItem> = self.items.iter().collect(); + + if !self.filter_text.is_empty() { + items.retain(|item| { + item.content.to_lowercase().contains(&self.filter_text.to_lowercase()) + || item + .location + .path + .to_string_lossy() + .to_lowercase() + .contains(&self.filter_text.to_lowercase()) + }); + } + + if let Some(priority) = self.filter_priority { + items.retain(|item| item.priority == priority); + } + + match self.sort_order { + SortOrder::Priority => items.sort_by(|a, b| b.priority.cmp(&a.priority)), + SortOrder::File => items.sort_by(|a, b| { + a.location + .path + .cmp(&b.location.path) + .then_with(|| a.location.line.cmp(&b.location.line)) + }), + SortOrder::Line => items.sort_by(|a, b| a.location.line.cmp(&b.location.line)), + SortOrder::Keyword => items.sort_by(|a, b| a.keyword.cmp(&b.keyword)), + } + + items + } + + pub fn summary(&self) -> AnalysisSummary { + let by_priority = ByPriority::from_items(&self.items); + let by_language = ByLanguage::from_items(&self.items); + let complexity_distribution = + crate::models::ComplexityDistribution::from_items(&self.items); + + AnalysisSummary { + total_items: self.items.len(), + by_priority, + by_language, + complexity_distribution, + } + } +} + +fn draw_dashboard(f: &mut Frame>, state: &TuiState, area: Rect) { + let summary = state.summary(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(10), + Constraint::Length(3), + ]) + .split(area); + + let title = Paragraph::new("TECHDEBT TRACKER - Dashboard") + .style(Style::default().fg(Color::Cyan).bold()); + f.render_widget(title, chunks[0]); + + let main_content = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[1]); + + let left_content = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), + Constraint::Length(4), + Constraint::Length(4), + Constraint::Min(0), + ]) + .split(main_content[0]); + + let right_content = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), + Constraint::Length(4), + Constraint::Min(0), + ]) + .split(main_content[1]); + + let priority_block = Block::default() + .title("Priority Breakdown") + .borders(Borders::ALL) + .style(Style::default().fg(Color::White)); + f.render_widget(priority_block, left_content[0]); + + let priority_content = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(left_content[0].inner); + + let critical_gauge = Gauge::default() + .gauge_style(Style::default().fg(Color::Red)) + .label(&format!("Critical: {}", summary.by_priority.critical)) + .ratio(if summary.total_items > 0 { + summary.by_priority.critical as f64 / summary.total_items as f64 + } else { + 0.0 + }); + f.render_widget(critical_gauge, priority_content[0]); + + let high_gauge = Gauge::default() + .gauge_style(Style::default().fg(Color::Yellow)) + .label(&format!("High: {}", summary.by_priority.high)) + .ratio(if summary.total_items > 0 { + summary.by_priority.high as f64 / summary.total_items as f64 + } else { + 0.0 + }); + f.render_widget(high_gauge, priority_content[1]); + + let medium_gauge = Gauge::default() + .gauge_style(Style::default().fg(Color::Blue)) + .label(&format!("Medium: {}", summary.by_priority.medium)) + .ratio(if summary.total_items > 0 { + summary.by_priority.medium as f64 / summary.total_items as f64 + } else { + 0.0 + }); + f.render_widget(medium_gauge, priority_content[2]); + + let low_gauge = Gauge::default() + .gauge_style(Style::default().fg(Color::Green)) + .label(&format!("Low: {}", summary.by_priority.low)) + .ratio(if summary.total_items > 0 { + summary.by_priority.low as f64 / summary.total_items as f64 + } else { + 0.0 + }); + f.render_widget(low_gauge, priority_content[3]); + + let total_block = Block::default() + .title("Total Items") + .borders(Borders::ALL) + .style(Style::default().fg(Color::White)); + f.render_widget(total_block, left_content[1]); + let total_text = Paragraph::new(format!("{}", summary.total_items)) + .style(Style::default().fg(Color::Cyan).bold()); + f.render_widget(total_text, left_content[1].inner); + + let complexity_block = Block::default() + .title("Complexity Distribution") + .borders(Borders::ALL) + .style(Style::default().fg(Color::White)); + f.render_widget(complexity_block, left_content[2]); + let complexity_text = format!( + "Low: {} Medium: {} High: {} Critical: {}", + summary.complexity_distribution.low, + summary.complexity_distribution.medium, + summary.complexity_distribution.high, + summary.complexity_distribution.critical + ); + let complexity_para = Paragraph::new(complexity_text); + f.render_widget(complexity_para, left_content[2].inner); + + let language_block = Block::default() + .title("By Language") + .borders(Borders::ALL) + .style(Style::default().fg(Color::White)); + f.render_widget(language_block, right_content[0]); + + let lang_rows: Vec = summary + .by_language + .items + .iter() + .take(5) + .map(|lang| { + Row::new(vec![ + Cell::from(lang.language.clone()), + Cell::from(lang.count.to_string()), + ]) + }) + .collect(); + + let lang_table = Table::new(lang_rows) + .widths(&[Constraint::Percentage(60), Constraint::Percentage(40)]) + .column_spacing(1); + f.render_widget(lang_table, right_content[0].inner); + + let help_block = Block::default() + .title("Navigation Help") + .borders(Borders::ALL) + .style(Style::default().fg(Color::White)); + f.render_widget(help_block, right_content[1]); + let help_text = "↑↓: Navigate | Tab: Switch View | q: Quit | /: Filter"; + let help_para = Paragraph::new(help_text); + f.render_widget(help_para, right_content[1].inner); + + let status_bar = Line::from(vec![ + Span::styled(" Press ", Style::default().fg(Color::Gray)), + Span::styled("TAB", Style::default().fg(Color::Yellow).bold()), + Span::styled(" to view items | ", Style::default().fg(Color::Gray)), + Span::styled("q", Style::default().fg(Color::Yellow).bold()), + Span::styled(" to quit", Style::default().fg(Color::Gray)), + ]); + f.render_widget(status_bar, chunks[2]); +} + +fn draw_list(f: &mut Frame>, state: &TuiState, area: Rect) { + let filtered = state.filtered_items(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(10), + Constraint::Length(3), + ]) + .split(area); + + let filter_text = if state.filter_text.is_empty() { + format!("Filter ({} items) - Press / to filter", filtered.len()) + } else { + format!( + "Filter: {} - {} results (Press / to edit, ESC to clear)", + state.filter_text, + filtered.len() + ) + }; + let filter_block = Paragraph::new(filter_text) + .style(Style::default().fg(Color::Yellow)); + f.render_widget(filter_block, chunks[0]); + + let list_items: Vec = filtered + .iter() + .enumerate() + .skip(state.selected_index.saturating_sub(10)) + .take(chunks[1].height as usize - 2) + .map(|(idx, item)| { + let priority_color = match item.priority { + Priority::Critical => Color::Red, + Priority::High => Color::Yellow, + Priority::Medium => Color::Blue, + Priority::Low => Color::Green, + }; + + let content = format!( + "[{}] {}:{} - {} ({}/10) - {}", + item.keyword, + item.location.path.file_name().unwrap_or_default().to_string_lossy(), + item.location.line, + item.content.chars().take(40).collect::(), + item.complexity_score, + item.metadata.language + ); + + let style = if *idx == state.selected_index { + Style::default() + .fg(priority_color) + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(priority_color) + }; + + ListItem::new(content).style(style) + }) + .collect(); + + let list = List::new(list_items).block( + Block::default() + .title(format!("Items ({})", filtered.len())) + .borders(Borders::ALL), + ); + f.render_widget(list, chunks[1]); + + let status_bar = Line::from(vec![ + Span::styled(" ↑↓: Navigate | ", Style::default().fg(Color::Gray)), + Span::styled("ENTER", Style::default().fg(Color::Yellow).bold()), + Span::styled(": View Detail | ", Style::default().fg(Color::Gray)), + Span::styled("TAB", Style::default().fg(Color::Yellow).bold()), + Span::styled(": Dashboard | ", Style::default().fg(Color::Gray)), + Span::styled("f", Style::default().fg(Color::Yellow).bold()), + Span::styled(": Filter | ", Style::default().fg(Color::Gray)), + Span::styled("s", Style::default().fg(Color::Yellow).bold()), + Span::styled(": Sort | ", Style::default().fg(Color::Gray)), + Span::styled("q", Style::default().fg(Color::Yellow).bold()), + Span::styled(": Quit", Style::default().fg(Color::Gray)), + ]); + f.render_widget(status_bar, chunks[2]); +} + +fn draw_detail(f: &mut Frame>, state: &TuiState, area: Rect) { + let filtered = state.filtered_items(); + + if filtered.is_empty() { + let para = Paragraph::new("No items selected"); + f.render_widget(para, area); + return; + } + + let item = if let Some(item) = filtered.get(state.selected_index) { + item + } else { + state.selected_index = 0; + if let Some(item) = filtered.get(0) { + item + } else { + return; + } + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(3), + ]) + .split(area); + + let title = format!( + "Detail - {} at {}:{}", + item.keyword, + item.location.path.display(), + item.location.line + ); + let title_block = Paragraph::new(title) + .style(Style::default().fg(Color::Cyan).bold()); + f.render_widget(title_block, chunks[0]); + + let detail_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(chunks[1]); + + let left_details = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Length(2), + Constraint::Length(2), + Constraint::Length(2), + Constraint::Min(0), + ]) + .split(detail_chunks[0]); + + let priority_color = match item.priority { + Priority::Critical => Color::Red, + Priority::High => Color::Yellow, + Priority::Medium => Color::Blue, + Priority::Low => Color::Green, + }; + + let priority_line = Line::from(vec![ + Span::styled("Priority: ", Style::default().fg(Color::Gray)), + Span::styled(item.priority.as_str(), Style::default().fg(priority_color).bold()), + ]); + f.render_widget(priority_line, left_details[0]); + + let complexity_line = Line::from(vec![ + Span::styled("Complexity: ", Style::default().fg(Color::Gray)), + Span::styled( + format!("{}/10", item.complexity_score), + Style::default() + .fg(match item.complexity_score { + 1..=3 => Color::Green, + 4..=6 => Color::Yellow, + 7..=8 => Color::Red, + _ => Color::Magenta, + }) + .bold(), + ), + ]); + f.render_widget(complexity_line, left_details[1]); + + let language_line = Line::from(vec![ + Span::styled("Language: ", Style::default().fg(Color::Gray)), + Span::styled(&item.metadata.language, Style::default().fg(Color::Cyan)), + ]); + f.render_widget(language_line, left_details[2]); + + let type_line = Line::from(vec![ + Span::styled("Type: ", Style::default().fg(Color::Gray)), + Span::styled( + format!("{:?}", item.comment_type), + Style::default().fg(Color::Blue), + ), + ]); + f.render_widget(type_line, left_details[3]); + + let content_block = Block::default().title("Content").borders(Borders::ALL); + f.render_widget(content_block, left_details[4]); + let content_para = Paragraph::new(&item.content) + .wrap(ratatui::text::Wrap { trim: true }); + f.render_widget(content_para, left_details[4].inner); + + let right_details = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Length(2), + Constraint::Length(2), + Constraint::Min(0), + ]) + .split(detail_chunks[1]); + + let path_line = Line::from(vec![ + Span::styled("File: ", Style::default().fg(Color::Gray)), + Span::styled( + item.location.path.display().to_string(), + Style::default().fg(Color::White), + ), + ]); + f.render_widget(path_line, right_details[0]); + + let location_line = Line::from(vec![ + Span::styled("Line: ", Style::default().fg(Color::Gray)), + Span::styled( + item.location.line.to_string(), + Style::default().fg(Color::White), + ), + Span::styled(" | Column: ", Style::default().fg(Color::Gray)), + Span::styled( + item.location.column.to_string(), + Style::default().fg(Color::White), + ), + ]); + f.render_widget(location_line, right_details[1]); + + let question_line = Line::from(vec![ + Span::styled("Contains Question: ", Style::default().fg(Color::Gray)), + Span::styled( + if item.metadata.is_question { "Yes" } else { "No" }, + Style::default().fg(if item.metadata.is_question { + Color::Yellow + } else { + Color::Green + }), + ), + ]); + f.render_widget(question_line, right_details[2]); + + let word_count_line = Line::from(vec![ + Span::styled("Word Count: ", Style::default().fg(Color::Gray)), + Span::styled(item.metadata.word_count.to_string(), Style::default().fg(Color::White)), + ]); + f.render_widget(word_count_line, right_details[3]); + + let status_bar = Line::from(vec![ + Span::styled(" ←: Back | ", Style::default().fg(Color::Gray)), + Span::styled("e", Style::default().fg(Color::Yellow).bold()), + Span::styled(": Export | ", Style::default().fg(Color::Gray)), + Span::styled("q", Style::default().fg(Color::Yellow).bold()), + Span::styled(": Quit", Style::default().fg(Color::Gray)), + ]); + f.render_widget(status_bar, chunks[2]); +} + +pub fn render(f: &mut Frame>, state: &TuiState) { + let area = f.size(); + + let block = Block::default() + .style(Style::default().bg(Color::Black)) + .borders(Borders::NONE); + f.render_widget(block, area); + + match state.current_view { + View::Dashboard => draw_dashboard(f, state, area), + View::List => draw_list(f, state, area), + View::Detail => draw_detail(f, state, area), + View::Export => { + let para = Paragraph::new("Export feature - Coming soon!"); + f.render_widget(para, area); + } + } +}