ghostscope_ui/components/command_panel/
history_manager.rs

1use std::fs::{File, OpenOptions};
2use std::io::{BufRead, BufReader, Write};
3use std::path::PathBuf;
4
5#[derive(Debug, Clone)]
6pub struct CommandHistory {
7    entries: Vec<String>,
8    file_path: PathBuf,
9    max_entries: usize,
10}
11
12#[derive(Debug, Clone)]
13pub struct HistorySearchState {
14    pub is_active: bool,
15    pub query: String,
16    pub current_index: Option<usize>,
17    pub matches: Vec<usize>,
18    pub current_match_index: usize,
19}
20
21#[derive(Debug, Clone)]
22pub struct AutoSuggestionState {
23    pub suggestion: Option<String>,
24    pub start_position: usize,
25}
26
27impl CommandHistory {
28    pub fn new() -> Self {
29        Self::new_with_config(&crate::model::ui_state::HistoryConfig::default())
30    }
31
32    pub fn new_with_config(config: &crate::model::ui_state::HistoryConfig) -> Self {
33        let file_path = if config.enabled {
34            std::env::current_dir()
35                .unwrap_or_else(|_| PathBuf::from("."))
36                .join(".ghostscope_history")
37        } else {
38            // Use empty path when disabled - won't save to file
39            PathBuf::new()
40        };
41
42        let mut history = Self {
43            entries: Vec::new(),
44            file_path,
45            max_entries: config.max_entries,
46        };
47
48        if config.enabled {
49            history.load_from_file();
50        }
51        history
52    }
53
54    pub fn load_from_file(&mut self) {
55        if let Ok(file) = File::open(&self.file_path) {
56            let reader = BufReader::new(file);
57            let mut entries = Vec::new();
58
59            for line in reader.lines() {
60                match line {
61                    Ok(line) => {
62                        if !line.trim().is_empty() {
63                            entries.push(line);
64                        }
65                    }
66                    Err(_) => continue,
67                }
68            }
69
70            self.entries = entries;
71        }
72    }
73
74    pub fn save_to_file(&self) {
75        // Don't save if path is empty (history disabled)
76        if self.file_path.as_os_str().is_empty() {
77            return;
78        }
79
80        if let Ok(mut file) = OpenOptions::new()
81            .create(true)
82            .write(true)
83            .truncate(true)
84            .open(&self.file_path)
85        {
86            for entry in &self.entries {
87                let _ = writeln!(file, "{entry}");
88            }
89        }
90    }
91
92    pub fn add_command(&mut self, command: &str) {
93        let cmd = command.trim().to_string();
94        if cmd.is_empty() {
95            return;
96        }
97
98        if let Some(last) = self.entries.last() {
99            if last == &cmd {
100                return;
101            }
102        }
103
104        self.entries.push(cmd);
105
106        if self.entries.len() > self.max_entries {
107            self.entries.remove(0);
108        }
109
110        self.save_to_file();
111    }
112
113    pub fn search_backwards(&self, query: &str, start_from: Option<usize>) -> Vec<usize> {
114        if query.is_empty() {
115            return Vec::new();
116        }
117
118        let start_index = start_from.unwrap_or(self.entries.len());
119        let mut matches = Vec::new();
120
121        for i in (0..start_index.min(self.entries.len())).rev() {
122            if self.entries[i].contains(query) {
123                matches.push(i);
124            }
125        }
126
127        matches
128    }
129
130    pub fn get_prefix_match(&self, prefix: &str) -> Option<&str> {
131        if prefix.is_empty() {
132            return None;
133        }
134
135        self.entries
136            .iter()
137            .rev()
138            .find(|entry| entry.starts_with(prefix) && entry.as_str() != prefix)
139            .map(|entry| entry.as_str())
140    }
141
142    pub fn get_entry(&self, index: usize) -> Option<&str> {
143        self.entries.get(index).map(|s| s.as_str())
144    }
145
146    pub fn len(&self) -> usize {
147        self.entries.len()
148    }
149
150    pub fn is_empty(&self) -> bool {
151        self.entries.is_empty()
152    }
153}
154
155impl HistorySearchState {
156    pub fn new() -> Self {
157        Self {
158            is_active: false,
159            query: String::new(),
160            current_index: None,
161            matches: Vec::new(),
162            current_match_index: 0,
163        }
164    }
165
166    pub fn start_search(&mut self) {
167        self.is_active = true;
168        self.query.clear();
169        self.current_index = None;
170        self.matches.clear();
171        self.current_match_index = 0;
172    }
173
174    pub fn update_query(&mut self, query: String, history: &CommandHistory) {
175        self.query = query;
176        self.matches = history.search_backwards(&self.query, None);
177        self.current_match_index = 0;
178        self.current_index = self.matches.first().copied();
179    }
180
181    pub fn next_match<'a>(&mut self, history: &'a CommandHistory) -> Option<&'a str> {
182        if self.matches.is_empty() {
183            return None;
184        }
185
186        self.current_match_index = (self.current_match_index + 1) % self.matches.len();
187        self.current_index = Some(self.matches[self.current_match_index]);
188
189        if let Some(index) = self.current_index {
190            history.get_entry(index)
191        } else {
192            None
193        }
194    }
195
196    pub fn current_match<'a>(&self, history: &'a CommandHistory) -> Option<&'a str> {
197        if let Some(index) = self.current_index {
198            history.get_entry(index)
199        } else {
200            None
201        }
202    }
203
204    pub fn clear(&mut self) {
205        self.is_active = false;
206        self.query.clear();
207        self.current_index = None;
208        self.matches.clear();
209        self.current_match_index = 0;
210    }
211}
212
213impl AutoSuggestionState {
214    pub fn new() -> Self {
215        Self {
216            suggestion: None,
217            start_position: 0,
218        }
219    }
220
221    pub fn update(&mut self, input: &str, history: &CommandHistory) {
222        if input.is_empty() {
223            self.clear();
224            return;
225        }
226
227        // First check static commands
228        if let Some(static_match) = Self::get_static_command_match(input) {
229            if static_match != input {
230                self.suggestion = Some(static_match);
231                self.start_position = input.len();
232                return;
233            }
234        }
235
236        // Then check history
237        if let Some(matched_command) = history.get_prefix_match(input) {
238            if matched_command != input {
239                self.suggestion = Some(matched_command.to_string());
240                self.start_position = input.len();
241            } else {
242                self.clear();
243            }
244        } else {
245            self.clear();
246        }
247    }
248
249    fn get_static_command_match(prefix: &str) -> Option<String> {
250        // Built-in commands for auto-completion
251        const COMMANDS: &[&str] = &[
252            "save traces",
253            "save traces enabled",
254            "save traces disabled",
255            "source",
256            "info trace",
257            "info source",
258            "info share",
259            "info share all",
260            "info function",
261            "info line",
262            "info address",
263            "trace",
264            "enable",
265            "disable",
266            "delete",
267            "delete all",
268            "disable all",
269            "enable all",
270            "quit",
271            "exit",
272            "clear",
273            "help",
274        ];
275
276        COMMANDS
277            .iter()
278            .find(|cmd| cmd.starts_with(prefix) && **cmd != prefix)
279            .map(|cmd| cmd.to_string())
280    }
281
282    pub fn get_suggestion_text(&self) -> Option<&str> {
283        if let Some(ref suggestion) = self.suggestion {
284            if suggestion.len() > self.start_position {
285                return Some(&suggestion[self.start_position..]);
286            }
287        }
288        None
289    }
290
291    pub fn get_full_suggestion(&self) -> Option<&str> {
292        self.suggestion.as_deref()
293    }
294
295    pub fn clear(&mut self) {
296        self.suggestion = None;
297        self.start_position = 0;
298    }
299}
300
301// Test helper methods - available in tests
302impl CommandHistory {
303    /// Create a new history without loading from file (for testing)
304    #[doc(hidden)]
305    pub fn new_for_test() -> Self {
306        Self {
307            entries: Vec::new(),
308            file_path: PathBuf::new(), // Empty path prevents file operations
309            max_entries: 1000,
310        }
311    }
312
313    /// Get all entries for testing (only available in test builds)
314    #[doc(hidden)]
315    pub fn get_entries_for_test(&self) -> Vec<String> {
316        self.entries.clone()
317    }
318
319    /// Get entry at specific index for testing
320    #[doc(hidden)]
321    pub fn get_at(&self, index: usize) -> Option<String> {
322        self.entries.get(index).cloned()
323    }
324
325    /// Get entries in reverse order (as they would appear when navigating)
326    #[doc(hidden)]
327    pub fn get_entries_reversed(&self) -> Vec<String> {
328        self.entries.iter().rev().cloned().collect()
329    }
330}
331
332impl Default for CommandHistory {
333    fn default() -> Self {
334        Self::new()
335    }
336}
337
338impl Default for HistorySearchState {
339    fn default() -> Self {
340        Self::new()
341    }
342}
343
344impl Default for AutoSuggestionState {
345    fn default() -> Self {
346        Self::new()
347    }
348}