remote: Support line and column numbers for remote paths (#40410)

Lukas Wirth and Max Brunsfeld created

Closes #40297
Closes https://github.com/zed-industries/zed/issues/40367

Release Notes:

- Improved line and column number handling for paths in remotes

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>

Change summary

crates/agent_servers/src/acp.rs                  |   2 
crates/cli/README.md                             |  15 +
crates/cli/src/main.rs                           |  11 
crates/recent_projects/src/remote_connections.rs | 175 +++++++++++------
crates/recent_projects/src/remote_servers.rs     |  45 ++++
crates/remote/src/remote.rs                      |   2 
crates/remote/src/remote_client.rs               |  89 ++++----
crates/remote/src/transport.rs                   |   1 
crates/workspace/src/workspace.rs                |  24 +-
9 files changed, 238 insertions(+), 126 deletions(-)

Detailed changes

crates/agent_servers/src/acp.rs 🔗

@@ -98,7 +98,7 @@ impl AcpConnection {
         let stdout = child.stdout.take().context("Failed to take stdout")?;
         let stdin = child.stdin.take().context("Failed to take stdin")?;
         let stderr = child.stderr.take().context("Failed to take stderr")?;
-        log::info!(
+        log::debug!(
             "Spawning external agent server: {:?}, {:?}",
             command.path,
             command.args

crates/cli/README.md 🔗

@@ -0,0 +1,15 @@
+# Cli
+
+## Testing
+
+You can test your changes to the `cli` crate by first building the main zed binary:
+
+```
+cargo build -p zed
+```
+
+And then building and running the `cli` crate with the following parameters:
+
+```
+ cargo run -p cli -- --zed ./target/debug/zed.exe
+```

crates/cli/src/main.rs 🔗

@@ -155,6 +155,7 @@ fn parse_path_with_position(argument_str: &str) -> anyhow::Result<String> {
 }
 
 fn parse_path_in_wsl(source: &str, wsl: &str) -> Result<String> {
+    let mut source = PathWithPosition::parse_str(source);
     let mut command = util::command::new_std_command("wsl.exe");
 
     let (user, distro_name) = if let Some((user, distro)) = wsl.split_once('@') {
@@ -173,19 +174,17 @@ fn parse_path_in_wsl(source: &str, wsl: &str) -> Result<String> {
     let output = command
         .arg("--distribution")
         .arg(distro_name)
+        .arg("--exec")
         .arg("wslpath")
         .arg("-m")
-        .arg(source)
+        .arg(&source.path)
         .output()?;
 
     let result = String::from_utf8_lossy(&output.stdout);
     let prefix = format!("//wsl.localhost/{}", distro_name);
+    source.path = Path::new(result.trim().strip_prefix(&prefix).unwrap_or(&result)).to_owned();
 
-    Ok(result
-        .trim()
-        .strip_prefix(&prefix)
-        .unwrap_or(&result)
-        .to_string())
+    Ok(source.to_string(|path| path.to_string_lossy().into_owned()))
 }
 
 fn main() -> Result<()> {

crates/recent_projects/src/remote_connections.rs 🔗

@@ -1,4 +1,7 @@
-use std::{path::PathBuf, sync::Arc};
+use std::{
+    path::{Path, PathBuf},
+    sync::Arc,
+};
 
 use anyhow::{Context as _, Result};
 use askpass::EncryptedPassword;
@@ -12,11 +15,11 @@ use gpui::{
     TextStyleRefinement, WeakEntity,
 };
 
-use language::CursorShape;
+use language::{CursorShape, Point};
 use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 use release_channel::ReleaseChannel;
 use remote::{
-    ConnectionIdentifier, RemoteClient, RemoteConnectionOptions, RemotePlatform,
+    ConnectionIdentifier, RemoteClient, RemoteConnection, RemoteConnectionOptions, RemotePlatform,
     SshConnectionOptions,
 };
 pub use settings::SshConnection;
@@ -26,6 +29,7 @@ use ui::{
     ActiveTheme, Color, CommonAnimationExt, Context, Icon, IconName, IconSize, InteractiveElement,
     IntoElement, Label, LabelCommon, Styled, Window, prelude::*,
 };
+use util::paths::PathWithPosition;
 use workspace::{AppState, ModalView, Workspace};
 
 pub struct SshSettings {
@@ -533,34 +537,6 @@ impl RemoteClientDelegate {
     }
 }
 
-pub fn connect_over_ssh(
-    unique_identifier: ConnectionIdentifier,
-    connection_options: SshConnectionOptions,
-    ui: Entity<RemoteConnectionPrompt>,
-    window: &mut Window,
-    cx: &mut App,
-) -> Task<Result<Option<Entity<RemoteClient>>>> {
-    let window = window.window_handle();
-    let known_password = connection_options
-        .password
-        .as_deref()
-        .and_then(|pw| EncryptedPassword::try_from(pw).ok());
-    let (tx, rx) = oneshot::channel();
-    ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
-
-    remote::RemoteClient::ssh(
-        unique_identifier,
-        connection_options,
-        rx,
-        Arc::new(RemoteClientDelegate {
-            window,
-            ui: ui.downgrade(),
-            known_password,
-        }),
-        cx,
-    )
-}
-
 pub fn connect(
     unique_identifier: ConnectionIdentifier,
     connection_options: RemoteConnectionOptions,
@@ -579,17 +555,17 @@ pub fn connect(
     let (tx, rx) = oneshot::channel();
     ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
 
-    remote::RemoteClient::new(
-        unique_identifier,
-        connection_options,
-        rx,
-        Arc::new(RemoteClientDelegate {
-            window,
-            ui: ui.downgrade(),
-            known_password,
-        }),
-        cx,
-    )
+    let delegate = Arc::new(RemoteClientDelegate {
+        window,
+        ui: ui.downgrade(),
+        known_password,
+    });
+
+    cx.spawn(async move |cx| {
+        let connection = remote::connect(connection_options, delegate.clone(), cx).await?;
+        cx.update(|cx| remote::RemoteClient::new(unique_identifier, connection, rx, delegate, cx))?
+            .await
+    })
 }
 
 pub async fn open_remote_project(
@@ -604,6 +580,7 @@ pub async fn open_remote_project(
     } else {
         let workspace_position = cx
             .update(|cx| {
+                // todo: These paths are wrong they may have column and line information
                 workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
             })?
             .await
@@ -671,11 +648,16 @@ pub async fn open_remote_project(
 
         let Some(delegate) = delegate else { break };
 
-        let did_open_project = cx
+        let remote_connection =
+            remote::connect(connection_options.clone(), delegate.clone(), cx).await?;
+        let (paths, paths_with_positions) =
+            determine_paths_with_positions(&remote_connection, paths.clone()).await;
+
+        let opened_items = cx
             .update(|cx| {
                 workspace::open_remote_project_with_new_connection(
                     window,
-                    connection_options.clone(),
+                    remote_connection,
                     cancel_rx,
                     delegate.clone(),
                     app_state.clone(),
@@ -693,25 +675,51 @@ pub async fn open_remote_project(
             })
             .ok();
 
-        if let Err(e) = did_open_project {
-            log::error!("Failed to open project: {e:?}");
-            let response = window
-                .update(cx, |_, window, cx| {
-                    window.prompt(
-                        PromptLevel::Critical,
-                        match connection_options {
-                            RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
-                            RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
-                        },
-                        Some(&e.to_string()),
-                        &["Retry", "Ok"],
-                        cx,
-                    )
-                })?
-                .await;
-
-            if response == Ok(0) {
-                continue;
+        match opened_items {
+            Err(e) => {
+                log::error!("Failed to open project: {e:?}");
+                let response = window
+                    .update(cx, |_, window, cx| {
+                        window.prompt(
+                            PromptLevel::Critical,
+                            match connection_options {
+                                RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
+                                RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
+                            },
+                            Some(&e.to_string()),
+                            &["Retry", "Ok"],
+                            cx,
+                        )
+                    })?
+                    .await;
+                if response == Ok(0) {
+                    continue;
+                }
+            }
+            Ok(items) => {
+                for (item, path) in items.into_iter().zip(paths_with_positions) {
+                    let Some(item) = item else {
+                        continue;
+                    };
+                    let Some(row) = path.row else {
+                        continue;
+                    };
+                    if let Some(active_editor) = item.downcast::<Editor>() {
+                        window
+                            .update(cx, |_, window, cx| {
+                                active_editor.update(cx, |editor, cx| {
+                                    let row = row.saturating_sub(1);
+                                    let col = path.column.unwrap_or(0).saturating_sub(1);
+                                    editor.go_to_singleton_buffer_point(
+                                        Point::new(row, col),
+                                        window,
+                                        cx,
+                                    );
+                                });
+                            })
+                            .ok();
+                    }
+                }
             }
         }
 
@@ -730,3 +738,44 @@ pub async fn open_remote_project(
     // Already showed the error to the user
     Ok(())
 }
+
+pub(crate) async fn determine_paths_with_positions(
+    remote_connection: &Arc<dyn RemoteConnection>,
+    mut paths: Vec<PathBuf>,
+) -> (Vec<PathBuf>, Vec<PathWithPosition>) {
+    let mut paths_with_positions = Vec::<PathWithPosition>::new();
+    for path in &mut paths {
+        if let Some(path_str) = path.to_str() {
+            let path_with_position = PathWithPosition::parse_str(&path_str);
+            if path_with_position.row.is_some() {
+                if !path_exists(&remote_connection, &path).await {
+                    *path = path_with_position.path.clone();
+                    paths_with_positions.push(path_with_position);
+                    continue;
+                }
+            }
+        }
+        paths_with_positions.push(PathWithPosition::from_path(path.clone()))
+    }
+    (paths, paths_with_positions)
+}
+
+async fn path_exists(connection: &Arc<dyn RemoteConnection>, path: &Path) -> bool {
+    let Ok(command) = connection.build_command(
+        Some("test".to_string()),
+        &["-e".to_owned(), path.to_string_lossy().to_string()],
+        &Default::default(),
+        None,
+        None,
+    ) else {
+        return false;
+    };
+    let Ok(mut child) = util::command::new_smol_command(command.program)
+        .args(command.args)
+        .envs(command.env)
+        .spawn()
+    else {
+        return false;
+    };
+    child.status().await.is_ok_and(|status| status.success())
+}

crates/recent_projects/src/remote_servers.rs 🔗

@@ -1,7 +1,8 @@
 use crate::{
     remote_connections::{
         Connection, RemoteConnectionModal, RemoteConnectionPrompt, SshConnection,
-        SshConnectionHeader, SshSettings, connect, connect_over_ssh, open_remote_project,
+        SshConnectionHeader, SshSettings, connect, determine_paths_with_positions,
+        open_remote_project,
     },
     ssh_config::parse_ssh_config_hosts,
 };
@@ -13,6 +14,7 @@ use gpui::{
     FocusHandle, Focusable, PromptLevel, ScrollHandle, Subscription, Task, WeakEntity, Window,
     canvas,
 };
+use language::Point;
 use log::info;
 use paths::{global_ssh_config_file, user_ssh_config_file};
 use picker::Picker;
@@ -233,6 +235,15 @@ impl ProjectPicker {
                         .read_with(cx, |workspace, _| workspace.app_state().clone())
                         .ok()?;
 
+                    let remote_connection = project
+                        .read_with(cx, |project, cx| {
+                            project.remote_client()?.read(cx).connection()
+                        })
+                        .ok()??;
+
+                    let (paths, paths_with_positions) =
+                        determine_paths_with_positions(&remote_connection, paths).await;
+
                     cx.update(|_, cx| {
                         let fs = app_state.fs.clone();
                         update_settings_file(fs, cx, {
@@ -278,12 +289,38 @@ impl ProjectPicker {
                         })
                         .log_err()?;
 
-                    open_remote_project_with_existing_connection(
+                    let items = open_remote_project_with_existing_connection(
                         connection, project, paths, app_state, window, cx,
                     )
                     .await
                     .log_err();
 
+                    if let Some(items) = items {
+                        for (item, path) in items.into_iter().zip(paths_with_positions) {
+                            let Some(item) = item else {
+                                continue;
+                            };
+                            let Some(row) = path.row else {
+                                continue;
+                            };
+                            if let Some(active_editor) = item.downcast::<Editor>() {
+                                window
+                                    .update(cx, |_, window, cx| {
+                                        active_editor.update(cx, |editor, cx| {
+                                            let row = row.saturating_sub(1);
+                                            let col = path.column.unwrap_or(0).saturating_sub(1);
+                                            editor.go_to_singleton_buffer_point(
+                                                Point::new(row, col),
+                                                window,
+                                                cx,
+                                            );
+                                        });
+                                    })
+                                    .ok();
+                            }
+                        }
+                    }
+
                     this.update(cx, |_, cx| {
                         cx.emit(DismissEvent);
                     })
@@ -671,9 +708,9 @@ impl RemoteServerProjects {
             )
         });
 
-        let connection = connect_over_ssh(
+        let connection = connect(
             ConnectionIdentifier::setup(),
-            connection_options.clone(),
+            RemoteConnectionOptions::Ssh(connection_options.clone()),
             ssh_prompt.clone(),
             window,
             cx,

crates/remote/src/remote.rs 🔗

@@ -6,7 +6,7 @@ mod transport;
 
 pub use remote_client::{
     ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent,
-    RemoteConnectionOptions, RemotePlatform,
+    RemoteConnection, RemoteConnectionOptions, RemotePlatform, connect,
 };
 pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption};
 pub use transport::wsl::WslConnectionOptions;

crates/remote/src/remote_client.rs 🔗

@@ -93,7 +93,7 @@ const MAX_RECONNECT_ATTEMPTS: usize = 3;
 enum State {
     Connecting,
     Connected {
-        ssh_connection: Arc<dyn RemoteConnection>,
+        remote_connection: Arc<dyn RemoteConnection>,
         delegate: Arc<dyn RemoteClientDelegate>,
 
         multiplex_task: Task<Result<()>>,
@@ -137,7 +137,10 @@ impl fmt::Display for State {
 impl State {
     fn remote_connection(&self) -> Option<Arc<dyn RemoteConnection>> {
         match self {
-            Self::Connected { ssh_connection, .. } => Some(ssh_connection.clone()),
+            Self::Connected {
+                remote_connection: ssh_connection,
+                ..
+            } => Some(ssh_connection.clone()),
             Self::HeartbeatMissed { ssh_connection, .. } => Some(ssh_connection.clone()),
             Self::ReconnectFailed { ssh_connection, .. } => Some(ssh_connection.clone()),
             _ => None,
@@ -181,7 +184,7 @@ impl State {
                 heartbeat_task,
                 ..
             } => Self::Connected {
-                ssh_connection,
+                remote_connection: ssh_connection,
                 delegate,
                 multiplex_task,
                 heartbeat_task,
@@ -193,7 +196,7 @@ impl State {
     fn heartbeat_missed(self) -> Self {
         match self {
             Self::Connected {
-                ssh_connection,
+                remote_connection: ssh_connection,
                 delegate,
                 multiplex_task,
                 heartbeat_task,
@@ -260,8 +263,8 @@ pub enum RemoteClientEvent {
 
 impl EventEmitter<RemoteClientEvent> for RemoteClient {}
 
-// Identifies the socket on the remote server so that reconnects
-// can re-join the same project.
+/// Identifies the socket on the remote server so that reconnects
+/// can re-join the same project.
 pub enum ConnectionIdentifier {
     Setup(u64),
     Workspace(i64),
@@ -294,26 +297,24 @@ impl ConnectionIdentifier {
     }
 }
 
-impl RemoteClient {
-    pub fn ssh(
-        unique_identifier: ConnectionIdentifier,
-        connection_options: SshConnectionOptions,
-        cancellation: oneshot::Receiver<()>,
-        delegate: Arc<dyn RemoteClientDelegate>,
-        cx: &mut App,
-    ) -> Task<Result<Option<Entity<Self>>>> {
-        Self::new(
-            unique_identifier,
-            RemoteConnectionOptions::Ssh(connection_options),
-            cancellation,
-            delegate,
-            cx,
-        )
-    }
+pub async fn connect(
+    connection_options: RemoteConnectionOptions,
+    delegate: Arc<dyn RemoteClientDelegate>,
+    cx: &mut AsyncApp,
+) -> Result<Arc<dyn RemoteConnection>> {
+    cx.update(|cx| {
+        cx.update_default_global(|pool: &mut ConnectionPool, cx| {
+            pool.connect(connection_options.clone(), delegate.clone(), cx)
+        })
+    })?
+    .await
+    .map_err(|e| e.cloned())
+}
 
+impl RemoteClient {
     pub fn new(
         unique_identifier: ConnectionIdentifier,
-        connection_options: RemoteConnectionOptions,
+        remote_connection: Arc<dyn RemoteConnection>,
         cancellation: oneshot::Receiver<()>,
         delegate: Arc<dyn RemoteClientDelegate>,
         cx: &mut App,
@@ -328,25 +329,16 @@ impl RemoteClient {
                 let client =
                     cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "client"))?;
 
-                let ssh_connection = cx
-                    .update(|cx| {
-                        cx.update_default_global(|pool: &mut ConnectionPool, cx| {
-                            pool.connect(connection_options.clone(), delegate.clone(), cx)
-                        })
-                    })?
-                    .await
-                    .map_err(|e| e.cloned())?;
-
-                let path_style = ssh_connection.path_style();
+                let path_style = remote_connection.path_style();
                 let this = cx.new(|_| Self {
                     client: client.clone(),
                     unique_identifier: unique_identifier.clone(),
-                    connection_options,
+                    connection_options: remote_connection.connection_options(),
                     path_style,
                     state: Some(State::Connecting),
                 })?;
 
-                let io_task = ssh_connection.start_proxy(
+                let io_task = remote_connection.start_proxy(
                     unique_identifier,
                     false,
                     incoming_tx,
@@ -402,7 +394,7 @@ impl RemoteClient {
 
                 this.update(cx, |this, _| {
                     this.state = Some(State::Connected {
-                        ssh_connection,
+                        remote_connection,
                         delegate,
                         multiplex_task,
                         heartbeat_task,
@@ -441,7 +433,7 @@ impl RemoteClient {
         let State::Connected {
             multiplex_task,
             heartbeat_task,
-            ssh_connection,
+            remote_connection: ssh_connection,
             delegate,
         } = state
         else {
@@ -488,7 +480,7 @@ impl RemoteClient {
         let state = self.state.take().unwrap();
         let (attempts, remote_connection, delegate) = match state {
             State::Connected {
-                ssh_connection,
+                remote_connection: ssh_connection,
                 delegate,
                 multiplex_task,
                 heartbeat_task,
@@ -593,7 +585,7 @@ impl RemoteClient {
             };
 
             State::Connected {
-                ssh_connection,
+                remote_connection: ssh_connection,
                 delegate,
                 multiplex_task,
                 heartbeat_task: Self::heartbeat(this.clone(), connection_activity_rx, cx),
@@ -866,6 +858,17 @@ impl RemoteClient {
         self.connection_options.clone()
     }
 
+    pub fn connection(&self) -> Option<Arc<dyn RemoteConnection>> {
+        if let State::Connected {
+            remote_connection, ..
+        } = self.state.as_ref()?
+        {
+            Some(remote_connection.clone())
+        } else {
+            None
+        }
+    }
+
     pub fn connection_state(&self) -> ConnectionState {
         self.state
             .as_ref()
@@ -947,11 +950,15 @@ impl RemoteClient {
         client_cx: &mut gpui::TestAppContext,
     ) -> Entity<Self> {
         let (_tx, rx) = oneshot::channel();
+        let mut cx = client_cx.to_async();
+        let connection = connect(opts, Arc::new(fake::Delegate), &mut cx)
+            .await
+            .unwrap();
         client_cx
             .update(|cx| {
                 Self::new(
                     ConnectionIdentifier::setup(),
-                    opts,
+                    connection,
                     rx,
                     Arc::new(fake::Delegate),
                     cx,
@@ -1084,7 +1091,7 @@ impl From<WslConnectionOptions> for RemoteConnectionOptions {
 }
 
 #[async_trait(?Send)]
-pub(crate) trait RemoteConnection: Send + Sync {
+pub trait RemoteConnection: Send + Sync {
     fn start_proxy(
         &self,
         unique_identifier: String,

crates/remote/src/transport.rs 🔗

@@ -183,6 +183,7 @@ async fn build_remote_server_from_source(
         log::info!("building remote server binary from source");
         run_cmd(
             Command::new("cargo")
+                .current_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/../.."))
                 .args([
                     "build",
                     "--package",

crates/workspace/src/workspace.rs 🔗

@@ -76,7 +76,10 @@ use project::{
     debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus},
     toolchain_store::ToolchainStoreEvent,
 };
-use remote::{RemoteClientDelegate, RemoteConnectionOptions, remote_client::ConnectionIdentifier};
+use remote::{
+    RemoteClientDelegate, RemoteConnection, RemoteConnectionOptions,
+    remote_client::ConnectionIdentifier,
+};
 use schemars::JsonSchema;
 use serde::Deserialize;
 use session::AppSession;
@@ -7441,22 +7444,23 @@ pub fn create_and_open_local_file(
 
 pub fn open_remote_project_with_new_connection(
     window: WindowHandle<Workspace>,
-    connection_options: RemoteConnectionOptions,
+    remote_connection: Arc<dyn RemoteConnection>,
     cancel_rx: oneshot::Receiver<()>,
     delegate: Arc<dyn RemoteClientDelegate>,
     app_state: Arc<AppState>,
     paths: Vec<PathBuf>,
     cx: &mut App,
-) -> Task<Result<()>> {
+) -> Task<Result<Vec<Option<Box<dyn ItemHandle>>>>> {
     cx.spawn(async move |cx| {
         let (workspace_id, serialized_workspace) =
-            serialize_remote_project(connection_options.clone(), paths.clone(), cx).await?;
+            serialize_remote_project(remote_connection.connection_options(), paths.clone(), cx)
+                .await?;
 
         let session = match cx
             .update(|cx| {
                 remote::RemoteClient::new(
                     ConnectionIdentifier::Workspace(workspace_id.0),
-                    connection_options,
+                    remote_connection,
                     cancel_rx,
                     delegate,
                     cx,
@@ -7465,7 +7469,7 @@ pub fn open_remote_project_with_new_connection(
             .await?
         {
             Some(result) => result,
-            None => return Ok(()),
+            None => return Ok(Vec::new()),
         };
 
         let project = cx.update(|cx| {
@@ -7500,7 +7504,7 @@ pub fn open_remote_project_with_existing_connection(
     app_state: Arc<AppState>,
     window: WindowHandle<Workspace>,
     cx: &mut AsyncApp,
-) -> Task<Result<()>> {
+) -> Task<Result<Vec<Option<Box<dyn ItemHandle>>>>> {
     cx.spawn(async move |cx| {
         let (workspace_id, serialized_workspace) =
             serialize_remote_project(connection_options.clone(), paths.clone(), cx).await?;
@@ -7526,7 +7530,7 @@ async fn open_remote_project_inner(
     app_state: Arc<AppState>,
     window: WindowHandle<Workspace>,
     cx: &mut AsyncApp,
-) -> Result<()> {
+) -> Result<Vec<Option<Box<dyn ItemHandle>>>> {
     let toolchains = DB.toolchains(workspace_id).await?;
     for (toolchain, worktree_id, path) in toolchains {
         project
@@ -7583,7 +7587,7 @@ async fn open_remote_project_inner(
         });
     })?;
 
-    window
+    let items = window
         .update(cx, |_, window, cx| {
             window.activate_window();
             open_items(serialized_workspace, project_paths_to_open, window, cx)
@@ -7602,7 +7606,7 @@ async fn open_remote_project_inner(
         }
     })?;
 
-    Ok(())
+    Ok(items.into_iter().map(|item| item?.ok()).collect())
 }
 
 fn serialize_remote_project(