open_path_prompt.rs

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