flop_cli/
ui.rs

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    // Display all matches grouped by file
26    println!("\nFound {} debug statement(s):\n", matches.len());
27
28    // Group matches by file
29    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    // Sort files by path for consistent display
36    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        // Display filename in color (magenta like ripgrep)
41        println!("\x1b[35m{}\x1b[0m", file_path.display());
42
43        // Sort matches by line number
44        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            // Line number in green, followed by colon and content with highlighted debug keyword
49            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!(); // Empty line between files
59    }
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    // Highlight "debug" or "DEBUG" keywords in red
75    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>,              // Track which items are selected
84    row_to_match: Vec<Option<usize>>, // Maps table row index to match index (None for separators)
85    file_list: Vec<PathBuf>,          // List of unique files
86    current_file_index: usize,        // Index of currently displayed file
87}
88
89impl App {
90    fn new(matches: Vec<Match>) -> Self {
91        let selected = vec![false; matches.len()];
92
93        // Build unique file list
94        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(), // Will be populated in ui()
114            file_list,
115            current_file_index: 0,
116        }
117    }
118
119    fn run(&mut self) -> Result<Vec<Match>> {
120        // Setup terminal with inline viewport (keeps CLI history)
121        enable_raw_mode()?;
122        let stdout = io::stdout();
123        let backend = CrosstermBackend::new(stdout);
124
125        // Calculate height based on the file with most table rows (including multiline expansion)
126        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)) // Count actual lines
134                    .sum::<usize>()
135            })
136            .max()
137            .unwrap_or(self.matches.len());
138
139        // Get terminal size to calculate reasonable height
140        let terminal_size = crossterm::terminal::size().unwrap_or((80, 24));
141        let terminal_height = terminal_size.1;
142
143        // Use inline mode to preserve terminal history
144        // +6 for borders, headers, and help text
145        // Use up to 80% of terminal height or max needed rows, whichever is smaller
146        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        // Restore terminal (no need for LeaveAlternateScreen in inline mode)
159        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        // Find next selectable row (not a separator)
237        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; // Avoid infinite loop
248            }
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        // Find previous selectable row (not a separator)
260        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; // Avoid infinite loop
276            }
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                // Move to next item after toggling
288                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        // Reset cursor to first row of new file
306        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        // Reset cursor to first row of new file
320        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        // Create layout
328        let chunks = Layout::vertical([
329            Constraint::Min(3),    // Table
330            Constraint::Length(1), // Help text
331        ])
332        .split(area);
333
334        // Get current file to filter by
335        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        // Filter matches to only show current file
342        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        // Create table rows (no file separators needed when showing single file)
356        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            // Display multiline content if available
373            if m.multiline_content.len() > 1 {
374                // First line - selectable
375                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                // Continuation lines - not selectable
383                for line in &m.multiline_content[1..] {
384                    rows.push(Row::new(vec![
385                        "    ".to_string(), // No checkbox
386                        "...".to_string(),  // Continuation marker
387                        line.trim().to_string(),
388                    ]));
389                    row_to_match.push(None); // Not selectable
390                }
391            } else {
392                // Single line
393                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        // Store mapping for navigation
403        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(); // Store before moving rows
410
411        // Adjust scroll offset to ensure selected row and its continuation lines are visible
412        let selected_row = self.table_state.selected().unwrap_or(0);
413        let table_height = chunks[0].height.saturating_sub(3) as usize; // Subtract borders and header
414
415        // Find how many continuation lines follow the selected row
416        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        // Calculate offset to keep selected row and its continuations visible
426        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            // Need to scroll down to show continuation lines
432            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            // Need to scroll up to show selected row
436            *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), // Checkbox + space
461                Constraint::Length(8), // Line (increased to accommodate ranges like "10-15")
462                Constraint::Min(20),   // Code
463            ],
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); // Remove spacing between columns
482
483        f.render_stateful_widget(table, chunks[0], &mut self.table_state);
484
485        // Render scrollbar
486        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
487            .begin_symbol(Some("↑"))
488            .end_symbol(Some("↓"));
489
490        // Update scrollbar to reflect actual number of rows (including multiline expansion)
491        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        // Help text
503        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}