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