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}