use crate::commit_tooltip::CommitAvatar; use crate::git_status_icon; use git::status::FileStatus; use git::{ BuildCommitPermalinkParams, GitHostingProviderRegistry, GitRemote, ParsedGitRemote, parse_git_remote_url, repository::RepoPath, }; use gpui::{ AnyElement, App, ClickEvent, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, Window, px, }; use project::git_store::Repository; use std::sync::Arc; use time::{OffsetDateTime, UtcOffset}; use ui::{ ButtonLike, ButtonStyle, CopyButton, Icon, IconButton, IconName, IconSize, Label, LabelSize, Tooltip, prelude::*, }; pub struct CommitDetailsSidebarData { pub sha: SharedString, pub author_name: SharedString, pub author_email: SharedString, pub commit_timestamp: i64, pub subject: SharedString, pub body: SharedString, pub ref_names: Vec, pub accent_color: Hsla, } impl CommitDetailsSidebarData { pub fn new( sha: SharedString, author_name: SharedString, author_email: SharedString, commit_timestamp: i64, subject: SharedString, body: SharedString, ) -> Self { Self { sha, author_name, author_email, commit_timestamp, subject, body, ref_names: Vec::new(), accent_color: gpui::hsla(0.0, 0.0, 0.5, 1.0), } } pub fn with_ref_names(mut self, ref_names: Vec) -> Self { self.ref_names = ref_names; self } pub fn with_accent_color(mut self, accent_color: Hsla) -> Self { self.accent_color = accent_color; self } } pub struct CommitDetailsSidebar { data: CommitDetailsSidebarData, remote: Option, changed_files: Vec<(RepoPath, FileStatus)>, on_close: Option>, on_file_click: Option>, } impl CommitDetailsSidebar { pub fn new(data: CommitDetailsSidebarData) -> Self { Self { data, remote: None, changed_files: Vec::new(), on_close: None, on_file_click: None, } } pub fn remote(mut self, remote: Option) -> Self { self.remote = remote; self } pub fn changed_files(mut self, files: Vec<(RepoPath, FileStatus)>) -> Self { self.changed_files = files; self } pub fn on_close(mut self, callback: impl Fn(&mut Window, &mut App) + 'static) -> Self { self.on_close = Some(Box::new(callback)); self } pub fn on_file_click( mut self, callback: impl Fn(&RepoPath, &ClickEvent, &mut Window, &mut App) + 'static, ) -> Self { self.on_file_click = Some(Arc::new(callback)); self } pub fn render(self, window: &mut Window, cx: &mut App) -> AnyElement { let full_sha = self.data.sha.clone(); let date_string = OffsetDateTime::from_unix_timestamp(self.data.commit_timestamp) .ok() .map(|datetime| { let local_offset = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC); let local_datetime = datetime.to_offset(local_offset); let format = time::format_description::parse("[month repr:short] [day], [year]").ok(); format .and_then(|f| local_datetime.format(&f).ok()) .unwrap_or_default() }) .unwrap_or_default(); let author_email_for_avatar = if self.data.author_email.is_empty() { None } else { Some(self.data.author_email.clone()) }; let avatar = CommitAvatar::new(&full_sha, author_email_for_avatar, self.remote.as_ref()); let avatar_element = v_flex() .w(px(32.)) .h(px(32.)) .border_1() .border_color(cx.theme().colors().border) .rounded_full() .justify_center() .items_center() .child( avatar .avatar(window, cx) .map(|a| a.size(px(32.)).into_any_element()) .unwrap_or_else(|| { Icon::new(IconName::Person) .color(Color::Muted) .size(IconSize::XLarge) .into_any_element() }), ); let changed_files_count = self.changed_files.len(); let author_name = self.data.author_name.clone(); let author_email = self.data.author_email.clone(); let subject = self.data.subject.clone(); let body = self.data.body.clone(); let ref_names = self.data.ref_names.clone(); let accent_color = self.data.accent_color; let remote = self.remote.clone(); let on_close = self.on_close; let on_file_click = self.on_file_click; let changed_files = self.changed_files; v_flex() .w(px(300.)) .h_full() .border_l_1() .border_color(cx.theme().colors().border) .bg(cx.theme().colors().surface_background) // Commit Details .child( div() .id("commit-details") .w_full() .child( v_flex() .p_3() .gap_3() .child( h_flex() .w_full() .px_1() .items_start() .justify_between() .child(avatar_element) .when_some(on_close, |this, on_close| { this.child( IconButton::new("close-detail", IconName::Close) .icon_size(IconSize::Small) .on_click(move |_, window, cx| { on_close(window, cx); }), ) }), ) .child( v_flex() .px_1() .gap_0p5() .child( Label::new(author_name.clone()) .weight(FontWeight::SEMIBOLD), ) .child( Label::new(date_string) .color(Color::Muted) .size(LabelSize::Small), ), ) .child( v_flex() .gap_1p5() .child( h_flex() .gap_1() .child( div() .w_5() .h_5() .flex() .items_center() .justify_center() .child( Icon::new(IconName::Person) .size(IconSize::Small) .color(Color::Muted), ), ) .child( div() .id("author-name") .child( Label::new(author_name) .size(LabelSize::Small) .color(Color::Muted), ) .when(!author_email.is_empty(), |this| { this.tooltip(Tooltip::text(author_email)) }), ), ) .child({ let short_sha: SharedString = full_sha.chars().take(7).collect::().into(); h_flex() .gap_1() .child( CopyButton::new("copy-sha", full_sha.to_string()) .tooltip_label("Copy SHA"), ) .child( Label::new(short_sha) .size(LabelSize::Small) .color(Color::Muted), ) }) .when_some(remote, |this, remote| { let provider_name = remote.host.name(); let icon = match provider_name.as_str() { "GitHub" => IconName::Github, _ => IconName::Link, }; let parsed_remote = ParsedGitRemote { owner: remote.owner.as_ref().into(), repo: remote.repo.as_ref().into(), }; let params = BuildCommitPermalinkParams { sha: full_sha.as_ref(), }; let url = remote .host .build_commit_permalink(&parsed_remote, params) .to_string(); this.child( h_flex().child( ButtonLike::new("view-on-provider") .style(ButtonStyle::Subtle) .child( h_flex() .gap_1() .child( Icon::new(icon) .size(IconSize::Small) .color(Color::Muted), ) .child( Label::new(format!( "View on {}", provider_name )) .size(LabelSize::Small) .color(Color::Muted), ), ) .on_click(move |_, _, cx| { cx.open_url(&url); }), ), ) }), ) .when(!ref_names.is_empty(), |this| { this.child(h_flex().pt_1().gap_1().flex_wrap().children( ref_names.iter().map(|name| { render_badge(name, accent_color).into_any_element() }), )) }) .child( v_flex() .px_1() .gap_2() .child(Label::new(subject).weight(FontWeight::MEDIUM)) .when(!body.is_empty(), |this| { this.child( Label::new(body) .size(LabelSize::Small) .color(Color::Muted), ) }), ), ) .h_1_2() .overflow_y_scroll(), ) // Changes list .child( div() .border_t_1() .border_color(cx.theme().colors().border) .id("changed-files") .w_full() .child( v_flex() .p_3() .gap_2() .child( Label::new(format!("{} Changed Files", changed_files_count)) .size(LabelSize::Small) .color(Color::Muted), ) .when(!changed_files.is_empty(), |this| { this.child(v_flex().gap_1().children(changed_files.iter().map( |(path, status)| { let file_name: String = path .file_name() .map(|n| n.to_string()) .unwrap_or_default(); let dir_path: String = path .parent() .map(|p| p.as_unix_str().to_string()) .unwrap_or_default(); let on_file_click = on_file_click.clone(); let path_for_click = path.clone(); h_flex() .id(SharedString::from(path.as_unix_str().to_string())) .gap_1() .overflow_hidden() .child(git_status_icon(*status)) .child( Label::new(file_name) .size(LabelSize::Small) .single_line(), ) .when(!dir_path.is_empty(), |this| { this.child( Label::new(dir_path) .size(LabelSize::Small) .color(Color::Muted) .single_line(), ) }) .when_some(on_file_click, |this, callback| { this.cursor_pointer().on_click( move |event, window, cx| { callback( &path_for_click, event, window, cx, ); }, ) }) }, ))) }), ) .h_1_2() .overflow_y_scroll(), ) .into_any_element() } } fn render_badge(name: &SharedString, accent_color: Hsla) -> impl IntoElement { div() .px_1p5() .py_0p5() .flex() .items_center() .justify_center() .rounded_md() .bg(accent_color.opacity(0.18)) .border_1() .border_color(accent_color.opacity(0.55)) .child( Label::new(name.clone()) .size(LabelSize::Small) .color(Color::Default) .single_line(), ) } pub fn get_remote_from_repository(repository: &Repository, cx: &mut App) -> Option { let remote_url = repository.default_remote_url()?; let provider_registry = GitHostingProviderRegistry::default_global(cx); let (provider, parsed) = parse_git_remote_url(provider_registry, &remote_url)?; Some(GitRemote { host: provider, owner: parsed.owner.into(), repo: parsed.repo.into(), }) }