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 let is_local = self.project.read(cx).is_local();
2331
2332 let dragged_selection = DraggedSelection {
2333 active_selection: selection,
2334 marked_selections: selections,
2335 };
2336 div()
2337 .id(entry_id.to_proto() as usize)
2338 .when(is_local, |div| {
2339 div.on_drag_move::<ExternalPaths>(cx.listener(
2340 move |this, event: &DragMoveEvent<ExternalPaths>, cx| {
2341 if event.bounds.contains(&event.event.position) {
2342 if this.last_external_paths_drag_over_entry == Some(entry_id) {
2343 return;
2344 }
2345 this.last_external_paths_drag_over_entry = Some(entry_id);
2346 this.marked_entries.clear();
2347
2348 let Some((worktree, path, entry)) = maybe!({
2349 let worktree = this
2350 .project
2351 .read(cx)
2352 .worktree_for_id(selection.worktree_id, cx)?;
2353 let worktree = worktree.read(cx);
2354 let abs_path = worktree.absolutize(&path).log_err()?;
2355 let path = if abs_path.is_dir() {
2356 path.as_ref()
2357 } else {
2358 path.parent()?
2359 };
2360 let entry = worktree.entry_for_path(path)?;
2361 Some((worktree, path, entry))
2362 }) else {
2363 return;
2364 };
2365
2366 this.marked_entries.insert(SelectedEntry {
2367 entry_id: entry.id,
2368 worktree_id: worktree.id(),
2369 });
2370
2371 for entry in worktree.child_entries(path) {
2372 this.marked_entries.insert(SelectedEntry {
2373 entry_id: entry.id,
2374 worktree_id: worktree.id(),
2375 });
2376 }
2377
2378 cx.notify();
2379 }
2380 },
2381 ))
2382 .on_drop(cx.listener(
2383 move |this, external_paths: &ExternalPaths, cx| {
2384 this.last_external_paths_drag_over_entry = None;
2385 this.marked_entries.clear();
2386 this.drop_external_files(external_paths.paths(), entry_id, cx);
2387 cx.stop_propagation();
2388 },
2389 ))
2390 })
2391 .on_drag(dragged_selection, move |selection, cx| {
2392 cx.new_view(|_| DraggedProjectEntryView {
2393 details: details.clone(),
2394 width,
2395 selection: selection.active_selection,
2396 selections: selection.marked_selections.clone(),
2397 })
2398 })
2399 .drag_over::<DraggedSelection>(|style, _, cx| {
2400 style.bg(cx.theme().colors().drop_target_background)
2401 })
2402 .on_drop(cx.listener(move |this, selections: &DraggedSelection, cx| {
2403 this.drag_onto(selections, entry_id, kind.is_file(), cx);
2404 }))
2405 .child(
2406 ListItem::new(entry_id.to_proto() as usize)
2407 .indent_level(depth)
2408 .indent_step_size(px(settings.indent_size))
2409 .selected(is_marked || is_active)
2410 .when_some(canonical_path, |this, path| {
2411 this.end_slot::<AnyElement>(
2412 div()
2413 .id("symlink_icon")
2414 .pr_3()
2415 .tooltip(move |cx| {
2416 Tooltip::with_meta(path.to_string(), None, "Symbolic Link", cx)
2417 })
2418 .child(
2419 Icon::new(IconName::ArrowUpRight)
2420 .size(IconSize::Indicator)
2421 .color(filename_text_color),
2422 )
2423 .into_any_element(),
2424 )
2425 })
2426 .child(if let Some(icon) = &icon {
2427 h_flex().child(Icon::from_path(icon.to_string()).color(filename_text_color))
2428 } else {
2429 h_flex()
2430 .size(IconSize::default().rems())
2431 .invisible()
2432 .flex_none()
2433 })
2434 .child(
2435 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
2436 h_flex().h_6().w_full().child(editor.clone())
2437 } else {
2438 h_flex().h_6().map(|mut this| {
2439 if let Some(folded_ancestors) =
2440 is_active.then(|| self.ancestors.get(&entry_id)).flatten()
2441 {
2442 let components = Path::new(&file_name)
2443 .components()
2444 .map(|comp| {
2445 let comp_str =
2446 comp.as_os_str().to_string_lossy().into_owned();
2447 comp_str
2448 })
2449 .collect::<Vec<_>>();
2450 let components_len = components.len();
2451 let active_index = components_len
2452 - 1
2453 - folded_ancestors.current_ancestor_depth;
2454 const DELIMITER: SharedString =
2455 SharedString::new_static(std::path::MAIN_SEPARATOR_STR);
2456 for (index, component) in components.into_iter().enumerate() {
2457 if index != 0 {
2458 this = this.child(
2459 Label::new(DELIMITER.clone())
2460 .single_line()
2461 .color(filename_text_color),
2462 );
2463 }
2464 let id = SharedString::from(format!(
2465 "project_panel_path_component_{}_{index}",
2466 entry_id.to_usize()
2467 ));
2468 let label = div()
2469 .id(id)
2470 .on_click(cx.listener(move |this, _, cx| {
2471 if index != active_index {
2472 if let Some(folds) =
2473 this.ancestors.get_mut(&entry_id)
2474 {
2475 folds.current_ancestor_depth =
2476 components_len - 1 - index;
2477 cx.notify();
2478 }
2479 }
2480 }))
2481 .child(
2482 Label::new(component)
2483 .single_line()
2484 .color(filename_text_color)
2485 .when(index == active_index, |this| {
2486 this.underline(true)
2487 }),
2488 );
2489
2490 this = this.child(label);
2491 }
2492
2493 this
2494 } else {
2495 this.child(
2496 Label::new(file_name)
2497 .single_line()
2498 .color(filename_text_color),
2499 )
2500 }
2501 })
2502 }
2503 .ml_1(),
2504 )
2505 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
2506 if event.down.button == MouseButton::Right || event.down.first_mouse {
2507 return;
2508 }
2509 if !show_editor {
2510 cx.stop_propagation();
2511
2512 if let Some(selection) =
2513 this.selection.filter(|_| event.down.modifiers.shift)
2514 {
2515 let current_selection = this.index_for_selection(selection);
2516 let target_selection = this.index_for_selection(SelectedEntry {
2517 entry_id,
2518 worktree_id,
2519 });
2520 if let Some(((_, _, source_index), (_, _, target_index))) =
2521 current_selection.zip(target_selection)
2522 {
2523 let range_start = source_index.min(target_index);
2524 let range_end = source_index.max(target_index) + 1; // Make the range inclusive.
2525 let mut new_selections = BTreeSet::new();
2526 this.for_each_visible_entry(
2527 range_start..range_end,
2528 cx,
2529 |entry_id, details, _| {
2530 new_selections.insert(SelectedEntry {
2531 entry_id,
2532 worktree_id: details.worktree_id,
2533 });
2534 },
2535 );
2536
2537 this.marked_entries = this
2538 .marked_entries
2539 .union(&new_selections)
2540 .cloned()
2541 .collect();
2542
2543 this.selection = Some(SelectedEntry {
2544 entry_id,
2545 worktree_id,
2546 });
2547 // Ensure that the current entry is selected.
2548 this.marked_entries.insert(SelectedEntry {
2549 entry_id,
2550 worktree_id,
2551 });
2552 }
2553 } else if event.down.modifiers.secondary() {
2554 if event.down.click_count > 1 {
2555 this.split_entry(entry_id, cx);
2556 } else if !this.marked_entries.insert(selection) {
2557 this.marked_entries.remove(&selection);
2558 }
2559 } else if kind.is_dir() {
2560 this.toggle_expanded(entry_id, cx);
2561 } else {
2562 let click_count = event.up.click_count;
2563 this.open_entry(
2564 entry_id,
2565 cx.modifiers().secondary(),
2566 click_count > 1,
2567 click_count == 1,
2568 cx,
2569 );
2570 }
2571 }
2572 }))
2573 .on_secondary_mouse_down(cx.listener(
2574 move |this, event: &MouseDownEvent, cx| {
2575 // Stop propagation to prevent the catch-all context menu for the project
2576 // panel from being deployed.
2577 cx.stop_propagation();
2578 this.deploy_context_menu(event.position, entry_id, cx);
2579 },
2580 ))
2581 .overflow_x(),
2582 )
2583 .border_1()
2584 .border_r_2()
2585 .rounded_none()
2586 .hover(|style| {
2587 if is_active {
2588 style
2589 } else {
2590 let hover_color = cx.theme().colors().ghost_element_hover;
2591 style.bg(hover_color).border_color(hover_color)
2592 }
2593 })
2594 .when(is_marked || is_active, |this| {
2595 let colors = cx.theme().colors();
2596 this.when(is_marked, |this| this.bg(colors.ghost_element_selected))
2597 .border_color(colors.ghost_element_selected)
2598 })
2599 .when(
2600 is_active && self.focus_handle.contains_focused(cx),
2601 |this| this.border_color(Color::Selected.color(cx)),
2602 )
2603 }
2604
2605 fn render_vertical_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
2606 if !Self::should_show_scrollbar(cx)
2607 || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging())
2608 {
2609 return None;
2610 }
2611 Some(
2612 div()
2613 .occlude()
2614 .id("project-panel-vertical-scroll")
2615 .on_mouse_move(cx.listener(|_, _, cx| {
2616 cx.notify();
2617 cx.stop_propagation()
2618 }))
2619 .on_hover(|_, cx| {
2620 cx.stop_propagation();
2621 })
2622 .on_any_mouse_down(|_, cx| {
2623 cx.stop_propagation();
2624 })
2625 .on_mouse_up(
2626 MouseButton::Left,
2627 cx.listener(|this, _, cx| {
2628 if !this.vertical_scrollbar_state.is_dragging()
2629 && !this.focus_handle.contains_focused(cx)
2630 {
2631 this.hide_scrollbar(cx);
2632 cx.notify();
2633 }
2634
2635 cx.stop_propagation();
2636 }),
2637 )
2638 .on_scroll_wheel(cx.listener(|_, _, cx| {
2639 cx.notify();
2640 }))
2641 .h_full()
2642 .absolute()
2643 .right_1()
2644 .top_1()
2645 .bottom_1()
2646 .w(px(12.))
2647 .cursor_default()
2648 .children(Scrollbar::vertical(
2649 // percentage as f32..end_offset as f32,
2650 self.vertical_scrollbar_state.clone(),
2651 )),
2652 )
2653 }
2654
2655 fn render_horizontal_scrollbar(&self, cx: &mut ViewContext<Self>) -> Option<Stateful<Div>> {
2656 if !Self::should_show_scrollbar(cx)
2657 || !(self.show_scrollbar || self.horizontal_scrollbar_state.is_dragging())
2658 {
2659 return None;
2660 }
2661
2662 Some(
2663 div()
2664 .occlude()
2665 .id("project-panel-horizontal-scroll")
2666 .on_mouse_move(cx.listener(|_, _, cx| {
2667 cx.notify();
2668 cx.stop_propagation()
2669 }))
2670 .on_hover(|_, cx| {
2671 cx.stop_propagation();
2672 })
2673 .on_any_mouse_down(|_, cx| {
2674 cx.stop_propagation();
2675 })
2676 .on_mouse_up(
2677 MouseButton::Left,
2678 cx.listener(|this, _, cx| {
2679 if !this.horizontal_scrollbar_state.is_dragging()
2680 && !this.focus_handle.contains_focused(cx)
2681 {
2682 this.hide_scrollbar(cx);
2683 cx.notify();
2684 }
2685
2686 cx.stop_propagation();
2687 }),
2688 )
2689 .on_scroll_wheel(cx.listener(|_, _, cx| {
2690 cx.notify();
2691 }))
2692 .w_full()
2693 .absolute()
2694 .right_1()
2695 .left_1()
2696 .bottom_1()
2697 .h(px(12.))
2698 .cursor_default()
2699 .when(self.width.is_some(), |this| {
2700 this.children(Scrollbar::horizontal(
2701 self.horizontal_scrollbar_state.clone(),
2702 ))
2703 }),
2704 )
2705 }
2706
2707 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
2708 let mut dispatch_context = KeyContext::new_with_defaults();
2709 dispatch_context.add("ProjectPanel");
2710 dispatch_context.add("menu");
2711
2712 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
2713 "editing"
2714 } else {
2715 "not_editing"
2716 };
2717
2718 dispatch_context.add(identifier);
2719 dispatch_context
2720 }
2721
2722 fn should_show_scrollbar(cx: &AppContext) -> bool {
2723 let show = ProjectPanelSettings::get_global(cx)
2724 .scrollbar
2725 .show
2726 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
2727 match show {
2728 ShowScrollbar::Auto => true,
2729 ShowScrollbar::System => true,
2730 ShowScrollbar::Always => true,
2731 ShowScrollbar::Never => false,
2732 }
2733 }
2734
2735 fn should_autohide_scrollbar(cx: &AppContext) -> bool {
2736 let show = ProjectPanelSettings::get_global(cx)
2737 .scrollbar
2738 .show
2739 .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show);
2740 match show {
2741 ShowScrollbar::Auto => true,
2742 ShowScrollbar::System => cx
2743 .try_global::<ScrollbarAutoHide>()
2744 .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0),
2745 ShowScrollbar::Always => false,
2746 ShowScrollbar::Never => true,
2747 }
2748 }
2749
2750 fn hide_scrollbar(&mut self, cx: &mut ViewContext<Self>) {
2751 const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1);
2752 if !Self::should_autohide_scrollbar(cx) {
2753 return;
2754 }
2755 self.hide_scrollbar_task = Some(cx.spawn(|panel, mut cx| async move {
2756 cx.background_executor()
2757 .timer(SCROLLBAR_SHOW_INTERVAL)
2758 .await;
2759 panel
2760 .update(&mut cx, |panel, cx| {
2761 panel.show_scrollbar = false;
2762 cx.notify();
2763 })
2764 .log_err();
2765 }))
2766 }
2767
2768 fn reveal_entry(
2769 &mut self,
2770 project: Model<Project>,
2771 entry_id: ProjectEntryId,
2772 skip_ignored: bool,
2773 cx: &mut ViewContext<'_, Self>,
2774 ) {
2775 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
2776 let worktree = worktree.read(cx);
2777 if skip_ignored
2778 && worktree
2779 .entry_for_id(entry_id)
2780 .map_or(true, |entry| entry.is_ignored)
2781 {
2782 return;
2783 }
2784
2785 let worktree_id = worktree.id();
2786 self.marked_entries.clear();
2787 self.expand_entry(worktree_id, entry_id, cx);
2788 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
2789 self.autoscroll(cx);
2790 cx.notify();
2791 }
2792 }
2793}
2794
2795fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize {
2796 const ICON_SIZE_FACTOR: usize = 2;
2797 let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars;
2798 if is_symlink {
2799 item_width += ICON_SIZE_FACTOR;
2800 }
2801 item_width
2802}
2803
2804impl Render for ProjectPanel {
2805 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
2806 let has_worktree = !self.visible_entries.is_empty();
2807 let project = self.project.read(cx);
2808 let is_local = project.is_local();
2809
2810 if has_worktree {
2811 let item_count = self
2812 .visible_entries
2813 .iter()
2814 .map(|(_, worktree_entries, _)| worktree_entries.len())
2815 .sum();
2816
2817 h_flex()
2818 .id("project-panel")
2819 .group("project-panel")
2820 .size_full()
2821 .relative()
2822 .on_hover(cx.listener(|this, hovered, cx| {
2823 if *hovered {
2824 this.show_scrollbar = true;
2825 this.hide_scrollbar_task.take();
2826 cx.notify();
2827 } else if !this.focus_handle.contains_focused(cx) {
2828 this.hide_scrollbar(cx);
2829 }
2830 }))
2831 .key_context(self.dispatch_context(cx))
2832 .on_action(cx.listener(Self::select_next))
2833 .on_action(cx.listener(Self::select_prev))
2834 .on_action(cx.listener(Self::select_first))
2835 .on_action(cx.listener(Self::select_last))
2836 .on_action(cx.listener(Self::select_parent))
2837 .on_action(cx.listener(Self::expand_selected_entry))
2838 .on_action(cx.listener(Self::collapse_selected_entry))
2839 .on_action(cx.listener(Self::collapse_all_entries))
2840 .on_action(cx.listener(Self::open))
2841 .on_action(cx.listener(Self::open_permanent))
2842 .on_action(cx.listener(Self::confirm))
2843 .on_action(cx.listener(Self::cancel))
2844 .on_action(cx.listener(Self::copy_path))
2845 .on_action(cx.listener(Self::copy_relative_path))
2846 .on_action(cx.listener(Self::new_search_in_directory))
2847 .on_action(cx.listener(Self::unfold_directory))
2848 .on_action(cx.listener(Self::fold_directory))
2849 .when(!project.is_read_only(cx), |el| {
2850 el.on_action(cx.listener(Self::new_file))
2851 .on_action(cx.listener(Self::new_directory))
2852 .on_action(cx.listener(Self::rename))
2853 .on_action(cx.listener(Self::delete))
2854 .on_action(cx.listener(Self::trash))
2855 .on_action(cx.listener(Self::cut))
2856 .on_action(cx.listener(Self::copy))
2857 .on_action(cx.listener(Self::paste))
2858 .on_action(cx.listener(Self::duplicate))
2859 .on_click(cx.listener(|this, event: &gpui::ClickEvent, cx| {
2860 if event.up.click_count > 1 {
2861 if let Some(entry_id) = this.last_worktree_root_id {
2862 let project = this.project.read(cx);
2863
2864 let worktree_id = if let Some(worktree) =
2865 project.worktree_for_entry(entry_id, cx)
2866 {
2867 worktree.read(cx).id()
2868 } else {
2869 return;
2870 };
2871
2872 this.selection = Some(SelectedEntry {
2873 worktree_id,
2874 entry_id,
2875 });
2876
2877 this.new_file(&NewFile, cx);
2878 }
2879 }
2880 }))
2881 })
2882 .when(project.is_local(), |el| {
2883 el.on_action(cx.listener(Self::reveal_in_finder))
2884 .on_action(cx.listener(Self::open_system))
2885 .on_action(cx.listener(Self::open_in_terminal))
2886 })
2887 .when(project.is_via_ssh(), |el| {
2888 el.on_action(cx.listener(Self::open_in_terminal))
2889 })
2890 .on_mouse_down(
2891 MouseButton::Right,
2892 cx.listener(move |this, event: &MouseDownEvent, cx| {
2893 // When deploying the context menu anywhere below the last project entry,
2894 // act as if the user clicked the root of the last worktree.
2895 if let Some(entry_id) = this.last_worktree_root_id {
2896 this.deploy_context_menu(event.position, entry_id, cx);
2897 }
2898 }),
2899 )
2900 .track_focus(&self.focus_handle)
2901 .child(
2902 uniform_list(cx.view().clone(), "entries", item_count, {
2903 |this, range, cx| {
2904 let mut items = Vec::with_capacity(range.end - range.start);
2905 this.for_each_visible_entry(range, cx, |id, details, cx| {
2906 items.push(this.render_entry(id, details, cx));
2907 });
2908 items
2909 }
2910 })
2911 .size_full()
2912 .with_sizing_behavior(ListSizingBehavior::Infer)
2913 .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained)
2914 .with_width_from_item(self.max_width_item_index)
2915 .track_scroll(self.scroll_handle.clone()),
2916 )
2917 .children(self.render_vertical_scrollbar(cx))
2918 .when_some(self.render_horizontal_scrollbar(cx), |this, scrollbar| {
2919 this.pb_4().child(scrollbar)
2920 })
2921 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
2922 deferred(
2923 anchored()
2924 .position(*position)
2925 .anchor(gpui::AnchorCorner::TopLeft)
2926 .child(menu.clone()),
2927 )
2928 .with_priority(1)
2929 }))
2930 } else {
2931 v_flex()
2932 .id("empty-project_panel")
2933 .size_full()
2934 .p_4()
2935 .track_focus(&self.focus_handle)
2936 .child(
2937 Button::new("open_project", "Open a project")
2938 .full_width()
2939 .key_binding(KeyBinding::for_action(&workspace::Open, cx))
2940 .on_click(cx.listener(|this, _, cx| {
2941 this.workspace
2942 .update(cx, |_, cx| cx.dispatch_action(Box::new(workspace::Open)))
2943 .log_err();
2944 })),
2945 )
2946 .when(is_local, |div| {
2947 div.drag_over::<ExternalPaths>(|style, _, cx| {
2948 style.bg(cx.theme().colors().drop_target_background)
2949 })
2950 .on_drop(cx.listener(
2951 move |this, external_paths: &ExternalPaths, cx| {
2952 this.last_external_paths_drag_over_entry = None;
2953 this.marked_entries.clear();
2954 if let Some(task) = this
2955 .workspace
2956 .update(cx, |workspace, cx| {
2957 workspace.open_workspace_for_paths(
2958 true,
2959 external_paths.paths().to_owned(),
2960 cx,
2961 )
2962 })
2963 .log_err()
2964 {
2965 task.detach_and_log_err(cx);
2966 }
2967 cx.stop_propagation();
2968 },
2969 ))
2970 })
2971 }
2972 }
2973}
2974
2975impl Render for DraggedProjectEntryView {
2976 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
2977 let settings = ProjectPanelSettings::get_global(cx);
2978 let ui_font = ThemeSettings::get_global(cx).ui_font.clone();
2979 h_flex().font(ui_font).map(|this| {
2980 if self.selections.contains(&self.selection) {
2981 this.flex_shrink()
2982 .p_1()
2983 .items_end()
2984 .rounded_md()
2985 .child(self.selections.len().to_string())
2986 } else {
2987 this.bg(cx.theme().colors().background).w(self.width).child(
2988 ListItem::new(self.selection.entry_id.to_proto() as usize)
2989 .indent_level(self.details.depth)
2990 .indent_step_size(px(settings.indent_size))
2991 .child(if let Some(icon) = &self.details.icon {
2992 div().child(Icon::from_path(icon.clone()))
2993 } else {
2994 div()
2995 })
2996 .child(Label::new(self.details.filename.clone())),
2997 )
2998 }
2999 })
3000 }
3001}
3002
3003impl EventEmitter<Event> for ProjectPanel {}
3004
3005impl EventEmitter<PanelEvent> for ProjectPanel {}
3006
3007impl Panel for ProjectPanel {
3008 fn position(&self, cx: &WindowContext) -> DockPosition {
3009 match ProjectPanelSettings::get_global(cx).dock {
3010 ProjectPanelDockPosition::Left => DockPosition::Left,
3011 ProjectPanelDockPosition::Right => DockPosition::Right,
3012 }
3013 }
3014
3015 fn position_is_valid(&self, position: DockPosition) -> bool {
3016 matches!(position, DockPosition::Left | DockPosition::Right)
3017 }
3018
3019 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
3020 settings::update_settings_file::<ProjectPanelSettings>(
3021 self.fs.clone(),
3022 cx,
3023 move |settings, _| {
3024 let dock = match position {
3025 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
3026 DockPosition::Right => ProjectPanelDockPosition::Right,
3027 };
3028 settings.dock = Some(dock);
3029 },
3030 );
3031 }
3032
3033 fn size(&self, cx: &WindowContext) -> Pixels {
3034 self.width
3035 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
3036 }
3037
3038 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
3039 self.width = size;
3040 self.serialize(cx);
3041 cx.notify();
3042 }
3043
3044 fn icon(&self, cx: &WindowContext) -> Option<IconName> {
3045 ProjectPanelSettings::get_global(cx)
3046 .button
3047 .then_some(IconName::FileTree)
3048 }
3049
3050 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
3051 Some("Project Panel")
3052 }
3053
3054 fn toggle_action(&self) -> Box<dyn Action> {
3055 Box::new(ToggleFocus)
3056 }
3057
3058 fn persistent_name() -> &'static str {
3059 "Project Panel"
3060 }
3061
3062 fn starts_open(&self, cx: &WindowContext) -> bool {
3063 let project = &self.project.read(cx);
3064 project.dev_server_project_id().is_some()
3065 || project.visible_worktrees(cx).any(|tree| {
3066 tree.read(cx)
3067 .root_entry()
3068 .map_or(false, |entry| entry.is_dir())
3069 })
3070 }
3071}
3072
3073impl FocusableView for ProjectPanel {
3074 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
3075 self.focus_handle.clone()
3076 }
3077}
3078
3079impl ClipboardEntry {
3080 fn is_cut(&self) -> bool {
3081 matches!(self, Self::Cut { .. })
3082 }
3083
3084 fn items(&self) -> &BTreeSet<SelectedEntry> {
3085 match self {
3086 ClipboardEntry::Copied(entries) | ClipboardEntry::Cut(entries) => entries,
3087 }
3088 }
3089}
3090
3091#[cfg(test)]
3092mod tests {
3093 use super::*;
3094 use collections::HashSet;
3095 use gpui::{Empty, TestAppContext, View, VisualTestContext, WindowHandle};
3096 use pretty_assertions::assert_eq;
3097 use project::{FakeFs, WorktreeSettings};
3098 use serde_json::json;
3099 use settings::SettingsStore;
3100 use std::path::{Path, PathBuf};
3101 use ui::Context;
3102 use workspace::{
3103 item::{Item, ProjectItem},
3104 register_project_item, AppState,
3105 };
3106
3107 #[gpui::test]
3108 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
3109 init_test(cx);
3110
3111 let fs = FakeFs::new(cx.executor().clone());
3112 fs.insert_tree(
3113 "/root1",
3114 json!({
3115 ".dockerignore": "",
3116 ".git": {
3117 "HEAD": "",
3118 },
3119 "a": {
3120 "0": { "q": "", "r": "", "s": "" },
3121 "1": { "t": "", "u": "" },
3122 "2": { "v": "", "w": "", "x": "", "y": "" },
3123 },
3124 "b": {
3125 "3": { "Q": "" },
3126 "4": { "R": "", "S": "", "T": "", "U": "" },
3127 },
3128 "C": {
3129 "5": {},
3130 "6": { "V": "", "W": "" },
3131 "7": { "X": "" },
3132 "8": { "Y": {}, "Z": "" }
3133 }
3134 }),
3135 )
3136 .await;
3137 fs.insert_tree(
3138 "/root2",
3139 json!({
3140 "d": {
3141 "9": ""
3142 },
3143 "e": {}
3144 }),
3145 )
3146 .await;
3147
3148 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3149 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3150 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3151 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3152 assert_eq!(
3153 visible_entries_as_strings(&panel, 0..50, cx),
3154 &[
3155 "v root1",
3156 " > .git",
3157 " > a",
3158 " > b",
3159 " > C",
3160 " .dockerignore",
3161 "v root2",
3162 " > d",
3163 " > e",
3164 ]
3165 );
3166
3167 toggle_expand_dir(&panel, "root1/b", cx);
3168 assert_eq!(
3169 visible_entries_as_strings(&panel, 0..50, cx),
3170 &[
3171 "v root1",
3172 " > .git",
3173 " > a",
3174 " v b <== selected",
3175 " > 3",
3176 " > 4",
3177 " > C",
3178 " .dockerignore",
3179 "v root2",
3180 " > d",
3181 " > e",
3182 ]
3183 );
3184
3185 assert_eq!(
3186 visible_entries_as_strings(&panel, 6..9, cx),
3187 &[
3188 //
3189 " > C",
3190 " .dockerignore",
3191 "v root2",
3192 ]
3193 );
3194 }
3195
3196 #[gpui::test]
3197 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
3198 init_test(cx);
3199 cx.update(|cx| {
3200 cx.update_global::<SettingsStore, _>(|store, cx| {
3201 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3202 worktree_settings.file_scan_exclusions =
3203 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
3204 });
3205 });
3206 });
3207
3208 let fs = FakeFs::new(cx.background_executor.clone());
3209 fs.insert_tree(
3210 "/root1",
3211 json!({
3212 ".dockerignore": "",
3213 ".git": {
3214 "HEAD": "",
3215 },
3216 "a": {
3217 "0": { "q": "", "r": "", "s": "" },
3218 "1": { "t": "", "u": "" },
3219 "2": { "v": "", "w": "", "x": "", "y": "" },
3220 },
3221 "b": {
3222 "3": { "Q": "" },
3223 "4": { "R": "", "S": "", "T": "", "U": "" },
3224 },
3225 "C": {
3226 "5": {},
3227 "6": { "V": "", "W": "" },
3228 "7": { "X": "" },
3229 "8": { "Y": {}, "Z": "" }
3230 }
3231 }),
3232 )
3233 .await;
3234 fs.insert_tree(
3235 "/root2",
3236 json!({
3237 "d": {
3238 "4": ""
3239 },
3240 "e": {}
3241 }),
3242 )
3243 .await;
3244
3245 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3246 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3247 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3248 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3249 assert_eq!(
3250 visible_entries_as_strings(&panel, 0..50, cx),
3251 &[
3252 "v root1",
3253 " > a",
3254 " > b",
3255 " > C",
3256 " .dockerignore",
3257 "v root2",
3258 " > d",
3259 " > e",
3260 ]
3261 );
3262
3263 toggle_expand_dir(&panel, "root1/b", cx);
3264 assert_eq!(
3265 visible_entries_as_strings(&panel, 0..50, cx),
3266 &[
3267 "v root1",
3268 " > a",
3269 " v b <== selected",
3270 " > 3",
3271 " > C",
3272 " .dockerignore",
3273 "v root2",
3274 " > d",
3275 " > e",
3276 ]
3277 );
3278
3279 toggle_expand_dir(&panel, "root2/d", cx);
3280 assert_eq!(
3281 visible_entries_as_strings(&panel, 0..50, cx),
3282 &[
3283 "v root1",
3284 " > a",
3285 " v b",
3286 " > 3",
3287 " > C",
3288 " .dockerignore",
3289 "v root2",
3290 " v d <== selected",
3291 " > e",
3292 ]
3293 );
3294
3295 toggle_expand_dir(&panel, "root2/e", cx);
3296 assert_eq!(
3297 visible_entries_as_strings(&panel, 0..50, cx),
3298 &[
3299 "v root1",
3300 " > a",
3301 " v b",
3302 " > 3",
3303 " > C",
3304 " .dockerignore",
3305 "v root2",
3306 " v d",
3307 " v e <== selected",
3308 ]
3309 );
3310 }
3311
3312 #[gpui::test]
3313 async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
3314 init_test(cx);
3315
3316 let fs = FakeFs::new(cx.executor().clone());
3317 fs.insert_tree(
3318 "/root1",
3319 json!({
3320 "dir_1": {
3321 "nested_dir_1": {
3322 "nested_dir_2": {
3323 "nested_dir_3": {
3324 "file_a.java": "// File contents",
3325 "file_b.java": "// File contents",
3326 "file_c.java": "// File contents",
3327 "nested_dir_4": {
3328 "nested_dir_5": {
3329 "file_d.java": "// File contents",
3330 }
3331 }
3332 }
3333 }
3334 }
3335 }
3336 }),
3337 )
3338 .await;
3339 fs.insert_tree(
3340 "/root2",
3341 json!({
3342 "dir_2": {
3343 "file_1.java": "// File contents",
3344 }
3345 }),
3346 )
3347 .await;
3348
3349 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3350 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3351 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3352 cx.update(|cx| {
3353 let settings = *ProjectPanelSettings::get_global(cx);
3354 ProjectPanelSettings::override_global(
3355 ProjectPanelSettings {
3356 auto_fold_dirs: true,
3357 ..settings
3358 },
3359 cx,
3360 );
3361 });
3362 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
3363 assert_eq!(
3364 visible_entries_as_strings(&panel, 0..10, cx),
3365 &[
3366 "v root1",
3367 " > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
3368 "v root2",
3369 " > dir_2",
3370 ]
3371 );
3372
3373 toggle_expand_dir(
3374 &panel,
3375 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
3376 cx,
3377 );
3378 assert_eq!(
3379 visible_entries_as_strings(&panel, 0..10, cx),
3380 &[
3381 "v root1",
3382 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
3383 " > nested_dir_4/nested_dir_5",
3384 " file_a.java",
3385 " file_b.java",
3386 " file_c.java",
3387 "v root2",
3388 " > dir_2",
3389 ]
3390 );
3391
3392 toggle_expand_dir(
3393 &panel,
3394 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
3395 cx,
3396 );
3397 assert_eq!(
3398 visible_entries_as_strings(&panel, 0..10, cx),
3399 &[
3400 "v root1",
3401 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
3402 " v nested_dir_4/nested_dir_5 <== selected",
3403 " file_d.java",
3404 " file_a.java",
3405 " file_b.java",
3406 " file_c.java",
3407 "v root2",
3408 " > dir_2",
3409 ]
3410 );
3411 toggle_expand_dir(&panel, "root2/dir_2", cx);
3412 assert_eq!(
3413 visible_entries_as_strings(&panel, 0..10, cx),
3414 &[
3415 "v root1",
3416 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
3417 " v nested_dir_4/nested_dir_5",
3418 " file_d.java",
3419 " file_a.java",
3420 " file_b.java",
3421 " file_c.java",
3422 "v root2",
3423 " v dir_2 <== selected",
3424 " file_1.java",
3425 ]
3426 );
3427 }
3428
3429 #[gpui::test(iterations = 30)]
3430 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
3431 init_test(cx);
3432
3433 let fs = FakeFs::new(cx.executor().clone());
3434 fs.insert_tree(
3435 "/root1",
3436 json!({
3437 ".dockerignore": "",
3438 ".git": {
3439 "HEAD": "",
3440 },
3441 "a": {
3442 "0": { "q": "", "r": "", "s": "" },
3443 "1": { "t": "", "u": "" },
3444 "2": { "v": "", "w": "", "x": "", "y": "" },
3445 },
3446 "b": {
3447 "3": { "Q": "" },
3448 "4": { "R": "", "S": "", "T": "", "U": "" },
3449 },
3450 "C": {
3451 "5": {},
3452 "6": { "V": "", "W": "" },
3453 "7": { "X": "" },
3454 "8": { "Y": {}, "Z": "" }
3455 }
3456 }),
3457 )
3458 .await;
3459 fs.insert_tree(
3460 "/root2",
3461 json!({
3462 "d": {
3463 "9": ""
3464 },
3465 "e": {}
3466 }),
3467 )
3468 .await;
3469
3470 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3471 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3472 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3473 let panel = workspace
3474 .update(cx, |workspace, cx| {
3475 let panel = ProjectPanel::new(workspace, cx);
3476 workspace.add_panel(panel.clone(), cx);
3477 panel
3478 })
3479 .unwrap();
3480
3481 select_path(&panel, "root1", cx);
3482 assert_eq!(
3483 visible_entries_as_strings(&panel, 0..10, cx),
3484 &[
3485 "v root1 <== selected",
3486 " > .git",
3487 " > a",
3488 " > b",
3489 " > C",
3490 " .dockerignore",
3491 "v root2",
3492 " > d",
3493 " > e",
3494 ]
3495 );
3496
3497 // Add a file with the root folder selected. The filename editor is placed
3498 // before the first file in the root folder.
3499 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3500 panel.update(cx, |panel, cx| {
3501 assert!(panel.filename_editor.read(cx).is_focused(cx));
3502 });
3503 assert_eq!(
3504 visible_entries_as_strings(&panel, 0..10, cx),
3505 &[
3506 "v root1",
3507 " > .git",
3508 " > a",
3509 " > b",
3510 " > C",
3511 " [EDITOR: ''] <== selected",
3512 " .dockerignore",
3513 "v root2",
3514 " > d",
3515 " > e",
3516 ]
3517 );
3518
3519 let confirm = panel.update(cx, |panel, cx| {
3520 panel
3521 .filename_editor
3522 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
3523 panel.confirm_edit(cx).unwrap()
3524 });
3525 assert_eq!(
3526 visible_entries_as_strings(&panel, 0..10, cx),
3527 &[
3528 "v root1",
3529 " > .git",
3530 " > a",
3531 " > b",
3532 " > C",
3533 " [PROCESSING: 'the-new-filename'] <== selected",
3534 " .dockerignore",
3535 "v root2",
3536 " > d",
3537 " > e",
3538 ]
3539 );
3540
3541 confirm.await.unwrap();
3542 assert_eq!(
3543 visible_entries_as_strings(&panel, 0..10, cx),
3544 &[
3545 "v root1",
3546 " > .git",
3547 " > a",
3548 " > b",
3549 " > C",
3550 " .dockerignore",
3551 " the-new-filename <== selected <== marked",
3552 "v root2",
3553 " > d",
3554 " > e",
3555 ]
3556 );
3557
3558 select_path(&panel, "root1/b", cx);
3559 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3560 assert_eq!(
3561 visible_entries_as_strings(&panel, 0..10, cx),
3562 &[
3563 "v root1",
3564 " > .git",
3565 " > a",
3566 " v b",
3567 " > 3",
3568 " > 4",
3569 " [EDITOR: ''] <== selected",
3570 " > C",
3571 " .dockerignore",
3572 " the-new-filename",
3573 ]
3574 );
3575
3576 panel
3577 .update(cx, |panel, cx| {
3578 panel
3579 .filename_editor
3580 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
3581 panel.confirm_edit(cx).unwrap()
3582 })
3583 .await
3584 .unwrap();
3585 assert_eq!(
3586 visible_entries_as_strings(&panel, 0..10, cx),
3587 &[
3588 "v root1",
3589 " > .git",
3590 " > a",
3591 " v b",
3592 " > 3",
3593 " > 4",
3594 " another-filename.txt <== selected <== marked",
3595 " > C",
3596 " .dockerignore",
3597 " the-new-filename",
3598 ]
3599 );
3600
3601 select_path(&panel, "root1/b/another-filename.txt", cx);
3602 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3603 assert_eq!(
3604 visible_entries_as_strings(&panel, 0..10, cx),
3605 &[
3606 "v root1",
3607 " > .git",
3608 " > a",
3609 " v b",
3610 " > 3",
3611 " > 4",
3612 " [EDITOR: 'another-filename.txt'] <== selected <== marked",
3613 " > C",
3614 " .dockerignore",
3615 " the-new-filename",
3616 ]
3617 );
3618
3619 let confirm = panel.update(cx, |panel, cx| {
3620 panel.filename_editor.update(cx, |editor, cx| {
3621 let file_name_selections = editor.selections.all::<usize>(cx);
3622 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
3623 let file_name_selection = &file_name_selections[0];
3624 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
3625 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
3626
3627 editor.set_text("a-different-filename.tar.gz", cx)
3628 });
3629 panel.confirm_edit(cx).unwrap()
3630 });
3631 assert_eq!(
3632 visible_entries_as_strings(&panel, 0..10, cx),
3633 &[
3634 "v root1",
3635 " > .git",
3636 " > a",
3637 " v b",
3638 " > 3",
3639 " > 4",
3640 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected <== marked",
3641 " > C",
3642 " .dockerignore",
3643 " the-new-filename",
3644 ]
3645 );
3646
3647 confirm.await.unwrap();
3648 assert_eq!(
3649 visible_entries_as_strings(&panel, 0..10, cx),
3650 &[
3651 "v root1",
3652 " > .git",
3653 " > a",
3654 " v b",
3655 " > 3",
3656 " > 4",
3657 " a-different-filename.tar.gz <== selected",
3658 " > C",
3659 " .dockerignore",
3660 " the-new-filename",
3661 ]
3662 );
3663
3664 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3665 assert_eq!(
3666 visible_entries_as_strings(&panel, 0..10, cx),
3667 &[
3668 "v root1",
3669 " > .git",
3670 " > a",
3671 " v b",
3672 " > 3",
3673 " > 4",
3674 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
3675 " > C",
3676 " .dockerignore",
3677 " the-new-filename",
3678 ]
3679 );
3680
3681 panel.update(cx, |panel, cx| {
3682 panel.filename_editor.update(cx, |editor, cx| {
3683 let file_name_selections = editor.selections.all::<usize>(cx);
3684 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
3685 let file_name_selection = &file_name_selections[0];
3686 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
3687 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..");
3688
3689 });
3690 panel.cancel(&menu::Cancel, cx)
3691 });
3692
3693 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
3694 assert_eq!(
3695 visible_entries_as_strings(&panel, 0..10, cx),
3696 &[
3697 "v root1",
3698 " > .git",
3699 " > a",
3700 " v b",
3701 " > 3",
3702 " > 4",
3703 " > [EDITOR: ''] <== selected",
3704 " a-different-filename.tar.gz",
3705 " > C",
3706 " .dockerignore",
3707 ]
3708 );
3709
3710 let confirm = panel.update(cx, |panel, cx| {
3711 panel
3712 .filename_editor
3713 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
3714 panel.confirm_edit(cx).unwrap()
3715 });
3716 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
3717 assert_eq!(
3718 visible_entries_as_strings(&panel, 0..10, cx),
3719 &[
3720 "v root1",
3721 " > .git",
3722 " > a",
3723 " v b",
3724 " > 3",
3725 " > 4",
3726 " > [PROCESSING: 'new-dir']",
3727 " a-different-filename.tar.gz <== selected",
3728 " > C",
3729 " .dockerignore",
3730 ]
3731 );
3732
3733 confirm.await.unwrap();
3734 assert_eq!(
3735 visible_entries_as_strings(&panel, 0..10, cx),
3736 &[
3737 "v root1",
3738 " > .git",
3739 " > a",
3740 " v b",
3741 " > 3",
3742 " > 4",
3743 " > new-dir",
3744 " a-different-filename.tar.gz <== selected",
3745 " > C",
3746 " .dockerignore",
3747 ]
3748 );
3749
3750 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
3751 assert_eq!(
3752 visible_entries_as_strings(&panel, 0..10, cx),
3753 &[
3754 "v root1",
3755 " > .git",
3756 " > a",
3757 " v b",
3758 " > 3",
3759 " > 4",
3760 " > new-dir",
3761 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
3762 " > C",
3763 " .dockerignore",
3764 ]
3765 );
3766
3767 // Dismiss the rename editor when it loses focus.
3768 workspace.update(cx, |_, cx| cx.blur()).unwrap();
3769 assert_eq!(
3770 visible_entries_as_strings(&panel, 0..10, cx),
3771 &[
3772 "v root1",
3773 " > .git",
3774 " > a",
3775 " v b",
3776 " > 3",
3777 " > 4",
3778 " > new-dir",
3779 " a-different-filename.tar.gz <== selected",
3780 " > C",
3781 " .dockerignore",
3782 ]
3783 );
3784 }
3785
3786 #[gpui::test(iterations = 10)]
3787 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
3788 init_test(cx);
3789
3790 let fs = FakeFs::new(cx.executor().clone());
3791 fs.insert_tree(
3792 "/root1",
3793 json!({
3794 ".dockerignore": "",
3795 ".git": {
3796 "HEAD": "",
3797 },
3798 "a": {
3799 "0": { "q": "", "r": "", "s": "" },
3800 "1": { "t": "", "u": "" },
3801 "2": { "v": "", "w": "", "x": "", "y": "" },
3802 },
3803 "b": {
3804 "3": { "Q": "" },
3805 "4": { "R": "", "S": "", "T": "", "U": "" },
3806 },
3807 "C": {
3808 "5": {},
3809 "6": { "V": "", "W": "" },
3810 "7": { "X": "" },
3811 "8": { "Y": {}, "Z": "" }
3812 }
3813 }),
3814 )
3815 .await;
3816 fs.insert_tree(
3817 "/root2",
3818 json!({
3819 "d": {
3820 "9": ""
3821 },
3822 "e": {}
3823 }),
3824 )
3825 .await;
3826
3827 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
3828 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3829 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3830 let panel = workspace
3831 .update(cx, |workspace, cx| {
3832 let panel = ProjectPanel::new(workspace, cx);
3833 workspace.add_panel(panel.clone(), cx);
3834 panel
3835 })
3836 .unwrap();
3837
3838 select_path(&panel, "root1", cx);
3839 assert_eq!(
3840 visible_entries_as_strings(&panel, 0..10, cx),
3841 &[
3842 "v root1 <== selected",
3843 " > .git",
3844 " > a",
3845 " > b",
3846 " > C",
3847 " .dockerignore",
3848 "v root2",
3849 " > d",
3850 " > e",
3851 ]
3852 );
3853
3854 // Add a file with the root folder selected. The filename editor is placed
3855 // before the first file in the root folder.
3856 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3857 panel.update(cx, |panel, cx| {
3858 assert!(panel.filename_editor.read(cx).is_focused(cx));
3859 });
3860 assert_eq!(
3861 visible_entries_as_strings(&panel, 0..10, cx),
3862 &[
3863 "v root1",
3864 " > .git",
3865 " > a",
3866 " > b",
3867 " > C",
3868 " [EDITOR: ''] <== selected",
3869 " .dockerignore",
3870 "v root2",
3871 " > d",
3872 " > e",
3873 ]
3874 );
3875
3876 let confirm = panel.update(cx, |panel, cx| {
3877 panel.filename_editor.update(cx, |editor, cx| {
3878 editor.set_text("/bdir1/dir2/the-new-filename", cx)
3879 });
3880 panel.confirm_edit(cx).unwrap()
3881 });
3882
3883 assert_eq!(
3884 visible_entries_as_strings(&panel, 0..10, cx),
3885 &[
3886 "v root1",
3887 " > .git",
3888 " > a",
3889 " > b",
3890 " > C",
3891 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
3892 " .dockerignore",
3893 "v root2",
3894 " > d",
3895 " > e",
3896 ]
3897 );
3898
3899 confirm.await.unwrap();
3900 assert_eq!(
3901 visible_entries_as_strings(&panel, 0..13, cx),
3902 &[
3903 "v root1",
3904 " > .git",
3905 " > a",
3906 " > b",
3907 " v bdir1",
3908 " v dir2",
3909 " the-new-filename <== selected <== marked",
3910 " > C",
3911 " .dockerignore",
3912 "v root2",
3913 " > d",
3914 " > e",
3915 ]
3916 );
3917 }
3918
3919 #[gpui::test]
3920 async fn test_adding_directory_via_file(cx: &mut gpui::TestAppContext) {
3921 init_test(cx);
3922
3923 let fs = FakeFs::new(cx.executor().clone());
3924 fs.insert_tree(
3925 "/root1",
3926 json!({
3927 ".dockerignore": "",
3928 ".git": {
3929 "HEAD": "",
3930 },
3931 }),
3932 )
3933 .await;
3934
3935 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
3936 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3937 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3938 let panel = workspace
3939 .update(cx, |workspace, cx| {
3940 let panel = ProjectPanel::new(workspace, cx);
3941 workspace.add_panel(panel.clone(), cx);
3942 panel
3943 })
3944 .unwrap();
3945
3946 select_path(&panel, "root1", cx);
3947 assert_eq!(
3948 visible_entries_as_strings(&panel, 0..10, cx),
3949 &["v root1 <== selected", " > .git", " .dockerignore",]
3950 );
3951
3952 // Add a file with the root folder selected. The filename editor is placed
3953 // before the first file in the root folder.
3954 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3955 panel.update(cx, |panel, cx| {
3956 assert!(panel.filename_editor.read(cx).is_focused(cx));
3957 });
3958 assert_eq!(
3959 visible_entries_as_strings(&panel, 0..10, cx),
3960 &[
3961 "v root1",
3962 " > .git",
3963 " [EDITOR: ''] <== selected",
3964 " .dockerignore",
3965 ]
3966 );
3967
3968 let confirm = panel.update(cx, |panel, cx| {
3969 panel
3970 .filename_editor
3971 .update(cx, |editor, cx| editor.set_text("/new_dir/", cx));
3972 panel.confirm_edit(cx).unwrap()
3973 });
3974
3975 assert_eq!(
3976 visible_entries_as_strings(&panel, 0..10, cx),
3977 &[
3978 "v root1",
3979 " > .git",
3980 " [PROCESSING: '/new_dir/'] <== selected",
3981 " .dockerignore",
3982 ]
3983 );
3984
3985 confirm.await.unwrap();
3986 assert_eq!(
3987 visible_entries_as_strings(&panel, 0..13, cx),
3988 &[
3989 "v root1",
3990 " > .git",
3991 " v new_dir <== selected",
3992 " .dockerignore",
3993 ]
3994 );
3995 }
3996
3997 #[gpui::test]
3998 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
3999 init_test(cx);
4000
4001 let fs = FakeFs::new(cx.executor().clone());
4002 fs.insert_tree(
4003 "/root1",
4004 json!({
4005 "one.two.txt": "",
4006 "one.txt": ""
4007 }),
4008 )
4009 .await;
4010
4011 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
4012 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4013 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4014 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4015
4016 panel.update(cx, |panel, cx| {
4017 panel.select_next(&Default::default(), cx);
4018 panel.select_next(&Default::default(), cx);
4019 });
4020
4021 assert_eq!(
4022 visible_entries_as_strings(&panel, 0..50, cx),
4023 &[
4024 //
4025 "v root1",
4026 " one.txt <== selected",
4027 " one.two.txt",
4028 ]
4029 );
4030
4031 // Regression test - file name is created correctly when
4032 // the copied file's name contains multiple dots.
4033 panel.update(cx, |panel, cx| {
4034 panel.copy(&Default::default(), cx);
4035 panel.paste(&Default::default(), cx);
4036 });
4037 cx.executor().run_until_parked();
4038
4039 assert_eq!(
4040 visible_entries_as_strings(&panel, 0..50, cx),
4041 &[
4042 //
4043 "v root1",
4044 " one.txt",
4045 " one copy.txt <== selected",
4046 " one.two.txt",
4047 ]
4048 );
4049
4050 panel.update(cx, |panel, cx| {
4051 panel.paste(&Default::default(), cx);
4052 });
4053 cx.executor().run_until_parked();
4054
4055 assert_eq!(
4056 visible_entries_as_strings(&panel, 0..50, cx),
4057 &[
4058 //
4059 "v root1",
4060 " one.txt",
4061 " one copy.txt",
4062 " one copy 1.txt <== selected",
4063 " one.two.txt",
4064 ]
4065 );
4066 }
4067
4068 #[gpui::test]
4069 async fn test_cut_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
4070 init_test(cx);
4071
4072 let fs = FakeFs::new(cx.executor().clone());
4073 fs.insert_tree(
4074 "/root1",
4075 json!({
4076 "one.txt": "",
4077 "two.txt": "",
4078 "three.txt": "",
4079 "a": {
4080 "0": { "q": "", "r": "", "s": "" },
4081 "1": { "t": "", "u": "" },
4082 "2": { "v": "", "w": "", "x": "", "y": "" },
4083 },
4084 }),
4085 )
4086 .await;
4087
4088 fs.insert_tree(
4089 "/root2",
4090 json!({
4091 "one.txt": "",
4092 "two.txt": "",
4093 "four.txt": "",
4094 "b": {
4095 "3": { "Q": "" },
4096 "4": { "R": "", "S": "", "T": "", "U": "" },
4097 },
4098 }),
4099 )
4100 .await;
4101
4102 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4103 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4104 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4105 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4106
4107 select_path(&panel, "root1/three.txt", cx);
4108 panel.update(cx, |panel, cx| {
4109 panel.cut(&Default::default(), cx);
4110 });
4111
4112 select_path(&panel, "root2/one.txt", cx);
4113 panel.update(cx, |panel, cx| {
4114 panel.select_next(&Default::default(), cx);
4115 panel.paste(&Default::default(), cx);
4116 });
4117 cx.executor().run_until_parked();
4118 assert_eq!(
4119 visible_entries_as_strings(&panel, 0..50, cx),
4120 &[
4121 //
4122 "v root1",
4123 " > a",
4124 " one.txt",
4125 " two.txt",
4126 "v root2",
4127 " > b",
4128 " four.txt",
4129 " one.txt",
4130 " three.txt <== selected",
4131 " two.txt",
4132 ]
4133 );
4134
4135 select_path(&panel, "root1/a", cx);
4136 panel.update(cx, |panel, cx| {
4137 panel.cut(&Default::default(), cx);
4138 });
4139 select_path(&panel, "root2/two.txt", cx);
4140 panel.update(cx, |panel, cx| {
4141 panel.select_next(&Default::default(), cx);
4142 panel.paste(&Default::default(), cx);
4143 });
4144
4145 cx.executor().run_until_parked();
4146 assert_eq!(
4147 visible_entries_as_strings(&panel, 0..50, cx),
4148 &[
4149 //
4150 "v root1",
4151 " one.txt",
4152 " two.txt",
4153 "v root2",
4154 " > a <== selected",
4155 " > b",
4156 " four.txt",
4157 " one.txt",
4158 " three.txt",
4159 " two.txt",
4160 ]
4161 );
4162 }
4163
4164 #[gpui::test]
4165 async fn test_copy_paste_between_different_worktrees(cx: &mut gpui::TestAppContext) {
4166 init_test(cx);
4167
4168 let fs = FakeFs::new(cx.executor().clone());
4169 fs.insert_tree(
4170 "/root1",
4171 json!({
4172 "one.txt": "",
4173 "two.txt": "",
4174 "three.txt": "",
4175 "a": {
4176 "0": { "q": "", "r": "", "s": "" },
4177 "1": { "t": "", "u": "" },
4178 "2": { "v": "", "w": "", "x": "", "y": "" },
4179 },
4180 }),
4181 )
4182 .await;
4183
4184 fs.insert_tree(
4185 "/root2",
4186 json!({
4187 "one.txt": "",
4188 "two.txt": "",
4189 "four.txt": "",
4190 "b": {
4191 "3": { "Q": "" },
4192 "4": { "R": "", "S": "", "T": "", "U": "" },
4193 },
4194 }),
4195 )
4196 .await;
4197
4198 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
4199 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4200 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4201 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4202
4203 select_path(&panel, "root1/three.txt", cx);
4204 panel.update(cx, |panel, cx| {
4205 panel.copy(&Default::default(), cx);
4206 });
4207
4208 select_path(&panel, "root2/one.txt", cx);
4209 panel.update(cx, |panel, cx| {
4210 panel.select_next(&Default::default(), cx);
4211 panel.paste(&Default::default(), cx);
4212 });
4213 cx.executor().run_until_parked();
4214 assert_eq!(
4215 visible_entries_as_strings(&panel, 0..50, cx),
4216 &[
4217 //
4218 "v root1",
4219 " > a",
4220 " one.txt",
4221 " three.txt",
4222 " two.txt",
4223 "v root2",
4224 " > b",
4225 " four.txt",
4226 " one.txt",
4227 " three.txt <== selected",
4228 " two.txt",
4229 ]
4230 );
4231
4232 select_path(&panel, "root1/three.txt", cx);
4233 panel.update(cx, |panel, cx| {
4234 panel.copy(&Default::default(), cx);
4235 });
4236 select_path(&panel, "root2/two.txt", cx);
4237 panel.update(cx, |panel, cx| {
4238 panel.select_next(&Default::default(), cx);
4239 panel.paste(&Default::default(), cx);
4240 });
4241
4242 cx.executor().run_until_parked();
4243 assert_eq!(
4244 visible_entries_as_strings(&panel, 0..50, cx),
4245 &[
4246 //
4247 "v root1",
4248 " > a",
4249 " one.txt",
4250 " three.txt",
4251 " two.txt",
4252 "v root2",
4253 " > b",
4254 " four.txt",
4255 " one.txt",
4256 " three.txt",
4257 " three copy.txt <== selected",
4258 " two.txt",
4259 ]
4260 );
4261
4262 select_path(&panel, "root1/a", cx);
4263 panel.update(cx, |panel, cx| {
4264 panel.copy(&Default::default(), cx);
4265 });
4266 select_path(&panel, "root2/two.txt", cx);
4267 panel.update(cx, |panel, cx| {
4268 panel.select_next(&Default::default(), cx);
4269 panel.paste(&Default::default(), cx);
4270 });
4271
4272 cx.executor().run_until_parked();
4273 assert_eq!(
4274 visible_entries_as_strings(&panel, 0..50, cx),
4275 &[
4276 //
4277 "v root1",
4278 " > a",
4279 " one.txt",
4280 " three.txt",
4281 " two.txt",
4282 "v root2",
4283 " > a <== selected",
4284 " > b",
4285 " four.txt",
4286 " one.txt",
4287 " three.txt",
4288 " three copy.txt",
4289 " two.txt",
4290 ]
4291 );
4292 }
4293
4294 #[gpui::test]
4295 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
4296 init_test(cx);
4297
4298 let fs = FakeFs::new(cx.executor().clone());
4299 fs.insert_tree(
4300 "/root",
4301 json!({
4302 "a": {
4303 "one.txt": "",
4304 "two.txt": "",
4305 "inner_dir": {
4306 "three.txt": "",
4307 "four.txt": "",
4308 }
4309 },
4310 "b": {}
4311 }),
4312 )
4313 .await;
4314
4315 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
4316 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4317 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4318 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4319
4320 select_path(&panel, "root/a", cx);
4321 panel.update(cx, |panel, cx| {
4322 panel.copy(&Default::default(), cx);
4323 panel.select_next(&Default::default(), cx);
4324 panel.paste(&Default::default(), cx);
4325 });
4326 cx.executor().run_until_parked();
4327
4328 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
4329 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
4330
4331 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
4332 assert_ne!(
4333 pasted_dir_file, None,
4334 "Pasted directory file should have an entry"
4335 );
4336
4337 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
4338 assert_ne!(
4339 pasted_dir_inner_dir, None,
4340 "Directories inside pasted directory should have an entry"
4341 );
4342
4343 toggle_expand_dir(&panel, "root/b/a", cx);
4344 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
4345
4346 assert_eq!(
4347 visible_entries_as_strings(&panel, 0..50, cx),
4348 &[
4349 //
4350 "v root",
4351 " > a",
4352 " v b",
4353 " v a",
4354 " v inner_dir <== selected",
4355 " four.txt",
4356 " three.txt",
4357 " one.txt",
4358 " two.txt",
4359 ]
4360 );
4361
4362 select_path(&panel, "root", cx);
4363 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
4364 cx.executor().run_until_parked();
4365 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
4366 cx.executor().run_until_parked();
4367 assert_eq!(
4368 visible_entries_as_strings(&panel, 0..50, cx),
4369 &[
4370 //
4371 "v root",
4372 " > a",
4373 " v a copy",
4374 " > a <== selected",
4375 " > inner_dir",
4376 " one.txt",
4377 " two.txt",
4378 " v b",
4379 " v a",
4380 " v inner_dir",
4381 " four.txt",
4382 " three.txt",
4383 " one.txt",
4384 " two.txt"
4385 ]
4386 );
4387 }
4388
4389 #[gpui::test]
4390 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
4391 init_test_with_editor(cx);
4392
4393 let fs = FakeFs::new(cx.executor().clone());
4394 fs.insert_tree(
4395 "/src",
4396 json!({
4397 "test": {
4398 "first.rs": "// First Rust file",
4399 "second.rs": "// Second Rust file",
4400 "third.rs": "// Third Rust file",
4401 }
4402 }),
4403 )
4404 .await;
4405
4406 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4407 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4408 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4409 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4410
4411 toggle_expand_dir(&panel, "src/test", cx);
4412 select_path(&panel, "src/test/first.rs", cx);
4413 panel.update(cx, |panel, cx| panel.open(&Open, cx));
4414 cx.executor().run_until_parked();
4415 assert_eq!(
4416 visible_entries_as_strings(&panel, 0..10, cx),
4417 &[
4418 "v src",
4419 " v test",
4420 " first.rs <== selected",
4421 " second.rs",
4422 " third.rs"
4423 ]
4424 );
4425 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
4426
4427 submit_deletion(&panel, cx);
4428 assert_eq!(
4429 visible_entries_as_strings(&panel, 0..10, cx),
4430 &[
4431 "v src",
4432 " v test",
4433 " second.rs",
4434 " third.rs"
4435 ],
4436 "Project panel should have no deleted file, no other file is selected in it"
4437 );
4438 ensure_no_open_items_and_panes(&workspace, cx);
4439
4440 select_path(&panel, "src/test/second.rs", cx);
4441 panel.update(cx, |panel, cx| panel.open(&Open, cx));
4442 cx.executor().run_until_parked();
4443 assert_eq!(
4444 visible_entries_as_strings(&panel, 0..10, cx),
4445 &[
4446 "v src",
4447 " v test",
4448 " second.rs <== selected",
4449 " third.rs"
4450 ]
4451 );
4452 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
4453
4454 workspace
4455 .update(cx, |workspace, cx| {
4456 let active_items = workspace
4457 .panes()
4458 .iter()
4459 .filter_map(|pane| pane.read(cx).active_item())
4460 .collect::<Vec<_>>();
4461 assert_eq!(active_items.len(), 1);
4462 let open_editor = active_items
4463 .into_iter()
4464 .next()
4465 .unwrap()
4466 .downcast::<Editor>()
4467 .expect("Open item should be an editor");
4468 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
4469 })
4470 .unwrap();
4471 submit_deletion_skipping_prompt(&panel, cx);
4472 assert_eq!(
4473 visible_entries_as_strings(&panel, 0..10, cx),
4474 &["v src", " v test", " third.rs"],
4475 "Project panel should have no deleted file, with one last file remaining"
4476 );
4477 ensure_no_open_items_and_panes(&workspace, cx);
4478 }
4479
4480 #[gpui::test]
4481 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
4482 init_test_with_editor(cx);
4483
4484 let fs = FakeFs::new(cx.executor().clone());
4485 fs.insert_tree(
4486 "/src",
4487 json!({
4488 "test": {
4489 "first.rs": "// First Rust file",
4490 "second.rs": "// Second Rust file",
4491 "third.rs": "// Third Rust file",
4492 }
4493 }),
4494 )
4495 .await;
4496
4497 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
4498 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4499 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4500 let panel = workspace
4501 .update(cx, |workspace, cx| {
4502 let panel = ProjectPanel::new(workspace, cx);
4503 workspace.add_panel(panel.clone(), cx);
4504 panel
4505 })
4506 .unwrap();
4507
4508 select_path(&panel, "src/", cx);
4509 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
4510 cx.executor().run_until_parked();
4511 assert_eq!(
4512 visible_entries_as_strings(&panel, 0..10, cx),
4513 &[
4514 //
4515 "v src <== selected",
4516 " > test"
4517 ]
4518 );
4519 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
4520 panel.update(cx, |panel, cx| {
4521 assert!(panel.filename_editor.read(cx).is_focused(cx));
4522 });
4523 assert_eq!(
4524 visible_entries_as_strings(&panel, 0..10, cx),
4525 &[
4526 //
4527 "v src",
4528 " > [EDITOR: ''] <== selected",
4529 " > test"
4530 ]
4531 );
4532 panel.update(cx, |panel, cx| {
4533 panel
4534 .filename_editor
4535 .update(cx, |editor, cx| editor.set_text("test", cx));
4536 assert!(
4537 panel.confirm_edit(cx).is_none(),
4538 "Should not allow to confirm on conflicting new directory name"
4539 )
4540 });
4541 assert_eq!(
4542 visible_entries_as_strings(&panel, 0..10, cx),
4543 &[
4544 //
4545 "v src",
4546 " > test"
4547 ],
4548 "File list should be unchanged after failed folder create confirmation"
4549 );
4550
4551 select_path(&panel, "src/test/", cx);
4552 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
4553 cx.executor().run_until_parked();
4554 assert_eq!(
4555 visible_entries_as_strings(&panel, 0..10, cx),
4556 &[
4557 //
4558 "v src",
4559 " > test <== selected"
4560 ]
4561 );
4562 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
4563 panel.update(cx, |panel, cx| {
4564 assert!(panel.filename_editor.read(cx).is_focused(cx));
4565 });
4566 assert_eq!(
4567 visible_entries_as_strings(&panel, 0..10, cx),
4568 &[
4569 "v src",
4570 " v test",
4571 " [EDITOR: ''] <== selected",
4572 " first.rs",
4573 " second.rs",
4574 " third.rs"
4575 ]
4576 );
4577 panel.update(cx, |panel, cx| {
4578 panel
4579 .filename_editor
4580 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
4581 assert!(
4582 panel.confirm_edit(cx).is_none(),
4583 "Should not allow to confirm on conflicting new file name"
4584 )
4585 });
4586 assert_eq!(
4587 visible_entries_as_strings(&panel, 0..10, cx),
4588 &[
4589 "v src",
4590 " v test",
4591 " first.rs",
4592 " second.rs",
4593 " third.rs"
4594 ],
4595 "File list should be unchanged after failed file create confirmation"
4596 );
4597
4598 select_path(&panel, "src/test/first.rs", cx);
4599 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
4600 cx.executor().run_until_parked();
4601 assert_eq!(
4602 visible_entries_as_strings(&panel, 0..10, cx),
4603 &[
4604 "v src",
4605 " v test",
4606 " first.rs <== selected",
4607 " second.rs",
4608 " third.rs"
4609 ],
4610 );
4611 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4612 panel.update(cx, |panel, cx| {
4613 assert!(panel.filename_editor.read(cx).is_focused(cx));
4614 });
4615 assert_eq!(
4616 visible_entries_as_strings(&panel, 0..10, cx),
4617 &[
4618 "v src",
4619 " v test",
4620 " [EDITOR: 'first.rs'] <== selected",
4621 " second.rs",
4622 " third.rs"
4623 ]
4624 );
4625 panel.update(cx, |panel, cx| {
4626 panel
4627 .filename_editor
4628 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
4629 assert!(
4630 panel.confirm_edit(cx).is_none(),
4631 "Should not allow to confirm on conflicting file rename"
4632 )
4633 });
4634 assert_eq!(
4635 visible_entries_as_strings(&panel, 0..10, cx),
4636 &[
4637 "v src",
4638 " v test",
4639 " first.rs <== selected",
4640 " second.rs",
4641 " third.rs"
4642 ],
4643 "File list should be unchanged after failed rename confirmation"
4644 );
4645 }
4646
4647 #[gpui::test]
4648 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
4649 init_test_with_editor(cx);
4650
4651 let fs = FakeFs::new(cx.executor().clone());
4652 fs.insert_tree(
4653 "/project_root",
4654 json!({
4655 "dir_1": {
4656 "nested_dir": {
4657 "file_a.py": "# File contents",
4658 }
4659 },
4660 "file_1.py": "# File contents",
4661 }),
4662 )
4663 .await;
4664
4665 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4666 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4667 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4668 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4669
4670 panel.update(cx, |panel, cx| panel.open(&Open, cx));
4671 cx.executor().run_until_parked();
4672 select_path(&panel, "project_root/dir_1", cx);
4673 panel.update(cx, |panel, cx| panel.open(&Open, cx));
4674 select_path(&panel, "project_root/dir_1/nested_dir", cx);
4675 panel.update(cx, |panel, cx| panel.open(&Open, cx));
4676 panel.update(cx, |panel, cx| panel.open(&Open, cx));
4677 cx.executor().run_until_parked();
4678 assert_eq!(
4679 visible_entries_as_strings(&panel, 0..10, cx),
4680 &[
4681 "v project_root",
4682 " v dir_1",
4683 " > nested_dir <== selected",
4684 " file_1.py",
4685 ]
4686 );
4687 }
4688
4689 #[gpui::test]
4690 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
4691 init_test_with_editor(cx);
4692
4693 let fs = FakeFs::new(cx.executor().clone());
4694 fs.insert_tree(
4695 "/project_root",
4696 json!({
4697 "dir_1": {
4698 "nested_dir": {
4699 "file_a.py": "# File contents",
4700 "file_b.py": "# File contents",
4701 "file_c.py": "# File contents",
4702 },
4703 "file_1.py": "# File contents",
4704 "file_2.py": "# File contents",
4705 "file_3.py": "# File contents",
4706 },
4707 "dir_2": {
4708 "file_1.py": "# File contents",
4709 "file_2.py": "# File contents",
4710 "file_3.py": "# File contents",
4711 }
4712 }),
4713 )
4714 .await;
4715
4716 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4717 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4718 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4719 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4720
4721 panel.update(cx, |panel, cx| {
4722 panel.collapse_all_entries(&CollapseAllEntries, cx)
4723 });
4724 cx.executor().run_until_parked();
4725 assert_eq!(
4726 visible_entries_as_strings(&panel, 0..10, cx),
4727 &["v project_root", " > dir_1", " > dir_2",]
4728 );
4729
4730 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
4731 toggle_expand_dir(&panel, "project_root/dir_1", cx);
4732 cx.executor().run_until_parked();
4733 assert_eq!(
4734 visible_entries_as_strings(&panel, 0..10, cx),
4735 &[
4736 "v project_root",
4737 " v dir_1 <== selected",
4738 " > nested_dir",
4739 " file_1.py",
4740 " file_2.py",
4741 " file_3.py",
4742 " > dir_2",
4743 ]
4744 );
4745 }
4746
4747 #[gpui::test]
4748 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
4749 init_test(cx);
4750
4751 let fs = FakeFs::new(cx.executor().clone());
4752 fs.as_fake().insert_tree("/root", json!({})).await;
4753 let project = Project::test(fs, ["/root".as_ref()], cx).await;
4754 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4755 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4756 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4757
4758 // Make a new buffer with no backing file
4759 workspace
4760 .update(cx, |workspace, cx| {
4761 Editor::new_file(workspace, &Default::default(), cx)
4762 })
4763 .unwrap();
4764
4765 cx.executor().run_until_parked();
4766
4767 // "Save as" the buffer, creating a new backing file for it
4768 let save_task = workspace
4769 .update(cx, |workspace, cx| {
4770 workspace.save_active_item(workspace::SaveIntent::Save, cx)
4771 })
4772 .unwrap();
4773
4774 cx.executor().run_until_parked();
4775 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
4776 save_task.await.unwrap();
4777
4778 // Rename the file
4779 select_path(&panel, "root/new", cx);
4780 assert_eq!(
4781 visible_entries_as_strings(&panel, 0..10, cx),
4782 &["v root", " new <== selected"]
4783 );
4784 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
4785 panel.update(cx, |panel, cx| {
4786 panel
4787 .filename_editor
4788 .update(cx, |editor, cx| editor.set_text("newer", cx));
4789 });
4790 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
4791
4792 cx.executor().run_until_parked();
4793 assert_eq!(
4794 visible_entries_as_strings(&panel, 0..10, cx),
4795 &["v root", " newer <== selected"]
4796 );
4797
4798 workspace
4799 .update(cx, |workspace, cx| {
4800 workspace.save_active_item(workspace::SaveIntent::Save, cx)
4801 })
4802 .unwrap()
4803 .await
4804 .unwrap();
4805
4806 cx.executor().run_until_parked();
4807 // assert that saving the file doesn't restore "new"
4808 assert_eq!(
4809 visible_entries_as_strings(&panel, 0..10, cx),
4810 &["v root", " newer <== selected"]
4811 );
4812 }
4813
4814 #[gpui::test]
4815 async fn test_multiple_marked_entries(cx: &mut gpui::TestAppContext) {
4816 init_test_with_editor(cx);
4817 let fs = FakeFs::new(cx.executor().clone());
4818 fs.insert_tree(
4819 "/project_root",
4820 json!({
4821 "dir_1": {
4822 "nested_dir": {
4823 "file_a.py": "# File contents",
4824 }
4825 },
4826 "file_1.py": "# File contents",
4827 }),
4828 )
4829 .await;
4830
4831 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
4832 let worktree_id =
4833 cx.update(|cx| project.read(cx).worktrees(cx).next().unwrap().read(cx).id());
4834 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
4835 let cx = &mut VisualTestContext::from_window(*workspace, cx);
4836 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
4837 cx.update(|cx| {
4838 panel.update(cx, |this, cx| {
4839 this.select_next(&Default::default(), cx);
4840 this.expand_selected_entry(&Default::default(), cx);
4841 this.expand_selected_entry(&Default::default(), cx);
4842 this.select_next(&Default::default(), cx);
4843 this.expand_selected_entry(&Default::default(), cx);
4844 this.select_next(&Default::default(), cx);
4845 })
4846 });
4847 assert_eq!(
4848 visible_entries_as_strings(&panel, 0..10, cx),
4849 &[
4850 "v project_root",
4851 " v dir_1",
4852 " v nested_dir",
4853 " file_a.py <== selected",
4854 " file_1.py",
4855 ]
4856 );
4857 let modifiers_with_shift = gpui::Modifiers {
4858 shift: true,
4859 ..Default::default()
4860 };
4861 cx.simulate_modifiers_change(modifiers_with_shift);
4862 cx.update(|cx| {
4863 panel.update(cx, |this, cx| {
4864 this.select_next(&Default::default(), cx);
4865 })
4866 });
4867 assert_eq!(
4868 visible_entries_as_strings(&panel, 0..10, cx),
4869 &[
4870 "v project_root",
4871 " v dir_1",
4872 " v nested_dir",
4873 " file_a.py",
4874 " file_1.py <== selected <== marked",
4875 ]
4876 );
4877 cx.update(|cx| {
4878 panel.update(cx, |this, cx| {
4879 this.select_prev(&Default::default(), cx);
4880 })
4881 });
4882 assert_eq!(
4883 visible_entries_as_strings(&panel, 0..10, cx),
4884 &[
4885 "v project_root",
4886 " v dir_1",
4887 " v nested_dir",
4888 " file_a.py <== selected <== marked",
4889 " file_1.py <== marked",
4890 ]
4891 );
4892 cx.update(|cx| {
4893 panel.update(cx, |this, cx| {
4894 let drag = DraggedSelection {
4895 active_selection: this.selection.unwrap(),
4896 marked_selections: Arc::new(this.marked_entries.clone()),
4897 };
4898 let target_entry = this
4899 .project
4900 .read(cx)
4901 .entry_for_path(&(worktree_id, "").into(), cx)
4902 .unwrap();
4903 this.drag_onto(&drag, target_entry.id, false, cx);
4904 });
4905 });
4906 cx.run_until_parked();
4907 assert_eq!(
4908 visible_entries_as_strings(&panel, 0..10, cx),
4909 &[
4910 "v project_root",
4911 " v dir_1",
4912 " v nested_dir",
4913 " file_1.py <== marked",
4914 " file_a.py <== selected <== marked",
4915 ]
4916 );
4917 // ESC clears out all marks
4918 cx.update(|cx| {
4919 panel.update(cx, |this, cx| {
4920 this.cancel(&menu::Cancel, cx);
4921 })
4922 });
4923 assert_eq!(
4924 visible_entries_as_strings(&panel, 0..10, cx),
4925 &[
4926 "v project_root",
4927 " v dir_1",
4928 " v nested_dir",
4929 " file_1.py",
4930 " file_a.py <== selected",
4931 ]
4932 );
4933 // ESC clears out all marks
4934 cx.update(|cx| {
4935 panel.update(cx, |this, cx| {
4936 this.select_prev(&SelectPrev, cx);
4937 this.select_next(&SelectNext, cx);
4938 })
4939 });
4940 assert_eq!(
4941 visible_entries_as_strings(&panel, 0..10, cx),
4942 &[
4943 "v project_root",
4944 " v dir_1",
4945 " v nested_dir",
4946 " file_1.py <== marked",
4947 " file_a.py <== selected <== marked",
4948 ]
4949 );
4950 cx.simulate_modifiers_change(Default::default());
4951 cx.update(|cx| {
4952 panel.update(cx, |this, cx| {
4953 this.cut(&Cut, cx);
4954 this.select_prev(&SelectPrev, cx);
4955 this.select_prev(&SelectPrev, cx);
4956
4957 this.paste(&Paste, cx);
4958 // this.expand_selected_entry(&ExpandSelectedEntry, cx);
4959 })
4960 });
4961 cx.run_until_parked();
4962 assert_eq!(
4963 visible_entries_as_strings(&panel, 0..10, cx),
4964 &[
4965 "v project_root",
4966 " v dir_1",
4967 " v nested_dir",
4968 " file_1.py <== marked",
4969 " file_a.py <== selected <== marked",
4970 ]
4971 );
4972 cx.simulate_modifiers_change(modifiers_with_shift);
4973 cx.update(|cx| {
4974 panel.update(cx, |this, cx| {
4975 this.expand_selected_entry(&Default::default(), cx);
4976 this.select_next(&SelectNext, cx);
4977 this.select_next(&SelectNext, cx);
4978 })
4979 });
4980 submit_deletion(&panel, cx);
4981 assert_eq!(
4982 visible_entries_as_strings(&panel, 0..10, cx),
4983 &["v project_root", " v dir_1", " v nested_dir",]
4984 );
4985 }
4986 #[gpui::test]
4987 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
4988 init_test_with_editor(cx);
4989 cx.update(|cx| {
4990 cx.update_global::<SettingsStore, _>(|store, cx| {
4991 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
4992 worktree_settings.file_scan_exclusions = Some(Vec::new());
4993 });
4994 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
4995 project_panel_settings.auto_reveal_entries = Some(false)
4996 });
4997 })
4998 });
4999
5000 let fs = FakeFs::new(cx.background_executor.clone());
5001 fs.insert_tree(
5002 "/project_root",
5003 json!({
5004 ".git": {},
5005 ".gitignore": "**/gitignored_dir",
5006 "dir_1": {
5007 "file_1.py": "# File 1_1 contents",
5008 "file_2.py": "# File 1_2 contents",
5009 "file_3.py": "# File 1_3 contents",
5010 "gitignored_dir": {
5011 "file_a.py": "# File contents",
5012 "file_b.py": "# File contents",
5013 "file_c.py": "# File contents",
5014 },
5015 },
5016 "dir_2": {
5017 "file_1.py": "# File 2_1 contents",
5018 "file_2.py": "# File 2_2 contents",
5019 "file_3.py": "# File 2_3 contents",
5020 }
5021 }),
5022 )
5023 .await;
5024
5025 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5026 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5027 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5028 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5029
5030 assert_eq!(
5031 visible_entries_as_strings(&panel, 0..20, cx),
5032 &[
5033 "v project_root",
5034 " > .git",
5035 " > dir_1",
5036 " > dir_2",
5037 " .gitignore",
5038 ]
5039 );
5040
5041 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
5042 .expect("dir 1 file is not ignored and should have an entry");
5043 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
5044 .expect("dir 2 file is not ignored and should have an entry");
5045 let gitignored_dir_file =
5046 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5047 assert_eq!(
5048 gitignored_dir_file, None,
5049 "File in the gitignored dir should not have an entry before its dir is toggled"
5050 );
5051
5052 toggle_expand_dir(&panel, "project_root/dir_1", cx);
5053 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5054 cx.executor().run_until_parked();
5055 assert_eq!(
5056 visible_entries_as_strings(&panel, 0..20, cx),
5057 &[
5058 "v project_root",
5059 " > .git",
5060 " v dir_1",
5061 " v gitignored_dir <== selected",
5062 " file_a.py",
5063 " file_b.py",
5064 " file_c.py",
5065 " file_1.py",
5066 " file_2.py",
5067 " file_3.py",
5068 " > dir_2",
5069 " .gitignore",
5070 ],
5071 "Should show gitignored dir file list in the project panel"
5072 );
5073 let gitignored_dir_file =
5074 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
5075 .expect("after gitignored dir got opened, a file entry should be present");
5076
5077 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5078 toggle_expand_dir(&panel, "project_root/dir_1", cx);
5079 assert_eq!(
5080 visible_entries_as_strings(&panel, 0..20, cx),
5081 &[
5082 "v project_root",
5083 " > .git",
5084 " > dir_1 <== selected",
5085 " > dir_2",
5086 " .gitignore",
5087 ],
5088 "Should hide all dir contents again and prepare for the auto reveal test"
5089 );
5090
5091 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
5092 panel.update(cx, |panel, cx| {
5093 panel.project.update(cx, |_, cx| {
5094 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
5095 })
5096 });
5097 cx.run_until_parked();
5098 assert_eq!(
5099 visible_entries_as_strings(&panel, 0..20, cx),
5100 &[
5101 "v project_root",
5102 " > .git",
5103 " > dir_1 <== selected",
5104 " > dir_2",
5105 " .gitignore",
5106 ],
5107 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
5108 );
5109 }
5110
5111 cx.update(|cx| {
5112 cx.update_global::<SettingsStore, _>(|store, cx| {
5113 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5114 project_panel_settings.auto_reveal_entries = Some(true)
5115 });
5116 })
5117 });
5118
5119 panel.update(cx, |panel, cx| {
5120 panel.project.update(cx, |_, cx| {
5121 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
5122 })
5123 });
5124 cx.run_until_parked();
5125 assert_eq!(
5126 visible_entries_as_strings(&panel, 0..20, cx),
5127 &[
5128 "v project_root",
5129 " > .git",
5130 " v dir_1",
5131 " > gitignored_dir",
5132 " file_1.py <== selected",
5133 " file_2.py",
5134 " file_3.py",
5135 " > dir_2",
5136 " .gitignore",
5137 ],
5138 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
5139 );
5140
5141 panel.update(cx, |panel, cx| {
5142 panel.project.update(cx, |_, cx| {
5143 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
5144 })
5145 });
5146 cx.run_until_parked();
5147 assert_eq!(
5148 visible_entries_as_strings(&panel, 0..20, cx),
5149 &[
5150 "v project_root",
5151 " > .git",
5152 " v dir_1",
5153 " > gitignored_dir",
5154 " file_1.py",
5155 " file_2.py",
5156 " file_3.py",
5157 " v dir_2",
5158 " file_1.py <== selected",
5159 " file_2.py",
5160 " file_3.py",
5161 " .gitignore",
5162 ],
5163 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
5164 );
5165
5166 panel.update(cx, |panel, cx| {
5167 panel.project.update(cx, |_, cx| {
5168 cx.emit(project::Event::ActiveEntryChanged(Some(
5169 gitignored_dir_file,
5170 )))
5171 })
5172 });
5173 cx.run_until_parked();
5174 assert_eq!(
5175 visible_entries_as_strings(&panel, 0..20, cx),
5176 &[
5177 "v project_root",
5178 " > .git",
5179 " v dir_1",
5180 " > gitignored_dir",
5181 " file_1.py",
5182 " file_2.py",
5183 " file_3.py",
5184 " v dir_2",
5185 " file_1.py <== selected",
5186 " file_2.py",
5187 " file_3.py",
5188 " .gitignore",
5189 ],
5190 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
5191 );
5192
5193 panel.update(cx, |panel, cx| {
5194 panel.project.update(cx, |_, cx| {
5195 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
5196 })
5197 });
5198 cx.run_until_parked();
5199 assert_eq!(
5200 visible_entries_as_strings(&panel, 0..20, cx),
5201 &[
5202 "v project_root",
5203 " > .git",
5204 " v dir_1",
5205 " v gitignored_dir",
5206 " file_a.py <== selected",
5207 " file_b.py",
5208 " file_c.py",
5209 " file_1.py",
5210 " file_2.py",
5211 " file_3.py",
5212 " v dir_2",
5213 " file_1.py",
5214 " file_2.py",
5215 " file_3.py",
5216 " .gitignore",
5217 ],
5218 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
5219 );
5220 }
5221
5222 #[gpui::test]
5223 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
5224 init_test_with_editor(cx);
5225 cx.update(|cx| {
5226 cx.update_global::<SettingsStore, _>(|store, cx| {
5227 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5228 worktree_settings.file_scan_exclusions = Some(Vec::new());
5229 });
5230 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5231 project_panel_settings.auto_reveal_entries = Some(false)
5232 });
5233 })
5234 });
5235
5236 let fs = FakeFs::new(cx.background_executor.clone());
5237 fs.insert_tree(
5238 "/project_root",
5239 json!({
5240 ".git": {},
5241 ".gitignore": "**/gitignored_dir",
5242 "dir_1": {
5243 "file_1.py": "# File 1_1 contents",
5244 "file_2.py": "# File 1_2 contents",
5245 "file_3.py": "# File 1_3 contents",
5246 "gitignored_dir": {
5247 "file_a.py": "# File contents",
5248 "file_b.py": "# File contents",
5249 "file_c.py": "# File contents",
5250 },
5251 },
5252 "dir_2": {
5253 "file_1.py": "# File 2_1 contents",
5254 "file_2.py": "# File 2_2 contents",
5255 "file_3.py": "# File 2_3 contents",
5256 }
5257 }),
5258 )
5259 .await;
5260
5261 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
5262 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5263 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5264 let panel = workspace.update(cx, ProjectPanel::new).unwrap();
5265
5266 assert_eq!(
5267 visible_entries_as_strings(&panel, 0..20, cx),
5268 &[
5269 "v project_root",
5270 " > .git",
5271 " > dir_1",
5272 " > dir_2",
5273 " .gitignore",
5274 ]
5275 );
5276
5277 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
5278 .expect("dir 1 file is not ignored and should have an entry");
5279 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
5280 .expect("dir 2 file is not ignored and should have an entry");
5281 let gitignored_dir_file =
5282 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
5283 assert_eq!(
5284 gitignored_dir_file, None,
5285 "File in the gitignored dir should not have an entry before its dir is toggled"
5286 );
5287
5288 toggle_expand_dir(&panel, "project_root/dir_1", cx);
5289 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5290 cx.run_until_parked();
5291 assert_eq!(
5292 visible_entries_as_strings(&panel, 0..20, cx),
5293 &[
5294 "v project_root",
5295 " > .git",
5296 " v dir_1",
5297 " v gitignored_dir <== selected",
5298 " file_a.py",
5299 " file_b.py",
5300 " file_c.py",
5301 " file_1.py",
5302 " file_2.py",
5303 " file_3.py",
5304 " > dir_2",
5305 " .gitignore",
5306 ],
5307 "Should show gitignored dir file list in the project panel"
5308 );
5309 let gitignored_dir_file =
5310 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
5311 .expect("after gitignored dir got opened, a file entry should be present");
5312
5313 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
5314 toggle_expand_dir(&panel, "project_root/dir_1", cx);
5315 assert_eq!(
5316 visible_entries_as_strings(&panel, 0..20, cx),
5317 &[
5318 "v project_root",
5319 " > .git",
5320 " > dir_1 <== selected",
5321 " > dir_2",
5322 " .gitignore",
5323 ],
5324 "Should hide all dir contents again and prepare for the explicit reveal test"
5325 );
5326
5327 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
5328 panel.update(cx, |panel, cx| {
5329 panel.project.update(cx, |_, cx| {
5330 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
5331 })
5332 });
5333 cx.run_until_parked();
5334 assert_eq!(
5335 visible_entries_as_strings(&panel, 0..20, cx),
5336 &[
5337 "v project_root",
5338 " > .git",
5339 " > dir_1 <== selected",
5340 " > dir_2",
5341 " .gitignore",
5342 ],
5343 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
5344 );
5345 }
5346
5347 panel.update(cx, |panel, cx| {
5348 panel.project.update(cx, |_, cx| {
5349 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
5350 })
5351 });
5352 cx.run_until_parked();
5353 assert_eq!(
5354 visible_entries_as_strings(&panel, 0..20, cx),
5355 &[
5356 "v project_root",
5357 " > .git",
5358 " v dir_1",
5359 " > gitignored_dir",
5360 " file_1.py <== selected",
5361 " file_2.py",
5362 " file_3.py",
5363 " > dir_2",
5364 " .gitignore",
5365 ],
5366 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
5367 );
5368
5369 panel.update(cx, |panel, cx| {
5370 panel.project.update(cx, |_, cx| {
5371 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
5372 })
5373 });
5374 cx.run_until_parked();
5375 assert_eq!(
5376 visible_entries_as_strings(&panel, 0..20, cx),
5377 &[
5378 "v project_root",
5379 " > .git",
5380 " v dir_1",
5381 " > gitignored_dir",
5382 " file_1.py",
5383 " file_2.py",
5384 " file_3.py",
5385 " v dir_2",
5386 " file_1.py <== selected",
5387 " file_2.py",
5388 " file_3.py",
5389 " .gitignore",
5390 ],
5391 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
5392 );
5393
5394 panel.update(cx, |panel, cx| {
5395 panel.project.update(cx, |_, cx| {
5396 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
5397 })
5398 });
5399 cx.run_until_parked();
5400 assert_eq!(
5401 visible_entries_as_strings(&panel, 0..20, cx),
5402 &[
5403 "v project_root",
5404 " > .git",
5405 " v dir_1",
5406 " v gitignored_dir",
5407 " file_a.py <== selected",
5408 " file_b.py",
5409 " file_c.py",
5410 " file_1.py",
5411 " file_2.py",
5412 " file_3.py",
5413 " v dir_2",
5414 " file_1.py",
5415 " file_2.py",
5416 " file_3.py",
5417 " .gitignore",
5418 ],
5419 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
5420 );
5421 }
5422
5423 #[gpui::test]
5424 async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
5425 init_test(cx);
5426 cx.update(|cx| {
5427 cx.update_global::<SettingsStore, _>(|store, cx| {
5428 store.update_user_settings::<WorktreeSettings>(cx, |project_settings| {
5429 project_settings.file_scan_exclusions =
5430 Some(vec!["excluded_dir".to_string(), "**/.git".to_string()]);
5431 });
5432 });
5433 });
5434
5435 cx.update(|cx| {
5436 register_project_item::<TestProjectItemView>(cx);
5437 });
5438
5439 let fs = FakeFs::new(cx.executor().clone());
5440 fs.insert_tree(
5441 "/root1",
5442 json!({
5443 ".dockerignore": "",
5444 ".git": {
5445 "HEAD": "",
5446 },
5447 }),
5448 )
5449 .await;
5450
5451 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
5452 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
5453 let cx = &mut VisualTestContext::from_window(*workspace, cx);
5454 let panel = workspace
5455 .update(cx, |workspace, cx| {
5456 let panel = ProjectPanel::new(workspace, cx);
5457 workspace.add_panel(panel.clone(), cx);
5458 panel
5459 })
5460 .unwrap();
5461
5462 select_path(&panel, "root1", cx);
5463 assert_eq!(
5464 visible_entries_as_strings(&panel, 0..10, cx),
5465 &["v root1 <== selected", " .dockerignore",]
5466 );
5467 workspace
5468 .update(cx, |workspace, cx| {
5469 assert!(
5470 workspace.active_item(cx).is_none(),
5471 "Should have no active items in the beginning"
5472 );
5473 })
5474 .unwrap();
5475
5476 let excluded_file_path = ".git/COMMIT_EDITMSG";
5477 let excluded_dir_path = "excluded_dir";
5478
5479 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
5480 panel.update(cx, |panel, cx| {
5481 assert!(panel.filename_editor.read(cx).is_focused(cx));
5482 });
5483 panel
5484 .update(cx, |panel, cx| {
5485 panel
5486 .filename_editor
5487 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
5488 panel.confirm_edit(cx).unwrap()
5489 })
5490 .await
5491 .unwrap();
5492
5493 assert_eq!(
5494 visible_entries_as_strings(&panel, 0..13, cx),
5495 &["v root1", " .dockerignore"],
5496 "Excluded dir should not be shown after opening a file in it"
5497 );
5498 panel.update(cx, |panel, cx| {
5499 assert!(
5500 !panel.filename_editor.read(cx).is_focused(cx),
5501 "Should have closed the file name editor"
5502 );
5503 });
5504 workspace
5505 .update(cx, |workspace, cx| {
5506 let active_entry_path = workspace
5507 .active_item(cx)
5508 .expect("should have opened and activated the excluded item")
5509 .act_as::<TestProjectItemView>(cx)
5510 .expect(
5511 "should have opened the corresponding project item for the excluded item",
5512 )
5513 .read(cx)
5514 .path
5515 .clone();
5516 assert_eq!(
5517 active_entry_path.path.as_ref(),
5518 Path::new(excluded_file_path),
5519 "Should open the excluded file"
5520 );
5521
5522 assert!(
5523 workspace.notification_ids().is_empty(),
5524 "Should have no notifications after opening an excluded file"
5525 );
5526 })
5527 .unwrap();
5528 assert!(
5529 fs.is_file(Path::new("/root1/.git/COMMIT_EDITMSG")).await,
5530 "Should have created the excluded file"
5531 );
5532
5533 select_path(&panel, "root1", cx);
5534 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
5535 panel.update(cx, |panel, cx| {
5536 assert!(panel.filename_editor.read(cx).is_focused(cx));
5537 });
5538 panel
5539 .update(cx, |panel, cx| {
5540 panel
5541 .filename_editor
5542 .update(cx, |editor, cx| editor.set_text(excluded_file_path, cx));
5543 panel.confirm_edit(cx).unwrap()
5544 })
5545 .await
5546 .unwrap();
5547
5548 assert_eq!(
5549 visible_entries_as_strings(&panel, 0..13, cx),
5550 &["v root1", " .dockerignore"],
5551 "Should not change the project panel after trying to create an excluded directorya directory with the same name as the excluded file"
5552 );
5553 panel.update(cx, |panel, cx| {
5554 assert!(
5555 !panel.filename_editor.read(cx).is_focused(cx),
5556 "Should have closed the file name editor"
5557 );
5558 });
5559 workspace
5560 .update(cx, |workspace, cx| {
5561 let notifications = workspace.notification_ids();
5562 assert_eq!(
5563 notifications.len(),
5564 1,
5565 "Should receive one notification with the error message"
5566 );
5567 workspace.dismiss_notification(notifications.first().unwrap(), cx);
5568 assert!(workspace.notification_ids().is_empty());
5569 })
5570 .unwrap();
5571
5572 select_path(&panel, "root1", cx);
5573 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
5574 panel.update(cx, |panel, cx| {
5575 assert!(panel.filename_editor.read(cx).is_focused(cx));
5576 });
5577 panel
5578 .update(cx, |panel, cx| {
5579 panel
5580 .filename_editor
5581 .update(cx, |editor, cx| editor.set_text(excluded_dir_path, cx));
5582 panel.confirm_edit(cx).unwrap()
5583 })
5584 .await
5585 .unwrap();
5586
5587 assert_eq!(
5588 visible_entries_as_strings(&panel, 0..13, cx),
5589 &["v root1", " .dockerignore"],
5590 "Should not change the project panel after trying to create an excluded directory"
5591 );
5592 panel.update(cx, |panel, cx| {
5593 assert!(
5594 !panel.filename_editor.read(cx).is_focused(cx),
5595 "Should have closed the file name editor"
5596 );
5597 });
5598 workspace
5599 .update(cx, |workspace, cx| {
5600 let notifications = workspace.notification_ids();
5601 assert_eq!(
5602 notifications.len(),
5603 1,
5604 "Should receive one notification explaining that no directory is actually shown"
5605 );
5606 workspace.dismiss_notification(notifications.first().unwrap(), cx);
5607 assert!(workspace.notification_ids().is_empty());
5608 })
5609 .unwrap();
5610 assert!(
5611 fs.is_dir(Path::new("/root1/excluded_dir")).await,
5612 "Should have created the excluded directory"
5613 );
5614 }
5615
5616 fn toggle_expand_dir(
5617 panel: &View<ProjectPanel>,
5618 path: impl AsRef<Path>,
5619 cx: &mut VisualTestContext,
5620 ) {
5621 let path = path.as_ref();
5622 panel.update(cx, |panel, cx| {
5623 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5624 let worktree = worktree.read(cx);
5625 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5626 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
5627 panel.toggle_expanded(entry_id, cx);
5628 return;
5629 }
5630 }
5631 panic!("no worktree for path {:?}", path);
5632 });
5633 }
5634
5635 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
5636 let path = path.as_ref();
5637 panel.update(cx, |panel, cx| {
5638 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5639 let worktree = worktree.read(cx);
5640 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5641 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
5642 panel.selection = Some(crate::SelectedEntry {
5643 worktree_id: worktree.id(),
5644 entry_id,
5645 });
5646 return;
5647 }
5648 }
5649 panic!("no worktree for path {:?}", path);
5650 });
5651 }
5652
5653 fn find_project_entry(
5654 panel: &View<ProjectPanel>,
5655 path: impl AsRef<Path>,
5656 cx: &mut VisualTestContext,
5657 ) -> Option<ProjectEntryId> {
5658 let path = path.as_ref();
5659 panel.update(cx, |panel, cx| {
5660 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
5661 let worktree = worktree.read(cx);
5662 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
5663 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
5664 }
5665 }
5666 panic!("no worktree for path {path:?}");
5667 })
5668 }
5669
5670 fn visible_entries_as_strings(
5671 panel: &View<ProjectPanel>,
5672 range: Range<usize>,
5673 cx: &mut VisualTestContext,
5674 ) -> Vec<String> {
5675 let mut result = Vec::new();
5676 let mut project_entries = HashSet::default();
5677 let mut has_editor = false;
5678
5679 panel.update(cx, |panel, cx| {
5680 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
5681 if details.is_editing {
5682 assert!(!has_editor, "duplicate editor entry");
5683 has_editor = true;
5684 } else {
5685 assert!(
5686 project_entries.insert(project_entry),
5687 "duplicate project entry {:?} {:?}",
5688 project_entry,
5689 details
5690 );
5691 }
5692
5693 let indent = " ".repeat(details.depth);
5694 let icon = if details.kind.is_dir() {
5695 if details.is_expanded {
5696 "v "
5697 } else {
5698 "> "
5699 }
5700 } else {
5701 " "
5702 };
5703 let name = if details.is_editing {
5704 format!("[EDITOR: '{}']", details.filename)
5705 } else if details.is_processing {
5706 format!("[PROCESSING: '{}']", details.filename)
5707 } else {
5708 details.filename.clone()
5709 };
5710 let selected = if details.is_selected {
5711 " <== selected"
5712 } else {
5713 ""
5714 };
5715 let marked = if details.is_marked {
5716 " <== marked"
5717 } else {
5718 ""
5719 };
5720
5721 result.push(format!("{indent}{icon}{name}{selected}{marked}"));
5722 });
5723 });
5724
5725 result
5726 }
5727
5728 fn init_test(cx: &mut TestAppContext) {
5729 cx.update(|cx| {
5730 let settings_store = SettingsStore::test(cx);
5731 cx.set_global(settings_store);
5732 init_settings(cx);
5733 theme::init(theme::LoadThemes::JustBase, cx);
5734 language::init(cx);
5735 editor::init_settings(cx);
5736 crate::init((), cx);
5737 workspace::init_settings(cx);
5738 client::init_settings(cx);
5739 Project::init_settings(cx);
5740
5741 cx.update_global::<SettingsStore, _>(|store, cx| {
5742 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5743 project_panel_settings.auto_fold_dirs = Some(false);
5744 });
5745 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5746 worktree_settings.file_scan_exclusions = Some(Vec::new());
5747 });
5748 });
5749 });
5750 }
5751
5752 fn init_test_with_editor(cx: &mut TestAppContext) {
5753 cx.update(|cx| {
5754 let app_state = AppState::test(cx);
5755 theme::init(theme::LoadThemes::JustBase, cx);
5756 init_settings(cx);
5757 language::init(cx);
5758 editor::init(cx);
5759 crate::init((), cx);
5760 workspace::init(app_state.clone(), cx);
5761 Project::init_settings(cx);
5762
5763 cx.update_global::<SettingsStore, _>(|store, cx| {
5764 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
5765 project_panel_settings.auto_fold_dirs = Some(false);
5766 });
5767 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
5768 worktree_settings.file_scan_exclusions = Some(Vec::new());
5769 });
5770 });
5771 });
5772 }
5773
5774 fn ensure_single_file_is_opened(
5775 window: &WindowHandle<Workspace>,
5776 expected_path: &str,
5777 cx: &mut TestAppContext,
5778 ) {
5779 window
5780 .update(cx, |workspace, cx| {
5781 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
5782 assert_eq!(worktrees.len(), 1);
5783 let worktree_id = worktrees[0].read(cx).id();
5784
5785 let open_project_paths = workspace
5786 .panes()
5787 .iter()
5788 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
5789 .collect::<Vec<_>>();
5790 assert_eq!(
5791 open_project_paths,
5792 vec![ProjectPath {
5793 worktree_id,
5794 path: Arc::from(Path::new(expected_path))
5795 }],
5796 "Should have opened file, selected in project panel"
5797 );
5798 })
5799 .unwrap();
5800 }
5801
5802 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
5803 assert!(
5804 !cx.has_pending_prompt(),
5805 "Should have no prompts before the deletion"
5806 );
5807 panel.update(cx, |panel, cx| {
5808 panel.delete(&Delete { skip_prompt: false }, cx)
5809 });
5810 assert!(
5811 cx.has_pending_prompt(),
5812 "Should have a prompt after the deletion"
5813 );
5814 cx.simulate_prompt_answer(0);
5815 assert!(
5816 !cx.has_pending_prompt(),
5817 "Should have no prompts after prompt was replied to"
5818 );
5819 cx.executor().run_until_parked();
5820 }
5821
5822 fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
5823 assert!(
5824 !cx.has_pending_prompt(),
5825 "Should have no prompts before the deletion"
5826 );
5827 panel.update(cx, |panel, cx| {
5828 panel.delete(&Delete { skip_prompt: true }, cx)
5829 });
5830 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
5831 cx.executor().run_until_parked();
5832 }
5833
5834 fn ensure_no_open_items_and_panes(
5835 workspace: &WindowHandle<Workspace>,
5836 cx: &mut VisualTestContext,
5837 ) {
5838 assert!(
5839 !cx.has_pending_prompt(),
5840 "Should have no prompts after deletion operation closes the file"
5841 );
5842 workspace
5843 .read_with(cx, |workspace, cx| {
5844 let open_project_paths = workspace
5845 .panes()
5846 .iter()
5847 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
5848 .collect::<Vec<_>>();
5849 assert!(
5850 open_project_paths.is_empty(),
5851 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
5852 );
5853 })
5854 .unwrap();
5855 }
5856
5857 struct TestProjectItemView {
5858 focus_handle: FocusHandle,
5859 path: ProjectPath,
5860 }
5861
5862 struct TestProjectItem {
5863 path: ProjectPath,
5864 }
5865
5866 impl project::Item for TestProjectItem {
5867 fn try_open(
5868 _project: &Model<Project>,
5869 path: &ProjectPath,
5870 cx: &mut AppContext,
5871 ) -> Option<Task<gpui::Result<Model<Self>>>> {
5872 let path = path.clone();
5873 Some(cx.spawn(|mut cx| async move { cx.new_model(|_| Self { path }) }))
5874 }
5875
5876 fn entry_id(&self, _: &AppContext) -> Option<ProjectEntryId> {
5877 None
5878 }
5879
5880 fn project_path(&self, _: &AppContext) -> Option<ProjectPath> {
5881 Some(self.path.clone())
5882 }
5883 }
5884
5885 impl ProjectItem for TestProjectItemView {
5886 type Item = TestProjectItem;
5887
5888 fn for_project_item(
5889 _: Model<Project>,
5890 project_item: Model<Self::Item>,
5891 cx: &mut ViewContext<Self>,
5892 ) -> Self
5893 where
5894 Self: Sized,
5895 {
5896 Self {
5897 path: project_item.update(cx, |project_item, _| project_item.path.clone()),
5898 focus_handle: cx.focus_handle(),
5899 }
5900 }
5901 }
5902
5903 impl Item for TestProjectItemView {
5904 type Event = ();
5905 }
5906
5907 impl EventEmitter<()> for TestProjectItemView {}
5908
5909 impl FocusableView for TestProjectItemView {
5910 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
5911 self.focus_handle.clone()
5912 }
5913 }
5914
5915 impl Render for TestProjectItemView {
5916 fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
5917 Empty
5918 }
5919 }
5920}