Start work on expanding and collapsing directories in project panel

Max Brunsfeld and Nathan Sobo created

Co-Authored-By: Nathan Sobo <nathan@zed.dev>

Change summary

gpui/src/elements/uniform_list.rs |  12 
gpui/src/presenter.rs             |  12 
gpui/src/views/select.rs          |   6 
zed/src/fuzzy.rs                  |  60 +++-
zed/src/main.rs                   |   3 
zed/src/project_panel.rs          | 423 +++++++++++++++++++++++++++-----
zed/src/worktree.rs               | 191 ++++++++++----
7 files changed, 553 insertions(+), 154 deletions(-)

Detailed changes

gpui/src/elements/uniform_list.rs 🔗

@@ -5,7 +5,7 @@ use crate::{
         vector::{vec2f, Vector2F},
     },
     json::{self, json},
-    ElementBox, MutableAppContext,
+    ElementBox,
 };
 use json::ToJson;
 use parking_lot::Mutex;
@@ -38,7 +38,7 @@ pub struct LayoutState {
 
 pub struct UniformList<F>
 where
-    F: Fn(Range<usize>, &mut Vec<ElementBox>, &mut MutableAppContext),
+    F: Fn(Range<usize>, &mut Vec<ElementBox>, &mut LayoutContext),
 {
     state: UniformListState,
     item_count: usize,
@@ -47,7 +47,7 @@ where
 
 impl<F> UniformList<F>
 where
-    F: Fn(Range<usize>, &mut Vec<ElementBox>, &mut MutableAppContext),
+    F: Fn(Range<usize>, &mut Vec<ElementBox>, &mut LayoutContext),
 {
     pub fn new(state: UniformListState, item_count: usize, append_items: F) -> Self {
         Self {
@@ -102,7 +102,7 @@ where
 
 impl<F> Element for UniformList<F>
 where
-    F: Fn(Range<usize>, &mut Vec<ElementBox>, &mut MutableAppContext),
+    F: Fn(Range<usize>, &mut Vec<ElementBox>, &mut LayoutContext),
 {
     type LayoutState = LayoutState;
     type PaintState = ();
@@ -124,7 +124,7 @@ where
         let mut scroll_max = 0.;
 
         let mut items = Vec::new();
-        (self.append_items)(0..1, &mut items, cx.app);
+        (self.append_items)(0..1, &mut items, cx);
         if let Some(first_item) = items.first_mut() {
             let mut item_size = first_item.layout(item_constraint, cx);
             item_size.set_x(size.x());
@@ -146,7 +146,7 @@ where
                 self.item_count,
                 start + (size.y() / item_height).ceil() as usize + 1,
             );
-            (self.append_items)(start..end, &mut items, cx.app);
+            (self.append_items)(start..end, &mut items, cx);
             for item in &mut items {
                 item.layout(item_constraint, cx);
             }

gpui/src/presenter.rs 🔗

@@ -7,7 +7,7 @@ use crate::{
     platform::Event,
     text_layout::TextLayoutCache,
     Action, AnyAction, AssetCache, ElementBox, Entity, FontSystem, ModelHandle, ReadModel,
-    ReadView, Scene, View, ViewHandle,
+    ReadView, Scene, UpdateView, View, ViewHandle,
 };
 use pathfinder_geometry::vector::{vec2f, Vector2F};
 use serde_json::json;
@@ -264,6 +264,16 @@ impl<'a> ReadView for LayoutContext<'a> {
     }
 }
 
+impl<'a> UpdateView for LayoutContext<'a> {
+    fn update_view<T, F, S>(&mut self, handle: &ViewHandle<T>, update: F) -> S
+    where
+        T: View,
+        F: FnOnce(&mut T, &mut crate::ViewContext<T>) -> S,
+    {
+        self.app.update_view(handle, update)
+    }
+}
+
 impl<'a> ReadModel for LayoutContext<'a> {
     fn read_model<T: Entity>(&self, handle: &ModelHandle<T>) -> &T {
         self.app.read_model(handle)

gpui/src/views/select.rs 🔗

@@ -126,7 +126,7 @@ impl View for Select {
                             UniformList::new(
                                 self.list_state.clone(),
                                 self.item_count,
-                                move |mut range, items, mut cx| {
+                                move |mut range, items, cx| {
                                     let handle = handle.upgrade(cx).unwrap();
                                     let this = handle.read(cx);
                                     let selected_item_ix = this.selected_item_ix;
@@ -134,9 +134,9 @@ impl View for Select {
                                     items.extend(range.map(|ix| {
                                         MouseEventHandler::new::<Item, _, _, _>(
                                             (handle.id(), ix),
-                                            &mut cx,
+                                            cx,
                                             |mouse_state, cx| {
-                                                (handle.read(*cx).render_item)(
+                                                (handle.read(cx).render_item)(
                                                     ix,
                                                     if ix == selected_item_ix {
                                                         ItemType::Selected

zed/src/fuzzy.rs 🔗

@@ -278,29 +278,47 @@ pub async fn match_paths(
 
                             let start = max(tree_start, segment_start) - tree_start;
                             let end = min(tree_end, segment_end) - tree_start;
-                            let entries = if include_ignored {
-                                snapshot.files(start).take(end - start)
+                            if include_ignored {
+                                let paths = snapshot.files(start).take(end - start).map(|entry| {
+                                    if let EntryKind::File(char_bag) = entry.kind {
+                                        PathMatchCandidate {
+                                            path: &entry.path,
+                                            char_bag,
+                                        }
+                                    } else {
+                                        unreachable!()
+                                    }
+                                });
+                                matcher.match_paths(
+                                    snapshot.id(),
+                                    path_prefix,
+                                    paths,
+                                    results,
+                                    &cancel_flag,
+                                );
                             } else {
-                                snapshot.visible_files(start).take(end - start)
+                                let paths =
+                                    snapshot
+                                        .visible_files(start)
+                                        .take(end - start)
+                                        .map(|entry| {
+                                            if let EntryKind::File(char_bag) = entry.kind {
+                                                PathMatchCandidate {
+                                                    path: &entry.path,
+                                                    char_bag,
+                                                }
+                                            } else {
+                                                unreachable!()
+                                            }
+                                        });
+                                matcher.match_paths(
+                                    snapshot.id(),
+                                    path_prefix,
+                                    paths,
+                                    results,
+                                    &cancel_flag,
+                                );
                             };
-                            let paths = entries.map(|entry| {
-                                if let EntryKind::File(char_bag) = entry.kind {
-                                    PathMatchCandidate {
-                                        path: &entry.path,
-                                        char_bag,
-                                    }
-                                } else {
-                                    unreachable!()
-                                }
-                            });
-
-                            matcher.match_paths(
-                                snapshot.id(),
-                                path_prefix,
-                                paths,
-                                results,
-                                &cancel_flag,
-                            );
                         }
                         if tree_end >= segment_end {
                             break;

zed/src/main.rs 🔗

@@ -13,7 +13,7 @@ use zed::{
     channel::ChannelList,
     chat_panel, editor, file_finder,
     fs::RealFs,
-    http, language, menus, rpc, settings, theme_selector,
+    http, language, menus, project_panel, rpc, settings, theme_selector,
     user::UserStore,
     workspace::{self, OpenNew, OpenParams, OpenPaths},
     AppState,
@@ -55,6 +55,7 @@ fn main() {
         editor::init(cx);
         file_finder::init(cx);
         chat_panel::init(cx);
+        project_panel::init(cx);
         theme_selector::init(&app_state, cx);
 
         cx.set_menus(menus::menus(&app_state.clone()));

zed/src/project_panel.rs 🔗

@@ -1,19 +1,41 @@
-use crate::{
-    project::Project,
-    theme::Theme,
-    worktree::{self, Worktree},
-    Settings,
-};
+use crate::{project::Project, theme, Settings};
 use gpui::{
-    elements::{Empty, Label, List, ListState, Orientation},
-    AppContext, Element, ElementBox, Entity, ModelHandle, View, ViewContext,
+    action,
+    elements::{Label, MouseEventHandler, UniformList, UniformListState},
+    Element, ElementBox, Entity, ModelHandle, MutableAppContext, ReadModel, View, ViewContext,
+    WeakViewHandle,
 };
 use postage::watch;
+use std::ops::Range;
 
 pub struct ProjectPanel {
     project: ModelHandle<Project>,
-    list: ListState,
+    list: UniformListState,
+    visible_entries: Vec<Vec<usize>>,
+    expanded_dir_ids: Vec<Vec<usize>>,
     settings: watch::Receiver<Settings>,
+    handle: WeakViewHandle<Self>,
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct EntryDetails {
+    filename: String,
+    depth: usize,
+    is_dir: bool,
+    is_expanded: bool,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub struct ProjectEntry {
+    worktree_ix: usize,
+    entry_id: usize,
+}
+
+action!(ToggleExpanded, ProjectEntry);
+action!(Open, ProjectEntry);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(ProjectPanel::toggle_expanded);
 }
 
 pub enum Event {}
@@ -24,77 +46,139 @@ impl ProjectPanel {
         settings: watch::Receiver<Settings>,
         cx: &mut ViewContext<Self>,
     ) -> Self {
-        cx.observe(&project, |this, project, cx| {
-            let project = project.read(cx);
-            this.list.reset(Self::entry_count(project, cx));
+        cx.observe(&project, |this, _, cx| {
+            this.update_visible_entries(cx);
             cx.notify();
         })
         .detach();
 
-        Self {
-            list: ListState::new(
-                {
-                    let project = project.read(cx);
-                    Self::entry_count(project, cx)
-                },
-                Orientation::Top,
-                1000.,
-                {
-                    let project = project.clone();
-                    let settings = settings.clone();
-                    move |ix, cx| {
-                        let project = project.read(cx);
-                        Self::render_entry_at_index(project, ix, &settings.borrow().theme, cx)
-                    }
-                },
-            ),
+        let mut this = Self {
             project,
             settings,
+            list: Default::default(),
+            visible_entries: Default::default(),
+            expanded_dir_ids: Default::default(),
+            handle: cx.handle().downgrade(),
+        };
+        this.update_visible_entries(cx);
+        this
+    }
+
+    fn toggle_expanded(&mut self, action: &ToggleExpanded, cx: &mut ViewContext<Self>) {
+        let ProjectEntry {
+            worktree_ix,
+            entry_id,
+        } = action.0;
+        let expanded_dir_ids = &mut self.expanded_dir_ids[worktree_ix];
+        match expanded_dir_ids.binary_search(&entry_id) {
+            Ok(ix) => {
+                expanded_dir_ids.remove(ix);
+            }
+            Err(ix) => {
+                expanded_dir_ids.insert(ix, entry_id);
+            }
         }
+        self.update_visible_entries(cx);
     }
 
-    fn entry_count(project: &Project, cx: &AppContext) -> usize {
-        project
-            .worktrees()
-            .iter()
-            .map(|worktree| worktree.read(cx).visible_entry_count())
-            .sum()
+    fn update_visible_entries(&mut self, cx: &mut ViewContext<Self>) {
+        let worktrees = self.project.read(cx).worktrees();
+        self.visible_entries.clear();
+        for (worktree_ix, worktree) in worktrees.iter().enumerate() {
+            let snapshot = worktree.read(cx).snapshot();
+
+            if self.expanded_dir_ids.len() <= worktree_ix {
+                self.expanded_dir_ids
+                    .push(vec![snapshot.root_entry().unwrap().id])
+            }
+
+            let expanded_dir_ids = &self.expanded_dir_ids[worktree_ix];
+            let mut visible_worktree_entries = Vec::new();
+            let mut entry_iter = snapshot.visible_entries(0);
+            while let Some(item) = entry_iter.item() {
+                visible_worktree_entries.push(entry_iter.ix());
+                if expanded_dir_ids.binary_search(&item.id).is_err() {
+                    if entry_iter.advance_sibling() {
+                        continue;
+                    }
+                }
+                entry_iter.advance();
+            }
+            self.visible_entries.push(visible_worktree_entries);
+        }
     }
 
-    fn render_entry_at_index(
-        project: &Project,
-        mut ix: usize,
-        theme: &Theme,
-        cx: &AppContext,
-    ) -> ElementBox {
-        for worktree in project.worktrees() {
-            let worktree = worktree.read(cx);
-            let visible_entry_count = worktree.visible_entry_count();
-            if ix < visible_entry_count {
-                let entry = worktree.visible_entries(ix).next().unwrap();
-                return Self::render_entry(worktree, entry, theme, cx);
-            } else {
-                ix -= visible_entry_count;
+    fn append_visible_entries<C: ReadModel, T>(
+        &self,
+        range: Range<usize>,
+        items: &mut Vec<T>,
+        cx: &mut C,
+        mut render_item: impl FnMut(ProjectEntry, EntryDetails, &mut C) -> T,
+    ) {
+        let worktrees = self.project.read(cx).worktrees().to_vec();
+        let mut total_ix = 0;
+        for (worktree_ix, visible_worktree_entries) in self.visible_entries.iter().enumerate() {
+            if total_ix >= range.end {
+                break;
+            }
+            if total_ix + visible_worktree_entries.len() <= range.start {
+                total_ix += visible_worktree_entries.len();
+                continue;
+            }
+
+            let expanded_entry_ids = &self.expanded_dir_ids[worktree_ix];
+            let snapshot = worktrees[worktree_ix].read(cx).snapshot();
+            let mut cursor = snapshot.visible_entries(0);
+            for ix in visible_worktree_entries[(range.start - total_ix)..]
+                .iter()
+                .copied()
+            {
+                cursor.advance_to_ix(ix);
+                if let Some(entry) = cursor.item() {
+                    let details = EntryDetails {
+                        filename: entry.path.file_name().map_or_else(
+                            || snapshot.root_name().to_string(),
+                            |name| name.to_string_lossy().to_string(),
+                        ),
+                        depth: entry.path.components().count(),
+                        is_dir: entry.is_dir(),
+                        is_expanded: expanded_entry_ids.binary_search(&entry.id).is_ok(),
+                    };
+                    let entry = ProjectEntry {
+                        worktree_ix,
+                        entry_id: entry.id,
+                    };
+                    items.push(render_item(entry, details, cx));
+                }
+                total_ix += 1;
             }
         }
-        Empty::new().boxed()
     }
 
     fn render_entry(
-        worktree: &Worktree,
-        entry: &worktree::Entry,
-        theme: &Theme,
-        _: &AppContext,
+        entry: ProjectEntry,
+        details: EntryDetails,
+        theme: &theme::ProjectPanel,
+        cx: &mut ViewContext<Self>,
     ) -> ElementBox {
-        let path = &entry.path;
-        let depth = path.iter().count() as f32;
-        Label::new(
-            path.file_name()
-                .map_or(String::new(), |s| s.to_string_lossy().to_string()),
-            theme.project_panel.entry.clone(),
+        let is_dir = details.is_dir;
+        MouseEventHandler::new::<Self, _, _, _>(
+            (entry.worktree_ix, entry.entry_id),
+            cx,
+            |state, cx| {
+                Label::new(details.filename, theme.entry.clone())
+                    .contained()
+                    .with_margin_left(details.depth as f32 * 20.)
+                    .boxed()
+            },
         )
-        .contained()
-        .with_margin_left(depth * 20.)
+        .on_click(move |cx| {
+            if is_dir {
+                cx.dispatch_action(ToggleExpanded(entry))
+            } else {
+                cx.dispatch_action(Open(entry))
+            }
+        })
         .boxed()
     }
 }
@@ -105,14 +189,219 @@ impl View for ProjectPanel {
     }
 
     fn render(&mut self, _: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
-        let theme = &self.settings.borrow().theme.project_panel;
-        List::new(self.list.clone())
-            .contained()
-            .with_style(theme.container)
-            .boxed()
+        let settings = self.settings.clone();
+        let handle = self.handle.clone();
+        UniformList::new(
+            self.list.clone(),
+            self.visible_entries.len(),
+            move |range, items, cx| {
+                let theme = &settings.borrow().theme.project_panel;
+                let this = handle.upgrade(cx).unwrap();
+                this.update(cx.app, |this, cx| {
+                    this.append_visible_entries(range, items, cx, |entry, details, cx| {
+                        Self::render_entry(entry, details, theme, cx)
+                    });
+                })
+            },
+        )
+        .contained()
+        .with_style(self.settings.borrow().theme.project_panel.container)
+        .boxed()
     }
 }
 
 impl Entity for ProjectPanel {
     type Event = Event;
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::test::test_app_state;
+    use gpui::{TestAppContext, ViewHandle};
+    use serde_json::json;
+    use std::{collections::HashSet, path::Path};
+
+    #[gpui::test]
+    async fn test_visible_list(mut cx: gpui::TestAppContext) {
+        let app_state = cx.update(test_app_state);
+        let settings = app_state.settings.clone();
+        let fs = app_state.fs.as_fake();
+
+        fs.insert_tree(
+            "/root1",
+            json!({
+                ".dockerignore": "",
+                ".git": {
+                    "HEAD": "",
+                },
+                "a": {
+                    "0": { "q": "", "r": "", "s": "" },
+                    "1": { "t": "", "u": "" },
+                    "2": { "v": "", "w": "", "x": "", "y": "" },
+                },
+                "b": {
+                    "3": { "Q": "" },
+                    "4": { "R": "", "S": "", "T": "", "U": "" },
+                },
+                "c": {
+                    "5": {},
+                    "6": { "V": "", "W": "" },
+                    "7": { "X": "" },
+                    "8": { "Y": {}, "Z": "" }
+                }
+            }),
+        )
+        .await;
+
+        let project = cx.add_model(|_| Project::new(&app_state));
+        let worktree = project
+            .update(&mut cx, |project, cx| {
+                project.add_local_worktree("/root1".as_ref(), cx)
+            })
+            .await
+            .unwrap();
+        worktree
+            .read_with(&cx, |t, _| t.as_local().unwrap().scan_complete())
+            .await;
+
+        let (_, panel) = cx.add_window(|cx| ProjectPanel::new(project, settings, cx));
+        assert_eq!(
+            visible_entry_details(&panel, 0..50, &mut cx),
+            &[
+                EntryDetails {
+                    filename: "root1".to_string(),
+                    depth: 0,
+                    is_dir: true,
+                    is_expanded: true,
+                },
+                EntryDetails {
+                    filename: ".dockerignore".to_string(),
+                    depth: 1,
+                    is_dir: false,
+                    is_expanded: false,
+                },
+                EntryDetails {
+                    filename: "a".to_string(),
+                    depth: 1,
+                    is_dir: true,
+                    is_expanded: false,
+                },
+                EntryDetails {
+                    filename: "b".to_string(),
+                    depth: 1,
+                    is_dir: true,
+                    is_expanded: false,
+                },
+                EntryDetails {
+                    filename: "c".to_string(),
+                    depth: 1,
+                    is_dir: true,
+                    is_expanded: false,
+                },
+            ]
+        );
+
+        toggle_expand_dir(&panel, "root1/b", &mut cx);
+        assert_eq!(
+            visible_entry_details(&panel, 0..50, &mut cx),
+            &[
+                EntryDetails {
+                    filename: "root1".to_string(),
+                    depth: 0,
+                    is_dir: true,
+                    is_expanded: true,
+                },
+                EntryDetails {
+                    filename: ".dockerignore".to_string(),
+                    depth: 1,
+                    is_dir: false,
+                    is_expanded: false,
+                },
+                EntryDetails {
+                    filename: "a".to_string(),
+                    depth: 1,
+                    is_dir: true,
+                    is_expanded: false,
+                },
+                EntryDetails {
+                    filename: "b".to_string(),
+                    depth: 1,
+                    is_dir: true,
+                    is_expanded: true,
+                },
+                EntryDetails {
+                    filename: "3".to_string(),
+                    depth: 2,
+                    is_dir: true,
+                    is_expanded: false,
+                },
+                EntryDetails {
+                    filename: "4".to_string(),
+                    depth: 2,
+                    is_dir: true,
+                    is_expanded: false,
+                },
+                EntryDetails {
+                    filename: "c".to_string(),
+                    depth: 1,
+                    is_dir: true,
+                    is_expanded: false,
+                },
+            ]
+        );
+
+        fn toggle_expand_dir(
+            panel: &ViewHandle<ProjectPanel>,
+            path: impl AsRef<Path>,
+            cx: &mut TestAppContext,
+        ) {
+            let path = path.as_ref();
+            panel.update(cx, |panel, cx| {
+                for (worktree_ix, worktree) in panel.project.read(cx).worktrees().iter().enumerate()
+                {
+                    let worktree = worktree.read(cx);
+                    if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) {
+                        let entry_id = worktree.entry_for_path(relative_path).unwrap().id;
+                        panel.toggle_expanded(
+                            &ToggleExpanded(ProjectEntry {
+                                worktree_ix,
+                                entry_id,
+                            }),
+                            cx,
+                        );
+                        return;
+                    }
+                }
+                panic!("no worktree for path {:?}", path);
+            });
+        }
+
+        fn visible_entry_details(
+            panel: &ViewHandle<ProjectPanel>,
+            range: Range<usize>,
+            cx: &mut TestAppContext,
+        ) -> Vec<EntryDetails> {
+            let mut result = Vec::new();
+            let mut project_entries = HashSet::new();
+            panel.update(cx, |panel, cx| {
+                panel.append_visible_entries(
+                    range,
+                    &mut result,
+                    cx,
+                    |project_entry, details, _| {
+                        assert!(
+                            project_entries.insert(project_entry),
+                            "duplicate project entry {:?} {:?}",
+                            project_entry,
+                            details
+                        );
+                        details
+                    },
+                );
+            });
+
+            result
+        }
+    }
+}

zed/src/worktree.rs 🔗

@@ -1482,16 +1482,16 @@ impl Snapshot {
         self.entries_by_path.summary().visible_file_count
     }
 
-    pub fn files(&self, start: usize) -> EntryIter {
-        EntryIter::files(self, start)
+    pub fn files(&self, start: usize) -> EntryIter<FileCount> {
+        EntryIter::new(self, start)
     }
 
-    pub fn visible_entries(&self, start: usize) -> EntryIter {
-        EntryIter::visible(self, start)
+    pub fn visible_entries(&self, start: usize) -> EntryIter<VisibleCountAndPath> {
+        EntryIter::new(self, start)
     }
 
-    pub fn visible_files(&self, start: usize) -> EntryIter {
-        EntryIter::visible_files(self, start)
+    pub fn visible_files(&self, start: usize) -> EntryIter<VisibleFileCount> {
+        EntryIter::new(self, start)
     }
 
     pub fn paths(&self) -> impl Iterator<Item = &Arc<Path>> {
@@ -1514,7 +1514,7 @@ impl Snapshot {
         &self.root_name
     }
 
-    fn entry_for_path(&self, path: impl AsRef<Path>) -> Option<&Entry> {
+    pub fn entry_for_path(&self, path: impl AsRef<Path>) -> Option<&Entry> {
         let mut cursor = self.entries_by_path.cursor::<_, ()>();
         if cursor.seek(&PathSearch::Exact(path.as_ref()), Bias::Left, &()) {
             cursor.item()
@@ -2065,30 +2065,108 @@ impl<'a: 'b, 'b> sum_tree::Dimension<'a, EntrySummary> for PathSearch<'b> {
     }
 }
 
-#[derive(Copy, Clone, Default, Debug, Eq, PartialEq, Ord, PartialOrd)]
+#[derive(Clone, Default, Debug, Eq, PartialEq, Ord, PartialOrd)]
 pub struct FileCount(usize);
 
+#[derive(Clone, Default, Debug, Eq, PartialEq, Ord, PartialOrd)]
+pub struct VisibleFileCount(usize);
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct VisibleCountAndPath<'a> {
+    count: Option<usize>,
+    path: PathSearch<'a>,
+}
+
 impl<'a> sum_tree::Dimension<'a, EntrySummary> for FileCount {
     fn add_summary(&mut self, summary: &'a EntrySummary, _: &()) {
         self.0 += summary.file_count;
     }
 }
 
-#[derive(Copy, Clone, Default, Debug, Eq, PartialEq, Ord, PartialOrd)]
-pub struct VisibleCount(usize);
+impl<'a> sum_tree::Dimension<'a, EntrySummary> for VisibleFileCount {
+    fn add_summary(&mut self, summary: &'a EntrySummary, _: &()) {
+        self.0 += summary.visible_file_count;
+    }
+}
 
-impl<'a> sum_tree::Dimension<'a, EntrySummary> for VisibleCount {
+impl<'a> sum_tree::Dimension<'a, EntrySummary> for VisibleCountAndPath<'a> {
     fn add_summary(&mut self, summary: &'a EntrySummary, _: &()) {
-        self.0 += summary.visible_count;
+        if let Some(count) = self.count.as_mut() {
+            *count += summary.visible_count;
+        } else {
+            unreachable!()
+        }
+        self.path = PathSearch::Exact(summary.max_path.as_ref());
     }
 }
 
-#[derive(Copy, Clone, Default, Debug, Eq, PartialEq, Ord, PartialOrd)]
-pub struct VisibleFileCount(usize);
+impl<'a> Ord for VisibleCountAndPath<'a> {
+    fn cmp(&self, other: &Self) -> cmp::Ordering {
+        if let Some(count) = self.count {
+            count.cmp(&other.count.unwrap())
+        } else {
+            self.path.cmp(&other.path)
+        }
+    }
+}
 
-impl<'a> sum_tree::Dimension<'a, EntrySummary> for VisibleFileCount {
-    fn add_summary(&mut self, summary: &'a EntrySummary, _: &()) {
-        self.0 += summary.visible_file_count;
+impl<'a> PartialOrd for VisibleCountAndPath<'a> {
+    fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl From<usize> for FileCount {
+    fn from(count: usize) -> Self {
+        Self(count)
+    }
+}
+
+impl From<usize> for VisibleFileCount {
+    fn from(count: usize) -> Self {
+        Self(count)
+    }
+}
+
+impl<'a> From<usize> for VisibleCountAndPath<'a> {
+    fn from(count: usize) -> Self {
+        Self {
+            count: Some(count),
+            path: PathSearch::default(),
+        }
+    }
+}
+
+impl Deref for FileCount {
+    type Target = usize;
+
+    fn deref(&self) -> &usize {
+        &self.0
+    }
+}
+
+impl Deref for VisibleFileCount {
+    type Target = usize;
+
+    fn deref(&self) -> &usize {
+        &self.0
+    }
+}
+
+impl<'a> Deref for VisibleCountAndPath<'a> {
+    type Target = usize;
+
+    fn deref(&self) -> &usize {
+        self.count.as_ref().unwrap()
+    }
+}
+
+impl<'a> Default for VisibleCountAndPath<'a> {
+    fn default() -> Self {
+        Self {
+            count: Some(0),
+            path: Default::default(),
+        }
     }
 }
 
@@ -2584,63 +2662,66 @@ impl WorktreeHandle for ModelHandle<Worktree> {
     }
 }
 
-pub enum EntryIter<'a> {
-    Files(Cursor<'a, Entry, FileCount, ()>),
-    Visible(Cursor<'a, Entry, VisibleCount, ()>),
-    VisibleFiles(Cursor<'a, Entry, VisibleFileCount, ()>),
+pub struct EntryIter<'a, Dim> {
+    cursor: Cursor<'a, Entry, Dim, ()>,
 }
 
-impl<'a> EntryIter<'a> {
-    fn files(snapshot: &'a Snapshot, start: usize) -> Self {
+impl<'a, Dim> EntryIter<'a, Dim>
+where
+    Dim: sum_tree::SeekDimension<'a, EntrySummary> + From<usize> + Deref<Target = usize>,
+{
+    fn new(snapshot: &'a Snapshot, start: usize) -> Self {
         let mut cursor = snapshot.entries_by_path.cursor();
-        cursor.seek(&FileCount(start), Bias::Right, &());
-        Self::Files(cursor)
+        cursor.seek(&Dim::from(start), Bias::Right, &());
+        Self { cursor }
     }
 
-    fn visible(snapshot: &'a Snapshot, start: usize) -> Self {
-        let mut cursor = snapshot.entries_by_path.cursor();
-        cursor.seek(&VisibleCount(start), Bias::Right, &());
-        Self::Visible(cursor)
+    pub fn ix(&self) -> usize {
+        *self.cursor.seek_start().deref()
     }
 
-    fn visible_files(snapshot: &'a Snapshot, start: usize) -> Self {
-        let mut cursor = snapshot.entries_by_path.cursor();
-        cursor.seek(&VisibleFileCount(start), Bias::Right, &());
-        Self::VisibleFiles(cursor)
+    pub fn advance_to_ix(&mut self, ix: usize) {
+        self.cursor.seek_forward(&Dim::from(ix), Bias::Right, &());
     }
 
-    fn next_internal(&mut self) {
-        match self {
-            Self::Files(cursor) => {
-                let ix = *cursor.seek_start();
-                cursor.seek_forward(&FileCount(ix.0 + 1), Bias::Right, &());
-            }
-            Self::Visible(cursor) => {
-                let ix = *cursor.seek_start();
-                cursor.seek_forward(&VisibleCount(ix.0 + 1), Bias::Right, &());
-            }
-            Self::VisibleFiles(cursor) => {
-                let ix = *cursor.seek_start();
-                cursor.seek_forward(&VisibleFileCount(ix.0 + 1), Bias::Right, &());
-            }
-        }
+    pub fn advance(&mut self) {
+        self.advance_to_ix(self.ix() + 1);
     }
 
-    fn item(&self) -> Option<&'a Entry> {
-        match self {
-            Self::Files(cursor) => cursor.item(),
-            Self::Visible(cursor) => cursor.item(),
-            Self::VisibleFiles(cursor) => cursor.item(),
+    pub fn item(&self) -> Option<&'a Entry> {
+        self.cursor.item()
+    }
+}
+
+impl<'a> EntryIter<'a, VisibleCountAndPath<'a>> {
+    pub fn advance_sibling(&mut self) -> bool {
+        let start_count = self.cursor.seek_start().count.unwrap();
+        while let Some(item) = self.cursor.item() {
+            self.cursor.seek_forward(
+                &VisibleCountAndPath {
+                    count: None,
+                    path: PathSearch::Successor(item.path.as_ref()),
+                },
+                Bias::Right,
+                &(),
+            );
+            if self.cursor.seek_start().count.unwrap() > start_count {
+                return true;
+            }
         }
+        false
     }
 }
 
-impl<'a> Iterator for EntryIter<'a> {
+impl<'a, Dim> Iterator for EntryIter<'a, Dim>
+where
+    Dim: sum_tree::SeekDimension<'a, EntrySummary> + From<usize> + Deref<Target = usize>,
+{
     type Item = &'a Entry;
 
     fn next(&mut self) -> Option<Self::Item> {
         if let Some(entry) = self.item() {
-            self.next_internal();
+            self.advance();
             Some(entry)
         } else {
             None