From 3ba893e4ac545e4dfef252441fb8c3a7b9ffca7b Mon Sep 17 00:00:00 2001 From: Jacob Wolf Date: Wed, 26 Aug 2026 18:21:33 +0000 Subject: [PATCH] editor: Add file permalink support to tabs and the Project Panel (#62177) # Objective - Add file-level hyperlink support for opening and copying hyperlinks for Git remote source control to project panel and tabs - Unify the language for Git remote hyperlinks across blame, project panels, and tabs to be similar to GitLens' definitions (which are commonly used in VS Code) ## Solution - Updated the Git crate to expose relevant metadata to surface new options within the editor - Added new options for `Open File on Remote` and `Copy Remote File URL` to project panel/file explorer and file tabs - Altered the blame UI options to be consistent with new labels / more clear ## Testing - Ran `cargo run` to boot app and manually smoke test (see proof below) - Added unit tests for this change ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase https://github.com/user-attachments/assets/d600ca4f-47e7-438c-90e1-f40124a606b4 --- Release Notes: - Added options to open or copy whole-file permalinks from file tabs and the Project Panel. --------- Co-authored-by: Smit Barmase --- crates/collab/src/rpc.rs | 1 + crates/collab/tests/integration/git_tests.rs | 61 +++++ crates/editor/src/actions.rs | 4 +- crates/editor/src/git.rs | 4 +- crates/editor/src/mouse_context_menu.rs | 2 +- crates/git/src/git.rs | 4 + crates/git_ui/src/blame_ui.rs | 2 +- crates/project/src/git_store.rs | 227 +++++++++++++++---- crates/project/src/project.rs | 10 + crates/project_panel/src/project_panel.rs | 62 ++++- crates/proto/proto/git.proto | 9 + crates/proto/proto/zed.proto | 4 +- crates/proto/src/proto.rs | 4 + crates/workspace/src/pane.rs | 61 ++++- crates/workspace/src/workspace.rs | 79 ++++++- docs/src/git.md | 14 +- 16 files changed, 491 insertions(+), 57 deletions(-) diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index fff5ff29d1..8d8f3fd306 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -488,6 +488,7 @@ impl Server { .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) + .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs index 1397bd271b..c0f7932b66 100644 --- a/crates/collab/tests/integration/git_tests.rs +++ b/crates/collab/tests/integration/git_tests.rs @@ -86,6 +86,67 @@ async fn test_root_repo_common_dir_sync( ); } +#[gpui::test] +async fn test_remote_file_permalink( + executor: BackgroundExecutor, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + let mut server = TestServer::start(executor.clone()).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + + let fs = client_a.fs(); + fs.insert_tree( + path!("/project"), + json!({ + ".git": {}, + "tracked.txt": "tracked", + }), + ) + .await; + + let dot_git = Path::new(path!("/project/.git")); + let sha = "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7"; + fs.set_head_for_repo(dot_git, &[("tracked.txt", "tracked".into())], sha); + fs.set_remote_for_repo( + dot_git, + "origin", + "https://github.com/zed-industries/zed.git", + ); + + let (project_a, worktree_id) = client_a.build_local_project(path!("/project"), cx_a).await; + project_a + .update(cx_a, |project, cx| project.git_scans_complete(cx)) + .await; + + let active_call_a = cx_a.read(ActiveCall::global); + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.join_remote_project(project_id, cx_b).await; + executor.run_until_parked(); + + let tracked_path = ProjectPath { + worktree_id, + path: rel_path("tracked.txt").into(), + }; + let permalink = project_b + .update(cx_b, |project, cx| { + project.get_file_permalink(&tracked_path, cx) + }) + .await + .unwrap(); + assert_eq!( + permalink.as_str(), + format!("https://github.com/zed-industries/zed/blob/{sha}/tracked.txt") + ); +} + fn collect_diff_stats( panel: &gpui::Entity, cx: &C, diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 600c1e54ec..95f961be80 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -487,7 +487,7 @@ actions!( CopyFileName, /// Copies the file name without extension to the clipboard. CopyFileNameWithoutExtension, - /// Copies a permalink to the current line. + /// Copies a permalink to the current line or selection. CopyPermalinkToLine, /// Cuts selected text to the clipboard. Cut, @@ -711,7 +711,7 @@ actions!( OpenProposedChangesEditor, /// Opens documentation for the symbol at cursor. OpenDocs, - /// Opens a permalink to the current line. + /// Opens a permalink to the current line or selection. OpenPermalinkToLine, /// Opens the file whose name is selected in the editor. #[action(deprecated_aliases = ["editor::OpenFile"])] diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index 97d00ec2ea..fe3570869e 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -1669,7 +1669,7 @@ impl Editor { .ok(); } Err(err) => { - let message = format!("Failed to copy permalink: {err}"); + let message = format!("Failed to copy permalink to line: {err}"); anyhow::Result::<()>::Err(err).log_err(); @@ -1710,7 +1710,7 @@ impl Editor { .ok(); } Err(err) => { - let message = format!("Failed to open permalink: {err}"); + let message = format!("Failed to open permalink to line: {err}"); anyhow::Result::<()>::Err(err).log_err(); diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 56a3b3ea34..616e4101b3 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -310,7 +310,7 @@ pub fn deploy_context_menu( ) .action_disabled_when( !has_git_repo, - "Copy Permalink", + "Copy Permalink to Line", Box::new(CopyPermalinkToLine), ) .action_disabled_when( diff --git a/crates/git/src/git.rs b/crates/git/src/git.rs index fbe1d936c6..98cbcbd46a 100644 --- a/crates/git/src/git.rs +++ b/crates/git/src/git.rs @@ -61,6 +61,10 @@ actions!( Blame, /// Shows the git history for the selected file, folder, or project. FileHistory, + /// Opens a permalink for the selected file on its Git hosting provider. + OpenFilePermalink, + /// Copies a permalink for the selected file on its Git hosting provider. + CopyFilePermalink, /// Opens the selected file in the editor without a diff view. ViewFile, /// Stages the current file. diff --git a/crates/git_ui/src/blame_ui.rs b/crates/git_ui/src/blame_ui.rs index de7a7fd8db..90e1d780bd 100644 --- a/crates/git_ui/src/blame_ui.rs +++ b/crates/git_ui/src/blame_ui.rs @@ -570,7 +570,7 @@ fn deploy_blame_entry_context_menu( .when_some( details.and_then(|details| details.permalink.clone()), |this, url| { - this.entry("Open Permalink", None, move |_, cx| { + this.entry("Open Commit Permalink", None, move |_, cx| { cx.open_url(url.as_str()) }) }, diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs index 8ad12fa182..1809f59f10 100644 --- a/crates/project/src/git_store.rs +++ b/crates/project/src/git_store.rs @@ -741,6 +741,23 @@ pub enum RepositoryState { Remote(RemoteRepositoryState), } +enum PermalinkTarget { + Buffer { + buffer_id: BufferId, + selection: Range, + }, + File(ProjectPath), +} + +impl PermalinkTarget { + fn selection(&self) -> Option> { + match self { + Self::Buffer { selection, .. } => Some(selection.clone()), + Self::File(_) => None, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub enum GitGraphEvent { CountUpdated(usize), @@ -986,6 +1003,7 @@ impl GitStore { client.add_entity_request_handler(Self::handle_open_unstaged_diff); client.add_entity_request_handler(Self::handle_open_uncommitted_diff); client.add_entity_message_handler(Self::handle_update_diff_bases); + client.add_entity_request_handler(Self::handle_get_file_permalink); client.add_entity_request_handler(Self::handle_get_permalink_to_line); client.add_entity_request_handler(Self::handle_blame_buffer); client.add_entity_request_handler(Self::handle_blame_buffer_at_revision); @@ -2170,30 +2188,68 @@ impl GitStore { return Task::ready(Err(anyhow!("buffer has no file"))); }; - let Some((repo, repo_path)) = self.repository_and_path_for_project_path( - &(file.worktree.read(cx).id(), file.path.clone()).into(), - cx, - ) else { - // If we're not in a Git repo, check whether this is a Rust source - // file in the Cargo registry (presumably opened with go-to-definition - // from a normal Rust file). If so, we can put together a permalink - // using crate metadata. - if buffer - .read(cx) - .language() - .is_none_or(|lang| lang.name() != "Rust") - { - return Task::ready(Err(anyhow!("no permalink available"))); - } - let file_path = file.worktree.read(cx).absolutize(&file.path); - return cx.spawn(async move |cx| { - let provider_registry = cx.update(GitHostingProviderRegistry::default_global); - get_permalink_in_rust_registry_src(provider_registry, file_path, selection) - .context("no permalink available") - }); + let project_path = ProjectPath { + worktree_id: file.worktree.read(cx).id(), + path: file.path.clone(), }; - let buffer_id = buffer.read(cx).remote_id(); + if let Some((repo, repo_path)) = + self.repository_and_path_for_project_path(&project_path, cx) + { + return self.build_permalink( + repo, + repo_path, + PermalinkTarget::Buffer { + buffer_id: buffer.read(cx).remote_id(), + selection, + }, + cx, + ); + } + + // If we're not in a Git repo, check whether this is a Rust source + // file in the Cargo registry (presumably opened with go-to-definition + // from a normal Rust file). If so, we can put together a permalink + // using crate metadata. + if buffer + .read(cx) + .language() + .is_none_or(|lang| lang.name() != "Rust") + { + return Task::ready(Err(anyhow!("no permalink available"))); + } + let file_path = file.worktree.read(cx).absolutize(&file.path); + cx.spawn(async move |cx| { + let provider_registry = cx.update(GitHostingProviderRegistry::default_global); + get_permalink_in_rust_registry_src(provider_registry, file_path, selection) + .context("no permalink available") + }) + } + + pub fn get_file_permalink( + &self, + project_path: &ProjectPath, + cx: &mut App, + ) -> Task> { + let Some((repo, repo_path)) = self.repository_and_path_for_project_path(project_path, cx) + else { + return Task::ready(Err(anyhow!("no permalink available"))); + }; + self.build_permalink( + repo, + repo_path, + PermalinkTarget::File(project_path.clone()), + cx, + ) + } + + fn build_permalink( + &self, + repo: Entity, + repo_path: RepoPath, + target: PermalinkTarget, + cx: &mut App, + ) -> Task> { let branch = repo.read(cx).branch.clone(); let remote = branch .as_ref() @@ -2203,7 +2259,7 @@ impl GitStore { .to_string(); let rx = repo.update(cx, |repo, _| { - repo.send_job("get_permalink_to_line", None, move |state, cx| async move { + repo.send_job("get_permalink", None, move |state, cx| async move { match state { RepositoryState::Local(LocalRepositoryState { backend, .. }) => { let origin_url = backend @@ -2222,22 +2278,39 @@ impl GitStore { Ok(provider.build_permalink( remote, - BuildPermalinkParams::new(&sha, &repo_path, Some(selection)), + BuildPermalinkParams::new(&sha, &repo_path, target.selection()), )) } RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => { - let response = client - .request(proto::GetPermalinkToLine { - project_id: project_id.to_proto(), - buffer_id: buffer_id.into(), - selection: Some(proto::Range { - start: selection.start as u64, - end: selection.end as u64, - }), - }) - .await?; + let permalink = match target { + PermalinkTarget::Buffer { + buffer_id, + selection, + } => { + client + .request(proto::GetPermalinkToLine { + project_id: project_id.to_proto(), + buffer_id: buffer_id.into(), + selection: Some(proto::Range { + start: selection.start as u64, + end: selection.end as u64, + }), + }) + .await? + .permalink + } + PermalinkTarget::File(project_path) => { + client + .request(proto::GetFilePermalink { + project_id: project_id.to_proto(), + path: Some(project_path.to_proto()), + }) + .await? + .permalink + } + }; - url::Url::parse(&response.permalink).context("failed to parse permalink") + url::Url::parse(&permalink).context("failed to parse permalink") } } }) @@ -4833,13 +4906,12 @@ impl GitStore { mut cx: AsyncApp, ) -> Result { let buffer_id = BufferId::new(envelope.payload.buffer_id)?; - // let version = deserialize_version(&envelope.payload.version); let selection = { - let proto_selection = envelope + let selection = envelope .payload .selection .context("no selection to get permalink for defined")?; - proto_selection.start as u32..proto_selection.end as u32 + selection.start as u32..selection.end as u32 }; let buffer = this.read_with(&cx, |this, cx| { this.buffer_store.read(cx).get_existing(buffer_id) @@ -4849,11 +4921,31 @@ impl GitStore { this.get_permalink_to_line(&buffer, selection, cx) }) .await?; + Ok(proto::GetPermalinkToLineResponse { permalink: permalink.to_string(), }) } + async fn handle_get_file_permalink( + this: Entity, + envelope: TypedEnvelope, + mut cx: AsyncApp, + ) -> Result { + let path = envelope + .payload + .path + .context("GetFilePermalink requires a path")?; + let path = ProjectPath::from_proto(path).context("invalid file permalink path")?; + let permalink = this + .update(&mut cx, |this, cx| this.get_file_permalink(&path, cx)) + .await?; + + Ok(proto::GetFilePermalinkResponse { + permalink: permalink.to_string(), + }) + } + fn repository_for_request( this: &Entity, id: RepositoryId, @@ -11370,6 +11462,65 @@ mod tests { assert!(!is_submodule_git_dir(Path::new("/Foo/modules/Bar"))); } + #[gpui::test] + async fn test_get_permalink_for_file_without_selection(cx: &mut TestAppContext) { + use util::rel_path::rel_path; + + init_test(cx); + cx.update(|cx| { + GitHostingProviderRegistry::default_global(cx); + git_hosting_providers::init(cx); + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + Path::new("/project"), + json!({ + ".git": {}, + "src": { "main.rs": "fn main() {}\n" }, + }), + ) + .await; + + let sha = "e6ebe7974deb6bb6cc0e2595c8ec31f0c71084b7"; + fs.set_head_for_repo( + Path::new("/project/.git"), + &[("src/main.rs", "fn main() {}\n".into())], + sha, + ); + fs.set_remote_for_repo( + Path::new("/project/.git"), + "origin", + "https://github.com/zed-industries/zed.git", + ); + + let project = Project::test(fs.clone(), [Path::new("/project")], cx).await; + project + .update(cx, |project, cx| project.git_scans_complete(cx)) + .await; + + let worktree_id = project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }); + let project_path = ProjectPath { + worktree_id, + path: rel_path("src/main.rs").into(), + }; + + let permalink = project + .update(cx, |project, cx| { + project.get_file_permalink(&project_path, cx) + }) + .await + .unwrap(); + + assert_eq!( + permalink.as_str(), + &format!("https://github.com/zed-industries/zed/blob/{sha}/src/main.rs") + ); + assert!(permalink.fragment().is_none()); + } + #[gpui::test] async fn test_resolve_git_worktree_to_main_repo_ignores_submodule(cx: &mut TestAppContext) { let fs = FakeFs::new(cx.executor()); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 79042b5a28..70820f5f3d 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -5293,6 +5293,16 @@ impl Project { }) } + pub fn get_file_permalink( + &self, + project_path: &ProjectPath, + cx: &mut App, + ) -> Task> { + self.git_store.update(cx, |git_store, cx| { + git_store.get_file_permalink(project_path, cx) + }) + } + // RPC message handlers async fn handle_unshare_project( diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 5b35531fa2..277b15ebc2 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -73,10 +73,11 @@ use util::{ }; use workspace::{ DraggedSelection, OpenInTerminal, OpenMode, OpenOptions, OpenVisible, PreviewTabsSettings, - SelectedEntry, SplitDirection, Workspace, WorkspaceSettings, + SelectedEntry, SplitDirection, Workspace, WorkspaceSettings, copy_file_permalink, dock::{DockPosition, Panel, PanelEvent}, focus_follows_mouse::FocusFollowsMouse as _, notifications::{DetachAndPromptErr, NotifyResultExt, NotifyTaskExt}, + open_file_permalink, }; use worktree::CreatedEntry; use zed_actions::{ @@ -1199,6 +1200,16 @@ impl ProjectPanel { .when(has_history, |menu| { menu.action("View History", Box::new(git::FileHistory)) }) + .when(!is_dir, |menu| { + menu.action( + "Open File Permalink", + git::OpenFilePermalink.boxed_clone(), + ) + .action( + "Copy File Permalink", + git::CopyFilePermalink.boxed_clone(), + ) + }) }) .when(!should_hide_rename, |menu| { menu.separator().action("Rename", Box::new(Rename)) @@ -3790,6 +3801,53 @@ impl ProjectPanel { } } + fn open_file_permalink( + &mut self, + _: &git::OpenFilePermalink, + window: &mut Window, + cx: &mut Context, + ) { + let Some(project_path) = self.selected_file_project_path(cx) else { + return; + }; + open_file_permalink( + self.project.clone(), + project_path, + self.workspace.clone(), + window, + cx, + ); + } + + fn copy_file_permalink( + &mut self, + _: &git::CopyFilePermalink, + window: &mut Window, + cx: &mut Context, + ) { + let Some(project_path) = self.selected_file_project_path(cx) else { + return; + }; + copy_file_permalink( + self.project.clone(), + project_path, + self.workspace.clone(), + window, + cx, + ); + } + + fn selected_file_project_path(&self, cx: &App) -> Option { + let (worktree, entry) = self.selected_sub_entry(cx)?; + if entry.is_dir() { + return None; + } + Some(ProjectPath { + worktree_id: worktree.read(cx).id(), + path: entry.path.clone(), + }) + } + fn reveal_in_finder( &mut self, _: &RevealInFileManager, @@ -7101,6 +7159,8 @@ impl Render for ProjectPanel { .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::copy_path)) .on_action(cx.listener(Self::copy_relative_path)) + .on_action(cx.listener(Self::open_file_permalink)) + .on_action(cx.listener(Self::copy_file_permalink)) .on_action(cx.listener(Self::new_search_in_directory)) .on_action(cx.listener(Self::unfold_directory)) .on_action(cx.listener(Self::fold_directory)) diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto index bc797eec11..aece2bd61b 100644 --- a/crates/proto/proto/git.proto +++ b/crates/proto/proto/git.proto @@ -79,6 +79,15 @@ message GetPermalinkToLineResponse { string permalink = 1; } +message GetFilePermalink { + uint64 project_id = 1; + ProjectPath path = 2; +} + +message GetFilePermalinkResponse { + string permalink = 1; +} + message Branch { bool is_head = 1; string ref_name = 2; diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 077602d479..c8048dff2b 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -507,7 +507,9 @@ message Envelope { RefreshFoldingRanges refresh_folding_ranges = 476; RefreshDocumentSymbols refresh_document_symbols = 477; BlameBufferAtRevision blame_buffer_at_revision = 478; - BlameBufferAtRevisionResponse blame_buffer_at_revision_response = 479; // current max + BlameBufferAtRevisionResponse blame_buffer_at_revision_response = 479; + GetFilePermalink get_file_permalink = 480; + GetFilePermalinkResponse get_file_permalink_response = 481; // current max } reserved 87 to 88; diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index dc735c2aab..cbbb7d0eb6 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -112,6 +112,8 @@ messages!( (GetNotificationsResponse, Foreground), (GetCrashFiles, Background), (GetCrashFilesResponse, Background), + (GetFilePermalink, Foreground), + (GetFilePermalinkResponse, Foreground), (GetPathMetadata, Background), (GetPathMetadataResponse, Background), (GetPermalinkToLine, Foreground), @@ -554,6 +556,7 @@ request_messages!( (ShutdownRemoteServer, Ack), (RemoveWorktree, Ack), (OpenServerSettings, OpenBufferResponse), + (GetFilePermalink, GetFilePermalinkResponse), (GetPermalinkToLine, GetPermalinkToLineResponse), (FlushBufferedMessages, Ack), (LanguageServerPromptRequest, LanguageServerPromptResponse), @@ -781,6 +784,7 @@ entity_messages!( Toast, HideToast, OpenServerSettings, + GetFilePermalink, GetPermalinkToLine, LanguageServerPromptRequest, GitGetBranches, diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index f9d51fd2f0..56338cbaa0 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -17,6 +17,7 @@ use crate::{ use anyhow::Result; use collections::{BTreeSet, HashMap, HashSet, VecDeque}; use futures::{StreamExt, stream::FuturesUnordered}; +use git::{CopyFilePermalink, OpenFilePermalink}; use gpui::{ Action, Anchor, AnyElement, App, AsyncWindowContext, ClickEvent, ClipboardItem, Context, Div, DragMoveEvent, Entity, EntityId, EventEmitter, ExternalPaths, FocusHandle, FocusOutEvent, @@ -3289,8 +3290,19 @@ impl Pane { let parent_abs_path = entry_abs_path .as_deref() .and_then(|abs_path| Some(abs_path.parent()?.to_path_buf())); + let has_git_repo = project_path.as_ref().is_some_and(|project_path| { + pane.read(cx).project.upgrade().is_some_and(|project| { + project + .read(cx) + .git_store() + .read(cx) + .repository_and_path_for_project_path(project_path, cx) + .is_some() + }) + }); let relative_path = project_path - .map(|project_path| project_path.path) + .as_ref() + .map(|project_path| project_path.path.clone()) .filter(|_| has_relative_path); let visible_in_project_panel = relative_path.is_some() @@ -3336,6 +3348,53 @@ impl Pane { }), ) }) + .when(has_git_repo, |menu| { + menu.separator().when_some( + project_path.clone(), + |menu, project_path| { + menu.entry( + "Open File Permalink", + Some(OpenFilePermalink.boxed_clone()), + window.handler_for(&pane, { + let project_path = project_path.clone(); + move |pane, window, cx| { + let Some(project) = pane.project.upgrade() + else { + return; + }; + crate::open_file_permalink( + project, + project_path.clone(), + pane.workspace.clone(), + window, + cx, + ); + } + }), + ) + .entry( + "Copy File Permalink", + Some(CopyFilePermalink.boxed_clone()), + window.handler_for( + &pane, + move |pane, window, cx| { + let Some(project) = pane.project.upgrade() + else { + return; + }; + crate::copy_file_permalink( + project, + project_path.clone(), + pane.workspace.clone(), + window, + cx, + ); + }, + ), + ) + }, + ) + }) .when(is_local, |menu| { menu.when_some(reveal_path, |menu, reveal_path| { menu.separator().entry( diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 0267ddd1a2..63379ed6b0 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -61,11 +61,11 @@ use futures::{ }; use gpui::{ Action, AnyEntity, AnyView, AnyWeakView, App, AppContext, AsyncApp, AsyncWindowContext, Axis, - Bounds, Context, CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, EventEmitter, - FocusHandle, Focusable, Global, HitboxBehavior, Hsla, KeyContext, Keystroke, ManagedView, - MouseButton, PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, Stateful, - Subscription, SystemWindowTabController, Task, TaskExt, Tiling, WeakEntity, WindowBounds, - WindowHandle, WindowId, WindowOptions, actions, canvas, point, relative, size, + Bounds, ClipboardItem, Context, CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, + EventEmitter, FocusHandle, Focusable, Global, HitboxBehavior, Hsla, KeyContext, Keystroke, + ManagedView, MouseButton, PathPromptOptions, Point, PromptLevel, Render, ResizeEdge, Size, + Stateful, Subscription, SystemWindowTabController, Task, TaskExt, Tiling, WeakEntity, + WindowBounds, WindowHandle, WindowId, WindowOptions, actions, canvas, point, relative, size, transparent_black, }; pub use history_manager::*; @@ -622,6 +622,75 @@ impl Toast { } } +/// Opens a permalink for the selected file on its Git hosting provider. +pub fn open_file_permalink( + project: Entity, + project_path: ProjectPath, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, +) { + handle_file_permalink(project, project_path, workspace, false, window, cx); +} + +/// Copies a permalink for the selected file on its Git hosting provider. +pub fn copy_file_permalink( + project: Entity, + project_path: ProjectPath, + workspace: WeakEntity, + window: &mut Window, + cx: &mut App, +) { + handle_file_permalink(project, project_path, workspace, true, window, cx); +} + +fn handle_file_permalink( + project: Entity, + project_path: ProjectPath, + workspace: WeakEntity, + copy: bool, + window: &mut Window, + cx: &mut App, +) { + let permalink_task = project.update(cx, |project, cx| { + project.get_file_permalink(&project_path, cx) + }); + + window + .spawn(cx, async move |cx| match permalink_task.await { + Ok(permalink) => { + cx.update(|_, cx| { + if copy { + cx.write_to_clipboard(ClipboardItem::new_string(permalink.to_string())); + } else { + cx.open_url(permalink.as_ref()); + } + }) + .ok(); + } + Err(err) => { + let action = if copy { + "copy file permalink" + } else { + "open file permalink" + }; + let message = format!("Failed to {action}: {err}"); + anyhow::Result::<()>::Err(err).log_err(); + + workspace + .update(cx, |workspace, cx| { + struct FilePermalinkAction; + workspace.show_toast( + Toast::new(NotificationId::unique::(), message), + cx, + ); + }) + .ok(); + } + }) + .detach(); +} + impl PartialEq for Toast { fn eq(&self, other: &Self) -> bool { self.id == other.id diff --git a/docs/src/git.md b/docs/src/git.md index 45fa54c78e..2ed25a12ed 100644 --- a/docs/src/git.md +++ b/docs/src/git.md @@ -367,12 +367,16 @@ You can configure multiple custom providers if you work with several self-hosted ### Permalinks -Zed also has a Copy Permalink feature to create a permanent link to a code snippet on your Git hosting service. +Zed can open or copy a permanent link to a file or code selection on your Git hosting service. These links are useful for sharing a specific line or range of lines in a file at a specific commit. -Trigger this action via the [Command Palette](./getting-started.md#command-palette) (search for `permalink`), -by creating [custom key bindings](./key-bindings.md#custom-key-bindings) for the -`editor::CopyPermalinkToLine` or `editor::OpenPermalinkToLine` actions -or by simply right clicking and selecting `Copy Permalink` with line(s) selected in your editor. + +For the current selection in the editor, use {#action editor::CopyPermalinkToLine} or +{#action editor::OpenPermalinkToLine} from the [Command Palette](./getting-started.md#command-palette) +(search for `permalink`), via [custom key bindings](./key-bindings.md#custom-key-bindings), +or by right-clicking and selecting **Copy Permalink to Line**. + +From a file tab or the Project Panel, you can also **Open File Permalink** or **Copy File Permalink** +for the whole file (without a line selection). ## Diff Hunk Keyboard Shortcuts