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().size(IconSize::default().rems()).invisible()
1646 })
1647 .child(
1648 if let (Some(editor), true) = (Some(&self.filename_editor), show_editor) {
1649 h_flex().h_6().w_full().child(editor.clone())
1650 } else {
1651 h_flex().h_6().child(
1652 Label::new(file_name)
1653 .single_line()
1654 .color(filename_text_color),
1655 )
1656 }
1657 .ml_1(),
1658 )
1659 .on_click(cx.listener(move |this, event: &gpui::ClickEvent, cx| {
1660 if event.down.button == MouseButton::Right || event.down.first_mouse {
1661 return;
1662 }
1663 if !show_editor {
1664 if kind.is_dir() {
1665 this.toggle_expanded(entry_id, cx);
1666 } else {
1667 if event.down.modifiers.secondary() {
1668 this.split_entry(entry_id, cx);
1669 } else {
1670 let click_count = event.up.click_count;
1671 this.open_entry(
1672 entry_id,
1673 click_count > 1,
1674 click_count == 1,
1675 cx,
1676 );
1677 }
1678 }
1679 }
1680 }))
1681 .on_secondary_mouse_down(cx.listener(
1682 move |this, event: &MouseDownEvent, cx| {
1683 // Stop propagation to prevent the catch-all context menu for the project
1684 // panel from being deployed.
1685 cx.stop_propagation();
1686 this.deploy_context_menu(event.position, entry_id, cx);
1687 },
1688 )),
1689 )
1690 }
1691
1692 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
1693 let mut dispatch_context = KeyContext::default();
1694 dispatch_context.add("ProjectPanel");
1695 dispatch_context.add("menu");
1696
1697 let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) {
1698 "editing"
1699 } else {
1700 "not_editing"
1701 };
1702
1703 dispatch_context.add(identifier);
1704 dispatch_context
1705 }
1706
1707 fn reveal_entry(
1708 &mut self,
1709 project: Model<Project>,
1710 entry_id: ProjectEntryId,
1711 skip_ignored: bool,
1712 cx: &mut ViewContext<'_, ProjectPanel>,
1713 ) {
1714 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
1715 let worktree = worktree.read(cx);
1716 if skip_ignored
1717 && worktree
1718 .entry_for_id(entry_id)
1719 .map_or(true, |entry| entry.is_ignored)
1720 {
1721 return;
1722 }
1723
1724 let worktree_id = worktree.id();
1725 self.expand_entry(worktree_id, entry_id, cx);
1726 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
1727 self.autoscroll(cx);
1728 cx.notify();
1729 }
1730 }
1731}
1732
1733impl Render for ProjectPanel {
1734 fn render(&mut self, cx: &mut gpui::ViewContext<Self>) -> impl IntoElement {
1735 let has_worktree = self.visible_entries.len() != 0;
1736 let project = self.project.read(cx);
1737
1738 if has_worktree {
1739 div()
1740 .id("project-panel")
1741 .size_full()
1742 .relative()
1743 .key_context(self.dispatch_context(cx))
1744 .on_action(cx.listener(Self::select_next))
1745 .on_action(cx.listener(Self::select_prev))
1746 .on_action(cx.listener(Self::expand_selected_entry))
1747 .on_action(cx.listener(Self::collapse_selected_entry))
1748 .on_action(cx.listener(Self::collapse_all_entries))
1749 .on_action(cx.listener(Self::open))
1750 .on_action(cx.listener(Self::open_permanent))
1751 .on_action(cx.listener(Self::confirm))
1752 .on_action(cx.listener(Self::cancel))
1753 .on_action(cx.listener(Self::copy_path))
1754 .on_action(cx.listener(Self::copy_relative_path))
1755 .on_action(cx.listener(Self::new_search_in_directory))
1756 .on_action(cx.listener(Self::unfold_directory))
1757 .on_action(cx.listener(Self::fold_directory))
1758 .when(!project.is_read_only(), |el| {
1759 el.on_action(cx.listener(Self::new_file))
1760 .on_action(cx.listener(Self::new_directory))
1761 .on_action(cx.listener(Self::rename))
1762 .on_action(cx.listener(Self::delete))
1763 .on_action(cx.listener(Self::cut))
1764 .on_action(cx.listener(Self::copy))
1765 .on_action(cx.listener(Self::paste))
1766 })
1767 .when(project.is_local(), |el| {
1768 el.on_action(cx.listener(Self::reveal_in_finder))
1769 .on_action(cx.listener(Self::open_in_terminal))
1770 })
1771 .on_mouse_down(
1772 MouseButton::Right,
1773 cx.listener(move |this, event: &MouseDownEvent, cx| {
1774 // When deploying the context menu anywhere below the last project entry,
1775 // act as if the user clicked the root of the last worktree.
1776 if let Some(entry_id) = this.last_worktree_root_id {
1777 this.deploy_context_menu(event.position, entry_id, cx);
1778 }
1779 }),
1780 )
1781 .track_focus(&self.focus_handle)
1782 .child(
1783 uniform_list(
1784 cx.view().clone(),
1785 "entries",
1786 self.visible_entries
1787 .iter()
1788 .map(|(_, worktree_entries)| worktree_entries.len())
1789 .sum(),
1790 {
1791 |this, range, cx| {
1792 let mut items = Vec::new();
1793 this.for_each_visible_entry(range, cx, |id, details, cx| {
1794 items.push(this.render_entry(id, details, cx));
1795 });
1796 items
1797 }
1798 },
1799 )
1800 .size_full()
1801 .track_scroll(self.scroll_handle.clone()),
1802 )
1803 .children(self.context_menu.as_ref().map(|(menu, position, _)| {
1804 deferred(
1805 anchored()
1806 .position(*position)
1807 .anchor(gpui::AnchorCorner::TopLeft)
1808 .child(menu.clone()),
1809 )
1810 .with_priority(1)
1811 }))
1812 } else {
1813 v_flex()
1814 .id("empty-project_panel")
1815 .size_full()
1816 .p_4()
1817 .track_focus(&self.focus_handle)
1818 .child(
1819 Button::new("open_project", "Open a project")
1820 .style(ButtonStyle::Filled)
1821 .full_width()
1822 .key_binding(KeyBinding::for_action(&workspace::Open, cx))
1823 .on_click(cx.listener(|this, _, cx| {
1824 this.workspace
1825 .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
1826 .log_err();
1827 })),
1828 )
1829 }
1830 }
1831}
1832
1833impl Render for DraggedProjectEntryView {
1834 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl Element {
1835 let settings = ProjectPanelSettings::get_global(cx);
1836 let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
1837 h_flex()
1838 .font(ui_font)
1839 .bg(cx.theme().colors().background)
1840 .w(self.width)
1841 .child(
1842 ListItem::new(self.entry_id.to_proto() as usize)
1843 .indent_level(self.details.depth)
1844 .indent_step_size(px(settings.indent_size))
1845 .child(if let Some(icon) = &self.details.icon {
1846 div().child(Icon::from_path(icon.to_string()))
1847 } else {
1848 div()
1849 })
1850 .child(Label::new(self.details.filename.clone())),
1851 )
1852 }
1853}
1854
1855impl EventEmitter<Event> for ProjectPanel {}
1856
1857impl EventEmitter<PanelEvent> for ProjectPanel {}
1858
1859impl Panel for ProjectPanel {
1860 fn position(&self, cx: &WindowContext) -> DockPosition {
1861 match ProjectPanelSettings::get_global(cx).dock {
1862 ProjectPanelDockPosition::Left => DockPosition::Left,
1863 ProjectPanelDockPosition::Right => DockPosition::Right,
1864 }
1865 }
1866
1867 fn position_is_valid(&self, position: DockPosition) -> bool {
1868 matches!(position, DockPosition::Left | DockPosition::Right)
1869 }
1870
1871 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
1872 settings::update_settings_file::<ProjectPanelSettings>(
1873 self.fs.clone(),
1874 cx,
1875 move |settings| {
1876 let dock = match position {
1877 DockPosition::Left | DockPosition::Bottom => ProjectPanelDockPosition::Left,
1878 DockPosition::Right => ProjectPanelDockPosition::Right,
1879 };
1880 settings.dock = Some(dock);
1881 },
1882 );
1883 }
1884
1885 fn size(&self, cx: &WindowContext) -> Pixels {
1886 self.width
1887 .unwrap_or_else(|| ProjectPanelSettings::get_global(cx).default_width)
1888 }
1889
1890 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
1891 self.width = size;
1892 self.serialize(cx);
1893 cx.notify();
1894 }
1895
1896 fn icon(&self, _: &WindowContext) -> Option<ui::IconName> {
1897 Some(ui::IconName::FileTree)
1898 }
1899
1900 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
1901 Some("Project Panel")
1902 }
1903
1904 fn toggle_action(&self) -> Box<dyn Action> {
1905 Box::new(ToggleFocus)
1906 }
1907
1908 fn persistent_name() -> &'static str {
1909 "Project Panel"
1910 }
1911
1912 fn starts_open(&self, cx: &WindowContext) -> bool {
1913 self.project.read(cx).visible_worktrees(cx).any(|tree| {
1914 tree.read(cx)
1915 .root_entry()
1916 .map_or(false, |entry| entry.is_dir())
1917 })
1918 }
1919}
1920
1921impl FocusableView for ProjectPanel {
1922 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1923 self.focus_handle.clone()
1924 }
1925}
1926
1927impl ClipboardEntry {
1928 fn is_cut(&self) -> bool {
1929 matches!(self, Self::Cut { .. })
1930 }
1931
1932 fn entry_id(&self) -> ProjectEntryId {
1933 match self {
1934 ClipboardEntry::Copied { entry_id, .. } | ClipboardEntry::Cut { entry_id, .. } => {
1935 *entry_id
1936 }
1937 }
1938 }
1939
1940 fn worktree_id(&self) -> WorktreeId {
1941 match self {
1942 ClipboardEntry::Copied { worktree_id, .. }
1943 | ClipboardEntry::Cut { worktree_id, .. } => *worktree_id,
1944 }
1945 }
1946}
1947
1948#[cfg(test)]
1949mod tests {
1950 use super::*;
1951 use collections::HashSet;
1952 use gpui::{TestAppContext, View, VisualTestContext, WindowHandle};
1953 use pretty_assertions::assert_eq;
1954 use project::{FakeFs, WorktreeSettings};
1955 use serde_json::json;
1956 use settings::SettingsStore;
1957 use std::path::{Path, PathBuf};
1958 use workspace::AppState;
1959
1960 #[gpui::test]
1961 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
1962 init_test(cx);
1963
1964 let fs = FakeFs::new(cx.executor().clone());
1965 fs.insert_tree(
1966 "/root1",
1967 json!({
1968 ".dockerignore": "",
1969 ".git": {
1970 "HEAD": "",
1971 },
1972 "a": {
1973 "0": { "q": "", "r": "", "s": "" },
1974 "1": { "t": "", "u": "" },
1975 "2": { "v": "", "w": "", "x": "", "y": "" },
1976 },
1977 "b": {
1978 "3": { "Q": "" },
1979 "4": { "R": "", "S": "", "T": "", "U": "" },
1980 },
1981 "C": {
1982 "5": {},
1983 "6": { "V": "", "W": "" },
1984 "7": { "X": "" },
1985 "8": { "Y": {}, "Z": "" }
1986 }
1987 }),
1988 )
1989 .await;
1990 fs.insert_tree(
1991 "/root2",
1992 json!({
1993 "d": {
1994 "9": ""
1995 },
1996 "e": {}
1997 }),
1998 )
1999 .await;
2000
2001 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2002 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2003 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2004 let panel = workspace
2005 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2006 .unwrap();
2007 assert_eq!(
2008 visible_entries_as_strings(&panel, 0..50, cx),
2009 &[
2010 "v root1",
2011 " > .git",
2012 " > a",
2013 " > b",
2014 " > C",
2015 " .dockerignore",
2016 "v root2",
2017 " > d",
2018 " > e",
2019 ]
2020 );
2021
2022 toggle_expand_dir(&panel, "root1/b", cx);
2023 assert_eq!(
2024 visible_entries_as_strings(&panel, 0..50, cx),
2025 &[
2026 "v root1",
2027 " > .git",
2028 " > a",
2029 " v b <== selected",
2030 " > 3",
2031 " > 4",
2032 " > C",
2033 " .dockerignore",
2034 "v root2",
2035 " > d",
2036 " > e",
2037 ]
2038 );
2039
2040 assert_eq!(
2041 visible_entries_as_strings(&panel, 6..9, cx),
2042 &[
2043 //
2044 " > C",
2045 " .dockerignore",
2046 "v root2",
2047 ]
2048 );
2049 }
2050
2051 #[gpui::test]
2052 async fn test_exclusions_in_visible_list(cx: &mut gpui::TestAppContext) {
2053 init_test(cx);
2054 cx.update(|cx| {
2055 cx.update_global::<SettingsStore, _>(|store, cx| {
2056 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
2057 worktree_settings.file_scan_exclusions =
2058 Some(vec!["**/.git".to_string(), "**/4/**".to_string()]);
2059 });
2060 });
2061 });
2062
2063 let fs = FakeFs::new(cx.background_executor.clone());
2064 fs.insert_tree(
2065 "/root1",
2066 json!({
2067 ".dockerignore": "",
2068 ".git": {
2069 "HEAD": "",
2070 },
2071 "a": {
2072 "0": { "q": "", "r": "", "s": "" },
2073 "1": { "t": "", "u": "" },
2074 "2": { "v": "", "w": "", "x": "", "y": "" },
2075 },
2076 "b": {
2077 "3": { "Q": "" },
2078 "4": { "R": "", "S": "", "T": "", "U": "" },
2079 },
2080 "C": {
2081 "5": {},
2082 "6": { "V": "", "W": "" },
2083 "7": { "X": "" },
2084 "8": { "Y": {}, "Z": "" }
2085 }
2086 }),
2087 )
2088 .await;
2089 fs.insert_tree(
2090 "/root2",
2091 json!({
2092 "d": {
2093 "4": ""
2094 },
2095 "e": {}
2096 }),
2097 )
2098 .await;
2099
2100 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2101 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2102 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2103 let panel = workspace
2104 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2105 .unwrap();
2106 assert_eq!(
2107 visible_entries_as_strings(&panel, 0..50, cx),
2108 &[
2109 "v root1",
2110 " > a",
2111 " > b",
2112 " > C",
2113 " .dockerignore",
2114 "v root2",
2115 " > d",
2116 " > e",
2117 ]
2118 );
2119
2120 toggle_expand_dir(&panel, "root1/b", cx);
2121 assert_eq!(
2122 visible_entries_as_strings(&panel, 0..50, cx),
2123 &[
2124 "v root1",
2125 " > a",
2126 " v b <== selected",
2127 " > 3",
2128 " > C",
2129 " .dockerignore",
2130 "v root2",
2131 " > d",
2132 " > e",
2133 ]
2134 );
2135
2136 toggle_expand_dir(&panel, "root2/d", cx);
2137 assert_eq!(
2138 visible_entries_as_strings(&panel, 0..50, cx),
2139 &[
2140 "v root1",
2141 " > a",
2142 " v b",
2143 " > 3",
2144 " > C",
2145 " .dockerignore",
2146 "v root2",
2147 " v d <== selected",
2148 " > e",
2149 ]
2150 );
2151
2152 toggle_expand_dir(&panel, "root2/e", cx);
2153 assert_eq!(
2154 visible_entries_as_strings(&panel, 0..50, cx),
2155 &[
2156 "v root1",
2157 " > a",
2158 " v b",
2159 " > 3",
2160 " > C",
2161 " .dockerignore",
2162 "v root2",
2163 " v d",
2164 " v e <== selected",
2165 ]
2166 );
2167 }
2168
2169 #[gpui::test]
2170 async fn test_auto_collapse_dir_paths(cx: &mut gpui::TestAppContext) {
2171 init_test(cx);
2172
2173 let fs = FakeFs::new(cx.executor().clone());
2174 fs.insert_tree(
2175 "/root1",
2176 json!({
2177 "dir_1": {
2178 "nested_dir_1": {
2179 "nested_dir_2": {
2180 "nested_dir_3": {
2181 "file_a.java": "// File contents",
2182 "file_b.java": "// File contents",
2183 "file_c.java": "// File contents",
2184 "nested_dir_4": {
2185 "nested_dir_5": {
2186 "file_d.java": "// File contents",
2187 }
2188 }
2189 }
2190 }
2191 }
2192 }
2193 }),
2194 )
2195 .await;
2196 fs.insert_tree(
2197 "/root2",
2198 json!({
2199 "dir_2": {
2200 "file_1.java": "// File contents",
2201 }
2202 }),
2203 )
2204 .await;
2205
2206 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2207 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2208 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2209 cx.update(|cx| {
2210 let settings = *ProjectPanelSettings::get_global(cx);
2211 ProjectPanelSettings::override_global(
2212 ProjectPanelSettings {
2213 auto_fold_dirs: true,
2214 ..settings
2215 },
2216 cx,
2217 );
2218 });
2219 let panel = workspace
2220 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2221 .unwrap();
2222 assert_eq!(
2223 visible_entries_as_strings(&panel, 0..10, cx),
2224 &[
2225 "v root1",
2226 " > dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2227 "v root2",
2228 " > dir_2",
2229 ]
2230 );
2231
2232 toggle_expand_dir(
2233 &panel,
2234 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2235 cx,
2236 );
2237 assert_eq!(
2238 visible_entries_as_strings(&panel, 0..10, cx),
2239 &[
2240 "v root1",
2241 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3 <== selected",
2242 " > nested_dir_4/nested_dir_5",
2243 " file_a.java",
2244 " file_b.java",
2245 " file_c.java",
2246 "v root2",
2247 " > dir_2",
2248 ]
2249 );
2250
2251 toggle_expand_dir(
2252 &panel,
2253 "root1/dir_1/nested_dir_1/nested_dir_2/nested_dir_3/nested_dir_4/nested_dir_5",
2254 cx,
2255 );
2256 assert_eq!(
2257 visible_entries_as_strings(&panel, 0..10, cx),
2258 &[
2259 "v root1",
2260 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2261 " v nested_dir_4/nested_dir_5 <== selected",
2262 " file_d.java",
2263 " file_a.java",
2264 " file_b.java",
2265 " file_c.java",
2266 "v root2",
2267 " > dir_2",
2268 ]
2269 );
2270 toggle_expand_dir(&panel, "root2/dir_2", cx);
2271 assert_eq!(
2272 visible_entries_as_strings(&panel, 0..10, cx),
2273 &[
2274 "v root1",
2275 " v dir_1/nested_dir_1/nested_dir_2/nested_dir_3",
2276 " v nested_dir_4/nested_dir_5",
2277 " file_d.java",
2278 " file_a.java",
2279 " file_b.java",
2280 " file_c.java",
2281 "v root2",
2282 " v dir_2 <== selected",
2283 " file_1.java",
2284 ]
2285 );
2286 }
2287
2288 #[gpui::test(iterations = 30)]
2289 async fn test_editing_files(cx: &mut gpui::TestAppContext) {
2290 init_test(cx);
2291
2292 let fs = FakeFs::new(cx.executor().clone());
2293 fs.insert_tree(
2294 "/root1",
2295 json!({
2296 ".dockerignore": "",
2297 ".git": {
2298 "HEAD": "",
2299 },
2300 "a": {
2301 "0": { "q": "", "r": "", "s": "" },
2302 "1": { "t": "", "u": "" },
2303 "2": { "v": "", "w": "", "x": "", "y": "" },
2304 },
2305 "b": {
2306 "3": { "Q": "" },
2307 "4": { "R": "", "S": "", "T": "", "U": "" },
2308 },
2309 "C": {
2310 "5": {},
2311 "6": { "V": "", "W": "" },
2312 "7": { "X": "" },
2313 "8": { "Y": {}, "Z": "" }
2314 }
2315 }),
2316 )
2317 .await;
2318 fs.insert_tree(
2319 "/root2",
2320 json!({
2321 "d": {
2322 "9": ""
2323 },
2324 "e": {}
2325 }),
2326 )
2327 .await;
2328
2329 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2330 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2331 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2332 let panel = workspace
2333 .update(cx, |workspace, cx| {
2334 let panel = ProjectPanel::new(workspace, cx);
2335 workspace.add_panel(panel.clone(), cx);
2336 panel
2337 })
2338 .unwrap();
2339
2340 select_path(&panel, "root1", cx);
2341 assert_eq!(
2342 visible_entries_as_strings(&panel, 0..10, cx),
2343 &[
2344 "v root1 <== selected",
2345 " > .git",
2346 " > a",
2347 " > b",
2348 " > C",
2349 " .dockerignore",
2350 "v root2",
2351 " > d",
2352 " > e",
2353 ]
2354 );
2355
2356 // Add a file with the root folder selected. The filename editor is placed
2357 // before the first file in the root folder.
2358 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2359 panel.update(cx, |panel, cx| {
2360 assert!(panel.filename_editor.read(cx).is_focused(cx));
2361 });
2362 assert_eq!(
2363 visible_entries_as_strings(&panel, 0..10, cx),
2364 &[
2365 "v root1",
2366 " > .git",
2367 " > a",
2368 " > b",
2369 " > C",
2370 " [EDITOR: ''] <== selected",
2371 " .dockerignore",
2372 "v root2",
2373 " > d",
2374 " > e",
2375 ]
2376 );
2377
2378 let confirm = panel.update(cx, |panel, cx| {
2379 panel
2380 .filename_editor
2381 .update(cx, |editor, cx| editor.set_text("the-new-filename", cx));
2382 panel.confirm_edit(cx).unwrap()
2383 });
2384 assert_eq!(
2385 visible_entries_as_strings(&panel, 0..10, cx),
2386 &[
2387 "v root1",
2388 " > .git",
2389 " > a",
2390 " > b",
2391 " > C",
2392 " [PROCESSING: 'the-new-filename'] <== selected",
2393 " .dockerignore",
2394 "v root2",
2395 " > d",
2396 " > e",
2397 ]
2398 );
2399
2400 confirm.await.unwrap();
2401 assert_eq!(
2402 visible_entries_as_strings(&panel, 0..10, cx),
2403 &[
2404 "v root1",
2405 " > .git",
2406 " > a",
2407 " > b",
2408 " > C",
2409 " .dockerignore",
2410 " the-new-filename <== selected",
2411 "v root2",
2412 " > d",
2413 " > e",
2414 ]
2415 );
2416
2417 select_path(&panel, "root1/b", cx);
2418 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2419 assert_eq!(
2420 visible_entries_as_strings(&panel, 0..10, cx),
2421 &[
2422 "v root1",
2423 " > .git",
2424 " > a",
2425 " v b",
2426 " > 3",
2427 " > 4",
2428 " [EDITOR: ''] <== selected",
2429 " > C",
2430 " .dockerignore",
2431 " the-new-filename",
2432 ]
2433 );
2434
2435 panel
2436 .update(cx, |panel, cx| {
2437 panel
2438 .filename_editor
2439 .update(cx, |editor, cx| editor.set_text("another-filename.txt", cx));
2440 panel.confirm_edit(cx).unwrap()
2441 })
2442 .await
2443 .unwrap();
2444 assert_eq!(
2445 visible_entries_as_strings(&panel, 0..10, cx),
2446 &[
2447 "v root1",
2448 " > .git",
2449 " > a",
2450 " v b",
2451 " > 3",
2452 " > 4",
2453 " another-filename.txt <== selected",
2454 " > C",
2455 " .dockerignore",
2456 " the-new-filename",
2457 ]
2458 );
2459
2460 select_path(&panel, "root1/b/another-filename.txt", cx);
2461 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2462 assert_eq!(
2463 visible_entries_as_strings(&panel, 0..10, cx),
2464 &[
2465 "v root1",
2466 " > .git",
2467 " > a",
2468 " v b",
2469 " > 3",
2470 " > 4",
2471 " [EDITOR: 'another-filename.txt'] <== selected",
2472 " > C",
2473 " .dockerignore",
2474 " the-new-filename",
2475 ]
2476 );
2477
2478 let confirm = panel.update(cx, |panel, cx| {
2479 panel.filename_editor.update(cx, |editor, cx| {
2480 let file_name_selections = editor.selections.all::<usize>(cx);
2481 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2482 let file_name_selection = &file_name_selections[0];
2483 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2484 assert_eq!(file_name_selection.end, "another-filename".len(), "Should not select file extension");
2485
2486 editor.set_text("a-different-filename.tar.gz", cx)
2487 });
2488 panel.confirm_edit(cx).unwrap()
2489 });
2490 assert_eq!(
2491 visible_entries_as_strings(&panel, 0..10, cx),
2492 &[
2493 "v root1",
2494 " > .git",
2495 " > a",
2496 " v b",
2497 " > 3",
2498 " > 4",
2499 " [PROCESSING: 'a-different-filename.tar.gz'] <== selected",
2500 " > C",
2501 " .dockerignore",
2502 " the-new-filename",
2503 ]
2504 );
2505
2506 confirm.await.unwrap();
2507 assert_eq!(
2508 visible_entries_as_strings(&panel, 0..10, cx),
2509 &[
2510 "v root1",
2511 " > .git",
2512 " > a",
2513 " v b",
2514 " > 3",
2515 " > 4",
2516 " a-different-filename.tar.gz <== selected",
2517 " > C",
2518 " .dockerignore",
2519 " the-new-filename",
2520 ]
2521 );
2522
2523 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
2524 assert_eq!(
2525 visible_entries_as_strings(&panel, 0..10, cx),
2526 &[
2527 "v root1",
2528 " > .git",
2529 " > a",
2530 " v b",
2531 " > 3",
2532 " > 4",
2533 " [EDITOR: 'a-different-filename.tar.gz'] <== selected",
2534 " > C",
2535 " .dockerignore",
2536 " the-new-filename",
2537 ]
2538 );
2539
2540 panel.update(cx, |panel, cx| {
2541 panel.filename_editor.update(cx, |editor, cx| {
2542 let file_name_selections = editor.selections.all::<usize>(cx);
2543 assert_eq!(file_name_selections.len(), 1, "File editing should have a single selection, but got: {file_name_selections:?}");
2544 let file_name_selection = &file_name_selections[0];
2545 assert_eq!(file_name_selection.start, 0, "Should select the file name from the start");
2546 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..");
2547
2548 });
2549 panel.cancel(&Cancel, cx)
2550 });
2551
2552 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
2553 assert_eq!(
2554 visible_entries_as_strings(&panel, 0..10, cx),
2555 &[
2556 "v root1",
2557 " > .git",
2558 " > a",
2559 " v b",
2560 " > [EDITOR: ''] <== selected",
2561 " > 3",
2562 " > 4",
2563 " a-different-filename.tar.gz",
2564 " > C",
2565 " .dockerignore",
2566 ]
2567 );
2568
2569 let confirm = panel.update(cx, |panel, cx| {
2570 panel
2571 .filename_editor
2572 .update(cx, |editor, cx| editor.set_text("new-dir", cx));
2573 panel.confirm_edit(cx).unwrap()
2574 });
2575 panel.update(cx, |panel, cx| panel.select_next(&Default::default(), cx));
2576 assert_eq!(
2577 visible_entries_as_strings(&panel, 0..10, cx),
2578 &[
2579 "v root1",
2580 " > .git",
2581 " > a",
2582 " v b",
2583 " > [PROCESSING: 'new-dir']",
2584 " > 3 <== selected",
2585 " > 4",
2586 " a-different-filename.tar.gz",
2587 " > C",
2588 " .dockerignore",
2589 ]
2590 );
2591
2592 confirm.await.unwrap();
2593 assert_eq!(
2594 visible_entries_as_strings(&panel, 0..10, cx),
2595 &[
2596 "v root1",
2597 " > .git",
2598 " > a",
2599 " v b",
2600 " > 3 <== selected",
2601 " > 4",
2602 " > new-dir",
2603 " a-different-filename.tar.gz",
2604 " > C",
2605 " .dockerignore",
2606 ]
2607 );
2608
2609 panel.update(cx, |panel, cx| panel.rename(&Default::default(), cx));
2610 assert_eq!(
2611 visible_entries_as_strings(&panel, 0..10, cx),
2612 &[
2613 "v root1",
2614 " > .git",
2615 " > a",
2616 " v b",
2617 " > [EDITOR: '3'] <== selected",
2618 " > 4",
2619 " > new-dir",
2620 " a-different-filename.tar.gz",
2621 " > C",
2622 " .dockerignore",
2623 ]
2624 );
2625
2626 // Dismiss the rename editor when it loses focus.
2627 workspace.update(cx, |_, cx| cx.blur()).unwrap();
2628 assert_eq!(
2629 visible_entries_as_strings(&panel, 0..10, cx),
2630 &[
2631 "v root1",
2632 " > .git",
2633 " > a",
2634 " v b",
2635 " > 3 <== selected",
2636 " > 4",
2637 " > new-dir",
2638 " a-different-filename.tar.gz",
2639 " > C",
2640 " .dockerignore",
2641 ]
2642 );
2643 }
2644
2645 #[gpui::test(iterations = 10)]
2646 async fn test_adding_directories_via_file(cx: &mut gpui::TestAppContext) {
2647 init_test(cx);
2648
2649 let fs = FakeFs::new(cx.executor().clone());
2650 fs.insert_tree(
2651 "/root1",
2652 json!({
2653 ".dockerignore": "",
2654 ".git": {
2655 "HEAD": "",
2656 },
2657 "a": {
2658 "0": { "q": "", "r": "", "s": "" },
2659 "1": { "t": "", "u": "" },
2660 "2": { "v": "", "w": "", "x": "", "y": "" },
2661 },
2662 "b": {
2663 "3": { "Q": "" },
2664 "4": { "R": "", "S": "", "T": "", "U": "" },
2665 },
2666 "C": {
2667 "5": {},
2668 "6": { "V": "", "W": "" },
2669 "7": { "X": "" },
2670 "8": { "Y": {}, "Z": "" }
2671 }
2672 }),
2673 )
2674 .await;
2675 fs.insert_tree(
2676 "/root2",
2677 json!({
2678 "d": {
2679 "9": ""
2680 },
2681 "e": {}
2682 }),
2683 )
2684 .await;
2685
2686 let project = Project::test(fs.clone(), ["/root1".as_ref(), "/root2".as_ref()], cx).await;
2687 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2688 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2689 let panel = workspace
2690 .update(cx, |workspace, cx| {
2691 let panel = ProjectPanel::new(workspace, cx);
2692 workspace.add_panel(panel.clone(), cx);
2693 panel
2694 })
2695 .unwrap();
2696
2697 select_path(&panel, "root1", cx);
2698 assert_eq!(
2699 visible_entries_as_strings(&panel, 0..10, cx),
2700 &[
2701 "v root1 <== selected",
2702 " > .git",
2703 " > a",
2704 " > b",
2705 " > C",
2706 " .dockerignore",
2707 "v root2",
2708 " > d",
2709 " > e",
2710 ]
2711 );
2712
2713 // Add a file with the root folder selected. The filename editor is placed
2714 // before the first file in the root folder.
2715 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
2716 panel.update(cx, |panel, cx| {
2717 assert!(panel.filename_editor.read(cx).is_focused(cx));
2718 });
2719 assert_eq!(
2720 visible_entries_as_strings(&panel, 0..10, cx),
2721 &[
2722 "v root1",
2723 " > .git",
2724 " > a",
2725 " > b",
2726 " > C",
2727 " [EDITOR: ''] <== selected",
2728 " .dockerignore",
2729 "v root2",
2730 " > d",
2731 " > e",
2732 ]
2733 );
2734
2735 let confirm = panel.update(cx, |panel, cx| {
2736 panel.filename_editor.update(cx, |editor, cx| {
2737 editor.set_text("/bdir1/dir2/the-new-filename", cx)
2738 });
2739 panel.confirm_edit(cx).unwrap()
2740 });
2741
2742 assert_eq!(
2743 visible_entries_as_strings(&panel, 0..10, cx),
2744 &[
2745 "v root1",
2746 " > .git",
2747 " > a",
2748 " > b",
2749 " > C",
2750 " [PROCESSING: '/bdir1/dir2/the-new-filename'] <== selected",
2751 " .dockerignore",
2752 "v root2",
2753 " > d",
2754 " > e",
2755 ]
2756 );
2757
2758 confirm.await.unwrap();
2759 assert_eq!(
2760 visible_entries_as_strings(&panel, 0..13, cx),
2761 &[
2762 "v root1",
2763 " > .git",
2764 " > a",
2765 " > b",
2766 " v bdir1",
2767 " v dir2",
2768 " the-new-filename <== selected",
2769 " > C",
2770 " .dockerignore",
2771 "v root2",
2772 " > d",
2773 " > e",
2774 ]
2775 );
2776 }
2777
2778 #[gpui::test]
2779 async fn test_copy_paste(cx: &mut gpui::TestAppContext) {
2780 init_test(cx);
2781
2782 let fs = FakeFs::new(cx.executor().clone());
2783 fs.insert_tree(
2784 "/root1",
2785 json!({
2786 "one.two.txt": "",
2787 "one.txt": ""
2788 }),
2789 )
2790 .await;
2791
2792 let project = Project::test(fs.clone(), ["/root1".as_ref()], cx).await;
2793 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2794 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2795 let panel = workspace
2796 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2797 .unwrap();
2798
2799 panel.update(cx, |panel, cx| {
2800 panel.select_next(&Default::default(), cx);
2801 panel.select_next(&Default::default(), cx);
2802 });
2803
2804 assert_eq!(
2805 visible_entries_as_strings(&panel, 0..50, cx),
2806 &[
2807 //
2808 "v root1",
2809 " one.two.txt <== selected",
2810 " one.txt",
2811 ]
2812 );
2813
2814 // Regression test - file name is created correctly when
2815 // the copied file's name contains multiple dots.
2816 panel.update(cx, |panel, cx| {
2817 panel.copy(&Default::default(), cx);
2818 panel.paste(&Default::default(), cx);
2819 });
2820 cx.executor().run_until_parked();
2821
2822 assert_eq!(
2823 visible_entries_as_strings(&panel, 0..50, cx),
2824 &[
2825 //
2826 "v root1",
2827 " one.two copy.txt",
2828 " one.two.txt <== selected",
2829 " one.txt",
2830 ]
2831 );
2832
2833 panel.update(cx, |panel, cx| {
2834 panel.paste(&Default::default(), cx);
2835 });
2836 cx.executor().run_until_parked();
2837
2838 assert_eq!(
2839 visible_entries_as_strings(&panel, 0..50, cx),
2840 &[
2841 //
2842 "v root1",
2843 " one.two copy 1.txt",
2844 " one.two copy.txt",
2845 " one.two.txt <== selected",
2846 " one.txt",
2847 ]
2848 );
2849 }
2850
2851 #[gpui::test]
2852 async fn test_copy_paste_directory(cx: &mut gpui::TestAppContext) {
2853 init_test(cx);
2854
2855 let fs = FakeFs::new(cx.executor().clone());
2856 fs.insert_tree(
2857 "/root",
2858 json!({
2859 "a": {
2860 "one.txt": "",
2861 "two.txt": "",
2862 "inner_dir": {
2863 "three.txt": "",
2864 "four.txt": "",
2865 }
2866 },
2867 "b": {}
2868 }),
2869 )
2870 .await;
2871
2872 let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
2873 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2874 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2875 let panel = workspace
2876 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2877 .unwrap();
2878
2879 select_path(&panel, "root/a", cx);
2880 panel.update(cx, |panel, cx| {
2881 panel.copy(&Default::default(), cx);
2882 panel.select_next(&Default::default(), cx);
2883 panel.paste(&Default::default(), cx);
2884 });
2885 cx.executor().run_until_parked();
2886
2887 let pasted_dir = find_project_entry(&panel, "root/b/a", cx);
2888 assert_ne!(pasted_dir, None, "Pasted directory should have an entry");
2889
2890 let pasted_dir_file = find_project_entry(&panel, "root/b/a/one.txt", cx);
2891 assert_ne!(
2892 pasted_dir_file, None,
2893 "Pasted directory file should have an entry"
2894 );
2895
2896 let pasted_dir_inner_dir = find_project_entry(&panel, "root/b/a/inner_dir", cx);
2897 assert_ne!(
2898 pasted_dir_inner_dir, None,
2899 "Directories inside pasted directory should have an entry"
2900 );
2901
2902 toggle_expand_dir(&panel, "root/b", cx);
2903 toggle_expand_dir(&panel, "root/b/a", cx);
2904 toggle_expand_dir(&panel, "root/b/a/inner_dir", cx);
2905
2906 assert_eq!(
2907 visible_entries_as_strings(&panel, 0..50, cx),
2908 &[
2909 //
2910 "v root",
2911 " > a",
2912 " v b",
2913 " v a",
2914 " v inner_dir <== selected",
2915 " four.txt",
2916 " three.txt",
2917 " one.txt",
2918 " two.txt",
2919 ]
2920 );
2921
2922 select_path(&panel, "root", cx);
2923 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2924 cx.executor().run_until_parked();
2925 panel.update(cx, |panel, cx| panel.paste(&Default::default(), cx));
2926 cx.executor().run_until_parked();
2927 assert_eq!(
2928 visible_entries_as_strings(&panel, 0..50, cx),
2929 &[
2930 //
2931 "v root <== selected",
2932 " > a",
2933 " > a copy",
2934 " > a copy 1",
2935 " v b",
2936 " v a",
2937 " v inner_dir",
2938 " four.txt",
2939 " three.txt",
2940 " one.txt",
2941 " two.txt"
2942 ]
2943 );
2944 }
2945
2946 #[gpui::test]
2947 async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) {
2948 init_test_with_editor(cx);
2949
2950 let fs = FakeFs::new(cx.executor().clone());
2951 fs.insert_tree(
2952 "/src",
2953 json!({
2954 "test": {
2955 "first.rs": "// First Rust file",
2956 "second.rs": "// Second Rust file",
2957 "third.rs": "// Third Rust file",
2958 }
2959 }),
2960 )
2961 .await;
2962
2963 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
2964 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
2965 let cx = &mut VisualTestContext::from_window(*workspace, cx);
2966 let panel = workspace
2967 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
2968 .unwrap();
2969
2970 toggle_expand_dir(&panel, "src/test", cx);
2971 select_path(&panel, "src/test/first.rs", cx);
2972 panel.update(cx, |panel, cx| panel.open(&Open, cx));
2973 cx.executor().run_until_parked();
2974 assert_eq!(
2975 visible_entries_as_strings(&panel, 0..10, cx),
2976 &[
2977 "v src",
2978 " v test",
2979 " first.rs <== selected",
2980 " second.rs",
2981 " third.rs"
2982 ]
2983 );
2984 ensure_single_file_is_opened(&workspace, "test/first.rs", cx);
2985
2986 submit_deletion(&panel, cx);
2987 assert_eq!(
2988 visible_entries_as_strings(&panel, 0..10, cx),
2989 &[
2990 "v src",
2991 " v test",
2992 " second.rs",
2993 " third.rs"
2994 ],
2995 "Project panel should have no deleted file, no other file is selected in it"
2996 );
2997 ensure_no_open_items_and_panes(&workspace, cx);
2998
2999 select_path(&panel, "src/test/second.rs", cx);
3000 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3001 cx.executor().run_until_parked();
3002 assert_eq!(
3003 visible_entries_as_strings(&panel, 0..10, cx),
3004 &[
3005 "v src",
3006 " v test",
3007 " second.rs <== selected",
3008 " third.rs"
3009 ]
3010 );
3011 ensure_single_file_is_opened(&workspace, "test/second.rs", cx);
3012
3013 workspace
3014 .update(cx, |workspace, cx| {
3015 let active_items = workspace
3016 .panes()
3017 .iter()
3018 .filter_map(|pane| pane.read(cx).active_item())
3019 .collect::<Vec<_>>();
3020 assert_eq!(active_items.len(), 1);
3021 let open_editor = active_items
3022 .into_iter()
3023 .next()
3024 .unwrap()
3025 .downcast::<Editor>()
3026 .expect("Open item should be an editor");
3027 open_editor.update(cx, |editor, cx| editor.set_text("Another text!", cx));
3028 })
3029 .unwrap();
3030 submit_deletion_skipping_prompt(&panel, cx);
3031 assert_eq!(
3032 visible_entries_as_strings(&panel, 0..10, cx),
3033 &["v src", " v test", " third.rs"],
3034 "Project panel should have no deleted file, with one last file remaining"
3035 );
3036 ensure_no_open_items_and_panes(&workspace, cx);
3037 }
3038
3039 #[gpui::test]
3040 async fn test_create_duplicate_items(cx: &mut gpui::TestAppContext) {
3041 init_test_with_editor(cx);
3042
3043 let fs = FakeFs::new(cx.executor().clone());
3044 fs.insert_tree(
3045 "/src",
3046 json!({
3047 "test": {
3048 "first.rs": "// First Rust file",
3049 "second.rs": "// Second Rust file",
3050 "third.rs": "// Third Rust file",
3051 }
3052 }),
3053 )
3054 .await;
3055
3056 let project = Project::test(fs.clone(), ["/src".as_ref()], cx).await;
3057 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3058 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3059 let panel = workspace
3060 .update(cx, |workspace, cx| {
3061 let panel = ProjectPanel::new(workspace, cx);
3062 workspace.add_panel(panel.clone(), cx);
3063 panel
3064 })
3065 .unwrap();
3066
3067 select_path(&panel, "src/", cx);
3068 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3069 cx.executor().run_until_parked();
3070 assert_eq!(
3071 visible_entries_as_strings(&panel, 0..10, cx),
3072 &[
3073 //
3074 "v src <== selected",
3075 " > test"
3076 ]
3077 );
3078 panel.update(cx, |panel, cx| panel.new_directory(&NewDirectory, cx));
3079 panel.update(cx, |panel, cx| {
3080 assert!(panel.filename_editor.read(cx).is_focused(cx));
3081 });
3082 assert_eq!(
3083 visible_entries_as_strings(&panel, 0..10, cx),
3084 &[
3085 //
3086 "v src",
3087 " > [EDITOR: ''] <== selected",
3088 " > test"
3089 ]
3090 );
3091 panel.update(cx, |panel, cx| {
3092 panel
3093 .filename_editor
3094 .update(cx, |editor, cx| editor.set_text("test", cx));
3095 assert!(
3096 panel.confirm_edit(cx).is_none(),
3097 "Should not allow to confirm on conflicting new directory name"
3098 )
3099 });
3100 assert_eq!(
3101 visible_entries_as_strings(&panel, 0..10, cx),
3102 &[
3103 //
3104 "v src",
3105 " > test"
3106 ],
3107 "File list should be unchanged after failed folder create confirmation"
3108 );
3109
3110 select_path(&panel, "src/test/", cx);
3111 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3112 cx.executor().run_until_parked();
3113 assert_eq!(
3114 visible_entries_as_strings(&panel, 0..10, cx),
3115 &[
3116 //
3117 "v src",
3118 " > test <== selected"
3119 ]
3120 );
3121 panel.update(cx, |panel, cx| panel.new_file(&NewFile, cx));
3122 panel.update(cx, |panel, cx| {
3123 assert!(panel.filename_editor.read(cx).is_focused(cx));
3124 });
3125 assert_eq!(
3126 visible_entries_as_strings(&panel, 0..10, cx),
3127 &[
3128 "v src",
3129 " v test",
3130 " [EDITOR: ''] <== selected",
3131 " first.rs",
3132 " second.rs",
3133 " third.rs"
3134 ]
3135 );
3136 panel.update(cx, |panel, cx| {
3137 panel
3138 .filename_editor
3139 .update(cx, |editor, cx| editor.set_text("first.rs", cx));
3140 assert!(
3141 panel.confirm_edit(cx).is_none(),
3142 "Should not allow to confirm on conflicting new file name"
3143 )
3144 });
3145 assert_eq!(
3146 visible_entries_as_strings(&panel, 0..10, cx),
3147 &[
3148 "v src",
3149 " v test",
3150 " first.rs",
3151 " second.rs",
3152 " third.rs"
3153 ],
3154 "File list should be unchanged after failed file create confirmation"
3155 );
3156
3157 select_path(&panel, "src/test/first.rs", cx);
3158 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3159 cx.executor().run_until_parked();
3160 assert_eq!(
3161 visible_entries_as_strings(&panel, 0..10, cx),
3162 &[
3163 "v src",
3164 " v test",
3165 " first.rs <== selected",
3166 " second.rs",
3167 " third.rs"
3168 ],
3169 );
3170 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3171 panel.update(cx, |panel, cx| {
3172 assert!(panel.filename_editor.read(cx).is_focused(cx));
3173 });
3174 assert_eq!(
3175 visible_entries_as_strings(&panel, 0..10, cx),
3176 &[
3177 "v src",
3178 " v test",
3179 " [EDITOR: 'first.rs'] <== selected",
3180 " second.rs",
3181 " third.rs"
3182 ]
3183 );
3184 panel.update(cx, |panel, cx| {
3185 panel
3186 .filename_editor
3187 .update(cx, |editor, cx| editor.set_text("second.rs", cx));
3188 assert!(
3189 panel.confirm_edit(cx).is_none(),
3190 "Should not allow to confirm on conflicting file rename"
3191 )
3192 });
3193 assert_eq!(
3194 visible_entries_as_strings(&panel, 0..10, cx),
3195 &[
3196 "v src",
3197 " v test",
3198 " first.rs <== selected",
3199 " second.rs",
3200 " third.rs"
3201 ],
3202 "File list should be unchanged after failed rename confirmation"
3203 );
3204 }
3205
3206 #[gpui::test]
3207 async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
3208 init_test_with_editor(cx);
3209
3210 let fs = FakeFs::new(cx.executor().clone());
3211 fs.insert_tree(
3212 "/project_root",
3213 json!({
3214 "dir_1": {
3215 "nested_dir": {
3216 "file_a.py": "# File contents",
3217 }
3218 },
3219 "file_1.py": "# File contents",
3220 }),
3221 )
3222 .await;
3223
3224 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3225 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3226 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3227 let panel = workspace
3228 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3229 .unwrap();
3230
3231 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3232 cx.executor().run_until_parked();
3233 select_path(&panel, "project_root/dir_1", cx);
3234 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3235 select_path(&panel, "project_root/dir_1/nested_dir", cx);
3236 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3237 panel.update(cx, |panel, cx| panel.open(&Open, cx));
3238 cx.executor().run_until_parked();
3239 assert_eq!(
3240 visible_entries_as_strings(&panel, 0..10, cx),
3241 &[
3242 "v project_root",
3243 " v dir_1",
3244 " > nested_dir <== selected",
3245 " file_1.py",
3246 ]
3247 );
3248 }
3249
3250 #[gpui::test]
3251 async fn test_collapse_all_entries(cx: &mut gpui::TestAppContext) {
3252 init_test_with_editor(cx);
3253
3254 let fs = FakeFs::new(cx.executor().clone());
3255 fs.insert_tree(
3256 "/project_root",
3257 json!({
3258 "dir_1": {
3259 "nested_dir": {
3260 "file_a.py": "# File contents",
3261 "file_b.py": "# File contents",
3262 "file_c.py": "# File contents",
3263 },
3264 "file_1.py": "# File contents",
3265 "file_2.py": "# File contents",
3266 "file_3.py": "# File contents",
3267 },
3268 "dir_2": {
3269 "file_1.py": "# File contents",
3270 "file_2.py": "# File contents",
3271 "file_3.py": "# File contents",
3272 }
3273 }),
3274 )
3275 .await;
3276
3277 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3278 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3279 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3280 let panel = workspace
3281 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3282 .unwrap();
3283
3284 panel.update(cx, |panel, cx| {
3285 panel.collapse_all_entries(&CollapseAllEntries, cx)
3286 });
3287 cx.executor().run_until_parked();
3288 assert_eq!(
3289 visible_entries_as_strings(&panel, 0..10, cx),
3290 &["v project_root", " > dir_1", " > dir_2",]
3291 );
3292
3293 // Open dir_1 and make sure nested_dir was collapsed when running collapse_all_entries
3294 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3295 cx.executor().run_until_parked();
3296 assert_eq!(
3297 visible_entries_as_strings(&panel, 0..10, cx),
3298 &[
3299 "v project_root",
3300 " v dir_1 <== selected",
3301 " > nested_dir",
3302 " file_1.py",
3303 " file_2.py",
3304 " file_3.py",
3305 " > dir_2",
3306 ]
3307 );
3308 }
3309
3310 #[gpui::test]
3311 async fn test_new_file_move(cx: &mut gpui::TestAppContext) {
3312 init_test(cx);
3313
3314 let fs = FakeFs::new(cx.executor().clone());
3315 fs.as_fake().insert_tree("/root", json!({})).await;
3316 let project = Project::test(fs, ["/root".as_ref()], cx).await;
3317 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3318 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3319 let panel = workspace
3320 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3321 .unwrap();
3322
3323 // Make a new buffer with no backing file
3324 workspace
3325 .update(cx, |workspace, cx| {
3326 Editor::new_file(workspace, &Default::default(), cx)
3327 })
3328 .unwrap();
3329
3330 // "Save as"" the buffer, creating a new backing file for it
3331 let save_task = workspace
3332 .update(cx, |workspace, cx| {
3333 workspace.save_active_item(workspace::SaveIntent::Save, cx)
3334 })
3335 .unwrap();
3336
3337 cx.executor().run_until_parked();
3338 cx.simulate_new_path_selection(|_| Some(PathBuf::from("/root/new")));
3339 save_task.await.unwrap();
3340
3341 // Rename the file
3342 select_path(&panel, "root/new", cx);
3343 assert_eq!(
3344 visible_entries_as_strings(&panel, 0..10, cx),
3345 &["v root", " new <== selected"]
3346 );
3347 panel.update(cx, |panel, cx| panel.rename(&Rename, cx));
3348 panel.update(cx, |panel, cx| {
3349 panel
3350 .filename_editor
3351 .update(cx, |editor, cx| editor.set_text("newer", cx));
3352 });
3353 panel.update(cx, |panel, cx| panel.confirm(&Confirm, cx));
3354
3355 cx.executor().run_until_parked();
3356 assert_eq!(
3357 visible_entries_as_strings(&panel, 0..10, cx),
3358 &["v root", " newer <== selected"]
3359 );
3360
3361 workspace
3362 .update(cx, |workspace, cx| {
3363 workspace.save_active_item(workspace::SaveIntent::Save, cx)
3364 })
3365 .unwrap()
3366 .await
3367 .unwrap();
3368
3369 cx.executor().run_until_parked();
3370 // assert that saving the file doesn't restore "new"
3371 assert_eq!(
3372 visible_entries_as_strings(&panel, 0..10, cx),
3373 &["v root", " newer <== selected"]
3374 );
3375 }
3376
3377 #[gpui::test]
3378 async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) {
3379 init_test_with_editor(cx);
3380 cx.update(|cx| {
3381 cx.update_global::<SettingsStore, _>(|store, cx| {
3382 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3383 worktree_settings.file_scan_exclusions = Some(Vec::new());
3384 });
3385 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3386 project_panel_settings.auto_reveal_entries = Some(false)
3387 });
3388 })
3389 });
3390
3391 let fs = FakeFs::new(cx.background_executor.clone());
3392 fs.insert_tree(
3393 "/project_root",
3394 json!({
3395 ".git": {},
3396 ".gitignore": "**/gitignored_dir",
3397 "dir_1": {
3398 "file_1.py": "# File 1_1 contents",
3399 "file_2.py": "# File 1_2 contents",
3400 "file_3.py": "# File 1_3 contents",
3401 "gitignored_dir": {
3402 "file_a.py": "# File contents",
3403 "file_b.py": "# File contents",
3404 "file_c.py": "# File contents",
3405 },
3406 },
3407 "dir_2": {
3408 "file_1.py": "# File 2_1 contents",
3409 "file_2.py": "# File 2_2 contents",
3410 "file_3.py": "# File 2_3 contents",
3411 }
3412 }),
3413 )
3414 .await;
3415
3416 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3417 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3418 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3419 let panel = workspace
3420 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3421 .unwrap();
3422
3423 assert_eq!(
3424 visible_entries_as_strings(&panel, 0..20, cx),
3425 &[
3426 "v project_root",
3427 " > .git",
3428 " > dir_1",
3429 " > dir_2",
3430 " .gitignore",
3431 ]
3432 );
3433
3434 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3435 .expect("dir 1 file is not ignored and should have an entry");
3436 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3437 .expect("dir 2 file is not ignored and should have an entry");
3438 let gitignored_dir_file =
3439 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3440 assert_eq!(
3441 gitignored_dir_file, None,
3442 "File in the gitignored dir should not have an entry before its dir is toggled"
3443 );
3444
3445 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3446 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3447 cx.executor().run_until_parked();
3448 assert_eq!(
3449 visible_entries_as_strings(&panel, 0..20, cx),
3450 &[
3451 "v project_root",
3452 " > .git",
3453 " v dir_1",
3454 " v gitignored_dir <== selected",
3455 " file_a.py",
3456 " file_b.py",
3457 " file_c.py",
3458 " file_1.py",
3459 " file_2.py",
3460 " file_3.py",
3461 " > dir_2",
3462 " .gitignore",
3463 ],
3464 "Should show gitignored dir file list in the project panel"
3465 );
3466 let gitignored_dir_file =
3467 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3468 .expect("after gitignored dir got opened, a file entry should be present");
3469
3470 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3471 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3472 assert_eq!(
3473 visible_entries_as_strings(&panel, 0..20, cx),
3474 &[
3475 "v project_root",
3476 " > .git",
3477 " > dir_1 <== selected",
3478 " > dir_2",
3479 " .gitignore",
3480 ],
3481 "Should hide all dir contents again and prepare for the auto reveal test"
3482 );
3483
3484 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3485 panel.update(cx, |panel, cx| {
3486 panel.project.update(cx, |_, cx| {
3487 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3488 })
3489 });
3490 cx.run_until_parked();
3491 assert_eq!(
3492 visible_entries_as_strings(&panel, 0..20, cx),
3493 &[
3494 "v project_root",
3495 " > .git",
3496 " > dir_1 <== selected",
3497 " > dir_2",
3498 " .gitignore",
3499 ],
3500 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3501 );
3502 }
3503
3504 cx.update(|cx| {
3505 cx.update_global::<SettingsStore, _>(|store, cx| {
3506 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3507 project_panel_settings.auto_reveal_entries = Some(true)
3508 });
3509 })
3510 });
3511
3512 panel.update(cx, |panel, cx| {
3513 panel.project.update(cx, |_, cx| {
3514 cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file)))
3515 })
3516 });
3517 cx.run_until_parked();
3518 assert_eq!(
3519 visible_entries_as_strings(&panel, 0..20, cx),
3520 &[
3521 "v project_root",
3522 " > .git",
3523 " v dir_1",
3524 " > gitignored_dir",
3525 " file_1.py <== selected",
3526 " file_2.py",
3527 " file_3.py",
3528 " > dir_2",
3529 " .gitignore",
3530 ],
3531 "When auto reveal is enabled, not ignored dir_1 entry should be revealed"
3532 );
3533
3534 panel.update(cx, |panel, cx| {
3535 panel.project.update(cx, |_, cx| {
3536 cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file)))
3537 })
3538 });
3539 cx.run_until_parked();
3540 assert_eq!(
3541 visible_entries_as_strings(&panel, 0..20, cx),
3542 &[
3543 "v project_root",
3544 " > .git",
3545 " v dir_1",
3546 " > gitignored_dir",
3547 " file_1.py",
3548 " file_2.py",
3549 " file_3.py",
3550 " v dir_2",
3551 " file_1.py <== selected",
3552 " file_2.py",
3553 " file_3.py",
3554 " .gitignore",
3555 ],
3556 "When auto reveal is enabled, not ignored dir_2 entry should be revealed"
3557 );
3558
3559 panel.update(cx, |panel, cx| {
3560 panel.project.update(cx, |_, cx| {
3561 cx.emit(project::Event::ActiveEntryChanged(Some(
3562 gitignored_dir_file,
3563 )))
3564 })
3565 });
3566 cx.run_until_parked();
3567 assert_eq!(
3568 visible_entries_as_strings(&panel, 0..20, cx),
3569 &[
3570 "v project_root",
3571 " > .git",
3572 " v dir_1",
3573 " > gitignored_dir",
3574 " file_1.py",
3575 " file_2.py",
3576 " file_3.py",
3577 " v dir_2",
3578 " file_1.py <== selected",
3579 " file_2.py",
3580 " file_3.py",
3581 " .gitignore",
3582 ],
3583 "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel"
3584 );
3585
3586 panel.update(cx, |panel, cx| {
3587 panel.project.update(cx, |_, cx| {
3588 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3589 })
3590 });
3591 cx.run_until_parked();
3592 assert_eq!(
3593 visible_entries_as_strings(&panel, 0..20, cx),
3594 &[
3595 "v project_root",
3596 " > .git",
3597 " v dir_1",
3598 " v gitignored_dir",
3599 " file_a.py <== selected",
3600 " file_b.py",
3601 " file_c.py",
3602 " file_1.py",
3603 " file_2.py",
3604 " file_3.py",
3605 " v dir_2",
3606 " file_1.py",
3607 " file_2.py",
3608 " file_3.py",
3609 " .gitignore",
3610 ],
3611 "When a gitignored entry is explicitly revealed, it should be shown in the project tree"
3612 );
3613 }
3614
3615 #[gpui::test]
3616 async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
3617 init_test_with_editor(cx);
3618 cx.update(|cx| {
3619 cx.update_global::<SettingsStore, _>(|store, cx| {
3620 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3621 worktree_settings.file_scan_exclusions = Some(Vec::new());
3622 });
3623 store.update_user_settings::<ProjectPanelSettings>(cx, |project_panel_settings| {
3624 project_panel_settings.auto_reveal_entries = Some(false)
3625 });
3626 })
3627 });
3628
3629 let fs = FakeFs::new(cx.background_executor.clone());
3630 fs.insert_tree(
3631 "/project_root",
3632 json!({
3633 ".git": {},
3634 ".gitignore": "**/gitignored_dir",
3635 "dir_1": {
3636 "file_1.py": "# File 1_1 contents",
3637 "file_2.py": "# File 1_2 contents",
3638 "file_3.py": "# File 1_3 contents",
3639 "gitignored_dir": {
3640 "file_a.py": "# File contents",
3641 "file_b.py": "# File contents",
3642 "file_c.py": "# File contents",
3643 },
3644 },
3645 "dir_2": {
3646 "file_1.py": "# File 2_1 contents",
3647 "file_2.py": "# File 2_2 contents",
3648 "file_3.py": "# File 2_3 contents",
3649 }
3650 }),
3651 )
3652 .await;
3653
3654 let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
3655 let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
3656 let cx = &mut VisualTestContext::from_window(*workspace, cx);
3657 let panel = workspace
3658 .update(cx, |workspace, cx| ProjectPanel::new(workspace, cx))
3659 .unwrap();
3660
3661 assert_eq!(
3662 visible_entries_as_strings(&panel, 0..20, cx),
3663 &[
3664 "v project_root",
3665 " > .git",
3666 " > dir_1",
3667 " > dir_2",
3668 " .gitignore",
3669 ]
3670 );
3671
3672 let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx)
3673 .expect("dir 1 file is not ignored and should have an entry");
3674 let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx)
3675 .expect("dir 2 file is not ignored and should have an entry");
3676 let gitignored_dir_file =
3677 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx);
3678 assert_eq!(
3679 gitignored_dir_file, None,
3680 "File in the gitignored dir should not have an entry before its dir is toggled"
3681 );
3682
3683 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3684 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3685 cx.run_until_parked();
3686 assert_eq!(
3687 visible_entries_as_strings(&panel, 0..20, cx),
3688 &[
3689 "v project_root",
3690 " > .git",
3691 " v dir_1",
3692 " v gitignored_dir <== selected",
3693 " file_a.py",
3694 " file_b.py",
3695 " file_c.py",
3696 " file_1.py",
3697 " file_2.py",
3698 " file_3.py",
3699 " > dir_2",
3700 " .gitignore",
3701 ],
3702 "Should show gitignored dir file list in the project panel"
3703 );
3704 let gitignored_dir_file =
3705 find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx)
3706 .expect("after gitignored dir got opened, a file entry should be present");
3707
3708 toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx);
3709 toggle_expand_dir(&panel, "project_root/dir_1", cx);
3710 assert_eq!(
3711 visible_entries_as_strings(&panel, 0..20, cx),
3712 &[
3713 "v project_root",
3714 " > .git",
3715 " > dir_1 <== selected",
3716 " > dir_2",
3717 " .gitignore",
3718 ],
3719 "Should hide all dir contents again and prepare for the explicit reveal test"
3720 );
3721
3722 for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] {
3723 panel.update(cx, |panel, cx| {
3724 panel.project.update(cx, |_, cx| {
3725 cx.emit(project::Event::ActiveEntryChanged(Some(file_entry)))
3726 })
3727 });
3728 cx.run_until_parked();
3729 assert_eq!(
3730 visible_entries_as_strings(&panel, 0..20, cx),
3731 &[
3732 "v project_root",
3733 " > .git",
3734 " > dir_1 <== selected",
3735 " > dir_2",
3736 " .gitignore",
3737 ],
3738 "When no auto reveal is enabled, the selected entry should not be revealed in the project panel"
3739 );
3740 }
3741
3742 panel.update(cx, |panel, cx| {
3743 panel.project.update(cx, |_, cx| {
3744 cx.emit(project::Event::RevealInProjectPanel(dir_1_file))
3745 })
3746 });
3747 cx.run_until_parked();
3748 assert_eq!(
3749 visible_entries_as_strings(&panel, 0..20, cx),
3750 &[
3751 "v project_root",
3752 " > .git",
3753 " v dir_1",
3754 " > gitignored_dir",
3755 " file_1.py <== selected",
3756 " file_2.py",
3757 " file_3.py",
3758 " > dir_2",
3759 " .gitignore",
3760 ],
3761 "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel"
3762 );
3763
3764 panel.update(cx, |panel, cx| {
3765 panel.project.update(cx, |_, cx| {
3766 cx.emit(project::Event::RevealInProjectPanel(dir_2_file))
3767 })
3768 });
3769 cx.run_until_parked();
3770 assert_eq!(
3771 visible_entries_as_strings(&panel, 0..20, cx),
3772 &[
3773 "v project_root",
3774 " > .git",
3775 " v dir_1",
3776 " > gitignored_dir",
3777 " file_1.py",
3778 " file_2.py",
3779 " file_3.py",
3780 " v dir_2",
3781 " file_1.py <== selected",
3782 " file_2.py",
3783 " file_3.py",
3784 " .gitignore",
3785 ],
3786 "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel"
3787 );
3788
3789 panel.update(cx, |panel, cx| {
3790 panel.project.update(cx, |_, cx| {
3791 cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file))
3792 })
3793 });
3794 cx.run_until_parked();
3795 assert_eq!(
3796 visible_entries_as_strings(&panel, 0..20, cx),
3797 &[
3798 "v project_root",
3799 " > .git",
3800 " v dir_1",
3801 " v gitignored_dir",
3802 " file_a.py <== selected",
3803 " file_b.py",
3804 " file_c.py",
3805 " file_1.py",
3806 " file_2.py",
3807 " file_3.py",
3808 " v dir_2",
3809 " file_1.py",
3810 " file_2.py",
3811 " file_3.py",
3812 " .gitignore",
3813 ],
3814 "With no auto reveal, explicit reveal should show the gitignored entry in the project panel"
3815 );
3816 }
3817
3818 fn toggle_expand_dir(
3819 panel: &View<ProjectPanel>,
3820 path: impl AsRef<Path>,
3821 cx: &mut VisualTestContext,
3822 ) {
3823 let path = path.as_ref();
3824 panel.update(cx, |panel, cx| {
3825 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3826 let worktree = worktree.read(cx);
3827 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3828 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3829 panel.toggle_expanded(entry_id, cx);
3830 return;
3831 }
3832 }
3833 panic!("no worktree for path {:?}", path);
3834 });
3835 }
3836
3837 fn select_path(panel: &View<ProjectPanel>, path: impl AsRef<Path>, cx: &mut VisualTestContext) {
3838 let path = path.as_ref();
3839 panel.update(cx, |panel, cx| {
3840 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3841 let worktree = worktree.read(cx);
3842 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3843 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
3844 panel.selection = Some(crate::Selection {
3845 worktree_id: worktree.id(),
3846 entry_id,
3847 });
3848 return;
3849 }
3850 }
3851 panic!("no worktree for path {:?}", path);
3852 });
3853 }
3854
3855 fn find_project_entry(
3856 panel: &View<ProjectPanel>,
3857 path: impl AsRef<Path>,
3858 cx: &mut VisualTestContext,
3859 ) -> Option<ProjectEntryId> {
3860 let path = path.as_ref();
3861 panel.update(cx, |panel, cx| {
3862 for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
3863 let worktree = worktree.read(cx);
3864 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
3865 return worktree.entry_for_path(relative_path).map(|entry| entry.id);
3866 }
3867 }
3868 panic!("no worktree for path {path:?}");
3869 })
3870 }
3871
3872 fn visible_entries_as_strings(
3873 panel: &View<ProjectPanel>,
3874 range: Range<usize>,
3875 cx: &mut VisualTestContext,
3876 ) -> Vec<String> {
3877 let mut result = Vec::new();
3878 let mut project_entries = HashSet::default();
3879 let mut has_editor = false;
3880
3881 panel.update(cx, |panel, cx| {
3882 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
3883 if details.is_editing {
3884 assert!(!has_editor, "duplicate editor entry");
3885 has_editor = true;
3886 } else {
3887 assert!(
3888 project_entries.insert(project_entry),
3889 "duplicate project entry {:?} {:?}",
3890 project_entry,
3891 details
3892 );
3893 }
3894
3895 let indent = " ".repeat(details.depth);
3896 let icon = if details.kind.is_dir() {
3897 if details.is_expanded {
3898 "v "
3899 } else {
3900 "> "
3901 }
3902 } else {
3903 " "
3904 };
3905 let name = if details.is_editing {
3906 format!("[EDITOR: '{}']", details.filename)
3907 } else if details.is_processing {
3908 format!("[PROCESSING: '{}']", details.filename)
3909 } else {
3910 details.filename.clone()
3911 };
3912 let selected = if details.is_selected {
3913 " <== selected"
3914 } else {
3915 ""
3916 };
3917 result.push(format!("{indent}{icon}{name}{selected}"));
3918 });
3919 });
3920
3921 result
3922 }
3923
3924 fn init_test(cx: &mut TestAppContext) {
3925 cx.update(|cx| {
3926 let settings_store = SettingsStore::test(cx);
3927 cx.set_global(settings_store);
3928 init_settings(cx);
3929 theme::init(theme::LoadThemes::JustBase, cx);
3930 language::init(cx);
3931 editor::init_settings(cx);
3932 crate::init((), cx);
3933 workspace::init_settings(cx);
3934 client::init_settings(cx);
3935 Project::init_settings(cx);
3936
3937 cx.update_global::<SettingsStore, _>(|store, cx| {
3938 store.update_user_settings::<WorktreeSettings>(cx, |worktree_settings| {
3939 worktree_settings.file_scan_exclusions = Some(Vec::new());
3940 });
3941 });
3942 });
3943 }
3944
3945 fn init_test_with_editor(cx: &mut TestAppContext) {
3946 cx.update(|cx| {
3947 let app_state = AppState::test(cx);
3948 theme::init(theme::LoadThemes::JustBase, cx);
3949 init_settings(cx);
3950 language::init(cx);
3951 editor::init(cx);
3952 crate::init((), cx);
3953 workspace::init(app_state.clone(), cx);
3954 Project::init_settings(cx);
3955 });
3956 }
3957
3958 fn ensure_single_file_is_opened(
3959 window: &WindowHandle<Workspace>,
3960 expected_path: &str,
3961 cx: &mut TestAppContext,
3962 ) {
3963 window
3964 .update(cx, |workspace, cx| {
3965 let worktrees = workspace.worktrees(cx).collect::<Vec<_>>();
3966 assert_eq!(worktrees.len(), 1);
3967 let worktree_id = worktrees[0].read(cx).id();
3968
3969 let open_project_paths = workspace
3970 .panes()
3971 .iter()
3972 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
3973 .collect::<Vec<_>>();
3974 assert_eq!(
3975 open_project_paths,
3976 vec![ProjectPath {
3977 worktree_id,
3978 path: Arc::from(Path::new(expected_path))
3979 }],
3980 "Should have opened file, selected in project panel"
3981 );
3982 })
3983 .unwrap();
3984 }
3985
3986 fn submit_deletion(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
3987 assert!(
3988 !cx.has_pending_prompt(),
3989 "Should have no prompts before the deletion"
3990 );
3991 panel.update(cx, |panel, cx| {
3992 panel.delete(&Delete { skip_prompt: false }, cx)
3993 });
3994 assert!(
3995 cx.has_pending_prompt(),
3996 "Should have a prompt after the deletion"
3997 );
3998 cx.simulate_prompt_answer(0);
3999 assert!(
4000 !cx.has_pending_prompt(),
4001 "Should have no prompts after prompt was replied to"
4002 );
4003 cx.executor().run_until_parked();
4004 }
4005
4006 fn submit_deletion_skipping_prompt(panel: &View<ProjectPanel>, cx: &mut VisualTestContext) {
4007 assert!(
4008 !cx.has_pending_prompt(),
4009 "Should have no prompts before the deletion"
4010 );
4011 panel.update(cx, |panel, cx| {
4012 panel.delete(&Delete { skip_prompt: true }, cx)
4013 });
4014 assert!(!cx.has_pending_prompt(), "Should have received no prompts");
4015 cx.executor().run_until_parked();
4016 }
4017
4018 fn ensure_no_open_items_and_panes(
4019 workspace: &WindowHandle<Workspace>,
4020 cx: &mut VisualTestContext,
4021 ) {
4022 assert!(
4023 !cx.has_pending_prompt(),
4024 "Should have no prompts after deletion operation closes the file"
4025 );
4026 workspace
4027 .read_with(cx, |workspace, cx| {
4028 let open_project_paths = workspace
4029 .panes()
4030 .iter()
4031 .filter_map(|pane| pane.read(cx).active_item()?.project_path(cx))
4032 .collect::<Vec<_>>();
4033 assert!(
4034 open_project_paths.is_empty(),
4035 "Deleted file's buffer should be closed, but got open files: {open_project_paths:?}"
4036 );
4037 })
4038 .unwrap();
4039 }
4040}