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