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            let result = format!("{}/", path);
358            self.last_selected_dir = Some(path);
359            Some(result)
360        } else {
361            None
362        }
363    }
364
365    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
366        let Some(m) = self.matches.get(self.selected_index) else {
367            return;
368        };
369
370        let exists = m.entry(self.project.read(cx), cx).is_some();
371        if exists {
372            self.should_dismiss = false;
373            let answer = window.prompt(
374                gpui::PromptLevel::Critical,
375                &format!("{} already exists. Do you want to replace it?", m.relative_path()),
376                Some(
377                    "A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
378                ),
379                &["Replace", "Cancel"],
380            cx);
381            let m = m.clone();
382            cx.spawn_in(window, async move |picker, cx| {
383                let answer = answer.await.ok();
384                picker
385                    .update(cx, |picker, cx| {
386                        picker.delegate.should_dismiss = true;
387                        if answer != Some(0) {
388                            return;
389                        }
390                        if let Some(path) = m.project_path(picker.delegate.project.read(cx), cx) {
391                            if let Some(tx) = picker.delegate.tx.take() {
392                                tx.send(Some(path)).ok();
393                            }
394                        }
395                        cx.emit(gpui::DismissEvent);
396                    })
397                    .ok();
398            })
399            .detach();
400            return;
401        }
402
403        if let Some(path) = m.project_path(self.project.read(cx), cx) {
404            if let Some(tx) = self.tx.take() {
405                tx.send(Some(path)).ok();
406            }
407        }
408        cx.emit(gpui::DismissEvent);
409    }
410
411    fn should_dismiss(&self) -> bool {
412        self.should_dismiss
413    }
414
415    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<picker::Picker<Self>>) {
416        if let Some(tx) = self.tx.take() {
417            tx.send(None).ok();
418        }
419        cx.emit(gpui::DismissEvent)
420    }
421
422    fn render_match(
423        &self,
424        ix: usize,
425        selected: bool,
426        window: &mut Window,
427        cx: &mut Context<picker::Picker<Self>>,
428    ) -> Option<Self::ListItem> {
429        let m = self.matches.get(ix)?;
430
431        Some(
432            ListItem::new(ix)
433                .spacing(ListItemSpacing::Sparse)
434                .inset(true)
435                .toggle_state(selected)
436                .child(LabelLike::new().child(m.styled_text(self.project.read(cx), window, cx))),
437        )
438    }
439
440    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
441        Some("Type a path...".into())
442    }
443
444    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
445        Arc::from("[directory/]filename.ext")
446    }
447}
448
449impl NewPathDelegate {
450    fn set_search_matches(
451        &mut self,
452        query: String,
453        prefix: String,
454        suffix: Option<String>,
455        matches: Vec<PathMatch>,
456        cx: &mut Context<Picker<Self>>,
457    ) {
458        cx.notify();
459        if query.is_empty() {
460            self.matches = self
461                .project
462                .read(cx)
463                .worktrees(cx)
464                .flat_map(|worktree| {
465                    let worktree_id = worktree.read(cx).id();
466                    worktree
467                        .read(cx)
468                        .child_entries(Path::new(""))
469                        .filter_map(move |entry| {
470                            entry.is_dir().then(|| Match {
471                                path_match: Some(PathMatch {
472                                    score: 1.0,
473                                    positions: Default::default(),
474                                    worktree_id: worktree_id.to_usize(),
475                                    path: entry.path.clone(),
476                                    path_prefix: "".into(),
477                                    is_dir: entry.is_dir(),
478                                    distance_to_relative_ancestor: 0,
479                                }),
480                                suffix: None,
481                            })
482                        })
483                })
484                .collect();
485
486            return;
487        }
488
489        let mut directory_exists = false;
490
491        self.matches = matches
492            .into_iter()
493            .map(|m| {
494                if m.path.as_ref().to_string_lossy() == prefix {
495                    directory_exists = true
496                }
497                Match {
498                    path_match: Some(m),
499                    suffix: suffix.clone(),
500                }
501            })
502            .collect();
503
504        if !directory_exists {
505            if suffix.is_none()
506                || self
507                    .last_selected_dir
508                    .as_ref()
509                    .is_some_and(|d| query.starts_with(d))
510            {
511                self.matches.insert(
512                    0,
513                    Match {
514                        path_match: None,
515                        suffix: Some(query.clone()),
516                    },
517                )
518            } else {
519                self.matches.push(Match {
520                    path_match: None,
521                    suffix: Some(query.clone()),
522                })
523            }
524        }
525    }
526}