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    // todo(windows)
 97    // Is this method woring correctly on Windows? This method uses `/` for path separator.
 98    fn update_matches(
 99        &mut self,
100        query: String,
101        window: &mut Window,
102        cx: &mut Context<Picker<Self>>,
103    ) -> gpui::Task<()> {
104        let lister = self.lister.clone();
105        let (mut dir, suffix) = if let Some(index) = query.rfind('/') {
106            (query[..index].to_string(), query[index + 1..].to_string())
107        } else {
108            (query, String::new())
109        };
110        if dir == "" {
111            dir = "/".to_string();
112        }
113
114        let query = if self
115            .directory_state
116            .as_ref()
117            .map_or(false, |s| s.path == dir)
118        {
119            None
120        } else {
121            Some(lister.list_directory(dir.clone(), cx))
122        };
123        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
124        self.cancel_flag = Arc::new(AtomicBool::new(false));
125        let cancel_flag = self.cancel_flag.clone();
126
127        cx.spawn_in(window, |this, mut cx| async move {
128            if let Some(query) = query {
129                let paths = query.await;
130                if cancel_flag.load(atomic::Ordering::Relaxed) {
131                    return;
132                }
133
134                this.update(&mut cx, |this, _| {
135                    this.delegate.directory_state = Some(match paths {
136                        Ok(mut paths) => {
137                            paths.sort_by(|a, b| compare_paths((a, true), (b, true)));
138                            let match_candidates = paths
139                                .iter()
140                                .enumerate()
141                                .map(|(ix, path)| {
142                                    StringMatchCandidate::new(ix, &path.to_string_lossy())
143                                })
144                                .collect::<Vec<_>>();
145
146                            DirectoryState {
147                                match_candidates,
148                                path: dir,
149                                error: None,
150                            }
151                        }
152                        Err(err) => DirectoryState {
153                            match_candidates: vec![],
154                            path: dir,
155                            error: Some(err.to_string().into()),
156                        },
157                    });
158                })
159                .ok();
160            }
161
162            let match_candidates = this
163                .update(&mut cx, |this, cx| {
164                    let directory_state = this.delegate.directory_state.as_ref()?;
165                    if directory_state.error.is_some() {
166                        this.delegate.matches.clear();
167                        this.delegate.selected_index = 0;
168                        cx.notify();
169                        return None;
170                    }
171
172                    Some(directory_state.match_candidates.clone())
173                })
174                .unwrap_or(None);
175
176            let Some(mut match_candidates) = match_candidates else {
177                return;
178            };
179
180            if !suffix.starts_with('.') {
181                match_candidates.retain(|m| !m.string.starts_with('.'));
182            }
183
184            if suffix == "" {
185                this.update(&mut cx, |this, cx| {
186                    this.delegate.matches.clear();
187                    this.delegate
188                        .matches
189                        .extend(match_candidates.iter().map(|m| m.id));
190
191                    cx.notify();
192                })
193                .ok();
194                return;
195            }
196
197            let matches = fuzzy::match_strings(
198                match_candidates.as_slice(),
199                &suffix,
200                false,
201                100,
202                &cancel_flag,
203                cx.background_executor().clone(),
204            )
205            .await;
206            if cancel_flag.load(atomic::Ordering::Relaxed) {
207                return;
208            }
209
210            this.update(&mut cx, |this, cx| {
211                this.delegate.matches.clear();
212                this.delegate
213                    .matches
214                    .extend(matches.into_iter().map(|m| m.candidate_id));
215                this.delegate.matches.sort_by_key(|m| {
216                    (
217                        this.delegate.directory_state.as_ref().and_then(|d| {
218                            d.match_candidates
219                                .get(*m)
220                                .map(|c| !c.string.starts_with(&suffix))
221                        }),
222                        *m,
223                    )
224                });
225                cx.notify();
226            })
227            .ok();
228        })
229    }
230
231    fn confirm_completion(
232        &mut self,
233        query: String,
234        _window: &mut Window,
235        _: &mut Context<Picker<Self>>,
236    ) -> Option<String> {
237        Some(
238            maybe!({
239                let m = self.matches.get(self.selected_index)?;
240                let directory_state = self.directory_state.as_ref()?;
241                let candidate = directory_state.match_candidates.get(*m)?;
242                Some(format!("{}/{}", directory_state.path, candidate.string))
243            })
244            .unwrap_or(query),
245        )
246    }
247
248    fn confirm(&mut self, _: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
249        let Some(m) = self.matches.get(self.selected_index) else {
250            return;
251        };
252        let Some(directory_state) = self.directory_state.as_ref() else {
253            return;
254        };
255        let Some(candidate) = directory_state.match_candidates.get(*m) else {
256            return;
257        };
258        let result = Path::new(
259            self.lister
260                .resolve_tilde(&directory_state.path, cx)
261                .as_ref(),
262        )
263        .join(&candidate.string);
264        if let Some(tx) = self.tx.take() {
265            tx.send(Some(vec![result])).ok();
266        }
267        cx.emit(gpui::DismissEvent);
268    }
269
270    fn should_dismiss(&self) -> bool {
271        self.should_dismiss
272    }
273
274    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
275        if let Some(tx) = self.tx.take() {
276            tx.send(None).ok();
277        }
278        cx.emit(gpui::DismissEvent)
279    }
280
281    fn render_match(
282        &self,
283        ix: usize,
284        selected: bool,
285        _window: &mut Window,
286        _: &mut Context<Picker<Self>>,
287    ) -> Option<Self::ListItem> {
288        let m = self.matches.get(ix)?;
289        let directory_state = self.directory_state.as_ref()?;
290        let candidate = directory_state.match_candidates.get(*m)?;
291
292        Some(
293            ListItem::new(ix)
294                .spacing(ListItemSpacing::Sparse)
295                .inset(true)
296                .toggle_state(selected)
297                .child(LabelLike::new().child(candidate.string.clone())),
298        )
299    }
300
301    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> SharedString {
302        if let Some(error) = self.directory_state.as_ref().and_then(|s| s.error.clone()) {
303            error
304        } else {
305            "No such file or directory".into()
306        }
307    }
308
309    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
310        Arc::from("[directory/]filename.ext")
311    }
312}