remote ssh: Make "get permalink to line" work (#19366)

Thorsten Ball created

This makes the `editor: copy permalink to line` and `editor: copy
permalink to line` actions work in SSH remote projects.

Previously it would only work in local projects.

Demo:


https://github.com/user-attachments/assets/a8012152-b631-4b34-9ff2-e4d033c97dee




Release Notes:

- N/A

Change summary

Cargo.lock                         |   3 
crates/editor/src/editor.rs        | 144 ++++++++++++++++---------------
crates/project/Cargo.toml          |   1 
crates/project/src/buffer_store.rs | 101 ++++++++++++++++++++++
crates/project/src/project.rs      |  11 ++
crates/proto/proto/zed.proto       |  15 +++
crates/proto/src/proto.rs          |   7 +
crates/remote_server/Cargo.toml    |   2 
crates/remote_server/src/unix.rs   |   6 +
9 files changed, 215 insertions(+), 75 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -8472,6 +8472,7 @@ dependencies = [
  "terminal",
  "text",
  "unindent",
+ "url",
  "util",
  "which 6.0.3",
  "windows 0.58.0",
@@ -9147,6 +9148,8 @@ dependencies = [
  "env_logger",
  "fs",
  "futures 0.3.30",
+ "git",
+ "git_hosting_providers",
  "gpui",
  "http_client",
  "language",

crates/editor/src/editor.rs 🔗

@@ -48,7 +48,6 @@ mod signature_help;
 pub mod test;
 
 use ::git::diff::DiffHunkStatus;
-use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry};
 pub(crate) use actions::*;
 use aho_corasick::AhoCorasick;
 use anyhow::{anyhow, Context as _, Result};
@@ -11488,11 +11487,8 @@ impl Editor {
         snapshot.line_len(buffer_row) == 0
     }
 
-    fn get_permalink_to_line(&mut self, cx: &mut ViewContext<Self>) -> Result<url::Url> {
-        let (path, selection, repo) = maybe!({
-            let project_handle = self.project.as_ref()?.clone();
-            let project = project_handle.read(cx);
-
+    fn get_permalink_to_line(&mut self, cx: &mut ViewContext<Self>) -> Task<Result<url::Url>> {
+        let buffer_and_selection = maybe!({
             let selection = self.selections.newest::<Point>(cx);
             let selection_range = selection.range();
 
@@ -11516,64 +11512,58 @@ impl Editor {
                 (buffer.clone(), selection)
             };
 
-            let path = buffer
-                .read(cx)
-                .file()?
-                .as_local()?
-                .path()
-                .to_str()?
-                .to_string();
-            let repo = project.get_repo(&buffer.read(cx).project_path(cx)?, cx)?;
-            Some((path, selection, repo))
+            Some((buffer, selection))
+        });
+
+        let Some((buffer, selection)) = buffer_and_selection else {
+            return Task::ready(Err(anyhow!("failed to determine buffer and selection")));
+        };
+
+        let Some(project) = self.project.as_ref() else {
+            return Task::ready(Err(anyhow!("editor does not have project")));
+        };
+
+        project.update(cx, |project, cx| {
+            project.get_permalink_to_line(&buffer, selection, cx)
         })
-        .ok_or_else(|| anyhow!("unable to open git repository"))?;
-
-        const REMOTE_NAME: &str = "origin";
-        let origin_url = repo
-            .remote_url(REMOTE_NAME)
-            .ok_or_else(|| anyhow!("remote \"{REMOTE_NAME}\" not found"))?;
-        let sha = repo
-            .head_sha()
-            .ok_or_else(|| anyhow!("failed to read HEAD SHA"))?;
-
-        let (provider, remote) =
-            parse_git_remote_url(GitHostingProviderRegistry::default_global(cx), &origin_url)
-                .ok_or_else(|| anyhow!("failed to parse Git remote URL"))?;
-
-        Ok(provider.build_permalink(
-            remote,
-            BuildPermalinkParams {
-                sha: &sha,
-                path: &path,
-                selection: Some(selection),
-            },
-        ))
     }
 
     pub fn copy_permalink_to_line(&mut self, _: &CopyPermalinkToLine, cx: &mut ViewContext<Self>) {
-        let permalink = self.get_permalink_to_line(cx);
+        let permalink_task = self.get_permalink_to_line(cx);
+        let workspace = self.workspace();
 
-        match permalink {
-            Ok(permalink) => {
-                cx.write_to_clipboard(ClipboardItem::new_string(permalink.to_string()));
-            }
-            Err(err) => {
-                let message = format!("Failed to copy permalink: {err}");
+        cx.spawn(|_, mut cx| async move {
+            match permalink_task.await {
+                Ok(permalink) => {
+                    cx.update(|cx| {
+                        cx.write_to_clipboard(ClipboardItem::new_string(permalink.to_string()));
+                    })
+                    .ok();
+                }
+                Err(err) => {
+                    let message = format!("Failed to copy permalink: {err}");
 
-                Err::<(), anyhow::Error>(err).log_err();
+                    Err::<(), anyhow::Error>(err).log_err();
 
-                if let Some(workspace) = self.workspace() {
-                    workspace.update(cx, |workspace, cx| {
-                        struct CopyPermalinkToLine;
+                    if let Some(workspace) = workspace {
+                        workspace
+                            .update(&mut cx, |workspace, cx| {
+                                struct CopyPermalinkToLine;
 
-                        workspace.show_toast(
-                            Toast::new(NotificationId::unique::<CopyPermalinkToLine>(), message),
-                            cx,
-                        )
-                    })
+                                workspace.show_toast(
+                                    Toast::new(
+                                        NotificationId::unique::<CopyPermalinkToLine>(),
+                                        message,
+                                    ),
+                                    cx,
+                                )
+                            })
+                            .ok();
+                    }
                 }
             }
-        }
+        })
+        .detach();
     }
 
     pub fn copy_file_location(&mut self, _: &CopyFileLocation, cx: &mut ViewContext<Self>) {
@@ -11586,29 +11576,41 @@ impl Editor {
     }
 
     pub fn open_permalink_to_line(&mut self, _: &OpenPermalinkToLine, cx: &mut ViewContext<Self>) {
-        let permalink = self.get_permalink_to_line(cx);
+        let permalink_task = self.get_permalink_to_line(cx);
+        let workspace = self.workspace();
 
-        match permalink {
-            Ok(permalink) => {
-                cx.open_url(permalink.as_ref());
-            }
-            Err(err) => {
-                let message = format!("Failed to open permalink: {err}");
+        cx.spawn(|_, mut cx| async move {
+            match permalink_task.await {
+                Ok(permalink) => {
+                    cx.update(|cx| {
+                        cx.open_url(permalink.as_ref());
+                    })
+                    .ok();
+                }
+                Err(err) => {
+                    let message = format!("Failed to open permalink: {err}");
 
-                Err::<(), anyhow::Error>(err).log_err();
+                    Err::<(), anyhow::Error>(err).log_err();
 
-                if let Some(workspace) = self.workspace() {
-                    workspace.update(cx, |workspace, cx| {
-                        struct OpenPermalinkToLine;
+                    if let Some(workspace) = workspace {
+                        workspace
+                            .update(&mut cx, |workspace, cx| {
+                                struct OpenPermalinkToLine;
 
-                        workspace.show_toast(
-                            Toast::new(NotificationId::unique::<OpenPermalinkToLine>(), message),
-                            cx,
-                        )
-                    })
+                                workspace.show_toast(
+                                    Toast::new(
+                                        NotificationId::unique::<OpenPermalinkToLine>(),
+                                        message,
+                                    ),
+                                    cx,
+                                )
+                            })
+                            .ok();
+                    }
                 }
             }
-        }
+        })
+        .detach();
     }
 
     /// Adds a row highlight for the given range. If a row has multiple highlights, the

crates/project/Cargo.toml 🔗

@@ -69,6 +69,7 @@ snippet_provider.workspace = true
 terminal.workspace = true
 text.workspace = true
 util.workspace = true
+url.workspace = true
 which.workspace = true
 
 [target.'cfg(target_os = "windows")'.dependencies]

crates/project/src/buffer_store.rs 🔗

@@ -3,6 +3,7 @@ use crate::{
     worktree_store::{WorktreeStore, WorktreeStoreEvent},
     Item, NoRepositoryError, ProjectPath,
 };
+use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry};
 use anyhow::{anyhow, Context as _, Result};
 use client::Client;
 use collections::{hash_map, HashMap, HashSet};
@@ -23,7 +24,7 @@ use language::{
 };
 use rpc::{proto, AnyProtoClient, ErrorExt as _, TypedEnvelope};
 use smol::channel::Receiver;
-use std::{io, path::Path, str::FromStr as _, sync::Arc, time::Instant};
+use std::{io, ops::Range, path::Path, str::FromStr as _, sync::Arc, time::Instant};
 use text::BufferId;
 use util::{debug_panic, maybe, ResultExt as _, TryFutureExt};
 use worktree::{File, PathChange, ProjectEntryId, UpdatedGitRepositoriesSet, Worktree, WorktreeId};
@@ -971,6 +972,7 @@ impl BufferStore {
         client.add_model_request_handler(Self::handle_save_buffer);
         client.add_model_request_handler(Self::handle_blame_buffer);
         client.add_model_request_handler(Self::handle_reload_buffers);
+        client.add_model_request_handler(Self::handle_get_permalink_to_line);
     }
 
     /// Creates a buffer store, optionally retaining its buffers.
@@ -1170,6 +1172,78 @@ impl BufferStore {
         }
     }
 
+    pub fn get_permalink_to_line(
+        &self,
+        buffer: &Model<Buffer>,
+        selection: Range<u32>,
+        cx: &AppContext,
+    ) -> Task<Result<url::Url>> {
+        let buffer = buffer.read(cx);
+        let Some(file) = File::from_dyn(buffer.file()) else {
+            return Task::ready(Err(anyhow!("buffer has no file")));
+        };
+
+        match file.worktree.clone().read(cx) {
+            Worktree::Local(worktree) => {
+                let Some(repo) = worktree.local_git_repo(file.path()) else {
+                    return Task::ready(Err(anyhow!("no repository for buffer found")));
+                };
+
+                let path = file.path().clone();
+
+                cx.spawn(|cx| async move {
+                    const REMOTE_NAME: &str = "origin";
+                    let origin_url = repo
+                        .remote_url(REMOTE_NAME)
+                        .ok_or_else(|| anyhow!("remote \"{REMOTE_NAME}\" not found"))?;
+
+                    let sha = repo
+                        .head_sha()
+                        .ok_or_else(|| anyhow!("failed to read HEAD SHA"))?;
+
+                    let provider_registry =
+                        cx.update(GitHostingProviderRegistry::default_global)?;
+
+                    let (provider, remote) =
+                        parse_git_remote_url(provider_registry, &origin_url)
+                            .ok_or_else(|| anyhow!("failed to parse Git remote URL"))?;
+
+                    let path = path
+                        .to_str()
+                        .context("failed to convert buffer path to string")?;
+
+                    Ok(provider.build_permalink(
+                        remote,
+                        BuildPermalinkParams {
+                            sha: &sha,
+                            path,
+                            selection: Some(selection),
+                        },
+                    ))
+                })
+            }
+            Worktree::Remote(worktree) => {
+                let buffer_id = buffer.remote_id();
+                let project_id = worktree.project_id();
+                let client = worktree.client();
+                cx.spawn(|_| async move {
+                    let response = client
+                        .request(proto::GetPermalinkToLine {
+                            project_id,
+                            buffer_id: buffer_id.into(),
+                            selection: Some(proto::Range {
+                                start: selection.start as u64,
+                                end: selection.end as u64,
+                            }),
+                        })
+                        .await?;
+
+                    url::Url::parse(&response.permalink).context("failed to parse permalink")
+                })
+            }
+        }
+    }
+
     fn add_buffer(&mut self, buffer: Model<Buffer>, cx: &mut ModelContext<Self>) -> Result<()> {
         let remote_id = buffer.read(cx).remote_id();
         let is_remote = buffer.read(cx).replica_id() != 0;
@@ -1775,6 +1849,31 @@ impl BufferStore {
         Ok(serialize_blame_buffer_response(blame))
     }
 
+    pub async fn handle_get_permalink_to_line(
+        this: Model<Self>,
+        envelope: TypedEnvelope<proto::GetPermalinkToLine>,
+        mut cx: AsyncAppContext,
+    ) -> 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
+                .payload
+                .selection
+                .context("no selection to get permalink for defined")?;
+            proto_selection.start as u32..proto_selection.end as u32
+        };
+        let buffer = this.read_with(&cx, |this, _| this.get_existing(buffer_id))??;
+        let permalink = this
+            .update(&mut cx, |this, cx| {
+                this.get_permalink_to_line(&buffer, selection, cx)
+            })?
+            .await?;
+        Ok(proto::GetPermalinkToLineResponse {
+            permalink: permalink.to_string(),
+        })
+    }
+
     pub async fn wait_for_loading_buffer(
         mut receiver: postage::watch::Receiver<Option<Result<Model<Buffer>, Arc<anyhow::Error>>>>,
     ) -> Result<Model<Buffer>, Arc<anyhow::Error>> {

crates/project/src/project.rs 🔗

@@ -3463,6 +3463,17 @@ impl Project {
         self.buffer_store.read(cx).blame_buffer(buffer, version, cx)
     }
 
+    pub fn get_permalink_to_line(
+        &self,
+        buffer: &Model<Buffer>,
+        selection: Range<u32>,
+        cx: &AppContext,
+    ) -> Task<Result<url::Url>> {
+        self.buffer_store
+            .read(cx)
+            .get_permalink_to_line(buffer, selection, cx)
+    }
+
     // RPC message handlers
 
     async fn handle_unshare_project(

crates/proto/proto/zed.proto 🔗

@@ -292,7 +292,10 @@ message Envelope {
         Toast toast = 261;
         HideToast hide_toast = 262;
 
-        OpenServerSettings open_server_settings = 263; // current max
+        OpenServerSettings open_server_settings = 263;
+
+        GetPermalinkToLine get_permalink_to_line = 264;
+        GetPermalinkToLineResponse get_permalink_to_line_response = 265;  // current max
     }
 
     reserved 87 to 88;
@@ -2508,3 +2511,13 @@ message HideToast {
 message OpenServerSettings {
     uint64 project_id = 1;
 }
+
+message GetPermalinkToLine {
+    uint64 project_id = 1;
+    uint64 buffer_id = 2;
+    Range selection = 3;
+}
+
+message GetPermalinkToLineResponse {
+    string permalink = 1;
+}

crates/proto/src/proto.rs 🔗

@@ -370,6 +370,8 @@ messages!(
     (Toast, Background),
     (HideToast, Background),
     (OpenServerSettings, Foreground),
+    (GetPermalinkToLine, Foreground),
+    (GetPermalinkToLineResponse, Foreground),
 );
 
 request_messages!(
@@ -494,7 +496,8 @@ request_messages!(
     (CheckFileExists, CheckFileExistsResponse),
     (ShutdownRemoteServer, Ack),
     (RemoveWorktree, Ack),
-    (OpenServerSettings, OpenBufferResponse)
+    (OpenServerSettings, OpenBufferResponse),
+    (GetPermalinkToLine, GetPermalinkToLineResponse),
 );
 
 entity_messages!(
@@ -571,7 +574,7 @@ entity_messages!(
     Toast,
     HideToast,
     OpenServerSettings,
-
+    GetPermalinkToLine,
 );
 
 entity_messages!(

crates/remote_server/Cargo.toml 🔗

@@ -30,6 +30,8 @@ client.workspace = true
 env_logger.workspace = true
 fs.workspace = true
 futures.workspace = true
+git.workspace = true
+git_hosting_providers.workspace = true
 gpui.workspace = true
 http_client.workspace = true
 language.workspace = true

crates/remote_server/src/unix.rs 🔗

@@ -5,6 +5,7 @@ use client::ProxySettings;
 use fs::{Fs, RealFs};
 use futures::channel::mpsc;
 use futures::{select, select_biased, AsyncRead, AsyncWrite, AsyncWriteExt, FutureExt, SinkExt};
+use git::GitHostingProviderRegistry;
 use gpui::{AppContext, Context as _, ModelContext, UpdateGlobal as _};
 use http_client::{read_proxy_from_env, Uri};
 use language::LanguageRegistry;
@@ -313,6 +314,8 @@ pub fn execute_run(
     let listeners = ServerListeners::new(stdin_socket, stdout_socket, stderr_socket)?;
 
     log::info!("starting headless gpui app");
+
+    let git_hosting_provider_registry = Arc::new(GitHostingProviderRegistry::new());
     gpui::App::headless().run(move |cx| {
         settings::init(cx);
         HeadlessProject::init(cx);
@@ -322,6 +325,9 @@ pub fn execute_run(
 
         client::init_settings(cx);
 
+        GitHostingProviderRegistry::set_global(git_hosting_provider_registry, cx);
+        git_hosting_providers::init(cx);
+
         let project = cx.new_model(|cx| {
             let fs = Arc::new(RealFs::new(Default::default(), None));
             let node_settings_rx = initialize_settings(session.clone(), fs.clone(), cx);