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