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