project_panel.rs

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