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                100,
422                &cancel_flag,
423                cx.background_executor().clone(),
424            )
425            .await;
426            if cancel_flag.load(atomic::Ordering::Acquire) {
427                return;
428            }
429
430            this.update(cx, |this, cx| {
431                this.delegate.selected_index = 0;
432                this.delegate.string_matches = matches.clone();
433                this.delegate.string_matches.sort_by_key(|m| {
434                    (
435                        new_entries
436                            .iter()
437                            .find(|entry| entry.path.id == m.candidate_id)
438                            .map(|entry| &entry.path)
439                            .map(|candidate| !candidate.string.starts_with(&suffix)),
440                        m.candidate_id,
441                    )
442                });
443                this.delegate.directory_state = match &this.delegate.directory_state {
444                    DirectoryState::None { create: false } | DirectoryState::List { .. } => {
445                        DirectoryState::List {
446                            entries: new_entries,
447                            parent_path: dir.clone(),
448                            error: None,
449                        }
450                    }
451                    DirectoryState::None { create: true } => DirectoryState::Create {
452                        entries: new_entries,
453                        parent_path: dir.clone(),
454                        user_input: Some(UserInput {
455                            file: StringMatchCandidate::new(0, &suffix),
456                            exists: false,
457                            is_dir: false,
458                        }),
459                    },
460                    DirectoryState::Create { user_input, .. } => {
461                        let (new_id, exists, is_dir) = user_input
462                            .as_ref()
463                            .map(|input| (input.file.id, input.exists, input.is_dir))
464                            .unwrap_or_else(|| (0, false, false));
465                        DirectoryState::Create {
466                            entries: new_entries,
467                            parent_path: dir.clone(),
468                            user_input: Some(UserInput {
469                                file: StringMatchCandidate::new(new_id, &suffix),
470                                exists,
471                                is_dir,
472                            }),
473                        }
474                    }
475                };
476
477                cx.notify();
478            })
479            .ok();
480        })
481    }
482
483    fn confirm_completion(
484        &mut self,
485        query: String,
486        _window: &mut Window,
487        _: &mut Context<Picker<Self>>,
488    ) -> Option<String> {
489        let candidate = self.get_entry(self.selected_index)?;
490        Some(
491            maybe!({
492                match &self.directory_state {
493                    DirectoryState::Create { parent_path, .. } => Some(format!(
494                        "{}{}{}",
495                        parent_path,
496                        candidate.path.string,
497                        if candidate.is_dir {
498                            MAIN_SEPARATOR_STR
499                        } else {
500                            ""
501                        }
502                    )),
503                    DirectoryState::List { parent_path, .. } => Some(format!(
504                        "{}{}{}",
505                        parent_path,
506                        candidate.path.string,
507                        if candidate.is_dir {
508                            MAIN_SEPARATOR_STR
509                        } else {
510                            ""
511                        }
512                    )),
513                    DirectoryState::None { .. } => return None,
514                }
515            })
516            .unwrap_or(query),
517        )
518    }
519
520    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
521        let Some(candidate) = self.get_entry(self.selected_index) else {
522            return;
523        };
524
525        match &self.directory_state {
526            DirectoryState::None { .. } => return,
527            DirectoryState::List { parent_path, .. } => {
528                let confirmed_path =
529                    if parent_path == PROMPT_ROOT && candidate.path.string.is_empty() {
530                        PathBuf::from(PROMPT_ROOT)
531                    } else {
532                        Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
533                            .join(&candidate.path.string)
534                    };
535                if let Some(tx) = self.tx.take() {
536                    tx.send(Some(vec![confirmed_path])).ok();
537                }
538            }
539            DirectoryState::Create {
540                parent_path,
541                user_input,
542                ..
543            } => match user_input {
544                None => return,
545                Some(user_input) => {
546                    if user_input.is_dir {
547                        return;
548                    }
549                    let prompted_path =
550                        if parent_path == PROMPT_ROOT && user_input.file.string.is_empty() {
551                            PathBuf::from(PROMPT_ROOT)
552                        } else {
553                            Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
554                                .join(&user_input.file.string)
555                        };
556                    if user_input.exists {
557                        self.should_dismiss = false;
558                        let answer = window.prompt(
559                            gpui::PromptLevel::Critical,
560                            &format!("{prompted_path:?} already exists. Do you want to replace it?"),
561                            Some(
562                                "A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
563                            ),
564                            &["Replace", "Cancel"],
565                            cx
566                        );
567                        self.replace_prompt = cx.spawn_in(window, async move |picker, cx| {
568                            let answer = answer.await.ok();
569                            picker
570                                .update(cx, |picker, cx| {
571                                    picker.delegate.should_dismiss = true;
572                                    if answer != Some(0) {
573                                        return;
574                                    }
575                                    if let Some(tx) = picker.delegate.tx.take() {
576                                        tx.send(Some(vec![prompted_path])).ok();
577                                    }
578                                    cx.emit(gpui::DismissEvent);
579                                })
580                                .ok();
581                        });
582                        return;
583                    } else if let Some(tx) = self.tx.take() {
584                        tx.send(Some(vec![prompted_path])).ok();
585                    }
586                }
587            },
588        }
589
590        cx.emit(gpui::DismissEvent);
591    }
592
593    fn should_dismiss(&self) -> bool {
594        self.should_dismiss
595    }
596
597    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
598        if let Some(tx) = self.tx.take() {
599            tx.send(None).ok();
600        }
601        cx.emit(gpui::DismissEvent)
602    }
603
604    fn render_match(
605        &self,
606        ix: usize,
607        selected: bool,
608        window: &mut Window,
609        cx: &mut Context<Picker<Self>>,
610    ) -> Option<Self::ListItem> {
611        let settings = FileFinderSettings::get_global(cx);
612        let candidate = self.get_entry(ix)?;
613        let match_positions = match &self.directory_state {
614            DirectoryState::List { .. } => self.string_matches.get(ix)?.positions.clone(),
615            DirectoryState::Create { user_input, .. } => {
616                if let Some(user_input) = user_input {
617                    if !user_input.exists || !user_input.is_dir {
618                        if ix == 0 {
619                            Vec::new()
620                        } else {
621                            self.string_matches.get(ix - 1)?.positions.clone()
622                        }
623                    } else {
624                        self.string_matches.get(ix)?.positions.clone()
625                    }
626                } else {
627                    self.string_matches.get(ix)?.positions.clone()
628                }
629            }
630            DirectoryState::None { .. } => Vec::new(),
631        };
632
633        let file_icon = maybe!({
634            if !settings.file_icons {
635                return None;
636            }
637            let icon = if candidate.is_dir {
638                FileIcons::get_folder_icon(false, cx)?
639            } else {
640                let path = path::Path::new(&candidate.path.string);
641                FileIcons::get_icon(&path, cx)?
642            };
643            Some(Icon::from_path(icon).color(Color::Muted))
644        });
645
646        match &self.directory_state {
647            DirectoryState::List { parent_path, .. } => Some(
648                ListItem::new(ix)
649                    .spacing(ListItemSpacing::Sparse)
650                    .start_slot::<Icon>(file_icon)
651                    .inset(true)
652                    .toggle_state(selected)
653                    .child(HighlightedLabel::new(
654                        if parent_path == PROMPT_ROOT {
655                            format!("{}{}", PROMPT_ROOT, candidate.path.string)
656                        } else {
657                            candidate.path.string.clone()
658                        },
659                        match_positions,
660                    )),
661            ),
662            DirectoryState::Create {
663                parent_path,
664                user_input,
665                ..
666            } => {
667                let (label, delta) = if parent_path == PROMPT_ROOT {
668                    (
669                        format!("{}{}", PROMPT_ROOT, candidate.path.string),
670                        PROMPT_ROOT.len(),
671                    )
672                } else {
673                    (candidate.path.string.clone(), 0)
674                };
675                let label_len = label.len();
676
677                let label_with_highlights = match user_input {
678                    Some(user_input) => {
679                        if user_input.file.string == candidate.path.string {
680                            if user_input.exists {
681                                let label = if user_input.is_dir {
682                                    label
683                                } else {
684                                    format!("{label} (replace)")
685                                };
686                                StyledText::new(label)
687                                    .with_default_highlights(
688                                        &window.text_style().clone(),
689                                        vec![(
690                                            delta..delta + label_len,
691                                            HighlightStyle::color(Color::Conflict.color(cx)),
692                                        )],
693                                    )
694                                    .into_any_element()
695                            } else {
696                                StyledText::new(format!("{label} (create)"))
697                                    .with_default_highlights(
698                                        &window.text_style().clone(),
699                                        vec![(
700                                            delta..delta + label_len,
701                                            HighlightStyle::color(Color::Created.color(cx)),
702                                        )],
703                                    )
704                                    .into_any_element()
705                            }
706                        } else {
707                            let mut highlight_positions = match_positions;
708                            highlight_positions.iter_mut().for_each(|position| {
709                                *position += delta;
710                            });
711                            HighlightedLabel::new(label, highlight_positions).into_any_element()
712                        }
713                    }
714                    None => {
715                        let mut highlight_positions = match_positions;
716                        highlight_positions.iter_mut().for_each(|position| {
717                            *position += delta;
718                        });
719                        HighlightedLabel::new(label, highlight_positions).into_any_element()
720                    }
721                };
722
723                Some(
724                    ListItem::new(ix)
725                        .spacing(ListItemSpacing::Sparse)
726                        .start_slot::<Icon>(file_icon)
727                        .inset(true)
728                        .toggle_state(selected)
729                        .child(LabelLike::new().child(label_with_highlights)),
730                )
731            }
732            DirectoryState::None { .. } => return None,
733        }
734    }
735
736    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
737        Some(match &self.directory_state {
738            DirectoryState::Create { .. } => SharedString::from("Type a path…"),
739            DirectoryState::List {
740                error: Some(error), ..
741            } => error.clone(),
742            DirectoryState::List { .. } | DirectoryState::None { .. } => {
743                SharedString::from("No such file or directory")
744            }
745        })
746    }
747
748    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
749        Arc::from(format!("[directory{MAIN_SEPARATOR_STR}]filename.ext"))
750    }
751}
752
753fn path_candidates(parent_path: &String, mut children: Vec<DirectoryItem>) -> Vec<CandidateInfo> {
754    if *parent_path == PROMPT_ROOT {
755        children.push(DirectoryItem {
756            is_dir: true,
757            path: PathBuf::default(),
758        });
759    }
760
761    children.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true)));
762    children
763        .iter()
764        .enumerate()
765        .map(|(ix, item)| CandidateInfo {
766            path: StringMatchCandidate::new(ix, &item.path.to_string_lossy()),
767            is_dir: item.is_dir,
768        })
769        .collect()
770}