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