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