Add ability to copy a permalink to a line (#7119)

Marshall Bowers created

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:

<img width="589" alt="Screenshot 2024-01-30 at 7 07 46 PM"
src="https://github.com/zed-industries/zed/assets/1486634/332282cb-211f-4f16-9eb1-415bcfee9b7b">

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.

Change summary

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(-)

Detailed changes

Cargo.lock 🔗

@@ -2336,6 +2336,7 @@ dependencies = [
  "tree-sitter-typescript",
  "ui",
  "unindent",
+ "url",
  "util",
  "workspace",
 ]

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

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"

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

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 }

crates/editor/src/actions.rs 🔗

@@ -110,6 +110,7 @@ gpui::actions!(
         Copy,
         CopyHighlightJson,
         CopyPath,
+        CopyPermalinkToLine,
         CopyRelativePath,
         Cut,
         CutToEndOfLine,

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<Self>) {
+        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::<Point>(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<Range<u32>>) {
         self.highlighted_rows = rows;
     }

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);

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<Point>) -> 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<Range<Point>>,
+}
+
+pub fn build_permalink(params: BuildPermalinkParams) -> Result<Url> {
+    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<ParsedGitRemote> {
+    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())
+    }
+}

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<String>;
+
+    /// Returns the URL of the remote with the given name.
+    fn remote_url(&self, name: &str) -> Option<String>;
     fn branch_name(&self) -> Option<String>;
 
+    /// Returns the SHA of the current HEAD.
+    fn head_sha(&self) -> Option<String>;
+
     /// 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<String> {
+        let remote = self.find_remote(name).ok()?;
+        remote.url().map(|url| url.to_string())
+    }
+
     fn branch_name(&self) -> Option<String> {
         let head = self.head().log_err()?;
         let branch = String::from_utf8_lossy(head.shorthand_bytes());
         Some(branch.to_string())
     }
 
+    fn head_sha(&self) -> Option<String> {
+        let head = self.head().ok()?;
+        head.target().map(|oid| oid.to_string())
+    }
+
     fn staged_statuses(&self, path_prefix: &Path) -> TreeMap<RepoPath, GitFileStatus> {
         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<String> {
+        None
+    }
+
     fn branch_name(&self) -> Option<String> {
         let state = self.state.lock();
         state.branch_name.clone()
     }
 
+    fn head_sha(&self) -> Option<String> {
+        None
+    }
+
     fn staged_statuses(&self, path_prefix: &Path) -> TreeMap<RepoPath, GitFileStatus> {
         let mut map = TreeMap::default();
         let state = self.state.lock();

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 }

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