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