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 println!("\x1b[32m{}\x1b[0m:{}", m.line_number, highlighted.trim());
51 }
52
53 println!(); }
55}
56
57pub fn select_statements_interactive(matches: &[Match]) -> Result<Vec<Match>> {
58 if matches.is_empty() {
59 return Ok(vec![]);
60 }
61
62 let mut app = App::new(matches.to_vec());
63 let selected = app.run()?;
64
65 Ok(selected)
66}
67
68fn highlight_debug_keyword(line: &str) -> String {
69 let re = Regex::new(r"(debug|DEBUG)").unwrap();
71 re.replace_all(line, "\x1b[1;31m$1\x1b[0m").to_string()
72}
73
74struct App {
75 matches: Vec<Match>,
76 table_state: TableState,
77 scroll_state: ScrollbarState,
78 selected: Vec<bool>, row_to_match: Vec<Option<usize>>, file_list: Vec<PathBuf>, current_file_index: usize, }
83
84impl App {
85 fn new(matches: Vec<Match>) -> Self {
86 let selected = vec![false; matches.len()];
87
88 let mut file_list = Vec::new();
90 let mut seen_files = std::collections::HashSet::new();
91 for m in &matches {
92 if seen_files.insert(m.file_path.clone()) {
93 file_list.push(m.file_path.clone());
94 }
95 }
96
97 let scroll_state = ScrollbarState::new(matches.len());
98 let mut table_state = TableState::default();
99 if !matches.is_empty() {
100 table_state.select(Some(0));
101 }
102
103 Self {
104 matches,
105 table_state,
106 scroll_state,
107 selected,
108 row_to_match: Vec::new(), file_list,
110 current_file_index: 0,
111 }
112 }
113
114 fn run(&mut self) -> Result<Vec<Match>> {
115 enable_raw_mode()?;
117 let stdout = io::stdout();
118 let backend = CrosstermBackend::new(stdout);
119
120 let max_matches_per_file = self
122 .file_list
123 .iter()
124 .map(|file| self.matches.iter().filter(|m| &m.file_path == file).count())
125 .max()
126 .unwrap_or(self.matches.len());
127
128 let height = (max_matches_per_file as u16 + 6).min(30); let mut terminal = Terminal::with_options(
131 backend,
132 ratatui::TerminalOptions {
133 viewport: ratatui::Viewport::Inline(height),
134 },
135 )?;
136
137 let result = self.run_app(&mut terminal);
138
139 disable_raw_mode()?;
141 terminal.show_cursor()?;
142
143 result
144 }
145
146 fn run_app(
147 &mut self,
148 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
149 ) -> Result<Vec<Match>> {
150 loop {
151 terminal.draw(|f| self.ui(f))?;
152
153 if let Event::Key(key) = event::read()? {
154 if key.kind == KeyEventKind::Press {
155 match self.handle_key(key) {
156 KeyAction::Quit => return Ok(vec![]),
157 KeyAction::Confirm => {
158 let selected: Vec<Match> = self
159 .matches
160 .iter()
161 .enumerate()
162 .filter(|(i, _)| self.selected[*i])
163 .map(|(_, m)| m.clone())
164 .collect();
165 return Ok(selected);
166 }
167 KeyAction::Continue => {}
168 }
169 }
170 }
171 }
172 }
173
174 fn handle_key(&mut self, key: KeyEvent) -> KeyAction {
175 match key.code {
176 KeyCode::Char('q') | KeyCode::Esc => KeyAction::Quit,
177 KeyCode::Char('c')
178 if key
179 .modifiers
180 .contains(crossterm::event::KeyModifiers::CONTROL) =>
181 {
182 KeyAction::Quit
183 }
184 KeyCode::Enter => KeyAction::Confirm,
185 KeyCode::Down | KeyCode::Char('j') => {
186 self.next();
187 KeyAction::Continue
188 }
189 KeyCode::Up | KeyCode::Char('k') => {
190 self.previous();
191 KeyAction::Continue
192 }
193 KeyCode::Left | KeyCode::Char('h') => {
194 self.previous_file();
195 KeyAction::Continue
196 }
197 KeyCode::Right | KeyCode::Char('l') => {
198 self.next_file();
199 KeyAction::Continue
200 }
201 KeyCode::Tab | KeyCode::Char(' ') => {
202 self.toggle_current();
203 KeyAction::Continue
204 }
205 KeyCode::Char('a') => {
206 self.toggle_all();
207 KeyAction::Continue
208 }
209 _ => KeyAction::Continue,
210 }
211 }
212
213 fn next(&mut self) {
214 let current = self.table_state.selected().unwrap_or(0);
215 let max_rows = self.row_to_match.len();
216
217 let mut next_row = current + 1;
219 loop {
220 if next_row >= max_rows {
221 next_row = 0;
222 }
223 if self.row_to_match.get(next_row).and_then(|&x| x).is_some() {
224 break;
225 }
226 next_row += 1;
227 if next_row == current {
228 break; }
230 }
231
232 self.table_state.select(Some(next_row));
233 self.scroll_state = self.scroll_state.position(next_row);
234 }
235
236 fn previous(&mut self) {
237 let current = self.table_state.selected().unwrap_or(0);
238 let max_rows = self.row_to_match.len();
239
240 let mut prev_row = if current == 0 {
242 max_rows - 1
243 } else {
244 current - 1
245 };
246 loop {
247 if self.row_to_match.get(prev_row).and_then(|&x| x).is_some() {
248 break;
249 }
250 if prev_row == 0 {
251 prev_row = max_rows - 1;
252 } else {
253 prev_row -= 1;
254 }
255 if prev_row == current {
256 break; }
258 }
259
260 self.table_state.select(Some(prev_row));
261 self.scroll_state = self.scroll_state.position(prev_row);
262 }
263
264 fn toggle_current(&mut self) {
265 if let Some(row_idx) = self.table_state.selected() {
266 if let Some(Some(match_idx)) = self.row_to_match.get(row_idx) {
267 self.selected[*match_idx] = !self.selected[*match_idx];
268 self.next();
270 }
271 }
272 }
273
274 fn toggle_all(&mut self) {
275 let all_selected = self.selected.iter().all(|&s| s);
276 for s in &mut self.selected {
277 *s = !all_selected;
278 }
279 }
280
281 fn next_file(&mut self) {
282 if self.file_list.is_empty() {
283 return;
284 }
285 self.current_file_index = (self.current_file_index + 1) % self.file_list.len();
286 self.table_state.select(Some(0));
288 self.scroll_state = self.scroll_state.position(0);
289 }
290
291 fn previous_file(&mut self) {
292 if self.file_list.is_empty() {
293 return;
294 }
295 if self.current_file_index == 0 {
296 self.current_file_index = self.file_list.len() - 1;
297 } else {
298 self.current_file_index -= 1;
299 }
300 self.table_state.select(Some(0));
302 self.scroll_state = self.scroll_state.position(0);
303 }
304
305 fn ui(&mut self, f: &mut Frame) {
306 let area = f.area();
307
308 let chunks = Layout::vertical([
310 Constraint::Min(3), Constraint::Length(1), ])
313 .split(area);
314
315 let current_file = if !self.file_list.is_empty() {
317 Some(&self.file_list[self.current_file_index])
318 } else {
319 None
320 };
321
322 let filtered_matches: Vec<(usize, &Match)> = self
324 .matches
325 .iter()
326 .enumerate()
327 .filter(|(_, m)| {
328 if let Some(cf) = current_file {
329 &m.file_path == cf
330 } else {
331 true
332 }
333 })
334 .collect();
335
336 let mut rows: Vec<Row> = Vec::new();
338 let mut row_to_match: Vec<Option<usize>> = Vec::new();
339
340 for (original_idx, m) in filtered_matches.iter() {
341 let checkbox = if self.selected[*original_idx] {
342 "[✓] "
343 } else {
344 "[ ] "
345 };
346
347 rows.push(Row::new(vec![
348 checkbox.to_string(),
349 m.line_number.to_string(),
350 m.line_content.trim().to_string(),
351 ]));
352 row_to_match.push(Some(*original_idx)); }
354
355 self.row_to_match = row_to_match;
357
358 let selected_count = self.selected.iter().filter(|&&s| s).count();
359 let total = self.matches.len();
360 let current_pos = self.table_state.selected().unwrap_or(0) + 1;
361 let filtered_count = filtered_matches.len();
362
363 let title = if let Some(cf) = current_file {
364 format!(
365 " {} / {} selected | {} / {} | File {}/{}: {} ",
366 selected_count,
367 total,
368 current_pos,
369 filtered_count,
370 self.current_file_index + 1,
371 self.file_list.len(),
372 cf.display()
373 )
374 } else {
375 format!(
376 " {} / {} selected | {} / {} ",
377 selected_count, total, current_pos, total
378 )
379 };
380
381 let table = Table::new(
382 rows,
383 [
384 Constraint::Length(4), Constraint::Length(6), Constraint::Min(20), ],
388 )
389 .header(
390 Row::new(vec![" ", "LINE", "CODE"])
391 .style(
392 Style::default()
393 .fg(Color::Yellow)
394 .add_modifier(Modifier::BOLD),
395 )
396 .bottom_margin(0),
397 )
398 .block(Block::default().borders(Borders::ALL).title(title))
399 .row_highlight_style(
400 Style::default()
401 .bg(Color::DarkGray)
402 .add_modifier(Modifier::BOLD),
403 )
404 .highlight_spacing(HighlightSpacing::Always)
405 .column_spacing(0); f.render_stateful_widget(table, chunks[0], &mut self.table_state);
408
409 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
411 .begin_symbol(Some("↑"))
412 .end_symbol(Some("↓"));
413
414 let mut scrollbar_state = self.scroll_state;
415 f.render_stateful_widget(
416 scrollbar,
417 chunks[0].inner(ratatui::layout::Margin {
418 vertical: 1,
419 horizontal: 0,
420 }),
421 &mut scrollbar_state,
422 );
423
424 let help = Line::from(vec![
426 Span::styled("↑/k", Style::default().fg(Color::Cyan)),
427 Span::raw(" up | "),
428 Span::styled("↓/j", Style::default().fg(Color::Cyan)),
429 Span::raw(" down | "),
430 Span::styled("←/h", Style::default().fg(Color::Cyan)),
431 Span::raw(" prev file | "),
432 Span::styled("→/l", Style::default().fg(Color::Cyan)),
433 Span::raw(" next file | "),
434 Span::styled("Space/Tab", Style::default().fg(Color::Cyan)),
435 Span::raw(" toggle | "),
436 Span::styled("a", Style::default().fg(Color::Cyan)),
437 Span::raw(" all | "),
438 Span::styled("Enter", Style::default().fg(Color::Green)),
439 Span::raw(" confirm | "),
440 Span::styled("Esc/q/Ctrl-C", Style::default().fg(Color::Red)),
441 Span::raw(" cancel"),
442 ]);
443
444 f.render_widget(
445 ratatui::widgets::Paragraph::new(help).alignment(ratatui::layout::Alignment::Right),
446 chunks[1],
447 );
448 }
449}
450
451enum KeyAction {
452 Continue,
453 Quit,
454 Confirm,
455}