1use anyhow::Result;
2use crossterm::{
3 event::{self, Event, KeyCode, KeyEvent, KeyEventKind},
4 terminal::{disable_raw_mode, enable_raw_mode},
5};
6use ratatui::{
7 backend::CrosstermBackend,
8 layout::{Constraint, Layout},
9 style::{Color, Modifier, Style},
10 text::{Line, Span},
11 widgets::{
12 Block, Borders, HighlightSpacing, Row, Scrollbar, ScrollbarOrientation, ScrollbarState,
13 Table, TableState,
14 },
15 Frame, Terminal,
16};
17use regex::Regex;
18use std::collections::HashMap;
19use std::io;
20use std::path::PathBuf;
21
22use crate::types::Match;
23
24pub fn display_matches(matches: &[Match]) {
25 println!("\nFound {} debug statement(s):\n", matches.len());
27
28 let mut files_map: HashMap<PathBuf, Vec<&Match>> = HashMap::new();
30
31 for m in matches {
32 files_map.entry(m.file_path.clone()).or_default().push(m);
33 }
34
35 let mut sorted_files: Vec<_> = files_map.iter().collect();
37 sorted_files.sort_by_key(|(path, _)| path.as_path());
38
39 for (file_path, file_matches) in sorted_files {
40 println!("\x1b[35m{}\x1b[0m", file_path.display());
42
43 let mut sorted_matches = file_matches.clone();
45 sorted_matches.sort_by_key(|m| m.line_number);
46
47 for m in sorted_matches {
48 let highlighted = highlight_debug_keyword(&m.line_content);
50 let line_display = if m.line_number == m.end_line_number {
51 format!("{}", m.line_number)
52 } else {
53 format!("{}-{}", m.line_number, m.end_line_number)
54 };
55 println!("\x1b[32m{}\x1b[0m:{}", line_display, highlighted.trim());
56 }
57
58 println!(); }
60}
61
62pub fn select_statements_interactive(matches: &[Match]) -> Result<Vec<Match>> {
63 if matches.is_empty() {
64 return Ok(vec![]);
65 }
66
67 let mut app = App::new(matches.to_vec());
68 let selected = app.run()?;
69
70 Ok(selected)
71}
72
73fn highlight_debug_keyword(line: &str) -> String {
74 let re = Regex::new(r"(debug|DEBUG)").unwrap();
76 re.replace_all(line, "\x1b[1;31m$1\x1b[0m").to_string()
77}
78
79struct App {
80 matches: Vec<Match>,
81 table_state: TableState,
82 scroll_state: ScrollbarState,
83 selected: Vec<bool>, row_to_match: Vec<Option<usize>>, file_list: Vec<PathBuf>, current_file_index: usize, }
88
89impl App {
90 fn new(matches: Vec<Match>) -> Self {
91 let selected = vec![false; matches.len()];
92
93 let mut file_list = Vec::new();
95 let mut seen_files = std::collections::HashSet::new();
96 for m in &matches {
97 if seen_files.insert(m.file_path.clone()) {
98 file_list.push(m.file_path.clone());
99 }
100 }
101
102 let scroll_state = ScrollbarState::new(matches.len());
103 let mut table_state = TableState::default();
104 if !matches.is_empty() {
105 table_state.select(Some(0));
106 }
107
108 Self {
109 matches,
110 table_state,
111 scroll_state,
112 selected,
113 row_to_match: Vec::new(), file_list,
115 current_file_index: 0,
116 }
117 }
118
119 fn run(&mut self) -> Result<Vec<Match>> {
120 enable_raw_mode()?;
122 let stdout = io::stdout();
123 let backend = CrosstermBackend::new(stdout);
124
125 let max_rows_per_file = self
127 .file_list
128 .iter()
129 .map(|file| {
130 self.matches
131 .iter()
132 .filter(|m| &m.file_path == file)
133 .map(|m| m.multiline_content.len().max(1)) .sum::<usize>()
135 })
136 .max()
137 .unwrap_or(self.matches.len());
138
139 let terminal_size = crossterm::terminal::size().unwrap_or((80, 24));
141 let terminal_height = terminal_size.1;
142
143 let max_height = (terminal_height as f32 * 0.8) as u16;
147 let needed_height = (max_rows_per_file as u16 + 6).min(max_height).max(10);
148
149 let mut terminal = Terminal::with_options(
150 backend,
151 ratatui::TerminalOptions {
152 viewport: ratatui::Viewport::Inline(needed_height),
153 },
154 )?;
155
156 let result = self.run_app(&mut terminal);
157
158 disable_raw_mode()?;
160 terminal.show_cursor()?;
161
162 result
163 }
164
165 fn run_app(
166 &mut self,
167 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
168 ) -> Result<Vec<Match>> {
169 loop {
170 terminal.draw(|f| self.ui(f))?;
171
172 if let Event::Key(key) = event::read()? {
173 if key.kind == KeyEventKind::Press {
174 match self.handle_key(key) {
175 KeyAction::Quit => return Ok(vec![]),
176 KeyAction::Confirm => {
177 let selected: Vec<Match> = self
178 .matches
179 .iter()
180 .enumerate()
181 .filter(|(i, _)| self.selected[*i])
182 .map(|(_, m)| m.clone())
183 .collect();
184 return Ok(selected);
185 }
186 KeyAction::Continue => {}
187 }
188 }
189 }
190 }
191 }
192
193 fn handle_key(&mut self, key: KeyEvent) -> KeyAction {
194 match key.code {
195 KeyCode::Char('q') | KeyCode::Esc => KeyAction::Quit,
196 KeyCode::Char('c')
197 if key
198 .modifiers
199 .contains(crossterm::event::KeyModifiers::CONTROL) =>
200 {
201 KeyAction::Quit
202 }
203 KeyCode::Enter => KeyAction::Confirm,
204 KeyCode::Down | KeyCode::Char('j') => {
205 self.next();
206 KeyAction::Continue
207 }
208 KeyCode::Up | KeyCode::Char('k') => {
209 self.previous();
210 KeyAction::Continue
211 }
212 KeyCode::Left | KeyCode::Char('h') => {
213 self.previous_file();
214 KeyAction::Continue
215 }
216 KeyCode::Right | KeyCode::Char('l') => {
217 self.next_file();
218 KeyAction::Continue
219 }
220 KeyCode::Tab | KeyCode::Char(' ') => {
221 self.toggle_current();
222 KeyAction::Continue
223 }
224 KeyCode::Char('a') => {
225 self.toggle_all();
226 KeyAction::Continue
227 }
228 _ => KeyAction::Continue,
229 }
230 }
231
232 fn next(&mut self) {
233 let current = self.table_state.selected().unwrap_or(0);
234 let max_rows = self.row_to_match.len();
235
236 let mut next_row = current + 1;
238 loop {
239 if next_row >= max_rows {
240 next_row = 0;
241 }
242 if self.row_to_match.get(next_row).and_then(|&x| x).is_some() {
243 break;
244 }
245 next_row += 1;
246 if next_row == current {
247 break; }
249 }
250
251 self.table_state.select(Some(next_row));
252 self.scroll_state = self.scroll_state.position(next_row);
253 }
254
255 fn previous(&mut self) {
256 let current = self.table_state.selected().unwrap_or(0);
257 let max_rows = self.row_to_match.len();
258
259 let mut prev_row = if current == 0 {
261 max_rows - 1
262 } else {
263 current - 1
264 };
265 loop {
266 if self.row_to_match.get(prev_row).and_then(|&x| x).is_some() {
267 break;
268 }
269 if prev_row == 0 {
270 prev_row = max_rows - 1;
271 } else {
272 prev_row -= 1;
273 }
274 if prev_row == current {
275 break; }
277 }
278
279 self.table_state.select(Some(prev_row));
280 self.scroll_state = self.scroll_state.position(prev_row);
281 }
282
283 fn toggle_current(&mut self) {
284 if let Some(row_idx) = self.table_state.selected() {
285 if let Some(Some(match_idx)) = self.row_to_match.get(row_idx) {
286 self.selected[*match_idx] = !self.selected[*match_idx];
287 self.next();
289 }
290 }
291 }
292
293 fn toggle_all(&mut self) {
294 let all_selected = self.selected.iter().all(|&s| s);
295 for s in &mut self.selected {
296 *s = !all_selected;
297 }
298 }
299
300 fn next_file(&mut self) {
301 if self.file_list.is_empty() {
302 return;
303 }
304 self.current_file_index = (self.current_file_index + 1) % self.file_list.len();
305 self.table_state.select(Some(0));
307 self.scroll_state = self.scroll_state.position(0);
308 }
309
310 fn previous_file(&mut self) {
311 if self.file_list.is_empty() {
312 return;
313 }
314 if self.current_file_index == 0 {
315 self.current_file_index = self.file_list.len() - 1;
316 } else {
317 self.current_file_index -= 1;
318 }
319 self.table_state.select(Some(0));
321 self.scroll_state = self.scroll_state.position(0);
322 }
323
324 fn ui(&mut self, f: &mut Frame) {
325 let area = f.area();
326
327 let chunks = Layout::vertical([
329 Constraint::Min(3), Constraint::Length(1), ])
332 .split(area);
333
334 let current_file = if !self.file_list.is_empty() {
336 Some(&self.file_list[self.current_file_index])
337 } else {
338 None
339 };
340
341 let filtered_matches: Vec<(usize, &Match)> = self
343 .matches
344 .iter()
345 .enumerate()
346 .filter(|(_, m)| {
347 if let Some(cf) = current_file {
348 &m.file_path == cf
349 } else {
350 true
351 }
352 })
353 .collect();
354
355 let mut rows: Vec<Row> = Vec::new();
357 let mut row_to_match: Vec<Option<usize>> = Vec::new();
358
359 for (original_idx, m) in filtered_matches.iter() {
360 let checkbox = if self.selected[*original_idx] {
361 "[✓] "
362 } else {
363 "[ ] "
364 };
365
366 let line_display = if m.line_number == m.end_line_number {
367 format!("{}", m.line_number)
368 } else {
369 format!("{}-{}", m.line_number, m.end_line_number)
370 };
371
372 if m.multiline_content.len() > 1 {
374 rows.push(Row::new(vec![
376 checkbox.to_string(),
377 line_display.clone(),
378 m.multiline_content[0].trim().to_string(),
379 ]));
380 row_to_match.push(Some(*original_idx));
381
382 for line in &m.multiline_content[1..] {
384 rows.push(Row::new(vec![
385 " ".to_string(), "...".to_string(), line.trim().to_string(),
388 ]));
389 row_to_match.push(None); }
391 } else {
392 rows.push(Row::new(vec![
394 checkbox.to_string(),
395 line_display,
396 m.line_content.trim().to_string(),
397 ]));
398 row_to_match.push(Some(*original_idx));
399 }
400 }
401
402 self.row_to_match = row_to_match;
404
405 let selected_count = self.selected.iter().filter(|&&s| s).count();
406 let total = self.matches.len();
407 let current_pos = self.table_state.selected().unwrap_or(0) + 1;
408 let filtered_count = filtered_matches.len();
409 let total_rows = rows.len(); let selected_row = self.table_state.selected().unwrap_or(0);
413 let table_height = chunks[0].height.saturating_sub(3) as usize; let mut continuation_lines = 0;
417 for i in (selected_row + 1)..total_rows {
418 if self.row_to_match.get(i).and_then(|&x| x).is_none() {
419 continuation_lines += 1;
420 } else {
421 break;
422 }
423 }
424
425 let current_offset = self.table_state.offset();
427 let last_visible_row = current_offset + table_height.saturating_sub(1);
428 let needed_last_row = selected_row + continuation_lines;
429
430 if needed_last_row > last_visible_row {
431 let new_offset = needed_last_row.saturating_sub(table_height.saturating_sub(1));
433 *self.table_state.offset_mut() = new_offset;
434 } else if selected_row < current_offset {
435 *self.table_state.offset_mut() = selected_row;
437 }
438
439 let title = if let Some(cf) = current_file {
440 format!(
441 " {} / {} selected | {} / {} | File {}/{}: {} ",
442 selected_count,
443 total,
444 current_pos,
445 filtered_count,
446 self.current_file_index + 1,
447 self.file_list.len(),
448 cf.display()
449 )
450 } else {
451 format!(
452 " {} / {} selected | {} / {} ",
453 selected_count, total, current_pos, total
454 )
455 };
456
457 let table = Table::new(
458 rows,
459 [
460 Constraint::Length(4), Constraint::Length(8), Constraint::Min(20), ],
464 )
465 .header(
466 Row::new(vec![" ", "LINE", "CODE"])
467 .style(
468 Style::default()
469 .fg(Color::Yellow)
470 .add_modifier(Modifier::BOLD),
471 )
472 .bottom_margin(0),
473 )
474 .block(Block::default().borders(Borders::ALL).title(title))
475 .row_highlight_style(
476 Style::default()
477 .bg(Color::DarkGray)
478 .add_modifier(Modifier::BOLD),
479 )
480 .highlight_spacing(HighlightSpacing::Always)
481 .column_spacing(0); f.render_stateful_widget(table, chunks[0], &mut self.table_state);
484
485 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
487 .begin_symbol(Some("↑"))
488 .end_symbol(Some("↓"));
489
490 let mut scrollbar_state =
492 ScrollbarState::new(total_rows).position(self.table_state.selected().unwrap_or(0));
493 f.render_stateful_widget(
494 scrollbar,
495 chunks[0].inner(ratatui::layout::Margin {
496 vertical: 1,
497 horizontal: 0,
498 }),
499 &mut scrollbar_state,
500 );
501
502 let help = Line::from(vec![
504 Span::styled("↑/k", Style::default().fg(Color::Cyan)),
505 Span::raw(" up | "),
506 Span::styled("↓/j", Style::default().fg(Color::Cyan)),
507 Span::raw(" down | "),
508 Span::styled("←/h", Style::default().fg(Color::Cyan)),
509 Span::raw(" prev file | "),
510 Span::styled("→/l", Style::default().fg(Color::Cyan)),
511 Span::raw(" next file | "),
512 Span::styled("Space/Tab", Style::default().fg(Color::Cyan)),
513 Span::raw(" toggle | "),
514 Span::styled("a", Style::default().fg(Color::Cyan)),
515 Span::raw(" all | "),
516 Span::styled("Enter", Style::default().fg(Color::Green)),
517 Span::raw(" confirm | "),
518 Span::styled("Esc/q/Ctrl-C", Style::default().fg(Color::Red)),
519 Span::raw(" cancel"),
520 ]);
521
522 f.render_widget(
523 ratatui::widgets::Paragraph::new(help).alignment(ratatui::layout::Alignment::Right),
524 chunks[1],
525 );
526 }
527}
528
529enum KeyAction {
530 Continue,
531 Quit,
532 Confirm,
533}