Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:35:59.362Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

blame_ui.rs

557 lines · 22.5 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.