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