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::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().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().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().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            for (range, style) in highlight_ranges(
111                &path_match.path.to_string_lossy(),
112                &path_match.positions,
113                gpui::HighlightStyle::color(Color::Accent.color(cx)),
114            ) {
115                highlights.push((range.start + offset..range.end + offset, style))
116            }
117            text.push(separator);
118            offset = text.as_bytes().len();
119
120            if let Some(suffix) = &self.suffix {
121                text.push_str(suffix);
122                let entry = self.entry(project, cx);
123                let color = if let Some(entry) = entry {
124                    if entry.is_dir() {
125                        Color::Accent
126                    } else {
127                        Color::Conflict
128                    }
129                } else {
130                    Color::Created
131                };
132                highlights.push((
133                    offset..offset + suffix.as_bytes().len(),
134                    HighlightStyle::color(color.color(cx)),
135                ));
136                offset += suffix.as_bytes().len();
137                if entry.is_some_and(|e| e.is_dir()) {
138                    text.push(separator);
139                    offset += separator.len_utf8();
140
141                    text.push_str(dir_indicator);
142                    highlights.push((
143                        offset..offset + dir_indicator.bytes().len(),
144                        HighlightStyle::color(Color::Muted.color(cx)),
145                    ));
146                }
147            } else {
148                text.push_str(dir_indicator);
149                highlights.push((
150                    offset..offset + dir_indicator.bytes().len(),
151                    HighlightStyle::color(Color::Muted.color(cx)),
152                ))
153            }
154        } else if let Some(suffix) = &self.suffix {
155            text.push_str(suffix);
156            let existing_prefix_len = self
157                .existing_prefix(project, cx)
158                .map(|prefix| prefix.to_string_lossy().as_bytes().len())
159                .unwrap_or(0);
160
161            if existing_prefix_len > 0 {
162                highlights.push((
163                    offset..offset + existing_prefix_len,
164                    HighlightStyle::color(Color::Accent.color(cx)),
165                ));
166            }
167            highlights.push((
168                offset + existing_prefix_len..offset + suffix.as_bytes().len(),
169                HighlightStyle::color(if self.entry(project, cx).is_some() {
170                    Color::Conflict.color(cx)
171                } else {
172                    Color::Created.color(cx)
173                }),
174            ));
175            offset += suffix.as_bytes().len();
176            if suffix.ends_with('/') {
177                text.push_str(dir_indicator);
178                highlights.push((
179                    offset..offset + dir_indicator.bytes().len(),
180                    HighlightStyle::color(Color::Muted.color(cx)),
181                ));
182            }
183        }
184
185        StyledText::new(text).with_highlights(&cx.text_style().clone(), highlights)
186    }
187}
188
189pub struct NewPathDelegate {
190    project: Model<Project>,
191    tx: Option<oneshot::Sender<Option<ProjectPath>>>,
192    selected_index: usize,
193    matches: Vec<Match>,
194    last_selected_dir: Option<String>,
195    cancel_flag: Arc<AtomicBool>,
196    should_dismiss: bool,
197}
198
199impl NewPathPrompt {
200    pub(crate) fn register(workspace: &mut Workspace, _cx: &mut ViewContext<Workspace>) {
201        workspace.set_prompt_for_new_path(Box::new(|workspace, cx| {
202            let (tx, rx) = futures::channel::oneshot::channel();
203            Self::prompt_for_new_path(workspace, tx, cx);
204            rx
205        }));
206    }
207
208    fn prompt_for_new_path(
209        workspace: &mut Workspace,
210        tx: oneshot::Sender<Option<ProjectPath>>,
211        cx: &mut ViewContext<Workspace>,
212    ) {
213        let project = workspace.project().clone();
214        workspace.toggle_modal(cx, |cx| {
215            let delegate = NewPathDelegate {
216                project,
217                tx: Some(tx),
218                selected_index: 0,
219                matches: vec![],
220                cancel_flag: Arc::new(AtomicBool::new(false)),
221                last_selected_dir: None,
222                should_dismiss: true,
223            };
224
225            Picker::uniform_list(delegate, cx).width(rems(34.))
226        });
227    }
228}
229
230impl PickerDelegate for NewPathDelegate {
231    type ListItem = ui::ListItem;
232
233    fn match_count(&self) -> usize {
234        self.matches.len()
235    }
236
237    fn selected_index(&self) -> usize {
238        self.selected_index
239    }
240
241    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<picker::Picker<Self>>) {
242        self.selected_index = ix;
243        cx.notify();
244    }
245
246    fn update_matches(
247        &mut self,
248        query: String,
249        cx: &mut ViewContext<picker::Picker<Self>>,
250    ) -> gpui::Task<()> {
251        let query = query.trim().trim_start_matches('/');
252        let (dir, suffix) = if let Some(index) = query.rfind('/') {
253            let suffix = if index + 1 < query.len() {
254                Some(query[index + 1..].to_string())
255            } else {
256                None
257            };
258            (query[0..index].to_string(), suffix)
259        } else {
260            (query.to_string(), None)
261        };
262
263        let worktrees = self
264            .project
265            .read(cx)
266            .visible_worktrees(cx)
267            .collect::<Vec<_>>();
268        let include_root_name = worktrees.len() > 1;
269        let candidate_sets = worktrees
270            .into_iter()
271            .map(|worktree| {
272                let worktree = worktree.read(cx);
273                PathMatchCandidateSet {
274                    snapshot: worktree.snapshot(),
275                    include_ignored: worktree
276                        .root_entry()
277                        .map_or(false, |entry| entry.is_ignored),
278                    include_root_name,
279                    candidates: project::Candidates::Directories,
280                }
281            })
282            .collect::<Vec<_>>();
283
284        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
285        self.cancel_flag = Arc::new(AtomicBool::new(false));
286
287        let cancel_flag = self.cancel_flag.clone();
288        let query = query.to_string();
289        let prefix = dir.clone();
290        cx.spawn(|picker, mut cx| async move {
291            let matches = fuzzy::match_path_sets(
292                candidate_sets.as_slice(),
293                &dir,
294                None,
295                false,
296                100,
297                &cancel_flag,
298                cx.background_executor().clone(),
299            )
300            .await;
301            let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
302            if did_cancel {
303                return;
304            }
305            picker
306                .update(&mut cx, |picker, cx| {
307                    picker
308                        .delegate
309                        .set_search_matches(query, prefix, suffix, matches, cx)
310                })
311                .log_err();
312        })
313    }
314
315    fn confirm_update_query(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<String> {
316        let m = self.matches.get(self.selected_index)?;
317        if m.is_dir(self.project.read(cx), cx) {
318            let path = m.relative_path();
319            self.last_selected_dir = Some(path.clone());
320            Some(format!("{}/", path))
321        } else {
322            None
323        }
324    }
325
326    fn confirm(&mut self, _: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
327        let Some(m) = self.matches.get(self.selected_index) else {
328            return;
329        };
330
331        let exists = m.entry(self.project.read(cx), cx).is_some();
332        if exists {
333            self.should_dismiss = false;
334            let answer = cx.prompt(
335                gpui::PromptLevel::Critical,
336                &format!("{} already exists. Do you want to replace it?", m.relative_path()),
337                Some(
338                    "A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
339                ),
340                &["Replace", "Cancel"],
341            );
342            let m = m.clone();
343            cx.spawn(|picker, mut cx| async move {
344                let answer = answer.await.ok();
345                picker
346                    .update(&mut cx, |picker, cx| {
347                        picker.delegate.should_dismiss = true;
348                        if answer != Some(0) {
349                            return;
350                        }
351                        if let Some(path) = m.project_path(picker.delegate.project.read(cx), cx) {
352                            if let Some(tx) = picker.delegate.tx.take() {
353                                tx.send(Some(path)).ok();
354                            }
355                        }
356                        cx.emit(gpui::DismissEvent);
357                    })
358                    .ok();
359            })
360            .detach();
361            return;
362        }
363
364        if let Some(path) = m.project_path(self.project.read(cx), cx) {
365            if let Some(tx) = self.tx.take() {
366                tx.send(Some(path)).ok();
367            }
368        }
369        cx.emit(gpui::DismissEvent);
370    }
371
372    fn should_dismiss(&self) -> bool {
373        self.should_dismiss
374    }
375
376    fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
377        if let Some(tx) = self.tx.take() {
378            tx.send(None).ok();
379        }
380        cx.emit(gpui::DismissEvent)
381    }
382
383    fn render_match(
384        &self,
385        ix: usize,
386        selected: bool,
387        cx: &mut ViewContext<picker::Picker<Self>>,
388    ) -> Option<Self::ListItem> {
389        let m = self.matches.get(ix)?;
390
391        Some(
392            ListItem::new(ix)
393                .spacing(ListItemSpacing::Sparse)
394                .inset(true)
395                .selected(selected)
396                .child(LabelLike::new().child(m.styled_text(self.project.read(cx), cx))),
397        )
398    }
399
400    fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
401        "Type a path...".into()
402    }
403
404    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
405        Arc::from("[directory/]filename.ext")
406    }
407}
408
409impl NewPathDelegate {
410    fn set_search_matches(
411        &mut self,
412        query: String,
413        prefix: String,
414        suffix: Option<String>,
415        matches: Vec<PathMatch>,
416        cx: &mut ViewContext<Picker<Self>>,
417    ) {
418        cx.notify();
419        if query.is_empty() {
420            self.matches = vec![];
421            return;
422        }
423
424        let mut directory_exists = false;
425
426        self.matches = matches
427            .into_iter()
428            .map(|m| {
429                if m.path.as_ref().to_string_lossy() == prefix {
430                    directory_exists = true
431                }
432                Match {
433                    path_match: Some(m),
434                    suffix: suffix.clone(),
435                }
436            })
437            .collect();
438
439        if !directory_exists {
440            if suffix.is_none()
441                || self
442                    .last_selected_dir
443                    .as_ref()
444                    .is_some_and(|d| query.starts_with(d))
445            {
446                self.matches.insert(
447                    0,
448                    Match {
449                        path_match: None,
450                        suffix: Some(query.clone()),
451                    },
452                )
453            } else {
454                self.matches.push(Match {
455                    path_match: None,
456                    suffix: Some(query.clone()),
457                })
458            }
459        }
460    }
461}