project_panel.rs

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