projectable/app/components/
file_cmd_popup.rs1use 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 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}