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:
parent
78fea0dd8e
commit
7335f211fd
3 changed files with 559 additions and 1 deletions
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
|
|
42
crates/project_panel/src/utils.rs
Normal file
42
crates/project_panel/src/utils.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue