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