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