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", } } }