1use gpui::{
2 actions,
3 elements::{
4 Align, ConstrainedBox, Empty, Flex, Label, MouseEventHandler, ParentElement, ScrollTarget,
5 Svg, UniformList, UniformListState,
6 },
7 impl_actions,
8 keymap::{self, Binding},
9 platform::CursorStyle,
10 AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, View, ViewContext,
11 ViewHandle, WeakViewHandle,
12};
13use project::{Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
14use settings::Settings;
15use std::{
16 collections::{hash_map, HashMap},
17 ffi::OsStr,
18 ops::Range,
19};
20use workspace::{
21 menu::{SelectNext, SelectPrev},
22 Workspace,
23};
24
25pub struct ProjectPanel {
26 project: ModelHandle<Project>,
27 list: UniformListState,
28 visible_entries: Vec<(WorktreeId, Vec<usize>)>,
29 expanded_dir_ids: HashMap<WorktreeId, Vec<ProjectEntryId>>,
30 selection: Option<Selection>,
31 handle: WeakViewHandle<Self>,
32}
33
34#[derive(Copy, Clone)]
35struct Selection {
36 worktree_id: WorktreeId,
37 entry_id: ProjectEntryId,
38 index: usize,
39}
40
41#[derive(Debug, PartialEq, Eq)]
42struct EntryDetails {
43 filename: String,
44 depth: usize,
45 is_dir: bool,
46 is_expanded: bool,
47 is_selected: bool,
48}
49
50#[derive(Clone)]
51pub struct ToggleExpanded(pub ProjectEntryId);
52
53#[derive(Clone)]
54pub struct Open(pub ProjectEntryId);
55
56actions!(project_panel, [ExpandSelectedEntry, CollapseSelectedEntry]);
57impl_actions!(project_panel, [Open, ToggleExpanded]);
58
59pub fn init(cx: &mut MutableAppContext) {
60 cx.add_action(ProjectPanel::expand_selected_entry);
61 cx.add_action(ProjectPanel::collapse_selected_entry);
62 cx.add_action(ProjectPanel::toggle_expanded);
63 cx.add_action(ProjectPanel::select_prev);
64 cx.add_action(ProjectPanel::select_next);
65 cx.add_action(ProjectPanel::open_entry);
66 cx.add_bindings([
67 Binding::new("right", ExpandSelectedEntry, Some("ProjectPanel")),
68 Binding::new("left", CollapseSelectedEntry, Some("ProjectPanel")),
69 ]);
70}
71
72pub enum Event {
73 OpenedEntry(ProjectEntryId),
74}
75
76impl ProjectPanel {
77 pub fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
78 let project_panel = cx.add_view(|cx: &mut ViewContext<Self>| {
79 cx.observe(&project, |this, _, cx| {
80 this.update_visible_entries(None, cx);
81 cx.notify();
82 })
83 .detach();
84 cx.subscribe(&project, |this, project, event, cx| match event {
85 project::Event::ActiveEntryChanged(Some(entry_id)) => {
86 if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx)
87 {
88 this.expand_entry(worktree_id, *entry_id, cx);
89 this.update_visible_entries(Some((worktree_id, *entry_id)), cx);
90 this.autoscroll();
91 cx.notify();
92 }
93 }
94 project::Event::WorktreeRemoved(id) => {
95 this.expanded_dir_ids.remove(id);
96 this.update_visible_entries(None, cx);
97 cx.notify();
98 }
99 _ => {}
100 })
101 .detach();
102
103 let mut this = Self {
104 project: project.clone(),
105 list: Default::default(),
106 visible_entries: Default::default(),
107 expanded_dir_ids: Default::default(),
108 selection: None,
109 handle: cx.weak_handle(),
110 };
111 this.update_visible_entries(None, cx);
112 this
113 });
114 cx.subscribe(&project_panel, move |workspace, _, event, cx| match event {
115 &Event::OpenedEntry(entry_id) => {
116 if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) {
117 if let Some(entry) = worktree.read(cx).entry_for_id(entry_id) {
118 workspace
119 .open_path(
120 ProjectPath {
121 worktree_id: worktree.read(cx).id(),
122 path: entry.path.clone(),
123 },
124 cx,
125 )
126 .detach_and_log_err(cx);
127 }
128 }
129 }
130 })
131 .detach();
132
133 project_panel
134 }
135
136 fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext<Self>) {
137 if let Some((worktree, entry)) = self.selected_entry(cx) {
138 let expanded_dir_ids =
139 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
140 expanded_dir_ids
141 } else {
142 return;
143 };
144
145 if entry.is_dir() {
146 match expanded_dir_ids.binary_search(&entry.id) {
147 Ok(_) => self.select_next(&SelectNext, cx),
148 Err(ix) => {
149 expanded_dir_ids.insert(ix, entry.id);
150 self.update_visible_entries(None, cx);
151 cx.notify();
152 }
153 }
154 } else {
155 let event = Event::OpenedEntry(entry.id);
156 cx.emit(event);
157 }
158 }
159 }
160
161 fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext<Self>) {
162 if let Some((worktree, mut entry)) = self.selected_entry(cx) {
163 let expanded_dir_ids =
164 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree.id()) {
165 expanded_dir_ids
166 } else {
167 return;
168 };
169
170 loop {
171 match expanded_dir_ids.binary_search(&entry.id) {
172 Ok(ix) => {
173 expanded_dir_ids.remove(ix);
174 self.update_visible_entries(Some((worktree.id(), entry.id)), cx);
175 cx.notify();
176 break;
177 }
178 Err(_) => {
179 if let Some(parent_entry) =
180 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
181 {
182 entry = parent_entry;
183 } else {
184 break;
185 }
186 }
187 }
188 }
189 }
190 }
191
192 fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
193 let entry_id = action.0;
194 if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
195 if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
196 match expanded_dir_ids.binary_search(&entry_id) {
197 Ok(ix) => {
198 expanded_dir_ids.remove(ix);
199 }
200 Err(ix) => {
201 expanded_dir_ids.insert(ix, entry_id);
202 }
203 }
204 self.update_visible_entries(Some((worktree_id, entry_id)), cx);
205 cx.focus_self();
206 }
207 }
208 }
209
210 fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
211 if let Some(selection) = self.selection {
212 let prev_ix = selection.index.saturating_sub(1);
213 let (worktree, entry) = self.visible_entry_for_index(prev_ix, cx).unwrap();
214 self.selection = Some(Selection {
215 worktree_id: worktree.id(),
216 entry_id: entry.id,
217 index: prev_ix,
218 });
219 self.autoscroll();
220 cx.notify();
221 } else {
222 self.select_first(cx);
223 }
224 }
225
226 fn open_entry(&mut self, action: &Open, cx: &mut ViewContext<Self>) {
227 cx.emit(Event::OpenedEntry(action.0));
228 }
229
230 fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
231 if let Some(selection) = self.selection {
232 let next_ix = selection.index + 1;
233 if let Some((worktree, entry)) = self.visible_entry_for_index(next_ix, cx) {
234 self.selection = Some(Selection {
235 worktree_id: worktree.id(),
236 entry_id: entry.id,
237 index: next_ix,
238 });
239 self.autoscroll();
240 cx.notify();
241 }
242 } else {
243 self.select_first(cx);
244 }
245 }
246
247 fn select_first(&mut self, cx: &mut ViewContext<Self>) {
248 let worktree = self
249 .visible_entries
250 .first()
251 .and_then(|(worktree_id, _)| self.project.read(cx).worktree_for_id(*worktree_id, cx));
252 if let Some(worktree) = worktree {
253 let worktree = worktree.read(cx);
254 let worktree_id = worktree.id();
255 if let Some(root_entry) = worktree.root_entry() {
256 self.selection = Some(Selection {
257 worktree_id,
258 entry_id: root_entry.id,
259 index: 0,
260 });
261 self.autoscroll();
262 cx.notify();
263 }
264 }
265 }
266
267 fn autoscroll(&mut self) {
268 if let Some(selection) = self.selection {
269 self.list.scroll_to(ScrollTarget::Show(selection.index));
270 }
271 }
272
273 fn visible_entry_for_index<'a>(
274 &self,
275 target_ix: usize,
276 cx: &'a AppContext,
277 ) -> Option<(&'a Worktree, &'a project::Entry)> {
278 let project = self.project.read(cx);
279 let mut offset = None;
280 let mut ix = 0;
281 for (worktree_id, visible_entries) in &self.visible_entries {
282 if target_ix < ix + visible_entries.len() {
283 offset = project
284 .worktree_for_id(*worktree_id, cx)
285 .map(|w| (w.read(cx), visible_entries[target_ix - ix]));
286 break;
287 } else {
288 ix += visible_entries.len();
289 }
290 }
291
292 offset.and_then(|(worktree, offset)| {
293 let mut entries = worktree.entries(false);
294 entries.advance_to_offset(offset);
295 Some((worktree, entries.entry()?))
296 })
297 }
298
299 fn selected_entry<'a>(&self, cx: &'a AppContext) -> Option<(&'a Worktree, &'a project::Entry)> {
300 let selection = self.selection?;
301 let project = self.project.read(cx);
302 let worktree = project.worktree_for_id(selection.worktree_id, cx)?.read(cx);
303 Some((worktree, worktree.entry_for_id(selection.entry_id)?))
304 }
305
306 fn update_visible_entries(
307 &mut self,
308 new_selected_entry: Option<(WorktreeId, ProjectEntryId)>,
309 cx: &mut ViewContext<Self>,
310 ) {
311 let worktrees = self
312 .project
313 .read(cx)
314 .worktrees(cx)
315 .filter(|worktree| worktree.read(cx).is_visible());
316 self.visible_entries.clear();
317
318 let mut entry_ix = 0;
319 for worktree in worktrees {
320 let snapshot = worktree.read(cx).snapshot();
321 let worktree_id = snapshot.id();
322
323 let expanded_dir_ids = match self.expanded_dir_ids.entry(worktree_id) {
324 hash_map::Entry::Occupied(e) => e.into_mut(),
325 hash_map::Entry::Vacant(e) => {
326 // The first time a worktree's root entry becomes available,
327 // mark that root entry as expanded.
328 if let Some(entry) = snapshot.root_entry() {
329 e.insert(vec![entry.id]).as_slice()
330 } else {
331 &[]
332 }
333 }
334 };
335
336 let mut visible_worktree_entries = Vec::new();
337 let mut entry_iter = snapshot.entries(false);
338 while let Some(item) = entry_iter.entry() {
339 visible_worktree_entries.push(entry_iter.offset());
340 if let Some(new_selected_entry) = new_selected_entry {
341 if new_selected_entry == (worktree_id, item.id) {
342 self.selection = Some(Selection {
343 worktree_id,
344 entry_id: item.id,
345 index: entry_ix,
346 });
347 }
348 } else if self.selection.map_or(false, |e| {
349 e.worktree_id == worktree_id && e.entry_id == item.id
350 }) {
351 self.selection = Some(Selection {
352 worktree_id,
353 entry_id: item.id,
354 index: entry_ix,
355 });
356 }
357
358 entry_ix += 1;
359 if expanded_dir_ids.binary_search(&item.id).is_err() {
360 if entry_iter.advance_to_sibling() {
361 continue;
362 }
363 }
364 entry_iter.advance();
365 }
366 self.visible_entries
367 .push((worktree_id, visible_worktree_entries));
368 }
369 }
370
371 fn expand_entry(
372 &mut self,
373 worktree_id: WorktreeId,
374 entry_id: ProjectEntryId,
375 cx: &mut ViewContext<Self>,
376 ) {
377 let project = self.project.read(cx);
378 if let Some((worktree, expanded_dir_ids)) = project
379 .worktree_for_id(worktree_id, cx)
380 .zip(self.expanded_dir_ids.get_mut(&worktree_id))
381 {
382 let worktree = worktree.read(cx);
383
384 if let Some(mut entry) = worktree.entry_for_id(entry_id) {
385 loop {
386 if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
387 expanded_dir_ids.insert(ix, entry.id);
388 }
389
390 if let Some(parent_entry) =
391 entry.path.parent().and_then(|p| worktree.entry_for_path(p))
392 {
393 entry = parent_entry;
394 } else {
395 break;
396 }
397 }
398 }
399 }
400 }
401
402 fn for_each_visible_entry(
403 &self,
404 range: Range<usize>,
405 cx: &mut ViewContext<ProjectPanel>,
406 mut callback: impl FnMut(ProjectEntryId, EntryDetails, &mut ViewContext<ProjectPanel>),
407 ) {
408 let mut ix = 0;
409 for (worktree_id, visible_worktree_entries) in &self.visible_entries {
410 if ix >= range.end {
411 return;
412 }
413 if ix + visible_worktree_entries.len() <= range.start {
414 ix += visible_worktree_entries.len();
415 continue;
416 }
417
418 let end_ix = range.end.min(ix + visible_worktree_entries.len());
419 if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) {
420 let snapshot = worktree.read(cx).snapshot();
421 let expanded_entry_ids = self
422 .expanded_dir_ids
423 .get(&snapshot.id())
424 .map(Vec::as_slice)
425 .unwrap_or(&[]);
426 let root_name = OsStr::new(snapshot.root_name());
427 let mut cursor = snapshot.entries(false);
428
429 for ix in visible_worktree_entries[range.start.saturating_sub(ix)..end_ix - ix]
430 .iter()
431 .copied()
432 {
433 cursor.advance_to_offset(ix);
434 if let Some(entry) = cursor.entry() {
435 let filename = entry.path.file_name().unwrap_or(root_name);
436 let details = EntryDetails {
437 filename: filename.to_string_lossy().to_string(),
438 depth: entry.path.components().count(),
439 is_dir: entry.is_dir(),
440 is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
441 is_selected: self.selection.map_or(false, |e| {
442 e.worktree_id == snapshot.id() && e.entry_id == entry.id
443 }),
444 };
445 callback(entry.id, details, cx);
446 }
447 }
448 }
449 ix = end_ix;
450 }
451 }
452
453 fn render_entry(
454 entry_id: ProjectEntryId,
455 details: EntryDetails,
456 theme: &theme::ProjectPanel,
457 cx: &mut ViewContext<Self>,
458 ) -> ElementBox {
459 let is_dir = details.is_dir;
460 MouseEventHandler::new::<Self, _, _>(entry_id.to_usize(), cx, |state, _| {
461 let style = match (details.is_selected, state.hovered) {
462 (false, false) => &theme.entry,
463 (false, true) => &theme.hovered_entry,
464 (true, false) => &theme.selected_entry,
465 (true, true) => &theme.hovered_selected_entry,
466 };
467 Flex::row()
468 .with_child(
469 ConstrainedBox::new(
470 Align::new(
471 ConstrainedBox::new(if is_dir {
472 if details.is_expanded {
473 Svg::new("icons/disclosure-open.svg")
474 .with_color(style.icon_color)
475 .boxed()
476 } else {
477 Svg::new("icons/disclosure-closed.svg")
478 .with_color(style.icon_color)
479 .boxed()
480 }
481 } else {
482 Empty::new().boxed()
483 })
484 .with_max_width(style.icon_size)
485 .with_max_height(style.icon_size)
486 .boxed(),
487 )
488 .boxed(),
489 )
490 .with_width(style.icon_size)
491 .boxed(),
492 )
493 .with_child(
494 Label::new(details.filename, style.text.clone())
495 .contained()
496 .with_margin_left(style.icon_spacing)
497 .aligned()
498 .left()
499 .boxed(),
500 )
501 .constrained()
502 .with_height(theme.entry.height)
503 .contained()
504 .with_style(style.container)
505 .with_padding_left(theme.container.padding.left + details.depth as f32 * 20.)
506 .boxed()
507 })
508 .on_click(move |cx| {
509 if is_dir {
510 cx.dispatch_action(ToggleExpanded(entry_id))
511 } else {
512 cx.dispatch_action(Open(entry_id))
513 }
514 })
515 .with_cursor_style(CursorStyle::PointingHand)
516 .boxed()
517 }
518}
519
520impl View for ProjectPanel {
521 fn ui_name() -> &'static str {
522 "ProjectPanel"
523 }
524
525 fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
526 let theme = &cx.global::<Settings>().theme.project_panel;
527 let mut container_style = theme.container;
528 let padding = std::mem::take(&mut container_style.padding);
529 let handle = self.handle.clone();
530 UniformList::new(
531 self.list.clone(),
532 self.visible_entries
533 .iter()
534 .map(|(_, worktree_entries)| worktree_entries.len())
535 .sum(),
536 move |range, items, cx| {
537 let theme = cx.global::<Settings>().theme.clone();
538 let this = handle.upgrade(cx).unwrap();
539 this.update(cx.app, |this, cx| {
540 this.for_each_visible_entry(range.clone(), cx, |entry, details, cx| {
541 items.push(Self::render_entry(entry, details, &theme.project_panel, cx));
542 });
543 })
544 },
545 )
546 .with_padding_top(padding.top)
547 .with_padding_bottom(padding.bottom)
548 .contained()
549 .with_style(container_style)
550 .boxed()
551 }
552
553 fn keymap_context(&self, _: &AppContext) -> keymap::Context {
554 let mut cx = Self::default_keymap_context();
555 cx.set.insert("menu".into());
556 cx
557 }
558}
559
560impl Entity for ProjectPanel {
561 type Event = Event;
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use gpui::{TestAppContext, ViewHandle};
568 use serde_json::json;
569 use std::{collections::HashSet, path::Path};
570 use workspace::WorkspaceParams;
571
572 #[gpui::test]
573 async fn test_visible_list(cx: &mut gpui::TestAppContext) {
574 cx.foreground().forbid_parking();
575
576 let params = cx.update(WorkspaceParams::test);
577 let fs = params.fs.as_fake();
578 fs.insert_tree(
579 "/root1",
580 json!({
581 ".dockerignore": "",
582 ".git": {
583 "HEAD": "",
584 },
585 "a": {
586 "0": { "q": "", "r": "", "s": "" },
587 "1": { "t": "", "u": "" },
588 "2": { "v": "", "w": "", "x": "", "y": "" },
589 },
590 "b": {
591 "3": { "Q": "" },
592 "4": { "R": "", "S": "", "T": "", "U": "" },
593 },
594 "c": {
595 "5": {},
596 "6": { "V": "", "W": "" },
597 "7": { "X": "" },
598 "8": { "Y": {}, "Z": "" }
599 }
600 }),
601 )
602 .await;
603 fs.insert_tree(
604 "/root2",
605 json!({
606 "d": {
607 "9": ""
608 },
609 "e": {}
610 }),
611 )
612 .await;
613
614 let project = cx.update(|cx| {
615 Project::local(
616 params.client.clone(),
617 params.user_store.clone(),
618 params.languages.clone(),
619 params.fs.clone(),
620 cx,
621 )
622 });
623 let (root1, _) = project
624 .update(cx, |project, cx| {
625 project.find_or_create_local_worktree("/root1", true, cx)
626 })
627 .await
628 .unwrap();
629 root1
630 .read_with(cx, |t, _| t.as_local().unwrap().scan_complete())
631 .await;
632 let (root2, _) = project
633 .update(cx, |project, cx| {
634 project.find_or_create_local_worktree("/root2", true, cx)
635 })
636 .await
637 .unwrap();
638 root2
639 .read_with(cx, |t, _| t.as_local().unwrap().scan_complete())
640 .await;
641
642 let (_, workspace) = cx.add_window(|cx| Workspace::new(¶ms, cx));
643 let panel = workspace.update(cx, |_, cx| ProjectPanel::new(project, cx));
644 assert_eq!(
645 visible_entry_details(&panel, 0..50, cx),
646 &[
647 EntryDetails {
648 filename: "root1".to_string(),
649 depth: 0,
650 is_dir: true,
651 is_expanded: true,
652 is_selected: false,
653 },
654 EntryDetails {
655 filename: ".dockerignore".to_string(),
656 depth: 1,
657 is_dir: false,
658 is_expanded: false,
659 is_selected: false,
660 },
661 EntryDetails {
662 filename: "a".to_string(),
663 depth: 1,
664 is_dir: true,
665 is_expanded: false,
666 is_selected: false,
667 },
668 EntryDetails {
669 filename: "b".to_string(),
670 depth: 1,
671 is_dir: true,
672 is_expanded: false,
673 is_selected: false,
674 },
675 EntryDetails {
676 filename: "c".to_string(),
677 depth: 1,
678 is_dir: true,
679 is_expanded: false,
680 is_selected: false,
681 },
682 EntryDetails {
683 filename: "root2".to_string(),
684 depth: 0,
685 is_dir: true,
686 is_expanded: true,
687 is_selected: false
688 },
689 EntryDetails {
690 filename: "d".to_string(),
691 depth: 1,
692 is_dir: true,
693 is_expanded: false,
694 is_selected: false
695 },
696 EntryDetails {
697 filename: "e".to_string(),
698 depth: 1,
699 is_dir: true,
700 is_expanded: false,
701 is_selected: false
702 }
703 ],
704 );
705
706 toggle_expand_dir(&panel, "root1/b", cx);
707 assert_eq!(
708 visible_entry_details(&panel, 0..50, cx),
709 &[
710 EntryDetails {
711 filename: "root1".to_string(),
712 depth: 0,
713 is_dir: true,
714 is_expanded: true,
715 is_selected: false,
716 },
717 EntryDetails {
718 filename: ".dockerignore".to_string(),
719 depth: 1,
720 is_dir: false,
721 is_expanded: false,
722 is_selected: false,
723 },
724 EntryDetails {
725 filename: "a".to_string(),
726 depth: 1,
727 is_dir: true,
728 is_expanded: false,
729 is_selected: false,
730 },
731 EntryDetails {
732 filename: "b".to_string(),
733 depth: 1,
734 is_dir: true,
735 is_expanded: true,
736 is_selected: true,
737 },
738 EntryDetails {
739 filename: "3".to_string(),
740 depth: 2,
741 is_dir: true,
742 is_expanded: false,
743 is_selected: false,
744 },
745 EntryDetails {
746 filename: "4".to_string(),
747 depth: 2,
748 is_dir: true,
749 is_expanded: false,
750 is_selected: false,
751 },
752 EntryDetails {
753 filename: "c".to_string(),
754 depth: 1,
755 is_dir: true,
756 is_expanded: false,
757 is_selected: false,
758 },
759 EntryDetails {
760 filename: "root2".to_string(),
761 depth: 0,
762 is_dir: true,
763 is_expanded: true,
764 is_selected: false
765 },
766 EntryDetails {
767 filename: "d".to_string(),
768 depth: 1,
769 is_dir: true,
770 is_expanded: false,
771 is_selected: false
772 },
773 EntryDetails {
774 filename: "e".to_string(),
775 depth: 1,
776 is_dir: true,
777 is_expanded: false,
778 is_selected: false
779 }
780 ]
781 );
782
783 assert_eq!(
784 visible_entry_details(&panel, 5..8, cx),
785 [
786 EntryDetails {
787 filename: "4".to_string(),
788 depth: 2,
789 is_dir: true,
790 is_expanded: false,
791 is_selected: false
792 },
793 EntryDetails {
794 filename: "c".to_string(),
795 depth: 1,
796 is_dir: true,
797 is_expanded: false,
798 is_selected: false
799 },
800 EntryDetails {
801 filename: "root2".to_string(),
802 depth: 0,
803 is_dir: true,
804 is_expanded: true,
805 is_selected: false
806 }
807 ]
808 );
809
810 fn toggle_expand_dir(
811 panel: &ViewHandle<ProjectPanel>,
812 path: impl AsRef<Path>,
813 cx: &mut TestAppContext,
814 ) {
815 let path = path.as_ref();
816 panel.update(cx, |panel, cx| {
817 for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
818 let worktree = worktree.read(cx);
819 if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
820 let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
821 panel.toggle_expanded(&ToggleExpanded(entry_id), cx);
822 return;
823 }
824 }
825 panic!("no worktree for path {:?}", path);
826 });
827 }
828
829 fn visible_entry_details(
830 panel: &ViewHandle<ProjectPanel>,
831 range: Range<usize>,
832 cx: &mut TestAppContext,
833 ) -> Vec<EntryDetails> {
834 let mut result = Vec::new();
835 let mut project_entries = HashSet::new();
836 panel.update(cx, |panel, cx| {
837 panel.for_each_visible_entry(range, cx, |project_entry, details, _| {
838 assert!(
839 project_entries.insert(project_entry),
840 "duplicate project entry {:?} {:?}",
841 project_entry,
842 details
843 );
844 result.push(details);
845 });
846 });
847
848 result
849 }
850 }
851}