Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:34:37.738Z 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.rs

1434 lines · 48.6 KB · rust
1use crate::Editor;
2use anyhow::{Context as _, Result};
3use collections::HashMap;
4
5use git::{
6    GitHostingProviderRegistry, Oid,
7    blame::{Blame, BlameEntry},
8    commit::ParsedCommitMessage,
9};
10use gpui::{
11    AnyElement, App, AppContext as _, Context, Entity, Hsla, Pixels, ScrollHandle, SharedString,
12    Subscription, Task, TextStyle, WeakEntity, Window,
13};
14use itertools::Itertools;
15use language::{Bias, BufferSnapshot, Edit};
16use markdown::Markdown;
17use multi_buffer::{MultiBuffer, RowInfo};
18use project::{
19    Project, ProjectItem as _,
20    git_store::{GitStoreEvent, Repository},
21};
22use smallvec::SmallVec;
23use std::{sync::Arc, time::Duration};
24use sum_tree::SumTree;
25use text::BufferId;
26use workspace::Workspace;
27
28#[derive(Clone, Debug, Default)]
29pub struct GitBlameEntry {
30    pub rows: u32,
31    pub blame: Option<BlameEntry>,
32}
33
34#[derive(Clone, Debug, Default)]
35pub struct GitBlameEntrySummary {
36    rows: u32,
37}
38
39impl sum_tree::Item for GitBlameEntry {
40    type Summary = GitBlameEntrySummary;
41
42    fn summary(&self, _cx: ()) -> Self::Summary {
43        GitBlameEntrySummary { rows: self.rows }
44    }
45}
46
47impl sum_tree::ContextLessSummary for GitBlameEntrySummary {
48    fn zero() -> Self {
49        Default::default()
50    }
51
52    fn add_summary(&mut self, summary: &Self) {
53        self.rows += summary.rows;
54    }
55}
56
57impl<'a> sum_tree::Dimension<'a, GitBlameEntrySummary> for u32 {
58    fn zero(_cx: ()) -> Self {
59        Default::default()
60    }
61
62    fn add_summary(&mut self, summary: &'a GitBlameEntrySummary, _cx: ()) {
63        *self += summary.rows;
64    }
65}
66
67struct GitBlameBuffer {
68    entries: SumTree<GitBlameEntry>,
69    buffer_snapshot: BufferSnapshot,
70    buffer_edits: text::Subscription<usize>,
71    commit_details: HashMap<Oid, ParsedCommitMessage>,
72    commit_tag_names: HashMap<Oid, Vec<SharedString>>,
73}
74
75pub struct GitBlame {
76    project: Entity<Project>,
77    multi_buffer: WeakEntity<MultiBuffer>,
78    buffers: HashMap<BufferId, GitBlameBuffer>,
79    task: Task<Result<()>>,
80    focused: bool,
81    changed_while_blurred: bool,
82    user_triggered: bool,
83    regenerate_on_edit_task: Task<Result<()>>,
84    _regenerate_subscriptions: Vec<Subscription>,
85}
86
87pub trait BlameRenderer {
88    fn max_author_length(&self) -> usize;
89
90    fn blame_entry_non_text_width(&self, _: &Window, _: &App) -> Pixels {
91        Pixels::ZERO
92    }
93
94    fn render_blame_entry(
95        &self,
96        _: &TextStyle,
97        _: BlameEntry,
98        _: Option<ParsedCommitMessage>,
99        _: Vec<SharedString>,
100        _: Entity<Repository>,
101        _: WeakEntity<Workspace>,
102        _: Entity<Editor>,
103        _: usize,
104        _: Hsla,
105        window: &mut Window,
106        _: &mut App,
107    ) -> Option<AnyElement>;
108
109    fn render_inline_blame_entry(
110        &self,
111        _: &TextStyle,
112        _: BlameEntry,
113        _: &mut App,
114    ) -> Option<AnyElement>;
115
116    fn render_blame_entry_popover(
117        &self,
118        _: BlameEntry,
119        _: ScrollHandle,
120        _: Option<ParsedCommitMessage>,
121        _: Vec<SharedString>,
122        _: Entity<Markdown>,
123        _: Entity<Repository>,
124        _: WeakEntity<Workspace>,
125        _: &mut Window,
126        _: &mut App,
127    ) -> Option<AnyElement>;
128
129    fn open_blame_commit(
130        &self,
131        _: BlameEntry,
132        _: Entity<Repository>,
133        _: WeakEntity<Workspace>,
134        _: &mut Window,
135        _: &mut App,
136    );
137}
138
139impl BlameRenderer for () {
140    fn max_author_length(&self) -> usize {
141        0
142    }
143
144    fn render_blame_entry(
145        &self,
146        _: &TextStyle,
147        _: BlameEntry,
148        _: Option<ParsedCommitMessage>,
149        _: Vec<SharedString>,
150        _: Entity<Repository>,
151        _: WeakEntity<Workspace>,
152        _: Entity<Editor>,
153        _: usize,
154        _: Hsla,
155        _: &mut Window,
156        _: &mut App,
157    ) -> Option<AnyElement> {
158        None
159    }
160
161    fn render_inline_blame_entry(
162        &self,
163        _: &TextStyle,
164        _: BlameEntry,
165        _: &mut App,
166    ) -> Option<AnyElement> {
167        None
168    }
169
170    fn render_blame_entry_popover(
171        &self,
172        _: BlameEntry,
173        _: ScrollHandle,
174        _: Option<ParsedCommitMessage>,
175        _: Vec<SharedString>,
176        _: Entity<Markdown>,
177        _: Entity<Repository>,
178        _: WeakEntity<Workspace>,
179        _: &mut Window,
180        _: &mut App,
181    ) -> Option<AnyElement> {
182        None
183    }
184
185    fn open_blame_commit(
186        &self,
187        _: BlameEntry,
188        _: Entity<Repository>,
189        _: WeakEntity<Workspace>,
190        _: &mut Window,
191        _: &mut App,
192    ) {
193    }
194}
195
196pub(crate) struct GlobalBlameRenderer(pub Arc<dyn BlameRenderer>);
197
198impl gpui::Global for GlobalBlameRenderer {}
199
200impl GitBlame {
201    pub fn new(
202        multi_buffer: Entity<MultiBuffer>,
203        project: Entity<Project>,
204        user_triggered: bool,
205        focused: bool,
206        cx: &mut Context<Self>,
207    ) -> Self {
208        let multi_buffer_subscription = cx.subscribe(
209            &multi_buffer,
210            |git_blame, multi_buffer, event, cx| match event {
211                multi_buffer::Event::DirtyChanged => {
212                    if !multi_buffer.read(cx).is_dirty(cx) {
213                        git_blame.generate(cx);
214                    }
215                }
216                multi_buffer::Event::BufferRangesUpdated { .. }
217                | multi_buffer::Event::BuffersEdited { .. } => git_blame.regenerate_on_edit(cx),
218                _ => {}
219            },
220        );
221
222        let project_subscription = cx.subscribe(&project, {
223            let multi_buffer = multi_buffer.downgrade();
224
225            move |git_blame, _, event, cx| {
226                if let project::Event::WorktreeUpdatedEntries(_, updated) = event {
227                    let Some(multi_buffer) = multi_buffer.upgrade() else {
228                        return;
229                    };
230                    let project_entry_id = multi_buffer
231                        .read(cx)
232                        .as_singleton()
233                        .and_then(|it| it.read(cx).entry_id(cx));
234                    if updated
235                        .iter()
236                        .any(|(_, entry_id, _)| project_entry_id == Some(*entry_id))
237                    {
238                        log::debug!("Updated buffers. Regenerating blame data...",);
239                        git_blame.generate(cx);
240                    }
241                }
242            }
243        });
244
245        let git_store = project.read(cx).git_store().clone();
246        let git_store_subscription =
247            cx.subscribe(&git_store, move |this, _, event, cx| match event {
248                GitStoreEvent::RepositoryUpdated(_, _, _)
249                | GitStoreEvent::RepositoryAdded
250                | GitStoreEvent::RepositoryRemoved(_) => {
251                    log::debug!("Status of git repositories updated. Regenerating blame data...",);
252                    this.generate(cx);
253                }
254                _ => {}
255            });
256
257        let mut this = Self {
258            project,
259            multi_buffer: multi_buffer.downgrade(),
260            buffers: HashMap::default(),
261            user_triggered,
262            focused,
263            changed_while_blurred: false,
264            task: Task::ready(Ok(())),
265            regenerate_on_edit_task: Task::ready(Ok(())),
266            _regenerate_subscriptions: vec![
267                multi_buffer_subscription,
268                project_subscription,
269                git_store_subscription,
270            ],
271        };
272        this.generate(cx);
273        this
274    }
275
276    pub fn repository(&self, cx: &App, id: BufferId) -> Option<Entity<Repository>> {
277        self.project
278            .read(cx)
279            .git_store()
280            .read(cx)
281            .repository_and_path_for_buffer_id(id, cx)
282            .map(|(repo, _)| repo)
283    }
284
285    pub fn has_generated_entries(&self) -> bool {
286        !self.buffers.is_empty()
287    }
288
289    pub fn details_for_entry(
290        &self,
291        buffer: BufferId,
292        entry: &BlameEntry,
293    ) -> Option<ParsedCommitMessage> {
294        self.buffers
295            .get(&buffer)?
296            .commit_details
297            .get(&entry.sha)
298            .cloned()
299    }
300
301    pub fn tag_names_for_entry(&self, buffer: BufferId, entry: &BlameEntry) -> Vec<SharedString> {
302        self.buffers
303            .get(&buffer)
304            .and_then(|buffer| buffer.commit_tag_names.get(&entry.sha))
305            .cloned()
306            .unwrap_or_default()
307    }
308
309    pub fn blame_for_rows<'a>(
310        &'a mut self,
311        rows: &'a [RowInfo],
312        cx: &'a mut App,
313    ) -> impl Iterator<Item = Option<(BufferId, BlameEntry)>> + use<'a> {
314        rows.iter().map(move |info| {
315            let buffer_id = info.buffer_id?;
316            self.sync(cx, buffer_id);
317
318            let buffer_row = info.buffer_row?;
319            let mut cursor = self.buffers.get(&buffer_id)?.entries.cursor::<u32>(());
320            cursor.seek_forward(&buffer_row, Bias::Right);
321            Some((buffer_id, cursor.item()?.blame.clone()?))
322        })
323    }
324
325    pub fn max_author_length(&mut self, cx: &mut App) -> usize {
326        let mut max_author_length = 0;
327        self.sync_all(cx);
328
329        for buffer in self.buffers.values() {
330            for entry in buffer.entries.iter() {
331                let author_len = entry
332                    .blame
333                    .as_ref()
334                    .and_then(|entry| entry.author.as_ref())
335                    .map(|author| author.len());
336                if let Some(author_len) = author_len
337                    && author_len > max_author_length
338                {
339                    max_author_length = author_len;
340                }
341            }
342        }
343
344        max_author_length
345    }
346
347    pub fn blur(&mut self, _: &mut Context<Self>) {
348        self.focused = false;
349    }
350
351    pub fn focus(&mut self, cx: &mut Context<Self>) {
352        if self.focused {
353            return;
354        }
355        self.focused = true;
356        if self.changed_while_blurred {
357            self.changed_while_blurred = false;
358            self.generate(cx);
359        }
360    }
361
362    fn sync_all(&mut self, cx: &mut App) {
363        let Some(multi_buffer) = self.multi_buffer.upgrade() else {
364            return;
365        };
366        let snapshot = multi_buffer.read(cx).snapshot(cx);
367        for id in snapshot.all_buffer_ids() {
368            self.sync(cx, id)
369        }
370    }
371
372    fn sync(&mut self, cx: &mut App, buffer_id: BufferId) {
373        let Some(blame_buffer) = self.buffers.get_mut(&buffer_id) else {
374            return;
375        };
376        let Some(buffer) = self
377            .multi_buffer
378            .upgrade()
379            .and_then(|multi_buffer| multi_buffer.read(cx).buffer(buffer_id))
380        else {
381            return;
382        };
383        let edits = blame_buffer.buffer_edits.consume();
384        let new_snapshot = buffer.read(cx).snapshot();
385
386        let mut row_edits = edits
387            .into_iter()
388            .map(|edit| {
389                let old_point_range = blame_buffer.buffer_snapshot.offset_to_point(edit.old.start)
390                    ..blame_buffer.buffer_snapshot.offset_to_point(edit.old.end);
391                let new_point_range = new_snapshot.offset_to_point(edit.new.start)
392                    ..new_snapshot.offset_to_point(edit.new.end);
393
394                if old_point_range.start.column
395                    == blame_buffer
396                        .buffer_snapshot
397                        .line_len(old_point_range.start.row)
398                    && (new_snapshot.chars_at(edit.new.start).next() == Some('\n')
399                        || blame_buffer
400                            .buffer_snapshot
401                            .line_len(old_point_range.end.row)
402                            == 0)
403                {
404                    Edit {
405                        old: old_point_range.start.row + 1..old_point_range.end.row + 1,
406                        new: new_point_range.start.row + 1..new_point_range.end.row + 1,
407                    }
408                } else if old_point_range.start.column == 0
409                    && old_point_range.end.column == 0
410                    && new_point_range.end.column == 0
411                {
412                    Edit {
413                        old: old_point_range.start.row..old_point_range.end.row,
414                        new: new_point_range.start.row..new_point_range.end.row,
415                    }
416                } else {
417                    Edit {
418                        old: old_point_range.start.row..old_point_range.end.row + 1,
419                        new: new_point_range.start.row..new_point_range.end.row + 1,
420                    }
421                }
422            })
423            .peekable();
424
425        let mut new_entries = SumTree::default();
426        let mut cursor = blame_buffer.entries.cursor::<u32>(());
427
428        while let Some(mut edit) = row_edits.next() {
429            while let Some(next_edit) = row_edits.peek() {
430                if edit.old.end >= next_edit.old.start {
431                    edit.old.end = next_edit.old.end;
432                    edit.new.end = next_edit.new.end;
433                    row_edits.next();
434                } else {
435                    break;
436                }
437            }
438
439            new_entries.append(cursor.slice(&edit.old.start, Bias::Right), ());
440
441            if edit.new.start > new_entries.summary().rows {
442                new_entries.push(
443                    GitBlameEntry {
444                        rows: edit.new.start - new_entries.summary().rows,
445                        blame: cursor.item().and_then(|entry| entry.blame.clone()),
446                    },
447                    (),
448                );
449            }
450
451            cursor.seek(&edit.old.end, Bias::Right);
452            if !edit.new.is_empty() {
453                new_entries.push(
454                    GitBlameEntry {
455                        rows: edit.new.len() as u32,
456                        blame: None,
457                    },
458                    (),
459                );
460            }
461
462            let old_end = cursor.end();
463            if row_edits
464                .peek()
465                .is_none_or(|next_edit| next_edit.old.start >= old_end)
466                && let Some(entry) = cursor.item()
467            {
468                if old_end > edit.old.end {
469                    new_entries.push(
470                        GitBlameEntry {
471                            rows: cursor.end() - edit.old.end,
472                            blame: entry.blame.clone(),
473                        },
474                        (),
475                    );
476                }
477
478                cursor.next();
479            }
480        }
481        new_entries.append(cursor.suffix(), ());
482        drop(cursor);
483
484        blame_buffer.buffer_snapshot = new_snapshot;
485        blame_buffer.entries = new_entries;
486    }
487
488    #[cfg(test)]
489    fn check_invariants(&mut self, cx: &mut Context<Self>) {
490        self.sync_all(cx);
491        for (&id, buffer) in &self.buffers {
492            assert_eq!(
493                buffer.entries.summary().rows,
494                self.multi_buffer
495                    .upgrade()
496                    .unwrap()
497                    .read(cx)
498                    .buffer(id)
499                    .unwrap()
500                    .read(cx)
501                    .max_point()
502                    .row
503                    + 1
504            );
505        }
506    }
507
508    #[ztracing::instrument(skip_all)]
509    fn generate(&mut self, cx: &mut Context<Self>) {
510        if !self.focused {
511            self.changed_while_blurred = true;
512            return;
513        }
514        let buffers_to_blame = self
515            .multi_buffer
516            .update(cx, |multi_buffer, cx| {
517                let snapshot = multi_buffer.snapshot(cx);
518                snapshot
519                    .all_buffer_ids()
520                    .filter_map(|id| Some(multi_buffer.buffer(id)?.downgrade()))
521                    .collect::<Vec<_>>()
522            })
523            .unwrap_or_default();
524        let project = self.project.downgrade();
525
526        self.task = cx.spawn(async move |this, cx| {
527            let mut all_results = Vec::new();
528            let mut all_errors = Vec::new();
529
530            for buffers in buffers_to_blame.chunks(4) {
531                let span = ztracing::debug_span!("for each chunk of buffers");
532                let _enter = span.enter();
533                let blame = cx.update(|cx| {
534                    buffers
535                        .iter()
536                        .map(|buffer| {
537                            let buffer = buffer.upgrade().context("buffer was dropped")?;
538                            let project = project.upgrade().context("project was dropped")?;
539                            let id = buffer.read(cx).remote_id();
540                            let snapshot = buffer.read(cx).snapshot();
541                            let buffer_edits = buffer.update(cx, |buffer, _| buffer.subscribe());
542
543                            let repository = project
544                                .read(cx)
545                                .git_store()
546                                .read(cx)
547                                .repository_and_path_for_buffer_id(id, cx);
548
549                            let remote_url = repository
550                                .as_ref()
551                                .and_then(|(repo, _)| repo.read(cx).default_remote_url());
552
553                            let blame_buffer = if repository.is_some() {
554                                project.update(cx, |project, cx| {
555                                    project.blame_buffer(&buffer, None, cx)
556                                })
557                            } else {
558                                Task::ready(Ok(None))
559                            };
560
561                            Ok(async move {
562                                (id, snapshot, buffer_edits, blame_buffer.await, remote_url)
563                            })
564                        })
565                        .collect::<Result<Vec<_>>>()
566                })?;
567                let provider_registry =
568                    cx.update(|cx| GitHostingProviderRegistry::default_global(cx));
569                let (results, errors) = cx
570                    .background_spawn({
571                        async move {
572                            let blame = futures::future::join_all(blame).await;
573                            let mut res = vec![];
574                            let mut errors = vec![];
575                            for (id, snapshot, buffer_edits, blame, remote_url) in blame {
576                                match blame {
577                                    Ok(Some(Blame {
578                                        entries,
579                                        messages,
580                                        tag_names,
581                                    })) => {
582                                        let entries = build_blame_entry_sum_tree(
583                                            entries,
584                                            snapshot.max_point().row,
585                                        );
586                                        let commit_details = messages
587                                            .into_iter()
588                                            .map(|(oid, message)| {
589                                                let parsed_commit_message =
590                                                    ParsedCommitMessage::parse(
591                                                        oid.to_string(),
592                                                        message,
593                                                        remote_url.as_deref(),
594                                                        Some(provider_registry.clone()),
595                                                    );
596                                                (oid, parsed_commit_message)
597                                            })
598                                            .collect();
599                                        let commit_tag_names = tag_names
600                                            .into_iter()
601                                            .map(|(oid, tag_names)| {
602                                                (
603                                                    oid,
604                                                    tag_names
605                                                        .into_iter()
606                                                        .map(SharedString::from)
607                                                        .collect(),
608                                                )
609                                            })
610                                            .collect();
611                                        res.push((
612                                            id,
613                                            snapshot,
614                                            buffer_edits,
615                                            Some(entries),
616                                            commit_details,
617                                            commit_tag_names,
618                                        ));
619                                    }
620                                    Ok(None) => res.push((
621                                        id,
622                                        snapshot,
623                                        buffer_edits,
624                                        None,
625                                        Default::default(),
626                                        Default::default(),
627                                    )),
628                                    Err(e) => errors.push(e),
629                                }
630                            }
631                            (res, errors)
632                        }
633                    })
634                    .await;
635                all_results.extend(results);
636                all_errors.extend(errors)
637            }
638
639            this.update(cx, |this, cx| {
640                this.buffers.clear();
641                for (id, snapshot, buffer_edits, entries, commit_details, commit_tag_names) in
642                    all_results
643                {
644                    let Some(entries) = entries else {
645                        continue;
646                    };
647                    this.buffers.insert(
648                        id,
649                        GitBlameBuffer {
650                            buffer_edits,
651                            buffer_snapshot: snapshot,
652                            entries,
653                            commit_details,
654                            commit_tag_names,
655                        },
656                    );
657                }
658                cx.notify();
659                if !all_errors.is_empty() {
660                    this.project.update(cx, |_, cx| {
661                        let all_errors = all_errors
662                            .into_iter()
663                            .map(|e| format!("{e:#}"))
664                            .dedup()
665                            .collect::<Vec<_>>();
666                        let all_errors = all_errors.join(", ");
667                        if this.user_triggered {
668                            log::error!("failed to get git blame data: {all_errors}");
669                            cx.emit(project::Event::Toast {
670                                notification_id: "git-blame".into(),
671                                message: all_errors,
672                                link: None,
673                            });
674                        } else {
675                            // If we weren't triggered by a user, we just log errors in the background, instead of sending
676                            // notifications.
677                            log::debug!("failed to get git blame data: {all_errors}");
678                        }
679                    })
680                }
681            })
682        });
683    }
684
685    fn regenerate_on_edit(&mut self, cx: &mut Context<Self>) {
686        // todo(lw): hot foreground spawn
687        self.regenerate_on_edit_task = cx.spawn(async move |this, cx| {
688            cx.background_executor()
689                .timer(REGENERATE_ON_EDIT_DEBOUNCE_INTERVAL)
690                .await;
691
692            this.update(cx, |this, cx| {
693                this.generate(cx);
694            })
695        });
696    }
697}
698
699const REGENERATE_ON_EDIT_DEBOUNCE_INTERVAL: Duration = Duration::from_secs(2);
700
701fn build_blame_entry_sum_tree(entries: Vec<BlameEntry>, max_row: u32) -> SumTree<GitBlameEntry> {
702    let mut current_row = 0;
703    let mut entries = SumTree::from_iter(
704        entries.into_iter().flat_map(|entry| {
705            let mut entries = SmallVec::<[GitBlameEntry; 2]>::new();
706
707            if entry.range.start > current_row {
708                let skipped_rows = entry.range.start - current_row;
709                entries.push(GitBlameEntry {
710                    rows: skipped_rows,
711                    blame: None,
712                });
713            }
714            entries.push(GitBlameEntry {
715                rows: entry.range.len() as u32,
716                blame: Some(entry.clone()),
717            });
718
719            current_row = entry.range.end;
720            entries
721        }),
722        (),
723    );
724
725    if max_row >= current_row {
726        entries.push(
727            GitBlameEntry {
728                rows: (max_row + 1) - current_row,
729                blame: None,
730            },
731            (),
732        );
733    }
734
735    entries
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741    use git::repository::repo_path;
742    use gpui::Context;
743    use language::{Point, Rope};
744    use project::FakeFs;
745    use rand::prelude::*;
746    use serde_json::json;
747    use settings::SettingsStore;
748    use std::{cmp, env, ops::Range, path::Path, sync::Mutex};
749    use text::BufferId;
750    use unindent::Unindent as _;
751    use util::{RandomCharIter, path};
752
753    // macro_rules! assert_blame_rows {
754    //     ($blame:expr, $rows:expr, $expected:expr, $cx:expr) => {
755    //         assert_eq!(
756    //             $blame
757    //                 .blame_for_rows($rows.map(MultiBufferRow).map(Some), $cx)
758    //                 .collect::<Vec<_>>(),
759    //             $expected
760    //         );
761    //     };
762    // }
763
764    #[track_caller]
765    fn assert_blame_rows(
766        blame: &mut GitBlame,
767        buffer_id: BufferId,
768        rows: Range<u32>,
769        expected: Vec<Option<BlameEntry>>,
770        cx: &mut Context<GitBlame>,
771    ) {
772        pretty_assertions::assert_eq!(
773            blame
774                .blame_for_rows(
775                    &rows
776                        .map(|row| RowInfo {
777                            buffer_row: Some(row),
778                            buffer_id: Some(buffer_id),
779                            ..Default::default()
780                        })
781                        .collect::<Vec<_>>(),
782                    cx
783                )
784                .collect::<Vec<_>>(),
785            expected
786                .into_iter()
787                .map(|it| Some((buffer_id, it?)))
788                .collect::<Vec<_>>()
789        );
790    }
791
792    fn init_test(cx: &mut gpui::TestAppContext) {
793        cx.update(|cx| {
794            let settings = SettingsStore::test(cx);
795            cx.set_global(settings);
796
797            theme_settings::init(theme::LoadThemes::JustBase, cx);
798
799            crate::init(cx);
800        });
801    }
802
803    #[gpui::test]
804    async fn test_blame_error_notifications(cx: &mut gpui::TestAppContext) {
805        init_test(cx);
806
807        let fs = FakeFs::new(cx.executor());
808        fs.insert_tree(
809            "/my-repo",
810            json!({
811                ".git": {},
812                "file.txt": r#"
813                    irrelevant contents
814                "#
815                .unindent()
816            }),
817        )
818        .await;
819
820        // Creating a GitBlame without a corresponding blame state
821        // will result in an error.
822
823        let project = Project::test(fs, ["/my-repo".as_ref()], cx).await;
824        let buffer = project
825            .update(cx, |project, cx| {
826                project.open_local_buffer("/my-repo/file.txt", cx)
827            })
828            .await
829            .unwrap();
830        let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
831
832        let blame = cx.new(|cx| GitBlame::new(buffer.clone(), project.clone(), true, true, cx));
833
834        let event = project.next_event(cx).await;
835        assert_eq!(
836            event,
837            project::Event::Toast {
838                notification_id: "git-blame".into(),
839                message: "Failed to blame \"file.txt\": failed to get blame for \"file.txt\""
840                    .to_string(),
841                link: None
842            }
843        );
844
845        blame.update(cx, |blame, cx| {
846            assert_eq!(
847                blame
848                    .blame_for_rows(
849                        &(0..1)
850                            .map(|row| RowInfo {
851                                buffer_row: Some(row),
852                                ..Default::default()
853                            })
854                            .collect::<Vec<_>>(),
855                        cx
856                    )
857                    .collect::<Vec<_>>(),
858                vec![None]
859            );
860        });
861    }
862
863    #[gpui::test]
864    async fn test_blame_ignores_buffers_outside_git_repositories(cx: &mut gpui::TestAppContext) {
865        init_test(cx);
866
867        let fs = FakeFs::new(cx.executor());
868
869        fs.insert_tree(
870            "/not-a-repo",
871            json!({
872                "foo": "bar",
873            }),
874        )
875        .await;
876
877        let project = Project::test(fs, ["/not-a-repo".as_ref()], cx).await;
878
879        let buffer = project
880            .update(cx, |project, cx| {
881                project.open_local_buffer("/not-a-repo/foo", cx)
882            })
883            .await
884            .unwrap();
885
886        let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
887
888        let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
889
890        let events = Arc::new(Mutex::new(Vec::new()));
891
892        let _subscription = project.update(cx, |_, cx| {
893            cx.subscribe(&project, {
894                let events = events.clone();
895
896                move |_, _, event: &project::Event, _| {
897                    events
898                        .lock()
899                        .expect("events mutex poisoned")
900                        .push(event.clone());
901                }
902            })
903        });
904
905        let blame = cx.new(|cx| GitBlame::new(buffer.clone(), project.clone(), true, true, cx));
906
907        cx.executor().run_until_parked();
908
909        assert!(events.lock().expect("events mutex poisoned").is_empty());
910
911        blame.update(cx, |blame, cx| {
912            assert_eq!(
913                blame
914                    .blame_for_rows(
915                        &(0..1)
916                            .map(|row| RowInfo {
917                                buffer_row: Some(row),
918                                buffer_id: Some(buffer_id),
919                                ..Default::default()
920                            })
921                            .collect::<Vec<_>>(),
922                        cx
923                    )
924                    .collect::<Vec<_>>(),
925                vec![None]
926            );
927        });
928    }
929
930    #[gpui::test]
931    async fn test_blame_for_rows(cx: &mut gpui::TestAppContext) {
932        init_test(cx);
933
934        let fs = FakeFs::new(cx.executor());
935        fs.insert_tree(
936            "/my-repo",
937            json!({
938                ".git": {},
939                "file.txt": r#"
940                    AAA Line 1
941                    BBB Line 2 - Modified 1
942                    CCC Line 3 - Modified 2
943                    modified in memory 1
944                    modified in memory 1
945                    DDD Line 4 - Modified 2
946                    EEE Line 5 - Modified 1
947                    FFF Line 6 - Modified 2
948                "#
949                .unindent()
950            }),
951        )
952        .await;
953
954        fs.set_blame_for_repo(
955            Path::new("/my-repo/.git"),
956            vec![(
957                repo_path("file.txt"),
958                Blame {
959                    entries: vec![
960                        blame_entry("1b1b1b", 0..1),
961                        blame_entry("0d0d0d", 1..2),
962                        blame_entry("3a3a3a", 2..3),
963                        blame_entry("3a3a3a", 5..6),
964                        blame_entry("0d0d0d", 6..7),
965                        blame_entry("3a3a3a", 7..8),
966                    ],
967                    ..Default::default()
968                },
969            )],
970        );
971        let project = Project::test(fs, ["/my-repo".as_ref()], cx).await;
972        let buffer = project
973            .update(cx, |project, cx| {
974                project.open_local_buffer("/my-repo/file.txt", cx)
975            })
976            .await
977            .unwrap();
978        let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
979        let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
980
981        let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
982
983        cx.executor().run_until_parked();
984
985        git_blame.update(cx, |blame, cx| {
986            // All lines
987            pretty_assertions::assert_eq!(
988                blame
989                    .blame_for_rows(
990                        &(0..8)
991                            .map(|buffer_row| RowInfo {
992                                buffer_row: Some(buffer_row),
993                                buffer_id: Some(buffer_id),
994                                ..Default::default()
995                            })
996                            .collect::<Vec<_>>(),
997                        cx
998                    )
999                    .collect::<Vec<_>>(),
1000                vec![
1001                    Some((buffer_id, blame_entry("1b1b1b", 0..1))),
1002                    Some((buffer_id, blame_entry("0d0d0d", 1..2))),
1003                    Some((buffer_id, blame_entry("3a3a3a", 2..3))),
1004                    None,
1005                    None,
1006                    Some((buffer_id, blame_entry("3a3a3a", 5..6))),
1007                    Some((buffer_id, blame_entry("0d0d0d", 6..7))),
1008                    Some((buffer_id, blame_entry("3a3a3a", 7..8))),
1009                ]
1010            );
1011            // Subset of lines
1012            pretty_assertions::assert_eq!(
1013                blame
1014                    .blame_for_rows(
1015                        &(1..4)
1016                            .map(|buffer_row| RowInfo {
1017                                buffer_row: Some(buffer_row),
1018                                buffer_id: Some(buffer_id),
1019                                ..Default::default()
1020                            })
1021                            .collect::<Vec<_>>(),
1022                        cx
1023                    )
1024                    .collect::<Vec<_>>(),
1025                vec![
1026                    Some((buffer_id, blame_entry("0d0d0d", 1..2))),
1027                    Some((buffer_id, blame_entry("3a3a3a", 2..3))),
1028                    None
1029                ]
1030            );
1031            // Subset of lines, with some not displayed
1032            pretty_assertions::assert_eq!(
1033                blame
1034                    .blame_for_rows(
1035                        &[
1036                            RowInfo {
1037                                buffer_row: Some(1),
1038                                buffer_id: Some(buffer_id),
1039                                ..Default::default()
1040                            },
1041                            Default::default(),
1042                            Default::default(),
1043                        ],
1044                        cx
1045                    )
1046                    .collect::<Vec<_>>(),
1047                vec![Some((buffer_id, blame_entry("0d0d0d", 1..2))), None, None]
1048            );
1049        });
1050    }
1051
1052    #[gpui::test]
1053    async fn test_blame_for_rows_with_edits(cx: &mut gpui::TestAppContext) {
1054        init_test(cx);
1055
1056        let fs = FakeFs::new(cx.executor());
1057        fs.insert_tree(
1058            path!("/my-repo"),
1059            json!({
1060                ".git": {},
1061                "file.txt": r#"
1062                    Line 1
1063                    Line 2
1064                    Line 3
1065                "#
1066                .unindent()
1067            }),
1068        )
1069        .await;
1070
1071        fs.set_blame_for_repo(
1072            Path::new(path!("/my-repo/.git")),
1073            vec![(
1074                repo_path("file.txt"),
1075                Blame {
1076                    entries: vec![blame_entry("1b1b1b", 0..4)],
1077                    ..Default::default()
1078                },
1079            )],
1080        );
1081
1082        let project = Project::test(fs, [path!("/my-repo").as_ref()], cx).await;
1083        let buffer = project
1084            .update(cx, |project, cx| {
1085                project.open_local_buffer(path!("/my-repo/file.txt"), cx)
1086            })
1087            .await
1088            .unwrap();
1089        let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id());
1090        let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
1091
1092        let git_blame = cx.new(|cx| GitBlame::new(buffer.clone(), project, false, true, cx));
1093
1094        cx.executor().run_until_parked();
1095
1096        git_blame.update(cx, |blame, cx| {
1097            // Sanity check before edits: make sure that we get the same blame entry for all
1098            // lines.
1099            assert_blame_rows(
1100                blame,
1101                buffer_id,
1102                0..4,
1103                vec![
1104                    Some(blame_entry("1b1b1b", 0..4)),
1105                    Some(blame_entry("1b1b1b", 0..4)),
1106                    Some(blame_entry("1b1b1b", 0..4)),
1107                    Some(blame_entry("1b1b1b", 0..4)),
1108                ],
1109                cx,
1110            );
1111        });
1112
1113        // Modify a single line, at the start of the line
1114        buffer.update(cx, |buffer, cx| {
1115            buffer.edit([(Point::new(0, 0)..Point::new(0, 0), "X")], None, cx);
1116        });
1117        git_blame.update(cx, |blame, cx| {
1118            assert_blame_rows(
1119                blame,
1120                buffer_id,
1121                0..2,
1122                vec![None, Some(blame_entry("1b1b1b", 0..4))],
1123                cx,
1124            );
1125        });
1126        // Modify a single line, in the middle of the line
1127        buffer.update(cx, |buffer, cx| {
1128            buffer.edit([(Point::new(1, 2)..Point::new(1, 2), "X")], None, cx);
1129        });
1130        git_blame.update(cx, |blame, cx| {
1131            assert_blame_rows(
1132                blame,
1133                buffer_id,
1134                1..4,
1135                vec![
1136                    None,
1137                    Some(blame_entry("1b1b1b", 0..4)),
1138                    Some(blame_entry("1b1b1b", 0..4)),
1139                ],
1140                cx,
1141            );
1142        });
1143
1144        // Before we insert a newline at the end, sanity check:
1145        git_blame.update(cx, |blame, cx| {
1146            assert_blame_rows(
1147                blame,
1148                buffer_id,
1149                3..4,
1150                vec![Some(blame_entry("1b1b1b", 0..4))],
1151                cx,
1152            );
1153        });
1154        // Insert a newline at the end
1155        buffer.update(cx, |buffer, cx| {
1156            buffer.edit([(Point::new(3, 6)..Point::new(3, 6), "\n")], None, cx);
1157        });
1158        // Only the new line is marked as edited:
1159        git_blame.update(cx, |blame, cx| {
1160            assert_blame_rows(
1161                blame,
1162                buffer_id,
1163                3..5,
1164                vec![Some(blame_entry("1b1b1b", 0..4)), None],
1165                cx,
1166            );
1167        });
1168
1169        // Before we insert a newline at the start, sanity check:
1170        git_blame.update(cx, |blame, cx| {
1171            assert_blame_rows(
1172                blame,
1173                buffer_id,
1174                2..3,
1175                vec![Some(blame_entry("1b1b1b", 0..4))],
1176                cx,
1177            );
1178        });
1179
1180        // Usage example
1181        // Insert a newline at the start of the row
1182        buffer.update(cx, |buffer, cx| {
1183            buffer.edit([(Point::new(2, 0)..Point::new(2, 0), "\n")], None, cx);
1184        });
1185        // Only the new line is marked as edited:
1186        git_blame.update(cx, |blame, cx| {
1187            assert_blame_rows(
1188                blame,
1189                buffer_id,
1190                2..4,
1191                vec![None, Some(blame_entry("1b1b1b", 0..4))],
1192                cx,
1193            );
1194        });
1195    }
1196
1197    #[gpui::test(iterations = 100)]
1198    async fn test_blame_random(mut rng: StdRng, cx: &mut gpui::TestAppContext) {
1199        let operations = env::var("OPERATIONS")
1200            .map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
1201            .unwrap_or(10);
1202        let max_edits_per_operation = env::var("MAX_EDITS_PER_OPERATION")
1203            .map(|i| {
1204                i.parse()
1205                    .expect("invalid `MAX_EDITS_PER_OPERATION` variable")
1206            })
1207            .unwrap_or(5);
1208
1209        init_test(cx);
1210
1211        let fs = FakeFs::new(cx.executor());
1212        let buffer_initial_text_len = rng.random_range(5..15);
1213        let mut buffer_initial_text = Rope::from(
1214            RandomCharIter::new(&mut rng)
1215                .take(buffer_initial_text_len)
1216                .collect::<String>()
1217                .as_str(),
1218        );
1219
1220        let mut newline_ixs = (0..buffer_initial_text_len).choose_multiple(&mut rng, 5);
1221        newline_ixs.sort_unstable();
1222        for newline_ix in newline_ixs.into_iter().rev() {
1223            let newline_ix = buffer_initial_text.clip_offset(newline_ix, Bias::Right);
1224            buffer_initial_text.replace(newline_ix..newline_ix, "\n");
1225        }
1226        log::info!("initial buffer text: {:?}", buffer_initial_text);
1227
1228        fs.insert_tree(
1229            path!("/my-repo"),
1230            json!({
1231                ".git": {},
1232                "file.txt": buffer_initial_text.to_string()
1233            }),
1234        )
1235        .await;
1236
1237        let blame_entries = gen_blame_entries(buffer_initial_text.max_point().row, &mut rng);
1238        log::info!("initial blame entries: {:?}", blame_entries);
1239        fs.set_blame_for_repo(
1240            Path::new(path!("/my-repo/.git")),
1241            vec![(
1242                repo_path("file.txt"),
1243                Blame {
1244                    entries: blame_entries,
1245                    ..Default::default()
1246                },
1247            )],
1248        );
1249
1250        let project = Project::test(fs.clone(), [path!("/my-repo").as_ref()], cx).await;
1251        let buffer = project
1252            .update(cx, |project, cx| {
1253                project.open_local_buffer(path!("/my-repo/file.txt"), cx)
1254            })
1255            .await
1256            .unwrap();
1257        let mbuffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
1258
1259        let git_blame = cx.new(|cx| GitBlame::new(mbuffer.clone(), project, false, true, cx));
1260        cx.executor().run_until_parked();
1261        git_blame.update(cx, |blame, cx| blame.check_invariants(cx));
1262
1263        for _ in 0..operations {
1264            match rng.random_range(0..100) {
1265                0..=19 => {
1266                    log::info!("quiescing");
1267                    cx.executor().run_until_parked();
1268                }
1269                20..=69 => {
1270                    log::info!("editing buffer");
1271                    buffer.update(cx, |buffer, cx| {
1272                        buffer.randomly_edit(&mut rng, max_edits_per_operation, cx);
1273                        log::info!("buffer text: {:?}", buffer.text());
1274                    });
1275
1276                    let blame_entries = gen_blame_entries(
1277                        buffer.read_with(cx, |buffer, _| buffer.max_point().row),
1278                        &mut rng,
1279                    );
1280                    log::info!("regenerating blame entries: {:?}", blame_entries);
1281
1282                    fs.set_blame_for_repo(
1283                        Path::new(path!("/my-repo/.git")),
1284                        vec![(
1285                            repo_path("file.txt"),
1286                            Blame {
1287                                entries: blame_entries,
1288                                ..Default::default()
1289                            },
1290                        )],
1291                    );
1292                }
1293                _ => {
1294                    git_blame.update(cx, |blame, cx| blame.check_invariants(cx));
1295                }
1296            }
1297        }
1298
1299        git_blame.update(cx, |blame, cx| blame.check_invariants(cx));
1300    }
1301
1302    fn gen_blame_entries(max_row: u32, rng: &mut StdRng) -> Vec<BlameEntry> {
1303        let mut last_row = 0;
1304        let mut blame_entries = Vec::new();
1305        for ix in 0..5 {
1306            if last_row < max_row {
1307                let row_start = rng.random_range(last_row..max_row);
1308                let row_end = rng.random_range(row_start + 1..cmp::min(row_start + 3, max_row) + 1);
1309                blame_entries.push(blame_entry(&ix.to_string(), row_start..row_end));
1310                last_row = row_end;
1311            } else {
1312                break;
1313            }
1314        }
1315        blame_entries
1316    }
1317
1318    fn blame_entry(sha: &str, range: Range<u32>) -> BlameEntry {
1319        BlameEntry {
1320            sha: sha.parse().unwrap(),
1321            range,
1322            original_line_number: 0,
1323            author: None,
1324            author_mail: None,
1325            author_time: None,
1326            author_tz: None,
1327            committer_name: None,
1328            committer_email: None,
1329            committer_time: None,
1330            committer_tz: None,
1331            summary: None,
1332            previous: None,
1333            filename: String::new(),
1334        }
1335    }
1336
1337    #[gpui::test]
1338    async fn test_blame_hover_shows_popover_on_first_trigger(cx: &mut gpui::TestAppContext) {
1339        init_test(cx);
1340
1341        cx.update(|cx| {
1342            use gpui::UpdateGlobal;
1343            settings::SettingsStore::update_global(
1344                cx,
1345                |store: &mut settings::SettingsStore, cx| {
1346                    store
1347                        .set_user_settings(r#"{"git": {"inline_blame": {"enabled": false}}}"#, cx)
1348                        .expect("failed to set user settings");
1349                },
1350            );
1351        });
1352
1353        let fs = FakeFs::new(cx.executor());
1354        fs.insert_tree(
1355            "/my-repo",
1356            json!({
1357                ".git": {},
1358                "file.txt": "line 1\nline 2\nline 3\n"
1359            }),
1360        )
1361        .await;
1362
1363        fs.set_blame_for_repo(
1364            Path::new("/my-repo/.git"),
1365            vec![(
1366                repo_path("file.txt"),
1367                Blame {
1368                    entries: vec![
1369                        blame_entry("1b1b1b", 0..1),
1370                        blame_entry("2c2c2c", 1..2),
1371                        blame_entry("3d3d3d", 2..3),
1372                    ],
1373                    ..Default::default()
1374                },
1375            )],
1376        );
1377
1378        let project = project::Project::test(fs, ["/my-repo".as_ref()], cx).await;
1379        let buffer = project
1380            .update(cx, |project, cx| {
1381                project.open_local_buffer("/my-repo/file.txt", cx)
1382            })
1383            .await
1384            .unwrap();
1385        let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
1386
1387        let (editor, cx) = cx.add_window_view(|window, cx| {
1388            crate::test::build_editor_with_project(project, multi_buffer, window, cx)
1389        });
1390
1391        // Verify blame is not loaded yet
1392        editor.update(cx, |editor, _cx| {
1393            assert!(
1394                editor.blame().is_none(),
1395                "blame should not be loaded initially"
1396            );
1397        });
1398
1399        // Focus the editor so that blame generation proceeds
1400        editor.update_in(cx, |editor, window, cx| {
1401            editor.focus_handle.focus(window, cx);
1402        });
1403
1404        // Trigger BlameHover — this should start blame loading and defer showing the popover
1405        editor.update_in(cx, |editor, window, cx| {
1406            assert!(editor.blame().is_none());
1407            editor.blame_hover(&crate::BlameHover, window, cx);
1408            assert!(
1409                editor.blame().is_some(),
1410                "blame entity should be created after blame_hover"
1411            );
1412            assert!(
1413                editor.pending_blame_hover_observation.is_some(),
1414                "should have registered an observation to wait for blame data"
1415            );
1416        });
1417
1418        // Let the async blame generation complete
1419        cx.run_until_parked();
1420
1421        // The observation should have fired and cleaned itself up
1422        editor.update(cx, |editor, cx| {
1423            assert!(
1424                editor.pending_blame_hover_observation.is_none(),
1425                "observation should be consumed after blame data is generated"
1426            );
1427            assert!(
1428                editor.blame().unwrap().read(cx).has_generated_entries(),
1429                "blame should have generated entries"
1430            );
1431        });
1432    }
1433}
1434
Served at tenant.openagents/omega Member data and write actions are omitted.