Detailed changes
@@ -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.
@@ -1189,7 +1189,7 @@ impl CursorPosition {
impl Render for CursorPosition {
type Element = Div;
- fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
+ fn render(&mut self, _: &mut ViewContext<Self>) -> Self::Element {
div().when_some(self.position, |el, position| {
let mut text = format!(
"{}{FILE_ROW_COLUMN_DELIMITER}{}",
@@ -301,6 +301,7 @@ pub enum Event {
CollaboratorJoined(proto::PeerId),
CollaboratorLeft(proto::PeerId),
RefreshInlayHints,
+ RevealInProjectPanel(ProjectEntryId),
}
pub enum LanguageServerState {
@@ -299,6 +299,7 @@ pub enum Event {
CollaboratorJoined(proto::PeerId),
CollaboratorLeft(proto::PeerId),
RefreshInlayHints,
+ RevealInProjectPanel(ProjectEntryId),
}
pub enum LanguageServerState {
@@ -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::<ProjectPanelSettings>(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<Project>,
+ 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::<SettingsStore, _, _>(|store, cx| {
+ store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
+ project_settings.file_scan_exclusions = Some(Vec::new());
+ });
+ store.update_user_settings::<ProjectPanelSettings>(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::<SettingsStore, _, _>(|store, cx| {
+ store.update_user_settings::<ProjectPanelSettings>(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::<SettingsStore, _, _>(|store, cx| {
+ store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
+ project_settings.file_scan_exclusions = Some(Vec::new());
+ });
+ store.update_user_settings::<ProjectPanelSettings>(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<ProjectPanel>,
path: impl AsRef<Path>,
@@ -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<ProjectPanel>,
+ path: impl AsRef<Path>,
+ cx: &mut TestAppContext,
+ ) -> Option<ProjectEntryId> {
+ let path = path.as_ref();
+ panel.update(cx, |panel, cx| {
+ for worktree in panel.project.read(cx).worktrees(cx).collect::<Vec<_>>() {
+ 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<ProjectPanel>,
range: Range<usize>,
@@ -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<bool>,
pub git_status: Option<bool>,
pub indent_size: Option<f32>,
+ pub auto_reveal_entries: Option<bool>,
}
impl Setting for ProjectPanelSettings {
@@ -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<Project>,
+ 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::<SettingsStore, _>(|store, cx| {
+ store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
+ project_settings.file_scan_exclusions = Some(Vec::new());
+ });
+ store.update_user_settings::<ProjectPanelSettings>(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::<SettingsStore, _>(|store, cx| {
+ store.update_user_settings::<ProjectPanelSettings>(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::<SettingsStore, _>(|store, cx| {
+ store.update_user_settings::<ProjectSettings>(cx, |project_settings| {
+ project_settings.file_scan_exclusions = Some(Vec::new());
+ });
+ store.update_user_settings::<ProjectPanelSettings>(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<ProjectPanel>,
path: impl AsRef<Path>,
@@ -2913,6 +3379,23 @@ mod tests {
});
}
+ fn find_project_entry(
+ panel: &View<ProjectPanel>,
+ path: impl AsRef<Path>,
+ cx: &mut VisualTestContext,
+ ) -> Option<ProjectEntryId> {
+ let path = path.as_ref();
+ panel.update(cx, |panel, cx| {
+ for worktree in panel.project.read(cx).worktrees().collect::<Vec<_>>() {
+ 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<ProjectPanel>,
range: Range<usize>,
@@ -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<bool>,
pub git_status: Option<bool>,
pub indent_size: Option<f32>,
+ pub auto_reveal_entries: Option<bool>,
}
impl Settings for ProjectPanelSettings {
@@ -96,6 +96,12 @@ pub struct CloseAllItems {
pub save_intent: Option<SaveIntent>,
}
+#[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<Self>,
) {
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);
});
}
@@ -85,7 +85,21 @@ pub struct CloseAllItems {
pub save_intent: Option<SaveIntent>,
}
-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() {
@@ -130,7 +130,6 @@ pub fn initialize_workspace(app_state: Arc<AppState>, 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);