From 176f63e86eefeaf1673ca2bc25a2dec3fbc4c126 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 30 Jan 2024 19:20:15 -0500 Subject: [PATCH] Add ability to copy a permalink to a line (#7119) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds the ability to copy the permalink to a line from within Zed. This functionality is available through the `editor: copy permalink to line` action in the command palette: Screenshot 2024-01-30 at 7 07 46 PM Executing this action will create a permalink to the currently selected line(s) and copy it to the clipboard. Here is an example line: ``` https://github.com/maxdeviant/auk/blob/56c80e80112744740be1969c89fdd34db4be6f64/src/lib.rs#L25 ``` Currently, both GitHub and GitLab are supported. ### Notes and known limitations - In order to determine where to permalink to, we read the URL of the `origin` remote in Git. This feature will not work if the `origin` remote is not present. - Attempting to permalink to a ref that is not pushed to the origin will result in the link 404ing. - Attempting to permalink when Git is in a dirty state may not generate the right link. - For instance, modifying a file (e.g., adding new lines) and grabbing a permalink to it will result in incorrect line numbers. Release Notes: - Added the ability to copy a permalink to a line ([#6777](https://github.com/zed-industries/zed/issues/6777)). - Available via the `editor: copy permalink to line` action in the command palette. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/channel/Cargo.toml | 2 +- crates/client/Cargo.toml | 2 +- crates/editor/Cargo.toml | 1 + crates/editor/src/actions.rs | 1 + crates/editor/src/editor.rs | 39 +++- crates/editor/src/element.rs | 1 + crates/editor/src/git.rs | 2 + crates/editor/src/git/permalink.rs | 288 +++++++++++++++++++++++++++++ crates/fs/src/repository.rs | 24 +++ crates/util/Cargo.toml | 2 +- crates/zed/Cargo.toml | 2 +- 13 files changed, 361 insertions(+), 5 deletions(-) create mode 100644 crates/editor/src/git/permalink.rs diff --git a/Cargo.lock b/Cargo.lock index f677efdcfd..737ce42797 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2336,6 +2336,7 @@ dependencies = [ "tree-sitter-typescript", "ui", "unindent", + "url", "util", "workspace", ] diff --git a/Cargo.toml b/Cargo.toml index c254233b33..e2723f5ee7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,6 +132,7 @@ tree-sitter = { version = "0.20" } unindent = { version = "0.1.7" } pretty_assertions = "1.3.0" git2 = { version = "0.15", default-features = false} +url = "2.2" uuid = { version = "1.1.2", features = ["v4"] } tree-sitter-bash = { git = "https://github.com/tree-sitter/tree-sitter-bash", rev = "7331995b19b8f8aba2d5e26deb51d2195c18bc94" } diff --git a/crates/channel/Cargo.toml b/crates/channel/Cargo.toml index f33f7e7f34..8c3e6dea21 100644 --- a/crates/channel/Cargo.toml +++ b/crates/channel/Cargo.toml @@ -42,7 +42,7 @@ thiserror.workspace = true time.workspace = true tiny_http = "0.8" uuid.workspace = true -url = "2.2" +url.workspace = true serde.workspace = true serde_derive.workspace = true tempfile = "3" diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index fe012ddcc5..7cf00e7df0 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -46,7 +46,7 @@ thiserror.workspace = true time.workspace = true tiny_http = "0.8" uuid.workspace = true -url = "2.2" +url.workspace = true [dev-dependencies] collections = { path = "../collections", features = ["test-support"] } diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index d21d431ff9..bd117d10a4 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -67,6 +67,7 @@ serde_json.workspace = true serde_derive.workspace = true smallvec.workspace = true smol.workspace = true +url.workspace = true tree-sitter-rust = { workspace = true, optional = true } tree-sitter-html = { workspace = true, optional = true } diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 9cfaeaaf4a..dd3735c6a9 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -110,6 +110,7 @@ gpui::actions!( Copy, CopyHighlightJson, CopyPath, + CopyPermalinkToLine, CopyRelativePath, Cut, CutToEndOfLine, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index c1b2f2087d..b6b2bac7be 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -117,7 +117,7 @@ use ui::{ h_flex, prelude::*, ButtonSize, ButtonStyle, IconButton, IconName, IconSize, ListItem, Popover, Tooltip, }; -use util::{post_inc, RangeExt, ResultExt, TryFutureExt}; +use util::{maybe, post_inc, RangeExt, ResultExt, TryFutureExt}; use workspace::{searchable::SearchEvent, ItemNavHistory, Pane, SplitDirection, ViewId, Workspace}; const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); @@ -8215,6 +8215,43 @@ impl Editor { } } + pub fn copy_permalink_to_line(&mut self, _: &CopyPermalinkToLine, cx: &mut ViewContext) { + use git::permalink::{build_permalink, BuildPermalinkParams}; + + let permalink = maybe!({ + let project = self.project.clone()?; + let project = project.read(cx); + + let worktree = project.visible_worktrees(cx).next()?; + + let mut cwd = worktree.read(cx).abs_path().to_path_buf(); + cwd.push(".git"); + + let repo = project.fs().open_repo(&cwd)?; + let origin_url = repo.lock().remote_url("origin")?; + let sha = repo.lock().head_sha()?; + + let buffer = self.buffer().read(cx).as_singleton()?; + let file = buffer.read(cx).file().and_then(|f| f.as_local())?; + let path = file.path().to_str().map(|path| path.to_string())?; + + let selections = self.selections.all::(cx); + let selection = selections.iter().peekable().next(); + + build_permalink(BuildPermalinkParams { + remote_url: &origin_url, + sha: &sha, + path: &path, + selection: selection.map(|selection| selection.range()), + }) + .log_err() + }); + + if let Some(permalink) = permalink { + cx.write_to_clipboard(ClipboardItem::new(permalink.to_string())); + } + } + pub fn highlight_rows(&mut self, rows: Option>) { self.highlighted_rows = rows; } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 2c5bf48395..7f37f539d3 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -277,6 +277,7 @@ impl EditorElement { register_action(view, cx, Editor::copy_path); register_action(view, cx, Editor::copy_relative_path); register_action(view, cx, Editor::copy_highlight_json); + register_action(view, cx, Editor::copy_permalink_to_line); register_action(view, cx, |editor, action, cx| { if let Some(task) = editor.format(action, cx) { task.detach_and_log_err(cx); diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index 6eb80b99fc..18e544e4a6 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -1,3 +1,5 @@ +pub mod permalink; + use std::ops::Range; use git::diff::{DiffHunk, DiffHunkStatus}; diff --git a/crates/editor/src/git/permalink.rs b/crates/editor/src/git/permalink.rs new file mode 100644 index 0000000000..1dc1aa3953 --- /dev/null +++ b/crates/editor/src/git/permalink.rs @@ -0,0 +1,288 @@ +use std::ops::Range; + +use anyhow::{anyhow, Result}; +use language::Point; +use url::Url; + +enum GitHostingProvider { + Github, + Gitlab, +} + +impl GitHostingProvider { + fn base_url(&self) -> Url { + let base_url = match self { + Self::Github => "https://github.com", + Self::Gitlab => "https://gitlab.com", + }; + + Url::parse(&base_url).unwrap() + } + + /// Returns the fragment portion of the URL for the selected lines in + /// the representation the [`GitHostingProvider`] expects. + fn line_fragment(&self, selection: &Range) -> String { + if selection.start.row == selection.end.row { + let line = selection.start.row + 1; + + match self { + Self::Github | Self::Gitlab => format!("L{}", line), + } + } else { + let start_line = selection.start.row + 1; + let end_line = selection.end.row + 1; + + match self { + Self::Github => format!("L{}-L{}", start_line, end_line), + Self::Gitlab => format!("L{}-{}", start_line, end_line), + } + } + } +} + +pub struct BuildPermalinkParams<'a> { + pub remote_url: &'a str, + pub sha: &'a str, + pub path: &'a str, + pub selection: Option>, +} + +pub fn build_permalink(params: BuildPermalinkParams) -> Result { + let BuildPermalinkParams { + remote_url, + sha, + path, + selection, + } = params; + + let ParsedGitRemote { + provider, + owner, + repo, + } = parse_git_remote_url(remote_url) + .ok_or_else(|| anyhow!("failed to parse Git remote URL"))?; + + let path = match provider { + GitHostingProvider::Github => format!("{owner}/{repo}/blob/{sha}/{path}"), + GitHostingProvider::Gitlab => format!("{owner}/{repo}/-/blob/{sha}/{path}"), + }; + let line_fragment = selection.map(|selection| provider.line_fragment(&selection)); + + let mut permalink = provider.base_url().join(&path).unwrap(); + permalink.set_fragment(line_fragment.as_deref()); + + Ok(permalink) +} + +struct ParsedGitRemote<'a> { + pub provider: GitHostingProvider, + pub owner: &'a str, + pub repo: &'a str, +} + +fn parse_git_remote_url(url: &str) -> Option { + if url.starts_with("git@github.com:") || url.starts_with("https://github.com/") { + let repo_with_owner = url + .trim_start_matches("git@github.com:") + .trim_start_matches("https://github.com/") + .trim_end_matches(".git"); + + let (owner, repo) = repo_with_owner.split_once("/")?; + + return Some(ParsedGitRemote { + provider: GitHostingProvider::Github, + owner, + repo, + }); + } + + if url.starts_with("git@gitlab.com:") || url.starts_with("https://gitlab.com/") { + let repo_with_owner = url + .trim_start_matches("git@gitlab.com:") + .trim_start_matches("https://gitlab.com/") + .trim_end_matches(".git"); + + let (owner, repo) = repo_with_owner.split_once("/")?; + + return Some(ParsedGitRemote { + provider: GitHostingProvider::Gitlab, + owner, + repo, + }); + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_github_permalink_from_ssh_url() { + let permalink = build_permalink(BuildPermalinkParams { + remote_url: "git@github.com:zed-industries/zed.git", + sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + path: "crates/editor/src/git/permalink.rs", + selection: None, + }) + .unwrap(); + + let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_github_permalink_from_ssh_url_single_line_selection() { + let permalink = build_permalink(BuildPermalinkParams { + remote_url: "git@github.com:zed-industries/zed.git", + sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + path: "crates/editor/src/git/permalink.rs", + selection: Some(Point::new(6, 1)..Point::new(6, 10)), + }) + .unwrap(); + + let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_github_permalink_from_ssh_url_multi_line_selection() { + let permalink = build_permalink(BuildPermalinkParams { + remote_url: "git@github.com:zed-industries/zed.git", + sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + path: "crates/editor/src/git/permalink.rs", + selection: Some(Point::new(23, 1)..Point::new(47, 10)), + }) + .unwrap(); + + let expected_url = "https://github.com/zed-industries/zed/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-L48"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_github_permalink_from_https_url() { + let permalink = build_permalink(BuildPermalinkParams { + remote_url: "https://github.com/zed-industries/zed.git", + sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", + path: "crates/zed/src/main.rs", + selection: None, + }) + .unwrap(); + + let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_github_permalink_from_https_url_single_line_selection() { + let permalink = build_permalink(BuildPermalinkParams { + remote_url: "https://github.com/zed-industries/zed.git", + sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", + path: "crates/zed/src/main.rs", + selection: Some(Point::new(6, 1)..Point::new(6, 10)), + }) + .unwrap(); + + let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_github_permalink_from_https_url_multi_line_selection() { + let permalink = build_permalink(BuildPermalinkParams { + remote_url: "https://github.com/zed-industries/zed.git", + sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", + path: "crates/zed/src/main.rs", + selection: Some(Point::new(23, 1)..Point::new(47, 10)), + }) + .unwrap(); + + let expected_url = "https://github.com/zed-industries/zed/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L24-L48"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_gitlab_permalink_from_ssh_url() { + let permalink = build_permalink(BuildPermalinkParams { + remote_url: "git@gitlab.com:zed-industries/zed.git", + sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + path: "crates/editor/src/git/permalink.rs", + selection: None, + }) + .unwrap(); + + let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_gitlab_permalink_from_ssh_url_single_line_selection() { + let permalink = build_permalink(BuildPermalinkParams { + remote_url: "git@gitlab.com:zed-industries/zed.git", + sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + path: "crates/editor/src/git/permalink.rs", + selection: Some(Point::new(6, 1)..Point::new(6, 10)), + }) + .unwrap(); + + let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L7"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_gitlab_permalink_from_ssh_url_multi_line_selection() { + let permalink = build_permalink(BuildPermalinkParams { + remote_url: "git@gitlab.com:zed-industries/zed.git", + sha: "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7", + path: "crates/editor/src/git/permalink.rs", + selection: Some(Point::new(23, 1)..Point::new(47, 10)), + }) + .unwrap(); + + let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7/crates/editor/src/git/permalink.rs#L24-48"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_gitlab_permalink_from_https_url() { + let permalink = build_permalink(BuildPermalinkParams { + remote_url: "https://gitlab.com/zed-industries/zed.git", + sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", + path: "crates/zed/src/main.rs", + selection: None, + }) + .unwrap(); + + let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_gitlab_permalink_from_https_url_single_line_selection() { + let permalink = build_permalink(BuildPermalinkParams { + remote_url: "https://gitlab.com/zed-industries/zed.git", + sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", + path: "crates/zed/src/main.rs", + selection: Some(Point::new(6, 1)..Point::new(6, 10)), + }) + .unwrap(); + + let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L7"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } + + #[test] + fn test_build_gitlab_permalink_from_https_url_multi_line_selection() { + let permalink = build_permalink(BuildPermalinkParams { + remote_url: "https://gitlab.com/zed-industries/zed.git", + sha: "b2efec9824c45fcc90c9a7eb107a50d1772a60aa", + path: "crates/zed/src/main.rs", + selection: Some(Point::new(23, 1)..Point::new(47, 10)), + }) + .unwrap(); + + let expected_url = "https://gitlab.com/zed-industries/zed/-/blob/b2efec9824c45fcc90c9a7eb107a50d1772a60aa/crates/zed/src/main.rs#L24-48"; + assert_eq!(permalink.to_string(), expected_url.to_string()) + } +} diff --git a/crates/fs/src/repository.rs b/crates/fs/src/repository.rs index 620ea72acc..4bd666381c 100644 --- a/crates/fs/src/repository.rs +++ b/crates/fs/src/repository.rs @@ -26,8 +26,14 @@ pub struct Branch { pub trait GitRepository: Send { fn reload_index(&self); fn load_index_text(&self, relative_file_path: &Path) -> Option; + + /// Returns the URL of the remote with the given name. + fn remote_url(&self, name: &str) -> Option; fn branch_name(&self) -> Option; + /// Returns the SHA of the current HEAD. + fn head_sha(&self) -> Option; + /// Get the statuses of all of the files in the index that start with the given /// path and have changes with respect to the HEAD commit. This is fast because /// the index stores hashes of trees, so that unchanged directories can be skipped. @@ -88,12 +94,22 @@ impl GitRepository for LibGitRepository { None } + fn remote_url(&self, name: &str) -> Option { + let remote = self.find_remote(name).ok()?; + remote.url().map(|url| url.to_string()) + } + fn branch_name(&self) -> Option { let head = self.head().log_err()?; let branch = String::from_utf8_lossy(head.shorthand_bytes()); Some(branch.to_string()) } + fn head_sha(&self) -> Option { + let head = self.head().ok()?; + head.target().map(|oid| oid.to_string()) + } + fn staged_statuses(&self, path_prefix: &Path) -> TreeMap { let mut map = TreeMap::default(); @@ -255,11 +271,19 @@ impl GitRepository for FakeGitRepository { state.index_contents.get(path).cloned() } + fn remote_url(&self, _name: &str) -> Option { + None + } + fn branch_name(&self) -> Option { let state = self.state.lock(); state.branch_name.clone() } + fn head_sha(&self) -> Option { + None + } + fn staged_statuses(&self, path_prefix: &Path) -> TreeMap { let mut map = TreeMap::default(); let state = self.state.lock(); diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index 924c6fe688..a1045f8f82 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -21,7 +21,7 @@ lazy_static.workspace = true futures.workspace = true isahc.workspace = true smol.workspace = true -url = "2.2" +url.workspace = true rand.workspace = true rust-embed.workspace = true tempfile = { workspace = true, optional = true } diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 7497b26f0d..3741efbad0 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -148,7 +148,7 @@ tree-sitter-vue.workspace = true tree-sitter-uiua.workspace = true tree-sitter-zig.workspace = true -url = "2.2" +url.workspace = true urlencoding = "2.1.2" uuid.workspace = true