Allow ssh connection for setting up zed (#12063)
Co-Authored-By: Mikayla <mikayla@zed.dev> Release Notes: - Magic `ssh` login feature for remote development --------- Co-authored-by: Mikayla <mikayla@zed.dev> Co-authored-by: Nate Butler <iamnbutler@gmail.com>
This commit is contained in:
parent
3382e79ef9
commit
e5b9e2044e
29 changed files with 1242 additions and 785 deletions
|
@ -5,8 +5,13 @@ use gpui::{
|
|||
};
|
||||
use settings::{Settings, SettingsLocation};
|
||||
use smol::channel::bounded;
|
||||
use std::path::{Path, PathBuf};
|
||||
use task::SpawnInTerminal;
|
||||
use std::{
|
||||
env,
|
||||
fs::File,
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use task::{SpawnInTerminal, TerminalWorkDir};
|
||||
use terminal::{
|
||||
terminal_settings::{self, Shell, TerminalSettings, VenvSettingsContent},
|
||||
TaskState, TaskStatus, Terminal, TerminalBuilder,
|
||||
|
@ -27,58 +32,57 @@ pub struct ConnectRemoteTerminal {
|
|||
}
|
||||
|
||||
impl Project {
|
||||
pub fn remote_terminal_connection_data(
|
||||
pub fn terminal_work_dir_for(
|
||||
&self,
|
||||
pathbuf: Option<&PathBuf>,
|
||||
cx: &AppContext,
|
||||
) -> Option<ConnectRemoteTerminal> {
|
||||
self.dev_server_project_id()
|
||||
.and_then(|dev_server_project_id| {
|
||||
let projects_store = dev_server_projects::Store::global(cx).read(cx);
|
||||
let project_path = projects_store
|
||||
.dev_server_project(dev_server_project_id)?
|
||||
.path
|
||||
.clone();
|
||||
let ssh_connection_string = projects_store
|
||||
.dev_server_for_project(dev_server_project_id)?
|
||||
.ssh_connection_string
|
||||
.clone();
|
||||
Some(project_path).zip(ssh_connection_string)
|
||||
})
|
||||
.map(
|
||||
|(project_path, ssh_connection_string)| ConnectRemoteTerminal {
|
||||
ssh_connection_string,
|
||||
project_path,
|
||||
},
|
||||
)
|
||||
) -> Option<TerminalWorkDir> {
|
||||
if self.is_local() {
|
||||
return Some(TerminalWorkDir::Local(pathbuf?.clone()));
|
||||
}
|
||||
let dev_server_project_id = self.dev_server_project_id()?;
|
||||
let projects_store = dev_server_projects::Store::global(cx).read(cx);
|
||||
let ssh_command = projects_store
|
||||
.dev_server_for_project(dev_server_project_id)?
|
||||
.ssh_connection_string
|
||||
.clone()?
|
||||
.to_string();
|
||||
|
||||
let path = if let Some(pathbuf) = pathbuf {
|
||||
pathbuf.to_string_lossy().to_string()
|
||||
} else {
|
||||
projects_store
|
||||
.dev_server_project(dev_server_project_id)?
|
||||
.path
|
||||
.to_string()
|
||||
};
|
||||
|
||||
Some(TerminalWorkDir::Ssh {
|
||||
ssh_command,
|
||||
path: Some(path),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_terminal(
|
||||
&mut self,
|
||||
working_directory: Option<PathBuf>,
|
||||
working_directory: Option<TerminalWorkDir>,
|
||||
spawn_task: Option<SpawnInTerminal>,
|
||||
window: AnyWindowHandle,
|
||||
cx: &mut ModelContext<Self>,
|
||||
) -> anyhow::Result<Model<Terminal>> {
|
||||
let remote_connection_data = if self.is_remote() {
|
||||
let remote_connection_data = self.remote_terminal_connection_data(cx);
|
||||
if remote_connection_data.is_none() {
|
||||
anyhow::bail!("Cannot create terminal for remote project without connection data")
|
||||
}
|
||||
remote_connection_data
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// used only for TerminalSettings::get
|
||||
let worktree = {
|
||||
let terminal_cwd = working_directory.as_deref();
|
||||
let terminal_cwd = working_directory
|
||||
.as_ref()
|
||||
.and_then(|cwd| cwd.local_path().clone());
|
||||
let task_cwd = spawn_task
|
||||
.as_ref()
|
||||
.and_then(|spawn_task| spawn_task.cwd.as_deref());
|
||||
.and_then(|spawn_task| spawn_task.cwd.as_ref())
|
||||
.and_then(|cwd| cwd.local_path());
|
||||
|
||||
terminal_cwd
|
||||
.and_then(|terminal_cwd| self.find_local_worktree(terminal_cwd, cx))
|
||||
.or_else(|| task_cwd.and_then(|spawn_cwd| self.find_local_worktree(spawn_cwd, cx)))
|
||||
.and_then(|terminal_cwd| self.find_local_worktree(&terminal_cwd, cx))
|
||||
.or_else(|| task_cwd.and_then(|spawn_cwd| self.find_local_worktree(&spawn_cwd, cx)))
|
||||
};
|
||||
|
||||
let settings_location = worktree.as_ref().map(|(worktree, path)| SettingsLocation {
|
||||
|
@ -86,7 +90,8 @@ impl Project {
|
|||
path,
|
||||
});
|
||||
|
||||
let is_terminal = spawn_task.is_none() && remote_connection_data.is_none();
|
||||
let is_terminal = spawn_task.is_none() && (working_directory.as_ref().is_none())
|
||||
|| (working_directory.as_ref().unwrap().is_local());
|
||||
let settings = TerminalSettings::get(settings_location, cx);
|
||||
let python_settings = settings.detect_venv.clone();
|
||||
let (completion_tx, completion_rx) = bounded(1);
|
||||
|
@ -95,60 +100,138 @@ impl Project {
|
|||
// Alacritty uses parent project's working directory when no working directory is provided
|
||||
// https://github.com/alacritty/alacritty/blob/fd1a3cc79192d1d03839f0fd8c72e1f8d0fce42e/extra/man/alacritty.5.scd?plain=1#L47-L52
|
||||
|
||||
let mut retained_script = None;
|
||||
|
||||
let venv_base_directory = working_directory
|
||||
.as_deref()
|
||||
.unwrap_or_else(|| Path::new(""));
|
||||
.as_ref()
|
||||
.and_then(|cwd| cwd.local_path().map(|path| path.clone()))
|
||||
.unwrap_or_else(|| PathBuf::new())
|
||||
.clone();
|
||||
|
||||
let (spawn_task, shell) = if let Some(remote_connection_data) = remote_connection_data {
|
||||
log::debug!("Connecting to a remote server: {remote_connection_data:?}");
|
||||
// Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
|
||||
// to properly display colors.
|
||||
// We do not have the luxury of assuming the host has it installed,
|
||||
// so we set it to a default that does not break the highlighting via ssh.
|
||||
env.entry("TERM".to_string())
|
||||
.or_insert_with(|| "xterm-256color".to_string());
|
||||
let (spawn_task, shell) = match working_directory.as_ref() {
|
||||
Some(TerminalWorkDir::Ssh { ssh_command, path }) => {
|
||||
log::debug!("Connecting to a remote server: {ssh_command:?}");
|
||||
// Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
|
||||
// to properly display colors.
|
||||
// We do not have the luxury of assuming the host has it installed,
|
||||
// so we set it to a default that does not break the highlighting via ssh.
|
||||
env.entry("TERM".to_string())
|
||||
.or_insert_with(|| "xterm-256color".to_string());
|
||||
|
||||
(
|
||||
None,
|
||||
Shell::WithArguments {
|
||||
program: "ssh".to_string(),
|
||||
args: vec![
|
||||
remote_connection_data.ssh_connection_string.to_string(),
|
||||
"-t".to_string(),
|
||||
format!(
|
||||
"cd {} && exec $SHELL -l",
|
||||
escape_path_for_shell(remote_connection_data.project_path.as_ref())
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
} else if let Some(spawn_task) = spawn_task {
|
||||
log::debug!("Spawning task: {spawn_task:?}");
|
||||
env.extend(spawn_task.env);
|
||||
// Activate minimal Python virtual environment
|
||||
if let Some(python_settings) = &python_settings.as_option() {
|
||||
self.set_python_venv_path_for_tasks(python_settings, venv_base_directory, &mut env);
|
||||
let tmp_dir = tempfile::tempdir()?;
|
||||
let real_ssh = which::which("ssh")?;
|
||||
let ssh_path = tmp_dir.path().join("ssh");
|
||||
let mut ssh_file = File::create(ssh_path.clone())?;
|
||||
|
||||
let to_run = if let Some(spawn_task) = spawn_task.as_ref() {
|
||||
Some(shlex::try_quote(&spawn_task.command)?.to_string())
|
||||
.into_iter()
|
||||
.chain(spawn_task.args.iter().filter_map(|arg| {
|
||||
shlex::try_quote(arg).ok().map(|arg| arg.to_string())
|
||||
}))
|
||||
.collect::<Vec<String>>()
|
||||
.join(" ")
|
||||
} else {
|
||||
"exec $SHELL -l".to_string()
|
||||
};
|
||||
|
||||
let (port_forward, local_dev_env) =
|
||||
if env::var("ZED_RPC_URL") == Ok("http://localhost:8080/rpc".to_string()) {
|
||||
(
|
||||
"-R 8080:localhost:8080",
|
||||
"export ZED_RPC_URL=http://localhost:8080/rpc;",
|
||||
)
|
||||
} else {
|
||||
("", "")
|
||||
};
|
||||
|
||||
let commands = if let Some(path) = path {
|
||||
// I've found that `ssh -t dev sh -c 'cd; cd /tmp; pwd'` gives /tmp
|
||||
// but `ssh -t dev sh -c 'cd /tmp; pwd'` gives /root
|
||||
format!("cd {}; {} {}", path, local_dev_env, to_run)
|
||||
} else {
|
||||
format!("cd; {} {}", local_dev_env, to_run)
|
||||
};
|
||||
|
||||
let shell_invocation = &format!("sh -c {}", shlex::try_quote(&commands)?);
|
||||
|
||||
// To support things like `gh cs ssh`/`coder ssh`, we run whatever command
|
||||
// you have configured, but place our custom script on the path so that it will
|
||||
// be run instead.
|
||||
write!(
|
||||
&mut ssh_file,
|
||||
"#!/bin/sh\nexec {} \"$@\" {} {} {}",
|
||||
real_ssh.to_string_lossy(),
|
||||
if spawn_task.is_none() { "-t" } else { "" },
|
||||
port_forward,
|
||||
shlex::try_quote(shell_invocation)?,
|
||||
)?;
|
||||
// todo(windows)
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
std::fs::set_permissions(
|
||||
ssh_path,
|
||||
smol::fs::unix::PermissionsExt::from_mode(0o755),
|
||||
)?;
|
||||
let path = format!(
|
||||
"{}:{}",
|
||||
tmp_dir.path().to_string_lossy(),
|
||||
env.get("PATH")
|
||||
.cloned()
|
||||
.or(env::var("PATH").ok())
|
||||
.unwrap_or_default()
|
||||
);
|
||||
env.insert("PATH".to_string(), path);
|
||||
|
||||
let mut args = shlex::split(&ssh_command).unwrap_or_default();
|
||||
let program = args.drain(0..1).next().unwrap_or("ssh".to_string());
|
||||
|
||||
retained_script = Some(tmp_dir);
|
||||
(
|
||||
spawn_task.map(|spawn_task| TaskState {
|
||||
id: spawn_task.id,
|
||||
full_label: spawn_task.full_label,
|
||||
label: spawn_task.label,
|
||||
command_label: spawn_task.command_label,
|
||||
status: TaskStatus::Running,
|
||||
completion_rx,
|
||||
}),
|
||||
Shell::WithArguments { program, args },
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
if let Some(spawn_task) = spawn_task {
|
||||
log::debug!("Spawning task: {spawn_task:?}");
|
||||
env.extend(spawn_task.env);
|
||||
// Activate minimal Python virtual environment
|
||||
if let Some(python_settings) = &python_settings.as_option() {
|
||||
self.set_python_venv_path_for_tasks(
|
||||
python_settings,
|
||||
&venv_base_directory,
|
||||
&mut env,
|
||||
);
|
||||
}
|
||||
(
|
||||
Some(TaskState {
|
||||
id: spawn_task.id,
|
||||
full_label: spawn_task.full_label,
|
||||
label: spawn_task.label,
|
||||
command_label: spawn_task.command_label,
|
||||
status: TaskStatus::Running,
|
||||
completion_rx,
|
||||
}),
|
||||
Shell::WithArguments {
|
||||
program: spawn_task.command,
|
||||
args: spawn_task.args,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(None, settings.shell.clone())
|
||||
}
|
||||
}
|
||||
(
|
||||
Some(TaskState {
|
||||
id: spawn_task.id,
|
||||
full_label: spawn_task.full_label,
|
||||
label: spawn_task.label,
|
||||
command_label: spawn_task.command_label,
|
||||
status: TaskStatus::Running,
|
||||
completion_rx,
|
||||
}),
|
||||
Shell::WithArguments {
|
||||
program: spawn_task.command,
|
||||
args: spawn_task.args,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
(None, settings.shell.clone())
|
||||
};
|
||||
|
||||
let terminal = TerminalBuilder::new(
|
||||
working_directory.clone(),
|
||||
working_directory.and_then(|cwd| cwd.local_path()).clone(),
|
||||
spawn_task,
|
||||
shell,
|
||||
env,
|
||||
|
@ -167,6 +250,7 @@ impl Project {
|
|||
|
||||
let id = terminal_handle.entity_id();
|
||||
cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
|
||||
drop(retained_script);
|
||||
let handles = &mut project.terminals.local_handles;
|
||||
|
||||
if let Some(index) = handles
|
||||
|
@ -183,7 +267,7 @@ impl Project {
|
|||
if is_terminal {
|
||||
if let Some(python_settings) = &python_settings.as_option() {
|
||||
if let Some(activate_script_path) =
|
||||
self.find_activate_script_path(python_settings, venv_base_directory)
|
||||
self.find_activate_script_path(python_settings, &venv_base_directory)
|
||||
{
|
||||
self.activate_python_virtual_environment(
|
||||
Project::get_activate_command(python_settings),
|
||||
|
@ -291,39 +375,3 @@ impl Project {
|
|||
&self.terminals.local_handles
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn escape_path_for_shell(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.fold(String::with_capacity(input.len()), |mut s, c| {
|
||||
match c {
|
||||
' ' | '"' | '\'' | '\\' | '(' | ')' | '{' | '}' | '[' | ']' | '|' | ';' | '&'
|
||||
| '<' | '>' | '*' | '?' | '$' | '#' | '!' | '=' | '^' | '%' | ':' => {
|
||||
s.push('\\');
|
||||
s.push('\\');
|
||||
s.push(c);
|
||||
}
|
||||
_ => s.push(c),
|
||||
}
|
||||
s
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn escape_path_for_shell(input: &str) -> String {
|
||||
input
|
||||
.chars()
|
||||
.fold(String::with_capacity(input.len()), |mut s, c| {
|
||||
match c {
|
||||
'^' | '&' | '|' | '<' | '>' | ' ' | '(' | ')' | '@' | '`' | '=' | ';' | '%' => {
|
||||
s.push('^');
|
||||
s.push(c);
|
||||
}
|
||||
_ => s.push(c),
|
||||
}
|
||||
s
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Add a few tests for adding and removing terminal tabs
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue