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