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