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