1use crate::command_history::CommandHistoryEntry;
7use egui::{Context, Window};
8use fuzzy_matcher::FuzzyMatcher;
9use fuzzy_matcher::skim::SkimMatcherV2;
10use std::collections::VecDeque;
11
12pub struct CommandHistoryUI {
14 pub visible: bool,
16
17 search_query: String,
19
20 selected_index: Option<usize>,
22
23 cached_entries: Vec<CommandHistoryEntry>,
25
26 matcher: SkimMatcherV2,
28
29 request_focus: bool,
31}
32
33#[derive(Debug, Clone)]
35pub enum CommandHistoryAction {
36 None,
38 Insert(String),
40}
41
42impl Default for CommandHistoryUI {
43 fn default() -> Self {
44 Self::new()
45 }
46}
47
48struct MatchedEntry {
50 index: usize,
51 score: i64,
52 indices: Vec<usize>,
53}
54
55impl CommandHistoryUI {
56 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 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 pub fn close(&mut self) {
82 self.visible = false;
83 self.search_query.clear();
84 self.selected_index = None;
85 }
86
87 pub fn toggle(&mut self) {
89 if self.visible {
90 self.close();
91 } else {
92 self.open();
93 }
94 }
95
96 pub fn update_entries(&mut self, entries: &VecDeque<CommandHistoryEntry>) {
98 self.cached_entries = entries.iter().cloned().collect();
99 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 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 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 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 fn get_matched_entries(&self) -> Vec<MatchedEntry> {
143 if self.search_query.is_empty() {
144 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 matches.sort_by(|a, b| b.score.cmp(&a.score));
174 matches
175 }
176
177 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 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 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 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 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 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 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 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 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 if !open {
298 self.visible = false;
299 }
300
301 action
302 }
303}
304
305fn 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 let status_color = match exit_code {
317 Some(0) => egui::Color32::from_rgb(100, 200, 100), Some(_) => egui::Color32::from_rgb(200, 100, 100), None => egui::Color32::from_rgb(150, 150, 150), };
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 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); let chars: Vec<char> = command.chars().collect();
344 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 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 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
403fn 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
416fn 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}