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
This commit is contained in:
parent
37e4f83a78
commit
9247da77a3
12 changed files with 514 additions and 239 deletions
250
crates/editor/src/blame_entry_tooltip.rs
Normal file
250
crates/editor/src/blame_entry_tooltip.rs
Normal file
|
@ -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<BlameEntryTooltip>) -> Option<impl IntoElement> {
|
||||
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::<CommitAvatarAsset>(&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<H: std::hash::Hasher>(&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<SharedString>;
|
||||
|
||||
fn load(
|
||||
source: Self::Source,
|
||||
cx: &mut WindowContext,
|
||||
) -> impl Future<Output = Self::Output> + 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<CommitDetails>,
|
||||
editor_style: EditorStyle,
|
||||
workspace: Option<WeakView<Workspace>>,
|
||||
scroll_handle: ScrollHandle,
|
||||
}
|
||||
|
||||
impl BlameEntryTooltip {
|
||||
pub(crate) fn new(
|
||||
blame_entry: BlameEntry,
|
||||
details: Option<CommitDetails>,
|
||||
style: &EditorStyle,
|
||||
workspace: Option<WeakView<Workspace>>,
|
||||
) -> 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<Self>) -> 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("<no name>".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::<String>();
|
||||
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("<no commit message>".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,
|
||||
)
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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<CommitDetails>,
|
||||
style: EditorStyle,
|
||||
workspace: Option<WeakView<Workspace>>,
|
||||
scroll_handle: ScrollHandle,
|
||||
}
|
||||
|
||||
impl BlameEntryTooltip {
|
||||
fn new(
|
||||
blame_entry: BlameEntry,
|
||||
details: Option<CommitDetails>,
|
||||
style: &EditorStyle,
|
||||
workspace: Option<WeakView<Workspace>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
style: style.clone(),
|
||||
blame_entry,
|
||||
details,
|
||||
workspace,
|
||||
scroll_handle: ScrollHandle::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for BlameEntryTooltip {
|
||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
|
||||
let author = self
|
||||
.blame_entry
|
||||
.author
|
||||
.clone()
|
||||
.unwrap_or("<no name>".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::<String>();
|
||||
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("<no commit message>".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<GitBlame>,
|
||||
|
|
|
@ -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<dyn HttpClient>) -> Option<Url> {
|
||||
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<Url>,
|
||||
pub avatar_url: Option<Url>,
|
||||
pub remote: Option<GitRemote>,
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
107
crates/git/src/hosting_provider.rs
Normal file
107
crates/git/src/hosting_provider.rs
Normal file
|
@ -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<u32>) -> 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<dyn HttpClient>,
|
||||
) -> Result<Option<Url>> {
|
||||
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)
|
||||
}
|
||||
}
|
|
@ -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<u32>) -> 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<Url> {
|
|||
.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<Url> {
|
|||
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<ParsedGitRemote> {
|
|||
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<ParsedGitRemote> {
|
|||
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<ParsedGitRemote> {
|
|||
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<ParsedGitRemote> {
|
|||
.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<ParsedGitRemote> {
|
|||
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<ParsedGitRemote> {
|
|||
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");
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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<User>,
|
||||
}
|
||||
|
||||
#[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<dyn HttpClient>,
|
||||
) -> Result<Option<GitHubAuthor>> {
|
||||
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::<CommitDetails>(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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue