project_panel.rs

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