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