open_path_prompt.rs

  1use crate::file_finder_settings::FileFinderSettings;
  2use file_icons::FileIcons;
  3use futures::channel::oneshot;
  4use fuzzy::{StringMatch, StringMatchCandidate};
  5use picker::{Picker, PickerDelegate};
  6use project::{DirectoryItem, DirectoryLister};
  7use settings::Settings;
  8use std::{
  9    path::{self, MAIN_SEPARATOR_STR, Path, PathBuf},
 10    sync::{
 11        Arc,
 12        atomic::{self, AtomicBool},
 13    },
 14};
 15use ui::{Context, ListItem, Window};
 16use ui::{HighlightedLabel, ListItemSpacing, prelude::*};
 17use util::{maybe, paths::compare_paths};
 18use workspace::Workspace;
 19
 20pub(crate) struct OpenPathPrompt;
 21
 22pub struct OpenPathDelegate {
 23    tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
 24    lister: DirectoryLister,
 25    selected_index: usize,
 26    directory_state: Option<DirectoryState>,
 27    matches: Vec<usize>,
 28    string_matches: Vec<StringMatch>,
 29    cancel_flag: Arc<AtomicBool>,
 30    should_dismiss: bool,
 31}
 32
 33impl OpenPathDelegate {
 34    pub fn new(tx: oneshot::Sender<Option<Vec<PathBuf>>>, lister: DirectoryLister) -> Self {
 35        Self {
 36            tx: Some(tx),
 37            lister,
 38            selected_index: 0,
 39            directory_state: None,
 40            matches: Vec::new(),
 41            string_matches: Vec::new(),
 42            cancel_flag: Arc::new(AtomicBool::new(false)),
 43            should_dismiss: true,
 44        }
 45    }
 46
 47    #[cfg(any(test, feature = "test-support"))]
 48    pub fn collect_match_candidates(&self) -> Vec<String> {
 49        if let Some(state) = self.directory_state.as_ref() {
 50            self.matches
 51                .iter()
 52                .filter_map(|&index| {
 53                    state
 54                        .match_candidates
 55                        .get(index)
 56                        .map(|candidate| candidate.path.string.clone())
 57                })
 58                .collect()
 59        } else {
 60            Vec::new()
 61        }
 62    }
 63}
 64
 65#[derive(Debug)]
 66struct DirectoryState {
 67    path: String,
 68    match_candidates: Vec<CandidateInfo>,
 69    error: Option<SharedString>,
 70}
 71
 72#[derive(Debug, Clone)]
 73struct CandidateInfo {
 74    path: StringMatchCandidate,
 75    is_dir: bool,
 76}
 77
 78impl OpenPathPrompt {
 79    pub(crate) fn register(
 80        workspace: &mut Workspace,
 81        _window: Option<&mut Window>,
 82        _: &mut Context<Workspace>,
 83    ) {
 84        workspace.set_prompt_for_open_path(Box::new(|workspace, lister, window, cx| {
 85            let (tx, rx) = futures::channel::oneshot::channel();
 86            Self::prompt_for_open_path(workspace, lister, tx, window, cx);
 87            rx
 88        }));
 89    }
 90
 91    fn prompt_for_open_path(
 92        workspace: &mut Workspace,
 93        lister: DirectoryLister,
 94        tx: oneshot::Sender<Option<Vec<PathBuf>>>,
 95        window: &mut Window,
 96        cx: &mut Context<Workspace>,
 97    ) {
 98        workspace.toggle_modal(window, cx, |window, cx| {
 99            let delegate = OpenPathDelegate::new(tx, lister.clone());
100
101            let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.));
102            let query = lister.default_query(cx);
103            picker.set_query(query, window, cx);
104            picker
105        });
106    }
107}
108
109impl PickerDelegate for OpenPathDelegate {
110    type ListItem = ui::ListItem;
111
112    fn match_count(&self) -> usize {
113        self.matches.len()
114    }
115
116    fn selected_index(&self) -> usize {
117        self.selected_index
118    }
119
120    fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
121        self.selected_index = ix;
122        cx.notify();
123    }
124
125    fn update_matches(
126        &mut self,
127        query: String,
128        window: &mut Window,
129        cx: &mut Context<Picker<Self>>,
130    ) -> gpui::Task<()> {
131        let lister = self.lister.clone();
132        let query_path = Path::new(&query);
133        let last_item = query_path
134            .file_name()
135            .unwrap_or_default()
136            .to_string_lossy()
137            .to_string();
138        let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(&last_item) {
139            (dir.to_string(), last_item)
140        } else {
141            (query, String::new())
142        };
143
144        if dir == "" {
145            #[cfg(not(target_os = "windows"))]
146            {
147                dir = "/".to_string();
148            }
149            #[cfg(target_os = "windows")]
150            {
151                dir = "C:\\".to_string();
152            }
153        }
154
155        let query = if self
156            .directory_state
157            .as_ref()
158            .map_or(false, |s| s.path == dir)
159        {
160            None
161        } else {
162            Some(lister.list_directory(dir.clone(), cx))
163        };
164        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
165        self.cancel_flag = Arc::new(AtomicBool::new(false));
166        let cancel_flag = self.cancel_flag.clone();
167
168        cx.spawn_in(window, async move |this, cx| {
169            if let Some(query) = query {
170                let paths = query.await;
171                if cancel_flag.load(atomic::Ordering::Relaxed) {
172                    return;
173                }
174
175                this.update(cx, |this, _| {
176                    this.delegate.directory_state = Some(match paths {
177                        Ok(mut paths) => {
178                            if dir == "/" {
179                                paths.push(DirectoryItem {
180                                    is_dir: true,
181                                    path: Default::default(),
182                                });
183                            }
184
185                            paths.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
186                            let match_candidates = paths
187                                .iter()
188                                .enumerate()
189                                .map(|(ix, item)| CandidateInfo {
190                                    path: StringMatchCandidate::new(
191                                        ix,
192                                        &item.path.to_string_lossy(),
193                                    ),
194                                    is_dir: item.is_dir,
195                                })
196                                .collect::<Vec<_>>();
197
198                            DirectoryState {
199                                match_candidates,
200                                path: dir,
201                                error: None,
202                            }
203                        }
204                        Err(err) => DirectoryState {
205                            match_candidates: vec![],
206                            path: dir,
207                            error: Some(err.to_string().into()),
208                        },
209                    });
210                })
211                .ok();
212            }
213
214            let match_candidates = this
215                .update(cx, |this, cx| {
216                    let directory_state = this.delegate.directory_state.as_ref()?;
217                    if directory_state.error.is_some() {
218                        this.delegate.matches.clear();
219                        this.delegate.selected_index = 0;
220                        cx.notify();
221                        return None;
222                    }
223
224                    Some(directory_state.match_candidates.clone())
225                })
226                .unwrap_or(None);
227
228            let Some(mut match_candidates) = match_candidates else {
229                return;
230            };
231
232            if !suffix.starts_with('.') {
233                match_candidates.retain(|m| !m.path.string.starts_with('.'));
234            }
235
236            if suffix == "" {
237                this.update(cx, |this, cx| {
238                    this.delegate.matches.clear();
239                    this.delegate.string_matches.clear();
240                    this.delegate
241                        .matches
242                        .extend(match_candidates.iter().map(|m| m.path.id));
243
244                    cx.notify();
245                })
246                .ok();
247                return;
248            }
249
250            let candidates = match_candidates.iter().map(|m| &m.path).collect::<Vec<_>>();
251            let matches = fuzzy::match_strings(
252                candidates.as_slice(),
253                &suffix,
254                false,
255                100,
256                &cancel_flag,
257                cx.background_executor().clone(),
258            )
259            .await;
260            if cancel_flag.load(atomic::Ordering::Relaxed) {
261                return;
262            }
263
264            this.update(cx, |this, cx| {
265                this.delegate.matches.clear();
266                this.delegate.string_matches = matches.clone();
267                this.delegate
268                    .matches
269                    .extend(matches.into_iter().map(|m| m.candidate_id));
270                this.delegate.matches.sort_by_key(|m| {
271                    (
272                        this.delegate.directory_state.as_ref().and_then(|d| {
273                            d.match_candidates
274                                .get(*m)
275                                .map(|c| !c.path.string.starts_with(&suffix))
276                        }),
277                        *m,
278                    )
279                });
280                this.delegate.selected_index = 0;
281                cx.notify();
282            })
283            .ok();
284        })
285    }
286
287    fn confirm_completion(
288        &mut self,
289        query: String,
290        _window: &mut Window,
291        _: &mut Context<Picker<Self>>,
292    ) -> Option<String> {
293        Some(
294            maybe!({
295                let m = self.matches.get(self.selected_index)?;
296                let directory_state = self.directory_state.as_ref()?;
297                let candidate = directory_state.match_candidates.get(*m)?;
298                Some(format!(
299                    "{}{}{}",
300                    directory_state.path,
301                    candidate.path.string,
302                    if candidate.is_dir {
303                        MAIN_SEPARATOR_STR
304                    } else {
305                        ""
306                    }
307                ))
308            })
309            .unwrap_or(query),
310        )
311    }
312
313    fn confirm(&mut self, _: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
314        let Some(m) = self.matches.get(self.selected_index) else {
315            return;
316        };
317        let Some(directory_state) = self.directory_state.as_ref() else {
318            return;
319        };
320        let Some(candidate) = directory_state.match_candidates.get(*m) else {
321            return;
322        };
323        let result = if directory_state.path == "/" && candidate.path.string.is_empty() {
324            PathBuf::from("/")
325        } else {
326            Path::new(
327                self.lister
328                    .resolve_tilde(&directory_state.path, cx)
329                    .as_ref(),
330            )
331            .join(&candidate.path.string)
332        };
333        if let Some(tx) = self.tx.take() {
334            tx.send(Some(vec![result])).ok();
335        }
336        cx.emit(gpui::DismissEvent);
337    }
338
339    fn should_dismiss(&self) -> bool {
340        self.should_dismiss
341    }
342
343    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
344        if let Some(tx) = self.tx.take() {
345            tx.send(None).ok();
346        }
347        cx.emit(gpui::DismissEvent)
348    }
349
350    fn render_match(
351        &self,
352        ix: usize,
353        selected: bool,
354        _window: &mut Window,
355        cx: &mut Context<Picker<Self>>,
356    ) -> Option<Self::ListItem> {
357        let settings = FileFinderSettings::get_global(cx);
358        let m = self.matches.get(ix)?;
359        let directory_state = self.directory_state.as_ref()?;
360        let candidate = directory_state.match_candidates.get(*m)?;
361        let highlight_positions = self
362            .string_matches
363            .iter()
364            .find(|string_match| string_match.candidate_id == *m)
365            .map(|string_match| string_match.positions.clone())
366            .unwrap_or_default();
367
368        let file_icon = maybe!({
369            if !settings.file_icons {
370                return None;
371            }
372            let icon = if candidate.is_dir {
373                FileIcons::get_folder_icon(false, cx)?
374            } else {
375                let path = path::Path::new(&candidate.path.string);
376                FileIcons::get_icon(&path, cx)?
377            };
378            Some(Icon::from_path(icon).color(Color::Muted))
379        });
380
381        Some(
382            ListItem::new(ix)
383                .spacing(ListItemSpacing::Sparse)
384                .start_slot::<Icon>(file_icon)
385                .inset(true)
386                .toggle_state(selected)
387                .child(HighlightedLabel::new(
388                    if directory_state.path == "/" {
389                        format!("/{}", candidate.path.string)
390                    } else {
391                        candidate.path.string.clone()
392                    },
393                    highlight_positions,
394                )),
395        )
396    }
397
398    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
399        let text = if let Some(error) = self.directory_state.as_ref().and_then(|s| s.error.clone())
400        {
401            error
402        } else {
403            "No such file or directory".into()
404        };
405        Some(text)
406    }
407
408    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
409        Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext"))
410    }
411}