sql_cli/widgets/
history_widget.rs

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/// Manages the state for history search and display
15#[derive(Clone)]
16pub struct HistoryState {
17    pub search_query: String,
18    pub matches: Vec<HistoryMatch>,
19    pub selected_index: usize,
20}
21
22/// A self-contained widget for command history
23pub 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    /// Initialize history mode - load all history entries
43    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    /// Update search query and filter matches
60    pub fn update_search(&mut self, query: String) {
61        self.state.search_query = query;
62
63        if self.state.search_query.is_empty() {
64            // Show all history when no search
65            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            // Fuzzy search through history
78            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            // Sort by score (highest first)
95            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    /// Handle key input for history mode
103    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            // Delete functionality disabled - CommandHistory doesn't support deletion yet
153            // KeyCode::Delete | KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
154            //     HistoryAction::None
155            // }
156            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    /// Render the history widget
172    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        // Split the area to show selected command details
179        let chunks = Layout::default()
180            .direction(Direction::Vertical)
181            .constraints([
182                Constraint::Percentage(50), // History list
183                Constraint::Percentage(50), // Selected command preview
184            ])
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                // Use more space for the command, less for metadata
222                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                // Highlight matching characters
253                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    /// Get the current state (for persistence/restoration)
317    pub fn get_state(&self) -> &HistoryState {
318        &self.state
319    }
320
321    /// Restore state (for mode switching)
322    pub fn set_state(&mut self, state: HistoryState) {
323        self.state = state;
324    }
325
326    /// Get selected command if any
327    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/// Actions that can result from history widget interaction
336#[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!("{}...", &current.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}