project_panel.rs

  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(&params, 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}