open_path_prompt.rs

  1use futures::channel::oneshot;
  2use fuzzy::{StringMatch, 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::*, HighlightedLabel, 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    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        if dir == "" {
141            #[cfg(not(target_os = "windows"))]
142            {
143                dir = "/".to_string();
144            }
145            #[cfg(target_os = "windows")]
146            {
147                dir = "C:\\".to_string();
148            }
149        }
150
151        let query = if self
152            .directory_state
153            .as_ref()
154            .map_or(false, |s| s.path == dir)
155        {
156            None
157        } else {
158            Some(lister.list_directory(dir.clone(), cx))
159        };
160        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
161        self.cancel_flag = Arc::new(AtomicBool::new(false));
162        let cancel_flag = self.cancel_flag.clone();
163
164        cx.spawn_in(window, |this, mut cx| async move {
165            if let Some(query) = query {
166                let paths = query.await;
167                if cancel_flag.load(atomic::Ordering::Relaxed) {
168                    return;
169                }
170
171                this.update(&mut cx, |this, _| {
172                    this.delegate.directory_state = Some(match paths {
173                        Ok(mut paths) => {
174                            paths.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
175                            let match_candidates = paths
176                                .iter()
177                                .enumerate()
178                                .map(|(ix, item)| CandidateInfo {
179                                    path: StringMatchCandidate::new(
180                                        ix,
181                                        &item.path.to_string_lossy(),
182                                    ),
183                                    is_dir: item.is_dir,
184                                })
185                                .collect::<Vec<_>>();
186
187                            DirectoryState {
188                                match_candidates,
189                                path: dir,
190                                error: None,
191                            }
192                        }
193                        Err(err) => DirectoryState {
194                            match_candidates: vec![],
195                            path: dir,
196                            error: Some(err.to_string().into()),
197                        },
198                    });
199                })
200                .ok();
201            }
202
203            let match_candidates = this
204                .update(&mut cx, |this, cx| {
205                    let directory_state = this.delegate.directory_state.as_ref()?;
206                    if directory_state.error.is_some() {
207                        this.delegate.matches.clear();
208                        this.delegate.selected_index = 0;
209                        cx.notify();
210                        return None;
211                    }
212
213                    Some(directory_state.match_candidates.clone())
214                })
215                .unwrap_or(None);
216
217            let Some(mut match_candidates) = match_candidates else {
218                return;
219            };
220
221            if !suffix.starts_with('.') {
222                match_candidates.retain(|m| !m.path.string.starts_with('.'));
223            }
224
225            if suffix == "" {
226                this.update(&mut cx, |this, cx| {
227                    this.delegate.matches.clear();
228                    this.delegate.string_matches.clear();
229                    this.delegate
230                        .matches
231                        .extend(match_candidates.iter().map(|m| m.path.id));
232
233                    cx.notify();
234                })
235                .ok();
236                return;
237            }
238
239            let candidates = match_candidates.iter().map(|m| &m.path).collect::<Vec<_>>();
240            let matches = fuzzy::match_strings(
241                candidates.as_slice(),
242                &suffix,
243                false,
244                100,
245                &cancel_flag,
246                cx.background_executor().clone(),
247            )
248            .await;
249            if cancel_flag.load(atomic::Ordering::Relaxed) {
250                return;
251            }
252
253            this.update(&mut cx, |this, cx| {
254                this.delegate.matches.clear();
255                this.delegate.string_matches = matches.clone();
256                this.delegate
257                    .matches
258                    .extend(matches.into_iter().map(|m| m.candidate_id));
259                this.delegate.matches.sort_by_key(|m| {
260                    (
261                        this.delegate.directory_state.as_ref().and_then(|d| {
262                            d.match_candidates
263                                .get(*m)
264                                .map(|c| !c.path.string.starts_with(&suffix))
265                        }),
266                        *m,
267                    )
268                });
269                cx.notify();
270            })
271            .ok();
272        })
273    }
274
275    fn confirm_completion(
276        &mut self,
277        query: String,
278        _window: &mut Window,
279        _: &mut Context<Picker<Self>>,
280    ) -> Option<String> {
281        Some(
282            maybe!({
283                let m = self.matches.get(self.selected_index)?;
284                let directory_state = self.directory_state.as_ref()?;
285                let candidate = directory_state.match_candidates.get(*m)?;
286                Some(format!(
287                    "{}{}{}",
288                    directory_state.path,
289                    candidate.path.string,
290                    if candidate.is_dir {
291                        MAIN_SEPARATOR_STR
292                    } else {
293                        ""
294                    }
295                ))
296            })
297            .unwrap_or(query),
298        )
299    }
300
301    fn confirm(&mut self, _: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
302        let Some(m) = self.matches.get(self.selected_index) else {
303            return;
304        };
305        let Some(directory_state) = self.directory_state.as_ref() else {
306            return;
307        };
308        let Some(candidate) = directory_state.match_candidates.get(*m) else {
309            return;
310        };
311        let result = Path::new(
312            self.lister
313                .resolve_tilde(&directory_state.path, cx)
314                .as_ref(),
315        )
316        .join(&candidate.path.string);
317        if let Some(tx) = self.tx.take() {
318            tx.send(Some(vec![result])).ok();
319        }
320        cx.emit(gpui::DismissEvent);
321    }
322
323    fn should_dismiss(&self) -> bool {
324        self.should_dismiss
325    }
326
327    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
328        if let Some(tx) = self.tx.take() {
329            tx.send(None).ok();
330        }
331        cx.emit(gpui::DismissEvent)
332    }
333
334    fn render_match(
335        &self,
336        ix: usize,
337        selected: bool,
338        _window: &mut Window,
339        _: &mut Context<Picker<Self>>,
340    ) -> Option<Self::ListItem> {
341        let m = self.matches.get(ix)?;
342        let directory_state = self.directory_state.as_ref()?;
343        let candidate = directory_state.match_candidates.get(*m)?;
344        let highlight_positions = self
345            .string_matches
346            .iter()
347            .find(|string_match| string_match.candidate_id == *m)
348            .map(|string_match| string_match.positions.clone())
349            .unwrap_or_default();
350
351        Some(
352            ListItem::new(ix)
353                .spacing(ListItemSpacing::Sparse)
354                .inset(true)
355                .toggle_state(selected)
356                .child(HighlightedLabel::new(
357                    candidate.path.string.clone(),
358                    highlight_positions,
359                )),
360        )
361    }
362
363    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
364        let text = if let Some(error) = self.directory_state.as_ref().and_then(|s| s.error.clone())
365        {
366            error
367        } else {
368            "No such file or directory".into()
369        };
370        Some(text)
371    }
372
373    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
374        Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext"))
375    }
376}