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