1use bevy::prelude::*;
2use bevy_ratatui::{error::exit_on_error, terminal::RatatuiContext};
3use bevy_tokio_tasks::TokioTasksRuntime;
4use crossterm::event::{KeyCode, KeyEventKind};
5use ratatui::{
6 buffer::Buffer,
7 layout::{Alignment, Constraint, Direction, Layout, Rect},
8 style::Style,
9 text::{Line, Span},
10 widgets::{Block, Borders, Gauge, List, ListItem, Paragraph, StatefulWidgetRef, WidgetRef},
11};
12
13use crate::{
14 args::Args,
15 bevy_states::app::AppState,
16 core,
17 events::{analysis::AnalysisEvent, app::AppEvent},
18 theme::THEME,
19 widget_states::analysis::AnalysisWidgetState,
20};
21
22pub struct AnalysisPlugin;
23
24impl Plugin for AnalysisPlugin {
25 fn build(&self, app: &mut App) {
26 app.add_event::<AnalysisEvent>()
27 .init_resource::<AnalysisWidgetState>()
28 .add_systems(PreUpdate, analysis_event_handler)
29 .add_systems(Update, render_analysis.pipe(exit_on_error));
30 }
31}
32
33pub fn analysis_event_handler(
34 mut analysis_events: EventReader<AnalysisEvent>,
35 mut analysis_state: ResMut<AnalysisWidgetState>,
36 mut app_events: EventWriter<AppEvent>,
37 args: Res<Args>,
38 tokio_runtime: ResMut<TokioTasksRuntime>,
39) {
40 for event in analysis_events.read() {
41 match event {
42 AnalysisEvent::KeyEvent(key_event) => {
43 match key_event.code {
44 KeyCode::Esc => {
45 if analysis_state.is_analyzing {
48 analysis_state.is_analyzing = false;
49 }
50 app_events.send(AppEvent::SwitchTo(AppState::Overview));
51 }
52 _ => {
53 if key_event.kind == KeyEventKind::Release {
55 match key_event.code {
56 KeyCode::Enter => {
57 if !analysis_state.is_analyzing
58 && analysis_state.review.is_none()
59 {
60 start_analysis(&mut analysis_state, &args, &tokio_runtime);
61 }
62 }
63 KeyCode::Up => {
64 if !analysis_state.is_analyzing {
65 analysis_state.move_issue_selection(-1);
66 }
67 }
68 KeyCode::Down => {
69 if !analysis_state.is_analyzing {
70 analysis_state.move_issue_selection(1);
71 }
72 }
73 KeyCode::Char('r') => {
74 if !analysis_state.is_analyzing {
75 app_events.send(AppEvent::SwitchTo(AppState::Reports));
76 }
77 }
78 _ => {}
79 }
80 }
81 }
82 }
83 }
84 AnalysisEvent::MouseEvent(_mouse_event) => {
85 }
87 }
88 }
89}
90
91fn start_analysis(
92 analysis_state: &mut AnalysisWidgetState,
93 args: &Args,
94 tokio_runtime: &TokioTasksRuntime,
95) {
96 analysis_state.start_analysis();
97
98 let args = args.clone();
99 tokio_runtime.spawn_background_task(|mut ctx| async move {
100 use tokio::sync::mpsc;
102 let (progress_tx, mut progress_rx) = mpsc::unbounded_channel();
103
104 let ctx_clone = ctx.clone();
106 tokio::spawn(async move {
107 let mut ctx = ctx_clone;
108 while let Some((progress, current_file)) = progress_rx.recv().await {
109 ctx.run_on_main_thread(move |ctx| {
110 if let Some(mut analysis_state) =
111 ctx.world.get_resource_mut::<AnalysisWidgetState>()
112 {
113 analysis_state.update_progress(progress, current_file);
114 }
115 })
116 .await;
117 }
118 });
119
120 let progress_callback = {
122 let tx = progress_tx.clone();
123 Box::new(move |progress: f64, current_file: String| {
124 let _ = tx.send((progress, current_file));
125 }) as Box<dyn Fn(f64, String) + Send + Sync>
126 };
127
128 match core::analysis::perform_analysis_with_progress(&args, Some(progress_callback)).await {
130 Ok(review) => {
131 drop(progress_tx);
133
134 ctx.run_on_main_thread(move |ctx| {
135 if let Some(mut analysis_state) =
136 ctx.world.get_resource_mut::<AnalysisWidgetState>()
137 {
138 analysis_state.complete_analysis(review);
139 }
140 })
141 .await;
142 }
143 Err(e) => {
144 eprintln!("AI analysis failed: {e}");
145 drop(progress_tx);
146
147 ctx.run_on_main_thread(move |ctx| {
148 if let Some(mut analysis_state) =
149 ctx.world.get_resource_mut::<AnalysisWidgetState>()
150 {
151 analysis_state.is_analyzing = false;
152 }
153 })
154 .await;
155 }
156 }
157 });
158}
159
160fn render_analysis(
161 app_state: Res<State<AppState>>,
162 mut ratatui_context: ResMut<RatatuiContext>,
163 mut analysis_state: ResMut<AnalysisWidgetState>,
164) -> color_eyre::Result<()> {
165 if app_state.get() != &AppState::Analysis {
166 return Ok(());
167 }
168
169 ratatui_context.draw(|frame| {
170 let area = frame.area();
171 frame.render_stateful_widget_ref(AnalysisWidget, area, &mut analysis_state);
172 })?;
173
174 Ok(())
175}
176
177pub struct AnalysisWidget;
178
179impl StatefulWidgetRef for AnalysisWidget {
180 type State = AnalysisWidgetState;
181
182 fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
183 let chunks = Layout::default()
184 .direction(Direction::Vertical)
185 .constraints([
186 Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ])
190 .split(area);
191
192 let title = Paragraph::new("đ Code Analysis")
194 .style(THEME.title_style())
195 .alignment(Alignment::Center)
196 .block(
197 Block::default()
198 .borders(Borders::ALL)
199 .border_style(THEME.header_style()),
200 );
201 title.render_ref(chunks[0], buf);
202
203 if state.is_analyzing {
205 self.render_analysis_progress(chunks[1], buf, state);
206 } else if let Some(review) = &state.review {
207 self.render_results(chunks[1], buf, state, review);
208 } else {
209 self.render_start_screen(chunks[1], buf);
210 }
211
212 self.render_status_bar(chunks[2], buf, state);
214 }
215}
216
217impl AnalysisWidget {
218 fn render_start_screen(&self, area: Rect, buf: &mut Buffer) {
219 let content = Paragraph::new(vec![
220 Line::from(""),
221 Line::from("Press Enter to start the code analysis"),
222 Line::from(""),
223 Line::from("This will analyze your Git repository for:"),
224 Line::from("âĸ Security vulnerabilities"),
225 Line::from("âĸ Performance issues"),
226 Line::from("âĸ Code quality problems"),
227 Line::from("âĸ Best practice violations"),
228 ])
229 .alignment(Alignment::Center)
230 .block(
231 Block::default()
232 .borders(Borders::ALL)
233 .title("Ready to Analyze")
234 .title_style(THEME.header_style()),
235 );
236
237 content.render_ref(area, buf);
238 }
239
240 fn render_analysis_progress(&self, area: Rect, buf: &mut Buffer, state: &AnalysisWidgetState) {
241 let chunks = Layout::default()
242 .direction(Direction::Vertical)
243 .constraints([
244 Constraint::Length(5), Constraint::Min(3), ])
247 .split(area);
248
249 let progress = Gauge::default()
251 .block(
252 Block::default()
253 .borders(Borders::ALL)
254 .title("Analysis Progress")
255 .title_style(THEME.header_style()),
256 )
257 .gauge_style(THEME.success_style())
258 .percent(state.progress as u16)
259 .label(format!("{:.1}%", state.progress));
260
261 progress.render_ref(chunks[0], buf);
262
263 let current_file = Paragraph::new(vec![
265 Line::from(""),
266 Line::from(vec![
267 Span::styled("Currently analyzing: ", THEME.info_style()),
268 Span::raw(&state.current_file),
269 ]),
270 ])
271 .alignment(Alignment::Center)
272 .block(
273 Block::default()
274 .borders(Borders::ALL)
275 .title("Status")
276 .title_style(THEME.header_style()),
277 );
278
279 current_file.render_ref(chunks[1], buf);
280 }
281
282 fn render_results(
283 &self,
284 area: Rect,
285 buf: &mut Buffer,
286 state: &AnalysisWidgetState,
287 review: &crate::core::review::Review,
288 ) {
289 let chunks = Layout::default()
290 .direction(Direction::Horizontal)
291 .constraints([
292 Constraint::Percentage(30), Constraint::Percentage(70), ])
295 .split(area);
296
297 self.render_summary(chunks[0], buf, review);
299
300 self.render_issue_list(chunks[1], buf, state, review);
302 }
303
304 fn render_summary(&self, area: Rect, buf: &mut Buffer, review: &crate::core::review::Review) {
305 let summary_lines = vec![
306 Line::from(""),
307 Line::from(vec![
308 Span::styled("đ Files: ", THEME.info_style()),
309 Span::raw(format!("{}", review.files_count)),
310 ]),
311 Line::from(""),
312 Line::from(vec![
313 Span::styled("đ Total Issues: ", THEME.info_style()),
314 Span::raw(format!("{}", review.issues_count)),
315 ]),
316 Line::from(""),
317 Line::from(vec![
318 Span::styled("đ¨ Critical: ", THEME.error_style()),
319 Span::raw(format!("{}", review.critical_issues)),
320 ]),
321 Line::from(vec![
322 Span::styled("â ī¸ High: ", THEME.warning_style()),
323 Span::raw(format!("{}", review.high_issues)),
324 ]),
325 Line::from(vec![
326 Span::styled("đļ Medium: ", THEME.warning_style()),
327 Span::raw(format!("{}", review.medium_issues)),
328 ]),
329 Line::from(vec![
330 Span::styled("âšī¸ Low: ", THEME.info_style()),
331 Span::raw(format!("{}", review.low_issues)),
332 ]),
333 ];
334
335 let summary = Paragraph::new(summary_lines).block(
336 Block::default()
337 .borders(Borders::ALL)
338 .title("Summary")
339 .title_style(THEME.header_style()),
340 );
341
342 summary.render_ref(area, buf);
343 }
344
345 fn render_issue_list(
346 &self,
347 area: Rect,
348 buf: &mut Buffer,
349 state: &AnalysisWidgetState,
350 review: &crate::core::review::Review,
351 ) {
352 if review.issues.is_empty() {
353 let no_issues = Paragraph::new(vec![
354 Line::from(""),
355 Line::from("đ No issues found!"),
356 Line::from(""),
357 Line::from("Your code looks clean. Great job!"),
358 ])
359 .alignment(Alignment::Center)
360 .block(
361 Block::default()
362 .borders(Borders::ALL)
363 .title("Issues")
364 .title_style(THEME.header_style()),
365 );
366 no_issues.render_ref(area, buf);
367 return;
368 }
369
370 let items: Vec<ListItem> = review
371 .issues
372 .iter()
373 .enumerate()
374 .map(|(i, issue)| {
375 let severity_icon = match issue.severity.as_str() {
376 "Critical" => "đ¨",
377 "High" => "â ī¸",
378 "Medium" => "đļ",
379 "Low" => "âšī¸",
380 _ => "đĄ",
381 };
382
383 let severity_style = match issue.severity.as_str() {
384 "Critical" => THEME.error_style(),
385 "High" => THEME.warning_style(),
386 "Medium" => THEME.warning_style(),
387 "Low" => THEME.info_style(),
388 _ => Style::default(),
389 };
390
391 let is_selected = i == state.selected_issue;
392
393 let lines = vec![
395 Line::from(vec![
396 Span::styled(format!("{severity_icon} "), severity_style),
397 Span::styled(issue.severity.to_string(), severity_style),
398 Span::raw(" "),
399 Span::styled(format!("{}:{}", issue.file, issue.line), THEME.info_style()),
400 ]),
401 Line::from(vec![
402 Span::raw(" "),
403 Span::styled(format!("{}: ", issue.category), THEME.header_style()),
404 Span::raw(issue.description.to_string()),
405 ]),
406 Line::from(""), ];
408
409 let style = if is_selected {
410 THEME.selected_style()
411 } else {
412 Style::default()
413 };
414
415 ListItem::new(lines).style(style)
416 })
417 .collect();
418
419 let issue_list = List::new(items)
420 .block(
421 Block::default()
422 .borders(Borders::ALL)
423 .title(format!(
424 "Issues ({}/{})",
425 state.selected_issue + 1,
426 review.issues.len().max(1)
427 ))
428 .title_style(THEME.header_style()),
429 )
430 .highlight_style(THEME.selected_style());
431
432 WidgetRef::render_ref(&issue_list, area, buf);
433 }
434
435 fn render_status_bar(&self, area: Rect, buf: &mut Buffer, state: &AnalysisWidgetState) {
436 let status_text = if state.is_analyzing {
437 "Analysis in progress... Please wait"
438 } else if state.review.is_some() {
439 "Use ââ to navigate issues, R for reports, Esc to go back"
440 } else {
441 "Enter to start analysis, Esc to go back"
442 };
443
444 let status = Paragraph::new(status_text)
445 .style(THEME.info_style())
446 .alignment(Alignment::Center)
447 .block(
448 Block::default()
449 .borders(Borders::TOP)
450 .border_style(THEME.info_style()),
451 );
452
453 status.render_ref(area, buf);
454 }
455}