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