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