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