fm/modes/menu/
completion.rs1use std::fmt;
2use std::fs::{self, ReadDir};
3use std::path::Path;
4
5use ratatui::style::{Modifier, Style};
6
7use crate::common::{is_in_path, tilde, UtfWidth, ZOXIDE};
8use crate::event::ActionMap;
9use crate::io::{execute_and_capture_output_with_path, DrawMenu};
10use crate::modes::{CursorOffset, Leave};
11use crate::{impl_content, impl_selectable};
12
13#[derive(Clone, Default, Copy, Eq, PartialEq)]
15pub enum InputCompleted {
16 #[default]
17 Cd,
19 Search,
21 Exec,
23 Action,
25}
26
27impl fmt::Display for InputCompleted {
28 #[rustfmt::skip]
29 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
30 match *self {
31 Self::Exec => write!(f, "Open with: "),
32 Self::Cd => write!(f, "Cd: "),
33 Self::Search => write!(f, "Search: "),
34 Self::Action => write!(f, "Action: "),
35 }
36 }
37}
38
39impl CursorOffset for InputCompleted {
40 fn cursor_offset(&self) -> u16 {
41 self.to_string().utf_width_u16() + 2
42 }
43}
44
45impl Leave for InputCompleted {
46 fn must_refresh(&self) -> bool {
47 true
48 }
49
50 fn must_reset_mode(&self) -> bool {
51 !matches!(self, Self::Action)
52 }
53}
54
55#[derive(Clone, Default)]
58pub struct Completion {
59 pub content: Vec<String>,
61 pub index: usize,
63}
64
65impl Completion {
66 pub fn is_empty(&self) -> bool {
68 self.content.is_empty()
69 }
70
71 pub fn current_proposition(&self) -> &str {
74 if self.content.is_empty() {
75 return "";
76 }
77 &self.content[self.index]
78 }
79
80 fn update(&mut self, proposals: Vec<String>) {
83 self.index = 0;
84 self.content = proposals;
85 self.content.dedup()
86 }
87
88 fn extend(&mut self, proposals: &[String]) {
89 self.index = 0;
90 self.content.extend_from_slice(proposals);
91 self.content.dedup()
92 }
93
94 pub fn reset(&mut self) {
97 self.index = 0;
98 self.content.clear();
99 }
100
101 pub fn cd(&mut self, current_path: &str, input_string: &str) {
104 self.cd_update_from_input(input_string, current_path);
105 let (parent, last_name) = split_input_string(input_string);
106 if !last_name.is_empty() {
107 self.extend_absolute_paths(&parent, &last_name);
108 self.extend_relative_paths(current_path, &last_name);
109 }
110 self.extend_with_children(input_string);
111 }
112
113 fn cd_update_from_input(&mut self, input_string: &str, current_path: &str) {
114 self.content = vec![];
115 self.cd_update_from_zoxide(input_string, current_path);
116 if let Some(expanded_input) = self.expand_input(input_string) {
117 let formated = Self::attach_slash_to_dirs(&expanded_input);
118 self.content.push(formated);
119 }
120 if let Some(cannonicalized_input) = self.canonicalize_input(input_string, current_path) {
121 let formated = Self::attach_slash_to_dirs(&cannonicalized_input);
122 self.content.push(formated);
123 }
124 }
125
126 fn extend_with_children(&mut self, input_string: &str) {
128 if !input_string.ends_with('/') {
129 return;
130 }
131 let input_path = std::path::Path::new(input_string);
132 if !input_path.is_dir() {
133 return;
134 }
135 let Ok(entries) = fs::read_dir(input_path) else {
136 return;
137 };
138 let children: Vec<String> = entries
139 .filter_map(|e| e.ok())
140 .map(|e| e.path().to_string_lossy().into_owned())
141 .map(|path_str| Self::attach_slash_to_dirs(&path_str))
142 .collect();
143 self.extend(&children);
144 }
145
146 fn cd_update_from_zoxide(&mut self, input_string: &str, current_path: &str) {
147 if !is_in_path(ZOXIDE) {
148 return;
149 }
150 let mut args = vec!["query"];
151 args.extend(input_string.split(' '));
152 let Ok(zoxide_output) = execute_and_capture_output_with_path(ZOXIDE, current_path, &args)
153 else {
154 return;
155 };
156 if !zoxide_output.is_empty() {
157 self.content
158 .push(Self::attach_slash_to_dirs(zoxide_output.trim()));
159 }
160 }
161
162 fn attach_slash_to_dirs<T: AsRef<str> + std::string::ToString + std::fmt::Display>(
163 path_str: T,
164 ) -> String {
165 let p = Path::new(path_str.as_ref());
166 if !path_str.as_ref().ends_with('/') && p.exists() && p.is_dir() {
167 format!("{path_str}/")
168 } else {
169 path_str.to_string()
170 }
171 }
172
173 fn expand_input(&mut self, input_string: &str) -> Option<String> {
174 let expanded_input = tilde(input_string).into_owned();
175 if std::path::PathBuf::from(&expanded_input).exists() {
176 Some(expanded_input)
177 } else {
178 None
179 }
180 }
181
182 fn canonicalize_input(&mut self, input_string: &str, current_path: &str) -> Option<String> {
183 let mut path = fs::canonicalize(current_path).unwrap_or_default();
184 path.push(input_string);
185 let path = fs::canonicalize(path).unwrap_or_default();
186 if path.exists() {
187 Some(path.to_str().unwrap_or_default().to_owned())
188 } else {
189 None
190 }
191 }
192
193 fn extend_absolute_paths(&mut self, parent: &str, last_name: &str) {
194 let Ok(path) = std::fs::canonicalize(parent) else {
195 return;
196 };
197 let Ok(entries) = fs::read_dir(path) else {
198 return;
199 };
200 self.extend(&Self::entries_matching_filename(entries, last_name))
201 }
202
203 fn extend_relative_paths(&mut self, current_path: &str, last_name: &str) {
204 if let Ok(entries) = fs::read_dir(current_path) {
205 self.extend(&Self::entries_matching_filename(entries, last_name))
206 }
207 }
208
209 fn entries_matching_filename(entries: ReadDir, last_name: &str) -> Vec<String> {
210 entries
211 .filter_map(|e| e.ok())
212 .filter(|e| e.file_type().is_ok())
213 .filter(|e| e.file_type().unwrap().is_dir() && filename_startswith(e, last_name))
214 .map(|e| e.path().to_string_lossy().into_owned())
215 .map(|path_str| Self::attach_slash_to_dirs(&path_str))
216 .collect()
217 }
218
219 pub fn exec(&mut self, input_string: &str) {
221 let mut proposals: Vec<String> = vec![];
222 if let Some(paths) = std::env::var_os("PATH") {
223 for path in std::env::split_paths(&paths).filter(|path| path.exists()) {
224 proposals.extend(Self::find_completion_in_path(path, input_string));
225 }
226 }
227 self.update(proposals);
228 }
229
230 pub fn action(&mut self, input_string: &str) {
232 self.update(ActionMap::actions_matching(input_string.to_lowercase()));
233 }
234
235 fn find_completion_in_path(path: std::path::PathBuf, input_string: &str) -> Vec<String> {
236 let Ok(entries) = fs::read_dir(path) else {
237 return vec![];
238 };
239 entries
240 .filter_map(|e| e.ok())
241 .filter(|e| file_match_input(e, input_string))
242 .map(|e| e.path().to_string_lossy().into_owned())
243 .collect()
244 }
245
246 pub fn search(&mut self, files: Vec<String>) {
248 self.update(files);
249 }
250
251 pub fn complete_input_string(&self, input_string: &str) -> Option<&str> {
255 self.current_proposition().strip_prefix(input_string)
256 }
257
258 pub fn style(&self, index: usize, style: &Style) -> Style {
260 let mut style = *style;
261 if index == self.index {
262 style.add_modifier |= Modifier::REVERSED;
263 }
264 style
265 }
266}
267
268fn file_match_input(dir_entry: &std::fs::DirEntry, input_string: &str) -> bool {
269 let Ok(file_type) = dir_entry.file_type() else {
270 return false;
271 };
272 (file_type.is_file() || file_type.is_symlink()) && filename_startswith(dir_entry, input_string)
273}
274
275fn filename_startswith(entry: &std::fs::DirEntry, pattern: &str) -> bool {
277 entry
278 .file_name()
279 .to_string_lossy()
280 .as_ref()
281 .starts_with(pattern)
282}
283
284fn split_input_string(input_string: &str) -> (String, String) {
285 let steps = input_string.split('/');
286 let mut vec_steps: Vec<&str> = steps.collect();
287 let last_name = vec_steps.pop().unwrap_or("").to_owned();
288 let parent = create_parent(vec_steps);
289 (parent, last_name)
290}
291
292fn create_parent(vec_steps: Vec<&str>) -> String {
293 let mut parent = if vec_steps.is_empty() || vec_steps.len() == 1 && vec_steps[0] != "~" {
294 "/".to_owned()
295 } else {
296 "".to_owned()
297 };
298 parent.push_str(&vec_steps.join("/"));
299 tilde(&parent).to_string()
300}
301
302impl_selectable!(Completion);
303impl_content!(Completion, String);
304
305impl DrawMenu<String> for Completion {}