open_path_prompt.rs

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