From 9247da77a3a350c0d1b85848adaa3d370d5557ba Mon Sep 17 00:00:00 2001 From: Thorsten Ball Date: Fri, 19 Apr 2024 15:15:19 +0200 Subject: [PATCH] git blame: Display GitHub avatars in blame tooltips, if available (#10767) Release Notes: - Added GitHub avatars to tooltips that appear when hovering over a `git blame` entry (either inline or in the blame gutter). Demo: https://github.com/zed-industries/zed/assets/1185253/295c5aee-3a4e-46aa-812d-495439d8840d --- Cargo.lock | 3 - assets/icons/person.svg | 3 + crates/editor/src/blame_entry_tooltip.rs | 250 +++++++++++++++++++++++ crates/editor/src/editor.rs | 1 + crates/editor/src/element.rs | 170 +-------------- crates/editor/src/git/blame.rs | 35 +++- crates/git/Cargo.toml | 2 +- crates/git/src/git.rs | 1 + crates/git/src/hosting_provider.rs | 107 ++++++++++ crates/git/src/permalink.rs | 95 +++------ crates/ui/src/components/icon.rs | 2 + crates/util/src/github.rs | 84 ++++++++ 12 files changed, 514 insertions(+), 239 deletions(-) create mode 100644 assets/icons/person.svg create mode 100644 crates/editor/src/blame_entry_tooltip.rs create mode 100644 crates/git/src/hosting_provider.rs diff --git a/Cargo.lock b/Cargo.lock index 4e3473b639..db5620acba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4002,15 +4002,12 @@ dependencies = [ "gpui", "lazy_static", "libc", - "log", "notify", "parking_lot", "rope", "serde", - "serde_derive", "serde_json", "smol", - "sum_tree", "tempfile", "text", "time", diff --git a/assets/icons/person.svg b/assets/icons/person.svg new file mode 100644 index 0000000000..f6133478d1 --- /dev/null +++ b/assets/icons/person.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/editor/src/blame_entry_tooltip.rs b/crates/editor/src/blame_entry_tooltip.rs new file mode 100644 index 0000000000..a07d399149 --- /dev/null +++ b/crates/editor/src/blame_entry_tooltip.rs @@ -0,0 +1,250 @@ +use futures::Future; +use git::blame::BlameEntry; +use git::Oid; +use gpui::{ + Asset, Element, ParentElement, Render, ScrollHandle, StatefulInteractiveElement, WeakView, + WindowContext, +}; +use settings::Settings; +use std::hash::Hash; +use theme::{ActiveTheme, ThemeSettings}; +use ui::{ + div, h_flex, tooltip_container, v_flex, Avatar, Button, ButtonStyle, Clickable as _, Color, + FluentBuilder, Icon, IconName, IconPosition, InteractiveElement as _, IntoElement, + SharedString, Styled as _, ViewContext, +}; +use ui::{ButtonCommon, Disableable as _}; +use workspace::Workspace; + +use crate::git::blame::{CommitDetails, GitRemote}; +use crate::EditorStyle; + +struct CommitAvatar<'a> { + details: Option<&'a CommitDetails>, + sha: Oid, +} + +impl<'a> CommitAvatar<'a> { + fn new(details: Option<&'a CommitDetails>, sha: Oid) -> Self { + Self { details, sha } + } +} + +impl<'a> CommitAvatar<'a> { + fn render(&'a self, cx: &mut ViewContext) -> Option { + let remote = self + .details + .and_then(|details| details.remote.as_ref()) + .filter(|remote| remote.host_supports_avatars())?; + + let avatar_url = CommitAvatarAsset::new(remote.clone(), self.sha); + + let element = cx.with_element_context(|cx| { + match cx.use_cached_asset::(&avatar_url) { + // Loading or no avatar found + None | Some(None) => Icon::new(IconName::Person) + .color(Color::Muted) + .into_element() + .into_any(), + // Found + Some(Some(url)) => Avatar::new(url.to_string()).into_element().into_any(), + } + }); + Some(element) + } +} + +#[derive(Clone, Debug)] +struct CommitAvatarAsset { + sha: Oid, + remote: GitRemote, +} + +impl Hash for CommitAvatarAsset { + fn hash(&self, state: &mut H) { + self.sha.hash(state); + self.remote.host.hash(state); + } +} + +impl CommitAvatarAsset { + fn new(remote: GitRemote, sha: Oid) -> Self { + Self { remote, sha } + } +} + +impl Asset for CommitAvatarAsset { + type Source = Self; + type Output = Option; + + fn load( + source: Self::Source, + cx: &mut WindowContext, + ) -> impl Future + Send + 'static { + let client = cx.http_client(); + + async move { + source + .remote + .avatar_url(source.sha, client) + .await + .map(|url| SharedString::from(url.to_string())) + } + } +} + +pub(crate) struct BlameEntryTooltip { + blame_entry: BlameEntry, + details: Option, + editor_style: EditorStyle, + workspace: Option>, + scroll_handle: ScrollHandle, +} + +impl BlameEntryTooltip { + pub(crate) fn new( + blame_entry: BlameEntry, + details: Option, + style: &EditorStyle, + workspace: Option>, + ) -> Self { + Self { + editor_style: style.clone(), + blame_entry, + details, + workspace, + scroll_handle: ScrollHandle::new(), + } + } +} + +impl Render for BlameEntryTooltip { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let avatar = CommitAvatar::new(self.details.as_ref(), self.blame_entry.sha).render(cx); + + let author = self + .blame_entry + .author + .clone() + .unwrap_or("".to_string()); + + let author_email = self.blame_entry.author_mail.clone(); + + let pretty_commit_id = format!("{}", self.blame_entry.sha); + let short_commit_id = pretty_commit_id.chars().take(6).collect::(); + let absolute_timestamp = blame_entry_absolute_timestamp(&self.blame_entry, cx); + + let message = self + .details + .as_ref() + .map(|details| { + crate::render_parsed_markdown( + "blame-message", + &details.parsed_message, + &self.editor_style, + self.workspace.clone(), + cx, + ) + .into_any() + }) + .unwrap_or("".into_any()); + + let ui_font_size = ThemeSettings::get_global(cx).ui_font_size; + let message_max_height = cx.line_height() * 12 + (ui_font_size / 0.4); + + tooltip_container(cx, move |this, cx| { + this.occlude() + .on_mouse_move(|_, cx| cx.stop_propagation()) + .child( + v_flex() + .w(gpui::rems(30.)) + .gap_4() + .child( + h_flex() + .gap_x_2() + .overflow_x_hidden() + .flex_wrap() + .children(avatar) + .child(author) + .when_some(author_email, |this, author_email| { + this.child( + div() + .text_color(cx.theme().colors().text_muted) + .child(author_email), + ) + }) + .border_b_1() + .border_color(cx.theme().colors().border), + ) + .child( + div() + .id("inline-blame-commit-message") + .occlude() + .child(message) + .max_h(message_max_height) + .overflow_y_scroll() + .track_scroll(&self.scroll_handle), + ) + .child( + h_flex() + .text_color(cx.theme().colors().text_muted) + .w_full() + .justify_between() + .child(absolute_timestamp) + .child( + Button::new("commit-sha-button", short_commit_id.clone()) + .style(ButtonStyle::Transparent) + .color(Color::Muted) + .icon(IconName::FileGit) + .icon_color(Color::Muted) + .icon_position(IconPosition::Start) + .disabled( + self.details.as_ref().map_or(true, |details| { + details.permalink.is_none() + }), + ) + .when_some( + self.details + .as_ref() + .and_then(|details| details.permalink.clone()), + |this, url| { + this.on_click(move |_, cx| { + cx.stop_propagation(); + cx.open_url(url.as_str()) + }) + }, + ), + ), + ), + ) + }) + } +} + +fn blame_entry_timestamp( + blame_entry: &BlameEntry, + format: time_format::TimestampFormat, + cx: &WindowContext, +) -> String { + match blame_entry.author_offset_date_time() { + Ok(timestamp) => time_format::format_localized_timestamp( + timestamp, + time::OffsetDateTime::now_utc(), + cx.local_timezone(), + format, + ), + Err(_) => "Error parsing date".to_string(), + } +} + +pub fn blame_entry_relative_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String { + blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative, cx) +} + +fn blame_entry_absolute_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String { + blame_entry_timestamp( + blame_entry, + time_format::TimestampFormat::MediumAbsolute, + cx, + ) +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 982525d8f4..9605d6842d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -13,6 +13,7 @@ //! //! If you're looking to improve Vim mode, you should check out Vim crate that wraps Editor and overrides its behaviour. pub mod actions; +mod blame_entry_tooltip; mod blink_manager; pub mod display_map; mod editor_settings; diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index fa05745171..810657632c 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1,13 +1,11 @@ use crate::{ + blame_entry_tooltip::{blame_entry_relative_timestamp, BlameEntryTooltip}, display_map::{ BlockContext, BlockStyle, DisplaySnapshot, FoldStatus, HighlightedChunk, ToDisplayPoint, TransformBlock, }, editor_settings::{DoubleClickInMultibuffer, MultiCursorModifier, ShowScrollbar}, - git::{ - blame::{CommitDetails, GitBlame}, - diff_hunk_to_display, DisplayDiffHunk, - }, + git::{blame::GitBlame, diff_hunk_to_display, DisplayDiffHunk}, hover_popover::{ self, hover_at, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT, }, @@ -28,9 +26,9 @@ use gpui::{ ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementContext, ElementInputHandler, Entity, Hitbox, Hsla, InteractiveElement, IntoElement, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, - ParentElement, Pixels, ScrollDelta, ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, - Size, Stateful, StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, - TextStyleRefinement, View, ViewContext, WeakView, WindowContext, + ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size, Stateful, + StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, TextStyleRefinement, View, + ViewContext, WeakView, WindowContext, }; use itertools::Itertools; use language::language_settings::ShowWhitespaceSetting; @@ -52,9 +50,9 @@ use std::{ sync::Arc, }; use sum_tree::Bias; -use theme::{ActiveTheme, PlayerColor, ThemeSettings}; +use theme::{ActiveTheme, PlayerColor}; +use ui::prelude::*; use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip}; -use ui::{prelude::*, tooltip_container}; use util::ResultExt; use workspace::{item::Item, Workspace}; @@ -3048,160 +3046,6 @@ fn render_inline_blame_entry( .into_any() } -fn blame_entry_timestamp( - blame_entry: &BlameEntry, - format: time_format::TimestampFormat, - cx: &WindowContext, -) -> String { - match blame_entry.author_offset_date_time() { - Ok(timestamp) => time_format::format_localized_timestamp( - timestamp, - time::OffsetDateTime::now_utc(), - cx.local_timezone(), - format, - ), - Err(_) => "Error parsing date".to_string(), - } -} - -fn blame_entry_relative_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String { - blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative, cx) -} - -fn blame_entry_absolute_timestamp(blame_entry: &BlameEntry, cx: &WindowContext) -> String { - blame_entry_timestamp( - blame_entry, - time_format::TimestampFormat::MediumAbsolute, - cx, - ) -} - -struct BlameEntryTooltip { - blame_entry: BlameEntry, - details: Option, - style: EditorStyle, - workspace: Option>, - scroll_handle: ScrollHandle, -} - -impl BlameEntryTooltip { - fn new( - blame_entry: BlameEntry, - details: Option, - style: &EditorStyle, - workspace: Option>, - ) -> Self { - Self { - style: style.clone(), - blame_entry, - details, - workspace, - scroll_handle: ScrollHandle::new(), - } - } -} - -impl Render for BlameEntryTooltip { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let author = self - .blame_entry - .author - .clone() - .unwrap_or("".to_string()); - - let author_email = self.blame_entry.author_mail.clone(); - - let pretty_commit_id = format!("{}", self.blame_entry.sha); - let short_commit_id = pretty_commit_id.chars().take(6).collect::(); - let absolute_timestamp = blame_entry_absolute_timestamp(&self.blame_entry, cx); - - let message = self - .details - .as_ref() - .map(|details| { - crate::render_parsed_markdown( - "blame-message", - &details.parsed_message, - &self.style, - self.workspace.clone(), - cx, - ) - .into_any() - }) - .unwrap_or("".into_any()); - - let ui_font_size = ThemeSettings::get_global(cx).ui_font_size; - let message_max_height = cx.line_height() * 12 + (ui_font_size / 0.4); - - tooltip_container(cx, move |this, cx| { - this.occlude() - .on_mouse_move(|_, cx| cx.stop_propagation()) - .child( - v_flex() - .w(gpui::rems(30.)) - .gap_4() - .child( - h_flex() - .gap_x_2() - .overflow_x_hidden() - .flex_wrap() - .child(author) - .when_some(author_email, |this, author_email| { - this.child( - div() - .text_color(cx.theme().colors().text_muted) - .child(author_email), - ) - }) - .pb_1() - .border_b_1() - .border_color(cx.theme().colors().border), - ) - .child( - div() - .id("inline-blame-commit-message") - .occlude() - .child(message) - .max_h(message_max_height) - .overflow_y_scroll() - .track_scroll(&self.scroll_handle), - ) - .child( - h_flex() - .text_color(cx.theme().colors().text_muted) - .w_full() - .justify_between() - .child(absolute_timestamp) - .child( - Button::new("commit-sha-button", short_commit_id.clone()) - .style(ButtonStyle::Transparent) - .color(Color::Muted) - .icon(IconName::FileGit) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .disabled( - self.details.as_ref().map_or(true, |details| { - details.permalink.is_none() - }), - ) - .when_some( - self.details - .as_ref() - .and_then(|details| details.permalink.clone()), - |this, url| { - this.on_click(move |_, cx| { - cx.stop_propagation(); - cx.open_url(url.as_str()) - }) - }, - ), - ), - ), - ) - }) - } -} - fn render_blame_entry( ix: usize, blame: &gpui::Model, diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index bef72e0817..e686e083c9 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -4,6 +4,7 @@ use anyhow::Result; use collections::HashMap; use git::{ blame::{Blame, BlameEntry}, + hosting_provider::HostingProvider, permalink::{build_commit_permalink, parse_git_remote_url}, Oid, }; @@ -13,6 +14,7 @@ use project::{Item, Project}; use smallvec::SmallVec; use sum_tree::SumTree; use url::Url; +use util::http::HttpClient; #[derive(Clone, Debug, Default)] pub struct GitBlameEntry { @@ -47,11 +49,34 @@ impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 { } } +#[derive(Clone, Debug)] +pub struct GitRemote { + pub host: HostingProvider, + pub owner: String, + pub repo: String, +} + +impl GitRemote { + pub fn host_supports_avatars(&self) -> bool { + self.host.supports_avatars() + } + + pub async fn avatar_url(&self, commit: Oid, client: Arc) -> Option { + self.host + .commit_author_avatar_url(&self.owner, &self.repo, commit, client) + .await + .ok() + .flatten() + } +} + #[derive(Clone, Debug)] pub struct CommitDetails { pub message: String, pub parsed_message: ParsedMarkdown, pub permalink: Option, + pub avatar_url: Option, + pub remote: Option, } pub struct GitBlame { @@ -337,7 +362,7 @@ impl GitBlame { this.update(&mut cx, |this, cx| { this.generate(cx); }) - }); + }) } } @@ -408,12 +433,20 @@ async fn parse_commit_messages( deprecated_permalinks.get(&oid).cloned() }; + let remote = parsed_remote_url.as_ref().map(|remote| GitRemote { + host: remote.provider.clone(), + owner: remote.owner.to_string(), + repo: remote.repo.to_string(), + }); + commit_details.insert( oid, CommitDetails { message, parsed_message, permalink, + remote, + avatar_url: None, }, ); } diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 26585e2c0e..6944075461 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -23,9 +23,9 @@ sum_tree.workspace = true text.workspace = true time.workspace = true url.workspace = true +util.workspace = true serde.workspace = true rope.workspace = true -util.workspace = true parking_lot.workspace = true windows.workspace = true diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index ddb7f11f5a..269e5606b8 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -10,6 +10,7 @@ pub use lazy_static::lazy_static; pub mod blame; pub mod commit; pub mod diff; +pub mod hosting_provider; pub mod permalink; pub mod repository; diff --git a/crates/git/src/hosting_provider.rs b/crates/git/src/hosting_provider.rs new file mode 100644 index 0000000000..eb7f9c5523 --- /dev/null +++ b/crates/git/src/hosting_provider.rs @@ -0,0 +1,107 @@ +use core::fmt; +use std::{ops::Range, sync::Arc}; + +use anyhow::Result; +use url::Url; +use util::{github, http::HttpClient}; + +use crate::Oid; + +#[derive(Clone, Debug, Hash)] +pub enum HostingProvider { + Github, + Gitlab, + Gitee, + Bitbucket, + Sourcehut, + Codeberg, +} + +impl HostingProvider { + pub(crate) fn base_url(&self) -> Url { + let base_url = match self { + Self::Github => "https://github.com", + Self::Gitlab => "https://gitlab.com", + Self::Gitee => "https://gitee.com", + Self::Bitbucket => "https://bitbucket.org", + Self::Sourcehut => "https://git.sr.ht", + Self::Codeberg => "https://codeberg.org", + }; + + Url::parse(&base_url).unwrap() + } + + /// Returns the fragment portion of the URL for the selected lines in + /// the representation the [`GitHostingProvider`] expects. + pub(crate) fn line_fragment(&self, selection: &Range) -> String { + if selection.start == selection.end { + let line = selection.start + 1; + + match self { + Self::Github | Self::Gitlab | Self::Gitee | Self::Sourcehut | Self::Codeberg => { + format!("L{}", line) + } + Self::Bitbucket => format!("lines-{}", line), + } + } else { + let start_line = selection.start + 1; + let end_line = selection.end + 1; + + match self { + Self::Github | Self::Codeberg => format!("L{}-L{}", start_line, end_line), + Self::Gitlab | Self::Gitee | Self::Sourcehut => { + format!("L{}-{}", start_line, end_line) + } + Self::Bitbucket => format!("lines-{}:{}", start_line, end_line), + } + } + } + + pub fn supports_avatars(&self) -> bool { + match self { + HostingProvider::Github => true, + _ => false, + } + } + + pub async fn commit_author_avatar_url( + &self, + repo_owner: &str, + repo: &str, + commit: Oid, + client: Arc, + ) -> Result> { + match self { + HostingProvider::Github => { + let commit = commit.to_string(); + + let author = + github::fetch_github_commit_author(repo_owner, repo, &commit, &client).await?; + + let url = if let Some(author) = author { + let mut url = Url::parse(&author.avatar_url)?; + url.set_query(Some("size=128")); + Some(url) + } else { + None + }; + Ok(url) + } + _ => Ok(None), + } + } +} + +impl fmt::Display for HostingProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + HostingProvider::Github => "GitHub", + HostingProvider::Gitlab => "GitLab", + HostingProvider::Gitee => "Gitee", + HostingProvider::Bitbucket => "Bitbucket", + HostingProvider::Sourcehut => "Sourcehut", + HostingProvider::Codeberg => "Codeberg", + }; + write!(f, "{}", name) + } +} diff --git a/crates/git/src/permalink.rs b/crates/git/src/permalink.rs index 8ec2d86426..3b256a03c3 100644 --- a/crates/git/src/permalink.rs +++ b/crates/git/src/permalink.rs @@ -3,55 +3,7 @@ use std::ops::Range; use anyhow::{anyhow, Result}; use url::Url; -pub enum GitHostingProvider { - Github, - Gitlab, - Gitee, - Bitbucket, - Sourcehut, - Codeberg, -} - -impl GitHostingProvider { - fn base_url(&self) -> Url { - let base_url = match self { - Self::Github => "https://github.com", - Self::Gitlab => "https://gitlab.com", - Self::Gitee => "https://gitee.com", - Self::Bitbucket => "https://bitbucket.org", - Self::Sourcehut => "https://git.sr.ht", - Self::Codeberg => "https://codeberg.org", - }; - - Url::parse(&base_url).unwrap() - } - - /// Returns the fragment portion of the URL for the selected lines in - /// the representation the [`GitHostingProvider`] expects. - fn line_fragment(&self, selection: &Range) -> String { - if selection.start == selection.end { - let line = selection.start + 1; - - match self { - Self::Github | Self::Gitlab | Self::Gitee | Self::Sourcehut | Self::Codeberg => { - format!("L{}", line) - } - Self::Bitbucket => format!("lines-{}", line), - } - } else { - let start_line = selection.start + 1; - let end_line = selection.end + 1; - - match self { - Self::Github | Self::Codeberg => format!("L{}-L{}", start_line, end_line), - Self::Gitlab | Self::Gitee | Self::Sourcehut => { - format!("L{}-{}", start_line, end_line) - } - Self::Bitbucket => format!("lines-{}:{}", start_line, end_line), - } - } - } -} +use crate::hosting_provider::HostingProvider; pub struct BuildPermalinkParams<'a> { pub remote_url: &'a str, @@ -76,12 +28,12 @@ pub fn build_permalink(params: BuildPermalinkParams) -> Result { .ok_or_else(|| anyhow!("failed to parse Git remote URL"))?; let path = match provider { - GitHostingProvider::Github => format!("{owner}/{repo}/blob/{sha}/{path}"), - GitHostingProvider::Gitlab => format!("{owner}/{repo}/-/blob/{sha}/{path}"), - GitHostingProvider::Gitee => format!("{owner}/{repo}/blob/{sha}/{path}"), - GitHostingProvider::Bitbucket => format!("{owner}/{repo}/src/{sha}/{path}"), - GitHostingProvider::Sourcehut => format!("~{owner}/{repo}/tree/{sha}/item/{path}"), - GitHostingProvider::Codeberg => format!("{owner}/{repo}/src/commit/{sha}/{path}"), + HostingProvider::Github => format!("{owner}/{repo}/blob/{sha}/{path}"), + HostingProvider::Gitlab => format!("{owner}/{repo}/-/blob/{sha}/{path}"), + HostingProvider::Gitee => format!("{owner}/{repo}/blob/{sha}/{path}"), + HostingProvider::Bitbucket => format!("{owner}/{repo}/src/{sha}/{path}"), + HostingProvider::Sourcehut => format!("~{owner}/{repo}/tree/{sha}/item/{path}"), + HostingProvider::Codeberg => format!("{owner}/{repo}/src/commit/{sha}/{path}"), }; let line_fragment = selection.map(|selection| provider.line_fragment(&selection)); @@ -90,8 +42,9 @@ pub fn build_permalink(params: BuildPermalinkParams) -> Result { Ok(permalink) } +#[derive(Debug)] pub struct ParsedGitRemote<'a> { - pub provider: GitHostingProvider, + pub provider: HostingProvider, pub owner: &'a str, pub repo: &'a str, } @@ -111,12 +64,12 @@ pub fn build_commit_permalink(params: BuildCommitPermalinkParams) -> Url { } = remote; let path = match provider { - GitHostingProvider::Github => format!("{owner}/{repo}/commit/{sha}"), - GitHostingProvider::Gitlab => format!("{owner}/{repo}/-/commit/{sha}"), - GitHostingProvider::Gitee => format!("{owner}/{repo}/commit/{sha}"), - GitHostingProvider::Bitbucket => format!("{owner}/{repo}/commits/{sha}"), - GitHostingProvider::Sourcehut => format!("~{owner}/{repo}/commit/{sha}"), - GitHostingProvider::Codeberg => format!("{owner}/{repo}/commit/{sha}"), + HostingProvider::Github => format!("{owner}/{repo}/commit/{sha}"), + HostingProvider::Gitlab => format!("{owner}/{repo}/-/commit/{sha}"), + HostingProvider::Gitee => format!("{owner}/{repo}/commit/{sha}"), + HostingProvider::Bitbucket => format!("{owner}/{repo}/commits/{sha}"), + HostingProvider::Sourcehut => format!("~{owner}/{repo}/commit/{sha}"), + HostingProvider::Codeberg => format!("{owner}/{repo}/commit/{sha}"), }; provider.base_url().join(&path).unwrap() @@ -132,7 +85,7 @@ pub fn parse_git_remote_url(url: &str) -> Option { let (owner, repo) = repo_with_owner.split_once('/')?; return Some(ParsedGitRemote { - provider: GitHostingProvider::Github, + provider: HostingProvider::Github, owner, repo, }); @@ -147,7 +100,7 @@ pub fn parse_git_remote_url(url: &str) -> Option { let (owner, repo) = repo_with_owner.split_once('/')?; return Some(ParsedGitRemote { - provider: GitHostingProvider::Gitlab, + provider: HostingProvider::Gitlab, owner, repo, }); @@ -162,7 +115,7 @@ pub fn parse_git_remote_url(url: &str) -> Option { let (owner, repo) = repo_with_owner.split_once('/')?; return Some(ParsedGitRemote { - provider: GitHostingProvider::Gitee, + provider: HostingProvider::Gitee, owner, repo, }); @@ -176,7 +129,7 @@ pub fn parse_git_remote_url(url: &str) -> Option { .split_once('/')?; return Some(ParsedGitRemote { - provider: GitHostingProvider::Bitbucket, + provider: HostingProvider::Bitbucket, owner, repo, }); @@ -193,7 +146,7 @@ pub fn parse_git_remote_url(url: &str) -> Option { let (owner, repo) = repo_with_owner.split_once('/')?; return Some(ParsedGitRemote { - provider: GitHostingProvider::Sourcehut, + provider: HostingProvider::Sourcehut, owner, repo, }); @@ -208,7 +161,7 @@ pub fn parse_git_remote_url(url: &str) -> Option { let (owner, repo) = repo_with_owner.split_once('/')?; return Some(ParsedGitRemote { - provider: GitHostingProvider::Codeberg, + provider: HostingProvider::Codeberg, owner, repo, }); @@ -476,7 +429,7 @@ mod tests { fn test_parse_git_remote_url_bitbucket_https_with_username() { let url = "https://thorstenballzed@bitbucket.org/thorstenzed/testingrepo.git"; let parsed = parse_git_remote_url(url).unwrap(); - assert!(matches!(parsed.provider, GitHostingProvider::Bitbucket)); + assert!(matches!(parsed.provider, HostingProvider::Bitbucket)); assert_eq!(parsed.owner, "thorstenzed"); assert_eq!(parsed.repo, "testingrepo"); } @@ -485,7 +438,7 @@ mod tests { fn test_parse_git_remote_url_bitbucket_https_without_username() { let url = "https://bitbucket.org/thorstenzed/testingrepo.git"; let parsed = parse_git_remote_url(url).unwrap(); - assert!(matches!(parsed.provider, GitHostingProvider::Bitbucket)); + assert!(matches!(parsed.provider, HostingProvider::Bitbucket)); assert_eq!(parsed.owner, "thorstenzed"); assert_eq!(parsed.repo, "testingrepo"); } @@ -494,7 +447,7 @@ mod tests { fn test_parse_git_remote_url_bitbucket_git() { let url = "git@bitbucket.org:thorstenzed/testingrepo.git"; let parsed = parse_git_remote_url(url).unwrap(); - assert!(matches!(parsed.provider, GitHostingProvider::Bitbucket)); + assert!(matches!(parsed.provider, HostingProvider::Bitbucket)); assert_eq!(parsed.owner, "thorstenzed"); assert_eq!(parsed.repo, "testingrepo"); } diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index cd7c53d67c..c4db95c301 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -94,6 +94,7 @@ pub enum IconName { PageDown, PageUp, Pencil, + Person, Play, Plus, Public, @@ -193,6 +194,7 @@ impl IconName { IconName::Option => "icons/option.svg", IconName::PageDown => "icons/page_down.svg", IconName::PageUp => "icons/page_up.svg", + IconName::Person => "icons/person.svg", IconName::Pencil => "icons/pencil.svg", IconName::Play => "icons/play.svg", IconName::Plus => "icons/plus.svg", diff --git a/crates/util/src/github.rs b/crates/util/src/github.rs index f6d768d066..895c5a7590 100644 --- a/crates/util/src/github.rs +++ b/crates/util/src/github.rs @@ -1,6 +1,7 @@ use crate::http::HttpClient; use anyhow::{anyhow, bail, Context, Result}; use futures::AsyncReadExt; +use isahc::{config::Configurable, AsyncBody, Request}; use serde::Deserialize; use std::sync::Arc; use url::Url; @@ -26,6 +27,89 @@ pub struct GithubReleaseAsset { pub browser_download_url: String, } +#[derive(Debug, Deserialize)] +struct CommitDetails { + commit: Commit, + author: Option, +} + +#[derive(Debug, Deserialize)] +struct Commit { + author: Author, +} + +#[derive(Debug, Deserialize)] +struct Author { + name: String, + email: String, + date: String, +} + +#[derive(Debug, Deserialize)] +struct User { + pub login: String, + pub id: u64, + pub node_id: String, + pub avatar_url: String, + pub gravatar_id: String, +} + +#[derive(Debug)] +pub struct GitHubAuthor { + pub id: u64, + pub email: String, + pub avatar_url: String, +} + +pub async fn fetch_github_commit_author( + repo_owner: &str, + repo: &str, + commit: &str, + client: &Arc, +) -> Result> { + let url = format!("https://api.github.com/repos/{repo_owner}/{repo}/commits/{commit}"); + + let mut request = Request::get(&url) + .redirect_policy(isahc::config::RedirectPolicy::Follow) + .header("Content-Type", "application/json"); + + if let Ok(github_token) = std::env::var("GITHUB_TOKEN") { + request = request.header("Authorization", format!("Bearer {}", github_token)); + } + + let mut response = client + .send(request.body(AsyncBody::default())?) + .await + .with_context(|| format!("error fetching GitHub commit details at {:?}", url))?; + + let mut body = Vec::new(); + response.body_mut().read_to_end(&mut body).await?; + + if response.status().is_client_error() { + let text = String::from_utf8_lossy(body.as_slice()); + bail!( + "status error {}, response: {text:?}", + response.status().as_u16() + ); + } + + let body_str = std::str::from_utf8(&body)?; + + serde_json::from_str::(body_str) + .map(|github_commit| { + if let Some(author) = github_commit.author { + Some(GitHubAuthor { + id: author.id, + avatar_url: author.avatar_url, + email: github_commit.commit.author.email, + }) + } else { + None + } + }) + .context("deserializing GitHub commit details failed") +} + pub async fn latest_github_release( repo_name_with_owner: &str, require_assets: bool,