1use 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
16pub struct CommandHistoryUI {
18 pub visible: bool,
20
21 search_query: String,
23
24 selected_index: Option<usize>,
26
27 cached_entries: Vec<CommandHistoryEntry>,
29
30 matcher: SkimMatcherV2,
32
33 request_focus: bool,
35}
36
37#[derive(Debug, Clone)]
39pub enum CommandHistoryAction {
40 None,
42 Insert(String),
44}
45
46impl Default for CommandHistoryUI {
47 fn default() -> Self {
48 Self::new()
49 }
50}
51
52struct MatchedEntry {
54 index: usize,
55 score: i64,
56 indices: Vec<usize>,
57}
58
59impl CommandHistoryUI {
60 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 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 pub fn close(&mut self) {
86 self.visible = false;
87 self.search_query.clear();
88 self.selected_index = None;
89 }
90
91 pub fn toggle(&mut self) {
93 if self.visible {
94 self.close();
95 } else {
96 self.open();
97 }
98 }
99
100 pub fn update_entries(&mut self, entries: &VecDeque<CommandHistoryEntry>) {
102 self.cached_entries = entries.iter().cloned().collect();
103 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 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 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 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 fn get_matched_entries(&self) -> Vec<MatchedEntry> {
147 if self.search_query.is_empty() {
148 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 matches.sort_by_key(|m| std::cmp::Reverse(m.score));
178 matches
179 }
180
181 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 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 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 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 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 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 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 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 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 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
325fn 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 let status_color = match exit_code {
337 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), };
341 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 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); let chars: Vec<char> = command.chars().collect();
367 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 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 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
426fn 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
439fn 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}