diff --git a/crates/project/src/prettier_support.rs b/crates/project/src/prettier_support.rs index 62c6cf26d7..7aa688b0f8 100644 --- a/crates/project/src/prettier_support.rs +++ b/crates/project/src/prettier_support.rs @@ -515,7 +515,8 @@ impl Project { buffer: &Model, cx: &mut ModelContext, ) -> Task, PrettierTask)>> { - if !self.is_local() { + // todo(ssh remote): prettier support + if self.is_remote() || self.ssh_session.is_some() { return Task::ready(None); } let buffer = buffer.read(cx); diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 8109e35a00..73379e56a6 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1914,6 +1914,13 @@ impl Project { } } + pub fn is_ssh(&self) -> bool { + match &self.client_state { + ProjectClientState::Local | ProjectClientState::Shared { .. } => true, + ProjectClientState::Remote { .. } => false, + } + } + pub fn is_remote(&self) -> bool { !self.is_local() } @@ -7687,11 +7694,7 @@ impl Project { ) -> Option<(Model, PathBuf)> { self.worktree_store.read_with(cx, |worktree_store, cx| { for tree in worktree_store.worktrees() { - if let Some(relative_path) = tree - .read(cx) - .as_local() - .and_then(|t| abs_path.strip_prefix(t.abs_path()).ok()) - { + if let Ok(relative_path) = abs_path.strip_prefix(tree.read(cx).abs_path()) { return Some((tree.clone(), relative_path.into())); } } diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index 2ca152b482..2d64a25b2f 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -1,21 +1,18 @@ use crate::Project; use anyhow::Context as _; use collections::HashMap; -use gpui::{ - AnyWindowHandle, AppContext, Context, Entity, Model, ModelContext, SharedString, WeakModel, -}; +use gpui::{AnyWindowHandle, AppContext, Context, Entity, Model, ModelContext, WeakModel}; use itertools::Itertools; use settings::{Settings, SettingsLocation}; use smol::channel::bounded; use std::{ - env, - fs::File, - io::Write, + env::{self}, + iter, path::{Path, PathBuf}, }; -use task::{Shell, SpawnInTerminal, TerminalWorkDir}; +use task::{Shell, SpawnInTerminal}; use terminal::{ - terminal_settings::{self, TerminalSettings, VenvSettingsContent}, + terminal_settings::{self, TerminalSettings}, TaskState, TaskStatus, Terminal, TerminalBuilder, }; use util::ResultExt; @@ -27,21 +24,53 @@ pub struct Terminals { pub(crate) local_handles: Vec>, } -#[derive(Debug, Clone)] -pub struct ConnectRemoteTerminal { - pub ssh_connection_string: SharedString, - pub project_path: SharedString, +/// Terminals are opened either for the users shell, or to run a task. +#[allow(clippy::large_enum_variant)] +#[derive(Debug)] +pub enum TerminalKind { + /// Run a shell at the given path (or $HOME if None) + Shell(Option), + /// Run a task. + Task(SpawnInTerminal), +} + +/// SshCommand describes how to connect to a remote server +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SshCommand { + /// DevServers give a string from the user + DevServer(String), + /// Direct ssh has a list of arguments to pass to ssh + Direct(Vec), } impl Project { - pub fn terminal_work_dir_for( - &self, - pathbuf: Option<&Path>, - cx: &AppContext, - ) -> Option { - if self.is_local() { - return Some(TerminalWorkDir::Local(pathbuf?.to_owned())); + pub fn active_project_directory(&self, cx: &AppContext) -> Option { + let worktree = self + .active_entry() + .and_then(|entry_id| self.worktree_for_entry(entry_id, cx)) + .or_else(|| self.worktrees(cx).next())?; + let worktree = worktree.read(cx); + if !worktree.root_entry()?.is_dir() { + return None; } + Some(worktree.abs_path().to_path_buf()) + } + + pub fn first_project_directory(&self, cx: &AppContext) -> Option { + let worktree = self.worktrees(cx).next()?; + let worktree = worktree.read(cx); + if worktree.root_entry()?.is_dir() { + return Some(worktree.abs_path().to_path_buf()); + } else { + None + } + } + + fn ssh_command(&self, cx: &AppContext) -> Option { + if let Some(ssh_session) = self.ssh_session.as_ref() { + return Some(SshCommand::Direct(ssh_session.ssh_args())); + } + 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 @@ -49,133 +78,132 @@ impl Project { .ssh_connection_string .as_ref()? .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)? - .paths - .get(0) - .unwrap() - .to_string() - }; - - Some(TerminalWorkDir::Ssh { - ssh_command, - path: Some(path), - }) + Some(SshCommand::DevServer(ssh_command)) } pub fn create_terminal( &mut self, - working_directory: Option, - spawn_task: Option, + kind: TerminalKind, window: AnyWindowHandle, cx: &mut ModelContext, ) -> anyhow::Result> { - // used only for TerminalSettings::get - let worktree = { - let terminal_cwd = working_directory.as_ref().and_then(|cwd| cwd.local_path()); - let task_cwd = spawn_task - .as_ref() - .and_then(|spawn_task| spawn_task.cwd.as_ref()) - .and_then(|cwd| cwd.local_path()); - - terminal_cwd - .and_then(|terminal_cwd| self.find_worktree(&terminal_cwd, cx)) - .or_else(|| task_cwd.and_then(|spawn_cwd| self.find_worktree(&spawn_cwd, cx))) + let path = match &kind { + TerminalKind::Shell(path) => path.as_ref().map(|path| path.to_path_buf()), + TerminalKind::Task(spawn_task) => { + if let Some(cwd) = &spawn_task.cwd { + Some(cwd.clone()) + } else { + self.active_project_directory(cx) + } + } }; + let ssh_command = self.ssh_command(cx); - let settings_location = worktree.as_ref().map(|(worktree, path)| SettingsLocation { - worktree_id: worktree.read(cx).id().to_usize(), - path, - }); - - let is_terminal = spawn_task.is_none() - && working_directory - .as_ref() - .map_or(true, |work_dir| work_dir.is_local()); + let mut settings_location = None; + if let Some(path) = path.as_ref() { + if let Some((worktree, _)) = self.find_worktree(path, cx) { + settings_location = Some(SettingsLocation { + worktree_id: worktree.read(cx).id().to_usize(), + path, + }); + } + } let settings = TerminalSettings::get(settings_location, cx); - let python_settings = settings.detect_venv.clone(); + let (completion_tx, completion_rx) = bounded(1); let mut env = settings.env.clone(); - // 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 + let local_path = if ssh_command.is_none() { + path.clone() + } else { + None + }; + let python_venv_directory = path .as_ref() - .and_then(|cwd| cwd.local_path()) - .unwrap_or_else(|| Path::new("")); + .and_then(|path| self.python_venv_directory(path, settings, cx)); + let mut python_venv_activate_command = None; - let (spawn_task, shell) = match working_directory.as_ref() { - Some(TerminalWorkDir::Ssh { ssh_command, path }) => { - log::debug!("Connecting to a remote server: {ssh_command:?}"); - let tmp_dir = tempfile::tempdir()?; - let ssh_shell_result = prepare_ssh_shell( - &mut env, - tmp_dir.path(), - spawn_task.as_ref(), - ssh_command, - path.as_deref(), - ); - retained_script = Some(tmp_dir); - let ssh_shell = ssh_shell_result?; + let (spawn_task, shell) = match kind { + TerminalKind::Shell(_) => { + if let Some(python_venv_directory) = python_venv_directory { + python_venv_activate_command = + self.python_activate_command(&python_venv_directory, settings); + } - ( - 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, - hide: spawn_task.hide, - status: TaskStatus::Running, - completion_rx, - }), - ssh_shell, - ) - } - _ => { - 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, - ); + match &ssh_command { + Some(ssh_command) => { + 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()); + + let (program, args) = + wrap_for_ssh(ssh_command, None, path.as_deref(), env, None); + env = HashMap::default(); + (None, Shell::WithArguments { program, args }) + } + None => (None, settings.shell.clone()), + } + } + TerminalKind::Task(spawn_task) => { + let task_state = Some(TaskState { + id: spawn_task.id, + full_label: spawn_task.full_label, + label: spawn_task.label, + command_label: spawn_task.command_label, + hide: spawn_task.hide, + status: TaskStatus::Running, + completion_rx, + }); + + env.extend(spawn_task.env); + + if let Some(venv_path) = &python_venv_directory { + env.insert( + "VIRTUAL_ENV".to_string(), + venv_path.to_string_lossy().to_string(), + ); + } + + match &ssh_command { + Some(ssh_command) => { + log::debug!("Connecting to a remote server: {ssh_command:?}"); + env.entry("TERM".to_string()) + .or_insert_with(|| "xterm-256color".to_string()); + let (program, args) = wrap_for_ssh( + ssh_command, + Some((&spawn_task.command, &spawn_task.args)), + path.as_deref(), + env, + python_venv_directory, + ); + env = HashMap::default(); + (task_state, Shell::WithArguments { program, args }) + } + None => { + if let Some(venv_path) = &python_venv_directory { + add_environment_path(&mut env, &venv_path.join("bin")).log_err(); + } + + ( + task_state, + Shell::WithArguments { + program: spawn_task.command, + args: spawn_task.args, + }, + ) } - ( - Some(TaskState { - id: spawn_task.id, - full_label: spawn_task.full_label, - label: spawn_task.label, - command_label: spawn_task.command_label, - hide: spawn_task.hide, - 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 - .as_ref() - .and_then(|cwd| cwd.local_path()) - .map(ToOwned::to_owned), + local_path, spawn_task, shell, env, @@ -195,7 +223,6 @@ 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 @@ -208,20 +235,8 @@ impl Project { }) .detach(); - // if the terminal is not a task, activate full Python virtual environment - 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.activate_python_virtual_environment( - Project::get_activate_command(python_settings), - activate_script_path, - &terminal_handle, - cx, - ); - } - } + if let Some(activate_command) = python_venv_activate_command { + self.activate_python_virtual_environment(activate_command, &terminal_handle, cx); } terminal_handle }); @@ -229,80 +244,58 @@ impl Project { terminal } - pub fn find_activate_script_path( - &mut self, - settings: &VenvSettingsContent, - venv_base_directory: &Path, + pub fn python_venv_directory( + &self, + abs_path: &Path, + settings: &TerminalSettings, + cx: &AppContext, ) -> Option { - let activate_script_name = match settings.activate_script { + let venv_settings = settings.detect_venv.as_option()?; + venv_settings + .directories + .into_iter() + .map(|virtual_environment_name| abs_path.join(virtual_environment_name)) + .find(|venv_path| { + self.find_worktree(&venv_path, cx) + .and_then(|(worktree, relative_path)| { + worktree.read(cx).entry_for_path(&relative_path) + }) + .is_some() + }) + } + + fn python_activate_command( + &self, + venv_base_directory: &Path, + settings: &TerminalSettings, + ) -> Option { + let venv_settings = settings.detect_venv.as_option()?; + let activate_script_name = match venv_settings.activate_script { terminal_settings::ActivateScript::Default => "activate", terminal_settings::ActivateScript::Csh => "activate.csh", terminal_settings::ActivateScript::Fish => "activate.fish", terminal_settings::ActivateScript::Nushell => "activate.nu", }; + let path = venv_base_directory + .join("bin") + .join(activate_script_name) + .to_string_lossy() + .to_string(); + let quoted = shlex::try_quote(&path).ok()?; - settings - .directories - .into_iter() - .find_map(|virtual_environment_name| { - let path = venv_base_directory - .join(virtual_environment_name) - .join("bin") - .join(activate_script_name); - path.exists().then_some(path) - }) - } - - pub fn set_python_venv_path_for_tasks( - &mut self, - settings: &VenvSettingsContent, - venv_base_directory: &Path, - env: &mut HashMap, - ) { - let activate_path = settings - .directories - .into_iter() - .find_map(|virtual_environment_name| { - let path = venv_base_directory.join(virtual_environment_name); - path.exists().then_some(path) - }); - - if let Some(path) = activate_path { - // Some tools use VIRTUAL_ENV to detect the virtual environment - env.insert( - "VIRTUAL_ENV".to_string(), - path.to_string_lossy().to_string(), - ); - - // We need to set the PATH to include the virtual environment's bin directory - add_environment_path(env, &path.join("bin")).log_err(); - } - } - - fn get_activate_command(settings: &VenvSettingsContent) -> &'static str { - match settings.activate_script { - terminal_settings::ActivateScript::Nushell => "overlay use", - _ => "source", - } + Some(match venv_settings.activate_script { + terminal_settings::ActivateScript::Nushell => format!("overlay use {}\n", quoted), + _ => format!("source {}\n", quoted), + }) } fn activate_python_virtual_environment( - &mut self, - activate_command: &'static str, - activate_script: PathBuf, + &self, + command: String, terminal_handle: &Model, cx: &mut ModelContext, ) { - // Paths are not strings so we need to jump through some hoops to format the command without `format!` - let mut command = Vec::from(activate_command.as_bytes()); - command.push(b' '); - // Wrapping path in double quotes to catch spaces in folder name - command.extend_from_slice(b"\""); - command.extend_from_slice(activate_script.as_os_str().as_encoded_bytes()); - command.extend_from_slice(b"\""); - command.push(b'\n'); - - terminal_handle.update(cx, |this, _| this.input_bytes(command)); + terminal_handle.update(cx, |this, _| this.input_bytes(command.into_bytes())); } pub fn local_terminal_handles(&self) -> &Vec> { @@ -310,65 +303,55 @@ impl Project { } } -fn prepare_ssh_shell( - env: &mut HashMap, - tmp_dir: &Path, - spawn_task: Option<&SpawnInTerminal>, - ssh_command: &str, - path: Option<&str>, -) -> anyhow::Result { - // 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 real_ssh = which::which("ssh")?; - let ssh_path = tmp_dir.join("ssh"); - let mut ssh_file = File::create(&ssh_path)?; - - let to_run = if let Some(spawn_task) = spawn_task { - Some(shlex::try_quote(&spawn_task.command)?) - .into_iter() - .chain( - spawn_task - .args - .iter() - .filter_map(|arg| shlex::try_quote(arg).ok()), - ) +pub fn wrap_for_ssh( + ssh_command: &SshCommand, + command: Option<(&String, &Vec)>, + path: Option<&Path>, + env: HashMap, + venv_directory: Option, +) -> (String, Vec) { + let to_run = if let Some((command, args)) = command { + iter::once(command) + .chain(args) + .filter_map(|arg| shlex::try_quote(arg).ok()) .join(" ") } else { - "exec $SHELL -l".to_string() + "exec ${SHELL:-sh} -l".to_string() }; + let mut env_changes = String::new(); + for (k, v) in env.iter() { + if let Some((k, v)) = shlex::try_quote(k).ok().zip(shlex::try_quote(v).ok()) { + env_changes.push_str(&format!("{}={} ", k, v)); + } + } + if let Some(venv_directory) = venv_directory { + if let Some(str) = shlex::try_quote(venv_directory.to_string_lossy().as_ref()).ok() { + env_changes.push_str(&format!("PATH={}:$PATH ", str)); + } + } + let commands = if let Some(path) = path { - format!("cd {path}; {to_run}") + format!("cd {:?}; {} {}", path, env_changes, to_run) } else { - format!("cd; {to_run}") + format!("cd; {env_changes} {to_run}") }; - let shell_invocation = &format!("sh -c {}", shlex::try_quote(&commands)?); + let shell_invocation = format!("sh -c {}", shlex::try_quote(&commands).unwrap()); - // 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 { "" }, - shlex::try_quote(shell_invocation)?, - )?; + let (program, mut args) = match ssh_command { + SshCommand::DevServer(ssh_command) => { + let mut args = shlex::split(&ssh_command).unwrap_or_default(); + let program = args.drain(0..1).next().unwrap_or("ssh".to_string()); + (program, args) + } + SshCommand::Direct(ssh_args) => ("ssh".to_string(), ssh_args.clone()), + }; - // todo(windows) - #[cfg(not(target_os = "windows"))] - std::fs::set_permissions(ssh_path, smol::fs::unix::PermissionsExt::from_mode(0o755))?; - - add_environment_path(env, tmp_dir)?; - - let mut args = shlex::split(&ssh_command).unwrap_or_default(); - let program = args.drain(0..1).next().unwrap_or("ssh".to_string()); - Ok(Shell::WithArguments { program, args }) + if command.is_none() { + args.push("-t".to_string()) + } + args.push(shell_invocation); + (program, args) } fn add_environment_path(env: &mut HashMap, new_path: &Path) -> anyhow::Result<()> { diff --git a/crates/recent_projects/src/dev_servers.rs b/crates/recent_projects/src/dev_servers.rs index 195a55fab0..4de3073195 100644 --- a/crates/recent_projects/src/dev_servers.rs +++ b/crates/recent_projects/src/dev_servers.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; @@ -18,6 +19,8 @@ use gpui::{ }; use markdown::Markdown; use markdown::MarkdownStyle; +use project::terminals::wrap_for_ssh; +use project::terminals::SshCommand; use rpc::proto::RegenerateDevServerTokenResponse; use rpc::{ proto::{CreateDevServerResponse, DevServerStatus}, @@ -28,7 +31,6 @@ use settings::Settings; use task::HideStrategy; use task::RevealStrategy; use task::SpawnInTerminal; -use task::TerminalWorkDir; use terminal_view::terminal_panel::TerminalPanel; use ui::ElevationIndex; use ui::Section; @@ -1638,6 +1640,13 @@ pub async fn spawn_ssh_task( ]; let ssh_connection_string = ssh_connection_string.to_string(); + let (command, args) = wrap_for_ssh( + &SshCommand::DevServer(ssh_connection_string.clone()), + Some((&command, &args)), + None, + HashMap::default(), + None, + ); let terminal = terminal_panel .update(cx, |terminal_panel, cx| { @@ -1649,10 +1658,7 @@ pub async fn spawn_ssh_task( command, args, command_label: ssh_connection_string.clone(), - cwd: Some(TerminalWorkDir::Ssh { - ssh_command: ssh_connection_string, - path: None, - }), + cwd: None, use_new_terminal: true, allow_concurrent_runs: false, reveal: RevealStrategy::Always, diff --git a/crates/remote/src/ssh_session.rs b/crates/remote/src/ssh_session.rs index 87fa662bb6..42d71d9639 100644 --- a/crates/remote/src/ssh_session.rs +++ b/crates/remote/src/ssh_session.rs @@ -36,11 +36,18 @@ use std::{ }; use tempfile::TempDir; +#[derive(Clone)] +pub struct SshSocket { + connection_options: SshConnectionOptions, + socket_path: PathBuf, +} + pub struct SshSession { next_message_id: AtomicU32, response_channels: ResponseChannels, outgoing_tx: mpsc::UnboundedSender, spawn_process_tx: mpsc::UnboundedSender, + client_socket: Option, message_handlers: Mutex< HashMap< TypeId, @@ -58,8 +65,7 @@ pub struct SshSession { } struct SshClientState { - connection_options: SshConnectionOptions, - socket_path: PathBuf, + socket: SshSocket, _master_process: process::Child, _temp_dir: TempDir, } @@ -162,9 +168,10 @@ impl SshSession { let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded::(); let (incoming_tx, incoming_rx) = mpsc::unbounded::(); - run_cmd(client_state.ssh_command(&remote_binary_path).arg("version")).await?; + let socket = client_state.socket.clone(); + run_cmd(socket.ssh_command(&remote_binary_path).arg("version")).await?; - let mut remote_server_child = client_state + let mut remote_server_child = socket .ssh_command(&format!( "RUST_LOG={} {:?} run", std::env::var("RUST_LOG").unwrap_or(String::new()), @@ -202,7 +209,7 @@ impl SshSession { }; log::info!("spawn process: {:?}", request.command); - let child = client_state + let child = client_state.socket .ssh_command(&request.command) .spawn() .context("failed to create channel")?; @@ -268,7 +275,7 @@ impl SshSession { } }).detach(); - cx.update(|cx| Self::new(incoming_rx, outgoing_tx, spawn_process_tx, cx)) + cx.update(|cx| Self::new(incoming_rx, outgoing_tx, spawn_process_tx, Some(socket), cx)) } pub fn server( @@ -277,7 +284,7 @@ impl SshSession { cx: &AppContext, ) -> Arc { let (tx, _rx) = mpsc::unbounded(); - Self::new(incoming_rx, outgoing_tx, tx, cx) + Self::new(incoming_rx, outgoing_tx, tx, None, cx) } #[cfg(any(test, feature = "test-support"))] @@ -289,10 +296,24 @@ impl SshSession { let (client_to_server_tx, client_to_server_rx) = mpsc::unbounded(); let (tx, _rx) = mpsc::unbounded(); ( - client_cx - .update(|cx| Self::new(server_to_client_rx, client_to_server_tx, tx.clone(), cx)), - server_cx - .update(|cx| Self::new(client_to_server_rx, server_to_client_tx, tx.clone(), cx)), + client_cx.update(|cx| { + Self::new( + server_to_client_rx, + client_to_server_tx, + tx.clone(), + None, // todo() + cx, + ) + }), + server_cx.update(|cx| { + Self::new( + client_to_server_rx, + server_to_client_tx, + tx.clone(), + None, + cx, + ) + }), ) } @@ -300,6 +321,7 @@ impl SshSession { mut incoming_rx: mpsc::UnboundedReceiver, outgoing_tx: mpsc::UnboundedSender, spawn_process_tx: mpsc::UnboundedSender, + client_socket: Option, cx: &AppContext, ) -> Arc { let this = Arc::new(Self { @@ -307,6 +329,7 @@ impl SshSession { response_channels: ResponseChannels::default(), outgoing_tx, spawn_process_tx, + client_socket, message_handlers: Default::default(), }); @@ -400,6 +423,10 @@ impl SshSession { process_rx.await.unwrap() } + pub fn ssh_args(&self) -> Vec { + self.client_socket.as_ref().unwrap().ssh_args() + } + pub fn add_message_handler(&self, entity: WeakModel, handler: H) where M: EnvelopedMessage, @@ -559,8 +586,10 @@ impl SshClientState { } Ok(Self { - connection_options, - socket_path, + socket: SshSocket { + connection_options, + socket_path, + }, _master_process: master_process, _temp_dir: temp_dir, }) @@ -578,12 +607,13 @@ impl SshClientState { dst_path_gz.set_extension("gz"); if let Some(parent) = dst_path.parent() { - run_cmd(self.ssh_command("mkdir").arg("-p").arg(parent)).await?; + run_cmd(self.socket.ssh_command("mkdir").arg("-p").arg(parent)).await?; } let mut server_binary_exists = false; if cfg!(not(debug_assertions)) { - if let Ok(installed_version) = run_cmd(self.ssh_command(&dst_path).arg("version")).await + if let Ok(installed_version) = + run_cmd(self.socket.ssh_command(&dst_path).arg("version")).await { if installed_version.trim() == version.to_string() { server_binary_exists = true; @@ -609,11 +639,18 @@ impl SshClientState { log::info!("uploaded remote development server in {:?}", t0.elapsed()); delegate.set_status(Some("extracting remote development server"), cx); - run_cmd(self.ssh_command("gunzip").arg("--force").arg(&dst_path_gz)).await?; + run_cmd( + self.socket + .ssh_command("gunzip") + .arg("--force") + .arg(&dst_path_gz), + ) + .await?; delegate.set_status(Some("unzipping remote development server"), cx); run_cmd( - self.ssh_command("chmod") + self.socket + .ssh_command("chmod") .arg(format!("{:o}", server_mode)) .arg(&dst_path), ) @@ -623,8 +660,8 @@ impl SshClientState { } async fn query_platform(&self) -> Result { - let os = run_cmd(self.ssh_command("uname").arg("-s")).await?; - let arch = run_cmd(self.ssh_command("uname").arg("-m")).await?; + let os = run_cmd(self.socket.ssh_command("uname").arg("-s")).await?; + let arch = run_cmd(self.socket.ssh_command("uname").arg("-m")).await?; let os = match os.trim() { "Darwin" => "macos", @@ -645,9 +682,11 @@ impl SshClientState { async fn upload_file(&self, src_path: &Path, dest_path: &Path) -> Result<()> { let mut command = process::Command::new("scp"); let output = self + .socket .ssh_options(&mut command) .args( - self.connection_options + self.socket + .connection_options .port .map(|port| vec!["-P".to_string(), port.to_string()]) .unwrap_or_default(), @@ -655,7 +694,7 @@ impl SshClientState { .arg(&src_path) .arg(&format!( "{}:{}", - self.connection_options.scp_url(), + self.socket.connection_options.scp_url(), dest_path.display() )) .output() @@ -672,7 +711,9 @@ impl SshClientState { )) } } +} +impl SshSocket { fn ssh_command>(&self, program: S) -> process::Command { let mut command = process::Command::new("ssh"); self.ssh_options(&mut command) @@ -689,6 +730,16 @@ impl SshClientState { .args(["-o", "ControlMaster=no", "-o"]) .arg(format!("ControlPath={}", self.socket_path.display())) } + + fn ssh_args(&self) -> Vec { + vec![ + "-o".to_string(), + "ControlMaster=no".to_string(), + "-o".to_string(), + format!("ControlPath={}", self.socket_path.display()), + self.connection_options.ssh_url(), + ] + } } async fn run_cmd(command: &mut process::Command) -> Result { diff --git a/crates/task/src/lib.rs b/crates/task/src/lib.rs index 5e1a9309d9..8321518e03 100644 --- a/crates/task/src/lib.rs +++ b/crates/task/src/lib.rs @@ -9,9 +9,9 @@ use collections::{hash_map, HashMap, HashSet}; use gpui::SharedString; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use std::borrow::Cow; use std::path::PathBuf; use std::str::FromStr; -use std::{borrow::Cow, path::Path}; pub use task_template::{HideStrategy, RevealStrategy, TaskTemplate, TaskTemplates}; pub use vscode_format::VsCodeTaskFile; @@ -21,38 +21,6 @@ pub use vscode_format::VsCodeTaskFile; #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Deserialize)] pub struct TaskId(pub String); -/// TerminalWorkDir describes where a task should be run -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TerminalWorkDir { - /// Local is on this machine - Local(PathBuf), - /// SSH runs the terminal over ssh - Ssh { - /// The command to run to connect - ssh_command: String, - /// The path on the remote server - path: Option, - }, -} - -impl TerminalWorkDir { - /// Returns whether the terminal task is supposed to be spawned on a local machine or not. - pub fn is_local(&self) -> bool { - match self { - Self::Local(_) => true, - Self::Ssh { .. } => false, - } - } - - /// Returns a local CWD if the terminal is local, None otherwise. - pub fn local_path(&self) -> Option<&Path> { - match self { - Self::Local(path) => Some(path), - Self::Ssh { .. } => None, - } - } -} - /// Contains all information needed by Zed to spawn a new terminal tab for the given task. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SpawnInTerminal { @@ -70,7 +38,7 @@ pub struct SpawnInTerminal { /// A human-readable label, containing command and all of its arguments, joined and substituted. pub command_label: String, /// Current working directory to spawn the command into. - pub cwd: Option, + pub cwd: Option, /// Env overrides for the command, will be appended to the terminal's environment from the settings. pub env: HashMap, /// Whether to use a new terminal tab or reuse the existing one to spawn the process. @@ -265,7 +233,7 @@ impl IntoIterator for TaskVariables { /// Keeps track of the file associated with a task and context of tasks execution (i.e. current file or current function). /// Keeps all Zed-related state inside, used to produce a resolved task out of its template. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct TaskContext { /// A path to a directory in which the task should be executed. pub cwd: Option, diff --git a/crates/task/src/task_template.rs b/crates/task/src/task_template.rs index 55f13f6bca..5daebe853f 100644 --- a/crates/task/src/task_template.rs +++ b/crates/task/src/task_template.rs @@ -8,7 +8,7 @@ use sha2::{Digest, Sha256}; use util::{truncate_and_remove_front, ResultExt}; use crate::{ - ResolvedTask, Shell, SpawnInTerminal, TaskContext, TaskId, TerminalWorkDir, VariableName, + ResolvedTask, Shell, SpawnInTerminal, TaskContext, TaskId, VariableName, ZED_VARIABLE_NAME_PREFIX, }; @@ -134,14 +134,11 @@ impl TaskTemplate { &variable_names, &mut substituted_variables, )?; - Some(TerminalWorkDir::Local(PathBuf::from(substitured_cwd))) + Some(PathBuf::from(substitured_cwd)) } None => None, } - .or(cx - .cwd - .as_ref() - .map(|cwd| TerminalWorkDir::Local(cwd.clone()))); + .or(cx.cwd.clone()); let human_readable_label = substitute_all_template_variables_in_str( &self.label, &truncated_variables, @@ -421,11 +418,8 @@ mod tests { project_env: HashMap::default(), }; assert_eq!( - resolved_task(&task_without_cwd, &cx) - .cwd - .as_ref() - .and_then(|cwd| cwd.local_path()), - Some(context_cwd.as_path()), + resolved_task(&task_without_cwd, &cx).cwd, + Some(context_cwd.clone()), "TaskContext's cwd should be taken on resolve if task's cwd is None" ); @@ -440,11 +434,8 @@ mod tests { project_env: HashMap::default(), }; assert_eq!( - resolved_task(&task_with_cwd, &cx) - .cwd - .as_ref() - .and_then(|cwd| cwd.local_path()), - Some(task_cwd.as_path()), + resolved_task(&task_with_cwd, &cx).cwd, + Some(task_cwd.clone()), "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is None" ); @@ -454,11 +445,8 @@ mod tests { project_env: HashMap::default(), }; assert_eq!( - resolved_task(&task_with_cwd, &cx) - .cwd - .as_ref() - .and_then(|cwd| cwd.local_path()), - Some(task_cwd.as_path()), + resolved_task(&task_with_cwd, &cx).cwd, + Some(task_cwd), "TaskTemplate's cwd should be taken on resolve if TaskContext's cwd is not None" ); } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 5b34477d54..62196d89e3 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1,6 +1,6 @@ use std::{ops::ControlFlow, path::PathBuf, sync::Arc}; -use crate::TerminalView; +use crate::{default_working_directory, TerminalView}; use collections::{HashMap, HashSet}; use db::kvp::KEY_VALUE_STORE; use futures::future::join_all; @@ -10,11 +10,11 @@ use gpui::{ Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext, }; use itertools::Itertools; -use project::{Fs, ProjectEntryId}; +use project::{terminals::TerminalKind, Fs, ProjectEntryId}; use search::{buffer_search::DivRegistrar, BufferSearchBar}; use serde::{Deserialize, Serialize}; use settings::Settings; -use task::{RevealStrategy, Shell, SpawnInTerminal, TaskId, TerminalWorkDir}; +use task::{RevealStrategy, Shell, SpawnInTerminal, TaskId}; use terminal::{ terminal_settings::{TerminalDockPosition, TerminalSettings}, Terminal, @@ -348,14 +348,13 @@ impl TerminalPanel { return; }; - let terminal_work_dir = workspace - .project() - .read(cx) - .terminal_work_dir_for(Some(&action.working_directory), cx); - terminal_panel .update(cx, |panel, cx| { - panel.add_terminal(terminal_work_dir, None, RevealStrategy::Always, cx) + panel.add_terminal( + TerminalKind::Shell(Some(action.working_directory.clone())), + RevealStrategy::Always, + cx, + ) }) .detach_and_log_err(cx); } @@ -484,7 +483,7 @@ impl TerminalPanel { cx: &mut ViewContext, ) -> Task>> { let reveal = spawn_task.reveal; - self.add_terminal(spawn_task.cwd.clone(), Some(spawn_task), reveal, cx) + self.add_terminal(TerminalKind::Task(spawn_task), reveal, cx) } /// Create a new Terminal in the current working directory or the user's home directory @@ -497,9 +496,11 @@ impl TerminalPanel { return; }; + let kind = TerminalKind::Shell(default_working_directory(workspace, cx)); + terminal_panel .update(cx, |this, cx| { - this.add_terminal(None, None, RevealStrategy::Always, cx) + this.add_terminal(kind, RevealStrategy::Always, cx) }) .detach_and_log_err(cx); } @@ -533,22 +534,14 @@ impl TerminalPanel { fn add_terminal( &mut self, - working_directory: Option, - spawn_task: Option, + kind: TerminalKind, reveal_strategy: RevealStrategy, cx: &mut ViewContext, ) -> Task>> { if !self.enabled { - if spawn_task.is_none() - || !matches!( - spawn_task.as_ref().unwrap().cwd, - Some(TerminalWorkDir::Ssh { .. }) - ) - { - return Task::ready(Err(anyhow::anyhow!( - "terminal not yet supported for remote projects" - ))); - } + return Task::ready(Err(anyhow::anyhow!( + "terminal not yet supported for remote projects" + ))); } let workspace = self.workspace.clone(); @@ -557,18 +550,10 @@ impl TerminalPanel { cx.spawn(|terminal_panel, mut cx| async move { let pane = terminal_panel.update(&mut cx, |this, _| this.pane.clone())?; let result = workspace.update(&mut cx, |workspace, cx| { - let working_directory = if let Some(working_directory) = working_directory { - Some(working_directory) - } else { - let working_directory_strategy = - TerminalSettings::get_global(cx).working_directory.clone(); - crate::get_working_directory(workspace, cx, working_directory_strategy) - }; - let window = cx.window_handle(); - let terminal = workspace.project().update(cx, |project, cx| { - project.create_terminal(working_directory, spawn_task, window, cx) - })?; + let terminal = workspace + .project() + .update(cx, |project, cx| project.create_terminal(kind, window, cx))?; let terminal_view = Box::new(cx.new_view(|cx| { TerminalView::new( terminal.clone(), @@ -655,7 +640,7 @@ impl TerminalPanel { let window = cx.window_handle(); let new_terminal = project.update(cx, |project, cx| { project - .create_terminal(spawn_task.cwd.clone(), Some(spawn_task), window, cx) + .create_terminal(TerminalKind::Task(spawn_task), window, cx) .log_err() })?; terminal_to_replace.update(cx, |terminal_to_replace, cx| { @@ -802,10 +787,19 @@ impl Panel for TerminalPanel { } fn set_active(&mut self, active: bool, cx: &mut ViewContext) { - if active && self.has_no_terminals(cx) { - self.add_terminal(None, None, RevealStrategy::Never, cx) - .detach_and_log_err(cx) + if !active || !self.has_no_terminals(cx) { + return; } + cx.defer(|this, cx| { + let Ok(kind) = this.workspace.update(cx, |workspace, cx| { + TerminalKind::Shell(default_working_directory(workspace, cx)) + }) else { + return; + }; + + this.add_terminal(kind, RevealStrategy::Never, cx) + .detach_and_log_err(cx) + }) } fn icon_label(&self, cx: &WindowContext) -> Option { diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 804bc96dc7..e01585c4cc 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -13,8 +13,7 @@ use gpui::{ }; use language::Bias; use persistence::TERMINAL_DB; -use project::{search::SearchQuery, Fs, LocalWorktree, Metadata, Project}; -use task::TerminalWorkDir; +use project::{search::SearchQuery, terminals::TerminalKind, Fs, Metadata, Project}; use terminal::{ alacritty_terminal::{ index::Point, @@ -38,7 +37,6 @@ use workspace::{ }; use anyhow::Context; -use dirs::home_dir; use serde::Deserialize; use settings::{Settings, SettingsStore}; use smol::Timer; @@ -130,15 +128,13 @@ impl TerminalView { _: &NewCenterTerminal, cx: &mut ViewContext, ) { - let strategy = TerminalSettings::get_global(cx); - let working_directory = - get_working_directory(workspace, cx, strategy.working_directory.clone()); + let working_directory = default_working_directory(workspace, cx); let window = cx.window_handle(); let terminal = workspace .project() .update(cx, |project, cx| { - project.create_terminal(working_directory, None, window, cx) + project.create_terminal(TerminalKind::Shell(working_directory), window, cx) }) .notify_err(workspace, cx); @@ -1134,21 +1130,18 @@ impl SerializableItem for TerminalView { .as_ref() .is_some_and(|from_db| !from_db.as_os_str().is_empty()) { - project - .read(cx) - .terminal_work_dir_for(from_db.as_deref(), cx) + from_db } else { - let strategy = TerminalSettings::get_global(cx).working_directory.clone(); - workspace.upgrade().and_then(|workspace| { - get_working_directory(workspace.read(cx), cx, strategy) - }) + workspace + .upgrade() + .and_then(|workspace| default_working_directory(workspace.read(cx), cx)) } }) .ok() .flatten(); let terminal = project.update(&mut cx, |project, cx| { - project.create_terminal(cwd, None, window, cx) + project.create_terminal(TerminalKind::Shell(cwd), window, cx) })??; pane.update(&mut cx, |_, cx| { cx.new_view(|cx| TerminalView::new(terminal, workspace, Some(workspace_id), cx)) @@ -1276,59 +1269,29 @@ impl SearchableItem for TerminalView { } ///Gets the working directory for the given workspace, respecting the user's settings. -pub fn get_working_directory( - workspace: &Workspace, - cx: &AppContext, - strategy: WorkingDirectory, -) -> Option { - if workspace.project().read(cx).is_local() { - let res = match strategy { - WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx) - .or_else(|| first_project_directory(workspace, cx)), - WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx), - WorkingDirectory::AlwaysHome => None, - WorkingDirectory::Always { directory } => { - shellexpand::full(&directory) //TODO handle this better - .ok() - .map(|dir| Path::new(&dir.to_string()).to_path_buf()) - .filter(|dir| dir.is_dir()) - } - }; - res.or_else(home_dir).map(|cwd| TerminalWorkDir::Local(cwd)) - } else { - workspace.project().read(cx).terminal_work_dir_for(None, cx) +/// None implies "~" on whichever machine we end up on. +pub fn default_working_directory(workspace: &Workspace, cx: &AppContext) -> Option { + match &TerminalSettings::get_global(cx).working_directory { + WorkingDirectory::CurrentProjectDirectory => { + workspace.project().read(cx).active_project_directory(cx) + } + WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx), + WorkingDirectory::AlwaysHome => None, + WorkingDirectory::Always { directory } => { + shellexpand::full(&directory) //TODO handle this better + .ok() + .map(|dir| Path::new(&dir.to_string()).to_path_buf()) + .filter(|dir| dir.is_dir()) + } } } - ///Gets the first project's home directory, or the home directory fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option { - workspace - .worktrees(cx) - .next() - .and_then(|worktree_handle| worktree_handle.read(cx).as_local()) - .and_then(get_path_from_wt) -} - -///Gets the intuitively correct working directory from the given workspace -///If there is an active entry for this project, returns that entry's worktree root. -///If there's no active entry but there is a worktree, returns that worktrees root. -///If either of these roots are files, or if there are any other query failures, -/// returns the user's home directory -fn current_project_directory(workspace: &Workspace, cx: &AppContext) -> Option { - let project = workspace.project().read(cx); - - project - .active_entry() - .and_then(|entry_id| project.worktree_for_entry(entry_id, cx)) - .or_else(|| workspace.worktrees(cx).next()) - .and_then(|worktree_handle| worktree_handle.read(cx).as_local()) - .and_then(get_path_from_wt) -} - -fn get_path_from_wt(wt: &LocalWorktree) -> Option { - wt.root_entry() - .filter(|re| re.is_dir()) - .map(|_| wt.abs_path().to_path_buf()) + let worktree = workspace.worktrees(cx).next()?.read(cx); + if !worktree.root_entry()?.is_dir() { + return None; + } + Some(worktree.abs_path().to_path_buf()) } #[cfg(test)] @@ -1353,7 +1316,7 @@ mod tests { assert!(active_entry.is_none()); assert!(workspace.worktrees(cx).next().is_none()); - let res = current_project_directory(workspace, cx); + let res = default_working_directory(workspace, cx); assert_eq!(res, None); let res = first_project_directory(workspace, cx); assert_eq!(res, None); @@ -1374,7 +1337,7 @@ mod tests { assert!(active_entry.is_none()); assert!(workspace.worktrees(cx).next().is_some()); - let res = current_project_directory(workspace, cx); + let res = default_working_directory(workspace, cx); assert_eq!(res, None); let res = first_project_directory(workspace, cx); assert_eq!(res, None); @@ -1394,7 +1357,7 @@ mod tests { assert!(active_entry.is_none()); assert!(workspace.worktrees(cx).next().is_some()); - let res = current_project_directory(workspace, cx); + let res = default_working_directory(workspace, cx); assert_eq!(res, Some((Path::new("/root/")).to_path_buf())); let res = first_project_directory(workspace, cx); assert_eq!(res, Some((Path::new("/root/")).to_path_buf())); @@ -1416,7 +1379,7 @@ mod tests { assert!(active_entry.is_some()); - let res = current_project_directory(workspace, cx); + let res = default_working_directory(workspace, cx); assert_eq!(res, None); let res = first_project_directory(workspace, cx); assert_eq!(res, Some((Path::new("/root1/")).to_path_buf())); @@ -1438,7 +1401,7 @@ mod tests { assert!(active_entry.is_some()); - let res = current_project_directory(workspace, cx); + let res = default_working_directory(workspace, cx); assert_eq!(res, Some((Path::new("/root2/")).to_path_buf())); let res = first_project_directory(workspace, cx); assert_eq!(res, Some((Path::new("/root1/")).to_path_buf())); @@ -1449,6 +1412,7 @@ mod tests { pub async fn init_test(cx: &mut TestAppContext) -> (Model, View) { let params = cx.update(AppState::test); cx.update(|cx| { + terminal::init(cx); theme::init(theme::LoadThemes::JustBase, cx); Project::init_settings(cx); language::init(cx); diff --git a/docs/src/remote-development.md b/docs/src/remote-development.md index 7010c7e219..7624bd5aa2 100644 --- a/docs/src/remote-development.md +++ b/docs/src/remote-development.md @@ -35,17 +35,9 @@ Once a connection is established, Zed will be downloaded and installed to `~/.lo If you don't see any output from the Zed command, it is likely that Zed is crashing on startup. You can troubleshoot this by switching to manual mode and passing the `--foreground` flag. Please [file a bug](https://github.com/zed-industries/zed) so we can debug it together. -### SSH-like connections +If you are trying to connect to a platform like GitHub Codespaces or Google Cloud, you may want to first make sure that your SSH configuration is set up correctly. Once you can `ssh X` to connect to the machine, then Zed will be able to connect. -Zed intercepts `ssh` in a way that should make it possible to intercept connections made by most "ssh wrappers". For example you -can specify: - -- `user@host` will assume you meant `ssh user@host` -- `ssh -J jump target` to connect via a jump-host -- `gh cs ssh -c example-codespace` to connect to a GitHub codespace -- `doctl compute ssh example-droplet` to connect to a DigitalOcean Droplet -- `gcloud compute ssh` for a Google Cloud instance -- `ssh -i path_to_key_file user@host` to connect to a host using a key file or certificate +> **Note:** In an earlier version of remoting, we supported typing in `gh cs ssh` or `gcloud compute ssh` directly. This is no longer supported. Instead you should make sure your SSH configuration is up to date with `gcloud compute ssh --config` or `gh cs ssh --config`, or use Manual setup mode if you cannot ssh directly to the machine. ### zed --dev-server-token isn't connecting