use anyhow::Result; use collections::HashMap; use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity}; use itertools::Itertools as _; use language::LanguageName; use remote::RemoteClient; use settings::{Settings, SettingsLocation}; use smol::channel::bounded; use std::{ borrow::Cow, path::{Path, PathBuf}, sync::Arc, }; use task::{Shell, ShellBuilder, ShellKind, SpawnInTerminal}; use terminal::{ TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::TerminalSettings, }; use util::{get_default_system_shell, maybe, rel_path::RelPath}; use crate::{Project, ProjectPath}; pub struct Terminals { pub(crate) local_handles: Vec>, } impl Project { pub fn active_project_directory(&self, cx: &App) -> Option> { self.active_entry() .and_then(|entry_id| self.worktree_for_entry(entry_id, cx)) .into_iter() .chain(self.worktrees(cx)) .find_map(|tree| tree.read(cx).root_dir()) } pub fn first_project_directory(&self, cx: &App) -> Option { let worktree = self.worktrees(cx).next()?; let worktree = worktree.read(cx); if worktree.root_entry()?.is_dir() { Some(worktree.abs_path().to_path_buf()) } else { None } } pub fn create_terminal_task( &mut self, spawn_task: SpawnInTerminal, cx: &mut Context, ) -> Task>> { let is_via_remote = self.remote_client.is_some(); let path: Option> = if let Some(cwd) = &spawn_task.cwd { if is_via_remote { Some(Arc::from(cwd.as_ref())) } else { let cwd = cwd.to_string_lossy(); let tilde_substituted = shellexpand::tilde(&cwd); Some(Arc::from(Path::new(tilde_substituted.as_ref()))) } } else { self.active_project_directory(cx) }; let mut settings_location = None; if let Some(path) = path.as_ref() && let Some((worktree, _)) = self.find_worktree(path, cx) { settings_location = Some(SettingsLocation { worktree_id: worktree.read(cx).id(), path: RelPath::empty(), }); } let settings = TerminalSettings::get(settings_location, cx).clone(); let detect_venv = settings.detect_venv.as_option().is_some(); let (completion_tx, completion_rx) = bounded(1); // Start with the environment that we might have inherited from the Zed CLI. let mut env = self .environment .read(cx) .get_cli_environment() .unwrap_or_default(); // Then extend it with the explicit env variables from the settings, so they take // precedence. env.extend(settings.env); let local_path = if is_via_remote { None } else { path.clone() }; let task_state = Some(TaskState { spawned_task: spawn_task.clone(), status: TaskStatus::Running, completion_rx, }); let remote_client = self.remote_client.clone(); let shell = match &remote_client { Some(remote_client) => remote_client .read(cx) .shell() .unwrap_or_else(get_default_system_shell), None => settings.shell.program(), }; let project_path_contexts = self .active_entry() .and_then(|entry_id| self.path_for_entry(entry_id, cx)) .into_iter() .chain( self.visible_worktrees(cx) .map(|wt| wt.read(cx).id()) .map(|worktree_id| ProjectPath { worktree_id, path: Arc::from(RelPath::empty()), }), ); let toolchains = project_path_contexts .filter(|_| detect_venv) .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx)) .collect::>(); let lang_registry = self.languages.clone(); let fs = self.fs.clone(); cx.spawn(async move |project, cx| { let shell_kind = ShellKind::new(&shell); let activation_script = maybe!(async { for toolchain in toolchains { let Some(toolchain) = toolchain.await else { continue; }; let language = lang_registry .language_for_name(&toolchain.language_name.0) .await .ok(); let lister = language?.toolchain_lister(); return Some( lister? .activation_script(&toolchain, shell_kind, fs.as_ref()) .await, ); } None }) .await .unwrap_or_default(); project.update(cx, move |this, cx| { let format_to_run = || { if let Some(command) = &spawn_task.command { let mut command: Option> = shlex::try_quote(command).ok(); if let Some(command) = &mut command && command.starts_with('"') && let Some(prefix) = shell_kind.command_prefix() { *command = Cow::Owned(format!("{prefix}{command}")); } let args = spawn_task .args .iter() .filter_map(|arg| shlex::try_quote(arg).ok()); command.into_iter().chain(args).join(" ") } else { // todo: this breaks for remotes to windows format!("exec {shell} -l") } }; let (shell, env) = { env.extend(spawn_task.env); match remote_client { Some(remote_client) => match activation_script.clone() { activation_script if !activation_script.is_empty() => { let activation_script = activation_script.join("; "); let to_run = format_to_run(); let args = vec!["-c".to_owned(), format!("{activation_script}; {to_run}")]; create_remote_shell( Some(( &remote_client .read(cx) .shell() .unwrap_or_else(get_default_system_shell), &args, )), env, path, remote_client, cx, )? } _ => create_remote_shell( spawn_task .command .as_ref() .map(|command| (command, &spawn_task.args)), env, path, remote_client, cx, )?, }, None => match activation_script.clone() { activation_script if !activation_script.is_empty() => { let activation_script = activation_script.join("; "); let to_run = format_to_run(); let arg = format!("{activation_script}; {to_run}"); ( Shell::WithArguments { program: shell, args: vec!["-c".to_owned(), arg], title_override: None, }, env, ) } _ => ( if let Some(program) = spawn_task.command { Shell::WithArguments { program, args: spawn_task.args, title_override: None, } } else { Shell::System }, env, ), }, } }; TerminalBuilder::new( local_path.map(|path| path.to_path_buf()), task_state, shell, env, settings.cursor_shape.unwrap_or_default(), settings.alternate_scroll, settings.max_scroll_history_lines, is_via_remote, cx.entity_id().as_u64(), Some(completion_tx), cx, activation_script, ) .map(|builder| { let terminal_handle = cx.new(|cx| builder.subscribe(cx)); this.terminals .local_handles .push(terminal_handle.downgrade()); let id = terminal_handle.entity_id(); cx.observe_release(&terminal_handle, move |project, _terminal, cx| { let handles = &mut project.terminals.local_handles; if let Some(index) = handles .iter() .position(|terminal| terminal.entity_id() == id) { handles.remove(index); cx.notify(); } }) .detach(); terminal_handle }) })? }) } pub fn create_terminal_shell( &mut self, cwd: Option, cx: &mut Context, ) -> Task>> { let path = cwd.map(|p| Arc::from(&*p)); let is_via_remote = self.remote_client.is_some(); let mut settings_location = None; if let Some(path) = path.as_ref() && let Some((worktree, _)) = self.find_worktree(path, cx) { settings_location = Some(SettingsLocation { worktree_id: worktree.read(cx).id(), path: RelPath::empty(), }); } let settings = TerminalSettings::get(settings_location, cx).clone(); let detect_venv = settings.detect_venv.as_option().is_some(); // Start with the environment that we might have inherited from the Zed CLI. let mut env = self .environment .read(cx) .get_cli_environment() .unwrap_or_default(); // Then extend it with the explicit env variables from the settings, so they take // precedence. env.extend(settings.env); let local_path = if is_via_remote { None } else { path.clone() }; let project_path_contexts = self .active_entry() .and_then(|entry_id| self.path_for_entry(entry_id, cx)) .into_iter() .chain( self.visible_worktrees(cx) .map(|wt| wt.read(cx).id()) .map(|worktree_id| ProjectPath { worktree_id, path: RelPath::empty().into(), }), ); let toolchains = project_path_contexts .filter(|_| detect_venv) .map(|p| self.active_toolchain(p, LanguageName::new("Python"), cx)) .collect::>(); let remote_client = self.remote_client.clone(); let shell_kind = ShellKind::new(&match &remote_client { Some(remote_client) => remote_client .read(cx) .shell() .unwrap_or_else(get_default_system_shell), None => settings.shell.program(), }); let lang_registry = self.languages.clone(); let fs = self.fs.clone(); cx.spawn(async move |project, cx| { let activation_script = maybe!(async { for toolchain in toolchains { let Some(toolchain) = toolchain.await else { continue; }; let language = lang_registry .language_for_name(&toolchain.language_name.0) .await .ok(); let lister = language?.toolchain_lister(); return Some( lister? .activation_script(&toolchain, shell_kind, fs.as_ref()) .await, ); } None }) .await .unwrap_or_default(); project.update(cx, move |this, cx| { let (shell, env) = { match remote_client { Some(remote_client) => { create_remote_shell(None, env, path, remote_client, cx)? } None => (settings.shell, env), } }; TerminalBuilder::new( local_path.map(|path| path.to_path_buf()), None, shell, env, settings.cursor_shape.unwrap_or_default(), settings.alternate_scroll, settings.max_scroll_history_lines, is_via_remote, cx.entity_id().as_u64(), None, cx, activation_script, ) .map(|builder| { let terminal_handle = cx.new(|cx| builder.subscribe(cx)); this.terminals .local_handles .push(terminal_handle.downgrade()); let id = terminal_handle.entity_id(); cx.observe_release(&terminal_handle, move |project, _terminal, cx| { let handles = &mut project.terminals.local_handles; if let Some(index) = handles .iter() .position(|terminal| terminal.entity_id() == id) { handles.remove(index); cx.notify(); } }) .detach(); terminal_handle }) })? }) } pub fn clone_terminal( &mut self, terminal: &Entity, cx: &mut Context<'_, Project>, cwd: Option, ) -> Result> { let local_path = if self.is_via_remote_server() { None } else { cwd }; terminal .read(cx) .clone_builder(cx, local_path) .map(|builder| { let terminal_handle = cx.new(|cx| builder.subscribe(cx)); self.terminals .local_handles .push(terminal_handle.downgrade()); let id = terminal_handle.entity_id(); cx.observe_release(&terminal_handle, move |project, _terminal, cx| { let handles = &mut project.terminals.local_handles; if let Some(index) = handles .iter() .position(|terminal| terminal.entity_id() == id) { handles.remove(index); cx.notify(); } }) .detach(); terminal_handle }) } pub fn terminal_settings<'a>( &'a self, path: &'a Option, cx: &'a App, ) -> &'a TerminalSettings { let mut settings_location = None; if let Some(path) = path.as_ref() && let Some((worktree, _)) = self.find_worktree(path, cx) { settings_location = Some(SettingsLocation { worktree_id: worktree.read(cx).id(), path: RelPath::empty(), }); } TerminalSettings::get(settings_location, cx) } pub fn exec_in_shell(&self, command: String, cx: &App) -> Result { let path = self.first_project_directory(cx); let remote_client = self.remote_client.as_ref(); let settings = self.terminal_settings(&path, cx).clone(); let shell = remote_client .as_ref() .and_then(|remote_client| remote_client.read(cx).shell()) .map(Shell::Program) .unwrap_or_else(|| settings.shell.clone()); let builder = ShellBuilder::new(&shell).non_interactive(); let (command, args) = builder.build(Some(command), &Vec::new()); let mut env = self .environment .read(cx) .get_cli_environment() .unwrap_or_default(); env.extend(settings.env); match remote_client { Some(remote_client) => { let command_template = remote_client .read(cx) .build_command(Some(command), &args, &env, None, None)?; let mut command = std::process::Command::new(command_template.program); command.args(command_template.args); command.envs(command_template.env); Ok(command) } None => { let mut command = std::process::Command::new(command); command.args(args); command.envs(env); if let Some(path) = path { command.current_dir(path); } Ok(command) } } .map(|mut process| { util::set_pre_exec_to_start_new_session(&mut process); smol::process::Command::from(process) }) } pub fn local_terminal_handles(&self) -> &Vec> { &self.terminals.local_handles } } fn create_remote_shell( spawn_command: Option<(&String, &Vec)>, mut env: HashMap, working_directory: Option>, remote_client: Entity, cx: &mut App, ) -> Result<(Shell, HashMap)> { // 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) = match spawn_command { Some((program, args)) => (Some(program.clone()), args), None => (None, &Vec::new()), }; let command = remote_client.read(cx).build_command( program, args.as_slice(), &env, working_directory.map(|path| path.display().to_string()), None, )?; log::debug!("Connecting to a remote server: {:?}", command.program); let host = remote_client.read(cx).connection_options().display_name(); Ok(( Shell::WithArguments { program: command.program, args: command.args, title_override: Some(format!("{} — Terminal", host).into()), }, command.env, )) }