From 72a9dcd916499142388a388fc14942bc80df1bd5 Mon Sep 17 00:00:00 2001 From: Peter Schilling Date: Thu, 16 Apr 2026 09:29:58 -0700 Subject: [PATCH] Add 'git: view commit' command palette action (#39009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adds a 'git: view commit' – accepting a ref (e.g. HEAD, an sha, etc) to more easily navigate to the git commit view. Screenshot 2025-09-26 at 21 43
09@2x if a bad ref is entered, the user is shown a generic error Screenshot 2025-09-27 at 21 04
52@2x happy to adjust any of that. also worth noting is the `git: branch` command UI is a bit nicer, can e.g. show you some metadata on the commit before you select it, so happy to take it further in that direction if desired, but thought i'd keep it simple to start. Release Notes: - Added view commit command palette action --------- Co-authored-by: Cole Miller Co-authored-by: Christopher Biscardi Co-authored-by: Marshall Bowers --- crates/git/src/git.rs | 1 + crates/git_ui/src/git_ui.rs | 345 +++++++++++++++++++++++++++++- crates/workspace/src/workspace.rs | 6 +- 3 files changed, 342 insertions(+), 10 deletions(-) diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index 766378bf2e514d8a50348b608d52e9e764072f21..d965605fe08da801967d7594c69fe94b1020c570 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -102,6 +102,7 @@ actions!( OpenModifiedFiles, /// Clones a repository. Clone, + ViewCommit, /// Adds a file to .gitignore. AddToGitignore, ] diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 1e7391178d2473a173a1503b4f2c724191c06a60..350999164dca6cfd157231a2997821d3f616e1ba 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -4,28 +4,31 @@ use editor::{Editor, actions::DiffClipboardWithSelectionData}; use project::ProjectPath; use ui::{ - Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render, Styled, - StyledExt, div, h_flex, rems, v_flex, + Color, Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render, + Styled, StyledExt, div, h_flex, rems, v_flex, }; +use workspace::{Toast, notifications::NotificationId}; mod blame_ui; pub mod clone; use git::{ - repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus}, + repository::{Branch, CommitDetails, Upstream, UpstreamTracking, UpstreamTrackingStatus}, status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode}, }; use gpui::{ - App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, SharedString, Window, + App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, SharedString, + Subscription, Task, Window, }; use menu::{Cancel, Confirm}; use project::git_store::Repository; use project_diff::ProjectDiff; +use time::OffsetDateTime; use ui::prelude::*; use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr}; use zed_actions; -use crate::{git_panel::GitPanel, text_diff_view::TextDiffView}; +use crate::{commit_view::CommitView, git_panel::GitPanel, text_diff_view::TextDiffView}; mod askpass_modal; pub mod branch_picker; @@ -207,6 +210,7 @@ pub fn init(cx: &mut App) { workspace.register_action(|workspace, _: &git::RenameBranch, window, cx| { rename_current_branch(workspace, window, cx); }); + workspace.register_action(show_ref_picker); workspace.register_action( |workspace, action: &DiffClipboardWithSelectionData, window, cx| { if let Some(task) = TextDiffView::open(action, workspace, window, cx) { @@ -401,6 +405,235 @@ fn rename_current_branch( }); } +struct RefPickerModal { + editor: Entity, + repo: Entity, + workspace: Entity, + commit_details: Option, + lookup_task: Option>, + _editor_subscription: Subscription, +} + +impl RefPickerModal { + fn new( + repo: Entity, + workspace: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + editor.set_placeholder_text("Enter git ref...", window, cx); + editor + }); + + let _editor_subscription = cx.subscribe_in( + &editor, + window, + |this, _editor, event: &editor::EditorEvent, window, cx| { + if let editor::EditorEvent::BufferEdited = event { + this.lookup_commit_details(window, cx); + } + }, + ); + + Self { + editor, + repo, + workspace, + commit_details: None, + lookup_task: None, + _editor_subscription, + } + } + + fn lookup_commit_details(&mut self, window: &mut Window, cx: &mut Context) { + let git_ref = self.editor.read(cx).text(cx); + let git_ref = git_ref.trim().to_string(); + + if git_ref.is_empty() { + self.commit_details = None; + cx.notify(); + return; + } + + let repo = self.repo.clone(); + self.lookup_task = Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(300)) + .await; + + let show_result = repo + .update(cx, |repo, _| repo.show(git_ref.clone())) + .await + .ok(); + + if let Some(show_future) = show_result { + if let Ok(details) = show_future { + this.update(cx, |this, cx| { + this.commit_details = Some(details); + cx.notify(); + }) + .ok(); + } else { + this.update(cx, |this, cx| { + this.commit_details = None; + cx.notify(); + }) + .ok(); + } + } + })); + } + + fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } + + fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context) { + let git_ref = self.editor.read(cx).text(cx); + let git_ref = git_ref.trim(); + + if git_ref.is_empty() { + cx.emit(DismissEvent); + return; + } + + let git_ref_string = git_ref.to_string(); + + let repo = self.repo.clone(); + let workspace = self.workspace.clone(); + + window + .spawn(cx, async move |cx| -> anyhow::Result<()> { + let show_future = repo.update(cx, |repo, _| repo.show(git_ref_string.clone())); + let show_result = show_future.await; + + match show_result { + Ok(Ok(details)) => { + workspace.update_in(cx, |workspace, window, cx| { + CommitView::open( + details.sha.to_string(), + repo.downgrade(), + workspace.weak_handle(), + None, + None, + window, + cx, + ); + })?; + } + Ok(Err(_)) | Err(_) => { + workspace.update(cx, |workspace, cx| { + let error = anyhow::anyhow!("View commit failed"); + Self::show_git_error_toast(&git_ref_string, error, workspace, cx); + }); + } + } + + Ok(()) + }) + .detach(); + cx.emit(DismissEvent); + } + + fn show_git_error_toast( + _git_ref: &str, + error: anyhow::Error, + workspace: &mut Workspace, + cx: &mut Context, + ) { + let toast = Toast::new(NotificationId::unique::<()>(), error.to_string()); + workspace.show_toast(toast, cx); + } +} + +impl EventEmitter for RefPickerModal {} +impl ModalView for RefPickerModal {} +impl Focusable for RefPickerModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.editor.focus_handle(cx) + } +} + +impl Render for RefPickerModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let has_commit_details = self.commit_details.is_some(); + let commit_preview = self.commit_details.as_ref().map(|details| { + let commit_time = OffsetDateTime::from_unix_timestamp(details.commit_timestamp) + .unwrap_or_else(|_| OffsetDateTime::now_utc()); + let local_offset = + time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let formatted_time = time_format::format_localized_timestamp( + commit_time, + OffsetDateTime::now_utc(), + local_offset, + time_format::TimestampFormat::Relative, + ); + + let subject = details.message.lines().next().unwrap_or("").to_string(); + let author_and_subject = format!("{} • {}", details.author_name, subject); + + h_flex() + .w_full() + .gap_6() + .justify_between() + .overflow_x_hidden() + .child( + div().max_w_96().child( + Label::new(author_and_subject) + .size(LabelSize::Small) + .truncate() + .color(Color::Muted), + ), + ) + .child( + Label::new(formatted_time) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }); + + v_flex() + .key_context("RefPickerModal") + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .elevation_2(cx) + .w(rems(34.)) + .child( + h_flex() + .px_3() + .pt_2() + .pb_1() + .w_full() + .gap_1p5() + .child(Icon::new(IconName::Hash).size(IconSize::XSmall)) + .child(Headline::new("View Commit").size(HeadlineSize::XSmall)), + ) + .child(div().px_3().w_full().child(self.editor.clone())) + .when_some(commit_preview, |el, preview| { + el.child(div().px_3().pb_3().w_full().child(preview)) + }) + .when(!has_commit_details, |el| el.child(div().pb_3())) + } +} + +fn show_ref_picker( + workspace: &mut Workspace, + _: &git::ViewCommit, + window: &mut Window, + cx: &mut Context, +) { + let Some(repo) = workspace.project().read(cx).active_repository(cx) else { + return; + }; + + let workspace_entity = cx.entity(); + workspace.toggle_modal(window, cx, |window, cx| { + RefPickerModal::new(repo, workspace_entity, window, cx) + }); +} + fn render_remote_button( id: impl Into, branch: &Branch, @@ -869,3 +1102,105 @@ impl Render for GitCloneModal { impl EventEmitter for GitCloneModal {} impl ModalView for GitCloneModal {} + +#[cfg(test)] +mod view_commit_tests { + use super::*; + use gpui::{TestAppContext, VisualTestContext, WindowHandle}; + use language::language_settings::AllLanguageSettings; + use project::project_settings::ProjectSettings; + use project::{FakeFs, Project, WorktreeSettings}; + use serde_json::json; + use settings::{Settings as _, SettingsStore}; + use std::path::Path; + use std::sync::Arc; + use theme::LoadThemes; + use util::path; + use workspace::WorkspaceSettings; + + fn init_test(cx: &mut TestAppContext) { + zlog::init_test(); + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme_settings::init(LoadThemes::JustBase, cx); + AllLanguageSettings::register(cx); + editor::init(cx); + ProjectSettings::register(cx); + WorktreeSettings::register(cx); + WorkspaceSettings::register(cx); + }); + } + + async fn setup_git_repo(cx: &mut TestAppContext) -> Arc { + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/root", + json!({ + "project": { + ".git": {}, + "src": { + "main.rs": "fn main() {}" + } + } + }), + ) + .await; + fs + } + + async fn create_test_workspace( + fs: Arc, + cx: &mut TestAppContext, + ) -> (Entity, WindowHandle) { + let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await; + let workspace = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + cx.read(|cx| { + project + .read(cx) + .worktrees(cx) + .next() + .unwrap() + .read(cx) + .as_local() + .unwrap() + .scan_complete() + }) + .await; + (project, workspace) + } + + #[gpui::test] + async fn test_show_ref_picker_with_repository(cx: &mut TestAppContext) { + init_test(cx); + let fs = setup_git_repo(cx).await; + + fs.set_status_for_repo( + Path::new("/root/project/.git"), + &[("src/main.rs", git::status::StatusCode::Modified.worktree())], + ); + + let (_project, workspace) = create_test_workspace(fs, cx).await; + let cx = &mut VisualTestContext::from_window(*workspace, cx); + + let initial_modal_state = workspace + .read_with(cx, |workspace, cx| { + workspace.active_modal::(cx).is_some() + }) + .unwrap_or(false); + + let _ = workspace.update(cx, |workspace, window, cx| { + show_ref_picker(workspace, &git::ViewCommit, window, cx); + }); + + let final_modal_state = workspace + .read_with(cx, |workspace, cx| { + workspace.active_modal::(cx).is_some() + }) + .unwrap_or(false); + + assert!(!initial_modal_state); + assert!(final_modal_state); + } +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index e0a497fbe4ee38552a505376a15a6b5db57ee599..76736ba471a77bd99cd50f913ae228fe79a899e8 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -7317,11 +7317,7 @@ impl Workspace { } #[cfg(any(test, feature = "test-support"))] - pub(crate) fn test_new( - project: Entity, - window: &mut Window, - cx: &mut Context, - ) -> Self { + pub fn test_new(project: Entity, window: &mut Window, cx: &mut Context) -> Self { use node_runtime::NodeRuntime; use session::Session;