ai_code_buddy/widgets/
analysis.rs

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                        // Always allow going back to overview with Escape
46                        // If analysis is running, this will stop it and go back
47                        if analysis_state.is_analyzing {
48                            analysis_state.is_analyzing = false;
49                        }
50                        app_events.send(AppEvent::SwitchTo(AppState::Overview));
51                    }
52                    _ => {
53                        // Only handle other keys on release to avoid double-triggering
54                        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                // Handle mouse events if needed
86            }
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        // Create a channel for progress updates
101        use tokio::sync::mpsc;
102        let (progress_tx, mut progress_rx) = mpsc::unbounded_channel();
103
104        // Spawn task to handle progress updates
105        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        // Create progress callback that sends to channel
121        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        // Perform actual AI-powered analysis
129        match core::analysis::perform_analysis_with_progress(&args, Some(progress_callback)).await {
130            Ok(review) => {
131                // Close progress channel
132                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), // Title
187                Constraint::Min(10),   // Content
188                Constraint::Length(3), // Status bar
189            ])
190            .split(area);
191
192        // Render title
193        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        // Render content based on state
204        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        // Render status bar
213        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), // Progress bar
245                Constraint::Min(3),    // Current file
246            ])
247            .split(area);
248
249        // Progress bar
250        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        // Current file
264        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), // Summary
293                Constraint::Percentage(70), // Issue list
294            ])
295            .split(area);
296
297        // Summary
298        self.render_summary(chunks[0], buf, review);
299
300        // Issue list
301        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                // Create a multi-line item for better readability
394                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(""), // Empty line for spacing
407                ];
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}