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}