new_path_prompt.rs

  1use futures::channel::oneshot;
  2use fuzzy::PathMatch;
  3use gpui::{HighlightStyle, Model, StyledText};
  4use picker::{Picker, PickerDelegate};
  5use project::{Entry, PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
  6use std::{
  7    path::{Path, PathBuf},
  8    sync::{
  9        atomic::{self, AtomicBool},
 10        Arc,
 11    },
 12};
 13use ui::{highlight_ranges, prelude::*, LabelLike, ListItemSpacing};
 14use ui::{ListItem, ViewContext};
 15use util::ResultExt;
 16use workspace::Workspace;
 17
 18pub(crate) struct NewPathPrompt;
 19
 20#[derive(Debug, Clone)]
 21struct Match {
 22    path_match: Option<PathMatch>,
 23    suffix: Option<String>,
 24}
 25
 26impl Match {
 27    fn entry<'a>(&'a self, project: &'a Project, cx: &'a WindowContext) -> Option<&'a Entry> {
 28        if let Some(suffix) = &self.suffix {
 29            let (worktree, path) = if let Some(path_match) = &self.path_match {
 30                (
 31                    project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx),
 32                    path_match.path.join(suffix),
 33                )
 34            } else {
 35                (project.worktrees(cx).next(), PathBuf::from(suffix))
 36            };
 37
 38            worktree.and_then(|worktree| worktree.read(cx).entry_for_path(path))
 39        } else if let Some(path_match) = &self.path_match {
 40            let worktree =
 41                project.worktree_for_id(WorktreeId::from_usize(path_match.worktree_id), cx)?;
 42            worktree.read(cx).entry_for_path(path_match.path.as_ref())
 43        } else {
 44            None
 45        }
 46    }
 47
 48    fn is_dir(&self, project: &Project, cx: &WindowContext) -> bool {
 49        self.entry(project, cx).is_some_and(|e| e.is_dir())
 50            || self.suffix.as_ref().is_some_and(|s| s.ends_with('/'))
 51    }
 52
 53    fn relative_path(&self) -> String {
 54        if let Some(path_match) = &self.path_match {
 55            if let Some(suffix) = &self.suffix {
 56                format!(
 57                    "{}/{}",
 58                    path_match.path.to_string_lossy(),
 59                    suffix.trim_end_matches('/')
 60                )
 61            } else {
 62                path_match.path.to_string_lossy().to_string()
 63            }
 64        } else if let Some(suffix) = &self.suffix {
 65            suffix.trim_end_matches('/').to_string()
 66        } else {
 67            "".to_string()
 68        }
 69    }
 70
 71    fn project_path(&self, project: &Project, cx: &WindowContext) -> Option<ProjectPath> {
 72        let worktree_id = if let Some(path_match) = &self.path_match {
 73            WorktreeId::from_usize(path_match.worktree_id)
 74        } else {
 75            project.worktrees(cx).next()?.read(cx).id()
 76        };
 77
 78        let path = PathBuf::from(self.relative_path());
 79
 80        Some(ProjectPath {
 81            worktree_id,
 82            path: Arc::from(path),
 83        })
 84    }
 85
 86    fn existing_prefix(&self, project: &Project, cx: &WindowContext) -> Option<PathBuf> {
 87        let worktree = project.worktrees(cx).next()?.read(cx);
 88        let mut prefix = PathBuf::new();
 89        let parts = self.suffix.as_ref()?.split('/');
 90        for part in parts {
 91            if worktree.entry_for_path(prefix.join(&part)).is_none() {
 92                return Some(prefix);
 93            }
 94            prefix = prefix.join(part);
 95        }
 96
 97        None
 98    }
 99
