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 <heysmitbarmase@gmail.com>
This commit is contained in:
Jacob WolfandSmit Barmase authored and GitHub committed 2026-08-26 18:21:33 +00:00
1 parent a3c4abf07c
commit 3ba893e4ac
16 files changed
+491 -57

No files matched your search

+1
View File
@@ -488,6 +488,7 @@ impl Server {
.add_request_handler(forward_mutating_project_request::<proto::Commit>)
.add_request_handler(forward_mutating_project_request::<proto::RunGitHook>)
.add_request_handler(forward_mutating_project_request::<proto::GitInit>)
.add_request_handler(forward_read_only_project_request::<proto::GetFilePermalink>)
.add_request_handler(forward_read_only_project_request::<proto::GetRemotes>)
.add_request_handler(forward_read_only_project_request::<proto::GitShow>)
.add_request_handler(forward_read_only_project_request::<proto::LoadCommitDiff>)
@@ -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<C: gpui::AppContext>(
panel: &gpui::Entity<GitPanel>,
cx: &C,
+2 -2
View File
@@ -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"])]
+2 -2
View File
@@ -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();
+1 -1
View File
@@ -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(
+4
View File
@@ -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.
+1 -1
View File
@@ -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())
})
},
+189 -38
View File
@@ -741,6 +741,23 @@ pub enum RepositoryState {
Remote(RemoteRepositoryState),
}
enum PermalinkTarget {
Buffer {
buffer_id: BufferId,
selection: Range<u32>,
},
File(ProjectPath),
}
impl PermalinkTarget {
fn selection(&self) -> Option<Range<u32>> {
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<Result<url::Url>> {
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<Repository>,
repo_path: RepoPath,
target: PermalinkTarget,
cx: &mut App,
) -> Task<Result<url::Url>> {
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<proto::GetPermalinkToLineResponse> {
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<Self>,
envelope: TypedEnvelope<proto::GetFilePermalink>,
mut cx: AsyncApp,
) -> Result<proto::GetFilePermalinkResponse> {
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<Self>,
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());
+10
View File
@@ -5293,6 +5293,16 @@ impl Project {
})
}
pub fn get_file_permalink(
&self,
project_path: &ProjectPath,
cx: &mut App,
) -> Task<Result<url::Url>> {
self.git_store.update(cx, |git_store, cx| {
git_store.get_file_permalink(project_path, cx)
})
}
// RPC message handlers
async fn handle_unshare_project(
+61 -1
View File
@@ -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<Self>,
) {
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<Self>,
) {
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<ProjectPath> {
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))
+9
View File
@@ -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;
+3 -1
View File
@@ -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;
+4
View File
@@ -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,
+60 -1
View File
@@ -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(
+74 -5
View File
@@ -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>,
project_path: ProjectPath,
workspace: WeakEntity<Workspace>,
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>,
project_path: ProjectPath,
workspace: WeakEntity<Workspace>,
window: &mut Window,
cx: &mut App,
) {
handle_file_permalink(project, project_path, workspace, true, window, cx);
}
fn handle_file_permalink(
project: Entity<Project>,
project_path: ProjectPath,
workspace: WeakEntity<Workspace>,
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::<FilePermalinkAction>(), message),
cx,
);
})
.ok();
}
})
.detach();
}
impl PartialEq for Toast {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
+9 -5
View File
@@ -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