project_panel.rs

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