projectable/app/components/
file_cmd_popup.rs

1use crate::{
2    app::component::{Component, Drawable},
3    config::{Config, Key},
4    external_event::ExternalEvent,
5    queue::{AppEvent, Queue},
6    ui,
7};
8use anyhow::Result;
9use crossterm::event::Event;
10use easy_switch::switch;
11use globset::{Glob, GlobMatcher};
12use itertools::Itertools;
13use std::{cell::Cell, path::PathBuf, rc::Rc};
14
15use tui::{
16    backend::Backend,
17    layout::Rect,
18    widgets::{Block, Borders, Clear, List, ListItem, ListState},
19    Frame,
20};
21
22use super::{FuzzyOperation, InputOperation};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum MatchState {
26    Matched,
27    NotMatched,
28}
29
30impl MatchState {
31    pub fn is_matched(&self) -> bool {
32        self == &MatchState::Matched
33    }
34}
35
36#[derive(Debug, Clone)]
37pub struct FileCommand {
38    pub pattern: GlobMatcher,
39    pub commands: Vec<String>,
40}
41
42pub struct FileCmdPopup {
43    state: Cell<ListState>,
44    registry: Vec<FileCommand>,
45    queue: Queue,
46    opened: Option<(FileCommand, PathBuf)>,
47    config: Rc<Config>,
48}
49
50impl Default for FileCmdPopup {
51    fn default() -> Self {
52        Self::new(Queue::new(), Config::default().into())
53    }
54}
55
56impl FileCmdPopup {
57    pub fn new(queue: Queue, config: Rc<Config>) -> Self {
58        let registry = config
59            .special_commands
60            .iter()
61            .map(|(pattern, commands)| {
62                // Prefixed with ** to work with absolute paths
63                let pat = Glob::new(&format!("**/{pattern}"))
64                    .unwrap()
65                    .compile_matcher();
66                FileCommand {
67                    pattern: pat,
68                    commands: commands.clone(),
69                }
70            })
71            .collect_vec();
72        let mut state = ListState::default();
73        state.select(Some(0));
74        Self {
75            queue,
76            registry,
77            state: state.into(),
78            opened: None,
79            config,
80        }
81    }
82
83    pub fn open_for(&mut self, path: PathBuf) -> MatchState {
84        self.state.get_mut().select(Some(0));
85        let position = self
86            .registry
87            .iter()
88            .position(|file_command| file_command.pattern.is_match(&path));
89        if let Some(pos) = position {
90            self.opened = Some((self.registry.remove(pos), path));
91            MatchState::Matched
92        } else {
93            MatchState::NotMatched
94        }
95    }
96
97    pub fn open_fuzzy(&mut self, path: PathBuf) -> MatchState {
98        let commands = self.registry.iter().find_map(|file_command| {
99            file_command
100                .pattern
101                .is_match(&path)
102                .then(|| &file_command.commands)
103        });
104        if let Some(commands) = commands {
105            self.queue.add(AppEvent::OpenFuzzy(
106                commands.clone(),
107                FuzzyOperation::RunCommandOnFile(path),
108            ));
109            MatchState::Matched
110        } else {
111            MatchState::NotMatched
112        }
113    }
114
115    fn selected(&self) -> usize {
116        let state = self.state.take();
117        let selected = state.selected().expect("should have selected something");
118        self.state.set(state);
119        selected
120    }
121
122    fn select_next(&mut self) {
123        let current = self.selected();
124        let Some(opened) = &self.opened else {
125            panic!("cannot call with no opened items")
126        };
127        if current == opened.0.commands.len() - 1 {
128            return;
129        }
130        self.state.get_mut().select(Some(current + 1));
131    }
132
133    fn select_prev(&mut self) {
134        let current = self.selected();
135        if current == 0 {
136            return;
137        }
138        self.state.get_mut().select(Some(current - 1));
139    }
140
141    fn select_first(&mut self) {
142        self.state.get_mut().select(Some(0));
143    }
144
145    fn select_last(&mut self) {
146        let Some(opened) = &self.opened else {
147            panic!("cannot call with no opened items")
148        };
149        self.state
150            .get_mut()
151            .select(Some(opened.0.commands.len() - 1));
152    }
153
154    fn close(&mut self) {
155        let Some(opened) = self.opened.take() else {
156            return;
157        };
158        self.registry.push(opened.0);
159    }
160}
161
162impl Component for FileCmdPopup {
163    fn visible(&self) -> bool {
164        self.opened.is_some()
165    }
166    fn focused(&self) -> bool {
167        true
168    }
169
170    fn handle_event(&mut self, ev: &ExternalEvent) -> Result<()> {
171        if !self.visible() {
172            return Ok(());
173        }
174
175        if let ExternalEvent::Crossterm(Event::Key(key)) = ev {
176            switch! { key;
177                self.config.down => self.select_next(),
178                self.config.up => self.select_prev(),
179                self.config.all_up => self.select_first(),
180                self.config.all_down => self.select_last(),
181                self.config.quit => self.close(),
182                Key::esc() => self.close(),
183                self.config.open => {
184                    let Some(opened) = self.opened.take() else {
185                        unreachable!("checked at top of method");
186                    };
187                    let option = &opened.0.commands[self.selected()];
188                    let replaced = option.replace("{}", &opened.1.display().to_string());
189                    if replaced.contains("{...}") {
190                        self.queue
191                            .add(AppEvent::OpenInput(InputOperation::SpecialCommand(
192                                replaced,
193                            )));
194                    } else {
195                        self.queue.add(AppEvent::RunCommand(replaced));
196                    }
197                    self.registry.push(opened.0);
198                }
199            }
200        }
201        Ok(())
202    }
203}
204
205impl Drawable for FileCmdPopup {
206    fn draw<B: Backend>(&self, f: &mut Frame<B>, area: Rect) -> Result<()> {
207        let Some(opened) = &self.opened else {
208            return Ok(());
209        };
210
211        let commands = opened
212            .0
213            .commands
214            .iter()
215            .map(|command| ListItem::new(command.as_str()))
216            .collect_vec();
217        let list = List::new(commands)
218            .highlight_style(self.config.selected.into())
219            .block(
220                Block::default()
221                    .borders(Borders::ALL)
222                    .border_style(self.config.popup_border_style.into())
223                    .title("Special Commands"),
224            );
225        let area = ui::centered_rect_absolute(50, 10, area);
226        f.render_widget(Clear, area);
227        let mut state = self.state.take();
228        f.render_stateful_widget(list, area, &mut state);
229        self.state.set(state);
230
231        Ok(())
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::app::components::testing::*;
239    use collect_all::collect;
240    use test_log::test;
241
242    fn test_popup() -> FileCmdPopup {
243        let config = Config {
244            special_commands: collect![_:
245                ("*".to_owned(), vec!["command {}".to_owned(), "command2 {} {...}".to_owned(), "command3".to_owned()])
246            ],
247            ..Default::default()
248        };
249        let mut popup = FileCmdPopup::new(Queue::new(), config.into());
250        let path = "test.txt".into();
251        popup.open_for(path);
252        popup
253    }
254
255    #[test]
256    fn starts_with_first_selected() {
257        let popup = FileCmdPopup::default();
258        assert_eq!(0, popup.selected());
259    }
260
261    #[test]
262    fn silent_when_file_does_not_match() {
263        let path = "test.txt".into();
264        let mut popup = FileCmdPopup::default();
265        let state = popup.open_for(path);
266        assert_eq!(MatchState::NotMatched, state);
267    }
268
269    #[test]
270    fn can_open_for_file() {
271        let config = Config {
272            special_commands: collect![_:
273                ("*".to_owned(), vec!["command".to_owned()]),
274                ("not_there.txt".to_owned(), vec!["should_not_be_here".to_owned()])
275            ],
276            ..Default::default()
277        };
278        let mut popup = FileCmdPopup::new(Queue::new(), config.into());
279        let path = "test.txt".into();
280        let state = popup.open_for(path);
281        assert_eq!(MatchState::Matched, state);
282        assert_eq!(PathBuf::from("test.txt"), popup.opened.as_ref().unwrap().1);
283        assert_eq!(vec!["command".to_owned()], popup.opened.unwrap().0.commands);
284    }
285
286    #[test]
287    fn selecting_prev_cannot_go_below_zero() {
288        let mut popup = FileCmdPopup::default();
289        popup.select_prev();
290        assert_eq!(0, popup.selected());
291    }
292
293    #[test]
294    fn selecting_next_cannot_go_above_opened_amount() {
295        let mut popup = test_popup();
296        for _ in 0..10 {
297            popup.select_next();
298        }
299        assert_eq!(2, popup.selected());
300    }
301
302    #[test]
303    fn can_select_last() {
304        let mut popup = test_popup();
305        popup.select_last();
306        assert_eq!(2, popup.selected());
307    }
308
309    #[test]
310    fn can_select_first() {
311        let mut popup = test_popup();
312        popup.select_next();
313        popup.select_first();
314        assert_eq!(0, popup.selected());
315    }
316
317    #[test]
318    fn visible_with_work() {
319        let popup = test_popup();
320        assert!(popup.visible());
321    }
322
323    #[test]
324    fn quitting_properly_restores_registry() {
325        let mut popup = test_popup();
326        assert!(popup.registry.is_empty());
327        let event = input_event!(KeyCode::Char('q'));
328        popup.handle_event(&event).unwrap();
329        assert_eq!(1, popup.registry.len());
330    }
331
332    #[test]
333    fn confirming_properly_restores_registry() {
334        let mut popup = test_popup();
335        assert!(popup.registry.is_empty());
336        let event = input_event!(KeyCode::Enter);
337        popup.handle_event(&event).unwrap();
338        assert_eq!(1, popup.registry.len());
339    }
340
341    #[test]
342    fn confirming_sends_run_command_event_with_interpolated_path() {
343        let mut popup = test_popup();
344        let event = input_event!(KeyCode::Enter);
345        popup.handle_event(&event).unwrap();
346        assert!(popup
347            .queue
348            .contains(&AppEvent::RunCommand("command test.txt".to_owned())));
349    }
350
351    #[test]
352    fn confirming_with_search_interpolation_opens_search_box() {
353        let mut popup = test_popup();
354        popup.select_next();
355        let event = input_event!(KeyCode::Enter);
356        popup.handle_event(&event).unwrap();
357        assert!(popup
358            .queue
359            .contains(&AppEvent::OpenInput(InputOperation::SpecialCommand(
360                "command2 test.txt {...}".to_owned()
361            ))));
362    }
363}