Cargo.lock 🔗
@@ -2336,6 +2336,7 @@ dependencies = [
"tree-sitter-typescript",
"ui",
"unindent",
+ "url",
"util",
"workspace",
]
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.
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(-)
@@ -2336,6 +2336,7 @@ dependencies = [
"tree-sitter-typescript",
"ui",
"unindent",
+ "url",
"util",
"workspace",
]
@@ -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" }
@@ -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"
@@ -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"] }
@@ -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 }
@@ -110,6 +110,7 @@ gpui::actions!(
Copy,
CopyHighlightJson,
CopyPath,
+ CopyPermalinkToLine,
CopyRelativePath,
Cut,
CutToEndOfLine,
@@ -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;
}
@@ -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);
@@ -1,3 +1,5 @@
+pub mod permalink;
+
use std::ops::Range;
use git::diff::{DiffHunk, DiffHunkStatus};
@@ -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())
+ }
+}
@@ -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();
@@ -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 }
@@ -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