open_path_prompt.rs

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