1mod project_panel_settings;
2mod scrollbar;
3use client::{ErrorCode, ErrorExt};
4use scrollbar::ProjectPanelScrollbar;
5use settings::{Settings, SettingsStore};
6
7use db::kvp::KEY_VALUE_STORE;
8use editor::{
9 items::entry_git_aware_label_color,
10 scroll::{Autoscroll, ScrollbarAutoHide},
11 Editor,
12};
13use file_icons::FileIcons;
14
15use anyhow::{anyhow, Result};
16use collections::{hash_map, BTreeSet, HashMap};
17use git::repository::GitFileStatus;
18use gpui::{
19 actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement,
20 AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, DragMoveEvent,
21 EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement, KeyContext,
22 ListSizingBehavior, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point,
23 PromptLevel, Render, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View,
24 ViewContext, VisualContext as _, WeakView, WindowContext,
25};
26use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
27use project::{Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
28use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings, ShowScrollbar};
29use serde::{Deserialize, Serialize};
30use std::{
31 cell::{Cell, OnceCell},
32 collections::HashSet,
33 ffi::OsStr,
34 ops::Range,
35 path::{Path, PathBuf},
36 rc::Rc,
37 sync::Arc,
38 time::Duration,
39};
40use theme::ThemeSettings;
41use ui::{prelude::*, v_flex, ContextMenu, Icon, KeyBinding, Label, ListItem, Tooltip};
42use util::{maybe, ResultExt, TryFutureExt};
43use workspace::{
44 dock::{DockPosition, Panel, PanelEvent},
45 notifications::{DetachAndPromptErr, NotifyTaskExt},
46 DraggedSelection, OpenInTerminal, SelectedEntry, Workspace,
47};
48use worktree::CreatedEntry;
49
50const PROJECT_PANEL_KEY: &str = "ProjectPanel";
51const NEW_ENTRY_ID: ProjectEntryId = ProjectEntryId::MAX;
52
53pub struct ProjectPanel {
54 project: Model<Project>,
55 fs: Arc<dyn Fs>,
56 scroll_handle: UniformListScrollHandle,
57 focus_handle: FocusHandle,
58 visible_entries: Vec<(WorktreeId, Vec<Entry>, OnceCell<HashSet<Arc<Path>>>)>,
59 last_worktree_root_id: Option<ProjectEntryId>,
60 last_external_paths_drag_over_entry: Option<ProjectEntryId>,
61 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
62 unfolded_dir_ids: HashSet<ProjectEntryId>,
63 // Currently selected entry in a file tree
64 selection: Option<SelectedEntry>,
65 marked_entries: BTreeSet<SelectedEntry>,
66 context_menu: Option<(View<ContextMenu>, Point<Pixels>, Subscription)>,
67 edit_state: Option<EditState>,
68 filename_editor: View<Editor>,
69 clipboard: Option<ClipboardEntry>,
70 _dragged_entry_destination: Option<Arc<Path>>,
71 workspace: WeakView<Workspace>,
72 width: Option<Pixels>,
73 pending_serialization: Task<Option<()>>,
74 show_scrollbar: bool,
75 scrollbar_drag_thumb_offset: Rc<Cell<Option<f32>>>,
76 hide_scrollbar_task: Option<Task<()>>,
77}
78
79#[derive(Clone, Debug)]
80struct EditState {
81 worktree_id: WorktreeId,
82 entry_id: ProjectEntryId,
83 is_new_entry: bool,
84 is_dir: bool,
85 processing_filename: Option<String>,
86}
87
88#[derive(Clone, Debug)]
89enum ClipboardEntry {
90 Copied(BTreeSet<SelectedEntry>),
91 Cut(BTreeSet<SelectedEntry>),
92}
93
94#[derive(Debug, PartialEq, Eq, Clone)]
95pub struct EntryDetails {
96 filename: String,
97 icon: Option<Arc<str>>,
98 path: Arc<Path>,
99 depth: usize,
100 kind: EntryKind,
101 is_ignored: bool,
102 is_expanded: bool,
103 is_selected: bool,
104 is_marked: bool,
105 is_editing: bool,
106 is_processing: bool,
107 is_cut: bool,
108 git_status: Option<GitFileStatus>,
109 is_private: bool,
110 worktree_id: WorktreeId,
111 canonical_path: Option<PathBuf>,
112}
113
114#[derive(PartialEq, Clone, Default, Debug, Deserialize)]
115pub struct Delete {
116 #[serde(default)]
117 pub skip_prompt: bool,
118}
119
120#[derive(PartialEq, Clone, Default, Debug, Deserialize)]
121pub struct Trash {
122 #[serde(default)]
123 pub skip_prompt: bool,
124}
125
126impl_actions!(project_panel, [Delete, Trash]);
127
128actions!(
129 project_panel,
130 [
131 ExpandSelectedEntry,
132 CollapseSelectedEntry,
133 CollapseAllEntries,
134 NewDirectory,
135 NewFile,
136 Copy,
137 CopyPath,
138 CopyRelativePath,
139 Duplicate,
140 RevealInFinder,
141 Cut,
142 Paste,
143 Rename,
144 Open,
145 OpenPermanent,
146 ToggleFocus,
147 NewSearchInDirectory,
148 UnfoldDirectory,
149 FoldDirectory,
150 SelectParent,
151 ]
152);
153
154pub fn init_settings(cx: &mut AppContext) {
155 ProjectPanelSettings::register(cx);
156}
157
158pub fn init(assets: impl AssetSource, cx: &mut AppContext) {
159 init_settings(cx);
160 file_icons::init(assets, cx);
161
162 cx.observe_new_views(|workspace: &mut Workspace, _| {
163 workspace.register_action(|workspace, _: &ToggleFocus, cx| {
164 workspace.toggle_panel_focus::<ProjectPanel>(cx);
165 });
166 })
167 .detach();
168}
169
170#[derive(Debug)]
171pub enum Event {
172 OpenedEntry {
173 entry_id: ProjectEntryId,
174 focus_opened_item: bool,
175 allow_preview: bool,
176 mark_selected: bool,
177 },
178 SplitEntry {
179 entry_id: ProjectEntryId,
180 },
181 Focus,
182}
183
184#[derive(Serialize, Deserialize)]
185struct SerializedProjectPanel {
186 width: Option<Pixels>,
187}
188
189struct DraggedProjectEntryView {
190 selection: SelectedEntry,
191 details: EntryDetails,
192 width: Pixels,
193 selections: Arc<BTreeSet<SelectedEntry>>,
194}
195
196impl ProjectPanel {
197 fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
198 let project = workspace.project().clone();
199 let project_panel = cx.new_view(|cx: &mut ViewContext<Self>| {
200 let focus_handle = cx.focus_handle();
201 cx.on_focus(&focus_handle, Self::focus_in).detach();
202 cx.on_focus_out(&focus_handle, |this, _, cx| {
203 this.hide_scrollbar(cx);
204 })
205 .detach();
206 cx.subscribe(&project, |this, project, event, cx| match event {
207 project::Event::ActiveEntryChanged(Some(entry_id)) => {
208 if ProjectPanelSettings::get_global(cx).auto_reveal_entries {
209 this.reveal_entry(project, *entry_id, true, cx);
210 }
211 }
212 project::Event::RevealInProjectPanel(entry_id) => {
213 this.reveal_entry(project, *entry_id, false, cx);
214 cx.emit(PanelEvent::Activate);
215 }
216 project::Event::ActivateProjectPanel => {
217 cx.emit(PanelEvent::Activate);
218 }
219 project::Event::WorktreeRemoved(id) => {
220 this.expanded_dir_ids.remove(id);
221 this.update_visible_entries(None, cx);
222 cx.notify();
223 }
224 project::Event::WorktreeUpdatedEntries(_, _)
225 | project::Event::WorktreeAdded
226 | project::Event::WorktreeOrderChanged => {
227 this.update_visible_entries(None, cx);
228 cx.notify();
229 }
230 _ => {}
231 })
232 .detach();
233
234 let filename_editor = cx.new_view(|cx| Editor::single_line(cx));
235
236 cx.subscribe(&filename_editor, |this, _, event, cx| match event {
237 editor::EditorEvent::BufferEdited
238 | editor::EditorEvent::SelectionsChanged { .. } => {
239 this.autoscroll(cx);
240 }
241 editor::EditorEvent::Blurred => {
242 if this
243 .edit_state
244 .as_ref()
245 .map_or(false, |state| state.processing_filename.is_none())
246 {
247 this.edit_state = None;
248 this.update_visible_entries(None, cx);
249 }
250 }
251 _ => {}
252 })
253 .detach();
254
255 cx.observe_global::<FileIcons>(|_, cx| {
256 cx.notify();
257 })
258 .detach();
259
260 let mut project_panel_settings = *ProjectPanelSettings::get_global(cx);
261 cx.observe_global::<SettingsStore>(move |_, cx| {
262 let new_settings = *ProjectPanelSettings::get_global(cx);
263 if project_panel_settings != new_settings {
264 project_panel_settings = new_settings;
265 cx.notify();
266 }
267 })
268 .detach();
269
270 let mut this = Self {
271 project: project.clone(),
272 fs: workspace.app_state().fs.clone(),
273 scroll_handle: UniformListScrollHandle::new(),
274 focus_handle,
275 visible_entries: Default::default(),
276 last_worktree_root_id: Default::default(),
277 last_external_paths_drag_over_entry: None,
278 expanded_dir_ids: Default::default(),
279 unfolded_dir_ids: Default::default(),
280 selection: None,
281 marked_entries: Default::default(),
282 edit_state: None,
283 context_menu: None,
284 filename_editor,
285 clipboard: None,
286 _dragged_entry_destination: None,
287 workspace: workspace.weak_handle(),
288 width: None,
289 pending_serialization: Task::ready(None),
290 show_scrollbar: !Self::should_autohide_scrollbar(cx),
291 hide_scrollbar_task: None,
292 scrollbar_drag_thumb_offset: Default::default(),
293 };
294 this.update_visible_entries(None, cx);
295
296 this
297 });
298
299 cx.subscribe(&project_panel, {
300 let project_panel = project_panel.downgrade();
301 move |workspace, _, event, cx| match event {
302 &Event::OpenedEntry {
303 entry_id,
304 focus_opened_item,
305 allow_preview,
306 mark_selected
307 } => {
308 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
309 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
310 let file_path = entry.path.clone();
311 let worktree_id = worktree.read(cx).id();
312 let entry_id = entry.id;
313
314 project_panel.update(cx, |this, _| {
315 if !mark_selected {
316 this.marked_entries.clear();
317 }
318 this.marked_entries.insert(SelectedEntry {
319 worktree_id,
320 entry_id
321 });
322 }).ok();
323
324
325 workspace
326 .open_path_preview(
327 ProjectPath {
328 worktree_id,
329 path: file_path.clone(),
330 },
331 None,
332 focus_opened_item,
333 allow_preview,
334 cx,
335 )
336 .detach_and_prompt_err("Failed to open file", cx, move |e, _| {
337 match e.error_code() {
338 ErrorCode::Disconnected => Some("Disconnected from remote project".to_string()),
339 ErrorCode::UnsharedItem => Some(format!(
340 "{} is not shared by the host. This could be because it has been marked as `private`",
341 file_path.display()
342 )),
343 _ => None,
344 }
345 });
346
347 if let Some(project_panel) = project_panel.upgrade() {
348 // Always select the entry, regardless of whether it is opened or not.
349 project_panel.update(cx, |project_panel, _| {
350 project_panel.selection = Some(SelectedEntry {
351 worktree_id,
352 entry_id
353 });
354 });
355 if !focus_opened_item {
356 let focus_handle = project_panel.read(cx).focus_handle.clone();
357 cx.focus(&focus_handle);
358 }
359 }
360 }
361 }
362 }
363 &Event::SplitEntry { entry_id } => {
364 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
365 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
366 workspace
367 .split_path(
368 ProjectPath {
369 worktree_id: worktree.read(cx).id(),
370 path: entry.path.clone(),
371 },
372 cx,
373 )
374 .detach_and_log_err(cx);
375 }
376 }
377 }
378 _ => {}
379 }
380 })
381 .detach();
382
383 project_panel
384 }
385
386 pub async fn load(
387 workspace: WeakView<Workspace>,
388 mut cx: AsyncWindowContext,
389 ) -> Result<View<Self>> {
390 let serialized_panel = cx
391 .background_executor()
392 .spawn(async move { KEY_VALUE_STORE.read_kvp(PROJECT_PANEL_KEY) })
393 .await
394 .map_err(|e| anyhow!("Failed to load project panel: {}", e))
395 .log_err()
396 .flatten()
397 .map(|panel| serde_json::from_str::<SerializedProjectPanel>(&panel))
398 .transpose()
399 .log_err()
400 .flatten();
401
402 workspace.update(&mut cx, |workspace, cx| {
403 let panel = ProjectPanel::new(workspace, cx);
404 if let Some(serialized_panel) = serialized_panel {
405 panel.update(cx, |panel, cx| {
406 panel.width = serialized_panel.width.map(|px| px.round());
407 cx.notify();
408 });
409 }
410 panel
411 })
412 }
413
414 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
415 let width = self.width;
416 self.pending_serialization = cx.background_executor().spawn(
417 async move {
418 KEY_VALUE_STORE
419 .write_kvp(
420 PROJECT_PANEL_KEY.into(),
421 serde_json::to_string(&SerializedProjectPanel { width })?,
422 )
423 .await?;
424 anyhow::Ok(())
425 }
426 .log_err(),
427 );
428 }
429
430 fn focus_in(&mut self, cx: &mut ViewContext<Self>) {
431 if !self.focus_handle.contains_focused(cx) {
432 cx.emit(Event::Focus);
433 }
434 }
435
436 fn deploy_context_menu(
437 &mut self,
438 position: Point<Pixels>,
439 entry_id: ProjectEntryId,
440 cx: &mut ViewContext<Self>,
441 ) {
442 let this = cx.view().clone();
443 let project = self.project.read(cx);
444
445 let worktree_id = if let Some(id) = project.worktree_id_for_entry(entry_id, cx) {
446 id
447 } else {
448 return;
449 };
450
451 self.selection = Some(SelectedEntry {
452 worktree_id,
453 entry_id,
454 });
455
456 if let Some((worktree, entry)) = self.selected_entry(cx) {
457 let auto_fold_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
458 let is_root = Some(entry) == worktree.root_entry();
459 let is_dir = entry.is_dir();
460 let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree);
461 let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree);
462 let worktree_id = worktree.id();
463 let is_local = project.is_local();
464 let is_read_only = project.is_read_only();
465 let is_remote = project.is_remote();
466
467 let context_menu = ContextMenu::build(cx, |menu, cx| {
468 menu.context(self.focus_handle.clone()).when_else(
469 is_read_only,
470 |menu| {
471 menu.action("Copy Relative Path", Box::new(CopyRelativePath))
472 .when(is_dir, |menu| {
473 menu.action("Search Inside", Box::new(NewSearchInDirectory))
474 })
475 },
476 |menu| {
477 menu.action("New File", Box::new(NewFile))
478 .action("New Folder", Box::new(NewDirectory))
479 .separator()
480 .action("Reveal in Finder", Box::new(RevealInFinder))
481 .action("Open in Terminal", Box::new(OpenInTerminal))
482 .when(is_dir, |menu| {
483 menu.separator()
484 .action("Find in Folder…", Box::new(NewSearchInDirectory))
485 })
486 .when(is_unfoldable, |menu| {
487 menu.action("Unfold Directory", Box::new(UnfoldDirectory))
488 })
489 .when(is_foldable, |menu| {
490 menu.action("Fold Directory", Box::new(FoldDirectory))
491 })
492 .separator()
493 .action("Cut", Box::new(Cut))
494 .action("Copy", Box::new(Copy))
495 .action("Duplicate", Box::new(Duplicate))
496 // TODO: Paste should always be visible, cbut disabled when clipboard is empty
497 .when_some(self.clipboard.as_ref(), |menu, entry| {
498 let entries_for_worktree_id = (SelectedEntry {
499 worktree_id,
500 entry_id: ProjectEntryId::MIN,
501 })
502 ..(SelectedEntry {
503 worktree_id,
504 entry_id: ProjectEntryId::MAX,
505 });
506 menu.when(
507 entry
508 .items()
509 .range(entries_for_worktree_id)
510 .next()
511 .is_some(),
512 |menu| menu.action("Paste", Box::new(Paste)),
513 )
514 })
515 .separator()
516 .action("Copy Path", Box::new(CopyPath))
517 .action("Copy Relative Path", Box::new(CopyRelativePath))
518 .separator()
519 .action("Rename", Box::new(Rename))
520 .when(!is_root, |menu| {
521 menu.action("Trash", Box::new(Trash { skip_prompt: false }))
522 .action("Delete", Box::new(Delete { skip_prompt: false }))
523 })
524 .when(is_local & is_root, |menu| {
525 menu.separator()
526 .when(!is_remote, |menu| {
527 menu.action(
528 "Add Folder to Project…",
529 Box::new(workspace::AddFolderToProject),
530 )
531 })
532 .entry(
533 "Remove from Project",
534 None,
535 cx.handler_for(&this, move |this, cx| {
536 this.project.update(cx, |project, cx| {
537 project.remove_worktree(worktree_id, cx)
538 });
539 }),
540 )
541 })
542 .when(is_local & is_root, |menu| {
543 menu.separator()
544 .action("Collapse All", Box::new(CollapseAllEntries))
545 })
546 },
547 )
548 });
549
550 cx.focus_view(&context_menu);
551 let subscription = cx.subscribe(&context_menu, |this, _, _: &DismissEvent, cx| {
552 this.context_menu.take();
553 cx.notify();
554 });
555 self.context_menu = Some((context_menu, position, subscription));
556 }
557
558 cx.notify();
559 }
560
561 fn is_unfoldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
562 if !entry.is_dir() || self.unfolded_dir_ids.contains(&entry.id) {
563 return false;
564 }
565
566 if let Some(parent_path) = entry.path.parent() {
567 let snapshot = worktree.snapshot();
568 let mut child_entries = snapshot.child_entries(&parent_path);
569 if let Some(child) = child_entries.next() {
570 if child_entries.next().is_none() {
571 return child.kind.is_dir();
572 }
573 }
574 };
575 false
576 }
577
578 fn is_foldable(&self, entry: &Entry, worktree: &Worktree) -> bool {
579 if entry.is_dir() {
580 let snapshot = worktree.snapshot();
581
582 let mut child_entries = snapshot.child_entries(&entry.path);
583 if let Some(child) = child_entries.next() {
584 if child_entries.next().is_none() {
585 return child.kind.is_dir();
586 }
587 }
588 }
589 false
590 }
591
592 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
593 if let Some((worktree, entry)) = self.selected_entry(cx) {
594 if entry.is_dir() {
595 let worktree_id = worktree.id();
596 let entry_id = entry.id;
597 let expanded_dir_ids =
598 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
599 expanded_dir_ids
600 } else {
601 return;
602 };
603
604 match expanded_dir_ids.binary_search(&entry_id) {
605 Ok(_) => self.select_next(&SelectNext, cx),
606 Err(ix) => {
607 self.project.update(cx, |project, cx| {
608 project.expand_entry(worktree_id, entry_id, cx);
609 });
610
611 expanded_dir_ids.insert(ix, entry_id);
612 self.update_visible_entries(None, cx);
613 cx.notify();
614 }
615 }
616 }
617 }
618 }
619
620 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
621 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
622 let worktree_id = worktree.id();
623 let expanded_dir_ids =
624 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
625 expanded_dir_ids
626 } else {
627 return;
628 };
629
630 loop {
631 let entry_id = entry.id;
632 match expanded_dir_ids.binary_search(&entry_id) {
633 Ok(ix) => {
634 expanded_dir_ids.remove(ix);
635 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
636 cx.notify();
637 break;
638 }
639 Err(_) => {
640 if let Some(parent_entry) =
641 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
642 {
643 entry = parent_entry;
644 } else {
645 break;
646 }
647 }
648 }
649 }
650 }
651 }
652
653 pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext<Self>) {
654 // By keeping entries for fully collapsed worktrees, we avoid expanding them within update_visible_entries
655 // (which is it's default behaviour when there's no entry for a worktree in expanded_dir_ids).
656 self.expanded_dir_ids
657 .retain(|_, expanded_entries| expanded_entries.is_empty());
658 self.update_visible_entries(None, cx);
659 cx.notify();
660 }
661
662 fn toggle_expanded(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
663 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
664 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
665 self.project.update(cx, |project, cx| {
666 match expanded_dir_ids.binary_search(&entry_id) {
667 Ok(ix) => {
668 expanded_dir_ids.remove(ix);
669 }
670 Err(ix) => {
671 project.expand_entry(worktree_id, entry_id, cx);
672 expanded_dir_ids.insert(ix, entry_id);
673 }
674 }
675 });
676 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
677 cx.focus(&self.focus_handle);
678 cx.notify();
679 }
680 }
681 }
682
683 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
684 if let Some(selection) = self.selection {
685 let (mut worktree_ix, mut entry_ix, _) =
686 self.index_for_selection(selection).unwrap_or_default();
687 if entry_ix > 0 {
688 entry_ix -= 1;
689 } else if worktree_ix > 0 {
690 worktree_ix -= 1;
691 entry_ix = self.visible_entries[worktree_ix].1.len() - 1;
692 } else {
693 return;
694 }
695
696 let (worktree_id, worktree_entries, _) = &self.visible_entries[worktree_ix];
697 let selection = SelectedEntry {
698 worktree_id: *worktree_id,
699 entry_id: worktree_entries[entry_ix].id,
700 };
701 self.selection = Some(selection);
702 if cx.modifiers().shift {
703 self.marked_entries.insert(selection);
704 }
705 self.autoscroll(cx);
706 cx.notify();
707 } else {
708 self.select_first(&SelectFirst {}, cx);
709 }
710 }
711
712 fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
713 if let Some(task) = self.confirm_edit(cx) {
714 task.detach_and_notify_err(cx);
715 }
716 }
717
718 fn open(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
719 self.open_internal(false, true, false, cx);
720 }
721
722 fn open_permanent(&mut self, _: &OpenPermanent, cx: &mut ViewContext<Self>) {
723 self.open_internal(true, false, true, cx);
724 }
725
726 fn open_internal(
727 &mut self,
728 mark_selected: bool,
729 allow_preview: bool,
730 focus_opened_item: bool,
731 cx: &mut ViewContext<Self>,
732 ) {
733 if let Some((_, entry)) = self.selected_entry(cx) {
734 if entry.is_file() {
735 self.open_entry(
736 entry.id,
737 mark_selected,
738 focus_opened_item,
739 allow_preview,
740 cx,
741 );
742 } else {
743 self.toggle_expanded(entry.id, cx);
744 }
745 }
746 }
747
748 fn confirm_edit(&mut self, cx: &mut ViewContext<Self>) -> Option<Task<Result<()>>> {
749 let edit_state = self.edit_state.as_mut()?;
750 cx.focus(&self.focus_handle);
751
752 let worktree_id = edit_state.worktree_id;
753 let is_new_entry = edit_state.is_new_entry;
754 let filename = self.filename_editor.read(cx).text(cx);
755 edit_state.is_dir = edit_state.is_dir
756 || (edit_state.is_new_entry && filename.ends_with(std::path::MAIN_SEPARATOR));
757 let is_dir = edit_state.is_dir;
758 let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
759 let entry = worktree.read(cx).entry_for_id(edit_state.entry_id)?.clone();
760
761 let path_already_exists = |path| worktree.read(cx).entry_for_path(path).is_some();
762 let edit_task;
763 let edited_entry_id;
764 if is_new_entry {
765 self.selection = Some(SelectedEntry {
766 worktree_id,
767 entry_id: NEW_ENTRY_ID,
768 });
769 let new_path = entry.path.join(&filename.trim_start_matches('/'));
770 if path_already_exists(new_path.as_path()) {
771 return None;
772 }
773
774 edited_entry_id = NEW_ENTRY_ID;
775 edit_task = self.project.update(cx, |project, cx| {
776 project.create_entry((worktree_id, &new_path), is_dir, cx)
777 });
778 } else {
779 let new_path = if let Some(parent) = entry.path.clone().parent() {
780 parent.join(&filename)
781 } else {
782 filename.clone().into()
783 };
784 if path_already_exists(new_path.as_path()) {
785 return None;
786 }
787
788 edited_entry_id = entry.id;
789 edit_task = self.project.update(cx, |project, cx| {
790 project.rename_entry(entry.id, new_path.as_path(), cx)
791 });
792 };
793
794 edit_state.processing_filename = Some(filename);
795 cx.notify();
796
797 Some(cx.spawn(|project_panel, mut cx| async move {
798 let new_entry = edit_task.await;
799 project_panel.update(&mut cx, |project_panel, cx| {
800 project_panel.edit_state.take();
801 cx.notify();
802 })?;
803
804 match new_entry {
805 Err(e) => {
806 project_panel.update(&mut cx, |project_panel, cx| {
807 project_panel.marked_entries.clear();
808 project_panel.update_visible_entries(None, cx);
809 }).ok();
810 Err(e)?;
811 }
812 Ok(CreatedEntry::Included(new_entry)) => {
813 project_panel.update(&mut cx, |project_panel, cx| {
814 if let Some(selection) = &mut project_panel.selection {
815 if selection.entry_id == edited_entry_id {
816 selection.worktree_id = worktree_id;
817 selection.entry_id = new_entry.id;
818 project_panel.marked_entries.clear();
819 project_panel.expand_to_selection(cx);
820 }
821 }
822 project_panel.update_visible_entries(None, cx);
823 if is_new_entry && !is_dir {
824 project_panel.open_entry(new_entry.id, false, true, false, cx);
825 }
826 cx.notify();
827 })?;
828 }
829 Ok(CreatedEntry::Excluded { abs_path }) => {
830 if let Some(open_task) = project_panel
831 .update(&mut cx, |project_panel, cx| {
832 project_panel.marked_entries.clear();
833 project_panel.update_visible_entries(None, cx);
834
835 if is_dir {
836 project_panel.project.update(cx, |_, cx| {
837 cx.emit(project::Event::Notification(format!(
838 "Created an excluded directory at {abs_path:?}.\nAlter `file_scan_exclusions` in the settings to show it in the panel"
839 )))
840 });
841 None
842 } else {
843 project_panel
844 .workspace
845 .update(cx, |workspace, cx| {
846 workspace.open_abs_path(abs_path, true, cx)
847 })
848 .ok()
849 }
850 })
851 .ok()
852 .flatten()
853 {
854 let _ = open_task.await?;
855 }
856 }
857 }
858 Ok(())
859 }))
860 }
861
862 fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
863 self.edit_state = None;
864 self.update_visible_entries(None, cx);
865 self.marked_entries.clear();
866 cx.focus(&self.focus_handle);
867 cx.notify();
868 }
869
870 fn open_entry(
871 &mut self,
872 entry_id: ProjectEntryId,
873 mark_selected: bool,
874 focus_opened_item: bool,
875 allow_preview: bool,
876 cx: &mut ViewContext<Self>,
877 ) {
878 cx.emit(Event::OpenedEntry {
879 entry_id,
880 focus_opened_item,
881 allow_preview,
882 mark_selected,
883 });
884 }
885
886 fn split_entry(&mut self, entry_id: ProjectEntryId, cx: &mut ViewContext<Self>) {
887 cx.emit(Event::SplitEntry { entry_id });
888 }
889
890 fn new_file(&mut self, _: &NewFile, cx: &mut ViewContext<Self>) {
891 self.add_entry(false, cx)
892 }
893
894 fn new_directory(&mut self, _: &NewDirectory, cx: &mut ViewContext<Self>) {
895 self.add_entry(true, cx)
896 }
897
898 fn add_entry(&mut self, is_dir: bool, cx: &mut ViewContext<Self>) {
899 if let Some(SelectedEntry {
900 worktree_id,
901 entry_id,
902 }) = self.selection
903 {
904 let directory_id;
905 if let Some((worktree, expanded_dir_ids)) = self
906 .project
907 .read(cx)
908 .worktree_for_id(worktree_id, cx)
909 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
910 {
911 let worktree = worktree.read(cx);
912 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
913 loop {
914 if entry.is_dir() {
915 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
916 expanded_dir_ids.insert(ix, entry.id);
917 }
918 directory_id = entry.id;
919 break;
920 } else {
921 if let Some(parent_path) = entry.path.parent() {
922 if let Some(parent_entry) = worktree.entry_for_path(parent_path) {
923 entry = parent_entry;
924 continue;
925 }
926 }
927 return;
928 }
929 }
930 } else {
931 return;
932 };
933 } else {
934 return;
935 };
936 self.marked_entries.clear();
937 self.edit_state = Some(EditState {
938 worktree_id,
939 entry_id: directory_id,
940 is_new_entry: true,
941 is_dir,
942 processing_filename: None,
943 });
944 self.filename_editor.update(cx, |editor, cx| {
945 editor.clear(cx);
946 editor.focus(cx);
947 });
948 self.update_visible_entries(Some((worktree_id, NEW_ENTRY_ID)), cx);
949 self.autoscroll(cx);
950 cx.notify();
951 }
952 }
953
954 fn rename(&mut self, _: &Rename, cx: &mut ViewContext<Self>) {
955 if let Some(SelectedEntry {
956 worktree_id,
957 entry_id,
958 }) = self.selection
959 {
960 if let Some(worktree) = self.project.read(cx).worktree_for_id(worktree_id, cx) {
961 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
962 self.edit_state = Some(EditState {
963 worktree_id,
964 entry_id,
965 is_new_entry: false,
966 is_dir: entry.is_dir(),
967 processing_filename: None,
968 });
969 let file_name = entry
970 .path
971 .file_name()
972 .map(|s| s.to_string_lossy())
973 .unwrap_or_default()
974 .to_string();
975 let file_stem = entry.path.file_stem().map(|s| s.to_string_lossy());
976 let selection_end =
977 file_stem.map_or(file_name.len(), |file_stem| file_stem.len());
978 self.filename_editor.update(cx, |editor, cx| {
979 editor.set_text(file_name, cx);
980 editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
981 s.select_ranges([0..selection_end])
982 });
983 editor.focus(cx);
984 });
985 self.update_visible_entries(None, cx);
986 self.autoscroll(cx);
987 cx.notify();
988 }
989 }
990 }
991 }
992
993 fn trash(&mut self, action: &Trash, cx: &mut ViewContext<Self>) {
994 self.remove(true, action.skip_prompt, cx);
995 }
996
997 fn delete(&mut self, action: &Delete, cx: &mut ViewContext<Self>) {
998 self.remove(false, action.skip_prompt, cx);
999 }
1000
1001 fn remove(&mut self, trash: bool, skip_prompt: bool, cx: &mut ViewContext<'_, ProjectPanel>) {
1002 maybe!({
1003 if self.marked_entries.is_empty() && self.selection.is_none() {
1004 return None;
1005 }
1006 let project = self.project.read(cx);
1007 let items_to_delete = self.marked_entries();
1008 let file_paths = items_to_delete
1009 .into_iter()
1010 .filter_map(|selection| {
1011 Some((
1012 selection.entry_id,
1013 project
1014 .path_for_entry(selection.entry_id, cx)?
1015 .path
1016 .file_name()?
1017 .to_string_lossy()
1018 .into_owned(),
1019 ))
1020 })
1021 .collect::<Vec<_>>();
1022 if file_paths.is_empty() {
1023 return None;
1024 }
1025 let answer = if !skip_prompt {
1026 let operation = if trash { "Trash" } else { "Delete" };
1027
1028 let prompt =
1029 if let Some((_, path)) = file_paths.first().filter(|_| file_paths.len() == 1) {
1030 format!("{operation} {path}?")
1031 } else {
1032 const CUTOFF_POINT: usize = 10;
1033 let names = if file_paths.len() > CUTOFF_POINT {
1034 let truncated_path_counts = file_paths.len() - CUTOFF_POINT;
1035 let mut paths = file_paths
1036 .iter()
1037 .map(|(_, path)| path.clone())
1038 .take(CUTOFF_POINT)
1039 .collect::<Vec<_>>();
1040 paths.truncate(CUTOFF_POINT);
1041 if truncated_path_counts == 1 {
1042 paths.push(".. 1 file not shown".into());
1043 } else {
1044 paths.push(format!(".. {} files not shown", truncated_path_counts));
1045 }
1046 paths
1047 } else {
1048 file_paths.iter().map(|(_, path)| path.clone()).collect()
1049 };
1050
1051 format!(
1052 "Do you want to {} the following {} files?\n{}",
1053 operation.to_lowercase(),
1054 file_paths.len(),
1055 names.join("\n")
1056 )
1057 };
1058 Some(cx.prompt(PromptLevel::Info, &prompt, None, &[operation, "Cancel"]))
1059 } else {
1060 None
1061 };
1062
1063 cx.spawn(|this, mut cx| async move {
1064 if let Some(answer) = answer {
1065 if answer.await != Ok(0) {
1066 return Result::<(), anyhow::Error>::Ok(());
1067 }
1068 }
1069 for (entry_id, _) in file_paths {
1070 this.update(&mut cx, |this, cx| {
1071 this.project
1072 .update(cx, |project, cx| project.delete_entry(entry_id, trash, cx))
1073 .ok_or_else(|| anyhow!("no such entry"))
1074 })??
1075 .await?;
1076 }
1077 Result::<(), anyhow::Error>::Ok(())
1078 })
1079 .detach_and_log_err(cx);
1080 Some(())
1081 });
1082 }
1083
1084 fn unfold_directory(&mut self, _: &UnfoldDirectory, cx: &mut ViewContext<Self>) {
1085 if let Some((worktree, entry)) = self.selected_entry(cx) {
1086 self.unfolded_dir_ids.insert(entry.id);
1087
1088 let snapshot = worktree.snapshot();
1089 let mut parent_path = entry.path.parent();
1090 while let Some(path) = parent_path {
1091 if let Some(parent_entry) = worktree.entry_for_path(path) {
1092 let mut children_iter = snapshot.child_entries(path);
1093
1094 if children_iter.by_ref().take(2).count() > 1 {
1095 break;
1096 }
1097
1098 self.unfolded_dir_ids.insert(parent_entry.id);
1099 parent_path = path.parent();
1100 } else {
1101 break;
1102 }
1103 }
1104
1105 self.update_visible_entries(None, cx);
1106 self.autoscroll(cx);
1107 cx.notify();
1108 }
1109 }
1110
1111 fn fold_directory(&mut self, _: &FoldDirectory, cx: &mut ViewContext<Self>) {
1112 if let Some((worktree, entry)) = self.selected_entry(cx) {
1113 self.unfolded_dir_ids.remove(&entry.id);
1114
1115 let snapshot = worktree.snapshot();
1116 let mut path = &*entry.path;
1117 loop {
1118 let mut child_entries_iter = snapshot.child_entries(path);
1119 if let Some(child) = child_entries_iter.next() {
1120 if child_entries_iter.next().is_none() && child.is_dir() {
1121 self.unfolded_dir_ids.remove(&child.id);
1122 path = &*child.path;
1123 } else {
1124 break;
1125 }
1126 } else {
1127 break;
1128 }
1129 }
1130
1131 self.update_visible_entries(None, cx);
1132 self.autoscroll(cx);
1133 cx.notify();
1134 }
1135 }
1136
1137 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
1138 if let Some(selection) = self.selection {
1139 let (mut worktree_ix, mut entry_ix, _) =
1140 self.index_for_selection(selection).unwrap_or_default();
1141 if let Some((_, worktree_entries, _)) = self.visible_entries.get(worktree_ix) {
1142 if entry_ix + 1 < worktree_entries.len() {
1143 entry_ix += 1;
1144 } else {
1145 worktree_ix += 1;
1146 entry_ix = 0;
1147 }
1148 }
1149
1150 if let Some((worktree_id, worktree_entries, _)) = self.visible_entries.get(worktree_ix)
1151 {
1152 if let Some(entry) = worktree_entries.get(entry_ix) {
1153 let selection = SelectedEntry {
1154 worktree_id: *worktree_id,
1155 entry_id: entry.id,
1156 };
1157 self.selection = Some(selection);
1158 if cx.modifiers().shift {
1159 self.marked_entries.insert(selection);
1160 }
1161
1162 self.autoscroll(cx);
1163 cx.notify();
1164 }
1165 }
1166 } else {
1167 self.select_first(&SelectFirst {}, cx);
1168 }
1169 }
1170
1171 fn select_parent(&mut self, _: &SelectParent, cx: &mut ViewContext<Self>) {
1172 if let Some((worktree, entry)) = self.selected_entry(cx) {
1173 if let Some(parent) = entry.path.parent() {
1174 if let Some(parent_entry) = worktree.entry_for_path(parent) {
1175 self.selection = Some(SelectedEntry {
1176 worktree_id: worktree.id(),
1177 entry_id: parent_entry.id,
1178 });
1179 self.autoscroll(cx);
1180 cx.notify();
1181 }
1182 }
1183 } else {
1184 self.select_first(&SelectFirst {}, cx);
1185 }
1186 }
1187
1188 fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext<Self>) {
1189 let worktree = self
1190 .visible_entries
1191 .first()
1192 .and_then(|(worktree_id, _, _)| {
1193 self.project.read(cx).worktree_for_id(*worktree_id, cx)
1194 });
1195 if let Some(worktree) = worktree {
1196 let worktree = worktree.read(cx);
1197 let worktree_id = worktree.id();
1198 if let Some(root_entry) = worktree.root_entry() {
1199 let selection = SelectedEntry {
1200 worktree_id,
1201 entry_id: root_entry.id,
1202 };
1203 self.selection = Some(selection);
1204 if cx.modifiers().shift {
1205 self.marked_entries.insert(selection);
1206 }
1207 self.autoscroll(cx);
1208 cx.notify();
1209 }
1210 }
1211 }
1212
1213 fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext<Self>) {
1214 let worktree = self.visible_entries.last().and_then(|(worktree_id, _, _)| {
1215 self.project.read(cx).worktree_for_id(*worktree_id, cx)
1216 });
1217 if let Some(worktree) = worktree {
1218 let worktree = worktree.read(cx);
1219 let worktree_id = worktree.id();
1220 if let Some(last_entry) = worktree.entries(true, 0).last() {
1221 self.selection = Some(SelectedEntry {
1222 worktree_id,
1223 entry_id: last_entry.id,
1224 });
1225 self.autoscroll(cx);
1226 cx.notify();
1227 }
1228 }
1229 }
1230
1231 fn autoscroll(&mut self, cx: &mut ViewContext<Self>) {
1232 if let Some((_, _, index)) = self.selection.and_then(|s| self.index_for_selection(s)) {
1233 self.scroll_handle.scroll_to_item(index);
1234 cx.notify();
1235 }
1236 }
1237
1238 fn cut(&mut self, _: &Cut, cx: &mut ViewContext<Self>) {
1239 let entries = self.marked_entries();
1240 if !entries.is_empty() {
1241 self.clipboard = Some(ClipboardEntry::Cut(entries));
1242 cx.notify();
1243 }
1244 }
1245
1246 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
1247 let entries = self.marked_entries();
1248 if !entries.is_empty() {
1249 self.clipboard = Some(ClipboardEntry::Copied(entries));
1250 cx.notify();
1251 }
1252 }
1253
1254 fn create_paste_path(
1255 &self,
1256 source: &SelectedEntry,
1257 (worktree, target_entry): (Model<Worktree>, &Entry),
1258 cx: &AppContext,
1259 ) -> Option<PathBuf> {
1260 let mut new_path = target_entry.path.to_path_buf();
1261 // If we're pasting into a file, or a directory into itself, go up one level.
1262 if target_entry.is_file() || (target_entry.is_dir() && target_entry.id == source.entry_id) {
1263 new_path.pop();
1264 }
1265 let clipboard_entry_file_name = self
1266 .project
1267 .read(cx)
1268 .path_for_entry(source.entry_id, cx)?
1269 .path
1270 .file_name()?
1271 .to_os_string();
1272 new_path.push(&clipboard_entry_file_name);
1273 let extension = new_path.extension().map(|e| e.to_os_string());
1274 let file_name_without_extension = Path::new(&clipboard_entry_file_name).file_stem()?;
1275 let mut ix = 0;
1276 {
1277 let worktree = worktree.read(cx);
1278 while worktree.entry_for_path(&new_path).is_some() {
1279 new_path.pop();
1280
1281 let mut new_file_name = file_name_without_extension.to_os_string();
1282 new_file_name.push(" copy");
1283 if ix > 0 {
1284 new_file_name.push(format!(" {}", ix));
1285 }
1286 if let Some(extension) = extension.as_ref() {
1287 new_file_name.push(".");
1288 new_file_name.push(extension);
1289 }
1290
1291 new_path.push(new_file_name);
1292 ix += 1;
1293 }
1294 }
1295 Some(new_path)
1296 }
1297
1298 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
1299 maybe!({
1300 let (worktree, entry) = self.selected_entry_handle(cx)?;
1301 let entry = entry.clone();
1302 let worktree_id = worktree.read(cx).id();
1303 let clipboard_entries = self
1304 .clipboard
1305 .as_ref()
1306 .filter(|clipboard| !clipboard.items().is_empty())?;
1307
1308 for clipboard_entry in clipboard_entries.items() {
1309 if clipboard_entry.worktree_id != worktree_id {
1310 return None;
1311 }
1312 let new_path =
1313 self.create_paste_path(clipboard_entry, self.selected_entry_handle(cx)?, cx)?;
1314 if clipboard_entries.is_cut() {
1315 self.project
1316 .update(cx, |project, cx| {
1317 project.rename_entry(clipboard_entry.entry_id, new_path, cx)
1318 })
1319 .detach_and_log_err(cx)
1320 } else {
1321 self.project
1322 .update(cx, |project, cx| {
1323 project.copy_entry(clipboard_entry.entry_id, new_path, cx)
1324 })
1325 .detach_and_log_err(cx)
1326 }
1327 }
1328 self.expand_entry(worktree_id, entry.id, cx);
1329 Some(())
1330 });
1331 }
1332
1333 fn duplicate(&mut self, _: &Duplicate, cx: &mut ViewContext<Self>) {
1334 self.copy(&Copy {}, cx);
1335 self.paste(&Paste {}, cx);
1336 }
1337
1338 fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext<Self>) {
1339 if let Some((worktree, entry)) = self.selected_entry(cx) {
1340 cx.write_to_clipboard(ClipboardItem::new(
1341 worktree
1342 .abs_path()
1343 .join(&entry.path)
1344 .to_string_lossy()
1345 .to_string(),
1346 ));
1347 }
1348 }
1349
1350 fn copy_relative_path(&mut self, _: &CopyRelativePath, cx: &mut ViewContext<Self>) {
1351 if let Some((_, entry)) = self.selected_entry(cx) {
1352 cx.write_to_clipboard(ClipboardItem::new(entry.path.to_string_lossy().to_string()));
1353 }
1354 }
1355
1356 fn reveal_in_finder(&mut self, _: &RevealInFinder, cx: &mut ViewContext<Self>) {
1357 if let Some((worktree, entry)) = self.selected_entry(cx) {
1358 cx.reveal_path(&worktree.abs_path().join(&entry.path));
1359 }
1360 }
1361
1362 fn open_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext<Self>) {
1363 if let Some((worktree, entry)) = self.selected_entry(cx) {
1364 let abs_path = worktree.abs_path().join(&entry.path);
1365 let working_directory = if entry.is_dir() {
1366 Some(abs_path)
1367 } else {
1368 if entry.is_symlink {
1369 abs_path.canonicalize().ok()
1370 } else {
1371 Some(abs_path)
1372 }
1373 .and_then(|path| Some(path.parent()?.to_path_buf()))
1374 };
1375 if let Some(working_directory) = working_directory {
1376 cx.dispatch_action(workspace::OpenTerminal { working_directory }.boxed_clone())
1377 }
1378 }
1379 }
1380
1381 pub fn new_search_in_directory(
1382 &mut self,
1383 _: &NewSearchInDirectory,
1384 cx: &mut ViewContext<Self>,
1385 ) {
1386 if let Some((worktree, entry)) = self.selected_entry(cx) {
1387 if entry.is_dir() {
1388 let include_root = self.project.read(cx).visible_worktrees(cx).count() > 1;
1389 let dir_path = if include_root {
1390 let mut full_path = PathBuf::from(worktree.root_name());
1391 full_path.push(&entry.path);
1392 Arc::from(full_path)
1393 } else {
1394 entry.path.clone()
1395 };
1396
1397 self.workspace
1398 .update(cx, |workspace, cx| {
1399 search::ProjectSearchView::new_search_in_directory(
1400 workspace, &dir_path, cx,
1401 );
1402 })
1403 .ok();
1404 }
1405 }
1406 }
1407
1408 fn move_entry(
1409 &mut self,
1410 entry_to_move: ProjectEntryId,
1411 destination: ProjectEntryId,
1412 destination_is_file: bool,
1413 cx: &mut ViewContext<Self>,
1414 ) {
1415 if self
1416 .project
1417 .read(cx)
1418 .entry_is_worktree_root(entry_to_move, cx)
1419 {
1420 self.move_worktree_root(entry_to_move, destination, cx)
1421 } else {
1422 self.move_worktree_entry(entry_to_move, destination, destination_is_file, cx)
1423 }
1424 }
1425
1426 fn move_worktree_root(
1427 &mut self,
1428 entry_to_move: ProjectEntryId,
1429 destination: ProjectEntryId,
1430 cx: &mut ViewContext<Self>,
1431 ) {
1432 self.project.update(cx, |project, cx| {
1433 let Some(worktree_to_move) = project.worktree_for_entry(entry_to_move, cx) else {
1434 return;
1435 };
1436 let Some(destination_worktree) = project.worktree_for_entry(destination, cx) else {
1437 return;
1438 };
1439
1440 let worktree_id = worktree_to_move.read(cx).id();
1441 let destination_id = destination_worktree.read(cx).id();
1442
1443 project
1444 .move_worktree(worktree_id, destination_id, cx)
1445 .log_err();
1446 });
1447 return;
1448 }
1449
1450 fn move_worktree_entry(
1451 &mut self,
1452 entry_to_move: ProjectEntryId,
1453 destination: ProjectEntryId,
1454 destination_is_file: bool,
1455 cx: &mut ViewContext<Self>,
1456 ) {
1457 let destination_worktree = self.project.update(cx, |project, cx| {
1458 let entry_path = project.path_for_entry(entry_to_move, cx)?;
1459 let destination_entry_path = project.path_for_entry(destination, cx)?.path.clone();
1460
1461 let mut destination_path = destination_entry_path.as_ref();
1462 if destination_is_file {
1463 destination_path = destination_path.parent()?;
1464 }
1465
1466 let mut new_path = destination_path.to_path_buf();
1467 new_path.push(entry_path.path.file_name()?);
1468 if new_path != entry_path.path.as_ref() {
1469 let task = project.rename_entry(entry_to_move, new_path, cx);
1470 cx.foreground_executor().spawn(task).detach_and_log_err(cx);
1471 }
1472
1473 project.worktree_id_for_entry(destination, cx)
1474 });
1475
1476 if let Some(destination_worktree) = destination_worktree {
1477 self.expand_entry(destination_worktree, destination, cx);
1478 }
1479 }
1480
1481 fn index_for_selection(&self, selection: SelectedEntry) -> Option<(usize, usize, usize)> {
1482 let mut entry_index = 0;
1483 let mut visible_entries_index = 0;
1484 for (worktree_index, (worktree_id, worktree_entries, _)) in
1485 self.visible_entries.iter().enumerate()
1486 {
1487 if *worktree_id == selection.worktree_id {
1488 for entry in worktree_entries {
1489 if entry.id == selection.entry_id {
1490 return Some((worktree_index, entry_index, visible_entries_index));
1491 } else {
1492 visible_entries_index += 1;
1493 entry_index += 1;
1494 }
1495 }
1496 break;
1497 } else {
1498 visible_entries_index += worktree_entries.len();
1499 }
1500 }
1501 None
1502 }
1503
1504 // Returns list of entries that should be affected by an operation.
1505 // When currently selected entry is not marked, it's treated as the only marked entry.
1506 fn marked_entries(&self) -> BTreeSet<SelectedEntry> {
1507 let Some(selection) = self.selection else {
1508 return Default::default();
1509 };
1510 if self.marked_entries.contains(&selection) {
1511 self.marked_entries.clone()
1512 } else {
1513 BTreeSet::from_iter([selection])
1514 }
1515 }
1516 pub fn selected_entry<'a>(
1517 &self,
1518 cx: &'a AppContext,
1519 ) -> Option<(&'a Worktree, &'a project::Entry)> {
1520 let (worktree, entry) = self.selected_entry_handle(cx)?;
1521 Some((worktree.read(cx), entry))
1522 }
1523
1524 fn selected_entry_handle<'a>(
1525 &self,
1526 cx: &'a AppContext,
1527 ) -> Option<(Model<Worktree>, &'a project::Entry)> {
1528 let selection = self.selection?;
1529 let project = self.project.read(cx);
1530 let worktree = project.worktree_for_id(selection.worktree_id, cx)?;
1531 let entry = worktree.read(cx).entry_for_id(selection.entry_id)?;
1532 Some((worktree, entry))
1533 }
1534
1535 fn expand_to_selection(&mut self, cx: &mut ViewContext<Self>) -> Option<()> {
1536 let (worktree, entry) = self.selected_entry(cx)?;
1537 let expanded_dir_ids = self.expanded_dir_ids.entry(worktree.id()).or_default();
1538
1539 for path in entry.path.ancestors() {
1540 let Some(entry) = worktree.entry_for_path(path) else {
1541 continue;
1542 };
1543 if entry.is_dir() {
1544 if let Err(idx) = expanded_dir_ids.binary_search(&entry.id) {
1545 expanded_dir_ids.insert(idx, entry.id);
1546 }
1547 }
1548 }
1549
1550 Some(())
1551 }
1552
1553 fn update_visible_entries(
1554 &mut self,
1555 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
1556 cx: &mut ViewContext<Self>,
1557 ) {
1558 let auto_collapse_dirs = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
1559 let project = self.project.read(cx);
1560 self.last_worktree_root_id = project
1561 .visible_worktrees(cx)
1562 .rev()
1563 .next()
1564 .and_then(|worktree| worktree.read(cx).root_entry())
1565 .map(|entry| entry.id);
1566
1567 self.visible_entries.clear();
1568 for worktree in project.visible_worktrees(cx) {
1569 let snapshot = worktree.read(cx).snapshot();
1570 let worktree_id = snapshot.id();
1571
1572 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
1573 hash_map::Entry::Occupied(e) => e.into_mut(),
1574 hash_map::Entry::Vacant(e) => {
1575 // The first time a worktree's root entry becomes available,
1576 // mark that root entry as expanded.
1577 if let Some(entry) = snapshot.root_entry() {
1578 e.insert(vec![entry.id]).as_slice()
1579 } else {
1580 &[]
1581 }
1582 }
1583 };
1584
1585 let mut new_entry_parent_id = None;
1586 let mut new_entry_kind = EntryKind::Dir;
1587 if let Some(edit_state) = &self.edit_state {
1588 if edit_state.worktree_id == worktree_id && edit_state.is_new_entry {
1589 new_entry_parent_id = Some(edit_state.entry_id);
1590 new_entry_kind = if edit_state.is_dir {
1591 EntryKind::Dir
1592 } else {
1593 EntryKind::File(Default::default())
1594 };
1595 }
1596 }
1597
1598 let mut visible_worktree_entries = Vec::new();
1599 let mut entry_iter = snapshot.entries(true, 0);
1600 while let Some(entry) = entry_iter.entry() {
1601 if auto_collapse_dirs
1602 && entry.kind.is_dir()
1603 && !self.unfolded_dir_ids.contains(&entry.id)
1604 {
1605 if let Some(root_path) = snapshot.root_entry() {
1606 let mut child_entries = snapshot.child_entries(&entry.path);
1607 if let Some(child) = child_entries.next() {
1608 if entry.path != root_path.path
1609 && child_entries.next().is_none()
1610 && child.kind.is_dir()
1611 {
1612 entry_iter.advance();
1613 continue;
1614 }
1615 }
1616 }
1617 }
1618
1619 visible_worktree_entries.push(entry.clone());
1620 if Some(entry.id) == new_entry_parent_id {
1621 visible_worktree_entries.push(Entry {
1622 id: NEW_ENTRY_ID,
1623 kind: new_entry_kind,
1624 path: entry.path.join("\0").into(),
1625 inode: 0,
1626 mtime: entry.mtime,
1627 is_ignored: entry.is_ignored,
1628 is_external: false,
1629 is_private: false,
1630 git_status: entry.git_status,
1631 canonical_path: entry.canonical_path.clone(),
1632 is_symlink: entry.is_symlink,
1633 });
1634 }
1635 if expanded_dir_ids.binary_search(&entry.id).is_err()
1636 && entry_iter.advance_to_sibling()
1637 {
1638 continue;
1639 }
1640 entry_iter.advance();
1641 }
1642
1643 snapshot.propagate_git_statuses(&mut visible_worktree_entries);
1644 project::sort_worktree_entries(&mut visible_worktree_entries);
1645 self.visible_entries
1646 .push((worktree_id, visible_worktree_entries, OnceCell::new()));
1647 }
1648
1649 if let Some((worktree_id, entry_id)) = new_selected_entry {
1650 self.selection = Some(SelectedEntry {
1651 worktree_id,
1652 entry_id,
1653 });
1654 if cx.modifiers().shift {
1655 self.marked_entries.insert(SelectedEntry {
1656 worktree_id,
1657 entry_id,
1658 });
1659 }
1660 }
1661 }
1662
1663 fn expand_entry(
1664 &mut self,
1665 worktree_id: WorktreeId,
1666 entry_id: ProjectEntryId,
1667 cx: &mut ViewContext<Self>,
1668 ) {
1669 self.project.update(cx, |project, cx| {
1670 if let Some((worktree, expanded_dir_ids)) = project
1671 .worktree_for_id(worktree_id, cx)
1672 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
1673 {
1674 project.expand_entry(worktree_id, entry_id, cx);
1675 let worktree = worktree.read(cx);
1676
1677 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
1678 loop {
1679 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
1680 expanded_dir_ids.insert(ix, entry.id);
1681 }
1682
1683 if let Some(parent_entry) =
1684 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
1685 {
1686 entry = parent_entry;
1687 } else {
1688 break;
1689 }
1690 }
1691 }
1692 }
1693 });
1694 }
1695
1696 fn drop_external_files(
1697 &mut self,
1698 paths: &[PathBuf],
1699 entry_id: ProjectEntryId,
1700 cx: &mut ViewContext<Self>,
1701 ) {
1702 let mut paths: Vec<Arc<Path>> = paths
1703 .into_iter()
1704 .map(|path| Arc::from(path.clone()))
1705 .collect();
1706
1707 let open_file_after_drop = paths.len() == 1 && paths[0].is_file();
1708
1709 let Some((target_directory, worktree)) = maybe!({
1710 let worktree = self.project.read(cx).worktree_for_entry(entry_id, cx)?;
1711 let entry = worktree.read(cx).entry_for_id(entry_id)?;
1712 let path = worktree.read(cx).absolutize(&entry.path).ok()?;
1713 let target_directory = if path.is_dir() {
1714 path
1715 } else {
1716 path.parent()?.to_path_buf()
1717 };
1718 Some((target_directory, worktree))
1719 }) else {
1720 return;
1721 };
1722
1723 let mut paths_to_replace = Vec::new();
1724 for path in &paths {
1725 if let Some(name) = path.file_name() {
1726 let mut target_path = target_directory.clone();
1727 target_path.push(name);
1728 if target_path.exists() {
1729 paths_to_replace.push((name.to_string_lossy().to_string(), path.clone()));
1730 }
1731 }
1732 }
1733
1734 cx.spawn(|this, mut cx| {
1735 async move {
1736 for (filename, original_path) in &paths_to_replace {
1737 let answer = cx
1738 .prompt(
1739 PromptLevel::Info,
1740 format!("A file or folder with name {filename} already exists in the destination folder. Do you want to replace it?").as_str(),
1741 None,
1742 &["Replace", "Cancel"],
1743 )
1744 .await?;
1745 if answer == 1 {
1746 if let Some(item_idx) = paths.iter().position(|p| p == original_path) {
1747 paths.remove(item_idx);
1748 }
1749 }
1750 }
1751
1752 if paths.is_empty() {
1753 return Ok(());
1754 }
1755
1756 let task = worktree.update(&mut cx, |worktree, cx| {
1757 worktree.copy_external_entries(target_directory, paths, true, cx)
1758 })?;
1759
1760 let opened_entries = task.await?;
1761 this.update(&mut cx, |this, cx| {
1762 if open_file_after_drop && !opened_entries.is_empty() {
1763 this.open_entry(opened_entries[0], true, true, false, cx);
1764 }
1765 })
1766 }
1767 .log_err()
1768 })
1769 .detach();
1770 }
1771
1772 fn drag_onto(
1773 &mut self,
1774 selections: &DraggedSelection,
1775 target_entry_id: ProjectEntryId,
1776 is_file: bool,
1777 cx: &mut ViewContext<Self>,
1778 ) {
1779 let should_copy = cx.modifiers().alt;
1780 if should_copy {
1781 let _ = maybe!({
1782 let project = self.project.read(cx);
1783 let target_worktree = project.worktree_for_entry(target_entry_id, cx)?;
1784 let target_entry = target_worktree
1785 .read(cx)
1786 .entry_for_id(target_entry_id)?
1787 .clone();
1788 for selection in selections.items() {
1789 let new_path = self.create_paste_path(
1790 &selection,
1791 (target_worktree.clone(), &target_entry),
1792 cx,
1793 )?;
1794 self.project
1795 .update(cx, |project, cx| {
1796 project.copy_entry(selection.entry_id, new_path, cx)
1797 })
1798 .detach_and_log_err(cx)
1799 }
1800
1801 Some(())
1802 });
1803 } else {
1804 for selection in selections.items() {
1805 self.move_entry(selection.entry_id, target_entry_id, is_file, cx);
1806 }
1807 }
1808 }
1809
1810 fn for_each_visible_entry(
1811 &self,
1812 range: Range<usize>,
1813 cx: &mut ViewContext<ProjectPanel>,
1814 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
1815 ) {
1816 let mut ix = 0;
1817 for (worktree_id, visible_worktree_entries, entries_paths) in &self.visible_entries {
1818 if ix >= range.end {
1819 return;
1820 }
1821
1822 if ix + visible_worktree_entries.len() <= range.start {
1823 ix += visible_worktree_entries.len();
1824 continue;
1825 }
1826
1827 let end_ix = range.end.min(ix + visible_worktree_entries.len());
1828 let (git_status_setting, show_file_icons, show_folder_icons) = {
1829 let settings = ProjectPanelSettings::get_global(cx);
1830 (
1831 settings.git_status,
1832 settings.file_icons,
1833 settings.folder_icons,
1834 )
1835 };
1836 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
1837 let snapshot = worktree.read(cx).snapshot();
1838 let root_name = OsStr::new(snapshot.root_name());
1839 let expanded_entry_ids = self
1840 .expanded_dir_ids
1841 .get(&snapshot.id())
1842 .map(Vec::as_slice)
1843 .unwrap_or(&[]);
1844
1845 let entry_range = range.start.saturating_sub(ix)..end_ix - ix;
1846 let entries = entries_paths.get_or_init(|| {
1847 visible_worktree_entries
1848 .iter()
1849 .map(|e| (e.path.clone()))
1850 .collect()
1851 });
1852 for entry in visible_worktree_entries[entry_range].iter() {
1853 let status = git_status_setting.then(|| entry.git_status).flatten();
1854 let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok();
1855 let icon = match entry.kind {
1856 EntryKind::File(_) => {
1857 if show_file_icons {
1858 FileIcons::get_icon(&entry.path, cx)
1859 } else {
1860 None
1861 }
1862 }
1863 _ => {
1864 if show_folder_icons {
1865 FileIcons::get_folder_icon(is_expanded, cx)
1866 } else {
1867 FileIcons::get_chevron_icon(is_expanded, cx)
1868 }
1869 }
1870 };
1871
1872 let (depth, difference) =
1873 ProjectPanel::calculate_depth_and_difference(entry, &entries);
1874
1875 let filename = match difference {
1876 diff if diff > 1 => entry
1877 .path
1878 .iter()
1879 .skip(entry.path.components().count() - diff)
1880 .collect::<PathBuf>()
1881 .to_str()
1882 .unwrap_or_default()
1883 .to_string(),
1884 _ => entry
1885 .path
1886 .file_name()
1887 .map(|name| name.to_string_lossy().into_owned())
1888 .unwrap_or_else(|| root_name.to_string_lossy().to_string()),
1889 };
1890 let selection = SelectedEntry {
1891 worktree_id: snapshot.id(),
1892 entry_id: entry.id,
1893 };
1894 let mut details = EntryDetails {
1895 filename,
1896 icon,
1897 path: entry.path.clone(),
1898 depth,
1899 kind: entry.kind,
1900 is_ignored: entry.is_ignored,
1901 is_expanded,
1902 is_selected: self.selection == Some(selection),
1903 is_marked: self.marked_entries.contains(&selection),
1904 is_editing: false,
1905 is_processing: false,
1906 is_cut: self
1907 .clipboard
1908 .as_ref()
1909 .map_or(false, |e| e.is_cut() && e.items().contains(&selection)),
1910 git_status: status,
1911 is_private: entry.is_private,
1912 worktree_id: *worktree_id,
1913 canonical_path: entry.canonical_path.clone(),
1914 };
1915
1916 if let Some(edit_state) = &self.edit_state {
1917 let is_edited_entry = if edit_state.is_new_entry {
1918 entry.id == NEW_ENTRY_ID
1919 } else {
1920 entry.id == edit_state.entry_id
1921 };
1922
1923 if is_edited_entry {
1924 if let Some(processing_filename) = &edit_state.processing_filename {
1925 details.is_processing = true;
1926 details.filename.clear();
1927 details.filename.push_str(processing_filename);
1928 } else {
1929 if edit_state.is_new_entry {
1930 details.filename.clear();
1931 }
1932 details.is_editing = true;
1933 }
1934 }
1935 }
1936
1937 callback(entry.id, details, cx);
1938 }
1939 }
1940 ix = end_ix;
1941 }
1942 }
1943
1944 fn calculate_depth_and_difference(
1945 entry: &Entry,
1946 visible_worktree_entries: &HashSet<Arc<Path>>,
1947 ) -> (usize, usize) {
1948 let (depth, difference) = entry
1949 .path
1950 .ancestors()
1951 .skip(1) // Skip the entry itself
1952 .find_map(|ancestor| {
1953 if let Some(parent_entry) = visible_worktree_entries.get(ancestor) {
1954 let entry_path_components_count = entry.path.components().count();
1955 let parent_path_components_count = parent_entry.components().count();
1956 let difference = entry_path_components_count - parent_path_components_count;
1957 let depth = parent_entry
1958 .ancestors()
1959 .skip(1)
1960 .filter(|ancestor| visible_worktree_entries.contains(*ancestor))
1961 .count();
1962 Some((depth + 1, difference))
1963 } else {
1964 None
1965 }
1966 })
1967 .unwrap_or((0, 0));
1968
1969 (depth, difference)
1970 }
1971
1972 fn render_entry(
1973 &self,
1974 entry_id: ProjectEntryId,
1975 details: EntryDetails,
1976 cx: &mut ViewContext<Self>,
1977 ) -> Stateful<Div> {
1978 let kind = details.kind;
1979 let settings = ProjectPanelSettings::get_global(cx);
1980 let show_editor = details.is_editing && !details.is_processing;
1981 let selection = SelectedEntry {
1982 worktree_id: details.worktree_id,
1983 entry_id,
1984 };
1985 let is_marked = self.marked_entries.contains(&selection);
1986 let is_active = self
1987 .selection
1988 .map_or(false, |selection| selection.entry_id == entry_id);
1989 let width = self.size(cx);
1990 let filename_text_color =
1991 entry_git_aware_label_color(details.git_status, details.is_ignored, is_marked);
1992 let file_name = details.filename.clone();
1993 let mut icon = details.icon.clone();
1994 if settings.file_icons && show_editor && details.kind.is_file() {
1995 let filename = self.filename_editor.read(cx).text(cx);
1996 if filename.len() > 2 {
1997 icon = FileIcons::get_icon(Path::new(&filename), cx);
1998 }
1999 }
2000
2001 let canonical_path = details
2002 .canonical_path
2003 .as_ref()
2004 .map(|f| f.to_string_lossy().to_string());
2005 let path = details.path.clone();
2006
2007 let depth = details.depth;
2008 let worktree_id = details.worktree_id;
2009 let selections = Arc::new(self.marked_entries.clone());
2010
2011 let dragged_selection = DraggedSelection {
2012 active_selection: selection,
2013 marked_selections: selections,
2014 };
2015 div()
2016 .id(entry_id.to_proto() as usize)
2017 .on_drag_move::<ExternalPaths>(cx.listener(
2018 move |this, event: &DragMoveEvent<ExternalPaths>, cx| {
2019 if event.bounds.contains(&event.event.position) {
2020 if this.last_external_paths_drag_over_entry == Some(entry_id) {
2021 return;
2022 }
2023 this.last_external_paths_drag_over_entry = Some(entry_id);
2024 this.marked_entries.clear();
2025
2026 let Some((worktree, path, entry)) = maybe!({
2027 let worktree = this
2028 .project
2029 .read(cx)
2030 .worktree_for_id(selection.worktree_id, cx)?;
2031 let worktree = worktree.read(cx);
2032 let abs_path = worktree.absolutize(&path).log_err()?;
2033 let path = if abs_path.is_dir() {
2034 path.as_ref()
2035 } else {
2036 path.parent()?
2037 };
2038 let entry = worktree.entry_for_path(path)?;
2039 Some((worktree, path, entry))
2040 }) else {
2041 return;
2042 };
2043
2044 this.marked_entries.insert(SelectedEntry {
2045 entry_id: entry.id,
2046 worktree_id: worktree.id(),
2047 });
2048
2049 for entry in worktree.child_entries(path) {
2050 this.marked_entries.insert(SelectedEntry {
2051 entry_id: entry.id,
2052 worktree_id: worktree.id(),
2053 });
2054 }
2055
2056 cx.notify();
2057 }
2058 },
2059 ))
2060 .on_drop(
2061 cx.listener(move |this, external_paths: &ExternalPaths, cx| {
2062 this.last_external_paths_drag_over_entry = None;
2063 this.marked_entries.clear();
2064 this.drop_external_files(external_paths.paths(), entry_id, cx);
2065 cx.stop_propagation();
2066 }),
2067 )
2068 .on_drag(dragged_selection, move |selection, cx| {
2069 cx.new_view(|_| DraggedProjectEntryView {
2070 details: details.clone(),
2071 width,
2072 selection: selection.active_selection,
2073 selections: selection.marked_selections.clone(),
2074 })
2075 })
2076 .drag_over::<DraggedSelection>(|style, _, cx| {
2077 style.bg(cx.theme().colors().drop_target_background)
2078 })
2079 .on_drop(cx.listener(move |this, selections: &DraggedSelection, cx| {
2080 this.drag_onto(selections, entry_id, kind.is_file(), cx);
2081 }))
2082 .child(
2083 ListItem::new(entry_id.to_proto() as usize)
2084 .indent_level(depth)
2085 .indent_step_size(px(settings.indent_size))
2086 .selected(is_marked || is_active)
2087 .when_some(canonical_path, |this, path| {
2088 this.end_slot::<AnyElement>(
2089 div()
2090 .id("symlink_icon")
2091 .tooltip(move |cx| {
2092 Tooltip::text(format!("{path} • Symbolic Link"), cx)
2093 })
2094 .child(
2095 Icon::new(IconName::ArrowUpRight)
2096 .size(IconSize::Indicator)
2097 .color(filename_text_color),
2098 )
2099 .into_any_element(),
2100 )
2101 })
2102 .child(if let Some(icon) = &icon {
2103 h_flex().child(Icon::from_path(icon.to_string()).color(filename_text_color))
2104 } else {
2105 h_flex()
2106 .size(IconSize::default().rems())
2107 .invisible()
2108 .flex_none()
2109 })
2110 .child(
2111 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
2112 h_flex().h_6().w_full().child(editor.clone())
2113 } else {
2114 h_flex().h_6().child(
2115 Label::new(file_name)
2116 .single_line()
2117 .color(filename_text_color),
2118 )
2119 }
2120 .ml_1(),
2121 )
2122 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
2123 if event.down.button == MouseButton::Right || event.down.first_mouse {
2124 return;
2125 }
2126 if !show_editor {
2127 if let Some(selection) =
2128 this.selection.filter(|_| event.down.modifiers.shift)
2129 {
2130 let current_selection = this.index_for_selection(selection);
2131 let target_selection = this.index_for_selection(SelectedEntry {
2132 entry_id,
2133 worktree_id,
2134 });
2135 if let Some(((_, _, source_index), (_, _, target_index))) =
2136 current_selection.zip(target_selection)
2137 {
2138 let range_start = source_index.min(target_index);
2139 let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
2140 let mut new_selections = BTreeSet::new();
2141 this.for_each_visible_entry(
2142 range_start..range_end,
2143 cx,
2144 |entry_id, details, _| {
2145 new_selections.insert(SelectedEntry {
2146 entry_id,
2147 worktree_id: details.worktree_id,
2148 });
2149 },
2150 );
2151
2152 this.marked_entries = this
2153 .marked_entries
2154 .union(&new_selections)
2155 .cloned()
2156 .collect();
2157
2158 this.selection = Some(SelectedEntry {
2159 entry_id,
2160 worktree_id,
2161 });
2162 // Ensure that the current entry is selected.
2163 this.marked_entries.insert(SelectedEntry {
2164 entry_id,
2165 worktree_id,
2166 });
2167 }
2168 } else if event.down.modifiers.secondary() {
2169 if !this.marked_entries.insert(selection) {
2170 this.marked_entries.remove(&selection);
2171 }
2172 } else if kind.is_dir() {
2173 this.toggle_expanded(entry_id, cx);
2174 } else {
2175 let click_count = event.up.click_count;
2176 if click_count > 1 && event.down.modifiers.secondary() {
2177 this.split_entry(entry_id, cx);
2178 } else {
2179 this.open_entry(
2180 entry_id,
2181 cx.modifiers().secondary(),
2182 click_count > 1,
2183 click_count == 1,
2184 cx,
2185 );
2186 }
2187 }
2188 }
2189 }))
2190 .on_secondary_mouse_down(cx.listener(
2191 move |this, event: &MouseDownEvent, cx| {
2192 // Stop propagation to prevent the catch-all context menu for the project
2193 // panel from being deployed.
2194 cx.stop_propagation();
2195 this.deploy_context_menu(event.position, entry_id, cx);
2196 },
2197 )),
2198 )
2199 .border_1()
2200 .border_r_2()
2201 .rounded_none()
2202 .hover(|style| {
2203 if is_active {
2204 style
2205 } else {
2206 let hover_color = cx.theme().colors().ghost_element_hover;
2207 style.bg(hover_color).border_color(hover_color)
2208 }
2209 })
2210 .when(is_marked || is_active, |this| {
2211 let colors = cx.theme().colors();
2212 this.when(is_marked, |this| this.bg(colors.ghost_element_selected))
2213 .border_color(colors.ghost_element_selected)
2214 })
2215 .when(
2216 is_active && self.focus_handle.contains_focused(cx),
2217 |this| this.border_color(Color::Selected.color(cx)),
2218 )
2219 }
2220
2221 fn render_scrollbar(
2222 &self,
2223 items_count: usize,
2224 cx: &mut ViewContext<Self>,
2225 ) -> Option<Stateful<Div>> {
2226 let settings = ProjectPanelSettings::get_global(cx);
2227 if settings.scrollbar.show == ShowScrollbar::Never {
2228 return None;
2229 }
2230 let scroll_handle = self.scroll_handle.0.borrow();
2231
2232 let height = scroll_handle
2233 .last_item_height
2234 .filter(|_| self.show_scrollbar || self.scrollbar_drag_thumb_offset.get().is_some())?;
2235
2236 let total_list_length = height.0 as f64 * items_count as f64;
2237 let current_offset = scroll_handle.base_handle.offset().y.0.min(0.).abs() as f64;
2238 let mut percentage = current_offset / total_list_length;
2239 let end_offset = (current_offset + scroll_handle.base_handle.bounds().size.height.0 as f64)
2240 / total_list_length;
2241 // Uniform scroll handle might briefly report an offset greater than the length of a list;
2242 // in such case we'll adjust the starting offset as well to keep the scrollbar thumb length stable.
2243 let overshoot = (end_offset - 1.).clamp(0., 1.);
2244 if overshoot > 0. {
2245 percentage -= overshoot;
2246 }
2247 const MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT: f64 = 0.005;
2248 if percentage + MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT > 1.0 || end_offset > total_list_length
2249 {
2250 return None;
2251 }
2252 if total_list_length < scroll_handle.base_handle.bounds().size.height.0 as f64 {
2253 return None;
2254 }
2255 let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_HEIGHT, 1.);
2256 Some(
2257 div()
2258 .occlude()
2259 .id("project-panel-scroll")
2260 .on_mouse_move(cx.listener(|_, _, cx| {
2261 cx.notify();
2262 cx.stop_propagation()
2263 }))
2264 .on_hover(|_, cx| {
2265 cx.stop_propagation();
2266 })
2267 .on_any_mouse_down(|_, cx| {
2268 cx.stop_propagation();
2269 })
2270 .on_mouse_up(
2271 MouseButton::Left,
2272 cx.listener(|this, _, cx| {
2273 if this.scrollbar_drag_thumb_offset.get().is_none()
2274 && !this.focus_handle.contains_focused(cx)
2275 {
2276 this.hide_scrollbar(cx);
2277 cx.notify();
2278 }
2279
2280 cx.stop_propagation();
2281 }),
2282 )
2283 .on_scroll_wheel(cx.listener(|_, _, cx| {
2284 cx.notify();
2285 }))
2286 .h_full()
2287 .absolute()
2288 .right_0()
2289 .top_0()
2290 .bottom_0()
2291 .w_3()
2292 .cursor_default()
2293 .child(ProjectPanelScrollbar::new(
2294 percentage as f32..end_offset as f32,
2295 self.scroll_handle.clone(),
2296 self.scrollbar_drag_thumb_offset.clone(),
2297 cx.view().clone().into(),
2298 items_count,
2299 )),
2300 )
2301 }
2302
2303 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
2304 let mut dispatch_context = KeyContext::new_with_defaults();
2305 dispatch_context.add("ProjectPanel");
2306 dispatch_context.add("menu");
2307
2308 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
2309 "editing"
2310 } else {
2311 "not_editing"
2312 };
2313
2314 dispatch_context.add(identifier);
2315 dispatch_context
2316 }
2317
2318 fn should_autohide_scrollbar(cx: &AppContext) -> bool {
2319 cx.try_global::<ScrollbarAutoHide>()
2320 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0)
2321 }
2322
2323 fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
2324 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
2325 if !Self::should_autohide_scrollbar(cx) {
2326 return;
2327 }
2328 self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
2329 cx.background_executor()
2330 .timer(SCROLLBAR_SHOW_INTERVAL)
2331 .await;
2332 panel
2333 .update(&mut cx, |panel, cx| {
2334 panel.show_scrollbar = false;
2335 cx.notify();
2336 })
2337 .log_err();
2338 }))
2339 }
2340
2341 fn reveal_entry(
2342 &mut self,
2343 project: Model<Project>,
2344 entry_id: ProjectEntryId,
2345 skip_ignored: bool,
2346 cx: &mut ViewContext<'_, ProjectPanel>,
2347 ) {
2348 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
2349 let worktree = worktree.read(cx);
2350 if skip_ignored
2351 && worktree
2352 .entry_for_id(entry_id)
2353 .map_or(true, |entry| entry.is_ignored)
2354 {
2355 return;
2356 }
2357
2358 let worktree_id = worktree.id();
2359 self.marked_entries.clear();
2360 self.expand_entry(worktree_id, entry_id, cx);
2361 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
2362 self.autoscroll(cx);
2363 cx.notify();
2364 }
2365 }
2366}
2367
2368impl Render for ProjectPanel {
2369 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
2370 let has_worktree = self.visible_entries.len() != 0;
2371 let project = self.project.read(cx);
2372
2373 if has_worktree {
2374 let items_count = self
2375 .visible_entries
2376 .iter()
2377 .map(|(_, worktree_entries, _)| worktree_entries.len())
2378 .sum();
2379
2380 h_flex()
2381 .id("project-panel")
2382 .group("project-panel")
2383 .size_full()
2384 .relative()
2385 .on_hover(cx.listener(|this, hovered, cx| {
2386 if *hovered {
2387 this.show_scrollbar = true;
2388 this.hide_scrollbar_task.take();
2389 cx.notify();
2390 } else if !this.focus_handle.contains_focused(cx) {
2391 this.hide_scrollbar(cx);
2392 }
2393 }))
2394 .key_context(self.dispatch_context(cx))
2395 .on_action(cx.listener(Self::select_next))
2396 .on_action(cx.listener(Self::select_prev))
2397 .on_action(cx.listener(Self::select_first))
2398 .on_action(cx.listener(Self::select_last))
2399 .on_action(cx.listener(Self::select_parent))
2400 .on_action(cx.listener(Self::expand_selected_entry))
2401 .on_action(cx.listener(Self::collapse_selected_entry))
2402 .on_action(cx.listener(Self::collapse_all_entries))
2403 .on_action(cx.listener(Self::open))
2404 .on_action(cx.listener(Self::open_permanent))
2405 .on_action(cx.listener(Self::confirm))
2406 .on_action(cx.listener(Self::cancel))
2407 .on_action(cx.listener(Self::copy_path))
2408 .on_action(cx.listener(Self::copy_relative_path))
2409 .on_action(cx.listener(Self::new_search_in_directory))
2410 .on_action(cx.listener(Self::unfold_directory))
2411 .on_action(cx.listener(Self::fold_directory))
2412 .when(!project.is_read_only(), |el| {
2413 el.on_action(cx.listener(Self::new_file))
2414 .on_action(cx.listener(Self::new_directory))
2415 .on_action(cx.listener(Self::rename))
2416 .on_action(cx.listener(Self::delete))
2417 .on_action(cx.listener(Self::trash))
2418 .on_action(cx.listener(Self::cut))
2419 .on_action(cx.listener(Self::copy))
2420 .on_action(cx.listener(Self::paste))
2421 .on_action(cx.listener(Self::duplicate))
2422 })
2423 .when(project.is_local(), |el| {
2424 el.on_action(cx.listener(Self::reveal_in_finder))
2425 .on_action(cx.listener(Self::open_in_terminal))
2426 })
2427 .on_mouse_down(
2428 MouseButton::Right,
2429 cx.listener(move |this, event: &MouseDownEvent, cx| {
2430 // When deploying the context menu anywhere below the last project entry,
2431 // act as if the user clicked the root of the last worktree.
2432 if let Some(entry_id) = this.last_worktree_root_id {
2433 this.deploy_context_menu(event.position, entry_id, cx);
2434 }
2435 }),
2436 )
2437 .track_focus(&self.focus_handle)
2438 .child(
2439 uniform_list(cx.view().clone(), "entries", items_count, {
2440 |this, range, cx| {
2441 let mut items = Vec::new();
2442 this.for_each_visible_entry(range, cx, |id, details, cx| {
2443 items.push(this.render_entry(id, details, cx));
2444 });
2445 items
2446 }
2447 })
2448 .size_full()
2449 .with_sizing_behavior(ListSizingBehavior::Infer)
2450 .track_scroll(self.scroll_handle.clone()),
2451 )
2452 .children(self.render_scrollbar(items_count, cx))
2453 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2454 deferred(
2455 anchored()
2456 .position(*position)
2457 .anchor(gpui::AnchorCorner::TopLeft)
2458 .child(menu.clone()),
2459 )
2460 .with_priority(1)
2461 }))
2462 } else {
2463 v_flex()
2464 .id("empty-project_panel")
2465 .size_full()
2466 .p_4()
2467 .track_focus(&self.focus_handle)
2468 .child(
2469 Button::new("open_project", "Open a project")
2470 .style(ButtonStyle::Filled)
2471 .full_width()
2472 .key_binding(KeyBinding::for_action(&workspace::Open, cx))
2473 .on_click(cx.listener(|this, _, cx| {
2474 this.workspace
2475 .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
2476 .log_err();
2477 })),
2478 )
2479 .drag_over::<ExternalPaths>(|style, _, cx| {
2480 style.bg(cx.theme().colors().drop_target_background)
2481 })
2482 .on_drop(
2483 cx.listener(move |this, external_paths: &ExternalPaths, cx| {
2484 this.last_external_paths_drag_over_entry = None;
2485 this.marked_entries.clear();
2486 if let Some(task) = this
2487 .workspace
2488 .update(cx, |workspace, cx| {
2489 workspace.open_workspace_for_paths(
2490 true,
2491 external_paths.paths().to_owned(),
2492 cx,
2493 )
2494 })
2495 .log_err()
2496 {
2497 task.detach_and_log_err(cx);
2498 }
2499 cx.stop_propagation();
2500 }),
2501 )
2502 }
2503 }
2504}
2505
2506impl Render for DraggedProjectEntryView {
2507 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2508 let settings = ProjectPanelSettings::get_global(cx);
2509 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
2510 h_flex().font(ui_font).map(|this| {
2511 if self.selections.contains(&self.selection) {
2512 this.flex_shrink()
2513 .p_1()
2514 .items_end()
2515 .rounded_md()
2516 .child(self.selections.len().to_string())
2517 } else {
2518 this.bg(cx.theme().colors().background).w(self.width).child(
2519 ListItem::new(self.selection.entry_id.to_proto() as usize)
2520 .indent_level(self.details.depth)
2521 .indent_step_size(px(settings.indent_size))
2522 .child(if let Some(icon) = &self.details.icon {
2523 div().child(Icon::from_path(icon.to_string()))
2524 } else {
2525 div()
2526 })
2527 .child(Label::new(self.details.filename.clone())),
2528 )
2529 }
2530 })
2531 }
2532}
2533
2534impl EventEmitter<Event> for ProjectPanel {}
2535
2536impl EventEmitter<PanelEvent> for ProjectPanel {}
2537
2538impl Panel for ProjectPanel {
2539 fn position(&self, cx: &WindowContext) -> DockPosition {
2540 match ProjectPanelSettings::get_global(cx).dock {
2541 ProjectPanelDockPosition::Left => DockPosition::Left,
2542 ProjectPanelDockPosition::Right => DockPosition::Right,
2543 }
2544 }
2545
2546 fn position_is_valid(&self, position: DockPosition) -> bool {
2547 matches!(position, DockPosition::Left | DockPosition::Right)
2548 }
2549
2550 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
2551 settings::update_settings_file::<ProjectPanelSettings>(
2552 self.fs.clone(),
2553 cx,
2554 move |settings| {
2555 let dock = match position {
2556 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
2557 DockPosition::Right => ProjectPanelDockPosition::Right,
2558 };
2559 settings.dock = Some(dock);
2560 },
2561 );
2562 }
2563
2564 fn size(&self, cx: &WindowContext) -> Pixels {
2565 self.width
2566 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
2567 }
2568
2569 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
2570 self.width = size;
2571 self.serialize(cx);
2572 cx.notify();
2573 }
2574
2575 fn icon(&self, cx: &WindowContext) -> Option<IconName> {
2576 ProjectPanelSettings::get_global(cx)
2577 .button
2578 .then(|| IconName::FileTree)
2579 }
2580
2581 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
2582 Some("Project Panel")
2583 }
2584
2585 fn toggle_action(&self) -> Box<dyn Action> {
2586 Box::new(ToggleFocus)
2587 }
2588
2589 fn persistent_name() -> &'static str {
2590 "Project Panel"
2591 }
2592
2593 fn starts_open(&self, cx: &WindowContext) -> bool {
2594 let project = &self.project.read(cx);
2595 project.dev_server_project_id().is_some()
2596 || project.visible_worktrees(cx).any(|tree| {
2597 tree.read(cx)
2598 .root_entry()
2599 .map_or(false, |entry| entry.is_dir())
2600 })
2601 }
2602}
2603
2604impl FocusableView for ProjectPanel {
2605 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
2606 self.focus_handle.clone()
2607 }
2608}
2609
2610impl ClipboardEntry {
2611 fn is_cut(&self) -> bool {
2612 matches!(self, Self::Cut { .. })
2613 }
2614
2615 fn items(&self) -> &BTreeSet<SelectedEntry> {
2616 match self {
2617 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
2618 }
2619 }
2620}
2621
2622#[cfg(test)]
2623mod tests {
2624 use super::*;
2625 use collections::HashSet;
2626 use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
2627 use pretty_assertions::assert_eq;
2628 use project::{FakeFs, WorktreeSettings};
2629 use serde_json::json;
2630 use settings::SettingsStore;
2631 use std::path::{Path, PathBuf};
2632 use workspace::{
2633 item::{Item, ProjectItem},
2634 register_project_item, AppState,
2635 };
2636
2637 #[gpui::test]
2638 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
2639 init_test(cx);
2640
2641 let fs = FakeFs::new(cx.executor().clone());
2642 fs.insert_tree(
2643 "/root1",
2644 json!({
2645 ".dockerignore": "",
2646 ".git": {
2647 "HEAD": "",
2648 },
2649 "a": {
2650 "0": { "q": "", "r": "", "s": "" },
2651 "1": { "t": "", "u": "" },
2652 "2": { "v": "", "w": "", "x": "", "y": "" },
2653 },
2654 "b": {
2655 "3": { "Q": "" },
2656 "4": { "R": "", "S": "", "T": "", "U": "" },
2657 },
2658 "C": {
2659 "5": {},
2660 "6": { "V": "", "W": "" },
2661 "7": { "X": "" },
2662 "8": { "Y": {}, "Z": "" }
2663 }
2664 }),
2665 )
2666 .await;
2667 fs.insert_tree(
2668 "/root2",
2669 json!({
2670 "d": {
2671 "9": ""
2672 },
2673 "e": {}
2674 }),
2675 )
2676 .await;
2677
2678 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2679 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2680 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2681 let panel = workspace
2682 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2683 .unwrap();
2684 assert_eq!(
2685 visible_entries_as_strings(&panel, 0..50, cx),
2686 &[
2687 "v root1",
2688 " > .git",
2689 " > a",
2690 " > b",
2691 " > C",
2692 " .dockerignore",
2693 "v root2",
2694 " > d",
2695 " > e",
2696 ]
2697 );
2698
2699 toggle_expand_dir(&panel, "root1/b", cx);
2700 assert_eq!(
2701 visible_entries_as_strings(&panel, 0..50, cx),
2702 &[
2703 "v root1",
2704 " > .git",
2705 " > a",
2706 " v b <== selected",
2707 " > 3",
2708 " > 4",
2709 " > C",
2710 " .dockerignore",
2711 "v root2",
2712 " > d",
2713 " > e",
2714 ]
2715 );
2716
2717 assert_eq!(
2718 visible_entries_as_strings(&panel, 6..9, cx),
2719 &[
2720 //
2721 " > C",
2722 " .dockerignore",
2723 "v root2",
2724 ]
2725 );
2726 }
2727
2728 #[gpui::test]
2729 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
2730 init_test(cx);
2731 cx.update(|cx| {
2732 cx.update_global::<SettingsStore, _>(|store, cx| {
2733 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
2734 worktree_settings.file_scan_exclusions =
2735 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
2736 });
2737 });
2738 });
2739
2740 let fs = FakeFs::new(cx.background_executor.clone());
2741 fs.insert_tree(
2742 "/root1",
2743 json!({
2744 ".dockerignore": "",
2745 ".git": {
2746 "HEAD": "",
2747 },
2748 "a": {
2749 "0": { "q": "", "r": "", "s": "" },
2750 "1": { "t": "", "u": "" },
2751 "2": { "v": "", "w": "", "x": "", "y": "" },
2752 },
2753 "b": {
2754 "3": { "Q": "" },
2755 "4": { "R": "", "S": "", "T": "", "U": "" },
2756 },
2757 "C": {
2758 "5": {},
2759 "6": { "V": "", "W": "" },
2760 "7": { "X": "" },
2761 "8": { "Y": {}, "Z": "" }
2762 }
2763 }),
2764 )
2765 .await;
2766 fs.insert_tree(
2767 "/root2",
2768 json!({
2769 "d": {
2770 "4": ""
2771 },
2772 "e": {}
2773 }),
2774 )
2775 .await;
2776
2777 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2778 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2779 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2780 let panel = workspace
2781 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2782 .unwrap();
2783 assert_eq!(
2784 visible_entries_as_strings(&panel, 0..50, cx),
2785 &[
2786 "v root1",
2787 " > a",
2788 " > b",
2789 " > C",
2790 " .dockerignore",
2791 "v root2",
2792 " > d",
2793 " > e",
2794 ]
2795 );
2796
2797 toggle_expand_dir(&panel, "root1/b", cx);
2798 assert_eq!(
2799 visible_entries_as_strings(&panel, 0..50, cx),
2800 &[
2801 "v root1",
2802 " > a",
2803 " v b <== selected",
2804 " > 3",
2805 " > C",
2806 " .dockerignore",
2807 "v root2",
2808 " > d",
2809 " > e",
2810 ]
2811 );
2812
2813 toggle_expand_dir(&panel, "root2/d", cx);
2814 assert_eq!(
2815 visible_entries_as_strings(&panel, 0..50, cx),
2816 &[
2817 "v root1",
2818 " > a",
2819 " v b",
2820 " > 3",
2821 " > C",
2822 " .dockerignore",
2823 "v root2",
2824 " v d <== selected",
2825 " > e",
2826 ]
2827 );
2828
2829 toggle_expand_dir(&panel, "root2/e", cx);
2830 assert_eq!(
2831 visible_entries_as_strings(&panel, 0..50, cx),
2832 &[
2833 "v root1",
2834 " > a",
2835 " v b",
2836 " > 3",
2837 " > C",
2838 " .dockerignore",
2839 "v root2",
2840 " v d",
2841 " v e <== selected",
2842 ]
2843 );
2844 }
2845
2846 #[gpui::test]
2847 async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
2848 init_test(cx);
2849
2850 let fs = FakeFs::new(cx.executor().clone());
2851 fs.insert_tree(
2852 "/root1",
2853 json!({
2854 "dir_1": {
2855 "nested_dir_1": {
2856 "nested_dir_2": {
2857 "nested_dir_3": {
2858 "file_a.java": "// File contents",
2859 "file_b.java": "// File contents",
2860 "file_c.java": "// File contents",
2861 "nested_dir_4": {
2862 "nested_dir_5": {
2863 "file_d.java": "// File contents",
2864 }
2865 }
2866 }
2867 }
2868 }
2869 }
2870 }),
2871 )
2872 .await;
2873 fs.insert_tree(
2874 "/root2",
2875 json!({
2876 "dir_2": {
2877 "file_1.java": "// File contents",
2878 }
2879 }),
2880 )
2881 .await;
2882
2883 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2884 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2885 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2886 cx.update(|cx| {
2887 let settings = *ProjectPanelSettings::get_global(cx);
2888 ProjectPanelSettings::override_global(
2889 ProjectPanelSettings {
2890 auto_fold_dirs: true,
2891 ..settings
2892 },
2893 cx,
2894 );
2895 });
2896 let panel = workspace
2897 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2898 .unwrap();
2899 assert_eq!(
2900 visible_entries_as_strings(&panel, 0..10, cx),
2901 &[
2902 "v root1",
2903 " > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2904 "v root2",
2905 " > dir_2",
2906 ]
2907 );
2908
2909 toggle_expand_dir(
2910 &panel,
2911 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2912 cx,
2913 );
2914 assert_eq!(
2915 visible_entries_as_strings(&panel, 0..10, cx),
2916 &[
2917 "v root1",
2918 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
2919 " > nested_dir_4/nested_dir_5",
2920 " file_a.java",
2921 " file_b.java",
2922 " file_c.java",
2923 "v root2",
2924 " > dir_2",
2925 ]
2926 );
2927
2928 toggle_expand_dir(
2929 &panel,
2930 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
2931 cx,
2932 );
2933 assert_eq!(
2934 visible_entries_as_strings(&panel, 0..10, cx),
2935 &[
2936 "v root1",
2937 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2938 " v nested_dir_4/nested_dir_5 <== selected",
2939 " file_d.java",
2940 " file_a.java",
2941 " file_b.java",
2942 " file_c.java",
2943 "v root2",
2944 " > dir_2",
2945 ]
2946 );
2947 toggle_expand_dir(&panel, "root2/dir_2", cx);
2948 assert_eq!(
2949 visible_entries_as_strings(&panel, 0..10, cx),
2950 &[
2951 "v root1",
2952 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2953 " v nested_dir_4/nested_dir_5",
2954 " file_d.java",
2955 " file_a.java",
2956 " file_b.java",
2957 " file_c.java",
2958 "v root2",
2959 " v dir_2 <== selected",
2960 " file_1.java",
2961 ]
2962 );
2963 }
2964
2965 #[gpui::test(iterations = 30)]
2966 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
2967 init_test(cx);
2968
2969 let fs = FakeFs::new(cx.executor().clone());
2970 fs.insert_tree(
2971 "/root1",
2972 json!({
2973 ".dockerignore": "",
2974 ".git": {
2975 "HEAD": "",
2976 },
2977 "a": {
2978 "0": { "q": "", "r": "", "s": "" },
2979 "1": { "t": "", "u": "" },
2980 "2": { "v": "", "w": "", "x": "", "y": "" },
2981 },
2982 "b": {
2983 "3": { "Q": "" },
2984 "4": { "R": "", "S": "", "T": "", "U": "" },
2985 },
2986 "C": {
2987 "5": {},
2988 "6": { "V": "", "W": "" },
2989 "7": { "X": "" },
2990 "8": { "Y": {}, "Z": "" }
2991 }
2992 }),
2993 )
2994 .await;
2995 fs.insert_tree(
2996 "/root2",
2997 json!({
2998 "d": {
2999 "9": ""
3000 },
3001 "e": {}
3002 }),
3003 )
3004 .await;
3005
3006 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3007 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3008 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3009 let panel = workspace
3010 .update(cx, |workspace, cx| {
3011 let panel = ProjectPanel::new(workspace, cx);
3012 workspace.add_panel(panel.clone(), cx);
3013 panel
3014 })
3015 .unwrap();
3016
3017 select_path(&panel, "root1", cx);
3018 assert_eq!(
3019 visible_entries_as_strings(&panel, 0..10, cx),
3020 &[
3021 "v root1 <== selected",
3022 " > .git",
3023 " > a",
3024 " > b",
3025 " > C",
3026 " .dockerignore",
3027 "v root2",
3028 " > d",
3029 " > e",
3030 ]
3031 );
3032
3033 // Add a file with the root folder selected. The filename editor is placed
3034 // before the first file in the root folder.
3035 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3036 panel.update(cx, |panel, cx| {
3037 assert!(panel.filename_editor.read(cx).is_focused(cx));
3038 });
3039 assert_eq!(
3040 visible_entries_as_strings(&panel, 0..10, cx),
3041 &[
3042 "v root1",
3043 " > .git",
3044 " > a",
3045 " > b",
3046 " > C",
3047 " [EDITOR: ''] <== selected",
3048 " .dockerignore",
3049 "v root2",
3050 " > d",
3051 " > e",
3052 ]
3053 );
3054
3055 let confirm = panel.update(cx, |panel, cx| {
3056 panel
3057 .filename_editor
3058 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
3059 panel.confirm_edit(cx).unwrap()
3060 });
3061 assert_eq!(
3062 visible_entries_as_strings(&panel, 0..10, cx),
3063 &[
3064 "v root1",
3065 " > .git",
3066 " > a",
3067 " > b",
3068 " > C",
3069 " [PROCESSING: 'the-new-filename'] <== selected",
3070 " .dockerignore",
3071 "v root2",
3072 " > d",
3073 " > e",
3074 ]
3075 );
3076
3077 confirm.await.unwrap();
3078 assert_eq!(
3079 visible_entries_as_strings(&panel, 0..10, cx),
3080 &[
3081 "v root1",
3082 " > .git",
3083 " > a",
3084 " > b",
3085 " > C",
3086 " .dockerignore",
3087 " the-new-filename <== selected <== marked",
3088 "v root2",
3089 " > d",
3090 " > e",
3091 ]
3092 );
3093
3094 select_path(&panel, "root1/b", cx);
3095 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3096 assert_eq!(
3097 visible_entries_as_strings(&panel, 0..10, cx),
3098 &[
3099 "v root1",
3100 " > .git",
3101 " > a",
3102 " v b",
3103 " > 3",
3104 " > 4",
3105 " [EDITOR: ''] <== selected",
3106 " > C",
3107 " .dockerignore",
3108 " the-new-filename",
3109 ]
3110 );
3111
3112 panel
3113 .update(cx, |panel, cx| {
3114 panel
3115 .filename_editor
3116 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
3117 panel.confirm_edit(cx).unwrap()
3118 })
3119 .await
3120 .unwrap();
3121 assert_eq!(
3122 visible_entries_as_strings(&panel, 0..10, cx),
3123 &[
3124 "v root1",
3125 " > .git",
3126 " > a",
3127 " v b",
3128 " > 3",
3129 " > 4",
3130 " another-filename.txt <== selected <== marked",
3131 " > C",
3132 " .dockerignore",
3133 " the-new-filename",
3134 ]
3135 );
3136
3137 select_path(&panel, "root1/b/another-filename.txt", cx);
3138 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3139 assert_eq!(
3140 visible_entries_as_strings(&panel, 0..10, cx),
3141 &[
3142 "v root1",
3143 " > .git",
3144 " > a",
3145 " v b",
3146 " > 3",
3147 " > 4",
3148 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
3149 " > C",
3150 " .dockerignore",
3151 " the-new-filename",
3152 ]
3153 );
3154
3155 let confirm = panel.update(cx, |panel, cx| {
3156 panel.filename_editor.update(cx, |editor, cx| {
3157 let file_name_selections = editor.selections.all::<usize>(cx);
3158 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
3159 let file_name_selection = &file_name_selections[0];
3160 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
3161 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
3162
3163 editor.set_text("a-different-filename.tar.gz", cx)
3164 });
3165 panel.confirm_edit(cx).unwrap()
3166 });
3167 assert_eq!(
3168 visible_entries_as_strings(&panel, 0..10, cx),
3169 &[
3170 "v root1",
3171 " > .git",
3172 " > a",
3173 " v b",
3174 " > 3",
3175 " > 4",
3176 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
3177 " > C",
3178 " .dockerignore",
3179 " the-new-filename",
3180 ]
3181 );
3182
3183 confirm.await.unwrap();
3184 assert_eq!(
3185 visible_entries_as_strings(&panel, 0..10, cx),
3186 &[
3187 "v root1",
3188 " > .git",
3189 " > a",
3190 " v b",
3191 " > 3",
3192 " > 4",
3193 " a-different-filename.tar.gz <== selected",
3194 " > C",
3195 " .dockerignore",
3196 " the-new-filename",
3197 ]
3198 );
3199
3200 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3201 assert_eq!(
3202 visible_entries_as_strings(&panel, 0..10, cx),
3203 &[
3204 "v root1",
3205 " > .git",
3206 " > a",
3207 " v b",
3208 " > 3",
3209 " > 4",
3210 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
3211 " > C",
3212 " .dockerignore",
3213 " the-new-filename",
3214 ]
3215 );
3216
3217 panel.update(cx, |panel, cx| {
3218 panel.filename_editor.update(cx, |editor, cx| {
3219 let file_name_selections = editor.selections.all::<usize>(cx);
3220 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
3221 let file_name_selection = &file_name_selections[0];
3222 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
3223 assert_eq!(file_name_selection.end, "a-different-filename.tar".len(), "Should not select file extension, but still may select anything up to the last dot..");
3224
3225 });
3226 panel.cancel(&menu::Cancel, cx)
3227 });
3228
3229 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
3230 assert_eq!(
3231 visible_entries_as_strings(&panel, 0..10, cx),
3232 &[
3233 "v root1",
3234 " > .git",
3235 " > a",
3236 " v b",
3237 " > [EDITOR: ''] <== selected",
3238 " > 3",
3239 " > 4",
3240 " a-different-filename.tar.gz",
3241 " > C",
3242 " .dockerignore",
3243 ]
3244 );
3245
3246 let confirm = panel.update(cx, |panel, cx| {
3247 panel
3248 .filename_editor
3249 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
3250 panel.confirm_edit(cx).unwrap()
3251 });
3252 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
3253 assert_eq!(
3254 visible_entries_as_strings(&panel, 0..10, cx),
3255 &[
3256 "v root1",
3257 " > .git",
3258 " > a",
3259 " v b",
3260 " > [PROCESSING: 'new-dir']",
3261 " > 3 <== selected",
3262 " > 4",
3263 " a-different-filename.tar.gz",
3264 " > C",
3265 " .dockerignore",
3266 ]
3267 );
3268
3269 confirm.await.unwrap();
3270 assert_eq!(
3271 visible_entries_as_strings(&panel, 0..10, cx),
3272 &[
3273 "v root1",
3274 " > .git",
3275 " > a",
3276 " v b",
3277 " > 3 <== selected",
3278 " > 4",
3279 " > new-dir",
3280 " a-different-filename.tar.gz",
3281 " > C",
3282 " .dockerignore",
3283 ]
3284 );
3285
3286 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
3287 assert_eq!(
3288 visible_entries_as_strings(&panel, 0..10, cx),
3289 &[
3290 "v root1",
3291 " > .git",
3292 " > a",
3293 " v b",
3294 " > [EDITOR: '3'] <== selected",
3295 " > 4",
3296 " > new-dir",
3297 " a-different-filename.tar.gz",
3298 " > C",
3299 " .dockerignore",
3300 ]
3301 );
3302
3303 // Dismiss the rename editor when it loses focus.
3304 workspace.update(cx, |_, cx| cx.blur()).unwrap();
3305 assert_eq!(
3306 visible_entries_as_strings(&panel, 0..10, cx),
3307 &[
3308 "v root1",
3309 " > .git",
3310 " > a",
3311 " v b",
3312 " > 3 <== selected",
3313 " > 4",
3314 " > new-dir",
3315 " a-different-filename.tar.gz",
3316 " > C",
3317 " .dockerignore",
3318 ]
3319 );
3320 }
3321
3322 #[gpui::test(iterations = 10)]
3323 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
3324 init_test(cx);
3325
3326 let fs = FakeFs::new(cx.executor().clone());
3327 fs.insert_tree(
3328 "/root1",
3329 json!({
3330 ".dockerignore": "",
3331 ".git": {
3332 "HEAD": "",
3333 },
3334 "a": {
3335 "0": { "q": "", "r": "", "s": "" },
3336 "1": { "t": "", "u": "" },
3337 "2": { "v": "", "w": "", "x": "", "y": "" },
3338 },
3339 "b": {
3340 "3": { "Q": "" },
3341 "4": { "R": "", "S": "", "T": "", "U": "" },
3342 },
3343 "C": {
3344 "5": {},
3345 "6": { "V": "", "W": "" },
3346 "7": { "X": "" },
3347 "8": { "Y": {}, "Z": "" }
3348 }
3349 }),
3350 )
3351 .await;
3352 fs.insert_tree(
3353 "/root2",
3354 json!({
3355 "d": {
3356 "9": ""
3357 },
3358 "e": {}
3359 }),
3360 )
3361 .await;
3362
3363 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3364 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3365 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3366 let panel = workspace
3367 .update(cx, |workspace, cx| {
3368 let panel = ProjectPanel::new(workspace, cx);
3369 workspace.add_panel(panel.clone(), cx);
3370 panel
3371 })
3372 .unwrap();
3373
3374 select_path(&panel, "root1", cx);
3375 assert_eq!(
3376 visible_entries_as_strings(&panel, 0..10, cx),
3377 &[
3378 "v root1 <== selected",
3379 " > .git",
3380 " > a",
3381 " > b",
3382 " > C",
3383 " .dockerignore",
3384 "v root2",
3385 " > d",
3386 " > e",
3387 ]
3388 );
3389
3390 // Add a file with the root folder selected. The filename editor is placed
3391 // before the first file in the root folder.
3392 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3393 panel.update(cx, |panel, cx| {
3394 assert!(panel.filename_editor.read(cx).is_focused(cx));
3395 });
3396 assert_eq!(
3397 visible_entries_as_strings(&panel, 0..10, cx),
3398 &[
3399 "v root1",
3400 " > .git",
3401 " > a",
3402 " > b",
3403 " > C",
3404 " [EDITOR: ''] <== selected",
3405 " .dockerignore",
3406 "v root2",
3407 " > d",
3408 " > e",
3409 ]
3410 );
3411
3412 let confirm = panel.update(cx, |panel, cx| {
3413 panel.filename_editor.update(cx, |editor, cx| {
3414 editor.set_text("/bdir1/dir2/the-new-filename", cx)
3415 });
3416 panel.confirm_edit(cx).unwrap()
3417 });
3418
3419 assert_eq!(
3420 visible_entries_as_strings(&panel, 0..10, cx),
3421 &[
3422 "v root1",
3423 " > .git",
3424 " > a",
3425 " > b",
3426 " > C",
3427 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
3428 " .dockerignore",
3429 "v root2",
3430 " > d",
3431 " > e",
3432 ]
3433 );
3434
3435 confirm.await.unwrap();
3436 assert_eq!(
3437 visible_entries_as_strings(&panel, 0..13, cx),
3438 &[
3439 "v root1",
3440 " > .git",
3441 " > a",
3442 " > b",
3443 " v bdir1",
3444 " v dir2",
3445 " the-new-filename <== selected <== marked",
3446 " > C",
3447 " .dockerignore",
3448 "v root2",
3449 " > d",
3450 " > e",
3451 ]
3452 );
3453 }
3454
3455 #[gpui::test]
3456 async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
3457 init_test(cx);
3458
3459 let fs = FakeFs::new(cx.executor().clone());
3460 fs.insert_tree(
3461 "/root1",
3462 json!({
3463 ".dockerignore": "",
3464 ".git": {
3465 "HEAD": "",
3466 },
3467 }),
3468 )
3469 .await;
3470
3471 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3472 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3473 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3474 let panel = workspace
3475 .update(cx, |workspace, cx| {
3476 let panel = ProjectPanel::new(workspace, cx);
3477 workspace.add_panel(panel.clone(), cx);
3478 panel
3479 })
3480 .unwrap();
3481
3482 select_path(&panel, "root1", cx);
3483 assert_eq!(
3484 visible_entries_as_strings(&panel, 0..10, cx),
3485 &["v root1 <== selected", " > .git", " .dockerignore",]
3486 );
3487
3488 // Add a file with the root folder selected. The filename editor is placed
3489 // before the first file in the root folder.
3490 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3491 panel.update(cx, |panel, cx| {
3492 assert!(panel.filename_editor.read(cx).is_focused(cx));
3493 });
3494 assert_eq!(
3495 visible_entries_as_strings(&panel, 0..10, cx),
3496 &[
3497 "v root1",
3498 " > .git",
3499 " [EDITOR: ''] <== selected",
3500 " .dockerignore",
3501 ]
3502 );
3503
3504 let confirm = panel.update(cx, |panel, cx| {
3505 panel
3506 .filename_editor
3507 .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
3508 panel.confirm_edit(cx).unwrap()
3509 });
3510
3511 assert_eq!(
3512 visible_entries_as_strings(&panel, 0..10, cx),
3513 &[
3514 "v root1",
3515 " > .git",
3516 " [PROCESSING: '/new_dir/'] <== selected",
3517 " .dockerignore",
3518 ]
3519 );
3520
3521 confirm.await.unwrap();
3522 assert_eq!(
3523 visible_entries_as_strings(&panel, 0..13, cx),
3524 &[
3525 "v root1",
3526 " > .git",
3527 " v new_dir <== selected",
3528 " .dockerignore",
3529 ]
3530 );
3531 }
3532
3533 #[gpui::test]
3534 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
3535 init_test(cx);
3536
3537 let fs = FakeFs::new(cx.executor().clone());
3538 fs.insert_tree(
3539 "/root1",
3540 json!({
3541 "one.two.txt": "",
3542 "one.txt": ""
3543 }),
3544 )
3545 .await;
3546
3547 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3548 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3549 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3550 let panel = workspace
3551 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3552 .unwrap();
3553
3554 panel.update(cx, |panel, cx| {
3555 panel.select_next(&Default::default(), cx);
3556 panel.select_next(&Default::default(), cx);
3557 });
3558
3559 assert_eq!(
3560 visible_entries_as_strings(&panel, 0..50, cx),
3561 &[
3562 //
3563 "v root1",
3564 " one.two.txt <== selected",
3565 " one.txt",
3566 ]
3567 );
3568
3569 // Regression test - file name is created correctly when
3570 // the copied file's name contains multiple dots.
3571 panel.update(cx, |panel, cx| {
3572 panel.copy(&Default::default(), cx);
3573 panel.paste(&Default::default(), cx);
3574 });
3575 cx.executor().run_until_parked();
3576
3577 assert_eq!(
3578 visible_entries_as_strings(&panel, 0..50, cx),
3579 &[
3580 //
3581 "v root1",
3582 " one.two copy.txt",
3583 " one.two.txt <== selected",
3584 " one.txt",
3585 ]
3586 );
3587
3588 panel.update(cx, |panel, cx| {
3589 panel.paste(&Default::default(), cx);
3590 });
3591 cx.executor().run_until_parked();
3592
3593 assert_eq!(
3594 visible_entries_as_strings(&panel, 0..50, cx),
3595 &[
3596 //
3597 "v root1",
3598 " one.two copy 1.txt",
3599 " one.two copy.txt",
3600 " one.two.txt <== selected",
3601 " one.txt",
3602 ]
3603 );
3604 }
3605
3606 #[gpui::test]
3607 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
3608 init_test(cx);
3609
3610 let fs = FakeFs::new(cx.executor().clone());
3611 fs.insert_tree(
3612 "/root",
3613 json!({
3614 "a": {
3615 "one.txt": "",
3616 "two.txt": "",
3617 "inner_dir": {
3618 "three.txt": "",
3619 "four.txt": "",
3620 }
3621 },
3622 "b": {}
3623 }),
3624 )
3625 .await;
3626
3627 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
3628 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3629 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3630 let panel = workspace
3631 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3632 .unwrap();
3633
3634 select_path(&panel, "root/a", cx);
3635 panel.update(cx, |panel, cx| {
3636 panel.copy(&Default::default(), cx);
3637 panel.select_next(&Default::default(), cx);
3638 panel.paste(&Default::default(), cx);
3639 });
3640 cx.executor().run_until_parked();
3641
3642 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
3643 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
3644
3645 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
3646 assert_ne!(
3647 pasted_dir_file, None,
3648 "Pasted directory file should have an entry"
3649 );
3650
3651 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
3652 assert_ne!(
3653 pasted_dir_inner_dir, None,
3654 "Directories inside pasted directory should have an entry"
3655 );
3656
3657 toggle_expand_dir(&panel, "root/b/a", cx);
3658 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
3659
3660 assert_eq!(
3661 visible_entries_as_strings(&panel, 0..50, cx),
3662 &[
3663 //
3664 "v root",
3665 " > a",
3666 " v b",
3667 " v a",
3668 " v inner_dir <== selected",
3669 " four.txt",
3670 " three.txt",
3671 " one.txt",
3672 " two.txt",
3673 ]
3674 );
3675
3676 select_path(&panel, "root", cx);
3677 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
3678 cx.executor().run_until_parked();
3679 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
3680 cx.executor().run_until_parked();
3681 assert_eq!(
3682 visible_entries_as_strings(&panel, 0..50, cx),
3683 &[
3684 //
3685 "v root <== selected",
3686 " > a",
3687 " > a copy",
3688 " > a copy 1",
3689 " v b",
3690 " v a",
3691 " v inner_dir",
3692 " four.txt",
3693 " three.txt",
3694 " one.txt",
3695 " two.txt"
3696 ]
3697 );
3698 }
3699
3700 #[gpui::test]
3701 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
3702 init_test_with_editor(cx);
3703
3704 let fs = FakeFs::new(cx.executor().clone());
3705 fs.insert_tree(
3706 "/src",
3707 json!({
3708 "test": {
3709 "first.rs": "// First Rust file",
3710 "second.rs": "// Second Rust file",
3711 "third.rs": "// Third Rust file",
3712 }
3713 }),
3714 )
3715 .await;
3716
3717 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3718 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3719 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3720 let panel = workspace
3721 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3722 .unwrap();
3723
3724 toggle_expand_dir(&panel, "src/test", cx);
3725 select_path(&panel, "src/test/first.rs", cx);
3726 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3727 cx.executor().run_until_parked();
3728 assert_eq!(
3729 visible_entries_as_strings(&panel, 0..10, cx),
3730 &[
3731 "v src",
3732 " v test",
3733 " first.rs <== selected",
3734 " second.rs",
3735 " third.rs"
3736 ]
3737 );
3738 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
3739
3740 submit_deletion(&panel, cx);
3741 assert_eq!(
3742 visible_entries_as_strings(&panel, 0..10, cx),
3743 &[
3744 "v src",
3745 " v test",
3746 " second.rs",
3747 " third.rs"
3748 ],
3749 "Project panel should have no deleted file, no other file is selected in it"
3750 );
3751 ensure_no_open_items_and_panes(&workspace, cx);
3752
3753 select_path(&panel, "src/test/second.rs", cx);
3754 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3755 cx.executor().run_until_parked();
3756 assert_eq!(
3757 visible_entries_as_strings(&panel, 0..10, cx),
3758 &[
3759 "v src",
3760 " v test",
3761 " second.rs <== selected",
3762 " third.rs"
3763 ]
3764 );
3765 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
3766
3767 workspace
3768 .update(cx, |workspace, cx| {
3769 let active_items = workspace
3770 .panes()
3771 .iter()
3772 .filter_map(|pane| pane.read(cx).active_item())
3773 .collect::<Vec<_>>();
3774 assert_eq!(active_items.len(), 1);
3775 let open_editor = active_items
3776 .into_iter()
3777 .next()
3778 .unwrap()
3779 .downcast::<Editor>()
3780 .expect("Open item should be an editor");
3781 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
3782 })
3783 .unwrap();
3784 submit_deletion_skipping_prompt(&panel, cx);
3785 assert_eq!(
3786 visible_entries_as_strings(&panel, 0..10, cx),
3787 &["v src", " v test", " third.rs"],
3788 "Project panel should have no deleted file, with one last file remaining"
3789 );
3790 ensure_no_open_items_and_panes(&workspace, cx);
3791 }
3792
3793 #[gpui::test]
3794 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
3795 init_test_with_editor(cx);
3796
3797 let fs = FakeFs::new(cx.executor().clone());
3798 fs.insert_tree(
3799 "/src",
3800 json!({
3801 "test": {
3802 "first.rs": "// First Rust file",
3803 "second.rs": "// Second Rust file",
3804 "third.rs": "// Third Rust file",
3805 }
3806 }),
3807 )
3808 .await;
3809
3810 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3811 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3812 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3813 let panel = workspace
3814 .update(cx, |workspace, cx| {
3815 let panel = ProjectPanel::new(workspace, cx);
3816 workspace.add_panel(panel.clone(), cx);
3817 panel
3818 })
3819 .unwrap();
3820
3821 select_path(&panel, "src/", cx);
3822 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3823 cx.executor().run_until_parked();
3824 assert_eq!(
3825 visible_entries_as_strings(&panel, 0..10, cx),
3826 &[
3827 //
3828 "v src <== selected",
3829 " > test"
3830 ]
3831 );
3832 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
3833 panel.update(cx, |panel, cx| {
3834 assert!(panel.filename_editor.read(cx).is_focused(cx));
3835 });
3836 assert_eq!(
3837 visible_entries_as_strings(&panel, 0..10, cx),
3838 &[
3839 //
3840 "v src",
3841 " > [EDITOR: ''] <== selected",
3842 " > test"
3843 ]
3844 );
3845 panel.update(cx, |panel, cx| {
3846 panel
3847 .filename_editor
3848 .update(cx, |editor, cx| editor.set_text("test", cx));
3849 assert!(
3850 panel.confirm_edit(cx).is_none(),
3851 "Should not allow to confirm on conflicting new directory name"
3852 )
3853 });
3854 assert_eq!(
3855 visible_entries_as_strings(&panel, 0..10, cx),
3856 &[
3857 //
3858 "v src",
3859 " > test"
3860 ],
3861 "File list should be unchanged after failed folder create confirmation"
3862 );
3863
3864 select_path(&panel, "src/test/", cx);
3865 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3866 cx.executor().run_until_parked();
3867 assert_eq!(
3868 visible_entries_as_strings(&panel, 0..10, cx),
3869 &[
3870 //
3871 "v src",
3872 " > test <== selected"
3873 ]
3874 );
3875 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3876 panel.update(cx, |panel, cx| {
3877 assert!(panel.filename_editor.read(cx).is_focused(cx));
3878 });
3879 assert_eq!(
3880 visible_entries_as_strings(&panel, 0..10, cx),
3881 &[
3882 "v src",
3883 " v test",
3884 " [EDITOR: ''] <== selected",
3885 " first.rs",
3886 " second.rs",
3887 " third.rs"
3888 ]
3889 );
3890 panel.update(cx, |panel, cx| {
3891 panel
3892 .filename_editor
3893 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
3894 assert!(
3895 panel.confirm_edit(cx).is_none(),
3896 "Should not allow to confirm on conflicting new file name"
3897 )
3898 });
3899 assert_eq!(
3900 visible_entries_as_strings(&panel, 0..10, cx),
3901 &[
3902 "v src",
3903 " v test",
3904 " first.rs",
3905 " second.rs",
3906 " third.rs"
3907 ],
3908 "File list should be unchanged after failed file create confirmation"
3909 );
3910
3911 select_path(&panel, "src/test/first.rs", cx);
3912 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3913 cx.executor().run_until_parked();
3914 assert_eq!(
3915 visible_entries_as_strings(&panel, 0..10, cx),
3916 &[
3917 "v src",
3918 " v test",
3919 " first.rs <== selected",
3920 " second.rs",
3921 " third.rs"
3922 ],
3923 );
3924 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3925 panel.update(cx, |panel, cx| {
3926 assert!(panel.filename_editor.read(cx).is_focused(cx));
3927 });
3928 assert_eq!(
3929 visible_entries_as_strings(&panel, 0..10, cx),
3930 &[
3931 "v src",
3932 " v test",
3933 " [EDITOR: 'first.rs'] <== selected",
3934 " second.rs",
3935 " third.rs"
3936 ]
3937 );
3938 panel.update(cx, |panel, cx| {
3939 panel
3940 .filename_editor
3941 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
3942 assert!(
3943 panel.confirm_edit(cx).is_none(),
3944 "Should not allow to confirm on conflicting file rename"
3945 )
3946 });
3947 assert_eq!(
3948 visible_entries_as_strings(&panel, 0..10, cx),
3949 &[
3950 "v src",
3951 " v test",
3952 " first.rs <== selected",
3953 " second.rs",
3954 " third.rs"
3955 ],
3956 "File list should be unchanged after failed rename confirmation"
3957 );
3958 }
3959
3960 #[gpui::test]
3961 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
3962 init_test_with_editor(cx);
3963
3964 let fs = FakeFs::new(cx.executor().clone());
3965 fs.insert_tree(
3966 "/project_root",
3967 json!({
3968 "dir_1": {
3969 "nested_dir": {
3970 "file_a.py": "# File contents",
3971 }
3972 },
3973 "file_1.py": "# File contents",
3974 }),
3975 )
3976 .await;
3977
3978 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3979 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3980 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3981 let panel = workspace
3982 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3983 .unwrap();
3984
3985 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3986 cx.executor().run_until_parked();
3987 select_path(&panel, "project_root/dir_1", cx);
3988 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3989 select_path(&panel, "project_root/dir_1/nested_dir", cx);
3990 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3991 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3992 cx.executor().run_until_parked();
3993 assert_eq!(
3994 visible_entries_as_strings(&panel, 0..10, cx),
3995 &[
3996 "v project_root",
3997 " v dir_1",
3998 " > nested_dir <== selected",
3999 " file_1.py",
4000 ]
4001 );
4002 }
4003
4004 #[gpui::test]
4005 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
4006 init_test_with_editor(cx);
4007
4008 let fs = FakeFs::new(cx.executor().clone());
4009 fs.insert_tree(
4010 "/project_root",
4011 json!({
4012 "dir_1": {
4013 "nested_dir": {
4014 "file_a.py": "# File contents",
4015 "file_b.py": "# File contents",
4016 "file_c.py": "# File contents",
4017 },
4018 "file_1.py": "# File contents",
4019 "file_2.py": "# File contents",
4020 "file_3.py": "# File contents",
4021 },
4022 "dir_2": {
4023 "file_1.py": "# File contents",
4024 "file_2.py": "# File contents",
4025 "file_3.py": "# File contents",
4026 }
4027 }),
4028 )
4029 .await;
4030
4031 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4032 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4033 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4034 let panel = workspace
4035 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4036 .unwrap();
4037
4038 panel.update(cx, |panel, cx| {
4039 panel.collapse_all_entries(&CollapseAllEntries, cx)
4040 });
4041 cx.executor().run_until_parked();
4042 assert_eq!(
4043 visible_entries_as_strings(&panel, 0..10, cx),
4044 &["v project_root", " > dir_1", " > dir_2",]
4045 );
4046
4047 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
4048 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4049 cx.executor().run_until_parked();
4050 assert_eq!(
4051 visible_entries_as_strings(&panel, 0..10, cx),
4052 &[
4053 "v project_root",
4054 " v dir_1 <== selected",
4055 " > nested_dir",
4056 " file_1.py",
4057 " file_2.py",
4058 " file_3.py",
4059 " > dir_2",
4060 ]
4061 );
4062 }
4063
4064 #[gpui::test]
4065 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
4066 init_test(cx);
4067
4068 let fs = FakeFs::new(cx.executor().clone());
4069 fs.as_fake().insert_tree("/root", json!({})).await;
4070 let project = Project::test(fs, ["/root".as_ref()], cx).await;
4071 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4072 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4073 let panel = workspace
4074 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4075 .unwrap();
4076
4077 // Make a new buffer with no backing file
4078 workspace
4079 .update(cx, |workspace, cx| {
4080 Editor::new_file(workspace, &Default::default(), cx)
4081 })
4082 .unwrap();
4083
4084 cx.executor().run_until_parked();
4085
4086 // "Save as" the buffer, creating a new backing file for it
4087 let save_task = workspace
4088 .update(cx, |workspace, cx| {
4089 workspace.save_active_item(workspace::SaveIntent::Save, cx)
4090 })
4091 .unwrap();
4092
4093 cx.executor().run_until_parked();
4094 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
4095 save_task.await.unwrap();
4096
4097 // Rename the file
4098 select_path(&panel, "root/new", cx);
4099 assert_eq!(
4100 visible_entries_as_strings(&panel, 0..10, cx),
4101 &["v root", " new <== selected"]
4102 );
4103 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4104 panel.update(cx, |panel, cx| {
4105 panel
4106 .filename_editor
4107 .update(cx, |editor, cx| editor.set_text("newer", cx));
4108 });
4109 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
4110
4111 cx.executor().run_until_parked();
4112 assert_eq!(
4113 visible_entries_as_strings(&panel, 0..10, cx),
4114 &["v root", " newer <== selected"]
4115 );
4116
4117 workspace
4118 .update(cx, |workspace, cx| {
4119 workspace.save_active_item(workspace::SaveIntent::Save, cx)
4120 })
4121 .unwrap()
4122 .await
4123 .unwrap();
4124
4125 cx.executor().run_until_parked();
4126 // assert that saving the file doesn't restore "new"
4127 assert_eq!(
4128 visible_entries_as_strings(&panel, 0..10, cx),
4129 &["v root", " newer <== selected"]
4130 );
4131 }
4132
4133 #[gpui::test]
4134 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
4135 init_test_with_editor(cx);
4136 let fs = FakeFs::new(cx.executor().clone());
4137 fs.insert_tree(
4138 "/project_root",
4139 json!({
4140 "dir_1": {
4141 "nested_dir": {
4142 "file_a.py": "# File contents",
4143 }
4144 },
4145 "file_1.py": "# File contents",
4146 }),
4147 )
4148 .await;
4149
4150 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4151 let worktree_id =
4152 cx.update(|cx| project.read(cx).worktrees().next().unwrap().read(cx).id());
4153 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4154 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4155 let panel = workspace
4156 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4157 .unwrap();
4158 cx.update(|cx| {
4159 panel.update(cx, |this, cx| {
4160 this.select_next(&Default::default(), cx);
4161 this.expand_selected_entry(&Default::default(), cx);
4162 this.expand_selected_entry(&Default::default(), cx);
4163 this.select_next(&Default::default(), cx);
4164 this.expand_selected_entry(&Default::default(), cx);
4165 this.select_next(&Default::default(), cx);
4166 })
4167 });
4168 assert_eq!(
4169 visible_entries_as_strings(&panel, 0..10, cx),
4170 &[
4171 "v project_root",
4172 " v dir_1",
4173 " v nested_dir",
4174 " file_a.py <== selected",
4175 " file_1.py",
4176 ]
4177 );
4178 let modifiers_with_shift = gpui::Modifiers {
4179 shift: true,
4180 ..Default::default()
4181 };
4182 cx.simulate_modifiers_change(modifiers_with_shift);
4183 cx.update(|cx| {
4184 panel.update(cx, |this, cx| {
4185 this.select_next(&Default::default(), cx);
4186 })
4187 });
4188 assert_eq!(
4189 visible_entries_as_strings(&panel, 0..10, cx),
4190 &[
4191 "v project_root",
4192 " v dir_1",
4193 " v nested_dir",
4194 " file_a.py",
4195 " file_1.py <== selected <== marked",
4196 ]
4197 );
4198 cx.update(|cx| {
4199 panel.update(cx, |this, cx| {
4200 this.select_prev(&Default::default(), cx);
4201 })
4202 });
4203 assert_eq!(
4204 visible_entries_as_strings(&panel, 0..10, cx),
4205 &[
4206 "v project_root",
4207 " v dir_1",
4208 " v nested_dir",
4209 " file_a.py <== selected <== marked",
4210 " file_1.py <== marked",
4211 ]
4212 );
4213 cx.update(|cx| {
4214 panel.update(cx, |this, cx| {
4215 let drag = DraggedSelection {
4216 active_selection: this.selection.unwrap(),
4217 marked_selections: Arc::new(this.marked_entries.clone()),
4218 };
4219 let target_entry = this
4220 .project
4221 .read(cx)
4222 .entry_for_path(&(worktree_id, "").into(), cx)
4223 .unwrap();
4224 this.drag_onto(&drag, target_entry.id, false, cx);
4225 });
4226 });
4227 cx.run_until_parked();
4228 assert_eq!(
4229 visible_entries_as_strings(&panel, 0..10, cx),
4230 &[
4231 "v project_root",
4232 " v dir_1",
4233 " v nested_dir",
4234 " file_1.py <== marked",
4235 " file_a.py <== selected <== marked",
4236 ]
4237 );
4238 // ESC clears out all marks
4239 cx.update(|cx| {
4240 panel.update(cx, |this, cx| {
4241 this.cancel(&menu::Cancel, cx);
4242 })
4243 });
4244 assert_eq!(
4245 visible_entries_as_strings(&panel, 0..10, cx),
4246 &[
4247 "v project_root",
4248 " v dir_1",
4249 " v nested_dir",
4250 " file_1.py",
4251 " file_a.py <== selected",
4252 ]
4253 );
4254 // ESC clears out all marks
4255 cx.update(|cx| {
4256 panel.update(cx, |this, cx| {
4257 this.select_prev(&SelectPrev, cx);
4258 this.select_next(&SelectNext, cx);
4259 })
4260 });
4261 assert_eq!(
4262 visible_entries_as_strings(&panel, 0..10, cx),
4263 &[
4264 "v project_root",
4265 " v dir_1",
4266 " v nested_dir",
4267 " file_1.py <== marked",
4268 " file_a.py <== selected <== marked",
4269 ]
4270 );
4271 cx.simulate_modifiers_change(Default::default());
4272 cx.update(|cx| {
4273 panel.update(cx, |this, cx| {
4274 this.cut(&Cut, cx);
4275 this.select_prev(&SelectPrev, cx);
4276 this.select_prev(&SelectPrev, cx);
4277
4278 this.paste(&Paste, cx);
4279 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
4280 })
4281 });
4282 cx.run_until_parked();
4283 assert_eq!(
4284 visible_entries_as_strings(&panel, 0..10, cx),
4285 &[
4286 "v project_root",
4287 " v dir_1",
4288 " v nested_dir <== selected",
4289 " file_1.py <== marked",
4290 " file_a.py <== marked",
4291 ]
4292 );
4293 cx.simulate_modifiers_change(modifiers_with_shift);
4294 cx.update(|cx| {
4295 panel.update(cx, |this, cx| {
4296 this.expand_selected_entry(&Default::default(), cx);
4297 this.select_next(&SelectNext, cx);
4298 this.select_next(&SelectNext, cx);
4299 })
4300 });
4301 submit_deletion(&panel, cx);
4302 assert_eq!(
4303 visible_entries_as_strings(&panel, 0..10, cx),
4304 &["v project_root", " v dir_1", " v nested_dir",]
4305 );
4306 }
4307 #[gpui::test]
4308 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
4309 init_test_with_editor(cx);
4310 cx.update(|cx| {
4311 cx.update_global::<SettingsStore, _>(|store, cx| {
4312 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4313 worktree_settings.file_scan_exclusions = Some(Vec::new());
4314 });
4315 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4316 project_panel_settings.auto_reveal_entries = Some(false)
4317 });
4318 })
4319 });
4320
4321 let fs = FakeFs::new(cx.background_executor.clone());
4322 fs.insert_tree(
4323 "/project_root",
4324 json!({
4325 ".git": {},
4326 ".gitignore": "**/gitignored_dir",
4327 "dir_1": {
4328 "file_1.py": "# File 1_1 contents",
4329 "file_2.py": "# File 1_2 contents",
4330 "file_3.py": "# File 1_3 contents",
4331 "gitignored_dir": {
4332 "file_a.py": "# File contents",
4333 "file_b.py": "# File contents",
4334 "file_c.py": "# File contents",
4335 },
4336 },
4337 "dir_2": {
4338 "file_1.py": "# File 2_1 contents",
4339 "file_2.py": "# File 2_2 contents",
4340 "file_3.py": "# File 2_3 contents",
4341 }
4342 }),
4343 )
4344 .await;
4345
4346 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4347 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4348 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4349 let panel = workspace
4350 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4351 .unwrap();
4352
4353 assert_eq!(
4354 visible_entries_as_strings(&panel, 0..20, cx),
4355 &[
4356 "v project_root",
4357 " > .git",
4358 " > dir_1",
4359 " > dir_2",
4360 " .gitignore",
4361 ]
4362 );
4363
4364 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4365 .expect("dir 1 file is not ignored and should have an entry");
4366 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4367 .expect("dir 2 file is not ignored and should have an entry");
4368 let gitignored_dir_file =
4369 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4370 assert_eq!(
4371 gitignored_dir_file, None,
4372 "File in the gitignored dir should not have an entry before its dir is toggled"
4373 );
4374
4375 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4376 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4377 cx.executor().run_until_parked();
4378 assert_eq!(
4379 visible_entries_as_strings(&panel, 0..20, cx),
4380 &[
4381 "v project_root",
4382 " > .git",
4383 " v dir_1",
4384 " v gitignored_dir <== selected",
4385 " file_a.py",
4386 " file_b.py",
4387 " file_c.py",
4388 " file_1.py",
4389 " file_2.py",
4390 " file_3.py",
4391 " > dir_2",
4392 " .gitignore",
4393 ],
4394 "Should show gitignored dir file list in the project panel"
4395 );
4396 let gitignored_dir_file =
4397 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4398 .expect("after gitignored dir got opened, a file entry should be present");
4399
4400 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4401 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4402 assert_eq!(
4403 visible_entries_as_strings(&panel, 0..20, cx),
4404 &[
4405 "v project_root",
4406 " > .git",
4407 " > dir_1 <== selected",
4408 " > dir_2",
4409 " .gitignore",
4410 ],
4411 "Should hide all dir contents again and prepare for the auto reveal test"
4412 );
4413
4414 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4415 panel.update(cx, |panel, cx| {
4416 panel.project.update(cx, |_, cx| {
4417 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4418 })
4419 });
4420 cx.run_until_parked();
4421 assert_eq!(
4422 visible_entries_as_strings(&panel, 0..20, cx),
4423 &[
4424 "v project_root",
4425 " > .git",
4426 " > dir_1 <== selected",
4427 " > dir_2",
4428 " .gitignore",
4429 ],
4430 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4431 );
4432 }
4433
4434 cx.update(|cx| {
4435 cx.update_global::<SettingsStore, _>(|store, cx| {
4436 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4437 project_panel_settings.auto_reveal_entries = Some(true)
4438 });
4439 })
4440 });
4441
4442 panel.update(cx, |panel, cx| {
4443 panel.project.update(cx, |_, cx| {
4444 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
4445 })
4446 });
4447 cx.run_until_parked();
4448 assert_eq!(
4449 visible_entries_as_strings(&panel, 0..20, cx),
4450 &[
4451 "v project_root",
4452 " > .git",
4453 " v dir_1",
4454 " > gitignored_dir",
4455 " file_1.py <== selected",
4456 " file_2.py",
4457 " file_3.py",
4458 " > dir_2",
4459 " .gitignore",
4460 ],
4461 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
4462 );
4463
4464 panel.update(cx, |panel, cx| {
4465 panel.project.update(cx, |_, cx| {
4466 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
4467 })
4468 });
4469 cx.run_until_parked();
4470 assert_eq!(
4471 visible_entries_as_strings(&panel, 0..20, cx),
4472 &[
4473 "v project_root",
4474 " > .git",
4475 " v dir_1",
4476 " > gitignored_dir",
4477 " file_1.py",
4478 " file_2.py",
4479 " file_3.py",
4480 " v dir_2",
4481 " file_1.py <== selected",
4482 " file_2.py",
4483 " file_3.py",
4484 " .gitignore",
4485 ],
4486 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
4487 );
4488
4489 panel.update(cx, |panel, cx| {
4490 panel.project.update(cx, |_, cx| {
4491 cx.emit(project::Event::ActiveEntryChanged(Some(
4492 gitignored_dir_file,
4493 )))
4494 })
4495 });
4496 cx.run_until_parked();
4497 assert_eq!(
4498 visible_entries_as_strings(&panel, 0..20, cx),
4499 &[
4500 "v project_root",
4501 " > .git",
4502 " v dir_1",
4503 " > gitignored_dir",
4504 " file_1.py",
4505 " file_2.py",
4506 " file_3.py",
4507 " v dir_2",
4508 " file_1.py <== selected",
4509 " file_2.py",
4510 " file_3.py",
4511 " .gitignore",
4512 ],
4513 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
4514 );
4515
4516 panel.update(cx, |panel, cx| {
4517 panel.project.update(cx, |_, cx| {
4518 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4519 })
4520 });
4521 cx.run_until_parked();
4522 assert_eq!(
4523 visible_entries_as_strings(&panel, 0..20, cx),
4524 &[
4525 "v project_root",
4526 " > .git",
4527 " v dir_1",
4528 " v gitignored_dir",
4529 " file_a.py <== selected",
4530 " file_b.py",
4531 " file_c.py",
4532 " file_1.py",
4533 " file_2.py",
4534 " file_3.py",
4535 " v dir_2",
4536 " file_1.py",
4537 " file_2.py",
4538 " file_3.py",
4539 " .gitignore",
4540 ],
4541 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
4542 );
4543 }
4544
4545 #[gpui::test]
4546 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
4547 init_test_with_editor(cx);
4548 cx.update(|cx| {
4549 cx.update_global::<SettingsStore, _>(|store, cx| {
4550 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4551 worktree_settings.file_scan_exclusions = Some(Vec::new());
4552 });
4553 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4554 project_panel_settings.auto_reveal_entries = Some(false)
4555 });
4556 })
4557 });
4558
4559 let fs = FakeFs::new(cx.background_executor.clone());
4560 fs.insert_tree(
4561 "/project_root",
4562 json!({
4563 ".git": {},
4564 ".gitignore": "**/gitignored_dir",
4565 "dir_1": {
4566 "file_1.py": "# File 1_1 contents",
4567 "file_2.py": "# File 1_2 contents",
4568 "file_3.py": "# File 1_3 contents",
4569 "gitignored_dir": {
4570 "file_a.py": "# File contents",
4571 "file_b.py": "# File contents",
4572 "file_c.py": "# File contents",
4573 },
4574 },
4575 "dir_2": {
4576 "file_1.py": "# File 2_1 contents",
4577 "file_2.py": "# File 2_2 contents",
4578 "file_3.py": "# File 2_3 contents",
4579 }
4580 }),
4581 )
4582 .await;
4583
4584 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4585 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4586 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4587 let panel = workspace
4588 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
4589 .unwrap();
4590
4591 assert_eq!(
4592 visible_entries_as_strings(&panel, 0..20, cx),
4593 &[
4594 "v project_root",
4595 " > .git",
4596 " > dir_1",
4597 " > dir_2",
4598 " .gitignore",
4599 ]
4600 );
4601
4602 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
4603 .expect("dir 1 file is not ignored and should have an entry");
4604 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
4605 .expect("dir 2 file is not ignored and should have an entry");
4606 let gitignored_dir_file =
4607 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
4608 assert_eq!(
4609 gitignored_dir_file, None,
4610 "File in the gitignored dir should not have an entry before its dir is toggled"
4611 );
4612
4613 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4614 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4615 cx.run_until_parked();
4616 assert_eq!(
4617 visible_entries_as_strings(&panel, 0..20, cx),
4618 &[
4619 "v project_root",
4620 " > .git",
4621 " v dir_1",
4622 " v gitignored_dir <== selected",
4623 " file_a.py",
4624 " file_b.py",
4625 " file_c.py",
4626 " file_1.py",
4627 " file_2.py",
4628 " file_3.py",
4629 " > dir_2",
4630 " .gitignore",
4631 ],
4632 "Should show gitignored dir file list in the project panel"
4633 );
4634 let gitignored_dir_file =
4635 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
4636 .expect("after gitignored dir got opened, a file entry should be present");
4637
4638 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
4639 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4640 assert_eq!(
4641 visible_entries_as_strings(&panel, 0..20, cx),
4642 &[
4643 "v project_root",
4644 " > .git",
4645 " > dir_1 <== selected",
4646 " > dir_2",
4647 " .gitignore",
4648 ],
4649 "Should hide all dir contents again and prepare for the explicit reveal test"
4650 );
4651
4652 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
4653 panel.update(cx, |panel, cx| {
4654 panel.project.update(cx, |_, cx| {
4655 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
4656 })
4657 });
4658 cx.run_until_parked();
4659 assert_eq!(
4660 visible_entries_as_strings(&panel, 0..20, cx),
4661 &[
4662 "v project_root",
4663 " > .git",
4664 " > dir_1 <== selected",
4665 " > dir_2",
4666 " .gitignore",
4667 ],
4668 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
4669 );
4670 }
4671
4672 panel.update(cx, |panel, cx| {
4673 panel.project.update(cx, |_, cx| {
4674 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
4675 })
4676 });
4677 cx.run_until_parked();
4678 assert_eq!(
4679 visible_entries_as_strings(&panel, 0..20, cx),
4680 &[
4681 "v project_root",
4682 " > .git",
4683 " v dir_1",
4684 " > gitignored_dir",
4685 " file_1.py <== selected",
4686 " file_2.py",
4687 " file_3.py",
4688 " > dir_2",
4689 " .gitignore",
4690 ],
4691 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
4692 );
4693
4694 panel.update(cx, |panel, cx| {
4695 panel.project.update(cx, |_, cx| {
4696 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
4697 })
4698 });
4699 cx.run_until_parked();
4700 assert_eq!(
4701 visible_entries_as_strings(&panel, 0..20, cx),
4702 &[
4703 "v project_root",
4704 " > .git",
4705 " v dir_1",
4706 " > gitignored_dir",
4707 " file_1.py",
4708 " file_2.py",
4709 " file_3.py",
4710 " v dir_2",
4711 " file_1.py <== selected",
4712 " file_2.py",
4713 " file_3.py",
4714 " .gitignore",
4715 ],
4716 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
4717 );
4718
4719 panel.update(cx, |panel, cx| {
4720 panel.project.update(cx, |_, cx| {
4721 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
4722 })
4723 });
4724 cx.run_until_parked();
4725 assert_eq!(
4726 visible_entries_as_strings(&panel, 0..20, cx),
4727 &[
4728 "v project_root",
4729 " > .git",
4730 " v dir_1",
4731 " v gitignored_dir",
4732 " file_a.py <== selected",
4733 " file_b.py",
4734 " file_c.py",
4735 " file_1.py",
4736 " file_2.py",
4737 " file_3.py",
4738 " v dir_2",
4739 " file_1.py",
4740 " file_2.py",
4741 " file_3.py",
4742 " .gitignore",
4743 ],
4744 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
4745 );
4746 }
4747
4748 #[gpui::test]
4749 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
4750 init_test(cx);
4751 cx.update(|cx| {
4752 cx.update_global::<SettingsStore, _>(|store, cx| {
4753 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
4754 project_settings.file_scan_exclusions =
4755 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
4756 });
4757 });
4758 });
4759
4760 cx.update(|cx| {
4761 register_project_item::<TestProjectItemView>(cx);
4762 });
4763
4764 let fs = FakeFs::new(cx.executor().clone());
4765 fs.insert_tree(
4766 "/root1",
4767 json!({
4768 ".dockerignore": "",
4769 ".git": {
4770 "HEAD": "",
4771 },
4772 }),
4773 )
4774 .await;
4775
4776 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4777 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4778 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4779 let panel = workspace
4780 .update(cx, |workspace, cx| {
4781 let panel = ProjectPanel::new(workspace, cx);
4782 workspace.add_panel(panel.clone(), cx);
4783 panel
4784 })
4785 .unwrap();
4786
4787 select_path(&panel, "root1", cx);
4788 assert_eq!(
4789 visible_entries_as_strings(&panel, 0..10, cx),
4790 &["v root1 <== selected", " .dockerignore",]
4791 );
4792 workspace
4793 .update(cx, |workspace, cx| {
4794 assert!(
4795 workspace.active_item(cx).is_none(),
4796 "Should have no active items in the beginning"
4797 );
4798 })
4799 .unwrap();
4800
4801 let excluded_file_path = ".git/COMMIT_EDITMSG";
4802 let excluded_dir_path = "excluded_dir";
4803
4804 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4805 panel.update(cx, |panel, cx| {
4806 assert!(panel.filename_editor.read(cx).is_focused(cx));
4807 });
4808 panel
4809 .update(cx, |panel, cx| {
4810 panel
4811 .filename_editor
4812 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
4813 panel.confirm_edit(cx).unwrap()
4814 })
4815 .await
4816 .unwrap();
4817
4818 assert_eq!(
4819 visible_entries_as_strings(&panel, 0..13, cx),
4820 &["v root1", " .dockerignore"],
4821 "Excluded dir should not be shown after opening a file in it"
4822 );
4823 panel.update(cx, |panel, cx| {
4824 assert!(
4825 !panel.filename_editor.read(cx).is_focused(cx),
4826 "Should have closed the file name editor"
4827 );
4828 });
4829 workspace
4830 .update(cx, |workspace, cx| {
4831 let active_entry_path = workspace
4832 .active_item(cx)
4833 .expect("should have opened and activated the excluded item")
4834 .act_as::<TestProjectItemView>(cx)
4835 .expect(
4836 "should have opened the corresponding project item for the excluded item",
4837 )
4838 .read(cx)
4839 .path
4840 .clone();
4841 assert_eq!(
4842 active_entry_path.path.as_ref(),
4843 Path::new(excluded_file_path),
4844 "Should open the excluded file"
4845 );
4846
4847 assert!(
4848 workspace.notification_ids().is_empty(),
4849 "Should have no notifications after opening an excluded file"
4850 );
4851 })
4852 .unwrap();
4853 assert!(
4854 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
4855 "Should have created the excluded file"
4856 );
4857
4858 select_path(&panel, "root1", cx);
4859 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4860 panel.update(cx, |panel, cx| {
4861 assert!(panel.filename_editor.read(cx).is_focused(cx));
4862 });
4863 panel
4864 .update(cx, |panel, cx| {
4865 panel
4866 .filename_editor
4867 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
4868 panel.confirm_edit(cx).unwrap()
4869 })
4870 .await
4871 .unwrap();
4872
4873 assert_eq!(
4874 visible_entries_as_strings(&panel, 0..13, cx),
4875 &["v root1", " .dockerignore"],
4876 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
4877 );
4878 panel.update(cx, |panel, cx| {
4879 assert!(
4880 !panel.filename_editor.read(cx).is_focused(cx),
4881 "Should have closed the file name editor"
4882 );
4883 });
4884 workspace
4885 .update(cx, |workspace, cx| {
4886 let notifications = workspace.notification_ids();
4887 assert_eq!(
4888 notifications.len(),
4889 1,
4890 "Should receive one notification with the error message"
4891 );
4892 workspace.dismiss_notification(notifications.first().unwrap(), cx);
4893 assert!(workspace.notification_ids().is_empty());
4894 })
4895 .unwrap();
4896
4897 select_path(&panel, "root1", cx);
4898 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4899 panel.update(cx, |panel, cx| {
4900 assert!(panel.filename_editor.read(cx).is_focused(cx));
4901 });
4902 panel
4903 .update(cx, |panel, cx| {
4904 panel
4905 .filename_editor
4906 .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
4907 panel.confirm_edit(cx).unwrap()
4908 })
4909 .await
4910 .unwrap();
4911
4912 assert_eq!(
4913 visible_entries_as_strings(&panel, 0..13, cx),
4914 &["v root1", " .dockerignore"],
4915 "Should not change the project panel after trying to create an excluded directory"
4916 );
4917 panel.update(cx, |panel, cx| {
4918 assert!(
4919 !panel.filename_editor.read(cx).is_focused(cx),
4920 "Should have closed the file name editor"
4921 );
4922 });
4923 workspace
4924 .update(cx, |workspace, cx| {
4925 let notifications = workspace.notification_ids();
4926 assert_eq!(
4927 notifications.len(),
4928 1,
4929 "Should receive one notification explaining that no directory is actually shown"
4930 );
4931 workspace.dismiss_notification(notifications.first().unwrap(), cx);
4932 assert!(workspace.notification_ids().is_empty());
4933 })
4934 .unwrap();
4935 assert!(
4936 fs.is_dir(Path::new("/root1/excluded_dir")).await,
4937 "Should have created the excluded directory"
4938 );
4939 }
4940
4941 fn toggle_expand_dir(
4942 panel: &View<ProjectPanel>,
4943 path: impl AsRef<Path>,
4944 cx: &mut VisualTestContext,
4945 ) {
4946 let path = path.as_ref();
4947 panel.update(cx, |panel, cx| {
4948 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
4949 let worktree = worktree.read(cx);
4950 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4951 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4952 panel.toggle_expanded(entry_id, cx);
4953 return;
4954 }
4955 }
4956 panic!("no worktree for path {:?}", path);
4957 });
4958 }
4959
4960 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
4961 let path = path.as_ref();
4962 panel.update(cx, |panel, cx| {
4963 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
4964 let worktree = worktree.read(cx);
4965 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4966 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
4967 panel.selection = Some(crate::SelectedEntry {
4968 worktree_id: worktree.id(),
4969 entry_id,
4970 });
4971 return;
4972 }
4973 }
4974 panic!("no worktree for path {:?}", path);
4975 });
4976 }
4977
4978 fn find_project_entry(
4979 panel: &View<ProjectPanel>,
4980 path: impl AsRef<Path>,
4981 cx: &mut VisualTestContext,
4982 ) -> Option<ProjectEntryId> {
4983 let path = path.as_ref();
4984 panel.update(cx, |panel, cx| {
4985 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
4986 let worktree = worktree.read(cx);
4987 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
4988 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
4989 }
4990 }
4991 panic!("no worktree for path {path:?}");
4992 })
4993 }
4994
4995 fn visible_entries_as_strings(
4996 panel: &View<ProjectPanel>,
4997 range: Range<usize>,
4998 cx: &mut VisualTestContext,
4999 ) -> Vec<String> {
5000 let mut result = Vec::new();
5001 let mut project_entries = HashSet::default();
5002 let mut has_editor = false;
5003
5004 panel.update(cx, |panel, cx| {
5005 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
5006 if details.is_editing {
5007 assert!(!has_editor, "duplicate editor entry");
5008 has_editor = true;
5009 } else {
5010 assert!(
5011 project_entries.insert(project_entry),
5012 "duplicate project entry {:?} {:?}",
5013 project_entry,
5014 details
5015 );
5016 }
5017
5018 let indent = " ".repeat(details.depth);
5019 let icon = if details.kind.is_dir() {
5020 if details.is_expanded {
5021 "v "
5022 } else {
5023 "> "
5024 }
5025 } else {
5026 " "
5027 };
5028 let name = if details.is_editing {
5029 format!("[EDITOR: '{}']", details.filename)
5030 } else if details.is_processing {
5031 format!("[PROCESSING: '{}']", details.filename)
5032 } else {
5033 details.filename.clone()
5034 };
5035 let selected = if details.is_selected {
5036 " <== selected"
5037 } else {
5038 ""
5039 };
5040 let marked = if details.is_marked {
5041 " <== marked"
5042 } else {
5043 ""
5044 };
5045
5046 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
5047 });
5048 });
5049
5050 result
5051 }
5052
5053 fn init_test(cx: &mut TestAppContext) {
5054 cx.update(|cx| {
5055 let settings_store = SettingsStore::test(cx);
5056 cx.set_global(settings_store);
5057 init_settings(cx);
5058 theme::init(theme::LoadThemes::JustBase, cx);
5059 language::init(cx);
5060 editor::init_settings(cx);
5061 crate::init((), cx);
5062 workspace::init_settings(cx);
5063 client::init_settings(cx);
5064 Project::init_settings(cx);
5065
5066 cx.update_global::<SettingsStore, _>(|store, cx| {
5067 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5068 worktree_settings.file_scan_exclusions = Some(Vec::new());
5069 });
5070 });
5071 });
5072 }
5073
5074 fn init_test_with_editor(cx: &mut TestAppContext) {
5075 cx.update(|cx| {
5076 let app_state = AppState::test(cx);
5077 theme::init(theme::LoadThemes::JustBase, cx);
5078 init_settings(cx);
5079 language::init(cx);
5080 editor::init(cx);
5081 crate::init((), cx);
5082 workspace::init(app_state.clone(), cx);
5083 Project::init_settings(cx);
5084 });
5085 }
5086
5087 fn ensure_single_file_is_opened(
5088 window: &WindowHandle<Workspace>,
5089 expected_path: &str,
5090 cx: &mut TestAppContext,
5091 ) {
5092 window
5093 .update(cx, |workspace, cx| {
5094 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
5095 assert_eq!(worktrees.len(), 1);
5096 let worktree_id = worktrees[0].read(cx).id();
5097
5098 let open_project_paths = workspace
5099 .panes()
5100 .iter()
5101 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
5102 .collect::<Vec<_>>();
5103 assert_eq!(
5104 open_project_paths,
5105 vec![ProjectPath {
5106 worktree_id,
5107 path: Arc::from(Path::new(expected_path))
5108 }],
5109 "Should have opened file, selected in project panel"
5110 );
5111 })
5112 .unwrap();
5113 }
5114
5115 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
5116 assert!(
5117 !cx.has_pending_prompt(),
5118 "Should have no prompts before the deletion"
5119 );
5120 panel.update(cx, |panel, cx| {
5121 panel.delete(&Delete { skip_prompt: false }, cx)
5122 });
5123 assert!(
5124 cx.has_pending_prompt(),
5125 "Should have a prompt after the deletion"
5126 );
5127 cx.simulate_prompt_answer(0);
5128 assert!(
5129 !cx.has_pending_prompt(),
5130 "Should have no prompts after prompt was replied to"
5131 );
5132 cx.executor().run_until_parked();
5133 }
5134
5135 fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
5136 assert!(
5137 !cx.has_pending_prompt(),
5138 "Should have no prompts before the deletion"
5139 );
5140 panel.update(cx, |panel, cx| {
5141 panel.delete(&Delete { skip_prompt: true }, cx)
5142 });
5143 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
5144 cx.executor().run_until_parked();
5145 }
5146
5147 fn ensure_no_open_items_and_panes(
5148 workspace: &WindowHandle<Workspace>,
5149 cx: &mut VisualTestContext,
5150 ) {
5151 assert!(
5152 !cx.has_pending_prompt(),
5153 "Should have no prompts after deletion operation closes the file"
5154 );
5155 workspace
5156 .read_with(cx, |workspace, cx| {
5157 let open_project_paths = workspace
5158 .panes()
5159 .iter()
5160 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
5161 .collect::<Vec<_>>();
5162 assert!(
5163 open_project_paths.is_empty(),
5164 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
5165 );
5166 })
5167 .unwrap();
5168 }
5169
5170 struct TestProjectItemView {
5171 focus_handle: FocusHandle,
5172 path: ProjectPath,
5173 }
5174
5175 struct TestProjectItem {
5176 path: ProjectPath,
5177 }
5178
5179 impl project::Item for TestProjectItem {
5180 fn try_open(
5181 _project: &Model<Project>,
5182 path: &ProjectPath,
5183 cx: &mut AppContext,
5184 ) -> Option<Task<gpui::Result<Model<Self>>>> {
5185 let path = path.clone();
5186 Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
5187 }
5188
5189 fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
5190 None
5191 }
5192
5193 fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
5194 Some(self.path.clone())
5195 }
5196 }
5197
5198 impl ProjectItem for TestProjectItemView {
5199 type Item = TestProjectItem;
5200
5201 fn for_project_item(
5202 _: Model<Project>,
5203 project_item: Model<Self::Item>,
5204 cx: &mut ViewContext<Self>,
5205 ) -> Self
5206 where
5207 Self: Sized,
5208 {
5209 Self {
5210 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
5211 focus_handle: cx.focus_handle(),
5212 }
5213 }
5214 }
5215
5216 impl Item for TestProjectItemView {
5217 type Event = ();
5218 }
5219
5220 impl EventEmitter<()> for TestProjectItemView {}
5221
5222 impl FocusableView for TestProjectItemView {
5223 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
5224 self.focus_handle.clone()
5225 }
5226 }
5227
5228 impl Render for TestProjectItemView {
5229 fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
5230 Empty
5231 }
5232 }
5233}