This commit is contained in:
545
src/tui/mod.rs
Normal file
545
src/tui/mod.rs
Normal file
@@ -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<TechDebtItem>,
|
||||
pub current_view: View,
|
||||
pub selected_index: usize,
|
||||
pub filter_text: String,
|
||||
pub filter_priority: Option<Priority>,
|
||||
pub sort_order: SortOrder,
|
||||
pub show_help: bool,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
impl TuiState {
|
||||
pub fn new(items: Vec<TechDebtItem>, 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<CrosstermBackend<Stdout>>, 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<Row> = 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<CrosstermBackend<Stdout>>, 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<ListItem> = 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::<String>(),
|
||||
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<CrosstermBackend<Stdout>>, 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<CrosstermBackend<Stdout>>, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user