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') | crossterm::event::KeyCode::Char('N') => {
345 self.quit = true;
346 }
347 _ => {}
348 }
349 }
350
351 pub fn handle_key_termux_warning_mode(&mut self, key: crossterm::event::KeyEvent) {
352 match key.code {
353 crossterm::event::KeyCode::Char('o')
354 | crossterm::event::KeyCode::Char('O')
355 | crossterm::event::KeyCode::Enter => {
356 self.mode = AppMode::Normal;
357 }
358 _ => {}
359 }
360 }
361}