open_path_prompt.rs

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