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