diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index c61f819ea2..980960f6e8 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -48,6 +48,8 @@ actions!( Blame, /// Shows the git history for the selected file, folder, or project. FileHistory, + /// Opens the selected file in the editor without a diff view. + ViewFile, /// Stages the current file. StageFile, /// Unstages the current file. diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index a8b052a593..f20a0a3813 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -33,7 +33,7 @@ use git::{Amend, Commit, Signoff, ToggleStaged, repository::RepoPath, status::Fi use git::{ ExpandCommitEditor, GitHostingProviderRegistry, GitRemote, RestoreTrackedFiles, StageAll, StashAll, StashApply, StashPop, ToggleFillCommitEditor, TrashUntrackedFiles, UnstageAll, - parse_git_remote_url, + ViewFile, parse_git_remote_url, }; use gpui::{ AbsoluteLength, Action, Anchor, AsyncApp, AsyncWindowContext, Bounds, ClickEvent, DismissEvent, @@ -1392,6 +1392,27 @@ impl GitPanel { }); } + fn view_file(&mut self, _: &ViewFile, window: &mut Window, cx: &mut Context) { + maybe!({ + let entry = self.entries.get(self.selected_entry?)?.status_entry()?; + let project_path = self + .active_repository + .as_ref()? + .read(cx) + .repo_path_to_project_path(&entry.repo_path, cx)?; + + self.workspace + .update(cx, |workspace, cx| { + workspace + .open_path_preview(project_path, None, false, false, true, window, cx) + .detach_and_log_err(cx); + }) + .ok()?; + + Some(()) + }); + } + fn revert_selected( &mut self, action: &git::RestoreFile, @@ -6017,6 +6038,7 @@ impl GitPanel { .separator() .action("Open Diff", menu::Confirm.boxed_clone()) .action("Open Diff (File)", menu::SecondaryConfirm.boxed_clone()) + .action("View File", ViewFile.boxed_clone()) .when(!is_created, |context_menu| { context_menu .separator() @@ -6752,6 +6774,7 @@ impl Render for GitPanel { .on_action(cx.listener(Self::close_panel)) .on_action(cx.listener(Self::open_diff)) .on_action(cx.listener(Self::open_solo_diff)) + .on_action(cx.listener(Self::view_file)) .on_action(cx.listener(Self::focus_changes_list)) .on_action(cx.listener(Self::focus_editor)) .on_action(cx.listener(Self::expand_commit_editor)) @@ -7551,6 +7574,262 @@ mod tests { }); } + fn entry_index_for_repo_path(panel: &GitPanel, repo_path: &RepoPath) -> Option { + panel.entries.iter().position(|entry| { + entry + .status_entry() + .is_some_and(|entry| &entry.repo_path == repo_path) + }) + } + + async fn await_git_panel_entries(panel: &Entity, cx: &mut VisualTestContext) { + let handle = cx.update_window_entity(panel, |panel, _, _| { + std::mem::replace(&mut panel.update_visible_entries_task, Task::ready(())) + }); + cx.executor().advance_clock(2 * UPDATE_DEBOUNCE); + handle.await; + } + + fn assert_editor_opened_with_path( + workspace: &Entity, + expected_path: &Path, + cx: &mut VisualTestContext, + ) { + workspace.update_in(cx, |workspace, _window, cx| { + let editor = workspace + .item_of_type::(cx) + .expect("Editor should exist after View File"); + let file_path = editor + .read(cx) + .active_buffer(cx) + .expect("Buffer should have an active buffer") + .read(cx) + .file() + .cloned() + .expect("Buffer should have a file"); + assert_eq!(file_path.path().as_ref().as_std_path(), expected_path); + }); + } + + async fn setup_git_panel_with_changes( + cx: &mut TestAppContext, + tree: serde_json::Value, + status_entries: &[(&str, git::status::StatusCode)], + ) -> ( + Entity, + Entity, + Entity, + VisualTestContext, + ) { + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/project"), tree).await; + + if !status_entries.is_empty() { + fs.set_status_for_repo( + path!("/project/.git").as_ref(), + &status_entries + .iter() + .map(|(path, status)| (*path, status.worktree())) + .collect::>(), + ); + } + + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.executor().run_until_parked(); + + let panel = workspace.update_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + (project, workspace, panel, cx) + } + + #[gpui::test] + async fn test_view_file_tracked(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "tracked": "tracked\n", + }), + ) + .await; + + fs.set_head_and_index_for_repo( + path!("/project/.git").as_ref(), + &[("tracked", "old tracked\n".into())], + ); + + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + let panel = workspace.update_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + let entry_index = panel + .read_with(&cx, |panel, _| { + entry_index_for_repo_path(panel, &repo_path("tracked")) + }) + .expect("tracked file should exist in the changes list"); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.selected_entry = Some(entry_index); + panel.view_file(&ViewFile, window, cx); + }); + cx.run_until_parked(); + + assert_editor_opened_with_path(&workspace, Path::new("tracked"), &mut cx); + } + + #[gpui::test] + async fn test_view_file_untracked(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "tracked": "tracked\n", + "untracked": "\n", + }), + ) + .await; + + fs.set_head_and_index_for_repo( + path!("/project/.git").as_ref(), + &[("tracked", "old tracked\n".into())], + ); + + let project = Project::test(fs, [Path::new(path!("/project"))], cx).await; + let window_handle = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window_handle + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let mut cx = VisualTestContext::from_window(window_handle.into(), cx); + + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().sort_by_path = Some(true); + }) + }); + }); + + let panel = workspace.update_in(&mut cx, GitPanel::new); + await_git_panel_entries(&panel, &mut cx).await; + + let entry_index = panel + .read_with(&cx, |panel, _| { + entry_index_for_repo_path(panel, &repo_path("untracked")) + }) + .expect("untracked file should exist in the changes list"); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.selected_entry = Some(entry_index); + panel.view_file(&ViewFile, window, cx); + }); + cx.run_until_parked(); + + assert_editor_opened_with_path(&workspace, Path::new("untracked"), &mut cx); + } + + #[gpui::test] + async fn test_view_file_tree_view(cx: &mut TestAppContext) { + init_test(cx); + + let (_project, workspace, panel, mut cx) = setup_git_panel_with_changes( + cx, + json!({ + ".git": {}, + "src": { + "a": { + "foo.rs": "fn foo() {}", + }, + }, + }), + &[("src/a/foo.rs", StatusCode::Modified)], + ) + .await; + + cx.update(|_window, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.git_panel.get_or_insert_default().tree_view = Some(true); + }) + }); + }); + await_git_panel_entries(&panel, &mut cx).await; + + let entry_index = panel + .read_with(&cx, |panel, _| { + entry_index_for_repo_path(panel, &repo_path("src/a/foo.rs")) + }) + .expect("foo.rs should exist in the tree view changes list"); + + panel.update_in(&mut cx, |panel, window, cx| { + panel.selected_entry = Some(entry_index); + panel.view_file(&ViewFile, window, cx); + }); + cx.run_until_parked(); + + assert_editor_opened_with_path(&workspace, Path::new("src/a/foo.rs"), &mut cx); + } + #[test] fn test_format_git_error_toast_message_prefers_raw_rpc_message() { let rpc_error = RpcError::from_proto( diff --git a/docs/src/git.md b/docs/src/git.md index 39bfb42cb8..68514d4409 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -142,6 +142,8 @@ From the panel, you can simply type a commit message and hit the commit button, Entries can be staged using each individual entry's checkbox. All changes can be staged using the button at the top of the panel, or {#action git::StageAll}. +To open a changed file in the editor without a diff view, right-click on the file in the Git Panel and select **View File**. Use **Open Diff** ({#kb menu::Confirm}) or **Open Diff (File)** to review changes in a diff view instead. + ## Committing