windows: Add support for SSH (#29145)

Closes #19892

This PR builds on top of #20587 and improves upon it.

Release Notes:

- N/A

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
张小白 2025-07-08 22:34:57 +08:00 committed by GitHub
parent 8bd739d869
commit 0ca0914cca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1435 additions and 354 deletions

View file

@ -33,7 +33,7 @@ use http_client::HttpClient;
use language::{Buffer, LanguageToolchainStore, language_settings::InlayHintKind};
use node_runtime::NodeRuntime;
use remote::SshRemoteClient;
use remote::{SshRemoteClient, ssh_session::SshArgs};
use rpc::{
AnyProtoClient, TypedEnvelope,
proto::{self},
@ -253,11 +253,16 @@ impl DapStore {
cx.spawn(async move |_, cx| {
let response = request.await?;
let binary = DebugAdapterBinary::from_proto(response)?;
let mut ssh_command = ssh_client.read_with(cx, |ssh, _| {
anyhow::Ok(SshCommand {
arguments: ssh.ssh_args().context("SSH arguments not found")?,
})
})??;
let (mut ssh_command, envs, path_style) =
ssh_client.read_with(cx, |ssh, _| {
let (SshArgs { arguments, envs }, path_style) =
ssh.ssh_info().context("SSH arguments not found")?;
anyhow::Ok((
SshCommand { arguments },
envs.unwrap_or_default(),
path_style,
))
})??;
let mut connection = None;
if let Some(c) = binary.connection {
@ -282,12 +287,13 @@ impl DapStore {
binary.cwd.as_deref(),
binary.envs,
None,
path_style,
);
Ok(DebugAdapterBinary {
command: Some(program),
arguments: args,
envs: HashMap::default(),
envs,
cwd: None,
connection,
request_args: binary.request_args,

View file

@ -117,7 +117,7 @@ use text::{Anchor, BufferId, Point};
use toolchain_store::EmptyToolchainStore;
use util::{
ResultExt as _,
paths::{SanitizedPath, compare_paths},
paths::{PathStyle, RemotePathBuf, SanitizedPath, compare_paths},
};
use worktree::{CreatedEntry, Snapshot, Traversal};
pub use worktree::{
@ -1159,9 +1159,11 @@ impl Project {
let snippets =
SnippetProvider::new(fs.clone(), BTreeSet::from_iter([global_snippets_dir]), cx);
let ssh_proto = ssh.read(cx).proto_client();
let worktree_store =
cx.new(|_| WorktreeStore::remote(false, ssh_proto.clone(), SSH_PROJECT_ID));
let (ssh_proto, path_style) =
ssh.read_with(cx, |ssh, _| (ssh.proto_client(), ssh.path_style()));
let worktree_store = cx.new(|_| {
WorktreeStore::remote(false, ssh_proto.clone(), SSH_PROJECT_ID, path_style)
});
cx.subscribe(&worktree_store, Self::on_worktree_store_event)
.detach();
@ -1410,8 +1412,15 @@ impl Project {
let remote_id = response.payload.project_id;
let role = response.payload.role();
// todo(zjk)
// Set the proper path style based on the remote
let worktree_store = cx.new(|_| {
WorktreeStore::remote(true, client.clone().into(), response.payload.project_id)
WorktreeStore::remote(
true,
client.clone().into(),
response.payload.project_id,
PathStyle::Posix,
)
})?;
let buffer_store = cx.new(|cx| {
BufferStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx)
@ -4039,7 +4048,8 @@ impl Project {
})
})
} else if let Some(ssh_client) = self.ssh_client.as_ref() {
let request_path = Path::new(path);
let path_style = ssh_client.read(cx).path_style();
let request_path = RemotePathBuf::from_str(path, path_style);
let request = ssh_client
.read(cx)
.proto_client()

View file

@ -4,6 +4,7 @@ use collections::HashMap;
use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, Task, WeakEntity};
use itertools::Itertools;
use language::LanguageName;
use remote::ssh_session::SshArgs;
use settings::{Settings, SettingsLocation};
use smol::channel::bounded;
use std::{
@ -17,7 +18,10 @@ use terminal::{
TaskState, TaskStatus, Terminal, TerminalBuilder,
terminal_settings::{self, TerminalSettings, VenvSettings},
};
use util::ResultExt;
use util::{
ResultExt,
paths::{PathStyle, RemotePathBuf},
};
pub struct Terminals {
pub(crate) local_handles: Vec<WeakEntity<terminal::Terminal>>,
@ -47,6 +51,13 @@ impl SshCommand {
}
}
pub struct SshDetails {
pub host: String,
pub ssh_command: SshCommand,
pub envs: Option<HashMap<String, String>>,
pub path_style: PathStyle,
}
impl Project {
pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
let worktree = self
@ -68,14 +79,16 @@ impl Project {
}
}
pub fn ssh_details(&self, cx: &App) -> Option<(String, SshCommand)> {
pub fn ssh_details(&self, cx: &App) -> Option<SshDetails> {
if let Some(ssh_client) = &self.ssh_client {
let ssh_client = ssh_client.read(cx);
if let Some(args) = ssh_client.ssh_args() {
return Some((
ssh_client.connection_options().host.clone(),
SshCommand { arguments: args },
));
if let Some((SshArgs { arguments, envs }, path_style)) = ssh_client.ssh_info() {
return Some(SshDetails {
host: ssh_client.connection_options().host.clone(),
ssh_command: SshCommand { arguments },
envs,
path_style,
});
}
}
@ -158,17 +171,26 @@ impl Project {
.unwrap_or_default();
env.extend(settings.env.clone());
match &self.ssh_details(cx) {
Some((_, ssh_command)) => {
match self.ssh_details(cx) {
Some(SshDetails {
ssh_command,
envs,
path_style,
..
}) => {
let (command, args) = wrap_for_ssh(
ssh_command,
&ssh_command,
Some((&command, &args)),
path.as_deref(),
env,
None,
path_style,
);
let mut command = std::process::Command::new(command);
command.args(args);
if let Some(envs) = envs {
command.envs(envs);
}
command
}
None => {
@ -202,6 +224,7 @@ impl Project {
}
};
let ssh_details = this.ssh_details(cx);
let is_ssh_terminal = ssh_details.is_some();
let mut settings_location = None;
if let Some(path) = path.as_ref() {
@ -226,11 +249,7 @@ impl Project {
// precedence.
env.extend(settings.env.clone());
let local_path = if ssh_details.is_none() {
path.clone()
} else {
None
};
let local_path = if is_ssh_terminal { None } else { path.clone() };
let mut python_venv_activate_command = None;
@ -241,8 +260,13 @@ impl Project {
this.python_activate_command(python_venv_directory, &settings.detect_venv);
}
match &ssh_details {
Some((host, ssh_command)) => {
match ssh_details {
Some(SshDetails {
host,
ssh_command,
envs,
path_style,
}) => {
log::debug!("Connecting to a remote server: {ssh_command:?}");
// Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
@ -252,9 +276,18 @@ impl Project {
env.entry("TERM".to_string())
.or_insert_with(|| "xterm-256color".to_string());
let (program, args) =
wrap_for_ssh(&ssh_command, None, path.as_deref(), env, None);
let (program, args) = wrap_for_ssh(
&ssh_command,
None,
path.as_deref(),
env,
None,
path_style,
);
env = HashMap::default();
if let Some(envs) = envs {
env.extend(envs);
}
(
Option::<TaskState>::None,
Shell::WithArguments {
@ -290,8 +323,13 @@ impl Project {
);
}
match &ssh_details {
Some((host, ssh_command)) => {
match ssh_details {
Some(SshDetails {
host,
ssh_command,
envs,
path_style,
}) => {
log::debug!("Connecting to a remote server: {ssh_command:?}");
env.entry("TERM".to_string())
.or_insert_with(|| "xterm-256color".to_string());
@ -304,8 +342,12 @@ impl Project {
path.as_deref(),
env,
python_venv_directory.as_deref(),
path_style,
);
env = HashMap::default();
if let Some(envs) = envs {
env.extend(envs);
}
(
task_state,
Shell::WithArguments {
@ -343,7 +385,7 @@ impl Project {
settings.cursor_shape.unwrap_or_default(),
settings.alternate_scroll,
settings.max_scroll_history_lines,
ssh_details.is_some(),
is_ssh_terminal,
window,
completion_tx,
cx,
@ -533,6 +575,7 @@ pub fn wrap_for_ssh(
path: Option<&Path>,
env: HashMap<String, String>,
venv_directory: Option<&Path>,
path_style: PathStyle,
) -> (String, Vec<String>) {
let to_run = if let Some((command, args)) = command {
// DEFAULT_REMOTE_SHELL is '"${SHELL:-sh}"' so must not be escaped
@ -555,24 +598,25 @@ pub fn wrap_for_ssh(
}
if let Some(venv_directory) = venv_directory {
if let Ok(str) = shlex::try_quote(venv_directory.to_string_lossy().as_ref()) {
env_changes.push_str(&format!("PATH={}:$PATH ", str));
let path = RemotePathBuf::new(PathBuf::from(str.to_string()), path_style).to_string();
env_changes.push_str(&format!("PATH={}:$PATH ", path));
}
}
let commands = if let Some(path) = path {
let path_string = path.to_string_lossy().to_string();
let path = RemotePathBuf::new(path.to_path_buf(), path_style).to_string();
// shlex will wrap the command in single quotes (''), disabling ~ expansion,
// replace ith with something that works
let tilde_prefix = "~/";
if path.starts_with(tilde_prefix) {
let trimmed_path = path_string
let trimmed_path = path
.trim_start_matches("/")
.trim_start_matches("~")
.trim_start_matches("/");
format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}")
} else {
format!("cd {path:?}; {env_changes} {to_run}")
format!("cd {path}; {env_changes} {to_run}")
}
} else {
format!("cd; {env_changes} {to_run}")

View file

@ -25,7 +25,10 @@ use smol::{
stream::StreamExt,
};
use text::ReplicaId;
use util::{ResultExt, paths::SanitizedPath};
use util::{
ResultExt,
paths::{PathStyle, RemotePathBuf, SanitizedPath},
};
use worktree::{
Entry, ProjectEntryId, UpdatedEntriesSet, UpdatedGitRepositoriesSet, Worktree, WorktreeId,
WorktreeSettings,
@ -46,6 +49,7 @@ enum WorktreeStoreState {
Remote {
upstream_client: AnyProtoClient,
upstream_project_id: u64,
path_style: PathStyle,
},
}
@ -100,6 +104,7 @@ impl WorktreeStore {
retain_worktrees: bool,
upstream_client: AnyProtoClient,
upstream_project_id: u64,
path_style: PathStyle,
) -> Self {
Self {
next_entry_id: Default::default(),
@ -111,6 +116,7 @@ impl WorktreeStore {
state: WorktreeStoreState::Remote {
upstream_client,
upstream_project_id,
path_style,
},
}
}
@ -214,17 +220,16 @@ impl WorktreeStore {
if !self.loading_worktrees.contains_key(&abs_path) {
let task = match &self.state {
WorktreeStoreState::Remote {
upstream_client, ..
upstream_client,
path_style,
..
} => {
if upstream_client.is_via_collab() {
Task::ready(Err(Arc::new(anyhow!("cannot create worktrees via collab"))))
} else {
self.create_ssh_worktree(
upstream_client.clone(),
abs_path.clone(),
visible,
cx,
)
let abs_path =
RemotePathBuf::new(abs_path.as_path().to_path_buf(), *path_style);
self.create_ssh_worktree(upstream_client.clone(), abs_path, visible, cx)
}
}
WorktreeStoreState::Local { fs } => {
@ -250,11 +255,12 @@ impl WorktreeStore {
fn create_ssh_worktree(
&mut self,
client: AnyProtoClient,
abs_path: impl Into<SanitizedPath>,
abs_path: RemotePathBuf,
visible: bool,
cx: &mut Context<Self>,
) -> Task<Result<Entity<Worktree>, Arc<anyhow::Error>>> {
let mut abs_path = Into::<SanitizedPath>::into(abs_path).to_string();
let path_style = abs_path.path_style();
let mut abs_path = abs_path.to_string();
// If we start with `/~` that means the ssh path was something like `ssh://user@host/~/home-dir-folder/`
// in which case want to strip the leading the `/`.
// On the host-side, the `~` will get expanded.
@ -265,10 +271,11 @@ impl WorktreeStore {
if abs_path.is_empty() {
abs_path = "~/".to_string();
}
cx.spawn(async move |this, cx| {
let this = this.upgrade().context("Dropped worktree store")?;
let path = Path::new(abs_path.as_str());
let path = RemotePathBuf::new(abs_path.into(), path_style);
let response = client
.request(proto::AddWorktree {
project_id: SSH_PROJECT_ID,