open_path_prompt.rs

  1use futures::channel::oneshot;
  2use fuzzy::{StringMatch, StringMatchCandidate};
  3use picker::{Picker, PickerDelegate};
  4use project::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        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, async move |this, cx| {
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(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(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(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(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                this.delegate.selected_index = 0;
270                cx.notify();
271            })
272            .ok();
273        })
274    }
275
276    fn confirm_completion(
277        &mut self,
278        query: String,
279        _window: &mut Window,
280        _: &mut Context<Picker<Self>>,
281    ) -> Option<String> {
282        Some(
283            maybe!({
284                let m = self.matches.get(self.selected_index)?;
285                let directory_state = self.directory_state.as_ref()?;
286                let candidate = directory_state.match_candidates.get(*m)?;
287                Some(format!(
288                    "{}{}{}",
289                    directory_state.path,
290                    candidate.path.string,
291                    if candidate.is_dir {
292                        MAIN_SEPARATOR_STR
293                    } else {
294                        ""
295                    }
296                ))
297            })
298            .unwrap_or(query),
299        )
300    }
301
302    fn confirm(&mut self, _: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
303        let Some(m) = self.matches.get(self.selected_index) else {
304            return;
305        };
306        let Some(directory_state) = self.directory_state.as_ref() else {
307            return;
308        };
309        let Some(candidate) = directory_state.match_candidates.get(*m) else {
310            return;
311        };
312        let result = Path::new(
313            self.lister
314                .resolve_tilde(&directory_state.path, cx)
315                .as_ref(),
316        )
317        .join(&candidate.path.string);
318        if let Some(tx) = self.tx.take() {
319            tx.send(Some(vec![result])).ok();
320        }
321        cx.emit(gpui::DismissEvent);
322    }
323
324    fn should_dismiss(&self) -> bool {
325        self.should_dismiss
326    }
327
328    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
329        if let Some(tx) = self.tx.take() {
330            tx.send(None).ok();
331        }
332        cx.emit(gpui::DismissEvent)
333    }
334
335    fn render_match(
336        &self,
337        ix: usize,
338        selected: bool,
339        _window: &mut Window,
340        _: &mut Context<Picker<Self>>,
341    ) -> Option<Self::ListItem> {
342        let m = self.matches.get(ix)?;
343        let directory_state = self.directory_state.as_ref()?;
344        let candidate = directory_state.match_candidates.get(*m)?;
345        let highlight_positions = self
346            .string_matches
347            .iter()
348            .find(|string_match| string_match.candidate_id == *m)
349            .map(|string_match| string_match.positions.clone())
350            .unwrap_or_default();
351
352        Some(
353            ListItem::new(ix)
354                .spacing(ListItemSpacing::Sparse)
355                .inset(true)
356                .toggle_state(selected)
357                .child(HighlightedLabel::new(
358                    candidate.path.string.clone(),
359                    highlight_positions,
360                )),
361        )
362    }
363
364    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
365        let text = if let Some(error) = self.directory_state.as_ref().and_then(|s| s.error.clone())
366        {
367            error
368        } else {
369            "No such file or directory".into()
370        };
371        Some(text)
372    }
373
374    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
375        Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext"))
376    }
377}