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