git_ui: Add View File action to Git Panel (#59383)

# 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

<details>
  <summary>Click to view showcase</summary>

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



</details>

---

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 <chris@christopherbiscardi.com>
This commit is contained in:
Ahmed AmmarandChristopher Biscardi authored and GitHub committed 2026-06-16 03:57:51 +00:00
1 parent cec847afc9
commit 23042e9caa
3 files changed
+284 -1

No files matched your search

+2
View File
@@ -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.
+280 -1
View File
@@ -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<Self>) {
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<usize> {
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<GitPanel>, 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<Workspace>,
expected_path: &Path,
cx: &mut VisualTestContext,
) {
workspace.update_in(cx, |workspace, _window, cx| {
let editor = workspace
.item_of_type::<editor::Editor>(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<Project>,
Entity<Workspace>,
Entity<GitPanel>,
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::<Vec<_>>(),
);
}
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(
+2
View File
@@ -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.
<!-- Add media -->
## Committing