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