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