From d9b0828beb4df1c950b09e73a43899eb89f22345 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 12 Dec 2023 13:50:23 +0200 Subject: [PATCH] Port to gpui2 --- crates/project2/src/project2.rs | 1 + crates/project_panel2/src/project_panel.rs | 495 +++++++++++++++++- .../src/project_panel_settings.rs | 2 + crates/workspace2/src/pane.rs | 55 +- 4 files changed, 542 insertions(+), 11 deletions(-) diff --git a/crates/project2/src/project2.rs b/crates/project2/src/project2.rs index fe3498b930..773b68c9e3 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_panel2/src/project_panel.rs b/crates/project_panel2/src/project_panel.rs index b8961810e7..adcd21cac6 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 5b0e0194a5..c1009648a0 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/workspace2/src/pane.rs b/crates/workspace2/src/pane.rs index e559ecb8f5..5801a05d83 100644 --- a/crates/workspace2/src/pane.rs +++ b/crates/workspace2/src/pane.rs @@ -1,4 +1,4 @@ -se crate::{ +use crate::{ item::{ClosePosition, Item, ItemHandle, ItemSettings, WeakItemHandle}, toolbar::Toolbar, workspace_settings::{AutosaveSetting, WorkspaceSettings}, @@ -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() {