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