new_path_prompt.rs

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