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:
1 parent
cec847afc9
commit
23042e9caa
3 files changed
+284
-1
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user