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

+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