diff --git a/Cargo.lock b/Cargo.lock
index d3f300019d..63aa72410f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4317,6 +4317,7 @@ dependencies = [
"log",
"parking_lot",
"pretty_assertions",
+ "regex",
"rope",
"serde",
"serde_json",
diff --git a/assets/icons/pull_request.svg b/assets/icons/pull_request.svg
new file mode 100644
index 0000000000..150a532cc6
--- /dev/null
+++ b/assets/icons/pull_request.svg
@@ -0,0 +1 @@
+
diff --git a/crates/editor/src/blame_entry_tooltip.rs b/crates/editor/src/blame_entry_tooltip.rs
index a07d399149..ac8c6bfc05 100644
--- a/crates/editor/src/blame_entry_tooltip.rs
+++ b/crates/editor/src/blame_entry_tooltip.rs
@@ -149,6 +149,11 @@ impl Render for BlameEntryTooltip {
})
.unwrap_or("".into_any());
+ let pull_request = self
+ .details
+ .as_ref()
+ .and_then(|details| details.pull_request.clone());
+
let ui_font_size = ThemeSettings::get_global(cx).ui_font_size;
let message_max_height = cx.line_height() * 12 + (ui_font_size / 0.4);
@@ -192,27 +197,51 @@ impl Render for BlameEntryTooltip {
.justify_between()
.child(absolute_timestamp)
.child(
- Button::new("commit-sha-button", short_commit_id.clone())
- .style(ButtonStyle::Transparent)
- .color(Color::Muted)
- .icon(IconName::FileGit)
- .icon_color(Color::Muted)
- .icon_position(IconPosition::Start)
- .disabled(
- self.details.as_ref().map_or(true, |details| {
- details.permalink.is_none()
- }),
- )
- .when_some(
- self.details
- .as_ref()
- .and_then(|details| details.permalink.clone()),
- |this, url| {
- this.on_click(move |_, cx| {
+ h_flex()
+ .gap_2()
+ .when_some(pull_request, |this, pr| {
+ this.child(
+ Button::new(
+ "pull-request-button",
+ format!("#{}", pr.number),
+ )
+ .color(Color::Muted)
+ .icon(IconName::PullRequest)
+ .icon_color(Color::Muted)
+ .icon_position(IconPosition::Start)
+ .style(ButtonStyle::Transparent)
+ .on_click(move |_, cx| {
cx.stop_propagation();
- cx.open_url(url.as_str())
- })
- },
+ cx.open_url(pr.url.as_str())
+ }),
+ )
+ })
+ .child(
+ Button::new(
+ "commit-sha-button",
+ short_commit_id.clone(),
+ )
+ .style(ButtonStyle::Transparent)
+ .color(Color::Muted)
+ .icon(IconName::FileGit)
+ .icon_color(Color::Muted)
+ .icon_position(IconPosition::Start)
+ .disabled(
+ self.details.as_ref().map_or(true, |details| {
+ details.permalink.is_none()
+ }),
+ )
+ .when_some(
+ self.details
+ .as_ref()
+ .and_then(|details| details.permalink.clone()),
+ |this, url| {
+ this.on_click(move |_, cx| {
+ cx.stop_propagation();
+ cx.open_url(url.as_str())
+ })
+ },
+ ),
),
),
),
diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs
index b69caba35d..efbe9d5ffd 100644
--- a/crates/editor/src/git/blame.rs
+++ b/crates/editor/src/git/blame.rs
@@ -6,6 +6,7 @@ use git::{
blame::{Blame, BlameEntry},
hosting_provider::HostingProvider,
permalink::{build_commit_permalink, parse_git_remote_url},
+ pull_request::{extract_pull_request, PullRequest},
Oid,
};
use gpui::{Model, ModelContext, Subscription, Task};
@@ -75,6 +76,7 @@ pub struct CommitDetails {
pub message: String,
pub parsed_message: ParsedMarkdown,
pub permalink: Option,
+ pub pull_request: Option,
pub remote: Option,
}
@@ -438,6 +440,10 @@ async fn parse_commit_messages(
repo: remote.repo.to_string(),
});
+ let pull_request = parsed_remote_url
+ .as_ref()
+ .and_then(|remote| extract_pull_request(remote, &message));
+
commit_details.insert(
oid,
CommitDetails {
@@ -445,6 +451,7 @@ async fn parse_commit_messages(
parsed_message,
permalink,
remote,
+ pull_request,
},
);
}
diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml
index 6944075461..3392fa24e3 100644
--- a/crates/git/Cargo.toml
+++ b/crates/git/Cargo.toml
@@ -25,6 +25,7 @@ time.workspace = true
url.workspace = true
util.workspace = true
serde.workspace = true
+regex.workspace = true
rope.workspace = true
parking_lot.workspace = true
windows.workspace = true
diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs
index 269e5606b8..df82c45940 100644
--- a/crates/git/src/git.rs
+++ b/crates/git/src/git.rs
@@ -12,6 +12,7 @@ pub mod commit;
pub mod diff;
pub mod hosting_provider;
pub mod permalink;
+pub mod pull_request;
pub mod repository;
lazy_static! {
diff --git a/crates/git/src/pull_request.rs b/crates/git/src/pull_request.rs
new file mode 100644
index 0000000000..a0876de7ab
--- /dev/null
+++ b/crates/git/src/pull_request.rs
@@ -0,0 +1,83 @@
+use lazy_static::lazy_static;
+use url::Url;
+
+use crate::{hosting_provider::HostingProvider, permalink::ParsedGitRemote};
+
+lazy_static! {
+ static ref GITHUB_PULL_REQUEST_NUMBER: regex::Regex =
+ regex::Regex::new(r"\(#(\d+)\)$").unwrap();
+}
+
+#[derive(Clone, Debug)]
+pub struct PullRequest {
+ pub number: u32,
+ pub url: Url,
+}
+
+pub fn extract_pull_request(remote: &ParsedGitRemote, message: &str) -> Option {
+ match remote.provider {
+ HostingProvider::Github => {
+ let line = message.lines().next()?;
+ let capture = GITHUB_PULL_REQUEST_NUMBER.captures(line)?;
+ let number = capture.get(1)?.as_str().parse::().ok()?;
+
+ let mut url = remote.provider.base_url();
+ let path = format!("/{}/{}/pull/{}", remote.owner, remote.repo, number);
+ url.set_path(&path);
+
+ Some(PullRequest { number, url })
+ }
+ _ => None,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use unindent::Unindent;
+
+ use crate::{
+ hosting_provider::HostingProvider, permalink::ParsedGitRemote,
+ pull_request::extract_pull_request,
+ };
+
+ #[test]
+ fn test_github_pull_requests() {
+ let remote = ParsedGitRemote {
+ provider: HostingProvider::Github,
+ owner: "zed-industries",
+ repo: "zed",
+ };
+
+ let message = "This does not contain a pull request";
+ assert!(extract_pull_request(&remote, message).is_none());
+
+ // Pull request number at end of first line
+ let message = r#"
+ project panel: do not expand collapsed worktrees on "collapse all entries" (#10687)
+
+ Fixes #10597
+
+ Release Notes:
+
+ - Fixed "project panel: collapse all entries" expanding collapsed worktrees.
+ "#
+ .unindent();
+
+ assert_eq!(
+ extract_pull_request(&remote, &message)
+ .unwrap()
+ .url
+ .as_str(),
+ "https://github.com/zed-industries/zed/pull/10687"
+ );
+
+ // Pull request number in middle of line, which we want to ignore
+ let message = r#"
+ Follow-up to #10687 to fix problems
+
+ See the original PR, this is a fix.
+ "#
+ .unindent();
+ assert!(extract_pull_request(&remote, &message).is_none());
+ }
+}
diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs
index c4db95c301..1bf4505032 100644
--- a/crates/ui/src/components/icon.rs
+++ b/crates/ui/src/components/icon.rs
@@ -121,6 +121,7 @@ pub enum IconName {
WholeWord,
XCircle,
ZedXCopilot,
+ PullRequest,
}
impl IconName {
@@ -222,6 +223,7 @@ impl IconName {
IconName::WholeWord => "icons/word_search.svg",
IconName::XCircle => "icons/error.svg",
IconName::ZedXCopilot => "icons/zed_x_copilot.svg",
+ IconName::PullRequest => "icons/pull_request.svg",
}
}
}