Skip to main content

par_term/
command_history_ui.rs

1//! Fuzzy command history search overlay UI.
2//!
3//! Provides a searchable popup for browsing and selecting from command history,
4//! with fuzzy matching and ranked results with match highlighting.
5
6use crate::command_history::CommandHistoryEntry;
7use crate::ui_constants::{
8    CMD_HISTORY_WINDOW_DEFAULT_HEIGHT, CMD_HISTORY_WINDOW_DEFAULT_WIDTH,
9    CMD_HISTORY_WINDOW_MAX_HEIGHT,
10};
11use egui::{Context, Window};
12use fuzzy_matcher::FuzzyMatcher;
13use fuzzy_matcher::skim::SkimMatcherV2;
14use std::collections::VecDeque;
15
16/// Command history UI manager using egui
17pub struct CommandHistoryUI {
18    /// Whether the command history window is currently visible
19    pub visible: bool,
20
21    /// Current search query
22    search_query: String,
23
24    /// Index of currently selected entry in filtered results
25    selected_index: Option<usize>,
26
27    /// Cached command history entries (refreshed when shown)
28    cached_entries: Vec<CommandHistoryEntry>,
29
30    /// Fuzzy matcher instance
31    matcher: SkimMatcherV2,
32
33    /// Whether the search input should request focus
34    request_focus: bool,
35}
36
37/// Action to take after showing the UI
38#[derive(Debug, Clone)]
39pub enum CommandHistoryAction {
40    /// No action needed
41    None,
42    /// Insert the selected command into the terminal
43    Insert(String),
44}
45
46impl Default for CommandHistoryUI {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52/// A matched entry with score and match indices for highlighting
53struct MatchedEntry {
54    index: usize,
55    score: i64,
56    indices: Vec<usize>,
57}
58
59impl CommandHistoryUI {
60    /// Create a new command history UI
61    pub fn new() -> Self {
62        Self {
63            visible: false,
64            search_query: String::new(),
65            selected_index: None,
66            cached_entries: Vec::new(),
67            matcher: SkimMatcherV2::default(),
68            request_focus: false,
69        }
70    }
71
72    /// Open the command history UI
73    pub fn open(&mut self) {
74        self.visible = true;
75        self.search_query.clear();
76        self.request_focus = true;
77        self.selected_index = if self.cached_entries.is_empty() {
78            None
79        } else {
80            Some(0)
81        };
82    }
83
84    /// Close the command history UI
85    pub fn close(&mut self) {
86        self.visible = false;
87        self.search_query.clear();
88        self.selected_index = None;
89    }
90
91    /// Toggle visibility
92    pub fn toggle(&mut self) {
93        if self.visible {
94            self.close();
95        } else {
96            self.open();
97        }
98    }
99
100    /// Update cached entries from persistent command history
101    pub fn update_entries(&mut self, entries: &VecDeque<CommandHistoryEntry>) {
102        self.cached_entries = entries.iter().cloned().collect();
103        // Reset selection if out of bounds
104        if let Some(idx) = self.selected_index
105            && idx >= self.cached_entries.len()
106        {
107            self.selected_index = if self.cached_entries.is_empty() {
108                None
109            } else {
110                Some(0)
111            };
112        }
113    }
114
115    /// Navigate selection up
116    pub fn select_previous(&mut self) {
117        if let Some(idx) = self.selected_index
118            && idx > 0
119        {
120            self.selected_index = Some(idx - 1);
121        }
122    }
123
124    /// Get the command text of the currently selected entry (if any).
125    /// Re-runs fuzzy matching to resolve the filtered index.
126    pub fn selected_command(&self) -> Option<String> {
127        let idx = self.selected_index?;
128        let matches = self.get_matched_entries();
129        matches
130            .get(idx)
131            .map(|m| self.cached_entries[m.index].command.clone())
132    }
133
134    /// Navigate selection down
135    pub fn select_next(&mut self, filtered_count: usize) {
136        if let Some(idx) = self.selected_index {
137            if idx < filtered_count.saturating_sub(1) {
138                self.selected_index = Some(idx + 1);
139            }
140        } else if filtered_count > 0 {
141            self.selected_index = Some(0);
142        }
143    }
144
145    /// Get fuzzy-matched and ranked entries based on current search query
146    fn get_matched_entries(&self) -> Vec<MatchedEntry> {
147        if self.search_query.is_empty() {
148            // No query: return all entries in order (newest first)
149            return self
150                .cached_entries
151                .iter()
152                .enumerate()
153                .map(|(i, _)| MatchedEntry {
154                    index: i,
155                    score: 0,
156                    indices: Vec::new(),
157                })
158                .collect();
159        }
160
161        let mut matches: Vec<MatchedEntry> = self
162            .cached_entries
163            .iter()
164            .enumerate()
165            .filter_map(|(i, entry)| {
166                self.matcher
167                    .fuzzy_indices(&entry.command, &self.search_query)
168                    .map(|(score, indices)| MatchedEntry {
169                        index: i,
170                        score,
171                        indices,
172                    })
173            })
174            .collect();
175
176        // Sort by score descending (best matches first)
177        matches.sort_by_key(|m| std::cmp::Reverse(m.score));
178        matches
179    }
180
181    /// Show the command history window and return any action to take
182    pub fn show(&mut self, ctx: &Context) -> CommandHistoryAction {
183        if !self.visible {
184            return CommandHistoryAction::None;
185        }
186
187        let mut action = CommandHistoryAction::None;
188        let mut open = true;
189
190        // Calculate center position for initial placement
191        let screen_rect = ctx.content_rect();
192        let default_pos = egui::pos2(
193            (screen_rect.width() - CMD_HISTORY_WINDOW_DEFAULT_WIDTH) / 2.0,
194            (screen_rect.height() - CMD_HISTORY_WINDOW_DEFAULT_HEIGHT) / 2.0,
195        );
196
197        let matched_entries = self.get_matched_entries();
198
199        Window::new("Command History Search")
200            .resizable(true)
201            .collapsible(false)
202            .default_width(CMD_HISTORY_WINDOW_DEFAULT_WIDTH)
203            .default_height(CMD_HISTORY_WINDOW_DEFAULT_HEIGHT)
204            .max_height(CMD_HISTORY_WINDOW_MAX_HEIGHT)
205            .default_pos(default_pos)
206            .open(&mut open)
207            .show(ctx, |ui| {
208                // Search bar
209                ui.horizontal(|ui| {
210                    ui.label("Search:");
211                    let response = ui.text_edit_singleline(&mut self.search_query);
212                    if self.request_focus {
213                        response.request_focus();
214                        self.request_focus = false;
215                    }
216                });
217
218                ui.separator();
219
220                // Results count
221                ui.horizontal(|ui| {
222                    ui.label(format!(
223                        "{} / {} commands",
224                        matched_entries.len(),
225                        self.cached_entries.len()
226                    ));
227                });
228
229                ui.separator();
230
231                // Entry list
232                egui::ScrollArea::vertical()
233                    .auto_shrink([false, false])
234                    .show(ui, |ui| {
235                        if matched_entries.is_empty() {
236                            ui.label("No matching commands");
237                        } else {
238                            for (filtered_idx, matched) in matched_entries.iter().enumerate() {
239                                let entry = &self.cached_entries[matched.index];
240                                let is_selected = self.selected_index == Some(filtered_idx);
241
242                                // Build highlighted text
243                                let layout_job = build_highlighted_label(
244                                    &entry.command,
245                                    &matched.indices,
246                                    is_selected,
247                                    entry.exit_code,
248                                    entry.timestamp_ms,
249                                );
250
251                                let response = ui.selectable_label(is_selected, layout_job);
252
253                                if response.clicked() {
254                                    self.selected_index = Some(filtered_idx);
255                                }
256
257                                if response.double_clicked() {
258                                    action = CommandHistoryAction::Insert(entry.command.clone());
259                                    self.visible = false;
260                                }
261
262                                // Show tooltip with full command and metadata on hover
263                                // Auto-scroll to selected item
264                                let response = response.on_hover_text(format_tooltip(entry));
265                                if is_selected {
266                                    response.scroll_to_me(Some(egui::Align::Center));
267                                }
268                            }
269                        }
270                    });
271
272                ui.separator();
273
274                // Action buttons
275                ui.horizontal(|ui| {
276                    if ui.button("Insert Selected").clicked()
277                        && let Some(idx) = self.selected_index
278                        && let Some(matched) = matched_entries.get(idx)
279                    {
280                        let entry = &self.cached_entries[matched.index];
281                        action = CommandHistoryAction::Insert(entry.command.clone());
282                        self.visible = false;
283                    }
284
285                    if ui.button("Close").clicked() {
286                        self.visible = false;
287                    }
288                });
289
290                // Keyboard hints
291                ui.separator();
292                ui.horizontal(|ui| {
293                    ui.label("Hints:");
294                    ui.label("\u{f062}\u{f063} Navigate");
295                    ui.label("Enter Insert");
296                    ui.label("Esc Close");
297                });
298            });
299
300        // Handle window close
301        if !open {
302            self.visible = false;
303        }
304
305        action
306    }
307}
308
309impl crate::traits::OverlayComponent for CommandHistoryUI {
310    type Action = CommandHistoryAction;
311
312    fn show(&mut self, ctx: &egui::Context) -> Self::Action {
313        CommandHistoryUI::show(self, ctx)
314    }
315
316    fn is_visible(&self) -> bool {
317        self.visible
318    }
319
320    fn set_visible(&mut self, visible: bool) {
321        self.visible = visible;
322    }
323}
324
325/// Build an egui LayoutJob with fuzzy match highlighting
326fn build_highlighted_label(
327    command: &str,
328    match_indices: &[usize],
329    is_selected: bool,
330    exit_code: Option<i32>,
331    timestamp_ms: u64,
332) -> egui::text::LayoutJob {
333    let mut job = egui::text::LayoutJob::default();
334
335    // Exit code indicator
336    let status_color = match exit_code {
337        Some(0) => egui::Color32::from_rgb(100, 200, 100), // Green for success
338        Some(_) => egui::Color32::from_rgb(200, 100, 100), // Red for failure
339        None => egui::Color32::from_rgb(150, 150, 150),    // Gray for unknown
340    };
341    // Use Nerd Font PUA codepoints (confirmed in SymbolsNerdFontMono-Regular):
342    // U+F05D = fa-check-circle, U+F057 = fa-times-circle
343    // Unknown exit code falls back to ASCII '?' (always renderable)
344    let status_char = match exit_code {
345        Some(0) => "\u{f05d} ",
346        Some(_) => "\u{f057} ",
347        None => "? ",
348    };
349    job.append(
350        status_char,
351        0.0,
352        egui::TextFormat {
353            color: status_color,
354            ..Default::default()
355        },
356    );
357
358    // Command text with highlighting
359    let normal_color = if is_selected {
360        egui::Color32::WHITE
361    } else {
362        egui::Color32::from_rgb(220, 220, 220)
363    };
364    let highlight_color = egui::Color32::from_rgb(255, 200, 0); // Yellow highlight
365
366    let chars: Vec<char> = command.chars().collect();
367    // Truncate display for very long commands
368    let display_len = chars.len().min(120);
369
370    let mut i = 0;
371    while i < display_len {
372        let is_match = match_indices.contains(&i);
373        let color = if is_match {
374            highlight_color
375        } else {
376            normal_color
377        };
378
379        // Batch consecutive chars with same highlight state
380        let start = i;
381        while i < display_len && match_indices.contains(&i) == is_match {
382            i += 1;
383        }
384
385        let text: String = chars[start..i].iter().collect();
386        let format = if is_match {
387            egui::TextFormat {
388                color,
389                underline: egui::Stroke::new(1.0, highlight_color),
390                ..Default::default()
391            }
392        } else {
393            egui::TextFormat {
394                color,
395                ..Default::default()
396            }
397        };
398        job.append(&text, 0.0, format);
399    }
400
401    if chars.len() > 120 {
402        job.append(
403            "...",
404            0.0,
405            egui::TextFormat {
406                color: egui::Color32::GRAY,
407                ..Default::default()
408            },
409        );
410    }
411
412    // Timestamp suffix
413    let time_str = format_relative_time(timestamp_ms);
414    job.append(
415        &format!("  {time_str}"),
416        0.0,
417        egui::TextFormat {
418            color: egui::Color32::from_rgb(120, 120, 120),
419            ..Default::default()
420        },
421    );
422
423    job
424}
425
426/// Format a tooltip with full command details
427fn format_tooltip(entry: &CommandHistoryEntry) -> String {
428    let mut parts = vec![entry.command.clone()];
429    if let Some(code) = entry.exit_code {
430        parts.push(format!("Exit: {code}"));
431    }
432    if let Some(ms) = entry.duration_ms {
433        parts.push(format!("Duration: {}ms", ms));
434    }
435    parts.push(format_relative_time(entry.timestamp_ms));
436    parts.join("\n")
437}
438
439/// Format a timestamp as relative time (e.g., "5m ago")
440fn format_relative_time(timestamp_ms: u64) -> String {
441    use std::time::{Duration, SystemTime, UNIX_EPOCH};
442
443    let time = UNIX_EPOCH + Duration::from_millis(timestamp_ms);
444    if let Ok(elapsed) = SystemTime::now().duration_since(time) {
445        let secs = elapsed.as_secs();
446        if secs < 60 {
447            format!("{secs}s ago")
448        } else if secs < 3600 {
449            format!("{}m ago", secs / 60)
450        } else if secs < 86400 {
451            format!("{}h ago", secs / 3600)
452        } else {
453            format!("{}d ago", secs / 86400)
454        }
455    } else {
456        "just now".to_string()
457    }
458}