1use futures::Future;
2use git::blame::BlameEntry;
3use git::PullRequest;
4use gpui::{
5 App, Asset, ClipboardItem, Element, Entity, MouseButton, ParentElement, Render, ScrollHandle,
6 StatefulInteractiveElement,
7};
8use markdown::Markdown;
9use settings::Settings;
10use std::hash::Hash;
11use theme::ThemeSettings;
12use time::{OffsetDateTime, UtcOffset};
13use time_format::format_local_timestamp;
14use ui::{prelude::*, tooltip_container, Avatar, Divider, IconButtonShape};
15use url::Url;
16
17use crate::git::blame::GitRemote;
18use crate::hover_popover::hover_markdown_style;
19
20#[derive(Clone, Debug)]
21pub struct CommitDetails {
22 pub sha: SharedString,
23 pub author_name: SharedString,
24 pub author_email: SharedString,
25 pub commit_time: OffsetDateTime,
26 pub message: Option<ParsedCommitMessage>,
27}
28
29#[derive(Clone, Debug, Default)]
30pub struct ParsedCommitMessage {
31 pub message: SharedString,
32 pub permalink: Option<Url>,
33 pub pull_request: Option<PullRequest>,
34 pub remote: Option<GitRemote>,
35}
36
37struct CommitAvatar<'a> {
38 commit: &'a CommitDetails,
39}
40
41impl<'a> CommitAvatar<'a> {
42 fn new(details: &'a CommitDetails) -> Self {
43 Self { commit: details }
44 }
45}
46
47impl<'a> CommitAvatar<'a> {
48 fn render(
49 &'a self,
50 window: &mut Window,
51 cx: &mut Context<CommitTooltip>,
52 ) -> Option<impl IntoElement + use<>> {
53 let remote = self
54 .commit
55 .message
56 .as_ref()
57 .and_then(|details| details.remote.as_ref())
58 .filter(|remote| remote.host_supports_avatars())?;
59
60 let avatar_url = CommitAvatarAsset::new(remote.clone(), self.commit.sha.clone());
61
62 let element = match window.use_asset::<CommitAvatarAsset>(&avatar_url, cx) {
63 // Loading or no avatar found
64 None | Some(None) => Icon::new(IconName::Person)
65 .color(Color::Muted)
66 .into_element()
67 .into_any(),
68 // Found
69 Some(Some(url)) => Avatar::new(url.to_string()).into_element().into_any(),
70 };
71 Some(element)
72 }
73}
74
75#[derive(Clone, Debug)]
76struct CommitAvatarAsset {
77 sha: SharedString,
78 remote: GitRemote,
79}
80
81impl Hash for CommitAvatarAsset {
82 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
83 self.sha.hash(state);
84 self.remote.host.name().hash(state);
85 }
86}
87
88impl CommitAvatarAsset {
89 fn new(remote: GitRemote, sha: SharedString) -> Self {
90 Self { remote, sha }
91 }
92}
93
94impl Asset for CommitAvatarAsset {
95 type Source = Self;
96 type Output = Option<SharedString>;
97
98 fn load(
99 source: Self::Source,
100 cx: &mut App,
101 ) -> impl Future<Output = Self::Output> + Send + 'static {
102 let client = cx.http_client();
103
104 async move {
105 source
106 .remote
107 .avatar_url(source.sha, client)
108 .await
109 .map(|url| SharedString::from(url.to_string()))
110 }
111 }
112}
113
114pub struct CommitTooltip {
115 commit: CommitDetails,
116 scroll_handle: ScrollHandle,
117 markdown: Entity<Markdown>,
118}
119
120impl CommitTooltip {
121 pub fn blame_entry(
122 blame: &BlameEntry,
123 details: Option<ParsedCommitMessage>,
124 window: &mut Window,
125 cx: &mut Context<Self>,
126 ) -> Self {
127 let commit_time = blame
128 .committer_time
129 .and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok())
130 .unwrap_or(OffsetDateTime::now_utc());
131
132 Self::new(
133 CommitDetails {
134 sha: blame.sha.to_string().into(),
135 commit_time,
136 author_name: blame
137 .author
138 .clone()
139 .unwrap_or("<no name>".to_string())
140 .into(),
141 author_email: blame.author_mail.clone().unwrap_or("".to_string()).into(),
142 message: details,
143 },
144 window,
145 cx,
146 )
147 }
148
149 pub fn new(commit: CommitDetails, window: &mut Window, cx: &mut Context<Self>) -> Self {
150 let mut style = hover_markdown_style(window, cx);
151 if let Some(code_block) = &style.code_block.text {
152 style.base_text_style.refine(code_block);
153 }
154 let markdown = cx.new(|cx| {
155 Markdown::new(
156 commit
157 .message
158 .as_ref()
159 .map(|message| message.message.clone())
160 .unwrap_or_default(),
161 style,
162 None,
163 None,
164 cx,
165 )
166 });
167 Self {
168 commit,
169 scroll_handle: ScrollHandle::new(),
170 markdown,
171 }
172 }
173}
174
175impl Render for CommitTooltip {
176 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
177 let avatar = CommitAvatar::new(&self.commit).render(window, cx);
178
179 let author = self.commit.author_name.clone();
180
181 let author_email = self.commit.author_email.clone();
182
183 let short_commit_id = self
184 .commit
185 .sha
186 .get(0..8)
187 .map(|sha| sha.to_string().into())
188 .unwrap_or_else(|| self.commit.sha.clone());
189 let full_sha = self.commit.sha.to_string().clone();
190 let absolute_timestamp = format_local_timestamp(
191 self.commit.commit_time,
192 OffsetDateTime::now_utc(),
193 time_format::TimestampFormat::MediumAbsolute,
194 );
195
196 let message = self
197 .commit
198 .message
199 .as_ref()
200 .map(|_| self.markdown.clone().into_any_element())
201 .unwrap_or("<no commit message>".into_any());
202
203 let pull_request = self
204 .commit
205 .message
206 .as_ref()
207 .and_then(|details| details.pull_request.clone());
208
209 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
210 let message_max_height = window.line_height() * 12 + (ui_font_size / 0.4);
211
212 tooltip_container(window, cx, move |this, _, cx| {
213 this.occlude()
214 .on_mouse_move(|_, _, cx| cx.stop_propagation())
215 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
216 .child(
217 v_flex()
218 .w(gpui::rems(30.))
219 .gap_4()
220 .child(
221 h_flex()
222 .pb_1p5()
223 .gap_x_2()
224 .overflow_x_hidden()
225 .flex_wrap()
226 .children(avatar)
227 .child(author)
228 .when(!author_email.is_empty(), |this| {
229 this.child(
230 div()
231 .text_color(cx.theme().colors().text_muted)
232 .child(author_email),
233 )
234 })
235 .border_b_1()
236 .border_color(cx.theme().colors().border_variant),
237 )
238 .child(
239 div()
240 .id("inline-blame-commit-message")
241 .child(message)
242 .max_h(message_max_height)
243 .overflow_y_scroll()
244 .track_scroll(&self.scroll_handle),
245 )
246 .child(
247 h_flex()
248 .text_color(cx.theme().colors().text_muted)
249 .w_full()
250 .justify_between()
251 .pt_1p5()
252 .border_t_1()
253 .border_color(cx.theme().colors().border_variant)
254 .child(absolute_timestamp)
255 .child(
256 h_flex()
257 .gap_1p5()
258 .when_some(pull_request, |this, pr| {
259 this.child(
260 Button::new(
261 "pull-request-button",
262 format!("#{}", pr.number),
263 )
264 .color(Color::Muted)
265 .icon(IconName::PullRequest)
266 .icon_color(Color::Muted)
267 .icon_position(IconPosition::Start)
268 .style(ButtonStyle::Subtle)
269 .on_click(move |_, _, cx| {
270 cx.stop_propagation();
271 cx.open_url(pr.url.as_str())
272 }),
273 )
274 })
275 .child(Divider::vertical())
276 .child(
277 Button::new(
278 "commit-sha-button",
279 short_commit_id.clone(),
280 )
281 .style(ButtonStyle::Subtle)
282 .color(Color::Muted)
283 .icon(IconName::FileGit)
284 .icon_color(Color::Muted)
285 .icon_position(IconPosition::Start)
286 .disabled(
287 self.commit
288 .message
289 .as_ref()
290 .map_or(true, |details| {
291 details.permalink.is_none()
292 }),
293 )
294 .when_some(
295 self.commit
296 .message
297 .as_ref()
298 .and_then(|details| details.permalink.clone()),
299 |this, url| {
300 this.on_click(move |_, _, cx| {
301 cx.stop_propagation();
302 cx.open_url(url.as_str())
303 })
304 },
305 ),
306 )
307 .child(
308 IconButton::new("copy-sha-button", IconName::Copy)
309 .shape(IconButtonShape::Square)
310 .icon_size(IconSize::Small)
311 .icon_color(Color::Muted)
312 .on_click(move |_, _, cx| {
313 cx.stop_propagation();
314 cx.write_to_clipboard(
315 ClipboardItem::new_string(full_sha.clone()),
316 )
317 }),
318 ),
319 ),
320 ),
321 )
322 })
323 }
324}
325
326fn blame_entry_timestamp(blame_entry: &BlameEntry, format: time_format::TimestampFormat) -> String {
327 match blame_entry.author_offset_date_time() {
328 Ok(timestamp) => {
329 let local = chrono::Local::now().offset().local_minus_utc();
330 time_format::format_localized_timestamp(
331 timestamp,
332 time::OffsetDateTime::now_utc(),
333 UtcOffset::from_whole_seconds(local).unwrap(),
334 format,
335 )
336 }
337 Err(_) => "Error parsing date".to_string(),
338 }
339}
340
341pub fn blame_entry_relative_timestamp(blame_entry: &BlameEntry) -> String {
342 blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative)
343}