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
This commit is contained in:
Anthony Eid 2024-12-05 08:07:13 -05:00 committed by GitHub
parent 78fea0dd8e
commit 7335f211fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 559 additions and 1 deletions

View file

@ -659,6 +659,12 @@
"p": "project_panel::Open", "p": "project_panel::Open",
"x": "project_panel::RevealInFileManager", "x": "project_panel::RevealInFileManager",
"s": "project_panel::OpenWithSystem", "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", "shift-g": "menu::SelectLast",
"g g": "menu::SelectFirst", "g g": "menu::SelectFirst",
"-": "project_panel::SelectParent", "-": "project_panel::SelectParent",

View file

@ -1,4 +1,5 @@
mod project_panel_settings; mod project_panel_settings;
mod utils;
use client::{ErrorCode, ErrorExt}; use client::{ErrorCode, ErrorExt};
use language::DiagnosticSeverity; use language::DiagnosticSeverity;
@ -56,7 +57,7 @@ use ui::{
IndentGuideColors, IndentGuideLayout, KeyBinding, Label, ListItem, Scrollbar, ScrollbarState, IndentGuideColors, IndentGuideLayout, KeyBinding, Label, ListItem, Scrollbar, ScrollbarState,
Tooltip, Tooltip,
}; };
use util::{maybe, paths::compare_paths, ResultExt, TryFutureExt}; use util::{maybe, paths::compare_paths, ResultExt, TakeUntilExt, TryFutureExt};
use workspace::{ use workspace::{
dock::{DockPosition, Panel, PanelEvent}, dock::{DockPosition, Panel, PanelEvent},
notifications::{DetachAndPromptErr, NotifyTaskExt}, notifications::{DetachAndPromptErr, NotifyTaskExt},
@ -192,6 +193,12 @@ actions!(
UnfoldDirectory, UnfoldDirectory,
FoldDirectory, FoldDirectory,
SelectParent, 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>) { fn select_parent(&mut self, _: &SelectParent, cx: &mut ViewContext<Self>) {
if let Some((worktree, entry)) = self.selected_sub_entry(cx) { if let Some((worktree, entry)) = self.selected_sub_entry(cx) {
if let Some(parent) = entry.path.parent() { 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( fn calculate_depth_and_difference(
entry: &Entry, entry: &Entry,
visible_worktree_entries: &HashSet<Arc<Path>>, 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_first))
.on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::select_last))
.on_action(cx.listener(Self::select_parent)) .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::expand_selected_entry))
.on_action(cx.listener(Self::collapse_selected_entry)) .on_action(cx.listener(Self::collapse_selected_entry))
.on_action(cx.listener(Self::collapse_all_entries)) .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] #[gpui::test]
async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) { async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) {
init_test_with_editor(cx); init_test_with_editor(cx);

View file

@ -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))
}
}
}