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