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