From 7335f211fdc8503666cf11bfb14fd3ff2b288db1 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Thu, 5 Dec 2024 08:07:13 -0500 Subject: [PATCH] Add Project Panel navigation actions in netrw mode (#20941) 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 --- 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(-) create mode 100644 crates/project_panel/src/utils.rs diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index c80a6912cc..8931ad0dca 100644 --- a/assets/keymaps/vim.json +++ b/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", diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 3ef9f1905d..d263c75ca7 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/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) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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) { 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, + ) -> Option { + 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, + ) -> Option { + let mut worktree_ids: Vec<_> = self + .visible_entries + .iter() + .map(|(worktree_id, _, _)| *worktree_id) + .collect(); + + let mut last_found: Option = 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, + ) -> Option { + let mut worktree_ids: Vec<_> = self + .visible_entries + .iter() + .map(|(worktree_id, _, _)| *worktree_id) + .collect(); + + let mut last_found: Option = 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>, @@ -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); diff --git a/crates/project_panel/src/utils.rs b/crates/project_panel/src/utils.rs new file mode 100644 index 0000000000..486def9b84 --- /dev/null +++ b/crates/project_panel/src/utils.rs @@ -0,0 +1,42 @@ +pub(crate) struct ReversibleIterable { + pub(crate) it: It, + pub(crate) reverse: bool, +} + +impl ReversibleIterable { + pub(crate) fn new(it: T, reverse: bool) -> Self { + Self { it, reverse } + } +} + +impl ReversibleIterable +where + It: Iterator, +{ + pub(crate) fn find_single_ended(mut self, pred: F) -> Option + where + F: FnMut(&Item) -> bool, + { + if self.reverse { + self.it.filter(pred).last() + } else { + self.it.find(pred) + } + } +} + +impl ReversibleIterable +where + It: DoubleEndedIterator, +{ + pub(crate) fn find(mut self, mut pred: F) -> Option + where + F: FnMut(&Item) -> bool, + { + if self.reverse { + self.it.rfind(|x| pred(x)) + } else { + self.it.find(|x| pred(x)) + } + } +}