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