project_panel.rs

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