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