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