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