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 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: &WindowContext) -> 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, cx: &WindowContext) -> StyledText {
109        let mut text = "./".to_string();
110        let mut highlights = Vec::new();
111        let mut offset = text.as_bytes().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.as_bytes().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.as_bytes().len(),
144                    HighlightStyle::color(color.color(cx)),
145                ));
146                offset += suffix.as_bytes().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.bytes().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.bytes().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().as_bytes().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.as_bytes().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.as_bytes().len();
186            if suffix.ends_with('/') {
187                text.push_str(dir_indicator);
188                highlights.push((
189                    offset..offset + dir_indicator.bytes().len(),
190                    HighlightStyle::color(Color::Muted.color(cx)),
191                ));
192            }
193        }
194
195        StyledText::new(text).with_highlights(&cx.text_style().clone(), highlights)
196    }
197}
198
199pub struct NewPathDelegate {
200    project: Model<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(workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>) {
211        workspace.set_prompt_for_new_path(Box::new(|workspace, cx| {
212            let (tx, rx) = futures::channel::oneshot::channel();
213            Self::prompt_for_new_path(workspace, tx, cx);
214            rx
215        }));
216    }
217
218    fn prompt_for_new_path(
219        workspace: &mut Workspace,
220        tx: oneshot::Sender<Option<ProjectPath>>,
221        cx: &mut ViewContext<Workspace>,
222    ) {
223        let project = workspace.project().clone();
224        workspace.toggle_modal(cx, |cx| {
225            let delegate = NewPathDelegate {
226                project,
227                tx: Some(tx),
228                selected_index: 0,
229                matches: vec![],
230                cancel_flag: Arc::new(AtomicBool::new(false)),
231                last_selected_dir: None,
232                should_dismiss: true,
233            };
234
235            Picker::uniform_list(delegate, cx).width(rems(34.))
236        });
237    }
238}
239
240impl PickerDelegate for NewPathDelegate {
241    type ListItem = ui::ListItem;
242
243    fn match_count(&self) -> usize {
244        self.matches.len()
245    }
246
247    fn selected_index(&self) -> usize {
248        self.selected_index
249    }
250
251    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<picker::Picker<Self>>) {
252        self.selected_index = ix;
253        cx.notify();
254    }
255
256    fn update_matches(
257        &mut self,
258        query: String,
259        cx: &mut ViewContext<picker::Picker<Self>>,
260    ) -> gpui::Task<()> {
261        let query = query
262            .trim()
263            .trim_start_matches("./")
264            .trim_start_matches('/');
265
266        let (dir, suffix) = if let Some(index) = query.rfind('/') {
267            let suffix = if index + 1 < query.len() {
268                Some(query[index + 1..].to_string())
269            } else {
270                None
271            };
272            (query[0..index].to_string(), suffix)
273        } else {
274            (query.to_string(), None)
275        };
276
277        let worktrees = self
278            .project
279            .read(cx)
280            .visible_worktrees(cx)
281            .collect::<Vec<_>>();
282        let include_root_name = worktrees.len() > 1;
283        let candidate_sets = worktrees
284            .into_iter()
285            .map(|worktree| {
286                let worktree = worktree.read(cx);
287                PathMatchCandidateSet {
288                    snapshot: worktree.snapshot(),
289                    include_ignored: worktree
290                        .root_entry()
291                        .map_or(false, |entry| entry.is_ignored),
292                    include_root_name,
293                    candidates: project::Candidates::Directories,
294                }
295            })
296            .collect::<Vec<_>>();
297
298        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
299        self.cancel_flag = Arc::new(AtomicBool::new(false));
300
301        let cancel_flag = self.cancel_flag.clone();
302        let query = query.to_string();
303        let prefix = dir.clone();
304        cx.spawn(|picker, mut cx| async move {
305            let matches = fuzzy::match_path_sets(
306                candidate_sets.as_slice(),
307                &dir,
308                None,
309                false,
310                100,
311                &cancel_flag,
312                cx.background_executor().clone(),
313            )
314            .await;
315            let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
316            if did_cancel {
317                return;
318            }
319            picker
320                .update(&mut cx, |picker, cx| {
321                    picker
322                        .delegate
323                        .set_search_matches(query, prefix, suffix, matches, cx)
324                })
325                .log_err();
326        })
327    }
328
329    fn confirm_completion(
330        &mut self,
331        _: String,
332        cx: &mut ViewContext<Picker<Self>>,
333    ) -> Option<String> {
334        self.confirm_update_query(cx)
335    }
336
337    fn confirm_update_query(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<String> {
338        let m = self.matches.get(self.selected_index)?;
339        if m.is_dir(self.project.read(cx), cx) {
340            let path = m.relative_path();
341            self.last_selected_dir = Some(path.clone());
342            Some(format!("{}/", path))
343        } else {
344            None
345        }
346    }
347
348    fn confirm(&mut self, _: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
349        let Some(m) = self.matches.get(self.selected_index) else {
350            return;
351        };
352
353        let exists = m.entry(self.project.read(cx), cx).is_some();
354        if exists {
355            self.should_dismiss = false;
356            let answer = cx.prompt(
357                gpui::PromptLevel::Critical,
358                &format!("{} already exists. Do you want to replace it?", m.relative_path()),
359                Some(
360                    "A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
361                ),
362                &["Replace", "Cancel"],
363            );
364            let m = m.clone();
365            cx.spawn(|picker, mut cx| async move {
366                let answer = answer.await.ok();
367                picker
368                    .update(&mut cx, |picker, cx| {
369                        picker.delegate.should_dismiss = true;
370                        if answer != Some(0) {
371                            return;
372                        }
373                        if let Some(path) = m.project_path(picker.delegate.project.read(cx), cx) {
374                            if let Some(tx) = picker.delegate.tx.take() {
375                                tx.send(Some(path)).ok();
376                            }
377                        }
378                        cx.emit(gpui::DismissEvent);
379                    })
380                    .ok();
381            })
382            .detach();
383            return;
384        }
385
386        if let Some(path) = m.project_path(self.project.read(cx), cx) {
387            if let Some(tx) = self.tx.take() {
388                tx.send(Some(path)).ok();
389            }
390        }
391        cx.emit(gpui::DismissEvent);
392    }
393
394    fn should_dismiss(&self) -> bool {
395        self.should_dismiss
396    }
397
398    fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
399        if let Some(tx) = self.tx.take() {
400            tx.send(None).ok();
401        }
402        cx.emit(gpui::DismissEvent)
403    }
404
405    fn render_match(
406        &self,
407        ix: usize,
408        selected: bool,
409        cx: &mut ViewContext<picker::Picker<Self>>,
410    ) -> Option<Self::ListItem> {
411        let m = self.matches.get(ix)?;
412
413        Some(
414            ListItem::new(ix)
415                .spacing(ListItemSpacing::Sparse)
416                .inset(true)
417                .toggle_state(selected)
418                .child(LabelLike::new().child(m.styled_text(self.project.read(cx), cx))),
419        )
420    }
421
422    fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
423        "Type a path...".into()
424    }
425
426    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
427        Arc::from("[directory/]filename.ext")
428    }
429}
430
431impl NewPathDelegate {
432    fn set_search_matches(
433        &mut self,
434        query: String,
435        prefix: String,
436        suffix: Option<String>,
437        matches: Vec<PathMatch>,
438        cx: &mut ViewContext<Picker<Self>>,
439    ) {
440        cx.notify();
441        if query.is_empty() {
442            self.matches = self
443                .project
444                .read(cx)
445                .worktrees(cx)
446                .flat_map(|worktree| {
447                    let worktree_id = worktree.read(cx).id();
448                    worktree
449                        .read(cx)
450                        .child_entries(Path::new(""))
451                        .filter_map(move |entry| {
452                            entry.is_dir().then(|| Match {
453                                path_match: Some(PathMatch {
454                                    score: 1.0,
455                                    positions: Default::default(),
456                                    worktree_id: worktree_id.to_usize(),
457                                    path: entry.path.clone(),
458                                    path_prefix: "".into(),
459                                    is_dir: entry.is_dir(),
460                                    distance_to_relative_ancestor: 0,
461                                }),
462                                suffix: None,
463                            })
464                        })
465                })
466                .collect();
467
468            return;
469        }
470
471        let mut directory_exists = false;
472
473        self.matches = matches
474            .into_iter()
475            .map(|m| {
476                if m.path.as_ref().to_string_lossy() == prefix {
477                    directory_exists = true
478                }
479                Match {
480                    path_match: Some(m),
481                    suffix: suffix.clone(),
482                }
483            })
484            .collect();
485
486        if !directory_exists {
487            if suffix.is_none()
488                || self
489                    .last_selected_dir
490                    .as_ref()
491                    .is_some_and(|d| query.starts_with(d))
492            {
493                self.matches.insert(
494                    0,
495                    Match {
496                        path_match: None,
497                        suffix: Some(query.clone()),
498                    },
499                )
500            } else {
501                self.matches.push(Match {
502                    path_match: None,
503                    suffix: Some(query.clone()),
504                })
505            }
506        }
507    }
508}