1use crate::history::{CommandHistory, HistoryMatch};
2use crate::widget_traits::DebugInfoProvider;
3use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
4use fuzzy_matcher::skim::SkimMatcherV2;
5use fuzzy_matcher::FuzzyMatcher;
6use ratatui::{
7 layout::{Constraint, Direction, Layout, Rect},
8 style::{Color, Modifier, Style},
9 text::{Line, Span},
10 widgets::{Block, Borders, List, Paragraph, Wrap},
11 Frame,
12};
13
14#[derive(Clone)]
16pub struct HistoryState {
17 pub search_query: String,
18 pub matches: Vec<HistoryMatch>,
19 pub selected_index: usize,
20}
21
22pub struct HistoryWidget {
24 command_history: CommandHistory,
25 state: HistoryState,
26 fuzzy_matcher: SkimMatcherV2,
27}
28
29impl HistoryWidget {
30 pub fn new(command_history: CommandHistory) -> Self {
31 Self {
32 command_history,
33 state: HistoryState {
34 search_query: String::new(),
35 matches: Vec::new(),
36 selected_index: 0,
37 },
38 fuzzy_matcher: SkimMatcherV2::default(),
39 }
40 }
41
42 pub fn initialize(&mut self) {
44 self.state.search_query.clear();
45 self.state.matches = self
46 .command_history
47 .get_all()
48 .iter()
49 .cloned()
50 .map(|entry| HistoryMatch {
51 entry,
52 indices: Vec::new(),
53 score: 0,
54 })
55 .collect();
56 self.state.selected_index = 0;
57 }
58
59 pub fn update_search(&mut self, query: String) {
61 self.state.search_query = query;
62
63 if self.state.search_query.is_empty() {
64 self.state.matches = self
66 .command_history
67 .get_all()
68 .iter()
69 .cloned()
70 .map(|entry| HistoryMatch {
71 entry,
72 indices: Vec::new(),
73 score: 0,
74 })
75 .collect();
76 } else {
77 let mut matches: Vec<HistoryMatch> = self
79 .command_history
80 .get_all()
81 .iter()
82 .cloned()
83 .filter_map(|entry| {
84 self.fuzzy_matcher
85 .fuzzy_indices(&entry.command, &self.state.search_query)
86 .map(|(score, indices)| HistoryMatch {
87 entry,
88 score,
89 indices,
90 })
91 })
92 .collect();
93
94 matches.sort_by(|a, b| b.score.cmp(&a.score));
96 self.state.matches = matches;
97 }
98
99 self.state.selected_index = 0;
100 }
101
102 pub fn handle_key(&mut self, key: KeyEvent) -> HistoryAction {
104 match key.code {
105 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
106 HistoryAction::Quit
107 }
108 KeyCode::Esc => HistoryAction::Exit,
109 KeyCode::Up | KeyCode::Char('k') => {
110 if self.state.selected_index > 0 {
111 self.state.selected_index -= 1;
112 }
113 HistoryAction::None
114 }
115 KeyCode::Down | KeyCode::Char('j') => {
116 if self.state.selected_index < self.state.matches.len().saturating_sub(1) {
117 self.state.selected_index += 1;
118 }
119 HistoryAction::None
120 }
121 KeyCode::PageUp => {
122 self.state.selected_index = self.state.selected_index.saturating_sub(10);
123 HistoryAction::None
124 }
125 KeyCode::PageDown => {
126 let max_index = self.state.matches.len().saturating_sub(1);
127 self.state.selected_index = (self.state.selected_index + 10).min(max_index);
128 HistoryAction::None
129 }
130 KeyCode::Home | KeyCode::Char('g') => {
131 self.state.selected_index = 0;
132 HistoryAction::None
133 }
134 KeyCode::End | KeyCode::Char('G') if key.modifiers.contains(KeyModifiers::SHIFT) => {
135 self.state.selected_index = self.state.matches.len().saturating_sub(1);
136 HistoryAction::None
137 }
138 KeyCode::Enter => {
139 if let Some(selected_match) = self.state.matches.get(self.state.selected_index) {
140 HistoryAction::ExecuteCommand(selected_match.entry.command.clone())
141 } else {
142 HistoryAction::None
143 }
144 }
145 KeyCode::Tab => {
146 if let Some(selected_match) = self.state.matches.get(self.state.selected_index) {
147 HistoryAction::UseCommand(selected_match.entry.command.clone())
148 } else {
149 HistoryAction::None
150 }
151 }
152 KeyCode::Char('/') => HistoryAction::StartSearch,
157 KeyCode::Char(c) => {
158 self.state.search_query.push(c);
159 self.update_search(self.state.search_query.clone());
160 HistoryAction::None
161 }
162 KeyCode::Backspace => {
163 self.state.search_query.pop();
164 self.update_search(self.state.search_query.clone());
165 HistoryAction::None
166 }
167 _ => HistoryAction::None,
168 }
169 }
170
171 pub fn render(&self, f: &mut Frame, area: Rect) {
173 if self.state.matches.is_empty() {
174 self.render_empty_state(f, area);
175 return;
176 }
177
178 let chunks = Layout::default()
180 .direction(Direction::Vertical)
181 .constraints([
182 Constraint::Percentage(50), Constraint::Percentage(50), ])
185 .split(area);
186
187 self.render_history_list(f, chunks[0]);
188 self.render_selected_command_preview(f, chunks[1]);
189 }
190
191 fn render_empty_state(&self, f: &mut Frame, area: Rect) {
192 let message = if self.state.search_query.is_empty() {
193 "No command history found.\nExecute some queries to build history."
194 } else {
195 "No matches found for your search.\nTry a different search term."
196 };
197
198 let placeholder = Paragraph::new(message)
199 .block(
200 Block::default()
201 .borders(Borders::ALL)
202 .title("Command History"),
203 )
204 .style(Style::default().fg(Color::DarkGray));
205 f.render_widget(placeholder, area);
206 }
207
208 fn render_history_list(&self, f: &mut Frame, area: Rect) {
209 let history_items: Vec<Line> = self
210 .state
211 .matches
212 .iter()
213 .enumerate()
214 .map(|(i, history_match)| {
215 let entry = &history_match.entry;
216 let is_selected = i == self.state.selected_index;
217
218 let success_indicator = if entry.success { "✓" } else { "✗" };
219 let time_ago = self.format_time_ago(&entry.timestamp);
220
221 let terminal_width = area.width as usize;
223 let metadata_space = 15;
224 let available_for_command = terminal_width.saturating_sub(metadata_space).max(50);
225
226 let command_text = if entry.command.len() > available_for_command {
227 format!(
228 "{}…",
229 &entry.command[..available_for_command.saturating_sub(1)]
230 )
231 } else {
232 entry.command.clone()
233 };
234
235 let line_text = format!(
236 "{} {} {} {}x {}",
237 if is_selected { "►" } else { " " },
238 command_text,
239 success_indicator,
240 entry.execution_count,
241 time_ago
242 );
243
244 let mut style = Style::default();
245 if is_selected {
246 style = style.bg(Color::DarkGray).add_modifier(Modifier::BOLD);
247 }
248 if !entry.success {
249 style = style.fg(Color::Red);
250 }
251
252 if !history_match.indices.is_empty() && is_selected {
254 style = style.fg(Color::Yellow);
255 }
256
257 Line::from(vec![Span::styled(line_text, style)])
258 })
259 .collect();
260
261 let title = if self.state.search_query.is_empty() {
262 "Command History (↑/↓ navigate, Enter to execute, Tab to edit, / to search)"
263 } else {
264 "History Search (Esc to clear search)"
265 };
266
267 let history_list = List::new(history_items)
268 .block(Block::default().borders(Borders::ALL).title(title))
269 .style(Style::default().fg(Color::White));
270
271 f.render_widget(history_list, area);
272 }
273
274 fn render_selected_command_preview(&self, f: &mut Frame, area: Rect) {
275 if let Some(selected_match) = self.state.matches.get(self.state.selected_index) {
276 let entry = &selected_match.entry;
277
278 let metadata = [
279 format!("Executed: {}", entry.timestamp.format("%Y-%m-%d %H:%M:%S")),
280 format!("Run count: {}", entry.execution_count),
281 format!(
282 "Status: {}",
283 if entry.success { "Success" } else { "Failed" }
284 ),
285 format!("Duration: {}ms", entry.duration_ms.unwrap_or(0)),
286 ];
287
288 let content = format!("{}\n\n{}", metadata.join("\n"), entry.command);
289
290 let preview = Paragraph::new(content)
291 .block(
292 Block::default()
293 .borders(Borders::ALL)
294 .title("Command Details"),
295 )
296 .wrap(Wrap { trim: false })
297 .style(Style::default().fg(Color::Cyan));
298
299 f.render_widget(preview, area);
300 }
301 }
302
303 fn format_time_ago(&self, timestamp: &chrono::DateTime<chrono::Utc>) -> String {
304 let elapsed = chrono::Utc::now() - *timestamp;
305 if elapsed.num_days() > 0 {
306 format!("{}d", elapsed.num_days())
307 } else if elapsed.num_hours() > 0 {
308 format!("{}h", elapsed.num_hours())
309 } else if elapsed.num_minutes() > 0 {
310 format!("{}m", elapsed.num_minutes())
311 } else {
312 "now".to_string()
313 }
314 }
315
316 pub fn get_state(&self) -> &HistoryState {
318 &self.state
319 }
320
321 pub fn set_state(&mut self, state: HistoryState) {
323 self.state = state;
324 }
325
326 pub fn get_selected_command(&self) -> Option<String> {
328 self.state
329 .matches
330 .get(self.state.selected_index)
331 .map(|m| m.entry.command.clone())
332 }
333}
334
335#[derive(Debug, Clone)]
337pub enum HistoryAction {
338 None,
339 Exit,
340 Quit,
341 ExecuteCommand(String),
342 UseCommand(String),
343 StartSearch,
344}
345
346impl DebugInfoProvider for HistoryWidget {
347 fn debug_info(&self) -> String {
348 let mut info = String::from("=== HISTORY WIDGET ===\n");
349 info.push_str(&format!("Search Query: '{}'\n", self.state.search_query));
350 info.push_str(&format!("Total Matches: {}\n", self.state.matches.len()));
351 info.push_str(&format!("Selected Index: {}\n", self.state.selected_index));
352
353 if !self.state.matches.is_empty() && self.state.selected_index < self.state.matches.len() {
354 info.push_str("\nCurrent Selection:\n");
355 let current = &self.state.matches[self.state.selected_index];
356 info.push_str(&format!(
357 " Command: '{}'\n",
358 if current.entry.command.len() > 50 {
359 format!("{}...", ¤t.entry.command[..50])
360 } else {
361 current.entry.command.clone()
362 }
363 ));
364 info.push_str(&format!(" Score: {:?}\n", current.score));
365 }
366
367 info.push_str("\nHistory Stats:\n");
368 info.push_str(&format!(
369 " Total Entries: {}\n",
370 self.command_history.get_all().len()
371 ));
372
373 info
374 }
375
376 fn debug_summary(&self) -> String {
377 format!(
378 "HistoryWidget: {} matches, idx={}",
379 self.state.matches.len(),
380 self.state.selected_index
381 )
382 }
383}