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