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