Skip to main content

carch_core/ui/
actions.rs

1use log::info;
2use std::path::{Path, PathBuf};
3use std::{fs, io};
4
5use super::state::{
6    App, AppMode, FocusedPanel, ScriptItem, SearchResult, SearchState, StatefulList,
7};
8use fuzzy_matcher::FuzzyMatcher;
9
10impl App {
11    pub fn load_scripts(&mut self, modules_dir: &Path) -> io::Result<()> {
12        let mut categories = Vec::new();
13        let mut all_scripts = std::collections::HashMap::new();
14
15        for category_entry in fs::read_dir(modules_dir)? {
16            let category_entry = category_entry?;
17            let category_path = category_entry.path();
18
19            if category_path.is_dir() {
20                let category_name =
21                    category_path.file_name().unwrap_or_default().to_string_lossy().into_owned();
22                categories.push(category_name.clone());
23
24                let mut scripts_in_category = Vec::new();
25                for script_entry in fs::read_dir(&category_path)? {
26                    let script_entry = script_entry?;
27                    let script_path = script_entry.path();
28
29                    if script_path.is_file() && script_path.extension().unwrap_or_default() == "sh"
30                    {
31                        let script_name = script_path
32                            .file_stem()
33                            .unwrap_or_default()
34                            .to_string_lossy()
35                            .into_owned();
36
37                        let script_item = ScriptItem {
38                            category: category_name.clone(),
39                            name:     script_name,
40                            path:     script_path,
41                        };
42                        scripts_in_category.push(script_item);
43                    }
44                }
45                scripts_in_category.sort_by(|a, b| a.name.cmp(&b.name));
46                all_scripts.insert(category_name, scripts_in_category);
47            }
48        }
49
50        categories.sort();
51        self.categories = StatefulList::with_items(categories);
52        self.all_scripts = all_scripts;
53
54        self.update_script_list();
55        self.update_preview();
56
57        Ok(())
58    }
59
60    pub fn update_script_list(&mut self) {
61        if let Some(scripts) = self
62            .categories
63            .state
64            .selected()
65            .and_then(|i| self.categories.items.get(i))
66            .and_then(|name| self.all_scripts.get(name))
67        {
68            self.scripts = StatefulList::with_items(scripts.clone());
69            if self.focused_panel == FocusedPanel::Scripts && !self.scripts.items.is_empty() {
70                self.scripts.state.select(Some(0));
71            } else {
72                self.scripts.state.select(None);
73            }
74        }
75    }
76
77    pub fn update_preview(&mut self) {
78        if let Some(selected) = self.scripts.state.selected() {
79            let script_path = &self.scripts.items[selected].path;
80            if !self.preview.cache.contains_key(script_path) {
81                match fs::read_to_string(script_path) {
82                    Ok(content) => {
83                        self.preview.content = content;
84                        self.preview.scroll = 0;
85                    }
86                    Err(_) => {
87                        self.preview.content = "Error loading script content".to_string();
88                    }
89                }
90            }
91        } else {
92            self.preview.content = "No script selected".to_string();
93        }
94    }
95
96    pub fn toggle_preview_mode(&mut self) {
97        if self.scripts.state.selected().is_some() {
98            let prev_mode = self.mode;
99            self.mode = match self.mode {
100                AppMode::Normal => AppMode::Preview,
101                _ => AppMode::Normal,
102            };
103
104            if self.log_mode {
105                if prev_mode == AppMode::Normal && self.mode == AppMode::Preview {
106                    info!("Entered preview mode");
107                } else if prev_mode == AppMode::Preview && self.mode == AppMode::Normal {
108                    info!("Exited preview mode");
109                }
110            }
111            self.update_preview();
112        }
113    }
114
115    pub fn scroll_preview_up(&mut self) {
116        self.preview.scroll = self.preview.scroll.saturating_sub(1);
117    }
118
119    pub fn scroll_preview_down(&mut self) {
120        self.preview.scroll = (self.preview.scroll + 1).min(self.preview.max_scroll);
121    }
122
123    pub fn scroll_preview_page_up(&mut self) {
124        self.preview.scroll = self.preview.scroll.saturating_sub(10);
125    }
126
127    pub fn scroll_preview_page_down(&mut self) {
128        self.preview.scroll = (self.preview.scroll + 10).min(self.preview.max_scroll);
129    }
130
131    pub fn get_script_path(&self) -> Option<PathBuf> {
132        if self.mode == AppMode::Search {
133            if let Some(selected_idx) = self.search.results.get(self.search.selected_idx) {
134                return Some(selected_idx.item.path.clone());
135            }
136        } else if let Some(script_item) =
137            self.scripts.state.selected().and_then(|idx| self.scripts.items.get(idx))
138        {
139            return Some(script_item.path.clone());
140        }
141        None
142    }
143
144    pub fn toggle_search_mode(&mut self) {
145        let prev_mode = self.mode;
146        self.mode = if self.mode == AppMode::Search { AppMode::Normal } else { AppMode::Search };
147
148        if self.log_mode {
149            if prev_mode != AppMode::Search && self.mode == AppMode::Search {
150                info!("Entered search mode");
151            } else if prev_mode == AppMode::Search && self.mode != AppMode::Search {
152                info!("Exited search mode");
153            }
154        }
155
156        if self.mode == AppMode::Search {
157            self.search = SearchState::default();
158            self.perform_search();
159        }
160    }
161
162    pub fn perform_search(&mut self) {
163        self.search.results.clear();
164
165        if self.search.input.is_empty() {
166            let mut all_scripts: Vec<_> = self
167                .all_scripts
168                .values()
169                .flat_map(|scripts| scripts.iter().cloned())
170                .map(|item| SearchResult { item, score: 0, indices: Vec::new() })
171                .collect();
172            all_scripts.sort_by(|a, b| a.item.name.cmp(&b.item.name));
173            self.search.results = all_scripts;
174            return;
175        }
176
177        let mut results = Vec::new();
178        for item in self.all_scripts.values().flat_map(|scripts| scripts.iter()) {
179            let choice = format!("{}/{}", item.category, item.name);
180            if let Some((score, indices)) =
181                self.search.matcher.fuzzy_indices(&choice, &self.search.input)
182            {
183                results.push(SearchResult { item: item.clone(), score, indices });
184            }
185        }
186
187        results.sort_by_key(|b| std::cmp::Reverse(b.score));
188        self.search.results = results;
189    }
190
191    pub fn next(&mut self) {
192        if self.log_mode {
193            info!("Navigating next in {:?}", self.focused_panel);
194        }
195        match self.focused_panel {
196            FocusedPanel::Categories => {
197                self.categories.next();
198                self.update_script_list();
199                self.update_preview();
200            }
201            FocusedPanel::Scripts => {
202                self.scripts.next();
203                self.update_preview();
204            }
205        }
206    }
207
208    pub fn previous(&mut self) {
209        if self.log_mode {
210            info!("Navigating previous in {:?}", self.focused_panel);
211        }
212        match self.focused_panel {
213            FocusedPanel::Categories => {
214                self.categories.previous();
215                self.update_script_list();
216                self.update_preview();
217            }
218            FocusedPanel::Scripts => {
219                self.scripts.previous();
220                self.update_preview();
221            }
222        }
223    }
224
225    pub fn update_autocomplete(&mut self) {
226        self.search.autocomplete = None;
227
228        if self.search.input.is_empty() {
229            return;
230        }
231
232        let search_term = self.search.input.to_lowercase();
233        let mut best_match = None;
234        let mut shortest_len = usize::MAX;
235
236        for (category_name, scripts) in &self.all_scripts {
237            for item in scripts {
238                if item.name.to_lowercase().starts_with(&search_term)
239                    && item.name.len() > search_term.len()
240                    && item.name.len() < shortest_len
241                {
242                    best_match = Some(item.name.clone());
243                    shortest_len = item.name.len();
244                }
245
246                let full_path = format!("{}/{}", category_name, item.name);
247                if full_path.to_lowercase().starts_with(&search_term)
248                    && full_path.len() > search_term.len()
249                    && full_path.len() < shortest_len
250                {
251                    shortest_len = full_path.len();
252                    best_match = Some(full_path);
253                }
254            }
255        }
256
257        self.search.autocomplete = best_match;
258    }
259
260    pub fn toggle_multi_select_mode(&mut self) {
261        self.multi_select.enabled = !self.multi_select.enabled;
262        if !self.multi_select.enabled {
263            self.multi_select.scripts.clear();
264        }
265    }
266
267    pub fn toggle_script_selection(&mut self) {
268        if let Some(selected) = self.scripts.state.selected() {
269            let script_path = &self.scripts.items[selected].path;
270            if self.multi_select.scripts.contains(script_path) {
271                self.multi_select.scripts.retain(|p| p != script_path);
272            } else {
273                self.multi_select.scripts.push(script_path.clone());
274            }
275        }
276    }
277
278    pub fn is_script_selected(&self, script_path: &Path) -> bool {
279        self.multi_select.scripts.iter().any(|p| p == script_path)
280    }
281
282    #[must_use]
283    pub fn has_description(&self, category: &str, script_name: &str) -> bool {
284        let desc_path = self.modules_dir.join(category).join("desc.toml");
285        let Ok(content) = std::fs::read_to_string(&desc_path) else {
286            return false;
287        };
288        let Ok(table) = content.parse::<toml::Table>() else {
289            return false;
290        };
291        let Some(stem) = std::path::Path::new(script_name).file_stem().and_then(|s| s.to_str())
292        else {
293            return false;
294        };
295        table
296            .get(stem)
297            .and_then(|v| v.as_table())
298            .and_then(|t| t.get("description"))
299            .and_then(|v| v.as_str())
300            .is_some()
301    }
302
303    pub fn toggle_help_mode(&mut self) {
304        self.mode = if self.mode == AppMode::Help { AppMode::Normal } else { AppMode::Help };
305    }
306
307    pub fn top(&mut self) {
308        match self.focused_panel {
309            FocusedPanel::Categories => {
310                self.categories.state.select(Some(0));
311                self.update_script_list();
312                self.update_preview();
313            }
314            FocusedPanel::Scripts => {
315                self.scripts.state.select(Some(0));
316                self.update_preview();
317            }
318        }
319    }
320
321    pub fn bottom(&mut self) {
322        match self.focused_panel {
323            FocusedPanel::Categories => {
324                if let Some(last_idx) = self.categories.items.len().checked_sub(1) {
325                    self.categories.state.select(Some(last_idx));
326                    self.update_script_list();
327                    self.update_preview();
328                }
329            }
330            FocusedPanel::Scripts => {
331                if let Some(last_idx) = self.scripts.items.len().checked_sub(1) {
332                    self.scripts.state.select(Some(last_idx));
333                    self.update_preview();
334                }
335            }
336        }
337    }
338
339    pub fn handle_key_root_warning_mode(&mut self, key: crossterm::event::KeyEvent) {
340        match key.code {
341            crossterm::event::KeyCode::Char('y') | crossterm::event::KeyCode::Char('Y') => {
342                self.mode = AppMode::Normal;
343            }
344            crossterm::event::KeyCode::Char('n')
345            | crossterm::event::KeyCode::Char('N')
346            | crossterm::event::KeyCode::Char('q') => {
347                self.quit = true;
348            }
349            _ => {}
350        }
351    }
352
353    pub fn handle_key_termux_warning_mode(&mut self, key: crossterm::event::KeyEvent) {
354        match key.code {
355            crossterm::event::KeyCode::Char('o')
356            | crossterm::event::KeyCode::Char('O')
357            | crossterm::event::KeyCode::Enter => {
358                self.mode = AppMode::Normal;
359            }
360            _ => {}
361        }
362    }
363}