SSH Remoting: Add the ability to resolve file paths on the remote host (#18250)

Mikayla Maki created

Release Notes:

- N/A

Change summary

crates/project/src/project.rs                    | 67 ++++++++++-------
crates/proto/proto/zed.proto                     | 15 +++
crates/proto/src/proto.rs                        |  8 +
crates/remote_server/src/headless_project.rs     | 17 ++++
crates/remote_server/src/remote_editing_tests.rs | 45 +++++++++++
5 files changed, 119 insertions(+), 33 deletions(-)

Detailed changes

crates/project/src/project.rs 🔗

@@ -3037,15 +3037,11 @@ impl Project {
         buffer: &Model<Buffer>,
         cx: &mut ModelContext<Self>,
     ) -> Task<Option<ResolvedPath>> {
-        // TODO: ssh based remoting.
-        if self.ssh_session.is_some() {
-            return Task::ready(None);
-        }
-
-        if self.is_local_or_ssh() {
-            let expanded = PathBuf::from(shellexpand::tilde(&path).into_owned());
+        let path_buf = PathBuf::from(path);
+        if path_buf.is_absolute() || path.starts_with("~") {
+            if self.is_local() {
+                let expanded = PathBuf::from(shellexpand::tilde(&path).into_owned());
 
-            if expanded.is_absolute() {
                 let fs = self.fs.clone();
                 cx.background_executor().spawn(async move {
                     let path = expanded.as_path();
@@ -3053,16 +3049,24 @@ impl Project {
 
                     exists.then(|| ResolvedPath::AbsPath(expanded))
                 })
+            } else if let Some(ssh_session) = self.ssh_session.as_ref() {
+                let request = ssh_session.request(proto::CheckFileExists {
+                    project_id: SSH_PROJECT_ID,
+                    path: path.to_string(),
+                });
+                cx.background_executor().spawn(async move {
+                    let response = request.await.log_err()?;
+                    if response.exists {
+                        Some(ResolvedPath::AbsPath(PathBuf::from(response.path)))
+                    } else {
+                        None
+                    }
+                })
             } else {
-                self.resolve_path_in_worktrees(expanded, buffer, cx)
-            }
-        } else {
-            let path = PathBuf::from(path);
-            if path.is_absolute() || path.starts_with("~") {
                 return Task::ready(None);
             }
-
-            self.resolve_path_in_worktrees(path, buffer, cx)
+        } else {
+            self.resolve_path_in_worktrees(path_buf, buffer, cx)
         }
     }
 
@@ -4016,17 +4020,7 @@ impl Project {
     }
 
     pub fn worktree_metadata_protos(&self, cx: &AppContext) -> Vec<proto::WorktreeMetadata> {
-        self.worktrees(cx)
-            .map(|worktree| {
-                let worktree = worktree.read(cx);
-                proto::WorktreeMetadata {
-                    id: worktree.id().to_proto(),
-                    root_name: worktree.root_name().into(),
-                    visible: worktree.is_visible(),
-                    abs_path: worktree.abs_path().to_string_lossy().into(),
-                }
-            })
-            .collect()
+        self.worktree_store.read(cx).worktree_metadata_protos(cx)
     }
 
     fn set_worktrees_from_proto(
@@ -4035,10 +4029,9 @@ impl Project {
         cx: &mut ModelContext<Project>,
     ) -> Result<()> {
         cx.notify();
-        let result = self.worktree_store.update(cx, |worktree_store, cx| {
+        self.worktree_store.update(cx, |worktree_store, cx| {
             worktree_store.set_worktrees_from_proto(worktrees, self.replica_id(), cx)
-        });
-        result
+        })
     }
 
     fn set_collaborators_from_proto(
@@ -4547,6 +4540,22 @@ pub enum ResolvedPath {
     AbsPath(PathBuf),
 }
 
+impl ResolvedPath {
+    pub fn abs_path(&self) -> Option<&Path> {
+        match self {
+            Self::AbsPath(path) => Some(path.as_path()),
+            _ => None,
+        }
+    }
+
+    pub fn project_path(&self) -> Option<&ProjectPath> {
+        match self {
+            Self::ProjectPath(path) => Some(&path),
+            _ => None,
+        }
+    }
+}
+
 impl Item for Buffer {
     fn try_open(
         project: &Model<Project>,

crates/proto/proto/zed.proto 🔗

@@ -293,7 +293,10 @@ message Envelope {
 
         TryExec try_exec = 252;
         ReadTextFile read_text_file = 253;
-        ReadTextFileResponse read_text_file_response = 254; // current max
+        ReadTextFileResponse read_text_file_response = 254;
+
+        CheckFileExists check_file_exists = 255;
+        CheckFileExistsResponse check_file_exists_response = 256; // current max
     }
 
     reserved 158 to 161;
@@ -2574,3 +2577,13 @@ message TryExec {
 message TryExecResponse {
     string text = 1;
 }
+
+message CheckFileExists {
+    uint64 project_id = 1;
+    string path = 2;
+}
+
+message CheckFileExistsResponse {
+    bool exists = 1;
+    string path = 2;
+}

crates/proto/src/proto.rs 🔗

@@ -372,7 +372,9 @@ messages!(
     (ShellEnvResponse, Foreground),
     (TryExec, Foreground),
     (ReadTextFile, Foreground),
-    (ReadTextFileResponse, Foreground)
+    (ReadTextFileResponse, Foreground),
+    (CheckFileExists, Background),
+    (CheckFileExistsResponse, Background)
 );
 
 request_messages!(
@@ -501,6 +503,7 @@ request_messages!(
     (ShellEnv, ShellEnvResponse),
     (ReadTextFile, ReadTextFileResponse),
     (TryExec, Ack),
+    (CheckFileExists, CheckFileExistsResponse)
 );
 
 entity_messages!(
@@ -578,7 +581,8 @@ entity_messages!(
     WhichCommand,
     ShellEnv,
     TryExec,
-    ReadTextFile
+    ReadTextFile,
+    CheckFileExists,
 );
 
 entity_messages!(

crates/remote_server/src/headless_project.rs 🔗

@@ -108,6 +108,7 @@ impl HeadlessProject {
         session.subscribe_to_entity(SSH_PROJECT_ID, &settings_observer);
 
         client.add_request_handler(cx.weak_model(), Self::handle_list_remote_directory);
+        client.add_request_handler(cx.weak_model(), Self::handle_check_file_exists);
 
         client.add_model_request_handler(Self::handle_add_worktree);
         client.add_model_request_handler(Self::handle_open_buffer_by_path);
@@ -298,4 +299,20 @@ impl HeadlessProject {
         }
         Ok(proto::ListRemoteDirectoryResponse { entries })
     }
+
+    pub async fn handle_check_file_exists(
+        this: Model<Self>,
+        envelope: TypedEnvelope<proto::CheckFileExists>,
+        cx: AsyncAppContext,
+    ) -> Result<proto::CheckFileExistsResponse> {
+        let fs = cx.read_model(&this, |this, _| this.fs.clone())?;
+        let expanded = shellexpand::tilde(&envelope.payload.path).to_string();
+
+        let exists = fs.is_file(&PathBuf::from(expanded.clone())).await;
+
+        Ok(proto::CheckFileExistsResponse {
+            exists,
+            path: expanded,
+        })
+    }
 }

crates/remote_server/src/remote_editing_tests.rs 🔗

@@ -12,7 +12,7 @@ use lsp::{CompletionContext, CompletionResponse, CompletionTriggerKind};
 use node_runtime::NodeRuntime;
 use project::{
     search::{SearchQuery, SearchResult},
-    Project,
+    Project, ProjectPath,
 };
 use remote::SshSession;
 use serde_json::json;
@@ -440,6 +440,49 @@ async fn test_remote_lsp(cx: &mut TestAppContext, server_cx: &mut TestAppContext
     })
 }
 
+#[gpui::test]
+async fn test_remote_resolve_file_path(cx: &mut TestAppContext, server_cx: &mut TestAppContext) {
+    let (project, _headless, _fs) = init_test(cx, server_cx).await;
+    let (worktree, _) = project
+        .update(cx, |project, cx| {
+            project.find_or_create_worktree("/code/project1", true, cx)
+        })
+        .await
+        .unwrap();
+
+    let worktree_id = cx.update(|cx| worktree.read(cx).id());
+
+    let buffer = project
+        .update(cx, |project, cx| {
+            project.open_buffer((worktree_id, Path::new("src/lib.rs")), cx)
+        })
+        .await
+        .unwrap();
+
+    let path = project
+        .update(cx, |project, cx| {
+            project.resolve_existing_file_path("/code/project1/README.md", &buffer, cx)
+        })
+        .await
+        .unwrap();
+    assert_eq!(
+        path.abs_path().unwrap().to_string_lossy(),
+        "/code/project1/README.md"
+    );
+
+    let path = project
+        .update(cx, |project, cx| {
+            project.resolve_existing_file_path("../README.md", &buffer, cx)
+        })
+        .await
+        .unwrap();
+
+    assert_eq!(
+        path.project_path().unwrap().clone(),
+        ProjectPath::from((worktree_id, "README.md"))
+    );
+}
+
 fn init_logger() {
     if std::env::var("RUST_LOG").is_ok() {
         env_logger::try_init().ok();