open_path_prompt.rs

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