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            println!("\x1b[32m{}\x1b[0m:{}", m.line_number, highlighted.trim());
51        }
52
53        println!(); // Empty line between files
54    }
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    // Highlight "debug" or "DEBUG" keywords in red
70    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>,              // Track which items are selected
79    row_to_match: Vec<Option<usize>>, // Maps table row index to match index (None for separators)
80    file_list: Vec<PathBuf>,          // List of unique files
81    current_file_index: usize,        // Index of currently displayed file
82}
83
84impl App {
85    fn new(matches: Vec<Match>) -> Self {
86        let selected = vec![false; matches.len()];
87
88        // Build unique file list
89        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(), // Will be populated in ui()
109            file_list,
110            current_file_index: 0,
111        }
112    }
113
114    fn run(&mut self) -> Result<Vec<Match>> {
115        // Setup terminal with inline viewport (keeps CLI history)
116        enable_raw_mode()?;
117        let stdout = io::stdout();
118        let backend = CrosstermBackend::new(stdout);
119
120        // Calculate height based on the file with most matches
121        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        // Use inline mode to preserve terminal history
129        let height = (max_matches_per_file as u16 + 6).min(30); // +6 for borders and headers
130        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        // Restore terminal (no need for LeaveAlternateScreen in inline mode)
140        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        // Find next selectable row (not a separator)
218        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; // Avoid infinite loop
229            }
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        // Find previous selectable row (not a separator)
241        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; // Avoid infinite loop
257            }
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                // Move to next item after toggling
269                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        // Reset cursor to first row of new file
287        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        // Reset cursor to first row of new file
301        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        // Create layout
309        let chunks = Layout::vertical([
310            Constraint::Min(3),    // Table
311            Constraint::Length(1), // Help text
312        ])
313        .split(area);
314
315        // Get current file to filter by
316        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        // Filter matches to only show current file
323        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        // Create table rows (no file separators needed when showing single file)
337        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)); // Map this row to original match index
353        }
354
355        // Store mapping for navigation
356        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), // Checkbox + space
385                Constraint::Length(6), // Line
386                Constraint::Min(20),   // Code
387            ],
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); // Remove spacing between columns
406
407        f.render_stateful_widget(table, chunks[0], &mut self.table_state);
408
409        // Render scrollbar
410        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        // Help text
425        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}