1use context_menu::{ContextMenu, ContextMenuItem};
2use drag_and_drop::{DragAndDrop, Draggable};
3use editor::{Cancel, Editor};
4use futures::stream::StreamExt;
5use gpui::{
6 actions,
7 anyhow::{anyhow, Result},
8 elements::{
9 AnchorCorner, ChildView, ContainerStyle, Empty, Flex, Label, MouseEventHandler,
10 ParentElement, ScrollTarget, Stack, Svg, UniformList, UniformListState,
11 },
12 geometry::vector::Vector2F,
13 keymap_matcher::KeymapContext,
14 platform::{CursorStyle, MouseButton, PromptLevel},
15 AnyElement, AppContext, ClipboardItem, Element, Entity, ModelHandle, Task, View, ViewContext,
16 ViewHandle, WeakViewHandle,
17};
18use menu::{Confirm, SelectNext, SelectPrev};
19use project::{
20 repository::GitFileStatus, Entry, EntryKind, Project, ProjectEntryId, ProjectPath, Worktree,
21 WorktreeId,
22};
23use settings::Settings;
24use std::{
25 cmp::Ordering,
26 collections::{hash_map, HashMap},
27 ffi::OsStr,
28 ops::Range,
29 path::Path,
30 sync::Arc,
31};
32use theme::ProjectPanelEntry;
33use unicase::UniCase;
34use workspace::Workspace;
35
36const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
37
38pub struct ProjectPanel {
39 project: ModelHandle<Project>,
40 list: UniformListState,
41 visible_entries: Vec<(WorktreeId, Vec<Entry>)>,
42 last_worktree_root_id: Option<ProjectEntryId>,
43 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
44 selection: Option<Selection>,
45 edit_state: Option<EditState>,
46 filename_editor: ViewHandle<Editor>,
47 clipboard_entry: Option<ClipboardEntry>,
48 context_menu: ViewHandle<ContextMenu>,
49 dragged_entry_destination: Option<Arc<Path>>,
50 workspace: WeakViewHandle<Workspace>,
51}
52
53#[derive(Copy, Clone)]
54struct Selection {
55 worktree_id: WorktreeId,
56 entry_id: ProjectEntryId,
57}
58
59#[derive(Clone, Debug)]
60struct EditState {
61 worktree_id: WorktreeId,
62 entry_id: ProjectEntryId,
63 is_new_entry: bool,
64 is_dir: bool,
65 processing_filename: Option<String>,
66}
67
68#[derive(Copy, Clone)]
69pub enum ClipboardEntry {
70 Copied {
71 worktree_id: WorktreeId,
72 entry_id: ProjectEntryId,
73 },
74 Cut {
75 worktree_id: WorktreeId,
76 entry_id: ProjectEntryId,
77 },
78}
79
80#[derive(Debug, PartialEq, Eq)]
81pub struct EntryDetails {
82 filename: String,
83 path: Arc<Path>,
84 depth: usize,
85 kind: EntryKind,
86 is_ignored: bool,
87 is_expanded: bool,
88 is_selected: bool,
89 is_editing: bool,
90 is_processing: bool,
91 is_cut: bool,
92 git_status: Option<GitFileStatus>,
93}
94
95actions!(
96 project_panel,
97 [
98 ExpandSelectedEntry,
99 CollapseSelectedEntry,
100 NewDirectory,
101 NewFile,
102 Copy,
103 CopyPath,
104 CopyRelativePath,
105 RevealInFinder,
106 Cut,
107 Paste,
108 Delete,
109 Rename,
110 ToggleFocus
111 ]
112);
113
114pub fn init(cx: &mut AppContext) {
115 cx.add_action(ProjectPanel::expand_selected_entry);
116 cx.add_action(ProjectPanel::collapse_selected_entry);
117 cx.add_action(ProjectPanel::select_prev);
118 cx.add_action(ProjectPanel::select_next);
119 cx.add_action(ProjectPanel::new_file);
120 cx.add_action(ProjectPanel::new_directory);
121 cx.add_action(ProjectPanel::rename);
122 cx.add_async_action(ProjectPanel::delete);
123 cx.add_async_action(ProjectPanel::confirm);
124 cx.add_action(ProjectPanel::cancel);
125 cx.add_action(ProjectPanel::cut);
126 cx.add_action(ProjectPanel::copy);
127 cx.add_action(ProjectPanel::copy_path);
128 cx.add_action(ProjectPanel::copy_relative_path);
129 cx.add_action(ProjectPanel::reveal_in_finder);
130 cx.add_action(
131 |this: &mut ProjectPanel, action: &Paste, cx: &mut ViewContext<ProjectPanel>| {
132 this.paste(action, cx);
133 },
134 );
135}
136
137pub enum Event {
138 OpenedEntry {
139 entry_id: ProjectEntryId,
140 focus_opened_item: bool,
141 },
142}
143
144impl ProjectPanel {
145 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
146 let project = workspace.project().clone();
147 let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
148 cx.observe(&project, |this, _, cx| {
149 this.update_visible_entries(None, cx);
150 cx.notify();
151 })
152 .detach();
153 cx.subscribe(&project, |this, project, event, cx| match event {
154 project::Event::ActiveEntryChanged(Some(entry_id)) => {
155 if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
156 {
157 this.expand_entry(worktree_id, *entry_id, cx);
158 this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
159 this.autoscroll(cx);
160 cx.notify();
161 }
162 }
163 project::Event::WorktreeRemoved(id) => {
164 this.expanded_dir_ids.remove(id);
165 this.update_visible_entries(None, cx);
166 cx.notify();
167 }
168 _ => {}
169 })
170 .detach();
171
172 let filename_editor = cx.add_view(|cx| {
173 Editor::single_line(
174 Some(Arc::new(|theme| {
175 let mut style = theme.project_panel.filename_editor.clone();
176 style.container.background_color.take();
177 style
178 })),
179 cx,
180 )
181 });
182
183 cx.subscribe(&filename_editor, |this, _, event, cx| match event {
184 editor::Event::BufferEdited | editor::Event::SelectionsChanged { .. } => {
185 this.autoscroll(cx);
186 }
187 _ => {}
188 })
189 .detach();
190 cx.observe_focus(&filename_editor, |this, _, is_focused, cx| {
191 if !is_focused
192 && this
193 .edit_state
194 .as_ref()
195 .map_or(false, |state| state.processing_filename.is_none())
196 {
197 this.edit_state = None;
198 this.update_visible_entries(None, cx);
199 }
200 })
201 .detach();
202
203 let view_id = cx.view_id();
204 let mut this = Self {
205 project: project.clone(),
206 list: Default::default(),
207 visible_entries: Default::default(),
208 last_worktree_root_id: Default::default(),
209 expanded_dir_ids: Default::default(),
210 selection: None,
211 edit_state: None,
212 filename_editor,
213 clipboard_entry: None,
214 context_menu: cx.add_view(|cx| ContextMenu::new(view_id, cx)),
215 dragged_entry_destination: None,
216 workspace: workspace.weak_handle(),
217 };
218 this.update_visible_entries(None, cx);
219 this
220 });
221
222 cx.subscribe(&project_panel, {
223 let project_panel = project_panel.downgrade();
224 move |workspace, _, event, cx| match event {
225 &Event::OpenedEntry {
226 entry_id,
227 focus_opened_item,
228 } => {
229 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
230 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
231 workspace
232 .open_path(
233 ProjectPath {
234 worktree_id: worktree.read(cx).id(),
235 path: entry.path.clone(),
236 },
237 None,
238 focus_opened_item,
239 cx,
240 )
241 .detach_and_log_err(cx);
242 if !focus_opened_item {
243 if let Some(project_panel) = project_panel.upgrade(cx) {
244 cx.focus(&project_panel);
245 }
246 }
247 }
248 }
249 }
250 }
251 })
252 .detach();
253
254 project_panel
255 }
256
257 fn deploy_context_menu(
258 &mut self,
259 position: Vector2F,
260 entry_id: ProjectEntryId,
261 cx: &mut ViewContext<Self>,
262 ) {
263 let project = self.project.read(cx);
264
265 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
266 id
267 } else {
268 return;
269 };
270
271 self.selection = Some(Selection {
272 worktree_id,
273 entry_id,
274 });
275
276 let mut menu_entries = Vec::new();
277 if let Some((worktree, entry)) = self.selected_entry(cx) {
278 let is_root = Some(entry) == worktree.root_entry();
279 if !project.is_remote() {
280 menu_entries.push(ContextMenuItem::action(
281 "Add Folder to Project",
282 workspace::AddFolderToProject,
283 ));
284 if is_root {
285 let project = self.project.clone();
286 menu_entries.push(ContextMenuItem::handler("Remove from Project", move |cx| {
287 project.update(cx, |project, cx| project.remove_worktree(worktree_id, cx));
288 }));
289 }
290 }
291 menu_entries.push(ContextMenuItem::action("New File", NewFile));
292 menu_entries.push(ContextMenuItem::action("New Folder", NewDirectory));
293 menu_entries.push(ContextMenuItem::Separator);
294 menu_entries.push(ContextMenuItem::action("Cut", Cut));
295 menu_entries.push(ContextMenuItem::action("Copy", Copy));
296 menu_entries.push(ContextMenuItem::Separator);
297 menu_entries.push(ContextMenuItem::action("Copy Path", CopyPath));
298 menu_entries.push(ContextMenuItem::action(
299 "Copy Relative Path",
300 CopyRelativePath,
301 ));
302 menu_entries.push(ContextMenuItem::action("Reveal in Finder", RevealInFinder));
303 if let Some(clipboard_entry) = self.clipboard_entry {
304 if clipboard_entry.worktree_id() == worktree.id() {
305 menu_entries.push(ContextMenuItem::action("Paste", Paste));
306 }
307 }
308 menu_entries.push(ContextMenuItem::Separator);
309 menu_entries.push(ContextMenuItem::action("Rename", Rename));
310 if !is_root {
311 menu_entries.push(ContextMenuItem::action("Delete", Delete));
312 }
313 }
314
315 self.context_menu.update(cx, |menu, cx| {
316 menu.show(position, AnchorCorner::TopLeft, menu_entries, cx);
317 });
318
319 cx.notify();
320 }
321
322 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
323 if let Some((worktree, entry)) = self.selected_entry(cx) {
324 if entry.is_dir() {
325 let expanded_dir_ids =
326 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
327 expanded_dir_ids
328 } else {
329 return;
330 };
331
332 match expanded_dir_ids.binary_search(&entry.id) {
333 Ok(_) => self.select_next(&SelectNext, cx),
334 Err(ix) => {
335 expanded_dir_ids.insert(ix, entry.id);
336 self.update_visible_entries(None, cx);
337 cx.notify();
338 }
339 }
340 }
341 }
342 }
343
344 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
345 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
346 let expanded_dir_ids =
347 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
348 expanded_dir_ids
349 } else {
350 return;
351 };
352
353 loop {
354 match expanded_dir_ids.binary_search(&entry.id) {
355 Ok(ix) => {
356 expanded_dir_ids.remove(ix);
357 self.update_visible_entries(Some((worktree.id(), entry.id)), cx);
358 cx.notify();
359 break;
360 }
361 Err(_) => {
362 if let Some(parent_entry) =
363 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
364 {
365 entry = parent_entry;
366 } else {
367 break;
368 }
369 }
370 }
371 }
372 }
373 }
374
375 fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
376 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
377 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
378 match expanded_dir_ids.binary_search(&entry_id) {
379 Ok(ix) => {
380 expanded_dir_ids.remove(ix);
381 }
382 Err(ix) => {
383 expanded_dir_ids.insert(ix, entry_id);
384 }
385 }
386 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
387 cx.focus_self();
388 cx.notify();
389 }
390 }
391 }
392
393 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
394 if let Some(selection) = self.selection {
395 let (mut worktree_ix, mut entry_ix, _) =
396 self.index_for_selection(selection).unwrap_or_default();
397 if entry_ix > 0 {
398 entry_ix -= 1;
399 } else if worktree_ix > 0 {
400 worktree_ix -= 1;
401 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
402 } else {
403 return;
404 }
405
406 let (worktree_id, worktree_entries) = &self.visible_entries[worktree_ix];
407 self.selection = Some(Selection {
408 worktree_id: *worktree_id,
409 entry_id: worktree_entries[entry_ix].id,
410 });
411 self.autoscroll(cx);
412 cx.notify();
413 } else {
414 self.select_first(cx);
415 }
416 }
417
418 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
419 if let Some(task) = self.confirm_edit(cx) {
420 Some(task)
421 } else if let Some((_, entry)) = self.selected_entry(cx) {
422 if entry.is_file() {
423 self.open_entry(entry.id, true, cx);
424 }
425 None
426 } else {
427 None
428 }
429 }
430
431 fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
432 let edit_state = self.edit_state.as_mut()?;
433 cx.focus_self();
434
435 let worktree_id = edit_state.worktree_id;
436 let is_new_entry = edit_state.is_new_entry;
437 let is_dir = edit_state.is_dir;
438 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
439 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
440 let filename = self.filename_editor.read(cx).text(cx);
441
442 let edit_task;
443 let edited_entry_id;
444
445 if is_new_entry {
446 self.selection = Some(Selection {
447 worktree_id,
448 entry_id: NEW_ENTRY_ID,
449 });
450 let new_path = entry.path.join(&filename);
451 edited_entry_id = NEW_ENTRY_ID;
452 edit_task = self.project.update(cx, |project, cx| {
453 project.create_entry((worktree_id, new_path), is_dir, cx)
454 })?;
455 } else {
456 let new_path = if let Some(parent) = entry.path.clone().parent() {
457 parent.join(&filename)
458 } else {
459 filename.clone().into()
460 };
461 edited_entry_id = entry.id;
462 edit_task = self.project.update(cx, |project, cx| {
463 project.rename_entry(entry.id, new_path, cx)
464 })?;
465 };
466
467 edit_state.processing_filename = Some(filename);
468 cx.notify();
469
470 Some(cx.spawn(|this, mut cx| async move {
471 let new_entry = edit_task.await;
472 this.update(&mut cx, |this, cx| {
473 this.edit_state.take();
474 cx.notify();
475 })?;
476
477 let new_entry = new_entry?;
478 this.update(&mut cx, |this, cx| {
479 if let Some(selection) = &mut this.selection {
480 if selection.entry_id == edited_entry_id {
481 selection.worktree_id = worktree_id;
482 selection.entry_id = new_entry.id;
483 }
484 }
485 this.update_visible_entries(None, cx);
486 if is_new_entry && !is_dir {
487 this.open_entry(new_entry.id, true, cx);
488 }
489 cx.notify();
490 })?;
491 Ok(())
492 }))
493 }
494
495 fn cancel(&mut self, _: &Cancel, cx: &mut ViewContext<Self>) {
496 self.edit_state = None;
497 self.update_visible_entries(None, cx);
498 cx.focus_self();
499 cx.notify();
500 }
501
502 fn open_entry(
503 &mut self,
504 entry_id: ProjectEntryId,
505 focus_opened_item: bool,
506 cx: &mut ViewContext<Self>,
507 ) {
508 cx.emit(Event::OpenedEntry {
509 entry_id,
510 focus_opened_item,
511 });
512 }
513
514 fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
515 self.add_entry(false, cx)
516 }
517
518 fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
519 self.add_entry(true, cx)
520 }
521
522 fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
523 if let Some(Selection {
524 worktree_id,
525 entry_id,
526 }) = self.selection
527 {
528 let directory_id;
529 if let Some((worktree, expanded_dir_ids)) = self
530 .project
531 .read(cx)
532 .worktree_for_id(worktree_id, cx)
533 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
534 {
535 let worktree = worktree.read(cx);
536 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
537 loop {
538 if entry.is_dir() {
539 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
540 expanded_dir_ids.insert(ix, entry.id);
541 }
542 directory_id = entry.id;
543 break;
544 } else {
545 if let Some(parent_path) = entry.path.parent() {
546 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
547 entry = parent_entry;
548 continue;
549 }
550 }
551 return;
552 }
553 }
554 } else {
555 return;
556 };
557 } else {
558 return;
559 };
560
561 self.edit_state = Some(EditState {
562 worktree_id,
563 entry_id: directory_id,
564 is_new_entry: true,
565 is_dir,
566 processing_filename: None,
567 });
568 self.filename_editor
569 .update(cx, |editor, cx| editor.clear(cx));
570 cx.focus(&self.filename_editor);
571 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
572 self.autoscroll(cx);
573 cx.notify();
574 }
575 }
576
577 fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
578 if let Some(Selection {
579 worktree_id,
580 entry_id,
581 }) = self.selection
582 {
583 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
584 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
585 self.edit_state = Some(EditState {
586 worktree_id,
587 entry_id,
588 is_new_entry: false,
589 is_dir: entry.is_dir(),
590 processing_filename: None,
591 });
592 let filename = entry
593 .path
594 .file_name()
595 .map_or(String::new(), |s| s.to_string_lossy().to_string());
596 self.filename_editor.update(cx, |editor, cx| {
597 editor.set_text(filename, cx);
598 editor.select_all(&Default::default(), cx);
599 });
600 cx.focus(&self.filename_editor);
601 self.update_visible_entries(None, cx);
602 self.autoscroll(cx);
603 cx.notify();
604 }
605 }
606
607 cx.update_global(|drag_and_drop: &mut DragAndDrop<Workspace>, cx| {
608 drag_and_drop.cancel_dragging::<ProjectEntryId>(cx);
609 })
610 }
611 }
612
613 fn delete(&mut self, _: &Delete, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
614 let Selection { entry_id, .. } = self.selection?;
615 let path = self.project.read(cx).path_for_entry(entry_id, cx)?.path;
616 let file_name = path.file_name()?;
617
618 let mut answer = cx.prompt(
619 PromptLevel::Info,
620 &format!("Delete {file_name:?}?"),
621 &["Delete", "Cancel"],
622 );
623 Some(cx.spawn(|this, mut cx| async move {
624 if answer.next().await != Some(0) {
625 return Ok(());
626 }
627 this.update(&mut cx, |this, cx| {
628 this.project
629 .update(cx, |project, cx| project.delete_entry(entry_id, cx))
630 .ok_or_else(|| anyhow!("no such entry"))
631 })??
632 .await
633 }))
634 }
635
636 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
637 if let Some(selection) = self.selection {
638 let (mut worktree_ix, mut entry_ix, _) =
639 self.index_for_selection(selection).unwrap_or_default();
640 if let Some((_, worktree_entries)) = self.visible_entries.get(worktree_ix) {
641 if entry_ix + 1 < worktree_entries.len() {
642 entry_ix += 1;
643 } else {
644 worktree_ix += 1;
645 entry_ix = 0;
646 }
647 }
648
649 if let Some((worktree_id, worktree_entries)) = self.visible_entries.get(worktree_ix) {
650 if let Some(entry) = worktree_entries.get(entry_ix) {
651 self.selection = Some(Selection {
652 worktree_id: *worktree_id,
653 entry_id: entry.id,
654 });
655 self.autoscroll(cx);
656 cx.notify();
657 }
658 }
659 } else {
660 self.select_first(cx);
661 }
662 }
663
664 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
665 let worktree = self
666 .visible_entries
667 .first()
668 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
669 if let Some(worktree) = worktree {
670 let worktree = worktree.read(cx);
671 let worktree_id = worktree.id();
672 if let Some(root_entry) = worktree.root_entry() {
673 self.selection = Some(Selection {
674 worktree_id,
675 entry_id: root_entry.id,
676 });
677 self.autoscroll(cx);
678 cx.notify();
679 }
680 }
681 }
682
683 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
684 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
685 self.list.scroll_to(ScrollTarget::Show(index));
686 cx.notify();
687 }
688 }
689
690 fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
691 if let Some((worktree, entry)) = self.selected_entry(cx) {
692 self.clipboard_entry = Some(ClipboardEntry::Cut {
693 worktree_id: worktree.id(),
694 entry_id: entry.id,
695 });
696 cx.notify();
697 }
698 }
699
700 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
701 if let Some((worktree, entry)) = self.selected_entry(cx) {
702 self.clipboard_entry = Some(ClipboardEntry::Copied {
703 worktree_id: worktree.id(),
704 entry_id: entry.id,
705 });
706 cx.notify();
707 }
708 }
709
710 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) -> Option<()> {
711 if let Some((worktree, entry)) = self.selected_entry(cx) {
712 let clipboard_entry = self.clipboard_entry?;
713 if clipboard_entry.worktree_id() != worktree.id() {
714 return None;
715 }
716
717 let clipboard_entry_file_name = self
718 .project
719 .read(cx)
720 .path_for_entry(clipboard_entry.entry_id(), cx)?
721 .path
722 .file_name()?
723 .to_os_string();
724
725 let mut new_path = entry.path.to_path_buf();
726 if entry.is_file() {
727 new_path.pop();
728 }
729
730 new_path.push(&clipboard_entry_file_name);
731 let extension = new_path.extension().map(|e| e.to_os_string());
732 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
733 let mut ix = 0;
734 while worktree.entry_for_path(&new_path).is_some() {
735 new_path.pop();
736
737 let mut new_file_name = file_name_without_extension.to_os_string();
738 new_file_name.push(" copy");
739 if ix > 0 {
740 new_file_name.push(format!(" {}", ix));
741 }
742 if let Some(extension) = extension.as_ref() {
743 new_file_name.push(".");
744 new_file_name.push(extension);
745 }
746
747 new_path.push(new_file_name);
748 ix += 1;
749 }
750
751 if clipboard_entry.is_cut() {
752 if let Some(task) = self.project.update(cx, |project, cx| {
753 project.rename_entry(clipboard_entry.entry_id(), new_path, cx)
754 }) {
755 task.detach_and_log_err(cx)
756 }
757 } else if let Some(task) = self.project.update(cx, |project, cx| {
758 project.copy_entry(clipboard_entry.entry_id(), new_path, cx)
759 }) {
760 task.detach_and_log_err(cx)
761 }
762 }
763 None
764 }
765
766 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
767 if let Some((worktree, entry)) = self.selected_entry(cx) {
768 cx.write_to_clipboard(ClipboardItem::new(
769 worktree
770 .abs_path()
771 .join(&entry.path)
772 .to_string_lossy()
773 .to_string(),
774 ));
775 }
776 }
777
778 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
779 if let Some((_, entry)) = self.selected_entry(cx) {
780 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
781 }
782 }
783
784 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
785 if let Some((worktree, entry)) = self.selected_entry(cx) {
786 cx.reveal_path(&worktree.abs_path().join(&entry.path));
787 }
788 }
789
790 fn move_entry(
791 &mut self,
792 entry_to_move: ProjectEntryId,
793 destination: ProjectEntryId,
794 destination_is_file: bool,
795 cx: &mut ViewContext<Self>,
796 ) {
797 let destination_worktree = self.project.update(cx, |project, cx| {
798 let entry_path = project.path_for_entry(entry_to_move, cx)?;
799 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
800
801 let mut destination_path = destination_entry_path.as_ref();
802 if destination_is_file {
803 destination_path = destination_path.parent()?;
804 }
805
806 let mut new_path = destination_path.to_path_buf();
807 new_path.push(entry_path.path.file_name()?);
808 if new_path != entry_path.path.as_ref() {
809 let task = project.rename_entry(entry_to_move, new_path, cx)?;
810 cx.foreground().spawn(task).detach_and_log_err(cx);
811 }
812
813 Some(project.worktree_id_for_entry(destination, cx)?)
814 });
815
816 if let Some(destination_worktree) = destination_worktree {
817 self.expand_entry(destination_worktree, destination, cx);
818 }
819 }
820
821 fn index_for_selection(&self, selection: Selection) -> Option<(usize, usize, usize)> {
822 let mut entry_index = 0;
823 let mut visible_entries_index = 0;
824 for (worktree_index, (worktree_id, worktree_entries)) in
825 self.visible_entries.iter().enumerate()
826 {
827 if *worktree_id == selection.worktree_id {
828 for entry in worktree_entries {
829 if entry.id == selection.entry_id {
830 return Some((worktree_index, entry_index, visible_entries_index));
831 } else {
832 visible_entries_index += 1;
833 entry_index += 1;
834 }
835 }
836 break;
837 } else {
838 visible_entries_index += worktree_entries.len();
839 }
840 }
841 None
842 }
843
844 fn selected_entry<'a>(&self, cx: &'a AppContext) -> Option<(&'a Worktree, &'a project::Entry)> {
845 let selection = self.selection?;
846 let project = self.project.read(cx);
847 let worktree = project.worktree_for_id(selection.worktree_id, cx)?.read(cx);
848 Some((worktree, worktree.entry_for_id(selection.entry_id)?))
849 }
850
851 fn update_visible_entries(
852 &mut self,
853 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
854 cx: &mut ViewContext<Self>,
855 ) {
856 let project = self.project.read(cx);
857 self.last_worktree_root_id = project
858 .visible_worktrees(cx)
859 .rev()
860 .next()
861 .and_then(|worktree| worktree.read(cx).root_entry())
862 .map(|entry| entry.id);
863
864 self.visible_entries.clear();
865 for worktree in project.visible_worktrees(cx) {
866 let snapshot = worktree.read(cx).snapshot();
867 let worktree_id = snapshot.id();
868
869 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
870 hash_map::Entry::Occupied(e) => e.into_mut(),
871 hash_map::Entry::Vacant(e) => {
872 // The first time a worktree's root entry becomes available,
873 // mark that root entry as expanded.
874 if let Some(entry) = snapshot.root_entry() {
875 e.insert(vec![entry.id]).as_slice()
876 } else {
877 &[]
878 }
879 }
880 };
881
882 let mut new_entry_parent_id = None;
883 let mut new_entry_kind = EntryKind::Dir;
884 if let Some(edit_state) = &self.edit_state {
885 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
886 new_entry_parent_id = Some(edit_state.entry_id);
887 new_entry_kind = if edit_state.is_dir {
888 EntryKind::Dir
889 } else {
890 EntryKind::File(Default::default())
891 };
892 }
893 }
894
895 let mut visible_worktree_entries = Vec::new();
896 let mut entry_iter = snapshot.entries(true);
897
898 while let Some(entry) = entry_iter.entry() {
899 visible_worktree_entries.push(entry.clone());
900 if Some(entry.id) == new_entry_parent_id {
901 visible_worktree_entries.push(Entry {
902 id: NEW_ENTRY_ID,
903 kind: new_entry_kind,
904 path: entry.path.join("\0").into(),
905 inode: 0,
906 mtime: entry.mtime,
907 is_symlink: false,
908 is_ignored: false,
909 });
910 }
911 if expanded_dir_ids.binary_search(&entry.id).is_err()
912 && entry_iter.advance_to_sibling()
913 {
914 continue;
915 }
916 entry_iter.advance();
917 }
918 visible_worktree_entries.sort_by(|entry_a, entry_b| {
919 let mut components_a = entry_a.path.components().peekable();
920 let mut components_b = entry_b.path.components().peekable();
921 loop {
922 match (components_a.next(), components_b.next()) {
923 (Some(component_a), Some(component_b)) => {
924 let a_is_file = components_a.peek().is_none() && entry_a.is_file();
925 let b_is_file = components_b.peek().is_none() && entry_b.is_file();
926 let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
927 let name_a =
928 UniCase::new(component_a.as_os_str().to_string_lossy());
929 let name_b =
930 UniCase::new(component_b.as_os_str().to_string_lossy());
931 name_a.cmp(&name_b)
932 });
933 if !ordering.is_eq() {
934 return ordering;
935 }
936 }
937 (Some(_), None) => break Ordering::Greater,
938 (None, Some(_)) => break Ordering::Less,
939 (None, None) => break Ordering::Equal,
940 }
941 }
942 });
943 self.visible_entries
944 .push((worktree_id, visible_worktree_entries));
945 }
946
947 if let Some((worktree_id, entry_id)) = new_selected_entry {
948 self.selection = Some(Selection {
949 worktree_id,
950 entry_id,
951 });
952 }
953 }
954
955 fn expand_entry(
956 &mut self,
957 worktree_id: WorktreeId,
958 entry_id: ProjectEntryId,
959 cx: &mut ViewContext<Self>,
960 ) {
961 let project = self.project.read(cx);
962 if let Some((worktree, expanded_dir_ids)) = project
963 .worktree_for_id(worktree_id, cx)
964 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
965 {
966 let worktree = worktree.read(cx);
967
968 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
969 loop {
970 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
971 expanded_dir_ids.insert(ix, entry.id);
972 }
973
974 if let Some(parent_entry) =
975 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
976 {
977 entry = parent_entry;
978 } else {
979 break;
980 }
981 }
982 }
983 }
984 }
985
986 fn for_each_visible_entry(
987 &self,
988 range: Range<usize>,
989 cx: &mut ViewContext<ProjectPanel>,
990 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
991 ) {
992 let mut ix = 0;
993 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
994 if ix >= range.end {
995 return;
996 }
997
998 if ix + visible_worktree_entries.len() <= range.start {
999 ix += visible_worktree_entries.len();
1000 continue;
1001 }
1002
1003 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1004 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1005 let snapshot = worktree.read(cx).snapshot();
1006 let root_name = OsStr::new(snapshot.root_name());
1007 let expanded_entry_ids = self
1008 .expanded_dir_ids
1009 .get(&snapshot.id())
1010 .map(Vec::as_slice)
1011 .unwrap_or(&[]);
1012
1013 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1014 for entry in &visible_worktree_entries[entry_range] {
1015 let path = &entry.path;
1016 let status = (entry.path.parent().is_some() && !entry.is_ignored)
1017 .then(|| {
1018 snapshot
1019 .repo_for(path)
1020 .and_then(|entry| entry.status_for_path(&snapshot, path))
1021 })
1022 .flatten();
1023
1024 let mut details = EntryDetails {
1025 filename: entry
1026 .path
1027 .file_name()
1028 .unwrap_or(root_name)
1029 .to_string_lossy()
1030 .to_string(),
1031 path: entry.path.clone(),
1032 depth: entry.path.components().count(),
1033 kind: entry.kind,
1034 is_ignored: entry.is_ignored,
1035 is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
1036 is_selected: self.selection.map_or(false, |e| {
1037 e.worktree_id == snapshot.id() && e.entry_id == entry.id
1038 }),
1039 is_editing: false,
1040 is_processing: false,
1041 is_cut: self
1042 .clipboard_entry
1043 .map_or(false, |e| e.is_cut() && e.entry_id() == entry.id),
1044 git_status: status,
1045 };
1046
1047 if let Some(edit_state) = &self.edit_state {
1048 let is_edited_entry = if edit_state.is_new_entry {
1049 entry.id == NEW_ENTRY_ID
1050 } else {
1051 entry.id == edit_state.entry_id
1052 };
1053
1054 if is_edited_entry {
1055 if let Some(processing_filename) = &edit_state.processing_filename {
1056 details.is_processing = true;
1057 details.filename.clear();
1058 details.filename.push_str(processing_filename);
1059 } else {
1060 if edit_state.is_new_entry {
1061 details.filename.clear();
1062 }
1063 details.is_editing = true;
1064 }
1065 }
1066 }
1067
1068 callback(entry.id, details, cx);
1069 }
1070 }
1071 ix = end_ix;
1072 }
1073 }
1074
1075 fn render_entry_visual_element<V: View>(
1076 details: &EntryDetails,
1077 editor: Option<&ViewHandle<Editor>>,
1078 padding: f32,
1079 row_container_style: ContainerStyle,
1080 style: &ProjectPanelEntry,
1081 cx: &mut ViewContext<V>,
1082 ) -> AnyElement<V> {
1083 let kind = details.kind;
1084 let show_editor = details.is_editing && !details.is_processing;
1085
1086 // Prepare colors for git statuses
1087 let editor_theme = &cx.global::<Settings>().theme.editor;
1088 let mut filename_text_style = style.text.clone();
1089 filename_text_style.color = details
1090 .git_status
1091 .as_ref()
1092 .map(|status| match status {
1093 GitFileStatus::Added => editor_theme.diff.inserted,
1094 GitFileStatus::Modified => editor_theme.diff.modified,
1095 GitFileStatus::Conflict => editor_theme.diff.deleted,
1096 })
1097 .unwrap_or(style.text.color);
1098
1099 Flex::row()
1100 .with_child(
1101 if kind == EntryKind::Dir {
1102 if details.is_expanded {
1103 Svg::new("icons/chevron_down_8.svg").with_color(style.icon_color)
1104 } else {
1105 Svg::new("icons/chevron_right_8.svg").with_color(style.icon_color)
1106 }
1107 .constrained()
1108 } else {
1109 Empty::new().constrained()
1110 }
1111 .with_max_width(style.icon_size)
1112 .with_max_height(style.icon_size)
1113 .aligned()
1114 .constrained()
1115 .with_width(style.icon_size),
1116 )
1117 .with_child(if show_editor && editor.is_some() {
1118 ChildView::new(editor.as_ref().unwrap(), cx)
1119 .contained()
1120 .with_margin_left(style.icon_spacing)
1121 .aligned()
1122 .left()
1123 .flex(1.0, true)
1124 .into_any()
1125 } else {
1126 Label::new(details.filename.clone(), filename_text_style)
1127 .contained()
1128 .with_margin_left(style.icon_spacing)
1129 .aligned()
1130 .left()
1131 .into_any()
1132 })
1133 .constrained()
1134 .with_height(style.height)
1135 .contained()
1136 .with_style(row_container_style)
1137 .with_padding_left(padding)
1138 .into_any_named("project panel entry visual element")
1139 }
1140
1141 fn render_entry(
1142 entry_id: ProjectEntryId,
1143 details: EntryDetails,
1144 editor: &ViewHandle<Editor>,
1145 dragged_entry_destination: &mut Option<Arc<Path>>,
1146 theme: &theme::ProjectPanel,
1147 cx: &mut ViewContext<Self>,
1148 ) -> AnyElement<Self> {
1149 let kind = details.kind;
1150 let path = details.path.clone();
1151 let padding = theme.container.padding.left + details.depth as f32 * theme.indent_width;
1152
1153 let entry_style = if details.is_cut {
1154 &theme.cut_entry
1155 } else if details.is_ignored {
1156 &theme.ignored_entry
1157 } else {
1158 &theme.entry
1159 };
1160
1161 let show_editor = details.is_editing && !details.is_processing;
1162
1163 MouseEventHandler::<Self, _>::new(entry_id.to_usize(), cx, |state, cx| {
1164 let mut style = entry_style.style_for(state, details.is_selected).clone();
1165
1166 if cx
1167 .global::<DragAndDrop<Workspace>>()
1168 .currently_dragged::<ProjectEntryId>(cx.window_id())
1169 .is_some()
1170 && dragged_entry_destination
1171 .as_ref()
1172 .filter(|destination| details.path.starts_with(destination))
1173 .is_some()
1174 {
1175 style = entry_style.active.clone().unwrap();
1176 }
1177
1178 let row_container_style = if show_editor {
1179 theme.filename_editor.container
1180 } else {
1181 style.container
1182 };
1183
1184 Self::render_entry_visual_element(
1185 &details,
1186 Some(editor),
1187 padding,
1188 row_container_style,
1189 &style,
1190 cx,
1191 )
1192 })
1193 .on_click(MouseButton::Left, move |event, this, cx| {
1194 if !show_editor {
1195 if kind == EntryKind::Dir {
1196 this.toggle_expanded(entry_id, cx);
1197 } else {
1198 this.open_entry(entry_id, event.click_count > 1, cx);
1199 }
1200 }
1201 })
1202 .on_down(MouseButton::Right, move |event, this, cx| {
1203 this.deploy_context_menu(event.position, entry_id, cx);
1204 })
1205 .on_up(MouseButton::Left, move |_, this, cx| {
1206 if let Some((_, dragged_entry)) = cx
1207 .global::<DragAndDrop<Workspace>>()
1208 .currently_dragged::<ProjectEntryId>(cx.window_id())
1209 {
1210 this.move_entry(
1211 *dragged_entry,
1212 entry_id,
1213 matches!(details.kind, EntryKind::File(_)),
1214 cx,
1215 );
1216 }
1217 })
1218 .on_move(move |_, this, cx| {
1219 if cx
1220 .global::<DragAndDrop<Workspace>>()
1221 .currently_dragged::<ProjectEntryId>(cx.window_id())
1222 .is_some()
1223 {
1224 this.dragged_entry_destination = if matches!(kind, EntryKind::File(_)) {
1225 path.parent().map(|parent| Arc::from(parent))
1226 } else {
1227 Some(path.clone())
1228 };
1229 }
1230 })
1231 .as_draggable(entry_id, {
1232 let row_container_style = theme.dragged_entry.container;
1233
1234 move |_, cx: &mut ViewContext<Workspace>| {
1235 let theme = cx.global::<Settings>().theme.clone();
1236 Self::render_entry_visual_element(
1237 &details,
1238 None,
1239 padding,
1240 row_container_style,
1241 &theme.project_panel.dragged_entry,
1242 cx,
1243 )
1244 }
1245 })
1246 .with_cursor_style(CursorStyle::PointingHand)
1247 .into_any_named("project panel entry")
1248 }
1249}
1250
1251impl View for ProjectPanel {
1252 fn ui_name() -> &'static str {
1253 "ProjectPanel"
1254 }
1255
1256 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> gpui::AnyElement<Self> {
1257 enum ProjectPanel {}
1258 let theme = &cx.global::<Settings>().theme.project_panel;
1259 let mut container_style = theme.container;
1260 let padding = std::mem::take(&mut container_style.padding);
1261 let last_worktree_root_id = self.last_worktree_root_id;
1262
1263 let has_worktree = self.visible_entries.len() != 0;
1264
1265 if has_worktree {
1266 Stack::new()
1267 .with_child(
1268 MouseEventHandler::<ProjectPanel, _>::new(0, cx, |_, cx| {
1269 UniformList::new(
1270 self.list.clone(),
1271 self.visible_entries
1272 .iter()
1273 .map(|(_, worktree_entries)| worktree_entries.len())
1274 .sum(),
1275 cx,
1276 move |this, range, items, cx| {
1277 let theme = cx.global::<Settings>().theme.clone();
1278 let mut dragged_entry_destination =
1279 this.dragged_entry_destination.clone();
1280 this.for_each_visible_entry(range, cx, |id, details, cx| {
1281 items.push(Self::render_entry(
1282 id,
1283 details,
1284 &this.filename_editor,
1285 &mut dragged_entry_destination,
1286 &theme.project_panel,
1287 cx,
1288 ));
1289 });
1290 this.dragged_entry_destination = dragged_entry_destination;
1291 },
1292 )
1293 .with_padding_top(padding.top)
1294 .with_padding_bottom(padding.bottom)
1295 .contained()
1296 .with_style(container_style)
1297 .expanded()
1298 })
1299 .on_down(MouseButton::Right, move |event, this, cx| {
1300 // When deploying the context menu anywhere below the last project entry,
1301 // act as if the user clicked the root of the last worktree.
1302 if let Some(entry_id) = last_worktree_root_id {
1303 this.deploy_context_menu(event.position, entry_id, cx);
1304 }
1305 }),
1306 )
1307 .with_child(ChildView::new(&self.context_menu, cx))
1308 .into_any_named("project panel")
1309 } else {
1310 Flex::column()
1311 .with_child(
1312 MouseEventHandler::<Self, _>::new(2, cx, {
1313 let button_style = theme.open_project_button.clone();
1314 let context_menu_item_style =
1315 cx.global::<Settings>().theme.context_menu.item.clone();
1316 move |state, cx| {
1317 let button_style = button_style.style_for(state, false).clone();
1318 let context_menu_item =
1319 context_menu_item_style.style_for(state, true).clone();
1320
1321 theme::ui::keystroke_label(
1322 "Open a project",
1323 &button_style,
1324 &context_menu_item.keystroke,
1325 Box::new(workspace::Open),
1326 cx,
1327 )
1328 }
1329 })
1330 .on_click(MouseButton::Left, move |_, this, cx| {
1331 if let Some(workspace) = this.workspace.upgrade(cx) {
1332 workspace.update(cx, |workspace, cx| {
1333 if let Some(task) = workspace.open(&Default::default(), cx) {
1334 task.detach_and_log_err(cx);
1335 }
1336 })
1337 }
1338 })
1339 .with_cursor_style(CursorStyle::PointingHand),
1340 )
1341 .contained()
1342 .with_style(container_style)
1343 .into_any_named("empty project panel")
1344 }
1345 }
1346
1347 fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) {
1348 Self::reset_to_default_keymap_context(keymap);
1349 keymap.add_identifier("menu");
1350 }
1351}
1352
1353impl Entity for ProjectPanel {
1354 type Event = Event;
1355}
1356
1357impl workspace::sidebar::SidebarItem for ProjectPanel {
1358 fn should_show_badge(&self, _: &AppContext) -> bool {
1359 false
1360 }
1361}
1362
1363impl ClipboardEntry {
1364 fn is_cut(&self) -> bool {
1365 matches!(self, Self::Cut { .. })
1366 }
1367
1368 fn entry_id(&self) -> ProjectEntryId {
1369 match self {
1370 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1371 *entry_id
1372 }
1373 }
1374 }
1375
1376 fn worktree_id(&self) -> WorktreeId {
1377 match self {
1378 ClipboardEntry::Copied { worktree_id, .. }
1379 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1380 }
1381 }
1382}
1383
1384#[cfg(test)]
1385mod tests {
1386 use super::*;
1387 use gpui::{TestAppContext, ViewHandle};
1388 use project::FakeFs;
1389 use serde_json::json;
1390 use std::{collections::HashSet, path::Path};
1391
1392 #[gpui::test]
1393 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1394 cx.foreground().forbid_parking();
1395 cx.update(|cx| {
1396 let settings = Settings::test(cx);
1397 cx.set_global(settings);
1398 });
1399
1400 let fs = FakeFs::new(cx.background());
1401 fs.insert_tree(
1402 "/root1",
1403 json!({
1404 ".dockerignore": "",
1405 ".git": {
1406 "HEAD": "",
1407 },
1408 "a": {
1409 "0": { "q": "", "r": "", "s": "" },
1410 "1": { "t": "", "u": "" },
1411 "2": { "v": "", "w": "", "x": "", "y": "" },
1412 },
1413 "b": {
1414 "3": { "Q": "" },
1415 "4": { "R": "", "S": "", "T": "", "U": "" },
1416 },
1417 "C": {
1418 "5": {},
1419 "6": { "V": "", "W": "" },
1420 "7": { "X": "" },
1421 "8": { "Y": {}, "Z": "" }
1422 }
1423 }),
1424 )
1425 .await;
1426 fs.insert_tree(
1427 "/root2",
1428 json!({
1429 "d": {
1430 "9": ""
1431 },
1432 "e": {}
1433 }),
1434 )
1435 .await;
1436
1437 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1438 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1439 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1440 assert_eq!(
1441 visible_entries_as_strings(&panel, 0..50, cx),
1442 &[
1443 "v root1",
1444 " > .git",
1445 " > a",
1446 " > b",
1447 " > C",
1448 " .dockerignore",
1449 "v root2",
1450 " > d",
1451 " > e",
1452 ]
1453 );
1454
1455 toggle_expand_dir(&panel, "root1/b", cx);
1456 assert_eq!(
1457 visible_entries_as_strings(&panel, 0..50, cx),
1458 &[
1459 "v root1",
1460 " > .git",
1461 " > a",
1462 " v b <== selected",
1463 " > 3",
1464 " > 4",
1465 " > C",
1466 " .dockerignore",
1467 "v root2",
1468 " > d",
1469 " > e",
1470 ]
1471 );
1472
1473 assert_eq!(
1474 visible_entries_as_strings(&panel, 6..9, cx),
1475 &[
1476 //
1477 " > C",
1478 " .dockerignore",
1479 "v root2",
1480 ]
1481 );
1482 }
1483
1484 #[gpui::test(iterations = 30)]
1485 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
1486 cx.foreground().forbid_parking();
1487 cx.update(|cx| {
1488 let settings = Settings::test(cx);
1489 cx.set_global(settings);
1490 });
1491
1492 let fs = FakeFs::new(cx.background());
1493 fs.insert_tree(
1494 "/root1",
1495 json!({
1496 ".dockerignore": "",
1497 ".git": {
1498 "HEAD": "",
1499 },
1500 "a": {
1501 "0": { "q": "", "r": "", "s": "" },
1502 "1": { "t": "", "u": "" },
1503 "2": { "v": "", "w": "", "x": "", "y": "" },
1504 },
1505 "b": {
1506 "3": { "Q": "" },
1507 "4": { "R": "", "S": "", "T": "", "U": "" },
1508 },
1509 "C": {
1510 "5": {},
1511 "6": { "V": "", "W": "" },
1512 "7": { "X": "" },
1513 "8": { "Y": {}, "Z": "" }
1514 }
1515 }),
1516 )
1517 .await;
1518 fs.insert_tree(
1519 "/root2",
1520 json!({
1521 "d": {
1522 "9": ""
1523 },
1524 "e": {}
1525 }),
1526 )
1527 .await;
1528
1529 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
1530 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1531 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1532
1533 select_path(&panel, "root1", cx);
1534 assert_eq!(
1535 visible_entries_as_strings(&panel, 0..10, cx),
1536 &[
1537 "v root1 <== selected",
1538 " > .git",
1539 " > a",
1540 " > b",
1541 " > C",
1542 " .dockerignore",
1543 "v root2",
1544 " > d",
1545 " > e",
1546 ]
1547 );
1548
1549 // Add a file with the root folder selected. The filename editor is placed
1550 // before the first file in the root folder.
1551 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1552 cx.read_window(window_id, |cx| {
1553 let panel = panel.read(cx);
1554 assert!(panel.filename_editor.is_focused(cx));
1555 });
1556 assert_eq!(
1557 visible_entries_as_strings(&panel, 0..10, cx),
1558 &[
1559 "v root1",
1560 " > .git",
1561 " > a",
1562 " > b",
1563 " > C",
1564 " [EDITOR: ''] <== selected",
1565 " .dockerignore",
1566 "v root2",
1567 " > d",
1568 " > e",
1569 ]
1570 );
1571
1572 let confirm = panel.update(cx, |panel, cx| {
1573 panel
1574 .filename_editor
1575 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
1576 panel.confirm(&Confirm, cx).unwrap()
1577 });
1578 assert_eq!(
1579 visible_entries_as_strings(&panel, 0..10, cx),
1580 &[
1581 "v root1",
1582 " > .git",
1583 " > a",
1584 " > b",
1585 " > C",
1586 " [PROCESSING: 'the-new-filename'] <== selected",
1587 " .dockerignore",
1588 "v root2",
1589 " > d",
1590 " > e",
1591 ]
1592 );
1593
1594 confirm.await.unwrap();
1595 assert_eq!(
1596 visible_entries_as_strings(&panel, 0..10, cx),
1597 &[
1598 "v root1",
1599 " > .git",
1600 " > a",
1601 " > b",
1602 " > C",
1603 " .dockerignore",
1604 " the-new-filename <== selected",
1605 "v root2",
1606 " > d",
1607 " > e",
1608 ]
1609 );
1610
1611 select_path(&panel, "root1/b", cx);
1612 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
1613 assert_eq!(
1614 visible_entries_as_strings(&panel, 0..10, cx),
1615 &[
1616 "v root1",
1617 " > .git",
1618 " > a",
1619 " v b",
1620 " > 3",
1621 " > 4",
1622 " [EDITOR: ''] <== selected",
1623 " > C",
1624 " .dockerignore",
1625 " the-new-filename",
1626 ]
1627 );
1628
1629 panel
1630 .update(cx, |panel, cx| {
1631 panel
1632 .filename_editor
1633 .update(cx, |editor, cx| editor.set_text("another-filename", cx));
1634 panel.confirm(&Confirm, cx).unwrap()
1635 })
1636 .await
1637 .unwrap();
1638 assert_eq!(
1639 visible_entries_as_strings(&panel, 0..10, cx),
1640 &[
1641 "v root1",
1642 " > .git",
1643 " > a",
1644 " v b",
1645 " > 3",
1646 " > 4",
1647 " another-filename <== selected",
1648 " > C",
1649 " .dockerignore",
1650 " the-new-filename",
1651 ]
1652 );
1653
1654 select_path(&panel, "root1/b/another-filename", cx);
1655 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
1656 assert_eq!(
1657 visible_entries_as_strings(&panel, 0..10, cx),
1658 &[
1659 "v root1",
1660 " > .git",
1661 " > a",
1662 " v b",
1663 " > 3",
1664 " > 4",
1665 " [EDITOR: 'another-filename'] <== selected",
1666 " > C",
1667 " .dockerignore",
1668 " the-new-filename",
1669 ]
1670 );
1671
1672 let confirm = panel.update(cx, |panel, cx| {
1673 panel
1674 .filename_editor
1675 .update(cx, |editor, cx| editor.set_text("a-different-filename", cx));
1676 panel.confirm(&Confirm, cx).unwrap()
1677 });
1678 assert_eq!(
1679 visible_entries_as_strings(&panel, 0..10, cx),
1680 &[
1681 "v root1",
1682 " > .git",
1683 " > a",
1684 " v b",
1685 " > 3",
1686 " > 4",
1687 " [PROCESSING: 'a-different-filename'] <== selected",
1688 " > C",
1689 " .dockerignore",
1690 " the-new-filename",
1691 ]
1692 );
1693
1694 confirm.await.unwrap();
1695 assert_eq!(
1696 visible_entries_as_strings(&panel, 0..10, cx),
1697 &[
1698 "v root1",
1699 " > .git",
1700 " > a",
1701 " v b",
1702 " > 3",
1703 " > 4",
1704 " a-different-filename <== selected",
1705 " > C",
1706 " .dockerignore",
1707 " the-new-filename",
1708 ]
1709 );
1710
1711 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
1712 assert_eq!(
1713 visible_entries_as_strings(&panel, 0..10, cx),
1714 &[
1715 "v root1",
1716 " > .git",
1717 " > a",
1718 " v b",
1719 " > [EDITOR: ''] <== selected",
1720 " > 3",
1721 " > 4",
1722 " a-different-filename",
1723 " > C",
1724 " .dockerignore",
1725 ]
1726 );
1727
1728 let confirm = panel.update(cx, |panel, cx| {
1729 panel
1730 .filename_editor
1731 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
1732 panel.confirm(&Confirm, cx).unwrap()
1733 });
1734 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
1735 assert_eq!(
1736 visible_entries_as_strings(&panel, 0..10, cx),
1737 &[
1738 "v root1",
1739 " > .git",
1740 " > a",
1741 " v b",
1742 " > [PROCESSING: 'new-dir']",
1743 " > 3 <== selected",
1744 " > 4",
1745 " a-different-filename",
1746 " > C",
1747 " .dockerignore",
1748 ]
1749 );
1750
1751 confirm.await.unwrap();
1752 assert_eq!(
1753 visible_entries_as_strings(&panel, 0..10, cx),
1754 &[
1755 "v root1",
1756 " > .git",
1757 " > a",
1758 " v b",
1759 " > 3 <== selected",
1760 " > 4",
1761 " > new-dir",
1762 " a-different-filename",
1763 " > C",
1764 " .dockerignore",
1765 ]
1766 );
1767
1768 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
1769 assert_eq!(
1770 visible_entries_as_strings(&panel, 0..10, cx),
1771 &[
1772 "v root1",
1773 " > .git",
1774 " > a",
1775 " v b",
1776 " > [EDITOR: '3'] <== selected",
1777 " > 4",
1778 " > new-dir",
1779 " a-different-filename",
1780 " > C",
1781 " .dockerignore",
1782 ]
1783 );
1784
1785 // Dismiss the rename editor when it loses focus.
1786 workspace.update(cx, |_, cx| cx.focus_self());
1787 assert_eq!(
1788 visible_entries_as_strings(&panel, 0..10, cx),
1789 &[
1790 "v root1",
1791 " > .git",
1792 " > a",
1793 " v b",
1794 " > 3 <== selected",
1795 " > 4",
1796 " > new-dir",
1797 " a-different-filename",
1798 " > C",
1799 " .dockerignore",
1800 ]
1801 );
1802 }
1803
1804 #[gpui::test]
1805 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
1806 cx.foreground().forbid_parking();
1807 cx.update(|cx| {
1808 let settings = Settings::test(cx);
1809 cx.set_global(settings);
1810 });
1811
1812 let fs = FakeFs::new(cx.background());
1813 fs.insert_tree(
1814 "/root1",
1815 json!({
1816 "one.two.txt": "",
1817 "one.txt": ""
1818 }),
1819 )
1820 .await;
1821
1822 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
1823 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
1824 let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx));
1825
1826 panel.update(cx, |panel, cx| {
1827 panel.select_next(&Default::default(), cx);
1828 panel.select_next(&Default::default(), cx);
1829 });
1830
1831 assert_eq!(
1832 visible_entries_as_strings(&panel, 0..50, cx),
1833 &[
1834 //
1835 "v root1",
1836 " one.two.txt <== selected",
1837 " one.txt",
1838 ]
1839 );
1840
1841 // Regression test - file name is created correctly when
1842 // the copied file's name contains multiple dots.
1843 panel.update(cx, |panel, cx| {
1844 panel.copy(&Default::default(), cx);
1845 panel.paste(&Default::default(), cx);
1846 });
1847 cx.foreground().run_until_parked();
1848
1849 assert_eq!(
1850 visible_entries_as_strings(&panel, 0..50, cx),
1851 &[
1852 //
1853 "v root1",
1854 " one.two copy.txt",
1855 " one.two.txt <== selected",
1856 " one.txt",
1857 ]
1858 );
1859
1860 panel.update(cx, |panel, cx| {
1861 panel.paste(&Default::default(), cx);
1862 });
1863 cx.foreground().run_until_parked();
1864
1865 assert_eq!(
1866 visible_entries_as_strings(&panel, 0..50, cx),
1867 &[
1868 //
1869 "v root1",
1870 " one.two copy 1.txt",
1871 " one.two copy.txt",
1872 " one.two.txt <== selected",
1873 " one.txt",
1874 ]
1875 );
1876 }
1877
1878 fn toggle_expand_dir(
1879 panel: &ViewHandle<ProjectPanel>,
1880 path: impl AsRef<Path>,
1881 cx: &mut TestAppContext,
1882 ) {
1883 let path = path.as_ref();
1884 panel.update(cx, |panel, cx| {
1885 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1886 let worktree = worktree.read(cx);
1887 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1888 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1889 panel.toggle_expanded(entry_id, cx);
1890 return;
1891 }
1892 }
1893 panic!("no worktree for path {:?}", path);
1894 });
1895 }
1896
1897 fn select_path(
1898 panel: &ViewHandle<ProjectPanel>,
1899 path: impl AsRef<Path>,
1900 cx: &mut TestAppContext,
1901 ) {
1902 let path = path.as_ref();
1903 panel.update(cx, |panel, cx| {
1904 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
1905 let worktree = worktree.read(cx);
1906 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
1907 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
1908 panel.selection = Some(Selection {
1909 worktree_id: worktree.id(),
1910 entry_id,
1911 });
1912 return;
1913 }
1914 }
1915 panic!("no worktree for path {:?}", path);
1916 });
1917 }
1918
1919 fn visible_entries_as_strings(
1920 panel: &ViewHandle<ProjectPanel>,
1921 range: Range<usize>,
1922 cx: &mut TestAppContext,
1923 ) -> Vec<String> {
1924 let mut result = Vec::new();
1925 let mut project_entries = HashSet::new();
1926 let mut has_editor = false;
1927
1928 panel.update(cx, |panel, cx| {
1929 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
1930 if details.is_editing {
1931 assert!(!has_editor, "duplicate editor entry");
1932 has_editor = true;
1933 } else {
1934 assert!(
1935 project_entries.insert(project_entry),
1936 "duplicate project entry {:?} {:?}",
1937 project_entry,
1938 details
1939 );
1940 }
1941
1942 let indent = " ".repeat(details.depth);
1943 let icon = if matches!(details.kind, EntryKind::Dir | EntryKind::PendingDir) {
1944 if details.is_expanded {
1945 "v "
1946 } else {
1947 "> "
1948 }
1949 } else {
1950 " "
1951 };
1952 let name = if details.is_editing {
1953 format!("[EDITOR: '{}']", details.filename)
1954 } else if details.is_processing {
1955 format!("[PROCESSING: '{}']", details.filename)
1956 } else {
1957 details.filename.clone()
1958 };
1959 let selected = if details.is_selected {
1960 " <== selected"
1961 } else {
1962 ""
1963 };
1964 result.push(format!("{indent}{icon}{name}{selected}"));
1965 });
1966 });
1967
1968 result
1969 }
1970}