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