From 23042e9caaa17df5d071dd187bc448bf1c46e7dc Mon Sep 17 00:00:00 2001 From: Ahmed Ammar Date: Tue, 16 Jun 2026 05:57:51 +0200 Subject: [PATCH] git_ui: Add View File action to Git Panel (#59383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Objective When reviewing changes in the Git Panel, the only ways to open a file were through a diff view ("Open Diff" or "Open Diff (File)"). There was no way to open the file directly in the editor to inspect or edit its current contents without entering a diff. This PR adds a **View File** action to the Git Panel changes list context menu. ## Solution - Add a `git::ViewFile` action in `crates/git/src/git.rs`, alongside other per-file git actions like `FileHistory`. - Wire it into the Git Panel file context menu, between the diff actions and "View File History". - Implement `GitPanel::view_file` to resolve the selected entry's repo path to a project path and open it via `workspace.open_path_preview`, matching the pattern used in `commit_view::open_file_at_head`. - Document the new action in `docs/src/git.md`. ### Related issues - https://github.com/zed-industries/zed/issues/58250 ## Testing - Added three GPUI tests in `git_panel.rs`: - `test_view_file_tracked` — modified tracked file - `test_view_file_untracked` — untracked file - `test_view_file_tree_view` — nested file in tree view - Ran `cargo test -p git_ui test_view_file` — all three pass. - Ran `./script/clippy -p git_ui` — clean. **Manual testing for reviewers:** 1. Open a project with changed files and open the Git Panel. 2. Right-click a changed file and select **View File**. 3. Confirm the file opens in the editor (not a diff view). 4. Repeat for an untracked file and with tree view enabled. 5. Confirm **Open Diff** and **Open Diff (File)** still work. Tested on macOS only. Linux and Windows should behave the same since this is a context menu action with no platform-specific code, but I haven't verified on those platforms. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase
Click to view showcase Video recoding of the Git Panel context menu showing the new **View File** item: https://github.com/user-attachments/assets/c878fd4d-9acb-4c05-bd91-b452da0a6b90
--- Release Notes: - Added "View File" to the Git Panel context menu to open a changed file in the editor without a diff view. --------- Co-authored-by: Christopher Biscardi --- crates/git/src/git.rs | 2 + crates/git_ui/src/git_panel.rs | 281 ++++++++++++++++++++++++++++++++- docs/src/git.md | 2 + 3 files changed, 284 insertions(+), 1 deletion(-) 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