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 committer_name: SharedString,
24 pub committer_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> {
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 committer_name: blame
137 .committer_name
138 .clone()
139 .unwrap_or("<no name>".to_string())
140 .into(),
141 committer_email: blame
142 .committer_email
143 .clone()
144 .unwrap_or("".to_string())
145 .into(),
146 message: details,
147 },
148 window,
149 cx,
150 )
151 }
152
153 pub fn new(commit: CommitDetails, window: &mut Window, cx: &mut Context<Self>) -> Self {
154 let mut style = hover_markdown_style(window, cx);
155 if let Some(code_block) = &style.code_block.text {
156 style.base_text_style.refine(code_block);
157 }
158 let markdown = cx.new(|cx| {
159 Markdown::new(
160 commit
161 .message
162 .as_ref()
163 .map(|message| message.message.clone())
164 .unwrap_or_default(),
165 style,
166 None,
167 None,
168 cx,
169 )
170 });
171 Self {
172 commit,
173 scroll_handle: ScrollHandle::new(),
174 markdown,
175 }
176 }
177}
178
179impl Render for CommitTooltip {
180 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
181 let avatar = CommitAvatar::new(&self.commit).render(window, cx);
182
183 let author = self.commit.committer_name.clone();
184
185 let author_email = self.commit.committer_email.clone();
186
187 let short_commit_id = self
188 .commit
189 .sha
190 .get(0..8)
191 .map(|sha| sha.to_string().into())
192 .unwrap_or_else(|| self.commit.sha.clone());
193 let full_sha = self.commit.sha.to_string().clone();
194 let absolute_timestamp = format_local_timestamp(
195 self.commit.commit_time,
196 OffsetDateTime::now_utc(),
197 time_format::TimestampFormat::MediumAbsolute,
198 );
199
200 let message = self
201 .commit
202 .message
203 .as_ref()
204 .map(|_| self.markdown.clone().into_any_element())
205 .unwrap_or("<no commit message>".into_any());
206
207 let pull_request = self
208 .commit
209 .message
210 .as_ref()
211 .and_then(|details| details.pull_request.clone());
212
213 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
214 let message_max_height = window.line_height() * 12 + (ui_font_size / 0.4);
215
216 tooltip_container(window, cx, move |this, _, cx| {
217 this.occlude()
218 .on_mouse_move(|_, _, cx| cx.stop_propagation())
219 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
220 .child(
221 v_flex()
222 .w(gpui::rems(30.))
223 .gap_4()
224 .child(
225 h_flex()
226 .pb_1p5()
227 .gap_x_2()
228 .overflow_x_hidden()
229 .flex_wrap()
230 .children(avatar)
231 .child(author)
232 .when(!author_email.is_empty(), |this| {
233 this.child(
234 div()
235 .text_color(cx.theme().colors().text_muted)
236 .child(author_email),
237 )
238 })
239 .border_b_1()
240 .border_color(cx.theme().colors().border_variant),
241 )
242 .child(
243 div()
244 .id("inline-blame-commit-message")
245 .child(message)
246 .max_h(message_max_height)
247 .overflow_y_scroll()
248 .track_scroll(&self.scroll_handle),
249 )
250 .child(
251 h_flex()
252 .text_color(cx.theme().colors().text_muted)
253 .w_full()
254 .justify_between()
255 .pt_1p5()
256 .border_t_1()
257 .border_color(cx.theme().colors().border_variant)
258 .child(absolute_timestamp)
259 .child(
260 h_flex()
261 .gap_1p5()
262 .when_some(pull_request, |this, pr| {
263 this.child(
264 Button::new(
265 "pull-request-button",
266 format!("#{}", pr.number),
267 )
268 .color(Color::Muted)
269 .icon(IconName::PullRequest)
270 .icon_color(Color::Muted)
271 .icon_position(IconPosition::Start)
272 .style(ButtonStyle::Subtle)
273 .on_click(move |_, _, cx| {
274 cx.stop_propagation();
275 cx.open_url(pr.url.as_str())
276 }),
277 )
278 })
279 .child(Divider::vertical())
280 .child(
281 Button::new(
282 "commit-sha-button",
283 short_commit_id.clone(),
284 )
285 .style(ButtonStyle::Subtle)
286 .color(Color::Muted)
287 .icon(IconName::FileGit)
288 .icon_color(Color::Muted)
289 .icon_position(IconPosition::Start)
290 .disabled(
291 self.commit
292 .message
293 .as_ref()
294 .map_or(true, |details| {
295 details.permalink.is_none()
296 }),
297 )
298 .when_some(
299 self.commit
300 .message
301 .as_ref()
302 .and_then(|details| details.permalink.clone()),
303 |this, url| {
304 this.on_click(move |_, _, cx| {
305 cx.stop_propagation();
306 cx.open_url(url.as_str())
307 })
308 },
309 ),
310 )
311 .child(
312 IconButton::new("copy-sha-button", IconName::Copy)
313 .shape(IconButtonShape::Square)
314 .icon_size(IconSize::Small)
315 .icon_color(Color::Muted)
316 .on_click(move |_, _, cx| {
317 cx.stop_propagation();
318 cx.write_to_clipboard(
319 ClipboardItem::new_string(full_sha.clone()),
320 )
321 }),
322 ),
323 ),
324 ),
325 )
326 })
327 }
328}
329
330fn blame_entry_timestamp(blame_entry: &BlameEntry, format: time_format::TimestampFormat) -> String {
331 match blame_entry.author_offset_date_time() {
332 Ok(timestamp) => {
333 let local = chrono::Local::now().offset().local_minus_utc();
334 time_format::format_localized_timestamp(
335 timestamp,
336 time::OffsetDateTime::now_utc(),
337 UtcOffset::from_whole_seconds(local).unwrap(),
338 format,
339 )
340 }
341 Err(_) => "Error parsing date".to_string(),
342 }
343}
344
345pub fn blame_entry_relative_timestamp(blame_entry: &BlameEntry) -> String {
346 blame_entry_timestamp(blame_entry, time_format::TimestampFormat::Relative)
347}