crates/git/src/git.rs 🔗
@@ -102,6 +102,7 @@ actions!(
OpenModifiedFiles,
/// Clones a repository.
Clone,
+ ViewCommit,
/// Adds a file to .gitignore.
AddToGitignore,
]
Peter Schilling , Cole Miller , Christopher Biscardi , and Marshall Bowers created
adds a 'git: view commit' – accepting a ref (e.g. HEAD, an sha, etc) to
more easily navigate to the git commit view.
<img width="3024" height="1888" alt="Screenshot 2025-09-26 at 21 43
09@2x"
src="https://github.com/user-attachments/assets/c001baec-66c2-46e5-b4a7-f691631f4166"
/>
if a bad ref is entered, the user is shown a generic error
<img width="2734" height="1442" alt="Screenshot 2025-09-27 at 21 04
52@2x"
src="https://github.com/user-attachments/assets/abdbd92d-ef0b-4de9-afb9-e9e52607dfdd"
/>
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 <cole@zed.dev>
Co-authored-by: Christopher Biscardi <chris@christopherbiscardi.com>
Co-authored-by: Marshall Bowers <git@maxdeviant.com>
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(-)
@@ -102,6 +102,7 @@ actions!(
OpenModifiedFiles,
/// Clones a repository.
Clone,
+ ViewCommit,
/// Adds a file to .gitignore.
AddToGitignore,
]
@@ -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<Editor>,
+ repo: Entity<Repository>,
+ workspace: Entity<Workspace>,
+ commit_details: Option<CommitDetails>,
+ lookup_task: Option<Task<()>>,
+ _editor_subscription: Subscription,
+}
+
+impl RefPickerModal {
+ fn new(
+ repo: Entity<Repository>,
+ workspace: Entity<Workspace>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> 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<Self>) {
+ 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<Self>) {
+ cx.emit(DismissEvent);
+ }
+
+ fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
+ 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<Workspace>,
+ ) {
+ let toast = Toast::new(NotificationId::unique::<()>(), error.to_string());
+ workspace.show_toast(toast, cx);
+ }
+}
+
+impl EventEmitter<DismissEvent> 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<Self>) -> 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<Workspace>,
+) {
+ 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<SharedString>,
branch: &Branch,
@@ -869,3 +1102,105 @@ impl Render for GitCloneModal {
impl EventEmitter<DismissEvent> 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<FakeFs> {
+ 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<FakeFs>,
+ cx: &mut TestAppContext,
+ ) -> (Entity<Project>, WindowHandle<Workspace>) {
+ 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::<RefPickerModal>(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::<RefPickerModal>(cx).is_some()
+ })
+ .unwrap_or(false);
+
+ assert!(!initial_modal_state);
+ assert!(final_modal_state);
+ }
+}
@@ -7317,11 +7317,7 @@ impl Workspace {
}
#[cfg(any(test, feature = "test-support"))]
- pub(crate) fn test_new(
- project: Entity<Project>,
- window: &mut Window,
- cx: &mut Context<Self>,
- ) -> Self {
+ pub fn test_new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
use node_runtime::NodeRuntime;
use session::Session;