open_path_prompt.rs

  1use crate::file_finder_settings::FileFinderSettings;
  2use file_icons::FileIcons;
  3use futures::channel::oneshot;
  4use fuzzy::{StringMatch, StringMatchCandidate};
  5use gpui::{HighlightStyle, StyledText, Task};
  6use picker::{Picker, PickerDelegate};
  7use project::{DirectoryItem, DirectoryLister};
  8use settings::Settings;
  9use std::{
 10    path::{self, MAIN_SEPARATOR_STR, Path, PathBuf},
 11    sync::{
 12        Arc,
 13        atomic::{self, AtomicBool},
 14    },
 15};
 16use ui::{Context, LabelLike, ListItem, Window};
 17use ui::{HighlightedLabel, ListItemSpacing, prelude::*};
 18use util::{maybe, paths::compare_paths};
 19use workspace::Workspace;
 20
 21pub(crate) struct OpenPathPrompt;
 22
 23#[cfg(target_os = "windows")]
 24const PROMPT_ROOT: &str = "C:\\";
 25#[cfg(not(target_os = "windows"))]
 26const PROMPT_ROOT: &str = "/";
 27
 28#[derive(Debug)]
 29pub struct OpenPathDelegate {
 30    tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
 31    lister: DirectoryLister,
 32    selected_index: usize,
 33    directory_state: DirectoryState,
 34    string_matches: Vec<StringMatch>,
 35    cancel_flag: Arc<AtomicBool>,
 36    should_dismiss: bool,
 37    replace_prompt: Task<()>,
 38}
 39
 40impl OpenPathDelegate {
 41    pub fn new(
 42        tx: oneshot::Sender<Option<Vec<PathBuf>>>,
 43        lister: DirectoryLister,
 44        creating_path: bool,
 45    ) -> Self {
 46        Self {
 47            tx: Some(tx),
 48            lister,
 49            selected_index: 0,
 50            directory_state: DirectoryState::None {
 51                create: creating_path,
 52            },
 53            string_matches: Vec::new(),
 54            cancel_flag: Arc::new(AtomicBool::new(false)),
 55            should_dismiss: true,
 56            replace_prompt: Task::ready(()),
 57        }
 58    }
 59
 60    fn get_entry(&self, selected_match_index: usize) -> Option<CandidateInfo> {
 61        match &self.directory_state {
 62            DirectoryState::List { entries, .. } => {
 63                let id = self.string_matches.get(selected_match_index)?.candidate_id;
 64                entries.iter().find(|entry| entry.path.id == id).cloned()
 65            }
 66            DirectoryState::Create {
 67                user_input,
 68                entries,
 69                ..
 70            } => {
 71                let mut i = selected_match_index;
 72                if let Some(user_input) = user_input {
 73                    if !user_input.exists || !user_input.is_dir {
 74                        if i == 0 {
 75                            return Some(CandidateInfo {
 76                                path: user_input.file.clone(),
 77                                is_dir: false,
 78                            });
 79                        } else {
 80                            i -= 1;
 81                        }
 82                    }
 83                }
 84                let id = self.string_matches.get(i)?.candidate_id;
 85                entries.iter().find(|entry| entry.path.id == id).cloned()
 86            }
 87            DirectoryState::None { .. } => None,
 88        }
 89    }
 90
 91    #[cfg(any(test, feature = "test-support"))]
 92    pub fn collect_match_candidates(&self) -> Vec<String> {
 93        match &self.directory_state {
 94            DirectoryState::List { entries, .. } => self
 95                .string_matches
 96                .iter()
 97                .filter_map(|string_match| {
 98                    entries
 99                        .iter()
100                        .find(|entry| entry.path.id == string_match.candidate_id)
101                        .map(|candidate| candidate.path.string.clone())
102                })
103                .collect(),
104            DirectoryState::Create {
105                user_input,
106                entries,
107                ..
108            } => user_input
109                .into_iter()
110                .filter(|user_input| !user_input.exists || !user_input.is_dir)
111                .map(|user_input| user_input.file.string.clone())
112                .chain(self.string_matches.iter().filter_map(|string_match| {
113                    entries
114                        .iter()
115                        .find(|entry| entry.path.id == string_match.candidate_id)
116                        .map(|candidate| candidate.path.string.clone())
117                }))
118                .collect(),
119            DirectoryState::None { .. } => Vec::new(),
120        }
121    }
122}
123
124#[derive(Debug)]
125enum DirectoryState {
126    List {
127        parent_path: String,
128        entries: Vec<CandidateInfo>,
129        error: Option<SharedString>,
130    },
131    Create {
132        parent_path: String,
133        user_input: Option<UserInput>,
134        entries: Vec<CandidateInfo>,
135    },
136    None {
137        create: bool,
138    },
139}
140
141#[derive(Debug, Clone)]
142struct UserInput {
143    file: StringMatchCandidate,
144    exists: bool,
145    is_dir: bool,
146}
147
148#[derive(Debug, Clone)]
149struct CandidateInfo {
150    path: StringMatchCandidate,
151    is_dir: bool,
152}
153
154impl OpenPathPrompt {
155    pub(crate) fn register(
156        workspace: &mut Workspace,
157        _window: Option<&mut Window>,
158        _: &mut Context<Workspace>,
159    ) {
160        workspace.set_prompt_for_open_path(Box::new(|workspace, lister, window, cx| {
161            let (tx, rx) = futures::channel::oneshot::channel();
162            Self::prompt_for_open_path(workspace, lister, false, tx, window, cx);
163            rx
164        }));
165    }
166
167    pub(crate) fn register_new_path(
168        workspace: &mut Workspace,
169        _window: Option<&mut Window>,
170        _: &mut Context<Workspace>,
171    ) {
172        workspace.set_prompt_for_new_path(Box::new(|workspace, lister, window, cx| {
173            let (tx, rx) = futures::channel::oneshot::channel();
174            Self::prompt_for_open_path(workspace, lister, true, tx, window, cx);
175            rx
176        }));
177    }
178
179    fn prompt_for_open_path(
180        workspace: &mut Workspace,
181        lister: DirectoryLister,
182        creating_path: bool,
183        tx: oneshot::Sender<Option<Vec<PathBuf>>>,
184        window: &mut Window,
185        cx: &mut Context<Workspace>,
186    ) {
187        workspace.toggle_modal(window, cx, |window, cx| {
188            let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path);
189            let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.));
190            let query = lister.default_query(cx);
191            picker.set_query(query, window, cx);
192            picker
193        });
194    }
195}
196
197impl PickerDelegate for OpenPathDelegate {
198    type ListItem = ui::ListItem;
199
200    fn match_count(&self) -> usize {
201        let user_input = if let DirectoryState::Create { user_input, .. } = &self.directory_state {
202            user_input
203                .as_ref()
204                .filter(|input| !input.exists || !input.is_dir)
205                .into_iter()
206                .count()
207        } else {
208            0
209        };
210        self.string_matches.len() + user_input
211    }
212
213    fn selected_index(&self) -> usize {
214        self.selected_index
215    }
216
217    fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
218        self.selected_index = ix;
219        cx.notify();
220    }
221
222    fn update_matches(
223        &mut self,
224        query: String,
225        window: &mut Window,
226        cx: &mut Context<Picker<Self>>,
227    ) -> Task<()> {
228        let lister = &self.lister;
229        let last_item = Path::new(&query)
230            .file_name()
231            .unwrap_or_default()
232            .to_string_lossy();
233        let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) {
234            (dir.to_string(), last_item.into_owned())
235        } else {
236            (query, String::new())
237        };
238        if dir == "" {
239            dir = PROMPT_ROOT.to_string();
240        }
241
242        let query = match &self.directory_state {
243            DirectoryState::List { parent_path, .. } => {
244                if parent_path == &dir {
245                    None
246                } else {
247                    Some(lister.list_directory(dir.clone(), cx))
248                }
249            }
250            DirectoryState::Create {
251                parent_path,
252                user_input,
253                ..
254            } => {
255                if parent_path == &dir
256                    && user_input.as_ref().map(|input| &input.file.string) == Some(&suffix)
257                {
258                    None
259                } else {
260                    Some(lister.list_directory(dir.clone(), cx))
261                }
262            }
263            DirectoryState::None { .. } => Some(lister.list_directory(dir.clone(), cx)),
264        };
265        self.cancel_flag.store(true, atomic::Ordering::Release);
266        self.cancel_flag = Arc::new(AtomicBool::new(false));
267        let cancel_flag = self.cancel_flag.clone();
268
269        cx.spawn_in(window, async move |this, cx| {
270            if let Some(query) = query {
271                let paths = query.await;
272                if cancel_flag.load(atomic::Ordering::Acquire) {
273                    return;
274                }
275
276                if this
277                    .update(cx, |this, _| {
278                        let new_state = match &this.delegate.directory_state {
279                            DirectoryState::None { create: false }
280                            | DirectoryState::List { .. } => match paths {
281                                Ok(paths) => DirectoryState::List {
282                                    entries: path_candidates(&dir, paths),
283                                    parent_path: dir.clone(),
284                                    error: None,
285                                },
286                                Err(e) => DirectoryState::List {
287                                    entries: Vec::new(),
288                                    parent_path: dir.clone(),
289                                    error: Some(SharedString::from(e.to_string())),
290                                },
291                            },
292                            DirectoryState::None { create: true }
293                            | DirectoryState::Create { .. } => match paths {
294                                Ok(paths) => {
295                                    let mut entries = path_candidates(&dir, paths);
296                                    let mut exists = false;
297                                    let mut is_dir = false;
298                                    let mut new_id = None;
299                                    entries.retain(|entry| {
300                                        new_id = new_id.max(Some(entry.path.id));
301                                        if entry.path.string == suffix {
302                                            exists = true;
303                                            is_dir = entry.is_dir;
304                                        }
305                                        !exists || is_dir
306                                    });
307
308                                    let new_id = new_id.map(|id| id + 1).unwrap_or(0);
309                                    let user_input = if suffix.is_empty() {
310                                        None
311                                    } else {
312                                        Some(UserInput {
313                                            file: StringMatchCandidate::new(new_id, &suffix),
314                                            exists,
315                                            is_dir,
316                                        })
317                                    };
318                                    DirectoryState::Create {
319                                        entries,
320                                        parent_path: dir.clone(),
321                                        user_input,
322                                    }
323                                }
324                                Err(_) => DirectoryState::Create {
325                                    entries: Vec::new(),
326                                    parent_path: dir.clone(),
327                                    user_input: Some(UserInput {
328                                        exists: false,
329                                        is_dir: false,
330                                        file: StringMatchCandidate::new(0, &suffix),
331                                    }),
332                                },
333                            },
334                        };
335                        this.delegate.directory_state = new_state;
336                    })
337                    .is_err()
338                {
339                    return;
340                }
341            }
342
343            let Ok(mut new_entries) =
344                this.update(cx, |this, _| match &this.delegate.directory_state {
345                    DirectoryState::List {
346                        entries,
347                        error: None,
348                        ..
349                    }
350                    | DirectoryState::Create { entries, .. } => entries.clone(),
351                    DirectoryState::List { error: Some(_), .. } | DirectoryState::None { .. } => {
352                        Vec::new()
353                    }
354                })
355            else {
356                return;
357            };
358
359            if !suffix.starts_with('.') {
360                new_entries.retain(|entry| !entry.path.string.starts_with('.'));
361            }
362            if suffix.is_empty() {
363                this.update(cx, |this, cx| {
364                    this.delegate.selected_index = 0;
365                    this.delegate.string_matches = new_entries
366                        .iter()
367                        .map(|m| StringMatch {
368                            candidate_id: m.path.id,
369                            score: 0.0,
370                            positions: Vec::new(),
371                            string: m.path.string.clone(),
372                        })
373                        .collect();
374                    this.delegate.directory_state =
375                        match &this.delegate.directory_state {
376                            DirectoryState::None { create: false }
377                            | DirectoryState::List { .. } => DirectoryState::List {
378                                parent_path: dir.clone(),
379                                entries: new_entries,
380                                error: None,
381                            },
382                            DirectoryState::None { create: true }
383                            | DirectoryState::Create { .. } => DirectoryState::Create {
384                                parent_path: dir.clone(),
385                                user_input: None,
386                                entries: new_entries,
387                            },
388                        };
389                    cx.notify();
390                })
391                .ok();
392                return;
393            }
394
395            let Ok(is_create_state) =
396                this.update(cx, |this, _| match &this.delegate.directory_state {
397                    DirectoryState::Create { .. } => true,
398                    DirectoryState::List { .. } => false,
399                    DirectoryState::None { create } => *create,
400                })
401            else {
402                return;
403            };
404
405            let candidates = new_entries
406                .iter()
407                .filter_map(|entry| {
408                    if is_create_state && !entry.is_dir && Some(&suffix) == Some(&entry.path.string)
409                    {
410                        None
411                    } else {
412                        Some(&entry.path)
413                    }
414                })
415                .collect::<Vec<_>>();
416
417            let matches = fuzzy::match_strings(
418                candidates.as_slice(),
419                &suffix,
420                false,
421                true,
422                100,
423                &cancel_flag,
424                cx.background_executor().clone(),
425            )
426            .await;
427            if cancel_flag.load(atomic::Ordering::Acquire) {
428                return;
429            }
430
431            this.update(cx, |this, cx| {
432                this.delegate.selected_index = 0;
433                this.delegate.string_matches = matches.clone();
434                this.delegate.string_matches.sort_by_key(|m| {
435                    (
436                        new_entries
437                            .iter()
438                            .find(|entry| entry.path.id == m.candidate_id)
439                            .map(|entry| &entry.path)
440                            .map(|candidate| !candidate.string.starts_with(&suffix)),
441                        m.candidate_id,
442                    )
443                });
444                this.delegate.directory_state = match &this.delegate.directory_state {
445                    DirectoryState::None { create: false } | DirectoryState::List { .. } => {
446                        DirectoryState::List {
447                            entries: new_entries,
448                            parent_path: dir.clone(),
449                            error: None,
450                        }
451                    }
452                    DirectoryState::None { create: true } => DirectoryState::Create {
453                        entries: new_entries,
454                        parent_path: dir.clone(),
455                        user_input: Some(UserInput {
456                            file: StringMatchCandidate::new(0, &suffix),
457                            exists: false,
458                            is_dir: false,
459                        }),
460                    },
461                    DirectoryState::Create { user_input, .. } => {
462                        let (new_id, exists, is_dir) = user_input
463                            .as_ref()
464                            .map(|input| (input.file.id, input.exists, input.is_dir))
465                            .unwrap_or_else(|| (0, false, false));
466                        DirectoryState::Create {
467                            entries: new_entries,
468                            parent_path: dir.clone(),
469                            user_input: Some(UserInput {
470                                file: StringMatchCandidate::new(new_id, &suffix),
471                                exists,
472                                is_dir,
473                            }),
474                        }
475                    }
476                };
477
478                cx.notify();
479            })
480            .ok();
481        })
482    }
483
484    fn confirm_completion(
485        &mut self,
486        query: String,
487        _window: &mut Window,
488        _: &mut Context<Picker<Self>>,
489    ) -> Option<String> {
490        let candidate = self.get_entry(self.selected_index)?;
491        Some(
492            maybe!({
493                match &self.directory_state {
494                    DirectoryState::Create { parent_path, .. } => Some(format!(
495                        "{}{}{}",
496                        parent_path,
497                        candidate.path.string,
498                        if candidate.is_dir {
499                            MAIN_SEPARATOR_STR
500                        } else {
501                            ""
502                        }
503                    )),
504                    DirectoryState::List { parent_path, .. } => Some(format!(
505                        "{}{}{}",
506                        parent_path,
507                        candidate.path.string,
508                        if candidate.is_dir {
509                            MAIN_SEPARATOR_STR
510                        } else {
511                            ""
512                        }
513                    )),
514                    DirectoryState::None { .. } => return None,
515                }
516            })
517            .unwrap_or(query),
518        )
519    }
520
521    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
522        let Some(candidate) = self.get_entry(self.selected_index) else {
523            return;
524        };
525
526        match &self.directory_state {
527            DirectoryState::None { .. } => return,
528            DirectoryState::List { parent_path, .. } => {
529                let confirmed_path =
530                    if parent_path == PROMPT_ROOT && candidate.path.string.is_empty() {
531                        PathBuf::from(PROMPT_ROOT)
532                    } else {
533                        Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
534                            .join(&candidate.path.string)
535                    };
536                if let Some(tx) = self.tx.take() {
537                    tx.send(Some(vec![confirmed_path])).ok();
538                }
539            }
540            DirectoryState::Create {
541                parent_path,
542                user_input,
543                ..
544            } => match user_input {
545                None => return,
546                Some(user_input) => {
547                    if user_input.is_dir {
548                        return;
549                    }
550                    let prompted_path =
551                        if parent_path == PROMPT_ROOT && user_input.file.string.is_empty() {
552                            PathBuf::from(PROMPT_ROOT)
553                        } else {
554                            Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
555                                .join(&user_input.file.string)
556                        };
557                    if user_input.exists {
558                        self.should_dismiss = false;
559                        let answer = window.prompt(
560                            gpui::PromptLevel::Critical,
561                            &format!("{prompted_path:?} already exists. Do you want to replace it?"),
562                            Some(
563                                "A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
564                            ),
565                            &["Replace", "Cancel"],
566                            cx
567                        );
568                        self.replace_prompt = cx.spawn_in(window, async move |picker, cx| {
569                            let answer = answer.await.ok();
570                            picker
571                                .update(cx, |picker, cx| {
572                                    picker.delegate.should_dismiss = true;
573                                    if answer != Some(0) {
574                                        return;
575                                    }
576                                    if let Some(tx) = picker.delegate.tx.take() {
577                                        tx.send(Some(vec![prompted_path])).ok();
578                                    }
579                                    cx.emit(gpui::DismissEvent);
580                                })
581                                .ok();
582                        });
583                        return;
584                    } else if let Some(tx) = self.tx.take() {
585                        tx.send(Some(vec![prompted_path])).ok();
586                    }
587                }
588            },
589        }
590
591        cx.emit(gpui::DismissEvent);
592    }
593
594    fn should_dismiss(&self) -> bool {
595        self.should_dismiss
596    }
597
598    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
599        if let Some(tx) = self.tx.take() {
600            tx.send(None).ok();
601        }
602        cx.emit(gpui::DismissEvent)
603    }
604
605    fn render_match(
606        &self,
607        ix: usize,
608        selected: bool,
609        window: &mut Window,
610        cx: &mut Context<Picker<Self>>,
611    ) -> Option<Self::ListItem> {
612        let settings = FileFinderSettings::get_global(cx);
613        let candidate = self.get_entry(ix)?;
614        let match_positions = match &self.directory_state {
615            DirectoryState::List { .. } => self.string_matches.get(ix)?.positions.clone(),
616            DirectoryState::Create { user_input, .. } => {
617                if let Some(user_input) = user_input {
618                    if !user_input.exists || !user_input.is_dir {
619                        if ix == 0 {
620                            Vec::new()
621                        } else {
622                            self.string_matches.get(ix - 1)?.positions.clone()
623                        }
624                    } else {
625                        self.string_matches.get(ix)?.positions.clone()
626                    }
627                } else {
628                    self.string_matches.get(ix)?.positions.clone()
629                }
630            }
631            DirectoryState::None { .. } => Vec::new(),
632        };
633
634        let file_icon = maybe!({
635            if !settings.file_icons {
636                return None;
637            }
638            let icon = if candidate.is_dir {
639                FileIcons::get_folder_icon(false, cx)?
640            } else {
641                let path = path::Path::new(&candidate.path.string);
642                FileIcons::get_icon(&path, cx)?
643            };
644            Some(Icon::from_path(icon).color(Color::Muted))
645        });
646
647        match &self.directory_state {
648            DirectoryState::List { parent_path, .. } => Some(
649                ListItem::new(ix)
650                    .spacing(ListItemSpacing::Sparse)
651                    .start_slot::<Icon>(file_icon)
652                    .inset(true)
653                    .toggle_state(selected)
654                    .child(HighlightedLabel::new(
655                        if parent_path == PROMPT_ROOT {
656                            format!("{}{}", PROMPT_ROOT, candidate.path.string)
657                        } else {
658                            candidate.path.string.clone()
659                        },
660                        match_positions,
661                    )),
662            ),
663            DirectoryState::Create {
664                parent_path,
665                user_input,
666                ..
667            } => {
668                let (label, delta) = if parent_path == PROMPT_ROOT {
669                    (
670                        format!("{}{}", PROMPT_ROOT, candidate.path.string),
671                        PROMPT_ROOT.len(),
672                    )
673                } else {
674                    (candidate.path.string.clone(), 0)
675                };
676                let label_len = label.len();
677
678                let label_with_highlights = match user_input {
679                    Some(user_input) => {
680                        if user_input.file.string == candidate.path.string {
681                            if user_input.exists {
682                                let label = if user_input.is_dir {
683                                    label
684                                } else {
685                                    format!("{label} (replace)")
686                                };
687                                StyledText::new(label)
688                                    .with_default_highlights(
689                                        &window.text_style().clone(),
690                                        vec![(
691                                            delta..delta + label_len,
692                                            HighlightStyle::color(Color::Conflict.color(cx)),
693                                        )],
694                                    )
695                                    .into_any_element()
696                            } else {
697                                StyledText::new(format!("{label} (create)"))
698                                    .with_default_highlights(
699                                        &window.text_style().clone(),
700                                        vec![(
701                                            delta..delta + label_len,
702                                            HighlightStyle::color(Color::Created.color(cx)),
703                                        )],
704                                    )
705                                    .into_any_element()
706                            }
707                        } else {
708                            let mut highlight_positions = match_positions;
709                            highlight_positions.iter_mut().for_each(|position| {
710                                *position += delta;
711                            });
712                            HighlightedLabel::new(label, highlight_positions).into_any_element()
713                        }
714                    }
715                    None => {
716                        let mut highlight_positions = match_positions;
717                        highlight_positions.iter_mut().for_each(|position| {
718                            *position += delta;
719                        });
720                        HighlightedLabel::new(label, highlight_positions).into_any_element()
721                    }
722                };
723
724                Some(
725                    ListItem::new(ix)
726                        .spacing(ListItemSpacing::Sparse)
727                        .start_slot::<Icon>(file_icon)
728                        .inset(true)
729                        .toggle_state(selected)
730                        .child(LabelLike::new().child(label_with_highlights)),
731                )
732            }
733            DirectoryState::None { .. } => return None,
734        }
735    }
736
737    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
738        Some(match &self.directory_state {
739            DirectoryState::Create { .. } => SharedString::from("Type a path…"),
740            DirectoryState::List {
741                error: Some(error), ..
742            } => error.clone(),
743            DirectoryState::List { .. } | DirectoryState::None { .. } => {
744                SharedString::from("No such file or directory")
745            }
746        })
747    }
748
749    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
750        Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext"))
751    }
752}
753
754fn path_candidates(parent_path: &String, mut children: Vec<DirectoryItem>) -> Vec<CandidateInfo> {
755    if *parent_path == PROMPT_ROOT {
756        children.push(DirectoryItem {
757            is_dir: true,
758            path: PathBuf::default(),
759        });
760    }
761
762    children.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
763    children
764        .iter()
765        .enumerate()
766        .map(|(ix, item)| CandidateInfo {
767            path: StringMatchCandidate::new(ix, &item.path.to_string_lossy()),
768            is_dir: item.is_dir,
769        })
770        .collect()
771}