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