Add Project Panel navigation actions in netrw mode (#20941)

Anthony Eid created

Release Notes:

- Added "[ c" & "] c" To select prev/next git modified file within the
project panel
- Added "[ d" & "] d" To select prev/next file with diagnostics from an
LSP within the project panel
- Added "{" & "}" To select prev/next directory within the project panel

Note:

I wanted to extend project panel's functionality when netrw is active so
I added some shortcuts that I believe will be helpful for most users. I
tried to keep the default key mappings for the shortcuts inline with
Zed's vim mode.

## Selecting prev/next modified git file

https://github.com/user-attachments/assets/a9c057c7-1015-444f-b273-6d52ac54aa9c


## Selecting prev/next diagnostics 

https://github.com/user-attachments/assets/d1fb04ac-02c6-477c-b751-90a11bb42a78

## Selecting prev/next directories (Only works with visible directoires)

https://github.com/user-attachments/assets/9e96371e-105f-4fe9-bbf7-58f4a529f0dd

Change summary

assets/keymaps/vim.json                   |   6 
crates/project_panel/src/project_panel.rs | 512 ++++++++++++++++++++++++
crates/project_panel/src/utils.rs         |  42 ++
3 files changed, 559 insertions(+), 1 deletion(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -659,6 +659,12 @@
       "p": "project_panel::Open",
       "x": "project_panel::RevealInFileManager",
       "s": "project_panel::OpenWithSystem",
+      "] c": "project_panel::SelectNextGitEntry",
+      "[ c": "project_panel::SelectPrevGitEntry",
+      "] d": "project_panel::SelectNextDiagnostic",
+      "[ d": "project_panel::SelectPrevDiagnostic",
+      "}": "project_panel::SelectNextDirectory",
+      "{": "project_panel::SelectPrevDirectory",
       "shift-g": "menu::SelectLast",
       "g g": "menu::SelectFirst",
       "-": "project_panel::SelectParent",

crates/project_panel/src/project_panel.rs 🔗

@@ -1,4 +1,5 @@
 mod project_panel_settings;
+mod utils;
 
 use client::{ErrorCode, ErrorExt};
 use language::DiagnosticSeverity;
@@ -56,7 +57,7 @@ use ui::{
     IndentGuideColors, IndentGuideLayout, KeyBinding, Label, ListItem, Scrollbar, ScrollbarState,
     Tooltip,
 };
-use util::{maybe, paths::compare_paths, ResultExt, TryFutureExt};
+use util::{maybe, paths::compare_paths, ResultExt, TakeUntilExt, TryFutureExt};
 use workspace::{
     dock::{DockPosition, Panel, PanelEvent},
     notifications::{DetachAndPromptErr, NotifyTaskExt},
@@ -192,6 +193,12 @@ actions!(
         UnfoldDirectory,
         FoldDirectory,
         SelectParent,
+        SelectNextGitEntry,
+        SelectPrevGitEntry,
+        SelectNextDiagnostic,
+        SelectPrevDiagnostic,
+        SelectNextDirectory,
+        SelectPrevDirectory,
     ]
 );
 
@@ -1489,6 +1496,176 @@ impl ProjectPanel {
         }
     }
 
+    fn select_prev_diagnostic(&mut self, _: &SelectPrevDiagnostic, cx: &mut ViewContext<Self>) {
+        let selection = self.find_entry(
+            self.selection.as_ref(),
+            true,
+            |entry, worktree_id| {
+                (self.selection.is_none()
+                    || self.selection.is_some_and(|selection| {
+                        if selection.worktree_id == worktree_id {
+                            selection.entry_id != entry.id
+                        } else {
+                            true
+                        }
+                    }))
+                    && entry.is_file()
+                    && self
+                        .diagnostics
+                        .contains_key(&(worktree_id, entry.path.to_path_buf()))
+            },
+            cx,
+        );
+
+        if let Some(selection) = selection {
+            self.selection = Some(selection);
+            self.expand_entry(selection.worktree_id, selection.entry_id, cx);
+            self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
+            self.autoscroll(cx);
+            cx.notify();
+        }
+    }
+
+    fn select_next_diagnostic(&mut self, _: &SelectNextDiagnostic, cx: &mut ViewContext<Self>) {
+        let selection = self.find_entry(
+            self.selection.as_ref(),
+            false,
+            |entry, worktree_id| {
+                (self.selection.is_none()
+                    || self.selection.is_some_and(|selection| {
+                        if selection.worktree_id == worktree_id {
+                            selection.entry_id != entry.id
+                        } else {
+                            true
+                        }
+                    }))
+                    && entry.is_file()
+                    && self
+                        .diagnostics
+                        .contains_key(&(worktree_id, entry.path.to_path_buf()))
+            },
+            cx,
+        );
+
+        if let Some(selection) = selection {
+            self.selection = Some(selection);
+            self.expand_entry(selection.worktree_id, selection.entry_id, cx);
+            self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
+            self.autoscroll(cx);
+            cx.notify();
+        }
+    }
+
+    fn select_prev_git_entry(&mut self, _: &SelectPrevGitEntry, cx: &mut ViewContext<Self>) {
+        let selection = self.find_entry(
+            self.selection.as_ref(),
+            true,
+            |entry, worktree_id| {
+                (self.selection.is_none()
+                    || self.selection.is_some_and(|selection| {
+                        if selection.worktree_id == worktree_id {
+                            selection.entry_id != entry.id
+                        } else {
+                            true
+                        }
+                    }))
+                    && entry.is_file()
+                    && entry
+                        .git_status
+                        .is_some_and(|status| matches!(status, GitFileStatus::Modified))
+            },
+            cx,
+        );
+
+        if let Some(selection) = selection {
+            self.selection = Some(selection);
+            self.expand_entry(selection.worktree_id, selection.entry_id, cx);
+            self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
+            self.autoscroll(cx);
+            cx.notify();
+        }
+    }
+
+    fn select_prev_directory(&mut self, _: &SelectPrevDirectory, cx: &mut ViewContext<Self>) {
+        let selection = self.find_visible_entry(
+            self.selection.as_ref(),
+            true,
+            |entry, worktree_id| {
+                (self.selection.is_none()
+                    || self.selection.is_some_and(|selection| {
+                        if selection.worktree_id == worktree_id {
+                            selection.entry_id != entry.id
+                        } else {
+                            true
+                        }
+                    }))
+                    && entry.is_dir()
+            },
+            cx,
+        );
+
+        if let Some(selection) = selection {
+            self.selection = Some(selection);
+            self.autoscroll(cx);
+            cx.notify();
+        }
+    }
+
+    fn select_next_directory(&mut self, _: &SelectNextDirectory, cx: &mut ViewContext<Self>) {
+        let selection = self.find_visible_entry(
+            self.selection.as_ref(),
+            false,
+            |entry, worktree_id| {
+                (self.selection.is_none()
+                    || self.selection.is_some_and(|selection| {
+                        if selection.worktree_id == worktree_id {
+                            selection.entry_id != entry.id
+                        } else {
+                            true
+                        }
+                    }))
+                    && entry.is_dir()
+            },
+            cx,
+        );
+
+        if let Some(selection) = selection {
+            self.selection = Some(selection);
+            self.autoscroll(cx);
+            cx.notify();
+        }
+    }
+
+    fn select_next_git_entry(&mut self, _: &SelectNextGitEntry, cx: &mut ViewContext<Self>) {
+        let selection = self.find_entry(
+            self.selection.as_ref(),
+            true,
+            |entry, worktree_id| {
+                (self.selection.is_none()
+                    || self.selection.is_some_and(|selection| {
+                        if selection.worktree_id == worktree_id {
+                            selection.entry_id != entry.id
+                        } else {
+                            true
+                        }
+                    }))
+                    && entry.is_file()
+                    && entry
+                        .git_status
+                        .is_some_and(|status| matches!(status, GitFileStatus::Modified))
+            },
+            cx,
+        );
+
+        if let Some(selection) = selection {
+            self.selection = Some(selection);
+            self.expand_entry(selection.worktree_id, selection.entry_id, cx);
+            self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx);
+            self.autoscroll(cx);
+            cx.notify();
+        }
+    }
+
     fn select_parent(&mut self, _: &SelectParent, cx: &mut ViewContext<Self>) {
         if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
             if let Some(parent) = entry.path.parent() {
@@ -2705,6 +2882,232 @@ impl ProjectPanel {
         }
     }
 
+    fn find_entry_in_worktree(
+        &self,
+        worktree_id: WorktreeId,
+        reverse_search: bool,
+        only_visible_entries: bool,
+        predicate: impl Fn(&Entry, WorktreeId) -> bool,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<Entry> {
+        if only_visible_entries {
+            let entries = self
+                .visible_entries
+                .iter()
+                .find_map(|(tree_id, entries, _)| {
+                    if worktree_id == *tree_id {
+                        Some(entries)
+                    } else {
+                        None
+                    }
+                })?
+                .clone();
+
+            return utils::ReversibleIterable::new(entries.iter(), reverse_search)
+                .find(|ele| predicate(ele, worktree_id))
+                .cloned();
+        }
+
+        let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?;
+        worktree.update(cx, |tree, _| {
+            utils::ReversibleIterable::new(tree.entries(true, 0usize), reverse_search)
+                .find_single_ended(|ele| predicate(ele, worktree_id))
+                .cloned()
+        })
+    }
+
+    fn find_entry(
+        &self,
+        start: Option<&SelectedEntry>,
+        reverse_search: bool,
+        predicate: impl Fn(&Entry, WorktreeId) -> bool,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<SelectedEntry> {
+        let mut worktree_ids: Vec<_> = self
+            .visible_entries
+            .iter()
+            .map(|(worktree_id, _, _)| *worktree_id)
+            .collect();
+
+        let mut last_found: Option<SelectedEntry> = None;
+
+        if let Some(start) = start {
+            let worktree = self
+                .project
+                .read(cx)
+                .worktree_for_id(start.worktree_id, cx)?;
+
+            let search = worktree.update(cx, |tree, _| {
+                let entry = tree.entry_for_id(start.entry_id)?;
+                let root_entry = tree.root_entry()?;
+                let tree_id = tree.id();
+
+                let mut first_iter = tree.traverse_from_path(true, true, true, entry.path.as_ref());
+
+                if reverse_search {
+                    first_iter.next();
+                }
+
+                let first = first_iter
+                    .enumerate()
+                    .take_until(|(count, ele)| *ele == root_entry && *count != 0usize)
+                    .map(|(_, ele)| ele)
+                    .find(|ele| predicate(ele, tree_id))
+                    .cloned();
+
+                let second_iter = tree.entries(true, 0usize);
+
+                let second = if reverse_search {
+                    second_iter
+                        .take_until(|ele| ele.id == start.entry_id)
+                        .filter(|ele| predicate(ele, tree_id))
+                        .last()
+                        .cloned()
+                } else {
+                    second_iter
+                        .take_while(|ele| ele.id != start.entry_id)
+                        .filter(|ele| predicate(ele, tree_id))
+                        .last()
+                        .cloned()
+                };
+
+                if reverse_search {
+                    Some((second, first))
+                } else {
+                    Some((first, second))
+                }
+            });
+
+            if let Some((first, second)) = search {
+                let first = first.map(|entry| SelectedEntry {
+                    worktree_id: start.worktree_id,
+                    entry_id: entry.id,
+                });
+
+                let second = second.map(|entry| SelectedEntry {
+                    worktree_id: start.worktree_id,
+                    entry_id: entry.id,
+                });
+
+                if first.is_some() {
+                    return first;
+                }
+                last_found = second;
+
+                let idx = worktree_ids
+                    .iter()
+                    .enumerate()
+                    .find(|(_, ele)| **ele == start.worktree_id)
+                    .map(|(idx, _)| idx);
+
+                if let Some(idx) = idx {
+                    worktree_ids.rotate_left(idx + 1usize);
+                    worktree_ids.pop();
+                }
+            }
+        }
+
+        for tree_id in worktree_ids.into_iter() {
+            if let Some(found) =
+                self.find_entry_in_worktree(tree_id, reverse_search, false, &predicate, cx)
+            {
+                return Some(SelectedEntry {
+                    worktree_id: tree_id,
+                    entry_id: found.id,
+                });
+            }
+        }
+
+        last_found
+    }
+
+    fn find_visible_entry(
+        &self,
+        start: Option<&SelectedEntry>,
+        reverse_search: bool,
+        predicate: impl Fn(&Entry, WorktreeId) -> bool,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<SelectedEntry> {
+        let mut worktree_ids: Vec<_> = self
+            .visible_entries
+            .iter()
+            .map(|(worktree_id, _, _)| *worktree_id)
+            .collect();
+
+        let mut last_found: Option<SelectedEntry> = None;
+
+        if let Some(start) = start {
+            let entries = self
+                .visible_entries
+                .iter()
+                .find(|(worktree_id, _, _)| *worktree_id == start.worktree_id)
+                .map(|(_, entries, _)| entries)?;
+
+            let mut start_idx = entries
+                .iter()
+                .enumerate()
+                .find(|(_, ele)| ele.id == start.entry_id)
+                .map(|(idx, _)| idx)?;
+
+            if reverse_search {
+                start_idx = start_idx.saturating_add(1usize);
+            }
+
+            let (left, right) = entries.split_at_checked(start_idx)?;
+
+            let (first_iter, second_iter) = if reverse_search {
+                (
+                    utils::ReversibleIterable::new(left.iter(), reverse_search),
+                    utils::ReversibleIterable::new(right.iter(), reverse_search),
+                )
+            } else {
+                (
+                    utils::ReversibleIterable::new(right.iter(), reverse_search),
+                    utils::ReversibleIterable::new(left.iter(), reverse_search),
+                )
+            };
+
+            let first_search = first_iter.find(|ele| predicate(ele, start.worktree_id));
+            let second_search = second_iter.find(|ele| predicate(ele, start.worktree_id));
+
+            if first_search.is_some() {
+                return first_search.map(|entry| SelectedEntry {
+                    worktree_id: start.worktree_id,
+                    entry_id: entry.id,
+                });
+            }
+
+            last_found = second_search.map(|entry| SelectedEntry {
+                worktree_id: start.worktree_id,
+                entry_id: entry.id,
+            });
+
+            let idx = worktree_ids
+                .iter()
+                .enumerate()
+                .find(|(_, ele)| **ele == start.worktree_id)
+                .map(|(idx, _)| idx);
+
+            if let Some(idx) = idx {
+                worktree_ids.rotate_left(idx + 1usize);
+                worktree_ids.pop();
+            }
+        }
+
+        for tree_id in worktree_ids.into_iter() {
+            if let Some(found) =
+                self.find_entry_in_worktree(tree_id, reverse_search, true, &predicate, cx)
+            {
+                return Some(SelectedEntry {
+                    worktree_id: tree_id,
+                    entry_id: found.id,
+                });
+            }
+        }
+
+        last_found
+    }
+
     fn calculate_depth_and_difference(
         entry: &Entry,
         visible_worktree_entries: &HashSet<Arc<Path>>,
@@ -3482,6 +3885,12 @@ impl Render for ProjectPanel {
                 .on_action(cx.listener(Self::select_first))
                 .on_action(cx.listener(Self::select_last))
                 .on_action(cx.listener(Self::select_parent))
+                .on_action(cx.listener(Self::select_next_git_entry))
+                .on_action(cx.listener(Self::select_prev_git_entry))
+                .on_action(cx.listener(Self::select_next_diagnostic))
+                .on_action(cx.listener(Self::select_prev_diagnostic))
+                .on_action(cx.listener(Self::select_next_directory))
+                .on_action(cx.listener(Self::select_prev_directory))
                 .on_action(cx.listener(Self::expand_selected_entry))
                 .on_action(cx.listener(Self::collapse_selected_entry))
                 .on_action(cx.listener(Self::collapse_all_entries))
@@ -5606,6 +6015,107 @@ mod tests {
         );
     }
 
+    #[gpui::test]
+    async fn test_select_directory(cx: &mut gpui::TestAppContext) {
+        init_test_with_editor(cx);
+
+        let fs = FakeFs::new(cx.executor().clone());
+        fs.insert_tree(
+            "/project_root",
+            json!({
+                "dir_1": {
+                    "nested_dir": {
+                        "file_a.py": "# File contents",
+                    }
+                },
+                "file_1.py": "# File contents",
+                "dir_2": {
+
+                },
+                "dir_3": {
+
+                },
+                "file_2.py": "# File contents",
+                "dir_4": {
+
+                },
+            }),
+        )
+        .await;
+
+        let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await;
+        let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
+        let cx = &mut VisualTestContext::from_window(*workspace, cx);
+        let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+        panel.update(cx, |panel, cx| panel.open(&Open, cx));
+        cx.executor().run_until_parked();
+        select_path(&panel, "project_root/dir_1", cx);
+        cx.executor().run_until_parked();
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v project_root",
+                "    > dir_1  <== selected",
+                "    > dir_2",
+                "    > dir_3",
+                "    > dir_4",
+                "      file_1.py",
+                "      file_2.py",
+            ]
+        );
+        panel.update(cx, |panel, cx| {
+            panel.select_prev_directory(&SelectPrevDirectory, cx)
+        });
+
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v project_root  <== selected",
+                "    > dir_1",
+                "    > dir_2",
+                "    > dir_3",
+                "    > dir_4",
+                "      file_1.py",
+                "      file_2.py",
+            ]
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.select_prev_directory(&SelectPrevDirectory, cx)
+        });
+
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v project_root",
+                "    > dir_1",
+                "    > dir_2",
+                "    > dir_3",
+                "    > dir_4  <== selected",
+                "      file_1.py",
+                "      file_2.py",
+            ]
+        );
+
+        panel.update(cx, |panel, cx| {
+            panel.select_next_directory(&SelectNextDirectory, cx)
+        });
+
+        assert_eq!(
+            visible_entries_as_strings(&panel, 0..10, cx),
+            &[
+                "v project_root  <== selected",
+                "    > dir_1",
+                "    > dir_2",
+                "    > dir_3",
+                "    > dir_4",
+                "      file_1.py",
+                "      file_2.py",
+            ]
+        );
+    }
+
     #[gpui::test]
     async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
         init_test_with_editor(cx);

crates/project_panel/src/utils.rs 🔗

@@ -0,0 +1,42 @@
+pub(crate) struct ReversibleIterable<It> {
+    pub(crate) it: It,
+    pub(crate) reverse: bool,
+}
+
+impl<T> ReversibleIterable<T> {
+    pub(crate) fn new(it: T, reverse: bool) -> Self {
+        Self { it, reverse }
+    }
+}
+
+impl<It, Item> ReversibleIterable<It>
+where
+    It: Iterator<Item = Item>,
+{
+    pub(crate) fn find_single_ended<F>(mut self, pred: F) -> Option<Item>
+    where
+        F: FnMut(&Item) -> bool,
+    {
+        if self.reverse {
+            self.it.filter(pred).last()
+        } else {
+            self.it.find(pred)
+        }
+    }
+}
+
+impl<It, Item> ReversibleIterable<It>
+where
+    It: DoubleEndedIterator<Item = Item>,
+{
+    pub(crate) fn find<F>(mut self, mut pred: F) -> Option<Item>
+    where
+        F: FnMut(&Item) -> bool,
+    {
+        if self.reverse {
+            self.it.rfind(|x| pred(x))
+        } else {
+            self.it.find(|x| pred(x))
+        }
+    }
+}