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