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