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