ghostscope_ui/components/command_panel/
history_manager.rs1use 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 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 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 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 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 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
301impl CommandHistory {
303 #[doc(hidden)]
305 pub fn new_for_test() -> Self {
306 Self {
307 entries: Vec::new(),
308 file_path: PathBuf::new(), max_entries: 1000,
310 }
311 }
312
313 #[doc(hidden)]
315 pub fn get_entries_for_test(&self) -> Vec<String> {
316 self.entries.clone()
317 }
318
319 #[doc(hidden)]
321 pub fn get_at(&self, index: usize) -> Option<String> {
322 self.entries.get(index).cloned()
323 }
324
325 #[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}