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