100    fn styled_text(&self, project: &Project, cx: &WindowContext) -> StyledText {
101        let mut text = "./".to_string();
102        let mut highlights = Vec::new();
103        let mut offset = text.as_bytes().len();
104
105        let separator = '/';
106        let dir_indicator = "[…]";
107
108        if let Some(path_match) = &self.path_match {
109            text.push_str(&path_match.path.to_string_lossy());
110            let mut whole_path = PathBuf::from(path_match.path_prefix.to_string());
111            whole_path = whole_path.join(path_match.path.clone());
112            for (range, style) in highlight_ranges(
113                &whole_path.to_string_lossy(),
114                &path_match.positions,
115                gpui::HighlightStyle::color(Color::Accent.color(cx)),
116            ) {
117                highlights.push((range.start + offset..range.end + offset, style))
118            }
119            text.push(separator);
120            offset = text.as_bytes().len();
121
122            if let Some(suffix) = &self.suffix {
123                text.push_str(suffix);
124                let entry = self.entry(project, cx);
125                let color = if let Some(entry) = entry {
126                    if entry.is_dir() {
127                        Color::Accent
128                    } else {
129                        Color::Conflict
130                    }
131                } else {
132                    Color::Created
133                };
134                highlights.push((
135                    offset..offset + suffix.as_bytes().len(),
136                    HighlightStyle::color(color.color(cx)),
137                ));
138                offset += suffix.as_bytes().len();
139                if entry.is_some_and(|e| e.is_dir()) {
140                    text.push(separator);
141                    offset += separator.len_utf8();
142
143                    text.push_str(dir_indicator);
144                    highlights.push((
145                        offset..offset + dir_indicator.bytes().len(),
146                        HighlightStyle::color(Color::Muted.color(cx)),
147                    ));
148                }
149            } else {
150                text.push_str(dir_indicator);
151                highlights.push((
152                    offset..offset + dir_indicator.bytes().len(),
153                    HighlightStyle::color(Color::Muted.color(cx)),
154                ))
155            }
156        } else if let Some(suffix) = &self.suffix {
157            text.push_str(suffix);
158            let existing_prefix_len = self
159                .existing_prefix(project, cx)
160                .map(|prefix| prefix.to_string_lossy().as_bytes().len())
161                .unwrap_or(0);
162
163            if existing_prefix_len > 0 {
164                highlights.push((
165                    offset..offset + existing_prefix_len,
166                    HighlightStyle::color(Color::Accent.color(cx)),
167                ));
168            }
169            highlights.push((
170                offset + existing_prefix_len..offset + suffix.as_bytes().len(),
171                HighlightStyle::color(if self.entry(project, cx).is_some() {
172                    Color::Conflict.color(cx)
173                } else {
174                    Color::Created.color(cx)
175                }),
176            ));
177            offset += suffix.as_bytes().len();
178            if suffix.ends_with('/') {
179                text.push_str(dir_indicator);
180                highlights.push((
181                    offset..offset + dir_indicator.bytes().len(),
182                    HighlightStyle::color(Color::Muted.color(cx)),
183                ));
184            }
185        }
186
187        StyledText::new(text).with_highlights(&cx.text_style().clone(), highlights)
188    }
189}
190
191pub struct NewPathDelegate {
192    project: Model<Project>,
193    tx: Option<oneshot::Sender<Option<ProjectPath>>>,
194    selected_index: usize,
195    matches: Vec<Match>,
196    last_selected_dir: Option<String>,
197    cancel_flag: Arc<AtomicBool>,
198    should_dismiss: bool,
199}
200
201impl NewPathPrompt {
202    pub(crate) fn register(workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>) {
203        workspace.set_prompt_for_new_path(Box::new(|workspace, cx| {
204            let (tx, rx) = futures::channel::oneshot::channel();
205            Self::prompt_for_new_path(workspace, tx, cx);
206            rx
207        }));
208    }
209
210    fn prompt_for_new_path(
211        workspace: &mut Workspace,
212        tx: oneshot::Sender<Option<ProjectPath>>,
213        cx: &mut ViewContext<Workspace>,
214    ) {
215        let project = workspace.project().clone();
216        workspace.toggle_modal(cx, |cx| {
217            let delegate = NewPathDelegate {
218                project,
219                tx: Some(tx),
220                selected_index: 0,
221                matches: vec![],
222                cancel_flag: Arc::new(AtomicBool::new(false)),
223                last_selected_dir: None,
224                should_dismiss: true,
225            };
226
227            Picker::uniform_list(delegate, cx).width(rems(34.))
228        });
229    }
230}
231
232impl PickerDelegate for NewPathDelegate {
233    type ListItem = ui::ListItem;
234
235    fn match_count(&self) -> usize {
236        self.matches.len()
237    }
238
239    fn selected_index(&self) -> usize {
240        self.selected_index
241    }
242
243    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<picker::Picker<Self>>) {
244        self.selected_index = ix;
245        cx.notify();
246    }
247
248    fn update_matches(
249        &mut self,
250        query: String,
251        cx: &mut ViewContext<picker::Picker<Self>>,
252    ) -> gpui::Task<()> {
253        let query = query
254            .trim()
255            .trim_start_matches("./")
256            .trim_start_matches('/');
257
258        let (dir, suffix) = if let Some(index) = query.rfind('/') {
259            let suffix = if index + 1 < query.len() {
260                Some(query[index + 1..].to_string())
261            } else {
262                None
263            };
264            (query[0..index].to_string(), suffix)
265        } else {
266            (query.to_string(), None)
267        };
268
269        let worktrees = self
270            .project
271            .read(cx)
272            .visible_worktrees(cx)
273            .collect::<Vec<_>>();
274        let include_root_name = worktrees.len() > 1;
275        let candidate_sets = worktrees
276            .into_iter()
277            .map(|worktree| {
278                let worktree = worktree.read(cx);
279                PathMatchCandidateSet {
280                    snapshot: worktree.snapshot(),
281                    include_ignored: worktree
282                        .root_entry()
283                        .map_or(false, |entry| entry.is_ignored),
284                    include_root_name,
285                    candidates: project::Candidates::Directories,
286                }
287            })
288            .collect::<Vec<_>>();
289
290        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
291        self.cancel_flag = Arc::new(AtomicBool::new(false));
292
293        let cancel_flag = self.cancel_flag.clone();
294        let query = query.to_string();
295        let prefix = dir.clone();
296        cx.spawn(|picker, mut cx| async move {
297            let matches = fuzzy::match_path_sets(
298                candidate_sets.as_slice(),
299                &dir,
300                None,
301                false,
302                100,
303                &cancel_flag,
304                cx.background_executor().clone(),
305            )
306            .await;
307            let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
308            if did_cancel {
309                return;
310            }
311            picker
312                .update(&mut cx, |picker, cx| {
313                    picker
314                        .delegate
315                        .set_search_matches(query, prefix, suffix, matches, cx)
316                })
317                .log_err();
318        })
319    }
320
321    fn confirm_completion(
322        &mut self,
323        _: String,
324        cx: &mut ViewContext<Picker<Self>>,
325    ) -> Option<String> {
326        self.confirm_update_query(cx)
327    }
328
329    fn confirm_update_query(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<String> {
330        let m = self.matches.get(self.selected_index)?;
331        if m.is_dir(self.project.read(cx), cx) {
332            let path = m.relative_path();
333            self.last_selected_dir = Some(path.clone());
334            Some(format!("{}/", path))
335        } else {
336            None
337        }
338    }
339
340    fn confirm(&mut self, _: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
341        let Some(m) = self.matches.get(self.selected_index) else {
342            return;
343        };
344
345        let exists = m.entry(self.project.read(cx), cx).is_some();
346        if exists {
347            self.should_dismiss = false;
348            let answer = cx.prompt(
349                gpui::PromptLevel::Critical,
350                &format!("{} already exists. Do you want to replace it?", m.relative_path()),
351                Some(
352                    "A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
353                ),
354                &["Replace", "Cancel"],
355            );
356            let m = m.clone();
357            cx.spawn(|picker, mut cx| async move {
358                let answer = answer.await.ok();
359                picker
360                    .update(&mut cx, |picker, cx| {
361                        picker.delegate.should_dismiss = true;
362                        if answer != Some(0) {
363                            return;
364                        }
365                        if let Some(path) = m.project_path(picker.delegate.project.read(cx), cx) {
366                            if let Some(tx) = picker.delegate.tx.take() {
367                                tx.send(Some(path)).ok();
368                            }
369                        }
370                        cx.emit(gpui::DismissEvent);
371                    })
372                    .ok();
373            })
374            .detach();
375            return;
376        }
377
378        if let Some(path) = m.project_path(self.project.read(cx), cx) {
379            if let Some(tx) = self.tx.take() {
380                tx.send(Some(path)).ok();
381            }
382        }
383        cx.emit(gpui::DismissEvent);
384    }
385
386    fn should_dismiss(&self) -> bool {
387        self.should_dismiss
388    }
389
390    fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
391        if let Some(tx) = self.tx.take() {
392            tx.send(None).ok();
393        }
394        cx.emit(gpui::DismissEvent)
395    }
396
397    fn render_match(
398        &self,
399        ix: usize,
400        selected: bool,
401        cx: &mut ViewContext<picker::Picker<Self>>,
402    ) -> Option<Self::ListItem> {
403        let m = self.matches.get(ix)?;
404
405        Some(
406            ListItem::new(ix)
407                .spacing(ListItemSpacing::Sparse)
408                .inset(true)
409                .selected(selected)
410                .child(LabelLike::new().child(m.styled_text(self.project.read(cx), cx))),
411        )
412    }
413
414    fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
415        "Type a path...".into()
416    }
417
418    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
419        Arc::from("[directory/]filename.ext")
420    }
421}
422
423impl NewPathDelegate {
424    fn set_search_matches(
425        &mut self,
426        query: String,
427        prefix: String,
428        suffix: Option<String>,
429        matches: Vec<PathMatch>,
430        cx: &mut ViewContext<Picker<Self>>,
431    ) {
432        cx.notify();
433        if query.is_empty() {
434            self.matches = self
435                .project
436                .read(cx)
437                .worktrees(cx)
438                .flat_map(|worktree| {
439                    let worktree_id = worktree.read(cx).id();
440                    worktree
441                        .read(cx)
442                        .child_entries(Path::new(""))
443                        .filter_map(move |entry| {
444                            entry.is_dir().then(|| Match {
445                                path_match: Some(PathMatch {
446                                    score: 1.0,
447                                    positions: Default::default(),
448                                    worktree_id: worktree_id.to_usize(),
449                                    path: entry.path.clone(),
450                                    path_prefix: "".into(),
451                                    is_dir: entry.is_dir(),
452                                    distance_to_relative_ancestor: 0,
453                                }),
454                                suffix: None,
455                            })
456                        })
457                })
458                .collect();
459
460            return;
461        }
462
463        let mut directory_exists = false;
464
465        self.matches = matches
466            .into_iter()
467            .map(|m| {
468                if m.path.as_ref().to_string_lossy() == prefix {
469                    directory_exists = true
470                }
471                Match {
472                    path_match: Some(m),
473                    suffix: suffix.clone(),
474                }
475            })
476            .collect();
477
478        if !directory_exists {
479            if suffix.is_none()
480                || self
481                    .last_selected_dir
482                    .as_ref()
483                    .is_some_and(|d| query.starts_with(d))
484            {
485                self.matches.insert(
486                    0,
487                    Match {
488                        path_match: None,
489                        suffix: Some(query.clone()),
490                    },
491                )
492            } else {
493                self.matches.push(Match {
494                    path_match: None,
495                    suffix: Some(query.clone()),
496                })
497            }
498        }
499    }
500}