Skip to repository content1434 lines · 48.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:34:37.738Z 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.rs
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