Skip to repository content557 lines · 22.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:35:59.362Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
blame_ui.rs
1use crate::{
2 commit_tooltip::{CommitAvatar, CommitTooltip, commit_tag_chips},
3 commit_view::CommitView,
4};
5use editor::{BlameRenderer, Editor, hover_markdown_style};
6use git::{blame::BlameEntry, commit::ParsedCommitMessage, repository::CommitSummary};
7use gpui::{
8 ClipboardItem, Entity, Hsla, MouseButton, Pixels, Rems, ScrollHandle, Subscription, TextStyle,
9 TextStyleRefinement, UnderlineStyle, WeakEntity, prelude::*,
10};
11use markdown::{Markdown, MarkdownElement};
12use project::{
13 git_store::Repository,
14 project_settings::{InlineBlameLocation, ProjectSettings},
15};
16use settings::Settings as _;
17use theme_settings::ThemeSettings;
18use time::OffsetDateTime;
19use ui::{ContextMenu, CopyButton, Divider, prelude::*, tooltip_container};
20use workspace::Workspace;
21
22const GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED: usize = 20;
23const GIT_BLAME_GUTTER_MARGIN: Rems = rems(0.5);
24const GIT_BLAME_GUTTER_GAP: Rems = rems(0.5);
25const GIT_BLAME_AVATAR_SIZE: Rems = rems(1.);
26
27pub struct GitBlameRenderer;
28
29fn format_blame_text(blame_entry: &BlameEntry, cx: &App) -> String {
30 let relative_timestamp = blame_entry_relative_timestamp(blame_entry);
31 let author = blame_entry.author.as_deref().unwrap_or_default();
32 let summary_enabled = ProjectSettings::get_global(cx)
33 .git
34 .inline_blame
35 .show_commit_summary;
36
37 match blame_entry.summary.as_ref() {
38 Some(summary) if summary_enabled => {
39 format!("{author}, {relative_timestamp} - {summary}")
40 }
41 _ => format!("{author}, {relative_timestamp}"),
42 }
43}
44
45#[derive(Default)]
46pub struct GitBlameStatus {
47 text: Option<SharedString>,
48 active_editor: Option<Entity<Editor>>,
49 _subscriptions: Vec<Subscription>,
50}
51
52impl GitBlameStatus {
53 fn update(&mut self, editor: Entity<Editor>, _window: &mut Window, cx: &mut Context<Self>) {
54 let inline_blame = ProjectSettings::get_global(cx).git.inline_blame;
55 let text =
56 if inline_blame.enabled && inline_blame.location == InlineBlameLocation::StatusBar {
57 editor
58 .update(cx, |editor, cx| editor.active_git_blame_entry(cx))
59 .map(|blame_entry| SharedString::from(format_blame_text(&blame_entry, cx)))
60 } else {
61 None
62 };
63
64 if text != self.text {
65 self.text = text;
66 cx.notify();
67 }
68 }
69}
70
71impl Render for GitBlameStatus {
72 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
73 let inline_blame = ProjectSettings::get_global(cx).git.inline_blame;
74 if !inline_blame.enabled || inline_blame.location != InlineBlameLocation::StatusBar {
75 return div();
76 }
77
78 div().when_some(self.text.clone(), |el, text| {
79 el.child(
80 Button::new("git-blame-status", text.clone())
81 .label_size(LabelSize::Small)
82 .start_icon(
83 Icon::new(IconName::FileGit)
84 .size(IconSize::Small)
85 .color(Color::Hint),
86 )
87 .on_click(cx.listener(|this, _, window, cx| {
88 if let Some(editor) = this.active_editor.clone() {
89 let focus_handle = gpui::Focusable::focus_handle(editor.read(cx), cx);
90 focus_handle.dispatch_action(
91 &editor::actions::OpenGitBlameCommit,
92 window,
93 cx,
94 );
95 }
96 }))
97 .tooltip(ui::Tooltip::text(text)),
98 )
99 })
100 }
101}
102
103impl workspace::StatusItemView for GitBlameStatus {
104 fn set_active_pane_item(
105 &mut self,
106 active_pane_item: Option<&dyn workspace::item::ItemHandle>,
107 window: &mut Window,
108 cx: &mut Context<Self>,
109 ) {
110 if let Some(editor) = active_pane_item.and_then(|item| item.act_as::<Editor>(cx)) {
111 self.active_editor = Some(editor.clone());
112 self._subscriptions = vec![cx.observe_in(&editor, window, Self::update)];
113 self.update(editor, window, cx);
114 } else {
115 self.text = None;
116 self.active_editor = None;
117 self._subscriptions.clear();
118 cx.notify();
119 }
120 }
121
122 fn hide_setting(&self, _: &App) -> Option<workspace::HideStatusItem> {
123 None
124 }
125}
126
127impl BlameRenderer for GitBlameRenderer {
128 fn max_author_length(&self) -> usize {
129 GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED
130 }
131
132 fn blame_entry_non_text_width(&self, window: &Window, cx: &App) -> Pixels {
133 let show_avatar = ProjectSettings::get_global(cx).git.blame.show_avatar;
134 let gap_count = if show_avatar { 3. } else { 2. };
135 let width = GIT_BLAME_GUTTER_MARGIN.to_pixels(window.rem_size())
136 + GIT_BLAME_GUTTER_GAP.to_pixels(window.rem_size()) * gap_count;
137
138 if show_avatar {
139 width + CommitAvatar::rendered_size(GIT_BLAME_AVATAR_SIZE, window)
140 } else {
141 width
142 }
143 }
144
145 fn render_blame_entry(
146 &self,
147 style: &TextStyle,
148 blame_entry: BlameEntry,
149 details: Option<ParsedCommitMessage>,
150 tag_names: Vec<SharedString>,
151 repository: Entity<Repository>,
152 workspace: WeakEntity<Workspace>,
153 editor: Entity<Editor>,
154 ix: usize,
155 sha_color: Hsla,
156 window: &mut Window,
157 cx: &mut App,
158 ) -> Option<AnyElement> {
159 let relative_timestamp = blame_entry_relative_timestamp(&blame_entry);
160 let short_commit_id = blame_entry.sha.display_short();
161 let author_name = blame_entry.author.as_deref().unwrap_or("<no name>");
162 let name = util::truncate_and_trailoff(author_name, GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED);
163
164 let avatar = if ProjectSettings::get_global(cx).git.blame.show_avatar {
165 let author_email = blame_entry.author_mail.as_ref().map(|email| {
166 SharedString::from(
167 email
168 .trim_start_matches('<')
169 .trim_end_matches('>')
170 .to_string(),
171 )
172 });
173 Some(
174 CommitAvatar::new(
175 &blame_entry.sha.to_string().into(),
176 author_email,
177 details.as_ref().and_then(|it| it.remote.as_ref()),
178 )
179 .size(GIT_BLAME_AVATAR_SIZE)
180 .render(window, cx),
181 )
182 } else {
183 None
184 };
185
186 Some(
187 div()
188 .mr_2()
189 .child(
190 h_flex()
191 .id(("blame", ix))
192 .w_full()
193 .gap_2()
194 .justify_between()
195 .font(style.font())
196 .line_height(style.line_height)
197 .text_color(cx.theme().status().hint)
198 .child(
199 h_flex()
200 .gap_2()
201 .child(div().text_color(sha_color).child(short_commit_id))
202 .children(avatar)
203 .child(name),
204 )
205 .child(relative_timestamp)
206 .hover(|style| style.bg(cx.theme().colors().element_hover))
207 .cursor_pointer()
208 .on_mouse_down(MouseButton::Right, {
209 let blame_entry = blame_entry.clone();
210 let details = details.clone();
211 let editor = editor.clone();
212 move |event, window, cx| {
213 cx.stop_propagation();
214
215 deploy_blame_entry_context_menu(
216 &blame_entry,
217 details.as_ref(),
218 editor.clone(),
219 event.position,
220 window,
221 cx,
222 );
223 }
224 })
225 .on_click({
226 let blame_entry = blame_entry.clone();
227 let repository = repository.clone();
228 let workspace = workspace.clone();
229 move |_, window, cx| {
230 CommitView::open(
231 blame_entry.sha.to_string(),
232 repository.downgrade(),
233 workspace.clone(),
234 None,
235 None,
236 window,
237 cx,
238 )
239 }
240 })
241 .when(!editor.read(cx).has_mouse_context_menu(), |el| {
242 el.hoverable_tooltip(move |_window, cx| {
243 cx.new(|cx| {
244 CommitTooltip::blame_entry(
245 &blame_entry,
246 details.clone(),
247 tag_names.clone(),
248 repository.clone(),
249 workspace.clone(),
250 cx,
251 )
252 })
253 .into()
254 })
255 }),
256 )
257 .into_any(),
258 )
259 }
260
261 fn render_inline_blame_entry(
262 &self,
263 style: &TextStyle,
264 blame_entry: BlameEntry,
265 cx: &mut App,
266 ) -> Option<AnyElement> {
267 let text = format_blame_text(&blame_entry, cx);
268
269 Some(
270 h_flex()
271 .id("inline-blame")
272 .w_full()
273 .font(style.font())
274 .text_color(cx.theme().status().hint)
275 .line_height(style.line_height)
276 .child(Icon::new(IconName::FileGit).color(Color::Hint))
277 .child(text)
278 .gap_2()
279 .into_any(),
280 )
281 }
282
283 fn render_blame_entry_popover(
284 &self,
285 blame: BlameEntry,
286 scroll_handle: ScrollHandle,
287 details: Option<ParsedCommitMessage>,
288 tag_names: Vec<SharedString>,
289 markdown: Entity<Markdown>,
290 repository: Entity<Repository>,
291 workspace: WeakEntity<Workspace>,
292 window: &mut Window,
293 cx: &mut App,
294 ) -> Option<AnyElement> {
295 let commit_time = blame
296 .committer_time
297 .and_then(|t| OffsetDateTime::from_unix_timestamp(t).ok())
298 .unwrap_or(OffsetDateTime::now_utc());
299
300 let sha = blame.sha.to_string().into();
301 let author: SharedString = blame
302 .author
303 .clone()
304 .unwrap_or("<no name>".to_string())
305 .into();
306 let author_email = blame.author_mail.as_deref().unwrap_or_default();
307 let author_email_for_avatar = blame.author_mail.as_ref().map(|email| {
308 SharedString::from(
309 email
310 .trim_start_matches('<')
311 .trim_end_matches('>')
312 .to_string(),
313 )
314 });
315 let avatar = CommitAvatar::new(
316 &sha,
317 author_email_for_avatar,
318 details.as_ref().and_then(|it| it.remote.as_ref()),
319 )
320 .render(window, cx);
321
322 let short_commit_id = sha
323 .get(..git::SHORT_SHA_LENGTH)
324 .map(|sha| sha.to_string().into())
325 .unwrap_or_else(|| sha.clone());
326 let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
327 let absolute_timestamp = time_format::format_localized_timestamp(
328 commit_time,
329 OffsetDateTime::now_utc(),
330 local_offset,
331 time_format::TimestampFormat::MediumAbsolute,
332 );
333 let link_color = cx.theme().colors().text_accent;
334 let markdown_style = {
335 let mut style = hover_markdown_style(window, cx);
336 style.link.refine(&TextStyleRefinement {
337 color: Some(link_color),
338 underline: Some(UnderlineStyle {
339 color: Some(link_color.opacity(0.4)),
340 thickness: px(1.0),
341 ..Default::default()
342 }),
343 ..Default::default()
344 });
345 style
346 };
347
348 let message = details
349 .as_ref()
350 .map(|_| {
351 MarkdownElement::new(markdown.clone(), markdown_style)
352 .scroll_handle(scroll_handle.clone())
353 .into_any()
354 })
355 .unwrap_or("<no commit message>".into_any());
356
357 let pull_request = details
358 .as_ref()
359 .and_then(|details| details.pull_request.clone());
360
361 let ui_font_size = ThemeSettings::get_global(cx).ui_font_size(cx);
362 let message_max_height = window.line_height() * 12 + (ui_font_size / 0.4);
363 let commit_summary = CommitSummary {
364 sha: sha.clone(),
365 subject: details
366 .as_ref()
367 .and_then(|details| {
368 Some(
369 details
370 .message
371 .split('\n')
372 .next()?
373 .trim_end()
374 .to_string()
375 .into(),
376 )
377 })
378 .unwrap_or_default(),
379 commit_timestamp: commit_time.unix_timestamp(),
380 author_name: author.clone(),
381 has_parent: false,
382 };
383
384 Some(
385 tooltip_container(cx, |this, cx| {
386 this.occlude()
387 .on_mouse_move(|_, _, cx| cx.stop_propagation())
388 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
389 .child(
390 v_flex()
391 .w(gpui::rems(30.))
392 .child(
393 h_flex()
394 .pb_1()
395 .gap_2()
396 .overflow_x_hidden()
397 .flex_wrap()
398 .border_b_1()
399 .border_color(cx.theme().colors().border_variant)
400 .child(avatar)
401 .child(author)
402 .when(!author_email.is_empty(), |this| {
403 this.child(
404 div()
405 .text_color(cx.theme().colors().text_muted)
406 .child(author_email.to_owned()),
407 )
408 }),
409 )
410 .child(
411 div()
412 .id("inline-blame-commit-message")
413 .track_scroll(&scroll_handle)
414 .py_1p5()
415 .max_h(message_max_height)
416 .overflow_y_scroll()
417 .child(message),
418 )
419 .child(
420 h_flex()
421 .text_color(cx.theme().colors().text_muted)
422 .w_full()
423 .justify_between()
424 .pt_1()
425 .gap_1()
426 .flex_wrap()
427 .border_t_1()
428 .border_color(cx.theme().colors().border_variant)
429 .child(absolute_timestamp)
430 .child(
431 h_flex()
432 .gap_1()
433 .min_w_0()
434 .children(commit_tag_chips(&tag_names))
435 .when_some(pull_request, |this, pr| {
436 this.child(
437 Button::new(
438 "pull-request-button",
439 format!("#{}", pr.number),
440 )
441 .color(Color::Muted)
442 .start_icon(
443 Icon::new(IconName::PullRequest)
444 .size(IconSize::Small)
445 .color(Color::Muted),
446 )
447 .on_click(move |_, _, cx| {
448 cx.stop_propagation();
449 cx.open_url(pr.url.as_str())
450 }),
451 )
452 .child(Divider::vertical())
453 })
454 .child(
455 Button::new(
456 "commit-sha-button",
457 short_commit_id.clone(),
458 )
459 .color(Color::Muted)
460 .start_icon(
461 Icon::new(IconName::FileGit)
462 .size(IconSize::Small)
463 .color(Color::Muted),
464 )
465 .on_click(move |_, window, cx| {
466 CommitView::open(
467 commit_summary.sha.clone().into(),
468 repository.downgrade(),
469 workspace.clone(),
470 None,
471 None,
472 window,
473 cx,
474 );
475 cx.stop_propagation();
476 }),
477 )
478 .child(Divider::vertical())
479 .child(
480 CopyButton::new("copy-blame-sha", sha.to_string())
481 .tooltip_label("Copy SHA"),
482 ),
483 ),
484 ),
485 )
486 })
487 .into_any_element(),
488 )
489 }
490
491 fn open_blame_commit(
492 &self,
493 blame_entry: BlameEntry,
494 repository: Entity<Repository>,
495 workspace: WeakEntity<Workspace>,
496 window: &mut Window,
497 cx: &mut App,
498 ) {
499 CommitView::open(
500 blame_entry.sha.to_string(),
501 repository.downgrade(),
502 workspace,
503 None,
504 None,
505 window,
506 cx,
507 )
508 }
509}
510
511fn deploy_blame_entry_context_menu(
512 blame_entry: &BlameEntry,
513 details: Option<&ParsedCommitMessage>,
514 editor: Entity<Editor>,
515 position: gpui::Point<Pixels>,
516 window: &mut Window,
517 cx: &mut App,
518) {
519 let context_menu = ContextMenu::build(window, cx, move |menu, _, _| {
520 let sha = format!("{}", blame_entry.sha);
521 menu.on_blur_subscription(Subscription::new(|| {}))
522 .entry("Copy Commit SHA", None, move |_, cx| {
523 cx.write_to_clipboard(ClipboardItem::new_string(sha.clone()));
524 })
525 .when_some(
526 details.and_then(|details| details.permalink.clone()),
527 |this, url| {
528 this.entry("Open Permalink", None, move |_, cx| {
529 cx.open_url(url.as_str())
530 })
531 },
532 )
533 });
534
535 editor.update(cx, move |editor, cx| {
536 editor.hide_blame_popover(false, cx);
537 editor.deploy_mouse_context_menu(position, context_menu, window, cx);
538 cx.notify();
539 });
540}
541
542fn blame_entry_relative_timestamp(blame_entry: &BlameEntry) -> String {
543 match blame_entry.author_offset_date_time() {
544 Ok(timestamp) => {
545 let local_offset =
546 time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
547 time_format::format_localized_timestamp(
548 timestamp,
549 time::OffsetDateTime::now_utc(),
550 local_offset,
551 time_format::TimestampFormat::Relative,
552 )
553 }
554 Err(_) => "Error parsing date".to_string(),
555 }
556}
557