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