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