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