Skip to content

Commit 7f54935

Browse files
mrnuggetas-ciiosiewiczbennetbomikayla-maki
authored
Add git blame (#8889)
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 ![screenshot-2024-03-28-13 57 43@2x](https://github.com/zed-industries/zed/assets/1185253/ee8ec55d-3b5e-4d63-a85a-852da914f5ba) ![screenshot-2024-03-28-14 01 23@2x](https://github.com/zed-industries/zed/assets/1185253/2ba8efd7-e887-4076-a87a-587a732b9e9a) ![screenshot-2024-03-28-14 01 32@2x](https://github.com/zed-industries/zed/assets/1185253/496f4a06-b189-4881-b427-2289ae6e6075) ### 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>
1 parent e2d6b0d commit 7f54935

39 files changed

+3760
-157
lines changed

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,8 @@ tempfile = "3.9.0"
280280
thiserror = "1.0.29"
281281
tiktoken-rs = "0.5.7"
282282
time = { version = "0.3", features = [
283+
"macros",
284+
"parsing",
283285
"serde",
284286
"serde-well-known",
285287
"formatting",

crates/collab/src/rpc.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,7 @@ impl Server {
366366
.add_request_handler(forward_mutating_project_request::<proto::ExpandProjectEntry>)
367367
.add_request_handler(forward_mutating_project_request::<proto::OnTypeFormatting>)
368368
.add_request_handler(forward_mutating_project_request::<proto::SaveBuffer>)
369+
.add_request_handler(forward_mutating_project_request::<proto::BlameBuffer>)
369370
.add_message_handler(create_buffer_for_peer)
370371
.add_request_handler(update_buffer)
371372
.add_message_handler(broadcast_project_message_from_host::<proto::RefreshInlayHints>)

crates/collab/src/tests/editor_tests.rs

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use rpc::RECEIVE_TIMEOUT;
2323
use serde_json::json;
2424
use settings::SettingsStore;
2525
use std::{
26+
ops::Range,
2627
path::Path,
2728
sync::{
2829
atomic::{self, AtomicBool, AtomicUsize},
@@ -1986,6 +1987,187 @@ struct Row10;"#};
19861987
struct Row1220;"#});
19871988
}
19881989

1990+
#[gpui::test(iterations = 10)]
1991+
async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) {
1992+
let mut server = TestServer::start(cx_a.executor()).await;
1993+
let client_a = server.create_client(cx_a, "user_a").await;
1994+
let client_b = server.create_client(cx_b, "user_b").await;
1995+
server
1996+
.create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
1997+
.await;
1998+
let active_call_a = cx_a.read(ActiveCall::global);
1999+
2000+
cx_a.update(editor::init);
2001+
cx_b.update(editor::init);
2002+
2003+
client_a
2004+
.fs()
2005+
.insert_tree(
2006+
"/my-repo",
2007+
json!({
2008+
".git": {},
2009+
"file.txt": "line1\nline2\nline3\nline\n",
2010+
}),
2011+
)
2012+
.await;
2013+
2014+
let blame = git::blame::Blame {
2015+
entries: vec![
2016+
blame_entry("1b1b1b", 0..1),
2017+
blame_entry("0d0d0d", 1..2),
2018+
blame_entry("3a3a3a", 2..3),
2019+
blame_entry("4c4c4c", 3..4),
2020+
],
2021+
permalinks: [
2022+
("1b1b1b", "http://example.com/codehost/idx-0"),
2023+
("0d0d0d", "http://example.com/codehost/idx-1"),
2024+
("3a3a3a", "http://example.com/codehost/idx-2"),
2025+
("4c4c4c", "http://example.com/codehost/idx-3"),
2026+
]
2027+
.into_iter()
2028+
.map(|(sha, url)| (sha.parse().unwrap(), url.parse().unwrap()))
2029+
.collect(),
2030+
messages: [
2031+
("1b1b1b", "message for idx-0"),
2032+
("0d0d0d", "message for idx-1"),
2033+
("3a3a3a", "message for idx-2"),
2034+
("4c4c4c", "message for idx-3"),
2035+
]
2036+
.into_iter()
2037+
.map(|(sha, message)| (sha.parse().unwrap(), message.into()))
2038+
.collect(),
2039+
};
2040+
client_a.fs().set_blame_for_repo(
2041+
Path::new("/my-repo/.git"),
2042+
vec![(Path::new("file.txt"), blame)],
2043+
);
2044+
2045+
let (project_a, worktree_id) = client_a.build_local_project("/my-repo", cx_a).await;
2046+
let project_id = active_call_a
2047+
.update(cx_a, |call, cx| call.share_project(project_a.clone(), cx))
2048+
.await
2049+
.unwrap();
2050+
2051+
// Create editor_a
2052+
let (workspace_a, cx_a) = client_a.build_workspace(&project_a, cx_a);
2053+
let editor_a = workspace_a
2054+
.update(cx_a, |workspace, cx| {
2055+
workspace.open_path((worktree_id, "file.txt"), None, true, cx)
2056+
})
2057+
.await
2058+
.unwrap()
2059+
.downcast::<Editor>()
2060+
.unwrap();
2061+
2062+
// Join the project as client B.
2063+
let project_b = client_b.build_remote_project(project_id, cx_b).await;
2064+
let (workspace_b, cx_b) = client_b.build_workspace(&project_b, cx_b);
2065+
let editor_b = workspace_b
2066+
.update(cx_b, |workspace, cx| {
2067+
workspace.open_path((worktree_id, "file.txt"), None, true, cx)
2068+
})
2069+
.await
2070+
.unwrap()
2071+
.downcast::<Editor>()
2072+
.unwrap();
2073+
2074+
// client_b now requests git blame for the open buffer
2075+
editor_b.update(cx_b, |editor_b, cx| {
2076+
assert!(editor_b.blame().is_none());
2077+
editor_b.toggle_git_blame(&editor::actions::ToggleGitBlame {}, cx);
2078+
});
2079+
2080+
cx_a.executor().run_until_parked();
2081+
cx_b.executor().run_until_parked();
2082+
2083+
editor_b.update(cx_b, |editor_b, cx| {
2084+
let blame = editor_b.blame().expect("editor_b should have blame now");
2085+
let entries = blame.update(cx, |blame, cx| {
2086+
blame
2087+
.blame_for_rows((0..4).map(Some), cx)
2088+
.collect::<Vec<_>>()
2089+
});
2090+
2091+
assert_eq!(
2092+
entries,
2093+
vec![
2094+
Some(blame_entry("1b1b1b", 0..1)),
2095+
Some(blame_entry("0d0d0d", 1..2)),
2096+
Some(blame_entry("3a3a3a", 2..3)),
2097+
Some(blame_entry("4c4c4c", 3..4)),
2098+
]
2099+
);
2100+
2101+
blame.update(cx, |blame, _| {
2102+
for (idx, entry) in entries.iter().flatten().enumerate() {
2103+
assert_eq!(
2104+
blame.permalink_for_entry(entry).unwrap().to_string(),
2105+
format!("http://example.com/codehost/idx-{}", idx)
2106+
);
2107+
assert_eq!(
2108+
blame.message_for_entry(entry).unwrap(),
2109+
format!("message for idx-{}", idx)
2110+
);
2111+
}
2112+
});
2113+
});
2114+
2115+
// editor_b updates the file, which gets sent to client_a, which updates git blame,
2116+
// which gets back to client_b.
2117+
editor_b.update(cx_b, |editor_b, cx| {
2118+
editor_b.edit([(Point::new(0, 3)..Point::new(0, 3), "FOO")], cx);
2119+
});
2120+
2121+
cx_a.executor().run_until_parked();
2122+
cx_b.executor().run_until_parked();
2123+
2124+
editor_b.update(cx_b, |editor_b, cx| {
2125+
let blame = editor_b.blame().expect("editor_b should have blame now");
2126+
let entries = blame.update(cx, |blame, cx| {
2127+
blame
2128+
.blame_for_rows((0..4).map(Some), cx)
2129+
.collect::<Vec<_>>()
2130+
});
2131+
2132+
assert_eq!(
2133+
entries,
2134+
vec![
2135+
None,
2136+
Some(blame_entry("0d0d0d", 1..2)),
2137+
Some(blame_entry("3a3a3a", 2..3)),
2138+
Some(blame_entry("4c4c4c", 3..4)),
2139+
]
2140+
);
2141+
});
2142+
2143+
// Now editor_a also updates the file
2144+
editor_a.update(cx_a, |editor_a, cx| {
2145+
editor_a.edit([(Point::new(1, 3)..Point::new(1, 3), "FOO")], cx);
2146+
});
2147+
2148+
cx_a.executor().run_until_parked();
2149+
cx_b.executor().run_until_parked();
2150+
2151+
editor_b.update(cx_b, |editor_b, cx| {
2152+
let blame = editor_b.blame().expect("editor_b should have blame now");
2153+
let entries = blame.update(cx, |blame, cx| {
2154+
blame
2155+
.blame_for_rows((0..4).map(Some), cx)
2156+
.collect::<Vec<_>>()
2157+
});
2158+
2159+
assert_eq!(
2160+
entries,
2161+
vec![
2162+
None,
2163+
None,
2164+
Some(blame_entry("3a3a3a", 2..3)),
2165+
Some(blame_entry("4c4c4c", 3..4)),
2166+
]
2167+
);
2168+
});
2169+
}
2170+
19892171
fn extract_hint_labels(editor: &Editor) -> Vec<String> {
19902172
let mut labels = Vec::new();
19912173
for hint in editor.inlay_hint_cache().hints() {
@@ -1996,3 +2178,11 @@ fn extract_hint_labels(editor: &Editor) -> Vec<String> {
19962178
}
19972179
labels
19982180
}
2181+
2182+
fn blame_entry(sha: &str, range: Range<u32>) -> git::blame::BlameEntry {
2183+
git::blame::BlameEntry {
2184+
sha: sha.parse().unwrap(),
2185+
range,
2186+
..Default::default()
2187+
}
2188+
}

crates/editor/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ smol.workspace = true
6161
snippet.workspace = true
6262
sum_tree.workspace = true
6363
text.workspace = true
64+
time.workspace = true
65+
time_format.workspace = true
6466
theme.workspace = true
6567
tree-sitter-html = { workspace = true, optional = true }
6668
tree-sitter-rust = { workspace = true, optional = true }

crates/editor/src/actions.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ gpui::actions!(
244244
SplitSelectionIntoLines,
245245
Tab,
246246
TabPrev,
247+
ToggleGitBlame,
247248
ToggleInlayHints,
248249
ToggleLineNumbers,
249250
ToggleSoftWrap,

0 commit comments

Comments
 (0)