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