@@ -322,6 +322,41 @@ impl ProjectPanel {
this.update_visible_entries(None, cx);
cx.notify();
}
+ project::Event::ExpandedAllForEntry(worktree_id, entry_id) => {
+ if let Some((worktree, expanded_dir_ids)) = project
+ .read(cx)
+ .worktree_for_id(*worktree_id, cx)
+ .zip(this.expanded_dir_ids.get_mut(&worktree_id))
+ {
+ let worktree = worktree.read(cx);
+
+ let Some(entry) = worktree.entry_for_id(*entry_id) else {
+ return;
+ };
+ let include_ignored_dirs = !entry.is_ignored;
+
+ let mut dirs_to_expand = vec![*entry_id];
+ while let Some(current_id) = dirs_to_expand.pop() {
+ let Some(current_entry) = worktree.entry_for_id(current_id) else {
+ continue;
+ };
+ for child in worktree.child_entries(¤t_entry.path) {
+ if !child.is_dir() || (include_ignored_dirs && child.is_ignored) {
+ continue;
+ }
+
+ dirs_to_expand.push(child.id);
+
+ if let Err(ix) = expanded_dir_ids.binary_search(&child.id) {
+ expanded_dir_ids.insert(ix, child.id);
+ }
+ this.unfolded_dir_ids.insert(child.id);
+ }
+ }
+ this.update_visible_entries(None, cx);
+ cx.notify();
+ }
+ }
_ => {}
})
.detach();
@@ -487,6 +522,7 @@ impl ProjectPanel {
}
}
}
+
_ => {}
}
})
@@ -883,6 +919,99 @@ impl ProjectPanel {
}
}
+ fn toggle_expand_all(
+ &mut self,
+ entry_id: ProjectEntryId,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if let Some(worktree_id) = self.project.read(cx).worktree_id_for_entry(entry_id, cx) {
+ if let Some(expanded_dir_ids) = self.expanded_dir_ids.get_mut(&worktree_id) {
+ match expanded_dir_ids.binary_search(&entry_id) {
+ Ok(_ix) => {
+ self.collapse_all_for_entry(worktree_id, entry_id, cx);
+ }
+ Err(_ix) => {
+ self.expand_all_for_entry(worktree_id, entry_id, cx);
+ }
+ }
+ self.update_visible_entries(Some((worktree_id, entry_id)), cx);
+ window.focus(&self.focus_handle);
+ cx.notify();
+ }
+ }
+ }
+
+ fn expand_all_for_entry(
+ &mut self,
+ worktree_id: WorktreeId,
+ entry_id: ProjectEntryId,
+ cx: &mut Context<Self>,
+ ) {
+ self.project.update(cx, |project, cx| {
+ if let Some((worktree, expanded_dir_ids)) = project
+ .worktree_for_id(worktree_id, cx)
+ .zip(self.expanded_dir_ids.get_mut(&worktree_id))
+ {
+ if let Some(task) = project.expand_all_for_entry(worktree_id, entry_id, cx) {
+ task.detach();
+ }
+
+ let worktree = worktree.read(cx);
+
+ if let Some(mut entry) = worktree.entry_for_id(entry_id) {
+ loop {
+ if let Err(ix) = expanded_dir_ids.binary_search(&entry.id) {
+ expanded_dir_ids.insert(ix, entry.id);
+ }
+
+ if let Some(parent_entry) =
+ entry.path.parent().and_then(|p| worktree.entry_for_path(p))
+ {
+ entry = parent_entry;
+ } else {
+ break;
+ }
+ }
+ }
+ }
+ });
+ }
+
+ fn collapse_all_for_entry(
+ &mut self,
+ worktree_id: WorktreeId,
+ entry_id: ProjectEntryId,
+ cx: &mut Context<Self>,
+ ) {
+ self.project.update(cx, |project, cx| {
+ if let Some((worktree, expanded_dir_ids)) = project
+ .worktree_for_id(worktree_id, cx)
+ .zip(self.expanded_dir_ids.get_mut(&worktree_id))
+ {
+ let worktree = worktree.read(cx);
+ let mut dirs_to_collapse = vec![entry_id];
+ let auto_fold_enabled = ProjectPanelSettings::get_global(cx).auto_fold_dirs;
+ while let Some(current_id) = dirs_to_collapse.pop() {
+ let Some(current_entry) = worktree.entry_for_id(current_id) else {
+ continue;
+ };
+ if let Ok(ix) = expanded_dir_ids.binary_search(¤t_id) {
+ expanded_dir_ids.remove(ix);
+ }
+ if auto_fold_enabled {
+ self.unfolded_dir_ids.remove(¤t_id);
+ }
+ for child in worktree.child_entries(¤t_entry.path) {
+ if child.is_dir() {
+ dirs_to_collapse.push(child.id);
+ }
+ }
+ }
+ }
+ });
+ }
+
fn select_prev(&mut self, _: &SelectPrev, window: &mut Window, cx: &mut Context<Self>) {
if let Some(edit_state) = &self.edit_state {
if edit_state.processing_filename.is_none() {
@@ -3585,7 +3714,11 @@ impl ProjectPanel {
}
} else if kind.is_dir() {
this.marked_entries.clear();
- this.toggle_expanded(entry_id, window, cx);
+ if event.down.modifiers.alt {
+ this.toggle_expand_all(entry_id, window, cx);
+ } else {
+ this.toggle_expanded(entry_id, window, cx);
+ }
} else {
let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled;
let click_count = event.up.click_count;
@@ -8293,6 +8426,383 @@ mod tests {
});
}
+ #[gpui::test]
+ async fn test_expand_all_for_entry(cx: &mut gpui::TestAppContext) {
+ init_test_with_editor(cx);
+
+ let fs = FakeFs::new(cx.executor().clone());
+ fs.insert_tree(
+ "/root",
+ json!({
+ ".gitignore": "**/ignored_dir\n**/ignored_nested",
+ "dir1": {
+ "empty1": {
+ "empty2": {
+ "empty3": {
+ "file.txt": ""
+ }
+ }
+ },
+ "subdir1": {
+ "file1.txt": "",
+ "file2.txt": "",
+ "ignored_nested": {
+ "ignored_file.txt": ""
+ }
+ },
+ "ignored_dir": {
+ "subdir": {
+ "deep_file.txt": ""
+ }
+ }
+ }
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+ let workspace =
+ cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+ // Test 1: When auto-fold is enabled
+ cx.update(|_, cx| {
+ let settings = *ProjectPanelSettings::get_global(cx);
+ ProjectPanelSettings::override_global(
+ ProjectPanelSettings {
+ auto_fold_dirs: true,
+ ..settings
+ },
+ cx,
+ );
+ });
+
+ let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..20, cx),
+ &["v root", " > dir1", " .gitignore",],
+ "Initial state should show collapsed root structure"
+ );
+
+ toggle_expand_dir(&panel, "root/dir1", cx);
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..20, cx),
+ &[
+ "v root",
+ " v dir1 <== selected",
+ " > empty1/empty2/empty3",
+ " > ignored_dir",
+ " > subdir1",
+ " .gitignore",
+ ],
+ "Should show first level with auto-folded dirs and ignored dir visible"
+ );
+
+ let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
+ panel.update(cx, |panel, cx| {
+ let project = panel.project.read(cx);
+ let worktree = project.worktrees(cx).next().unwrap().read(cx);
+ panel.expand_all_for_entry(worktree.id(), entry_id, cx);
+ panel.update_visible_entries(None, cx);
+ });
+ cx.run_until_parked();
+
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..20, cx),
+ &[
+ "v root",
+ " v dir1 <== selected",
+ " v empty1",
+ " v empty2",
+ " v empty3",
+ " file.txt",
+ " > ignored_dir",
+ " v subdir1",
+ " > ignored_nested",
+ " file1.txt",
+ " file2.txt",
+ " .gitignore",
+ ],
+ "After expand_all with auto-fold: should not expand ignored_dir, should expand folded dirs, and should not expand ignored_nested"
+ );
+
+ // Test 2: When auto-fold is disabled
+ cx.update(|_, cx| {
+ let settings = *ProjectPanelSettings::get_global(cx);
+ ProjectPanelSettings::override_global(
+ ProjectPanelSettings {
+ auto_fold_dirs: false,
+ ..settings
+ },
+ cx,
+ );
+ });
+
+ panel.update_in(cx, |panel, window, cx| {
+ panel.collapse_all_entries(&CollapseAllEntries, window, cx);
+ });
+
+ toggle_expand_dir(&panel, "root/dir1", cx);
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..20, cx),
+ &[
+ "v root",
+ " v dir1 <== selected",
+ " > empty1",
+ " > ignored_dir",
+ " > subdir1",
+ " .gitignore",
+ ],
+ "With auto-fold disabled: should show all directories separately"
+ );
+
+ let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
+ panel.update(cx, |panel, cx| {
+ let project = panel.project.read(cx);
+ let worktree = project.worktrees(cx).next().unwrap().read(cx);
+ panel.expand_all_for_entry(worktree.id(), entry_id, cx);
+ panel.update_visible_entries(None, cx);
+ });
+ cx.run_until_parked();
+
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..20, cx),
+ &[
+ "v root",
+ " v dir1 <== selected",
+ " v empty1",
+ " v empty2",
+ " v empty3",
+ " file.txt",
+ " > ignored_dir",
+ " v subdir1",
+ " > ignored_nested",
+ " file1.txt",
+ " file2.txt",
+ " .gitignore",
+ ],
+ "After expand_all without auto-fold: should expand all dirs normally, \
+ expand ignored_dir itself but not its subdirs, and not expand ignored_nested"
+ );
+
+ // Test 3: When explicitly called on ignored directory
+ let ignored_dir_entry = find_project_entry(&panel, "root/dir1/ignored_dir", cx).unwrap();
+ panel.update(cx, |panel, cx| {
+ let project = panel.project.read(cx);
+ let worktree = project.worktrees(cx).next().unwrap().read(cx);
+ panel.expand_all_for_entry(worktree.id(), ignored_dir_entry, cx);
+ panel.update_visible_entries(None, cx);
+ });
+ cx.run_until_parked();
+
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..20, cx),
+ &[
+ "v root",
+ " v dir1 <== selected",
+ " v empty1",
+ " v empty2",
+ " v empty3",
+ " file.txt",
+ " v ignored_dir",
+ " v subdir",
+ " deep_file.txt",
+ " v subdir1",
+ " > ignored_nested",
+ " file1.txt",
+ " file2.txt",
+ " .gitignore",
+ ],
+ "After expand_all on ignored_dir: should expand all contents of the ignored directory"
+ );
+ }
+
+ #[gpui::test]
+ async fn test_collapse_all_for_entry(cx: &mut gpui::TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor().clone());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "dir1": {
+ "subdir1": {
+ "nested1": {
+ "file1.txt": "",
+ "file2.txt": ""
+ },
+ },
+ "subdir2": {
+ "file4.txt": ""
+ }
+ },
+ "dir2": {
+ "single_file": {
+ "file5.txt": ""
+ }
+ }
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+ let workspace =
+ cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+ let cx = &mut VisualTestContext::from_window(*workspace, cx);
+
+ // Test 1: Basic collapsing
+ {
+ let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+ toggle_expand_dir(&panel, "root/dir1", cx);
+ toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
+ toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
+ toggle_expand_dir(&panel, "root/dir1/subdir2", cx);
+
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..20, cx),
+ &[
+ "v root",
+ " v dir1",
+ " v subdir1",
+ " v nested1",
+ " file1.txt",
+ " file2.txt",
+ " v subdir2 <== selected",
+ " file4.txt",
+ " > dir2",
+ ],
+ "Initial state with everything expanded"
+ );
+
+ let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
+ panel.update(cx, |panel, cx| {
+ let project = panel.project.read(cx);
+ let worktree = project.worktrees(cx).next().unwrap().read(cx);
+ panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
+ panel.update_visible_entries(None, cx);
+ });
+
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..20, cx),
+ &["v root", " > dir1", " > dir2",],
+ "All subdirs under dir1 should be collapsed"
+ );
+ }
+
+ // Test 2: With auto-fold enabled
+ {
+ cx.update(|_, cx| {
+ let settings = *ProjectPanelSettings::get_global(cx);
+ ProjectPanelSettings::override_global(
+ ProjectPanelSettings {
+ auto_fold_dirs: true,
+ ..settings
+ },
+ cx,
+ );
+ });
+
+ let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+ toggle_expand_dir(&panel, "root/dir1", cx);
+ toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
+ toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
+
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..20, cx),
+ &[
+ "v root",
+ " v dir1",
+ " v subdir1/nested1 <== selected",
+ " file1.txt",
+ " file2.txt",
+ " > subdir2",
+ " > dir2/single_file",
+ ],
+ "Initial state with some dirs expanded"
+ );
+
+ let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
+ panel.update(cx, |panel, cx| {
+ let project = panel.project.read(cx);
+ let worktree = project.worktrees(cx).next().unwrap().read(cx);
+ panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
+ });
+
+ toggle_expand_dir(&panel, "root/dir1", cx);
+
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..20, cx),
+ &[
+ "v root",
+ " v dir1 <== selected",
+ " > subdir1/nested1",
+ " > subdir2",
+ " > dir2/single_file",
+ ],
+ "Subdirs should be collapsed and folded with auto-fold enabled"
+ );
+ }
+
+ // Test 3: With auto-fold disabled
+ {
+ cx.update(|_, cx| {
+ let settings = *ProjectPanelSettings::get_global(cx);
+ ProjectPanelSettings::override_global(
+ ProjectPanelSettings {
+ auto_fold_dirs: false,
+ ..settings
+ },
+ cx,
+ );
+ });
+
+ let panel = workspace.update(cx, ProjectPanel::new).unwrap();
+
+ toggle_expand_dir(&panel, "root/dir1", cx);
+ toggle_expand_dir(&panel, "root/dir1/subdir1", cx);
+ toggle_expand_dir(&panel, "root/dir1/subdir1/nested1", cx);
+
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..20, cx),
+ &[
+ "v root",
+ " v dir1",
+ " v subdir1",
+ " v nested1 <== selected",
+ " file1.txt",
+ " file2.txt",
+ " > subdir2",
+ " > dir2",
+ ],
+ "Initial state with some dirs expanded and auto-fold disabled"
+ );
+
+ let entry_id = find_project_entry(&panel, "root/dir1", cx).unwrap();
+ panel.update(cx, |panel, cx| {
+ let project = panel.project.read(cx);
+ let worktree = project.worktrees(cx).next().unwrap().read(cx);
+ panel.collapse_all_for_entry(worktree.id(), entry_id, cx);
+ });
+
+ toggle_expand_dir(&panel, "root/dir1", cx);
+
+ assert_eq!(
+ visible_entries_as_strings(&panel, 0..20, cx),
+ &[
+ "v root",
+ " v dir1 <== selected",
+ " > subdir1",
+ " > subdir2",
+ " > dir2",
+ ],
+ "Subdirs should be collapsed but not folded with auto-fold disabled"
+ );
+ }
+ }
+
fn select_path(
panel: &Entity<ProjectPanel>,
path: impl AsRef<Path>,
@@ -117,7 +117,7 @@ pub struct LoadedBinaryFile {
pub struct LocalWorktree {
snapshot: LocalSnapshot,
scan_requests_tx: channel::Sender<ScanRequest>,
- path_prefixes_to_scan_tx: channel::Sender<Arc<Path>>,
+ path_prefixes_to_scan_tx: channel::Sender<PathPrefixScanRequest>,
is_scanning: (watch::Sender<bool>, watch::Receiver<bool>),
_background_scanner_tasks: Vec<Task<()>>,
update_observer: Option<UpdateObservationState>,
@@ -129,6 +129,11 @@ pub struct LocalWorktree {
share_private_files: bool,
}
+pub struct PathPrefixScanRequest {
+ path: Arc<Path>,
+ done: SmallVec<[barrier::Sender; 1]>,
+}
+
struct ScanRequest {
relative_paths: Vec<Arc<Path>>,
done: SmallVec<[barrier::Sender; 1]>,
@@ -1097,6 +1102,32 @@ impl Worktree {
}
}
+ pub fn expand_all_for_entry(
+ &mut self,
+ entry_id: ProjectEntryId,
+ cx: &Context<Worktree>,
+ ) -> Option<Task<Result<()>>> {
+ match self {
+ Worktree::Local(this) => this.expand_all_for_entry(entry_id, cx),
+ Worktree::Remote(this) => {
+ let response = this.client.request(proto::ExpandAllForProjectEntry {
+ project_id: this.project_id,
+ entry_id: entry_id.to_proto(),
+ });
+ Some(cx.spawn(move |this, mut cx| async move {
+ let response = response.await?;
+ this.update(&mut cx, |this, _| {
+ this.as_remote_mut()
+ .unwrap()
+ .wait_for_snapshot(response.worktree_scan_id as usize)
+ })?
+ .await?;
+ Ok(())
+ }))
+ }
+ }
+ }
+
pub async fn handle_create_entry(
this: Entity<Self>,
request: proto::CreateProjectEntry,
@@ -1154,6 +1185,21 @@ impl Worktree {
})
}
+ pub async fn handle_expand_all_for_entry(
+ this: Entity<Self>,
+ request: proto::ExpandAllForProjectEntry,
+ mut cx: AsyncAppContext,
+ ) -> Result<proto::ExpandAllForProjectEntryResponse> {
+ let task = this.update(&mut cx, |this, cx| {
+ this.expand_all_for_entry(ProjectEntryId::from_proto(request.entry_id), cx)
+ })?;
+ task.ok_or_else(|| anyhow!("no such entry"))?.await?;
+ let scan_id = this.read_with(&cx, |this, _| this.scan_id())?;
+ Ok(proto::ExpandAllForProjectEntryResponse {
+ worktree_scan_id: scan_id as u64,
+ })
+ }
+
pub async fn handle_rename_entry(
this: Entity<Self>,
request: proto::RenameProjectEntry,
@@ -1238,7 +1284,7 @@ impl LocalWorktree {
fn start_background_scanner(
&mut self,
scan_requests_rx: channel::Receiver<ScanRequest>,
- path_prefixes_to_scan_rx: channel::Receiver<Arc<Path>>,
+ path_prefixes_to_scan_rx: channel::Receiver<PathPrefixScanRequest>,
cx: &Context<Worktree>,
) {
let snapshot = self.snapshot();
@@ -1961,6 +2007,19 @@ impl LocalWorktree {
}))
}
+ fn expand_all_for_entry(
+ &self,
+ entry_id: ProjectEntryId,
+ cx: &Context<Worktree>,
+ ) -> Option<Task<Result<()>>> {
+ let path = self.entry_for_id(entry_id).unwrap().path.clone();
+ let mut rx = self.add_path_prefix_to_scan(path.clone());
+ Some(cx.background_executor().spawn(async move {
+ rx.next().await;
+ Ok(())
+ }))
+ }
+
fn refresh_entries_for_paths(&self, paths: Vec<Arc<Path>>) -> barrier::Receiver {
let (tx, rx) = barrier::channel();
self.scan_requests_tx
@@ -1972,8 +2031,15 @@ impl LocalWorktree {
rx
}
- pub fn add_path_prefix_to_scan(&self, path_prefix: Arc<Path>) {
- self.path_prefixes_to_scan_tx.try_send(path_prefix).ok();
+ pub fn add_path_prefix_to_scan(&self, path_prefix: Arc<Path>) -> barrier::Receiver {
+ let (tx, rx) = barrier::channel();
+ self.path_prefixes_to_scan_tx
+ .try_send(PathPrefixScanRequest {
+ path: path_prefix,
+ done: smallvec![tx],
+ })
+ .ok();
+ rx
}
fn refresh_entry(
@@ -4007,7 +4073,7 @@ struct BackgroundScanner {
status_updates_tx: UnboundedSender<ScanState>,
executor: BackgroundExecutor,
scan_requests_rx: channel::Receiver<ScanRequest>,
- path_prefixes_to_scan_rx: channel::Receiver<Arc<Path>>,
+ path_prefixes_to_scan_rx: channel::Receiver<PathPrefixScanRequest>,
next_entry_id: Arc<AtomicUsize>,
phase: BackgroundScannerPhase,
watcher: Arc<dyn Watcher>,
@@ -4132,23 +4198,24 @@ impl BackgroundScanner {
}
}
- path_prefix = self.path_prefixes_to_scan_rx.recv().fuse() => {
- let Ok(path_prefix) = path_prefix else { break };
- log::trace!("adding path prefix {:?}", path_prefix);
+ path_prefix_request = self.path_prefixes_to_scan_rx.recv().fuse() => {
+ let Ok(request) = path_prefix_request else { break };
+ log::trace!("adding path prefix {:?}", request.path);
- let did_scan = self.forcibly_load_paths(&[path_prefix.clone()]).await;
+ let did_scan = self.forcibly_load_paths(&[request.path.clone()]).await;
if did_scan {
let abs_path =
{
let mut state = self.state.lock();
- state.path_prefixes_to_scan.insert(path_prefix.clone());
- state.snapshot.abs_path.as_path().join(&path_prefix)
+ state.path_prefixes_to_scan.insert(request.path.clone());
+ state.snapshot.abs_path.as_path().join(&request.path)
};
if let Some(abs_path) = self.fs.canonicalize(&abs_path).await.log_err() {
self.process_events(vec![abs_path]).await;
}
}
+ self.send_status_update(false, request.done);
}
paths = fs_events_rx.next().fuse() => {