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;