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