This adds a new action to the editor: `editor: toggle git blame`. When used it turns on a sidebar containing `git blame` information for the currently open buffer. The git blame information is updated when the buffer changes. It handles additions, deletions, modifications, changes to the underlying git data (new commits, changed commits, ...), file saves. It also handles folding and wrapping lines correctly. When the user hovers over a commit, a tooltip displays information for the commit that introduced the line. If the repository has a remote with the name `origin` configured, then clicking on a blame entry opens the permalink to the commit on the code host. Users can right-click on a blame entry to get a context menu which allows them to copy the SHA of the commit. The feature also works on shared projects, e.g. when collaborating a peer can request `git blame` data. As of this PR, Zed now comes bundled with a `git` binary so that users don't have to have `git` installed locally to use this feature. ### Screenshots    ### TODOs - [x] Bundling `git` binary ### Release Notes Release Notes: - Added `editor: toggle git blame` command that toggles a sidebar with git blame information for the current buffer. --------- Co-authored-by: Antonio <antonio@zed.dev> Co-authored-by: Piotr <piotr@zed.dev> Co-authored-by: Bennet <bennetbo@gmx.de> Co-authored-by: Mikayla <mikayla@zed.dev>
286 lines
8.7 KiB
Rust
286 lines
8.7 KiB
Rust
pub mod blame;
|
|
|
|
use std::ops::Range;
|
|
|
|
use git::diff::{DiffHunk, DiffHunkStatus};
|
|
use language::Point;
|
|
|
|
use crate::{
|
|
display_map::{DisplaySnapshot, ToDisplayPoint},
|
|
AnchorRangeExt,
|
|
};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum DisplayDiffHunk {
|
|
Folded {
|
|
display_row: u32,
|
|
},
|
|
|
|
Unfolded {
|
|
display_row_range: Range<u32>,
|
|
status: DiffHunkStatus,
|
|
},
|
|
}
|
|
|
|
impl DisplayDiffHunk {
|
|
pub fn start_display_row(&self) -> u32 {
|
|
match self {
|
|
&DisplayDiffHunk::Folded { display_row } => display_row,
|
|
DisplayDiffHunk::Unfolded {
|
|
display_row_range, ..
|
|
} => display_row_range.start,
|
|
}
|
|
}
|
|
|
|
pub fn contains_display_row(&self, display_row: u32) -> bool {
|
|
let range = match self {
|
|
&DisplayDiffHunk::Folded { display_row } => display_row..=display_row,
|
|
|
|
DisplayDiffHunk::Unfolded {
|
|
display_row_range, ..
|
|
} => display_row_range.start..=display_row_range.end,
|
|
};
|
|
|
|
range.contains(&display_row)
|
|
}
|
|
}
|
|
|
|
pub fn diff_hunk_to_display(hunk: DiffHunk<u32>, snapshot: &DisplaySnapshot) -> DisplayDiffHunk {
|
|
let hunk_start_point = Point::new(hunk.associated_range.start, 0);
|
|
let hunk_start_point_sub = Point::new(hunk.associated_range.start.saturating_sub(1), 0);
|
|
let hunk_end_point_sub = Point::new(
|
|
hunk.associated_range
|
|
.end
|
|
.saturating_sub(1)
|
|
.max(hunk.associated_range.start),
|
|
0,
|
|
);
|
|
|
|
let is_removal = hunk.status() == DiffHunkStatus::Removed;
|
|
|
|
let folds_start = Point::new(hunk.associated_range.start.saturating_sub(2), 0);
|
|
let folds_end = Point::new(hunk.associated_range.end + 2, 0);
|
|
let folds_range = folds_start..folds_end;
|
|
|
|
let containing_fold = snapshot.folds_in_range(folds_range).find(|fold| {
|
|
let fold_point_range = fold.range.to_point(&snapshot.buffer_snapshot);
|
|
let fold_point_range = fold_point_range.start..=fold_point_range.end;
|
|
|
|
let folded_start = fold_point_range.contains(&hunk_start_point);
|
|
let folded_end = fold_point_range.contains(&hunk_end_point_sub);
|
|
let folded_start_sub = fold_point_range.contains(&hunk_start_point_sub);
|
|
|
|
(folded_start && folded_end) || (is_removal && folded_start_sub)
|
|
});
|
|
|
|
if let Some(fold) = containing_fold {
|
|
let row = fold.range.start.to_display_point(snapshot).row();
|
|
DisplayDiffHunk::Folded { display_row: row }
|
|
} else {
|
|
let start = hunk_start_point.to_display_point(snapshot).row();
|
|
|
|
let hunk_end_row = hunk.associated_range.end.max(hunk.associated_range.start);
|
|
let hunk_end_point = Point::new(hunk_end_row, 0);
|
|
let end = hunk_end_point.to_display_point(snapshot).row();
|
|
|
|
DisplayDiffHunk::Unfolded {
|
|
display_row_range: start..end,
|
|
status: hunk.status(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::editor_tests::init_test;
|
|
use crate::Point;
|
|
use gpui::{Context, TestAppContext};
|
|
use language::Capability::ReadWrite;
|
|
use multi_buffer::{ExcerptRange, MultiBuffer};
|
|
use project::{FakeFs, Project};
|
|
use unindent::Unindent;
|
|
#[gpui::test]
|
|
async fn test_diff_hunks_in_range(cx: &mut TestAppContext) {
|
|
use git::diff::DiffHunkStatus;
|
|
init_test(cx, |_| {});
|
|
|
|
let fs = FakeFs::new(cx.background_executor.clone());
|
|
let project = Project::test(fs, [], cx).await;
|
|
|
|
// buffer has two modified hunks with two rows each
|
|
let buffer_1 = project
|
|
.update(cx, |project, cx| {
|
|
project.create_buffer(
|
|
"
|
|
1.zero
|
|
1.ONE
|
|
1.TWO
|
|
1.three
|
|
1.FOUR
|
|
1.FIVE
|
|
1.six
|
|
"
|
|
.unindent()
|
|
.as_str(),
|
|
None,
|
|
cx,
|
|
)
|
|
})
|
|
.unwrap();
|
|
buffer_1.update(cx, |buffer, cx| {
|
|
buffer.set_diff_base(
|
|
Some(
|
|
"
|
|
1.zero
|
|
1.one
|
|
1.two
|
|
1.three
|
|
1.four
|
|
1.five
|
|
1.six
|
|
"
|
|
.unindent(),
|
|
),
|
|
cx,
|
|
);
|
|
});
|
|
|
|
// buffer has a deletion hunk and an insertion hunk
|
|
let buffer_2 = project
|
|
.update(cx, |project, cx| {
|
|
project.create_buffer(
|
|
"
|
|
2.zero
|
|
2.one
|
|
2.two
|
|
2.three
|
|
2.four
|
|
2.five
|
|
2.six
|
|
"
|
|
.unindent()
|
|
.as_str(),
|
|
None,
|
|
cx,
|
|
)
|
|
})
|
|
.unwrap();
|
|
buffer_2.update(cx, |buffer, cx| {
|
|
buffer.set_diff_base(
|
|
Some(
|
|
"
|
|
2.zero
|
|
2.one
|
|
2.one-and-a-half
|
|
2.two
|
|
2.three
|
|
2.four
|
|
2.six
|
|
"
|
|
.unindent(),
|
|
),
|
|
cx,
|
|
);
|
|
});
|
|
|
|
cx.background_executor.run_until_parked();
|
|
|
|
let multibuffer = cx.new_model(|cx| {
|
|
let mut multibuffer = MultiBuffer::new(0, ReadWrite);
|
|
multibuffer.push_excerpts(
|
|
buffer_1.clone(),
|
|
[
|
|
// excerpt ends in the middle of a modified hunk
|
|
ExcerptRange {
|
|
context: Point::new(0, 0)..Point::new(1, 5),
|
|
primary: Default::default(),
|
|
},
|
|
// excerpt begins in the middle of a modified hunk
|
|
ExcerptRange {
|
|
context: Point::new(5, 0)..Point::new(6, 5),
|
|
primary: Default::default(),
|
|
},
|
|
],
|
|
cx,
|
|
);
|
|
multibuffer.push_excerpts(
|
|
buffer_2.clone(),
|
|
[
|
|
// excerpt ends at a deletion
|
|
ExcerptRange {
|
|
context: Point::new(0, 0)..Point::new(1, 5),
|
|
primary: Default::default(),
|
|
},
|
|
// excerpt starts at a deletion
|
|
ExcerptRange {
|
|
context: Point::new(2, 0)..Point::new(2, 5),
|
|
primary: Default::default(),
|
|
},
|
|
// excerpt fully contains a deletion hunk
|
|
ExcerptRange {
|
|
context: Point::new(1, 0)..Point::new(2, 5),
|
|
primary: Default::default(),
|
|
},
|
|
// excerpt fully contains an insertion hunk
|
|
ExcerptRange {
|
|
context: Point::new(4, 0)..Point::new(6, 5),
|
|
primary: Default::default(),
|
|
},
|
|
],
|
|
cx,
|
|
);
|
|
multibuffer
|
|
});
|
|
|
|
let snapshot = multibuffer.read_with(cx, |b, cx| b.snapshot(cx));
|
|
|
|
assert_eq!(
|
|
snapshot.text(),
|
|
"
|
|
1.zero
|
|
1.ONE
|
|
1.FIVE
|
|
1.six
|
|
2.zero
|
|
2.one
|
|
2.two
|
|
2.one
|
|
2.two
|
|
2.four
|
|
2.five
|
|
2.six"
|
|
.unindent()
|
|
);
|
|
|
|
let expected = [
|
|
(DiffHunkStatus::Modified, 1..2),
|
|
(DiffHunkStatus::Modified, 2..3),
|
|
//TODO: Define better when and where removed hunks show up at range extremities
|
|
(DiffHunkStatus::Removed, 6..6),
|
|
(DiffHunkStatus::Removed, 8..8),
|
|
(DiffHunkStatus::Added, 10..11),
|
|
];
|
|
|
|
assert_eq!(
|
|
snapshot
|
|
.git_diff_hunks_in_range(0..12)
|
|
.map(|hunk| (hunk.status(), hunk.associated_range))
|
|
.collect::<Vec<_>>(),
|
|
&expected,
|
|
);
|
|
|
|
assert_eq!(
|
|
snapshot
|
|
.git_diff_hunks_in_range_rev(0..12)
|
|
.map(|hunk| (hunk.status(), hunk.associated_range))
|
|
.collect::<Vec<_>>(),
|
|
expected
|
|
.iter()
|
|
.rev()
|
|
.cloned()
|
|
.collect::<Vec<_>>()
|
|
.as_slice(),
|
|
);
|
|
}
|
|
}
|