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(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            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
252            .trim()
253            .trim_start_matches("./")
254            .trim_start_matches('/');
255        let (dir, suffix) = if let Some(index) = query.rfind('/') {
256            let suffix = if index + 1 < query.len() {
257                Some(query[index + 1..].to_string())
258            } else {
259                None
260            };
261            (query[0..index].to_string(), suffix)
262        } else {
263            (query.to_string(), None)
264        };
265
266        let worktrees = self
267            .project
268            .read(cx)
269            .visible_worktrees(cx)
270            .collect::<Vec<_>>();
271        let include_root_name = worktrees.len() > 1;
272        let candidate_sets = worktrees
273            .into_iter()
274            .map(|worktree| {
275                let worktree = worktree.read(cx);
276                PathMatchCandidateSet {
277                    snapshot: worktree.snapshot(),
278                    include_ignored: worktree
279                        .root_entry()
280                        .map_or(false, |entry| entry.is_ignored),
281                    include_root_name,
282                    candidates: project::Candidates::Directories,
283                }
284            })
285            .collect::<Vec<_>>();
286
287        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
288        self.cancel_flag = Arc::new(AtomicBool::new(false));
289
290        let cancel_flag = self.cancel_flag.clone();
291        let query = query.to_string();
292        let prefix = dir.clone();
293        cx.spawn(|picker, mut cx| async move {
294            let matches = fuzzy::match_path_sets(
295                candidate_sets.as_slice(),
296                &dir,
297                None,
298                false,
299                100,
300                &cancel_flag,
301                cx.background_executor().clone(),
302            )
303            .await;
304            let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
305            if did_cancel {
306                return;
307            }
308            picker
309                .update(&mut cx, |picker, cx| {
310                    picker
311                        .delegate
312                        .set_search_matches(query, prefix, suffix, matches, cx)
313                })
314                .log_err();
315        })
316    }
317
318    fn confirm_update_query(&mut self, cx: &mut ViewContext<Picker<Self>>) -> Option<String> {
319        let m = self.matches.get(self.selected_index)?;
320        if m.is_dir(self.project.read(cx), cx) {
321            let path = m.relative_path();
322            self.last_selected_dir = Some(path.clone());
323            Some(format!("{}/", path))
324        } else {
325            None
326        }
327    }
328
329    fn confirm(&mut self, _: bool, cx: &mut ViewContext<picker::Picker<Self>>) {
330        let Some(m) = self.matches.get(self.selected_index) else {
331            return;
332        };
333
334        let exists = m.entry(self.project.read(cx), cx).is_some();
335        if exists {
336            self.should_dismiss = false;
337            let answer = cx.prompt(
338                gpui::PromptLevel::Critical,
339                &format!("{} already exists. Do you want to replace it?", m.relative_path()),
340                Some(
341                    "A file or folder with the same name already exists. Replacing it will overwrite its current contents.",
342                ),
343                &["Replace", "Cancel"],
344            );
345            let m = m.clone();
346            cx.spawn(|picker, mut cx| async move {
347                let answer = answer.await.ok();
348                picker
349                    .update(&mut cx, |picker, cx| {
350                        picker.delegate.should_dismiss = true;
351                        if answer != Some(0) {
352                            return;
353                        }
354                        if let Some(path) = m.project_path(picker.delegate.project.read(cx), cx) {
355                            if let Some(tx) = picker.delegate.tx.take() {
356                                tx.send(Some(path)).ok();
357                            }
358                        }
359                        cx.emit(gpui::DismissEvent);
360                    })
361                    .ok();
362            })
363            .detach();
364            return;
365        }
366
367        if let Some(path) = m.project_path(self.project.read(cx), cx) {
368            if let Some(tx) = self.tx.take() {
369                tx.send(Some(path)).ok();
370            }
371        }
372        cx.emit(gpui::DismissEvent);
373    }
374
375    fn should_dismiss(&self) -> bool {
376        self.should_dismiss
377    }
378
379    fn dismissed(&mut self, cx: &mut ViewContext<picker::Picker<Self>>) {
380        if let Some(tx) = self.tx.take() {
381            tx.send(None).ok();
382        }
383        cx.emit(gpui::DismissEvent)
384    }
385
386    fn render_match(
387        &self,
388        ix: usize,
389        selected: bool,
390        cx: &mut ViewContext<picker::Picker<Self>>,
391    ) -> Option<Self::ListItem> {
392        let m = self.matches.get(ix)?;
393
394        Some(
395            ListItem::new(ix)
396                .spacing(ListItemSpacing::Sparse)
397                .inset(true)
398                .selected(selected)
399                .child(LabelLike::new().child(m.styled_text(self.project.read(cx), cx))),
400        )
401    }
402
403    fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
404        "Type a path...".into()
405    }
406
407    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
408        Arc::from("[directory/]filename.ext")
409    }
410}
411
412impl NewPathDelegate {
413    fn set_search_matches(
414        &mut self,
415        query: String,
416        prefix: String,
417        suffix: Option<String>,
418        matches: Vec<PathMatch>,
419        cx: &mut ViewContext<Picker<Self>>,
420    ) {
421        cx.notify();
422        if query.is_empty() {
423            self.matches = vec![];
424            return;
425        }
426
427        let mut directory_exists = false;
428
429        self.matches = matches
430            .into_iter()
431            .map(|m| {
432                if m.path.as_ref().to_string_lossy() == prefix {
433                    directory_exists = true
434                }
435                Match {
436                    path_match: Some(m),
437                    suffix: suffix.clone(),
438                }
439            })
440            .collect();
441
442        if !directory_exists {
443            if suffix.is_none()
444                || self
445                    .last_selected_dir
446                    .as_ref()
447                    .is_some_and(|d| query.starts_with(d))
448            {
449                self.matches.insert(
450                    0,
451                    Match {
452                        path_match: None,
453                        suffix: Some(query.clone()),
454                    },
455                )
456            } else {
457                self.matches.push(Match {
458                    path_match: None,
459                    suffix: Some(query.clone()),
460                })
461            }
462        }
463    }
464}