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