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:
1 parent
a3c4abf07c
commit
3ba893e4ac
16 files changed
+491
-57
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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"])]
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user