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