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