1use crate::Project;
2use anyhow::Context as _;
3use collections::HashMap;
4use gpui::{
5 AnyWindowHandle, AppContext, Context, Entity, Model, ModelContext, SharedString, WeakModel,
6};
7use itertools::Itertools;
8use settings::{Settings, SettingsLocation};
9use smol::channel::bounded;
10use std::{
11 env,
12 fs::File,
13 io::Write,
14 path::{Path, PathBuf},
15};
16use task::{Shell, SpawnInTerminal, TerminalWorkDir};
17use terminal::{
18 terminal_settings::{self, TerminalSettings, VenvSettingsContent},
19 TaskState, TaskStatus, Terminal, TerminalBuilder,
20};
21use util::ResultExt;
22
23// #[cfg(target_os = "macos")]
24// use std::os::unix::ffi::OsStrExt;
25
26pub struct Terminals {
27 pub(crate) local_handles: Vec<WeakModel<terminal::Terminal>>,
28}
29
30#[derive(Debug, Clone)]
31pub struct ConnectRemoteTerminal {
32 pub ssh_connection_string: SharedString,
33 pub project_path: SharedString,
34}
35
36impl Project {
37 pub fn terminal_work_dir_for(
38 &self,
39 pathbuf: Option<&Path>,
40 cx: &AppContext,
41 ) -> Option<TerminalWorkDir> {
42 if self.is_local() {
43 return Some(TerminalWorkDir::Local(pathbuf?.to_owned()));
44 }
45 let dev_server_project_id = self.dev_server_project_id()?;
46 let projects_store = dev_server_projects::Store::global(cx).read(cx);
47 let ssh_command = projects_store
48 .dev_server_for_project(dev_server_project_id)?
49 .ssh_connection_string
50 .as_ref()?
51 .to_string();
52
53 let path = if let Some(pathbuf) = pathbuf {
54 pathbuf.to_string_lossy().to_string()
55 } else {
56 projects_store
57 .dev_server_project(dev_server_project_id)?
58 .paths
59 .get(0)
60 .unwrap()
61 .to_string()
62 };
63
64 Some(TerminalWorkDir::Ssh {
65 ssh_command,
66 path: Some(path),
67 })
68 }
69
70 pub fn create_terminal(
71 &mut self,
72 working_directory: Option<TerminalWorkDir>,
73 spawn_task: Option<SpawnInTerminal>,
74 window: AnyWindowHandle,
75 cx: &mut ModelContext<Self>,
76 ) -> anyhow::Result<Model<Terminal>> {
77 // used only for TerminalSettings::get
78 let worktree = {
79 let terminal_cwd = working_directory.as_ref().and_then(|cwd| cwd.local_path());
80 let task_cwd = spawn_task
81 .as_ref()
82 .and_then(|spawn_task| spawn_task.cwd.as_ref())
83 .and_then(|cwd| cwd.local_path());
84
85 terminal_cwd
86 .and_then(|terminal_cwd| self.find_worktree(&terminal_cwd, cx))
87 .or_else(|| task_cwd.and_then(|spawn_cwd| self.find_worktree(&spawn_cwd, cx)))
88 };
89
90 let settings_location = worktree.as_ref().map(|(worktree, path)| SettingsLocation {
91 worktree_id: worktree.read(cx).id().to_usize(),
92 path,
93 });
94
95 let is_terminal = spawn_task.is_none()
96 && working_directory
97 .as_ref()
98 .map_or(true, |work_dir| work_dir.is_local());
99 let settings = TerminalSettings::get(settings_location, cx);
100 let python_settings = settings.detect_venv.clone();
101 let (completion_tx, completion_rx) = bounded(1);
102
103 let mut env = settings.env.clone();
104 // Alacritty uses parent project's working directory when no working directory is provided
105 // https://github.com/alacritty/alacritty/blob/fd1a3cc79192d1d03839f0fd8c72e1f8d0fce42e/extra/man/alacritty.5.scd?plain=1#L47-L52
106
107 let mut retained_script = None;
108
109 let venv_base_directory = working_directory
110 .as_ref()
111 .and_then(|cwd| cwd.local_path())
112 .unwrap_or_else(|| Path::new(""));
113
114 let (spawn_task, shell) = match working_directory.as_ref() {
115 Some(TerminalWorkDir::Ssh { ssh_command, path }) => {
116 log::debug!("Connecting to a remote server: {ssh_command:?}");
117 let tmp_dir = tempfile::tempdir()?;
118 let ssh_shell_result = prepare_ssh_shell(
119 &mut env,
120 tmp_dir.path(),
121 spawn_task.as_ref(),
122 ssh_command,
123 path.as_deref(),
124 );
125 retained_script = Some(tmp_dir);
126 let ssh_shell = ssh_shell_result?;
127
128 (
129 spawn_task.map(|spawn_task| TaskState {
130 id: spawn_task.id,
131 full_label: spawn_task.full_label,
132 label: spawn_task.label,
133 command_label: spawn_task.command_label,
134 hide: spawn_task.hide,
135 status: TaskStatus::Running,
136 completion_rx,
137 }),
138 ssh_shell,
139 )
140 }
141 _ => {
142 if let Some(spawn_task) = spawn_task {
143 log::debug!("Spawning task: {spawn_task:?}");
144 env.extend(spawn_task.env);
145 // Activate minimal Python virtual environment
146 if let Some(python_settings) = &python_settings.as_option() {
147 self.set_python_venv_path_for_tasks(
148 python_settings,
149 &venv_base_directory,
150 &mut env,
151 );
152 }
153 (
154 Some(TaskState {
155 id: spawn_task.id,
156 full_label: spawn_task.full_label,
157 label: spawn_task.label,
158 command_label: spawn_task.command_label,
159 hide: spawn_task.hide,
160 status: TaskStatus::Running,
161 completion_rx,
162 }),
163 Shell::WithArguments {
164 program: spawn_task.command,
165 args: spawn_task.args,
166 },
167 )
168 } else {
169 (None, settings.shell.clone())
170 }
171 }
172 };
173
174 let terminal = TerminalBuilder::new(
175 working_directory
176 .as_ref()
177 .and_then(|cwd| cwd.local_path())
178 .map(ToOwned::to_owned),
179 spawn_task,
180 shell,
181 env,
182 Some(settings.blinking),
183 settings.alternate_scroll,
184 settings.max_scroll_history_lines,
185 window,
186 completion_tx,
187 cx,
188 )
189 .map(|builder| {
190 let terminal_handle = cx.new_model(|cx| builder.subscribe(cx));
191
192 self.terminals
193 .local_handles
194 .push(terminal_handle.downgrade());
195
196 let id = terminal_handle.entity_id();
197 cx.observe_release(&terminal_handle, move |project, _terminal, cx| {
198 drop(retained_script);
199 let handles = &mut project.terminals.local_handles;
200
201 if let Some(index) = handles
202 .iter()
203 .position(|terminal| terminal.entity_id() == id)
204 {
205 handles.remove(index);
206 cx.notify();
207 }
208 })
209 .detach();
210
211 // if the terminal is not a task, activate full Python virtual environment
212 if is_terminal {
213 if let Some(python_settings) = &python_settings.as_option() {
214 if let Some(activate_script_path) =
215 self.find_activate_script_path(python_settings, &venv_base_directory)
216 {
217 self.activate_python_virtual_environment(
218 Project::get_activate_command(python_settings),
219 activate_script_path,
220 &terminal_handle,
221 cx,
222 );
223 }
224 }
225 }
226 terminal_handle
227 });
228
229 terminal
230 }
231
232 pub fn find_activate_script_path(
233 &mut self,
234 settings: &VenvSettingsContent,
235 venv_base_directory: &Path,
236 ) -> Option<PathBuf> {
237 let activate_script_name = match settings.activate_script {
238 terminal_settings::ActivateScript::Default => "activate",
239 terminal_settings::ActivateScript::Csh => "activate.csh",
240 terminal_settings::ActivateScript::Fish => "activate.fish",
241 terminal_settings::ActivateScript::Nushell => "activate.nu",
242 };
243
244 settings
245 .directories
246 .into_iter()
247 .find_map(|virtual_environment_name| {
248 let path = venv_base_directory
249 .join(virtual_environment_name)
250 .join("bin")
251 .join(activate_script_name);
252 path.exists().then_some(path)
253 })
254 }
255
256 pub fn set_python_venv_path_for_tasks(
257 &mut self,
258 settings: &VenvSettingsContent,
259 venv_base_directory: &Path,
260 env: &mut HashMap<String, String>,
261 ) {
262 let activate_path = settings
263 .directories
264 .into_iter()
265 .find_map(|virtual_environment_name| {
266 let path = venv_base_directory.join(virtual_environment_name);
267 path.exists().then_some(path)
268 });
269
270 if let Some(path) = activate_path {
271 // Some tools use VIRTUAL_ENV to detect the virtual environment
272 env.insert(
273 "VIRTUAL_ENV".to_string(),
274 path.to_string_lossy().to_string(),
275 );
276
277 // We need to set the PATH to include the virtual environment's bin directory
278 add_environment_path(env, &path.join("bin")).log_err();
279 }
280 }
281
282 fn get_activate_command(settings: &VenvSettingsContent) -> &'static str {
283 match settings.activate_script {
284 terminal_settings::ActivateScript::Nushell => "overlay use",
285 _ => "source",
286 }
287 }
288
289 fn activate_python_virtual_environment(
290 &mut self,
291 activate_command: &'static str,
292 activate_script: PathBuf,
293 terminal_handle: &Model<Terminal>,
294 cx: &mut ModelContext<Project>,
295 ) {
296 // Paths are not strings so we need to jump through some hoops to format the command without `format!`
297 let mut command = Vec::from(activate_command.as_bytes());
298 command.push(b' ');
299 // Wrapping path in double quotes to catch spaces in folder name
300 command.extend_from_slice(b"\"");
301 command.extend_from_slice(activate_script.as_os_str().as_encoded_bytes());
302 command.extend_from_slice(b"\"");
303 command.push(b'\n');
304
305 terminal_handle.update(cx, |this, _| this.input_bytes(command));
306 }
307
308 pub fn local_terminal_handles(&self) -> &Vec<WeakModel<terminal::Terminal>> {
309 &self.terminals.local_handles
310 }
311}
312
313fn prepare_ssh_shell(
314 env: &mut HashMap<String, String>,
315 tmp_dir: &Path,
316 spawn_task: Option<&SpawnInTerminal>,
317 ssh_command: &str,
318 path: Option<&str>,
319) -> anyhow::Result<Shell> {
320 // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
321 // to properly display colors.
322 // We do not have the luxury of assuming the host has it installed,
323 // so we set it to a default that does not break the highlighting via ssh.
324 env.entry("TERM".to_string())
325 .or_insert_with(|| "xterm-256color".to_string());
326
327 let real_ssh = which::which("ssh")?;
328 let ssh_path = tmp_dir.join("ssh");
329 let mut ssh_file = File::create(&ssh_path)?;
330
331 let to_run = if let Some(spawn_task) = spawn_task {
332 Some(shlex::try_quote(&spawn_task.command)?)
333 .into_iter()
334 .chain(
335 spawn_task
336 .args
337 .iter()
338 .filter_map(|arg| shlex::try_quote(arg).ok()),
339 )
340 .join(" ")
341 } else {
342 "exec $SHELL -l".to_string()
343 };
344
345 let commands = if let Some(path) = path {
346 format!("cd {path}; {to_run}")
347 } else {
348 format!("cd; {to_run}")
349 };
350 let shell_invocation = &format!("sh -c {}", shlex::try_quote(&commands)?);
351
352 // To support things like `gh cs ssh`/`coder ssh`, we run whatever command
353 // you have configured, but place our custom script on the path so that it will
354 // be run instead.
355 write!(
356 &mut ssh_file,
357 "#!/bin/sh\nexec {} \"$@\" {} {}",
358 real_ssh.to_string_lossy(),
359 if spawn_task.is_none() { "-t" } else { "" },
360 shlex::try_quote(shell_invocation)?,
361 )?;
362
363 // todo(windows)
364 #[cfg(not(target_os = "windows"))]
365 std::fs::set_permissions(ssh_path, smol::fs::unix::PermissionsExt::from_mode(0o755))?;
366
367 add_environment_path(env, tmp_dir)?;
368
369 let mut args = shlex::split(&ssh_command).unwrap_or_default();
370 let program = args.drain(0..1).next().unwrap_or("ssh".to_string());
371 Ok(Shell::WithArguments { program, args })
372}
373
374fn add_environment_path(env: &mut HashMap<String, String>, new_path: &Path) -> anyhow::Result<()> {
375 let mut env_paths = vec![new_path.to_path_buf()];
376 if let Some(path) = env.get("PATH").or(env::var("PATH").ok().as_ref()) {
377 let mut paths = std::env::split_paths(&path).collect::<Vec<_>>();
378 env_paths.append(&mut paths);
379 }
380
381 let paths = std::env::join_paths(env_paths).context("failed to create PATH env variable")?;
382 env.insert("PATH".to_string(), paths.to_string_lossy().to_string());
383
384 Ok(())
385}
386
387#[cfg(test)]
388mod tests {
389 use collections::HashMap;
390
391 #[test]
392 fn test_add_environment_path_with_existing_path() {
393 let tmp_path = std::path::PathBuf::from("/tmp/new");
394 let mut env = HashMap::default();
395 let old_path = if cfg!(windows) {
396 "/usr/bin;/usr/local/bin"
397 } else {
398 "/usr/bin:/usr/local/bin"
399 };
400 env.insert("PATH".to_string(), old_path.to_string());
401 env.insert("OTHER".to_string(), "aaa".to_string());
402
403 super::add_environment_path(&mut env, &tmp_path).unwrap();
404 if cfg!(windows) {
405 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", old_path));
406 } else {
407 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", old_path));
408 }
409 assert_eq!(env.get("OTHER").unwrap(), "aaa");
410 }
411
412 #[test]
413 fn test_add_environment_path_with_empty_path() {
414 let tmp_path = std::path::PathBuf::from("/tmp/new");
415 let mut env = HashMap::default();
416 env.insert("OTHER".to_string(), "aaa".to_string());
417 let os_path = std::env::var("PATH").unwrap();
418 super::add_environment_path(&mut env, &tmp_path).unwrap();
419 if cfg!(windows) {
420 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new;{}", os_path));
421 } else {
422 assert_eq!(env.get("PATH").unwrap(), &format!("/tmp/new:{}", os_path));
423 }
424 assert_eq!(env.get("OTHER").unwrap(), "aaa");
425 }
426}