diff --git a/assets/settings/default.json b/assets/settings/default.json index 221862ca987d4cc964daa8e436fe96a469e468b8..8217f1675a4382d4dd0ab9daa6247391d40b3e4f 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -143,7 +143,11 @@ // Whether to show the git status in the project panel. "git_status": true, // Amount of indentation for nested items. - "indent_size": 20 + "indent_size": 20, + // Whether to reveal it in the project panel automatically, + // when a corresponding project entry becomes active. + // Gitignored entries are never auto revealed. + "auto_reveal_entries": true }, "collaboration_panel": { // Whether to show the collaboration panel button in the status bar. diff --git a/crates/editor2/src/items.rs b/crates/editor2/src/items.rs index eb30931aac32fd460f022ec0d22f843581ecb3c6..fc8f7dfbbfb5cefe7f6d91b1d4dfdc6adbc3a3d8 100644 --- a/crates/editor2/src/items.rs +++ b/crates/editor2/src/items.rs @@ -1189,7 +1189,7 @@ impl CursorPosition { impl Render for CursorPosition { type Element = Div; - fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + fn render(&mut self, _: &mut ViewContext) -> Self::Element { div().when_some(self.position, |el, position| { let mut text = format!( "{}{FILE_ROW_COLUMN_DELIMITER}{}", diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 8b059948c8c5bc45628585c1da038843faacf55c..ed18ff700b3a01f1d4ff03996ecb241952fd74d1 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -301,6 +301,7 @@ pub enum Event { CollaboratorJoined(proto::PeerId), CollaboratorLeft(proto::PeerId), RefreshInlayHints, + RevealInProjectPanel(ProjectEntryId), } pub enum LanguageServerState { diff --git a/crates/project2/src/project2.rs b/crates/project2/src/project2.rs index fe3498b930ef76ad7f061aed7af19c5c66aa96a0..773b68c9e355209780900126b79a9d27ca7e3200 100644 --- a/crates/project2/src/project2.rs +++ b/crates/project2/src/project2.rs @@ -299,6 +299,7 @@ pub enum Event { CollaboratorJoined(proto::PeerId), CollaboratorLeft(proto::PeerId), RefreshInlayHints, + RevealInProjectPanel(ProjectEntryId), } pub enum LanguageServerState { diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index c37d38804151585ca99bdc909cddbbbf0633f141..30d750cf46842a8e2387a8425b3c60e8b6afe4b1 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -199,14 +199,14 @@ impl ProjectPanel { .detach(); cx.subscribe(&project, |this, project, event, cx| match event { project::Event::ActiveEntryChanged(Some(entry_id)) => { - if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx) - { - this.expand_entry(worktree_id, *entry_id, cx); - this.update_visible_entries(Some((worktree_id, *entry_id)), cx); - this.autoscroll(cx); - cx.notify(); + if settings::get::(cx).auto_reveal_entries { + this.reveal_entry(project, *entry_id, true, cx); } } + project::Event::RevealInProjectPanel(entry_id) => { + this.reveal_entry(project, *entry_id, false, cx); + cx.emit(Event::ActivatePanel); + } project::Event::ActivateProjectPanel => { cx.emit(Event::ActivatePanel); } @@ -1531,6 +1531,31 @@ impl ProjectPanel { .with_cursor_style(CursorStyle::PointingHand) .into_any_named("project panel entry") } + + fn reveal_entry( + &mut self, + project: ModelHandle, + entry_id: ProjectEntryId, + skip_ignored: bool, + cx: &mut ViewContext<'_, '_, ProjectPanel>, + ) { + if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) { + let worktree = worktree.read(cx); + if skip_ignored + && worktree + .entry_for_id(entry_id) + .map_or(true, |entry| entry.is_ignored) + { + return; + } + + let worktree_id = worktree.id(); + self.expand_entry(worktree_id, entry_id, cx); + self.update_visible_entries(Some((worktree_id, entry_id)), cx); + self.autoscroll(cx); + cx.notify(); + } + } } impl View for ProjectPanel { @@ -2956,6 +2981,445 @@ mod tests { ); } + #[gpui::test] + async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = Some(Vec::new()); + }); + store.update_user_settings::(cx, |project_panel_settings| { + project_panel_settings.auto_reveal_entries = Some(false) + }); + }) + }); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/project_root", + json!({ + ".git": {}, + ".gitignore": "**/gitignored_dir", + "dir_1": { + "file_1.py": "# File 1_1 contents", + "file_2.py": "# File 1_2 contents", + "file_3.py": "# File 1_3 contents", + "gitignored_dir": { + "file_a.py": "# File contents", + "file_b.py": "# File contents", + "file_c.py": "# File contents", + }, + }, + "dir_2": { + "file_1.py": "# File 2_1 contents", + "file_2.py": "# File 2_2 contents", + "file_3.py": "# File 2_3 contents", + } + }), + ) + .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)) + .root(cx); + let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1", + " > dir_2", + " .gitignore", + ] + ); + + let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx) + .expect("dir 1 file is not ignored and should have an entry"); + let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx) + .expect("dir 2 file is not ignored and should have an entry"); + let gitignored_dir_file = + find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx); + assert_eq!( + gitignored_dir_file, None, + "File in the gitignored dir should not have an entry before its dir is toggled" + ); + + toggle_expand_dir(&panel, "project_root/dir_1", cx); + toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); + cx.foreground().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " v gitignored_dir <== selected", + " file_a.py", + " file_b.py", + " file_c.py", + " file_1.py", + " file_2.py", + " file_3.py", + " > dir_2", + " .gitignore", + ], + "Should show gitignored dir file list in the project panel" + ); + let gitignored_dir_file = + find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx) + .expect("after gitignored dir got opened, a file entry should be present"); + + toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); + toggle_expand_dir(&panel, "project_root/dir_1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1 <== selected", + " > dir_2", + " .gitignore", + ], + "Should hide all dir contents again and prepare for the auto reveal test" + ); + + for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] { + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some(file_entry))) + }) + }); + cx.foreground().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1 <== selected", + " > dir_2", + " .gitignore", + ], + "When no auto reveal is enabled, the selected entry should not be revealed in the project panel" + ); + } + + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_panel_settings| { + project_panel_settings.auto_reveal_entries = Some(true) + }); + }) + }); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file))) + }) + }); + cx.foreground().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " > gitignored_dir", + " file_1.py <== selected", + " file_2.py", + " file_3.py", + " > dir_2", + " .gitignore", + ], + "When auto reveal is enabled, not ignored dir_1 entry should be revealed" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file))) + }) + }); + cx.foreground().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " > gitignored_dir", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py <== selected", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "When auto reveal is enabled, not ignored dir_2 entry should be revealed" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some( + gitignored_dir_file, + ))) + }) + }); + cx.foreground().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " > gitignored_dir", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py <== selected", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file)) + }) + }); + cx.foreground().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " v gitignored_dir", + " file_a.py <== selected", + " file_b.py", + " file_c.py", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "When a gitignored entry is explicitly revealed, it should be shown in the project tree" + ); + } + + #[gpui::test] + async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = Some(Vec::new()); + }); + store.update_user_settings::(cx, |project_panel_settings| { + project_panel_settings.auto_reveal_entries = Some(false) + }); + }) + }); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/project_root", + json!({ + ".git": {}, + ".gitignore": "**/gitignored_dir", + "dir_1": { + "file_1.py": "# File 1_1 contents", + "file_2.py": "# File 1_2 contents", + "file_3.py": "# File 1_3 contents", + "gitignored_dir": { + "file_a.py": "# File contents", + "file_b.py": "# File contents", + "file_c.py": "# File contents", + }, + }, + "dir_2": { + "file_1.py": "# File 2_1 contents", + "file_2.py": "# File 2_2 contents", + "file_3.py": "# File 2_3 contents", + } + }), + ) + .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)) + .root(cx); + let panel = workspace.update(cx, |workspace, cx| ProjectPanel::new(workspace, cx)); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1", + " > dir_2", + " .gitignore", + ] + ); + + let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx) + .expect("dir 1 file is not ignored and should have an entry"); + let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx) + .expect("dir 2 file is not ignored and should have an entry"); + let gitignored_dir_file = + find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx); + assert_eq!( + gitignored_dir_file, None, + "File in the gitignored dir should not have an entry before its dir is toggled" + ); + + toggle_expand_dir(&panel, "project_root/dir_1", cx); + toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); + cx.foreground().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " v gitignored_dir <== selected", + " file_a.py", + " file_b.py", + " file_c.py", + " file_1.py", + " file_2.py", + " file_3.py", + " > dir_2", + " .gitignore", + ], + "Should show gitignored dir file list in the project panel" + ); + let gitignored_dir_file = + find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx) + .expect("after gitignored dir got opened, a file entry should be present"); + + toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); + toggle_expand_dir(&panel, "project_root/dir_1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1 <== selected", + " > dir_2", + " .gitignore", + ], + "Should hide all dir contents again and prepare for the explicit reveal test" + ); + + for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] { + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some(file_entry))) + }) + }); + cx.foreground().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1 <== selected", + " > dir_2", + " .gitignore", + ], + "When no auto reveal is enabled, the selected entry should not be revealed in the project panel" + ); + } + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(dir_1_file)) + }) + }); + cx.foreground().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " > gitignored_dir", + " file_1.py <== selected", + " file_2.py", + " file_3.py", + " > dir_2", + " .gitignore", + ], + "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(dir_2_file)) + }) + }); + cx.foreground().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " > gitignored_dir", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py <== selected", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file)) + }) + }); + cx.foreground().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " v gitignored_dir", + " file_a.py <== selected", + " file_b.py", + " file_c.py", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "With no auto reveal, explicit reveal should show the gitignored entry in the project panel" + ); + } + fn toggle_expand_dir( panel: &ViewHandle, path: impl AsRef, @@ -2993,10 +3457,27 @@ mod tests { return; } } - panic!("no worktree for path {:?}", path); + panic!("no worktree for path {path:?}"); }); } + fn find_project_entry( + panel: &ViewHandle, + path: impl AsRef, + cx: &mut TestAppContext, + ) -> Option { + let path = path.as_ref(); + panel.update(cx, |panel, cx| { + for worktree in panel.project.read(cx).worktrees(cx).collect::>() { + let worktree = worktree.read(cx); + if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) { + return worktree.entry_for_path(relative_path).map(|entry| entry.id); + } + } + panic!("no worktree for path {path:?}"); + }) + } + fn visible_entries_as_strings( panel: &ViewHandle, range: Range, diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 126433e5a3930c832a8d7c66fa7031e1236172b1..836fe1d5584acfe4598db4e8a524954c9fcdde93 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -18,6 +18,7 @@ pub struct ProjectPanelSettings { pub folder_icons: bool, pub git_status: bool, pub indent_size: f32, + pub auto_reveal_entries: bool, } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] @@ -28,6 +29,7 @@ pub struct ProjectPanelSettingsContent { pub folder_icons: Option, pub git_status: Option, pub indent_size: Option, + pub auto_reveal_entries: Option, } impl Setting for ProjectPanelSettings { diff --git a/crates/project_panel2/src/project_panel.rs b/crates/project_panel2/src/project_panel.rs index b8961810e7643907f894c773d6b338a1485e18d6..adcd21cac6d35f2a00f24938184122d7c7d842c1 100644 --- a/crates/project_panel2/src/project_panel.rs +++ b/crates/project_panel2/src/project_panel.rs @@ -184,14 +184,14 @@ impl ProjectPanel { cx.subscribe(&project, |this, project, event, cx| match event { project::Event::ActiveEntryChanged(Some(entry_id)) => { - if let Some(worktree_id) = project.read(cx).worktree_id_for_entry(*entry_id, cx) - { - this.expand_entry(worktree_id, *entry_id, cx); - this.update_visible_entries(Some((worktree_id, *entry_id)), cx); - this.autoscroll(cx); - cx.notify(); + if ProjectPanelSettings::get_global(cx).auto_reveal_entries { + this.reveal_entry(project, *entry_id, true, cx); } } + project::Event::RevealInProjectPanel(entry_id) => { + this.reveal_entry(project, *entry_id, false, cx); + cx.emit(Event::ActivatePanel); + } project::Event::ActivateProjectPanel => { cx.emit(Event::ActivatePanel); } @@ -1456,6 +1456,31 @@ impl ProjectPanel { dispatch_context.add(identifier); dispatch_context } + + fn reveal_entry( + &mut self, + project: Model, + entry_id: ProjectEntryId, + skip_ignored: bool, + cx: &mut ViewContext<'_, ProjectPanel>, + ) { + if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) { + let worktree = worktree.read(cx); + if skip_ignored + && worktree + .entry_for_id(entry_id) + .map_or(true, |entry| entry.is_ignored) + { + return; + } + + let worktree_id = worktree.id(); + self.expand_entry(worktree_id, entry_id, cx); + self.update_visible_entries(Some((worktree_id, entry_id)), cx); + self.autoscroll(cx); + cx.notify(); + } + } } impl Render for ProjectPanel { @@ -2876,6 +2901,447 @@ mod tests { ); } + #[gpui::test] + async fn test_autoreveal_and_gitignored_files(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = Some(Vec::new()); + }); + store.update_user_settings::(cx, |project_panel_settings| { + project_panel_settings.auto_reveal_entries = Some(false) + }); + }) + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/project_root", + json!({ + ".git": {}, + ".gitignore": "**/gitignored_dir", + "dir_1": { + "file_1.py": "# File 1_1 contents", + "file_2.py": "# File 1_2 contents", + "file_3.py": "# File 1_3 contents", + "gitignored_dir": { + "file_a.py": "# File contents", + "file_b.py": "# File contents", + "file_c.py": "# File contents", + }, + }, + "dir_2": { + "file_1.py": "# File 2_1 contents", + "file_2.py": "# File 2_2 contents", + "file_3.py": "# File 2_3 contents", + } + }), + ) + .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, |workspace, cx| ProjectPanel::new(workspace, cx)) + .unwrap(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1", + " > dir_2", + " .gitignore", + ] + ); + + let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx) + .expect("dir 1 file is not ignored and should have an entry"); + let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx) + .expect("dir 2 file is not ignored and should have an entry"); + let gitignored_dir_file = + find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx); + assert_eq!( + gitignored_dir_file, None, + "File in the gitignored dir should not have an entry before its dir is toggled" + ); + + toggle_expand_dir(&panel, "project_root/dir_1", cx); + toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " v gitignored_dir <== selected", + " file_a.py", + " file_b.py", + " file_c.py", + " file_1.py", + " file_2.py", + " file_3.py", + " > dir_2", + " .gitignore", + ], + "Should show gitignored dir file list in the project panel" + ); + let gitignored_dir_file = + find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx) + .expect("after gitignored dir got opened, a file entry should be present"); + + toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); + toggle_expand_dir(&panel, "project_root/dir_1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1 <== selected", + " > dir_2", + " .gitignore", + ], + "Should hide all dir contents again and prepare for the auto reveal test" + ); + + for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] { + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some(file_entry))) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1 <== selected", + " > dir_2", + " .gitignore", + ], + "When no auto reveal is enabled, the selected entry should not be revealed in the project panel" + ); + } + + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_panel_settings| { + project_panel_settings.auto_reveal_entries = Some(true) + }); + }) + }); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some(dir_1_file))) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " > gitignored_dir", + " file_1.py <== selected", + " file_2.py", + " file_3.py", + " > dir_2", + " .gitignore", + ], + "When auto reveal is enabled, not ignored dir_1 entry should be revealed" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some(dir_2_file))) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " > gitignored_dir", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py <== selected", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "When auto reveal is enabled, not ignored dir_2 entry should be revealed" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some( + gitignored_dir_file, + ))) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " > gitignored_dir", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py <== selected", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "When auto reveal is enabled, a gitignored selected entry should not be revealed in the project panel" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file)) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " v gitignored_dir", + " file_a.py <== selected", + " file_b.py", + " file_c.py", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "When a gitignored entry is explicitly revealed, it should be shown in the project tree" + ); + } + + #[gpui::test] + async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = Some(Vec::new()); + }); + store.update_user_settings::(cx, |project_panel_settings| { + project_panel_settings.auto_reveal_entries = Some(false) + }); + }) + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/project_root", + json!({ + ".git": {}, + ".gitignore": "**/gitignored_dir", + "dir_1": { + "file_1.py": "# File 1_1 contents", + "file_2.py": "# File 1_2 contents", + "file_3.py": "# File 1_3 contents", + "gitignored_dir": { + "file_a.py": "# File contents", + "file_b.py": "# File contents", + "file_c.py": "# File contents", + }, + }, + "dir_2": { + "file_1.py": "# File 2_1 contents", + "file_2.py": "# File 2_2 contents", + "file_3.py": "# File 2_3 contents", + } + }), + ) + .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, |workspace, cx| ProjectPanel::new(workspace, cx)) + .unwrap(); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1", + " > dir_2", + " .gitignore", + ] + ); + + let dir_1_file = find_project_entry(&panel, "project_root/dir_1/file_1.py", cx) + .expect("dir 1 file is not ignored and should have an entry"); + let dir_2_file = find_project_entry(&panel, "project_root/dir_2/file_1.py", cx) + .expect("dir 2 file is not ignored and should have an entry"); + let gitignored_dir_file = + find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx); + assert_eq!( + gitignored_dir_file, None, + "File in the gitignored dir should not have an entry before its dir is toggled" + ); + + toggle_expand_dir(&panel, "project_root/dir_1", cx); + toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " v gitignored_dir <== selected", + " file_a.py", + " file_b.py", + " file_c.py", + " file_1.py", + " file_2.py", + " file_3.py", + " > dir_2", + " .gitignore", + ], + "Should show gitignored dir file list in the project panel" + ); + let gitignored_dir_file = + find_project_entry(&panel, "project_root/dir_1/gitignored_dir/file_a.py", cx) + .expect("after gitignored dir got opened, a file entry should be present"); + + toggle_expand_dir(&panel, "project_root/dir_1/gitignored_dir", cx); + toggle_expand_dir(&panel, "project_root/dir_1", cx); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1 <== selected", + " > dir_2", + " .gitignore", + ], + "Should hide all dir contents again and prepare for the explicit reveal test" + ); + + for file_entry in [dir_1_file, dir_2_file, gitignored_dir_file] { + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::ActiveEntryChanged(Some(file_entry))) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " > dir_1 <== selected", + " > dir_2", + " .gitignore", + ], + "When no auto reveal is enabled, the selected entry should not be revealed in the project panel" + ); + } + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(dir_1_file)) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " > gitignored_dir", + " file_1.py <== selected", + " file_2.py", + " file_3.py", + " > dir_2", + " .gitignore", + ], + "With no auto reveal, explicit reveal should show the dir_1 entry in the project panel" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(dir_2_file)) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " > gitignored_dir", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py <== selected", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "With no auto reveal, explicit reveal should show the dir_2 entry in the project panel" + ); + + panel.update(cx, |panel, cx| { + panel.project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel(gitignored_dir_file)) + }) + }); + cx.run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v project_root", + " > .git", + " v dir_1", + " v gitignored_dir", + " file_a.py <== selected", + " file_b.py", + " file_c.py", + " file_1.py", + " file_2.py", + " file_3.py", + " v dir_2", + " file_1.py", + " file_2.py", + " file_3.py", + " .gitignore", + ], + "With no auto reveal, explicit reveal should show the gitignored entry in the project panel" + ); + } + fn toggle_expand_dir( panel: &View, path: impl AsRef, @@ -2913,6 +3379,23 @@ mod tests { }); } + fn find_project_entry( + panel: &View, + path: impl AsRef, + cx: &mut VisualTestContext, + ) -> Option { + let path = path.as_ref(); + panel.update(cx, |panel, cx| { + for worktree in panel.project.read(cx).worktrees().collect::>() { + let worktree = worktree.read(cx); + if let Ok(relative_path) = path.strip_prefix(worktree.root_name()) { + return worktree.entry_for_path(relative_path).map(|entry| entry.id); + } + } + panic!("no worktree for path {path:?}"); + }) + } + fn visible_entries_as_strings( panel: &View, range: Range, diff --git a/crates/project_panel2/src/project_panel_settings.rs b/crates/project_panel2/src/project_panel_settings.rs index 5b0e0194a5d5596dadadc450677895d9a91910dd..c1009648a02009d1559295c5e8a029f2e51980e3 100644 --- a/crates/project_panel2/src/project_panel_settings.rs +++ b/crates/project_panel2/src/project_panel_settings.rs @@ -18,6 +18,7 @@ pub struct ProjectPanelSettings { pub folder_icons: bool, pub git_status: bool, pub indent_size: f32, + pub auto_reveal_entries: bool, } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] @@ -28,6 +29,7 @@ pub struct ProjectPanelSettingsContent { pub folder_icons: Option, pub git_status: Option, pub indent_size: Option, + pub auto_reveal_entries: Option, } impl Settings for ProjectPanelSettings { diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index a50a109c83287997d87a71bcb4d396d43adb25aa..c32dca936e47e3e78937118c4b4bf5bc6eabc1ea 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -96,6 +96,12 @@ pub struct CloseAllItems { pub save_intent: Option, } +#[derive(Clone, PartialEq, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RevealInProjectPanel { + pub entry_id: u64, +} + actions!( pane, [ @@ -116,7 +122,15 @@ actions!( ] ); -impl_actions!(pane, [ActivateItem, CloseActiveItem, CloseAllItems]); +impl_actions!( + pane, + [ + ActivateItem, + CloseActiveItem, + CloseAllItems, + RevealInProjectPanel, + ] +); const MAX_NAVIGATION_HISTORY_LEN: usize = 1024; @@ -146,6 +160,13 @@ pub fn init(cx: &mut AppContext) { cx.add_action(|pane: &mut Pane, _: &SplitUp, cx| pane.split(SplitDirection::Up, cx)); cx.add_action(|pane: &mut Pane, _: &SplitRight, cx| pane.split(SplitDirection::Right, cx)); cx.add_action(|pane: &mut Pane, _: &SplitDown, cx| pane.split(SplitDirection::Down, cx)); + cx.add_action(|pane: &mut Pane, action: &RevealInProjectPanel, cx| { + pane.project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel( + ProjectEntryId::from_proto(action.entry_id), + )) + }) + }); } #[derive(Debug)] @@ -1232,80 +1253,87 @@ impl Pane { cx: &mut ViewContext, ) { let active_item_id = self.items[self.active_item_index].id(); + let single_entry_to_resolve = + self.items() + .find(|i| i.id() == target_item_id) + .and_then(|i| { + let item_entries = i.project_entry_ids(cx); + if item_entries.len() == 1 { + Some(item_entries[0]) + } else { + None + } + }); let is_active_item = target_item_id == active_item_id; let target_pane = cx.weak_handle(); // The `CloseInactiveItems` action should really be called "CloseOthers" and the behaviour should be dynamically based on the tab the action is ran on. Currently, this is a weird action because you can run it on a non-active tab and it will close everything by the actual active tab - self.tab_context_menu.update(cx, |menu, cx| { - menu.show( - position, - AnchorCorner::TopLeft, - if is_active_item { - vec![ - ContextMenuItem::action( - "Close Active Item", - CloseActiveItem { save_intent: None }, - ), - ContextMenuItem::action("Close Inactive Items", CloseInactiveItems), - ContextMenuItem::action("Close Clean Items", CloseCleanItems), - ContextMenuItem::action("Close Items To The Left", CloseItemsToTheLeft), - ContextMenuItem::action("Close Items To The Right", CloseItemsToTheRight), - ContextMenuItem::action( - "Close All Items", - CloseAllItems { save_intent: None }, - ), - ] - } else { - // In the case of the user right clicking on a non-active tab, for some item-closing commands, we need to provide the id of the tab, for the others, we can reuse the existing command. - vec![ - ContextMenuItem::handler("Close Inactive Item", { - let pane = target_pane.clone(); - move |cx| { - if let Some(pane) = pane.upgrade(cx) { - pane.update(cx, |pane, cx| { - pane.close_item_by_id( - target_item_id, - SaveIntent::Close, - cx, - ) + let mut menu_items = if is_active_item { + vec![ + ContextMenuItem::action( + "Close Active Item", + CloseActiveItem { save_intent: None }, + ), + ContextMenuItem::action("Close Inactive Items", CloseInactiveItems), + ContextMenuItem::action("Close Clean Items", CloseCleanItems), + ContextMenuItem::action("Close Items To The Left", CloseItemsToTheLeft), + ContextMenuItem::action("Close Items To The Right", CloseItemsToTheRight), + ContextMenuItem::action("Close All Items", CloseAllItems { save_intent: None }), + ] + } else { + // In the case of the user right clicking on a non-active tab, for some item-closing commands, we need to provide the id of the tab, for the others, we can reuse the existing command. + vec![ + ContextMenuItem::handler("Close Inactive Item", { + let pane = target_pane.clone(); + move |cx| { + if let Some(pane) = pane.upgrade(cx) { + pane.update(cx, |pane, cx| { + pane.close_item_by_id(target_item_id, SaveIntent::Close, cx) .detach_and_log_err(cx); - }) - } + }) } - }), - ContextMenuItem::action("Close Inactive Items", CloseInactiveItems), - ContextMenuItem::action("Close Clean Items", CloseCleanItems), - ContextMenuItem::handler("Close Items To The Left", { - let pane = target_pane.clone(); - move |cx| { - if let Some(pane) = pane.upgrade(cx) { - pane.update(cx, |pane, cx| { - pane.close_items_to_the_left_by_id(target_item_id, cx) - .detach_and_log_err(cx); - }) - } + } + }), + ContextMenuItem::action("Close Inactive Items", CloseInactiveItems), + ContextMenuItem::action("Close Clean Items", CloseCleanItems), + ContextMenuItem::handler("Close Items To The Left", { + let pane = target_pane.clone(); + move |cx| { + if let Some(pane) = pane.upgrade(cx) { + pane.update(cx, |pane, cx| { + pane.close_items_to_the_left_by_id(target_item_id, cx) + .detach_and_log_err(cx); + }) } - }), - ContextMenuItem::handler("Close Items To The Right", { - let pane = target_pane.clone(); - move |cx| { - if let Some(pane) = pane.upgrade(cx) { - pane.update(cx, |pane, cx| { - pane.close_items_to_the_right_by_id(target_item_id, cx) - .detach_and_log_err(cx); - }) - } + } + }), + ContextMenuItem::handler("Close Items To The Right", { + let pane = target_pane.clone(); + move |cx| { + if let Some(pane) = pane.upgrade(cx) { + pane.update(cx, |pane, cx| { + pane.close_items_to_the_right_by_id(target_item_id, cx) + .detach_and_log_err(cx); + }) } - }), - ContextMenuItem::action( - "Close All Items", - CloseAllItems { save_intent: None }, - ), - ] - }, - cx, - ); + } + }), + ContextMenuItem::action("Close All Items", CloseAllItems { save_intent: None }), + ] + }; + + if let Some(entry) = single_entry_to_resolve { + menu_items.push(ContextMenuItem::Separator); + menu_items.push(ContextMenuItem::action( + "Reveal In Project Panel", + RevealInProjectPanel { + entry_id: entry.to_proto(), + }, + )); + } + + menu.show(position, AnchorCorner::TopLeft, menu_items, cx); }); } diff --git a/crates/workspace2/src/pane.rs b/crates/workspace2/src/pane.rs index 5c5801960e335e2efe1ff4fa59e8e2dcdd08a496..5801a05d836bd3261d6cdc108bc4ec839647b1f7 100644 --- a/crates/workspace2/src/pane.rs +++ b/crates/workspace2/src/pane.rs @@ -85,7 +85,21 @@ pub struct CloseAllItems { pub save_intent: Option, } -impl_actions!(pane, [CloseAllItems, CloseActiveItem, ActivateItem]); +#[derive(Clone, PartialEq, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RevealInProjectPanel { + pub entry_id: u64, +} + +impl_actions!( + pane, + [ + CloseAllItems, + CloseActiveItem, + ActivateItem, + RevealInProjectPanel + ] +); actions!( pane, @@ -1499,9 +1513,19 @@ impl Pane { ) .child(label); - right_click_menu(ix).trigger(tab).menu(|cx| { - ContextMenu::build(cx, |menu, cx| { - menu.action("Close", CloseActiveItem { save_intent: None }.boxed_clone()) + let single_entry_to_resolve = { + let item_entries = self.items[ix].project_entry_ids(cx); + if item_entries.len() == 1 { + Some(item_entries[0]) + } else { + None + } + }; + + right_click_menu(ix).trigger(tab).menu(move |cx| { + ContextMenu::build(cx, |menu, _| { + let menu = menu + .action("Close", CloseActiveItem { save_intent: None }.boxed_clone()) .action("Close Others", CloseInactiveItems.boxed_clone()) .separator() .action("Close Left", CloseItemsToTheLeft.boxed_clone()) @@ -1511,7 +1535,19 @@ impl Pane { .action( "Close All", CloseAllItems { save_intent: None }.boxed_clone(), + ); + + if let Some(entry) = single_entry_to_resolve { + menu.separator().action( + "Reveal In Project Panel", + RevealInProjectPanel { + entry_id: entry.to_proto(), + } + .boxed_clone(), ) + } else { + menu + } }) }) } @@ -2135,6 +2171,15 @@ impl Render for Pane { .map(|task| task.detach_and_log_err(cx)); }), ) + .on_action( + cx.listener(|pane: &mut Self, action: &RevealInProjectPanel, cx| { + pane.project.update(cx, |_, cx| { + cx.emit(project::Event::RevealInProjectPanel( + ProjectEntryId::from_proto(action.entry_id), + )) + }) + }), + ) .child(self.render_tab_bar(cx)) .child(self.toolbar.clone()) .child(if let Some(item) = self.active_item() { diff --git a/crates/zed2/src/zed2.rs b/crates/zed2/src/zed2.rs index 7c8dd0c45e9b55dd9fe383598f4f433c15f1cb36..958ea4cf04b3a3887dbcf9eaf22d1b1015160113 100644 --- a/crates/zed2/src/zed2.rs +++ b/crates/zed2/src/zed2.rs @@ -130,7 +130,6 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { let vim_mode_indicator = cx.build_view(|cx| vim::ModeIndicator::new(cx)); let feedback_button = cx .build_view(|_| feedback::deploy_feedback_button::DeployFeedbackButton::new(workspace)); - // let cursor_position = cx.add_view(|_| editor::items::CursorPosition::new()); let cursor_position = cx.build_view(|_| editor::items::CursorPosition::new()); workspace.status_bar().update(cx, |status_bar, cx| { status_bar.add_left_item(diagnostic_summary, cx);