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