lib.rs

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